Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 77 additions & 24 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ env:
APP_NAME: BetterCapture
SCHEME: BetterCapture
XCODE_VERSION: "26.0"
SPARKLE_VERSION: "2.7.3"

jobs:
build:
Expand All @@ -34,6 +35,14 @@ jobs:
- name: Show Xcode version
run: xcodebuild -version

- name: Setup Sparkle
run: |
mkdir -p "$RUNNER_TEMP/sparkle"
cd "$RUNNER_TEMP/sparkle"
curl -L "https://github.com/sparkle-project/Sparkle/releases/download/${SPARKLE_VERSION}/Sparkle-for-Swift-Package-Manager.zip" -o sparkle.zip
unzip -q sparkle.zip
echo "$RUNNER_TEMP/sparkle/bin" >> $GITHUB_PATH

- name: Import signing certificate
env:
APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
Expand Down Expand Up @@ -80,6 +89,12 @@ jobs:
agvtool new-marketing-version "$MARKETING_VERSION"
agvtool new-version -all 1

- name: Set Sparkle public key in Info.plist
env:
SPARKLE_PUBLIC_KEY: ${{ secrets.SPARKLE_PUBLIC_EDDSA_KEY }}
run: |
/usr/libexec/PlistBuddy -c "Set :SUPublicEDKey $SPARKLE_PUBLIC_KEY" "BetterCapture/Info.plist"

- name: Build application
run: |
set -o pipefail
Expand Down Expand Up @@ -170,45 +185,83 @@ jobs:

echo "DMG_PATH=$DMG_PATH" >> $GITHUB_ENV

- name: Sign DMG with Sparkle EdDSA
env:
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_EDDSA_KEY }}
run: |
echo "$SPARKLE_PRIVATE_KEY" > "$RUNNER_TEMP/sparkle_key"
sign_update "${{ env.DMG_PATH }}" -f "$RUNNER_TEMP/sparkle_key" > "$RUNNER_TEMP/sign_update.txt"
rm "$RUNNER_TEMP/sparkle_key"
echo "Sparkle signature:"
cat "$RUNNER_TEMP/sign_update.txt"

- name: Generate Sparkle appcast
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION=${{ steps.version.outputs.version }}
TAG="${{ github.event.release.tag_name }}"
DMG_FILENAME="${{ env.APP_NAME }}-${VERSION}-arm64.dmg"
RELEASE_URL="${{ github.event.release.html_url }}"

# The DMG URL uses the release tag for a permanent link
if [ -n "$TAG" ]; then
DMG_URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/${DMG_FILENAME}"
else
DMG_URL="https://github.com/${{ github.repository }}/releases/latest/download/${DMG_FILENAME}"
fi

# Fetch release notes body from the GitHub release
RELEASE_NOTES=""
if [ -n "$TAG" ]; then
RELEASE_NOTES=$(gh release view "$TAG" --json body --jq '.body' 2>/dev/null || echo "")
fi

# Try to download the appcast from the previous latest release
APPCAST_DIR="$RUNNER_TEMP/appcast"
mkdir -p "$APPCAST_DIR"
cp "$RUNNER_TEMP/sign_update.txt" "$APPCAST_DIR/sign_update.txt"

curl -fsSL \
"https://github.com/${{ github.repository }}/releases/latest/download/appcast.xml" \
-o "$APPCAST_DIR/appcast.xml" 2>/dev/null \
|| echo "No previous appcast found, will create a fresh one"

# Run the appcast generation script
cd "$APPCAST_DIR"
VERSION="$VERSION" \
DMG_URL="$DMG_URL" \
RELEASE_NOTES="$RELEASE_NOTES" \
RELEASE_URL="$RELEASE_URL" \
python3 "$GITHUB_WORKSPACE/dist/update_appcast.py"

# Move the generated appcast to a known location
mv appcast_new.xml "$RUNNER_TEMP/appcast.xml"

- name: Upload DMG artifact
uses: actions/upload-artifact@v4
with:
name: ${{ env.APP_NAME }}-${{ steps.version.outputs.version }}-arm64.dmg
path: ${{ env.DMG_PATH }}
if-no-files-found: error

- name: Upload to release
- name: Upload appcast artifact
uses: actions/upload-artifact@v4
with:
name: appcast
path: ${{ runner.temp }}/appcast.xml
if-no-files-found: error

- name: Upload assets to release
if: github.event_name == 'release'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release upload "${{ github.event.release.tag_name }}" \
"${{ env.DMG_PATH }}" \
"$RUNNER_TEMP/appcast.xml" \
--clobber

- name: Calculate SHA256 and update Homebrew cask
if: github.event_name == 'release'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Calculate SHA256
SHA256=$(shasum -a 256 "${{ env.DMG_PATH }}" | awk '{print $1}')
VERSION=${{ steps.version.outputs.version }}

echo "SHA256: $SHA256"
echo "Version: $VERSION"

# Update cask file
sed -i '' "s/version \".*\"/version \"$VERSION\"/" Casks/bettercapture.rb
sed -i '' "s/sha256 .*/sha256 \"$SHA256\"/" Casks/bettercapture.rb

# Commit and push the updated cask
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add Casks/bettercapture.rb
git commit -m "chore(brew): update cask to v$VERSION" || echo "No changes to commit"
git push origin HEAD:main

- name: Clean up keychain
if: always()
run: |
Expand Down
28 changes: 28 additions & 0 deletions BetterCapture.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
objectVersion = 77;
objects = {

/* Begin PBXBuildFile section */
6C5C123D2F3893FE0082CE23 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 6C0990E12F2BE0C200D48100 /* Sparkle */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
6C0990BD2F2BE0C200D48100 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
Expand Down Expand Up @@ -65,6 +69,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
6C5C123D2F3893FE0082CE23 /* Sparkle in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -125,6 +130,7 @@
);
name = BetterCapture;
packageProductDependencies = (
6C0990E12F2BE0C200D48100 /* Sparkle */,
);
productName = BetterCapture;
productReference = 6C0990AF2F2BE0C100D48100 /* BetterCapture.app */;
Expand Down Expand Up @@ -208,6 +214,9 @@
);
mainGroup = 6C0990A62F2BE0C100D48100;
minimizedProjectReferenceProxies = 1;
packageReferences = (
6C0990E02F2BE0C200D48100 /* XCRemoteSwiftPackageReference "Sparkle" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 6C0990B02F2BE0C100D48100 /* Products */;
projectDirPath = "";
Expand Down Expand Up @@ -598,6 +607,25 @@
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */

/* Begin XCRemoteSwiftPackageReference section */
6C0990E02F2BE0C200D48100 /* XCRemoteSwiftPackageReference "Sparkle" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/sparkle-project/Sparkle";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.7.0;
};
};
/* End XCRemoteSwiftPackageReference section */

/* Begin XCSwiftPackageProductDependency section */
6C0990E12F2BE0C200D48100 /* Sparkle */ = {
isa = XCSwiftPackageProductDependency;
package = 6C0990E02F2BE0C200D48100 /* XCRemoteSwiftPackageReference "Sparkle" */;
productName = Sparkle;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 6C0990A72F2BE0C100D48100 /* Project object */;
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions BetterCapture/BetterCapture.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,12 @@
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
<array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)-spks</string>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)-spki</string>
</array>
</dict>
</plist>
3 changes: 2 additions & 1 deletion BetterCapture/BetterCaptureApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import SwiftUI
@main
struct BetterCaptureApp: App {
@State private var viewModel = RecorderViewModel()
@State private var updaterService = UpdaterService()

var body: some Scene {
// Menu bar extra - the primary interface
Expand All @@ -27,7 +28,7 @@ struct BetterCaptureApp: App {

// Settings window
Settings {
SettingsView(settings: viewModel.settings)
SettingsView(settings: viewModel.settings, updaterService: updaterService)
}
}
}
Expand Down
6 changes: 6 additions & 0 deletions BetterCapture/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,11 @@
<string>BetterCapture needs access to your microphone to record audio alongside screen captures.</string>
<key>NSScreenCaptureUsageDescription</key>
<string>BetterCapture needs access to screen recording to capture your screen content.</string>
<key>SUFeedURL</key>
<string>https://github.com/jsattler/BetterCapture/releases/latest/download/appcast.xml</string>
<key>SUPublicEDKey</key>
<string>SPARKLE_PUBLIC_KEY</string>
<key>SUEnableInstallerLauncherService</key>
<true/>
</dict>
</plist>
14 changes: 0 additions & 14 deletions BetterCapture/Model/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -368,20 +368,6 @@ final class SettingsStore {
}
}

// MARK: - Update Settings

var automaticallyCheckForUpdates: Bool {
get {
access(keyPath: \.automaticallyCheckForUpdates)
return UserDefaults.standard.object(forKey: "automaticallyCheckForUpdates") as? Bool ?? true
}
set {
withMutation(keyPath: \.automaticallyCheckForUpdates) {
UserDefaults.standard.set(newValue, forKey: "automaticallyCheckForUpdates")
}
}
}

// MARK: - Output Settings

/// The default output directory (Movies/BetterCapture)
Expand Down
69 changes: 69 additions & 0 deletions BetterCapture/Service/UpdaterService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
//
// UpdaterService.swift
// BetterCapture
//
// Created by Joshua Sattler on 08.02.26.
//

import Foundation
import Sparkle

/// Wraps Sparkle's updater controller for use in SwiftUI
///
/// This service owns the `SPUStandardUpdaterController` and exposes
/// observable state for whether the user can check for updates, and
/// a binding to the automatic-check preference managed by Sparkle.
@MainActor
@Observable
final class UpdaterService {

// MARK: - Properties

/// Whether the updater is currently able to check for updates
private(set) var canCheckForUpdates = false

/// The underlying Sparkle updater controller
private let controller: SPUStandardUpdaterController

/// KVO observation for `canCheckForUpdates`
private var canCheckObservation: NSKeyValueObservation?

/// Convenience accessor for the updater
var updater: SPUUpdater {
controller.updater
}

/// Whether Sparkle should automatically check for updates.
/// This directly reads/writes Sparkle's own user-defaults-backed property.
var automaticallyChecksForUpdates: Bool {
get { updater.automaticallyChecksForUpdates }
set { updater.automaticallyChecksForUpdates = newValue }
}

// MARK: - Initialization

init() {
controller = SPUStandardUpdaterController(
startingUpdater: true,
updaterDelegate: nil,
userDriverDelegate: nil
)

// Observe Sparkle's canCheckForUpdates via KVO
canCheckObservation = updater.observe(
\.canCheckForUpdates,
options: [.initial, .new]
) { [weak self] updater, _ in
MainActor.assumeIsolated {
self?.canCheckForUpdates = updater.canCheckForUpdates
}
}
}

// MARK: - Actions

/// Triggers a user-initiated check for updates
func checkForUpdates() {
updater.checkForUpdates()
}
}
Loading