Mapped drives stopped reporting because the user-context path failed silently in three separate places, and none of them left a trace to read back. - Probe: launch via -EncodedCommand instead of staging a temp .ps1 and running powershell.exe -File. A missing or quarantined script file fails BEFORE any statement runs, so the script's own try/catch never sees it and the only symptom is exit -196608. - Spool: pin to the ShopFloor profile. Resolving it through WTSQueryUserToken is privileged, endpoint protection blocks it, and on failure the tray (writer) and service (reader) silently disagree about the path. - Spool: consume after a successful parse, so a stopped tray cannot keep re-reporting stale mapped drives as current. Unparseable files are left in place for the next cycle. - Logging: user-session components cannot write to Program Files or to the SYSTEM-owned ProgramData log directory, and the file sink degrades to a silent no-op when it cannot create its directory. Tray and probe now log to C:\Users\ShopFloor\ShopDB\Logs, installs transcript to ProgramData, and the sink records a SinkProblem instead of swallowing UnauthorizedAccessException. - Installer: provision the operator spool/log directories with ShopFloor:Modify before the tray first runs, and stop the transcript via trap so a throw cannot orphan exactly the failed install worth reading. - Upload cap 64 -> 128 MB: the self-contained tray publishes at ~69 MB and was rejected on SIZE, but the size check returns before the PE is parsed, so the operator only ever saw a version-detection failure. AGENT-CONTRACT.md gains a normative section defining the three security contexts, their log destinations, the fixed spool path and consume-on-read. Agent 1.8.0 -> 1.8.1, tray 1.1.0 -> 1.1.1 so the deployed build is identifiable from a log line.
438 lines
21 KiB
PowerShell
438 lines
21 KiB
PowerShell
# install-shopdbagent.ps1
|
|
# ShopDB Endpoint Agent installer for the test endpoint. Run from an ELEVATED
|
|
# (admin) PowerShell on the endpoint. Provisions the registry secrets, copies
|
|
# the agent files to Program Files, runs a one-shot smoke test, then installs
|
|
# and starts the Windows service.
|
|
#
|
|
# Secrets are provisioned to HKLM\SOFTWARE\GE\ShopDB. On locked-down hosts
|
|
# (CyberArk/EPM) the agent may be blocked from READING that hive, so the same
|
|
# values are also stamped into the installed appsettings.json as a fallback.
|
|
#
|
|
# The agent and tray are published as single-file self-contained executables,
|
|
# so copying the two .exe files plus appsettings.json is the complete payload.
|
|
|
|
[CmdletBinding()]
|
|
param(
|
|
# ShopDB server root URL for this instance. REQUIRED - there is no default,
|
|
# because a default here means one site's hostname shipped to every site
|
|
# (ADR-015) and, worse, a working install pointed at somebody else's server.
|
|
#
|
|
# https is required. The collector key travels in the X-API-Key header and
|
|
# every telemetry payload follows it, so plain http puts both on the wire in
|
|
# clear. Pass -AllowInsecureHttp only for a local test instance.
|
|
[Parameter(Mandatory = $true)]
|
|
[string] $BaseUrl,
|
|
|
|
# Endpoints-only collector key: this instance's .env COLLECTOR_API_KEY_ENDPOINTS.
|
|
# REQUIRED and never defaulted - a key in source is a key in git history, and
|
|
# this one is also the command channel's kill switch.
|
|
[Parameter(Mandatory = $true)]
|
|
[string] $CollectorKey,
|
|
|
|
# Permit an http:// BaseUrl. For a local test instance only.
|
|
[switch] $AllowInsecureHttp,
|
|
|
|
|
|
[string] $InstallDir = "C:\Program Files\ShopDB\EndpointAgent",
|
|
[string] $ServiceName = "ShopDbEndpointAgent",
|
|
|
|
# Interactive tray app that shows the system-tray icon and the on-demand
|
|
# "Send Status now" action. Separate process from the SYSTEM service (a
|
|
# service cannot draw a tray icon in a user session). Pass -NoTray to skip.
|
|
[string] $TrayExeName = "shopdb-endpoint-tray.exe",
|
|
[switch] $NoTray,
|
|
|
|
# Add a Microsoft Defender path exclusion for the install directory. The
|
|
# agent and tray are unsigned single-file self-contained executables, which
|
|
# Defender heuristics (and the tray's GDI/user32 icon P/Invoke) can flag as
|
|
# a false positive and quarantine, killing the tray before its icon appears.
|
|
# Pass -NoDefenderExclusion to skip (e.g. where policy manages exclusions
|
|
# centrally or the binaries are code-signed).
|
|
[switch] $NoDefenderExclusion
|
|
)
|
|
|
|
# Refuse cleartext unless it was asked for explicitly. The key is a bearer
|
|
# credential for the whole fleet's telemetry and command channel.
|
|
if ($BaseUrl -notmatch '^https://' -and -not $AllowInsecureHttp) {
|
|
throw "BaseUrl must be https:// (got '$BaseUrl'). Pass -AllowInsecureHttp for a local test instance."
|
|
}
|
|
|
|
$ErrorActionPreference = "Stop"
|
|
$sourceDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
|
|
function Assert-Admin {
|
|
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
|
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
|
|
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
|
throw "This script must be run from an elevated (Run as administrator) PowerShell."
|
|
}
|
|
}
|
|
|
|
Assert-Admin
|
|
|
|
if ([string]::IsNullOrWhiteSpace($BaseUrl)) {
|
|
throw "BaseUrl is not set. Pass -BaseUrl 'http://<host>:<port>' when running the script."
|
|
}
|
|
|
|
# Write HKLM values through the explicit 64-bit registry view. If this script
|
|
# runs under 32-bit PowerShell (some management tooling launches x86), the
|
|
# PSDrive HKLM: silently redirects writes into WOW6432Node. The 64-bit agent
|
|
# service and 64-bit Explorer (the logon Run key) then read the 64-bit view and
|
|
# see nothing, so config "goes missing" and the tray never autostarts. Pinning
|
|
# the view removes that dependency on the launching shell's bitness.
|
|
function Set-Hklm64Value {
|
|
param(
|
|
[Parameter(Mandatory)] [string] $SubKey,
|
|
[Parameter(Mandatory)] [string] $Name,
|
|
[Parameter(Mandatory)] [string] $Value
|
|
)
|
|
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey(
|
|
[Microsoft.Win32.RegistryHive]::LocalMachine,
|
|
[Microsoft.Win32.RegistryView]::Registry64)
|
|
try {
|
|
$key = $base.CreateSubKey($SubKey)
|
|
try {
|
|
$key.SetValue($Name, $Value, [Microsoft.Win32.RegistryValueKind]::String)
|
|
}
|
|
finally { $key.Dispose() }
|
|
}
|
|
finally { $base.Dispose() }
|
|
}
|
|
|
|
# Tear down any previously installed agent BEFORE touching files. The agent and
|
|
# tray are single-file self-contained exes; while the old service or tray is
|
|
# running it holds those .exe files open, so Copy-Item over them fails with a
|
|
# file-in-use error. Stopping the service, deleting it, and killing any lingering
|
|
# agent/tray processes up front makes the install a clean overwrite. Best-effort
|
|
# throughout: a missing service or process is the normal fresh-install case.
|
|
function Uninstall-PreviousAgent {
|
|
param(
|
|
[Parameter(Mandatory)] [string] $Service,
|
|
[Parameter(Mandatory)] [string] $TrayExe,
|
|
[Parameter(Mandatory)] [string] $TargetDir
|
|
)
|
|
|
|
$existing = Get-Service -Name $Service -ErrorAction SilentlyContinue
|
|
if ($existing) {
|
|
Write-Host " stopping and removing existing service $Service ..."
|
|
if ($existing.Status -ne "Stopped") {
|
|
try { Stop-Service -Name $Service -Force -ErrorAction Stop }
|
|
catch { Write-Warning " could not stop service: $($_.Exception.Message)" }
|
|
}
|
|
# sc.exe delete only marks the service for deletion; the SCM finishes the
|
|
# removal once every handle closes. Give it a moment so the subsequent
|
|
# sc.exe create does not hit a "marked for deletion" (1072) error.
|
|
sc.exe delete $Service | Out-Null
|
|
Start-Sleep -Seconds 2
|
|
}
|
|
|
|
# Kill any lingering worker or tray processes that would keep the exes
|
|
# locked. Match by the installed exe base names; the service host process is
|
|
# shopdbagent, the tray is the TrayExe base name.
|
|
$trayBase = [System.IO.Path]::GetFileNameWithoutExtension($TrayExe)
|
|
foreach ($name in @("shopdbagent", $trayBase)) {
|
|
$procs = Get-Process -Name $name -ErrorAction SilentlyContinue
|
|
foreach ($p in $procs) {
|
|
Write-Host " killing lingering process $($p.ProcessName) (pid $($p.Id)) ..."
|
|
try { Stop-Process -Id $p.Id -Force -ErrorAction Stop }
|
|
catch { Write-Warning " could not kill $($p.ProcessName): $($_.Exception.Message)" }
|
|
}
|
|
}
|
|
# Also kill anything still running out of the target install directory,
|
|
# regardless of exe name. A tray from an older build (different exe name) or
|
|
# one launched from a stale path would otherwise survive the name-based sweep
|
|
# above, keep its exe locked, and leave a duplicate icon once step 5 starts
|
|
# the fresh tray. Match on the executable path so we only touch our own dir.
|
|
$targetPrefix = $TargetDir.TrimEnd('\') + '\'
|
|
foreach ($p in (Get-Process -ErrorAction SilentlyContinue)) {
|
|
$path = $null
|
|
try { $path = $p.Path } catch { $path = $null }
|
|
if ($path -and $path.StartsWith($targetPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
|
|
Write-Host " killing process from install dir $($p.ProcessName) (pid $($p.Id)) ..."
|
|
try { Stop-Process -Id $p.Id -Force -ErrorAction Stop }
|
|
catch { Write-Warning " could not kill $($p.ProcessName): $($_.Exception.Message)" }
|
|
}
|
|
}
|
|
# Brief settle so file handles are released before the copy overwrites them.
|
|
Start-Sleep -Milliseconds 500
|
|
}
|
|
|
|
# Installer transcript. Deliberately SEPARATE from both the service log
|
|
# (%ProgramData%\ShopDB\EndpointAgent\logs\agent-<date>.log) and the tray log
|
|
# (C:\Users\ShopFloor\ShopDB\Logs\<datetime>-trayagent.log): the installer runs
|
|
# elevated and one-shot, and mixing its output into the service's rolling log
|
|
# made an install indistinguishable from runtime behaviour when reading back a
|
|
# failure. This runs as admin, so ProgramData is writable here.
|
|
$installLogDir = Join-Path $env:ProgramData 'ShopDB\Logs'
|
|
try {
|
|
New-Item -ItemType Directory -Path $installLogDir -Force | Out-Null
|
|
$installLog = Join-Path $installLogDir (
|
|
'{0}-shopdbinstalls.log' -f (Get-Date -Format 'yyyyMMdd-HHmmss'))
|
|
Start-Transcript -Path $installLog -Append | Out-Null
|
|
$transcriptStarted = $true
|
|
}
|
|
catch {
|
|
# Never let logging block an install.
|
|
$transcriptStarted = $false
|
|
Write-Warning "could not start the install transcript: $($_.Exception.Message)"
|
|
}
|
|
|
|
# The shop-floor operator profile. The tray and the user-context probe run as
|
|
# this local account on every shop-floor endpoint, and it is the one place both
|
|
# the operator (writer) and the SYSTEM service (reader) can reach. Defined here
|
|
# so the install can provision the directories and report their location.
|
|
$shopFloorProfile = Join-Path (Join-Path $env:SystemDrive 'Users') 'ShopFloor'
|
|
|
|
# Stop the transcript on ANY terminating error, not just the success path. The
|
|
# script throws on several validation and smoke-test failures, and those are
|
|
# precisely the installs worth reading back afterwards - an orphaned transcript
|
|
# would truncate exactly the failure it was started to capture.
|
|
trap {
|
|
if ($transcriptStarted) {
|
|
try { Stop-Transcript | Out-Null } catch { }
|
|
$script:transcriptStarted = $false
|
|
}
|
|
break
|
|
}
|
|
|
|
Write-Host "== ShopDB Endpoint Agent install ==" -ForegroundColor Cyan
|
|
Write-Host "BaseUrl : $BaseUrl"
|
|
Write-Host "InstallDir : $InstallDir"
|
|
Write-Host "ServiceName : $ServiceName"
|
|
if ($transcriptStarted) { Write-Host "InstallLog : $installLog" }
|
|
|
|
# 0. Clear the Mark-of-the-Web on the SOURCE payload first. The operator
|
|
# typically extracts installer.zip into Downloads, so every file carries a
|
|
# Zone.Identifier ADS. SmartScreen reads that and blocks the unsigned exes with
|
|
# "Windows protected your PC" the first time they run - including the [3/5]
|
|
# smoke test below, which launches shopdbagent.exe out of $sourceDir before the
|
|
# installed copy exists. Unblocking the source folder up front keeps the smoke
|
|
# test (and any manual run from the extract folder) clean; the installed copies
|
|
# get unblocked again in step 2a after they land in Program Files. Best-effort:
|
|
# a file with no MotW is a no-op.
|
|
Write-Host "`n[0a] Clearing Mark-of-the-Web on the extracted installer files ..."
|
|
Get-ChildItem -Path $sourceDir -File | ForEach-Object {
|
|
try { Unblock-File -Path $_.FullName -ErrorAction Stop }
|
|
catch { Write-Warning " could not unblock $($_.Name): $($_.Exception.Message)" }
|
|
}
|
|
|
|
# 0. Uninstall any previous agent first, so the file copy is a clean overwrite
|
|
# and does not fail on exes locked by a running service or tray.
|
|
Write-Host "`n[0/5] Removing any previously installed agent ..."
|
|
Uninstall-PreviousAgent -Service $ServiceName -TrayExe $TrayExeName -TargetDir $InstallDir
|
|
|
|
# 1. Provision registry secrets (64-bit view, see Set-Hklm64Value).
|
|
$regPath = "HKLM:\SOFTWARE\GE\ShopDB"
|
|
Write-Host "`n[1/5] Provisioning registry $regPath (Registry64) ..."
|
|
Set-Hklm64Value -SubKey "SOFTWARE\GE\ShopDB" -Name "BaseUrl" -Value $BaseUrl
|
|
Set-Hklm64Value -SubKey "SOFTWARE\GE\ShopDB" -Name "CollectorKey" -Value $CollectorKey
|
|
|
|
# 1a. Provision the user-context spool and tray log directories in the operator
|
|
# profile, and grant the operator Modify on them.
|
|
#
|
|
# This is the handoff point between the two security contexts (AGENT-CONTRACT.md
|
|
# section 5): the tray WRITES here as the ShopFloor operator, and the SYSTEM
|
|
# service READS and then removes the spool. The operator cannot write to
|
|
# Program Files or to the SYSTEM-owned ProgramData log directory, and the file
|
|
# log sink degrades to a silent no-op when it cannot create its directory - so
|
|
# without this step a tray-side failure leaves no trace anywhere on the machine.
|
|
# Creating them here, elevated, means the directories exist and are writable
|
|
# before the tray ever starts.
|
|
Write-Host "[1a/5] Provisioning operator spool/log directories under $shopFloorProfile ..."
|
|
if (Test-Path $shopFloorProfile) {
|
|
foreach ($dir in @(
|
|
(Join-Path $shopFloorProfile 'ShopDB'),
|
|
(Join-Path $shopFloorProfile 'ShopDB\Logs'))) {
|
|
try {
|
|
New-Item -ItemType Directory -Force -Path $dir | Out-Null
|
|
$acl = Get-Acl $dir
|
|
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule(
|
|
'ShopFloor', 'Modify',
|
|
'ContainerInherit,ObjectInherit', 'None', 'Allow')
|
|
$acl.SetAccessRule($rule)
|
|
Set-Acl -Path $dir -AclObject $acl
|
|
Write-Host " provisioned $dir (ShopFloor: Modify)"
|
|
}
|
|
catch {
|
|
# Non-fatal: telemetry, commands and inventory are unaffected. Only
|
|
# user-context (mapped drives, idle time) depends on this path.
|
|
Write-Warning " could not provision $dir : $($_.Exception.Message)"
|
|
}
|
|
}
|
|
}
|
|
else {
|
|
Write-Warning " no ShopFloor profile at $shopFloorProfile; user-context (mapped drives, idle time) will be unavailable until that user has logged in once."
|
|
}
|
|
|
|
# 2. Copy agent files to the install directory. The agent and tray are
|
|
# single-file self-contained executables, so the .exe plus appsettings.json is
|
|
# the whole payload; there are no side-by-side runtime DLLs to copy.
|
|
Write-Host "[2/5] Copying agent files to $InstallDir ..."
|
|
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
|
|
$agentSource = Join-Path $sourceDir "shopdbagent.exe"
|
|
if (-not (Test-Path $agentSource)) {
|
|
throw "shopdbagent.exe not found in $sourceDir. Unzip the full installer package and run from that folder."
|
|
}
|
|
Copy-Item -Path $agentSource -Destination $InstallDir -Force
|
|
$appsettings = Join-Path $sourceDir "appsettings.json"
|
|
$installedSettings = Join-Path $InstallDir "appsettings.json"
|
|
if (Test-Path $appsettings) {
|
|
Copy-Item -Path $appsettings -Destination $InstallDir -Force
|
|
}
|
|
|
|
# Copy the interactive tray app next to the agent so its same-dir resolution
|
|
# finds shopdbagent.exe for the "Send Status now" action. Optional: absent on a
|
|
# service-only (headless) build.
|
|
$trayInstalled = $null
|
|
if (-not $NoTray) {
|
|
$traySource = Join-Path $sourceDir $TrayExeName
|
|
if (Test-Path $traySource) {
|
|
Copy-Item -Path $traySource -Destination $InstallDir -Force
|
|
$trayInstalled = Join-Path $InstallDir $TrayExeName
|
|
Write-Host " copied tray app $TrayExeName"
|
|
}
|
|
else {
|
|
Write-Warning " tray app $TrayExeName not found in $sourceDir; skipping tray (service still installs)"
|
|
}
|
|
}
|
|
|
|
# Fallback for locked-down endpoints: some hosts (CyberArk/EPM) gate the HKLM
|
|
# hive so the agent cannot READ the values the registry step just wrote. Stamp
|
|
# BaseUrl/CollectorKey into the installed appsettings.json too. The agent reads
|
|
# appsettings first, then lets the registry override it, so this is a safe
|
|
# no-op where the registry read works and the rescue where it does not.
|
|
if (Test-Path $installedSettings) {
|
|
try {
|
|
$json = Get-Content $installedSettings -Raw | ConvertFrom-Json
|
|
$json.baseurl = $BaseUrl
|
|
$json.apikey = $CollectorKey
|
|
$json | ConvertTo-Json -Depth 10 | Set-Content -Path $installedSettings -Encoding UTF8
|
|
Write-Host " stamped BaseUrl/CollectorKey into appsettings.json (registry-read fallback)"
|
|
}
|
|
catch {
|
|
Write-Warning " could not stamp appsettings.json: $($_.Exception.Message)"
|
|
}
|
|
}
|
|
$exePath = Join-Path $InstallDir "shopdbagent.exe"
|
|
|
|
# 2a. Strip the Mark-of-the-Web from the copied files. Windows tags files
|
|
# extracted from a downloaded zip with a Zone.Identifier alternate data stream;
|
|
# SmartScreen reads that and blocks the unsigned exe with "Windows protected
|
|
# your PC" the first time it runs, and the operator sees only a "Don't run"
|
|
# button. Unblock-File removes the ADS so the smoke test and the tray launch
|
|
# start clean. Best-effort: a file with no MotW is a no-op.
|
|
Write-Host "[2a/5] Clearing Mark-of-the-Web on installed files ..."
|
|
Get-ChildItem -Path $InstallDir -File | ForEach-Object {
|
|
try { Unblock-File -Path $_.FullName -ErrorAction Stop }
|
|
catch { Write-Warning " could not unblock $($_.Name): $($_.Exception.Message)" }
|
|
}
|
|
|
|
# 2b. Defender path exclusion for the install dir. Unsigned single-file
|
|
# self-contained binaries (and the tray's icon P/Invoke) can trip Defender
|
|
# heuristics and get quarantined, which is why the tray dies before showing an
|
|
# icon. Excluding the fixed install directory is the least-privilege fix short
|
|
# of code-signing. Best-effort: Set-MpPreference is absent on servers without
|
|
# Defender and can be blocked by tamper protection; a failure is not fatal.
|
|
if (-not $NoDefenderExclusion) {
|
|
Write-Host "[2b/5] Adding Defender path exclusion for $InstallDir ..."
|
|
if (Get-Command Add-MpPreference -ErrorAction SilentlyContinue) {
|
|
try {
|
|
Add-MpPreference -ExclusionPath $InstallDir -ErrorAction Stop
|
|
Write-Host " excluded $InstallDir from Defender scanning."
|
|
}
|
|
catch {
|
|
Write-Warning " could not add Defender exclusion (tamper protection or policy?): $($_.Exception.Message)"
|
|
Write-Warning " if the tray is quarantined, code-sign the binaries or add the exclusion via policy."
|
|
}
|
|
}
|
|
else {
|
|
Write-Host " Defender cmdlets not present; skipping exclusion."
|
|
}
|
|
}
|
|
|
|
# 3. One-shot smoke test (exact production collection path, then exits).
|
|
Write-Host "[3/5] Running one-shot smoke test (--once) ..."
|
|
$proc = Start-Process -FilePath $exePath -ArgumentList "--once" -WorkingDirectory $InstallDir -Wait -PassThru -NoNewWindow
|
|
Write-Host " smoke test exit code: $($proc.ExitCode)"
|
|
|
|
$logDir = Join-Path $env:ProgramData "ShopDB\EndpointAgent\logs"
|
|
$logFile = Join-Path $logDir ("agent-{0}.log" -f (Get-Date -Format "yyyyMMdd"))
|
|
if (Test-Path $logFile) {
|
|
Write-Host " --- last log lines ---" -ForegroundColor DarkGray
|
|
Get-Content $logFile -Tail 20 | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray }
|
|
}
|
|
if ($proc.ExitCode -ne 0) {
|
|
throw "Smoke test failed (exit $($proc.ExitCode)). Check $logFile before installing the service."
|
|
}
|
|
|
|
# 4. Install the Windows service. Any previous instance was already stopped,
|
|
# deleted, and its processes killed in step 0, so this is a clean create. Guard
|
|
# against the rare case where the SCM has not finished a pending deletion yet.
|
|
Write-Host "[4/5] Installing service $ServiceName ..."
|
|
$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
|
if ($existing) {
|
|
Write-Host " service still present (pending deletion); removing again ..."
|
|
if ($existing.Status -ne "Stopped") { Stop-Service -Name $ServiceName -Force }
|
|
sc.exe delete $ServiceName | Out-Null
|
|
Start-Sleep -Seconds 2
|
|
}
|
|
$binPath = '"' + $exePath + '"'
|
|
sc.exe create $ServiceName binPath= $binPath start= auto DisplayName= "ShopDB Endpoint Agent" | Out-Null
|
|
sc.exe description $ServiceName "Collects endpoint telemetry for ShopDB and runs allowlisted WebUI commands." | Out-Null
|
|
sc.exe failure $ServiceName reset= 86400 actions= restart/5000/restart/5000/restart/5000 | Out-Null
|
|
|
|
Start-Service -Name $ServiceName
|
|
Write-Host " service started."
|
|
|
|
# 5. Register the interactive tray app to launch at user logon (per-machine).
|
|
Write-Host "[5/5] Registering tray app for logon ..."
|
|
if ($trayInstalled -and (Test-Path $trayInstalled)) {
|
|
# Register in the 64-bit Run key so 64-bit Explorer fires it at logon. A
|
|
# 32-bit write would land in WOW6432Node\...\Run, which Explorer ignores.
|
|
Set-Hklm64Value -SubKey "SOFTWARE\Microsoft\Windows\CurrentVersion\Run" `
|
|
-Name "ShopDbEndpointTray" -Value ('"' + $trayInstalled + '"')
|
|
Write-Host " tray app registered to start at logon (Registry64): $trayInstalled"
|
|
|
|
# Start it now so the icon appears without waiting for the next logon. The
|
|
# Run key only fires at logon, so a fresh install would otherwise show no
|
|
# tray until the operator signs out and back in.
|
|
#
|
|
# The installer runs elevated, so a direct Start-Process would launch the
|
|
# tray ELEVATED too. That is wrong for a tray app (it should run at the
|
|
# user's medium integrity) and, on EPM-managed hosts (CyberArk), elevating an
|
|
# unsigned exe triggers a broker prompt that comes back as Win32 1223
|
|
# "The operation was canceled by the user" - which is exactly what we hit.
|
|
# Launching through explorer.exe hands the exe to the already-running user
|
|
# shell, which spawns it de-elevated in the interactive session, sidestepping
|
|
# the elevation broker entirely.
|
|
$interactiveUser = $env:USERNAME
|
|
if ($interactiveUser -and $interactiveUser -ne "SYSTEM") {
|
|
try {
|
|
# explorer.exe <path> re-parents the launch under the user shell at
|
|
# medium integrity; it returns immediately regardless of the tray's
|
|
# own lifetime, so there is nothing to wait on here.
|
|
Start-Process -FilePath "explorer.exe" -ArgumentList "`"$trayInstalled`"" | Out-Null
|
|
Write-Host " tray app started for the current session (de-elevated via explorer)."
|
|
}
|
|
catch {
|
|
Write-Warning " could not start tray now: $($_.Exception.Message); it will start at next logon."
|
|
}
|
|
}
|
|
else {
|
|
Write-Host " no interactive user session; tray will start at next logon."
|
|
}
|
|
}
|
|
else {
|
|
Write-Host " tray skipped (headless install)."
|
|
}
|
|
|
|
Write-Host "`nInstall complete." -ForegroundColor Green
|
|
Write-Host "Service '$ServiceName' is running and set to auto-start."
|
|
# The three log destinations are separate on purpose; see AGENT-CONTRACT.md.
|
|
Write-Host "Service log : $logDir"
|
|
Write-Host "Tray log : $(Join-Path $shopFloorProfile 'ShopDB\Logs')"
|
|
if ($transcriptStarted) {
|
|
Write-Host "Install log : $installLog"
|
|
try { Stop-Transcript | Out-Null } catch { }
|
|
}
|