diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a01d73a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,237 @@ +name: Release Build + +on: + release: + types: [created] + workflow_dispatch: + inputs: + tag: + description: 'Version tag (e.g., v1.0.0)' + required: false + +env: + VERSION: ${{ github.event.release.tag_name || github.event.inputs.tag || 'dev' }} + SCHEME: cull + PROJECT: cull/cull.xcodeproj + +jobs: + build-macos: + runs-on: macos-26 + permissions: + contents: write + outputs: + version: ${{ steps.version.outputs.version }} + dmg_name: ${{ steps.package.outputs.dmg_name }} + dmg_size: ${{ steps.sparkle_sign.outputs.dmg_size }} + sparkle_signature: ${{ steps.sparkle_sign.outputs.signature }} + steps: + - uses: actions/checkout@v5 + + - name: Get version + id: version + run: | + VERSION="${{ env.VERSION }}" + VERSION="${VERSION#v}" + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Building version: $VERSION" + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode.app + + - name: Import Code Signing Certificate + env: + APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + run: | + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + KEYCHAIN_PASSWORD=$(openssl rand -base64 32) + + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + CERTIFICATE_PATH=$RUNNER_TEMP/certificate.p12 + echo "$APPLE_CERTIFICATE_BASE64" | base64 --decode > "$CERTIFICATE_PATH" + + security import "$CERTIFICATE_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" + security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security list-keychain -d user -s "$KEYCHAIN_PATH" + + - name: Resolve SPM Dependencies + run: | + xcodebuild -resolvePackageDependencies \ + -project "$PROJECT" \ + -scheme "$SCHEME" + + - name: Build and Archive + env: + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + xcodebuild archive \ + -project "$PROJECT" \ + -scheme "$SCHEME" \ + -configuration Release \ + -archivePath "$PWD/build/cull.xcarchive" \ + DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \ + CODE_SIGN_IDENTITY="Developer ID Application" \ + CODE_SIGN_STYLE=Manual \ + MARKETING_VERSION="${{ steps.version.outputs.version }}" \ + CURRENT_PROJECT_VERSION="${{ steps.version.outputs.version }}" + + - name: Export Archive + run: | + mkdir -p build + cat > build/ExportOptions.plist << 'PLIST' + + + + + method + developer-id + + + PLIST + + xcodebuild -exportArchive \ + -archivePath build/cull.xcarchive \ + -exportOptionsPlist build/ExportOptions.plist \ + -exportPath build/export + + - name: Sign Sparkle Framework + env: + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + APP_PATH=$(find build/export -name "*.app" -type d | head -1) + SIGN_ID="Developer ID Application: Single Feather LLC ($APPLE_TEAM_ID)" + + SPARKLE_PATH="$APP_PATH/Contents/Frameworks/Sparkle.framework" + if [ -d "$SPARKLE_PATH" ]; then + # Sign Sparkle inside-out + codesign -f -s "$SIGN_ID" -o runtime --timestamp \ + "$SPARKLE_PATH/Versions/B/XPCServices/Installer.xpc" 2>/dev/null || true + codesign -f -s "$SIGN_ID" -o runtime --timestamp --preserve-metadata=entitlements \ + "$SPARKLE_PATH/Versions/B/XPCServices/Downloader.xpc" 2>/dev/null || true + codesign -f -s "$SIGN_ID" -o runtime --timestamp \ + "$SPARKLE_PATH/Versions/B/Updater.app" 2>/dev/null || true + codesign -f -s "$SIGN_ID" -o runtime --timestamp \ + "$SPARKLE_PATH/Versions/B/Autoupdate" 2>/dev/null || true + codesign -f -s "$SIGN_ID" -o runtime --timestamp \ + "$SPARKLE_PATH" + fi + + # Re-sign the main app after framework signing + codesign -f -s "$SIGN_ID" -o runtime --timestamp "$APP_PATH" + codesign -dv --verbose=2 "$APP_PATH" + + - name: Create DMG + id: package + run: | + APP_PATH=$(find build/export -name "*.app" -type d | head -1) + DMG_NAME="Cull-${{ steps.version.outputs.version }}-macOS.dmg" + + DMG_STAGING=$(mktemp -d) + cp -a "$APP_PATH" "$DMG_STAGING/" + ln -s /Applications "$DMG_STAGING/Applications" + + hdiutil create \ + -volname "Cull" \ + -srcfolder "$DMG_STAGING" \ + -ov -format UDZO \ + "build/$DMG_NAME" + + rm -rf "$DMG_STAGING" + echo "dmg_name=$DMG_NAME" >> $GITHUB_OUTPUT + + - name: Sign DMG + env: + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + DMG_PATH=$(find build -name "*.dmg" -type f | head -1) + codesign --force \ + --sign "Developer ID Application: Single Feather LLC ($APPLE_TEAM_ID)" \ + --timestamp \ + "$DMG_PATH" + + - name: Notarize DMG + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + DMG_PATH=$(find build -name "*.dmg" -type f | head -1) + echo "Notarizing $DMG_PATH" + + xcrun notarytool submit "$DMG_PATH" \ + --apple-id "$APPLE_ID" \ + --password "$APPLE_ID_PASSWORD" \ + --team-id "$APPLE_TEAM_ID" \ + --wait \ + --output-format json | tee notarization_result.json + + SUBMISSION_ID=$(cat notarization_result.json | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])" 2>/dev/null || true) + if [ -n "$SUBMISSION_ID" ]; then + xcrun notarytool log "$SUBMISSION_ID" \ + --apple-id "$APPLE_ID" \ + --password "$APPLE_ID_PASSWORD" \ + --team-id "$APPLE_TEAM_ID" || true + fi + + xcrun stapler staple "$DMG_PATH" + echo "Notarization complete!" + + - name: Sign for Sparkle + id: sparkle_sign + env: + SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} + run: | + DMG_PATH=$(find build -name "*.dmg" -type f | head -1) + + DMG_SIZE=$(stat -f%z "$DMG_PATH") + echo "dmg_size=$DMG_SIZE" >> $GITHUB_OUTPUT + + # Download Sparkle tools + SPARKLE_VERSION="2.6.4" + curl -L "https://github.com/sparkle-project/Sparkle/releases/download/${SPARKLE_VERSION}/Sparkle-${SPARKLE_VERSION}.tar.xz" | tar -xJ + + SIGN_OUTPUT=$(echo -n "$SPARKLE_ED_PRIVATE_KEY" | ./bin/sign_update "$DMG_PATH" -f -) + SIGNATURE=$(echo "$SIGN_OUTPUT" | sed -n 's/.*sparkle:edSignature="\([^"]*\)".*/\1/p') + echo "signature=$SIGNATURE" >> $GITHUB_OUTPUT + + - name: Upload DMG to Release + if: github.event_name == 'release' + env: + GH_TOKEN: ${{ github.token }} + run: | + DMG_PATH=$(find build -name "*.dmg" -type f | head -1) + gh release upload "${{ env.VERSION }}" "$DMG_PATH" --clobber + + update-appcast: + needs: [build-macos] + runs-on: ubuntu-latest + if: github.event_name == 'release' + permissions: + contents: write + steps: + - uses: actions/checkout@v5 + with: + ref: main + + - name: Update Sparkle appcast + env: + RELEASE_BODY: ${{ github.event.release.body }} + run: | + ./scripts/update-appcast.sh \ + "${{ needs.build-macos.outputs.version }}" \ + "${{ env.VERSION }}" \ + "${{ needs.build-macos.outputs.dmg_size }}" \ + "${{ needs.build-macos.outputs.sparkle_signature }}" \ + "$RELEASE_BODY" \ + "${{ github.repository }}" + + - name: Commit and push appcast + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add docs/appcast.xml + git commit -m "Update appcast for ${{ env.VERSION }}" || exit 0 + git push diff --git a/cull/CullApp.swift b/cull/CullApp.swift index b4a6c24..f1a4aba 100644 --- a/cull/CullApp.swift +++ b/cull/CullApp.swift @@ -1,7 +1,9 @@ import SwiftUI +import Sparkle @main struct CullApp: App { + private let updaterController = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil) @State private var session = CullSession() @State private var thumbnailCache = ThumbnailCache() @AppStorage("recentFolders") private var recentFoldersData: Data = Data() @@ -169,6 +171,12 @@ struct CullApp: App { } } + .commands { + CommandGroup(after: .appInfo) { + CheckForUpdatesView(updater: updaterController.updater) + } + } + Settings { SettingsView() } @@ -186,6 +194,30 @@ struct CullApp: App { } } +struct CheckForUpdatesView: View { + @ObservedObject private var checkForUpdatesViewModel: CheckForUpdatesViewModel + + init(updater: SPUUpdater) { + self.checkForUpdatesViewModel = CheckForUpdatesViewModel(updater: updater) + } + + var body: some View { + Button("Check for Updates…", action: checkForUpdatesViewModel.updater.checkForUpdates) + .disabled(!checkForUpdatesViewModel.canCheckForUpdates) + } +} + +final class CheckForUpdatesViewModel: ObservableObject { + @Published var canCheckForUpdates = false + let updater: SPUUpdater + + init(updater: SPUUpdater) { + self.updater = updater + updater.publisher(for: \.canCheckForUpdates) + .assign(to: &$canCheckForUpdates) + } +} + extension Notification.Name { static let openFolder = Notification.Name("openFolder") static let showExport = Notification.Name("showExport") diff --git a/cull/Info.plist b/cull/Info.plist new file mode 100644 index 0000000..caaf232 --- /dev/null +++ b/cull/Info.plist @@ -0,0 +1,12 @@ + + + + + SUEnableAutomaticChecks + + SUFeedURL + https://taciturnaxolotl.github.io/cull/appcast.xml + SUPublicEDKey + 5bTd4f9953ucOnAPvXfGMzGHRk1yQaURiKlfVfmfuNs= + + diff --git a/cull/cull.xcodeproj/project.pbxproj b/cull/cull.xcodeproj/project.pbxproj index 596e467..dfc5b13 100644 --- a/cull/cull.xcodeproj/project.pbxproj +++ b/cull/cull.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + BB000001SPARKLE00000001 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = BB000002SPARKLE00000002 /* Sparkle */; }; 0B0EC2722F72210B004523FA /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0B0EC2712F72210B004523FA /* Assets.xcassets */; }; 0B0EC28A2F722491004523FA /* ImportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B0EC2872F722491004523FA /* ImportView.swift */; }; 0B0EC28B2F722491004523FA /* ExportSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B0EC2842F722491004523FA /* ExportSheet.swift */; }; @@ -58,6 +59,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + BB000001SPARKLE00000001 /* Sparkle in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -132,6 +134,7 @@ ); name = cull; packageProductDependencies = ( + BB000002SPARKLE00000002 /* Sparkle */, ); productName = cull; productReference = 0B0EC2992F724FE5004523FA /* cull.app */; @@ -161,6 +164,9 @@ ); mainGroup = 0B0EC2612F722109004523FA; minimizedProjectReferenceProxies = 1; + packageReferences = ( + BB000003SPARKLE00000003 /* XCRemoteSwiftPackageReference "Sparkle" */, + ); preferredProjectObjectVersion = 77; productRefGroup = 0B0EC2612F722109004523FA; projectDirPath = ""; @@ -344,7 +350,7 @@ ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_INCOMING_NETWORK_CONNECTIONS = NO; - ENABLE_OUTGOING_NETWORK_CONNECTIONS = NO; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_PREVIEWS = YES; ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; @@ -356,6 +362,7 @@ ENABLE_RESOURCE_ACCESS_USB = NO; ENABLE_USER_SELECTED_FILES = readwrite; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = cull/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Cull; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.photography"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; @@ -388,7 +395,7 @@ ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_INCOMING_NETWORK_CONNECTIONS = NO; - ENABLE_OUTGOING_NETWORK_CONNECTIONS = NO; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_PREVIEWS = YES; ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; @@ -400,6 +407,7 @@ ENABLE_RESOURCE_ACCESS_USB = NO; ENABLE_USER_SELECTED_FILES = readwrite; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = cull/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Cull; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.photography"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; @@ -442,6 +450,25 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + BB000003SPARKLE00000003 /* XCRemoteSwiftPackageReference "Sparkle" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/sparkle-project/Sparkle"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.6.4; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + BB000002SPARKLE00000002 /* Sparkle */ = { + isa = XCSwiftPackageProductDependency; + package = BB000003SPARKLE00000003 /* XCRemoteSwiftPackageReference "Sparkle" */; + productName = Sparkle; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 0B0EC2622F722109004523FA /* Project object */; } diff --git a/docs/appcast.xml b/docs/appcast.xml new file mode 100644 index 0000000..12608f6 --- /dev/null +++ b/docs/appcast.xml @@ -0,0 +1,9 @@ + + + + Cull Updates + https://taciturnaxolotl.github.io/cull/appcast.xml + Most recent updates to Cull + en + + diff --git a/scripts/update-appcast.sh b/scripts/update-appcast.sh new file mode 100755 index 0000000..73f9dce --- /dev/null +++ b/scripts/update-appcast.sh @@ -0,0 +1,93 @@ +#!/bin/bash +# Update the Sparkle appcast.xml with a new release +# Usage: ./update-appcast.sh VERSION TAG DMG_SIZE SPARKLE_SIGNATURE RELEASE_NOTES REPO + +set -e + +VERSION="$1" +TAG="$2" +DMG_SIZE="$3" +SPARKLE_SIGNATURE="$4" +RELEASE_NOTES="$5" +REPO="$6" + +PUBDATE=$(date -R) +DMG_URL="https://github.com/${REPO}/releases/download/${TAG}/Cull-${VERSION}-macOS.dmg" + +# Convert markdown release notes to HTML +RELEASE_NOTES_HTML=$(echo "$RELEASE_NOTES" | python3 -c " +import sys, re, html +md = sys.stdin.read().strip() +lines = [] +in_list = False +for line in md.split('\n'): + stripped = line.strip() + if stripped.startswith('### '): + if in_list: lines.append(''); in_list = False + lines.append(f'

{html.escape(stripped[4:])}

') + elif stripped.startswith('## '): + if in_list: lines.append(''); in_list = False + lines.append(f'

{html.escape(stripped[3:])}

') + elif stripped.startswith('- '): + if not in_list: lines.append(''); in_list = False + else: + if in_list: lines.append(''); in_list = False + content = re.sub(r'\*\*(.+?)\*\*', r'\1', stripped) + lines.append(f'

{content}

') +if in_list: lines.append('') +print('\n'.join(lines)) +") + +NEW_ITEM=" + Version ${VERSION} + ${PUBDATE} + ${VERSION} + ${VERSION} + 14.0 + + + " + +APPCAST_FILE="docs/appcast.xml" + +if [ -f "$APPCAST_FILE" ]; then + # Insert the new item after en, before existing items + python3 << PYEOF +appcast = open("$APPCAST_FILE").read() +marker = "en" +idx = appcast.find(marker) +if idx == -1: + raise SystemExit("Error: could not find tag in appcast.xml") +end = idx + len(marker) +new_item = """ +$NEW_ITEM""" +result = appcast[:end] + new_item + appcast[end:] +open("$APPCAST_FILE", "w").write(result) +PYEOF +else + cat > "$APPCAST_FILE" << APPCAST_EOF + + + + Cull Updates + https://taciturnaxolotl.github.io/cull/appcast.xml + Most recent updates to Cull + en +${NEW_ITEM} + + +APPCAST_EOF +fi + +echo "Appcast updated for version ${VERSION}"