Skip to content

Commit d4a4cb0

Browse files
committed
Add LOB (Managed PKG) app deployment visibility via native MDM channel
Adds visibility into Intune LOB apps (managed PKGs) deployed via Apple's native MDM InstallApplication command, which are invisible in the existing IntuneMDMDaemon log parsing. Data sources: - macOS unified log (com.apple.ManagedClient / InstallApplication) - /var/log/install.log - /Library/Receipts/InstallHistory.plist UI: LOB Installs is a 4th filter button (Cmd+4) in the existing sidebar alongside Sync, Recurring, and Health events. Selecting a LOB event shows a detail view with deployment lifecycle timeline, receipt info, and correlated log entries. LOB stats appear in the Analysis Summary header at a glance. Detection filters: - Unified log predicate targets only InstallApplication category events and storedownloadd - Correlation engine skips entries without command UUID - InstallHistory parser accepts only appstored, mdmclient, and GUID-named installer entries New files: LOBModels, UnifiedLogReader, InstallLogParser, InstallHistoryParser, LOBCorrelationEngine, LOBSidebarView, LOBDetailView, DeploymentLifecycleView Modified: Models (DeploymentChannel enum), LogParser (channel tag), ViewController (unified sidebar, LOB filter), ClipLibrary, PolicyDetailView, SyncEventDetailView (channel badges)
1 parent 11e7bb2 commit d4a4cb0

16 files changed

Lines changed: 2359 additions & 88 deletions

IntuneLogWatch.xcodeproj/project.pbxproj

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,10 +115,17 @@
115115
CertificateInspector.swift,
116116
ClipLibrary.swift,
117117
ClipLibraryView.swift,
118+
DeploymentLifecycleView.swift,
118119
ErrorCodeDetailView.swift,
119120
ErrorCodesReferenceViewSimple.swift,
121+
InstallHistoryParser.swift,
122+
InstallLogParser.swift,
120123
IntuneErrorCodes.swift,
121124
IntuneLogWatchApp.swift,
125+
LOBCorrelationEngine.swift,
126+
LOBDetailView.swift,
127+
LOBModels.swift,
128+
LOBSidebarView.swift,
122129
LogEntryDetailView.swift,
123130
LogParser.swift,
124131
Models.swift,
@@ -127,6 +134,7 @@
127134
PolicyExportHelper.swift,
128135
SyncEventDetailView.swift,
129136
TooltipView.swift,
137+
UnifiedLogReader.swift,
130138
ViewController.swift,
131139
);
132140
target = 1B35FC5B2EDD0A0200F4EE46 /* IntuneLogWatchQuickLook */;

IntuneLogWatch/ClipLibrary.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ struct PolicyExecutionSnapshot: Codable, Hashable {
6262
let scriptType: String?
6363
let executionContext: String?
6464
let healthDomain: String?
65+
let deploymentChannel: String?
6566

6667
init(from policy: PolicyExecution) {
6768
self.policyId = policy.policyId
@@ -83,6 +84,7 @@ struct PolicyExecutionSnapshot: Codable, Hashable {
8384
self.scriptType = policy.scriptType
8485
self.executionContext = policy.executionContext
8586
self.healthDomain = policy.healthDomain
87+
self.deploymentChannel = policy.deploymentChannel.rawValue
8688
}
8789

8890
// Convert back to PolicyExecution for viewing
@@ -97,6 +99,7 @@ struct PolicyExecutionSnapshot: Codable, Hashable {
9799
scriptType: scriptType,
98100
executionContext: executionContext,
99101
healthDomain: healthDomain,
102+
deploymentChannel: DeploymentChannel(rawValue: deploymentChannel ?? "Agent") ?? .agent,
100103
status: status.toPolicyStatus(),
101104
startTime: startTime,
102105
endTime: endTime,
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
//
2+
// DeploymentLifecycleView.swift
3+
// IntuneLogWatch
4+
//
5+
// Visual lifecycle pipeline component showing LOB deployment stages.
6+
//
7+
8+
import SwiftUI
9+
10+
struct DeploymentLifecycleView: View {
11+
let stages: [LOBLifecycleStageInfo]
12+
13+
var body: some View {
14+
HStack(spacing: 0) {
15+
ForEach(Array(stages.enumerated()), id: \.element.id) { index, stage in
16+
stageView(stage)
17+
18+
if index < stages.count - 1 {
19+
connector(
20+
from: stage.status,
21+
to: stages[index + 1].status
22+
)
23+
}
24+
}
25+
}
26+
.padding(.vertical, 8)
27+
}
28+
29+
private func stageView(_ stage: LOBLifecycleStageInfo) -> some View {
30+
VStack(spacing: 4) {
31+
ZStack {
32+
Circle()
33+
.fill(stageColor(stage.status).opacity(0.15))
34+
.frame(width: 40, height: 40)
35+
36+
Image(systemName: stageIcon(stage))
37+
.foregroundColor(stageColor(stage.status))
38+
.font(.system(size: 16))
39+
}
40+
41+
Text(stage.stage.rawValue)
42+
.font(.caption2)
43+
.foregroundColor(.secondary)
44+
.lineLimit(1)
45+
46+
if let timestamp = stage.timestamp {
47+
Text(formatTime(timestamp))
48+
.font(.caption2)
49+
.foregroundColor(.secondary)
50+
}
51+
}
52+
.frame(minWidth: 70)
53+
}
54+
55+
private func connector(from: LOBDeploymentStatus, to: LOBDeploymentStatus) -> some View {
56+
Rectangle()
57+
.fill(connectorColor(from: from, to: to))
58+
.frame(height: 2)
59+
.frame(maxWidth: 30)
60+
.padding(.bottom, 24) // Align with circle center
61+
}
62+
63+
private func stageIcon(_ stage: LOBLifecycleStageInfo) -> String {
64+
switch stage.status {
65+
case .completed:
66+
return "checkmark.circle.fill"
67+
case .failed:
68+
return "xmark.circle.fill"
69+
case .installing, .downloading:
70+
return "arrow.clockwise"
71+
case .pending:
72+
return stage.stage.icon
73+
case .unknown:
74+
return "circle.dotted"
75+
}
76+
}
77+
78+
private func stageColor(_ status: LOBDeploymentStatus) -> Color {
79+
switch status {
80+
case .completed: return .green
81+
case .failed: return .red
82+
case .installing, .downloading: return .blue
83+
case .pending: return .orange
84+
case .unknown: return .secondary
85+
}
86+
}
87+
88+
private func connectorColor(from: LOBDeploymentStatus, to: LOBDeploymentStatus) -> Color {
89+
if from == .completed {
90+
return .green
91+
}
92+
if from == .failed {
93+
return .red
94+
}
95+
return .secondary.opacity(0.3)
96+
}
97+
98+
private func formatTime(_ date: Date) -> String {
99+
let formatter = DateFormatter()
100+
formatter.timeStyle = .medium
101+
return formatter.string(from: date)
102+
}
103+
}
104+
105+
// MARK: - Helper to build lifecycle stages from a LOBAppEvent
106+
107+
extension LOBAppEvent {
108+
var lifecycleStages: [LOBLifecycleStageInfo] {
109+
var stages: [LOBLifecycleStageInfo] = []
110+
111+
// MDM Command stage
112+
let mdmEntries = unifiedLogEntries.filter {
113+
$0.message.lowercased().contains("installapplication") ||
114+
$0.message.lowercased().contains("mdm command") ||
115+
$0.message.lowercased().contains("received command")
116+
}
117+
let mdmStatus: LOBDeploymentStatus = mdmEntries.isEmpty ? .unknown : .completed
118+
stages.append(LOBLifecycleStageInfo(
119+
stage: .mdmCommand,
120+
status: mdmStatus,
121+
timestamp: mdmEntries.first?.timestamp ?? (status != .unknown ? timestamp : nil),
122+
entries: mdmEntries,
123+
errorMessage: nil
124+
))
125+
126+
// Download stage
127+
let downloadEntries = unifiedLogEntries.filter {
128+
$0.message.lowercased().contains("download") ||
129+
$0.process.lowercased() == "storedownloadd"
130+
}
131+
let downloadStatus: LOBDeploymentStatus
132+
if downloadEntries.contains(where: { $0.level == .error || $0.level == .fault }) {
133+
downloadStatus = .failed
134+
} else if !downloadEntries.isEmpty {
135+
downloadStatus = .completed
136+
} else if mdmStatus == .completed {
137+
downloadStatus = status == .unknown ? .unknown : .completed // Assume download happened
138+
} else {
139+
downloadStatus = .unknown
140+
}
141+
stages.append(LOBLifecycleStageInfo(
142+
stage: .download,
143+
status: downloadStatus,
144+
timestamp: downloadEntries.first?.timestamp,
145+
entries: downloadEntries,
146+
errorMessage: downloadEntries.first(where: { $0.level == .error })?.message
147+
))
148+
149+
// Installation stage
150+
let installEntries = unifiedLogEntries.filter {
151+
$0.message.lowercased().contains("install") &&
152+
!$0.message.lowercased().contains("installapplication")
153+
}
154+
let allInstallEntries = installEntries + unifiedLogEntries.filter { $0.process.lowercased() == "installer" }
155+
let installStatus: LOBDeploymentStatus
156+
if allInstallEntries.contains(where: { $0.level == .error || $0.level == .fault }) {
157+
installStatus = .failed
158+
} else if !allInstallEntries.isEmpty || !installLogEntries.isEmpty {
159+
installStatus = .completed
160+
} else if downloadStatus == .completed && status == .completed {
161+
installStatus = .completed
162+
} else {
163+
installStatus = status == .failed ? .failed : .unknown
164+
}
165+
stages.append(LOBLifecycleStageInfo(
166+
stage: .installation,
167+
status: installStatus,
168+
timestamp: allInstallEntries.first?.timestamp ?? installLogEntries.first?.timestamp,
169+
entries: allInstallEntries,
170+
errorMessage: allInstallEntries.first(where: { $0.level == .error })?.message
171+
))
172+
173+
// Verification stage
174+
let verificationStatus: LOBDeploymentStatus
175+
if receiptInfo != nil {
176+
verificationStatus = .completed
177+
} else if status == .completed {
178+
verificationStatus = .completed
179+
} else if status == .failed {
180+
verificationStatus = .failed
181+
} else {
182+
verificationStatus = .unknown
183+
}
184+
stages.append(LOBLifecycleStageInfo(
185+
stage: .verification,
186+
status: verificationStatus,
187+
timestamp: receiptInfo?.installDate,
188+
entries: [],
189+
errorMessage: nil
190+
))
191+
192+
return stages
193+
}
194+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
//
2+
// InstallHistoryParser.swift
3+
// IntuneLogWatch
4+
//
5+
// Parses /Library/Receipts/InstallHistory.plist for MDM-pushed installs.
6+
//
7+
8+
import Foundation
9+
10+
class InstallHistoryParser {
11+
12+
// MARK: - Public API
13+
14+
/// Parse InstallHistory.plist and return MDM-pushed install receipts
15+
func parseMDMInstalls(since date: Date? = nil) throws -> [LOBPackageReceipt] {
16+
let path = "/Library/Receipts/InstallHistory.plist"
17+
18+
guard FileManager.default.fileExists(atPath: path) else {
19+
throw InstallHistoryError.fileNotFound(path)
20+
}
21+
22+
let url = URL(fileURLWithPath: path)
23+
let data = try Data(contentsOf: url)
24+
25+
guard let plistArray = try PropertyListSerialization.propertyList(from: data, format: nil) as? [[String: Any]] else {
26+
throw InstallHistoryError.invalidFormat
27+
}
28+
29+
return parseEntries(plistArray, since: date)
30+
}
31+
32+
/// Parse from raw plist data (useful for testing)
33+
func parseEntries(_ entries: [[String: Any]], since date: Date? = nil) -> [LOBPackageReceipt] {
34+
return entries.compactMap { entry -> LOBPackageReceipt? in
35+
guard let installDate = entry["date"] as? Date,
36+
let displayName = entry["displayName"] as? String,
37+
let processName = entry["processName"] as? String else {
38+
return nil
39+
}
40+
41+
// Filter by date if specified
42+
if let since = date, installDate < since {
43+
return nil
44+
}
45+
46+
// Filter for MDM-pushed installs
47+
let displayVersion = entry["displayVersion"] as? String ?? ""
48+
let packageIdentifiers = entry["packageIdentifiers"] as? [String] ?? []
49+
50+
guard isMDMRelatedInstall(processName: processName, displayName: displayName, displayVersion: displayVersion, packageIdentifiers: packageIdentifiers) else {
51+
return nil
52+
}
53+
54+
return LOBPackageReceipt(
55+
packageIdentifiers: packageIdentifiers,
56+
displayName: displayName,
57+
displayVersion: displayVersion.isEmpty ? "Unknown" : displayVersion,
58+
installDate: installDate,
59+
processName: processName
60+
)
61+
}
62+
.sorted { $0.installDate > $1.installDate } // Newest first
63+
}
64+
65+
// MARK: - Private
66+
67+
/// Determine if an InstallHistory entry is MDM-related.
68+
///
69+
/// MDM-pushed installs come through several paths:
70+
/// - "appstored": App Store apps deployed via MDM (VPP/device assignment)
71+
/// - "mdmclient"/"storedownloadd": Direct MDM install commands
72+
/// - "installer" with GUID display name: Managed PKGs pushed via InstallApplication
73+
///
74+
/// We intentionally avoid broad heuristics (e.g. matching "Microsoft" or "Company Portal")
75+
/// because those are often deployed via the Intune sidecar agent, not the MDM channel.
76+
private func isMDMRelatedInstall(processName: String, displayName: String, displayVersion: String, packageIdentifiers: [String]) -> Bool {
77+
let processLower = processName.lowercased()
78+
79+
// App Store apps deployed via MDM (VPP / device-based licensing)
80+
if processLower == "appstored" {
81+
return true
82+
}
83+
84+
// Direct MDM process names
85+
if processLower == "mdmclient" || processLower == "storedownloadd" {
86+
return true
87+
}
88+
89+
// For "installer" process: only match GUID display names (strong signal of MDM push)
90+
// e.g. "058f90bf-06a7-4cfe-87e6-6918a0c5aa45"
91+
if processLower == "installer" {
92+
let uuidPattern = #"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"#
93+
if displayName.range(of: uuidPattern, options: .regularExpression) != nil {
94+
return true
95+
}
96+
}
97+
98+
return false
99+
}
100+
}
101+
102+
// MARK: - Errors
103+
104+
enum InstallHistoryError: LocalizedError {
105+
case fileNotFound(String)
106+
case invalidFormat
107+
108+
var errorDescription: String? {
109+
switch self {
110+
case .fileNotFound(let path):
111+
return "InstallHistory.plist not found at \(path)"
112+
case .invalidFormat:
113+
return "InstallHistory.plist has an unexpected format"
114+
}
115+
}
116+
}

0 commit comments

Comments
 (0)