diff --git a/apps/desktop/build/entitlements.mas.child.plist b/apps/desktop/build/entitlements.mas.child.plist
index d8dc69e808..e4b6d54abf 100644
--- a/apps/desktop/build/entitlements.mas.child.plist
+++ b/apps/desktop/build/entitlements.mas.child.plist
@@ -6,5 +6,9 @@
com.apple.security.inherit
+ com.apple.developer.applesignin
+
+ Default
+
diff --git a/apps/desktop/build/entitlements.mas.plist b/apps/desktop/build/entitlements.mas.plist
index a101240dda..9e1e86bc98 100644
--- a/apps/desktop/build/entitlements.mas.plist
+++ b/apps/desktop/build/entitlements.mas.plist
@@ -10,5 +10,9 @@
com.apple.security.network.client
+ com.apple.developer.applesignin
+
+ Default
+
diff --git a/apps/desktop/forge.config.cts b/apps/desktop/forge.config.cts
index c060718dfa..7343217595 100644
--- a/apps/desktop/forge.config.cts
+++ b/apps/desktop/forge.config.cts
@@ -95,6 +95,9 @@ const noopAfterCopy = (_buildPath, _electronVersion, _platform, _arch, callback)
const ignorePattern = new RegExp(`^/node_modules/(?!${[...keepModules].join("|")})`)
+// For MAS builds, include the Apple Auth Helper (native Sign in with Apple is only available on MAS)
+const isMAS = platform === "mas"
+
const config: ForgeConfig = {
packagerConfig: {
name: isStaging ? "Folo Staging" : "Folo",
@@ -102,7 +105,11 @@ const config: ForgeConfig = {
buildVersion: process.env.BUILD_VERSION || undefined,
appBundleId: "is.follow",
icon: isStaging ? "resources/icon-staging" : "resources/icon",
- extraResource: ["./resources/app-update.yml"],
+ extraResource: [
+ "./resources/app-update.yml",
+ // Include Apple Auth Helper only for MAS builds (native Sign in with Apple)
+ ...(isMAS ? ["./resources/apple-auth-helper"] : []),
+ ],
protocols: [
{
name: "Folo",
diff --git a/apps/desktop/layer/main/src/ipc/services/auth.ts b/apps/desktop/layer/main/src/ipc/services/auth.ts
index ec4b277904..7964f6a5bd 100644
--- a/apps/desktop/layer/main/src/ipc/services/auth.ts
+++ b/apps/desktop/layer/main/src/ipc/services/auth.ts
@@ -1,7 +1,32 @@
+import { execFile } from "node:child_process"
+import { fileURLToPath } from "node:url"
+import { promisify } from "node:util"
+
+import { app } from "electron"
import type { IpcContext } from "electron-ipc-decorator"
import { IpcMethod, IpcService } from "electron-ipc-decorator"
+import path from "pathe"
+import { isMAS } from "../../env"
import { deleteNotificationsToken, updateNotificationsToken } from "../../lib/user"
+import { logger } from "../../logger"
+
+const execFileAsync = promisify(execFile)
+
+export interface NativeAppleAuthResult {
+ success: boolean
+ data?: {
+ identityToken: string
+ authorizationCode: string
+ user: string
+ email?: string
+ fullName?: {
+ givenName?: string
+ familyName?: string
+ }
+ }
+ error?: string
+}
export class AuthService extends IpcService {
static override readonly groupName = "auth"
@@ -15,4 +40,97 @@ export class AuthService extends IpcService {
async signOut(_context: IpcContext): Promise {
await deleteNotificationsToken()
}
+
+ /**
+ * Performs native Sign in with Apple using the macOS AuthenticationServices framework.
+ * This is only available on Mac App Store (MAS) builds.
+ * Returns the Apple ID credential including the identity token for server-side verification.
+ */
+ @IpcMethod()
+ async signInWithApple(_context: IpcContext): Promise {
+ if (!isMAS) {
+ return {
+ success: false,
+ error: "Native Sign in with Apple is only available on Mac App Store builds",
+ }
+ }
+
+ try {
+ const __dirname = fileURLToPath(new URL(".", import.meta.url))
+ // In production, the helper is in the Resources directory
+ // In development, we need to find it relative to the source
+ // Path from: apps/desktop/layer/main/src/ipc/services/
+ // To: apps/desktop/resources/apple-auth-helper/
+ // The helper is packaged as an .app bundle to have a proper Bundle ID for Sign in with Apple
+ const helperPath = app.isPackaged
+ ? path.join(
+ process.resourcesPath,
+ "apple-auth-helper",
+ "AppleAuthHelper.app",
+ "Contents",
+ "MacOS",
+ "AppleAuthHelper",
+ )
+ : path.resolve(
+ __dirname,
+ "../../../../../resources/apple-auth-helper/AppleAuthHelper.app/Contents/MacOS/AppleAuthHelper",
+ )
+
+ logger.info("Executing AppleAuthHelper", { helperPath })
+
+ const { stdout, stderr } = await execFileAsync(helperPath, [], {
+ timeout: 120000, // 2 minutes timeout for user interaction
+ })
+
+ if (stderr) {
+ logger.warn("AppleAuthHelper stderr", { stderr })
+ }
+
+ const result = JSON.parse(stdout) as NativeAppleAuthResult
+ logger.info("AppleAuthHelper result", { success: result.success, hasData: !!result.data })
+
+ return result
+ } catch (error) {
+ logger.error("Failed to execute AppleAuthHelper", { error })
+
+ // When the helper exits with non-zero status, execFileAsync rejects
+ // but the error object still contains stdout with the JSON result
+ if (error && typeof error === "object" && "stdout" in error) {
+ const { stdout } = error as { stdout?: string }
+ if (stdout) {
+ try {
+ const result = JSON.parse(stdout) as NativeAppleAuthResult
+ logger.info("AppleAuthHelper result from error.stdout", {
+ success: result.success,
+ error: result.error,
+ })
+ return result
+ } catch {
+ // Failed to parse stdout, fall through to generic error handling
+ }
+ }
+ }
+
+ if (error instanceof Error) {
+ return {
+ success: false,
+ error: error.message,
+ }
+ }
+
+ return {
+ success: false,
+ error: "Unknown error occurred during Sign in with Apple",
+ }
+ }
+ }
+
+ /**
+ * Check if native Sign in with Apple is available.
+ * This is only true on Mac App Store (MAS) builds.
+ */
+ @IpcMethod()
+ isNativeAppleAuthAvailable(_context: IpcContext): boolean {
+ return isMAS
+ }
}
diff --git a/apps/desktop/layer/renderer/src/lib/auth.ts b/apps/desktop/layer/renderer/src/lib/auth.ts
index 09e308de83..1c530a3715 100644
--- a/apps/desktop/layer/renderer/src/lib/auth.ts
+++ b/apps/desktop/layer/renderer/src/lib/auth.ts
@@ -1,8 +1,11 @@
+import type { LoginRuntime } from "@follow/shared/auth"
import { Auth } from "@follow/shared/auth"
import { env } from "@follow/shared/env.desktop"
import { createDesktopAPIHeaders } from "@follow/utils/headers"
import PKG from "@pkg"
+import { ipcServices } from "./client"
+
const headers = createDesktopAPIHeaders({ version: PKG.version })
const auth = new Auth({
@@ -38,4 +41,66 @@ export const {
updateUser,
} = auth.authClient
-export const { loginHandler } = auth
+/**
+ * Enhanced login handler that supports native Sign in with Apple on Mac App Store builds.
+ * For Apple provider on MAS builds, it uses the native AuthenticationServices
+ * framework to get an identity token, then authenticates with the server using the idToken flow.
+ * Non-MAS builds (DMG) use web-based Apple Sign In.
+ */
+export const loginHandler = async (
+ provider: string,
+ runtime?: LoginRuntime,
+ args?: {
+ email?: string
+ password?: string
+ headers?: Record
+ },
+) => {
+ // Check if we should use native Apple Sign In (only available on MAS builds)
+ if (provider === "apple" && ipcServices) {
+ console.info("[Apple Auth] Checking native availability...")
+ try {
+ const isNativeAvailable = await ipcServices.auth.isNativeAppleAuthAvailable()
+ console.info("[Apple Auth] isNativeAvailable:", isNativeAvailable)
+
+ if (isNativeAvailable) {
+ console.info("[Apple Auth] Calling signInWithApple...")
+ const result = await ipcServices.auth.signInWithApple()
+ console.info("[Apple Auth] signInWithApple result:", {
+ success: result.success,
+ hasData: !!result.data,
+ error: result.error,
+ })
+
+ if (!result.success || !result.data) {
+ // If user canceled, just return silently
+ if (result.error?.includes("canceled")) {
+ console.info("[Apple Auth] User canceled, returning silently")
+ return
+ }
+ console.error("[Apple Auth] Native sign in failed:", result.error)
+ throw new Error(result.error || "Failed to sign in with Apple")
+ }
+
+ console.info("[Apple Auth] Got identity token, authenticating with server...")
+ // Use the identity token to authenticate with the server
+ // The idToken flow in better-auth doesn't redirect, it authenticates directly
+ return authClient.signIn.social({
+ provider: "apple",
+ idToken: {
+ token: result.data.identityToken,
+ },
+ })
+ } else {
+ console.info("[Apple Auth] Native not available, falling back to web")
+ }
+ } catch (error) {
+ console.error("[Apple Auth] Native Apple Sign In failed:", error)
+ // Fall through to web-based Apple Sign In
+ }
+ }
+
+ // Use the default login handler for all other cases
+ console.info("[Apple Auth] Using web-based login handler")
+ return auth.loginHandler(provider, runtime, args)
+}
diff --git a/apps/desktop/resources/apple-auth-helper/AppleAuthHelper.app/Contents/Info.plist b/apps/desktop/resources/apple-auth-helper/AppleAuthHelper.app/Contents/Info.plist
new file mode 100644
index 0000000000..4720e092a5
--- /dev/null
+++ b/apps/desktop/resources/apple-auth-helper/AppleAuthHelper.app/Contents/Info.plist
@@ -0,0 +1,28 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleExecutable
+ AppleAuthHelper
+ CFBundleIdentifier
+ is.follow
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ AppleAuthHelper
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ 1.0
+ CFBundleVersion
+ 1
+ LSMinimumSystemVersion
+ 11.0
+ LSUIElement
+
+ NSHighResolutionCapable
+
+
+
diff --git a/apps/desktop/resources/apple-auth-helper/AppleAuthHelper.app/Contents/MacOS/AppleAuthHelper b/apps/desktop/resources/apple-auth-helper/AppleAuthHelper.app/Contents/MacOS/AppleAuthHelper
new file mode 100755
index 0000000000..e804203805
Binary files /dev/null and b/apps/desktop/resources/apple-auth-helper/AppleAuthHelper.app/Contents/MacOS/AppleAuthHelper differ
diff --git a/apps/desktop/resources/apple-auth-helper/AppleAuthHelper.swift b/apps/desktop/resources/apple-auth-helper/AppleAuthHelper.swift
new file mode 100644
index 0000000000..60ebe261a2
--- /dev/null
+++ b/apps/desktop/resources/apple-auth-helper/AppleAuthHelper.swift
@@ -0,0 +1,253 @@
+import AuthenticationServices
+import Foundation
+
+// MARK: - Apple Sign In Helper for Electron
+
+/// A helper tool that performs native Sign in with Apple authentication.
+/// Outputs JSON with the authentication result or error.
+
+class AppleSignInHelper: NSObject, ASAuthorizationControllerDelegate, ASAuthorizationControllerPresentationContextProviding {
+ private var result: Result?
+ private var presentationWindow: NSWindow?
+ private var authController: ASAuthorizationController?
+
+ struct AppleAuthResult: Codable {
+ let identityToken: String
+ let authorizationCode: String
+ let user: String
+ let email: String?
+ let fullName: FullName?
+
+ struct FullName: Codable {
+ let givenName: String?
+ let familyName: String?
+ }
+ }
+
+ enum AppleSignInError: Error, LocalizedError {
+ case canceled
+ case failed(String)
+ case invalidCredential
+ case unknown
+
+ var errorDescription: String? {
+ switch self {
+ case .canceled:
+ return "User canceled the Sign in with Apple request"
+ case .failed(let message):
+ return message
+ case .invalidCredential:
+ return "Invalid credential received from Apple"
+ case .unknown:
+ return "Unknown error occurred"
+ }
+ }
+ }
+
+ func startSignIn() {
+ fputs("[AppleAuthHelper] Creating authorization request...\n", stderr)
+
+ let appleIDProvider = ASAuthorizationAppleIDProvider()
+ let request = appleIDProvider.createRequest()
+ request.requestedScopes = [.fullName, .email]
+
+ let controller = ASAuthorizationController(authorizationRequests: [request])
+ controller.delegate = self
+ controller.presentationContextProvider = self
+
+ // Keep a strong reference to prevent premature deallocation
+ self.authController = controller
+
+ fputs("[AppleAuthHelper] Performing authorization request...\n", stderr)
+ controller.performRequests()
+ }
+
+ private func complete(with result: Result) {
+ fputs("[AppleAuthHelper] Completing with result...\n", stderr)
+ self.result = result
+
+ // Close the presentation window and stop the app on the main thread
+ DispatchQueue.main.async {
+ self.presentationWindow?.close()
+ self.presentationWindow = nil
+ NSApp.stop(nil)
+ }
+ }
+
+ func getResult() -> Result {
+ return result ?? .failure(AppleSignInError.unknown)
+ }
+
+ // MARK: - ASAuthorizationControllerDelegate
+
+ func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
+ fputs("[AppleAuthHelper] Authorization completed successfully\n", stderr)
+
+ guard let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential else {
+ complete(with: .failure(AppleSignInError.invalidCredential))
+ return
+ }
+
+ guard let identityTokenData = appleIDCredential.identityToken,
+ let identityToken = String(data: identityTokenData, encoding: .utf8) else {
+ complete(with: .failure(AppleSignInError.failed("Failed to get identity token")))
+ return
+ }
+
+ guard let authorizationCodeData = appleIDCredential.authorizationCode,
+ let authorizationCode = String(data: authorizationCodeData, encoding: .utf8) else {
+ complete(with: .failure(AppleSignInError.failed("Failed to get authorization code")))
+ return
+ }
+
+ var fullName: AppleAuthResult.FullName?
+ if let name = appleIDCredential.fullName {
+ fullName = AppleAuthResult.FullName(
+ givenName: name.givenName,
+ familyName: name.familyName
+ )
+ }
+
+ let authResult = AppleAuthResult(
+ identityToken: identityToken,
+ authorizationCode: authorizationCode,
+ user: appleIDCredential.user,
+ email: appleIDCredential.email,
+ fullName: fullName
+ )
+
+ complete(with: .success(authResult))
+ }
+
+ func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) {
+ fputs("[AppleAuthHelper] Authorization failed with error: \(error.localizedDescription)\n", stderr)
+
+ if let authError = error as? ASAuthorizationError {
+ switch authError.code {
+ case .canceled:
+ complete(with: .failure(AppleSignInError.canceled))
+ case .failed:
+ complete(with: .failure(AppleSignInError.failed("Authorization failed: \(error.localizedDescription)")))
+ case .invalidResponse:
+ complete(with: .failure(AppleSignInError.failed("Invalid response from Apple: \(error.localizedDescription)")))
+ case .notHandled:
+ complete(with: .failure(AppleSignInError.failed("Request not handled: \(error.localizedDescription)")))
+ case .notInteractive:
+ complete(with: .failure(AppleSignInError.failed("Not interactive: \(error.localizedDescription)")))
+ case .unknown:
+ complete(with: .failure(AppleSignInError.failed("Unknown error (code: \(authError.code.rawValue)): \(error.localizedDescription)")))
+ case .matchedExcludedCredential:
+ complete(with: .failure(AppleSignInError.failed("Matched excluded credential: \(error.localizedDescription)")))
+ case .credentialImport:
+ complete(with: .failure(AppleSignInError.failed("Credential import error: \(error.localizedDescription)")))
+ case .credentialExport:
+ complete(with: .failure(AppleSignInError.failed("Credential export error: \(error.localizedDescription)")))
+ case .preferSignInWithApple:
+ complete(with: .failure(AppleSignInError.failed("Prefer Sign in with Apple: \(error.localizedDescription)")))
+ case .deviceNotConfiguredForPasskeyCreation:
+ complete(with: .failure(AppleSignInError.failed("Device not configured for passkey creation: \(error.localizedDescription)")))
+ @unknown default:
+ complete(with: .failure(AppleSignInError.failed("Unknown authorization error (code: \(authError.code.rawValue)): \(error.localizedDescription)")))
+ }
+ } else {
+ complete(with: .failure(AppleSignInError.failed("Non-authorization error: \(error.localizedDescription)")))
+ }
+ }
+
+ // MARK: - ASAuthorizationControllerPresentationContextProviding
+
+ func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
+ fputs("[AppleAuthHelper] Creating presentation anchor...\n", stderr)
+
+ // Return an existing window if available
+ if let window = NSApplication.shared.keyWindow {
+ fputs("[AppleAuthHelper] Using existing key window\n", stderr)
+ return window
+ }
+
+ // Create a minimal transparent window as the anchor for the Sign in with Apple sheet
+ // The system's Sign in with Apple UI appears as a popover anchored to this window
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 1, height: 1),
+ styleMask: [], // No title bar, no decorations
+ backing: .buffered,
+ defer: false
+ )
+ window.isOpaque = false
+ window.backgroundColor = .clear
+ window.hasShadow = false
+ window.level = .modalPanel
+ window.center()
+ window.orderFrontRegardless()
+
+ self.presentationWindow = window
+ fputs("[AppleAuthHelper] Created minimal transparent window\n", stderr)
+ return window
+ }
+}
+
+// MARK: - Main Entry Point
+
+struct OutputResult: Codable {
+ let success: Bool
+ let data: AppleSignInHelper.AppleAuthResult?
+ let error: String?
+}
+
+fputs("[AppleAuthHelper] Starting...\n", stderr)
+
+// Initialize the application
+let app = NSApplication.shared
+
+// Use .accessory so the app doesn't appear in the Dock
+// but can still present system UI elements like Sign in with Apple
+app.setActivationPolicy(.accessory)
+
+// Activate the app to allow system dialogs to appear
+app.activate(ignoringOtherApps: true)
+
+fputs("[AppleAuthHelper] App initialized with accessory policy, starting sign in...\n", stderr)
+
+let helper = AppleSignInHelper()
+helper.startSignIn()
+
+// Run the standard Cocoa event loop
+// This properly handles all events including Touch ID authentication
+// The loop exits when NSApp.stop() is called from the completion handler
+fputs("[AppleAuthHelper] Running app event loop...\n", stderr)
+app.run()
+
+fputs("[AppleAuthHelper] App event loop ended\n", stderr)
+
+// Get the result after the run loop exits
+let result = helper.getResult()
+
+let output: OutputResult
+switch result {
+case .success(let authResult):
+ output = OutputResult(success: true, data: authResult, error: nil)
+case .failure(let error):
+ output = OutputResult(success: false, data: nil, error: error.localizedDescription)
+}
+
+// Output JSON to stdout
+let encoder = JSONEncoder()
+encoder.outputFormatting = .prettyPrinted
+if let jsonData = try? encoder.encode(output),
+ let jsonString = String(data: jsonData, encoding: .utf8) {
+ print(jsonString)
+} else {
+ print("{\"success\": false, \"error\": \"Failed to encode result\"}")
+}
+
+fputs("[AppleAuthHelper] Done\n", stderr)
+
+// Exit with appropriate code
+exit(result.isSuccess ? 0 : 1)
+
+extension Result {
+ var isSuccess: Bool {
+ if case .success = self { return true }
+ return false
+ }
+}
diff --git a/apps/desktop/resources/apple-auth-helper/build.sh b/apps/desktop/resources/apple-auth-helper/build.sh
new file mode 100755
index 0000000000..14e7d5b4b0
--- /dev/null
+++ b/apps/desktop/resources/apple-auth-helper/build.sh
@@ -0,0 +1,82 @@
+#!/bin/bash
+
+# Build script for AppleAuthHelper
+# This script compiles the Swift helper for Sign in with Apple and packages it as an .app bundle
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+OUTPUT_DIR="${SCRIPT_DIR}"
+OUTPUT_NAME="AppleAuthHelper"
+APP_BUNDLE="${OUTPUT_DIR}/${OUTPUT_NAME}.app"
+
+echo "Building AppleAuthHelper..."
+
+# Clean up old bundle if exists
+rm -rf "${APP_BUNDLE}"
+
+# Create .app bundle structure
+mkdir -p "${APP_BUNDLE}/Contents/MacOS"
+
+# Compile for both architectures (universal binary)
+swiftc \
+ -O \
+ -target arm64-apple-macos11.0 \
+ -o "${OUTPUT_DIR}/${OUTPUT_NAME}-arm64" \
+ "${SCRIPT_DIR}/AppleAuthHelper.swift"
+
+swiftc \
+ -O \
+ -target x86_64-apple-macos11.0 \
+ -o "${OUTPUT_DIR}/${OUTPUT_NAME}-x86_64" \
+ "${SCRIPT_DIR}/AppleAuthHelper.swift"
+
+# Create universal binary directly in the bundle
+lipo -create \
+ "${OUTPUT_DIR}/${OUTPUT_NAME}-arm64" \
+ "${OUTPUT_DIR}/${OUTPUT_NAME}-x86_64" \
+ -output "${APP_BUNDLE}/Contents/MacOS/${OUTPUT_NAME}"
+
+# Clean up architecture-specific binaries
+rm "${OUTPUT_DIR}/${OUTPUT_NAME}-arm64" "${OUTPUT_DIR}/${OUTPUT_NAME}-x86_64"
+
+# Remove old standalone binary if exists
+rm -f "${OUTPUT_DIR}/${OUTPUT_NAME}"
+
+# Generate Info.plist directly in the bundle
+# (Do NOT keep Info.plist at the directory level, or Apple will treat the whole directory as a bundle)
+cat > "${APP_BUNDLE}/Contents/Info.plist" << 'PLIST'
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleExecutable
+ AppleAuthHelper
+ CFBundleIdentifier
+ is.follow
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ AppleAuthHelper
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ 1.0
+ CFBundleVersion
+ 1
+ LSMinimumSystemVersion
+ 11.0
+ LSUIElement
+
+ NSHighResolutionCapable
+
+
+
+PLIST
+
+# Make executable
+chmod +x "${APP_BUNDLE}/Contents/MacOS/${OUTPUT_NAME}"
+
+echo "Build complete: ${APP_BUNDLE}"