Files
Assetmanager/app/software_packages.py
T

903 lines
36 KiB
Python
Executable File

from __future__ import annotations
import json
import os
import re
import shutil
import uuid
from pathlib import Path
from typing import Any
from sqlalchemy.orm import Session
from .models import SoftwarePackage
PACKAGE_ROOT = Path(os.getenv("SOFTWARE_PACKAGE_DIR", "/app/data/software-packages"))
PACKAGE_ROOT.mkdir(parents=True, exist_ok=True)
def package_directory(package_id: int) -> Path:
package_id = int(package_id)
if package_id <= 0:
raise ValueError("invalid package id")
return PACKAGE_ROOT / str(package_id)
def _safe_member_name(value: str) -> str:
name = Path(str(value or "")).name
if not name or name in {".", ".."}:
raise ValueError("invalid package member name")
return name
def _safe_package_label(value: str) -> str:
text = re.sub(r"[\x00-\x1f]+", " ", str(value or "")).strip()
return re.sub(r"\s+", " ", text)[:180]
def unique_package_name(db: Session, product_name: str, product_version: str) -> str:
base = _safe_package_label(
f"{product_name.strip()} {product_version.strip()}".strip()
) or "Software package"
candidate = base
counter = 2
while db.query(SoftwarePackage.id).filter(SoftwarePackage.name == candidate).first() is not None:
candidate = _safe_package_label(f"{base} ({counter})")
counter += 1
return candidate
def write_package_storage(
package_id: int,
installer_path: Path,
install_script: str,
uninstall_script: str,
detect_script: str,
manifest: dict[str, Any],
analysis: dict[str, Any],
) -> Path:
target = package_directory(package_id)
temporary = PACKAGE_ROOT / f".{package_id}-{uuid.uuid4().hex}.tmp"
shutil.rmtree(temporary, ignore_errors=True)
temporary.mkdir(parents=True, exist_ok=False)
try:
installer_name = _safe_member_name(manifest.get("installer_file", installer_path.name))
shutil.copy2(installer_path, temporary / installer_name)
(temporary / "install.ps1").write_text(install_script, encoding="utf-8", newline="\n")
(temporary / "uninstall.ps1").write_text(uninstall_script, encoding="utf-8", newline="\n")
(temporary / "detect.ps1").write_text(detect_script, encoding="utf-8", newline="\n")
(temporary / "package.json").write_text(
json.dumps(manifest, ensure_ascii=True, indent=2) + "\n",
encoding="utf-8",
)
(temporary / "analysis.json").write_text(
json.dumps(analysis, ensure_ascii=True, indent=2) + "\n",
encoding="utf-8",
)
if target.exists():
shutil.rmtree(target)
temporary.replace(target)
except Exception:
shutil.rmtree(temporary, ignore_errors=True)
raise
return target
def load_package_manifest(package_id: int) -> dict[str, Any]:
manifest_path = package_directory(package_id) / "package.json"
if not manifest_path.is_file():
raise FileNotFoundError(f"Package manifest not found: {manifest_path}")
data = json.loads(manifest_path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError("package manifest is not an object")
if str(data.get("schema") or "") not in {
"assetmanager-software-package-v1",
"assetmanager-setup-analyzer-package-v1",
}:
raise ValueError("unsupported package manifest schema")
normalized, _notes = normalize_package_manifest(data)
if normalized != data:
manifest_path.write_text(json.dumps(normalized, ensure_ascii=True, indent=2) + "\n", encoding="utf-8")
return normalized
def package_payload_files(package_id: int, action: str) -> list[Path]:
manifest = load_package_manifest(package_id)
refresh_generated_package_runtime_scripts(package_id, manifest)
root = package_directory(package_id)
action = str(action or "").strip().lower()
if action not in {"install", "uninstall", "reinstall"}:
raise ValueError("unsupported deployment action")
names: list[str] = ["package.json"]
if action in {"install", "reinstall"}:
names.append(_safe_member_name(manifest.get("installer_file", "")))
names.append(_safe_member_name((manifest.get("install") or {}).get("script", "install.ps1")))
if action in {"uninstall", "reinstall"}:
names.append(_safe_member_name((manifest.get("uninstall") or {}).get("script", "uninstall.ps1")))
if str((manifest.get("detection") or {}).get("method") or "manual") != "manual":
names.append(_safe_member_name((manifest.get("detection") or {}).get("script", "detect.ps1")))
result: list[Path] = []
seen: set[str] = set()
for name in names:
if not name or name in seen:
continue
seen.add(name)
path = root / name
if not path.is_file():
raise FileNotFoundError(f"Package file not found: {path}")
result.append(path)
return result
def package_storage_size(package_id: int) -> int:
root = package_directory(package_id)
if not root.is_dir():
return 0
return sum(path.stat().st_size for path in root.rglob("*") if path.is_file())
def human_size(size: int) -> str:
value = float(max(0, int(size or 0)))
for unit in ("B", "KB", "MB", "GB", "TB"):
if value < 1024.0 or unit == "TB":
return f"{int(value)} {unit}" if unit == "B" else f"{value:.1f} {unit}"
value /= 1024.0
return f"{int(size or 0)} B"
def package_summary(package: SoftwarePackage) -> dict[str, Any]:
manifest: dict[str, Any] = {}
error = ""
try:
manifest = load_package_manifest(package.id)
except Exception as exc:
error = str(exc)
return {
"package": package,
"manifest": manifest,
"storage_size": package_storage_size(package.id),
"storage_error": error,
}
def package_execution_timeout_seconds(manifest: dict[str, Any]) -> int:
try:
value = int((manifest.get("install") or {}).get("timeout_seconds") or 600)
except (TypeError, ValueError):
value = 600
return max(30, min(value, 86400))
def _ps_quote(value: str) -> str:
return "'" + str(value or "").replace("'", "''") + "'"
def _package_identity_text(manifest: dict[str, Any]) -> str:
parts = [
str(manifest.get("name") or ""),
str(manifest.get("vendor") or ""),
str(manifest.get("installer_file") or ""),
str((manifest.get("detection") or {}).get("display_name") or ""),
]
return " ".join(parts).casefold()
def _append_install_argument(arguments: str, value: str, pattern: str) -> str:
arguments = str(arguments or "").strip()
if re.search(pattern, arguments, flags=re.IGNORECASE):
return arguments
return (arguments + " " + value).strip()
def normalize_process_names(value: Any) -> list[str]:
if isinstance(value, str):
raw_items = re.split(r"[\r\n,;]+", value)
elif isinstance(value, (list, tuple, set)):
raw_items = [str(item or "") for item in value]
else:
raw_items = []
result: list[str] = []
seen: set[str] = set()
for raw in raw_items:
name = str(raw or "").strip().strip('"').strip("'")
name = name.replace("\\", "/").rsplit("/", 1)[-1].strip()
if not name or any(char in name for char in "*?[]"):
continue
name = re.sub(r"[\x00-\x1f]", "", name).strip()
if not name:
continue
if not name.casefold().endswith(".exe"):
name += ".exe"
name = name[:128]
key = name.casefold()
if key in seen:
continue
seen.add(key)
result.append(name)
if len(result) >= 32:
break
return result
def _process_control_values(manifest: dict[str, Any]) -> tuple[list[str], int, bool]:
control = manifest.get("process_control") or {}
if not isinstance(control, dict):
control = {}
process_names = normalize_process_names(control.get("process_names"))
try:
grace_seconds = int(control.get("grace_seconds", 5))
except (TypeError, ValueError):
grace_seconds = 5
grace_seconds = max(0, min(grace_seconds, 30))
force_close = control.get("force_close", True)
if isinstance(force_close, str):
force_close = force_close.strip().lower() in {"1", "true", "yes", "on"}
else:
force_close = bool(force_close)
return process_names, grace_seconds, force_close
def _process_control_ps_lines(manifest: dict[str, Any], phase: str) -> list[str]:
process_names, grace_seconds, force_close = _process_control_values(manifest)
if not process_names:
return []
ps_names = ", ".join(_ps_quote(name) for name in process_names)
return [
f"$configuredProcessNames = @({ps_names})",
f"$processGraceSeconds = {grace_seconds}",
"$forceCloseProcesses = $" + ("true" if force_close else "false"),
"",
"function Stop-ConfiguredPackageProcesses([string]$Phase) {",
"\tforeach ($configuredName in $configuredProcessNames) {",
"\t\t$lookupName = [System.IO.Path]::GetFileNameWithoutExtension([string]$configuredName)",
"\t\tif ([string]::IsNullOrWhiteSpace($lookupName)) { continue }",
"\t\t$running = @(Get-Process -Name $lookupName -ErrorAction SilentlyContinue)",
"\t\tif ($running.Count -eq 0) {",
"\t\t\tWrite-Output (\"No running configured process before \" + $Phase + \": \" + [string]$configuredName)",
"\t\t\tcontinue",
"\t\t}",
"\t\tforeach ($item in $running) {",
"\t\t\tWrite-Output (\"Running process found before \" + $Phase + \": \" + $item.ProcessName + '.exe; PID=' + $item.Id)",
"\t\t}",
"\t\t$closeRequested = $false",
"\t\tforeach ($item in $running) {",
"\t\t\ttry {",
"\t\t\t\tif ($item.MainWindowHandle -ne 0) {",
"\t\t\t\t\t$requested = $item.CloseMainWindow()",
"\t\t\t\t\tif ($requested) {",
"\t\t\t\t\t\t$closeRequested = $true",
"\t\t\t\t\t\tWrite-Output (\"Close request sent: \" + $item.ProcessName + '.exe; PID=' + $item.Id)",
"\t\t\t\t\t}",
"\t\t\t\t}",
"\t\t\t} catch {}",
"\t\t}",
"\t\tif ($closeRequested -and $processGraceSeconds -gt 0) {",
"\t\t\t$deadline = (Get-Date).AddSeconds($processGraceSeconds)",
"\t\t\tdo {",
"\t\t\t\t$remaining = @(Get-Process -Name $lookupName -ErrorAction SilentlyContinue)",
"\t\t\t\tif ($remaining.Count -eq 0) { break }",
"\t\t\t\tStart-Sleep -Milliseconds 400",
"\t\t\t} while ((Get-Date) -lt $deadline)",
"\t\t}",
"\t\t$remaining = @(Get-Process -Name $lookupName -ErrorAction SilentlyContinue)",
"\t\tif ($remaining.Count -gt 0 -and $forceCloseProcesses) {",
"\t\t\tforeach ($item in $remaining) {",
"\t\t\t\tWrite-Output (\"Process still running; forcing termination: \" + $item.ProcessName + '.exe; PID=' + $item.Id)",
"\t\t\t\ttry { Stop-Process -Id $item.Id -Force -ErrorAction Stop } catch { throw (\"Could not terminate process \" + $item.ProcessName + '.exe; PID=' + $item.Id + ': ' + $_.Exception.Message) }",
"\t\t\t}",
"\t\t\tStart-Sleep -Milliseconds 500",
"\t\t}",
"\t\t$remaining = @(Get-Process -Name $lookupName -ErrorAction SilentlyContinue)",
"\t\tif ($remaining.Count -gt 0) {",
"\t\t\t$ids = ($remaining | Select-Object -ExpandProperty Id) -join ','",
"\t\t\tthrow (\"Configured process is still running before \" + $Phase + \": \" + [string]$configuredName + '; PID=' + $ids)",
"\t\t}",
"\t\tWrite-Output (\"Configured process stopped before \" + $Phase + \": \" + [string]$configuredName)",
"\t}",
"}",
"",
f"Stop-ConfiguredPackageProcesses -Phase {_ps_quote(phase)}",
"",
]
def normalize_package_manifest(manifest: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
if not isinstance(manifest, dict):
raise ValueError("package manifest is not an object")
result = json.loads(json.dumps(manifest))
notes: list[str] = []
installer_type = str(result.get("installer_type") or "").strip().lower()
identity = _package_identity_text(result)
install = result.setdefault("install", {})
if not isinstance(install, dict):
install = {}
result["install"] = install
arguments = str(install.get("arguments") or "").strip()
if installer_type == "inno" and "greenshot" in identity:
before = arguments
current_user_scope = re.search(r"(?i)(^|\s)/CURRENTUSER(?=\s|$)", arguments) is not None
if not current_user_scope:
if not re.search(r"(?i)(^|\s)/ALLUSERS(?=\s|$)", arguments):
arguments = _append_install_argument(arguments, "/ALLUSERS", r"(^|\s)/ALLUSERS(?=\s|$)")
if not re.search(r"(?i)(^|\s)/DIR=", arguments):
arguments = (arguments + ' /DIR="C:\\Program Files\\Greenshot"').strip()
if arguments != before:
notes.append("greenshot_machine_scope")
install["arguments"] = arguments
if not current_user_scope:
result.setdefault("deployment_profile", "greenshot-machine")
existing_process_control = result.get("process_control")
process_names_configured = isinstance(existing_process_control, dict) and "process_names" in existing_process_control
process_control = result.setdefault("process_control", {})
if not isinstance(process_control, dict):
process_control = {}
result["process_control"] = process_control
process_names = normalize_process_names(process_control.get("process_names"))
if "greenshot" in identity and not process_names and not process_names_configured:
process_names = ["Greenshot.exe"]
notes.append("greenshot_process_control")
process_control["process_names"] = process_names
try:
grace_seconds = int(process_control.get("grace_seconds", 5))
except (TypeError, ValueError):
grace_seconds = 5
process_control["grace_seconds"] = max(0, min(grace_seconds, 30))
force_close = process_control.get("force_close", True)
if isinstance(force_close, str):
force_close = force_close.strip().lower() in {"1", "true", "yes", "on"}
process_control["force_close"] = bool(force_close)
if installer_type == "inno" and process_names:
arguments = str(install.get("arguments") or arguments).strip()
if not re.search(r"(?i)(^|\s)/(NO)?CLOSEAPPLICATIONS(?=\s|$)", arguments):
arguments = _append_install_argument(arguments, "/CLOSEAPPLICATIONS", r"(^|\s)/(NO)?CLOSEAPPLICATIONS(?=\s|$)")
if process_control["force_close"] and not re.search(r"(?i)(^|\s)/(NO)?FORCECLOSEAPPLICATIONS(?=\s|$)", arguments):
arguments = _append_install_argument(arguments, "/FORCECLOSEAPPLICATIONS", r"(^|\s)/(NO)?FORCECLOSEAPPLICATIONS(?=\s|$)")
install["arguments"] = arguments
return result, notes
def delete_package_storage(package_id: int) -> None:
root = package_directory(package_id)
if root.is_dir():
shutil.rmtree(root)
def _int_codes(value: Any, fallback: list[int]) -> list[int]:
result: list[int] = []
for item in value if isinstance(value, (list, tuple, set)) else fallback:
try:
number = int(item)
except (TypeError, ValueError):
continue
if number not in result:
result.append(number)
return result or list(fallback)
def build_generated_install_script(manifest: dict[str, Any]) -> str:
manifest, notes = normalize_package_manifest(manifest)
install = manifest.get("install") or {}
installer_type = str(manifest.get("installer_type") or "").strip().lower()
installer_file = _safe_member_name(manifest.get("installer_file", ""))
arguments = str(install.get("arguments") or "").strip()
success_codes = _int_codes(install.get("success_codes"), [0])
reboot_codes = _int_codes(install.get("reboot_codes"), [])
timeout_seconds = package_execution_timeout_seconds(manifest)
suppress_browser = bool(install.get("suppress_browser"))
greenshot_preset = "greenshot_machine_scope" in notes or str(manifest.get("deployment_profile") or "") == "greenshot-machine"
success = ", ".join(str(code) for code in success_codes)
reboot = ", ".join(str(code) for code in reboot_codes) or "-999999"
lines = [
"$ErrorActionPreference = 'Stop'",
"Set-StrictMode -Version Latest",
"",
"$packageDir = $PSScriptRoot",
f"$installer = Join-Path $packageDir {_ps_quote(installer_file)}",
f"$installerType = {_ps_quote(installer_type)}",
f"$arguments = {_ps_quote(arguments)}",
f"$successCodes = @({success})",
f"$rebootCodes = @({reboot})",
f"$timeoutSeconds = {timeout_seconds}",
"$suppressBrowser = $" + ("true" if suppress_browser else "false"),
"$greenshotPreset = $" + ("true" if greenshot_preset else "false"),
"$innoLog = $null",
"",
"if (-not (Test-Path -LiteralPath $installer)) {",
"\tWrite-Error \"Installer not found: $installer\"",
"\texit 2",
"}",
"",
"if ($installerType -eq 'inno') {",
"\t$innoLog = Join-Path $env:TEMP ('AssetManager-Inno-' + [guid]::NewGuid().ToString('N') + '.log')",
"\tif ($arguments -notmatch '(?i)(^|\\s)/LOG(=|\\s)') {",
"\t\t$arguments = ($arguments + ' /LOG=\"' + $innoLog + '\"').Trim()",
"\t}",
"}",
"",
"Write-Output (\"Installer file: \" + $installer)",
"Write-Output (\"Installer type: \" + $installerType)",
"Write-Output (\"Installer working directory: \" + $packageDir)",
"if ($greenshotPreset) { Write-Output 'Greenshot deployment preset: machine scope, C:\\Program Files\\Greenshot' }",
"",
"$browserPidsBefore = @()",
"if ($suppressBrowser) {",
"\t$browserPidsBefore = @(Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.ProcessName -in @('msedge','chrome','firefox','brave','opera','iexplore') } | Select-Object -ExpandProperty Id)",
"}",
"",
]
lines += _process_control_ps_lines(manifest, "installation")
if installer_type == "msi":
lines += [
"$processArguments = '/i \"' + $installer + '\" ' + $arguments",
"$process = Start-Process -FilePath 'msiexec.exe' -ArgumentList $processArguments -WorkingDirectory $packageDir -PassThru -NoNewWindow",
]
elif installer_type == "msp":
lines += [
"$processArguments = '/p \"' + $installer + '\" ' + $arguments",
"$process = Start-Process -FilePath 'msiexec.exe' -ArgumentList $processArguments -WorkingDirectory $packageDir -PassThru -NoNewWindow",
]
elif installer_type == "msu":
lines += [
"$processArguments = '\"' + $installer + '\" ' + $arguments",
"$process = Start-Process -FilePath 'wusa.exe' -ArgumentList $processArguments -WorkingDirectory $packageDir -PassThru -NoNewWindow",
]
elif installer_type in {"msix", "appx"}:
lines += ["Add-AppxPackage -Path $installer -ErrorAction Stop", "exit 0"]
return "\n".join(lines) + "\n"
else:
lines.append("$process = Start-Process -FilePath $installer -ArgumentList $arguments -WorkingDirectory $packageDir -PassThru -NoNewWindow")
lines += [
"Write-Output (\"Installer PID: \" + $process.Id)",
"try {",
"\tWait-Process -Id $process.Id -Timeout $timeoutSeconds -ErrorAction Stop",
"} catch {",
"\ttry { Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue } catch {}",
"\tWrite-Error (\"Installer timeout after \" + $timeoutSeconds + \" seconds.\")",
"\texit 1460",
"}",
"$process.Refresh()",
"$exitCode = [int]$process.ExitCode",
"Write-Output (\"Installer exit code: \" + $exitCode)",
"",
"if ($suppressBrowser) {",
"\tStart-Sleep -Milliseconds 1500",
"\t$newBrowsers = @(Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.ProcessName -in @('msedge','chrome','firefox','brave','opera','iexplore') -and $browserPidsBefore -notcontains $_.Id })",
"\tforeach ($browser in $newBrowsers) {",
"\t\ttry { Write-Output (\"Stopping installer-opened browser: \" + $browser.ProcessName + \"; PID=\" + $browser.Id); Stop-Process -Id $browser.Id -Force -ErrorAction SilentlyContinue } catch {}",
"\t}",
"}",
"",
"if ($null -ne $innoLog -and (Test-Path -LiteralPath $innoLog)) {",
"\tWrite-Output 'Inno Setup log summary:'",
"\t$interesting = @(Get-Content -LiteralPath $innoLog -ErrorAction SilentlyContinue | Where-Object { $_ -match '(?i)(Dest filename:|Creating directory:|Defaulting to Abort|Error|Installation process succeeded|Installation process failed|Setup exit code|Installation finished)' } | Select-Object -Last 60)",
"\tforeach ($line in $interesting) { Write-Output ('Inno: ' + [string]$line) }",
"}",
"",
"if ($successCodes -notcontains $exitCode) { exit $exitCode }",
"if ($rebootCodes -contains $exitCode) { exit 3010 }",
"exit 0",
]
return "\n".join(lines) + "\n"
def _registry_discovery_block(
display_name: str,
display_version: str,
publisher: str,
) -> str:
return f"""$displayName = {_ps_quote(display_name)}
$displayVersion = {_ps_quote(display_version)}
$publisher = {_ps_quote(publisher)}
function Get-UninstallEntries {{
$entries = @()
$machineRoots = @(
'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*',
'HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*',
'HKCU:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*',
'HKCU:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'
)
$entries += @(Get-ItemProperty -Path $machineRoots -ErrorAction SilentlyContinue)
try {{
$userHives = @(Get-ChildItem -Path 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue | Where-Object {{
$_.PSChildName -match '^S-1-(5-21|12-1)-'
}})
foreach ($hive in $userHives) {{
$roots = @(
("Registry::HKEY_USERS\\" + $hive.PSChildName + "\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*"),
("Registry::HKEY_USERS\\" + $hive.PSChildName + "\\Software\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*")
)
$entries += @(Get-ItemProperty -Path $roots -ErrorAction SilentlyContinue)
}}
}} catch {{
}}
return @($entries | Where-Object {{ -not [string]::IsNullOrWhiteSpace([string]$_.DisplayName) }})
}}
function Get-PackageEntry {{
$entries = @(Get-UninstallEntries)
if ($entries.Count -eq 0) {{ return $null }}
$exact = @($entries | Where-Object {{ [string]$_.DisplayName -ieq $displayName }})
$candidates = $exact
if ($candidates.Count -eq 0) {{
$prefixSpace = $displayName + ' '
$prefixDash = $displayName + '-'
$prefixParen = $displayName + ' ('
$candidates = @($entries | Where-Object {{
$name = [string]$_.DisplayName
$name.StartsWith($prefixSpace, [System.StringComparison]::OrdinalIgnoreCase) -or
$name.StartsWith($prefixDash, [System.StringComparison]::OrdinalIgnoreCase) -or
$name.StartsWith($prefixParen, [System.StringComparison]::OrdinalIgnoreCase)
}})
}}
if ($candidates.Count -eq 0) {{ return $null }}
$ranked = foreach ($entry in $candidates) {{
$score = 0
if ([string]$entry.DisplayName -ieq $displayName) {{ $score += 100 }} else {{ $score += 60 }}
if (-not [string]::IsNullOrWhiteSpace($displayVersion) -and [string]$entry.DisplayVersion -ieq $displayVersion) {{ $score += 30 }}
if (-not [string]::IsNullOrWhiteSpace($publisher) -and [string]$entry.Publisher -ieq $publisher) {{ $score += 20 }}
[pscustomobject]@{{ Score = $score; Entry = $entry }}
}}
return ($ranked | Sort-Object Score -Descending | Select-Object -First 1).Entry
}}
"""
def build_generated_detection_script(manifest: dict[str, Any]) -> str:
detection = manifest.get("detection") or {}
method = str(detection.get("method") or "manual")
product_code = str(detection.get("product_code") or "").strip()
display_name = str(detection.get("display_name") or manifest.get("name") or "").strip()
display_version = str(detection.get("display_version") or manifest.get("version") or "").strip()
publisher = str(detection.get("publisher") or manifest.get("vendor") or "").strip()
installer_type = str(manifest.get("installer_type") or "").strip().lower()
if method == "msi_product_code" and product_code:
return f"""$ErrorActionPreference = 'SilentlyContinue'
$productCode = {_ps_quote(product_code)}
$paths = @(
"HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\$productCode",
"HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\$productCode"
)
if ($paths | Where-Object {{ Test-Path -LiteralPath $_ }}) {{
Write-Output "Software found by MSI ProductCode: $productCode"
exit 0
}}
Write-Output "Software not found by MSI ProductCode: $productCode"
exit 1
"""
if method == "appx_package" and display_name:
return f"""$ErrorActionPreference = 'SilentlyContinue'
$name = {_ps_quote(display_name)}
$package = Get-AppxPackage -AllUsers | Where-Object {{ $_.Name -eq $name -or $_.PackageFullName -like "$name*" }} | Select-Object -First 1
if ($null -ne $package) {{
Write-Output ("Software found: " + $package.PackageFullName)
exit 0
}}
Write-Output "Software not found: $name"
exit 1
"""
if method == "registry_display_name" and display_name:
return "$ErrorActionPreference = 'SilentlyContinue'\n" + _registry_discovery_block(
display_name,
display_version,
publisher,
) + """$entry = Get-PackageEntry
if ($null -ne $entry) {
Write-Output ("Software found: " + [string]$entry.DisplayName + "; version=" + [string]$entry.DisplayVersion + "; publisher=" + [string]$entry.Publisher)
exit 0
}
Write-Output ("Software not found: " + $displayName)
exit 1
"""
if installer_type in {"msix", "appx"} and display_name:
fallback = dict(manifest)
fallback["detection"] = {"method": "appx_package", "display_name": display_name}
return build_generated_detection_script(fallback)
return "Write-Output 'No automatic detection rule is available for this package.'\nexit 2\n"
def build_generated_uninstall_script(manifest: dict[str, Any]) -> str:
detection = manifest.get("detection") or {}
method = str(detection.get("method") or "manual")
product_code = str(detection.get("product_code") or "").strip()
display_name = str(detection.get("display_name") or manifest.get("name") or "").strip()
display_version = str(detection.get("display_version") or manifest.get("version") or "").strip()
publisher = str(detection.get("publisher") or manifest.get("vendor") or "").strip()
installer_type = str(manifest.get("installer_type") or "").strip().lower()
timeout_seconds = package_execution_timeout_seconds(manifest)
process_block = "\n".join(_process_control_ps_lines(manifest, "uninstallation"))
if process_block:
process_block += "\n"
if method == "msi_product_code" and product_code:
return f"""$ErrorActionPreference = 'Stop'
{process_block}$productCode = {_ps_quote(product_code)}
$arguments = '/x "' + $productCode + '" /qn /norestart'
Write-Output ("Starting MSI uninstall: msiexec.exe " + $arguments)
$process = Start-Process -FilePath 'msiexec.exe' -ArgumentList $arguments -PassThru -NoNewWindow
try {{
Wait-Process -Id $process.Id -Timeout {timeout_seconds} -ErrorAction Stop
}} catch {{
Write-Error "MSI uninstall timed out after {timeout_seconds} seconds."
exit 1460
}}
$process.Refresh()
$exitCode = [int]$process.ExitCode
Write-Output "Uninstaller exit code: $exitCode"
if (@(0, 1641, 3010) -notcontains $exitCode) {{ exit $exitCode }}
if (@(1641, 3010) -contains $exitCode) {{ exit 3010 }}
exit 0
"""
if method == "appx_package" and display_name:
return f"""$ErrorActionPreference = 'Stop'
{process_block}$name = {_ps_quote(display_name)}
$packages = @(Get-AppxPackage -AllUsers | Where-Object {{ $_.Name -eq $name -or $_.PackageFullName -like "$name*" }})
if ($packages.Count -eq 0) {{
Write-Output "Software not found: $name. Nothing to uninstall."
exit 0
}}
foreach ($package in $packages) {{
Write-Output ("Removing AppX package: " + $package.PackageFullName)
Remove-AppxPackage -Package $package.PackageFullName -AllUsers -ErrorAction Stop
}}
exit 0
"""
if method == "registry_display_name" and display_name:
base = "$ErrorActionPreference = 'Stop'\n" + process_block + _registry_discovery_block(
display_name,
display_version,
publisher,
)
base += f"$installerType = {_ps_quote(installer_type)}\n$timeoutSeconds = {timeout_seconds}\n"
base += """
function Split-UninstallCommand([string]$Command) {
$expanded = [Environment]::ExpandEnvironmentVariables([string]$Command).Trim()
if ([string]::IsNullOrWhiteSpace($expanded)) { throw 'Uninstall command is empty.' }
if ($expanded.StartsWith('"')) {
$closing = $expanded.IndexOf('"', 1)
if ($closing -lt 1) { throw "Invalid uninstall command: $expanded" }
$filePath = $expanded.Substring(1, $closing - 1)
$arguments = $expanded.Substring($closing + 1).Trim()
} else {
$space = $expanded.IndexOf(' ')
if ($space -lt 0) {
$filePath = $expanded
$arguments = ''
} else {
$filePath = $expanded.Substring(0, $space)
$arguments = $expanded.Substring($space + 1).Trim()
}
}
return [pscustomobject]@{ FilePath = $filePath; Arguments = $arguments }
}
$entry = Get-PackageEntry
if ($null -eq $entry) {
Write-Output ("Software not found: " + $displayName + ". Nothing to uninstall.")
exit 0
}
Write-Output ("Software found: " + [string]$entry.DisplayName + "; version=" + [string]$entry.DisplayVersion + "; publisher=" + [string]$entry.Publisher)
$command = [string]$entry.QuietUninstallString
$usedQuiet = -not [string]::IsNullOrWhiteSpace($command)
if ($usedQuiet) {
Write-Output 'QuietUninstallString found.'
} else {
$command = [string]$entry.UninstallString
Write-Output 'QuietUninstallString not available; using UninstallString.'
}
if ([string]::IsNullOrWhiteSpace($command)) {
Write-Error 'No uninstall command was found in the registry.'
exit 3
}
if (-not $usedQuiet) {
if ($installerType -eq 'inno' -and $command -notmatch '(?i)/(very)?silent') {
$command += ' /VERYSILENT /SUPPRESSMSGBOXES /NORESTART'
} elseif ($installerType -eq 'nsis' -and $command -notmatch '(?i)(^|\\s)/S($|\\s)') {
$command += ' /S'
}
}
$parts = Split-UninstallCommand $command
Write-Output ("Starting uninstaller: " + $parts.FilePath + $(if ([string]::IsNullOrWhiteSpace($parts.Arguments)) { '' } else { ' ' + $parts.Arguments }))
if ([string]::IsNullOrWhiteSpace($parts.Arguments)) {
$process = Start-Process -FilePath $parts.FilePath -PassThru
} else {
$process = Start-Process -FilePath $parts.FilePath -ArgumentList $parts.Arguments -PassThru
}
Write-Output ("Uninstaller PID: " + $process.Id)
try {
Wait-Process -Id $process.Id -Timeout $timeoutSeconds -ErrorAction Stop
} catch {
Write-Error ("Uninstaller timed out after " + $timeoutSeconds + " seconds.")
exit 1460
}
$process.Refresh()
$exitCode = [int]$process.ExitCode
Write-Output ("Uninstaller exit code: " + $exitCode)
if (@(0, 1641, 3010) -notcontains $exitCode) { exit $exitCode }
if (@(1641, 3010) -contains $exitCode) { exit 3010 }
exit 0
"""
return base
return "Write-Error 'No automatic uninstall rule is available for this package.'\nexit 2\n"
def refresh_generated_package_runtime_scripts(
package_id: int,
manifest: dict[str, Any] | None = None,
) -> None:
manifest = manifest or load_package_manifest(package_id)
if not isinstance(manifest.get("analysis"), dict):
return
root = package_directory(package_id)
install = manifest.get("install") or {}
detection = manifest.get("detection") or {}
uninstall = manifest.get("uninstall") or {}
install_name = _safe_member_name(install.get("script", "install.ps1"))
detect_name = _safe_member_name(detection.get("script", "detect.ps1"))
uninstall_name = _safe_member_name(uninstall.get("script", "uninstall.ps1"))
generated = {
root / install_name: build_generated_install_script(manifest),
root / detect_name: build_generated_detection_script(manifest),
root / uninstall_name: build_generated_uninstall_script(manifest),
}
for path, content in generated.items():
if not path.parent.is_dir():
continue
current = path.read_text(encoding="utf-8") if path.is_file() else ""
if current != content:
path.write_text(content, encoding="utf-8", newline="\n")
def update_package_process_control(
package_id: int,
process_names: Any,
grace_seconds: int = 5,
force_close: bool = True,
) -> dict[str, Any]:
manifest = load_package_manifest(package_id)
manifest["process_control"] = {
"process_names": normalize_process_names(process_names),
"grace_seconds": max(0, min(int(grace_seconds), 30)),
"force_close": bool(force_close),
}
manifest, _notes = normalize_package_manifest(manifest)
manifest_path = package_directory(package_id) / "package.json"
manifest_path.write_text(
json.dumps(manifest, ensure_ascii=True, indent=2) + "\n",
encoding="utf-8",
)
refresh_generated_package_runtime_scripts(package_id, manifest)
return manifest
def build_deployment_user_script(manifest: dict[str, Any], action: str) -> str:
action = str(action or "").strip().lower()
if action not in {"install", "uninstall", "reinstall"}:
raise ValueError("unsupported deployment action")
package_name = str(manifest.get("name") or "Software package")
install_script = _safe_member_name((manifest.get("install") or {}).get("script", "install.ps1"))
uninstall_script = _safe_member_name((manifest.get("uninstall") or {}).get("script", "uninstall.ps1"))
detect_script = _safe_member_name((manifest.get("detection") or {}).get("script", "detect.ps1"))
detection_method = str((manifest.get("detection") or {}).get("method") or "manual")
def ps(value: str) -> str:
return "'" + value.replace("'", "''") + "'"
lines = [
f"$deploymentAction = {ps(action)}",
f"$deploymentPackage = {ps(package_name)}",
f"$detectionMethod = {ps(detection_method)}",
"$deploymentExitCode = 0",
"$rebootRequired = $false",
"",
"function Invoke-PackagePowerShell([string]$ScriptName) {",
" $scriptPath = Join-Path $PSScriptRoot $ScriptName",
" if (-not (Test-Path -LiteralPath $scriptPath)) { throw \"Package script not found: $scriptPath\" }",
" Write-JobLog (\"Running package script: $ScriptName\") | Out-Null",
" $packageOutput = @(& powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $scriptPath 2>&1)",
" $code = [int]$LASTEXITCODE",
" foreach ($outputLine in $packageOutput) {",
" $outputText = [string]$outputLine",
" if (-not [string]::IsNullOrWhiteSpace($outputText)) { Write-JobLog (\"Package output: $outputText\") | Out-Null }",
" }",
" Write-JobLog (\"Package script exit code: $code\") | Out-Null",
" return [int]$code",
"}",
"",
"function Assert-PackageResult([int]$Code, [string]$Step) {",
" if ($Code -eq 3010) { $script:rebootRequired = $true; return }",
" if ($Code -ne 0) { throw \"$Step failed with exit code $Code\" }",
"}",
"",
"function Test-PackageDetected() {",
" if ($detectionMethod -eq 'manual') { return $null }",
f" $detectScript = {ps(detect_script)}",
" $scriptPath = Join-Path $PSScriptRoot $detectScript",
" if (-not (Test-Path -LiteralPath $scriptPath)) { return $null }",
" $detectOutput = @(& powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $scriptPath 2>&1)",
" $detectCode = [int]$LASTEXITCODE",
" foreach ($outputLine in $detectOutput) {",
" $outputText = [string]$outputLine",
" if (-not [string]::IsNullOrWhiteSpace($outputText)) { Write-JobLog (\"Detection output: $outputText\") | Out-Null }",
" }",
" Write-JobLog (\"Detection script exit code: $detectCode\") | Out-Null",
" return [bool]($detectCode -eq 0)",
"}",
"",
"function Wait-PackageDetection([bool]$Expected, [int]$TimeoutSeconds = 30) {",
" if ($detectionMethod -eq 'manual') { return $null }",
" $deadline = (Get-Date).AddSeconds($TimeoutSeconds)",
" $last = $null",
" do {",
" $last = Test-PackageDetected",
" if ($null -eq $last) { return $null }",
" if ([bool]$last -eq $Expected) { return [bool]$last }",
" Start-Sleep -Seconds 2",
" } while ((Get-Date) -lt $deadline)",
" return [bool]$last",
"}",
"",
"Write-JobLog (\"Software deployment started: $deploymentPackage; action=$deploymentAction\")",
]
if action == "install":
lines += [
f"$deploymentExitCode = [int](Invoke-PackagePowerShell {ps(install_script)})",
"Assert-PackageResult $deploymentExitCode 'Installation'",
"$detected = Wait-PackageDetection $true 30",
"if ($detected -eq $false) { throw 'Installation completed but the detection rule did not find the software.' }",
]
elif action == "uninstall":
lines += [
f"$deploymentExitCode = [int](Invoke-PackagePowerShell {ps(uninstall_script)})",
"Assert-PackageResult $deploymentExitCode 'Uninstallation'",
"$detected = Wait-PackageDetection $false 30",
"if ($detected -eq $true) { throw 'Uninstallation completed but the detection rule still finds the software.' }",
]
else:
lines += [
f"$uninstallCode = [int](Invoke-PackagePowerShell {ps(uninstall_script)})",
"Assert-PackageResult $uninstallCode 'Uninstallation'",
"$detectedAfterUninstall = Wait-PackageDetection $false 30",
"if ($detectedAfterUninstall -eq $true) { throw 'Uninstallation completed but the detection rule still finds the software.' }",
f"$deploymentExitCode = [int](Invoke-PackagePowerShell {ps(install_script)})",
"Assert-PackageResult $deploymentExitCode 'Installation'",
"$detected = Wait-PackageDetection $true 30",
"if ($detected -eq $false) { throw 'Reinstallation completed but the detection rule did not find the software.' }",
]
lines += [
"$JobResult['deployment_action'] = $deploymentAction",
"$JobResult['deployment_package'] = $deploymentPackage",
"$JobResult['process_exit_code'] = $deploymentExitCode",
"$JobResult['reboot_required'] = $rebootRequired",
"if ($null -ne $detected) { $JobResult['detected_after_action'] = [bool]$detected }",
"Write-JobLog (\"Software deployment completed: $deploymentPackage; action=$deploymentAction; reboot=$rebootRequired\")",
]
return "\n".join(lines)