From d4a4cb02ac8097c56616f0270d3c8b103bf9c41b Mon Sep 17 00:00:00 2001 From: Aaron Voges Date: Fri, 20 Mar 2026 10:35:06 -0500 Subject: [PATCH] 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) --- IntuneLogWatch.xcodeproj/project.pbxproj | 8 + IntuneLogWatch/ClipLibrary.swift | 3 + IntuneLogWatch/DeploymentLifecycleView.swift | 194 ++++++++ IntuneLogWatch/InstallHistoryParser.swift | 116 +++++ IntuneLogWatch/InstallLogParser.swift | 211 +++++++++ IntuneLogWatch/LOBCorrelationEngine.swift | 389 ++++++++++++++++ IntuneLogWatch/LOBDetailView.swift | 460 +++++++++++++++++++ IntuneLogWatch/LOBModels.swift | 261 +++++++++++ IntuneLogWatch/LOBSidebarView.swift | 107 +++++ IntuneLogWatch/LogParser.swift | 5 +- IntuneLogWatch/Models.swift | 11 + IntuneLogWatch/PolicyDetailView.swift | 6 +- IntuneLogWatch/SyncEventDetailView.swift | 3 + IntuneLogWatch/UnifiedLogReader.swift | 214 +++++++++ IntuneLogWatch/ViewController.swift | 325 +++++++++---- docs/design.md | 134 ++++++ 16 files changed, 2359 insertions(+), 88 deletions(-) create mode 100644 IntuneLogWatch/DeploymentLifecycleView.swift create mode 100644 IntuneLogWatch/InstallHistoryParser.swift create mode 100644 IntuneLogWatch/InstallLogParser.swift create mode 100644 IntuneLogWatch/LOBCorrelationEngine.swift create mode 100644 IntuneLogWatch/LOBDetailView.swift create mode 100644 IntuneLogWatch/LOBModels.swift create mode 100644 IntuneLogWatch/LOBSidebarView.swift create mode 100644 IntuneLogWatch/UnifiedLogReader.swift create mode 100644 docs/design.md diff --git a/IntuneLogWatch.xcodeproj/project.pbxproj b/IntuneLogWatch.xcodeproj/project.pbxproj index c3d2ec7..8f2aeeb 100644 --- a/IntuneLogWatch.xcodeproj/project.pbxproj +++ b/IntuneLogWatch.xcodeproj/project.pbxproj @@ -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, @@ -127,6 +134,7 @@ PolicyExportHelper.swift, SyncEventDetailView.swift, TooltipView.swift, + UnifiedLogReader.swift, ViewController.swift, ); target = 1B35FC5B2EDD0A0200F4EE46 /* IntuneLogWatchQuickLook */; diff --git a/IntuneLogWatch/ClipLibrary.swift b/IntuneLogWatch/ClipLibrary.swift index 026b39c..41aa911 100644 --- a/IntuneLogWatch/ClipLibrary.swift +++ b/IntuneLogWatch/ClipLibrary.swift @@ -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 @@ -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 @@ -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, diff --git a/IntuneLogWatch/DeploymentLifecycleView.swift b/IntuneLogWatch/DeploymentLifecycleView.swift new file mode 100644 index 0000000..ccc1fd2 --- /dev/null +++ b/IntuneLogWatch/DeploymentLifecycleView.swift @@ -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 + } +} diff --git a/IntuneLogWatch/InstallHistoryParser.swift b/IntuneLogWatch/InstallHistoryParser.swift new file mode 100644 index 0000000..b6e7e0a --- /dev/null +++ b/IntuneLogWatch/InstallHistoryParser.swift @@ -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" + } + } +} diff --git a/IntuneLogWatch/InstallLogParser.swift b/IntuneLogWatch/InstallLogParser.swift new file mode 100644 index 0000000..9de9c45 --- /dev/null +++ b/IntuneLogWatch/InstallLogParser.swift @@ -0,0 +1,211 @@ +// +// InstallLogParser.swift +// IntuneLogWatch +// +// Parses /var/log/install.log for MDM-pushed package installation records. +// + +import Foundation + +class InstallLogParser { + + // MARK: - Public API + + /// Parse install.log and return entries related to MDM installs + func parseInstallLog(since date: Date? = nil) async throws -> [InstallLogEntry] { + let path = "/var/log/install.log" + + guard FileManager.default.fileExists(atPath: path) else { + throw InstallLogError.fileNotFound(path) + } + + let content = try String(contentsOfFile: path, encoding: .utf8) + return parseContent(content, since: date) + } + + /// Parse install.log content (useful for testing) + func parseContent(_ content: String, since date: Date? = nil) -> [InstallLogEntry] { + let lines = content.components(separatedBy: .newlines) + var entries: [InstallLogEntry] = [] + var currentInstallBlock: [String] = [] + var inInstallBlock = false + var currentPackagePath: String? + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { continue } + + // Detect install block boundaries + if trimmed.contains("---Begin Install---") || trimmed.contains("### BEGIN") { + inInstallBlock = true + currentInstallBlock = [line] + currentPackagePath = nil + continue + } + + if trimmed.contains("---End Install---") || trimmed.contains("### END") { + if inInstallBlock { + currentInstallBlock.append(line) + // Process the collected block + let blockEntries = parseInstallBlock(currentInstallBlock, packagePath: currentPackagePath, since: date) + entries.append(contentsOf: blockEntries) + } + inInstallBlock = false + currentInstallBlock = [] + currentPackagePath = nil + continue + } + + if inInstallBlock { + currentInstallBlock.append(line) + // Try to extract package path + if currentPackagePath == nil, let path = extractPackagePath(from: line) { + currentPackagePath = path + } + continue + } + + // Lines outside install blocks — parse individually if they match MDM patterns + if let entry = parseLine(line, since: date) { + if isMDMRelated(entry) { + entries.append(entry) + } + } + } + + // Handle unterminated install block + if inInstallBlock && !currentInstallBlock.isEmpty { + let blockEntries = parseInstallBlock(currentInstallBlock, packagePath: currentPackagePath, since: date) + entries.append(contentsOf: blockEntries) + } + + return entries + } + + // MARK: - Private Parsing + + /// Line format: YYYY-MM-DD HH:MM:SS-TZ process[pid]: message + private func parseLine(_ line: String, since date: Date? = nil) -> InstallLogEntry? { + // Pattern: 2024-03-15 10:30:45+00 installer[12345]: message + let pattern = #"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}[+-]\d{2})\s+(\w+)\[?\d*\]?:\s+(.+)$"# + + guard let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch(in: line, range: NSRange(location: 0, length: line.count)) else { + return nil + } + + guard let timestampRange = Range(match.range(at: 1), in: line), + let processRange = Range(match.range(at: 2), in: line), + let messageRange = Range(match.range(at: 3), in: line) else { + return nil + } + + let timestampStr = String(line[timestampRange]) + let process = String(line[processRange]) + let message = String(line[messageRange]) + + guard let timestamp = Self.parseTimestamp(timestampStr) else { + return nil + } + + // Filter by date if specified + if let since = date, timestamp < since { + return nil + } + + let packagePath = extractPackagePath(from: message) + let result = extractResult(from: message) + + return InstallLogEntry( + timestamp: timestamp, + process: process, + message: message, + packagePath: packagePath, + result: result + ) + } + + private func parseInstallBlock(_ lines: [String], packagePath: String?, since date: Date?) -> [InstallLogEntry] { + var entries: [InstallLogEntry] = [] + + for line in lines { + if let entry = parseLine(line, since: date) { + // Include all entries in install blocks (they're all relevant) + entries.append(entry) + } + } + + return entries + } + + // MARK: - Helpers + + private func isMDMRelated(_ entry: InstallLogEntry) -> Bool { + let mdmKeywords = ["mdmclient", "MDM", "ManagedClient", "InstallApplication"] + return mdmKeywords.contains { entry.message.localizedCaseInsensitiveContains($0) } + || entry.process.lowercased() == "mdmclient" + } + + private func extractPackagePath(from message: String) -> String? { + // Look for path patterns like /path/to/something.pkg + let pathPattern = #"(/[^\s]+\.pkg)"# + if let regex = try? NSRegularExpression(pattern: pathPattern), + let match = regex.firstMatch(in: message, range: NSRange(location: 0, length: message.count)), + let range = Range(match.range(at: 1), in: message) { + return String(message[range]) + } + return nil + } + + private func extractResult(from message: String) -> String? { + if message.localizedCaseInsensitiveContains("successfully installed") || + message.localizedCaseInsensitiveContains("install successful") || + message.localizedCaseInsensitiveContains("Installation successful") { + return "success" + } + if message.localizedCaseInsensitiveContains("install failed") || + message.localizedCaseInsensitiveContains("error") || + message.localizedCaseInsensitiveContains("Installation failed") { + return "failure" + } + return nil + } + + static func parseTimestamp(_ str: String) -> Date? { + // Try multiple formats + let formatters: [DateFormatter] = [ + { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd HH:mm:ssZ" + f.locale = Locale(identifier: "en_US_POSIX") + return f + }(), + { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd HH:mm:ss" + f.locale = Locale(identifier: "en_US_POSIX") + return f + }() + ] + + for formatter in formatters { + if let date = formatter.date(from: str) { + return date + } + } + return nil + } +} + +// MARK: - Errors + +enum InstallLogError: LocalizedError { + case fileNotFound(String) + + var errorDescription: String? { + switch self { + case .fileNotFound(let path): + return "Install log not found at \(path). This is normal if no packages have been installed recently." + } + } +} diff --git a/IntuneLogWatch/LOBCorrelationEngine.swift b/IntuneLogWatch/LOBCorrelationEngine.swift new file mode 100644 index 0000000..cfa8b01 --- /dev/null +++ b/IntuneLogWatch/LOBCorrelationEngine.swift @@ -0,0 +1,389 @@ +// +// LOBCorrelationEngine.swift +// IntuneLogWatch +// +// Merges data from unified logs, install.log, and InstallHistory.plist +// into unified LOBAppEvent objects. +// + +import Foundation + +class LOBCorrelationEngine: ObservableObject { + @Published var isLoading = false + @Published var analysis: LOBAnalysis? + @Published var error: String? + + private let unifiedLogReader = UnifiedLogReader() + private let installLogParser = InstallLogParser() + private let installHistoryParser = InstallHistoryParser() + + // MARK: - Public API + + /// Load and correlate all LOB data sources + func loadLOBData(duration: String = "7d") { + isLoading = true + error = nil + + Task { + do { + let result = try await performAnalysis(duration: duration) + await MainActor.run { + self.analysis = result + self.isLoading = false + } + } catch { + await MainActor.run { + self.error = error.localizedDescription + self.isLoading = false + } + } + } + } + + // MARK: - Analysis + + private func performAnalysis(duration: String) async throws -> LOBAnalysis { + var parseErrors: [String] = [] + + // 1. Query unified logs + var unifiedEntries: [UnifiedLogEntry] = [] + do { + unifiedEntries = try await unifiedLogReader.queryLOBInstalls(since: duration) + } catch { + parseErrors.append("Unified log query failed: \(error.localizedDescription)") + } + + // 2. Parse install.log + var installEntries: [InstallLogEntry] = [] + do { + // Parse entries from roughly the same time window + let sinceDate = durationToDate(duration) + installEntries = try await installLogParser.parseInstallLog(since: sinceDate) + } catch { + parseErrors.append("Install log parse failed: \(error.localizedDescription)") + } + + // 3. Parse InstallHistory.plist + var receipts: [LOBPackageReceipt] = [] + do { + let sinceDate = durationToDate(duration) + receipts = try installHistoryParser.parseMDMInstalls(since: sinceDate) + } catch { + parseErrors.append("InstallHistory parse failed: \(error.localizedDescription)") + } + + // 4. Correlate into LOBAppEvents + let events = correlate( + unifiedEntries: unifiedEntries, + installEntries: installEntries, + receipts: receipts + ) + + return LOBAnalysis( + events: events, + totalUnifiedLogEntries: unifiedEntries.count, + totalInstallLogEntries: installEntries.count, + totalReceipts: receipts.count, + parseErrors: parseErrors, + queryDuration: duration + ) + } + + // MARK: - Correlation Logic + + private func correlate( + unifiedEntries: [UnifiedLogEntry], + installEntries: [InstallLogEntry], + receipts: [LOBPackageReceipt] + ) -> [LOBAppEvent] { + var events: [LOBAppEvent] = [] + + // Step 1: Group unified log entries by MDM command UUID + let groupedByCommand = groupByMDMCommand(unifiedEntries) + + // Step 2: Create events from grouped unified log entries + for (commandUUID, entries) in groupedByCommand { + let sortedEntries = entries.sorted { $0.timestamp < $1.timestamp } + let appName = extractAppName(from: sortedEntries) + let packageId = extractPackageId(from: sortedEntries) + let status = determineStatus(from: sortedEntries) + + var event = LOBAppEvent( + timestamp: sortedEntries.first?.timestamp ?? Date(), + appName: appName, + packageId: packageId, + mdmCommandUUID: commandUUID == "unknown" ? nil : commandUUID, + status: status, + unifiedLogEntries: sortedEntries, + installLogEntries: [], + receiptInfo: nil + ) + + // Step 3: Match install.log entries by timestamp proximity + let matchedInstallEntries = matchInstallLogEntries( + installEntries, + toEvent: event, + windowSeconds: 30 + ) + event.installLogEntries = matchedInstallEntries + + // Step 4: Match receipt by package identifier or name + if let receipt = matchReceipt(receipts, toEvent: event) { + event.receiptInfo = receipt + if event.status != .failed { + event.status = .completed + } + } + + events.append(event) + } + + // Step 5: Create events from receipts that weren't matched to unified log entries + let matchedReceiptIds = Set(events.compactMap { $0.receiptInfo?.id }) + for receipt in receipts where !matchedReceiptIds.contains(receipt.id) { + let event = LOBAppEvent( + timestamp: receipt.installDate, + appName: receipt.displayName, + packageId: receipt.packageIdentifiers.first, + mdmCommandUUID: nil, + status: .completed, + unifiedLogEntries: [], + installLogEntries: matchInstallLogEntries(installEntries, toReceipt: receipt), + receiptInfo: receipt + ) + events.append(event) + } + + return events.sorted { $0.timestamp > $1.timestamp } // Newest first + } + + // MARK: - Grouping & Extraction + + private func groupByMDMCommand(_ entries: [UnifiedLogEntry]) -> [String: [UnifiedLogEntry]] { + var groups: [String: [UnifiedLogEntry]] = [:] + + // Only group entries that have a command UUID (actual InstallApplication events) + for entry in entries { + guard let uuid = extractCommandUUID(from: entry.message) else { + continue // Skip entries without a command UUID + } + groups[uuid, default: []].append(entry) + } + + return groups + } + + private func extractCommandUUID(from message: String) -> String? { + let uuidRegex = #"([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})"# + + // Match "InstallApplication (UUID:XXXX)" — actual Intune format + let installAppPattern = #"InstallApplication\s*\(UUID:([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 let regex = try? NSRegularExpression(pattern: installAppPattern), + let match = regex.firstMatch(in: message, range: NSRange(location: 0, length: message.count)), + let range = Range(match.range(at: 1), in: message) { + return String(message[range]) + } + + // Generic command UUID patterns + let commandPattern = #"[Cc]ommand\s*(?:UUID|Id|ID)?\s*[:=]?\s*([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 let regex = try? NSRegularExpression(pattern: commandPattern), + let match = regex.firstMatch(in: message, range: NSRange(location: 0, length: message.count)), + let range = Range(match.range(at: 1), in: message) { + return String(message[range]) + } + + // Any UUID in an InstallApplication context + if message.contains("InstallApplication") || message.contains("InstallEnterpriseApplication") { + if let regex = try? NSRegularExpression(pattern: uuidRegex), + let match = regex.firstMatch(in: message, range: NSRange(location: 0, length: message.count)), + let range = Range(match.range(at: 1), in: message) { + return String(message[range]) + } + } + + return nil + } + + private func extractAppName(from entries: [UnifiedLogEntry]) -> String? { + for entry in entries { + // Look for app name in various message patterns + let patterns = [ + #"Installing\s+\"([^\"]+)\""#, + #"package\s+name\s*[:=]\s*\"?([^\";\n]+)"#, + #"DisplayName\s*[:=]\s*\"?([^\";\n]+)"#, + #"applicationName\s*[:=]\s*\"?([^\";\n]+)"# + ] + + for pattern in patterns { + if let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive), + let match = regex.firstMatch(in: entry.message, range: NSRange(location: 0, length: entry.message.count)), + let range = Range(match.range(at: 1), in: entry.message) { + return String(entry.message[range]).trimmingCharacters(in: .whitespaces) + } + } + } + return nil + } + + private func extractPackageId(from entries: [UnifiedLogEntry]) -> String? { + for entry in entries { + let patterns = [ + #"packageIdentifier\s*[:=]\s*\"?([^\";\s\n]+)"#, + #"bundleIdentifier\s*[:=]\s*\"?([^\";\s\n]+)"#, + #"com\.[a-zA-Z0-9]+\.[a-zA-Z0-9.]+"# + ] + + for pattern in patterns { + if let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch(in: entry.message, range: NSRange(location: 0, length: entry.message.count)) { + let captureRange = match.numberOfRanges > 1 ? match.range(at: 1) : match.range(at: 0) + if let range = Range(captureRange, in: entry.message) { + return String(entry.message[range]) + } + } + } + } + return nil + } + + private func determineStatus(from entries: [UnifiedLogEntry]) -> LOBDeploymentStatus { + let messages = entries.map { $0.message.lowercased() } + let levels = entries.map { $0.level } + + if levels.contains(.error) || levels.contains(.fault) { + return .failed + } + + if messages.contains(where: { $0.contains("successfully installed") || $0.contains("install successful") || $0.contains("installation complete") }) { + return .completed + } + + if messages.contains(where: { $0.contains("installing") || $0.contains("running installer") }) { + return .installing + } + + if messages.contains(where: { $0.contains("downloading") || $0.contains("download") }) { + return .downloading + } + + if messages.contains(where: { $0.contains("installapplication") || $0.contains("mdm command") }) { + return .pending + } + + return .unknown + } + + // MARK: - Matching + + private func matchInstallLogEntries( + _ installEntries: [InstallLogEntry], + toEvent event: LOBAppEvent, + windowSeconds: TimeInterval = 30 + ) -> [InstallLogEntry] { + guard let eventStart = event.unifiedLogEntries.first?.timestamp, + let eventEnd = event.unifiedLogEntries.last?.timestamp else { + return [] + } + + let windowStart = eventStart.addingTimeInterval(-windowSeconds) + let windowEnd = eventEnd.addingTimeInterval(windowSeconds) + + return installEntries.filter { entry in + entry.timestamp >= windowStart && entry.timestamp <= windowEnd + } + } + + private func matchInstallLogEntries( + _ installEntries: [InstallLogEntry], + toReceipt receipt: LOBPackageReceipt + ) -> [InstallLogEntry] { + let windowSeconds: TimeInterval = 60 + let windowStart = receipt.installDate.addingTimeInterval(-windowSeconds) + let windowEnd = receipt.installDate.addingTimeInterval(windowSeconds) + + return installEntries.filter { entry in + entry.timestamp >= windowStart && entry.timestamp <= windowEnd + } + } + + private func matchReceipt( + _ receipts: [LOBPackageReceipt], + toEvent event: LOBAppEvent + ) -> LOBPackageReceipt? { + // Try matching by package identifier + if let eventPkgId = event.packageId { + if let match = receipts.first(where: { $0.packageIdentifiers.contains(eventPkgId) }) { + return match + } + } + + // Try matching by app name + if let eventName = event.appName { + if let match = receipts.first(where: { + $0.displayName.localizedCaseInsensitiveContains(eventName) || + eventName.localizedCaseInsensitiveContains($0.displayName) + }) { + return match + } + } + + // Try matching by timestamp proximity + let eventTime = event.timestamp + let closest = receipts.min(by: { + abs($0.installDate.timeIntervalSince(eventTime)) < abs($1.installDate.timeIntervalSince(eventTime)) + }) + + if let closest = closest, abs(closest.installDate.timeIntervalSince(eventTime)) < 300 { // 5 min window + return closest + } + + return nil + } + + // MARK: - Utilities + + private func splitByTimeGap(_ entries: [UnifiedLogEntry], gapThresholdSeconds: TimeInterval) -> [[UnifiedLogEntry]] { + let sorted = entries.sorted { $0.timestamp < $1.timestamp } + var groups: [[UnifiedLogEntry]] = [] + var currentGroup: [UnifiedLogEntry] = [] + + for entry in sorted { + if let last = currentGroup.last, + entry.timestamp.timeIntervalSince(last.timestamp) > gapThresholdSeconds { + groups.append(currentGroup) + currentGroup = [entry] + } else { + currentGroup.append(entry) + } + } + + if !currentGroup.isEmpty { + groups.append(currentGroup) + } + + return groups + } + + private func durationToDate(_ duration: String) -> Date? { + // Parse duration strings like "24h", "7d", "1h" + let pattern = #"(\d+)([hHdDmM])"# + guard let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch(in: duration, range: NSRange(location: 0, length: duration.count)), + let valueRange = Range(match.range(at: 1), in: duration), + let unitRange = Range(match.range(at: 2), in: duration), + let value = Double(String(duration[valueRange])) else { + return nil + } + + let unit = String(duration[unitRange]).lowercased() + let seconds: TimeInterval + switch unit { + case "m": seconds = value * 60 + case "h": seconds = value * 3600 + case "d": seconds = value * 86400 + default: return nil + } + + return Date().addingTimeInterval(-seconds) + } +} diff --git a/IntuneLogWatch/LOBDetailView.swift b/IntuneLogWatch/LOBDetailView.swift new file mode 100644 index 0000000..0850554 --- /dev/null +++ b/IntuneLogWatch/LOBDetailView.swift @@ -0,0 +1,460 @@ +// +// LOBDetailView.swift +// IntuneLogWatch +// +// Detail view for a selected LOB app deployment event. +// + +import SwiftUI + +struct LOBDetailView: View { + let event: LOBAppEvent + @State private var showRawLogs = false + @State private var selectedLogSource: LOBLogSource = .all + + enum LOBLogSource: String, CaseIterable { + case all = "All" + case unifiedLog = "Unified Log" + case installLog = "Install Log" + } + + var body: some View { + VStack(spacing: 0) { + eventHeader + lifecycleSection + Divider() + logControls + logEntryList + } + } + + // MARK: - Header + + private var eventHeader: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: "shippingbox.fill") + .foregroundColor(.indigo) + .font(.title2) + + VStack(alignment: .leading, spacing: 2) { + Text(event.displayName) + .font(.title2) + .fontWeight(.semibold) + + if let pkgId = event.packageId { + Text(pkgId) + .font(.caption) + .foregroundColor(.secondary) + } + } + + Spacer() + + ChannelBadge(channel: .managedLOB) + overallStatusBadge + } + + HStack(spacing: 16) { + if let version = event.packageVersion { + metricView(value: version, label: "Version", icon: "tag", color: .purple) + } + + if let duration = event.duration { + metricView(value: formatDuration(duration), label: "Duration", icon: "clock", color: .secondary) + } + + metricView( + value: "\(event.unifiedLogEntries.count)", + label: "Log Entries", + icon: "doc.text", + color: .blue + ) + + if !event.installLogEntries.isEmpty { + metricView( + value: "\(event.installLogEntries.count)", + label: "Install Logs", + icon: "wrench.and.screwdriver", + color: .green + ) + } + + if event.receiptInfo != nil { + metricView(value: "Yes", label: "Receipt", icon: "checkmark.seal", color: .green) + } + + Spacer() + } + + HStack { + Text("First seen: \(formatDateTime(event.timestamp))") + .font(.caption) + .foregroundColor(.secondary) + if let endTime = event.endTime { + Text("Last activity: \(formatDateTime(endTime))") + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + } + } + .padding() + .background(Color(NSColor.controlBackgroundColor)) + } + + private var overallStatusBadge: some View { + HStack(spacing: 4) { + Image(systemName: statusIcon) + .foregroundColor(statusColor) + Text(event.status.displayName) + .fontWeight(.medium) + } + .font(.title3) + } + + // MARK: - Lifecycle Timeline + + private var lifecycleSection: some View { + VStack(alignment: .leading, spacing: 4) { + Text("Deployment Lifecycle") + .font(.headline) + .padding(.horizontal) + .padding(.top, 8) + + DeploymentLifecycleView(stages: event.lifecycleStages) + .padding(.horizontal) + } + .padding(.bottom, 8) + } + + // MARK: - Receipt Info + + @ViewBuilder + private var receiptSection: some View { + if let receipt = event.receiptInfo { + VStack(alignment: .leading, spacing: 4) { + Text("Package Receipt") + .font(.headline) + .padding(.horizontal) + + VStack(alignment: .leading, spacing: 2) { + receiptRow("Name", receipt.displayName) + receiptRow("Version", receipt.displayVersion) + receiptRow("Installed", formatDateTime(receipt.installDate)) + receiptRow("Process", receipt.processName) + if !receipt.packageIdentifiers.isEmpty { + receiptRow("Package IDs", receipt.packageIdentifiers.joined(separator: ", ")) + } + } + .padding(.horizontal) + .padding(.bottom, 8) + } + } + } + + private func receiptRow(_ label: String, _ value: String) -> some View { + HStack { + Text(label + ":") + .font(.caption) + .foregroundColor(.secondary) + .frame(width: 80, alignment: .trailing) + Text(value) + .font(.caption) + .textSelection(.enabled) + Spacer() + } + } + + // MARK: - Log Controls + + private var logControls: some View { + HStack { + Picker("Source:", selection: $selectedLogSource) { + ForEach(LOBLogSource.allCases, id: \.self) { source in + Text(source.rawValue).tag(source) + } + } + .pickerStyle(SegmentedPickerStyle()) + .fixedSize() + + Spacer() + + receiptSection + + Text("\(filteredLogCount) entries") + .font(.caption) + .foregroundColor(.secondary) + } + .padding(.horizontal) + .padding(.vertical, 8) + } + + // MARK: - Log Entry List + + private var filteredLogCount: Int { + switch selectedLogSource { + case .all: return event.unifiedLogEntries.count + event.installLogEntries.count + case .unifiedLog: return event.unifiedLogEntries.count + case .installLog: return event.installLogEntries.count + } + } + + private var logEntryList: some View { + List { + switch selectedLogSource { + case .all: + combinedLogEntries + case .unifiedLog: + unifiedLogEntries + case .installLog: + installLogEntries + } + } + } + + @ViewBuilder + private var combinedLogEntries: some View { + let combined = buildCombinedEntries() + ForEach(combined, id: \.id) { entry in + CombinedLogRow(entry: entry) + } + } + + @ViewBuilder + private var unifiedLogEntries: some View { + ForEach(event.unifiedLogEntries) { entry in + UnifiedLogRow(entry: entry) + } + } + + @ViewBuilder + private var installLogEntries: some View { + ForEach(event.installLogEntries) { entry in + InstallLogRow(entry: entry) + } + } + + // MARK: - Combined entries + + private func buildCombinedEntries() -> [CombinedLogEntry] { + var entries: [CombinedLogEntry] = [] + + for entry in event.unifiedLogEntries { + entries.append(CombinedLogEntry( + id: entry.id, + timestamp: entry.timestamp, + source: .unifiedLog, + process: entry.process, + message: entry.message, + level: entry.level.rawValue + )) + } + + for entry in event.installLogEntries { + entries.append(CombinedLogEntry( + id: entry.id, + timestamp: entry.timestamp, + source: .installLog, + process: entry.process, + message: entry.message, + level: entry.result ?? "" + )) + } + + return entries.sorted { $0.timestamp < $1.timestamp } + } + + // MARK: - Helpers + + private func metricView(value: String, label: String, icon: String, color: Color) -> some View { + VStack(spacing: 2) { + HStack(spacing: 4) { + Image(systemName: icon) + .foregroundColor(color) + Text(value) + .fontWeight(.semibold) + } + Text(label) + .font(.caption2) + .foregroundColor(.secondary) + } + } + + private var statusIcon: String { + switch event.status { + case .completed: return "checkmark.circle.fill" + case .failed: return "xmark.circle.fill" + case .installing: return "arrow.clockwise" + case .downloading: return "icloud.and.arrow.down" + case .pending: return "clock" + case .unknown: return "questionmark.circle" + } + } + + private var statusColor: Color { + switch event.status { + case .completed: return .green + case .failed: return .red + case .installing, .downloading: return .blue + case .pending: return .orange + case .unknown: return .secondary + } + } + + private func formatDateTime(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .medium + return formatter.string(from: date) + } + + private func formatDuration(_ duration: TimeInterval) -> String { + if duration < 60 { + return String(format: "%.1fs", duration) + } else { + let minutes = Int(duration) / 60 + let seconds = Int(duration) % 60 + return String(format: "%dm %ds", minutes, seconds) + } + } +} + +// MARK: - Combined Log Entry Model + +struct CombinedLogEntry: Identifiable { + let id: UUID + let timestamp: Date + let source: LogSourceType + let process: String + let message: String + let level: String +} + +// MARK: - Row Views + +struct CombinedLogRow: View { + let entry: CombinedLogEntry + + var body: some View { + HStack(alignment: .top, spacing: 8) { + sourceIndicator + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(formatTime(entry.timestamp)) + .font(.caption) + .foregroundColor(.secondary) + Text(entry.process) + .font(.caption) + .fontWeight(.medium) + .foregroundColor(.secondary) + } + Text(entry.message) + .font(.caption) + .lineLimit(3) + .textSelection(.enabled) + } + } + .padding(.vertical, 2) + } + + private var sourceIndicator: some View { + Circle() + .fill(entry.source == .unifiedLog ? Color.blue : Color.green) + .frame(width: 8, height: 8) + .padding(.top, 4) + } + + private func formatTime(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.timeStyle = .medium + return formatter.string(from: date) + } +} + +struct UnifiedLogRow: View { + let entry: UnifiedLogEntry + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(formatTime(entry.timestamp)) + .font(.caption) + .foregroundColor(.secondary) + Text(entry.process) + .font(.caption) + .fontWeight(.medium) + levelBadge + Spacer() + } + Text(entry.message) + .font(.caption) + .lineLimit(3) + .textSelection(.enabled) + } + .padding(.vertical, 2) + } + + private var levelBadge: some View { + Text(entry.level.rawValue) + .font(.caption2) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(levelColor.opacity(0.15)) + .foregroundColor(levelColor) + .cornerRadius(3) + } + + private var levelColor: Color { + switch entry.level { + case .error, .fault: return .red + case .debug: return .purple + case .info: return .blue + case .default: return .secondary + } + } + + private func formatTime(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.timeStyle = .medium + return formatter.string(from: date) + } +} + +struct InstallLogRow: View { + let entry: InstallLogEntry + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(formatTime(entry.timestamp)) + .font(.caption) + .foregroundColor(.secondary) + Text(entry.process) + .font(.caption) + .fontWeight(.medium) + if let result = entry.result { + Text(result) + .font(.caption2) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(result == "success" ? Color.green.opacity(0.15) : Color.red.opacity(0.15)) + .foregroundColor(result == "success" ? .green : .red) + .cornerRadius(3) + } + Spacer() + } + Text(entry.message) + .font(.caption) + .lineLimit(3) + .textSelection(.enabled) + } + .padding(.vertical, 2) + } + + private func formatTime(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.timeStyle = .medium + return formatter.string(from: date) + } +} diff --git a/IntuneLogWatch/LOBModels.swift b/IntuneLogWatch/LOBModels.swift new file mode 100644 index 0000000..16a9930 --- /dev/null +++ b/IntuneLogWatch/LOBModels.swift @@ -0,0 +1,261 @@ +// +// LOBModels.swift +// IntuneLogWatch +// +// LOB (Line of Business) app deployment models for managed PKG apps +// deployed via Apple's native MDM channel (mdmclient). +// + +import Foundation +import SwiftUI + +// DeploymentChannel is defined in Models.swift (shared with CLI target) + +// MARK: - Log Source Types + +enum LogSourceType: String { + case mdmDaemon // Existing: IntuneMDMDaemon*.log + case unifiedLog // NEW: macOS unified log (mdmclient) + case installLog // NEW: /var/log/install.log + case installHistory // NEW: InstallHistory.plist +} + +// MARK: - Sidebar Event (unified wrapper for agent sync + LOB events) + +enum SidebarEvent: Identifiable, Hashable { + case agentSync(SyncEvent) + case lobInstall(LOBAppEvent) + + var id: UUID { + switch self { + case .agentSync(let syncEvent): return syncEvent.id + case .lobInstall(let lobEvent): return lobEvent.id + } + } + + var timestamp: Date { + switch self { + case .agentSync(let syncEvent): return syncEvent.startTime + case .lobInstall(let lobEvent): return lobEvent.timestamp + } + } +} + +// MARK: - Channel Badge + +struct ChannelBadge: View { + let channel: DeploymentChannel + + var body: some View { + Text(channel.displayName) + .font(.caption2) + .fontWeight(.medium) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(badgeColor.opacity(0.15)) + .foregroundColor(badgeColor) + .cornerRadius(4) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color.white, lineWidth: 0.5) + ) + } + + private var badgeColor: Color { + switch channel { + case .managedLOB: return .indigo + case .agent: return .teal + case .unknown: return .secondary + } + } +} + +// MARK: - LOB Deployment Status + +enum LOBDeploymentStatus: String, CaseIterable { + case pending = "Pending" + case downloading = "Downloading" + case installing = "Installing" + case completed = "Completed" + case failed = "Failed" + case unknown = "Unknown" + + var displayName: String { rawValue } +} + +// MARK: - Unified Log Entry (from mdmclient) + +struct UnifiedLogEntry: Identifiable, Hashable { + let id = UUID() + let timestamp: Date + let process: String // mdmclient, storedownloadd, installer, etc. + let subsystem: String // com.apple.ManagedClient + let category: String + let level: UnifiedLogLevel + let message: String + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + static func == (lhs: UnifiedLogEntry, rhs: UnifiedLogEntry) -> Bool { + lhs.id == rhs.id + } +} + +enum UnifiedLogLevel: String, CaseIterable { + case `default` = "Default" + case info = "Info" + case debug = "Debug" + case error = "Error" + case fault = "Fault" + + init(fromLogShow value: String) { + switch value.lowercased() { + case "default": self = .default + case "info": self = .info + case "debug": self = .debug + case "error": self = .error + case "fault": self = .fault + default: self = .default + } + } +} + +// MARK: - Install Log Entry (from /var/log/install.log) + +struct InstallLogEntry: Identifiable, Hashable { + let id = UUID() + let timestamp: Date + let process: String // installer, mdmclient, etc. + let message: String + let packagePath: String? + let result: String? // success/failure indicator + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + static func == (lhs: InstallLogEntry, rhs: InstallLogEntry) -> Bool { + lhs.id == rhs.id + } +} + +// MARK: - Package Receipt Info (from InstallHistory.plist) + +struct LOBPackageReceipt: Identifiable, Hashable { + let id = UUID() + let packageIdentifiers: [String] + let displayName: String + let displayVersion: String + let installDate: Date + let processName: String // "mdmclient" for MDM-pushed installs + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + static func == (lhs: LOBPackageReceipt, rhs: LOBPackageReceipt) -> Bool { + lhs.id == rhs.id + } +} + +// MARK: - LOB App Deployment Event + +struct LOBAppEvent: Identifiable, Hashable { + let id = UUID() + let timestamp: Date + let appName: String? + let packageId: String? + let mdmCommandUUID: String? + var status: LOBDeploymentStatus + var unifiedLogEntries: [UnifiedLogEntry] + var installLogEntries: [InstallLogEntry] + var receiptInfo: LOBPackageReceipt? + + var displayName: String { + if let name = appName, !name.isEmpty { + return name + } else if let receipt = receiptInfo { + return receipt.displayName + } else if let pkgId = packageId { + return pkgId + } else if let uuid = mdmCommandUUID { + return "MDM Command \(uuid.prefix(8))..." + } else { + return "Unknown LOB App" + } + } + + var endTime: Date? { + let allTimestamps = unifiedLogEntries.map(\.timestamp) + installLogEntries.map(\.timestamp) + if let receipt = receiptInfo { + return ([receipt.installDate] + allTimestamps).max() + } + return allTimestamps.max() + } + + var duration: TimeInterval? { + guard let end = endTime else { return nil } + return end.timeIntervalSince(timestamp) + } + + var packageVersion: String? { + receiptInfo?.displayVersion + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + static func == (lhs: LOBAppEvent, rhs: LOBAppEvent) -> Bool { + lhs.id == rhs.id + } +} + +// MARK: - LOB Analysis Result + +struct LOBAnalysis { + let events: [LOBAppEvent] + let totalUnifiedLogEntries: Int + let totalInstallLogEntries: Int + let totalReceipts: Int + let parseErrors: [String] + let queryDuration: String? // e.g. "last 24h" + + var totalEvents: Int { events.count } + var completedEvents: Int { events.filter { $0.status == .completed }.count } + var failedEvents: Int { events.filter { $0.status == .failed }.count } + + var successRate: Double { + guard totalEvents > 0 else { return 0 } + return Double(completedEvents) / Double(totalEvents) * 100 + } +} + +// MARK: - Lifecycle Stage (for timeline visualization) + +enum LOBLifecycleStage: String, CaseIterable { + case mdmCommand = "MDM Command" + case download = "Download" + case installation = "Installation" + case verification = "Verification" + + var icon: String { + switch self { + case .mdmCommand: return "arrow.down.doc" + case .download: return "icloud.and.arrow.down" + case .installation: return "shippingbox" + case .verification: return "checkmark.seal" + } + } +} + +struct LOBLifecycleStageInfo: Identifiable { + let id = UUID() + let stage: LOBLifecycleStage + let status: LOBDeploymentStatus + let timestamp: Date? + let entries: [UnifiedLogEntry] + let errorMessage: String? +} diff --git a/IntuneLogWatch/LOBSidebarView.swift b/IntuneLogWatch/LOBSidebarView.swift new file mode 100644 index 0000000..f5e84a3 --- /dev/null +++ b/IntuneLogWatch/LOBSidebarView.swift @@ -0,0 +1,107 @@ +// +// LOBSidebarView.swift +// IntuneLogWatch +// +// LOB event row view for the unified sidebar. +// + +import SwiftUI + +// MARK: - LOB Event Row + +struct LOBEventRow: View { + let event: LOBAppEvent + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Image(systemName: "shippingbox.fill") + .foregroundColor(.indigo) + .font(.title3) + + VStack(alignment: .leading, spacing: 2) { + Text(event.displayName) + .font(.headline) + .lineLimit(1) + + if let pkgId = event.packageId { + Text(pkgId) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + + Spacer() + + VStack(alignment: .trailing, spacing: 2) { + statusBadge + Text(formatDateTime(event.timestamp)) + .font(.caption2) + .foregroundColor(.secondary) + } + } + + HStack(spacing: 4) { + ChannelBadge(channel: .managedLOB) + + if let version = event.packageVersion { + Text("v\(version)") + .font(.caption2) + .padding(.horizontal, 6) + .padding(.vertical, 1) + .background(Color.gray.opacity(0.15)) + .foregroundColor(.secondary) + .cornerRadius(3) + } + + if let duration = event.duration { + Text(formatDuration(duration)) + .font(.caption2) + .foregroundColor(.secondary) + } + + Spacer() + } + } + .padding(.vertical, 4) + } + + private var statusBadge: some View { + Text(event.status.displayName) + .font(.caption2) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(statusColor.opacity(0.2)) + .foregroundColor(statusColor) + .cornerRadius(4) + } + + private var statusColor: Color { + switch event.status { + case .completed: return .green + case .failed: return .red + case .installing: return .blue + case .downloading: return .orange + case .pending: return .yellow + case .unknown: return .secondary + } + } + + private func formatDateTime(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .medium + return formatter.string(from: date) + } + + private func formatDuration(_ duration: TimeInterval) -> String { + if duration < 60 { + return String(format: "%.1fs", duration) + } else { + let minutes = Int(duration) / 60 + let seconds = Int(duration) % 60 + return String(format: "%dm %ds", minutes, seconds) + } + } +} diff --git a/IntuneLogWatch/LogParser.swift b/IntuneLogWatch/LogParser.swift index e6334ff..5abdad6 100644 --- a/IntuneLogWatch/LogParser.swift +++ b/IntuneLogWatch/LogParser.swift @@ -278,9 +278,7 @@ class LogParser: ObservableObject { // Process the previous entry if it exists if let current = currentEntry { if let entry = buildLogEntry(from: current.components, additionalLines: current.additionalLines, rawLines: [current.components.joined(separator: " | ")] + current.additionalLines) { - if entry.component != "AppPolicyResultsReporter" { - entries.append(entry) - } + entries.append(entry) } else { parseErrors.append("Line \(index): Failed to parse multi-line log entry") } @@ -637,6 +635,7 @@ class LogParser: ObservableObject { scriptType: scriptType, executionContext: executionContext, healthDomain: healthDomain, + deploymentChannel: .agent, status: status, startTime: startTime, endTime: endTime, diff --git a/IntuneLogWatch/Models.swift b/IntuneLogWatch/Models.swift index 5631601..d2f9a10 100644 --- a/IntuneLogWatch/Models.swift +++ b/IntuneLogWatch/Models.swift @@ -57,6 +57,16 @@ enum PolicyStatus: String, CaseIterable { } } +// MARK: - Deployment Channel + +enum DeploymentChannel: String, CaseIterable, Codable { + case managedLOB = "Managed LOB" // Native MDM channel (mdmclient) + case agent = "Agent" // Sidecar/IME (IntuneMDMDaemon) + case unknown = "Unknown" + + var displayName: String { rawValue } +} + struct LogEntry: Identifiable, Hashable { let id = UUID() let timestamp: Date @@ -412,6 +422,7 @@ struct PolicyExecution: Identifiable, Hashable { let scriptType: String? // Custom Attribute or Script Policy let executionContext: String? // root or user let healthDomain: String? // Domain name for health checks + let deploymentChannel: DeploymentChannel // LOB vs Agent channel let status: PolicyStatus let startTime: Date? let endTime: Date? diff --git a/IntuneLogWatch/PolicyDetailView.swift b/IntuneLogWatch/PolicyDetailView.swift index 0e353ad..350f998 100644 --- a/IntuneLogWatch/PolicyDetailView.swift +++ b/IntuneLogWatch/PolicyDetailView.swift @@ -99,11 +99,9 @@ struct PolicyDetailView: View { } Spacer() - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .trailing, spacing: 4) { statusBadge - Text("") - .font(.caption) - .foregroundColor(.secondary) + ChannelBadge(channel: policy.deploymentChannel) } } .padding(.bottom, -4) diff --git a/IntuneLogWatch/SyncEventDetailView.swift b/IntuneLogWatch/SyncEventDetailView.swift index 25e946b..0ba5452 100644 --- a/IntuneLogWatch/SyncEventDetailView.swift +++ b/IntuneLogWatch/SyncEventDetailView.swift @@ -692,6 +692,9 @@ struct PolicyRow: View { private var policyTypeTiles: some View { HStack(spacing: 4) { + // Deployment channel badge + ChannelBadge(channel: policy.deploymentChannel) + // Main policy type Text(policy.type.displayName) .font(.caption) diff --git a/IntuneLogWatch/UnifiedLogReader.swift b/IntuneLogWatch/UnifiedLogReader.swift new file mode 100644 index 0000000..4ea6c85 --- /dev/null +++ b/IntuneLogWatch/UnifiedLogReader.swift @@ -0,0 +1,214 @@ +// +// UnifiedLogReader.swift +// IntuneLogWatch +// +// Reads macOS unified logs via `log show` to capture mdmclient +// LOB (managed PKG) app deployment events. +// + +import Foundation + +class UnifiedLogReader { + + // MARK: - Public API + + /// Query mdmclient logs for InstallApplication events + func queryLOBInstalls(since duration: String = "24h") async throws -> [UnifiedLogEntry] { + let jsonEntries = try await runLogShow(duration: duration) + return parseJSONEntries(jsonEntries) + } + + /// Query with a specific start date + func queryLOBInstalls(since date: Date) async throws -> [UnifiedLogEntry] { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + formatter.locale = Locale(identifier: "en_US_POSIX") + let dateString = formatter.string(from: date) + + let jsonEntries = try await runLogShowSinceDate(dateString) + return parseJSONEntries(jsonEntries) + } + + /// Check if we can access unified logs + func checkAccess() async -> Bool { + do { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/log") + process.arguments = ["show", "--predicate", "process == \"mdmclient\"", "--last", "1m", "--style", "json"] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() + + try process.run() + process.waitUntilExit() + + return process.terminationStatus == 0 + } catch { + return false + } + } + + // MARK: - Private: Run log show command + + private func runLogShow(duration: String) async throws -> [[String: Any]] { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/log") + process.arguments = [ + "show", + "--predicate", Self.mdmPredicate, + "--info", "--debug", + "--style", "json", + "--last", duration + ] + + return try await executeLogProcess(process) + } + + private func runLogShowSinceDate(_ dateString: String) async throws -> [[String: Any]] { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/log") + process.arguments = [ + "show", + "--predicate", Self.mdmPredicate, + "--info", "--debug", + "--style", "json", + "--start", dateString + ] + + return try await executeLogProcess(process) + } + + private func executeLogProcess(_ process: Process) async throws -> [[String: Any]] { + let outputPipe = Pipe() + let errorPipe = Pipe() + process.standardOutput = outputPipe + process.standardError = errorPipe + + try process.run() + + // Read output asynchronously + let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() + let errorString = String(data: errorData, encoding: .utf8) ?? "Unknown error" + throw UnifiedLogError.commandFailed(errorString) + } + + guard !outputData.isEmpty else { + return [] + } + + // Parse JSON array + guard let json = try? JSONSerialization.jsonObject(with: outputData) as? [[String: Any]] else { + // Sometimes log show returns empty or invalid JSON + return [] + } + + return json + } + + // MARK: - JSON Parsing + + private func parseJSONEntries(_ entries: [[String: Any]]) -> [UnifiedLogEntry] { + // The timestamp format from `log show --style json` is like: + // "2026-03-18 10:29:05.055328-0500" + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSSSSZ" + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + + let altDateFormatter = DateFormatter() + altDateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSSSS" + altDateFormatter.locale = Locale(identifier: "en_US_POSIX") + + // Timezone offset format: "2026-03-18 10:29:05.055328-0500" + let tzDateFormatter = DateFormatter() + tzDateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSSSSZ" + tzDateFormatter.locale = Locale(identifier: "en_US_POSIX") + + return entries.compactMap { entry -> UnifiedLogEntry? in + guard let timestampStr = entry["timestamp"] as? String, + let message = entry["eventMessage"] as? String else { + return nil + } + + // Try parsing with timezone offset first (most common from log show) + let cleanTimestamp = timestampStr.trimmingCharacters(in: .whitespaces) + let timestamp = dateFormatter.date(from: cleanTimestamp) + ?? altDateFormatter.date(from: cleanTimestamp) + ?? parseFlexibleTimestamp(cleanTimestamp) + ?? Date() + + // processImagePath gives full path like "/usr/libexec/mdmclient" + let processPath = entry["processImagePath"] as? String ?? "" + let processName = processPath.isEmpty + ? (entry["process"] as? String ?? "unknown") + : (processPath as NSString).lastPathComponent + let subsystem = entry["subsystem"] as? String ?? "" + let category = entry["category"] as? String ?? "" + let levelStr = entry["messageType"] as? String ?? "Default" + + return UnifiedLogEntry( + timestamp: timestamp, + process: processName, + subsystem: subsystem, + category: category, + level: UnifiedLogLevel(fromLogShow: levelStr), + message: message + ) + } + } + + /// Flexible timestamp parser for edge cases + private func parseFlexibleTimestamp(_ str: String) -> Date? { + // Handle "2026-03-18 10:29:05.055328-0500" format + // The issue is the timezone offset without colon: -0500 vs -05:00 + let iso = ISO8601DateFormatter() + iso.formatOptions = [.withFullDate, .withFullTime, .withFractionalSeconds, .withTimeZone] + + // Try inserting colon in timezone offset + if str.count > 6 { + let sign = str[str.index(str.endIndex, offsetBy: -5)] + if sign == "+" || sign == "-" { + var modified = str + modified.insert(":", at: str.index(str.endIndex, offsetBy: -2)) + // Replace space with T for ISO8601 + modified = modified.replacingOccurrences(of: " ", with: "T", range: modified.range(of: " ")) + if let date = iso.date(from: modified) { + return date + } + } + } + + return nil + } + + // MARK: - Predicate + + /// Combined predicate for MDM app installation events. + /// Tightly scoped to avoid picking up general mdmclient housekeeping. + static let mdmPredicate = """ + (process == "mdmclient" AND subsystem == "com.apple.ManagedClient" AND \ + category == "InstallApplication") OR \ + (process == "mdmclient" AND eventMessage CONTAINS "InstallApplication") OR \ + process == "storedownloadd" + """ +} + +// MARK: - Errors + +enum UnifiedLogError: LocalizedError { + case commandFailed(String) + case accessDenied + + var errorDescription: String? { + switch self { + case .commandFailed(let detail): + return "Failed to query unified logs: \(detail)" + case .accessDenied: + return "Unable to access macOS unified logs. The app may need Full Disk Access permission." + } + } +} diff --git a/IntuneLogWatch/ViewController.swift b/IntuneLogWatch/ViewController.swift index 8e604ce..2f20336 100644 --- a/IntuneLogWatch/ViewController.swift +++ b/IntuneLogWatch/ViewController.swift @@ -10,8 +10,20 @@ import SwiftUI struct ContentView: View { @StateObject private var parser = LogParser() - @State private var selectedSyncEvent: SyncEvent? + @StateObject private var lobEngine = LOBCorrelationEngine() + @State private var selectedEvent: SidebarEvent? @State private var selectedPolicy: PolicyExecution? + + // Convenience accessors for compatibility with existing detail views + private var selectedSyncEvent: SyncEvent? { + if case .agentSync(let event) = selectedEvent { return event } + return nil + } + private var selectedLOBEvent: LOBAppEvent? { + if case .lobInstall(let event) = selectedEvent { return event } + return nil + } + @State private var showingFilePicker = false @Binding var showingCertificateInspector: Bool @Binding var sidebarVisibility: NavigationSplitViewVisibility @@ -33,16 +45,18 @@ struct ContentView: View { case syncOnly = "sync" case recurringOnly = "recurring" case healthOnly = "health" + case lobOnly = "lob" var displayName: String { switch self { case .syncOnly: return "Sync Events" case .recurringOnly: return "Recurring Events" case .healthOnly: return "Health Events" + case .lobOnly: return "LOB Installs" } } - func displayNameWithCount(syncCount: Int, recurringCount: Int, healthCount: Int) -> String { + func displayNameWithCount(syncCount: Int, recurringCount: Int, healthCount: Int, lobCount: Int) -> String { switch self { case .syncOnly: return "Sync\nEvents\n(\(syncCount))" @@ -50,17 +64,21 @@ struct ContentView: View { return "Recurring\nEvents\n(\(recurringCount))" case .healthOnly: return "Health\nEvents\n(\(healthCount))" + case .lobOnly: + return "LOB\nInstalls\n(\(lobCount))" } } func toolTipForFilter() -> String { switch self { case .syncOnly: - return "Sync Events" + return "Sync Events (⌘1)" case .recurringOnly: - return "Recurring Events" + return "Recurring Events (⌘2)" case .healthOnly: - return "Health Events" + return "Health Events (⌘3)" + case .lobOnly: + return "LOB Installs (⌘4)" } } @@ -69,6 +87,7 @@ struct ContentView: View { case .syncOnly: return "1" case .recurringOnly: return "2" case .healthOnly: return "3" + case .lobOnly: return "4" } } } @@ -92,21 +111,52 @@ struct ContentView: View { _eventFilter = State(initialValue: filter) } } - + + private var navigationTitle: String { + if eventFilter == .lobOnly { + if let analysis = lobEngine.analysis { + return "LOB Installs (\(analysis.totalEvents) events)" + } + return "LOB Installs" + } + return parser.analysis?.sourceTitle ?? "IntuneLogWatch LOB" + } + var body: some View { GeometryReader { geometry in NavigationSplitView(columnVisibility: $sidebarVisibility) { - sidebar.frame(minWidth: 300) + sidebar + .frame(minWidth: 300) } content: { - syncEventDetail + switch selectedEvent { + case .agentSync: + syncEventDetail + case .lobInstall(let event): + LOBDetailView(event: event) + case nil: + VStack { + Image(systemName: "sidebar.left") + .font(.largeTitle) + .foregroundColor(.secondary) + Text("Select an Event") + .font(.headline) + Text("Choose an event from the sidebar to view its details") + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } } detail: { - policyDetail - .frame(minWidth: 400) + if case .agentSync = selectedEvent { + policyDetail + .frame(minWidth: 400) + } else { + EmptyView() + } } - .navigationTitle(parser.analysis?.sourceTitle ?? "Intune Log Watch") + .navigationTitle(navigationTitle) .fileImporter( isPresented: $showingFilePicker, allowedContentTypes: [.log, .plainText], @@ -126,10 +176,15 @@ struct ContentView: View { if parser.analysis == nil && parser.error == nil { parser.loadLocalIntuneLogs() } - + + // Also load LOB data in background + if lobEngine.analysis == nil && lobEngine.error == nil { + lobEngine.loadLOBData() + } + // Capture the window height on appearance windowHeight = geometry.size.height - + // Add local event monitor for mouse movement NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved]) { event in self.handleMouseMoved(event, windowHeight: geometry.size.height) @@ -139,7 +194,7 @@ struct ContentView: View { } .onReceive(NotificationCenter.default.publisher(for: .openLogFile)) { _ in // Trigger the same action as the toolbar button - selectedSyncEvent = nil + selectedEvent = nil selectedPolicy = nil parser.analysis = nil parser.error = nil @@ -157,9 +212,11 @@ struct ContentView: View { } .onReceive(NotificationCenter.default.publisher(for: .reloadLocalLogs)) { _ in // Trigger the same action as the reload button - selectedSyncEvent = nil + selectedEvent = nil selectedPolicy = nil parser.loadLocalIntuneLogs() + // Also reload LOB data + lobEngine.loadLOBData() } .onReceive(NotificationCenter.default.publisher(for: .focusSearchField)) { _ in // Handle search focus at the top level @@ -176,9 +233,11 @@ struct ContentView: View { // Auto-select first sync event when parsing completes if !newValue, let analysis = parser.analysis, selectedSyncEvent == nil { let sortedEvents = sortedSyncEvents(analysis.syncEvents) - selectedSyncEvent = sortedEvents.first + if let first = sortedEvents.first { + selectedEvent = .agentSync(first) + } syncEventListFocused = true - + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { withAnimation(.spring(duration: 1.0, bounce: 0.5, blendDuration: 1.0)) { infoViewVisible = false @@ -201,7 +260,7 @@ struct ContentView: View { .toolbar { ToolbarItemGroup(placement: .primaryAction) { Button("Open Log File…", systemImage: "arrow.up.right") { - selectedSyncEvent = nil + selectedEvent = nil selectedPolicy = nil parser.analysis = nil parser.error = nil @@ -218,9 +277,10 @@ struct ContentView: View { .animation(.easeInOut(duration: 0.25), value: sidebarVisibility) Button("Reload Local Logs…", systemImage: "arrow.clockwise") { - selectedSyncEvent = nil + selectedEvent = nil selectedPolicy = nil parser.loadLocalIntuneLogs() + lobEngine.loadLOBData() } .disabled(parser.isLoading) .help("Reload Local Logs…") @@ -281,7 +341,7 @@ struct ContentView: View { ProgressView("Parsing log file...") // .frame(maxWidth: .infinity, maxHeight: .infinity) } else if let analysis = parser.analysis { - + Group { appInfoHeader() enrollmentHeader(analysis) @@ -289,19 +349,26 @@ struct ContentView: View { analysisHeader(analysis) filterControls(analysis: analysis) - sortControls + if eventFilter != .lobOnly { + sortControls + } } .background(Color(NSColor.controlBackgroundColor)) - - if #available(macOS 14.0, *) { - List(sortedSyncEvents(analysis.syncEvents), selection: $selectedSyncEvent) { syncEvent in - SyncEventRow( - syncEvent: syncEvent, - isSelected: selectedSyncEvent?.id == syncEvent.id - ) - .tag(syncEvent) + + if eventFilter == .lobOnly { + lobEventList + } else if #available(macOS 14.0, *) { + let wrappedEvents = sortedSyncEvents(analysis.syncEvents).map { SidebarEvent.agentSync($0) } + List(wrappedEvents, selection: $selectedEvent) { event in + if case .agentSync(let syncEvent) = event { + SyncEventRow( + syncEvent: syncEvent, + isSelected: selectedEvent?.id == syncEvent.id + ) + .tag(event) + } } .focused($syncEventListFocused) .onKeyPress(.rightArrow) { @@ -315,7 +382,9 @@ struct ContentView: View { // Auto-select the first sync event when the list appears if selectedSyncEvent == nil { let sortedEvents = sortedSyncEvents(analysis.syncEvents) - selectedSyncEvent = sortedEvents.first + if let first = sortedEvents.first { + selectedEvent = .agentSync(first) + } syncEventListFocused = true } } @@ -323,6 +392,13 @@ struct ContentView: View { // Fallback on earlier versions } + } else if eventFilter == .lobOnly { + Group { + appInfoHeader() + filterControls(analysis: nil) + } + .background(Color(NSColor.controlBackgroundColor)) + lobEventList } else if let error = parser.error { VStack { Image(systemName: "exclamationmark.triangle") @@ -353,7 +429,7 @@ struct ContentView: View { ToolbarItemGroup(placement: .primaryAction) { Spacer() Button("Open Log File…", systemImage: "arrow.up.right") { - selectedSyncEvent = nil + selectedEvent = nil selectedPolicy = nil parser.analysis = nil parser.error = nil @@ -370,9 +446,10 @@ struct ContentView: View { .animation(.easeInOut(duration: 0.25), value: sidebarVisibility) Button("Reload Local Logs…", systemImage: "arrow.clockwise") { - selectedSyncEvent = nil + selectedEvent = nil selectedPolicy = nil parser.loadLocalIntuneLogs() + lobEngine.loadLOBData() } .disabled(parser.isLoading) .help("Reload Local Logs…") @@ -882,6 +959,73 @@ struct ContentView: View { .background(Color(NSColor.controlBackgroundColor)) } + // MARK: - LOB Event List (shown when lobOnly filter is active) + + private var lobEventList: some View { + Group { + if lobEngine.isLoading { + VStack { + Spacer() + ProgressView("Querying LOB installs...") + Spacer() + } + } else if let error = lobEngine.error { + VStack(spacing: 12) { + Spacer() + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 40)) + .foregroundColor(.orange) + Text("Unable to Load LOB Data") + .font(.headline) + Text(error) + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + Button("Retry") { + lobEngine.loadLOBData() + } + .buttonStyle(.borderedProminent) + Spacer() + } + .padding() + } else if let analysis = lobEngine.analysis, !analysis.events.isEmpty { + let wrappedEvents = analysis.events.map { SidebarEvent.lobInstall($0) } + if #available(macOS 14.0, *) { + List(wrappedEvents, selection: $selectedEvent) { event in + if case .lobInstall(let lobEvent) = event { + LOBEventRow(event: lobEvent) + .tag(event) + } + } + } else { + List(wrappedEvents) { event in + if case .lobInstall(let lobEvent) = event { + LOBEventRow(event: lobEvent) + .onTapGesture { selectedEvent = event } + } + } + } + } else { + VStack(spacing: 12) { + Spacer() + Image(systemName: "shippingbox") + .font(.system(size: 40)) + .foregroundColor(.secondary) + Text("No LOB App Events") + .font(.headline) + .foregroundColor(.secondary) + Text("No managed PKG deployments detected.") + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + Spacer() + } + .padding() + } + } + } + private func analysisHeader(_ analysis: LogAnalysis) -> some View { VStack(alignment: .leading, spacing: 2) { @@ -957,6 +1101,40 @@ struct ContentView: View { .padding(.trailing, 10) .padding(.bottom, 6) + // LOB stats + if let lobAnalysis = lobEngine.analysis, lobAnalysis.totalEvents > 0 { + Divider() + .padding(.leading, 18) + .padding(.trailing, 40) + .padding(.bottom, 6) + + HStack { + Image(systemName: "shippingbox.fill") + .foregroundColor(.indigo) + .font(.caption) + Text("LOB Installs:") + .font(.caption) + .foregroundColor(.secondary) + Text("\(lobAnalysis.totalEvents)") + .font(.caption) + .fontWeight(.semibold) + if lobAnalysis.completedEvents > 0 { + Label("\(lobAnalysis.completedEvents)", systemImage: "checkmark.circle.fill") + .font(.caption) + .foregroundColor(.green) + } + if lobAnalysis.failedEvents > 0 { + Label("\(lobAnalysis.failedEvents)", systemImage: "xmark.circle.fill") + .font(.caption) + .foregroundColor(.red) + } + Spacer() + } + .padding(.leading, 20) + .padding(.trailing, 10) + .padding(.bottom, 6) + } + } } .font(.headline) @@ -964,7 +1142,7 @@ struct ContentView: View { Divider() .padding(.bottom, 6) - + } .background(Color(NSColor.controlBackgroundColor)) } @@ -1021,10 +1199,11 @@ struct ContentView: View { } } - private func filterControls(analysis: LogAnalysis) -> some View { - let syncCount = analysis.syncEvents.filter { $0.eventType == .fullSync }.count - let recurringCount = analysis.syncEvents.filter { $0.eventType == .recurringPolicy }.count - let healthCount = analysis.syncEvents.filter { $0.eventType == .healthPolicy }.count + private func filterControls(analysis: LogAnalysis?) -> some View { + let syncCount = analysis?.syncEvents.filter { $0.eventType == .fullSync }.count ?? 0 + let recurringCount = analysis?.syncEvents.filter { $0.eventType == .recurringPolicy }.count ?? 0 + let healthCount = analysis?.syncEvents.filter { $0.eventType == .healthPolicy }.count ?? 0 + let lobCount = lobEngine.analysis?.totalEvents ?? 0 return VStack(spacing: 0) { @@ -1037,14 +1216,14 @@ struct ContentView: View { } .padding(.horizontal) .padding(.vertical, 2) - + HStack(spacing: 4) { ForEach(EventFilter.allCases, id: \.self) { filter in Button(action: { eventFilter = filter }) { - - Text(filter.displayNameWithCount(syncCount: syncCount, recurringCount: recurringCount, healthCount: healthCount)) + + Text(filter.displayNameWithCount(syncCount: syncCount, recurringCount: recurringCount, healthCount: healthCount, lobCount: lobCount)) .multilineTextAlignment(.center) .font(.caption) .padding(.horizontal, 4) @@ -1068,51 +1247,36 @@ struct ContentView: View { UserDefaults.standard.set(newValue.rawValue, forKey: "EventFilterPreference") // Clear selection first - selectedSyncEvent = nil + selectedEvent = nil selectedPolicy = nil - // Delay the selection to allow the List to update its content first - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - let filteredEvents = sortedSyncEvents(analysis.syncEvents) - if !filteredEvents.isEmpty { - selectedSyncEvent = filteredEvents.first + if newValue == .lobOnly { + // LOB filter — load data if needed + if lobEngine.analysis == nil && !lobEngine.isLoading { + lobEngine.loadLOBData() + } + } else if let analysis = analysis { + // Agent filter — auto-select first event + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + let filteredEvents = sortedSyncEvents(analysis.syncEvents) + if let first = filteredEvents.first { + selectedEvent = .agentSync(first) + } } } } HStack(spacing: 4) { - Button(action: {}) { - Text("⌘1") - .font(.caption) - .foregroundColor(eventFilter == .syncOnly ? .blue : .secondary) - .padding(.horizontal, 4) - .frame(maxWidth: .infinity) - - } - .background(Color(NSColor.controlBackgroundColor)) - .buttonStyle(.borderless) - - Button(action: {}) { - Text("⌘2") - .font(.caption) - .foregroundColor(eventFilter == .recurringOnly ? .blue : .secondary) - .padding(.horizontal, 4) - .frame(maxWidth: .infinity) - - } - .background(Color(NSColor.controlBackgroundColor)) - .buttonStyle(.borderless) - - Button(action: {}) { - Text("⌘3") - .font(.caption) - .foregroundColor(eventFilter == .healthOnly ? .blue : .secondary) - .padding(.horizontal, 4) - .frame(maxWidth: .infinity) - + ForEach(Array(EventFilter.allCases.enumerated()), id: \.element) { index, filter in + Button(action: {}) { + Text("⌘\(index + 1)") + .font(.caption) + .foregroundColor(eventFilter == filter ? .blue : .secondary) + .padding(.horizontal, 4) + .frame(maxWidth: .infinity) + } + .background(Color(NSColor.controlBackgroundColor)) + .buttonStyle(.borderless) } - .background(Color(NSColor.controlBackgroundColor)) - .buttonStyle(.borderless) - } .padding(.horizontal) .padding(.vertical, 2) @@ -1189,15 +1353,14 @@ struct ContentView: View { // First, apply the filter var filteredEvents = syncEvents switch eventFilter { -// case .all: -// // Show only Sync and Recurring events (exclude health events as they're too frequent) -// filteredEvents = syncEvents.filter { $0.eventType == .fullSync || $0.eventType == .recurringPolicy } case .syncOnly: filteredEvents = syncEvents.filter { $0.eventType == .fullSync } case .recurringOnly: filteredEvents = syncEvents.filter { $0.eventType == .recurringPolicy } case .healthOnly: filteredEvents = syncEvents.filter { $0.eventType == .healthPolicy } + case .lobOnly: + return [] // LOB events handled separately } // Then, apply the sort diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..2091411 --- /dev/null +++ b/docs/design.md @@ -0,0 +1,134 @@ +# IntuneLogWatchLOB - Design Document + +## Overview + +IntuneLogWatchLOB is a fork of [IntuneLogWatch](https://github.com/gilburns/IntuneLogWatch) that adds visibility into macOS Intune LOB (Line of Business) app deployments. The upstream project only monitors the Intune sidecar agent (`IntuneMDMDaemon`) logs. This fork adds monitoring of the Apple native MDM channel (`mdmclient`) which handles managed PKG deployments. + +## Problem Statement + +Intune uses two completely separate channels for deploying apps to macOS: + +| Aspect | LOB (Managed PKG) | Agent (DMG/Unmanaged PKG) | +|---|---|---| +| Mechanism | Apple native MDM `InstallApplication` | Intune sidecar agent | +| Process | `mdmclient` | `IntuneMDMDaemon` | +| Log Location | macOS unified log (`com.apple.ManagedClient`) | `/Library/Logs/Microsoft/Intune/IntuneMDMDaemon*.log` | +| Install Logs | `/var/log/install.log`, `/Library/Receipts/InstallHistory.plist` | Inline in IntuneMDMDaemon | +| Visible in Upstream? | **No** | Yes | + +LOB apps deployed via the native MDM channel are completely invisible in the upstream IntuneLogWatch. + +## Architecture + +### Data Flow + +``` +macOS Unified Log ──> UnifiedLogReader ──┐ + │ +/var/log/install.log ──> InstallLogParser ──> LOBCorrelationEngine ──> LOBAppEvent[] + │ +InstallHistory.plist ──> InstallHistoryParser ──┘ +``` + +### New Files + +| File | Purpose | +|------|---------| +| `LOBModels.swift` | Data models: `LOBAppEvent`, `UnifiedLogEntry`, `InstallLogEntry`, `LOBPackageReceipt`, `LOBAnalysis`, `SidebarEvent` enum, `ChannelBadge`, lifecycle stages | +| `UnifiedLogReader.swift` | Reads macOS unified logs via `log show --style json` with predicates for mdmclient/storedownloadd/installer | +| `InstallLogParser.swift` | Parses `/var/log/install.log` for MDM install records | +| `InstallHistoryParser.swift` | Parses `/Library/Receipts/InstallHistory.plist` filtering for `processName == "mdmclient"` | +| `LOBCorrelationEngine.swift` | Merges all three data sources into `LOBAppEvent` objects using command UUID + timestamp proximity + package name matching | +| `LOBSidebarView.swift` | Left pane listing LOB deployment events with status/search filtering | +| `LOBDetailView.swift` | Detail pane showing lifecycle timeline, receipt info, unified + install log entries | +| `DeploymentLifecycleView.swift` | Visual pipeline component: MDM Command -> Download -> Installation -> Verification | + +### Modified Files + +| File | Changes | +|------|---------| +| `Models.swift` | Added `DeploymentChannel` enum, `deploymentChannel` property to `PolicyExecution` | +| `LogParser.swift` | Removed `AppPolicyResultsReporter` filter (line ~281), added `deploymentChannel: .agent` to policy creation | +| `ViewController.swift` | Added `LOBCorrelationEngine` state, `SidebarEvent`-based unified selection, LOB Installs filter button (Cmd+4), LOB stats in Analysis Summary header, auto-loads LOB data | +| `ClipLibrary.swift` | Added `deploymentChannel` to `PolicyExecutionSnapshot` for clip persistence | +| `SyncEventDetailView.swift` | Added `ChannelBadge` (from `LOBModels.swift`) to `PolicyRow.policyTypeTiles` | +| `PolicyDetailView.swift` | Added `ChannelBadge` to policy header | +| `project.pbxproj` | Updated bundle identifiers, added new files to QuickLook target membership | + +## Key Design Decisions + +### 1. Using `log show` instead of OSLog API + +We shell out to `/usr/bin/log show --style json` rather than using the OSLog Swift API because: +- `OSLog` API has restricted access in sandboxed apps +- `log show` provides JSON output that's easy to parse +- The command-line tool handles predicate filtering natively +- Fallback is straightforward (detect failure, show permission instructions) + +### 2. Three-Source Correlation + +LOB events are correlated from three independent data sources: +1. **Unified logs** (mdmclient) - Primary source for deployment lifecycle events +2. **install.log** - Detailed installation execution records +3. **InstallHistory.plist** - Definitive record of completed installations + +Correlation strategy: +- Group unified log entries by MDM command UUID +- Match install.log entries by timestamp proximity (30s window) +- Match receipts by package identifier, app name, or timestamp proximity (5min window) +- Fall back to time-gap-based grouping when UUIDs aren't available + +### 3. Non-Destructive Integration + +The LOB functionality is additive: +- Existing Agent Apps view works identically to upstream +- LOB events are integrated into the main sidebar as a 4th filter button ("LOB Installs", Cmd+4) alongside Sync Events, Recurring Events, and Health Events +- A `SidebarEvent` enum wraps both `SyncEvent` and `LOBAppEvent` for unified selection across the sidebar +- All existing keyboard shortcuts, clip library, and export features remain functional +- `DeploymentChannel.agent` is set as default for all existing policies + +### 4. Bundle Identifier Changes + +- Main app: `com.avoges.IntuneLogWatchLOB` +- CLI tool: `com.avoges.IntuneLogWatchLOB-cli` +- QuickLook: `com.avoges.IntuneLogWatchLOB.IntuneLogWatchQuickLook` + +## UI Layout + +LOB events are integrated directly into the main sidebar rather than using a separate tab switcher. The sidebar filter bar includes a 4th button for LOB content: + +``` ++------------------------------------------+ +| Analysis Summary (includes LOB stats) | +|------------------------------------------| +| Filter Bar | +| [Sync Events] [Recurring] [Health] | +| [LOB Installs] (Cmd+1..4) | +|------------------------------------------| +| Sidebar Event List | +| SidebarEvent wraps SyncEvent or | +| LOBAppEvent for unified selection | +|------------------------------------------| +| Content Pane | +| SyncEvent selected: SyncEventDetailView| +| LOBAppEvent selected: LOBDetailView | +|------------------------------------------| +| Detail Pane | +| SyncEvent: PolicyDetailView | +| LOBAppEvent: (integrated in LOBDetail) | ++------------------------------------------+ +``` + +## Permissions & Requirements + +- **macOS 14.6+** (same as upstream) +- **Full Disk Access** may be required for unified log access +- No app sandbox (same as upstream - entitlements file is empty) +- The app gracefully handles permission failures with user-facing instructions + +## Limitations + +- Unified log retention is limited (hours to days depending on macOS settings) +- `log show` output format may vary slightly across macOS versions (mitigated by `--style json`) +- MDM command UUID correlation may not work for all MDM command types +- InstallHistory.plist only records completed installations, not failures