diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0395d3f..464ba13 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,54 @@ jobs: run: | xcodebuild -version - # 步骤 2.5:安装 create-dmg 以启用精美 DMG 打包布局 + # 步骤 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="$( + 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.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: | brew install create-dmg @@ -42,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 20a8055..ff36ea2 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 */; }; @@ -27,11 +30,23 @@ 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 */; }; + 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 */; }; + 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 */; }; + 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 */; }; + B20000000000000000000007 /* DirectoryChangeMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000731 /* DirectoryChangeMonitor.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -76,6 +91,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 = ""; }; @@ -84,11 +100,19 @@ 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 = ""; }; + 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 = ""; }; + 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; }; + 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 */ @@ -107,6 +131,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + B20000000000000000000020 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -115,6 +146,7 @@ children = ( A10000000000000000000022 /* BetterMenu */, A10000000000000000000023 /* BetterMenuFinderSync */, + B20000000000000000000021 /* BetterMenuTests */, A10000000000000000000020 /* README.md */, A10000000000000000000024 /* Products */, ); @@ -129,6 +161,7 @@ A10000000000000000000902 /* Views */, A10000000000000000000903 /* Services */, A10000000000000000000301 /* BetterMenuShared.swift */, + A10000000000000000000741 /* BetterMenuSecureRequest.swift */, A10000000000000000000025 /* Assets.xcassets */, A10000000000000000000015 /* Info.plist */, A10000000000000000000016 /* BetterMenu.entitlements */, @@ -164,6 +197,10 @@ A10000000000000000000401 /* ExternalAppLauncher.swift */, A10000000000000000000601 /* SystemCommand.swift */, A10000000000000000000611 /* IconCacheManager.swift */, + A10000000000000000000621 /* FileTransferService.swift */, + A10000000000000000000631 /* AppleScriptPathArgument.swift */, + A10000000000000000000641 /* FileTransferOperations.swift */, + A10000000000000000000651 /* FileTransferProgressWindow.swift */, ); path = Services; sourceTree = ""; @@ -175,6 +212,7 @@ A10000000000000000000701 /* SettingsMonitor.swift */, A10000000000000000000711 /* MenuIconManager.swift */, A10000000000000000000721 /* FileCreator.swift */, + A10000000000000000000731 /* DirectoryChangeMonitor.swift */, A10000000000000000000027 /* blank.docx */, A10000000000000000000028 /* blank.xlsx */, A10000000000000000000029 /* blank.pptx */, @@ -189,10 +227,20 @@ children = ( A10000000000000000000011 /* BetterMenu.app */, A10000000000000000000012 /* BetterMenuFinderSync.appex */, + B20000000000000000000011 /* BetterMenuTests.xctest */, ); name = Products; sourceTree = ""; }; + B20000000000000000000021 /* BetterMenuTests */ = { + isa = PBXGroup; + children = ( + B20000000000000000000012 /* BetterMenuSecurityTests.swift */, + B20000000000000000000013 /* BetterMenuCoreServiceTests.swift */, + ); + path = BetterMenuTests; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXVariantGroup section */ @@ -247,6 +295,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 */ @@ -265,6 +330,10 @@ CreatedOnToolsVersion = 15.3; ProvisioningStyle = Automatic; }; + B20000000000000000000031 = { + CreatedOnToolsVersion = 16.0; + ProvisioningStyle = Automatic; + }; }; }; buildConfigurationList = A10000000000000000000081 /* Build configuration list for PBXProject "BetterMenu" */; @@ -286,6 +355,7 @@ targets = ( A10000000000000000000031 /* BetterMenu */, A10000000000000000000032 /* BetterMenuFinderSync */, + B20000000000000000000031 /* BetterMenuTests */, ); }; /* End PBXProject section */ @@ -310,6 +380,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + B20000000000000000000052 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -328,8 +405,13 @@ 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 */, + A10000000000000000000630 /* AppleScriptPathArgument.swift in Sources */, + A10000000000000000000640 /* FileTransferOperations.swift in Sources */, + A10000000000000000000650 /* FileTransferProgressWindow.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -339,9 +421,26 @@ 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; + }; + B20000000000000000000051 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 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 */, + B20000000000000000000007 /* DirectoryChangeMonitor.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -364,7 +463,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; @@ -373,7 +472,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; @@ -388,7 +487,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; @@ -397,7 +496,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; @@ -411,7 +510,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 = ( @@ -420,7 +519,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; @@ -436,7 +535,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 = ( @@ -445,7 +544,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; @@ -564,6 +663,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 */ @@ -594,6 +719,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..5194660 100644 --- a/BetterMenu.xcodeproj/xcshareddata/xcschemes/BetterMenu.xcscheme +++ b/BetterMenu.xcodeproj/xcshareddata/xcschemes/BetterMenu.xcscheme @@ -7,7 +7,7 @@ buildImplicitDependencies = "YES"> + + + + + + + + + + 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 f71d683..769991c 100644 --- a/BetterMenu/BetterMenuShared.swift +++ b/BetterMenu/BetterMenuShared.swift @@ -1,4 +1,5 @@ import Cocoa +import Darwin // MARK: - 共享常量与路径定义 @@ -14,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 插件运行在沙盒中,直接调用 @@ -51,6 +68,57 @@ enum BetterMenuShared { .appendingPathComponent("Library/Caches/BetterMenu/icon_cache.plist") } + /// Finder 扩展向主应用提交一次性请求时使用的受限目录。 + static var secureRequestDirectoryUrl: URL { + realHomeDirectoryUrl + .appendingPathComponent("Library/Application Support/BetterMenu/Requests", isDirectory: true) + } + + /// 返回当前已挂载、可写且不位于 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: - 支持的第三方编辑器信息 /// 表示支持的第三方编辑器的元数据。 @@ -244,7 +312,7 @@ enum BetterMenuShared { text: "#!/usr/bin/env bash\n\n", shouldBeExecutable: true, enabledByDefault: false - ) + ), ] } @@ -316,27 +384,55 @@ 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: "vscode", - title: "在 VS Code 中打开", - iconName: "square.and.arrow.up", - isEnabled: true - ), + FinderAction(id: "sendFile", title: "发送文件到", iconName: "paperplane", isEnabled: true), ] +/// 默认提供的快捷操作列表。 +let defaultSharedActions: [FinderAction] = + requiredSharedActions + [ + FinderAction( + id: "vscode", + title: "在 VS Code 中打开", + iconName: "square.and.arrow.up", + isEnabled: true + ) + ] + +/// 为旧版配置补齐后来新增的必备内置操作,同时保留用户原有的启用状态和排序。 +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 映射并提取标准的路径扩展名。 /// "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 } @@ -402,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 27c0cde..4cb62f7 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? @@ -220,7 +243,7 @@ final class BetterMenuSettingsModel: ObservableObject { ) ?? .menuBarOnly if let storedActions = sharedSettings.actions { - self.actions = storedActions + self.actions = actionsAddingMissingRequiredActions(storedActions) } else { // 兼容以前的硬编码布尔值 var acts = defaultSharedActions @@ -238,7 +261,7 @@ final class BetterMenuSettingsModel: ObservableObject { acts[idx].isEnabled = oldPathEnabled } // vscode 默认启用 - self.actions = acts + self.actions = actionsAddingMissingRequiredActions(acts) } let storedTerminalType = @@ -254,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, @@ -270,9 +294,7 @@ final class BetterMenuSettingsModel: ObservableObject { } deinit { - for observer in notificationObservers { - NotificationCenter.default.removeObserver(observer) - } + notificationObservers.cleanUp() } // MARK: - 终端支持列表 @@ -290,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) @@ -448,7 +472,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 }) { @@ -728,7 +752,8 @@ 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)") } } @@ -741,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) } @@ -782,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()) 文件") } @@ -823,5 +841,3 @@ final class BetterMenuSettingsModel: ObservableObject { return migrated } } - - 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 9221e06..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 @@ -190,7 +194,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/FileTransferOperations.swift b/BetterMenu/Services/FileTransferOperations.swift new file mode 100644 index 0000000..3e800a0 --- /dev/null +++ b/BetterMenu/Services/FileTransferOperations.swift @@ -0,0 +1,512 @@ +import Darwin +import Foundation + +struct FileTransferCopyFailure: Equatable, Sendable { + let fileName: String + let message: String +} + +struct FileTransferCopyResult: Equatable, Sendable { + let copiedUrls: [URL] + let failures: [FileTransferCopyFailure] + let wasCancelled: Bool +} + +struct FileTransferProgressSnapshot: Equatable, Sendable { + let currentFileName: String + let completedBytes: Int64 + let totalBytes: Int64 + let completedItemCount: Int + let totalItemCount: Int +} + +final class FileTransferCancellationToken: @unchecked Sendable { + private let lock = NSLock() + private var cancelled = false + + var isCancelled: Bool { + lock.lock() + defer { lock.unlock() } + return cancelled + } + + func cancel() { + lock.lock() + cancelled = true + lock.unlock() + } +} + +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 + 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 + } + } + + private static let targetReservationRegistry = TargetReservationRegistry() + + static func copyItems( + _ sourceUrls: [URL], + to requestedDestinationUrl: URL, + cancellationToken: FileTransferCancellationToken = FileTransferCancellationToken(), + progressHandler: @escaping ProgressHandler = { _ in }, + copyAttemptWillStart: @escaping @Sendable (URL) -> Void = { _ in } + ) -> FileTransferCopyResult { + let fileManager = FileManager() + let destinationUrl = requestedDestinationUrl.standardizedFileURL + let standardizedSourceUrls = sourceUrls.map(\.standardizedFileURL) + 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, +) + + 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( + copiedUrls: copiedUrls, + failures: failures, + wasCancelled: true + ) + } + + guard !destinationIsInsideSource(destinationUrl, sourceUrl: sourceUrl) else { + failures.append( + FileTransferCopyFailure( + fileName: sourceUrl.lastPathComponent, + message: "不能将项目复制到其自身内部。" + ) + ) + finishItem(at: index, fileName: sourceUrl.lastPathComponent) + continue + } + + 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 + ) + + 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) + 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) + ) + ) + finishItem(at: index, fileName: sourceUrl.lastPathComponent) + } + + return FileTransferCopyResult( + copiedUrls: copiedUrls, + failures: failures, + wasCancelled: false + ) + } + + static func availableTargetUrl( + for sourceUrl: URL, + in destinationUrl: URL, + fileManager: FileManager, + excluding reservedPaths: Set = [] + ) -> URL { + let originalTargetUrl = destinationUrl.appendingPathComponent(sourceUrl.lastPathComponent) + guard + fileManager.fileExists(atPath: originalTargetUrl.path) + || reservedPaths.contains(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) + && !reservedPaths.contains(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, + cancellationToken: FileTransferCancellationToken + ) -> 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 + 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 + else { + continue + } + totalBytes += Int64(values.fileSize ?? 0) + } + return totalBytes + } + + /// 把复制失败的 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))。" + } + } +} diff --git a/BetterMenu/Services/FileTransferProgressWindow.swift b/BetterMenu/Services/FileTransferProgressWindow.swift new file mode 100644 index 0000000..3cfb452 --- /dev/null +++ b/BetterMenu/Services/FileTransferProgressWindow.swift @@ -0,0 +1,162 @@ +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") + } + + /// 显示进度窗口。 + /// + /// 复制任务是由访达右键菜单以 `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) + if visibleFrame.contains(shiftedFrame) { + window.setFrameOrigin(origin) + } + } else { + window.setFrameOrigin(origin) + } + } + + window.orderFrontRegardless() + } + + /// 相邻两个并发传输窗口的偏移量。 + private static let cascadeStep: CGFloat = 28 +} diff --git a/BetterMenu/Services/FileTransferService.swift b/BetterMenu/Services/FileTransferService.swift new file mode 100644 index 0000000..47f5fb1 --- /dev/null +++ b/BetterMenu/Services/FileTransferService.swift @@ -0,0 +1,194 @@ +import Cocoa +import os + +/// 承接 Finder 扩展发起的文件传输任务,确保大文件复制不依赖扩展进程生命周期。 +@MainActor +final class FileTransferService { + private struct ActiveCopyTask { + let task: Task + let cancellationToken: FileTransferCancellationToken + let progressWindowController: FileTransferProgressWindowController + let cascadeIndex: Int + } + + /// `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") + /// 正在进行的隔空投送。`perform(withItems:)` 是异步的,面板关闭前必须保持强引用; + /// 用单值保存会让第二次投送顶掉第一次的引用,因此按任务 id 分别持有,结束回调里再移除。 + private var activeAirDropTasks: [UUID: ActiveAirDropTask] = [:] + private var copyTasks: [UUID: ActiveCopyTask] = [:] + + /// 使用系统隔空投送面板发送选中的文件或文件夹。 + func sendViaAirDrop(sourceUrls: [URL]) { + let existingUrls = existingSourceUrls(from: sourceUrls) + guard !existingUrls.isEmpty else { + showError(title: "无法发送文件", message: "选中的文件或文件夹已不存在。") + return + } + guard let service = NSSharingService(named: .sendViaAirDrop) else { + showError(title: "隔空投送不可用", message: "请确认隔空投送已开启后再试。") + return + } + + 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) + } + + /// 将选中的文件或文件夹异步复制到外置磁盘、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 cancellationToken = FileTransferCancellationToken() + let progressModel = FileTransferProgressModel() + let progressWindowController = FileTransferProgressWindowController(model: progressModel) + progressModel.onCancel = { [weak self] in + self?.cancelCopyTask(taskId) + } + let cascadeIndex = nextAvailableCascadeIndex() + // 已有传输任务时把新窗口阶梯式错开,避免多个进度窗口完全重叠。 + progressWindowController.show(cascadeIndex: cascadeIndex) + + let task = Task { [weak self] in + let result = await Task.detached(priority: .userInitiated) { + 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] = ActiveCopyTask( + task: task, + cancellationToken: cancellationToken, + 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 = [] + 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 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.wasCancelled, !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() + } +} diff --git a/BetterMenu/Services/IconCacheManager.swift b/BetterMenu/Services/IconCacheManager.swift index 5fe68c2..eb0dc05 100644 --- a/BetterMenu/Services/IconCacheManager.swift +++ b/BetterMenu/Services/IconCacheManager.swift @@ -1,8 +1,11 @@ import Cocoa import UniformTypeIdentifiers +import os /// 负责在后台异步预热文件类型图标及外部应用程序图标,并将渲染好的 Retina PNG 数据序列化写入磁盘缓存。 enum IconCacheManager { + private static let logger = Logger( + subsystem: "com.zombie.BetterMenu", category: "IconCacheManager") /// 执行图标的异步预热与磁盘缓存写入 /// - Parameters: /// - fileTypes: 当前配置的全部内置文件类型定义 @@ -18,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 - DispatchQueue.global(qos: .utility).async { - let targetSize = NSSize(width: 18, height: 18) + Task.detached(priority: .utility) { + let targetSize = BetterMenuShared.menuIconSize var dict: [String: Data] = [:] // 1. 预热并绘制所有可能用到得新建文件类型图标 @@ -40,12 +43,13 @@ 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 } 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 @@ -80,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)") } } } 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/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/DirectoryChangeMonitor.swift b/BetterMenuFinderSync/DirectoryChangeMonitor.swift new file mode 100644 index 0000000..29aad39 --- /dev/null +++ b/BetterMenuFinderSync/DirectoryChangeMonitor.swift @@ -0,0 +1,195 @@ +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 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 state = State() + + 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) { + 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) + guard fileDescriptor >= 0 else { + logger.error( + "Failed to open directory descriptor for monitoring: \(self.directoryUrl.path, privacy: .public)" + ) + scheduleRetry() + return + } + + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fileDescriptor, + eventMask: [.write, .delete, .rename, .revoke], + queue: queue + ) + source.setEventHandler { [weak self] in + self?.handleEvent(for: generation) + } + source.setCancelHandler { + close(fileDescriptor) + } + + stateLock.lock() + let shouldInstall = + state.isRunning + && state.generation == generation + && state.source == nil + if shouldInstall { + state.source = source + state.retryAttempt = 0 + } + stateLock.unlock() + + guard shouldInstall else { + source.cancel() + source.resume() + return + } + source.resume() + logger.info("Started monitoring directory: \(self.directoryUrl.path, privacy: .public)") + } + + private func handleEvent(for generation: UInt64) { + stateLock.lock() + 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() + + 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/BetterMenuFinderSync/FileCreator.swift b/BetterMenuFinderSync/FileCreator.swift index bfe481e..eb0ee40 100644 --- a/BetterMenuFinderSync/FileCreator.swift +++ b/BetterMenuFinderSync/FileCreator.swift @@ -1,5 +1,5 @@ -import Foundation import Cocoa +import Foundation /// 负责实际物理文件的创建、防文件名冲突算法计算以及 POSIX 执行权限设置。 enum FileCreator { @@ -7,15 +7,15 @@ enum FileCreator { struct FileCreationRequest { let directory: URL let definition: FileDefinition + let bundle: Bundle private let fileManager = FileManager.default /// 执行物理文件的创建 /// - Returns: 返回最终成功创建的文件路径 URL 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 549e8bf..fd12b33 100644 --- a/BetterMenuFinderSync/FinderSync.swift +++ b/BetterMenuFinderSync/FinderSync.swift @@ -6,15 +6,17 @@ 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] = [:] + private var fileTransferVolumesByTag: [Int: FileTransferVolume] = [:] private static let dynamicActionTagBase = 1000 + private static let fileTransferVolumeTagBase = 2000 // MARK: - 基础图标加载 @@ -22,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 }() @@ -73,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() @@ -110,6 +113,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) { @@ -120,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 @@ -153,9 +159,15 @@ 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).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 +175,62 @@ 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) { + let airDropItem = NSMenuItem( + title: "隔空投送…", + action: #selector(sendSelectedItemsViaAirDrop(_:)), + keyEquivalent: "" + ) + airDropItem.target = self + let airDropIcon = airDropService.image + airDropIcon.size = BetterMenuShared.menuIconSize + airDropItem.image = airDropIcon + submenu.addItem(airDropItem) + } + + let selectedVolumePaths = sourceVolumePaths(for: selectedUrls) + let transferVolumes = BetterMenuShared.availableFileTransferVolumes().filter { + !selectedVolumePaths.contains($0.url.path) + } + 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 = BetterMenuShared.menuIconSize + 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: "") @@ -170,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 } @@ -207,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()) 文件", @@ -283,27 +356,67 @@ 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), - ] + let request = BetterMenuSecureRequest.runAction( + actionId: action.id, + path: resolvedUrl.standardizedFileURL.path + ) + openMainApplication(with: request, activates: true) + } + + @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: BetterMenuFileTransferMode, + destination: URL? + ) { + let selectedUrls = selectedItemUrlsForFileTransfer() + guard !selectedUrls.isEmpty else { + showError(message: "没有可发送的文件或文件夹。") + return + } + + 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 = true + configuration.activates = activates configuration.promptsUserIfNeeded = true NSWorkspace.shared.open(requestUrl, configuration: configuration) { [weak self] _, error in - if let error = error { + if let error { + BetterMenuSecureRequestStore.discard(requestId: request.id) self?.showError(message: "唤起 BetterMenu 失败:\(error.localizedDescription)") - return } } } @@ -318,7 +431,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)") @@ -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 @@ -374,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 6d801e8..21f5b1e 100644 --- a/BetterMenuFinderSync/MenuIconManager.swift +++ b/BetterMenuFinderSync/MenuIconManager.swift @@ -8,22 +8,49 @@ final class MenuIconManager: @unchecked Sendable { private var menuItemIconsById: [String: NSImage] = [:] 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 + /// 主应用的图标预热是在设置写盘之后才异步完成的,因此不能只依赖 `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() + } + } - /// 清除全部内存缓存(在偏好设置或系统外观模式发生改变时调用) - func clearCache() { + 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] = [:] + var lastAppearanceIsDark: Bool? = nil + } + private static let symbolCacheLock = OSAllocatedUnfairLock(initialState: SymbolCacheState()) + + /// 清除全部内存缓存(在偏好设置或系统外观模式发生改变时调用) + func clearCache() { + invalidateDiskCache() - Self.symbolCacheLock.lock() - Self.symbolCache.removeAll() - Self.lastAppearanceIsDark = nil - Self.symbolCacheLock.unlock() + Self.symbolCacheLock.withLock { state in + state.symbolCache.removeAll() + state.lastAppearanceIsDark = nil + } } /// 获取在磁盘中预热缓存的图标。若未加载,则在此处触发加载。 @@ -44,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() @@ -57,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 @@ -75,47 +103,51 @@ 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() - 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 = BetterMenuShared.menuIconSize + 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() - } - 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 + state.symbolCache[symbolName] = image + return image } - image.isTemplate = false // 禁用 template 模式,防止系统自动二次涂色 - - symbolCache[symbolName] = image - return image } /// 无 API 警告地通过系统全局 UserDefaults 快速读取系统深浅色外观状态 @@ -123,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 69bd7dc..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,26 +66,39 @@ 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 { - finalActions = actionsList + finalActions = actionsAddingMissingRequiredActions(actionsList) } 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 = acts + finalActions = actionsAddingMissingRequiredActions(acts) } return SettingsSnapshot( @@ -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 new file mode 100644 index 0000000..94fd507 --- /dev/null +++ b/BetterMenuTests/BetterMenuCoreServiceTests.swift @@ -0,0 +1,550 @@ +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 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) + 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 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") + 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 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) + 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) + } + + /// 失败项目也已处理完毕,必须计入进度分子,否则分母含全部项目、 + /// 分子只含成功项,部分失败时进度条会永远停在中途、无法到达 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) + 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() + } +} + +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 + 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/BetterMenuTests/BetterMenuSecurityTests.swift b/BetterMenuTests/BetterMenuSecurityTests.swift new file mode 100644 index 0000000..cbea269 --- /dev/null +++ b/BetterMenuTests/BetterMenuSecurityTests.swift @@ -0,0 +1,202 @@ +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) + } + + /// 自定义菜单 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) + 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 1bc52ee..c5d2884 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/ @@ -26,17 +27,26 @@ BetterMenu/ BetterMenuPermissionAboutViews.swift # 权限、Finder 重启与关于页面 BetterMenuSettingsComponents.swift # 设置页共享 UI 组件与流式布局 Services/ + AppleScriptPathArgument.swift # AppleScript 文件路径安全编码,防止路径改变脚本语法 ExternalAppLauncher.swift # 终端、VS Code 等外部应用一键拉起服务 + FileTransferService.swift # 隔空投送与后台复制任务生命周期调度 + FileTransferOperations.swift # copyfile 复制、字节进度、取消、重名与失败清理 + FileTransferProgressWindow.swift # 文件传输进度和取消窗口 TerminalApp.swift # 可选终端应用定义列表 SystemCommand.swift # 底层命令行调用 Process 进程运行封装 IconCacheManager.swift # 专职后台图标渲染、预热与序列化磁盘缓存服务 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 内置空白二进制模板文件 + +BetterMenuTests/ + BetterMenuSecurityTests.swift # 一次性请求、URL 路由、AppleScript 路径与设置迁移测试 + BetterMenuCoreServiceTests.swift # 文件创建、复制进度、取消与路径边界测试 ``` ### 共享配置机制 @@ -44,18 +54,53 @@ 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 同时读取的数据定义时,必须直接归入共享层,禁止在调用方重复定义常量或硬编码字面量。自定义菜单 ID 只能通过 `BetterMenuShared.customMenuId(for:)` 与 `customExtension(fromMenuId:)` 构造与解析,菜单图标尺寸统一取 `BetterMenuShared.menuIconSize`(图标缓存按此尺寸预渲染,两侧取值不一致会导致缓存图标被系统再次缩放而模糊)。 + +### Finder 扩展安全请求机制 + +Finder 扩展不得通过 URL Scheme 直接传递动作 ID、源文件路径或目标路径。扩展会先将请求写入权限为 `0600` 的一次性 plist 文件,再使用仅包含随机 UUID 的 `bettermenu://request?id=...` 链接唤起主应用。 + +主应用消费请求前会验证请求目录与文件所有者、POSIX 权限、文件类型、大小、协议版本、路径格式和 30 秒有效期。请求文件在读取时立即从目录移除,防止重放。旧版 `run-action`、`send-files` 和 `open-terminal` 明文参数路由不再接受。 -共享数据模型 `FileDefinition`、`FinderAction` 和键名统一在 `BetterMenuShared.swift` 中管理。新增需要两个 Target 同时读取的数据定义时,必须直接归入共享层。 +该机制的数据模型与实现集中在 `BetterMenu/BetterMenuSecureRequest.swift`,与其余共享定义分离,便于单独审查这条安全边界。 ### 结构维护规范 - **主应用入口保持轻量**:`AppDelegate` 仅处理生命周期分发、AppKit 激活策略以及 URL Scheme 路由。 - **ViewModel 高度内聚**:`BetterMenuSettingsModel` 只做数据和 UI 绑定,底层任务(如图标预热、System 进程命令)一律剥离成独立业务服务(如 `IconCacheManager`,`SystemCommand`)。 -- **FinderSync 扩展轻量化**:`FinderSync` 类仅扮演控制器角色。偏好读取与目录监听由 `SettingsMonitor` 托管,图标解析与渲染由 `MenuIconManager` 托管,文件生成由 `FileCreator` 托管。 +- **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`。 +--- + +## ✅ 自动化测试 + +运行全部单元测试: + +```bash +xcodebuild \ + -project BetterMenu.xcodeproj \ + -scheme BetterMenu \ + -configuration Debug \ + -destination 'platform=macOS' \ + test +``` + +`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 前注销其中的开发扩展;注销必须在 `xcodebuild` 覆盖旧 `.appex` 前完成,否则旧插件 UUID 会残留在系统扩展列表中。脚本不会处理其他 DerivedData 或临时目录中的 BetterMenu 注册,`package.sh` 会在打包结束后注销临时 Release 产物。 + +GitHub Actions 会在打包前执行代码风格检查与同一组测试,并检查测试产物中没有 `BetterMenu.app` 或 `BetterMenuFinderSync.appex`,防止 Scheme 配置回退。安全通信或 AppleScript 路径处理发生变化时,必须同步补充 `BetterMenuSecurityTests`;文件创建、复制、进度或取消逻辑发生变化时,必须同步补充 `BetterMenuCoreServiceTests`。 + +测试结果会以 `.xcresult` 包上传为 `BetterMenu-TestResults` 工件(无论成败),打包日志上传为 `BetterMenu-BuildLog`,均保留 14 天。排查 CI 失败时优先下载这两个工件,而不是只看日志尾部输出。 + + --- ## 🔁 软件更新与自动化发布 (Sparkle) @@ -90,4 +135,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..45d52ed 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 访达、或退出软件。 @@ -99,18 +103,44 @@ xattr -cr /Applications/BetterMenu.app ./script/build_and_run.sh run ``` +该脚本默认使用增量编译,日常改动只重新编译受影响的文件。需要全量重建时追加 `--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 ./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 ./script/package.sh ``` +单元测试使用无宿主测试 Target,不会构建或注册 Finder Sync 扩展。开发脚本会在重新构建或清理当前项目的固定 DerivedData 前注销其中的开发扩展,`package.sh` 会在打包完成时注销对应的临时 Release 产物;脚本不会处理其他 DerivedData 或临时目录中的 BetterMenu 注册。 + --- ## 📖 开发者架构文档 @@ -122,6 +152,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..1795185 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 # ------------------------------------------------------------------------------ @@ -152,10 +167,78 @@ 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 +} + +# 注销其他 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 误伤系统根目录或用户主目录 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 @@ -208,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 @@ -279,8 +367,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 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. 执行环境校验与步骤函数