diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 522716f..6e8f93d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,6 +17,7 @@ env: APP_NAME: BetterCapture SCHEME: BetterCapture XCODE_VERSION: "26.0" + SPARKLE_VERSION: "2.7.3" jobs: build: @@ -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 }} @@ -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 @@ -170,6 +185,59 @@ 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: @@ -177,38 +245,23 @@ jobs: 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: | diff --git a/BetterCapture.xcodeproj/project.pbxproj b/BetterCapture.xcodeproj/project.pbxproj index b634e77..7555e1a 100644 --- a/BetterCapture.xcodeproj/project.pbxproj +++ b/BetterCapture.xcodeproj/project.pbxproj @@ -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; @@ -65,6 +69,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 6C5C123D2F3893FE0082CE23 /* Sparkle in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -125,6 +130,7 @@ ); name = BetterCapture; packageProductDependencies = ( + 6C0990E12F2BE0C200D48100 /* Sparkle */, ); productName = BetterCapture; productReference = 6C0990AF2F2BE0C100D48100 /* BetterCapture.app */; @@ -208,6 +214,9 @@ ); mainGroup = 6C0990A62F2BE0C100D48100; minimizedProjectReferenceProxies = 1; + packageReferences = ( + 6C0990E02F2BE0C200D48100 /* XCRemoteSwiftPackageReference "Sparkle" */, + ); preferredProjectObjectVersion = 77; productRefGroup = 6C0990B02F2BE0C100D48100 /* Products */; projectDirPath = ""; @@ -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 */; } diff --git a/BetterCapture.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/BetterCapture.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..f55129a --- /dev/null +++ b/BetterCapture.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "e721da7f9826abdffcb6185e886155efa2514bd6234475f1afa893e29eb258d6", + "pins" : [ + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle", + "state" : { + "revision" : "5581748cef2bae787496fe6d61139aebe0a451f6", + "version" : "2.8.1" + } + } + ], + "version" : 3 +} diff --git a/BetterCapture/BetterCapture.entitlements b/BetterCapture/BetterCapture.entitlements index 4803372..db9d655 100644 --- a/BetterCapture/BetterCapture.entitlements +++ b/BetterCapture/BetterCapture.entitlements @@ -12,5 +12,12 @@ com.apple.security.device.audio-input + com.apple.security.network.client + + com.apple.security.temporary-exception.mach-lookup.global-name + + $(PRODUCT_BUNDLE_IDENTIFIER)-spks + $(PRODUCT_BUNDLE_IDENTIFIER)-spki + diff --git a/BetterCapture/BetterCaptureApp.swift b/BetterCapture/BetterCaptureApp.swift index 0bbc2ad..37f1419 100644 --- a/BetterCapture/BetterCaptureApp.swift +++ b/BetterCapture/BetterCaptureApp.swift @@ -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 @@ -27,7 +28,7 @@ struct BetterCaptureApp: App { // Settings window Settings { - SettingsView(settings: viewModel.settings) + SettingsView(settings: viewModel.settings, updaterService: updaterService) } } } diff --git a/BetterCapture/Info.plist b/BetterCapture/Info.plist index 19d8387..efc83c6 100644 --- a/BetterCapture/Info.plist +++ b/BetterCapture/Info.plist @@ -8,5 +8,11 @@ BetterCapture needs access to your microphone to record audio alongside screen captures. NSScreenCaptureUsageDescription BetterCapture needs access to screen recording to capture your screen content. + SUFeedURL + https://github.com/jsattler/BetterCapture/releases/latest/download/appcast.xml + SUPublicEDKey + SPARKLE_PUBLIC_KEY + SUEnableInstallerLauncherService + diff --git a/BetterCapture/Model/SettingsStore.swift b/BetterCapture/Model/SettingsStore.swift index 5aa61c6..108562b 100644 --- a/BetterCapture/Model/SettingsStore.swift +++ b/BetterCapture/Model/SettingsStore.swift @@ -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) diff --git a/BetterCapture/Service/UpdaterService.swift b/BetterCapture/Service/UpdaterService.swift new file mode 100644 index 0000000..c0d0bbc --- /dev/null +++ b/BetterCapture/Service/UpdaterService.swift @@ -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() + } +} diff --git a/BetterCapture/View/SettingsView.swift b/BetterCapture/View/SettingsView.swift index ab17064..0403a01 100644 --- a/BetterCapture/View/SettingsView.swift +++ b/BetterCapture/View/SettingsView.swift @@ -11,11 +11,12 @@ import SwiftUI /// The settings window for BetterCapture struct SettingsView: View { @Bindable var settings: SettingsStore + var updaterService: UpdaterService var body: some View { TabView { Tab("General", systemImage: "gearshape") { - GeneralSettingsView(settings: settings) + GeneralSettingsView(settings: settings, updaterService: updaterService) } Tab("Video", systemImage: "video") { @@ -157,6 +158,15 @@ struct AudioSettingsView: View { struct GeneralSettingsView: View { @Bindable var settings: SettingsStore + var updaterService: UpdaterService + + @State private var automaticallyChecksForUpdates: Bool + + init(settings: SettingsStore, updaterService: UpdaterService) { + self.settings = settings + self.updaterService = updaterService + self._automaticallyChecksForUpdates = State(initialValue: updaterService.automaticallyChecksForUpdates) + } /// Formats the output directory path for display private var displayPath: String { @@ -196,10 +206,16 @@ struct GeneralSettingsView: View { } Section("Software Updates") { - Toggle("Automatically check for updates", isOn: $settings.automaticallyCheckForUpdates) + Toggle("Automatically check for updates", isOn: $automaticallyChecksForUpdates) + .onChange(of: automaticallyChecksForUpdates) { _, newValue in + updaterService.automaticallyChecksForUpdates = newValue + } LabeledContent("Updates") { - Button("Check for Update") {} + Button("Check for Update") { + updaterService.checkForUpdates() + } + .disabled(!updaterService.canCheckForUpdates) } } @@ -255,5 +271,5 @@ struct AboutSection: View { // MARK: - Preview #Preview { - SettingsView(settings: SettingsStore()) + SettingsView(settings: SettingsStore(), updaterService: UpdaterService()) } diff --git a/Casks/bettercapture.rb b/Casks/bettercapture.rb index a551777..202df17 100644 --- a/Casks/bettercapture.rb +++ b/Casks/bettercapture.rb @@ -1,12 +1,18 @@ cask "bettercapture" do version "1.0.0" - sha256 :no_check # Updated automatically by release workflow + sha256 :no_check - url "https://github.com/jsattler/BetterCapture/releases/download/v#{version}/BetterCapture-#{version}-arm64.dmg" + url "https://github.com/jsattler/BetterCapture/releases/download/v#{version}/BetterCapture-#{version}-arm64.dmg", + verified: "github.com/jsattler/BetterCapture/" name "BetterCapture" desc "The macOS screen recorder you deserve - always free and open source" homepage "https://github.com/jsattler/BetterCapture" + livecheck do + url :url + strategy :github_latest + end + depends_on macos: ">= :sequoia" depends_on arch: :arm64 diff --git a/dist/update_appcast.py b/dist/update_appcast.py new file mode 100644 index 0000000..6789f99 --- /dev/null +++ b/dist/update_appcast.py @@ -0,0 +1,228 @@ +""" +Update the appcast.xml file for BetterCapture releases. + +This script adds a new entry to the Sparkle appcast with the release +information, including GitHub release notes rendered as inline HTML. + +The resulting appcast.xml is uploaded as a GitHub release asset so that +Sparkle clients can fetch it from a stable URL: + https://github.com/jsattler/BetterCapture/releases/latest/download/appcast.xml + +Expected files in the current directory: + - sign_update.txt Output from Sparkle's `sign_update` tool. + - appcast.xml The existing appcast file (downloaded from the + previous release, or a fresh template if this is + the first release). + +Required environment variables: + - VERSION The version number (e.g. 1.0.0). + - DMG_URL The download URL for the DMG. + +Optional environment variables: + - RELEASE_NOTES The GitHub release body in markdown. + - RELEASE_URL The GitHub release URL. + +The script outputs appcast_new.xml. +""" + +import os +import re +import sys +import xml.etree.ElementTree as ET +from datetime import datetime, timezone + +now = datetime.now(timezone.utc) +version = os.environ["VERSION"] +dmg_url = os.environ["DMG_URL"] +release_notes = os.environ.get("RELEASE_NOTES", "") +release_url = os.environ.get("RELEASE_URL", "") +repo_url = "https://github.com/jsattler/BetterCapture" + +# Read sign_update output (e.g. 'sparkle:edSignature="..." length="12345"') +with open("sign_update.txt", "r") as f: + attrs = {} + for pair in f.read().strip().split(" "): + if "=" not in pair: + continue + key, value = pair.split("=", 1) + value = value.strip().strip('"') + attrs[key] = value + +# Register Sparkle namespace +namespaces = {"sparkle": "http://www.andymatuschak.org/xml-namespaces/sparkle"} +for prefix, uri in namespaces.items(): + ET.register_namespace(prefix, uri) + +# Parse existing appcast or create a fresh one if missing / malformed +if os.path.exists("appcast.xml"): + try: + et = ET.parse("appcast.xml") + channel = et.find("channel") + if channel is None: + raise ValueError("No element found") + except Exception as exc: + print( + f"Warning: could not parse existing appcast.xml ({exc}), creating fresh one" + ) + et = None +else: + print("No existing appcast.xml found, creating fresh one") + et = None + +if et is None: + root = ET.fromstring( + '' + '' + "" + "BetterCapture Updates" + "https://github.com/jsattler/BetterCapture/releases/latest/download/appcast.xml" + "Updates for BetterCapture" + "en" + "" + "" + ) + et = ET.ElementTree(root) + channel = root.find("channel") + +# Remove any existing items with the same version +for item in channel.findall("item"): + sv = item.find("sparkle:shortVersionString", namespaces) + if sv is not None and sv.text == version: + channel.remove(item) + # Also remove items without pubDate (malformed) + if item.find("pubDate") is None: + channel.remove(item) + +# Prune old items, keep the most recent 15 +pubdate_format = "%a, %d %b %Y %H:%M:%S %z" +items = channel.findall("item") +items_with_date = [item for item in items if item.find("pubDate") is not None] +items_with_date.sort( + key=lambda item: datetime.strptime(item.find("pubDate").text, pubdate_format) +) +prune_limit = 15 +if len(items_with_date) > prune_limit: + for item in items_with_date[:-prune_limit]: + channel.remove(item) + + +def markdown_to_simple_html(md: str) -> str: + """Very basic markdown to HTML conversion for release notes.""" + lines = md.strip().split("\n") + html_lines = [] + in_list = False + + for line in lines: + stripped = line.strip() + + # Skip empty lines + if not stripped: + if in_list: + html_lines.append("") + in_list = False + html_lines.append("") + continue + + # Headers + if stripped.startswith("### "): + if in_list: + html_lines.append("") + in_list = False + html_lines.append(f"

{stripped[4:]}

") + elif stripped.startswith("## "): + if in_list: + html_lines.append("") + in_list = False + html_lines.append(f"

{stripped[3:]}

") + elif stripped.startswith("# "): + if in_list: + html_lines.append("") + in_list = False + html_lines.append(f"

{stripped[2:]}

") + # List items + elif stripped.startswith("- ") or stripped.startswith("* "): + if not in_list: + html_lines.append("
    ") + in_list = True + content = stripped[2:] + # Convert markdown links to HTML + content = re.sub( + r"\[([^\]]+)\]\(([^)]+)\)", r'\1', content + ) + # Convert bold + content = re.sub(r"\*\*([^*]+)\*\*", r"\1", content) + # Convert inline code + content = re.sub(r"`([^`]+)`", r"\1", content) + html_lines.append(f"
  • {content}
  • ") + # Regular paragraph text + else: + if in_list: + html_lines.append("
") + in_list = False + # Convert inline formatting + text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r'\1', stripped) + text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text) + text = re.sub(r"`([^`]+)`", r"\1", text) + html_lines.append(f"

{text}

") + + if in_list: + html_lines.append("") + + return "\n".join(html_lines) + + +# Build release notes HTML +if release_notes.strip(): + notes_html = markdown_to_simple_html(release_notes) + description_html = f""" +

BetterCapture v{version}

+{notes_html} +""" +else: + description_html = f""" +

BetterCapture v{version}

+

This release was published on {now.strftime("%Y-%m-%d")}.

+

+View the full release notes on +GitHub. +

+""" + +# Create new appcast item +item = ET.SubElement(channel, "item") + +elem = ET.SubElement(item, "title") +elem.text = f"Version {version}" + +elem = ET.SubElement(item, "pubDate") +elem.text = now.strftime(pubdate_format) + +elem = ET.SubElement(item, "sparkle:version") +# Use a build number derived from version for CFBundleVersion comparison +# Sparkle compares sparkle:version against CFBundleVersion +elem.text = "1" # Will be overridden in CI with the actual build number + +elem = ET.SubElement(item, "sparkle:shortVersionString") +elem.text = version + +elem = ET.SubElement(item, "sparkle:minimumSystemVersion") +elem.text = "26.0" + +if release_url: + elem = ET.SubElement(item, "sparkle:fullReleaseNotesLink") + elem.text = release_url + +elem = ET.SubElement(item, "description") +elem.text = description_html + +elem = ET.SubElement(item, "enclosure") +elem.set("url", dmg_url) +elem.set("type", "application/octet-stream") +for key, value in attrs.items(): + elem.set(key, value) + +# Write output +et.write("appcast_new.xml", xml_declaration=True, encoding="utf-8") +print(f"Generated appcast_new.xml for version {version}")