from __future__ import annotations import base64 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)}", "", ] INTERACTIVE_LAUNCHER_CSHARP = r"""using System; using System.ComponentModel; using System.IO; using System.Runtime.InteropServices; using System.Text; public sealed class AssetManagerLaunchResult { public bool UserFound { get; set; } public bool Success { get; set; } public int SessionId { get; set; } public int ProcessId { get; set; } public int ErrorCode { get; set; } public string UserName { get; set; } public string ErrorMessage { get; set; } } public static class AssetManagerInteractiveLauncher { private const uint CREATE_UNICODE_ENVIRONMENT = 0x00000400; private const int WTS_CURRENT_SERVER = 0; private const int WTS_USER_NAME = 5; private const int WTS_DOMAIN_NAME = 7; private const int WTS_ACTIVE = 0; private const uint INVALID_SESSION_ID = 0xFFFFFFFF; [StructLayout(LayoutKind.Sequential)] private struct WTS_SESSION_INFO { public int SessionID; public IntPtr pWinStationName; public int State; } [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] private struct STARTUPINFO { public int cb; public string lpReserved; public string lpDesktop; public string lpTitle; public int dwX; public int dwY; public int dwXSize; public int dwYSize; public int dwXCountChars; public int dwYCountChars; public int dwFillAttribute; public int dwFlags; public short wShowWindow; public short cbReserved2; public IntPtr lpReserved2; public IntPtr hStdInput; public IntPtr hStdOutput; public IntPtr hStdError; } [StructLayout(LayoutKind.Sequential)] private struct PROCESS_INFORMATION { public IntPtr hProcess; public IntPtr hThread; public int dwProcessId; public int dwThreadId; } [DllImport("kernel32.dll")] private static extern uint WTSGetActiveConsoleSessionId(); [DllImport("wtsapi32.dll", SetLastError = true)] private static extern bool WTSEnumerateSessionsW( IntPtr hServer, int Reserved, int Version, out IntPtr ppSessionInfo, out int pCount ); [DllImport("wtsapi32.dll")] private static extern void WTSFreeMemory(IntPtr pMemory); [DllImport("wtsapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] private static extern bool WTSQuerySessionInformationW( IntPtr hServer, int sessionId, int wtsInfoClass, out IntPtr ppBuffer, out int pBytesReturned ); [DllImport("wtsapi32.dll", SetLastError = true)] private static extern bool WTSQueryUserToken(uint sessionId, out IntPtr token); [DllImport("userenv.dll", SetLastError = true)] private static extern bool CreateEnvironmentBlock( out IntPtr lpEnvironment, IntPtr hToken, bool bInherit ); [DllImport("userenv.dll", SetLastError = true)] private static extern bool DestroyEnvironmentBlock(IntPtr lpEnvironment); [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] private static extern bool CreateProcessAsUserW( IntPtr hToken, string lpApplicationName, StringBuilder lpCommandLine, IntPtr lpProcessAttributes, IntPtr lpThreadAttributes, bool bInheritHandles, uint dwCreationFlags, IntPtr lpEnvironment, string lpCurrentDirectory, ref STARTUPINFO lpStartupInfo, out PROCESS_INFORMATION lpProcessInformation ); [DllImport("kernel32.dll", SetLastError = true)] private static extern bool CloseHandle(IntPtr hObject); private static string QuerySessionString(int sessionId, int infoClass) { IntPtr buffer = IntPtr.Zero; int bytes = 0; try { if (!WTSQuerySessionInformationW( new IntPtr(WTS_CURRENT_SERVER), sessionId, infoClass, out buffer, out bytes )) { return string.Empty; } if (buffer == IntPtr.Zero || bytes <= 1) { return string.Empty; } return Marshal.PtrToStringUni(buffer) ?? string.Empty; } finally { if (buffer != IntPtr.Zero) { WTSFreeMemory(buffer); } } } private static string SessionUserName(int sessionId) { string user = QuerySessionString(sessionId, WTS_USER_NAME).Trim(); if (user.Length == 0) { return string.Empty; } string domain = QuerySessionString(sessionId, WTS_DOMAIN_NAME).Trim(); return domain.Length == 0 ? user : domain + "\\" + user; } private static int FindActiveSession(out string userName) { userName = string.Empty; uint consoleId = WTSGetActiveConsoleSessionId(); IntPtr buffer = IntPtr.Zero; int count = 0; if (WTSEnumerateSessionsW( new IntPtr(WTS_CURRENT_SERVER), 0, 1, out buffer, out count )) { int firstActiveSession = -1; string firstActiveUser = string.Empty; try { int size = Marshal.SizeOf(typeof(WTS_SESSION_INFO)); IntPtr current = buffer; for (int i = 0; i < count; i++) { WTS_SESSION_INFO row = (WTS_SESSION_INFO)Marshal.PtrToStructure( current, typeof(WTS_SESSION_INFO) ); current = new IntPtr(current.ToInt64() + size); if (row.State != WTS_ACTIVE) { continue; } string user = SessionUserName(row.SessionID); if (user.Length == 0) { continue; } if (consoleId != INVALID_SESSION_ID && row.SessionID == (int)consoleId) { userName = user; return row.SessionID; } if (firstActiveSession < 0) { firstActiveSession = row.SessionID; firstActiveUser = user; } } if (firstActiveSession >= 0) { userName = firstActiveUser; return firstActiveSession; } } finally { if (buffer != IntPtr.Zero) { WTSFreeMemory(buffer); } } } if (consoleId != INVALID_SESSION_ID) { string consoleUser = SessionUserName((int)consoleId); if (consoleUser.Length > 0) { userName = consoleUser; return (int)consoleId; } } return -1; } private static string QuoteCommandArgument(string value) { return "\"" + (value ?? string.Empty).Replace("\"", "\\\"") + "\""; } public static AssetManagerLaunchResult Launch( string applicationPath, string arguments, string workingDirectory ) { AssetManagerLaunchResult result = new AssetManagerLaunchResult(); result.UserName = string.Empty; result.ErrorMessage = string.Empty; string userName; int sessionId = FindActiveSession(out userName); if (sessionId < 0) { result.UserFound = false; result.ErrorMessage = "No active interactive user session found."; return result; } result.UserFound = true; result.SessionId = sessionId; result.UserName = userName; IntPtr userToken = IntPtr.Zero; IntPtr environment = IntPtr.Zero; PROCESS_INFORMATION processInfo = new PROCESS_INFORMATION(); try { if (!WTSQueryUserToken((uint)sessionId, out userToken)) { int error = Marshal.GetLastWin32Error(); result.ErrorCode = error; result.ErrorMessage = "WTSQueryUserToken failed: " + new Win32Exception(error).Message; return result; } if (!CreateEnvironmentBlock(out environment, userToken, false)) { int error = Marshal.GetLastWin32Error(); result.ErrorCode = error; result.ErrorMessage = "CreateEnvironmentBlock failed: " + new Win32Exception(error).Message; return result; } STARTUPINFO startup = new STARTUPINFO(); startup.cb = Marshal.SizeOf(typeof(STARTUPINFO)); startup.lpDesktop = "winsta0\\default"; string currentDirectory = workingDirectory; if (string.IsNullOrWhiteSpace(currentDirectory)) { currentDirectory = Path.GetDirectoryName(applicationPath); } StringBuilder commandLine = new StringBuilder(); commandLine.Append(QuoteCommandArgument(applicationPath)); if (!string.IsNullOrWhiteSpace(arguments)) { commandLine.Append(" "); commandLine.Append(arguments); } bool created = CreateProcessAsUserW( userToken, applicationPath, commandLine, IntPtr.Zero, IntPtr.Zero, false, CREATE_UNICODE_ENVIRONMENT, environment, currentDirectory, ref startup, out processInfo ); if (!created) { int error = Marshal.GetLastWin32Error(); result.ErrorCode = error; result.ErrorMessage = "CreateProcessAsUser failed: " + new Win32Exception(error).Message; return result; } result.Success = true; result.ProcessId = processInfo.dwProcessId; return result; } finally { if (processInfo.hThread != IntPtr.Zero) { CloseHandle(processInfo.hThread); } if (processInfo.hProcess != IntPtr.Zero) { CloseHandle(processInfo.hProcess); } if (environment != IntPtr.Zero) { DestroyEnvironmentBlock(environment); } if (userToken != IntPtr.Zero) { CloseHandle(userToken); } } } } """ def _manifest_bool(value: Any, default: bool = False) -> bool: if value is None: return bool(default) if isinstance(value, str): return value.strip().lower() in {"1", "true", "yes", "on"} return bool(value) def _clean_manifest_text(value: Any, limit: int) -> str: text = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]+", "", str(value or "")).strip() return text[:limit] def _post_install_values(manifest: dict[str, Any]) -> tuple[bool, str, str, bool, bool]: post_install = manifest.get("post_install") or {} if not isinstance(post_install, dict): post_install = {} return ( _manifest_bool(post_install.get("start_application"), False), _clean_manifest_text(post_install.get("executable"), 1024), _clean_manifest_text(post_install.get("arguments"), 2048), _manifest_bool(post_install.get("only_if_user_logged_on"), True), _manifest_bool(post_install.get("fail_job_on_error"), False), ) def _interactive_launch_ps_lines(manifest: dict[str, Any]) -> list[str]: start_application, executable, arguments, only_if_user_logged_on, fail_job_on_error = _post_install_values(manifest) if not start_application or not executable: return [] launcher_source = base64.b64encode(INTERACTIVE_LAUNCHER_CSHARP.encode("utf-8")).decode("ascii") return [ f"$postInstallExecutable = {_ps_quote(executable)}", f"$postInstallArguments = {_ps_quote(arguments)}", "$postInstallOnlyIfUserLoggedOn = $" + ("true" if only_if_user_logged_on else "false"), "$postInstallFailJobOnError = $" + ("true" if fail_job_on_error else "false"), "$postInstallStartStatus = 'pending'", "$postInstallStartUser = ''", "$postInstallStartPid = 0", "", "function Invoke-PackagePostInstallStart {", " if ($rebootRequired) {", " $script:postInstallStartStatus = 'skipped_reboot'", " Write-JobLog 'Application start skipped because a reboot is required.' | Out-Null", " return", " }", " $applicationPath = [Environment]::ExpandEnvironmentVariables([string]$postInstallExecutable)", " if (-not [System.IO.File]::Exists($applicationPath)) {", " $script:postInstallStartStatus = 'executable_not_found'", " $message = 'Post-install application executable not found: ' + $applicationPath", " Write-JobLog $message | Out-Null", " if ($postInstallFailJobOnError) { throw $message }", " return", " }", " if (-not ([System.Management.Automation.PSTypeName]'AssetManagerInteractiveLauncher').Type) {", f" $launcherSource = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String({_ps_quote(launcher_source)}))", " Add-Type -TypeDefinition $launcherSource -Language CSharp -ErrorAction Stop", " }", " $workingDirectory = [System.IO.Path]::GetDirectoryName($applicationPath)", " if ([string]::IsNullOrWhiteSpace($workingDirectory)) { $workingDirectory = $PSScriptRoot }", " $launchResult = [AssetManagerInteractiveLauncher]::Launch($applicationPath, [string]$postInstallArguments, $workingDirectory)", " if (-not $launchResult.UserFound) {", " $script:postInstallStartStatus = 'skipped_no_user'", " Write-JobLog 'No active interactive user session found. Application start skipped.' | Out-Null", " if (-not $postInstallOnlyIfUserLoggedOn -and $postInstallFailJobOnError) { throw 'No active interactive user session found for application start.' }", " return", " }", " $script:postInstallStartUser = [string]$launchResult.UserName", " Write-JobLog ('Logged-on user for application start: ' + [string]$launchResult.UserName + '; session=' + [string]$launchResult.SessionId) | Out-Null", " if (-not $launchResult.Success) {", " $script:postInstallStartStatus = 'failed'", " $message = 'Application start failed: ' + [string]$launchResult.ErrorMessage + '; Win32=' + [string]$launchResult.ErrorCode", " Write-JobLog $message | Out-Null", " if ($postInstallFailJobOnError) { throw $message }", " return", " }", " $script:postInstallStartStatus = 'started'", " $script:postInstallStartPid = [int]$launchResult.ProcessId", " Write-JobLog ('Application start requested successfully: ' + $applicationPath + '; PID=' + [string]$launchResult.ProcessId) | Out-Null", "}", "", ] 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 existing_post_install = result.get("post_install") start_configured = isinstance(existing_post_install, dict) and "start_application" in existing_post_install executable_configured = isinstance(existing_post_install, dict) and "executable" in existing_post_install post_install = result.setdefault("post_install", {}) if not isinstance(post_install, dict): post_install = {} result["post_install"] = post_install if "greenshot" in identity and not start_configured: post_install["start_application"] = True notes.append("greenshot_post_install_start") else: post_install["start_application"] = _manifest_bool(post_install.get("start_application"), False) if "greenshot" in identity and not executable_configured: post_install["executable"] = r"C:\Program Files\Greenshot\Greenshot.exe" else: post_install["executable"] = _clean_manifest_text(post_install.get("executable"), 1024) post_install["arguments"] = _clean_manifest_text(post_install.get("arguments"), 2048) post_install["only_if_user_logged_on"] = _manifest_bool(post_install.get("only_if_user_logged_on"), True) post_install["fail_job_on_error"] = _manifest_bool(post_install.get("fail_job_on_error"), False) 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 update_package_post_install( package_id: int, start_application: bool, executable: str, arguments: str = "", only_if_user_logged_on: bool = True, fail_job_on_error: bool = False, ) -> dict[str, Any]: manifest = load_package_manifest(package_id) manifest["post_install"] = { "start_application": bool(start_application), "executable": _clean_manifest_text(executable, 1024), "arguments": _clean_manifest_text(arguments, 2048), "only_if_user_logged_on": bool(only_if_user_logged_on), "fail_job_on_error": bool(fail_job_on_error), } 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", ) 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", "}", "", ] if action in {"install", "reinstall"}: lines += _interactive_launch_ps_lines(manifest) lines += [ "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.' }", ] if _interactive_launch_ps_lines(manifest): lines += [ "try { Invoke-PackagePostInstallStart }", "catch {", " $script:postInstallStartStatus = 'failed'", " $message = 'Post-install application start error: ' + [string]$_.Exception.Message", " Write-JobLog $message | Out-Null", " if ($postInstallFailJobOnError) { throw }", "}", ] 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.' }", ] if _interactive_launch_ps_lines(manifest): lines += [ "try { Invoke-PackagePostInstallStart }", "catch {", " $script:postInstallStartStatus = 'failed'", " $message = 'Post-install application start error: ' + [string]$_.Exception.Message", " Write-JobLog $message | Out-Null", " if ($postInstallFailJobOnError) { throw }", "}", ] 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 }", ] if action in {"install", "reinstall"} and _interactive_launch_ps_lines(manifest): lines += [ "$JobResult['post_install_start_status'] = $postInstallStartStatus", "if (-not [string]::IsNullOrWhiteSpace($postInstallStartUser)) { $JobResult['post_install_start_user'] = $postInstallStartUser }", "if ($postInstallStartPid -gt 0) { $JobResult['post_install_start_pid'] = $postInstallStartPid }", ] lines += [ "Write-JobLog (\"Software deployment completed: $deploymentPackage; action=$deploymentAction; reboot=$rebootRequired\")", ] return "\n".join(lines)