diff --git a/Cargo.lock b/Cargo.lock index b9e5f74a..26be1861 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12,14 +12,17 @@ checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" name = "Readest" version = "0.2.2" dependencies = [ + "block", "cocoa", "font-enumeration", "futures-util", "log", "objc", + "objc-foundation", "objc2 0.6.0", "objc2-authentication-services", "objc2-foundation 0.3.0", + "objc_id", "rand 0.8.5", "read-progress-stream", "reqwest", @@ -3000,6 +3003,17 @@ dependencies = [ "malloc_buf", ] +[[package]] +name = "objc-foundation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +dependencies = [ + "block", + "objc", + "objc_id", +] + [[package]] name = "objc-sys" version = "0.3.5" @@ -3251,6 +3265,15 @@ dependencies = [ "objc2-security", ] +[[package]] +name = "objc_id" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +dependencies = [ + "objc", +] + [[package]] name = "object" version = "0.36.7" diff --git a/apps/readest-app/src-tauri/Cargo.toml b/apps/readest-app/src-tauri/Cargo.toml index ca775cfc..fdae46a6 100644 --- a/apps/readest-app/src-tauri/Cargo.toml +++ b/apps/readest-app/src-tauri/Cargo.toml @@ -51,6 +51,9 @@ tauri-plugin-native-bridge = { path = "./plugins/tauri-plugin-native-bridge" } rand = "0.8" cocoa = "0.25" objc = "0.2.7" +objc-foundation = "0.1.1" +objc_id = "0.1.1" +block = "0.1.6" objc2 = "0.6" objc2-authentication-services = "0.3" objc2-foundation = { version = "0.3", features = ["NSError", "NSArray"] } diff --git a/apps/readest-app/src-tauri/src/lib.rs b/apps/readest-app/src-tauri/src/lib.rs index f5b632f6..9befd136 100644 --- a/apps/readest-app/src-tauri/src/lib.rs +++ b/apps/readest-app/src-tauri/src/lib.rs @@ -6,17 +6,6 @@ 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")] -mod traffic_light; -#[cfg(target_os = "macos")] -use traffic_light::set_traffic_lights; - #[cfg(target_os = "macos")] use tauri::TitleBarStyle; @@ -27,6 +16,8 @@ use tauri::{AppHandle, Listener, Manager, Url}; #[cfg(desktop)] use tauri_plugin_fs::FsExt; +#[cfg(target_os = "macos")] +mod macos; mod transfer_file; use tauri::{command, Emitter, WebviewUrl, WebviewWindowBuilder, Window}; use tauri_plugin_oauth::start; @@ -139,9 +130,11 @@ pub fn run() { download_file, upload_file, #[cfg(target_os = "macos")] - start_apple_sign_in, + macos::safari_auth::auth_with_safari, #[cfg(target_os = "macos")] - set_traffic_lights, + macos::apple_auth::start_apple_sign_in, + #[cfg(target_os = "macos")] + macos::traffic_light::set_traffic_lights, #[cfg(desktop)] list_fonts ]) @@ -176,7 +169,10 @@ pub fn run() { let builder = builder.plugin(tauri_plugin_window_state::Builder::default().build()); #[cfg(target_os = "macos")] - let builder = builder.plugin(traffic_light::init()); + let builder = builder.plugin(macos::traffic_light::init()); + + #[cfg(target_os = "macos")] + let builder = builder.plugin(macos::safari_auth::init()); #[cfg(target_os = "ios")] let builder = builder.plugin(tauri_plugin_sign_in_with_apple::init()); @@ -268,7 +264,7 @@ pub fn run() { // win.open_devtools(); #[cfg(target_os = "macos")] - menu::setup_macos_menu(app.handle())?; + macos::menu::setup_macos_menu(app.handle())?; app.handle().emit("window-ready", ()).unwrap(); diff --git a/apps/readest-app/src-tauri/src/apple_auth.rs b/apps/readest-app/src-tauri/src/macos/apple_auth.rs similarity index 97% rename from apps/readest-app/src-tauri/src/apple_auth.rs rename to apps/readest-app/src-tauri/src/macos/apple_auth.rs index f67c267d..127cefbe 100644 --- a/apps/readest-app/src-tauri/src/apple_auth.rs +++ b/apps/readest-app/src-tauri/src/macos/apple_auth.rs @@ -17,14 +17,16 @@ use serde_json::json; use tauri::{command, AppHandle, Emitter}; -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] pub struct AppleIDAuthorizationRequest { pub scope: Vec, pub nonce: Option, pub state: Option, } -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] pub struct AppleIDAuthorizationResponse { pub user_identifier: Option, pub given_name: Option, diff --git a/apps/readest-app/src-tauri/src/menu.rs b/apps/readest-app/src-tauri/src/macos/menu.rs similarity index 100% rename from apps/readest-app/src-tauri/src/menu.rs rename to apps/readest-app/src-tauri/src/macos/menu.rs diff --git a/apps/readest-app/src-tauri/src/macos/mod.rs b/apps/readest-app/src-tauri/src/macos/mod.rs new file mode 100644 index 00000000..d13b7851 --- /dev/null +++ b/apps/readest-app/src-tauri/src/macos/mod.rs @@ -0,0 +1,4 @@ +pub mod apple_auth; +pub mod menu; +pub mod safari_auth; +pub mod traffic_light; diff --git a/apps/readest-app/src-tauri/src/macos/safari_auth.rs b/apps/readest-app/src-tauri/src/macos/safari_auth.rs new file mode 100644 index 00000000..ab36108e --- /dev/null +++ b/apps/readest-app/src-tauri/src/macos/safari_auth.rs @@ -0,0 +1,255 @@ +use std::ffi::CStr; +use std::marker::PhantomData; +use std::sync::{Arc, Mutex}; + +use block::ConcreteBlock; +use objc::declare::ClassDecl; +use objc::runtime::{Class, Object, Sel, BOOL, YES}; +use objc::{class, msg_send, sel, sel_impl}; +use objc_foundation::{INSString, NSString}; +use objc_id::Id; +use serde::{Deserialize, Serialize}; + +use tauri::{ + command, + plugin::{Builder, TauriPlugin}, + Emitter, Manager, Runtime, State, Window, +}; + +pub struct ThreadSafeObjcPointer(*mut Object, PhantomData<*mut Object>); + +unsafe impl Send for ThreadSafeObjcPointer {} +unsafe impl Sync for ThreadSafeObjcPointer {} + +impl ThreadSafeObjcPointer { + pub fn new(ptr: *mut Object) -> Self { + Self(ptr, PhantomData) + } + + pub fn as_ptr(&self) -> *mut Object { + self.0 + } + + pub fn is_null(&self) -> bool { + self.0.is_null() + } +} + +pub struct AuthSession { + auth_session: Option, +} + +impl Default for AuthSession { + fn default() -> Self { + Self { auth_session: None } + } +} + +impl Drop for AuthSession { + fn drop(&mut self) { + if let Some(session) = &self.auth_session { + if !session.is_null() { + unsafe { + let _: () = msg_send![session.as_ptr(), cancel]; + } + } + } + } +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SafariAuthRequestArgs { + auth_url: String, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthResult { + redirect_url: String, +} + +fn setup_presentation_context_provider() -> &'static Class { + let class_name = "TauriPresentationContextProvider"; + let mut decl = match Class::get(class_name) { + Some(class) => return class, + None => ClassDecl::new(class_name, class!(NSObject)).unwrap(), + }; + + extern "C" fn presentation_anchor_for_web_auth_session( + this: &Object, + _sel: Sel, + _session: *mut Object, + ) -> *mut Object { + unsafe { + let window: *mut Object = *this.get_ivar("window"); + window + } + } + + unsafe { + decl.add_ivar::<*mut Object>("window"); + + let sel = sel!(presentationAnchorForWebAuthenticationSession:); + decl.add_method( + sel, + presentation_anchor_for_web_auth_session + as extern "C" fn(&Object, Sel, *mut Object) -> *mut Object, + ); + } + + decl.register() +} + +unsafe fn create_provider(window: *mut Object) -> *mut Object { + let class = setup_presentation_context_provider(); + let provider: *mut Object = msg_send![class, alloc]; + let provider: *mut Object = msg_send![provider, init]; + + (*provider).set_ivar("window", window); + + provider +} + +unsafe fn nsstring_to_string(ns_string: *mut Object) -> String { + if ns_string.is_null() { + return String::new(); + } + + let utf8_ptr: *const i8 = msg_send![ns_string, UTF8String]; + if utf8_ptr.is_null() { + return String::new(); + } + + CStr::from_ptr(utf8_ptr).to_string_lossy().into_owned() +} + +#[command] +pub async fn auth_with_safari( + window: Window, + state: State<'_, Arc>>, + payload: SafariAuthRequestArgs, +) -> Result { + let auth_url_str = NSString::from_str(&payload.auth_url); + let url_class = Class::get("NSURL").unwrap(); + let auth_url: Id = unsafe { + let url: *mut Object = msg_send![url_class, URLWithString:auth_url_str]; + if url.is_null() { + return Err("Failed to create URL".to_string()); + } + Id::from_ptr(url) + }; + + let auth_session_class = match Class::get("ASWebAuthenticationSession") { + Some(class) => class, + None => { + return Err( + "ASWebAuthenticationSession class not found. This requires macOS 10.15+" + .to_string(), + ) + } + }; + + let window_clone = window.clone(); + let state_clone = Arc::clone(&*state); + let completion_block = + ConcreteBlock::new(move |callback_url: *mut Object, error: *mut Object| { + let mut auth_session = match state_clone.lock() { + Ok(guard) => guard, + Err(_) => return, + }; + + if !error.is_null() { + unsafe { + let description: *mut Object = msg_send![error, localizedDescription]; + let error_description = nsstring_to_string(description); + log::error!("Auth session error: {}", error_description); + } + return; + } + + if !callback_url.is_null() { + let url_string_value = unsafe { + let abs_str: *mut Object = msg_send![callback_url, absoluteString]; + nsstring_to_string(abs_str) + }; + + log::info!("Auth session callback URL: {}", url_string_value); + + if let Some(session) = &auth_session.auth_session { + unsafe { + let _: () = msg_send![session.as_ptr(), cancel]; + } + } + + auth_session.auth_session = None; + + let result = AuthResult { + redirect_url: url_string_value, + }; + + let _ = window_clone.emit("safari-auth-complete", result); + } + }); + + let completion_block = completion_block.copy(); + + let callback_scheme = NSString::from_str("readest"); + let auth_session_ptr: *mut Object = unsafe { + let alloc: *mut Object = msg_send![auth_session_class, alloc]; + if alloc.is_null() { + return Err("Failed to allocate ASWebAuthenticationSession".to_string()); + } + + let init: *mut Object = msg_send![ + alloc, + initWithURL:auth_url + callbackURLScheme:callback_scheme + completionHandler:&*completion_block + ]; + + if init.is_null() { + return Err("Failed to initialize ASWebAuthenticationSession".to_string()); + } + init + }; + + unsafe { + match window.ns_window() { + Ok(ns_window) => { + let ns_window = ns_window as *mut Object; + let provider = create_provider(ns_window); + + let _: () = msg_send![auth_session_ptr, setPresentationContextProvider:provider]; + } + Err(err) => { + log::warn!("Failed to get NSWindow for presentation context: {}", err); + } + } + } + + let started: BOOL = unsafe { msg_send![auth_session_ptr, start] }; + + log::info!("Auth session start result: {}", started == YES); + + if started != YES { + return Err("Failed to start authentication session".to_string()); + } + + let mut auth_session_guard = state.lock().unwrap(); + auth_session_guard.auth_session = Some(ThreadSafeObjcPointer::new(auth_session_ptr)); + + Ok(AuthResult { + redirect_url: "pending".to_string(), + }) +} + +pub fn init() -> TauriPlugin { + Builder::new("safari_auth") + .setup(|app, _| { + app.manage(Arc::new(Mutex::new(AuthSession::default()))); + Ok(()) + }) + .invoke_handler(tauri::generate_handler![auth_with_safari]) + .build() +} diff --git a/apps/readest-app/src-tauri/src/traffic_light.rs b/apps/readest-app/src-tauri/src/macos/traffic_light.rs similarity index 99% rename from apps/readest-app/src-tauri/src/traffic_light.rs rename to apps/readest-app/src-tauri/src/macos/traffic_light.rs index 45355591..d69f7fbf 100644 --- a/apps/readest-app/src-tauri/src/traffic_light.rs +++ b/apps/readest-app/src-tauri/src/macos/traffic_light.rs @@ -30,7 +30,6 @@ pub fn init() -> TauriPlugin { .build() } -#[cfg(target_os = "macos")] #[command] pub fn set_traffic_lights(window: Window, visible: bool, x: f64, y: f64) { unsafe { @@ -47,7 +46,6 @@ pub fn set_traffic_lights(window: Window, visible: bool, x: f64, y: f64) { } } -#[cfg(target_os = "macos")] fn position_traffic_lights(ns_window_handle: UnsafeWindowHandle, visible: bool, x: f64, y: f64) { use cocoa::appkit::{NSView, NSWindow, NSWindowButton}; use cocoa::foundation::NSRect; @@ -83,13 +81,11 @@ fn position_traffic_lights(ns_window_handle: UnsafeWindowHandle, visible: bool, } } -#[cfg(target_os = "macos")] #[derive(Debug)] struct WindowState { window: Window, } -#[cfg(target_os = "macos")] pub fn setup_traffic_light_positioner(window: Window) { use cocoa::appkit::NSWindow; use cocoa::base::{id, BOOL}; diff --git a/apps/readest-app/src/app/auth/page.tsx b/apps/readest-app/src/app/auth/page.tsx index 699160fd..852a1c78 100644 --- a/apps/readest-app/src/app/auth/page.tsx +++ b/apps/readest-app/src/app/auth/page.tsx @@ -81,7 +81,10 @@ export default function AuthPage() { } return DEEPLINK_CALLBACK; } - return `http://localhost:${port}`; // only for development env on Desktop + // For development env on Desktop, use a custom OAuth callback server + // it's possible to register a custom URL scheme for the app + // but this is not supported by macOS, so we use a local server instead + return `http://localhost:${port}`; }; const getWebRedirectTo = () => { @@ -133,7 +136,7 @@ export default function AuthPage() { } // Open the OAuth URL in a ASWebAuthenticationSession on iOS to comply with Apple's guidelines // for other platforms, open the OAuth URL in the default browser - if (appService?.isIOSApp) { + if (appService?.isIOSApp || appService?.isMacOSApp) { const res = await authWithSafari({ authUrl: data.url }); if (res) { handleOAuthUrl(res.redirectUrl); diff --git a/apps/readest-app/src/app/auth/utils/appleIdAuth.ts b/apps/readest-app/src/app/auth/utils/appleIdAuth.ts index 797fa1ea..f0c27a05 100644 --- a/apps/readest-app/src/app/auth/utils/appleIdAuth.ts +++ b/apps/readest-app/src/app/auth/utils/appleIdAuth.ts @@ -22,16 +22,6 @@ 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 { @@ -47,19 +37,11 @@ export async function getAppleIdAuth( return result; } else if (OS_TYPE === 'macos') { return new Promise(async (resolve, reject) => { - const unlistenComplete = await listen( + 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, - }); + resolve(payload); }, ); diff --git a/apps/readest-app/src/app/auth/utils/nativeAuth.ts b/apps/readest-app/src/app/auth/utils/nativeAuth.ts index 32d3afa2..3e3cb218 100644 --- a/apps/readest-app/src/app/auth/utils/nativeAuth.ts +++ b/apps/readest-app/src/app/auth/utils/nativeAuth.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 interface AuthRequest { authUrl: string; @@ -9,11 +11,33 @@ export interface AuthResponse { } export async function authWithSafari(request: AuthRequest): Promise { - const result = await invoke('plugin:native-bridge|auth_with_safari', { - payload: request, - }); + const OS_TYPE = osType(); + if (OS_TYPE === 'ios') { + const result = await invoke('plugin:native-bridge|auth_with_safari', { + payload: request, + }); + return result; + } else if (OS_TYPE === 'macos') { + return new Promise(async (resolve, reject) => { + const unlistenComplete = await listen('safari-auth-complete', ({ payload }) => { + cleanup(); + resolve(payload); + }); - return result; + function cleanup() { + unlistenComplete(); + } + + try { + await invoke('auth_with_safari', { payload: request }); + } catch (err) { + cleanup(); + reject(err); + } + }); + } else { + throw new Error('Unsupported OS type'); + } } export async function authWithCustomTab(request: AuthRequest): Promise { diff --git a/apps/readest-app/src/services/appService.ts b/apps/readest-app/src/services/appService.ts index 17995f59..784cbe2f 100644 --- a/apps/readest-app/src/services/appService.ts +++ b/apps/readest-app/src/services/appService.ts @@ -42,21 +42,23 @@ import { BOOK_FILE_NOT_FOUND_ERROR } from './errors'; export abstract class BaseAppService implements AppService { osPlatform: string = getOSPlatform(); - localBooksDir: string = ''; + appPlatform: AppPlatform = 'tauri'; + localBooksDir = ''; + isMobile = false; + isMacOSApp = false; + isAppDataSandbox = false; + isAndroidApp = false; + isIOSApp = false; + hasTrafficLight = false; + hasWindow = false; + hasWindowBar = false; + hasContextMenu = false; + hasRoundedWindow = false; + hasSafeAreaInset = false; + hasHaptics = false; + hasSysFontsList = false; + abstract fs: FileSystem; - abstract appPlatform: AppPlatform; - abstract isAppDataSandbox: boolean; - abstract isMobile: boolean; - abstract isAndroidApp: boolean; - abstract isIOSApp: boolean; - abstract hasTrafficLight: boolean; - abstract hasWindow: boolean; - abstract hasWindowBar: boolean; - abstract hasContextMenu: boolean; - abstract hasRoundedWindow: boolean; - abstract hasSafeAreaInset: boolean; - abstract hasHaptics: boolean; - abstract hasSysFontsList: boolean; abstract resolvePath(fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string }; abstract getCoverImageUrl(book: Book): string; diff --git a/apps/readest-app/src/services/nativeAppService.ts b/apps/readest-app/src/services/nativeAppService.ts index 12f4d41c..35e2092d 100644 --- a/apps/readest-app/src/services/nativeAppService.ts +++ b/apps/readest-app/src/services/nativeAppService.ts @@ -207,19 +207,20 @@ export const nativeFileSystem: FileSystem = { export class NativeAppService extends BaseAppService { fs = nativeFileSystem; - appPlatform = 'tauri' as AppPlatform; - isAppDataSandbox = ['android', 'ios'].includes(OS_TYPE); - isMobile = ['android', 'ios'].includes(OS_TYPE); - isAndroidApp = OS_TYPE === 'android'; - isIOSApp = OS_TYPE === 'ios'; - hasTrafficLight = OS_TYPE === 'macos'; - hasWindow = !(OS_TYPE === 'ios' || OS_TYPE === 'android'); - hasWindowBar = !(OS_TYPE === 'ios' || OS_TYPE === 'android'); - hasContextMenu = !(OS_TYPE === 'ios' || OS_TYPE === 'android'); - hasRoundedWindow = !(OS_TYPE === 'ios' || OS_TYPE === 'android') && !!window.IS_ROUNDED; - hasSafeAreaInset = OS_TYPE === 'ios' || OS_TYPE === 'android'; - hasHaptics = OS_TYPE === 'ios' || OS_TYPE === 'android'; - hasSysFontsList = !(OS_TYPE === 'ios' || OS_TYPE === 'android'); + override appPlatform = 'tauri' as AppPlatform; + override isAppDataSandbox = ['android', 'ios'].includes(OS_TYPE); + override isMobile = ['android', 'ios'].includes(OS_TYPE); + override isAndroidApp = OS_TYPE === 'android'; + override isIOSApp = OS_TYPE === 'ios'; + override isMacOSApp = OS_TYPE === 'macos'; + override hasTrafficLight = OS_TYPE === 'macos'; + override hasWindow = !(OS_TYPE === 'ios' || OS_TYPE === 'android'); + override hasWindowBar = !(OS_TYPE === 'ios' || OS_TYPE === 'android'); + override hasContextMenu = !(OS_TYPE === 'ios' || OS_TYPE === 'android'); + override hasRoundedWindow = !(OS_TYPE === 'ios' || OS_TYPE === 'android') && !!window.IS_ROUNDED; + override hasSafeAreaInset = OS_TYPE === 'ios' || OS_TYPE === 'android'; + override hasHaptics = OS_TYPE === 'ios' || OS_TYPE === 'android'; + override hasSysFontsList = !(OS_TYPE === 'ios' || OS_TYPE === 'android'); override resolvePath(fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string } { return resolvePath(fp, base); diff --git a/apps/readest-app/src/services/webAppService.ts b/apps/readest-app/src/services/webAppService.ts index aee4f803..97f50ce4 100644 --- a/apps/readest-app/src/services/webAppService.ts +++ b/apps/readest-app/src/services/webAppService.ts @@ -190,19 +190,9 @@ const indexedDBFileSystem: FileSystem = { export class WebAppService extends BaseAppService { fs = indexedDBFileSystem; - appPlatform = 'web' as AppPlatform; - isAppDataSandbox = false; - isMobile = ['android', 'ios'].includes(getOSPlatform()); - isAndroidApp = false; - isIOSApp = false; - hasTrafficLight = false; - hasWindow = false; - hasWindowBar = false; - hasContextMenu = false; - hasRoundedWindow = false; - hasSafeAreaInset = isPWA(); - hasHaptics = false; - hasSysFontsList = false; + override isMobile = ['android', 'ios'].includes(getOSPlatform()); + override appPlatform = 'web' as AppPlatform; + override hasSafeAreaInset = isPWA(); override resolvePath(fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string } { return resolvePath(fp, base); diff --git a/apps/readest-app/src/types/system.ts b/apps/readest-app/src/types/system.ts index 4b9863c9..603f3e87 100644 --- a/apps/readest-app/src/types/system.ts +++ b/apps/readest-app/src/types/system.ts @@ -37,6 +37,7 @@ export interface AppService { isAppDataSandbox: boolean; isAndroidApp: boolean; isIOSApp: boolean; + isMacOSApp: boolean; selectDirectory(): Promise; selectFiles(name: string, extensions: string[]): Promise;