2060 lines
107 KiB
PowerShell
2060 lines
107 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.
|
|
|
|
Install licensed/OEM tools from the vendor portal and validate compatibility with your exact interface.
|
|
The only exception is -InstallXhorseSoftware, which fetches the Xhorse Multi-PROG,
|
|
VVDI-MLB, 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
|
|
.\Automotive-Workstation-Setup.ps1 -WorkstationProfile 'DailyTech & Tuning'
|
|
|
|
.EXAMPLE
|
|
# Set up the isolated VAG and Mercedes diagnostic workstation.
|
|
.\Automotive-Workstation-Setup.ps1 -WorkstationProfile 'ODIS & XENTRY'
|
|
|
|
.EXAMPLE
|
|
# Set up the isolated Porsche and BMW diagnostic workstation.
|
|
.\Automotive-Workstation-Setup.ps1 -WorkstationProfile 'PIWIS & ISTA'
|
|
|
|
.EXAMPLE
|
|
# Initial full setup, including optional development and utility applications.
|
|
.\Automotive-Workstation-Setup.ps1 -InstallOptionalApps
|
|
|
|
.EXAMPLE
|
|
# Resume a prior run and retry missing or failed optional packages.
|
|
.\Automotive-Workstation-Setup.ps1 -InstallOptionalApps
|
|
|
|
.EXAMPLE
|
|
# Also fetch and launch the official Xhorse Multi-PROG, VVDI-MLB, and MVCI PRO installers.
|
|
.\Automotive-Workstation-Setup.ps1 -InstallXhorseSoftware
|
|
|
|
.EXAMPLE
|
|
# Install communication software on this Windows installation.
|
|
# Run this once in each of the three isolated Windows installations.
|
|
.\Automotive-Workstation-Setup.ps1 -WorkstationProfile 'PIWIS & ISTA' -InstallCommunicationSoftware
|
|
|
|
.EXAMPLE
|
|
# Intentionally refresh all portable applications after a successful setup.
|
|
.\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',
|
|
[ValidateSet('Apply', 'Audit', 'Plan', 'Repair', 'HealthCheck', 'Backup', 'Restore', 'Inventory', 'UpdatePortable', 'CreateWorkspace')]
|
|
[string]$Mode = 'Apply',
|
|
[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,
|
|
[string]$ExecutionId,
|
|
[switch]$ResumeFromCheckpoint,
|
|
[string]$JobId,
|
|
[string]$Manufacturer,
|
|
[string]$Model,
|
|
[ValidateRange(1886, 2100)][int]$ModelYear = (Get-Date).Year,
|
|
[ValidateSet('ECU', 'TCU', 'EEPROM', 'ABS', 'Other')]
|
|
[string]$Module = 'ECU',
|
|
[string]$VIN,
|
|
[string]$Notes,
|
|
[string]$Technician,
|
|
[switch]$PromptForRestart,
|
|
[switch]$AllowAutomaticRestart
|
|
)
|
|
|
|
Set-StrictMode -Version Latest
|
|
$ErrorActionPreference = 'Stop'
|
|
$ProgressPreference = 'SilentlyContinue'
|
|
$moduleRoot = Join-Path $PSScriptRoot 'Modules'
|
|
Import-Module (Join-Path $moduleRoot 'Configuration.psm1') -Force
|
|
Import-Module (Join-Path $moduleRoot 'Checkpoint.psm1') -Force
|
|
Import-Module (Join-Path $moduleRoot 'Download.psm1') -Force
|
|
Import-Module (Join-Path $moduleRoot 'Workspace.psm1') -Force
|
|
|
|
function Import-SetupConfiguration {
|
|
param([Parameter(Mandatory)][string]$Name)
|
|
return Import-WorkstationJsonConfiguration -BasePath $PSScriptRoot -Name $Name
|
|
}
|
|
|
|
$Configuration = @{
|
|
AppCatalog = if (Test-Path -LiteralPath (Join-Path $PSScriptRoot 'Config\app-catalog.json') -PathType Leaf) { Import-SetupConfiguration -Name 'app-catalog.json' } else { $null }
|
|
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'
|
|
WindowsSettings = Import-SetupConfiguration -Name 'windows-settings.json'
|
|
WorkspaceTemplates = Import-SetupConfiguration -Name 'workspace-templates.json'
|
|
}
|
|
|
|
function Get-ProfileCatalogItems {
|
|
param(
|
|
[Parameter(Mandatory)][object[]]$Items,
|
|
[Parameter(Mandatory)][string]$Channel
|
|
)
|
|
$profileFolder = $Profile.Folder
|
|
return @($Items | Where-Object {
|
|
$_.Channel -eq $Channel -and
|
|
((-not $_.PSObject.Properties['Profiles']) -or @($_.Profiles) -contains $profileFolder)
|
|
})
|
|
}
|
|
|
|
$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]
|
|
$EffectiveMode = $Mode
|
|
if ($HealthCheck) { $EffectiveMode = 'HealthCheck' }
|
|
elseif ($BackupConfiguration) { $EffectiveMode = 'Backup' }
|
|
elseif ($RestoreConfiguration) { $EffectiveMode = 'Restore' }
|
|
$windowsProfileKey = $Profile.Folder
|
|
if (-not $Configuration.WindowsSettings.Profiles.PSObject.Properties[$windowsProfileKey]) {
|
|
throw "Windows settings are not defined for profile '$windowsProfileKey'."
|
|
}
|
|
$DesiredWindowsSettings = $Configuration.WindowsSettings.Profiles.$windowsProfileKey
|
|
$ProfileDataRoot = "{0}\Workstations\{1}" -f $SharedDataRoot.TrimEnd('\'), $Profile.Folder
|
|
$ProfileConfigRoot = "{0}\Config\{1}" -f $SharedPortableRoot.TrimEnd('\'), $Profile.Folder
|
|
# Installer binaries are shared by all isolated Windows profiles. With the default
|
|
# portable root this resolves to P:\Install, never P:\Install\<Profile>.
|
|
$SharedInstallerRoot = Join-Path (Split-Path $SharedPortableRoot -Parent) 'Install'
|
|
|
|
$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 $SharedInstallerRoot '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, $Paths.Config -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)
|
|
$CheckpointFile = Join-Path $Paths.Config '.workstation-setup-state.json'
|
|
$ExecutionIdWasProvided = -not [string]::IsNullOrWhiteSpace($ExecutionId)
|
|
$ExecutionId = if ([string]::IsNullOrWhiteSpace($ExecutionId)) {
|
|
'{0}-{1}' -f $TimeStamp, ([guid]::NewGuid().ToString('N').Substring(0, 8))
|
|
} else {
|
|
$ExecutionId
|
|
}
|
|
$Results = [System.Collections.Generic.List[object]]::new()
|
|
$Events = [System.Collections.Generic.List[object]]::new()
|
|
$ExecutionState = [ordered]@{
|
|
ExecutionId = $ExecutionId
|
|
Profile = $WorkstationProfile
|
|
Mode = $EffectiveMode
|
|
StartTime = $ScriptStartTime.ToString('o')
|
|
LastCheckpoint = $null
|
|
Status = 'InProgress'
|
|
CompletedPhases = @()
|
|
}
|
|
|
|
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 Save-ExecutionCheckpoint {
|
|
param(
|
|
[Parameter(Mandatory)][string]$Phase,
|
|
[ValidateSet('Completed', 'InProgress', 'Failed', 'AwaitingRestart')]
|
|
[string]$Status = 'Completed',
|
|
[string]$Details = ''
|
|
)
|
|
|
|
$ExecutionState.LastCheckpoint = (Get-Date).ToString('o')
|
|
$ExecutionState.Status = $Status
|
|
$ExecutionState.CompletedPhases = @(
|
|
@($ExecutionState.CompletedPhases | Where-Object { $_.Phase -ne $Phase })
|
|
[pscustomobject]@{
|
|
Phase = $Phase
|
|
Status = $Status
|
|
Timestamp = $ExecutionState.LastCheckpoint
|
|
Details = $Details
|
|
}
|
|
)
|
|
$ExecutionState | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $CheckpointFile -Encoding UTF8
|
|
Write-Log "Checkpoint saved: $Phase ($Status)" 'INFO'
|
|
}
|
|
|
|
function Load-ExecutionCheckpoint {
|
|
if (-not (Test-Path -LiteralPath $CheckpointFile -PathType Leaf)) {
|
|
throw "Checkpoint file was not found: $CheckpointFile"
|
|
}
|
|
|
|
$checkpoint = Get-Content -LiteralPath $CheckpointFile -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop
|
|
if ($checkpoint.Profile -ne $WorkstationProfile) {
|
|
throw "Checkpoint profile '$($checkpoint.Profile)' does not match '$WorkstationProfile'."
|
|
}
|
|
if ($ExecutionIdWasProvided -and $checkpoint.ExecutionId -ne $ExecutionId) {
|
|
throw "Checkpoint execution ID '$($checkpoint.ExecutionId)' does not match '$ExecutionId'."
|
|
}
|
|
return $checkpoint
|
|
}
|
|
|
|
function Test-SetupPhaseCompleted {
|
|
param([Parameter(Mandatory)][string]$Phase)
|
|
if (-not $ResumeFromCheckpoint -or -not (Test-Path -LiteralPath $CheckpointFile -PathType Leaf)) {
|
|
return $false
|
|
}
|
|
return Test-WorkstationCheckpointPhase -Path $CheckpointFile -Phase $Phase
|
|
}
|
|
|
|
function Detect-PendingRestart {
|
|
$cbs = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending'
|
|
$windowsUpdate = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired'
|
|
$sessionManager = Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue
|
|
return $cbs -or $windowsUpdate -or ($sessionManager -and $null -ne $sessionManager.PendingFileRenameOperations)
|
|
}
|
|
|
|
function Complete-SetupPhase {
|
|
param([Parameter(Mandatory)][string]$Phase, [string]$Details = '')
|
|
Save-ExecutionCheckpoint -Phase $Phase -Status 'Completed' -Details $Details
|
|
}
|
|
|
|
function Request-SetupRestart {
|
|
param([Parameter(Mandatory)][string]$Reason)
|
|
Save-ExecutionCheckpoint -Phase 'RestartRequired' -Status 'AwaitingRestart' -Details $Reason
|
|
if ($AllowAutomaticRestart) {
|
|
Write-Log "Restarting Windows automatically: $Reason" 'WARN'
|
|
Restart-Computer -Force
|
|
return
|
|
}
|
|
if ($PromptForRestart) {
|
|
$answer = Read-Host "$Reason Restart now? (Y/N)"
|
|
if ($answer -match '^(?i)y(?:es)?$') {
|
|
Restart-Computer -Force
|
|
return
|
|
}
|
|
}
|
|
Write-Log "Restart required before continuing. Resume with -ResumeFromCheckpoint after restarting." 'WARN'
|
|
}
|
|
|
|
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,
|
|
[switch]$AllowInsecureHttp
|
|
)
|
|
Test-DownloadUri -Uri $Uri -AllowInsecureHttp:$AllowInsecureHttp
|
|
$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 Invoke-PowerCfg {
|
|
param([Parameter(Mandatory)][string[]]$Arguments)
|
|
$process = Start-Process -FilePath 'powercfg.exe' -ArgumentList $Arguments -Wait -PassThru -NoNewWindow
|
|
if ($process.ExitCode -ne 0) {
|
|
throw "powercfg.exe $($Arguments -join ' ') failed with exit code $($process.ExitCode)."
|
|
}
|
|
}
|
|
|
|
function Get-WindowsConfigurationState {
|
|
$activeScheme = (& powercfg.exe /getactivescheme 2>$null) -join ' '
|
|
$powerSettings = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Power' -Name HibernateEnabled -ErrorAction SilentlyContinue
|
|
$hibernateValue = if ($null -eq $powerSettings -or -not $powerSettings.PSObject.Properties['HibernateEnabled']) { $null } else { $powerSettings.HibernateEnabled }
|
|
$fileSystem = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' -ErrorAction SilentlyContinue
|
|
$explorer = Get-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced' -ErrorAction SilentlyContinue
|
|
return [pscustomobject]@{
|
|
Time = Get-Date
|
|
ActivePowerScheme = $activeScheme
|
|
HibernateEnabled = $hibernateValue
|
|
UsbSelectiveSuspend = 'Inspect with powercfg /query'
|
|
MonitorTimeoutAC = 'Inspect with powercfg /query'
|
|
DiskTimeoutAC = 'Inspect with powercfg /query'
|
|
StandbyTimeoutAC = 'Inspect with powercfg /query'
|
|
ShowFileExtensions = if ($null -eq $explorer -or -not $explorer.PSObject.Properties['HideFileExt']) { $null } else { $explorer.HideFileExt -eq 0 }
|
|
LongPathsEnabled = if ($null -eq $fileSystem -or -not $fileSystem.PSObject.Properties['LongPathsEnabled']) { $null } else { $fileSystem.LongPathsEnabled -eq 1 }
|
|
}
|
|
}
|
|
|
|
function Get-WindowsConfigurationPlan {
|
|
$current = Get-WindowsConfigurationState
|
|
$desired = $DesiredWindowsSettings
|
|
return @(
|
|
[pscustomobject]@{ Setting='PowerPlan'; Current=$current.ActivePowerScheme; Desired=$desired.PowerPlan; Action='Set active scheme' }
|
|
[pscustomobject]@{ Setting='Hibernation'; Current=if ($current.HibernateEnabled -eq 1) { 'Enabled' } else { 'Disabled' }; Desired=$desired.Hibernation; Action='powercfg /hibernate' }
|
|
[pscustomobject]@{ Setting='USB selective suspend'; Current=$current.UsbSelectiveSuspend; Desired=$desired.UsbSelectiveSuspend; Action='Set AC/DC USB policy' }
|
|
[pscustomobject]@{ Setting='Monitor timeout AC'; Current=$current.MonitorTimeoutAC; Desired="$($desired.MonitorTimeoutACMinutes) minutes"; Action='powercfg /change' }
|
|
[pscustomobject]@{ Setting='System sleep AC'; Current=$current.StandbyTimeoutAC; Desired="$($desired.StandbyTimeoutACMinutes) minutes"; Action='powercfg /change' }
|
|
[pscustomobject]@{ Setting='Show file extensions'; Current=$current.ShowFileExtensions; Desired=[bool]$desired.ShowFileExtensions; Action='Explorer registry setting' }
|
|
[pscustomobject]@{ Setting='Long paths'; Current=$current.LongPathsEnabled; Desired=[bool]$desired.LongPathsEnabled; Action='FileSystem registry setting' }
|
|
)
|
|
}
|
|
|
|
function Set-WindowsProfileConfiguration {
|
|
$desired = $DesiredWindowsSettings
|
|
if ($desired.Hibernation -eq 'Disabled') { Invoke-PowerCfg -Arguments @('/hibernate', 'off') }
|
|
else { Invoke-PowerCfg -Arguments @('/hibernate', 'on') }
|
|
|
|
$usbValue = if ($desired.UsbSelectiveSuspend -eq 'Disabled') { '0' } else { '1' }
|
|
Invoke-PowerCfg -Arguments @('/setacvalueindex', 'SCHEME_CURRENT', 'SUB_USB', 'USBSELECTIVE', $usbValue)
|
|
Invoke-PowerCfg -Arguments @('/setdcvalueindex', 'SCHEME_CURRENT', 'SUB_USB', 'USBSELECTIVE', $usbValue)
|
|
Invoke-PowerCfg -Arguments @('/setactive', $desired.PowerPlanAlias)
|
|
Invoke-PowerCfg -Arguments @('/change', 'monitor-timeout-ac', [string]$desired.MonitorTimeoutACMinutes)
|
|
Invoke-PowerCfg -Arguments @('/change', 'disk-timeout-ac', [string]$desired.DiskTimeoutACMinutes)
|
|
Invoke-PowerCfg -Arguments @('/change', 'standby-timeout-ac', [string]$desired.StandbyTimeoutACMinutes)
|
|
|
|
$explorerPath = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced'
|
|
$hideFileExtensionValue = if ($desired.ShowFileExtensions) { 0 } else { 1 }
|
|
Set-ItemProperty -Path $explorerPath -Name HideFileExt -Value $hideFileExtensionValue
|
|
$fileSystemPath = 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem'
|
|
$longPathValue = if ($desired.LongPathsEnabled) { 1 } else { 0 }
|
|
Set-ItemProperty -Path $fileSystemPath -Name LongPathsEnabled -Value $longPathValue
|
|
Write-Log "Applied Windows settings for $($Profile.Folder): $($desired.PowerPlan), hibernation $($desired.Hibernation), USB suspend $($desired.UsbSelectiveSuspend)." 'OK'
|
|
}
|
|
|
|
function ConvertTo-ProjectSegment {
|
|
param([Parameter(Mandatory)][string]$Value, [Parameter(Mandatory)][string]$Name)
|
|
return ConvertTo-WorkstationProjectSegment -Value $Value -Name $Name
|
|
}
|
|
|
|
function New-AutomotiveProject {
|
|
param(
|
|
[Parameter(Mandatory)][string]$ProjectJobId,
|
|
[Parameter(Mandatory)][string]$ProjectManufacturer,
|
|
[Parameter(Mandatory)][string]$ProjectModel,
|
|
[int]$ProjectModelYear = (Get-Date).Year,
|
|
[string]$ProjectVIN = '',
|
|
[string]$ProjectNotes = '',
|
|
[string]$ProjectTechnician = ''
|
|
)
|
|
$templateProperty = $Configuration.WorkspaceTemplates.Templates.PSObject.Properties[$Profile.Folder]
|
|
if (-not $templateProperty) { throw "Workspace template is not defined for profile '$($Profile.Folder)'." }
|
|
$template = $templateProperty.Value
|
|
|
|
$safeJobId = ConvertTo-ProjectSegment -Value $ProjectJobId -Name 'JobId'
|
|
$safeManufacturer = ConvertTo-ProjectSegment -Value $ProjectManufacturer -Name 'Manufacturer'
|
|
$safeModel = ConvertTo-ProjectSegment -Value $ProjectModel -Name 'Model'
|
|
$date = Get-Date -Format 'yyyy-MM-dd'
|
|
$projectName = '{0}_{1}_{2}_{3}' -f $safeJobId, $safeManufacturer, $safeModel, $date
|
|
$localBase = Join-Path $LocalRoot $template.LocalBase
|
|
$localProject = Join-Path $localBase $projectName
|
|
$sharedProject = Join-Path $ProfileDataRoot $projectName
|
|
|
|
$localFolders = @($template.Folders | ForEach-Object { Join-Path $localProject $_ })
|
|
$sharedFolders = @($template.Folders | ForEach-Object { Join-Path $sharedProject $_ })
|
|
$null = New-Item -ItemType Directory -Path @($localProject, $sharedProject) -Force
|
|
$null = New-Item -ItemType Directory -Path ($localFolders + $sharedFolders) -Force
|
|
|
|
$now = (Get-Date).ToString('o')
|
|
$metadata = [ordered]@{
|
|
SchemaVersion = '1.0'
|
|
JobId = $safeJobId
|
|
Profile = $Profile.Folder
|
|
CreatedDate = $now
|
|
LastModified = $now
|
|
Vehicle = [ordered]@{
|
|
Manufacturer = $safeManufacturer
|
|
Model = $safeModel
|
|
ModelYear = $ProjectModelYear
|
|
VIN = $ProjectVIN
|
|
}
|
|
Module = [ordered]@{ Type = $Module; PartNumber = ''; HardwareVersion = ''; SoftwareVersion = '' }
|
|
Work = [ordered]@{
|
|
OperationType = $template.OperationType
|
|
Technician = $ProjectTechnician
|
|
TechnicianNotes = $ProjectNotes
|
|
AuthorizationReference = ''
|
|
ToolsUsed = @()
|
|
}
|
|
Paths = [ordered]@{ Local = $localProject; Shared = $sharedProject }
|
|
Status = 'New'
|
|
}
|
|
$metadataPath = Join-Path $localProject 'ProjectMetadata.json'
|
|
$metadata | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $metadataPath -Encoding UTF8
|
|
$metadata | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath (Join-Path $sharedProject 'ProjectMetadata.json') -Encoding UTF8
|
|
|
|
$jobLog = @"
|
|
# $safeJobId - $safeManufacturer $safeModel
|
|
|
|
**Profile:** $($Profile.Folder)
|
|
**Created:** $now
|
|
**Module:** $Module
|
|
**Technician:** $ProjectTechnician
|
|
**VIN:** $ProjectVIN
|
|
|
|
## Work Notes
|
|
$ProjectNotes
|
|
|
|
## Timeline
|
|
|
|
### $now - Project initialized
|
|
- Workspace folders created from the $($Profile.Folder) template.
|
|
- Original vehicle/module files belong in the `Originals` or profile-equivalent folder.
|
|
- Preserve originals and record SHA256 hashes before making changes.
|
|
|
|
## Recovery
|
|
- Local project: `$localProject`
|
|
- Shared project copy: `$sharedProject`
|
|
- Do not flash, code, erase, or modify a vehicle without explicit authorization and verified backups.
|
|
"@
|
|
$jobLog | Set-Content -LiteralPath (Join-Path $localProject 'JobLog.md') -Encoding UTF8
|
|
$jobLog | Set-Content -LiteralPath (Join-Path $sharedProject 'JobLog.md') -Encoding UTF8
|
|
|
|
return [pscustomobject]@{
|
|
JobId = $safeJobId
|
|
Profile = $Profile.Folder
|
|
LocalPath = $localProject
|
|
SharedPath = $sharedProject
|
|
MetadataPath = $metadataPath
|
|
}
|
|
}
|
|
|
|
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 Test-DownloadUri {
|
|
param(
|
|
[Parameter(Mandatory)][string]$Uri,
|
|
[switch]$AllowInsecureHttp
|
|
)
|
|
Test-WorkstationDownloadUri -Uri $Uri -AllowInsecureHttp:$AllowInsecureHttp
|
|
if (([Uri]$Uri).Scheme -eq 'http' -and $AllowInsecureHttp) {
|
|
Write-Log "Using explicitly approved HTTP download: $Uri" 'WARN'
|
|
}
|
|
}
|
|
|
|
function Test-DownloadedFile {
|
|
param(
|
|
[Parameter(Mandatory)][string]$Path,
|
|
[Parameter(Mandatory)][string]$Name,
|
|
[int64]$MinimumBytes = 65536,
|
|
[string]$ExpectedSHA256,
|
|
[switch]$RequireAuthenticode
|
|
)
|
|
$file = Get-Item -LiteralPath $Path -ErrorAction Stop
|
|
if ($file.Length -lt $MinimumBytes) {
|
|
throw "$Name download is unexpectedly small: $($file.Length) bytes; minimum is $MinimumBytes."
|
|
}
|
|
if (-not [string]::IsNullOrWhiteSpace($ExpectedSHA256) -and $ExpectedSHA256 -notmatch '(?i)^TO_BE_FILLED') {
|
|
$actualHash = (Get-FileHash -LiteralPath $Path -Algorithm SHA256 -ErrorAction Stop).Hash
|
|
if ($actualHash -ne $ExpectedSHA256.ToUpperInvariant()) {
|
|
throw "$Name SHA256 mismatch. Expected $ExpectedSHA256, received $actualHash."
|
|
}
|
|
Write-Log "$Name SHA256 verified: $actualHash" 'OK'
|
|
}
|
|
if ($RequireAuthenticode -and $file.Extension -notin @('.exe', '.dll')) {
|
|
throw "$Name requires an Authenticode signature, but $($file.Extension) files cannot be validated."
|
|
}
|
|
if ($file.Extension -in @('.exe', '.dll')) {
|
|
$signature = Get-AuthenticodeSignature -LiteralPath $Path
|
|
if ($signature.Status -ne 'Valid') {
|
|
if ($RequireAuthenticode) { throw "$Name Authenticode signature is not valid: $($signature.Status)." }
|
|
Write-Log "$Name has no valid Authenticode signature: $($signature.Status)." 'WARN'
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
[string]$ExpectedSHA256,
|
|
[switch]$AllowInsecureHttp,
|
|
[switch]$RequireAuthenticode
|
|
)
|
|
$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 {
|
|
Test-DownloadUri -Uri $Uri -AllowInsecureHttp:$AllowInsecureHttp
|
|
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"
|
|
Test-DownloadedFile -Path $archive -Name $Name -MinimumBytes 65536 -ExpectedSHA256 $ExpectedSHA256
|
|
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." }
|
|
if ($RequireAuthenticode) {
|
|
Test-DownloadedFile -Path $expected.FullName -Name $Name -MinimumBytes 1 -RequireAuthenticode
|
|
}
|
|
|
|
# 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,
|
|
[string]$ExpectedSHA256,
|
|
[switch]$AllowInsecureHttp,
|
|
[switch]$RequireAuthenticode
|
|
)
|
|
$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 {
|
|
Test-DownloadUri -Uri $Uri -AllowInsecureHttp:$AllowInsecureHttp
|
|
Invoke-ReliableDownload -Name $Name -Uri $Uri -Destination $temporary
|
|
Test-DownloadedFile -Path $temporary -Name $Name -MinimumBytes 65536 -ExpectedSHA256 $ExpectedSHA256 -RequireAuthenticode:$RequireAuthenticode
|
|
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,
|
|
[switch]$RequireAuthenticode
|
|
)
|
|
$expected = Get-ChildItem -LiteralPath $Destination -Filter $ExpectedExecutable -File -Recurse -ErrorAction SilentlyContinue |
|
|
Select-Object -First 1
|
|
if (-not $expected) { return $false }
|
|
if (-not $RequireAuthenticode) { return $true }
|
|
if ($expected.Extension -notin @('.exe', '.dll')) { return $false }
|
|
return (Get-AuthenticodeSignature -LiteralPath $expected.FullName).Status -eq 'Valid'
|
|
}
|
|
|
|
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"
|
|
Write-Log "Execution ID: $ExecutionId"
|
|
Write-Log "Execution mode: $EffectiveMode"
|
|
|
|
if ($ResumeFromCheckpoint) {
|
|
$checkpoint = Load-ExecutionCheckpoint
|
|
$ExecutionId = $checkpoint.ExecutionId
|
|
$ExecutionState.StartTime = $checkpoint.StartTime
|
|
$ExecutionState.CompletedPhases = @($checkpoint.CompletedPhases)
|
|
Write-Log "Loaded checkpoint from $($checkpoint.LastCheckpoint). Resume is idempotent; completed work will be revalidated." 'INFO'
|
|
}
|
|
|
|
if ($EnableTranscript) {
|
|
$transcriptPath = Join-Path $Paths.Logs ("{0}_Transcript_{1}.log" -f $Profile.Folder, $TimeStamp)
|
|
Start-Transcript -LiteralPath $transcriptPath -Append | Out-Null
|
|
}
|
|
|
|
if ($EffectiveMode -eq 'Audit' -or $EffectiveMode -eq 'HealthCheck') {
|
|
Invoke-Safe 'HealthCheck' 'Automotive workstation health check' { Test-WorkstationHealth | Out-Null } | Out-Null
|
|
if ($EffectiveMode -eq 'Audit') {
|
|
Invoke-Safe 'Inventory' 'Export workstation inventory' { Export-WorkstationInventory } | Out-Null
|
|
}
|
|
$Results | Export-Csv -LiteralPath $SummaryFile -NoTypeInformation -Encoding UTF8
|
|
if ($EnableTranscript) { Stop-Transcript | Out-Null }
|
|
return
|
|
}
|
|
|
|
if ($EffectiveMode -eq 'Inventory') {
|
|
Invoke-Safe 'Inventory' 'Export workstation inventory' { Export-WorkstationInventory } | Out-Null
|
|
$Results | Export-Csv -LiteralPath $SummaryFile -NoTypeInformation -Encoding UTF8
|
|
if ($EnableTranscript) { Stop-Transcript | Out-Null }
|
|
return
|
|
}
|
|
|
|
if ($EffectiveMode -eq 'Plan') {
|
|
$windowsPlan = @(Get-WindowsConfigurationPlan)
|
|
$planFile = Join-Path $Paths.Logs ("{0}_Plan_{1}.csv" -f $Profile.Folder, $TimeStamp)
|
|
$windowsPlan | Export-Csv -LiteralPath $planFile -NoTypeInformation -Encoding UTF8
|
|
$catalogPlan = if ($Configuration.AppCatalog) {
|
|
@($Configuration.AppCatalog.Packages | Where-Object {
|
|
(-not $_.PSObject.Properties['Profiles']) -or @($_.Profiles) -contains $Profile.Folder
|
|
} | Select-Object Name, Id, Channel)
|
|
} else {
|
|
@($Configuration.CorePackages | Select-Object Name, Id | ForEach-Object { $_ | Add-Member -NotePropertyName Channel -NotePropertyValue 'Core' -PassThru })
|
|
}
|
|
$catalogPlan | Export-Csv -LiteralPath (Join-Path $Paths.Logs ("{0}_ApplicationPlan_{1}.csv" -f $Profile.Folder, $TimeStamp)) -NoTypeInformation -Encoding UTF8
|
|
Write-Log "Plan generated for $($Profile.Folder): $($catalogPlan.Count) catalog applications and $($windowsPlan.Count) Windows settings." 'OK'
|
|
$Results | Export-Csv -LiteralPath $SummaryFile -NoTypeInformation -Encoding UTF8
|
|
if ($EnableTranscript) { Stop-Transcript | Out-Null }
|
|
return
|
|
}
|
|
|
|
if ($EffectiveMode -eq 'CreateWorkspace') {
|
|
if ([string]::IsNullOrWhiteSpace($JobId) -or [string]::IsNullOrWhiteSpace($Manufacturer) -or [string]::IsNullOrWhiteSpace($Model)) {
|
|
throw '-Mode CreateWorkspace requires -JobId, -Manufacturer, and -Model.'
|
|
}
|
|
$project = New-AutomotiveProject -ProjectJobId $JobId -ProjectManufacturer $Manufacturer `
|
|
-ProjectModel $Model -ProjectModelYear $ModelYear -ProjectVIN $VIN `
|
|
-ProjectNotes $Notes -ProjectTechnician $Technician
|
|
Add-Result 'Workspace' $project.JobId 'Success' $project.LocalPath
|
|
$project | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath (Join-Path $Paths.Logs "Workspace_$TimeStamp.json") -Encoding UTF8
|
|
Write-Log "Workspace created: $($project.LocalPath)" 'OK'
|
|
$Results | Export-Csv -LiteralPath $SummaryFile -NoTypeInformation -Encoding UTF8
|
|
if ($EnableTranscript) { Stop-Transcript | Out-Null }
|
|
return
|
|
}
|
|
|
|
if ($EffectiveMode -eq 'UpdatePortable') {
|
|
$SkipWinget = $true
|
|
$InstallOptionalApps = $false
|
|
}
|
|
|
|
if ($EffectiveMode -eq 'Repair') {
|
|
if (-not (Test-Path -LiteralPath $CheckpointFile -PathType Leaf)) {
|
|
throw "Repair requires an existing checkpoint: $CheckpointFile"
|
|
}
|
|
$ResumeFromCheckpoint = $true
|
|
$repairCheckpoint = Load-ExecutionCheckpoint
|
|
$ExecutionId = $repairCheckpoint.ExecutionId
|
|
$ExecutionState.StartTime = $repairCheckpoint.StartTime
|
|
$ExecutionState.CompletedPhases = @($repairCheckpoint.CompletedPhases)
|
|
Write-Log 'Repair mode will revalidate completed phases and retry failed or incomplete work.' 'INFO'
|
|
}
|
|
|
|
if (-not (Test-Administrator)) {
|
|
throw 'Run this script from Windows PowerShell or PowerShell as Administrator.'
|
|
}
|
|
|
|
if ($EffectiveMode -eq 'Backup') {
|
|
Invoke-Safe 'Recovery' 'Backup workstation configuration' {
|
|
$archive = Backup-WorkstationConfiguration
|
|
Write-Log "Configuration backup created: $archive" 'OK'
|
|
} | Out-Null
|
|
$Results | Export-Csv -LiteralPath $SummaryFile -NoTypeInformation -Encoding UTF8
|
|
if ($EnableTranscript) { Stop-Transcript | Out-Null }
|
|
return
|
|
}
|
|
|
|
if ($EffectiveMode -eq 'Restore') {
|
|
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
|
|
}
|
|
|
|
Save-ExecutionCheckpoint -Phase 'Initialize' -Status 'InProgress' -Details 'Prerequisites and administrator check passed.'
|
|
|
|
Invoke-Safe 'Configuration' "Apply Windows settings for $($Profile.Folder)" {
|
|
Set-WindowsProfileConfiguration
|
|
$windowsState = Get-WindowsConfigurationState
|
|
$windowsState | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath (Join-Path $Paths.Config "WindowsState_$TimeStamp.json") -Encoding UTF8
|
|
} | 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
|
|
}
|
|
|
|
if (-not (Test-SetupPhaseCompleted -Phase 'LocalStructure')) {
|
|
# 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
|
|
Complete-SetupPhase -Phase 'LocalStructure' -Details $LocalRoot
|
|
|
|
Invoke-Safe 'Configuration' 'Workstation profile metadata' {
|
|
@"
|
|
Profile: $WorkstationProfile
|
|
Focus: $($Profile.Focus)
|
|
Local workspace: $LocalRoot
|
|
Shared data workspace: $ProfileDataRoot
|
|
Shared configuration: $ProfileConfigRoot
|
|
Shared installers: $SharedInstallerRoot
|
|
|
|
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
|
|
}
|
|
|
|
if (-not (Test-SetupPhaseCompleted -Phase 'SharedPartitions')) {
|
|
# 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
|
|
}
|
|
|
|
Complete-SetupPhase -Phase 'SharedPartitions' -Details "DataRoot=$SharedDataRoot; PortableRoot=$SharedPortableRoot"
|
|
}
|
|
|
|
# 3. Portable applications are installed after WinGet so archive prerequisites are available.
|
|
|
|
# 4. Core applications. WinGet uses package manifests and each vendor's installer.
|
|
$catalogPackages = if ($Configuration.AppCatalog) { @($Configuration.AppCatalog.Packages) } else { @() }
|
|
$CorePackages = if ($catalogPackages.Count -gt 0) {
|
|
Get-ProfileCatalogItems -Items $catalogPackages -Channel 'Core'
|
|
} else {
|
|
@($Configuration.CorePackages)
|
|
}
|
|
$OptionalPackages = if ($catalogPackages.Count -gt 0) {
|
|
Get-ProfileCatalogItems -Items $catalogPackages -Channel 'Optional'
|
|
} else {
|
|
@($Configuration.OptionalApps.Packages)
|
|
}
|
|
|
|
if (-not (Test-SetupPhaseCompleted -Phase 'WinGetPackages')) {
|
|
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
|
|
}
|
|
}
|
|
|
|
if (Detect-PendingRestart) {
|
|
Request-SetupRestart -Reason 'WinGet reports that Windows needs to restart before setup can continue.'
|
|
if ($AllowAutomaticRestart -or $PromptForRestart) { return }
|
|
}
|
|
Complete-SetupPhase -Phase 'WinGetPackages' -Details "Skipped=$SkipWinget; Optional=$InstallOptionalApps"
|
|
}
|
|
|
|
# 5. Download, install/update, validate, and configure portable applications.
|
|
if (-not (Test-SetupPhaseCompleted -Phase 'PortableApplications')) {
|
|
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)
|
|
$expectedSHA256 = if ($configuredApp.PSObject.Properties['ExpectedSHA256']) { [string]$configuredApp.ExpectedSHA256 } else { '' }
|
|
$requireAuthenticode = [bool]($configuredApp.PSObject.Properties['RequireAuthenticode'] -and $configuredApp.RequireAuthenticode)
|
|
if (-not $ForcePortableUpdates -and (Test-PortableInstallation -Destination $destination -ExpectedExecutable $configuredApp.Executable -RequireAuthenticode:$requireAuthenticode)) {
|
|
$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; ExpectedSHA256=$expectedSHA256; RequireAuthenticode=$requireAuthenticode
|
|
})
|
|
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;
|
|
ExpectedSHA256=$expectedSHA256; RequireAuthenticode=$requireAuthenticode
|
|
})
|
|
} | 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;
|
|
ExpectedSHA256=$expectedSHA256; RequireAuthenticode=$requireAuthenticode
|
|
})
|
|
} | 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; ExpectedSHA256=$expectedSHA256; RequireAuthenticode=$requireAuthenticode
|
|
})
|
|
}
|
|
}
|
|
|
|
foreach ($app in $PortableDefinitions) {
|
|
$destination = Join-Path $PortableRoot $app.Folder
|
|
if (-not $ForcePortableUpdates -and (Test-PortableInstallation -Destination $destination -ExpectedExecutable $app.Executable -RequireAuthenticode:$app.RequireAuthenticode)) {
|
|
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 `
|
|
-ExpectedSHA256 $app.ExpectedSHA256 -RequireAuthenticode:$app.RequireAuthenticode
|
|
}
|
|
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 -ExpectedSHA256 $app.ExpectedSHA256 `
|
|
-RequireAuthenticode:$app.RequireAuthenticode
|
|
}
|
|
} | 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 = if ($Configuration.AppCatalog -and @($Configuration.AppCatalog.VSCodeExtensions).Count -gt 0) {
|
|
@($Configuration.AppCatalog.VSCodeExtensions | Where-Object {
|
|
(-not $_.PSObject.Properties['Profiles']) -or @($_.Profiles) -contains $Profile.Folder
|
|
})
|
|
} else {
|
|
@($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 $SharedInstallerRoot 'Xhorse\Multi-PROG'
|
|
$null = New-Item -ItemType Directory -Path $multiProgSoftwareRoot -Force
|
|
$installerPath = Join-Path $multiProgSoftwareRoot 'Multi-PROG.exe'
|
|
$multiProg = $Configuration.Automotive.Installers | Where-Object { $_.Name -eq 'Multi-PROG' } | Select-Object -First 1
|
|
if (-not $multiProg) { throw 'Multi-PROG installer is not defined in automotive-resources.json.' }
|
|
Invoke-ReliableDownload -Name 'Xhorse Multi-PROG installer' -Uri $multiProg.Uri -Destination $installerPath -AllowInsecureHttp
|
|
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 VVDI-MLB software installer' {
|
|
$vvdiMlbSoftwareRoot = Join-Path $SharedInstallerRoot 'Xhorse\VVDI-MLB'
|
|
$null = New-Item -ItemType Directory -Path $vvdiMlbSoftwareRoot -Force
|
|
$installerPath = Join-Path $vvdiMlbSoftwareRoot 'VVDI-MLB.exe'
|
|
$vvdiMlb = $Configuration.Automotive.Installers | Where-Object { $_.Name -eq 'VVDI-MLB' } | Select-Object -First 1
|
|
if (-not $vvdiMlb) { throw 'VVDI-MLB installer is not defined in automotive-resources.json.' }
|
|
Invoke-ReliableDownload -Name 'Xhorse VVDI-MLB installer' -Uri $vvdiMlb.Uri -Destination $installerPath -AllowInsecureHttp
|
|
if ((Get-Item -LiteralPath $installerPath).Length -lt 1MB) { throw 'VVDI-MLB installer download is unexpectedly small.' }
|
|
Unblock-File -LiteralPath $installerPath -ErrorAction SilentlyContinue
|
|
Write-Log 'VVDI-MLB installer downloaded. Launching interactive setup; connect the VVDI-MLB dongle only when the wizard prompts for it.' 'WARN'
|
|
Start-Process -FilePath $installerPath -WorkingDirectory $vvdiMlbSoftwareRoot
|
|
} | Out-Null
|
|
|
|
Invoke-Safe 'AutomotiveTooling' 'Xhorse MVCI PRO software installer' {
|
|
$mvciSoftwareRoot = Join-Path $SharedInstallerRoot '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.
|
|
$mvci = $Configuration.Automotive.CommunicationSoftware | Where-Object { $_.Name -eq 'MVCI PRO J2534' } | Select-Object -First 1
|
|
if (-not $mvci) { throw 'MVCI PRO J2534 is not defined in automotive-resources.json.' }
|
|
Invoke-ReliableDownload -Name 'Xhorse MVCI PRO installer' -Uri $mvci.Uri -Destination $installerPath -AllowInsecureHttp:$mvci.AllowInsecureHttp
|
|
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 -AllowInsecureHttp
|
|
}
|
|
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 $SharedInstallerRoot $mvci.Folder
|
|
$null = New-Item -ItemType Directory -Path $mvciRoot -Force
|
|
$mvciPath = Join-Path $mvciRoot $mvci.FileName
|
|
if ($mvci.RequiresVpn) { Write-Log "$($mvci.Name) requires the vendor/VPN network path before downloading." 'WARN' }
|
|
Invoke-ReliableDownload -Name $mvci.Name -Uri $mvci.Uri -Destination $mvciPath -AllowInsecureHttp:$mvci.AllowInsecureHttp
|
|
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 $SharedInstallerRoot $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
|
|
}
|
|
|
|
Complete-SetupPhase -Phase 'PortableApplications' -Details "Skipped=$SkipDownloads"
|
|
}
|
|
|
|
# 6. Convenience shortcuts.
|
|
|
|
if (-not (Test-SetupPhaseCompleted -Phase '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 $SharedInstallerRoot) { $folderShortcuts += @{ Name='Shared Installers'; Target=$SharedInstallerRoot } }
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
Complete-SetupPhase -Phase 'Shortcuts' -Details "Skipped=$SkipShortcuts"
|
|
}
|
|
|
|
# 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
|
|
|
|
Complete-SetupPhase -Phase 'EnvironmentAndSafety'
|
|
|
|
# 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
|
|
if (Detect-PendingRestart) {
|
|
Request-SetupRestart -Reason 'Setup completed but Windows reports a pending restart.'
|
|
Save-ExecutionCheckpoint -Phase 'Complete' -Status 'AwaitingRestart' -Details 'Windows reports a pending restart.'
|
|
} else {
|
|
Save-ExecutionCheckpoint -Phase 'Complete' -Status 'Completed' -Details 'Setup flow completed.'
|
|
}
|
|
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 }
|