fix: tg-skill (#34)
* chore: add simulation function for deep link testing in development - Introduced a simulation function for deep links in the development environment to facilitate testing. - The function allows developers to simulate deep link URLs without affecting production behavior. * feat: implement QuickJS skill runtime integration - Added a new QuickJS skill runtime engine to manage skill execution within the application. - Introduced commands for skill management, including discovery, starting, stopping, and querying skill states. - Implemented a SQLite database bridge for each skill to handle data storage. - Enhanced the application structure with new modules for runtime management, skill instances, and manifest parsing. - Updated dependencies in Cargo.toml and Cargo.lock to support new features. * refactor: improve code readability and formatting in DownloadScreen and deviceDetection - Enhanced the button className formatting in DownloadScreen for better readability. - Reformatted the fetchLatestRelease and parseReleaseAssetsByArchitecture functions in deviceDetection for improved clarity and maintainability. - Utilized multiline formatting for complex conditions to enhance code structure. * feat: integrate Rust-native Socket.io client for persistent connections - Implemented a SocketManager in Rust to handle Socket.io connections, ensuring persistence across app backgrounding. - Updated SocketProvider to connect/disconnect using Rust-native methods in Tauri mode. - Enhanced Tauri event listeners for socket state changes and server events. - Refactored socket handling logic to differentiate between web and Tauri modes, improving maintainability and clarity. - Added new commands for connecting, disconnecting, and emitting events through the Rust socket. * refactor: remove Telegram login commands and related functionality - Deleted the startTelegramLogin and startTelegramLoginWithUrl functions from the Tauri commands. - Removed associated references in the Rust command module and utility file. - This cleanup simplifies the codebase by eliminating unused Telegram login features. * feat: add cron scheduling functionality for skills - Introduced a new CronScheduler to manage scheduled tasks for skills, allowing for cron expression parsing and execution. - Added a cron bridge to expose scheduling methods to skill contexts, enabling skills to register, unregister, and list cron schedules. - Updated the RuntimeEngine to initialize and manage the cron scheduler, ensuring it runs in the background. - Enhanced skill instances to support cron scheduling through a new BridgeDeps structure. - Updated dependencies in Cargo.toml and Cargo.lock to include the cron crate. * feat: add Android support and enhance dependencies - Introduced Android-specific build and run commands in package.json for Tauri. - Updated Cargo.toml to include OpenSSL for Android and adjusted reqwest for cross-platform TLS support. - Added new mobile capabilities configuration for Android and iOS. - Implemented a foreground service in the Android app to maintain the Rust backend process. - Added various Android resources including layouts, icons, and notification handling. - Updated Cargo.lock with new dependencies for improved functionality. * chore: update .gitignore and AndroidManifest.xml for deep link support - Added .kotlin and .cargo to .gitignore to exclude Kotlin and Cargo build artifacts. - Updated AndroidManifest.xml with comments for the deep link plugin, clarifying its auto-generated nature. * feat: enhance store configuration for development testing - Added functionality to auto-inject a JWT token from environment variables during development. - Implemented automatic onboarding for users once their profile is fetched, improving the testing experience. * feat: enhance socket connection handling and add Android logging support - Updated the Rust socket connection command to accept an optional URL parameter, allowing for dynamic backend URL configuration. - Integrated Android logging capabilities using the android_logger crate, ensuring logs are properly routed to logcat. - Improved error handling in the SocketManager to log connection errors and successful connections for better debugging. * feat: implement notification permission handling and enhance foreground service in Android - Added runtime permission request for POST_NOTIFICATIONS in MainActivity to comply with API 33+ requirements. - Updated RuntimeService to specify foreground service type for API 34+ compatibility. - Improved logging levels in the Rust backend for better debugging and monitoring of socket connections and skill discovery. * feat: integrate QuickJS skill management and service - Added QuickJS skill hooks for retrieving and managing QuickJS skills from Redux. - Implemented a QuickJS service to handle skill lifecycle, preferences, and IPC calls with the Rust backend. - Enhanced the skills state in Redux to include QuickJS skills, enabling better management and state tracking. - Updated the store configuration to persist QuickJS skills state across sessions. - Introduced new commands in the Rust backend for enabling/disabling skills and managing preferences. - Improved the SkillProvider to initialize the QuickJS service during app startup. * refactor: transition skill management to QuickJS runtime - Removed legacy skill catalog and loading logic, replacing it with QuickJS runtime integration for skill discovery and management. - Updated SkillProvider to utilize QuickJS for skill registration and lifecycle management. - Simplified skill data handling by directly invoking the Rust backend for skill operations. - Enhanced error handling and logging for skill loading processes. - Cleaned up unused interfaces and functions related to previous skill management methods. * refactor: remove unused skill commands and plugins - Deleted the skills command module and related functions to streamline the codebase. - Removed dependencies on the tauri-plugin-shell and other unused plugins from Cargo.toml and Cargo.lock. - Updated the authentication module by removing the exchange_token function and its associated structures. - Simplified the capabilities configuration by eliminating shell-related permissions. - Enhanced the runtime engine to support new JSON-RPC commands for skill data management. * feat: expose whitelisted environment values to skills - Added `platform.env(key)` function to retrieve whitelisted environment values for skills. - Implemented `get_skill_env` function to provide access to `BACKEND_URL` and `PLATFORM`. - Updated `get_backend_url` to check for `VITE_BACKEND_URL` before falling back to `BACKEND_URL`. - Enhanced the QuickJS runtime to support the new environment functionality. * feat: fix skills enable/disable flow, setup pipeline, and status derivation - Add SkillSetup struct to Rust manifest and include setup field in discovery response so the frontend knows which skills need setup - Map setup field in SkillProvider discoverSkills() - Fix SkillsGrid to use real hasSetup from manifest instead of hardcoding false - Add contextual Enable/Setup/Configure/Retry buttons in management modal - Add status indicator dots to compact skill table rows - Fix deriveConnectionStatus to return "connected" for ready skills with completed setup that don't push host state (e.g. cron-based skills) - Add select option renderer in SkillManagementPanel for non-boolean options - Add dotenvy crate to load .env file at Rust startup so env vars like VITE_BACKEND_URL are available to the runtime engine and skills Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: add platform support for skills and enhance manifest handling - Introduced platform filtering for skills in the SkillManifest, allowing skills to specify supported platforms. - Updated QuickJSManifest and runtime engine to handle platform checks, ensuring skills are only loaded on compatible platforms. - Added new build and watch commands for skills in package.json to streamline development. - Enhanced the Rust backend to log unsupported skills based on platform restrictions. * feat: update skills submodule with TypeScript pipeline and test harness Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: transition from QuickJS to V8 runtime for skill management - Replaced QuickJS with V8 (via deno_core) for improved JavaScript execution and WASM support. - Updated skill management logic to utilize the new V8 runtime, including changes to the RuntimeEngine and skill instance handling. - Enhanced manifest handling to support the new runtime and added platform compatibility checks. - Introduced new dependencies and updated Cargo.toml to reflect changes in the skill execution environment. - Refactored related modules and commands to align with the V8 integration, ensuring a seamless transition for skill operations. * refactor: clean up dead code in IdbStorage and enhance ops module - Removed unused `#[allow(dead_code)]` annotations from the IdbStorage struct and its methods to improve code clarity. - Introduced new timer and WebSocket state management structures in the ops module, laying groundwork for future enhancements. - Added timer and WebSocket operation functions, including `op_timer_start`, `op_timer_cancel`, and WebSocket connection handling, to support asynchronous operations. * feat: implement high-level TDLib service with V8 integration - Introduced a new `TdlibV8Service` to manage TDLib client instances using the V8 runtime. - Added a `MockTdClient` for development and testing, simulating TDLib responses for various queries. - Enhanced the bootstrap script to initialize and manage TDLib clients, including methods for sending queries and retrieving authentication states. - Updated the `mod.rs` file to include the new service module and improved documentation for clarity. - Refactored existing code to support the new TDLib integration, ensuring a seamless experience for skill management. * refactor: update V8 runtime integration and platform handling - Updated the V8 runtime integration to ensure it is only available on desktop platforms, with appropriate error handling for mobile. - Refactored the `SocketManager` and command implementations to conditionally include desktop-only features, enhancing clarity and maintainability. - Cleaned up the `Cargo.toml` to reflect the changes in V8 runtime availability and added relevant documentation. - Removed dead code related to mobile platform handling in the TDLib integration, ensuring a streamlined codebase. * chore: update skills submodule to latest commit - Updated the skills submodule to the latest commit (19a18e8), ensuring alignment with recent changes and improvements in the codebase. * refactor: transition from QuickJS to V8 runtime for skill management - Updated the skills submodule to the latest commit, reflecting the transition from QuickJS to V8 for improved JavaScript execution. - Refactored skill management logic to utilize the V8 runtime, including changes to the RuntimeEngine and skill instance handling. - Enhanced manifest handling to support the new runtime and updated platform compatibility checks. - Removed dead code related to QuickJS, ensuring a streamlined codebase. - Updated comments and documentation to reflect the changes in runtime integration. * feat: enhance logging and update timer operations in V8 integration - Added logging for skill discovery and manifest processing in the V8 runtime, improving traceability during skill management. - Updated timer operation functions to use new prefixed names (`op_ah_timer_start` and `op_ah_timer_cancel`) to avoid conflicts with deno_core built-ins. - Implemented a mechanism to load .env files from various locations, ensuring environment variables are available for configuration. - Enhanced the `get_backend_url` function to include debug logging for better visibility of the backend URL resolution process. * chore: update skills submodule to latest commit and enhance logging - Updated the skills submodule to the latest commit (54c40a1), ensuring alignment with recent changes. - Added console logging for skill manifests during discovery in the SkillsGrid component to improve debugging and traceability. * refactor: clean up and format code in DownloadScreen and SkillsGrid components - Improved code readability by formatting multi-line statements in the DownloadScreen and SkillsGrid components. - Removed unnecessary type imports in DownloadScreen for clarity. - Enhanced the structure of the SkillsGrid component by adjusting the layout of JSX elements for better maintainability. - Updated socketService to ensure consistent import order and improved logging in TauriSocket utility functions. * chore: update development script in package.json - Replaced the existing setup-python-sidecar script with a new dev:app script to streamline the development process for Tauri applications, enabling better debugging with RUST_BACKTRACE and RUST_LOG settings. * refactor: update load method to accept additional parameters - Modified the load method in SkillRuntime to accept an optional additionalParams argument, enhancing flexibility for skill loading. - Ensured compatibility by using a fallback to an empty object when additionalParams is not provided. * refactor: improve JSX structure in SkillsGrid component - Enhanced the formatting of the connection status indicator in the SkillsGrid component for better readability. - Adjusted the indentation and spacing to maintain consistent code style and improve maintainability. * Refactor invoke_handler to consolidate command registration across platforms - Simplified the command registration process by merging desktop and mobile command handlers into a single `invoke_handler` call. - Improved code readability and maintainability by reducing duplication in command definitions. - Ensured that window commands remain desktop-only while maintaining common functionality for all platforms. * updated icon * Update skills submodule to latest commit and remove redundant logging statements in V8 engine skill discovery * Update skills submodule to reflect dirty state * feat: expose BACKEND_URL environment variable for skills - Added "BACKEND_URL" to the list of whitelisted environment variables accessible to skills, allowing for broader usage without the "VITE_" prefix. * Enhance V8 runtime with async event loop and timer management - Implemented an async event loop in the V8 skill instance to handle timers and messages efficiently. - Introduced a new TimerState structure for managing scheduled timers, allowing for better integration with the V8 event loop. - Updated the dotenv loading mechanism to check for environment variables in the current and parent directories. - Enhanced logging for timer operations and skill management processes to improve traceability. * Update skills submodule to latest commit and expose additional environment variables for skills - Updated the skills submodule to commit 3793fdc, ensuring alignment with recent changes. - Added "TELEGRAM_API_ID" and "TELEGRAM_API_HASH" to the list of whitelisted environment variables, allowing skills to access these without the "VITE_" prefix. * Integrate TDLib support for Telegram skill - Added TDLib commands for creating, sending, receiving, and destroying clients, enabling Telegram functionality. - Implemented a TdLibManager for managing TDLib client lifecycle and asynchronous operations on desktop platforms. - Introduced JNI bridge for Android to facilitate TDLib interactions. - Updated Cargo.toml and Cargo.lock to include tdlib-rs and related dependencies. - Enhanced the V8 runtime to support TDLib operations, ensuring compatibility across platforms. * Enhance TDLib manager with update queue and async polling - Introduced an update queue and notification channel in ClientState for managing TDLib updates. - Implemented a separate polling task for TDLib updates to improve responsiveness and prevent blocking the main event loop. - Updated lifecycle function handling in the V8 runtime to avoid waiting for the event loop, allowing for long-running async operations. - Improved error handling and logging for better traceability during TDLib operations. * Enhance documentation and improve code formatting - Updated CLAUDE.md to include new core commands and runtime management features for the Rust backend, as well as Android support and platform-specific details. - Refactored SettingsHeader component in SettingsHeader.tsx for improved readability by consolidating props and cleaning up JSX formatting. - Enhanced SkillProvider.tsx with better logging for skill loading errors, improving traceability in the development process. --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
@@ -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
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev:web": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"compile": "tsc --noEmit",
|
||||
"preview": "vite preview",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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!!
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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<Long, (TdApi.Object) -> 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 6.6 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 634 B |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 894 B After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 816 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 32 KiB |
@@ -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::*;
|
||||
|
||||
@@ -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<i32, String> {
|
||||
#[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<Value, String> {
|
||||
#[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<Option<Value>, 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
|
||||
}
|
||||
}
|
||||
@@ -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<dyn std::error::Error>> {
|
||||
#[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,
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
@@ -45,6 +45,7 @@ impl SkillRegistry {
|
||||
state: Arc<RwLock<SkillState>>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -108,12 +108,6 @@ impl RuntimeEngine {
|
||||
/// Discover all JavaScript skills from the skills directory.
|
||||
pub async fn discover_skills(&self) -> Result<Vec<SkillManifest>, 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 })?;
|
||||
|
||||
@@ -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<SkillMessage>,
|
||||
state: &Arc<RwLock<SkillState>>,
|
||||
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("<timer-callback>", 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<RwLock<SkillState>>,
|
||||
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<RwLock<SkillState>>) {
|
||||
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<RwLock<SkillState>>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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("<lifecycle>", 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("<tool-call>", 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<serde_json::Value, 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};
|
||||
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);
|
||||
}}
|
||||
}})()"#
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod session_service;
|
||||
pub mod socket_service;
|
||||
pub mod tdlib;
|
||||
pub mod tdlib_v8;
|
||||
|
||||
#[cfg(desktop)]
|
||||
|
||||
@@ -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<TdLibManager> = 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<Result<serde_json::Value, String>>,
|
||||
},
|
||||
/// Receive next update (with timeout).
|
||||
Receive {
|
||||
timeout_ms: u32,
|
||||
reply: oneshot::Sender<Option<serde_json::Value>>,
|
||||
},
|
||||
/// Destroy the client.
|
||||
Destroy {
|
||||
reply: oneshot::Sender<Result<(), String>>,
|
||||
},
|
||||
}
|
||||
|
||||
/// 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<RwLock<HashMap<String, oneshot::Sender<serde_json::Value>>>>,
|
||||
/// Broadcast channel for updates (messages without @extra or with update type).
|
||||
update_tx: broadcast::Sender<serde_json::Value>,
|
||||
/// Queue of updates for polling via receive().
|
||||
update_queue: Arc<parking_lot::Mutex<std::collections::VecDeque<serde_json::Value>>>,
|
||||
/// Notification channel for new updates.
|
||||
update_notify: Arc<tokio::sync::Notify>,
|
||||
/// 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<serde_json::Value> {
|
||||
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<Option<i32>>,
|
||||
/// Client state for request correlation.
|
||||
state: Arc<ClientState>,
|
||||
/// Data directory for TDLib files.
|
||||
data_dir: RwLock<Option<PathBuf>>,
|
||||
/// Request sender for the worker thread.
|
||||
request_tx: Arc<RwLock<Option<mpsc::Sender<TdRequest>>>>,
|
||||
/// Handle to the worker thread.
|
||||
worker_handle: RwLock<Option<std::thread::JoinHandle<()>>>,
|
||||
}
|
||||
|
||||
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<i32, String> {
|
||||
// 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::<TdRequest>(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<ClientState>,
|
||||
mut request_rx: mpsc::Receiver<TdRequest>,
|
||||
) {
|
||||
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<ClientState>, 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<ClientState>, 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::<SetTdlibParametersRequest>(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<serde_json::Value, String> {
|
||||
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<serde_json::Value> {
|
||||
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<serde_json::Value> {
|
||||
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<PathBuf> {
|
||||
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<bool>,
|
||||
database_directory: Option<String>,
|
||||
files_directory: Option<String>,
|
||||
database_encryption_key: Option<String>,
|
||||
use_file_database: Option<bool>,
|
||||
use_chat_info_database: Option<bool>,
|
||||
use_message_database: Option<bool>,
|
||||
use_secret_chats: Option<bool>,
|
||||
api_id: i32,
|
||||
api_hash: String,
|
||||
system_language_code: Option<String>,
|
||||
device_model: Option<String>,
|
||||
system_version: Option<String>,
|
||||
application_version: Option<String>,
|
||||
}
|
||||
|
||||
// Ensure TdLibManager is Send + Sync for use with Tauri
|
||||
unsafe impl Send for TdLibManager {}
|
||||
unsafe impl Sync for TdLibManager {}
|
||||
@@ -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};
|
||||
@@ -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<number>} 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<object>} 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<object|null>} 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<void>}
|
||||
*/
|
||||
destroy: async function () {
|
||||
return await Deno.core.ops.op_tdlib_destroy();
|
||||
},
|
||||
};
|
||||
|
||||
console.log('[bootstrap] V8 browser APIs initialized');
|
||||
|
||||
@@ -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<u32>, Option<std::time::Duration>) {
|
||||
let timer_state = state.borrow_mut::<TimerState>();
|
||||
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<u32, (bool, u64)>,
|
||||
/// Timer cancellation senders
|
||||
pub cancel_senders: HashMap<u32, tokio::sync::oneshot::Sender<()>>,
|
||||
/// Active timers: id -> TimerEntry
|
||||
pub timers: HashMap<u32, TimerEntry>,
|
||||
}
|
||||
|
||||
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<u32> {
|
||||
let now = std::time::Instant::now();
|
||||
let mut ready = Vec::new();
|
||||
|
||||
// Collect IDs of ready timers
|
||||
let ready_ids: Vec<u32> = 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<std::time::Duration> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
// ============================================================================
|
||||
|
||||
/// 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::<TimerState>();
|
||||
|
||||
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::<TimerState>();
|
||||
|
||||
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::<SkillContext>();
|
||||
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<i32, deno_core::error::AnyError> {
|
||||
// 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<RefCell<OpState>>,
|
||||
#[serde] request: serde_json::Value,
|
||||
) -> Result<serde_json::Value, deno_core::error::AnyError> {
|
||||
// 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<RefCell<OpState>>,
|
||||
timeout_ms: u32,
|
||||
) -> Result<Option<serde_json::Value>, 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<RefCell<OpState>>,
|
||||
) -> 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))
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ const SettingsHeader = ({
|
||||
const { closeSettings } = useSettingsNavigation();
|
||||
|
||||
return (
|
||||
<div className={`bg-black/30 border-b border-stone-700 py-3 px-4 relative ${className}`}>
|
||||
<div className={`bg-black/30 border-b border-stone-700 p-6 relative ${className}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
{/* Back button */}
|
||||
|
||||
@@ -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({
|
||||
<SetupFormRenderer
|
||||
step={state.step}
|
||||
loading={true}
|
||||
onSubmit={() => {}}
|
||||
onCancel={() => {}}
|
||||
onSubmit={() => { }}
|
||||
onCancel={() => { }}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -128,14 +128,17 @@ class SkillManager {
|
||||
* Start the setup flow for a skill. Returns the first step.
|
||||
*/
|
||||
async startSetup(skillId: string): Promise<SetupStep> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -71,9 +71,11 @@ export class SkillRuntime {
|
||||
* Start the setup flow. Returns the first SetupStep.
|
||||
*/
|
||||
async setupStart(): Promise<SetupStep> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||