From e5653111233c53b4ce4360dcf56aae3eaab27f76 Mon Sep 17 00:00:00 2001 From: zombie Date: Fri, 12 Jun 2026 15:46:21 +0800 Subject: [PATCH 01/10] refactor: update ExternalAppLauncher logger access and adjust cache and settings models for concurrency safety --- .../Models/BetterMenuSettingsModel.swift | 31 ++++++++++++++++--- BetterMenu/Services/ExternalAppLauncher.swift | 2 +- BetterMenu/Services/IconCacheManager.swift | 4 ++- 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/BetterMenu/Models/BetterMenuSettingsModel.swift b/BetterMenu/Models/BetterMenuSettingsModel.swift index 27c0cde..043bc9e 100644 --- a/BetterMenu/Models/BetterMenuSettingsModel.swift +++ b/BetterMenu/Models/BetterMenuSettingsModel.swift @@ -64,6 +64,29 @@ let betterMenuFileTypes: [FileDefinition] = BetterMenuShared.supportedFileTypes // MARK: - ViewModel +/// 线程安全的通知监听器容器包装,用于规避 Swift 6 deinit 非隔离上下文下的并发警告 +final class NotificationObserversBox: @unchecked Sendable { + private let lock = NSLock() + private var observers: [NSObjectProtocol] = [] + + func append(_ observer: NSObjectProtocol) { + lock.lock() + defer { lock.unlock() } + observers.append(observer) + } + + func cleanUp() { + lock.lock() + let current = observers + observers.removeAll() + lock.unlock() + + for observer in current { + NotificationCenter.default.removeObserver(observer) + } + } +} + /// 应用的设置与业务逻辑 ViewModel,负责数据读写、服务状态监控与进程通信 @MainActor final class BetterMenuSettingsModel: ObservableObject { @@ -179,7 +202,7 @@ final class BetterMenuSettingsModel: ObservableObject { let appVersion: String var onDisplayModeDidChange: ((BetterMenuDisplayMode) -> Void)? - nonisolated(unsafe) private var notificationObservers: [NSObjectProtocol] = [] + private let notificationObservers = NotificationObserversBox() private var isSynchronizingLaunchAtLogin = false private var writeDebounceWorkItem: DispatchWorkItem? @@ -270,9 +293,7 @@ final class BetterMenuSettingsModel: ObservableObject { } deinit { - for observer in notificationObservers { - NotificationCenter.default.removeObserver(observer) - } + notificationObservers.cleanUp() } // MARK: - 终端支持列表 @@ -728,7 +749,7 @@ final class BetterMenuSettingsModel: ObservableObject { // 异步预热图标缓存 prewarmIconCache() } catch { - NSLog("BetterMenu shared settings write failed: \(error.localizedDescription)") + logger.error("BetterMenu shared settings write failed: \(error.localizedDescription, privacy: .public)") } } diff --git a/BetterMenu/Services/ExternalAppLauncher.swift b/BetterMenu/Services/ExternalAppLauncher.swift index 9221e06..baf2b1f 100644 --- a/BetterMenu/Services/ExternalAppLauncher.swift +++ b/BetterMenu/Services/ExternalAppLauncher.swift @@ -190,7 +190,7 @@ struct ExternalAppLauncher { // 完成回调在后台队列 (com.apple.launchservices.open-queue) 执行, // 不能直接访问 @MainActor 隔离成员,需用 @Sendable 标记并手动派发回主线程。 - nonisolated(unsafe) let log = logger + let log = self.logger NSWorkspace.shared.open([directoryUrl], withApplicationAt: appUrl, configuration: configuration) { @Sendable runningApplication, error in if let error = error { diff --git a/BetterMenu/Services/IconCacheManager.swift b/BetterMenu/Services/IconCacheManager.swift index 5fe68c2..1e16319 100644 --- a/BetterMenu/Services/IconCacheManager.swift +++ b/BetterMenu/Services/IconCacheManager.swift @@ -1,8 +1,10 @@ import Cocoa import UniformTypeIdentifiers +import os /// 负责在后台异步预热文件类型图标及外部应用程序图标,并将渲染好的 Retina PNG 数据序列化写入磁盘缓存。 enum IconCacheManager { + private static let logger = Logger(subsystem: "com.zombie.BetterMenu", category: "IconCacheManager") /// 执行图标的异步预热与磁盘缓存写入 /// - Parameters: /// - fileTypes: 当前配置的全部内置文件类型定义 @@ -80,7 +82,7 @@ enum IconCacheManager { ) try data.write(to: cacheUrl, options: .atomic) } catch { - // 缓存写入失败不影响正常功能 + logger.error("Failed to write icon cache to disk: \(error.localizedDescription, privacy: .public)") } } } From 50941eec2e948f35f82984136b9cdb9ae88b4bd7 Mon Sep 17 00:00:00 2001 From: zombie Date: Fri, 12 Jun 2026 16:02:27 +0800 Subject: [PATCH 02/10] refactor: optimize icon retrieval logic by centralizing cache management in IconCacheManager --- BetterMenu/Services/IconCacheManager.swift | 2 +- BetterMenuFinderSync/MenuIconManager.swift | 83 +++++++++++----------- 2 files changed, 43 insertions(+), 42 deletions(-) diff --git a/BetterMenu/Services/IconCacheManager.swift b/BetterMenu/Services/IconCacheManager.swift index 1e16319..d668d56 100644 --- a/BetterMenu/Services/IconCacheManager.swift +++ b/BetterMenu/Services/IconCacheManager.swift @@ -24,7 +24,7 @@ enum IconCacheManager { } let iconItems = builtInItems + customItems - DispatchQueue.global(qos: .utility).async { + Task.detached(priority: .utility) { let targetSize = NSSize(width: 18, height: 18) var dict: [String: Data] = [:] diff --git a/BetterMenuFinderSync/MenuIconManager.swift b/BetterMenuFinderSync/MenuIconManager.swift index 6d801e8..ad864d9 100644 --- a/BetterMenuFinderSync/MenuIconManager.swift +++ b/BetterMenuFinderSync/MenuIconManager.swift @@ -9,9 +9,11 @@ final class MenuIconManager: @unchecked Sendable { private var didLoadDiskCache = false // 静态 SF Symbol 渲染缓存与锁 - private static let symbolCacheLock = NSLock() - nonisolated(unsafe) private static var symbolCache: [String: NSImage] = [:] - nonisolated(unsafe) private static var lastAppearanceIsDark: Bool? = nil + private struct SymbolCacheState { + var symbolCache: [String: NSImage] = [:] + var lastAppearanceIsDark: Bool? = nil + } + private static let symbolCacheLock = OSAllocatedUnfairLock(initialState: SymbolCacheState()) /// 清除全部内存缓存(在偏好设置或系统外观模式发生改变时调用) func clearCache() { @@ -20,10 +22,10 @@ final class MenuIconManager: @unchecked Sendable { didLoadDiskCache = false lock.unlock() - Self.symbolCacheLock.lock() - Self.symbolCache.removeAll() - Self.lastAppearanceIsDark = nil - Self.symbolCacheLock.unlock() + Self.symbolCacheLock.withLock { state in + state.symbolCache.removeAll() + state.lastAppearanceIsDark = nil + } } /// 获取在磁盘中预热缓存的图标。若未加载,则在此处触发加载。 @@ -78,44 +80,43 @@ final class MenuIconManager: @unchecked Sendable { static func finderMenuSymbol(named symbolName: String, accessibilityDescription: String) -> NSImage { let isDark = systemUsesDarkAppearance() - symbolCacheLock.lock() - defer { symbolCacheLock.unlock() } + return symbolCacheLock.withLock { state in + // 动态检测系统深浅色外观变化,若发生变化清空内存缓存重新生成 + if state.lastAppearanceIsDark != isDark { + state.symbolCache.removeAll() + state.lastAppearanceIsDark = isDark + } - // 动态检测系统深浅色外观变化,若发生变化清空内存缓存重新生成 - if lastAppearanceIsDark != isDark { - symbolCache.removeAll() - lastAppearanceIsDark = isDark - } + if let cached = state.symbolCache[symbolName] { + return cached + } - if let cached = symbolCache[symbolName] { - return cached - } + let targetSize = NSSize(width: 18, height: 18) + guard let symbolImage = NSImage(systemSymbolName: symbolName, accessibilityDescription: accessibilityDescription) + ?? NSImage(systemSymbolName: "doc", accessibilityDescription: accessibilityDescription) + else { + return NSImage() + } + symbolImage.size = targetSize + + // 使用 NSImage(size:flipped:drawingHandler:) 替代已弃用的 lockFocus/unlockFocus + let image = NSImage(size: targetSize, flipped: false) { drawRect in + NSGraphicsContext.current?.imageInterpolation = .high + symbolImage.draw(in: drawRect, from: .zero, operation: .sourceOver, fraction: 1.0) + + // 深色模式使用系统高对比淡白色,浅色模式使用系统高对比深灰色 + let tintColor = isDark + ? NSColor(calibratedWhite: 0.85, alpha: 1.0) + : NSColor(calibratedWhite: 0.17, alpha: 1.0) + tintColor.setFill() + drawRect.fill(using: .sourceAtop) + return true + } + image.isTemplate = false // 禁用 template 模式,防止系统自动二次涂色 - let targetSize = NSSize(width: 18, height: 18) - guard let symbolImage = NSImage(systemSymbolName: symbolName, accessibilityDescription: accessibilityDescription) - ?? NSImage(systemSymbolName: "doc", accessibilityDescription: accessibilityDescription) - else { - return NSImage() + state.symbolCache[symbolName] = image + return image } - symbolImage.size = targetSize - - // 使用 NSImage(size:flipped:drawingHandler:) 替代已弃用的 lockFocus/unlockFocus - let image = NSImage(size: targetSize, flipped: false) { drawRect in - NSGraphicsContext.current?.imageInterpolation = .high - symbolImage.draw(in: drawRect, from: .zero, operation: .sourceOver, fraction: 1.0) - - // 深色模式使用系统高对比淡白色,浅色模式使用系统高对比深灰色 - let tintColor = isDark - ? NSColor(calibratedWhite: 0.85, alpha: 1.0) - : NSColor(calibratedWhite: 0.17, alpha: 1.0) - tintColor.setFill() - drawRect.fill(using: .sourceAtop) - return true - } - image.isTemplate = false // 禁用 template 模式,防止系统自动二次涂色 - - symbolCache[symbolName] = image - return image } /// 无 API 警告地通过系统全局 UserDefaults 快速读取系统深浅色外观状态 From 3ba60b6c3ce71d39f6db4c852ba9ba3ce685b81c Mon Sep 17 00:00:00 2001 From: zombie Date: Sun, 26 Jul 2026 15:34:03 +0800 Subject: [PATCH 03/10] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E4=BC=A0=E8=BE=93=E6=9C=8D=E5=8A=A1=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E9=9A=94=E7=A9=BA=E6=8A=95=E9=80=81=E5=92=8C=E5=A4=8D?= =?UTF-8?q?=E5=88=B6=E5=88=B0=E5=A4=96=E9=83=A8=E5=8D=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BetterMenu.xcodeproj/project.pbxproj | 4 + BetterMenu/AppDelegate.swift | 45 +++- BetterMenu/BetterMenuShared.swift | 79 ++++++- .../Models/BetterMenuSettingsModel.swift | 7 +- BetterMenu/Services/FileTransferService.swift | 196 ++++++++++++++++++ BetterMenu/Services/IconCacheManager.swift | 2 +- .../BetterMenuGeneralSettingsViews.swift | 6 +- .../Views/BetterMenuSettingsComponents.swift | 5 +- BetterMenuFinderSync/FinderSync.swift | 141 ++++++++++++- BetterMenuFinderSync/SettingsMonitor.swift | 4 +- DEVELOPMENT.md | 3 +- README.md | 4 + 12 files changed, 480 insertions(+), 16 deletions(-) create mode 100644 BetterMenu/Services/FileTransferService.swift diff --git a/BetterMenu.xcodeproj/project.pbxproj b/BetterMenu.xcodeproj/project.pbxproj index 20a8055..8ad88a7 100644 --- a/BetterMenu.xcodeproj/project.pbxproj +++ b/BetterMenu.xcodeproj/project.pbxproj @@ -27,6 +27,7 @@ A10000000000000000000503 /* BetterMenuSettingsComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000513 /* BetterMenuSettingsComponents.swift */; }; A10000000000000000000600 /* SystemCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000601 /* SystemCommand.swift */; }; A10000000000000000000610 /* IconCacheManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000611 /* IconCacheManager.swift */; }; + A10000000000000000000620 /* FileTransferService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000621 /* FileTransferService.swift */; }; A10000000000000000000700 /* SettingsMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000701 /* SettingsMonitor.swift */; }; A10000000000000000000710 /* MenuIconManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000711 /* MenuIconManager.swift */; }; A10000000000000000000720 /* FileCreator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000721 /* FileCreator.swift */; }; @@ -84,6 +85,7 @@ A10000000000000000000513 /* BetterMenuSettingsComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BetterMenuSettingsComponents.swift; sourceTree = ""; }; A10000000000000000000601 /* SystemCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemCommand.swift; sourceTree = ""; }; A10000000000000000000611 /* IconCacheManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconCacheManager.swift; sourceTree = ""; }; + A10000000000000000000621 /* FileTransferService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileTransferService.swift; sourceTree = ""; }; A10000000000000000000701 /* SettingsMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsMonitor.swift; sourceTree = ""; }; A10000000000000000000711 /* MenuIconManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuIconManager.swift; sourceTree = ""; }; A10000000000000000000721 /* FileCreator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileCreator.swift; sourceTree = ""; }; @@ -164,6 +166,7 @@ A10000000000000000000401 /* ExternalAppLauncher.swift */, A10000000000000000000601 /* SystemCommand.swift */, A10000000000000000000611 /* IconCacheManager.swift */, + A10000000000000000000621 /* FileTransferService.swift */, ); path = Services; sourceTree = ""; @@ -330,6 +333,7 @@ A10000000000000000000300 /* BetterMenuShared.swift in Sources */, A10000000000000000000600 /* SystemCommand.swift in Sources */, A10000000000000000000610 /* IconCacheManager.swift in Sources */, + A10000000000000000000620 /* FileTransferService.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/BetterMenu/AppDelegate.swift b/BetterMenu/AppDelegate.swift index b8a924b..aa4247f 100644 --- a/BetterMenu/AppDelegate.swift +++ b/BetterMenu/AppDelegate.swift @@ -102,6 +102,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { static private(set) var shared: AppDelegate? private let logger = Logger(subsystem: "com.zombie.BetterMenu", category: "AppDelegate") + private let fileTransferService = FileTransferService() let settingsModel = BetterMenuSettingsModel() var window: NSWindow? private var initialWindowWorkItem: DispatchWorkItem? @@ -251,18 +252,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { } guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return } + let queryItems = components.queryItems ?? [] + + if url.host == "send-files" { + handleFileTransferRequest(queryItems: queryItems) + return + } // URLComponents.queryItems 会自动对参数值做百分号解码。 // 这里不能再次调用 removingPercentEncoding,否则文件名中真实存在的 "%20" // 会被误还原为空格,导致外部应用打开错误路径。 - guard let path = components.queryItems?.first(where: { $0.name == "path" })?.value else { + guard let path = queryItems.first(where: { $0.name == "path" })?.value else { logger.error("Missing path query parameter in URL: \(url.absoluteString, privacy: .public)") return } // 统一处理 run-action 指令 if url.host == "run-action" { - guard let actionId = components.queryItems?.first(where: { $0.name == "id" })?.value else { + guard let actionId = queryItems.first(where: { $0.name == "id" })?.value else { logger.error("Missing action ID in URL: \(url.absoluteString, privacy: .public)") return } @@ -278,6 +285,40 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { } } + private func handleFileTransferRequest(queryItems: [URLQueryItem]) { + let sourceUrls = queryItems + .filter { $0.name == "path" } + .compactMap(\.value) + .map { URL(fileURLWithPath: $0) } + guard !sourceUrls.isEmpty else { + logger.error("Missing source paths in file transfer request") + return + } + + guard let mode = queryItems.first(where: { $0.name == "mode" })?.value else { + logger.error("Missing mode in file transfer request") + return + } + + switch mode { + case "airdrop": + fileTransferService.sendViaAirDrop(sourceUrls: sourceUrls) + case "copy": + guard + let destinationPath = queryItems.first(where: { $0.name == "destination" })?.value + else { + logger.error("Missing destination in file copy request") + return + } + fileTransferService.copy( + sourceUrls: sourceUrls, + to: URL(fileURLWithPath: destinationPath, isDirectory: true) + ) + default: + logger.error("Unsupported file transfer mode: \(mode, privacy: .public)") + } + } + /// 分发具体 Action 到对应的业务逻辑执行器中 private func dispatchAction(id: String, path: String) { switch id { diff --git a/BetterMenu/BetterMenuShared.swift b/BetterMenu/BetterMenuShared.swift index f71d683..24d79bb 100644 --- a/BetterMenu/BetterMenuShared.swift +++ b/BetterMenu/BetterMenuShared.swift @@ -51,6 +51,51 @@ enum BetterMenuShared { .appendingPathComponent("Library/Caches/BetterMenu/icon_cache.plist") } + /// 返回当前已挂载、可写且不位于 Mac 内部磁盘上的文件传输目标。 + static func availableFileTransferVolumes( + fileManager: FileManager = .default + ) -> [FileTransferVolume] { + let resourceKeys: Set = [ + .volumeIsBrowsableKey, + .volumeIsEjectableKey, + .volumeIsInternalKey, + .volumeIsLocalKey, + .volumeIsReadOnlyKey, + .volumeIsRemovableKey, + .volumeLocalizedNameKey, + ] + let mountedVolumeUrls = + fileManager.mountedVolumeURLs( + includingResourceValuesForKeys: Array(resourceKeys), + options: [.skipHiddenVolumes] + ) ?? [] + + return mountedVolumeUrls.compactMap { volumeUrl in + guard let values = try? volumeUrl.resourceValues(forKeys: resourceKeys) else { + return nil + } + let isSupportedTarget = + values.volumeIsInternal == false + || values.volumeIsRemovable == true + || values.volumeIsEjectable == true + || values.volumeIsLocal == false + guard + values.volumeIsBrowsable != false, + isSupportedTarget, + values.volumeIsReadOnly != true, + fileManager.isWritableFile(atPath: volumeUrl.path) + else { + return nil + } + + let name = values.volumeLocalizedName ?? volumeUrl.lastPathComponent + return FileTransferVolume(name: name, url: volumeUrl.standardizedFileURL) + } + .sorted { + $0.name.localizedStandardCompare($1.name) == .orderedAscending + } + } + // MARK: - 支持的第三方编辑器信息 /// 表示支持的第三方编辑器的元数据。 @@ -316,10 +361,22 @@ struct FinderAction: Identifiable, Codable, Hashable, Sendable { var isEnabled: Bool } -/// 默认提供的快捷操作列表。 -let defaultSharedActions: [FinderAction] = [ +/// 表示“发送文件到”菜单中的一个已挂载文件系统目标。 +struct FileTransferVolume: Identifiable, Hashable, Sendable { + var id: URL { url } + let name: String + let url: URL +} + +/// 始终保留在设置页中的内置 Finder 快捷操作。 +let requiredSharedActions: [FinderAction] = [ FinderAction(id: "terminal", title: "在终端中打开", iconName: "terminal", isEnabled: true), FinderAction(id: "copyPath", title: "复制当前路径", iconName: "doc.on.doc", isEnabled: true), + FinderAction(id: "sendFile", title: "发送文件到", iconName: "paperplane", isEnabled: true), +] + +/// 默认提供的快捷操作列表。 +let defaultSharedActions: [FinderAction] = requiredSharedActions + [ FinderAction( id: "vscode", title: "在 VS Code 中打开", @@ -328,6 +385,24 @@ let defaultSharedActions: [FinderAction] = [ ), ] +/// 为旧版配置补齐后来新增的必备内置操作,同时保留用户原有的启用状态和排序。 +func actionsAddingMissingRequiredActions(_ actions: [FinderAction]) -> [FinderAction] { + var normalizedActions = actions + + for requiredAction in requiredSharedActions + where !normalizedActions.contains(where: { $0.id == requiredAction.id }) { + if requiredAction.id == "sendFile", + let copyPathIndex = normalizedActions.firstIndex(where: { $0.id == "copyPath" }) + { + normalizedActions.insert(requiredAction, at: copyPathIndex + 1) + } else { + normalizedActions.append(requiredAction) + } + } + + return normalizedActions +} + // MARK: - 公共映射函数 /// 根据传入的文件类型 ID 映射并提取标准的路径扩展名。 diff --git a/BetterMenu/Models/BetterMenuSettingsModel.swift b/BetterMenu/Models/BetterMenuSettingsModel.swift index 043bc9e..11364c4 100644 --- a/BetterMenu/Models/BetterMenuSettingsModel.swift +++ b/BetterMenu/Models/BetterMenuSettingsModel.swift @@ -243,7 +243,7 @@ final class BetterMenuSettingsModel: ObservableObject { ) ?? .menuBarOnly if let storedActions = sharedSettings.actions { - self.actions = storedActions + self.actions = actionsAddingMissingRequiredActions(storedActions) } else { // 兼容以前的硬编码布尔值 var acts = defaultSharedActions @@ -261,7 +261,7 @@ final class BetterMenuSettingsModel: ObservableObject { acts[idx].isEnabled = oldPathEnabled } // vscode 默认启用 - self.actions = acts + self.actions = actionsAddingMissingRequiredActions(acts) } let storedTerminalType = @@ -469,7 +469,7 @@ final class BetterMenuSettingsModel: ObservableObject { /// 检查某一个 action 是否在系统中已安装 func isActionInstalled(_ actionId: String) -> Bool { - if actionId == "terminal" || actionId == "copyPath" { + if requiredSharedActions.contains(where: { $0.id == actionId }) { return true } if let editor = BetterMenuShared.supportedEditors.first(where: { $0.idString == actionId }) { @@ -845,4 +845,3 @@ final class BetterMenuSettingsModel: ObservableObject { } } - diff --git a/BetterMenu/Services/FileTransferService.swift b/BetterMenu/Services/FileTransferService.swift new file mode 100644 index 0000000..53d2ae4 --- /dev/null +++ b/BetterMenu/Services/FileTransferService.swift @@ -0,0 +1,196 @@ +import Cocoa +import os + +/// 承接 Finder 扩展发起的文件传输任务,确保大文件复制不依赖扩展进程生命周期。 +@MainActor +final class FileTransferService { + private struct CopyFailure: Sendable { + let fileName: String + let message: String + } + + private struct CopyResult: Sendable { + let copiedUrls: [URL] + let failures: [CopyFailure] + } + + private let logger = Logger( + subsystem: "com.zombie.BetterMenu", category: "FileTransferService") + private var activeAirDropService: NSSharingService? + private var copyTasks: [UUID: Task] = [:] + + /// 使用系统隔空投送面板发送选中的文件或文件夹。 + func sendViaAirDrop(sourceUrls: [URL]) { + let existingUrls = existingSourceUrls(from: sourceUrls) + guard !existingUrls.isEmpty else { + showError(title: "无法发送文件", message: "选中的文件或文件夹已不存在。") + return + } + guard + let service = NSSharingService(named: .sendViaAirDrop), + service.canPerform(withItems: existingUrls) + else { + showError(title: "隔空投送不可用", message: "请确认隔空投送已开启后再试。") + return + } + + activeAirDropService = service + service.perform(withItems: existingUrls) + } + + /// 将选中的文件或文件夹异步复制到外置磁盘、U 盘或网络共享卷根目录。 + func copy(sourceUrls: [URL], to destinationUrl: URL) { + let existingUrls = existingSourceUrls(from: sourceUrls) + guard !existingUrls.isEmpty else { + showError(title: "无法发送文件", message: "选中的文件或文件夹已不存在。") + return + } + + let taskId = UUID() + let task = Task { [weak self] in + let result = await Task.detached(priority: .userInitiated) { + Self.copyItems(existingUrls, to: destinationUrl) + }.value + + guard let self else { return } + self.copyTasks.removeValue(forKey: taskId) + self.handleCopyResult(result, destinationUrl: destinationUrl) + } + copyTasks[taskId] = task + } + + private func existingSourceUrls(from urls: [URL]) -> [URL] { + let fileManager = FileManager.default + var acceptedPaths: Set = [] + return urls.compactMap { url in + let standardizedUrl = url.standardizedFileURL + guard + fileManager.fileExists(atPath: standardizedUrl.path), + acceptedPaths.insert(standardizedUrl.path).inserted + else { + return nil + } + return standardizedUrl + } + } + + private func handleCopyResult(_ result: CopyResult, destinationUrl: URL) { + if !result.copiedUrls.isEmpty { + NSWorkspace.shared.activateFileViewerSelecting(result.copiedUrls) + } + + guard !result.failures.isEmpty else { return } + + let maximumDisplayedFailures = 3 + var detailLines = result.failures.prefix(maximumDisplayedFailures).map { + "\($0.fileName):\($0.message)" + } + if result.failures.count > maximumDisplayedFailures { + detailLines.append("另有 \(result.failures.count - maximumDisplayedFailures) 个项目失败。") + } + + let destinationName = destinationUrl.lastPathComponent + showError( + title: result.copiedUrls.isEmpty ? "发送文件失败" : "部分文件发送失败", + message: "目标:\(destinationName)\n\n\(detailLines.joined(separator: "\n"))" + ) + } + + private func showError(title: String, message: String) { + logger.error("\(title, privacy: .public): \(message, privacy: .public)") + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.alertStyle = .warning + NSApp.activate() + alert.runModal() + } + + nonisolated private static func copyItems( + _ sourceUrls: [URL], + to requestedDestinationUrl: URL + ) -> CopyResult { + let fileManager = FileManager() + let destinationUrl = requestedDestinationUrl.standardizedFileURL + guard + BetterMenuShared.availableFileTransferVolumes(fileManager: fileManager) + .contains(where: { $0.url.path == destinationUrl.path }) + else { + return CopyResult( + copiedUrls: [], + failures: [ + CopyFailure( + fileName: destinationUrl.lastPathComponent, + message: "目标设备已断开、只读或不可写。" + ) + ] + ) + } + + var copiedUrls: [URL] = [] + var failures: [CopyFailure] = [] + for sourceUrl in sourceUrls { + guard !destinationIsInsideSource(destinationUrl, sourceUrl: sourceUrl) else { + failures.append( + CopyFailure(fileName: sourceUrl.lastPathComponent, message: "不能将项目复制到其自身内部。")) + continue + } + + let targetUrl = availableTargetUrl( + for: sourceUrl, + in: destinationUrl, + fileManager: fileManager + ) + do { + try fileManager.copyItem(at: sourceUrl, to: targetUrl) + copiedUrls.append(targetUrl) + } catch { + if fileManager.fileExists(atPath: targetUrl.path) { + try? fileManager.removeItem(at: targetUrl) + } + failures.append( + CopyFailure(fileName: sourceUrl.lastPathComponent, message: error.localizedDescription)) + } + } + + return CopyResult(copiedUrls: copiedUrls, failures: failures) + } + + nonisolated private static func destinationIsInsideSource( + _ destinationUrl: URL, + sourceUrl: URL + ) -> Bool { + let sourcePath = sourceUrl.standardizedFileURL.path + let destinationPath = destinationUrl.standardizedFileURL.path + return destinationPath == sourcePath || destinationPath.hasPrefix(sourcePath + "/") + } + + nonisolated private static func availableTargetUrl( + for sourceUrl: URL, + in destinationUrl: URL, + fileManager: FileManager + ) -> URL { + let originalTargetUrl = destinationUrl.appendingPathComponent(sourceUrl.lastPathComponent) + guard fileManager.fileExists(atPath: originalTargetUrl.path) else { + return originalTargetUrl + } + + let pathExtension = sourceUrl.pathExtension + let baseName = + pathExtension.isEmpty + ? sourceUrl.lastPathComponent + : sourceUrl.deletingPathExtension().lastPathComponent + var copyIndex = 2 + while true { + let candidateName = + pathExtension.isEmpty + ? "\(baseName) \(copyIndex)" + : "\(baseName) \(copyIndex).\(pathExtension)" + let candidateUrl = destinationUrl.appendingPathComponent(candidateName) + if !fileManager.fileExists(atPath: candidateUrl.path) { + return candidateUrl + } + copyIndex += 1 + } + } +} diff --git a/BetterMenu/Services/IconCacheManager.swift b/BetterMenu/Services/IconCacheManager.swift index d668d56..7740f75 100644 --- a/BetterMenu/Services/IconCacheManager.swift +++ b/BetterMenu/Services/IconCacheManager.swift @@ -42,7 +42,7 @@ enum IconCacheManager { // 2. 预热并缓存外部操作的 App 真实图标到共享缓存中,以便 FinderSync 进程直接读取 for action in activeActions { - if action.id == "terminal" || action.id == "copyPath" { + if requiredSharedActions.contains(where: { $0.id == action.id }) { continue } diff --git a/BetterMenu/Views/BetterMenuGeneralSettingsViews.swift b/BetterMenu/Views/BetterMenuGeneralSettingsViews.swift index d02a3ec..128645e 100644 --- a/BetterMenu/Views/BetterMenuGeneralSettingsViews.swift +++ b/BetterMenu/Views/BetterMenuGeneralSettingsViews.swift @@ -165,7 +165,7 @@ struct QuickAccessSettingsCard: View { } private func canRemove(_ action: FinderAction) -> Bool { - action.id != "terminal" && action.id != "copyPath" + !requiredSharedActions.contains(where: { $0.id == action.id }) } /// 弹出原生打开面板让用户手动选择可添加到 Finder 右键菜单的应用程序。 @@ -192,7 +192,7 @@ struct ActionIconView: View { let isInstalled: Bool var body: some View { - if action.id == "terminal" || action.id == "copyPath" { + if requiredSharedActions.contains(where: { $0.id == action.id }) { Image(systemName: action.iconName) .font(.system(size: 15, weight: .regular)) .symbolRenderingMode(.monochrome) @@ -318,6 +318,8 @@ struct FinderActionSettingsRow: View { return "在当前 Finder 目录启动终端。" case "copyPath": return "复制当前目录或选中项目路径。" + case "sendFile": + return "发送到隔空投送、移动硬盘、U 盘或网络共享卷。" default: return editorDescription ?? "在当前 Finder 目录中打开。" } diff --git a/BetterMenu/Views/BetterMenuSettingsComponents.swift b/BetterMenu/Views/BetterMenuSettingsComponents.swift index 48e0a08..4454bf8 100644 --- a/BetterMenu/Views/BetterMenuSettingsComponents.swift +++ b/BetterMenu/Views/BetterMenuSettingsComponents.swift @@ -70,7 +70,10 @@ extension View { @ViewBuilder func liquidGlassCard() -> some View { self - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .background( + Color(nsColor: .controlBackgroundColor), + in: RoundedRectangle(cornerRadius: 10, style: .continuous) + ) .overlay { RoundedRectangle(cornerRadius: 10, style: .continuous) .stroke(.quaternary, lineWidth: 1) diff --git a/BetterMenuFinderSync/FinderSync.swift b/BetterMenuFinderSync/FinderSync.swift index 549e8bf..3a2c5c8 100644 --- a/BetterMenuFinderSync/FinderSync.swift +++ b/BetterMenuFinderSync/FinderSync.swift @@ -14,7 +14,9 @@ final class FinderSync: FIFinderSync { // Tag 路由映射,每次构建菜单时更新,以保证点击回调可以找到对应数据 private var menuDefinitionsByTag: [Int: FileDefinition] = [:] private var actionDefinitionsByTag: [Int: FinderAction] = [:] + private var fileTransferVolumesByTag: [Int: FileTransferVolume] = [:] private static let dynamicActionTagBase = 1000 + private static let fileTransferVolumeTagBase = 2000 // MARK: - 基础图标加载 @@ -110,6 +112,7 @@ final class FinderSync: FIFinderSync { let menu = NSMenu(title: "BetterMenu") menuDefinitionsByTag.removeAll() actionDefinitionsByTag.removeAll() + fileTransferVolumesByTag.removeAll() menu.addItem(makeFileCreationMenuItem(from: snapshot)) for item in makeEnabledShortcutItems(from: snapshot) { @@ -155,7 +158,11 @@ final class FinderSync: FIFinderSync { /// 构建终端、复制路径、编辑器等一级右键快捷操作 private func makeEnabledShortcutItems(from snapshot: SettingsMonitor.SettingsSnapshot) -> [NSMenuItem] { var nextTag = Self.dynamicActionTagBase - return snapshot.actions.filter(\.isEnabled).map { action in + return snapshot.actions.filter(\.isEnabled).compactMap { action in + if action.id == "sendFile" { + return makeFileTransferMenuItem(action: action) + } + let tag = nextTag nextTag += 1 actionDefinitionsByTag[tag] = action @@ -163,6 +170,68 @@ final class FinderSync: FIFinderSync { } } + /// 构建“发送文件到”二级菜单,列出隔空投送以及当前可用的外置卷和网络共享卷。 + private func makeFileTransferMenuItem(action: FinderAction) -> NSMenuItem? { + let selectedUrls = selectedItemUrlsForFileTransfer() + guard !selectedUrls.isEmpty else { return nil } + + let parent = NSMenuItem(title: action.title, action: nil, keyEquivalent: "") + parent.image = MenuIconManager.finderMenuSymbol( + named: action.iconName, accessibilityDescription: action.title) + + let submenu = NSMenu(title: action.title) + if let airDropService = NSSharingService(named: .sendViaAirDrop), + airDropService.canPerform(withItems: selectedUrls) + { + let airDropItem = NSMenuItem( + title: "隔空投送…", + action: #selector(sendSelectedItemsViaAirDrop(_:)), + keyEquivalent: "" + ) + airDropItem.target = self + let airDropIcon = airDropService.image + airDropIcon.size = NSSize(width: 18, height: 18) + airDropItem.image = airDropIcon + submenu.addItem(airDropItem) + } + + let selectedVolumePaths = sourceVolumePaths(for: selectedUrls) + let transferVolumes = BetterMenuShared.availableFileTransferVolumes().filter { + !selectedVolumePaths.contains($0.url.path) + } + if !submenu.items.isEmpty, !transferVolumes.isEmpty { + submenu.addItem(.separator()) + } + + for (offset, volume) in transferVolumes.enumerated() { + let tag = Self.fileTransferVolumeTagBase + offset + let item = NSMenuItem( + title: volume.name, + action: #selector(copySelectedItemsToVolume(_:)), + keyEquivalent: "" + ) + item.target = self + item.tag = tag + + let volumeIcon = NSWorkspace.shared.icon(forFile: volume.url.path) + volumeIcon.size = NSSize(width: 18, height: 18) + item.image = volumeIcon + + fileTransferVolumesByTag[tag] = volume + submenu.addItem(item) + } + + if submenu.items.isEmpty { + let placeholder = NSMenuItem( + title: "未找到可用设备或磁盘", action: nil, keyEquivalent: "") + placeholder.isEnabled = false + submenu.addItem(placeholder) + } + + parent.submenu = submenu + return parent + } + private func makeShortcutItem(action: FinderAction, tag: Int) -> NSMenuItem { let item = NSMenuItem( title: action.title, action: #selector(runDynamicAction(_:)), keyEquivalent: "") @@ -308,6 +377,54 @@ final class FinderSync: FIFinderSync { } } + @objc private func sendSelectedItemsViaAirDrop(_ sender: NSMenuItem) { + openFileTransferRequest(mode: .airDrop, destination: nil) + } + + @objc private func copySelectedItemsToVolume(_ sender: NSMenuItem) { + guard let volume = fileTransferVolumesByTag[sender.tag] else { + showError(message: "所选设备已不可用,请重新打开右键菜单后再试。") + return + } + openFileTransferRequest(mode: .copy, destination: volume.url) + } + + /// 将文件传输任务交给主应用执行,避免长时间复制受 Finder 扩展生命周期影响。 + private func openFileTransferRequest(mode: FileTransferMode, destination: URL?) { + let selectedUrls = selectedItemUrlsForFileTransfer() + guard !selectedUrls.isEmpty else { + showError(message: "没有可发送的文件或文件夹。") + return + } + + var components = URLComponents() + components.scheme = "BetterMenu" + components.host = "send-files" + + var queryItems = [URLQueryItem(name: "mode", value: mode.rawValue)] + queryItems.append( + contentsOf: selectedUrls.map { URLQueryItem(name: "path", value: $0.path) }) + if let destination { + queryItems.append(URLQueryItem(name: "destination", value: destination.path)) + } + components.queryItems = queryItems + + guard let requestUrl = components.url else { + showError(message: "无法生成文件发送请求。") + return + } + + let configuration = NSWorkspace.OpenConfiguration() + configuration.activates = mode == .airDrop + configuration.promptsUserIfNeeded = true + + NSWorkspace.shared.open(requestUrl, configuration: configuration) { [weak self] _, error in + if let error { + self?.showError(message: "唤起 BetterMenu 失败:\(error.localizedDescription)") + } + } + } + // MARK: - 文件与目录处理辅助 private func createFile(from definition: FileDefinition) { @@ -347,6 +464,23 @@ final class FinderSync: FIFinderSync { } } + private func selectedItemUrlsForFileTransfer() -> [URL] { + let fileManager = FileManager.default + return FIFinderSyncController.default().selectedItemURLs()?.filter { + fileManager.fileExists(atPath: $0.path) + } ?? [] + } + + private func sourceVolumePaths(for urls: [URL]) -> Set { + Set( + urls.compactMap { url in + let values = try? url.resourceValues(forKeys: [.volumeURLKey]) + let volumeUrl = values?.allValues[.volumeURLKey] as? URL + return volumeUrl?.standardizedFileURL.path + } + ) + } + private func revealCreatedFileIfNeeded(_ fileUrl: URL, in directory: URL) { guard !directory.isDesktopDirectory else { return @@ -392,6 +526,11 @@ private enum FinderLocationIntent { case openSelection } +private enum FileTransferMode: String { + case airDrop = "airdrop" + case copy +} + extension URL { fileprivate var isDesktopDirectory: Bool { let realDesktopPath = "/Users/\(NSUserName())/Desktop" diff --git a/BetterMenuFinderSync/SettingsMonitor.swift b/BetterMenuFinderSync/SettingsMonitor.swift index 69bd7dc..c286293 100644 --- a/BetterMenuFinderSync/SettingsMonitor.swift +++ b/BetterMenuFinderSync/SettingsMonitor.swift @@ -72,7 +72,7 @@ final class SettingsMonitor: @unchecked Sendable { let finalActions: [FinderAction] if let actionsList = shared.actions { - finalActions = actionsList + finalActions = actionsAddingMissingRequiredActions(actionsList) } else { // 兼容以前的布尔设置键 var acts = defaultSharedActions @@ -85,7 +85,7 @@ final class SettingsMonitor: @unchecked Sendable { if let idx = acts.firstIndex(where: { $0.id == "copyPath" }) { acts[idx] = FinderAction(id: "copyPath", title: "复制当前路径", iconName: "doc.on.doc", isEnabled: pathVal) } - finalActions = acts + finalActions = actionsAddingMissingRequiredActions(acts) } return SettingsSnapshot( diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 1bc52ee..1d04b58 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -27,6 +27,7 @@ BetterMenu/ BetterMenuSettingsComponents.swift # 设置页共享 UI 组件与流式布局 Services/ ExternalAppLauncher.swift # 终端、VS Code 等外部应用一键拉起服务 + FileTransferService.swift # 隔空投送调度、外置卷后台复制与重名处理 TerminalApp.swift # 可选终端应用定义列表 SystemCommand.swift # 底层命令行调用 Process 进程运行封装 IconCacheManager.swift # 专职后台图标渲染、预热与序列化磁盘缓存服务 @@ -52,6 +53,7 @@ BetterMenuFinderSync/ - **主应用入口保持轻量**:`AppDelegate` 仅处理生命周期分发、AppKit 激活策略以及 URL Scheme 路由。 - **ViewModel 高度内聚**:`BetterMenuSettingsModel` 只做数据和 UI 绑定,底层任务(如图标预热、System 进程命令)一律剥离成独立业务服务(如 `IconCacheManager`,`SystemCommand`)。 - **FinderSync 扩展轻量化**:`FinderSync` 类仅扮演控制器角色。偏好读取与目录监听由 `SettingsMonitor` 托管,图标解析与渲染由 `MenuIconManager` 托管,文件生成由 `FileCreator` 托管。 +- **文件发送链路**:FinderSync 只负责读取当前选择、发现已挂载可写卷并构造菜单;隔空投送与文件复制通过 URL Scheme 交给主应用的 `FileTransferService` 执行,避免大文件任务依赖扩展进程生命周期。复制目标仅允许已挂载的非内部可写卷,发生重名时生成递增后缀,任务完成后由访达定位结果。 - **Swift 6 Concurrency 并发安全**:编写异步或跨 Target 通信的工具类时,必须按照 Swift 6 安全规范进行严格类型隔离,或使用锁机制辅以 `nonisolated(unsafe)` 消除编译警告。 - **Xcode 项目同步**:新增 Swift 源文件后,必须同步注册在 `BetterMenu.xcodeproj/project.pbxproj` 对应的 Target 编译阶段。 - **文档维护**:更改项目行为、扩展逻辑或业务边界时,必须同步修改本指南及 `README.md`。 @@ -90,4 +92,3 @@ BetterMenuFinderSync/ 1. **配置公钥**:将生成的 **`SUPublicEDKey`(公钥)** 填入项目中的 `BetterMenu/Info.plist` 的 `SUPublicEDKey` 键值中。 2. **配置私钥**:将生成的 **`SUPrivateEDKey`(私钥)** 配置到您的 GitHub 仓库的 Secrets 中,变量名设置为 `SPARKLE_PRIVATE_KEY`。 3. **触发自动化部署**:每次推送以 `v` 开头的 Tag(如 `v1.0.1`),GitHub Actions 工作流将自动使用此私钥对应用包进行签名,生成描述文件 `appcast.xml`,并随 Release 一同分发。客户端将通过 `https://github.com/zombieht/BetterMenu/releases/latest/download/appcast.xml` 获取最新的更新信息并下载安全的更新包。 - diff --git a/README.md b/README.md index 3ff5b3a..ca63edd 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,10 @@ - 支持主流代码编辑器一键拉起:**VS Code**、**Cursor**、**OpenAI Codex**、**Xcode**、**Sublime Text**、**IntelliJ IDEA**、**WebStorm**、**PyCharm**、**Android Studio**、**CotEditor**。 - 📋 **一键复制路径**: - 快速复制当前访达目录或选中文件的绝对路径。 +- 📤 **发送文件到其他设备**: + - 右键选中的文件或文件夹,可通过隔空投送发送到附近的 Mac、iPhone 或 iPad。 + - 支持直接复制到已挂载且可写的移动硬盘、U 盘和网络共享卷,并支持一次发送多个项目。 + - 目标位置存在同名项目时自动生成不冲突的文件名,完成后在访达中定位结果。 - ⚙️ **SwiftUI 设置面板与状态栏菜单**: - 现代化、清爽的设置界面,支持调整右键菜单项的启用状态与排序,即时同步生效。 - 支持通过系统状态栏菜单快速调起面板、重启 Finder 访达、或退出软件。 From 4a1813c68b6efa4fb531882be52f847b6819d681 Mon Sep 17 00:00:00 2001 From: zombie Date: Sun, 26 Jul 2026 15:55:32 +0800 Subject: [PATCH 04/10] Enhance security and functionality of BetterMenu - Added support for secure request handling between Finder extension and main application using a temporary plist file. - Implemented a new request structure to encapsulate actions and file transfers, ensuring safe communication. - Updated URL scheme handling to reject direct parameter passing for sensitive data. - Introduced AppleScript path encoding to prevent syntax alteration. - Added unit tests for secure request lifecycle, including validation of permissions and path formats. - Improved error handling and logging for better debugging and user feedback. --- .github/workflows/ci.yml | 12 +- BetterMenu.xcodeproj/project.pbxproj | 100 +++++ .../xcschemes/BetterMenu.xcscheme | 27 ++ BetterMenu/AppDelegate.swift | 98 ++--- BetterMenu/BetterMenuShared.swift | 371 ++++++++++++++++++ .../Services/AppleScriptPathArgument.swift | 41 ++ BetterMenu/Services/ExternalAppLauncher.swift | 12 +- BetterMenuFinderSync/FinderSync.swift | 73 ++-- BetterMenuTests/BetterMenuSecurityTests.swift | 185 +++++++++ DEVELOPMENT.md | 29 ++ 10 files changed, 838 insertions(+), 110 deletions(-) create mode 100644 BetterMenu/Services/AppleScriptPathArgument.swift create mode 100644 BetterMenuTests/BetterMenuSecurityTests.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0395d3f..3e9937f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,17 @@ jobs: run: | xcodebuild -version - # 步骤 2.5:安装 create-dmg 以启用精美 DMG 打包布局 + # 步骤 2.5:运行安全通信、路径处理与设置迁移单元测试 + - name: Run Tests + run: | + xcodebuild \ + -project BetterMenu.xcodeproj \ + -scheme BetterMenu \ + -configuration Debug \ + -destination 'platform=macOS' \ + test + + # 步骤 2.6:安装 create-dmg 以启用精美 DMG 打包布局 - name: Install create-dmg run: | brew install create-dmg diff --git a/BetterMenu.xcodeproj/project.pbxproj b/BetterMenu.xcodeproj/project.pbxproj index 8ad88a7..1688aea 100644 --- a/BetterMenu.xcodeproj/project.pbxproj +++ b/BetterMenu.xcodeproj/project.pbxproj @@ -28,11 +28,15 @@ A10000000000000000000600 /* SystemCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000601 /* SystemCommand.swift */; }; A10000000000000000000610 /* IconCacheManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000611 /* IconCacheManager.swift */; }; A10000000000000000000620 /* FileTransferService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000621 /* FileTransferService.swift */; }; + A10000000000000000000630 /* AppleScriptPathArgument.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000631 /* AppleScriptPathArgument.swift */; }; A10000000000000000000700 /* SettingsMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000701 /* SettingsMonitor.swift */; }; A10000000000000000000710 /* MenuIconManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000711 /* MenuIconManager.swift */; }; A10000000000000000000720 /* FileCreator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000721 /* FileCreator.swift */; }; A65100FA5752180F6B4730FB /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 529E8C462C2C9F3A67CF1C58 /* Sparkle */; }; A10000000000000000000093 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000090 /* InfoPlist.strings */; }; + B20000000000000000000001 /* BetterMenuSecurityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000012 /* BetterMenuSecurityTests.swift */; }; + B20000000000000000000003 /* BetterMenuShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000301 /* BetterMenuShared.swift */; }; + B20000000000000000000004 /* AppleScriptPathArgument.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000631 /* AppleScriptPathArgument.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -86,11 +90,14 @@ A10000000000000000000601 /* SystemCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemCommand.swift; sourceTree = ""; }; A10000000000000000000611 /* IconCacheManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconCacheManager.swift; sourceTree = ""; }; A10000000000000000000621 /* FileTransferService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileTransferService.swift; sourceTree = ""; }; + A10000000000000000000631 /* AppleScriptPathArgument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleScriptPathArgument.swift; sourceTree = ""; }; A10000000000000000000701 /* SettingsMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsMonitor.swift; sourceTree = ""; }; A10000000000000000000711 /* MenuIconManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuIconManager.swift; sourceTree = ""; }; A10000000000000000000721 /* FileCreator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileCreator.swift; sourceTree = ""; }; A10000000000000000000091 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = ""; }; A10000000000000000000092 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; sourceTree = ""; }; + B20000000000000000000011 /* BetterMenuTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BetterMenuTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + B20000000000000000000012 /* BetterMenuSecurityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BetterMenuSecurityTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -109,6 +116,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + B20000000000000000000020 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -117,6 +131,7 @@ children = ( A10000000000000000000022 /* BetterMenu */, A10000000000000000000023 /* BetterMenuFinderSync */, + B20000000000000000000021 /* BetterMenuTests */, A10000000000000000000020 /* README.md */, A10000000000000000000024 /* Products */, ); @@ -167,6 +182,7 @@ A10000000000000000000601 /* SystemCommand.swift */, A10000000000000000000611 /* IconCacheManager.swift */, A10000000000000000000621 /* FileTransferService.swift */, + A10000000000000000000631 /* AppleScriptPathArgument.swift */, ); path = Services; sourceTree = ""; @@ -192,10 +208,19 @@ children = ( A10000000000000000000011 /* BetterMenu.app */, A10000000000000000000012 /* BetterMenuFinderSync.appex */, + B20000000000000000000011 /* BetterMenuTests.xctest */, ); name = Products; sourceTree = ""; }; + B20000000000000000000021 /* BetterMenuTests */ = { + isa = PBXGroup; + children = ( + B20000000000000000000012 /* BetterMenuSecurityTests.swift */, + ); + path = BetterMenuTests; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXVariantGroup section */ @@ -250,6 +275,23 @@ productReference = A10000000000000000000012 /* BetterMenuFinderSync.appex */; productType = "com.apple.product-type.app-extension"; }; + B20000000000000000000031 /* BetterMenuTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = B20000000000000000000082 /* Build configuration list for PBXNativeTarget "BetterMenuTests" */; + buildPhases = ( + B20000000000000000000051 /* Sources */, + B20000000000000000000020 /* Frameworks */, + B20000000000000000000052 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = BetterMenuTests; + productName = BetterMenuTests; + productReference = B20000000000000000000011 /* BetterMenuTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -268,6 +310,10 @@ CreatedOnToolsVersion = 15.3; ProvisioningStyle = Automatic; }; + B20000000000000000000031 = { + CreatedOnToolsVersion = 16.0; + ProvisioningStyle = Automatic; + }; }; }; buildConfigurationList = A10000000000000000000081 /* Build configuration list for PBXProject "BetterMenu" */; @@ -289,6 +335,7 @@ targets = ( A10000000000000000000031 /* BetterMenu */, A10000000000000000000032 /* BetterMenuFinderSync */, + B20000000000000000000031 /* BetterMenuTests */, ); }; /* End PBXProject section */ @@ -313,6 +360,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + B20000000000000000000052 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -334,6 +388,7 @@ A10000000000000000000600 /* SystemCommand.swift in Sources */, A10000000000000000000610 /* IconCacheManager.swift in Sources */, A10000000000000000000620 /* FileTransferService.swift in Sources */, + A10000000000000000000630 /* AppleScriptPathArgument.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -349,6 +404,16 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + B20000000000000000000051 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B20000000000000000000001 /* BetterMenuSecurityTests.swift in Sources */, + B20000000000000000000003 /* BetterMenuShared.swift in Sources */, + B20000000000000000000004 /* AppleScriptPathArgument.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -568,6 +633,32 @@ }; name = Release; }; + B20000000000000000000071 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Automatic; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 15.0; + PRODUCT_BUNDLE_IDENTIFIER = com.zombie.BetterMenuTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 6.0; + }; + name = Debug; + }; + B20000000000000000000072 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Automatic; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 15.0; + PRODUCT_BUNDLE_IDENTIFIER = com.zombie.BetterMenuTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 6.0; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -598,6 +689,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + B20000000000000000000082 /* Build configuration list for PBXNativeTarget "BetterMenuTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B20000000000000000000071 /* Debug */, + B20000000000000000000072 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/BetterMenu.xcodeproj/xcshareddata/xcschemes/BetterMenu.xcscheme b/BetterMenu.xcodeproj/xcshareddata/xcschemes/BetterMenu.xcscheme index 97ca983..ec2b39d 100644 --- a/BetterMenu.xcodeproj/xcshareddata/xcschemes/BetterMenu.xcscheme +++ b/BetterMenu.xcodeproj/xcshareddata/xcschemes/BetterMenu.xcscheme @@ -34,6 +34,20 @@ ReferencedContainer = "container:BetterMenu.xcodeproj"> + + + + + + + + + + BetterMenuSecureRequest { + BetterMenuSecureRequest( + version: currentVersion, + id: id, + createdAt: createdAt, + operation: .runAction, + actionId: actionId, + sourcePaths: [path], + transferMode: nil, + destinationPath: nil + ) + } + + static func fileTransfer( + sourcePaths: [String], + mode: BetterMenuFileTransferMode, + destinationPath: String?, + id: UUID = UUID(), + createdAt: TimeInterval = Date().timeIntervalSince1970 + ) -> BetterMenuSecureRequest { + BetterMenuSecureRequest( + version: currentVersion, + id: id, + createdAt: createdAt, + operation: .fileTransfer, + actionId: nil, + sourcePaths: sourcePaths, + transferMode: mode, + destinationPath: destinationPath + ) + } +} + +/// 一次性请求文件的写入、URL 生成和安全消费错误。 +enum BetterMenuSecureRequestError: LocalizedError { + case invalidLaunchUrl + case invalidRequest + case requestExpired + case requestFileUnavailable + case insecureRequestFile + + var errorDescription: String? { + switch self { + case .invalidLaunchUrl: + return "请求链接无效。" + case .invalidRequest: + return "请求内容无效。" + case .requestExpired: + return "请求已过期。" + case .requestFileUnavailable: + return "请求文件不存在或无法读取。" + case .insecureRequestFile: + return "请求文件未通过安全检查。" + } + } +} + +/// 通过仅包含不透明 UUID 的 URL Scheme,在 Finder 扩展与主应用间传递一次性请求。 +enum BetterMenuSecureRequestStore { + private static let maximumRequestAge: TimeInterval = 30 + private static let maximumFutureClockSkew: TimeInterval = 5 + private static let maximumRequestFileSize: off_t = 1_048_576 + private static let requestFileExtension = "plist" + private static let launchScheme = "bettermenu" + private static let launchHost = "request" + + static func submit( + _ request: BetterMenuSecureRequest, + directoryUrl: URL = BetterMenuShared.secureRequestDirectoryUrl + ) throws { + try validateStructure(request) + try prepareRequestDirectory(directoryUrl) + removeExpiredFiles(in: directoryUrl) + + let data = try PropertyListEncoder().encode(request) + let requestUrl = fileUrl(for: request.id, directoryUrl: directoryUrl) + try writeSecurely(data, to: requestUrl) + } + + static func launchUrl(for requestId: UUID) throws -> URL { + var components = URLComponents() + components.scheme = launchScheme + components.host = launchHost + components.queryItems = [ + URLQueryItem(name: "id", value: requestId.uuidString.lowercased()) + ] + guard let url = components.url else { + throw BetterMenuSecureRequestError.invalidLaunchUrl + } + return url + } + + static func requestId(from launchUrl: URL) throws -> UUID { + guard + launchUrl.scheme?.lowercased() == launchScheme, + launchUrl.host?.lowercased() == launchHost, + let components = URLComponents(url: launchUrl, resolvingAgainstBaseURL: false), + components.user == nil, + components.password == nil, + components.port == nil, + components.path.isEmpty, + components.fragment == nil, + let queryItems = components.queryItems, + queryItems.count == 1, + queryItems[0].name == "id", + let value = queryItems[0].value, + let requestId = UUID(uuidString: value) + else { + throw BetterMenuSecureRequestError.invalidLaunchUrl + } + return requestId + } + + static func consume( + requestId: UUID, + directoryUrl: URL = BetterMenuShared.secureRequestDirectoryUrl, + now: TimeInterval = Date().timeIntervalSince1970 + ) throws -> BetterMenuSecureRequest { + try validateRequestDirectory(directoryUrl) + let requestUrl = fileUrl(for: requestId, directoryUrl: directoryUrl) + let fileDescriptor = open(requestUrl.path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW) + guard fileDescriptor >= 0 else { + throw BetterMenuSecureRequestError.requestFileUnavailable + } + + var shouldCloseDescriptor = true + defer { + if shouldCloseDescriptor { + close(fileDescriptor) + } + } + + var fileStatus = stat() + guard fstat(fileDescriptor, &fileStatus) == 0 else { + throw BetterMenuSecureRequestError.requestFileUnavailable + } + + let isRegularFile = (fileStatus.st_mode & S_IFMT) == S_IFREG + let hasRestrictedPermissions = (fileStatus.st_mode & 0o077) == 0 + guard + isRegularFile, + fileStatus.st_uid == getuid(), + hasRestrictedPermissions, + fileStatus.st_size > 0, + fileStatus.st_size <= maximumRequestFileSize + else { + throw BetterMenuSecureRequestError.insecureRequestFile + } + + guard unlink(requestUrl.path) == 0 else { + throw BetterMenuSecureRequestError.requestFileUnavailable + } + + let fileHandle = FileHandle(fileDescriptor: fileDescriptor, closeOnDealloc: true) + shouldCloseDescriptor = false + let data = try fileHandle.readToEnd() ?? Data() + try fileHandle.close() + + let request = try PropertyListDecoder().decode(BetterMenuSecureRequest.self, from: data) + guard request.id == requestId else { + throw BetterMenuSecureRequestError.invalidRequest + } + try validateStructure(request) + + let requestAge = now - request.createdAt + guard + requestAge >= -maximumFutureClockSkew, + requestAge <= maximumRequestAge + else { + throw BetterMenuSecureRequestError.requestExpired + } + + return request + } + + static func discard( + requestId: UUID, + directoryUrl: URL = BetterMenuShared.secureRequestDirectoryUrl + ) { + try? FileManager.default.removeItem(at: fileUrl(for: requestId, directoryUrl: directoryUrl)) + } + + private static func prepareRequestDirectory(_ directoryUrl: URL) throws { + try FileManager.default.createDirectory( + at: directoryUrl, + withIntermediateDirectories: true + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o700], + ofItemAtPath: directoryUrl.path + ) + try validateRequestDirectory(directoryUrl) + } + + private static func validateRequestDirectory(_ directoryUrl: URL) throws { + var directoryStatus = stat() + guard + lstat(directoryUrl.path, &directoryStatus) == 0, + (directoryStatus.st_mode & S_IFMT) == S_IFDIR, + directoryStatus.st_uid == getuid(), + (directoryStatus.st_mode & 0o077) == 0 + else { + throw BetterMenuSecureRequestError.insecureRequestFile + } + } + + private static func writeSecurely(_ data: Data, to requestUrl: URL) throws { + let fileDescriptor = open( + requestUrl.path, + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, + mode_t(0o600) + ) + guard fileDescriptor >= 0 else { + throw BetterMenuSecureRequestError.requestFileUnavailable + } + + var didFinishWriting = false + defer { + close(fileDescriptor) + if !didFinishWriting { + unlink(requestUrl.path) + } + } + + try data.withUnsafeBytes { rawBuffer in + guard let baseAddress = rawBuffer.baseAddress else { + throw BetterMenuSecureRequestError.invalidRequest + } + + var totalBytesWritten = 0 + while totalBytesWritten < rawBuffer.count { + let bytesWritten = Darwin.write( + fileDescriptor, + baseAddress.advanced(by: totalBytesWritten), + rawBuffer.count - totalBytesWritten + ) + if bytesWritten < 0, errno == EINTR { + continue + } + guard bytesWritten > 0 else { + throw BetterMenuSecureRequestError.requestFileUnavailable + } + totalBytesWritten += bytesWritten + } + } + + guard fsync(fileDescriptor) == 0 else { + throw BetterMenuSecureRequestError.requestFileUnavailable + } + didFinishWriting = true + } + + private static func fileUrl(for requestId: UUID, directoryUrl: URL) -> URL { + directoryUrl + .appendingPathComponent(requestId.uuidString.lowercased()) + .appendingPathExtension(requestFileExtension) + } + + private static func validateStructure(_ request: BetterMenuSecureRequest) throws { + guard + request.version == BetterMenuSecureRequest.currentVersion, + request.sourcePaths.allSatisfy(isStandardAbsolutePath(_:)) + else { + throw BetterMenuSecureRequestError.invalidRequest + } + + switch request.operation { + case .runAction: + guard + let actionId = request.actionId, + !actionId.isEmpty, + request.sourcePaths.count == 1, + request.transferMode == nil, + request.destinationPath == nil + else { + throw BetterMenuSecureRequestError.invalidRequest + } + case .fileTransfer: + guard + request.actionId == nil, + !request.sourcePaths.isEmpty, + let transferMode = request.transferMode + else { + throw BetterMenuSecureRequestError.invalidRequest + } + + switch transferMode { + case .airDrop: + guard request.destinationPath == nil else { + throw BetterMenuSecureRequestError.invalidRequest + } + case .copy: + guard + let destinationPath = request.destinationPath, + isStandardAbsolutePath(destinationPath) + else { + throw BetterMenuSecureRequestError.invalidRequest + } + } + } + } + + private static func isStandardAbsolutePath(_ path: String) -> Bool { + guard path.hasPrefix("/") else { return false } + return URL(fileURLWithPath: path).standardizedFileURL.path == path + } + + private static func removeExpiredFiles(in directoryUrl: URL) { + let expirationDate = Date().addingTimeInterval(-maximumRequestAge * 2) + guard + let fileUrls = try? FileManager.default.contentsOfDirectory( + at: directoryUrl, + includingPropertiesForKeys: [.contentModificationDateKey, .isRegularFileKey], + options: [.skipsHiddenFiles] + ) + else { + return + } + + for fileUrl in fileUrls where fileUrl.pathExtension == requestFileExtension { + guard + let values = try? fileUrl.resourceValues( + forKeys: [.contentModificationDateKey, .isRegularFileKey]), + values.isRegularFile == true, + let modificationDate = values.contentModificationDate, + modificationDate < expirationDate + else { + continue + } + try? FileManager.default.removeItem(at: fileUrl) + } + } +} + /// 表示可以从 Finder 右键菜单创建的文件类型及其模板定义。 struct FileDefinition: Identifiable, Codable, Hashable, Sendable { let id: String diff --git a/BetterMenu/Services/AppleScriptPathArgument.swift b/BetterMenu/Services/AppleScriptPathArgument.swift new file mode 100644 index 0000000..3bba776 --- /dev/null +++ b/BetterMenu/Services/AppleScriptPathArgument.swift @@ -0,0 +1,41 @@ +import Foundation + +/// 将任意合法文件路径编码为不会改变 AppleScript 语法结构的字符串表达式。 +enum AppleScriptPathArgument { + static func sourceLiteral(for value: String) -> String { + var expressions: [String] = [] + var currentText = "" + + func appendCurrentTextIfNeeded() { + guard !currentText.isEmpty else { return } + expressions.append("\"\(currentText)\"") + currentText.removeAll(keepingCapacity: true) + } + + for scalar in value.unicodeScalars { + switch scalar.value { + case 0x09: + appendCurrentTextIfNeeded() + expressions.append("tab") + case 0x0A: + appendCurrentTextIfNeeded() + expressions.append("linefeed") + case 0x0D: + appendCurrentTextIfNeeded() + expressions.append("return") + case 0x00...0x1F, 0x7F: + appendCurrentTextIfNeeded() + expressions.append("character id \(scalar.value)") + case 0x22: + currentText.append(contentsOf: "\\\"") + case 0x5C: + currentText.append(contentsOf: "\\\\") + default: + currentText.append(contentsOf: String(scalar)) + } + } + + appendCurrentTextIfNeeded() + return expressions.isEmpty ? "\"\"" : expressions.joined(separator: " & ") + } +} diff --git a/BetterMenu/Services/ExternalAppLauncher.swift b/BetterMenu/Services/ExternalAppLauncher.swift index baf2b1f..1e42930 100644 --- a/BetterMenu/Services/ExternalAppLauncher.swift +++ b/BetterMenu/Services/ExternalAppLauncher.swift @@ -74,19 +74,21 @@ struct ExternalAppLauncher { return } + let pathLiteral = AppleScriptPathArgument.sourceLiteral(for: path) let scriptText = """ tell application "iTerm" + set targetPath to \(pathLiteral) activate if (count of windows) is 0 then set newWindow to (create window with default profile) tell current session of newWindow - write text "cd " & quoted form of "\(path)" + write text "cd " & quoted form of targetPath end tell else tell current window create tab with default profile tell current session - write text "cd " & quoted form of "\(path)" + write text "cd " & quoted form of targetPath end tell end tell end if @@ -135,14 +137,16 @@ struct ExternalAppLauncher { /// Terminal 已运行时,通过 AppleScript 在前台窗口新建 tab 并 cd 到目标路径。 private func runTerminalNewTabScript(path: String) -> Bool { + let pathLiteral = AppleScriptPathArgument.sourceLiteral(for: path) let scriptText = """ tell application "Terminal" + set targetPath to \(pathLiteral) if (count of windows) > 0 then tell front window - set newTab to do script "cd " & quoted form of "\(path)" + set newTab to do script "cd " & quoted form of targetPath end tell else - do script "cd " & quoted form of "\(path)" + do script "cd " & quoted form of targetPath end if activate end tell diff --git a/BetterMenuFinderSync/FinderSync.swift b/BetterMenuFinderSync/FinderSync.swift index 3a2c5c8..ad736df 100644 --- a/BetterMenuFinderSync/FinderSync.swift +++ b/BetterMenuFinderSync/FinderSync.swift @@ -352,29 +352,11 @@ final class FinderSync: FIFinderSync { return } - var components = URLComponents() - components.scheme = "BetterMenu" - components.host = "run-action" - components.queryItems = [ - URLQueryItem(name: "id", value: action.id), - URLQueryItem(name: "path", value: resolvedUrl.path), - ] - - guard let requestUrl = components.url else { - showError(message: "无法生成请求指令。") - return - } - - let configuration = NSWorkspace.OpenConfiguration() - configuration.activates = true - configuration.promptsUserIfNeeded = true - - NSWorkspace.shared.open(requestUrl, configuration: configuration) { [weak self] _, error in - if let error = error { - self?.showError(message: "唤起 BetterMenu 失败:\(error.localizedDescription)") - return - } - } + let request = BetterMenuSecureRequest.runAction( + actionId: action.id, + path: resolvedUrl.standardizedFileURL.path + ) + openMainApplication(with: request, activates: true) } @objc private func sendSelectedItemsViaAirDrop(_ sender: NSMenuItem) { @@ -390,36 +372,46 @@ final class FinderSync: FIFinderSync { } /// 将文件传输任务交给主应用执行,避免长时间复制受 Finder 扩展生命周期影响。 - private func openFileTransferRequest(mode: FileTransferMode, destination: URL?) { + private func openFileTransferRequest( + mode: BetterMenuFileTransferMode, + destination: URL? + ) { let selectedUrls = selectedItemUrlsForFileTransfer() guard !selectedUrls.isEmpty else { showError(message: "没有可发送的文件或文件夹。") return } - var components = URLComponents() - components.scheme = "BetterMenu" - components.host = "send-files" - - var queryItems = [URLQueryItem(name: "mode", value: mode.rawValue)] - queryItems.append( - contentsOf: selectedUrls.map { URLQueryItem(name: "path", value: $0.path) }) - if let destination { - queryItems.append(URLQueryItem(name: "destination", value: destination.path)) - } - components.queryItems = queryItems + let request = BetterMenuSecureRequest.fileTransfer( + sourcePaths: selectedUrls.map { $0.standardizedFileURL.path }, + mode: mode, + destinationPath: destination?.standardizedFileURL.path + ) + openMainApplication(with: request, activates: mode == .airDrop) + } - guard let requestUrl = components.url else { - showError(message: "无法生成文件发送请求。") + /// 写入一次性请求后,仅通过不透明 UUID 唤起主应用。 + private func openMainApplication( + with request: BetterMenuSecureRequest, + activates: Bool + ) { + let requestUrl: URL + do { + try BetterMenuSecureRequestStore.submit(request) + requestUrl = try BetterMenuSecureRequestStore.launchUrl(for: request.id) + } catch { + BetterMenuSecureRequestStore.discard(requestId: request.id) + showError(message: "无法安全地提交请求:\(error.localizedDescription)") return } let configuration = NSWorkspace.OpenConfiguration() - configuration.activates = mode == .airDrop + configuration.activates = activates configuration.promptsUserIfNeeded = true NSWorkspace.shared.open(requestUrl, configuration: configuration) { [weak self] _, error in if let error { + BetterMenuSecureRequestStore.discard(requestId: request.id) self?.showError(message: "唤起 BetterMenu 失败:\(error.localizedDescription)") } } @@ -526,11 +518,6 @@ private enum FinderLocationIntent { case openSelection } -private enum FileTransferMode: String { - case airDrop = "airdrop" - case copy -} - extension URL { fileprivate var isDesktopDirectory: Bool { let realDesktopPath = "/Users/\(NSUserName())/Desktop" diff --git a/BetterMenuTests/BetterMenuSecurityTests.swift b/BetterMenuTests/BetterMenuSecurityTests.swift new file mode 100644 index 0000000..5af7668 --- /dev/null +++ b/BetterMenuTests/BetterMenuSecurityTests.swift @@ -0,0 +1,185 @@ +import Cocoa +import XCTest + +final class BetterMenuSecurityTests: XCTestCase { + func testSecureRequestRoundTripConsumesFileOnce() throws { + let directoryUrl = try makeTemporaryDirectory() + let request = BetterMenuSecureRequest.runAction( + actionId: "terminal", + path: "/Users/test/Project", + createdAt: 100 + ) + + try BetterMenuSecureRequestStore.submit(request, directoryUrl: directoryUrl) + let consumedRequest = try BetterMenuSecureRequestStore.consume( + requestId: request.id, + directoryUrl: directoryUrl, + now: 101 + ) + + XCTAssertEqual(consumedRequest, request) + XCTAssertThrowsError( + try BetterMenuSecureRequestStore.consume( + requestId: request.id, + directoryUrl: directoryUrl, + now: 101 + ) + ) + } + + func testSecureRequestRejectsExpiredPayload() throws { + let directoryUrl = try makeTemporaryDirectory() + let request = BetterMenuSecureRequest.runAction( + actionId: "terminal", + path: "/Users/test/Project", + createdAt: 100 + ) + + try BetterMenuSecureRequestStore.submit(request, directoryUrl: directoryUrl) + + XCTAssertThrowsError( + try BetterMenuSecureRequestStore.consume( + requestId: request.id, + directoryUrl: directoryUrl, + now: 131 + ) + ) + } + + func testSecureRequestRejectsBroadFilePermissions() throws { + let directoryUrl = try makeTemporaryDirectory() + let request = BetterMenuSecureRequest.runAction( + actionId: "terminal", + path: "/Users/test/Project" + ) + try BetterMenuSecureRequestStore.submit(request, directoryUrl: directoryUrl) + + let requestUrl = + directoryUrl + .appendingPathComponent(request.id.uuidString.lowercased()) + .appendingPathExtension("plist") + try FileManager.default.setAttributes( + [.posixPermissions: 0o644], + ofItemAtPath: requestUrl.path + ) + + XCTAssertThrowsError( + try BetterMenuSecureRequestStore.consume( + requestId: request.id, + directoryUrl: directoryUrl + ) + ) + } + + func testSecureRequestRejectsBroadDirectoryPermissions() throws { + let directoryUrl = try makeTemporaryDirectory() + let request = BetterMenuSecureRequest.runAction( + actionId: "terminal", + path: "/Users/test/Project" + ) + try BetterMenuSecureRequestStore.submit(request, directoryUrl: directoryUrl) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: directoryUrl.path + ) + + XCTAssertThrowsError( + try BetterMenuSecureRequestStore.consume( + requestId: request.id, + directoryUrl: directoryUrl + ) + ) + } + + func testLaunchUrlContainsOnlyOpaqueRequestIdentifier() throws { + let requestId = UUID() + let url = try BetterMenuSecureRequestStore.launchUrl(for: requestId) + let parsedRequestId = try BetterMenuSecureRequestStore.requestId(from: url) + + XCTAssertEqual(parsedRequestId, requestId) + XCTAssertEqual(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.count, 1) + + let legacyUrl = try XCTUnwrap( + URL(string: "bettermenu://run-action?id=terminal&path=/Users/test/Secret")) + XCTAssertThrowsError(try BetterMenuSecureRequestStore.requestId(from: legacyUrl)) + + let urlWithExtraParameter = try XCTUnwrap( + URL(string: "\(url.absoluteString)&path=/Users/test/Secret")) + XCTAssertThrowsError(try BetterMenuSecureRequestStore.requestId(from: urlWithExtraParameter)) + + let urlWithExtraPath = try XCTUnwrap( + URL(string: "bettermenu://request/extra?id=\(requestId.uuidString)")) + XCTAssertThrowsError(try BetterMenuSecureRequestStore.requestId(from: urlWithExtraPath)) + } + + func testSecureRequestRejectsNonStandardPath() throws { + let directoryUrl = try makeTemporaryDirectory() + let request = BetterMenuSecureRequest.runAction( + actionId: "terminal", + path: "/Users/test/../Secret" + ) + + XCTAssertThrowsError( + try BetterMenuSecureRequestStore.submit(request, directoryUrl: directoryUrl) + ) + } + + func testCopyRequestRequiresDestination() throws { + let directoryUrl = try makeTemporaryDirectory() + let request = BetterMenuSecureRequest.fileTransfer( + sourcePaths: ["/Users/test/Document.txt"], + mode: .copy, + destinationPath: nil + ) + + XCTAssertThrowsError( + try BetterMenuSecureRequestStore.submit(request, directoryUrl: directoryUrl) + ) + } + + func testAppleScriptPathLiteralRoundTripsSpecialCharacters() throws { + let path = "/tmp/quote\" slash\\ single' ampersand&\nline\rreturn\ttab" + let literal = AppleScriptPathArgument.sourceLiteral(for: path) + let script = try XCTUnwrap(NSAppleScript(source: "return \(literal)")) + var errorInfo: NSDictionary? + + let result = script.executeAndReturnError(&errorInfo) + + XCTAssertNil(errorInfo) + XCTAssertEqual(result.stringValue, path) + } + + func testAppleScriptPathLiteralDoesNotExecuteInjectedSource() throws { + let path = "/tmp/\" & do shell script \"exit 99\" & \"" + let literal = AppleScriptPathArgument.sourceLiteral(for: path) + let script = try XCTUnwrap(NSAppleScript(source: "return \(literal)")) + var errorInfo: NSDictionary? + + let result = script.executeAndReturnError(&errorInfo) + + XCTAssertNil(errorInfo) + XCTAssertEqual(result.stringValue, path) + } + + func testRequiredActionsMigrationPreservesExistingOrderAndState() { + let existingActions = [ + FinderAction(id: "copyPath", title: "复制当前路径", iconName: "doc.on.doc", isEnabled: false), + FinderAction(id: "terminal", title: "在终端中打开", iconName: "terminal", isEnabled: true), + ] + + let migratedActions = actionsAddingMissingRequiredActions(existingActions) + + XCTAssertEqual(migratedActions.map(\.id), ["copyPath", "sendFile", "terminal"]) + XCTAssertEqual(migratedActions.first(where: { $0.id == "copyPath" })?.isEnabled, false) + } + + private func makeTemporaryDirectory() throws -> URL { + let directoryUrl = FileManager.default.temporaryDirectory + .appendingPathComponent("BetterMenuTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directoryUrl, withIntermediateDirectories: true) + addTeardownBlock { + try? FileManager.default.removeItem(at: directoryUrl) + } + return directoryUrl + } +} diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 1d04b58..ca5bbed 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -26,6 +26,7 @@ BetterMenu/ BetterMenuPermissionAboutViews.swift # 权限、Finder 重启与关于页面 BetterMenuSettingsComponents.swift # 设置页共享 UI 组件与流式布局 Services/ + AppleScriptPathArgument.swift # AppleScript 文件路径安全编码,防止路径改变脚本语法 ExternalAppLauncher.swift # 终端、VS Code 等外部应用一键拉起服务 FileTransferService.swift # 隔空投送调度、外置卷后台复制与重名处理 TerminalApp.swift # 可选终端应用定义列表 @@ -38,6 +39,9 @@ BetterMenuFinderSync/ MenuIconManager.swift # 磁盘缓存图标加载、SF Symbol 深浅色重绘与内存渲染二级缓存 FileCreator.swift # 物理文件落盘、防文件名冲突算法计算与可执行权限设置 Resources/Templates/ # Word/Excel/PPTX 内置空白二进制模板文件 + +BetterMenuTests/ + BetterMenuSecurityTests.swift # 一次性请求、URL 路由、AppleScript 路径与设置迁移测试 ``` ### 共享配置机制 @@ -45,9 +49,16 @@ BetterMenuFinderSync/ 主 App 与 FinderSync 扩展运行在不同的进程中,二者通过用户主目录下的 plist 文件共享偏好配置: - **偏好设置路径**:`~/Library/Application Support/BetterMenu/settings.plist` - **图标缓存路径**:`~/Library/Caches/BetterMenu/icon_cache.plist` +- **一次性请求目录**:`~/Library/Application Support/BetterMenu/Requests/` 共享数据模型 `FileDefinition`、`FinderAction` 和键名统一在 `BetterMenuShared.swift` 中管理。新增需要两个 Target 同时读取的数据定义时,必须直接归入共享层。 +### Finder 扩展安全请求机制 + +Finder 扩展不得通过 URL Scheme 直接传递动作 ID、源文件路径或目标路径。扩展会先将请求写入权限为 `0600` 的一次性 plist 文件,再使用仅包含随机 UUID 的 `bettermenu://request?id=...` 链接唤起主应用。 + +主应用消费请求前会验证请求目录与文件所有者、POSIX 权限、文件类型、大小、协议版本、路径格式和 30 秒有效期。请求文件在读取时立即从目录移除,防止重放。旧版 `run-action`、`send-files` 和 `open-terminal` 明文参数路由不再接受。 + ### 结构维护规范 - **主应用入口保持轻量**:`AppDelegate` 仅处理生命周期分发、AppKit 激活策略以及 URL Scheme 路由。 @@ -58,6 +69,24 @@ BetterMenuFinderSync/ - **Xcode 项目同步**:新增 Swift 源文件后,必须同步注册在 `BetterMenu.xcodeproj/project.pbxproj` 对应的 Target 编译阶段。 - **文档维护**:更改项目行为、扩展逻辑或业务边界时,必须同步修改本指南及 `README.md`。 +--- + +## ✅ 自动化测试 + +运行全部单元测试: + +```bash +xcodebuild \ + -project BetterMenu.xcodeproj \ + -scheme BetterMenu \ + -configuration Debug \ + -destination 'platform=macOS' \ + test +``` + +GitHub Actions 会在打包前执行同一组测试。安全通信或 AppleScript 路径处理发生变化时,必须同步补充 `BetterMenuSecurityTests`。 + + --- ## 🔁 软件更新与自动化发布 (Sparkle) From 1a1d00adfbdb7b4886aefe5205f82d47d9e9ddb4 Mon Sep 17 00:00:00 2001 From: zombie Date: Sun, 26 Jul 2026 16:46:16 +0800 Subject: [PATCH 05/10] Refactor file transfer operations and enhance progress reporting - Updated `FileTransferService` to manage file transfer tasks with improved lifecycle handling. - Introduced `FileTransferOperations` for handling file copying with progress tracking and cancellation support. - Added `FileTransferProgressWindow` to display real-time transfer progress and allow cancellation. - Modified `FileCreator` to accept a bundle parameter for file creation, ensuring correct template file access. - Enhanced unit tests for file creation and transfer operations, covering progress reporting, cancellation, and error handling. - Updated documentation to reflect changes in file transfer functionality and testing procedures. --- .github/workflows/ci.yml | 14 + BetterMenu.xcodeproj/project.pbxproj | 32 +- .../xcschemes/BetterMenu.xcscheme | 4 +- .../Services/FileTransferOperations.swift | 397 ++++++++++++++++++ .../Services/FileTransferProgressWindow.swift | 132 ++++++ BetterMenu/Services/FileTransferService.swift | 158 +++---- BetterMenuFinderSync/FileCreator.swift | 16 +- BetterMenuFinderSync/FinderSync.swift | 6 +- .../BetterMenuCoreServiceTests.swift | 243 +++++++++++ DEVELOPMENT.md | 13 +- README.md | 10 +- script/build_and_run.sh | 10 + script/package.sh | 19 +- 13 files changed, 933 insertions(+), 121 deletions(-) create mode 100644 BetterMenu/Services/FileTransferOperations.swift create mode 100644 BetterMenu/Services/FileTransferProgressWindow.swift create mode 100644 BetterMenuTests/BetterMenuCoreServiceTests.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e9937f..bed07eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,13 +34,27 @@ jobs: # 步骤 2.5:运行安全通信、路径处理与设置迁移单元测试 - name: Run Tests run: | + TEST_DERIVED_DATA="$RUNNER_TEMP/BetterMenuTestsDerivedData" xcodebuild \ -project BetterMenu.xcodeproj \ -scheme BetterMenu \ -configuration Debug \ -destination 'platform=macOS' \ + -derivedDataPath "$TEST_DERIVED_DATA" \ test + UNEXPECTED_PRODUCTS="$( + find "$TEST_DERIVED_DATA/Build/Products" \ + -type d \ + \( -name 'BetterMenu.app' -o -name 'BetterMenuFinderSync.appex' \) \ + -print + )" + if [[ -n "$UNEXPECTED_PRODUCTS" ]]; then + echo "单元测试不应构建或注册 BetterMenu App/Finder Sync 扩展:" + echo "$UNEXPECTED_PRODUCTS" + exit 1 + fi + # 步骤 2.6:安装 create-dmg 以启用精美 DMG 打包布局 - name: Install create-dmg run: | diff --git a/BetterMenu.xcodeproj/project.pbxproj b/BetterMenu.xcodeproj/project.pbxproj index 1688aea..72a3404 100644 --- a/BetterMenu.xcodeproj/project.pbxproj +++ b/BetterMenu.xcodeproj/project.pbxproj @@ -29,14 +29,19 @@ A10000000000000000000610 /* IconCacheManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000611 /* IconCacheManager.swift */; }; A10000000000000000000620 /* FileTransferService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000621 /* FileTransferService.swift */; }; A10000000000000000000630 /* AppleScriptPathArgument.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000631 /* AppleScriptPathArgument.swift */; }; + A10000000000000000000640 /* FileTransferOperations.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000641 /* FileTransferOperations.swift */; }; + A10000000000000000000650 /* FileTransferProgressWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000651 /* FileTransferProgressWindow.swift */; }; A10000000000000000000700 /* SettingsMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000701 /* SettingsMonitor.swift */; }; A10000000000000000000710 /* MenuIconManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000711 /* MenuIconManager.swift */; }; A10000000000000000000720 /* FileCreator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000721 /* FileCreator.swift */; }; A65100FA5752180F6B4730FB /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 529E8C462C2C9F3A67CF1C58 /* Sparkle */; }; A10000000000000000000093 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000090 /* InfoPlist.strings */; }; B20000000000000000000001 /* BetterMenuSecurityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000012 /* BetterMenuSecurityTests.swift */; }; + B20000000000000000000002 /* BetterMenuCoreServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000013 /* BetterMenuCoreServiceTests.swift */; }; B20000000000000000000003 /* BetterMenuShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000301 /* BetterMenuShared.swift */; }; B20000000000000000000004 /* AppleScriptPathArgument.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000631 /* AppleScriptPathArgument.swift */; }; + B20000000000000000000005 /* FileTransferOperations.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000641 /* FileTransferOperations.swift */; }; + B20000000000000000000006 /* FileCreator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000721 /* FileCreator.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -91,6 +96,8 @@ A10000000000000000000611 /* IconCacheManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconCacheManager.swift; sourceTree = ""; }; A10000000000000000000621 /* FileTransferService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileTransferService.swift; sourceTree = ""; }; A10000000000000000000631 /* AppleScriptPathArgument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleScriptPathArgument.swift; sourceTree = ""; }; + A10000000000000000000641 /* FileTransferOperations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileTransferOperations.swift; sourceTree = ""; }; + A10000000000000000000651 /* FileTransferProgressWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileTransferProgressWindow.swift; sourceTree = ""; }; A10000000000000000000701 /* SettingsMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsMonitor.swift; sourceTree = ""; }; A10000000000000000000711 /* MenuIconManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuIconManager.swift; sourceTree = ""; }; A10000000000000000000721 /* FileCreator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileCreator.swift; sourceTree = ""; }; @@ -98,6 +105,7 @@ A10000000000000000000092 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; sourceTree = ""; }; B20000000000000000000011 /* BetterMenuTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BetterMenuTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; B20000000000000000000012 /* BetterMenuSecurityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BetterMenuSecurityTests.swift; sourceTree = ""; }; + B20000000000000000000013 /* BetterMenuCoreServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BetterMenuCoreServiceTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -183,6 +191,8 @@ A10000000000000000000611 /* IconCacheManager.swift */, A10000000000000000000621 /* FileTransferService.swift */, A10000000000000000000631 /* AppleScriptPathArgument.swift */, + A10000000000000000000641 /* FileTransferOperations.swift */, + A10000000000000000000651 /* FileTransferProgressWindow.swift */, ); path = Services; sourceTree = ""; @@ -217,6 +227,7 @@ isa = PBXGroup; children = ( B20000000000000000000012 /* BetterMenuSecurityTests.swift */, + B20000000000000000000013 /* BetterMenuCoreServiceTests.swift */, ); path = BetterMenuTests; sourceTree = ""; @@ -389,6 +400,8 @@ A10000000000000000000610 /* IconCacheManager.swift in Sources */, A10000000000000000000620 /* FileTransferService.swift in Sources */, A10000000000000000000630 /* AppleScriptPathArgument.swift in Sources */, + A10000000000000000000640 /* FileTransferOperations.swift in Sources */, + A10000000000000000000650 /* FileTransferProgressWindow.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -409,8 +422,11 @@ buildActionMask = 2147483647; files = ( B20000000000000000000001 /* BetterMenuSecurityTests.swift in Sources */, + B20000000000000000000002 /* BetterMenuCoreServiceTests.swift in Sources */, B20000000000000000000003 /* BetterMenuShared.swift in Sources */, B20000000000000000000004 /* AppleScriptPathArgument.swift in Sources */, + B20000000000000000000005 /* FileTransferOperations.swift in Sources */, + B20000000000000000000006 /* FileCreator.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -433,7 +449,7 @@ CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 27; + CURRENT_PROJECT_VERSION = 28; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = BetterMenu/Info.plist; @@ -442,7 +458,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 2.0.7; + MARKETING_VERSION = 2.0.8; PRODUCT_BUNDLE_IDENTIFIER = com.zombie.BetterMenu; PRODUCT_NAME = BetterMenu; SWIFT_VERSION = 6.0; @@ -457,7 +473,7 @@ CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 27; + CURRENT_PROJECT_VERSION = 28; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = BetterMenu/Info.plist; @@ -466,7 +482,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 2.0.7; + MARKETING_VERSION = 2.0.8; PRODUCT_BUNDLE_IDENTIFIER = com.zombie.BetterMenu; PRODUCT_NAME = BetterMenu; SWIFT_VERSION = 6.0; @@ -480,7 +496,7 @@ CODE_SIGN_ENTITLEMENTS = BetterMenuFinderSync/BetterMenuFinderSync.entitlements; CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 27; + CURRENT_PROJECT_VERSION = 28; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = BetterMenuFinderSync/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -489,7 +505,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 2.0.7; + MARKETING_VERSION = 2.0.8; PRODUCT_BUNDLE_IDENTIFIER = com.zombie.BetterMenu.FinderSync; PRODUCT_NAME = BetterMenuFinderSync; SKIP_INSTALL = YES; @@ -505,7 +521,7 @@ CODE_SIGN_ENTITLEMENTS = BetterMenuFinderSync/BetterMenuFinderSync.entitlements; CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 27; + CURRENT_PROJECT_VERSION = 28; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = BetterMenuFinderSync/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -514,7 +530,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 2.0.7; + MARKETING_VERSION = 2.0.8; PRODUCT_BUNDLE_IDENTIFIER = com.zombie.BetterMenu.FinderSync; PRODUCT_NAME = BetterMenuFinderSync; SKIP_INSTALL = YES; diff --git a/BetterMenu.xcodeproj/xcshareddata/xcschemes/BetterMenu.xcscheme b/BetterMenu.xcodeproj/xcshareddata/xcschemes/BetterMenu.xcscheme index ec2b39d..5194660 100644 --- a/BetterMenu.xcodeproj/xcshareddata/xcschemes/BetterMenu.xcscheme +++ b/BetterMenu.xcodeproj/xcshareddata/xcschemes/BetterMenu.xcscheme @@ -7,7 +7,7 @@ buildImplicitDependencies = "YES"> Void + + private final class CopyProgressContext: @unchecked Sendable { + let baseCompletedBytes: Int64 + let currentFileName: String + let totalBytes: Int64 + let completedItemCount: Int + let totalItemCount: Int + let cancellationToken: FileTransferCancellationToken + let progressHandler: ProgressHandler + + private let lock = NSLock() + private var copiedBytesBySourcePath: [String: Int64] = [:] + private var lastReportedTotalBytes: Int64 = 0 + + init( + baseCompletedBytes: Int64, + currentFileName: String, + totalBytes: Int64, + completedItemCount: Int, + totalItemCount: Int, + cancellationToken: FileTransferCancellationToken, + progressHandler: @escaping ProgressHandler + ) { + self.baseCompletedBytes = baseCompletedBytes + self.currentFileName = currentFileName + self.totalBytes = totalBytes + self.completedItemCount = completedItemCount + self.totalItemCount = totalItemCount + self.cancellationToken = cancellationToken + self.progressHandler = progressHandler + } + + func handleProgress( + state: copyfile_state_t, + sourcePath: String + ) -> Int32 { + guard !cancellationToken.isCancelled else { + return COPYFILE_QUIT + } + + var copiedBytes: off_t = 0 + guard + copyfile_state_get( + state, + UInt32(COPYFILE_STATE_COPIED), + &copiedBytes + ) == 0 + else { + return COPYFILE_CONTINUE + } + + let currentCopiedBytes = max(0, Int64(copiedBytes)) + let minimumUpdateInterval = max(524_288, totalBytes / 200) + + lock.lock() + copiedBytesBySourcePath[sourcePath] = max( + copiedBytesBySourcePath[sourcePath] ?? 0, + currentCopiedBytes + ) + let totalCopiedBytes = copiedBytesBySourcePath.values.reduce(0, +) + let shouldReport = + totalCopiedBytes == 0 + || totalCopiedBytes - lastReportedTotalBytes >= minimumUpdateInterval + if shouldReport { + lastReportedTotalBytes = totalCopiedBytes + } + lock.unlock() + + if shouldReport { + progressHandler( + FileTransferProgressSnapshot( + currentFileName: currentFileName, + completedBytes: min(baseCompletedBytes + totalCopiedBytes, totalBytes), + totalBytes: totalBytes, + completedItemCount: completedItemCount, + totalItemCount: totalItemCount + ) + ) + } + + return COPYFILE_CONTINUE + } + } + + static func copyItems( + _ sourceUrls: [URL], + to requestedDestinationUrl: URL, + cancellationToken: FileTransferCancellationToken = FileTransferCancellationToken(), + progressHandler: @escaping ProgressHandler = { _ in } + ) -> FileTransferCopyResult { + let fileManager = FileManager() + let destinationUrl = requestedDestinationUrl.standardizedFileURL + let standardizedSourceUrls = sourceUrls.map(\.standardizedFileURL) + let sourceByteCounts = standardizedSourceUrls.map { + byteCount(for: $0, fileManager: fileManager) + } + let totalBytes = sourceByteCounts.reduce(0, +) + let totalItemCount = standardizedSourceUrls.count + + var copiedUrls: [URL] = [] + var failures: [FileTransferCopyFailure] = [] + var completedBytes: Int64 = 0 + + for (index, sourceUrl) in standardizedSourceUrls.enumerated() { + guard !cancellationToken.isCancelled else { + return FileTransferCopyResult( + copiedUrls: copiedUrls, + failures: failures, + wasCancelled: true + ) + } + + guard !destinationIsInsideSource(destinationUrl, sourceUrl: sourceUrl) else { + failures.append( + FileTransferCopyFailure( + fileName: sourceUrl.lastPathComponent, + message: "不能将项目复制到其自身内部。" + ) + ) + continue + } + + let targetUrl = availableTargetUrl( + for: sourceUrl, + in: destinationUrl, + fileManager: fileManager + ) + let snapshot = FileTransferProgressSnapshot( + currentFileName: sourceUrl.lastPathComponent, + completedBytes: completedBytes, + totalBytes: totalBytes, + completedItemCount: index, + totalItemCount: totalItemCount + ) + progressHandler(snapshot) + + let context = CopyProgressContext( + baseCompletedBytes: completedBytes, + currentFileName: sourceUrl.lastPathComponent, + totalBytes: totalBytes, + completedItemCount: index, + totalItemCount: totalItemCount, + cancellationToken: cancellationToken, + progressHandler: progressHandler + ) + + let copyErrorNumber = copyItem( + at: sourceUrl, + to: targetUrl, + context: context + ) + if copyErrorNumber == 0 { + copiedUrls.append(targetUrl) + completedBytes += sourceByteCounts[index] + progressHandler( + FileTransferProgressSnapshot( + currentFileName: sourceUrl.lastPathComponent, + completedBytes: min(completedBytes, totalBytes), + totalBytes: totalBytes, + completedItemCount: index + 1, + totalItemCount: totalItemCount + ) + ) + continue + } + + let wasCancelled = + cancellationToken.isCancelled || copyErrorNumber == ECANCELED + if copyErrorNumber != EEXIST, fileManager.fileExists(atPath: targetUrl.path) { + try? fileManager.removeItem(at: targetUrl) + } + if wasCancelled { + return FileTransferCopyResult( + copiedUrls: copiedUrls, + failures: failures, + wasCancelled: true + ) + } + + failures.append( + FileTransferCopyFailure( + fileName: sourceUrl.lastPathComponent, + message: errorMessage(for: copyErrorNumber) + ) + ) + } + + return FileTransferCopyResult( + copiedUrls: copiedUrls, + failures: failures, + wasCancelled: false + ) + } + + static func availableTargetUrl( + for sourceUrl: URL, + in destinationUrl: URL, + fileManager: FileManager + ) -> URL { + let originalTargetUrl = destinationUrl.appendingPathComponent(sourceUrl.lastPathComponent) + guard fileManager.fileExists(atPath: originalTargetUrl.path) else { + return originalTargetUrl + } + + let pathExtension = sourceUrl.pathExtension + let baseName = + pathExtension.isEmpty + ? sourceUrl.lastPathComponent + : sourceUrl.deletingPathExtension().lastPathComponent + var copyIndex = 2 + while true { + let candidateName = + pathExtension.isEmpty + ? "\(baseName) \(copyIndex)" + : "\(baseName) \(copyIndex).\(pathExtension)" + let candidateUrl = destinationUrl.appendingPathComponent(candidateName) + if !fileManager.fileExists(atPath: candidateUrl.path) { + return candidateUrl + } + copyIndex += 1 + } + } + + private static func copyItem( + at sourceUrl: URL, + to targetUrl: URL, + context: CopyProgressContext + ) -> Int32 { + guard let state = copyfile_state_alloc() else { + return ENOMEM + } + defer { + copyfile_state_free(state) + } + + let callback: copyfile_callback_t = { + what, + stage, + callbackState, + sourcePath, + _, + rawContext in + guard + what == COPYFILE_COPY_DATA, + stage == COPYFILE_PROGRESS, + let callbackState, + let rawContext + else { + if let rawContext { + let context = Unmanaged + .fromOpaque(rawContext) + .takeUnretainedValue() + return context.cancellationToken.isCancelled + ? COPYFILE_QUIT + : COPYFILE_CONTINUE + } + return COPYFILE_CONTINUE + } + + let context = Unmanaged + .fromOpaque(rawContext) + .takeUnretainedValue() + return context.handleProgress( + state: callbackState, + sourcePath: sourcePath.map { String(cString: $0) } ?? context.currentFileName + ) + } + + let callbackPointer = unsafeBitCast(callback, to: UnsafeRawPointer.self) + let callbackResult = copyfile_state_set( + state, + UInt32(COPYFILE_STATE_STATUS_CB), + callbackPointer + ) + guard callbackResult == 0 else { + return errno + } + + let contextPointer = Unmanaged.passUnretained(context).toOpaque() + guard + copyfile_state_set( + state, + UInt32(COPYFILE_STATE_STATUS_CTX), + contextPointer + ) == 0 + else { + return errno + } + + let flags = + copyfile_flags_t(COPYFILE_ALL) + | copyfile_flags_t(COPYFILE_RECURSIVE) + | copyfile_flags_t(COPYFILE_EXCL) + | copyfile_flags_t(COPYFILE_NOFOLLOW_SRC) + let result = copyfile(sourceUrl.path, targetUrl.path, state, flags) + return result == 0 ? 0 : errno + } + + private static func destinationIsInsideSource( + _ destinationUrl: URL, + sourceUrl: URL + ) -> Bool { + let sourcePath = sourceUrl.standardizedFileURL.path + let destinationPath = destinationUrl.standardizedFileURL.path + return destinationPath == sourcePath || destinationPath.hasPrefix(sourcePath + "/") + } + + private static func byteCount( + for sourceUrl: URL, + fileManager: FileManager + ) -> Int64 { + let resourceKeys: Set = [ + .fileSizeKey, + .isDirectoryKey, + .isRegularFileKey, + .isSymbolicLinkKey, + ] + guard + let sourceValues = try? sourceUrl.resourceValues(forKeys: resourceKeys) + else { + return 0 + } + + if sourceValues.isRegularFile == true { + return Int64(sourceValues.fileSize ?? 0) + } + guard sourceValues.isDirectory == true else { + return 0 + } + + let enumerator = fileManager.enumerator( + at: sourceUrl, + includingPropertiesForKeys: Array(resourceKeys), + options: [], + errorHandler: { _, _ in true } + ) + var totalBytes: Int64 = 0 + while let itemUrl = enumerator?.nextObject() as? URL { + guard + let values = try? itemUrl.resourceValues(forKeys: resourceKeys), + values.isRegularFile == true + else { + continue + } + totalBytes += Int64(values.fileSize ?? 0) + } + return totalBytes + } + + private static func errorMessage(for errorNumber: Int32) -> String { + guard let message = strerror(errorNumber) else { + return "复制失败,错误码 \(errorNumber)。" + } + return String(cString: message) + } +} diff --git a/BetterMenu/Services/FileTransferProgressWindow.swift b/BetterMenu/Services/FileTransferProgressWindow.swift new file mode 100644 index 0000000..6458c7f --- /dev/null +++ b/BetterMenu/Services/FileTransferProgressWindow.swift @@ -0,0 +1,132 @@ +import Cocoa +import SwiftUI + +@MainActor +final class FileTransferProgressModel: ObservableObject { + @Published private(set) var currentFileName = "" + @Published private(set) var completedBytes: Int64 = 0 + @Published private(set) var totalBytes: Int64 = 0 + @Published private(set) var completedItemCount = 0 + @Published private(set) var totalItemCount = 0 + @Published private(set) var isCancelling = false + + var onCancel: (() -> Void)? + + var progressValue: Double? { + if totalBytes > 0 { + return min(1, Double(completedBytes) / Double(totalBytes)) + } + guard totalItemCount > 0 else { return nil } + return min(1, Double(completedItemCount) / Double(totalItemCount)) + } + + var statusText: String { + if isCancelling { + return "正在取消传输…" + } + if currentFileName.isEmpty { + return "正在计算传输大小…" + } + return "正在复制“\(currentFileName)”" + } + + var detailText: String { + let itemProgress = "\(completedItemCount)/\(totalItemCount) 项" + guard totalBytes > 0 else { + return itemProgress + } + + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useAll] + formatter.countStyle = .file + let completedText = formatter.string(fromByteCount: completedBytes) + let totalText = formatter.string(fromByteCount: totalBytes) + return "\(itemProgress) · \(completedText)/\(totalText)" + } + + func update(with snapshot: FileTransferProgressSnapshot) { + guard !isCancelling else { return } + currentFileName = snapshot.currentFileName + completedBytes = snapshot.completedBytes + totalBytes = snapshot.totalBytes + completedItemCount = snapshot.completedItemCount + totalItemCount = snapshot.totalItemCount + } + + func requestCancellation() { + guard !isCancelling else { return } + isCancelling = true + onCancel?() + } +} + +private struct FileTransferProgressView: View { + @ObservedObject var model: FileTransferProgressModel + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text("发送文件") + .font(.headline) + + Text(model.statusText) + .font(.callout) + .lineLimit(1) + + if let progressValue = model.progressValue { + ProgressView(value: progressValue) + .progressViewStyle(.linear) + } else { + ProgressView() + .progressViewStyle(.linear) + } + + HStack { + Text(model.detailText) + .font(.caption) + .foregroundStyle(.secondary) + + Spacer() + + Button("取消") { + model.requestCancellation() + } + .disabled(model.isCancelling) + .keyboardShortcut(.cancelAction) + } + } + .padding(20) + .frame(width: 430) + } +} + +@MainActor +final class FileTransferProgressWindowController: NSWindowController { + let model: FileTransferProgressModel + + init(model: FileTransferProgressModel) { + self.model = model + + let contentViewController = NSHostingController( + rootView: FileTransferProgressView(model: model) + ) + let window = NSWindow(contentViewController: contentViewController) + window.title = "BetterMenu 文件传输" + window.styleMask = [.titled] + window.isReleasedWhenClosed = false + window.standardWindowButton(.zoomButton)?.isHidden = true + window.standardWindowButton(.miniaturizeButton)?.isHidden = true + + super.init(window: window) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func show() { + window?.center() + window?.makeKeyAndOrderFront(nil) + NSApp.activate() + } +} diff --git a/BetterMenu/Services/FileTransferService.swift b/BetterMenu/Services/FileTransferService.swift index 53d2ae4..68779ba 100644 --- a/BetterMenu/Services/FileTransferService.swift +++ b/BetterMenu/Services/FileTransferService.swift @@ -4,20 +4,16 @@ import os /// 承接 Finder 扩展发起的文件传输任务,确保大文件复制不依赖扩展进程生命周期。 @MainActor final class FileTransferService { - private struct CopyFailure: Sendable { - let fileName: String - let message: String - } - - private struct CopyResult: Sendable { - let copiedUrls: [URL] - let failures: [CopyFailure] + private struct ActiveCopyTask { + let task: Task + let cancellationToken: FileTransferCancellationToken + let progressWindowController: FileTransferProgressWindowController } private let logger = Logger( subsystem: "com.zombie.BetterMenu", category: "FileTransferService") private var activeAirDropService: NSSharingService? - private var copyTasks: [UUID: Task] = [:] + private var copyTasks: [UUID: ActiveCopyTask] = [:] /// 使用系统隔空投送面板发送选中的文件或文件夹。 func sendViaAirDrop(sourceUrls: [URL]) { @@ -47,16 +43,55 @@ final class FileTransferService { } let taskId = UUID() + let cancellationToken = FileTransferCancellationToken() + let progressModel = FileTransferProgressModel() + let progressWindowController = FileTransferProgressWindowController(model: progressModel) + progressModel.onCancel = { [weak self] in + self?.cancelCopyTask(taskId) + } + progressWindowController.show() + let task = Task { [weak self] in let result = await Task.detached(priority: .userInitiated) { - Self.copyItems(existingUrls, to: destinationUrl) + let standardizedDestinationUrl = destinationUrl.standardizedFileURL + guard + BetterMenuShared.availableFileTransferVolumes() + .contains(where: { $0.url.path == standardizedDestinationUrl.path }) + else { + return FileTransferCopyResult( + copiedUrls: [], + failures: [ + FileTransferCopyFailure( + fileName: standardizedDestinationUrl.lastPathComponent, + message: "目标设备已断开、只读或不可写。" + ) + ], + wasCancelled: false + ) + } + + return FileTransferOperations.copyItems( + existingUrls, + to: standardizedDestinationUrl, + cancellationToken: cancellationToken, + progressHandler: { snapshot in + Task { @MainActor [weak progressModel] in + progressModel?.update(with: snapshot) + } + } + ) }.value guard let self else { return } self.copyTasks.removeValue(forKey: taskId) + progressWindowController.close() self.handleCopyResult(result, destinationUrl: destinationUrl) } - copyTasks[taskId] = task + copyTasks[taskId] = ActiveCopyTask( + task: task, + cancellationToken: cancellationToken, + progressWindowController: progressWindowController + ) } private func existingSourceUrls(from urls: [URL]) -> [URL] { @@ -74,12 +109,21 @@ final class FileTransferService { } } - private func handleCopyResult(_ result: CopyResult, destinationUrl: URL) { + private func cancelCopyTask(_ taskId: UUID) { + guard let activeTask = copyTasks[taskId] else { return } + activeTask.cancellationToken.cancel() + activeTask.task.cancel() + } + + private func handleCopyResult( + _ result: FileTransferCopyResult, + destinationUrl: URL + ) { if !result.copiedUrls.isEmpty { NSWorkspace.shared.activateFileViewerSelecting(result.copiedUrls) } - guard !result.failures.isEmpty else { return } + guard !result.wasCancelled, !result.failures.isEmpty else { return } let maximumDisplayedFailures = 3 var detailLines = result.failures.prefix(maximumDisplayedFailures).map { @@ -105,92 +149,4 @@ final class FileTransferService { NSApp.activate() alert.runModal() } - - nonisolated private static func copyItems( - _ sourceUrls: [URL], - to requestedDestinationUrl: URL - ) -> CopyResult { - let fileManager = FileManager() - let destinationUrl = requestedDestinationUrl.standardizedFileURL - guard - BetterMenuShared.availableFileTransferVolumes(fileManager: fileManager) - .contains(where: { $0.url.path == destinationUrl.path }) - else { - return CopyResult( - copiedUrls: [], - failures: [ - CopyFailure( - fileName: destinationUrl.lastPathComponent, - message: "目标设备已断开、只读或不可写。" - ) - ] - ) - } - - var copiedUrls: [URL] = [] - var failures: [CopyFailure] = [] - for sourceUrl in sourceUrls { - guard !destinationIsInsideSource(destinationUrl, sourceUrl: sourceUrl) else { - failures.append( - CopyFailure(fileName: sourceUrl.lastPathComponent, message: "不能将项目复制到其自身内部。")) - continue - } - - let targetUrl = availableTargetUrl( - for: sourceUrl, - in: destinationUrl, - fileManager: fileManager - ) - do { - try fileManager.copyItem(at: sourceUrl, to: targetUrl) - copiedUrls.append(targetUrl) - } catch { - if fileManager.fileExists(atPath: targetUrl.path) { - try? fileManager.removeItem(at: targetUrl) - } - failures.append( - CopyFailure(fileName: sourceUrl.lastPathComponent, message: error.localizedDescription)) - } - } - - return CopyResult(copiedUrls: copiedUrls, failures: failures) - } - - nonisolated private static func destinationIsInsideSource( - _ destinationUrl: URL, - sourceUrl: URL - ) -> Bool { - let sourcePath = sourceUrl.standardizedFileURL.path - let destinationPath = destinationUrl.standardizedFileURL.path - return destinationPath == sourcePath || destinationPath.hasPrefix(sourcePath + "/") - } - - nonisolated private static func availableTargetUrl( - for sourceUrl: URL, - in destinationUrl: URL, - fileManager: FileManager - ) -> URL { - let originalTargetUrl = destinationUrl.appendingPathComponent(sourceUrl.lastPathComponent) - guard fileManager.fileExists(atPath: originalTargetUrl.path) else { - return originalTargetUrl - } - - let pathExtension = sourceUrl.pathExtension - let baseName = - pathExtension.isEmpty - ? sourceUrl.lastPathComponent - : sourceUrl.deletingPathExtension().lastPathComponent - var copyIndex = 2 - while true { - let candidateName = - pathExtension.isEmpty - ? "\(baseName) \(copyIndex)" - : "\(baseName) \(copyIndex).\(pathExtension)" - let candidateUrl = destinationUrl.appendingPathComponent(candidateName) - if !fileManager.fileExists(atPath: candidateUrl.path) { - return candidateUrl - } - copyIndex += 1 - } - } } diff --git a/BetterMenuFinderSync/FileCreator.swift b/BetterMenuFinderSync/FileCreator.swift index bfe481e..a19fb21 100644 --- a/BetterMenuFinderSync/FileCreator.swift +++ b/BetterMenuFinderSync/FileCreator.swift @@ -7,6 +7,7 @@ enum FileCreator { struct FileCreationRequest { let directory: URL let definition: FileDefinition + let bundle: Bundle private let fileManager = FileManager.default /// 执行物理文件的创建 @@ -14,8 +15,7 @@ enum FileCreator { func create() throws -> URL { let destination = try makeAvailableDestination() - // 将 FinderSync.self 所在得 bundle 作为参数传递,以定位扩展包中的模板文件 - try definition.contents(in: Bundle(for: FinderSync.self)).write(to: destination, options: .withoutOverwriting) + try definition.contents(in: bundle).write(to: destination, options: .withoutOverwriting) if definition.shouldBeExecutable { try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: destination.path) } @@ -48,8 +48,16 @@ enum FileCreator { /// - definition: 新建文件类型的具体定义描述 /// - directory: 新建文件要存放的所在目录 URL /// - Returns: 返回创建完成后的文件路径 URL - static func createFile(from definition: FileDefinition, at directory: URL) throws -> URL { - let request = FileCreationRequest(directory: directory, definition: definition) + static func createFile( + from definition: FileDefinition, + at directory: URL, + bundle: Bundle + ) throws -> URL { + let request = FileCreationRequest( + directory: directory, + definition: definition, + bundle: bundle + ) return try request.create() } } diff --git a/BetterMenuFinderSync/FinderSync.swift b/BetterMenuFinderSync/FinderSync.swift index ad736df..9b1b4ce 100644 --- a/BetterMenuFinderSync/FinderSync.swift +++ b/BetterMenuFinderSync/FinderSync.swift @@ -427,7 +427,11 @@ final class FinderSync: FIFinderSync { } do { - let createdFileUrl = try FileCreator.createFile(from: definition, at: directory) + let createdFileUrl = try FileCreator.createFile( + from: definition, + at: directory, + bundle: Bundle(for: FinderSync.self) + ) revealCreatedFileIfNeeded(createdFileUrl, in: directory) } catch { logger.error("Create file failed: \(error.localizedDescription, privacy: .public)") diff --git a/BetterMenuTests/BetterMenuCoreServiceTests.swift b/BetterMenuTests/BetterMenuCoreServiceTests.swift new file mode 100644 index 0000000..247b154 --- /dev/null +++ b/BetterMenuTests/BetterMenuCoreServiceTests.swift @@ -0,0 +1,243 @@ +import Foundation +import XCTest + +final class BetterMenuCoreServiceTests: XCTestCase { + func testFileCreatorWritesContentsAndGeneratesNonconflictingNames() throws { + let directoryUrl = try makeTemporaryDirectory() + let definition = FileDefinition( + id: "log", + title: "LOG", + menuTitle: "新建 LOG 文件", + baseName: "新建日志", + pathExtension: "log", + text: "initial contents\n", + enabledByDefault: false + ) + + let firstUrl = try FileCreator.createFile( + from: definition, + at: directoryUrl, + bundle: Bundle(for: Self.self) + ) + let secondUrl = try FileCreator.createFile( + from: definition, + at: directoryUrl, + bundle: Bundle(for: Self.self) + ) + + XCTAssertEqual(firstUrl.lastPathComponent, "新建日志.log") + XCTAssertEqual(secondUrl.lastPathComponent, "新建日志 2.log") + XCTAssertEqual(try String(contentsOf: firstUrl, encoding: .utf8), "initial contents\n") + } + + func testFileCreatorAppliesExecutablePermission() throws { + let directoryUrl = try makeTemporaryDirectory() + let definition = FileDefinition( + id: "sh", + title: "Shell", + menuTitle: "新建 Shell 文件", + baseName: "script", + pathExtension: "sh", + text: "#!/usr/bin/env bash\n", + shouldBeExecutable: true, + enabledByDefault: false + ) + + let fileUrl = try FileCreator.createFile( + from: definition, + at: directoryUrl, + bundle: Bundle(for: Self.self) + ) + let attributes = try FileManager.default.attributesOfItem(atPath: fileUrl.path) + let permissions = try XCTUnwrap(attributes[.posixPermissions] as? NSNumber) + + XCTAssertEqual(permissions.intValue & 0o777, 0o755) + } + + func testFileTransferCopiesFileAndReportsProgress() throws { + let rootUrl = try makeTemporaryDirectory() + let sourceDirectoryUrl = rootUrl.appendingPathComponent("Source", isDirectory: true) + let destinationDirectoryUrl = rootUrl.appendingPathComponent("Destination", isDirectory: true) + try FileManager.default.createDirectory( + at: sourceDirectoryUrl, + withIntermediateDirectories: true + ) + try FileManager.default.createDirectory( + at: destinationDirectoryUrl, + withIntermediateDirectories: true + ) + + let sourceUrl = sourceDirectoryUrl.appendingPathComponent("Report.txt") + let sourceData = Data(repeating: 0x41, count: 2 * 1_024 * 1_024) + try sourceData.write(to: sourceUrl) + let snapshots = LockedSnapshots() + + let result = FileTransferOperations.copyItems( + [sourceUrl], + to: destinationDirectoryUrl, + progressHandler: { snapshot in + snapshots.append(snapshot) + } + ) + + let copiedUrl = try XCTUnwrap(result.copiedUrls.first) + XCTAssertFalse(result.wasCancelled) + XCTAssertTrue(result.failures.isEmpty) + XCTAssertEqual(try Data(contentsOf: copiedUrl), sourceData) + XCTAssertEqual(snapshots.values.last?.completedBytes, Int64(sourceData.count)) + XCTAssertEqual(snapshots.values.last?.completedItemCount, 1) + } + + func testFileTransferCopiesDirectoryContents() throws { + let rootUrl = try makeTemporaryDirectory() + let sourceUrl = rootUrl.appendingPathComponent("Project", isDirectory: true) + let nestedUrl = sourceUrl.appendingPathComponent("Nested", isDirectory: true) + let destinationUrl = rootUrl.appendingPathComponent("Destination", isDirectory: true) + try FileManager.default.createDirectory(at: nestedUrl, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: destinationUrl, withIntermediateDirectories: true) + try Data("hello".utf8).write(to: nestedUrl.appendingPathComponent("README.md")) + try Data(repeating: 0x42, count: 1_024 * 1_024).write( + to: sourceUrl.appendingPathComponent("Archive.bin") + ) + let snapshots = LockedSnapshots() + + let result = FileTransferOperations.copyItems( + [sourceUrl], + to: destinationUrl, + progressHandler: { snapshot in + snapshots.append(snapshot) + } + ) + + XCTAssertFalse(result.wasCancelled) + XCTAssertTrue(result.failures.isEmpty) + let copiedFileUrl = + destinationUrl + .appendingPathComponent("Project", isDirectory: true) + .appendingPathComponent("Nested", isDirectory: true) + .appendingPathComponent("README.md") + XCTAssertEqual(try String(contentsOf: copiedFileUrl, encoding: .utf8), "hello") + let completedByteCounts = snapshots.values.map(\.completedBytes) + XCTAssertTrue( + zip(completedByteCounts, completedByteCounts.dropFirst()) + .allSatisfy { $0 <= $1 } + ) + } + + func testFileTransferGeneratesNonconflictingDestinationName() throws { + let rootUrl = try makeTemporaryDirectory() + let sourceUrl = rootUrl.appendingPathComponent("Report.txt") + let destinationUrl = rootUrl.appendingPathComponent("Destination", isDirectory: true) + try FileManager.default.createDirectory(at: destinationUrl, withIntermediateDirectories: true) + try Data("new".utf8).write(to: sourceUrl) + try Data("existing".utf8).write( + to: destinationUrl.appendingPathComponent("Report.txt") + ) + + let result = FileTransferOperations.copyItems([sourceUrl], to: destinationUrl) + + XCTAssertEqual(result.copiedUrls.first?.lastPathComponent, "Report 2.txt") + XCTAssertEqual( + try String( + contentsOf: destinationUrl.appendingPathComponent("Report.txt"), + encoding: .utf8 + ), + "existing" + ) + } + + func testFileTransferCanBeCancelledBeforeCopying() throws { + let rootUrl = try makeTemporaryDirectory() + let sourceUrl = rootUrl.appendingPathComponent("Source.txt") + let destinationUrl = rootUrl.appendingPathComponent("Destination", isDirectory: true) + try FileManager.default.createDirectory(at: destinationUrl, withIntermediateDirectories: true) + try Data("contents".utf8).write(to: sourceUrl) + let cancellationToken = FileTransferCancellationToken() + cancellationToken.cancel() + + let result = FileTransferOperations.copyItems( + [sourceUrl], + to: destinationUrl, + cancellationToken: cancellationToken + ) + + XCTAssertTrue(result.wasCancelled) + XCTAssertTrue(result.copiedUrls.isEmpty) + XCTAssertFalse( + FileManager.default.fileExists( + atPath: destinationUrl.appendingPathComponent("Source.txt").path + ) + ) + } + + func testFileTransferCanBeCancelledWhileCopying() throws { + let rootUrl = try makeTemporaryDirectory() + let sourceUrl = rootUrl.appendingPathComponent("LargeFile.bin") + let destinationUrl = rootUrl.appendingPathComponent("Destination", isDirectory: true) + try FileManager.default.createDirectory(at: destinationUrl, withIntermediateDirectories: true) + try Data(repeating: 0x43, count: 8 * 1_024 * 1_024).write(to: sourceUrl) + let cancellationToken = FileTransferCancellationToken() + + let result = FileTransferOperations.copyItems( + [sourceUrl], + to: destinationUrl, + cancellationToken: cancellationToken, + progressHandler: { snapshot in + if snapshot.completedBytes > 0 { + cancellationToken.cancel() + } + } + ) + + XCTAssertTrue(result.wasCancelled) + XCTAssertTrue(result.copiedUrls.isEmpty) + XCTAssertFalse( + FileManager.default.fileExists( + atPath: destinationUrl.appendingPathComponent("LargeFile.bin").path + ) + ) + } + + func testFileTransferRejectsDestinationInsideSource() throws { + let rootUrl = try makeTemporaryDirectory() + let sourceUrl = rootUrl.appendingPathComponent("Source", isDirectory: true) + let destinationUrl = sourceUrl.appendingPathComponent("Destination", isDirectory: true) + try FileManager.default.createDirectory(at: destinationUrl, withIntermediateDirectories: true) + + let result = FileTransferOperations.copyItems([sourceUrl], to: destinationUrl) + + XCTAssertFalse(result.wasCancelled) + XCTAssertTrue(result.copiedUrls.isEmpty) + XCTAssertEqual(result.failures.count, 1) + } + + private func makeTemporaryDirectory() throws -> URL { + let directoryUrl = FileManager.default.temporaryDirectory + .appendingPathComponent("BetterMenuCoreTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory( + at: directoryUrl, + withIntermediateDirectories: true + ) + addTeardownBlock { + try? FileManager.default.removeItem(at: directoryUrl) + } + return directoryUrl + } +} + +private final class LockedSnapshots: @unchecked Sendable { + private let lock = NSLock() + private var snapshots: [FileTransferProgressSnapshot] = [] + + var values: [FileTransferProgressSnapshot] { + lock.lock() + defer { lock.unlock() } + return snapshots + } + + func append(_ snapshot: FileTransferProgressSnapshot) { + lock.lock() + snapshots.append(snapshot) + lock.unlock() + } +} diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index ca5bbed..074589c 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -28,7 +28,9 @@ BetterMenu/ Services/ AppleScriptPathArgument.swift # AppleScript 文件路径安全编码,防止路径改变脚本语法 ExternalAppLauncher.swift # 终端、VS Code 等外部应用一键拉起服务 - FileTransferService.swift # 隔空投送调度、外置卷后台复制与重名处理 + FileTransferService.swift # 隔空投送与后台复制任务生命周期调度 + FileTransferOperations.swift # copyfile 复制、字节进度、取消、重名与失败清理 + FileTransferProgressWindow.swift # 文件传输进度和取消窗口 TerminalApp.swift # 可选终端应用定义列表 SystemCommand.swift # 底层命令行调用 Process 进程运行封装 IconCacheManager.swift # 专职后台图标渲染、预热与序列化磁盘缓存服务 @@ -42,6 +44,7 @@ BetterMenuFinderSync/ BetterMenuTests/ BetterMenuSecurityTests.swift # 一次性请求、URL 路由、AppleScript 路径与设置迁移测试 + BetterMenuCoreServiceTests.swift # 文件创建、复制进度、取消与路径边界测试 ``` ### 共享配置机制 @@ -64,7 +67,7 @@ Finder 扩展不得通过 URL Scheme 直接传递动作 ID、源文件路径或 - **主应用入口保持轻量**:`AppDelegate` 仅处理生命周期分发、AppKit 激活策略以及 URL Scheme 路由。 - **ViewModel 高度内聚**:`BetterMenuSettingsModel` 只做数据和 UI 绑定,底层任务(如图标预热、System 进程命令)一律剥离成独立业务服务(如 `IconCacheManager`,`SystemCommand`)。 - **FinderSync 扩展轻量化**:`FinderSync` 类仅扮演控制器角色。偏好读取与目录监听由 `SettingsMonitor` 托管,图标解析与渲染由 `MenuIconManager` 托管,文件生成由 `FileCreator` 托管。 -- **文件发送链路**:FinderSync 只负责读取当前选择、发现已挂载可写卷并构造菜单;隔空投送与文件复制通过 URL Scheme 交给主应用的 `FileTransferService` 执行,避免大文件任务依赖扩展进程生命周期。复制目标仅允许已挂载的非内部可写卷,发生重名时生成递增后缀,任务完成后由访达定位结果。 +- **文件发送链路**:FinderSync 只负责读取当前选择、发现已挂载可写卷并构造菜单;隔空投送与文件复制通过 URL Scheme 交给主应用的 `FileTransferService` 执行,避免大文件任务依赖扩展进程生命周期。`FileTransferOperations` 使用系统 `copyfile` 回调提供字节进度和复制中取消,复制目标仅允许已挂载的非内部可写卷,发生重名时生成递增后缀,取消或失败时清理未完成项目,任务完成后由访达定位结果。 - **Swift 6 Concurrency 并发安全**:编写异步或跨 Target 通信的工具类时,必须按照 Swift 6 安全规范进行严格类型隔离,或使用锁机制辅以 `nonisolated(unsafe)` 消除编译警告。 - **Xcode 项目同步**:新增 Swift 源文件后,必须同步注册在 `BetterMenu.xcodeproj/project.pbxproj` 对应的 Target 编译阶段。 - **文档维护**:更改项目行为、扩展逻辑或业务边界时,必须同步修改本指南及 `README.md`。 @@ -84,7 +87,11 @@ xcodebuild \ test ``` -GitHub Actions 会在打包前执行同一组测试。安全通信或 AppleScript 路径处理发生变化时,必须同步补充 `BetterMenuSecurityTests`。 +`BetterMenuTests` 是无宿主单元测试 Target。共享 Scheme 在测试动作中只构建测试 Target,不构建或注册 `BetterMenu.app` 与 Finder Sync 扩展。不要重新启用主 App 或扩展的 `buildForTesting`,否则本地测试产物会出现在“登录项与扩展”中。 + +构建主 App 或 Finder Sync 扩展时必须固定复用项目 `DerivedData` 或 Xcode 默认路径中的一种,不得混用或为同一工作区创建多个临时 DerivedData 路径。`build_and_run.sh clean` 会在清理构建产物前注销开发扩展,`package.sh` 会在打包结束后注销临时 Release 产物。 + +GitHub Actions 会在打包前执行同一组测试,并检查测试产物中没有 `BetterMenu.app` 或 `BetterMenuFinderSync.appex`,防止 Scheme 配置回退。安全通信或 AppleScript 路径处理发生变化时,必须同步补充 `BetterMenuSecurityTests`;文件创建、复制、进度或取消逻辑发生变化时,必须同步补充 `BetterMenuCoreServiceTests`。 --- diff --git a/README.md b/README.md index ca63edd..f1d5ea8 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ - 📤 **发送文件到其他设备**: - 右键选中的文件或文件夹,可通过隔空投送发送到附近的 Mac、iPhone 或 iPad。 - 支持直接复制到已挂载且可写的移动硬盘、U 盘和网络共享卷,并支持一次发送多个项目。 - - 目标位置存在同名项目时自动生成不冲突的文件名,完成后在访达中定位结果。 + - 复制时显示文件数量、字节进度并支持随时取消;目标位置存在同名项目时自动生成不冲突的文件名,完成后在访达中定位结果。 - ⚙️ **SwiftUI 设置面板与状态栏菜单**: - 现代化、清爽的设置界面,支持调整右键菜单项的启用状态与排序,即时同步生效。 - 支持通过系统状态栏菜单快速调起面板、重启 Finder 访达、或退出软件。 @@ -115,6 +115,8 @@ xattr -cr /Applications/BetterMenu.app ./script/package.sh ``` +单元测试使用无宿主测试 Target,不会构建或注册 Finder Sync 扩展。开发脚本会在清理或打包完成时注销对应构建产物,避免“登录项与扩展”积累重复的开发扩展。 + --- ## 📖 开发者架构文档 @@ -126,6 +128,12 @@ xattr -cr /Applications/BetterMenu.app ## 📝 更新日志 +### v2.0.8 (2026-07-26) +- **传输体验**:复制文件到移动硬盘、U 盘或网络共享卷时显示实时字节进度、项目数量和当前文件,并支持复制过程中取消任务。 +- **复制可靠性**:取消或失败后自动清理未完成的目标文件,同时保留已经成功复制的项目。 +- **测试完善**:新增文件创建与传输核心服务测试,覆盖内容写入、可执行权限、重名处理、目录复制、进度回调、取消和路径边界。 +- **开发环境**:单元测试不再构建 Finder Sync 扩展,开发清理与 Release 打包会自动注销临时扩展,避免系统设置积累重复条目。 + ### v2.0.7 (2026-06-09) - **结构重构**:整理并优化项目的文件组织结构,将源文件分类整理至 Models、Services 和 Views 目录中,提升代码可读性与可维护性。 - **稳定性修复**:修复了系统重启后会自动打开主界面的问题。 diff --git a/script/build_and_run.sh b/script/build_and_run.sh index 11e39d8..16a976d 100755 --- a/script/build_and_run.sh +++ b/script/build_and_run.sh @@ -152,10 +152,20 @@ require_xcode() { fi } +# 注销项目固定 DerivedData 路径中的开发构建,避免 LaunchServices 保留失效扩展。 +unregister_build_product() { + /usr/bin/pluginkit -r "$APP_EXTENSION" >/dev/null 2>&1 || true + + if [[ -x "$LSREGISTER" ]]; then + "$LSREGISTER" -u "$APP_BUNDLE" >/dev/null 2>&1 || true + fi +} + # 安全地清理编译缓存,避免危险删除 clean_derived_data() { # 安全审计:确保路径不为空,且父目录属于该项目,防止 rm -rf 误伤系统根目录或用户主目录 if [[ -n "${DERIVED_DATA_PATH:-}" && "$DERIVED_DATA_PATH" == *"/DerivedData" && "$DERIVED_DATA_PATH" == "$ROOT_DIR/DerivedData" ]]; then + unregister_build_product log_info "正在清理构建缓存: $DERIVED_DATA_PATH" rm -rf "$DERIVED_DATA_PATH" else diff --git a/script/package.sh b/script/package.sh index 0b617e8..a7efc55 100755 --- a/script/package.sh +++ b/script/package.sh @@ -10,9 +10,11 @@ set -euo pipefail # 1. 默认配置与路径定义 # ------------------------------------------------------------------------------ APP_NAME="BetterMenu" +EXTENSION_NAME="BetterMenuFinderSync" SCHEME_NAME="BetterMenu" PROJECT_NAME="BetterMenu.xcodeproj" CONFIGURATION="Release" +VOLUME_NAME="$APP_NAME" # 获取项目根目录 (绝对路径) ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -20,6 +22,10 @@ DERIVED_DATA_PATH="$ROOT_DIR/DerivedData" DIST_DIR="$ROOT_DIR/dist" STAGE_DIR="$DIST_DIR/dmg-root" APP_BUNDLE="$DERIVED_DATA_PATH/Build/Products/$CONFIGURATION/$APP_NAME.app" +APP_EXTENSION="$APP_BUNDLE/Contents/PlugIns/$EXTENSION_NAME.appex" +DMG_APP_BUNDLE="/Volumes/$VOLUME_NAME/$APP_NAME.app" +DMG_APP_EXTENSION="$DMG_APP_BUNDLE/Contents/PlugIns/$EXTENSION_NAME.appex" +LSREGISTER="/System/Library/Frameworks/CoreServices.framework/Versions/Current/Frameworks/LaunchServices.framework/Versions/Current/Support/lsregister" # ------------------------------------------------------------------------------ # 2. 终端彩色日志输出工具函数 @@ -43,7 +49,19 @@ log_error() { # ------------------------------------------------------------------------------ # 3. 自动垃圾回收 (无论成功或异常出错退出,均安全擦除临时打包目录) # ------------------------------------------------------------------------------ +unregister_build_product() { + /usr/bin/pluginkit -r "$APP_EXTENSION" >/dev/null 2>&1 || true + /usr/bin/pluginkit -r "$DMG_APP_EXTENSION" >/dev/null 2>&1 || true + + if [[ -x "$LSREGISTER" ]]; then + "$LSREGISTER" -u "$APP_BUNDLE" >/dev/null 2>&1 || true + "$LSREGISTER" -u "$DMG_APP_BUNDLE" >/dev/null 2>&1 || true + fi +} + cleanup() { + unregister_build_product + if [[ -d "$STAGE_DIR" ]]; then rm -rf "$STAGE_DIR" fi @@ -70,7 +88,6 @@ fi DMG_NAME="$APP_NAME-$VERSION.dmg" ZIP_NAME="$APP_NAME-$VERSION.zip" -VOLUME_NAME="$APP_NAME" # ------------------------------------------------------------------------------ # 5. 执行环境校验与步骤函数 From 7b347ef9668ff1dacd57362665d63b3e0bce3c51 Mon Sep 17 00:00:00 2001 From: zombie Date: Sun, 26 Jul 2026 21:50:50 +0800 Subject: [PATCH 06/10] =?UTF-8?q?refactor:=20=E7=AE=80=E5=8C=96=E9=9A=94?= =?UTF-8?q?=E7=A9=BA=E6=8A=95=E9=80=81=E6=9C=8D=E5=8A=A1=E7=9A=84=E5=8F=AF?= =?UTF-8?q?=E7=94=A8=E6=80=A7=E6=A3=80=E6=9F=A5=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BetterMenu/Services/FileTransferService.swift | 5 +---- BetterMenuFinderSync/FinderSync.swift | 8 +------- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/BetterMenu/Services/FileTransferService.swift b/BetterMenu/Services/FileTransferService.swift index 68779ba..e244f67 100644 --- a/BetterMenu/Services/FileTransferService.swift +++ b/BetterMenu/Services/FileTransferService.swift @@ -22,10 +22,7 @@ final class FileTransferService { showError(title: "无法发送文件", message: "选中的文件或文件夹已不存在。") return } - guard - let service = NSSharingService(named: .sendViaAirDrop), - service.canPerform(withItems: existingUrls) - else { + guard let service = NSSharingService(named: .sendViaAirDrop) else { showError(title: "隔空投送不可用", message: "请确认隔空投送已开启后再试。") return } diff --git a/BetterMenuFinderSync/FinderSync.swift b/BetterMenuFinderSync/FinderSync.swift index 9b1b4ce..7d6c52a 100644 --- a/BetterMenuFinderSync/FinderSync.swift +++ b/BetterMenuFinderSync/FinderSync.swift @@ -180,9 +180,7 @@ final class FinderSync: FIFinderSync { named: action.iconName, accessibilityDescription: action.title) let submenu = NSMenu(title: action.title) - if let airDropService = NSSharingService(named: .sendViaAirDrop), - airDropService.canPerform(withItems: selectedUrls) - { + if let airDropService = NSSharingService(named: .sendViaAirDrop) { let airDropItem = NSMenuItem( title: "隔空投送…", action: #selector(sendSelectedItemsViaAirDrop(_:)), @@ -199,10 +197,6 @@ final class FinderSync: FIFinderSync { let transferVolumes = BetterMenuShared.availableFileTransferVolumes().filter { !selectedVolumePaths.contains($0.url.path) } - if !submenu.items.isEmpty, !transferVolumes.isEmpty { - submenu.addItem(.separator()) - } - for (offset, volume) in transferVolumes.enumerated() { let tag = Self.fileTransferVolumeTagBase + offset let item = NSMenuItem( From 89b1ec042fb3f6ac9d57b7c1bc2601d768d9e4e7 Mon Sep 17 00:00:00 2001 From: zombie Date: Sun, 26 Jul 2026 22:51:35 +0800 Subject: [PATCH 07/10] Refactor SettingsMonitor to use DirectoryChangeMonitor for directory monitoring - Replaced manual file descriptor management in SettingsMonitor with DirectoryChangeMonitor for improved readability and maintainability. - Updated the logic for handling settings changes and invalidating cached configurations. - Added new tests for file transfer cancellation and partial failure scenarios in BetterMenuCoreServiceTests. - Introduced BetterMenuSecureRequest for secure communication between Finder extension and main app, ensuring safe request handling. - Implemented code style checks and formatting using swift-format, enforcing consistent code style across the project. - Updated build_and_run.sh script to support incremental builds and added commands for code formatting and linting. - Documented changes in DEVELOPMENT.md and README.md to reflect new features and guidelines. --- .github/workflows/ci.yml | 34 ++ .gitignore | 1 + .swift-format | 62 +++ BetterMenu.xcodeproj/project.pbxproj | 12 + BetterMenu/AppDelegate.swift | 9 +- BetterMenu/BetterMenuSecureRequest.swift | 374 ++++++++++++++++ BetterMenu/BetterMenuShared.swift | 406 ++---------------- .../Models/BetterMenuSettingsModel.swift | 22 +- .../Services/FileTransferOperations.swift | 99 ++++- .../Services/FileTransferProgressWindow.swift | 35 +- BetterMenu/Services/FileTransferService.swift | 42 +- BetterMenu/Services/IconCacheManager.swift | 13 +- .../BetterMenuFileTypeSettingsViews.swift | 14 +- .../DirectoryChangeMonitor.swift | 73 ++++ BetterMenuFinderSync/FileCreator.swift | 4 +- BetterMenuFinderSync/FinderSync.swift | 39 +- BetterMenuFinderSync/MenuIconManager.swift | 59 ++- BetterMenuFinderSync/SettingsMonitor.swift | 81 ++-- .../BetterMenuCoreServiceTests.swift | 89 ++++ BetterMenuTests/BetterMenuSecurityTests.swift | 17 + DEVELOPMENT.md | 19 +- README.md | 9 + script/build_and_run.sh | 54 ++- 23 files changed, 1043 insertions(+), 524 deletions(-) create mode 100644 .swift-format create mode 100644 BetterMenu/BetterMenuSecureRequest.swift create mode 100644 BetterMenuFinderSync/DirectoryChangeMonitor.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bed07eb..464ba13 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,16 +31,28 @@ jobs: run: | xcodebuild -version + # 步骤 2.4:校验代码风格。swift-format 随 Xcode 提供,无需额外安装依赖。 + # --strict 让任何风格告警都以非零退出码结束,把当前统一的排版固化为门禁。 + - name: Lint Formatting + run: | + xcrun swift-format lint \ + --configuration .swift-format \ + --recursive \ + --strict \ + BetterMenu BetterMenuFinderSync BetterMenuTests + # 步骤 2.5:运行安全通信、路径处理与设置迁移单元测试 - name: Run Tests run: | TEST_DERIVED_DATA="$RUNNER_TEMP/BetterMenuTestsDerivedData" + echo "TEST_DERIVED_DATA=$TEST_DERIVED_DATA" >> "$GITHUB_ENV" xcodebuild \ -project BetterMenu.xcodeproj \ -scheme BetterMenu \ -configuration Debug \ -destination 'platform=macOS' \ -derivedDataPath "$TEST_DERIVED_DATA" \ + -resultBundlePath "$RUNNER_TEMP/BetterMenuTests.xcresult" \ test UNEXPECTED_PRODUCTS="$( @@ -55,6 +67,17 @@ jobs: exit 1 fi + # 步骤 2.55:无论测试通过与否都上传 xcresult 包,失败时可直接下载查看具体失败用例, + # 不必再依赖日志尾部输出定位问题。 + - name: Upload Test Results + if: always() + uses: actions/upload-artifact@v7 + with: + name: BetterMenu-TestResults + path: ${{ runner.temp }}/BetterMenuTests.xcresult + if-no-files-found: warn + retention-days: 14 + # 步骤 2.6:安装 create-dmg 以启用精美 DMG 打包布局 - name: Install create-dmg run: | @@ -66,6 +89,17 @@ jobs: chmod +x script/package.sh ./script/package.sh > build.log 2>&1 || { echo "=== BUILD FAILED ==="; tail -n 200 build.log; exit 1; } + # 步骤 3.5:上传完整打包日志。tail -n 200 只能看到尾部, + # 编译错误常出现在中段,因此把整份日志留作工件。 + - name: Upload Build Log + if: always() + uses: actions/upload-artifact@v7 + with: + name: BetterMenu-BuildLog + path: build.log + if-no-files-found: warn + retention-days: 14 + # 步骤 4:当推送以 'v' 开头的 tag 时,下载 Sparkle 工具生成 appcast.xml 并对其签名 - name: Generate Sparkle Appcast if: startsWith(github.ref, 'refs/tags/v') diff --git a/.gitignore b/.gitignore index 7af7351..03af65e 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ dist/ # 本地设计源文件或临时素材,不影响应用构建。 docs/* !docs/screenshots/ +CLAUDE.md diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..c43d5cc --- /dev/null +++ b/.swift-format @@ -0,0 +1,62 @@ +{ + "version": 1, + "lineLength": 100, + "indentation": { + "spaces": 2 + }, + "tabWidth": 2, + "maximumBlankLines": 1, + "respectsExistingLineBreaks": true, + "lineBreakBeforeControlFlowKeywords": false, + "lineBreakBeforeEachArgument": false, + "lineBreakBeforeEachGenericRequirement": false, + "lineBreakBetweenDeclarationAttributes": false, + "prioritizeKeepingFunctionOutputTogether": false, + "indentConditionalCompilationBlocks": false, + "indentSwitchCaseLabels": false, + "spacesAroundRangeFormationOperators": false, + "multiElementCollectionTrailingCommas": true, + "rules": { + "AllPublicDeclarationsHaveDocumentation": false, + "AlwaysUseLiteralForEmptyCollectionInit": false, + "AlwaysUseLowerCamelCase": true, + "AmbiguousTrailingClosureOverload": true, + "BeginDocumentationCommentWithOneLineSummary": false, + "DoNotUseSemicolons": true, + "DontRepeatTypeInStaticProperties": true, + "FileScopedDeclarationPrivacy": true, + "FullyIndirectEnum": true, + "GroupNumericLiterals": true, + "IdentifiersMustBeASCII": true, + "NeverForceUnwrap": false, + "NeverUseForceTry": false, + "NeverUseImplicitlyUnwrappedOptionals": false, + "NoAccessLevelOnExtensionDeclaration": true, + "NoAssignmentInExpressions": true, + "NoBlockComments": false, + "NoCasesWithOnlyFallthrough": true, + "NoEmptyLinesOpeningClosingBraces": false, + "NoEmptyTrailingClosureParentheses": true, + "NoLabelsInCasePatterns": true, + "NoLeadingUnderscores": false, + "NoParensAroundConditions": true, + "NoPlaygroundLiterals": true, + "NoVoidReturnOnFunctionSignature": true, + "OmitExplicitReturns": false, + "OneCasePerLine": true, + "OneVariableDeclarationPerLine": true, + "OnlyOneTrailingClosureArgument": true, + "OrderedImports": true, + "ReplaceForEachWithForLoop": false, + "ReturnVoidInsteadOfEmptyTuple": true, + "TypeNamesShouldBeCapitalized": true, + "UseEarlyExits": false, + "UseLetInEveryBoundCaseVariable": false, + "UseShorthandTypeNames": true, + "UseSingleLinePropertyGetter": true, + "UseSynthesizedInitializer": false, + "UseTripleSlashForDocumentationComments": true, + "UseWhereClausesInForLoops": false, + "ValidateDocumentationComments": false + } +} diff --git a/BetterMenu.xcodeproj/project.pbxproj b/BetterMenu.xcodeproj/project.pbxproj index 72a3404..182d68a 100644 --- a/BetterMenu.xcodeproj/project.pbxproj +++ b/BetterMenu.xcodeproj/project.pbxproj @@ -18,6 +18,9 @@ A10000000000000000000100 /* BetterMenuSettingsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000200 /* BetterMenuSettingsModel.swift */; }; A10000000000000000000101 /* BetterMenuSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000201 /* BetterMenuSettingsView.swift */; }; A10000000000000000000300 /* BetterMenuShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000301 /* BetterMenuShared.swift */; }; + A10000000000000000000742 /* BetterMenuSecureRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000741 /* BetterMenuSecureRequest.swift */; }; + A10000000000000000000743 /* BetterMenuSecureRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000741 /* BetterMenuSecureRequest.swift */; }; + A10000000000000000000744 /* BetterMenuSecureRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000741 /* BetterMenuSecureRequest.swift */; }; A10000000000000000000302 /* BetterMenuShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000301 /* BetterMenuShared.swift */; }; A10000000000000000000402 /* TerminalApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000400 /* TerminalApp.swift */; }; A10000000000000000000403 /* ExternalAppLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000401 /* ExternalAppLauncher.swift */; }; @@ -34,6 +37,7 @@ A10000000000000000000700 /* SettingsMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000701 /* SettingsMonitor.swift */; }; A10000000000000000000710 /* MenuIconManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000711 /* MenuIconManager.swift */; }; A10000000000000000000720 /* FileCreator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000721 /* FileCreator.swift */; }; + A10000000000000000000730 /* DirectoryChangeMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000731 /* DirectoryChangeMonitor.swift */; }; A65100FA5752180F6B4730FB /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 529E8C462C2C9F3A67CF1C58 /* Sparkle */; }; A10000000000000000000093 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000090 /* InfoPlist.strings */; }; B20000000000000000000001 /* BetterMenuSecurityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000012 /* BetterMenuSecurityTests.swift */; }; @@ -86,6 +90,7 @@ A10000000000000000000200 /* BetterMenuSettingsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BetterMenuSettingsModel.swift; sourceTree = ""; }; A10000000000000000000201 /* BetterMenuSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BetterMenuSettingsView.swift; sourceTree = ""; }; A10000000000000000000301 /* BetterMenuShared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BetterMenuShared.swift; sourceTree = ""; }; + A10000000000000000000741 /* BetterMenuSecureRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BetterMenuSecureRequest.swift; sourceTree = ""; }; A10000000000000000000400 /* TerminalApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalApp.swift; sourceTree = ""; }; A10000000000000000000401 /* ExternalAppLauncher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExternalAppLauncher.swift; sourceTree = ""; }; A10000000000000000000510 /* BetterMenuGeneralSettingsViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BetterMenuGeneralSettingsViews.swift; sourceTree = ""; }; @@ -101,6 +106,7 @@ A10000000000000000000701 /* SettingsMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsMonitor.swift; sourceTree = ""; }; A10000000000000000000711 /* MenuIconManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuIconManager.swift; sourceTree = ""; }; A10000000000000000000721 /* FileCreator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileCreator.swift; sourceTree = ""; }; + A10000000000000000000731 /* DirectoryChangeMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectoryChangeMonitor.swift; sourceTree = ""; }; A10000000000000000000091 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = ""; }; A10000000000000000000092 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; sourceTree = ""; }; B20000000000000000000011 /* BetterMenuTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BetterMenuTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -154,6 +160,7 @@ A10000000000000000000902 /* Views */, A10000000000000000000903 /* Services */, A10000000000000000000301 /* BetterMenuShared.swift */, + A10000000000000000000741 /* BetterMenuSecureRequest.swift */, A10000000000000000000025 /* Assets.xcassets */, A10000000000000000000015 /* Info.plist */, A10000000000000000000016 /* BetterMenu.entitlements */, @@ -204,6 +211,7 @@ A10000000000000000000701 /* SettingsMonitor.swift */, A10000000000000000000711 /* MenuIconManager.swift */, A10000000000000000000721 /* FileCreator.swift */, + A10000000000000000000731 /* DirectoryChangeMonitor.swift */, A10000000000000000000027 /* blank.docx */, A10000000000000000000028 /* blank.xlsx */, A10000000000000000000029 /* blank.pptx */, @@ -396,6 +404,7 @@ A10000000000000000000502 /* BetterMenuPermissionAboutViews.swift in Sources */, A10000000000000000000503 /* BetterMenuSettingsComponents.swift in Sources */, A10000000000000000000300 /* BetterMenuShared.swift in Sources */, + A10000000000000000000742 /* BetterMenuSecureRequest.swift in Sources */, A10000000000000000000600 /* SystemCommand.swift in Sources */, A10000000000000000000610 /* IconCacheManager.swift in Sources */, A10000000000000000000620 /* FileTransferService.swift in Sources */, @@ -411,9 +420,11 @@ files = ( A10000000000000000000003 /* FinderSync.swift in Sources */, A10000000000000000000302 /* BetterMenuShared.swift in Sources */, + A10000000000000000000743 /* BetterMenuSecureRequest.swift in Sources */, A10000000000000000000700 /* SettingsMonitor.swift in Sources */, A10000000000000000000710 /* MenuIconManager.swift in Sources */, A10000000000000000000720 /* FileCreator.swift in Sources */, + A10000000000000000000730 /* DirectoryChangeMonitor.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -424,6 +435,7 @@ B20000000000000000000001 /* BetterMenuSecurityTests.swift in Sources */, B20000000000000000000002 /* BetterMenuCoreServiceTests.swift in Sources */, B20000000000000000000003 /* BetterMenuShared.swift in Sources */, + A10000000000000000000744 /* BetterMenuSecureRequest.swift in Sources */, B20000000000000000000004 /* AppleScriptPathArgument.swift in Sources */, B20000000000000000000005 /* FileTransferOperations.swift in Sources */, B20000000000000000000006 /* FileCreator.swift in Sources */, diff --git a/BetterMenu/AppDelegate.swift b/BetterMenu/AppDelegate.swift index 511972d..afe6afc 100644 --- a/BetterMenu/AppDelegate.swift +++ b/BetterMenu/AppDelegate.swift @@ -1,7 +1,7 @@ import Cocoa +import Sparkle import SwiftUI import os -import Sparkle /// 状态栏菜单的 SwiftUI 视图定义 struct BetterMenuBarView: View { @@ -114,8 +114,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { guard let event = NSAppleEventManager.shared().currentAppleEvent else { return false } - return event.eventID == kAEOpenApplication && - event.paramDescriptor(forKeyword: keyAEPropData)?.enumCodeValue == keyAELaunchedAsLogInItem + return event.eventID == kAEOpenApplication + && event.paramDescriptor(forKeyword: keyAEPropData)?.enumCodeValue == keyAELaunchedAsLogInItem } override init() { @@ -127,7 +127,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { func applicationDidFinishLaunching(_ notification: Notification) { // 初始化 Sparkle 自动更新组件 - updaterController = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil) + updaterController = SPUStandardUpdaterController( + startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil) settingsModel.onDisplayModeDidChange = { [weak self] mode in self?.applyDisplayMode(mode) diff --git a/BetterMenu/BetterMenuSecureRequest.swift b/BetterMenu/BetterMenuSecureRequest.swift new file mode 100644 index 0000000..4c6d44e --- /dev/null +++ b/BetterMenu/BetterMenuSecureRequest.swift @@ -0,0 +1,374 @@ +import Darwin +import Foundation + +// MARK: - Finder 扩展与主应用之间的一次性安全请求 +// +// 扩展不得通过 URL Scheme 明文传递动作 ID 或文件路径,只能传递不透明 UUID。 +// 请求本体以 0600 权限原子写入 `~/Library/Application Support/BetterMenu/Requests/`, +// 由主应用校验所有者、权限位、文件类型、体积、协议版本、路径格式与有效期后一次性消费。 +// 该文件同时编译进主 App、FinderSync 扩展与测试三个 Target,是三者共享的安全边界, +// 因此从 `BetterMenuShared.swift` 中独立出来单独维护与审查。 + +/// Finder 扩展可以委托给主应用执行的请求类型。 +enum BetterMenuRequestOperation: String, Codable, Sendable { + case runAction + case fileTransfer +} + +/// 文件传输请求的执行方式。 +enum BetterMenuFileTransferMode: String, Codable, Sendable { + case airDrop + case copy +} + +/// Finder 扩展写入磁盘、由主应用一次性消费的安全请求。 +struct BetterMenuSecureRequest: Codable, Equatable, Sendable { + static let currentVersion = 1 + + let version: Int + let id: UUID + let createdAt: TimeInterval + let operation: BetterMenuRequestOperation + let actionId: String? + let sourcePaths: [String] + let transferMode: BetterMenuFileTransferMode? + let destinationPath: String? + + static func runAction( + actionId: String, + path: String, + id: UUID = UUID(), + createdAt: TimeInterval = Date().timeIntervalSince1970 + ) -> BetterMenuSecureRequest { + BetterMenuSecureRequest( + version: currentVersion, + id: id, + createdAt: createdAt, + operation: .runAction, + actionId: actionId, + sourcePaths: [path], + transferMode: nil, + destinationPath: nil + ) + } + + static func fileTransfer( + sourcePaths: [String], + mode: BetterMenuFileTransferMode, + destinationPath: String?, + id: UUID = UUID(), + createdAt: TimeInterval = Date().timeIntervalSince1970 + ) -> BetterMenuSecureRequest { + BetterMenuSecureRequest( + version: currentVersion, + id: id, + createdAt: createdAt, + operation: .fileTransfer, + actionId: nil, + sourcePaths: sourcePaths, + transferMode: mode, + destinationPath: destinationPath + ) + } +} + +/// 一次性请求文件的写入、URL 生成和安全消费错误。 +enum BetterMenuSecureRequestError: LocalizedError { + case invalidLaunchUrl + case invalidRequest + case requestExpired + case requestFileUnavailable + case insecureRequestFile + + var errorDescription: String? { + switch self { + case .invalidLaunchUrl: + return "请求链接无效。" + case .invalidRequest: + return "请求内容无效。" + case .requestExpired: + return "请求已过期。" + case .requestFileUnavailable: + return "请求文件不存在或无法读取。" + case .insecureRequestFile: + return "请求文件未通过安全检查。" + } + } +} + +/// 通过仅包含不透明 UUID 的 URL Scheme,在 Finder 扩展与主应用间传递一次性请求。 +enum BetterMenuSecureRequestStore { + private static let maximumRequestAge: TimeInterval = 30 + private static let maximumFutureClockSkew: TimeInterval = 5 + private static let maximumRequestFileSize: off_t = 1_048_576 + private static let requestFileExtension = "plist" + private static let launchScheme = "bettermenu" + private static let launchHost = "request" + + static func submit( + _ request: BetterMenuSecureRequest, + directoryUrl: URL = BetterMenuShared.secureRequestDirectoryUrl + ) throws { + try validateStructure(request) + try prepareRequestDirectory(directoryUrl) + removeExpiredFiles(in: directoryUrl) + + let data = try PropertyListEncoder().encode(request) + let requestUrl = fileUrl(for: request.id, directoryUrl: directoryUrl) + try writeSecurely(data, to: requestUrl) + } + + static func launchUrl(for requestId: UUID) throws -> URL { + var components = URLComponents() + components.scheme = launchScheme + components.host = launchHost + components.queryItems = [ + URLQueryItem(name: "id", value: requestId.uuidString.lowercased()) + ] + guard let url = components.url else { + throw BetterMenuSecureRequestError.invalidLaunchUrl + } + return url + } + + static func requestId(from launchUrl: URL) throws -> UUID { + guard + launchUrl.scheme?.lowercased() == launchScheme, + launchUrl.host?.lowercased() == launchHost, + let components = URLComponents(url: launchUrl, resolvingAgainstBaseURL: false), + components.user == nil, + components.password == nil, + components.port == nil, + components.path.isEmpty, + components.fragment == nil, + let queryItems = components.queryItems, + queryItems.count == 1, + queryItems[0].name == "id", + let value = queryItems[0].value, + let requestId = UUID(uuidString: value) + else { + throw BetterMenuSecureRequestError.invalidLaunchUrl + } + return requestId + } + + static func consume( + requestId: UUID, + directoryUrl: URL = BetterMenuShared.secureRequestDirectoryUrl, + now: TimeInterval = Date().timeIntervalSince1970 + ) throws -> BetterMenuSecureRequest { + try validateRequestDirectory(directoryUrl) + let requestUrl = fileUrl(for: requestId, directoryUrl: directoryUrl) + let fileDescriptor = open(requestUrl.path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW) + guard fileDescriptor >= 0 else { + throw BetterMenuSecureRequestError.requestFileUnavailable + } + + var shouldCloseDescriptor = true + defer { + if shouldCloseDescriptor { + close(fileDescriptor) + } + } + + var fileStatus = stat() + guard fstat(fileDescriptor, &fileStatus) == 0 else { + throw BetterMenuSecureRequestError.requestFileUnavailable + } + + let isRegularFile = (fileStatus.st_mode & S_IFMT) == S_IFREG + let hasRestrictedPermissions = (fileStatus.st_mode & 0o077) == 0 + guard + isRegularFile, + fileStatus.st_uid == getuid(), + hasRestrictedPermissions, + fileStatus.st_size > 0, + fileStatus.st_size <= maximumRequestFileSize + else { + throw BetterMenuSecureRequestError.insecureRequestFile + } + + guard unlink(requestUrl.path) == 0 else { + throw BetterMenuSecureRequestError.requestFileUnavailable + } + + let fileHandle = FileHandle(fileDescriptor: fileDescriptor, closeOnDealloc: true) + shouldCloseDescriptor = false + let data = try fileHandle.readToEnd() ?? Data() + try fileHandle.close() + + let request = try PropertyListDecoder().decode(BetterMenuSecureRequest.self, from: data) + guard request.id == requestId else { + throw BetterMenuSecureRequestError.invalidRequest + } + try validateStructure(request) + + let requestAge = now - request.createdAt + guard + requestAge >= -maximumFutureClockSkew, + requestAge <= maximumRequestAge + else { + throw BetterMenuSecureRequestError.requestExpired + } + + return request + } + + static func discard( + requestId: UUID, + directoryUrl: URL = BetterMenuShared.secureRequestDirectoryUrl + ) { + try? FileManager.default.removeItem(at: fileUrl(for: requestId, directoryUrl: directoryUrl)) + } + + private static func prepareRequestDirectory(_ directoryUrl: URL) throws { + try FileManager.default.createDirectory( + at: directoryUrl, + withIntermediateDirectories: true + ) + try FileManager.default.setAttributes( + [.posixPermissions: 0o700], + ofItemAtPath: directoryUrl.path + ) + try validateRequestDirectory(directoryUrl) + } + + private static func validateRequestDirectory(_ directoryUrl: URL) throws { + var directoryStatus = stat() + guard + lstat(directoryUrl.path, &directoryStatus) == 0, + (directoryStatus.st_mode & S_IFMT) == S_IFDIR, + directoryStatus.st_uid == getuid(), + (directoryStatus.st_mode & 0o077) == 0 + else { + throw BetterMenuSecureRequestError.insecureRequestFile + } + } + + private static func writeSecurely(_ data: Data, to requestUrl: URL) throws { + let fileDescriptor = open( + requestUrl.path, + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, + mode_t(0o600) + ) + guard fileDescriptor >= 0 else { + throw BetterMenuSecureRequestError.requestFileUnavailable + } + + var didFinishWriting = false + defer { + close(fileDescriptor) + if !didFinishWriting { + unlink(requestUrl.path) + } + } + + try data.withUnsafeBytes { rawBuffer in + guard let baseAddress = rawBuffer.baseAddress else { + throw BetterMenuSecureRequestError.invalidRequest + } + + var totalBytesWritten = 0 + while totalBytesWritten < rawBuffer.count { + let bytesWritten = Darwin.write( + fileDescriptor, + baseAddress.advanced(by: totalBytesWritten), + rawBuffer.count - totalBytesWritten + ) + if bytesWritten < 0, errno == EINTR { + continue + } + guard bytesWritten > 0 else { + throw BetterMenuSecureRequestError.requestFileUnavailable + } + totalBytesWritten += bytesWritten + } + } + + guard fsync(fileDescriptor) == 0 else { + throw BetterMenuSecureRequestError.requestFileUnavailable + } + didFinishWriting = true + } + + private static func fileUrl(for requestId: UUID, directoryUrl: URL) -> URL { + directoryUrl + .appendingPathComponent(requestId.uuidString.lowercased()) + .appendingPathExtension(requestFileExtension) + } + + private static func validateStructure(_ request: BetterMenuSecureRequest) throws { + guard + request.version == BetterMenuSecureRequest.currentVersion, + request.sourcePaths.allSatisfy(isStandardAbsolutePath(_:)) + else { + throw BetterMenuSecureRequestError.invalidRequest + } + + switch request.operation { + case .runAction: + guard + let actionId = request.actionId, + !actionId.isEmpty, + request.sourcePaths.count == 1, + request.transferMode == nil, + request.destinationPath == nil + else { + throw BetterMenuSecureRequestError.invalidRequest + } + case .fileTransfer: + guard + request.actionId == nil, + !request.sourcePaths.isEmpty, + let transferMode = request.transferMode + else { + throw BetterMenuSecureRequestError.invalidRequest + } + + switch transferMode { + case .airDrop: + guard request.destinationPath == nil else { + throw BetterMenuSecureRequestError.invalidRequest + } + case .copy: + guard + let destinationPath = request.destinationPath, + isStandardAbsolutePath(destinationPath) + else { + throw BetterMenuSecureRequestError.invalidRequest + } + } + } + } + + private static func isStandardAbsolutePath(_ path: String) -> Bool { + guard path.hasPrefix("/") else { return false } + return URL(fileURLWithPath: path).standardizedFileURL.path == path + } + + private static func removeExpiredFiles(in directoryUrl: URL) { + let expirationDate = Date().addingTimeInterval(-maximumRequestAge * 2) + guard + let fileUrls = try? FileManager.default.contentsOfDirectory( + at: directoryUrl, + includingPropertiesForKeys: [.contentModificationDateKey, .isRegularFileKey], + options: [.skipsHiddenFiles] + ) + else { + return + } + + for fileUrl in fileUrls where fileUrl.pathExtension == requestFileExtension { + guard + let values = try? fileUrl.resourceValues( + forKeys: [.contentModificationDateKey, .isRegularFileKey]), + values.isRegularFile == true, + let modificationDate = values.contentModificationDate, + modificationDate < expirationDate + else { + continue + } + try? FileManager.default.removeItem(at: fileUrl) + } + } +} diff --git a/BetterMenu/BetterMenuShared.swift b/BetterMenu/BetterMenuShared.swift index c36124e..769991c 100644 --- a/BetterMenu/BetterMenuShared.swift +++ b/BetterMenu/BetterMenuShared.swift @@ -15,6 +15,22 @@ enum BetterMenuShared { static let terminalTypeKey = "terminalType" static let customMenuIdPrefix = "custom." + /// 菜单项与设置面板中图标的统一渲染尺寸。图标缓存按此尺寸预渲染, + /// 两侧取值必须一致,否则扩展读到的缓存图标会被系统再次缩放而模糊。 + static let menuIconSize = NSSize(width: 18, height: 18) + + /// 拼装自定义扩展名对应的菜单项 ID。 + /// 该 ID 同时用于设置存储、图标缓存键与扩展侧菜单构建,必须只有一处定义。 + static func customMenuId(for fileExtension: String) -> String { + "\(customMenuIdPrefix)\(fileExtension)" + } + + /// 从菜单项 ID 还原自定义扩展名,非自定义 ID 返回 nil。 + static func customExtension(fromMenuId id: String) -> String? { + guard id.hasPrefix(customMenuIdPrefix) else { return nil } + return String(id.dropFirst(customMenuIdPrefix.count)) + } + /// 获取用户真正的 Home 目录 URL。 /// /// FinderSync 插件运行在沙盒中,直接调用 @@ -296,376 +312,12 @@ enum BetterMenuShared { text: "#!/usr/bin/env bash\n\n", shouldBeExecutable: true, enabledByDefault: false - ) + ), ] } // MARK: - 共享数据结构定义 -/// Finder 扩展可以委托给主应用执行的请求类型。 -enum BetterMenuRequestOperation: String, Codable, Sendable { - case runAction - case fileTransfer -} - -/// 文件传输请求的执行方式。 -enum BetterMenuFileTransferMode: String, Codable, Sendable { - case airDrop - case copy -} - -/// Finder 扩展写入磁盘、由主应用一次性消费的安全请求。 -struct BetterMenuSecureRequest: Codable, Equatable, Sendable { - static let currentVersion = 1 - - let version: Int - let id: UUID - let createdAt: TimeInterval - let operation: BetterMenuRequestOperation - let actionId: String? - let sourcePaths: [String] - let transferMode: BetterMenuFileTransferMode? - let destinationPath: String? - - static func runAction( - actionId: String, - path: String, - id: UUID = UUID(), - createdAt: TimeInterval = Date().timeIntervalSince1970 - ) -> BetterMenuSecureRequest { - BetterMenuSecureRequest( - version: currentVersion, - id: id, - createdAt: createdAt, - operation: .runAction, - actionId: actionId, - sourcePaths: [path], - transferMode: nil, - destinationPath: nil - ) - } - - static func fileTransfer( - sourcePaths: [String], - mode: BetterMenuFileTransferMode, - destinationPath: String?, - id: UUID = UUID(), - createdAt: TimeInterval = Date().timeIntervalSince1970 - ) -> BetterMenuSecureRequest { - BetterMenuSecureRequest( - version: currentVersion, - id: id, - createdAt: createdAt, - operation: .fileTransfer, - actionId: nil, - sourcePaths: sourcePaths, - transferMode: mode, - destinationPath: destinationPath - ) - } -} - -/// 一次性请求文件的写入、URL 生成和安全消费错误。 -enum BetterMenuSecureRequestError: LocalizedError { - case invalidLaunchUrl - case invalidRequest - case requestExpired - case requestFileUnavailable - case insecureRequestFile - - var errorDescription: String? { - switch self { - case .invalidLaunchUrl: - return "请求链接无效。" - case .invalidRequest: - return "请求内容无效。" - case .requestExpired: - return "请求已过期。" - case .requestFileUnavailable: - return "请求文件不存在或无法读取。" - case .insecureRequestFile: - return "请求文件未通过安全检查。" - } - } -} - -/// 通过仅包含不透明 UUID 的 URL Scheme,在 Finder 扩展与主应用间传递一次性请求。 -enum BetterMenuSecureRequestStore { - private static let maximumRequestAge: TimeInterval = 30 - private static let maximumFutureClockSkew: TimeInterval = 5 - private static let maximumRequestFileSize: off_t = 1_048_576 - private static let requestFileExtension = "plist" - private static let launchScheme = "bettermenu" - private static let launchHost = "request" - - static func submit( - _ request: BetterMenuSecureRequest, - directoryUrl: URL = BetterMenuShared.secureRequestDirectoryUrl - ) throws { - try validateStructure(request) - try prepareRequestDirectory(directoryUrl) - removeExpiredFiles(in: directoryUrl) - - let data = try PropertyListEncoder().encode(request) - let requestUrl = fileUrl(for: request.id, directoryUrl: directoryUrl) - try writeSecurely(data, to: requestUrl) - } - - static func launchUrl(for requestId: UUID) throws -> URL { - var components = URLComponents() - components.scheme = launchScheme - components.host = launchHost - components.queryItems = [ - URLQueryItem(name: "id", value: requestId.uuidString.lowercased()) - ] - guard let url = components.url else { - throw BetterMenuSecureRequestError.invalidLaunchUrl - } - return url - } - - static func requestId(from launchUrl: URL) throws -> UUID { - guard - launchUrl.scheme?.lowercased() == launchScheme, - launchUrl.host?.lowercased() == launchHost, - let components = URLComponents(url: launchUrl, resolvingAgainstBaseURL: false), - components.user == nil, - components.password == nil, - components.port == nil, - components.path.isEmpty, - components.fragment == nil, - let queryItems = components.queryItems, - queryItems.count == 1, - queryItems[0].name == "id", - let value = queryItems[0].value, - let requestId = UUID(uuidString: value) - else { - throw BetterMenuSecureRequestError.invalidLaunchUrl - } - return requestId - } - - static func consume( - requestId: UUID, - directoryUrl: URL = BetterMenuShared.secureRequestDirectoryUrl, - now: TimeInterval = Date().timeIntervalSince1970 - ) throws -> BetterMenuSecureRequest { - try validateRequestDirectory(directoryUrl) - let requestUrl = fileUrl(for: requestId, directoryUrl: directoryUrl) - let fileDescriptor = open(requestUrl.path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW) - guard fileDescriptor >= 0 else { - throw BetterMenuSecureRequestError.requestFileUnavailable - } - - var shouldCloseDescriptor = true - defer { - if shouldCloseDescriptor { - close(fileDescriptor) - } - } - - var fileStatus = stat() - guard fstat(fileDescriptor, &fileStatus) == 0 else { - throw BetterMenuSecureRequestError.requestFileUnavailable - } - - let isRegularFile = (fileStatus.st_mode & S_IFMT) == S_IFREG - let hasRestrictedPermissions = (fileStatus.st_mode & 0o077) == 0 - guard - isRegularFile, - fileStatus.st_uid == getuid(), - hasRestrictedPermissions, - fileStatus.st_size > 0, - fileStatus.st_size <= maximumRequestFileSize - else { - throw BetterMenuSecureRequestError.insecureRequestFile - } - - guard unlink(requestUrl.path) == 0 else { - throw BetterMenuSecureRequestError.requestFileUnavailable - } - - let fileHandle = FileHandle(fileDescriptor: fileDescriptor, closeOnDealloc: true) - shouldCloseDescriptor = false - let data = try fileHandle.readToEnd() ?? Data() - try fileHandle.close() - - let request = try PropertyListDecoder().decode(BetterMenuSecureRequest.self, from: data) - guard request.id == requestId else { - throw BetterMenuSecureRequestError.invalidRequest - } - try validateStructure(request) - - let requestAge = now - request.createdAt - guard - requestAge >= -maximumFutureClockSkew, - requestAge <= maximumRequestAge - else { - throw BetterMenuSecureRequestError.requestExpired - } - - return request - } - - static func discard( - requestId: UUID, - directoryUrl: URL = BetterMenuShared.secureRequestDirectoryUrl - ) { - try? FileManager.default.removeItem(at: fileUrl(for: requestId, directoryUrl: directoryUrl)) - } - - private static func prepareRequestDirectory(_ directoryUrl: URL) throws { - try FileManager.default.createDirectory( - at: directoryUrl, - withIntermediateDirectories: true - ) - try FileManager.default.setAttributes( - [.posixPermissions: 0o700], - ofItemAtPath: directoryUrl.path - ) - try validateRequestDirectory(directoryUrl) - } - - private static func validateRequestDirectory(_ directoryUrl: URL) throws { - var directoryStatus = stat() - guard - lstat(directoryUrl.path, &directoryStatus) == 0, - (directoryStatus.st_mode & S_IFMT) == S_IFDIR, - directoryStatus.st_uid == getuid(), - (directoryStatus.st_mode & 0o077) == 0 - else { - throw BetterMenuSecureRequestError.insecureRequestFile - } - } - - private static func writeSecurely(_ data: Data, to requestUrl: URL) throws { - let fileDescriptor = open( - requestUrl.path, - O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, - mode_t(0o600) - ) - guard fileDescriptor >= 0 else { - throw BetterMenuSecureRequestError.requestFileUnavailable - } - - var didFinishWriting = false - defer { - close(fileDescriptor) - if !didFinishWriting { - unlink(requestUrl.path) - } - } - - try data.withUnsafeBytes { rawBuffer in - guard let baseAddress = rawBuffer.baseAddress else { - throw BetterMenuSecureRequestError.invalidRequest - } - - var totalBytesWritten = 0 - while totalBytesWritten < rawBuffer.count { - let bytesWritten = Darwin.write( - fileDescriptor, - baseAddress.advanced(by: totalBytesWritten), - rawBuffer.count - totalBytesWritten - ) - if bytesWritten < 0, errno == EINTR { - continue - } - guard bytesWritten > 0 else { - throw BetterMenuSecureRequestError.requestFileUnavailable - } - totalBytesWritten += bytesWritten - } - } - - guard fsync(fileDescriptor) == 0 else { - throw BetterMenuSecureRequestError.requestFileUnavailable - } - didFinishWriting = true - } - - private static func fileUrl(for requestId: UUID, directoryUrl: URL) -> URL { - directoryUrl - .appendingPathComponent(requestId.uuidString.lowercased()) - .appendingPathExtension(requestFileExtension) - } - - private static func validateStructure(_ request: BetterMenuSecureRequest) throws { - guard - request.version == BetterMenuSecureRequest.currentVersion, - request.sourcePaths.allSatisfy(isStandardAbsolutePath(_:)) - else { - throw BetterMenuSecureRequestError.invalidRequest - } - - switch request.operation { - case .runAction: - guard - let actionId = request.actionId, - !actionId.isEmpty, - request.sourcePaths.count == 1, - request.transferMode == nil, - request.destinationPath == nil - else { - throw BetterMenuSecureRequestError.invalidRequest - } - case .fileTransfer: - guard - request.actionId == nil, - !request.sourcePaths.isEmpty, - let transferMode = request.transferMode - else { - throw BetterMenuSecureRequestError.invalidRequest - } - - switch transferMode { - case .airDrop: - guard request.destinationPath == nil else { - throw BetterMenuSecureRequestError.invalidRequest - } - case .copy: - guard - let destinationPath = request.destinationPath, - isStandardAbsolutePath(destinationPath) - else { - throw BetterMenuSecureRequestError.invalidRequest - } - } - } - } - - private static func isStandardAbsolutePath(_ path: String) -> Bool { - guard path.hasPrefix("/") else { return false } - return URL(fileURLWithPath: path).standardizedFileURL.path == path - } - - private static func removeExpiredFiles(in directoryUrl: URL) { - let expirationDate = Date().addingTimeInterval(-maximumRequestAge * 2) - guard - let fileUrls = try? FileManager.default.contentsOfDirectory( - at: directoryUrl, - includingPropertiesForKeys: [.contentModificationDateKey, .isRegularFileKey], - options: [.skipsHiddenFiles] - ) - else { - return - } - - for fileUrl in fileUrls where fileUrl.pathExtension == requestFileExtension { - guard - let values = try? fileUrl.resourceValues( - forKeys: [.contentModificationDateKey, .isRegularFileKey]), - values.isRegularFile == true, - let modificationDate = values.contentModificationDate, - modificationDate < expirationDate - else { - continue - } - try? FileManager.default.removeItem(at: fileUrl) - } - } -} - /// 表示可以从 Finder 右键菜单创建的文件类型及其模板定义。 struct FileDefinition: Identifiable, Codable, Hashable, Sendable { let id: String @@ -747,14 +399,15 @@ let requiredSharedActions: [FinderAction] = [ ] /// 默认提供的快捷操作列表。 -let defaultSharedActions: [FinderAction] = requiredSharedActions + [ - FinderAction( - id: "vscode", - title: "在 VS Code 中打开", - iconName: "square.and.arrow.up", - isEnabled: true - ), -] +let defaultSharedActions: [FinderAction] = + requiredSharedActions + [ + FinderAction( + id: "vscode", + title: "在 VS Code 中打开", + iconName: "square.and.arrow.up", + isEnabled: true + ) + ] /// 为旧版配置补齐后来新增的必备内置操作,同时保留用户原有的启用状态和排序。 func actionsAddingMissingRequiredActions(_ actions: [FinderAction]) -> [FinderAction] { @@ -779,10 +432,7 @@ func actionsAddingMissingRequiredActions(_ actions: [FinderAction]) -> [FinderAc /// 根据传入的文件类型 ID 映射并提取标准的路径扩展名。 /// "blank" 对应空字符串(无扩展名文件),其余 ID 直接作为扩展名使用。 func cleanExtension(for id: String) -> String { - let cleanID = - id.hasPrefix(BetterMenuShared.customMenuIdPrefix) - ? String(id.dropFirst(BetterMenuShared.customMenuIdPrefix.count)) - : id + let cleanID = BetterMenuShared.customExtension(fromMenuId: id) ?? id return cleanID == "blank" ? "" : cleanID } @@ -848,7 +498,7 @@ func readSharedSettings() -> SharedSettingsPayload { extension NSImage { /// 将系统图标重绘到 Retina PNG,避免保存完整 TIFF 造成缓存膨胀。 - func exportToPngData(targetSize: NSSize = NSSize(width: 18, height: 18)) -> Data? { + func exportToPngData(targetSize: NSSize = BetterMenuShared.menuIconSize) -> Data? { let scale: CGFloat = 2.0 let pixelSize = NSSize(width: targetSize.width * scale, height: targetSize.height * scale) diff --git a/BetterMenu/Models/BetterMenuSettingsModel.swift b/BetterMenu/Models/BetterMenuSettingsModel.swift index 11364c4..4cb62f7 100644 --- a/BetterMenu/Models/BetterMenuSettingsModel.swift +++ b/BetterMenu/Models/BetterMenuSettingsModel.swift @@ -277,7 +277,8 @@ final class BetterMenuSettingsModel: ObservableObject { forKey: Self.enabledFileTypesKey) ?? defaultTypes) customExtensions = storedCustomExtensions - let finalDefaultOrder = defaultTypes + storedCustomExtensions.map { Self.customMenuId(for: $0) } + let finalDefaultOrder = + defaultTypes + storedCustomExtensions.map { BetterMenuShared.customMenuId(for: $0) } menuOrder = Self.normalizedMenuOrder( sharedSettings.menuOrder ?? UserDefaults.standard.stringArray(forKey: Self.menuOrderKey) ?? finalDefaultOrder, @@ -311,7 +312,9 @@ final class BetterMenuSettingsModel: ObservableObject { var menuPreviewItems: [MenuPreviewItem] { menuOrder.compactMap { id in - guard enabledFileTypes.contains(id) || id.hasPrefix(Self.customMenuIdPrefix) else { + guard + enabledFileTypes.contains(id) || id.hasPrefix(BetterMenuShared.customMenuIdPrefix) + else { return nil } return menuItem(for: id) @@ -749,7 +752,8 @@ final class BetterMenuSettingsModel: ObservableObject { // 异步预热图标缓存 prewarmIconCache() } catch { - logger.error("BetterMenu shared settings write failed: \(error.localizedDescription, privacy: .public)") + logger.error( + "BetterMenu shared settings write failed: \(error.localizedDescription, privacy: .public)") } } @@ -762,19 +766,13 @@ final class BetterMenuSettingsModel: ObservableObject { ) } - private static let customMenuIdPrefix = "custom." - private static var defaultMenuOrder: [String] { betterMenuFileTypes.map(\.id) } - private static func customMenuId(for fileExtension: String) -> String { - "\(customMenuIdPrefix)\(fileExtension)" - } - private static func normalizedMenuOrder(_ order: [String], customExtensions: [String]) -> [String] { - let allIDs = defaultMenuOrder + customExtensions.map(customMenuId(for:)) + let allIDs = defaultMenuOrder + customExtensions.map(BetterMenuShared.customMenuId(for:)) let allowedIDs = Set(allIDs) let orderedKnownIDs = uniqueMenuIds(from: order.filter { allowedIDs.contains($0) }) let missingIDs = allIDs.filter { !orderedKnownIDs.contains($0) } @@ -803,8 +801,7 @@ final class BetterMenuSettingsModel: ObservableObject { return MenuPreviewItem(id: id, title: builtIn.menuTitle) } - guard id.hasPrefix(Self.customMenuIdPrefix) else { return nil } - let fileExtension = String(id.dropFirst(Self.customMenuIdPrefix.count)) + guard let fileExtension = BetterMenuShared.customExtension(fromMenuId: id) else { return nil } guard customExtensions.contains(fileExtension) else { return nil } return MenuPreviewItem(id: id, title: "新建 \(fileExtension.uppercased()) 文件") } @@ -844,4 +841,3 @@ final class BetterMenuSettingsModel: ObservableObject { return migrated } } - diff --git a/BetterMenu/Services/FileTransferOperations.swift b/BetterMenu/Services/FileTransferOperations.swift index cd1a7d2..a57d87f 100644 --- a/BetterMenu/Services/FileTransferOperations.swift +++ b/BetterMenu/Services/FileTransferOperations.swift @@ -132,16 +132,48 @@ enum FileTransferOperations { let fileManager = FileManager() let destinationUrl = requestedDestinationUrl.standardizedFileURL let standardizedSourceUrls = sourceUrls.map(\.standardizedFileURL) - let sourceByteCounts = standardizedSourceUrls.map { - byteCount(for: $0, fileManager: fileManager) + let totalItemCount = standardizedSourceUrls.count + + // 统计体积需要完整枚举目录树,包含数万文件的目录会耗时很久, + // 因此这一阶段也必须响应取消,否则进度窗口会长时间停在“正在计算传输大小…”且点取消无反应。 + var sourceByteCounts: [Int64] = [] + sourceByteCounts.reserveCapacity(totalItemCount) + for sourceUrl in standardizedSourceUrls { + guard !cancellationToken.isCancelled else { + return FileTransferCopyResult(copiedUrls: [], failures: [], wasCancelled: true) + } + sourceByteCounts.append( + byteCount( + for: sourceUrl, + fileManager: fileManager, + cancellationToken: cancellationToken + ) + ) + } + guard !cancellationToken.isCancelled else { + return FileTransferCopyResult(copiedUrls: [], failures: [], wasCancelled: true) } let totalBytes = sourceByteCounts.reduce(0, +) - let totalItemCount = standardizedSourceUrls.count var copiedUrls: [URL] = [] var failures: [FileTransferCopyFailure] = [] var completedBytes: Int64 = 0 + // 无论成功还是失败,项目都已处理完毕,因此进度的分子必须一并推进。 + // 否则分母含全部项目、分子只含成功项,部分失败时进度条会永远停在中途。 + func finishItem(at index: Int, fileName: String) { + completedBytes += sourceByteCounts[index] + progressHandler( + FileTransferProgressSnapshot( + currentFileName: fileName, + completedBytes: min(completedBytes, totalBytes), + totalBytes: totalBytes, + completedItemCount: index + 1, + totalItemCount: totalItemCount + ) + ) + } + for (index, sourceUrl) in standardizedSourceUrls.enumerated() { guard !cancellationToken.isCancelled else { return FileTransferCopyResult( @@ -158,6 +190,7 @@ enum FileTransferOperations { message: "不能将项目复制到其自身内部。" ) ) + finishItem(at: index, fileName: sourceUrl.lastPathComponent) continue } @@ -192,16 +225,7 @@ enum FileTransferOperations { ) if copyErrorNumber == 0 { copiedUrls.append(targetUrl) - completedBytes += sourceByteCounts[index] - progressHandler( - FileTransferProgressSnapshot( - currentFileName: sourceUrl.lastPathComponent, - completedBytes: min(completedBytes, totalBytes), - totalBytes: totalBytes, - completedItemCount: index + 1, - totalItemCount: totalItemCount - ) - ) + finishItem(at: index, fileName: sourceUrl.lastPathComponent) continue } @@ -224,6 +248,7 @@ enum FileTransferOperations { message: errorMessage(for: copyErrorNumber) ) ) + finishItem(at: index, fileName: sourceUrl.lastPathComponent) } return FileTransferCopyResult( @@ -346,9 +371,12 @@ enum FileTransferOperations { return destinationPath == sourcePath || destinationPath.hasPrefix(sourcePath + "/") } + /// 统计单个项目的字节数。目录会被递归枚举,枚举过程中定期检查取消标记, + /// 一旦取消立即返回已累计的数值(调用方会据此终止整个任务,该数值不会被使用)。 private static func byteCount( for sourceUrl: URL, - fileManager: FileManager + fileManager: FileManager, + cancellationToken: FileTransferCancellationToken ) -> Int64 { let resourceKeys: Set = [ .fileSizeKey, @@ -376,7 +404,13 @@ enum FileTransferOperations { errorHandler: { _, _ in true } ) var totalBytes: Int64 = 0 + var visitedItemCount = 0 while let itemUrl = enumerator?.nextObject() as? URL { + // 取消标记的读取会加锁,按批检查以免在大目录上带来额外开销。 + visitedItemCount += 1 + if visitedItemCount % 256 == 0, cancellationToken.isCancelled { + return totalBytes + } guard let values = try? itemUrl.resourceValues(forKeys: resourceKeys), values.isRegularFile == true @@ -388,10 +422,39 @@ enum FileTransferOperations { return totalBytes } - private static func errorMessage(for errorNumber: Int32) -> String { - guard let message = strerror(errorNumber) else { - return "复制失败,错误码 \(errorNumber)。" + /// 把复制失败的 errno 翻译成中文提示。 + /// + /// `strerror` 只返回英文("No space left on device" 等),在全中文界面里很突兀, + /// 因此常见错误单独给出可操作的中文说明;其余错误回落到系统描述并附带错误码便于排查。 + static func errorMessage(for errorNumber: Int32) -> String { + switch errorNumber { + case ENOSPC: + return "目标磁盘空间不足。" + case EACCES, EPERM: + return "没有写入目标位置的权限。" + case EEXIST: + return "目标位置已存在同名项目。" + case EROFS: + return "目标磁盘为只读,无法写入。" + case EIO: + return "读写磁盘时发生输入输出错误,设备可能已断开或损坏。" + case ENOENT: + return "源项目或目标位置已不存在。" + case EDQUOT: + return "已超出目标磁盘的配额限制。" + case EFBIG: + return "文件体积超出目标磁盘文件系统的上限。" + case ENAMETOOLONG: + return "文件路径过长,目标文件系统无法接受。" + case ENOTDIR: + return "目标位置不是文件夹。" + case ENOMEM: + return "可用内存不足,无法完成复制。" + default: + guard let message = strerror(errorNumber) else { + return "复制失败,错误码 \(errorNumber)。" + } + return "复制失败:\(String(cString: message))(错误码 \(errorNumber))。" } - return String(cString: message) } } diff --git a/BetterMenu/Services/FileTransferProgressWindow.swift b/BetterMenu/Services/FileTransferProgressWindow.swift index 6458c7f..854687f 100644 --- a/BetterMenu/Services/FileTransferProgressWindow.swift +++ b/BetterMenu/Services/FileTransferProgressWindow.swift @@ -124,9 +124,36 @@ final class FileTransferProgressWindowController: NSWindowController { fatalError("init(coder:) has not been implemented") } - func show() { - window?.center() - window?.makeKeyAndOrderFront(nil) - NSApp.activate() + /// 显示进度窗口。 + /// + /// 复制任务是由访达右键菜单以 `activates: false` 唤起的,用户预期停留在访达继续操作, + /// 因此这里只把窗口置于前面而不激活 BetterMenu,避免抵消扩展侧刻意的不抢焦点设计。 + /// 用户点击窗口(例如“取消”按钮)时系统会自然地将其变为 key window。 + /// + /// - Parameter cascadeIndex: 同时进行的第几个传输任务。传输任务支持并发, + /// 若每个窗口都居中显示会完全重叠、用户无法分辨与操作,因此按序号阶梯式偏移。 + func show(cascadeIndex: Int = 0) { + guard let window else { return } + window.center() + + if cascadeIndex > 0 { + let offset = Self.cascadeStep * CGFloat(cascadeIndex) + var origin = window.frame.origin + origin.x += offset + origin.y -= offset + + // 偏移后若超出屏幕可见范围就回到居中位置,避免窗口跑到屏幕外。 + // 窗口尚未上屏时 `window.screen` 为 nil,此时以主屏为准。 + if let visibleFrame = (window.screen ?? NSScreen.main)?.visibleFrame { + let shiftedFrame = CGRect(origin: origin, size: window.frame.size) + guard visibleFrame.contains(shiftedFrame) else { return } + } + window.setFrameOrigin(origin) + } + + window.orderFrontRegardless() } + + /// 相邻两个并发传输窗口的偏移量。 + private static let cascadeStep: CGFloat = 28 } diff --git a/BetterMenu/Services/FileTransferService.swift b/BetterMenu/Services/FileTransferService.swift index e244f67..709092b 100644 --- a/BetterMenu/Services/FileTransferService.swift +++ b/BetterMenu/Services/FileTransferService.swift @@ -10,9 +10,39 @@ final class FileTransferService { let progressWindowController: FileTransferProgressWindowController } + /// `NSSharingService.delegate` 是弱引用,因此需要与 service 一起被持有。 + private struct ActiveAirDropTask { + let service: NSSharingService + let delegate: AirDropCompletionDelegate + } + + /// 在隔空投送成功或失败后通知调用方释放对应的 service 强引用。 + @MainActor + final class AirDropCompletionDelegate: NSObject, NSSharingServiceDelegate { + private let onFinish: () -> Void + + init(onFinish: @escaping () -> Void) { + self.onFinish = onFinish + } + + func sharingService(_ sharingService: NSSharingService, didShareItems items: [Any]) { + onFinish() + } + + func sharingService( + _ sharingService: NSSharingService, + didFailToShareItems items: [Any], + error: any Error + ) { + onFinish() + } + } + private let logger = Logger( subsystem: "com.zombie.BetterMenu", category: "FileTransferService") - private var activeAirDropService: NSSharingService? + /// 正在进行的隔空投送。`perform(withItems:)` 是异步的,面板关闭前必须保持强引用; + /// 用单值保存会让第二次投送顶掉第一次的引用,因此按任务 id 分别持有,结束回调里再移除。 + private var activeAirDropTasks: [UUID: ActiveAirDropTask] = [:] private var copyTasks: [UUID: ActiveCopyTask] = [:] /// 使用系统隔空投送面板发送选中的文件或文件夹。 @@ -27,7 +57,12 @@ final class FileTransferService { return } - activeAirDropService = service + let taskId = UUID() + let delegate = AirDropCompletionDelegate { [weak self] in + self?.activeAirDropTasks.removeValue(forKey: taskId) + } + service.delegate = delegate + activeAirDropTasks[taskId] = ActiveAirDropTask(service: service, delegate: delegate) service.perform(withItems: existingUrls) } @@ -46,7 +81,8 @@ final class FileTransferService { progressModel.onCancel = { [weak self] in self?.cancelCopyTask(taskId) } - progressWindowController.show() + // 已有传输任务时把新窗口阶梯式错开,避免多个进度窗口完全重叠。 + progressWindowController.show(cascadeIndex: copyTasks.count) let task = Task { [weak self] in let result = await Task.detached(priority: .userInitiated) { diff --git a/BetterMenu/Services/IconCacheManager.swift b/BetterMenu/Services/IconCacheManager.swift index 7740f75..eb0dc05 100644 --- a/BetterMenu/Services/IconCacheManager.swift +++ b/BetterMenu/Services/IconCacheManager.swift @@ -4,7 +4,8 @@ import os /// 负责在后台异步预热文件类型图标及外部应用程序图标,并将渲染好的 Retina PNG 数据序列化写入磁盘缓存。 enum IconCacheManager { - private static let logger = Logger(subsystem: "com.zombie.BetterMenu", category: "IconCacheManager") + private static let logger = Logger( + subsystem: "com.zombie.BetterMenu", category: "IconCacheManager") /// 执行图标的异步预热与磁盘缓存写入 /// - Parameters: /// - fileTypes: 当前配置的全部内置文件类型定义 @@ -20,12 +21,12 @@ enum IconCacheManager { return (type.id, fileExtension) } let customItems = customExtensions.map { fileExtension in - (id: "custom.\(fileExtension)", fileExtension: fileExtension) + (id: BetterMenuShared.customMenuId(for: fileExtension), fileExtension: fileExtension) } let iconItems = builtInItems + customItems Task.detached(priority: .utility) { - let targetSize = NSSize(width: 18, height: 18) + let targetSize = BetterMenuShared.menuIconSize var dict: [String: Data] = [:] // 1. 预热并绘制所有可能用到得新建文件类型图标 @@ -47,7 +48,8 @@ enum IconCacheManager { } var appUrl: URL? = nil - if let editor = BetterMenuShared.supportedEditors.first(where: { $0.idString == action.id }) { + if let editor = BetterMenuShared.supportedEditors.first(where: { $0.idString == action.id }) + { for bid in editor.bundleIds { if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bid) { appUrl = url @@ -82,7 +84,8 @@ enum IconCacheManager { ) try data.write(to: cacheUrl, options: .atomic) } catch { - logger.error("Failed to write icon cache to disk: \(error.localizedDescription, privacy: .public)") + logger.error( + "Failed to write icon cache to disk: \(error.localizedDescription, privacy: .public)") } } } diff --git a/BetterMenu/Views/BetterMenuFileTypeSettingsViews.swift b/BetterMenu/Views/BetterMenuFileTypeSettingsViews.swift index 4270875..1e06844 100644 --- a/BetterMenu/Views/BetterMenuFileTypeSettingsViews.swift +++ b/BetterMenu/Views/BetterMenuFileTypeSettingsViews.swift @@ -113,7 +113,10 @@ struct FileTypeGridItem: View { var body: some View { HStack(spacing: 8) { FileTypeIconView(fileTypeId: type.id) - .frame(width: 18, height: 18) + .frame( + width: BetterMenuShared.menuIconSize.width, + height: BetterMenuShared.menuIconSize.height + ) Toggle(isOn: $isEnabled) { Text(type.title) @@ -139,7 +142,7 @@ struct FileTypeIconView: View { let fileTypeId: String var body: some View { - if let image = BetterMenuIcon.icon(for: fileTypeId, size: NSSize(width: 18, height: 18)) { + if let image = BetterMenuIcon.icon(for: fileTypeId, size: BetterMenuShared.menuIconSize) { Image(nsImage: image) .resizable() .aspectRatio(contentMode: .fit) @@ -159,7 +162,7 @@ struct CustomExtensionTag: View { var body: some View { HStack(spacing: 6) { - FileTypeIconView(fileTypeId: "custom.\(fileExtension)") + FileTypeIconView(fileTypeId: BetterMenuShared.customMenuId(for: fileExtension)) .frame(width: 14, height: 14) Text(".\(fileExtension)") @@ -265,7 +268,10 @@ struct PreviewMenuRow: View { var body: some View { HStack(spacing: 12) { FileTypeIconView(fileTypeId: item.id) - .frame(width: 18, height: 18) + .frame( + width: BetterMenuShared.menuIconSize.width, + height: BetterMenuShared.menuIconSize.height + ) Text(item.title) .font(.callout) diff --git a/BetterMenuFinderSync/DirectoryChangeMonitor.swift b/BetterMenuFinderSync/DirectoryChangeMonitor.swift new file mode 100644 index 0000000..62c7776 --- /dev/null +++ b/BetterMenuFinderSync/DirectoryChangeMonitor.swift @@ -0,0 +1,73 @@ +import Foundation +import os + +/// 监听单个目录的写入事件,用于在主应用更新共享文件后及时作废扩展进程内的缓存。 +/// +/// 主应用写入 `settings.plist` 与 `icon_cache.plist` 时都使用原子写(临时文件 + rename), +/// 文件 inode 会被整体替换,因此必须监听父目录而不是文件本身。 +final class DirectoryChangeMonitor: @unchecked Sendable { + private let logger = Logger( + subsystem: "com.zombie.BetterMenu", category: "DirectoryChangeMonitor") + private let directoryUrl: URL + private let queue: DispatchQueue + + private let stateLock = NSLock() + private var source: DispatchSourceFileSystemObject? + + init(directoryUrl: URL, queueLabel: String) { + self.directoryUrl = directoryUrl + self.queue = DispatchQueue(label: queueLabel, qos: .utility) + } + + deinit { + stop() + } + + /// 开始监听目录写入事件。目录不存在时会尝试创建,创建失败则放弃监听并保持缓存原样。 + /// - Parameter onChange: 目录内容变化时在内部串行队列上触发的回调。 + func start(onChange: @escaping @Sendable () -> Void) { + do { + try FileManager.default.createDirectory(at: directoryUrl, withIntermediateDirectories: true) + } catch { + logger.error( + "Failed to create monitored directory \(self.directoryUrl.path, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) + } + + let fileDescriptor = open(directoryUrl.path, O_EVTONLY) + guard fileDescriptor >= 0 else { + logger.error( + "Failed to open directory descriptor for monitoring: \(self.directoryUrl.path, privacy: .public)" + ) + return + } + + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fileDescriptor, + eventMask: .write, + queue: queue + ) + source.setEventHandler(handler: onChange) + source.setCancelHandler { + close(fileDescriptor) + } + + stateLock.lock() + let previousSource = source === self.source ? nil : self.source + self.source = source + stateLock.unlock() + previousSource?.cancel() + + source.resume() + logger.info("Started monitoring directory: \(self.directoryUrl.path, privacy: .public)") + } + + /// 停止监听并释放文件描述符。可重复调用。 + func stop() { + stateLock.lock() + let currentSource = source + source = nil + stateLock.unlock() + currentSource?.cancel() + } +} diff --git a/BetterMenuFinderSync/FileCreator.swift b/BetterMenuFinderSync/FileCreator.swift index a19fb21..eb0ee40 100644 --- a/BetterMenuFinderSync/FileCreator.swift +++ b/BetterMenuFinderSync/FileCreator.swift @@ -1,5 +1,5 @@ -import Foundation import Cocoa +import Foundation /// 负责实际物理文件的创建、防文件名冲突算法计算以及 POSIX 执行权限设置。 enum FileCreator { @@ -14,7 +14,7 @@ enum FileCreator { /// - Returns: 返回最终成功创建的文件路径 URL func create() throws -> URL { let destination = try makeAvailableDestination() - + try definition.contents(in: bundle).write(to: destination, options: .withoutOverwriting) if definition.shouldBeExecutable { try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: destination.path) diff --git a/BetterMenuFinderSync/FinderSync.swift b/BetterMenuFinderSync/FinderSync.swift index 7d6c52a..fd12b33 100644 --- a/BetterMenuFinderSync/FinderSync.swift +++ b/BetterMenuFinderSync/FinderSync.swift @@ -6,11 +6,11 @@ import os /// 访达扩展插件主类,负责动态向 Finder 插入右键菜单 final class FinderSync: FIFinderSync { private let logger = Logger(subsystem: "com.zombie.BetterMenu", category: "FinderSync") - + // 偏好设置监控与图标管理器实例 private let settingsMonitor = SettingsMonitor() private let iconManager = MenuIconManager() - + // Tag 路由映射,每次构建菜单时更新,以保证点击回调可以找到对应数据 private var menuDefinitionsByTag: [Int: FileDefinition] = [:] private var actionDefinitionsByTag: [Int: FinderAction] = [:] @@ -24,10 +24,11 @@ final class FinderSync: FIFinderSync { private var menuIcon: NSImage { MenuIconManager.finderMenuSymbol(named: "doc.badge.plus", accessibilityDescription: "新建文件") } - + /// 工具栏图标,自适应系统外观并强制单色 private lazy var toolbarIcon: NSImage = { - let symbolImage = MenuIconManager.finderMenuSymbol(named: "doc.badge.plus", accessibilityDescription: "BetterMenu") + let symbolImage = MenuIconManager.finderMenuSymbol( + named: "doc.badge.plus", accessibilityDescription: "BetterMenu") symbolImage.isTemplate = true return symbolImage }() @@ -75,7 +76,7 @@ final class FinderSync: FIFinderSync { let monitoredUrls = Self.monitoredDirectoryUrls() FIFinderSyncController.default().directoryURLs = Set(monitoredUrls) logger.info("BetterMenu Finder Sync extension loaded") - + // 设置监控失效回调:当 settings.plist 发生改变时,自动清理图标管理器中的缓存 settingsMonitor.onSettingsChanged = { [weak self] in self?.iconManager.clearCache() @@ -123,7 +124,9 @@ final class FinderSync: FIFinderSync { } /// 构建“新建文件”二级菜单 - private func makeFileCreationMenuItem(from snapshot: SettingsMonitor.SettingsSnapshot) -> NSMenuItem { + private func makeFileCreationMenuItem(from snapshot: SettingsMonitor.SettingsSnapshot) + -> NSMenuItem + { let parent = NSMenuItem(title: "新建文件", action: nil, keyEquivalent: "") parent.image = menuIcon @@ -156,7 +159,9 @@ final class FinderSync: FIFinderSync { } /// 构建终端、复制路径、编辑器等一级右键快捷操作 - private func makeEnabledShortcutItems(from snapshot: SettingsMonitor.SettingsSnapshot) -> [NSMenuItem] { + private func makeEnabledShortcutItems(from snapshot: SettingsMonitor.SettingsSnapshot) + -> [NSMenuItem] + { var nextTag = Self.dynamicActionTagBase return snapshot.actions.filter(\.isEnabled).compactMap { action in if action.id == "sendFile" { @@ -188,7 +193,7 @@ final class FinderSync: FIFinderSync { ) airDropItem.target = self let airDropIcon = airDropService.image - airDropIcon.size = NSSize(width: 18, height: 18) + airDropIcon.size = BetterMenuShared.menuIconSize airDropItem.image = airDropIcon submenu.addItem(airDropItem) } @@ -208,7 +213,7 @@ final class FinderSync: FIFinderSync { item.tag = tag let volumeIcon = NSWorkspace.shared.icon(forFile: volume.url.path) - volumeIcon.size = NSSize(width: 18, height: 18) + volumeIcon.size = BetterMenuShared.menuIconSize item.image = volumeIcon fileTransferVolumesByTag[tag] = volume @@ -233,7 +238,8 @@ final class FinderSync: FIFinderSync { item.tag = tag item.image = iconManager.getCachedIcon(for: action.id) - ?? MenuIconManager.finderMenuSymbol(named: action.iconName, accessibilityDescription: action.title) + ?? MenuIconManager.finderMenuSymbol( + named: action.iconName, accessibilityDescription: action.title) return item } @@ -270,11 +276,15 @@ final class FinderSync: FIFinderSync { } /// 获取当前已启用的文件类型列表并进行排序 - private func enabledDefinitions(with snapshot: SettingsMonitor.SettingsSnapshot) -> [FileDefinition] { - let builtIns = BetterMenuShared.supportedFileTypes.filter { snapshot.enabledFileTypes.contains($0.id) } + private func enabledDefinitions(with snapshot: SettingsMonitor.SettingsSnapshot) + -> [FileDefinition] + { + let builtIns = BetterMenuShared.supportedFileTypes.filter { + snapshot.enabledFileTypes.contains($0.id) + } let customDefinitions = snapshot.customExtensions.map { fileExtension in FileDefinition( - id: "custom.\(fileExtension)", + id: BetterMenuShared.customMenuId(for: fileExtension), title: "新建 \(fileExtension.uppercased()) 文件", menuTitle: "新建 \(fileExtension.uppercased()) 文件", baseName: "新建 \(fileExtension.uppercased()) 文件", @@ -498,7 +508,8 @@ final class FinderSync: FIFinderSync { UNUserNotificationCenter.current().add(request) { [weak self] error in if let error = error { - self?.logger.error("Failed to post notification: \(error.localizedDescription, privacy: .public)") + self?.logger.error( + "Failed to post notification: \(error.localizedDescription, privacy: .public)") } } } diff --git a/BetterMenuFinderSync/MenuIconManager.swift b/BetterMenuFinderSync/MenuIconManager.swift index ad864d9..21f5b1e 100644 --- a/BetterMenuFinderSync/MenuIconManager.swift +++ b/BetterMenuFinderSync/MenuIconManager.swift @@ -8,6 +8,34 @@ final class MenuIconManager: @unchecked Sendable { private var menuItemIconsById: [String: NSImage] = [:] private var didLoadDiskCache = false + /// 主应用的图标预热是在设置写盘之后才异步完成的,因此不能只依赖 `settings.plist` 的变化来作废缓存, + /// 否则新增文件类型或操作时扩展会读到尚未写入新图标的旧缓存,菜单里一直显示 SF Symbol 占位图。 + /// 这里独立监听缓存目录,等 `icon_cache.plist` 真正落盘后再重新加载。 + private let cacheDirectoryMonitor = DirectoryChangeMonitor( + directoryUrl: BetterMenuShared.iconCacheUrl.deletingLastPathComponent(), + queueLabel: "com.zombie.BetterMenu.iconCacheMonitorQueue" + ) + + init() { + cacheDirectoryMonitor.start { [weak self] in + guard let self = self else { return } + self.logger.info("Icon cache directory changed, reloading icons on next request") + self.invalidateDiskCache() + } + } + + deinit { + cacheDirectoryMonitor.stop() + } + + /// 仅作废磁盘图标缓存,保留 SF Symbol 渲染缓存(后者只受系统深浅色外观影响)。 + private func invalidateDiskCache() { + lock.lock() + menuItemIconsById.removeAll() + didLoadDiskCache = false + lock.unlock() + } + // 静态 SF Symbol 渲染缓存与锁 private struct SymbolCacheState { var symbolCache: [String: NSImage] = [:] @@ -17,10 +45,7 @@ final class MenuIconManager: @unchecked Sendable { /// 清除全部内存缓存(在偏好设置或系统外观模式发生改变时调用) func clearCache() { - lock.lock() - menuItemIconsById.removeAll() - didLoadDiskCache = false - lock.unlock() + invalidateDiskCache() Self.symbolCacheLock.withLock { state in state.symbolCache.removeAll() @@ -46,7 +71,8 @@ final class MenuIconManager: @unchecked Sendable { /// 从磁盘加载 App 预热好的图标数据缓存文件到内存 private func loadDiskCache() { guard let data = try? Data(contentsOf: BetterMenuShared.iconCacheUrl), - let dict = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? [String: Data] + let dict = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil) + as? [String: Data] else { logger.info("No icon disk cache found or failed to load") lock.lock() @@ -59,7 +85,7 @@ final class MenuIconManager: @unchecked Sendable { var loadedCount = 0 for (id, imageData) in dict { if let image = NSImage(data: imageData) { - image.size = NSSize(width: 18, height: 18) + image.size = BetterMenuShared.menuIconSize image.isTemplate = false loadedIcons[id] = image loadedCount += 1 @@ -77,7 +103,9 @@ final class MenuIconManager: @unchecked Sendable { } /// 创建 Finder 菜单专用 SF Symbol 图像,自适应系统深浅色外观,并且借助内存缓存优化性能。 - static func finderMenuSymbol(named symbolName: String, accessibilityDescription: String) -> NSImage { + static func finderMenuSymbol(named symbolName: String, accessibilityDescription: String) + -> NSImage + { let isDark = systemUsesDarkAppearance() return symbolCacheLock.withLock { state in @@ -91,9 +119,11 @@ final class MenuIconManager: @unchecked Sendable { return cached } - let targetSize = NSSize(width: 18, height: 18) - guard let symbolImage = NSImage(systemSymbolName: symbolName, accessibilityDescription: accessibilityDescription) - ?? NSImage(systemSymbolName: "doc", accessibilityDescription: accessibilityDescription) + let targetSize = BetterMenuShared.menuIconSize + guard + let symbolImage = NSImage( + systemSymbolName: symbolName, accessibilityDescription: accessibilityDescription) + ?? NSImage(systemSymbolName: "doc", accessibilityDescription: accessibilityDescription) else { return NSImage() } @@ -105,14 +135,15 @@ final class MenuIconManager: @unchecked Sendable { symbolImage.draw(in: drawRect, from: .zero, operation: .sourceOver, fraction: 1.0) // 深色模式使用系统高对比淡白色,浅色模式使用系统高对比深灰色 - let tintColor = isDark + let tintColor = + isDark ? NSColor(calibratedWhite: 0.85, alpha: 1.0) : NSColor(calibratedWhite: 0.17, alpha: 1.0) tintColor.setFill() drawRect.fill(using: .sourceAtop) return true } - image.isTemplate = false // 禁用 template 模式,防止系统自动二次涂色 + image.isTemplate = false // 禁用 template 模式,防止系统自动二次涂色 state.symbolCache[symbolName] = image return image @@ -124,7 +155,9 @@ final class MenuIconManager: @unchecked Sendable { if let style = UserDefaults.standard.string(forKey: "AppleInterfaceStyle") { return style.caseInsensitiveCompare("Dark") == .orderedSame } - if let style = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain)?["AppleInterfaceStyle"] as? String { + if let style = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain)?[ + "AppleInterfaceStyle"] as? String + { return style.caseInsensitiveCompare("Dark") == .orderedSame } return false diff --git a/BetterMenuFinderSync/SettingsMonitor.swift b/BetterMenuFinderSync/SettingsMonitor.swift index c286293..52c5fb8 100644 --- a/BetterMenuFinderSync/SettingsMonitor.swift +++ b/BetterMenuFinderSync/SettingsMonitor.swift @@ -4,14 +4,14 @@ import os /// 负责从共享存储中加载 BetterMenu 偏好设置快照,并监控设置 plist 目录变化以自动刷新缓存。 final class SettingsMonitor: @unchecked Sendable { private let logger = Logger(subsystem: "com.zombie.BetterMenu", category: "SettingsMonitor") - private let lock = NSLock() - private let queue = DispatchQueue(label: "com.zombie.BetterMenu.settingsMonitorQueue", qos: .utility) - + private let directoryMonitor = DirectoryChangeMonitor( + directoryUrl: BetterMenuShared.sharedSettingsUrl.deletingLastPathComponent(), + queueLabel: "com.zombie.BetterMenu.settingsMonitorQueue" + ) + // 跨线程读写的缓存状态用 lock 保护 private struct State { var cachedSnapshot: SettingsSnapshot? - var fileMonitorSource: DispatchSourceFileSystemObject? - var fileDescriptor: Int32 = -1 } private let stateLock = NSLock() private var state = State() @@ -66,9 +66,14 @@ final class SettingsMonitor: @unchecked Sendable { let shared = readSharedSettings() // 默认启用列表从共享定义中动态计算,确保与主程序保持一致 - let defaultEnabledFileTypeIds = Set(BetterMenuShared.supportedFileTypes.filter(\.enabledByDefault).map(\.id)) - let enabledIds = Set(shared.enabledFileTypes ?? UserDefaults.standard.stringArray(forKey: BetterMenuShared.enabledFileTypesKey) ?? Array(defaultEnabledFileTypeIds)) - let customExts = shared.customExtensions ?? UserDefaults.standard.stringArray(forKey: BetterMenuShared.customExtensionsKey) ?? [] + let defaultEnabledFileTypeIds = Set( + BetterMenuShared.supportedFileTypes.filter(\.enabledByDefault).map(\.id)) + let enabledIds = Set( + shared.enabledFileTypes ?? UserDefaults.standard.stringArray( + forKey: BetterMenuShared.enabledFileTypesKey) ?? Array(defaultEnabledFileTypeIds)) + let customExts = + shared.customExtensions ?? UserDefaults.standard.stringArray( + forKey: BetterMenuShared.customExtensionsKey) ?? [] let finalActions: [FinderAction] if let actionsList = shared.actions { @@ -76,14 +81,22 @@ final class SettingsMonitor: @unchecked Sendable { } else { // 兼容以前的布尔设置键 var acts = defaultSharedActions - let terminalVal = shared.oldTerminalDirectEnabled ?? (UserDefaults.standard.object(forKey: BetterMenuShared.oldTerminalDirectEnabledKey) as? Bool ?? true) - let pathVal = shared.oldPathCopyEnabled ?? (UserDefaults.standard.object(forKey: BetterMenuShared.oldPathCopyEnabledKey) as? Bool ?? true) + let terminalVal = + shared.oldTerminalDirectEnabled + ?? (UserDefaults.standard.object(forKey: BetterMenuShared.oldTerminalDirectEnabledKey) + as? Bool ?? true) + let pathVal = + shared.oldPathCopyEnabled + ?? (UserDefaults.standard.object(forKey: BetterMenuShared.oldPathCopyEnabledKey) as? Bool + ?? true) if let idx = acts.firstIndex(where: { $0.id == "terminal" }) { - acts[idx] = FinderAction(id: "terminal", title: "在终端中打开", iconName: "terminal", isEnabled: terminalVal) + acts[idx] = FinderAction( + id: "terminal", title: "在终端中打开", iconName: "terminal", isEnabled: terminalVal) } if let idx = acts.firstIndex(where: { $0.id == "copyPath" }) { - acts[idx] = FinderAction(id: "copyPath", title: "复制当前路径", iconName: "doc.on.doc", isEnabled: pathVal) + acts[idx] = FinderAction( + id: "copyPath", title: "复制当前路径", iconName: "doc.on.doc", isEnabled: pathVal) } finalActions = actionsAddingMissingRequiredActions(acts) } @@ -98,35 +111,10 @@ final class SettingsMonitor: @unchecked Sendable { /// 开始监控偏好设置所在的目录变化 private func startMonitoring() { - let fileUrl = BetterMenuShared.sharedSettingsUrl - let directoryUrl = fileUrl.deletingLastPathComponent() - - do { - try FileManager.default.createDirectory(at: directoryUrl, withIntermediateDirectories: true) - } catch { - logger.error("Failed to create settings directory for monitoring: \(error.localizedDescription, privacy: .public)") - } - - let dirPath = directoryUrl.path - let fd = open(dirPath, O_EVTONLY) - guard fd >= 0 else { - logger.error("Failed to open settings directory descriptor for monitoring") - return - } - - stateLock.lock() - state.fileDescriptor = fd - - let source = DispatchSource.makeFileSystemObjectSource( - fileDescriptor: fd, - eventMask: .write, - queue: queue - ) - - source.setEventHandler { [weak self] in + directoryMonitor.start { [weak self] in guard let self = self else { return } self.logger.info("Settings directory changed, invalidating cached configuration") - + self.stateLock.lock() self.state.cachedSnapshot = nil self.stateLock.unlock() @@ -134,23 +122,10 @@ final class SettingsMonitor: @unchecked Sendable { // 触发外部无效化通知 self.onSettingsChanged?() } - - source.setCancelHandler { - close(fd) - } - - state.fileMonitorSource = source - stateLock.unlock() - - source.resume() - logger.info("Started monitoring settings directory: \(dirPath, privacy: .public)") } /// 停止文件监听 private func stopMonitoring() { - stateLock.lock() - state.fileMonitorSource?.cancel() - state.fileMonitorSource = nil - stateLock.unlock() + directoryMonitor.stop() } } diff --git a/BetterMenuTests/BetterMenuCoreServiceTests.swift b/BetterMenuTests/BetterMenuCoreServiceTests.swift index 247b154..7718f81 100644 --- a/BetterMenuTests/BetterMenuCoreServiceTests.swift +++ b/BetterMenuTests/BetterMenuCoreServiceTests.swift @@ -198,6 +198,42 @@ final class BetterMenuCoreServiceTests: XCTestCase { ) } + /// 统计目录体积需要枚举整棵目录树,这一阶段也必须响应取消, + /// 否则用户在“正在计算传输大小…”期间点取消会没有任何反应。 + func testFileTransferCancellationDuringByteCountingCopiesNothing() throws { + let rootUrl = try makeTemporaryDirectory() + let sourceUrl = rootUrl.appendingPathComponent("Tree", isDirectory: true) + let destinationUrl = rootUrl.appendingPathComponent("Destination", isDirectory: true) + try FileManager.default.createDirectory(at: sourceUrl, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: destinationUrl, withIntermediateDirectories: true) + for index in 0..<600 { + try Data("x".utf8).write(to: sourceUrl.appendingPathComponent("file-\(index).txt")) + } + + let cancellationToken = FileTransferCancellationToken() + cancellationToken.cancel() + let snapshots = LockedSnapshots() + + let result = FileTransferOperations.copyItems( + [sourceUrl], + to: destinationUrl, + cancellationToken: cancellationToken, + progressHandler: { snapshot in + snapshots.append(snapshot) + } + ) + + XCTAssertTrue(result.wasCancelled) + XCTAssertTrue(result.copiedUrls.isEmpty) + XCTAssertTrue(result.failures.isEmpty) + XCTAssertTrue(snapshots.values.isEmpty) + XCTAssertFalse( + FileManager.default.fileExists( + atPath: destinationUrl.appendingPathComponent("Tree").path + ) + ) + } + func testFileTransferRejectsDestinationInsideSource() throws { let rootUrl = try makeTemporaryDirectory() let sourceUrl = rootUrl.appendingPathComponent("Source", isDirectory: true) @@ -211,6 +247,59 @@ final class BetterMenuCoreServiceTests: XCTestCase { XCTAssertEqual(result.failures.count, 1) } + /// 失败项目也已处理完毕,必须计入进度分子,否则分母含全部项目、 + /// 分子只含成功项,部分失败时进度条会永远停在中途、无法到达 100%。 + func testFileTransferPartialFailureStillCompletesProgress() throws { + let rootUrl = try makeTemporaryDirectory() + let destinationUrl = rootUrl.appendingPathComponent("Destination", isDirectory: true) + try FileManager.default.createDirectory(at: destinationUrl, withIntermediateDirectories: true) + + // 第一项可以正常复制;第二项是把目录复制进它自己内部,一定失败。 + let copyableUrl = rootUrl.appendingPathComponent("copyable.txt") + try Data(repeating: 0x41, count: 2048).write(to: copyableUrl) + let failingSourceUrl = destinationUrl.deletingLastPathComponent() + + let snapshots = LockedSnapshots() + let result = FileTransferOperations.copyItems( + [copyableUrl, failingSourceUrl], + to: destinationUrl, + progressHandler: { snapshot in + snapshots.append(snapshot) + } + ) + + XCTAssertFalse(result.wasCancelled) + XCTAssertEqual(result.copiedUrls.count, 1) + XCTAssertEqual(result.failures.count, 1) + + let finalSnapshot = try XCTUnwrap(snapshots.values.last) + XCTAssertEqual(finalSnapshot.completedItemCount, finalSnapshot.totalItemCount) + XCTAssertEqual(finalSnapshot.completedItemCount, 2) + XCTAssertEqual(finalSnapshot.completedBytes, finalSnapshot.totalBytes) + } + + /// 复制失败提示必须是中文。此前直接返回 `strerror` 的英文文案, + /// 在全中文界面里会出现 "No space left on device" 之类的混排。 + func testFileTransferErrorMessagesAreLocalized() { + let localizedErrorNumbers: [Int32] = [ + ENOSPC, EACCES, EPERM, EEXIST, EROFS, EIO, ENOENT, EDQUOT, EFBIG, ENAMETOOLONG, ENOTDIR, + ENOMEM, + ] + for errorNumber in localizedErrorNumbers { + let message = FileTransferOperations.errorMessage(for: errorNumber) + XCTAssertFalse(message.isEmpty, "errno \(errorNumber) 缺少提示文案") + XCTAssertTrue( + message.contains(where: { $0.unicodeScalars.contains { $0.value > 0x7F } }), + "errno \(errorNumber) 的提示应为中文,实际为:\(message)" + ) + } + + // 未单独映射的错误码回落到系统描述,但仍需带上错误码便于排查。 + let fallbackMessage = FileTransferOperations.errorMessage(for: EPIPE) + XCTAssertTrue(fallbackMessage.contains("\(EPIPE)")) + XCTAssertTrue(fallbackMessage.hasPrefix("复制失败")) + } + private func makeTemporaryDirectory() throws -> URL { let directoryUrl = FileManager.default.temporaryDirectory .appendingPathComponent("BetterMenuCoreTests-\(UUID().uuidString)", isDirectory: true) diff --git a/BetterMenuTests/BetterMenuSecurityTests.swift b/BetterMenuTests/BetterMenuSecurityTests.swift index 5af7668..cbea269 100644 --- a/BetterMenuTests/BetterMenuSecurityTests.swift +++ b/BetterMenuTests/BetterMenuSecurityTests.swift @@ -173,6 +173,23 @@ final class BetterMenuSecurityTests: XCTestCase { XCTAssertEqual(migratedActions.first(where: { $0.id == "copyPath" })?.isEnabled, false) } + /// 自定义菜单 ID 的拼装与还原过去在四处各写一份字面量 `"custom.\(ext)"`, + /// 现已收拢到共享层。这里锁定两个方向互为逆运算,避免任一侧被单独改动。 + func testCustomMenuIdRoundTripsFileExtension() { + for fileExtension in ["md", "swift", "tar.gz", ""] { + let menuId = BetterMenuShared.customMenuId(for: fileExtension) + XCTAssertTrue(menuId.hasPrefix(BetterMenuShared.customMenuIdPrefix)) + XCTAssertEqual(BetterMenuShared.customExtension(fromMenuId: menuId), fileExtension) + XCTAssertEqual(cleanExtension(for: menuId), fileExtension) + } + + // 内置类型 ID 不带前缀,不应被当作自定义扩展名解析。 + XCTAssertNil(BetterMenuShared.customExtension(fromMenuId: "docx")) + XCTAssertEqual(cleanExtension(for: "docx"), "docx") + // "blank" 代表无扩展名文件。 + XCTAssertEqual(cleanExtension(for: "blank"), "") + } + private func makeTemporaryDirectory() throws -> URL { let directoryUrl = FileManager.default.temporaryDirectory .appendingPathComponent("BetterMenuTests-\(UUID().uuidString)", isDirectory: true) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 074589c..b21371c 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -17,6 +17,7 @@ BetterMenu/ AppDelegate.swift # 主应用生命周期、窗口和 URL Scheme 路由 main.swift # 主应用入口点 BetterMenuShared.swift # 共享核心:统一 FileDefinition、全局 constants 与公共映射函数 + BetterMenuSecureRequest.swift # 一次性安全请求的数据模型、原子写入、校验与消费(三 Target 共享的安全边界) Models/ BetterMenuSettingsModel.swift # 设置页 UI 状态绑定与业务分发 Views/ @@ -37,8 +38,9 @@ BetterMenu/ BetterMenuFinderSync/ FinderSync.swift # Finder Sync 扩展入口、上下文交互与菜单组装(极简 Controller) - SettingsMonitor.swift # 共享偏好 plist 文件的加载、只读配置快照维护与 DispatchSource 监听 + SettingsMonitor.swift # 共享偏好 plist 文件的加载、只读配置快照维护与变更作废 MenuIconManager.swift # 磁盘缓存图标加载、SF Symbol 深浅色重绘与内存渲染二级缓存 + DirectoryChangeMonitor.swift # 目录级 DispatchSource 监听封装,供设置与图标缓存复用 FileCreator.swift # 物理文件落盘、防文件名冲突算法计算与可执行权限设置 Resources/Templates/ # Word/Excel/PPTX 内置空白二进制模板文件 @@ -54,7 +56,7 @@ BetterMenuTests/ - **图标缓存路径**:`~/Library/Caches/BetterMenu/icon_cache.plist` - **一次性请求目录**:`~/Library/Application Support/BetterMenu/Requests/` -共享数据模型 `FileDefinition`、`FinderAction` 和键名统一在 `BetterMenuShared.swift` 中管理。新增需要两个 Target 同时读取的数据定义时,必须直接归入共享层。 +共享数据模型 `FileDefinition`、`FinderAction` 和键名统一在 `BetterMenuShared.swift` 中管理。新增需要两个 Target 同时读取的数据定义时,必须直接归入共享层,禁止在调用方重复定义常量或硬编码字面量。自定义菜单 ID 只能通过 `BetterMenuShared.customMenuId(for:)` 与 `customExtension(fromMenuId:)` 构造与解析,菜单图标尺寸统一取 `BetterMenuShared.menuIconSize`(图标缓存按此尺寸预渲染,两侧取值不一致会导致缓存图标被系统再次缩放而模糊)。 ### Finder 扩展安全请求机制 @@ -62,14 +64,17 @@ Finder 扩展不得通过 URL Scheme 直接传递动作 ID、源文件路径或 主应用消费请求前会验证请求目录与文件所有者、POSIX 权限、文件类型、大小、协议版本、路径格式和 30 秒有效期。请求文件在读取时立即从目录移除,防止重放。旧版 `run-action`、`send-files` 和 `open-terminal` 明文参数路由不再接受。 +该机制的数据模型与实现集中在 `BetterMenu/BetterMenuSecureRequest.swift`,与其余共享定义分离,便于单独审查这条安全边界。 + ### 结构维护规范 - **主应用入口保持轻量**:`AppDelegate` 仅处理生命周期分发、AppKit 激活策略以及 URL Scheme 路由。 - **ViewModel 高度内聚**:`BetterMenuSettingsModel` 只做数据和 UI 绑定,底层任务(如图标预热、System 进程命令)一律剥离成独立业务服务(如 `IconCacheManager`,`SystemCommand`)。 -- **FinderSync 扩展轻量化**:`FinderSync` 类仅扮演控制器角色。偏好读取与目录监听由 `SettingsMonitor` 托管,图标解析与渲染由 `MenuIconManager` 托管,文件生成由 `FileCreator` 托管。 -- **文件发送链路**:FinderSync 只负责读取当前选择、发现已挂载可写卷并构造菜单;隔空投送与文件复制通过 URL Scheme 交给主应用的 `FileTransferService` 执行,避免大文件任务依赖扩展进程生命周期。`FileTransferOperations` 使用系统 `copyfile` 回调提供字节进度和复制中取消,复制目标仅允许已挂载的非内部可写卷,发生重名时生成递增后缀,取消或失败时清理未完成项目,任务完成后由访达定位结果。 +- **FinderSync 扩展轻量化**:`FinderSync` 类仅扮演控制器角色。偏好读取由 `SettingsMonitor` 托管,图标解析与渲染由 `MenuIconManager` 托管,文件生成由 `FileCreator` 托管。两者的目录监听统一复用 `DirectoryChangeMonitor`:主应用是先同步写 `settings.plist`、再异步预热 `icon_cache.plist`,因此设置目录与缓存目录必须各自独立监听,否则扩展会在新图标落盘前就重新加载缓存,菜单里持续显示 SF Symbol 占位图。共享文件均为原子写(临时文件 + rename),监听对象只能是父目录而非文件本身。 +- **文件发送链路**:FinderSync 只负责读取当前选择、发现已挂载可写卷并构造菜单;隔空投送与文件复制通过 URL Scheme 交给主应用的 `FileTransferService` 执行,避免大文件任务依赖扩展进程生命周期。`FileTransferOperations` 使用系统 `copyfile` 回调提供字节进度和复制中取消,复制目标仅允许已挂载的非内部可写卷,发生重名时生成递增后缀,取消或失败时清理未完成项目,任务完成后由访达定位结果。统计目录体积同样必须响应取消(大目录枚举耗时很久),失败项目也要计入进度分子(否则部分失败时进度条永远到不了 100%),失败原因需经 `errorMessage(for:)` 转成中文。传输任务支持并发:`FileTransferService` 按任务 id 分别持有隔空投送的 `NSSharingService` 与其 delegate(面板异步关闭前不能提前释放),进度窗口按 `show(cascadeIndex:)` 阶梯错开。复制任务由扩展以 `activates: false` 唤起,进度窗口只能 `orderFrontRegardless()`,不得调用 `NSApp.activate()` 抢占前台。 - **Swift 6 Concurrency 并发安全**:编写异步或跨 Target 通信的工具类时,必须按照 Swift 6 安全规范进行严格类型隔离,或使用锁机制辅以 `nonisolated(unsafe)` 消除编译警告。 - **Xcode 项目同步**:新增 Swift 源文件后,必须同步注册在 `BetterMenu.xcodeproj/project.pbxproj` 对应的 Target 编译阶段。 +- **代码风格由 `swift-format` 门禁**:规则见仓库根目录 `.swift-format`(2 空格缩进、100 列、导入按字典序)。`swift-format` 随 Xcode 提供,无需额外安装依赖。提交前执行 `./script/build_and_run.sh format` 自动修正,或用 `lint` 动作只做检查;CI 会以 `--strict` 校验,任何风格告警都会让流水线失败。 - **文档维护**:更改项目行为、扩展逻辑或业务边界时,必须同步修改本指南及 `README.md`。 --- @@ -89,9 +94,11 @@ xcodebuild \ `BetterMenuTests` 是无宿主单元测试 Target。共享 Scheme 在测试动作中只构建测试 Target,不构建或注册 `BetterMenu.app` 与 Finder Sync 扩展。不要重新启用主 App 或扩展的 `buildForTesting`,否则本地测试产物会出现在“登录项与扩展”中。 -构建主 App 或 Finder Sync 扩展时必须固定复用项目 `DerivedData` 或 Xcode 默认路径中的一种,不得混用或为同一工作区创建多个临时 DerivedData 路径。`build_and_run.sh clean` 会在清理构建产物前注销开发扩展,`package.sh` 会在打包结束后注销临时 Release 产物。 +构建主 App 或 Finder Sync 扩展时必须固定复用项目 `DerivedData` 或 Xcode 默认路径中的一种,不得混用或为同一工作区创建多个临时 DerivedData 路径。`build_and_run.sh` 默认增量编译以复用该 `DerivedData`,需要全量重建时显式传入 `--clean`(`--clean-cache` 另行清空运行缓存)。`build_and_run.sh clean` 会在清理构建产物前注销开发扩展,`package.sh` 会在打包结束后注销临时 Release 产物。 + +GitHub Actions 会在打包前执行代码风格检查与同一组测试,并检查测试产物中没有 `BetterMenu.app` 或 `BetterMenuFinderSync.appex`,防止 Scheme 配置回退。安全通信或 AppleScript 路径处理发生变化时,必须同步补充 `BetterMenuSecurityTests`;文件创建、复制、进度或取消逻辑发生变化时,必须同步补充 `BetterMenuCoreServiceTests`。 -GitHub Actions 会在打包前执行同一组测试,并检查测试产物中没有 `BetterMenu.app` 或 `BetterMenuFinderSync.appex`,防止 Scheme 配置回退。安全通信或 AppleScript 路径处理发生变化时,必须同步补充 `BetterMenuSecurityTests`;文件创建、复制、进度或取消逻辑发生变化时,必须同步补充 `BetterMenuCoreServiceTests`。 +测试结果会以 `.xcresult` 包上传为 `BetterMenu-TestResults` 工件(无论成败),打包日志上传为 `BetterMenu-BuildLog`,均保留 14 天。排查 CI 失败时优先下载这两个工件,而不是只看日志尾部输出。 --- diff --git a/README.md b/README.md index f1d5ea8..a8adedc 100644 --- a/README.md +++ b/README.md @@ -103,12 +103,21 @@ xattr -cr /Applications/BetterMenu.app ./script/build_and_run.sh run ``` +该脚本默认使用增量编译,日常改动只重新编译受影响的文件。需要全量重建时追加 `--clean`,需要一并清空运行缓存时追加 `--clean-cache`;`./script/build_and_run.sh clean` 会同时清理两者。 + ### 仅构建并注册扩展 如果只需重新注册 Finder Sync 扩展,可执行: ```bash ./script/build_and_run.sh register ``` +### 代码风格 +提交前统一代码排版(规则见 `.swift-format`,`swift-format` 随 Xcode 提供): +```bash +./script/build_and_run.sh format # 就地格式化 +./script/build_and_run.sh lint # 仅检查,CI 使用同一规则 +``` + ### Release 打包 生成生产环境发布包(DMG/ZIP): ```bash diff --git a/script/build_and_run.sh b/script/build_and_run.sh index 16a976d..a51b7ca 100755 --- a/script/build_and_run.sh +++ b/script/build_and_run.sh @@ -30,10 +30,12 @@ APP_BINARY="$APP_BUNDLE/Contents/MacOS/$APP_NAME" APP_EXTENSION="$APP_BUNDLE/Contents/PlugIns/$EXTENSION_NAME.appex" # 命令行标志默认值 +# 默认走增量编译:日常改动只需重新编译受影响的文件,无需每次全量重建 DerivedData。 +# 需要全量重建时显式传入 --clean,或直接执行 clean 动作。 NO_BUILD=false NO_RESTART_FINDER=false -CLEAN=true -CLEAN_CACHE=true +CLEAN=false +CLEAN_CACHE=false # ------------------------------------------------------------------------------ # 2. 日志输出工具函数 (ANSI 颜色支持) @@ -64,8 +66,8 @@ usage() { 选项: -n, --no-build 跳过构建阶段 (使用已构建的 App 产物) -f, --no-restart-finder 跳过重启 Finder 步骤 (开发调试非扩展功能时推荐) - --no-clean 构建前跳过清理 DerivedData 构建缓存 (默认自动清理) - --no-clean-cache 构建前跳过清理 App 运行时缓存 (默认自动清理) + --clean 构建前清理 DerivedData 构建缓存 (默认增量编译) + --clean-cache 构建前清理 App 运行时缓存 (默认保留) -h, --help 显示当前帮助信息 动作 (默认: run): @@ -75,6 +77,8 @@ usage() { logs 启动应用程序并实时流式查看其系统日志 telemetry 启动应用程序并过滤 subsystem == "$BUNDLE_ID" 的遥测日志 verify 校验应用程序运行状态以及 FinderSync 扩展激活状态 + format 按 .swift-format 就地格式化全部 Swift 源码 (与 CI 门禁同一规则) + lint 仅检查代码风格,不修改文件 (CI 使用的校验方式) clean 仅清理 DerivedData 构建缓存和 App 运行时缓存 EOF exit "${1:-0}" @@ -94,6 +98,16 @@ while [[ $# -gt 0 ]]; do NO_RESTART_FINDER=true shift ;; + --clean) + CLEAN=true + shift + ;; + --clean-cache) + CLEAN_CACHE=true + shift + ;; + # 兼容旧调用方式:此前默认全量清理,--no-clean 系列用于跳过。 + # 现在默认已是增量编译,这两个选项等价于不做清理,保留以免既有脚本或习惯用法直接报错。 --no-clean) CLEAN=false shift @@ -124,7 +138,8 @@ case "$ACTION" in --logs) ACTION="logs" ;; --telemetry) ACTION="telemetry" ;; --verify) ACTION="verify" ;; - --clean) ACTION="clean" ;; + # 注意:这里不再接受 --clean 作为动作别名。--clean 已是上面的标志选项 + # (表示本次构建前清理 DerivedData),要执行纯清理请使用不带横线的 clean 动作。 esac # ------------------------------------------------------------------------------ @@ -289,8 +304,33 @@ open_app() { # 6. 主逻辑执行控制 # ------------------------------------------------------------------------------ -# 步骤 A: 如果指定了 --clean,优先清理构建缓存;若指定了 -c 或动作为 clean,清理 App 运行缓存 -if [[ "$CLEAN" == "true" ]]; then +# 代码风格动作不需要编译产物,先在此分流并直接结束,避免走后续的清理与构建流程。 +# swift-format 随 Xcode 提供,因此无需安装额外依赖,与 CI 使用的完全是同一份 .swift-format 规则。 +if [[ "$ACTION" == "format" || "$ACTION" == "lint" ]]; then + SWIFT_SOURCE_DIRS=(BetterMenu BetterMenuFinderSync BetterMenuTests) + cd "$ROOT_DIR" + + if [[ "$ACTION" == "format" ]]; then + log_info "正在按 .swift-format 就地格式化 Swift 源码..." + xcrun swift-format format --configuration .swift-format --recursive --in-place \ + "${SWIFT_SOURCE_DIRS[@]}" + log_success "代码格式化完成。" + else + log_info "正在检查 Swift 代码风格..." + if xcrun swift-format lint --configuration .swift-format --recursive --strict \ + "${SWIFT_SOURCE_DIRS[@]}"; then + log_success "代码风格检查通过。" + else + log_error "代码风格检查未通过,可执行 '$0 format' 自动修正。" + exit 1 + fi + fi + exit 0 +fi + +# 步骤 A: 清理阶段。默认增量编译,仅在显式传入 --clean/--clean-cache 或执行 clean 动作时清理。 +# clean 动作代表“清空全部本地状态”,因此两类缓存都要处理。 +if [[ "$CLEAN" == "true" || "$ACTION" == "clean" ]]; then clean_derived_data fi From 72f27fa32d045cde452b90501492c7b4d475f9c5 Mon Sep 17 00:00:00 2001 From: zombie Date: Mon, 27 Jul 2026 01:11:31 +0800 Subject: [PATCH 08/10] =?UTF-8?q?refactor:=20=E4=BC=98=E5=8C=96=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E5=8F=98=E6=9B=B4=E7=9B=91=E6=8E=A7=E5=99=A8=EF=BC=8C?= =?UTF-8?q?=E5=A2=9E=E5=BC=BA=E8=87=AA=E5=8A=A8=E6=81=A2=E5=A4=8D=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E5=B9=B6=E6=94=B9=E8=BF=9B=E6=96=87=E4=BB=B6=E4=BC=A0?= =?UTF-8?q?=E8=BE=93=E6=9C=8D=E5=8A=A1=E7=9A=84=E5=B1=82=E5=8F=A0=E7=AA=97?= =?UTF-8?q?=E5=8F=A3=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BetterMenu.xcodeproj/project.pbxproj | 2 + .../Services/FileTransferProgressWindow.swift | 7 +- BetterMenu/Services/FileTransferService.swift | 13 +- .../DirectoryChangeMonitor.swift | 146 ++++++++++++++++-- .../BetterMenuCoreServiceTests.swift | 96 ++++++++++++ DEVELOPMENT.md | 2 +- 6 files changed, 249 insertions(+), 17 deletions(-) diff --git a/BetterMenu.xcodeproj/project.pbxproj b/BetterMenu.xcodeproj/project.pbxproj index 182d68a..ff36ea2 100644 --- a/BetterMenu.xcodeproj/project.pbxproj +++ b/BetterMenu.xcodeproj/project.pbxproj @@ -46,6 +46,7 @@ B20000000000000000000004 /* AppleScriptPathArgument.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000631 /* AppleScriptPathArgument.swift */; }; B20000000000000000000005 /* FileTransferOperations.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000641 /* FileTransferOperations.swift */; }; B20000000000000000000006 /* FileCreator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000721 /* FileCreator.swift */; }; + B20000000000000000000007 /* DirectoryChangeMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000731 /* DirectoryChangeMonitor.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -439,6 +440,7 @@ B20000000000000000000004 /* AppleScriptPathArgument.swift in Sources */, B20000000000000000000005 /* FileTransferOperations.swift in Sources */, B20000000000000000000006 /* FileCreator.swift in Sources */, + B20000000000000000000007 /* DirectoryChangeMonitor.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/BetterMenu/Services/FileTransferProgressWindow.swift b/BetterMenu/Services/FileTransferProgressWindow.swift index 854687f..3cfb452 100644 --- a/BetterMenu/Services/FileTransferProgressWindow.swift +++ b/BetterMenu/Services/FileTransferProgressWindow.swift @@ -146,9 +146,12 @@ final class FileTransferProgressWindowController: NSWindowController { // 窗口尚未上屏时 `window.screen` 为 nil,此时以主屏为准。 if let visibleFrame = (window.screen ?? NSScreen.main)?.visibleFrame { let shiftedFrame = CGRect(origin: origin, size: window.frame.size) - guard visibleFrame.contains(shiftedFrame) else { return } + if visibleFrame.contains(shiftedFrame) { + window.setFrameOrigin(origin) + } + } else { + window.setFrameOrigin(origin) } - window.setFrameOrigin(origin) } window.orderFrontRegardless() diff --git a/BetterMenu/Services/FileTransferService.swift b/BetterMenu/Services/FileTransferService.swift index 709092b..47f5fb1 100644 --- a/BetterMenu/Services/FileTransferService.swift +++ b/BetterMenu/Services/FileTransferService.swift @@ -8,6 +8,7 @@ final class FileTransferService { let task: Task let cancellationToken: FileTransferCancellationToken let progressWindowController: FileTransferProgressWindowController + let cascadeIndex: Int } /// `NSSharingService.delegate` 是弱引用,因此需要与 service 一起被持有。 @@ -81,8 +82,9 @@ final class FileTransferService { progressModel.onCancel = { [weak self] in self?.cancelCopyTask(taskId) } + let cascadeIndex = nextAvailableCascadeIndex() // 已有传输任务时把新窗口阶梯式错开,避免多个进度窗口完全重叠。 - progressWindowController.show(cascadeIndex: copyTasks.count) + progressWindowController.show(cascadeIndex: cascadeIndex) let task = Task { [weak self] in let result = await Task.detached(priority: .userInitiated) { @@ -123,10 +125,17 @@ final class FileTransferService { copyTasks[taskId] = ActiveCopyTask( task: task, cancellationToken: cancellationToken, - progressWindowController: progressWindowController + progressWindowController: progressWindowController, + cascadeIndex: cascadeIndex ) } + /// 返回当前未被传输窗口占用的最小层叠位置,任务完成后该位置会自然释放并复用。 + private func nextAvailableCascadeIndex() -> Int { + let occupiedIndexes = Set(copyTasks.values.map(\.cascadeIndex)) + return (0...).first(where: { !occupiedIndexes.contains($0) }) ?? 0 + } + private func existingSourceUrls(from urls: [URL]) -> [URL] { let fileManager = FileManager.default var acceptedPaths: Set = [] diff --git a/BetterMenuFinderSync/DirectoryChangeMonitor.swift b/BetterMenuFinderSync/DirectoryChangeMonitor.swift index 62c7776..29aad39 100644 --- a/BetterMenuFinderSync/DirectoryChangeMonitor.swift +++ b/BetterMenuFinderSync/DirectoryChangeMonitor.swift @@ -11,8 +11,17 @@ final class DirectoryChangeMonitor: @unchecked Sendable { private let directoryUrl: URL private let queue: DispatchQueue + private struct State { + var source: DispatchSourceFileSystemObject? + var onChange: (@Sendable () -> Void)? + var isRunning = false + var retryScheduled = false + var retryAttempt = 0 + var generation: UInt64 = 0 + } + private let stateLock = NSLock() - private var source: DispatchSourceFileSystemObject? + private var state = State() init(directoryUrl: URL, queueLabel: String) { self.directoryUrl = directoryUrl @@ -23,15 +32,54 @@ final class DirectoryChangeMonitor: @unchecked Sendable { stop() } - /// 开始监听目录写入事件。目录不存在时会尝试创建,创建失败则放弃监听并保持缓存原样。 + /// 开始监听目录写入事件。目录不存在时会尝试创建,创建失败则稍后重试。 /// - Parameter onChange: 目录内容变化时在内部串行队列上触发的回调。 func start(onChange: @escaping @Sendable () -> Void) { + stateLock.lock() + let previousSource = state.source + state.source = nil + state.onChange = onChange + state.isRunning = true + state.retryScheduled = false + state.retryAttempt = 0 + state.generation &+= 1 + stateLock.unlock() + previousSource?.cancel() + + installSource() + } + + /// 停止监听并释放文件描述符。可重复调用。 + func stop() { + stateLock.lock() + let currentSource = state.source + state.source = nil + state.onChange = nil + state.isRunning = false + state.retryScheduled = false + state.retryAttempt = 0 + state.generation &+= 1 + stateLock.unlock() + currentSource?.cancel() + } + + private func installSource() { + stateLock.lock() + guard state.isRunning, state.source == nil else { + stateLock.unlock() + return + } + let generation = state.generation + stateLock.unlock() + do { try FileManager.default.createDirectory(at: directoryUrl, withIntermediateDirectories: true) } catch { logger.error( "Failed to create monitored directory \(self.directoryUrl.path, privacy: .public): \(error.localizedDescription, privacy: .public)" ) + scheduleRetry() + return } let fileDescriptor = open(directoryUrl.path, O_EVTONLY) @@ -39,35 +87,109 @@ final class DirectoryChangeMonitor: @unchecked Sendable { logger.error( "Failed to open directory descriptor for monitoring: \(self.directoryUrl.path, privacy: .public)" ) + scheduleRetry() return } let source = DispatchSource.makeFileSystemObjectSource( fileDescriptor: fileDescriptor, - eventMask: .write, + eventMask: [.write, .delete, .rename, .revoke], queue: queue ) - source.setEventHandler(handler: onChange) + source.setEventHandler { [weak self] in + self?.handleEvent(for: generation) + } source.setCancelHandler { close(fileDescriptor) } stateLock.lock() - let previousSource = source === self.source ? nil : self.source - self.source = source + let shouldInstall = + state.isRunning + && state.generation == generation + && state.source == nil + if shouldInstall { + state.source = source + state.retryAttempt = 0 + } stateLock.unlock() - previousSource?.cancel() + guard shouldInstall else { + source.cancel() + source.resume() + return + } source.resume() logger.info("Started monitoring directory: \(self.directoryUrl.path, privacy: .public)") } - /// 停止监听并释放文件描述符。可重复调用。 - func stop() { + private func handleEvent(for generation: UInt64) { stateLock.lock() - let currentSource = source - source = nil + guard + state.isRunning, + state.generation == generation, + let currentSource = state.source + else { + stateLock.unlock() + return + } + + let events = currentSource.data + let onChange = state.onChange + let requiresReattachment = + events.contains(.delete) + || events.contains(.rename) + || events.contains(.revoke) + if requiresReattachment { + state.source = nil + state.retryAttempt = 0 + state.generation &+= 1 + } stateLock.unlock() - currentSource?.cancel() + + onChange?() + + guard requiresReattachment else { return } + currentSource.cancel() + logger.info( + "Monitored directory was replaced or removed, scheduling reattachment: \(self.directoryUrl.path, privacy: .public)" + ) + scheduleRetry() + } + + private func scheduleRetry() { + stateLock.lock() + guard + state.isRunning, + state.source == nil, + !state.retryScheduled + else { + stateLock.unlock() + return + } + state.retryScheduled = true + let retryAttempt = state.retryAttempt + state.retryAttempt = min(retryAttempt + 1, 5) + let generation = state.generation + stateLock.unlock() + + let retryDelayMilliseconds = 250 * (1 << min(retryAttempt, 4)) + queue.asyncAfter(deadline: .now() + .milliseconds(retryDelayMilliseconds)) { [weak self] in + guard let self else { return } + + self.stateLock.lock() + guard + self.state.isRunning, + self.state.generation == generation, + self.state.source == nil + else { + self.stateLock.unlock() + return + } + self.state.retryScheduled = false + self.stateLock.unlock() + + self.installSource() + } } } diff --git a/BetterMenuTests/BetterMenuCoreServiceTests.swift b/BetterMenuTests/BetterMenuCoreServiceTests.swift index 7718f81..36fe7c0 100644 --- a/BetterMenuTests/BetterMenuCoreServiceTests.swift +++ b/BetterMenuTests/BetterMenuCoreServiceTests.swift @@ -54,6 +54,53 @@ final class BetterMenuCoreServiceTests: XCTestCase { XCTAssertEqual(permissions.intValue & 0o777, 0o755) } + func testDirectoryChangeMonitorRecoversAfterDirectoryRecreation() throws { + let rootUrl = try makeTemporaryDirectory() + let monitoredDirectoryUrl = rootUrl.appendingPathComponent("Monitored", isDirectory: true) + let markerUrl = monitoredDirectoryUrl.appendingPathComponent("icon_cache.plist") + try FileManager.default.createDirectory( + at: monitoredDirectoryUrl, + withIntermediateDirectories: true + ) + + let changeExpectation = expectation(description: "重新创建目录后仍能收到写入事件") + changeExpectation.assertForOverFulfill = false + let signal = DirectoryMonitorTestSignal( + markerUrl: markerUrl, + expectation: changeExpectation + ) + let monitor = DirectoryChangeMonitor( + directoryUrl: monitoredDirectoryUrl, + queueLabel: "com.zombie.BetterMenuTests.directoryMonitor" + ) + monitor.start { + signal.handleChange() + } + defer { + monitor.stop() + } + + try FileManager.default.removeItem(at: monitoredDirectoryUrl) + DispatchQueue.global(qos: .utility).async { + for attempt in 0..<10 where !signal.isComplete { + do { + try FileManager.default.createDirectory( + at: monitoredDirectoryUrl, + withIntermediateDirectories: true + ) + try Data("updated-\(attempt)".utf8).write(to: markerUrl, options: .atomic) + } catch { + signal.handleFailure(error) + return + } + Thread.sleep(forTimeInterval: 0.2) + } + } + + wait(for: [changeExpectation], timeout: 3) + XCTAssertNil(signal.failureMessage) + } + func testFileTransferCopiesFileAndReportsProgress() throws { let rootUrl = try makeTemporaryDirectory() let sourceDirectoryUrl = rootUrl.appendingPathComponent("Source", isDirectory: true) @@ -330,3 +377,52 @@ private final class LockedSnapshots: @unchecked Sendable { lock.unlock() } } + +private final class DirectoryMonitorTestSignal: @unchecked Sendable { + private let markerUrl: URL + private let expectation: XCTestExpectation + private let lock = NSLock() + private var storedFailureMessage: String? + private var didComplete = false + + init(markerUrl: URL, expectation: XCTestExpectation) { + self.markerUrl = markerUrl + self.expectation = expectation + } + + var failureMessage: String? { + lock.lock() + defer { lock.unlock() } + return storedFailureMessage + } + + var isComplete: Bool { + lock.lock() + defer { lock.unlock() } + return didComplete + } + + func handleChange() { + guard FileManager.default.fileExists(atPath: markerUrl.path) else { return } + lock.lock() + guard !didComplete else { + lock.unlock() + return + } + didComplete = true + lock.unlock() + expectation.fulfill() + } + + func handleFailure(_ error: any Error) { + lock.lock() + guard !didComplete else { + lock.unlock() + return + } + didComplete = true + storedFailureMessage = error.localizedDescription + lock.unlock() + expectation.fulfill() + } +} diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index b21371c..7da5c42 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -70,7 +70,7 @@ Finder 扩展不得通过 URL Scheme 直接传递动作 ID、源文件路径或 - **主应用入口保持轻量**:`AppDelegate` 仅处理生命周期分发、AppKit 激活策略以及 URL Scheme 路由。 - **ViewModel 高度内聚**:`BetterMenuSettingsModel` 只做数据和 UI 绑定,底层任务(如图标预热、System 进程命令)一律剥离成独立业务服务(如 `IconCacheManager`,`SystemCommand`)。 -- **FinderSync 扩展轻量化**:`FinderSync` 类仅扮演控制器角色。偏好读取由 `SettingsMonitor` 托管,图标解析与渲染由 `MenuIconManager` 托管,文件生成由 `FileCreator` 托管。两者的目录监听统一复用 `DirectoryChangeMonitor`:主应用是先同步写 `settings.plist`、再异步预热 `icon_cache.plist`,因此设置目录与缓存目录必须各自独立监听,否则扩展会在新图标落盘前就重新加载缓存,菜单里持续显示 SF Symbol 占位图。共享文件均为原子写(临时文件 + rename),监听对象只能是父目录而非文件本身。 +- **FinderSync 扩展轻量化**:`FinderSync` 类仅扮演控制器角色。偏好读取由 `SettingsMonitor` 托管,图标解析与渲染由 `MenuIconManager` 托管,文件生成由 `FileCreator` 托管。两者的目录监听统一复用 `DirectoryChangeMonitor`:主应用是先同步写 `settings.plist`、再异步预热 `icon_cache.plist`,因此设置目录与缓存目录必须各自独立监听,否则扩展会在新图标落盘前就重新加载缓存,菜单里持续显示 SF Symbol 占位图。共享文件均为原子写(临时文件 + rename),监听对象只能是父目录而非文件本身;缓存目录可能被系统或清理脚本删除,监听器必须在目录重建后自动恢复订阅。 - **文件发送链路**:FinderSync 只负责读取当前选择、发现已挂载可写卷并构造菜单;隔空投送与文件复制通过 URL Scheme 交给主应用的 `FileTransferService` 执行,避免大文件任务依赖扩展进程生命周期。`FileTransferOperations` 使用系统 `copyfile` 回调提供字节进度和复制中取消,复制目标仅允许已挂载的非内部可写卷,发生重名时生成递增后缀,取消或失败时清理未完成项目,任务完成后由访达定位结果。统计目录体积同样必须响应取消(大目录枚举耗时很久),失败项目也要计入进度分子(否则部分失败时进度条永远到不了 100%),失败原因需经 `errorMessage(for:)` 转成中文。传输任务支持并发:`FileTransferService` 按任务 id 分别持有隔空投送的 `NSSharingService` 与其 delegate(面板异步关闭前不能提前释放),进度窗口按 `show(cascadeIndex:)` 阶梯错开。复制任务由扩展以 `activates: false` 唤起,进度窗口只能 `orderFrontRegardless()`,不得调用 `NSApp.activate()` 抢占前台。 - **Swift 6 Concurrency 并发安全**:编写异步或跨 Target 通信的工具类时,必须按照 Swift 6 安全规范进行严格类型隔离,或使用锁机制辅以 `nonisolated(unsafe)` 消除编译警告。 - **Xcode 项目同步**:新增 Swift 源文件后,必须同步注册在 `BetterMenu.xcodeproj/project.pbxproj` 对应的 Target 编译阶段。 From 964b79840857b412aae0ce2530358221b7522746 Mon Sep 17 00:00:00 2001 From: zombie Date: Mon, 27 Jul 2026 01:38:19 +0800 Subject: [PATCH 09/10] =?UTF-8?q?refactor:=20=E5=A2=9E=E5=BC=BA=E6=9E=84?= =?UTF-8?q?=E5=BB=BA=E8=84=9A=E6=9C=AC=EF=BC=8C=E4=BC=98=E5=8C=96=E5=BC=80?= =?UTF-8?q?=E5=8F=91=E6=9E=84=E5=BB=BA=E6=B3=A8=E9=94=80=E6=B5=81=E7=A8=8B?= =?UTF-8?q?=E4=BB=A5=E9=81=BF=E5=85=8D=E9=87=8D=E5=A4=8D=E6=B3=A8=E5=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DEVELOPMENT.md | 2 +- README.md | 17 ++++++++++- script/build_and_run.sh | 63 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 2 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 7da5c42..785ced5 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -94,7 +94,7 @@ xcodebuild \ `BetterMenuTests` 是无宿主单元测试 Target。共享 Scheme 在测试动作中只构建测试 Target,不构建或注册 `BetterMenu.app` 与 Finder Sync 扩展。不要重新启用主 App 或扩展的 `buildForTesting`,否则本地测试产物会出现在“登录项与扩展”中。 -构建主 App 或 Finder Sync 扩展时必须固定复用项目 `DerivedData` 或 Xcode 默认路径中的一种,不得混用或为同一工作区创建多个临时 DerivedData 路径。`build_and_run.sh` 默认增量编译以复用该 `DerivedData`,需要全量重建时显式传入 `--clean`(`--clean-cache` 另行清空运行缓存)。`build_and_run.sh clean` 会在清理构建产物前注销开发扩展,`package.sh` 会在打包结束后注销临时 Release 产物。 +构建主 App 或 Finder Sync 扩展时必须固定复用项目 `DerivedData` 或 Xcode 默认路径中的一种,不得混用或为同一工作区创建多个临时 DerivedData 路径。`build_and_run.sh` 默认增量编译以复用该 `DerivedData`,需要全量重建时显式传入 `--clean`(`--clean-cache` 另行清空运行缓存)。`build_and_run.sh` 会在每次重新构建前注销其他 DerivedData 或临时目录中的 BetterMenu 开发构建,并在重新构建或清理当前构建产物前注销当前开发扩展;注销必须在 `xcodebuild` 覆盖旧 `.appex` 前完成,否则旧插件 UUID 会残留在系统扩展列表中。清理其他开发构建的注册记录不会删除其文件;`package.sh` 会在打包结束后注销临时 Release 产物。 GitHub Actions 会在打包前执行代码风格检查与同一组测试,并检查测试产物中没有 `BetterMenu.app` 或 `BetterMenuFinderSync.appex`,防止 Scheme 配置回退。安全通信或 AppleScript 路径处理发生变化时,必须同步补充 `BetterMenuSecurityTests`;文件创建、复制、进度或取消逻辑发生变化时,必须同步补充 `BetterMenuCoreServiceTests`。 diff --git a/README.md b/README.md index a8adedc..6a37099 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,21 @@ xattr -cr /Applications/BetterMenu.app 该脚本默认使用增量编译,日常改动只重新编译受影响的文件。需要全量重建时追加 `--clean`,需要一并清空运行缓存时追加 `--clean-cache`;`./script/build_and_run.sh clean` 会同时清理两者。 +清理 App 运行缓存后启动: +```bash +./script/build_and_run.sh --clean-cache run +``` + +同时清理 DerivedData 构建缓存和 App 运行缓存后启动: +```bash +./script/build_and_run.sh --clean --clean-cache run +``` + +只清理两类缓存、不启动 App: +```bash +./script/build_and_run.sh clean +``` + ### 仅构建并注册扩展 如果只需重新注册 Finder Sync 扩展,可执行: ```bash @@ -124,7 +139,7 @@ xattr -cr /Applications/BetterMenu.app ./script/package.sh ``` -单元测试使用无宿主测试 Target,不会构建或注册 Finder Sync 扩展。开发脚本会在清理或打包完成时注销对应构建产物,避免“登录项与扩展”积累重复的开发扩展。 +单元测试使用无宿主测试 Target,不会构建或注册 Finder Sync 扩展。开发脚本会在重新构建前注销其他 DerivedData 或临时目录中的 BetterMenu 开发构建,并在清理或打包完成时注销对应构建产物,避免“登录项与扩展”积累重复的开发扩展;该过程不会删除其他构建目录中的文件。 --- diff --git a/script/build_and_run.sh b/script/build_and_run.sh index a51b7ca..1795185 100755 --- a/script/build_and_run.sh +++ b/script/build_and_run.sh @@ -176,6 +176,64 @@ unregister_build_product() { fi } +# 注销其他 DerivedData 或临时目录中的 BetterMenu 开发构建,避免系统扩展列表重复。 +# unregister_other_build_products() { +# if [[ ! -x "$LSREGISTER" ]]; then +# return +# fi + +# local registered_app +# while IFS= read -r registered_app; do +# if [[ -z "$registered_app" || "$registered_app" == "$APP_BUNDLE" ]]; then +# continue +# fi + +# case "$registered_app" in +# */DerivedData/Build/Products/*/"$APP_NAME.app"|\ +# /private/tmp/BetterMenu*/Build/Products/*/"$APP_NAME.app"|\ +# /private/var/folders/*/BetterMenu*/Build/Products/*/"$APP_NAME.app") +# log_info "正在注销其他 BetterMenu 开发构建: $registered_app" +# local registered_extension +# registered_extension="$registered_app/Contents/PlugIns/$EXTENSION_NAME.appex" +# /usr/bin/pluginkit \ +# -r "$registered_extension" >/dev/null 2>&1 || true +# "$LSREGISTER" -u "$registered_app" >/dev/null 2>&1 || true +# ;; +# esac +# done < <( +# "$LSREGISTER" -dump 2>/dev/null | awk -v bundle_id="$BUNDLE_ID" ' +# function emit_record() { +# if (path != "" && identifier == bundle_id) { +# print path +# } +# path = "" +# identifier = "" +# } + +# /^-+$/ { +# emit_record() +# next +# } + +# /^path:/ { +# path = $0 +# sub(/^path:[[:space:]]+/, "", path) +# sub(/[[:space:]]+\(0x[[:xdigit:]]+\)$/, "", path) +# next +# } + +# /^identifier:/ { +# identifier = $0 +# sub(/^identifier:[[:space:]]+/, "", identifier) +# } + +# END { +# emit_record() +# } +# ' +# ) +# } + # 安全地清理编译缓存,避免危险删除 clean_derived_data() { # 安全审计:确保路径不为空,且父目录属于该项目,防止 rm -rf 误伤系统根目录或用户主目录 @@ -233,6 +291,11 @@ build_app() { pkill -u "$USER" -x "$APP_NAME" >/dev/null 2>&1 || true pkill -u "$USER" -x "$EXTENSION_NAME" >/dev/null 2>&1 || true + # unregister_other_build_products + + # 必须在 xcodebuild 覆盖旧 appex 前注销,否则旧插件 UUID 会残留在系统扩展列表中。 + unregister_build_product + log_info "开始编译项目 BetterMenu (Configuration: $CONFIGURATION)..." local start_time=$SECONDS From b6c98226529f42cd8e4600a689ff96cdab87f136 Mon Sep 17 00:00:00 2001 From: zombie Date: Mon, 27 Jul 2026 18:50:36 +0800 Subject: [PATCH 10/10] =?UTF-8?q?refactor:=20=E4=BC=98=E5=8C=96=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E4=BC=A0=E8=BE=93=E6=93=8D=E4=BD=9C=EF=BC=8C=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E7=9B=AE=E6=A0=87=E8=B7=AF=E5=BE=84=E9=A2=84=E7=95=99?= =?UTF-8?q?=E6=9C=BA=E5=88=B6=E4=BB=A5=E6=94=AF=E6=8C=81=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E5=A4=8D=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BetterMenu/AppDelegate.swift | 3 - .../Services/FileTransferOperations.swift | 80 ++++++++++-- .../BetterMenuCoreServiceTests.swift | 122 ++++++++++++++++++ DEVELOPMENT.md | 2 +- README.md | 2 +- 5 files changed, 190 insertions(+), 19 deletions(-) diff --git a/BetterMenu/AppDelegate.swift b/BetterMenu/AppDelegate.swift index afe6afc..aa7009d 100644 --- a/BetterMenu/AppDelegate.swift +++ b/BetterMenu/AppDelegate.swift @@ -235,9 +235,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { let targetPolicy = mode.activationPolicy if NSApp.activationPolicy() != targetPolicy { NSApp.setActivationPolicy(targetPolicy) - if targetPolicy == .regular { - NSApp.activate(ignoringOtherApps: true) - } } } diff --git a/BetterMenu/Services/FileTransferOperations.swift b/BetterMenu/Services/FileTransferOperations.swift index a57d87f..3e800a0 100644 --- a/BetterMenu/Services/FileTransferOperations.swift +++ b/BetterMenu/Services/FileTransferOperations.swift @@ -40,6 +40,35 @@ final class FileTransferCancellationToken: @unchecked Sendable { enum FileTransferOperations { typealias ProgressHandler = @Sendable (FileTransferProgressSnapshot) -> Void + private final class TargetReservationRegistry: @unchecked Sendable { + private let lock = NSLock() + private var reservedPaths: Set = [] + + func reserveTargetUrl( + for sourceUrl: URL, + in destinationUrl: URL, + fileManager: FileManager + ) -> URL { + lock.lock() + defer { lock.unlock() } + + let targetUrl = availableTargetUrl( + for: sourceUrl, + in: destinationUrl, + fileManager: fileManager, + excluding: reservedPaths + ) + reservedPaths.insert(targetUrl.path) + return targetUrl + } + + func release(_ targetUrl: URL) { + lock.lock() + reservedPaths.remove(targetUrl.path) + lock.unlock() + } + } + private final class CopyProgressContext: @unchecked Sendable { let baseCompletedBytes: Int64 let currentFileName: String @@ -123,11 +152,14 @@ enum FileTransferOperations { } } + private static let targetReservationRegistry = TargetReservationRegistry() + static func copyItems( _ sourceUrls: [URL], to requestedDestinationUrl: URL, cancellationToken: FileTransferCancellationToken = FileTransferCancellationToken(), - progressHandler: @escaping ProgressHandler = { _ in } + progressHandler: @escaping ProgressHandler = { _ in }, + copyAttemptWillStart: @escaping @Sendable (URL) -> Void = { _ in } ) -> FileTransferCopyResult { let fileManager = FileManager() let destinationUrl = requestedDestinationUrl.standardizedFileURL @@ -194,11 +226,6 @@ enum FileTransferOperations { continue } - let targetUrl = availableTargetUrl( - for: sourceUrl, - in: destinationUrl, - fileManager: fileManager - ) let snapshot = FileTransferProgressSnapshot( currentFileName: sourceUrl.lastPathComponent, completedBytes: completedBytes, @@ -218,11 +245,30 @@ enum FileTransferOperations { progressHandler: progressHandler ) - let copyErrorNumber = copyItem( - at: sourceUrl, - to: targetUrl, - context: context - ) + var targetUrl: URL + var copyErrorNumber: Int32 + repeat { + guard !cancellationToken.isCancelled else { + return FileTransferCopyResult( + copiedUrls: copiedUrls, + failures: failures, + wasCancelled: true + ) + } + targetUrl = targetReservationRegistry.reserveTargetUrl( + for: sourceUrl, + in: destinationUrl, + fileManager: fileManager + ) + copyAttemptWillStart(targetUrl) + copyErrorNumber = copyItem( + at: sourceUrl, + to: targetUrl, + context: context + ) + targetReservationRegistry.release(targetUrl) + } while copyErrorNumber == EEXIST + if copyErrorNumber == 0 { copiedUrls.append(targetUrl) finishItem(at: index, fileName: sourceUrl.lastPathComponent) @@ -261,10 +307,14 @@ enum FileTransferOperations { static func availableTargetUrl( for sourceUrl: URL, in destinationUrl: URL, - fileManager: FileManager + fileManager: FileManager, + excluding reservedPaths: Set = [] ) -> URL { let originalTargetUrl = destinationUrl.appendingPathComponent(sourceUrl.lastPathComponent) - guard fileManager.fileExists(atPath: originalTargetUrl.path) else { + guard + fileManager.fileExists(atPath: originalTargetUrl.path) + || reservedPaths.contains(originalTargetUrl.path) + else { return originalTargetUrl } @@ -280,7 +330,9 @@ enum FileTransferOperations { ? "\(baseName) \(copyIndex)" : "\(baseName) \(copyIndex).\(pathExtension)" let candidateUrl = destinationUrl.appendingPathComponent(candidateName) - if !fileManager.fileExists(atPath: candidateUrl.path) { + if !fileManager.fileExists(atPath: candidateUrl.path) + && !reservedPaths.contains(candidateUrl.path) + { return candidateUrl } copyIndex += 1 diff --git a/BetterMenuTests/BetterMenuCoreServiceTests.swift b/BetterMenuTests/BetterMenuCoreServiceTests.swift index 36fe7c0..94fd507 100644 --- a/BetterMenuTests/BetterMenuCoreServiceTests.swift +++ b/BetterMenuTests/BetterMenuCoreServiceTests.swift @@ -193,6 +193,51 @@ final class BetterMenuCoreServiceTests: XCTestCase { ) } + func testConcurrentFileTransfersReserveUniqueTargetNames() throws { + let rootUrl = try makeTemporaryDirectory() + let sourceUrl = rootUrl.appendingPathComponent("Concurrent.txt") + let destinationUrl = rootUrl.appendingPathComponent("Destination", isDirectory: true) + try FileManager.default.createDirectory(at: destinationUrl, withIntermediateDirectories: true) + let expectedContents = Data(repeating: 0x43, count: 1_024 * 1_024) + try expectedContents.write(to: sourceUrl) + + let completionGroup = DispatchGroup() + let startGate = ConcurrentCopyStartGate(participantCount: 2) + let results = LockedCopyResults() + + for _ in 0..<2 { + completionGroup.enter() + let firstAttemptGate = FirstCopyAttemptGate(startGate: startGate) + DispatchQueue.global(qos: .userInteractive).async { + let result = FileTransferOperations.copyItems( + [sourceUrl], + to: destinationUrl, + copyAttemptWillStart: { _ in + firstAttemptGate.waitIfNeeded() + } + ) + results.append(result) + completionGroup.leave() + } + } + + XCTAssertEqual(completionGroup.wait(timeout: .now() + 10), .success) + XCTAssertFalse(startGate.didTimeOut) + + let completedResults = results.values + XCTAssertEqual(completedResults.count, 2) + XCTAssertTrue(completedResults.allSatisfy { !$0.wasCancelled && $0.failures.isEmpty }) + + let copiedUrls = completedResults.flatMap(\.copiedUrls) + XCTAssertEqual( + Set(copiedUrls.map(\.lastPathComponent)), + ["Concurrent.txt", "Concurrent 2.txt"] + ) + for copiedUrl in copiedUrls { + XCTAssertEqual(try Data(contentsOf: copiedUrl), expectedContents) + } + } + func testFileTransferCanBeCancelledBeforeCopying() throws { let rootUrl = try makeTemporaryDirectory() let sourceUrl = rootUrl.appendingPathComponent("Source.txt") @@ -378,6 +423,83 @@ private final class LockedSnapshots: @unchecked Sendable { } } +private final class LockedCopyResults: @unchecked Sendable { + private let lock = NSLock() + private var results: [FileTransferCopyResult] = [] + + var values: [FileTransferCopyResult] { + lock.lock() + defer { lock.unlock() } + return results + } + + func append(_ result: FileTransferCopyResult) { + lock.lock() + results.append(result) + lock.unlock() + } +} + +private final class FirstCopyAttemptGate: @unchecked Sendable { + private let startGate: ConcurrentCopyStartGate + private let lock = NSLock() + private var isFirstAttempt = true + + init(startGate: ConcurrentCopyStartGate) { + self.startGate = startGate + } + + func waitIfNeeded() { + lock.lock() + let shouldWait = isFirstAttempt + isFirstAttempt = false + lock.unlock() + + guard shouldWait else { return } + startGate.arriveAndWait() + } +} + +private final class ConcurrentCopyStartGate: @unchecked Sendable { + private let participantCount: Int + private let lock = NSLock() + private var arrivedCount = 0 + private var timedOut = false + + init(participantCount: Int) { + self.participantCount = participantCount + } + + var didTimeOut: Bool { + lock.lock() + defer { lock.unlock() } + return timedOut + } + + func arriveAndWait() { + lock.lock() + arrivedCount += 1 + lock.unlock() + + let deadline = DispatchTime.now() + 5 + while true { + lock.lock() + let canStart = arrivedCount == participantCount + lock.unlock() + if canStart { + return + } + if DispatchTime.now() >= deadline { + lock.lock() + timedOut = true + lock.unlock() + return + } + Thread.sleep(forTimeInterval: 0.001) + } + } +} + private final class DirectoryMonitorTestSignal: @unchecked Sendable { private let markerUrl: URL private let expectation: XCTestExpectation diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 785ced5..c5d2884 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -94,7 +94,7 @@ xcodebuild \ `BetterMenuTests` 是无宿主单元测试 Target。共享 Scheme 在测试动作中只构建测试 Target,不构建或注册 `BetterMenu.app` 与 Finder Sync 扩展。不要重新启用主 App 或扩展的 `buildForTesting`,否则本地测试产物会出现在“登录项与扩展”中。 -构建主 App 或 Finder Sync 扩展时必须固定复用项目 `DerivedData` 或 Xcode 默认路径中的一种,不得混用或为同一工作区创建多个临时 DerivedData 路径。`build_and_run.sh` 默认增量编译以复用该 `DerivedData`,需要全量重建时显式传入 `--clean`(`--clean-cache` 另行清空运行缓存)。`build_and_run.sh` 会在每次重新构建前注销其他 DerivedData 或临时目录中的 BetterMenu 开发构建,并在重新构建或清理当前构建产物前注销当前开发扩展;注销必须在 `xcodebuild` 覆盖旧 `.appex` 前完成,否则旧插件 UUID 会残留在系统扩展列表中。清理其他开发构建的注册记录不会删除其文件;`package.sh` 会在打包结束后注销临时 Release 产物。 +构建主 App 或 Finder Sync 扩展时必须固定复用项目 `DerivedData` 或 Xcode 默认路径中的一种,不得混用或为同一工作区创建多个临时 DerivedData 路径。`build_and_run.sh` 默认增量编译以复用该 `DerivedData`,需要全量重建时显式传入 `--clean`(`--clean-cache` 另行清空运行缓存)。`build_and_run.sh` 会在重新构建或清理当前项目的固定 DerivedData 前注销其中的开发扩展;注销必须在 `xcodebuild` 覆盖旧 `.appex` 前完成,否则旧插件 UUID 会残留在系统扩展列表中。脚本不会处理其他 DerivedData 或临时目录中的 BetterMenu 注册,`package.sh` 会在打包结束后注销临时 Release 产物。 GitHub Actions 会在打包前执行代码风格检查与同一组测试,并检查测试产物中没有 `BetterMenu.app` 或 `BetterMenuFinderSync.appex`,防止 Scheme 配置回退。安全通信或 AppleScript 路径处理发生变化时,必须同步补充 `BetterMenuSecurityTests`;文件创建、复制、进度或取消逻辑发生变化时,必须同步补充 `BetterMenuCoreServiceTests`。 diff --git a/README.md b/README.md index 6a37099..45d52ed 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ xattr -cr /Applications/BetterMenu.app ./script/package.sh ``` -单元测试使用无宿主测试 Target,不会构建或注册 Finder Sync 扩展。开发脚本会在重新构建前注销其他 DerivedData 或临时目录中的 BetterMenu 开发构建,并在清理或打包完成时注销对应构建产物,避免“登录项与扩展”积累重复的开发扩展;该过程不会删除其他构建目录中的文件。 +单元测试使用无宿主测试 Target,不会构建或注册 Finder Sync 扩展。开发脚本会在重新构建或清理当前项目的固定 DerivedData 前注销其中的开发扩展,`package.sh` 会在打包完成时注销对应的临时 Release 产物;脚本不会处理其他 DerivedData 或临时目录中的 BetterMenu 注册。 ---