diff --git a/apps/macos/Munkel.entitlements b/apps/macos/Munkel.entitlements
new file mode 100644
index 00000000..ad3771d4
--- /dev/null
+++ b/apps/macos/Munkel.entitlements
@@ -0,0 +1,14 @@
+
+
+
+
+
+ com.apple.developer.applesignin
+
+ Default
+
+
+
diff --git a/apps/macos/Sources/MunkelApp/AppModel.swift b/apps/macos/Sources/MunkelApp/AppModel.swift
index 682cab2c..9052d45b 100644
--- a/apps/macos/Sources/MunkelApp/AppModel.swift
+++ b/apps/macos/Sources/MunkelApp/AppModel.swift
@@ -5,11 +5,13 @@ import Foundation
import IOKit.pwr_mgt
import MunkelKit
-enum GitHubLoginState: Equatable {
+enum AuthFlowState: Equatable {
case idle
- case requestingCode
+ /// Starting the flow: GitHub requests a device code, Apple presents its sheet.
+ case connecting(AuthProviderKind)
+ /// GitHub device flow only — Apple never enters this.
case awaitingUser(userCode: String, verificationURI: URL, expiresAt: Date)
- case fetchingProfile
+ case fetchingProfile(AuthProviderKind)
case failed(String)
}
@@ -26,13 +28,16 @@ final class AppModel: ObservableObject {
scheduleProfileBroadcast()
}
}
- @Published private(set) var githubLoginState: GitHubLoginState = .idle {
+ @Published private(set) var authFlow: AuthFlowState = .idle {
didSet {
- guard githubLoginState != oldValue else { return }
+ guard authFlow != oldValue else { return }
syncAuthCodeNotch()
}
}
- @Published private(set) var githubUserLogin: String? = Identity.githubLogin
+ /// The provider the user is signed in with, or nil when signed out. This is
+ /// the app-wide "signed in?" signal.
+ @Published private(set) var signedInProvider: AuthProviderKind? = Identity.authProvider
+ var isSignedIn: Bool { signedInProvider != nil }
@Published var relayURLString: String {
didSet {
UserDefaults.standard.set(relayURLString, forKey: Self.relayURLKey)
@@ -90,8 +95,11 @@ final class AppModel: ObservableObject {
private let notch = NotchPresenter()
private var controlServer: ControlServer?
private var palette: CommandPalettePresenter?
- private var githubLoginTask: Task?
- private var githubLoginGeneration = 0
+ private var loginTask: Task?
+ private var loginGeneration = 0
+ private var appleSignIn: AppleSignIn?
+ /// The provider of the most recent attempt, so Retry re-runs the right one.
+ private var lastLoginProvider: AuthProviderKind = .github
private var profileBroadcastTask: Task?
private var idleTimer: Timer?
private var presenceObservers: Set = []
@@ -102,9 +110,9 @@ final class AppModel: ObservableObject {
self.displayName = Identity.displayName
self.relayURLString = UserDefaults.standard.string(forKey: Self.relayURLKey) ?? Self.defaultRelayURL
self.groupCodes = UserDefaults.standard.stringArray(forKey: Self.groupsKey) ?? []
- // GitHub login is mandatory: without it no session connects — the
+ // Signing in is mandatory: without it no session connects — the
// persisted groups come back online with the next login.
- if Identity.githubLogin != nil {
+ if Identity.isSignedIn {
for code in groupCodes {
openSession(code: code)
}
@@ -123,7 +131,7 @@ final class AppModel: ObservableObject {
}
func join(code rawCode: String) {
- guard githubUserLogin != nil else { return }
+ guard isSignedIn else { return }
let code = GroupKey.normalize(rawCode)
guard !code.isEmpty, sessions[code] == nil else { return }
groupCodes.append(code)
@@ -254,7 +262,7 @@ final class AppModel: ObservableObject {
/// is focus-independent and keeps the code (already on the clipboard)
/// visible until the flow leaves `.awaitingUser`.
private func syncAuthCodeNotch() {
- if case let .awaitingUser(userCode, _, _) = githubLoginState {
+ if case let .awaitingUser(userCode, _, _) = authFlow {
notch.showAuthCode(userCode)
} else {
notch.hideAuthCode()
@@ -262,27 +270,48 @@ final class AppModel: ObservableObject {
}
func startGitHubLogin() {
- githubLoginTask?.cancel()
- githubLoginGeneration += 1
- let generation = githubLoginGeneration
- githubLoginState = .requestingCode
- githubLoginTask = Task { await runGitHubLogin(generation: generation) }
+ let generation = beginLogin(.github)
+ authFlow = .connecting(.github)
+ loginTask = Task { await runGitHubLogin(generation: generation) }
}
- func cancelGitHubLogin() {
- githubLoginTask?.cancel()
- githubLoginTask = nil
- githubLoginGeneration += 1
- githubLoginState = .idle
+ func startAppleLogin() {
+ let generation = beginLogin(.apple)
+ authFlow = .connecting(.apple)
+ loginTask = Task { await runAppleLogin(generation: generation) }
}
- /// Logout makes the app unusable until the next login: all sessions
+ /// Retry re-runs whichever provider was last attempted.
+ func retryLogin() {
+ switch lastLoginProvider {
+ case .github: startGitHubLogin()
+ case .apple: startAppleLogin()
+ }
+ }
+
+ private func beginLogin(_ provider: AuthProviderKind) -> Int {
+ loginTask?.cancel()
+ loginGeneration += 1
+ lastLoginProvider = provider
+ return loginGeneration
+ }
+
+ func cancelLogin() {
+ loginTask?.cancel()
+ loginTask = nil
+ loginGeneration += 1
+ authFlow = .idle
+ }
+
+ /// Signing out makes the app unusable until the next login: all sessions
/// stop (the group codes stay persisted and reconnect after re-login).
- func logoutGitHub() {
+ func signOut() {
Identity.avatarData = nil
Identity.avatarURL = nil
Identity.githubLogin = nil
- githubUserLogin = nil
+ Identity.authProvider = nil
+ Identity.providerUserID = nil
+ signedInProvider = nil
for session in sessions.values {
session.stop()
}
@@ -302,10 +331,10 @@ final class AppModel: ObservableObject {
let auth = GitHubDeviceAuth(clientID: GitHubConfig.clientID)
do {
let grant = try await auth.requestDeviceCode()
- guard generation == githubLoginGeneration else { return }
+ guard generation == loginGeneration else { return }
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(grant.userCode, forType: .string)
- githubLoginState = .awaitingUser(
+ authFlow = .awaitingUser(
userCode: grant.userCode,
verificationURI: grant.verificationURI,
expiresAt: grant.expiresAt
@@ -314,8 +343,8 @@ final class AppModel: ObservableObject {
// Token stays a local — used for one profile fetch, never stored.
let token = try await auth.pollForAccessToken(grant)
- guard generation == githubLoginGeneration else { return }
- githubLoginState = .fetchingProfile
+ guard generation == loginGeneration else { return }
+ authFlow = .fetchingProfile(.github)
let user = try await auth.fetchUser(token: token)
// Avatar is best-effort: login succeeds without one, the
@@ -328,33 +357,70 @@ final class AppModel: ObservableObject {
avatar = AvatarCodec.makeAvatar(from: raw)
}
- guard generation == githubLoginGeneration else { return }
- applyProfile(name: Self.firstName(of: user), avatar: avatar, avatarURL: user.avatarURL?.absoluteString, githubLogin: user.login)
- githubLoginState = .idle
- // Login gates everything: the persisted groups connect only now.
- for code in groupCodes where sessions[code] == nil {
- openSession(code: code)
- }
+ guard generation == loginGeneration else { return }
+ completeLogin(AuthProfile(
+ provider: .github,
+ providerUserID: user.login,
+ displayName: Self.firstName(of: user),
+ avatarURL: user.avatarURL,
+ avatarData: avatar
+ ), generation: generation)
} catch is CancellationError {
- // Cancelled flows say nothing — cancelGitHubLogin already reset.
+ // Cancelled flows say nothing — cancelLogin already reset.
} catch let error as URLError where error.code == .cancelled {
} catch let error as GitHubAuthError {
- guard generation == githubLoginGeneration else { return }
- githubLoginState = .failed(Self.message(for: error))
+ guard generation == loginGeneration else { return }
+ authFlow = .failed(Self.message(for: error))
} catch {
- guard generation == githubLoginGeneration else { return }
- githubLoginState = .failed("No connection to GitHub.")
+ guard generation == loginGeneration else { return }
+ authFlow = .failed("No connection to GitHub.")
+ }
+ }
+
+ private func runAppleLogin(generation: Int) async {
+ let provider = AppleSignIn()
+ appleSignIn = provider
+ defer { appleSignIn = nil }
+ do {
+ let profile = try await provider.signIn()
+ guard generation == loginGeneration else { return }
+ completeLogin(profile, generation: generation)
+ } catch is CancellationError {
+ // Cancelled sheet is silent.
+ } catch let error as AppleAuthError {
+ guard generation == loginGeneration else { return }
+ if case let .failed(message) = error {
+ authFlow = .failed(message)
+ } else {
+ authFlow = .failed("Couldn't sign in with Apple — please try again.")
+ }
+ } catch {
+ guard generation == loginGeneration else { return }
+ authFlow = .failed("Couldn't sign in with Apple — please try again.")
+ }
+ }
+
+ /// Shared success tail for every provider: store the profile, then open the
+ /// persisted groups (login gates everything — they connect only now).
+ private func completeLogin(_ profile: AuthProfile, generation: Int) {
+ applyProfile(profile)
+ authFlow = .idle
+ for code in groupCodes where sessions[code] == nil {
+ openSession(code: code)
}
}
- /// Writes both identity halves before the single broadcast — a didSet
+ /// Writes the identity fields before the single broadcast — a didSet
/// broadcast would race the avatar write and send a stale profile.
- private func applyProfile(name: String, avatar: Data?, avatarURL: String?, githubLogin login: String?) {
- Identity.avatarData = avatar
- Identity.avatarURL = avatarURL
- Identity.githubLogin = login
- githubUserLogin = login
- displayName = name
+ private func applyProfile(_ profile: AuthProfile) {
+ Identity.avatarData = profile.avatarData
+ Identity.avatarURL = profile.avatarURL?.absoluteString
+ Identity.authProvider = profile.provider
+ Identity.providerUserID = profile.providerUserID
+ // Keep the legacy field in sync so older reads still work.
+ Identity.githubLogin = profile.provider == .github ? profile.providerUserID : nil
+ signedInProvider = profile.provider
+ displayName = profile.displayName
profileBroadcastTask?.cancel()
broadcastProfile()
}
diff --git a/apps/macos/Sources/MunkelApp/AppleSignIn.swift b/apps/macos/Sources/MunkelApp/AppleSignIn.swift
new file mode 100644
index 00000000..4f84be7a
--- /dev/null
+++ b/apps/macos/Sources/MunkelApp/AppleSignIn.swift
@@ -0,0 +1,97 @@
+import AppKit
+import AuthenticationServices
+
+enum AppleAuthError: Error {
+ case noPresentationAnchor
+ case malformedCredential
+ case failed(String)
+}
+
+/// Sign in with Apple. Unlike GitHub's headless device flow (which lives in
+/// MunkelKit), Apple's flow is UI-bound — `ASAuthorizationController` presents a
+/// system sheet — so it lives here in the app target.
+///
+/// Apple returns the full name only on the *first* authorization for an app, so
+/// we remember it keyed by the user id and reuse it on later sign-ins. No token
+/// is kept, matching the GitHub provider: the result is just an `AuthProfile`.
+@MainActor
+final class AppleSignIn: NSObject {
+ private var continuation: CheckedContinuation?
+ private var controller: ASAuthorizationController?
+
+ func signIn() async throws -> AuthProfile {
+ let request = ASAuthorizationAppleIDProvider().createRequest()
+ request.requestedScopes = [.fullName, .email]
+
+ let controller = ASAuthorizationController(authorizationRequests: [request])
+ controller.delegate = self
+ controller.presentationContextProvider = self
+ self.controller = controller
+
+ return try await withCheckedThrowingContinuation { continuation in
+ self.continuation = continuation
+ controller.performRequests()
+ }
+ }
+
+ private func finish(_ result: Result) {
+ continuation?.resume(with: result)
+ continuation = nil
+ controller = nil
+ }
+
+ private static func nameKey(_ userID: String) -> String { "appleName.\(userID)" }
+
+ /// Apple gives the name only on first authorization, so persist it and fall
+ /// back to the stored value (then a sensible default) on later sign-ins.
+ private static func displayName(for credential: ASAuthorizationAppleIDCredential) -> String {
+ let key = nameKey(credential.user)
+ if let given = credential.fullName?.givenName?.trimmingCharacters(in: .whitespaces),
+ !given.isEmpty {
+ UserDefaults.standard.set(given, forKey: key)
+ return given
+ }
+ if let stored = UserDefaults.standard.string(forKey: key), !stored.isEmpty {
+ return stored
+ }
+ return NSFullUserName()
+ }
+}
+
+extension AppleSignIn: ASAuthorizationControllerDelegate {
+ func authorizationController(
+ controller: ASAuthorizationController,
+ didCompleteWithAuthorization authorization: ASAuthorization
+ ) {
+ guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential else {
+ finish(.failure(AppleAuthError.malformedCredential))
+ return
+ }
+ let profile = AuthProfile(
+ provider: .apple,
+ providerUserID: credential.user,
+ displayName: Self.displayName(for: credential),
+ avatarURL: nil,
+ avatarData: nil
+ )
+ finish(.success(profile))
+ }
+
+ func authorizationController(
+ controller: ASAuthorizationController,
+ didCompleteWithError error: Error
+ ) {
+ // A user-cancelled sheet is silent, like cancelling the GitHub flow.
+ if let authError = error as? ASAuthorizationError, authError.code == .canceled {
+ finish(.failure(CancellationError()))
+ } else {
+ finish(.failure(AppleAuthError.failed(error.localizedDescription)))
+ }
+ }
+}
+
+extension AppleSignIn: ASAuthorizationControllerPresentationContextProviding {
+ func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
+ NSApp.keyWindow ?? NSApp.mainWindow ?? NSApp.windows.first ?? ASPresentationAnchor()
+ }
+}
diff --git a/apps/macos/Sources/MunkelApp/AuthProvider.swift b/apps/macos/Sources/MunkelApp/AuthProvider.swift
new file mode 100644
index 00000000..58f22f91
--- /dev/null
+++ b/apps/macos/Sources/MunkelApp/AuthProvider.swift
@@ -0,0 +1,32 @@
+import Foundation
+
+/// The sign-in providers Munkel supports. GitHub and Apple are the first two;
+/// adding another means a new case here plus a flow that produces an
+/// `AuthProfile` — the UI, identity store, and orchestration are provider-aware
+/// already and don't need reworking.
+enum AuthProviderKind: String, Codable, CaseIterable {
+ case github
+ case apple
+
+ /// User-facing provider name, e.g. "Sign in with \(displayName)".
+ var displayName: String {
+ switch self {
+ case .github: "GitHub"
+ case .apple: "Apple"
+ }
+ }
+}
+
+/// What every provider hands back after a successful sign-in. Munkel keeps no
+/// token — this is the whole result: a stable id, a name to show, and an
+/// optional avatar (GitHub has one, Apple doesn't).
+struct AuthProfile {
+ let provider: AuthProviderKind
+ /// Stable per-provider identifier: the GitHub login or Apple's user id.
+ let providerUserID: String
+ let displayName: String
+ /// Broadcast to peers so they can fetch it; nil when the provider has none.
+ let avatarURL: URL?
+ /// Downscaled avatar bytes for local display only; nil when there's none.
+ let avatarData: Data?
+}
diff --git a/apps/macos/Sources/MunkelApp/CommandPaletteView.swift b/apps/macos/Sources/MunkelApp/CommandPaletteView.swift
index 43f60768..b6d74dde 100644
--- a/apps/macos/Sources/MunkelApp/CommandPaletteView.swift
+++ b/apps/macos/Sources/MunkelApp/CommandPaletteView.swift
@@ -255,8 +255,8 @@ struct CommandPaletteView: View {
}
private var emptyMessage: String {
- if model.githubUserLogin == nil {
- return "Sign in with GitHub to use Munkel."
+ if !model.isSignedIn {
+ return "Sign in to use Munkel."
}
return "Join a channel to send."
}
diff --git a/apps/macos/Sources/MunkelApp/Identity.swift b/apps/macos/Sources/MunkelApp/Identity.swift
index f149ddb1..b20c9f3e 100644
--- a/apps/macos/Sources/MunkelApp/Identity.swift
+++ b/apps/macos/Sources/MunkelApp/Identity.swift
@@ -10,6 +10,8 @@ enum Identity {
private static let avatarDataKey = "avatarData"
private static let avatarURLKey = "avatarURL"
private static let githubLoginKey = "githubLogin"
+ private static let authProviderKey = "authProvider"
+ private static let providerUserIDKey = "providerUserID"
private static let presenceStatusKey = "presenceStatus"
static var memberId: String {
@@ -42,12 +44,35 @@ enum Identity {
}
/// GitHub login while signed in; nil after logout. Purely informational —
- /// no token is ever kept.
+ /// no token is ever kept. Kept for back-compat; `providerUserID` is the
+ /// provider-agnostic equivalent.
static var githubLogin: String? {
get { UserDefaults.standard.string(forKey: githubLoginKey) }
set { UserDefaults.standard.set(newValue, forKey: githubLoginKey) }
}
+ /// Which provider the user signed in with; nil after sign-out. This is the
+ /// "am I signed in?" signal. Migrates installs that predate the field: a
+ /// stored `githubLogin` means they signed in with GitHub.
+ static var authProvider: AuthProviderKind? {
+ get {
+ if let raw = UserDefaults.standard.string(forKey: authProviderKey) {
+ return AuthProviderKind(rawValue: raw)
+ }
+ return githubLogin != nil ? .github : nil
+ }
+ set { UserDefaults.standard.set(newValue?.rawValue, forKey: authProviderKey) }
+ }
+
+ /// Stable per-provider id (GitHub login or Apple user id); nil after
+ /// sign-out. Falls back to the legacy `githubLogin` for older installs.
+ static var providerUserID: String? {
+ get { UserDefaults.standard.string(forKey: providerUserIDKey) ?? githubLogin }
+ set { UserDefaults.standard.set(newValue, forKey: providerUserIDKey) }
+ }
+
+ static var isSignedIn: Bool { authProvider != nil }
+
static var presenceStatus: PresenceStatus {
get {
UserDefaults.standard.string(forKey: presenceStatusKey)
diff --git a/apps/macos/Sources/MunkelApp/MenuView.swift b/apps/macos/Sources/MunkelApp/MenuView.swift
index 8055ec08..ffbd2080 100644
--- a/apps/macos/Sources/MunkelApp/MenuView.swift
+++ b/apps/macos/Sources/MunkelApp/MenuView.swift
@@ -28,15 +28,15 @@ struct MenuView: View {
VStack(alignment: .leading, spacing: 12) {
header
- if model.githubUserLogin == nil {
- Text("Sign in with GitHub to use Munkel.")
+ if !model.isSignedIn {
+ Text("Sign in to use Munkel.")
.font(.callout)
.foregroundStyle(.secondary)
// Without this the popup truncates to one ellipsized
// line instead of wrapping.
.fixedSize(horizontal: false, vertical: true)
- githubArea
+ authArea
} else {
if model.groupCodes.isEmpty {
Text("No channels yet. Create one or join with a code.")
@@ -80,7 +80,7 @@ struct MenuView: View {
Divider()
- githubArea
+ authArea
}
}
.padding(14)
@@ -188,9 +188,9 @@ struct MenuView: View {
}
#endif
Divider()
- if model.githubUserLogin != nil {
+ if model.isSignedIn {
Button {
- model.logoutGitHub()
+ model.signOut()
} label: {
Label("Sign out", systemImage: "rectangle.portrait.and.arrow.right")
}
@@ -286,10 +286,10 @@ struct MenuView: View {
}
@ViewBuilder
- private var githubArea: some View {
- switch model.githubLoginState {
+ private var authArea: some View {
+ switch model.authFlow {
case .idle:
- if model.githubUserLogin != nil {
+ if model.isSignedIn {
HStack(spacing: 8) {
AvatarView(name: model.displayName, imageData: Identity.avatarData, size: 20, status: model.effectiveStatus)
Text(model.displayName)
@@ -299,27 +299,35 @@ struct MenuView: View {
statusPicker
}
} else {
- Button {
- model.startGitHubLogin()
- } label: {
- Label("Sign in with GitHub", systemImage: "person.crop.circle.badge.checkmark")
+ VStack(alignment: .leading, spacing: 8) {
+ Button {
+ model.startGitHubLogin()
+ } label: {
+ Label("Sign in with GitHub", systemImage: "person.crop.circle.badge.checkmark")
+ }
+ .disabled(!GitHubConfig.isConfigured)
+ .help(
+ GitHubConfig.isConfigured
+ ? "Fetches your username + avatar from GitHub (once, no account)"
+ : "No client ID configured — see README"
+ )
+ Button {
+ model.startAppleLogin()
+ } label: {
+ Label("Sign in with Apple", systemImage: "apple.logo")
+ }
+ .help("Sign in with your Apple Account")
}
- .disabled(!GitHubConfig.isConfigured)
- .help(
- GitHubConfig.isConfigured
- ? "Fetches your username + avatar from GitHub (once, no account)"
- : "No client ID configured — see README"
- )
}
- case .requestingCode:
+ case let .connecting(provider):
HStack(spacing: 8) {
ProgressView().controlSize(.small)
- Text("Connecting to GitHub…")
+ Text(provider == .apple ? "Signing in with Apple…" : "Connecting to GitHub…")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
- Button("Cancel") { model.cancelGitHubLogin() }
+ Button("Cancel") { model.cancelLogin() }
.controlSize(.small)
}
@@ -338,7 +346,7 @@ struct MenuView: View {
.foregroundStyle(.secondary)
.help("Copy code")
Spacer()
- Button("Cancel") { model.cancelGitHubLogin() }
+ Button("Cancel") { model.cancelLogin() }
.controlSize(.small)
}
Text(
@@ -355,10 +363,10 @@ struct MenuView: View {
.controlSize(.small)
}
- case .fetchingProfile:
+ case let .fetchingProfile(provider):
HStack(spacing: 8) {
ProgressView().controlSize(.small)
- Text("Loading GitHub profile…")
+ Text("Loading \(provider.displayName) profile…")
.font(.caption)
.foregroundStyle(.secondary)
}
@@ -369,9 +377,9 @@ struct MenuView: View {
.font(.caption)
.foregroundStyle(.red)
Spacer()
- Button("Retry") { model.startGitHubLogin() }
+ Button("Retry") { model.retryLogin() }
.controlSize(.small)
- Button("Dismiss") { model.cancelGitHubLogin() }
+ Button("Dismiss") { model.cancelLogin() }
.controlSize(.small)
}
}
diff --git a/apps/macos/Sources/MunkelApp/NotchPresenter.swift b/apps/macos/Sources/MunkelApp/NotchPresenter.swift
index 1ad68a71..a11e8489 100644
--- a/apps/macos/Sources/MunkelApp/NotchPresenter.swift
+++ b/apps/macos/Sources/MunkelApp/NotchPresenter.swift
@@ -656,7 +656,7 @@ final class NotchPresenter {
await previous?.value
// Idempotent: a panel is already up for this flow. This relies on
// every new `.awaitingUser` being preceded by a non-`.awaitingUser`
- // state (startGitHubLogin sets `.requestingCode`), which runs
+ // state (startGitHubLogin sets `.connecting`), which runs
// hideAuthCode and nils this first — so a fresh code always rebuilds.
guard let self, self.authCodeNotch == nil else { return }
// The notch slot holds one panel: tear down a lingering message /
diff --git a/apps/macos/make-bundle.sh b/apps/macos/make-bundle.sh
index ddcec6bd..d4da9a1b 100755
--- a/apps/macos/make-bundle.sh
+++ b/apps/macos/make-bundle.sh
@@ -92,10 +92,16 @@ fi
# Swift Bundler ad-hoc signs; re-sign with our identity (hardened runtime +
# secure timestamp are notarization prerequisites) or a clean ad-hoc signature.
+# Sign in with Apple needs the com.apple.developer.applesignin entitlement,
+# which only a real identity can carry (plus an App ID with the capability and,
+# for distribution, a provisioning profile). Ad-hoc builds skip it: the
+# entitlement wouldn't be honored anyway and GitHub sign-in still works.
+ENTITLEMENTS="Munkel.entitlements"
if [[ "$IDENTITY" == "-" ]]; then
codesign --force --sign - "$BUNDLE" >/dev/null 2>&1 || true
else
- codesign --force --options runtime --timestamp --sign "$IDENTITY" "$BUNDLE"
+ codesign --force --options runtime --timestamp \
+ --entitlements "$ENTITLEMENTS" --sign "$IDENTITY" "$BUNDLE"
fi
echo "built $BUNDLE ($VERSION)"