Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions IntuneLogWatch.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,17 @@
CertificateInspector.swift,
ClipLibrary.swift,
ClipLibraryView.swift,
DeploymentLifecycleView.swift,
ErrorCodeDetailView.swift,
ErrorCodesReferenceViewSimple.swift,
InstallHistoryParser.swift,
InstallLogParser.swift,
IntuneErrorCodes.swift,
IntuneLogWatchApp.swift,
LOBCorrelationEngine.swift,
LOBDetailView.swift,
LOBModels.swift,
LOBSidebarView.swift,
LogEntryDetailView.swift,
LogParser.swift,
Models.swift,
Expand All @@ -127,6 +134,7 @@
PolicyExportHelper.swift,
SyncEventDetailView.swift,
TooltipView.swift,
UnifiedLogReader.swift,
ViewController.swift,
);
target = 1B35FC5B2EDD0A0200F4EE46 /* IntuneLogWatchQuickLook */;
Expand Down
3 changes: 3 additions & 0 deletions IntuneLogWatch/ClipLibrary.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ struct PolicyExecutionSnapshot: Codable, Hashable {
let scriptType: String?
let executionContext: String?
let healthDomain: String?
let deploymentChannel: String?

init(from policy: PolicyExecution) {
self.policyId = policy.policyId
Expand All @@ -83,6 +84,7 @@ struct PolicyExecutionSnapshot: Codable, Hashable {
self.scriptType = policy.scriptType
self.executionContext = policy.executionContext
self.healthDomain = policy.healthDomain
self.deploymentChannel = policy.deploymentChannel.rawValue
}

// Convert back to PolicyExecution for viewing
Expand All @@ -97,6 +99,7 @@ struct PolicyExecutionSnapshot: Codable, Hashable {
scriptType: scriptType,
executionContext: executionContext,
healthDomain: healthDomain,
deploymentChannel: DeploymentChannel(rawValue: deploymentChannel ?? "Agent") ?? .agent,
status: status.toPolicyStatus(),
startTime: startTime,
endTime: endTime,
Expand Down
194 changes: 194 additions & 0 deletions IntuneLogWatch/DeploymentLifecycleView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
//
// DeploymentLifecycleView.swift
// IntuneLogWatch
//
// Visual lifecycle pipeline component showing LOB deployment stages.
//

import SwiftUI

struct DeploymentLifecycleView: View {
let stages: [LOBLifecycleStageInfo]

var body: some View {
HStack(spacing: 0) {
ForEach(Array(stages.enumerated()), id: \.element.id) { index, stage in
stageView(stage)

if index < stages.count - 1 {
connector(
from: stage.status,
to: stages[index + 1].status
)
}
}
}
.padding(.vertical, 8)
}

private func stageView(_ stage: LOBLifecycleStageInfo) -> some View {
VStack(spacing: 4) {
ZStack {
Circle()
.fill(stageColor(stage.status).opacity(0.15))
.frame(width: 40, height: 40)

Image(systemName: stageIcon(stage))
.foregroundColor(stageColor(stage.status))
.font(.system(size: 16))
}

Text(stage.stage.rawValue)
.font(.caption2)
.foregroundColor(.secondary)
.lineLimit(1)

if let timestamp = stage.timestamp {
Text(formatTime(timestamp))
.font(.caption2)
.foregroundColor(.secondary)
}
}
.frame(minWidth: 70)
}

private func connector(from: LOBDeploymentStatus, to: LOBDeploymentStatus) -> some View {
Rectangle()
.fill(connectorColor(from: from, to: to))
.frame(height: 2)
.frame(maxWidth: 30)
.padding(.bottom, 24) // Align with circle center
}

private func stageIcon(_ stage: LOBLifecycleStageInfo) -> String {
switch stage.status {
case .completed:
return "checkmark.circle.fill"
case .failed:
return "xmark.circle.fill"
case .installing, .downloading:
return "arrow.clockwise"
case .pending:
return stage.stage.icon
case .unknown:
return "circle.dotted"
}
}

private func stageColor(_ status: LOBDeploymentStatus) -> Color {
switch status {
case .completed: return .green
case .failed: return .red
case .installing, .downloading: return .blue
case .pending: return .orange
case .unknown: return .secondary
}
}

private func connectorColor(from: LOBDeploymentStatus, to: LOBDeploymentStatus) -> Color {
if from == .completed {
return .green
}
if from == .failed {
return .red
}
return .secondary.opacity(0.3)
}

private func formatTime(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.timeStyle = .medium
return formatter.string(from: date)
}
}

// MARK: - Helper to build lifecycle stages from a LOBAppEvent

extension LOBAppEvent {
var lifecycleStages: [LOBLifecycleStageInfo] {
var stages: [LOBLifecycleStageInfo] = []

// MDM Command stage
let mdmEntries = unifiedLogEntries.filter {
$0.message.lowercased().contains("installapplication") ||
$0.message.lowercased().contains("mdm command") ||
$0.message.lowercased().contains("received command")
}
let mdmStatus: LOBDeploymentStatus = mdmEntries.isEmpty ? .unknown : .completed
stages.append(LOBLifecycleStageInfo(
stage: .mdmCommand,
status: mdmStatus,
timestamp: mdmEntries.first?.timestamp ?? (status != .unknown ? timestamp : nil),
entries: mdmEntries,
errorMessage: nil
))

// Download stage
let downloadEntries = unifiedLogEntries.filter {
$0.message.lowercased().contains("download") ||
$0.process.lowercased() == "storedownloadd"
}
let downloadStatus: LOBDeploymentStatus
if downloadEntries.contains(where: { $0.level == .error || $0.level == .fault }) {
downloadStatus = .failed
} else if !downloadEntries.isEmpty {
downloadStatus = .completed
} else if mdmStatus == .completed {
downloadStatus = status == .unknown ? .unknown : .completed // Assume download happened
} else {
downloadStatus = .unknown
}
stages.append(LOBLifecycleStageInfo(
stage: .download,
status: downloadStatus,
timestamp: downloadEntries.first?.timestamp,
entries: downloadEntries,
errorMessage: downloadEntries.first(where: { $0.level == .error })?.message
))

// Installation stage
let installEntries = unifiedLogEntries.filter {
$0.message.lowercased().contains("install") &&
!$0.message.lowercased().contains("installapplication")
}
let allInstallEntries = installEntries + unifiedLogEntries.filter { $0.process.lowercased() == "installer" }
let installStatus: LOBDeploymentStatus
if allInstallEntries.contains(where: { $0.level == .error || $0.level == .fault }) {
installStatus = .failed
} else if !allInstallEntries.isEmpty || !installLogEntries.isEmpty {
installStatus = .completed
} else if downloadStatus == .completed && status == .completed {
installStatus = .completed
} else {
installStatus = status == .failed ? .failed : .unknown
}
stages.append(LOBLifecycleStageInfo(
stage: .installation,
status: installStatus,
timestamp: allInstallEntries.first?.timestamp ?? installLogEntries.first?.timestamp,
entries: allInstallEntries,
errorMessage: allInstallEntries.first(where: { $0.level == .error })?.message
))

// Verification stage
let verificationStatus: LOBDeploymentStatus
if receiptInfo != nil {
verificationStatus = .completed
} else if status == .completed {
verificationStatus = .completed
} else if status == .failed {
verificationStatus = .failed
} else {
verificationStatus = .unknown
}
stages.append(LOBLifecycleStageInfo(
stage: .verification,
status: verificationStatus,
timestamp: receiptInfo?.installDate,
entries: [],
errorMessage: nil
))

return stages
}
}
116 changes: 116 additions & 0 deletions IntuneLogWatch/InstallHistoryParser.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
//
// InstallHistoryParser.swift
// IntuneLogWatch
//
// Parses /Library/Receipts/InstallHistory.plist for MDM-pushed installs.
//

import Foundation

class InstallHistoryParser {

// MARK: - Public API

/// Parse InstallHistory.plist and return MDM-pushed install receipts
func parseMDMInstalls(since date: Date? = nil) throws -> [LOBPackageReceipt] {
let path = "/Library/Receipts/InstallHistory.plist"

guard FileManager.default.fileExists(atPath: path) else {
throw InstallHistoryError.fileNotFound(path)
}

let url = URL(fileURLWithPath: path)
let data = try Data(contentsOf: url)

guard let plistArray = try PropertyListSerialization.propertyList(from: data, format: nil) as? [[String: Any]] else {
throw InstallHistoryError.invalidFormat
}

return parseEntries(plistArray, since: date)
}

/// Parse from raw plist data (useful for testing)
func parseEntries(_ entries: [[String: Any]], since date: Date? = nil) -> [LOBPackageReceipt] {
return entries.compactMap { entry -> LOBPackageReceipt? in
guard let installDate = entry["date"] as? Date,
let displayName = entry["displayName"] as? String,
let processName = entry["processName"] as? String else {
return nil
}

// Filter by date if specified
if let since = date, installDate < since {
return nil
}

// Filter for MDM-pushed installs
let displayVersion = entry["displayVersion"] as? String ?? ""
let packageIdentifiers = entry["packageIdentifiers"] as? [String] ?? []

guard isMDMRelatedInstall(processName: processName, displayName: displayName, displayVersion: displayVersion, packageIdentifiers: packageIdentifiers) else {
return nil
}

return LOBPackageReceipt(
packageIdentifiers: packageIdentifiers,
displayName: displayName,
displayVersion: displayVersion.isEmpty ? "Unknown" : displayVersion,
installDate: installDate,
processName: processName
)
}
.sorted { $0.installDate > $1.installDate } // Newest first
}

// MARK: - Private

/// Determine if an InstallHistory entry is MDM-related.
///
/// MDM-pushed installs come through several paths:
/// - "appstored": App Store apps deployed via MDM (VPP/device assignment)
/// - "mdmclient"/"storedownloadd": Direct MDM install commands
/// - "installer" with GUID display name: Managed PKGs pushed via InstallApplication
///
/// We intentionally avoid broad heuristics (e.g. matching "Microsoft" or "Company Portal")
/// because those are often deployed via the Intune sidecar agent, not the MDM channel.
private func isMDMRelatedInstall(processName: String, displayName: String, displayVersion: String, packageIdentifiers: [String]) -> Bool {
let processLower = processName.lowercased()

// App Store apps deployed via MDM (VPP / device-based licensing)
if processLower == "appstored" {
return true
}

// Direct MDM process names
if processLower == "mdmclient" || processLower == "storedownloadd" {
return true
}

// For "installer" process: only match GUID display names (strong signal of MDM push)
// e.g. "058f90bf-06a7-4cfe-87e6-6918a0c5aa45"
if processLower == "installer" {
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}$"#
if displayName.range(of: uuidPattern, options: .regularExpression) != nil {
return true
}
}

return false
}
}

// MARK: - Errors

enum InstallHistoryError: LocalizedError {
case fileNotFound(String)
case invalidFormat

var errorDescription: String? {
switch self {
case .fileNotFound(let path):
return "InstallHistory.plist not found at \(path)"
case .invalidFormat:
return "InstallHistory.plist has an unexpected format"
}
}
}
Loading