diff --git a/Latest/Interface/Main Window/Update Table View/Controller/UpdateTableView+Search.swift b/Latest/Interface/Main Window/Update Table View/Controller/UpdateTableView+Search.swift index 91fd9a92..8fb9c97f 100644 --- a/Latest/Interface/Main Window/Update Table View/Controller/UpdateTableView+Search.swift +++ b/Latest/Interface/Main Window/Update Table View/Controller/UpdateTableView+Search.swift @@ -24,7 +24,7 @@ extension UpdateTableViewController { if sender.stringValue.isEmpty { searchQuery = nil } - self.scheduleTableViewUpdate(with: self.snapshot.updated(with: searchQuery), animated: false) + self.scheduleSnapshotUpdate(withApps: self.snapshot.apps, filterQuery: searchQuery, animated: false) // Reload all visible lists self.scrubber?.reloadData() diff --git a/Latest/Interface/Main Window/Update Table View/Controller/UpdateTableViewController.swift b/Latest/Interface/Main Window/Update Table View/Controller/UpdateTableViewController.swift index a8e0df0d..650e3274 100644 --- a/Latest/Interface/Main Window/Update Table View/Controller/UpdateTableViewController.swift +++ b/Latest/Interface/Main Window/Update Table View/Controller/UpdateTableViewController.swift @@ -13,8 +13,19 @@ import Cocoa */ class UpdateTableViewController: NSViewController, NSMenuItemValidation, NSTableViewDataSource, NSTableViewDelegate, NSMenuDelegate, Observer { + private struct SnapshotUpdateRequest { + let apps: [App] + let configuration: AppListSnapshot.Configuration + let animated: Bool + } + var id = UUID() + /// Background queue used to prepare expensive snapshots without blocking the main thread. + private let snapshotQueue = DispatchQueue(label: "UpdateTableViewController.snapshot", qos: .userInitiated) + private var pendingSnapshotUpdate: SnapshotUpdateRequest? + private var snapshotUpdateInProgress = false + /// The array holding the apps that have an update available. var snapshot: AppListSnapshot = AppListSnapshot(withApps: [], filterQuery: nil) { didSet { @@ -73,8 +84,7 @@ class UpdateTableViewController: NSViewController, NSMenuItemValidation, NSTable AppListSettings.shared.add(self, handler: self.updateSnapshot) UpdateCheckCoordinator.shared.appProvider.addObserver(self) { newValue in - self.scheduleTableViewUpdate(with: AppListSnapshot(withApps: newValue, filterQuery: self.snapshot.filterQuery), animated: true) - self.updateTitleAndBatch() + self.scheduleSnapshotUpdate(withApps: newValue, filterQuery: self.snapshot.filterQuery, animated: true) } if #available(macOS 11, *) { @@ -104,8 +114,7 @@ class UpdateTableViewController: NSViewController, NSMenuItemValidation, NSTable @IBOutlet weak var tableView: NSTableView! func updateSnapshot() { - self.scheduleTableViewUpdate(with: self.snapshot.updated(), animated: true) - self.updateTitleAndBatch() + self.scheduleSnapshotUpdate(withApps: self.snapshot.apps, filterQuery: self.snapshot.filterQuery, animated: true) } @@ -251,6 +260,40 @@ class UpdateTableViewController: NSViewController, NSMenuItemValidation, NSTable /// Whether a table view update is currently ongoing. private var tableViewUpdateInProgress = false + func scheduleSnapshotUpdate(withApps apps: [App], filterQuery: String?, animated: Bool) { + let request = SnapshotUpdateRequest( + apps: apps, + configuration: AppListSnapshot.Configuration(filterQuery: filterQuery), + animated: animated + ) + pendingSnapshotUpdate = request + startNextSnapshotUpdateIfNeeded() + } + + private func startNextSnapshotUpdateIfNeeded() { + guard !snapshotUpdateInProgress, let request = pendingSnapshotUpdate else { return } + pendingSnapshotUpdate = nil + snapshotUpdateInProgress = true + + snapshotQueue.async { + let snapshot = AppListSnapshot(withApps: request.apps, configuration: request.configuration) + + DispatchQueue.main.async { + let shouldAnimate = request.animated && self.shouldAnimateTransition(from: self.snapshot, to: snapshot) + self.scheduleTableViewUpdate(with: snapshot, animated: shouldAnimate) + self.updateTitleAndBatch() + self.snapshotUpdateInProgress = false + self.startNextSnapshotUpdateIfNeeded() + } + } + } + + private func shouldAnimateTransition(from oldSnapshot: AppListSnapshot, to newSnapshot: AppListSnapshot) -> Bool { + let maxEntryCount = max(oldSnapshot.entries.count, newSnapshot.entries.count) + let delta = abs(oldSnapshot.entries.count - newSnapshot.entries.count) + return maxEntryCount <= 150 && delta <= 30 + } + /// Schedules a table view update with the given snapshot. func scheduleTableViewUpdate(with snapshot: AppListSnapshot, animated: Bool) { self.newSnapshot = snapshot diff --git a/Latest/Interface/Main Window/Views/UpdateButton.swift b/Latest/Interface/Main Window/Views/UpdateButton.swift index 4b383045..683297aa 100644 --- a/Latest/Interface/Main Window/Views/UpdateButton.swift +++ b/Latest/Interface/Main Window/Views/UpdateButton.swift @@ -126,7 +126,7 @@ class UpdateButton: NSButton { switch state { case .none: if let app = self.app, self.showActionButton { - self.updateInterfaceVisibility(with: app.updateAvailable ? .update : .open) + self.updateInterfaceVisibility(with: app.updateAvailable ? (app.canPerformUpdate ? .update : .open) : .open) } else { self.updateInterfaceVisibility(with: .none) } diff --git a/Latest/Interface/Main Window/Window Controllers/MainWindowController.swift b/Latest/Interface/Main Window/Window Controllers/MainWindowController.swift index 9e6dfa8e..b7abaf96 100644 --- a/Latest/Interface/Main Window/Window Controllers/MainWindowController.swift +++ b/Latest/Interface/Main Window/Window Controllers/MainWindowController.swift @@ -48,13 +48,22 @@ class MainWindowController: NSWindowController, NSMenuItemValidation, NSMenuDele @IBOutlet weak var reloadButton: NSButton! @IBOutlet weak var reloadTouchBarButton: NSButton! - /// The button that triggers all available updates to be done - @IBOutlet weak var updateAllButton: NSButton! + /// The button that triggers all available updates to be done + @IBOutlet weak var updateAllButton: NSButton! + + private struct ObservationFailure { + let url: URL + let error: Error + } private var presentedObservationFailures = Set() + private var pendingObservationFailures = [ObservationFailure]() + private var currentObservationFailure: ObservationFailure? + private var observationFailureSheetController: DirectoryObservationSheetController? override func windowDidLoad() { super.windowDidLoad() + DiagnosticsLog.trace(.mainWindow, "windowDidLoad") self.window?.titlebarAppearsTransparent = true self.window?.title = Bundle.main.localizedInfoDictionary?[kCFBundleNameKey as String] as! String @@ -73,15 +82,17 @@ class MainWindowController: NSWindowController, NSMenuItemValidation, NSMenuDele self.window?.makeFirstResponder(self.listViewController) self.window?.delegate = self - self.listViewController.checkForUpdates() - self.listViewController.releaseNotesViewController = self.releaseNotesViewController + self.listViewController.checkForUpdates() + self.listViewController.releaseNotesViewController = self.releaseNotesViewController - if let splitViewController = self.contentViewController as? NSSplitViewController { + if let splitViewController = self.contentViewController as? NSSplitViewController { splitViewController.splitView.autosaveName = "MainSplitView" let detailItem = splitViewController.splitViewItems[1] detailItem.collapseBehavior = .preferResizingSplitViewWithFixedSiblings - } + } + + self.presentNextObservationFailureIfPossible() } @@ -200,18 +211,14 @@ class MainWindowController: NSWindowController, NSMenuItemValidation, NSMenuDele } func updateChecker(_ updateChecker: UpdateCheckCoordinator, didFailToObserveDirectoryAt url: URL, error: Error) { - let failureKey = "\(url.path)|\(error.localizedDescription)" - guard presentedObservationFailures.insert(failureKey).inserted else { return } - NSApplication.shared.requestUserAttention(.informationalRequest) - - guard let window = self.window else { return } - - let alert = NSAlert() - alert.alertStyle = .warning - alert.messageText = NSLocalizedString("DirectoryObservationFailedAlertTitle", comment: "Title of alert shown when Latest cannot monitor a configured app scan directory.") - alert.informativeText = self.directoryObservationFailureMessage(for: url, error: error) - alert.addButton(withTitle: NSLocalizedString("OKAction", comment: "Default button for dismissing an informational alert.")) - alert.beginSheetModal(for: window) + DispatchQueue.main.async { + DiagnosticsLog.trace(.mainWindow, "didFailToObserveDirectoryAt path=\(url.path) error=\(error.localizedDescription)") + let failureKey = "\(url.path)|\(error.localizedDescription)" + guard self.presentedObservationFailures.insert(failureKey).inserted else { return } + NSApplication.shared.requestUserAttention(.informationalRequest) + self.pendingObservationFailures.append(ObservationFailure(url: url, error: error)) + self.presentNextObservationFailureIfPossible() + } } @@ -259,29 +266,411 @@ class MainWindowController: NSWindowController, NSMenuItemValidation, NSMenuDele } } - private func directoryObservationFailureMessage(for url: URL, error: Error) -> String { - let format = NSLocalizedString("DirectoryObservationFailedAlertMessage", comment: "Alert text shown when Latest cannot monitor a configured app scan directory. The first placeholder is the directory path, the second is the localized system error.") - var message = String.localizedStringWithFormat(format, url.path, error.localizedDescription) - - let nsError = error as NSError - if nsError.domain == NSPOSIXErrorDomain, - let code = POSIXErrorCode(rawValue: Int32(nsError.code)), - code == .EACCES || code == .EPERM { - let recovery = NSLocalizedString("DirectoryObservationFailedPermissionSuggestion", comment: "Additional suggestion shown when the app likely lacks permission to observe a scan directory.") - message += "\n\n\(recovery)" + private func presentNextObservationFailureIfPossible() { + DiagnosticsLog.trace(.mainWindow, "presentNextObservationFailureIfPossible current=\(currentObservationFailure != nil) pending=\(pendingObservationFailures.count)") + guard currentObservationFailure == nil, !pendingObservationFailures.isEmpty else { return } + guard let window = self.window, window.isVisible, window.attachedSheet == nil else { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + self.presentNextObservationFailureIfPossible() + } + return + } + + let failure = pendingObservationFailures.removeFirst() + currentObservationFailure = failure + let sheetController = DirectoryObservationSheetController(url: failure.url, error: failure.error) + observationFailureSheetController = sheetController + DiagnosticsLog.trace(.mainWindow, "presentingCustomSheet path=\(failure.url.path)") + window.beginSheet(sheetController.window!) { [weak self] response in + guard let self else { return } + self.observationFailureSheetController = nil + switch response { + case .alertFirstButtonReturn: + DirectoryObservationAlertPresenter.openPrivacySettings(for: failure.url) + case .alertSecondButtonReturn: + DirectoryObservationAlertPresenter.openFullDiskAccess() + default: + break + } + self.currentObservationFailure = nil + self.presentNextObservationFailureIfPossible() } - - return message } } extension MainWindowController: NSWindowDelegate { - @available(macOS, deprecated: 11.0) func window(_ window: NSWindow, willPositionSheet sheet: NSWindow, using rect: NSRect) -> NSRect { - // Always position sheets at the top of the window, ignoring toolbar insets - return NSRect(x: rect.minX, y: window.frame.height, width: rect.width, height: rect.height) + NSRect(x: rect.minX + 50, y: rect.minY - 50, width: rect.width, height: rect.height) } } + +enum DirectoryObservationAlertPresenter { + + static func handle(response: NSApplication.ModalResponse, for url: URL, error: Error) { + switch action(for: response, error: error) { + case .dismiss: + return + case .openPrivacySettings: + _ = openPrivacySettings(for: url) + case .openFullDiskAccess: + _ = openFullDiskAccess() + } + } + + static func summary(for error: Error) -> String { + isPermissionError(error) + ? NSLocalizedString("DirectoryObservationFailedPermissionSummary", comment: "Summary shown when Latest cannot access a configured app folder because of missing macOS privacy permissions.") + : NSLocalizedString("DirectoryObservationFailedSummary", comment: "Summary shown when Latest cannot access a configured app folder.") + } + + static func recoverySuggestion(for url: URL) -> String { + let recoveryKey = url.path.hasPrefix("/Volumes/") + ? "DirectoryObservationFailedRemovableVolumeSuggestion" + : "DirectoryObservationFailedPermissionSuggestion" + return NSLocalizedString(recoveryKey, comment: "Guidance shown when Latest cannot access a configured app folder because of permissions.") + } + + private enum Action { + case dismiss + case openPrivacySettings + case openFullDiskAccess + } + + private static func action(for response: NSApplication.ModalResponse, error: Error) -> Action { + guard isPermissionError(error) else { return .dismiss } + + switch response { + case .alertFirstButtonReturn: + return .openPrivacySettings + case .alertSecondButtonReturn: + return .openFullDiskAccess + default: + return .dismiss + } + } + + @discardableResult + static func openPrivacySettings(for url: URL) -> Bool { + let candidates = if url.path.hasPrefix("/Volumes/") { + [ + "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_FilesAndFolders", + "x-apple.systempreferences:com.apple.preference.security?Privacy_FilesAndFolders", + "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension", + "x-apple.systempreferences:com.apple.preference.security" + ] + } else { + [ + "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension", + "x-apple.systempreferences:com.apple.preference.security" + ] + } + + return openFirstAvailableURL(from: candidates) + } + + @discardableResult + static func openFullDiskAccess() -> Bool { + openFirstAvailableURL(from: [ + "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFiles", + "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles", + "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension", + "x-apple.systempreferences:com.apple.preference.security" + ]) + } + + private static func openFirstAvailableURL(from urlStrings: [String]) -> Bool { + for urlString in urlStrings { + guard let url = URL(string: urlString) else { continue } + if NSWorkspace.shared.open(url) { + return true + } + } + + let fallbackURL = URL(fileURLWithPath: "/System/Applications/System Settings.app") + return NSWorkspace.shared.open(fallbackURL) + } + + static func isPermissionError(_ error: Error) -> Bool { + let nsError = error as NSError + + if isPermissionCode(domain: nsError.domain, code: nsError.code) { + return true + } + + if let underlyingError = nsError.userInfo[NSUnderlyingErrorKey] as? NSError { + return isPermissionCode(domain: underlyingError.domain, code: underlyingError.code) + } + + return false + } + + private static func isPermissionCode(domain: String, code: Int) -> Bool { + if domain == NSPOSIXErrorDomain, + let posixCode = POSIXErrorCode(rawValue: Int32(code)) { + return posixCode == .EACCES || posixCode == .EPERM + } + + return domain == NSCocoaErrorDomain && code == NSFileReadNoPermissionError + } +} + +final class DirectoryObservationBannerView: NSVisualEffectView { + var onDismiss: (() -> Void)? + var onOpenPrivacySettings: ((URL) -> Void)? + var onOpenFullDiskAccess: (() -> Void)? + + private let iconView = NSImageView() + private let titleLabel = NSTextField(labelWithString: NSLocalizedString("DirectoryObservationFailedAlertTitle", comment: "Title of alert shown when Latest cannot monitor a configured app scan directory.")) + private let summaryLabel = NSTextField(wrappingLabelWithString: "") + private let folderCaptionLabel = NSTextField(labelWithString: NSLocalizedString("DirectoryObservationFailedFolderLabel", comment: "Caption shown above the inaccessible folder path.")) + private let folderPathLabel = NSTextField(wrappingLabelWithString: "") + private let reasonCaptionLabel = NSTextField(labelWithString: NSLocalizedString("DirectoryObservationFailedReasonLabel", comment: "Caption shown above the system-provided failure reason.")) + private let reasonLabel = NSTextField(wrappingLabelWithString: "") + private let recoveryLabel = NSTextField(wrappingLabelWithString: "") + private let permissionNoteLabel = NSTextField(wrappingLabelWithString: NSLocalizedString("DirectoryObservationFailedPermissionControlNote", comment: "Note clarifying that Latest cannot grant macOS privacy permissions on the user's behalf.")) + private let openPrivacySettingsButton = NSButton(title: NSLocalizedString("OpenPrivacySettingsAction", comment: "Action to open the relevant Privacy & Security settings pane."), target: nil, action: nil) + private let openFullDiskAccessButton = NSButton(title: NSLocalizedString("OpenFullDiskAccessAction", comment: "Action to open the Full Disk Access privacy settings pane."), target: nil, action: nil) + private let dismissButton = NSButton(title: NSLocalizedString("OKAction", comment: "Default button for dismissing an informational alert."), target: nil, action: nil) + private let container = NSView() + private let buttonRowContainer = NSView() + private let buttonSeparator = NSBox() + + private var url: URL? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + configureView() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configureView() + } + + func configure(with url: URL, error: Error) { + self.url = url + summaryLabel.stringValue = DirectoryObservationAlertPresenter.summary(for: error) + folderPathLabel.stringValue = url.path + reasonLabel.stringValue = error.localizedDescription + recoveryLabel.stringValue = DirectoryObservationAlertPresenter.recoverySuggestion(for: url) + + let isPermissionError = DirectoryObservationAlertPresenter.isPermissionError(error) + openPrivacySettingsButton.isHidden = !isPermissionError + openFullDiskAccessButton.isHidden = !isPermissionError + permissionNoteLabel.isHidden = !isPermissionError + invalidateIntrinsicContentSize() + } + + override var intrinsicContentSize: NSSize { + let containerSize = container.fittingSize + return NSSize(width: NSView.noIntrinsicMetric, height: containerSize.height + 28) + } + + @objc private func dismiss(_ sender: Any?) { + onDismiss?() + } + + @objc private func openPrivacySettings(_ sender: Any?) { + guard let url else { return } + onOpenPrivacySettings?(url) + } + + @objc private func openFullDiskAccess(_ sender: Any?) { + onOpenFullDiskAccess?() + } + + private func configureView() { + translatesAutoresizingMaskIntoConstraints = false + material = .contentBackground + blendingMode = .withinWindow + state = .followsWindowActiveState + + container.translatesAutoresizingMaskIntoConstraints = false + addSubview(container) + + let rootStack = NSStackView() + rootStack.orientation = .vertical + rootStack.alignment = .leading + rootStack.spacing = 14 + rootStack.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(rootStack) + + iconView.image = NSImage(named: NSImage.cautionName) + iconView.contentTintColor = .systemOrange + iconView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + iconView.widthAnchor.constraint(equalToConstant: 16), + iconView.heightAnchor.constraint(equalToConstant: 16) + ]) + + titleLabel.font = .systemFont(ofSize: NSFont.systemFontSize + 1, weight: .semibold) + titleLabel.lineBreakMode = .byWordWrapping + + let headerStack = NSStackView(views: [iconView, titleLabel]) + headerStack.orientation = .horizontal + headerStack.alignment = .centerY + headerStack.spacing = 10 + rootStack.addArrangedSubview(headerStack) + rootStack.setCustomSpacing(22, after: headerStack) + + configureWrappingLabel(summaryLabel) + configureSectionLabel(folderCaptionLabel) + configurePathLabel(folderPathLabel) + configureSectionLabel(reasonCaptionLabel) + configureWrappingLabel(reasonLabel) + configureWrappingLabel(recoveryLabel) + configureWrappingLabel(permissionNoteLabel, secondary: true) + + [summaryLabel, folderCaptionLabel, folderPathLabel, reasonCaptionLabel, reasonLabel, recoveryLabel, permissionNoteLabel].forEach { + rootStack.addArrangedSubview($0) + } + rootStack.setCustomSpacing(18, after: summaryLabel) + rootStack.setCustomSpacing(18, after: folderPathLabel) + rootStack.setCustomSpacing(18, after: reasonLabel) + rootStack.setCustomSpacing(20, after: recoveryLabel) + rootStack.setCustomSpacing(24, after: permissionNoteLabel) + + openPrivacySettingsButton.target = self + openPrivacySettingsButton.action = #selector(openPrivacySettings(_:)) + openFullDiskAccessButton.target = self + openFullDiskAccessButton.action = #selector(openFullDiskAccess(_:)) + dismissButton.target = self + dismissButton.action = #selector(dismiss(_:)) + [openPrivacySettingsButton, openFullDiskAccessButton, dismissButton].forEach { + $0.controlSize = .small + $0.font = .systemFont(ofSize: NSFont.smallSystemFontSize) + $0.bezelStyle = .rounded + $0.sizeToFit() + } + + let buttonStack = NSStackView(views: [openPrivacySettingsButton, openFullDiskAccessButton, dismissButton]) + buttonStack.orientation = .horizontal + buttonStack.alignment = .centerY + buttonStack.spacing = 8 + buttonStack.translatesAutoresizingMaskIntoConstraints = false + + buttonSeparator.boxType = .separator + buttonSeparator.translatesAutoresizingMaskIntoConstraints = false + rootStack.addArrangedSubview(buttonSeparator) + rootStack.setCustomSpacing(14, after: buttonSeparator) + + buttonRowContainer.translatesAutoresizingMaskIntoConstraints = false + buttonRowContainer.addSubview(buttonStack) + rootStack.addArrangedSubview(buttonRowContainer) + + NSLayoutConstraint.activate([ + container.topAnchor.constraint(equalTo: topAnchor, constant: 20), + container.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20), + container.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20), + container.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -18), + rootStack.topAnchor.constraint(equalTo: container.topAnchor), + rootStack.leadingAnchor.constraint(equalTo: container.leadingAnchor), + rootStack.trailingAnchor.constraint(equalTo: container.trailingAnchor), + rootStack.bottomAnchor.constraint(equalTo: container.bottomAnchor), + buttonRowContainer.widthAnchor.constraint(equalTo: rootStack.widthAnchor), + buttonStack.topAnchor.constraint(equalTo: buttonRowContainer.topAnchor), + buttonStack.trailingAnchor.constraint(equalTo: buttonRowContainer.trailingAnchor), + buttonStack.bottomAnchor.constraint(equalTo: buttonRowContainer.bottomAnchor) + ]) + } + + private func configureWrappingLabel(_ label: NSTextField, secondary: Bool = false) { + label.textColor = secondary ? .secondaryLabelColor : .labelColor + label.alignment = .left + label.font = .systemFont(ofSize: NSFont.systemFontSize) + label.lineBreakMode = .byWordWrapping + label.maximumNumberOfLines = 0 + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + } + + private func configureSectionLabel(_ label: NSTextField) { + label.font = .systemFont(ofSize: NSFont.smallSystemFontSize, weight: .semibold) + label.textColor = .secondaryLabelColor + } + + private func configurePathLabel(_ label: NSTextField) { + configureWrappingLabel(label) + label.font = .monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular) + } +} + +final class DirectoryObservationSheetController: NSWindowController { + init(url: URL, error: Error) { + let contentViewController = DirectoryObservationSheetViewController(url: url, error: error) + let window = NSWindow(contentViewController: contentViewController) + window.styleMask = [.titled] + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.isReleasedWhenClosed = false + window.standardWindowButton(.miniaturizeButton)?.isHidden = true + window.standardWindowButton(.zoomButton)?.isHidden = true + window.standardWindowButton(.closeButton)?.isHidden = true + window.contentMinSize = contentViewController.preferredContentSize + window.setContentSize(contentViewController.preferredContentSize) + super.init(window: window) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +final class DirectoryObservationSheetViewController: NSViewController { + private let bannerView = DirectoryObservationBannerView() + + init(url: URL, error: Error) { + super.init(nibName: nil, bundle: nil) + bannerView.configure(with: url, error: error) + bannerView.onDismiss = { [weak self] in + self?.endSheet(with: .cancel) + } + bannerView.onOpenPrivacySettings = { [weak self] _ in + self?.endSheet(with: .alertFirstButtonReturn) + } + bannerView.onOpenFullDiskAccess = { [weak self] in + self?.endSheet(with: .alertSecondButtonReturn) + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + let view = NSView() + view.translatesAutoresizingMaskIntoConstraints = false + view.wantsLayer = true + view.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor + view.addSubview(bannerView) + bannerView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + bannerView.topAnchor.constraint(equalTo: view.topAnchor, constant: 16), + bannerView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + bannerView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + bannerView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -16), + bannerView.widthAnchor.constraint(equalToConstant: 490) + ]) + + self.view = view + updatePreferredContentSize() + } + + override func viewDidLayout() { + super.viewDidLayout() + updatePreferredContentSize() + } + + private func updatePreferredContentSize() { + let size = bannerView.fittingSize + self.preferredContentSize = NSSize(width: 522, height: size.height + 32) + } + + private func endSheet(with response: NSApplication.ModalResponse) { + guard let sheetWindow = self.view.window, let sheetParent = sheetWindow.sheetParent else { return } + sheetParent.endSheet(sheetWindow, returnCode: response) + } +} diff --git a/Latest/Interface/Settings/Locations/AppDirectoryCellView.swift b/Latest/Interface/Settings/Locations/AppDirectoryCellView.swift index 18f904f0..7b97b8ad 100644 --- a/Latest/Interface/Settings/Locations/AppDirectoryCellView.swift +++ b/Latest/Interface/Settings/Locations/AppDirectoryCellView.swift @@ -8,6 +8,86 @@ import Cocoa +/// Shared provider for counting tracked apps in a directory without rescanning the same URL on every table reload. +final class AppDirectoryCountProvider { + + typealias CountFailureHandler = (URL, Error) -> Void + typealias BundleCounter = (URL, CountFailureHandler?) -> Int + typealias CountHandler = (Int) -> Void + + private let collectionQueue: DispatchQueue + private let stateQueue = DispatchQueue(label: "AppDirectoryCountProvider.state") + private let bundleCounter: BundleCounter + private var cachedCounts = [URL: Int]() + private var pendingHandlers = [URL: [CountHandler]]() + private var pendingFailureHandlers = [URL: [CountFailureHandler]]() + + init( + collectionQueue: DispatchQueue = DispatchQueue(label: "AppDirectoryCountProvider.collection", qos: .utility), + bundleCounter: @escaping BundleCounter = { url, failureHandler in + BundleCollector.collectBundles(at: url, errorHandler: failureHandler).count + } + ) { + self.collectionQueue = collectionQueue + self.bundleCounter = bundleCounter + } + + func count(for url: URL, completion: @escaping CountHandler, onFailure: CountFailureHandler? = nil) { + if let cachedCount = stateQueue.sync(execute: { cachedCounts[url] }) { + completion(cachedCount) + return + } + + let shouldStartCollection = stateQueue.sync { () -> Bool in + if pendingHandlers[url] != nil { + pendingHandlers[url]?.append(completion) + if let onFailure { + pendingFailureHandlers[url, default: []].append(onFailure) + } + return false + } + + pendingHandlers[url] = [completion] + if let onFailure { + pendingFailureHandlers[url] = [onFailure] + } + return true + } + + guard shouldStartCollection else { return } + + collectionQueue.async { [bundleCounter] in + let count = bundleCounter(url) { failedURL, error in + let failureHandlers = self.stateQueue.sync { () -> [CountFailureHandler] in + self.pendingFailureHandlers[url] ?? [] + } + + DispatchQueue.main.async { + failureHandlers.forEach { $0(failedURL, error) } + } + } + let handlers = self.stateQueue.sync { () -> [CountHandler] in + self.cachedCounts[url] = count + let handlers = self.pendingHandlers[url] ?? [] + self.pendingHandlers[url] = nil + self.pendingFailureHandlers[url] = nil + return handlers + } + + DispatchQueue.main.async { + handlers.forEach { $0(count) } + } + } + } + + func invalidate(_ url: URL) { + stateQueue.sync { + cachedCounts.removeValue(forKey: url) + pendingFailureHandlers.removeValue(forKey: url) + } + } +} + /// View that holds a single location checked for updates. class AppDirectoryCellView: NSTableCellView { @@ -33,12 +113,21 @@ class AppDirectoryCellView: NSTableCellView { } } + var countProvider: AppDirectoryCountProvider? { + didSet { + setUpView() + } + } + + var observationFailureHandler: AppDirectoryCountProvider.CountFailureHandler? + var isReachable: Bool = false private func setUpView() { guard let url else { titleLabel.stringValue = "" iconImageView.image = nil + activityIndicator.stopAnimation(nil) appCountLabel.isHidden = true return } @@ -53,14 +142,21 @@ class AppDirectoryCellView: NSTableCellView { // App Count activityIndicator.startAnimation(nil) appCountLabel.isHidden = true - DispatchQueue.global().async { - let count = BundleCollector.collectBundles(at: url).count - DispatchQueue.main.async { - self.appCountLabel.isHidden = false - self.activityIndicator.stopAnimation(nil) - self.appCountLabel.stringValue = NumberFormatter.localizedString(from: NSNumber(value: count), number: .none) - } + guard let countProvider else { + activityIndicator.stopAnimation(nil) + return } + + countProvider.count(for: url, completion: { [weak self] count in + guard let self, self.url == url else { return } + + self.appCountLabel.isHidden = false + self.activityIndicator.stopAnimation(nil) + self.appCountLabel.stringValue = NumberFormatter.localizedString(from: NSNumber(value: count), number: .none) + }, onFailure: { [weak self] failedURL, error in + guard let self, self.url == url else { return } + self.observationFailureHandler?(failedURL, error) + }) } private var tintColor: NSColor { diff --git a/Latest/Interface/Settings/Locations/AppLocationViewController.swift b/Latest/Interface/Settings/Locations/AppLocationViewController.swift index 97c0086c..9b40a66c 100644 --- a/Latest/Interface/Settings/Locations/AppLocationViewController.swift +++ b/Latest/Interface/Settings/Locations/AppLocationViewController.swift @@ -10,6 +10,8 @@ import AppKit /// View displaying a list of directories to be checked for apps with updates. class AppDirectoryViewController: SettingsTabItemViewController, NSTableViewDataSource, NSTableViewDelegate { + private var presentedObservationFailures = Set() + private var observationFailureSheetController: DirectoryObservationSheetController? // MARK: - View Lifecycle @@ -21,6 +23,8 @@ class AppDirectoryViewController: SettingsTabItemViewController, NSTableViewData validateButtons() } + private let countProvider = AppDirectoryCountProvider() + private lazy var directoryStore: AppDirectoryStore = { AppDirectoryStore(updateHandler: { [weak self] in self?.tableView.reloadData() @@ -37,6 +41,10 @@ class AppDirectoryViewController: SettingsTabItemViewController, NSTableViewData func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { guard let view = tableView.makeView(withIdentifier: NSUserInterfaceItemIdentifier("directoryCellView"), owner: self) as? AppDirectoryCellView else { return nil } + view.countProvider = countProvider + view.observationFailureHandler = { [weak self] url, error in + self?.presentObservationFailure(for: url, error: error) + } view.url = directoryStore.URLs[row] return view @@ -92,8 +100,77 @@ class AppDirectoryViewController: SettingsTabItemViewController, NSTableViewData panel.beginSheetModal(for: self.view.window!) { response in guard response == .OK else { return } - panel.urls.forEach { url in - self.directoryStore.add(url) + self.confirmAddition(of: panel.urls, at: 0) + } + } + + private func confirmAddition(of urls: [URL], at index: Int) { + guard index < urls.count else { return } + let url = urls[index] + let addDirectory = { + self.countProvider.invalidate(url) + self.directoryStore.add(url) + self.confirmAddition(of: urls, at: index + 1) + } + + guard isLikelyBroadScanLocation(url) else { + addDirectory() + return + } + + presentBroadScanWarning(for: url) { shouldAdd in + if shouldAdd { + addDirectory() + } else { + self.confirmAddition(of: urls, at: index + 1) + } + } + } + + private func isLikelyBroadScanLocation(_ url: URL) -> Bool { + let normalizedURL = url.standardizedFileURL.resolvingSymlinksInPath() + if normalizedURL.path == "/" || normalizedURL.path == NSHomeDirectory() { + return true + } + + guard let volumeURL = try? normalizedURL.resourceValues(forKeys: [.volumeURLKey]).volume else { + return false + } + + return volumeURL.standardizedFileURL.resolvingSymlinksInPath() == normalizedURL + } + + private func presentBroadScanWarning(for url: URL, completion: @escaping (Bool) -> Void) { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = NSLocalizedString("BroadAppDirectoryAlertTitle", comment: "Title shown when the user selects a very broad folder to scan for apps.") + alert.informativeText = String.localizedStringWithFormat( + NSLocalizedString("BroadAppDirectoryAlertMessage", comment: "Warning shown when the user selects a very broad folder to scan for apps."), + url.path + ) + alert.addButton(withTitle: NSLocalizedString("AddAnywayAction", comment: "Confirmation action for keeping a broad app scan folder.")) + alert.addButton(withTitle: NSLocalizedString("ChooseDifferentFolderAction", comment: "Action for cancelling a broad app scan folder selection.")) + + if let window = self.view.window { + alert.beginSheetModal(for: window) { response in + completion(response == .alertFirstButtonReturn) + } + } else { + completion(alert.runModal() == .alertFirstButtonReturn) + } + } + + private func presentObservationFailure(for url: URL, error: Error) { + let failureKey = "\(url.path)|\(error.localizedDescription)" + guard presentedObservationFailures.insert(failureKey).inserted else { return } + NSApplication.shared.requestUserAttention(.informationalRequest) + NSApplication.shared.activate(ignoringOtherApps: true) + if let window = self.view.window { + let sheetController = DirectoryObservationSheetController(url: url, error: error) + observationFailureSheetController = sheetController + window.beginSheet(sheetController.window!) { [weak self] response in + self?.observationFailureSheetController = nil + DirectoryObservationAlertPresenter.handle(response: response, for: url, error: error) } } } diff --git a/Latest/Model/App.swift b/Latest/Model/App.swift index d5330938..6da2eb60 100644 --- a/Latest/Model/App.swift +++ b/Latest/Model/App.swift @@ -152,6 +152,11 @@ extension App { return self.update?.usesBuiltInUpdater ?? false } + /// Whether an available update can be triggered immediately. + var canPerformUpdate: Bool { + update?.canPerformAction ?? false + } + /// The name of the external updater used to update this app. /// /// Returns `nil` if `usesBuiltInUpdater` is `true`. @@ -197,6 +202,11 @@ extension App { return attributedName } + + /// Returns the last known update information, if any. + var cachedUpdate: Update? { + update + } } diff --git a/Latest/Model/AppDataStore.swift b/Latest/Model/AppDataStore.swift index 990b21b2..0b60eb9e 100644 --- a/Latest/Model/AppDataStore.swift +++ b/Latest/Model/AppDataStore.swift @@ -36,11 +36,13 @@ class AppDataStore: AppProviding { /// The queue on which updates to the collection are being performed. private var updateQueue = DispatchQueue(label: "DataStoreQueue") + private let persistenceQueue = DispatchQueue(label: "AppDataStore.persistence", qos: .utility) init() { self.updateScheduler = DispatchSource.makeUserDataAddSource(queue: .global()) self.setupScheduler() + self.apps = self.loadCachedApps() } @@ -78,13 +80,14 @@ class AppDataStore: AppProviding { didSet { // Schedule an update for observers self.scheduleFilterUpdate() + self.persist(apps: self.apps) } } /// A subset of apps that can be updated. Ignored apps are not part of this list. var updatableApps: [App] { updateQueue.sync { - return self.apps.filter({ $0.updateAvailable && $0.usesBuiltInUpdater && !$0.isIgnored }) + return self.apps.filter({ $0.updateAvailable && $0.canPerformUpdate && $0.usesBuiltInUpdater && !$0.isIgnored }) } } @@ -174,6 +177,152 @@ class AppDataStore: AppProviding { /// A mapping of observers associated with apps. private var observers = [NSObject: ObserverHandler]() + + private struct CachedBundle: Codable { + let versionNumber: String? + let buildNumber: String? + let name: String + let bundleIdentifier: String + let filePath: String + let source: String + + init(bundle: App.Bundle) { + self.versionNumber = bundle.version.versionNumber + self.buildNumber = bundle.version.buildNumber + self.name = bundle.name + self.bundleIdentifier = bundle.bundleIdentifier + self.filePath = bundle.fileURL.path + self.source = bundle.source.rawValue + } + + var bundle: App.Bundle? { + guard let source = App.Source(rawValue: source) else { return nil } + + return App.Bundle( + version: Version(versionNumber: versionNumber, buildNumber: buildNumber), + name: name, + bundleIdentifier: bundleIdentifier, + fileURL: URL(fileURLWithPath: filePath), + source: source + ) + } + } + + private struct CachedUpdate: Codable { + let versionNumber: String? + let buildNumber: String? + let minimumOSVersion: String? + let source: String + let date: Date? + + init(update: App.Update) { + self.versionNumber = update.remoteVersion.versionNumber + self.buildNumber = update.remoteVersion.buildNumber + if let minimumOSVersion = update.minimumOSVersion { + self.minimumOSVersion = "\(minimumOSVersion.majorVersion).\(minimumOSVersion.minorVersion).\(minimumOSVersion.patchVersion)" + } else { + self.minimumOSVersion = nil + } + self.source = update.source.rawValue + self.date = update.date + } + + func update(for bundle: App.Bundle) -> App.Update? { + guard let source = App.Source(rawValue: source) else { return nil } + let minimumOSVersion = minimumOSVersion.flatMap { try? OperatingSystemVersion(string: $0) } + + return App.Update( + app: bundle, + remoteVersion: Version(versionNumber: versionNumber, buildNumber: buildNumber), + minimumOSVersion: minimumOSVersion, + source: source, + date: date, + releaseNotes: nil, + updateAction: Self.cachedAction(for: source, bundle: bundle), + isCached: true + ) + } + + private static func cachedAction(for source: App.Source, bundle: App.Bundle) -> App.Update.Action { + switch source { + case .sparkle: + return .builtIn(block: { app in + UpdateQueue.shared.addOperation(SparkleUpdateOperation(bundleIdentifier: app.bundleIdentifier, appIdentifier: app.identifier)) + }) + case .appStore: + return .external(label: NSLocalizedString("AppStoreSource", comment: "The source name of apps loaded from the App Store."), block: { app in + app.open() + }) + case .homebrew: + return .external(label: NSLocalizedString("HomebrewSource", comment: "The source name for apps checked via the Homebrew package manager."), block: { app in + app.open() + }) + case .none: + return .external(label: bundle.name, block: { app in + app.open() + }) + } + } + } + + private struct CachedApp: Codable { + let bundle: CachedBundle + let update: CachedUpdate? + let isIgnored: Bool + + init(app: App) { + self.bundle = CachedBundle(bundle: app.bundle) + self.update = app.cachedUpdate.map(CachedUpdate.init) + self.isIgnored = app.isIgnored + } + + var app: App? { + guard let bundle = bundle.bundle else { return nil } + let update = update?.update(for: bundle) + return App(bundle: bundle, update: update.map(Result.success), isIgnored: isIgnored) + } + } + + private static var cacheURL: URL? { + FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first? + .appendingPathComponent(Bundle.main.bundleIdentifier ?? "Latest", isDirectory: true) + .appendingPathComponent("AppBundles.json") + } + + private func loadCachedApps() -> Set { + guard let cacheURL = Self.cacheURL, + let data = try? Data(contentsOf: cacheURL) else { + return [] + } + + if let cachedApps = try? JSONDecoder().decode([CachedApp].self, from: data) { + return Set(cachedApps.compactMap(\.app)) + } + + guard let cachedBundles = try? JSONDecoder().decode([CachedBundle].self, from: data) else { + return [] + } + + return Set(cachedBundles.compactMap(\.bundle).map { bundle in + App(bundle: bundle, update: nil, isIgnored: self.isIdentifierIgnored(bundle.bundleIdentifier)) + }) + } + + private func persist(apps: Set) { + let cachedApps = apps.map(CachedApp.init) + + persistenceQueue.async { + guard let cacheURL = Self.cacheURL else { return } + + do { + try FileManager.default.createDirectory(at: cacheURL.deletingLastPathComponent(), withIntermediateDirectories: true) + let data = try JSONEncoder().encode(cachedApps) + try data.write(to: cacheURL, options: .atomic) + } catch { + () + } + } + } /// Adds the observer if it is not already registered. func addObserver(_ observer: NSObject, handler: @escaping ObserverHandler) { diff --git a/Latest/Model/Directory/AppDirectory.swift b/Latest/Model/Directory/AppDirectory.swift index 9f421579..f4b979fe 100644 --- a/Latest/Model/Directory/AppDirectory.swift +++ b/Latest/Model/Directory/AppDirectory.swift @@ -31,7 +31,7 @@ class AppDirectory { let handler: UpdateHandler /// The queue on which updates to the collection are being performed. - private var collectionQueue = DispatchQueue(label: "DataStoreQueue") + private lazy var collectionQueue = DispatchQueue(label: "AppDirectory.collection.\(url.path)", qos: .utility) private let descriptorProvider: DescriptorProvider private let observationErrorHandler: ObservationErrorHandler? @@ -64,11 +64,13 @@ class AppDirectory { /// Resumes tracking if it is not already running private func resumeTracking() { guard canObserveDirectory() else { + DiagnosticsLog.trace(.appDirectory, "canObserveDirectory failed path=\(url.path)") collectBundles() return } if let error = listener.start() { + DiagnosticsLog.trace(.appDirectory, "listener.start failed path=\(url.path) error=\(error.localizedDescription)") observationErrorHandler?(url, error) } collectBundles() @@ -76,13 +78,22 @@ class AppDirectory { /// Triggers an update run private func collectBundles() { - bundles = BundleCollector.collectBundles(at: self.url) + collectionQueue.async { + DiagnosticsLog.trace(.appDirectory, "collectBundles start path=\(self.url.path)") + let bundles = BundleCollector.collectBundles(at: self.url) { failedURL, error in + DiagnosticsLog.trace(.appDirectory, "collectBundles error path=\(failedURL.path) error=\(error.localizedDescription)") + self.observationErrorHandler?(failedURL, error) + } + DiagnosticsLog.trace(.appDirectory, "collectBundles finished path=\(self.url.path) bundles=\(bundles.count)") + self.bundles = bundles + } } private func canObserveDirectory() -> Bool { let result = descriptorProvider(url) guard result.descriptor != -1 else { if let error = result.error { + DiagnosticsLog.trace(.appDirectory, "openDescriptor failed path=\(url.path) error=\(error.localizedDescription)") observationErrorHandler?(url, error) } return false diff --git a/Latest/Model/Directory/BundleCollector.swift b/Latest/Model/Directory/BundleCollector.swift index 255e16cd..c5a71449 100644 --- a/Latest/Model/Directory/BundleCollector.swift +++ b/Latest/Model/Directory/BundleCollector.swift @@ -11,6 +11,7 @@ import UniformTypeIdentifiers /// Gathers apps at a given URL. enum BundleCollector { + typealias ErrorHandler = (URL, Error) -> Void /// Excluded subfolders that won't be checked. private static let excludedSubfolders = Set(["Setapp"]) @@ -25,10 +26,23 @@ enum BundleCollector { private static let appExtension = UTType.applicationBundle.preferredFilenameExtension /// Returns a list of application bundles at the given URL. - static func collectBundles(at url: URL) -> [App.Bundle] { - let enumerator = FileManager.default.enumerator(at: url, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles, .skipsPackageDescendants]) + static func collectBundles(at url: URL, errorHandler: ErrorHandler? = nil) -> [App.Bundle] { + let totalStart = CFAbsoluteTimeGetCurrent() + let enumerationStart = CFAbsoluteTimeGetCurrent() + var reportedAccessFailure = false + let enumerator = FileManager.default.enumerator( + at: url, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles, .skipsPackageDescendants], + errorHandler: { failedURL, error in + guard !reportedAccessFailure else { return true } + reportedAccessFailure = true + errorHandler?(failedURL, error) + return true + } + ) - var bundles = [App.Bundle]() + var appURLs = [URL]() while let bundleURL = enumerator?.nextObject() as? URL { guard !excludedSubfolders.contains(where: { bundleURL.path.contains($0) }) else { enumerator?.skipDescendants() @@ -36,10 +50,39 @@ enum BundleCollector { } let expectedExtension = if #available(macOS 11.0, *) { appExtension } else { "app" } - if bundleURL.pathExtension == expectedExtension, let bundle = bundle(forAppAt: bundleURL) { - bundles.append(bundle) + if bundleURL.pathExtension == expectedExtension { + appURLs.append(bundleURL) } } + + let enumerationDuration = CFAbsoluteTimeGetCurrent() - enumerationStart + let processingStart = CFAbsoluteTimeGetCurrent() + var bundles = [App.Bundle]() + let lock = NSLock() + + if appURLs.count < 8 { + for appURL in appURLs { + if let bundle = bundle(forAppAt: appURL) { + bundles.append(bundle) + } + } + } else { + DispatchQueue.concurrentPerform(iterations: appURLs.count) { index in + autoreleasepool { + guard let bundle = bundle(forAppAt: appURLs[index]) else { return } + lock.lock() + bundles.append(bundle) + lock.unlock() + } + } + } + + let processingDuration = CFAbsoluteTimeGetCurrent() - processingStart + let totalDuration = CFAbsoluteTimeGetCurrent() - totalStart + DiagnosticsLog.trace( + .appDirectory, + "collectBundles stats path=\(url.path) candidates=\(appURLs.count) bundles=\(bundles.count) enumerate=\(String(format: "%.3f", enumerationDuration))s process=\(String(format: "%.3f", processingDuration))s total=\(String(format: "%.3f", totalDuration))s" + ) return bundles } @@ -58,7 +101,7 @@ enum BundleCollector { } // Find update source - guard let source = UpdateCheckCoordinator.source(forAppAt: url) else { + guard let source = UpdateCheckCoordinator.source(forAppAt: url, bundle: appBundle) else { return nil } diff --git a/Latest/Model/Update Checker Extensions/App Store/MacAppStoreCheckerOperation.swift b/Latest/Model/Update Checker Extensions/App Store/MacAppStoreCheckerOperation.swift index 994d7094..24edeedf 100644 --- a/Latest/Model/Update Checker Extensions/App Store/MacAppStoreCheckerOperation.swift +++ b/Latest/Model/Update Checker Extensions/App Store/MacAppStoreCheckerOperation.swift @@ -20,10 +20,15 @@ class MacAppStoreUpdateCheckerOperation: StatefulOperation, UpdateCheckerOperati } static func canPerformUpdateCheck(forAppAt url: URL) -> Bool { + guard let bundle = Bundle(path: url.path) else { return false } + return canPerformUpdateCheck(forAppAt: url, bundle: bundle) + } + + static func canPerformUpdateCheck(forAppAt url: URL, bundle: Bundle) -> Bool { let fileManager = FileManager.default // Mac Apps contain a receipt, iOS apps are only available via the Mac App Store - guard let receiptPath = receiptPath(forAppAt: url), fileManager.fileExists(atPath: receiptPath) || isIOSAppBundle(at: url) else { return false } + guard let receiptPath = receiptPath(for: bundle), fileManager.fileExists(atPath: receiptPath) || isIOSAppBundle(withReceiptPath: receiptPath) else { return false } return true } @@ -80,14 +85,22 @@ class MacAppStoreUpdateCheckerOperation: StatefulOperation, UpdateCheckerOperati /// Returns the app store receipt path for the app at the given URL, if available. static fileprivate func receiptPath(forAppAt url: URL) -> String? { let bundle = Bundle(path: url.path) - return bundle?.appStoreReceiptURL?.path + return bundle.flatMap(receiptPath(for:)) + } + + static fileprivate func receiptPath(for bundle: Bundle) -> String? { + return bundle.appStoreReceiptURL?.path } /// Returns whether the app at the given URL is an iOS app wrapped to run on macOS. static fileprivate func isIOSAppBundle(at url: URL) -> Bool { // iOS apps are wrapped inside a macOS bundle let path = receiptPath(forAppAt: url) - return path?.contains("WrappedBundle") ?? false + return isIOSAppBundle(withReceiptPath: path) + } + + static fileprivate func isIOSAppBundle(withReceiptPath path: String?) -> Bool { + path?.contains("WrappedBundle") ?? false } } diff --git a/Latest/Model/Update Checker Extensions/Sparkle/SparkleCheckerOperation.swift b/Latest/Model/Update Checker Extensions/Sparkle/SparkleCheckerOperation.swift index 031d2219..ed0ae8aa 100644 --- a/Latest/Model/Update Checker Extensions/Sparkle/SparkleCheckerOperation.swift +++ b/Latest/Model/Update Checker Extensions/Sparkle/SparkleCheckerOperation.swift @@ -16,7 +16,13 @@ class SparkleUpdateCheckerOperation: StatefulOperation, UpdateCheckerOperation, static func canPerformUpdateCheck(forAppAt url: URL) -> Bool { // Can check for updates if a feed URL is available for the given app - return Self.feedURL(from: url) != nil + guard let bundle = Bundle(path: url.path) else { return false } + return canPerformUpdateCheck(forAppAt: url, bundle: bundle) + } + + static func canPerformUpdateCheck(forAppAt url: URL, bundle: Bundle) -> Bool { + // Can check for updates if a feed URL is available for the given app + return Self.feedURL(from: bundle) != nil } static var sourceType: App.Source { @@ -42,7 +48,11 @@ class SparkleUpdateCheckerOperation: StatefulOperation, UpdateCheckerOperation, /// Returns the Sparkle feed url for the app at the given URL, if available. private static func feedURL(from appURL: URL) -> URL? { guard let bundle = Bundle(path: appURL.path) else { return nil } - return Sparke.feedURL(from: bundle) + return feedURL(from: bundle) + } + + private static func feedURL(from bundle: Bundle) -> URL? { + Sparke.feedURL(from: bundle) } /// The bundle to be checked for updates. diff --git a/Latest/Model/Update.swift b/Latest/Model/Update.swift index 4fd1de9c..e9dbde26 100644 --- a/Latest/Model/Update.swift +++ b/Latest/Model/Update.swift @@ -36,8 +36,11 @@ extension App { /// A handler performing the update action of the app. let updateAction: Action + /// Whether this update was restored from cache and still needs a fresh verification. + let isCached: Bool + /// Initializes the update with the given parameters. - init(app: App.Bundle, remoteVersion: Version, minimumOSVersion: OperatingSystemVersion?, source: Source, date: Date?, releaseNotes: ReleaseNotes?, updateAction: Action) { + init(app: App.Bundle, remoteVersion: Version, minimumOSVersion: OperatingSystemVersion?, source: Source, date: Date?, releaseNotes: ReleaseNotes?, updateAction: Action, isCached: Bool = false) { self.app = app self.remoteVersion = remoteVersion self.minimumOSVersion = minimumOSVersion @@ -45,6 +48,7 @@ extension App { self.date = date self.releaseNotes = releaseNotes self.updateAction = updateAction + self.isCached = isCached } /// Whether an update is available for the given app. @@ -72,12 +76,21 @@ extension App { var externalUpdaterName: String? { if case .external(let label, _) = updateAction { label } else { nil } } + + /// Whether the update action can safely be executed. + var canPerformAction: Bool { + !isCached + } // MARK: - Actions /// Updates the app. This is a sub-classing hook. The default implementation opens the app. final func perform() { + guard !self.isCached else { + fatalError("Attempt to perform a cached update before it has been refreshed.") + } + guard !self.isUpdating else { fatalError("Attempt to perform update on app that is already updating.") } @@ -103,7 +116,7 @@ extension App { guard version != remoteVersion else { return self } // Modify just the remote version - return Update(app: app, remoteVersion: version, minimumOSVersion: minimumOSVersion, source: source, date: date, releaseNotes: releaseNotes, updateAction: updateAction) + return Update(app: app, remoteVersion: version, minimumOSVersion: minimumOSVersion, source: source, date: date, releaseNotes: releaseNotes, updateAction: updateAction, isCached: isCached) } diff --git a/Latest/Model/UpdateCheckCoordinator.swift b/Latest/Model/UpdateCheckCoordinator.swift index d228bb1b..a68b7c53 100644 --- a/Latest/Model/UpdateCheckCoordinator.swift +++ b/Latest/Model/UpdateCheckCoordinator.swift @@ -9,7 +9,7 @@ import Foundation /** - Protocol that defines some methods on reporting the progress of the update checking process. +Protocol that defines some methods on reporting the progress of the update checking process. */ protocol UpdateCheckProgressReporting : AnyObject { @@ -40,6 +40,10 @@ protocol UpdateCheckProgressReporting : AnyObject { class UpdateCheckCoordinator { typealias UpdateCheckerCallback = (_ app: App.Bundle) -> Void + private struct ObservationFailure { + let url: URL + let error: Error + } /// The object holding the apps found by the checker. var appProvider: AppProviding { @@ -59,7 +63,12 @@ class UpdateCheckCoordinator { private var waitForInitialCheck = true /// The delegate for the progress of the entire update checking progress - weak var progressDelegate : UpdateCheckProgressReporting? + weak var progressDelegate : UpdateCheckProgressReporting? { + didSet { + DiagnosticsLog.trace(.updateCheckCoordinator, "progressDelegate set nil=\(progressDelegate == nil)") + flushPendingObservationFailures() + } + } /// The library containing all bundles loaded from disk. private lazy var library: AppLibrary = { @@ -70,15 +79,15 @@ class UpdateCheckCoordinator { self.runUpdateCheck(on: newApps.map({ $0.bundle })) }, observationFailureHandler: { url, error in - DispatchQueue.main.async { - self.progressDelegate?.updateChecker(self, didFailToObserveDirectoryAt: url, error: error) - } + self.reportObservationFailure(at: url, error: error) } ) }() /// The data store updated apps should be passed to private let dataStore = AppDataStore() + private let observationFailureQueue = DispatchQueue(label: "UpdateCheckCoordinator.observationFailures") + private var pendingObservationFailures = [ObservationFailure]() /// The queue to run update checks on. private let updateOperationQueue: OperationQueue = { @@ -143,6 +152,30 @@ class UpdateCheckCoordinator { self.progressDelegate?.updateChecker(self, didCheckApp: app) } } + + private func reportObservationFailure(at url: URL, error: Error) { + observationFailureQueue.async { + DiagnosticsLog.trace(.updateCheckCoordinator, "reportObservationFailure path=\(url.path) error=\(error.localizedDescription)") + self.pendingObservationFailures.append(ObservationFailure(url: url, error: error)) + self.flushPendingObservationFailures() + } + } + + private func flushPendingObservationFailures() { + observationFailureQueue.async { + DiagnosticsLog.trace(.updateCheckCoordinator, "flushPendingObservationFailures pending=\(self.pendingObservationFailures.count) delegateNil=\(self.progressDelegate == nil)") + guard let delegate = self.progressDelegate, !self.pendingObservationFailures.isEmpty else { return } + let failures = self.pendingObservationFailures + self.pendingObservationFailures.removeAll() + + DispatchQueue.main.async { + failures.forEach { failure in + DiagnosticsLog.trace(.updateCheckCoordinator, "deliverObservationFailure path=\(failure.url.path)") + delegate.updateChecker(self, didFailToObserveDirectoryAt: failure.url, error: failure.error) + } + } + } + } } @@ -164,6 +197,23 @@ extension UpdateCheckCoordinator { return self.availableOperations.first { $0.canPerformUpdateCheck(forAppAt: url) }?.sourceType } + /// Returns the update source for the given app using an already loaded bundle. + static func source(forAppAt url: URL, bundle: Bundle) -> App.Source? { + if MacAppStoreUpdateCheckerOperation.canPerformUpdateCheck(forAppAt: url, bundle: bundle) { + return MacAppStoreUpdateCheckerOperation.sourceType + } + + if SparkleUpdateCheckerOperation.canPerformUpdateCheck(forAppAt: url, bundle: bundle) { + return SparkleUpdateCheckerOperation.sourceType + } + + if HomebrewCheckerOperation.canPerformUpdateCheck(forAppAt: url) { + return HomebrewCheckerOperation.sourceType + } + + return nil + } + /// Returns the update check operation for the given app bundle. static func operation(forChecking bundle: App.Bundle, repository: UpdateRepository?, completion: @escaping UpdateCheckerOperation.UpdateCheckerCompletionBlock) -> UpdateCheckerOperation? { return self.availableOperations.first { $0.sourceType == bundle.source }?.init(with: bundle, repository: repository, completionBlock: completion) diff --git a/Latest/Model/Utilities/Observable.swift b/Latest/Model/Utilities/Observable.swift index 370383af..f1f5edc9 100644 --- a/Latest/Model/Utilities/Observable.swift +++ b/Latest/Model/Utilities/Observable.swift @@ -46,3 +46,44 @@ extension Observable { } } + +/// Shared diagnostics logger for opt-in runtime tracing. +enum DiagnosticsLog { + enum Category: String { + case appDirectory = "AppDirectory" + case updateCheckCoordinator = "UpdateCheckCoordinator" + case mainWindow = "MainWindowController" + } + + static let userDefaultsKey = "diagnosticsTracingEnabled" + private static let environmentKey = "LATEST_ENABLE_DIAGNOSTICS_TRACE" + private static let fileName = "Diagnostics.log" + + static var isEnabled: Bool { + UserDefaults.standard.bool(forKey: userDefaultsKey) || ProcessInfo.processInfo.environment[environmentKey] == "1" + } + + static var logFileURL: URL { + let baseURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + return baseURL.appendingPathComponent(fileName) + } + + static func trace(_ category: Category, _ message: @autoclosure () -> String) { + guard isEnabled else { return } + + let line = "[\(category.rawValue)] \(message())\n" + guard let data = line.data(using: .utf8) else { return } + + let url = logFileURL + if FileManager.default.fileExists(atPath: url.path) { + if let handle = try? FileHandle(forWritingTo: url) { + handle.seekToEndOfFile() + handle.write(data) + handle.closeFile() + } + } else { + try? data.write(to: url) + } + } +} diff --git a/Latest/Resources/en.lproj/Localizable.strings b/Latest/Resources/en.lproj/Localizable.strings index 0f7b2e44..e1d65183 100644 --- a/Latest/Resources/en.lproj/Localizable.strings +++ b/Latest/Resources/en.lproj/Localizable.strings @@ -28,17 +28,50 @@ /* Update date sorting option. Displayed in menu with title: 'Sort By' -> 'Date' */ "DateSortOption" = "Date"; -/* Alert text shown when Latest cannot monitor a configured app scan directory. The first placeholder is the directory path, the second is the localized system error. */ -"DirectoryObservationFailedAlertMessage" = "Latest could not monitor this app folder for changes:\n%@\n\nReason: %@\n\nLatest will continue with a one-time scan, but changes in this folder may not be detected automatically."; - /* Title of alert shown when Latest cannot monitor a configured app scan directory. */ -"DirectoryObservationFailedAlertTitle" = "Couldn’t Monitor App Folder"; +"DirectoryObservationFailedAlertTitle" = "Couldn’t Access App Folder"; + +/* Title shown when the user selects a very broad folder to scan for apps. */ +"BroadAppDirectoryAlertTitle" = "Large Folder Selected"; + +/* Warning shown when the user selects a very broad folder to scan for apps. The placeholder is the chosen path. */ +"BroadAppDirectoryAlertMessage" = "Scanning a whole disk or another very broad folder can take a long time before Latest finds the actual apps you care about.\n\nSelected folder:\n%@\n\nIf possible, choose a narrower folder that directly contains your apps instead."; + +/* Confirmation action for keeping a broad app scan folder. */ +"AddAnywayAction" = "Add Anyway"; + +/* Action for cancelling a broad app scan folder selection. */ +"ChooseDifferentFolderAction" = "Choose Different Folder"; + +/* Short summary shown when Latest cannot access a configured app folder. */ +"DirectoryObservationFailedSummary" = "Latest could not fully access this app folder. It will keep scanning as well as it can, but apps in this location may load slowly or stop refreshing automatically."; + +/* Short summary shown when Latest cannot access a configured app folder because of missing macOS privacy permissions. */ +"DirectoryObservationFailedPermissionSummary" = "Latest does not currently have permission to fully access this app folder. Until that is fixed, apps in this location may load slowly or stop refreshing automatically."; + +/* Caption shown above the inaccessible folder path. */ +"DirectoryObservationFailedFolderLabel" = "Folder"; + +/* Caption shown above the system-provided failure reason. */ +"DirectoryObservationFailedReasonLabel" = "Reason"; /* Error shown when Latest cannot start recursive monitoring for a configured app scan directory. */ "DirectoryObservationUnavailableError" = "Latest could not start recursive monitoring for this folder."; /* Additional suggestion shown when the app likely lacks permission to observe a scan directory. */ -"DirectoryObservationFailedPermissionSuggestion" = "If this looks like a permissions problem, review macOS Privacy & Security settings and folder access for Latest."; +"DirectoryObservationFailedPermissionSuggestion" = "Open Privacy & Security, review the permissions for Latest, and allow access to this location if it is listed there."; + +/* Additional suggestion shown when the inaccessible scan directory is on an external or removable volume. */ +"DirectoryObservationFailedRemovableVolumeSuggestion" = "For external disks, check Files and Folders for Removable Volumes access first. If that still does not help, add Latest to Full Disk Access and try again."; + +/* Note clarifying that Latest cannot grant macOS privacy permissions on the user's behalf. */ +"DirectoryObservationFailedPermissionControlNote" = "Latest can open the relevant System Settings page for you, but macOS requires you to grant the permission yourself."; + +/* Action to open the most relevant Privacy & Security settings pane for a directory access failure. */ +"OpenPrivacySettingsAction" = "Open Privacy Settings"; + +/* Action to open the Full Disk Access privacy settings pane. */ +"OpenFullDiskAccessAction" = "Open Full Disk Access"; /* Update progress state of downloading an update. The first %@ stands for the already downloaded bytes, the second one for the total amount of bytes. One expected output would be 'Downloading 3 MB of 21 MB' */ "DownloadingUpdateStatus" = "Downloading %@ of %@"; diff --git a/Latest/View Model/AppListSnapshot.swift b/Latest/View Model/AppListSnapshot.swift index 8ada3bb8..adaa1170 100644 --- a/Latest/View Model/AppListSnapshot.swift +++ b/Latest/View Model/AppListSnapshot.swift @@ -14,60 +14,90 @@ /// - A filtered list of apps based on a given filter string struct AppListSnapshot { - /// The query after which apps can be filtered - let filterQuery: String? + struct Configuration { + + /// The query after which apps can be filtered + let filterQuery: String? + let showInstalledUpdates: Bool + let showIgnoredUpdates: Bool + let includeUnsupportedApps: Bool + let includeAppsWithLimitedSupport: Bool + let sortOrder: AppListSettings.SortOptions + + init(filterQuery: String?, settings: AppListSettings = .shared) { + self.filterQuery = filterQuery + self.showInstalledUpdates = settings.showInstalledUpdates + self.showIgnoredUpdates = settings.showIgnoredUpdates + self.includeUnsupportedApps = settings.includeUnsupportedApps + self.includeAppsWithLimitedSupport = settings.includeAppsWithLimitedSupport + self.sortOrder = settings.sortOrder + } + } + + /// The configuration used to generate the current snapshot. + let configuration: Configuration + + /// The query after which apps can be filtered. + var filterQuery: String? { + configuration.filterQuery + } /// The apps from which the content is created let apps: [App] /// Initializes the snapshot with the given list of apps and filter query. init(withApps apps: [App], filterQuery: String?) { - self.filterQuery = filterQuery + self.init(withApps: apps, configuration: Configuration(filterQuery: filterQuery)) + } + + /// Initializes the snapshot with the given list of apps and configuration. + init(withApps apps: [App], configuration: Configuration) { + self.configuration = configuration self.apps = apps - self.entries = Self.generateEntries(from: apps, filterQuery: filterQuery) + self.entries = Self.generateEntries(from: apps, configuration: configuration) } /// Returns a new snapshot containing an updated filter query. func updated(with filterQuery: String?) -> AppListSnapshot { - return AppListSnapshot(withApps: self.apps, filterQuery: filterQuery) + return AppListSnapshot(withApps: self.apps, configuration: Configuration(filterQuery: filterQuery)) } /// Returns an updated snapshot. func updated() -> AppListSnapshot { - return AppListSnapshot(withApps: self.apps, filterQuery: self.filterQuery) + return AppListSnapshot(withApps: self.apps, configuration: Configuration(filterQuery: self.filterQuery)) } /// The user-facable, sorted and filtered list of apps and sections. Observers of the data store will be notified, when this list changes. let entries: [Entry] /// Sorts and filters all available apps based on the given filter criteria. - private static func generateEntries(from apps: [App], filterQuery: String?) -> [Entry] { + private static func generateEntries(from apps: [App], configuration: Configuration) -> [Entry] { // Mutable copy var visibleApps = apps visibleApps = visibleApps.filter { app in // Apply filter query - if let filterQuery = filterQuery, !app.name.localizedCaseInsensitiveContains(filterQuery) { + if let filterQuery = configuration.filterQuery, !app.name.localizedCaseInsensitiveContains(filterQuery) { return false } // Filter installed updates - if !AppListSettings.shared.showInstalledUpdates && !(app.updateAvailable || app.isIgnored) { + if !configuration.showInstalledUpdates && !(app.updateAvailable || app.isIgnored) { return false } // Filter ignored apps - if !AppListSettings.shared.showIgnoredUpdates && app.isIgnored { + if !configuration.showIgnoredUpdates && app.isIgnored { return false } // Filter unsupported apps - if !AppListSettings.shared.includeUnsupportedApps && !app.supported { + if !configuration.includeUnsupportedApps && !app.supported { return false } // Filter apps not using the builtin updater - if !AppListSettings.shared.includeAppsWithLimitedSupport && app.updateAvailable && !app.usesBuiltInUpdater { + if !configuration.includeAppsWithLimitedSupport && app.updateAvailable && !app.usesBuiltInUpdater { return false } @@ -76,7 +106,7 @@ struct AppListSnapshot { // Sort apps based on setting let filteredApps = visibleApps.sorted(by: { (app1, app2) -> Bool in - switch AppListSettings.shared.sortOrder { + switch configuration.sortOrder { case .updateDate: return app1.updateDate > app2.updateDate case .name: diff --git a/Tests/OSVersionTest.swift b/Tests/OSVersionTest.swift index 30e28285..db1933e7 100644 --- a/Tests/OSVersionTest.swift +++ b/Tests/OSVersionTest.swift @@ -131,3 +131,72 @@ final class AppDirectoryTest: XCTestCase { } } + +final class AppDirectoryCountProviderTest: XCTestCase { + + func testCountDeduplicatesInFlightRequests() { + let startedCollection = expectation(description: "collection starts once") + let firstResult = expectation(description: "first request receives result") + let secondResult = expectation(description: "second request receives result") + let semaphore = DispatchSemaphore(value: 0) + let url = URL(fileURLWithPath: "/tmp/Latest-AppDirectoryCountProviderTest", isDirectory: true) + + var invocationCount = 0 + let provider = AppDirectoryCountProvider( + collectionQueue: DispatchQueue(label: "AppDirectoryCountProviderTest.collection"), + bundleCounter: { _, _ in + invocationCount += 1 + startedCollection.fulfill() + semaphore.wait() + return 3 + } + ) + + provider.count(for: url) { count in + XCTAssertEqual(count, 3) + firstResult.fulfill() + } + provider.count(for: url) { count in + XCTAssertEqual(count, 3) + secondResult.fulfill() + } + + wait(for: [startedCollection], timeout: 1) + XCTAssertEqual(invocationCount, 1) + + semaphore.signal() + wait(for: [firstResult, secondResult], timeout: 1) + XCTAssertEqual(invocationCount, 1) + } + + func testCountUsesCachedResultAfterInitialScan() { + let firstResult = expectation(description: "first request receives result") + let secondResult = expectation(description: "second request receives cached result") + let url = URL(fileURLWithPath: "/tmp/Latest-AppDirectoryCountProviderTest-cached", isDirectory: true) + + var invocationCount = 0 + let provider = AppDirectoryCountProvider( + collectionQueue: DispatchQueue(label: "AppDirectoryCountProviderTest.cached"), + bundleCounter: { _, _ in + invocationCount += 1 + return 7 + } + ) + + provider.count(for: url) { count in + XCTAssertEqual(count, 7) + firstResult.fulfill() + } + + wait(for: [firstResult], timeout: 1) + XCTAssertEqual(invocationCount, 1) + + provider.count(for: url) { count in + XCTAssertEqual(count, 7) + secondResult.fulfill() + } + + wait(for: [secondResult], timeout: 1) + XCTAssertEqual(invocationCount, 1) + } +}