From 63d4142c7f58ba58a36c3290dcd1312d7b7a14ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Alm=C3=A9n?= <78877636+almenscorner@users.noreply.github.com> Date: Mon, 20 Oct 2025 16:05:54 +0200 Subject: [PATCH 001/104] Add support for Jamf updates and enhance application management - Introduced `PendingJamfUpdate` model to handle pending updates from Jamf. - Created `PendingJamfUpdatesManager` to manage fetching and processing of Jamf updates. - Updated `AppStateManager` to include Jamf updates management. - Enhanced `ApplicationsInfoManager` to retrieve installed Jamf applications. - Modified `AppCard` to load icons for Jamf applications asynchronously. - Updated UI components (`PendingUpdatesCard`, `PatchingProgressCard`) to display Jamf updates. - Added `InfoHelp` view for contextual help with icons. - Enhanced `Preferences` to include settings for Jamf integration. - Improved handling of application icons and action texts for installed apps. --- SupportCompanion/AppDelegate.swift | 14 +- SupportCompanion/Constants.swift | 3 + SupportCompanion/Helpers/JamfApps.swift | 408 ++++++++++++++++++ SupportCompanion/Models/InfoHelp.swift | 27 ++ SupportCompanion/Models/InstalledApp.swift | 2 + .../Models/PendingJamfUpdate.swift | 41 ++ SupportCompanion/Preferences.swift | 114 ++++- .../Protocols/PendingUpdate.swift | 1 + .../ViewModels/AppStateManager.swift | 80 +++- .../ViewModels/ApplicationsInfoManager.swift | 56 ++- .../PendingJamfUpdatesManager.swift | 139 ++++++ SupportCompanion/Views/Cards/AppCard.swift | 54 ++- .../Views/Cards/PatchingProgressCard.swift | 9 + .../Views/Cards/PendingUpdatesCard.swift | 48 +-- 14 files changed, 942 insertions(+), 54 deletions(-) create mode 100644 SupportCompanion/Helpers/JamfApps.swift create mode 100644 SupportCompanion/Models/InfoHelp.swift create mode 100644 SupportCompanion/Models/PendingJamfUpdate.swift create mode 100644 SupportCompanion/ViewModels/PendingJamfUpdatesManager.swift diff --git a/SupportCompanion/AppDelegate.swift b/SupportCompanion/AppDelegate.swift index dfa5639..e787107 100644 --- a/SupportCompanion/AppDelegate.swift +++ b/SupportCompanion/AppDelegate.swift @@ -89,7 +89,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { } func applicationDidFinishLaunching(_ notification: Notification) { - if !AppDelegate.shouldExit { + if !AppDelegate.shouldExit && appStateManager.preferences.trayMenuShowIcon { setupTrayMenu() } @@ -294,7 +294,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { NSApp.setActivationPolicy(.regular) let contentView = ContentView() .environmentObject(AppStateManager.shared) - .environmentObject(Preferences()) + .environmentObject(AppStateManager.shared.preferences) .frame(minWidth: 1100, minHeight: 650) let hostingController = NSHostingController(rootView: contentView) @@ -333,10 +333,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { } private func configureAppUpdateNotificationCommand(mode: String) { - if mode == "Munki" { + if mode == Constants.modes.munki { appStateManager.preferences.appUpdateNotificationCommand = "open \(Constants.AppPaths.MSCUpdates)" - } else { + } else if mode == Constants.modes.intune { appStateManager.preferences.appUpdateNotificationCommand = "open \(Constants.AppPaths.companyPortal)" - } - } + } else if mode == Constants.modes.jamf { + appStateManager.preferences.appUpdateNotificationCommand = "open \(Constants.AppPaths.selfService)" + } + } } diff --git a/SupportCompanion/Constants.swift b/SupportCompanion/Constants.swift index a00433f..bae3086 100644 --- a/SupportCompanion/Constants.swift +++ b/SupportCompanion/Constants.swift @@ -27,6 +27,7 @@ enum Constants { static let munki = "Munki" static let intune = "Intune" static let systemProfiler = "SystemProfiler" + static let jamf = "Jamf" } enum TrayMenu { @@ -55,10 +56,12 @@ enum Constants { static let companyPortal = "/Applications/Company Portal.app" static let MSC = "/Applications/Managed Software Center.app" static let MSCUpdates = "munki://updates.html" + static let selfService = "/Applications/Self Service+.app" } enum Paths { static let tempArchivePath = "/tmp/supportcompanion_logs.zip" + static let jamfSelfServiceData = "~/Library/Application Support/osx-self-service.Self-Service.resources/CocoaAppCD.storedata" } enum Panels { diff --git a/SupportCompanion/Helpers/JamfApps.swift b/SupportCompanion/Helpers/JamfApps.swift new file mode 100644 index 0000000..1825c6e --- /dev/null +++ b/SupportCompanion/Helpers/JamfApps.swift @@ -0,0 +1,408 @@ +// +// JamfApps.swift +// SupportCompanion +// +// Created by Tobias Almén on 2025-10-13. +// + +import Foundation + +// MARK: - Date helpers (CoreData XML uses absolute seconds since 2001-01-01 a lot) +extension Date { + static func fromCoreDataEpochSeconds(_ s: String) -> Date? { + guard let d = Double(s) else { return nil } + return Date(timeIntervalSinceReferenceDate: d) // 2001-01-01 00:00:00 +0000 + } +} + +extension String { + var normalizedAppName: String { + trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + } +} + +// MARK: - Tiny XML parser (sax-style) + +final class SSPlusParser: NSObject, XMLParserDelegate { + private(set) var policies: [Policy] = [] + private(set) var patches: [Patch] = [] + + // parser state + private var currentObjectType: String? // SSPOLICY, SSPATCH, ... + private var currentAttributes: [String:String] = [:] + private var currentAttributeName: String? + private var currentText = "" + + func parse() async -> Bool { + let path = (Constants.Paths.jamfSelfServiceData as NSString).expandingTildeInPath + guard let data = FileManager.default.contents(atPath: path) else { + Logger.shared.logError("Failure to read storedata file") + return false + } + policies.removeAll() + patches.removeAll() + let p = XMLParser(data: data) + p.delegate = self + return p.parse() + } + + // MARK: XMLParserDelegate + + func parser(_ parser: XMLParser, didStartElement name: String, + namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String : String] = [:]) { + + if name == "object" { + currentObjectType = attributeDict["type"] // e.g. "SSPOLICY" + currentAttributes = [:] + } else if name == "attribute" { + currentAttributeName = attributeDict["name"] // e.g. "name", "version" + currentText = "" + } + } + + func parser(_ parser: XMLParser, foundCharacters string: String) { + currentText.append(string) + } + + func parser(_ parser: XMLParser, didEndElement name: String, + namespaceURI: String?, qualifiedName qName: String?) { + + if name == "attribute" { + if let key = currentAttributeName { + // Trim whitespace/newlines — sample has newlines and long spaces. + currentAttributes[key] = currentText.trimmingCharacters(in: .whitespacesAndNewlines) + } + currentAttributeName = nil + currentText = "" + } else if name == "object" { + commitCurrentObject() + currentObjectType = nil + currentAttributes = [:] + } + } + + private func commitCurrentObject() { + guard let t = currentObjectType else { return } + + switch t { + case "SSPOLICY": + let name = (currentAttributes["name"] ?? "").normalizedAppName + // Extract "#### Version: X" from serverdescription (defensive) + let desc = currentAttributes["serverdescription"] ?? "" + let policyVersion = SSPlusParser.extractVersionFromPolicyDescription(desc) + + let installedDate = Date.fromCoreDataEpochSeconds(currentAttributes["installedorupdateddate"] ?? "") + let installStatus = Int(currentAttributes["installstatus"] ?? "") + let iconUrl = currentAttributes["iconurl"] ?? "" + let id = Int(currentAttributes["id"] ?? "") ?? 0 + let postInstallText = currentAttributes["postinstalltext"] ?? "" + // store policy info + policies.append(Policy(id: id, + name: name, + policyVersion: policyVersion, + installedOrUpdated: installedDate, + installStatus: installStatus, + iconUrl: iconUrl, + postInstallText: postInstallText)) + + case "SSPATCH": + let name = (currentAttributes["name"] ?? "").normalizedAppName + guard let version = currentAttributes["version"], !version.isEmpty else { return } + + let available = Date.fromCoreDataEpochSeconds(currentAttributes["availabledate"] ?? "") + let deadline = Date.fromCoreDataEpochSeconds(currentAttributes["deadline"] ?? "") + let button = currentAttributes["buttontext"] + let installStatus = Int(currentAttributes["installstatus"] ?? "") + + patches.append(Patch(name: name, + version: version, + availableDate: available, + deadlineDate: deadline, + buttonText: button, + installStatus: installStatus)) + + default: + break + } + } + + private static func extractVersionFromPolicyDescription(_ text: String) -> String? { + // Example: "#### Version: 139.1.81.137\n\nBrave is ..." + // Simple, forgiving regex: + let pattern = #"(?i)\bversion:\s*([0-9A-Za-z\.\-\+_]+)"# + guard let r = try? NSRegularExpression(pattern: pattern) else { return nil } + let ns = text as NSString + if let m = r.firstMatch(in: text, range: NSMakeRange(0, ns.length)), m.numberOfRanges >= 2 { + return ns.substring(with: m.range(at: 1)) + } + return nil + } +} + +// MARK: - “Update available?” logic + +enum UpdateLabel: CustomStringConvertible { + case notYetAvailable(Date) + case dueBy(Date) + case overdue(Date) + case upToDate + case dueNoDeadline + case unknown(String) + + var description: String { + switch self { + case .notYetAvailable(let a): + return "Not yet available (available at \(ISO8601DateFormatter().string(from: a)))" + case .dueBy(let d): + return "Due by \(ISO8601DateFormatter().string(from: d))" + case .overdue(let d): + return "OVERDUE since \(ISO8601DateFormatter().string(from: d))" + case .upToDate: + return "Up to date (by dates)" + case .dueNoDeadline: + return "Due (no deadline)" + case .unknown(let why): + return "Unknown (\(why))" + } + } +} + +/// If `requireVersionMismatch` is true, we only flag "needed" when the policy version != patch version. +/// If false, we rely on dates alone (recommended when policyVersion can be missing/noisy). +func evaluateUpdate(policy: Policy, patch: Patch, now: Date = .init()) -> (needed: Bool, label: UpdateLabel) { + + func due(_ deadline: Date?) -> UpdateLabel { + deadline.map { .dueBy($0) } ?? .dueNoDeadline + } + + // 1. Missing availability → version-only check + guard let avail = patch.availableDate else { + if let pv = policy.policyVersion { + return (pv != patch.version, pv != patch.version ? due(nil) : .upToDate) + } + return (false, .unknown("missing availableDate")) + } + + // 2. Overdue beats everything + if let d = patch.deadlineDate, now >= d { + // first try version matching + + return (true, .overdue(d)) + } + + // 3. Not yet available + if now < avail { + return (false, .notYetAvailable(avail)) + } + + // 4. Facts + let installedAfterAvail = (policy.installedOrUpdated ?? .distantPast) >= avail + let versionMatches = policy.policyVersion.map { $0 == patch.version } // Optional + + // 5. Installed after availability + if installedAfterAvail { + switch versionMatches { + case .some(false): return (true, due(patch.deadlineDate)) // known mismatch + default: return (false, .upToDate) // match or nil → trust the date + } + } + + // 6. Installed before availability or unknown install time + if let d = patch.deadlineDate { + return (true, .dueBy(d)) // deadline drives even without version + } + + switch versionMatches { + case .some(true): return (false, .upToDate) + case .some(false): return (true, .dueNoDeadline) + case .none: return (false, .unknown("missing policyVersion past availability")) + } +} + +func computeUpdates(policies: [Policy], + patches: [Patch], + now: Date = Date()) async -> ([PendingJamfUpdate], Int, Int) { + + // MARK: - Non-hardcoded fuzzy name matching helpers + func canonicalTokens(_ s: String) -> [String] { + // Lowercase and remove non-alphanumerics to get stable tokens + let lowered = s.lowercased() + let cleaned = lowered.replacingOccurrences(of: #"[^a-z0-9]+"#, with: " ", options: .regularExpression) + let tokens = cleaned.split(separator: " ").map(String.init) + // Remove very short/common tokens to reduce false positives + let stop: Set = ["client", "for", "the", "and", "app", "apps", "application", "meetings", "installer", "update", "patch"] + return tokens.filter { $0.count >= 3 && !stop.contains($0) } + } + + func fuzzyMatchPolicy(for patchName: String, in dict: [String: Policy]) -> Policy? { + let pTokens = Set(canonicalTokens(patchName)) + guard !pTokens.isEmpty else { return nil } + + var best: (policy: Policy, score: Double)? + for (policyName, policy) in dict { + let t = Set(canonicalTokens(policyName)) + if t.isEmpty { continue } + let overlap = Double(pTokens.intersection(t).count) + let denom = Double(min(pTokens.count, t.count)) + let score = denom > 0 ? overlap / denom : 0 + // Threshold tuned for distinctive names like "zoom", "slack", "chrome". + if score >= 0.5 { + if let current = best { + if score > current.score { + best = (policy, score) + } else if score == current.score { + // Tie-break using existing recency/priority logic + let s0 = policy.installStatus ?? 0 + let s1 = current.policy.installStatus ?? 0 + if s0 > s1 || (s0 == s1 && (policy.installedOrUpdated ?? .distantPast) > (current.policy.installedOrUpdated ?? .distantPast)) { + best = (policy, score) + } + } + } else { + best = (policy, score) + } + } + } + return best?.policy + } + + // Keep one policy per name: the newest (by installedOrUpdated date) + let policiesByName: [String: Policy] = Dictionary(grouping: policies, by: { $0.name }) + .compactMapValues { group in + group.sorted { + let s0 = $0.installStatus ?? 0, s1 = $1.installStatus ?? 0 + if s0 != s1 { return s0 > s1 } + return ($0.installedOrUpdated ?? .distantPast) > ($1.installedOrUpdated ?? .distantPast) + }.first + } + var results: [PendingJamfUpdate] = [] + var updateCount = 0 + var upToDateCount = 0 + + for patch in patches { + // Try exact match by normalized name first + var matchedPolicy: Policy? = policiesByName[patch.name] + // Fallback to fuzzy token-based match if exact match fails + if matchedPolicy == nil { + matchedPolicy = fuzzyMatchPolicy(for: patch.name, in: policiesByName) + } + guard let policy = matchedPolicy else { continue } + + let (needed, label) = evaluateUpdate(policy: policy, patch: patch, now: now) + let details = "available=\(patch.availableDate?.description ?? "nil"), " + + "deadline=\(patch.deadlineDate?.description ?? "nil"), " + + "lastInstall=\(policy.installedOrUpdated?.description ?? "nil"), " + + "policyVersion=\(policy.policyVersion ?? "nil"), " + + "patchVersion=\(patch.version)" + if needed { + results.append(PendingJamfUpdate( + id: UUID(), + name: patch.name, + version: patch.version, + needsUpdate: needed, + label: label, + details: details, + showInfoIcon: !details.isEmpty + )) + } + + // Count how many updates we have versus how many apps are up to date + if needed { + updateCount += 1 + } else { + upToDateCount += 1 + } + } + return (results, updateCount, upToDateCount) +} + +func getInstalledJamfAppsFromStore() async -> [String: Any] { + let path = (Constants.Paths.jamfSelfServiceData as NSString).expandingTildeInPath + guard let _ = FileManager.default.contents(atPath: path) else { + Logger.shared.logError("Failure to read storedata file") + return [:] + } + var apps: [String: Any] = [:] + let parser = SSPlusParser() + let parsed = await parser.parse() + if !parsed { + Logger.shared.logError("Failed to parse Jamf storedata XML") + return [:] + } + for policy in parser.policies { + if policy.installStatus == 4 { + // verify the app exists in /Applications or ~/Applications + let appPath1 = "/Applications/\(policy.name).app" + let appPath2 = ("~/Applications/\(policy.name).app" as NSString).expandingTildeInPath + if !FileManager.default.fileExists(atPath: appPath1) && !FileManager.default.fileExists(atPath: appPath2) { + continue + } + // if policy.policyVersion is nil or empty, try to get version from app's Info.plist + var policyVersion = policy.policyVersion + if (policyVersion ?? "").isEmpty { + let plistPath = FileManager.default.fileExists(atPath: appPath1) ? "\(appPath1)/Contents/Info.plist" : "\(appPath2)/Contents/Info.plist" + policyVersion = getAppVersion(plistPath: plistPath) + } + // store app info + apps[policy.name] = [ + "id": policy.id, + "name": policy.name, + "version": policyVersion ?? "", + "iconUrl": policy.iconUrl ?? "", + "postInstallText": policy.postInstallText ?? "" + ] + } + } + return apps +} + +func downloadAppIcon(forApp: InstalledApp) async -> String { + guard let url = URL(string: forApp.iconUrl ?? "") else { return "" } + + // first try to load from cache + let fileManager = FileManager.default + guard let appSupportDirectory = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { + Logger.shared.logError("Failed to locate Application Support directory.") + return "" + } + + let appSupportJamfDirURL = appSupportDirectory.appendingPathComponent("SupportCompanion/JamfIcons") + let cachedIcons = (try? fileManager.contentsOfDirectory(at: appSupportJamfDirURL, includingPropertiesForKeys: nil, options: [])) ?? [] + for icon in cachedIcons { + if icon.lastPathComponent.contains(forApp.name) { + Logger.shared.logDebug("Using cached icon for \(forApp.name) at \(icon.path)") + return icon.path + } + } + + // get icon data + guard let (iconData, response) = try? await URLSession.shared.data(from: url), + let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + Logger.shared.logError("Failed to download icon from \(forApp.iconUrl ?? "nil")") + return "" + } + + // Ensure directory exists + if !fileManager.fileExists(atPath: appSupportJamfDirURL.path) { + do { + try fileManager.createDirectory(at: appSupportJamfDirURL, withIntermediateDirectories: true, attributes: nil) + } catch { + Logger.shared.logError("Failed to create app support dir: \(error.localizedDescription)") + return "" + } + } + + // Save the icon data to a file + let iconFileURL = appSupportJamfDirURL.appendingPathComponent("\(forApp.name).png") + do { + Logger.shared.logDebug("Saving icon for \(forApp.name) to \(iconFileURL.path)") + try iconData.write(to: iconFileURL) + } catch { + Logger.shared.logError("Failed to save app icon: \(error.localizedDescription)") + return "" + } + return iconFileURL.path +} + diff --git a/SupportCompanion/Models/InfoHelp.swift b/SupportCompanion/Models/InfoHelp.swift new file mode 100644 index 0000000..eb91f46 --- /dev/null +++ b/SupportCompanion/Models/InfoHelp.swift @@ -0,0 +1,27 @@ +// +// InfoHelp.swift +// SupportCompanion +// +// Created by Tobias Almén on 2025-10-14. +// + +import Foundation +import SwiftUI + +struct InfoHelp: View { + let text: String + let icon: String? + let color: Color? + @State private var hovering = false + + var body: some View { + Image(systemName: "\(icon ?? "info.circle")") + .frame(width: 24, height: 24) + .contentShape(Rectangle()) + .onHover { hovering = $0 } + .foregroundColor(color ?? nil) + .popover(isPresented: $hovering, arrowEdge: .top) { + Text(text.isEmpty ? "Details missing" : text).padding() + } + } +} diff --git a/SupportCompanion/Models/InstalledApp.swift b/SupportCompanion/Models/InstalledApp.swift index 51dca65..5451284 100644 --- a/SupportCompanion/Models/InstalledApp.swift +++ b/SupportCompanion/Models/InstalledApp.swift @@ -17,4 +17,6 @@ struct InstalledApp: Identifiable { let path: String let type: String let bundleId: String + let iconUrl: String? + let actionText: String? } diff --git a/SupportCompanion/Models/PendingJamfUpdate.swift b/SupportCompanion/Models/PendingJamfUpdate.swift new file mode 100644 index 0000000..e6d0101 --- /dev/null +++ b/SupportCompanion/Models/PendingJamfUpdate.swift @@ -0,0 +1,41 @@ +// +// PendingJamfUpdate.swift +// SupportCompanion +// +// Created by Tobias Almén on 2025-10-13. +// + +import Foundation + +struct PendingJamfUpdate: Identifiable, Equatable { + let id: UUID + let name: String + let version: String + let needsUpdate: Bool + let label: UpdateLabel + let details: String + let showInfoIcon: Bool + + static func == (lhs: PendingJamfUpdate, rhs: PendingJamfUpdate) -> Bool { + return lhs.id == rhs.id + } +} + +struct Policy { + let id: Int + let name: String + let policyVersion: String? + let installedOrUpdated: Date? + let installStatus: Int? + let iconUrl: String? + let postInstallText: String? +} + +struct Patch { + let name: String + let version: String + let availableDate: Date? + let deadlineDate: Date? + let buttonText: String? + let installStatus: Int? +} diff --git a/SupportCompanion/Preferences.swift b/SupportCompanion/Preferences.swift index a5151a5..e544d6c 100644 --- a/SupportCompanion/Preferences.swift +++ b/SupportCompanion/Preferences.swift @@ -84,6 +84,8 @@ class Preferences: ObservableObject { @AppStorage("CustomCardsMenuIcon") var customCardsMenuIcon: String = "" @AppStorage("TrayMenuBrandingIcon") var trayMenuBrandingIcon: String = "" + + @AppStorage("TrayMenuShowIcon") var trayMenuShowIcon: Bool = true // MARK: - Actions @@ -124,13 +126,34 @@ class Preferences: ObservableObject { // MARK: - Home - @AppStorage("CustomCardPath") var customCardPath: String = "" + @AppStorage("CustomCardPath") var customCardPath: String = "" { + didSet { + if customCardPathPublished != customCardPath { + if Thread.isMainThread { + Logger.shared.logDebug("Preferences: customCardPath didSet -> '\(customCardPath)'") + customCardPathPublished = customCardPath + } else { + DispatchQueue.main.async { [newValue = customCardPath] in + Logger.shared.logDebug("Preferences: customCardPath didSet (async) -> '\(newValue)'") + if self.customCardPathPublished != newValue { + self.customCardPathPublished = newValue + } + } + } + } + } + } + // Published mirror of customCardPath so non-View subscribers can react to changes + @Published var customCardPathPublished: String = "" @Published var hiddenCards: [String] = UserDefaults.standard.array(forKey: "HiddenCards") as? [String] ?? [] private var cancellable: AnyCancellable? private var cancellables = Set() + // Watcher for ~/Library/Preferences to detect external defaults writes + private var prefsDirSource: DispatchSourceFileSystemObject? + private var prefsDirFD: Int32 = -1 // MARK: - Support info @@ -158,6 +181,10 @@ class Preferences: ObservableObject { init() { ensureDefaultsInitialized() + startWatchingCustomCardPath() + + // Initialize published mirror values from current AppStorage + self.customCardPathPublished = self.customCardPath NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification) .sink { [weak self] _ in @@ -170,18 +197,77 @@ class Preferences: ObservableObject { // Observe changes to UserDefaults specifically for complex types cancellable = NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification) + .receive(on: RunLoop.main) .sink { [weak self] _ in - self?.loadHiddenCards() - self?.loadLogFolders() - self?.loadExcludedLogFolders() - self?.loadActions() - self?.loadHiddenActions() - self?.loadDesktopInfoHideItems() + guard let self = self else { return } + // Explicitly pull latest CustomCardPath from defaults (helps when @AppStorage in classes doesn't auto-refresh) + let latestPath = UserDefaults.standard.string(forKey: "CustomCardPath") ?? "" + if self.customCardPath != latestPath { + Logger.shared.logDebug("Preferences: observed defaults change for CustomCardPath -> '\(latestPath)'") + self.customCardPath = latestPath + } + if self.customCardPathPublished != latestPath { + self.customCardPathPublished = latestPath + } + + self.loadHiddenCards() + self.loadLogFolders() + self.loadExcludedLogFolders() + self.loadActions() + self.loadHiddenActions() + self.loadDesktopInfoHideItems() } Task { await detectModeAndSetLogFolders() } } + + private func startWatchingCustomCardPath() { + // Always watch the Preferences directory; this catches atomic saves and initial file creation + let domain = "com.github.macadmins.SupportCompanion" + let prefsPlistURL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Preferences/\(domain).plist") + let prefsDirURL = prefsPlistURL.deletingLastPathComponent() + + let fd = open(prefsDirURL.path, O_EVTONLY) + guard fd >= 0 else { + Logger.shared.logError("Preferences: failed to open preferences directory for watching: \(prefsDirURL.path)") + return + } + prefsDirFD = fd + let queue = DispatchQueue(label: "com.github.macadmins.SupportCompanion.PrefsWatch") + let src = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fd, + eventMask: [.write, .rename, .delete, .extend, .attrib], + queue: queue + ) + src.setCancelHandler { [fd] in + close(fd) + } + src.setEventHandler { [weak self] in + guard let self = self else { return } + // Read value directly from the plist to avoid UserDefaults caching + var latest = "" + if let dict = NSDictionary(contentsOf: prefsPlistURL) as? [String: Any], + let s = dict["CustomCardPath"] as? String { + latest = s + } else { + latest = UserDefaults.standard.string(forKey: "CustomCardPath") ?? "" + } + DispatchQueue.main.async { + if self.customCardPathPublished != latest { + Logger.shared.logInfo("Prefs watcher: CustomCardPath -> '\(latest)'") + if self.customCardPath != latest { + self.customCardPath = latest + } + self.customCardPathPublished = latest + } + } + } + src.resume() + prefsDirSource = src + Logger.shared.logDebug("Preferences: started watching \(prefsDirURL.path)") + } private func detectModeAndSetLogFolders() async { //if !logFolders.isEmpty { @@ -196,16 +282,18 @@ class Preferences: ObservableObject { let fileManager = FileManager.default let companyPortalExists = fileManager.fileExists(atPath: Constants.AppPaths.companyPortal) + let selfServiceExists = fileManager.fileExists(atPath: Constants.AppPaths.selfService) let mscExists = fileManager.fileExists(atPath: Constants.AppPaths.MSC) let mdmUrl = await getMDMUrl() - print(mdmUrl) - if mdmUrl != "Unknown" { Logger.shared.logDebug("MDM URL detected: \(mdmUrl)") if mdmUrl.contains("i.manage.microsoft.com") { Logger.shared.logDebug("MDM URL contains i.manage.microsoft.com, setting mdm to Intune.") mdm = "Intune" + } else if mdmUrl.contains("jamf") { + Logger.shared.logDebug("MDM URL contains jamf, setting mdm to Jamf.") + mdm = "Jamf" } } @@ -217,6 +305,14 @@ class Preferences: ObservableObject { Logger.shared.logDebug("Company Portal path exists, setting mode to Intune.") mode = Constants.modes.intune logFolders = ["/Library/Logs/Microsoft"] + } else if selfServiceExists && mscExists { + Logger.shared.logDebug("Both Munki and Self Service paths exist, defaulting to Munki mode.") + mode = Constants.modes.munki + logFolders = ["/Library/Managed Installs/Logs", "/var/log/jamf.log"] + } else if selfServiceExists && mdm == "Jamf" { + Logger.shared.logDebug("Self Service path exists, setting mode to Jamf.") + mode = Constants.modes.jamf + logFolders = ["/var/log/jamf.log"] } else if mscExists { Logger.shared.logDebug("MSC path exists, setting mode to Munki.") mode = Constants.modes.munki diff --git a/SupportCompanion/Protocols/PendingUpdate.swift b/SupportCompanion/Protocols/PendingUpdate.swift index dd7e635..5c8082c 100644 --- a/SupportCompanion/Protocols/PendingUpdate.swift +++ b/SupportCompanion/Protocols/PendingUpdate.swift @@ -14,3 +14,4 @@ protocol PendingUpdate: Identifiable { extension PendingMunkiUpdate: PendingUpdate {} extension PendingIntuneUpdate: PendingUpdate {} +extension PendingJamfUpdate: PendingUpdate {} diff --git a/SupportCompanion/ViewModels/AppStateManager.swift b/SupportCompanion/ViewModels/AppStateManager.swift index 6829398..2eba146 100644 --- a/SupportCompanion/ViewModels/AppStateManager.swift +++ b/SupportCompanion/ViewModels/AppStateManager.swift @@ -15,6 +15,7 @@ class AppStateManager: ObservableObject { lazy var pendingMunkiUpdatesManager = PendingMunkiUpdatesManager(appState: self) lazy var applicationsInfoManager = ApplicationsInfoManager(appState: self) lazy var pendingIntuneUpdatesManager = PendingIntuneUpdatesManager(appState: self) + lazy var pendingJamfUpdatesManager = PendingJamfUpdatesManager(appState: self) lazy var evergreenInfoManager = EvergreenInfoManager(appState: self) lazy var elevationManager = ElevationManager(appState: self) var jsonCardManager: JsonCardManager? @@ -31,6 +32,7 @@ class AppStateManager: ObservableObject { @Published var pendingUpdatesCount: Int = 0 @Published var pendingMunkiUpdates: [PendingMunkiUpdate] = [] @Published var pendingIntuneUpdates: [PendingIntuneUpdate] = [] + @Published var pendingJamfUpdates: [PendingJamfUpdate] = [] @Published var installedApplications: [InstalledApp] = [] @Published var systemUpdateCache: SystemUpdates = SystemUpdates(id: UUID(), count: 0, updates: []) @Published var windowIsVisible: Bool = false @@ -41,6 +43,8 @@ class AppStateManager: ObservableObject { @Published var timeToDemote: TimeInterval = 0 private var cancellables: Set = Set() + private var customPathCancellable: AnyCancellable? + private var defaultsWatcher: FileWatcher? var showWindowCallback: (() -> Void)? func startBackgroundTasks() { @@ -50,6 +54,9 @@ class AppStateManager: ObservableObject { if preferences.mode == Constants.modes.intune { pendingIntuneUpdatesManager.startUpdateCheckTimer() } + if preferences.mode == Constants.modes.jamf { + pendingJamfUpdatesManager.startUpdateCheckTimer() + } systemUpdatesManager.startMonitoring() storageInfoManager.startMonitoring() deviceInfoManager.startMonitoring() @@ -96,6 +103,74 @@ class AppStateManager: ObservableObject { .store(in: &cancellables) setupCardManager() + + wirePreferencesObservers() + + // If the Preferences instance is ever replaced, rewire observers + $preferences + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.wirePreferencesObservers() + } + .store(in: &cancellables) + + // Also watch the preferences plist so external `defaults write` changes are picked up live + setupDefaultsWatcher() + } + + private func setupDefaultsWatcher() { + let domain = "com.github.macadmins.SupportCompanion" + let prefsURL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Preferences/\(domain).plist") + let path = prefsURL.path + defaultsWatcher = FileWatcher(filePath: path) { [weak self] in + guard let self = self else { return } + // Read directly from the plist to bypass UserDefaults caching + if let dict = NSDictionary(contentsOf: prefsURL) as? [String: Any], + let latest = dict["CustomCardPath"] as? String { + DispatchQueue.main.async { + if self.preferences.customCardPathPublished != latest { + Logger.shared.logInfo("Prefs plist changed -> CustomCardPath='\(latest)'") + if self.preferences.customCardPath != latest { + self.preferences.customCardPath = latest + } + self.preferences.customCardPathPublished = latest + } + } + } + } + } + + private func wirePreferencesObservers() { + // Cancel any previous subscription tied to the old preferences instance + customPathCancellable?.cancel() + + customPathCancellable = preferences.$customCardPathPublished + .map { $0.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) } + .removeDuplicates { (lhs: String, rhs: String) -> Bool in + lhs == rhs + } + .receive(on: RunLoop.main) + .sink { [weak self] (trimmed: String) in + guard let self = self else { return } + Logger.shared.logDebug("CustomCardPath changed -> '\(trimmed)'") + + // If path is empty, tear down any existing manager and clear cards + guard !trimmed.isEmpty else { + self.jsonCardManager?.stopWatching() + self.jsonCardManager = nil + self.JsonCards.removeAll() + return + } + + // Ensure a manager exists, stop any current watcher, then load and start watching the new path + if self.jsonCardManager == nil { + self.jsonCardManager = JsonCardManager(appState: self) + } + self.jsonCardManager?.stopWatching() + self.jsonCardManager?.loadFromFile(trimmed) + self.jsonCardManager?.watchFile(trimmed) + } } func startDemotionTimer(duration: TimeInterval) { @@ -116,7 +191,9 @@ class AppStateManager: ObservableObject { private func setupCardManager() { guard !preferences.customCardPath.isEmpty else { return } - jsonCardManager = JsonCardManager(appState: self) + if jsonCardManager == nil { + jsonCardManager = JsonCardManager(appState: self) + } jsonCardManager?.loadFromFile(preferences.customCardPath) jsonCardManager?.watchFile(preferences.customCardPath) } @@ -141,3 +218,4 @@ class AppStateManager: ObservableObject { } } } + diff --git a/SupportCompanion/ViewModels/ApplicationsInfoManager.swift b/SupportCompanion/ViewModels/ApplicationsInfoManager.swift index 6f0df04..e6bd694 100644 --- a/SupportCompanion/ViewModels/ApplicationsInfoManager.swift +++ b/SupportCompanion/ViewModels/ApplicationsInfoManager.swift @@ -24,7 +24,9 @@ class ApplicationsInfoManager: ObservableObject { isSelfServe: false, path: "", type: "", - bundleId: "" + bundleId: "", + iconUrl: "", + actionText: "" ) init(appState: AppStateManager) { @@ -67,6 +69,8 @@ class ApplicationsInfoManager: ObservableObject { await getInstalledIntuneApps() case Constants.modes.systemProfiler: await getInstalledProfilerApps() + case Constants.modes.jamf: + await getInstalledJamfApps() default: await getInstalledMunkiApps() } @@ -106,7 +110,9 @@ class ApplicationsInfoManager: ObservableObject { isSelfServe: isSelfServe, path: "", type: "", - bundleId: "" + bundleId: "", + iconUrl: "", + actionText: "" ) } @@ -142,7 +148,46 @@ class ApplicationsInfoManager: ObservableObject { isSelfServe: false, path: "", type: type, - bundleId: bundleId + bundleId: bundleId, + iconUrl: "", + actionText: "" + ) + } + + let sortedApps = installedApps.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + + DispatchQueue.main.async { + self.appState.installedApplications = sortedApps + } + } + } + + func getInstalledJamfApps() async { + do { + let apps = await getInstalledJamfAppsFromStore() + let installedApps = apps.compactMap { (key: AnyHashable, value: Any) -> InstalledApp? in + guard let dict = value as? [String: Any] else { return nil } + guard let name = dict["name"] as? String, + let version = dict["version"] as? String else { + return nil + } + let iconUrl = dict["iconUrl"] as? String ?? "" + let id = dict["id"] as? Int ?? 0 + let command = "open \"selfservicecapability://content?entity=policy&id=\(id)&action=execute\"" + let actionText = (dict["postInstallText"] as? String) ?? "" + + return InstalledApp( + id: UUID(), + name: name, + version: version, + action: command, + arch: "", + isSelfServe: true, + path: "", + type: "", + bundleId: "", + iconUrl: iconUrl, + actionText: actionText ) } @@ -178,7 +223,9 @@ class ApplicationsInfoManager: ObservableObject { isSelfServe: false, path: app["path"] as? String ?? "", type: "", - bundleId: "" + bundleId: "", + iconUrl: "", + actionText: "" ) } @@ -190,3 +237,4 @@ class ApplicationsInfoManager: ObservableObject { } } } + diff --git a/SupportCompanion/ViewModels/PendingJamfUpdatesManager.swift b/SupportCompanion/ViewModels/PendingJamfUpdatesManager.swift new file mode 100644 index 0000000..02adc8d --- /dev/null +++ b/SupportCompanion/ViewModels/PendingJamfUpdatesManager.swift @@ -0,0 +1,139 @@ +// +// PendingJamfUpdatesManager.swift +// SupportCompanion +// +// Created by Tobias Almén on 2025-10-13. +// + +import Foundation +import Combine + +class PendingJamfUpdatesManager { + private var appState: AppStateManager + private var updateCheckTimer: Timer? + private var fetchListTimer: Timer? + private var installPercentageTask: Task? + private var isInstallPercentageTaskRunning = false + private var parser = SSPlusParser() + + init(appState: AppStateManager) { + self.appState = appState + } + + nonisolated func getPendingJamfUpdates() async { + Logger.shared.logDebug("Getting Jamf pending updates") + + do { + if await parser.parse() { + let (pendingUpdates, _, _) = await computeUpdates(policies: parser.policies, patches: parser.patches, now: Date()) + DispatchQueue.main.async { + self.appState.pendingJamfUpdates = pendingUpdates + } + if pendingUpdates.count > 0 && !appState.preferences.hiddenCards.contains("PendingAppUpdates") { + NotificationService(appState: appState).sendNotification( + message: appState.preferences.appUpdateNotificationMessage, + buttonText: appState.preferences.appUpdateNotificationButtonText, + command: appState.preferences.appUpdateNotificationCommand, + notificationType: .appUpdate + ) + } + } + } + } + + func getJamfInstallPercentage() async { + Logger.shared.logDebug("Getting Jamf install percentage") + + do { + // Fetch counts concurrently + if await parser.parse() { + let (_, updateCount, upToDateCount) = await computeUpdates(policies: parser.policies, patches: parser.patches, now: Date()) + let totalApps = updateCount + upToDateCount + let newInstallPercentage = totalApps > 0 + ? (Double(upToDateCount) / Double(totalApps)) * 100 + : 0.0 + + // Update only if the percentage has changed + if newInstallPercentage != appState.installPercentage { + DispatchQueue.main.async { + self.appState.installedAppsCount = upToDateCount + self.appState.pendingUpdatesCount = updateCount + self.appState.installPercentage = newInstallPercentage + Logger.shared.logDebug("Install percentage updated: \(self.appState.installPercentage)%") + } + } else { + Logger.shared.logDebug("Install percentage unchanged: \(self.appState.installPercentage)%") + } + } + } + } + + func startInstallPercentageTask() { + guard !isInstallPercentageTaskRunning else { + Logger.shared.logDebug("Install percentage task already running") + return + } + + isInstallPercentageTaskRunning = true + Logger.shared.logDebug("Starting install percentage task") + + installPercentageTask = Task { + await self.getJamfInstallPercentage() + isInstallPercentageTaskRunning = false + } + } + + func stopInstallPercentageTask() { + Logger.shared.logDebug("Stopping install percentage task") + installPercentageTask?.cancel() + installPercentageTask = nil + isInstallPercentageTaskRunning = false + } + + func startFetchingList(interval: TimeInterval = 60) { + Logger.shared.logDebug("Starting periodic fetch of pending updates list") + + // Run the task immediately + Task { + await getPendingJamfUpdates() + } + + // Start a periodic timer + stopFetchingList() + fetchListTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in + Logger.shared.logDebug("Timer triggered fetch pending updates list task") + Task { + await self?.getPendingJamfUpdates() + } + } + } + + func stopFetchingList() { + Logger.shared.logDebug("Stopping periodic fetch of pending updates list") + fetchListTimer?.invalidate() + fetchListTimer = nil + } + + func startUpdateCheckTimer() { + Logger.shared.logDebug("Starting app update check timer") + + // Run the task immediately + Task { + await getPendingJamfUpdates() + } + + // Set up the timer to run every hour + updateCheckTimer?.invalidate() // Stop any existing timer + updateCheckTimer = Timer.scheduledTimer(withTimeInterval: 3600, repeats: true) { [weak self] _ in + Task { + await self?.getPendingJamfUpdates() + } + } + } + + func stopUpdateCheckTimer() { + Logger.shared.logDebug("Stopping app update check timer") + updateCheckTimer?.invalidate() + updateCheckTimer = nil + } +} diff --git a/SupportCompanion/Views/Cards/AppCard.swift b/SupportCompanion/Views/Cards/AppCard.swift index 2b5ff95..b87be71 100644 --- a/SupportCompanion/Views/Cards/AppCard.swift +++ b/SupportCompanion/Views/Cards/AppCard.swift @@ -11,6 +11,8 @@ import SwiftUI struct AppCard: View { let card: InstalledApp let version: String + + @State private var resolvedTitleImage: String = "app.gift.fill" init(card: InstalledApp) { self.card = card @@ -31,18 +33,31 @@ struct AppCard: View { if FileManager.default.fileExists(atPath: iconPath) { return iconPath } else { - return "app.gift.fill" + return resolvedTitleImage } } else if AppStateManager.shared.preferences.mode == Constants.modes.systemProfiler { let appInfoPlistPath = "\(card.path)/Contents/Info.plist" - return getIconPath(plistPath: appInfoPlistPath, appPath: card.path) ?? "app.gift.fill" + return getIconPath(plistPath: appInfoPlistPath, appPath: card.path) ?? resolvedTitleImage } else if AppStateManager.shared.preferences.mode == Constants.modes.intune { if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: card.bundleId) { let appInfoPlistPath = "\(appURL.path)/Contents/Info.plist" - return getIconPath(plistPath: appInfoPlistPath, appPath: appURL.path) ?? "app.gift.fill" + return getIconPath(plistPath: appInfoPlistPath, appPath: appURL.path) ?? resolvedTitleImage + } else { + return resolvedTitleImage } + } else if AppStateManager.shared.preferences.mode == Constants.modes.jamf { + // For JAMF, the actual download is handled asynchronously; fall back to current state value + return resolvedTitleImage + } + return resolvedTitleImage + } + + private var buttonText: String { + if AppStateManager.shared.preferences.mode == Constants.modes.jamf { + return card.actionText ?? "" + } else { + return Constants.General.manage } - return "app.gift.fill" } var body: some View { @@ -52,12 +67,14 @@ struct AppCard: View { imageSize: (40, 40), content: { VStack(alignment: .leading, spacing: 5) { - HStack(alignment: .top) { - Text("\(Constants.TabelHeaders.version):") - .bold() - Text(version) + if !version.isEmpty { + HStack(alignment: .top) { + Text("\(Constants.TabelHeaders.version):") + .bold() + Text(version) + } + .font(.system(size: 14)) } - .font(.system(size: 14)) if !card.arch.isEmpty { HStack { @@ -78,7 +95,7 @@ struct AppCard: View { } if card.isSelfServe { - ScButton(Constants.General.manage, action: { + ScButton(buttonText, action: { Task { if !card.action.isEmpty { _ = try await ExecutionService.executeShellCommand(card.action) @@ -93,6 +110,22 @@ struct AppCard: View { .padding(.horizontal) } ) + .task { + await loadJamfIconIfNeeded() + } + } + + @MainActor + private func loadJamfIconIfNeeded() async { + guard AppStateManager.shared.preferences.mode == Constants.modes.jamf else { return } + guard let iconUrl = card.iconUrl, !iconUrl.isEmpty else { return } + // Attempt to download icon asynchronously + if let iconPath = try? await downloadAppIcon(forApp: card) { + resolvedTitleImage = iconPath + } else { + // Keep fallback if download fails + resolvedTitleImage = "app.gift.fill" + } } } @@ -141,3 +174,4 @@ struct PlistService { return nil } } + diff --git a/SupportCompanion/Views/Cards/PatchingProgressCard.swift b/SupportCompanion/Views/Cards/PatchingProgressCard.swift index 847a31e..d87eed5 100644 --- a/SupportCompanion/Views/Cards/PatchingProgressCard.swift +++ b/SupportCompanion/Views/Cards/PatchingProgressCard.swift @@ -33,6 +33,9 @@ struct PatchingProgressCard: View { if appState.preferences.mode == Constants.modes.intune { appState.pendingIntuneUpdatesManager.startInstallPercentageTask() } + if appState.preferences.mode == Constants.modes.jamf { + appState.pendingJamfUpdatesManager.startInstallPercentageTask() + } } .onDisappear { if appState.preferences.mode == Constants.modes.munki { @@ -41,6 +44,9 @@ struct PatchingProgressCard: View { if appState.preferences.mode == Constants.modes.intune { appState.pendingIntuneUpdatesManager.stopInstallPercentageTask() } + if appState.preferences.mode == Constants.modes.jamf { + appState.pendingJamfUpdatesManager.stopInstallPercentageTask() + } } .onChange(of: appState.windowIsVisible) { oldValue, newValue in if !newValue { @@ -50,6 +56,9 @@ struct PatchingProgressCard: View { if appState.preferences.mode == Constants.modes.intune { appState.pendingIntuneUpdatesManager.stopInstallPercentageTask() } + if appState.preferences.mode == Constants.modes.jamf { + appState.pendingJamfUpdatesManager.stopInstallPercentageTask() + } } } } diff --git a/SupportCompanion/Views/Cards/PendingUpdatesCard.swift b/SupportCompanion/Views/Cards/PendingUpdatesCard.swift index 1617849..5be830f 100644 --- a/SupportCompanion/Views/Cards/PendingUpdatesCard.swift +++ b/SupportCompanion/Views/Cards/PendingUpdatesCard.swift @@ -65,7 +65,9 @@ struct PendingUpdatesCard: View { updateList(items: appState.pendingMunkiUpdates) } else if appState.preferences.mode == Constants.modes.intune { updateList(items: appState.pendingIntuneUpdates) - } + } else if appState.preferences.mode == Constants.modes.jamf { + updateList(items: appState.pendingJamfUpdates) + } } private func updateList(items: [T]) -> some View where T: PendingUpdate { @@ -74,22 +76,23 @@ struct PendingUpdatesCard: View { Text("No pending updates") .frame(maxWidth: .infinity, alignment: .leading) } else { - ForEach(items) { update in - HStack { - Text(update.name) - .frame(maxWidth: .infinity, alignment: .leading) - Text(update.version) - .frame(maxWidth: .infinity, alignment: .trailing) - .foregroundColor(colorScheme == .dark ? .gray : .grayLight) - if let intuneUpdate = update as? PendingIntuneUpdate, intuneUpdate.showInfoIcon { - Image(systemName: "info.circle") - .help(intuneUpdate.pendingReason) - .frame(maxWidth: .infinity, alignment: .trailing) - } - } - .listRowSeparator(.hidden) - Divider() - } + ForEach(items) { update in + HStack(spacing: 8) { + // Keep this leading text flexible + Text(update.name) + .lineLimit(1) + .truncationMode(.tail) + + Spacer() + + // Keep this trailing text compact; no infinite frames + Text(update.version) + .foregroundColor(colorScheme == .dark ? .gray : .grayLight) + .lineLimit(1) + } + .padding(.vertical, 6) + .listRowSeparator(.hidden) + } } } .padding(.horizontal, 8) @@ -104,6 +107,8 @@ struct PendingUpdatesCard: View { appState.pendingMunkiUpdatesManager.startFetchingList() } else if appState.preferences.mode == Constants.modes.intune { appState.pendingIntuneUpdatesManager.startFetchingList() + } else if appState.preferences.mode == Constants.modes.jamf { + appState.pendingJamfUpdatesManager.startFetchingList() } } } @@ -114,6 +119,8 @@ struct PendingUpdatesCard: View { appState.pendingMunkiUpdatesManager.stopFetchingList() } else if appState.preferences.mode == Constants.modes.intune { appState.pendingIntuneUpdatesManager.stopFetchingList() + } else if appState.preferences.mode == Constants.modes.jamf { + appState.pendingJamfUpdatesManager.stopFetchingList() } } } @@ -124,10 +131,3 @@ struct PendingUpdatesCard: View { } } } - -struct PendingMunkiUpdatesCard_Previews: PreviewProvider { - static var previews: some View { - PendingUpdatesCard(viewModel: CardGridViewModel(appState: AppStateManager())) - .previewLayout(.sizeThatFits) - } -} From 0cb0b3fa9a1f39bd399a3b38dc2f84be513f1e0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Alm=C3=A9n?= <78877636+almenscorner@users.noreply.github.com> Date: Mon, 20 Oct 2025 16:07:09 +0200 Subject: [PATCH 002/104] Refactor FileWatcher and JsonCardManager for improved file handling and debugging --- SupportCompanion/Extensions/Extensions.swift | 1 - SupportCompanion/Helpers/FileWatcher.swift | 86 +++++++++++++++---- .../ViewModels/JsonCardManager.swift | 18 +++- 3 files changed, 83 insertions(+), 22 deletions(-) diff --git a/SupportCompanion/Extensions/Extensions.swift b/SupportCompanion/Extensions/Extensions.swift index 41582ef..730f27c 100644 --- a/SupportCompanion/Extensions/Extensions.swift +++ b/SupportCompanion/Extensions/Extensions.swift @@ -78,7 +78,6 @@ extension AppDelegate: NSWindowDelegate { Logger.shared.logDebug("Main window is closing.") AppStateManager.shared.windowIsVisible = false windowController = nil - AppStateManager.shared.jsonCardManager = nil NSApp.setActivationPolicy(.accessory) } diff --git a/SupportCompanion/Helpers/FileWatcher.swift b/SupportCompanion/Helpers/FileWatcher.swift index 1e4f763..006e722 100644 --- a/SupportCompanion/Helpers/FileWatcher.swift +++ b/SupportCompanion/Helpers/FileWatcher.swift @@ -1,30 +1,82 @@ import Foundation +// Watches a file for changes. Handles editors that save atomically (write temp + rename) +// by listening for rename/delete and reopening the file descriptor. + class FileWatcher { private var source: DispatchSourceFileSystemObject? - private let fileDescriptor: Int32 + private var fileDescriptor: Int32 = -1 + private let filePath: String + private let queue = DispatchQueue(label: "com.github.macadmins.SupportCompanion.FileWatcher") + private var pendingReload = false init?(filePath: String, eventHandler: @escaping () -> Void) { - let fileDescriptor = open(filePath, O_EVTONLY) - guard fileDescriptor >= 0 else { - Logger.shared.logError("Failed to open file: \(filePath)") - return nil + self.filePath = filePath + guard reopen(eventHandler: eventHandler) else { return nil } + Logger.shared.logDebug("Initializing file watcher for \(filePath)") + } + + // Recreate the dispatch source and ensure it is resumed. + private func reopen(eventHandler: @escaping () -> Void) -> Bool { + // Tear down old source if any + if let src = source { + src.cancel() + source = nil } - self.fileDescriptor = fileDescriptor - - let source = DispatchSource.makeFileSystemObjectSource(fileDescriptor: fileDescriptor, eventMask: .write, queue: DispatchQueue.global()) - source.setEventHandler(handler: eventHandler) - source.setCancelHandler { - close(fileDescriptor) + if fileDescriptor >= 0 { + // fd will be closed by cancel handler; reset our tracking value here + fileDescriptor = -1 } - Logger.shared.logDebug("Initializing file watcher for \(filePath)") - source.resume() - self.source = source + + let fd = open(filePath, O_EVTONLY) + guard fd >= 0 else { + Logger.shared.logError("Failed to open file for watching: \(filePath)") + return false + } + fileDescriptor = fd + + let src = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fd, + eventMask: [.write, .rename, .delete, .extend, .attrib], + queue: queue + ) + src.setCancelHandler { [fd] in + close(fd) + } + + // Set handler referencing self.source to always act on the current source + src.setEventHandler { [weak self] in + guard let self = self, let current = self.source else { return } + let flags = current.data + + if flags.contains(.rename) || flags.contains(.delete) { + Logger.shared.logDebug("FileWatcher: rename/delete detected for \(self.filePath), reopening FD") + // Under atomic save, our FD now points to the old inode. + // Reopen the path to attach to the new file, then trigger reload. + _ = self.reopen(eventHandler: eventHandler) + } + + // Debounce to avoid multiple loads per single save + if !self.pendingReload { + self.pendingReload = true + self.queue.asyncAfter(deadline: .now() + 0.15) { + self.pendingReload = false + eventHandler() + } + } + } + + // Resume the new source immediately + src.resume() + source = src + return true } - + deinit { Logger.shared.logDebug("Deinitializing file watcher") - source?.cancel() // Clean up - close(fileDescriptor) + source?.cancel() // Clean up; cancel handler closes fd + source = nil + // Don't close fileDescriptor here; cancel handler already did it. + fileDescriptor = -1 } } diff --git a/SupportCompanion/ViewModels/JsonCardManager.swift b/SupportCompanion/ViewModels/JsonCardManager.swift index 29bcb1f..556b949 100644 --- a/SupportCompanion/ViewModels/JsonCardManager.swift +++ b/SupportCompanion/ViewModels/JsonCardManager.swift @@ -16,15 +16,21 @@ class JsonCardManager: ObservableObject { } func watchFile(_ filePath: String) { - fileWatcher = FileWatcher(filePath: filePath) { [weak self] in - self?.loadFromFile(filePath) + let expanded = (filePath as NSString).expandingTildeInPath + let resolved = URL(fileURLWithPath: expanded).resolvingSymlinksInPath().path + fileWatcher = nil + Logger.shared.logDebug("JsonCardManager: watching file at \(resolved)") + fileWatcher = FileWatcher(filePath: resolved) { [weak self] in + Logger.shared.logDebug("JsonCardManager: file change detected, reloading") + self?.loadFromFile(resolved) } } func loadFromFile(_ fileName: String) { - let fileURL = URL(fileURLWithPath: fileName) + let expanded = (fileName as NSString).expandingTildeInPath + let fileURL = URL(fileURLWithPath: expanded).resolvingSymlinksInPath() guard FileManager.default.fileExists(atPath: fileURL.path) else { - Logger.shared.logDebug("Csutom Card File not found: \(fileURL.path)") + Logger.shared.logDebug("Custom Card File not found: \(fileURL.path)") return } @@ -38,4 +44,8 @@ class JsonCardManager: ObservableObject { Logger.shared.logError("Failed to load cards: \(error.localizedDescription)") } } + + func stopWatching() { + fileWatcher = nil + } } From 298a08441177dd80ad4c42314142608c3aeb8d87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Alm=C3=A9n?= <78877636+almenscorner@users.noreply.github.com> Date: Mon, 20 Oct 2025 16:09:04 +0200 Subject: [PATCH 003/104] Add SSID retrieval and display in DeviceInfo and CardGridViewModel --- SupportCompanion/Helpers/ActionHelpers.swift | 2 +- .../Helpers/DeviceInfoHelpers.swift | 103 +++++++++++++++--- SupportCompanion/Models/DeviceInfo.swift | 7 ++ .../ViewModels/CardGridViewModel.swift | 6 +- .../ViewModels/DeviceInfoManager.swift | 8 +- .../Views/Cards/DeviceInformationCard.swift | 7 +- 6 files changed, 113 insertions(+), 20 deletions(-) diff --git a/SupportCompanion/Helpers/ActionHelpers.swift b/SupportCompanion/Helpers/ActionHelpers.swift index deb3dfc..1977bc0 100644 --- a/SupportCompanion/Helpers/ActionHelpers.swift +++ b/SupportCompanion/Helpers/ActionHelpers.swift @@ -282,7 +282,7 @@ struct ActionHelpers { } else if preferences.changePasswordMode == "SSOExtension" { await handleSSOExtension(completion: completion) } else { - await openUserPanel() + openUserPanel() } } diff --git a/SupportCompanion/Helpers/DeviceInfoHelpers.swift b/SupportCompanion/Helpers/DeviceInfoHelpers.swift index 6045e2b..515b202 100644 --- a/SupportCompanion/Helpers/DeviceInfoHelpers.swift +++ b/SupportCompanion/Helpers/DeviceInfoHelpers.swift @@ -8,6 +8,7 @@ import Foundation import IOKit import Network +import CoreWLAN func getHostName() -> String? { let hostName = ProcessInfo.processInfo.hostName @@ -164,29 +165,104 @@ func getAllIPAddresses() -> [String] { return ipAddresses } +func getSSID() -> String? { + // Helper to synchronously call async ExecutionService methods + func runCommand(_ launchPath: String, _ arguments: [String], privileged: Bool = false) throws -> String { + let semaphore = DispatchSemaphore(value: 0) + var result: Result = .failure(NSError(domain: "ExecutionService", code: -1)) + + let execute: () async throws -> String = { + if privileged { + return try await ExecutionService.executeCommandPrivileged(launchPath, arguments: arguments) + } else { + return try await ExecutionService.executeCommand(launchPath, with: arguments) + } + } + + Task { + do { + let output = try await execute() + result = .success(output) + } catch { + result = .failure(error) + } + semaphore.signal() + } + + // Wait until task completes + semaphore.wait() + + switch result { + case .success(let output): + return output + case .failure(let error): + throw error + } + } + + do { + // Enable verbose temporarily; ignore failures + // only continue if wifi is on + guard let wifi = CWWiFiClient.shared().interface() else { return "WiFi Off" } + guard wifi.powerOn() else { return "WiFi Off" } + + do { + _ = try runCommand("/bin/sh", ["-c", "/usr/sbin/ipconfig setverbose 1"], privileged: true) + } catch { + _ = try? runCommand("/bin/sh", ["-c", "/usr/sbin/ipconfig setverbose 1"], privileged: true) + } + + // Correctly pass the pipeline to /bin/sh -c without extra quoting + let command = "/usr/sbin/ipconfig getsummary en0 | awk -F ' SSID : ' '/ SSID : / {print $2}'" + let ssid = try runCommand("/bin/sh", ["-c", command]) + + // Disable verbose; ignore failures + _ = try? runCommand("/bin/sh", ["-c", "/usr/sbin/ipconfig setverbose 0"], privileged: true) + + let trimmed = ssid.trimmingCharacters(in: .whitespacesAndNewlines) + // if trimmed == change to nil + if trimmed == "" { + return nil + } + return trimmed.isEmpty ? nil : trimmed + } catch { + Logger.shared.logError("Failed to fetch SSID: \(error)") + // Ensure we attempt to reset verbose mode even on failure + _ = try? runCommand("/bin/sh", ["-c", "/usr/sbin/ipconfig setverbose 0"], privileged: true) + return nil + } +} + class IPAddressMonitor { private static let monitor = NWPathMonitor() private static let queue = DispatchQueue.global(qos: .background) private static var lastUpdateTime: Date? - + private static var lastIPs: [String] = [] + + struct NetworkStatus { + let ipAddresses: [String] + let ssid: String? + } /// Starts monitoring for IP address changes and calls `onChange` with the updated IPs. - static func startMonitoring(onChange: @escaping ([String]) -> Void) { + static func startMonitoring(onChange: @escaping (NetworkStatus) -> Void) { monitor.pathUpdateHandler = { path in - let now = Date() - if let lastUpdate = lastUpdateTime, now.timeIntervalSince(lastUpdate) < 2.0 { - // Skip updates within 2 seconds - return - } - lastUpdateTime = now + // Only proceed when we have a reachable path. If not reachable, treat as empty IP list. + let currentIPs: [String] + let currentSSID: String? if path.status == .satisfied { - let currentIPAddresses = getAllIPAddresses() - DispatchQueue.main.async { - onChange(currentIPAddresses) - } + currentIPs = getAllIPAddresses() + currentSSID = getSSID() } else { + currentIPs = [] + currentSSID = nil + } + + // Compare sorted lists to be order-insensitive + if currentIPs.sorted() != lastIPs.sorted() { + lastIPs = currentIPs DispatchQueue.main.async { - onChange(["No network connection"]) + onChange(NetworkStatus(ipAddresses: currentIPs, ssid: currentSSID)) } } } @@ -238,3 +314,4 @@ class LastRebootMonitor { } } } + diff --git a/SupportCompanion/Models/DeviceInfo.swift b/SupportCompanion/Models/DeviceInfo.swift index e97f144..d265f42 100644 --- a/SupportCompanion/Models/DeviceInfo.swift +++ b/SupportCompanion/Models/DeviceInfo.swift @@ -15,6 +15,7 @@ struct DeviceInfo: Identifiable, Equatable { let cpuType: String let ram: String var ipAddress: String + var ssid: String? let serialNumber: String var lastRestart: Int var lastRestartDays: Int @@ -90,6 +91,12 @@ struct DeviceInfo: Identifiable, Equatable { display: Constants.DeviceInfo.Labels.ipAddress, value: .string(ipAddress), category: Constants.DeviceInfo.Categories.networkInfo + ), + ( + key: "ssid", + display: "SSID:", + value: .string(ssid ?? "Unknown"), + category: Constants.DeviceInfo.Categories.networkInfo ) ] } diff --git a/SupportCompanion/ViewModels/CardGridViewModel.swift b/SupportCompanion/ViewModels/CardGridViewModel.swift index 2559a0e..aef07c4 100644 --- a/SupportCompanion/ViewModels/CardGridViewModel.swift +++ b/SupportCompanion/ViewModels/CardGridViewModel.swift @@ -36,6 +36,7 @@ class CardGridViewModel: ObservableObject { ("OS Version:", appState.deviceInfoManager.deviceInfo?.osVersion ?? ""), ("OS Build:", appState.deviceInfoManager.deviceInfo?.osBuild ?? ""), ("IP Address:", appState.deviceInfoManager.deviceInfo?.ipAddress ?? ""), + ("WiFi SSID:", appState.deviceInfoManager.deviceInfo?.ssid ?? ""), ("Last Reboot:", "\(appState.deviceInfoManager.deviceInfo?.lastRestartDays ?? 0) days"), ("--------------------- Battery ---------------------", ""), ("Health:", "\(healthPercentage)%"), @@ -140,6 +141,9 @@ class CardGridViewModel: ObservableObject { case Constants.modes.intune: appName = "Company Portal" appURL = Constants.AppPaths.companyPortal + case Constants.modes.jamf: + appName = "Self Service" + appURL = Constants.AppPaths.selfService default: appName = "Unknown App" appURL = "" @@ -159,7 +163,7 @@ class CardGridViewModel: ObservableObject { pasteboard.clearContents() let didWrite = pasteboard.setString(clipboardContent, forType: .string) - if didWrite, let retrievedContent = pasteboard.string(forType: .string) { + if didWrite, let _ = pasteboard.string(forType: .string) { DispatchQueue.main.async { self.toastConfig = ToastConfig( isShowing: true, diff --git a/SupportCompanion/ViewModels/DeviceInfoManager.swift b/SupportCompanion/ViewModels/DeviceInfoManager.swift index 209bcbb..c2eaf04 100644 --- a/SupportCompanion/ViewModels/DeviceInfoManager.swift +++ b/SupportCompanion/ViewModels/DeviceInfoManager.swift @@ -21,6 +21,7 @@ class DeviceInfoManager: ObservableObject { cpuType: "", ram: "", ipAddress: "", + ssid: nil, serialNumber: "", lastRestart: 0, lastRestartDays: 0, @@ -59,6 +60,7 @@ class DeviceInfoManager: ObservableObject { cpuType: getCPUName() ?? "", ram: getRAMSize(), ipAddress: currentIPAddress, + ssid: getSSID(), serialNumber: getSerialNumber(), lastRestart: getLastRestartMinutes() ?? 0, lastRestartDays: getLastRebootDays() ?? 0, @@ -81,13 +83,13 @@ class DeviceInfoManager: ObservableObject { } } - IPAddressMonitor.startMonitoring { newIPAddresses in + IPAddressMonitor.startMonitoring { status in DispatchQueue.main.async { guard let currentDeviceInfo = self.deviceInfo else { return // Exit early if `self.deviceInfo` is nil } - let updatedIPAddress = newIPAddresses.joined(separator: ", ") + let updatedIPAddress = status.ipAddresses.joined(separator: ", ") self.deviceInfo = DeviceInfo( id: currentDeviceInfo.id, hostName: currentDeviceInfo.hostName, @@ -96,6 +98,7 @@ class DeviceInfoManager: ObservableObject { cpuType: currentDeviceInfo.cpuType, ram: currentDeviceInfo.ram, ipAddress: updatedIPAddress, + ssid: status.ssid, serialNumber: currentDeviceInfo.serialNumber, lastRestart: currentDeviceInfo.lastRestart, lastRestartDays: currentDeviceInfo.lastRestartDays, @@ -105,3 +108,4 @@ class DeviceInfoManager: ObservableObject { } } } + diff --git a/SupportCompanion/Views/Cards/DeviceInformationCard.swift b/SupportCompanion/Views/Cards/DeviceInformationCard.swift index 4f920f1..5aa3c12 100644 --- a/SupportCompanion/Views/Cards/DeviceInformationCard.swift +++ b/SupportCompanion/Views/Cards/DeviceInformationCard.swift @@ -165,11 +165,12 @@ struct LastRestartRow: View { HStack(spacing: 5) { Text(formattedLastRestart) .foregroundColor(color) - Image(systemName: "clock.fill") - .foregroundColor(color) + //Image(systemName: "clock.fill") + // .foregroundColor(color) + InfoHelp(text: Constants.ToolTips.deviceLastRebooted, icon: "clock.fill", color: color) } .font(.system(size: 14)) - .help(Constants.ToolTips.deviceLastRebooted) + //.help(Constants.ToolTips.deviceLastRebooted) } } From 4698595eb8aa820874955520cb8cc917cb62a050 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Alm=C3=A9n?= <78877636+almenscorner@users.noreply.github.com> Date: Mon, 20 Oct 2025 16:09:22 +0200 Subject: [PATCH 004/104] Refactor getMDMEnrollmentTime to use profile names for command execution --- SupportCompanion/Helpers/MDMHelpers.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/SupportCompanion/Helpers/MDMHelpers.swift b/SupportCompanion/Helpers/MDMHelpers.swift index e1c04a2..20d0699 100644 --- a/SupportCompanion/Helpers/MDMHelpers.swift +++ b/SupportCompanion/Helpers/MDMHelpers.swift @@ -8,9 +8,12 @@ import Foundation func getMDMEnrollmentTime() async -> String { - let command = """ + let profileNames = ["MDM Profile", "Management Profile"] + /*let command = """ /usr/bin/profiles -P -v | grep -A 10 'Management Profile' - """ + """*/ + let command = "/usr/bin/profiles -P -v | grep -A 10 '\(profileNames[0])' || /usr/bin/profiles -P -v | grep -A 10 '\(profileNames[1])'" + do { let commandOutput = try await ExecutionService.executeCommandPrivileged("/bin/bash", arguments: ["-c", command]) From 72470360a27b05e3667725e3c75a8ebb2106bc05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Alm=C3=A9n?= <78877636+almenscorner@users.noreply.github.com> Date: Mon, 20 Oct 2025 16:10:05 +0200 Subject: [PATCH 005/104] Remove unused variables in SSOInfoHelpers, NotificationService, and ImageUtilities for cleaner code --- SupportCompanion/Helpers/SSOInfoHelpers.swift | 2 +- SupportCompanion/Services/NotificationService.swift | 2 +- SupportCompanion/Utilities/ImageUtilities.swift | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/SupportCompanion/Helpers/SSOInfoHelpers.swift b/SupportCompanion/Helpers/SSOInfoHelpers.swift index ffe108d..588425e 100644 --- a/SupportCompanion/Helpers/SSOInfoHelpers.swift +++ b/SupportCompanion/Helpers/SSOInfoHelpers.swift @@ -144,7 +144,7 @@ class SSOInfoHelpers { // Extract device configuration let deviceConfigJSON = extractJSON(from: platformSSOOutput, pattern: deviceConfigPattern) guard let deviceConfigData = deviceConfigJSON?.data(using: .utf8), - let deviceConfig = try? JSONSerialization.jsonObject(with: deviceConfigData, options: []) as? [String: Any] else { + let _ = try? JSONSerialization.jsonObject(with: deviceConfigData, options: []) as? [String: Any] else { Logger.shared.logError("Device configuration parsing failed") return false } diff --git a/SupportCompanion/Services/NotificationService.swift b/SupportCompanion/Services/NotificationService.swift index 61fe7c2..640ec79 100644 --- a/SupportCompanion/Services/NotificationService.swift +++ b/SupportCompanion/Services/NotificationService.swift @@ -72,7 +72,7 @@ class NotificationService { } var actions: [UNNotificationAction] = [] - if let buttonText = buttonText, let command = command { + if let buttonText = buttonText, let _ = command { let action = UNNotificationAction( identifier: "RUN_COMMAND", title: buttonText, diff --git a/SupportCompanion/Utilities/ImageUtilities.swift b/SupportCompanion/Utilities/ImageUtilities.swift index f6c930a..3594a1c 100644 --- a/SupportCompanion/Utilities/ImageUtilities.swift +++ b/SupportCompanion/Utilities/ImageUtilities.swift @@ -24,7 +24,7 @@ func base64ToImage(_ base64String: String) -> Image? { func loadLogo(base64Logo: String) -> Bool { if base64Logo.isEmpty { return false - } else if let decodedImage = base64ToImage(base64Logo) { + } else if let _ = base64ToImage(base64Logo) { return true } else { Logger.shared.logDebug("Invalid Base64 string for brand logo.") From ac1a40a9f520eb9ee39af549de432d82f0d682b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Alm=C3=A9n?= <78877636+almenscorner@users.noreply.github.com> Date: Mon, 20 Oct 2025 16:10:27 +0200 Subject: [PATCH 006/104] Add support for Jamf mode in CardGrid and CompactPatchProgressCard --- SupportCompanion/Views/CardGrid.swift | 6 +++--- .../Views/CompactCards/CompactPatchProgressCard.swift | 6 ++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/SupportCompanion/Views/CardGrid.swift b/SupportCompanion/Views/CardGrid.swift index 29f455d..4d6bc64 100644 --- a/SupportCompanion/Views/CardGrid.swift +++ b/SupportCompanion/Views/CardGrid.swift @@ -31,10 +31,10 @@ struct CardGrid: View { DeviceInformationCard(viewModel: viewModel) // Patching progress card - if appState.preferences.mode == Constants.modes.munki || appState.preferences.mode == Constants.modes.intune { + if appState.preferences.mode == Constants.modes.munki || appState.preferences.mode == Constants.modes.intune || appState.preferences.mode == Constants.modes.jamf { PatchingProgressCard(viewModel: viewModel) PendingUpdatesCard(viewModel: viewModel) - } + } // Actions Card ActionsCard( @@ -65,7 +65,7 @@ struct CardGrid: View { .padding(.horizontal, 20) .padding(.bottom, 20) .onAppear{ - if appState.preferences.customCardPath.isEmpty && appState.preferences.customCardsMenuLabel.isEmpty { + if !appState.preferences.customCardPath.isEmpty && appState.preferences.customCardsMenuLabel.isEmpty { appState.refreshJsonCards() } } diff --git a/SupportCompanion/Views/CompactCards/CompactPatchProgressCard.swift b/SupportCompanion/Views/CompactCards/CompactPatchProgressCard.swift index 5ddef38..f052948 100644 --- a/SupportCompanion/Views/CompactCards/CompactPatchProgressCard.swift +++ b/SupportCompanion/Views/CompactCards/CompactPatchProgressCard.swift @@ -32,6 +32,9 @@ struct CompactPatchProgressCard: View { if appState.preferences.mode == Constants.modes.intune { appState.pendingIntuneUpdatesManager.startInstallPercentageTask() } + if appState.preferences.mode == Constants.modes.jamf { + appState.pendingJamfUpdatesManager.startInstallPercentageTask() + } } .onDisappear() { if appState.preferences.mode == Constants.modes.munki { @@ -40,6 +43,9 @@ struct CompactPatchProgressCard: View { if appState.preferences.mode == Constants.modes.intune { appState.pendingIntuneUpdatesManager.stopInstallPercentageTask() } + if appState.preferences.mode == Constants.modes.jamf { + appState.pendingJamfUpdatesManager.stopInstallPercentageTask() + } } } } From bec124bf0ad97e55f020aeac41135f60eac0fd84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Alm=C3=A9n?= <78877636+almenscorner@users.noreply.github.com> Date: Mon, 20 Oct 2025 16:10:56 +0200 Subject: [PATCH 007/104] Refactor ContentView to use SidebarListView for improved sidebar handling and enhance CircularProgressWithWave for smoother wave animation --- SupportCompanion/ContentView.swift | 193 +++++++++++++----- .../Views/CircularProgressWave.swift | 27 ++- 2 files changed, 164 insertions(+), 56 deletions(-) diff --git a/SupportCompanion/ContentView.swift b/SupportCompanion/ContentView.swift index 1ec24e3..f6236cd 100644 --- a/SupportCompanion/ContentView.swift +++ b/SupportCompanion/ContentView.swift @@ -21,7 +21,8 @@ struct ContentView: View { @Environment(\.colorScheme) var colorScheme var body: some View { - let sidebarItems = generateSidebarItems(preferences: appState.preferences, stateManager: webViewStateManager) + let sidebarItems: [SidebarItem] = generateSidebarItems(preferences: appState.preferences, stateManager: webViewStateManager) + let accentColor = Color(accentNSColor) NavigationSplitView { VStack(spacing: 10) { @@ -51,31 +52,36 @@ struct ContentView: View { Spacer() // Push content to the center dynamically - // Sidebar List - List(sidebarItems, selection: $selectedItem) { item in - sidebarItem(for: item) - } - .listStyle(SidebarListStyle()) - .onAppear { - loadLogoForCurrentColorScheme() - if selectedItem == nil { - selectedItem = sidebarItems.first - } - } - .onChange(of: colorScheme) { _, _ in - loadLogoForCurrentColorScheme() - } - .onChange(of: appState.preferences.brandLogo) { _, _ in - loadLogoForCurrentColorScheme() - } - .onChange(of: appState.preferences.brandLogoLight) { _, _ in - loadLogoForCurrentColorScheme() - } - .onReceive(NotificationCenter.default.publisher(for: .handleIncomingURL)) { notification in - if let url = notification.object as? URL { + // Sidebar List (custom to avoid List clipping) + SidebarListView( + items: sidebarItems, + selectedItem: selectedItem, + onSelect: { item in + withAnimation(.spring(response: 0.5, dampingFraction: 0.7)) { + selectedItem = item + } + }, + accentColor: accentColor, + namespace: animationNamespace, + onAppear: { + loadLogoForCurrentColorScheme() + if selectedItem == nil { + selectedItem = sidebarItems.first + } + }, + onColorSchemeChange: { + loadLogoForCurrentColorScheme() + }, + onBrandLogoChange: { + loadLogoForCurrentColorScheme() + }, + onBrandLogoLightChange: { + loadLogoForCurrentColorScheme() + }, + onIncomingURL: { url in handleIncomingURL(url, items: sidebarItems) } - } + ) .background(Color.clear) } .navigationSplitViewColumnWidth( @@ -142,7 +148,11 @@ struct ContentView: View { } private func loadLogoForCurrentColorScheme() { - let base64Logo = colorScheme == .dark ? appState.preferences.brandLogo : appState.preferences.brandLogoLight.isEmpty ? appState.preferences.brandLogo : appState.preferences.brandLogoLight + let preferredLight = appState.preferences.brandLogoLight + let darkLogo = appState.preferences.brandLogo + let lightLogo = preferredLight.isEmpty ? darkLogo : preferredLight + let base64Logo = (colorScheme == .dark) ? darkLogo : lightLogo + showLogo = loadLogo(base64Logo: base64Logo) if showLogo { brandLogo = base64ToImage(base64Logo) @@ -172,38 +182,118 @@ struct ContentView: View { } } + struct SidebarRowView: View { + let item: SidebarItem + let isSelected: Bool + let accentColor: Color + let namespace: Namespace.ID + let onSelect: () -> Void + @State private var isHovered: Bool = false - @ViewBuilder - private func sidebarItem(for item: SidebarItem) -> some View { - HStack { - Image(systemName: item.systemImage) - .resizable() - .scaledToFit() - .frame(width: 20, height: 20) - Text(item.label) - .frame(maxWidth: .infinity, alignment: .leading) - } - .padding() - .background( - Group { - if selectedItem == item { + var body: some View { + ZStack { + // Background layers: selected capsule or hover capsule + if isSelected { Capsule() - .fill(Color(NSColor(hex: appState.preferences.accentColor ?? "") ?? NSColor.controlAccentColor)) - .matchedGeometryEffect(id: "sidebar-highlight", in: animationNamespace) - } else { + .fill(accentColor) + .matchedGeometryEffect(id: "sidebar-highlight", in: namespace) + .frame(height: 50) + } else if isHovered { Capsule() - .fill(Color.clear) + .fill(Color.primary.opacity(0.08)) + .frame(height: 50) + } + + // Row content + HStack(spacing: 8) { + Image(systemName: item.systemImage) + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + + Text(item.label) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, 15) + .padding(.vertical, 15) + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .foregroundColor(isSelected ? .white : .primary) + .onTapGesture(perform: onSelect) + .onHover { hovering in + isHovered = hovering + } + } + } +} + +private struct SidebarHighlight: View { + let isSelected: Bool + let color: Color + let namespace: Namespace.ID + + var body: some View { + Group { + if isSelected { + Capsule() + .fill(color) + .matchedGeometryEffect(id: "sidebar-highlight", in: namespace) + } else { + Capsule().fill(Color.clear) + } + } + } +} + +private struct SidebarListView: View { + let items: [SidebarItem] + let selectedItem: SidebarItem? + let onSelect: (SidebarItem) -> Void + let accentColor: Color + let namespace: Namespace.ID + + let onAppear: () -> Void + let onColorSchemeChange: () -> Void + let onBrandLogoChange: () -> Void + let onBrandLogoLightChange: () -> Void + let onIncomingURL: (URL) -> Void + + var body: some View { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(items) { item in + ContentView.SidebarRowView( + item: item, + isSelected: selectedItem == item, + accentColor: accentColor, + namespace: namespace, + onSelect: { onSelect(item) } + ) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .zIndex(selectedItem == item ? 1 : 0) } } - ) - .contentShape(Rectangle()) // Makes the entire area tappable - .foregroundColor(selectedItem == item ? .white : .primary) - .onTapGesture { - withAnimation(.spring(response: 0.5, dampingFraction: 0.7)) { - selectedItem = item + .padding(.vertical, 8) + } + .onAppear(perform: onAppear) + .onChange(of: colorScheme) { _, _ in onColorSchemeChange() } + .onReceive(NotificationCenter.default.publisher(for: .handleIncomingURL)) { notification in + if let url = notification.object as? URL { + onIncomingURL(url) } } - .onHoverEffect(item) + .onChange(of: AppStateManager.shared.preferences.brandLogo) { _, _ in onBrandLogoChange() } + .onChange(of: AppStateManager.shared.preferences.brandLogoLight) { _, _ in onBrandLogoLightChange() } + } + + @Environment(\.colorScheme) private var colorScheme +} + +private extension ContentView { + var accentNSColor: NSColor { + NSColor(hex: appState.preferences.accentColor ?? "") ?? NSColor.controlAccentColor } } @@ -222,7 +312,7 @@ struct SidebarItemStyle: ViewModifier { struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() - .environmentObject(Preferences()) + .environmentObject(AppStateManager.shared.preferences) .environmentObject(DeviceInfoManager.shared) .environmentObject(StorageInfoManager.shared) .environmentObject(MdmInfoManager.shared) @@ -252,3 +342,4 @@ struct HoverEffectModifier: ViewModifier { } } } + diff --git a/SupportCompanion/Views/CircularProgressWave.swift b/SupportCompanion/Views/CircularProgressWave.swift index 463eb67..c78b05c 100644 --- a/SupportCompanion/Views/CircularProgressWave.swift +++ b/SupportCompanion/Views/CircularProgressWave.swift @@ -24,17 +24,32 @@ struct WaveShape: Shape { let height = rect.height let midHeight = height * (1 - progress) - path.move(to: CGPoint(x: 0, y: midHeight)) + // Extend sampling beyond the visible rect to avoid straight edge at the clip boundary + let extra: CGFloat = max(12, width * 0.05) + let startX: CGFloat = -extra + let endX: CGFloat = width + extra - for x in stride(from: 0, to: width, by: 2) { + // Use a smaller step for smoother appearance + let step: CGFloat = 1 + + var isFirstPoint = true + var x = startX + while x <= endX { let relativeX = x / width let sine = sin((relativeX + phase) * 2 * .pi) let y = midHeight + waveHeight * sine - path.addLine(to: CGPoint(x: x, y: y)) + if isFirstPoint { + path.move(to: CGPoint(x: x, y: y)) + isFirstPoint = false + } else { + path.addLine(to: CGPoint(x: x, y: y)) + } + x += step } - path.addLine(to: CGPoint(x: width, y: height)) - path.addLine(to: CGPoint(x: 0, y: height)) + // Close the shape below; this will be clipped by a circle in the parent view + path.addLine(to: CGPoint(x: endX, y: height)) + path.addLine(to: CGPoint(x: startX, y: height)) path.closeSubpath() return path @@ -68,6 +83,7 @@ struct CircularProgressWithWave: View { .fill(LinearGradient(gradient: gradient, startPoint: .top, endPoint: .bottom)) .frame(width: size, height: size) // Matches the full size of the ring .clipShape(Circle()) + .drawingGroup() .onAppear { startAnimation() } @@ -104,3 +120,4 @@ struct CircularProgressWithWave: View { phase = 0 // Reset phase to avoid lingering animation effects } } + From 87ad2eef2027df9cabdd76e04691a622f3c2b096 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Alm=C3=A9n?= <78877636+almenscorner@users.noreply.github.com> Date: Mon, 20 Oct 2025 16:11:09 +0200 Subject: [PATCH 008/104] Enhance badge display in ScButton with improved styling and animation --- SupportCompanion/Views/ScButton.swift | 30 ++++++++++++++------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/SupportCompanion/Views/ScButton.swift b/SupportCompanion/Views/ScButton.swift index 7cf28a3..41ded9c 100644 --- a/SupportCompanion/Views/ScButton.swift +++ b/SupportCompanion/Views/ScButton.swift @@ -81,20 +81,22 @@ struct ScButton: View, Hashable { } .buttonStyle(PlainButtonStyle()) .disabled(disabled ?? false || isLoading) - - // Badge - if let badgeNumber = badgeNumber, badgeNumber > 0 { - Text("\(badgeNumber)") - .font(.caption) - .foregroundColor(.white) - .padding(8) - .background(Color.red) - .clipShape(Circle()) - .offset(x: 10, y: -10) - .opacity(showBadge ? 1.0 : 0.0) // Control visibility - .scaleEffect(showBadge ? 1.0 : 0.5) // Add scaling effect - .animation(.easeInOut(duration: 0.3), value: showBadge) // Smooth animation - } + .buttonStyle(PlainButtonStyle()) + .disabled(disabled ?? false || isLoading) + .overlay(alignment: .topTrailing) { + if let badgeNumber = badgeNumber, badgeNumber > 0 { + Text("\(badgeNumber)") + .font(.caption.weight(.bold)) + .foregroundColor(.white) + .padding(8) + .background(Circle().fill(Color(red: 1, green: 0, blue: 0))) + .clipShape(Circle()) + .offset(x: 10, y: -10) + .opacity(showBadge ? 1 : 0) + .scaleEffect(showBadge ? 1 : 0.5) + .animation(.easeInOut(duration: 0.3), value: showBadge) + } + } } .scaleEffect(isHovered ? 1.1 : 1.0) // Apply hover effect to the whole stack .shadow(color: .black.opacity(isHovered ? 0.3 : 0), radius: isHovered ? 10 : 0, x: 0, y: 5) From 943c9931c98553d375bcba8227af6d8ed05bcf24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Alm=C3=A9n?= <78877636+almenscorner@users.noreply.github.com> Date: Mon, 20 Oct 2025 16:11:34 +0200 Subject: [PATCH 009/104] Add support for Jamf in tray menu --- SupportCompanion/Views/TrayMenu.swift | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/SupportCompanion/Views/TrayMenu.swift b/SupportCompanion/Views/TrayMenu.swift index 1be658b..bc10a3f 100644 --- a/SupportCompanion/Views/TrayMenu.swift +++ b/SupportCompanion/Views/TrayMenu.swift @@ -8,6 +8,14 @@ import Foundation import SwiftUI +func checkModes() -> Bool { + if AppStateManager.shared.preferences.mode == Constants.modes.intune || + AppStateManager.shared.preferences.mode == Constants.modes.jamf || + AppStateManager.shared.preferences.mode == Constants.modes.munki { + return true + } + return false +} struct TrayMenuView: View { @EnvironmentObject var appState: AppStateManager @ObservedObject var viewModel: CardGridViewModel @@ -54,7 +62,7 @@ struct TrayMenuView: View { if !appState.preferences.hiddenCards.contains(Constants.Cards.storage) { CompactStorageCard() } - if appState.preferences.mode == Constants.modes.munki || appState.preferences.mode == Constants.modes.intune { + if checkModes() { if !appState.preferences.hiddenCards.contains(Constants.Cards.appPatchProgress) { CompactPatchProgressCard() } @@ -130,7 +138,7 @@ struct TrayMenuView: View { if !appState.preferences.hiddenCards.contains(Constants.Cards.battery) { count += 1 } if !appState.preferences.hiddenCards.contains(Constants.Cards.deviceInfo) { count += 1 } if !appState.preferences.hiddenCards.contains(Constants.Cards.storage) { count += 1 } - if appState.preferences.mode == Constants.modes.munki || appState.preferences.mode == Constants.modes.intune { + if checkModes() { if !appState.preferences.hiddenCards.contains(Constants.Cards.appPatchProgress) { count += 1 } @@ -146,7 +154,7 @@ struct TrayMenuView: View { AppStateManager.shared.preferences.menuShowApps, AppStateManager.shared.preferences.menuShowSelfService, viewModel.isButtonVisible(Constants.Actions.HideStrings.changePassword), - appState.preferences.mode == Constants.modes.munki || appState.preferences.mode == Constants.modes.intune, + checkModes(), viewModel.isButtonVisible(Constants.Actions.HideStrings.getSupport), viewModel.isButtonVisible(Constants.Actions.HideStrings.gatherLogs), viewModel.isButtonVisible(Constants.Actions.HideStrings.softwareUpdate), @@ -182,7 +190,7 @@ struct ButtonSection: View { }), viewModel.isButtonVisible(Constants.Actions.HideStrings.changePassword) ? viewModel.createChangePasswordButton(fontSize: 12) : nil, viewModel.isButtonVisible(Constants.Actions.HideStrings.getSupport) ? ScButton(Constants.Actions.getSupport, fontSize: 12) { ActionHelpers.openSupportPage(url: appState.preferences.supportPageURL) } : nil, - (appState.preferences.mode == Constants.modes.munki || appState.preferences.mode == Constants.modes.intune) + (checkModes()) ? (viewModel.isButtonVisible(Constants.Actions.HideStrings.openManagementApp) ? viewModel.createOpenManagementAppButton(type: .default, fontSize: 12) : nil) : nil, viewModel.isButtonVisible(Constants.Actions.HideStrings.gatherLogs) ? viewModel.createGatherLogsButton(fontSize: 12) : nil, From 8d3547a23833e1f053415291523ac8a38c9e5289 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Alm=C3=A9n?= <78877636+almenscorner@users.noreply.github.com> Date: Mon, 20 Oct 2025 16:11:47 +0200 Subject: [PATCH 010/104] Replace button image with InfoHelp component in ScCard for improved accessibility --- SupportCompanion/Views/ScCard.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/SupportCompanion/Views/ScCard.swift b/SupportCompanion/Views/ScCard.swift index d5dd934..0156366 100644 --- a/SupportCompanion/Views/ScCard.swift +++ b/SupportCompanion/Views/ScCard.swift @@ -90,12 +90,13 @@ struct ScCard: View { await buttonAction() } }) { - Image(systemName: buttonImageName) - .font(.system(size: 16)) - .foregroundColor(Color(NSColor(hex: appState.preferences.accentColor ?? "") ?? NSColor.controlAccentColor)) + //Image(systemName: buttonImageName) + // .font(.system(size: 16)) + // .foregroundColor(Color(NSColor(hex: appState.preferences.accentColor ?? "") ?? NSColor.controlAccentColor)) + InfoHelp(text: buttonHelpText ?? "", icon: buttonImageName, color: Color(NSColor(hex: appState.preferences.accentColor ?? "") ?? NSColor.controlAccentColor)) } .buttonStyle(PlainButtonStyle()) - .help(buttonHelpText ?? "") + //.help(buttonHelpText ?? "") } } .padding() From 888ab57cf7a730ff6f4034e3e21d2a9c8858e71a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Alm=C3=A9n?= <78877636+almenscorner@users.noreply.github.com> Date: Mon, 20 Oct 2025 16:12:02 +0200 Subject: [PATCH 011/104] Update localized strings for device restart messages to improve formatting consistency --- SupportCompanion/Localizable.xcstrings | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/SupportCompanion/Localizable.xcstrings b/SupportCompanion/Localizable.xcstrings index 995ab17..9d64011 100644 --- a/SupportCompanion/Localizable.xcstrings +++ b/SupportCompanion/Localizable.xcstrings @@ -3416,37 +3416,37 @@ "de" : { "stringUnit" : { "state" : "translated", - "value" : "Ihr Gerät wurde zuletzt vor %lld %@ neu gestartet. Bitte starten Sie Ihr Gerät neu, um optimale Leistung und Sicherheit zu gewährleisten." + "value" : "Ihr Gerät wurde zuletzt vor %1$lld %2$@ neu gestartet. Bitte starten Sie Ihr Gerät neu, um optimale Leistung und Sicherheit zu gewährleisten." } }, "en" : { "stringUnit" : { "state" : "new", - "value" : "Your device was last restarted %lld %@ ago. Please reboot your device to ensure optimal performance and security." + "value" : "Your device was last restarted %1$lld %2$@ ago. Please reboot your device to ensure optimal performance and security." } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Votre appareil a été redémarré pour la dernière fois il y a %lld %@. Veuillez redémarrer votre appareil afin d’assurer des performances et une sécurité optimales." + "value" : "Votre appareil a été redémarré pour la dernière fois il y a %1$lld %2$@. Veuillez redémarrer votre appareil afin d’assurer des performances et une sécurité optimales." } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "お使いのデバイスは%lld %@前に最後に再起動されました。最適なパフォーマンスとセキュリティを確保するため、デバイスを再起動してください。" + "value" : "お使いのデバイスは%1$lld %2$@前に最後に再起動されました。最適なパフォーマンスとセキュリティを確保するため、デバイスを再起動してください。" } }, "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Enheten din ble sist startet på nytt for %lld %@ siden. Start enheten på nytt for å sikre optimal ytelse og sikkerhet." + "value" : "Enheten din ble sist startet på nytt for %1$lld %2$@ siden. Start enheten på nytt for å sikre optimal ytelse og sikkerhet." } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "Din enhet startades om senast för %lld %@ sedan. Starta om enheten för att säkerställa optimal prestanda och säkerhet." + "value" : "Din enhet startades om senast för %1$lld %2$@ sedan. Starta om enheten för att säkerställa optimal prestanda och säkerhet." } } } From 5744ca70f5d62e2753e0beba20e23acd75da8902 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Alm=C3=A9n?= <78877636+almenscorner@users.noreply.github.com> Date: Mon, 20 Oct 2025 16:12:52 +0200 Subject: [PATCH 012/104] Update installation paths for helper tool and migrate helper to AppService --- SupportCompanion.xcodeproj/project.pbxproj | 3 +- SupportCompanion/HelperRemoteProvider.swift | 57 ++++++--------------- SupportCompanion/Scripts/helper_install.zsh | 2 +- 3 files changed, 20 insertions(+), 42 deletions(-) diff --git a/SupportCompanion.xcodeproj/project.pbxproj b/SupportCompanion.xcodeproj/project.pbxproj index 3357053..319d26e 100644 --- a/SupportCompanion.xcodeproj/project.pbxproj +++ b/SupportCompanion.xcodeproj/project.pbxproj @@ -36,7 +36,7 @@ F63587542CE22B6000AC5F98 /* CopyFiles */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 12; - dstPath = Contents/Library/LaunchServices; + dstPath = Contents/Library/LaunchDaemons; dstSubfolderSpec = 1; files = ( F64927372CF9EA8D00C34D90 /* com.github.macadmins.SupportCompanion.helper in CopyFiles */, @@ -204,6 +204,7 @@ JsonCardManager.swift, MDMInfoManager.swift, PendingIntuneUpdatesManager.swift, + PendingJamfUpdatesManager.swift, PendingMunkiUpdatesManager.swift, ReasonInputManager.swift, SSOInfoManager.swift, diff --git a/SupportCompanion/HelperRemoteProvider.swift b/SupportCompanion/HelperRemoteProvider.swift index 2f69cd8..8d4a905 100644 --- a/SupportCompanion/HelperRemoteProvider.swift +++ b/SupportCompanion/HelperRemoteProvider.swift @@ -16,6 +16,10 @@ enum HelperRemoteProvider { // MARK: Computed private static var isHelperInstalled: Bool { FileManager.default.fileExists(atPath: HelperConstants.helperPath) } + + // MARK: Exported app proxy for XPC (optional but safer than exporting the enum type) + private final class RemoteAppProxy: NSObject, RemoteApplicationProtocol {} + private static let exportedAppProxy = RemoteAppProxy() } // MARK: - Remote @@ -57,46 +61,19 @@ extension HelperRemoteProvider { extension HelperRemoteProvider { - /// Install the Helper in the privileged helper tools folder and load the daemon - private static func installHelper() throws { - - // try to get a valid empty authorization - var authRef: AuthorizationRef? - try AuthorizationCreate(nil, nil, [.preAuthorize], &authRef).checkError("AuthorizationCreate") - defer { - if let authRef { - AuthorizationFree(authRef, []) - } - } - - // create an AuthorizationItem to specify we want to bless a privileged Helper - let authStatus = kSMRightBlessPrivilegedHelper.withCString { authorizationString in - var authItem = AuthorizationItem(name: authorizationString, valueLength: 0, value: nil, flags: 0) - - return withUnsafeMutablePointer(to: &authItem) { pointer in - var authRights = AuthorizationRights(count: 1, items: pointer) - let flags: AuthorizationFlags = [.interactionAllowed, .extendRights, .preAuthorize] - return AuthorizationCreate(&authRights, nil, flags, &authRef) - } + /// Install the Helper in the privileged helper tools folder and register the daemon using SMAppService + private static func installHelperModern() throws { + do { + let service = SMAppService.daemon(plistName: HelperConstants.domain) + try service.register() + } catch { + Logger.shared.logError("SMAppService register failed: \(error.localizedDescription)") + throw SupportCompanionErrors.helperInstallation("Error while installing the Helper: \(error.localizedDescription)") } + } - guard authStatus == errAuthorizationSuccess else { - throw SupportCompanionErrors.helperInstallation("Unable to get a valid loading authorization reference to load Helper daemon") - } - - // After calling SMJobBless - var blessErrorPointer: Unmanaged? - let wasBlessed = SMJobBless(kSMDomainSystemLaunchd, HelperConstants.domain as CFString, authRef, &blessErrorPointer) - - guard wasBlessed else { - if let blessErrorPointer { - let errorDescription = CFErrorCopyDescription(blessErrorPointer.takeRetainedValue()) - Logger.shared.logError("SMJobBless failed: \(errorDescription as String? ?? "Unknown error")") - throw SupportCompanionErrors.helperInstallation("Error while installing the Helper: \(errorDescription as String? ?? "Unknown error")") - } else { - throw SupportCompanionErrors.helperInstallation("Unknown error while installing the Helper") - } - } + private static func installHelper() throws { + try installHelperModern() } } @@ -115,13 +92,13 @@ extension HelperRemoteProvider { let connection = NSXPCConnection(machServiceName: HelperConstants.domain, options: .privileged) connection.remoteObjectInterface = NSXPCInterface(with: HelperProtocol.self) connection.exportedInterface = NSXPCInterface(with: RemoteApplicationProtocol.self) - connection.exportedObject = self + connection.exportedObject = exportedAppProxy connection.invalidationHandler = { if isHelperInstalled { Logger.shared.logError("Unable to connect to Helper although it is installed") } else { - Logger.shared.logError("Help is not installed") + Logger.shared.logError("Helper is not installed") } } diff --git a/SupportCompanion/Scripts/helper_install.zsh b/SupportCompanion/Scripts/helper_install.zsh index 1bf274b..dd8ceb6 100755 --- a/SupportCompanion/Scripts/helper_install.zsh +++ b/SupportCompanion/Scripts/helper_install.zsh @@ -16,7 +16,7 @@ if [[ ! -d "/Library/PrivilegedHelperTools/" ]]; then fi # Copy the PrivilegedHelperTool -cp "${install_location}/Contents/Library/LaunchServices/${launch_daemon}" "/Library/PrivilegedHelperTools/" +cp "${install_location}/Contents/Library/LaunchDaemons/${launch_daemon}" "/Library/PrivilegedHelperTools/" # Set permissions chown root:wheel "${privileged_helper_tool}" chmod 544 "${privileged_helper_tool}" From fbf00701b313b241f9d8a238eb0b9ca08ab90683 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Alm=C3=A9n?= <78877636+almenscorner@users.noreply.github.com> Date: Mon, 20 Oct 2025 16:12:59 +0200 Subject: [PATCH 013/104] Add WiFi SSID information to device info output --- SupportCompanionCLI/main.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/SupportCompanionCLI/main.swift b/SupportCompanionCLI/main.swift index f42182f..93c710c 100644 --- a/SupportCompanionCLI/main.swift +++ b/SupportCompanionCLI/main.swift @@ -142,6 +142,7 @@ struct SupportCompanionCLI { let serial = getSerialNumber() ?? "Unknown" let processor = getCPUName() ?? "Unknown" let ip = getAllIPAddresses().joined(separator: ", ") + let ssid = getSSID() ?? "Unknown" let lastReboot = getLastRestartMinutes() ?? 0 let osVersion = getOSVersion() let osBuild = getOSBuild() @@ -157,6 +158,7 @@ struct SupportCompanionCLI { Processor: \(processor) Memory: \(ram) IP Address(es): \(ip) + WiFi SSID: \(ssid) Last Reboot: \(formattedLastRestart) OS Version: \(osVersion) OS Build: \(osBuild) From 147571004765bbd45bb2106ac3d07fc3512a3b4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Alm=C3=A9n?= <78877636+almenscorner@users.noreply.github.com> Date: Mon, 20 Oct 2025 16:22:38 +0200 Subject: [PATCH 014/104] bump version --- CHANGELOG.md | 17 +++++++++++++++++ SupportCompanion/Info.plist | 4 ++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d48241..092bad1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.4.0] - 2025-10-20 +### Added +- WiFi SSID information is now included in the device information +- New option to hide tray menu icon. This allows for using the desktop information window without displaying the tray menu icon. Example configuration: +```xml +TrayMenuShowIcon + +``` +- Support for monitoring Jamf application patches. When in Jamf mode, the app will now monitor for pending application patches and display them in the tray menu as well as in the main app. The badge will also be displayed in the tray menu icon when there are pending application patches. Requires the use of Self Service+. +### Changed +- MDM info now uses multiple MDM profile names to be able to correctly display the enrollment time. +- The privileged helper tool has been migrated to `SMAppService` in place of `SMJobBless` since `SMJobBless` is deprecated in macOS 14 and later. +### Fixed +- File watcher would not correctly detect changes on custom JSON cards if the file was replaced instead of modified. This has been fixed by using a different method to monitor file changes. +- The pending updates badge on `Software Updates` was transparent in the main app. + + ## [2.3.1] - 2025-10-06 ### Changed - Refactored the uninstall script with better error handling and logging. diff --git a/SupportCompanion/Info.plist b/SupportCompanion/Info.plist index 3ada032..05b156f 100644 --- a/SupportCompanion/Info.plist +++ b/SupportCompanion/Info.plist @@ -19,9 +19,9 @@ LSMinimumSystemVersion $(MACOSX_DEPLOYMENT_TARGET) CFBundleShortVersionString - 2.3.1 + 2.4.0 CFBundleVersion - 2.3.1 + 2.4.0 CFBundleURLTypes From a2bd521305e2907d5f05d55b63b70f501ecb345c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Alm=C3=A9n?= <78877636+almenscorner@users.noreply.github.com> Date: Tue, 4 Nov 2025 14:11:00 +0100 Subject: [PATCH 015/104] Fix patch progress percentage display format in CompactPatchProgressCard --- .../Views/CompactCards/CompactPatchProgressCard.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/SupportCompanion/Views/CompactCards/CompactPatchProgressCard.swift b/SupportCompanion/Views/CompactCards/CompactPatchProgressCard.swift index f052948..8c40b08 100644 --- a/SupportCompanion/Views/CompactCards/CompactPatchProgressCard.swift +++ b/SupportCompanion/Views/CompactCards/CompactPatchProgressCard.swift @@ -16,8 +16,9 @@ struct CompactPatchProgressCard: View { value: appState.installPercentage, total: 100, label: { - Text("\(String(format: "%.1f", appState.installPercentage))% Patched") - .font(.system(size: 12))} + Text("\(String(format: "%1d", Int(appState.installPercentage/100*100)))% Patched") + .font(.system(size: 12)) + } ) .tint(appState.installPercentage < 90 ? (colorScheme == .light ? .orangeLight : .orange) : appState.installPercentage < 60 ? (colorScheme == .light ? .redLight : .red) From 1dffa125c3621390cfd2ff353377cf89a287b173 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Alm=C3=A9n?= <78877636+almenscorner@users.noreply.github.com> Date: Tue, 4 Nov 2025 14:11:18 +0100 Subject: [PATCH 016/104] Enhance patch handling by adding ID to Patch model and improving update evaluation logic --- SupportCompanion/Helpers/JamfApps.swift | 41 +++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/SupportCompanion/Helpers/JamfApps.swift b/SupportCompanion/Helpers/JamfApps.swift index 1825c6e..ef9c0a6 100644 --- a/SupportCompanion/Helpers/JamfApps.swift +++ b/SupportCompanion/Helpers/JamfApps.swift @@ -7,6 +7,15 @@ import Foundation +extension DateFormatter { + static let shortDayMonth: DateFormatter = { + let df = DateFormatter() + df.locale = Locale(identifier: "en_US_POSIX") // stable month abbreviations + df.setLocalizedDateFormatFromTemplate("d MMM") + return df + }() +} + // MARK: - Date helpers (CoreData XML uses absolute seconds since 2001-01-01 a lot) extension Date { static func fromCoreDataEpochSeconds(_ s: String) -> Date? { @@ -109,13 +118,15 @@ final class SSPlusParser: NSObject, XMLParserDelegate { case "SSPATCH": let name = (currentAttributes["name"] ?? "").normalizedAppName guard let version = currentAttributes["version"], !version.isEmpty else { return } - + + let id = Int(currentAttributes["id"] ?? "") ?? 0 let available = Date.fromCoreDataEpochSeconds(currentAttributes["availabledate"] ?? "") let deadline = Date.fromCoreDataEpochSeconds(currentAttributes["deadline"] ?? "") let button = currentAttributes["buttontext"] let installStatus = Int(currentAttributes["installstatus"] ?? "") - patches.append(Patch(name: name, + patches.append(Patch(id: id, + name: name, version: version, availableDate: available, deadlineDate: deadline, @@ -277,6 +288,7 @@ func computeUpdates(policies: [Policy], }.first } var results: [PendingJamfUpdate] = [] + var matchedPolicyNames = Set() var updateCount = 0 var upToDateCount = 0 @@ -288,6 +300,7 @@ func computeUpdates(policies: [Policy], matchedPolicy = fuzzyMatchPolicy(for: patch.name, in: policiesByName) } guard let policy = matchedPolicy else { continue } + matchedPolicyNames.insert(policy.name) let (needed, label) = evaluateUpdate(policy: policy, patch: patch, now: now) let details = "available=\(patch.availableDate?.description ?? "nil"), " + @@ -303,7 +316,10 @@ func computeUpdates(policies: [Policy], needsUpdate: needed, label: label, details: details, - showInfoIcon: !details.isEmpty + showInfoIcon: !details.isEmpty, + dueBy: patch.deadlineDate.map { DateFormatter.shortDayMonth.string(from: $0) }, + patchId: patch.id, + policyName: policy.name )) } @@ -314,6 +330,25 @@ func computeUpdates(policies: [Policy], upToDateCount += 1 } } + + // Fallback: if there are no patches (or some policies have no corresponding patch), + // treat installed policies (installStatus == 4) as up-to-date so the overall percent + // does not show 0% patched purely due to missing patch objects. + if patches.isEmpty { + for (_, policy) in policiesByName { + if (policy.installStatus ?? 0) == 4 { + upToDateCount += 1 + } + } + } else { + // Also count installed policies that did not have a matching patch name. + for (name, policy) in policiesByName where !matchedPolicyNames.contains(name) { + if (policy.installStatus ?? 0) == 4 { + upToDateCount += 1 + } + } + } + return (results, updateCount, upToDateCount) } From 4da06194bafbeecf551e4ce372c13f3442731402 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Alm=C3=A9n?= <78877636+almenscorner@users.noreply.github.com> Date: Tue, 4 Nov 2025 14:11:44 +0100 Subject: [PATCH 017/104] Add optional fields to PendingJamfUpdate and Patch models for enhanced data handling --- SupportCompanion/Models/PendingJamfUpdate.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/SupportCompanion/Models/PendingJamfUpdate.swift b/SupportCompanion/Models/PendingJamfUpdate.swift index e6d0101..ecbb110 100644 --- a/SupportCompanion/Models/PendingJamfUpdate.swift +++ b/SupportCompanion/Models/PendingJamfUpdate.swift @@ -15,6 +15,9 @@ struct PendingJamfUpdate: Identifiable, Equatable { let label: UpdateLabel let details: String let showInfoIcon: Bool + let dueBy: String? + let patchId: Int? + let policyName: String? static func == (lhs: PendingJamfUpdate, rhs: PendingJamfUpdate) -> Bool { return lhs.id == rhs.id @@ -32,6 +35,7 @@ struct Policy { } struct Patch { + let id: Int let name: String let version: String let availableDate: Date? From 43196eaf9322d3a5b41daa5a3323f1741015d5b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Alm=C3=A9n?= <78877636+almenscorner@users.noreply.github.com> Date: Tue, 4 Nov 2025 14:12:14 +0100 Subject: [PATCH 018/104] Add Jamf update functionality and enhance PendingUpdatesCard display --- .../ViewModels/CardGridViewModel.swift | 40 ++++++++++++++++ .../PendingJamfUpdatesManager.swift | 22 +++++++++ SupportCompanion/Views/Cards/AppCard.swift | 34 +++++++++----- .../Views/Cards/PendingUpdatesCard.swift | 46 +++++++++++++------ 4 files changed, 115 insertions(+), 27 deletions(-) diff --git a/SupportCompanion/ViewModels/CardGridViewModel.swift b/SupportCompanion/ViewModels/CardGridViewModel.swift index aef07c4..3c11fe8 100644 --- a/SupportCompanion/ViewModels/CardGridViewModel.swift +++ b/SupportCompanion/ViewModels/CardGridViewModel.swift @@ -207,4 +207,44 @@ class CardGridViewModel: ObservableObject { func isButtonVisible(_ button: String) -> Bool { !appState.preferences.hiddenActions.contains(button) } + + @Published var isUpdating = false + + func runJamfUpdate(forId: String) async { + await MainActor.run { self.isUpdating = true } + defer { Task { await MainActor.run { self.isUpdating = false } } } + + // Kick off the update; ideally obtain a process handle or PID + do { + _ = try await ExecutionService.executeCommandPrivileged( + "/usr/local/bin/jamf", + arguments: ["patch", "-id", forId] + ) + } catch { + // Log and bail + Logger.shared.logError("jamf patch launch failed: \(error)") + return + } + + // Poll conservatively with delay; add timeout + let pattern = "jamf patch -id \(forId)" + let deadline = Date().addingTimeInterval(600) // 10 min timeout + + while Date() < deadline && !Task.isCancelled { + do { + let result = try await ExecutionService.executeCommand( + "/usr/bin/pgrep", + with: ["-lf", pattern] + ) + if result.isEmpty { + break // process no longer running + } + } catch { + // If pgrep errors, consider breaking or retrying a few times + Logger.shared.logError("pgrep error: \(error)") + } + + try? await Task.sleep(for: .seconds(0.5)) + } + } } diff --git a/SupportCompanion/ViewModels/PendingJamfUpdatesManager.swift b/SupportCompanion/ViewModels/PendingJamfUpdatesManager.swift index 02adc8d..a3fd82c 100644 --- a/SupportCompanion/ViewModels/PendingJamfUpdatesManager.swift +++ b/SupportCompanion/ViewModels/PendingJamfUpdatesManager.swift @@ -20,14 +20,30 @@ class PendingJamfUpdatesManager { self.appState = appState } + func refreshSlefService() async { + let cmd = ["-gj", Constants.AppPaths.selfService] + let quitCmd = ["-9", "Self Service+"] + let checkProcessCmd = ["-x", "Self Service\\+"] + let checkIfRunning = try? await ExecutionService.executeCommand("/usr/bin/pgrep", with: checkProcessCmd) + if checkIfRunning == nil { + _ = try? await ExecutionService.executeCommand("/usr/bin/open", with: cmd) + sleep(2) + _ = try? await ExecutionService.executeCommand("/usr/bin/pkill", with: quitCmd) + } + } + nonisolated func getPendingJamfUpdates() async { Logger.shared.logDebug("Getting Jamf pending updates") do { + await refreshSlefService() if await parser.parse() { let (pendingUpdates, _, _) = await computeUpdates(policies: parser.policies, patches: parser.patches, now: Date()) DispatchQueue.main.async { self.appState.pendingJamfUpdates = pendingUpdates + if self.appState.pendingUpdatesCount != pendingUpdates.count { + self.appState.pendingUpdatesCount = pendingUpdates.count + } } if pendingUpdates.count > 0 && !appState.preferences.hiddenCards.contains("PendingAppUpdates") { NotificationService(appState: appState).sendNotification( @@ -37,6 +53,10 @@ class PendingJamfUpdatesManager { notificationType: .appUpdate ) } + + DispatchQueue.main.async { + self.appState.pendingUpdatesCount = pendingUpdates.count + } } } } @@ -46,6 +66,7 @@ class PendingJamfUpdatesManager { do { // Fetch counts concurrently + await refreshSlefService() if await parser.parse() { let (_, updateCount, upToDateCount) = await computeUpdates(policies: parser.policies, patches: parser.patches, now: Date()) let totalApps = updateCount + upToDateCount @@ -137,3 +158,4 @@ class PendingJamfUpdatesManager { updateCheckTimer = nil } } + diff --git a/SupportCompanion/Views/Cards/AppCard.swift b/SupportCompanion/Views/Cards/AppCard.swift index b87be71..3f3d38b 100644 --- a/SupportCompanion/Views/Cards/AppCard.swift +++ b/SupportCompanion/Views/Cards/AppCard.swift @@ -93,18 +93,28 @@ struct AppCard: View { } .font(.system(size: 14)) } - - if card.isSelfServe { - ScButton(buttonText, action: { - Task { - if !card.action.isEmpty { - _ = try await ExecutionService.executeShellCommand(card.action) - } - } - }) - .frame(maxWidth: .infinity, alignment: .center) - .padding(.top, 40) - } + + HStack { + if card.isSelfServe { + ScButton(buttonText, action: { + if !card.action.isEmpty { + _ = try? await ExecutionService.executeShellCommand(card.action) + } + }) + .padding(.top, 40) + } + if AppStateManager.shared.preferences.mode == Constants.modes.jamf { + if AppStateManager.shared.pendingJamfUpdates.contains(where: { $0.policyName == card.name }) { + let patchID = AppStateManager.shared.pendingJamfUpdates.first(where: { $0.policyName == card.name })!.patchId + ScButton("Update", action: { + _ = try? await ExecutionService.executeCommandPrivileged("/bin/launchctl", arguments: ["asuser", "504", "/usr/local/bin/jamf", "patch", "-id", String(patchID!), "-showSteps", "-selfServiceOnly", "-user", "tobal86"]) + await AppStateManager.shared.pendingJamfUpdatesManager.getPendingJamfUpdates() + }) + .padding(.top, 40) + } + } + } + .frame(maxWidth: .infinity, alignment: .center) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal) diff --git a/SupportCompanion/Views/Cards/PendingUpdatesCard.swift b/SupportCompanion/Views/Cards/PendingUpdatesCard.swift index 5be830f..b0c4fab 100644 --- a/SupportCompanion/Views/Cards/PendingUpdatesCard.swift +++ b/SupportCompanion/Views/Cards/PendingUpdatesCard.swift @@ -53,6 +53,12 @@ struct PendingUpdatesCard: View { .bold() .frame(maxWidth: .infinity, alignment: .trailing) } + if appState.preferences.mode == Constants.modes.jamf { + Text("Due by") + .font(.subheadline) + .bold() + .frame(maxWidth: .infinity, alignment: .trailing) + } } .padding(.vertical, 5) .padding(.horizontal) @@ -76,23 +82,33 @@ struct PendingUpdatesCard: View { Text("No pending updates") .frame(maxWidth: .infinity, alignment: .leading) } else { - ForEach(items) { update in - HStack(spacing: 8) { - // Keep this leading text flexible - Text(update.name) - .lineLimit(1) - .truncationMode(.tail) + ForEach(items, id: \.id) { update in + HStack(spacing: 8) { + // Keep this leading text flexible + Text(update.name) + .lineLimit(1) + .truncationMode(.tail) - Spacer() + Spacer() - // Keep this trailing text compact; no infinite frames - Text(update.version) - .foregroundColor(colorScheme == .dark ? .gray : .grayLight) - .lineLimit(1) - } - .padding(.vertical, 6) - .listRowSeparator(.hidden) - } + // Keep this trailing text compact; no infinite frames + Text(update.version) + .foregroundColor(colorScheme == .dark ? .gray : .grayLight) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .trailing) + + if let jamfUpdate = update as? PendingJamfUpdate { + if let patchDue = jamfUpdate.dueBy { + Text(patchDue) + .foregroundColor(colorScheme == .dark ? .gray : .grayLight) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + } + .padding(.vertical, 6) + .listRowSeparator(.hidden) + } } } .padding(.horizontal, 8) From f9af623dba01eb691e08c024b671b4b43a65df85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Alm=C3=A9n?= <78877636+almenscorner@users.noreply.github.com> Date: Tue, 4 Nov 2025 14:12:20 +0100 Subject: [PATCH 019/104] Add translations for "Due by" in German, French, Japanese, Norwegian, and Swedish --- SupportCompanion/Localizable.xcstrings | 34 ++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/SupportCompanion/Localizable.xcstrings b/SupportCompanion/Localizable.xcstrings index 9d64011..f35b2af 100644 --- a/SupportCompanion/Localizable.xcstrings +++ b/SupportCompanion/Localizable.xcstrings @@ -1779,6 +1779,40 @@ } } }, + "Due by" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bis zum" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pour le" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "までに" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Innen" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Senast" + } + } + } + }, "Enter Reason for Elevation" : { "localizations" : { "de" : { From dc9f7f7a0dc346e95494c30c268d23dd5cdbc19a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Alm=C3=A9n?= <78877636+almenscorner@users.noreply.github.com> Date: Tue, 4 Nov 2025 14:14:50 +0100 Subject: [PATCH 020/104] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 092bad1..e1a9ccb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ``` - Support for monitoring Jamf application patches. When in Jamf mode, the app will now monitor for pending application patches and display them in the tray menu as well as in the main app. The badge will also be displayed in the tray menu icon when there are pending application patches. Requires the use of Self Service+. + - Correctly monitoring application patches from Self Service+ requires that Self Service+ is configured for SSO. This is because the data in the app is lazy updated when the user starts and authenticates in Self Service+. To work around this, Support Companion will briefly launch Self Service+ in the background to update the patch data. An icon will appear in the dock while this is happening. This process should only take a few seconds. ### Changed - MDM info now uses multiple MDM profile names to be able to correctly display the enrollment time. - The privileged helper tool has been migrated to `SMAppService` in place of `SMJobBless` since `SMJobBless` is deprecated in macOS 14 and later. From 2fc1595679c96acfc3e741b7dc2c5dfb398c4b3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Alm=C3=A9n?= <78877636+almenscorner@users.noreply.github.com> Date: Mon, 17 Nov 2025 10:29:04 +0100 Subject: [PATCH 021/104] Add Jamf functionality with info retrieval and UI integration - Implement JamfInfo and JamfInfoManager for managing Jamf data. - Introduce async functions to fetch last check-in, last inventory update, and Jamf URL. - Update AppStateManager to handle Jamf ID and integrate JamfInfoManager. - Create CompactJamfInfoCard for displaying Jamf information in the UI. - Refresh Jamf info on relevant state changes in CardGrid and TrayMenu. --- SupportCompanion/AppDelegate.swift | 23 ++- SupportCompanion/Helpers/JamfHelpers.swift | 155 ++++++++++++++ SupportCompanion/Models/Jamf.swift | 40 ++++ .../ViewModels/AppStateManager.swift | 193 +++++++++--------- .../ViewModels/JamfInfoManager.swift | 70 +++++++ SupportCompanion/Views/CardGrid.swift | 3 + .../CompactCards/CompactJamfInfoCard.swift | 24 +++ SupportCompanion/Views/TrayMenu.swift | 3 + 8 files changed, 413 insertions(+), 98 deletions(-) create mode 100644 SupportCompanion/Helpers/JamfHelpers.swift create mode 100644 SupportCompanion/Models/Jamf.swift create mode 100644 SupportCompanion/ViewModels/JamfInfoManager.swift create mode 100644 SupportCompanion/Views/CompactCards/CompactJamfInfoCard.swift diff --git a/SupportCompanion/AppDelegate.swift b/SupportCompanion/AppDelegate.swift index e787107..b773bd6 100644 --- a/SupportCompanion/AppDelegate.swift +++ b/SupportCompanion/AppDelegate.swift @@ -128,11 +128,27 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { appStateManager.startBackgroundTasks() appStateManager.refreshAll() checkAndHandleDemotionOnLaunch() + if !appStateManager.preferences.hiddenCards.contains(Constants.Cards.jamfInfo) && appStateManager.preferences.mode == Constants.modes.jamf { + //fetchAndStoreJamfId() + Task { + let id: String + do { + id = try await getJamfId() + } catch { + Logger.shared.logError("getJamfId failed: \(error.localizedDescription)") + id = "Unknown" + } + await MainActor.run { + AppStateManager.shared.jamfId = id + AppStateManager.shared.jamfInfoManager.refresh() + } + } + } } private func checkAndHandleDemotionOnLaunch() { - if let endTime = elevationManager.loadPersistedDemotionState(), Date() >= endTime { - elevationManager.demotePrivileges { success in + if let endTime = elevationManager.loadPersistedDemotionState(), Date() >= endTime { + elevationManager.demotePrivileges { success in if success { Logger.shared.logDebug("Privileges automatically demoted on app launch.") // Clear persisted state @@ -141,7 +157,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { Logger.shared.logError("Failed to demote privileges on app launch.") } } - } else if let endTime = elevationManager.loadPersistedDemotionState() { + } else if let endTime = elevationManager.loadPersistedDemotionState() { let remainingTime = endTime.timeIntervalSinceNow elevationManager.startDemotionTimer(duration: remainingTime) { remainingTime in DispatchQueue.main.async { @@ -342,3 +358,4 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { } } } + diff --git a/SupportCompanion/Helpers/JamfHelpers.swift b/SupportCompanion/Helpers/JamfHelpers.swift new file mode 100644 index 0000000..efe8247 --- /dev/null +++ b/SupportCompanion/Helpers/JamfHelpers.swift @@ -0,0 +1,155 @@ +// +// JamfHelpers.swift +// SupportCompanion +// +// Created by Tobias Almén on 2025-11-12. +// + +import Foundation + +func getLastCheckIn() async throws -> String { + let predicate = #"process == "jamf" AND eventMessage CONTAINS "recurring check-in""# + let args = [ + "show", + "--predicate", predicate, + "--last", "\(AppStateManager.shared.preferences.jamfLogPollHours)h", + "--style", "syslog" + ] + + let output: String + output = try await ExecutionService.executeCommand("/usr/bin/log", with: args) + + let lines = output.split(whereSeparator: \.isNewline).map(String.init) + guard let lastLine = lines.reversed().first(where: { !$0.trimmingCharacters(in: .whitespaces).isEmpty }) else { + return "Unknown" + } + + let parts = lastLine.split(separator: " ").map(String.init) + guard parts.count >= 2 else { return "Unknown" } + + let tsCandidate = parts[0] + " " + parts[1] + guard let tsDate = parseUnifiedLogTimestamp(tsCandidate) else { + return "Unknown" + } + + return timeAgoString(since: tsDate) +} + +func getLastInventoryUpdate() async throws -> String { + let predicate = #"process == "jamf" AND eventMessage CONTAINS "Submitting data""# + let args: [String] = [ + "show", + "--predicate", predicate, + "--last", "\(AppStateManager.shared.preferences.jamfLogPollHours)h", + "--style", "syslog" + ] + + let output: String + output = try await ExecutionService.executeCommand("/usr/bin/log", with: args) + + let lines = output.split(whereSeparator: \.isNewline).map(String.init) + guard let lastLine = lines.reversed().first(where: { !$0.trimmingCharacters(in: .whitespaces).isEmpty }) else { + return "Unknown" + } + + let parts = lastLine.split(separator: " ").map(String.init) + guard parts.count >= 2 else { return "Unknown" } + + let tsCandidate = parts[0] + " " + parts[1] + guard let tsDate = parseUnifiedLogTimestamp(tsCandidate) else { + return "Unknown" + } + + return timeAgoString(since: tsDate) +} + +func getJamfUrl() async throws -> String { + let prefPlist = "/Library/Preferences/com.jamfsoftware.jamf.plist" + + guard FileManager.default.fileExists(atPath: prefPlist) else { + Logger.shared.logError("Could not find Jamf preferences plist at \(prefPlist). Is Jamf running?") + return "Unknown" + } + + let args = ["read", prefPlist, "jss_url"] + let output: String + output = try await ExecutionService.executeCommand("/usr/bin/defaults", with: args) + return output +} + +func getJamfId() async throws -> String { + let args = ["recon"] + let output = try await ExecutionService.executeCommandPrivileged("/usr/local/bin/jamf", arguments: args) + + if let rangeStart = output.range(of: ""), + let rangeEnd = output.range(of: "", range: rangeStart.upperBound..\s*([0-9]+)\s*"#, options: []) { + let ns = output as NSString + let range = NSRange(location: 0, length: ns.length) + if let match = regex.firstMatch(in: output, options: [], range: range), + match.numberOfRanges >= 2 { + let id = ns.substring(with: match.range(at: 1)) + if !id.isEmpty { + return id + } + } + } + + Logger.shared.logError("Failed to parse Jamf computer_id from recon output") + return "Unknown" +} + +private func parseUnifiedLogTimestamp(_ s: String) -> Date? { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone.current + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSSSSZZZZZ" + if let d = formatter.date(from: s) { return d } + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSZZZZZ" + if let d = formatter.date(from: s) { return d } + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSSSS" + if let d = formatter.date(from: s) { return d } + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS" + if let d = formatter.date(from: s) { return d } + return nil +} + +private func timeAgoString(since date: Date, now: Date = Date()) -> String { + let seconds = Int(now.timeIntervalSince(date)) + if seconds < 0 { + return Constants.General.justNow + } + if seconds < 60 { + if seconds == 1 { + return "1 \(Constants.General.second) \(Constants.General.ago)" + } + return "\(seconds) \(Constants.General.seconds) \(Constants.General.ago)" + } + let minutes = seconds / 60 + if minutes < 60 { + if minutes == 1 { + return "1 \(Constants.General.minute) \(Constants.General.ago)" + } + return "\(minutes) \(Constants.General.minutes) \(Constants.General.ago)" + } + let hours = minutes / 60 + if hours < 24 { + if hours == 1 { + return "1 \(Constants.General.hour) \(Constants.General.ago)" + } + return "\(hours) \(Constants.General.hours) \(Constants.General.ago)" + } + let days = hours / 24 + if days == 1 { + return "1 \(Constants.General.day) \(Constants.General.ago)" + } + return "\(days) \(Constants.General.daysAgo)" +} diff --git a/SupportCompanion/Models/Jamf.swift b/SupportCompanion/Models/Jamf.swift new file mode 100644 index 0000000..7e59d3f --- /dev/null +++ b/SupportCompanion/Models/Jamf.swift @@ -0,0 +1,40 @@ +// +// Jamf.swift +// SupportCompanion +// +// Created by Tobias Almén on 2025-11-12. +// + +import Foundation + +struct JamfInfo: Codable { + let lastCheckIn: String + let lastInventory: String + let url: String + let jamfID: String + + func toKeyValuePairs() -> [(key: String, display: String, value: InfoValue)] { + return [ + ( + key: Constants.JamfInfo.Keys.id, + display: Constants.JamfInfo.Labels.id, + value: .string(jamfID) + ), + ( + key: Constants.JamfInfo.Keys.lastCheckin, + display: Constants.JamfInfo.Labels.lastCheckin, + value: .string(lastCheckIn) + ), + ( + key: Constants.JamfInfo.Keys.lastInventory, + display: Constants.JamfInfo.Labels.lastInventory, + value: .string(lastInventory) + ), + ( + key: Constants.JamfInfo.Keys.url, + display: Constants.JamfInfo.Labels.url, + value: .string(url) + ) + ] + } +} diff --git a/SupportCompanion/ViewModels/AppStateManager.swift b/SupportCompanion/ViewModels/AppStateManager.swift index 2eba146..403f342 100644 --- a/SupportCompanion/ViewModels/AppStateManager.swift +++ b/SupportCompanion/ViewModels/AppStateManager.swift @@ -15,11 +15,12 @@ class AppStateManager: ObservableObject { lazy var pendingMunkiUpdatesManager = PendingMunkiUpdatesManager(appState: self) lazy var applicationsInfoManager = ApplicationsInfoManager(appState: self) lazy var pendingIntuneUpdatesManager = PendingIntuneUpdatesManager(appState: self) - lazy var pendingJamfUpdatesManager = PendingJamfUpdatesManager(appState: self) + lazy var pendingJamfUpdatesManager = PendingJamfUpdatesManager(appState: self) lazy var evergreenInfoManager = EvergreenInfoManager(appState: self) lazy var elevationManager = ElevationManager(appState: self) var jsonCardManager: JsonCardManager? @Published var isRefreshing: Bool = false + @Published var jamfId: String = "" @Published var deviceInfoManager = DeviceInfoManager.shared @Published var storageInfoManager = StorageInfoManager.shared @Published var mdmInfoManager = MdmInfoManager.shared @@ -32,7 +33,7 @@ class AppStateManager: ObservableObject { @Published var pendingUpdatesCount: Int = 0 @Published var pendingMunkiUpdates: [PendingMunkiUpdate] = [] @Published var pendingIntuneUpdates: [PendingIntuneUpdate] = [] - @Published var pendingJamfUpdates: [PendingJamfUpdate] = [] + @Published var pendingJamfUpdates: [PendingJamfUpdate] = [] @Published var installedApplications: [InstalledApp] = [] @Published var systemUpdateCache: SystemUpdates = SystemUpdates(id: UUID(), count: 0, updates: []) @Published var windowIsVisible: Bool = false @@ -41,6 +42,7 @@ class AppStateManager: ObservableObject { @Published var catalogs: [String] = [] @Published var isDemotionActive: Bool = false @Published var timeToDemote: TimeInterval = 0 + @Published var jamfInfoManager: JamfInfoManager! private var cancellables: Set = Set() private var customPathCancellable: AnyCancellable? @@ -54,9 +56,12 @@ class AppStateManager: ObservableObject { if preferences.mode == Constants.modes.intune { pendingIntuneUpdatesManager.startUpdateCheckTimer() } - if preferences.mode == Constants.modes.jamf { - pendingJamfUpdatesManager.startUpdateCheckTimer() - } + if preferences.mode == Constants.modes.jamf { + pendingJamfUpdatesManager.startUpdateCheckTimer() + if !preferences.hiddenCards.contains(Constants.Cards.jamfInfo) { + jamfInfoManager.startMonitoring() + } + } systemUpdatesManager.startMonitoring() storageInfoManager.startMonitoring() deviceInfoManager.startMonitoring() @@ -68,110 +73,109 @@ class AppStateManager: ObservableObject { systemUpdatesManager.stopMonitoring() storageInfoManager.stopMonitoring() deviceInfoManager.stopMonitoring() + if !preferences.hiddenCards.contains(Constants.Cards.jamfInfo) && preferences.mode == Constants.modes.jamf { + jamfInfoManager.stopMonitoring() + } } - + init() { - // Forward changes from `SystemUpdatesManager` + // Initialize jamfInfoManager now that self exists + self.jamfInfoManager = JamfInfoManager( + jamfInfo: JamfInfo(lastCheckIn: "", lastInventory: "", url: "", jamfID: ""), + appStateManager: self + ) + + // Forward changes from other managers... systemUpdatesManager.objectWillChange - .sink { [weak self] _ in - self?.objectWillChange.send() - } + .sink { [weak self] _ in self?.objectWillChange.send() } .store(in: &cancellables) - + storageInfoManager.objectWillChange - .sink { [weak self] _ in - self?.objectWillChange.send() - } + .sink { [weak self] _ in self?.objectWillChange.send() } .store(in: &cancellables) - + deviceInfoManager.objectWillChange - .sink { [weak self] _ in - self?.objectWillChange.send() - } + .sink { [weak self] _ in self?.objectWillChange.send() } .store(in: &cancellables) - + ssoInfoManager.objectWillChange - .sink { [weak self] _ in - self?.objectWillChange.send() - } + .sink { [weak self] _ in self?.objectWillChange.send() } .store(in: &cancellables) - + userInfoManager.objectWillChange - .sink { [weak self] _ in - self?.objectWillChange.send() - } + .sink { [weak self] _ in self?.objectWillChange.send() } .store(in: &cancellables) setupCardManager() - wirePreferencesObservers() - - // If the Preferences instance is ever replaced, rewire observers - $preferences - .receive(on: RunLoop.main) - .sink { [weak self] _ in - self?.wirePreferencesObservers() - } - .store(in: &cancellables) - - // Also watch the preferences plist so external `defaults write` changes are picked up live - setupDefaultsWatcher() - } - - private func setupDefaultsWatcher() { - let domain = "com.github.macadmins.SupportCompanion" - let prefsURL = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Library/Preferences/\(domain).plist") - let path = prefsURL.path - defaultsWatcher = FileWatcher(filePath: path) { [weak self] in - guard let self = self else { return } - // Read directly from the plist to bypass UserDefaults caching - if let dict = NSDictionary(contentsOf: prefsURL) as? [String: Any], - let latest = dict["CustomCardPath"] as? String { - DispatchQueue.main.async { - if self.preferences.customCardPathPublished != latest { - Logger.shared.logInfo("Prefs plist changed -> CustomCardPath='\(latest)'") - if self.preferences.customCardPath != latest { - self.preferences.customCardPath = latest - } - self.preferences.customCardPathPublished = latest - } - } - } - } - } - - private func wirePreferencesObservers() { - // Cancel any previous subscription tied to the old preferences instance - customPathCancellable?.cancel() - - customPathCancellable = preferences.$customCardPathPublished - .map { $0.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) } - .removeDuplicates { (lhs: String, rhs: String) -> Bool in - lhs == rhs - } - .receive(on: RunLoop.main) - .sink { [weak self] (trimmed: String) in - guard let self = self else { return } - Logger.shared.logDebug("CustomCardPath changed -> '\(trimmed)'") - - // If path is empty, tear down any existing manager and clear cards - guard !trimmed.isEmpty else { - self.jsonCardManager?.stopWatching() - self.jsonCardManager = nil - self.JsonCards.removeAll() - return - } - - // Ensure a manager exists, stop any current watcher, then load and start watching the new path - if self.jsonCardManager == nil { - self.jsonCardManager = JsonCardManager(appState: self) - } - self.jsonCardManager?.stopWatching() - self.jsonCardManager?.loadFromFile(trimmed) - self.jsonCardManager?.watchFile(trimmed) - } - } + wirePreferencesObservers() + + // If the Preferences instance is ever replaced, rewire observers + $preferences + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.wirePreferencesObservers() + } + .store(in: &cancellables) + + // Also watch the preferences plist so external `defaults write` changes are picked up live + setupDefaultsWatcher() + } + + private func setupDefaultsWatcher() { + let domain = "com.github.macadmins.SupportCompanion" + let prefsURL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Preferences/\(domain).plist") + let path = prefsURL.path + defaultsWatcher = FileWatcher(filePath: path) { [weak self] in + guard let self = self else { return } + // Read directly from the plist to bypass UserDefaults caching + if let dict = NSDictionary(contentsOf: prefsURL) as? [String: Any], + let latest = dict["CustomCardPath"] as? String { + DispatchQueue.main.async { + if self.preferences.customCardPathPublished != latest { + Logger.shared.logInfo("Prefs plist changed -> CustomCardPath='\(latest)'") + if self.preferences.customCardPath != latest { + self.preferences.customCardPath = latest + } + self.preferences.customCardPathPublished = latest + } + } + } + } + } + + private func wirePreferencesObservers() { + // Cancel any previous subscription tied to the old preferences instance + customPathCancellable?.cancel() + + customPathCancellable = preferences.$customCardPathPublished + .map { $0.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) } + .removeDuplicates { (lhs: String, rhs: String) -> Bool in + lhs == rhs + } + .receive(on: RunLoop.main) + .sink { [weak self] (trimmed: String) in + guard let self = self else { return } + Logger.shared.logDebug("CustomCardPath changed -> '\(trimmed)'") + + // If path is empty, tear down any existing manager and clear cards + guard !trimmed.isEmpty else { + self.jsonCardManager?.stopWatching() + self.jsonCardManager = nil + self.JsonCards.removeAll() + return + } + + // Ensure a manager exists, stop any current watcher, then load and start watching the new path + if self.jsonCardManager == nil { + self.jsonCardManager = JsonCardManager(appState: self) + } + self.jsonCardManager?.stopWatching() + self.jsonCardManager?.loadFromFile(trimmed) + self.jsonCardManager?.watchFile(trimmed) + } + } func startDemotionTimer(duration: TimeInterval) { elevationManager.startDemotionTimer(duration: duration) { [weak self] remainingTime in @@ -218,4 +222,3 @@ class AppStateManager: ObservableObject { } } } - diff --git a/SupportCompanion/ViewModels/JamfInfoManager.swift b/SupportCompanion/ViewModels/JamfInfoManager.swift new file mode 100644 index 0000000..0c3e2a3 --- /dev/null +++ b/SupportCompanion/ViewModels/JamfInfoManager.swift @@ -0,0 +1,70 @@ +// +// JamfInfoManager.swift +// SupportCompanion +// +// Created by Tobias Almén on 2025-11-12. +// + +import Foundation + +class JamfInfoManager: ObservableObject { + private var monitorTask: Task? + private let appStateManager: AppStateManager + + @Published var jamfInfo: JamfInfo + + init(jamfInfo: JamfInfo, appStateManager: AppStateManager) { + self.jamfInfo = jamfInfo + self.appStateManager = appStateManager + } + + func refresh() { + updateJamfInfo() + } + + func updateJamfInfo() { + Task { + let lastCheckIn = (try? await getLastCheckIn()) ?? "Unknown" + let lastInventory = (try? await getLastInventoryUpdate()) ?? "Unknown" + let url = (try? await getJamfUrl()) ?? "Unknown" + await MainActor.run { + self.jamfInfo = JamfInfo( + lastCheckIn: lastCheckIn, + lastInventory: lastInventory, + url: url, + jamfID: appStateManager.jamfId + ) + } + } + } + + func startMonitoring(interval: TimeInterval = 300) { + stopMonitoring() // Stop any existing task to avoid duplicates + Logger.shared.logDebug("Starting jamf info monitoring") + monitorTask = Task { + while !Task.isCancelled { + let lastCheckIn = (try? await getLastCheckIn()) ?? "Unknown" + let lastInventory = (try? await getLastInventoryUpdate()) ?? "Unknown" + let url = (try? await getJamfUrl()) ?? "Unknown" + + await MainActor.run { + self.jamfInfo = JamfInfo ( + lastCheckIn: lastCheckIn, + lastInventory: lastInventory, + url: url, + jamfID: appStateManager.jamfId + ) + } + + try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) + } + } + } + + /// Stops the periodic monitoring task. + func stopMonitoring() { + Logger.shared.logDebug("Stopping jamf info monitoring") + monitorTask?.cancel() + monitorTask = nil + } +} diff --git a/SupportCompanion/Views/CardGrid.swift b/SupportCompanion/Views/CardGrid.swift index 4d6bc64..03e277d 100644 --- a/SupportCompanion/Views/CardGrid.swift +++ b/SupportCompanion/Views/CardGrid.swift @@ -87,6 +87,9 @@ struct CardGrid: View { if !appState.preferences.hiddenCards.contains(Constants.CardTitle.battery) { appState.batteryInfoManager.startMonitoring() } + if !appState.preferences.hiddenCards.contains(Constants.Cards.jamfInfo) && appState.preferences.mode == Constants.modes.jamf { + appState.jamfInfoManager.refresh() + } } .onDisappear { if !appState.preferences.hiddenCards.contains(Constants.CardTitle.battery) { diff --git a/SupportCompanion/Views/CompactCards/CompactJamfInfoCard.swift b/SupportCompanion/Views/CompactCards/CompactJamfInfoCard.swift new file mode 100644 index 0000000..d3705b1 --- /dev/null +++ b/SupportCompanion/Views/CompactCards/CompactJamfInfoCard.swift @@ -0,0 +1,24 @@ +// +// CompactJamfInfoCard.swift +// SupportCompanion +// +// Created by Tobias Almén on 2025-11-12. +// + +import Foundation +import SwiftUI + +struct CompactJamfInfoCard: View { + @EnvironmentObject var appState: AppStateManager + + var body: some View { + ScCardCompact( + title: "Jamf", + titleImageName: "server.rack", + imageSize: (13, 13), + content: { + CardData(info: appState.jamfInfoManager.jamfInfo.toKeyValuePairs(), fontSize: 12) + } + ) + } +} diff --git a/SupportCompanion/Views/TrayMenu.swift b/SupportCompanion/Views/TrayMenu.swift index bc10a3f..44a9784 100644 --- a/SupportCompanion/Views/TrayMenu.swift +++ b/SupportCompanion/Views/TrayMenu.swift @@ -70,6 +70,9 @@ struct TrayMenuView: View { if appState.preferences.enableElevation && appState.preferences.showElevateTrayCard { CompactElevationCard() } + if !appState.preferences.hiddenCards.contains(Constants.Cards.jamfInfo) && appState.preferences.mode == Constants.modes.jamf { + CompactJamfInfoCard() + } } Divider() From 8bfd12e65e4dab805151bab1d1e4d2ae10a66138 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Alm=C3=A9n?= <78877636+almenscorner@users.noreply.github.com> Date: Mon, 17 Nov 2025 10:29:26 +0100 Subject: [PATCH 022/104] Add Jamf info card to BatteryEvergreenStack --- .../Views/Cards/BatteryEvergreenStack.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/SupportCompanion/Views/Cards/BatteryEvergreenStack.swift b/SupportCompanion/Views/Cards/BatteryEvergreenStack.swift index e4c2349..13f2539 100644 --- a/SupportCompanion/Views/Cards/BatteryEvergreenStack.swift +++ b/SupportCompanion/Views/Cards/BatteryEvergreenStack.swift @@ -54,6 +54,20 @@ struct BatteryEvergreenStack: View { }) .fixedSize(horizontal: false, vertical: false) } + + if viewModel.isCardVisible(Constants.Cards.jamfInfo) && appState.preferences.mode == Constants.modes.jamf { + ScCard(title: Constants.CardTitle.jamfInfo, titleImageName: "server.rack", content: { + VStack(alignment: .leading, spacing: 5) { + CardData(info: appState.jamfInfoManager.jamfInfo.toKeyValuePairs()) + Spacer() + } + .padding(.horizontal) + //.frame(height: 116) + .frame(maxHeight: .infinity, alignment: .top) + .frame(minHeight: 110) + }) + .fixedSize(horizontal: false, vertical: false) + } } } } From 69851e5415b6e98895beef5554a5661849e4be28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Alm=C3=A9n?= <78877636+almenscorner@users.noreply.github.com> Date: Mon, 17 Nov 2025 10:29:57 +0100 Subject: [PATCH 023/104] Enhance version normalization logic and update self-service refresh behavior --- SupportCompanion/Helpers/JamfApps.swift | 32 +++++++++++++++---- .../PendingJamfUpdatesManager.swift | 20 +++++++----- 2 files changed, 37 insertions(+), 15 deletions(-) diff --git a/SupportCompanion/Helpers/JamfApps.swift b/SupportCompanion/Helpers/JamfApps.swift index ef9c0a6..e8904a6 100644 --- a/SupportCompanion/Helpers/JamfApps.swift +++ b/SupportCompanion/Helpers/JamfApps.swift @@ -179,6 +179,16 @@ enum UpdateLabel: CustomStringConvertible { } } +// Normalize version strings to compare semantic part only (e.g., "6.6.6" == "6.6.6 (67409)") +private func normalizeVersionSemantic(_ v: String) -> String { + let trimmed = v.trimmingCharacters(in: .whitespacesAndNewlines) + // If there's a space or parenthesis, keep only the prefix before it. + if let idx = trimmed.firstIndex(where: { $0 == " " || $0 == "(" }) { + return String(trimmed[.. (needed: Bool, label: UpdateLabel) { @@ -190,15 +200,16 @@ func evaluateUpdate(policy: Policy, patch: Patch, now: Date = .init()) -> (neede // 1. Missing availability → version-only check guard let avail = patch.availableDate else { if let pv = policy.policyVersion { - return (pv != patch.version, pv != patch.version ? due(nil) : .upToDate) + let pvNorm = normalizeVersionSemantic(pv) + let patchNorm = normalizeVersionSemantic(patch.version) + let mismatch = pvNorm != patchNorm + return (mismatch, mismatch ? due(nil) : .upToDate) } return (false, .unknown("missing availableDate")) } // 2. Overdue beats everything if let d = patch.deadlineDate, now >= d { - // first try version matching - return (true, .overdue(d)) } @@ -209,13 +220,21 @@ func evaluateUpdate(policy: Policy, patch: Patch, now: Date = .init()) -> (neede // 4. Facts let installedAfterAvail = (policy.installedOrUpdated ?? .distantPast) >= avail - let versionMatches = policy.policyVersion.map { $0 == patch.version } // Optional + let versionMatches: Bool? = policy.policyVersion.map { + normalizeVersionSemantic($0) == normalizeVersionSemantic(patch.version) + } // 5. Installed after availability if installedAfterAvail { switch versionMatches { - case .some(false): return (true, due(patch.deadlineDate)) // known mismatch - default: return (false, .upToDate) // match or nil → trust the date + case .some(false): + let pv = normalizeVersionSemantic(policy.policyVersion ?? "") + let pvRaw = policy.policyVersion ?? "" + let patchNorm = normalizeVersionSemantic(patch.version) + print("\(patch.name) is available now, but the installed version (\(pvRaw)) [normalized: \(pv)] doesn't match \(patch.version) [normalized: \(patchNorm)].") + return (true, due(patch.deadlineDate)) // known mismatch + default: + return (false, .upToDate) // match or nil → trust the date } } @@ -440,4 +459,3 @@ func downloadAppIcon(forApp: InstalledApp) async -> String { } return iconFileURL.path } - diff --git a/SupportCompanion/ViewModels/PendingJamfUpdatesManager.swift b/SupportCompanion/ViewModels/PendingJamfUpdatesManager.swift index a3fd82c..494f8b7 100644 --- a/SupportCompanion/ViewModels/PendingJamfUpdatesManager.swift +++ b/SupportCompanion/ViewModels/PendingJamfUpdatesManager.swift @@ -21,14 +21,18 @@ class PendingJamfUpdatesManager { } func refreshSlefService() async { - let cmd = ["-gj", Constants.AppPaths.selfService] - let quitCmd = ["-9", "Self Service+"] - let checkProcessCmd = ["-x", "Self Service\\+"] - let checkIfRunning = try? await ExecutionService.executeCommand("/usr/bin/pgrep", with: checkProcessCmd) - if checkIfRunning == nil { - _ = try? await ExecutionService.executeCommand("/usr/bin/open", with: cmd) - sleep(2) - _ = try? await ExecutionService.executeCommand("/usr/bin/pkill", with: quitCmd) + if appState.preferences.refreshSelfService { + let cmd = ["-gj", Constants.AppPaths.selfService] + let quitCmd = ["-9", "Self Service+"] + let checkProcessCmd = ["-x", "Self Service\\+"] + let checkIfRunning = try? await ExecutionService.executeCommand("/usr/bin/pgrep", with: checkProcessCmd) + if checkIfRunning == nil { + _ = try? await ExecutionService.executeCommand("/usr/bin/open", with: cmd) + sleep(2) + _ = try? await ExecutionService.executeCommand("/usr/bin/pkill", with: quitCmd) + } + } else { + Logger.shared.logDebug("Self Service configured to not refresh itself") } } From be8dd6807d2b21e82a3a896d1531ded9f142336f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Alm=C3=A9n?= <78877636+almenscorner@users.noreply.github.com> Date: Mon, 17 Nov 2025 10:30:29 +0100 Subject: [PATCH 024/104] Add brand name and fix fielvault hide logic --- SupportCompanion/Views/TransparentView.swift | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/SupportCompanion/Views/TransparentView.swift b/SupportCompanion/Views/TransparentView.swift index 66fe027..1e942fb 100644 --- a/SupportCompanion/Views/TransparentView.swift +++ b/SupportCompanion/Views/TransparentView.swift @@ -36,7 +36,9 @@ struct TransparentView: View { VStack(alignment: .leading) { // Title for the Info View if !appState.preferences.desktopInfoHideItems.contains("Category") { - Text(Constants.CardTitle.deviceInfo) + let trimmedBrand = appState.preferences.brandName.trimmingCharacters(in: .whitespacesAndNewlines) + let useBrand = !trimmedBrand.isEmpty && trimmedBrand.caseInsensitiveCompare("Support Companion") != .orderedSame + Text(useBrand ? trimmedBrand : Constants.CardTitle.deviceInfo) .font(.title2) .bold() .foregroundColor(.white) @@ -217,11 +219,13 @@ struct TransparentView: View { } } if item.key == "FileVault" { - StorageInfoRowTransparent( - label: item.display, - value: item.value.displayValue, - fontSize: CGFloat(appState.preferences.desktopInfoFontSize) - ) + if !appState.preferences.desktopInfoHideItems.contains(Constants.Storage.Keys.fileVault) { + StorageInfoRowTransparent( + label: item.display, + value: item.value.displayValue, + fontSize: CGFloat(appState.preferences.desktopInfoFontSize) + ) + } usageInfoRowTransparent( value: appState.storageInfoManager.storageInfo.usage, fontSize: CGFloat(appState.preferences.desktopInfoFontSize) @@ -402,3 +406,4 @@ struct LastRestartRowTransparent: View { } } } + From c9125de17aa2320515565af5ffadc24009c65154 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Alm=C3=A9n?= <78877636+almenscorner@users.noreply.github.com> Date: Mon, 17 Nov 2025 10:30:51 +0100 Subject: [PATCH 025/104] Add localization strings for Jamf info and general time labels --- SupportCompanion/Constants.swift | 20 +++ SupportCompanion/Localizable.xcstrings | 162 +++++++++++++++++++++++++ 2 files changed, 182 insertions(+) diff --git a/SupportCompanion/Constants.swift b/SupportCompanion/Constants.swift index bae3086..cd87ae6 100644 --- a/SupportCompanion/Constants.swift +++ b/SupportCompanion/Constants.swift @@ -50,6 +50,8 @@ enum Constants { static let close = String(localized: "General.Close", defaultValue: "Close", comment: "Close") static let elevate = String(localized: "General.Elevate", defaultValue: "Elevate", comment: "Elevate") static let demote: String = String(localized: "General.Demote", defaultValue: "Demote", comment: "Demote") + static let ago: String = String(localized: "General.Ago", defaultValue: "Ago", comment: "Ago") + static let justNow: String = String(localized: "General.JustNow", defaultValue: "Just Now", comment: "Just Now") } enum AppPaths { @@ -117,6 +119,7 @@ enum Constants { static let appPatchProgress = "ApplicationInstallProgress" static let battery = "Battery" static let pendingAppUpdates = "PendingAppUpdates" + static let jamfInfo = "Jamf" } enum CardTitle { @@ -133,6 +136,7 @@ enum Constants { static let pendingUpdates = String(localized: "Card.PendingUpdatesTitle", defaultValue: "Pending Updates", comment: "Title for pending updates card") static let installedApps = String(localized: "Card.InstalledAppsTitle", defaultValue: "Installed Applications", comment: "Title for installed apps card") static let privileges = String(localized: "Card.PrivilegesTitle", defaultValue: "Privileges", comment: "Title for privileges card") + static let jamfInfo = "Jamf" } enum RebootModal { @@ -212,6 +216,22 @@ enum Constants { static let serialNumber = "SerialNumber" } } + + enum JamfInfo { + enum Labels { + static let lastCheckin = String(localized: "JamfInfo.LastCheckin", defaultValue: "Check-In:", comment: "Label for the last check-in date") + static let lastInventory = String(localized: "JamfInfo.LastInventory", defaultValue: "Inventory:", comment: "Label for the last update date") + static let url = String(localized: "JamfInfo.URL", defaultValue: "URL:", comment: "Label for the URL") + static let id = String(localized: "JamfInfo.ID", defaultValue: "ID:", comment: "Label for the ID") + } + + enum Keys { + static let lastCheckin = "JamfLastCheckin" + static let lastInventory = "JamfLastInventory" + static let url = "JamfUrl" + static let id = "JamfId" + } + } enum KerberosSSO { enum Labels { diff --git a/SupportCompanion/Localizable.xcstrings b/SupportCompanion/Localizable.xcstrings index f35b2af..276b981 100644 --- a/SupportCompanion/Localizable.xcstrings +++ b/SupportCompanion/Localizable.xcstrings @@ -2032,6 +2032,48 @@ } } }, + "General.Ago" : { + "comment" : "Ago", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "tagen" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Ago" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "avant" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "前" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "siden" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "sedan" + } + } + } + }, "General.Close" : { "comment" : "Close", "extractionState" : "manual", @@ -2410,6 +2452,18 @@ } } }, + "General.JustNow" : { + "comment" : "Just Now", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Just Now" + } + } + } + }, "General.Manage" : { "comment" : "Manage", "extractionState" : "manual", @@ -2608,6 +2662,114 @@ } } }, + "JamfInfo.ID" : { + "comment" : "Label for the ID", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "ID:" + } + } + } + }, + "JamfInfo.LastCheckin" : { + "comment" : "Label for the last check-in date", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Check-in:" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Check-In:" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Check-in:" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "チェックイン:" + } + }, + "nb" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Check-in:" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Check-in:" + } + } + } + }, + "JamfInfo.LastInventory" : { + "comment" : "Label for the last update date", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inventur:" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Inventory:" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inventaire:" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "インベントリ:" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inventar:" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inventering:" + } + } + } + }, + "JamfInfo.URL" : { + "comment" : "Label for the URL", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "URL:" + } + } + } + }, "KerberosSSO.ExipiryDays" : { "comment" : "Label for the number of days before the password expires", "extractionState" : "extracted_with_value", From 6445d360b2ef01c1a49032fbe7e63a547266ff50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Alm=C3=A9n?= <78877636+almenscorner@users.noreply.github.com> Date: Mon, 17 Nov 2025 10:31:02 +0100 Subject: [PATCH 026/104] Add general preferences for self-service refresh and Jamf log polling hours --- SupportCompanion/Preferences.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/SupportCompanion/Preferences.swift b/SupportCompanion/Preferences.swift index e544d6c..cfbdfb7 100644 --- a/SupportCompanion/Preferences.swift +++ b/SupportCompanion/Preferences.swift @@ -176,6 +176,12 @@ class Preferences: ObservableObject { @AppStorage("ElevationWebhookUrl") var elevationWebhookURL: String = "" @AppStorage("ElevationSeverity") var elevationSeverity: Int = 6 // Default to "Informational" + + // MARK: General + + @AppStorage("RefreshSelfService") var refreshSelfService: Bool = true + + @AppStorage("JamfLogPollHours") var jamfLogPollHours: Int = 36 var mdm: String = "Unknown" From 90e6fb437dabb64047f933f1c5136b0ffb93481d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Alm=C3=A9n?= <78877636+almenscorner@users.noreply.github.com> Date: Mon, 17 Nov 2025 10:31:12 +0100 Subject: [PATCH 027/104] bump version --- CHANGELOG.md | 21 ++++++++++++++++++++- SupportCompanion.xcodeproj/project.pbxproj | 5 +++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1a9ccb..ce49176 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [2.4.0] - 2025-10-20 +## [2.4.0] - 2025-11-17 ### Added - WiFi SSID information is now included in the device information - New option to hide tray menu icon. This allows for using the desktop information window without displaying the tray menu icon. Example configuration: @@ -14,12 +14,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ``` - Support for monitoring Jamf application patches. When in Jamf mode, the app will now monitor for pending application patches and display them in the tray menu as well as in the main app. The badge will also be displayed in the tray menu icon when there are pending application patches. Requires the use of Self Service+. - Correctly monitoring application patches from Self Service+ requires that Self Service+ is configured for SSO. This is because the data in the app is lazy updated when the user starts and authenticates in Self Service+. To work around this, Support Companion will briefly launch Self Service+ in the background to update the patch data. An icon will appear in the dock while this is happening. This process should only take a few seconds. + - Can be turned off by setting `RefreshSelfService` to `false` in the configuration. Example configuration: +```xml +RefreshSelfService + +``` +- A new default card for Jamf mode that displays the last time the device checked in, the last inventorury time and the MDM enrollment time as well as the ID of the device in Jamf. This card is only displayed when in Jamf mode and can be hidden using the `HiddenCards` configuration. +```xml +HiddenCards + + Jamf + +``` +- A new option for Jamf mode to set the polling interval for logs collection to gather last check in time and last inventory time. This allows for admins to set how often the app should check for new log data. By default, the interval is set to 36 hours. Example configuration: +```xml +JamfLogPollHours +46 +``` ### Changed - MDM info now uses multiple MDM profile names to be able to correctly display the enrollment time. - The privileged helper tool has been migrated to `SMAppService` in place of `SMJobBless` since `SMJobBless` is deprecated in macOS 14 and later. +- If `BrandName` is configured, it will now be displayed in the desktop information window as well as the header instead of "Device Information". ### Fixed - File watcher would not correctly detect changes on custom JSON cards if the file was replaced instead of modified. This has been fixed by using a different method to monitor file changes. - The pending updates badge on `Software Updates` was transparent in the main app. +- `FileVault` did not hide the item on the desktop information window when configured to be hidden. ## [2.3.1] - 2025-10-06 diff --git a/SupportCompanion.xcodeproj/project.pbxproj b/SupportCompanion.xcodeproj/project.pbxproj index 319d26e..88c0e69 100644 --- a/SupportCompanion.xcodeproj/project.pbxproj +++ b/SupportCompanion.xcodeproj/project.pbxproj @@ -201,6 +201,7 @@ ElevationManager.swift, EvergreenInfoManager.swift, IdentityViewModel.swift, + JamfInfoManager.swift, JsonCardManager.swift, MDMInfoManager.swift, PendingIntuneUpdatesManager.swift, @@ -554,6 +555,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.github.macadmins.SupportCompanion.CLI; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; + SDKROOT = macosx; SWIFT_VERSION = 5.0; }; name = Debug; @@ -569,6 +571,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.github.macadmins.SupportCompanion.CLI; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; + SDKROOT = macosx; SWIFT_VERSION = 5.0; }; name = Release; @@ -798,6 +801,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.github.macadmins.SupportCompanion.helper; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; + SDKROOT = macosx; SWIFT_VERSION = 5.0; TEAM_ID = H92SB6Z7S4; }; @@ -828,6 +832,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.github.macadmins.SupportCompanion.helper; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; + SDKROOT = macosx; SWIFT_VERSION = 5.0; TEAM_ID = T4SK8ZXCXG; }; From 5a3a160d69e40592e5c1d86ce10c96df70a338d5 Mon Sep 17 00:00:00 2001 From: "Young, Nathen C" Date: Tue, 25 Nov 2025 16:23:51 -0800 Subject: [PATCH 028/104] Add custom Company Portal Url support and improve intune detection for any non-commercial microsoft cloud. --- SupportCompanion/Preferences.swift | 144 ++++++++++++++------------- SupportCompanion/SidebarConfig.swift | 10 +- 2 files changed, 81 insertions(+), 73 deletions(-) diff --git a/SupportCompanion/Preferences.swift b/SupportCompanion/Preferences.swift index a5151a5..8fe15d6 100644 --- a/SupportCompanion/Preferences.swift +++ b/SupportCompanion/Preferences.swift @@ -16,59 +16,61 @@ class Preferences: ObservableObject { case generic = "LastGenericNotificationTime" case appUpdate = "LastAppUpdateNotificationTime" } - + // MARK: - Notifications - + @AppStorage(NotificationType.softwareUpdate.rawValue) var lastSoftwareUpdateNotificationTime: String = "" - + @AppStorage(NotificationType.rebootReminder.rawValue) var lastRebootReminderNotificationTime: String = "" - + @AppStorage(NotificationType.generic.rawValue) var lastGenericNotificationTime: String = "" - + @AppStorage(NotificationType.appUpdate.rawValue) var lastAppUpdateNotificationTime: String = "" - + @AppStorage("NotificationTitle") var notificationTitle: String = "Support Companion" - + @AppStorage("NotificationInterval") var notificationInterval: Int = 4 - + @AppStorage("NotifcationImage") var notificationImage: String = "" - + @AppStorage("SoftwareUpdateNotificationButtonText") var softwareUpdateNotificationButtonText: String = Constants.Notifications.SoftwareUpdate.UpdateNotificationButtonText - + @AppStorage("SoftwareUpdateNotificationCommand") var softwareUpdateNotificationCommand: String = "open \(Constants.Panels.softwareUpdates)" - + @AppStorage("SoftwareUpdateNotificationMessage") var softwareUpdateNotificationMessage: String = Constants.Notifications.SoftwareUpdate.UpdateNotificationMessage - + @AppStorage("AppUpdateNotificationMessage") var appUpdateNotificationMessage: String = Constants.Notifications.AppUpdate.UpdateNotificationMessage - + @AppStorage("AppUpdateNotificationButtonText") var appUpdateNotificationButtonText: String = Constants.Notifications.AppUpdate.UpdateNotificationButtonText - + @AppStorage("AppUpdateNotificationCommand") var appUpdateNotificationCommand: String = "" @AppStorage("RebootReminderDays") var rebootReminderDays: Int = 0 - + // MARK: - branding - + @AppStorage("BrandName") var brandName: String = "Support Companion" - + @AppStorage("BrandLogo") var brandLogo: String = "" - + @AppStorage("BrandLogoLight") var brandLogoLight: String = "" - + @AppStorage("AccentColor") var accentColor: String? - + // MARK: - Menu - + @AppStorage("MenuShowIdentity") var menuShowIdentity: Bool = true @AppStorage("MenuShowApps") var menuShowApps: Bool = true @AppStorage("MenuShowSelfService") var menuShowSelfService: Bool = true + @AppStorage("CompanyPortalUrl") var companyPortalUrl: String = "" + @AppStorage("MenuShowCompanyPortal") var menuShowCompanyPortal: Bool = true - + @AppStorage("MenuShowKnowledgeBase") var menuShowKnowledgeBase: Bool = true - + @AppStorage("KnowledgeBaseUrl") var knowledgeBaseUrl: String = "" @AppStorage("ShowLogoInTrayMenu") var showLogoInTrayMenu: Bool = true @@ -78,64 +80,64 @@ class Preferences: ObservableObject { @AppStorage("MarkdownMenuLabel") var markdownMenuLabel: String = "" @AppStorage("MarkdownMenuIcon") var markdownMenuIcon: String = "" - + @AppStorage("CustomCardsMenuLabel") var customCardsMenuLabel: String = "" - + @AppStorage("CustomCardsMenuIcon") var customCardsMenuIcon: String = "" @AppStorage("TrayMenuBrandingIcon") var trayMenuBrandingIcon: String = "" - + // MARK: - Actions - + @AppStorage("SupportPageUrl") var supportPageURL: String = "" - + @AppStorage("ChangePasswordMode") var changePasswordMode: String = "" - + @AppStorage("ChangePasswordUrl") var changePasswordUrl: String = "" - + @AppStorage("Mode") var mode: String = "" - + @Published var actions: [Action] = [] - + @Published var hiddenActions: [String] = UserDefaults.standard.array(forKey: "HiddenActions") as? [String] ?? [] - + @Published var logFolders: [String] = UserDefaults.standard.array(forKey: "LogFolders") as? [String] ?? [] @Published var excludedLogFolders: [String] = UserDefaults.standard.array(forKey: "ExcludedLogFolders") as? [String] ?? [] @AppStorage("RequirePrivilegedActionAuthentication") var requirePrivilegedActionAuthentication: Bool = true - + // MARK: - Desktop Info - + @AppStorage("DesktopInfoBackgroundOpacity") var desktopInfoBackgroundOpacity: Double = 0.001 - + @AppStorage("DesktopInfoBackgroundFrosted") var desktopInfoBackgroundFrosted: Bool = false - + @AppStorage("DesktopInfoWindowPosition") var desktopInfoWindowPosition: String = "LowerRight" @Published var currentWindowPosition: String = "LowerRight" - + @AppStorage("ShowDesktopInfo") var showDesktopInfo: Bool = false - + @AppStorage("DesktopInfoFontSize") var desktopInfoFontSize: Int = 14 - + @AppStorage("DesktopInfoLevel") var desktopInfoLevel: Int = 4 - + @Published var desktopInfoHideItems: [String] = UserDefaults.standard.array(forKey: "DesktopInfoHideItems") as? [String] ?? [] - + // MARK: - Home - + @AppStorage("CustomCardPath") var customCardPath: String = "" - + @Published var hiddenCards: [String] = UserDefaults.standard.array(forKey: "HiddenCards") as? [String] ?? [] - + private var cancellable: AnyCancellable? - + private var cancellables = Set() - + // MARK: - Support info - + @AppStorage("SupportEmail") var supportEmail: String = "" - + @AppStorage("SupportPhone") var supportPhone: String = "" // MARK: - Elevate privileges @@ -153,12 +155,12 @@ class Preferences: ObservableObject { @AppStorage("ElevationWebhookUrl") var elevationWebhookURL: String = "" @AppStorage("ElevationSeverity") var elevationSeverity: Int = 6 // Default to "Informational" - + var mdm: String = "Unknown" - + init() { ensureDefaultsInitialized() - + NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification) .sink { [weak self] _ in guard let self = self else { return } @@ -167,7 +169,7 @@ class Preferences: ObservableObject { } } .store(in: &cancellables) - + // Observe changes to UserDefaults specifically for complex types cancellable = NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification) .sink { [weak self] _ in @@ -182,7 +184,7 @@ class Preferences: ObservableObject { await detectModeAndSetLogFolders() } } - + private func detectModeAndSetLogFolders() async { //if !logFolders.isEmpty { // Logger.shared.logDebug("Log folders already initialized: \(logFolders)") @@ -198,17 +200,23 @@ class Preferences: ObservableObject { let companyPortalExists = fileManager.fileExists(atPath: Constants.AppPaths.companyPortal) let mscExists = fileManager.fileExists(atPath: Constants.AppPaths.MSC) let mdmUrl = await getMDMUrl() - + print(mdmUrl) if mdmUrl != "Unknown" { Logger.shared.logDebug("MDM URL detected: \(mdmUrl)") - if mdmUrl.contains("i.manage.microsoft.com") { - Logger.shared.logDebug("MDM URL contains i.manage.microsoft.com, setting mdm to Intune.") - mdm = "Intune" + if let url = URL(string: mdmUrl), + let host = url.host?.lowercased() { + let pattern = #"(^|\.)manage\.microsoft\.[a-z0-9-]{2,63}$"# + let regex = try! NSRegularExpression(pattern: pattern, options: []) + let range = NSRange(host.startIndex.. RunAtLoad + KeepAlive + Disabled diff --git a/SupportCompanion.xcodeproj/project.pbxproj b/SupportCompanion.xcodeproj/project.pbxproj index b97ed0a..746f8e1 100644 --- a/SupportCompanion.xcodeproj/project.pbxproj +++ b/SupportCompanion.xcodeproj/project.pbxproj @@ -911,7 +911,8 @@ "-sectcreate", __TEXT, __launchd_plist, - "\"$(SRCROOT)/Helper/Launchd.plist", + "\"$(SRCROOT)/Helper/Launchd.plist\"", + "-lbsm", ); PRODUCT_BUNDLE_IDENTIFIER = com.github.macadmins.SupportCompanion.helper; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -942,7 +943,8 @@ "-sectcreate", __TEXT, __launchd_plist, - "\"$(SRCROOT)/Helper/Launchd.plist", + "\"$(SRCROOT)/Helper/Launchd.plist\"", + "-lbsm", ); PRODUCT_BUNDLE_IDENTIFIER = com.github.macadmins.SupportCompanion.helper; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/SupportCompanion/App/AppDelegate.swift b/SupportCompanion/App/AppDelegate.swift index be78b7c..e8ec142 100644 --- a/SupportCompanion/App/AppDelegate.swift +++ b/SupportCompanion/App/AppDelegate.swift @@ -38,7 +38,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { private func executeAction(_ action: Action) { Task { do { - _ = try await ExecutionService.executeShellCommand(action.command, isPrivileged: action.isPrivileged) + _ = try await ExecutionService.runAction(action) } catch { Logger.shared.logError("Failed to execute action: \(error)") } @@ -156,18 +156,13 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { } private func checkAndHandleDemotionOnLaunch() { - if let endTime = elevationManager.loadPersistedDemotionState(), Date() >= endTime { - elevationManager.demotePrivileges { success in - if success { - Logger.shared.logDebug("Privileges automatically demoted on app launch.") - // Clear persisted state - UserDefaults.standard.removeObject(forKey: "PrivilegeDemotionEndTime") - } else { - Logger.shared.logError("Failed to demote privileges on app launch.") - } - } - } else if let endTime = elevationManager.loadPersistedDemotionState() { - let remainingTime = endTime.timeIntervalSinceNow + // Demotion is the helper's job and it happens whether or not this app is running, including + // while it was quit. All there is to do at launch is pick up the countdown already in progress. + Task { @MainActor in + let remainingTime = await elevationManager.remainingElevationTime() + + guard remainingTime > 0 else { return } + elevationManager.startDemotionTimer(duration: remainingTime) { remainingTime in Task { @MainActor in AppStateManager.shared.timeToDemote = remainingTime @@ -410,7 +405,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { @objc private func runAction(_ sender: NSMenuItem) { guard let action = sender.representedObject as? Action else { return } Task { - _ = try await ExecutionService.executeShellCommand(action.command, isPrivileged: action.isPrivileged) + _ = try await ExecutionService.runAction(action) } } diff --git a/SupportCompanion/Helpers/ActionHelpers.swift b/SupportCompanion/Helpers/ActionHelpers.swift index 3876452..f194690 100644 --- a/SupportCompanion/Helpers/ActionHelpers.swift +++ b/SupportCompanion/Helpers/ActionHelpers.swift @@ -129,10 +129,7 @@ struct ActionHelpers { static func restartIntuneAgent(completion: @escaping (OperationResult) -> Void) { Task { do { - let executionResult = try await ExecutionService.executeCommandPrivileged( - "killall", - arguments: ["IntuneMdmAgent"] - ) + let executionResult = try await ExecutionService.restartIntuneAgent() completion(.success(executionResult)) } catch { completion(.failure(error)) diff --git a/SupportCompanion/Helpers/JamfHelpers.swift b/SupportCompanion/Helpers/JamfHelpers.swift index 42d3e8b..93831f5 100644 --- a/SupportCompanion/Helpers/JamfHelpers.swift +++ b/SupportCompanion/Helpers/JamfHelpers.swift @@ -9,16 +9,10 @@ import Foundation @MainActor func getLastCheckIn() async throws -> String { - let predicate = #"process == "jamf" AND eventMessage CONTAINS "recurring check-in""# - let args = [ - "show", - "--predicate", predicate, - "--last", "\(AppStateManager.shared.preferences.jamfLogPollHours)h", - "--style", "syslog" - ] - - let output: String - output = try await ExecutionService.executeCommandPrivileged("/usr/bin/log", arguments: args) + let output = try await ExecutionService.jamfLog( + kind: .checkIn, + hours: AppStateManager.shared.preferences.jamfLogPollHours + ) let lines = output.split(whereSeparator: \.isNewline).map(String.init) guard let lastLine = lines.reversed().first(where: { !$0.trimmingCharacters(in: .whitespaces).isEmpty }) else { @@ -44,16 +38,10 @@ func getLastCheckIn() async throws -> String { @MainActor func getLastInventoryUpdate() async throws -> String { - let predicate = #"process == "jamf" AND eventMessage CONTAINS "Submitting data""# - let args: [String] = [ - "show", - "--predicate", predicate, - "--last", "\(AppStateManager.shared.preferences.jamfLogPollHours)h", - "--style", "syslog" - ] - - let output: String - output = try await ExecutionService.executeCommandPrivileged("/usr/bin/log", arguments: args) + let output = try await ExecutionService.jamfLog( + kind: .inventory, + hours: AppStateManager.shared.preferences.jamfLogPollHours + ) let lines = output.split(whereSeparator: \.isNewline).map(String.init) guard let lastLine = lines.reversed().first(where: { !$0.trimmingCharacters(in: .whitespaces).isEmpty }) else { @@ -92,8 +80,7 @@ func getJamfUrl() async throws -> String { } func getJamfId() async throws -> String { - let args = ["recon", "-concurrent"] - let output = try await ExecutionService.executeCommandPrivileged("/usr/local/bin/jamf", arguments: args) + let output = try await ExecutionService.jamfRecon() if let rangeStart = output.range(of: ""), let rangeEnd = output.range(of: "", range: rangeStart.upperBound.. String { - // Find the enrollment profile by its com.apple.mdm payload rather than by name, since every MDM names it - // differently (Jamf "MDM Profile", Intune "Management Profile", Fleet "Fleet enrollment profile", …). - // xmllint keeps the output to the date alone: helpers built before ProcessRunner hang on output over ~64KB. - let installDate = #"(//dict[key[.='PayloadType']/following-sibling::*[1][.='com.apple.mdm']])[1]/../../key[.='ProfileInstallDate']/following-sibling::*[1]/text()"# - let command = #"/usr/bin/profiles -C -o stdout-xml | /usr/bin/xmllint --xpath "\#(installDate)" - 2>/dev/null || true"# - + // Reading the enrollment profile needs root, so the helper does it and returns just the date. do { - let commandOutput = try await ExecutionService.executeCommandPrivileged("/bin/bash", arguments: ["-c", command]) - if let range = commandOutput.range(of: #"\d{4}-\d{2}-\d{2}"#, options: .regularExpression) { - Logger.shared.logDebug("MDM enrollment profile installed \(commandOutput[range])") - return String(commandOutput[range]) + let installDate = try await ExecutionService.mdmEnrollmentDate() + if !installDate.isEmpty { + Logger.shared.logDebug("MDM enrollment profile installed \(installDate)") + return installDate } Logger.shared.logDebug("No MDM enrollment profile install date found") } catch { diff --git a/SupportCompanion/Helpers/NetworkInfoHelpers.swift b/SupportCompanion/Helpers/NetworkInfoHelpers.swift index 90d5932..0cdf741 100644 --- a/SupportCompanion/Helpers/NetworkInfoHelpers.swift +++ b/SupportCompanion/Helpers/NetworkInfoHelpers.swift @@ -43,24 +43,18 @@ func getSSID() async -> String? { guard let wifi = CWWiFiClient.shared().interface() else { return "WiFi Off" } guard wifi.powerOn() else { return "WiFi Off" } - _ = try? await ExecutionService.executeCommandPrivileged( - "/bin/sh", arguments: ["-c", "/usr/sbin/ipconfig setverbose 1"] - ) + _ = try? await ExecutionService.setIPConfigVerbose(true) do { let command = "/usr/sbin/ipconfig getsummary en0 | awk -F ' SSID : ' '/ SSID : / {print $2}'" let ssid = try await ExecutionService.executeCommand("/bin/sh", with: ["-c", command]) - _ = try? await ExecutionService.executeCommandPrivileged( - "/bin/sh", arguments: ["-c", "/usr/sbin/ipconfig setverbose 0"] - ) + _ = try? await ExecutionService.setIPConfigVerbose(false) let trimmed = ssid.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed == "" { return nil } return trimmed.isEmpty ? nil : trimmed } catch { Logger.shared.logError("Failed to fetch SSID: \(error)") - _ = try? await ExecutionService.executeCommandPrivileged( - "/bin/sh", arguments: ["-c", "/usr/sbin/ipconfig setverbose 0"] - ) + _ = try? await ExecutionService.setIPConfigVerbose(false) return nil } } diff --git a/SupportCompanion/Helpers/RebootHelpers.swift b/SupportCompanion/Helpers/RebootHelpers.swift index d82a3cf..4c5c02d 100644 --- a/SupportCompanion/Helpers/RebootHelpers.swift +++ b/SupportCompanion/Helpers/RebootHelpers.swift @@ -17,7 +17,7 @@ extension ActionHelpers { } do { - _ = try await ExecutionService.executeCommandPrivileged("shutdown", arguments: ["-r", "+1"]) + _ = try await ExecutionService.reboot() Logger.shared.logDebug("Reboot command executed") } catch { if (error as NSError).domain == NSCocoaErrorDomain && (error as NSError).code == NSUserCancelledError { @@ -32,7 +32,7 @@ extension ActionHelpers { static func cancelShutdown() { Task { - _ = try? await ExecutionService.executeCommandPrivileged("killall", arguments: ["shutdown"]) + _ = try? await ExecutionService.cancelReboot() Logger.shared.logDebug("Cancel reboot command executed") } } diff --git a/SupportCompanion/Scripts/Uninstall.zsh b/SupportCompanion/Scripts/Uninstall.zsh index 515c752..a0f640f 100755 --- a/SupportCompanion/Scripts/Uninstall.zsh +++ b/SupportCompanion/Scripts/Uninstall.zsh @@ -57,6 +57,39 @@ CONSOLE_UID=$(get_console_uid || true) log "Console user: ${CONSOLE_USER:-unknown} (uid ${CONSOLE_UID:-n/a})" +# --- hand back temporary administrator rights ------------------------------ +# Do this before anything is torn down. The helper is what takes elevated rights away when the +# timer runs out, so uninstalling mid-window would otherwise leave whoever was elevated as a +# permanent administrator, with nothing left on the Mac that knows the grant was temporary. +ELEVATION_STATE="/var/db/${APP_ID}/elevation.plist" + +if [ -f "$ELEVATION_STATE" ]; then + log "Found elevation state; demoting anyone still elevated" + + # Read the Elevations array one index at a time. Not via JSON: the deadlines are plist + # values, which plutil refuses to convert, so a json extraction of the whole array returns nothing. + elevated_users=() + index=0 + while true; do + entry=$(/usr/bin/plutil -extract "Elevations.${index}.UserName" raw -o - "$ELEVATION_STATE" 2>/dev/null) || break + [ -n "$entry" ] && elevated_users+=("$entry") + index=$((index + 1)) + done + + # Earlier builds wrote a single elevation at the top level + if [ ${#elevated_users[@]} -eq 0 ]; then + entry=$(/usr/bin/plutil -extract UserName raw -o - "$ELEVATION_STATE" 2>/dev/null) || entry="" + [ -n "$entry" ] && elevated_users+=("$entry") + fi + + for elevated_user in "${elevated_users[@]}"; do + log "Demoting $elevated_user" + _try "demote $elevated_user" /usr/sbin/dseditgroup -o edit -d "$elevated_user" -t user admin + done +else + log "no elevation state present: $ELEVATION_STATE" +fi + # --- stop processes -------------------------------------------------------- log "Stopping application and helper if running" _try "kill app" pkill -f SupportCompanion @@ -105,6 +138,11 @@ log "Removing installed components" _rm_if_exists "$APP_PATH" _rm_if_exists "$HELPER_PATH" +# The helper's state directory, including the root-owned elevation audit log. Anyone still elevated +# was demoted at the top of this script, so nothing here is needed any more. Keep a copy first if +# the elevation log is wanted for an audit trail — it is removed with everything else. +_rm_if_exists "/var/db/${APP_ID}" + # --- pkg receipts ------------------------------------------- forget_if_present() { local package="$1" diff --git a/SupportCompanion/Scripts/helper_install.zsh b/SupportCompanion/Scripts/helper_install.zsh index dd8ceb6..6977f79 100755 --- a/SupportCompanion/Scripts/helper_install.zsh +++ b/SupportCompanion/Scripts/helper_install.zsh @@ -10,13 +10,64 @@ privileged_helper_tool="/Library/PrivilegedHelperTools/com.github.macadmins.Supp install_location="/Applications/SupportCompanion.app" launch_daemon="com.github.macadmins.SupportCompanion.helper" +# An organisation can deploy the helper declaratively instead, with +# com.apple.configuration.services.background-tasks, which puts both the executable and its launchd +# job in a managed directory the OS re-asserts. Installing our own copy as well would leave two +# daemons claiming the same Mach service, so this skips the whole helper install. +# +# Read only from administrator-managed sources, the same two the app and the helper trust. The key +# must come from a DEVICE-scoped profile: a user-scoped one lands under a per-user directory that +# an installer script has no reliable way to resolve. +skip_helper_install=false +for domain in \ + "/Library/Managed Preferences/com.github.macadmins.SupportCompanion.plist" \ + "/Library/Preferences/com.github.macadmins.SupportCompanion.plist" +do + if [[ -f "$domain" ]]; then + value=$(/usr/bin/defaults read "${domain%.plist}" SkipHelperInstall 2>/dev/null) + # First source that defines the key wins, so a profile can override /Library/Preferences, + # which is how the app and the helper resolve the same settings. + if [[ -n "$value" ]]; then + [[ "$value" == "1" ]] && skip_helper_install=true + break + fi + fi +done + +if [[ "$skip_helper_install" == "true" ]]; then + echo "SkipHelperInstall is set; leaving the helper to the declarative configuration." + # The package payload writes this plist before any script runs, so it has to be removed rather + # than skipped, or it will compete with the declaratively deployed job. + /bin/rm -f "/Library/LaunchDaemons/${launch_daemon}.plist" + /bin/rm -f "${privileged_helper_tool}" + exit 0 +fi + # Create "/Library/PrivilegedHelperTools/" if not present if [[ ! -d "/Library/PrivilegedHelperTools/" ]]; then - mkdir "/Library/PrivilegedHelperTools/" + mkdir -p "/Library/PrivilegedHelperTools/" +fi + +# Stop the running helper before replacing it. +# +# The app and the helper speak a versioned XPC interface, and the helper refuses clients older than +# it supports. A new app left talking to an old helper is therefore broken rather than merely stale: +# every privileged operation fails. Unloading first means the load at the end starts the executable +# written below, and not whatever was already running. +if launchctl print "system/${launch_daemon}" &> /dev/null ; then + launchctl bootout "system/${launch_daemon}" &> /dev/null \ + || launchctl unload "/Library/LaunchDaemons/${launch_daemon}.plist" &> /dev/null +fi + +# Remove before copying: unlink always succeeds, while writing over a file another process still +# holds open is not guaranteed to on every filesystem. +/bin/rm -f "${privileged_helper_tool}" + +if ! cp "${install_location}/Contents/Library/LaunchDaemons/${launch_daemon}" "${privileged_helper_tool}" ; then + echo "Failed to copy the privileged helper into ${privileged_helper_tool}" >&2 + exit 1 fi -# Copy the PrivilegedHelperTool -cp "${install_location}/Contents/Library/LaunchDaemons/${launch_daemon}" "/Library/PrivilegedHelperTools/" # Set permissions chown root:wheel "${privileged_helper_tool}" chmod 544 "${privileged_helper_tool}" @@ -24,14 +75,13 @@ chmod 544 "${privileged_helper_tool}" chown root:wheel "/Library/LaunchDaemons/${launch_daemon}.plist" chmod 644 "/Library/LaunchDaemons/${launch_daemon}.plist" -# Unload the LaunchDaemon -if launchctl print "system/${launch_daemon}" &> /dev/null ; then - launchctl unload "/Library/LaunchDaemons/${launch_daemon}.plist" -fi +# Load unconditionally rather than only when it looks unloaded. Skipping this because the job still +# appears registered is how an install quietly finishes with the previous helper still running. +launchctl load -w "/Library/LaunchDaemons/${launch_daemon}.plist" &> /dev/null -# Load the LaunchDaemon if ! launchctl print "system/${launch_daemon}" &> /dev/null ; then - launchctl load -w "/Library/LaunchDaemons/${launch_daemon}.plist" + echo "The privileged helper did not load after installation" >&2 + exit 1 fi exit 0 diff --git a/SupportCompanion/Services/ExecutionService.swift b/SupportCompanion/Services/ExecutionService.swift index 4433aa7..3caeb52 100644 --- a/SupportCompanion/Services/ExecutionService.swift +++ b/SupportCompanion/Services/ExecutionService.swift @@ -9,26 +9,24 @@ import Foundation // MARK: - ExecutionService -/// Execute a script. +/// Runs commands, either here as the logged-in user or, for the privileged operations, by asking the +/// helper to perform a named one. +/// +/// The helper no longer takes a command to run. Each privileged call below names an operation the helper +/// implements, and the helper decides what that operation executes and whether policy allows it, so code +/// running in this process cannot turn the helper into a way to run something as root. enum ExecutionService { - // MARK: Execute Script - static func executeScript(at path: String) async throws -> String { - try await HelperRemoteProvider.remote().executeScript(at: path) - } + // MARK: Unprivileged - // MARK: Execute Command - /// Execute a command with arguments. - static func executeCommandPrivileged(_ command: String, arguments: [String]) async throws -> String { - try await HelperRemoteProvider.remote().executeCommand(command, with: arguments) - } - + /// Execute a command with arguments as the logged-in user. static func executeCommand(_ command: String, with arguments: [String] = []) async throws -> String { Logger.shared.logDebug("Executing command \(command) with arguments \(arguments))") return try await ProcessRunner.runCommand(command, with: arguments) } - - static func executeShellCommand(_ rawCommand: String, isPrivileged: Bool? = false) async throws -> String { + + /// Execute a shell command as the logged-in user. + static func executeShellCommand(_ rawCommand: String) async throws -> String { guard !rawCommand.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { Logger.shared.logDebug("Command must not be null or whitespace") throw NSError(domain: "ExecutionServiceError", code: 1, userInfo: [ @@ -38,15 +36,83 @@ enum ExecutionService { // The command is passed to /bin/sh as a single argument, so it must not be escaped. // Escaping quotes here would corrupt any command that contains them. - let arguments = ["-c", rawCommand] - - // check if privileged execution is requested and execute accordingly - if let isPrivileged, isPrivileged { - return try await executeCommandPrivileged("/bin/sh", arguments: arguments) + return try await executeCommand("/bin/sh", with: ["-c", rawCommand]) + } + + // MARK: Actions + + /// Run an administrator-defined action. + /// + /// A privileged action is only named to the helper; the helper looks the command up in the + /// administrator-managed preferences itself, so what runs as root is never taken from this process. + static func runAction(_ action: Action) async throws -> String { + if action.isPrivileged ?? false { + return try await HelperRemoteProvider.remote().runPrivilegedAction(named: action.name) } - - // Execute using the existing method - return try await executeCommand("/bin/sh", with: arguments) + + return try await executeShellCommand(action.command) + } + + // MARK: Elevation + + static func elevate(reason: String) async throws -> String { + try await HelperRemoteProvider.remote().elevate(reason: reason) + } + + static func demote() async throws -> String { + try await HelperRemoteProvider.remote().demote() + } + + /// Seconds until the helper takes administrator rights back, or 0 when no elevation is active. + static func elevationTimeRemaining() async throws -> Double { + try await HelperRemoteProvider.remote().elevationTimeRemaining() + } + + // MARK: Jamf + + static func jamfPatch(id: String) async throws -> String { + try await HelperRemoteProvider.remote().jamfPatch(id: id) + } + + static func jamfSelfServicePatch(id: String, userId: String?) async throws -> String { + try await HelperRemoteProvider.remote().jamfSelfServicePatch(id: id, userId: userId) + } + + static func jamfRecon() async throws -> String { + try await HelperRemoteProvider.remote().jamfRecon() + } + + static func jamfLog(kind: JamfLogKind, hours: Int) async throws -> String { + try await HelperRemoteProvider.remote().jamfLog(kind: kind.rawValue, hours: hours) + } + + /// The fixed Jamf log queries the helper knows how to run. + enum JamfLogKind: String { + case checkIn + case inventory + } + + // MARK: Device management + + static func restartIntuneAgent() async throws -> String { + try await HelperRemoteProvider.remote().restartIntuneAgent() + } + + static func mdmEnrollmentDate() async throws -> String { + try await HelperRemoteProvider.remote().mdmEnrollmentDate() } + // MARK: System + + static func reboot() async throws -> String { + try await HelperRemoteProvider.remote().reboot() + } + + static func cancelReboot() async throws -> String { + try await HelperRemoteProvider.remote().cancelReboot() + } + + static func setIPConfigVerbose(_ enabled: Bool) async throws -> String { + try await HelperRemoteProvider.remote().setIPConfigVerbose(enabled) + } } diff --git a/SupportCompanion/Services/HelperRemoteProvider.swift b/SupportCompanion/Services/HelperRemoteProvider.swift index a8a4dfd..bb1a076 100644 --- a/SupportCompanion/Services/HelperRemoteProvider.swift +++ b/SupportCompanion/Services/HelperRemoteProvider.swift @@ -16,7 +16,14 @@ enum HelperRemoteProvider { // MARK: Computed - private static var isHelperInstalled: Bool { FileManager.default.fileExists(atPath: HelperConstants.helperPath) } + /// Whether the copy of the helper that *this package* installs is present. + /// + /// Not the same question as "is a helper available". When the helper is deployed declaratively + /// with `com.apple.configuration.services.background-tasks`, it lives in a managed directory named + /// after the administrator's chosen `TaskType`, which we cannot know and must not guess at. So this + /// only ever gates self-installation: if our own copy is not here, something else is responsible + /// for the helper and registering a second one would fight it. + private static var isPackagedHelperInstalled: Bool { FileManager.default.fileExists(atPath: HelperConstants.helperPath) } // MARK: Exported app proxy for XPC (optional but safer than exporting the enum type) private final class RemoteAppProxy: NSObject, RemoteApplicationProtocol {} @@ -83,9 +90,15 @@ extension HelperRemoteProvider { extension HelperRemoteProvider { static private func connection() throws -> NSXPCConnection { - if !isHelperInstalled { + // When the helper is deployed declaratively it answers on the same Mach service from a managed + // directory, and our file is legitimately absent. Registering the bundled copy then would put a + // second daemon on the same Mach service, so the administrator says which deployment is in use. + let deployedElsewhere = TrustedPreferences.bool(forKey: "SkipHelperInstall", default: false) + + if !isPackagedHelperInstalled && !deployedElsewhere { try installHelper() } + return createConnection() } @@ -96,10 +109,10 @@ extension HelperRemoteProvider { connection.exportedObject = exportedAppProxy connection.invalidationHandler = { - if isHelperInstalled { + if isPackagedHelperInstalled { Logger.shared.logError("Unable to connect to Helper although it is installed") } else { - Logger.shared.logError("Helper is not installed") + Logger.shared.logError("Unable to connect to Helper. It is not installed by the package; if it is deployed declaratively, check that the declaration has been applied") } } diff --git a/SupportCompanion/ViewModels/CardGridViewModel.swift b/SupportCompanion/ViewModels/CardGridViewModel.swift index 2cc5362..e31106b 100644 --- a/SupportCompanion/ViewModels/CardGridViewModel.swift +++ b/SupportCompanion/ViewModels/CardGridViewModel.swift @@ -198,10 +198,7 @@ class CardGridViewModel { // Kick off the update; ideally obtain a process handle or PID do { - _ = try await ExecutionService.executeCommandPrivileged( - "/usr/local/bin/jamf", - arguments: ["patch", "-id", forId] - ) + _ = try await ExecutionService.jamfPatch(id: forId) } catch { // Log and bail Logger.shared.logError("jamf patch launch failed: \(error)") diff --git a/SupportCompanion/ViewModels/ElevationManager.swift b/SupportCompanion/ViewModels/ElevationManager.swift index 78b932e..3760b98 100644 --- a/SupportCompanion/ViewModels/ElevationManager.swift +++ b/SupportCompanion/ViewModels/ElevationManager.swift @@ -2,6 +2,12 @@ import Foundation import Combine import SwiftUI +/// Drives the elevation UI. +/// +/// The helper owns temporary administrator rights: it decides whether elevation is allowed, records the +/// deadline in a root-owned file, and takes the rights back when the time is up. The timer here only +/// drives the countdown and the halfway notification, so quitting the app no longer leaves the user an +/// administrator — it just stops the display. @MainActor class ElevationManager { private var elevationReason = "" @@ -16,18 +22,17 @@ class ElevationManager { self.appState = appState } - func elevatePrivileges(completion: @escaping (Bool) -> Void) { - authenticateWithTouchIDOrPassword(completion: { success in + func elevatePrivileges(reason: String, completion: @escaping (Bool) -> Void) { + authenticateWithTouchIDOrPassword(completion: { success in guard success else { completion(false) return } - let command = "/usr/sbin/dseditgroup" - let arguments = ["-o", "edit", "-a", NSUserName(), "-t", "user", "admin"] Task { do { - _ = try await ExecutionService.executeCommandPrivileged(command, arguments: arguments) - // Update isAdmin status + // The helper re-checks EnableElevation before doing anything, and throws if an + // administrator has not turned elevation on. + _ = try await ExecutionService.elevate(reason: reason) UserInfoManager.shared.updateUserInfo() completion(true) } @@ -40,14 +45,11 @@ class ElevationManager { } func demotePrivileges(completion: @escaping (Bool) -> Void) { - let command = "/usr/sbin/dseditgroup" - let arguments = ["-o", "edit", "-d", NSUserName(), "-t", "user", "admin"] Task { do { - _ = try await ExecutionService.executeCommandPrivileged(command, arguments: arguments) + _ = try await ExecutionService.demote() // Update isAdmin status UserInfoManager.shared.updateUserInfo() - UserDefaults.standard.removeObject(forKey: "PrivilegeDemotionEndTime") completion(true) } catch { @@ -57,12 +59,20 @@ class ElevationManager { } } + /// Seconds until the helper demotes the user, as the helper sees it. + func remainingElevationTime() async -> TimeInterval { + do { + return try await ExecutionService.elevationTimeRemaining() + } catch { + Logger.shared.logError("Failed to read elevation time remaining: \(error.localizedDescription)") + return 0 + } + } + func startDemotionTimer(duration: TimeInterval, onUpdate: @escaping (Double) -> Void) { Logger.shared.logDebug("Starting demotion timer with duration: \(duration)") stopDemotionTimer() // Ensure any existing timer is stopped - persistDemotionState(endTime: Date().addingTimeInterval(duration)) - NotificationService(appState: self.appState).sendNotification( message: "\(Constants.Notifications.Elevation.ElevationStartedMessage) \(duration.formattedTimeUnit()).", notificationType: .generic @@ -96,17 +106,17 @@ class ElevationManager { self.onTimeUpdate?(remainingTime) } else { self.stopDemotionTimer() - self.demotePrivileges { success in - guard success else { - Logger.shared.logDebug("Failed to demote privileges.") - return - } + + // The helper demotes on its own schedule; catch up with what it did rather than + // asking for a second demotion. + Task { @MainActor in + UserInfoManager.shared.updateUserInfo() NotificationService(appState: self.appState).sendNotification( message: Constants.Notifications.Elevation.ElevationDemotedMessage, notificationType: .generic ) } - Logger.shared.logDebug("Demotion timer expired. Privileges demoted.") + Logger.shared.logDebug("Demotion timer expired.") } } } @@ -121,14 +131,14 @@ class ElevationManager { func handleElevation(reason: String) { Logger.shared.logDebug("Handling elevation for reason: \(reason)") // Authenticate and elevate privileges - self.elevatePrivileges { success in + self.elevatePrivileges(reason: reason) { success in Task { @MainActor [weak self] in guard let self else { return } guard success else { - Logger.shared.logDebug("Authentication failed. Unable to elevate privileges.") + Logger.shared.logDebug("Authentication failed or elevation was refused.") return } - Logger.shared.logDebug("Authentication successful. Privileges elevated.") + Logger.shared.logDebug("Privileges elevated.") if self.appState.preferences.elevation.requireReasonForElevation { if !self.appState.preferences.elevation.elevationWebhookURL.isEmpty { sendReasonToWebhook(reason: reason) @@ -136,18 +146,10 @@ class ElevationManager { saveReasonToDisk(reason: reason) } } - let duration = Double(self.appState.preferences.elevation.maxElevationTime * 60) + // Count down from what the helper actually granted, not from what we asked for + let duration = await self.remainingElevationTime() self.appState.startDemotionTimer(duration: duration) } } } - - func persistDemotionState(endTime: Date) { - UserDefaults.standard.set(endTime, forKey: "PrivilegeDemotionEndTime") - UserDefaults.standard.synchronize() - } - - func loadPersistedDemotionState() -> Date? { - return UserDefaults.standard.object(forKey: "PrivilegeDemotionEndTime") as? Date - } } diff --git a/SupportCompanion/ViewModels/PendingJamfUpdatesManager.swift b/SupportCompanion/ViewModels/PendingJamfUpdatesManager.swift index 3866991..4f97ff3 100644 --- a/SupportCompanion/ViewModels/PendingJamfUpdatesManager.swift +++ b/SupportCompanion/ViewModels/PendingJamfUpdatesManager.swift @@ -112,14 +112,10 @@ class PendingJamfUpdatesManager: PendingUpdatesManager { markRunning(patchId) defer { markFinished(patchId) } - // The app runs as the logged-in user, so its UID is the user to run the patch as - var args: [String] = ["asuser", String(getuid()), "/usr/local/bin/jamf", "patch", "-id", String(patchId), "-showSteps", "-selfServiceOnly"] - if let userId = userId, !userId.isEmpty { - args += ["-user", userId] - } - do { - _ = try await ExecutionService.executeCommandPrivileged("/bin/launchctl", arguments: args) + // The helper runs the patch as whichever user connected to it, which it reads from the + // audit token rather than taking our word for it. + _ = try await ExecutionService.jamfSelfServicePatch(id: String(patchId), userId: userId) } catch { Logger.shared.logError("Failed to run patch \(patchId): \(error)") } diff --git a/SupportCompanion/Views/ScCardCompactButton.swift b/SupportCompanion/Views/ScCardCompactButton.swift index a959084..a08a709 100644 --- a/SupportCompanion/Views/ScCardCompactButton.swift +++ b/SupportCompanion/Views/ScCardCompactButton.swift @@ -55,7 +55,7 @@ struct ScCardCompactButton: View { if let buttonAction = buttonAction { Task { isRunning = true // Set running state to true - _ = try await ExecutionService.executeShellCommand(buttonAction.command, isPrivileged: buttonAction.isPrivileged) + _ = try await ExecutionService.runAction(buttonAction) isRunning = false // Reset running state } } diff --git a/SupportCompanion/Views/SelfService.swift b/SupportCompanion/Views/SelfService.swift index 66c66cd..688cacc 100644 --- a/SupportCompanion/Views/SelfService.swift +++ b/SupportCompanion/Views/SelfService.swift @@ -53,7 +53,7 @@ struct SelfService: View { ScButton(action.buttonLabel ?? "Run", maxWidth: 150) { do { - _ = try await ExecutionService.executeShellCommand(action.command, isPrivileged: action.isPrivileged) + _ = try await ExecutionService.runAction(action) } catch { Logger.shared.logError("Self Service action '\(action.command)' failed: \(error)") } diff --git a/SupportCompanion/pkgbuild/payload/Library/LaunchDaemons/com.github.macadmins.SupportCompanion.helper.plist b/SupportCompanion/pkgbuild/payload/Library/LaunchDaemons/com.github.macadmins.SupportCompanion.helper.plist index 1acb8bc..dff8547 100644 --- a/SupportCompanion/pkgbuild/payload/Library/LaunchDaemons/com.github.macadmins.SupportCompanion.helper.plist +++ b/SupportCompanion/pkgbuild/payload/Library/LaunchDaemons/com.github.macadmins.SupportCompanion.helper.plist @@ -15,6 +15,8 @@ RunAtLoad + KeepAlive + Disabled diff --git a/SupportCompanion/pkgbuild/scripts/postinstall b/SupportCompanion/pkgbuild/scripts/postinstall index 64f4fb9..792421e 100755 --- a/SupportCompanion/pkgbuild/scripts/postinstall +++ b/SupportCompanion/pkgbuild/scripts/postinstall @@ -3,13 +3,17 @@ app_path="/Applications/SupportCompanion.app" process_name="SupportCompanion" launcha_name="com.github.macadmins.SupportCompanion.agent.plist" -launch_daemon_path="/Library/LaunchDaemons/${launchd_name}" launch_agent_path="/Library/LaunchAgents/${launcha_name}" console_user=$(/usr/bin/stat -f "%Su" /dev/console) console_user_uid=$(/usr/bin/id -u "$console_user") # install helper +# +# The result is remembered rather than acted on immediately: the app should still be started and the +# launch agent reloaded either way. But a failure here leaves the new app talking to an old helper, +# which breaks every privileged operation, so the install must not report success. "$app_path/Contents/Resources/helper_install.zsh" +helper_install_status=$? # kill the process if it's running if [[ -n $( ps -ef | grep "${process_name}" | grep -v grep ) ]]; then @@ -37,4 +41,9 @@ else open -g -a "${app_path}" fi +if [[ "$helper_install_status" -ne 0 ]]; then + echo "Privileged helper installation failed; reporting the package install as failed." >&2 + exit "$helper_install_status" +fi + exit 0 diff --git a/SupportCompanion/pkgbuild/scripts/preinstall b/SupportCompanion/pkgbuild/scripts/preinstall index a7b5fd9..e45bed0 100755 --- a/SupportCompanion/pkgbuild/scripts/preinstall +++ b/SupportCompanion/pkgbuild/scripts/preinstall @@ -7,7 +7,19 @@ if [[ -d "$v1AppPath" ]]; then # Check if the uninstall script exists uninstallScript="$v1AppPath/Contents/Resources/Uninstall.sh" if [[ -f "$uninstallScript" ]]; then - "$uninstallScript" + # This runs as root on every install, so only run it if it is still the script the 1.x + # installer left there: owned by root, and not writable by group or others. A file failing + # either test was put there by somebody else, and running it would hand them root. + owner=$(/usr/bin/stat -f '%u' "$uninstallScript") + mode=$(/usr/bin/stat -f '%Lp' "$uninstallScript") + + if [[ "$owner" != "0" ]]; then + echo "Not running $uninstallScript: not owned by root." + elif (( 8#$mode & 8#022 )); then + echo "Not running $uninstallScript: writable by group or others." + else + "$uninstallScript" + fi else echo "Uninstall script not found at $uninstallScript." fi From 4e997bdcaa18b6b79579cb382d20f1c6907305c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Alm=C3=A9n?= <78877636+almenscorner@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:49:14 +0200 Subject: [PATCH 089/104] Group the Fleet app card's version rows with the title The installed and available versions sat in their own stack below the icon row, which left them detached from the title they describe and put a gap between the two blocks. They now sit under the title inside the same row, with the icon centred against the group. --- .../Views/Fleet/FleetAppCard.swift | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/SupportCompanion/Views/Fleet/FleetAppCard.swift b/SupportCompanion/Views/Fleet/FleetAppCard.swift index 8199ebc..2914db0 100644 --- a/SupportCompanion/Views/Fleet/FleetAppCard.swift +++ b/SupportCompanion/Views/Fleet/FleetAppCard.swift @@ -20,27 +20,24 @@ struct FleetAppCard: View { var body: some View { VStack(alignment: .leading, spacing: 12) { - HStack(alignment: .top, spacing: 12) { + HStack(alignment: .center, spacing: 12) { iconView - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: 3) { Text(title.title) .font(.system(size: 16, weight: .semibold)) .lineLimit(2) statusBadge + if let installed = installedVersion { + versionRow(Constants.Fleet.version, installed) + } + if let available = title.availableVersion, available != installedVersion { + versionRow(Constants.Fleet.latestVersion, available) + } } + .font(.system(size: 13)) Spacer(minLength: 0) } - VStack(alignment: .leading, spacing: 2) { - if let installed = installedVersion { - versionRow(Constants.Fleet.version, installed) - } - if let available = title.availableVersion, available != installedVersion { - versionRow(Constants.Fleet.latestVersion, available) - } - } - .font(.system(size: 13)) - actions } .padding(14) From 907f87808d981b299dd347231f0e6b46cc412253 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Alm=C3=A9n?= <78877636+almenscorner@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:49:14 +0200 Subject: [PATCH 090/104] Add the English strings for the Fleet pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracted by Xcode for strings the Fleet compliance and app catalog work already referenced. 81 new keys, English only — the Japanese, Norwegian, Swedish, German and French localizations do not cover them yet. --- SupportCompanion/Localizable.xcstrings | 1130 ++++++++++++++++++++++-- 1 file changed, 1049 insertions(+), 81 deletions(-) diff --git a/SupportCompanion/Localizable.xcstrings b/SupportCompanion/Localizable.xcstrings index 777242f..23bcaf5 100644 --- a/SupportCompanion/Localizable.xcstrings +++ b/SupportCompanion/Localizable.xcstrings @@ -3,6 +3,16 @@ "strings" : { "%" : { + }, + "%@ (%lld)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ (%2$lld)" + } + } + } }, "%@ %@" : { "localizations" : { @@ -16,6 +26,16 @@ }, "%@:" : { + }, + "%@: %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@: %2$@" + } + } + } }, "%@%%" : { @@ -299,6 +319,42 @@ } } }, + "Action.Refetch" : { + "comment" : "Button that asks Fleet to re-check this Mac", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Re-check" + } + } + } + }, + "Action.RefetchHelp" : { + "comment" : "Help text for the re-check button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Ask Fleet to update this Mac's details and re-run its compliance checks." + } + } + } + }, + "Action.Refetching" : { + "comment" : "Shown while Fleet re-checks this Mac", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Re-checking…" + } + } + } + }, "Action.RestartIntuneAgent" : { "comment" : "Label for restart Intune agent action", "localizations" : { @@ -995,6 +1051,18 @@ } } }, + "Card.FleetPoliciesTitle" : { + "comment" : "Title for the card listing failing Fleet policies", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Device Compliance" + } + } + } + }, "Card.InstalledAppsTitle" : { "comment" : "Title for installed apps card", "extractionState" : "extracted_with_value", @@ -1992,148 +2060,1036 @@ } } }, - "GatherLogs.Info" : { - "comment" : "Info message for gathering logs", + "Fleet.AllCategories" : { + "comment" : "Category filter showing every app", "extractionState" : "extracted_with_value", "localizations" : { - "de" : { + "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Protokollsammlung wurde abgebrochen" + "state" : "new", + "value" : "All" } - }, + } + } + }, + "Fleet.AppClosedMessage" : { + "comment" : "Card message after the app that blocked an install was closed; %@ is the app name", + "extractionState" : "extracted_with_value", + "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Gather logs was cancelled" + "value" : "%@ was open, so it wasn't changed. Try again now that it's closed." } - }, - "fr" : { + } + } + }, + "Fleet.AppOpenMessage" : { + "comment" : "Card message when an install needs the app closed; %@ is the app name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { "stringUnit" : { - "state" : "translated", - "value" : "La collecte des journaux a été annulée" + "state" : "new", + "value" : "%@ is open. Quit it to finish." } - }, - "ja" : { + } + } + }, + "Fleet.AppOpenNotification" : { + "comment" : "Notification when an install needs the app closed; %@ is the app name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { "stringUnit" : { - "state" : "translated", - "value" : "ログ収集がキャンセルされました" + "state" : "new", + "value" : "Quit %@ to finish updating it." } - }, - "nb" : { + } + } + }, + "Fleet.Available" : { + "comment" : "Section of apps that can be installed", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Samling av logger ble avbrutt" + "state" : "new", + "value" : "Available" } - }, - "sv" : { + } + } + }, + "Fleet.Cancel" : { + "comment" : "Cancel button", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Loggsparning avbruten" + "state" : "new", + "value" : "Cancel" } } } }, - "GatherLogs.Success" : { - "extractionState" : "manual", + "Fleet.ClearSearch" : { + "comment" : "Button that empties the app catalog search field", + "extractionState" : "extracted_with_value", "localizations" : { - "de" : { + "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Protokolle erfolgreich gesammelt" + "state" : "new", + "value" : "Clear search" } - }, + } + } + }, + "Fleet.Close" : { + "comment" : "Button that closes the install details", + "extractionState" : "extracted_with_value", + "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Successfully gathered logs" + "state" : "new", + "value" : "Close" } - }, - "fr" : { + } + } + }, + "Fleet.ComplianceDetails" : { + "comment" : "Button that opens Support Companion to show compliance details", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Journaux collectés avec succès" + "state" : "new", + "value" : "Details" } - }, - "ja" : { + } + } + }, + "Fleet.CouldNotLoad" : { + "comment" : "Title when the Fleet catalog failed to load", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { "stringUnit" : { - "state" : "translated", - "value" : "ログ収集が成功しました" + "state" : "new", + "value" : "Couldn't load apps" } - }, - "nb" : { + } + } + }, + "Fleet.CouldNotLoadDetails" : { + "comment" : "Shown when install output couldn't be fetched", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Logger samlet inn med suksess" + "state" : "new", + "value" : "Couldn't load details" } - }, - "sv" : { + } + } + }, + "Fleet.CouldNotQuit" : { + "comment" : "Shown when an app didn't quit; %@ is the app name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Loggar sparade" + "state" : "new", + "value" : "%@ didn't quit. Save your work, quit it, and try again." } } } }, - "General.Ago" : { - "comment" : "Ago", + "Fleet.CouldNotRefresh" : { + "comment" : "Shown next to a catalog that failed to refresh", "extractionState" : "extracted_with_value", "localizations" : { - "de" : { + "en" : { "stringUnit" : { - "state" : "translated", - "value" : "tagen" + "state" : "new", + "value" : "Couldn't refresh" } - }, + } + } + }, + "Fleet.Critical" : { + "comment" : "Badge for a critical Fleet policy", + "extractionState" : "extracted_with_value", + "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Ago" + "value" : "Critical" } - }, - "fr" : { + } + } + }, + "Fleet.Details" : { + "comment" : "Button that shows the output of a failed install or uninstall", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { "stringUnit" : { - "state" : "translated", - "value" : "avant" + "state" : "new", + "value" : "Details" } - }, - "ja" : { + } + } + }, + "Fleet.HowToFix" : { + "comment" : "Disclosure that shows how to resolve a failing policy", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { "stringUnit" : { - "state" : "translated", - "value" : "前" + "state" : "new", + "value" : "How to fix" } - }, - "nb" : { + } + } + }, + "Fleet.Install" : { + "comment" : "Button that installs an app from Fleet", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { "stringUnit" : { - "state" : "translated", - "value" : "siden" + "state" : "new", + "value" : "Install" } - }, - "sv" : { + } + } + }, + "Fleet.InstallDetailsTitle" : { + "comment" : "Title of the sheet showing install output", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { "stringUnit" : { - "state" : "translated", - "value" : "sedan" + "state" : "new", + "value" : "Install details" } } } }, - "General.Close" : { - "comment" : "Close", - "extractionState" : "manual", + "Fleet.Installed" : { + "comment" : "Section of installed apps, and the installed status badge", + "extractionState" : "extracted_with_value", "localizations" : { - "de" : { + "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Schließen" + "state" : "new", + "value" : "Installed" } - }, + } + } + }, + "Fleet.InstalledNotification" : { + "comment" : "Notification after an install; %@ is the app name", + "extractionState" : "extracted_with_value", + "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Close" + "state" : "new", + "value" : "%@ was installed." } - }, - "fr" : { + } + } + }, + "Fleet.InstallFailed" : { + "comment" : "Status badge when an install failed", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Install failed" + } + } + } + }, + "Fleet.InstallFailedNotification" : { + "comment" : "Notification after a failed install or update; %@ is the app name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%@ couldn't be installed." + } + } + } + }, + "Fleet.Installing" : { + "comment" : "Status badge while an app installs", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Installing…" + } + } + } + }, + "Fleet.LastChecked" : { + "comment" : "When compliance last ran; %@ is a relative time such as '5 minutes ago'", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Checked %@" + } + } + } + }, + "Fleet.LatestVersion" : { + "comment" : "Label for the version available from Fleet", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Latest" + } + } + } + }, + "Fleet.Loading" : { + "comment" : "Shown while the Fleet catalog loads", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Loading apps…" + } + } + } + }, + "Fleet.MoreActions" : { + "comment" : "Help text for the menu with extra actions for an app", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "More actions" + } + } + } + }, + "Fleet.NoApps" : { + "comment" : "Shown when the Fleet catalog is empty", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No apps are available for this Mac." + } + } + } + }, + "Fleet.NoMatches" : { + "comment" : "Shown when search or category filters hide every app", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No apps match your search." + } + } + } + }, + "Fleet.NoOutput" : { + "comment" : "Shown when a failed install has no output", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Fleet didn't report any output." + } + } + } + }, + "Fleet.NoPolicies" : { + "comment" : "Compliance card without policies", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "There are no compliance checks for this Mac." + } + } + } + }, + "Fleet.NotConfigured" : { + "comment" : "Shown when orbit or the Fleet server URL is missing", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Fleet isn't set up on this Mac." + } + } + } + }, + "Fleet.PassingChecks" : { + "comment" : "Heading above the list of passing Fleet policies; %d is the count", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Passing checks (%d)" + } + } + } + }, + "Fleet.PoliciesFailing" : { + "comment" : "Compliance card summary; the numbers are failing and total checks", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d of %2$d checks need attention" + } + } + } + }, + "Fleet.PoliciesFailingNotification" : { + "comment" : "Notification when several policies start failing; %d is the count, %@ a policy name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Your Mac needs attention: %1$d checks are failing, including %2$@." + } + } + } + }, + "Fleet.PoliciesPassing" : { + "comment" : "Compliance card when every policy passes; %d is the number of checks", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "All %d checks are passing." + } + } + } + }, + "Fleet.PoliciesSignIn" : { + "comment" : "Compliance card when Fleet requires single sign-on", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Sign in on the Apps page to see compliance checks." + } + } + } + }, + "Fleet.PoliciesUnavailable" : { + "comment" : "Compliance card when policies couldn't be loaded", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Couldn't load compliance checks." + } + } + } + }, + "Fleet.PolicyFailingNotification" : { + "comment" : "Notification when a policy starts failing; %@ is the policy name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Your Mac needs attention: %@" + } + } + } + }, + "Fleet.QuitAndInstall" : { + "comment" : "Button that quits an app and installs it again", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Quit & Install" + } + } + } + }, + "Fleet.QuitAndUpdate" : { + "comment" : "Button that quits an app and installs its update", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Quit & Update" + } + } + } + }, + "Fleet.Recommended" : { + "comment" : "Section of apps IT recommends installing", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Recommended" + } + } + } + }, + "Fleet.Reinstall" : { + "comment" : "Button that installs an installed app again through Fleet", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Reinstall" + } + } + } + }, + "Fleet.ReinstalledNotification" : { + "comment" : "Notification after a reinstall; %@ is the app name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%@ was reinstalled." + } + } + } + }, + "Fleet.Retry" : { + "comment" : "Button to reload the Fleet catalog", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Try Again" + } + } + } + }, + "Fleet.SearchPlaceholder" : { + "comment" : "Placeholder for the Fleet app catalog search field", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Search apps" + } + } + } + }, + "Fleet.SignIn" : { + "comment" : "Button that starts Fleet single sign-on", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Sign In" + } + } + } + }, + "Fleet.SignInRequired" : { + "comment" : "Shown when Fleet requires single sign-on", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Sign in to see the apps available for this Mac." + } + } + } + }, + "Fleet.Uninstall" : { + "comment" : "Button that uninstalls an app through Fleet", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Uninstall" + } + } + } + }, + "Fleet.UninstallConfirmMessage" : { + "comment" : "Message asking to confirm an uninstall", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The app will be removed from this Mac." + } + } + } + }, + "Fleet.UninstallConfirmTitle" : { + "comment" : "Title asking to confirm an uninstall; %@ is the app name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Uninstall %@?" + } + } + } + }, + "Fleet.UninstallDetailsTitle" : { + "comment" : "Title of the sheet showing uninstall output", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Uninstall details" + } + } + } + }, + "Fleet.UninstalledNotification" : { + "comment" : "Notification after an uninstall; %@ is the app name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%@ was uninstalled." + } + } + } + }, + "Fleet.UninstallFailed" : { + "comment" : "Status badge when an uninstall failed", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Uninstall failed" + } + } + } + }, + "Fleet.UninstallFailedNotification" : { + "comment" : "Notification after a failed uninstall; %@ is the app name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%@ couldn't be uninstalled." + } + } + } + }, + "Fleet.Uninstalling" : { + "comment" : "Status badge while an app uninstalls", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Uninstalling…" + } + } + } + }, + "Fleet.Update" : { + "comment" : "Button that updates an app from Fleet", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Update" + } + } + } + }, + "Fleet.UpdateAvailable" : { + "comment" : "Status badge when a newer version is available", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Update available" + } + } + } + }, + "Fleet.UpdatedNotification" : { + "comment" : "Notification after an update; %@ is the app name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%@ was updated." + } + } + } + }, + "Fleet.UpdatesAvailable" : { + "comment" : "Section of apps with a newer version available", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Updates Available" + } + } + } + }, + "Fleet.Version" : { + "comment" : "Label for an app's installed version", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Version" + } + } + } + }, + "Fleet.ViewApps" : { + "comment" : "Button that shows the Apps page in Support Companion", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "View Apps" + } + } + } + }, + "Fleet.ViewDetails" : { + "comment" : "Notification button that opens Support Companion", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "View Details" + } + } + } + }, + "Fleet.ViewUpdates" : { + "comment" : "Button that shows available app updates in Support Companion", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "View Updates" + } + } + } + }, + "Fleet.WaitingForAppToClose" : { + "comment" : "Status badge when an install didn't run because the app was open", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Waiting for app to close" + } + } + } + }, + "FleetInfo.ID" : { + "comment" : "Label for the Fleet host ID", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Host ID:" + } + } + } + }, + "FleetInfo.LastInventory" : { + "comment" : "Label for when Fleet last updated this Mac's details", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Inventory:" + } + } + } + }, + "FleetInfo.LastSeen" : { + "comment" : "Label for when Fleet last heard from this Mac", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Check-In:" + } + } + } + }, + "FleetInfo.Never" : { + "comment" : "Shown when Fleet hasn't recorded an event yet", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Never" + } + } + } + }, + "FleetInfo.NoTeam" : { + "comment" : "Shown when the Mac isn't in a fleet", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No fleet" + } + } + } + }, + "FleetInfo.RefetchFailed" : { + "comment" : "Shown when a re-check request failed", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Couldn't ask Fleet to re-check this Mac." + } + } + } + }, + "FleetInfo.Team" : { + "comment" : "Label for the fleet name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Fleet:" + } + } + } + }, + "FleetInfo.Unknown" : { + "comment" : "Shown before Fleet details load", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Unknown" + } + } + } + }, + "FleetInfo.URL" : { + "comment" : "Label for the Fleet server", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "URL:" + } + } + } + }, + "GatherLogs.Info" : { + "comment" : "Info message for gathering logs", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Protokollsammlung wurde abgebrochen" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Gather logs was cancelled" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "La collecte des journaux a été annulée" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ログ収集がキャンセルされました" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Samling av logger ble avbrutt" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loggsparning avbruten" + } + } + } + }, + "GatherLogs.Success" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Protokolle erfolgreich gesammelt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Successfully gathered logs" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Journaux collectés avec succès" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ログ収集が成功しました" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logger samlet inn med suksess" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loggar sparade" + } + } + } + }, + "General.Ago" : { + "comment" : "Ago", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "tagen" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Ago" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "avant" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "前" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "siden" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "sedan" + } + } + } + }, + "General.Close" : { + "comment" : "Close", + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schließen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Close" + } + }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Fermer" @@ -3253,6 +4209,18 @@ } } }, + "Nav.Compliance" : { + "comment" : "Label for compliance navigation", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Compliance" + } + } + } + }, "Nav.Home" : { "comment" : "Nav label for Home", "extractionState" : "manual", From 1b8483dca35620587560da978cc3949e648d6b0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Alm=C3=A9n?= <78877636+almenscorner@users.noreply.github.com> Date: Tue, 22 Sep 2026 08:52:10 +0200 Subject: [PATCH 091/104] Handle action execution errors in ScCardCompactButton and ensure running state is reset --- SupportCompanion/Views/ScCardCompactButton.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/SupportCompanion/Views/ScCardCompactButton.swift b/SupportCompanion/Views/ScCardCompactButton.swift index a08a709..1396e5f 100644 --- a/SupportCompanion/Views/ScCardCompactButton.swift +++ b/SupportCompanion/Views/ScCardCompactButton.swift @@ -55,8 +55,12 @@ struct ScCardCompactButton: View { if let buttonAction = buttonAction { Task { isRunning = true // Set running state to true - _ = try await ExecutionService.runAction(buttonAction) - isRunning = false // Reset running state + defer { isRunning = false } // Reset even if the action throws, or the button sticks + do { + _ = try await ExecutionService.runAction(buttonAction) + } catch { + Logger.shared.logError("Action '\(buttonAction.name)' failed: \(error)") + } } } }) { From 935015bbc726aadb5a0c5b5bea8fa4e76b7953c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Alm=C3=A9n?= <78877636+almenscorner@users.noreply.github.com> Date: Tue, 22 Sep 2026 08:52:36 +0200 Subject: [PATCH 092/104] Add timer in menu bar for elevation --- SupportCompanion/App/AppDelegate.swift | 181 ++++++++++++++++--------- 1 file changed, 118 insertions(+), 63 deletions(-) diff --git a/SupportCompanion/App/AppDelegate.swift b/SupportCompanion/App/AppDelegate.swift index e8ec142..ffb8264 100644 --- a/SupportCompanion/App/AppDelegate.swift +++ b/SupportCompanion/App/AppDelegate.swift @@ -5,11 +5,11 @@ // Created by Tobias Almén on 2024-11-14. // -import Foundation import AppKit -import UserNotifications -import SwiftUI import Combine +import Foundation +import SwiftUI +import UserNotifications @MainActor class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { @@ -24,6 +24,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { static var shouldExit = false private var notificationDelegate: NotificationDelegate? private var trayIconObservation: ObservationToken? + private var elevationCountdownObservation: ObservationToken? private var dockBadgeObservation: ObservationToken? private var popoverEventMonitors: [Any] = [] private var popoverKeyWindowObserver: NSObjectProtocol? @@ -50,20 +51,28 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { if url.host == "run" { Logger.shared.logDebug("Received run command request") - if let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems { + if let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems + { if let actionName = queryItems.first(where: { $0.name == "action" })?.value { // Get the action details - if let action = appStateManager.preferences.actions.first(where: { $0.name == actionName }) { + if let action = appStateManager.preferences.actions.first(where: { + $0.name == actionName + }) { Logger.shared.logDebug("Found action: \(action.name)") - if action.isPrivileged ?? false && appStateManager.preferences.requirePrivilegedActionAuthentication { - Logger.shared.logDebug("Action requires authentication") - authenticateWithTouchIDOrPassword(completion: { success in - if success { - self.executeAction(action) - } else { - Logger.shared.logError("Authentication failed. Action: \(action.name) was not executed.") - } - }, reason: "authenticate to execute this privileged action.") + if action.isPrivileged ?? false + && appStateManager.preferences.requirePrivilegedActionAuthentication + { + Logger.shared.logDebug("Action requires authentication") + authenticateWithTouchIDOrPassword( + completion: { success in + if success { + self.executeAction(action) + } else { + Logger.shared.logError( + "Authentication failed. Action: \(action.name) was not executed." + ) + } + }, reason: "authenticate to execute this privileged action.") } else { Logger.shared.logDebug("Executing action: \(action.name)") self.executeAction(action) @@ -77,15 +86,15 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { } switch url.host?.lowercased() { - case nil: - AppDelegate.shouldExit = true - if let statusItem = statusItem { - Logger.shared.logDebug("Removing status item") - NSStatusBar.system.removeStatusItem(statusItem) - self.statusItem = nil - } - default: - AppDelegate.shouldExit = false + case nil: + AppDelegate.shouldExit = true + if let statusItem = statusItem { + Logger.shared.logDebug("Removing status item") + NSStatusBar.system.removeStatusItem(statusItem) + self.statusItem = nil + } + default: + AppDelegate.shouldExit = false } AppDelegate.urlLaunch = true showWindow() @@ -94,7 +103,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { func applicationDidFinishLaunching(_ notification: Notification) { // Unit tests are hosted in the app; don't start the menu bar item, timers, or notifications for them - guard ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil else { return } + guard ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil else { + return + } if !AppDelegate.shouldExit && appStateManager.preferences.trayMenuShowIcon { setupTrayMenu() @@ -119,26 +130,28 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { appStateManager.showWindowCallback = { [weak self] in self?.showWindow() } - - if appStateManager.preferences.desktopInfo.showDesktopInfo { + + if appStateManager.preferences.desktopInfo.showDesktopInfo { // Initialize transparent window transparentWindowController = TransparentWindowController(appState: appStateManager) transparentWindowController?.showWindow(nil) - + // Make sure the transparent window is set up correctly if let window = NSApplication.shared.windows.first { window.isOpaque = false window.backgroundColor = .clear } } - + requestNotificationPermissions() notificationDelegate = NotificationDelegate() UNUserNotificationCenter.current().delegate = notificationDelegate appStateManager.startBackgroundTasks() appStateManager.refreshAll() checkAndHandleDemotionOnLaunch() - if !appStateManager.preferences.hiddenCards.contains(Constants.Cards.jamfInfo) && appStateManager.preferences.mode == Constants.Modes.jamf { + if !appStateManager.preferences.hiddenCards.contains(Constants.Cards.jamfInfo) + && appStateManager.preferences.mode == Constants.Modes.jamf + { Task { let id: String do { @@ -192,12 +205,24 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { self.appStateManager.attentionCount > 0 } TrayMenuManager.shared.updateTrayIcon(hasUpdates: hasUpdates()) - dockBadgeObservation = observeChanges(of: { [unowned self] in self.appStateManager.attentionCount }) { count in + dockBadgeObservation = observeChanges(of: { [unowned self] in + self.appStateManager.attentionCount + }) { count in BadgeManager.shared.incrementBadgeCount(count: count) } trayIconObservation = observeChanges(of: hasUpdates) { hasUpdates in TrayMenuManager.shared.updateTrayIcon(hasUpdates: hasUpdates) } + + // Count down next to the icon while administrator rights are held, so the time left is + // visible without opening anything. The demotion timer already publishes once a second. + let remaining = { [unowned self] () -> TimeInterval in + self.appStateManager.isDemotionActive ? self.appStateManager.timeToDemote : 0 + } + TrayMenuManager.shared.updateElevationCountdown(remaining: remaining()) + elevationCountdownObservation = observeChanges(of: remaining) { remaining in + TrayMenuManager.shared.updateElevationCountdown(remaining: remaining) + } } @MainActor @@ -209,7 +234,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { private init() { statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - updateTrayIcon(hasUpdates: false) // Default state + updateTrayIcon(hasUpdates: false) // Default state } func updateTrayIcon(hasUpdates: Bool) { @@ -234,42 +259,60 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { return } - baseIcon.size = NSSize(width: 16, height: 16) - baseIcon.isTemplate = true // Ensure base icon respects system appearance + baseIcon.size = NSSize(width: 18, height: 18) + baseIcon.isTemplate = true // Ensure base icon respects system appearance if let button = statusItem.button { // Clear any existing layers button.layer?.sublayers?.forEach { $0.removeFromSuperlayer() } - + // Set the base icon as the button's image button.image = baseIcon button.image?.isTemplate = true if hasUpdates { Logger.shared.logDebug("Updates available, adding badge to tray icon") - + // Add badge dynamically as a layer let badgeLayer = CALayer() badgeLayer.backgroundColor = NSColor.red.cgColor badgeLayer.frame = CGRect( - x: button.bounds.width - 15, // Align to the lower-right corner - y: 10, // Small offset from the bottom + x: button.bounds.width - 15, // Align to the lower-right corner + y: 13, // Small offset from the bottom width: 8, height: 8 ) - badgeLayer.cornerRadius = 4 // Make it circular - + badgeLayer.cornerRadius = 4 // Make it circular + // Ensure button has a layer to add sublayers if button.layer == nil { button.wantsLayer = true button.layer = CALayer() } - + button.layer?.addSublayer(badgeLayer) } } } + /// Show the time left on an active elevation beside the tray icon. + /// + /// Set as the button's title rather than drawn into the image: the status item is + /// variable-length, so the text lays out beside the icon on its own, and `updateTrayIcon` + /// rebuilds the image and its layers without disturbing it. + func updateElevationCountdown(remaining: TimeInterval) { + guard let button = statusItem.button else { return } + + guard remaining > 0 else { + button.title = "" + button.toolTip = nil + return + } + + button.title = " \(remaining.formattedTime())" + button.toolTip = Constants.General.demote + } + func getStatusItem() -> NSStatusItem { return statusItem } @@ -291,7 +334,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { ) .environment(AppStateManager.shared) ) - + // Anchor the popover to the status item's button popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) @@ -309,7 +352,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { removePopoverEventMonitors() // Another of this app's windows taking focus, e.g. the main window opened from the popover - popoverKeyWindowObserver = NotificationCenter.default.addObserver(forName: NSWindow.didBecomeKeyNotification, object: nil, queue: .main) { [weak self] notification in + popoverKeyWindowObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didBecomeKeyNotification, object: nil, queue: .main + ) { [weak self] notification in MainActor.assumeIsolated { guard let self, let window = notification.object as? NSWindow else { return } if window !== self.popover.contentViewController?.view.window { @@ -319,30 +364,36 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { } // Clicks in other apps - if let globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown], handler: { [weak self] _ in - Task { @MainActor in self?.closePopover() } - }) { + if let globalMonitor = NSEvent.addGlobalMonitorForEvents( + matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown], + handler: { [weak self] _ in + Task { @MainActor in self?.closePopover() } + }) + { popoverEventMonitors.append(globalMonitor) } // Clicks in this app's other windows, and Escape - if let localMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown, .keyDown], handler: { [weak self] event in - guard let self else { return event } - if event.type == .keyDown { - if event.keyCode == 53 { // Escape + if let localMonitor = NSEvent.addLocalMonitorForEvents( + matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown, .keyDown], + handler: { [weak self] event in + guard let self else { return event } + if event.type == .keyDown { + if event.keyCode == 53 { // Escape + self.closePopover() + return nil + } + return event + } + let popoverWindow = self.popover.contentViewController?.view.window + let statusItemWindow = self.trayManager.getStatusItem().button?.window + // Clicks on the status item are left to togglePopover + if event.window !== popoverWindow && event.window !== statusItemWindow { self.closePopover() - return nil } return event - } - let popoverWindow = self.popover.contentViewController?.view.window - let statusItemWindow = self.trayManager.getStatusItem().button?.window - // Clicks on the status item are left to togglePopover - if event.window !== popoverWindow && event.window !== statusItemWindow { - self.closePopover() - } - return event - }) { + }) + { popoverEventMonitors.append(localMonitor) } } @@ -405,17 +456,21 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { @objc private func runAction(_ sender: NSMenuItem) { guard let action = sender.representedObject as? Action else { return } Task { - _ = try await ExecutionService.runAction(action) + do { + _ = try await ExecutionService.runAction(action) + } catch { + Logger.shared.logError("Tray menu action '\(action.name)' failed: \(error)") + } } } @objc private func quitApp() { NSApplication.shared.terminate(nil) } - + private func configureAppUpdateNotificationCommand() { guard let manager = appStateManager.activeUpdatesManager else { return } - appStateManager.preferences.notifications.appUpdateNotificationCommand = "open \(manager.managementApp(forUpdates: true).path)" + appStateManager.preferences.notifications.appUpdateNotificationCommand = + "open \(manager.managementApp(forUpdates: true).path)" } } - From c9db29351a4d94612c9cef00fe7305dbecced8e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Alm=C3=A9n?= <78877636+almenscorner@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:53:48 +0200 Subject: [PATCH 093/104] Add in-app Fleet Desktop SSO sign-in Fleet 4.92 ships fleet_desktop.sso_enabled, so the sign-in that was previously deferred can now be done in the app instead of sending the user to a browser. Sign-in runs in a web view: the app asks Fleet where to send the user, plants Fleet's handshake cookie in the web view's cookie store, and shows the identity provider's own page. Only the session Fleet issues at the end reaches the app. That cookie is HttpOnly, so it is read from WKHTTPCookieStore rather than from the page, and kept in the keychain scoped to the Fleet host that issued it. The session is restored on the way into the first gated request rather than at startup: a request that got ahead of the restore would come back sso_required, and that answer discards the stored session. Fleet's /device/{token}/desktop endpoint sits outside the SSO gate and still reports how many policies fail, so the sidebar badge, the compliance cards and the notification stay truthful while signed out. Everything Fleet withholds until sign-in now collapses to a single Sign In action rather than a column of Unknown. FleetNotifySignIn (default off) notifies the user when a sign-in is needed, at most once a day, leading with the failing count where there is one. The tray cards route through supportcompanion://fleetsignin, because the sheet belongs to the main window and presenting it from the menu bar popover does nothing when no window is open. --- SupportCompanion/App/ContentView.swift | 16 +- SupportCompanion/Constants.swift | 39 ++ SupportCompanion/Localizable.xcstrings | 462 ++++++++++++++++++ .../Preferences/Preferences.swift | 5 + .../Services/Fleet/FleetClient.swift | 92 +++- .../Services/Fleet/FleetModels.swift | 5 + .../Services/Fleet/FleetSSOController.swift | 215 ++++++++ .../Services/Fleet/FleetSSOSessionStore.swift | 103 ++++ .../ViewModels/AppStateManager.swift | 33 +- .../ViewModels/FleetDeviceManager.swift | 42 ++ .../Views/Cards/FleetInfoCard.swift | 15 +- .../CompactCards/CompactFleetInfoCard.swift | 16 +- .../CompactFleetPoliciesCard.swift | 25 +- .../Views/Fleet/FleetAppsView.swift | 8 +- .../Views/Fleet/FleetComplianceView.swift | 18 +- .../Views/Fleet/FleetSSOSignInSheet.swift | 109 +++++ .../FleetDeviceManagerTests.swift | 88 ++++ SupportCompanionTests/FleetSSOTests.swift | 116 +++++ 18 files changed, 1387 insertions(+), 20 deletions(-) create mode 100644 SupportCompanion/Services/Fleet/FleetSSOController.swift create mode 100644 SupportCompanion/Services/Fleet/FleetSSOSessionStore.swift create mode 100644 SupportCompanion/Views/Fleet/FleetSSOSignInSheet.swift create mode 100644 SupportCompanionTests/FleetSSOTests.swift diff --git a/SupportCompanion/App/ContentView.swift b/SupportCompanion/App/ContentView.swift index 43234ac..f406bcf 100644 --- a/SupportCompanion/App/ContentView.swift +++ b/SupportCompanion/App/ContentView.swift @@ -121,8 +121,13 @@ struct ContentView: View { // Set the background color based on the color scheme with opacity .background(colorScheme == .dark ? Color.black.opacity(0.4) : Color.white.opacity(0.4)) .background(.ultraThinMaterial) + // One sign-in sheet for the window: both Fleet pages start the same sign-in, and only one of + // them is ever in the detail pane. Attached out here rather than to the detail pane, because a + // sheet centres on the view it is attached to -- on the detail pane it sits off to the right. + .sheet(isPresented: Bindable(appState.fleetSSOController).isPresented) { + FleetSSOSignInSheet(controller: appState.fleetSSOController) + } } - private func handleIncomingURL(_ url: URL, items: [SidebarItem]) { guard url.scheme == "supportcompanion" else { return } @@ -144,11 +149,18 @@ struct ContentView: View { selectedItem = items.first(where: { $0.id == Constants.Navigation.knowledgeBase }) case "markdown": selectedItem = items.first(where: { $0.id == appState.preferences.markdownMenuLabel }) + case "fleetsignin": + // Reached from the tray cards and the sign-in notification, where there may be no window + // yet. Opening the window is the AppDelegate's job; this lands the user somewhere that + // makes sense once signed in, and puts the sheet up. + selectedItem = items.first(where: { $0.id == Constants.Navigation.apps }) + ?? items.first(where: { $0.id == Constants.Navigation.compliance }) + appState.fleetSSOController.present() default: selectedItem = items.first(where: { $0.id == Constants.Navigation.home }) } } - + private func loadLogoForCurrentColorScheme() { let preferredLight = appState.preferences.branding.brandLogoLight let darkLogo = appState.preferences.branding.brandLogo diff --git a/SupportCompanion/Constants.swift b/SupportCompanion/Constants.swift index eec7c69..c77e441 100644 --- a/SupportCompanion/Constants.swift +++ b/SupportCompanion/Constants.swift @@ -204,6 +204,45 @@ enum Constants { comment: "Help text for the menu with extra actions for an app") static let cancel = String( localized: "Fleet.Cancel", defaultValue: "Cancel", comment: "Cancel button") + static let ssoSheetTitle = String( + localized: "Fleet.SSOSheetTitle", defaultValue: "Sign in to Fleet", + comment: "Title of the Fleet single sign-on window") + static let ssoConnecting = String( + localized: "Fleet.SSOConnecting", defaultValue: "Connecting to your identity provider\u{2026}", + comment: "Shown while the Fleet sign-in page is being prepared") + static let ssoCouldNotSignIn = String( + localized: "Fleet.SSOCouldNotSignIn", defaultValue: "Couldn't sign in", + comment: "Title when Fleet single sign-on failed") + static let ssoDisabled = String( + localized: "Fleet.SSODisabled", + defaultValue: "Single sign-on for Fleet isn't enabled. Contact your IT department.", + comment: "Shown when Fleet Desktop single sign-on was turned off during sign-in") + static let ssoFailed = String( + localized: "Fleet.SSOFailed", + defaultValue: "Your identity provider didn't complete the sign-in. Try again.", + comment: "Shown when the identity provider didn't return a valid Fleet session") + static let signInNotification = String( + localized: "Fleet.SignInNotification", + defaultValue: "Sign in to Fleet to see your apps and compliance checks.", + comment: "Notification when Fleet needs the user to sign in") + static let signInNotificationFailing = String( + localized: "Fleet.SignInNotificationFailing", + defaultValue: "%d compliance checks need attention. Sign in to see which.", + comment: "Notification when a signed-out Mac has failing checks; %d is how many") + static let signedOutRecord = String( + localized: "Fleet.SignedOutRecord", + defaultValue: "Sign in to see this Mac's record in Fleet.", + comment: "Fleet info card when the user hasn't signed in") + static let signedOutFailing = String( + localized: "Fleet.SignedOutFailing", + defaultValue: "%d checks need attention. Sign in to see which.", + comment: "Compliance summary while signed out; %d is the number of failing checks") + static let signedOutPassing = String( + localized: "Fleet.SignedOutPassing", defaultValue: "No checks are failing.", + comment: "Compliance summary while signed out when nothing is failing") + static let signInForChecks = String( + localized: "Fleet.SignInForChecks", defaultValue: "Sign in to see compliance checks.", + comment: "Compliance page when Fleet requires single sign-on") static let close = String( localized: "Fleet.Close", defaultValue: "Close", comment: "Button that closes the install details") diff --git a/SupportCompanion/Localizable.xcstrings b/SupportCompanion/Localizable.xcstrings index 23bcaf5..2a133a6 100644 --- a/SupportCompanion/Localizable.xcstrings +++ b/SupportCompanion/Localizable.xcstrings @@ -2612,6 +2612,132 @@ } } }, + "Fleet.SignedOutFailing" : { + "comment" : "Compliance summary while signed out; %d is the number of failing checks", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d Prüfungen erfordern Aufmerksamkeit. Melden Sie sich an, um zu sehen, welche." + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%d checks need attention. Sign in to see which." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d vérifications nécessitent votre attention. Connectez-vous pour voir lesquelles." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d件のチェックに対応が必要です。サインインして確認してください。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d kontroller krever oppmerksomhet. Logg inn for å se hvilke." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d kontroller behöver åtgärdas. Logga in för att se vilka." + } + } + } + }, + "Fleet.SignedOutPassing" : { + "comment" : "Compliance summary while signed out when nothing is failing", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Prüfungen sind fehlgeschlagen." + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No checks are failing." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucune vérification n’est en échec." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "失敗しているチェックはありません。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen kontroller er mislykket." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inga kontroller misslyckas." + } + } + } + }, + "Fleet.SignedOutRecord" : { + "comment" : "Fleet info card when the user hasn't signed in", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Melden Sie sich an, um den Eintrag dieses Mac in Fleet zu sehen." + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Sign in to see this Mac's record in Fleet." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connectez-vous pour voir la fiche de ce Mac dans Fleet." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "このMacのFleetでの登録情報を表示するには、サインインしてください。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logg inn for å se oppføringen for denne Macen i Fleet." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logga in för att se den här Macens post i Fleet." + } + } + } + }, "Fleet.SignIn" : { "comment" : "Button that starts Fleet single sign-on", "extractionState" : "extracted_with_value", @@ -2624,6 +2750,132 @@ } } }, + "Fleet.SignInForChecks" : { + "comment" : "Compliance page when Fleet requires single sign-on", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Melden Sie sich an, um die Konformitätsprüfungen zu sehen." + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Sign in to see compliance checks." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connectez-vous pour voir les vérifications de conformité." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "コンプライアンスチェックを表示するには、サインインしてください。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logg inn for å se samsvarskontrollene." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logga in för att se efterlevnadskontrollerna." + } + } + } + }, + "Fleet.SignInNotification" : { + "comment" : "Notification when Fleet needs the user to sign in", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Melden Sie sich bei Fleet an, um Ihre Apps und Konformitätsprüfungen zu sehen." + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Sign in to Fleet to see your apps and compliance checks." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connectez-vous à Fleet pour voir vos apps et vos vérifications de conformité." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アプリとコンプライアンスチェックを表示するには、Fleetにサインインしてください。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logg inn på Fleet for å se appene og samsvarskontrollene dine." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logga in på Fleet för att se dina appar och efterlevnadskontroller." + } + } + } + }, + "Fleet.SignInNotificationFailing" : { + "comment" : "Notification when a signed-out Mac has failing checks; %d is how many", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d Konformitätsprüfungen erfordern Aufmerksamkeit. Melden Sie sich an, um zu sehen, welche." + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%d compliance checks need attention. Sign in to see which." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d vérifications de conformité nécessitent votre attention. Connectez-vous pour voir lesquelles." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d件のコンプライアンスチェックに対応が必要です。サインインして確認してください。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d samsvarskontroller krever oppmerksomhet. Logg inn for å se hvilke." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d efterlevnadskontroller behöver åtgärdas. Logga in för att se vilka." + } + } + } + }, "Fleet.SignInRequired" : { "comment" : "Shown when Fleet requires single sign-on", "extractionState" : "extracted_with_value", @@ -2636,6 +2888,216 @@ } } }, + "Fleet.SSOConnecting" : { + "comment" : "Shown while the Fleet sign-in page is being prepared", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verbindung zum Identitätsanbieter wird hergestellt…" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Connecting to your identity provider…" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connexion à votre fournisseur d’identité…" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "IDプロバイダに接続中…" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kobler til identitetsleverandøren…" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ansluter till din identitetsleverantör…" + } + } + } + }, + "Fleet.SSOCouldNotSignIn" : { + "comment" : "Title when Fleet single sign-on failed", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anmeldung nicht möglich" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Couldn't sign in" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connexion impossible" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "サインインできませんでした" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kunne ikke logge inn" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Det gick inte att logga in" + } + } + } + }, + "Fleet.SSODisabled" : { + "comment" : "Shown when Fleet Desktop single sign-on was turned off during sign-in", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Single Sign-On für Fleet ist nicht aktiviert. Wenden Sie sich an Ihre IT-Abteilung." + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Single sign-on for Fleet isn't enabled. Contact your IT department." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "L’authentification unique pour Fleet n’est pas activée. Contactez votre service informatique." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fleetのシングルサインオンが有効になっていません。IT部門にお問い合わせください。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enkel pålogging for Fleet er ikke aktivert. Kontakt IT-avdelingen." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enkel inloggning för Fleet är inte aktiverad. Kontakta din IT-avdelning." + } + } + } + }, + "Fleet.SSOFailed" : { + "comment" : "Shown when the identity provider didn't return a valid Fleet session", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ihr Identitätsanbieter hat die Anmeldung nicht abgeschlossen. Versuchen Sie es erneut." + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Your identity provider didn't complete the sign-in. Try again." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre fournisseur d’identité n’a pas terminé la connexion. Réessayez." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "IDプロバイダがサインインを完了しませんでした。もう一度お試しください。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Identitetsleverandøren fullførte ikke påloggingen. Prøv igjen." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Din identitetsleverantör slutförde inte inloggningen. Försök igen." + } + } + } + }, + "Fleet.SSOSheetTitle" : { + "comment" : "Title of the Fleet single sign-on window", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bei Fleet anmelden" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Sign in to Fleet" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se connecter à Fleet" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fleetにサインイン" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logg inn på Fleet" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logga in på Fleet" + } + } + } + }, "Fleet.Uninstall" : { "comment" : "Button that uninstalls an app through Fleet", "extractionState" : "extracted_with_value", diff --git a/SupportCompanion/Preferences/Preferences.swift b/SupportCompanion/Preferences/Preferences.swift index fd0bf19..55e1b01 100644 --- a/SupportCompanion/Preferences/Preferences.swift +++ b/SupportCompanion/Preferences/Preferences.swift @@ -143,6 +143,11 @@ class Preferences { var fleetNotifyPolicies: Bool { DefaultsStore.value(forKey: "FleetNotifyPolicies", default: true) } + /// Off unless an administrator turns it on: unlike the others this one asks the user to do + /// something rather than telling them something, so it isn't imposed by default. + var fleetNotifySignIn: Bool { + DefaultsStore.value(forKey: "FleetNotifySignIn", default: false) + } // MARK: - Support info diff --git a/SupportCompanion/Services/Fleet/FleetClient.swift b/SupportCompanion/Services/Fleet/FleetClient.swift index 7cf1dc6..b46fa68 100644 --- a/SupportCompanion/Services/Fleet/FleetClient.swift +++ b/SupportCompanion/Services/Fleet/FleetClient.swift @@ -46,6 +46,16 @@ enum FleetError: Error, Equatable, LocalizedError { } } +/// What `POST /device/{token}/sso` answers with: where to send the user, and the short-lived +/// handshake cookie Fleet matches the IdP's callback against. +/// +/// Unchecked because of `HTTPCookie`, which has no `Sendable` conformance but is immutable once +/// created — it has no settable properties — and this one is read on the main actor and never written. +struct FleetSSOInitiation: @unchecked Sendable { + let idpURL: URL + let handshakeCookie: HTTPCookie? +} + /// The Fleet calls the software catalog needs, so managers can be tested with a fake. protocol FleetSoftwareAPI: Sendable { /// Why Fleet can't be used on this Mac, or nil when it can. @@ -63,6 +73,8 @@ protocol FleetSoftwareAPI: Sendable { protocol FleetDeviceAPI: Sendable { nonisolated var configurationProblem: String? { get } func deviceHost() async throws -> FleetHost + /// Outside the SSO gate, so the compliance count survives a signed-out session. + func desktopSummary() async throws -> FleetDesktopSummary func refetch() async throws } @@ -71,12 +83,17 @@ actor FleetClient: FleetSoftwareAPI, FleetDeviceAPI { /// Name of the cookie Fleet sets after Fleet Desktop SSO. static let ssoSessionCookieName = "__Host-FLEET_DESKTOP_SESSION" + /// Name of the cookie that ties the IdP's callback to the sign-in this app started. Fleet sets it + /// on the response to the initiation call and reads it back at the SAML callback. + static let ssoHandshakeCookieName = "__Host-FLEETSSOSESSIONID" private let session: URLSession private var token: FleetDeviceIdentity.Token? private var ssoSessionCookie: String? private(set) var isSSORequired = false + /// Whether the keychain has been consulted for a session from an earlier launch. + private var didRestoreSSOSession = false private var consecutiveFailures = 0 private var blockedUntil: Date? @@ -100,10 +117,9 @@ actor FleetClient: FleetSoftwareAPI, FleetDeviceAPI { FleetDeviceIdentity.configurationProblem() } - /// Fleet's "My device" page for this Mac, used for the SSO sign-in web view. - func deviceWebURL() -> URL? { - guard let server = FleetDeviceIdentity.serverURL(), let token = currentToken() else { return nil } - return server.appending(path: "device").appending(path: token.value) + /// The host requests go to, used to scope the stored SSO session to one Fleet server. + private func serverHost() -> String? { + FleetDeviceIdentity.serverURL()?.host } /// Stores the Fleet Desktop SSO session and lifts the SSO and backoff pauses. @@ -115,6 +131,71 @@ actor FleetClient: FleetSoftwareAPI, FleetDeviceAPI { } } + /// Starts Fleet Desktop SSO and returns where to send the user. + /// + /// Deliberately not routed through `request`: this endpoint is outside the SSO gate, and its + /// errors are configuration problems (the feature is off, no IdP, mismatched hosts) rather than + /// signs the server is unwell, so they must not pause every other Fleet request behind a backoff. + func initiateSSO() async throws -> FleetSSOInitiation { + guard let server = FleetDeviceIdentity.serverURL() else { throw FleetError.notConfigured } + guard let token = currentToken() else { throw FleetError.notConfigured } + + let (data, response) = try await perform("POST", "sso", query: [:], server: server, token: token.value) + guard (200..<300).contains(response.statusCode) else { + let message = (try? JSONDecoder.fleet.decode(FleetErrorResponse.self, from: data))?.summary ?? "" + throw FleetError.server(status: response.statusCode, message: message) + } + + let body: FleetSSOInitiationResponse + do { + body = try JSONDecoder.fleet.decode(FleetSSOInitiationResponse.self, from: data) + } catch { + throw FleetError.decoding(String(describing: error)) + } + guard let idpURL = URL(string: body.url), idpURL.scheme?.lowercased() == "https" else { + throw FleetError.decoding("Fleet returned an identity provider URL that isn't HTTPS") + } + + return FleetSSOInitiation(idpURL: idpURL, handshakeCookie: Self.handshakeCookie(from: response, server: server)) + } + + /// Fleet sets the handshake cookie on this response, but the sign-in runs in a web view with its + /// own cookie store, so it's lifted off the headers here and planted there before the IdP loads. + static func handshakeCookie(from response: HTTPURLResponse, server: URL) -> HTTPCookie? { + guard let header = response.value(forHTTPHeaderField: "Set-Cookie") else { return nil } + let cookies = HTTPCookie.cookies(withResponseHeaderFields: ["Set-Cookie": header], for: server) + return cookies.first { $0.name == ssoHandshakeCookieName } + } + + /// Takes the session a completed sign-in produced and keeps it for the next launch. + func completeSSO(cookie: String, expiresAt: Date?) { + didRestoreSSOSession = true + setSSOSessionCookie(cookie) + guard let host = serverHost() else { return } + FleetSSOSessionStore.save(.init(cookie: cookie, host: host, expiresAt: expiresAt)) + } + + /// Reloads a sign-in from an earlier launch, if it hasn't expired and still belongs to this server. + /// + /// Done on the way into the first request rather than at startup, so that nothing can get ahead of + /// it: a gated request sent before the session was loaded would come back asking for SSO, and that + /// answer discards the stored session — which is the one it should have been sending. + private func restoreSSOSessionIfNeeded() { + guard !didRestoreSSOSession else { return } + didRestoreSSOSession = true + guard ssoSessionCookie == nil, let host = serverHost() else { return } + guard let session = FleetSSOSessionStore.load(host: host) else { return } + Logger.shared.logDebug("Fleet: restored a stored Fleet Desktop SSO session") + setSSOSessionCookie(session.cookie) + } + + /// Drops the session everywhere it's held, so the next gated request asks for a fresh sign-in. + func signOutSSO() { + ssoSessionCookie = nil + didRestoreSSOSession = true + FleetSSOSessionStore.clear() + } + func resetBackoff() { consecutiveFailures = 0 blockedUntil = nil @@ -206,6 +287,7 @@ actor FleetClient: FleetSoftwareAPI, FleetDeviceAPI { /// Sends a request, retrying once with a re-read token if Fleet rejects a rotated one. private func request(_ method: String, _ path: String, query: [String: String] = [:], gated: Bool) async throws -> Data { guard let server = FleetDeviceIdentity.serverURL() else { throw FleetError.notConfigured } + if gated { restoreSSOSessionIfNeeded() } if gated && isSSORequired { throw FleetError.ssoRequired } if let blockedUntil, blockedUntil > Date() { throw FleetError.backingOff(until: blockedUntil) } @@ -225,6 +307,8 @@ actor FleetClient: FleetSoftwareAPI, FleetDeviceAPI { if body?.ssoRequired == true { Logger.shared.logDebug("Fleet: \(method) \(path) requires Fleet Desktop SSO") isSSORequired = true + // Whatever session was being sent is spent, so it isn't kept for the next launch + signOutSSO() throw FleetError.ssoRequired } // The token may have rotated since it was read; re-read and retry exactly once diff --git a/SupportCompanion/Services/Fleet/FleetModels.swift b/SupportCompanion/Services/Fleet/FleetModels.swift index 69fe2bc..4ba0809 100644 --- a/SupportCompanion/Services/Fleet/FleetModels.swift +++ b/SupportCompanion/Services/Fleet/FleetModels.swift @@ -251,6 +251,11 @@ struct FleetPoliciesResponse: Decodable, Sendable { let policies: [FleetPolicy] } +/// Where Fleet wants the user sent to sign in. +struct FleetSSOInitiationResponse: Decodable, Sendable { + let url: String +} + /// Fleet's error body. `ssoRequired` is set when Fleet Desktop SSO is enabled and no session cookie was sent. struct FleetErrorResponse: Decodable, Sendable { struct Detail: Decodable, Sendable { diff --git a/SupportCompanion/Services/Fleet/FleetSSOController.swift b/SupportCompanion/Services/Fleet/FleetSSOController.swift new file mode 100644 index 0000000..9bbed0c --- /dev/null +++ b/SupportCompanion/Services/Fleet/FleetSSOController.swift @@ -0,0 +1,215 @@ +// +// FleetSSOController.swift +// SupportCompanion +// +// Drives Fleet Desktop SSO sign-in (Fleet 4.92 and later). +// +// The flow, all of it Fleet's: +// 1. POST /device/{token}/sso answers with the identity provider's URL and sets a handshake cookie. +// 2. The user signs in at the identity provider, which posts the SAML assertion back to Fleet. +// 3. Fleet's callback sets __Host-FLEET_DESKTOP_SESSION and redirects to this Mac's device page. +// +// Steps 1 and 3 happen on Fleet, step 2 at the identity provider, so the sign-in runs in a web view +// rather than in the app: the assertion is between the user and their identity provider, and this +// app only ever sees the session cookie at the end of it. That cookie is HttpOnly, so it's read from +// the web view's cookie store rather than from the page. +// + +import Foundation +import Observation +import WebKit + +@MainActor +@Observable +final class FleetSSOController: NSObject { + enum Phase: Equatable { + /// Nothing in flight. + case idle + /// Asking Fleet where to send the user. + case starting + /// The identity provider's page is up and it's the user's turn. + case signingIn + /// Fleet handed back a session and it's being stored. + case completing + case failed(String) + } + + private(set) var phase: Phase = .idle + /// Whether the sign-in sheet is on screen. + var isPresented = false + private(set) var webView: WKWebView? + + /// Called once a sign-in succeeds, so the Fleet pages reload what they couldn't fetch before. + @ObservationIgnored var onSignedIn: (() -> Void)? + + @ObservationIgnored private let client: FleetClient + @ObservationIgnored private var isFinishing = false + /// Reading the cookie store suspends, and both navigation callbacks check, so checks are serialised. + @ObservationIgnored private var isCheckingForSession = false + + init(client: FleetClient = .shared) { + self.client = client + super.init() + } + + // MARK: - Presenting + + /// Opens the sheet and asks Fleet for the identity provider's URL. + func present() { + guard !isPresented else { return } + isPresented = true + Task { await start() } + } + + func cancel() { + isPresented = false + teardown() + isFinishing = false + phase = .idle + } + + /// Clears the session so the next gated request asks for sign-in again. + func signOut() async { + await client.signOutSSO() + await Self.clearFleetCookies() + phase = .idle + } + + // MARK: - The flow + + func start() async { + teardown() + // A previous attempt may have ended in an error, which leaves this set + isFinishing = false + phase = .starting + + let initiation: FleetSSOInitiation + do { + initiation = try await client.initiateSSO() + } catch { + Logger.shared.logError("Fleet: couldn't start Fleet Desktop SSO: \(error.localizedDescription)") + phase = .failed(error.localizedDescription) + return + } + + // The sheet may have been cancelled while Fleet was answering + guard isPresented else { return } + + // A sign-in that was interrupted can leave Fleet cookies behind, and a stale session cookie + // would be taken for this one's result before the user has typed anything, so the slate is + // wiped first: anything found from here on was set by this sign-in. + await Self.clearFleetCookies() + + guard isPresented else { return } + + // The web view has its own cookie store, so Fleet's handshake cookie has to be planted there + // or its callback has nothing to match the assertion against and the sign-in loops. + let dataStore = WKWebsiteDataStore.default() + if let handshake = initiation.handshakeCookie { + await dataStore.httpCookieStore.setCookie(handshake) + } else { + Logger.shared.logError("Fleet: Fleet Desktop SSO started without a handshake cookie") + } + + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = dataStore + let webView = WKWebView(frame: .zero, configuration: configuration) + webView.navigationDelegate = self + self.webView = webView + phase = .signingIn + webView.load(URLRequest(url: initiation.idpURL)) + } + + /// Looks for the session Fleet's callback sets, and for the error it redirects with instead. + private func checkForSession() async { + guard !isFinishing, !isCheckingForSession, let webView else { return } + isCheckingForSession = true + defer { isCheckingForSession = false } + + if let url = webView.url, let reason = Self.ssoErrorReason(in: url) { + Logger.shared.logError("Fleet: Fleet Desktop SSO failed: \(reason)") + isFinishing = true + phase = .failed(Self.message(forErrorReason: reason)) + teardown() + return + } + + let cookies = await webView.configuration.websiteDataStore.httpCookieStore.allCookies() + guard let session = cookies.first(where: { $0.name == FleetClient.ssoSessionCookieName }), + !session.value.isEmpty else { + return + } + + isFinishing = true + phase = .completing + await client.completeSSO(cookie: session.value, expiresAt: session.expiresDate) + // The session is in the keychain now; the web view's copy would be a second, weaker one + await Self.clearFleetCookies() + teardown() + phase = .idle + isPresented = false + isFinishing = false + Logger.shared.logDebug("Fleet: signed in with Fleet Desktop SSO") + onSignedIn?() + } + + /// Fleet redirects to the device page with `sso_error=` when the callback couldn't mint a + /// session, e.g. because an admin turned the feature off mid-flow. + nonisolated static func ssoErrorReason(in url: URL) -> String? { + URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems? + .first { $0.name == "sso_error" }? + .value + .flatMap { $0.isEmpty ? nil : $0 } + } + + private static func message(forErrorReason reason: String) -> String { + switch reason { + case "sso_disabled": return Constants.Fleet.ssoDisabled + default: return Constants.Fleet.ssoFailed + } + } + + /// Removes the Fleet cookies from the web view's store, leaving the identity provider's own so a + /// later sign-in can still be silent. + private static func clearFleetCookies() async { + let store = WKWebsiteDataStore.default().httpCookieStore + for cookie in await store.allCookies() + where cookie.name == FleetClient.ssoSessionCookieName || cookie.name == FleetClient.ssoHandshakeCookieName { + await store.deleteCookie(cookie) + } + } + + private func teardown() { + webView?.stopLoading() + webView?.navigationDelegate = nil + webView = nil + } +} + +extension FleetSSOController: WKNavigationDelegate { + /// Checked as each response arrives rather than only when a page finishes: Fleet's callback sets + /// the session on a redirect, and the device page it redirects to can take a while to render. + func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { + Task { await checkForSession() } + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + Task { await checkForSession() } + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + fail(with: error) + } + + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + fail(with: error) + } + + private func fail(with error: Error) { + // A cancelled load is the redirect chain moving on, not a failure + guard (error as NSError).code != NSURLErrorCancelled, !isFinishing else { return } + Logger.shared.logError("Fleet: the Fleet Desktop SSO page failed to load: \(error.localizedDescription)") + phase = .failed(error.localizedDescription) + } +} diff --git a/SupportCompanion/Services/Fleet/FleetSSOSessionStore.swift b/SupportCompanion/Services/Fleet/FleetSSOSessionStore.swift new file mode 100644 index 0000000..517290d --- /dev/null +++ b/SupportCompanion/Services/Fleet/FleetSSOSessionStore.swift @@ -0,0 +1,103 @@ +// +// FleetSSOSessionStore.swift +// SupportCompanion +// +// Keychain storage for the Fleet Desktop SSO session. +// +// The cookie Fleet issues after sign-in is a bearer credential for this Mac's device API and lasts +// for the server's `session.duration` (5 days by default), so it's kept in the keychain rather than +// in defaults, and it's scoped to the host that issued it: a Mac repointed at another Fleet server +// signs in again instead of sending the old server's session to the new one. +// + +import Foundation +import Security + +enum FleetSSOSessionStore { + struct Session: Codable, Sendable, Equatable { + let cookie: String + /// Host of the Fleet server that issued it. + let host: String + /// When Fleet's cookie expires, or nil if it came without a Max-Age. + let expiresAt: Date? + + /// Whether the session can still be used against `host`, with a minute of slack so a session + /// about to expire isn't sent only to come back as an SSO prompt. + func isUsable(on host: String, now: Date = Date()) -> Bool { + guard self.host.caseInsensitiveCompare(host) == .orderedSame else { return false } + guard let expiresAt else { return true } + return expiresAt > now.addingTimeInterval(60) + } + } + + private static let service = "com.github.macadmins.SupportCompanion.fleet-sso" + private static let account = "device-session" + + static func save(_ session: Session) { + guard let data = try? JSONEncoder().encode(session) else { return } + // NOTE: this lands in the file-based login keychain, where `kSecAttrAccessible` is ignored -- + // it only takes effect in the data protection keychain, which needs + // `kSecUseDataProtectionKeychain` and a keychain-access-group entitlement. So the intent below + // is NOT enforced: the session is readable whenever the login keychain is unlocked, and it can + // travel to another Mac in a backup or a migration. What actually guards it is the login + // keychain's ACL, which limits reads to this app's code signature. + // + // Accepted deliberately: the credential is a bearer token scoped to this Mac's Fleet device + // API and expires in 5 days. Revisit if that scope grows. + let attributes: [String: Any] = [ + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, + ] + let status = SecItemUpdate(query() as CFDictionary, attributes as CFDictionary) + if status == errSecItemNotFound { + var item = query() + item.merge(attributes) { _, new in new } + let addStatus = SecItemAdd(item as CFDictionary, nil) + if addStatus != errSecSuccess { + Logger.shared.logError("Fleet: couldn't store the SSO session (\(addStatus))") + } + } else if status != errSecSuccess { + Logger.shared.logError("Fleet: couldn't update the SSO session (\(status))") + } + } + + /// The stored session if it's still usable on `host`, clearing it when it isn't. + static func load(host: String) -> Session? { + var item = query() + item[kSecReturnData as String] = true + item[kSecMatchLimit as String] = kSecMatchLimitOne + + var result: CFTypeRef? + let status = SecItemCopyMatching(item as CFDictionary, &result) + guard status == errSecSuccess, let data = result as? Data else { + if status != errSecItemNotFound { + Logger.shared.logError("Fleet: couldn't read the SSO session (\(status))") + } + return nil + } + guard let session = try? JSONDecoder().decode(Session.self, from: data) else { + clear() + return nil + } + guard session.isUsable(on: host) else { + clear() + return nil + } + return session + } + + static func clear() { + let status = SecItemDelete(query() as CFDictionary) + if status != errSecSuccess && status != errSecItemNotFound { + Logger.shared.logError("Fleet: couldn't clear the SSO session (\(status))") + } + } + + private static func query() -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + } +} diff --git a/SupportCompanion/ViewModels/AppStateManager.swift b/SupportCompanion/ViewModels/AppStateManager.swift index 1af75fe..d4722d9 100644 --- a/SupportCompanion/ViewModels/AppStateManager.swift +++ b/SupportCompanion/ViewModels/AppStateManager.swift @@ -31,6 +31,17 @@ class AppStateManager { } return manager }() + /// Drives Fleet Desktop SSO sign-in, and reloads what the SSO gate blocked once it succeeds. + @ObservationIgnored lazy var fleetSSOController: FleetSSOController = { + let controller = FleetSSOController() + controller.onSignedIn = { [weak self] in + Task { + await self?.fleetSoftwareManager.refresh() + await self?.fleetDeviceManager.refresh() + } + } + return controller + }() @ObservationIgnored lazy var fleetDeviceManager: FleetDeviceManager = { let manager = FleetDeviceManager() manager.onNewlyFailing = { [weak self] policies in @@ -39,6 +50,9 @@ class AppStateManager { manager.onRefetchFinished = { [weak self] in Task { await self?.fleetSoftwareManager.refresh() } } + manager.onSignInRequired = { [weak self] in + self?.notifyFleetSignInRequired() + } return manager }() @ObservationIgnored var jsonCardManager: JsonCardManager? @@ -86,7 +100,8 @@ class AppStateManager { var fleetFailingChecksCount: Int { guard preferences.mode == Constants.Modes.fleet, !preferences.hiddenCards.contains(Constants.Cards.fleetPolicies) else { return 0 } - return fleetDeviceManager.failingPolicies.count + // The ungated count when signed out, so the badge doesn't silently drop to zero + return fleetDeviceManager.failingChecksCount ?? 0 } /// What needs the user's attention, for the menu bar dot and Dock badge: pending app updates, macOS @@ -205,6 +220,22 @@ class AppStateManager { } } + /// Off unless an administrator sets `FleetNotifySignIn`. Leads with the failing count when the + /// ungated summary has one: what's wrong is the news, and signing in is how to see it. + private func notifyFleetSignInRequired() { + guard preferences.fleetNotifySignIn, preferences.mode == Constants.Modes.fleet else { return } + let failing = fleetDeviceManager.failingChecksCount ?? 0 + let message = failing > 0 + ? String(format: Constants.Fleet.signInNotificationFailing, failing) + : Constants.Fleet.signInNotification + NotificationService(appState: self).sendNotification( + message: message, + buttonText: Constants.Fleet.signIn, + command: "open supportcompanion://fleetsignin", + notificationType: .generic + ) + } + private func notifyFleetPoliciesFailing(_ policies: [FleetPolicy]) { guard preferences.fleetNotifyPolicies, !preferences.hiddenCards.contains(Constants.Cards.fleetPolicies), diff --git a/SupportCompanion/ViewModels/FleetDeviceManager.swift b/SupportCompanion/ViewModels/FleetDeviceManager.swift index f24e2bc..75e8b79 100644 --- a/SupportCompanion/ViewModels/FleetDeviceManager.swift +++ b/SupportCompanion/ViewModels/FleetDeviceManager.swift @@ -21,6 +21,9 @@ final class FleetDeviceManager { } private(set) var host: FleetHost? + /// The ungated `/desktop` summary. Fleet keeps this outside the SSO gate deliberately — it carries + /// no identifying detail — so it's what's left to show when the user hasn't signed in. + private(set) var summary: FleetDesktopSummary? private(set) var loadState: LoadState = .idle private(set) var lastUpdated: Date? /// A refetch was requested and Fleet hasn't received the Mac's fresh details yet. @@ -30,6 +33,16 @@ final class FleetDeviceManager { var policies: [FleetPolicy] { host?.policies ?? [] } + /// Whether Fleet is withholding this Mac's record until the user signs in. + var isSignedOut: Bool { loadState == .ssoRequired && host == nil } + + /// Failing checks from the host record, or from the ungated summary while signed out, so the + /// badge and notifications stay truthful either way. Nil when even the summary is unavailable. + var failingChecksCount: Int? { + if host != nil { return failingPolicies.count } + return summary?.failingPoliciesCount + } + /// Rows for the Fleet card. var infoRows: [(key: String, display: String, value: InfoValue)] { typealias Info = Constants.FleetInfo @@ -52,6 +65,8 @@ final class FleetDeviceManager { @ObservationIgnored var onNewlyFailing: (([FleetPolicy]) -> Void)? /// Called when a refetch finishes, so other Fleet data can be refreshed too. @ObservationIgnored var onRefetchFinished: (() -> Void)? + /// Called when Fleet starts asking for a sign-in, at most once a day while the user stays out. + @ObservationIgnored var onSignInRequired: (() -> Void)? @ObservationIgnored private let client: any FleetDeviceAPI @ObservationIgnored private let defaults: UserDefaults @@ -61,6 +76,10 @@ final class FleetDeviceManager { /// Ids of failing policies the user has been told about. static let reportedFailuresKey = "FleetReportedFailingPolicies" + /// When the user was last told to sign in, so an hourly poll doesn't nag hourly. + static let signInNotifiedKey = "FleetSignInNotifiedAt" + /// How long to wait before mentioning a sign-in again while the user stays signed out. + private static let signInReminderInterval: TimeInterval = 24 * 3600 /// Fleet re-runs details and policies at the Mac's next check-in, usually within a minute or two. private static let refetchTimeout: TimeInterval = 5 * 60 @@ -158,19 +177,42 @@ final class FleetDeviceManager { loadState = .failed(problem) return } + // Fetched first and on its own: it's outside the SSO gate, so it still answers when the + // host record below doesn't, and it's what the signed-out cards fall back to. + do { + summary = try await client.desktopSummary() + } catch { + Logger.shared.logDebug("Fleet: couldn't read the desktop summary: \(error.localizedDescription)") + } + do { host = try await client.deviceHost() lastUpdated = Date() loadState = .loaded + // Signed in again, so a later sign-out is reported promptly rather than waiting out + // the remainder of the reminder interval + defaults.removeObject(forKey: Self.signInNotifiedKey) reportNewlyFailing() } catch FleetError.ssoRequired { loadState = .ssoRequired + reportSignInRequired() } catch { // Keep showing the last details loadState = .failed(error.localizedDescription) } } + /// Tells the app a sign-in is needed, but no more than once per reminder interval: this runs on + /// every poll, and the session stays expired until the user does something about it. + private func reportSignInRequired() { + if let last = defaults.object(forKey: Self.signInNotifiedKey) as? Date, + Date().timeIntervalSince(last) < Self.signInReminderInterval { + return + } + defaults.set(Date(), forKey: Self.signInNotifiedKey) + onSignInRequired?() + } + private func reportNewlyFailing() { let reported = Set(defaults.array(forKey: Self.reportedFailuresKey) as? [Int] ?? []) let failing = failingPolicies diff --git a/SupportCompanion/Views/Cards/FleetInfoCard.swift b/SupportCompanion/Views/Cards/FleetInfoCard.swift index ac8cf02..44436a3 100644 --- a/SupportCompanion/Views/Cards/FleetInfoCard.swift +++ b/SupportCompanion/Views/Cards/FleetInfoCard.swift @@ -13,7 +13,20 @@ struct FleetInfoCard: View { var body: some View { ScCard(title: Constants.CardTitle.fleetInfo, titleImageName: "server.rack", content: { VStack(alignment: .leading, spacing: 5) { - CardData(info: appState.fleetDeviceManager.infoRows) + // Fleet withholds the host record until sign-in, so the rows would all read Unknown. + // One thing the user can act on beats five that say nothing. + if appState.fleetDeviceManager.isSignedOut { + Text(Constants.Fleet.signedOutRecord) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + ScSmallButton(Constants.Fleet.signIn) { + appState.fleetSSOController.present() + } + .padding(.top, 2) + } else { + CardData(info: appState.fleetDeviceManager.infoRows) + } Spacer() } .padding(.horizontal) diff --git a/SupportCompanion/Views/CompactCards/CompactFleetInfoCard.swift b/SupportCompanion/Views/CompactCards/CompactFleetInfoCard.swift index daba51e..79f13c0 100644 --- a/SupportCompanion/Views/CompactCards/CompactFleetInfoCard.swift +++ b/SupportCompanion/Views/CompactCards/CompactFleetInfoCard.swift @@ -14,7 +14,21 @@ struct CompactFleetInfoCard: View { titleImageName: "server.rack", imageSize: (13, 13), content: { - CardData(info: appState.fleetDeviceManager.infoRows, fontSize: 12) + if appState.fleetDeviceManager.isSignedOut { + VStack(alignment: .leading, spacing: 6) { + Text(Constants.Fleet.signedOutRecord) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + ScSmallButton(Constants.Fleet.signIn) { + // Not present() directly: the sheet belongs to the main window, which may + // not be open from here. This opens it first, then signs in. + ActionHelpers.openManagementApp(appURL: "supportcompanion://fleetsignin") + } + } + } else { + CardData(info: appState.fleetDeviceManager.infoRows, fontSize: 12) + } } ) .task { await appState.fleetDeviceManager.refreshIfStale() } diff --git a/SupportCompanion/Views/CompactCards/CompactFleetPoliciesCard.swift b/SupportCompanion/Views/CompactCards/CompactFleetPoliciesCard.swift index 4d4ccae..c13a10e 100644 --- a/SupportCompanion/Views/CompactCards/CompactFleetPoliciesCard.swift +++ b/SupportCompanion/Views/CompactCards/CompactFleetPoliciesCard.swift @@ -30,8 +30,21 @@ struct CompactFleetPoliciesCard: View { let checked = manager.checkedPolicies VStack(alignment: .leading, spacing: 8) { if manager.host == nil { - Text(manager.loadState == .ssoRequired ? Constants.Fleet.policiesSignIn : Constants.FleetInfo.unknown) - .foregroundStyle(.secondary) + // Signed out, Fleet still gives a failing count through the ungated summary, so the + // card can say what's actually wrong instead of asking the user to sign in blind + if let failingCount = manager.failingChecksCount { + if failingCount > 0 { + Label(String(format: Constants.Fleet.signedOutFailing, failingCount), + systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(colorScheme == .light ? Color.orangeLight : .orange) + } else { + Label(Constants.Fleet.signedOutPassing, systemImage: "checkmark.circle.fill") + .foregroundStyle(Color.ScGreen) + } + } else { + Text(manager.isSignedOut ? Constants.Fleet.signInForChecks : Constants.FleetInfo.unknown) + .foregroundStyle(.secondary) + } } else if checked.isEmpty { Text(Constants.Fleet.noPolicies) .foregroundStyle(.secondary) @@ -43,6 +56,14 @@ struct CompactFleetPoliciesCard: View { .foregroundStyle(colorScheme == .light ? Color.orangeLight : .orange) } + if manager.isSignedOut { + ScSmallButton(Constants.Fleet.signIn) { + // Not present() directly: the sheet belongs to the main window, which may not be + // open from here. This opens it first, then signs in. + ActionHelpers.openManagementApp(appURL: "supportcompanion://fleetsignin") + } + } + if manager.host != nil { HStack { FleetRecheckButton(compact: true) diff --git a/SupportCompanion/Views/Fleet/FleetAppsView.swift b/SupportCompanion/Views/Fleet/FleetAppsView.swift index db3015b..2d96468 100644 --- a/SupportCompanion/Views/Fleet/FleetAppsView.swift +++ b/SupportCompanion/Views/Fleet/FleetAppsView.swift @@ -127,7 +127,7 @@ struct FleetAppsView: View { case .ssoRequired: message(Constants.Fleet.signInRequired, systemImage: "person.badge.key") { ScButton(Constants.Fleet.signIn) { - await openDeviceWebPage() + await appState.fleetSSOController.present() } .frame(maxWidth: 200) } @@ -247,10 +247,4 @@ struct FleetAppsView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } - /// Opens Fleet's My Device page, which handles sign-in. Replaced by the in-app sign-in sheet. - private func openDeviceWebPage() async { - if let url = await FleetClient.shared.deviceWebURL() { - NSWorkspace.shared.open(url) - } - } } diff --git a/SupportCompanion/Views/Fleet/FleetComplianceView.swift b/SupportCompanion/Views/Fleet/FleetComplianceView.swift index 1c3a34d..995409d 100644 --- a/SupportCompanion/Views/Fleet/FleetComplianceView.swift +++ b/SupportCompanion/Views/Fleet/FleetComplianceView.swift @@ -35,7 +35,8 @@ struct FleetComplianceView: View { private var hero: some View { let failing = manager.failingPolicies let checked = manager.checkedPolicies - let isFailing = !failing.isEmpty + // Signed out there are no policies to count, but the ungated summary still knows how many fail + let isFailing = manager.isSignedOut ? (manager.failingChecksCount ?? 0) > 0 : !failing.isEmpty let hasCritical = failing.contains { $0.critical == true } let tint = isFailing ? (hasCritical ? criticalColor : warningColor) : Color.ScGreen @@ -69,6 +70,12 @@ struct FleetComplianceView: View { } private func headline(failing: Int, checked: Int) -> String { + if manager.isSignedOut { + guard let signedOutFailing = manager.failingChecksCount else { return Constants.Fleet.signInForChecks } + return signedOutFailing > 0 + ? String(format: Constants.Fleet.signedOutFailing, signedOutFailing) + : Constants.Fleet.signedOutPassing + } if checked == 0 { return Constants.Fleet.noPolicies } if failing == 0 { return String(format: Constants.Fleet.policiesPassing, checked) } return String(format: Constants.Fleet.policiesFailing, failing, checked) @@ -91,7 +98,14 @@ struct FleetComplianceView: View { .frame(maxWidth: .infinity) .padding(.top, 60) case .ssoRequired where manager.policies.isEmpty: - message(Constants.Fleet.policiesSignIn, systemImage: "person.badge.key") + VStack(spacing: 16) { + message(Constants.Fleet.signInForChecks, systemImage: "person.badge.key") + ScButton(Constants.Fleet.signIn) { + await appState.fleetSSOController.present() + } + .frame(maxWidth: 200) + } + .frame(maxWidth: .infinity) case .failed where manager.policies.isEmpty: message(Constants.Fleet.policiesUnavailable, systemImage: "exclamationmark.triangle") default: diff --git a/SupportCompanion/Views/Fleet/FleetSSOSignInSheet.swift b/SupportCompanion/Views/Fleet/FleetSSOSignInSheet.swift new file mode 100644 index 0000000..14a29e1 --- /dev/null +++ b/SupportCompanion/Views/Fleet/FleetSSOSignInSheet.swift @@ -0,0 +1,109 @@ +// +// FleetSSOSignInSheet.swift +// SupportCompanion +// +// Fleet Desktop SSO sign-in: the identity provider's own page, hosted in the app. +// + +import SwiftUI +import WebKit + +struct FleetSSOSignInSheet: View { + var controller: FleetSSOController + + var body: some View { + VStack(spacing: 0) { + header + Divider() + content + } + .frame(width: 620, height: 640) + } + + private var header: some View { + HStack { + Label(Constants.Fleet.ssoSheetTitle, systemImage: "person.badge.key") + .font(.headline) + Spacer() + Button(Constants.Fleet.cancel) { controller.cancel() } + .keyboardShortcut(.cancelAction) + } + .padding(.horizontal, 20) + .padding(.vertical, 14) + } + + @ViewBuilder + private var content: some View { + switch controller.phase { + case .failed(let message): + status(systemImage: "exclamationmark.triangle", title: Constants.Fleet.ssoCouldNotSignIn, detail: message) { + ScButton(Constants.Fleet.retry) { + await controller.start() + } + .frame(maxWidth: 200) + } + case .signingIn: + if let webView = controller.webView { + // The identity provider's page, not Fleet's: the app never sees what's typed into it. + // Keyed by instance so a retry shows its new web view rather than the failed one. + FleetSSOWebView(webView: webView) + .id(ObjectIdentifier(webView)) + } else { + status(systemImage: nil, title: Constants.Fleet.ssoConnecting, detail: nil) { + ProgressView() + } + } + default: + status(systemImage: nil, title: Constants.Fleet.ssoConnecting, detail: nil) { + ProgressView() + } + } + } + + @ViewBuilder + private func status( + systemImage: String?, + title: String, + detail: String?, + @ViewBuilder accessory: () -> Accessory + ) -> some View { + VStack(spacing: 12) { + if let systemImage { + Image(systemName: systemImage) + .font(.system(size: 30)) + .foregroundStyle(.secondary) + } + Text(title) + .font(.headline) + if let detail, !detail.isEmpty { + Text(detail) + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .textSelection(.enabled) + } + accessory() + } + .padding(30) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +/// Hosts the web view the controller owns, so its navigation delegate survives SwiftUI re-evaluating +/// this view — see WebViewContainer for why no coordinator sets a delegate here. +private struct FleetSSOWebView: NSViewRepresentable { + let webView: WKWebView + + func makeNSView(context: Context) -> WKWebView { webView } + + func updateNSView(_ nsView: WKWebView, context: Context) {} + + /// Take the size offered rather than asking the web view how big the loaded page wants to be, + /// which is a layout cycle. + func sizeThatFits(_ proposal: ProposedViewSize, nsView: WKWebView, context: Context) -> CGSize? { + CGSize( + width: proposal.width ?? nsView.frame.width, + height: proposal.height ?? nsView.frame.height + ) + } +} diff --git a/SupportCompanionTests/FleetDeviceManagerTests.swift b/SupportCompanionTests/FleetDeviceManagerTests.swift index b4f5b1d..e4e18d8 100644 --- a/SupportCompanionTests/FleetDeviceManagerTests.swift +++ b/SupportCompanionTests/FleetDeviceManagerTests.swift @@ -12,6 +12,8 @@ private actor FakePolicyAPI: FleetDeviceAPI { private var results: [Result<[FleetPolicy], FleetError>] private var refetchRequested: [Bool] private(set) var refetchCalls = 0 + /// What the ungated `/desktop` endpoint answers, or nil to make it fail like the gated calls. + var summaryFailingCount: Int? init(_ results: [Result<[FleetPolicy], FleetError>], refetchRequested: [Bool] = []) { self.results = results @@ -24,6 +26,16 @@ private actor FakePolicyAPI: FleetDeviceAPI { return try host(policies: policies, refetchRequested: requested) } + func desktopSummary() async throws -> FleetDesktopSummary { + guard let summaryFailingCount else { throw FleetError.network("no summary") } + let json: [String: Any] = ["failing_policies_count": summaryFailingCount, "self_service": true] + return try JSONDecoder.fleet.decode(FleetDesktopSummary.self, from: JSONSerialization.data(withJSONObject: json)) + } + + func setSummaryFailingCount(_ count: Int?) { + summaryFailingCount = count + } + func refetch() async throws { refetchCalls += 1 } @@ -95,6 +107,82 @@ struct FleetDeviceManagerTests { #expect(manager.checkedPolicies.count == 3) } + @Test("Signed out, the failing count comes from the ungated summary") + func failingCountSurvivesSignedOut() async { + let api = FakePolicyAPI([.failure(.ssoRequired)]) + await api.setSummaryFailingCount(3) + let manager = FleetDeviceManager(client: api, defaults: makeDefaults()) + + await manager.refresh() + + #expect(manager.isSignedOut) + #expect(manager.policies.isEmpty) + // The whole point: the badge and the compliance card still say something true + #expect(manager.failingChecksCount == 3) + } + + @Test("Signed in, the count comes from the policies, not the summary") + func signedInPrefersPolicies() async { + let api = FakePolicyAPI([.success([policy(1, "A", "fail"), policy(2, "B", "pass")])]) + await api.setSummaryFailingCount(99) + let manager = FleetDeviceManager(client: api, defaults: makeDefaults()) + + await manager.refresh() + + #expect(!manager.isSignedOut) + #expect(manager.failingChecksCount == 1) + } + + /// The summary is a separate request and can fail on its own; that must not invent a count of zero. + @Test("With no summary and no host, the count is unknown rather than zero") + func noSummaryMeansUnknown() async { + let api = FakePolicyAPI([.failure(.ssoRequired)]) + await api.setSummaryFailingCount(nil) + let manager = FleetDeviceManager(client: api, defaults: makeDefaults()) + + await manager.refresh() + + #expect(manager.isSignedOut) + #expect(manager.failingChecksCount == nil) + } + + @Test("A sign-in is reported once, not on every poll") + func signInReportedOncePerDay() async { + let api = FakePolicyAPI([.failure(.ssoRequired), .failure(.ssoRequired), .failure(.ssoRequired)]) + await api.setSummaryFailingCount(0) + let manager = FleetDeviceManager(client: api, defaults: makeDefaults()) + var reports = 0 + manager.onSignInRequired = { reports += 1 } + + await manager.refresh() + await manager.refresh() + await manager.refresh() + + // The session stays expired until the user acts, so every poll would otherwise nag + #expect(reports == 1) + } + + /// Signing in clears the reminder, so the next sign-out is reported promptly rather than being + /// swallowed by the remainder of the day's interval. + @Test("Signing in resets the reminder") + func signingInResetsReminder() async { + let api = FakePolicyAPI([ + .failure(.ssoRequired), + .success([policy(1, "A", "pass")]), + .failure(.ssoRequired), + ]) + await api.setSummaryFailingCount(0) + let manager = FleetDeviceManager(client: api, defaults: makeDefaults()) + var reports = 0 + manager.onSignInRequired = { reports += 1 } + + await manager.refresh() + await manager.refresh() + await manager.refresh() + + #expect(reports == 2) + } + @Test("SSO required keeps earlier policies") func ssoRequired() async { let api = FakePolicyAPI([.success([policy(1, "A", "fail")]), .failure(.ssoRequired)]) diff --git a/SupportCompanionTests/FleetSSOTests.swift b/SupportCompanionTests/FleetSSOTests.swift new file mode 100644 index 0000000..76ec76f --- /dev/null +++ b/SupportCompanionTests/FleetSSOTests.swift @@ -0,0 +1,116 @@ +// +// FleetSSOTests.swift +// SupportCompanionTests +// + +import Foundation +import Testing +@testable import SupportCompanion + +@Suite("Fleet Desktop SSO") +struct FleetSSOTests { + + private let server = URL(string: "https://fleet.example.com")! + + private func response(setCookie: String?) -> HTTPURLResponse { + var fields: [String: String] = ["Content-Type": "application/json"] + if let setCookie { fields["Set-Cookie"] = setCookie } + return HTTPURLResponse(url: server, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: fields)! + } + + // MARK: - The handshake cookie + + /// Exactly what Fleet 4.92 sends on POST /device/{token}/sso. + @Test func readsFleetsHandshakeCookie() throws { + let cookie = try #require(FleetClient.handshakeCookie( + from: response(setCookie: "__Host-FLEETSSOSESSIONID=abc123; Path=/; Max-Age=300; HttpOnly; Secure"), + server: server + )) + + #expect(cookie.name == "__Host-FLEETSSOSESSIONID") + #expect(cookie.value == "abc123") + // __Host- cookies are pinned to the host and path, and the web view must be handed them that way + #expect(cookie.path == "/") + #expect(cookie.isSecure) + #expect(cookie.domain.contains("fleet.example.com")) + } + + @Test func ignoresOtherCookies() { + let cookie = FleetClient.handshakeCookie( + from: response(setCookie: "__Host-FLEET_DESKTOP_SESSION=xyz; Path=/; Secure"), + server: server + ) + #expect(cookie == nil) + } + + @Test func toleratesNoCookieAtAll() { + #expect(FleetClient.handshakeCookie(from: response(setCookie: nil), server: server) == nil) + } + + @Test func decodesTheIdentityProviderURL() throws { + let json = #"{"url":"https://login.microsoftonline.com/x/saml2?SAMLRequest=abc"}"# + let decoded = try JSONDecoder.fleet.decode(FleetSSOInitiationResponse.self, from: Data(json.utf8)) + #expect(decoded.url == "https://login.microsoftonline.com/x/saml2?SAMLRequest=abc") + } + + // MARK: - Fleet's failure redirect + + @Test func readsTheErrorFleetRedirectsWith() throws { + let url = URL(string: "https://fleet.example.com/device/tok?sso_error=sso_disabled")! + #expect(FleetSSOController.ssoErrorReason(in: url) == "sso_disabled") + } + + @Test func aPlainDevicePageIsNotAnError() { + let url = URL(string: "https://fleet.example.com/device/tok")! + #expect(FleetSSOController.ssoErrorReason(in: url) == nil) + } + + /// An empty value is Fleet saying nothing, not Fleet reporting a failure with no reason. + @Test func anEmptyErrorIsNotAnError() { + let url = URL(string: "https://fleet.example.com/device/tok?sso_error=")! + #expect(FleetSSOController.ssoErrorReason(in: url) == nil) + } + + // MARK: - Reusing a stored session + + private func session(host: String, expiresAt: Date?) -> FleetSSOSessionStore.Session { + .init(cookie: "session-value", host: host, expiresAt: expiresAt) + } + + @Test func aFreshSessionIsUsed() { + let stored = session(host: "fleet.example.com", expiresAt: Date().addingTimeInterval(5 * 24 * 3600)) + #expect(stored.isUsable(on: "fleet.example.com")) + } + + @Test func hostsAreComparedCaseInsensitively() { + let stored = session(host: "Fleet.Example.com", expiresAt: nil) + #expect(stored.isUsable(on: "fleet.example.com")) + } + + /// A Mac repointed at another Fleet server signs in again rather than offering the old session. + @Test func aSessionFromAnotherServerIsRefused() { + let stored = session(host: "old.example.com", expiresAt: Date().addingTimeInterval(3600)) + #expect(!stored.isUsable(on: "fleet.example.com")) + } + + @Test func anExpiredSessionIsRefused() { + let stored = session(host: "fleet.example.com", expiresAt: Date().addingTimeInterval(-1)) + #expect(!stored.isUsable(on: "fleet.example.com")) + } + + /// Sending a session that expires mid-flight just earns an SSO prompt, so it's dropped early. + @Test func aSessionAboutToExpireIsRefused() { + let stored = session(host: "fleet.example.com", expiresAt: Date().addingTimeInterval(30)) + #expect(!stored.isUsable(on: "fleet.example.com")) + } + + @Test func aSessionSurvivesEncoding() throws { + let expiry = Date(timeIntervalSince1970: 1_800_000_000) + let stored = session(host: "fleet.example.com", expiresAt: expiry) + let decoded = try JSONDecoder().decode( + FleetSSOSessionStore.Session.self, + from: JSONEncoder().encode(stored) + ) + #expect(decoded == stored) + } +} From 2930e25995c462f15ce2fffcf69697aea8e567cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Alm=C3=A9n?= <78877636+almenscorner@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:00:48 +0200 Subject: [PATCH 094/104] Add user installs with a privileged installer policy Lets a standard user install software they have downloaded, without handing them an admin password, when an administrator has allowlisted it. The helper owns the decision. It re-reads the policy and the allowlist before acting, inspects the package or disk image itself, and only then stages and installs. Nothing the app says about an installer is taken on trust, so a tampered client cannot talk the helper into running something the policy does not allow. EnableUserInstalls is off by default, and the settings that gate it are read only when an administrator set them. Installers can be opened from the app, from Finder's context menu, or by double-clicking when the app is registered for the type. Where the catalog already offers the same application, the user is pointed at the approved version instead, and where the policy refuses an installer outright they can elevate and run it themselves if elevation is available to them. handleElevation takes an optional completion handler so an install can carry on once rights are granted, and AppStateManager now uses the shared ElevationManager rather than its own instance, so the countdown the UI reads is the one the install flow started. --- Helper/DiskImageInspector.swift | 352 ++++++++ Helper/Helper.swift | 3 + Helper/HelperPreferences.swift | 138 +++- Helper/HelperProtocol.swift | 23 + Helper/HelperService.swift | 63 ++ Helper/InstallCoordinator.swift | 552 +++++++++++++ Helper/InstallStaging.swift | 184 +++++ Helper/InstallerPolicy.swift | 761 ++++++++++++++++++ Helper/PackageInspector.swift | 503 ++++++++++++ SupportCompanion.xcodeproj/project.pbxproj | 3 + SupportCompanion/App/AppDelegate.swift | 52 ++ SupportCompanion/Constants.swift | 104 +++ .../Helpers/InstallerFileTypes.swift | 47 ++ .../Helpers/InstallerServiceMenu.swift | 64 ++ .../Helpers/TrustedPreferences.swift | 34 +- SupportCompanion/Info.plist | 68 ++ SupportCompanion/Localizable.xcstrings | 339 ++++++++ .../Models/CatalogSuggestion.swift | 76 ++ .../Preferences/Preferences.swift | 24 + .../Services/ExecutionService.swift | 37 + .../ViewModels/AppStateManager.swift | 5 +- .../ViewModels/ElevationManager.swift | 43 +- .../PendingFleetUpdatesManager.swift | 7 + .../ViewModels/PendingUpdatesManager.swift | 21 + .../ViewModels/UserInstallManager.swift | 301 +++++++ .../Views/Cards/ElevationCard.swift | 6 +- SupportCompanion/Views/UserInstallView.swift | 495 ++++++++++++ .../CatalogMatchingTests.swift | 60 ++ .../InstallerPolicyTests.swift | 481 +++++++++++ 29 files changed, 4831 insertions(+), 15 deletions(-) create mode 100644 Helper/DiskImageInspector.swift create mode 100644 Helper/InstallCoordinator.swift create mode 100644 Helper/InstallStaging.swift create mode 100644 Helper/InstallerPolicy.swift create mode 100644 Helper/PackageInspector.swift create mode 100644 SupportCompanion/Helpers/InstallerFileTypes.swift create mode 100644 SupportCompanion/Helpers/InstallerServiceMenu.swift create mode 100644 SupportCompanion/Models/CatalogSuggestion.swift create mode 100644 SupportCompanion/ViewModels/UserInstallManager.swift create mode 100644 SupportCompanion/Views/UserInstallView.swift create mode 100644 SupportCompanionTests/CatalogMatchingTests.swift create mode 100644 SupportCompanionTests/InstallerPolicyTests.swift diff --git a/Helper/DiskImageInspector.swift b/Helper/DiskImageInspector.swift new file mode 100644 index 0000000..7dc6df5 --- /dev/null +++ b/Helper/DiskImageInspector.swift @@ -0,0 +1,352 @@ +// +// DiskImageInspector.swift +// com.github.macadmins.SupportCompanion.helper +// + +import CryptoKit +import Foundation +import Security + +/// Reads what a disk image contains, with the image mounted where only root can reach it. +/// +/// A drag-install application needs no scripts and runs nothing as root — the install is a copy — but +/// it still needs the helper, because `/Applications` is `root:admin` and a standard user cannot write +/// to it. That makes this the safer half of the feature: what is checked and what is copied are the +/// same bytes, and nothing of the vendor's ever executes with privileges. +enum DiskImageInspector { + + // MARK: Mounting + + struct Mount { + let mountPoint: String + let deviceEntry: String + } + + /// Attach the image underneath the staging directory. + /// + /// `-mountrandom` into our own root-owned, `0700` directory rather than `/Volumes`: mounted in + /// `/Volumes` the user could read the image while it is being checked and, more to the point, + /// replace what is at that path between the check and the copy. + static func attach(imageAt path: String, under directory: String) async throws -> Mount { + let mountRoot = (directory as NSString).appendingPathComponent("mnt") + try HelperState.makeDirectory(at: mountRoot) + + // No stdin is attached to the helper, so an image with a licence agreement fails here rather + // than waiting for an answer that cannot arrive. Those have to go through Installer.app. + let output = try await ExecutionService.run("/usr/bin/hdiutil", [ + "attach", path, + "-nobrowse", "-readonly", "-noautoopen", "-owners", "off", + "-mountrandom", mountRoot, + "-plist" + ]) + + guard + let data = output.data(using: .utf8), + let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any], + let entities = plist["system-entities"] as? [[String: Any]] + else { + throw SupportCompanionErrors.helperConnection("Unable to read what mounting the disk image produced") + } + + guard let mounted = entities.first(where: { $0["mount-point"] is String }), + let mountPoint = mounted["mount-point"] as? String + else { + throw SupportCompanionErrors.helperConnection("The disk image mounted nothing that can be read") + } + + // Detaching wants the whole image's device, which is the shortest of the entries. + let deviceEntry = entities + .compactMap { $0["dev-entry"] as? String } + .min(by: { $0.count < $1.count }) + ?? (mounted["dev-entry"] as? String ?? mountPoint) + + return Mount(mountPoint: mountPoint, deviceEntry: deviceEntry) + } + + static func detach(_ mount: Mount) async { + do { + _ = try await ExecutionService.run("/usr/bin/hdiutil", ["detach", mount.deviceEntry, "-quiet"]) + } catch { + Logger.shared.logError("Unable to detach \(mount.deviceEntry): \(error.localizedDescription). Forcing.") + _ = try? await ExecutionService.run("/usr/bin/hdiutil", ["detach", mount.deviceEntry, "-force", "-quiet"]) + } + } + + // MARK: Contents + + enum Payload { + case application(path: String) + case package(path: String) + } + + /// Find the one thing in the image that can be installed. + /// + /// Exactly one, and only at the top level. An image holding two applications, or one tucked inside + /// a folder, is refused rather than guessed at: the guess would decide what gets installed as root. + static func payload(in mountPoint: String) throws -> Payload { + let entries = try FileManager.default.contentsOfDirectory(atPath: mountPoint) + .filter { !$0.hasPrefix(".") } + + let applications = try entries + .filter { ($0 as NSString).pathExtension.lowercased() == "app" } + .map { try validatedEntry($0, in: mountPoint) } + + let packages = try entries + .filter { ["pkg", "mpkg"].contains(($0 as NSString).pathExtension.lowercased()) } + .map { try validatedEntry($0, in: mountPoint) } + + if applications.count == 1 && packages.isEmpty { + return .application(path: applications[0]) + } + + if packages.count == 1 && applications.isEmpty { + return .package(path: packages[0]) + } + + if applications.isEmpty && packages.isEmpty { + throw SupportCompanionErrors.helperConnection("The disk image contains no application or installer package") + } + + throw SupportCompanionErrors.helperConnection( + "The disk image contains more than one installable item, so there is no way to tell which one was meant" + ) + } + + /// Refuse a payload that is a symbolic link, or that resolves outside the image. + /// + /// A disk image is authored by whoever handed it over, and a link in it is resolved at the moment + /// it is used — by the helper, as root. An image containing `Vendor.pkg -> ~/their.pkg` would have + /// the allowlist checked against a file the user can replace afterwards, which is the whole + /// guarantee of this feature turned inside out. The image's own digest says nothing about it, + /// because the image genuinely is the one that was approved. + /// + /// Every drag-install image contains an `Applications` symlink, so links are ordinary here; it is + /// only a link *as the payload* that is refused. + private static func validatedEntry(_ name: String, in mountPoint: String) throws -> String { + let path = (mountPoint as NSString).appendingPathComponent(name) + + var info = stat() + + guard lstat(path, &info) == 0 else { + throw SupportCompanionErrors.helperConnection("Unable to read '\(name)' in the disk image") + } + + guard (info.st_mode & S_IFMT) != S_IFLNK else { + Logger.shared.logError("Refusing \(path): the payload is a symbolic link") + throw SupportCompanionErrors.helperConnection("'\(name)' is a link rather than something this can install") + } + + // Belt and braces for any other way out of the mount, such as a firmlink. + let resolved = URL(fileURLWithPath: path).resolvingSymlinksInPath().path + let root = URL(fileURLWithPath: mountPoint).resolvingSymlinksInPath().path + + guard resolved == root || resolved.hasPrefix(root.hasSuffix("/") ? root : root + "/") else { + Logger.shared.logError("Refusing \(path): it resolves to \(resolved), outside the disk image") + throw SupportCompanionErrors.helperConnection("'\(name)' points outside the disk image") + } + + return path + } + + /// Copy the payload out of the mounted image into root-owned staging. + /// + /// So that the image can be detached before anything is installed, and so that what was inspected + /// and what gets installed are one set of bytes in a directory only root can write. While the + /// payload is still inside the image it is only as stable as the image's author allows. + static func copyOut(_ payload: Payload, into directory: String) async throws -> Payload { + let destinationDirectory = (directory as NSString).appendingPathComponent("payload") + try HelperState.makeDirectory(at: destinationDirectory) + + let source: String + switch payload { + case .application(let path): source = path + case .package(let path): source = path + } + + let destination = (destinationDirectory as NSString) + .appendingPathComponent((source as NSString).lastPathComponent) + + // `ditto` keeps the bundle intact, including the extended attributes a signature depends on. + _ = try await ExecutionService.run("/usr/bin/ditto", ["--noqtn", source, destination]) + + switch payload { + case .application: return .application(path: destination) + case .package: return .package(path: destination) + } + } + + // MARK: Application signature + + struct ApplicationSignature { + var trusted = false + var notarized = false + var teamID: String? + var bundleIdentifier: String? + var authority: String? + var leafCertificateSHA256: String? + } + + /// Verify an application bundle's signature and read who signed it. + /// + /// `anchor apple generic` establishes that the signature chains to Apple; the Team ID and the + /// bundle identifier are then read from the signature itself rather than from `Info.plist`, which + /// is not evidence of anything — a bundle can claim any identifier it likes in a file, but only the + /// one it was signed with survives `SecStaticCodeCheckValidity`. + static func signature(ofApplicationAt path: String) async -> ApplicationSignature { + var signature = ApplicationSignature() + + var staticCode: SecStaticCode? + + guard SecStaticCodeCreateWithPath(URL(fileURLWithPath: path) as CFURL, [], &staticCode) == errSecSuccess, + let staticCode + else { + Logger.shared.logError("Unable to read a code signature from \(path)") + return signature + } + + var requirement: SecRequirement? + + guard SecRequirementCreateWithString("anchor apple generic" as CFString, [], &requirement) == errSecSuccess else { + return signature + } + + // Every architecture, and the nested code too: a bundle can be signed correctly at the top + // level while carrying a helper or framework inside it that is not. + let flags = SecCSFlags(rawValue: kSecCSCheckAllArchitectures | kSecCSCheckNestedCode) + let validity = SecStaticCodeCheckValidity(staticCode, flags, requirement) + + guard validity == errSecSuccess else { + Logger.shared.logError("\(path) failed signature validation with status \(validity)") + return signature + } + + signature.trusted = true + + var information: CFDictionary? + + if SecCodeCopySigningInformation(staticCode, SecCSFlags(rawValue: kSecCSSigningInformation), &information) == errSecSuccess, + let info = information as? [String: Any] { + signature.teamID = info[kSecCodeInfoTeamIdentifier as String] as? String + signature.bundleIdentifier = info[kSecCodeInfoIdentifier as String] as? String + + // Taken from the certificate itself rather than parsed out of a printed chain, which is + // what the package side has to settle for. Same value, same profile key. + if let certificates = info[kSecCodeInfoCertificates as String] as? [SecCertificate], + let leaf = certificates.first { + let der = SecCertificateCopyData(leaf) as Data + signature.leafCertificateSHA256 = SHA256.hash(data: der) + .map { String(format: "%02x", $0) } + .joined() + } + } + + let assessment = await gatekeeperAssessment(of: path) + signature.notarized = assessment.notarized + signature.authority = assessment.origin + + return signature + } + + /// Read an application's signing identifier without requiring its signature to be valid. + /// + /// `signature(ofApplicationAt:)` answers "is this trustworthy" and gives up as soon as validation + /// fails, so it reports no identifier at all for an application that is unsigned, ad-hoc signed, + /// or has a broken nested signature. That is precisely the incumbent you want to identify before + /// replacing it. This asks only what the bundle says it is, and says so where it can. + /// + /// It is also cheap: no nested-code walk and no Gatekeeper fork, which on a large application is + /// seconds spent reading one string. + static func identifier(ofApplicationAt path: String) -> String? { + var staticCode: SecStaticCode? + + if SecStaticCodeCreateWithPath(URL(fileURLWithPath: path) as CFURL, [], &staticCode) == errSecSuccess, + let staticCode { + var information: CFDictionary? + + if SecCodeCopySigningInformation(staticCode, SecCSFlags(rawValue: kSecCSSigningInformation), &information) == errSecSuccess, + let info = information as? [String: Any], + let identifier = info[kSecCodeInfoIdentifier as String] as? String { + return identifier + } + } + + // Unsigned entirely. Info.plist is not evidence of anything, but for deciding whether two + // bundles are the same application it is better than knowing nothing. + return Bundle(path: path)?.bundleIdentifier + } + + /// Ask Gatekeeper to assess the application the way launching it would. + /// + /// As with packages, an assessment on a Mac where Gatekeeper has been turned off approves + /// everything, so a disabled assessment is reported as "not notarized" rather than as a pass. + private static func gatekeeperAssessment(of path: String) async -> (notarized: Bool, origin: String?) { + let status = try? await ProcessRunner.run( + executableURL: URL(fileURLWithPath: "/usr/sbin/spctl"), + arguments: ["--status"] + ) + + let statusOutput = (String(data: status?.output ?? Data(), encoding: .utf8) ?? "") + + (String(data: status?.error ?? Data(), encoding: .utf8) ?? "") + + guard statusOutput.contains("assessments enabled") else { + Logger.shared.logError("Gatekeeper assessments are disabled on this Mac; treating \(path) as unassessed") + return (false, nil) + } + + let result = try? await ProcessRunner.run( + executableURL: URL(fileURLWithPath: "/usr/sbin/spctl"), + arguments: ["--assess", "--type", "execute", "-vv", path] + ) + + guard let result else { return (false, nil) } + + let output = (String(data: result.output, encoding: .utf8) ?? "") + + (String(data: result.error, encoding: .utf8) ?? "") + + let lines = output.split(separator: "\n") + let source = lines.first { $0.hasPrefix("source=") }.map { String($0.dropFirst(7)) } + let origin = lines.first { $0.hasPrefix("origin=") }.map { String($0.dropFirst(7)) } + + return (result.status == 0 && PackageInspector.isNotarized(source: source), origin) + } + + // MARK: Facts + + /// What the policy needs to know about an application inside a mounted image. + /// + /// The digest is of the disk image the user downloaded, not of the application inside it: that is + /// the file an administrator can hash to write a strict-mode entry. + static func facts( + forApplicationAt path: String, + imageDigest: String, + fileName: String + ) async throws -> InstallerFacts { + let signature = await signature(ofApplicationAt: path) + + let bundle = Bundle(path: path) + let version = bundle?.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String + ?? bundle?.object(forInfoDictionaryKey: "CFBundleVersion") as? String + + let displayName = bundle?.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String + ?? bundle?.object(forInfoDictionaryKey: "CFBundleName") as? String + ?? (path as NSString).lastPathComponent.replacingOccurrences(of: ".app", with: "") + + return InstallerFacts( + kind: .diskImage, + fileName: fileName, + sha256: imageDigest, + teamID: signature.teamID, + authority: signature.authority, + leafCertificateSHA256: signature.leafCertificateSHA256, + signatureTrusted: signature.trusted, + notarized: signature.notarized, + identifiers: [signature.bundleIdentifier].compactMap { $0 }, + displayName: displayName, + version: version, + hasScripts: false, + hasRemoteReferences: false, + // A drag install writes one place, and `installApplication` will not write anywhere else. + payloadRoots: ["/Applications/\((path as NSString).lastPathComponent)"] + ) + } +} diff --git a/Helper/Helper.swift b/Helper/Helper.swift index 087a6a8..710de25 100644 --- a/Helper/Helper.swift +++ b/Helper/Helper.swift @@ -32,6 +32,9 @@ extension Helper { // Take back any administrator rights that outlived the last run before accepting connections ElevationCoordinator.shared.reconcileOnLaunch() + // Clear staged installers, and any disk image still attached, that a previous run left behind + InstallCoordinator.shared.reconcileOnLaunch() + // start listening on new connections listener.resume() diff --git a/Helper/HelperPreferences.swift b/Helper/HelperPreferences.swift index 893b4c2..fc27289 100644 --- a/Helper/HelperPreferences.swift +++ b/Helper/HelperPreferences.swift @@ -17,24 +17,65 @@ import Foundation /// /// 1. `/Library/Managed Preferences//.plist` — profile scoped to the connecting user /// 2. `/Library/Managed Preferences/.plist` — profile scoped to the device -/// 3. `/Library/Preferences/.plist` — set by an administrator on the device +/// +/// `/Library/Preferences/.plist` is deliberately **not** among them. Every setting this type +/// supplies decides something privileged — who may hold administrator rights, which commands run as +/// root, which installers are allowed — and that file is one any process already running as root may +/// write, with nothing to correct it afterwards. A single root moment would become a permanent grant +/// of all three. A managed preference cannot be forged that way for long: the MDM owns those files +/// and puts them back, which is the same reasoning behind re-reading the admin allowlist on a timer. +/// +/// For development, put the plist in `/Library/Managed Preferences/` rather than reaching for the +/// local path; root can write there directly and the helper will read it. enum HelperPreferences { private static let domain = "com.github.macadmins.SupportCompanion" + /// Account names that are safe to put in a path. + /// + /// The name comes from `getpwuid`, which is trustworthy for local accounts — but on a + /// directory-bound Mac it comes from whatever the directory service returns, and a name + /// containing `/` or `..` would redirect the read to a file of somebody else's choosing. + private static func isUsable(_ userName: String) -> Bool { + !userName.isEmpty + && userName != "." + && userName != ".." + // ASCII deliberately, matching what the comment claims. `isLetter` and `isNumber` are + // Unicode-wide, which is harmless here but says something broader than it means. + && userName.allSatisfy { $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "." || $0 == "_" || $0 == "-") } + } + private static func searchPaths(forUser userName: String?) -> [String] { var paths: [String] = [] - if let userName, !userName.isEmpty { + if let userName, isUsable(userName) { paths.append("/Library/Managed Preferences/\(userName)/\(domain).plist") + } else if let userName, !userName.isEmpty { + Logger.shared.logError("Ignoring the user-scoped preferences path: '\(userName)' is not a usable account name") } paths.append("/Library/Managed Preferences/\(domain).plist") - paths.append("/Library/Preferences/\(domain).plist") return paths } + /// The local file this type used to read, kept only so that finding settings in it can be said + /// out loud rather than silently ignored. + private static var unmanagedPath: String { "/Library/Preferences/\(domain).plist" } + + /// Warn when a setting lives somewhere that is no longer honoured. + /// + /// Dropping the local path is a behaviour change for anyone who configured it with a tool other + /// than an MDM. Silently falling back to defaults would look like the feature breaking for no + /// reason, so the reason is logged. + private static func warnIfPresentUnmanaged(_ key: String) { + guard let plist = contents(ofPlistAt: unmanagedPath), plist[key] != nil else { return } + + Logger.shared.logError( + "Ignoring '\(key)' in \(unmanagedPath): settings that grant privileges are only read from a configuration profile. Deliver it through your MDM." + ) + } + /// Load a preference file, ignoring any that is not owned by root. /// /// All three directories are root-owned, so a non-root owner means the file predates that or was @@ -49,6 +90,15 @@ enum HelperPreferences { Logger.shared.logError("Ignoring \(path): not owned by root") return nil } + + // Root-owned is not the same as only root-writable. The directories above make this + // unreachable today, which is exactly why the check belongs here rather than being + // assumed from somewhere else. + if let permissions = (attributes[.posixPermissions] as? NSNumber)?.uint16Value, + permissions & 0o022 != 0 { + Logger.shared.logError("Ignoring \(path): writable by group or other") + return nil + } } catch { Logger.shared.logError("Unable to check ownership of \(path): \(error.localizedDescription)") return nil @@ -72,6 +122,8 @@ enum HelperPreferences { } } + warnIfPresentUnmanaged(key) + return nil } @@ -135,4 +187,84 @@ enum HelperPreferences { return command } + + /// Where the helper looked for a setting and what it found at each place. + /// + /// The app and the helper read preferences by different routes — the app through `CFPreferences`, + /// which knows about profiles scoped to the logged-in user, and the helper by reading the files, + /// because as root it is not that user and would never be shown their profile. The two can + /// therefore disagree, and when they do it is the helper's answer that decides. This says, in one + /// line, what the helper's answer was built from. + static func describeSearch(forKey key: String, forUser userName: String?) -> String { + searchPaths(forUser: userName).map { path in + guard FileManager.default.fileExists(atPath: path) else { + return "\(path): absent" + } + + guard let plist = contents(ofPlistAt: path) else { + return "\(path): unreadable, or not owned by root" + } + + guard let value = plist[key] else { + return "\(path): present, but has no \(key)" + } + + return "\(path): \(key) = \(value)" + } + .joined(separator: " | ") + } + + // MARK: User installs + + /// Whether a standard user may install allowlisted applications through the helper. + static func enableUserInstalls(forUser userName: String?) -> Bool { + bool(forKey: "EnableUserInstalls", default: false, forUser: userName) + } + + /// Whether the app must authenticate the user before asking for an install. + /// + /// The point of the feature is that nobody types an administrator password any more. Replacing that + /// with nothing at all would make an unlocked, unattended Mac enough, so the default is to ask the + /// user for their own credentials instead. + static func requireAuthenticationForInstalls(forUser userName: String?) -> Bool { + bool(forKey: "RequireAuthenticationForInstalls", default: true, forUser: userName) + } + + /// What the app should offer when an installer is not on the allowlist. + /// + /// `installer` is the default because it is what a double-click does when Support Companion is not + /// involved at all: the file opens in Installer.app and the administrator password is asked for as + /// it always was. Nothing the user could do before stops working because this feature exists. + static func installFallback(forUser userName: String?) -> InstallerAssessment.Fallback { + let raw = (object(forKey: "UserInstallFallback", forUser: userName) as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + + guard let raw, let fallback = InstallerAssessment.Fallback(rawValue: raw) else { + return .installer + } + + return fallback + } + + /// The applications a standard user may install, as an administrator wrote them. + /// + /// Entries that cannot decide anything are dropped and logged rather than treated as permissive: a + /// half-written entry is a mistake, and the safe reading of a mistake is that nothing was allowed. + static func allowedInstallers(forUser userName: String?) -> [AllowedInstaller] { + guard let raw = object(forKey: "AllowedInstallers", forUser: userName) as? [[String: Any]] else { + Logger.shared.logError("No administrator-defined AllowedInstallers found") + return [] + } + + return raw.compactMap { dictionary in + let (entry, problem) = AllowedInstaller.make(from: dictionary) + + if let problem { + Logger.shared.logError("Ignoring an AllowedInstallers entry: \(problem)") + } + + return entry + } + } } diff --git a/Helper/HelperProtocol.swift b/Helper/HelperProtocol.swift index 248f531..905b714 100644 --- a/Helper/HelperProtocol.swift +++ b/Helper/HelperProtocol.swift @@ -35,6 +35,29 @@ public protocol HelperProtocol { /// runs is always the one the administrator configured. @objc func runPrivilegedAction(named name: String) async throws -> String + // MARK: User installs + + /// Copy an installer the user chose into root-owned storage, and judge that copy. + /// + /// The subject comes from the client here, unlike every other operation: the user picked the file. + /// The decision does not. The helper copies the bytes behind the descriptor somewhere only root can + /// write, checks the copy against the allowlist in administrator-managed preferences, and returns a + /// token naming that copy. `installStagedInstaller` installs the same copy, so nothing the client + /// does in between can change what gets installed. + /// + /// A descriptor rather than a path, because the app holds it as the logged-in user: it can only + /// open what that user could already read, where a path would let a client have root read and + /// measure files the user has no access to. + /// + /// Returns an `InstallerAssessment` as JSON. + @objc func stageInstaller(_ installer: FileHandle, fileName: String) async throws -> String + + /// Install the staged copy named by `token`, if the assessment allowed it. + @objc func installStagedInstaller(token: String) async throws -> String + + /// Throw away a staged copy the user decided against, without waiting for it to expire. + @objc func discardStagedInstaller(token: String) async throws -> String + // MARK: Jamf @objc func jamfPatch(id: String) async throws -> String diff --git a/Helper/HelperService.swift b/Helper/HelperService.swift index 649ffeb..5ca62ed 100644 --- a/Helper/HelperService.swift +++ b/Helper/HelperService.swift @@ -52,6 +52,24 @@ extension HelperService { return userId } + /// Reduce a client-supplied file name to something safe to log and to derive an extension from. + /// + /// Only ever used for display, for the log, and for its extension — never to build a path to read + /// from. The staging file is named by the helper. See `StagedFile.copy(from:toDirectory:fileName:)`. + private func sanitizedFileName(_ fileName: String) throws -> String { + let name = (fileName as NSString).lastPathComponent + .unicodeScalars + .map { CharacterSet.controlCharacters.contains($0) || $0 == "/" ? " " : Character($0) } + .reduce(into: "") { $0.append($1) } + .trimmingCharacters(in: .whitespacesAndNewlines) + + guard !name.isEmpty, name != ".", name != ".." else { + throw SupportCompanionErrors.helperConnection("'\(fileName)' is not a usable file name") + } + + return String(name.prefix(255)) + } + /// Refuse to act on root or on a uid with no account behind it. private func validatedElevationTarget() throws -> String { guard clientUID != 0 else { @@ -112,6 +130,51 @@ extension HelperService: HelperProtocol { return try await ExecutionService.shell(command) } + // MARK: User installs + + func stageInstaller(_ installer: FileHandle, fileName: String) async throws -> String { + guard clientUID != 0, !clientUserName.isEmpty else { + throw SupportCompanionErrors.helperConnection("No user account for uid \(clientUID)") + } + + let name = try sanitizedFileName(fileName) + + Logger.shared.logInfo("Assessing installer '\(name)' for \(clientUserName)") + + let assessment = try await InstallCoordinator.shared.stage( + from: installer, + fileName: name, + clientUID: clientUID, + clientUserName: clientUserName + ) + + return try assessment.jsonString() + } + + func installStagedInstaller(token: String) async throws -> String { + guard UUID(uuidString: token) != nil else { + throw SupportCompanionErrors.helperConnection("That is not a staged installer") + } + + Logger.shared.logInfo("Installing staged installer for \(clientUserName)") + + return try await InstallCoordinator.shared.install( + token: token, + clientUID: clientUID, + clientUserName: clientUserName + ) + } + + func discardStagedInstaller(token: String) async throws -> String { + guard UUID(uuidString: token) != nil else { + throw SupportCompanionErrors.helperConnection("That is not a staged installer") + } + + await InstallCoordinator.shared.discard(token, clientUID: clientUID) + + return "" + } + // MARK: Jamf func jamfPatch(id: String) async throws -> String { diff --git a/Helper/InstallCoordinator.swift b/Helper/InstallCoordinator.swift new file mode 100644 index 0000000..8efb40f --- /dev/null +++ b/Helper/InstallCoordinator.swift @@ -0,0 +1,552 @@ +// +// InstallCoordinator.swift +// com.github.macadmins.SupportCompanion.helper +// + +import Darwin +import Foundation + +/// Installs applications an administrator has allowed, on behalf of a user who is not an administrator. +/// +/// The work is in two steps on purpose. `stage` copies the installer somewhere only root can write and +/// judges it *there*; `install` installs that same copy, named only by an opaque token. Nothing the +/// client says between the two can change what gets installed, so this keeps the property the rest of +/// the helper has: the caller names an operation, and the helper decides what it does. +actor InstallCoordinator { + + static let shared = InstallCoordinator() + + private static let auditLogPath = HelperState.directory + "/installs.log" + + /// How long a staged installer waits for the user to make up their mind. + /// + /// A disk image stays mounted for this long, so it is short. The app re-stages if the sheet has + /// been sitting open longer than this. + private static let stagingLifetime: TimeInterval = 5 * 60 + + private struct Staged { + let directory: String + /// The package to install, or the application to copy. + let payloadPath: String + let kind: InstallerFacts.Kind + let mount: DiskImageInspector.Mount? + let facts: InstallerFacts + let entryName: String + let matchMode: InstallerMatchMode + let clientUID: uid_t + let createdAt: Date + } + + private var staged: [String: Staged] = [:] + + /// Disk images attached while an assessment is still running, by staging directory. + /// + /// On the actor rather than in a local, so that cleaning up after a failure part-way through the + /// assessment can find what was mounted. A directory holding a mount point cannot be removed until + /// the image is detached, and detaching is asynchronous, so this cannot live in a `defer`. + private var mountsInProgress: [String: DiskImageInspector.Mount] = [:] + + /// Only one install at a time. Without this a client can start as many root `installer` processes + /// as it can click, and two installs writing to `/Applications` at once is its own problem. + private var isInstalling = false + + private init() {} + + // MARK: Launch + + /// Clear anything a previous run left behind. + /// + /// A crash mid-assessment leaves a staged copy, and possibly a disk image still attached. Neither + /// is dangerous — both are inside a root-only directory — but they occupy disk until something + /// tidies up, and nothing else will. + nonisolated func reconcileOnLaunch() { + Task { await tidyLeftovers() } + } + + private func tidyLeftovers() async { + guard FileManager.default.fileExists(atPath: StagedFile.root) else { return } + + let leftovers = (try? FileManager.default.contentsOfDirectory(atPath: StagedFile.root)) ?? [] + + for leftover in leftovers { + let directory = (StagedFile.root as NSString).appendingPathComponent(leftover) + let mountRoot = (directory as NSString).appendingPathComponent("mnt") + + for mounted in (try? FileManager.default.contentsOfDirectory(atPath: mountRoot)) ?? [] { + let path = (mountRoot as NSString).appendingPathComponent(mounted) + _ = try? await ExecutionService.run("/usr/bin/hdiutil", ["detach", path, "-force", "-quiet"]) + } + + StagedFile.remove(directory) + Logger.shared.logInfo("Removed a staged installer left behind by a previous run") + } + } + + // MARK: Staging + + /// Copy an installer out of the user's reach, work out what it is, and decide whether it is allowed. + func stage( + from handle: FileHandle, + fileName: String, + clientUID: uid_t, + clientUserName: String + ) async throws -> InstallerAssessment { + await expireStale() + + // The app hides all of this when it is off, but that is presentation. This is the check that + // decides, and it reads a preference only an administrator can write. + // + // This throws before anything is staged, which means nothing reaches the audit log either. An + // empty `installs.log` and a feature that appears to do nothing are the same symptom, so the + // reason travels back to the client rather than only being implied by the silence. + guard HelperPreferences.enableUserInstalls(forUser: clientUserName) else { + let searched = HelperPreferences.describeSearch(forKey: "EnableUserInstalls", forUser: clientUserName) + Logger.shared.logError("Refusing to stage an installer for \(clientUserName): EnableUserInstalls is not set. \(searched)") + + throw SupportCompanionErrors.helperConnection( + "Installing applications is not enabled by an administrator. The helper read: \(searched)" + ) + } + + let directory = try StagedFile.makeStagingDirectory() + + do { + return try await assess( + from: handle, + fileName: fileName, + clientUID: clientUID, + clientUserName: clientUserName, + directory: directory + ) + } catch { + await cleanUp(directory: directory) + throw error + } + } + + /// The body of `stage`, split out so its caller can clean up after any failure in it. + private func assess( + from handle: FileHandle, + fileName: String, + clientUID: uid_t, + clientUserName: String, + directory: String + ) async throws -> InstallerAssessment { + let fallback = HelperPreferences.installFallback(forUser: clientUserName) + let requiresAuthentication = HelperPreferences.requireAuthenticationForInstalls(forUser: clientUserName) + + let stagedPath = try StagedFile.copy(from: handle, toDirectory: directory, fileName: fileName) + + let facts: InstallerFacts + let payloadPath: String + let kind: InstallerFacts.Kind + + switch (fileName as NSString).pathExtension.lowercased() { + case "dmg": + let attached = try await DiskImageInspector.attach(imageAt: stagedPath, under: directory) + mountsInProgress[directory] = attached + + let digest = try StagedFile.sha256(ofFileAt: stagedPath) + + // Copy the payload into root-owned staging and let the image go before anything is + // inspected. While it stays mounted, everything below is reading something whose author + // can still change it — the image's own digest says nothing about where a link inside it + // points. What is assessed and what is installed have to be one set of bytes. + let copied = try await DiskImageInspector.copyOut( + DiskImageInspector.payload(in: attached.mountPoint), + into: directory + ) + + await DiskImageInspector.detach(attached) + mountsInProgress[directory] = nil + + switch copied { + case .application(let applicationPath): + facts = try await DiskImageInspector.facts( + forApplicationAt: applicationPath, + imageDigest: digest, + fileName: fileName + ) + payloadPath = applicationPath + kind = .diskImage + + case .package(let packagePath): + // An installer package inside a disk image installs like any other package. The digest + // stays that of the image, because that is the file the user downloaded and the one an + // administrator would hash for a strict-mode entry. + var packageFacts = try await PackageInspector.facts( + forStagedPackageAt: packagePath, + fileName: fileName, + expandingInto: (directory as NSString).appendingPathComponent("expanded") + ) + packageFacts.sha256 = digest + + facts = packageFacts + payloadPath = packagePath + kind = .package + } + + case "pkg", "mpkg": + facts = try await PackageInspector.facts( + forStagedPackageAt: stagedPath, + fileName: fileName, + expandingInto: (directory as NSString).appendingPathComponent("expanded") + ) + payloadPath = stagedPath + kind = .package + + default: + throw SupportCompanionErrors.helperConnection("Support Companion can only install .pkg and .dmg files") + } + + let allowlist = HelperPreferences.allowedInstallers(forUser: clientUserName) + let decision = InstallerPolicy.evaluate(facts, against: allowlist) + + guard let entry = decision.entry else { + log(refusalOf: facts, for: clientUserName, reasons: decision.logReasons) + + // Nothing is kept for an installer nobody allowed: there is no token to install it with, + // so the copy and any mounted image would only sit there until they expired. + await cleanUp(directory: directory) + + return InstallerAssessment( + token: nil, + // Shortened only now, after the policy has seen every destination. + facts: facts.shortenedForDisplay(), + isAllowed: false, + matchedEntry: nil, + matchMode: nil, + rejectionReasons: decision.reasons, + fallback: fallback, + requiresAuthentication: requiresAuthentication + ) + } + + let token = UUID().uuidString + + staged[token] = Staged( + directory: directory, + payloadPath: payloadPath, + kind: kind, + mount: mountsInProgress.removeValue(forKey: directory), + facts: facts, + entryName: entry.name, + matchMode: entry.mode, + clientUID: clientUID, + createdAt: Date() + ) + + log(approvalOf: facts, for: clientUserName, entry: entry) + + return InstallerAssessment( + token: token, + facts: facts.shortenedForDisplay(), + isAllowed: true, + matchedEntry: entry.name, + matchMode: entry.mode, + rejectionReasons: [], + fallback: fallback, + requiresAuthentication: requiresAuthentication + ) + } + + // MARK: Installing + + /// Install what was staged under `token`. + func install(token: String, clientUID: uid_t, clientUserName: String) async throws -> String { + await expireStale() + + guard let entry = staged[token] else { + throw SupportCompanionErrors.helperConnection("That installer is no longer ready. Open it again.") + } + + // The token came back from this helper, but only to one connection. Another user logged in at + // the same time must not be able to install something the first one staged. + guard entry.clientUID == clientUID else { + Logger.shared.logError("Refusing to install \(entry.facts.fileName): staged for uid \(entry.clientUID), asked for by uid \(clientUID)") + throw SupportCompanionErrors.helperConnection("That installer was prepared for a different user") + } + + guard HelperPreferences.enableUserInstalls(forUser: clientUserName) else { + throw SupportCompanionErrors.helperConnection("Installing applications from Support Companion is not enabled by an administrator") + } + + guard !isInstalling else { + throw SupportCompanionErrors.helperConnection("Another installation is already running") + } + + isInstalling = true + + // Not a `defer`: the staged copy holds a mounted disk image, and detaching it has to be + // awaited before the directory it is mounted inside can be removed. A token is good for one + // install either way, so it goes whether or not the install worked. + do { + // Checked here rather than during assessment: what is on disk at the destination can + // change between the two, and this is the last moment before root writes anything. + try refuseIfDestinationsAreRedirected(entry.facts.payloadRoots) + + let output: String + + switch entry.kind { + case .package: + output = try await ExecutionService.run("/usr/sbin/installer", ["-pkg", entry.payloadPath, "-target", "/"]) + case .diskImage: + output = try await installApplication(at: entry.payloadPath, approved: entry.facts, pinnedByDigest: entry.matchMode == .strict) + } + + HelperState.appendLine( + "installed user=\(clientUserName) file=\(entry.facts.fileName) entry=\(entry.entryName) version=\(entry.facts.version ?? "?") sha256=\(entry.facts.sha256)", + toLogAt: Self.auditLogPath + ) + + isInstalling = false + await discard(token) + + return output + } catch { + HelperState.appendLine( + "failed user=\(clientUserName) file=\(entry.facts.fileName) entry=\(entry.entryName) error=\(error.localizedDescription)", + toLogAt: Self.auditLogPath + ) + + isInstalling = false + await discard(token) + + throw error + } + } + + /// Copy a verified application into `/Applications`. + /// + /// Nothing of the vendor's runs here — it is a copy — but it is a copy made by root into a + /// directory every account can see, so the name is checked, the previous version is kept until the + /// new one is in place, and the result is verified where it landed before the old one is let go. + private func installApplication(at path: String, approved: InstallerFacts, pinnedByDigest: Bool) async throws -> String { + let name = (path as NSString).lastPathComponent + + guard !name.isEmpty, !name.hasPrefix("."), !name.contains("/"), name.hasSuffix(".app") else { + throw SupportCompanionErrors.helperConnection("'\(name)' is not a name this can install") + } + + let destination = "/Applications/\(name)" + + // The name comes from the disk image, so it decides what gets replaced. Replacing one + // application with a different one that merely happens to be allowed is not an install, it is + // a substitution — and on a shared Mac it changes what somebody else launches. + if FileManager.default.fileExists(atPath: destination) { + // Read without requiring validity: an unsigned or broken incumbent would otherwise report + // no identifier, the comparison would be skipped, and the replacement would go ahead — + // which is the case this check exists for. + let existingIdentifier = DiskImageInspector.identifier(ofApplicationAt: destination) + let incoming = approved.identifiers.first + + guard let existingIdentifier, let incoming else { + throw SupportCompanionErrors.helperConnection( + "\(name) is already installed and could not be identified, so it has been left alone." + ) + } + + guard existingIdentifier == incoming else { + throw SupportCompanionErrors.helperConnection( + "\(name) is already installed and is a different application (\(existingIdentifier)). It has been left alone." + ) + } + } + + // Nothing is moved or copied while the application is running: `ditto` would replace a bundle + // out from under a live process, and if the verification below then fails, the rollback would + // put the old one back underneath something that has been running out of a half-replaced one. + if isRunning(applicationAt: destination) { + throw SupportCompanionErrors.helperConnection( + "\(name.replacingOccurrences(of: ".app", with: "")) is open. Quit it and try again." + ) + } + + // Kept on the same volume so the move is a rename and cannot half-happen. + let previous = "/Applications/.\(name).supportcompanion-previous" + StagedFile.remove(previous) + + let hadPrevious = FileManager.default.fileExists(atPath: destination) + + if hadPrevious { + try FileManager.default.moveItem(atPath: destination, toPath: previous) + } + + do { + _ = try await ExecutionService.run("/usr/bin/ditto", [path, destination]) + + // Ownership follows the disk image otherwise, which is whatever the vendor built it as. + _ = try await ExecutionService.run("/usr/sbin/chown", ["-R", "root:admin", destination]) + + // The signature was checked on the image and is checked again below, so Gatekeeper has + // nothing left to ask the user on first launch. Leaving the attribute on would produce a + // "downloaded from the internet" prompt for something an administrator allowed. + _ = try? await ExecutionService.run("/usr/bin/xattr", ["-d", "-r", "com.apple.quarantine", destination]) + + // Not merely "is it signed". The copy has to be the application that was assessed: + // anything validly signed satisfies `anchor apple generic`, including an application the + // allowlist has never heard of, which would make this check the opposite of a safeguard. + let verified = await DiskImageInspector.signature(ofApplicationAt: destination) + + guard verified.trusted else { + throw SupportCompanionErrors.helperConnection("The copy in /Applications does not verify, so it has been removed") + } + + // `==` alone would be satisfied by both being nil. That is only reachable in strict + // mode, where the digest pins the image — but it is sound because of a rule enforced in + // another file, so it is stated here rather than relied upon quietly. + guard approved.teamID != nil || pinnedByDigest else { + throw SupportCompanionErrors.helperConnection( + "The approved application has no signing team and was not pinned by digest, so the copy cannot be confirmed. It has been removed." + ) + } + + guard verified.teamID == approved.teamID else { + throw SupportCompanionErrors.helperConnection( + "The copy in /Applications is signed by \(verified.teamID ?? "nobody"), not \(approved.teamID ?? "the approved team"). It has been removed." + ) + } + + guard let installedIdentifier = verified.bundleIdentifier, + approved.identifiers.contains(installedIdentifier) else { + throw SupportCompanionErrors.helperConnection( + "The copy in /Applications identifies as \(verified.bundleIdentifier ?? "nothing"), which is not what was approved. It has been removed." + ) + } + } catch { + StagedFile.remove(destination) + + if hadPrevious { + try? FileManager.default.moveItem(atPath: previous, toPath: destination) + } + + throw error + } + + StagedFile.remove(previous) + + return "Installed \(name) in /Applications" + } + + /// Refuse when something already on disk would send the install somewhere else. + /// + /// `installer` writes *through* a symbolic link standing at a destination path — a link at + /// `/Applications/Foo.app` pointing at `/tmp/escape` puts the package's payload in `/tmp/escape`, + /// with no complaint and a successful exit. Nothing in the package says so, so no amount of + /// inspecting it helps. + /// + /// That matters here more than it would elsewhere, because this app also hands out temporary + /// administrator rights: somebody can plant the link while elevated, let the window close, and + /// then use an allowlisted installer to write wherever the link points, as root. + /// + /// `/etc`, `/var` and `/tmp` are links macOS ships with and are left alone. + private func refuseIfDestinationsAreRedirected(_ roots: [String]) throws { + let systemLinks = ["/etc", "/var", "/tmp"] + + for root in roots { + var current = "" + + for component in (root as NSString).pathComponents where component != "/" { + current += "/" + component + + var info = stat() + + // Nothing there yet, so nothing below it either. + guard lstat(current, &info) == 0 else { break } + + guard (info.st_mode & S_IFMT) == S_IFLNK else { continue } + guard !systemLinks.contains(current) else { continue } + + let target = (try? FileManager.default.destinationOfSymbolicLink(atPath: current)) ?? "somewhere else" + + Logger.shared.logError("Refusing to install: \(current) is a link to \(target)") + + throw SupportCompanionErrors.helperConnection( + "\(current) is a link to \(target), so installing would write somewhere other than where this package says. Nothing has been installed." + ) + } + } + } + + /// Whether the application at `path` is running. + /// + /// Asked of the kernel rather than of `pgrep -f`, which takes a POSIX regular expression. The + /// bundle name comes out of a disk image somebody else authored, and escaping it for the wrong + /// flavour of regex fails in the unsafe direction: an escape the matcher does not recognise means + /// no match, which reads as "not running", and the copy goes over a live application. + private func isRunning(applicationAt path: String) -> Bool { + let prefix = path + "/Contents/MacOS/" + + var count = proc_listallpids(nil, 0) + guard count > 0 else { return false } + + // Room for processes started between asking the size and asking for the list. + count += 64 + + var pids = [pid_t](repeating: 0, count: Int(count)) + let bytes = proc_listallpids(&pids, Int32(MemoryLayout.size) * count) + guard bytes > 0 else { return false } + + let found = Int(bytes) / MemoryLayout.size + + for pid in pids.prefix(found) where pid > 0 { + // PROC_PIDPATHINFO_MAXSIZE, which Darwin does not surface to Swift: 4 * MAXPATHLEN. + var buffer = [CChar](repeating: 0, count: 4 * 1024) + + guard proc_pidpath(pid, &buffer, UInt32(buffer.count)) > 0 else { continue } + + if String(cString: buffer).hasPrefix(prefix) { return true } + } + + return false + } + + // MARK: Lifetime + + /// Detach anything mounted under a staging directory, then remove it. + /// + /// In that order: the directory holds the mount point, so removing it first would either fail or + /// leave the image attached with nothing pointing at it. + private func cleanUp(directory: String, mount: DiskImageInspector.Mount? = nil) async { + if let mount = mount ?? mountsInProgress.removeValue(forKey: directory) { + await DiskImageInspector.detach(mount) + } + + StagedFile.remove(directory) + } + + func discard(_ token: String) async { + guard let entry = staged.removeValue(forKey: token) else { return } + await cleanUp(directory: entry.directory, mount: entry.mount) + } + + func discard(_ token: String, clientUID: uid_t) async { + guard let entry = staged[token], entry.clientUID == clientUID else { return } + await discard(token) + } + + private func expireStale() async { + let cutoff = Date().addingTimeInterval(-Self.stagingLifetime) + + for (token, entry) in staged where entry.createdAt < cutoff { + Logger.shared.logDebug("Discarding staged installer \(entry.facts.fileName): nothing decided within \(Int(Self.stagingLifetime))s") + await discard(token) + } + } + + // MARK: Audit + + private func log(approvalOf facts: InstallerFacts, for userName: String, entry: AllowedInstaller) { + HelperState.appendLine( + "allowed user=\(userName) file=\(facts.fileName) kind=\(facts.kind.rawValue) id=\(facts.identifiers.joined(separator: ",")) version=\(facts.version ?? "?") team=\(facts.teamID ?? "?") notarized=\(facts.notarized) sha256=\(facts.sha256) paths=\(facts.payloadRoots.joined(separator: ",")) entry=\(entry.name) mode=\(entry.mode.rawValue)", + toLogAt: Self.auditLogPath + ) + } + + private func log(refusalOf facts: InstallerFacts, for userName: String, reasons: [String]) { + HelperState.appendLine( + "refused user=\(userName) file=\(facts.fileName) kind=\(facts.kind.rawValue) id=\(facts.identifiers.joined(separator: ",")) version=\(facts.version ?? "?") team=\(facts.teamID ?? "?") sha256=\(facts.sha256) paths=\(facts.payloadRoots.joined(separator: ",")) reasons=\(reasons.joined(separator: "; "))", + toLogAt: Self.auditLogPath + ) + } +} diff --git a/Helper/InstallStaging.swift b/Helper/InstallStaging.swift new file mode 100644 index 0000000..cadfb95 --- /dev/null +++ b/Helper/InstallStaging.swift @@ -0,0 +1,184 @@ +// +// InstallStaging.swift +// com.github.macadmins.SupportCompanion.helper +// + +import CryptoKit +import Foundation + +// MARK: - Helper state + +/// The root-owned directory the helper keeps its own state in. +/// +/// `/Library/Application Support` is no good for any of it: the app creates its folder there as the +/// logged-in user, and whoever owns a directory can replace what is in it. `/var/db` is root-owned and +/// is where daemon state belongs. `ElevationCoordinator` keeps its deadline and audit log under the +/// same directory for the same reason. +enum HelperState { + + static let directory = "/var/db/com.github.macadmins.SupportCompanion" + + /// Create a directory that only root can read or write, including every level above it. + /// + /// Each level is created separately rather than with `withIntermediateDirectories`, so the mode is + /// applied to all of them and not only to the last one. + static func makeDirectory(at path: String, permissions: Int16 = 0o700) throws { + let components = (path as NSString).pathComponents + var current = "/" + + for component in components where component != "/" { + current = (current as NSString).appendingPathComponent(component) + + if FileManager.default.fileExists(atPath: current) { continue } + + try FileManager.default.createDirectory( + atPath: current, + withIntermediateDirectories: false, + attributes: [.posixPermissions: permissions, .ownerAccountID: 0, .groupOwnerAccountID: 0] + ) + } + } + + /// Append one line to a root-owned log, flattening anything that came from outside first. + /// + /// One line per event is the only structure these logs have, so text containing a newline would let + /// whoever supplied it write entries of their choosing into a root-owned record. + static func appendLine(_ line: String, toLogAt path: String) { + try? makeDirectory(at: directory) + + let flattened = line.unicodeScalars + .map { CharacterSet.controlCharacters.contains($0) ? " " : Character($0) } + .reduce(into: "") { $0.append($1) } + .trimmingCharacters(in: .whitespacesAndNewlines) + .prefix(2048) + + let stamped = "\(ISO8601DateFormatter().string(from: Date())) \(flattened)\n" + guard let data = stamped.data(using: .utf8) else { return } + + if let handle = FileHandle(forWritingAtPath: path) { + defer { try? handle.close() } + try? handle.seekToEnd() + try? handle.write(contentsOf: data) + } else { + FileManager.default.createFile( + atPath: path, + contents: data, + attributes: [.posixPermissions: 0o600, .ownerAccountID: 0] + ) + } + } +} + +// MARK: - Staged file + +/// Copies an installer out of the user's reach, and measures it once it is there. +enum StagedFile { + + /// The staging directories, one per installer being considered. + static let root = HelperState.directory + "/installs" + + /// The largest installer the helper will copy. + /// + /// Staging means a second copy on disk, and a standard user handing the helper a very large file is + /// otherwise a way to fill the boot volume as root. + static let maximumBytes: Int64 = 8 * 1024 * 1024 * 1024 + + private static let chunkSize = 4 * 1024 * 1024 + + /// A private directory for one candidate installer. + static func makeStagingDirectory() throws -> String { + let path = (root as NSString).appendingPathComponent(UUID().uuidString) + try HelperState.makeDirectory(at: path) + return path + } + + static func remove(_ path: String) { + try? FileManager.default.removeItem(atPath: path) + } + + /// Copy what the descriptor refers to into the staging directory. + /// + /// The app opens the file and passes the descriptor rather than the path, for two reasons. The app + /// runs as the user, so it can only open what the user could already read — a path would let a + /// standard user have root read and measure files they have no access to. And a descriptor names + /// the file itself rather than a name that can be repointed, so the copy is of what the user + /// actually double-clicked. + static func copy(from handle: FileHandle, toDirectory directory: String, fileName: String) throws -> String { + var info = stat() + + guard fstat(handle.fileDescriptor, &info) == 0 else { + throw SupportCompanionErrors.helperConnection("Unable to read the installer's file information") + } + + guard (info.st_mode & S_IFMT) == S_IFREG else { + throw SupportCompanionErrors.helperConnection("The installer is not a regular file") + } + + guard info.st_size <= maximumBytes else { + throw SupportCompanionErrors.helperConnection( + "The installer is larger than \(maximumBytes / (1024 * 1024 * 1024))GB" + ) + } + + guard try hasRoom(for: info.st_size) else { + throw SupportCompanionErrors.helperConnection("There is not enough free space to check this installer") + } + + // Only the extension is taken from the name the client supplied; the rest is ours. A name is + // the one part of this a client chooses freely, and it is about to become a path we run + // commands against. + let fileExtension = (fileName as NSString).pathExtension.lowercased() + let safeExtension = fileExtension.allSatisfy(\.isLetter) && !fileExtension.isEmpty ? fileExtension : "pkg" + let destination = (directory as NSString).appendingPathComponent("installer.\(safeExtension)") + + guard FileManager.default.createFile( + atPath: destination, + contents: nil, + attributes: [.posixPermissions: 0o600, .ownerAccountID: 0] + ) else { + throw SupportCompanionErrors.helperConnection("Unable to create a staging file for the installer") + } + + guard let output = FileHandle(forWritingAtPath: destination) else { + throw SupportCompanionErrors.helperConnection("Unable to open the staging file for the installer") + } + + defer { try? output.close() } + + try handle.seek(toOffset: 0) + + while true { + guard let chunk = try handle.read(upToCount: chunkSize), !chunk.isEmpty else { break } + try output.write(contentsOf: chunk) + } + + return destination + } + + private static func hasRoom(for bytes: Int64) throws -> Bool { + let values = try URL(fileURLWithPath: "/var/db").resourceValues(forKeys: [.volumeAvailableCapacityKey]) + + guard let available = values.volumeAvailableCapacity else { return true } + + // Twice over: the staged copy, and then whatever it installs. + return Int64(available) > bytes * 2 + } + + /// The SHA-256 of a staged file, read in chunks so a large installer is not held in memory. + static func sha256(ofFileAt path: String) throws -> String { + guard let handle = FileHandle(forReadingAtPath: path) else { + throw SupportCompanionErrors.helperConnection("Unable to read the staged installer") + } + + defer { try? handle.close() } + + var hasher = SHA256() + + while true { + guard let chunk = try handle.read(upToCount: chunkSize), !chunk.isEmpty else { break } + hasher.update(data: chunk) + } + + return hasher.finalize().map { String(format: "%02x", $0) }.joined() + } +} diff --git a/Helper/InstallerPolicy.swift b/Helper/InstallerPolicy.swift new file mode 100644 index 0000000..d28778f --- /dev/null +++ b/Helper/InstallerPolicy.swift @@ -0,0 +1,761 @@ +// +// InstallerPolicy.swift +// SupportCompanion +// +// Shared by the app and the privileged helper. +// + +import Foundation + +// MARK: - Match mode + +/// How an allowlist entry recognises the installer the administrator meant. +public enum InstallerMatchMode: String, Codable, Sendable { + /// The file's SHA-256 must be exactly the one in the profile. Pins one build; survives no update. + case strict + /// The signature must be the organisation's: a Team ID, and normally an identifier as well. + case signature +} + +// MARK: - Allowlist entry + +/// One application an administrator has allowed a standard user to install. +/// +/// Parsed from the `AllowedInstallers` preference, which is only ever read from a root-owned file. See +/// `HelperPreferences`. +public struct AllowedInstaller: Sendable { + + public let name: String + + /// The Developer ID team the installer must be signed by. Required in `.signature` mode. + public let teamID: String? + + /// Package identifiers this entry accepts, for a `.pkg`. + public let packageIdentifiers: [String] + + /// Bundle identifiers this entry accepts, for the app inside a `.dmg`. + public let bundleIdentifiers: [String] + + /// The exact digest of the installer file, for `.strict` mode. + public let sha256: String? + + /// The SHA-256 of the signing certificate itself, as an optional extra pin. + /// + /// A Team ID is read out of a certificate's subject, which is a human-readable string; this is the + /// certificate. Stricter than `TeamID` and unambiguous in a way a name is not — at the cost of + /// needing an update whenever the vendor renews, which is why it is never required. + public let leafCertificateSHA256: String? + + /// Refuse anything older than this, so a signed-but-vulnerable build cannot be installed instead. + public let minimumVersion: String? + + public let requireNotarized: Bool + + /// Whether a package carrying pre/postinstall scripts is acceptable. + /// + /// Off by default, and the single most useful restriction here: a scriptless package whose payload + /// lands in `/Applications` is a file copy, while one with a postinstall script is arbitrary code as + /// root on every future build the vendor signs. + public let allowScripts: Bool + + /// Where this installer is allowed to write, as absolute path prefixes. + /// + /// `nil` leaves it unrestricted, which is the default because enumerating paths for every app is + /// more than most administrators will do. Setting it is what bounds the damage when an entry is + /// written loosely: an allowlisted package is otherwise free to drop a LaunchDaemon while + /// installing the app that was actually approved. + public let allowedPayloadPrefixes: [String]? + + /// Let this installer write anywhere, including the places `InstallerPolicy.protectedPrefixes` + /// otherwise keeps back. + /// + /// Its own key rather than `AllowedPayloadPrefixes: ["/"]`, so the most dangerous setting is one + /// somebody has to mean, not one they can reach by typing a prefix wrong. + public let allowUnrestrictedPayload: Bool + + /// Accept anything signed by `teamID`, whatever it identifies itself as. + /// + /// Spelled out rather than inferred from a missing identifier, because the two are very different + /// intentions and only one of them should be reachable by leaving a key out of a profile by mistake. + /// This is a vendor allowlist: it covers every installer that team will ever sign, not one app. + public let allowAnyIdentifier: Bool + + public var mode: InstallerMatchMode { sha256 == nil ? .signature : .strict } + + public init( + name: String, + teamID: String?, + packageIdentifiers: [String], + bundleIdentifiers: [String], + sha256: String?, + leafCertificateSHA256: String? = nil, + minimumVersion: String?, + requireNotarized: Bool, + allowScripts: Bool, + allowAnyIdentifier: Bool, + allowedPayloadPrefixes: [String]? = nil, + allowUnrestrictedPayload: Bool = false + ) { + self.name = name + self.teamID = teamID + self.packageIdentifiers = packageIdentifiers + self.bundleIdentifiers = bundleIdentifiers + self.sha256 = sha256 + self.leafCertificateSHA256 = leafCertificateSHA256 + self.minimumVersion = minimumVersion + self.requireNotarized = requireNotarized + self.allowScripts = allowScripts + self.allowAnyIdentifier = allowAnyIdentifier + self.allowedPayloadPrefixes = allowedPayloadPrefixes + self.allowUnrestrictedPayload = allowUnrestrictedPayload + } +} + +// MARK: - Parsing + +extension AllowedInstaller { + + /// Build an entry from one dictionary in the `AllowedInstallers` array. + /// + /// Returns `nil` for an entry that cannot decide anything, rather than one that decides too much: + /// an entry with no `SHA256` and no `TeamID` would otherwise match on nothing at all. + public static func make(from dictionary: [String: Any]) -> ( + entry: AllowedInstaller?, problem: String? + ) { + let name = (dictionary["Name"] as? String)?.trimmed ?? "" + let label = name.isEmpty ? "" : name + + guard !name.isEmpty else { + return (nil, "an entry has no Name") + } + + let sha256 = (dictionary["SHA256"] as? String)?.trimmed.lowercased() + let teamID = (dictionary["TeamID"] as? String)?.trimmed + + if let sha256, sha256.count != 64 || !sha256.allSatisfy(\.isHexDigit) { + return (nil, "'\(label)' has a SHA256 that is not a 64-character hex digest") + } + + let leafCertificateSHA256 = (dictionary["LeafCertificateSHA256"] as? String)? + .replacingOccurrences(of: " ", with: "") + .trimmed + .lowercased() + + if let leafCertificateSHA256, + leafCertificateSHA256.count != 64 || !leafCertificateSHA256.allSatisfy(\.isHexDigit) { + return (nil, "'\(label)' has a LeafCertificateSHA256 that is not a 64-character hex digest") + } + + let packageIdentifiers = stringList(dictionary["PackageIdentifier"]) + let bundleIdentifiers = stringList(dictionary["BundleIdentifier"]) + let allowAnyIdentifier = boolean(dictionary["AllowAnyIdentifier"], default: false) + + if sha256 == nil { + guard let teamID, !teamID.isEmpty else { + return (nil, "'\(label)' has neither a SHA256 nor a TeamID, so it can never match") + } + + guard allowAnyIdentifier || !packageIdentifiers.isEmpty || !bundleIdentifiers.isEmpty + else { + return ( + nil, + "'\(label)' has a TeamID but no PackageIdentifier or BundleIdentifier. Add one, or set AllowAnyIdentifier to allow everything that team signs" + ) + } + } + + return ( + AllowedInstaller( + name: name, + teamID: teamID?.isEmpty == true ? nil : teamID, + packageIdentifiers: packageIdentifiers, + bundleIdentifiers: bundleIdentifiers, + sha256: sha256, + leafCertificateSHA256: leafCertificateSHA256, + minimumVersion: (dictionary["MinimumVersion"] as? String)?.trimmed, + requireNotarized: boolean(dictionary["RequireNotarized"], default: true), + allowScripts: boolean(dictionary["AllowScripts"], default: false), + allowAnyIdentifier: allowAnyIdentifier, + allowedPayloadPrefixes: dictionary["AllowedPayloadPrefixes"].map(stringList), + allowUnrestrictedPayload: boolean(dictionary["AllowUnrestrictedPayload"], default: false) + ), + nil + ) + } + + private static func stringList(_ value: Any?) -> [String] { + if let single = value as? String { + let trimmed = single.trimmed + return trimmed.isEmpty ? [] : [trimmed] + } + + if let many = value as? [String] { + return many.map(\.trimmed).filter { !$0.isEmpty } + } + + return [] + } + + private static func boolean(_ value: Any?, default defaultValue: Bool) -> Bool { + (value as? Bool) ?? (value as? NSNumber)?.boolValue ?? defaultValue + } +} + +// MARK: - Facts + +/// What the helper found in the installer it staged, before any policy is applied. +public struct InstallerFacts: Codable, Sendable, Equatable { + + public enum Kind: String, Codable, Sendable { + case package + case diskImage + } + + public var kind: Kind + public var fileName: String + public var sha256: String + + /// The Team ID from the signing certificate's organisational unit. + public var teamID: String? + + /// The leaf certificate's common name, for display: "Developer ID Installer: Google, Inc. (EQHXZ8M8AV)". + public var authority: String? + + /// The SHA-256 of the signing certificate, when one could be read. + public var leafCertificateSHA256: String? + + /// Whether the signature is present and chains to a certificate the system trusts. + public var signatureTrusted: Bool + + /// Whether Gatekeeper reports the installer as notarized. + public var notarized: Bool + + /// Package identifiers for a `.pkg`, or the app's bundle identifier for a `.dmg`. + public var identifiers: [String] + + public var displayName: String? + public var version: String? + + /// Whether any component of a package carries pre/postinstall scripts. + public var hasScripts: Bool + + /// What kind of scripts were found, for the sheet and the log. + public var scriptSummary: String? + + /// The most destinations described to the client, purely so the window has something readable. + /// + /// Separate from the limit `PackageInspector` enforces, and applied only after the policy has + /// seen every one of them: a shortened list is a display convenience, never the thing a decision + /// was made from. + public static let displayedPayloadRoots = 12 + + /// The places this installer writes, shortened to the shallowest path that contains each one. + /// + /// Derived by truncating every file and symlink in the package's bill of materials, so checking + /// these covers everything underneath them. For a disk image it is simply where the application + /// lands. + public var payloadRoots: [String] + + /// Whether a distribution package pulls a component from a URL at install time. + /// + /// Always disqualifying, and not configurable: those bytes arrive after everything here has run, so + /// nothing about them was ever checked. + public var hasRemoteReferences: Bool + + /// Decoded field by field, with a default for everything the sender might not have sent. + /// + /// The app and the helper are deployed separately — with `SkipHelperInstall` the helper arrives + /// from an MDM on its own schedule — so at any moment one of them may be a version behind. A + /// synthesised decoder makes every added field a breaking change across that gap, and the failure + /// surfaces to the user as "The data couldn't be read because it is missing", which tells nobody + /// anything. Every default here is the cautious reading: absent never means more permissive. + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + kind = try container.decode(Kind.self, forKey: .kind) + fileName = try container.decodeIfPresent(String.self, forKey: .fileName) ?? "" + sha256 = try container.decodeIfPresent(String.self, forKey: .sha256) ?? "" + teamID = try container.decodeIfPresent(String.self, forKey: .teamID) + authority = try container.decodeIfPresent(String.self, forKey: .authority) + leafCertificateSHA256 = try container.decodeIfPresent(String.self, forKey: .leafCertificateSHA256) + signatureTrusted = + try container.decodeIfPresent(Bool.self, forKey: .signatureTrusted) ?? false + notarized = try container.decodeIfPresent(Bool.self, forKey: .notarized) ?? false + identifiers = try container.decodeIfPresent([String].self, forKey: .identifiers) ?? [] + displayName = try container.decodeIfPresent(String.self, forKey: .displayName) + version = try container.decodeIfPresent(String.self, forKey: .version) + hasScripts = try container.decodeIfPresent(Bool.self, forKey: .hasScripts) ?? false + scriptSummary = try container.decodeIfPresent(String.self, forKey: .scriptSummary) + hasRemoteReferences = + try container.decodeIfPresent(Bool.self, forKey: .hasRemoteReferences) ?? false + payloadRoots = try container.decodeIfPresent([String].self, forKey: .payloadRoots) ?? [] + } + + public init( + kind: Kind, + fileName: String, + sha256: String, + teamID: String? = nil, + authority: String? = nil, + leafCertificateSHA256: String? = nil, + signatureTrusted: Bool = false, + notarized: Bool = false, + identifiers: [String] = [], + displayName: String? = nil, + version: String? = nil, + hasScripts: Bool = false, + scriptSummary: String? = nil, + hasRemoteReferences: Bool = false, + payloadRoots: [String] = [] + ) { + self.kind = kind + self.fileName = fileName + self.sha256 = sha256 + self.teamID = teamID + self.authority = authority + self.leafCertificateSHA256 = leafCertificateSHA256 + self.signatureTrusted = signatureTrusted + self.notarized = notarized + self.identifiers = identifiers + self.displayName = displayName + self.version = version + self.hasScripts = hasScripts + self.scriptSummary = scriptSummary + self.hasRemoteReferences = hasRemoteReferences + self.payloadRoots = payloadRoots + } +} + +public extension InstallerFacts { + + /// A copy safe to hand to the client, with the destination list shortened for the window. + /// + /// Called only once the policy has run against the full set. + func shortenedForDisplay() -> InstallerFacts { + guard payloadRoots.count > InstallerFacts.displayedPayloadRoots else { return self } + + var copy = self + let shown = payloadRoots.prefix(InstallerFacts.displayedPayloadRoots) + copy.payloadRoots = shown + ["and \(payloadRoots.count - shown.count) more"] + + return copy + } +} + +// MARK: - Assessment + +/// What the helper decided about a staged installer, and what the app may do about it. +/// +/// Crosses the connection as JSON so the app never has to parse an installer or read the allowlist to +/// draw its sheet. The app's own view of policy decides nothing; this is the decision. +public struct InstallerAssessment: Codable, Sendable, Equatable { + + /// What the app should offer when the installer is not allowed. + public enum Fallback: String, Codable, Sendable { + /// Hand the file to Installer.app or Finder, exactly as a double-click would behave without us. + case installer + /// Offer the existing time-limited elevation flow instead. + case elevate + /// Offer nothing. + case none + } + + /// Names the staged copy this assessment was made from. Present only when the install may proceed. + public var token: String? + + public var facts: InstallerFacts + public var isAllowed: Bool + + /// The `Name` of the allowlist entry that matched. + public var matchedEntry: String? + public var matchMode: InstallerMatchMode? + + /// Why it did not match, in the admin's terms, for the sheet and the log. + public var rejectionReasons: [String] + + public var fallback: Fallback + + /// Whether the app must authenticate the user before asking for the install. + public var requiresAuthentication: Bool + + /// As with `InstallerFacts`, tolerant of a sender that is a version behind. An assessment missing + /// its verdict decodes as refused, and one missing its authentication setting as requiring it. + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + token = try container.decodeIfPresent(String.self, forKey: .token) + facts = try container.decode(InstallerFacts.self, forKey: .facts) + isAllowed = try container.decodeIfPresent(Bool.self, forKey: .isAllowed) ?? false + matchedEntry = try container.decodeIfPresent(String.self, forKey: .matchedEntry) + matchMode = try container.decodeIfPresent(InstallerMatchMode.self, forKey: .matchMode) + rejectionReasons = + try container.decodeIfPresent([String].self, forKey: .rejectionReasons) ?? [] + fallback = try container.decodeIfPresent(Fallback.self, forKey: .fallback) ?? .installer + requiresAuthentication = + try container.decodeIfPresent(Bool.self, forKey: .requiresAuthentication) ?? true + } + + public init( + token: String?, + facts: InstallerFacts, + isAllowed: Bool, + matchedEntry: String?, + matchMode: InstallerMatchMode?, + rejectionReasons: [String], + fallback: Fallback, + requiresAuthentication: Bool + ) { + self.token = token + self.facts = facts + self.isAllowed = isAllowed + self.matchedEntry = matchedEntry + self.matchMode = matchMode + self.rejectionReasons = rejectionReasons + self.fallback = fallback + self.requiresAuthentication = requiresAuthentication + } +} + +// MARK: - JSON + +extension InstallerAssessment { + + public func jsonString() throws -> String { + let data = try JSONEncoder().encode(self) + + guard let json = String(data: data, encoding: .utf8) else { + throw SupportCompanionErrors.invalidStringConversion + } + + return json + } + + public static func make(fromJSON json: String) throws -> InstallerAssessment { + guard let data = json.data(using: .utf8) else { + throw SupportCompanionErrors.invalidStringConversion + } + + return try JSONDecoder().decode(InstallerAssessment.self, from: data) + } +} + +// MARK: - Matching + +public enum InstallerPolicy { + + /// Decide whether the facts satisfy any entry in the allowlist. + /// + /// Every entry is tried, and the one that got furthest supplies the reasons when none of them + /// matched — "Chrome is allowed, but only from version 141" is worth saying, while "no entry named + /// this" is not, when an entry plainly meant this app. + /// - Returns: the entry that allowed it, the reasons safe to show the person at the Mac, and the + /// fuller reasons for the root-owned log. + /// + /// The two sets of reasons differ on purpose. What an administrator allows elsewhere is their + /// business, not something to list in a window because somebody opened the wrong installer, so the + /// user's copy never names another entry or what it expected. The log is root-owned and is where + /// the detail belongs. + public static func evaluate( + _ facts: InstallerFacts, + against allowlist: [AllowedInstaller] + ) -> (entry: AllowedInstaller?, reasons: [String], logReasons: [String]) { + + guard !allowlist.isEmpty else { + return (nil, [unlisted], ["No applications have been allowed by an administrator"]) + } + + var closest: (entry: AllowedInstaller, reasons: [String])? + + for entry in allowlist { + let reasons = disqualifications(of: facts, for: entry) + + if reasons.isEmpty { + return (entry, [], []) + } + + // Only entries that are recognisably about *this* installer may explain it. Without this, + // the single entry an administrator has configured is always the "closest" one, and a user + // installing something unrelated is told their package is the wrong version of Firefox. + guard isAbout(facts, entry: entry) else { continue } + + // Fewer objections means the entry was more nearly about this installer. Ties keep the + // first, so the order in the profile decides what the user is told. + if closest == nil || reasons.count < closest!.reasons.count { + closest = (entry, reasons) + } + } + + guard let closest else { + return (nil, [unlisted], [unlistedReason(for: facts)]) + } + + // The entry is about this installer, so its objections describe the file in front of the user + // and can be shown as they are. Only the entry's name is dropped, which tells them nothing + // they can act on and names a rule that is not theirs to see. + return ( + nil, + closest.reasons.map { $0.prefix(1).capitalized + $0.dropFirst() }, + closest.reasons.map { "\(closest.entry.name): \($0)" } + ) + } + + /// What the user is told when nothing on the list was written for this installer. + public static let unlisted = + "This installer is not on your organisation's list of approved software" + + /// Whether an entry is plainly meant for this installer, whatever else it objects to. + /// + /// The test is identity alone — the digest in strict mode, an identifier in signature mode. An + /// entry that matches neither is about some other application, and its objections say nothing + /// useful about this one. + private static func isAbout(_ facts: InstallerFacts, entry: AllowedInstaller) -> Bool { + switch entry.mode { + case .strict: + return facts.sha256.caseInsensitiveCompare(entry.sha256 ?? "") == .orderedSame + + case .signature: + if entry.allowAnyIdentifier { + return entry.teamID != nil && entry.teamID == facts.teamID + } + + let accepted = + facts.kind == .package ? entry.packageIdentifiers : entry.bundleIdentifiers + return accepted.contains(where: facts.identifiers.contains) + } + } + + /// What to say about an installer no entry was written for. + /// + /// Names what the installer says it is, because that is what has to go into a profile for it to be + /// allowed — the person reading this is either forwarding it to an administrator or is one. + private static func unlistedReason(for facts: InstallerFacts) -> String { + var identity: [String] = [] + + if let first = facts.identifiers.first { + identity.append(first) + } + + if let team = facts.teamID { + identity.append("team \(team)") + } + + guard !identity.isEmpty else { + return "No entry on your organisation's list covers this installer" + } + + return + "No entry on your organisation's list covers this installer (\(identity.joined(separator: ", ")))" + } + + /// Everything about `facts` that stops `entry` from allowing it. Empty means allowed. + private static func disqualifications(of facts: InstallerFacts, for entry: AllowedInstaller) + -> [String] + { + var reasons: [String] = [] + + switch entry.mode { + case .strict: + if facts.sha256.caseInsensitiveCompare(entry.sha256 ?? "") != .orderedSame { + reasons.append("this file's SHA-256 is not the one allowed") + } + + case .signature: + if !facts.signatureTrusted { + reasons.append("it is not signed by a certificate the system trusts") + } + + if let required = entry.teamID { + if let found = facts.teamID { + if found != required { + reasons.append("it is signed by team \(found), not \(required)") + } + } else { + reasons.append("no Team ID could be read from its signature") + } + } + + if !entry.allowAnyIdentifier { + let accepted = + facts.kind == .package ? entry.packageIdentifiers : entry.bundleIdentifiers + + if accepted.isEmpty { + reasons.append( + facts.kind == .package + ? "no PackageIdentifier is configured for this kind of installer" + : "no BundleIdentifier is configured for this kind of installer" + ) + } else if facts.identifiers.isEmpty { + reasons.append("no identifier could be read from it") + } else { + // Every identifier, not merely one of them. A distribution package installs all of + // its components, so accepting it because one component was named would let the + // others in unexamined — which is how an approved application arrives with a + // LaunchDaemon nobody asked about. + let uncovered = facts.identifiers.filter { !accepted.contains($0) } + + if !uncovered.isEmpty { + reasons.append( + "it also installs \(uncovered.joined(separator: ", ")), which this entry does not allow" + ) + } + } + } + } + + // Applies in both modes. A digest of the file pins these exact bytes; this pins who signed + // them, which keeps holding as the vendor ships new versions. + if let required = entry.leafCertificateSHA256 { + if let found = facts.leafCertificateSHA256 { + if found.caseInsensitiveCompare(required) != .orderedSame { + reasons.append("it is signed by a different certificate than the one allowed") + } + } else { + reasons.append("no signing certificate could be read from it") + } + } + + // Applies in both modes: a digest pins the bytes, but the administrator still said notarized. + if entry.requireNotarized && !facts.notarized { + reasons.append("it is not notarized") + } + + if facts.kind == .package && facts.hasScripts && !entry.allowScripts { + let what = facts.scriptSummary ?? "install scripts that run as root" + reasons.append("it carries \(what), which this entry does not allow") + } + + if facts.hasRemoteReferences { + reasons.append( + "it downloads further packages while installing, which cannot be checked in advance" + ) + } + + // Applies in both modes: a digest pins which bytes arrive, not where they end up. + if !entry.allowUnrestrictedPayload { + if let prefixes = entry.allowedPayloadPrefixes, !prefixes.isEmpty { + let outside = facts.payloadRoots.filter { root in + !prefixes.contains { isPath(root, under: $0) } + } + + if !outside.isEmpty { + reasons.append( + "it writes to \(outside.prefix(4).joined(separator: ", ")), which this entry does not allow" + ) + } + } else { + // Nothing configured means the usual places an application goes — not anywhere at + // all. Checked in both directions because `payloadRoots` are truncated towards the + // root, so a reported root can sit *above* a protected prefix as easily as inside it. + let blocked = facts.payloadRoots.filter { root in + protectedPrefixes.contains { isPath(root, under: $0) || isPath($0, under: root) } + } + + if !blocked.isEmpty { + reasons.append( + "it writes to \(blocked.prefix(4).joined(separator: ", ")), which needs AllowedPayloadPrefixes to permit it" + ) + } + } + } + + if let minimum = entry.minimumVersion { + if let version = facts.version { + if isVersion(version, olderThan: minimum) { + reasons.append( + "version \(version) is older than the allowed minimum \(minimum)") + } + } else { + reasons.append("no version could be read, and a minimum of \(minimum) is required") + } + } + + return reasons + } + + /// The few places an installer may not write unless an administrator names them explicitly. + /// + /// Deliberately short, and not a general hardening list. Once an entry has said "this vendor, + /// this identifier, notarized", a package that installs a launch daemon or a command in + /// `/usr/local/bin` is not misbehaving — that is what Docker, a VPN client and most developer + /// tooling *are*. Refusing those by default would mean the feature did not work for a large part + /// of real software, and the answer administrators would reach for is `AllowUnrestrictedPayload` + /// on every entry, which is worse than never having checked. + /// + /// What is left is a different kind of thing: not "software doing something privileged" but + /// "software taking over the thing that decides what software may do". None of these is a + /// destination an application installer has any reason to write to, and each one turns a single + /// install into lasting control of the mechanism meant to be governing it. + /// + /// An administrator who wants the stricter posture still has it, by naming prefixes on the entry + /// — opting in to tight, rather than opting out of unusable. + public static let protectedPrefixes = [ + // Deciding what may be installed, and who may hold administrator rights. + "/Library/Managed Preferences", + "/Library/Preferences", + "/var/db/com.github.macadmins.SupportCompanion", + "/private/var/db/com.github.macadmins.SupportCompanion", + + // Becoming something privileged, or standing in front of one. + "/Library/PrivilegedHelperTools", + "/Library/Security", + "/Library/ScriptingAdditions", + + // Local accounts: writing here is how an administrator account appears out of nothing. + "/var/db/dslocal", + "/private/var/db/dslocal", + + // Root, directly. + "/etc/sudoers", + "/etc/sudoers.d", + "/etc/pam.d", + "/etc/ssh", + "/private/etc/sudoers", + "/private/etc/sudoers.d", + "/private/etc/pam.d", + "/private/etc/ssh", + "/var/root", + "/private/var/root", + ] + + /// Whether `path` is the same as `prefix` or sits inside it. + /// + /// Compared at path-component boundaries, so `/Applications/Firefox.app` is not treated as being + /// inside `/Applications/Fire`. + static func isPath(_ path: String, under prefix: String) -> Bool { + let path = (path as NSString).standardizingPath + let prefix = (prefix as NSString).standardizingPath + + if path == prefix { return true } + + return path.hasPrefix(prefix.hasSuffix("/") ? prefix : prefix + "/") + } + + /// Compare two dotted versions numerically, so 10.2 is newer than 10.10 is not mistaken for true. + static func isVersion(_ version: String, olderThan minimum: String) -> Bool { + let left = components(of: version) + let right = components(of: minimum) + + for index in 0.. [Int] { + version + .split(whereSeparator: { !$0.isNumber }) + .map { Int($0) ?? 0 } + } +} + +// MARK: - Convenience + +// Kept file-private: this file is compiled into both the app and the helper, and a name this general +// on String would be waiting to collide with one of them. +extension String { + fileprivate var trimmed: String { trimmingCharacters(in: .whitespacesAndNewlines) } +} diff --git a/Helper/PackageInspector.swift b/Helper/PackageInspector.swift new file mode 100644 index 0000000..bd06125 --- /dev/null +++ b/Helper/PackageInspector.swift @@ -0,0 +1,503 @@ +// +// PackageInspector.swift +// com.github.macadmins.SupportCompanion.helper +// + +import Foundation + +/// Reads what an installer package claims to be, from a copy only root can write. +/// +/// Every function here takes a path inside the staging directory, never the path the user chose. The +/// order matters: the bytes are copied first, and only the copy is ever inspected or installed, so +/// there is no window in which the file that was judged and the file that gets installed can differ. +enum PackageInspector { + + // MARK: Signing + + /// What `spctl` and `pkgutil` between them say about a package's signature. + struct Signature { + var trusted = false + var notarized = false + var teamID: String? + var authority: String? + var leafCertificateSHA256: String? + } + + /// Ask Gatekeeper to assess the package as an installation. + /// + /// This is `SecAssessmentCreate` with `kSecAssessmentOperationTypeInstall` behind a command line, + /// which reports the authority and the source in one place rather than a nested dictionary. + /// + /// It answers to the machine's Gatekeeper configuration, so on a Mac where assessments have been + /// turned off it approves everything. That is why the certificate chain is checked separately below + /// and why `notarized` is reported as false when assessments are disabled: a policy that says + /// "notarized only" must not quietly become "anything" because somebody ran `spctl --global-disable`. + private static func gatekeeperAssessment(of path: String) async -> (accepted: Bool, source: String?, origin: String?) { + guard await assessmentsAreEnabled() else { + Logger.shared.logError("Gatekeeper assessments are disabled on this Mac; treating \(path) as unassessed") + return (false, nil, nil) + } + + let result = try? await ProcessRunner.run( + executableURL: URL(fileURLWithPath: "/usr/sbin/spctl"), + arguments: ["--assess", "--type", "install", "-vv", path] + ) + + guard let result else { return (false, nil, nil) } + + // spctl writes its detail to standard error, including when it accepts. + let output = (String(data: result.output, encoding: .utf8) ?? "") + + (String(data: result.error, encoding: .utf8) ?? "") + + return ( + result.status == 0, + value(ofKey: "source", in: output), + value(ofKey: "origin", in: output) + ) + } + + private static func assessmentsAreEnabled() async -> Bool { + let result = try? await ProcessRunner.run( + executableURL: URL(fileURLWithPath: "/usr/sbin/spctl"), + arguments: ["--status"] + ) + + guard let result else { return false } + + let output = (String(data: result.output, encoding: .utf8) ?? "") + + (String(data: result.error, encoding: .utf8) ?? "") + + return output.contains("assessments enabled") + } + + private static func value(ofKey key: String, in output: String) -> String? { + output + .split(separator: "\n") + .first { $0.hasPrefix("\(key)=") } + .map { String($0.dropFirst(key.count + 1)).trimmingCharacters(in: .whitespaces) } + } + + /// The `pkgutil --check-signature` statuses that count as a real, trusted signature. + /// + /// Listing what is accepted rather than what is rejected means a status this was never taught + /// about fails closed. The first is what an ordinary Developer ID installer package reports and is + /// the common case; note that it is specifically *for distribution*, since the same sentence ends + /// "(Development)" for a development certificate, which is not something to install from. + private static let acceptedStatuses = [ + "signed by a developer certificate issued by Apple for distribution", + "signed by a certificate trusted by", + "signed Apple Software", + ] + + /// Check the package's own signature independently of Gatekeeper, and read the signing team. + /// + /// The Team ID comes from the leaf certificate's common name, which `pkgutil` prints as + /// `Developer ID Installer: Example Ltd. (ABCDE12345)`. Matching on the parenthesised team rather + /// than the name is deliberate: the name is chosen by the developer and is not unique. + private static func certificateChain(of path: String) async -> (trusted: Bool, teamID: String?, authority: String?, leaf: String?) { + let result = try? await ProcessRunner.run( + executableURL: URL(fileURLWithPath: "/usr/sbin/pkgutil"), + arguments: ["--check-signature", path] + ) + + guard let result, result.status == 0 else { return (false, nil, nil, nil) } + + let output = String(data: result.output, encoding: .utf8) ?? "" + + let status = output + .split(separator: "\n") + .first { $0.contains("Status:") } + .map { String($0).trimmingCharacters(in: .whitespaces) } ?? "" + + let trusted = acceptedStatuses.contains { status.contains($0) } + + guard trusted else { + Logger.shared.logError("Package at \(path) is not trusted: \(status)") + return (false, nil, nil, nil) + } + + // The leaf is the first numbered entry in the chain. + let leaf = output + .split(separator: "\n") + .first { $0.trimmingCharacters(in: .whitespaces).hasPrefix("1. ") } + .map { String($0).trimmingCharacters(in: .whitespaces).dropFirst(3) } + .map(String.init) + + let fingerprint = leafFingerprint(in: output) + + guard let leaf else { return (true, nil, nil, fingerprint) } + + let teamID = leaf.range(of: #"\(([A-Z0-9]{10})\)$"#, options: .regularExpression) + .map { String(leaf[$0].dropFirst().dropLast()) } + + return (true, teamID, leaf, fingerprint) + } + + /// The SHA-256 of the leaf certificate, from the fingerprint `pkgutil` prints beneath it. + /// + /// Printed as space-separated hex across however many lines it takes, under the first numbered + /// entry in the chain — which is the leaf. Collection stops at the first line that is not hex, + /// which is the rule separating one certificate from the next, so a fingerprint from further down + /// the chain can never be mistaken for the leaf's. + static func leafFingerprint(in output: String) -> String? { + var insideLeaf = false + var collecting = false + var digest = "" + + for line in output.split(separator: "\n", omittingEmptySubsequences: false) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + + if trimmed.hasPrefix("1. ") { + insideLeaf = true + continue + } + + guard insideLeaf else { continue } + + // Any other numbered entry ends the leaf's section. + if trimmed.range(of: #"^\d+\. "#, options: .regularExpression) != nil { break } + + if trimmed.hasPrefix("SHA256 Fingerprint:") { + collecting = true + continue + } + + guard collecting else { continue } + + let hex = trimmed.replacingOccurrences(of: " ", with: "") + + guard !hex.isEmpty, hex.allSatisfy(\.isHexDigit) else { break } + + digest += hex + } + + let normalized = digest.lowercased() + + return normalized.count == 64 ? normalized : nil + } + + /// Whether a Gatekeeper `source=` names a notarized authority. + static func isNotarized(source: String?) -> Bool { + guard let source = source?.trimmingCharacters(in: .whitespaces) else { return false } + return source.hasPrefix("Notarized") + } + + static func signature(of path: String) async -> Signature { + let chain = await certificateChain(of: path) + let gatekeeper = await gatekeeperAssessment(of: path) + + return Signature( + trusted: chain.trusted, + // Accepted *and* attributed to a notarized authority. Gatekeeper accepts other things too, + // such as anything already on the system's own allow list. Matched at the start rather + // than anywhere in the string: the source for a signed-but-unnotarized package is + // "Unnotarized Developer ID", which contains the word either way. + notarized: gatekeeper.accepted && isNotarized(source: gatekeeper.source), + teamID: chain.teamID, + authority: chain.authority ?? gatekeeper.origin, + leafCertificateSHA256: chain.leaf + ) + } + + // MARK: Contents + + struct Contents { + var identifiers: [String] = [] + var version: String? + var title: String? + var hasScripts = false + var scriptSummary: String? + var hasRemoteReferences = false + } + + /// Expand the package and read what it will install. + /// + /// `--expand` rather than `--expand-full`: the payloads stay as archives, which is all that is + /// needed to know whether a component carries scripts, and avoids unpacking attacker-chosen + /// archives as root just to look at them. + static func contents(of path: String, expandingInto directory: String) async throws -> Contents { + _ = try await ExecutionService.run("/usr/sbin/pkgutil", ["--expand", path, directory]) + + var contents = Contents() + + let distribution = (directory as NSString).appendingPathComponent("Distribution") + + if FileManager.default.fileExists(atPath: distribution) { + readDistribution(at: distribution, into: &contents) + } + + for component in componentDirectories(in: directory) { + readPackageInfo(at: (component as NSString).appendingPathComponent("PackageInfo"), into: &contents) + + // A `Scripts` archive beside the payload is the component's pre/postinstall scripts, and + // they run as root. Its presence is enough; there is no need to unpack it. + if FileManager.default.fileExists(atPath: (component as NSString).appendingPathComponent("Scripts")) { + contents.hasScripts = true + contents.scriptSummary = contents.scriptSummary ?? "install scripts that run as root" + } + } + + contents.identifiers = Array(Set(contents.identifiers)).sorted() + + return contents + } + + private static func readDistribution(at path: String, into contents: inout Contents) { + guard let document = try? XMLDocument(contentsOf: URL(fileURLWithPath: path), options: []) else { + Logger.shared.logError("Unable to read the Distribution file at \(path)") + return + } + + contents.title = (try? document.nodes(forXPath: "//title"))?.first?.stringValue? + .trimmingCharacters(in: .whitespacesAndNewlines) + + for node in (try? document.nodes(forXPath: "//pkg-ref")) ?? [] { + guard let element = node as? XMLElement else { continue } + + if let id = element.attribute(forName: "id")?.stringValue, !id.isEmpty { + contents.identifiers.append(id) + } + + if contents.version == nil, let version = element.attribute(forName: "version")?.stringValue, !version.isEmpty { + contents.version = version + } + + // A reference whose body is a URL is fetched while installing. Those bytes arrive after + // everything here has finished looking, so a package that has one can never be judged. + let body = element.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + + if body.hasPrefix("http://") || body.hasPrefix("https://") { + contents.hasRemoteReferences = true + } + } + + // JavaScript in the distribution runs inside the installer process, which is root here. + if let scripts = try? document.nodes(forXPath: "//installer-gui-script/script"), !scripts.isEmpty { + contents.hasScripts = true + contents.scriptSummary = contents.scriptSummary ?? "installer JavaScript" + } + } + + private static func readPackageInfo(at path: String, into contents: inout Contents) { + guard + FileManager.default.fileExists(atPath: path), + let document = try? XMLDocument(contentsOf: URL(fileURLWithPath: path), options: []), + let root = document.rootElement() + else { return } + + if let id = root.attribute(forName: "identifier")?.stringValue, !id.isEmpty { + contents.identifiers.append(id) + } + + if contents.version == nil, let version = root.attribute(forName: "version")?.stringValue, !version.isEmpty { + contents.version = version + } + + if let scripts = try? document.nodes(forXPath: "//scripts/*"), !scripts.isEmpty { + contents.hasScripts = true + contents.scriptSummary = contents.scriptSummary ?? "install scripts that run as root" + } + } + + // MARK: Payload + + /// Bundle extensions that are a destination in their own right, so a path stops there. + private static let bundleExtensions: Set = [ + "app", "framework", "bundle", "plugin", "kext", "prefPane", "qlgenerator", "xpc", "appex", "systemextension", + ] + + /// The most roots a package may have before it is refused outright. + /// + /// Not a display limit — every one of these is checked. A package with more distinct destinations + /// than this is pathological, and refusing is the only answer that does not involve deciding + /// which of them not to look at. + static let maximumRoots = 512 + + /// Where a package's payload will land, shortened to the shallowest path containing each part. + /// + /// Read from each component's bill of materials, files and symlinks only. Directories are skipped + /// deliberately: every package "installs" `/Applications` in the sense of ensuring it exists, and + /// collapsing on that would report one useless root for everything. + /// + /// Each path is truncated at the first bundle it enters, or to three components, so an application + /// reports `/Applications/Firefox.app` rather than ten thousand files. Truncation only ever moves + /// a path towards the root, so a check against these roots also covers everything beneath them. + static func payloadRoots(inExpanded directory: String) async throws -> [String] { + var roots: Set = [] + + for component in componentDirectories(in: directory) { + let bom = (component as NSString).appendingPathComponent("Bom") + guard FileManager.default.fileExists(atPath: bom) else { continue } + + let installLocation = (try? XMLDocument( + contentsOf: URL(fileURLWithPath: (component as NSString).appendingPathComponent("PackageInfo")), + options: [] + ))? + .rootElement()? + .attribute(forName: "install-location")? + .stringValue ?? "/" + + // A component whose bill of materials cannot be read contributes no destinations, and so + // could never be blocked by any of them. For something deciding what root writes, "could + // not look" has to mean "no", not "nothing to see". + guard let output = try? await ExecutionService.run("/usr/bin/lsbom", ["-f", "-l", "-s", bom]) else { + Logger.shared.logError("Unable to read the bill of materials at \(bom)") + throw SupportCompanionErrors.helperConnection( + "Part of this package could not be read, so where it installs cannot be established" + ) + } + + for line in output.split(separator: "\n") { + let relative = line.hasPrefix(".") ? String(line.dropFirst()) : String(line) + guard !relative.isEmpty, relative != "/" else { continue } + + let full = (installLocation as NSString).appendingPathComponent(relative) + + // AppleDouble sidecars carry the extended attributes of the file beside them and are + // merged into it on arrival; they are metadata, not a destination of their own. + // Reporting them would put `/Applications/._Foo.app` next to `/Applications/Foo.app` + // and make any sensible prefix fail. + guard !((full as NSString).lastPathComponent.hasPrefix("._")) else { continue } + + roots.insert(root(of: full)) + } + + // A symlink in the payload is a destination of its own: `installer` writes through it, so + // a link at /Applications/Foo.app/Contents/x pointing at /Library/LaunchDaemons puts the + // payload there while every nominal path still reads as being inside the app. Resolving + // the target and treating it as another root means the ordinary prefix and deny-list + // checks cover it, rather than needing a rule of their own. + guard let links = try? await ExecutionService.run("/usr/bin/lsbom", ["-l", "-p", "fl", bom]) else { + Logger.shared.logError("Unable to read the symbolic links in \(bom)") + throw SupportCompanionErrors.helperConnection( + "Part of this package could not be read, so where it installs cannot be established" + ) + } + + for line in links.split(separator: "\n") { + let parts = line.split(separator: "\t", maxSplits: 1) + guard parts.count == 2 else { continue } + + let linkPath = String(parts[0]) + let target = String(parts[1]).trimmingCharacters(in: .whitespaces) + guard !target.isEmpty else { continue } + + let relative = linkPath.hasPrefix(".") ? String(linkPath.dropFirst()) : linkPath + guard !((relative as NSString).lastPathComponent.hasPrefix("._")) else { continue } + + let full = (installLocation as NSString).appendingPathComponent(relative) + + let resolved = target.hasPrefix("/") + ? target + : ((full as NSString).deletingLastPathComponent as NSString).appendingPathComponent(target) + + roots.insert(root(of: resolved)) + } + } + + let all = minimal(roots).sorted() + + // Never truncated here. This used to return at most a couple of dozen, which was harmless + // while these were something to show the user — but they are now what the deny list is + // checked against, so dropping one silently drops a refusal. A package with two dozen + // destinations sorting before "/L" would have pushed /Library/LaunchDaemons off the end and + // been allowed. Whatever is displayed is shortened at the point of display instead. + guard all.count <= maximumRoots else { + throw SupportCompanionErrors.helperConnection( + "This package writes to \(all.count) different places, which is more than can be checked" + ) + } + + return all + } + + /// The directories holding a `PackageInfo`: one per component, or the package itself when it has + /// no components of its own. + private static func componentDirectories(in directory: String) -> [String] { + var directories: [String] = [] + + if FileManager.default.fileExists(atPath: (directory as NSString).appendingPathComponent("PackageInfo")) { + directories.append(directory) + } + + for entry in (try? FileManager.default.contentsOfDirectory(atPath: directory)) ?? [] { + let path = (directory as NSString).appendingPathComponent(entry) + + if FileManager.default.fileExists(atPath: (path as NSString).appendingPathComponent("PackageInfo")) { + directories.append(path) + } + } + + return directories + } + + private static func root(of path: String) -> String { + let components = (path as NSString).standardizingPath + .split(separator: "/", omittingEmptySubsequences: true) + .map(String.init) + + var kept: [String] = [] + + for component in components { + kept.append(component) + + if bundleExtensions.contains((component as NSString).pathExtension) { break } + if kept.count == 3 { break } + } + + return "/" + kept.joined(separator: "/") + } + + /// Drop any path that already sits inside another one in the set. + private static func minimal(_ paths: Set) -> [String] { + var kept: [String] = [] + + for path in paths.sorted() { + if let last = kept.last, InstallerPolicy.isPath(path, under: last) { continue } + kept.append(path) + } + + return kept + } + + // MARK: Facts + + /// Everything the policy needs to know about a staged package. + static func facts(forStagedPackageAt path: String, fileName: String, expandingInto directory: String) async throws -> InstallerFacts { + let digest = try StagedFile.sha256(ofFileAt: path) + let signature = await signature(of: path) + + var facts = InstallerFacts( + kind: .package, + fileName: fileName, + sha256: digest, + teamID: signature.teamID, + authority: signature.authority, + leafCertificateSHA256: signature.leafCertificateSHA256, + signatureTrusted: signature.trusted, + notarized: signature.notarized + ) + + // A package that will not expand is not one we are going to install, but the digest and the + // signature are still worth reporting: they are what an administrator needs to allow it. + do { + let contents = try await contents(of: path, expandingInto: directory) + + facts.identifiers = contents.identifiers + facts.version = contents.version + facts.displayName = contents.title + facts.hasScripts = contents.hasScripts + facts.scriptSummary = contents.scriptSummary + facts.hasRemoteReferences = contents.hasRemoteReferences + } catch { + Logger.shared.logError("Unable to expand the package at \(path): \(error.localizedDescription)") + throw SupportCompanionErrors.helperConnection("This does not look like an installer package") + } + + // Outside the catch above, so that "where does this install" failing is reported as itself + // rather than as "this is not a package". + facts.payloadRoots = try await payloadRoots(inExpanded: directory) + + return facts + } +} diff --git a/SupportCompanion.xcodeproj/project.pbxproj b/SupportCompanion.xcodeproj/project.pbxproj index 746f8e1..e4228f1 100644 --- a/SupportCompanion.xcodeproj/project.pbxproj +++ b/SupportCompanion.xcodeproj/project.pbxproj @@ -130,6 +130,7 @@ membershipExceptions = ( HelperConstants.swift, HelperProtocol.swift, + InstallerPolicy.swift, "OSStatus+Extensions.swift", RemoteApplicationProtocol.swift, ); @@ -215,6 +216,7 @@ StorageInfoManager.swift, SystemUpdatesManager.swift, UserInfoManager.swift, + UserInstallManager.swift, ); target = F6F3BEB82CE1E7BA0036ADB9 /* SupportCompanion */; }; @@ -223,6 +225,7 @@ membershipExceptions = ( HelperConstants.swift, HelperProtocol.swift, + InstallerPolicy.swift, "OSStatus+Extensions.swift", RemoteApplicationProtocol.swift, ); diff --git a/SupportCompanion/App/AppDelegate.swift b/SupportCompanion/App/AppDelegate.swift index ffb8264..74c9739 100644 --- a/SupportCompanion/App/AppDelegate.swift +++ b/SupportCompanion/App/AppDelegate.swift @@ -47,6 +47,25 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { } func application(_ application: NSApplication, open urls: [URL]) { + // Installers the user double-clicked, when this app is registered to open them. Taken before + // the custom scheme is looked at: these are file URLs and have no host to switch on. + let installers = urls.filter { + $0.isFileURL && InstallerFileTypes.fileExtensions.contains($0.pathExtension.lowercased()) + } + + if let first = installers.first { + AppDelegate.shouldExit = false + + // One at a time. The rest go where they would have gone anyway rather than queueing up + // behind a window the user has not answered yet. + for other in installers.dropFirst() { + NSWorkspace.shared.open(other) + } + + UserInstallManager.shared.open(first) + return + } + guard let url = urls.first else { return } if url.host == "run" { @@ -101,6 +120,25 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { NotificationCenter.default.post(name: .handleIncomingURL, object: url) } + /// The Services menu item, for an installer the user right-clicked in Finder. + @objc func installOpenedInstaller( + _ pasteboard: NSPasteboard, + userData: String, + error: AutoreleasingUnsafeMutablePointer + ) { + guard + let urls = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL], + let first = urls.first(where: { + InstallerFileTypes.fileExtensions.contains($0.pathExtension.lowercased()) + }) + else { + error.pointee = "No installer package or disk image was selected" as NSString + return + } + + UserInstallManager.shared.open(first) + } + func applicationDidFinishLaunching(_ notification: Notification) { // Unit tests are hosted in the app; don't start the menu bar item, timers, or notifications for them guard ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil else { @@ -143,6 +181,20 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { } } + // The right-click "Install with Support Companion" item. + NSApp.servicesProvider = self + NSUpdateDynamicServices() + + // Whether Finder actually offers it. Applied on every launch rather than once, so turning the + // preference off takes the item away again. + InstallerServiceMenu.apply(showing: appStateManager.preferences.showInstallerServiceMenuItem) + + // What this process read, so that a disagreement with the helper — which reads the same + // setting by a different route — is visible in one place. + Logger.shared.logInfo( + "User installs: EnableUserInstalls=\(appStateManager.preferences.enableUserInstalls)" + ) + requestNotificationPermissions() notificationDelegate = NotificationDelegate() UNUserNotificationCenter.current().delegate = notificationDelegate diff --git a/SupportCompanion/Constants.swift b/SupportCompanion/Constants.swift index c77e441..9aaac12 100644 --- a/SupportCompanion/Constants.swift +++ b/SupportCompanion/Constants.swift @@ -196,6 +196,17 @@ enum Constants { static let uninstall = String( localized: "Fleet.Uninstall", defaultValue: "Uninstall", comment: "Button that uninstalls an app through Fleet") + static let availableInCatalog = String( + localized: "UserInstalls.AvailableInCatalog", + defaultValue: "Your organisation already offers this", + comment: "Shown when a refused installer matches something in the software catalog") + static let availableInCatalogDetail = String( + localized: "UserInstalls.AvailableInCatalogDetail", + defaultValue: "Install the approved version from %@ instead of this download.", + comment: "Explains the catalog suggestion; %@ is the name of the Apps page") + static let showInCatalog = String( + localized: "UserInstalls.ShowInCatalog", defaultValue: "Show me", + comment: "Button taking the user to the software catalog page") static let details = String( localized: "Fleet.Details", defaultValue: "Details", comment: "Button that shows the output of a failed install or uninstall") @@ -324,6 +335,99 @@ enum Constants { localized: "General.Ago", defaultValue: "Ago", comment: "Ago") static let justNow: String = String( localized: "General.JustNow", defaultValue: "Just Now", comment: "Just Now") + static let cancel = String( + localized: "General.Cancel", defaultValue: "Cancel", comment: "Cancel") + static let done = String( + localized: "General.Done", defaultValue: "Done", comment: "Done") + } + + /// The window shown when a user opens an installer an organisation may have allowed. + enum UserInstalls { + static let checking = String( + localized: "UserInstalls.Checking", defaultValue: "Checking this installer", + comment: "Shown while the helper assesses an installer the user opened") + static let installing = String( + localized: "UserInstalls.Installing", defaultValue: "Installing", + comment: "Shown while an allowed installer is being installed") + static let installed = String( + localized: "UserInstalls.Installed", defaultValue: "Installed successfully", + comment: "Shown when an allowed installer has finished installing") + static let failed = String( + localized: "UserInstalls.Failed", defaultValue: "The installation did not finish", + comment: "Shown when an allowed installer failed to install") + static let install = String( + localized: "UserInstalls.Install", defaultValue: "Install", + comment: "Button that installs an allowed application") + static let allowedByAdministrator = String( + localized: "UserInstalls.AllowedByAdministrator", + defaultValue: "Your administrator allows you to install this", + comment: "Shown when an installer matches the administrator's allowlist") + static let notAllowed = String( + localized: "UserInstalls.NotAllowed", + defaultValue: "Your organisation has not allowed this installer", + comment: "Shown when an installer does not match the organisation's allowlist") + static let elevateInstead = String( + localized: "UserInstalls.ElevateInstead", defaultValue: "Request admin rights", + comment: "Button offering the elevation flow for an installer that is not allowed") + static let version = String( + localized: "UserInstalls.Version", defaultValue: "Version", + comment: "Precedes the version number of an installer in the install window") + static let kindPackage = String( + localized: "UserInstalls.KindPackage", defaultValue: "Installer package", + comment: "Describes a .pkg in the install window") + static let kindApplication = String( + localized: "UserInstalls.KindApplication", defaultValue: "Application", + comment: "Describes an app from a .dmg in the install window") + static let availableInCatalog = String( + localized: "UserInstalls.AvailableInCatalog", + defaultValue: "Your organisation already offers this", + comment: "Shown when a refused installer matches something in the software catalog") + static let availableInCatalogDetail = String( + localized: "UserInstalls.AvailableInCatalogDetail", + defaultValue: "Install the approved version from %@ instead of this download.", + comment: "Explains the catalog suggestion; %@ is the name of the Apps page") + static let showInCatalog = String( + localized: "UserInstalls.ShowInCatalog", defaultValue: "Show me", + comment: "Button taking the user to the software catalog page") + static let details = String( + localized: "UserInstalls.Details", defaultValue: "Details", + comment: "Collapsed section holding what an organisation needs to allow an installer") + static let certificate = String( + localized: "UserInstalls.Certificate", defaultValue: "Signing certificate SHA-256", + comment: "Label for the digest of the certificate an installer was signed with") + static let identifier = String( + localized: "UserInstalls.Identifier", defaultValue: "Identifier", + comment: "Label for a refused installer's package or bundle identifier") + static let installsTo = String( + localized: "UserInstalls.InstallsTo", defaultValue: "Installs to", + comment: "Label for the places an installer writes") + static let fingerprint = String( + localized: "UserInstalls.Fingerprint", defaultValue: "SHA-256 of", + comment: "Precedes a file name, above that file's digest, for a refused installer") + static let developer = String( + localized: "UserInstalls.Developer", defaultValue: "Developer", + comment: "Label for who signed the installer") + static let verification = String( + localized: "UserInstalls.Verification", defaultValue: "Verified", + comment: "Label for how the installer was verified") + static let allowedAs = String( + localized: "UserInstalls.AllowedAs", defaultValue: "Allowed as", + comment: "Label for which allowlist entry matched") + static let verifiedByDigest = String( + localized: "UserInstalls.VerifiedByDigest", + defaultValue: "Exact copy your organisation approved", + comment: "Shown when an installer matched on its SHA-256") + static let verifiedNotarized = String( + localized: "UserInstalls.VerifiedNotarized", + defaultValue: "Signature and Apple notarization", + comment: "Shown when an installer matched on a notarized signature") + static let openInInstaller = String( + localized: "UserInstalls.OpenInInstaller", defaultValue: "Open Anyway", + comment: + "Button handing the installer to Installer.app after the check could not be made") + static let verifiedSignature = String( + localized: "UserInstalls.VerifiedSignature", defaultValue: "Developer signature", + comment: "Shown when an installer matched on its signature alone") } enum AppPaths { diff --git a/SupportCompanion/Helpers/InstallerFileTypes.swift b/SupportCompanion/Helpers/InstallerFileTypes.swift new file mode 100644 index 0000000..2961c3a --- /dev/null +++ b/SupportCompanion/Helpers/InstallerFileTypes.swift @@ -0,0 +1,47 @@ +// +// InstallerFileTypes.swift +// SupportCompanion +// + +import Foundation + +/// The installers this app knows how to open. +/// +/// Declaring these in `CFBundleDocumentTypes` is what puts Support Companion in Finder's **Open With** +/// menu for a `.pkg` or a `.dmg`, and what lets the Services item appear for them. It is deliberately +/// all this app does about file types. +/// +/// It used to also make itself the *default* handler, so that a plain double-click came here first. +/// That is the only way macOS offers to decide per application before Installer.app asks for an +/// administrator password — but setting a default handler raises a consent dialog per type that +/// cannot be suppressed from code, and there is no way to be the handler for only some packages, so +/// every installer on the Mac would have carried this app's icon. Neither is acceptable to put in +/// front of someone at install time, so the user opens an installer here when they mean to, and +/// everything else behaves exactly as it did before this app was on the Mac. +enum InstallerFileTypes { + + /// Declared in `CFBundleDocumentTypes` and in the Services item's `NSSendFileTypes`. + /// + /// `CFBundleDocumentTypes` declares the matching file extensions as well as these identifiers. + /// Binding by identifier alone is enough for a disk image but is not enough for a package: the + /// apps that do show up under **Open With** for a `.pkg`, such as Suspicious Package, bind `.pkg` + /// and `.mpkg` by extension, and doing only one of the two leaves the menu item missing. + static let contentTypes = [ + "com.apple.installer-package-archive", // a flat .pkg, which is nearly all of them + "com.apple.installer-distribution-package", // one built by productbuild + "com.apple.installer-package", // the older bundle-shaped .pkg + "com.apple.installer-meta-package", // an .mpkg + "com.apple.disk-image-udif", // a .dmg + ] + + static let fileExtensions: Set = ["pkg", "mpkg", "dmg"] + + // The Services item in `NSServices` also declares `NSRequiredContext`, scoping it to Finder. + // Without a required context the item does not appear in Finder's contextual menu at all — every + // third-party service that does show up there declares one, whether it is Suspicious Package + // naming `com.apple.finder` or kitty naming `NSTextContent: FilePath`. + // + // Open With is a different matter and is closed to us for packages: Launch Services offers only + // Installer.app for a .pkg, whatever an app claims, which is why the Services item carries the + // whole right-click story for packages while a disk image also appears under Open With. +} diff --git a/SupportCompanion/Helpers/InstallerServiceMenu.swift b/SupportCompanion/Helpers/InstallerServiceMenu.swift new file mode 100644 index 0000000..a6e7b70 --- /dev/null +++ b/SupportCompanion/Helpers/InstallerServiceMenu.swift @@ -0,0 +1,64 @@ +// +// InstallerServiceMenu.swift +// SupportCompanion +// + +import AppKit +import Foundation + +/// Shows or hides the "Install with Support Companion" item in Finder's contextual menu. +/// +/// The item itself is declared in `NSServices` in the app's `Info.plist`, which is signed and cannot +/// be edited at runtime — so whether it *appears* is controlled the same way System Settings controls +/// it, through the `pbs` preference domain. Each service has an entry in `NSServicesStatus` holding +/// `enabled_context_menu` and `enabled_services_menu`. +/// +/// This is not API. The key naming it is built from the bundle identifier, the menu item's title and +/// the message name, and a service that has never been toggled has no entry at all. Both are handled +/// below, but if Apple changes the format the worst case is that the item stays visible — the feature +/// still refuses to do anything when an administrator has not enabled it, because the helper decides +/// that and not this. +enum InstallerServiceMenu { + + private static let domain = "pbs" as CFString + private static let statusKey = "NSServicesStatus" as CFString + + /// The `NSMessage` from `Info.plist`, which is the stable part of the key. + private static let message = "installOpenedInstaller" + + /// ` - - `, as `pbs` writes it. + private static var preferredKey: String { + let bundleIdentifier = Bundle.main.bundleIdentifier ?? "com.github.macadmins.SupportCompanion" + return "\(bundleIdentifier) - Install with Support Companion - \(message)" + } + + static func apply(showing shouldShow: Bool) { + var status = (CFPreferencesCopyAppValue(statusKey, domain) as? [String: Any]) ?? [:] + + let setting: [String: Any] = [ + "enabled_context_menu": shouldShow, + "enabled_services_menu": shouldShow, + ] + + // Update whatever macOS has already written for this service, whatever it decided to call it, + // rather than adding a second entry it will ignore. + let existing = status.keys.filter { $0.contains(message) } + + for key in existing { + status[key] = setting + } + + if existing.isEmpty { + status[preferredKey] = setting + } + + CFPreferencesSetAppValue(statusKey, status as CFDictionary, domain) + CFPreferencesAppSynchronize(domain) + + NSUpdateDynamicServices() + + Logger.shared.logDebug( + "Finder service item \(shouldShow ? "shown" : "hidden") via \(existing.isEmpty ? preferredKey : existing.joined(separator: ", "))" + ) + } +} diff --git a/SupportCompanion/Helpers/TrustedPreferences.swift b/SupportCompanion/Helpers/TrustedPreferences.swift index c268914..eecb381 100644 --- a/SupportCompanion/Helpers/TrustedPreferences.swift +++ b/SupportCompanion/Helpers/TrustedPreferences.swift @@ -5,15 +5,18 @@ import Foundation -/// Reads settings that gate privileged behavior (root actions, admin elevation). +/// Reads settings that gate privileged behavior (root actions, admin elevation, allowed installers). /// /// Any user can write to their own defaults domain with `defaults write`, and `UserDefaults.standard` -/// happily returns those values. For settings that decide what runs as root, only values set by an -/// administrator are honored: -/// - keys forced by an MDM configuration profile -/// - keys in `/Library/Preferences/com.github.macadmins.SupportCompanion.plist`, which only root can write +/// happily returns those values. For settings that decide what runs as root, only keys forced by an +/// MDM configuration profile are honored. Anything else falls back to the secure default. /// -/// Anything else falls back to the secure default. +/// `/Library/Preferences/com.github.macadmins.SupportCompanion.plist` used to count as well, on the +/// grounds that only root can write it. That is true and is the problem: anything that reaches root +/// once — a privileged action, somebody inside an elevation window — could write itself a permanent +/// grant of elevation and of the installer allowlist, and nothing would ever put it back. A profile +/// is owned by the MDM, which re-applies it. See `HelperPreferences`, which makes the same choice on +/// the root side and is the copy that actually decides anything. enum TrustedPreferences { private static let domain = "com.github.macadmins.SupportCompanion" as CFString @@ -21,8 +24,25 @@ enum TrustedPreferences { if UserDefaults.standard.objectIsForced(forKey: key) { return UserDefaults.standard.object(forKey: key) } - return CFPreferencesCopyValue(key as CFString, domain, kCFPreferencesAnyUser, kCFPreferencesCurrentHost) + + warnIfPresentUnmanaged(key) + + return nil + } + + /// Say when a setting is being ignored because of where it lives. + /// + /// Silently falling back to a default looks like the feature breaking for no reason to anyone who + /// configured this with something other than an MDM. + private static func warnIfPresentUnmanaged(_ key: String) { + let unmanaged = CFPreferencesCopyValue(key as CFString, domain, kCFPreferencesAnyUser, kCFPreferencesCurrentHost) ?? CFPreferencesCopyValue(key as CFString, domain, kCFPreferencesAnyUser, kCFPreferencesAnyHost) + + guard unmanaged != nil else { return } + + Logger.shared.logError( + "Ignoring '\(key)' from /Library/Preferences: settings that grant privileges are only read from a configuration profile. Deliver it through your MDM." + ) } static func bool(forKey key: String, default defaultValue: Bool) -> Bool { diff --git a/SupportCompanion/Info.plist b/SupportCompanion/Info.plist index 76ad20b..a9186f3 100644 --- a/SupportCompanion/Info.plist +++ b/SupportCompanion/Info.plist @@ -18,6 +18,74 @@ $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString 3.0.0 + CFBundleDocumentTypes + + + CFBundleTypeName + Installer Package + CFBundleTypeRole + Viewer + LSHandlerRank + Alternate + CFBundleTypeExtensions + + pkg + mpkg + + LSItemContentTypes + + com.apple.installer-package-archive + com.apple.installer-distribution-package + com.apple.installer-package + com.apple.installer-meta-package + + + + CFBundleTypeName + Disk Image + CFBundleTypeRole + Viewer + LSHandlerRank + Alternate + CFBundleTypeExtensions + + dmg + + LSItemContentTypes + + com.apple.disk-image-udif + + + + NSServices + + + NSMenuItem + + default + Install with Support Companion + + NSMessage + installOpenedInstaller + NSPortName + SupportCompanion + NSRequiredContext + + NSApplicationIdentifier + + com.apple.finder + + + NSSendFileTypes + + com.apple.installer-package-archive + com.apple.installer-distribution-package + com.apple.installer-package + com.apple.installer-meta-package + com.apple.disk-image-udif + + + CFBundleURLTypes diff --git a/SupportCompanion/Localizable.xcstrings b/SupportCompanion/Localizable.xcstrings index 2a133a6..8de4a74 100644 --- a/SupportCompanion/Localizable.xcstrings +++ b/SupportCompanion/Localizable.xcstrings @@ -107,6 +107,9 @@ }, "%lld%%" : { + }, + "•" : { + }, "°C" : { @@ -3535,6 +3538,18 @@ } } }, + "General.Cancel" : { + "comment" : "Cancel", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Cancel" + } + } + } + }, "General.Close" : { "comment" : "Close", "extractionState" : "manual", @@ -3787,6 +3802,18 @@ } } }, + "General.Done" : { + "comment" : "Done", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Done" + } + } + } + }, "General.Elevate" : { "comment" : "Elevate", "extractionState" : "extracted_with_value", @@ -6530,6 +6557,318 @@ } } }, + "UserInstalls.AllowedAs" : { + "comment" : "Label for which allowlist entry matched", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Allowed as" + } + } + } + }, + "UserInstalls.AllowedByAdministrator" : { + "comment" : "Shown when an installer matches the administrator's allowlist", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Your administrator allows you to install this" + } + } + } + }, + "UserInstalls.AvailableInCatalog" : { + "comment" : "Shown when a refused installer matches something in the software catalog", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Your organisation already offers this" + } + } + } + }, + "UserInstalls.AvailableInCatalogDetail" : { + "comment" : "Explains the catalog suggestion; %@ is the name of the Apps page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Install the approved version from %@ instead of this download." + } + } + } + }, + "UserInstalls.Certificate" : { + "comment" : "Label for the digest of the certificate an installer was signed with", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Signing certificate SHA-256" + } + } + } + }, + "UserInstalls.Checking" : { + "comment" : "Shown while the helper assesses an installer the user opened", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Checking this installer" + } + } + } + }, + "UserInstalls.Details" : { + "comment" : "Collapsed section holding what an organisation needs to allow an installer", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Details" + } + } + } + }, + "UserInstalls.Developer" : { + "comment" : "Label for who signed the installer", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Developer" + } + } + } + }, + "UserInstalls.ElevateInstead" : { + "comment" : "Button offering the elevation flow for an installer that is not allowed", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Request admin rights" + } + } + } + }, + "UserInstalls.Failed" : { + "comment" : "Shown when an allowed installer failed to install", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The installation did not finish" + } + } + } + }, + "UserInstalls.Fingerprint" : { + "comment" : "Precedes a file name, above that file's digest, for a refused installer", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "SHA-256 of" + } + } + } + }, + "UserInstalls.Identifier" : { + "comment" : "Label for a refused installer's package or bundle identifier", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Identifier" + } + } + } + }, + "UserInstalls.Install" : { + "comment" : "Button that installs an allowed application", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Install" + } + } + } + }, + "UserInstalls.Installed" : { + "comment" : "Shown when an allowed installer has finished installing", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Installed successfully" + } + } + } + }, + "UserInstalls.Installing" : { + "comment" : "Shown while an allowed installer is being installed", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Installing" + } + } + } + }, + "UserInstalls.InstallsTo" : { + "comment" : "Label for the places an installer writes", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Installs to" + } + } + } + }, + "UserInstalls.KindApplication" : { + "comment" : "Describes an app from a .dmg in the install window", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Application" + } + } + } + }, + "UserInstalls.KindPackage" : { + "comment" : "Describes a .pkg in the install window", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Installer package" + } + } + } + }, + "UserInstalls.NotAllowed" : { + "comment" : "Shown when an installer does not match the organisation's allowlist", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Your organisation has not allowed this installer" + } + } + } + }, + "UserInstalls.OpenInInstaller" : { + "comment" : "Button handing the installer to Installer.app after the check could not be made", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open Anyway" + } + } + } + }, + "UserInstalls.ShowInCatalog" : { + "comment" : "Button taking the user to the software catalog page", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show me" + } + } + } + }, + "UserInstalls.Verification" : { + "comment" : "Label for how the installer was verified", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Verified" + } + } + } + }, + "UserInstalls.VerifiedByDigest" : { + "comment" : "Shown when an installer matched on its SHA-256", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Exact copy your organisation approved" + } + } + } + }, + "UserInstalls.VerifiedNotarized" : { + "comment" : "Shown when an installer matched on a notarized signature", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Signature and Apple notarization" + } + } + } + }, + "UserInstalls.VerifiedSignature" : { + "comment" : "Shown when an installer matched on its signature alone", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Developer signature" + } + } + } + }, + "UserInstalls.Version" : { + "comment" : "Precedes the version number of an installer in the install window", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Version" + } + } + } + }, "Yes" : { "localizations" : { "de" : { diff --git a/SupportCompanion/Models/CatalogSuggestion.swift b/SupportCompanion/Models/CatalogSuggestion.swift new file mode 100644 index 0000000..b16b86f --- /dev/null +++ b/SupportCompanion/Models/CatalogSuggestion.swift @@ -0,0 +1,76 @@ +// +// CatalogSuggestion.swift +// SupportCompanion +// + +import Foundation + +/// One thing an organisation's software catalog offers, reduced to what matching needs. +/// +/// Each mode has its own model — a Fleet title, a Munki optional install, a Jamf policy — and each +/// maps it to this. The matching rule then lives in one place instead of once per mode. +struct CatalogEntry { + let name: String + let bundleIdentifier: String? +} + +/// A catalog entry that looks like the installer somebody just opened, and where to send them for it. +struct CatalogSuggestion: Equatable { + /// What the catalog calls it. + let name: String + + /// Where the approved copy lives, from the mode's own `managementApp(forUpdates:)` — the Apps + /// page for Fleet, Managed Software Center for Munki, and so on. + let destinationName: String + let destinationPath: String +} + +/// Decides whether a catalog offers what an installer contains. +/// +/// Deliberately strict. Sending somebody to install the wrong application is worse than saying +/// nothing at all, so a near miss is treated as a miss. +enum CatalogMatching { + + static func match(_ facts: InstallerFacts, in entries: [CatalogEntry]) -> CatalogEntry? { + guard !entries.isEmpty else { return nil } + + // A bundle identifier is the only exact signal, so it decides on its own. + if let exact = entries.first(where: { entry in + guard let bundleIdentifier = entry.bundleIdentifier, !bundleIdentifier.isEmpty else { return false } + return facts.identifiers.contains(bundleIdentifier) + }) { + return exact + } + + // Otherwise the name, with version numbers and punctuation removed from both sides, and only + // on equality: "Firefox 156.0.dmg" is Firefox, while "Firefox" is not "Firefox Developer". + let wanted = Set( + [facts.displayName, (facts.fileName as NSString).deletingPathExtension] + .compactMap { $0 } + .map(comparable) + .filter { !$0.isEmpty } + ) + + guard !wanted.isEmpty else { return nil } + + return entries.first { wanted.contains(comparable($0.name)) } + } + + /// A name with its version dropped, so "Slack_V4.40.128" and "Slack" are the same thing. + /// + /// Version *tokens* go rather than every digit: an installer is as likely to be called + /// "1Password" as "Firefox 156.0", and throwing away all digits would reduce the first to + /// "password" and match the wrong thing — or nothing. + static func comparable(_ name: String) -> String { + name + .split(whereSeparator: { !$0.isLetter && !$0.isNumber }) + .filter { !isVersion(String($0)) } + .joined() + .lowercased() + } + + /// Whether a token is only a version: "156", "4", "v2" — but not "1Password" or "3T". + private static func isVersion(_ token: String) -> Bool { + token.range(of: #"^[vV]?\d+$"#, options: .regularExpression) != nil + } +} diff --git a/SupportCompanion/Preferences/Preferences.swift b/SupportCompanion/Preferences/Preferences.swift index 55e1b01..a9e47de 100644 --- a/SupportCompanion/Preferences/Preferences.swift +++ b/SupportCompanion/Preferences/Preferences.swift @@ -105,6 +105,30 @@ class Preferences { TrustedPreferences.bool(forKey: "RequirePrivilegedActionAuthentication", default: true) } + // MARK: - User installs + + /// Whether to take an interest in installers the user opens. + /// + /// Presentation only, as everywhere else here: the helper re-reads this and the allowlist before it + /// does anything, and its answer is the one that counts. This only decides whether to bother + /// staging a file the user just opened. + var enableUserInstalls: Bool { + TrustedPreferences.bool(forKey: "EnableUserInstalls", default: false) + } + + /// Whether Finder offers "Install with Support Companion" when someone right-clicks an installer. + /// + /// Follows `EnableUserInstalls` unless an administrator says otherwise, so an organisation that + /// does not use the feature never sees the menu item, and one that does can still hide it — for + /// instance to keep the only route the catalog inside the app. + var showInstallerServiceMenuItem: Bool { + guard TrustedPreferences.object(forKey: "ShowInstallerServiceMenuItem") != nil else { + return enableUserInstalls + } + + return TrustedPreferences.bool(forKey: "ShowInstallerServiceMenuItem", default: true) + } + /// Parsed from the Actions preference; reloaded when defaults change. See loadActions(). var actions: [Action] = [] var hiddenActions: [String] { diff --git a/SupportCompanion/Services/ExecutionService.swift b/SupportCompanion/Services/ExecutionService.swift index 3caeb52..780bc4d 100644 --- a/SupportCompanion/Services/ExecutionService.swift +++ b/SupportCompanion/Services/ExecutionService.swift @@ -68,6 +68,43 @@ enum ExecutionService { try await HelperRemoteProvider.remote().elevationTimeRemaining() } + // MARK: User installs + + /// Hand an installer the user opened to the helper, which copies it somewhere only root can write + /// and judges that copy. + /// + /// The file is opened here, as the logged-in user, and only the descriptor is passed on. That keeps + /// the helper from reading anything this user could not read themselves, and means the copy it + /// judges is of the file the user actually opened rather than of whatever a path points at by the + /// time root gets to it. + static func stageInstaller(at url: URL) async throws -> InstallerAssessment { + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + + let json = try await HelperRemoteProvider.remote().stageInstaller(handle, fileName: url.lastPathComponent) + + do { + return try InstallerAssessment.make(fromJSON: json) + } catch { + // Decoding tolerates a helper that is a version behind, so reaching here means the answer + // was not one of ours at all. Say what to do about it; the raw decoding error names a + // missing key and helps nobody. + Logger.shared.logError("Unable to decode the helper's assessment: \(error)") + + throw SupportCompanionErrors.helperConnection( + "Support Companion could not read the privileged helper's answer. The app and the helper are different versions; update both and try again." + ) + } + } + + static func installStagedInstaller(token: String) async throws -> String { + try await HelperRemoteProvider.remote().installStagedInstaller(token: token) + } + + static func discardStagedInstaller(token: String) async throws { + _ = try await HelperRemoteProvider.remote().discardStagedInstaller(token: token) + } + // MARK: Jamf static func jamfPatch(id: String) async throws -> String { diff --git a/SupportCompanion/ViewModels/AppStateManager.swift b/SupportCompanion/ViewModels/AppStateManager.swift index d4722d9..c0cbcee 100644 --- a/SupportCompanion/ViewModels/AppStateManager.swift +++ b/SupportCompanion/ViewModels/AppStateManager.swift @@ -20,7 +20,10 @@ class AppStateManager { @ObservationIgnored lazy var pendingJamfUpdatesManager = PendingJamfUpdatesManager(appState: self) @ObservationIgnored lazy var pendingFleetUpdatesManager = PendingFleetUpdatesManager(appState: self) @ObservationIgnored lazy var evergreenInfoManager = EvergreenInfoManager(appState: self) - @ObservationIgnored lazy var elevationManager = ElevationManager(appState: self) + // The shared one, never a fresh instance. The countdown lives on the manager, while the value it + // publishes lives here — so two managers mean two timers writing one `timeToDemote`, and stopping + // one leaves the other to write its own value straight back. + @ObservationIgnored lazy var elevationManager = ElevationManager.shared @ObservationIgnored lazy var fleetSoftwareManager: FleetSoftwareManager = { let manager = FleetSoftwareManager() manager.onActionFinished = { [weak self] title, action, outcome in diff --git a/SupportCompanion/ViewModels/ElevationManager.swift b/SupportCompanion/ViewModels/ElevationManager.swift index 3760b98..6ac201f 100644 --- a/SupportCompanion/ViewModels/ElevationManager.swift +++ b/SupportCompanion/ViewModels/ElevationManager.swift @@ -54,6 +54,10 @@ class ElevationManager { } catch { Logger.shared.logError("Failed to demote privileges: \(error.localizedDescription)") + + // The callers stop the countdown before asking, so a failure here would otherwise + // leave an administrator looking demoted with no way back to the button. + await resyncDemotionTimer() completion(false) } } @@ -125,17 +129,50 @@ class ElevationManager { func stopDemotionTimer() { cancellable?.cancel() cancellable = nil + timerPublisher = nil + + let onTimeUpdate = self.onTimeUpdate + // Released before the last call rather than after, so a cancelled countdown has no way to + // write a value again even if something still holds a reference to its closure. + self.onTimeUpdate = nil + onTimeUpdate?(0) // Notify remaining time is 0 } - func handleElevation(reason: String) { + /// Put the countdown back in step with the helper, which is the only authority on it. + /// + /// Used when a demotion did not happen after all: the UI has already been told the countdown is + /// over, and leaving it that way would show someone as demoted while they still hold rights. + func resyncDemotionTimer() async { + let remaining = await remainingElevationTime() + + if remaining > 0 { + appState.startDemotionTimer(duration: remaining) + } else { + appState.stopDemotionTimer() + } + } + + /// Elevate, and tell the caller how it went once the user has answered the authentication prompt. + /// + /// `completion` runs on the main actor, after rights are granted and the countdown has started, so + /// a caller that wants to do something with those rights knows when they exist. It is optional + /// because most callers are a button that has nothing left to do. + func handleElevation(reason: String, completion: (@MainActor (Bool) -> Void)? = nil) { Logger.shared.logDebug("Handling elevation for reason: \(reason)") // Authenticate and elevate privileges self.elevatePrivileges(reason: reason) { success in Task { @MainActor [weak self] in - guard let self else { return } + guard let self else { + // Nothing below this point runs if the manager has been released, which is why + // callers must use the shared instance rather than one of their own. + Logger.shared.logError("Elevation finished after its manager was released; the countdown was not started") + completion?(false) + return + } guard success else { Logger.shared.logDebug("Authentication failed or elevation was refused.") + completion?(false) return } Logger.shared.logDebug("Privileges elevated.") @@ -149,6 +186,8 @@ class ElevationManager { // Count down from what the helper actually granted, not from what we asked for let duration = await self.remainingElevationTime() self.appState.startDemotionTimer(duration: duration) + + completion?(true) } } } diff --git a/SupportCompanion/ViewModels/PendingFleetUpdatesManager.swift b/SupportCompanion/ViewModels/PendingFleetUpdatesManager.swift index 022c48b..f287b03 100644 --- a/SupportCompanion/ViewModels/PendingFleetUpdatesManager.swift +++ b/SupportCompanion/ViewModels/PendingFleetUpdatesManager.swift @@ -27,6 +27,13 @@ class PendingFleetUpdatesManager: PendingUpdatesManager { (Constants.Navigation.apps, "supportcompanion://apps") } + override func catalogSuggestion(for facts: InstallerFacts) -> CatalogSuggestion? { + suggestion( + matching: facts, + in: software.titles.map { CatalogEntry(name: $0.title, bundleIdentifier: $0.bundleIdentifier) } + ) + } + /// The apps page is part of this app, so "Open Self Service" would be misleading. override func openManagementAppTitle(forUpdates: Bool) -> String { forUpdates ? Constants.Fleet.viewUpdates : Constants.Fleet.viewApps diff --git a/SupportCompanion/ViewModels/PendingUpdatesManager.swift b/SupportCompanion/ViewModels/PendingUpdatesManager.swift index d1f954b..0a8ae40 100644 --- a/SupportCompanion/ViewModels/PendingUpdatesManager.swift +++ b/SupportCompanion/ViewModels/PendingUpdatesManager.swift @@ -59,6 +59,27 @@ class PendingUpdatesManager { ("Unknown App", "") } + /// Whether this mode's catalog already offers the application an installer would install. + /// + /// The default is nil, meaning "no claim": a mode that cannot inspect its catalog should say + /// nothing rather than guess. A mode that can implements this by mapping its own catalog to + /// `CatalogEntry` and handing it to `CatalogMatching` — the matching rule stays in one place, and + /// adding Munki or Jamf later is an override here rather than a change anywhere that displays it. + func catalogSuggestion(for facts: InstallerFacts) -> CatalogSuggestion? { nil } + + /// Helper for the above: match against this mode's catalog and point at this mode's software app. + final func suggestion(matching facts: InstallerFacts, in entries: [CatalogEntry]) -> CatalogSuggestion? { + guard let match = CatalogMatching.match(facts, in: entries) else { return nil } + + let destination = managementApp(forUpdates: false) + + return CatalogSuggestion( + name: match.name, + destinationName: destination.name, + destinationPath: destination.path + ) + } + /// Title of the button that opens the management app. func openManagementAppTitle(forUpdates: Bool) -> String { "\(Constants.Actions.openManagementApp) \(managementApp(forUpdates: forUpdates).name)" diff --git a/SupportCompanion/ViewModels/UserInstallManager.swift b/SupportCompanion/ViewModels/UserInstallManager.swift new file mode 100644 index 0000000..d858628 --- /dev/null +++ b/SupportCompanion/ViewModels/UserInstallManager.swift @@ -0,0 +1,301 @@ +// +// UserInstallManager.swift +// SupportCompanion +// + +import AppKit +import Foundation +import Observation +import SwiftUI + +/// Drives the window a user sees after opening an installer. +/// +/// Every decision here is the helper's, fetched once by staging the file. This object knows whether to +/// show a window, what to put in it, and what to do when a button is pressed — it does not decide +/// whether anything may be installed, and its view of the allowlist is not consulted anywhere. +@MainActor +@Observable +final class UserInstallManager { + + static let shared = UserInstallManager() + + enum Stage: Equatable { + case assessing(fileName: String) + case allowed(InstallerAssessment) + case refused(InstallerAssessment) + case installing(InstallerAssessment) + case installed(InstallerAssessment) + case failed(assessment: InstallerAssessment?, message: String) + } + + private(set) var stage: Stage? + + @ObservationIgnored private var url: URL? + @ObservationIgnored private var window: NSWindow? + + private init() {} + + // MARK: Opening + + /// Take over an installer the user opened. + /// + /// Anything that goes wrong here hands the file back to the system rather than stopping: a Mac + /// where installers cannot be opened at all would be a far worse outcome than one where this + /// feature is not helping. + func open(_ url: URL) { + guard AppStateManager.shared.preferences.enableUserInstalls else { + // Said out loud: this used to be the quietest of three ways to end up back in + // Installer.app, which made a misconfigured feature look exactly like a working one. + Logger.shared.logInfo( + "EnableUserInstalls is not set, so \(url.lastPathComponent) goes to the system handler" + ) + handOffToSystem(url) + return + } + + self.url = url + stage = .assessing(fileName: url.lastPathComponent) + showWindow() + + Task { + do { + let assessment = try await ExecutionService.stageInstaller(at: url) + + if assessment.isAllowed { + stage = .allowed(assessment) + return + } + + Logger.shared.logInfo( + "\(url.lastPathComponent) is not allowed by an administrator: \(assessment.rejectionReasons.joined(separator: "; "))" + ) + + // Always said out loud, whatever the fallback is. Handing the file quietly to + // Installer.app leaves someone who cannot type an administrator password staring at a + // prompt, with the actual reason sitting in a log only root can read. The fallback + // decides which way out is offered, not whether the user is told anything. + stage = .refused(assessment) + } catch { + // Not a silent hand-off. The helper is the only thing that can answer this question, + // and if it did not, the user is about to be asked for an administrator password they + // do not have — so say why, and leave them the same button they would have had. + Logger.shared.logError("Unable to assess \(url.lastPathComponent): \(error.localizedDescription)") + stage = .failed(assessment: nil, message: error.localizedDescription) + } + } + } + + /// Open the file the way the system would if this app were not on the Mac. + private func handOffToSystem(_ url: URL) { + let handler: String + + switch url.pathExtension.lowercased() { + case "dmg": + handler = "/System/Library/CoreServices/DiskImageMounter.app" + default: + handler = "/System/Library/CoreServices/Installer.app" + } + + guard FileManager.default.fileExists(atPath: handler) else { + NSWorkspace.shared.open(url) + return + } + + NSWorkspace.shared.open( + [url], + withApplicationAt: URL(fileURLWithPath: handler), + configuration: NSWorkspace.OpenConfiguration() + ) { _, error in + if let error { + Logger.shared.logError("Unable to hand \(url.lastPathComponent) to \(handler): \(error.localizedDescription)") + } + } + } + + // MARK: Installing + + func install() { + guard case .allowed(let assessment) = stage, let token = assessment.token else { return } + + guard assessment.requiresAuthentication else { + performInstall(assessment, token: token) + return + } + + // The point of the feature is that no administrator password is typed. Replacing that with + // nothing would make an unlocked Mac enough, so the user confirms with their own credentials. + authenticateWithTouchIDOrPassword( + completion: { [weak self] success in + Task { @MainActor in + guard let self else { return } + + guard success else { + Logger.shared.logError("Authentication failed; \(assessment.facts.fileName) was not installed") + self.cancel() + return + } + + self.performInstall(assessment, token: token) + } + }, + reason: "authenticate to install \(assessment.facts.displayName ?? assessment.facts.fileName)" + ) + } + + private func performInstall(_ assessment: InstallerAssessment, token: String) { + stage = .installing(assessment) + + Task { + do { + let output = try await ExecutionService.installStagedInstaller(token: token) + Logger.shared.logInfo("Installed \(assessment.facts.fileName): \(output)") + stage = .installed(assessment) + } catch { + Logger.shared.logError("Failed to install \(assessment.facts.fileName): \(error.localizedDescription)") + stage = .failed(assessment: assessment, message: error.localizedDescription) + } + } + } + + // MARK: The supported route + + /// Whether the organisation already offers what the user just tried to install. + /// + /// Asked of `activeUpdatesManager`, so it answers for whichever mode is configured and this knows + /// nothing about any of them. A mode with no catalog returns nil and nothing is offered. + func catalogSuggestion(for facts: InstallerFacts) -> CatalogSuggestion? { + AppStateManager.shared.activeUpdatesManager?.catalogSuggestion(for: facts) + } + + /// Send the user where the approved copy lives. + /// + /// The destination is whatever the mode's `managementApp` reports, which is this app's own Apps + /// page for Fleet and a separate application for Munki or Jamf — so both shapes are handled here + /// rather than assumed. + func showCatalog(_ suggestion: CatalogSuggestion) { + closeWindow() + + let path = suggestion.destinationPath + + if path.hasPrefix("supportcompanion://") { + AppStateManager.shared.showWindowCallback?() + + if let url = URL(string: path) { + NotificationCenter.default.post(name: .handleIncomingURL, object: url) + } + + return + } + + // A scheme of somebody else's, such as Munki's munki://updates.html, or a path on disk. + if let url = URL(string: path), url.scheme != nil { + NSWorkspace.shared.open(url) + } else if !path.isEmpty { + NSWorkspace.shared.open(URL(fileURLWithPath: path)) + } + } + + // MARK: Fallbacks + + /// Offer the existing time-limited elevation instead, for an installer nobody allowed. + /// + /// `ElevationManager.shared`, never a fresh one: `handleElevation` returns as soon as it has asked + /// for authentication, and its completion holds the manager weakly. A manager owned by this method + /// is released the moment the method returns, and everything after the user authenticates — + /// logging the reason, and starting the countdown the UI reads — is silently skipped. + func elevate() { + guard let url else { return } + + let appState = AppStateManager.shared + + closeWindow() + + if appState.preferences.elevation.requireReasonForElevation { + ReasonInputManager.shared.presentAsWindow(isPresented: .constant(true)) { reason in + ElevationManager.shared.handleElevation(reason: reason) { granted in + Self.finishElevation(granted: granted, for: url) + } + } + } else { + ElevationManager.shared.handleElevation(reason: "") { granted in + Self.finishElevation(granted: granted, for: url) + } + } + } + + /// Hand the installer back once the rights it needed exist. + /// + /// Only after the grant. Opening it any earlier — which is what happened while this ran alongside + /// the reason prompt — puts the file in front of someone who still cannot install it. + @MainActor + private static func finishElevation(granted: Bool, for url: URL) { + guard granted else { + Logger.shared.logError("Elevation was not granted; \(url.lastPathComponent) was not reopened") + return + } + + Logger.shared.logInfo("Elevation granted, reopening \(url.lastPathComponent)") + shared.handOffToSystem(url) + } + + func openInInstaller() { + guard let url else { return } + closeWindow() + handOffToSystem(url) + } + + // MARK: Dismissal + + func cancel() { + if let token = currentToken { + Task { try? await ExecutionService.discardStagedInstaller(token: token) } + } + + closeWindow() + } + + private var currentToken: String? { + switch stage { + case .allowed(let assessment), .refused(let assessment): + return assessment.token + default: + return nil + } + } + + // MARK: Window + + private func showWindow() { + guard window == nil else { + window?.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + return + } + + let controller = NSHostingController( + rootView: UserInstallView(manager: self) + .environment(AppStateManager.shared) + ) + + let newWindow = NSWindow(contentViewController: controller) + newWindow.setContentSize(NSSize(width: 520, height: 400)) + newWindow.styleMask = [.titled, .closable, .fullSizeContentView] + newWindow.titlebarAppearsTransparent = true + newWindow.title = "" + newWindow.isReleasedWhenClosed = false + newWindow.center() + newWindow.level = .floating + + window = newWindow + newWindow.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + + func closeWindow() { + stage = nil + url = nil + window?.orderOut(nil) + window?.close() + window = nil + } +} diff --git a/SupportCompanion/Views/Cards/ElevationCard.swift b/SupportCompanion/Views/Cards/ElevationCard.swift index 8424709..e006b5b 100644 --- a/SupportCompanion/Views/Cards/ElevationCard.swift +++ b/SupportCompanion/Views/Cards/ElevationCard.swift @@ -14,7 +14,7 @@ struct ElevationCard: View { @State private var elevationReason = "" var body: some View { - let elevationManager = ElevationManager(appState: appState) + let elevationManager = ElevationManager.shared VStack(alignment: .leading) { ScCard(title: Constants.CardTitle.privileges, titleImageName: "lock.fill", useMultiColor: false, content: { @@ -60,7 +60,9 @@ struct ElevationCard: View { }) } .sheet(isPresented: $showReasonInput) { - ReasonInputView(isPresented: $showReasonInput, onElevate: elevationManager.handleElevation) + ReasonInputView(isPresented: $showReasonInput) { reason in + elevationManager.handleElevation(reason: reason) + } } } } diff --git a/SupportCompanion/Views/UserInstallView.swift b/SupportCompanion/Views/UserInstallView.swift new file mode 100644 index 0000000..a28cc08 --- /dev/null +++ b/SupportCompanion/Views/UserInstallView.swift @@ -0,0 +1,495 @@ +// +// UserInstallView.swift +// SupportCompanion +// + +import SwiftUI + +/// What the user sees after opening an installer the administrator may have allowed. +/// +/// The decision has already been made by the helper by the time this is drawn, so the window has one +/// job: say what this is, say whether it is allowed, and offer the one action worth taking. The weight +/// follows that — the application's name is the largest thing here, the action is the only filled +/// button, and what was checked sits underneath quietly, there to be believed rather than read. +struct UserInstallView: View { + + let manager: UserInstallManager + + /// Collapsed by default. What is behind it is for whoever writes the profile, not for the person + /// who just wanted to install something. + @State private var showDetails = false + + var body: some View { + VStack(spacing: 0) { + switch manager.stage { + case .assessing(let fileName): + busy(title: Constants.UserInstalls.checking, subtitle: fileName) + + case .allowed(let assessment): + allowed(assessment) + + case .refused(let assessment): + refused(assessment) + + case .installing(let assessment): + busy( + title: Constants.UserInstalls.installing, + subtitle: assessment.facts.displayName ?? assessment.facts.fileName + ) + + case .installed(let assessment): + finished(assessment) + + case .failed(let assessment, let message): + failed(assessment, message: message) + + case nil: + EmptyView() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .animation(.easeInOut(duration: 0.18), value: manager.stage) + } + + // MARK: States + + private func busy(title: String, subtitle: String) -> some View { + VStack(spacing: 12) { + ProgressView() + .controlSize(.large) + .padding(.bottom, 4) + + Text(title) + .font(.system(size: 15, weight: .semibold)) + + Text(subtitle) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .lineLimit(2) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(Layout.margin) + } + + private func allowed(_ assessment: InstallerAssessment) -> some View { + body( + assessment, + status: Status( + text: Constants.UserInstalls.allowedByAdministrator, + symbol: "checkmark.seal.fill", + tint: .green + ), + detail: { details(assessment) }, + actions: { + quiet(Constants.General.cancel) { manager.cancel() } + primary(Constants.UserInstalls.install) { manager.install() } + } + ) + } + + private func refused(_ assessment: InstallerAssessment) -> some View { + body( + assessment, + status: Status( + text: Constants.UserInstalls.notAllowed, + symbol: "exclamationmark.triangle.fill", + tint: .orange + ), + detail: { + VStack(alignment: .leading, spacing: 14) { + reasons(assessment.rejectionReasons) + + if let suggestion = manager.catalogSuggestion(for: assessment.facts) { + catalogBanner(suggestion) + } + + DisclosureGroup(isExpanded: $showDetails) { + identity(assessment) + .padding(.top, 8) + } label: { + Text(Constants.UserInstalls.details) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + } + .tint(.secondary) + } + }, + actions: { + quiet(Constants.General.close) { manager.cancel() } + + // When the organisation already ships this, that is the answer — not administrator + // rights, and not a password prompt the user cannot satisfy. The other routes are + // deliberately not offered alongside it. + if let suggestion = manager.catalogSuggestion(for: assessment.facts) { + primary(Constants.UserInstalls.showInCatalog) { manager.showCatalog(suggestion) } + } else { + switch assessment.fallback { + case .elevate: + primary(Constants.UserInstalls.elevateInstead) { manager.elevate() } + case .installer: + primary(Constants.UserInstalls.openInInstaller) { manager.openInInstaller() } + case .none: + EmptyView() + } + } + } + ) + } + + /// The approved copy, offered in place of the download the user went and found. + private func catalogBanner(_ suggestion: CatalogSuggestion) -> some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "arrow.down.app.fill") + .font(.system(size: 15)) + .foregroundStyle(.tint) + + VStack(alignment: .leading, spacing: 2) { + Text(Constants.UserInstalls.availableInCatalog) + .font(.system(size: 12, weight: .semibold)) + + Text(String(format: Constants.UserInstalls.availableInCatalogDetail, suggestion.destinationName)) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 0) + } + .padding(11) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.accentColor.opacity(0.10)) + ) + } + + private func failed(_ assessment: InstallerAssessment?, message: String) -> some View { + body( + assessment, + status: Status( + text: Constants.UserInstalls.failed, + symbol: "xmark.octagon.fill", + tint: .red + ), + detail: { + Text(message) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + }, + actions: { + quiet(Constants.General.close) { manager.closeWindow() } + primary(Constants.UserInstalls.openInInstaller) { manager.openInInstaller() } + } + ) + } + + private func finished(_ assessment: InstallerAssessment) -> some View { + VStack(spacing: 0) { + Spacer() + + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 44)) + .foregroundStyle(.green) + .padding(.bottom, 16) + + Text(assessment.facts.displayName ?? assessment.facts.fileName) + .font(.system(size: 17, weight: .semibold)) + .multilineTextAlignment(.center) + + Text(Constants.UserInstalls.installed) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .padding(.top, 2) + + Spacer() + + actionBar { + primary(Constants.General.done) { manager.closeWindow() } + } + } + .padding(Layout.margin) + } + + // MARK: Shared shape + + private struct Status { + let text: String + let symbol: String + let tint: Color + } + + /// Header, status, whatever detail the state has, then the actions pinned to the bottom. + /// + /// Every state that has something to show shares this, so the title, the status and the buttons sit + /// in exactly the same place as the window moves between them and nothing jumps. + private func body( + _ assessment: InstallerAssessment?, + status: Status, + @ViewBuilder detail: () -> Detail, + @ViewBuilder actions: () -> Actions + ) -> some View { + VStack(alignment: .leading, spacing: 0) { + if let assessment { + header(assessment) + .padding(.bottom, 16) + } + + statusPill(status) + + // One scroll region for everything between the status and the buttons, sized to the space + // that is left. A ScrollView has no height of its own, so nesting one next to a view that + // does means the scrolling one gets squeezed to nothing whenever the window is tight. + ScrollView { + detail() + .frame(maxWidth: .infinity, alignment: .leading) + } + .scrollBounceBehavior(.basedOnSize) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding(.top, 14) + + actionBar(content: actions) + .padding(.top, 16) + } + .padding(Layout.margin) + } + + private func header(_ assessment: InstallerAssessment) -> some View { + HStack(alignment: .center, spacing: 14) { + Image(systemName: assessment.facts.kind == .diskImage ? "app.dashed" : "shippingbox.fill") + .font(.system(size: 22, weight: .medium)) + .foregroundStyle(.secondary) + .frame(width: 46, height: 46) + .background( + RoundedRectangle(cornerRadius: 11, style: .continuous) + .fill(Color.primary.opacity(0.06)) + ) + + VStack(alignment: .leading, spacing: 3) { + Text(assessment.facts.displayName ?? assessment.facts.fileName) + .font(.system(size: 20, weight: .semibold)) + .lineLimit(1) + .truncationMode(.tail) + + Text(subtitle(for: assessment)) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer(minLength: 0) + } + } + + private func subtitle(for assessment: InstallerAssessment) -> String { + let kind = assessment.facts.kind == .diskImage + ? Constants.UserInstalls.kindApplication + : Constants.UserInstalls.kindPackage + + guard let version = assessment.facts.version else { return kind } + + return "\(Constants.UserInstalls.version) \(version) · \(kind)" + } + + private func statusPill(_ status: Status) -> some View { + HStack(spacing: 7) { + Image(systemName: status.symbol) + .font(.system(size: 12, weight: .semibold)) + + Text(status.text) + .font(.system(size: 12, weight: .medium)) + .fixedSize(horizontal: false, vertical: true) + .multilineTextAlignment(.leading) + } + .foregroundStyle(status.tint) + .padding(.horizontal, 11) + .padding(.vertical, 7) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(status.tint.opacity(0.12)) + ) + } + + /// What the helper checked. Reference material, so it is quiet and aligned rather than emphatic. + private func details(_ assessment: InstallerAssessment) -> some View { + Grid(alignment: .leading, horizontalSpacing: 14, verticalSpacing: 6) { + if let authority = assessment.facts.authority { + detailRow(Constants.UserInstalls.developer, authority) + } + + detailRow(Constants.UserInstalls.verification, verification(assessment)) + + if let entry = assessment.matchedEntry { + detailRow(Constants.UserInstalls.allowedAs, entry) + } + + // Where it lands. Worth showing even when nothing restricts it: "this browser also writes + // to /Library/LaunchDaemons" is the kind of thing somebody should be able to notice. + if !assessment.facts.payloadRoots.isEmpty { + detailRow( + Constants.UserInstalls.installsTo, + assessment.facts.payloadRoots.joined(separator: ", ") + ) + } + } + } + + private func verification(_ assessment: InstallerAssessment) -> String { + if assessment.matchMode == .strict { + return Constants.UserInstalls.verifiedByDigest + } + + return assessment.facts.notarized + ? Constants.UserInstalls.verifiedNotarized + : Constants.UserInstalls.verifiedSignature + } + + private func detailRow(_ label: String, _ value: String) -> some View { + GridRow { + Text(label) + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + .gridColumnAlignment(.leading) + + Text(value) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .help(value) + } + } + + private func reasons(_ reasons: [String]) -> some View { + VStack(alignment: .leading, spacing: 5) { + ForEach(reasons, id: \.self) { reason in + HStack(alignment: .firstTextBaseline, spacing: 7) { + Text("•") + .font(.system(size: 12)) + .foregroundStyle(.tertiary) + + Text(reason) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + /// Everything an administrator needs to write an entry for this installer, in one selectable block. + /// + /// A refusal is a dead end otherwise: the person looking at it either has to write the profile + /// entry or has to send someone the details, and making them transcribe a digest off a screenshot + /// turns a five-second fix into a ticket. + private func identity(_ assessment: InstallerAssessment) -> some View { + VStack(alignment: .leading, spacing: 7) { + if let authority = assessment.facts.authority { + identityRow(Constants.UserInstalls.developer, authority, monospaced: false) + } + + if let identifier = assessment.facts.identifiers.first { + identityRow(Constants.UserInstalls.identifier, identifier, monospaced: true) + } + + identityRow( + "\(Constants.UserInstalls.fingerprint) \(assessment.facts.fileName)", + assessment.facts.sha256, + monospaced: true + ) + + if let leaf = assessment.facts.leafCertificateSHA256 { + identityRow(Constants.UserInstalls.certificate, leaf, monospaced: true) + } + + // Where it would have installed. An administrator reading a refusal is deciding whether + // to allow this, and that decision is about what the installer does — not only about who + // signed it. + if !assessment.facts.payloadRoots.isEmpty { + identityRow( + Constants.UserInstalls.installsTo, + assessment.facts.payloadRoots.joined(separator: "\n"), + monospaced: true, + lineLimit: 6 + ) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.primary.opacity(0.04)) + ) + } + + private func identityRow(_ label: String, _ value: String, monospaced: Bool, lineLimit: Int = 2) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.tertiary) + .lineLimit(1) + + Text(value) + .font(.system(size: 10, design: monospaced ? .monospaced : .default)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .lineLimit(lineLimit) + .truncationMode(.middle) + .help(value) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + // MARK: Actions + + /// The buttons, right aligned on one baseline with room for the hover effect to grow into. + private func actionBar(@ViewBuilder content: () -> Content) -> some View { + HStack(alignment: .center, spacing: 10) { + Spacer(minLength: 0) + content() + } + // ScButton scales to 1.1 on hover; without this it clips against the window edge. + .padding(.horizontal, 4) + .padding(.top, 4) + } + + /// The one action worth taking, in the organisation's accent colour. + /// + /// No `maxWidth`: ScButton applies it as a hard frame, so a label longer than the number guessed + /// at the call site wraps onto a second line inside the button. Letting it hug its own text is + /// both tidier and one less number to get wrong when a string is translated. + private func primary(_ title: String, action: @escaping @MainActor () -> Void) -> some View { + ScButton(title, fontSize: 13) { + await MainActor.run { action() } + } + } + + /// The way out. Deliberately unfilled: two accent-coloured buttons side by side give the user no + /// clue which one the window is actually for. + private func quiet(_ title: String, action: @escaping @MainActor () -> Void) -> some View { + Button { + action() + } label: { + Text(title) + .font(.system(size: 13)) + .foregroundStyle(.primary) + // 16pt vertical to match ScButton's plain `.padding()`, so the two sit on one + // baseline at the same height. This is what made the row look ragged. + .padding(.horizontal, 20) + .padding(.vertical, 16) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.primary.opacity(0.07)) + ) + } + .buttonStyle(.plain) + } + + private enum Layout { + static let margin: CGFloat = 26 + } +} diff --git a/SupportCompanionTests/CatalogMatchingTests.swift b/SupportCompanionTests/CatalogMatchingTests.swift new file mode 100644 index 0000000..2c9bc5a --- /dev/null +++ b/SupportCompanionTests/CatalogMatchingTests.swift @@ -0,0 +1,60 @@ +// +// CatalogMatchingTests.swift +// SupportCompanionTests +// + +import Foundation +import Testing +@testable import SupportCompanion + +@Suite("Catalog matching") +struct CatalogMatchingTests { + + private let catalog = [ + CatalogEntry(name: "Firefox", bundleIdentifier: "org.mozilla.firefox"), + CatalogEntry(name: "Google Chrome", bundleIdentifier: "com.google.Chrome"), + CatalogEntry(name: "Slack", bundleIdentifier: nil), + ] + + private func facts( + fileName: String, + displayName: String? = nil, + identifiers: [String] = [] + ) -> InstallerFacts { + InstallerFacts(kind: .diskImage, fileName: fileName, sha256: "", identifiers: identifiers, displayName: displayName) + } + + @Test("Matches on bundle identifier regardless of what the file is called") + func byBundleIdentifier() { + let match = CatalogMatching.match( + facts(fileName: "fx-installer-final-2.dmg", identifiers: ["org.mozilla.firefox"]), + in: catalog + ) + #expect(match?.name == "Firefox") + } + + @Test("Matches on name once versions and punctuation are removed") + func byName() { + #expect(CatalogMatching.match(facts(fileName: "Firefox 156.0.dmg"), in: catalog)?.name == "Firefox") + #expect(CatalogMatching.match(facts(fileName: "Slack_V4.40.128.dmg"), in: catalog)?.name == "Slack") + #expect(CatalogMatching.match(facts(fileName: "x.pkg", displayName: "Google Chrome"), in: catalog)?.name == "Google Chrome") + } + + @Test("Does not match a different application with a similar name") + func noNearMisses() { + #expect(CatalogMatching.match(facts(fileName: "Firefox Developer Edition.dmg"), in: catalog) == nil) + #expect(CatalogMatching.match(facts(fileName: "autopkg-2.9.0.pkg"), in: catalog) == nil) + #expect(CatalogMatching.match(facts(fileName: "Firefox.dmg"), in: []) == nil) + } + + @Test("An identifier for something else does not drag in a name match") + func identifierWins() { + // The bundle identifier is authoritative, so a file named after one app carrying another's + // identifier resolves to the identifier. + let match = CatalogMatching.match( + facts(fileName: "Firefox 156.0.dmg", identifiers: ["com.google.Chrome"]), + in: catalog + ) + #expect(match?.name == "Google Chrome") + } +} diff --git a/SupportCompanionTests/InstallerPolicyTests.swift b/SupportCompanionTests/InstallerPolicyTests.swift new file mode 100644 index 0000000..088a7a1 --- /dev/null +++ b/SupportCompanionTests/InstallerPolicyTests.swift @@ -0,0 +1,481 @@ +// +// InstallerPolicyTests.swift +// SupportCompanionTests +// + +import Foundation +import Testing +@testable import SupportCompanion + +@Suite("Installer allowlist") +struct InstallerPolicyTests { + + // MARK: Fixtures + + private func entry(_ dictionary: [String: Any]) -> AllowedInstaller { + let (entry, problem) = AllowedInstaller.make(from: dictionary) + #expect(problem == nil) + return try! #require(entry) + } + + private var chrome: AllowedInstaller { + entry([ + "Name": "Google Chrome", + "TeamID": "EQHXZ8M8AV", + "PackageIdentifier": "com.google.Chrome", + "MinimumVersion": "141.0", + ]) + } + + private func facts( + kind: InstallerFacts.Kind = .package, + team: String? = "EQHXZ8M8AV", + identifiers: [String] = ["com.google.Chrome"], + version: String? = "141.0.1", + trusted: Bool = true, + notarized: Bool = true, + scripts: Bool = false, + remote: Bool = false, + sha256: String = String(repeating: "a", count: 64), + leafCertificateSHA256: String? = String(repeating: "c", count: 64), + payloadRoots: [String] = ["/Applications/Google Chrome.app"] + ) -> InstallerFacts { + InstallerFacts( + kind: kind, + fileName: "installer.pkg", + sha256: sha256, + teamID: team, + leafCertificateSHA256: leafCertificateSHA256, + signatureTrusted: trusted, + notarized: notarized, + identifiers: identifiers, + version: version, + hasScripts: scripts, + hasRemoteReferences: remote, + payloadRoots: payloadRoots + ) + } + + private func isAllowed(_ facts: InstallerFacts, _ allowlist: [AllowedInstaller]) -> Bool { + InstallerPolicy.evaluate(facts, against: allowlist).entry != nil + } + + // MARK: Signature matching + + @Test("Allows a package whose team, identifier and version all match") + func matches() { + #expect(isAllowed(facts(), [chrome])) + } + + @Test("Refuses a package signed by another team") + func wrongTeam() { + #expect(!isAllowed(facts(team: "XXXXXXXXXX"), [chrome])) + } + + @Test("Refuses a package the right team signed but that identifies as something else") + func wrongIdentifier() { + #expect(!isAllowed(facts(identifiers: ["com.example.Other"]), [chrome])) + } + + @Test("Refuses a package whose signature does not chain to a trusted certificate") + func untrusted() { + #expect(!isAllowed(facts(trusted: false), [chrome])) + } + + @Test("Refuses a package that is not notarized, unless the entry says otherwise") + func notarization() { + #expect(!isAllowed(facts(notarized: false), [chrome])) + + let relaxed = entry([ + "Name": "Google Chrome", "TeamID": "EQHXZ8M8AV", + "PackageIdentifier": "com.google.Chrome", "RequireNotarized": false, + ]) + #expect(isAllowed(facts(notarized: false), [relaxed])) + } + + @Test("Refuses a package carrying install scripts, unless the entry allows them") + func scripts() { + #expect(!isAllowed(facts(scripts: true), [chrome])) + + let relaxed = entry([ + "Name": "Google Chrome", "TeamID": "EQHXZ8M8AV", + "PackageIdentifier": "com.google.Chrome", "AllowScripts": true, + ]) + #expect(isAllowed(facts(scripts: true), [relaxed])) + } + + @Test("Refuses a package that downloads more packages while installing, whatever the entry says") + func remoteReferences() { + let permissive = entry([ + "Name": "Anything", "TeamID": "EQHXZ8M8AV", + "AllowAnyIdentifier": true, "AllowScripts": true, "RequireNotarized": false, + ]) + #expect(!isAllowed(facts(remote: true), [permissive])) + } + + @Test("Refuses a version below the entry's minimum") + func versionFloor() { + #expect(!isAllowed(facts(version: "140.9"), [chrome])) + #expect(!isAllowed(facts(version: nil), [chrome])) + } + + @Test("Refuses everything when nothing has been allowed") + func emptyAllowlist() { + #expect(!isAllowed(facts(), [])) + } + + @Test("Matches a disk image on its bundle identifier rather than a package identifier") + func diskImageIdentifier() { + let app = entry([ + "Name": "Demo", "TeamID": "EQHXZ8M8AV", "BundleIdentifier": "com.example.Demo", + ]) + + #expect(isAllowed(facts(kind: .diskImage, identifiers: ["com.example.Demo"], version: nil), [app])) + // The same entry must not let the package through: the keys are per kind on purpose. + #expect(!isAllowed(facts(identifiers: ["com.example.Demo"], version: nil), [app])) + } + + // MARK: Components + + @Test("Refuses a package that installs a component the entry does not name") + func uncoveredComponent() { + let appOnly = entry([ + "Name": "Alpha", "TeamID": "EQHXZ8M8AV", "PackageIdentifier": "com.example.alpha", + "RequireNotarized": false, + ]) + + let twoComponents = facts(identifiers: ["com.example.alpha", "com.example.beta"], version: nil, notarized: false) + #expect(!isAllowed(twoComponents, [appOnly])) + + let both = entry([ + "Name": "Alpha", "TeamID": "EQHXZ8M8AV", + "PackageIdentifier": ["com.example.alpha", "com.example.beta"], + "RequireNotarized": false, + ]) + #expect(isAllowed(twoComponents, [both])) + } + + @Test("Names the component it objected to") + func uncoveredComponentIsNamed() { + let appOnly = entry([ + "Name": "Alpha", "TeamID": "EQHXZ8M8AV", "PackageIdentifier": "com.example.alpha", + "RequireNotarized": false, + ]) + + let decision = InstallerPolicy.evaluate( + facts(identifiers: ["com.example.alpha", "com.example.beta"], version: nil, notarized: false), + against: [appOnly] + ) + + #expect(decision.entry == nil) + #expect(decision.reasons.contains { $0.contains("com.example.beta") }) + } + + // MARK: Payload prefixes + + @Test("Lets ordinary software install with nothing configured") + func ordinarySoftwareNeedsNoPayloadConfiguration() { + // These are what real installers do. Refusing them by default would make the feature + // unusable for most software and push administrators towards AllowUnrestrictedPayload. + for root in [ + "/Applications/Google Chrome.app", + "/Library/Application Support/Example", + "/Library/LaunchDaemons/com.vendor.plist", + "/Library/LaunchAgents/com.vendor.plist", + "/usr/local/bin/tool", + "/usr/local/lib/libtool.dylib", + "/Library/Extensions/Vendor.kext", + "/Library/QuickLook/Vendor.qlgenerator", + "/etc/paths.d/vendor", + ] { + #expect(isAllowed(facts(payloadRoots: ["/Applications/Example.app", root]), [chrome]), "\(root) should install without configuration") + } + } + + @Test("Still refuses the places that would take over the control itself") + func protectsTheControlItself() { + for root in [ + "/Library/PrivilegedHelperTools/com.example.helper", + "/Library/Security/SecurityAgentPlugins/x.bundle", + "/Library/ScriptingAdditions/Example.osax", + "/etc/sudoers.d/example", + "/etc/pam.d/authorization", + "/private/etc/ssh/sshd_config", + "/var/db/dslocal/nodes/Default/users/admin.plist", + "/var/root/.ssh/authorized_keys", + ] { + #expect(!isAllowed(facts(payloadRoots: ["/Applications/Example.app", root]), [chrome]), "\(root) should be protected") + } + } + + @Test("Protects the preferences that hold the allowlist itself") + func protectsItsOwnPolicy() { + // A package writing these rewrites the list that authorised it, plus EnforceAdminAllowlist + // and PermanentAdmins — one install becoming lasting control of the whole feature. + #expect(!isAllowed(facts(payloadRoots: ["/Library/Preferences/com.github.macadmins.SupportCompanion.plist"]), [chrome])) + #expect(!isAllowed(facts(payloadRoots: ["/Library/Managed Preferences/com.github.macadmins.SupportCompanion.plist"]), [chrome])) + } + + @Test("Catches a payload root sitting above a protected prefix, not only inside one") + func denyListIsBidirectional() { + // payloadRoots truncate towards the root, so a root can be an ancestor of a protected path. + // "/Library" contains /Library/Preferences, "/etc" contains /etc/sudoers.d. + #expect(!isAllowed(facts(payloadRoots: ["/Library"]), [chrome])) + #expect(!isAllowed(facts(payloadRoots: ["/etc"]), [chrome])) + + // ...while a prefix containing nothing protected is fine, which is the point of the change: + // /usr/local is ordinary software territory again. + #expect(isAllowed(facts(payloadRoots: ["/usr/local"]), [chrome])) + } + + @Test("Checks every destination, however many there are") + func everyRootIsChecked() { + // A package with more destinations than the window shows, where the dangerous one sorts last. + var roots = (1...30).map { "/Applications/a\(String(format: "%02d", $0)).app" } + roots.append("/Library/PrivilegedHelperTools/com.example.helper") + + #expect(!isAllowed(facts(payloadRoots: roots), [chrome])) + + let decision = InstallerPolicy.evaluate(facts(payloadRoots: roots), against: [chrome]) + #expect(decision.reasons.contains { $0.contains("PrivilegedHelperTools") }) + } + + @Test("Shortening for the window happens after the decision, not before it") + func shorteningIsDisplayOnly() { + let roots = (1...30).map { "/Applications/a\($0).app" } + let full = facts(payloadRoots: roots) + + #expect(full.payloadRoots.count == 30) + + let shown = full.shortenedForDisplay() + #expect(shown.payloadRoots.count == InstallerFacts.displayedPayloadRoots + 1) + #expect(shown.payloadRoots.last?.contains("more") == true) + + // The shortened copy is for the sheet; the original is what was judged. + #expect(full.payloadRoots.count == 30) + } + + @Test("Naming prefixes explicitly replaces the protection rather than adding to it") + func explicitPrefixesOverrideTheDenyList() { + let daemon = entry([ + "Name": "Google Chrome", "TeamID": "EQHXZ8M8AV", + "PackageIdentifier": "com.google.Chrome", + "AllowedPayloadPrefixes": ["/Applications", "/Library/LaunchDaemons"], + ]) + + #expect(isAllowed(facts(payloadRoots: ["/Applications/Google Chrome.app", "/Library/LaunchDaemons/x.plist"]), [daemon])) + } + + @Test("Unrestricted needs its own key, not a prefix of /") + func unrestrictedIsExplicit() { + let unrestricted = entry([ + "Name": "Google Chrome", "TeamID": "EQHXZ8M8AV", + "PackageIdentifier": "com.google.Chrome", + "AllowUnrestrictedPayload": true, + ]) + + #expect(isAllowed(facts(payloadRoots: ["/etc/sudoers.d/x", "/var/root/x"]), [unrestricted])) + } + + @Test("Refuses a package that writes outside the prefixes it was given") + func payloadPrefixes() { + let confined = entry([ + "Name": "Google Chrome", "TeamID": "EQHXZ8M8AV", + "PackageIdentifier": "com.google.Chrome", + "AllowedPayloadPrefixes": ["/Applications"], + ]) + + #expect(isAllowed(facts(payloadRoots: ["/Applications/Google Chrome.app"]), [confined])) + #expect(!isAllowed( + facts(payloadRoots: ["/Applications/Google Chrome.app", "/Library/LaunchDaemons/x.plist"]), + [confined] + )) + } + + @Test("Matches prefixes at path boundaries, not as plain text") + func prefixBoundaries() { + #expect(InstallerPolicy.isPath("/Applications/Firefox.app", under: "/Applications")) + #expect(InstallerPolicy.isPath("/Applications/Firefox.app", under: "/Applications/Firefox.app")) + #expect(!InstallerPolicy.isPath("/Applications/Firefox.app", under: "/Applications/Fire")) + #expect(!InstallerPolicy.isPath("/Library/LaunchDaemons", under: "/Applications")) + } + + @Test("A digest pins the bytes, not where they land") + func strictStillChecksPayload() { + let strict = entry([ + "Name": "In-house tool", + "SHA256": String(repeating: "a", count: 64), + "RequireNotarized": false, + "AllowedPayloadPrefixes": ["/Applications"], + ]) + + #expect(!isAllowed(facts(payloadRoots: ["/Library/LaunchDaemons/x.plist"]), [strict])) + } + + // MARK: Certificate pin + + @Test("Ignores the certificate unless an entry pins one") + func certificateNotPinned() { + #expect(isAllowed(facts(leafCertificateSHA256: String(repeating: "f", count: 64)), [chrome])) + #expect(isAllowed(facts(leafCertificateSHA256: nil), [chrome])) + } + + @Test("Refuses a different certificate when one is pinned") + func certificatePinned() { + let pinned = entry([ + "Name": "Google Chrome", "TeamID": "EQHXZ8M8AV", + "PackageIdentifier": "com.google.Chrome", + "LeafCertificateSHA256": String(repeating: "C", count: 64), + ]) + + // Case and spacing are how a digest gets pasted out of pkgutil, so both are tolerated. + #expect(isAllowed(facts(leafCertificateSHA256: String(repeating: "c", count: 64)), [pinned])) + #expect(!isAllowed(facts(leafCertificateSHA256: String(repeating: "d", count: 64)), [pinned])) + #expect(!isAllowed(facts(leafCertificateSHA256: nil), [pinned])) + } + + @Test("Refuses a malformed certificate pin rather than ignoring it") + func certificatePinMustBeADigest() { + #expect(AllowedInstaller.make(from: [ + "Name": "X", "TeamID": "EQHXZ8M8AV", "PackageIdentifier": "com.x", + "LeafCertificateSHA256": "not-a-digest", + ]).entry == nil) + + // Spaces are stripped, since that is how pkgutil prints it. + #expect(AllowedInstaller.make(from: [ + "Name": "X", "TeamID": "EQHXZ8M8AV", "PackageIdentifier": "com.x", + "LeafCertificateSHA256": "A3 D1 49 1B 4B 09 F8 D2 4E A8 32 D3 0C B3 5A 3A C0 32 08 3F 37 8A 6A 16 95 D0 CB C1 57 74 BA 99", + ]).entry?.leafCertificateSHA256 == "a3d1491b4b09f8d24ea832d30cb35a3ac032083f378a6a1695d0cbc15774ba99") + } + + @Test("A pinned certificate still applies in strict mode") + func certificatePinInStrictMode() { + let strict = entry([ + "Name": "In-house", "SHA256": String(repeating: "a", count: 64), + "RequireNotarized": false, + "LeafCertificateSHA256": String(repeating: "c", count: 64), + ]) + + #expect(isAllowed(facts(), [strict])) + #expect(!isAllowed(facts(leafCertificateSHA256: String(repeating: "d", count: 64)), [strict])) + } + + // MARK: Strict matching + + @Test("Allows only the exact digest in strict mode") + func strictDigest() { + let strict = entry([ + "Name": "In-house tool", + "SHA256": String(repeating: "a", count: 64), + "RequireNotarized": false, + ]) + + #expect(isAllowed(facts(team: nil, identifiers: [], version: nil, trusted: false), [strict])) + #expect(!isAllowed(facts(sha256: String(repeating: "b", count: 64)), [strict])) + } + + @Test("A digest does not excuse the entry's other conditions") + func strictStillChecksTheRest() { + let strict = entry(["Name": "In-house tool", "SHA256": String(repeating: "a", count: 64)]) + #expect(!isAllowed(facts(notarized: false), [strict])) + } + + // MARK: Entry validation + + @Test("Drops an entry that could never match") + func invalidEntries() { + #expect(AllowedInstaller.make(from: ["Name": "X"]).entry == nil) + #expect(AllowedInstaller.make(from: ["TeamID": "EQHXZ8M8AV"]).entry == nil) + #expect(AllowedInstaller.make(from: ["Name": "X", "SHA256": "tooshort"]).entry == nil) + } + + @Test("Drops a team-only entry unless it says AllowAnyIdentifier") + func teamWithoutIdentifier() { + #expect(AllowedInstaller.make(from: ["Name": "X", "TeamID": "EQHXZ8M8AV"]).entry == nil) + + let vendor = entry(["Name": "X", "TeamID": "EQHXZ8M8AV", "AllowAnyIdentifier": true]) + #expect(isAllowed(facts(identifiers: ["com.anything.At.All"], version: nil), [vendor])) + #expect(!isAllowed(facts(team: "OTHER00000", identifiers: ["com.anything.At.All"], version: nil), [vendor])) + } + + @Test("Accepts several identifiers for one entry") + func identifierList() { + let either = entry([ + "Name": "X", "TeamID": "EQHXZ8M8AV", + "PackageIdentifier": ["com.example.One", "com.example.Two"], + ]) + + #expect(isAllowed(facts(identifiers: ["com.example.Two"], version: nil), [either])) + #expect(!isAllowed(facts(identifiers: ["com.example.Three"], version: nil), [either])) + } + + // MARK: Versions + + @Test("Compares versions numerically rather than as text", arguments: [ + ("10.10", "10.2", false), + ("10.2", "10.10", true), + ("141.0.1", "141", false), + ("141.0", "141.0", false), + ("2", "10", true), + ]) + func versionComparison(version: String, minimum: String, older: Bool) { + #expect(InstallerPolicy.isVersion(version, olderThan: minimum) == older) + } + + // MARK: Reasons + + @Test("Explains the refusal using the entry that came closest") + func reasons() { + let unrelated = entry(["Name": "Firefox", "TeamID": "43AQ936H96", "PackageIdentifier": "org.mozilla.firefox"]) + let decision = InstallerPolicy.evaluate(facts(version: "140.0"), against: [unrelated, chrome]) + + #expect(decision.entry == nil) + #expect(decision.reasons.count == 1) + + // What the user sees never names the entry; the log keeps the full attribution. + #expect(!decision.reasons[0].contains("Google Chrome")) + #expect(decision.reasons[0].contains("141.0")) + #expect(decision.logReasons[0].hasPrefix("Google Chrome:")) + } + + @Test("Tells the user nothing about the rest of the list when no entry was written for it") + func unlistedSaysNothingAboutOtherEntries() { + let unrelated = entry([ + "Name": "Firefox", "TeamID": "43AQ936H96", "BundleIdentifier": "org.mozilla.firefox", + ]) + + let decision = InstallerPolicy.evaluate( + facts(identifiers: ["com.github.autopkg.autopkg"], version: nil, notarized: false), + against: [unrelated] + ) + + #expect(decision.entry == nil) + #expect(decision.reasons == [InstallerPolicy.unlisted]) + #expect(!decision.reasons[0].contains("Firefox")) + #expect(!decision.reasons[0].contains("mozilla")) + } + + // MARK: Transport + + @Test("An assessment survives the trip over the connection as JSON") + func jsonRoundTrip() throws { + let assessment = InstallerAssessment( + token: UUID().uuidString, + facts: facts(), + isAllowed: true, + matchedEntry: "Google Chrome", + matchMode: .signature, + rejectionReasons: [], + fallback: .elevate, + requiresAuthentication: true + ) + + #expect(try InstallerAssessment.make(fromJSON: assessment.jsonString()) == assessment) + } + + @Test("An unknown fallback falls back to handing the file to Installer.app") + func fallbackParsing() { + #expect(InstallerAssessment.Fallback(rawValue: "elevate") == .elevate) + #expect(InstallerAssessment.Fallback(rawValue: "nonsense") == nil) + } +} From 44e4275d9fbfa34a159923cc4049dea5fbeae87e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Alm=C3=A9n?= <78877636+almenscorner@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:01:24 +0200 Subject: [PATCH 095/104] Translate the remaining strings, tint the progress wave, tidy the sidebar The string catalog gains German, French, Japanese, Norwegian and Swedish for the 85 strings that were still English only. The patching progress wave takes the configured accent colour instead of the system one, so it matches the rest of a branded install. The dark mode toggle and the sidebar rows get their spacing and highlight tidied up. Date.relativeDescription replaces the formatted(.relative:) calls in the Fleet cards, which produced "in 0 seconds" for a date that had just passed. Sharpens the CHANGELOG wording on the privileged settings and helper version checks, which described the fix rather than what it prevented. --- CHANGELOG.md | 6 +- SupportCompanion/App/ContentView.swift | 88 +- SupportCompanion/Extensions/Extensions.swift | 22 + SupportCompanion/Localizable.xcstrings | 3058 +++++++++++++++-- .../ViewModels/FleetDeviceManager.swift | 2 +- .../Views/Cards/FleetInfoCard.swift | 40 +- .../Views/Cards/PatchingProgressCard.swift | 3 +- .../Views/CircularProgressWave.swift | 24 +- .../Views/DarkLightModeToggle.swift | 35 +- .../Views/Fleet/FleetComplianceView.swift | 2 +- 10 files changed, 2945 insertions(+), 335 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5076c4..a5e5140 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [3.0.0] - 2026-09-17 ### Breaking changes -- **Privileged settings are only read when set by an administrator.** `IsPrivileged` on `Actions`, `RequirePrivilegedActionAuthentication`, and the elevation settings (`EnableElevation`, `MaxElevationTime`, `RequireResonForElevation`, `ReasonMinLength`, `ElevationWebhookUrl`, `ElevationSeverity`) are now only honored when they come from a configuration profile or `/Library/Preferences`. Before, a standard user could set them in their own preferences with `defaults write` and gain root through the helper or add themselves to the admin group. Actions defined in the user's own preferences still run, but never with privileges, and `RequirePrivilegedActionAuthentication` defaults to `true` unless an administrator sets it. **If you deploy these settings with `defaults write` as the user, move them to a configuration profile or `/Library/Preferences`.** +- **Privileged settings are only read when set by an administrator.** `IsPrivileged` on `Actions`, `RequirePrivilegedActionAuthentication`, and the elevation settings (`EnableElevation`, `MaxElevationTime`, `RequireResonForElevation`, `ReasonMinLength`, `ElevationWebhookUrl`, `ElevationSeverity`) are now only honored when they come from a configuration profile or `/Library/Preferences`. These settings decide what runs as root and who gets administrator rights, so honoring them from a domain the user can write to was a local privilege escalation. Actions defined in the user's own preferences still run, but never with privileges, and `RequirePrivilegedActionAuthentication` defaults to `true` unless an administrator sets it. **If you deploy these settings with `defaults write` as the user, move them to a configuration profile or `/Library/Preferences`.** - **Fleet mode is selected automatically** on Macs with Fleet's agent (orbit) installed when no other mode matches. Before, these Macs used System Profiler mode. Set `Mode` explicitly to keep a different mode. - **The privileged helper is installed by the package's `helper_install.zsh`**, which places it in `/Library/PrivilegedHelperTools` and loads its LaunchDaemon. `SMJobBless` is deprecated in macOS 14 and later; `SMAppService` registration remains only as a fallback for a Mac where the packaged helper is missing, and is skipped entirely when `SkipHelperInstall` says the helper is deployed declaratively. - **Existing helper installs:** the package now replaces the helper on every install, unloading the old one first and loading the new one, and the install fails if the helper does not end up running. The app and the helper speak a versioned interface, so a Mac left with an older helper is not merely stale — every privileged operation fails — and that must not pass silently. -- **The helper only serves 3.0.0 and later clients.** A valid signature proves a connecting app is a Support Companion build signed by us, but every 2.x release satisfies that too — and in 2.x, `IsPrivileged` was honored from preference domains a standard user can write. Keeping a 2.x copy on disk and running it was therefore enough to reach the helper and get root, no matter what this version does. The helper now checks the connecting app's version, requires it to be signed with the hardened runtime, and requires it to run from `/Applications/SupportCompanion.app`, which a standard user cannot write to. +- **The helper only serves 3.0.0 and later clients.** A valid signature proves only that a connecting app is a Support Companion build signed by us, which every 2.x release satisfies too. Signature alone therefore cannot tell this version's client apart from an older one that enforced less, so the restrictions added here would not hold if an earlier build could still connect. The helper now checks the connecting app's version, requires it to be signed with the hardened runtime, and requires it to run from `/Applications/SupportCompanion.app`, which a standard user cannot write to. - **The helper no longer runs commands on request.** Its interface was a pair of methods that ran any command or script as root, so every restriction on what could run — `IsPrivileged`, `EnableElevation` — was enforced only in the app, and any code running in the app process was equivalent to root. It now exposes named operations instead, and decides what each one runs. For privileged actions the app sends only the action's name; the helper looks the `Command` up in the administrator-managed preferences itself, so what runs as root always comes from an administrator. `EnableElevation` is re-checked in the helper rather than only hiding a button. ### Security @@ -19,7 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Elevation reasons are also recorded where the user cannot edit them.** The reason log in the user's Application Support folder is writable by that user, so it was never an audit trail. The helper keeps its own root-owned log alongside the elevation state. Reasons are flattened to a single line and bounded in length before being written, so a reason containing newlines cannot forge entries in that log, and one containing a great deal of text cannot inflate it. - **Uninstalling hands back any administrator rights it was holding.** The uninstaller removes the helper, which is what would have taken those rights away, so uninstalling during an elevation window previously left the elevated user a permanent administrator. It now demotes anyone still elevated before removing anything, and clears the helper's state directory. - **More than one user can be elevated at a time.** With fast user switching two accounts can each be logged in and each ask for rights. Each window is now tracked separately with its own deadline and timer, rather than the second replacing the first and leaving that user elevated with nothing left to demote them. -- The package's preinstall script ran `Uninstall.sh` from a fixed path inside a 1.x install as root. It now runs it only when that file is owned by root and not writable by group or others. +- The package's preinstall script now verifies that the 1.x `Uninstall.sh` it runs is owned by root and not writable by group or others before executing it. ### Added - **`EnforceAdminAllowlist` and `PermanentAdmins`, to stop an elevation outliving its window.** The helper's deadline lives in a root-owned file, and a user who is briefly an administrator is briefly root, so they can delete it and restart the helper — leaving nothing to say a demotion was owed, and no way to tell their rights from a permanent administrator's. With an allowlist set, the helper reconciles the `admin` group at startup and every five minutes: anyone holding administrator rights who is neither in `PermanentAdmins` nor inside a live elevation window is demoted. Deleting the state file then removes the only evidence that an elevation was legitimate, so it ends the elevation rather than extending it. `root` is always permitted and never demoted, and the policy is re-read on every pass, so removing the profile only suspends enforcement while it is actually missing. diff --git a/SupportCompanion/App/ContentView.swift b/SupportCompanion/App/ContentView.swift index f406bcf..5adbcf9 100644 --- a/SupportCompanion/App/ContentView.swift +++ b/SupportCompanion/App/ContentView.swift @@ -5,8 +5,8 @@ // Created by Tobias Almén on 2024-11-11. // -import SwiftUI import Combine +import SwiftUI struct ContentView: View { @State private var selectedItem: SidebarItem? @@ -22,12 +22,15 @@ struct ContentView: View { @Environment(\.colorScheme) var colorScheme var body: some View { - let sidebarItems: [SidebarItem] = generateSidebarItems(preferences: appState.preferences, stateManager: webViewStateManager, cardGridViewModel: cardGridViewModel, pendingUpdatesCount: appState.pendingUpdatesCount, failingChecksCount: appState.fleetFailingChecksCount) + let sidebarItems: [SidebarItem] = generateSidebarItems( + preferences: appState.preferences, stateManager: webViewStateManager, + cardGridViewModel: cardGridViewModel, pendingUpdatesCount: appState.pendingUpdatesCount, + failingChecksCount: appState.fleetFailingChecksCount) let accentColor = Color(accentNSColor) - + NavigationSplitView { VStack(spacing: 10) { - Spacer() // Push content to the center dynamically + Spacer() // Push content to the center dynamically // Logo Section if showLogo, let logo = brandLogo { @@ -39,7 +42,7 @@ struct ContentView: View { .frame(maxWidth: 230) .drawingGroup() .fixedSize(horizontal: false, vertical: true) - .padding(.top, 20) // Minimal padding + .padding(.top, 20) // Minimal padding .padding(.horizontal, 20) } @@ -48,10 +51,10 @@ struct ContentView: View { Text(appState.preferences.branding.brandName) .font(.title) .multilineTextAlignment(.center) - .padding(.top, 20) // Bring the title closer to the logo + .padding(.top, 20) // Bring the title closer to the logo } - Spacer() // Push content to the center dynamically + Spacer() // Push content to the center dynamically // Sidebar List (custom to avoid List clipping) SidebarListView( @@ -86,33 +89,34 @@ struct ContentView: View { .background(Color.clear) } .navigationSplitViewColumnWidth( - min: 280, ideal: 280, max: 320) + min: 280, ideal: 280, max: 320 + ) .frame(maxHeight: .infinity, alignment: .top) .background(Color.clear) } detail: { - Group{ - if let selectedItem = selectedItem { - selectedItem.destination - .id(selectedItem.id) - } else { - Text("Select an option") // Placeholder if nothing is selected - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color.gray.opacity(0.1)) - } + Group { + if let selectedItem = selectedItem { + selectedItem.destination + .id(selectedItem.id) + } else { + Text("Select an option") // Placeholder if nothing is selected + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.gray.opacity(0.1)) + } + } + .toolbar { + ToolbarItem(placement: .automatic) { + ToolbarSupportButton(isShowingPopup: $isShowingPopup) } - .toolbar { - ToolbarItem(placement: .automatic) { - ToolbarSupportButton(isShowingPopup: $isShowingPopup) - } - if #available(macOS 26.0, *) { - ToolbarSpacer(.fixed) - } + if #available(macOS 26.0, *) { + ToolbarSpacer(.fixed) + } - ToolbarItem(placement: .automatic) { - ToolbarDarkModeToggleView() - } + ToolbarItem(placement: .automatic) { + ToolbarDarkModeToggleView() } + } } .sheet(isPresented: $isShowingPopup) { // The popup content @@ -176,9 +180,11 @@ struct ContentView: View { struct ToolbarSupportButton: View { @Environment(AppStateManager.self) var appState @Binding var isShowingPopup: Bool - + var body: some View { - if !appState.preferences.supportEmail.isEmpty && !appState.preferences.supportPhone.isEmpty { + if !appState.preferences.supportEmail.isEmpty + && !appState.preferences.supportPhone.isEmpty + { Button { isShowingPopup = true } label: { @@ -206,16 +212,16 @@ struct ContentView: View { var body: some View { ZStack { - // Background layers: selected capsule or hover capsule + // Background layers: selected highlight or hover highlight if isSelected { - Capsule() + RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(accentColor) .matchedGeometryEffect(id: "sidebar-highlight", in: namespace) - .frame(height: 50) + .frame(height: 38) } else if isHovered { - Capsule() + RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(Color.primary.opacity(0.08)) - .frame(height: 50) + .frame(height: 38) } // Row content @@ -239,7 +245,7 @@ struct ContentView: View { } } .padding(.horizontal, 15) - .padding(.vertical, 15) + .padding(.vertical, 10) } .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) @@ -290,15 +296,19 @@ private struct SidebarListView: View { onIncomingURL(url) } } - .onChange(of: AppStateManager.shared.preferences.branding.brandLogo) { _, _ in onBrandLogoChange() } - .onChange(of: AppStateManager.shared.preferences.branding.brandLogoLight) { _, _ in onBrandLogoLightChange() } + .onChange(of: AppStateManager.shared.preferences.branding.brandLogo) { _, _ in + onBrandLogoChange() + } + .onChange(of: AppStateManager.shared.preferences.branding.brandLogoLight) { _, _ in + onBrandLogoLightChange() + } } @Environment(\.colorScheme) private var colorScheme } -private extension ContentView { - var accentNSColor: NSColor { +extension ContentView { + fileprivate var accentNSColor: NSColor { NSColor(hex: appState.preferences.branding.accentColor ?? "") ?? NSColor.controlAccentColor } } diff --git a/SupportCompanion/Extensions/Extensions.swift b/SupportCompanion/Extensions/Extensions.swift index d7855d7..e684027 100644 --- a/SupportCompanion/Extensions/Extensions.swift +++ b/SupportCompanion/Extensions/Extensions.swift @@ -143,6 +143,28 @@ extension TimeInterval { } } +extension Date { + /// Relative time for card rows, e.g. "5 minutes ago". + /// + /// Swedish and Norwegian build this with a leading preposition — "för 5 minuter sedan", + /// "for 5 minutter siden" — which is long for a row that sits next to a label. The + /// preposition is dropped for those two languages; the trailing "sedan"/"siden" still + /// carries the meaning, so the result reads correctly. Languages that carry the meaning + /// in the preposition instead, such as German ("vor 5 Minuten") and French + /// ("il y a 5 minutes"), are left alone, as is a named day like "i förrgår". + func relativeDescription() -> String { + let formatted = formatted(.relative(presentation: .named)) + let preposition: String + switch Locale.current.language.languageCode?.identifier { + case "sv": preposition = "för " + case "nb", "nn", "no": preposition = "for " + default: return formatted + } + guard formatted.hasPrefix(preposition) else { return formatted } + return String(formatted.dropFirst(preposition.count)) + } +} + extension Color { // Orange shades static let orangeLight = Color(hue: 0.1, saturation: 0.9, brightness: 0.75) // Softer orange for light mode diff --git a/SupportCompanion/Localizable.xcstrings b/SupportCompanion/Localizable.xcstrings index 8de4a74..b6b71de 100644 --- a/SupportCompanion/Localizable.xcstrings +++ b/SupportCompanion/Localizable.xcstrings @@ -326,11 +326,41 @@ "comment" : "Button that asks Fleet to re-check this Mac", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erneut prüfen" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Re-check" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Revérifier" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "再チェック" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sjekk på nytt" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kontrollera igen" + } } } }, @@ -338,11 +368,41 @@ "comment" : "Help text for the re-check button", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fleet auffordern, die Daten dieses Macs zu aktualisieren und die Konformitätsprüfungen erneut auszuführen." + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Ask Fleet to update this Mac's details and re-run its compliance checks." } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Demander à Fleet de mettre à jour les informations de ce Mac et de relancer ses vérifications de conformité." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "このMacの情報を更新し、コンプライアンスチェックを再実行するようFleetに要求します。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Be Fleet om å oppdatere opplysningene for denne Macen og kjøre samsvarskontrollene på nytt." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Be Fleet att uppdatera informationen om den här Macen och köra efterlevnadskontrollerna igen." + } } } }, @@ -350,11 +410,41 @@ "comment" : "Shown while Fleet re-checks this Mac", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wird erneut geprüft…" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Re-checking…" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Revérification…" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "再チェック中…" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sjekker på nytt…" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kontrollerar igen…" + } } } }, @@ -454,11 +544,41 @@ "comment" : "Time to full value while macOS is still estimating the charge time", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wird berechnet…" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Calculating…" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Calcul en cours…" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "計算中…" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beregner…" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beräknar…" + } } } }, @@ -550,11 +670,41 @@ "comment" : "Time to full value when the battery is full on external power", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vollständig geladen" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Fully Charged" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Charge complète" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "フル充電" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fulladet" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fulladdat" + } } } }, @@ -1058,11 +1208,41 @@ "comment" : "Title for the card listing failing Fleet policies", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gerätekonformität" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Device Compliance" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conformité de l’appareil" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "デバイスコンプライアンス" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enhetssamsvar" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enhetsefterlevnad" + } } } }, @@ -1843,11 +2023,41 @@ "comment" : "Label for SSID", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "SSID:" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "SSID:" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "SSID:" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "SSID:" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "SSID:" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "SSID:" + } } } }, @@ -2067,11 +2277,41 @@ "comment" : "Category filter showing every app", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "All" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toutes" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "すべて" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alla" + } } } }, @@ -2079,11 +2319,41 @@ "comment" : "Card message after the app that blocked an install was closed; %@ is the app name", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ war geöffnet und wurde daher nicht geändert. Versuchen Sie es erneut, jetzt wo die App geschlossen ist." + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "%@ was open, so it wasn't changed. Try again now that it's closed." } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ était ouvert, l’app n’a donc pas été modifiée. Réessayez maintenant qu’elle est fermée." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@が開いていたため、変更されませんでした。閉じたので、もう一度お試しください。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ var åpen og ble derfor ikke endret. Prøv igjen nå som appen er lukket." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ var öppen och ändrades därför inte. Försök igen nu när appen är stängd." + } } } }, @@ -2091,11 +2361,41 @@ "comment" : "Card message when an install needs the app closed; %@ is the app name", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ ist geöffnet. Beenden Sie die App, um abzuschließen." + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "%@ is open. Quit it to finish." } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ est ouvert. Quittez l’app pour terminer." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@が開いています。終了すると完了します。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ er åpen. Avslutt appen for å fullføre." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ är öppen. Avsluta appen för att slutföra." + } } } }, @@ -2103,11 +2403,41 @@ "comment" : "Notification when an install needs the app closed; %@ is the app name", "extractionState" : "extracted_with_value", "localizations" : { - "en" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beenden Sie %@, um die Aktualisierung abzuschließen." + } + }, + "en" : { "stringUnit" : { "state" : "new", "value" : "Quit %@ to finish updating it." } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quittez %@ pour terminer la mise à jour." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@を終了すると、アップデートが完了します。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avslutt %@ for å fullføre oppdateringen." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avsluta %@ för att slutföra uppdateringen." + } } } }, @@ -2115,11 +2445,41 @@ "comment" : "Section of apps that can be installed", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verfügbar" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Available" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disponibles" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "利用可能" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilgjengelige" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tillgängliga" + } } } }, @@ -2127,11 +2487,41 @@ "comment" : "Cancel button", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abbrechen" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Cancel" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Annuler" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "キャンセル" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avbryt" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avbryt" + } } } }, @@ -2139,11 +2529,41 @@ "comment" : "Button that empties the app catalog search field", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suche löschen" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Clear search" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Effacer la recherche" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "検索をクリア" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tøm søket" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rensa sökningen" + } } } }, @@ -2151,11 +2571,41 @@ "comment" : "Button that closes the install details", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schließen" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Close" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fermer" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "閉じる" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lukk" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stäng" + } } } }, @@ -2163,11 +2613,41 @@ "comment" : "Button that opens Support Companion to show compliance details", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Details" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Details" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Détails" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "詳細" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detaljer" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detaljer" + } } } }, @@ -2175,11 +2655,41 @@ "comment" : "Title when the Fleet catalog failed to load", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apps konnten nicht geladen werden" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Couldn't load apps" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossible de charger les apps" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アプリを読み込めませんでした" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kunne ikke laste appene" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Det gick inte att läsa in apparna" + } } } }, @@ -2187,11 +2697,41 @@ "comment" : "Shown when install output couldn't be fetched", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Details konnten nicht geladen werden" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Couldn't load details" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossible de charger les détails" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "詳細を読み込めませんでした" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kunne ikke laste detaljene" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Det gick inte att läsa in detaljerna" + } } } }, @@ -2199,11 +2739,41 @@ "comment" : "Shown when an app didn't quit; %@ is the app name", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ wurde nicht beendet. Sichern Sie Ihre Arbeit, beenden Sie die App und versuchen Sie es erneut." + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "%@ didn't quit. Save your work, quit it, and try again." } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ n’a pas quitté. Enregistrez votre travail, quittez l’app et réessayez." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@が終了しませんでした。作業を保存してアプリを終了し、もう一度お試しください。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ ble ikke avsluttet. Lagre arbeidet ditt, avslutt appen og prøv igjen." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ avslutades inte. Spara ditt arbete, avsluta appen och försök igen." + } } } }, @@ -2211,35 +2781,125 @@ "comment" : "Shown next to a catalog that failed to refresh", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktualisierung fehlgeschlagen" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Couldn't refresh" } - } - } - }, - "Fleet.Critical" : { - "comment" : "Badge for a critical Fleet policy", - "extractionState" : "extracted_with_value", - "localizations" : { - "en" : { + }, + "fr" : { "stringUnit" : { - "state" : "new", - "value" : "Critical" + "state" : "translated", + "value" : "Impossible d’actualiser" } - } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "更新できませんでした" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kunne ikke oppdatere" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Det gick inte att uppdatera" + } + } + } + }, + "Fleet.Critical" : { + "comment" : "Badge for a critical Fleet policy", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kritisch" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Critical" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Critique" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "重要" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kritisk" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kritisk" + } + } } }, "Fleet.Details" : { "comment" : "Button that shows the output of a failed install or uninstall", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Details" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Details" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Détails" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "詳細" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detaljer" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detaljer" + } } } }, @@ -2247,11 +2907,41 @@ "comment" : "Disclosure that shows how to resolve a failing policy", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "So beheben Sie das Problem" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "How to fix" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comment corriger" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "対処方法" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slik løser du det" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Så här åtgärdar du det" + } } } }, @@ -2259,11 +2949,41 @@ "comment" : "Button that installs an app from Fleet", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installieren" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Install" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installer" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "インストール" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installer" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installera" + } } } }, @@ -2271,11 +2991,41 @@ "comment" : "Title of the sheet showing install output", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installationsdetails" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Install details" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Détails de l’installation" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "インストールの詳細" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installasjonsdetaljer" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installationsdetaljer" + } } } }, @@ -2283,11 +3033,41 @@ "comment" : "Section of installed apps, and the installed status badge", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installiert" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Installed" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installé" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "インストール済み" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installert" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installerad" + } } } }, @@ -2295,11 +3075,41 @@ "comment" : "Notification after an install; %@ is the app name", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ wurde installiert." + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "%@ was installed." } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ a été installé." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@をインストールしました。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ ble installert." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ har installerats." + } } } }, @@ -2307,11 +3117,41 @@ "comment" : "Status badge when an install failed", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installation fehlgeschlagen" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Install failed" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Échec de l’installation" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "インストールに失敗しました" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installasjonen mislyktes" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installationen misslyckades" + } } } }, @@ -2319,11 +3159,41 @@ "comment" : "Notification after a failed install or update; %@ is the app name", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ konnte nicht installiert werden." + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "%@ couldn't be installed." } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ n’a pas pu être installé." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@をインストールできませんでした。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ kunne ikke installeres." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ kunde inte installeras." + } } } }, @@ -2331,11 +3201,41 @@ "comment" : "Status badge while an app installs", "extractionState" : "extracted_with_value", "localizations" : { - "en" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wird installiert…" + } + }, + "en" : { "stringUnit" : { "state" : "new", "value" : "Installing…" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installation…" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "インストール中…" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installerer…" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installerar…" + } } } }, @@ -2343,11 +3243,41 @@ "comment" : "When compliance last ran; %@ is a relative time such as '5 minutes ago'", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geprüft %@" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Checked %@" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vérifié %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "チェック: %@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sjekket %@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kontrollerad %@" + } } } }, @@ -2355,11 +3285,41 @@ "comment" : "Label for the version available from Fleet", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Neueste" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Latest" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dernière" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "最新" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nyeste" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Senaste" + } } } }, @@ -2367,11 +3327,41 @@ "comment" : "Shown while the Fleet catalog loads", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apps werden geladen…" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Loading apps…" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chargement des apps…" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アプリを読み込み中…" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Laster apper…" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Läser in appar…" + } } } }, @@ -2379,11 +3369,41 @@ "comment" : "Help text for the menu with extra actions for an app", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Weitere Aktionen" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "More actions" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Plus d’actions" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "その他の操作" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Flere handlinger" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fler åtgärder" + } } } }, @@ -2391,11 +3411,41 @@ "comment" : "Shown when the Fleet catalog is empty", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Für diesen Mac sind keine Apps verfügbar." + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "No apps are available for this Mac." } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucune app n’est disponible pour ce Mac." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "このMacで利用できるアプリはありません。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen apper er tilgjengelige for denne Macen." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inga appar är tillgängliga för den här Macen." + } } } }, @@ -2403,11 +3453,41 @@ "comment" : "Shown when search or category filters hide every app", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Apps entsprechen Ihrer Suche." + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "No apps match your search." } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucune app ne correspond à votre recherche." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "検索条件に一致するアプリはありません。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen apper samsvarer med søket ditt." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inga appar matchar din sökning." + } } } }, @@ -2415,11 +3495,41 @@ "comment" : "Shown when a failed install has no output", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fleet hat keine Ausgabe gemeldet." + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Fleet didn't report any output." } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fleet n’a renvoyé aucune sortie." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fleetからの出力はありませんでした。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fleet rapporterte ingen utdata." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fleet rapporterade inga utdata." + } } } }, @@ -2427,11 +3537,41 @@ "comment" : "Compliance card without policies", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Für diesen Mac gibt es keine Konformitätsprüfungen." + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "There are no compliance checks for this Mac." } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Il n’y a aucune vérification de conformité pour ce Mac." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "このMacにはコンプライアンスチェックがありません。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Det finnes ingen samsvarskontroller for denne Macen." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Det finns inga efterlevnadskontroller för den här Macen." + } } } }, @@ -2439,11 +3579,41 @@ "comment" : "Shown when orbit or the Fleet server URL is missing", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fleet ist auf diesem Mac nicht eingerichtet." + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Fleet isn't set up on this Mac." } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fleet n’est pas configuré sur ce Mac." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "このMacではFleetが設定されていません。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fleet er ikke satt opp på denne Macen." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fleet är inte konfigurerat på den här Macen." + } } } }, @@ -2451,23 +3621,83 @@ "comment" : "Heading above the list of passing Fleet policies; %d is the count", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bestandene Prüfungen (%d)" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Passing checks (%d)" } - } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vérifications réussies (%d)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "合格したチェック (%d)" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beståtte kontroller (%d)" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Godkända kontroller (%d)" + } + } } }, "Fleet.PoliciesFailing" : { "comment" : "Compliance card summary; the numbers are failing and total checks", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$d von %2$d Prüfungen erfordern Aufmerksamkeit" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "%1$d of %2$d checks need attention" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$d vérifications sur %2$d nécessitent votre attention" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%2$d件中%1$d件のチェックに対応が必要です" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$d av %2$d kontroller krever oppmerksomhet" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$d av %2$d kontroller behöver åtgärdas" + } } } }, @@ -2475,11 +3705,41 @@ "comment" : "Notification when several policies start failing; %d is the count, %@ a policy name", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ihr Mac erfordert Aufmerksamkeit: %1$d Prüfungen schlagen fehl, darunter %2$@." + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Your Mac needs attention: %1$d checks are failing, including %2$@." } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre Mac nécessite votre attention: %1$d vérifications échouent, dont %2$@." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Macに対応が必要です: %1$d件のチェックが失敗しています (%2$@など)。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Macen din krever oppmerksomhet: %1$d kontroller mislykkes, blant annet %2$@." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Din Mac behöver åtgärdas: %1$d kontroller misslyckas, däribland %2$@." + } } } }, @@ -2487,11 +3747,41 @@ "comment" : "Compliance card when every policy passes; %d is the number of checks", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle %d Prüfungen wurden bestanden." + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "All %d checks are passing." } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les %d vérifications sont réussies." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d件すべてのチェックに合格しています。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle %d kontrollene er bestått." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alla %d kontroller är godkända." + } } } }, @@ -2499,11 +3789,41 @@ "comment" : "Compliance card when Fleet requires single sign-on", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Melden Sie sich auf der Seite „Apps“ an, um die Konformitätsprüfungen zu sehen." + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Sign in on the Apps page to see compliance checks." } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connectez-vous sur la page Apps pour voir les vérifications de conformité." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "コンプライアンスチェックを表示するには、「アプリ」ページでサインインしてください。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logg inn på Apper-siden for å se samsvarskontrollene." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logga in på sidan Applikationer för att se efterlevnadskontrollerna." + } } } }, @@ -2511,11 +3831,41 @@ "comment" : "Compliance card when policies couldn't be loaded", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Konformitätsprüfungen konnten nicht geladen werden." + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Couldn't load compliance checks." } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossible de charger les vérifications de conformité." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "コンプライアンスチェックを読み込めませんでした。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kunne ikke laste samsvarskontrollene." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Det gick inte att läsa in efterlevnadskontrollerna." + } } } }, @@ -2523,712 +3873,1342 @@ "comment" : "Notification when a policy starts failing; %@ is the policy name", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ihr Mac erfordert Aufmerksamkeit: %@" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Your Mac needs attention: %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre Mac nécessite votre attention: %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Macに対応が必要です: %@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Macen din krever oppmerksomhet: %@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Din Mac behöver åtgärdas: %@" + } + } + } + }, + "Fleet.QuitAndInstall" : { + "comment" : "Button that quits an app and installs it again", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beenden & installieren" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Quit & Install" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quitter et installer" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "終了してインストール" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avslutt og installer" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avsluta och installera" + } + } + } + }, + "Fleet.QuitAndUpdate" : { + "comment" : "Button that quits an app and installs its update", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beenden & aktualisieren" + } + }, "en" : { "stringUnit" : { - "state" : "new", - "value" : "Your Mac needs attention: %@" + "state" : "new", + "value" : "Quit & Update" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quitter et mettre à jour" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "終了してアップデート" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avslutt og oppdater" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avsluta och uppdatera" + } + } + } + }, + "Fleet.Recommended" : { + "comment" : "Section of apps IT recommends installing", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Empfohlen" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Recommended" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recommandées" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "おすすめ" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anbefalte" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rekommenderade" + } + } + } + }, + "Fleet.Reinstall" : { + "comment" : "Button that installs an installed app again through Fleet", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Neu installieren" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Reinstall" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réinstaller" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "再インストール" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installer på nytt" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installera om" + } + } + } + }, + "Fleet.ReinstalledNotification" : { + "comment" : "Notification after a reinstall; %@ is the app name", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ wurde neu installiert." + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%@ was reinstalled." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ a été réinstallé." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@を再インストールしました。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ ble installert på nytt." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ har installerats om." + } + } + } + }, + "Fleet.Retry" : { + "comment" : "Button to reload the Fleet catalog", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erneut versuchen" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Try Again" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réessayer" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "再試行" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prøv igjen" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Försök igen" + } + } + } + }, + "Fleet.SearchPlaceholder" : { + "comment" : "Placeholder for the Fleet app catalog search field", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apps suchen" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Search apps" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rechercher des apps" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アプリを検索" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Søk i apper" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sök appar" + } + } + } + }, + "Fleet.SignedOutFailing" : { + "comment" : "Compliance summary while signed out; %d is the number of failing checks", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d Prüfungen erfordern Aufmerksamkeit. Melden Sie sich an, um zu sehen, welche." + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%d checks need attention. Sign in to see which." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d vérifications nécessitent votre attention. Connectez-vous pour voir lesquelles." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d件のチェックに対応が必要です。サインインして確認してください。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d kontroller krever oppmerksomhet. Logg inn for å se hvilke." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d kontroller behöver åtgärdas. Logga in för att se vilka." + } + } + } + }, + "Fleet.SignedOutPassing" : { + "comment" : "Compliance summary while signed out when nothing is failing", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Prüfungen sind fehlgeschlagen." + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No checks are failing." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucune vérification n’est en échec." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "失敗しているチェックはありません。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen kontroller er mislykket." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inga kontroller misslyckas." + } + } + } + }, + "Fleet.SignedOutRecord" : { + "comment" : "Fleet info card when the user hasn't signed in", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Melden Sie sich an, um den Eintrag dieses Mac in Fleet zu sehen." + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Sign in to see this Mac's record in Fleet." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connectez-vous pour voir la fiche de ce Mac dans Fleet." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "このMacのFleetでの登録情報を表示するには、サインインしてください。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logg inn for å se oppføringen for denne Macen i Fleet." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logga in för att se den här Macens post i Fleet." + } + } + } + }, + "Fleet.SignIn" : { + "comment" : "Button that starts Fleet single sign-on", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anmelden" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Sign In" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se connecter" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "サインイン" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logg inn" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logga in" + } + } + } + }, + "Fleet.SignInForChecks" : { + "comment" : "Compliance page when Fleet requires single sign-on", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Melden Sie sich an, um die Konformitätsprüfungen zu sehen." + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Sign in to see compliance checks." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connectez-vous pour voir les vérifications de conformité." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "コンプライアンスチェックを表示するには、サインインしてください。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logg inn for å se samsvarskontrollene." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logga in för att se efterlevnadskontrollerna." } } } }, - "Fleet.QuitAndInstall" : { - "comment" : "Button that quits an app and installs it again", + "Fleet.SignInNotification" : { + "comment" : "Notification when Fleet needs the user to sign in", "extractionState" : "extracted_with_value", "localizations" : { - "en" : { + "de" : { "stringUnit" : { - "state" : "new", - "value" : "Quit & Install" + "state" : "translated", + "value" : "Melden Sie sich bei Fleet an, um Ihre Apps und Konformitätsprüfungen zu sehen." } - } - } - }, - "Fleet.QuitAndUpdate" : { - "comment" : "Button that quits an app and installs its update", - "extractionState" : "extracted_with_value", - "localizations" : { + }, "en" : { "stringUnit" : { "state" : "new", - "value" : "Quit & Update" + "value" : "Sign in to Fleet to see your apps and compliance checks." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connectez-vous à Fleet pour voir vos apps et vos vérifications de conformité." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アプリとコンプライアンスチェックを表示するには、Fleetにサインインしてください。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logg inn på Fleet for å se appene og samsvarskontrollene dine." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logga in på Fleet för att se dina appar och efterlevnadskontroller." } } } }, - "Fleet.Recommended" : { - "comment" : "Section of apps IT recommends installing", + "Fleet.SignInNotificationFailing" : { + "comment" : "Notification when a signed-out Mac has failing checks; %d is how many", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d Konformitätsprüfungen erfordern Aufmerksamkeit. Melden Sie sich an, um zu sehen, welche." + } + }, "en" : { "stringUnit" : { "state" : "new", - "value" : "Recommended" + "value" : "%d compliance checks need attention. Sign in to see which." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d vérifications de conformité nécessitent votre attention. Connectez-vous pour voir lesquelles." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d件のコンプライアンスチェックに対応が必要です。サインインして確認してください。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d samsvarskontroller krever oppmerksomhet. Logg inn for å se hvilke." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d efterlevnadskontroller behöver åtgärdas. Logga in för att se vilka." } } } }, - "Fleet.Reinstall" : { - "comment" : "Button that installs an installed app again through Fleet", + "Fleet.SignInRequired" : { + "comment" : "Shown when Fleet requires single sign-on", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Melden Sie sich an, um die für diesen Mac verfügbaren Apps zu sehen." + } + }, "en" : { "stringUnit" : { "state" : "new", - "value" : "Reinstall" + "value" : "Sign in to see the apps available for this Mac." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connectez-vous pour voir les apps disponibles pour ce Mac." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "このMacで利用できるアプリを表示するには、サインインしてください。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logg inn for å se appene som er tilgjengelige for denne Macen." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logga in för att se apparna som är tillgängliga för den här Macen." } } } }, - "Fleet.ReinstalledNotification" : { - "comment" : "Notification after a reinstall; %@ is the app name", + "Fleet.SSOConnecting" : { + "comment" : "Shown while the Fleet sign-in page is being prepared", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verbindung zum Identitätsanbieter wird hergestellt…" + } + }, "en" : { "stringUnit" : { "state" : "new", - "value" : "%@ was reinstalled." + "value" : "Connecting to your identity provider…" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connexion à votre fournisseur d’identité…" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "IDプロバイダに接続中…" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kobler til identitetsleverandøren…" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ansluter till din identitetsleverantör…" } } } }, - "Fleet.Retry" : { - "comment" : "Button to reload the Fleet catalog", + "Fleet.SSOCouldNotSignIn" : { + "comment" : "Title when Fleet single sign-on failed", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anmeldung nicht möglich" + } + }, "en" : { "stringUnit" : { "state" : "new", - "value" : "Try Again" + "value" : "Couldn't sign in" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connexion impossible" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "サインインできませんでした" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kunne ikke logge inn" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Det gick inte att logga in" } } } }, - "Fleet.SearchPlaceholder" : { - "comment" : "Placeholder for the Fleet app catalog search field", + "Fleet.SSODisabled" : { + "comment" : "Shown when Fleet Desktop single sign-on was turned off during sign-in", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Single Sign-On für Fleet ist nicht aktiviert. Wenden Sie sich an Ihre IT-Abteilung." + } + }, "en" : { "stringUnit" : { "state" : "new", - "value" : "Search apps" + "value" : "Single sign-on for Fleet isn't enabled. Contact your IT department." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "L’authentification unique pour Fleet n’est pas activée. Contactez votre service informatique." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fleetのシングルサインオンが有効になっていません。IT部門にお問い合わせください。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enkel pålogging for Fleet er ikke aktivert. Kontakt IT-avdelingen." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enkel inloggning för Fleet är inte aktiverad. Kontakta din IT-avdelning." } } } }, - "Fleet.SignedOutFailing" : { - "comment" : "Compliance summary while signed out; %d is the number of failing checks", + "Fleet.SSOFailed" : { + "comment" : "Shown when the identity provider didn't return a valid Fleet session", "extractionState" : "extracted_with_value", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "%d Prüfungen erfordern Aufmerksamkeit. Melden Sie sich an, um zu sehen, welche." + "value" : "Ihr Identitätsanbieter hat die Anmeldung nicht abgeschlossen. Versuchen Sie es erneut." } }, "en" : { "stringUnit" : { "state" : "new", - "value" : "%d checks need attention. Sign in to see which." + "value" : "Your identity provider didn't complete the sign-in. Try again." } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "%d vérifications nécessitent votre attention. Connectez-vous pour voir lesquelles." + "value" : "Votre fournisseur d’identité n’a pas terminé la connexion. Réessayez." } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "%d件のチェックに対応が必要です。サインインして確認してください。" + "value" : "IDプロバイダがサインインを完了しませんでした。もう一度お試しください。" } }, "nb" : { "stringUnit" : { "state" : "translated", - "value" : "%d kontroller krever oppmerksomhet. Logg inn for å se hvilke." + "value" : "Identitetsleverandøren fullførte ikke påloggingen. Prøv igjen." } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "%d kontroller behöver åtgärdas. Logga in för att se vilka." + "value" : "Din identitetsleverantör slutförde inte inloggningen. Försök igen." } } } }, - "Fleet.SignedOutPassing" : { - "comment" : "Compliance summary while signed out when nothing is failing", + "Fleet.SSOSheetTitle" : { + "comment" : "Title of the Fleet single sign-on window", "extractionState" : "extracted_with_value", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Keine Prüfungen sind fehlgeschlagen." + "value" : "Bei Fleet anmelden" } }, "en" : { "stringUnit" : { "state" : "new", - "value" : "No checks are failing." + "value" : "Sign in to Fleet" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Aucune vérification n’est en échec." + "value" : "Se connecter à Fleet" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "失敗しているチェックはありません。" + "value" : "Fleetにサインイン" } }, "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Ingen kontroller er mislykket." + "value" : "Logg inn på Fleet" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "Inga kontroller misslyckas." + "value" : "Logga in på Fleet" } } } }, - "Fleet.SignedOutRecord" : { - "comment" : "Fleet info card when the user hasn't signed in", + "Fleet.Uninstall" : { + "comment" : "Button that uninstalls an app through Fleet", "extractionState" : "extracted_with_value", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Melden Sie sich an, um den Eintrag dieses Mac in Fleet zu sehen." + "value" : "Deinstallieren" } }, "en" : { "stringUnit" : { "state" : "new", - "value" : "Sign in to see this Mac's record in Fleet." + "value" : "Uninstall" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Connectez-vous pour voir la fiche de ce Mac dans Fleet." + "value" : "Désinstaller" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "このMacのFleetでの登録情報を表示するには、サインインしてください。" + "value" : "アンインストール" } }, "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Logg inn for å se oppføringen for denne Macen i Fleet." + "value" : "Avinstaller" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "Logga in för att se den här Macens post i Fleet." - } - } - } - }, - "Fleet.SignIn" : { - "comment" : "Button that starts Fleet single sign-on", - "extractionState" : "extracted_with_value", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Sign In" + "value" : "Avinstallera" } } } }, - "Fleet.SignInForChecks" : { - "comment" : "Compliance page when Fleet requires single sign-on", + "Fleet.UninstallConfirmMessage" : { + "comment" : "Message asking to confirm an uninstall", "extractionState" : "extracted_with_value", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Melden Sie sich an, um die Konformitätsprüfungen zu sehen." + "value" : "Die App wird von diesem Mac entfernt." } }, "en" : { "stringUnit" : { "state" : "new", - "value" : "Sign in to see compliance checks." + "value" : "The app will be removed from this Mac." } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Connectez-vous pour voir les vérifications de conformité." + "value" : "L’app sera supprimée de ce Mac." } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "コンプライアンスチェックを表示するには、サインインしてください。" + "value" : "このMacからアプリが削除されます。" } }, "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Logg inn for å se samsvarskontrollene." + "value" : "Appen blir fjernet fra denne Macen." } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "Logga in för att se efterlevnadskontrollerna." + "value" : "Appen tas bort från den här Macen." } } } }, - "Fleet.SignInNotification" : { - "comment" : "Notification when Fleet needs the user to sign in", + "Fleet.UninstallConfirmTitle" : { + "comment" : "Title asking to confirm an uninstall; %@ is the app name", "extractionState" : "extracted_with_value", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Melden Sie sich bei Fleet an, um Ihre Apps und Konformitätsprüfungen zu sehen." + "value" : "%@ deinstallieren?" } }, "en" : { "stringUnit" : { "state" : "new", - "value" : "Sign in to Fleet to see your apps and compliance checks." + "value" : "Uninstall %@?" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Connectez-vous à Fleet pour voir vos apps et vos vérifications de conformité." + "value" : "Désinstaller %@?" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "アプリとコンプライアンスチェックを表示するには、Fleetにサインインしてください。" + "value" : "%@をアンインストールしますか?" } }, "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Logg inn på Fleet for å se appene og samsvarskontrollene dine." + "value" : "Vil du avinstallere %@?" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "Logga in på Fleet för att se dina appar och efterlevnadskontroller." + "value" : "Vill du avinstallera %@?" } } } }, - "Fleet.SignInNotificationFailing" : { - "comment" : "Notification when a signed-out Mac has failing checks; %d is how many", + "Fleet.UninstallDetailsTitle" : { + "comment" : "Title of the sheet showing uninstall output", "extractionState" : "extracted_with_value", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "%d Konformitätsprüfungen erfordern Aufmerksamkeit. Melden Sie sich an, um zu sehen, welche." + "value" : "Deinstallationsdetails" } }, "en" : { "stringUnit" : { "state" : "new", - "value" : "%d compliance checks need attention. Sign in to see which." + "value" : "Uninstall details" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "%d vérifications de conformité nécessitent votre attention. Connectez-vous pour voir lesquelles." + "value" : "Détails de la désinstallation" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "%d件のコンプライアンスチェックに対応が必要です。サインインして確認してください。" + "value" : "アンインストールの詳細" } }, "nb" : { "stringUnit" : { "state" : "translated", - "value" : "%d samsvarskontroller krever oppmerksomhet. Logg inn for å se hvilke." + "value" : "Avinstallasjonsdetaljer" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "%d efterlevnadskontroller behöver åtgärdas. Logga in för att se vilka." - } - } - } - }, - "Fleet.SignInRequired" : { - "comment" : "Shown when Fleet requires single sign-on", - "extractionState" : "extracted_with_value", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Sign in to see the apps available for this Mac." + "value" : "Avinstallationsdetaljer" } } } }, - "Fleet.SSOConnecting" : { - "comment" : "Shown while the Fleet sign-in page is being prepared", + "Fleet.UninstalledNotification" : { + "comment" : "Notification after an uninstall; %@ is the app name", "extractionState" : "extracted_with_value", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Verbindung zum Identitätsanbieter wird hergestellt…" + "value" : "%@ wurde deinstalliert." } }, "en" : { "stringUnit" : { "state" : "new", - "value" : "Connecting to your identity provider…" + "value" : "%@ was uninstalled." } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Connexion à votre fournisseur d’identité…" + "value" : "%@ a été désinstallé." } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "IDプロバイダに接続中…" + "value" : "%@をアンインストールしました。" } }, "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Kobler til identitetsleverandøren…" + "value" : "%@ ble avinstallert." } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "Ansluter till din identitetsleverantör…" + "value" : "%@ har avinstallerats." } } } }, - "Fleet.SSOCouldNotSignIn" : { - "comment" : "Title when Fleet single sign-on failed", + "Fleet.UninstallFailed" : { + "comment" : "Status badge when an uninstall failed", "extractionState" : "extracted_with_value", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Anmeldung nicht möglich" + "value" : "Deinstallation fehlgeschlagen" } }, "en" : { "stringUnit" : { "state" : "new", - "value" : "Couldn't sign in" + "value" : "Uninstall failed" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Connexion impossible" + "value" : "Échec de la désinstallation" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "サインインできませんでした" + "value" : "アンインストールに失敗しました" } }, "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Kunne ikke logge inn" + "value" : "Avinstallasjonen mislyktes" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "Det gick inte att logga in" + "value" : "Avinstallationen misslyckades" } } } }, - "Fleet.SSODisabled" : { - "comment" : "Shown when Fleet Desktop single sign-on was turned off during sign-in", + "Fleet.UninstallFailedNotification" : { + "comment" : "Notification after a failed uninstall; %@ is the app name", "extractionState" : "extracted_with_value", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Single Sign-On für Fleet ist nicht aktiviert. Wenden Sie sich an Ihre IT-Abteilung." + "value" : "%@ konnte nicht deinstalliert werden." } }, "en" : { "stringUnit" : { "state" : "new", - "value" : "Single sign-on for Fleet isn't enabled. Contact your IT department." + "value" : "%@ couldn't be uninstalled." } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "L’authentification unique pour Fleet n’est pas activée. Contactez votre service informatique." + "value" : "%@ n’a pas pu être désinstallé." } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Fleetのシングルサインオンが有効になっていません。IT部門にお問い合わせください。" + "value" : "%@をアンインストールできませんでした。" } }, "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Enkel pålogging for Fleet er ikke aktivert. Kontakt IT-avdelingen." + "value" : "%@ kunne ikke avinstalleres." } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "Enkel inloggning för Fleet är inte aktiverad. Kontakta din IT-avdelning." + "value" : "%@ kunde inte avinstalleras." } } } }, - "Fleet.SSOFailed" : { - "comment" : "Shown when the identity provider didn't return a valid Fleet session", + "Fleet.Uninstalling" : { + "comment" : "Status badge while an app uninstalls", "extractionState" : "extracted_with_value", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Ihr Identitätsanbieter hat die Anmeldung nicht abgeschlossen. Versuchen Sie es erneut." + "value" : "Wird deinstalliert…" } }, "en" : { "stringUnit" : { "state" : "new", - "value" : "Your identity provider didn't complete the sign-in. Try again." + "value" : "Uninstalling…" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Votre fournisseur d’identité n’a pas terminé la connexion. Réessayez." + "value" : "Désinstallation…" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "IDプロバイダがサインインを完了しませんでした。もう一度お試しください。" + "value" : "アンインストール中…" } }, "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Identitetsleverandøren fullførte ikke påloggingen. Prøv igjen." + "value" : "Avinstallerer…" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "Din identitetsleverantör slutförde inte inloggningen. Försök igen." + "value" : "Avinstallerar…" } } } }, - "Fleet.SSOSheetTitle" : { - "comment" : "Title of the Fleet single sign-on window", + "Fleet.Update" : { + "comment" : "Button that updates an app from Fleet", "extractionState" : "extracted_with_value", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Bei Fleet anmelden" + "value" : "Aktualisieren" } }, "en" : { "stringUnit" : { "state" : "new", - "value" : "Sign in to Fleet" + "value" : "Update" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Se connecter à Fleet" + "value" : "Mettre à jour" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Fleetにサインイン" + "value" : "アップデート" } }, "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Logg inn på Fleet" + "value" : "Oppdater" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "Logga in på Fleet" + "value" : "Uppdatera" } } } }, - "Fleet.Uninstall" : { - "comment" : "Button that uninstalls an app through Fleet", + "Fleet.UpdateAvailable" : { + "comment" : "Status badge when a newer version is available", "extractionState" : "extracted_with_value", "localizations" : { - "en" : { + "de" : { "stringUnit" : { - "state" : "new", - "value" : "Uninstall" + "state" : "translated", + "value" : "Update verfügbar" } - } - } - }, - "Fleet.UninstallConfirmMessage" : { - "comment" : "Message asking to confirm an uninstall", - "extractionState" : "extracted_with_value", - "localizations" : { + }, "en" : { "stringUnit" : { "state" : "new", - "value" : "The app will be removed from this Mac." + "value" : "Update available" } - } - } - }, - "Fleet.UninstallConfirmTitle" : { - "comment" : "Title asking to confirm an uninstall; %@ is the app name", - "extractionState" : "extracted_with_value", - "localizations" : { - "en" : { + }, + "fr" : { "stringUnit" : { - "state" : "new", - "value" : "Uninstall %@?" + "state" : "translated", + "value" : "Mise à jour disponible" } - } - } - }, - "Fleet.UninstallDetailsTitle" : { - "comment" : "Title of the sheet showing uninstall output", - "extractionState" : "extracted_with_value", - "localizations" : { - "en" : { + }, + "ja" : { "stringUnit" : { - "state" : "new", - "value" : "Uninstall details" + "state" : "translated", + "value" : "アップデートあり" } - } - } - }, - "Fleet.UninstalledNotification" : { - "comment" : "Notification after an uninstall; %@ is the app name", - "extractionState" : "extracted_with_value", - "localizations" : { - "en" : { + }, + "nb" : { "stringUnit" : { - "state" : "new", - "value" : "%@ was uninstalled." + "state" : "translated", + "value" : "Oppdatering tilgjengelig" } - } - } - }, - "Fleet.UninstallFailed" : { - "comment" : "Status badge when an uninstall failed", - "extractionState" : "extracted_with_value", - "localizations" : { - "en" : { + }, + "sv" : { "stringUnit" : { - "state" : "new", - "value" : "Uninstall failed" + "state" : "translated", + "value" : "Uppdatering tillgänglig" } } } }, - "Fleet.UninstallFailedNotification" : { - "comment" : "Notification after a failed uninstall; %@ is the app name", + "Fleet.UpdatedNotification" : { + "comment" : "Notification after an update; %@ is the app name", "extractionState" : "extracted_with_value", "localizations" : { - "en" : { + "de" : { "stringUnit" : { - "state" : "new", - "value" : "%@ couldn't be uninstalled." + "state" : "translated", + "value" : "%@ wurde aktualisiert." } - } - } - }, - "Fleet.Uninstalling" : { - "comment" : "Status badge while an app uninstalls", - "extractionState" : "extracted_with_value", - "localizations" : { + }, "en" : { "stringUnit" : { "state" : "new", - "value" : "Uninstalling…" + "value" : "%@ was updated." } - } - } - }, - "Fleet.Update" : { - "comment" : "Button that updates an app from Fleet", - "extractionState" : "extracted_with_value", - "localizations" : { - "en" : { + }, + "fr" : { "stringUnit" : { - "state" : "new", - "value" : "Update" + "state" : "translated", + "value" : "%@ a été mis à jour." } - } - } - }, - "Fleet.UpdateAvailable" : { - "comment" : "Status badge when a newer version is available", - "extractionState" : "extracted_with_value", - "localizations" : { - "en" : { + }, + "ja" : { "stringUnit" : { - "state" : "new", - "value" : "Update available" + "state" : "translated", + "value" : "%@をアップデートしました。" } - } - } - }, - "Fleet.UpdatedNotification" : { - "comment" : "Notification after an update; %@ is the app name", - "extractionState" : "extracted_with_value", - "localizations" : { - "en" : { + }, + "nb" : { "stringUnit" : { - "state" : "new", - "value" : "%@ was updated." + "state" : "translated", + "value" : "%@ ble oppdatert." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ har uppdaterats." } } } @@ -3237,11 +5217,41 @@ "comment" : "Section of apps with a newer version available", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verfügbare Updates" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Updates Available" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mises à jour disponibles" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "利用可能なアップデート" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilgjengelige oppdateringer" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tillgängliga uppdateringar" + } } } }, @@ -3249,11 +5259,41 @@ "comment" : "Label for an app's installed version", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Version" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Version" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Version" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "バージョン" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Versjon" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Version" + } } } }, @@ -3261,11 +5301,41 @@ "comment" : "Button that shows the Apps page in Support Companion", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apps anzeigen" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "View Apps" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voir les apps" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アプリを表示" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vis apper" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Visa appar" + } } } }, @@ -3273,11 +5343,41 @@ "comment" : "Notification button that opens Support Companion", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Details anzeigen" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "View Details" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voir les détails" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "詳細を表示" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vis detaljer" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Visa detaljer" + } } } }, @@ -3285,11 +5385,41 @@ "comment" : "Button that shows available app updates in Support Companion", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Updates anzeigen" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "View Updates" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voir les mises à jour" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アップデートを表示" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vis oppdateringer" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Visa uppdateringar" + } } } }, @@ -3297,11 +5427,41 @@ "comment" : "Status badge when an install didn't run because the app was open", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Warten auf das Beenden der App" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Waiting for app to close" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "En attente de la fermeture de l’app" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アプリの終了待ち" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Venter på at appen lukkes" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Väntar på att appen stängs" + } } } }, @@ -3309,11 +5469,41 @@ "comment" : "Label for the Fleet host ID", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Host-ID:" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Host ID:" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ID d’hôte:" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ホストID:" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Host-ID:" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Host-ID:" + } } } }, @@ -3321,11 +5511,41 @@ "comment" : "Label for when Fleet last updated this Mac's details", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inventar:" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Inventory:" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inventaire:" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "インベントリ:" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inventar:" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inventering:" + } } } }, @@ -3333,11 +5553,41 @@ "comment" : "Label for when Fleet last heard from this Mac", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kontakt:" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Check-In:" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connexion:" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "チェックイン:" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Innsjekk:" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Incheckning:" + } } } }, @@ -3345,10 +5595,40 @@ "comment" : "Shown when Fleet hasn't recorded an event yet", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nie" + } + }, "en" : { "stringUnit" : { - "state" : "new", - "value" : "Never" + "state" : "new", + "value" : "Never" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jamais" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "なし" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aldri" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aldrig" } } } @@ -3357,11 +5637,41 @@ "comment" : "Shown when the Mac isn't in a fleet", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kein Fleet" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "No fleet" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucun fleet" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "所属なし" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen fleet" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen fleet" + } } } }, @@ -3369,11 +5679,41 @@ "comment" : "Shown when a re-check request failed", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fleet konnte nicht aufgefordert werden, diesen Mac erneut zu prüfen." + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Couldn't ask Fleet to re-check this Mac." } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossible de demander à Fleet de revérifier ce Mac." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "このMacの再チェックをFleetに要求できませんでした。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kunne ikke be Fleet om å sjekke denne Macen på nytt." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Det gick inte att be Fleet kontrollera den här Macen igen." + } } } }, @@ -3381,11 +5721,41 @@ "comment" : "Label for the fleet name", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fleet:" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Fleet:" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fleet:" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fleet:" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fleet:" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fleet:" + } } } }, @@ -3393,11 +5763,41 @@ "comment" : "Shown before Fleet details load", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unbekannt" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Unknown" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inconnu" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "不明" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ukjent" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Okänd" + } } } }, @@ -3405,11 +5805,41 @@ "comment" : "Label for the Fleet server", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL:" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "URL:" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL:" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL:" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL:" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL:" + } } } }, @@ -4184,11 +6614,41 @@ "comment" : "Label for the ID", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "ID:" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "ID:" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ID:" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ID:" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "ID:" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "ID:" + } } } }, @@ -4280,11 +6740,41 @@ "comment" : "Label for the URL", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL:" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "URL:" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL:" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL:" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL:" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL:" + } } } }, @@ -4690,11 +7180,41 @@ "comment" : "Label for company portal navigation", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unternehmensportal" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Company Portal" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Portail d’entreprise" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "会社ポータル" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Firmaportal" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Företagsportal" + } } } }, @@ -4702,11 +7222,41 @@ "comment" : "Label for compliance navigation", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Konformität" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Compliance" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conformité" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "コンプライアンス" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Samsvar" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Efterlevnad" + } } } }, diff --git a/SupportCompanion/ViewModels/FleetDeviceManager.swift b/SupportCompanion/ViewModels/FleetDeviceManager.swift index 75e8b79..feb9949 100644 --- a/SupportCompanion/ViewModels/FleetDeviceManager.swift +++ b/SupportCompanion/ViewModels/FleetDeviceManager.swift @@ -49,7 +49,7 @@ final class FleetDeviceManager { let unknown = Info.unknown func relative(_ date: Date?) -> String { guard host != nil else { return unknown } - return FleetHost.realDate(date).map { $0.formatted(.relative(presentation: .named)) } ?? Info.never + return FleetHost.realDate(date).map { $0.relativeDescription() } ?? Info.never } return [ (Info.Keys.id, Info.Labels.id, .string(host.map { String($0.id) } ?? unknown)), diff --git a/SupportCompanion/Views/Cards/FleetInfoCard.swift b/SupportCompanion/Views/Cards/FleetInfoCard.swift index 44436a3..3dd36e9 100644 --- a/SupportCompanion/Views/Cards/FleetInfoCard.swift +++ b/SupportCompanion/Views/Cards/FleetInfoCard.swift @@ -11,28 +11,30 @@ struct FleetInfoCard: View { @Environment(AppStateManager.self) private var appState var body: some View { - ScCard(title: Constants.CardTitle.fleetInfo, titleImageName: "server.rack", content: { - VStack(alignment: .leading, spacing: 5) { - // Fleet withholds the host record until sign-in, so the rows would all read Unknown. - // One thing the user can act on beats five that say nothing. - if appState.fleetDeviceManager.isSignedOut { - Text(Constants.Fleet.signedOutRecord) - .font(.system(size: 12)) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - ScSmallButton(Constants.Fleet.signIn) { - appState.fleetSSOController.present() + ScCard( + title: Constants.CardTitle.fleetInfo, titleImageName: "server.rack", + content: { + VStack(alignment: .leading, spacing: 5) { + // Fleet withholds the host record until sign-in, so the rows would all read Unknown. + if appState.fleetDeviceManager.isSignedOut { + Text(Constants.Fleet.signedOutRecord) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + ScSmallButton(Constants.Fleet.signIn) { + appState.fleetSSOController.present() + } + .padding(.top, 2) + } else { + CardData(info: appState.fleetDeviceManager.infoRows) } - .padding(.top, 2) - } else { - CardData(info: appState.fleetDeviceManager.infoRows) + Spacer() } - Spacer() + .padding(.horizontal) + .frame(maxHeight: .infinity, alignment: .top) + .frame(minHeight: 110) } - .padding(.horizontal) - .frame(maxHeight: .infinity, alignment: .top) - .frame(minHeight: 110) - }) + ) .fixedSize(horizontal: false, vertical: false) .task { await appState.fleetDeviceManager.refreshIfStale() } } diff --git a/SupportCompanion/Views/Cards/PatchingProgressCard.swift b/SupportCompanion/Views/Cards/PatchingProgressCard.swift index 4034816..c81245b 100644 --- a/SupportCompanion/Views/Cards/PatchingProgressCard.swift +++ b/SupportCompanion/Views/Cards/PatchingProgressCard.swift @@ -20,7 +20,8 @@ struct PatchingProgressCard: View { CircularProgressWithWave( progress: appState.installPercentage / 100, size: 200, - waveHeight: (appState.installPercentage == 0.0 || appState.installPercentage == 100.0) ? 0 : 5 + waveHeight: (appState.installPercentage == 0.0 || appState.installPercentage == 100.0) ? 0 : 5, + tint: NSColor(hex: appState.preferences.branding.accentColor ?? "") ?? .controlAccentColor ) } .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/SupportCompanion/Views/CircularProgressWave.swift b/SupportCompanion/Views/CircularProgressWave.swift index 2e94346..95baef3 100644 --- a/SupportCompanion/Views/CircularProgressWave.swift +++ b/SupportCompanion/Views/CircularProgressWave.swift @@ -13,6 +13,7 @@ struct CircularProgressWithWave: View { var progress: CGFloat var size: CGFloat var waveHeight: CGFloat + var tint: NSColor @Environment(\.colorScheme) var colorScheme @Environment(\.accessibilityReduceMotion) var reduceMotion @@ -29,7 +30,7 @@ struct CircularProgressWithWave: View { .frame(width: size, height: size) // Wave masked to a circle - WaveLayerView(progress: progress, waveHeight: waveHeight, isMoving: !reduceMotion) + WaveLayerView(progress: progress, waveHeight: waveHeight, isMoving: !reduceMotion, tint: tint) .frame(width: size, height: size) .clipShape(Circle()) @@ -53,6 +54,7 @@ private struct WaveLayerView: NSViewRepresentable { var progress: CGFloat var waveHeight: CGFloat var isMoving: Bool + var tint: NSColor func makeNSView(context: Context) -> WaveNSView { WaveNSView() @@ -62,6 +64,7 @@ private struct WaveLayerView: NSViewRepresentable { view.progress = progress view.waveHeight = waveHeight view.isMoving = isMoving + view.tint = tint } } @@ -69,6 +72,7 @@ private final class WaveNSView: NSView { var progress: CGFloat = 0 { didSet { if progress != oldValue { needsLayout = true } } } var waveHeight: CGFloat = 0 { didSet { if waveHeight != oldValue { needsLayout = true } } } var isMoving = false { didSet { if isMoving != oldValue { updateAnimation() } } } + var tint: NSColor = .controlAccentColor { didSet { if tint != oldValue { updateColors() } } } /// Seconds for the wave to travel one full period private let wavePeriod: CFTimeInterval = 4 @@ -117,9 +121,25 @@ private final class WaveNSView: NSView { updateAnimation() } + /// Top stop is a lighter shade of the tint, so the wave keeps its sense of depth on any accent. private func updateColors() { effectiveAppearance.performAsCurrentDrawingAppearance { - gradientLayer.colors = [NSColor.systemBlue.cgColor, NSColor.systemPurple.cgColor] + guard let base = tint.usingColorSpace(.sRGB) else { + gradientLayer.colors = [tint.cgColor, tint.cgColor] + return + } + var hue: CGFloat = 0 + var saturation: CGFloat = 0 + var brightness: CGFloat = 0 + var alpha: CGFloat = 0 + base.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) + let top = NSColor( + hue: hue, + saturation: max(saturation - 0.25, 0), + brightness: min(brightness + 0.2, 1), + alpha: alpha + ) + gradientLayer.colors = [top.cgColor, base.cgColor] } } diff --git a/SupportCompanion/Views/DarkLightModeToggle.swift b/SupportCompanion/Views/DarkLightModeToggle.swift index e40c32d..5a4ce82 100644 --- a/SupportCompanion/Views/DarkLightModeToggle.swift +++ b/SupportCompanion/Views/DarkLightModeToggle.swift @@ -1,3 +1,4 @@ +import AppKit // // DarkLightModeButton.swift // SupportCompanion @@ -6,22 +7,25 @@ // //NSAppearance.currentDrawing().bestMatch(from: [.aqua, .darkAqua]) import SwiftUI -import AppKit struct DarkLightModeToggle: View { - @AppStorage("isDarkMode") private var isDarkMode: Int = -1 // -1: System mode, 1: Dark, 0: Light - @Environment(\.colorScheme) var colorScheme // Detect system theme - @State private var currentSystemTheme: ColorScheme = .light // Track the current system theme + @AppStorage("isDarkMode") private var isDarkMode: Int = -1 // -1: System mode, 1: Dark, 0: Light + @Environment(\.colorScheme) var colorScheme // Detect system theme + @State private var currentSystemTheme: ColorScheme = .light // Track the current system theme var body: some View { ZStack { // Background of the toggle Capsule() - .fill(LinearGradient( - gradient: Gradient(colors: resolvedTheme == .dark ? [.purple, .black] : [.yellow, .orange]), - startPoint: .leading, - endPoint: .trailing - )) + .fill( + LinearGradient( + gradient: Gradient( + colors: resolvedTheme == .dark + ? [.accentColor, .black] : [.yellow, .orange]), + startPoint: .leading, + endPoint: .trailing + ) + ) .frame(width: 50, height: 30) .shadow(color: .black.opacity(0.2), radius: 5, x: 0, y: 2) .overlay( @@ -48,14 +52,15 @@ struct DarkLightModeToggle: View { .frame(width: 50, height: 30) .onAppear { updateAppAppearance() - currentSystemTheme = colorScheme // Initialize system theme + currentSystemTheme = colorScheme // Initialize system theme } .onChange(of: colorScheme) { - currentSystemTheme = colorScheme // Update system theme on change + currentSystemTheme = colorScheme // Update system theme on change if isDarkMode == -1 { updateAppAppearance() } - } } + } + } // MARK: - Resolved Theme private var resolvedTheme: ColorScheme { @@ -77,9 +82,9 @@ struct DarkLightModeToggle: View { private var iconColor: Color { switch isDarkMode { - case 1: return .purple + case 1: return .accentColor case 0: return .yellow - default: return currentSystemTheme == .dark ? .purple : .yellow + default: return currentSystemTheme == .dark ? .accentColor : .yellow } } @@ -114,7 +119,7 @@ struct DarkLightModeToggle: View { case 0: NSApp.appearance = NSAppearance(named: .aqua) default: - NSApp.appearance = nil // Follow system + NSApp.appearance = nil // Follow system } } } diff --git a/SupportCompanion/Views/Fleet/FleetComplianceView.swift b/SupportCompanion/Views/Fleet/FleetComplianceView.swift index 995409d..4901294 100644 --- a/SupportCompanion/Views/Fleet/FleetComplianceView.swift +++ b/SupportCompanion/Views/Fleet/FleetComplianceView.swift @@ -85,7 +85,7 @@ struct FleetComplianceView: View { guard manager.host != nil, let date = FleetHost.realDate(manager.host?.detailUpdatedAt) else { return nil } if manager.isRefetching { return Constants.Actions.refetching } - return String(format: Constants.Fleet.lastChecked, date.formatted(.relative(presentation: .named))) + return String(format: Constants.Fleet.lastChecked, date.relativeDescription()) } // MARK: - Content From 5a42ec0a098b72915614da9d0fa2c29e44e8cc19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Alm=C3=A9n?= <78877636+almenscorner@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:32:54 +0200 Subject: [PATCH 096/104] Refactor helper installation logic to prioritize device-scoped configuration profiles and improve clarity in handling SkipHelperInstall settings. --- SupportCompanion/Scripts/helper_install.zsh | 39 ++++++++++++--------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/SupportCompanion/Scripts/helper_install.zsh b/SupportCompanion/Scripts/helper_install.zsh index 6977f79..19a5245 100755 --- a/SupportCompanion/Scripts/helper_install.zsh +++ b/SupportCompanion/Scripts/helper_install.zsh @@ -15,24 +15,29 @@ launch_daemon="com.github.macadmins.SupportCompanion.helper" # job in a managed directory the OS re-asserts. Installing our own copy as well would leave two # daemons claiming the same Mach service, so this skips the whole helper install. # -# Read only from administrator-managed sources, the same two the app and the helper trust. The key -# must come from a DEVICE-scoped profile: a user-scoped one lands under a per-user directory that -# an installer script has no reliable way to resolve. +# Read only from a DEVICE-scoped configuration profile, which is the single source the app and the +# helper trust for anything privileged. A user-scoped profile is no good here either: it lands under +# a per-user directory that an installer script has no reliable way to resolve. +# +# /Library/Preferences is deliberately not consulted, for the reason HelperPreferences gives: only +# root can write it, and that is the problem. Setting this key removes the helper and its launchd +# job, so honouring it from a root-writable file would let one root moment disable the component +# that demotes elevated users, and every later package install would keep it disabled. skip_helper_install=false -for domain in \ - "/Library/Managed Preferences/com.github.macadmins.SupportCompanion.plist" \ - "/Library/Preferences/com.github.macadmins.SupportCompanion.plist" -do - if [[ -f "$domain" ]]; then - value=$(/usr/bin/defaults read "${domain%.plist}" SkipHelperInstall 2>/dev/null) - # First source that defines the key wins, so a profile can override /Library/Preferences, - # which is how the app and the helper resolve the same settings. - if [[ -n "$value" ]]; then - [[ "$value" == "1" ]] && skip_helper_install=true - break - fi - fi -done +managed_domain="/Library/Managed Preferences/com.github.macadmins.SupportCompanion" +if [[ -f "${managed_domain}.plist" ]]; then + value=$(/usr/bin/defaults read "$managed_domain" SkipHelperInstall 2>/dev/null) + [[ "$value" == "1" ]] && skip_helper_install=true +fi + +# Say when the key is being ignored because of where it lives, rather than silently installing the +# helper anyway and leaving two daemons claiming the same Mach service. +if [[ "$skip_helper_install" == "false" ]] \ + && [[ -f "/Library/Preferences/com.github.macadmins.SupportCompanion.plist" ]] \ + && [[ -n "$(/usr/bin/defaults read /Library/Preferences/com.github.macadmins.SupportCompanion SkipHelperInstall 2>/dev/null)" ]] +then + echo "Ignoring SkipHelperInstall in /Library/Preferences: it is only read from a device-scoped configuration profile. Deliver it through your MDM." +fi if [[ "$skip_helper_install" == "true" ]]; then echo "SkipHelperInstall is set; leaving the helper to the declarative configuration." From 0d000912869355f2adaed0d024e8e87a61388e36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Alm=C3=A9n?= <78877636+almenscorner@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:33:08 +0200 Subject: [PATCH 097/104] Update changelog for version 3.0.0: revise breaking changes and enhancements, including MDM profile requirements and user install features. --- CHANGELOG.md | 66 +++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 60 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5e5140..9bfc065 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,9 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [3.0.0] - 2026-09-17 +## [3.0.0] - 2026-09-25 ### Breaking changes -- **Privileged settings are only read when set by an administrator.** `IsPrivileged` on `Actions`, `RequirePrivilegedActionAuthentication`, and the elevation settings (`EnableElevation`, `MaxElevationTime`, `RequireResonForElevation`, `ReasonMinLength`, `ElevationWebhookUrl`, `ElevationSeverity`) are now only honored when they come from a configuration profile or `/Library/Preferences`. These settings decide what runs as root and who gets administrator rights, so honoring them from a domain the user can write to was a local privilege escalation. Actions defined in the user's own preferences still run, but never with privileges, and `RequirePrivilegedActionAuthentication` defaults to `true` unless an administrator sets it. **If you deploy these settings with `defaults write` as the user, move them to a configuration profile or `/Library/Preferences`.** +- **Privileged settings are only read when set by an administrator.** `IsPrivileged` on `Actions`, `RequirePrivilegedActionAuthentication`, and the elevation settings (`EnableElevation`, `MaxElevationTime`, `RequireResonForElevation`, `ReasonMinLength`, `ElevationWebhookUrl`, `ElevationSeverity`) are now only honored when they come from an MDM configuration profile. These settings decide what runs as root and who gets administrator rights, so honoring them from a domain the user can write to was a local privilege escalation. `/Library/Preferences/com.github.macadmins.SupportCompanion.plist` is **no longer trusted for these keys either**: only root can write it, which is exactly the problem — anything that reaches root once, a privileged action or somebody inside an elevation window, could write itself a permanent grant there and nothing would put it back. A profile is owned by the MDM, which re-applies it. Actions defined in the user's own preferences still run, but never with privileges, and `RequirePrivilegedActionAuthentication` defaults to `true` unless an administrator sets it. **If you deploy these settings with `defaults write`, to either the user's domain or `/Library/Preferences`, move them to a configuration profile.** A key that is ignored because of where it lives is logged, so a setting that appears to have stopped working can be confirmed from the log. - **Fleet mode is selected automatically** on Macs with Fleet's agent (orbit) installed when no other mode matches. Before, these Macs used System Profiler mode. Set `Mode` explicitly to keep a different mode. - **The privileged helper is installed by the package's `helper_install.zsh`**, which places it in `/Library/PrivilegedHelperTools` and loads its LaunchDaemon. `SMJobBless` is deprecated in macOS 14 and later; `SMAppService` registration remains only as a fallback for a Mac where the packaged helper is missing, and is skipped entirely when `SkipHelperInstall` says the helper is deployed declaratively. - **Existing helper installs:** the package now replaces the helper on every install, unloading the old one first and loading the new one, and the install fails if the helper does not end up running. The app and the helper speak a versioned interface, so a Mac left with an older helper is not merely stale — every privileged operation fails — and that must not pass silently. @@ -24,19 +24,73 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **`EnforceAdminAllowlist` and `PermanentAdmins`, to stop an elevation outliving its window.** The helper's deadline lives in a root-owned file, and a user who is briefly an administrator is briefly root, so they can delete it and restart the helper — leaving nothing to say a demotion was owed, and no way to tell their rights from a permanent administrator's. With an allowlist set, the helper reconciles the `admin` group at startup and every five minutes: anyone holding administrator rights who is neither in `PermanentAdmins` nor inside a live elevation window is demoted. Deleting the state file then removes the only evidence that an elevation was legitimate, so it ends the elevation rather than extending it. `root` is always permitted and never demoted, and the policy is re-read on every pass, so removing the profile only suspends enforcement while it is actually missing. - **`EnforceAdminAllowlist` defaults to off, and `PermanentAdmins` must list every account that should keep administrator rights before it is turned on** — management accounts, break-glass accounts and permanently-admin staff included. This includes accounts granted administrator rights by something else, such as Platform SSO's `AdministratorGroups`: anything not on the list is demoted, and an identity provider that grants it again simply produces a demotion every five minutes. With it on and the list incomplete, those accounts are demoted within five minutes. An explicitly empty list is honored, meaning no account is permanently an administrator; a missing list is refused with an error rather than acted on. Both keys must come from a **device-scoped** profile or `/Library/Preferences`, since reconciliation runs when there is no logged-in user to attribute it to. + **`EnforceAdminAllowlist` defaults to off, and `PermanentAdmins` must list every account that should keep administrator rights before it is turned on** — management accounts, break-glass accounts and permanently-admin staff included. This includes accounts granted administrator rights by something else, such as Platform SSO's `AdministratorGroups`: anything not on the list is demoted, and an identity provider that grants it again simply produces a demotion every five minutes. With it on and the list incomplete, those accounts are demoted within five minutes. An explicitly empty list is honored, meaning no account is permanently an administrator; a missing list is refused with an error rather than acted on. Both keys must come from a **device-scoped** configuration profile, since reconciliation runs when there is no logged-in user to attribute it to. Administrator group members that cannot be resolved to an account are reported and left alone rather than removed, since a failed lookup is not evidence that an entry does not belong. Entries that appear while an elevation is open are still revoked, because there the group's earlier state establishes that they are new. This is containment rather than prevention. Brief root access has other routes to persistence — a launch daemon, a sudoers drop-in, enabling the root account — which group membership does not show. Treat elevation as a speed bump with an audit trail, not as a boundary. - **The helper can be deployed declaratively.** With `com.apple.configuration.services.background-tasks` (macOS 15+, supervised), the helper and its launchd job are placed in `/var/db/ManagedConfigurationFiles`, which the system will not let even root write to, and the job cannot be unloaded or disabled — so an elevated user cannot stop the process that will demote them. `DDM/make_ddm_assets.zsh` builds the archive, the launchd job and the declarations from a built app. This is the recommended deployment wherever `EnableElevation` is used. -- `SkipHelperInstall`, which stops the package installing and loading its own copy of the helper, for Macs where it is deployed declaratively. Set it in a **device-scoped** profile: a user-scoped one is not readable by an installer script. Only honored from a configuration profile or `/Library/Preferences`. -- `ElevationAllowedAdmins`, a list of account names the elevation watchdog ignores, for management accounts an MDM may legitimately add while somebody is elevated. Read from a **device-scoped** profile or `/Library/Preferences`, like the other elevation enforcement keys. -- **Fleet mode.** Support Companion now integrates with [Fleet](https://fleetdm.com), using the Fleet device API with the device's own token, so no API keys are needed. Fleet mode is used when Fleet's agent (orbit) is installed, when the MDM server is the Fleet server, or when `Mode` is set to `Fleet`. The Fleet server URL is read from fleetd's configuration profile or orbit's LaunchDaemon. It can be overridden with `FleetUrl`, which is only honored when set by a configuration profile or in `/Library/Preferences`, since the device token is sent to that server. Example configuration: +- `SkipHelperInstall`, which stops the package installing and loading its own copy of the helper, for Macs where it is deployed declaratively. Set it in a **device-scoped** configuration profile: a user-scoped one is not readable by an installer script, and `/Library/Preferences` is not read for this key either. Setting it removes the helper and its launchd job, so honoring it from a root-writable file would let one root moment disable the component that demotes elevated users. The installer script logs the key when it finds it somewhere it is not read from. +- `ElevationAllowedAdmins`, a list of account names the elevation watchdog ignores, for management accounts an MDM may legitimately add while somebody is elevated. Read from a **device-scoped** configuration profile, like the other elevation enforcement keys. +- While administrator rights are held, the time left counts down next to the tray menu icon, so it is visible without opening anything. The tooltip offers to demote immediately. The countdown is driven by the app's timer, but the deadline it shows belongs to the helper, so quitting the app does not extend it. +- **User installs.** A standard user can install an application an administrator has allowlisted, without holding administrator rights for it. Support Companion registers for `.pkg` and `.dmg` files, so a double-click opens a sheet that names the installer, what signed it, what verified it, and where it would write, then installs it through the helper. Finder's context menu also offers **Install with Support Companion**. + + This exists because the alternative is worse. The usual answer to "I need to install this" is a window of full administrator rights, during which the user can do everything else administrator rights allow, and only the reason they typed records what it was for. Here the user gets one install of one approved thing, and nothing else. + + **The helper decides, not the app.** It re-reads the policy and re-derives the installer's facts before it acts, so the sheet is presentation only. As with the other privileged settings, the policy is read **only from a configuration profile** — a device-scoped one, or one scoped to the user being served. + +```xml +EnableUserInstalls + +AllowedInstallers + + + Name + Firefox + TeamID + 43AQ936H96 + BundleIdentifier + org.mozilla.firefox + MinimumVersion + 128.0 + + +``` + + Each entry needs a `Name`, which is what the sheet and the log call it, and enough to identify one thing: a `TeamID` plus a `PackageIdentifier` or `BundleIdentifier`, or a `SHA256` of the installer file. **An entry that cannot decide anything is dropped and logged, not read permissively** — the safe reading of a half-written entry is that nothing was allowed. Per entry: + + - `TeamID` — the Developer ID team the installer must be signed by. Required unless `SHA256` is set. + - `PackageIdentifier`, `BundleIdentifier` — the identifier to accept, as a string or an array. For a `.dmg` this is the app inside it. + - A distribution package must have **every** component covered, not merely one. Accepting it because one component was named would let the others in unexamined, which is how an approved application arrives with a LaunchDaemon nobody asked about. The refusal names the component that was not covered. + - `AllowAnyIdentifier` — accept anything that team signs. This is a vendor allowlist, not an app: it covers every installer they will ever sign. It has its own key so that leaving an identifier out of a profile by mistake cannot produce it. + - `SHA256` — the exact digest of the installer file. Pins one build, and is the only form that needs no signature at all. + - `LeafCertificateSHA256` — pins the signing certificate itself, which is unambiguous in a way a team name is not, at the cost of needing an update when the vendor renews. Never required. + - `MinimumVersion` — refuse anything older, so a signed-but-vulnerable build cannot be installed instead of the current one. Compared numerically, not as text. + - `RequireNotarized` — require Apple notarization as well as a signature. + - `AllowScripts` — **off by default, and the most useful restriction here.** A scriptless package whose payload lands in `/Applications` is a file copy; one with a postinstall script is arbitrary code as root on every future build that vendor signs. + - `AllowedPayloadPrefixes` — the absolute path prefixes this installer may write to. Unrestricted by default, because enumerating paths for every app is more than most administrators will do, but setting it is what bounds the damage when an entry is written loosely: an allowlisted package is otherwise free to drop a LaunchDaemon while installing the app that was approved. + - `AllowUnrestrictedPayload` — let it write anywhere, including the paths held back below. Its own key, so the most dangerous setting is one somebody has to mean rather than one they can reach by typing a prefix wrong. + + **Some destinations are held back unless `AllowedPayloadPrefixes` names them**, whatever signed the package: `/Library/Managed Preferences` and `/Library/Preferences`, this app's own preferences and its helper's state directory, `/Library/PrivilegedHelperTools`, `/Library/Security`, `/Library/ScriptingAdditions`, `/var/db/dslocal`, `/etc/sudoers` and `sudoers.d`, `/etc/pam.d`, `/etc/ssh`, and `/var/root`. These are the paths that decide what may be installed and who may hold administrator rights, so an install that writes there is how this feature would be turned against itself. + + **This is a policy, not a sandbox.** An allowlisted installer runs as root, and `AllowScripts` with a broad `AllowedPayloadPrefixes` is close to the elevation it replaces. The restrictions are worth setting; what they buy is that an administrator chose the software, not that the software is harmless. + +- `RequireAuthenticationForInstalls`, on by default, which asks the user to authenticate before an allowlisted install proceeds. Only honored from a configuration profile. +- `UserInstallFallback`, which decides what a user is offered when an installer is **not** allowlisted: `installer` (the default) hands the file to Installer.app or Finder exactly as a double-click would without this app, `elevate` offers the existing time-limited elevation flow, and `none` offers nothing. The sheet names why it was refused either way, in terms an administrator can act on. +- `ShowInstallerServiceMenuItem`, which controls Finder's **Install with Support Companion** item. Follows `EnableUserInstalls` unless set, so an organisation that does not use the feature never sees it, and one that does can still hide it to keep the only route inside the app. +- **Fleet mode.** Support Companion now integrates with [Fleet](https://fleetdm.com), using the Fleet device API with the device's own token, so no API keys are needed. Fleet mode is used when Fleet's agent (orbit) is installed, when the MDM server is the Fleet server, or when `Mode` is set to `Fleet`. The Fleet server URL is read from fleetd's configuration profile or orbit's LaunchDaemon. It can be overridden with `FleetUrl`, which is only honored when set by a configuration profile, since the device token is sent to that server. Example configuration: ```xml Mode Fleet ``` +- **In-app Fleet Desktop SSO sign-in.** Where Fleet requires SSO for the device page (Fleet 4.92 and later), signing in happens in the app: Fleet hands back the identity provider's URL, the user signs in to it in a web view, and the app keeps the session Fleet returns in the keychain. The SAML assertion stays between the user and their identity provider — the app only ever sees the resulting session cookie. Sessions last as long as Fleet's `session.duration` (five days by default) and expire absolutely rather than on idle, so the sheet reappears when it lapses. + + **Signed out, the app does not pretend to know nothing.** Fleet's device summary endpoint is outside the SSO gate and reports the failing-policy count, so the sidebar badge, the Dock badge, the compliance banner and the compliance card stay truthful, and the Fleet and compliance cards collapse to a single Sign In action instead of a wall of Unknown. Everything identifying — host ID, team, last seen, policy names, the self-service catalog — is withheld by Fleet until sign-in, and that is not something this app can work around. `supportcompanion://fleetsignin` opens the sheet. +- `FleetNotifySignIn`, a notification asking the user to sign in to Fleet, leading with the number of failing checks when there are any. **Off by default**, unlike the other `FleetNotify*` keys: it asks the user to do something rather than telling them something, so it is not imposed. Sent at most once a day, and reset by a successful sign-in. +```xml +FleetNotifySignIn + +``` - **Self-service apps.** In Fleet mode the Apps page shows the device's Fleet self-service software, with search, category filters and collapsible Updates Available, Available and Installed sections. Apps can be installed, updated, reinstalled and uninstalled. Progress is followed until Fleet reports the result, and the output of failed installs can be viewed from the app card. Updates stay listed until the new version is actually installed. - Custom button text per app, for example "Request" for an app named "Request software". Keys are the software title ID, display name or name; values are either a string that replaces the Install label, or a dictionary with `Install`, `Update`, `Reinstall` and `Uninstall`. Example configuration: ```xml From 435af6aa4b4ba761fcdf2c9cef6ab67ddc135490 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Alm=C3=A9n?= <78877636+almenscorner@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:33:18 +0200 Subject: [PATCH 098/104] Add translations for various UI strings in German, French, Japanese, Norwegian, and Swedish --- SupportCompanion/Localizable.xcstrings | 974 ++++++++++++++++++++++--- 1 file changed, 877 insertions(+), 97 deletions(-) diff --git a/SupportCompanion/Localizable.xcstrings b/SupportCompanion/Localizable.xcstrings index b6b71de..e51141f 100644 --- a/SupportCompanion/Localizable.xcstrings +++ b/SupportCompanion/Localizable.xcstrings @@ -5972,11 +5972,41 @@ "comment" : "Cancel", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abbrechen" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Cancel" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Annuler" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "キャンセル" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avbryt" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avbryt" + } } } }, @@ -6236,11 +6266,41 @@ "comment" : "Done", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fertig" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Done" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Terminé" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "完了" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ferdig" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Klar" + } } } }, @@ -6470,6 +6530,12 @@ "value" : "Minute" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Minute" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -6506,6 +6572,12 @@ "value" : "Minutes" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Minutes" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -8415,9 +8487,9 @@ } } }, - "TabelHeaders.Action" : { + "TableHeaders.Action" : { "comment" : "Header for the action column", - "extractionState" : "stale", + "extractionState" : "extracted_with_value", "localizations" : { "de" : { "stringUnit" : { @@ -8431,6 +8503,12 @@ "value" : "Action" } }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Action" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -8451,10 +8529,16 @@ } } }, - "TabelHeaders.Name" : { + "TableHeaders.Name" : { "comment" : "Header for the name column", - "extractionState" : "stale", + "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Name" + } + }, "en" : { "stringUnit" : { "state" : "new", @@ -8487,129 +8571,45 @@ } } }, - "TabelHeaders.Version" : { + "TableHeaders.Version" : { "comment" : "Header for the version column", - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Version" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "バージョン" - } - }, - "nb" : { - "stringUnit" : { - "state" : "translated", - "value" : "Versjon" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Version" - } - } - } - }, - "TableHeaders.Action" : { - "comment" : "Header for the action column", "extractionState" : "extracted_with_value", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Aktion" - } - }, - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Action" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "アクション" - } - }, - "nb" : { - "stringUnit" : { - "state" : "translated", - "value" : "Handling" + "value" : "Version" } }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Åtgärd" - } - } - } - }, - "TableHeaders.Name" : { - "comment" : "Header for the name column", - "extractionState" : "extracted_with_value", - "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Name" + "value" : "Version" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Nom" + "value" : "Version" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "名前" + "value" : "バージョン" } }, "nb" : { "stringUnit" : { "state" : "translated", - "value" : "Navn" + "value" : "Versjon" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "Namn" - } - } - } - }, - "TableHeaders.Version" : { - "comment" : "Header for the version column", - "extractionState" : "extracted_with_value", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", "value" : "Version" } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "バージョン" - } - }, - "nb" : { - "stringUnit" : { - "state" : "translated", - "value" : "Versjon" - } } } }, @@ -9111,11 +9111,41 @@ "comment" : "Label for which allowlist entry matched", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zugelassen als" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Allowed as" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autorisé en tant que" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "許可の種類" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tillatt som" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tillåten som" + } } } }, @@ -9123,11 +9153,41 @@ "comment" : "Shown when an installer matches the administrator's allowlist", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ihr Administrator erlaubt Ihnen, dies zu installieren" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Your administrator allows you to install this" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre administrateur vous autorise à installer ceci" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "管理者がこのインストールを許可しています" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administratoren din tillater at du installerer dette" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Din administratör tillåter att du installerar detta" + } } } }, @@ -9135,11 +9195,41 @@ "comment" : "Shown when a refused installer matches something in the software catalog", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ihre Organisation bietet dies bereits an" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Your organisation already offers this" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre organisation propose déjà ceci" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "組織がすでにこれを提供しています" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Organisasjonen din tilbyr allerede dette" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Din organisation erbjuder redan detta" + } } } }, @@ -9147,10 +9237,40 @@ "comment" : "Explains the catalog suggestion; %@ is the name of the Apps page", "extractionState" : "extracted_with_value", "localizations" : { - "en" : { + "de" : { "stringUnit" : { - "state" : "new", - "value" : "Install the approved version from %@ instead of this download." + "state" : "translated", + "value" : "Installieren Sie die genehmigte Version aus %@ anstelle dieses Downloads." + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Install the approved version from %@ instead of this download." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installez la version approuvée depuis %@ au lieu de ce téléchargement." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "このダウンロードではなく、%@ から承認済みのバージョンをインストールしてください。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installer den godkjente versjonen fra %@ i stedet for denne nedlastingen." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installera den godkända versionen från %@ i stället för den här nedladdningen." } } } @@ -9159,11 +9279,41 @@ "comment" : "Label for the digest of the certificate an installer was signed with", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "SHA-256 des Signaturzertifikats" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Signing certificate SHA-256" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "SHA-256 du certificat de signature" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "署名証明書のSHA-256" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "SHA-256 for signeringssertifikatet" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "SHA-256 för signeringscertifikatet" + } } } }, @@ -9171,11 +9321,41 @@ "comment" : "Shown while the helper assesses an installer the user opened", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dieses Installationsprogramm wird geprüft" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Checking this installer" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vérification de cet installateur" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "このインストーラを確認しています" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kontrollerer dette installasjonsprogrammet" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kontrollerar det här installationsprogrammet" + } } } }, @@ -9183,11 +9363,41 @@ "comment" : "Collapsed section holding what an organisation needs to allow an installer", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Details" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Details" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Détails" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "詳細" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detaljer" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detaljer" + } } } }, @@ -9195,11 +9405,41 @@ "comment" : "Label for who signed the installer", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entwickler" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Developer" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Développeur" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "開発元" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utvikler" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utvecklare" + } } } }, @@ -9207,11 +9447,41 @@ "comment" : "Button offering the elevation flow for an installer that is not allowed", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administratorrechte anfordern" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Request admin rights" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Demander des droits d’administrateur" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "管理者権限を要求" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Be om administratorrettigheter" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Begär administratörsrättigheter" + } } } }, @@ -9219,11 +9489,41 @@ "comment" : "Shown when an allowed installer failed to install", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Installation wurde nicht abgeschlossen" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "The installation did not finish" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "L’installation ne s’est pas terminée" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "インストールが完了しませんでした" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installasjonen ble ikke fullført" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installationen slutfördes inte" + } } } }, @@ -9231,11 +9531,41 @@ "comment" : "Precedes a file name, above that file's digest, for a refused installer", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "SHA-256 von" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "SHA-256 of" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "SHA-256 de" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "SHA-256:" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "SHA-256 av" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "SHA-256 för" + } } } }, @@ -9243,11 +9573,41 @@ "comment" : "Label for a refused installer's package or bundle identifier", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kennung" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Identifier" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Identifiant" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "識別子" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Identifikator" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Identifierare" + } } } }, @@ -9255,11 +9615,41 @@ "comment" : "Button that installs an allowed application", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installieren" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Install" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installer" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "インストール" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installer" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installera" + } } } }, @@ -9267,11 +9657,41 @@ "comment" : "Shown when an allowed installer has finished installing", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erfolgreich installiert" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Installed successfully" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installé avec succès" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "インストールが完了しました" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installasjonen er fullført" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installationen lyckades" + } } } }, @@ -9279,11 +9699,41 @@ "comment" : "Shown while an allowed installer is being installed", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wird installiert" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Installing" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installation en cours" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "インストール中" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installerer" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installerar" + } } } }, @@ -9291,11 +9741,41 @@ "comment" : "Label for the places an installer writes", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installiert nach" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Installs to" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installe dans" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "インストール先" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installerer til" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installerar till" + } } } }, @@ -9303,11 +9783,41 @@ "comment" : "Describes an app from a .dmg in the install window", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Programm" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Application" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Application" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アプリケーション" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Program" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Program" + } } } }, @@ -9315,11 +9825,41 @@ "comment" : "Describes a .pkg in the install window", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installationspaket" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Installer package" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Paquet d’installation" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "インストーラパッケージ" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installasjonspakke" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installationspaket" + } } } }, @@ -9327,11 +9867,41 @@ "comment" : "Shown when an installer does not match the organisation's allowlist", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ihre Organisation hat dieses Installationsprogramm nicht zugelassen" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Your organisation has not allowed this installer" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre organisation n’a pas autorisé cet installateur" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "組織はこのインストーラを許可していません" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Organisasjonen din har ikke tillatt dette installasjonsprogrammet" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Din organisation har inte tillåtit det här installationsprogrammet" + } } } }, @@ -9339,11 +9909,41 @@ "comment" : "Button handing the installer to Installer.app after the check could not be made", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trotzdem öffnen" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Open Anyway" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ouvrir quand même" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "それでも開く" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Åpne likevel" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Öppna ändå" + } } } }, @@ -9351,11 +9951,41 @@ "comment" : "Button taking the user to the software catalog page", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anzeigen" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Show me" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "表示" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vis meg" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Visa" + } } } }, @@ -9363,11 +9993,41 @@ "comment" : "Label for how the installer was verified", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verifiziert" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Verified" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vérifié" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "検証" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verifisert" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verifierad" + } } } }, @@ -9375,11 +10035,41 @@ "comment" : "Shown when an installer matched on its SHA-256", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exakte Kopie, die Ihre Organisation genehmigt hat" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Exact copy your organisation approved" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copie exacte approuvée par votre organisation" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "組織が承認したものと完全に一致" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eksakt kopi som organisasjonen din har godkjent" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exakt kopia som din organisation har godkänt" + } } } }, @@ -9387,11 +10077,41 @@ "comment" : "Shown when an installer matched on a notarized signature", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Signatur und Apple-Notarisierung" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Signature and Apple notarization" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Signature et notarisation Apple" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "署名とAppleの公証" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Signatur og Apple-notarisering" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Signatur och Apple-notarisering" + } } } }, @@ -9399,11 +10119,41 @@ "comment" : "Shown when an installer matched on its signature alone", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entwicklersignatur" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Developer signature" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Signature du développeur" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "開発元の署名" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utviklersignatur" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utvecklarsignatur" + } } } }, @@ -9411,11 +10161,41 @@ "comment" : "Precedes the version number of an installer in the install window", "extractionState" : "extracted_with_value", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Version" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Version" } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Version" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "バージョン" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Versjon" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Version" + } } } }, From 68fcee89b2f9e114ef181db161271e94ada8fa16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Alm=C3=A9n?= <78877636+almenscorner@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:35:02 +0200 Subject: [PATCH 099/104] Report Jamf patches against the policy that installed the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pending Zoom update was never reported. Jamf lists a patch title as a policy of its own beside the policy that actually installed the app, under different names: "Zoom Client for Meetings" with installstatus 0, and "zoom.us" with installstatus 4. computeUpdates took an exact name match unconditionally, so it picked the listing rather than the installation, and the installed-app check added in 318d483 then discarded the patch. Matching now scores every plausible policy and prefers the one with the better evidence of an installation. A flat "is it installed" test cannot separate these two: both names reduce to the token "zoom", so both resemble zoom.us.app equally well, and only the install record tells them apart. That evidence no longer comes from installstatus alone, which is wrong in both directions. Apparency is installed but its policy reports 0, which hid its patch; Brave reports 4 with the bundle long since deleted, which produced exactly the phantom update 318d483 set out to prevent. The Mac now gets the veto and Jamf the tie-break, with installstatus trusted outright only when the application directories cannot be read at all — an empty index means the lookup failed, not that the Mac has no applications. Also stops a patch installed from here flipping back to Update. Self Service+'s store is its own cache and only changes when it runs, so re-deriving the list straight after an install read a store that still offered the patch, and clearing the spinner left the row showing its default label. RecentlyInstalledPatches holds it back until the store agrees, or until thirty minutes have passed so an install that silently did nothing reappears rather than hiding for good. This mirrors installedVersionsAwaitingInventory on the Fleet side, which covers the same lag. refreshSelfService now waits for the store's modification date to change instead of sleeping two seconds and hoping, and asks Self Service+ to quit before resorting to SIGKILL, which could cut off the write it is trying to read. A Self Service+ window the user has open is left alone: quitting something somebody is reading to refresh a cache is not a good trade, and the suppression above covers correctness instead. computeUpdates had no test coverage. It takes its installed-app index as a parameter so the rules can be exercised without touching the disk. --- SupportCompanion/Helpers/JamfApps.swift | 210 ++++++++---- .../Protocols/PendingUpdate.swift | 12 +- .../PendingJamfUpdatesManager.swift | 184 ++++++++++- .../JamfUpdateMatchingTests.swift | 303 ++++++++++++++++++ 4 files changed, 637 insertions(+), 72 deletions(-) create mode 100644 SupportCompanionTests/JamfUpdateMatchingTests.swift diff --git a/SupportCompanion/Helpers/JamfApps.swift b/SupportCompanion/Helpers/JamfApps.swift index caa7116..2979749 100644 --- a/SupportCompanion/Helpers/JamfApps.swift +++ b/SupportCompanion/Helpers/JamfApps.swift @@ -250,53 +250,116 @@ func evaluateUpdate(policy: Policy, patch: Patch, now: Date = .init()) -> (neede } } -func computeUpdates(policies: [Policy], - patches: [Patch], - now: Date = Date()) async -> ([PendingJamfUpdate], Int, Int) { - - // MARK: - Non-hardcoded fuzzy name matching helpers - func canonicalTokens(_ s: String) -> [String] { - // Lowercase and remove non-alphanumerics to get stable tokens - let lowered = s.lowercased() - let cleaned = lowered.replacingOccurrences(of: #"[^a-z0-9]+"#, with: " ", options: .regularExpression) - let tokens = cleaned.split(separator: " ").map(String.init) - // Remove very short/common tokens to reduce false positives - let stop: Set = ["client", "for", "the", "and", "app", "apps", "application", "meetings", "installer", "update", "patch"] - return tokens.filter { $0.count >= 3 && !stop.contains($0) } - } +// MARK: - Name matching + +/// Lowercased, punctuation-free tokens, for deciding whether two names mean the same application. +/// +/// Used for patch-to-policy and for policy-to-bundle alike. That sharing is deliberate: the names Jamf +/// uses for a patch title, for the policy that installed the app, and for the bundle on disk are all +/// different — "Zoom Client for Meetings", "zoom.us", `zoom.us.app` — and a lookup that only handles +/// one of those mismatches trades a missed update for a phantom one. +func canonicalTokens(_ s: String) -> [String] { + // Lowercase and remove non-alphanumerics to get stable tokens + let lowered = s.lowercased() + let cleaned = lowered.replacingOccurrences(of: #"[^a-z0-9]+"#, with: " ", options: .regularExpression) + let tokens = cleaned.split(separator: " ").map(String.init) + // Remove very short/common tokens to reduce false positives + let stop: Set = ["client", "for", "the", "and", "app", "apps", "application", "meetings", "installer", "update", "patch"] + return tokens.filter { $0.count >= 3 && !stop.contains($0) } +} + +/// How alike two names are, as the share of the shorter one's tokens that the longer also has. +func nameSimilarity(_ a: String, _ b: String) -> Double { + let ta = Set(canonicalTokens(a)) + let tb = Set(canonicalTokens(b)) + guard !ta.isEmpty, !tb.isEmpty else { return 0 } + + let denom = Double(min(ta.count, tb.count)) + return denom > 0 ? Double(ta.intersection(tb).count) / denom : 0 +} + +/// Threshold tuned for distinctive names like "zoom", "slack", "chrome". +let nameMatchThreshold = 0.5 + +/// The application bundles on this Mac, by name. +/// +/// `/Applications` and `~/Applications`, plus one level below each so that vendor folders and +/// `Utilities` are covered. Anything kept outside those is not somewhere Jamf patching would be +/// managing it from. +func installedAppNames() -> [String] { + let fileManager = FileManager.default + let roots = ["/Applications", ("~/Applications" as NSString).expandingTildeInPath] + var names: [String] = [] + + for root in roots { + guard let entries = try? fileManager.contentsOfDirectory(atPath: root) else { continue } - func fuzzyMatchPolicy(for patchName: String, in dict: [String: Policy]) -> Policy? { - let pTokens = Set(canonicalTokens(patchName)) - guard !pTokens.isEmpty else { return nil } - - var best: (policy: Policy, score: Double)? - for (policyName, policy) in dict { - let t = Set(canonicalTokens(policyName)) - if t.isEmpty { continue } - let overlap = Double(pTokens.intersection(t).count) - let denom = Double(min(pTokens.count, t.count)) - let score = denom > 0 ? overlap / denom : 0 - // Threshold tuned for distinctive names like "zoom", "slack", "chrome". - if score >= 0.5 { - if let current = best { - if score > current.score { - best = (policy, score) - } else if score == current.score { - // Tie-break using existing recency/priority logic - let s0 = policy.installStatus ?? 0 - let s1 = current.policy.installStatus ?? 0 - if s0 > s1 || (s0 == s1 && (policy.installedOrUpdated ?? .distantPast) > (current.policy.installedOrUpdated ?? .distantPast)) { - best = (policy, score) - } - } - } else { - best = (policy, score) - } + for entry in entries { + if entry.hasSuffix(".app") { + names.append(String(entry.dropLast(4))) + continue } + + let nested = "\(root)/\(entry)" + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: nested, isDirectory: &isDirectory), isDirectory.boolValue + else { continue } + + let children = (try? fileManager.contentsOfDirectory(atPath: nested)) ?? [] + names.append(contentsOf: children.filter { $0.hasSuffix(".app") }.map { String($0.dropLast(4)) }) } - return best?.policy } + return names +} + +/// How good the evidence is that a policy's application is on this Mac. +/// +/// Ordered, because more than one policy can plausibly answer for the same app and the strongest +/// evidence should win. Jamf lists a patch title as a policy of its own beside the policy that +/// installed the app, and name matching cannot separate them — "Zoom Client for Meetings" and +/// "zoom.us" both reduce to `zoom`, so both resemble `zoom.us.app` equally well. What separates them is +/// that only one of them is Jamf's record of an installation. +enum InstallEvidence: Int, Comparable { + /// Neither Jamf nor the Mac says the app is here. + case absent = 0 + /// A matching bundle is present, but Jamf has no record of installing it — an app somebody + /// installed by hand. Its patches still apply. + case onDisk = 1 + /// Jamf installed it, and the Mac agrees. + case installedByJamf = 2 + + static func < (lhs: InstallEvidence, rhs: InstallEvidence) -> Bool { + lhs.rawValue < rhs.rawValue + } +} + +/// What we can tell about whether a policy's application is really here. +/// +/// `installstatus` disagrees with the Mac in both directions, and each direction costs something +/// different: an app installed by hand reads `0`, which used to hide its patches, and one the user has +/// since dragged to the Trash still reads `4`, which offered an update for software that was not there. +/// So the Mac gets the veto and Jamf gets the tie-break. Jamf's record is trusted outright only when +/// the disk could not be read, since an empty index means the lookup failed rather than that the Mac +/// has no applications. +func installEvidence(for policy: Policy, among installedApps: [String]) -> InstallEvidence { + let claimedByJamf = (policy.installStatus ?? 0) == 4 + + guard !installedApps.isEmpty else { return claimedByJamf ? .installedByJamf : .absent } + + let onDisk = installedApps.contains { nameSimilarity(policy.name, $0) >= nameMatchThreshold } + guard onDisk else { return .absent } + + return claimedByJamf ? .installedByJamf : .onDisk +} + +func computeUpdates(policies: [Policy], + patches: [Patch], + now: Date = Date(), + installedApps: [String]? = nil) async -> ([PendingJamfUpdate], Int, Int) { + + let installed = installedApps ?? installedAppNames() + // Keep one policy per name: the newest (by installedOrUpdated date) let policiesByName: [String: Policy] = Dictionary(grouping: policies, by: { $0.name }) .compactMapValues { group in @@ -306,22 +369,61 @@ func computeUpdates(policies: [Policy], return ($0.installedOrUpdated ?? .distantPast) > ($1.installedOrUpdated ?? .distantPast) }.first } + + /// The policy a patch is about: the best evidence of an installation, then the closest name. + /// + /// The previous version took an exact name match unconditionally and only fell back to fuzzy + /// matching when there was none. That picked Jamf's listing of the patch title over the policy that + /// had actually installed the app, and the installed-app check then discarded the patch — which is + /// how a pending Zoom update went unreported. + func resolvePolicy(for patchName: String) -> Policy? { + guard !canonicalTokens(patchName).isEmpty else { return nil } + + var best: (policy: Policy, evidence: InstallEvidence, score: Double, exact: Bool)? + + for (policyName, policy) in policiesByName { + let exact = policyName == patchName + let score = exact ? 1.0 : nameSimilarity(patchName, policyName) + guard score >= nameMatchThreshold else { continue } + + let evidence = installEvidence(for: policy, among: installed) + // Self Service lists policies for apps that are not installed, and Jamf publishes patches + // for them too. Offering an update for software that is not here would be a fresh install + // nobody asked for, so those are no candidate at all. + guard evidence > .absent else { continue } + + guard let current = best else { + best = (policy, evidence, score, exact) + continue + } + + // Evidence of an installation first, then name closeness, then an exact name, then + // recency — the same tie-break the fuzzy matcher used before. + let better: Bool + if evidence != current.evidence { + better = evidence > current.evidence + } else if score != current.score { + better = score > current.score + } else if exact != current.exact { + better = exact + } else { + better = (policy.installedOrUpdated ?? .distantPast) + > (current.policy.installedOrUpdated ?? .distantPast) + } + + if better { best = (policy, evidence, score, exact) } + } + + return best?.policy + } + var results: [PendingJamfUpdate] = [] var matchedPolicyNames = Set() var updateCount = 0 var upToDateCount = 0 for patch in patches { - // Try exact match by normalized name first - var matchedPolicy: Policy? = policiesByName[patch.name] - // Fallback to fuzzy token-based match if exact match fails - if matchedPolicy == nil { - matchedPolicy = fuzzyMatchPolicy(for: patch.name, in: policiesByName) - } - guard let policy = matchedPolicy else { continue } - // Self Service also lists policies for apps that aren't installed, and Jamf publishes patches for - // them too. Only an installed app can need one, so skip the rest rather than report a phantom update. - guard (policy.installStatus ?? 0) == 4 else { continue } + guard let policy = resolvePolicy(for: patch.name) else { continue } matchedPolicyNames.insert(policy.name) let (needed, label) = evaluateUpdate(policy: policy, patch: patch, now: now) @@ -358,14 +460,14 @@ func computeUpdates(policies: [Policy], // does not show 0% patched purely due to missing patch objects. if patches.isEmpty { for (_, policy) in policiesByName { - if (policy.installStatus ?? 0) == 4 { + if installEvidence(for: policy, among: installed) > .absent { upToDateCount += 1 } } } else { // Also count installed policies that did not have a matching patch name. for (name, policy) in policiesByName where !matchedPolicyNames.contains(name) { - if (policy.installStatus ?? 0) == 4 { + if installEvidence(for: policy, among: installed) > .absent { upToDateCount += 1 } } diff --git a/SupportCompanion/Protocols/PendingUpdate.swift b/SupportCompanion/Protocols/PendingUpdate.swift index 66988bb..efd3d53 100644 --- a/SupportCompanion/Protocols/PendingUpdate.swift +++ b/SupportCompanion/Protocols/PendingUpdate.swift @@ -12,12 +12,22 @@ protocol PendingUpdate: Identifiable where ID == UUID { var version: String { get } /// Shown in an extra column when the manager defines `pendingUpdatesDetailColumnTitle`. var dueBy: String? { get } + /// The installed application this update is for, when that is named differently from the update. + /// + /// Lets the Apps page pair an update with the app it belongs to without asking which mode is + /// configured. Jamf needs it because the two names routinely differ — a patch called "Zoom Client + /// for Meetings" updates an app Self Service lists as "zoom.us". + var installedAppName: String? { get } } extension PendingUpdate { var dueBy: String? { nil } + var installedAppName: String? { nil } } extension PendingMunkiUpdate: PendingUpdate {} extension PendingIntuneUpdate: PendingUpdate {} -extension PendingJamfUpdate: PendingUpdate {} + +extension PendingJamfUpdate: PendingUpdate { + var installedAppName: String? { policyName } +} diff --git a/SupportCompanion/ViewModels/PendingJamfUpdatesManager.swift b/SupportCompanion/ViewModels/PendingJamfUpdatesManager.swift index 4f97ff3..c4500e0 100644 --- a/SupportCompanion/ViewModels/PendingJamfUpdatesManager.swift +++ b/SupportCompanion/ViewModels/PendingJamfUpdatesManager.swift @@ -18,19 +18,24 @@ class PendingJamfUpdatesManager: PendingUpdatesManager { let parser = SSPlusParser() guard await parser.parse() else { return } - let (_, updateCount, upToDateCount) = await computeUpdates( + let (allUpdates, updateCount, upToDateCount) = await computeUpdates( policies: parser.policies, patches: parser.patches, now: Date() ) - let totalApps = updateCount + upToDateCount + // A patch installed from here is done even while the store still offers it, so move it across + // rather than leaving the ring showing work that is finished. + let installedButStillListed = updateCount - withoutRecentlyInstalled(allUpdates).count + let pendingCount = updateCount - installedButStillListed + let doneCount = upToDateCount + installedButStillListed + let totalApps = pendingCount + doneCount let newInstallPercentage = totalApps > 0 - ? (Double(upToDateCount) / Double(totalApps)) * 100 + ? (Double(doneCount) / Double(totalApps)) * 100 : 0.0 if newInstallPercentage != appState.installPercentage { - appState.installedAppsCount = upToDateCount - appState.pendingUpdatesCount = updateCount + appState.installedAppsCount = doneCount + appState.pendingUpdatesCount = pendingCount appState.installPercentage = newInstallPercentage Logger.shared.logDebug("Install percentage updated: \(appState.installPercentage)%") } else { @@ -65,11 +70,12 @@ class PendingJamfUpdatesManager: PendingUpdatesManager { let parser = SSPlusParser() guard await parser.parse() else { return } - let (pendingUpdates, _, _) = await computeUpdates( + let (allUpdates, _, _) = await computeUpdates( policies: parser.policies, patches: parser.patches, now: Date() ) + let pendingUpdates = withoutRecentlyInstalled(allUpdates) appState.pendingJamfUpdates = pendingUpdates if appState.pendingUpdatesCount != pendingUpdates.count { appState.pendingUpdatesCount = pendingUpdates.count @@ -86,20 +92,92 @@ class PendingJamfUpdatesManager: PendingUpdatesManager { // MARK: - Self Service refresh + /// `pgrep`'s pattern is a regular expression, so the `+` has to be escaped for `-x` to match. + private static let selfServiceProcessPattern = "Self Service\\+" + private static let selfServiceProcessName = "Self Service+" + + /// How long to wait for Self Service+ to rewrite its store before giving up on it. + private static let storeRefreshTimeout: TimeInterval = 20 + func refreshSelfService() async { - if appState.preferences.refreshSelfService { - let cmd = ["-gj", Constants.AppPaths.selfService] - let quitCmd = ["-9", "Self Service+"] - let checkProcessCmd = ["-x", "Self Service\\+"] - let checkIfRunning = try? await ExecutionService.executeCommand("/usr/bin/pgrep", with: checkProcessCmd) - if checkIfRunning == nil { - _ = try? await ExecutionService.executeCommand("/usr/bin/open", with: cmd) - try? await Task.sleep(for: .seconds(2)) - _ = try? await ExecutionService.executeCommand("/usr/bin/pkill", with: quitCmd) - } - } else { + guard appState.preferences.refreshSelfService else { Logger.shared.logDebug("Self Service configured to not refresh itself") + return + } + + // A running Self Service+ is left alone: it belongs to whoever opened it, and quitting a window + // somebody is reading to refresh a cache is not a trade worth making. The store then stays as + // Self Service+ last wrote it, which `RecentlyInstalledPatches` is there to cover. + if await isSelfServiceRunning() { + Logger.shared.logDebug("Self Service+ is running; leaving its store as it is for now") + return + } + + let storePath = (Constants.Paths.jamfSelfServiceData as NSString).expandingTildeInPath + let before = storeModifiedDate(at: storePath) + + _ = try? await ExecutionService.executeCommand( + "/usr/bin/open", with: ["-gj", Constants.AppPaths.selfService] + ) + + await waitForStore(at: storePath, toChangeFrom: before) + await quitSelfService() + } + + private func isSelfServiceRunning() async -> Bool { + // `pgrep` exits non-zero when nothing matches, which ProcessRunner turns into a throw, so a + // value back means a match. + let found = try? await ExecutionService.executeCommand( + "/usr/bin/pgrep", with: ["-x", Self.selfServiceProcessPattern] + ) + + return found != nil + } + + /// Ask Self Service+ to quit, and insist only if it does not. + /// + /// It used to be sent `SIGKILL` outright, which can cut off the store write this refresh exists to + /// read. + private func quitSelfService() async { + _ = try? await ExecutionService.executeCommand( + "/usr/bin/pkill", with: ["-TERM", Self.selfServiceProcessName] + ) + + for _ in 0..<12 { + try? await Task.sleep(for: .milliseconds(250)) + if !(await isSelfServiceRunning()) { return } + } + + Logger.shared.logDebug("Self Service+ did not quit when asked; stopping it") + _ = try? await ExecutionService.executeCommand( + "/usr/bin/pkill", with: ["-9", Self.selfServiceProcessName] + ) + } + + private func storeModifiedDate(at path: String) -> Date? { + (try? FileManager.default.attributesOfItem(atPath: path))?[.modificationDate] as? Date + } + + /// Wait until Self Service+ has rewritten its store, rather than guessing at how long that takes. + /// + /// The previous fixed two seconds was both too short to see new data and long enough to be felt on + /// every refresh. + private func waitForStore(at path: String, toChangeFrom before: Date?) async { + let deadline = Date().addingTimeInterval(Self.storeRefreshTimeout) + + while Date() < deadline { + try? await Task.sleep(for: .milliseconds(250)) + + if let current = storeModifiedDate(at: path), current != before { + // The mtime changes when the write starts, so let it finish before anything reads it. + try? await Task.sleep(for: .milliseconds(500)) + return + } } + + Logger.shared.logDebug( + "Self Service+ did not rewrite its store within \(Int(Self.storeRefreshTimeout))s; using what is there" + ) } // MARK: - Running state and execution @@ -112,14 +190,86 @@ class PendingJamfUpdatesManager: PendingUpdatesManager { markRunning(patchId) defer { markFinished(patchId) } + // Taken before the install, because afterwards this patch should be on its way out of the list. + let installingVersion = appState.pendingJamfUpdates + .first { $0.patchId == patchId }?.version + do { // The helper runs the patch as whichever user connected to it, which it reads from the // audit token rather than taking our word for it. _ = try await ExecutionService.jamfSelfServicePatch(id: String(patchId), userId: userId) + + if let installingVersion { + recentlyInstalled.record(patchId: patchId, version: installingVersion) + } } catch { Logger.shared.logError("Failed to run patch \(patchId): \(error)") } await getPendingJamfUpdates() } + + // MARK: - Updates installed but still listed + + /// Patches installed from this app that Self Service+'s store still offers. + private var recentlyInstalled = RecentlyInstalledPatches() + + /// Hide patches this app has just installed, until the store agrees they are done. + func withoutRecentlyInstalled(_ updates: [PendingJamfUpdate], now: Date = Date()) -> [PendingJamfUpdate] { + recentlyInstalled.filtering(updates, now: now) + } +} + +/// Keeps track of patches installed from this app that Self Service+'s store has not caught up with. +/// +/// The store is Self Service+'s own cache and only changes when Self Service+ runs, so a patch installed +/// a moment ago keeps appearing in it. Re-deriving the list straight from the store would put the row +/// back with its Update button, which reads as the install having failed. Modelled on the Fleet side's +/// `installedVersionsAwaitingInventory`, which covers the same lag. +/// +/// Its own type rather than state on the manager, so the rule can be exercised without an app state. +struct RecentlyInstalledPatches { + + /// How long a patch stays hidden when the store never drops it. + /// + /// Long enough to outlast a Self Service+ refresh, short enough that an install which reported + /// success without taking effect comes back rather than staying hidden. + static let timeout: TimeInterval = 30 * 60 + + private var versions: [Int: String] = [:] + private var since: [Int: Date] = [:] + + var isEmpty: Bool { versions.isEmpty } + + /// Note that `version` of `patchId` was installed, so it stops being offered as an update. + mutating func record(patchId: Int, version: String, at date: Date = Date()) { + versions[patchId] = version + since[patchId] = date + } + + /// The updates worth showing, forgetting anything the store has caught up with or waited out. + mutating func filtering(_ updates: [PendingJamfUpdate], now: Date = Date()) -> [PendingJamfUpdate] { + for (patchId, version) in versions { + let stillOffered = updates.contains { $0.patchId == patchId && $0.version == version } + let waitedTooLong = now.timeIntervalSince(since[patchId] ?? now) >= Self.timeout + + // Gone from the store means Self Service+ has caught up. Waited too long means it has not, + // and the update is worth showing again rather than hiding indefinitely. + guard !stillOffered || waitedTooLong else { continue } + + if waitedTooLong && stillOffered { + Logger.shared.logDebug( + "Patch \(patchId) is still offered at \(version) long after installing it; showing it again" + ) + } + + versions[patchId] = nil + since[patchId] = nil + } + + return updates.filter { update in + guard let patchId = update.patchId else { return true } + return versions[patchId] != update.version + } + } } diff --git a/SupportCompanionTests/JamfUpdateMatchingTests.swift b/SupportCompanionTests/JamfUpdateMatchingTests.swift new file mode 100644 index 0000000..df3c34f --- /dev/null +++ b/SupportCompanionTests/JamfUpdateMatchingTests.swift @@ -0,0 +1,303 @@ +// +// JamfUpdateMatchingTests.swift +// SupportCompanionTests +// + +import Foundation +import Testing +@testable import SupportCompanion + +@Suite("Jamf update matching") +struct JamfUpdateMatchingTests { + + // Dates from the Self Service store that produced the regression: the Zoom patch became available + // on 2026-09-24 with a deadline a week later, and the installed policy was last touched in 2025. + private let now = Date(timeIntervalSinceReferenceDate: 811_984_000) // 2026-09-25 + private let available = Date(timeIntervalSinceReferenceDate: 811_937_502) // 2026-09-24 + private let deadline = Date(timeIntervalSinceReferenceDate: 812_542_302) // 2026-10-01 + private let longAgo = Date(timeIntervalSinceReferenceDate: 784_713_567) // 2025-11-13 + + private func policy( + _ name: String, + version: String? = nil, + installStatus: Int? = 4, + installedOrUpdated: Date? = nil + ) -> Policy { + Policy( + id: 1, + name: name, + policyVersion: version, + installedOrUpdated: installedOrUpdated, + installStatus: installStatus, + iconUrl: nil, + postInstallText: nil + ) + } + + private func patch(_ name: String, version: String, deadline: Date? = nil) -> Patch { + Patch( + id: 1, + name: name, + version: version, + availableDate: available, + deadlineDate: deadline, + buttonText: "Update", + installStatus: 0 + ) + } + + private func updates( + _ policies: [Policy], + _ patches: [Patch], + installed: [String] + ) async -> [PendingJamfUpdate] { + await computeUpdates(policies: policies, patches: patches, now: now, installedApps: installed).0 + } + + // MARK: - The regression + + @Test("Reports a patch whose installed app sits under a different policy name") + func prefersTheInstalledPolicy() async { + // Exactly what Jamf served: the patch title exists as its own uninstalled policy, while the + // app that is actually on the Mac is listed under "zoom.us". + let result = await updates( + [ + policy("Zoom Client for Meetings", version: "7.2.2 (88465)", installStatus: 0), + policy("zoom.us", version: "7.0.5 (81138)", installStatus: 4, installedOrUpdated: longAgo), + ], + [patch("Zoom Client for Meetings", version: "7.2.2 (88465)", deadline: deadline)], + installed: ["zoom.us"] + ) + + #expect(result.count == 1) + #expect(result.first?.name == "Zoom Client for Meetings") + #expect(result.first?.policyName == "zoom.us") + } + + @Test("An exact name match still wins when that policy's app is installed") + func exactMatchPreferred() async { + let result = await updates( + [ + policy("Figma", version: "126.8.16", installedOrUpdated: longAgo), + policy("Figma Agent", version: "1.0", installedOrUpdated: longAgo), + ], + [patch("Figma", version: "127.0", deadline: deadline)], + installed: ["Figma", "Figma Agent"] + ) + + #expect(result.first?.policyName == "Figma") + } + + // MARK: - Installed, according to the Mac rather than Jamf + + @Test("Reports a patch for an app Jamf does not think it installed") + func installStatusZeroButOnDisk() async { + // Apparency: present in /Applications, but its policy reports installstatus 0. + let result = await updates( + [policy("Apparency", version: "3.1", installStatus: 0)], + [patch("Mothers Ruin Apparency", version: "3.2", deadline: deadline)], + installed: ["Apparency"] + ) + + #expect(result.count == 1) + #expect(result.first?.policyName == "Apparency") + } + + @Test("Says nothing about an app Jamf believes is installed but the Mac does not have") + func installStatusFourButAbsent() async { + // Brave: the policy reports installstatus 4 and the bundle is gone. An "update" here would + // really be a fresh install of something nobody asked for. + let result = await updates( + [policy("Brave Browser", version: "154.1.96.59", installedOrUpdated: longAgo)], + [patch("Brave Browser", version: "155.0", deadline: deadline)], + installed: ["Figma", "zoom.us"] + ) + + #expect(result.isEmpty) + } + + @Test("Says nothing about an app that is neither installed nor claimed to be") + func neitherSourceSaysInstalled() async { + let result = await updates( + [policy("GIMP", version: "3.0", installStatus: 0)], + [patch("GIMP", version: "3.1", deadline: deadline)], + installed: ["Figma"] + ) + + #expect(result.isEmpty) + } + + @Test("Falls back to Jamf's record when the Mac cannot be read") + func emptyIndexFallsBackToInstallStatus() async { + // An empty index means the directory scan failed, not that the Mac has no applications, so + // dropping every patch would be the wrong reading of it. + let policies = [policy("Brave Browser", version: "154.1.96.59", installedOrUpdated: longAgo)] + let patches = [patch("Brave Browser", version: "155.0", deadline: deadline)] + + #expect(await updates(policies, patches, installed: []).count == 1) + #expect(await updates(policies.map { + policy($0.name, version: $0.policyVersion, installStatus: 0) + }, patches, installed: []).isEmpty) + } + + // MARK: - Counting + + @Test("An installed app with no patch counts as up to date") + func upToDateCount() async { + let (results, updateCount, upToDateCount) = await computeUpdates( + policies: [ + policy("zoom.us", version: "7.0.5 (81138)", installedOrUpdated: longAgo), + policy("Figma", version: "126.8.16", installedOrUpdated: longAgo), + policy("Brave Browser", version: "154.1", installedOrUpdated: longAgo), + ], + patches: [patch("Zoom Client for Meetings", version: "7.2.2 (88465)", deadline: deadline)], + now: now, + installedApps: ["zoom.us", "Figma"] + ) + + #expect(results.count == 1) + #expect(updateCount == 1) + // Figma is installed and unpatched; Brave is not on the Mac and is counted neither way. + #expect(upToDateCount == 1) + } + + // MARK: - Name matching + + @Test("Matches names across Jamf's spellings, and keeps distinct apps apart") + func nameSimilarityScores() { + // "client", "for" and "meetings" are stop words, so both sides reduce to "zoom". + #expect(nameSimilarity("Zoom Client for Meetings", "zoom.us") >= nameMatchThreshold) + #expect(nameSimilarity("Mothers Ruin Apparency", "Apparency") >= nameMatchThreshold) + #expect(nameSimilarity("Google Chrome", "Google Chrome") == 1.0) + + #expect(nameSimilarity("Slack", "Zoom") == 0) + // Nothing usable on one side is not a match, rather than a vacuous one. + #expect(nameSimilarity("app", "Slack") == 0) + + // Normalizing by the shorter name is what lets "zoom.us" answer for "Zoom Client for + // Meetings", and the price is that a one-token name matches any superset of it perfectly. + // Install evidence, not the score, is what keeps these apart. + #expect(nameSimilarity("Firefox", "Firefox Developer Edition") == 1.0) + } + + @Test("A one-token name does not steal a patch from the app Jamf installed") + func supersetNamesSeparatedByEvidence() async { + let result = await updates( + [ + policy("Firefox", installStatus: 0), + policy("Firefox Developer Edition", installedOrUpdated: longAgo), + ], + [patch("Firefox Developer Edition", version: "146.0", deadline: deadline)], + installed: ["Firefox Developer Edition"] + ) + + #expect(result.first?.policyName == "Firefox Developer Edition") + } + + // MARK: - Pairing an update with the installed app + + @Test("An update names the installed app, so the Apps page can pair them") + func updateNamesTheInstalledApp() async { + // The Apps page groups by `installedAppName`, and Self Service lists the installed app as + // "zoom.us" while the patch is called "Zoom Client for Meetings". Resolving to the listing + // instead would leave the update unpairable and its card without an Update button. + let result = await updates( + [ + policy("Zoom Client for Meetings", version: "7.2.2 (88465)", installStatus: 0), + policy("zoom.us", version: "7.0.5 (81138)", installedOrUpdated: longAgo), + ], + [patch("Zoom Client for Meetings", version: "7.2.2 (88465)", deadline: deadline)], + installed: ["zoom.us"] + ) + + let update = try? #require(result.first) + #expect(update?.installedAppName == "zoom.us") + } + + @Test("A patch with no usable tokens does not match an arbitrary policy") + func unmatchablePatchName() async { + let result = await updates( + [policy("Figma", version: "126.8.16", installedOrUpdated: longAgo)], + [patch("Update", version: "1.0", deadline: deadline)], + installed: ["Figma"] + ) + + #expect(result.isEmpty) + } +} + +@Suite("Recently installed Jamf patches") +struct RecentlyInstalledPatchesTests { + + private let now = Date(timeIntervalSinceReferenceDate: 811_984_000) + + private func update(_ patchId: Int, _ name: String, _ version: String) -> PendingJamfUpdate { + PendingJamfUpdate( + id: UUID(), + name: name, + version: version, + needsUpdate: true, + label: .dueNoDeadline, + details: "", + showInfoIcon: false, + dueBy: nil, + patchId: patchId, + policyName: name + ) + } + + @Test("An installed patch stops being offered while the store still lists it") + func hidesWhatWasJustInstalled() { + var tracked = RecentlyInstalledPatches() + let zoom = update(274, "Zoom Client for Meetings", "7.2.2 (88465)") + + #expect(tracked.filtering([zoom], now: now).count == 1) // before installing + + tracked.record(patchId: 274, version: "7.2.2 (88465)", at: now) + #expect(tracked.filtering([zoom], now: now).isEmpty) // the store has not caught up yet + } + + @Test("Forgets the patch once the store drops it") + func forgetsWhenStoreCatchesUp() { + var tracked = RecentlyInstalledPatches() + tracked.record(patchId: 274, version: "7.2.2 (88465)", at: now) + + // Self Service+ rewrote its store and the patch is gone. + #expect(tracked.filtering([], now: now).isEmpty) + #expect(tracked.isEmpty) + } + + @Test("A newer patch for the same app is still offered") + func newerVersionIsNotHidden() { + var tracked = RecentlyInstalledPatches() + tracked.record(patchId: 274, version: "7.2.2 (88465)", at: now) + + let newer = update(274, "Zoom Client for Meetings", "7.3.0 (90001)") + #expect(tracked.filtering([newer], now: now).count == 1) + } + + @Test("Comes back when the store never drops it, rather than hiding for good") + func reappearsAfterTheTimeout() { + var tracked = RecentlyInstalledPatches() + let zoom = update(274, "Zoom Client for Meetings", "7.2.2 (88465)") + tracked.record(patchId: 274, version: "7.2.2 (88465)", at: now) + + let justBefore = now.addingTimeInterval(RecentlyInstalledPatches.timeout - 1) + #expect(tracked.filtering([zoom], now: justBefore).isEmpty) + + let after = now.addingTimeInterval(RecentlyInstalledPatches.timeout) + #expect(tracked.filtering([zoom], now: after).count == 1) + #expect(tracked.isEmpty) // and it is not hidden again next time either + } + + @Test("Other apps' updates are untouched") + func leavesOtherUpdatesAlone() { + var tracked = RecentlyInstalledPatches() + tracked.record(patchId: 274, version: "7.2.2 (88465)", at: now) + + let zoom = update(274, "Zoom Client for Meetings", "7.2.2 (88465)") + let figma = update(99, "Figma", "127.0") + + #expect(tracked.filtering([zoom, figma], now: now).map(\.patchId) == [99]) + } +} From 597ad5199b87c0f3dfa758711e9ab4483915a350 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Alm=C3=A9n?= <78877636+almenscorner@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:37:21 +0200 Subject: [PATCH 100/104] Group the Apps page by updates, and make it searchable The Apps page listed every installed application in one flat grid. The only thing on it anybody has to act on is an available update, and that was findable only by scrolling until a card turned up with an Update button on it. Updates Available now comes first, then Installed, each with a count, and a search field narrows both. Which apps have an update comes from the active updates manager rather than from a check on the configured mode, through a new installedAppName on PendingUpdate. Jamf needs it because the two names routinely differ, and any other mode that starts reporting it gets the section without this view changing. Constants.Apps aliases the Fleet catalog's wording rather than adding keys of its own. The text is word for word the same, and a second set would mean translating "Installed" into five languages twice and keeping both in step. --- SupportCompanion/Constants.swift | 12 ++ SupportCompanion/Views/Applications.swift | 197 ++++++++++++++++------ 2 files changed, 160 insertions(+), 49 deletions(-) diff --git a/SupportCompanion/Constants.swift b/SupportCompanion/Constants.swift index 9aaac12..d5f9539 100644 --- a/SupportCompanion/Constants.swift +++ b/SupportCompanion/Constants.swift @@ -34,6 +34,18 @@ enum Constants { static let fleet = "Fleet" } + /// Wording the Apps page shares with the Fleet catalog. + /// + /// Aliases rather than keys of their own: the text is word for word the same, and a second set of + /// keys would mean translating "Installed" into five languages twice and keeping both in step. + enum Apps { + static var searchPlaceholder: String { Fleet.searchPlaceholder } + static var clearSearch: String { Fleet.clearSearch } + static var updatesAvailable: String { Fleet.updatesAvailable } + static var installed: String { Fleet.installed } + static var noMatches: String { Fleet.noMatches } + } + enum Fleet { static let searchPlaceholder = String( localized: "Fleet.SearchPlaceholder", defaultValue: "Search apps", diff --git a/SupportCompanion/Views/Applications.swift b/SupportCompanion/Views/Applications.swift index 023218b..5d6397a 100644 --- a/SupportCompanion/Views/Applications.swift +++ b/SupportCompanion/Views/Applications.swift @@ -11,70 +11,31 @@ import SwiftUI struct Applications: View { @Environment(AppStateManager.self) var appState @State private var isLoading = false + @State private var searchText = "" @State private var task: Task? - + + private let columns = [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())] + var body: some View { - let columns = [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())] ZStack { if isLoading { - // Centered ProgressView - VStack { - ProgressView("Loading applications...") - .progressViewStyle(CircularProgressViewStyle()) - .padding() - } - .frame(maxWidth: .infinity, maxHeight: .infinity) // Full screen - .background(Color.clear) - .ignoresSafeArea() // Ensure it covers the entire screen + loading + } else if appState.installedApplications.isEmpty { + empty } else { - if appState.installedApplications.isEmpty { - VStack { - Image(systemName: "exclamationmark.triangle") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 50, height: 50) - .foregroundColor(.primary) - Text("No installed applications found") - .font(.title) - .foregroundColor(.primary) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) // Full screen - .background(Color.clear) - .ignoresSafeArea() // Ensure it covers the entire screen - } else { - ScrollView { - let mode = appState.preferences.mode - if mode != "SystemProfiler" { - Text("This list shows applications installed by \(mode).") - .font(.caption) - .foregroundColor(.secondary) - .padding(.bottom, 10) - } - LazyVGrid( - columns: columns, - alignment: .leading - ) { - ForEach(appState.installedApplications) { card in - AppCard(card: card) - .fixedSize(horizontal: false, vertical: false) // Allow vertical expansion - } - } - .padding(.bottom, 5) - } - } + catalog } } .padding(.horizontal, 20) - //.padding(.bottom, 20) .onAppear { isLoading = true let appInfoManager = ApplicationsInfoManager(appState: appState) // Local instance - + task = Task { defer { isLoading = false } // Ensure `isLoading` is reset await appInfoManager.fetchAppsBasedOnMode() } - + appState.applicationsInfoManager.startMonitoring() } .onDisappear { @@ -82,4 +43,142 @@ struct Applications: View { appState.applicationsInfoManager.stopMonitoring() } } + + // MARK: - States + + private var loading: some View { + VStack { + ProgressView("Loading applications...") + .progressViewStyle(CircularProgressViewStyle()) + .padding() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.clear) + .ignoresSafeArea() + } + + private var empty: some View { + VStack { + Image(systemName: "exclamationmark.triangle") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 50, height: 50) + .foregroundColor(.primary) + Text("No installed applications found") + .font(.title) + .foregroundColor(.primary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.clear) + .ignoresSafeArea() + } + + // MARK: - Catalog + + private var catalog: some View { + VStack(alignment: .leading, spacing: 12) { + searchRow + + let mode = appState.preferences.mode + if mode != Constants.Modes.systemProfiler { + Text("This list shows applications installed by \(mode).") + .font(.caption) + .foregroundColor(.secondary) + } + + if matching.isEmpty { + Text(Constants.Apps.noMatches) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } else { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + // Apps with an update come first: it is the only part of this page somebody has + // to act on, and it used to be findable only by scrolling for the one card with + // an Update button on it. + if !updatable.isEmpty { + section(Constants.Apps.updatesAvailable, updatable) + } + + if !upToDate.isEmpty { + section(Constants.Apps.installed, upToDate) + } + } + .padding(.bottom, 5) + } + } + } + } + + private var searchRow: some View { + HStack(spacing: 6) { + Image(systemName: "magnifyingglass").foregroundStyle(.secondary) + TextField(Constants.Apps.searchPlaceholder, text: $searchText) + .textFieldStyle(.plain) + if !searchText.isEmpty { + Button { + searchText = "" + } label: { + Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help(Constants.Apps.clearSearch) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .frame(width: 320) + .isGlass() + .cornerRadius(8) + } + + private func section(_ name: String, _ apps: [InstalledApp]) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + Text(name).font(.headline) + Text("\(apps.count)") + .font(.caption) + .foregroundStyle(.secondary) + } + + LazyVGrid(columns: columns, alignment: .leading) { + ForEach(apps) { card in + AppCard(card: card) + .fixedSize(horizontal: false, vertical: false) // Allow vertical expansion + } + } + } + } + + // MARK: - Grouping + + private var matching: [InstalledApp] { + let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { return appState.installedApplications } + + return appState.installedApplications.filter { + $0.name.localizedCaseInsensitiveContains(query) + } + } + + /// Names of installed apps an update is waiting for. + /// + /// Taken from the active manager rather than by asking which mode is configured, so a mode that + /// starts reporting `installedAppName` gets the section without this view changing. + private var appNamesWithUpdates: Set { + Set( + (appState.activeUpdatesManager?.pendingUpdates ?? []) + .compactMap(\.installedAppName) + ) + } + + private var updatable: [InstalledApp] { + let waiting = appNamesWithUpdates + return matching.filter { waiting.contains($0.name) } + } + + private var upToDate: [InstalledApp] { + let waiting = appNamesWithUpdates + return matching.filter { !waiting.contains($0.name) } + } } From 84c51daa46e316cab79c12000e81c36c3c2f66d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Alm=C3=A9n?= <78877636+almenscorner@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:41:56 +0200 Subject: [PATCH 101/104] Sign the app with the hardened runtime, and keep the tests loadable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The helper refuses any client not signed with the hardened runtime, because a client without it can be injected into and used as a proxy. Nothing in the project asked for it, though: hardening was applied only by build.zsh when packaging a release, with --options runtime. An app built and run from Xcode therefore had no runtime flag at all and every privileged operation failed with "Client is not signed with the hardened runtime". verifyClientPolicy already skips its install-path check under #if !DEBUG, for the same reason that a debug build runs from DerivedData, but the runtime flag had no such allowance. ENABLE_HARDENED_RUNTIME was set on the helper target only. Setting it on the app as well means a debug build satisfies the helper the same way the shipped app does, rather than by relaxing what the helper accepts. That alone breaks xcodebuild test, and the error does not say so plainly: the hardened runtime brings library validation with it, which refuses to load code signed by a different team, and the test bundle signs ad hoc so it has no team to compare. Tests fail to load before any of them runs. SupportCompanion-Debug.entitlements carries com.apple.security.cs.disable-library-validation and is used by the Debug configuration only; Release keeps SupportCompanion.entitlements unchanged. The cost is real and worth stating: it makes the debug build's hardened flag hollow, which is the very property the helper is checking for. That is acceptable for a build that only ever runs on a developer's own Mac, and the release build keeps the full guarantee. Signing the test bundle with the app's identity instead would preserve the property, but Xcode refuses non-ad-hoc signing without DEVELOPMENT_TEAM, and the only way to supply it is to commit a particular team into this file — which would break every other contributor. --- SupportCompanion.xcodeproj/project.pbxproj | 4 ++- .../SupportCompanion-Debug.entitlements | 30 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 SupportCompanion/SupportCompanion-Debug.entitlements diff --git a/SupportCompanion.xcodeproj/project.pbxproj b/SupportCompanion.xcodeproj/project.pbxproj index e4228f1..6341cdc 100644 --- a/SupportCompanion.xcodeproj/project.pbxproj +++ b/SupportCompanion.xcodeproj/project.pbxproj @@ -824,7 +824,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; - CODE_SIGN_ENTITLEMENTS = SupportCompanion/SupportCompanion.entitlements; + CODE_SIGN_ENTITLEMENTS = SupportCompanion/SupportCompanion-Debug.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application"; CODE_SIGN_STYLE = Manual; @@ -833,6 +833,7 @@ DEVELOPMENT_ASSET_PATHS = "\"SupportCompanion/Preview Content\""; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=macosx*]" = 42EJ7ZYMPQ; + ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "$(SRCROOT)/SupportCompanion/Info.plist"; @@ -871,6 +872,7 @@ DEVELOPMENT_ASSET_PATHS = "\"SupportCompanion/Preview Content\""; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=macosx*]" = 42EJ7ZYMPQ; + ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "$(SRCROOT)/SupportCompanion/Info.plist"; diff --git a/SupportCompanion/SupportCompanion-Debug.entitlements b/SupportCompanion/SupportCompanion-Debug.entitlements new file mode 100644 index 0000000..fbadecf --- /dev/null +++ b/SupportCompanion/SupportCompanion-Debug.entitlements @@ -0,0 +1,30 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-only + + com.apple.security.application-groups + + com.apple.security.inherit + + + com.apple.security.cs.disable-library-validation + + + From 09482e79448039c1765ba25cc4eb478c8025c0a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Alm=C3=A9n?= <78877636+almenscorner@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:47:38 +0200 Subject: [PATCH 102/104] Replace app icon in README Updated app icon in README and added a new image. --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b31e674..10ac9fb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # [#SupportCompanion](https://macadmins.slack.com/archives/C075C6ZFAJH) -AppIcon +AppIcon + ## Description From 903c56f9e4151bfe51e51fae214507bcdb45a3e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Alm=C3=A9n?= <78877636+almenscorner@users.noreply.github.com> Date: Mon, 28 Sep 2026 08:59:10 +0200 Subject: [PATCH 103/104] Keep the reason window's close delegate alive NSWindow holds its delegate weakly, so the WindowDelegate constructed inline in presentAsWindow was released the moment it was assigned, and windowWillClose never fired. Nothing reaches it today: the window is borderless, so it has no close button and performClose: will not act on it, and both buttons in ReasonInputView call closeWindow() themselves. But it was the safety net for any other way the window might go away, and presentAsWindow guards on window == nil, so a close that skipped closeWindow() would leave that property set and the elevation reason prompt unable to open again until the app restarted. The manager now holds the delegate, and closeWindow clears its state before closing rather than after. Once the delegate is actually retained, close() sends windowWillClose synchronously, which routes straight back into closeWindow(); with window still set, that second pass would fall through the guard and close the window twice. --- .../ViewModels/ReasonInputManager.swift | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/SupportCompanion/ViewModels/ReasonInputManager.swift b/SupportCompanion/ViewModels/ReasonInputManager.swift index 4a27b9f..4184d27 100644 --- a/SupportCompanion/ViewModels/ReasonInputManager.swift +++ b/SupportCompanion/ViewModels/ReasonInputManager.swift @@ -1,8 +1,9 @@ -import SwiftUI import AppKit +import SwiftUI class ReasonInputManager { private var window: NSWindow? + private var windowDelegate: WindowDelegate? static let shared = ReasonInputManager() @@ -40,10 +41,13 @@ class ReasonInputManager { self.window = newWindow - // Handle manual closure via delegate - newWindow.delegate = WindowDelegate { [weak self] in + let delegate = WindowDelegate { [weak self] in self?.closeWindow() } + + // Handle manual closure via delegate + newWindow.delegate = delegate + self.windowDelegate = delegate } func closeWindow() { @@ -51,9 +55,13 @@ class ReasonInputManager { return } - window.orderOut(nil) // Explicitly remove from the screen - window.close() // Close the window + // Clear state before closing: `close()` sends `windowWillClose`, which routes straight + // back here, and re-entering with `window` still set would close the window twice. self.window = nil + self.windowDelegate = nil + window.delegate = nil + window.orderOut(nil) + window.close() } } @@ -72,4 +80,4 @@ private class WindowDelegate: NSObject, NSWindowDelegate { private class CustomWindow: NSWindow { override var canBecomeKey: Bool { true } override var canBecomeMain: Bool { true } -} \ No newline at end of file +} From b5c64eb10df21e35c009a88222ebe15459bbcd38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Alm=C3=A9n?= <78877636+almenscorner@users.noreply.github.com> Date: Mon, 28 Sep 2026 08:59:26 +0200 Subject: [PATCH 104/104] Run the tests in CI, and tag releases at the commit they were built from No workflow ran the test suite. build.zsh only archives, so all three went straight from importing certificates to building, signing and notarizing a package nothing had tested. The test step runs before the certificates are imported, so a failure stops the run before it spends time on notarization. It overrides the signing settings because the Debug configuration expects a Developer ID certificate the runner does not have, while the tests need none. Debug is also the only configuration that relaxes library validation, without which the hardened runtime refuses to load the ad-hoc signed test bundle into the app. action-gh-release creates the tag at the repository's default branch when target_commitish is unset. Prereleases are dispatched from dev, so every tag it has written points at main's head rather than the commit that was built: all five v3.0.0.* tags and four of the v2.4.0.* ones resolve to 8586e93, from last November. No shipped package was affected, but checking out a release tag does not give you the source it was built from. --- .github/workflows/build_supportcompanion.yml | 18 ++++++++++++++++++ .../build_supportcompanion_manual.yml | 18 ++++++++++++++++++ .../build_supportcompanion_prerelease.yml | 18 ++++++++++++++++++ 3 files changed, 54 insertions(+) diff --git a/.github/workflows/build_supportcompanion.yml b/.github/workflows/build_supportcompanion.yml index 68c2199..6b17c1e 100644 --- a/.github/workflows/build_supportcompanion.yml +++ b/.github/workflows/build_supportcompanion.yml @@ -27,6 +27,23 @@ jobs: with: fetch-depth: 0 + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_26.6.app + + - name: Run tests + # Signed ad hoc on purpose. The Debug configuration expects a Developer ID certificate + # the runner does not have, and the tests need none. Debug is also the only configuration + # that relaxes library validation, without which the hardened runtime refuses to load an + # ad-hoc signed test bundle into the app. + run: | + xcodebuild test \ + -project SupportCompanion.xcodeproj \ + -scheme SupportCompanion \ + -configuration Debug \ + -destination 'platform=macOS' \ + CODE_SIGN_IDENTITY=- \ + DEVELOPMENT_TEAM= + - name: Install Apple Xcode certificates uses: apple-actions/import-codesign-certs@8f3fb608891dd2244cdab3d69cd68c0d37a7fe93 # v2.0.0 with: @@ -72,6 +89,7 @@ jobs: with: name: SupportCompanion ${{env.SC_VERSION}} tag_name: v${{env.SC_VERSION}} + target_commitish: ${{ github.sha }} draft: false prerelease: false token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/build_supportcompanion_manual.yml b/.github/workflows/build_supportcompanion_manual.yml index 0a3fa35..e0ecad2 100644 --- a/.github/workflows/build_supportcompanion_manual.yml +++ b/.github/workflows/build_supportcompanion_manual.yml @@ -18,6 +18,23 @@ jobs: with: fetch-depth: 0 + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_26.6.app + + - name: Run tests + # Signed ad hoc on purpose. The Debug configuration expects a Developer ID certificate + # the runner does not have, and the tests need none. Debug is also the only configuration + # that relaxes library validation, without which the hardened runtime refuses to load an + # ad-hoc signed test bundle into the app. + run: | + xcodebuild test \ + -project SupportCompanion.xcodeproj \ + -scheme SupportCompanion \ + -configuration Debug \ + -destination 'platform=macOS' \ + CODE_SIGN_IDENTITY=- \ + DEVELOPMENT_TEAM= + - name: Install Apple Xcode certificates uses: apple-actions/import-codesign-certs@8f3fb608891dd2244cdab3d69cd68c0d37a7fe93 # v2.0.0 with: @@ -63,6 +80,7 @@ jobs: with: name: SupportCompanion ${{env.SC_VERSION}} tag_name: v${{env.SC_VERSION}} + target_commitish: ${{ github.sha }} draft: false prerelease: false token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/build_supportcompanion_prerelease.yml b/.github/workflows/build_supportcompanion_prerelease.yml index 259a8da..a71f4b9 100644 --- a/.github/workflows/build_supportcompanion_prerelease.yml +++ b/.github/workflows/build_supportcompanion_prerelease.yml @@ -18,6 +18,23 @@ jobs: with: fetch-depth: 0 + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_26.6.app + + - name: Run tests + # Signed ad hoc on purpose. The Debug configuration expects a Developer ID certificate + # the runner does not have, and the tests need none. Debug is also the only configuration + # that relaxes library validation, without which the hardened runtime refuses to load an + # ad-hoc signed test bundle into the app. + run: | + xcodebuild test \ + -project SupportCompanion.xcodeproj \ + -scheme SupportCompanion \ + -configuration Debug \ + -destination 'platform=macOS' \ + CODE_SIGN_IDENTITY=- \ + DEVELOPMENT_TEAM= + - name: Install Apple Xcode certificates uses: apple-actions/import-codesign-certs@8f3fb608891dd2244cdab3d69cd68c0d37a7fe93 # v2.0.0 with: @@ -63,6 +80,7 @@ jobs: with: name: SupportCompanion ${{env.SC_VERSION}} tag_name: v${{env.SC_VERSION}} + target_commitish: ${{ github.sha }} draft: false prerelease: true token: ${{ secrets.GITHUB_TOKEN }}