From 0878ba78df77aff79b8e31d3e17a4c69c057551c Mon Sep 17 00:00:00 2001 From: Mikhail Kilin Date: Wed, 20 May 2026 14:04:45 +0300 Subject: [PATCH] Add UniFFI iOS bridge crate --- Cargo.lock | 345 ++++++++++- Cargo.toml | 7 +- crates/tele-ios-ffi/Cargo.toml | 20 + crates/tele-ios-ffi/README.md | 25 + crates/tele-ios-ffi/src/lib.rs | 809 +++++++++++++++++++++++++ scripts/generate-ios-ffi-bindings.sh | 26 + tools/uniffi-bindgen-swift/Cargo.toml | 8 + tools/uniffi-bindgen-swift/src/main.rs | 3 + 8 files changed, 1241 insertions(+), 2 deletions(-) create mode 100644 crates/tele-ios-ffi/Cargo.toml create mode 100644 crates/tele-ios-ffi/README.md create mode 100644 crates/tele-ios-ffi/src/lib.rs create mode 100755 scripts/generate-ios-ffi-bindings.sh create mode 100644 tools/uniffi-bindgen-swift/Cargo.toml create mode 100644 tools/uniffi-bindgen-swift/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 3e3118d..8d0e053 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -203,6 +203,48 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "askama" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4" +dependencies = [ + "askama_derive", + "itoa", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "askama_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f" +dependencies = [ + "askama_parser", + "basic-toml", + "memchr", + "proc-macro2", + "quote", + "rustc-hash", + "serde", + "serde_derive", + "syn", +] + +[[package]] +name = "askama_parser" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358" +dependencies = [ + "memchr", + "serde", + "serde_derive", + "winnow", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -227,6 +269,19 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-compat" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1ba85bc55464dcbf728b56d97e119d673f4cf9062be330a9a26f3acf504a590" +dependencies = [ + "futures-core", + "futures-io", + "once_cell", + "pin-project-lite", + "tokio", +] + [[package]] name = "async-executor" version = "1.13.3" @@ -375,7 +430,7 @@ dependencies = [ "anyhow", "arrayvec", "log", - "nom", + "nom 8.0.0", "num-rational", "v_frame", ] @@ -405,6 +460,15 @@ dependencies = [ "vsimd", ] +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + [[package]] name = "bit_field" version = "0.10.3" @@ -518,6 +582,38 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "cassowary" version = "0.3.0" @@ -1408,6 +1504,15 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "fs-err" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41" +dependencies = [ + "autocfg", +] + [[package]] name = "fs2" version = "0.4.3" @@ -1538,6 +1643,23 @@ dependencies = [ "weezl", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "goblin" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47" +dependencies = [ + "log", + "plain", + "scroll", +] + [[package]] name = "h2" version = "0.4.13" @@ -2330,6 +2452,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2414,6 +2542,16 @@ dependencies = [ "pin-utils", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nom" version = "8.0.0" @@ -2833,6 +2971,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "plotters" version = "0.3.7" @@ -3342,6 +3486,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustc_version" version = "0.4.1" @@ -3479,6 +3629,26 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scroll" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" +dependencies = [ + "scroll_derive", +] + +[[package]] +name = "scroll_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -3507,6 +3677,10 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "serde" @@ -3776,6 +3950,12 @@ version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.11" @@ -3788,6 +3968,12 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smawk" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" + [[package]] name = "socket2" version = "0.6.2" @@ -3958,6 +4144,16 @@ dependencies = [ "tracing", ] +[[package]] +name = "tele-ios-ffi" +version = "0.1.0" +dependencies = [ + "tele-core", + "thiserror 1.0.69", + "tokio", + "uniffi", +] + [[package]] name = "tele-tui" version = "0.1.0" @@ -4036,6 +4232,15 @@ dependencies = [ "vt100", ] +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -4474,6 +4679,135 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +[[package]] +name = "uniffi" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc5f2297ee5b893405bed1a6929faec4713a061df158ecf5198089f23910d470" +dependencies = [ + "anyhow", + "camino", + "cargo_metadata", + "clap", + "uniffi_bindgen", + "uniffi_core", + "uniffi_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi-bindgen-swift" +version = "0.1.0" +dependencies = [ + "uniffi", +] + +[[package]] +name = "uniffi_bindgen" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bc0c60a9607e7ab77a2ad47ec5530178015014839db25af7512447d2238016c" +dependencies = [ + "anyhow", + "askama", + "camino", + "cargo_metadata", + "fs-err", + "glob", + "goblin", + "heck", + "indexmap 2.13.0", + "once_cell", + "serde", + "tempfile", + "textwrap", + "toml", + "uniffi_internal_macros", + "uniffi_meta", + "uniffi_pipeline", + "uniffi_udl", +] + +[[package]] +name = "uniffi_core" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77baf5d539fe2e1ad6805e942dbc5dbdeb2b83eb5f2b3a6535d422ca4b02a12f" +dependencies = [ + "anyhow", + "async-compat", + "bytes", + "once_cell", + "static_assertions", +] + +[[package]] +name = "uniffi_internal_macros" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4b42137524f4be6400fcaca9d02c1d4ecb6ad917e4013c0b93235526d8396e5" +dependencies = [ + "anyhow", + "indexmap 2.13.0", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "uniffi_macros" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9273ec45330d8fe9a3701b7b983cea7a4e218503359831967cb95d26b873561" +dependencies = [ + "camino", + "fs-err", + "once_cell", + "proc-macro2", + "quote", + "serde", + "syn", + "toml", + "uniffi_meta", +] + +[[package]] +name = "uniffi_meta" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "431d2f443e7828a6c29d188de98b6771a6491ee98bba2d4372643bf93f988a18" +dependencies = [ + "anyhow", + "siphasher", + "uniffi_internal_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi_pipeline" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "761ef74f6175e15603d0424cc5f98854c5baccfe7bf4ccb08e5816f9ab8af689" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.13.0", + "tempfile", + "uniffi_internal_macros", +] + +[[package]] +name = "uniffi_udl" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68773ec0e1c067b6505a73bbf6a5782f31a7f9209333a0df97b87565c46bf370" +dependencies = [ + "anyhow", + "textwrap", + "uniffi_meta", + "weedle2", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -4692,6 +5026,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "weedle2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "weezl" version = "0.1.12" diff --git a/Cargo.toml b/Cargo.toml index fb7c206..758505a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,4 +1,9 @@ [workspace] -members = ["crates/tele-core", "crates/tele-tui"] +members = [ + "crates/tele-core", + "crates/tele-ios-ffi", + "crates/tele-tui", + "tools/uniffi-bindgen-swift", +] default-members = ["crates/tele-tui"] resolver = "2" diff --git a/crates/tele-ios-ffi/Cargo.toml b/crates/tele-ios-ffi/Cargo.toml new file mode 100644 index 0000000..91e3110 --- /dev/null +++ b/crates/tele-ios-ffi/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "tele-ios-ffi" +version = "0.1.0" +edition = "2021" +authors = ["Your Name "] +description = "UniFFI bridge for the iOS Telegram client" +license = "MIT" +repository = "https://github.com/your-username/tele-tui" + +[lib] +crate-type = ["cdylib", "staticlib", "rlib"] + +[dependencies] +tele-core = { path = "../tele-core", features = ["test-support"] } +tokio = { version = "1", features = ["rt-multi-thread"] } +thiserror = "1.0" +uniffi = { version = "0.31.1", features = ["tokio"] } + +[dev-dependencies] +tele-core = { path = "../tele-core", features = ["test-support"] } diff --git a/crates/tele-ios-ffi/README.md b/crates/tele-ios-ffi/README.md new file mode 100644 index 0000000..ad79172 --- /dev/null +++ b/crates/tele-ios-ffi/README.md @@ -0,0 +1,25 @@ +# tele-ios-ffi + +UniFFI bridge for the future native iOS app. + +Current scope: + +- Exposes a fake-backed `SessionHandle` for Swift integration tests and app shell work. +- Mirrors the `tele-core::session` DTO/event model with UniFFI-compatible records and enums. +- Keeps real TDLib session creation out of this crate until iOS simulator/device linking is validated. + +Generate Swift bindings and headers: + +```bash +scripts/generate-ios-ffi-bindings.sh +``` + +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/`. + +Known blocker: + +- `xcodebuild -version` currently fails on this machine because only Command Line Tools are selected: + `xcode-select: error: tool 'xcodebuild' requires Xcode, but active developer directory '/Library/Developer/CommandLineTools' is a command line tools instance`. +- Real TDLib iOS simulator/device linking therefore is not validated in this phase. diff --git a/crates/tele-ios-ffi/src/lib.rs b/crates/tele-ios-ffi/src/lib.rs new file mode 100644 index 0000000..d636090 --- /dev/null +++ b/crates/tele-ios-ffi/src/lib.rs @@ -0,0 +1,809 @@ +use std::sync::Mutex; + +use tele_core::session::{ + CoreAuthState, CoreChatSummary, CoreDownloadedFile, CoreDraft, CoreEvent, CoreFolder, + CoreMedia, CoreMessage, CoreNetworkState, CoreProfile, CoreReaction, CoreSearchResult, + CoreSession, +}; +use tele_core::tdlib::{ChatInfo, MessageBuilder, NetworkState, ProfileInfo}; +use tele_core::test_support::FakeTdClient; +use tele_core::types::{ChatId, MessageId}; + +uniffi::setup_scaffolding!(); + +#[derive(Debug, thiserror::Error, uniffi::Error)] +pub enum IosFfiError { + #[error("{message}")] + Operation { message: String }, +} + +impl From for IosFfiError { + fn from(message: String) -> Self { + Self::Operation { message } + } +} + +#[derive(Debug, Clone, uniffi::Record)] +pub struct IosSessionConfig { + pub account_id: String, + pub display_name: String, + pub database_path: String, + pub use_fake_tdlib: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)] +pub enum IosAuthState { + WaitTdlibParameters, + WaitPhoneNumber, + WaitCode, + WaitPassword, + Ready, + Closed, + Error { message: String }, +} + +impl From for IosAuthState { + fn from(value: CoreAuthState) -> Self { + match value { + CoreAuthState::WaitTdlibParameters => Self::WaitTdlibParameters, + CoreAuthState::WaitPhoneNumber => Self::WaitPhoneNumber, + CoreAuthState::WaitCode => Self::WaitCode, + CoreAuthState::WaitPassword => Self::WaitPassword, + CoreAuthState::Ready => Self::Ready, + CoreAuthState::Closed => Self::Closed, + CoreAuthState::Error { message } => Self::Error { message }, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)] +pub enum IosNetworkState { + WaitingForNetwork, + ConnectingToProxy, + Connecting, + Updating, + Ready, +} + +impl From for IosNetworkState { + fn from(value: CoreNetworkState) -> Self { + match value { + CoreNetworkState::WaitingForNetwork => Self::WaitingForNetwork, + CoreNetworkState::ConnectingToProxy => Self::ConnectingToProxy, + CoreNetworkState::Connecting => Self::Connecting, + CoreNetworkState::Updating => Self::Updating, + CoreNetworkState::Ready => Self::Ready, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)] +pub enum IosDownloadState { + NotDownloaded, + Downloading, + Downloaded { path: String }, + Error { message: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct IosFolder { + pub id: i32, + pub name: String, +} + +impl From for IosFolder { + fn from(value: CoreFolder) -> Self { + Self { id: value.id, name: value.name } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct IosDraft { + pub chat_id: i64, + pub text: String, +} + +impl From for IosDraft { + fn from(value: CoreDraft) -> Self { + Self { chat_id: value.chat_id.as_i64(), text: value.text } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct IosChatSummary { + pub id: i64, + pub title: String, + pub username: Option, + pub last_message: String, + pub last_message_date: i32, + pub unread_count: i32, + pub unread_mention_count: i32, + pub is_pinned: bool, + pub order: i64, + pub last_read_outbox_message_id: i64, + pub folder_ids: Vec, + pub is_muted: bool, + pub draft: Option, +} + +impl From for IosChatSummary { + fn from(value: CoreChatSummary) -> Self { + Self { + id: value.id.as_i64(), + title: value.title, + username: value.username, + last_message: value.last_message, + last_message_date: value.last_message_date, + unread_count: value.unread_count, + unread_mention_count: value.unread_mention_count, + is_pinned: value.is_pinned, + order: value.order, + last_read_outbox_message_id: value.last_read_outbox_message_id.as_i64(), + folder_ids: value.folder_ids, + is_muted: value.is_muted, + draft: value.draft.map(IosDraft::from), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct IosReaction { + pub emoji: String, + pub count: i32, + pub is_chosen: bool, +} + +impl From for IosReaction { + fn from(value: CoreReaction) -> Self { + Self { + emoji: value.emoji, + count: value.count, + is_chosen: value.is_chosen, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct IosReply { + pub message_id: i64, + pub sender_name: String, + pub text: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct IosForward { + pub sender_name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct IosMedia { + pub kind: String, + pub file_id: i32, + pub width: Option, + pub height: Option, + pub duration: Option, + pub mime_type: Option, + pub waveform: Option, + pub download_state: IosDownloadState, +} + +impl From for IosMedia { + fn from(value: CoreMedia) -> Self { + match value { + CoreMedia::Photo(photo) => Self { + kind: "photo".to_string(), + file_id: photo.file_id, + width: Some(photo.width), + height: Some(photo.height), + duration: None, + mime_type: None, + waveform: None, + download_state: match photo.download_state { + tele_core::session::CoreDownloadState::NotDownloaded => { + IosDownloadState::NotDownloaded + } + tele_core::session::CoreDownloadState::Downloading => { + IosDownloadState::Downloading + } + tele_core::session::CoreDownloadState::Downloaded { path } => { + IosDownloadState::Downloaded { path } + } + tele_core::session::CoreDownloadState::Error { message } => { + IosDownloadState::Error { message } + } + }, + }, + CoreMedia::Voice(voice) => Self { + kind: "voice".to_string(), + file_id: voice.file_id, + width: None, + height: None, + duration: Some(voice.duration), + mime_type: Some(voice.mime_type), + waveform: Some(voice.waveform), + download_state: match voice.download_state { + tele_core::session::CoreDownloadState::NotDownloaded => { + IosDownloadState::NotDownloaded + } + tele_core::session::CoreDownloadState::Downloading => { + IosDownloadState::Downloading + } + tele_core::session::CoreDownloadState::Downloaded { path } => { + IosDownloadState::Downloaded { path } + } + tele_core::session::CoreDownloadState::Error { message } => { + IosDownloadState::Error { message } + } + }, + }, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct IosMessage { + pub id: i64, + pub sender_name: String, + pub date: i32, + pub edit_date: Option, + pub media_album_id: Option, + pub text: String, + pub media: Option, + pub is_outgoing: bool, + pub is_read: bool, + pub can_be_edited: bool, + pub can_be_deleted_only_for_self: bool, + pub can_be_deleted_for_all_users: bool, + pub reply: Option, + pub forward: Option, + pub reactions: Vec, +} + +impl From for IosMessage { + fn from(value: CoreMessage) -> Self { + Self { + id: value.id.as_i64(), + sender_name: value.sender_name, + date: value.date, + edit_date: value.edit_date, + media_album_id: value.media_album_id, + text: value.text, + media: value.media.map(IosMedia::from), + is_outgoing: value.is_outgoing, + is_read: value.is_read, + can_be_edited: value.can_be_edited, + can_be_deleted_only_for_self: value.can_be_deleted_only_for_self, + can_be_deleted_for_all_users: value.can_be_deleted_for_all_users, + reply: value.reply.map(|reply| IosReply { + message_id: reply.message_id.as_i64(), + sender_name: reply.sender_name, + text: reply.text, + }), + forward: value + .forward + .map(|forward| IosForward { sender_name: forward.sender_name }), + reactions: value.reactions.into_iter().map(IosReaction::from).collect(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct IosSearchResult { + pub chat_id: i64, + pub message: IosMessage, +} + +impl From for IosSearchResult { + fn from(value: CoreSearchResult) -> Self { + Self { + chat_id: value.chat_id.as_i64(), + message: IosMessage::from(value.message), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct IosProfile { + pub chat_id: i64, + pub title: String, + pub username: Option, + pub bio: Option, + pub phone_number: Option, + pub chat_type: String, + pub member_count: Option, + pub description: Option, + pub invite_link: Option, + pub is_group: bool, + pub online_status: Option, +} + +impl From for IosProfile { + fn from(value: CoreProfile) -> Self { + Self { + chat_id: value.chat_id.as_i64(), + title: value.title, + username: value.username, + bio: value.bio, + phone_number: value.phone_number, + chat_type: value.chat_type, + member_count: value.member_count, + description: value.description, + invite_link: value.invite_link, + is_group: value.is_group, + online_status: value.online_status, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct IosDownloadedFile { + pub file_id: i32, + pub path: String, +} + +impl From for IosDownloadedFile { + fn from(value: CoreDownloadedFile) -> Self { + Self { file_id: value.file_id, path: value.path } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)] +pub enum IosEvent { + AuthChanged { + state: IosAuthState, + }, + ChatListChanged { + chats: Vec, + }, + FolderListChanged { + folders: Vec, + }, + MessageAdded { + chat_id: i64, + message: IosMessage, + }, + MessageUpdated { + chat_id: i64, + message: IosMessage, + }, + MessageDeleted { + chat_id: i64, + message_ids: Vec, + }, + ReactionChanged { + chat_id: i64, + message_id: i64, + reactions: Vec, + }, + IncomingNotificationCandidate { + chat: IosChatSummary, + message: IosMessage, + sender_name: String, + }, + NetworkChanged { + state: IosNetworkState, + }, + DraftChanged { + draft: IosDraft, + }, + ProfileLoaded { + profile: IosProfile, + }, + MediaDownloadProgress { + file_id: i32, + downloaded_size: i64, + total_size: i64, + }, +} + +impl From for IosEvent { + fn from(value: CoreEvent) -> Self { + match value { + CoreEvent::AuthChanged(state) => Self::AuthChanged { state: state.into() }, + CoreEvent::ChatListChanged(chats) => Self::ChatListChanged { + chats: chats.into_iter().map(IosChatSummary::from).collect(), + }, + CoreEvent::FolderListChanged(folders) => Self::FolderListChanged { + folders: folders.into_iter().map(IosFolder::from).collect(), + }, + CoreEvent::MessageAdded { chat_id, message } => { + Self::MessageAdded { chat_id: chat_id.as_i64(), message: message.into() } + } + CoreEvent::MessageUpdated { chat_id, message } => { + Self::MessageUpdated { chat_id: chat_id.as_i64(), message: message.into() } + } + CoreEvent::MessageDeleted { chat_id, message_ids } => Self::MessageDeleted { + chat_id: chat_id.as_i64(), + message_ids: message_ids.into_iter().map(|id| id.as_i64()).collect(), + }, + CoreEvent::ReactionChanged { chat_id, message_id, reactions } => { + Self::ReactionChanged { + chat_id: chat_id.as_i64(), + message_id: message_id.as_i64(), + reactions: reactions.into_iter().map(IosReaction::from).collect(), + } + } + CoreEvent::IncomingNotificationCandidate(candidate) => { + Self::IncomingNotificationCandidate { + chat: candidate.chat.into(), + message: candidate.message.into(), + sender_name: candidate.sender_name, + } + } + CoreEvent::NetworkChanged(state) => Self::NetworkChanged { state: state.into() }, + CoreEvent::DraftChanged(draft) => Self::DraftChanged { draft: draft.into() }, + CoreEvent::ProfileLoaded(profile) => Self::ProfileLoaded { profile: profile.into() }, + CoreEvent::MediaDownloadProgress { file_id, downloaded_size, total_size } => { + Self::MediaDownloadProgress { file_id, downloaded_size, total_size } + } + CoreEvent::TypingChanged(_) => Self::NetworkChanged { state: IosNetworkState::Ready }, + } + } +} + +#[derive(uniffi::Object)] +pub struct SessionHandle { + session: Mutex>, + runtime: tokio::runtime::Runtime, +} + +#[uniffi::export] +pub fn create_session(config: IosSessionConfig) -> Result { + if !config.use_fake_tdlib { + return Err(IosFfiError::Operation { + message: "real TDLib sessions are not exposed by the iOS FFI crate yet".to_string(), + }); + } + + let client = seeded_fake_client(); + Ok(SessionHandle { + session: Mutex::new(CoreSession::new(client)), + runtime: tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(|error| IosFfiError::Operation { message: error.to_string() })?, + }) +} + +#[uniffi::export] +impl SessionHandle { + pub fn auth_state(&self) -> IosAuthState { + self.with_session(|session| session.auth_state().into()) + } + + pub fn poll_events(&self) -> Vec { + self.with_session(|session| { + session.drain_client_events(); + session + .poll_events() + .into_iter() + .map(IosEvent::from) + .collect() + }) + } + + pub fn send_phone_number(&self, phone: String) -> Result<(), IosFfiError> { + let session = self.session.lock().expect("session mutex poisoned"); + self.runtime + .block_on(session.send_phone_number(phone)) + .map_err(IosFfiError::from) + } + + pub fn send_code(&self, code: String) -> Result<(), IosFfiError> { + let session = self.session.lock().expect("session mutex poisoned"); + self.runtime + .block_on(session.send_code(code)) + .map_err(IosFfiError::from) + } + + pub fn send_password(&self, password: String) -> Result<(), IosFfiError> { + let session = self.session.lock().expect("session mutex poisoned"); + self.runtime + .block_on(session.send_password(password)) + .map_err(IosFfiError::from) + } + + pub fn load_chats(&self, limit: i32) -> Result, IosFfiError> { + let mut session = self.session.lock().expect("session mutex poisoned"); + let chats = self + .runtime + .block_on(session.client().load_chats(limit as usize)) + .map_err(IosFfiError::from)?; + let core_chats: Vec<_> = chats.iter().map(CoreChatSummary::from).collect(); + session.enqueue_event(CoreEvent::ChatListChanged(core_chats.clone())); + Ok(core_chats.into_iter().map(IosChatSummary::from).collect()) + } + + pub fn load_folders(&self) -> Vec { + self.with_session(|session| { + session + .client() + .get_folders() + .into_iter() + .map(|folder| IosFolder::from(CoreFolder::from(&folder))) + .collect() + }) + } + + pub fn load_folder_chats( + &self, + folder_id: i32, + limit: i32, + ) -> Result, IosFfiError> { + let mut session = self.session.lock().expect("session mutex poisoned"); + self.runtime + .block_on( + session + .client() + .load_folder_chats(folder_id, limit as usize), + ) + .map_err(IosFfiError::from)?; + let chats: Vec<_> = session + .client() + .get_chats() + .into_iter() + .filter(|chat| chat.folder_ids.contains(&folder_id)) + .map(|chat| CoreChatSummary::from(&chat)) + .collect(); + session.enqueue_event(CoreEvent::ChatListChanged(chats.clone())); + Ok(chats.into_iter().map(IosChatSummary::from).collect()) + } + + pub fn load_history(&self, chat_id: i64, limit: i32) -> Result, IosFfiError> { + let mut session = self.session.lock().expect("session mutex poisoned"); + self.runtime + .block_on(session.open_chat_history(ChatId::new(chat_id), limit)) + .map(|messages| messages.into_iter().map(IosMessage::from).collect()) + .map_err(IosFfiError::from) + } + + pub fn search_messages( + &self, + chat_id: i64, + query: String, + ) -> Result, IosFfiError> { + let session = self.session.lock().expect("session mutex poisoned"); + self.runtime + .block_on(session.search_messages(ChatId::new(chat_id), &query)) + .map(|results| results.into_iter().map(IosSearchResult::from).collect()) + .map_err(IosFfiError::from) + } + + pub fn open_profile(&self, chat_id: i64) -> Result { + let mut session = self.session.lock().expect("session mutex poisoned"); + self.runtime + .block_on(session.open_profile(ChatId::new(chat_id))) + .map(IosProfile::from) + .map_err(IosFfiError::from) + } + + pub fn send_message( + &self, + chat_id: i64, + text: String, + reply_to_message_id: Option, + ) -> Result { + let mut session = self.session.lock().expect("session mutex poisoned"); + self.runtime + .block_on(session.send_text_message( + ChatId::new(chat_id), + text, + reply_to_message_id.map(MessageId::new), + None, + )) + .map(IosMessage::from) + .map_err(IosFfiError::from) + } + + pub fn edit_message( + &self, + chat_id: i64, + message_id: i64, + text: String, + ) -> Result { + let mut session = self.session.lock().expect("session mutex poisoned"); + self.runtime + .block_on(session.edit_text_message( + ChatId::new(chat_id), + MessageId::new(message_id), + text, + )) + .map(IosMessage::from) + .map_err(IosFfiError::from) + } + + pub fn delete_messages( + &self, + chat_id: i64, + message_ids: Vec, + revoke: bool, + ) -> Result<(), IosFfiError> { + let mut session = self.session.lock().expect("session mutex poisoned"); + self.runtime + .block_on(session.delete_messages( + ChatId::new(chat_id), + message_ids.into_iter().map(MessageId::new).collect(), + revoke, + )) + .map_err(IosFfiError::from) + } + + pub fn forward_messages( + &self, + to_chat_id: i64, + from_chat_id: i64, + message_ids: Vec, + ) -> Result<(), IosFfiError> { + let mut session = self.session.lock().expect("session mutex poisoned"); + self.runtime + .block_on(session.forward_messages( + ChatId::new(to_chat_id), + ChatId::new(from_chat_id), + message_ids.into_iter().map(MessageId::new).collect(), + )) + .map_err(IosFfiError::from) + } + + pub fn react( + &self, + chat_id: i64, + message_id: i64, + reaction: String, + ) -> Result, IosFfiError> { + let mut session = self.session.lock().expect("session mutex poisoned"); + self.runtime + .block_on(session.toggle_reaction( + ChatId::new(chat_id), + MessageId::new(message_id), + reaction, + )) + .map(|reactions| reactions.into_iter().map(IosReaction::from).collect()) + .map_err(IosFfiError::from) + } + + pub fn set_draft(&self, chat_id: i64, text: String) -> Result<(), IosFfiError> { + let mut session = self.session.lock().expect("session mutex poisoned"); + self.runtime + .block_on(session.set_draft(ChatId::new(chat_id), text)) + .map_err(IosFfiError::from) + } + + pub fn download_photo(&self, file_id: i32) -> Result { + let session = self.session.lock().expect("session mutex poisoned"); + self.runtime + .block_on(session.download_photo(file_id)) + .map(IosDownloadedFile::from) + .map_err(IosFfiError::from) + } + + pub fn download_voice(&self, file_id: i32) -> Result { + let session = self.session.lock().expect("session mutex poisoned"); + self.runtime + .block_on(session.download_voice(file_id)) + .map(IosDownloadedFile::from) + .map_err(IosFfiError::from) + } + + pub fn simulate_incoming_message(&self, chat_id: i64, text: String, sender_name: String) { + self.with_session(|session| { + session + .client() + .simulate_incoming_message(ChatId::new(chat_id), text, &sender_name); + }); + } +} + +impl SessionHandle { + fn with_session(&self, f: impl FnOnce(&mut CoreSession) -> R) -> R { + let mut session = self.session.lock().expect("session mutex poisoned"); + f(&mut session) + } +} + +fn seeded_fake_client() -> FakeTdClient { + let chat = ChatInfo { + id: ChatId::new(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: MessageId::new(1), + folder_ids: vec![0], + is_muted: false, + draft_text: None, + }; + let message = MessageBuilder::new(MessageId::new(1)) + .sender_name("Alice") + .text("Hello from fake TDLib") + .date(1_700_000_000) + .build(); + let profile = ProfileInfo { + 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()), + }; + + FakeTdClient::new() + .with_chat(chat.clone()) + .with_message(chat.id.as_i64(), message) + .with_profile(chat.id.as_i64(), profile) + .with_network_state(NetworkState::Ready) + .with_downloaded_file(100, "/tmp/fake-photo.jpg") + .with_downloaded_file(200, "/tmp/fake-voice.ogg") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fake_session_can_load_send_react_search_and_poll_events() { + let session = create_session(IosSessionConfig { + account_id: "fake".to_string(), + display_name: "Fake".to_string(), + database_path: "/tmp/fake".to_string(), + use_fake_tdlib: true, + }) + .unwrap(); + + let chats = session.load_chats(20).unwrap(); + assert_eq!(chats.len(), 1); + + let history = session.load_history(chats[0].id, 20).unwrap(); + assert_eq!(history[0].text, "Hello from fake TDLib"); + + let sent = session + .send_message(chats[0].id, "Hi from Swift".to_string(), None) + .unwrap(); + assert_eq!(sent.text, "Hi from Swift"); + + let reactions = session + .react(chats[0].id, sent.id, "👍".to_string()) + .unwrap(); + assert_eq!(reactions[0].emoji, "👍"); + + let results = session + .search_messages(chats[0].id, "Swift".to_string()) + .unwrap(); + assert_eq!(results.len(), 1); + + session.simulate_incoming_message(chats[0].id, "Incoming".to_string(), "Bob".to_string()); + let events = session.poll_events(); + assert!(events + .iter() + .any(|event| matches!(event, IosEvent::MessageAdded { .. }))); + assert!(events + .iter() + .any(|event| matches!(event, IosEvent::ReactionChanged { .. }))); + assert!(events + .iter() + .any(|event| matches!(event, IosEvent::IncomingNotificationCandidate { .. }))); + } + + #[test] + fn real_tdlib_sessions_are_reported_as_not_yet_exposed() { + let error = match create_session(IosSessionConfig { + account_id: "real".to_string(), + display_name: "Real".to_string(), + database_path: "/tmp/tdlib".to_string(), + use_fake_tdlib: false, + }) { + Ok(_) => panic!("real TDLib session should not be exposed yet"), + Err(error) => error, + }; + + assert!(format!("{error}").contains("real TDLib sessions")); + } +} diff --git a/scripts/generate-ios-ffi-bindings.sh b/scripts/generate-ios-ffi-bindings.sh new file mode 100755 index 0000000..0e83f9d --- /dev/null +++ b/scripts/generate-ios-ffi-bindings.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +OUT_DIR="${1:-$ROOT_DIR/build/ios-ffi}" +LIB_PATH="$ROOT_DIR/target/release/libtele_ios_ffi.a" + +cd "$ROOT_DIR" + +cargo build -p tele-ios-ffi --release +rm -rf "$OUT_DIR" +mkdir -p "$OUT_DIR/Swift" "$OUT_DIR/Headers" "$OUT_DIR/Modules" + +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 +cargo run -p uniffi-bindgen-swift -- "$LIB_PATH" "$OUT_DIR/Modules" \ + --xcframework \ + --modulemap \ + --module-name tele_ios_ffiFFI \ + --modulemap-filename module.modulemap + +printf 'Generated UniFFI Swift bindings in %s\n' "$OUT_DIR" diff --git a/tools/uniffi-bindgen-swift/Cargo.toml b/tools/uniffi-bindgen-swift/Cargo.toml new file mode 100644 index 0000000..c236bf3 --- /dev/null +++ b/tools/uniffi-bindgen-swift/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "uniffi-bindgen-swift" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +uniffi = { version = "0.31.1", features = ["cli"] } diff --git a/tools/uniffi-bindgen-swift/src/main.rs b/tools/uniffi-bindgen-swift/src/main.rs new file mode 100644 index 0000000..5641fc6 --- /dev/null +++ b/tools/uniffi-bindgen-swift/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + uniffi::uniffi_bindgen_swift() +}