its whats on the tin; culls raw photos
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add sparkle

+412 -2
+237
.github/workflows/release.yml
··· 1 + name: Release Build 2 + 3 + on: 4 + release: 5 + types: [created] 6 + workflow_dispatch: 7 + inputs: 8 + tag: 9 + description: 'Version tag (e.g., v1.0.0)' 10 + required: false 11 + 12 + env: 13 + VERSION: ${{ github.event.release.tag_name || github.event.inputs.tag || 'dev' }} 14 + SCHEME: cull 15 + PROJECT: cull/cull.xcodeproj 16 + 17 + jobs: 18 + build-macos: 19 + runs-on: macos-26 20 + permissions: 21 + contents: write 22 + outputs: 23 + version: ${{ steps.version.outputs.version }} 24 + dmg_name: ${{ steps.package.outputs.dmg_name }} 25 + dmg_size: ${{ steps.sparkle_sign.outputs.dmg_size }} 26 + sparkle_signature: ${{ steps.sparkle_sign.outputs.signature }} 27 + steps: 28 + - uses: actions/checkout@v5 29 + 30 + - name: Get version 31 + id: version 32 + run: | 33 + VERSION="${{ env.VERSION }}" 34 + VERSION="${VERSION#v}" 35 + echo "version=$VERSION" >> $GITHUB_OUTPUT 36 + echo "Building version: $VERSION" 37 + 38 + - name: Select Xcode 39 + run: sudo xcode-select -s /Applications/Xcode.app 40 + 41 + - name: Import Code Signing Certificate 42 + env: 43 + APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} 44 + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} 45 + run: | 46 + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db 47 + KEYCHAIN_PASSWORD=$(openssl rand -base64 32) 48 + 49 + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" 50 + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" 51 + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" 52 + 53 + CERTIFICATE_PATH=$RUNNER_TEMP/certificate.p12 54 + echo "$APPLE_CERTIFICATE_BASE64" | base64 --decode > "$CERTIFICATE_PATH" 55 + 56 + security import "$CERTIFICATE_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" 57 + security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" 58 + security list-keychain -d user -s "$KEYCHAIN_PATH" 59 + 60 + - name: Resolve SPM Dependencies 61 + run: | 62 + xcodebuild -resolvePackageDependencies \ 63 + -project "$PROJECT" \ 64 + -scheme "$SCHEME" 65 + 66 + - name: Build and Archive 67 + env: 68 + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} 69 + run: | 70 + xcodebuild archive \ 71 + -project "$PROJECT" \ 72 + -scheme "$SCHEME" \ 73 + -configuration Release \ 74 + -archivePath "$PWD/build/cull.xcarchive" \ 75 + DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \ 76 + CODE_SIGN_IDENTITY="Developer ID Application" \ 77 + CODE_SIGN_STYLE=Manual \ 78 + MARKETING_VERSION="${{ steps.version.outputs.version }}" \ 79 + CURRENT_PROJECT_VERSION="${{ steps.version.outputs.version }}" 80 + 81 + - name: Export Archive 82 + run: | 83 + mkdir -p build 84 + cat > build/ExportOptions.plist << 'PLIST' 85 + <?xml version="1.0" encoding="UTF-8"?> 86 + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 87 + <plist version="1.0"> 88 + <dict> 89 + <key>method</key> 90 + <string>developer-id</string> 91 + </dict> 92 + </plist> 93 + PLIST 94 + 95 + xcodebuild -exportArchive \ 96 + -archivePath build/cull.xcarchive \ 97 + -exportOptionsPlist build/ExportOptions.plist \ 98 + -exportPath build/export 99 + 100 + - name: Sign Sparkle Framework 101 + env: 102 + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} 103 + run: | 104 + APP_PATH=$(find build/export -name "*.app" -type d | head -1) 105 + SIGN_ID="Developer ID Application: Single Feather LLC ($APPLE_TEAM_ID)" 106 + 107 + SPARKLE_PATH="$APP_PATH/Contents/Frameworks/Sparkle.framework" 108 + if [ -d "$SPARKLE_PATH" ]; then 109 + # Sign Sparkle inside-out 110 + codesign -f -s "$SIGN_ID" -o runtime --timestamp \ 111 + "$SPARKLE_PATH/Versions/B/XPCServices/Installer.xpc" 2>/dev/null || true 112 + codesign -f -s "$SIGN_ID" -o runtime --timestamp --preserve-metadata=entitlements \ 113 + "$SPARKLE_PATH/Versions/B/XPCServices/Downloader.xpc" 2>/dev/null || true 114 + codesign -f -s "$SIGN_ID" -o runtime --timestamp \ 115 + "$SPARKLE_PATH/Versions/B/Updater.app" 2>/dev/null || true 116 + codesign -f -s "$SIGN_ID" -o runtime --timestamp \ 117 + "$SPARKLE_PATH/Versions/B/Autoupdate" 2>/dev/null || true 118 + codesign -f -s "$SIGN_ID" -o runtime --timestamp \ 119 + "$SPARKLE_PATH" 120 + fi 121 + 122 + # Re-sign the main app after framework signing 123 + codesign -f -s "$SIGN_ID" -o runtime --timestamp "$APP_PATH" 124 + codesign -dv --verbose=2 "$APP_PATH" 125 + 126 + - name: Create DMG 127 + id: package 128 + run: | 129 + APP_PATH=$(find build/export -name "*.app" -type d | head -1) 130 + DMG_NAME="Cull-${{ steps.version.outputs.version }}-macOS.dmg" 131 + 132 + DMG_STAGING=$(mktemp -d) 133 + cp -a "$APP_PATH" "$DMG_STAGING/" 134 + ln -s /Applications "$DMG_STAGING/Applications" 135 + 136 + hdiutil create \ 137 + -volname "Cull" \ 138 + -srcfolder "$DMG_STAGING" \ 139 + -ov -format UDZO \ 140 + "build/$DMG_NAME" 141 + 142 + rm -rf "$DMG_STAGING" 143 + echo "dmg_name=$DMG_NAME" >> $GITHUB_OUTPUT 144 + 145 + - name: Sign DMG 146 + env: 147 + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} 148 + run: | 149 + DMG_PATH=$(find build -name "*.dmg" -type f | head -1) 150 + codesign --force \ 151 + --sign "Developer ID Application: Single Feather LLC ($APPLE_TEAM_ID)" \ 152 + --timestamp \ 153 + "$DMG_PATH" 154 + 155 + - name: Notarize DMG 156 + env: 157 + APPLE_ID: ${{ secrets.APPLE_ID }} 158 + APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} 159 + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} 160 + run: | 161 + DMG_PATH=$(find build -name "*.dmg" -type f | head -1) 162 + echo "Notarizing $DMG_PATH" 163 + 164 + xcrun notarytool submit "$DMG_PATH" \ 165 + --apple-id "$APPLE_ID" \ 166 + --password "$APPLE_ID_PASSWORD" \ 167 + --team-id "$APPLE_TEAM_ID" \ 168 + --wait \ 169 + --output-format json | tee notarization_result.json 170 + 171 + SUBMISSION_ID=$(cat notarization_result.json | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])" 2>/dev/null || true) 172 + if [ -n "$SUBMISSION_ID" ]; then 173 + xcrun notarytool log "$SUBMISSION_ID" \ 174 + --apple-id "$APPLE_ID" \ 175 + --password "$APPLE_ID_PASSWORD" \ 176 + --team-id "$APPLE_TEAM_ID" || true 177 + fi 178 + 179 + xcrun stapler staple "$DMG_PATH" 180 + echo "Notarization complete!" 181 + 182 + - name: Sign for Sparkle 183 + id: sparkle_sign 184 + env: 185 + SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} 186 + run: | 187 + DMG_PATH=$(find build -name "*.dmg" -type f | head -1) 188 + 189 + DMG_SIZE=$(stat -f%z "$DMG_PATH") 190 + echo "dmg_size=$DMG_SIZE" >> $GITHUB_OUTPUT 191 + 192 + # Download Sparkle tools 193 + SPARKLE_VERSION="2.6.4" 194 + curl -L "https://github.com/sparkle-project/Sparkle/releases/download/${SPARKLE_VERSION}/Sparkle-${SPARKLE_VERSION}.tar.xz" | tar -xJ 195 + 196 + SIGN_OUTPUT=$(echo -n "$SPARKLE_ED_PRIVATE_KEY" | ./bin/sign_update "$DMG_PATH" -f -) 197 + SIGNATURE=$(echo "$SIGN_OUTPUT" | sed -n 's/.*sparkle:edSignature="\([^"]*\)".*/\1/p') 198 + echo "signature=$SIGNATURE" >> $GITHUB_OUTPUT 199 + 200 + - name: Upload DMG to Release 201 + if: github.event_name == 'release' 202 + env: 203 + GH_TOKEN: ${{ github.token }} 204 + run: | 205 + DMG_PATH=$(find build -name "*.dmg" -type f | head -1) 206 + gh release upload "${{ env.VERSION }}" "$DMG_PATH" --clobber 207 + 208 + update-appcast: 209 + needs: [build-macos] 210 + runs-on: ubuntu-latest 211 + if: github.event_name == 'release' 212 + permissions: 213 + contents: write 214 + steps: 215 + - uses: actions/checkout@v5 216 + with: 217 + ref: main 218 + 219 + - name: Update Sparkle appcast 220 + env: 221 + RELEASE_BODY: ${{ github.event.release.body }} 222 + run: | 223 + ./scripts/update-appcast.sh \ 224 + "${{ needs.build-macos.outputs.version }}" \ 225 + "${{ env.VERSION }}" \ 226 + "${{ needs.build-macos.outputs.dmg_size }}" \ 227 + "${{ needs.build-macos.outputs.sparkle_signature }}" \ 228 + "$RELEASE_BODY" \ 229 + "${{ github.repository }}" 230 + 231 + - name: Commit and push appcast 232 + run: | 233 + git config user.name "github-actions[bot]" 234 + git config user.email "github-actions[bot]@users.noreply.github.com" 235 + git add docs/appcast.xml 236 + git commit -m "Update appcast for ${{ env.VERSION }}" || exit 0 237 + git push
+32
cull/CullApp.swift
··· 1 1 import SwiftUI 2 + import Sparkle 2 3 3 4 @main 4 5 struct CullApp: App { 6 + private let updaterController = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil) 5 7 @State private var session = CullSession() 6 8 @State private var thumbnailCache = ThumbnailCache() 7 9 @AppStorage("recentFolders") private var recentFoldersData: Data = Data() ··· 169 171 } 170 172 } 171 173 174 + .commands { 175 + CommandGroup(after: .appInfo) { 176 + CheckForUpdatesView(updater: updaterController.updater) 177 + } 178 + } 179 + 172 180 Settings { 173 181 SettingsView() 174 182 } ··· 183 191 184 192 guard panel.runModal() == .OK, let url = panel.url else { return } 185 193 NotificationCenter.default.post(name: .openFolder, object: url) 194 + } 195 + } 196 + 197 + struct CheckForUpdatesView: View { 198 + @ObservedObject private var checkForUpdatesViewModel: CheckForUpdatesViewModel 199 + 200 + init(updater: SPUUpdater) { 201 + self.checkForUpdatesViewModel = CheckForUpdatesViewModel(updater: updater) 202 + } 203 + 204 + var body: some View { 205 + Button("Check for Updates…", action: checkForUpdatesViewModel.updater.checkForUpdates) 206 + .disabled(!checkForUpdatesViewModel.canCheckForUpdates) 207 + } 208 + } 209 + 210 + final class CheckForUpdatesViewModel: ObservableObject { 211 + @Published var canCheckForUpdates = false 212 + let updater: SPUUpdater 213 + 214 + init(updater: SPUUpdater) { 215 + self.updater = updater 216 + updater.publisher(for: \.canCheckForUpdates) 217 + .assign(to: &$canCheckForUpdates) 186 218 } 187 219 } 188 220
+12
cull/Info.plist
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 3 + <plist version="1.0"> 4 + <dict> 5 + <key>SUEnableAutomaticChecks</key> 6 + <true/> 7 + <key>SUFeedURL</key> 8 + <string>https://taciturnaxolotl.github.io/cull/appcast.xml</string> 9 + <key>SUPublicEDKey</key> 10 + <string>5bTd4f9953ucOnAPvXfGMzGHRk1yQaURiKlfVfmfuNs=</string> 11 + </dict> 12 + </plist>
+29 -2
cull/cull.xcodeproj/project.pbxproj
··· 7 7 objects = { 8 8 9 9 /* Begin PBXBuildFile section */ 10 + BB000001SPARKLE00000001 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = BB000002SPARKLE00000002 /* Sparkle */; }; 10 11 0B0EC2722F72210B004523FA /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0B0EC2712F72210B004523FA /* Assets.xcassets */; }; 11 12 0B0EC28A2F722491004523FA /* ImportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B0EC2872F722491004523FA /* ImportView.swift */; }; 12 13 0B0EC28B2F722491004523FA /* ExportSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B0EC2842F722491004523FA /* ExportSheet.swift */; }; ··· 58 59 isa = PBXFrameworksBuildPhase; 59 60 buildActionMask = 2147483647; 60 61 files = ( 62 + BB000001SPARKLE00000001 /* Sparkle in Frameworks */, 61 63 ); 62 64 runOnlyForDeploymentPostprocessing = 0; 63 65 }; ··· 132 134 ); 133 135 name = cull; 134 136 packageProductDependencies = ( 137 + BB000002SPARKLE00000002 /* Sparkle */, 135 138 ); 136 139 productName = cull; 137 140 productReference = 0B0EC2992F724FE5004523FA /* cull.app */; ··· 161 164 ); 162 165 mainGroup = 0B0EC2612F722109004523FA; 163 166 minimizedProjectReferenceProxies = 1; 167 + packageReferences = ( 168 + BB000003SPARKLE00000003 /* XCRemoteSwiftPackageReference "Sparkle" */, 169 + ); 164 170 preferredProjectObjectVersion = 77; 165 171 productRefGroup = 0B0EC2612F722109004523FA; 166 172 projectDirPath = ""; ··· 344 350 ENABLE_APP_SANDBOX = YES; 345 351 ENABLE_HARDENED_RUNTIME = YES; 346 352 ENABLE_INCOMING_NETWORK_CONNECTIONS = NO; 347 - ENABLE_OUTGOING_NETWORK_CONNECTIONS = NO; 353 + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; 348 354 ENABLE_PREVIEWS = YES; 349 355 ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; 350 356 ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; ··· 356 362 ENABLE_RESOURCE_ACCESS_USB = NO; 357 363 ENABLE_USER_SELECTED_FILES = readwrite; 358 364 GENERATE_INFOPLIST_FILE = YES; 365 + INFOPLIST_FILE = cull/Info.plist; 359 366 INFOPLIST_KEY_CFBundleDisplayName = Cull; 360 367 INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.photography"; 361 368 INFOPLIST_KEY_NSHumanReadableCopyright = ""; ··· 388 395 ENABLE_APP_SANDBOX = YES; 389 396 ENABLE_HARDENED_RUNTIME = YES; 390 397 ENABLE_INCOMING_NETWORK_CONNECTIONS = NO; 391 - ENABLE_OUTGOING_NETWORK_CONNECTIONS = NO; 398 + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; 392 399 ENABLE_PREVIEWS = YES; 393 400 ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; 394 401 ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; ··· 400 407 ENABLE_RESOURCE_ACCESS_USB = NO; 401 408 ENABLE_USER_SELECTED_FILES = readwrite; 402 409 GENERATE_INFOPLIST_FILE = YES; 410 + INFOPLIST_FILE = cull/Info.plist; 403 411 INFOPLIST_KEY_CFBundleDisplayName = Cull; 404 412 INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.photography"; 405 413 INFOPLIST_KEY_NSHumanReadableCopyright = ""; ··· 442 450 defaultConfigurationName = Release; 443 451 }; 444 452 /* End XCConfigurationList section */ 453 + 454 + /* Begin XCRemoteSwiftPackageReference section */ 455 + BB000003SPARKLE00000003 /* XCRemoteSwiftPackageReference "Sparkle" */ = { 456 + isa = XCRemoteSwiftPackageReference; 457 + repositoryURL = "https://github.com/sparkle-project/Sparkle"; 458 + requirement = { 459 + kind = upToNextMajorVersion; 460 + minimumVersion = 2.6.4; 461 + }; 462 + }; 463 + /* End XCRemoteSwiftPackageReference section */ 464 + 465 + /* Begin XCSwiftPackageProductDependency section */ 466 + BB000002SPARKLE00000002 /* Sparkle */ = { 467 + isa = XCSwiftPackageProductDependency; 468 + package = BB000003SPARKLE00000003 /* XCRemoteSwiftPackageReference "Sparkle" */; 469 + productName = Sparkle; 470 + }; 471 + /* End XCSwiftPackageProductDependency section */ 445 472 }; 446 473 rootObject = 0B0EC2622F722109004523FA /* Project object */; 447 474 }
+9
docs/appcast.xml
··· 1 + <?xml version="1.0" encoding="utf-8"?> 2 + <rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" xmlns:dc="http://purl.org/dc/elements/1.1/"> 3 + <channel> 4 + <title>Cull Updates</title> 5 + <link>https://taciturnaxolotl.github.io/cull/appcast.xml</link> 6 + <description>Most recent updates to Cull</description> 7 + <language>en</language> 8 + </channel> 9 + </rss>
+93
scripts/update-appcast.sh
··· 1 + #!/bin/bash 2 + # Update the Sparkle appcast.xml with a new release 3 + # Usage: ./update-appcast.sh VERSION TAG DMG_SIZE SPARKLE_SIGNATURE RELEASE_NOTES REPO 4 + 5 + set -e 6 + 7 + VERSION="$1" 8 + TAG="$2" 9 + DMG_SIZE="$3" 10 + SPARKLE_SIGNATURE="$4" 11 + RELEASE_NOTES="$5" 12 + REPO="$6" 13 + 14 + PUBDATE=$(date -R) 15 + DMG_URL="https://github.com/${REPO}/releases/download/${TAG}/Cull-${VERSION}-macOS.dmg" 16 + 17 + # Convert markdown release notes to HTML 18 + RELEASE_NOTES_HTML=$(echo "$RELEASE_NOTES" | python3 -c " 19 + import sys, re, html 20 + md = sys.stdin.read().strip() 21 + lines = [] 22 + in_list = False 23 + for line in md.split('\n'): 24 + stripped = line.strip() 25 + if stripped.startswith('### '): 26 + if in_list: lines.append('</ul>'); in_list = False 27 + lines.append(f'<h3>{html.escape(stripped[4:])}</h3>') 28 + elif stripped.startswith('## '): 29 + if in_list: lines.append('</ul>'); in_list = False 30 + lines.append(f'<h2>{html.escape(stripped[3:])}</h2>') 31 + elif stripped.startswith('- '): 32 + if not in_list: lines.append('<ul>'); in_list = True 33 + content = stripped[2:] 34 + content = re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', content) 35 + content = re.sub(r'\x60(.+?)\x60', r'<code>\1</code>', content) 36 + lines.append(f' <li>{content}</li>') 37 + elif stripped == '': 38 + if in_list: lines.append('</ul>'); in_list = False 39 + else: 40 + if in_list: lines.append('</ul>'); in_list = False 41 + content = re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', stripped) 42 + lines.append(f'<p>{content}</p>') 43 + if in_list: lines.append('</ul>') 44 + print('\n'.join(lines)) 45 + ") 46 + 47 + NEW_ITEM=" <item> 48 + <title>Version ${VERSION}</title> 49 + <pubDate>${PUBDATE}</pubDate> 50 + <sparkle:version>${VERSION}</sparkle:version> 51 + <sparkle:shortVersionString>${VERSION}</sparkle:shortVersionString> 52 + <sparkle:minimumSystemVersion>14.0</sparkle:minimumSystemVersion> 53 + <description><![CDATA[${RELEASE_NOTES_HTML}]]></description> 54 + <enclosure 55 + url=\"${DMG_URL}\" 56 + sparkle:edSignature=\"${SPARKLE_SIGNATURE}\" 57 + length=\"${DMG_SIZE}\" 58 + type=\"application/octet-stream\" 59 + sparkle:os=\"macos\"/> 60 + </item>" 61 + 62 + APPCAST_FILE="docs/appcast.xml" 63 + 64 + if [ -f "$APPCAST_FILE" ]; then 65 + # Insert the new item after <language>en</language>, before existing items 66 + python3 << PYEOF 67 + appcast = open("$APPCAST_FILE").read() 68 + marker = "<language>en</language>" 69 + idx = appcast.find(marker) 70 + if idx == -1: 71 + raise SystemExit("Error: could not find <language> tag in appcast.xml") 72 + end = idx + len(marker) 73 + new_item = """ 74 + $NEW_ITEM""" 75 + result = appcast[:end] + new_item + appcast[end:] 76 + open("$APPCAST_FILE", "w").write(result) 77 + PYEOF 78 + else 79 + cat > "$APPCAST_FILE" << APPCAST_EOF 80 + <?xml version="1.0" encoding="utf-8"?> 81 + <rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" xmlns:dc="http://purl.org/dc/elements/1.1/"> 82 + <channel> 83 + <title>Cull Updates</title> 84 + <link>https://taciturnaxolotl.github.io/cull/appcast.xml</link> 85 + <description>Most recent updates to Cull</description> 86 + <language>en</language> 87 + ${NEW_ITEM} 88 + </channel> 89 + </rss> 90 + APPCAST_EOF 91 + fi 92 + 93 + echo "Appcast updated for version ${VERSION}"