Files
Workstation-Setup/Win1-Automotive-Workstation-Setup.ps1
T
2026-09-06 20:30:20 -07:00

1563 lines
82 KiB
PowerShell

#requires -Version 5.1
<#
.SYNOPSIS
Configures one isolated automotive Windows workstation using C:, S: (AUTO_DATA), and P: (PORTABLE_APPS).
.DESCRIPTION
- Creates a structured workspace on C: (or a supplied root).
- Installs general-purpose applications through WinGet.
- Downloads, updates, validates, and configures an extended portable application library.
- Creates Desktop and Start Menu shortcuts for the selected workstation profile.
- Configures both shared partitions: S: automotive data and P: portable applications, then creates links and shortcuts.
- Logs every action and continues after non-critical failures.
Re-running is supported. Existing portable applications with their expected executable are
validated and skipped; missing or failed portable applications are retried. WinGet checks
installed applications for upgrades and retries missing applications. Use -ForcePortableUpdates
only when intentionally downloading and replacing validated portable applications.
This script intentionally does NOT download licensed/OEM tools, cracked software,
ECU firmware, DAMOS/A2L collections, or hardware-interface drivers. Install those
from the vendor portal and validate compatibility with your exact interface. The
only exception is -InstallXhorseSoftware, which fetches the Xhorse Multi-PROG and
MVCI PRO J2534 installers directly from Xhorse's own download servers and launches
each one interactively so you can complete licensing, activation, and dongle setup
yourself.
.EXAMPLE
# Run from an elevated Windows PowerShell session in this script's directory.
Set-ExecutionPolicy -Scope Process Bypass
.\Win1-Automotive-Workstation-Setup.ps1 -WorkstationProfile 'DailyTech & Tuning'
.EXAMPLE
# Set up the isolated VAG and Mercedes diagnostic workstation.
.\Win1-Automotive-Workstation-Setup.ps1 -WorkstationProfile 'ODIS & XENTRY'
.EXAMPLE
# Set up the isolated Porsche and BMW diagnostic workstation.
.\Win1-Automotive-Workstation-Setup.ps1 -WorkstationProfile 'PIWIS & ISTA'
.EXAMPLE
# Initial full setup, including optional development and utility applications.
.\Win1-Automotive-Workstation-Setup.ps1 -InstallOptionalApps
.EXAMPLE
# Resume a prior run and retry missing or failed optional packages.
.\Win1-Automotive-Workstation-Setup.ps1 -InstallOptionalApps
.EXAMPLE
# Also fetch and launch the official Xhorse Multi-PROG and MVCI PRO installers.
.\Win1-Automotive-Workstation-Setup.ps1 -InstallXhorseSoftware
.EXAMPLE
# Install communication software on this Windows installation.
# Run this once in each of the three isolated Windows installations.
.\Win1-Automotive-Workstation-Setup.ps1 -WorkstationProfile 'PIWIS & ISTA' -InstallCommunicationSoftware
.EXAMPLE
# Intentionally refresh all portable applications after a successful setup.
.\Win1-Automotive-Workstation-Setup.ps1 -ForcePortableUpdates
#>
[CmdletBinding()]
param(
[string]$LocalRoot = 'C:\Automotive',
[string]$SharedDataRoot = 'S:\Automotive',
[string]$SharedPortableRoot = 'P:\PortableApps',
[string]$SharedDataLabel = 'AUTO_DATA',
[string]$SharedPortableLabel = 'PORTABLE_APPS',
[ValidateSet('DailyTech & Tuning', 'ODIS & XENTRY', 'PIWIS & ISTA')]
[string]$WorkstationProfile = 'DailyTech & Tuning',
[switch]$InstallOptionalApps,
[switch]$CreateSharedLinks = $true,
[switch]$SkipDownloads,
[switch]$SkipWinget,
[switch]$SkipShortcuts,
[switch]$ForcePortableUpdates,
[switch]$InstallXhorseSoftware,
[switch]$InstallCommunicationSoftware,
[switch]$HealthCheck,
[switch]$BackupConfiguration,
[switch]$RestoreConfiguration,
[ValidateRange(1, 10)][int]$DownloadRetryCount = 3,
[ValidateRange(30, 3600)][int]$DownloadTimeoutSeconds = 900,
[switch]$EnableTranscript
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
function Import-SetupConfiguration {
param([Parameter(Mandatory)][string]$Name)
$path = Join-Path $PSScriptRoot (Join-Path 'Config' $Name)
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
throw "Required configuration file is missing: $path"
}
try {
return Get-Content -LiteralPath $path -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop
}
catch {
throw "Configuration file is invalid: $path. $($_.Exception.Message)"
}
}
$Configuration = @{
CorePackages = Import-SetupConfiguration -Name 'apps-core.json'
OptionalApps = Import-SetupConfiguration -Name 'apps-optional.json'
PortableApps = Import-SetupConfiguration -Name 'portable-apps.json'
Folders = Import-SetupConfiguration -Name 'folder-structure.json'
Shortcuts = Import-SetupConfiguration -Name 'shortcuts.json'
Profiles = Import-SetupConfiguration -Name 'workstation-profiles.json'
Automotive = Import-SetupConfiguration -Name 'automotive-resources.json'
}
$Profile = @($Configuration.Profiles.Profiles | Where-Object { $_.Name -eq $WorkstationProfile } | Select-Object -First 1)
if ($Profile.Count -ne 1) {
throw "Workstation profile '$WorkstationProfile' is not defined in Config\workstation-profiles.json."
}
$Profile = $Profile[0]
$ProfileDataRoot = "{0}\Workstations\{1}" -f $SharedDataRoot.TrimEnd('\'), $Profile.Folder
$ProfileConfigRoot = "{0}\Config\{1}" -f $SharedPortableRoot.TrimEnd('\'), $Profile.Folder
$ProfileInstallerRoot = "P:\Install\{0}" -f $Profile.Folder
$Paths = [ordered]@{
Root = $LocalRoot
Apps = Join-Path $LocalRoot 'Apps'
Portable = Join-Path $LocalRoot 'PortableApps' # Local fallback if P: is unavailable
Tools = Join-Path $LocalRoot 'Tools'
Downloads = Join-Path $LocalRoot 'Downloads'
Temp = Join-Path $LocalRoot 'Temp'
Logs = Join-Path $LocalRoot 'Logs'
Config = Join-Path $LocalRoot 'Config'
Scripts = Join-Path $LocalRoot 'Scripts'
Projects = Join-Path $LocalRoot 'Projects'
Tuning = Join-Path $LocalRoot 'Projects\Tuning'
Cloning = Join-Path $LocalRoot 'Projects\Cloning'
ReverseEngineering = Join-Path $LocalRoot 'Projects\ReverseEngineering'
Research = Join-Path $LocalRoot 'Research'
Documentation = Join-Path $LocalRoot 'Documentation'
Drivers = Join-Path $LocalRoot 'Drivers'
Backups = Join-Path $LocalRoot 'Backups'
Exports = Join-Path $LocalRoot 'Exports'
Quarantine = Join-Path $LocalRoot 'Quarantine'
}
$null = New-Item -ItemType Directory -Path $Paths.Logs -Force
$TimeStamp = Get-Date -Format 'yyyyMMdd_HHmmss'
$ScriptStartTime = Get-Date
$LogFile = Join-Path $Paths.Logs ("{0}_Setup_{1}.log" -f $Profile.Folder, $TimeStamp)
$SummaryFile = Join-Path $Paths.Logs ("{0}_Setup_{1}.csv" -f $Profile.Folder, $TimeStamp)
$Results = [System.Collections.Generic.List[object]]::new()
$Events = [System.Collections.Generic.List[object]]::new()
function Write-Log {
param([string]$Message, [ValidateSet('INFO','WARN','ERROR','OK')][string]$Level = 'INFO')
$timestamp = Get-Date
$line = "{0} [{1}] {2}" -f $timestamp.ToString('yyyy-MM-dd HH:mm:ss'), $Level, $Message
Add-Content -LiteralPath $LogFile -Value $line -Encoding UTF8
$Events.Add([pscustomobject]@{ Time=$timestamp; Level=$Level; Message=$Message })
$color = @{ INFO='Gray'; WARN='Yellow'; ERROR='Red'; OK='Green' }[$Level]
Write-Host $line -ForegroundColor $color
}
function Add-Result {
param([string]$Category, [string]$Name, [string]$Status, [string]$Details = '')
$Results.Add([pscustomobject]@{
Time = Get-Date; Category = $Category; Name = $Name; Status = $Status; Details = $Details
})
}
function Invoke-Safe {
param([string]$Category, [string]$Name, [scriptblock]$Action)
try {
& $Action
Add-Result $Category $Name 'Success'
Write-Log "$Name completed." 'OK'
return $true
}
catch {
Add-Result $Category $Name 'Failed' $_.Exception.Message
Write-Log "$Name failed: $($_.Exception.Message)" 'ERROR'
return $false
}
}
function ConvertTo-ExitCodeHex {
param([int]$ExitCode)
return '0x{0:X8}' -f [BitConverter]::ToUInt32([BitConverter]::GetBytes([int32]$ExitCode), 0)
}
function Write-CommandOutput {
param(
[string]$CommandName,
[string]$Operation,
[object[]]$Output,
[ValidateSet('INFO','WARN','ERROR','OK')][string]$Level = 'INFO'
)
foreach ($line in $Output) {
if ($null -ne $line) {
Write-Log "$CommandName ${Operation}: $($line.ToString())" $Level
}
}
}
function Get-ExceptionHttpStatusCode {
param([System.Management.Automation.ErrorRecord]$ErrorRecord)
$response = $ErrorRecord.Exception.PSObject.Properties['Response']
if ($response -and $response.Value) {
return [int]$response.Value.StatusCode
}
return 'unavailable'
}
#region Core.ReportingAndRecovery
function Get-ManifestRoot {
$root = if (Test-Path -LiteralPath 'P:\') { 'P:\Manifests' } else { Join-Path $Paths.Config 'Manifests' }
$null = New-Item -ItemType Directory -Path $root -Force
return $root
}
function Export-JsonFile {
param([Parameter(Mandatory)][object]$InputObject, [Parameter(Mandatory)][string]$Path)
$InputObject | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $Path -Encoding UTF8
}
function Write-DownloadHash {
param([Parameter(Mandatory)][string]$Name, [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$Uri)
$hashRoot = Join-Path (Get-ManifestRoot) 'Hashes'
$null = New-Item -ItemType Directory -Path $hashRoot -Force
$hash = Get-FileHash -LiteralPath $Path -Algorithm SHA256 -ErrorAction Stop
[pscustomobject]@{ Time=Get-Date; Name=$Name; Uri=$Uri; File=$Path; SizeBytes=(Get-Item -LiteralPath $Path).Length; SHA256=$hash.Hash } |
Export-Csv -LiteralPath (Join-Path $hashRoot ("{0}_{1}.csv" -f ($Name -replace '[^A-Za-z0-9._-]', '_'), $TimeStamp)) -NoTypeInformation -Encoding UTF8
}
function Invoke-ReliableDownload {
param(
[Parameter(Mandatory)][string]$Name,
[Parameter(Mandatory)][string]$Uri,
[Parameter(Mandatory)][string]$Destination,
[hashtable]$Headers = @{},
[switch]$UseCurl
)
$partial = "$Destination.partial"
$lastError = $null
for ($attempt = 1; $attempt -le $DownloadRetryCount; $attempt++) {
try {
Write-Log "Downloading $Name (attempt $attempt/$DownloadRetryCount): $Uri"
if ($UseCurl) {
if (-not (Get-Command curl.exe -ErrorAction SilentlyContinue)) { throw 'curl.exe is unavailable.' }
$arguments = '--silent --show-error --fail --location --continue-at - --connect-timeout 30 --max-time {0} --output "{1}" "{2}"' -f $DownloadTimeoutSeconds, $partial, $Uri
$process = Start-Process -FilePath 'curl.exe' -ArgumentList $arguments -Wait -PassThru
if ($process.ExitCode -ne 0) { throw "curl.exe download exit code: $($process.ExitCode)" }
}
else {
Invoke-WebRequest -Uri $Uri -Headers $Headers -OutFile $partial -UseBasicParsing -MaximumRedirection 10 -TimeoutSec $DownloadTimeoutSeconds
}
if (-not (Test-Path -LiteralPath $partial) -or (Get-Item -LiteralPath $partial).Length -lt 1KB) { throw 'Download is empty or unexpectedly small.' }
Move-Item -LiteralPath $partial -Destination $Destination -Force
Write-DownloadHash -Name $Name -Path $Destination -Uri $Uri
return
}
catch {
$lastError = $_
Write-Log "$Name download attempt $attempt failed: $($_.Exception.Message)" 'WARN'
}
}
throw "$Name download failed after $DownloadRetryCount attempt(s): $($lastError.Exception.Message)"
}
function Test-WorkstationHealth {
$health = [System.Collections.Generic.List[object]]::new()
foreach ($command in @('winget.exe', 'powershell.exe', 'python.exe', 'git.exe', 'code.cmd')) {
$available = $null -ne (Get-Command $command -ErrorAction SilentlyContinue)
$health.Add([pscustomobject]@{ Time=Get-Date; Check="Command: $command"; Status=if ($available) { 'Pass' } else { 'Warning' }; Details=if ($available) { 'Available' } else { 'Not found on PATH' } })
}
foreach ($drive in @(@{ Letter='S'; Label=$SharedDataLabel }, @{ Letter='P'; Label=$SharedPortableLabel })) {
$volume = Get-Volume -DriveLetter $drive.Letter -ErrorAction SilentlyContinue
$matches = $volume -and $volume.FileSystemLabel -eq $drive.Label
$health.Add([pscustomobject]@{ Time=Get-Date; Check="Shared drive $($drive.Letter):"; Status=if ($matches) { 'Pass' } else { 'Warning' }; Details=if ($volume) { "Label=$($volume.FileSystemLabel); FreeGB=$([math]::Round($volume.SizeRemaining / 1GB, 1))" } else { 'Unavailable' } })
}
foreach ($path in @($Paths.Root, $Paths.Config, (Get-ManifestRoot))) {
$health.Add([pscustomobject]@{ Time=Get-Date; Check="Path: $path"; Status=if (Test-Path -LiteralPath $path) { 'Pass' } else { 'Warning' }; Details=if (Test-Path -LiteralPath $path) { 'Available' } else { 'Missing' } })
}
$reportRoot = Join-Path (Get-ManifestRoot) 'Inventory'
$null = New-Item -ItemType Directory -Path $reportRoot -Force
$base = Join-Path $reportRoot "HealthReport_$TimeStamp"
$health | Export-Csv -LiteralPath "$base.csv" -NoTypeInformation -Encoding UTF8
Export-JsonFile -InputObject @($health) -Path "$base.json"
$health | ConvertTo-Html -Title 'Automotive Workstation Health Report' | Set-Content -LiteralPath "$base.html" -Encoding UTF8
return @($health)
}
function Backup-WorkstationConfiguration {
$backupRoot = if (Test-Path -LiteralPath 'P:\') { 'P:\Automotive\Backups\Workstation' } else { Join-Path $Paths.Backups 'Workstation' }
$null = New-Item -ItemType Directory -Path $backupRoot -Force
$staging = Join-Path $Paths.Temp "ConfigurationBackup_$TimeStamp"
$null = New-Item -ItemType Directory -Path $staging -Force
$backupPaths = @($Paths.Config, (Join-Path (Get-PortableInstallRoot) 'Development\VSCode\data'), (Join-Path (Get-PortableInstallRoot) 'Editors\NotepadPP\doLocalConf.xml'), $PROFILE.CurrentUserCurrentHost)
foreach ($path in $backupPaths) {
if (Test-Path -LiteralPath $path) { Copy-Item -LiteralPath $path -Destination $staging -Recurse -Force }
}
$backupMetadata = [pscustomobject]@{ Time=Get-Date; Profile=$WorkstationProfile; UserPath=[Environment]::GetEnvironmentVariable('Path', 'User'); Desktop=[Environment]::GetFolderPath('Desktop'); StartMenu=[Environment]::GetFolderPath('Programs') }
Export-JsonFile -InputObject $backupMetadata -Path (Join-Path $staging 'BackupMetadata.json')
$archive = Join-Path $backupRoot "WorkstationConfiguration_$TimeStamp.zip"
Compress-Archive -Path (Join-Path $staging '*') -DestinationPath $archive -Force
Remove-Item -LiteralPath $staging -Recurse -Force
return $archive
}
function Restore-WorkstationConfiguration {
$backupRoot = if (Test-Path -LiteralPath 'P:\Automotive\Backups\Workstation') { 'P:\Automotive\Backups\Workstation' } else { Join-Path $Paths.Backups 'Workstation' }
$archive = Get-ChildItem -LiteralPath $backupRoot -Filter 'WorkstationConfiguration_*.zip' -File -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1
if (-not $archive) { throw "No configuration backup found in $backupRoot" }
Expand-Archive -LiteralPath $archive.FullName -DestinationPath $Paths.Root -Force
Write-Log "Restored configuration backup: $($archive.FullName)" 'OK'
}
function Export-WorkstationInventory {
$inventoryRoot = Join-Path (Get-ManifestRoot) 'Inventory'
$null = New-Item -ItemType Directory -Path $inventoryRoot -Force
$uninstallPaths = @('HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*', 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*')
$installed = Get-ItemProperty -Path $uninstallPaths -ErrorAction SilentlyContinue |
Where-Object { $_.PSObject.Properties['DisplayName'] -and -not [string]::IsNullOrWhiteSpace([string]$_.DisplayName) } |
Select-Object @{Name='Time';Expression={Get-Date}}, DisplayName, DisplayVersion, Publisher, InstallDate
$portable = Get-ChildItem -LiteralPath (Get-PortableInstallRoot) -File -Recurse -ErrorAction SilentlyContinue | Where-Object Extension -in '.exe', '.bat', '.html' |
Select-Object @{Name='Time';Expression={Get-Date}}, Name, DirectoryName, Length, LastWriteTime
$extensions = if (Test-Path -LiteralPath (Join-Path (Get-PortableInstallRoot) 'Development\VSCode\Code.exe')) { Get-InstalledVSCodeExtensions -CodeExecutable (Join-Path (Get-PortableInstallRoot) 'Development\VSCode\Code.exe') | ForEach-Object { [pscustomobject]@{ Time=Get-Date; Id=$_ } } } else { @() }
$inventories = @{ 'Installed_Software'=$installed; 'Portable_Apps'=$portable; 'VSCode_Extensions'=$extensions; 'Drivers'=(Get-CimInstance Win32_PnPSignedDriver | Select-Object @{Name='Time';Expression={Get-Date}}, DeviceName, DriverVersion, Manufacturer); 'USB_Devices'=(Get-PnpDevice -PresentOnly -ErrorAction SilentlyContinue | Where-Object InstanceId -match '^USB' | Select-Object @{Name='Time';Expression={Get-Date}}, FriendlyName, Status, InstanceId); 'Disk_Inventory'=(Get-Volume | Select-Object @{Name='Time';Expression={Get-Date}}, DriveLetter, FileSystemLabel, FileSystem, HealthStatus, Size, SizeRemaining); 'Environment_Variables'=(Get-ChildItem Env: | Select-Object @{Name='Time';Expression={Get-Date}}, Name, Value); 'Services'=(Get-Service | Select-Object @{Name='Time';Expression={Get-Date}}, Name, DisplayName, Status, StartType) }
foreach ($name in $inventories.Keys) { @($inventories[$name]) | Export-Csv -LiteralPath (Join-Path $inventoryRoot "${name}_$TimeStamp.csv") -NoTypeInformation -Encoding UTF8 }
}
function Export-SetupReport {
$reportRoot = Join-Path (Get-ManifestRoot) 'Inventory'
$null = New-Item -ItemType Directory -Path $reportRoot -Force
$duration = (Get-Date) - $ScriptStartTime
$report = [pscustomobject]@{
Time = Get-Date; Profile = $WorkstationProfile; DurationSeconds = [math]::Round($duration.TotalSeconds, 1)
LocalRoot = $LocalRoot; SharedDataRoot = $SharedDataRoot; SharedPortableRoot = $SharedPortableRoot
Results = @($Results); Events = @($Events)
}
$base = Join-Path $reportRoot "SetupReport_$TimeStamp"
$Results | Export-Csv -LiteralPath "$base.csv" -NoTypeInformation -Encoding UTF8
Export-JsonFile -InputObject $report -Path "$base.json"
$Results | ConvertTo-Html -Title 'Automotive Workstation Setup Report' -PreContent "<h1>Automotive Workstation Setup Report</h1><p>Profile: $WorkstationProfile<br />Duration: $([math]::Round($duration.TotalMinutes, 1)) minutes</p>" |
Set-Content -LiteralPath "$base.html" -Encoding UTF8
}
#endregion
function Test-Administrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
function New-Shortcut {
param(
[Parameter(Mandatory)][string]$ShortcutPath,
[Parameter(Mandatory)][string]$TargetPath,
[string]$Arguments = '',
[string]$WorkingDirectory = '',
[string]$Description = '',
[string]$IconLocation = ''
)
if (-not (Test-Path -LiteralPath $TargetPath)) {
throw "Shortcut target not found: $TargetPath"
}
$null = New-Item -ItemType Directory -Path (Split-Path $ShortcutPath -Parent) -Force
$shell = New-Object -ComObject WScript.Shell
$shortcut = $shell.CreateShortcut($ShortcutPath)
$shortcut.TargetPath = $TargetPath
$shortcut.Arguments = $Arguments
$shortcut.WorkingDirectory = if ($WorkingDirectory) { $WorkingDirectory } else { Split-Path $TargetPath -Parent }
$shortcut.Description = $Description
if ($IconLocation) { $shortcut.IconLocation = $IconLocation }
$shortcut.Save()
}
function New-DirectoryLink {
param([string]$Link, [string]$Target)
if (-not (Test-Path -LiteralPath $Target)) {
Write-Log "Link target unavailable; skipped: $Target" 'WARN'
Add-Result 'Link' $Link 'Skipped' "Target unavailable: $Target"
return
}
if (Test-Path -LiteralPath $Link) {
Write-Log "Link or folder already exists; preserved: $Link" 'WARN'
Add-Result 'Link' $Link 'Preserved' 'Existing path was not modified.'
return
}
# Junctions do not require Windows Developer Mode and are suitable for local NTFS volumes.
$null = New-Item -ItemType Junction -Path $Link -Target $Target
Add-Result 'Link' $Link 'Success' "Target: $Target"
}
function Get-PortableInstallRoot {
# Prefer the shared portable-app partition. Fall back to the local C: installation when P: is unavailable.
if (Test-Path -LiteralPath 'P:\') {
$root = $SharedPortableRoot
}
else {
$root = $Paths.Portable
Write-Log "P: is unavailable. Portable apps will use local fallback: $root" 'WARN'
}
$null = New-Item -ItemType Directory -Path $root -Force
return $root
}
function Get-GitHubReleaseAsset {
param(
[Parameter(Mandatory)][string]$Repository,
[Parameter(Mandatory)][string]$AssetRegex
)
$cacheRoot = Join-Path (Get-ManifestRoot) 'Cache'
$null = New-Item -ItemType Directory -Path $cacheRoot -Force
$cacheFile = Join-Path $cacheRoot (($Repository -replace '[^A-Za-z0-9._-]', '_') + '_latest.json')
if ((Test-Path -LiteralPath $cacheFile) -and ((Get-Date) - (Get-Item -LiteralPath $cacheFile).LastWriteTime).TotalHours -lt 6) {
$release = Get-Content -LiteralPath $cacheFile -Raw -Encoding UTF8 | ConvertFrom-Json
Write-Log "Using cached GitHub release metadata for $Repository."
}
else {
$headers = @{ 'User-Agent' = 'Automotive-Workstation-Setup'; 'Accept' = 'application/vnd.github+json' }
$release = Invoke-RestMethod -Uri "https://api.github.com/repos/$Repository/releases/latest" -Headers $headers
Export-JsonFile -InputObject $release -Path $cacheFile
}
$assets = @($release.assets | Where-Object { $_.name -match $AssetRegex })
if ($assets.Count -eq 0) { throw "No release asset matching '$AssetRegex' in $Repository." }
if ($assets.Count -gt 1) {
$assets = @($assets | Sort-Object size -Descending)
Write-Log "Multiple assets matched for $Repository; selected $($assets[0].name)." 'WARN'
}
return $assets[0]
}
function Get-ChromeForTestingAsset {
param([Parameter(Mandatory)][string]$Platform)
$metadata = Invoke-RestMethod -Uri 'https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json' -Headers @{ 'User-Agent' = 'Automotive-Workstation-Setup' }
$asset = @($metadata.channels.Stable.downloads.chrome | Where-Object { $_.platform -eq $Platform } | Select-Object -First 1)
if ($asset.Count -ne 1) { throw "No stable Chrome for Testing download was found for platform '$Platform'." }
return $asset[0]
}
function Get-HWiNFOPortableUri {
$portableUri = 'https://www.hwinfo.com/files/hwi_852.zip'
Write-Log "Using verified direct HWiNFO portable archive: $portableUri"
return $portableUri
}
function Install-PortableArchive {
param(
[Parameter(Mandatory)][string]$Name,
[Parameter(Mandatory)][string]$Uri,
[Parameter(Mandatory)][string]$Destination,
[Parameter(Mandatory)][string]$ExpectedExecutable,
[string]$ArchiveName = '',
[switch]$FlattenSingleDirectory,
[hashtable]$Headers = @{},
[switch]$DownloadWithCurl
)
$downloadName = if ($ArchiveName) { $ArchiveName } else { [IO.Path]::GetFileName(([Uri]$Uri).AbsolutePath) }
if (-not $downloadName) { $downloadName = ($Name -replace '[^A-Za-z0-9._-]','_') + '.zip' }
$archive = Join-Path $Paths.Downloads $downloadName
$staging = Join-Path $Paths.Temp (($Name -replace '[^A-Za-z0-9._-]','_') + '_staging')
Remove-Item -LiteralPath $staging -Recurse -Force -ErrorAction SilentlyContinue
$null = New-Item -ItemType Directory -Path $staging -Force
try {
Invoke-ReliableDownload -Name $Name -Uri $Uri -Destination $archive -Headers $Headers -UseCurl:$DownloadWithCurl
}
catch {
$statusCode = Get-ExceptionHttpStatusCode -ErrorRecord $_
Write-Log "$Name portable archive download failed: URL=$Uri; HTTP=$statusCode; Error=$($_.Exception.Message)" 'ERROR'
throw
}
$archiveSize = (Get-Item -LiteralPath $archive).Length
Write-Log "$Name portable archive downloaded: $archiveSize bytes"
if ($archiveSize -lt 64KB) { throw "$Name download is unexpectedly small." }
if ([IO.Path]::GetExtension($archive) -ne '.zip') { throw "$Name archive must be ZIP for safe native extraction." }
Expand-Archive -LiteralPath $archive -DestinationPath $staging -Force
$source = $staging
if ($FlattenSingleDirectory) {
$children = @(Get-ChildItem -LiteralPath $staging -Force)
if ($children.Count -eq 1 -and $children[0].PSIsContainer) { $source = $children[0].FullName }
}
$expected = Get-ChildItem -LiteralPath $source -Filter $ExpectedExecutable -File -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $expected) { throw "$Name validation failed: $ExpectedExecutable was not found." }
# Replace only after download and validation succeed. Preserve prior version as a rollback copy.
$backup = "$Destination.previous"
Remove-Item -LiteralPath $backup -Recurse -Force -ErrorAction SilentlyContinue
if (Test-Path -LiteralPath $Destination) { Move-Item -LiteralPath $Destination -Destination $backup -Force }
$null = New-Item -ItemType Directory -Path $Destination -Force
Copy-Item -Path (Join-Path $source '*') -Destination $Destination -Recurse -Force
Unblock-File -Path (Join-Path $Destination '*') -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $staging -Recurse -Force -ErrorAction SilentlyContinue
if (-not (Get-ChildItem -LiteralPath $Destination -Filter $ExpectedExecutable -File -Recurse -ErrorAction SilentlyContinue)) {
throw "$Name post-install validation failed. Previous version remains at $backup."
}
}
function Install-PortableExecutable {
param(
[Parameter(Mandatory)][string]$Name,
[Parameter(Mandatory)][string]$Uri,
[Parameter(Mandatory)][string]$Destination,
[Parameter(Mandatory)][string]$FileName
)
$null = New-Item -ItemType Directory -Path $Destination -Force
$target = Join-Path $Destination $FileName
$temporary = "$target.download"
$backup = "$target.previous"
Remove-Item -LiteralPath $temporary -Force -ErrorAction SilentlyContinue
try {
Invoke-ReliableDownload -Name $Name -Uri $Uri -Destination $temporary
if ((Get-Item -LiteralPath $temporary).Length -lt 64KB) { throw "$Name download is unexpectedly small." }
Remove-Item -LiteralPath $backup -Force -ErrorAction SilentlyContinue
if (Test-Path -LiteralPath $target) { Move-Item -LiteralPath $target -Destination $backup -Force }
Move-Item -LiteralPath $temporary -Destination $target -Force
Unblock-File -LiteralPath $target -ErrorAction SilentlyContinue
}
catch {
Remove-Item -LiteralPath $temporary -Force -ErrorAction SilentlyContinue
if (-not (Test-Path -LiteralPath $target) -and (Test-Path -LiteralPath $backup)) {
Move-Item -LiteralPath $backup -Destination $target -Force
}
throw
}
}
function Test-PortableInstallation {
param(
[Parameter(Mandatory)][string]$Destination,
[Parameter(Mandatory)][string]$ExpectedExecutable
)
return [bool](Get-ChildItem -LiteralPath $Destination -Filter $ExpectedExecutable -File -Recurse -ErrorAction SilentlyContinue |
Select-Object -First 1)
}
function New-PortableAppShortcut {
param([string]$Name, [string]$Executable, [string]$PortableRoot, [string]$Arguments = '')
$target = Get-ChildItem -LiteralPath $PortableRoot -Filter $Executable -File -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $target) { throw "Executable not found for shortcut: $Executable" }
$desktopGroup = Join-Path ([Environment]::GetFolderPath('Desktop')) 'Automotive Workstation\Portable Apps'
$startGroup = Join-Path ([Environment]::GetFolderPath('Programs')) 'Automotive Workstation\Portable Apps'
foreach ($group in @($desktopGroup, $startGroup)) {
New-Shortcut -ShortcutPath (Join-Path $group "$Name.lnk") -TargetPath $target.FullName `
-Arguments $Arguments -WorkingDirectory $target.DirectoryName -Description "$Name portable"
}
}
function Initialize-SharedPartition {
param(
[Parameter(Mandatory)][ValidatePattern('^[A-Z]:$')][string]$Drive,
[Parameter(Mandatory)][string]$ExpectedLabel,
[Parameter(Mandatory)][string[]]$Folders
)
$driveRoot = "$Drive\"
if (-not (Test-Path -LiteralPath $driveRoot)) {
throw "Required shared partition is unavailable: $Drive"
}
$volume = Get-Volume -DriveLetter $Drive.Substring(0,1) -ErrorAction Stop
if ($volume.FileSystem -ne 'NTFS') {
Write-Log "$Drive uses $($volume.FileSystem). NTFS is recommended for permissions and junctions." 'WARN'
}
if ($volume.FileSystemLabel -ne $ExpectedLabel) {
try {
Set-Volume -DriveLetter $Drive.Substring(0,1) -NewFileSystemLabel $ExpectedLabel -ErrorAction Stop
Write-Log "Set $Drive volume label to $ExpectedLabel." 'OK'
}
catch {
Write-Log "Could not set $Drive label to $ExpectedLabel`: $($_.Exception.Message)" 'WARN'
}
}
foreach ($folder in $Folders) {
$path = Join-Path $driveRoot $folder
$null = New-Item -ItemType Directory -Path $path -Force
}
$freeGB = [math]::Round($volume.SizeRemaining / 1GB, 1)
Add-Result 'SharedPartition' $Drive 'Success' "Label=$ExpectedLabel; FreeGB=$freeGB"
}
function Install-WingetPackage {
param([string]$Id, [string]$Name)
$source = 'winget'
if (-not (Get-Command winget.exe -ErrorAction SilentlyContinue)) {
throw 'WinGet is unavailable. Update/install Microsoft App Installer, then rerun.'
}
$listOutput = @(& winget.exe list --id $Id --exact --source $source --accept-source-agreements 2>&1)
$listExitCode = $LASTEXITCODE
if ($listExitCode -eq 0) {
Write-Log "$Name is already installed; requesting upgrade if available."
$upgradeOutput = @(& winget.exe upgrade --id $Id --exact --source $source --silent --accept-package-agreements --accept-source-agreements --disable-interactivity 2>&1)
$upgradeExitCode = $LASTEXITCODE
if ($upgradeExitCode -eq -1978335189) {
Write-Log "$Name is already current; WinGet reported no applicable upgrade." 'INFO'
}
elseif ($upgradeExitCode -ne 0) {
Write-CommandOutput -CommandName 'WinGet' -Operation "upgrade [$Id]" -Output $upgradeOutput -Level 'ERROR'
throw "WinGet upgrade exit code: $upgradeExitCode ($(ConvertTo-ExitCodeHex -ExitCode $upgradeExitCode))"
}
}
else {
Write-CommandOutput -CommandName 'WinGet' -Operation "list [$Id]" -Output $listOutput
$machineOutput = @(& winget.exe install --id $Id --exact --source $source --silent --scope machine --accept-package-agreements --accept-source-agreements --disable-interactivity 2>&1)
$machineScopeExitCode = $LASTEXITCODE
if ($machineScopeExitCode -ne 0) {
$machineScopeHex = ConvertTo-ExitCodeHex -ExitCode $machineScopeExitCode
Write-CommandOutput -CommandName 'WinGet' -Operation "machine install [$Id]" -Output $machineOutput -Level 'WARN'
Write-Log "$Name machine-scoped install failed with exit code $machineScopeExitCode ($machineScopeHex); retrying without a scope." 'WARN'
$defaultOutput = @(& winget.exe install --id $Id --exact --source $source --silent --accept-package-agreements --accept-source-agreements --disable-interactivity 2>&1)
$defaultExitCode = $LASTEXITCODE
if ($defaultExitCode -ne 0) {
$defaultHex = ConvertTo-ExitCodeHex -ExitCode $defaultExitCode
Write-CommandOutput -CommandName 'WinGet' -Operation "default install [$Id]" -Output $defaultOutput -Level 'ERROR'
if ($defaultHex -eq '0x80190194') {
Write-Log "$Name installer is unavailable at the URL in the current WinGet manifest. Install it from the vendor site or retry after the manifest is updated." 'WARN'
}
throw "WinGet install exit codes: machine=$machineScopeExitCode ($machineScopeHex); default=$defaultExitCode ($defaultHex)"
}
}
}
}
function Update-WingetSources {
if (-not (Get-Command winget.exe -ErrorAction SilentlyContinue)) {
throw 'WinGet is unavailable. Update/install Microsoft App Installer, then rerun.'
}
& winget.exe source update --disable-interactivity
if ($LASTEXITCODE -ne 0) { throw "WinGet source update exit code: $LASTEXITCODE" }
}
function Install-VSCodeExtension {
param(
[Parameter(Mandatory)][string]$CodeExecutable,
[Parameter(Mandatory)][string]$Id
)
if (-not (Test-Path -LiteralPath $CodeExecutable)) {
throw "VS Code executable not found: $CodeExecutable"
}
$codeCli = Join-Path (Split-Path $CodeExecutable -Parent) 'bin\code.cmd'
if (-not (Test-Path -LiteralPath $codeCli)) {
throw "VS Code command-line client not found: $codeCli"
}
$token = [guid]::NewGuid().ToString('N')
$standardOutput = Join-Path $Paths.Temp "VSCodeExtension_$token.stdout.log"
$standardError = Join-Path $Paths.Temp "VSCodeExtension_$token.stderr.log"
Write-Log "Installing VS Code extension with ${codeCli}: $Id"
try {
$process = Start-Process -FilePath $codeCli -ArgumentList @('--install-extension', $Id, '--force') -Wait -PassThru `
-RedirectStandardOutput $standardOutput -RedirectStandardError $standardError
if ($process.ExitCode -ne 0) {
$output = @(Get-Content -LiteralPath $standardOutput,$standardError -ErrorAction SilentlyContinue)
Write-CommandOutput -CommandName 'VS Code' -Operation "extension [$Id]" -Output $output -Level 'ERROR'
throw "VS Code extension install exit code: $($process.ExitCode) ($(ConvertTo-ExitCodeHex -ExitCode $process.ExitCode))"
}
Write-Log "VS Code extension installed: $Id (exit code 0)"
}
finally {
Remove-Item -LiteralPath $standardOutput,$standardError -Force -ErrorAction SilentlyContinue
}
}
function Get-InstalledVSCodeExtensions {
param([Parameter(Mandatory)][string]$CodeExecutable)
$codeCli = Join-Path (Split-Path $CodeExecutable -Parent) 'bin\code.cmd'
if (-not (Test-Path -LiteralPath $codeCli)) {
throw "VS Code command-line client not found: $codeCli"
}
$token = [guid]::NewGuid().ToString('N')
$standardOutput = Join-Path $Paths.Temp "VSCodeExtensions_$token.stdout.log"
$standardError = Join-Path $Paths.Temp "VSCodeExtensions_$token.stderr.log"
try {
$process = Start-Process -FilePath $codeCli -ArgumentList '--list-extensions' -Wait -PassThru `
-RedirectStandardOutput $standardOutput -RedirectStandardError $standardError
if ($process.ExitCode -ne 0) {
$output = @(Get-Content -LiteralPath $standardOutput,$standardError -ErrorAction SilentlyContinue)
Write-CommandOutput -CommandName 'VS Code' -Operation 'list extensions' -Output $output -Level 'WARN'
throw "VS Code extension inventory exit code: $($process.ExitCode) ($(ConvertTo-ExitCodeHex -ExitCode $process.ExitCode))"
}
return ,@(Get-Content -LiteralPath $standardOutput -ErrorAction Stop | Where-Object { $_ })
}
finally {
Remove-Item -LiteralPath $standardOutput,$standardError -Force -ErrorAction SilentlyContinue
}
}
Write-Log "Starting automotive workstation configuration: $WorkstationProfile"
Write-Log "Administrator: $(Test-Administrator)"
Write-Log "Local root: $LocalRoot"
Write-Log "Shared data root: $SharedDataRoot"
Write-Log "Shared portable root: $SharedPortableRoot"
if ($EnableTranscript) {
$transcriptPath = Join-Path $Paths.Logs ("{0}_Transcript_{1}.log" -f $Profile.Folder, $TimeStamp)
Start-Transcript -LiteralPath $transcriptPath -Append | Out-Null
}
if ($HealthCheck) {
Invoke-Safe 'HealthCheck' 'Automotive workstation health check' { Test-WorkstationHealth | Out-Null } | Out-Null
$Results | Export-Csv -LiteralPath $SummaryFile -NoTypeInformation -Encoding UTF8
if ($EnableTranscript) { Stop-Transcript | Out-Null }
return
}
if (-not (Test-Administrator)) {
throw 'Run this script from Windows PowerShell or PowerShell as Administrator.'
}
Invoke-Safe 'Configuration' 'Disable hibernation' {
$powerProcess = Start-Process -FilePath 'powercfg.exe' -ArgumentList '/hibernate', 'off' -Wait -PassThru -NoNewWindow
if ($powerProcess.ExitCode -ne 0) { throw "powercfg.exe /hibernate off failed with exit code $($powerProcess.ExitCode)." }
} | Out-Null
Invoke-Safe 'Configuration' 'Disable USB selective suspend' {
foreach ($powerArguments in @(
@('/setacvalueindex', 'SCHEME_CURRENT', 'SUB_USB', 'USBSELECTIVE', '0'),
@('/setdcvalueindex', 'SCHEME_CURRENT', 'SUB_USB', 'USBSELECTIVE', '0'),
@('/setactive', 'SCHEME_CURRENT')
)) {
$powerProcess = Start-Process -FilePath 'powercfg.exe' -ArgumentList $powerArguments -Wait -PassThru -NoNewWindow
if ($powerProcess.ExitCode -ne 0) { throw "powercfg.exe $($powerArguments -join ' ') failed with exit code $($powerProcess.ExitCode)." }
}
} | Out-Null
if ($RestoreConfiguration) {
Invoke-Safe 'Recovery' 'Restore workstation configuration' { Restore-WorkstationConfiguration } | Out-Null
$Results | Export-Csv -LiteralPath $SummaryFile -NoTypeInformation -Encoding UTF8
if ($EnableTranscript) { Stop-Transcript | Out-Null }
return
}
# 1. Create local structure.
$Paths.Values | ForEach-Object {
$null = New-Item -ItemType Directory -Path $_ -Force
}
$Subfolders = @($Configuration.Folders.Local)
foreach ($relative in $Subfolders) {
$path = Join-Path $LocalRoot $relative
$null = New-Item -ItemType Directory -Path $path -Force
}
Add-Result 'Folders' 'Local automotive structure' 'Success' $LocalRoot
Invoke-Safe 'Configuration' 'Workstation profile metadata' {
@"
Profile: $WorkstationProfile
Focus: $($Profile.Focus)
Local workspace: $LocalRoot
Shared data workspace: $ProfileDataRoot
Shared configuration: $ProfileConfigRoot
Shared installers: $ProfileInstallerRoot
This Windows installation uses only C:, S: (AUTO_DATA), and P: (PORTABLE_APPS).
Do not assign drive letters to other Windows installations from this workstation.
"@ | Set-Content -LiteralPath (Join-Path $Paths.Config 'Workstation-Profile.txt') -Encoding UTF8
} | Out-Null
if ($BackupConfiguration) {
Invoke-Safe 'Recovery' 'Backup workstation configuration' {
$archive = Backup-WorkstationConfiguration
Write-Log "Configuration backup created: $archive" 'OK'
} | Out-Null
}
# 2. Configure both shared partitions and expose them inside C:\Automotive.
# S: holds data shared by all three Windows installations.
$SharedDataFolders = @($Configuration.Folders.SharedData)
# P: holds portable applications, shared tool configuration, installers, and manifests.
$SharedPortableFolders = @($Configuration.Folders.SharedPortable)
Invoke-Safe 'SharedPartition' 'Configure S: shared automotive data' {
Initialize-SharedPartition -Drive 'S:' -ExpectedLabel $SharedDataLabel -Folders $SharedDataFolders
} | Out-Null
Invoke-Safe 'SharedPartition' 'Configure P: shared portable applications' {
Initialize-SharedPartition -Drive 'P:' -ExpectedLabel $SharedPortableLabel -Folders $SharedPortableFolders
} | Out-Null
if ($CreateSharedLinks) {
Invoke-Safe 'Link' 'Link C:\Automotive\SharedData to S:' {
New-DirectoryLink -Link (Join-Path $LocalRoot 'SharedData') -Target $SharedDataRoot
} | Out-Null
Invoke-Safe 'Link' 'Link C:\Automotive\SharedPortableApps to P:' {
New-DirectoryLink -Link (Join-Path $LocalRoot 'SharedPortableApps') -Target $SharedPortableRoot
} | Out-Null
}
if ((Test-Path -LiteralPath $SharedPortableRoot) -and (Test-Path -LiteralPath $SharedDataRoot)) {
Invoke-Safe 'AutomotiveTooling' 'Commercial automotive tool workspaces' {
foreach ($toolName in $Configuration.Shortcuts.CommercialAutomotiveTools) {
$portableToolRoot = Join-Path $SharedPortableRoot "Automotive\CommercialTools\$toolName"
$dataToolRoot = Join-Path $SharedDataRoot "Backups\CommercialTools\$toolName"
$null = New-Item -ItemType Directory -Path $portableToolRoot, (Join-Path $portableToolRoot 'Config'), (Join-Path $portableToolRoot 'Shortcuts'), $dataToolRoot -Force
}
} | Out-Null
}
# 3. Portable applications are installed after WinGet so archive prerequisites are available.
# 4. Core applications. WinGet uses package manifests and each vendor's installer.
$CorePackages = $Configuration.CorePackages
$OptionalPackages = $Configuration.OptionalApps.Packages
if (-not $SkipWinget) {
Invoke-Safe 'WinGet' 'Refresh package sources' {
Update-WingetSources
} | Out-Null
$packages = [System.Collections.Generic.List[object]]::new()
$CorePackages | ForEach-Object { $packages.Add($_) }
if ($InstallOptionalApps) { $OptionalPackages | ForEach-Object { $packages.Add($_) } }
foreach ($package in $packages) {
Invoke-Safe 'WinGet' $package.Name {
Install-WingetPackage -Id $package.Id -Name $package.Name
} | Out-Null
}
}
# 5. Download, install/update, validate, and configure portable applications.
if (-not $SkipDownloads) {
$PortableRoot = Get-PortableInstallRoot
$PortableDefinitions = [System.Collections.Generic.List[object]]::new()
Invoke-Safe 'AutomotiveTooling' 'Xhorse resource catalog structure' {
$xhorseRoot = Join-Path $PortableRoot $Configuration.Automotive.RootFolder
foreach ($folder in $Configuration.Automotive.Folders) {
$null = New-Item -ItemType Directory -Path (Join-Path $xhorseRoot $folder) -Force
}
$catalog = @(
'# Xhorse Multi-PROG Resource Catalog', '',
'This catalog contains references and locally created read-only resources. Licensed software is never downloaded automatically.', '',
'## Official Manuals'
)
$catalog += $Configuration.Automotive.Manuals | ForEach-Object { "- $($_.Name): $($_.Uri)" }
$catalog += '', '## References'
$catalog += $Configuration.Automotive.References | ForEach-Object { "- $_" }
$catalog | Set-Content -LiteralPath (Join-Path $xhorseRoot 'RESOURCE_CATALOG.md') -Encoding UTF8
$Configuration.Automotive.References | Set-Content -LiteralPath (Join-Path $xhorseRoot 'LINK_INDEX.md') -Encoding UTF8
if (-not (Test-Path -LiteralPath (Join-Path $xhorseRoot 'CHANGELOG.md'))) {
"# Changelog`r`n`r`n- $TimeStamp Created resource catalog structure." | Set-Content -LiteralPath (Join-Path $xhorseRoot 'CHANGELOG.md') -Encoding UTF8
}
if (-not (Test-Path -LiteralPath (Join-Path $xhorseRoot 'README.md'))) {
'# Xhorse Multi-PROG Resources' | Set-Content -LiteralPath (Join-Path $xhorseRoot 'README.md') -Encoding UTF8
}
} | Out-Null
$ConfiguredPortableApps = @($Configuration.PortableApps | ForEach-Object { $_ })
foreach ($configuredApp in $ConfiguredPortableApps) {
$appType = [string]$configuredApp.Type
$isGitHub = $appType -like 'GitHub*'
$isDirectExe = $appType -in @('Exe', 'GitHubExe')
$destination = Join-Path $PortableRoot $configuredApp.Folder
$createShortcut = $null -eq $configuredApp.Shortcut -or [bool]$configuredApp.Shortcut
$flattenSingleDirectory = if ($configuredApp.PSObject.Properties['Flatten']) { [bool]$configuredApp.Flatten } else { -not $isDirectExe }
$downloadWithCurl = [bool]($configuredApp.PSObject.Properties['DownloadWithCurl'] -and $configuredApp.DownloadWithCurl)
if (-not $ForcePortableUpdates -and (Test-PortableInstallation -Destination $destination -ExpectedExecutable $configuredApp.Executable)) {
$PortableDefinitions.Add([pscustomobject]@{
Name=$configuredApp.Name; Type=if ($isDirectExe) { 'Exe' } else { 'Zip' }; Uri=''; Folder=$configuredApp.Folder;
Executable=$configuredApp.Executable; Archive=''; Flatten=$flattenSingleDirectory; Shortcut=$createShortcut;
DownloadWithCurl=$downloadWithCurl
})
Add-Result 'PortableResolve' $configuredApp.Name 'Skipped' "Validated installation already exists: $destination"
Write-Log "$($configuredApp.Name) release lookup skipped because its portable installation is already validated." 'INFO'
continue
}
if ($appType -eq 'ChromeForTesting') {
Invoke-Safe 'PortableResolve' $configuredApp.Name {
$asset = Get-ChromeForTestingAsset -Platform $configuredApp.Platform
$PortableDefinitions.Add([pscustomobject]@{
Name=$configuredApp.Name; Type='Zip'; Uri=$asset.url; Folder=$configuredApp.Folder;
Executable=$configuredApp.Executable; Archive=$configuredApp.Archive; Flatten=[bool]$configuredApp.Flatten; Shortcut=$createShortcut
})
} | Out-Null
}
elseif ($isGitHub) {
Invoke-Safe 'PortableResolve' $configuredApp.Name {
$asset = Get-GitHubReleaseAsset -Repository $configuredApp.Repository -AssetRegex $configuredApp.Regex
$PortableDefinitions.Add([pscustomobject]@{
Name=$configuredApp.Name; Type=if ($isDirectExe) { 'Exe' } else { 'Zip' }; Uri=$asset.browser_download_url; Folder=$configuredApp.Folder;
Executable=$configuredApp.Executable; Archive=if ($isDirectExe) { '' } else { $asset.name }; Flatten=(-not $isDirectExe); Shortcut=$createShortcut
})
} | Out-Null
}
else {
$PortableDefinitions.Add([pscustomobject]@{
Name=$configuredApp.Name; Type=if ($isDirectExe) { 'Exe' } else { 'Zip' }; Uri=$configuredApp.Uri; Folder=$configuredApp.Folder;
Executable=$configuredApp.Executable; Archive=$configuredApp.Archive; Flatten=$flattenSingleDirectory; Shortcut=$createShortcut;
DownloadWithCurl=$downloadWithCurl
})
}
}
foreach ($app in $PortableDefinitions) {
$destination = Join-Path $PortableRoot $app.Folder
if (-not $ForcePortableUpdates -and (Test-PortableInstallation -Destination $destination -ExpectedExecutable $app.Executable)) {
Add-Result 'PortableInstall' $app.Name 'Skipped' "Validated installation already exists: $destination"
Write-Log "$($app.Name) is already installed and validated; skipped. Use -ForcePortableUpdates to download it again." 'INFO'
}
else {
Invoke-Safe 'PortableInstall' $app.Name {
if ($app.Type -eq 'Exe') {
Install-PortableExecutable -Name $app.Name -Uri $app.Uri -Destination $destination -FileName $app.Executable
}
else {
$requestHeaders = if ($app.PSObject.Properties['Headers']) { $app.Headers } else { @{} }
$downloadWithCurl = $app.PSObject.Properties['DownloadWithCurl'] -and $app.DownloadWithCurl
Install-PortableArchive -Name $app.Name -Uri $app.Uri -Destination $destination `
-ExpectedExecutable $app.Executable -ArchiveName $app.Archive -FlattenSingleDirectory:$app.Flatten `
-Headers $requestHeaders -DownloadWithCurl:$downloadWithCurl
}
} | Out-Null
}
if (-not $SkipShortcuts -and $app.Shortcut -and (Test-Path -LiteralPath $destination)) {
Invoke-Safe 'PortableShortcut' $app.Name {
$shortcutArguments = ''
if ($app.Name -eq 'Google Chrome Portable') {
$chromeProfileRoot = Join-Path $PortableRoot 'Config\Chrome\User Data'
$null = New-Item -ItemType Directory -Path $chromeProfileRoot -Force
$shortcutArguments = '--user-data-dir="{0}"' -f $chromeProfileRoot
}
New-PortableAppShortcut -Name $app.Name -Executable $app.Executable -PortableRoot $destination -Arguments $shortcutArguments
} | Out-Null
}
}
# Configure VS Code portable mode. Data and extensions remain beside Code.exe on P:.
$vsCodeRoot = Join-Path $PortableRoot 'Development\VSCode'
if (Test-Path (Join-Path $vsCodeRoot 'Code.exe')) {
Invoke-Safe 'PortableConfig' 'Visual Studio Code portable mode' {
$null = New-Item -ItemType Directory -Path (Join-Path $vsCodeRoot 'data'), (Join-Path $vsCodeRoot 'data\extensions') -Force
$settingsDir = Join-Path $vsCodeRoot 'data\user-data\User'
$null = New-Item -ItemType Directory -Path $settingsDir -Force
$settingsFile = Join-Path $settingsDir 'settings.json'
if (-not (Test-Path $settingsFile)) {
@{
'files.autoSave'='afterDelay'
'editor.formatOnSave'=$true
'terminal.integrated.defaultProfile.windows'='PowerShell'
'security.workspace.trust.untrustedFiles'='open'
'update.mode'='manual'
} | ConvertTo-Json | Set-Content -LiteralPath $settingsFile -Encoding UTF8
}
} | Out-Null
# Extensions are installed only after portable Code.exe and its data directory exist.
# They support offline binary inspection and embedded-module development only.
$vsCodeExtensions = @($Configuration.OptionalApps.VSCodeExtensions)
try {
$installedVSCodeExtensions = Get-InstalledVSCodeExtensions -CodeExecutable (Join-Path $vsCodeRoot 'Code.exe')
Write-Log "Found $($installedVSCodeExtensions.Count) installed VS Code extensions."
}
catch {
$installedVSCodeExtensions = @()
Write-Log "Could not inventory installed VS Code extensions; each requested extension will be checked by the installer. $($_.Exception.Message)" 'WARN'
}
foreach ($extension in $vsCodeExtensions) {
if ($installedVSCodeExtensions -contains $extension.Id) {
Add-Result 'VSCodeExtension' $extension.Name 'Skipped' "Already installed: $($extension.Id)"
Write-Log "$($extension.Name) VS Code extension is already installed; skipped." 'INFO'
continue
}
Invoke-Safe 'VSCodeExtension' $extension.Name {
Install-VSCodeExtension -CodeExecutable (Join-Path $vsCodeRoot 'Code.exe') -Id $extension.Id
} | Out-Null
}
}
# Configure Notepad++ local/portable mode without overwriting an existing configuration.
$notepadRoot = Join-Path $PortableRoot 'Editors\NotepadPP'
if (Test-Path (Join-Path $notepadRoot 'notepad++.exe')) {
Invoke-Safe 'PortableConfig' 'Notepad++ portable mode' {
$doLocal = Join-Path $notepadRoot 'doLocalConf.xml'
if (-not (Test-Path $doLocal)) { '<NotepadPlus />' | Set-Content -LiteralPath $doLocal -Encoding UTF8 }
} | Out-Null
}
# Add successfully installed CLI-tool folders to the current user's PATH without duplicates.
Invoke-Safe 'PortableConfig' 'Portable CLI PATH entries' {
$cliExecutables = @('jq.exe','yq.exe','rg.exe','fd.exe','bat.exe','fzf.exe','gh.exe','rclone.exe','restic.exe')
$cliFolders = foreach ($exe in $cliExecutables) {
Get-ChildItem -LiteralPath $PortableRoot -Filter $exe -File -Recurse -ErrorAction SilentlyContinue |
Select-Object -ExpandProperty DirectoryName
}
$cliFolders = @($cliFolders | Sort-Object -Unique)
$currentUserPath = [Environment]::GetEnvironmentVariable('Path', 'User')
$pathParts = @($currentUserPath -split ';' | Where-Object { $_ })
foreach ($folder in $cliFolders) {
if ($pathParts -notcontains $folder) { $pathParts += $folder }
}
[Environment]::SetEnvironmentVariable('Path', ($pathParts -join ';'), 'User')
$env:Path = (($env:Path -split ';') + $cliFolders | Select-Object -Unique) -join ';'
} | Out-Null
# Create a portable-app launcher menu that can be used from all three Windows installations.
Invoke-Safe 'PortableConfig' 'Portable Apps launcher' {
$launcherRoot = Join-Path $PortableRoot 'Launchers'
$null = New-Item -ItemType Directory -Path $launcherRoot -Force
$launcher = Join-Path $launcherRoot 'Open-PortableApps.ps1'
@'
$root = Split-Path $PSScriptRoot -Parent
$apps = Get-ChildItem -LiteralPath $root -Filter *.exe -File -Recurse |
Where-Object { $_.FullName -notmatch '\\.previous\\' } |
Sort-Object BaseName
$choice = $apps | Select-Object @{Name='Application';Expression={$_.BaseName}}, FullName |
Out-GridView -Title 'Portable Applications' -PassThru
if ($choice) {
$workingDirectory = Split-Path $choice.FullName -Parent
if ($choice.FullName -match '\\WebTools\\Chrome\\chrome\.exe$') {
$chromeProfile = Join-Path $root 'Config\Chrome\User Data'
$null = New-Item -ItemType Directory -Path $chromeProfile -Force
Start-Process -FilePath $choice.FullName -ArgumentList ('--user-data-dir="{0}"' -f $chromeProfile) -WorkingDirectory $workingDirectory
}
else {
Start-Process -FilePath $choice.FullName -WorkingDirectory $workingDirectory
}
}
'@ | Set-Content -LiteralPath $launcher -Encoding UTF8
if (-not $SkipShortcuts) {
$desktop = Join-Path ([Environment]::GetFolderPath('Desktop')) 'Automotive Workstation\Portable Apps Launcher.lnk'
$powershell = Join-Path $env:WINDIR 'System32\WindowsPowerShell\v1.0\powershell.exe'
New-Shortcut -ShortcutPath $desktop -TargetPath $powershell `
-Arguments "-NoProfile -ExecutionPolicy Bypass -File `"$launcher`"" `
-WorkingDirectory $launcherRoot -Description 'Search and launch shared portable applications'
}
} | Out-Null
# Xhorse Multi-PROG script-development workspace and official documentation.
# These templates are intentionally read-only/pure-data helpers. They do not perform
# immobilizer bypass, odometer alteration, key generation, or automatic ECU writes.
Invoke-Safe 'AutomotiveTooling' 'Xhorse Multi-PROG script workspace' {
$multiProgRoot = Join-Path $PortableRoot 'Automotive\Xhorse\Multi-PROG'
$multiProgDocs = Join-Path $multiProgRoot 'Documentation'
$multiProgScripts = Join-Path $multiProgRoot 'Scripts'
$multiProgTemplates = Join-Path $multiProgScripts 'Templates'
$multiProgLibraries = Join-Path $multiProgScripts 'Libraries'
$multiProgExamples = Join-Path $multiProgScripts 'Examples'
$multiProgReleased = Join-Path $multiProgScripts 'Released'
$multiProgTestData = Join-Path $multiProgRoot 'TestData'
$multiProgBackups = Join-Path $multiProgRoot 'Backups'
$null = New-Item -ItemType Directory -Path $multiProgDocs,$multiProgTemplates,$multiProgLibraries,$multiProgExamples,$multiProgReleased,$multiProgTestData,$multiProgBackups -Force
foreach ($manual in $Configuration.Automotive.Manuals) {
$manualPath = Join-Path $multiProgDocs $manual.Name
Invoke-ReliableDownload -Name $manual.Name -Uri $manual.Uri -Destination $manualPath
if ((Get-Item -LiteralPath $manualPath).Length -lt 100KB) { throw "Documentation validation failed: $($manual.Name)" }
}
@'
/* Multi-PROG pure utility functions.
* Safe starter library for offline buffer inspection and training with test files.
* No device I/O, write, erase, security bypass, key, or odometer functions.
*/
function toHexByte(value) {
var text = (value & 0xFF).toString(16).toUpperCase();
return text.length < 2 ? "0" + text : text;
}
function bytesToHex(bytes, separator) {
var sep = separator === undefined ? " " : separator;
var output = "";
for (var i = 0; i < bytes.length; i++) {
if (i > 0) output += sep;
output += toHexByte(bytes[i]);
}
return output;
}
function validateRange(bytes, offset, length) {
if (offset < 0 || length < 0 || offset + length > bytes.length) {
throw new Error("Range is outside the supplied buffer");
}
}
function copyRange(bytes, offset, length) {
validateRange(bytes, offset, length);
var result = new Uint8Array(length);
for (var i = 0; i < length; i++) result[i] = bytes[offset + i];
return result;
}
function compareBytes(left, right) {
var differences = [];
var maxLength = left.length > right.length ? left.length : right.length;
for (var i = 0; i < maxLength; i++) {
var a = i < left.length ? left[i] : null;
var b = i < right.length ? right[i] : null;
if (a !== b) differences.push({ offset:i, left:a, right:b });
}
return differences;
}
function additiveChecksum8(bytes, start, length) {
var first = start === undefined ? 0 : start;
var count = length === undefined ? bytes.length - first : length;
validateRange(bytes, first, count);
var sum = 0;
for (var i = first; i < first + count; i++) sum = (sum + bytes[i]) & 0xFF;
return sum;
}
function xorChecksum8(bytes, start, length) {
var first = start === undefined ? 0 : start;
var count = length === undefined ? bytes.length - first : length;
validateRange(bytes, first, count);
var checksum = 0;
for (var i = first; i < first + count; i++) checksum = checksum ^ bytes[i];
return checksum & 0xFF;
}
'@ | Set-Content -LiteralPath (Join-Path $multiProgLibraries 'SafeBufferUtilities.js') -Encoding UTF8
@'
/* Multi-PROG local-script starter template.
* Paste only the functions you need into a script created inside Multi-PROG.
* AddFunctionButton is documented by Xhorse. Keep the first version read-only.
*/
AddFunctionButton("Inspect", "Inspect loaded test data");
function Inspect() {
// Use Multi-PROG's Function window to insert the exact buffer/file API calls
// supported by your installed software version. Do not guess API signatures.
// Recommended development sequence:
// 1. Open a COPY of known-good test data.
// 2. Validate expected size and known header bytes.
// 3. Calculate/report information without modifying the buffer.
// 4. Test from Debug > Test and review the Message window.
// 5. Publish only after repeatable verification on non-production test data.
}
'@ | Set-Content -LiteralPath (Join-Path $multiProgTemplates 'ReadOnlyInspectionTemplate.js') -Encoding UTF8
@'
/* Read-only binary analysis helpers for copied offline data.
* These functions do not access a device or modify the supplied buffer.
*/
function assertByteRange(bytes, offset, length) {
if (offset < 0 || length < 0 || offset + length > bytes.length) {
throw new Error("Range is outside the supplied buffer");
}
}
function findByteSequence(bytes, pattern) {
var matches = [];
if (!pattern || pattern.length === 0) throw new Error("Pattern must not be empty");
for (var offset = 0; offset <= bytes.length - pattern.length; offset++) {
var matched = true;
for (var index = 0; index < pattern.length; index++) {
if (bytes[offset + index] !== pattern[index]) { matched = false; break; }
}
if (matched) matches.push(offset);
}
return matches;
}
function findAsciiStrings(bytes, minimumLength) {
var minimum = minimumLength === undefined ? 4 : minimumLength;
if (minimum < 1) throw new Error("Minimum length must be positive");
var results = [], start = -1, text = "";
for (var offset = 0; offset <= bytes.length; offset++) {
var value = offset < bytes.length ? bytes[offset] : 0;
if (value >= 32 && value <= 126) {
if (start < 0) start = offset;
text += String.fromCharCode(value);
} else {
if (text.length >= minimum) results.push({ offset:start, text:text });
start = -1;
text = "";
}
}
return results;
}
function hexPreview(bytes, offset, length) {
assertByteRange(bytes, offset, length);
var output = "";
for (var index = 0; index < length; index++) {
if (index > 0) output += " ";
var text = bytes[offset + index].toString(16).toUpperCase();
output += text.length < 2 ? "0" + text : text;
}
return output;
}
'@ | Set-Content -LiteralPath (Join-Path $multiProgExamples 'ReadOnlyBinaryAnalysis.js') -Encoding UTF8
@'
/* Generic checksum test vectors for offline JavaScript verification.
* These checks prove only the helper implementation, not ECU applicability.
*/
function additiveChecksum8ForTest(bytes) {
var sum = 0;
for (var index = 0; index < bytes.length; index++) sum = (sum + bytes[index]) & 0xFF;
return sum;
}
function xorChecksum8ForTest(bytes) {
var checksum = 0;
for (var index = 0; index < bytes.length; index++) checksum = checksum ^ bytes[index];
return checksum & 0xFF;
}
function runChecksumTestVectors() {
var sample = new Uint8Array([0x01, 0x02, 0x03, 0x04]);
var additive = additiveChecksum8ForTest(sample);
var xor = xorChecksum8ForTest(sample);
return {
additivePass: additive === 0x0A,
xorPass: xor === 0x04,
additive: additive,
xor: xor
};
}
'@ | Set-Content -LiteralPath (Join-Path $multiProgExamples 'ChecksumTestVectors.js') -Encoding UTF8
@'
# Multi-PROG checksum library usage guide
## Principle
A checksum function is valid only when all of these are known: exact ECU/software family,
covered byte ranges, excluded bytes, stored-checksum offsets, byte order, initial value,
polynomial (for CRC), reflection rules, final XOR, complement behavior, and block order.
## Safe workflow
1. Identify the module and software version from trusted metadata.
2. Read the source at least twice and compare the reads byte-for-byte.
3. Work on copies only.
4. Run detection/read-only analysis first.
5. Validate the candidate algorithm against an untouched known-good image.
6. Modify one controlled byte in test data and verify that only expected checksum fields change.
7. Cross-check with a second independent implementation or trusted tool.
8. Save output to a new file and produce a difference report.
9. Never treat a generic CRC32, additive, or XOR result as an ECU-specific correction.
## Algorithm categories
- Additive checksums: sum bytes or words, sometimes followed by complement or negation.
- XOR checksums: XOR bytes or words; useful in small records and communications.
- CRC: requires exact width, polynomial, initial value, refin/refout, xorout, and range.
- Block checksums: multiple independently protected regions, often with descriptor tables.
- Cryptographic hashes/signatures: integrity/authenticity mechanisms, not interchangeable with CRC.
## Library use
- Use SafeBufferUtilities.js for range checking, comparison, and learning on offline samples.
- Use upstream projects as references, not drop-in Multi-PROG modules.
- Port only reviewed pure functions supported by Multi-PROG's JavaScript runtime.
- Preserve upstream URL, tag/commit, license, and test vectors with every adapted function.
'@ | Set-Content -LiteralPath (Join-Path $multiProgDocs 'Checksum-Library-Usage.md') -Encoding UTF8
@'
# JavaScript helper functions for Multi-PROG
## toHexByte(value)
Masks a numeric value to eight bits and returns a two-character uppercase hexadecimal string.
Use it for display and logging, not as a numeric conversion for device writes.
## bytesToHex(bytes, separator)
Formats a Uint8Array as readable hexadecimal text. It does not alter the input buffer.
## copyRange(bytes, offset, length)
Copies a bounded region into a new Uint8Array. It rejects negative and out-of-range requests,
which helps prevent accidental reads from the wrong region.
## compareBytes(left, right)
Returns every differing offset and the values from both arrays. Use it to verify repeated reads,
compare original and modified files, and confirm that a transformation changed only expected bytes.
## additiveChecksum8(bytes, start, length)
Calculates a generic modulo-256 byte sum over a selected range. This is a teaching/reference
function and is not proof that a target module uses an 8-bit additive checksum.
## xorChecksum8(bytes, start, length)
Calculates a generic byte-wise XOR over a selected range. It is useful for test vectors and simple
records, but must not be assumed to match an ECU-specific algorithm.
## Multi-PROG compatibility rules
Prefer var, normal functions, Uint8Array, explicit loops, and explicit bounds checks. Avoid require,
npm imports, Node Buffer, browser DOM APIs, fetch, dynamic code generation, and BigInt unless the
installed Multi-PROG manual/runtime explicitly confirms support.
'@ | Set-Content -LiteralPath (Join-Path $multiProgDocs 'JavaScript-Helper-Functions.md') -Encoding UTF8
@'
# Curated Multi-PROG and checksum references
## Multi-PROG
- https://github.com/CarKeyGuyNL/Multi-Prog-Scripts
- https://github.com/search?q=%22Multi-PROG%22+Xhorse&type=repositories
- https://github.com/search?q=%22Multi-PROG%22+extension%3Amjs&type=code
## Automotive checksum and firmware analysis
- https://github.com/nyetwurk/ME7Sum
- https://github.com/ConnorHowell/medc17-checksum-tool
- https://github.com/VancePeterson/checksum-finder
- https://github.com/bri3d/VW_Flash
- https://github.com/RKDimitrov/easytuner
- https://github.com/ctabuyo/romHEX14-community
- https://github.com/topics/ecu
- https://github.com/topics/ecu-tuning
- https://pypi.org/project/openremap/
- https://reveng.sourceforge.io/crc-catalogue/
## JavaScript binary/checksum helpers
- https://github.com/SheetJS/js-crc32
- https://github.com/jaehaklee/easy-crc
- https://github.com/btnguyen2k/js-checksum
- https://github.com/Samuel-Hinchliffe/BrowserCheckSum
- https://github.com/keichi/binary-parser
- https://github.com/dloss/binary-parsing
- https://jdataview.github.io/jBinary/
- https://github.com/kaitai-io/kaitai_struct
- https://github.com/WerWolv/ImHex-Patterns
## Review rule
Do not auto-import or execute downloaded .mjs files. Review source and licensing, test offline on
copies, and reject scripts containing undocumented device selection, erase/write, security-data,
odometer, immobilizer, key-generation, or airbag-data behavior.
'@ | Set-Content -LiteralPath (Join-Path $multiProgDocs 'GitHub-and-Library-References.md') -Encoding UTF8
@'
# Xhorse Multi-PROG script workspace
## Locations
- Documentation: official user and script-function manuals
- Scripts\Libraries: reusable pure JavaScript helpers
- Scripts\Templates: safe starter templates
- Scripts\Examples: ready-to-use, read-only offline analysis helpers
- Scripts\Released: place exported/published `.mjs` files here
- TestData: use copies of known-good sample files only
- Backups: save original reads before any authorized operation
## Multi-PROG workflow
1. Update Multi-PROG software, database, and device firmware from `Update Online`.
2. Open `Script > Local Scripts`.
3. Create a script and complete its metadata. Use Advanced Settings only when required.
4. Multi-PROG uses JavaScript syntax. Use the Functions window to insert version-correct APIs.
5. Develop read-only inspection first. Use `Debug > Test` and inspect the Message window.
6. Keep test data separate from original vehicle/module reads.
7. Use Publish only after syntax checks and repeatable testing. Publishing creates an `.mjs` file.
8. Import published scripts through `Script > Released Features > Import`, then select Run.
## Included read-only examples
- `ReadOnlyBinaryAnalysis.js`: byte-pattern search, printable-text discovery, and hexadecimal preview helpers.
- `ChecksumTestVectors.js`: known-value test vectors for generic additive and XOR helper implementations.
- `SafeBufferUtilities.js`: range validation, copy, comparison, and generic checksum helpers.
These examples operate only on a `Uint8Array` you supply. In a Local Script, use the Functions
window to insert the version-correct command that obtains a copy of the selected test buffer, then
pass that buffer to an example function and report its result through documented UI APIs.
## Safety boundary
Use only on modules and vehicles you own or are explicitly authorized to service. These
starter files do not implement immobilizer defeat, key generation, odometer alteration,
airbag-crash clearing, or automatic writing/erasing. Never guess pinouts, voltage, device
selection, memory region, or checksum rules. Use the exact wiring and procedure shown by
Multi-PROG for the selected device, and preserve multiple verified original backups.
'@ | Set-Content -LiteralPath (Join-Path $multiProgRoot 'README.md') -Encoding UTF8
# Link the workspace into the local automotive folder for convenient access.
$link = Join-Path $LocalRoot 'Tools\Xhorse Multi-PROG'
if (-not (Test-Path -LiteralPath $link)) {
$null = New-Item -ItemType Junction -Path $link -Target $multiProgRoot
}
if (-not $SkipShortcuts) {
$desktopGroup = Join-Path ([Environment]::GetFolderPath('Desktop')) 'Automotive Workstation\Xhorse Multi-PROG'
$startGroup = Join-Path ([Environment]::GetFolderPath('Programs')) 'Automotive Workstation\Xhorse Multi-PROG'
$explorer = Join-Path $env:WINDIR 'explorer.exe'
foreach ($group in @($desktopGroup,$startGroup)) {
New-Shortcut -ShortcutPath (Join-Path $group 'Multi-PROG Workspace.lnk') -TargetPath $explorer -Arguments ('"{0}"' -f $multiProgRoot) -WorkingDirectory $multiProgRoot -Description 'Xhorse Multi-PROG script workspace'
New-Shortcut -ShortcutPath (Join-Path $group 'Multi-PROG Documentation.lnk') -TargetPath $explorer -Arguments ('"{0}"' -f $multiProgDocs) -WorkingDirectory $multiProgDocs -Description 'Multi-PROG manuals and development guides'
}
}
} | Out-Null
# Official Xhorse installers, fetched directly from Xhorse's own download servers.
# These are full licensed device-programming applications, not portable/read-only
# tools: each installer is launched interactively so the user completes licensing,
# activation, and dongle pairing themselves. Opt in with -InstallXhorseSoftware.
if ($InstallXhorseSoftware) {
Invoke-Safe 'AutomotiveTooling' 'Xhorse Multi-PROG software installer' {
$multiProgSoftwareRoot = Join-Path $ProfileInstallerRoot 'Xhorse\Multi-PROG'
$null = New-Item -ItemType Directory -Path $multiProgSoftwareRoot -Force
$installerPath = Join-Path $multiProgSoftwareRoot 'Multi-PROG.exe'
Invoke-ReliableDownload -Name 'Xhorse Multi-PROG installer' -Uri $Configuration.Automotive.Installers[0].Uri -Destination $installerPath
if ((Get-Item -LiteralPath $installerPath).Length -lt 1MB) { throw 'Multi-PROG installer download is unexpectedly small.' }
Unblock-File -LiteralPath $installerPath -ErrorAction SilentlyContinue
Write-Log 'Multi-PROG installer downloaded. Launching interactive setup; connect the Multi-PROG dongle only when the wizard prompts for it.' 'WARN'
Start-Process -FilePath $installerPath -WorkingDirectory $multiProgSoftwareRoot
} | Out-Null
Invoke-Safe 'AutomotiveTooling' 'Xhorse MVCI PRO software installer' {
$mvciSoftwareRoot = Join-Path $ProfileInstallerRoot 'Xhorse\MVCI-PRO'
$null = New-Item -ItemType Directory -Path $mvciSoftwareRoot -Force
$installerPath = Join-Path $mvciSoftwareRoot 'MVCI_PRO-J2534.exe'
# Xhorse's MVCI PRO CDN rejects HTTPS (403); HTTP is the vendor's only working transport for this asset.
Invoke-ReliableDownload -Name 'Xhorse MVCI PRO installer' -Uri $Configuration.Automotive.Installers[1].Uri -Destination $installerPath
if ((Get-Item -LiteralPath $installerPath).Length -lt 1MB) { throw 'MVCI PRO installer download is unexpectedly small.' }
$manualPath = Join-Path $mvciSoftwareRoot 'MVCI-PRO-User-Manual.pdf'
try {
Invoke-ReliableDownload -Name 'Xhorse MVCI PRO user manual' -Uri 'http://dl.xhorse.com/p/vd06/userManual.pdf' -Destination $manualPath
}
catch {
Write-Log "MVCI PRO user manual download failed (non-critical): $($_.Exception.Message)" 'WARN'
}
Unblock-File -LiteralPath $installerPath -ErrorAction SilentlyContinue
Write-Log 'MVCI PRO installer downloaded over HTTP (vendor CDN does not serve this file over HTTPS). Verify the file before trusting it. Launching interactive setup; connect the MVCI PRO J2534 interface only when the wizard prompts for it.' 'WARN'
Start-Process -FilePath $installerPath -WorkingDirectory $mvciSoftwareRoot
} | Out-Null
}
# Communication software is installed separately on each Windows installation because
# J2534 drivers, USB bindings, licensing, and device registration are OS-local.
if ($InstallCommunicationSoftware) {
$communicationSoftware = @($Configuration.Automotive.CommunicationSoftware)
$mvci = $communicationSoftware | Where-Object { $_.Name -eq 'MVCI PRO J2534' } | Select-Object -First 1
if ($mvci) {
Invoke-Safe 'AutomotiveTooling' 'MVCI PRO J2534 communication software' {
$mvciRoot = Join-Path $ProfileInstallerRoot $mvci.Folder
$null = New-Item -ItemType Directory -Path $mvciRoot -Force
$mvciPath = Join-Path $mvciRoot $mvci.FileName
Invoke-ReliableDownload -Name $mvci.Name -Uri $mvci.Uri -Destination $mvciPath
if ((Get-Item -LiteralPath $mvciPath).Length -lt [int64]$mvci.MinimumBytes) { throw 'MVCI PRO installer is unexpectedly small.' }
Unblock-File -LiteralPath $mvciPath -ErrorAction SilentlyContinue
Write-Log 'MVCI PRO J2534 installer downloaded. Launching interactive setup for this Windows installation; connect the interface only when prompted.' 'WARN'
Start-Process -FilePath $mvciPath -WorkingDirectory $mvciRoot
} | Out-Null
}
$autel = $communicationSoftware | Where-Object { $_.Name -eq 'Autel Maxi PC Suite' } | Select-Object -First 1
if ($autel) {
Invoke-Safe 'AutomotiveTooling' 'Autel Maxi PC Suite download' {
$autelRoot = Join-Path $ProfileInstallerRoot $autel.Folder
$null = New-Item -ItemType Directory -Path $autelRoot -Force
$autelPath = Join-Path $autelRoot $autel.FileName
Invoke-ReliableDownload -Name $autel.Name -Uri $autel.Uri -Destination $autelPath
if ((Get-Item -LiteralPath $autelPath).Length -lt [int64]$autel.MinimumBytes) { throw 'Autel Maxi PC Suite download is unexpectedly small.' }
Unblock-File -LiteralPath $autelPath -ErrorAction SilentlyContinue
$autelExtractRoot = Join-Path $autelRoot 'Extracted'
Remove-Item -LiteralPath $autelExtractRoot -Recurse -Force -ErrorAction SilentlyContinue
Expand-Archive -LiteralPath $autelPath -DestinationPath $autelExtractRoot -Force
$autelInstaller = Get-ChildItem -LiteralPath $autelExtractRoot -Filter '*.exe' -File -Recurse -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match '(?i)(setup|install|maxipcsuite)' } |
Select-Object -First 1
if (-not $autelInstaller) { throw 'Autel Maxi PC Suite installer executable was not found in the downloaded ZIP.' }
Unblock-File -LiteralPath $autelInstaller.FullName -ErrorAction SilentlyContinue
$autelReadme = Join-Path $autelRoot 'INSTALLATION.txt'
@"
Autel Maxi PC Suite
Profile: $WorkstationProfile
Downloaded package:
$autelPath
Extracted installer:
$($autelInstaller.FullName)
Official support page:
$($autel.DownloadPage)
Complete Autel licensing, device registration, USB driver, firmware, and vehicle-interface setup
according to the vendor instructions. Do not use a third-party mirror.
"@ | Set-Content -LiteralPath $autelReadme -Encoding UTF8
Write-Log "Autel Maxi PC Suite extracted to $autelExtractRoot. Launching the package installer for this Windows installation." 'WARN'
Start-Process -FilePath $autelInstaller.FullName -WorkingDirectory $autelInstaller.DirectoryName
} | Out-Null
}
}
# Save a manifest for auditing and reproducing the setup in the other isolated workstations.
$manifestRoot = if (Test-Path 'P:\Manifests') { 'P:\Manifests' } else { $Paths.Config }
$PortableDefinitions | Select-Object Name, Type, Uri, Folder, Executable, Shortcut |
Export-Csv -LiteralPath (Join-Path $manifestRoot "PortableApps_$TimeStamp.csv") -NoTypeInformation -Encoding UTF8
}
# 6. Convenience shortcuts.
if (-not $SkipShortcuts) {
$desktopGroup = Join-Path ([Environment]::GetFolderPath('Desktop')) 'Automotive Workstation'
$startGroup = Join-Path ([Environment]::GetFolderPath('Programs')) 'Automotive Workstation'
$null = New-Item -ItemType Directory -Path $desktopGroup, $startGroup -Force
$explorer = Join-Path $env:WINDIR 'explorer.exe'
$folderShortcuts = @(
@{ Name='Automotive Workspace'; Target=$LocalRoot },
@{ Name='Tuning Projects'; Target=$Paths.Tuning },
@{ Name='Cloning Projects'; Target=$Paths.Cloning },
@{ Name='Reverse Engineering'; Target=$Paths.ReverseEngineering },
@{ Name='Automotive Research'; Target=$Paths.Research },
@{ Name='Backups'; Target=$Paths.Backups },
@{ Name='Setup Logs'; Target=$Paths.Logs }
)
if (Test-Path 'S:\') { $folderShortcuts += @{ Name='S - Shared Automotive Data'; Target='S:\' } }
if (Test-Path $SharedDataRoot) { $folderShortcuts += @{ Name='Shared Automotive Workspace'; Target=$SharedDataRoot } }
if (Test-Path $ProfileDataRoot) { $folderShortcuts += @{ Name="$WorkstationProfile Shared Workspace"; Target=$ProfileDataRoot } }
if (Test-Path 'P:\') { $folderShortcuts += @{ Name='P - Shared Portable Apps'; Target='P:\' } }
if (Test-Path $SharedPortableRoot) { $folderShortcuts += @{ Name='Portable Apps Library'; Target=$SharedPortableRoot } }
if (Test-Path $ProfileConfigRoot) { $folderShortcuts += @{ Name="$WorkstationProfile Configuration"; Target=$ProfileConfigRoot } }
if (Test-Path $ProfileInstallerRoot) { $folderShortcuts += @{ Name="$WorkstationProfile Installers"; Target=$ProfileInstallerRoot } }
foreach ($item in $folderShortcuts) {
foreach ($group in @($desktopGroup, $startGroup)) {
Invoke-Safe 'Shortcut' "$($item.Name) in $(Split-Path $group -Leaf)" {
New-Shortcut -ShortcutPath (Join-Path $group "$($item.Name).lnk") `
-TargetPath $explorer -Arguments ('"{0}"' -f $item.Target) `
-WorkingDirectory $item.Target -Description $item.Name
} | Out-Null
}
}
$effectivePortableRoot = Get-PortableInstallRoot
$sysinternals = Join-Path $effectivePortableRoot 'SystemTools\SysinternalsSuite'
$toolShortcuts = @(
@{ Name='Process Explorer'; File='procexp64.exe' },
@{ Name='Process Monitor'; File='Procmon.exe' },
@{ Name='Autoruns'; File='Autoruns64.exe' },
@{ Name='TCPView'; File='Tcpview.exe' }
)
foreach ($tool in $toolShortcuts) {
$target = Join-Path $sysinternals $tool.File
if (Test-Path $target) {
Invoke-Safe 'Shortcut' $tool.Name {
New-Shortcut -ShortcutPath (Join-Path $startGroup "$($tool.Name).lnk") `
-TargetPath $target -Description "$($tool.Name) - Microsoft Sysinternals"
} | Out-Null
}
}
}
# 7. Environment and safety optimizations.
Invoke-Safe 'Configuration' 'Local TEMP workspace' {
$machineTemp = Join-Path $Paths.Temp 'System'
$userTemp = Join-Path $Paths.Temp 'User'
$null = New-Item -ItemType Directory -Path $machineTemp, $userTemp -Force
# Keep Windows/system TEMP unchanged by default. Save recommendations instead.
@"
Recommended manual checks after setup:
1. Create a full system image before installing OEM diagnostic suites or interface drivers.
2. Keep every other Windows installation offline and without a drive letter in this OS; use only C:, S:, and P:.
3. USB selective suspend is disabled by this setup for diagnostic-interface reliability.
4. Exclude project folders from antivirus only when the vendor explicitly requires it.
5. Never flash/code a vehicle while Windows Update is applying changes.
6. Use a stable power supply for the laptop and a regulated vehicle support charger.
7. Store irreplaceable ECU/EEPROM originals in at least two additional backup locations.
8. Do not place active databases on NTFS junctions unless the vendor supports it.
"@ | Set-Content -LiteralPath (Join-Path $Paths.Config 'Post-Setup-Recommendations.txt') -Encoding UTF8
} | Out-Null
# Record the final shared-partition state for troubleshooting and future Windows installs.
Invoke-Safe 'Report' 'Export shared partition inventory' {
Get-Volume -DriveLetter S,P -ErrorAction SilentlyContinue |
Select-Object DriveLetter, FileSystemLabel, FileSystem, HealthStatus,
@{Name='SizeGB';Expression={[math]::Round($_.Size / 1GB, 1)}},
@{Name='FreeGB';Expression={[math]::Round($_.SizeRemaining / 1GB, 1)}} |
Export-Csv -LiteralPath (Join-Path $Paths.Logs "SharedPartitions_$TimeStamp.csv") -NoTypeInformation -Encoding UTF8
} | Out-Null
Invoke-Safe 'Report' 'Export workstation inventory' { Export-WorkstationInventory } | Out-Null
# Export machine-readable results even when some packages failed.
$Results | Export-Csv -LiteralPath $SummaryFile -NoTypeInformation -Encoding UTF8
Invoke-Safe 'Report' 'Export setup report' { Export-SetupReport } | Out-Null
Write-Log "Completed. Detailed log: $LogFile" 'OK'
Write-Log "Result summary: $SummaryFile" 'OK'
Write-Log 'Restart Windows before installing automotive interface drivers or OEM suites.' 'WARN'
if ($EnableTranscript) { Stop-Transcript | Out-Null }