Skip to content
Draft
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
4 changes: 4 additions & 0 deletions apps/desktop/build/entitlements.mas.child.plist
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,9 @@
<true/>
<key>com.apple.security.inherit</key>
<true/>
<key>com.apple.developer.applesignin</key>
<array>
<string>Default</string>
</array>
</dict>
</plist>
4 changes: 4 additions & 0 deletions apps/desktop/build/entitlements.mas.plist
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,9 @@
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.developer.applesignin</key>
<array>
<string>Default</string>
</array>
Comment thread
DIYgod marked this conversation as resolved.
</dict>
</plist>
9 changes: 8 additions & 1 deletion apps/desktop/forge.config.cts
Original file line number Diff line number Diff line change
Expand Up @@ -95,14 +95,21 @@

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",
appCategoryType: "public.app-category.news",
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",
Expand Down Expand Up @@ -268,7 +275,7 @@
files: [],
}
let basePath = ""
makeResults = makeResults.map((result) => {

Check warning on line 278 in apps/desktop/forge.config.cts

View workflow job for this annotation

GitHub Actions / Format, Lint and Typecheck (lts/*)

Assignment to function parameter 'makeResults'
result.artifacts = result.artifacts
.map((artifact) => {
if (artifactRegex.test(artifact)) {
Expand Down
118 changes: 118 additions & 0 deletions apps/desktop/layer/main/src/ipc/services/auth.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -15,4 +40,97 @@ export class AuthService extends IpcService {
async signOut(_context: IpcContext): Promise<void> {
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<NativeAppleAuthResult> {
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
}
}
67 changes: 66 additions & 1 deletion apps/desktop/layer/renderer/src/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down Expand Up @@ -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<string, string>
},
) => {
// 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)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?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>en</string>
<key>CFBundleExecutable</key>
<string>AppleAuthHelper</string>
<key>CFBundleIdentifier</key>
<string>is.follow</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>AppleAuthHelper</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>11.0</string>
<key>LSUIElement</key>
<true/>
<key>NSHighResolutionCapable</key>
<true/>
</dict>
</plist>
Binary file not shown.
Loading
Loading