refactor: OAuth flow is now handled via ASWebAuthenticationSession on macOS (#866)

This commit is contained in:
Huang Xin
2025-04-12 12:49:41 +08:00
committed by GitHub
parent 6ba15319a8
commit c807f0ccc8
15 changed files with 369 additions and 87 deletions
Generated
+23
View File
@@ -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"
+3
View File
@@ -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"] }
+11 -15
View File
@@ -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();
@@ -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<String>,
pub nonce: Option<String>,
pub state: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AppleIDAuthorizationResponse {
pub user_identifier: Option<String>,
pub given_name: Option<String>,
@@ -0,0 +1,4 @@
pub mod apple_auth;
pub mod menu;
pub mod safari_auth;
pub mod traffic_light;
@@ -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<ThreadSafeObjcPointer>,
}
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<R: Runtime>(
window: Window<R>,
state: State<'_, Arc<Mutex<AuthSession>>>,
payload: SafariAuthRequestArgs,
) -> Result<AuthResult, String> {
let auth_url_str = NSString::from_str(&payload.auth_url);
let url_class = Class::get("NSURL").unwrap();
let auth_url: Id<Object> = 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<R: Runtime>() -> TauriPlugin<R> {
Builder::new("safari_auth")
.setup(|app, _| {
app.manage(Arc::new(Mutex::new(AuthSession::default())));
Ok(())
})
.invoke_handler(tauri::generate_handler![auth_with_safari])
.build()
}
@@ -30,7 +30,6 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
.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<R: Runtime> {
window: Window<R>,
}
#[cfg(target_os = "macos")]
pub fn setup_traffic_light_positioner<R: Runtime>(window: Window<R>) {
use cocoa::appkit::NSWindow;
use cocoa::base::{id, BOOL};
+5 -2
View File
@@ -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);
@@ -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<AppleIDAuthorizationResponse> {
@@ -47,19 +37,11 @@ export async function getAppleIdAuth(
return result;
} else if (OS_TYPE === 'macos') {
return new Promise<AppleIDAuthorizationResponse>(async (resolve, reject) => {
const unlistenComplete = await listen<NativeSuccess>(
const unlistenComplete = await listen<AppleIDAuthorizationResponse>(
'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);
},
);
@@ -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<AuthResponse> {
const result = await invoke<AuthResponse>('plugin:native-bridge|auth_with_safari', {
payload: request,
});
const OS_TYPE = osType();
if (OS_TYPE === 'ios') {
const result = await invoke<AuthResponse>('plugin:native-bridge|auth_with_safari', {
payload: request,
});
return result;
} else if (OS_TYPE === 'macos') {
return new Promise<AuthResponse>(async (resolve, reject) => {
const unlistenComplete = await listen<AuthResponse>('safari-auth-complete', ({ payload }) => {
cleanup();
resolve(payload);
});
return result;
function cleanup() {
unlistenComplete();
}
try {
await invoke<AuthResponse>('auth_with_safari', { payload: request });
} catch (err) {
cleanup();
reject(err);
}
});
} else {
throw new Error('Unsupported OS type');
}
}
export async function authWithCustomTab(request: AuthRequest): Promise<AuthResponse> {
+16 -14
View File
@@ -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;
@@ -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);
+3 -13
View File
@@ -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);
+1
View File
@@ -37,6 +37,7 @@ export interface AppService {
isAppDataSandbox: boolean;
isAndroidApp: boolean;
isIOSApp: boolean;
isMacOSApp: boolean;
selectDirectory(): Promise<string>;
selectFiles(name: string, extensions: string[]): Promise<string[]>;