From 01b6f8ab38468bdb87a40a240d49a3fc62d04523 Mon Sep 17 00:00:00 2001 From: wflixu Date: Wed, 16 Sep 2026 22:08:37 +0800 Subject: [PATCH 1/3] fix(startup): say why the store could not be opened instead of trapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RClick quit the instant it launched on macOS 27, leaving nothing behind but a SIGTRAP crash report. The stack ended in SharedDataManager.sharedModelContainer and the log held only _assertionFailure; the real reason — which file, and why it would not open — died with the process. The container was a static lazy var whose do/catch ended in fatalError, so every possible failure collapsed into the same silent trap. The failure seen in the wild is a build that is not properly code-signed: with no provisioning profile backing com.apple.security.application-groups, the sandbox never puts the App Group container in the process profile, and SQLite refuses the store with NSSQLiteErrorDomain=23. The crash reports carry the fingerprint (codeSigningTeamID ""), and reproducing it is just CODE_SIGNING_ALLOWED=NO. So both fatalErrors are gone: - SharedDataManager grows bootstrap() and a throwing makeSharedModelContainer(), plus a SharedStoreError that at least separates "no App Group container" from "container found, store unreadable" - StartupFailure logs the cause at .fault with privacy .public so it actually reaches the log store, shows it in an alert, then exits cleanly. It returns Never, which is what lets the call sites stay non-failable - AppDelegate opens the store in init() before anything touches AppState. appState had to become lazy: a default-valued property initializer runs before the init body, which would have inverted exactly that order Deliberately not done: falling back to a local or in-memory store. That would leave the app looking healthy while the extension read a different database, which is worse than refusing to start. Also clears the five Xcode 27 warnings and bumps to 2.3.0, build 20260916001: - four ImplicitStrongCapture in RClickApp, where the inner [weak self] captures were meaningless while the enclosing closure held self strongly - NoUseUnstructuredThrowingTask in MenuBarView, where the discarded throw is a cancellation we do not want to honour anyway Verified: Debug and Release both build warning-free under Xcode 27, the test suite passes, and the unsigned build now shows the alert and exits cleanly with no crash report instead of trapping. Co-Authored-By: Claude Code --- CHANGELOG.md | 8 +++ RClick.xcodeproj/project.pbxproj | 16 ++--- RClick/Localizable.xcstrings | 105 +++++++++++++++++++++++++++++ RClick/MenuBarView.swift | 6 +- RClick/Model/ModelContainer.swift | 102 ++++++++++++++++++++++------ RClick/RClickApp.swift | 25 +++++-- RClick/Shared/StartupFailure.swift | 66 ++++++++++++++++++ 7 files changed, 296 insertions(+), 32 deletions(-) create mode 100644 RClick/Shared/StartupFailure.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 1de6364..5a89421 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to RClick are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/). +## [2.3.0] - 2026-09-16 + +### Changed +- Builds clean against the macOS 27 SDK with Xcode 27, with no compiler warnings + +### Fixed +- RClick quit instantly and silently whenever it could not open its configuration database. It now reports the cause and exits cleanly instead of trapping. The case seen in the wild is a build that is not properly code-signed, which cannot reach the shared App Group container + ## [2.2.1] - 2026-09-13 ### Added diff --git a/RClick.xcodeproj/project.pbxproj b/RClick.xcodeproj/project.pbxproj index c7e1d84..da3345a 100644 --- a/RClick.xcodeproj/project.pbxproj +++ b/RClick.xcodeproj/project.pbxproj @@ -489,7 +489,7 @@ CODE_SIGN_ENTITLEMENTS = RClick/RClickDebug.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 20260913002; + CURRENT_PROJECT_VERSION = 20260916001; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 4L3563XCBN; ENABLE_APP_SANDBOX = YES; @@ -504,7 +504,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.6; - MARKETING_VERSION = 2.2.1; + MARKETING_VERSION = 2.3.0; PRODUCT_BUNDLE_IDENTIFIER = cn.wflixu.RClick; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -526,7 +526,7 @@ CODE_SIGN_ENTITLEMENTS = RClick/RClick.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 20260913002; + CURRENT_PROJECT_VERSION = 20260916001; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 4L3563XCBN; ENABLE_APP_SANDBOX = YES; @@ -541,7 +541,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.6; - MARKETING_VERSION = 2.2.1; + MARKETING_VERSION = 2.3.0; PRODUCT_BUNDLE_IDENTIFIER = cn.wflixu.RClick; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -604,7 +604,7 @@ CODE_SIGN_ENTITLEMENTS = FinderSyncExt/FinderSyncExt.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 20260913002; + CURRENT_PROJECT_VERSION = 20260916001; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 4L3563XCBN; ENABLE_APP_SANDBOX = YES; @@ -620,7 +620,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.6; - MARKETING_VERSION = 2.2.1; + MARKETING_VERSION = 2.3.0; PRODUCT_BUNDLE_IDENTIFIER = cn.wflixu.RClick.FinderSyncExt; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -639,7 +639,7 @@ CODE_SIGN_ENTITLEMENTS = FinderSyncExt/FinderSyncExt.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 20260913002; + CURRENT_PROJECT_VERSION = 20260916001; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 4L3563XCBN; ENABLE_APP_SANDBOX = YES; @@ -655,7 +655,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.6; - MARKETING_VERSION = 2.2.1; + MARKETING_VERSION = 2.3.0; PRODUCT_BUNDLE_IDENTIFIER = cn.wflixu.RClick.FinderSyncExt; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; diff --git a/RClick/Localizable.xcstrings b/RClick/Localizable.xcstrings index fb1ca68..7f67302 100644 --- a/RClick/Localizable.xcstrings +++ b/RClick/Localizable.xcstrings @@ -4477,6 +4477,41 @@ } } }, + "RClick could not access its shared App Group container, so it cannot read its settings." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "RClick could not access its shared App Group container, so it cannot read its settings." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "RClick no pudo acceder a su contenedor compartido de App Group, por lo que no puede leer su configuración." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "RClick n'a pas pu accéder à son conteneur App Group partagé et ne peut donc pas lire sa configuration." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "RClick は共有 App Group コンテナにアクセスできないため、設定を読み込めません。" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "RClick 无法访问共享的 App Group 容器,因此读不到配置数据。" + } + } + } + }, "RClick is a right-click menu extension that allows you to add applications for opening folders and includes some common actions." : { "extractionState" : "manual", "localizations" : { @@ -5667,6 +5702,41 @@ } } }, + "The most likely cause is that this build is not properly signed. Rebuild it with a valid signing identity — a build signed to run locally cannot reach the App Group container." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The most likely cause is that this build is not properly signed. Rebuild it with a valid signing identity — a build signed to run locally cannot reach the App Group container." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "La causa más probable es que esta compilación no está firmada correctamente. Vuelve a compilarla con una identidad de firma válida: una compilación firmada para ejecutarse localmente no puede acceder al contenedor de App Group." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "La cause la plus probable est que cette version n'est pas correctement signée. Recompilez-la avec une identité de signature valide : une version signée pour s'exécuter localement ne peut pas accéder au conteneur App Group." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "最も可能性が高い原因は、このビルドが正しく署名されていないことです。有効な署名 ID で再ビルドしてください。「ローカルで実行」用に署名されたビルドは App Group コンテナにアクセスできません。" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "最可能的原因是这个构建没有正确签名。请用有效的签名身份重新构建 —— 以「本地运行」方式签名的构建访问不了 App Group 容器。" + } + } + } + }, "The selected folder is a subfolder of an already selected folder. Please choose a different folder." : { "extractionState" : "manual", "localizations" : { @@ -5912,6 +5982,41 @@ } } }, + "Unable to open the configuration database" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unable to open the configuration database" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se puede abrir la base de datos de configuración" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossible d'ouvrir la base de données de configuration" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "設定データベースを開けません" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法打开配置数据库" + } + } + } + }, "Unauthorized Folder" : { "extractionState" : "manual", "localizations" : { diff --git a/RClick/MenuBarView.swift b/RClick/MenuBarView.swift index 6e9c46a..5034b94 100644 --- a/RClick/MenuBarView.swift +++ b/RClick/MenuBarView.swift @@ -46,8 +46,12 @@ struct MenuBarView: View { private func actionQuit() { messager.sendQuitNotification() + // 等一拍让菜单栏弹出面板收起,再退出。 + // + // 这里唯一的抛出是任务被取消;用户点的是"退出",不该因为一次取消就被留在这里, + // 所以显式吞掉取消、继续走完 —— 而不是让错误悄悄丢掉(这条警告说的正是后者)。 Task { @MainActor in - try await Task.sleep(nanoseconds: UInt64(1.0 * 1e9)) + try? await Task.sleep(for: .seconds(1)) NSApplication.shared.terminate(self) } diff --git a/RClick/Model/ModelContainer.swift b/RClick/Model/ModelContainer.swift index 8af1bb7..4332a7f 100644 --- a/RClick/Model/ModelContainer.swift +++ b/RClick/Model/ModelContainer.swift @@ -9,6 +9,42 @@ import Foundation import SwiftData import OSLog +/// 共享库打不开的原因。 +/// +/// 拆成两个 case 而不是笼统一个"失败",因为两者的排查路径完全不同: +/// 前者查 App Group 配置,后者多半是构建签名问题。 +enum SharedStoreError: LocalizedError { + /// 拿不到 App Group 容器目录:App Group 没配好,或 entitlement 没生效。 + case appGroupUnavailable(identifier: String) + /// 目录拿到了,但库文件打不开。 + case storeUnreadable(url: URL, underlying: Error) + + /// 诊断用,不是给用户看的文案 —— 用户看到的那份由 `StartupFailure.describe()` + /// 组装并走本地化。这里保持英文,避免一条没进 catalog 的中文串从某处 + /// `localizedDescription` 漏到界面上。 + var errorDescription: String? { + switch self { + case .appGroupUnavailable(let identifier): + return "Cannot resolve the App Group container: \(identifier)" + case .storeUnreadable(let url, let underlying): + return "Cannot open the store at \(url.path): \(underlying)" + } + } + + /// 错误链里是否出现 SQLite 的授权被拒(SQLITE_AUTH = 23)。 + /// + /// 这是"库文件在、当前进程却不许读"的特征。已知的唯一成因是构建产物没有有效签名: + /// `com.apple.security.application-groups` 没有被 provisioning profile 背书, + /// 沙盒因此不把容器目录写进本进程的 profile,打开库时即被拒。 + /// + /// 之所以靠字符串而不是 `NSError.userInfo`:SwiftData 把 Cocoa 错误裹在 + /// `SwiftDataError` 里,`NSSQLiteErrorDomain` 只出现在它的描述中,沿错误链取不到。 + var looksLikeAuthorizationDenial: Bool { + guard case .storeUnreadable(_, let underlying) = self else { return false } + return "\(underlying)".contains("NSSQLiteErrorDomain=23") + } +} + // 共享 ModelContainer 配置工具类 @MainActor class SharedDataManager { @@ -19,25 +55,54 @@ class SharedDataManager { category: "ModelContainer" ) - static var sharedModelContainer: ModelContainer = { + /// `bootstrap()` 打开的那个容器。 + private static var openedContainer: ModelContainer? + + /// 已打开的共享容器。 + /// + /// 正常路径由启动代码先跑 `bootstrap()`。若仍有更早的访问者(SwiftUI 求值顺序不是 + /// 我们能保证的),这里补一次并走同一个失败出口 —— 总之不再有 trap。 + static var sharedModelContainer: ModelContainer { + if let openedContainer { return openedContainer } + do { - // 获取 App Group 共享目录 - let storeURL: URL + let container = try makeSharedModelContainer() + openedContainer = container + return container + } catch { + StartupFailure.presentAndExit(error) + } + } - guard let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier) else { - fatalError("无法获取 App Group 共享目录。请检查 App Group 配置: \(appGroupIdentifier)") - } - storeURL = containerURL.appendingPathComponent("RClickDatabase.sqlite") + /// 打开共享容器,失败则抛出。 + /// + /// 启动路径应当在任何东西碰 `sharedModelContainer` 之前调用它。原先是静态 lazy + /// 初始化 + `fatalError`,于是"读不到库"表现为毫无提示的 SIGTRAP —— 崩溃报告里 + /// 只有 `_assertionFailure`,真正的原因(哪个文件、为什么打不开)随进程一起没了。 + /// 改成显式抛出后,调用方能把原因说清楚再退。 + static func bootstrap() throws { + guard openedContainer == nil else { return } + openedContainer = try makeSharedModelContainer() + } - // 创建 ModelConfiguration 使用共享路径 - let configuration = ModelConfiguration( - url: storeURL, - allowsSave: true, - cloudKitDatabase: .none - ) + /// 定位容器目录并打开 ModelContainer。不做任何 trap,失败一律抛给调用方。 + static func makeSharedModelContainer() throws -> ModelContainer { + // 获取 App Group 共享目录 + guard let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier) else { + throw SharedStoreError.appGroupUnavailable(identifier: appGroupIdentifier) + } + let storeURL = containerURL.appendingPathComponent("RClickDatabase.sqlite") - // 创建 ModelContainer,注册所有模型 - let container = try ModelContainer( + // 创建 ModelConfiguration 使用共享路径 + let configuration = ModelConfiguration( + url: storeURL, + allowsSave: true, + cloudKitDatabase: .none + ) + + // 创建 ModelContainer,注册所有模型 + do { + return try ModelContainer( for: AppEntity.self, ActionEntity.self, NewFileTypeEntity.self, @@ -46,12 +111,11 @@ class SharedDataManager { DataVersion.self, configurations: configuration ) - - return container } catch { - fatalError("创建共享 ModelContainer 失败: \(error)") + logger.error("打开共享 ModelContainer 失败: \(error)") + throw SharedStoreError.storeUnreadable(url: storeURL, underlying: error) } - }() + } /// 初始化默认数据 static func initializeDefaultData(context: ModelContext) async { diff --git a/RClick/RClickApp.swift b/RClick/RClickApp.swift index 01049fb..5dcea16 100644 --- a/RClick/RClickApp.swift +++ b/RClick/RClickApp.swift @@ -60,7 +60,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { @AppLog(category: "AppDelegate") private var logger - var appState: AppState = .shared + /// 惰性求值:`AppState.shared` 会一路构造到 `ModelContext(sharedModelContainer)`, + /// 所以必须等 `init` 里 `bootstrap()` 成功之后才碰它。写成默认值属性会在 init 体 + /// 之前求值,正好把顺序反过来。 + lazy var appState: AppState = .shared var pluginRunning: Bool = false var heartBeatCount = 0 @@ -69,6 +72,18 @@ class AppDelegate: NSObject, NSApplicationDelegate { var showInDock = UserDefaults.group.bool(forKey: Key.showInDock) var settingsWindow: NSWindow! + override init() { + super.init() + + // 先把共享库打开:失败就带着原因退出,而不是等到 AppState 构造时才崩。 + // 这是启动路径上唯一一处主动打开容器的地方,其余访问都指望它先跑过。 + do { + try SharedDataManager.bootstrap() + } catch { + StartupFailure.presentAndExit(error) + } + } + // MARK: - 重连机制状态 /// 最大重试次数 @@ -82,8 +97,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { forName: .menuConfigShouldUpdate, object: nil, queue: .main - ) { _ in - Task { @MainActor [weak self] in + ) { [weak self] _ in + Task { @MainActor in self?.sendMenuConfigurationUpdate() } } @@ -98,7 +113,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { migrateLegacyLaunchAtLoginSetting() // 执行数据迁移 - Task { @MainActor in + Task { @MainActor [weak self] in + guard let self = self else { return } + // 初始化默认数据 let context = ModelContext(SharedDataManager.sharedModelContainer) await SharedDataManager.initializeDefaultData(context: context) diff --git a/RClick/Shared/StartupFailure.swift b/RClick/Shared/StartupFailure.swift new file mode 100644 index 0000000..40cd1c0 --- /dev/null +++ b/RClick/Shared/StartupFailure.swift @@ -0,0 +1,66 @@ +// +// StartupFailure.swift +// RClick +// +// 启动阶段无法继续时的统一出口。 +// + +import AppKit +import Foundation +import OSLog + +/// 把"说不出口的崩溃"换成"看得到的说明 + 干净退出"。 +/// +/// 这类失败原先一律 `fatalError`:用户看到的是进程毫无征兆地消失,日志里只有一条 +/// SIGTRAP 崩溃报告,而真正的原因(哪个文件、为什么打不开)只存在于被丢弃的异常里。 +/// 典型的触发场景是构建产物没有有效签名 —— App Group 容器读不到,`ModelContainer` +/// 打开失败 —— 排查时只能靠崩溃报告里的签名指纹反推。 +@MainActor +enum StartupFailure { + @AppLog(category: "StartupFailure") + private static var logger + + /// 记录原因、告诉用户、退出。永不返回。 + /// + /// 返回 `Never` 是给调用方的类型保证:调用点不必再造一个占位返回值。 + static func presentAndExit(_ error: Error) -> Never { + let detail = describe(error) + + // `.fault` + `privacy: .public`:错误详情必须真的落到 log store 里。 + // 这里踩过一次坑 —— 持久化失败原本记在 `.info`,那条日志根本到不了 log store, + // 于是失败和成功在日志上长得一模一样。 + logger.fault("启动失败,即将退出: \(detail, privacy: .public)") + + let alert = NSAlert() + alert.alertStyle = .critical + alert.messageText = AppLocalization.localized("Unable to open the configuration database") + alert.informativeText = detail + alert.addButton(withTitle: AppLocalization.localized("Quit")) + alert.runModal() + + exit(EXIT_FAILURE) + } + + /// 拼给用户看的原因:一句人话 + 已知成因的下一步 + 原始错误。 + /// + /// 原始错误始终附上:定向提示只是启发式,不能替代事实。 + private static func describe(_ error: Error) -> String { + var lines = [ + AppLocalization.localized( + "RClick could not access its shared App Group container, so it cannot read its settings." + ) + ] + + if let storeError = error as? SharedStoreError, storeError.looksLikeAuthorizationDenial { + lines.append( + AppLocalization.localized( + "The most likely cause is that this build is not properly signed. Rebuild it with a valid signing identity — a build signed to run locally cannot reach the App Group container." + ) + ) + } + + lines.append("") + lines.append(String(describing: error)) + return lines.joined(separator: "\n") + } +} From b697cc3d8dc7ef2eba161fcef05a8fd598e21cd0 Mon Sep 17 00:00:00 2001 From: wflixu Date: Wed, 16 Sep 2026 23:09:45 +0800 Subject: [PATCH 2/3] fix(finder): stop the badge overlay and make a root grant actually apply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #155 reports two unrelated symptoms. Sidebar icons. Every external disk and mounted disk image showed RClick's icon. This had already been "fixed" once — 25b69e8 replaced random badge identifiers with an explicit empty string and closed #128 — and it came back. The empty string is not the fix. FinderSync draws badges from loose .png files in the extension's Resources, and the extension ships none (only Assets.car and the .lproj directories), so every setBadgeIdentifier call — empty string included — hands Finder an identifier it cannot draw and it falls back to the extension's own icon. RClick uses no badges, so requestBadgeIdentifier is now a no-op. Not calling is what "no badge" means. Authorization. A grant for "/" never matched anything, so a user who added it was left with no working grant at all and got the NSOpenPanel on whatever they touched. hasAccess built "/" + "/" = "//" and asked whether the target starts with that; nothing does. Matching is now done on path components, where "/" is just ["/"] — no special case needed, and it also cannot leak a grant for /Users/lixu onto /Users/lixu2. That second one may be the whole of a separate report where a folder had to be re-authorized after every reboot. With the only broad grant dead, nothing was ever really authorized, so every operation prompted — which is also why no log ever showed a bookmark failing to resolve. The destructive half of restoreBookmarks is deliberately untouched: it still deletes a grant when the bookmark fails to resolve, which is how eight grants were lost on 2026-09-13. That is a separate defect. Co-Authored-By: Claude Code --- FinderSyncExt/FinderSyncExt.swift | 14 +++++++--- RClick/Shared/BookmarkManager.swift | 31 ++++++++++++++------- RClickTests/BookmarkAccessTests.swift | 39 +++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 13 deletions(-) create mode 100644 RClickTests/BookmarkAccessTests.swift diff --git a/FinderSyncExt/FinderSyncExt.swift b/FinderSyncExt/FinderSyncExt.swift index f67d8c6..9e0641e 100644 --- a/FinderSyncExt/FinderSyncExt.swift +++ b/FinderSyncExt/FinderSyncExt.swift @@ -236,11 +236,17 @@ class FinderSyncExt: FIFinderSync, @unchecked Sendable { logger.debug("endObservingDirectoryAtURL: \(url.path)") } + /// Finder 会为观察范围内的每个项目回调这里,问它该显示什么徽章。 + /// + /// RClick 不使用徽章,所以这里**什么都不做** —— 要紧的是不要调用 + /// `setBadgeIdentifier`。扩展包里没有任何 badge 图(FinderSync 只认 + /// Resources 下的 `.png` 散文件,不认 asset catalog),一旦调用, + /// Finder 就退回拿扩展自己的图标去画叠加层,把外置盘和 DMG 的卷图标整个盖掉。 + /// + /// 这里原本传的是空字符串,当作"没有徽章"用。#128 那样修完,#155 又报了同一 + /// 现象,说明空串同样触发叠加。不调用才是「不画」。 override func requestBadgeIdentifier(for url: URL) { - // 不设置任何徽章标识,避免 Finder 在项目上叠加 RClick 图标。 - // 非空徽章 ID 会使 Finder 在文件/磁盘图标上显示扩展的图标叠加层, - // 这会导致移动磁盘和光盘等外部卷的图标被 RClick 图标覆盖。 - FIFinderSyncController.default().setBadgeIdentifier("", for: url) + // 故意留空,见上。 } // MARK: - Menu and toolbar item support diff --git a/RClick/Shared/BookmarkManager.swift b/RClick/Shared/BookmarkManager.swift index d20c045..ef02851 100644 --- a/RClick/Shared/BookmarkManager.swift +++ b/RClick/Shared/BookmarkManager.swift @@ -93,15 +93,28 @@ final class BookmarkManager: ObservableObject { /// 检查某个文件/目录是否在已授权的目录下 func hasAccess(to url: URL) -> Bool { - let target = url.resolvingSymlinksInPath().path - for dir in authorizedDirectories { - let dirPath = dir.resolvingSymlinksInPath().path - // 自身或子路径匹配 - if target == dirPath || target.hasPrefix(dirPath + "/") { - return true - } - } - return false + let target = url.resolvingSymlinksInPath() + return authorizedDirectories.contains { Self.covers($0, target) } + } + + /// `directory` 是否覆盖 `url`(自身,或 `url` 的某个祖先)。 + /// + /// 按路径分量比较,而不是把路径拼成字符串做前缀匹配。字符串前缀在根目录上会 + /// 失效:`dirPath + "/"` 对 `/` 拼出 `"//"`,而没有任何路径以 `//` 开头 —— + /// 于是"把 `/` 加进授权目录"恰好命中唯一一个永远不匹配的边界,用户越授权越 + /// 用不了(Issue #155)。分量比较天然处理它:`/` 的 `pathComponents` 是 `["/"]`。 + /// + /// 按分量比较同时也挡住了字符串前缀的另一个坑:`/Users/lixu` 不该覆盖 + /// `/Users/lixu2`,而 `hasPrefix("/Users/lixu")` 会误判为覆盖。 + /// + /// 两个入参都必须是**已解析符号链接**的路径 —— 调用点负责这一步 + /// (`authorizedDirectories` 在写入时就已 `resolvingSymlinksInPath`)。 + /// 这样这个函数是纯函数,可以脱离 `@MainActor` 状态直接单测。 + nonisolated static func covers(_ directory: URL, _ url: URL) -> Bool { + let dirParts = directory.pathComponents + let targetParts = url.pathComponents + guard targetParts.count >= dirParts.count else { return false } + return Array(targetParts.prefix(dirParts.count)) == dirParts } // MARK: - 授权弹窗 diff --git a/RClickTests/BookmarkAccessTests.swift b/RClickTests/BookmarkAccessTests.swift new file mode 100644 index 0000000..32086e1 --- /dev/null +++ b/RClickTests/BookmarkAccessTests.swift @@ -0,0 +1,39 @@ +import Foundation +import Testing + +@testable import RClick + +/// `BookmarkManager.covers` 决定一个已授权的目录是否覆盖某条路径。 +/// +/// 它原先用字符串前缀做匹配,于是悄悄坏掉了用户在"怎么授权都不管用"时最会去试的 +/// 那一个目录:`/`。`dirPath + "/"` 对根目录拼出 `"//"`,没有任何路径以它开头, +/// 所以把 `/` 加进授权列表等于什么都没加 —— 用户越授权越用不了(Issue #155)。 +/// +/// 入参约定是"已解析符号链接"的路径(调用点负责),所以这里直接喂普通路径即可。 +struct BookmarkAccessTests { + private func at(_ path: String) -> URL { URL(fileURLWithPath: path) } + + @Test func rootCoversEverything() { + #expect(BookmarkManager.covers(at("/"), at("/Users/lixu/Desktop"))) + #expect(BookmarkManager.covers(at("/"), at("/Volumes/Installer"))) + #expect(BookmarkManager.covers(at("/"), at("/"))) + } + + @Test func folderCoversItselfAndItsDescendants() { + #expect(BookmarkManager.covers(at("/Users/lixu"), at("/Users/lixu"))) + #expect(BookmarkManager.covers(at("/Users/lixu"), at("/Users/lixu/Desktop"))) + #expect(BookmarkManager.covers(at("/Users/lixu/Desktop"), at("/Users/lixu/Desktop/a.txt"))) + } + + @Test func folderDoesNotCoverSiblingsOrAncestors() { + #expect(!BookmarkManager.covers(at("/Users/lixu/Desktop"), at("/Users/lixu/Movies"))) + #expect(!BookmarkManager.covers(at("/Users/lixu/Desktop"), at("/Users/lixu"))) + #expect(!BookmarkManager.covers(at("/Users/lixu/Desktop"), at("/"))) + } + + /// 按分量比较不能比原来按字符串比较更松:共享前缀的兄弟目录必须仍然不覆盖。 + @Test func siblingSharingAPrefixIsNotCovered() { + #expect(!BookmarkManager.covers(at("/Users/lixu"), at("/Users/lixu2"))) + #expect(!BookmarkManager.covers(at("/Volumes/Disk"), at("/Volumes/Disk2/file"))) + } +} From 920d16eaffccaa52b8a2f810934286c30614e465 Mon Sep 17 00:00:00 2001 From: wflixu Date: Wed, 16 Sep 2026 23:15:44 +0800 Subject: [PATCH 3/3] docs(finder): correct the badge comment that blamed #155 on badges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit b697cc3 turned requestBadgeIdentifier into a no-op and justified it with a mechanism that is not true: the comment claimed an empty string still triggers the icon overlay, and that not calling is what stops it. That was measured and it is false. The running extension was loaded from a binary with no setBadgeIdentifier call anywhere in it, and the volume icons were unchanged. So nothing here changes at runtime. The override stays empty, which remains the right way to say "this extension has no badges" — it ships no badge images at all, and FinderSync can only draw badges from loose .png files in the extension's Resources. What changes is the comment: it now records that badges were blamed twice for this symptom (25b69e8 first, closing #128) and that neither attempt fixed it, so the next person does not repeat them, and it points at where the cause actually appears to be — the extension putting every volume root into directoryURLs. Co-Authored-By: Claude Code --- FinderSyncExt/FinderSyncExt.swift | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/FinderSyncExt/FinderSyncExt.swift b/FinderSyncExt/FinderSyncExt.swift index 9e0641e..a9f66c6 100644 --- a/FinderSyncExt/FinderSyncExt.swift +++ b/FinderSyncExt/FinderSyncExt.swift @@ -238,13 +238,14 @@ class FinderSyncExt: FIFinderSync, @unchecked Sendable { /// Finder 会为观察范围内的每个项目回调这里,问它该显示什么徽章。 /// - /// RClick 不使用徽章,所以这里**什么都不做** —— 要紧的是不要调用 - /// `setBadgeIdentifier`。扩展包里没有任何 badge 图(FinderSync 只认 - /// Resources 下的 `.png` 散文件,不认 asset catalog),一旦调用, - /// Finder 就退回拿扩展自己的图标去画叠加层,把外置盘和 DMG 的卷图标整个盖掉。 + /// RClick 不使用徽章,所以留空:扩展包里没有任何 badge 图(FinderSync 只认 + /// Resources 下的 `.png` 散文件,不认 asset catalog),没有可设的标识。 /// - /// 这里原本传的是空字符串,当作"没有徽章"用。#128 那样修完,#155 又报了同一 - /// 现象,说明空串同样触发叠加。不调用才是「不画」。 + /// ⚠️ 这两行**不是**"外置盘图标变成 RClick 图标"的修复。#155 那个现象在这里 + /// 被误判过两次:25b69e8 把随机标识改成空串,b697cc3 改成干脆不调用 —— 两次 + /// 都没有解决,第二次还实测过"不调用时图标照旧"。真正的原因在别处:扩展把 + /// 每个卷根都放进了 `directoryURLs`(见 setupObservingDirectories),Finder + /// 因而重新渲染这些卷的侧边栏图标。别再往 badge 上找了。 override func requestBadgeIdentifier(for url: URL) { // 故意留空,见上。 }