Files
2026-06-02 03:37:09 -07:00

568 lines
22 KiB
PowerShell

# UpdatePackages-oxmc.ps1
#
# Fetches two manifests from cdn.oxmc.me and applies any pending updates:
#
# iso-manifest.json -- versioned zip of C:\Windows\OEM contents
# (scripts, cursors, config -- the stuff baked into the ISO)
# internal-manifest.json -- individual app packages (exe/msi/msix/ps1/zip)
#
# Update state is written to C:\Windows\OEM\update-state.json so the script
# is idempotent: already-installed versions are skipped.
#
# Run as: PowerShell -ExecutionPolicy Bypass -File UpdatePackages-oxmc.ps1
#
# ---- iso-manifest.json schema (example) --------------------------------
# {
# "schemaVersion": 1,
# "package": {
# "name": "oxmc-oem-core",
# "version": "1.2.0",
# "description": "OEM scripts, cursors, config",
# "url": "https://cdn.oxmc.me/apps/windows/oem-core-1.2.0.zip",
# "sha256": "abcdef...",
# "installPath": "C:\\Windows\\OEM",
# "postInstallScript":"post-install.ps1"
# }
# }
#
# ---- internal-manifest.json schema (example) ---------------------------
# {
# "schemaVersion": 1,
# "packages": [
# {
# "name": "myapp",
# "version": "2.0.0",
# "description": "My App",
# "displayName": "My App",
# "publishedAt": "2026-01-01T00:00:00Z",
# "enabled": true, // false = skip without error
# "url": "https://cdn.oxmc.me/apps/windows/myapp-2.0.0.exe",
# "sha256": "abcdef...",
# "type": "exe", // exe | msi | msix | ps1 | zip
# "args": ["/quiet"], // extra installer args (exe/msi)
# "installPath": null, // required for zip type
# "minBuild": 22000, // optional: skip below this Windows build
# "maxBuild": null, // optional: skip above this Windows build
# "restartRequired": false, // log restart reminder after install
# "checkInstalled": { // optional: detect existing install
# "type": "registry", // registry | file | appx
# "key": "HKLM:\\SOFTWARE\\...",
# "value": "ValueName", // registry only
# "match": "^2\\." // optional regex against value data
# }
# // file type: { "type": "file", "path": "C:\\Program Files\\..." }
# // appx type: { "type": "appx", "packageFamilyName": "Publisher.App_xyz" }
# },
# {
# "name": "my-app-bundle",
# "version": "1.0.0",
# "type": "packagelist", // download + install each item individually
# "enabled": true,
# "minBuild": null,
# "maxBuild": null,
# "items": [
# {
# "name": "firefox",
# "url": "https://cdn.oxmc.me/...",
# "sha256": "abcdef...",
# "type": "exe",
# "args": ["-ms"],
# "installPath": null,
# "conditions": { "minBuild": 10240, "editions": ["Pro","Enterprise"] },
# "checkInstalled": { "type": "registry", "key": "HKLM:\\...", "value": "CurrentVersion", "match": "." }
# }
# ]
# },
# {
# "name": "my-app", // variant: pick installer per OS/arch
# "version": "2.0.0",
# "enabled": true,
# "minBuild": null, // outer gate runs before variant selection
# "maxBuild": null,
# "variants": [
# {
# "displayName": "My App (Win11 x64)",
# "conditions": { "minBuild": 22000, "architecture": "x64" },
# "url": "https://cdn.oxmc.me/...",
# "sha256": "abcdef...",
# "type": "exe",
# "args": ["/quiet"],
# "installPath": null,
# "checkInstalled": null
# },
# {
# "displayName": "My App (Win10 x64)",
# "conditions": { "minBuild": 10240, "maxBuild": 19045, "architecture": "x64" },
# "url": "https://cdn.oxmc.me/...",
# "sha256": "abcdef...",
# "type": "exe",
# "args": ["/quiet"],
# "installPath": null,
# "checkInstalled": null
# }
# ]
# // conditions fields: minBuild, maxBuild, editions (array), architecture (x64|x86|arm64)
# // first matching variant wins; no match = skip package
# }
# ]
# }
# -------------------------------------------------------------------------
Set-StrictMode -Off
$ErrorActionPreference = "Stop"
# -- Path setup ----------------------------------------------------------------
if ($PSVersionTable.PSVersion.Major -ge 3) {
$currentDir = $PSScriptRoot
} else {
$currentDir = (Get-Item .).FullName
}
. "$currentDir\script-helper.ps1"
$windowsDrive = Split-Path $env:SystemRoot -Qualifier
$oemRoot = "$windowsDrive\Windows\OEM"
$stateFile = "$oemRoot\update-state.json"
$tempDir = "$env:TEMP\oxmc-updates"
$logFile = "$windowsDrive\Windows\Setup\PrePostInstall\Logs\UpdatePackages-$(Get-Date -Format 'yyyyMMdd_HHmmss').log"
# -- Logging -------------------------------------------------------------------
function Write-Log {
param([string]$Msg, [string]$Level = "INFO")
$line = "[$(Get-Date -Format 'HH:mm:ss')] [$Level] $Msg"
Write-Host $line
Add-Content -Path $logFile -Value $line -ErrorAction SilentlyContinue
}
function Write-LogWarn { param([string]$Msg) Write-Log $Msg "WARN" }
function Write-LogError { param([string]$Msg) Write-Log $Msg "ERROR" }
# -- Admin check ---------------------------------------------------------------
if (-not (Test-Administrator)) {
Write-Host "Restarting as administrator..."
$argStr = "-NoProfile -ExecutionPolicy Bypass -File `"$PSCommandPath`""
Start-Process PowerShell -ArgumentList $argStr -Verb RunAs
exit
}
# -- State helpers -------------------------------------------------------------
function Read-State {
if (Test-Path $stateFile) {
try { return Get-Content $stateFile -Raw | ConvertFrom-Json } catch {}
}
return [PSCustomObject]@{ isoPackage = $null; packages = [PSCustomObject]@{} }
}
function Save-State {
param([PSCustomObject]$State)
$State | ConvertTo-Json -Depth 10 | Set-Content $stateFile -Encoding UTF8
}
function Get-InstalledVersion {
param([PSCustomObject]$State, [string]$Name, [string]$Type = "package")
if ($Type -eq "isoPackage") {
return if ($State.isoPackage) { $State.isoPackage.version } else { $null }
}
$pkg = $State.packages."$Name"
return if ($pkg) { $pkg.version } else { $null }
}
function Set-InstalledVersion {
param([PSCustomObject]$State, [string]$Name, [string]$Version, [string]$Type = "package")
if ($Type -eq "isoPackage") {
$State.isoPackage = [PSCustomObject]@{ version = $Version; installedAt = (Get-Date -Format "o") }
} else {
$State.packages | Add-Member -MemberType NoteProperty -Name $Name `
-Value ([PSCustomObject]@{ version = $Version; installedAt = (Get-Date -Format "o") }) `
-Force
}
}
# -- Version comparison --------------------------------------------------------
function Compare-Versions {
param([string]$Installed, [string]$Available)
if (-not $Installed) { return $true }
try {
$i = [Version]$Installed
$a = [Version]$Available
return $a -gt $i
} catch {
return $Installed -ne $Available
}
}
# -- Condition evaluator -------------------------------------------------------
# $Conditions object fields (all optional):
# minBuild int minimum Windows build (inclusive)
# maxBuild int maximum Windows build (inclusive)
# editions array e.g. ["Pro","Enterprise"] - null means any
# architecture string "x64" | "x86" | "arm64" - null means any
function Test-Conditions {
param([PSCustomObject]$Conditions)
if (-not $Conditions) { return $true }
$build = Get-WindowsBuildNumber
if ($Conditions.minBuild -and $build -lt [int]$Conditions.minBuild) { return $false }
if ($Conditions.maxBuild -and $build -gt [int]$Conditions.maxBuild) { return $false }
if ($Conditions.editions) {
$edition = Get-WindowsEdition
$hit = $Conditions.editions | Where-Object { $_ -ieq $edition }
if (-not $hit) { return $false }
}
if ($Conditions.architecture) {
$arch = Get-NativeArchitecture
if ($Conditions.architecture -ine $arch) { return $false }
}
return $true
}
# -- Check if package is already installed on the system -----------------------
function Test-PackageInstalled {
param([PSCustomObject]$Check)
if (-not $Check) { return $false }
switch ($Check.type.ToLower()) {
"registry" {
try {
$val = (Get-ItemProperty -Path $Check.key -Name $Check.value -ErrorAction Stop).$($Check.value)
if ($Check.match) { return [bool]($val -match $Check.match) }
return $true
} catch { return $false }
}
"file" {
return (Test-Path $Check.path)
}
"appx" {
try {
$pkg = Get-AppxPackage -Name "$($Check.packageFamilyName)*" -ErrorAction SilentlyContinue
return ($null -ne $pkg)
} catch { return $false }
}
default {
Write-LogWarn "Unknown checkInstalled type '$($Check.type)' - skipping check."
return $false
}
}
}
# -- Download + verify ---------------------------------------------------------
function Invoke-Download {
param([string]$Url, [string]$Dest, [string]$Sha256)
Write-Log "Downloading: $Url"
$null = New-Item -ItemType Directory -Path (Split-Path $Dest) -Force
Invoke-WebRequest -Uri $Url -OutFile $Dest -UseBasicParsing -ErrorAction Stop
if ($Sha256) {
$actual = (Get-FileHash $Dest -Algorithm SHA256).Hash
if ($actual -ne $Sha256.ToUpper()) {
Remove-Item $Dest -Force
throw "SHA256 mismatch for $Dest`nExpected : $Sha256`nActual : $actual"
}
Write-Log "SHA256 verified OK."
} else {
Write-LogWarn "No SHA256 in manifest for $(Split-Path $Dest -Leaf) - skipping hash check."
}
}
# -- ISO package installer -----------------------------------------------------
function Install-IsoPackage {
param([PSCustomObject]$Pkg, [PSCustomObject]$State)
$installPath = if ($Pkg.installPath) { $Pkg.installPath } else { $oemRoot }
Write-Log "==> ISO package: $($Pkg.name) v$($Pkg.version)"
Write-Log " Install path : $installPath"
$zipPath = "$tempDir\iso-pkg-$($Pkg.version).zip"
$extractDir = "$tempDir\iso-pkg-extract"
try {
Invoke-Download -Url $Pkg.url -Dest $zipPath -Sha256 $Pkg.sha256
# Clean previous extract
if (Test-Path $extractDir) { Remove-Item $extractDir -Recurse -Force }
New-Item -ItemType Directory -Path $extractDir | Out-Null
Write-Log "Extracting package..."
Expand-Archive -Path $zipPath -DestinationPath $extractDir -Force
# Copy all files from extract root into installPath, preserving structure
Write-Log "Applying files to: $installPath"
$null = New-Item -ItemType Directory -Path $installPath -Force
Copy-Item "$extractDir\*" -Destination $installPath -Recurse -Force
# Run post-install script if present
if ($Pkg.postInstallScript) {
$postScript = Join-Path $installPath $Pkg.postInstallScript
if (Test-Path $postScript) {
Write-Log "Running post-install script: $postScript"
& PowerShell -NoProfile -ExecutionPolicy Bypass -File $postScript
if ($LASTEXITCODE -and $LASTEXITCODE -ne 0) {
Write-LogWarn "Post-install script exited with code $LASTEXITCODE"
}
} else {
Write-LogWarn "postInstallScript '$($Pkg.postInstallScript)' not found after extraction."
}
}
Set-InstalledVersion -State $State -Name $Pkg.name -Version $Pkg.version -Type "isoPackage"
Write-Log "ISO package installed successfully."
} finally {
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
Remove-Item $extractDir -Recurse -Force -ErrorAction SilentlyContinue
}
}
# -- Core download-and-run for a single installable item -----------------------
# Used by both Install-Package (single) and Install-Package (packagelist items).
# $Item must have: name, url, type, sha256 (optional), args (optional),
# installPath (required for zip), checkInstalled (optional)
function Invoke-PackageInstall {
param([PSCustomObject]$Item, [string]$Label = $Item.name)
# Per-item conditions: skip if current system doesn't match
if ($Item.conditions -and -not (Test-Conditions -Conditions $Item.conditions)) {
Write-Log " [$Label] Conditions not met - skipping."
return
}
# Per-item checkInstalled: skip if already present on system
if ($Item.checkInstalled -and (Test-PackageInstalled -Check $Item.checkInstalled)) {
Write-Log " [$Label] Already installed (check passed) - skipping."
return
}
$ext = switch ($Item.type.ToLower()) {
"exe" { ".exe" }
"msi" { ".msi" }
"msix" { ".msix" }
"ps1" { ".ps1" }
"zip" { ".zip" }
default { throw "Unknown package type: $($Item.type)" }
}
$destFile = "$tempDir\$Label$ext"
$extraArgs = if ($Item.args) { $Item.args } else { @() }
try {
Invoke-Download -Url $Item.url -Dest $destFile -Sha256 $Item.sha256
switch ($Item.type.ToLower()) {
"exe" {
Write-Log " [$Label] Running installer..."
Start-Process $destFile -ArgumentList $extraArgs -Wait -NoNewWindow
if ($LASTEXITCODE -and $LASTEXITCODE -notin @(0, 3010)) {
throw "Installer exited with code $LASTEXITCODE"
}
}
"msi" {
Write-Log " [$Label] Running msiexec..."
$msiArgs = @("/i", $destFile, "/quiet", "/norestart") + $extraArgs
Start-Process msiexec.exe -ArgumentList $msiArgs -Wait -NoNewWindow
if ($LASTEXITCODE -and $LASTEXITCODE -notin @(0, 3010)) {
throw "msiexec exited with code $LASTEXITCODE"
}
}
"msix" {
Write-Log " [$Label] Installing MSIX/Appx..."
Add-AppxPackage -Path $destFile -ErrorAction Stop
}
"ps1" {
Write-Log " [$Label] Running PowerShell script..."
& PowerShell -NoProfile -ExecutionPolicy Bypass -File $destFile @extraArgs
if ($LASTEXITCODE -and $LASTEXITCODE -ne 0) {
throw "Script exited with code $LASTEXITCODE"
}
}
"zip" {
if (-not $Item.installPath) { throw "Package type 'zip' requires installPath." }
Write-Log " [$Label] Extracting to $($Item.installPath)..."
$null = New-Item -ItemType Directory -Path $Item.installPath -Force
Expand-Archive -Path $destFile -DestinationPath $Item.installPath -Force
}
}
Write-Log " [$Label] OK."
} finally {
Remove-Item $destFile -Force -ErrorAction SilentlyContinue
}
}
# -- Individual package / packagelist installer --------------------------------
function Install-Package {
param([PSCustomObject]$Pkg, [PSCustomObject]$State)
Write-Log "==> Package: $($Pkg.name) v$($Pkg.version) [$($Pkg.type)]"
# Outer build gate - quick reject before variant evaluation
$build = Get-WindowsBuildNumber
if ($Pkg.minBuild -and $build -lt [int]$Pkg.minBuild) {
Write-Log " Skipping: requires build >= $($Pkg.minBuild) (current: $build)"
return
}
if ($Pkg.maxBuild -and $build -gt [int]$Pkg.maxBuild) {
Write-Log " Skipping: requires build <= $($Pkg.maxBuild) (current: $build)"
return
}
# Variants: first matching variant wins; provides url/type/args/etc for this system
if ($Pkg.variants -and $Pkg.variants.Count -gt 0) {
$matched = $null
foreach ($v in $Pkg.variants) {
if (Test-Conditions -Conditions $v.conditions) {
$matched = $v
break
}
}
if (-not $matched) {
Write-Log " No variant matched current system - skipping."
return
}
$label = if ($matched.displayName) { $matched.displayName } else { $Pkg.name }
Write-Log " Variant: $label"
Invoke-PackageInstall -Item $matched -Label $Pkg.name
Set-InstalledVersion -State $State -Name $Pkg.name -Version $Pkg.version
Write-Log " Installed OK."
return
}
# packagelist: download and install each item individually
if ($Pkg.type.ToLower() -eq "packagelist") {
if (-not $Pkg.items -or $Pkg.items.Count -eq 0) {
throw "packagelist '$($Pkg.name)' has no items."
}
$listFailed = 0
foreach ($item in $Pkg.items) {
try {
Invoke-PackageInstall -Item $item -Label $item.name
} catch {
Write-LogError " Item '$($item.name)' failed: $_"
$listFailed++
}
}
if ($listFailed -gt 0) { throw "$listFailed item(s) in packagelist '$($Pkg.name)' failed." }
Set-InstalledVersion -State $State -Name $Pkg.name -Version $Pkg.version
Write-Log " PackageList installed OK."
return
}
# Single package - delegate to shared helper
Invoke-PackageInstall -Item $Pkg -Label $Pkg.name
Set-InstalledVersion -State $State -Name $Pkg.name -Version $Pkg.version
Write-Log " Installed OK."
}
# -- Entry point ---------------------------------------------------------------
Write-Log "UpdatePackages-oxmc starting."
Write-Log "OEM root : $oemRoot"
Write-Log "State : $stateFile"
$null = New-Item -ItemType Directory -Path $tempDir -Force
$state = Read-State
# -- 1. Fetch manifests --------------------------------------------------------
$internalManifestUrl = "https://cdn.oxmc.me/apps/windows/internal-manifest.json"
$isoManifestUrl = "https://cdn.oxmc.me/apps/windows/iso-manifest.json"
try {
Write-Log "Fetching internal manifest..."
$internalManifest = Get-ContentFromUrl -url $internalManifestUrl | ConvertFrom-Json
} catch {
Write-LogError "Failed to fetch internal manifest: $_"
exit 1
}
try {
Write-Log "Fetching ISO manifest..."
$isoManifest = Get-ContentFromUrl -url $isoManifestUrl | ConvertFrom-Json
} catch {
Write-LogError "Failed to fetch ISO manifest: $_"
exit 1
}
$updatesApplied = 0
$updatesFailed = 0
$restartNeeded = $false
# -- 2. ISO package (C:\Windows\OEM contents) ----------------------------------
if ($isoManifest.package) {
$pkg = $isoManifest.package
$installedVer = Get-InstalledVersion -State $state -Name $pkg.name -Type "isoPackage"
if (Compare-Versions -Installed $installedVer -Available $pkg.version) {
Write-Log "ISO package update: $(if ($installedVer) { $installedVer } else { 'not installed' }) -> $($pkg.version)"
try {
Install-IsoPackage -Pkg $pkg -State $state
Save-State -State $state
$updatesApplied++
} catch {
Write-LogError "ISO package install failed: $_"
$updatesFailed++
}
} else {
Write-Log "ISO package $($pkg.name) is up to date ($installedVer)."
}
} else {
Write-LogWarn "ISO manifest has no 'package' entry - skipping."
}
# -- 3. Internal packages ------------------------------------------------------
if ($internalManifest.packages) {
foreach ($pkg in $internalManifest.packages) {
# enabled check
if ($pkg.PSObject.Properties['enabled'] -and $pkg.enabled -eq $false) {
Write-Log "Package $($pkg.name) is disabled - skipping."
continue
}
$installedVer = Get-InstalledVersion -State $state -Name $pkg.name
if (Compare-Versions -Installed $installedVer -Available $pkg.version) {
# No state entry: probe the live system before downloading
if (-not $installedVer -and $pkg.checkInstalled -and (Test-PackageInstalled -Check $pkg.checkInstalled)) {
Write-Log "Package $($pkg.name) detected as already installed - recording as $($pkg.version), skipping download."
Set-InstalledVersion -State $state -Name $pkg.name -Version $pkg.version
Save-State -State $state
continue
}
Write-Log "Package update: $($pkg.name) $(if ($installedVer) { $installedVer } else { 'not installed' }) -> $($pkg.version)"
try {
Install-Package -Pkg $pkg -State $state
Save-State -State $state
$updatesApplied++
if ($pkg.restartRequired -eq $true) { $restartNeeded = $true }
} catch {
Write-LogError "Package '$($pkg.name)' failed: $_"
$updatesFailed++
}
} else {
Write-Log "Package $($pkg.name) is up to date ($installedVer)."
}
}
} else {
Write-LogWarn "Internal manifest has no 'packages' array - skipping."
}
# -- Summary -------------------------------------------------------------------
Write-Log "Done. Applied: $updatesApplied Failed: $updatesFailed"
if ($restartNeeded) {
Write-Log "*** One or more packages require a restart to complete installation. ***" "WARN"
}
# Clean up temp dir if empty
Remove-Item $tempDir -Recurse -Force -ErrorAction SilentlyContinue
if ($updatesFailed -gt 0) {
Write-LogError "Some updates failed - check log: $logFile"
exit 1
}