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