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)