Prepare ms-private 0.0.1 release candidate

This commit is contained in:
0x000 committed 2026-09-22 21:21:41 -04:00
1 parent 7aacc0b63f
commit a515538438
75 files changed
+3957 -3484

No files matched your search

+84
View File
@@ -0,0 +1,84 @@
Set-StrictMode -Version Latest
$ErrorActionPreference='Stop'
Import-Module (Join-Path $PSScriptRoot 'MsPrivate.IO.psm1') -ErrorAction Stop
function Get-MsPrivateAccountCatalog {
param([string]$Path=(Join-Path $PSScriptRoot '../config/accounts.json'))
$catalog=Get-Content -LiteralPath $Path -Raw|ConvertFrom-Json
if($catalog.Schema-ne1){throw 'Unsupported account catalog schema.'}
if(-not@($catalog.Packages).Count-or-not@($catalog.CredentialTargetPatterns).Count){throw 'Account catalog must contain packages and credential patterns.'}
$ids=@{};$names=@{};foreach($item in @($catalog.Packages)){
$id=[string]$item.Id;$name=[string]$item.Name
if($id-notmatch'^[a-z0-9][a-z0-9-]*$'-or$ids.ContainsKey($id)){throw "Invalid or duplicate account package ID: $id"}
if($name-notmatch'^[A-Za-z0-9.]+$'-or$names.ContainsKey($name)){throw "Invalid or duplicate account package: $name"}
if($item.Scope-notin@('Consumer','AllAppAccounts')){throw "Invalid account package scope: $id"}
$ids[$id]=$true;$names[$name]=$true
}
$patterns=@{};foreach($pattern in @($catalog.CredentialTargetPatterns)){
$value=[string]$pattern;if([string]::IsNullOrWhiteSpace($value)-or$patterns.ContainsKey($value)){throw 'Credential target patterns must be nonempty and unique.'}
try{[void][regex]::new($value)}catch{throw "Invalid credential target pattern: $value"};$patterns[$value]=$true
}
$catalog
}
function Initialize-MsPrivateCredentialNative {
if('MsPrivate.CredentialNative'-as[type]){return}
Add-Type -TypeDefinition @'
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.InteropServices;
namespace MsPrivate {
public sealed class CredentialItem { public string TargetName; public uint Type; }
public static class CredentialNative {
[StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)] struct CREDENTIAL { public uint Flags; public uint Type; public IntPtr TargetName; public IntPtr Comment; public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten; public uint CredentialBlobSize; public IntPtr CredentialBlob; public uint Persist; public uint AttributeCount; public IntPtr Attributes; public IntPtr TargetAlias; public IntPtr UserName; }
[DllImport("advapi32.dll",EntryPoint="CredEnumerateW",CharSet=CharSet.Unicode,SetLastError=true)] static extern bool CredEnumerate(string filter,uint flags,out uint count,out IntPtr credentials);
[DllImport("advapi32.dll",EntryPoint="CredDeleteW",CharSet=CharSet.Unicode,SetLastError=true)] static extern bool CredDelete(string target,uint type,uint flags);
[DllImport("advapi32.dll")] static extern void CredFree(IntPtr buffer);
public static CredentialItem[] Enumerate(){uint count;IntPtr array;if(!CredEnumerate(null,0,out count,out array)){int e=Marshal.GetLastWin32Error();if(e==1168)return new CredentialItem[0];throw new Win32Exception(e);}try{var result=new List<CredentialItem>();for(int i=0;i<count;i++){IntPtr p=Marshal.ReadIntPtr(array,i*IntPtr.Size);var c=(CREDENTIAL)Marshal.PtrToStructure(p,typeof(CREDENTIAL));result.Add(new CredentialItem{TargetName=Marshal.PtrToStringUni(c.TargetName),Type=c.Type});}return result.ToArray();}finally{CredFree(array);}}
public static void Delete(string target,uint type){if(!CredDelete(target,type,0))throw new Win32Exception(Marshal.GetLastWin32Error());}
}
}
'@
}
function New-MsPrivateAccountBackend {
@{
ListPackages={@(Get-AppxPackage -ErrorAction Stop|ForEach-Object{[pscustomobject]@{Name=$_.Name;PackageFullName=$_.PackageFullName}})}
ResetPackage={param($fullName)if(-not(Get-Command Reset-AppxPackage -ErrorAction SilentlyContinue)){throw 'Reset-AppxPackage is unavailable on this Windows build.'};Reset-AppxPackage -Package $fullName -Confirm:$false -ErrorAction Stop}
ListCredentials={Initialize-MsPrivateCredentialNative;@([MsPrivate.CredentialNative]::Enumerate()|ForEach-Object{[pscustomobject]@{TargetName=$_.TargetName;Type=[uint32]$_.Type}})}
DeleteCredential={param($target,$type)Initialize-MsPrivateCredentialNative;[MsPrivate.CredentialNative]::Delete($target,[uint32]$type)}
}
}
function Get-MsPrivateStableId {param([string]$Text)$sha=[Security.Cryptography.SHA256]::Create();try{$bytes=[Text.Encoding]::UTF8.GetBytes($Text);$hash=$sha.ComputeHash($bytes);'credential-'+(([BitConverter]::ToString($hash)).Replace('-','').Substring(0,16).ToLowerInvariant())}finally{$sha.Dispose()}}
function Get-MsPrivateAccountPlan {
param([ValidateSet('Consumer','AllAppAccounts')][string]$Scope='AllAppAccounts',[hashtable]$Backend=(New-MsPrivateAccountBackend))
$catalog=Get-MsPrivateAccountCatalog;$packages=@(& $Backend.ListPackages)
foreach($item in @($catalog.Packages|Where-Object{$_.Scope-eq'Consumer'-or$Scope-eq'AllAppAccounts'})){
$matches=@($packages|Where-Object Name -eq $item.Name)
if($matches.Count-gt1){throw "Ambiguous package inventory: $($item.Name)"}
[pscustomobject]@{Id=$item.Id;Kind='Package';Label=$item.Label;Status=if($matches.Count){'WouldReset'}else{'Unavailable'};Key=if($matches.Count){$matches[0].PackageFullName}else{$null};CredentialType=$null;Impact=$item.Impact}
}
foreach($credential in @(& $Backend.ListCredentials)){
if(@($catalog.CredentialTargetPatterns|Where-Object{[string]$credential.TargetName-match$_}).Count){
[pscustomobject]@{Id=(Get-MsPrivateStableId ([string]$credential.TargetName+':'+[string]$credential.Type));Kind='Credential';Label='Stored Microsoft application credential';Status='WouldDelete';Key=[string]$credential.TargetName;CredentialType=[uint32]$credential.Type;Impact='Deletes a matching Credential Manager entry. The secret is not read or written to the report.'}
}
}
}
function Save-MsPrivateAccountReport {param($Report,[Parameter(Mandatory)][string]$Path)Write-MsPrivateJsonAtomic -InputObject $Report -Path $Path -Depth 8}
function Invoke-MsPrivateAccountPurge {
param([ValidateSet('Consumer','AllAppAccounts')][string]$Scope='AllAppAccounts',[Parameter(Mandatory)][hashtable]$Backend,[Parameter(Mandatory)][scriptblock]$Save)
$plan=@(Get-MsPrivateAccountPlan -Scope $Scope -Backend $Backend)
$entries=@($plan|Where-Object {$_.Status -in @('WouldReset','WouldDelete')}|ForEach-Object{[pscustomobject]@{Id=$_.Id;Kind=$_.Kind;Label=$_.Label;Status='Pending'}})
$report=[pscustomobject]@{Schema=1;Kind='MsPrivateAccounts';CreatedUtc=[DateTime]::UtcNow.ToString('o');Scope=$Scope;Status='Prepared';Entries=$entries};& $Save $report
try{
foreach($item in @($plan|Where-Object {$_.Status -in @('WouldReset','WouldDelete')})){$entry=$entries|Where-Object Id -eq $item.Id;$entry.Status='Working';& $Save $report;if($item.Kind-eq'Package'){& $Backend.ResetPackage $item.Key;$entry.Status='Reset'}else{& $Backend.DeleteCredential $item.Key $item.CredentialType;$entry.Status='Deleted'};& $Save $report}
$report.Status='Completed';& $Save $report;$report
}catch{$report.Status='PartialFailure';& $Save $report;throw}
}
Export-ModuleMember -Function Get-MsPrivateAccountCatalog,New-MsPrivateAccountBackend,Get-MsPrivateAccountPlan,Save-MsPrivateAccountReport,Invoke-MsPrivateAccountPurge
+39
View File
@@ -0,0 +1,39 @@
Set-StrictMode -Version Latest
$ErrorActionPreference='Stop'
Import-Module (Join-Path $PSScriptRoot 'MsPrivate.IO.psm1') -ErrorAction Stop
function Invoke-MsPrivateGroupPlan {
param(
[Parameter(Mandatory)][object[]]$Groups,
[scriptblock]$OnProgress,
[string]$ReportPath
)
$seen=@{};$known=@{}
foreach($group in $Groups){
$id=[string]$group.Id
if($id-notmatch'^[A-Z][A-Za-z0-9-]*$'-or$seen.ContainsKey($id)-or$group.Action-isnot[scriptblock]){throw "Invalid or duplicate operation group: $id"}
foreach($dependency in @($group.DependsOn)){if(-not$known.ContainsKey([string]$dependency)){throw "Operation dependency must reference an earlier group: $id/$dependency"}}
$seen[$id]=$true;$known[$id]=$true
}
$entries=@($Groups|ForEach-Object{[pscustomobject]@{Id=[string]$_.Id;Label=[string]$_.Label;DependsOn=@($_.DependsOn);Status='Pending';Error=$null}})
$report=[pscustomobject]@{Schema=1;Kind='MsPrivateRun';CreatedUtc=[DateTime]::UtcNow.ToString('o');CompletedUtc=$null;Status='Running';Entries=$entries}
$save={if($ReportPath){Write-MsPrivateJsonAtomic -InputObject $report -Path $ReportPath -Depth 8}}.GetNewClosure();&$save
foreach($group in $Groups){
$entry=@($entries | Where-Object { $_.Id -ceq [string]$group.Id })[0]
$blocked=@($entry.DependsOn | Where-Object {
$dependency=[string]$_
$dependencyEntry=@($entries | Where-Object { $_.Id -ceq $dependency })
$dependencyEntry.Count -ne 1 -or $dependencyEntry[0].Status -ne 'Succeeded'
})
if($blocked.Count){$entry.Status='SkippedDependency';$entry.Error='Required group did not succeed: '+($blocked-join', ');&$save;continue}
$entry.Status='Running';&$save;if($OnProgress){&$OnProgress $entry}
try{& $group.Action|Out-Host;$entry.Status='Succeeded'}catch{$entry.Status='Failed';$entry.Error=$_.Exception.Message}
&$save
}
$failed=@($entries | Where-Object { $_.Status -eq 'Failed' }).Count
$skipped=@($entries | Where-Object { $_.Status -eq 'SkippedDependency' }).Count
$report.Status=if($failed-or$skipped){'PartialFailure'}else{'Completed'};$report.CompletedUtc=[DateTime]::UtcNow.ToString('o');&$save
$report
}
Export-ModuleMember -Function Invoke-MsPrivateGroupPlan
+41
View File
@@ -0,0 +1,41 @@
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Write-MsPrivateJsonAtomic {
[CmdletBinding()]
param(
[Parameter(Mandatory)]$InputObject,
[Parameter(Mandatory)][string]$Path,
[ValidateRange(2, 100)][int]$Depth = 12
)
$fullPath = [IO.Path]::GetFullPath($Path)
$parent = [IO.Path]::GetDirectoryName($fullPath)
[IO.Directory]::CreateDirectory($parent) | Out-Null
$tempPath = $fullPath + '.' + [guid]::NewGuid().ToString('N') + '.tmp'
$backupPath = $tempPath + '.previous'
try {
$json = $InputObject | ConvertTo-Json -Depth $Depth
$bytes = [Text.UTF8Encoding]::new($false).GetBytes($json)
$stream = [IO.FileStream]::new($tempPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
try {
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush($true)
} finally {
$stream.Dispose()
}
if ([IO.File]::Exists($fullPath)) {
[IO.File]::Replace($tempPath, $fullPath, $backupPath)
try { [IO.File]::Delete($backupPath) }
catch { Write-Warning "JSON saved, but its previous copy remains at $backupPath" }
} else {
[IO.File]::Move($tempPath, $fullPath)
}
} finally {
if ([IO.File]::Exists($tempPath)) { [IO.File]::Delete($tempPath) }
}
}
Export-ModuleMember -Function Write-MsPrivateJsonAtomic
+75
View File
@@ -0,0 +1,75 @@
Set-StrictMode -Version Latest
$ErrorActionPreference='Stop'
function Get-MsPrivatePresetCatalog {
param([string]$Path=(Join-Path $PSScriptRoot '../config/presets.json'))
$catalog=Get-Content -LiteralPath $Path -Raw|ConvertFrom-Json
if($catalog.Schema-ne2-or-not@($catalog.Presets).Count){throw 'Unsupported or empty preset catalog.'}
if([int]$catalog.FreshInstallMaxAgeDays-lt1-or[int]$catalog.FreshInstallMaxAgeDays-gt30){throw 'Fresh-install age threshold must be between 1 and 30 days.'}
$ids=@{}
foreach($p in @($catalog.Presets)){
$id=[string]$p.Id
if($id-notmatch'^[A-Z][A-Za-z0-9-]*$'-or$ids.ContainsKey($id)){throw "Invalid or duplicate preset ID: $id"}
if([string]::IsNullOrWhiteSpace([string]$p.Label)-or[string]::IsNullOrWhiteSpace([string]$p.Description)){throw "Preset requires a label and description: $id"}
if($p.Profile-notin@('Standard','Strict')-or$p.Updates-notin@('Keep','Automatic','Notify','Manual')-or$p.Network-notin@('Observe','BlockOptionalTelemetry','Isolate')){throw "Invalid preset mode: $id"}
if([int]$p.RollbackMinutes-lt2-or[int]$p.RollbackMinutes-gt60){throw "Invalid preset rollback interval: $id"}
$browsers=@($p.Browsers);if(@($browsers|Where-Object{$_-notin@('Zen','Helium','Tor')}).Count-or@($browsers|Select-Object -Unique).Count-ne$browsers.Count){throw "Invalid preset browser selection: $id"}
foreach($name in @('Services','Accounts','RemoveBundled','RemovePersonal','RemoveStore','RemoveOneDrive','RemoveEdge','Recall','RemoveRecall','Cleanup','PrivacyCleanup','CleanupSchedule','Lock','InstallMullvad','InstallVeraCrypt','DisableSensors')){if($p.$name-isnot[bool]){throw "Preset boolean is invalid: $id/$name"}}
if($p.RemoveRecall-and-not$p.Recall){throw "Preset cannot remove Recall without enabling its deletion workflow: $id"}
if($p.RemoveEdge-and-not$browsers.Count){throw "Preset removing Edge requires a replacement browser: $id"}
if($id-in@('FreshInstall','Medium','Strong','Maximum')-and(-not$p.RemovePersonal-or-not$p.RemoveStore-or-not$p.RemoveOneDrive-or-not$p.RemoveEdge-or-not$p.Recall-or-not$p.Accounts-or-not$p.Services)){throw "Aggressive preset is incomplete: $id"}
if($id-eq'EstablishedSafe'-and($p.Services-or$p.Accounts-or$p.RemoveBundled-or$p.RemovePersonal-or$p.RemoveStore-or$p.RemoveOneDrive-or$p.RemoveEdge-or$p.Recall-or$p.RemoveRecall-or$p.Network-ne'Observe'-or$p.Cleanup-or$p.PrivacyCleanup-or$p.CleanupSchedule-or$p.Lock-or$p.InstallMullvad-or$p.InstallVeraCrypt-or$p.DisableSensors-or@($p.Browsers).Count)){throw 'EstablishedSafe contains a destructive or installation action.'}
$ids[$id]=$true
}
foreach($name in @('DefaultFreshPreset','DefaultEstablishedPreset')){if(-not$ids.ContainsKey([string]$catalog.$name)){throw "Preset catalog default is missing: $name"}}
$catalog
}
function Get-MsPrivatePreset {
param([Parameter(Mandatory)][string]$Id,[string]$Path=(Join-Path $PSScriptRoot '../config/presets.json'))
$catalog=Get-MsPrivatePresetCatalog -Path $Path
$found=@($catalog.Presets|Where-Object Id -CEQ $Id)
if($found.Count-ne1){throw "Unknown preset: $Id"}
$found[0]
}
function Resolve-MsPrivateRecallDisposition {
param([bool]$Requested,$Plan)
if(-not$Requested){return [pscustomobject]@{Requested=$false;Applicable=$false;Conflict=$false;Skipped=@()}}
if($null-eq$Plan){throw 'A requested Recall workflow requires a plan.'}
$items=@($Plan.Items)
if(-not$items.Count){throw 'A requested Recall workflow returned an empty plan.'}
$unknown=@($items|Where-Object {$_.Status -notin @('WouldChange','Configured','Unsupported','Unavailable','Conflict')})
if($unknown.Count){throw 'Recall plan contains an unknown status.'}
$conflicts=@($items|Where-Object {$_.Status -eq 'Conflict'})
$skipped=@($items|Where-Object {$_.Status -in @('Unsupported','Unavailable')}|ForEach-Object{"$($_.Id): $($_.Status) on this Windows edition/build"})
[pscustomobject]@{Requested=$true;Applicable=[bool](-not$conflicts.Count-and-not$skipped.Count);Conflict=[bool]$conflicts.Count;Skipped=$skipped}
}
function Get-MsPrivateInstallContext {
param(
[string]$Path=(Join-Path $PSScriptRoot '../config/presets.json'),
[datetime]$NowUtc=[DateTime]::UtcNow,
[scriptblock]$Probe={
$os=Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
$profile=Get-Item -LiteralPath $env:USERPROFILE -Force -ErrorAction Stop
[pscustomobject]@{InstallDate=$os.InstallDate;ProfileCreationUtc=$profile.CreationTimeUtc}
}
)
$catalog=Get-MsPrivatePresetCatalog -Path $Path;$threshold=[int]$catalog.FreshInstallMaxAgeDays
try{$evidence=& $Probe}catch{return [pscustomobject]@{Classification='Unknown';RecommendedPreset=[string]$catalog.DefaultEstablishedPreset;ThresholdDays=$threshold;InstallAgeDays=$null;ProfileAgeDays=$null;Reason='Installation age could not be read, so the conservative established-PC preset was selected.'}}
try{
$installed=([datetime]$evidence.InstallDate).ToUniversalTime();$profileCreated=([datetime]$evidence.ProfileCreationUtc).ToUniversalTime();$now=$NowUtc.ToUniversalTime()
$installAge=[int][Math]::Floor(($now-$installed).TotalDays);$profileAge=[int][Math]::Floor(($now-$profileCreated).TotalDays)
if($installAge-lt0-or$profileAge-lt0){throw 'Future installation timestamp.'}
}catch{return [pscustomobject]@{Classification='Unknown';RecommendedPreset=[string]$catalog.DefaultEstablishedPreset;ThresholdDays=$threshold;InstallAgeDays=$null;ProfileAgeDays=$null;Reason='Installation timestamps were invalid, so the conservative established-PC preset was selected.'}}
$fresh=$installAge-le$threshold-and$profileAge-le$threshold
[pscustomobject]@{
Classification=if($fresh){'FreshInstall'}else{'Established'}
RecommendedPreset=if($fresh){[string]$catalog.DefaultFreshPreset}else{[string]$catalog.DefaultEstablishedPreset}
ThresholdDays=$threshold;InstallAgeDays=$installAge;ProfileAgeDays=$profileAge
Reason=if($fresh){"Windows and the current user profile are at most $threshold days old."}else{"Windows or the current user profile is older than $threshold days; broad destructive choices were not selected automatically."}
}
}
Export-ModuleMember -Function Get-MsPrivatePresetCatalog,Get-MsPrivatePreset,Resolve-MsPrivateRecallDisposition,Get-MsPrivateInstallContext
+98
View File
@@ -0,0 +1,98 @@
Set-StrictMode -Version Latest
$ErrorActionPreference='Stop'
Import-Module (Join-Path $PSScriptRoot 'MsPrivate.IO.psm1') -ErrorAction Stop
function Get-MsPrivateServiceCatalog {
param([string]$Path=(Join-Path $PSScriptRoot '../config/services.json'))
$catalog=Get-Content -LiteralPath $Path -Raw|ConvertFrom-Json
if($catalog.Schema-ne 1){throw 'Unsupported service catalog schema.'}
if(-not@($catalog.Services).Count-or-not@($catalog.ProtectedServices).Count){throw 'Service catalog must contain service and protected-service entries.'}
$protected=@{};foreach($service in @($catalog.ProtectedServices)){$name=[string]$service;if($name-notmatch'^[A-Za-z0-9_]+$'-or$protected.ContainsKey($name)){throw "Invalid or duplicate protected service: $name"};$protected[$name]=$true}
$ids=@{};$names=@{};foreach($item in @($catalog.Services)){
$id=[string]$item.Id;$name=[string]$item.Name
if($id-notmatch'^[a-z0-9][a-z0-9-]*$'-or$ids.ContainsKey($id)){throw "Invalid or duplicate service ID: $id"}
if($name-notmatch'^[A-Za-z0-9_]+$'-or$names.ContainsKey($name)){throw "Invalid or duplicate service template: $name"}
if($item.Standard-notin@('Keep','Disabled')-or$item.Strict-notin@('Keep','Disabled')){throw "Invalid service profile value: $id"}
if($protected.ContainsKey($name)){throw "Protected service entered the disable catalog: $name"}
$source=$null;if(-not[uri]::TryCreate([string]$item.Source,[UriKind]::Absolute,[ref]$source)-or$source.Scheme-ne'https'){throw "Service requires an HTTPS source: $id"}
$ids[$id]=$true;$names[$name]=$true
}
$catalog
}
function New-MsPrivateServiceBackend {
@{
ReadTemplate={param($name)
$base=[Microsoft.Win32.RegistryKey]::OpenBaseKey('LocalMachine','Registry64');try{$key=$base.OpenSubKey("SYSTEM\CurrentControlSet\Services\$name",$false);try{if(-not$key){return [pscustomobject]@{Exists=$false;StartExists=$false;Start=$null;FlagsExists=$false;Flags=$null}}
$values=@($key.GetValueNames());[pscustomobject]@{Exists=$true;StartExists=$values-contains'Start';Start=if($values-contains'Start'){[int]$key.GetValue('Start')}else{$null};FlagsExists=$values-contains'UserServiceFlags';Flags=if($values-contains'UserServiceFlags'){[int]$key.GetValue('UserServiceFlags')}else{$null}}
}finally{if($key){$key.Dispose()}}}finally{$base.Dispose()}}
WriteTemplate={param($name,$state)
$base=[Microsoft.Win32.RegistryKey]::OpenBaseKey('LocalMachine','Registry64');try{$key=$base.OpenSubKey("SYSTEM\CurrentControlSet\Services\$name",$true);if(-not$key){throw "Service template disappeared: $name"};try{if($state.StartExists){$key.SetValue('Start',[int]$state.Start,[Microsoft.Win32.RegistryValueKind]::DWord)}else{$key.DeleteValue('Start',$false)};if($state.FlagsExists){$key.SetValue('UserServiceFlags',[int]$state.Flags,[Microsoft.Win32.RegistryValueKind]::DWord)}else{$key.DeleteValue('UserServiceFlags',$false)}}finally{$key.Dispose()}}finally{$base.Dispose()}}
ReadInstances={param($name,$instances) $pattern=if($instances){$name+'_*'}else{$name};@(Get-Service -Name $pattern -ErrorAction SilentlyContinue|ForEach-Object{[pscustomobject]@{Name=$_.Name;Status=[string]$_.Status}})}
StopInstance={param($name) Stop-Service -Name $name -Force -ErrorAction Stop}
StartInstance={param($name) Start-Service -Name $name -ErrorAction Stop}
}
}
function Test-MsPrivateServiceStateEqual {param($Left,$Right) return $Left.Exists -eq $Right.Exists -and $Left.StartExists -eq $Right.StartExists -and $Left.Start -eq $Right.Start -and $Left.FlagsExists -eq $Right.FlagsExists -and $Left.Flags -eq $Right.Flags}
function Assert-MsPrivateServiceEntries {
param([object[]]$Entries,[string]$Context='service data',[switch]$Journal)
$catalog=Get-MsPrivateServiceCatalog
$byId=@{};foreach($item in @($catalog.Services)){$byId[[string]$item.Id]=$item}
$seen=@{}
foreach($entry in @($Entries)){
if(-not$entry.PSObject.Properties['Id']-or-not$entry.PSObject.Properties['Name']){throw "Invalid $Context entry."}
$id=[string]$entry.Id;$name=[string]$entry.Name
if(-not$byId.ContainsKey($id)-or$name-ne[string]$byId[$id].Name){throw "Unknown or mismatched $Context entry: $id"}
if($seen.ContainsKey($id)){throw "Duplicate $Context entry: $id"};$seen[$id]=$true
if($Journal){
foreach($property in @('Before','After','BeforeInstances')){if(-not$entry.PSObject.Properties[$property]){throw "Invalid $Context entry for ${id}: missing $property"}}
foreach($state in @($entry.Before,$entry.After)){
foreach($property in @('Exists','StartExists','Start','FlagsExists','Flags')){if(-not$state.PSObject.Properties[$property]){throw "Invalid $Context state for $id."}}
if($state.StartExists-and($null-eq$state.Start-or[int]$state.Start-lt0-or[int]$state.Start-gt4)){throw "Invalid $Context startup value for $id."}
}
foreach($instance in @($entry.BeforeInstances)){
if(-not$instance.PSObject.Properties['Name']-or-not$instance.PSObject.Properties['Status']){throw "Invalid $Context instance for $id."}
$valid=if([bool]$byId[$id].Instances){[string]$instance.Name-like($name+'_*')}else{[string]$instance.Name-eq$name}
if(-not$valid){throw "Mismatched $Context instance for $id."}
}
}
}
}
function Get-MsPrivateServicePlan {
param([ValidateSet('Standard','Strict')][string]$Profile='Strict',[hashtable]$Backend=(New-MsPrivateServiceBackend))
$catalog=Get-MsPrivateServiceCatalog
foreach($item in @($catalog.Services)){
$before=& $Backend.ReadTemplate $item.Name;$desired=[string]$item.$Profile;$status='Keep';$reason='Profile keeps this service unchanged.'
if($desired -eq 'Disabled'){
if(-not$before.Exists){$status='Unavailable';$reason='Service template is absent on this Windows build.'}
elseif($before.StartExists -and $before.Start -eq 4 -and (-not $item.SetUserServiceFlags -or ($before.FlagsExists -and $before.Flags -eq 0))){$status='Configured';$reason='Template is disabled; current instances still require status verification.'}
else{$status='WouldChange';$reason='Template will be disabled and matching running instances stopped.'}
}
[pscustomobject]@{Id=$item.Id;Name=$item.Name;Instances=[bool]$item.Instances;SetUserServiceFlags=[bool]$item.SetUserServiceFlags;Status=$status;Before=$before;Desired=$desired;Impact=$item.Impact;Source=$item.Source;Reason=$reason}
}
}
function Save-MsPrivateServiceJournal {param($Journal,[Parameter(Mandatory)][string]$Path)Write-MsPrivateJsonAtomic -InputObject $Journal -Path $Path -Depth 10}
function Invoke-MsPrivateServiceApply {
param([Parameter(Mandatory)][object[]]$Plan,[Parameter(Mandatory)][hashtable]$Backend,[Parameter(Mandatory)][scriptblock]$Save)
Assert-MsPrivateServiceEntries -Entries $Plan -Context 'service plan'
$entries=@($Plan|Where-Object Status -eq 'WouldChange'|ForEach-Object{
$instances=@(& $Backend.ReadInstances $_.Name $_.Instances)
$after=[pscustomobject]@{Exists=$true;StartExists=$true;Start=4;FlagsExists=if($_.SetUserServiceFlags){$true}else{[bool]$_.Before.FlagsExists};Flags=if($_.SetUserServiceFlags){0}else{$_.Before.Flags}}
[pscustomobject]@{Id=$_.Id;Name=$_.Name;Instances=$_.Instances;Before=$_.Before;BeforeInstances=$instances;After=$after;Status='Pending'}
})
$journal=[pscustomobject]@{Schema=1;Kind='MsPrivateServices';CreatedUtc=[DateTime]::UtcNow.ToString('o');Status='Prepared';Entries=$entries};& $Save $journal
try{foreach($entry in $entries){$current=& $Backend.ReadTemplate $entry.Name;if(-not(Test-MsPrivateServiceStateEqual $current $entry.Before)){throw "Service state changed since preview: $($entry.Name)"};$entry.Status='Writing';& $Save $journal;& $Backend.WriteTemplate $entry.Name $entry.After;foreach($instance in @(& $Backend.ReadInstances $entry.Name $entry.Instances)){if($instance.Status -eq 'Running'){& $Backend.StopInstance $instance.Name}};$readback=& $Backend.ReadTemplate $entry.Name;if(-not(Test-MsPrivateServiceStateEqual $readback $entry.After)){throw "Service disable readback failed: $($entry.Name)"};$entry.Status='Written';& $Save $journal};$journal.Status='Applied';& $Save $journal;$journal}catch{$journal.Status='ApplyFailed';& $Save $journal;throw}
}
function Restore-MsPrivateServices {
param([Parameter(Mandatory)]$Journal,[Parameter(Mandatory)][hashtable]$Backend,[Parameter(Mandatory)][scriptblock]$Save)
if($Journal.Schema -ne 1 -or $Journal.Kind -ne 'MsPrivateServices'){throw 'Invalid service journal.'};Assert-MsPrivateServiceEntries -Entries @($Journal.Entries) -Context 'service journal' -Journal;$problems=0;$entries=@($Journal.Entries);[array]::Reverse($entries)
foreach($entry in $entries){try{$current=& $Backend.ReadTemplate $entry.Name;if(Test-MsPrivateServiceStateEqual $current $entry.Before){$entry.Status='Restored'}elseif(Test-MsPrivateServiceStateEqual $current $entry.After){& $Backend.WriteTemplate $entry.Name $entry.Before;foreach($instance in @($entry.BeforeInstances|Where-Object Status -eq 'Running')){try{& $Backend.StartInstance $instance.Name}catch{}};if(-not(Test-MsPrivateServiceStateEqual (& $Backend.ReadTemplate $entry.Name) $entry.Before)){throw 'Restore readback failed.'};$entry.Status='Restored'}else{$entry.Status='Conflict';$problems++}}catch{$entry.Status='RestoreFailed';$problems++};& $Save $Journal};$Journal.Status=if($problems){'RestoreIncomplete'}else{'Restored'};& $Save $Journal;$Journal
}
Export-ModuleMember -Function Get-MsPrivateServiceCatalog,New-MsPrivateServiceBackend,Get-MsPrivateServicePlan,Save-MsPrivateServiceJournal,Invoke-MsPrivateServiceApply,Restore-MsPrivateServices,Test-MsPrivateServiceStateEqual
+61 -17
View File
@@ -2,18 +2,21 @@ Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Get-WindBrowserCatalog {
@(Get-Content -LiteralPath (Join-Path $PSScriptRoot '../config/apps.json') -Raw | ConvertFrom-Json)
$items = Get-Content -LiteralPath (Join-Path $PSScriptRoot '../config/apps.json') -Raw | ConvertFrom-Json
foreach ($item in $items) { $item }
}
function Test-WindProtectedApp {
param([Parameter(Mandatory)]$App)
param([Parameter(Mandatory)]$App,[switch]$AllowStoreRemoval)
if($AllowStoreRemoval -and $App.Kind -eq 'Appx' -and $App.Name -in @('Microsoft.WindowsStore','Microsoft.StorePurchaseApp')){return $false}
if ($App.Protected) { return $true }
# Removal is deliberately unavailable for infrastructure and known security tools.
$text = $App.Name + ' ' + $App.Publisher
return $text -match '(?i)WebView|DesktopAppInstaller|WindowsStore|StorePurchaseApp|SecHealth|SecurityHealth|AAD.Broker|AccountsControl|CloudExperienceHost|ShellExperienceHost|StartMenuExperienceHost|MicrosoftWindows\.|Microsoft\.Windows\.|Microsoft\.UI\.|VCLibs|NET\.Native|WinAppRuntime|WindowsAppRuntime|WindowsTerminal|WindowsSubsystem|GamingServices|XboxIdentityProvider|Xbox.TCUI|XboxGameOverlay|XboxGamingOverlay|HEIF|HEVC|WebMediaExtension|AV1VideoExtension|VP9VideoExtension|Visual C\+\+|\.NET|SDK|Driver|Firmware|Update for|Hotfix|Redistributable|Windows.*(Runtime|Kit)|Defender|Security|Antivirus|Anti-malware|Bitdefender|ESET|Kaspersky|Malwarebytes|Norton|McAfee|Avast|AVG |Sophos|CrowdStrike|SentinelOne|Zillya|Mullvad|VeraCrypt|BitLocker|1Password|KeePass|Bitwarden|Zen Browser|Helium|Tor Browser|PowerShell|NVIDIA|Realtek|Advanced Micro Devices|Intel\(R\)'
return $text -match '(?i)WebView|DesktopAppInstaller|WindowsStore|StorePurchaseApp|SecHealth|SecurityHealth|AAD.Broker|AccountsControl|CloudExperienceHost|ShellExperienceHost|StartMenuExperienceHost|MicrosoftWindows\.|Microsoft\.UI\.|VCLibs|NET\.Native|WinAppRuntime|WindowsAppRuntime|WindowsTerminal|WindowsSubsystem|GamingServices|XboxIdentityProvider|Xbox.TCUI|XboxGameOverlay|XboxGamingOverlay|HEIF|HEVC|WebMediaExtension|AV1VideoExtension|VP9VideoExtension|Visual C\+\+|\.NET|SDK|Driver|Firmware|Update for|Hotfix|Redistributable|Windows.*(Runtime|Kit)|Defender|Security|Antivirus|Anti-malware|Bitdefender|ESET|Kaspersky|Malwarebytes|Norton|McAfee|Avast|AVG |Sophos|CrowdStrike|SentinelOne|Zillya|Mullvad|VeraCrypt|BitLocker|2FAGuard|Authenticator|Authy|Yubi|F-Secure|Emsisoft|Trend Micro|1Password|KeePass|Bitwarden|Zen Browser|Helium|Tor Browser|PowerShell|NVIDIA|Realtek|Advanced Micro Devices|Intel\(R\)'
}
function Initialize-WindAppx {
if (Get-Command Get-AppxPackage -ErrorAction SilentlyContinue) { return }
if ($PSVersionTable.PSEdition -eq 'Core') {
Import-Module Appx -UseWindowsPowerShell -WarningAction SilentlyContinue -ErrorAction Stop
} else { Import-Module Appx -ErrorAction Stop }
@@ -22,7 +25,9 @@ function Initialize-WindAppx {
function Get-WindAppInventory {
Initialize-WindAppx
# Current user only. Do not query Win32_Product: enumeration can trigger MSI repairs.
foreach ($p in @(Get-AppxPackage -PackageTypeFilter Main,Bundle -ErrorAction Stop)) {
$packages = @(Get-AppxPackage -PackageTypeFilter Main,Bundle -ErrorAction Stop)
$parents = @(Select-WindAppxParents $packages)
foreach ($p in $parents) {
[pscustomobject]@{
Id = 'appx:' + $p.PackageFullName; Name = [string]$p.Name
Version = [string]$p.Version; Publisher = [string]$p.Publisher
@@ -31,6 +36,7 @@ function Get-WindAppInventory {
}
}
$uninstall = 'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall'
$sharedUserEntries = @{}
foreach ($hive in @('LocalMachine','CurrentUser')) {
foreach ($view in @('Registry64','Registry32')) {
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey($hive,$view)
@@ -45,6 +51,12 @@ function Get-WindAppInventory {
$display = [string]$key.GetValue('DisplayName')
if ([string]::IsNullOrWhiteSpace($display)) { continue }
if (-not $key.GetValue('UninstallString')) { continue }
# HKCU uninstall keys may be shared between 32/64-bit views.
if ($hive -eq 'CurrentUser') {
$fingerprint=@($name,$display,[string]$key.GetValue('DisplayVersion'),[string]$key.GetValue('Publisher'),[string]$key.GetValue('UninstallString')) | ConvertTo-Json -Compress
if ($sharedUserEntries.ContainsKey($fingerprint)) { continue }
$sharedUserEntries[$fingerprint]=$true
}
[pscustomobject]@{
Id = "desktop:$hive/$view/$name"; Name = $display
Version = [string]$key.GetValue('DisplayVersion'); Publisher = [string]$key.GetValue('Publisher')
@@ -60,15 +72,26 @@ function Get-WindAppInventory {
}
}
function Select-WindAppxParents {
param([object[]]$Packages)
foreach ($group in @($Packages | Group-Object PackageFamilyName)) {
$bundles=@($group.Group | Where-Object IsBundle)
if ($bundles.Count) { $bundles } else { $group.Group }
}
}
function Get-WindAppPlan {
param(
[Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Inventory,
[string[]]$ExtraId = @(), [string[]]$ExcludeId = @(),
[switch]$SkipBundled, [switch]$KeepOneDrive, [switch]$KeepEdge
[switch]$SkipBundled, [switch]$KeepOneDrive, [switch]$KeepEdge,
[switch]$RemovePersonal, [switch]$RemoveStore
)
$catalog = @(Get-Content -LiteralPath (Join-Path $PSScriptRoot '../config/debloat.json') -Raw | ConvertFrom-Json)
$decodedCatalog = Get-Content -LiteralPath (Join-Path $PSScriptRoot '../config/debloat.json') -Raw | ConvertFrom-Json
$catalog = @($decodedCatalog)
$inventoryIds = @($Inventory | ForEach-Object { $_.Id })
foreach ($id in @($ExtraId + $ExcludeId)) {
if ($Inventory.Id -notcontains $id) { throw "Selection no longer installed: $id. Refresh the app list." }
if ($inventoryIds -notcontains $id) { throw "Selection no longer installed: $id. Refresh the app list." }
}
foreach ($app in $Inventory) {
$reason = 'Personal app; opt in to remove'
@@ -76,21 +99,28 @@ function Get-WindAppPlan {
if (-not $SkipBundled -and $app.Kind -eq 'Appx' -and $catalog.Name -contains $app.Name) {
$selected = $true; $reason = 'Bundled/promotional catalog'
}
if($RemovePersonal){$selected=$true;$reason='Aggressive preset: all removable applications'}
if ($app.Kind -eq 'Desktop' -and $app.Name -eq 'Microsoft OneDrive') {
$selected = -not $KeepOneDrive; $reason = 'OneDrive: sync stops; cloud-only files stay online'
}
if ($app.Kind -eq 'Desktop' -and $app.Name -eq 'Microsoft Edge') {
$selected = -not $KeepEdge; $reason = 'Edge: supported uninstaller only; replacement browser required'
}
$protected = Test-WindProtectedApp $app
$storeRemovalAllowed=[bool]($RemoveStore-and$app.Kind-eq'Appx'-and$app.Name-in@('Microsoft.WindowsStore','Microsoft.StorePurchaseApp'))
if($storeRemovalAllowed){$selected=$true;$reason='Aggressive preset: Microsoft Store application'}
$protected = Test-WindProtectedApp $app -AllowStoreRemoval:$storeRemovalAllowed
if ($protected) { $selected = $false; $reason = 'Protected: system component, dependency or security tool' }
if ($ExcludeId -contains $app.Id) { $selected = $false; $reason = 'Kept by user' }
$status = if ($selected) { 'Remove' } else { 'Keep' }
# Exact name/scope must resolve uniquely before delegating to WinGet.
if ($selected -and $app.Kind -eq 'Desktop' -and @($Inventory | Where-Object { $_.Kind -eq 'Desktop' -and $_.Name -ceq $app.Name -and $_.Scope -eq $app.Scope }).Count -ne 1) {
$status = 'Blocked'; $reason = 'Ambiguous desktop registration; use Windows Installed apps'
if($RemovePersonal-and$ExtraId-notcontains$app.Id){
$status='Keep';$protected=$true;$reason='Skipped: duplicate desktop name/scope cannot be removed unambiguously'
}else{
$status = 'Blocked'; $reason = 'Ambiguous desktop registration; use Windows Installed apps'
}
}
[pscustomobject]@{ Id=$app.Id; Name=$app.Name; Version=$app.Version; Publisher=$app.Publisher; Kind=$app.Kind; Scope=$app.Scope; Target=$app.Target; Protected=$protected; Status=$status; Reason=$reason }
[pscustomobject]@{ Id=$app.Id; Name=$app.Name; Version=$app.Version; Publisher=$app.Publisher; Kind=$app.Kind; Scope=$app.Scope; Target=$app.Target; Protected=$protected; StoreRemovalAllowed=$storeRemovalAllowed; Status=$status; Reason=$reason }
}
}
@@ -102,6 +132,19 @@ function Invoke-WindWinget {
return [int]$LASTEXITCODE
}
function Set-WindRemovalGroups {
param([hashtable]$Options,[object[]]$Inventory,[bool]$Bundled,[bool]$OneDrive,[bool]$Edge)
$catalog=Get-Content -LiteralPath (Join-Path $PSScriptRoot '../config/debloat.json') -Raw | ConvertFrom-Json
$reset=@($Inventory | Where-Object {
($Options.SkipBundled -eq $Bundled -and $_.Kind -eq 'Appx' -and $catalog.Name -contains $_.Name) -or
($Options.KeepOneDrive -eq $OneDrive -and $_.Name -eq 'Microsoft OneDrive') -or
($Options.KeepEdge -eq $Edge -and $_.Name -eq 'Microsoft Edge')
} | ForEach-Object {$_.Id})
$Options.ExtraId=@($Options.ExtraId | Where-Object {$reset -notcontains $_})
$Options.ExcludeId=@($Options.ExcludeId | Where-Object {$reset -notcontains $_})
$Options.SkipBundled=-not $Bundled; $Options.KeepOneDrive=-not $OneDrive; $Options.KeepEdge=-not $Edge
}
function New-WindAppBackend {
@{
Inventory = { @(Get-WindAppInventory) }
@@ -123,7 +166,7 @@ function New-WindAppBackend {
Remove-AppxPackage -Package $app.Target -ErrorAction Stop
return 0
}
Invoke-WindWinget @('uninstall','--name',$app.Name,'--exact','--scope',$app.Scope,'--source','winget','--interactive','--disable-interactivity')
Invoke-WindWinget @('uninstall','--product-code',$app.Target,'--exact','--scope',$app.Scope,'--source','winget','--interactive','--disable-interactivity')
}
}
}
@@ -136,13 +179,14 @@ function Invoke-WindAppChanges {
[Parameter(Mandatory)][hashtable]$Backend,
[Parameter(Mandatory)][scriptblock]$Save
)
# Edge is last so an OS refusal does not prevent ordinary debloating.
$remove = @($Plan | Where-Object Status -eq 'Remove' | Sort-Object @{Expression={if ($_.Name -eq 'Microsoft Edge') {1} else {0}}},Name)
# Riskier OS-owned removals run last so a refusal does not prevent ordinary app cleanup.
$remove = @($Plan | Where-Object Status -eq 'Remove' | Sort-Object @{Expression={if ($_.Name -eq 'Microsoft Edge') {2}elseif($_.Name-in@('Microsoft.WindowsStore','Microsoft.StorePurchaseApp')){1}else{0}}},Name)
if (@($Plan | Where-Object Status -eq 'Blocked').Count) { throw 'Resolve blocked app selections before applying.' }
if (@($remove | Where-Object { Test-WindProtectedApp $_ }).Count) { throw 'Protected application in removal plan.' }
if (@($Plan.Id | Select-Object -Unique).Count -ne $Plan.Count) { throw 'Duplicate app selection.' }
if (@($remove | Where-Object { Test-WindProtectedApp $_ -AllowStoreRemoval:([bool]$_.StoreRemovalAllowed) }).Count) { throw 'Protected application in removal plan.' }
if (@($Plan | ForEach-Object { $_.Id } | Select-Object -Unique).Count -ne $Plan.Count) { throw 'Duplicate app selection.' }
if (@($remove | Where-Object Name -eq 'Microsoft Edge').Count -and $Browser.Count -eq 0) { throw 'Select a replacement browser or keep Edge.' }
$browsers = @(Get-WindBrowserCatalog | Where-Object { $Browser -contains $_.Name })
if ($browsers.Count -ne @($Browser | Select-Object -Unique).Count) { throw 'Browser catalog is missing or duplicates a selected browser.' }
if (-not $PSCmdlet.ShouldProcess('Selected browsers and applications', 'Install browsers, then uninstall selected apps (app data may be removed)')) {
return [pscustomobject]@{ Status='Preview'; Entries=@() }
}
@@ -171,7 +215,7 @@ function Invoke-WindAppChanges {
$inventory = @(& $Backend.Inventory)
$current = @($inventory | Where-Object Id -eq $app.Id)
if ($current.Count -eq 0) { $entry.Status='AlreadyAbsent'; & $Save $report; continue }
if ($current.Count -ne 1 -or $current[0].Version -cne $app.Version -or $current[0].Name -cne $app.Name -or $current[0].Publisher -cne $app.Publisher -or $current[0].Target -cne $app.Target -or $current[0].Kind -cne $app.Kind -or $current[0].Scope -cne $app.Scope -or (Test-WindProtectedApp $current[0])) {
if ($current.Count -ne 1 -or $current[0].Version -cne $app.Version -or $current[0].Name -cne $app.Name -or $current[0].Publisher -cne $app.Publisher -or $current[0].Target -cne $app.Target -or $current[0].Kind -cne $app.Kind -or $current[0].Scope -cne $app.Scope -or (Test-WindProtectedApp $current[0] -AllowStoreRemoval:([bool]$app.StoreRemovalAllowed))) {
$entry.Status='Conflict'; throw 'App changed since preview; refresh selections. No further removals performed.'
}
if ($app.Kind -eq 'Desktop' -and @($inventory | Where-Object { $_.Kind -eq 'Desktop' -and $_.Name -ceq $app.Name -and $_.Scope -eq $app.Scope }).Count -ne 1) {
@@ -195,4 +239,4 @@ function Invoke-WindAppChanges {
}
}
Export-ModuleMember -Function Get-WindBrowserCatalog,Get-WindAppInventory,Get-WindAppPlan,Test-WindProtectedApp,New-WindAppBackend,Invoke-WindAppChanges
Export-ModuleMember -Function Get-WindBrowserCatalog,Get-WindAppInventory,Get-WindAppPlan,Test-WindProtectedApp,New-WindAppBackend,Invoke-WindAppChanges,Select-WindAppxParents,Set-WindRemovalGroups
+120
View File
@@ -0,0 +1,120 @@
Set-StrictMode -Version Latest
$ErrorActionPreference='Stop'
Import-Module (Join-Path $PSScriptRoot 'MsPrivate.IO.psm1') -ErrorAction Stop
function Get-WindCleanupRoots {
$roots=[Collections.Generic.List[string]]::new()
foreach($candidate in @([IO.Path]::GetTempPath(),[Environment]::ExpandEnvironmentVariables('%LOCALAPPDATA%\Temp'))){
if([string]::IsNullOrWhiteSpace($candidate)){continue};$full=[IO.Path]::GetFullPath($candidate).TrimEnd('\');if(-not$roots.Contains($full)){$roots.Add($full)}
}
if([Environment]::Is64BitOperatingSystem){$windowsTemp=[IO.Path]::GetFullPath([Environment]::ExpandEnvironmentVariables('%WINDIR%\Temp')).TrimEnd('\');if(-not$roots.Contains($windowsTemp)){$roots.Add($windowsTemp)}}
@($roots)
}
function Get-WindBrowserCacheCatalog {
$local=[Environment]::GetFolderPath('LocalApplicationData');$roaming=[Environment]::GetFolderPath('ApplicationData');$home=[Environment]::GetFolderPath('UserProfile')
$profileRoots=[Collections.Generic.List[object]]::new()
$zen=Join-Path $roaming 'zen\Profiles'
if(Test-Path -LiteralPath $zen -PathType Container){foreach($profile in @(Get-ChildItem -LiteralPath $zen -Force -ErrorAction SilentlyContinue|Where-Object{$_.PSIsContainer-and-not($_.Attributes-band[IO.FileAttributes]::ReparsePoint)})){$profileRoots.Add([pscustomobject]@{Browser='Zen';Path=$profile.FullName;Kind='Gecko'})}}
foreach($tor in @((Join-Path $local 'Tor Browser\Browser\TorBrowser\Data\Browser\profile.default'),(Join-Path $home 'Desktop\Tor Browser\Browser\TorBrowser\Data\Browser\profile.default'),(Join-Path $env:ProgramFiles 'Tor Browser\Browser\TorBrowser\Data\Browser\profile.default'))|Select-Object -Unique){if($tor-and(Test-Path -LiteralPath $tor -PathType Container)){$profileRoots.Add([pscustomobject]@{Browser='Tor';Path=$tor;Kind='Gecko'})}}
$helium=Join-Path $local 'imput\Helium\User Data'
if(Test-Path -LiteralPath $helium -PathType Container){foreach($profile in @(Get-ChildItem -LiteralPath $helium -Force -ErrorAction SilentlyContinue|Where-Object{$_.PSIsContainer-and-not($_.Attributes-band[IO.FileAttributes]::ReparsePoint)-and($_.Name-eq'Default'-or$_.Name-eq'Guest Profile'-or$_.Name-eq'System Profile'-or$_.Name-like'Profile *')})){$profileRoots.Add([pscustomobject]@{Browser='Helium';Path=$profile.FullName;Kind='Chromium'})}}
foreach($profile in $profileRoots){
$relative=if($profile.Kind-eq'Gecko'){@('cache2','startupCache')}else{@('Cache','Code Cache','GPUCache','DawnCache','GrShaderCache','ShaderCache','Service Worker\CacheStorage')}
foreach($cache in $relative){$root=Join-Path $profile.Path $cache;if(Test-Path -LiteralPath $root -PathType Container){[pscustomobject]@{Category='BrowserCache';Root=$root;Patterns=@('*');Recurse=$true;Scheduled=$false;Description="$($profile.Browser) cache files only; profile data such as cookies, passwords, bookmarks, sessions and settings is retained"}}}
}
}
function Get-WindPrivacyCleanupCatalog {
$local=[Environment]::GetFolderPath('LocalApplicationData');$roaming=[Environment]::GetFolderPath('ApplicationData');$program=[Environment]::GetFolderPath('CommonApplicationData')
foreach($root in Get-WindCleanupRoots){[pscustomobject]@{Category='Temp';Root=$root;Patterns=@('*');Recurse=$true;Scheduled=$true;Description='Old files in allowlisted Windows temporary directories'}}
foreach($root in @((Join-Path $local 'Microsoft\Windows\WER'),(Join-Path $program 'Microsoft\Windows\WER\ReportArchive'),(Join-Path $program 'Microsoft\Windows\WER\ReportQueue'),(Join-Path $program 'Microsoft\Windows\WER\Temp'))){[pscustomobject]@{Category='CrashReports';Root=$root;Patterns=@('*');Recurse=$true;Scheduled=$false;Description='Queued and archived Windows Error Reporting data, which can include dumps and user content'}}
[pscustomobject]@{Category='CrashReports';Root=(Join-Path $local 'CrashDumps');Patterns=@('*.dmp');Recurse=$true;Scheduled=$false;Description='Current-user application crash dumps'}
[pscustomobject]@{Category='RecentItems';Root=(Join-Path $roaming 'Microsoft\Windows\Recent');Patterns=@('*.lnk','*.automaticDestinations-ms','*.customDestinations-ms');Recurse=$true;Scheduled=$false;Description='Current-user Recent Items and Jump Lists'}
[pscustomobject]@{Category='ThumbnailCache';Root=(Join-Path $local 'Microsoft\Windows\Explorer');Patterns=@('thumbcache_*.db','iconcache_*.db');Recurse=$false;Scheduled=$false;Description='Current-user thumbnail and icon caches; files in use are skipped'}
[pscustomobject]@{Category='PowerShellHistory';Root=(Join-Path $roaming 'Microsoft\Windows\PowerShell\PSReadLine');Patterns=@('*_history.txt');Recurse=$false;Scheduled=$false;Description='Current-user PowerShell interactive command history'}
foreach($target in Get-WindBrowserCacheCatalog){$target}
foreach($root in @((Join-Path $local 'MsPrivate\apps'),(Join-Path $local 'MsPrivate\cleanup'))){[pscustomobject]@{Category='WindReports';Root=$root;Patterns=@('*.json');Recurse=$true;Scheduled=$true;Description='Expired ms-private operation reports; policy, hardware, Recall and network recovery journals are excluded'}}
[pscustomobject]@{Category='WindReports';Root=(Join-Path $local 'MsPrivate\network');Patterns=@('capture.etl','capture.pcapng','connections.json');Recurse=$true;Scheduled=$true;Description='Expired ms-private packet captures and connection snapshots; firewall recovery journals are excluded'}
}
function Test-WindCleanupName {param([string]$Name,[string[]]$Patterns) foreach($pattern in $Patterns){if($Name-like$pattern){return $true}};return $false}
function Test-WindCleanupPathSafe {
param([Parameter(Mandatory)][string]$Path,[Parameter(Mandatory)][string]$Root)
$fullPath=[IO.Path]::GetFullPath($Path);$fullRoot=[IO.Path]::GetFullPath($Root).TrimEnd('\')
if(-not$fullPath.StartsWith($fullRoot+'\',[StringComparison]::OrdinalIgnoreCase)){return $false}
$cursor=[IO.Path]::GetDirectoryName($fullPath)
while($cursor-and($cursor.Length-ge$fullRoot.Length)){
try{$item=Get-Item -LiteralPath $cursor -Force -ErrorAction Stop}catch{return $false}
if($item.Attributes-band[IO.FileAttributes]::ReparsePoint){return $false}
if($cursor.Equals($fullRoot,[StringComparison]::OrdinalIgnoreCase)){return $true}
$next=[IO.Path]::GetDirectoryName($cursor);if($next-eq$cursor){break};$cursor=$next
}
return $false
}
function Get-WindCleanupPlan {
param([ValidateRange(0,365)][int]$AgeDays=7,[ValidateSet('Temp','CrashReports','RecentItems','ThumbnailCache','PowerShellHistory','BrowserCache','WindReports')][string[]]$Category=@('Temp'),[string[]]$Root,[datetime]$Now=(Get-Date))
$cutoff=$Now.AddDays(-$AgeDays);$catalog=@(Get-WindPrivacyCleanupCatalog|Where-Object{$Category-contains$_.Category})
if($PSBoundParameters.ContainsKey('Root')){
if(@($Category|Select-Object -Unique).Count-ne 1-or$Category[0]-ne'Temp'){throw '-Root is available only for the Temp category.'}
$allowed=@(Get-WindCleanupRoots|ForEach-Object{[IO.Path]::GetFullPath($_).TrimEnd('\')});$catalog=@($Root|ForEach-Object{$full=[IO.Path]::GetFullPath($_).TrimEnd('\');if($allowed-notcontains$full){throw "Cleanup root is not allowlisted: $full"};[pscustomobject]@{Category='Temp';Root=$full;Patterns=@('*');Recurse=$true}})
}
$files=[Collections.Generic.List[object]]::new();$errors=[Collections.Generic.List[object]]::new();$seen=@{}
foreach($target in $catalog){
$full=[IO.Path]::GetFullPath([string]$target.Root).TrimEnd('\');if(-not(Test-Path -LiteralPath $full -PathType Container)){continue}
try{
$rootItem=Get-Item -LiteralPath $full -Force -ErrorAction Stop;if($rootItem.Attributes-band[IO.FileAttributes]::ReparsePoint){throw 'Cleanup root is a reparse point and was skipped.'}
$queue=[Collections.Generic.Queue[string]]::new();$queue.Enqueue($full)
while($queue.Count){
$directory=$queue.Dequeue()
foreach($child in @(Get-ChildItem -LiteralPath $directory -Force -ErrorAction SilentlyContinue)){
if($child.Attributes-band[IO.FileAttributes]::ReparsePoint){continue}
if($child.PSIsContainer){if($target.Recurse){$childPath=[IO.Path]::GetFullPath($child.FullName);if($childPath.StartsWith($full+'\',[StringComparison]::OrdinalIgnoreCase)){$queue.Enqueue($childPath)}};continue}
if($child.LastWriteTime-ge$cutoff-or-not(Test-WindCleanupName $child.Name @($target.Patterns))){continue}
$path=[IO.Path]::GetFullPath($child.FullName);if(-not$path.StartsWith($full+'\',[StringComparison]::OrdinalIgnoreCase)-or$seen[$path]){continue};$seen[$path]=$true
$files.Add([pscustomobject]@{Path=$path;Root=$full;Category=[string]$target.Category;Length=[long]$child.Length;LastWriteUtc=$child.LastWriteTimeUtc.ToString('o')})
}
}
}catch{$errors.Add([pscustomobject]@{Category=$target.Category;Root=$full;Error=$_.Exception.Message})}
}
$totalBytes=[long]0;foreach($file in $files){$totalBytes+=[long]$file.Length}
[pscustomobject]@{Schema=2;CreatedUtc=[DateTime]::UtcNow.ToString('o');AgeDays=$AgeDays;CutoffUtc=$cutoff.ToUniversalTime().ToString('o');Categories=@($Category|Select-Object -Unique);FileCount=$files.Count;Bytes=$totalBytes;Files=@($files);Errors=@($errors);Exclusions='Documents, Downloads, Desktop, non-cache browser profile data, credential stores, recovery journals, event logs, Defender logs, update logs and authentication logs are excluded. Reparse points, changed files and locked files are skipped.'}
}
function Invoke-WindCleanup {
[CmdletBinding(SupportsShouldProcess)]param([Parameter(Mandatory)]$Plan,[Parameter(Mandatory)][string]$ReportPath)
if($Plan.Schema-ne 2-or$Plan.AgeDays-lt 0){throw 'Invalid cleanup plan.'};$valid=@('Temp','CrashReports','RecentItems','ThumbnailCache','PowerShellHistory','BrowserCache','WindReports');if(@($Plan.Categories|Where-Object{$_-notin$valid}).Count){throw 'Cleanup plan contains an unknown category.'}
$catalog=@(Get-WindPrivacyCleanupCatalog|Where-Object{$Plan.Categories-contains$_.Category});$cutoff=[datetime]::Parse($Plan.CutoffUtc).ToUniversalTime()
$report=[pscustomobject]@{Schema=2;Kind='WindCleanup';CreatedUtc=[DateTime]::UtcNow.ToString('o');Categories=@($Plan.Categories);Status='Running';Deleted=0;Skipped=0;FreedBytes=[long]0;Entries=@()}
$save={param($x)Write-MsPrivateJsonAtomic -InputObject $x -Path $ReportPath -Depth 7}.GetNewClosure();&$save $report
foreach($entry in @($Plan.Files)){
$path=[IO.Path]::GetFullPath([string]$entry.Path);$root=[IO.Path]::GetFullPath([string]$entry.Root).TrimEnd('\');$matches=@($catalog|Where-Object{[IO.Path]::GetFullPath([string]$_.Root).TrimEnd('\')-eq$root-and$_.Category-eq$entry.Category-and(Test-WindCleanupName ([IO.Path]::GetFileName($path)) @($_.Patterns))})
$out=[pscustomobject]@{Category=$entry.Category;Path=$path;Status='Checking';Reason=$null};$report.Entries+=@($out);&$save $report
try{
if($matches.Count-ne 1-or-not(Test-WindCleanupPathSafe -Path $path -Root $root)){throw 'Path escaped an allowlisted category target or crosses a reparse point.'}
$item=Get-Item -LiteralPath $path -Force -ErrorAction Stop;if($item.PSIsContainer-or($item.Attributes-band[IO.FileAttributes]::ReparsePoint)-or$item.LastWriteTimeUtc-ge$cutoff){$out.Status='Skipped';$out.Reason='Changed since plan or not a regular old file.';$report.Skipped++;continue}
if($PSCmdlet.ShouldProcess($path,'Delete selected privacy artifact')){$length=[long]$item.Length;Remove-Item -LiteralPath $path -Force -ErrorAction Stop;$out.Status='Deleted';$report.Deleted++;$report.FreedBytes+=$length}else{$out.Status='Preview'}
}catch{$out.Status='Skipped';$out.Reason=$_.Exception.Message;$report.Skipped++}finally{&$save $report}
}
$report.Status='Completed';&$save $report;$report
}
function Clear-WindCurrentClipboard {
Add-Type -AssemblyName PresentationCore -ErrorAction Stop
$last=$null
for($i=0;$i-lt 5;$i++){try{[Windows.Clipboard]::Clear();return}catch{$last=$_;Start-Sleep -Milliseconds 100}}
throw "Clipboard is busy; it was not cleared. $($last.Exception.Message)"
}
function Install-WindCleanupSchedule {
param([ValidateRange(1,365)][int]$AgeDays=7,[ValidateSet('Daily','Weekly')][string]$Frequency='Daily',[Parameter(Mandatory)][string]$SourceRoot)
$runtime=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'MsPrivate\cleanup-runtime';[IO.Directory]::CreateDirectory($runtime)|Out-Null
foreach($rel in @('Cleanup.ps1','src\Wind.Cleanup.psm1','src\Wind.Core.psm1','src\MsPrivate.IO.psm1')){$dst=Join-Path $runtime $rel;[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($dst))|Out-Null;Copy-Item -LiteralPath (Join-Path $SourceRoot $rel) -Destination $dst -Force}
$action=New-ScheduledTaskAction -Execute 'powershell.exe' -Argument ('-NoProfile -ExecutionPolicy RemoteSigned -File "{0}" -Mode Run -AgeDays {1} -Category Temp,WindReports' -f (Join-Path $runtime 'Cleanup.ps1'),$AgeDays)
$trigger=if($Frequency-eq'Daily'){New-ScheduledTaskTrigger -Daily -At '03:00'}else{New-ScheduledTaskTrigger -Weekly -DaysOfWeek Sunday -At '03:00'}
Register-ScheduledTask -TaskName 'ms-private Safe Cleanup' -Action $action -Trigger $trigger -Description 'Deletes old regular files from allowlisted Temp paths and expired ms-private reports; security logs and recovery journals are excluded.' -Force -ErrorAction Stop|Out-Null
}
function Remove-WindCleanupSchedule {Unregister-ScheduledTask -TaskName 'ms-private Safe Cleanup' -Confirm:$false -ErrorAction SilentlyContinue}
Export-ModuleMember -Function Get-WindCleanupRoots,Get-WindBrowserCacheCatalog,Get-WindPrivacyCleanupCatalog,Get-WindCleanupPlan,Invoke-WindCleanup,Clear-WindCurrentClipboard,Install-WindCleanupSchedule,Remove-WindCleanupSchedule
+29 -20
View File
@@ -1,5 +1,6 @@
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
Import-Module (Join-Path $PSScriptRoot 'MsPrivate.IO.psm1') -ErrorAction Stop
function Assert-WindMutationAllowed {
if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { throw 'Windows is required.' }
@@ -16,7 +17,25 @@ function Assert-WindMutationAllowed {
}
function Get-WindCatalog {
$items = Get-Content -LiteralPath (Join-Path $PSScriptRoot '../config/policies.json') -Raw | ConvertFrom-Json
param([string]$Path=(Join-Path $PSScriptRoot '../config/policies.json'))
[object[]]$items = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
if (-not $items.Count) { throw 'Policy catalog is empty.' }
$ids=@{};$targets=@{}
foreach($item in $items){
$id=[string]$item.Id;$registryPath=[string]$item.Path;$name=[string]$item.Name
$hive=if($item.PSObject.Properties['Hive']){[string]$item.Hive}else{'LocalMachine'}
$target=($hive+'\'+$registryPath+'\'+$name).ToLowerInvariant()
if($id-notmatch'^[a-z0-9][a-z0-9-]*$'-or$ids.ContainsKey($id)){throw "Invalid or duplicate policy ID: $id"}
if($hive-notin@('LocalMachine','CurrentUser')){throw "Invalid policy registry hive: $id"}
if($registryPath-notmatch'^(SOFTWARE|SYSTEM)\\[A-Za-z0-9 _.-]+(?:\\[A-Za-z0-9 _.-]+)*$'-or$registryPath-match'(^|\\)\.\.?($|\\)'){throw "Invalid policy registry path: $id"}
if($name-notmatch'^[A-Za-z0-9_]+$'-or$targets.ContainsKey($target)){throw "Invalid or duplicate policy registry target: $id"}
foreach($profile in @('Standard','Strict')){$value=$item.$profile;if($null-eq$value){continue};if($value-isnot[ValueType]-or[double]$value-ne[int]$value-or[int]$value-lt0){throw "Invalid $profile policy value: $id"}}
if($null-ne$item.EnterpriseValue-and($item.EnterpriseValue-isnot[ValueType]-or[double]$item.EnterpriseValue-ne[int]$item.EnterpriseValue-or[int]$item.EnterpriseValue-lt0)){throw "Invalid Enterprise policy value: $id"}
if($null-eq$item.Standard-and$null-eq$item.Strict-and$null-eq$item.EnterpriseValue-and$id-notin@('updates-auto','updates-options')){throw "Policy has no selectable value: $id"}
$source=$null;if(-not[uri]::TryCreate([string]$item.Source,[UriKind]::Absolute,[ref]$source)-or$source.Scheme-ne'https'){throw "Policy requires an HTTPS source: $id"}
$exceptions=@{};foreach($exception in @($item.Exceptions)){$value=[string]$exception;if($value-notmatch'^[A-Za-z0-9_]+$'-or$exceptions.ContainsKey($value)){throw "Invalid or duplicate policy exception: $id"};$exceptions[$value]=$true}
$ids[$id]=$true;$targets[$target]=$true
}
foreach ($item in $items) { $item }
}
@@ -35,11 +54,13 @@ function Get-WindContext {
}
function New-WindRegistryStore {
# Explicit 64-bit registry view; never fall through to HKCU or a redirected view.
# Explicit 64-bit registry view. The catalog selects only HKLM or the invoking user's HKCU.
@{
Read = {
param($Policy)
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey('LocalMachine', 'Registry64')
$hive=if($Policy.PSObject.Properties['Hive']){[string]$Policy.Hive}else{'LocalMachine'}
if($hive-notin@('LocalMachine','CurrentUser')){throw 'Untrusted registry hive.'}
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey($hive, 'Registry64')
try {
$key = $base.OpenSubKey($Policy.Path, $false)
try {
@@ -56,7 +77,9 @@ function New-WindRegistryStore {
}
Write = {
param($Policy, $State)
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey('LocalMachine', 'Registry64')
$hive=if($Policy.PSObject.Properties['Hive']){[string]$Policy.Hive}else{'LocalMachine'}
if($hive-notin@('LocalMachine','CurrentUser')){throw 'Untrusted registry hive.'}
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey($hive, 'Registry64')
try {
if ($State.Exists) {
$key = $base.CreateSubKey($Policy.Path)
@@ -110,7 +133,7 @@ function Get-WindPlan {
$status = 'Conflict'; $reason = 'Existing value is not a DWORD; it will not be overwritten.'
} elseif ($before.Exists -and $before.Value -eq $desired) { $status = 'Configured' }
foreach ($exceptionName in $policy.Exceptions) {
$exception = & $Store.Read ([pscustomobject]@{ Path=$policy.Path; Name=$exceptionName })
$exception = & $Store.Read ([pscustomobject]@{ Hive=if($policy.PSObject.Properties['Hive']){$policy.Hive}else{'LocalMachine'}; Path=$policy.Path; Name=$exceptionName })
if ($exception.Exists -and -not [string]::IsNullOrWhiteSpace(($exception.Value -join ''))) {
$status = 'Conflict'; $reason = 'Existing per-app exceptions need review; default deny would not cover all apps.'
}
@@ -131,21 +154,7 @@ function Get-WindPlan {
function Save-WindJournal {
param([Parameter(Mandatory)]$Journal, [Parameter(Mandatory)][string]$Path)
$fullPath = [IO.Path]::GetFullPath($Path)
$tempPath = $fullPath + '.' + [guid]::NewGuid().ToString('N') + '.tmp'
$json = $Journal | ConvertTo-Json -Depth 12
# Flush the write-ahead journal before registry mutation; publish atomically on NTFS.
$bytes = [Text.UTF8Encoding]::new($false).GetBytes($json)
$stream = [IO.FileStream]::new($tempPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
try { $stream.Write($bytes,0,$bytes.Length); $stream.Flush($true) } finally { $stream.Dispose() }
if ([IO.File]::Exists($fullPath)) {
# Windows PowerShell can marshal a null string argument as an empty path.
# Use a unique backup path for portable File.Replace behavior.
$backupPath = $tempPath + '.previous'
[IO.File]::Replace($tempPath, $fullPath, $backupPath)
try { [IO.File]::Delete($backupPath) }
catch { Write-Warning "Journal saved, but its previous copy remains at $backupPath" }
} else { [IO.File]::Move($tempPath, $fullPath) }
Write-MsPrivateJsonAtomic -InputObject $Journal -Path $Path -Depth 12
}
function Assert-WindJournal {
+39
View File
@@ -0,0 +1,39 @@
Set-StrictMode -Version Latest
$ErrorActionPreference='Stop'
Import-Module (Join-Path $PSScriptRoot 'MsPrivate.IO.psm1') -ErrorAction Stop
function Get-WindSensorInventory {
if(-not (Get-Command Get-PnpDevice -ErrorAction SilentlyContinue)){return}
foreach($d in @(Get-PnpDevice -PresentOnly -ErrorAction Stop)){
$name=[string]$d.FriendlyName;$class=[string]$d.Class
$kind=$null
if($class -in @('Camera','Image')){$kind='Camera'}
elseif($class -eq 'AudioEndpoint' -and [string]$d.InstanceId -match '\{0\.0\.1\.'){$kind='Microphone'}
elseif($class -eq 'Media' -and $name -match '(?i)microphone|mic array|array mic'){$kind='Microphone'}
elseif($class -eq 'Sensor'){$kind='Sensor'}
if($kind){[pscustomobject]@{InstanceId=[string]$d.InstanceId;Name=$name;Class=$class;Kind=$kind;Status=[string]$d.Status;Risk=if($name -match '(?i)hello|infrared|(^|\s)IR(\s|$)'){'May disable Windows Hello sign-in'}else{'Applications lose access to this hardware device'}}}
}
}
function Get-WindSensorPlan {
param([string[]]$InstanceId=@())
$inventory=@(Get-WindSensorInventory);$seen=@{}
foreach($id in $InstanceId){if($seen[$id]){throw 'Duplicate hardware selection.'};$seen[$id]=$true;if(@($inventory|Where-Object InstanceId -ceq $id).Count -ne 1){throw "Selected device is absent or ambiguous: $id"}}
foreach($d in $inventory){[pscustomobject]@{InstanceId=$d.InstanceId;Name=$d.Name;Kind=$d.Kind;Status=if($InstanceId -contains $d.InstanceId){if($d.Status -eq 'OK'){'WouldDisable'}else{'AlreadyUnavailable'}}else{'NotSelected'};Risk=$d.Risk}}
}
function Invoke-WindSensorApply {
[CmdletBinding(SupportsShouldProcess)]param([Parameter(Mandatory)][string[]]$InstanceId,[Parameter(Mandatory)][string]$JournalPath)
$plan=@(Get-WindSensorPlan -InstanceId $InstanceId);if(@($plan|Where-Object Status -eq 'AlreadyUnavailable').Count){throw 'One or more selected devices are already unavailable; select only enabled devices.'}
if(-not $PSCmdlet.ShouldProcess('Selected PnP devices','Disable exact camera, microphone or sensor devices')){return $plan}
$j=[pscustomobject]@{Schema=1;Kind='WindSensors';CreatedUtc=[DateTime]::UtcNow.ToString('o');Status='Prepared';Entries=@($plan|Where-Object Status -eq 'WouldDisable'|ForEach-Object{[pscustomobject]@{InstanceId=$_.InstanceId;Name=$_.Name;Status='Pending'}})}
$save={param($x)Write-MsPrivateJsonAtomic -InputObject $x -Path $JournalPath -Depth 7}.GetNewClosure();&$save $j
try{foreach($e in $j.Entries){$fresh=@(Get-WindSensorInventory|Where-Object InstanceId -ceq $e.InstanceId);if($fresh.Count-ne 1-or$fresh[0].Status-ne'OK'){throw "Device state changed: $($e.Name)"};$e.Status='Disabling';&$save $j;Disable-PnpDevice -InstanceId $e.InstanceId -Confirm:$false -ErrorAction Stop|Out-Null;$after=Get-PnpDevice -InstanceId $e.InstanceId -ErrorAction Stop;if($after.Status-eq'OK'){throw "Device still reports enabled after disable: $($e.Name)"};$e.Status='Disabled';&$save $j};$j.Status='Applied';&$save $j;$j}catch{$j.Status='ApplyFailed';&$save $j;throw}
}
function Restore-WindSensors {
[CmdletBinding(SupportsShouldProcess)]param([Parameter(Mandatory)]$Journal,[Parameter(Mandatory)][scriptblock]$Save)
if($Journal.Schema-ne 1-or$Journal.Kind-ne'WindSensors'){throw 'Invalid sensor journal.'};if(-not$PSCmdlet.ShouldProcess('Selected PnP devices','Re-enable devices disabled by this journal')){return}
foreach($e in @($Journal.Entries)){if($e.Status -notin @('Disabled','Disabling')){continue};$d=Get-PnpDevice -InstanceId $e.InstanceId -ErrorAction Stop;if($d.Status -ne 'OK'){Enable-PnpDevice -InstanceId $e.InstanceId -Confirm:$false -ErrorAction Stop|Out-Null};$after=Get-PnpDevice -InstanceId $e.InstanceId -ErrorAction Stop;if($after.Status-ne'OK'){throw "Device restore readback failed: $($e.Name)"};$e.Status='Restored';&$Save $Journal};$Journal.Status='Restored';&$Save $Journal;$Journal
}
Export-ModuleMember -Function Get-WindSensorInventory,Get-WindSensorPlan,Invoke-WindSensorApply,Restore-WindSensors
+45
View File
@@ -0,0 +1,45 @@
Set-StrictMode -Version Latest
$ErrorActionPreference='Stop'
Import-Module (Join-Path $PSScriptRoot 'MsPrivate.IO.psm1') -ErrorAction Stop
function Test-WindVeraCryptVolume {param([string]$Volume) return $Volume -match '^[A-Z]:$'}
function Resolve-WindVeraCryptPath {
param([string]$Path)
if($Path){$full=[IO.Path]::GetFullPath($Path);if(-not(Test-Path -LiteralPath $full -PathType Leaf)){throw 'Selected VeraCrypt executable does not exist.'};return $full}
$command=Get-Command VeraCrypt.exe -CommandType Application -ErrorAction SilentlyContinue;if($command){return $command.Source}
foreach($root in @($env:ProgramFiles,${env:ProgramFiles(x86)})){if($root){$candidate=Join-Path $root 'VeraCrypt\VeraCrypt.exe';if(Test-Path -LiteralPath $candidate -PathType Leaf){return $candidate}}}
return $null
}
function Invoke-WindLockEvent {
param([string[]]$VeraCryptVolume=@(),[string]$VeraCryptPath,[Parameter(Mandatory)][string]$ReportPath)
foreach($v in $VeraCryptVolume){if(-not(Test-WindVeraCryptVolume $v)){throw "Invalid VeraCrypt volume: $v"}}
$result=[pscustomobject]@{Schema=1;Kind='WindLockEvent';OccurredUtc=[DateTime]::UtcNow.ToString('o');Clipboard='Pending';Volumes=@();Status='Running'}
try{Add-Type -AssemblyName PresentationCore -ErrorAction Stop;[Windows.Clipboard]::Clear();$result.Clipboard='Cleared'}catch{$result.Clipboard='Failed: '+$_.Exception.Message}
foreach($v in $VeraCryptVolume){$entry=[pscustomobject]@{Volume=$v;Status='Pending'};$result.Volumes+=@($entry);try{if(-not$VeraCryptPath-or-not(Test-Path -LiteralPath $VeraCryptPath -PathType Leaf)){throw 'VeraCrypt executable unavailable.'};$p=Start-Process -FilePath $VeraCryptPath -ArgumentList @('/d',$v,'/q','/s') -Wait -PassThru -WindowStyle Hidden;if($p.ExitCode-ne 0){throw "VeraCrypt exit code $($p.ExitCode)"};$entry.Status='Unmounted'}catch{$entry.Status='Failed: '+$_.Exception.Message}}
$result.Status=if($result.Clipboard-eq'Cleared'-and-not@($result.Volumes|Where-Object Status -like 'Failed*').Count){'Completed'}else{'NeedsAttention'}
Write-MsPrivateJsonAtomic -InputObject $result -Path $ReportPath -Depth 6;$result
}
function Install-WindLockHandler {
param([string[]]$VeraCryptVolume=@(),[string]$VeraCryptPath,[Parameter(Mandatory)][string]$SourceRoot)
foreach($v in $VeraCryptVolume){if(-not(Test-WindVeraCryptVolume $v)){throw "Invalid VeraCrypt volume: $v"}}
if($VeraCryptVolume.Count){$VeraCryptPath=Resolve-WindVeraCryptPath $VeraCryptPath;if(-not$VeraCryptPath){throw 'VeraCrypt is not installed or VeraCrypt.exe was not supplied. No lock task was installed.'}}
$runtime=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'MsPrivate\lock';[IO.Directory]::CreateDirectory($runtime)|Out-Null
foreach($rel in @('Lock-Event.ps1','src\Wind.Lock.psm1','src\MsPrivate.IO.psm1')){$dst=Join-Path $runtime $rel;[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($dst))|Out-Null;Copy-Item -LiteralPath (Join-Path $SourceRoot $rel) -Destination $dst -Force}
$cfg=[pscustomobject]@{Schema=1;VeraCryptVolume=@($VeraCryptVolume);VeraCryptPath=$VeraCryptPath};$cfgPath=Join-Path $runtime 'config.json';Write-MsPrivateJsonAtomic -InputObject $cfg -Path $cfgPath -Depth 4
$sid=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value;$script=Join-Path $runtime 'Lock-Event.ps1';$scriptXml=[Security.SecurityElement]::Escape($script);$cfgXml=[Security.SecurityElement]::Escape($cfgPath)
$xml=@"
<?xml version="1.0" encoding="UTF-16"?>
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
<RegistrationInfo><Description>Clears the current user's clipboard and gracefully unmounts selected VeraCrypt volumes on session lock.</Description></RegistrationInfo>
<Triggers><SessionStateChangeTrigger><Enabled>true</Enabled><StateChange>SessionLock</StateChange><UserId>$sid</UserId></SessionStateChangeTrigger></Triggers>
<Principals><Principal id="Author"><UserId>$sid</UserId><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>
<Settings><MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy><DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries><StopIfGoingOnBatteries>false</StopIfGoingOnBatteries><AllowHardTerminate>true</AllowHardTerminate><StartWhenAvailable>true</StartWhenAvailable><ExecutionTimeLimit>PT2M</ExecutionTimeLimit></Settings>
<Actions Context="Author"><Exec><Command>powershell.exe</Command><Arguments>-NoProfile -STA -ExecutionPolicy RemoteSigned -File &quot;$scriptXml&quot; -Config &quot;$cfgXml&quot;</Arguments></Exec></Actions>
</Task>
"@
Register-ScheduledTask -TaskName 'ms-private Session Lock' -Xml $xml -Force -ErrorAction Stop|Out-Null
}
function Remove-WindLockHandler {Unregister-ScheduledTask -TaskName 'ms-private Session Lock' -Confirm:$false -ErrorAction SilentlyContinue}
Export-ModuleMember -Function Test-WindVeraCryptVolume,Resolve-WindVeraCryptPath,Invoke-WindLockEvent,Install-WindLockHandler,Remove-WindLockHandler
+170
View File
@@ -0,0 +1,170 @@
Set-StrictMode -Version Latest
$ErrorActionPreference='Stop'
Import-Module (Join-Path $PSScriptRoot 'MsPrivate.IO.psm1') -ErrorAction Stop
function Get-WindNetworkCatalog {
param([string]$Path=(Join-Path $PSScriptRoot '../config/network.json'))
$catalog=Get-Content -LiteralPath $Path -Raw|ConvertFrom-Json
if($catalog.Schema-ne 1-or[string]$catalog.RuleGroup-ne'ms-private'){throw 'Unsupported network catalog schema or rule group.'}
if(-not@($catalog.OptionalTelemetry).Count){throw 'Network catalog has no optional telemetry targets.'}
$ids=@{};$hosts=@{}
# These suffixes carry updating, trust/revocation, Defender/SmartScreen or WNS traffic.
# Optional telemetry catalogs must never target them.
$protectedSuffixes=@(
'windowsupdate.com','update.microsoft.com','delivery.mp.microsoft.com',
'definitionupdates.microsoft.com','wdcp.microsoft.com','smartscreen.microsoft.com',
'wns.windows.com','storequality.microsoft.com'
)
foreach($item in @($catalog.OptionalTelemetry)){
$id=[string]$item.Id;$fqdn=[string]$item.Fqdn
if($id-notmatch'^[a-z0-9][a-z0-9-]*$'-or$ids.ContainsKey($id)){throw "Invalid or duplicate network target ID: $id"}
if($fqdn-notmatch'^(?=.{1,253}$)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}$'-or$hosts.ContainsKey($fqdn.ToLowerInvariant())){throw "Invalid or duplicate network target FQDN: $fqdn"}
$normalized=$fqdn.ToLowerInvariant()
foreach($suffix in $protectedSuffixes){if($normalized-eq$suffix-or$normalized.EndsWith('.'+$suffix)){throw "Network target overlaps a protected security/update endpoint: $id"}}
$source=$null;if(-not[uri]::TryCreate([string]$item.Source,[UriKind]::Absolute,[ref]$source)-or$source.Scheme-ne'https'){throw "Network target requires an HTTPS source: $id"}
$ids[$id]=$true;$hosts[$fqdn.ToLowerInvariant()]=$true
}
$catalog
}
function Get-WindNetworkCapabilities {
$required=@('Get-NetFirewallProfile','Set-NetFirewallProfile','New-NetFirewallRule','Get-NetFirewallRule','Remove-NetFirewallRule')
$missing=@($required | Where-Object {-not (Get-Command $_ -ErrorAction SilentlyContinue)})
[pscustomobject]@{
FirewallAvailable=($missing.Count -eq 0)
DynamicFqdnAvailable=[bool](Get-Command New-NetFirewallDynamicKeywordAddress -ErrorAction SilentlyContinue)
ScheduledTaskAvailable=(-not @(@('New-ScheduledTaskAction','New-ScheduledTaskTrigger','New-ScheduledTaskPrincipal','Register-ScheduledTask','Unregister-ScheduledTask')|Where-Object{-not(Get-Command $_ -ErrorAction SilentlyContinue)}).Count)
PktmonAvailable=[bool](Get-Command pktmon.exe -ErrorAction SilentlyContinue)
Missing=@($missing)
}
}
function Get-WindNetworkPlan {
param([ValidateSet('Observe','BlockOptionalTelemetry','Isolate')][string]$Profile='Observe',[int]$RollbackMinutes=10)
if($RollbackMinutes -lt 2 -or $RollbackMinutes -gt 60){throw 'RollbackMinutes must be between 2 and 60.'}
$cap=Get-WindNetworkCapabilities
$items=[Collections.Generic.List[object]]::new()
if($Profile -eq 'Observe'){
$items.Add([pscustomobject]@{Id='observe';Status='Ready';Action='No firewall changes';Impact='Capture and inventory only.'})
} elseif($Profile -eq 'BlockOptionalTelemetry'){
$status=if($cap.FirewallAvailable -and $cap.DynamicFqdnAvailable){'WouldChange'}else{'Unsupported'}
foreach($target in @(Get-WindNetworkCatalog).OptionalTelemetry){
$items.Add([pscustomobject]@{Id=$target.Id;Status=$status;Action=('Block outbound FQDN '+$target.Fqdn);Impact=$target.Purpose;Source=$target.Source})
}
} else {
$status=if($cap.FirewallAvailable -and $cap.ScheduledTaskAvailable){'WouldChange'}else{'Unsupported'}
$items.Add([pscustomobject]@{Id='outbound-default';Status=$status;Action='Set default outbound action to Block on Domain, Private and Public profiles';Impact='Breaks most network activity until explicit allows exist. A local timed rollback is mandatory.'})
$items.Add([pscustomobject]@{Id='timed-rollback';Status=$status;Action="Restore prior firewall defaults after $RollbackMinutes minutes";Impact='Recovery runs locally as SYSTEM and does not require network access.'})
}
[pscustomobject]@{Schema=1;Profile=$Profile;Capabilities=$cap;RollbackMinutes=$RollbackMinutes;Items=@($items);Claim='Configuration preview only. No claim of zero telemetry or anonymity.'}
}
function New-WindNetworkBackend {
@{
ReadProfiles={ @(Get-NetFirewallProfile -PolicyStore ActiveStore | Select-Object Name,Enabled,DefaultInboundAction,DefaultOutboundAction) }
SetOutbound={param($name,$action) Set-NetFirewallProfile -Name $name -DefaultOutboundAction $action -ErrorAction Stop}
AddKeyword={param($id,$fqdn) New-NetFirewallDynamicKeywordAddress -Id $id -Keyword $fqdn -AutoResolve $true -ErrorAction Stop | Out-Null}
RemoveKeyword={param($id) $x=Get-NetFirewallDynamicKeywordAddress -Id $id -ErrorAction SilentlyContinue;if($x){$x|Remove-NetFirewallDynamicKeywordAddress -Confirm:$false -ErrorAction Stop}}
AddBlockRule={param($name,$id,$group) New-NetFirewallRule -Name $name -DisplayName $name -Group $group -Direction Outbound -Action Block -Enabled True -Profile Any -RemoteDynamicKeywordAddresses $id -ErrorAction Stop | Out-Null}
RemoveRules={param($group) Get-NetFirewallRule -Group $group -ErrorAction SilentlyContinue|Remove-NetFirewallRule -Confirm:$false -ErrorAction Stop}
ReadRules={param($group) @(Get-NetFirewallRule -Group $group -ErrorAction SilentlyContinue|Select-Object Name,DisplayName,Enabled,Direction,Action,Profile)}
ExportPolicy={param($path)& netsh.exe advfirewall export $path|Out-Null;if($LASTEXITCODE-ne 0){throw "Firewall export failed with exit code $LASTEXITCODE."}}
RegisterRollback={param($taskName,$script,$journal,$minutes)
$action=New-ScheduledTaskAction -Execute 'powershell.exe' -Argument ('-NoProfile -ExecutionPolicy Bypass -File "{0}" -Mode Restore -Journal "{1}" -FromRecoveryTask' -f $script,$journal)
$trigger=New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes($minutes)
$principal=New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Principal $principal -Force -ErrorAction Stop|Out-Null
}
RemoveRollback={param($taskName) Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue}
}
}
function Assert-WindNetworkJournal {
param([Parameter(Mandatory)]$Journal)
if($Journal.Schema-ne 1-or$Journal.Kind-ne'MsPrivateNetwork'-or$Journal.Profile-notin@('BlockOptionalTelemetry','Isolate')){throw 'Invalid network journal.'}
if($Journal.RuleGroup-ne'ms-private'){throw 'Unexpected network rule group.'}
if($Journal.RollbackTask -and [string]$Journal.RollbackTask -notmatch '^ms-private Network Recovery [0-9a-f]{32}$'){throw 'Invalid network recovery task name.'}
if($Journal.Profile-eq'Isolate'){
$profiles=@($Journal.Profiles);if($profiles.Count-ne 3-or@($profiles.Name|Sort-Object)-join','-ne'Domain,Private,Public'){throw 'Network journal must contain each firewall profile exactly once.'}
foreach($p in $profiles){if([string]$p.DefaultOutboundAction-notin@('Allow','Block','NotConfigured')){throw 'Invalid saved outbound action.'}}
} else {
foreach($item in @($Journal.KeywordIds)){$parsed=[guid]::Empty;if(-not[guid]::TryParse([string]$item.Id,[ref]$parsed)-or[string]$item.Rule-notmatch'^ms-private telemetry-'){throw 'Invalid dynamic keyword entry.'}}
}
}
function Invoke-WindNetworkApply {
[CmdletBinding(SupportsShouldProcess)]
param([ValidateSet('BlockOptionalTelemetry','Isolate')][string]$Profile,[int]$RollbackMinutes=10,[hashtable]$Backend=(New-WindNetworkBackend),[Parameter(Mandatory)][string]$JournalPath,[Parameter(Mandatory)][string]$RecoveryScript)
$plan=Get-WindNetworkPlan -Profile $Profile -RollbackMinutes $RollbackMinutes
if(@($plan.Items|Where-Object Status -ne 'WouldChange').Count){throw 'Required network capabilities are unavailable.'}
if(-not $PSCmdlet.ShouldProcess('Windows Firewall',"Apply $Profile network profile")){return $plan}
$catalog=Get-WindNetworkCatalog
if(@(& $Backend.ReadRules $catalog.RuleGroup).Count){throw 'ms-private firewall rules already exist. Restore their journal before applying another network profile.'}
$before=@(& $Backend.ReadProfiles)
$backup=$JournalPath+'.firewall.wfw'
if($Profile-eq'Isolate'){&$Backend.ExportPolicy $backup}
$journal=[pscustomobject]@{Schema=1;Kind='MsPrivateNetwork';CreatedUtc=[DateTime]::UtcNow.ToString('o');Profile=$Profile;Status='Prepared';RuleGroup=$catalog.RuleGroup;KeywordIds=@();Profiles=$before;RollbackTask=('ms-private Network Recovery '+[guid]::NewGuid().ToString('N'));BackupPath=if($Profile-eq'Isolate'){$backup}else{$null}}
$save={param($x) Write-MsPrivateJsonAtomic -InputObject $x -Path $JournalPath -Depth 8}.GetNewClosure()
& $save $journal
try{
if($Profile -eq 'BlockOptionalTelemetry'){
foreach($target in @($catalog.OptionalTelemetry)){
$id='{'+[guid]::NewGuid().ToString()+'}'; & $Backend.AddKeyword $id ([string]$target.Fqdn)
$journal.KeywordIds+=@([pscustomobject]@{Id=$id;Rule=('ms-private '+$target.Id)});& $save $journal
& $Backend.AddBlockRule ('ms-private '+$target.Id) $id $catalog.RuleGroup
}
} else {
& $Backend.RegisterRollback $journal.RollbackTask $RecoveryScript $JournalPath $RollbackMinutes
foreach($name in @('Domain','Private','Public')){& $Backend.SetOutbound $name 'Block'}
}
$journal.Status='Applied';& $save $journal
return $journal
}catch{
$journal.Status='ApplyFailed';& $save $journal
try{Restore-WindNetwork -Journal $journal -Backend $Backend -Save $save|Out-Null}catch{}
throw
}
}
function Restore-WindNetwork {
param([Parameter(Mandatory)]$Journal,[hashtable]$Backend=(New-WindNetworkBackend),[scriptblock]$Save)
Assert-WindNetworkJournal $Journal
if(-not $Save){$Save={param($x)}}
if($Journal.Profile -eq 'BlockOptionalTelemetry'){
& $Backend.RemoveRules ([string]$Journal.RuleGroup)
foreach($item in @($Journal.KeywordIds)){& $Backend.RemoveKeyword ([string]$item.Id)}
} elseif($Journal.Profile -eq 'Isolate'){
$current=@(& $Backend.ReadProfiles)
foreach($old in @($Journal.Profiles)){
$now=@($current|Where-Object Name -eq $old.Name)
if($now.Count -ne 1){throw "Firewall profile missing during restore: $($old.Name)"}
if([string]$now[0].DefaultOutboundAction -notin @('Block',[string]$old.DefaultOutboundAction)){throw "Firewall profile changed externally: $($old.Name)"}
& $Backend.SetOutbound ([string]$old.Name) ([string]$old.DefaultOutboundAction)
}
} else {throw 'Unknown network journal profile.'}
if($Journal.RollbackTask){& $Backend.RemoveRollback ([string]$Journal.RollbackTask)}
$Journal.Status='Restored';& $Save $Journal
$Journal
}
function Confirm-WindNetwork {
param([Parameter(Mandatory)]$Journal,[hashtable]$Backend=(New-WindNetworkBackend),[Parameter(Mandatory)][scriptblock]$Save)
Assert-WindNetworkJournal $Journal
if($Journal.Profile-ne'Isolate'-or$Journal.Status-ne'Applied'){throw 'Only an applied isolation journal can be confirmed.'}
& $Backend.RemoveRollback ([string]$Journal.RollbackTask);$Journal.Status='AppliedConfirmed';$Journal|Add-Member -NotePropertyName ConfirmedUtc -NotePropertyValue ([DateTime]::UtcNow.ToString('o')) -Force;&$Save $Journal;$Journal
}
function Get-WindNetworkValidation {
param([hashtable]$Backend=(New-WindNetworkBackend))
$catalog=Get-WindNetworkCatalog
[pscustomobject]@{
CapturedUtc=[DateTime]::UtcNow.ToString('o')
Profiles=@(& $Backend.ReadProfiles)
WindRules=@(& $Backend.ReadRules $catalog.RuleGroup)
TcpConnections=@(Get-NetTCPConnection -ErrorAction SilentlyContinue|Select-Object State,LocalAddress,LocalPort,RemoteAddress,RemotePort,OwningProcess)
UdpEndpoints=@(Get-NetUDPEndpoint -ErrorAction SilentlyContinue|Select-Object LocalAddress,LocalPort,OwningProcess)
Meaning='A point-in-time local observation. It does not prove that no traffic occurred.'
}
}
Export-ModuleMember -Function Get-WindNetworkCatalog,Get-WindNetworkCapabilities,Get-WindNetworkPlan,New-WindNetworkBackend,Invoke-WindNetworkApply,Restore-WindNetwork,Confirm-WindNetwork,Get-WindNetworkValidation,Assert-WindNetworkJournal
+61
View File
@@ -0,0 +1,61 @@
Set-StrictMode -Version Latest
$ErrorActionPreference='Stop'
Import-Module (Join-Path $PSScriptRoot 'MsPrivate.IO.psm1') -ErrorAction Stop
function Get-WindRecallState {
$context=Get-WindContext
$feature=$null
try{$feature=Get-WindowsOptionalFeature -Online -FeatureName Recall -ErrorAction Stop}catch{}
$path='SOFTWARE\Policies\Microsoft\Windows\WindowsAI';$name='DisableAIDataAnalysis'
$base=[Microsoft.Win32.RegistryKey]::OpenBaseKey('LocalMachine','Registry64')
try{$key=$base.OpenSubKey($path);try{$exists=[bool]($key -and $key.GetValueNames() -contains $name);$value=if($exists){[int]$key.GetValue($name)}else{$null};$kind=if($exists){$key.GetValueKind($name).ToString()}else{$null}}finally{if($key){$key.Dispose()}}}finally{$base.Dispose()}
$eligibleEdition=$context.Edition -match '^(Professional|Enterprise|Education)'
$eligibleBuild=($context.Build -gt 26100 -or ($context.Build -eq 26100 -and $context.Revision -ge 3915))
[pscustomobject]@{Context=$context;FeaturePresent=[bool]$feature;FeatureState=if($feature){[string]$feature.State}else{'Unavailable'};PolicyExists=$exists;PolicyValue=$value;PolicyKind=$kind;PolicySupported=($eligibleEdition -and $eligibleBuild)}
}
function Get-WindRecallPlan {
param([switch]$RemoveFeature)
$state=Get-WindRecallState
$items=@([pscustomobject]@{Id='recall-policy';Status=if($state.PolicySupported){if($state.PolicyExists -and $state.PolicyValue -eq 1){'Configured'}elseif($state.PolicyExists -and $state.PolicyKind -ne 'DWord'){'Conflict'}else{'WouldChange'}}else{'Unsupported'};Action='Set DisableAIDataAnalysis=1';Impact='Disables Recall and deletes existing Recall snapshots. Snapshot deletion cannot be undone.'})
if($RemoveFeature){$items+= [pscustomobject]@{Id='recall-feature';Status=if(-not $state.FeaturePresent){'Unavailable'}elseif($state.FeatureState -eq 'Disabled'){'Configured'}else{'WouldChange'};Action='Disable optional feature Recall';Impact='May require a reboot. Restore can re-enable the feature, but cannot recover snapshots.'}}
[pscustomobject]@{Schema=1;State=$state;Items=$items;RequiresDeletionAcknowledgement=$true}
}
function Set-WindRecallPolicyState {
param($State)
$base=[Microsoft.Win32.RegistryKey]::OpenBaseKey('LocalMachine','Registry64');$path='SOFTWARE\Policies\Microsoft\Windows\WindowsAI';$name='DisableAIDataAnalysis'
try{
if($State.Exists){$key=$base.CreateSubKey($path);try{$key.SetValue($name,[int]$State.Value,[Microsoft.Win32.RegistryValueKind]::DWord)}finally{$key.Dispose()}}
else{$key=$base.OpenSubKey($path,$true);if($key){try{$key.DeleteValue($name,$false)}finally{$key.Dispose()}}}
}finally{$base.Dispose()}
}
function Invoke-WindRecallApply {
[CmdletBinding(SupportsShouldProcess)]
param([switch]$RemoveFeature,[switch]$AcknowledgeSnapshotDeletion,[Parameter(Mandatory)][string]$JournalPath)
if(-not $AcknowledgeSnapshotDeletion){throw 'Recall changes require -AcknowledgeSnapshotDeletion because disabling the policy deletes existing snapshots.'}
$plan=Get-WindRecallPlan -RemoveFeature:$RemoveFeature
if(@($plan.Items|Where-Object Status -in @('Unsupported','Unavailable','Conflict')).Count){throw 'Recall plan contains unsupported, unavailable or conflicting actions.'}
if(-not $PSCmdlet.ShouldProcess('Windows Recall','Disable Recall and delete existing snapshots')){return $plan}
$s=$plan.State
$j=[pscustomobject]@{Schema=1;Kind='WindRecall';CreatedUtc=[DateTime]::UtcNow.ToString('o');Status='Prepared';Before=[pscustomobject]@{Policy=[pscustomobject]@{Exists=$s.PolicyExists;Kind=$s.PolicyKind;Value=$s.PolicyValue};FeatureState=$s.FeatureState};RemoveFeature=[bool]$RemoveFeature;Irreversible='Existing Recall snapshots deleted by policy cannot be restored.'}
$save={param($x)Write-MsPrivateJsonAtomic -InputObject $x -Path $JournalPath -Depth 7}.GetNewClosure();&$save $j
try{
Set-WindRecallPolicyState ([pscustomobject]@{Exists=$true;Value=1})
$after=Get-WindRecallState;if(-not $after.PolicyExists -or $after.PolicyValue -ne 1){throw 'Recall policy readback failed.'}
if($RemoveFeature -and $s.FeatureState -ne 'Disabled'){Disable-WindowsOptionalFeature -Online -FeatureName Recall -Remove -NoRestart -ErrorAction Stop|Out-Null}
$j.Status='Applied';&$save $j;$j
}catch{$j.Status='ApplyFailed';&$save $j;throw}
}
function Restore-WindRecall {
[CmdletBinding(SupportsShouldProcess)]param([Parameter(Mandatory)]$Journal,[Parameter(Mandatory)][scriptblock]$Save)
if($Journal.Schema -ne 1 -or $Journal.Kind -ne 'WindRecall'){throw 'Invalid Recall journal.'}
if(-not $PSCmdlet.ShouldProcess('Windows Recall','Restore saved Recall configuration')){return}
Set-WindRecallPolicyState $Journal.Before.Policy
if($Journal.RemoveFeature -and $Journal.Before.FeatureState -eq 'Enabled'){Enable-WindowsOptionalFeature -Online -FeatureName Recall -NoRestart -All -ErrorAction Stop|Out-Null}
$Journal.Status='RestoredConfiguration';$Journal.Note='Deleted snapshots cannot be recovered.';&$Save $Journal;$Journal
}
Export-ModuleMember -Function Get-WindRecallState,Get-WindRecallPlan,Invoke-WindRecallApply,Restore-WindRecall
+81
View File
@@ -0,0 +1,81 @@
Set-StrictMode -Version Latest
$ErrorActionPreference='Stop'
function Get-WindRegistryDword {
param([string]$Path,[string]$Name)
try {
$item=Get-ItemProperty -LiteralPath $Path -Name $Name -ErrorAction Stop
[pscustomobject]@{Readable=$true;Configured=$true;Value=[int]$item.$Name}
} catch [Management.Automation.ItemNotFoundException] {
[pscustomobject]@{Readable=$true;Configured=$false;Value=$null}
} catch [Management.Automation.PSArgumentException] {
[pscustomobject]@{Readable=$true;Configured=$false;Value=$null}
} catch {
[pscustomobject]@{Readable=$false;Configured=$null;Value=$null}
}
}
function Get-WindSecurityStatus {
$bitlocker=@();if(Get-Command Get-BitLockerVolume -ErrorAction SilentlyContinue){try{$bitlocker=@(Get-BitLockerVolume -ErrorAction Stop|Select-Object MountPoint,VolumeStatus,ProtectionStatus,EncryptionMethod,AutoUnlockEnabled)}catch{$bitlocker=@([pscustomobject]@{Status='Unreadable'})}}
$firewall=@();if(Get-Command Get-NetFirewallProfile -ErrorAction SilentlyContinue){try{$firewall=@(Get-NetFirewallProfile -PolicyStore ActiveStore|Select-Object Name,Enabled,DefaultInboundAction,DefaultOutboundAction)}catch{$firewall=@([pscustomobject]@{Status='Unreadable'})}}
$defender=$null
if(Get-Command Get-MpComputerStatus -ErrorAction SilentlyContinue){
try{$d=Get-MpComputerStatus -ErrorAction Stop;$defender=[pscustomobject]@{
AntivirusEnabled=$d.AntivirusEnabled;AntispywareEnabled=$d.AntispywareEnabled
RealTimeProtectionEnabled=$d.RealTimeProtectionEnabled;BehaviorMonitorEnabled=$d.BehaviorMonitorEnabled
IoavProtectionEnabled=$d.IoavProtectionEnabled;NISEnabled=$d.NISEnabled;OnAccessProtectionEnabled=$d.OnAccessProtectionEnabled
SignatureLastUpdated=$d.AntivirusSignatureLastUpdated;EngineVersion=$d.AMEngineVersion;PlatformVersion=$d.AMProductVersion
}}catch{$defender=[pscustomobject]@{Status='Unreadable'}}
}
$defenderPolicy=$null
if(Get-Command Get-MpPreference -ErrorAction SilentlyContinue){
try{$p=Get-MpPreference -ErrorAction Stop;$defenderPolicy=[pscustomobject]@{
CloudReporting=$p.MAPSReporting;SampleSubmission=$p.SubmitSamplesConsent;PUAProtection=$p.PUAProtection
NetworkProtection=$p.EnableNetworkProtection;ScriptScanningEnabled=(-not[bool]$p.DisableScriptScanning)
DownloadScanningEnabled=(-not[bool]$p.DisableIOAVProtection)
}}catch{$defenderPolicy=[pscustomobject]@{Status='Unreadable'}}
}
$secureBoot=$null;try{$secureBoot=[bool](Confirm-SecureBootUEFI -ErrorAction Stop)}catch{$secureBoot='Unavailable or unreadable'}
$tpm=$null;if(Get-Command Get-Tpm -ErrorAction SilentlyContinue){try{$t=Get-Tpm -ErrorAction Stop;$tpm=[pscustomobject]@{Present=$t.TpmPresent;Ready=$t.TpmReady;Enabled=$t.TpmEnabled;Activated=$t.TpmActivated;Owned=$t.TpmOwned;RestartPending=$t.RestartPending}}catch{$tpm=[pscustomobject]@{Status='Unreadable'}}}
$deviceGuard=$null
try{
$dg=Get-CimInstance -Namespace 'root\Microsoft\Windows\DeviceGuard' -ClassName Win32_DeviceGuard -ErrorAction Stop
$deviceGuard=[pscustomobject]@{
VirtualizationBasedSecurityStatus=$dg.VirtualizationBasedSecurityStatus
SecurityServicesConfigured=@($dg.SecurityServicesConfigured)
SecurityServicesRunning=@($dg.SecurityServicesRunning)
AvailableSecurityProperties=@($dg.AvailableSecurityProperties)
}
}catch{$deviceGuard=[pscustomobject]@{Status='Unavailable or unreadable'}}
$lsa=[pscustomobject]@{
RunAsPPL=Get-WindRegistryDword 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' 'RunAsPPL'
RunAsPPLBoot=Get-WindRegistryDword 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' 'RunAsPPLBoot'
}
$uac=[pscustomobject]@{
EnableLUA=Get-WindRegistryDword 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' 'EnableLUA'
ConsentPromptBehaviorAdmin=Get-WindRegistryDword 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' 'ConsentPromptBehaviorAdmin'
}
$remoteAccess=[pscustomobject]@{
RemoteDesktopDenied=Get-WindRegistryDword 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server' 'fDenyTSConnections'
RemoteAssistance=Get-WindRegistryDword 'HKLM:\SYSTEM\CurrentControlSet\Control\Remote Assistance' 'fAllowToGetHelp'
}
$smbServer=$null;if(Get-Command Get-SmbServerConfiguration -ErrorAction SilentlyContinue){try{$s=Get-SmbServerConfiguration -ErrorAction Stop;$smbServer=$s|Select-Object EnableSMB1Protocol,EnableSMB2Protocol,RequireSecuritySignature,EncryptData,EnableInsecureGuestLogons}catch{$smbServer=[pscustomobject]@{Status='Unreadable'}}}
$smbClient=$null;if(Get-Command Get-SmbClientConfiguration -ErrorAction SilentlyContinue){try{$s=Get-SmbClientConfiguration -ErrorAction Stop;$smbClient=$s|Select-Object RequireSecuritySignature,EnableInsecureGuestLogons}catch{$smbClient=[pscustomobject]@{Status='Unreadable'}}}
$smb1Feature=$null;if(Get-Command Get-WindowsOptionalFeature -ErrorAction SilentlyContinue){try{$smb1Feature=(Get-WindowsOptionalFeature -Online -FeatureName SMB1Protocol -ErrorAction Stop|Select-Object FeatureName,State)}catch{$smb1Feature=[pscustomobject]@{Status='Unreadable'}}}
$services=@();if(Get-Command Get-Service -ErrorAction SilentlyContinue){foreach($name in @('wuauserv','BITS','CryptSvc','WinDefend','WdNisSvc','mpssvc','EventLog')){try{$s=Get-Service -Name $name -ErrorAction Stop;$services+=@([pscustomobject]@{Name=$name;Status=[string]$s.Status;StartType=[string]$s.StartType})}catch{$services+=@([pscustomobject]@{Name=$name;Status='Unavailable'})}}}
[pscustomobject]@{
Schema=2;CapturedUtc=[DateTime]::UtcNow.ToString('o');BitLocker=$bitlocker;Firewall=$firewall
Defender=$defender;DefenderPolicy=$defenderPolicy;SecureBoot=$secureBoot;Tpm=$tpm;DeviceGuard=$deviceGuard
LsaProtection=$lsa;Uac=$uac;RemoteAccess=$remoteAccess;SmbServer=$smbServer;SmbClient=$smbClient;Smb1Feature=$smb1Feature
ProtectedServices=$services;RecoveryKeysCaptured=$false;SensitiveValuesCaptured=$false
Meaning='Read-only local security posture. Status fields require interpretation for the current Windows edition and build; no secret, recovery key, account name or Defender exclusion is collected.'
}
}
Export-ModuleMember -Function Get-WindSecurityStatus
+242 -117
View File
@@ -1,136 +1,261 @@
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function ConvertFrom-WindChoice {
param([string]$Text,[int]$Count)
if ([string]::IsNullOrWhiteSpace($Text) -or $Text.Trim() -eq 'none') { return }
if ($Text.Trim() -eq 'all') { if ($Count -gt 0) { 0..($Count-1) }; return }
$result = @()
foreach ($token in ($Text -split '[,\s]+' | Where-Object { $_ })) {
$number = 0
if (-not [int]::TryParse($token,[ref]$number) -or $number -lt 1 -or $number -gt $Count) { throw "Enter numbers from 1 to $Count, separated by commas, or all/none." }
$result += $number - 1
}
$result | Select-Object -Unique
function New-WindMenuItem {
param([string]$Id,[string]$Label,[string]$Detail='',[bool]$Disabled=$false)
[pscustomobject]@{Id=$Id;Label=$Label;Detail=$Detail;Disabled=$Disabled}
}
function Get-WindWrappedText {
param([string]$Text,[int]$Width)
$remaining=$Text -replace '[\x00-\x1f\x7f-\x9f]',' '
$Width=[Math]::Max(1,$Width)
while ($remaining.Length -gt $Width) {
$cut=$remaining.LastIndexOf(' ',$Width-1,$Width)
if ($cut -lt [Math]::Floor($Width/2)) {$cut=$Width}
$remaining.Substring(0,$cut).TrimEnd(); $remaining=$remaining.Substring($cut).TrimStart()
}
$remaining
}
function New-WindMenuState {
param([string[]]$Selected=@())
$checked=@{}; foreach ($id in $Selected) {$checked[$id]=$true}
[pscustomobject]@{Cursor=0;Filter='';Checked=$checked;Action='';PageSize=10}
}
function Get-WindVisibleItems {
param([object[]]$Items,$State)
foreach ($item in $Items) {
if (-not $State.Filter -or $item.Label.IndexOf($State.Filter,[StringComparison]::OrdinalIgnoreCase) -ge 0) {$item}
}
}
function Update-WindMenuState {
param($State,[object[]]$Items,[string]$Key,[char]$Character=[char]0,[switch]$Multiple,[switch]$Searchable)
$visible=@(Get-WindVisibleItems $Items $State); $count=$visible.Count; $State.Action=''
switch ($Key) {
'UpArrow' {if ($count) {$State.Cursor=($State.Cursor+$count-1)%$count}}
'DownArrow' {if ($count) {$State.Cursor=($State.Cursor+1)%$count}}
'Home' {$State.Cursor=0}
'End' {$State.Cursor=[Math]::Max(0,$count-1)}
'PageUp' {$State.Cursor=[Math]::Max(0,$State.Cursor-$State.PageSize)}
'PageDown' {$State.Cursor=[Math]::Max(0,[Math]::Min($count-1,$State.Cursor+$State.PageSize))}
'Escape' {$State.Action='Cancel'}
'Enter' {if ($Multiple -or ($count -and -not $visible[$State.Cursor].Disabled)) {$State.Action='Accept'}}
'Spacebar' {
if ($Multiple -and $count -and -not $visible[$State.Cursor].Disabled) {
$id=$visible[$State.Cursor].Id
if ($State.Checked.ContainsKey($id)) {$State.Checked.Remove($id)} else {$State.Checked[$id]=$true}
}
}
'Backspace' {if ($Searchable -and $State.Filter.Length) {$State.Filter=$State.Filter.Substring(0,$State.Filter.Length-1);$State.Cursor=0}}
default {if ($Searchable -and -not [char]::IsControl($Character) -and $Character -ne [char]0) {$State.Filter+=$Character;$State.Cursor=0}}
}
}
function Get-WindMenuFrame {
param([string]$Title,[string]$Subtitle,[object[]]$Items,$State,[int]$Width=90,[int]$Height=28,[switch]$Multiple,[switch]$Searchable)
$Width=[Math]::Max(30,$Width);$Height=[Math]::Max(16,$Height)
$visible=@(Get-WindVisibleItems $Items $State)
$State.PageSize=[Math]::Max(3,$Height-13)
$State.Cursor=[Math]::Max(0,[Math]::Min($State.Cursor,$visible.Count-1))
$frame=[Collections.Generic.List[object]]::new()
$frame.Add([pscustomobject]@{Text=' m s - p r i v a t e';Color='Cyan';Active=$false})
$frame.Add([pscustomobject]@{Text=(' '+$Title);Color='White';Active=$false})
$frame.Add([pscustomobject]@{Text=(' '+$Subtitle);Color='DarkGray';Active=$false})
$frame.Add([pscustomobject]@{Text=(' '+('-'*($Width-4)));Color='DarkGray';Active=$false})
$top=[int][Math]::Floor($State.Cursor/$State.PageSize)*$State.PageSize
for ($i=$top;$i -lt [Math]::Min($top+$State.PageSize,$visible.Count);$i++) {
$item=$visible[$i];$active=$i -eq $State.Cursor
$prefix=if ($active) {' > '} else {' '}
if ($Multiple) {$prefix+=if ($State.Checked.ContainsKey($item.Id)) {'[x] '} else {'[ ] '}}
if ($item.Disabled) {$prefix+='- '}
$frame.Add([pscustomobject]@{Text=$prefix+($item.Label -replace '[\x00-\x1f\x7f-\x9f]',' ');Color=$(if ($item.Disabled) {'DarkGray'} elseif ($active) {'White'} else {'Gray'});Active=$active})
}
if (-not $visible.Count) {$frame.Add([pscustomobject]@{Text=' No matches. Backspace clears the filter.';Color='Yellow';Active=$false})}
while ($frame.Count -lt 4+$State.PageSize) {$frame.Add([pscustomobject]@{Text='';Color='Gray';Active=$false})}
$frame.Add([pscustomobject]@{Text=(' '+('-'*($Width-4)));Color='DarkGray';Active=$false})
$detail=if ($visible.Count) {$visible[$State.Cursor].Detail} else {''}
$detailLines=@(Get-WindWrappedText $detail ($Width-4))
for ($i=0;$i -lt 3;$i++) {$frame.Add([pscustomobject]@{Text=$(if ($i -lt $detailLines.Count) {' '+$detailLines[$i]} else {''});Color='Gray';Active=$false})}
$position=if ($visible.Count) {'{0}/{1}' -f ($State.Cursor+1),$visible.Count} else {'0/0'}
$info=if ($Searchable) {"Filter: $($State.Filter)"} else {''}
if ($Multiple) {$info="Selected: $($State.Checked.Count) $info"}
$frame.Add([pscustomobject]@{Text=" $position $info";Color='DarkGray';Active=$false})
$hint=if ($Multiple) {'Arrows move Space toggle Enter save Esc cancel'} else {'Arrows move Enter select Esc back'}
$frame.Add([pscustomobject]@{Text=(' '+$hint);Color='Cyan';Active=$false})
if ($Searchable) {$frame.Add([pscustomobject]@{Text=' Type to filter Backspace erase PgUp/PgDn scroll';Color='DarkGray';Active=$false})}
foreach ($line in $frame) {if ($line.Text.Length -gt $Width-1) {$line.Text=$line.Text.Substring(0,$Width-4)+'...'};$line}
}
function Select-WindMenu {
param([string]$Title,[string]$Subtitle='',[object[]]$Items,[string[]]$Selected=@(),[switch]$Multiple,[switch]$Searchable)
$state=New-WindMenuState $Selected
if (-not $Multiple -and $Selected.Count) {
for ($i=0;$i -lt $Items.Count;$i++) {if ($Items[$i].Id -eq $Selected[0]) {$state.Cursor=$i;break}}
}
$cursorVisible=[Console]::CursorVisible
try {
[Console]::CursorVisible=$false;Clear-Host;$lastSize=''
while ($true) {
$width=[Console]::WindowWidth;$height=[Console]::WindowHeight
if ($width -lt 40 -or $height -lt 18) {throw 'Enlarge the terminal to at least 40 columns and 18 rows.'}
if ($lastSize -ne "$width/$height") {Clear-Host;$lastSize="$width/$height"}
$frame=@(Get-WindMenuFrame -Title $Title -Subtitle $Subtitle -Items $Items -State $state -Width $width -Height $height -Multiple:$Multiple -Searchable:$Searchable)
[Console]::SetCursorPosition(0,0)
foreach ($line in $frame) {Write-Host $line.Text.PadRight($width-1) -ForegroundColor $line.Color -BackgroundColor $(if ($line.Active) {'DarkBlue'} else {'Black'})}
$key=[Console]::ReadKey($true)
if ($key.Key -eq 'C' -and ($key.Modifiers -band [ConsoleModifiers]::Control)) {$state.Action='Cancel'}
else {Update-WindMenuState -State $state -Items $Items -Key $key.Key.ToString() -Character $key.KeyChar -Multiple:$Multiple -Searchable:$Searchable}
if ($state.Action -eq 'Cancel') {return [pscustomobject]@{Accepted=$false;Ids=@()}}
if ($state.Action -eq 'Accept') {
$ids=if ($Multiple) {@($Items | Where-Object {$state.Checked.ContainsKey($_.Id)} | ForEach-Object {$_.Id})} else {@(@(Get-WindVisibleItems $Items $state)[$state.Cursor].Id)}
return [pscustomobject]@{Accepted=$true;Ids=@($ids)}
}
}
} finally {[Console]::CursorVisible=$cursorVisible;[Console]::ResetColor();Clear-Host}
}
function Show-WindText {
param([string]$Title,[string[]]$Lines)
$items=@(New-WindMenuItem 'back' 'Back' 'Esc or Enter returns to the previous screen.');$number=0
foreach ($line in $Lines) {
foreach ($part in @(Get-WindWrappedText $line ([Math]::Max(30,[Console]::WindowWidth-8)))) {$number++;$items+=New-WindMenuItem "line-$number" $part $line $true}
}
Select-WindMenu -Title $Title -Subtitle 'Arrows and Page Up / Page Down scroll' -Items $items | Out-Null
}
function Get-WindSetupSnapshot {
param($Settings)
$inventory=@(Get-WindAppInventory);$ids=@($inventory | ForEach-Object {$_.Id})
$Settings.AppOptions.ExtraId=@($Settings.AppOptions.ExtraId | Where-Object {$ids -contains $_})
$Settings.AppOptions.ExcludeId=@($Settings.AppOptions.ExcludeId | Where-Object {$ids -contains $_})
$options=$Settings.AppOptions
$sensors=@();if($Settings.DisableSensors){$sensors=@(Get-WindSensorInventory|Where-Object Status -eq 'OK')}
$recallPlan=if($Settings.Recall){Get-WindRecallPlan -RemoveFeature:$Settings.RemoveRecall}else{$null}
[pscustomobject]@{Apps=@(Get-WindAppPlan -Inventory $inventory @options);Policies=@(Get-WindPlan -Context (Get-WindContext) -Store (New-WindRegistryStore) -Profile $Settings.Profile -Updates $Settings.Updates);Sensors=$sensors;Recall=$recallPlan}
}
function Show-WindSetupReview {
param($Snapshot,$Settings)
$items=@(New-WindMenuItem 'back' 'Back to setup' 'Nothing changes on this screen.')
foreach ($p in $Snapshot.Policies) {$items+=New-WindMenuItem $p.Id ("[$($p.Status)] $($p.Title)") $p.Impact $true}
foreach ($a in @($Snapshot.Apps | Where-Object Status -ne 'Keep')) {$items+=New-WindMenuItem $a.Id ("[$($a.Status)] $($a.Name)") ("$($a.Scope) scope. $($a.Reason). App data may be removed by the uninstaller.") $true}
foreach($s in @($Snapshot.Sensors)){$items+=New-WindMenuItem ('sensor:'+ $s.InstanceId) ("[WouldDisable] $($s.Kind): $($s.Name)") $s.Risk $true}
if($Snapshot.Recall){foreach($r in $Snapshot.Recall.Items){$items+=New-WindMenuItem ('recall:'+ $r.Id) ("[$($r.Status)] $($r.Action)") $r.Impact $true}}
Select-WindMenu -Title 'Review your plan' -Subtitle ("$($Settings.Preset) | $($Settings.Profile) | Apps: $(@($Snapshot.Apps|Where-Object Status -eq 'Remove').Count) | Sensors: $(@($Snapshot.Sensors).Count) | Network: $($Settings.Network)") -Items $items -Searchable | Out-Null
}
function Set-WindSetupPreset {
param($Settings,$Preset)
$Settings.Preset=$Preset.Id;$Settings.Profile=$Preset.Profile;$Settings.Updates=$Preset.Updates;$Settings.Browsers=@($Preset.Browsers);$Settings.AccountPurge=$Preset.Accounts
$Settings.Services=$Preset.Services;$Settings.Network=$Preset.Network;$Settings.RollbackMinutes=[int]$Preset.RollbackMinutes;$Settings.Recall=$Preset.Recall;$Settings.RemoveRecall=$Preset.RemoveRecall
$Settings.Cleanup=$Preset.Cleanup;$Settings.PrivacyCleanup=$Preset.PrivacyCleanup;$Settings.CleanupSchedule=$Preset.CleanupSchedule;$Settings.Lock=$Preset.Lock;$Settings.InstallMullvad=$Preset.InstallMullvad;$Settings.InstallVeraCrypt=$Preset.InstallVeraCrypt;$Settings.DisableSensors=$Preset.DisableSensors
$Settings.AppOptions=@{ExtraId=@();ExcludeId=@();SkipBundled=(-not$Preset.RemoveBundled);KeepOneDrive=(-not$Preset.RemoveOneDrive);KeepEdge=(-not$Preset.RemoveEdge);RemovePersonal=[bool]$Preset.RemovePersonal;RemoveStore=[bool]$Preset.RemoveStore}
}
function Start-WindMenu {
param([Parameter(Mandatory)][string]$Root,[string]$Profile='Standard',[string]$Updates='Keep')
if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { throw 'Windows is required.' }
if ([Console]::IsInputRedirected) { throw 'The menu needs an interactive terminal. Use -Mode Audit/Plan or Manage-Apps.ps1 for scripts.' }
Import-Module (Join-Path $Root 'src/Wind.Core.psm1') -Force
Import-Module (Join-Path $Root 'src/Wind.Apps.psm1') -Force
$browsers = @('Zen')
$appOptions = @{ ExtraId=@(); ExcludeId=@(); SkipBundled=$false; KeepOneDrive=$false; KeepEdge=$false }
if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or [Console]::IsInputRedirected) {throw 'Open ms-private in Windows Terminal or a PowerShell console. For automation, use the explicit CLI modes.'}
Import-Module (Join-Path $Root 'src/Wind.Core.psm1')
Import-Module (Join-Path $Root 'src/Wind.Apps.psm1')
Import-Module (Join-Path $Root 'src/Wind.Hardware.psm1')
Import-Module (Join-Path $Root 'src/Wind.Recall.psm1')
Import-Module (Join-Path $Root 'src/MsPrivate.Presets.psm1')
Import-Module (Join-Path $Root 'src/MsPrivate.Execution.psm1')
$installContext=Get-MsPrivateInstallContext
$settings=[pscustomobject]@{Preset='Custom';Profile=$Profile;Updates=$Updates;Browsers=@('Zen');AccountPurge=($Profile-eq'Strict');Services=($Profile-eq'Strict');Network='Observe';RollbackMinutes=10;Recall=$false;RemoveRecall=$false;Cleanup=$false;PrivacyCleanup=$false;CleanupSchedule=$false;Lock=$false;InstallMullvad=$false;InstallVeraCrypt=$false;DisableSensors=$false;AppOptions=@{ExtraId=@();ExcludeId=@();SkipBundled=$false;KeepOneDrive=$false;KeepEdge=$false;RemovePersonal=$false;RemoveStore=$false}}
Set-WindSetupPreset $settings (Get-MsPrivatePreset $installContext.RecommendedPreset)
$focus='review'
while ($true) {
Write-Host ''
Write-Host 'WIND / privacy setup' -ForegroundColor Cyan
Write-Host ("Profile: {0} Updates: {1} Browsers: {2}" -f $Profile,$Updates,($browsers -join ', '))
Write-Host ('Bundled apps: {0} OneDrive: {1} Edge: {2}' -f $(if ($appOptions.SkipBundled) {'keep'} else {'remove'}),$(if ($appOptions.KeepOneDrive) {'keep'} else {'remove'}),$(if ($appOptions.KeepEdge) {'keep'} else {'remove where supported'}))
Write-Host '1 Review plan 2 Privacy profile 3 Browsers 4 Apps'
Write-Host '5 Update mode 6 Apply plan 7 Audit 8 Restore policies'
Write-Host '9 Default browser settings G Home guide Q Quit'
$choice = (Read-Host 'Choose').Trim().ToUpperInvariant()
if ($choice -eq 'Q') { return }
$options=$settings.AppOptions
$items=@(
(New-WindMenuItem 'review' 'Review plan' 'See which policies and applications are selected before applying.'),
(New-WindMenuItem 'preset' ('Preset '+$settings.Preset) ("Auto recommendation: $($installContext.RecommendedPreset). $($installContext.Reason) Fresh Install performs the complete workflow; Established Safe keeps destructive groups off.")),
(New-WindMenuItem 'profile' ('Privacy profile '+$settings.Profile) 'Strict adds app-permission denials, activity-history controls and reversible shutdown of reviewed diagnostic, connected-device, cloud-sync, location and error-reporting services.'),
(New-WindMenuItem 'browsers' ('Browsers '+($settings.Browsers -join ', ')) 'Choose one or several browsers. Installation is verified before apps are removed.'),
(New-WindMenuItem 'apps' 'Apps & debloat' 'Medium and above select every unambiguously removable Microsoft and third-party app. Drivers, runtimes, security tools, browsers and recovery components remain protected.'),
(New-WindMenuItem 'accounts' ('Microsoft app accounts '+$(if($settings.AccountPurge){'Purge'}else{'Keep'})) 'Strict can reset Store, Xbox, packaged Office/Teams and the shared account broker, then delete matching stored Microsoft application credentials. Windows logon remains.'),
(New-WindMenuItem 'updates' ('Windows updates '+$settings.Updates) 'Keep the update engine intact. Manual mode makes you responsible for security updates.'),
(New-WindMenuItem 'apply' 'Apply selected changes' 'Review, then confirm once. App removals cannot be undone by policy restore.'),
(New-WindMenuItem 'restore' 'Restore policies' 'Restore saved policy values. Reinstall removed apps from their official sources.'),
(New-WindMenuItem 'defaults' 'Default browser settings' 'Open Windows Settings to choose which browser opens links.'),
(New-WindMenuItem 'guide' 'Privacy guide' 'Manual privacy settings for Home editions and unsupported builds.'),
(New-WindMenuItem 'gui' 'Open graphical wizard' 'Configure network, Recall, exact hardware devices, cleanup and lock handling in a tabbed Windows interface.'),
(New-WindMenuItem 'quit' 'Quit' 'Close ms-private. Unapplied choices are not saved.'))
$choice=Select-WindMenu -Title 'Make Windows yours.' -Subtitle 'Privacy controls / app cleanup / browser choice' -Items $items -Selected @($focus)
if (-not $choice.Accepted -or $choice.Ids[0] -eq 'quit') {return}
$focus=$choice.Ids[0]
try {
switch ($choice) {
'1' {
& (Join-Path $Root 'Wind.ps1') -Mode Plan -Profile $Profile -Updates $Updates
& (Join-Path $Root 'Manage-Apps.ps1') -Mode Plan -Browser $browsers @appOptions
switch ($choice.Ids[0]) {
'review' {Show-WindSetupReview (Get-WindSetupSnapshot $settings) $settings}
'preset' {
$catalog=Get-MsPrivatePresetCatalog
$r=Select-WindMenu -Title 'Choose an automatic preset' -Subtitle 'Review shows every exact app and device before Apply' -Items @($catalog.Presets|ForEach-Object{New-WindMenuItem $_.Id $_.Label $_.Description}) -Selected @($settings.Preset)
if($r.Accepted){Set-WindSetupPreset $settings (Get-MsPrivatePreset $r.Ids[0])}
}
'2' {
Write-Host '1 Standard: privacy controls; no Delivery Optimization peering'
Write-Host '2 Strict: also disable clipboard history and the DO cloud service'
Write-Host 'Switching back to Standard permits clipboard history; it cannot restore deleted history.'
switch (Read-Host 'Profile [1/2]') { '1' {$Profile='Standard'} '2' {$Profile='Strict'} default {throw 'Choose 1 or 2.'} }
'profile' {
$r=Select-WindMenu -Title 'Privacy profile' -Items @((New-WindMenuItem 'Standard' 'Standard' 'Core privacy controls with clipboard history permitted and Windows security/update services preserved.'),(New-WindMenuItem 'Strict' 'Strict' 'Adds app-permission denials, activity-history controls, clipboard-history disablement and the reviewed privacy-service shutdown. Returning to Standard does not recover cleared history.'))
if ($r.Accepted) {$settings.Preset='Custom';$settings.Profile=$r.Ids[0];$settings.AccountPurge=($settings.Profile-eq'Strict');$settings.Services=($settings.Profile-eq'Strict')}
}
'3' {
$names = @('Zen','Helium','Tor')
Write-Host '1 Zen 2 Helium 3 Tor Browser'
Write-Host 'Select any combination. Keep Tor settings separate from personal browsing.'
$indices = @(ConvertFrom-WindChoice -Text (Read-Host 'Numbers, all, or none') -Count 3)
$browsers = @($indices | ForEach-Object { $names[$_] })
if (-not $browsers.Count) { $appOptions.KeepEdge=$true; Write-Host 'Edge kept because no replacement browser is selected.' }
'accounts' {
$r=Select-WindMenu -Title 'Microsoft application accounts' -Items @((New-WindMenuItem 'keep' 'Keep current app sign-ins' 'Future authentication is still governed by the chosen policy profile.'),(New-WindMenuItem 'purge' 'Purge current app sign-ins' 'Permanently resets current-user Store, Xbox, packaged Office/Teams and shared account-broker data, and deletes matching stored Microsoft application credentials.')) -Selected @($(if($settings.AccountPurge){'purge'}else{'keep'}))
if($r.Accepted){$settings.Preset='Custom';$settings.AccountPurge=($r.Ids[0]-eq'purge')}
}
'4' {
Write-Host '1 Select individual apps 2 Toggle bundled removal 3 Toggle OneDrive 4 Toggle Edge'
switch (Read-Host 'App option') {
'1' {
$inventory = @(Get-WindAppInventory)
# Remove stale selections after a completed or partially completed operation.
$appOptions.ExtraId = @($appOptions.ExtraId | Where-Object { $inventory.Id -contains $_ })
$appOptions.ExcludeId = @($appOptions.ExcludeId | Where-Object { $inventory.Id -contains $_ })
$selectable = @(Get-WindAppPlan -Inventory $inventory @appOptions | Where-Object { -not $_.Protected } | Sort-Object Name)
for ($i=0; $i -lt $selectable.Count; $i++) {
Write-Host ('{0,3} [{1}] {2} ({3})' -f ($i+1),$(if ($selectable[$i].Status -eq 'Remove') {'x'} else {' '}),$selectable[$i].Name,$selectable[$i].Scope)
}
Write-Host 'Numbers toggle the displayed selections. Blank returns. Protected components are excluded.'
$indices = @(ConvertFrom-WindChoice -Text (Read-Host 'Toggle numbers') -Count $selectable.Count)
foreach ($i in $indices) {
$app = $selectable[$i]
if ($app.Status -eq 'Remove') {
$appOptions.ExtraId = @($appOptions.ExtraId | Where-Object { $_ -ne $app.Id })
$appOptions.ExcludeId = @($appOptions.ExcludeId + $app.Id | Select-Object -Unique)
} else {
if ($app.Name -eq 'Microsoft Edge') { $appOptions.KeepEdge=$false }
if ($app.Name -eq 'Microsoft OneDrive') { $appOptions.KeepOneDrive=$false }
$appOptions.ExcludeId = @($appOptions.ExcludeId | Where-Object { $_ -ne $app.Id })
$appOptions.ExtraId = @($appOptions.ExtraId + $app.Id | Select-Object -Unique)
}
}
}
'2' {$appOptions.SkipBundled = -not $appOptions.SkipBundled}
'3' {$appOptions.KeepOneDrive = -not $appOptions.KeepOneDrive}
'4' {$appOptions.KeepEdge = -not $appOptions.KeepEdge}
default {throw 'Choose 1, 2, 3 or 4.'}
'browsers' {
$r=Select-WindMenu -Title 'Choose your browsers' -Subtitle 'Select any combination' -Items @((New-WindMenuItem 'Zen' 'Zen' 'Firefox-based browser for everyday browsing.'),(New-WindMenuItem 'Helium' 'Helium' 'Chromium-based browser for everyday browsing.'),(New-WindMenuItem 'Tor' 'Tor Browser' 'Keep stock privacy protections. Personal account sign-ins still identify you.')) -Selected $settings.Browsers -Multiple
if ($r.Accepted) {$settings.Preset='Custom';$settings.Browsers=@($r.Ids);if (-not $r.Ids.Count) {$options.KeepEdge=$true}}
}
'apps' {
$selected=@();if (-not $options.SkipBundled) {$selected+='bundled'};if (-not $options.KeepOneDrive) {$selected+='onedrive'};if (-not $options.KeepEdge) {$selected+='edge'}
if($options.RemovePersonal){$selected+='personal'};if($options.RemoveStore){$selected+='store'}
$r=Select-WindMenu -Title 'Default app removals' -Subtitle 'Space toggles groups; Enter continues to individual apps' -Items @((New-WindMenuItem 'bundled' 'Bundled / promotional apps' 'Exact curated catalog names.'),(New-WindMenuItem 'personal' 'All removable personal / third-party apps' 'Selects every discovered app except protected dependencies, security tools and chosen privacy applications.'),(New-WindMenuItem 'store' 'Microsoft Store and purchase app' 'Current-user removal. Reinstallation may require an official recovery command or Windows repair.'),(New-WindMenuItem 'onedrive' 'OneDrive' 'Stops sync. Cloud-only files stay online. Download and back up needed files before removal.'),(New-WindMenuItem 'edge' 'Microsoft Edge' 'Supported uninstaller only, after replacement browsers are installed. Windows may refuse removal. WebView2 stays installed.')) -Selected $selected -Multiple
if (-not $r.Accepted) {break}
$settings.Preset='Custom';$options.RemovePersonal=($r.Ids-contains'personal');$options.RemoveStore=($r.Ids-contains'store')
$beforeGroups=Get-WindSetupSnapshot $settings
Set-WindRemovalGroups -Options $options -Inventory $beforeGroups.Apps -Bundled ($r.Ids -contains 'bundled') -OneDrive ($r.Ids -contains 'onedrive') -Edge ($r.Ids -contains 'edge')
$snapshot=Get-WindSetupSnapshot $settings
$apps=@($snapshot.Apps | Where-Object {-not $_.Protected} | Sort-Object Name)
$rows=@($apps | ForEach-Object {New-WindMenuItem $_.Id $_.Name ("$($_.Scope) / $($_.Version). $($_.Reason). Uninstalling can remove app data.")})
$r=Select-WindMenu -Title 'Applications to remove' -Subtitle 'Checked = remove. Personal apps start unchecked. Dependencies are hidden.' -Items $rows -Selected @($apps | Where-Object Status -eq 'Remove' | ForEach-Object {$_.Id}) -Multiple -Searchable
if ($r.Accepted) {
$options.ExtraId=@($r.Ids);$options.ExcludeId=@($apps | Where-Object {$r.Ids -notcontains $_.Id} | ForEach-Object {$_.Id})
if (@($apps | Where-Object {$_.Name -eq 'Microsoft Edge' -and $r.Ids -contains $_.Id}).Count) {$options.KeepEdge=$false}
if (@($apps | Where-Object {$_.Name -eq 'Microsoft OneDrive' -and $r.Ids -contains $_.Id}).Count) {$options.KeepOneDrive=$false}
}
}
'5' {
Write-Host '1 Keep current settings 2 Automatic 3 Notify before download 4 Manual'
Write-Host 'Manual turns off automatic updates. You become responsible for installing security updates.'
$names=@('Keep','Automatic','Notify','Manual')
$indices=@(ConvertFrom-WindChoice -Text (Read-Host 'Update mode [1-4]') -Count 4)
if ($indices.Count -ne 1) { throw 'Choose one update mode.' }
$Updates=$names[$indices[0]]
'updates' {
$r=Select-WindMenu -Title 'Windows updates' -Items @((New-WindMenuItem 'Keep' 'Keep current settings' 'Leave update policy unchanged, including any previous ms-private setting.'),(New-WindMenuItem 'Automatic' 'Automatic' 'Request automatic updates using the existing Windows Update engine.'),(New-WindMenuItem 'Notify' 'Notify before downloading' 'Request notification before downloads; supported policy editions only.'),(New-WindMenuItem 'Manual' 'Manual' 'Disable automatic updates. You must install security updates yourself. This does not block update traffic.'))
if ($r.Accepted) {$settings.Preset='Custom';$settings.Updates=$r.Ids[0]}
}
'6' {
Assert-WindMutationAllowed
$inventory = @(Get-WindAppInventory)
$appOptions.ExtraId = @($appOptions.ExtraId | Where-Object { $inventory.Id -contains $_ })
$appOptions.ExcludeId = @($appOptions.ExcludeId | Where-Object { $inventory.Id -contains $_ })
& (Join-Path $Root 'Manage-Apps.ps1') -Mode Plan -Browser $browsers @appOptions
$plan = @(Get-WindPlan -Context (Get-WindContext) -Store (New-WindRegistryStore) -Profile $Profile -Updates $Updates)
$plan | Select-Object Id,Status,Impact | Format-Table -Wrap | Out-Host
if (@($plan | Where-Object Status -in @('Conflict','ReadError')).Count) { throw 'Resolve policy conflicts/read errors before applying.' }
$eligible = @($plan | Where-Object Status -in @('WouldChange','Configured') | ForEach-Object Id)
if (@($plan | Where-Object Status -eq 'Unsupported').Count) { Write-Host 'Unsupported privacy policies will be skipped. Use G for the Home/manual guide.' -ForegroundColor Yellow }
Write-Host 'App uninstallers may remove app data. Policy journals do not restore apps. Back up your app data and make needed OneDrive files available locally.'
if ((Read-Host 'Type APPLY to execute this plan') -cne 'APPLY') { Write-Host 'Cancelled.'; break }
& (Join-Path $Root 'Manage-Apps.ps1') -Mode Apply -Browser $browsers @appOptions -Confirm:$false
if ($eligible.Count) { & (Join-Path $Root 'Wind.ps1') -Mode Apply -Profile $Profile -Updates $Updates -Id $eligible -Confirm:$false }
else { Write-Host 'App stage finished. Privacy policies were not applied on this edition/build; complete the manual guide.' }
Write-Host 'Review the result above, then reboot and check apps, updates and privacy settings.'
'apply' {
Assert-WindMutationAllowed;$snapshot=Get-WindSetupSnapshot $settings;Show-WindSetupReview $snapshot $settings
$recallDisposition=Resolve-MsPrivateRecallDisposition -Requested $settings.Recall -Plan $snapshot.Recall
$policyIssues=@($snapshot.Policies | Where-Object {$_.Status -in @('Conflict','ReadError')});$appIssues=@($snapshot.Apps | Where-Object {$_.Status -eq 'Blocked'})
$eligible=@($snapshot.Policies | Where-Object Status -in @('WouldChange','Configured') | ForEach-Object {$_.Id})
$skip=@($snapshot.Policies | Where-Object Status -eq 'Unsupported').Count;$count=@($snapshot.Apps | Where-Object Status -eq 'Remove').Count
$r=Select-WindMenu -Title 'Ready to apply?' -Subtitle "$count app removals / $($eligible.Count) policies / $(@($snapshot.Sensors).Count) sensors / $skip unsupported" -Items @((New-WindMenuItem 'cancel' 'Go back' 'Review selections or back up app data before continuing.'),(New-WindMenuItem 'apply' 'Apply the complete plan' 'This single approval authorizes selected app/account/Recall/cache deletion, sensor disablement and network changes. Apps and deleted data are not restored by policy rollback.'))
if (-not $r.Accepted -or $r.Ids[0] -ne 'apply') {break}
$tools=@();if($settings.InstallMullvad){$tools+='Mullvad'};if($settings.InstallVeraCrypt){$tools+='VeraCrypt'};$runState=[pscustomobject]@{NetworkJournal=$null};$groups=[Collections.Generic.List[object]]::new()
if($eligible.Count-or$policyIssues.Count){$groups.Add([pscustomobject]@{Id='Policies';Label='Registry policy writes';DependsOn=@();Action=({if($eligible.Count){& (Join-Path $Root 'ms-private.ps1') -Mode Apply -Profile $settings.Profile -Updates $settings.Updates -Id $eligible -Confirm:$false};if($policyIssues.Count){throw "$($policyIssues.Count) requested policies had a conflict or read error; eligible policies were attempted."}}.GetNewClosure())})}
if($settings.Services){$groups.Add([pscustomobject]@{Id='Services';Label='Service configuration';DependsOn=@();Action=({& (Join-Path $Root 'Services.ps1') -Mode Apply -Profile Strict -Confirm:$false}.GetNewClosure())})}
if($settings.AccountPurge){$groups.Add([pscustomobject]@{Id='Accounts';Label='Credential and application-account purge';DependsOn=@();Action=({& (Join-Path $Root 'Accounts.ps1') -Mode Apply -Scope AllAppAccounts -AcknowledgeAccountDataDeletion -Confirm:$false}.GetNewClosure())})}
if($tools.Count){$groups.Add([pscustomobject]@{Id='PrivacyTools';Label='Privacy-tool installation';DependsOn=@();Action=({& (Join-Path $Root 'Install-Apps.ps1') -App $tools -Confirm:$false}.GetNewClosure())})}
if(@($settings.Browsers).Count-or@($snapshot.Apps | Where-Object { $_.Status -eq 'Remove' }).Count-or$appIssues.Count){$groups.Add([pscustomobject]@{Id='Applications';Label='Browser installation and exact app removal';DependsOn=@();Action=({if($appIssues.Count){throw "$($appIssues.Count) application targets were blocked or ambiguous; no app removal was attempted."};& (Join-Path $Root 'Manage-Apps.ps1') -Mode Apply -Browser $settings.Browsers @options -ReviewedPlan $snapshot.Apps -Confirm:$false}.GetNewClosure())})}
if($settings.Network-ne'Observe'){$groups.Add([pscustomobject]@{Id='Network';Label='Firewall changes';DependsOn=@();Action=({$nr=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'MsPrivate\network';[IO.Directory]::CreateDirectory($nr)|Out-Null;$runState.NetworkJournal=Join-Path $nr ((Get-Date -Format 'yyyyMMdd-HHmmss')+'-'+[guid]::NewGuid().ToString('N')+'.json');& (Join-Path $Root 'Network.ps1') -Mode Apply -Profile $settings.Network -RollbackMinutes $settings.RollbackMinutes -Journal $runState.NetworkJournal -Confirm:$false}.GetNewClosure())})}
if(@($snapshot.Sensors).Count){$groups.Add([pscustomobject]@{Id='Sensors';Label='Exact device disablement';DependsOn=@();Action=({& (Join-Path $Root 'Hardware.ps1') -Mode Apply -InstanceId @($snapshot.Sensors.InstanceId) -Confirm:$false}.GetNewClosure())})}
$recallSkipped=$recallDisposition.Skipped.Count;if($settings.Recall-and($recallDisposition.Applicable-or$recallDisposition.Conflict)){$groups.Add([pscustomobject]@{Id='Recall';Label='Recall policy, snapshot and feature removal';DependsOn=@();Action=({if($recallDisposition.Conflict){throw 'The Recall plan contains a conflicting value.'};& (Join-Path $Root 'Recall.ps1') -Mode Apply -AcknowledgeSnapshotDeletion -RemoveFeature:$settings.RemoveRecall -Confirm:$false}.GetNewClosure())})}
if($settings.Cleanup-or$settings.PrivacyCleanup){$groups.Add([pscustomobject]@{Id='Cleanup';Label='Allowlisted file cleanup';DependsOn=@();Action=({if($settings.Cleanup){& (Join-Path $Root 'Cleanup.ps1') -Mode Run -AgeDays 7 -Category Temp -Confirm:$false};if($settings.PrivacyCleanup){& (Join-Path $Root 'Cleanup.ps1') -Mode Run -AgeDays 0 -Category @('CrashReports','RecentItems','ThumbnailCache','BrowserCache','PowerShellHistory') -ClearClipboard -Confirm:$false}}.GetNewClosure())})}
if($settings.CleanupSchedule){$groups.Add([pscustomobject]@{Id='CleanupSchedule';Label='Cleanup schedule';DependsOn=@();Action=({& (Join-Path $Root 'Cleanup.ps1') -Mode InstallSchedule -AgeDays 7 -Confirm:$false}.GetNewClosure())})}
if($settings.Lock){$groups.Add([pscustomobject]@{Id='LockHandler';Label='Session-lock handler';DependsOn=@();Action=({& (Join-Path $Root 'Lock.ps1') -Mode Install -Confirm:$false}.GetNewClosure())})}
$runRoot=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'MsPrivate\runs';$runPath=Join-Path $runRoot ((Get-Date -Format 'yyyyMMdd-HHmmss')+'-'+[guid]::NewGuid().ToString('N')+'.json');$runResult=Invoke-MsPrivateGroupPlan -Groups @($groups) -ReportPath $runPath
$networkEntry=@($runResult.Entries | Where-Object { $_.Id -eq 'Network' });if($settings.Network-eq'Isolate'-and$networkEntry.Count-and$networkEntry[0].Status-eq'Succeeded'){Show-WindText 'Isolation safety timer active' @("Network isolation will roll back automatically unless explicitly confirmed with Network.ps1 -Mode Confirm -Journal `"$($runState.NetworkJournal)`".",'This preserves recovery if updates, activation or required access stop working.')}
$failed=@($runResult.Entries | Where-Object { $_.Status -in @('Failed','SkippedDependency') });Show-WindText 'Changes finished' @("Run status: $($runResult.Status). $($failed.Count) groups failed or were dependency-skipped; all other independent groups were attempted.","$skip unsupported policies and $recallSkipped unsupported Recall actions skipped.",'Reboot and check apps and security updates. Use the guide for skipped settings.',"Run report: $runPath",'Configuration is not proof of anonymity.')
}
'7' { & (Join-Path $Root 'Wind.ps1') -Mode Audit -Profile $Profile -Updates $Updates }
'8' {
$journalRoot=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'WindPrivacy\journals'
$journals=@()
if (Test-Path -LiteralPath $journalRoot) { $journals=@(Get-ChildItem -LiteralPath $journalRoot -Filter '*.journal.json' -File | Sort-Object LastWriteTime -Descending) }
if (-not $journals.Count) { Write-Host 'No local policy journals.'; break }
for ($i=0; $i -lt $journals.Count; $i++) { Write-Host ('{0} {1}' -f ($i+1),$journals[$i].Name) }
Write-Host 'Restore most recent journals first. This restores policies only, not removed apps or app data.'
$indices=@(ConvertFrom-WindChoice -Text (Read-Host 'Journal number, or blank to cancel') -Count $journals.Count)
if (-not $indices.Count) { break }
if ($indices.Count -ne 1) { throw 'Select one journal.' }
& (Join-Path $Root 'Wind.ps1') -Mode Restore -Journal $journals[$indices[0]].FullName -WhatIf
if ((Read-Host 'Type RESTORE to restore these policies') -ceq 'RESTORE') { & (Join-Path $Root 'Wind.ps1') -Mode Restore -Journal $journals[$indices[0]].FullName -Confirm:$false }
'restore' {
$path=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'MsPrivate\journals'
$journals=@();if (Test-Path -LiteralPath $path) {$journals=@(Get-ChildItem -LiteralPath $path -Filter '*.journal.json' -File | Sort-Object LastWriteTime -Descending)}
if (-not $journals.Count) {Show-WindText 'Restore policies' @('No local policy journals.');break}
$r=Select-WindMenu -Title 'Restore policies' -Subtitle 'Newest first / apps and data are not restored' -Items @($journals | ForEach-Object {New-WindMenuItem $_.FullName $_.Name 'Restore newer journals first. Conflicts preserve later edits.'})
if (-not $r.Accepted) {break};$journal=$r.Ids[0]
$r=Select-WindMenu -Title 'Restore this journal?' -Subtitle ([IO.Path]::GetFileName($journal)) -Items @((New-WindMenuItem 'cancel' 'Cancel'),(New-WindMenuItem 'restore' 'Restore saved policy values'))
if ($r.Accepted -and $r.Ids[0] -eq 'restore') {& (Join-Path $Root 'ms-private.ps1') -Mode Restore -Journal $journal -Confirm:$false;Show-WindText 'Restore finished' @('Saved policy values restored. Reboot and verify.')}
}
'9' { Start-Process 'ms-settings:defaultapps' }
'G' { & (Join-Path $Root 'Wind.ps1') -Mode Guide }
default { Write-Host 'Choose a menu item.' }
'defaults' {Start-Process 'ms-settings:defaultapps'}
'guide' {Show-WindText 'Privacy guide' @(Get-Content -LiteralPath (Join-Path $Root 'docs/HOME-GUIDE.md') -Encoding UTF8)}
'gui' {Start-Process 'powershell.exe' -ArgumentList @('-NoProfile','-STA','-ExecutionPolicy','RemoteSigned','-File',(Join-Path $Root 'ms-private-GUI.ps1'))}
}
} catch { Write-Host $_.Exception.Message -ForegroundColor Red }
} catch {Show-WindText 'Action stopped' @($_.Exception.Message,'Check the run report and component journals. Completed groups remain applied; failed prerequisites are never reported as success.')}
}
}
Export-ModuleMember -Function Start-WindMenu,ConvertFrom-WindChoice
Export-ModuleMember -Function Start-WindMenu,New-WindMenuState,Update-WindMenuState,Get-WindMenuFrame,New-WindMenuItem,Get-WindVisibleItems