napt 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
napt/requirements.py ADDED
@@ -0,0 +1,566 @@
1
+ # Copyright 2025 Roger Cibrian
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Requirements script generation for Intune Win32 apps.
16
+
17
+ This module generates PowerShell requirements scripts for Intune Win32 app
18
+ deployments. Requirements scripts determine if the Update entry should be
19
+ applicable to a device based on whether an older version is installed.
20
+
21
+ Requirements Logic:
22
+ - Checks HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall (always)
23
+ - Checks HKCU:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall (always)
24
+ - Checks HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall
25
+ (only on 64-bit OS with 64-bit PowerShell process)
26
+ - Checks HKCU:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall
27
+ (only on 64-bit OS with 64-bit PowerShell process)
28
+ - Matches by DisplayName (using AppName from recipe or MSI ProductName)
29
+ - If installed version < target version: outputs "Required" and exits 0
30
+ - Otherwise: outputs nothing and exits 0
31
+
32
+ Installer Type Filtering:
33
+ Scripts filter registry entries based on installer type to prevent false
34
+ matches when both MSI and EXE versions of software exist:
35
+
36
+ - MSI installers (strict): Only matches registry entries with
37
+ WindowsInstaller=1. Prevents false matches with EXE versions.
38
+ - Non-MSI installers (permissive): Matches ANY registry entry. Handles
39
+ EXE installers that run embedded MSIs internally.
40
+
41
+ Logging:
42
+ - Primary (System): C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs\\NAPTRequirements.log
43
+ - Primary (User): C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs\\NAPTRequirementsUser.log
44
+ - Fallback (System): C:\\ProgramData\\NAPT\\NAPTRequirements.log
45
+ - Fallback (User): %LOCALAPPDATA%\\NAPT\\NAPTRequirementsUser.log
46
+ - Log rotation: 2-file rotation (.log and .log.old), configurable max size
47
+ (default: 3MB)
48
+ - Format: CMTrace format for compatibility with Intune diagnostics
49
+
50
+ Example:
51
+ Generate requirements script:
52
+ ```python
53
+ from pathlib import Path
54
+ from napt.requirements import RequirementsConfig, generate_requirements_script
55
+
56
+ config = RequirementsConfig(
57
+ app_name="Google Chrome",
58
+ version="131.0.6778.86",
59
+ )
60
+ script_path = generate_requirements_script(
61
+ config=config,
62
+ output_path=Path("builds/chrome/131.0.6778.86/Google-Chrome-131.0.6778.86-Requirements.ps1"),
63
+ )
64
+ ```
65
+
66
+ Note:
67
+ Requirements scripts are saved as siblings to the packagefiles directory
68
+ to prevent them from being included in the .intunewin package. They
69
+ should be uploaded separately to Intune as a custom requirement rule
70
+ with output type String, operator Equals, value "Required".
71
+
72
+ """
73
+
74
+ from __future__ import annotations
75
+
76
+ from dataclasses import dataclass
77
+ from pathlib import Path
78
+ import string
79
+ from typing import Literal
80
+
81
+ LogFormat = Literal["cmtrace"]
82
+ LogLevel = Literal["INFO", "WARNING", "ERROR", "DEBUG"]
83
+
84
+
85
+ # Type alias for architecture values
86
+ ArchitectureMode = Literal["x86", "x64", "arm64", "any"]
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class RequirementsConfig:
91
+ """Configuration for requirements script generation.
92
+
93
+ Attributes:
94
+ app_name: Application name to search for in registry DisplayName.
95
+ version: Target version string (requirement met if installed < this).
96
+ log_format: Log format (currently only "cmtrace" supported).
97
+ log_level: Minimum log level (INFO, WARNING, ERROR, DEBUG).
98
+ log_rotation_mb: Maximum log file size in MB before rotation.
99
+ app_id: Application ID (used for fallback if app_name sanitization
100
+ results in empty string).
101
+ is_msi_installer: If True, only match MSI-based registry entries.
102
+ If False, only match non-MSI entries. This prevents false matches
103
+ when both MSI and EXE versions of software exist with the same
104
+ DisplayName.
105
+ expected_architecture: Architecture filter for registry view selection.
106
+ - "x86": Check only 32-bit registry view
107
+ - "x64": Check only 64-bit registry view
108
+ - "arm64": Check only 64-bit registry view (ARM64 uses 64-bit registry)
109
+ - "any": Check both 32-bit and 64-bit views (permissive)
110
+ use_wildcard: If True, use PowerShell -like operator for DisplayName
111
+ matching (supports * and ? wildcards). If False, use exact -eq match.
112
+
113
+ """
114
+
115
+ app_name: str
116
+ version: str
117
+ log_format: LogFormat = "cmtrace"
118
+ log_level: LogLevel = "INFO"
119
+ log_rotation_mb: int = 3
120
+ app_id: str = ""
121
+ is_msi_installer: bool = False
122
+ expected_architecture: ArchitectureMode = "any"
123
+ use_wildcard: bool = False
124
+
125
+
126
+ # PowerShell requirements script template
127
+ # TODO: Move this template to a separate .ps1 file for syntax highlighting and easier editing
128
+ _REQUIREMENTS_SCRIPT_TEMPLATE = """# Requirements script for ${app_name} ${version}
129
+ # Generated by NAPT (Not a Package Tool)
130
+ # This script checks if an older version is installed (for Update entry applicability).
131
+ # Outputs "Required" if installed version < target version, nothing otherwise.
132
+ # Always exits with code 0 so Intune can evaluate STDOUT.
133
+ # Uses explicit registry views for deterministic architecture-aware detection.
134
+
135
+ param(
136
+ [string]$$AppName = "${app_name}",
137
+ [string]$$TargetVersion = "${version}",
138
+ [bool]$$IsMSIInstaller = ${is_msi_installer},
139
+ [string]$$ExpectedArchitecture = "${expected_architecture}"
140
+ )
141
+
142
+ # CMTrace log format function
143
+ function Write-CMTraceLog {
144
+ param(
145
+ [string]$$Message,
146
+ [string]$$Component = $$script:ComponentName,
147
+ [string]$$Type = "INFO" # "INFO", "WARNING", "ERROR", "DEBUG"
148
+ )
149
+
150
+ $$LogFile = $$script:LogFilePath
151
+
152
+ if (-not $$LogFile) {
153
+ return
154
+ }
155
+
156
+ # Convert string log level to CMTrace numeric type
157
+ # 1=Info, 2=Warning, 3=Error, 4=Debug
158
+ $$TypeNumber = switch ($$Type.ToUpper()) {
159
+ "INFO" { 1 }
160
+ "WARNING" { 2 }
161
+ "ERROR" { 3 }
162
+ "DEBUG" { 4 }
163
+ default { 1 } # Default to INFO if unknown
164
+ }
165
+
166
+ # Format time: HH:mm:ss.fff-offset (offset in minutes, e.g., -480 for -08:00)
167
+ $$Now = [DateTimeOffset](Get-Date)
168
+ $$TimeFormatted = $$Now.ToString("HH:mm:ss.fff")
169
+ $$OffsetMinutes = [int]$$Now.Offset.TotalMinutes
170
+ $$TimeWithOffset = "$$TimeFormatted$$OffsetMinutes"
171
+
172
+ # Format date: M-d-yyyy (single digit month/day when appropriate)
173
+ $$DateFormatted = $$Now.ToString("M-d-yyyy")
174
+
175
+ # Get context (user identity name) and script file path
176
+ $$ContextName = if ($$script:CurrentIdentity) { $$script:CurrentIdentity.Name } else { "UNKNOWN" }
177
+ $$ScriptFile = if ($$MyInvocation.ScriptName) { $$MyInvocation.ScriptName } else { "requirements.ps1" }
178
+
179
+ $$Line = "<![LOG[$$Message]LOG]!><time=""$$TimeWithOffset"" date=""$$DateFormatted"" component=""$$Component"" context=""$$ContextName"" type=""$$TypeNumber"" thread=""$$PID"" file=""$$ScriptFile"">"
180
+
181
+ try {
182
+ Add-Content -Path $$LogFile -Value $$Line -Encoding UTF8 -ErrorAction SilentlyContinue
183
+ } catch {
184
+ # Silently fail if we can't write to log
185
+ }
186
+ }
187
+
188
+ # Determine log file location
189
+ function Initialize-LogFile {
190
+ $$script:CurrentIdentity = [System.Security.Principal.WindowsIdentity]::GetCurrent()
191
+ $$IsSystemContext = $$script:CurrentIdentity.Name -eq "NT AUTHORITY\\SYSTEM"
192
+
193
+ if ($$IsSystemContext) {
194
+ # System context - try Intune log folder first
195
+ $$PrimaryLogDir = "C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs"
196
+ $$PrimaryLogFile = Join-Path $$PrimaryLogDir "NAPTRequirements.log"
197
+ $$FallbackLogDir = "C:\\ProgramData\\NAPT"
198
+ $$FallbackLogFile = Join-Path $$FallbackLogDir "NAPTRequirements.log"
199
+ } else {
200
+ # User context
201
+ $$PrimaryLogDir = "C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs"
202
+ $$PrimaryLogFile = Join-Path $$PrimaryLogDir "NAPTRequirementsUser.log"
203
+ $$FallbackLogDir = $$env:LOCALAPPDATA
204
+ $$FallbackLogFile = Join-Path $$FallbackLogDir "NAPT\\NAPTRequirementsUser.log"
205
+ }
206
+
207
+ # Try primary location first
208
+ try {
209
+ # Ensure parent directory exists (fails with -ErrorAction Stop if no perms)
210
+ $$PrimaryLogParent = Split-Path -Path $$PrimaryLogFile -Parent
211
+ if (-not (Test-Path -Path $$PrimaryLogParent)) {
212
+ New-Item -Path $$PrimaryLogParent -ItemType Directory -Force -ErrorAction Stop | Out-Null
213
+ }
214
+
215
+ # Handle log rotation if needed
216
+ if (Test-Path -Path $$PrimaryLogFile) {
217
+ $$LogSize = (Get-Item $$PrimaryLogFile).Length
218
+ $$MaxSize = ${log_rotation_mb} * 1024 * 1024
219
+ if ($$LogSize -ge $$MaxSize) {
220
+ $$OldLogFile = "$$PrimaryLogFile.old"
221
+ if (Test-Path $$OldLogFile) { Remove-Item $$OldLogFile -Force -ErrorAction Stop }
222
+ Move-Item -Path $$PrimaryLogFile -Destination $$OldLogFile -Force -ErrorAction Stop
223
+ }
224
+ }
225
+
226
+ # Verify write access (appends empty string - fails if no write permission)
227
+ [System.IO.File]::AppendAllText($$PrimaryLogFile, "")
228
+ $$script:LogFilePath = $$PrimaryLogFile
229
+ return
230
+ } catch {
231
+ # Fall through to fallback (directory creation, rotation, or write failed)
232
+ }
233
+
234
+ # Fallback location
235
+ try {
236
+ $$FallbackLogParent = Split-Path -Path $$FallbackLogFile -Parent
237
+ if (-not (Test-Path -Path $$FallbackLogParent)) {
238
+ New-Item -Path $$FallbackLogParent -ItemType Directory -Force -ErrorAction Stop | Out-Null
239
+ }
240
+
241
+ if (Test-Path -Path $$FallbackLogFile) {
242
+ $$LogSize = (Get-Item $$FallbackLogFile).Length
243
+ $$MaxSize = ${log_rotation_mb} * 1024 * 1024
244
+ if ($$LogSize -ge $$MaxSize) {
245
+ $$OldLogFile = "$$FallbackLogFile.old"
246
+ if (Test-Path $$OldLogFile) { Remove-Item $$OldLogFile -Force -ErrorAction Stop }
247
+ Move-Item -Path $$FallbackLogFile -Destination $$OldLogFile -Force -ErrorAction Stop
248
+ }
249
+ }
250
+
251
+ [System.IO.File]::AppendAllText($$FallbackLogFile, "")
252
+ $$script:LogFilePath = $$FallbackLogFile
253
+ } catch {
254
+ # All log locations failed - log warning to stderr and continue
255
+ Write-Warning "NAPT Requirements: Failed to initialize logging (primary and fallback locations unavailable). Script will continue but no log file will be created."
256
+ $$script:LogFilePath = $$null
257
+ }
258
+ }
259
+
260
+ # Check if registry entry is MSI-based installation
261
+ function Test-IsMSIInstallation {
262
+ param(
263
+ [Microsoft.Win32.RegistryKey]$$RegKey
264
+ )
265
+
266
+ # Check WindowsInstaller DWORD value - set automatically by Windows Installer
267
+ # for all MSI installations. This is the authoritative indicator.
268
+ try {
269
+ $$WindowsInstaller = $$RegKey.GetValue("WindowsInstaller")
270
+ return ($$WindowsInstaller -eq 1)
271
+ } catch {
272
+ return $$false
273
+ }
274
+ }
275
+
276
+ # Get registry keys using explicit RegistryView for deterministic behavior
277
+ # This works regardless of PowerShell process bitness
278
+ function Get-UninstallKeys {
279
+ param(
280
+ [Microsoft.Win32.RegistryHive]$$Hive,
281
+ [Microsoft.Win32.RegistryView]$$View,
282
+ [string]$$ViewName
283
+ )
284
+
285
+ $$Results = @()
286
+ $$UninstallPath = "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall"
287
+
288
+ try {
289
+ $$BaseKey = [Microsoft.Win32.RegistryKey]::OpenBaseKey($$Hive, $$View)
290
+ $$UninstallKey = $$BaseKey.OpenSubKey($$UninstallPath)
291
+
292
+ if ($$UninstallKey) {
293
+ foreach ($$SubKeyName in $$UninstallKey.GetSubKeyNames()) {
294
+ try {
295
+ $$SubKey = $$UninstallKey.OpenSubKey($$SubKeyName)
296
+ if ($$SubKey) {
297
+ $$Results += @{
298
+ Key = $$SubKey
299
+ Hive = $$Hive
300
+ View = $$ViewName
301
+ Path = "$$($$(if ($$Hive -eq [Microsoft.Win32.RegistryHive]::LocalMachine) { 'HKLM' } else { 'HKCU' })):\\$$UninstallPath\\$$SubKeyName"
302
+ }
303
+ }
304
+ } catch {
305
+ # Skip keys we can't open
306
+ }
307
+ }
308
+ }
309
+
310
+ # Note: Don't close BaseKey/UninstallKey here as SubKeys are still in use
311
+ } catch {
312
+ Write-CMTraceLog -Message "[Requirements] Error opening registry: $$Hive $$ViewName - $$($$_.Exception.Message)" -Type "WARNING"
313
+ }
314
+
315
+ return $$Results
316
+ }
317
+
318
+ # Version comparison function - returns true if Installed < Target
319
+ function Compare-VersionLessThan {
320
+ param(
321
+ [string]$$InstalledVersion,
322
+ [string]$$TargetVersion
323
+ )
324
+
325
+ # Parse version parts
326
+ $$InstalledParts = $$InstalledVersion -split '[.\\-]' | ForEach-Object { [int]$$_ }
327
+ $$TargetParts = $$TargetVersion -split '[.\\-]' | ForEach-Object { [int]$$_ }
328
+
329
+ $$MaxLength = [Math]::Max($$InstalledParts.Count, $$TargetParts.Count)
330
+
331
+ for ($$i = 0; $$i -lt $$MaxLength; $$i++) {
332
+ $$InstalledPart = if ($$i -lt $$InstalledParts.Count) { $$InstalledParts[$$i] } else { 0 }
333
+ $$TargetPart = if ($$i -lt $$TargetParts.Count) { $$TargetParts[$$i] } else { 0 }
334
+
335
+ if ($$InstalledPart -lt $$TargetPart) {
336
+ return $$true
337
+ }
338
+ if ($$InstalledPart -gt $$TargetPart) {
339
+ return $$false
340
+ }
341
+ }
342
+
343
+ return $$false # Versions are equal, so not less than
344
+ }
345
+
346
+ # Main requirements logic
347
+ Initialize-LogFile
348
+
349
+ # Build component identifier from app name and version (sanitized for valid identifier)
350
+ $$SanitizedAppName = $$AppName -replace '[^a-zA-Z0-9]', '-' -replace '-+', '-' -replace '^-|-$$', ''
351
+ $$script:ComponentName = "$$SanitizedAppName-$$TargetVersion-Requirements"
352
+
353
+ Write-CMTraceLog -Message "[Initialization] Requirements script running as user: $$($$script:CurrentIdentity.Name)" -Type "INFO"
354
+ Write-CMTraceLog -Message "[Initialization] Starting requirements check for: $$AppName (Target: $$TargetVersion, Installer Type: $$(if ($$IsMSIInstaller) { 'MSI' } else { 'Non-MSI' }), Architecture: $$ExpectedArchitecture)" -Type "INFO"
355
+
356
+ # Detect OS architecture
357
+ $$Is64BitOS = [Environment]::Is64BitOperatingSystem
358
+
359
+ Write-CMTraceLog -Message "[Initialization] OS Architecture: $$(if ($$Is64BitOS) { '64-bit' } else { '32-bit' })" -Type "INFO"
360
+ Write-CMTraceLog -Message "[Initialization] PowerShell Process: $$(if ([Environment]::Is64BitProcess) { '64-bit' } else { '32-bit' })" -Type "INFO"
361
+
362
+ # Build list of registry keys to check based on expected architecture
363
+ # Uses OpenBaseKey with explicit RegistryView for deterministic behavior
364
+ $$AllKeys = @()
365
+
366
+ # Determine which registry views to check based on architecture
367
+ $$CheckViews = @()
368
+
369
+ switch ($$ExpectedArchitecture.ToLower()) {
370
+ "x64" {
371
+ if (-not $$Is64BitOS) {
372
+ Write-CMTraceLog -Message "[Requirements] Expected x64 architecture but running on 32-bit OS - app cannot be installed" -Type "WARNING"
373
+ } else {
374
+ $$CheckViews += @{ View = [Microsoft.Win32.RegistryView]::Registry64; Name = "64-bit" }
375
+ }
376
+ }
377
+ "arm64" {
378
+ # ARM64 uses 64-bit registry view
379
+ if (-not $$Is64BitOS) {
380
+ Write-CMTraceLog -Message "[Requirements] Expected arm64 architecture but running on 32-bit OS - app cannot be installed" -Type "WARNING"
381
+ } else {
382
+ $$CheckViews += @{ View = [Microsoft.Win32.RegistryView]::Registry64; Name = "64-bit (ARM64)" }
383
+ }
384
+ }
385
+ "x86" {
386
+ $$CheckViews += @{ View = [Microsoft.Win32.RegistryView]::Registry32; Name = "32-bit" }
387
+ }
388
+ "any" {
389
+ # Check both views (permissive mode)
390
+ if ($$Is64BitOS) {
391
+ $$CheckViews += @{ View = [Microsoft.Win32.RegistryView]::Registry64; Name = "64-bit" }
392
+ }
393
+ $$CheckViews += @{ View = [Microsoft.Win32.RegistryView]::Registry32; Name = "32-bit" }
394
+ }
395
+ default {
396
+ Write-CMTraceLog -Message "[Requirements] Unknown architecture '$$ExpectedArchitecture', defaulting to 'any'" -Type "WARNING"
397
+ if ($$Is64BitOS) {
398
+ $$CheckViews += @{ View = [Microsoft.Win32.RegistryView]::Registry64; Name = "64-bit" }
399
+ }
400
+ $$CheckViews += @{ View = [Microsoft.Win32.RegistryView]::Registry32; Name = "32-bit" }
401
+ }
402
+ }
403
+
404
+ # Build literal registry paths for logging (matches actual paths opened by OpenBaseKey per view)
405
+ $$RegPathDescriptions = @()
406
+ foreach ($$ViewInfo in $$CheckViews) {
407
+ if ($$ViewInfo.Name -like '64-bit*') {
408
+ $$RegPathDescriptions += "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall"
409
+ $$RegPathDescriptions += "HKCU:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall"
410
+ } else {
411
+ if ($$Is64BitOS) {
412
+ $$RegPathDescriptions += "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall"
413
+ $$RegPathDescriptions += "HKCU:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall"
414
+ } else {
415
+ $$RegPathDescriptions += "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall"
416
+ $$RegPathDescriptions += "HKCU:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall"
417
+ }
418
+ }
419
+ }
420
+ Write-CMTraceLog -Message "[Initialization] Registry paths to check: $$($$RegPathDescriptions -join ', ')" -Type "INFO"
421
+
422
+ # Collect all registry keys from selected views
423
+ foreach ($$ViewInfo in $$CheckViews) {
424
+ # HKLM (machine-level installations)
425
+ $$AllKeys += Get-UninstallKeys -Hive ([Microsoft.Win32.RegistryHive]::LocalMachine) -View $$ViewInfo.View -ViewName $$ViewInfo.Name
426
+ # HKCU (user-level installations)
427
+ $$AllKeys += Get-UninstallKeys -Hive ([Microsoft.Win32.RegistryHive]::CurrentUser) -View $$ViewInfo.View -ViewName $$ViewInfo.Name
428
+ }
429
+
430
+ Write-CMTraceLog -Message "[Initialization] Found $$($$AllKeys.Count) registry keys to check" -Type "INFO"
431
+
432
+ $$FoundOlderVersion = $$false
433
+ $$InstalledVersion = $$null
434
+ $$DisplayName = $$null
435
+
436
+ # Check all registry keys to find matching DisplayName and version
437
+ foreach ($$KeyInfo in $$AllKeys) {
438
+ try {
439
+ $$Key = $$KeyInfo.Key
440
+ $$DisplayNameValue = $$Key.GetValue("DisplayName")
441
+ $$VersionValue = $$Key.GetValue("DisplayVersion")
442
+
443
+ if ($$DisplayNameValue ${display_name_operator} $$AppName) {
444
+ $$RegKeyPath = $$KeyInfo.Path
445
+
446
+ # Check if installer type matches (MSI vs non-MSI)
447
+ # MSI installers: strict matching - only accept MSI registry entries
448
+ # Non-MSI installers: permissive - accept any entry (EXEs may use embedded MSIs)
449
+ $$IsMSIEntry = Test-IsMSIInstallation -RegKey $$Key
450
+ if ($$IsMSIInstaller -and -not $$IsMSIEntry) {
451
+ # Building from MSI, but registry entry is not MSI - skip
452
+ Write-CMTraceLog -Message "[Requirements] Type mismatch: $$DisplayNameValue (Found: Non-MSI, Expected: MSI, Path: $$RegKeyPath)" -Type "INFO"
453
+ continue
454
+ }
455
+ # Note: Non-MSI installers accept ANY registry entry (MSI or non-MSI)
456
+ # because EXE installers often wrap embedded MSIs that set WindowsInstaller=1
457
+
458
+ $$DisplayName = $$DisplayNameValue
459
+ $$InstalledVersion = $$VersionValue
460
+
461
+ Write-CMTraceLog -Message "[Requirements] Match found: $$DisplayName (Found: $$(if ($$InstalledVersion) { $$InstalledVersion } else { 'None' }), Type: $$(if ($$IsMSIEntry) { 'MSI' } else { 'Non-MSI' }), Arch: $$($KeyInfo.View), Path: $$RegKeyPath)" -Type "INFO"
462
+
463
+ if ($$InstalledVersion) {
464
+ if (Compare-VersionLessThan -InstalledVersion $$InstalledVersion -TargetVersion $$TargetVersion) {
465
+ Write-CMTraceLog -Message "[Requirements] Version check passed: $$InstalledVersion < $$TargetVersion" -Type "INFO"
466
+ $$FoundOlderVersion = $$true
467
+ break
468
+ } else {
469
+ Write-CMTraceLog -Message "[Requirements] Version check not met: $$InstalledVersion >= $$TargetVersion" -Type "INFO"
470
+ }
471
+ } else {
472
+ Write-CMTraceLog -Message "[Requirements] No version found: $$DisplayName (Path: $$RegKeyPath)" -Type "WARNING"
473
+ }
474
+ }
475
+ } catch {
476
+ Write-CMTraceLog -Message "[Requirements] Error checking registry path $$($KeyInfo.Path) : $$($$_.Exception.Message)" -Type "ERROR"
477
+ }
478
+ }
479
+
480
+ # Always exit 0 - Intune evaluates STDOUT
481
+ if ($$FoundOlderVersion) {
482
+ Write-CMTraceLog -Message "[Result] Update Required: $$AppName (Found: $$InstalledVersion, Expected: < $$TargetVersion, Type: $$(if ($$IsMSIInstaller) { 'MSI' } else { 'Non-MSI' }), Arch: $$ExpectedArchitecture)" -Type "INFO"
483
+ Write-Output "Required"
484
+ exit 0
485
+ } else {
486
+ $$FoundVersion = if ($$InstalledVersion) { $$InstalledVersion } else { "None" }
487
+ Write-CMTraceLog -Message "[Result] Update Not Required: $$AppName (Found: $$FoundVersion, Expected: < $$TargetVersion, Type: $$(if ($$IsMSIInstaller) { 'MSI' } else { 'Non-MSI' }), Arch: $$ExpectedArchitecture)" -Type "WARNING"
488
+ # Output nothing - requirement not met
489
+ exit 0
490
+ }
491
+ """
492
+
493
+
494
+ def generate_requirements_script(config: RequirementsConfig, output_path: Path) -> Path:
495
+ """Generate PowerShell requirements script for Intune Win32 app.
496
+
497
+ Creates a PowerShell script that checks Windows uninstall registry keys
498
+ for software installation and determines if an older version is installed.
499
+ The script outputs "Required" if installed version < target version,
500
+ nothing otherwise. Always exits with code 0 so Intune can evaluate STDOUT.
501
+
502
+ Args:
503
+ config: Requirements configuration (app name, version, logging settings).
504
+ output_path: Path where the requirements script will be saved.
505
+
506
+ Returns:
507
+ Path to the generated requirements script.
508
+
509
+ Raises:
510
+ OSError: If the script file cannot be written.
511
+
512
+ Example:
513
+ Generate script with default settings:
514
+ ```python
515
+ from pathlib import Path
516
+ from napt.requirements import RequirementsConfig, generate_requirements_script
517
+
518
+ config = RequirementsConfig(
519
+ app_name="Google Chrome",
520
+ version="131.0.6778.86",
521
+ )
522
+ script_path = generate_requirements_script(
523
+ config,
524
+ Path("requirements.ps1")
525
+ )
526
+ ```
527
+
528
+ Note:
529
+ The script is saved with UTF-8 BOM encoding for proper PowerShell
530
+ execution on Windows systems.
531
+
532
+ """
533
+ from napt.logging import get_global_logger
534
+
535
+ logger = get_global_logger()
536
+
537
+ logger.verbose(
538
+ "REQUIREMENTS", f"Generating requirements script: {output_path.name}"
539
+ )
540
+
541
+ # Generate script content from template
542
+ # Use safe_substitute() so PowerShell variables ($$Variable) are preserved
543
+ # as $Variable without raising KeyError for missing placeholders
544
+ script_content = string.Template(_REQUIREMENTS_SCRIPT_TEMPLATE).safe_substitute(
545
+ app_name=config.app_name,
546
+ version=config.version,
547
+ log_rotation_mb=config.log_rotation_mb,
548
+ is_msi_installer="$True" if config.is_msi_installer else "$False",
549
+ expected_architecture=config.expected_architecture,
550
+ display_name_operator="-like" if config.use_wildcard else "-eq",
551
+ )
552
+
553
+ # Ensure output directory exists
554
+ output_path.parent.mkdir(parents=True, exist_ok=True)
555
+
556
+ # Write script with UTF-8 BOM encoding (required for PowerShell)
557
+ try:
558
+ script_bytes = script_content.encode("utf-8-sig")
559
+ output_path.write_bytes(script_bytes)
560
+ logger.verbose("REQUIREMENTS", f"Requirements script written to: {output_path}")
561
+ except OSError as err:
562
+ raise OSError(
563
+ f"Failed to write requirements script to {output_path}: {err}"
564
+ ) from err
565
+
566
+ return output_path