first commit
This commit is contained in:
1 parent
831814283e
commit
7aacc0b63f
29 files changed
+5114
No files matched your search
@@ -0,0 +1,198 @@
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Get-WindBrowserCatalog {
|
||||
@(Get-Content -LiteralPath (Join-Path $PSScriptRoot '../config/apps.json') -Raw | ConvertFrom-Json)
|
||||
}
|
||||
|
||||
function Test-WindProtectedApp {
|
||||
param([Parameter(Mandatory)]$App)
|
||||
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\)'
|
||||
}
|
||||
|
||||
function Initialize-WindAppx {
|
||||
if ($PSVersionTable.PSEdition -eq 'Core') {
|
||||
Import-Module Appx -UseWindowsPowerShell -WarningAction SilentlyContinue -ErrorAction Stop
|
||||
} else { Import-Module Appx -ErrorAction Stop }
|
||||
}
|
||||
|
||||
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)) {
|
||||
[pscustomobject]@{
|
||||
Id = 'appx:' + $p.PackageFullName; Name = [string]$p.Name
|
||||
Version = [string]$p.Version; Publisher = [string]$p.Publisher
|
||||
Kind = 'Appx'; Scope = 'user'; Target = [string]$p.PackageFullName
|
||||
Protected = [bool]($p.IsFramework -or $p.IsResourcePackage -or $p.NonRemovable -or $p.SignatureKind -eq 'System')
|
||||
}
|
||||
}
|
||||
$uninstall = 'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall'
|
||||
foreach ($hive in @('LocalMachine','CurrentUser')) {
|
||||
foreach ($view in @('Registry64','Registry32')) {
|
||||
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey($hive,$view)
|
||||
try {
|
||||
$root = $base.OpenSubKey($uninstall)
|
||||
if (-not $root) { continue }
|
||||
try {
|
||||
foreach ($name in $root.GetSubKeyNames()) {
|
||||
$key = $root.OpenSubKey($name)
|
||||
if (-not $key) { continue }
|
||||
try {
|
||||
$display = [string]$key.GetValue('DisplayName')
|
||||
if ([string]::IsNullOrWhiteSpace($display)) { continue }
|
||||
if (-not $key.GetValue('UninstallString')) { continue }
|
||||
[pscustomobject]@{
|
||||
Id = "desktop:$hive/$view/$name"; Name = $display
|
||||
Version = [string]$key.GetValue('DisplayVersion'); Publisher = [string]$key.GetValue('Publisher')
|
||||
Kind = 'Desktop'; Scope = $(if ($hive -eq 'LocalMachine') {'machine'} else {'user'})
|
||||
Target = $name
|
||||
Protected = [bool]($key.GetValue('SystemComponent',0) -eq 1 -or $key.GetValue('ParentKeyName') -or $key.GetValue('ReleaseType'))
|
||||
}
|
||||
} finally { $key.Dispose() }
|
||||
}
|
||||
} finally { $root.Dispose() }
|
||||
} finally { $base.Dispose() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-WindAppPlan {
|
||||
param(
|
||||
[Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Inventory,
|
||||
[string[]]$ExtraId = @(), [string[]]$ExcludeId = @(),
|
||||
[switch]$SkipBundled, [switch]$KeepOneDrive, [switch]$KeepEdge
|
||||
)
|
||||
$catalog = @(Get-Content -LiteralPath (Join-Path $PSScriptRoot '../config/debloat.json') -Raw | ConvertFrom-Json)
|
||||
foreach ($id in @($ExtraId + $ExcludeId)) {
|
||||
if ($Inventory.Id -notcontains $id) { throw "Selection no longer installed: $id. Refresh the app list." }
|
||||
}
|
||||
foreach ($app in $Inventory) {
|
||||
$reason = 'Personal app; opt in to remove'
|
||||
$selected = $ExtraId -contains $app.Id
|
||||
if (-not $SkipBundled -and $app.Kind -eq 'Appx' -and $catalog.Name -contains $app.Name) {
|
||||
$selected = $true; $reason = 'Bundled/promotional catalog'
|
||||
}
|
||||
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
|
||||
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'
|
||||
}
|
||||
[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 }
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-WindWinget {
|
||||
param([Parameter(Mandatory)][string[]]$Arguments)
|
||||
$command = Get-Command winget.exe -CommandType Application -ErrorAction Stop
|
||||
# An argument array, never Invoke-Expression or a registry-provided command string.
|
||||
& $command.Source @Arguments | Out-Host
|
||||
return [int]$LASTEXITCODE
|
||||
}
|
||||
|
||||
function New-WindAppBackend {
|
||||
@{
|
||||
Inventory = { @(Get-WindAppInventory) }
|
||||
BrowserPresent = {
|
||||
param($browser)
|
||||
$code = Invoke-WindWinget @('list','--id',$browser.Id,'--exact','--source','winget','--disable-interactivity')
|
||||
if ($code -eq 0) { return $true }
|
||||
# APPINSTALLER_CLI_ERROR_NO_APPLICATIONS_FOUND (0x8A150014).
|
||||
if ($code -eq -1978335212) { return $false }
|
||||
throw "Unable to verify $($browser.Name) installation (WinGet $code)."
|
||||
}
|
||||
InstallBrowser = {
|
||||
param($browser)
|
||||
Invoke-WindWinget @('install','--id',$browser.Id,'--exact','--source','winget','--interactive','--disable-interactivity')
|
||||
}
|
||||
Remove = {
|
||||
param($app)
|
||||
if ($app.Kind -eq 'Appx') {
|
||||
Remove-AppxPackage -Package $app.Target -ErrorAction Stop
|
||||
return 0
|
||||
}
|
||||
Invoke-WindWinget @('uninstall','--name',$app.Name,'--exact','--scope',$app.Scope,'--source','winget','--interactive','--disable-interactivity')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-WindAppChanges {
|
||||
[CmdletBinding(SupportsShouldProcess)]
|
||||
param(
|
||||
[Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Plan,
|
||||
[ValidateSet('Zen','Helium','Tor')][string[]]$Browser = @('Zen'),
|
||||
[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)
|
||||
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 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 (-not $PSCmdlet.ShouldProcess('Selected browsers and applications', 'Install browsers, then uninstall selected apps (app data may be removed)')) {
|
||||
return [pscustomobject]@{ Status='Preview'; Entries=@() }
|
||||
}
|
||||
$report = [pscustomobject]@{
|
||||
Schema=1; CreatedUtc=[DateTime]::UtcNow.ToString('o'); Status='Running'
|
||||
Recovery='App removal is not transactional. Reinstall from official sources; app data requires your own backup.'
|
||||
Entries=@()
|
||||
}
|
||||
& $Save $report
|
||||
try {
|
||||
# All chosen browsers must be available before the first destructive action.
|
||||
foreach ($browserItem in $browsers) {
|
||||
$entry = [pscustomobject]@{ Id=$browserItem.Id; Name=$browserItem.Name; Action='Install'; Status='Checking'; ExitCode=$null }
|
||||
$report.Entries += $entry; & $Save $report
|
||||
if (-not (& $Backend.BrowserPresent $browserItem)) {
|
||||
$entry.Status='Installing'; & $Save $report
|
||||
$entry.ExitCode = & $Backend.InstallBrowser $browserItem
|
||||
if ($entry.ExitCode -ne 0) { $entry.Status='Failed'; throw "Browser installation failed ($($entry.ExitCode)); no apps removed." }
|
||||
if (-not (& $Backend.BrowserPresent $browserItem)) { $entry.Status='Unverified'; throw 'Replacement browser not found after installation; no apps removed.' }
|
||||
}
|
||||
$entry.Status='Installed'; & $Save $report
|
||||
}
|
||||
foreach ($app in $remove) {
|
||||
$entry = [pscustomobject]@{ Id=$app.Id; Name=$app.Name; Action='Remove'; Status='Checking'; ExitCode=$null }
|
||||
$report.Entries += $entry; & $Save $report
|
||||
$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])) {
|
||||
$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) {
|
||||
$entry.Status='Conflict'; throw 'Desktop registration is now ambiguous; no further removals performed.'
|
||||
}
|
||||
$entry.Status='Removing'; & $Save $report
|
||||
$entry.ExitCode = & $Backend.Remove $current[0]
|
||||
if ($entry.ExitCode -ne 0) {
|
||||
$entry.Status='Failed'; throw "Uninstaller returned $($entry.ExitCode) for $($app.Name). It may be cancelled, unsupported or require a restart. No further removals performed."
|
||||
}
|
||||
$remaining = @(& $Backend.Inventory | Where-Object { $_.Id -eq $app.Id -or ($_.Kind -eq $app.Kind -and $_.Name -ceq $app.Name -and $_.Scope -eq $app.Scope) })
|
||||
if ($remaining.Count) { $entry.Status='Unverified'; throw "$($app.Name) remains registered. No removal success claimed; no further removals performed." }
|
||||
$entry.Status='Removed'; & $Save $report
|
||||
}
|
||||
$report.Status='Completed'; & $Save $report
|
||||
return $report
|
||||
} catch {
|
||||
$report.Status='Incomplete'
|
||||
try { & $Save $report } catch { Write-Warning 'Could not update the operation report. Keep its last saved copy.' }
|
||||
throw
|
||||
}
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function Get-WindBrowserCatalog,Get-WindAppInventory,Get-WindAppPlan,Test-WindProtectedApp,New-WindAppBackend,Invoke-WindAppChanges
|
||||
@@ -0,0 +1,248 @@
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Assert-WindMutationAllowed {
|
||||
if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { throw 'Windows is required.' }
|
||||
if (-not [Environment]::Is64BitProcess) { throw 'Use 64-bit PowerShell for changes.' }
|
||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
try {
|
||||
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
|
||||
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Run PowerShell as administrator using the same Windows account, then rerun the command.' }
|
||||
} finally { $identity.Dispose() }
|
||||
$computer = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop
|
||||
if ($computer.PartOfDomain) { throw 'Domain-managed computer: use administrator-managed deployment.' }
|
||||
$mdmPath = 'HKLM:\SOFTWARE\Microsoft\Provisioning\OMADM\Accounts'
|
||||
if ((Test-Path -LiteralPath $mdmPath) -and @(Get-ChildItem -LiteralPath $mdmPath -ErrorAction Stop).Count -gt 0) { throw 'MDM enrollment detected. Use administrator-managed deployment.' }
|
||||
}
|
||||
|
||||
function Get-WindCatalog {
|
||||
$items = Get-Content -LiteralPath (Join-Path $PSScriptRoot '../config/policies.json') -Raw | ConvertFrom-Json
|
||||
foreach ($item in $items) { $item }
|
||||
}
|
||||
|
||||
function Get-WindContext {
|
||||
$key = [Microsoft.Win32.RegistryKey]::OpenBaseKey('LocalMachine', 'Registry64')
|
||||
try {
|
||||
$cv = $key.OpenSubKey('SOFTWARE\Microsoft\Windows NT\CurrentVersion')
|
||||
try {
|
||||
[pscustomobject]@{
|
||||
Edition = [string]$cv.GetValue('EditionID')
|
||||
Build = [int]$cv.GetValue('CurrentBuildNumber')
|
||||
Revision = [int]$cv.GetValue('UBR')
|
||||
}
|
||||
} finally { if ($cv) { $cv.Dispose() } }
|
||||
} finally { $key.Dispose() }
|
||||
}
|
||||
|
||||
function New-WindRegistryStore {
|
||||
# Explicit 64-bit registry view; never fall through to HKCU or a redirected view.
|
||||
@{
|
||||
Read = {
|
||||
param($Policy)
|
||||
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey('LocalMachine', 'Registry64')
|
||||
try {
|
||||
$key = $base.OpenSubKey($Policy.Path, $false)
|
||||
try {
|
||||
if (-not $key -or $key.GetValueNames() -notcontains $Policy.Name) {
|
||||
return [pscustomobject]@{ Exists = $false; Kind = $null; Value = $null }
|
||||
}
|
||||
[pscustomobject]@{
|
||||
Exists = $true
|
||||
Kind = $key.GetValueKind($Policy.Name).ToString()
|
||||
Value = $key.GetValue($Policy.Name, $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
|
||||
}
|
||||
} finally { if ($key) { $key.Dispose() } }
|
||||
} finally { $base.Dispose() }
|
||||
}
|
||||
Write = {
|
||||
param($Policy, $State)
|
||||
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey('LocalMachine', 'Registry64')
|
||||
try {
|
||||
if ($State.Exists) {
|
||||
$key = $base.CreateSubKey($Policy.Path)
|
||||
try { $key.SetValue($Policy.Name, [int]$State.Value, [Microsoft.Win32.RegistryValueKind]::DWord) }
|
||||
finally { $key.Dispose() }
|
||||
} else {
|
||||
$key = $base.OpenSubKey($Policy.Path, $true)
|
||||
if ($key) {
|
||||
try { $key.DeleteValue($Policy.Name, $false) } finally { $key.Dispose() }
|
||||
}
|
||||
}
|
||||
} finally { $base.Dispose() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Test-WindStateEqual {
|
||||
param($Left, $Right)
|
||||
if ($Left.Exists -ne $Right.Exists) { return $false }
|
||||
if (-not $Left.Exists) { return $true }
|
||||
return $Left.Kind -eq $Right.Kind -and $Left.Value -eq $Right.Value
|
||||
}
|
||||
|
||||
function Get-WindPlan {
|
||||
param(
|
||||
[Parameter(Mandatory)]$Context,
|
||||
[Parameter(Mandatory)][hashtable]$Store,
|
||||
[ValidateSet('Standard','Strict')][string]$Profile = 'Standard',
|
||||
[ValidateSet('Keep','Automatic','Notify','Manual')][string]$Updates = 'Keep',
|
||||
[string[]]$Id = @()
|
||||
)
|
||||
$catalog = @(Get-WindCatalog)
|
||||
foreach ($item in $Id) {
|
||||
if ($catalog.Id -notcontains $item) { throw "Unknown policy ID: $item" }
|
||||
}
|
||||
$supportedEdition = $Context.Edition -in @('Professional','ProfessionalN','ProfessionalEducation','ProfessionalEducationN','ProfessionalWorkstation','ProfessionalWorkstationN','Enterprise','EnterpriseN','Education','EducationN','EnterpriseS','EnterpriseSN')
|
||||
$supportedBuild = $Context.Build -in @(26100,26200)
|
||||
foreach ($policy in $catalog) {
|
||||
if ($Id.Count -gt 0 -and $Id -notcontains $policy.Id) { continue }
|
||||
$desired = $policy.$Profile
|
||||
if ($null -ne $policy.EnterpriseValue -and $Context.Edition -match '^(Enterprise|Education)') { $desired = $policy.EnterpriseValue }
|
||||
if ($policy.Id -eq 'updates-auto' -and $Updates -ne 'Keep') { $desired = if ($Updates -eq 'Manual') { 1 } else { 0 } }
|
||||
if ($policy.Id -eq 'updates-options' -and $Updates -in @('Notify','Automatic')) { $desired = if ($Updates -eq 'Notify') { 2 } else { 4 } }
|
||||
if ($null -eq $desired) { continue }
|
||||
$status = 'WouldChange'
|
||||
$reason = 'Only policy configuration is checked. Behavioral and network verification remain required.'
|
||||
$before = $null
|
||||
try {
|
||||
$before = & $Store.Read $policy
|
||||
if ($before.Exists -and $before.Kind -ne 'DWord') {
|
||||
$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 })
|
||||
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.'
|
||||
}
|
||||
}
|
||||
if (-not $supportedEdition -or -not $supportedBuild) {
|
||||
$status = 'Unsupported'
|
||||
$reason = 'This edition/build is audit-only. Registry presence does not establish policy enforcement; use Guide.'
|
||||
}
|
||||
} catch {
|
||||
$status = 'ReadError'; $reason = 'Could not read policy state. No protection can be inferred.'
|
||||
}
|
||||
[pscustomobject]@{
|
||||
Id=$policy.Id; Title=$policy.Title; Status=$status; Before=$before
|
||||
Desired=[int]$desired; Reason=$reason; Impact=$policy.Impact; Source=$policy.Source
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) }
|
||||
}
|
||||
|
||||
function Assert-WindJournal {
|
||||
param($Journal, [string]$InstallationId)
|
||||
if ($Journal.Schema -ne 1 -or $Journal.InstallationId -cne $InstallationId -or [string]::IsNullOrWhiteSpace($InstallationId)) {
|
||||
throw 'Journal schema or originating installation does not match.'
|
||||
}
|
||||
$catalog = @(Get-WindCatalog)
|
||||
$seen = @{}
|
||||
if (@($Journal.Entries).Count -gt $catalog.Count) { throw 'Journal has too many entries.' }
|
||||
foreach ($entry in $Journal.Entries) {
|
||||
$policy = @($catalog | Where-Object Id -EQ $entry.Id)
|
||||
if ($policy.Count -ne 1 -or $seen.ContainsKey($entry.Id)) { throw 'Unknown or duplicate policy in journal.' }
|
||||
$seen[$entry.Id] = $true
|
||||
if ($entry.Status -notin @('Pending','Writing','Written','Restored','NotChanged','Conflict','RestoreFailed')) { throw 'Invalid journal entry status.' }
|
||||
$allowed = @($policy[0].Standard, $policy[0].Strict, $policy[0].EnterpriseValue) | Where-Object { $null -ne $_ }
|
||||
if ($entry.Id -eq 'updates-auto') { $allowed = @(0,1) }
|
||||
if ($entry.Id -eq 'updates-options') { $allowed = @(2,4) }
|
||||
if ($entry.After.Exists -isnot [bool] -or -not $entry.After.Exists -or $entry.After.Kind -ne 'DWord' -or $allowed -notcontains $entry.After.Value) { throw 'Invalid target state in journal.' }
|
||||
if ($entry.Before.Exists -isnot [bool]) { throw 'Invalid original state in journal.' }
|
||||
if ($entry.Before.Exists) {
|
||||
if ($entry.Before.Kind -ne 'DWord' -or $entry.Before.Value -isnot [ValueType] -or [double]$entry.Before.Value -ne [int]$entry.Before.Value) { throw 'Invalid original DWORD in journal.' }
|
||||
} elseif ($null -ne $entry.Before.Value -or $null -ne $entry.Before.Kind) { throw 'Invalid absent original state.' }
|
||||
}
|
||||
}
|
||||
|
||||
function Restore-WindTransaction {
|
||||
param([Parameter(Mandatory)]$Journal, [Parameter(Mandatory)][hashtable]$Store,
|
||||
[Parameter(Mandatory)][scriptblock]$Save, [Parameter(Mandatory)][string]$InstallationId)
|
||||
# Validate every entry before the first write. Journal paths are never used as registry targets.
|
||||
Assert-WindJournal $Journal $InstallationId
|
||||
$catalog = @(Get-WindCatalog)
|
||||
$entries = @($Journal.Entries)
|
||||
[array]::Reverse($entries)
|
||||
$problems = 0
|
||||
foreach ($entry in $entries) {
|
||||
# Never roll back a setting we had not started writing, or a completed restoration.
|
||||
if ($entry.Status -in @('Pending','NotChanged','Restored')) {
|
||||
if ($entry.Status -eq 'Pending') { $entry.Status = 'NotChanged' }
|
||||
continue
|
||||
}
|
||||
$policy = $catalog | Where-Object Id -EQ $entry.Id
|
||||
try {
|
||||
$current = & $Store.Read $policy
|
||||
if (Test-WindStateEqual $current $entry.Before) { $entry.Status = 'Restored' }
|
||||
elseif (Test-WindStateEqual $current $entry.After) {
|
||||
& $Store.Write $policy $entry.Before
|
||||
if (-not (Test-WindStateEqual (& $Store.Read $policy) $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 -eq 0) { 'Restored' } else { 'RestoreIncomplete' }
|
||||
& $Save $Journal
|
||||
return $Journal
|
||||
}
|
||||
|
||||
function Invoke-WindTransaction {
|
||||
param([Parameter(Mandatory)][object[]]$Plan, [Parameter(Mandatory)]$Context,
|
||||
[Parameter(Mandatory)][hashtable]$Store, [Parameter(Mandatory)][scriptblock]$Save,
|
||||
[Parameter(Mandatory)][string]$InstallationId)
|
||||
if (@($Plan | Where-Object Status -NotIn @('WouldChange','Configured')).Count -gt 0) { throw 'Plan includes unsupported, conflicting or unreadable settings. No changes made.' }
|
||||
$catalog = @(Get-WindCatalog)
|
||||
$entries = @($Plan | Where-Object Status -EQ 'WouldChange' | ForEach-Object {
|
||||
[pscustomobject]@{ Id=$_.Id; Before=$_.Before; After=[pscustomobject]@{ Exists=$true; Kind='DWord'; Value=$_.Desired }; Status='Pending' }
|
||||
})
|
||||
$journal = [pscustomobject]@{
|
||||
Schema=1; InstallationId=$InstallationId; CreatedUtc=[DateTime]::UtcNow.ToString('o')
|
||||
Edition=$Context.Edition; Build=$Context.Build; Status='Prepared'; Entries=$entries
|
||||
}
|
||||
Assert-WindJournal $journal $InstallationId
|
||||
& $Save $journal
|
||||
try {
|
||||
foreach ($entry in $entries) {
|
||||
$policy = $catalog | Where-Object Id -EQ $entry.Id
|
||||
if (-not (Test-WindStateEqual (& $Store.Read $policy) $entry.Before)) { throw "State changed since preview: $($entry.Id)" }
|
||||
$entry.Status = 'Writing'
|
||||
& $Save $journal
|
||||
& $Store.Write $policy $entry.After
|
||||
if (-not (Test-WindStateEqual (& $Store.Read $policy) $entry.After)) { throw "Write verification failed: $($entry.Id)" }
|
||||
$entry.Status = 'Written'
|
||||
& $Save $journal
|
||||
}
|
||||
$journal.Status = 'Applied'
|
||||
& $Save $journal
|
||||
return $journal
|
||||
} catch {
|
||||
$failure = $_.Exception.Message
|
||||
$journal.Status = 'ApplyFailed'
|
||||
try {
|
||||
$restored = Restore-WindTransaction -Journal $journal -Store $Store -Save $Save -InstallationId $InstallationId
|
||||
throw "Apply failed ($failure). Recovery status: $($restored.Status). Keep the journal."
|
||||
} catch {
|
||||
throw "Transaction failed: $failure. Recovery: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function Assert-WindMutationAllowed,Get-WindCatalog,Get-WindContext,New-WindRegistryStore,Get-WindPlan,Save-WindJournal,Assert-WindJournal,Invoke-WindTransaction,Restore-WindTransaction
|
||||
@@ -0,0 +1,89 @@
|
||||
#Requires -Version 5.1
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Get-WindUpdateTarget {
|
||||
$cv = Get-ItemProperty -LiteralPath 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion'
|
||||
$arch = [Environment]::GetEnvironmentVariable('PROCESSOR_ARCHITEW6432')
|
||||
if (-not $arch) { $arch = [Environment]::GetEnvironmentVariable('PROCESSOR_ARCHITECTURE') }
|
||||
$arch = switch ($arch) { 'AMD64' {'x64'} 'ARM64' {'arm64'} 'x86' {'x86'} default { throw 'Unrecognized OS architecture.' } }
|
||||
[pscustomobject]@{ Build=[int]$cv.CurrentBuildNumber; Revision=[int]$cv.UBR; Edition=[string]$cv.EditionID; Architecture=$arch }
|
||||
}
|
||||
|
||||
function Assert-WindLocalPlainPath {
|
||||
param([Parameter(Mandatory)][string]$Path)
|
||||
# Reject shares/device paths before a filesystem query can initiate remote access.
|
||||
if ($Path -notmatch '^[A-Za-z]:[\\/]' -or $Path.Substring(2).Contains(':')) { throw 'An absolute local drive path is required; UNC, device paths and alternate streams are refused.' }
|
||||
$full = [IO.Path]::GetFullPath($Path)
|
||||
$drive = [IO.Path]::GetPathRoot($full)
|
||||
$driveInfo = [IO.DriveInfo]::new($drive)
|
||||
if ($driveInfo.DriveType -notin @([IO.DriveType]::Fixed,[IO.DriveType]::Removable,[IO.DriveType]::CDRom)) { throw 'Only local fixed, removable or optical storage is accepted.' }
|
||||
$cursor = $full
|
||||
while ($cursor) {
|
||||
$item = Get-Item -LiteralPath $cursor -Force -ErrorAction Stop
|
||||
if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Reparse points are refused in the bundle path.' }
|
||||
if ($cursor.TrimEnd('\','/') -eq $drive.TrimEnd('\','/')) { break }
|
||||
$cursor = [IO.Path]::GetDirectoryName($cursor.TrimEnd('\','/'))
|
||||
}
|
||||
return $full
|
||||
}
|
||||
|
||||
function Assert-WindFields {
|
||||
param($Object,[string[]]$Names,[string]$Label)
|
||||
if ($null -eq $Object -or $Object -isnot [pscustomobject]) { throw "$Label must be an object." }
|
||||
$actual = @($Object.PSObject.Properties.Name)
|
||||
if ($actual.Count -ne $Names.Count -or @($actual | Where-Object { $_ -cnotin $Names }).Count) { throw "$Label has missing or unknown fields." }
|
||||
}
|
||||
|
||||
function Test-WindOfflineBundle {
|
||||
[CmdletBinding()]
|
||||
param([Parameter(Mandatory)][string]$Manifest,[Parameter(Mandatory)]$Target)
|
||||
$manifestPath = Assert-WindLocalPlainPath $Manifest
|
||||
$info = Get-Item -LiteralPath $manifestPath
|
||||
if ($info.PSIsContainer -or $info.Length -gt 1048576) { throw 'Manifest must be a file no larger than 1 MiB.' }
|
||||
$bundle = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json
|
||||
Assert-WindFields $bundle @('Schema','Target','CollectedOn','Packages') 'Manifest'
|
||||
if ($bundle.Schema -isnot [int] -and $bundle.Schema -isnot [long]) { throw 'Schema must be an integer.' }
|
||||
if ($bundle.Schema -ne 1) { throw 'Unsupported manifest schema.' }
|
||||
Assert-WindFields $bundle.Target @('Build','Architecture') 'Target'
|
||||
if (($bundle.Target.Build -isnot [int] -and $bundle.Target.Build -isnot [long]) -or $bundle.Target.Build -lt 10000) { throw 'Target build must be an integer Windows build.' }
|
||||
if ($bundle.Target.Architecture -cnotin @('x64','arm64','x86')) { throw 'Invalid target architecture.' }
|
||||
if ($bundle.Target.Build -ne $Target.Build -or $bundle.Target.Architecture -cne $Target.Architecture) { throw 'Bundle OS build or architecture does not match the target.' }
|
||||
$date = [datetime]::MinValue
|
||||
if ($bundle.CollectedOn -isnot [string] -or -not [datetime]::TryParseExact($bundle.CollectedOn,'yyyy-MM-dd',[Globalization.CultureInfo]::InvariantCulture,[Globalization.DateTimeStyles]::None,[ref]$date)) { throw 'CollectedOn must be yyyy-MM-dd.' }
|
||||
if ($date.Date -gt [datetime]::UtcNow.Date) { throw 'Collection date cannot be in the future.' }
|
||||
if ($bundle.Packages -isnot [array] -or $bundle.Packages.Count -lt 1 -or $bundle.Packages.Count -gt 1000) { throw 'Packages must be an array containing 1 to 1000 entries.' }
|
||||
$seenIds=@{}; $seenFiles=@{}; $rows=@(); $root=[IO.Path]::GetDirectoryName($manifestPath)
|
||||
foreach ($p in $bundle.Packages) {
|
||||
Assert-WindFields $p @('Id','File','Sha256','Source','Kind','Requires') 'Package'
|
||||
if ($p.Id -isnot [string] -or $p.Id -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$' -or $seenIds.ContainsKey($p.Id)) { throw 'Invalid or duplicate package ID.' }
|
||||
if ($p.File -isnot [string] -or $p.File -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,180}\.(msu|cab|exe|msi|msix|msixbundle|appx|appxbundle)$' -or $p.File -match '^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])\.' -or $seenFiles.ContainsKey($p.File)) { throw 'Invalid or duplicate package filename; use a plain leaf filename.' }
|
||||
if ($p.Sha256 -isnot [string] -or $p.Sha256 -notmatch '^[A-Fa-f0-9]{64}$') { throw 'Package requires a SHA-256 digest.' }
|
||||
$uri=$null
|
||||
if ($p.Source -isnot [string] -or -not [uri]::TryCreate($p.Source,[UriKind]::Absolute,[ref]$uri) -or $uri.Scheme -ne 'https' -or $uri.UserInfo -or -not $uri.Host) { throw 'Source must be an HTTPS provenance URL without credentials.' }
|
||||
if ($p.Kind -cnotin @('Windows','DotNet','Defender','Browser','Trust','Firmware','ScanCatalog','Application')) { throw 'Unknown package kind.' }
|
||||
if ($p.Requires -isnot [array]) { throw 'Requires must be an array.' }
|
||||
$dependencies=@{}
|
||||
foreach ($required in $p.Requires) {
|
||||
if ($required -isnot [string] -or -not $seenIds.ContainsKey($required) -or $dependencies.ContainsKey($required)) { throw 'Dependencies must be unique earlier package IDs. Missing, forward and cyclic dependencies are refused.' }
|
||||
$dependencies[$required]=$true
|
||||
}
|
||||
$file = Assert-WindLocalPlainPath (Join-Path $root $p.File)
|
||||
$fileInfo=Get-Item -LiteralPath $file
|
||||
if ($fileInfo.PSIsContainer) { throw 'A package cannot be a directory.' }
|
||||
$hash=(Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash
|
||||
if ($hash -ine $p.Sha256) { throw "Hash mismatch for package $($p.Id)." }
|
||||
$rows += [pscustomobject]@{ Id=$p.Id; File=$p.File; Kind=$p.Kind; Integrity='MatchesManifest'; Source=$p.Source; Requires=@($p.Requires) }
|
||||
$seenIds[$p.Id]=$true; $seenFiles[$p.File]=$true
|
||||
}
|
||||
# Hash equality is deliberately not presented as a signature or applicability result.
|
||||
[pscustomobject]@{
|
||||
Schema=1; Status='IntegrityVerified'; PackageCount=$rows.Count
|
||||
Target=[pscustomobject]@{Build=$Target.Build;Architecture=$Target.Architecture}
|
||||
CollectedOn=$bundle.CollectedOn; AgeDays=[int]([datetime]::UtcNow.Date-$date.Date).TotalDays
|
||||
Authenticity='NotVerified'; Applicability='NotVerified'; UpdateCoverage='NotAssessed'
|
||||
NetworkIsolation='NotAssessed'; Installation='NotPerformed'; Packages=$rows
|
||||
Limits='Unsigned manifest and hashes detect changes relative to that manifest only. They do not establish publisher identity, current revocation status, missing prerequisites or patch completeness. No network calls or installers are invoked by this module.'
|
||||
}
|
||||
}
|
||||
Export-ModuleMember -Function Get-WindUpdateTarget,Test-WindOfflineBundle
|
||||
@@ -0,0 +1,136 @@
|
||||
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 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 }
|
||||
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 }
|
||||
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
|
||||
}
|
||||
'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.'} }
|
||||
}
|
||||
'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.' }
|
||||
}
|
||||
'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.'}
|
||||
}
|
||||
}
|
||||
'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]]
|
||||
}
|
||||
'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.'
|
||||
}
|
||||
'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 }
|
||||
}
|
||||
'9' { Start-Process 'ms-settings:defaultapps' }
|
||||
'G' { & (Join-Path $Root 'Wind.ps1') -Mode Guide }
|
||||
default { Write-Host 'Choose a menu item.' }
|
||||
}
|
||||
} catch { Write-Host $_.Exception.Message -ForegroundColor Red }
|
||||
}
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function Start-WindMenu,ConvertFrom-WindChoice
|
||||
Reference in new issue
Block a user