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 = True
+ content = stripped[2:]
+ content = re.sub(r'\*\*(.+?)\*\*', r'\1', content)
+ content = re.sub(r'\x60(.+?)\x60', r'\1', content)
+ lines.append(f' - {content}
')
+ elif stripped == '':
+ if 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}"