diff --git a/.github/workflows/ios-rust.yml b/.github/workflows/ios-rust.yml index 1452f54..66290d7 100644 --- a/.github/workflows/ios-rust.yml +++ b/.github/workflows/ios-rust.yml @@ -19,10 +19,14 @@ jobs: run: cargo clippy --workspace --all-targets --all-features -- -D warnings - name: Workspace tests run: cargo test --workspace --all-features + - name: Fake iOS FFI tests + run: cargo test -p tele-ios-ffi --no-default-features --features standalone-fake - name: Generate iOS FFI bindings run: scripts/generate-ios-ffi-bindings.sh /tmp/tele-ios-ffi - name: Swift bindings typecheck run: swiftc -typecheck -I /tmp/tele-ios-ffi/Headers /tmp/tele-ios-ffi/Swift/tele_ios_ffi.swift + - name: Build fake iOS FFI XCFramework + run: scripts/build-ios-fake-ffi-xcframework.sh /tmp/tele-ios-fake-ffi-xcframework ios-shell: runs-on: macos-latest diff --git a/crates/tele-ios-ffi/Cargo.toml b/crates/tele-ios-ffi/Cargo.toml index 91e3110..7a66176 100644 --- a/crates/tele-ios-ffi/Cargo.toml +++ b/crates/tele-ios-ffi/Cargo.toml @@ -10,8 +10,13 @@ repository = "https://github.com/your-username/tele-tui" [lib] crate-type = ["cdylib", "staticlib", "rlib"] +[features] +default = ["core-session"] +core-session = ["dep:tele-core"] +standalone-fake = [] + [dependencies] -tele-core = { path = "../tele-core", features = ["test-support"] } +tele-core = { path = "../tele-core", features = ["test-support"], optional = true } tokio = { version = "1", features = ["rt-multi-thread"] } thiserror = "1.0" uniffi = { version = "0.31.1", features = ["tokio"] } diff --git a/crates/tele-ios-ffi/README.md b/crates/tele-ios-ffi/README.md index 0a15ba3..3d8df73 100644 --- a/crates/tele-ios-ffi/README.md +++ b/crates/tele-ios-ffi/README.md @@ -18,6 +18,12 @@ The script builds `target/release/libtele_ios_ffi.a` and writes Swift sources, headers, a Swift typecheck-friendly `tele_ios_ffiFFI` module map, and an XCFramework-compatible module map under `build/ios-ffi/`. +Build the fake-only iOS simulator XCFramework without linking TDLib: + +```bash +DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer scripts/build-ios-fake-ffi-xcframework.sh +``` + Current linking status: - Xcode is installed at `/Applications/Xcode.app`, and `DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -version` reports Xcode 26.5. diff --git a/crates/tele-ios-ffi/src/lib.rs b/crates/tele-ios-ffi/src/lib.rs index d636090..cc1fd57 100644 --- a/crates/tele-ios-ffi/src/lib.rs +++ b/crates/tele-ios-ffi/src/lib.rs @@ -1,12 +1,19 @@ use std::sync::Mutex; +#[cfg(not(feature = "core-session"))] +use std::collections::HashMap; + +#[cfg(feature = "core-session")] use tele_core::session::{ CoreAuthState, CoreChatSummary, CoreDownloadedFile, CoreDraft, CoreEvent, CoreFolder, CoreMedia, CoreMessage, CoreNetworkState, CoreProfile, CoreReaction, CoreSearchResult, CoreSession, }; +#[cfg(feature = "core-session")] use tele_core::tdlib::{ChatInfo, MessageBuilder, NetworkState, ProfileInfo}; +#[cfg(feature = "core-session")] use tele_core::test_support::FakeTdClient; +#[cfg(feature = "core-session")] use tele_core::types::{ChatId, MessageId}; uniffi::setup_scaffolding!(); @@ -42,6 +49,7 @@ pub enum IosAuthState { Error { message: String }, } +#[cfg(feature = "core-session")] impl From for IosAuthState { fn from(value: CoreAuthState) -> Self { match value { @@ -65,6 +73,7 @@ pub enum IosNetworkState { Ready, } +#[cfg(feature = "core-session")] impl From for IosNetworkState { fn from(value: CoreNetworkState) -> Self { match value { @@ -91,6 +100,7 @@ pub struct IosFolder { pub name: String, } +#[cfg(feature = "core-session")] impl From for IosFolder { fn from(value: CoreFolder) -> Self { Self { id: value.id, name: value.name } @@ -103,6 +113,7 @@ pub struct IosDraft { pub text: String, } +#[cfg(feature = "core-session")] impl From for IosDraft { fn from(value: CoreDraft) -> Self { Self { chat_id: value.chat_id.as_i64(), text: value.text } @@ -126,6 +137,7 @@ pub struct IosChatSummary { pub draft: Option, } +#[cfg(feature = "core-session")] impl From for IosChatSummary { fn from(value: CoreChatSummary) -> Self { Self { @@ -153,6 +165,7 @@ pub struct IosReaction { pub is_chosen: bool, } +#[cfg(feature = "core-session")] impl From for IosReaction { fn from(value: CoreReaction) -> Self { Self { @@ -187,6 +200,7 @@ pub struct IosMedia { pub download_state: IosDownloadState, } +#[cfg(feature = "core-session")] impl From for IosMedia { fn from(value: CoreMedia) -> Self { match value { @@ -259,6 +273,7 @@ pub struct IosMessage { pub reactions: Vec, } +#[cfg(feature = "core-session")] impl From for IosMessage { fn from(value: CoreMessage) -> Self { Self { @@ -293,6 +308,7 @@ pub struct IosSearchResult { pub message: IosMessage, } +#[cfg(feature = "core-session")] impl From for IosSearchResult { fn from(value: CoreSearchResult) -> Self { Self { @@ -317,6 +333,7 @@ pub struct IosProfile { pub online_status: Option, } +#[cfg(feature = "core-session")] impl From for IosProfile { fn from(value: CoreProfile) -> Self { Self { @@ -341,6 +358,7 @@ pub struct IosDownloadedFile { pub path: String, } +#[cfg(feature = "core-session")] impl From for IosDownloadedFile { fn from(value: CoreDownloadedFile) -> Self { Self { file_id: value.file_id, path: value.path } @@ -396,6 +414,7 @@ pub enum IosEvent { }, } +#[cfg(feature = "core-session")] impl From for IosEvent { fn from(value: CoreEvent) -> Self { match value { @@ -442,12 +461,14 @@ impl From for IosEvent { } #[derive(uniffi::Object)] +#[cfg(feature = "core-session")] pub struct SessionHandle { session: Mutex>, runtime: tokio::runtime::Runtime, } #[uniffi::export] +#[cfg(feature = "core-session")] pub fn create_session(config: IosSessionConfig) -> Result { if !config.use_fake_tdlib { return Err(IosFfiError::Operation { @@ -466,6 +487,7 @@ pub fn create_session(config: IosSessionConfig) -> Result IosAuthState { self.with_session(|session| session.auth_state().into()) @@ -694,12 +716,14 @@ impl SessionHandle { } impl SessionHandle { + #[cfg(feature = "core-session")] fn with_session(&self, f: impl FnOnce(&mut CoreSession) -> R) -> R { let mut session = self.session.lock().expect("session mutex poisoned"); f(&mut session) } } +#[cfg(feature = "core-session")] fn seeded_fake_client() -> FakeTdClient { let chat = ChatInfo { id: ChatId::new(1), @@ -744,6 +768,479 @@ fn seeded_fake_client() -> FakeTdClient { .with_downloaded_file(200, "/tmp/fake-voice.ogg") } +#[derive(uniffi::Object)] +#[cfg(not(feature = "core-session"))] +pub struct SessionHandle { + state: Mutex, +} + +#[cfg(not(feature = "core-session"))] +struct StandaloneFakeState { + auth: IosAuthState, + chats: Vec, + folders: Vec, + messages: HashMap>, + profiles: HashMap, + events: Vec, + downloaded_files: HashMap, + next_message_id: i64, +} + +#[cfg(not(feature = "core-session"))] +impl StandaloneFakeState { + fn seeded() -> Self { + let chat = IosChatSummary { + id: 1, + title: "Saved Messages".to_string(), + username: Some("saved".to_string()), + last_message: "Hello from fake TDLib".to_string(), + last_message_date: 1_700_000_000, + unread_count: 1, + unread_mention_count: 0, + is_pinned: true, + order: 1, + last_read_outbox_message_id: 1, + folder_ids: vec![0], + is_muted: false, + draft: None, + }; + let message = IosMessage { + id: 1, + sender_name: "Alice".to_string(), + date: 1_700_000_000, + edit_date: None, + media_album_id: None, + text: "Hello from fake TDLib".to_string(), + media: None, + is_outgoing: false, + is_read: false, + can_be_edited: false, + can_be_deleted_only_for_self: true, + can_be_deleted_for_all_users: false, + reply: None, + forward: None, + reactions: Vec::new(), + }; + let profile = IosProfile { + chat_id: chat.id, + title: chat.title.clone(), + username: chat.username.clone(), + bio: Some("Fake profile for iOS bridge tests".to_string()), + phone_number: None, + chat_type: "Private".to_string(), + member_count: None, + description: None, + invite_link: None, + is_group: false, + online_status: Some("online".to_string()), + }; + + let mut messages = HashMap::new(); + messages.insert(chat.id, vec![message]); + + let mut profiles = HashMap::new(); + profiles.insert(chat.id, profile); + + let mut downloaded_files = HashMap::new(); + downloaded_files.insert(100, "/tmp/fake-photo.jpg".to_string()); + downloaded_files.insert(200, "/tmp/fake-voice.ogg".to_string()); + + Self { + auth: IosAuthState::WaitPhoneNumber, + chats: vec![chat.clone()], + folders: vec![IosFolder { id: 0, name: "All".to_string() }], + messages, + profiles, + events: vec![IosEvent::ChatListChanged { chats: vec![chat] }], + downloaded_files, + next_message_id: 2, + } + } + + fn message_mut( + &mut self, + chat_id: i64, + message_id: i64, + ) -> Result<&mut IosMessage, IosFfiError> { + self.messages + .get_mut(&chat_id) + .and_then(|messages| messages.iter_mut().find(|message| message.id == message_id)) + .ok_or_else(|| IosFfiError::Operation { message: "message not found".to_string() }) + } +} + +#[uniffi::export] +#[cfg(not(feature = "core-session"))] +pub fn create_session(config: IosSessionConfig) -> Result { + if !config.use_fake_tdlib { + return Err(IosFfiError::Operation { + message: "real TDLib sessions require the core-session build".to_string(), + }); + } + + Ok(SessionHandle { state: Mutex::new(StandaloneFakeState::seeded()) }) +} + +#[uniffi::export] +#[cfg(not(feature = "core-session"))] +impl SessionHandle { + pub fn auth_state(&self) -> IosAuthState { + self.state + .lock() + .expect("session mutex poisoned") + .auth + .clone() + } + + pub fn poll_events(&self) -> Vec { + let mut state = self.state.lock().expect("session mutex poisoned"); + std::mem::take(&mut state.events) + } + + pub fn send_phone_number(&self, _phone: String) -> Result<(), IosFfiError> { + let mut state = self.state.lock().expect("session mutex poisoned"); + state.auth = IosAuthState::WaitCode; + let auth = state.auth.clone(); + state.events.push(IosEvent::AuthChanged { state: auth }); + Ok(()) + } + + pub fn send_code(&self, _code: String) -> Result<(), IosFfiError> { + let mut state = self.state.lock().expect("session mutex poisoned"); + state.auth = IosAuthState::WaitPassword; + let auth = state.auth.clone(); + state.events.push(IosEvent::AuthChanged { state: auth }); + Ok(()) + } + + pub fn send_password(&self, _password: String) -> Result<(), IosFfiError> { + let mut state = self.state.lock().expect("session mutex poisoned"); + state.auth = IosAuthState::Ready; + let auth = state.auth.clone(); + state.events.push(IosEvent::AuthChanged { state: auth }); + Ok(()) + } + + pub fn load_chats(&self, limit: i32) -> Result, IosFfiError> { + let mut state = self.state.lock().expect("session mutex poisoned"); + let chats: Vec<_> = state + .chats + .iter() + .take(limit.max(0) as usize) + .cloned() + .collect(); + state + .events + .push(IosEvent::ChatListChanged { chats: chats.clone() }); + Ok(chats) + } + + pub fn load_folders(&self) -> Vec { + self.state + .lock() + .expect("session mutex poisoned") + .folders + .clone() + } + + pub fn load_folder_chats( + &self, + folder_id: i32, + limit: i32, + ) -> Result, IosFfiError> { + let mut state = self.state.lock().expect("session mutex poisoned"); + let chats: Vec<_> = state + .chats + .iter() + .filter(|chat| chat.folder_ids.contains(&folder_id)) + .take(limit.max(0) as usize) + .cloned() + .collect(); + state + .events + .push(IosEvent::ChatListChanged { chats: chats.clone() }); + Ok(chats) + } + + pub fn load_history(&self, chat_id: i64, limit: i32) -> Result, IosFfiError> { + let state = self.state.lock().expect("session mutex poisoned"); + Ok(state + .messages + .get(&chat_id) + .cloned() + .unwrap_or_default() + .into_iter() + .take(limit.max(0) as usize) + .collect()) + } + + pub fn search_messages( + &self, + chat_id: i64, + query: String, + ) -> Result, IosFfiError> { + let state = self.state.lock().expect("session mutex poisoned"); + Ok(state + .messages + .get(&chat_id) + .cloned() + .unwrap_or_default() + .into_iter() + .filter(|message| { + query.is_empty() + || message.text.contains(&query) + || message.sender_name.contains(&query) + }) + .map(|message| IosSearchResult { chat_id, message }) + .collect()) + } + + pub fn open_profile(&self, chat_id: i64) -> Result { + let mut state = self.state.lock().expect("session mutex poisoned"); + let profile = state + .profiles + .get(&chat_id) + .cloned() + .ok_or_else(|| IosFfiError::Operation { message: "profile not found".to_string() })?; + state + .events + .push(IosEvent::ProfileLoaded { profile: profile.clone() }); + Ok(profile) + } + + pub fn send_message( + &self, + chat_id: i64, + text: String, + reply_to_message_id: Option, + ) -> Result { + let mut state = self.state.lock().expect("session mutex poisoned"); + let message = IosMessage { + id: state.next_message_id, + sender_name: "Me".to_string(), + date: 1_700_000_001, + edit_date: None, + media_album_id: None, + text: text.clone(), + media: None, + is_outgoing: true, + is_read: true, + can_be_edited: true, + can_be_deleted_only_for_self: true, + can_be_deleted_for_all_users: true, + reply: reply_to_message_id.map(|message_id| IosReply { + message_id, + sender_name: "Alice".to_string(), + text: format!("Reply to #{message_id}"), + }), + forward: None, + reactions: Vec::new(), + }; + state.next_message_id += 1; + state + .messages + .entry(chat_id) + .or_default() + .push(message.clone()); + if let Some(chat) = state.chats.iter_mut().find(|chat| chat.id == chat_id) { + chat.last_message = text; + chat.draft = None; + } + state + .events + .push(IosEvent::MessageAdded { chat_id, message: message.clone() }); + Ok(message) + } + + pub fn edit_message( + &self, + chat_id: i64, + message_id: i64, + text: String, + ) -> Result { + let mut state = self.state.lock().expect("session mutex poisoned"); + let updated = { + let message = state.message_mut(chat_id, message_id)?; + message.text = text; + message.edit_date = Some(1_700_000_002); + message.clone() + }; + state + .events + .push(IosEvent::MessageUpdated { chat_id, message: updated.clone() }); + Ok(updated) + } + + pub fn delete_messages( + &self, + chat_id: i64, + message_ids: Vec, + _revoke: bool, + ) -> Result<(), IosFfiError> { + let mut state = self.state.lock().expect("session mutex poisoned"); + state + .messages + .entry(chat_id) + .or_default() + .retain(|message| !message_ids.contains(&message.id)); + state + .events + .push(IosEvent::MessageDeleted { chat_id, message_ids }); + Ok(()) + } + + pub fn forward_messages( + &self, + to_chat_id: i64, + from_chat_id: i64, + message_ids: Vec, + ) -> Result<(), IosFfiError> { + let mut state = self.state.lock().expect("session mutex poisoned"); + let source_messages: Vec<_> = state + .messages + .get(&from_chat_id) + .cloned() + .unwrap_or_default() + .into_iter() + .filter(|message| message_ids.contains(&message.id)) + .collect(); + for source in source_messages { + let forwarded = IosMessage { + id: state.next_message_id, + sender_name: "Me".to_string(), + date: 1_700_000_003, + edit_date: None, + media_album_id: source.media_album_id, + text: source.text, + media: source.media, + is_outgoing: true, + is_read: true, + can_be_edited: true, + can_be_deleted_only_for_self: true, + can_be_deleted_for_all_users: true, + reply: None, + forward: Some(IosForward { sender_name: source.sender_name }), + reactions: Vec::new(), + }; + state.next_message_id += 1; + state + .messages + .entry(to_chat_id) + .or_default() + .push(forwarded.clone()); + state + .events + .push(IosEvent::MessageAdded { chat_id: to_chat_id, message: forwarded }); + } + Ok(()) + } + + pub fn react( + &self, + chat_id: i64, + message_id: i64, + reaction: String, + ) -> Result, IosFfiError> { + let mut state = self.state.lock().expect("session mutex poisoned"); + let reactions = { + let message = state.message_mut(chat_id, message_id)?; + if let Some(index) = message + .reactions + .iter() + .position(|item| item.emoji == reaction) + { + message.reactions.remove(index); + } else { + message + .reactions + .push(IosReaction { emoji: reaction, count: 1, is_chosen: true }); + } + message.reactions.clone() + }; + state.events.push(IosEvent::ReactionChanged { + chat_id, + message_id, + reactions: reactions.clone(), + }); + Ok(reactions) + } + + pub fn set_draft(&self, chat_id: i64, text: String) -> Result<(), IosFfiError> { + let mut state = self.state.lock().expect("session mutex poisoned"); + let draft = IosDraft { chat_id, text }; + if let Some(chat) = state.chats.iter_mut().find(|chat| chat.id == chat_id) { + chat.draft = Some(draft.clone()); + } + state.events.push(IosEvent::DraftChanged { draft }); + Ok(()) + } + + pub fn download_photo(&self, file_id: i32) -> Result { + self.standalone_download_file(file_id) + } + + pub fn download_voice(&self, file_id: i32) -> Result { + self.standalone_download_file(file_id) + } + + pub fn simulate_incoming_message(&self, chat_id: i64, text: String, sender_name: String) { + let mut state = self.state.lock().expect("session mutex poisoned"); + let message = IosMessage { + id: state.next_message_id, + sender_name: sender_name.clone(), + date: 1_700_000_004, + edit_date: None, + media_album_id: None, + text: text.clone(), + media: None, + is_outgoing: false, + is_read: false, + can_be_edited: false, + can_be_deleted_only_for_self: true, + can_be_deleted_for_all_users: false, + reply: None, + forward: None, + reactions: Vec::new(), + }; + state.next_message_id += 1; + state + .messages + .entry(chat_id) + .or_default() + .push(message.clone()); + let notification_chat = + if let Some(chat) = state.chats.iter_mut().find(|chat| chat.id == chat_id) { + chat.last_message = text; + Some(chat.clone()) + } else { + None + }; + if let Some(chat) = notification_chat { + state.events.push(IosEvent::IncomingNotificationCandidate { + chat, + message: message.clone(), + sender_name, + }); + } + state + .events + .push(IosEvent::MessageAdded { chat_id, message }); + } +} + +#[cfg(not(feature = "core-session"))] +impl SessionHandle { + fn standalone_download_file(&self, file_id: i32) -> Result { + let state = self.state.lock().expect("session mutex poisoned"); + state + .downloaded_files + .get(&file_id) + .cloned() + .map(|path| IosDownloadedFile { file_id, path }) + .ok_or_else(|| IosFfiError::Operation { message: "file not found".to_string() }) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/docs/ios/release-checklist.md b/docs/ios/release-checklist.md index a50c615..642ce93 100644 --- a/docs/ios/release-checklist.md +++ b/docs/ios/release-checklist.md @@ -33,6 +33,7 @@ - `swift run TeleTuiIOSSmokeTests` - `DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer scripts/build-ios-simulator-app.sh` - `DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer scripts/smoke-ios-simulator-ui.sh` +- `DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer scripts/build-ios-fake-ffi-xcframework.sh` - `DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer scripts/check-ios-tdlib-linking.sh` once TDLib iOS artifacts are available `swift test` is not currently a CI gate on this host because the CLI Swift toolchain available to the package does not expose `XCTest` or `Testing`. The deterministic view-model coverage lives in the executable `TeleTuiIOSSmokeTests` target until an Xcode test target is introduced. diff --git a/scripts/build-ios-fake-ffi-xcframework.sh b/scripts/build-ios-fake-ffi-xcframework.sh new file mode 100755 index 0000000..c5b3478 --- /dev/null +++ b/scripts/build-ios-fake-ffi-xcframework.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +out_dir="${1:-${repo_root}/build/ios-fake-ffi-xcframework}" +target="${IOS_RUST_TARGET:-aarch64-apple-ios-sim}" +lib_path="${repo_root}/target/${target}/release/libtele_ios_ffi.a" +framework_name="${IOS_FFI_FRAMEWORK_NAME:-tele_ios_ffi}" + +if [[ -z "${DEVELOPER_DIR:-}" && -d /Applications/Xcode.app/Contents/Developer ]]; then + export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer +fi + +cd "${repo_root}" + +if ! rustup target list --installed | grep -qx "${target}"; then + rustup target add "${target}" +fi + +cargo build \ + -p tele-ios-ffi \ + --no-default-features \ + --features standalone-fake \ + --target "${target}" \ + --release + +rm -rf "${out_dir}" +mkdir -p "${out_dir}/Swift" "${out_dir}/Headers" + +cargo run -p uniffi-bindgen-swift -- "${lib_path}" "${out_dir}/Swift" --swift-sources +cargo run -p uniffi-bindgen-swift -- "${lib_path}" "${out_dir}/Headers" --headers +cargo run -p uniffi-bindgen-swift -- "${lib_path}" "${out_dir}/Headers" \ + --modulemap \ + --module-name tele_ios_ffiFFI \ + --modulemap-filename module.modulemap + +xcodebuild -create-xcframework \ + -library "${lib_path}" \ + -headers "${out_dir}/Headers" \ + -output "${out_dir}/${framework_name}.xcframework" + +printf 'Generated fake-only iOS FFI XCFramework in %s\n' "${out_dir}"