first commit

This commit is contained in:
unknown committed 2026-09-19 20:55:32 -04:00
1 parent 831814283e
commit 7aacc0b63f
29 files changed
+5114

No files matched your search

+198
View File
@@ -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
+248
View File
@@ -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
+89
View File
@@ -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
+136
View File
@@ -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