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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@ All notable changes to Browseroute are documented here (Keep a Changelog style).
- App icon: charcoal squircle with a routing Y (About, Finder, README).

- Developer ID-signed, notarized GitHub releases (same methodology as yap and TickerBar) and a Homebrew cask.
- Sparkle EdDSA key baked in; Check for Updates is live on signed GitHub builds (Homebrew still manages its own updates).
- Sparkle EdDSA key baked in; Check for Updates is live on Developer ID-signed GitHub builds (ad-hoc and Homebrew stay off).
35 changes: 29 additions & 6 deletions Sources/Browseroute/Updater.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import Security
#if canImport(Sparkle)
import Sparkle
#endif
Expand Down Expand Up @@ -44,16 +45,38 @@ func makeUpdater() -> any UpdaterProviding {
final class SparkleUpdater: NSObject, UpdaterProviding {
private let controller: SPUStandardUpdaterController

/// Only enable Sparkle for a real .app bundle that is not a Homebrew cask,
/// AND only once a real EdDSA public key is baked into Info.plist. Without a
/// key (dev/unsigned builds) the appcast is unsigned/missing, so auto-checks
/// just throw a "failed to update" alert — keep the no-op updater instead.
/// (For production, also verify a Developer ID signature before enabling.)
/// Sparkle only for a Developer ID-signed `.app` that is not a Homebrew cask,
/// with a real EdDSA public key in Info.plist. Ad-hoc `make install` builds
/// share that plist, so the signature check is what keeps them on the no-op
/// updater — otherwise they would auto-replace themselves with the notarized zip.
static var shouldEnable: Bool {
let path = Bundle.main.bundlePath
guard path.hasSuffix(".app"), !path.contains("/Caskroom/") else { return false }
let key = Bundle.main.object(forInfoDictionaryKey: "SUPublicEDKey") as? String ?? ""
return !key.isEmpty && !key.hasPrefix("REPLACE_")
guard !key.isEmpty, !key.hasPrefix("REPLACE_") else { return false }
return isDeveloperIDSigned(at: Bundle.main.bundleURL)
}

/// Team 92X3ACDPD2 Developer ID Application. Ad-hoc, Apple Development,
/// and other-team signatures do not match.
static let developerIDRequirement =
"anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] exists and "
+ "certificate leaf[field.1.2.840.113635.100.6.1.13] exists and "
+ "certificate leaf[subject.OU] = \"92X3ACDPD2\""

static func isDeveloperIDSigned(at url: URL) -> Bool {
var staticCode: SecStaticCode?
let created = SecStaticCodeCreateWithPath(url as CFURL, [], &staticCode)
guard created == errSecSuccess, let staticCode else { return false }

var requirement: SecRequirement?
let parsed = SecRequirementCreateWithString(
developerIDRequirement as CFString,
[],
&requirement,
)
guard parsed == errSecSuccess, let requirement else { return false }
return SecStaticCodeCheckValidity(staticCode, [], requirement) == errSecSuccess
}

override init() {
Expand Down
Loading