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>
This commit is contained in:
Steven Enamakel
2026-02-04 08:02:06 +05:30
committed by GitHub
co-authored by Claude Opus 4.5
parent f529cc2887
commit f0e3c03544
78 changed files with 2072 additions and 187 deletions
+10 -1
View File
@@ -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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

+1
View File
@@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"dev:web": "vite",
"build": "tsc && vite build",
"compile": "tsc --noEmit",
"preview": "vite preview",
+1 -1
Submodule skills updated: 54c40a1b0d...79e21914f4
+246
View File
@@ -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"
+6
View File
@@ -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!!
+10
View File
@@ -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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 634 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 894 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 816 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 32 KiB

+2
View File
@@ -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::*;
+141
View File
@@ -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
}
}
+116 -9
View File
@@ -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,
]
}
})
+13 -3
View File
@@ -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)
}
}
}
}
+6 -10
View File
@@ -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 })?;
+305 -136
View File
@@ -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
View File
@@ -1,5 +1,6 @@
pub mod session_service;
pub mod socket_service;
pub mod tdlib;
pub mod tdlib_v8;
#[cfg(desktop)]
+547
View File
@@ -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 {}
+10
View File
@@ -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};
+110
View File
@@ -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');
+195 -23
View File
@@ -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 */}
+11 -2
View File
@@ -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={() => { }}
/>
);
+9 -1
View File
@@ -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;
}
+2
View File
@@ -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;
}
+22
View File
@@ -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) {
+2
View File
@@ -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;