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/FinderSyncExt/FinderSyncExt.swift b/FinderSyncExt/FinderSyncExt.swift index f67d8c6..a9f66c6 100644 --- a/FinderSyncExt/FinderSyncExt.swift +++ b/FinderSyncExt/FinderSyncExt.swift @@ -236,11 +236,18 @@ class FinderSyncExt: FIFinderSync, @unchecked Sendable { logger.debug("endObservingDirectoryAtURL: \(url.path)") } + /// Finder 会为观察范围内的每个项目回调这里,问它该显示什么徽章。 + /// + /// RClick 不使用徽章,所以留空:扩展包里没有任何 badge 图(FinderSync 只认 + /// Resources 下的 `.png` 散文件,不认 asset catalog),没有可设的标识。 + /// + /// ⚠️ 这两行**不是**"外置盘图标变成 RClick 图标"的修复。#155 那个现象在这里 + /// 被误判过两次:25b69e8 把随机标识改成空串,b697cc3 改成干脆不调用 —— 两次 + /// 都没有解决,第二次还实测过"不调用时图标照旧"。真正的原因在别处:扩展把 + /// 每个卷根都放进了 `directoryURLs`(见 setupObservingDirectories),Finder + /// 因而重新渲染这些卷的侧边栏图标。别再往 badge 上找了。 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.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/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/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") + } +} 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"))) + } +}