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/__init__.py +91 -0
- napt/build/__init__.py +47 -0
- napt/build/manager.py +1087 -0
- napt/build/packager.py +315 -0
- napt/build/template.py +301 -0
- napt/cli.py +602 -0
- napt/config/__init__.py +42 -0
- napt/config/loader.py +465 -0
- napt/core.py +385 -0
- napt/detection.py +630 -0
- napt/discovery/__init__.py +86 -0
- napt/discovery/api_github.py +445 -0
- napt/discovery/api_json.py +452 -0
- napt/discovery/base.py +244 -0
- napt/discovery/url_download.py +304 -0
- napt/discovery/web_scrape.py +467 -0
- napt/exceptions.py +149 -0
- napt/io/__init__.py +42 -0
- napt/io/download.py +357 -0
- napt/io/upload.py +37 -0
- napt/logging.py +230 -0
- napt/policy/__init__.py +50 -0
- napt/policy/updates.py +126 -0
- napt/psadt/__init__.py +43 -0
- napt/psadt/release.py +309 -0
- napt/requirements.py +566 -0
- napt/results.py +143 -0
- napt/state/__init__.py +58 -0
- napt/state/tracker.py +371 -0
- napt/validation.py +467 -0
- napt/versioning/__init__.py +115 -0
- napt/versioning/keys.py +309 -0
- napt/versioning/msi.py +725 -0
- napt-0.3.1.dist-info/METADATA +114 -0
- napt-0.3.1.dist-info/RECORD +38 -0
- napt-0.3.1.dist-info/WHEEL +4 -0
- napt-0.3.1.dist-info/entry_points.txt +3 -0
- napt-0.3.1.dist-info/licenses/LICENSE +202 -0
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
|