From fe25c3e99539b0344d7cc55c063d4e3747d341b2 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Thu, 10 Apr 2025 20:49:11 +0800 Subject: [PATCH] feat: support native sign-in-with-apple on macOS (#856) --- Cargo.lock | 18 ++ apps/readest-app/package.json | 2 +- apps/readest-app/src-tauri/Cargo.toml | 5 +- apps/readest-app/src-tauri/src/apple_auth.rs | 201 ++++++++++++++++++ apps/readest-app/src-tauri/src/lib.rs | 6 + apps/readest-app/src/app/auth/page.tsx | 27 ++- .../src/app/auth/utils/appleIdAuth.ts | 68 +++++- .../src/app/library/hooks/useDemoBooks.ts | 2 +- 8 files changed, 308 insertions(+), 21 deletions(-) create mode 100644 apps/readest-app/src-tauri/src/apple_auth.rs diff --git a/Cargo.lock b/Cargo.lock index ed778281..b9e5f74a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,9 @@ dependencies = [ "futures-util", "log", "objc", + "objc2 0.6.0", + "objc2-authentication-services", + "objc2-foundation 0.3.0", "rand 0.8.5", "read-progress-stream", "reqwest", @@ -3042,6 +3045,21 @@ dependencies = [ "objc2-quartz-core 0.3.0", ] +[[package]] +name = "objc2-authentication-services" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8963a73216bfe9565fe78e86fcc908b65232468d195b95346d2489110a60fca" +dependencies = [ + "bitflags 2.9.0", + "block2 0.6.0", + "objc2 0.6.0", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.0", + "objc2-security", +] + [[package]] name = "objc2-cloud-kit" version = "0.3.0" diff --git a/apps/readest-app/package.json b/apps/readest-app/package.json index 62a7d703..cd2810ea 100644 --- a/apps/readest-app/package.json +++ b/apps/readest-app/package.json @@ -1,6 +1,6 @@ { "name": "@readest/readest-app", - "version": "0.9.32", + "version": "0.9.33", "private": true, "scripts": { "dev": "dotenv -e .env.tauri -- next dev", diff --git a/apps/readest-app/src-tauri/Cargo.toml b/apps/readest-app/src-tauri/Cargo.toml index 6d148405..ca775cfc 100644 --- a/apps/readest-app/src-tauri/Cargo.toml +++ b/apps/readest-app/src-tauri/Cargo.toml @@ -48,9 +48,12 @@ tauri-plugin-haptics = "2.2.4" tauri-plugin-native-bridge = { path = "./plugins/tauri-plugin-native-bridge" } [target."cfg(target_os = \"macos\")".dependencies] +rand = "0.8" cocoa = "0.25" objc = "0.2.7" -rand = "0.8" +objc2 = "0.6" +objc2-authentication-services = "0.3" +objc2-foundation = { version = "0.3", features = ["NSError", "NSArray"] } [target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies] tauri-plugin-cli = "2" diff --git a/apps/readest-app/src-tauri/src/apple_auth.rs b/apps/readest-app/src-tauri/src/apple_auth.rs new file mode 100644 index 00000000..f67c267d --- /dev/null +++ b/apps/readest-app/src-tauri/src/apple_auth.rs @@ -0,0 +1,201 @@ +use std::cell::RefCell; + +use objc2::rc::Retained; +use objc2::runtime::ProtocolObject; +use objc2::{ + define_class, msg_send, AllocAnyThread, DefinedClass, MainThreadMarker, MainThreadOnly, +}; +use objc2_authentication_services::{ + ASAuthorization, ASAuthorizationAppleIDCredential, ASAuthorizationAppleIDProvider, + ASAuthorizationController, ASAuthorizationControllerDelegate, ASAuthorizationRequest, + ASAuthorizationScopeEmail, ASAuthorizationScopeFullName, +}; +use objc2_foundation::{NSArray, NSError, NSObject, NSObjectProtocol, NSString}; + +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use tauri::{command, AppHandle, Emitter}; + +#[derive(Debug, Deserialize, Serialize)] +pub struct AppleIDAuthorizationRequest { + pub scope: Vec, + pub nonce: Option, + pub state: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct AppleIDAuthorizationResponse { + pub user_identifier: Option, + pub given_name: Option, + pub family_name: Option, + pub email: Option, + pub authorization_code: Option, + pub identity_token: Option, + pub state: Option, +} + +thread_local! { + static APPLE_SIGN_IN_DELEGATE: RefCell>> = RefCell::new(None); + static AUTHORIZATION_CONTROLLER: RefCell>> = RefCell::new(None); +} + +#[derive(Clone)] +pub struct Ivars { + app: AppHandle, +} + +define_class!( + #[unsafe(super(NSObject))] + #[thread_kind = MainThreadOnly] + #[name = "ASAuthorizationControllerDelegateImpl"] + #[ivars = Ivars] + pub struct ASAuthorizationControllerDelegateImpl; + + unsafe impl NSObjectProtocol for ASAuthorizationControllerDelegateImpl {} + + unsafe impl ASAuthorizationControllerDelegate for ASAuthorizationControllerDelegateImpl { + #[unsafe(method(authorizationController:didCompleteWithAuthorization:))] + #[allow(non_snake_case)] + unsafe fn authorizationController_didCompleteWithAuthorization( + &self, + _controller: &ASAuthorizationController, + authorization: &ASAuthorization, + ) { + if let Ok(credential) = authorization + .credential() + .downcast::() + { + let user_identifier = { + let user = credential.user(); + if user.len() > 0 { + Some(user.to_string()) + } else { + None + } + }; + let given_name = credential + .fullName() + .and_then(|name| name.givenName()) + .map(|nsstr| nsstr.to_string()); + let family_name = credential + .fullName() + .and_then(|name| name.familyName()) + .map(|nsstr| nsstr.to_string()); + let email = credential.email().map(|nsstr| nsstr.to_string()); + + let authorization_code = credential + .authorizationCode() + .and_then(|code_data| String::from_utf8(code_data.to_vec()).ok()); + + let identity_token = credential + .identityToken() + .and_then(|token_data| String::from_utf8(token_data.to_vec()).ok()); + + let state = credential.state().map(|nsstr| nsstr.to_string()); + + let resp = AppleIDAuthorizationResponse { + user_identifier, + given_name, + family_name, + email, + authorization_code, + identity_token, + state, + }; + let _ = self.ivars().app.emit("apple-sign-in-complete", json!(resp)); + log::info!("Apple authorization complete"); + } else { + let _ = self + .ivars() + .app + .emit("apple-sign-in-error", "Invalid credential type"); + log::error!("Invalid credential type received"); + } + } + + #[unsafe(method(authorizationController:didCompleteWithError:))] + #[allow(non_snake_case)] + unsafe fn authorizationController_didCompleteWithError( + &self, + _controller: &ASAuthorizationController, + error: &NSError, + ) { + let code = error.code(); + let description = error.localizedDescription().to_string(); + let _ = self.ivars().app.emit( + "apple-sign-in-error", + json!({ + "code": code, + "description": description, + }), + ); + + log::error!( + "Authorization error: code={}, description={}", + code, + description + ); + } + } +); + +impl ASAuthorizationControllerDelegateImpl { + fn new(app: AppHandle) -> Retained { + let mtm = MainThreadMarker::new().expect("must run on main thread"); + let this = Self::alloc(mtm).set_ivars(Ivars { app }); + unsafe { msg_send![super(this), init] } + } +} + +#[command] +pub fn start_apple_sign_in(app: AppHandle, payload: AppleIDAuthorizationRequest) { + unsafe { + let provider = ASAuthorizationAppleIDProvider::new(); + let request = provider.createRequest(); + + if let Some(ref nonce) = payload.nonce { + let ns_nonce = NSString::from_str(nonce); + request.setNonce(Some(&ns_nonce)); + } + + if let Some(ref state) = payload.state { + let ns_state = NSString::from_str(state); + request.setState(Some(&ns_state)); + } + + let mut scopes = Vec::new(); + for scope in payload.scope.iter() { + match scope.as_str() { + "email" => scopes.push(ASAuthorizationScopeEmail), + "fullName" => scopes.push(ASAuthorizationScopeFullName), + _ => { + log::warn!("[Apple Sign-In] Unsupported scope: {}", scope); + } + } + } + if !scopes.is_empty() { + request.setRequestedScopes(Some(&*NSArray::from_slice(&scopes))); + } + + let auth_request = &request as &ASAuthorizationRequest; + let controller = ASAuthorizationController::initWithAuthorizationRequests( + ASAuthorizationController::alloc(), + &*NSArray::from_slice(&[auth_request]), + ); + + let delegate = ASAuthorizationControllerDelegateImpl::new(app.clone()); + APPLE_SIGN_IN_DELEGATE.with(|cell| { + *cell.borrow_mut() = Some(delegate.clone()); + }); + + AUTHORIZATION_CONTROLLER.with(|cell| { + *cell.borrow_mut() = Some(controller.clone()); + }); + + controller.setDelegate(Some(ProtocolObject::from_ref(&*delegate))); + + log::info!("Starting Apple Sign-In authorization request"); + controller.performRequests(); + } +} diff --git a/apps/readest-app/src-tauri/src/lib.rs b/apps/readest-app/src-tauri/src/lib.rs index db646709..f5b632f6 100644 --- a/apps/readest-app/src-tauri/src/lib.rs +++ b/apps/readest-app/src-tauri/src/lib.rs @@ -6,6 +6,10 @@ extern crate cocoa; #[macro_use] extern crate objc; +#[cfg(target_os = "macos")] +mod apple_auth; +#[cfg(target_os = "macos")] +use apple_auth::start_apple_sign_in; #[cfg(target_os = "macos")] mod menu; #[cfg(target_os = "macos")] @@ -135,6 +139,8 @@ pub fn run() { download_file, upload_file, #[cfg(target_os = "macos")] + start_apple_sign_in, + #[cfg(target_os = "macos")] set_traffic_lights, #[cfg(desktop)] list_fonts diff --git a/apps/readest-app/src/app/auth/page.tsx b/apps/readest-app/src/app/auth/page.tsx index 0eb604f3..699160fd 100644 --- a/apps/readest-app/src/app/auth/page.tsx +++ b/apps/readest-app/src/app/auth/page.tsx @@ -43,6 +43,7 @@ interface ProviderLoginProp { const WEB_AUTH_CALLBACK = `${READEST_WEB_BASE_URL}/auth/callback`; const DEEPLINK_CALLBACK = 'readest://auth-callback'; +const USE_APPLE_SIGN_IN = process.env['NEXT_PUBLIC_USE_APPLE_SIGN_IN'] === 'true'; const ProviderLogin: React.FC = ({ provider, handleSignIn, Icon, label }) => { return ( @@ -74,7 +75,7 @@ export default function AuthPage() { const headerRef = useRef(null); const getTauriRedirectTo = (isOAuth: boolean) => { - if (process.env.NODE_ENV === 'production' || appService?.isMobile) { + if (process.env.NODE_ENV === 'production' || appService?.isMobile || USE_APPLE_SIGN_IN) { if (appService?.isMobile) { return isOAuth ? DEEPLINK_CALLBACK : WEB_AUTH_CALLBACK; } @@ -97,15 +98,19 @@ export default function AuthPage() { const request = { scope: ['fullName', 'email'] as Scope[], }; - const appleAuthResponse = await getAppleIdAuth(request); - if (appleAuthResponse.identityToken) { - const { error } = await supabase.auth.signInWithIdToken({ - provider: 'apple', - token: appleAuthResponse.identityToken, - }); - if (error) { - console.error('Authentication error:', error); + if (appService?.isIOSApp || USE_APPLE_SIGN_IN) { + const appleAuthResponse = await getAppleIdAuth(request); + if (appleAuthResponse.identityToken) { + const { error } = await supabase.auth.signInWithIdToken({ + provider: 'apple', + token: appleAuthResponse.identityToken, + }); + if (error) { + console.error('Authentication error:', error); + } } + } else { + console.log('Sign in with Apple on this platform is not supported yet'); } }; @@ -161,7 +166,7 @@ export default function AuthPage() { const startTauriOAuth = async () => { try { - if (process.env.NODE_ENV === 'production' || appService?.isMobile) { + if (process.env.NODE_ENV === 'production' || appService?.isMobile || USE_APPLE_SIGN_IN) { const { getCurrentWindow } = await import('@tauri-apps/api/window'); const currentWindow = getCurrentWindow(); currentWindow.listen('single-instance', ({ event, payload }) => { @@ -345,7 +350,7 @@ export default function AuthPage() { /> diff --git a/apps/readest-app/src/app/auth/utils/appleIdAuth.ts b/apps/readest-app/src/app/auth/utils/appleIdAuth.ts index 2f3ad1ba..797fa1ea 100644 --- a/apps/readest-app/src/app/auth/utils/appleIdAuth.ts +++ b/apps/readest-app/src/app/auth/utils/appleIdAuth.ts @@ -1,4 +1,6 @@ import { invoke } from '@tauri-apps/api/core'; +import { listen } from '@tauri-apps/api/event'; +import { type as osType } from '@tauri-apps/plugin-os'; export type Scope = 'fullName' | 'email'; export interface AppleIDAuthorizationRequest { @@ -20,15 +22,67 @@ export interface AppleIDAuthorizationResponse { state: string | null; } +type NativeSuccess = { + authorization_code: string; + identity_token: string | null; + user_identifier: string | null; + given_name: string | null; + family_name: string | null; + email: string | null; + state: string | null; +}; + export async function getAppleIdAuth( request: AppleIDAuthorizationRequest, ): Promise { - const result = await invoke( - 'plugin:sign-in-with-apple|get_apple_id_credential', - { - payload: request, - }, - ); + const OS_TYPE = osType(); + if (OS_TYPE === 'ios') { + const result = await invoke( + 'plugin:sign-in-with-apple|get_apple_id_credential', + { + payload: request, + }, + ); - return result; + return result; + } else if (OS_TYPE === 'macos') { + return new Promise(async (resolve, reject) => { + const unlistenComplete = await listen( + 'apple-sign-in-complete', + ({ payload }) => { + cleanup(); + resolve({ + userIdentifier: payload.user_identifier, + authorizationCode: payload.authorization_code, + identityToken: payload.identity_token, + givenName: payload.given_name, + familyName: payload.family_name, + email: payload.email, + state: payload.state, + }); + }, + ); + + const unlistenError = await listen('apple-sign-in-error', ({ payload }) => { + cleanup(); + reject( + typeof payload === 'string' ? new Error(payload) : new Error('Apple sign‑in failed'), + ); + }); + + function cleanup() { + unlistenComplete(); + unlistenError(); + } + + try { + await invoke('start_apple_sign_in', { payload: request }); + } catch (err) { + cleanup(); + reject(err); + } + }); + } else { + throw new Error('Unsupported platform'); + } } diff --git a/apps/readest-app/src/app/library/hooks/useDemoBooks.ts b/apps/readest-app/src/app/library/hooks/useDemoBooks.ts index 4990db2e..93fdfa8f 100644 --- a/apps/readest-app/src/app/library/hooks/useDemoBooks.ts +++ b/apps/readest-app/src/app/library/hooks/useDemoBooks.ts @@ -19,7 +19,6 @@ interface DemoBooks { export const useDemoBooks = () => { const { envConfig } = useEnv(); - const userLang = getUserLang() as keyof typeof libraries; const [books, setBooks] = useState([]); const isLoading = useRef(false); @@ -27,6 +26,7 @@ export const useDemoBooks = () => { if (isLoading.current) return; isLoading.current = true; + const userLang = getUserLang() as keyof typeof libraries; const fetchDemoBooks = async () => { try { const appService = await envConfig.getAppService();