From 0f45df3fed52a4591d930d0b4d45bd89807d1275 Mon Sep 17 00:00:00 2001 From: mattt Date: Thu, 17 Sep 2026 10:27:51 -0700 Subject: [PATCH] Keep the existing grant when the folder picker is dismissed Dismissing the folder picker threw invalidFileSelected. During a Messages upgrade from a file grant, that error reached the service toggle and turned Messages off, although the old grant still worked and the alert's Cancel button already kept it. The picker now returns nil on dismissal; Messages keeps the old grant during an upgrade and otherwise reports userDeclinedAccess, and Phone reports the same instead of a wrong selection. The once-per-launch warning about a file-only Messages grant was a plain check-and-set on a shared flag that concurrent tool calls could race. It is now guarded by a lock, with the log call outside it. --- App/Services/Messages.swift | 30 ++++++++++++++++++++---------- App/Services/Phone.swift | 16 ++++++++++------ 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/App/Services/Messages.swift b/App/Services/Messages.swift index f1b4dbcc..a532d74d 100644 --- a/App/Services/Messages.swift +++ b/App/Services/Messages.swift @@ -1,5 +1,6 @@ import AppKit import OSLog +import os import SQLite3 import iMessage @@ -12,8 +13,9 @@ private let defaultLimit = 30 final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { static let shared = MessageService() - /// Logged once per launch when the stored grant covers `chat.db` alone. - private var warnedAboutFileGrant = false + /// Whether the once-per-launch warning about a grant on `chat.db` alone has been logged. + /// Tool calls run concurrently, so the check-and-set is guarded. + private let fileGrantWarningLogged = OSAllocatedUnfairLock(initialState: false) func activate() async throws { try await activate(offeringUpgrade: true) @@ -49,8 +51,11 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { } if upgrading, !offeringUpgrade { - if !warnedAboutFileGrant { - warnedAboutFileGrant = true + let isFirstWarning = fileGrantWarningLogged.withLock { logged in + defer { logged = true } + return !logged + } + if isFirstWarning { log.warning( "The Messages grant covers chat.db alone, so messages since its last checkpoint are not visible. Switch Messages off and on in the iMCP menu to grant the Messages folder." ) @@ -65,7 +70,11 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { throw DatabaseAccessError.userDeclinedAccess } - let selectedURL = try await showFolderPicker() + guard let selectedURL = try await showFolderPicker() else { + // Dismissing the picker is the same answer as Cancel on the alert. + if upgrading { return } + throw DatabaseAccessError.userDeclinedAccess + } guard FileManager.default.isReadableFile(atPath: databaseURL(in: selectedURL).path) else { throw DatabaseAccessError.fileNotReadable @@ -407,8 +416,9 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { return alert.runModal() == .alertFirstButtonReturn } + /// Returns the selected folder, or nil when the user dismisses the panel. @MainActor - private func showFolderPicker() async throws -> URL { + private func showFolderPicker() async throws -> URL? { let openPanel = NSOpenPanel() openPanel.delegate = self openPanel.message = "Please select your Messages folder (~/Library/Messages)" @@ -420,10 +430,10 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { openPanel.canChooseFiles = false openPanel.showsHiddenFiles = true - guard openPanel.runModal() == .OK, - let url = openPanel.url, - isMessagesDirectory(url) - else { + guard openPanel.runModal() == .OK, let url = openPanel.url else { + return nil + } + guard isMessagesDirectory(url) else { throw DatabaseAccessError.invalidFileSelected } diff --git a/App/Services/Phone.swift b/App/Services/Phone.swift index 33630898..c906b6a8 100644 --- a/App/Services/Phone.swift +++ b/App/Services/Phone.swift @@ -196,7 +196,10 @@ final class PhoneService: NSObject, Service, NSOpenSavePanelDelegate { throw DatabaseAccessError.userDeclinedAccess } - let selectedURL = try await showFolderPicker() + guard let selectedURL = try await showFolderPicker() else { + // Dismissing the picker is the same answer as Cancel on the alert. + throw DatabaseAccessError.userDeclinedAccess + } guard FileManager.default.isReadableFile(atPath: databaseURL(in: selectedURL).path) else { throw DatabaseAccessError.fileNotReadable @@ -407,8 +410,9 @@ final class PhoneService: NSObject, Service, NSOpenSavePanelDelegate { return alert.runModal() == .alertFirstButtonReturn } + /// Returns the selected folder, or nil when the user dismisses the panel. @MainActor - private func showFolderPicker() async throws -> URL { + private func showFolderPicker() async throws -> URL? { let openPanel = NSOpenPanel() openPanel.delegate = self openPanel.message = @@ -421,10 +425,10 @@ final class PhoneService: NSObject, Service, NSOpenSavePanelDelegate { openPanel.canChooseFiles = false openPanel.showsHiddenFiles = true - guard openPanel.runModal() == .OK, - let url = openPanel.url, - isCallHistoryDirectory(url) - else { + guard openPanel.runModal() == .OK, let url = openPanel.url else { + return nil + } + guard isCallHistoryDirectory(url) else { throw DatabaseAccessError.invalidFileSelected }