Add fake iOS FFI XCFramework build

This commit is contained in:
Mikhail Kilin
2026-05-20 23:50:53 +03:00
parent 7bde72f715
commit c83d2a1354
6 changed files with 556 additions and 1 deletions

View File

@@ -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

View File

@@ -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"] }

View File

@@ -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.

View File

@@ -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<CoreAuthState> for IosAuthState {
fn from(value: CoreAuthState) -> Self {
match value {
@@ -65,6 +73,7 @@ pub enum IosNetworkState {
Ready,
}
#[cfg(feature = "core-session")]
impl From<CoreNetworkState> 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<CoreFolder> 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<CoreDraft> 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<IosDraft>,
}
#[cfg(feature = "core-session")]
impl From<CoreChatSummary> 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<CoreReaction> 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<CoreMedia> for IosMedia {
fn from(value: CoreMedia) -> Self {
match value {
@@ -259,6 +273,7 @@ pub struct IosMessage {
pub reactions: Vec<IosReaction>,
}
#[cfg(feature = "core-session")]
impl From<CoreMessage> for IosMessage {
fn from(value: CoreMessage) -> Self {
Self {
@@ -293,6 +308,7 @@ pub struct IosSearchResult {
pub message: IosMessage,
}
#[cfg(feature = "core-session")]
impl From<CoreSearchResult> for IosSearchResult {
fn from(value: CoreSearchResult) -> Self {
Self {
@@ -317,6 +333,7 @@ pub struct IosProfile {
pub online_status: Option<String>,
}
#[cfg(feature = "core-session")]
impl From<CoreProfile> for IosProfile {
fn from(value: CoreProfile) -> Self {
Self {
@@ -341,6 +358,7 @@ pub struct IosDownloadedFile {
pub path: String,
}
#[cfg(feature = "core-session")]
impl From<CoreDownloadedFile> 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<CoreEvent> for IosEvent {
fn from(value: CoreEvent) -> Self {
match value {
@@ -442,12 +461,14 @@ impl From<CoreEvent> for IosEvent {
}
#[derive(uniffi::Object)]
#[cfg(feature = "core-session")]
pub struct SessionHandle {
session: Mutex<CoreSession<FakeTdClient>>,
runtime: tokio::runtime::Runtime,
}
#[uniffi::export]
#[cfg(feature = "core-session")]
pub fn create_session(config: IosSessionConfig) -> Result<SessionHandle, IosFfiError> {
if !config.use_fake_tdlib {
return Err(IosFfiError::Operation {
@@ -466,6 +487,7 @@ pub fn create_session(config: IosSessionConfig) -> Result<SessionHandle, IosFfiE
}
#[uniffi::export]
#[cfg(feature = "core-session")]
impl SessionHandle {
pub fn auth_state(&self) -> IosAuthState {
self.with_session(|session| session.auth_state().into())
@@ -694,12 +716,14 @@ impl SessionHandle {
}
impl SessionHandle {
#[cfg(feature = "core-session")]
fn with_session<R>(&self, f: impl FnOnce(&mut CoreSession<FakeTdClient>) -> 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<StandaloneFakeState>,
}
#[cfg(not(feature = "core-session"))]
struct StandaloneFakeState {
auth: IosAuthState,
chats: Vec<IosChatSummary>,
folders: Vec<IosFolder>,
messages: HashMap<i64, Vec<IosMessage>>,
profiles: HashMap<i64, IosProfile>,
events: Vec<IosEvent>,
downloaded_files: HashMap<i32, String>,
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<SessionHandle, IosFfiError> {
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<IosEvent> {
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<Vec<IosChatSummary>, 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<IosFolder> {
self.state
.lock()
.expect("session mutex poisoned")
.folders
.clone()
}
pub fn load_folder_chats(
&self,
folder_id: i32,
limit: i32,
) -> Result<Vec<IosChatSummary>, 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<Vec<IosMessage>, 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<Vec<IosSearchResult>, 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<IosProfile, IosFfiError> {
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<i64>,
) -> Result<IosMessage, IosFfiError> {
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<IosMessage, IosFfiError> {
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<i64>,
_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<i64>,
) -> 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<Vec<IosReaction>, 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<IosDownloadedFile, IosFfiError> {
self.standalone_download_file(file_id)
}
pub fn download_voice(&self, file_id: i32) -> Result<IosDownloadedFile, IosFfiError> {
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<IosDownloadedFile, IosFfiError> {
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::*;

View File

@@ -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.

View File

@@ -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}"