diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml new file mode 100644 index 0000000..aaa20cb --- /dev/null +++ b/.github/workflows/pr-build.yml @@ -0,0 +1,57 @@ +name: PR Build & Test + +on: + pull_request: + branches: + - main + - dev + +concurrency: + group: pr-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-test: + name: Build & Test (macOS) + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Select Xcode + run: | + XCODE_PATH=$(ls -d /Applications/Xcode*.app | sort -V | tail -1) + sudo xcode-select -s "$XCODE_PATH/Contents/Developer" + xcodebuild -version + swift --version + + - name: Build RClick + run: | + set -euo pipefail + xcodebuild build \ + -project RClick.xcodeproj \ + -scheme RClick \ + -configuration Debug \ + -destination "generic/platform=macOS" \ + ARCHS=arm64 \ + MACOSX_DEPLOYMENT_TARGET=15.6 \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGN_IDENTITY="" \ + | tee build.log + + - name: Run Unit Tests + run: | + set -euo pipefail + xcodebuild test \ + -project RClick.xcodeproj \ + -scheme RClick \ + -configuration Debug \ + -destination "platform=macOS" \ + -only-testing:RClickTests \ + ARCHS=arm64 \ + MACOSX_DEPLOYMENT_TARGET=15.6 \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGN_IDENTITY="" \ + 2>&1 | tee test-output.log diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3acf390 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,287 @@ +name: Build, Sign, Notarize and Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + build-and-release: + name: Build, Sign, Notarize & Release + runs-on: macos-latest + env: + APP_NAME: RClick + EXTENSION_NAME: FinderSyncExt + SCHEME: RClick + PROJECT: RClick.xcodeproj + steps: + # --- 0. 准备工作 --- + - name: Checkout + uses: actions/checkout@v4 + + - name: Select Xcode + run: | + XCODE_PATH=$(ls -d /Applications/Xcode*.app | sort -V | tail -1) + sudo xcode-select -s "$XCODE_PATH/Contents/Developer" + xcodebuild -version + + - name: Get Version from Tag + id: version + run: | + VERSION="${GITHUB_REF#refs/tags/v}" + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Will release version: $VERSION" + + # --- 1. 导入签名证书 --- + - name: Import Developer ID Certificate + env: + CERTIFICATE_P12: ${{ secrets.MACOS_CERT_P12 }} + CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }} + KEYCHAIN_PASSWORD: 'rclick_temp_keychain' + run: | + set -euo pipefail + security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain + security default-keychain -s build.keychain + security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain + security set-keychain-settings -lut 21600 build.keychain + + echo "$CERTIFICATE_P12" | base64 --decode > certificate.p12 + security import certificate.p12 \ + -k build.keychain \ + -P "$CERTIFICATE_PASSWORD" \ + -T /usr/bin/codesign + security set-key-partition-list \ + -S apple-tool:,apple:,codesign: \ + -s -k "$KEYCHAIN_PASSWORD" build.keychain + + security list-keychains -d user -s build.keychain login.keychain + + CERT_NAME=$(security find-identity -v -s 'Developer ID Application' | head -n 1 | awk -F'"' '{print $2}') + echo "CERT_NAME=$CERT_NAME" >> $GITHUB_ENV + echo "Using certificate: $CERT_NAME" + + # --- 2. 构建 Release App --- + - name: Build Release + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + + BUILD_NUMBER="$(date +%Y%m%d%H%M)" + xcodebuild build \ + -project "$PROJECT" \ + -scheme "$SCHEME" \ + -configuration Release \ + -derivedDataPath build \ + -destination "generic/platform=macOS" \ + ARCHS=arm64 \ + MACOSX_DEPLOYMENT_TARGET=15.6 \ + CODE_SIGNING_ALLOWED=NO \ + SWIFT_ACTIVE_COMPILATION_CONDITIONS="" \ + CURRENT_PROJECT_VERSION="$BUILD_NUMBER" \ + MARKETING_VERSION="$VERSION" \ + | tee build-release.log + + APP_PATH=$(find build -name "${APP_NAME}.app" -type d -path "*/Products/Release/*" | head -n 1) + if [ -z "$APP_PATH" ]; then + echo "Error: App not found in build output" + exit 1 + fi + echo "APP_PATH=$APP_PATH" >> $GITHUB_ENV + echo "Built app at: $APP_PATH" + + echo "Main app architecture:" + lipo -info "$APP_PATH/Contents/MacOS/${APP_NAME}" || true + + # --- 3. 分层代码签名 --- + - name: Code Sign + run: | + set -euo pipefail + + echo "Signing with: $CERT_NAME" + + # 1) 签名所有 framework + if [ -d "$APP_PATH/Contents/Frameworks" ]; then + echo "Signing frameworks..." + find "$APP_PATH/Contents/Frameworks" -type f -perm +111 \ + -exec codesign --force --options runtime \ + --timestamp \ + --sign "$CERT_NAME" {} \; + fi + + # 2) 签名扩展 (FinderSyncExt.appex) + echo "Signing FinderSync extension..." + EXT_PATH="$APP_PATH/Contents/PlugIns/${EXTENSION_NAME}.appex" + if [ -d "$EXT_PATH" ]; then + codesign --force --options runtime \ + --timestamp \ + --entitlements "${EXTENSION_NAME}/${EXTENSION_NAME}.entitlements" \ + --sign "$CERT_NAME" \ + "$EXT_PATH" + fi + + # 3) 签名主 App (使用项目 entitlements) + echo "Signing main app..." + codesign --force --options runtime \ + --timestamp \ + --entitlements "${APP_NAME}/${APP_NAME}.entitlements" \ + --sign "$CERT_NAME" \ + "$APP_PATH" + + # 验证签名 + echo "Verifying signatures..." + codesign -dv --verbose=4 "$APP_PATH" 2>&1 + codesign --verify --verbose "$APP_PATH" + + # --- 4. 打包为 ZIP 用于公证 --- + - name: Zip for Notarization + run: | + VERSION=${{ steps.version.outputs.version }} + ZIP_NAME="${APP_NAME}-v${VERSION}.notarize.zip" + ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "$ZIP_NAME" + echo "NOTARIZE_ZIP=$ZIP_NAME" >> $GITHUB_ENV + + # --- 5. 公证 --- + - name: Notarize + env: + TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + KEY_ID: ${{ secrets.NOTARY_KEY_ID }} + ISSUER_ID: ${{ secrets.NOTARY_ISSUER_ID }} + PRIVATE_KEY: ${{ secrets.NOTARY_PRIVATE_KEY }} + run: | + set -euo pipefail + + echo "$PRIVATE_KEY" > "AuthKey_${KEY_ID}.p8" + + RESULT=$(xcrun notarytool submit "$NOTARIZE_ZIP" \ + --key-id "$KEY_ID" \ + --issuer "$ISSUER_ID" \ + --key "AuthKey_${KEY_ID}.p8" \ + --team-id "$TEAM_ID" \ + --wait \ + --output-format json) + + echo "$RESULT" | python3 -m json.tool || echo "$RESULT" + + STATUS=$(echo "$RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('status','unknown'))") + if [ "$STATUS" != "Accepted" ]; then + echo "Notarization failed: $STATUS" + SUBMISSION_ID=$(echo "$RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))") + if [ -n "$SUBMISSION_ID" ]; then + xcrun notarytool log "$SUBMISSION_ID" \ + --key-id "$KEY_ID" \ + --issuer "$ISSUER_ID" \ + --key "AuthKey_${KEY_ID}.p8" \ + --team-id "$TEAM_ID" 2>&1 || true + fi + exit 1 + fi + + echo "Notarization successful" + + # --- 6. 绑定公证票据 --- + - name: Staple + run: | + xcrun stapler staple "$APP_PATH" + spctl --assess --type execute --verbose=4 "$APP_PATH" + + # --- 7. 创建 DMG --- + - name: Install create-dmg + run: brew install create-dmg + + - name: Create DMG + run: | + VERSION=${{ steps.version.outputs.version }} + DMG_NAME="${APP_NAME}-v${VERSION}.dmg" + + cp -R "$APP_PATH" "./${APP_NAME}.app" + + create-dmg \ + --volname "${APP_NAME}" \ + --window-pos 200 120 \ + --window-size 600 400 \ + --icon-size 100 \ + --icon "${APP_NAME}.app" 150 200 \ + --hide-extension "${APP_NAME}.app" \ + --app-drop-link 450 200 \ + "$DMG_NAME" \ + "./${APP_NAME}.app" + + codesign --force --sign "$CERT_NAME" "$DMG_NAME" + + echo "DMG_NAME=$DMG_NAME" >> $GITHUB_ENV + + # --- 7b. DMG 公证 --- + - name: Notarize DMG + env: + TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + KEY_ID: ${{ secrets.NOTARY_KEY_ID }} + ISSUER_ID: ${{ secrets.NOTARY_ISSUER_ID }} + PRIVATE_KEY: ${{ secrets.NOTARY_PRIVATE_KEY }} + run: | + set -euo pipefail + + echo "$PRIVATE_KEY" > "AuthKey_${KEY_ID}.p8" + + RESULT=$(xcrun notarytool submit "$DMG_NAME" \ + --key-id "$KEY_ID" \ + --issuer "$ISSUER_ID" \ + --key "AuthKey_${KEY_ID}.p8" \ + --team-id "$TEAM_ID" \ + --wait \ + --output-format json) + + echo "$RESULT" | python3 -m json.tool || echo "$RESULT" + + STATUS=$(echo "$RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('status','unknown'))") + if [ "$STATUS" != "Accepted" ]; then + echo "DMG notarization failed: $STATUS" + exit 1 + fi + + xcrun stapler staple "$DMG_NAME" + echo "DMG notarized and stapled" + + # --- 8. 创建 .app.zip(给自动更新器用)--- + - name: Create App ZIP + run: | + VERSION=${{ steps.version.outputs.version }} + ZIP_NAME="${APP_NAME}-v${VERSION}.app.zip" + + cp -R "$APP_PATH" "./${APP_NAME}.app" + ditto -c -k --sequesterRsrc --keepParent "./${APP_NAME}.app" "$ZIP_NAME" + + SHA256=$(shasum -a 256 "$ZIP_NAME" | awk '{print $1}') + printf '%s %s\n' "$SHA256" "$ZIP_NAME" > "${ZIP_NAME}.sha256" + + echo "ZIP_NAME=$ZIP_NAME" >> $GITHUB_ENV + + ls -lh "$ZIP_NAME" "${ZIP_NAME}.sha256" "$DMG_NAME" + + # --- 9. 创建 GitHub Release --- + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + files: | + ${{ env.ZIP_NAME }} + ${{ env.ZIP_NAME }}.sha256 + ${{ env.DMG_NAME }} + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # --- 10. 失败时删除 Tag --- + - name: Cleanup Tag on Failure + if: failure() + run: | + echo "Build failed, deleting tag: ${{ github.ref_name }}" + git push origin --delete "${{ github.ref_name }}" || true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/RClick.xcodeproj/project.pbxproj b/RClick.xcodeproj/project.pbxproj index a7d6997..0a43097 100644 --- a/RClick.xcodeproj/project.pbxproj +++ b/RClick.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 100; + objectVersion = 77; objects = { /* Begin PBXBuildFile section */ @@ -62,13 +62,6 @@ remoteGlobalIDString = 5C8A28D92F83D65900FE687C; remoteInfo = RClick; }; - 5C8A28F42F83D65B00FE687C /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 5C8A28D22F83D65900FE687C /* Project object */; - proxyType = 1; - remoteGlobalIDString = 5C8A28D92F83D65900FE687C; - remoteInfo = RClick; - }; 5C8A291D2F83D74A00FE687C /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 5C8A28D22F83D65900FE687C /* Project object */; @@ -93,7 +86,7 @@ /* Begin PBXFileReference section */ 5C8A28DA2F83D65900FE687C /* RClick.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RClick.app; sourceTree = BUILT_PRODUCTS_DIR; }; 5C8A28E92F83D65B00FE687C /* RClickTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RClickTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 5C8A28F32F83D65B00FE687C /* RClickUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RClickUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 5C8A29182F83D74A00FE687C /* FinderSyncExt.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = FinderSyncExt.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 5C969FB12F8A494600D022F8 /* CLAUDE.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CLAUDE.md; sourceTree = ""; }; 5C969FB22F8A494600D022F8 /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = ""; }; @@ -123,13 +116,7 @@ ); target = 5C8A28E82F83D65B00FE687C /* RClickTests */; }; - 5C96A3882F8A9E2B00D022F8 /* Exceptions for "RClick" folder in "RClickUITests" target */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - Assets.xcassets, - ); - target = 5C8A28F22F83D65B00FE687C /* RClickUITests */; - }; + 5C96A3892F8A9E2B00D022F8 /* Exceptions for "RClick" folder in "FinderSyncExt" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -144,7 +131,6 @@ isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( 5C96A3872F8A9E2B00D022F8 /* Exceptions for "RClick" folder in "RClickTests" target */, - 5C96A3882F8A9E2B00D022F8 /* Exceptions for "RClick" folder in "RClickUITests" target */, 5C96A3892F8A9E2B00D022F8 /* Exceptions for "RClick" folder in "FinderSyncExt" target */, ); path = RClick; @@ -155,11 +141,7 @@ path = RClickTests; sourceTree = ""; }; - 5C8A28F62F83D65B00FE687C /* RClickUITests */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = RClickUITests; - sourceTree = ""; - }; + 5C8A29192F83D74A00FE687C /* FinderSyncExt */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -186,11 +168,6 @@ files = ( ); }; - 5C8A28F02F83D65B00FE687C /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - files = ( - ); - }; 5C8A29152F83D74A00FE687C /* Frameworks */ = { isa = PBXFrameworksBuildPhase; files = ( @@ -204,7 +181,6 @@ children = ( 5C8A28DC2F83D65900FE687C /* RClick */, 5C8A28EC2F83D65B00FE687C /* RClickTests */, - 5C8A28F62F83D65B00FE687C /* RClickUITests */, 5C8A29192F83D74A00FE687C /* FinderSyncExt */, 5C8A29302F83D85000FE687C /* Shared */, 5C8A28DB2F83D65900FE687C /* Products */, @@ -220,7 +196,6 @@ children = ( 5C8A28DA2F83D65900FE687C /* RClick.app */, 5C8A28E92F83D65B00FE687C /* RClickTests.xctest */, - 5C8A28F32F83D65B00FE687C /* RClickUITests.xctest */, 5C8A29182F83D74A00FE687C /* FinderSyncExt.appex */, ); name = Products; @@ -296,27 +271,7 @@ productReference = 5C8A28E92F83D65B00FE687C /* RClickTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; - 5C8A28F22F83D65B00FE687C /* RClickUITests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 5C8A29032F83D65B00FE687C /* Build configuration list for PBXNativeTarget "RClickUITests" */; - buildPhases = ( - 5C8A28EF2F83D65B00FE687C /* Sources */, - 5C8A28F02F83D65B00FE687C /* Frameworks */, - 5C8A28F12F83D65B00FE687C /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - 5C8A28F52F83D65B00FE687C /* PBXTargetDependency */, - ); - fileSystemSynchronizedGroups = ( - 5C8A28F62F83D65B00FE687C /* RClickUITests */, - ); - name = RClickUITests; - productName = RClickUITests; - productReference = 5C8A28F32F83D65B00FE687C /* RClickUITests.xctest */; - productType = "com.apple.product-type.bundle.ui-testing"; - }; + 5C8A29172F83D74A00FE687C /* FinderSyncExt */ = { isa = PBXNativeTarget; buildConfigurationList = 5C8A29242F83D74A00FE687C /* Build configuration list for PBXNativeTarget "FinderSyncExt" */; @@ -353,10 +308,6 @@ CreatedOnToolsVersion = 26.4; TestTargetID = 5C8A28D92F83D65900FE687C; }; - 5C8A28F22F83D65B00FE687C = { - CreatedOnToolsVersion = 26.4; - TestTargetID = 5C8A28D92F83D65900FE687C; - }; 5C8A29172F83D74A00FE687C = { CreatedOnToolsVersion = 26.4; }; @@ -373,14 +324,13 @@ ); mainGroup = 5C8A28D12F83D65900FE687C; minimizedProjectReferenceProxies = 1; - preferredProjectObjectVersion = 100; + preferredProjectObjectVersion = 77; productRefGroup = 5C8A28DB2F83D65900FE687C /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( 5C8A28D92F83D65900FE687C /* RClick */, 5C8A28E82F83D65B00FE687C /* RClickTests */, - 5C8A28F22F83D65B00FE687C /* RClickUITests */, 5C8A29172F83D74A00FE687C /* FinderSyncExt */, ); }; @@ -419,22 +369,6 @@ 5C969FE62F8A496000D022F8 /* 设置界面重构设计.md in Resources */, ); }; - 5C8A28F12F83D65B00FE687C /* Resources */ = { - isa = PBXResourcesBuildPhase; - files = ( - 5C969FB42F8A494600D022F8 /* README.md in Resources */, - 5C969FB52F8A494600D022F8 /* CLAUDE.md in Resources */, - 5C969FB62F8A494600D022F8 /* LICENSE in Resources */, - 5C969FE72F8A496000D022F8 /* 架构重构设计及计划.md in Resources */, - 5C969FE82F8A496000D022F8 /* 重构计划.md in Resources */, - 5C969FE92F8A496000D022F8 /* 重构设计.md in Resources */, - 5C969FEA2F8A496000D022F8 /* communication-protocol.md in Resources */, - 5C969FEB2F8A496000D022F8 /* data-models.md in Resources */, - 5C969FEC2F8A496000D022F8 /* extension-reconnect.md in Resources */, - 5C969FED2F8A496000D022F8 /* git-branch-strategy.md in Resources */, - 5C969FEE2F8A496000D022F8 /* 设置界面重构设计.md in Resources */, - ); - }; 5C8A29162F83D74A00FE687C /* Resources */ = { isa = PBXResourcesBuildPhase; files = ( @@ -464,11 +398,6 @@ files = ( ); }; - 5C8A28EF2F83D65B00FE687C /* Sources */ = { - isa = PBXSourcesBuildPhase; - files = ( - ); - }; 5C8A29142F83D74A00FE687C /* Sources */ = { isa = PBXSourcesBuildPhase; files = ( @@ -482,11 +411,6 @@ target = 5C8A28D92F83D65900FE687C /* RClick */; targetProxy = 5C8A28EA2F83D65B00FE687C /* PBXContainerItemProxy */; }; - 5C8A28F52F83D65B00FE687C /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 5C8A28D92F83D65900FE687C /* RClick */; - targetProxy = 5C8A28F42F83D65B00FE687C /* PBXContainerItemProxy */; - }; 5C8A291E2F83D74A00FE687C /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 5C8A29172F83D74A00FE687C /* FinderSyncExt */; @@ -739,46 +663,6 @@ }; name = Release; }; - 5C8A29042F83D65B00FE687C /* Debug configuration for PBXNativeTarget "RClickUITests" */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = 4L3563XCBN; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = cn.wflixu.RClickUITests; - PRODUCT_NAME = "$(TARGET_NAME)"; - STRING_CATALOG_GENERATE_SYMBOLS = NO; - SWIFT_APPROACHABLE_CONCURRENCY = YES; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 6.2; - TEST_TARGET_NAME = RClick; - }; - name = Debug; - }; - 5C8A29052F83D65B00FE687C /* Release configuration for PBXNativeTarget "RClickUITests" */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = 4L3563XCBN; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = cn.wflixu.RClickUITests; - PRODUCT_NAME = "$(TARGET_NAME)"; - STRING_CATALOG_GENERATE_SYMBOLS = NO; - SWIFT_APPROACHABLE_CONCURRENCY = YES; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 6.2; - TEST_TARGET_NAME = RClick; - }; - name = Release; - }; 5C8A29212F83D74A00FE687C /* Debug configuration for PBXNativeTarget "FinderSyncExt" */ = { isa = XCBuildConfiguration; buildSettings = { @@ -876,14 +760,6 @@ ); defaultConfigurationName = Release; }; - 5C8A29032F83D65B00FE687C /* Build configuration list for PBXNativeTarget "RClickUITests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 5C8A29042F83D65B00FE687C /* Debug configuration for PBXNativeTarget "RClickUITests" */, - 5C8A29052F83D65B00FE687C /* Release configuration for PBXNativeTarget "RClickUITests" */, - ); - defaultConfigurationName = Release; - }; 5C8A29242F83D74A00FE687C /* Build configuration list for PBXNativeTarget "FinderSyncExt" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/RClick.xcodeproj/xcshareddata/xcschemes/RClick.xcscheme b/RClick.xcodeproj/xcshareddata/xcschemes/RClick.xcscheme index 289a7ec..5594aaf 100644 --- a/RClick.xcodeproj/xcshareddata/xcschemes/RClick.xcscheme +++ b/RClick.xcodeproj/xcshareddata/xcschemes/RClick.xcscheme @@ -50,17 +50,6 @@ ReferencedContainer = "container:RClick.xcodeproj"> - - - - + ├─ ditto new.app → old.app + ├─ 成功: 删备份, 删临时目录 + ├─ 失败: 恢复备份 + └─ open old.app 重新启动 +``` + +### 2.2 当前状态 vs 改进目标 + +文件:`RClick/Shared/Updater.swift` (495行), `RClick/Shared/UpdaterView.swift` (123行) + +| 问题 | 当前实现 | 改进后 | +|------|---------|--------| +| 状态管理 | 分散 @Published bool (`isChecking`, `isDownloading`, `availableUpdate`, `updateError`) | 单一 `UpdateState` 枚举状态机 | +| 下载进度 | 无 | URLSessionDownloadDelegate 报告百分比 | +| 解压工具 | `/usr/bin/unzip -o` | `/usr/bin/ditto -x -k`(更好保留元数据) | +| SHA-256 | 无 | CryptoKit 校验,缺文件时安全降级 | +| 备份回滚 | 无 | mv 备份,失败自动恢复 | +| 隔离属性 | 无 | `xattr -dr com.apple.quarantine` | +| 残留清理 | 无 | 每次更新前清理 `rclick-update-*` | +| 安装方式 | NSOpenPanel 让用户选文件夹 | isWritableFile 检查 + 分离脚本 | +| 进程交换 | 运行时直接复制(不可靠) | 分离 bash 脚本等待退出后交换 | +| 安装阶段 | 无区分 | InstallPhase 枚举(verifying, unzipping, installing) | + +### 2.3 状态机设计 + +```swift +enum UpdateState: Equatable { + case idle + case checking + case upToDate + case available(version: String) + case downloading(version: String, fraction: Double) + case installing(version: String, phase: InstallPhase) + case failed + case installFailed(version: String) +} + +enum InstallPhase: Equatable { + case verifying // SHA-256 校验 + case unzipping // ditto 解压 + case installing // 交换脚本 +} +``` + +`UpdateManager` 用单一 `@Published var state: UpdateState` 替代多个分散的 bool。 + +### 2.4 App Sandbox 适配 + +RClick 启用了 App Sandbox,`Process()` 启动的子进程继承沙盒限制: +- 分离脚本尽力而为(`try? task.run()`) +- 沙盒下 `/Applications` 写入可能失败,回退到手动安装提示 +- 开发版本通过 `temporary-exception` 可获得写入权限 + +--- + +## 3. 实施步骤 + +### 阶段 1: CI/CD(无代码风险,优先) + +| # | 任务 | 产出 | +|---|------|------| +| 1.0 | **删除 UI 测试**:删除 `RClickUITests/` 目录,从 Xcode project 中移除 target 引用 | 清理无用代码 | +| 1.1 | 创建 `.github/workflows/pr-build.yml` | PR 构建测试 | +| 1.2 | 创建 `.github/workflows/release.yml` | Tag 自动发布 | +| 1.3 | 配置 GitHub Secrets(6个) | 仓库设置 | +| 1.4 | 测试 PR 工作流 | 验证通过 | +| 1.5 | 推送测试 tag 验证 Release 流程 | 验证通过 | + +### 阶段 2: 自动更新器改进 + +| # | 任务 | 文件 | 风险 | +|---|------|------|------| +| 2.1 | 添加 UpdateState / InstallPhase 枚举 | `Updater.swift` | 低 | +| 2.2 | UpdateManager 迁移到状态机 | `Updater.swift` | 中 | +| 2.3 | 实现 UpdateDownloader (URLSessionDownloadDelegate) | `Updater.swift` | 中 | +| 2.4 | unzip → ditto 替换 | `Updater.swift` | 低 | +| 2.5 | SHA-256 校验逻辑 | `Updater.swift` | 低 | +| 2.6 | 备份/回滚机制 | `Updater.swift` | 中 | +| 2.7 | quarantine 移除 + cleanStaleArtifacts | `Updater.swift` | 低 | +| 2.8 | spawnSwapHelper 分离脚本 | `Updater.swift` | 高 | +| 2.9 | UpdateView 状态机 UI 适配 | `UpdaterView.swift` | 中 | +| 2.10 | AboutSettingsTabView 适配新 API | `AboutSettingsTabView.swift` | 低 | + +### 阶段 3: 集成测试 + +- 本地构建 Release,验证完整更新流程 +- 模拟失败场景(下载中断、校验失败、权限不足) +- 验证回滚机制 +- 端到端:旧版本 → 检查 → 下载 → 安装 → 重启 → 新版本 + +--- + +## 4. 验证方案 + +### CI/CD +```bash +# PR 工作流:创建测试 PR 到 dev,确认触发并通过 +# Release 工作流:推送测试 tag +git tag v2.0.2-test && git push origin v2.0.2-test +# 观察 Actions → 确认构建/签名/公证/Release 全流程 +# 清理:gh release delete v2.0.2-test --yes +``` + +### 自动更新器检查清单 +- [ ] 状态转换:idle → checking → available → downloading(0..1) → installing(verifying→unzipping→installing) +- [ ] 下载进度 0%→100%,每 1% 更新 +- [ ] SHA-256 错误 → installFailed +- [ ] 解压失败 → installFailed +- [ ] 用户忽略版本 → 不再提示;强制检查 → 仍提示 +- [ ] 残留清理:多次更新后 temp 目录无累积 +- [ ] 回滚:交换脚本中断后旧 App 仍可用 +- [ ] 重启:新版本正常启动 + +--- + +## 5. 涉及的关键文件 + +| 文件 | 操作 | 说明 | +|------|------|------| +| `.github/workflows/pr-build.yml` | **新建** | PR 构建测试工作流 | +| `.github/workflows/release.yml` | **新建** | Tag 自动发布工作流 | +| `RClick/Shared/Updater.swift` | **修改** | 状态机、ditto、SHA-256、备份回滚、分离脚本 | +| `RClick/Shared/UpdaterView.swift` | **修改** | 状态驱动 UI(下载进度、安装阶段) | +| `RClick/Settings/AboutSettingsTabView.swift` | **小改** | 适配新 API | + +参考代码位置: +- iMonet CI: `/Users/lixu/code/iMonet/.github/workflows/release.yml` +- capcap 更新器: `/Users/lixu/repos/capcap-main/capcap/Utilities/UpdateChecker.swift` 和 `UpdateInstaller.swift`