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)"