feat: Sparkle auto-updates + Developer ID notarization pipeline

Adds Sparkle 2 auto-updates and a local release script that produces
signed, notarized, stapled builds for GitHub distribution. App Store
submission was rejected because Scarf spawns the user-installed hermes
binary and reads ~/.hermes/ directly — both forbidden by App Sandbox —
so we commit to the GitHub-release path properly.

- Sparkle SPM dep wired into the app target (link-only; hardened-runtime
  entitlement disable-library-validation lets Sparkle load at runtime).
- Tracked Info.plist with SUFeedURL, SUPublicEDKey, and daily check
  interval; replaces the auto-generated plist so Sparkle keys live in
  version control rather than pbxproj INFOPLIST_KEY_* noise.
- UpdaterService wraps SPUStandardUpdaterController and is injected via
  .environment(). Menu bar, standard app menu (CommandGroup after
  .appInfo), and a new Updates section in Settings → General each call
  updater.checkForUpdates().
- scripts/release.sh runs the full pipeline: version bump → universal
  archive → Developer ID export → notarytool submit (keychain profile
  scarf-notary) → staple → appcast EdDSA sign → gh-pages push → gh
  release → tag. scripts/ExportOptions.plist pins manual Developer ID
  signing for team 3Q6X2L86C4.
- README: removes the right-click-Open workaround (notarized builds
  don't need it), notes Sparkle, adds a Releases section describing
  the pipeline and signing prerequisites.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-16 18:42:20 -07:00
parent eb39dcfa61
commit 41ea3aeb83
11 changed files with 427 additions and 17 deletions
+38 -14
View File
@@ -8,6 +8,7 @@
/* Begin PBXBuildFile section */
53495AB62F7B992C00BD31AD /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 53SWIFTTERM0001 /* SwiftTerm */; };
53SPARKLE00010 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 53SPARKLE00011 /* Sparkle */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -33,9 +34,22 @@
534959592F7B83B700BD31AD /* scarfUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = scarfUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
534959AA2F7B83B600BD31AD /* Exceptions for "scarf" folder in "scarf" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = 5349593F2F7B83B600BD31AD /* scarf */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
534959422F7B83B600BD31AD /* scarf */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
534959AA2F7B83B600BD31AD /* Exceptions for "scarf" folder in "scarf" target */,
);
path = scarf;
sourceTree = "<group>";
};
@@ -57,6 +71,7 @@
buildActionMask = 2147483647;
files = (
53495AB62F7B992C00BD31AD /* SwiftTerm in Frameworks */,
53SPARKLE00010 /* Sparkle in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -118,6 +133,7 @@
name = scarf;
packageProductDependencies = (
53SWIFTTERM0001 /* SwiftTerm */,
53SPARKLE00011 /* Sparkle */,
);
productName = scarf;
productReference = 534959402F7B83B600BD31AD /* scarf.app */;
@@ -203,6 +219,7 @@
minimizedProjectReferenceProxies = 1;
packageReferences = (
53SWIFTTERM0002 /* XCRemoteSwiftPackageReference "SwiftTerm" */,
53SPARKLE00012 /* XCRemoteSwiftPackageReference "Sparkle" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 534959412F7B83B600BD31AD /* Products */;
@@ -407,23 +424,20 @@
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 11;
CURRENT_PROJECT_VERSION = 16;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Scarf;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Scarf uses the microphone for Hermes voice chat.";
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = scarf/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 1.6.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -444,23 +458,20 @@
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 11;
CURRENT_PROJECT_VERSION = 16;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Scarf;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Scarf uses the microphone for Hermes voice chat.";
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = scarf/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 1.6.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -594,6 +605,14 @@
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
53SPARKLE00012 /* XCRemoteSwiftPackageReference "Sparkle" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/sparkle-project/Sparkle";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.6.0;
};
};
53SWIFTTERM0002 /* XCRemoteSwiftPackageReference "SwiftTerm" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/migueldeicaza/SwiftTerm.git";
@@ -605,6 +624,11 @@
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
53SPARKLE00011 /* Sparkle */ = {
isa = XCSwiftPackageProductDependency;
package = 53SPARKLE00012 /* XCRemoteSwiftPackageReference "Sparkle" */;
productName = Sparkle;
};
53SWIFTTERM0001 /* SwiftTerm */ = {
isa = XCSwiftPackageProductDependency;
package = 53SWIFTTERM0002 /* XCRemoteSwiftPackageReference "SwiftTerm" */;
@@ -0,0 +1,40 @@
import Foundation
import Sparkle
/// Thin wrapper around Sparkle's `SPUStandardUpdaterController`.
///
/// Sparkle reads `SUFeedURL`, `SUPublicEDKey`, and check-interval defaults from Info.plist.
/// This service exposes the bits the UI needs: a "check now" trigger, a toggle for automatic
/// checks, and observable state for the Settings screen.
@MainActor
@Observable
final class UpdaterService: NSObject {
private let controller: SPUStandardUpdaterController
/// User-facing toggle. Mirrors `updater.automaticallyChecksForUpdates`.
var automaticallyChecksForUpdates: Bool {
get { controller.updater.automaticallyChecksForUpdates }
set { controller.updater.automaticallyChecksForUpdates = newValue }
}
/// Last time Sparkle checked the appcast (nil before the first check).
var lastUpdateCheckDate: Date? {
controller.updater.lastUpdateCheckDate
}
override init() {
// startingUpdater: true Sparkle scans for updates on launch per Info.plist schedule.
// Default delegates are sufficient for a non-sandboxed app.
self.controller = SPUStandardUpdaterController(
startingUpdater: true,
updaterDelegate: nil,
userDriverDelegate: nil
)
super.init()
}
/// Triggers a user-initiated update check. Sparkle handles the UI (alert, progress, install).
func checkForUpdates() {
controller.checkForUpdates(nil)
}
}
@@ -0,0 +1,51 @@
import SwiftUI
/// Updates section for the General tab. Wraps the Sparkle-backed `UpdaterService`
/// in the same row idioms used elsewhere in Settings (per CLAUDE.md guidance
/// extract sections so individual tab bodies stay small).
struct UpdatesSection: View {
@Environment(UpdaterService.self) private var updater
var body: some View {
SettingsSection(title: "Updates", icon: "arrow.down.circle") {
ReadOnlyRow(label: "Current Version", value: versionString)
ToggleRow(
label: "Check Automatically",
isOn: updater.automaticallyChecksForUpdates
) { newValue in
updater.automaticallyChecksForUpdates = newValue
}
ReadOnlyRow(label: "Last Checked", value: lastCheckedString)
checkNowRow
}
}
private var versionString: String {
let info = Bundle.main.infoDictionary
let short = info?["CFBundleShortVersionString"] as? String ?? "?"
let build = info?["CFBundleVersion"] as? String ?? "?"
return "\(short) (\(build))"
}
private var lastCheckedString: String {
guard let date = updater.lastUpdateCheckDate else { return "Never" }
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .full
return formatter.localizedString(for: date, relativeTo: Date())
}
private var checkNowRow: some View {
HStack {
Text("Check Now")
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 160, alignment: .trailing)
Button("Check for Updates…") { updater.checkForUpdates() }
.controlSize(.small)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
}
}
@@ -39,6 +39,8 @@ struct GeneralTab: View {
SettingsSection(title: "Locale", icon: "globe.americas") {
EditableTextField(label: "Timezone (IANA)", value: viewModel.config.timezone) { viewModel.setTimezone($0) }
}
UpdatesSection()
}
/// Breadcrumb-style row that points users to the Credential Pools sidebar
+42
View File
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Scarf</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSHumanReadableCopyright</key>
<string></string>
<key>NSMicrophoneUsageDescription</key>
<string>Scarf uses the microphone for Hermes voice chat.</string>
<key>SUFeedURL</key>
<string>https://awizemann.github.io/scarf/appcast.xml</string>
<key>SUPublicEDKey</key>
<string>sxHR0OGLmx9I4Fyx1GdPANR9WUiVAz/rI38x3cLYnMU=</string>
<key>SUEnableAutomaticChecks</key>
<true/>
<key>SUScheduledCheckInterval</key>
<integer>86400</integer>
<key>SUEnableInstallerLauncherService</key>
<false/>
</dict>
</plist>
+2
View File
@@ -4,5 +4,7 @@
<dict>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
+11 -1
View File
@@ -6,6 +6,7 @@ struct ScarfApp: App {
@State private var fileWatcher = HermesFileWatcher()
@State private var menuBarStatus = MenuBarStatus()
@State private var chatViewModel = ChatViewModel()
@State private var updater = UpdaterService()
var body: some Scene {
WindowGroup {
@@ -13,6 +14,7 @@ struct ScarfApp: App {
.environment(coordinator)
.environment(fileWatcher)
.environment(chatViewModel)
.environment(updater)
.onAppear {
fileWatcher.startWatching()
menuBarStatus.startPolling()
@@ -23,9 +25,14 @@ struct ScarfApp: App {
}
}
.defaultSize(width: 1100, height: 700)
.commands {
CommandGroup(after: .appInfo) {
Button("Check for Updates…") { updater.checkForUpdates() }
}
}
MenuBarExtra("Scarf", systemImage: menuBarStatus.icon) {
MenuBarMenu(status: menuBarStatus, coordinator: coordinator)
MenuBarMenu(status: menuBarStatus, coordinator: coordinator, updater: updater)
}
}
}
@@ -90,6 +97,7 @@ final class MenuBarStatus {
struct MenuBarMenu: View {
let status: MenuBarStatus
let coordinator: AppCoordinator
let updater: UpdaterService
var body: some View {
VStack {
@@ -116,6 +124,8 @@ struct MenuBarMenu: View {
NSApplication.shared.activate()
}
Divider()
Button("Check for Updates…") { updater.checkForUpdates() }
Divider()
Button("Quit Scarf") {
NSApplication.shared.terminate(nil)
}