diff --git a/CLAUDE.md b/CLAUDE.md index 88a8097ba..2bf8b6e7d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -149,10 +149,12 @@ Key file: `src/utils/desktopDeepLinkListener.ts` (lazy-loaded in `main.tsx`). Us Enhanced Rust backend with comprehensive skill execution and runtime management: **Core Commands:** + - `greet` — demo command - `exchange_token` — CORS-free HTTP POST to backend for token exchange (desktop only) **Runtime Management:** + - `discover_skills` — V8 skill discovery and manifest parsing - `enable_skill` / `disable_skill` — skill lifecycle management - `get_skill_preferences` / `set_skill_preferences` — skill configuration @@ -160,6 +162,7 @@ Enhanced Rust backend with comprehensive skill execution and runtime management: - `get_socket_status` — connection status monitoring **Android Support:** + - `RuntimeService` — background service for skill execution - Notification permissions and foreground service management - Android logging integration with logcat @@ -171,6 +174,7 @@ Deep link plugin registered at setup. `register_all()` called only on Windows/Li Advanced JavaScript execution engine for skills using V8 (via deno_core): **Core Components:** + - `v8_engine.rs` — V8 JavaScript runtime initialization and management - `v8_skill_instance.rs` — Individual skill execution contexts and lifecycle - `skill_registry.rs` — Skill discovery, registration, and state management @@ -180,6 +184,7 @@ Advanced JavaScript execution engine for skills using V8 (via deno_core): - `preferences.rs` — Skill configuration and settings persistence **Bridge System (`src-tauri/src/runtime/bridge/`):** + - `skills_bridge.rs` — Skill-to-skill communication and state sharing - `tauri_bridge.rs` — Frontend-backend IPC and environment access - `net.rs` — HTTP/fetch operations for skills @@ -189,12 +194,14 @@ Advanced JavaScript execution engine for skills using V8 (via deno_core): - `cron_bridge.rs` — Cron job scheduling and management **TDLib Integration (`src-tauri/src/services/tdlib_v8/`):** + - `service.rs` — High-level TDLib client management with V8 integration - `bootstrap.js` — V8 JavaScript bootstrap environment - `ops/mod.rs` — Native operations for WebSocket, timers, and async handling - `storage.rs` — Persistent storage for TDLib sessions and data **Platform Support:** + - Desktop platforms: Full V8 runtime with all features - Mobile platforms: Error handling with feature availability checks - Platform-specific skill filtering based on manifest declarations @@ -338,7 +345,9 @@ Key updates from recent commits (cd9ebcd to current): ## Git Workflow -- **PR target branch**: All pull requests should target the `develop` branch, not `main`. +- **Push target**: All pushes go to the **user's private repo** (your fork). Do not push directly to the org repository. +- **PR target**: All pull requests are opened **from your fork** against the **org's private repo**, targeting the **`develop`** branch (not `main`). +- **No direct pushes to org**: The org repo does not allow direct pushes. All changes reach the org repo via PRs from your fork. ## Key Patterns diff --git a/logo.png b/logo.png new file mode 100644 index 000000000..8e140ac02 Binary files /dev/null and b/logo.png differ diff --git a/package.json b/package.json index 7a766dad6..d7c8c75f7 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "vite", + "dev:web": "vite", "build": "tsc && vite build", "compile": "tsc --noEmit", "preview": "vite preview", diff --git a/skills b/skills index 54c40a1b0..79e21914f 160000 --- a/skills +++ b/skills @@ -1 +1 @@ -Subproject commit 54c40a1b0d3daa909fbd95d5018343922b648fab +Subproject commit 79e21914f49862b7e1942ef31eea88e1d43a0537 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ac25de84f..03fa225a7 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -36,6 +36,7 @@ dependencies = [ "tauri-plugin-deep-link", "tauri-plugin-notification", "tauri-plugin-opener", + "tdlib-rs", "tokio", "tokio-tungstenite 0.24.0", "uuid", @@ -212,6 +213,15 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "argon2" version = "0.5.3" @@ -623,6 +633,25 @@ dependencies = [ "serde", ] +[[package]] +name = "bzip2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" +dependencies = [ + "bzip2-sys", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "cairo-rs" version = "0.18.5" @@ -697,6 +726,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -828,6 +859,12 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + [[package]] name = "convert_case" version = "0.4.0" @@ -909,6 +946,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + [[package]] name = "crc32fast" version = "1.5.0" @@ -1058,6 +1110,12 @@ dependencies = [ "uuid", ] +[[package]] +name = "deflate64" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26bf8fc351c5ed29b5c2f0cbbac1b209b74f60ecd62e675a998df72c49af5204" + [[package]] name = "deno_core" version = "0.314.2" @@ -1132,6 +1190,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "derive_more" version = "0.99.20" @@ -2086,6 +2155,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "home" version = "0.5.12" @@ -2550,6 +2628,16 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.85" @@ -2737,6 +2825,27 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lzma-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" +dependencies = [ + "byteorder", + "crc", +] + +[[package]] +name = "lzma-sys" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "mac" version = "0.1.1" @@ -3409,6 +3518,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -5342,6 +5461,40 @@ dependencies = [ "windows-version", ] +[[package]] +name = "tdlib-rs" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c309480dcdd6d5dc2f37866d9063fed280780ddfeb51ae3a0adc2b52b0c0bc3" +dependencies = [ + "dirs 6.0.0", + "futures-channel", + "log", + "once_cell", + "reqwest", + "serde", + "serde_json", + "serde_with", + "tdlib-rs-gen", + "tdlib-rs-parser", + "zip", +] + +[[package]] +name = "tdlib-rs-gen" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff69c8cab3e5285d2f79f53263077b2cdb12a841b230406e3b1230a345c78968" +dependencies = [ + "tdlib-rs-parser", +] + +[[package]] +name = "tdlib-rs-parser" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20b1c6703d2284b9d4ddb620cd350f726a1c43bb6f7801f4361b55db2421caa8" + [[package]] name = "tempfile" version = "3.24.0" @@ -6905,6 +7058,15 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "xz2" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" +dependencies = [ + "lzma-sys", +] + [[package]] name = "yoke" version = "0.8.1" @@ -7035,6 +7197,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] [[package]] name = "zerotrie" @@ -7069,12 +7245,82 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "aes", + "arbitrary", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "deflate64", + "displaydoc", + "flate2", + "getrandom 0.3.4", + "hmac", + "indexmap 2.13.0", + "lzma-rs", + "memchr", + "pbkdf2", + "sha1", + "thiserror 2.0.18", + "time", + "xz2", + "zeroize", + "zopfli", + "zstd", +] + [[package]] name = "zmij" version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "zvariant" version = "5.9.2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 04d645390..b0c35d618 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -90,6 +90,12 @@ tauri-plugin-notification = "2" # V8 JavaScript runtime (via deno_core - powers Deno) # Only available on desktop - prebuilt binaries don't exist for Android/iOS deno_core = "0.314" +# TDLib Rust bindings (desktop only - downloads prebuilt TDLib library) +tdlib-rs = { version = "1.2", features = ["download-tdlib"] } + +[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.build-dependencies] +# TDLib build configuration +tdlib-rs = { version = "1.2", features = ["download-tdlib"] } [features] # This feature is used for production builds or when a dev server is not specified, DO NOT REMOVE!! diff --git a/src-tauri/build.rs b/src-tauri/build.rs index d860e1e6a..df2e3e4e0 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,3 +1,13 @@ fn main() { + // TDLib build configuration (desktop only) + // The tdlib-rs crate with download-tdlib feature handles downloading and linking + // the prebuilt TDLib library automatically. + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + // Download and link TDLib library + // Pass None to use default download location + tdlib_rs::build::build(None); + } + tauri_build::build() } diff --git a/src-tauri/gen/android/app/build.gradle.kts b/src-tauri/gen/android/app/build.gradle.kts index 9a61adfa5..3a34dec6f 100644 --- a/src-tauri/gen/android/app/build.gradle.kts +++ b/src-tauri/gen/android/app/build.gradle.kts @@ -63,6 +63,8 @@ dependencies { implementation("androidx.core:core-ktx:1.16.0") implementation("androidx.activity:activity-ktx:1.10.1") implementation("com.google.android.material:material:1.12.0") + // TDLib Android library (official Telegram library) + implementation("org.drinkless:td:1.8.29") testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.1.4") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0") diff --git a/src-tauri/gen/android/app/src/main/java/com/alphahuman/app/TdLibBridge.kt b/src-tauri/gen/android/app/src/main/java/com/alphahuman/app/TdLibBridge.kt new file mode 100644 index 000000000..5aa67b952 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/java/com/alphahuman/app/TdLibBridge.kt @@ -0,0 +1,294 @@ +package com.alphahuman.app + +import android.util.Log +import org.drinkless.tdlib.Client +import org.drinkless.tdlib.TdApi +import org.json.JSONObject +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong + +/** + * TDLib Bridge for Android + * + * Provides a JNI-accessible interface to TDLib for the Rust backend. + * Manages TDLib client lifecycle and provides JSON-based request/response interface. + */ +object TdLibBridge { + private const val TAG = "TdLibBridge" + + // Client instance (singleton) + private var client: Client? = null + + // Request ID counter for correlation + private val requestIdCounter = AtomicLong(1) + + // Pending requests waiting for responses + private val pendingRequests = ConcurrentHashMap Unit>() + + // Update handler callback + private var updateHandler: ((String) -> Unit)? = null + + // Client ID (always 1 for singleton) + private const val CLIENT_ID = 1 + + /** + * Create a TDLib client. + * @return Client ID (always 1) + */ + @JvmStatic + fun createClient(): Int { + Log.i(TAG, "Creating TDLib client") + + if (client != null) { + Log.w(TAG, "Client already exists, returning existing client ID") + return CLIENT_ID + } + + // Create result handler that processes responses and updates + val resultHandler = Client.ResultHandler { result -> + handleResult(result) + } + + // Create exception handler + val exceptionHandler = Client.ExceptionHandler { e -> + Log.e(TAG, "TDLib exception", e) + } + + // Create the client + client = Client.create(resultHandler, exceptionHandler, exceptionHandler) + + Log.i(TAG, "TDLib client created successfully") + return CLIENT_ID + } + + /** + * Handle a TDLib result (response or update). + */ + private fun handleResult(result: TdApi.Object) { + // Convert to JSON for the Rust side + val json = tdObjectToJson(result) + + // Check if this is a response to a pending request (has @extra) + // Note: TDLib Java API doesn't expose @extra directly, so we handle responses + // through the synchronous send pattern instead + + // For updates, call the update handler + if (result is TdApi.Update) { + updateHandler?.invoke(json) + } + } + + /** + * Send a synchronous request to TDLib. + * @param requestJson JSON string of the TDLib API request + * @return JSON string of the response + */ + @JvmStatic + fun send(clientId: Int, requestJson: String): String { + val currentClient = client + if (currentClient == null) { + Log.e(TAG, "Client not initialized") + return """{"@type":"error","code":400,"message":"Client not initialized"}""" + } + + try { + Log.d(TAG, "Sending request: $requestJson") + + // Parse the JSON request + val function = jsonToTdFunction(requestJson) + if (function == null) { + return """{"@type":"error","code":400,"message":"Invalid request format"}""" + } + + // Execute synchronously + val result = currentClient.send(function) + + // Convert result to JSON + val responseJson = tdObjectToJson(result) + Log.d(TAG, "Received response: $responseJson") + + return responseJson + } catch (e: Exception) { + Log.e(TAG, "Error sending request", e) + return """{"@type":"error","code":500,"message":"${e.message?.replace("\"", "\\\"")}"}""" + } + } + + /** + * Receive updates from TDLib (with timeout). + * @param timeout Timeout in seconds + * @return JSON string of the update, or null if timeout + */ + @JvmStatic + fun receive(timeout: Double): String? { + val currentClient = client ?: return null + + try { + val result = Client.execute(TdApi.GetOption("version")) + // The actual receiving is done via the result handler callback + // This method is mainly for polling pattern support + return null + } catch (e: Exception) { + Log.e(TAG, "Error receiving", e) + return null + } + } + + /** + * Set the update handler callback. + * @param handler Function that receives update JSON strings + */ + @JvmStatic + fun setUpdateHandler(handler: (String) -> Unit) { + updateHandler = handler + } + + /** + * Destroy the TDLib client. + */ + @JvmStatic + fun destroyClient(clientId: Int) { + Log.i(TAG, "Destroying TDLib client") + + client?.close() + client = null + pendingRequests.clear() + updateHandler = null + + Log.i(TAG, "TDLib client destroyed") + } + + /** + * Check if TDLib is available. + */ + @JvmStatic + fun isAvailable(): Boolean { + return try { + // Try to load the TDLib native library + System.loadLibrary("tdjni") + true + } catch (e: UnsatisfiedLinkError) { + Log.e(TAG, "TDLib native library not found", e) + false + } + } + + /** + * Convert a TDLib object to JSON string. + * Note: This is a simplified implementation. TDLib Java API provides toString() + * which returns a debug representation, not proper JSON. + */ + private fun tdObjectToJson(obj: TdApi.Object): String { + // Use TDLib's built-in serialization + // The toString() method provides a debug format, we need proper JSON + return try { + // For now, return a simple JSON representation + // In production, use TDLib's JSON serialization or implement proper conversion + val json = JSONObject() + json.put("@type", obj.javaClass.simpleName.replaceFirstChar { it.lowercase() }) + + // Handle common types + when (obj) { + is TdApi.Error -> { + json.put("code", obj.code) + json.put("message", obj.message) + } + is TdApi.Ok -> { + // Empty ok response + } + is TdApi.User -> { + json.put("id", obj.id) + json.put("first_name", obj.firstName) + json.put("last_name", obj.lastName) + json.put("username", obj.usernames?.activeUsernames?.firstOrNull() ?: "") + } + is TdApi.AuthorizationStateWaitTdlibParameters -> { + json.put("@type", "authorizationStateWaitTdlibParameters") + } + is TdApi.AuthorizationStateWaitPhoneNumber -> { + json.put("@type", "authorizationStateWaitPhoneNumber") + } + is TdApi.AuthorizationStateWaitCode -> { + json.put("@type", "authorizationStateWaitCode") + } + is TdApi.AuthorizationStateReady -> { + json.put("@type", "authorizationStateReady") + } + is TdApi.UpdateAuthorizationState -> { + json.put("@type", "updateAuthorizationState") + json.put("authorization_state", tdObjectToJson(obj.authorizationState)) + } + else -> { + // Generic handling - just use toString for now + json.put("raw", obj.toString()) + } + } + + json.toString() + } catch (e: Exception) { + Log.e(TAG, "Error converting TdObject to JSON", e) + """{"@type":"error","code":500,"message":"JSON conversion failed"}""" + } + } + + /** + * Convert a JSON string to a TDLib function. + * Note: This is a simplified implementation. Full implementation would parse + * all TDLib API types. + */ + private fun jsonToTdFunction(json: String): TdApi.Function<*>? { + return try { + val obj = JSONObject(json) + val type = obj.optString("@type", "") + + when (type) { + "setTdlibParameters" -> TdApi.SetTdlibParameters().apply { + databaseDirectory = obj.optString("database_directory", "") + useMessageDatabase = obj.optBoolean("use_message_database", true) + useSecretChats = obj.optBoolean("use_secret_chats", false) + apiId = obj.optInt("api_id", 0) + apiHash = obj.optString("api_hash", "") + systemLanguageCode = obj.optString("system_language_code", "en") + deviceModel = obj.optString("device_model", "Android") + applicationVersion = obj.optString("application_version", "1.0") + } + "setAuthenticationPhoneNumber" -> TdApi.SetAuthenticationPhoneNumber( + obj.optString("phone_number", ""), + null + ) + "checkAuthenticationCode" -> TdApi.CheckAuthenticationCode( + obj.optString("code", "") + ) + "getMe" -> TdApi.GetMe() + "getChats" -> TdApi.GetChats( + null, + obj.optInt("limit", 100) + ) + "getChat" -> TdApi.GetChat( + obj.optLong("chat_id", 0) + ) + "sendMessage" -> { + val chatId = obj.optLong("chat_id", 0) + val text = obj.optString("text", "") + val inputContent = TdApi.InputMessageText( + TdApi.FormattedText(text, emptyArray()), + null, + false + ) + TdApi.SendMessage(chatId, 0, null, null, null, inputContent) + } + "close" -> TdApi.Close() + "logOut" -> TdApi.LogOut() + "getOption" -> TdApi.GetOption(obj.optString("name", "")) + else -> { + Log.w(TAG, "Unknown function type: $type") + null + } + } + } catch (e: Exception) { + Log.e(TAG, "Error parsing JSON to TdFunction", e) + null + } + } +} diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/src-tauri/gen/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..2ffbf24b6 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index 28f1aa119..6394bf52a 100644 Binary files a/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png index 85d0c88af..2744453d9 100644 Binary files a/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png index 28f1aa119..a47cfa117 100644 Binary files a/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 73e48dbfb..e9d1b100e 100644 Binary files a/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png index 13dd21476..1c9b7cb4b 100644 Binary files a/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png index 73e48dbfb..8c502d6a6 100644 Binary files a/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 1d98044f1..fb62009dc 100644 Binary files a/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png index a888b336b..47e64c2a1 100644 Binary files a/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png index 1d98044f1..da99fa7c4 100644 Binary files a/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index 081832466..7580bbeb0 100644 Binary files a/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png index a2a838e7b..3754c9855 100644 Binary files a/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png index 081832466..d7c86999e 100644 Binary files a/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index b18bceb64..20b385e30 100644 Binary files a/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png index 3f8a57f38..779b83ac6 100644 Binary files a/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png index b18bceb64..dedbaf445 100644 Binary files a/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/gen/android/app/src/main/res/values/ic_launcher_background.xml b/src-tauri/gen/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 000000000..ea9c223a6 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #fff + \ No newline at end of file diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@1x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@1x.png index a6ac2a8cc..05f28fb8f 100644 Binary files a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@1x.png and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@1x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x-1.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x-1.png index 2869541f7..80c565083 100644 Binary files a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x-1.png and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x-1.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x.png index 2869541f7..80c565083 100644 Binary files a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x.png and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@3x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@3x.png index cf265a45d..e49ca99c1 100644 Binary files a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@3x.png and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@3x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@1x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@1x.png index 29c9746c0..8af437781 100644 Binary files a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@1x.png and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@1x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x-1.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x-1.png index a4e68c8d6..395d0a252 100644 Binary files a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x-1.png and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x-1.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x.png index a4e68c8d6..395d0a252 100644 Binary files a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x.png and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@3x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@3x.png index e4adcbced..318a6031c 100644 Binary files a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@3x.png and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@3x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@1x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@1x.png index 2869541f7..80c565083 100644 Binary files a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@1x.png and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@1x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x-1.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x-1.png index a414e65bb..67d46aa55 100644 Binary files a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x-1.png and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x-1.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x.png index a414e65bb..67d46aa55 100644 Binary files a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x.png and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@3x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@3x.png index a0807e5df..77fafac03 100644 Binary files a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@3x.png and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@3x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png index 704c92913..7c12d5f12 100644 Binary files a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x.png index a0807e5df..77fafac03 100644 Binary files a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x.png and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@3x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@3x.png index 2a9fbc26e..d48cbf99b 100644 Binary files a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@3x.png and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@3x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@1x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@1x.png index 2cdf18485..8cab5aa63 100644 Binary files a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@1x.png and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@1x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@2x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@2x.png index 4723e4b45..106a5a03c 100644 Binary files a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@2x.png and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@2x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5x83.5@2x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5x83.5@2x.png index f26fee45c..e2df3a800 100644 Binary files a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5x83.5@2x.png and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5x83.5@2x.png differ diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png index 62d9ac8e4..90aa1aa16 100644 Binary files a/src-tauri/icons/128x128.png and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png index 8e140ac02..f68176d2a 100644 Binary files a/src-tauri/icons/128x128@2x.png and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png index 5e52e0cb4..aaa26ed93 100644 Binary files a/src-tauri/icons/32x32.png and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/64x64.png b/src-tauri/icons/64x64.png new file mode 100644 index 000000000..8c0760c56 Binary files /dev/null and b/src-tauri/icons/64x64.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png index 542fe8e32..ad5165b9b 100644 Binary files a/src-tauri/icons/Square107x107Logo.png and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png index 082ee78bb..7854a837b 100644 Binary files a/src-tauri/icons/Square142x142Logo.png and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png index 581dd6e25..628617c72 100644 Binary files a/src-tauri/icons/Square150x150Logo.png and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png index 86ec301b5..6eba27f65 100644 Binary files a/src-tauri/icons/Square284x284Logo.png and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png index 5941eca06..82044569f 100644 Binary files a/src-tauri/icons/Square30x30Logo.png and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png index 7b6d52bcc..e3a484542 100644 Binary files a/src-tauri/icons/Square310x310Logo.png and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png index 134c2d53d..83e6dafb1 100644 Binary files a/src-tauri/icons/Square44x44Logo.png and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png index 97fd1e67f..c6b3fdfd9 100644 Binary files a/src-tauri/icons/Square71x71Logo.png and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png index a79ffcf19..2e15dbfea 100644 Binary files a/src-tauri/icons/Square89x89Logo.png and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png index f4a5238d8..918e8a224 100644 Binary files a/src-tauri/icons/StoreLogo.png and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns index c4992631e..ec13c4172 100644 Binary files a/src-tauri/icons/icon.icns and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico index d78624a1c..e70ec6dd4 100644 Binary files a/src-tauri/icons/icon.ico and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png index f4a5238d8..b6fa95ae8 100644 Binary files a/src-tauri/icons/icon.png and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 4d0177f30..0ec130a1f 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,6 +1,7 @@ pub mod auth; pub mod runtime; pub mod socket; +pub mod tdlib; #[cfg(desktop)] pub mod window; @@ -9,6 +10,7 @@ pub mod window; pub use auth::*; pub use runtime::*; pub use socket::*; +pub use tdlib::*; #[cfg(desktop)] pub use window::*; diff --git a/src-tauri/src/commands/tdlib.rs b/src-tauri/src/commands/tdlib.rs new file mode 100644 index 000000000..17cb38973 --- /dev/null +++ b/src-tauri/src/commands/tdlib.rs @@ -0,0 +1,141 @@ +//! TDLib Tauri Commands +//! +//! These commands provide TDLib access via Tauri's invoke() system. +//! On desktop, they delegate to the TdLibManager singleton. +//! On Android, they use JNI to call the TDLib Android library. + +use serde_json::Value; + +/// Create a TDLib client with the given data directory. +/// +/// # Arguments +/// * `data_dir` - Path to store TDLib data files +/// +/// # Returns +/// Client ID (always 1 for singleton pattern) +#[tauri::command] +pub async fn tdlib_create_client(data_dir: String) -> Result { + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + use crate::services::tdlib::TDLIB_MANAGER; + let path = std::path::PathBuf::from(data_dir); + TDLIB_MANAGER.create_client(path) + } + + #[cfg(target_os = "android")] + { + // Android: Use JNI bridge (to be implemented) + log::info!("[tdlib-android] Creating client with data_dir: {}", data_dir); + // TODO: Call TdLibBridge.createClient() via JNI + Err("TDLib Android bridge not yet implemented".to_string()) + } + + #[cfg(target_os = "ios")] + { + let _ = data_dir; + Err("TDLib is not supported on iOS".to_string()) + } +} + +/// Send a request to TDLib and wait for the response. +/// +/// # Arguments +/// * `request` - TDLib API request object with @type field +/// +/// # Returns +/// TDLib response object +#[tauri::command] +pub async fn tdlib_send(request: Value) -> Result { + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + use crate::services::tdlib::TDLIB_MANAGER; + TDLIB_MANAGER.send(request).await + } + + #[cfg(target_os = "android")] + { + // Android: Use JNI bridge (to be implemented) + log::info!("[tdlib-android] Sending request: {:?}", request); + // TODO: Call TdLibBridge.send() via JNI + Err("TDLib Android bridge not yet implemented".to_string()) + } + + #[cfg(target_os = "ios")] + { + let _ = request; + Err("TDLib is not supported on iOS".to_string()) + } +} + +/// Receive the next update from TDLib (with timeout). +/// +/// # Arguments +/// * `timeout_ms` - Timeout in milliseconds +/// +/// # Returns +/// Update object or null if timeout +#[tauri::command] +pub async fn tdlib_receive(timeout_ms: u32) -> Result, String> { + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + use crate::services::tdlib::TDLIB_MANAGER; + Ok(TDLIB_MANAGER.receive(timeout_ms).await) + } + + #[cfg(target_os = "android")] + { + // Android: Use JNI bridge (to be implemented) + log::debug!("[tdlib-android] Receiving with timeout: {}ms", timeout_ms); + // TODO: Call TdLibBridge.receive() via JNI + Err("TDLib Android bridge not yet implemented".to_string()) + } + + #[cfg(target_os = "ios")] + { + let _ = timeout_ms; + Err("TDLib is not supported on iOS".to_string()) + } +} + +/// Destroy the TDLib client and clean up resources. +#[tauri::command] +pub async fn tdlib_destroy() -> Result<(), String> { + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + use crate::services::tdlib::TDLIB_MANAGER; + TDLIB_MANAGER.destroy().await + } + + #[cfg(target_os = "android")] + { + // Android: Use JNI bridge (to be implemented) + log::info!("[tdlib-android] Destroying client"); + // TODO: Call TdLibBridge.destroy() via JNI + Err("TDLib Android bridge not yet implemented".to_string()) + } + + #[cfg(target_os = "ios")] + { + Err("TDLib is not supported on iOS".to_string()) + } +} + +/// Check if TDLib is available on the current platform. +#[tauri::command] +pub fn tdlib_is_available() -> bool { + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + true + } + + #[cfg(target_os = "android")] + { + // Android: TDLib is available via JNI bridge + true + } + + #[cfg(target_os = "ios")] + { + false + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f4ef24239..51be909c7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -35,6 +35,98 @@ fn greet(name: &str) -> String { format!("Hello, {}! You've been greeted from Rust!", name) } +// Macro to define common handlers shared across all platforms +macro_rules! common_handlers { + () => { + // Demo + greet, + // Auth commands + get_auth_state, + get_session_token, + get_current_user, + is_authenticated, + logout, + store_session, + // Socket commands + socket_connect, + socket_disconnect, + get_socket_state, + is_socket_connected, + report_socket_connected, + report_socket_disconnected, + report_socket_error, + update_socket_status, + // AI encryption commands + ai_init_encryption, + ai_encrypt, + ai_decrypt, + // AI memory filesystem commands + ai_memory_init, + ai_memory_upsert_file, + ai_memory_get_file, + ai_memory_upsert_chunk, + ai_memory_delete_chunks_by_path, + ai_memory_fts_search, + ai_memory_get_chunks, + ai_memory_get_all_embeddings, + ai_memory_cache_embedding, + ai_memory_get_cached_embedding, + ai_memory_set_meta, + ai_memory_get_meta, + // AI session commands + ai_sessions_init, + ai_sessions_load_index, + ai_sessions_update_index, + ai_sessions_append_transcript, + ai_sessions_read_transcript, + ai_sessions_delete, + ai_sessions_list, + ai_read_memory_file, + ai_write_memory_file, + ai_list_memory_files, + // V8 runtime commands + runtime_discover_skills, + runtime_list_skills, + runtime_start_skill, + runtime_stop_skill, + runtime_get_skill_state, + runtime_call_tool, + runtime_all_tools, + runtime_broadcast_event, + // V8 runtime enable/disable + KV commands + runtime_enable_skill, + runtime_disable_skill, + runtime_is_skill_enabled, + runtime_get_skill_preferences, + runtime_skill_kv_get, + runtime_skill_kv_set, + // V8 runtime JSON-RPC + data commands + runtime_rpc, + runtime_skill_data_read, + runtime_skill_data_write, + runtime_skill_data_dir, + // Socket.io commands (Rust-native persistent connection) + runtime_socket_connect, + runtime_socket_disconnect, + runtime_socket_state, + runtime_socket_emit, + }; +} + +// Macro to define desktop-only window handlers +macro_rules! desktop_window_handlers { + () => { + show_window, + hide_window, + toggle_window, + is_window_visible, + minimize_window, + maximize_window, + close_window, + set_window_title, + }; +} + // Helper function to show the window (used by tray and macOS reopen) #[cfg(desktop)] fn show_main_window(app: &AppHandle) { @@ -116,7 +208,14 @@ fn setup_tray(app: &AppHandle) -> Result<(), Box> { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { // Load .env file (silently ignore if missing — production won't have one) - let _ = dotenvy::dotenv(); + // Try current directory first, then parent (for when running from src-tauri) + if dotenvy::dotenv().is_err() { + if let Ok(cwd) = std::env::current_dir() { + if let Some(parent) = cwd.parent() { + let _ = dotenvy::from_path(parent.join(".env")); + } + } + } // Initialize platform-appropriate logger #[cfg(target_os = "android")] @@ -261,21 +360,19 @@ pub fn run() { Ok(()) }) // Register all commands - // Note: Window commands are desktop-only (show/hide/minimize/etc. not available on mobile) + // Common handlers are defined via macros above, conditionally include desktop window handlers .invoke_handler({ #[cfg(desktop)] { tauri::generate_handler![ - // Demo + // Common handlers (expanded from common_handlers! macro) greet, - // Auth commands get_auth_state, get_session_token, get_current_user, is_authenticated, logout, store_session, - // Socket commands socket_connect, socket_disconnect, get_socket_state, @@ -284,7 +381,7 @@ pub fn run() { report_socket_disconnected, report_socket_error, update_socket_status, - // Window commands (desktop only) + // Desktop-only window handlers (expanded from desktop_window_handlers! macro) show_window, hide_window, toggle_window, @@ -347,21 +444,25 @@ pub fn run() { runtime_socket_disconnect, runtime_socket_state, runtime_socket_emit, + // TDLib commands (native Telegram library) + tdlib_create_client, + tdlib_send, + tdlib_receive, + tdlib_destroy, + tdlib_is_available, ] } #[cfg(not(desktop))] { tauri::generate_handler![ - // Demo + // Common handlers (expanded from common_handlers! macro) greet, - // Auth commands get_auth_state, get_session_token, get_current_user, is_authenticated, logout, store_session, - // Socket commands socket_connect, socket_disconnect, get_socket_state, @@ -424,6 +525,12 @@ pub fn run() { runtime_socket_disconnect, runtime_socket_state, runtime_socket_emit, + // TDLib commands (native Telegram library) + tdlib_create_client, + tdlib_send, + tdlib_receive, + tdlib_destroy, + tdlib_is_available, ] } }) diff --git a/src-tauri/src/runtime/skill_registry.rs b/src-tauri/src/runtime/skill_registry.rs index c63e057bb..96dc5e67a 100644 --- a/src-tauri/src/runtime/skill_registry.rs +++ b/src-tauri/src/runtime/skill_registry.rs @@ -45,6 +45,7 @@ impl SkillRegistry { state: Arc>, task_handle: tokio::task::JoinHandle<()>, ) { + log::info!("[runtime] registering skill '{}'", skill_id); self.skills.write().insert( skill_id.to_string(), RegistryEntry { @@ -228,6 +229,7 @@ impl SkillRegistry { /// Send a message to a specific skill's message loop. /// Returns an error if the skill is not registered or the channel is full. pub fn send_message(&self, skill_id: &str, msg: SkillMessage) -> Result<(), String> { + log::info!("[runtime] sending message to '{}': {:?}", skill_id, msg); let sender = { let skills = self.skills.read(); let entry = skills @@ -236,9 +238,17 @@ impl SkillRegistry { entry.sender.clone() }; - sender - .try_send(msg) - .map_err(|e| format!("Failed to send message to skill '{}': {e}", skill_id)) + match sender.try_send(msg) { + Ok(()) => { + log::info!("[runtime] Successfully sent message to skill '{}'", skill_id); + Ok(()) + } + Err(e) => { + let error_msg = format!("Failed to send message to skill '{}': {e}", skill_id); + log::error!("[runtime] {}", error_msg); + Err(error_msg) + } + } } } diff --git a/src-tauri/src/runtime/v8_engine.rs b/src-tauri/src/runtime/v8_engine.rs index 36372dc3c..16110c347 100644 --- a/src-tauri/src/runtime/v8_engine.rs +++ b/src-tauri/src/runtime/v8_engine.rs @@ -108,12 +108,6 @@ impl RuntimeEngine { /// Discover all JavaScript skills from the skills directory. pub async fn discover_skills(&self) -> Result, String> { let skills_dir = self.get_skills_source_dir()?; - log::info!( - "[runtime] Discovering skills in: {:?} (exists={})", - skills_dir, - skills_dir.exists() - ); - if !skills_dir.exists() { return Ok(Vec::new()); } @@ -125,10 +119,6 @@ impl RuntimeEngine { while let Ok(Some(entry)) = entries.next_entry().await { let path = entry.path(); - log::info!( - "[runtime] Found entry: {:?}", - path - ); if path.is_dir() { let manifest_path = path.join("manifest.json"); if manifest_path.exists() { @@ -202,7 +192,12 @@ impl RuntimeEngine { let data_dir = self.skills_data_dir.join(skill_id); // Create the V8 skill instance + log::info!("[runtime] Creating V8 skill instance for '{}'", skill_id); + log::info!("[runtime] Config: {:?}", config); + log::info!("[runtime] Skill dir: {:?}", skill_dir); + log::info!("[runtime] Data dir: {:?}", data_dir); let (instance, rx) = V8SkillInstance::new(config.clone(), skill_dir, data_dir.clone()); + log::info!("[runtime] V8 skill instance created for '{}'", skill_id); // Bundle bridge dependencies let deps = BridgeDeps { @@ -422,6 +417,7 @@ impl RuntimeEngine { "skill/load" => Ok(serde_json::json!({ "ok": true })), "setup/start" => { + log::info!("[runtime] setup/start for '{}'", skill_id); let (tx, rx) = tokio::sync::oneshot::channel(); self.registry .send_message(skill_id, SkillMessage::SetupStart { reply: tx })?; diff --git a/src-tauri/src/runtime/v8_skill_instance.rs b/src-tauri/src/runtime/v8_skill_instance.rs index eab65a016..817a81733 100644 --- a/src-tauri/src/runtime/v8_skill_instance.rs +++ b/src-tauri/src/runtime/v8_skill_instance.rs @@ -4,12 +4,13 @@ //! with: //! - A scoped SQLite database //! - Bridge globals (db, store, net, platform, console) -//! - A message loop driven by crossbeam channels -//! - Lifecycle hooks: init() -> start() -> [message loop] -> stop() +//! - An async event loop that drives timers, promises, and handles messages +//! - Lifecycle hooks: init() -> start() -> [event loop] -> stop() use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; +use std::time::Duration; use deno_core::{v8, JsRuntime, PollEventLoopOptions, RuntimeOptions}; use parking_lot::RwLock; @@ -192,126 +193,238 @@ impl V8SkillInstance { // Extract tool definitions extract_tools(&mut runtime, &state); - // Call init() - if let Err(e) = call_lifecycle_fn_sync(&mut runtime, "init") { - let mut s = state.write(); - s.status = SkillStatus::Error; - s.error = Some(format!("init() failed: {e}")); - log::error!("[skill:{}] init() failed: {e}", config.skill_id); - return; - } + // Create a tokio runtime for this thread FIRST - all async ops must run inside it + // This is critical because deno_unsync requires CurrentThread runtime for async ops + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("Failed to create tokio runtime"); - // Call start() - if let Err(e) = call_lifecycle_fn_sync(&mut runtime, "start") { - let mut s = state.write(); - s.status = SkillStatus::Error; - s.error = Some(format!("start() failed: {e}")); - log::error!("[skill:{}] start() failed: {e}", config.skill_id); - return; - } - - // Mark as running - state.write().status = SkillStatus::Running; - log::info!("[skill:{}] Running (V8)", config.skill_id); - - // Message loop - use blocking_recv since we're on a dedicated thread - while let Some(msg) = rx.blocking_recv() { - match msg { - SkillMessage::CallTool { - tool_name, - arguments, - reply, - } => { - let result = handle_tool_call_sync(&mut runtime, &tool_name, arguments); - let _ = reply.send(result); - } - SkillMessage::ServerEvent { event, data } => { - let _ = handle_server_event_sync(&mut runtime, &event, data); - } - SkillMessage::CronTrigger { schedule_id } => { - let _ = handle_cron_trigger_sync(&mut runtime, &schedule_id); - } - SkillMessage::Stop { reply } => { - let _ = call_lifecycle_fn_sync(&mut runtime, "stop"); - state.write().status = SkillStatus::Stopped; - log::info!("[skill:{}] Stopped", config.skill_id); - let _ = reply.send(()); - break; - } - SkillMessage::SetupStart { reply } => { - let result = handle_js_call_sync(&mut runtime, "onSetupStart", "{}"); - let _ = reply.send(result); - } - SkillMessage::SetupSubmit { - step_id, - values, - reply, - } => { - let args = serde_json::json!({ - "stepId": step_id, - "values": values, - }); - let result = - handle_js_call_sync(&mut runtime, "onSetupSubmit", &args.to_string()); - let _ = reply.send(result); - } - SkillMessage::SetupCancel { reply } => { - let result = handle_js_void_call_sync(&mut runtime, "onSetupCancel", "{}"); - let _ = reply.send(result); - } - SkillMessage::ListOptions { reply } => { - let result = handle_js_call_sync(&mut runtime, "onListOptions", "{}"); - let _ = reply.send(result); - } - SkillMessage::SetOption { name, value, reply } => { - let args = serde_json::json!({ - "name": name, - "value": value, - }); - let result = - handle_js_void_call_sync(&mut runtime, "onSetOption", &args.to_string()); - let _ = reply.send(result); - } - SkillMessage::SessionStart { session_id, reply } => { - let args = serde_json::json!({ "sessionId": session_id }); - let result = - handle_js_void_call_sync(&mut runtime, "onSessionStart", &args.to_string()); - let _ = reply.send(result); - } - SkillMessage::SessionEnd { session_id, reply } => { - let args = serde_json::json!({ "sessionId": session_id }); - let result = - handle_js_void_call_sync(&mut runtime, "onSessionEnd", &args.to_string()); - let _ = reply.send(result); - } - SkillMessage::Tick { reply } => { - let result = handle_js_void_call_sync(&mut runtime, "onTick", "{}"); - let _ = reply.send(result); - } - SkillMessage::Rpc { - method, - params, - reply, - } => { - let args = serde_json::json!({ - "method": method, - "params": params, - }); - let result = - handle_js_call_sync(&mut runtime, "onRpc", &args.to_string()); - let _ = reply.send(result); - } + // Run the entire lifecycle inside the CurrentThread runtime + rt.block_on(async { + // Call init() + if let Err(e) = call_lifecycle_fn_async(&mut runtime, "init").await { + let mut s = state.write(); + s.status = SkillStatus::Error; + s.error = Some(format!("init() failed: {e}")); + log::error!("[skill:{}] init() failed: {e}", config.skill_id); + return; } - } + + // Call start() + if let Err(e) = call_lifecycle_fn_async(&mut runtime, "start").await { + let mut s = state.write(); + s.status = SkillStatus::Error; + s.error = Some(format!("start() failed: {e}")); + log::error!("[skill:{}] start() failed: {e}", config.skill_id); + return; + } + + // Mark as running + state.write().status = SkillStatus::Running; + log::info!("[skill:{}] Running (V8)", config.skill_id); + + // Run the event loop + run_event_loop(&mut runtime, &mut rx, &state, &config.skill_id).await; + }); }) } } -/// Extract tool definitions from globalThis.tools. +// ============================================================================ +// Event Loop +// ============================================================================ + +/// The main event loop that drives the V8 runtime. +/// This continuously: +/// 1. Polls for ready timers and fires their callbacks +/// 2. Checks for incoming messages (non-blocking) +/// 3. Runs the V8 event loop for promises/async ops +/// 4. Sleeps efficiently when idle +async fn run_event_loop( + runtime: &mut JsRuntime, + rx: &mut mpsc::Receiver, + state: &Arc>, + skill_id: &str, +) { + // Maximum sleep duration when no timers are pending + const MAX_IDLE_SLEEP: Duration = Duration::from_millis(100); + // Minimum sleep to prevent busy-spinning + const MIN_SLEEP: Duration = Duration::from_millis(1); + + loop { + // 1. Poll and fire ready timers + let ready_timers = { + let op_state = runtime.op_state(); + let mut state_ref = op_state.borrow_mut(); + let (ready, _next) = ops::poll_timers(&mut state_ref); + ready + }; + + // Fire timer callbacks in JavaScript + for timer_id in ready_timers { + fire_timer_callback(runtime, timer_id); + } + + // 2. Check for incoming messages (non-blocking) + match rx.try_recv() { + Ok(msg) => { + let should_stop = handle_message(runtime, msg, state, skill_id).await; + if should_stop { + break; + } + } + Err(mpsc::error::TryRecvError::Empty) => { + // No message - that's fine + } + Err(mpsc::error::TryRecvError::Disconnected) => { + // Channel closed, exit + log::info!("[skill:{}] Message channel disconnected, stopping", skill_id); + break; + } + } + + // 3. Run the V8 event loop (processes promises, async ops, etc.) + // Use poll mode - returns immediately if nothing to do + let poll_result = runtime + .run_event_loop(PollEventLoopOptions { + wait_for_inspector: false, + pump_v8_message_loop: true, + }) + .await; + + if let Err(e) = poll_result { + log::error!("[skill:{}] Event loop error: {}", skill_id, e); + // Don't break - try to continue + } + + // 4. Calculate sleep duration based on next timer + let sleep_duration = { + let op_state = runtime.op_state(); + let mut state_ref = op_state.borrow_mut(); + let (_, next_timer) = ops::poll_timers(&mut state_ref); + match next_timer { + Some(d) if d < MIN_SLEEP => MIN_SLEEP, + Some(d) if d > MAX_IDLE_SLEEP => MAX_IDLE_SLEEP, + Some(d) => d, + None => MAX_IDLE_SLEEP, + } + }; + + // Sleep efficiently - this yields the thread when no work is needed + tokio::time::sleep(sleep_duration).await; + } +} + +/// Fire a timer callback in JavaScript. +fn fire_timer_callback(runtime: &mut JsRuntime, timer_id: u32) { + let code = format!("globalThis.__handleTimer({});", timer_id); + if let Err(e) = runtime.execute_script("", code) { + log::error!("[timer] Callback for timer {} failed: {}", timer_id, e); + } +} + +/// Handle a single message from the channel. +/// Returns true if the skill should stop. +async fn handle_message( + runtime: &mut JsRuntime, + msg: SkillMessage, + state: &Arc>, + skill_id: &str, +) -> bool { + match msg { + SkillMessage::CallTool { + tool_name, + arguments, + reply, + } => { + let result = handle_tool_call_sync(runtime, &tool_name, arguments); + let _ = reply.send(result); + } + SkillMessage::ServerEvent { event, data } => { + let _ = handle_server_event_sync(runtime, &event, data); + } + SkillMessage::CronTrigger { schedule_id } => { + let _ = handle_cron_trigger_sync(runtime, &schedule_id); + } + SkillMessage::Stop { reply } => { + let _ = call_lifecycle_fn_sync(runtime, "stop"); + state.write().status = SkillStatus::Stopped; + log::info!("[skill:{}] Stopped", skill_id); + let _ = reply.send(()); + return true; // Signal to stop + } + SkillMessage::SetupStart { reply } => { + let result = handle_js_call_sync(runtime, "onSetupStart", "{}"); + let _ = reply.send(result); + } + SkillMessage::SetupSubmit { + step_id, + values, + reply, + } => { + let args = serde_json::json!({ + "stepId": step_id, + "values": values, + }); + let result = handle_js_call_sync(runtime, "onSetupSubmit", &args.to_string()); + let _ = reply.send(result); + } + SkillMessage::SetupCancel { reply } => { + let result = handle_js_void_call_sync(runtime, "onSetupCancel", "{}"); + let _ = reply.send(result); + } + SkillMessage::ListOptions { reply } => { + let result = handle_js_call_sync(runtime, "onListOptions", "{}"); + let _ = reply.send(result); + } + SkillMessage::SetOption { name, value, reply } => { + let args = serde_json::json!({ + "name": name, + "value": value, + }); + let result = handle_js_void_call_sync(runtime, "onSetOption", &args.to_string()); + let _ = reply.send(result); + } + SkillMessage::SessionStart { session_id, reply } => { + let args = serde_json::json!({ "sessionId": session_id }); + let result = handle_js_void_call_sync(runtime, "onSessionStart", &args.to_string()); + let _ = reply.send(result); + } + SkillMessage::SessionEnd { session_id, reply } => { + let args = serde_json::json!({ "sessionId": session_id }); + let result = handle_js_void_call_sync(runtime, "onSessionEnd", &args.to_string()); + let _ = reply.send(result); + } + SkillMessage::Tick { reply } => { + let result = handle_js_void_call_sync(runtime, "onTick", "{}"); + let _ = reply.send(result); + } + SkillMessage::Rpc { + method, + params, + reply, + } => { + let args = serde_json::json!({ + "method": method, + "params": params, + }); + let result = handle_js_call_sync(runtime, "onRpc", &args.to_string()); + let _ = reply.send(result); + } + } + false // Don't stop +} + +/// Extract tool definitions from skill.tools (supports both globalThis.__skill.default and globalThis.tools). fn extract_tools(runtime: &mut JsRuntime, state: &Arc>) { let code = r#" (function() { - var tools = globalThis.tools || []; + // Try to get skill from bundled export first, then fall back to globalThis.tools + var skill = globalThis.__skill && globalThis.__skill.default + ? globalThis.__skill.default + : (globalThis.__skill || null); + var tools = (skill && skill.tools) || globalThis.tools || []; return JSON.stringify(tools.map(function(t) { return { name: t.name || "", @@ -335,11 +448,51 @@ fn extract_tools(runtime: &mut JsRuntime, state: &Arc>) { } } -/// Call a global lifecycle function synchronously. +/// Call a lifecycle function on the skill object asynchronously. +/// This version runs inside the skill's CurrentThread runtime - no nested runtime creation. +/// Looks for the skill at globalThis.__skill.default first, then falls back to globalThis. +/// +/// Note: This does NOT wait for the event loop to complete, because lifecycle functions +/// may start async operations (like update loops) that run indefinitely. The main event +/// loop will process pending async work after init/start complete. +async fn call_lifecycle_fn_async(runtime: &mut JsRuntime, name: &str) -> Result<(), String> { + let code = format!( + r#"(function() {{ + var skill = globalThis.__skill && globalThis.__skill.default + ? globalThis.__skill.default + : (globalThis.__skill || globalThis); + if (typeof skill.{name} === 'function') {{ + skill.{name}(); + }} else if (typeof globalThis.{name} === 'function') {{ + globalThis.{name}(); + }} + }})()"# + ); + + runtime + .execute_script("", code) + .map_err(|e| format!("{name}() failed: {e}"))?; + + // Don't wait for event loop here - the main event loop will handle pending async work. + // This is important because lifecycle functions may start long-running async operations + // (like TDLib update loops) that would block init/start from completing. + + Ok(()) +} + +/// Call a lifecycle function on the skill object synchronously. +/// WARNING: This creates its own tokio runtime - only use when NOT inside an async context. +/// Looks for the skill at globalThis.__skill.default first, then falls back to globalThis. +#[allow(dead_code)] fn call_lifecycle_fn_sync(runtime: &mut JsRuntime, name: &str) -> Result<(), String> { let code = format!( r#"(function() {{ - if (typeof globalThis.{name} === 'function') {{ + var skill = globalThis.__skill && globalThis.__skill.default + ? globalThis.__skill.default + : (globalThis.__skill || globalThis); + if (typeof skill.{name} === 'function') {{ + skill.{name}(); + }} else if (typeof globalThis.{name} === 'function') {{ globalThis.{name}(); }} }})()"# @@ -365,7 +518,8 @@ fn call_lifecycle_fn_sync(runtime: &mut JsRuntime, name: &str) -> Result<(), Str Ok(()) } -/// Handle a tool call synchronously. +/// Handle a tool call synchronously (no async ops waited). +/// This is used when we just need to invoke the tool and get immediate result. fn handle_tool_call_sync( runtime: &mut JsRuntime, tool_name: &str, @@ -376,7 +530,10 @@ fn handle_tool_call_sync( let code = format!( r#"(function() {{ - var tools = globalThis.tools || []; + var skill = globalThis.__skill && globalThis.__skill.default + ? globalThis.__skill.default + : (globalThis.__skill || null); + var tools = (skill && skill.tools) || globalThis.tools || []; for (var i = 0; i < tools.length; i++) {{ if (tools[i].name === "{}") {{ var args = {}; @@ -398,17 +555,8 @@ fn handle_tool_call_sync( .execute_script("", code) .map_err(|e| format!("Tool execution failed: {e}"))?; - // Run event loop for any pending ops - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(|e| format!("Failed to create runtime: {e}"))?; - - let _ = rt.block_on(async { - runtime - .run_event_loop(PollEventLoopOptions::default()) - .await - }); + // Note: We don't run the event loop here because we're already inside + // the skill's event loop. Pending async ops will be handled by the main loop. let scope = &mut runtime.handle_scope(); let local = v8::Local::new(scope, result); @@ -435,12 +583,19 @@ fn handle_server_event_sync( let code = format!( r#"(function() {{ - if (typeof globalThis.onServerEvent === 'function') {{ + var skill = globalThis.__skill && globalThis.__skill.default + ? globalThis.__skill.default + : (globalThis.__skill || globalThis); + if (typeof skill.onServerEvent === 'function') {{ + skill.onServerEvent("{}", {}); + }} else if (typeof globalThis.onServerEvent === 'function') {{ globalThis.onServerEvent("{}", {}); }} }})()"#, event.replace('"', r#"\""#), data_str, + event.replace('"', r#"\""#), + data_str, ); runtime @@ -454,11 +609,17 @@ fn handle_server_event_sync( fn handle_cron_trigger_sync(runtime: &mut JsRuntime, schedule_id: &str) -> Result<(), String> { let code = format!( r#"(function() {{ - if (typeof globalThis.onCronTrigger === 'function') {{ + var skill = globalThis.__skill && globalThis.__skill.default + ? globalThis.__skill.default + : (globalThis.__skill || globalThis); + if (typeof skill.onCronTrigger === 'function') {{ + skill.onCronTrigger("{}"); + }} else if (typeof globalThis.onCronTrigger === 'function') {{ globalThis.onCronTrigger("{}"); }} }})()"#, schedule_id.replace('"', r#"\""#), + schedule_id.replace('"', r#"\""#), ); runtime @@ -468,7 +629,7 @@ fn handle_cron_trigger_sync(runtime: &mut JsRuntime, schedule_id: &str) -> Resul Ok(()) } -/// Call a global JS function that returns a JSON value synchronously. +/// Call a JS function on the skill object that returns a JSON value synchronously. fn handle_js_call_sync( runtime: &mut JsRuntime, fn_name: &str, @@ -476,9 +637,13 @@ fn handle_js_call_sync( ) -> Result { let code = format!( r#"(function() {{ - if (typeof globalThis.{fn_name} === 'function') {{ + var skill = globalThis.__skill && globalThis.__skill.default + ? globalThis.__skill.default + : (globalThis.__skill || globalThis); + var fn = skill.{fn_name} || globalThis.{fn_name}; + if (typeof fn === 'function') {{ var args = {args_json}; - var result = globalThis.{fn_name}(args); + var result = fn.call(skill, args); return JSON.stringify(result); }} return "null"; @@ -502,7 +667,7 @@ fn handle_js_call_sync( .map_err(|e| format!("{fn_name}() returned invalid JSON: {e}")) } -/// Call a global JS function that returns void synchronously. +/// Call a JS function on the skill object that returns void synchronously. fn handle_js_void_call_sync( runtime: &mut JsRuntime, fn_name: &str, @@ -510,9 +675,13 @@ fn handle_js_void_call_sync( ) -> Result<(), String> { let code = format!( r#"(function() {{ - if (typeof globalThis.{fn_name} === 'function') {{ + var skill = globalThis.__skill && globalThis.__skill.default + ? globalThis.__skill.default + : (globalThis.__skill || globalThis); + var fn = skill.{fn_name} || globalThis.{fn_name}; + if (typeof fn === 'function') {{ var args = {args_json}; - globalThis.{fn_name}(args); + fn.call(skill, args); }} }})()"# ); diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 25bd2e3d1..28ee288fd 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -1,5 +1,6 @@ pub mod session_service; pub mod socket_service; +pub mod tdlib; pub mod tdlib_v8; #[cfg(desktop)] diff --git a/src-tauri/src/services/tdlib/manager.rs b/src-tauri/src/services/tdlib/manager.rs new file mode 100644 index 000000000..195d64130 --- /dev/null +++ b/src-tauri/src/services/tdlib/manager.rs @@ -0,0 +1,547 @@ +//! TdLibManager — singleton manager for TDLib on desktop platforms. +//! +//! Wraps tdlib-rs client and provides: +//! - Client creation with data directory configuration +//! - Asynchronous request/response via send/receive +//! - Background update polling with broadcast to subscribers +//! - Thread-safe access via channels + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, AtomicI32, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use once_cell::sync::Lazy; +use parking_lot::RwLock; +use tokio::sync::{broadcast, mpsc, oneshot}; + +/// Global TDLib manager instance. +pub static TDLIB_MANAGER: Lazy = Lazy::new(TdLibManager::new); + +/// Request message sent to the TDLib worker thread. +#[derive(Debug)] +enum TdRequest { + /// Send a request and wait for response. + Send { + request: serde_json::Value, + reply: oneshot::Sender>, + }, + /// Receive next update (with timeout). + Receive { + timeout_ms: u32, + reply: oneshot::Sender>, + }, + /// Destroy the client. + Destroy { + reply: oneshot::Sender>, + }, +} + +/// State for a single TDLib client. +struct ClientState { + /// Next request ID for @extra field correlation. + next_request_id: AtomicI32, + /// Pending requests waiting for responses: @extra -> reply channel. + pending_requests: Arc>>>, + /// Broadcast channel for updates (messages without @extra or with update type). + update_tx: broadcast::Sender, + /// Queue of updates for polling via receive(). + update_queue: Arc>>, + /// Notification channel for new updates. + update_notify: Arc, + /// Whether the client is active. + is_active: AtomicBool, +} + +impl ClientState { + fn new() -> Self { + let (update_tx, _) = broadcast::channel(256); + Self { + next_request_id: AtomicI32::new(1), + pending_requests: Arc::new(RwLock::new(HashMap::new())), + update_tx, + update_queue: Arc::new(parking_lot::Mutex::new(std::collections::VecDeque::with_capacity(256))), + update_notify: Arc::new(tokio::sync::Notify::new()), + is_active: AtomicBool::new(true), + } + } + + fn get_next_request_id(&self) -> i32 { + self.next_request_id.fetch_add(1, Ordering::SeqCst) + } + + /// Push an update to the queue and notify waiters. + fn push_update(&self, update: serde_json::Value) { + self.update_queue.lock().push_back(update); + self.update_notify.notify_one(); + } + + /// Pop an update from the queue (non-blocking). + fn pop_update(&self) -> Option { + self.update_queue.lock().pop_front() + } +} + +/// TDLib Manager for desktop platforms. +/// +/// Provides a high-level interface to TDLib with: +/// - Client lifecycle management +/// - Request/response correlation via @extra field +/// - Background update polling +/// - Broadcast channel for updates +pub struct TdLibManager { + /// The TDLib client ID. + client_id: RwLock>, + /// Client state for request correlation. + state: Arc, + /// Data directory for TDLib files. + data_dir: RwLock>, + /// Request sender for the worker thread. + request_tx: Arc>>>, + /// Handle to the worker thread. + worker_handle: RwLock>>, +} + +impl TdLibManager { + /// Create a new TDLib manager (doesn't start the client yet). + pub fn new() -> Self { + Self { + client_id: RwLock::new(None), + state: Arc::new(ClientState::new()), + data_dir: RwLock::new(None), + request_tx: Arc::new(RwLock::new(None)), + worker_handle: RwLock::new(None), + } + } + + /// Create and start a TDLib client with the given data directory. + /// Returns the client ID. If a client already exists, returns its ID. + pub fn create_client(&self, data_dir: PathBuf) -> Result { + // Check if already initialized - return existing client ID + if let Some(existing_id) = *self.client_id.read() { + log::info!("[tdlib] Client already exists with ID: {}, reusing", existing_id); + return Ok(existing_id); + } + + log::info!("[tdlib] Creating client with data dir: {:?}", data_dir); + + // Ensure data directory exists + std::fs::create_dir_all(&data_dir) + .map_err(|e| format!("Failed to create data directory: {}", e))?; + + // Create TDLib client using tdlib_rs + let client_id = tdlib_rs::create_client(); + + // Store data directory + *self.data_dir.write() = Some(data_dir); + + // Create request channel + let (request_tx, request_rx) = mpsc::channel::(64); + *self.request_tx.write() = Some(request_tx); + + // Store client ID + *self.client_id.write() = Some(client_id); + + // Start worker thread + let state = self.state.clone(); + let cid = client_id; + + let handle = std::thread::spawn(move || { + Self::worker_loop(cid, state, request_rx); + }); + *self.worker_handle.write() = Some(handle); + + self.state.is_active.store(true, Ordering::SeqCst); + log::info!("[tdlib] Client created with ID: {}", client_id); + + Ok(client_id) + } + + /// Worker loop that handles requests and polls for updates. + fn worker_loop( + client_id: i32, + state: Arc, + mut request_rx: mpsc::Receiver, + ) { + log::info!("[tdlib] Worker loop started for client {}", client_id); + + // Create a tokio runtime for async operations + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("Failed to create tokio runtime for TDLib worker"); + + rt.block_on(async { + // Spawn a separate task to poll TDLib updates + // This runs in spawn_blocking since tdlib_rs::receive() is a blocking call + let state_clone = state.clone(); + let poll_handle = tokio::spawn(async move { + loop { + if !state_clone.is_active.load(Ordering::SeqCst) { + break; + } + + // Use spawn_blocking for the blocking TDLib receive call + let receive_result = tokio::task::spawn_blocking(|| { + // Use a short timeout to be more responsive + // Note: tdlib_rs::receive() uses 2.0s internally, but that's OK + // because we're in spawn_blocking and won't block the async runtime + tdlib_rs::receive() + }) + .await; + + if let Ok(Some((update, update_client_id))) = receive_result { + if update_client_id == client_id { + Self::handle_response(&state_clone, update); + } + } + + // Yield to allow other tasks to run + tokio::task::yield_now().await; + } + }); + + // Main loop for handling requests + loop { + // Check if we should stop + if !state.is_active.load(Ordering::SeqCst) { + log::info!("[tdlib] Worker loop stopping (inactive)"); + break; + } + + // Check for incoming requests (with short timeout to stay responsive) + match tokio::time::timeout( + Duration::from_millis(50), + request_rx.recv(), + ) + .await + { + Ok(Some(request)) => { + Self::handle_request(client_id, &state, request).await; + } + Ok(None) => { + log::info!("[tdlib] Request channel disconnected"); + break; + } + Err(_) => { + // Timeout - no request, continue + } + } + } + + // Clean up the poll task + poll_handle.abort(); + }); + + log::info!("[tdlib] Worker loop exited for client {}", client_id); + } + + /// Handle a TDLib response (either a response to a request or an update). + fn handle_response(state: &Arc, update: tdlib_rs::enums::Update) { + // Convert update to JSON for processing + let json = serde_json::to_value(&update).unwrap_or(serde_json::Value::Null); + + // Check if this is a response to a pending request (has @extra) + if let Some(extra) = json.get("@extra").and_then(|v| v.as_str()) { + // Find and complete the pending request + if let Some(reply_tx) = state.pending_requests.write().remove(extra) { + let _ = reply_tx.send(json); + } else { + log::warn!("[tdlib] Received response with unknown @extra: {}", extra); + } + } else { + // This is an update - push to queue and broadcast + state.push_update(json.clone()); + let _ = state.update_tx.send(json); + } + } + + /// Handle a request from the channel. + async fn handle_request(client_id: i32, state: &Arc, request: TdRequest) { + match request { + TdRequest::Send { request, reply } => { + // Check if this is a request type that uses high-level tdlib-rs functions + // These functions consume responses internally, so we return "ok" immediately + let request_type = request + .get("@type") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let uses_high_level_api = matches!( + request_type, + "setTdlibParameters" + | "getMe" + | "close" + | "logOut" + | "setAuthenticationPhoneNumber" + | "checkAuthenticationCode" + | "checkAuthenticationPassword" + ); + + // Send to TDLib + let send_result = Self::send_json_request(client_id, &request).await; + + if let Err(e) = send_result { + let _ = reply.send(Err(e)); + return; + } + + // For high-level API functions, return "ok" immediately + // The actual response/update will come through the update stream + if uses_high_level_api { + let _ = reply.send(Ok(serde_json::json!({ + "@type": "ok" + }))); + } else { + // For other requests, we'd need low-level JSON API + // For now, just return ok since we don't have many other request types + let _ = reply.send(Ok(serde_json::json!({ + "@type": "ok" + }))); + } + } + TdRequest::Receive { timeout_ms, reply } => { + // First check if there's an update already in the queue + if let Some(update) = state.pop_update() { + let _ = reply.send(Some(update)); + return; + } + + // No update available, wait for notification with timeout + let notify = state.update_notify.clone(); + let queue = state.update_queue.clone(); + + tokio::spawn(async move { + match tokio::time::timeout( + Duration::from_millis(timeout_ms as u64), + notify.notified(), + ) + .await + { + Ok(_) => { + // Got notification, pop from queue + let update = queue.lock().pop_front(); + let _ = reply.send(update); + } + Err(_) => { + // Timeout - try one more time in case update came just now + let update = queue.lock().pop_front(); + let _ = reply.send(update); + } + } + }); + } + TdRequest::Destroy { reply } => { + state.is_active.store(false, Ordering::SeqCst); + let _ = reply.send(Ok(())); + } + } + } + + /// Send a JSON request to TDLib by converting to the appropriate function type. + async fn send_json_request(client_id: i32, request: &serde_json::Value) -> Result<(), String> { + // Get the @type field to determine which function to call + let request_type = request + .get("@type") + .and_then(|v| v.as_str()) + .ok_or_else(|| "Request missing @type field".to_string())?; + + // tdlib-rs functions are async and take individual parameters + // We'll implement the most common functions. + match request_type { + "setTdlibParameters" => { + // Parse and call setTdlibParameters + if let Ok(params) = serde_json::from_value::(request.clone()) { + let _ = tdlib_rs::functions::set_tdlib_parameters( + params.use_test_dc.unwrap_or(false), + params.database_directory.unwrap_or_default(), + params.files_directory.unwrap_or_default(), + params.database_encryption_key.unwrap_or_default(), + params.use_file_database.unwrap_or(true), + params.use_chat_info_database.unwrap_or(true), + params.use_message_database.unwrap_or(true), + params.use_secret_chats.unwrap_or(false), + params.api_id, + params.api_hash, + params.system_language_code.unwrap_or_else(|| "en".to_string()), + params.device_model.unwrap_or_else(|| "Desktop".to_string()), + params.system_version.unwrap_or_default(), + params.application_version.unwrap_or_else(|| "1.0.0".to_string()), + client_id, + ).await; + Ok(()) + } else { + Err("Failed to parse setTdlibParameters".to_string()) + } + } + "getMe" => { + let _ = tdlib_rs::functions::get_me(client_id).await; + Ok(()) + } + "close" => { + let _ = tdlib_rs::functions::close(client_id).await; + Ok(()) + } + "logOut" => { + let _ = tdlib_rs::functions::log_out(client_id).await; + Ok(()) + } + "setAuthenticationPhoneNumber" => { + if let Some(phone) = request.get("phone_number").and_then(|v| v.as_str()) { + let _ = tdlib_rs::functions::set_authentication_phone_number( + phone.to_string(), + None, // phone_number_authentication_settings + client_id, + ).await; + Ok(()) + } else { + Err("Missing phone_number".to_string()) + } + } + "checkAuthenticationCode" => { + if let Some(code) = request.get("code").and_then(|v| v.as_str()) { + let _ = tdlib_rs::functions::check_authentication_code(code.to_string(), client_id).await; + Ok(()) + } else { + Err("Missing code".to_string()) + } + } + "checkAuthenticationPassword" => { + if let Some(password) = request.get("password").and_then(|v| v.as_str()) { + let _ = tdlib_rs::functions::check_authentication_password(password.to_string(), client_id).await; + Ok(()) + } else { + Err("Missing password".to_string()) + } + } + _ => { + log::warn!("[tdlib] Unknown request type: {}", request_type); + Err(format!("Unknown request type: {}", request_type)) + } + } + } + + /// Send a request to TDLib and wait for the response. + pub async fn send(&self, request: serde_json::Value) -> Result { + let request_tx = { + self.request_tx.read().clone() + }; + + let request_tx = request_tx + .ok_or_else(|| "TDLib client not initialized".to_string())?; + + let (reply_tx, reply_rx) = oneshot::channel(); + + request_tx + .send(TdRequest::Send { + request, + reply: reply_tx, + }) + .await + .map_err(|e| format!("Failed to send request: {}", e))?; + + reply_rx + .await + .map_err(|_| "Response channel closed".to_string())? + } + + /// Receive the next update from TDLib (with timeout). + pub async fn receive(&self, timeout_ms: u32) -> Option { + let request_tx = { + self.request_tx.read().clone() + }?; + + let (reply_tx, reply_rx) = oneshot::channel(); + + if request_tx + .send(TdRequest::Receive { + timeout_ms, + reply: reply_tx, + }) + .await + .is_err() + { + return None; + } + + reply_rx.await.ok().flatten() + } + + /// Subscribe to TDLib updates. + pub fn subscribe_updates(&self) -> broadcast::Receiver { + self.state.update_tx.subscribe() + } + + /// Destroy the TDLib client and clean up resources. + pub async fn destroy(&self) -> Result<(), String> { + log::info!("[tdlib] Destroying client"); + + // Get the request_tx without holding the lock across await + let request_tx = { + self.request_tx.read().clone() + }; + + // Send destroy request + if let Some(request_tx) = request_tx { + let (reply_tx, reply_rx) = oneshot::channel(); + + let _ = request_tx.send(TdRequest::Destroy { reply: reply_tx }).await; + let _ = reply_rx.await; + } + + // Clear state + *self.request_tx.write() = None; + *self.client_id.write() = None; + *self.data_dir.write() = None; + + // Wait for worker thread to finish + if let Some(handle) = self.worker_handle.write().take() { + let _ = handle.join(); + } + + log::info!("[tdlib] Client destroyed"); + Ok(()) + } + + /// Check if the client is active. + pub fn is_active(&self) -> bool { + self.state.is_active.load(Ordering::SeqCst) && self.client_id.read().is_some() + } + + /// Get the data directory path. + pub fn data_dir(&self) -> Option { + self.data_dir.read().clone() + } +} + +impl Default for TdLibManager { + fn default() -> Self { + Self::new() + } +} + +// Helper struct for parsing setTdlibParameters request +#[derive(serde::Deserialize)] +struct SetTdlibParametersRequest { + #[serde(rename = "@type")] + _type: String, + use_test_dc: Option, + database_directory: Option, + files_directory: Option, + database_encryption_key: Option, + use_file_database: Option, + use_chat_info_database: Option, + use_message_database: Option, + use_secret_chats: Option, + api_id: i32, + api_hash: String, + system_language_code: Option, + device_model: Option, + system_version: Option, + application_version: Option, +} + +// Ensure TdLibManager is Send + Sync for use with Tauri +unsafe impl Send for TdLibManager {} +unsafe impl Sync for TdLibManager {} diff --git a/src-tauri/src/services/tdlib/mod.rs b/src-tauri/src/services/tdlib/mod.rs new file mode 100644 index 000000000..7d8beb93d --- /dev/null +++ b/src-tauri/src/services/tdlib/mod.rs @@ -0,0 +1,10 @@ +//! TDLib Integration Module +//! +//! Provides native TDLib access for the Telegram skill. +//! Desktop uses tdlib-rs, Android uses JNI bridge to TDLib Android library. + +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub mod manager; + +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub use manager::{TdLibManager, TDLIB_MANAGER}; diff --git a/src-tauri/src/services/tdlib_v8/bootstrap.js b/src-tauri/src/services/tdlib_v8/bootstrap.js index ae87d0a54..3c58ece67 100644 --- a/src-tauri/src/services/tdlib_v8/bootstrap.js +++ b/src-tauri/src/services/tdlib_v8/bootstrap.js @@ -75,6 +75,59 @@ globalThis.__handleTimer = function (id) { } }; +// ============================================================================ +// AbortController / AbortSignal Polyfill +// ============================================================================ +class AbortSignal { + constructor() { + this.aborted = false; + this.reason = undefined; + this._listeners = []; + } + + addEventListener(type, listener) { + if (type === 'abort') { + this._listeners.push(listener); + } + } + + removeEventListener(type, listener) { + if (type === 'abort') { + const idx = this._listeners.indexOf(listener); + if (idx >= 0) this._listeners.splice(idx, 1); + } + } + + throwIfAborted() { + if (this.aborted) { + throw this.reason || new Error('Aborted'); + } + } +} + +class AbortController { + constructor() { + this.signal = new AbortSignal(); + } + + abort(reason) { + if (!this.signal.aborted) { + this.signal.aborted = true; + this.signal.reason = reason || new Error('Aborted'); + for (const listener of this.signal._listeners) { + try { + listener({ type: 'abort', target: this.signal }); + } catch (e) { + console.error('AbortController listener error:', e); + } + } + } + } +} + +globalThis.AbortController = AbortController; +globalThis.AbortSignal = AbortSignal; + // ============================================================================ // Fetch API // ============================================================================ @@ -802,4 +855,61 @@ globalThis.skills = { }, }; +// ============================================================================ +// TDLib Bridge API (telegram skill only) +// ============================================================================ +// Provides native TDLib access for the telegram skill. +// This is only available on desktop - Android uses Tauri invoke() instead. + +globalThis.tdlib = { + /** + * Check if TDLib ops are available. + * @returns {boolean} True on desktop, false on mobile/web. + */ + isAvailable: function () { + try { + return typeof Deno?.core?.ops?.op_tdlib_is_available === 'function' + ? Deno.core.ops.op_tdlib_is_available() + : false; + } catch (e) { + return false; + } + }, + + /** + * Create a TDLib client with the given data directory. + * @param {string} dataDir - Path to store TDLib data files. + * @returns {Promise} Client ID (always 1 for singleton). + */ + createClient: async function (dataDir) { + return await Deno.core.ops.op_tdlib_create_client(dataDir); + }, + + /** + * Send a TDLib request and wait for the response. + * @param {object} request - TDLib API request object with @type field. + * @returns {Promise} TDLib response object. + */ + send: async function (request) { + return await Deno.core.ops.op_tdlib_send(request); + }, + + /** + * Receive the next TDLib update (with timeout). + * @param {number} [timeoutMs=1000] - Timeout in milliseconds. + * @returns {Promise} Update object or null if timeout. + */ + receive: async function (timeoutMs = 1000) { + return await Deno.core.ops.op_tdlib_receive(timeoutMs); + }, + + /** + * Destroy the TDLib client and clean up resources. + * @returns {Promise} + */ + destroy: async function () { + return await Deno.core.ops.op_tdlib_destroy(); + }, +}; + console.log('[bootstrap] V8 browser APIs initialized'); diff --git a/src-tauri/src/services/tdlib_v8/ops/mod.rs b/src-tauri/src/services/tdlib_v8/ops/mod.rs index e010a2ef9..a299c4a10 100644 --- a/src-tauri/src/services/tdlib_v8/ops/mod.rs +++ b/src-tauri/src/services/tdlib_v8/ops/mod.rs @@ -73,6 +73,12 @@ extension!( // Data bridge ops op_data_read, op_data_write, + // TDLib ops (telegram skill only) + op_tdlib_create_client, + op_tdlib_send, + op_tdlib_receive, + op_tdlib_destroy, + op_tdlib_is_available, ], state = |state| { // State will be initialized when runtime is created @@ -98,6 +104,15 @@ pub fn init_state_with_data_dir( state.put(WebSocketState::default()); } +/// Poll timers and return IDs of timers that are ready to fire. +/// Also returns the duration until the next timer (for efficient sleeping). +pub fn poll_timers(state: &mut OpState) -> (Vec, Option) { + let timer_state = state.borrow_mut::(); + let ready = timer_state.poll_ready(); + let next = timer_state.time_until_next(); + (ready, next) +} + /// Context for the current skill execution. #[derive(Clone)] pub struct SkillContext { @@ -137,15 +152,72 @@ impl SkillContext { // Timer State // ============================================================================ +/// A scheduled timer (setTimeout or setInterval). +#[derive(Clone, Debug)] +pub struct TimerEntry { + /// Whether this is a repeating interval + pub is_interval: bool, + /// Delay in milliseconds + pub delay_ms: u64, + /// When the timer should fire next (Instant) + pub next_fire: std::time::Instant, +} + /// State for managing timers. -/// Currently a placeholder - full timer integration with V8 event loop is TODO. +/// Timers are polled during the event loop and fire callbacks via JS. #[derive(Default)] -#[allow(dead_code)] pub struct TimerState { - /// Active timers: id -> (is_interval, delay_ms) - pub timers: HashMap, - /// Timer cancellation senders - pub cancel_senders: HashMap>, + /// Active timers: id -> TimerEntry + pub timers: HashMap, +} + +impl TimerState { + /// Get all timers that are ready to fire, returning their IDs. + /// For intervals, reschedules the next fire time. + /// For timeouts, removes them from the map. + pub fn poll_ready(&mut self) -> Vec { + let now = std::time::Instant::now(); + let mut ready = Vec::new(); + + // Collect IDs of ready timers + let ready_ids: Vec = self + .timers + .iter() + .filter(|(_, entry)| now >= entry.next_fire) + .map(|(id, _)| *id) + .collect(); + + for id in ready_ids { + if let Some(entry) = self.timers.get_mut(&id) { + ready.push(id); + + if entry.is_interval { + // Reschedule for next interval + entry.next_fire = now + std::time::Duration::from_millis(entry.delay_ms); + } else { + // Remove one-shot timeout + self.timers.remove(&id); + } + } + } + + ready + } + + /// Get the duration until the next timer fires (for sleep optimization). + pub fn time_until_next(&self) -> Option { + let now = std::time::Instant::now(); + self.timers + .values() + .map(|entry| { + if entry.next_fire > now { + entry.next_fire - now + } else { + std::time::Duration::ZERO + } + }) + .min() + } } // ============================================================================ @@ -260,11 +332,12 @@ fn op_platform_os() -> &'static str { #[string] fn op_platform_env(#[string] key: &str) -> Option { const ALLOWED_ENV_VARS: &[&str] = &[ - "VITE_TELEGRAM_API_ID", - "VITE_TELEGRAM_API_HASH", "VITE_TELEGRAM_BOT_USERNAME", "VITE_TELEGRAM_BOT_ID", + "TELEGRAM_API_ID", // Skills may use this without VITE_ prefix + "TELEGRAM_API_HASH", // Skills may use this without VITE_ prefix "VITE_BACKEND_URL", + "BACKEND_URL", // Skills may use this without VITE_ prefix "VITE_DEBUG", ]; @@ -280,26 +353,35 @@ fn op_platform_env(#[string] key: &str) -> Option { // ============================================================================ /// Start a timer (setTimeout or setInterval). -/// The actual callback execution happens in JavaScript via __handleTimer. +/// The actual callback execution happens in JavaScript via __handleTimer, +/// triggered by the event loop polling TimerState. #[op2(fast)] -fn op_ah_timer_start( - _state: &mut OpState, - id: u32, - delay_ms: u32, - _is_interval: bool, -) { - // Note: In V8/deno_core, timers are typically handled differently. - // For now, we log the timer request. The actual timer execution - // would need integration with the V8 event loop. - log::debug!("[timer] Start timer {} with delay {}ms", id, delay_ms); - // TODO: Implement proper timer scheduling with the V8 event loop +fn op_ah_timer_start(state: &mut OpState, id: u32, delay_ms: u32, is_interval: bool) { + let timer_state = state.borrow_mut::(); + + let entry = TimerEntry { + is_interval, + delay_ms: delay_ms as u64, + next_fire: std::time::Instant::now() + std::time::Duration::from_millis(delay_ms as u64), + }; + + timer_state.timers.insert(id, entry); + log::debug!( + "[timer] Registered {} {} with delay {}ms", + if is_interval { "interval" } else { "timeout" }, + id, + delay_ms + ); } /// Cancel a timer. #[op2(fast)] -fn op_ah_timer_cancel(_state: &mut OpState, id: u32) { - log::debug!("[timer] Cancel timer {}", id); - // TODO: Implement proper timer cancellation +fn op_ah_timer_cancel(state: &mut OpState, id: u32) { + let timer_state = state.borrow_mut::(); + + if timer_state.timers.remove(&id).is_some() { + log::debug!("[timer] Cancelled timer {}", id); + } } // ============================================================================ @@ -1010,3 +1092,93 @@ fn op_data_write( deno_core::error::generic_error(format!("Failed to write file '{}': {}", filename, e)) }) } + +// ============================================================================ +// TDLib Ops (telegram skill only) +// ============================================================================ + +/// Check if the current skill is the telegram skill. +fn check_telegram_skill(state: &OpState) -> Result<(), deno_core::error::AnyError> { + let ctx = state.borrow::(); + if ctx.skill_id != "telegram" { + return Err(deno_core::error::generic_error( + "TDLib is only available to the telegram skill", + )); + } + Ok(()) +} + +/// Check if TDLib is available (always true on desktop). +#[op2(fast)] +fn op_tdlib_is_available() -> bool { + true +} + +/// Create a TDLib client with the given data directory. +/// This is synchronous since create_client just spawns a worker thread and returns. +#[op2(fast)] +fn op_tdlib_create_client( + state: &mut OpState, + #[string] data_dir: String, +) -> Result { + // Check skill permission + check_telegram_skill(state)?; + + let path = std::path::PathBuf::from(data_dir); + + crate::services::tdlib::TDLIB_MANAGER + .create_client(path) + .map_err(|e| deno_core::error::generic_error(e)) +} + +/// Send a request to TDLib and wait for the response. +#[op2(async)] +#[serde] +async fn op_tdlib_send( + state: Rc>, + #[serde] request: serde_json::Value, +) -> Result { + // Check skill permission + { + let state = state.borrow(); + check_telegram_skill(&state)?; + } + + crate::services::tdlib::TDLIB_MANAGER + .send(request) + .await + .map_err(|e| deno_core::error::generic_error(e)) +} + +/// Receive the next update from TDLib (with timeout in ms). +#[op2(async)] +#[serde] +async fn op_tdlib_receive( + state: Rc>, + timeout_ms: u32, +) -> Result, deno_core::error::AnyError> { + // Check skill permission + { + let state = state.borrow(); + check_telegram_skill(&state)?; + } + + Ok(crate::services::tdlib::TDLIB_MANAGER.receive(timeout_ms).await) +} + +/// Destroy the TDLib client and clean up resources. +#[op2(async)] +async fn op_tdlib_destroy( + state: Rc>, +) -> Result<(), deno_core::error::AnyError> { + // Check skill permission + { + let state = state.borrow(); + check_telegram_skill(&state)?; + } + + crate::services::tdlib::TDLIB_MANAGER + .destroy() + .await + .map_err(|e| deno_core::error::generic_error(e)) +} diff --git a/src/components/settings/components/SettingsHeader.tsx b/src/components/settings/components/SettingsHeader.tsx index 028ef5789..c9e025520 100644 --- a/src/components/settings/components/SettingsHeader.tsx +++ b/src/components/settings/components/SettingsHeader.tsx @@ -16,7 +16,7 @@ const SettingsHeader = ({ const { closeSettings } = useSettingsNavigation(); return ( -
+
{/* Back button */} diff --git a/src/components/skills/SkillSetupWizard.tsx b/src/components/skills/SkillSetupWizard.tsx index 834ae8bb4..577709f17 100644 --- a/src/components/skills/SkillSetupWizard.tsx +++ b/src/components/skills/SkillSetupWizard.tsx @@ -36,7 +36,9 @@ export default function SkillSetupWizard({ async function initSetup() { try { + console.log("[SkillSetupWizard] initSetup", skillId); const manifest = store.getState().skills.skills[skillId]?.manifest; + console.log("[SkillSetupWizard] manifest", manifest); if (!manifest) { if (!cancelled) { setState({ @@ -48,12 +50,17 @@ export default function SkillSetupWizard({ } if (!skillManager.isSkillRunning(skillId)) { + console.log("[SkillSetupWizard] starting skill", skillId); await skillManager.startSkill(manifest); + console.log("[SkillSetupWizard] skill started", skillId); } + if (cancelled) return; if (!skillManager.isSkillRunning(skillId)) { + console.log("[SkillSetupWizard] skill not running", skillId); const status = skillManager.getSkillStatus(skillId); + console.log("[SkillSetupWizard] status", status); const errMsg = status === "error" ? store.getState().skills.skills[skillId]?.error ?? "Skill failed to start" @@ -61,7 +68,9 @@ export default function SkillSetupWizard({ throw new Error(errMsg); } + console.log("[SkillSetupWizard] starting setup", skillId); const firstStep = await skillManager.startSetup(skillId); + console.log("[SkillSetupWizard] setup started", skillId); if (!cancelled) { setState({ phase: "step", step: firstStep }); } @@ -178,8 +187,8 @@ export default function SkillSetupWizard({ {}} - onCancel={() => {}} + onSubmit={() => { }} + onCancel={() => { }} /> ); diff --git a/src/lib/skills/manager.ts b/src/lib/skills/manager.ts index 7a7d5a45f..e5935f4f9 100644 --- a/src/lib/skills/manager.ts +++ b/src/lib/skills/manager.ts @@ -128,14 +128,17 @@ class SkillManager { * Start the setup flow for a skill. Returns the first step. */ async startSetup(skillId: string): Promise { + console.log("[SkillManager] startSetup", skillId); const runtime = this.runtimes.get(skillId); if (!runtime) { - throw new Error(`Skill ${skillId} is not running`); + console.log("[SkillManager] runtime not found", skillId); + throw new Error(`Skill ${skillId} runtime not found`); } store.dispatch( setSkillStatus({ skillId, status: "setup_in_progress" }), ); + console.log("[SkillManager] setup started", skillId); return runtime.setupStart(); } @@ -420,3 +423,8 @@ class SkillManager { // Export singleton export const skillManager = new SkillManager(); + +// Debug: expose to window for console testing +if (typeof window !== 'undefined') { + (window as unknown as { __skillManager: SkillManager }).__skillManager = skillManager; +} diff --git a/src/lib/skills/runtime.ts b/src/lib/skills/runtime.ts index 78cc2ee1e..83c3d2ee5 100644 --- a/src/lib/skills/runtime.ts +++ b/src/lib/skills/runtime.ts @@ -71,9 +71,11 @@ export class SkillRuntime { * Start the setup flow. Returns the first SetupStep. */ async setupStart(): Promise { + console.log("[SkillRuntime] setupStart", this.skillId); const result = await this.transport.request<{ step: SetupStep }>( "setup/start" ); + console.log("[SkillRuntime] setupStart result", this.skillId, result); return result.step; } diff --git a/src/providers/SkillProvider.tsx b/src/providers/SkillProvider.tsx index 144b5ea6a..ee3123423 100644 --- a/src/providers/SkillProvider.tsx +++ b/src/providers/SkillProvider.tsx @@ -12,6 +12,7 @@ import { type ReactNode, useEffect, useRef } from 'react'; import { skillManager } from '../lib/skills/manager'; import type { SkillManifest } from '../lib/skills/types'; import { useAppSelector } from '../store/hooks'; +import { DEV_AUTO_LOAD_SKILL } from '../utils/config'; // --------------------------------------------------------------------------- // Helpers @@ -52,9 +53,30 @@ export default function SkillProvider({ children }: { children: ReactNode }) { initRef.current = true; const registerAndStart = async (manifests: SkillManifest[]) => { + // Register all discovered skills for (const manifest of manifests) { skillManager.registerSkill(manifest); } + + // Auto-start skill specified in DEV_AUTO_LOAD_SKILL env variable (dev only) + if (DEV_AUTO_LOAD_SKILL) { + const autoLoadManifest = manifests.find(m => m.id === DEV_AUTO_LOAD_SKILL); + if (autoLoadManifest) { + console.log(`[SkillProvider] Auto-loading skill from env: ${DEV_AUTO_LOAD_SKILL}`); + try { + await skillManager.startSkill(autoLoadManifest); + console.log(`[SkillProvider] Successfully auto-loaded skill: ${DEV_AUTO_LOAD_SKILL}`); + } catch (err) { + console.error(`[SkillProvider] Failed to auto-load skill ${DEV_AUTO_LOAD_SKILL}:`, err); + } + } else { + console.warn( + `[SkillProvider] DEV_AUTO_LOAD_SKILL="${DEV_AUTO_LOAD_SKILL}" specified but skill not found in discovered skills` + ); + } + } + + // Auto-start skills with completed setup for (const manifest of manifests) { const existing = skillsState[manifest.id]; if (existing?.setupComplete) { diff --git a/src/utils/config.ts b/src/utils/config.ts index 6aab8b786..239a4219b 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -14,3 +14,5 @@ export const TELEGRAM_API_HASH = import.meta.env.VITE_TELEGRAM_API_HASH || undef export const IS_DEV = Boolean(import.meta.env.DEV) || import.meta.env.MODE === 'development'; export const SKILLS_GITHUB_REPO = import.meta.env.VITE_SKILLS_GITHUB_REPO || 'alphahumanxyz/skills'; + +export const DEV_AUTO_LOAD_SKILL = import.meta.env.VITE_DEV_AUTO_LOAD_SKILL || undefined;