236 lines
9.5 KiB
PowerShell
236 lines
9.5 KiB
PowerShell
# Build the Windows executable (dist\opcda-diagnozer.exe) with PyInstaller.
|
|
#
|
|
# Usage (from the repo root):
|
|
# powershell -NoProfile -ExecutionPolicy Bypass -File scripts\build_exe.ps1
|
|
# .\scripts\build_exe.ps1 # onedir (default): dist\opcda-diagnozer\
|
|
# .\scripts\build_exe.ps1 -Onefile # single exe: dist\opcda-diagnozer.exe
|
|
# .\scripts\build_exe.ps1 -Clean # remove build\ + dist\ first
|
|
#
|
|
# The exe is a console application hosting every CLI verb (check-config,
|
|
# list-servers, list-items, read/probe, run) AND the Windows service host:
|
|
# 'install' registers this same exe with the SCM (IMPLEMENTATION_PLAN 6);
|
|
# when the SCM starts it, the fixed 'service' argv shape dispatches to
|
|
# the ServiceFramework host.
|
|
#
|
|
# Hard rules enforced here:
|
|
# 1. 32-bit interpreter (SPECIFICATION 11): the OPC DA Automation Wrapper
|
|
# (OPCAutomation.dll) is a 32-bit in-proc COM DLL - a 64-bit exe cannot
|
|
# load it and every COM call fails at runtime. The produced PE is
|
|
# re-verified to be 32-bit (IMAGE_FILE_MACHINE_I386) after the build.
|
|
# 2. PyInstaller must see the modules the code imports lazily inside
|
|
# functions (COM, browse, service host) - declared via -HiddenImport.
|
|
[CmdletBinding()]
|
|
param(
|
|
[switch]$Onefile,
|
|
[switch]$Clean
|
|
)
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
$Repo = Split-Path -Parent $PSScriptRoot
|
|
$VenvPython = Join-Path $Repo '.venv\Scripts\python.exe'
|
|
$Entry = Join-Path $Repo 'src\opcda_diagnozer\__main__.py'
|
|
$Dist = Join-Path $Repo 'dist'
|
|
$Build = Join-Path $Repo 'build'
|
|
$Name = 'opcda-diagnozer'
|
|
$VersionFile = Join-Path $PSScriptRoot '.exe_version_info.txt'
|
|
|
|
# Modules imported lazily inside functions (import guards, IMPLEMENTATION_PLAN 9);
|
|
# PyInstaller's static analysis misses those, so they are declared explicitly.
|
|
$HiddenImports = @(
|
|
# pywin32 / COM - deferred into methods in opc/automation.py, opc/discovery.py
|
|
'pythoncom',
|
|
'pywintypes',
|
|
'win32com.client',
|
|
# pywintypes.datetime(VARIANT DATE -> datetime conversion) lazily imports
|
|
# win32timezone at CONVERSION time - missing from the bundle, every read of
|
|
# item.TimeStamp raised SystemError('InvokeTypes ... exception set') frozen
|
|
# (observed 2026-10-07: all tags failed in the exe while the venv worked).
|
|
'win32timezone',
|
|
# opc backend modules imported lazily in main.py CLI verbs
|
|
'opcda_diagnozer.opc.automation',
|
|
'opcda_diagnozer.opc.browse',
|
|
# the service host (Phase 3) - lazily imported by main (SCM dispatch) and
|
|
# itself importing win32serviceutil/win32service/servicemanager/
|
|
# win32evtlogutil only inside functions
|
|
'opcda_diagnozer.service',
|
|
'win32service',
|
|
'win32serviceutil',
|
|
'servicemanager',
|
|
'win32evtlogutil'
|
|
)
|
|
|
|
function Fail {
|
|
param([string]$Message)
|
|
Write-Host 'build_exe: FAILED' -ForegroundColor Red
|
|
Write-Host " $Message" -ForegroundColor Red
|
|
exit 1
|
|
}
|
|
|
|
function Invoke-Captured {
|
|
# Run a native command and capture stdout+stderr with its exit code.
|
|
# $ErrorActionPreference must be relaxed around 2>&1 on PS 5.1: with 'Stop',
|
|
# redirecting native stderr raises a NativeCommandError and kills the script.
|
|
param([string]$FilePath, [string[]]$Arguments)
|
|
$prev = $ErrorActionPreference
|
|
$ErrorActionPreference = 'Continue'
|
|
try {
|
|
$lines = & $FilePath @Arguments 2>&1
|
|
$text = ($lines | ForEach-Object { "$_" }) -join [Environment]::NewLine
|
|
[pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = $text }
|
|
}
|
|
finally {
|
|
$ErrorActionPreference = $prev
|
|
}
|
|
}
|
|
|
|
# --- environment checks ------------------------------------------------------
|
|
|
|
if (-not (Test-Path $VenvPython)) {
|
|
Fail ("the 32-bit venv was not found: $VenvPython - create it first " +
|
|
"(README 'Development setup'): " +
|
|
'& "C:\...\Python314-32\python.exe" -m venv .venv; ' +
|
|
'& .\.venv\Scripts\python.exe -m pip install -e ".[dev]"')
|
|
}
|
|
|
|
$bitness = (Invoke-Captured -FilePath $VenvPython -Arguments @('-c', "import struct; print(struct.calcsize('P') * 8)")).Output.Trim()
|
|
if ($bitness -ne '32') {
|
|
Fail ("the venv interpreter is ${bitness}-bit, but the OPC DA wrappers are " +
|
|
'32-bit in-proc DLLs (SPECIFICATION 11): a 64-bit exe cannot load ' +
|
|
"them. Rebuild the venv with the 32-bit Python (README 'Development setup').")
|
|
}
|
|
Write-Host 'build_exe: OK - 32-bit interpreter (SPECIFICATION 11 bitness rule)'
|
|
|
|
$pyi = Invoke-Captured -FilePath $VenvPython -Arguments @('-m', 'PyInstaller', '--version')
|
|
if ($pyi.ExitCode -ne 0) {
|
|
Write-Host 'build_exe: pyinstaller missing - installing into the venv ...'
|
|
& $VenvPython -m pip install pyinstaller
|
|
if ($LASTEXITCODE -ne 0) { Fail 'pip install pyinstaller failed' }
|
|
}
|
|
|
|
if ($Clean) {
|
|
foreach ($folder in @($Build, $Dist)) {
|
|
if (Test-Path $folder) { Remove-Item -Recurse -Force $folder }
|
|
}
|
|
Write-Host 'build_exe: cleaned build/ and dist/'
|
|
}
|
|
|
|
# --- version metadata (exe properties -> Details tab) -------------------------
|
|
|
|
$initText = Get-Content (Join-Path $Repo 'src\opcda_diagnozer\__init__.py') -Raw
|
|
if ($initText -notmatch '__version__\s*=\s*"([^"]+)"') {
|
|
Fail 'could not read __version__ from src\opcda_diagnozer\__init__.py'
|
|
}
|
|
$Version = $Matches[1]
|
|
$parts = $Version.Split('.')
|
|
$major = $parts[0]
|
|
$minor = $parts[1]
|
|
|
|
# Keyword names must match PyInstaller's FixedFileInfo.__init__ exactly:
|
|
# filevers, prodvers, mask, flags, OS, fileType, subtype, date.
|
|
# [IO.File]::WriteAllText writes UTF-8 WITHOUT a BOM - Set-Content -Encoding
|
|
# UTF8 on PS 5.1 prepends one, and the BOM breaks PyInstaller's eval().
|
|
$VersionInfo = @"
|
|
# UTF-8
|
|
VSVersionInfo(
|
|
ffi=FixedFileInfo(
|
|
filevers=($major, $minor, 0, 0),
|
|
prodvers=($major, $minor, 0, 0),
|
|
mask=0x3F,
|
|
flags=0x0,
|
|
OS=0x40004,
|
|
fileType=0x1,
|
|
subtype=0x0,
|
|
date=(0, 0)
|
|
),
|
|
kids=[
|
|
StringFileInfo(
|
|
[
|
|
StringTable(
|
|
'040904B0',
|
|
[
|
|
StringStruct('CompanyName', 'Vivab'),
|
|
StringStruct('FileDescription', 'OPC DA Diagnozer - read-only OPC DA reference diagnostic client'),
|
|
StringStruct('FileVersion', '$Version.0'),
|
|
StringStruct('InternalName', 'opcda-diagnozer'),
|
|
StringStruct('OriginalFilename', 'opcda-diagnozer.exe'),
|
|
StringStruct('ProductName', 'OPC DA Diagnozer'),
|
|
StringStruct('ProductVersion', '$Version')
|
|
]
|
|
)
|
|
]
|
|
),
|
|
VarFileInfo([VarStruct('Translation', [1033, 1200])])
|
|
]
|
|
)
|
|
"@
|
|
[IO.File]::WriteAllText($VersionFile, $VersionInfo)
|
|
|
|
# --- the build itself ---------------------------------------------------------
|
|
|
|
$pyArgs = @(
|
|
'-m', 'PyInstaller',
|
|
'--noconfirm', '--clean',
|
|
'--name', $Name,
|
|
'--distpath', $Dist,
|
|
'--workpath', $Build,
|
|
'--specpath', $Build,
|
|
'--console',
|
|
'--version-file', $VersionFile
|
|
)
|
|
foreach ($hidden in $HiddenImports) { $pyArgs += @('--hidden-import', $hidden) }
|
|
$pyArgs += $(if ($Onefile) { '--onefile' } else { '--onedir' })
|
|
$pyArgs += $Entry
|
|
|
|
Write-Host 'build_exe: running PyInstaller...'
|
|
& $VenvPython @pyArgs
|
|
if ($LASTEXITCODE -ne 0) { Fail "PyInstaller exited $LASTEXITCODE" }
|
|
|
|
$Exe = if ($Onefile) { Join-Path $Dist "$Name.exe" } else { Join-Path $Dist "$Name\$Name.exe" }
|
|
if (-not (Test-Path $Exe)) { Fail "expected exe not found: $Exe" }
|
|
Write-Host "build_exe: OK - $Exe"
|
|
|
|
# --- final bitness guard: the produced PE must be 32-bit (SPECIFICATION 11) --
|
|
|
|
$bytes = [System.IO.File]::ReadAllBytes($Exe)
|
|
$peOffset = [System.BitConverter]::ToInt32($bytes, 0x3C)
|
|
$machine = [System.BitConverter]::ToUInt16($bytes, $peOffset + 4)
|
|
if ($machine -ne 0x14C) {
|
|
Fail ("the built exe is not 32-bit (PE machine 0x{0:X4}) - build from the 32-bit venv" -f $machine)
|
|
}
|
|
Write-Host 'build_exe: OK - exe is 32-bit (PE machine 0x014C, IMAGE_FILE_MACHINE_I386)'
|
|
|
|
# --- smoke test ---------------------------------------------------------------
|
|
|
|
Write-Host "build_exe: smoke-testing $Name.exe ..."
|
|
$ExampleConfig = Join-Path $Repo 'config.example.yaml'
|
|
$checks = @(
|
|
@{ Label = '--version'; Arguments = @('--version') },
|
|
@{ Label = 'check-config'; Arguments = @('check-config', '--config', $ExampleConfig) },
|
|
@{ Label = 'list-servers (registry)'; Arguments = @('list-servers', '--source', 'registry') }
|
|
)
|
|
foreach ($check in $checks) {
|
|
$result = Invoke-Captured -FilePath $Exe -Arguments $check.Arguments
|
|
$first = ($result.Output -split '\r?\n' | Where-Object { $_.Trim() } | Select-Object -First 1)
|
|
Write-Host (" {0} -> exit {1}: {2}" -f $check.Label, $result.ExitCode, $first)
|
|
if ($result.ExitCode -ne 0) { Fail ("smoke test failed: " + $check.Label) }
|
|
}
|
|
|
|
# Frozen-read canary: the COM timestamp conversion (item.TimeStamp) lazily
|
|
# imports win32timezone - if it is missing from the bundle, EVERY read fails
|
|
# with SystemError('InvokeTypes ... exception set'). The registry/discovery
|
|
# smoke tests cannot catch this; only a real read does. Skipped gracefully
|
|
# when no COM server is reachable on this build host.
|
|
$canary = Invoke-Captured -FilePath $Exe -Arguments @(
|
|
'read', '--server', 'Matrikon.OPC.Simulation.1', 'Bucket Brigade.Int1'
|
|
)
|
|
$canaryFirst = ($canary.Output -split '\r?\n' | Where-Object { $_.Trim() } | Select-Object -First 2) -join ' | '
|
|
if ($canary.ExitCode -eq 0) {
|
|
Write-Host " read canary (Matrikon sim) -> exit 0: $canaryFirst"
|
|
} elseif ($canary.Output -match 'SystemError.*InvokeTypes') {
|
|
Fail ("frozen read canary failed with the missing-win32timezone signature: $canaryFirst")
|
|
} else {
|
|
Write-Host " read canary skipped (no Matrikon sim reachable): exit $($canary.ExitCode)"
|
|
}
|
|
Write-Host 'build_exe: smoke test OK'
|
|
exit 0 |