From 632ecc05b7d4b112e9db25ad113fb05da7b6e17a Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Thu, 29 Jan 2026 21:21:07 +0530 Subject: [PATCH] Add comprehensive documentation for application architecture, state management, services layer, MCP system, pages and routing, components, and providers - Introduced detailed architecture overview outlining system components and entry points. - Documented state management using Redux Toolkit and Redux-Persist, including store configuration and slice structures. - Described services layer architecture, detailing API client, socket service, and MTProto service functionalities. - Explained the Model Context Protocol (MCP) system, including tool categories and implementation examples. - Outlined routing structure and page components, emphasizing protected and public routes. - Organized reusable components by feature, detailing their structure and usage. - Provided insights into provider management for service lifecycle and shared state. This documentation enhances understanding of the application's structure and improves onboarding for new developers, ensuring clarity in the system's design and functionality. --- docs/src-tauri/01-architecture.md | 143 +++++ docs/src-tauri/02-commands.md | 268 +++++++++ docs/src-tauri/03-services.md | 304 +++++++++++ docs/src-tauri/README.md | 374 +++++++++++++ docs/src/01-architecture.md | 175 ++++++ docs/src/02-state-management.md | 243 +++++++++ docs/src/03-services.md | 297 ++++++++++ docs/src/04-mcp-system.md | 437 +++++++++++++++ docs/src/05-pages-routing.md | 403 ++++++++++++++ docs/src/06-components.md | 511 ++++++++++++++++++ docs/src/07-providers.md | 404 ++++++++++++++ docs/src/08-hooks-utils.md | 505 +++++++++++++++++ docs/src/README.md | 78 +++ src-tauri/Cargo.lock | 249 ++++++++- src-tauri/Cargo.toml | 33 +- src-tauri/capabilities/default.json | 6 +- src-tauri/src/commands/auth.rs | 99 ++++ src-tauri/src/commands/mod.rs | 10 + src-tauri/src/commands/socket.rs | 66 +++ src-tauri/src/commands/telegram.rs | 35 ++ src-tauri/src/commands/window.rs | 80 +++ src-tauri/src/lib.rs | 174 +++--- src-tauri/src/models/auth.rs | 56 ++ src-tauri/src/models/mod.rs | 2 + src-tauri/src/models/socket.rs | 74 +++ src-tauri/src/services/mod.rs | 11 + .../src/services/notification_service.rs | 58 ++ src-tauri/src/services/session_service.rs | 180 ++++++ src-tauri/src/services/socket_service.rs | 182 +++++++ src-tauri/src/utils/config.rs | 38 ++ src-tauri/src/utils/mod.rs | 1 + src/providers/SocketProvider.tsx | 83 ++- src/utils/tauriCommands.ts | 188 +++++++ src/utils/tauriSocket.ts | 185 +++++++ 34 files changed, 5874 insertions(+), 78 deletions(-) create mode 100644 docs/src-tauri/01-architecture.md create mode 100644 docs/src-tauri/02-commands.md create mode 100644 docs/src-tauri/03-services.md create mode 100644 docs/src-tauri/README.md create mode 100644 docs/src/01-architecture.md create mode 100644 docs/src/02-state-management.md create mode 100644 docs/src/03-services.md create mode 100644 docs/src/04-mcp-system.md create mode 100644 docs/src/05-pages-routing.md create mode 100644 docs/src/06-components.md create mode 100644 docs/src/07-providers.md create mode 100644 docs/src/08-hooks-utils.md create mode 100644 docs/src/README.md create mode 100644 src-tauri/src/commands/auth.rs create mode 100644 src-tauri/src/commands/mod.rs create mode 100644 src-tauri/src/commands/socket.rs create mode 100644 src-tauri/src/commands/telegram.rs create mode 100644 src-tauri/src/commands/window.rs create mode 100644 src-tauri/src/models/auth.rs create mode 100644 src-tauri/src/models/mod.rs create mode 100644 src-tauri/src/models/socket.rs create mode 100644 src-tauri/src/services/mod.rs create mode 100644 src-tauri/src/services/notification_service.rs create mode 100644 src-tauri/src/services/session_service.rs create mode 100644 src-tauri/src/services/socket_service.rs create mode 100644 src-tauri/src/utils/config.rs create mode 100644 src-tauri/src/utils/mod.rs create mode 100644 src/utils/tauriCommands.ts create mode 100644 src/utils/tauriSocket.ts diff --git a/docs/src-tauri/01-architecture.md b/docs/src-tauri/01-architecture.md new file mode 100644 index 000000000..07708e2dc --- /dev/null +++ b/docs/src-tauri/01-architecture.md @@ -0,0 +1,143 @@ +# Rust Backend Architecture + +## Overview + +The Tauri Rust backend provides native functionality for the AlphaHuman desktop application: +- System tray with background execution +- Deep link authentication +- Secure session storage (OS Keychain) +- Socket.io state management +- Native notifications +- Window management + +## Directory Structure + +``` +src-tauri/src/ +├── lib.rs # Entry point, plugin registration, tray setup +├── main.rs # Binary entry (desktop) +├── commands/ # Tauri IPC commands +│ ├── mod.rs +│ ├── auth.rs # Authentication commands +│ ├── socket.rs # Socket state commands +│ ├── telegram.rs # Telegram login commands +│ └── window.rs # Window management commands +├── services/ # Background services +│ ├── mod.rs +│ ├── session_service.rs # Secure session storage (keychain) +│ ├── socket_service.rs # Socket.io state management +│ └── notification_service.rs # Native notifications +├── models/ # Data structures +│ ├── mod.rs +│ ├── auth.rs # Auth types (Session, User) +│ └── socket.rs # Socket types (ConnectionStatus) +└── utils/ # Configuration and helpers + ├── mod.rs + └── config.rs # Environment configuration +``` + +## Key Components + +### lib.rs - Application Entry + +The main entry point that: +1. Registers Tauri plugins (opener, deep-link, autostart, notification) +2. Sets up system tray with Show/Hide and Quit menu +3. Handles macOS-specific window close behavior (minimize to tray) +4. Registers all IPC commands + +### Commands Layer + +Commands are exposed to the frontend via Tauri's IPC: + +| Module | Commands | Purpose | +|--------|----------|---------| +| `auth` | `exchange_token`, `get_auth_state`, `logout`, etc. | Authentication | +| `socket` | `socket_connect`, `report_socket_connected`, etc. | Socket state | +| `telegram` | `start_telegram_login` | Telegram OAuth | +| `window` | `show_window`, `hide_window`, `toggle_window`, etc. | Window control | + +### Services Layer + +Singleton services providing background functionality: + +| Service | Purpose | Storage | +|---------|---------|---------| +| `SessionService` | Secure auth token storage | OS Keychain | +| `SocketService` | Socket.io state management | Memory + Events | +| `NotificationService` | Native notifications | N/A | + +## Architecture Decisions + +### Socket.io Strategy + +The frontend maintains the actual Socket.io connection, while Rust: +1. Stores connection parameters +2. Tracks connection state +3. Emits events to coordinate with frontend +4. Ensures socket stays connected when window is hidden + +This approach is necessary because: +- Rust's Socket.io libraries have API compatibility issues +- The WebView maintains state when hidden (unlike browser tabs) +- Frontend JavaScript is better suited for Socket.io's event-driven model + +### Keychain Storage + +Session tokens are stored in the OS keychain for security: +- **macOS**: Keychain +- **Windows**: Credential Manager +- **Linux**: Secret Service + +### Event Bridge Pattern + +Rust communicates with the frontend via Tauri events: + +```rust +// Rust emits event +app.emit("socket:should_connect", json!({ "backendUrl": url, "token": token })) + +// Frontend listens +listen("socket:should_connect", (event) => { + socketService.connect(event.payload.backendUrl, event.payload.token); +}); +``` + +## Plugin Dependencies + +| Plugin | Version | Purpose | +|--------|---------|---------| +| `tauri-plugin-opener` | 2 | Open URLs in browser | +| `tauri-plugin-deep-link` | 2.0.0 | Handle `outsourced://` URLs | +| `tauri-plugin-autostart` | 2 | Launch at login | +| `tauri-plugin-notification` | 2 | Native notifications | + +## Cargo Dependencies + +| Crate | Purpose | +|-------|---------| +| `tauri` | Core framework with tray and macOS APIs | +| `serde`, `serde_json` | Serialization | +| `reqwest` | HTTP client | +| `tokio` | Async runtime | +| `keyring` | Secure credential storage | +| `once_cell` | Lazy static singletons | +| `parking_lot` | Fast mutexes | +| `log`, `env_logger` | Logging | + +## Platform-Specific Behavior + +### macOS +- Window close button hides instead of quitting +- Dock icon click shows window +- LaunchAgent for autostart +- Keychain for secure storage + +### Windows/Linux +- Deep link registration at runtime +- Platform-specific credential storage +- Registry (Windows) or desktop file (Linux) autostart + +--- + +*Next: [Commands Reference](./02-commands.md)* diff --git a/docs/src-tauri/02-commands.md b/docs/src-tauri/02-commands.md new file mode 100644 index 000000000..f5d471fa2 --- /dev/null +++ b/docs/src-tauri/02-commands.md @@ -0,0 +1,268 @@ +# Tauri Commands Reference + +This document lists all Tauri commands available to the frontend. + +## Authentication Commands + +### `exchange_token` +Exchange a login token for a session token. + +```typescript +const result = await invoke('exchange_token', { + backendUrl: 'https://api.example.com', + token: 'login-token-from-deep-link' +}); +// Returns: { sessionToken: string, user: User } +``` + +### `get_auth_state` +Get current authentication state. + +```typescript +const state = await invoke('get_auth_state'); +// Returns: { is_authenticated: boolean, user: User | null } +``` + +### `get_session_token` +Get the current session token. + +```typescript +const token = await invoke('get_session_token'); +``` + +### `get_current_user` +Get the current authenticated user. + +```typescript +const user = await invoke('get_current_user'); +``` + +### `is_authenticated` +Check if user is authenticated. + +```typescript +const isAuth = await invoke('is_authenticated'); +``` + +### `logout` +Clear session and disconnect socket. + +```typescript +await invoke('logout'); +``` + +### `store_session` +Manually store a session (usually called automatically by `exchange_token`). + +```typescript +await invoke('store_session', { + token: 'session-token', + user: { id: '123', firstName: 'John' } +}); +``` + +## Socket Commands + +### `socket_connect` +Request frontend to connect to socket server. + +```typescript +await invoke('socket_connect', { + backendUrl: 'https://api.example.com', + token: 'session-token' +}); +// Emits "socket:should_connect" event +``` + +### `socket_disconnect` +Request frontend to disconnect from socket server. + +```typescript +await invoke('socket_disconnect'); +// Emits "socket:should_disconnect" event +``` + +### `get_socket_state` +Get current socket state. + +```typescript +const state = await invoke('get_socket_state'); +// Returns: { status: 'connected' | 'disconnected' | ..., socket_id: string | null, error: string | null } +``` + +### `is_socket_connected` +Check if socket is connected. + +```typescript +const isConnected = await invoke('is_socket_connected'); +``` + +### `report_socket_connected` +Report that socket connected (called by frontend). + +```typescript +await invoke('report_socket_connected', { socketId: 'socket-id' }); +``` + +### `report_socket_disconnected` +Report that socket disconnected (called by frontend). + +```typescript +await invoke('report_socket_disconnected'); +``` + +### `report_socket_error` +Report socket error (called by frontend). + +```typescript +await invoke('report_socket_error', { error: 'Connection failed' }); +``` + +### `update_socket_status` +Update socket status (called by frontend). + +```typescript +await invoke('update_socket_status', { + status: 'connected', // 'connected' | 'connecting' | 'disconnected' | 'reconnecting' | 'error' + socketId: 'socket-id' +}); +``` + +## Telegram Commands + +### `start_telegram_login` +Open Telegram login widget in browser. + +```typescript +await invoke('start_telegram_login'); +// Opens ${DEFAULT_BACKEND_URL}/auth/telegram-widget?redirect=outsourced://auth +``` + +### `start_telegram_login_with_url` +Open Telegram login widget with custom backend URL. + +```typescript +await invoke('start_telegram_login_with_url', { + backendUrl: 'https://custom-backend.com' +}); +``` + +## Window Commands + +### `show_window` +Show and focus the main window. + +```typescript +await invoke('show_window'); +``` + +### `hide_window` +Hide the main window. + +```typescript +await invoke('hide_window'); +``` + +### `toggle_window` +Toggle window visibility. + +```typescript +await invoke('toggle_window'); +``` + +### `is_window_visible` +Check if window is visible. + +```typescript +const isVisible = await invoke('is_window_visible'); +``` + +### `minimize_window` +Minimize the window. + +```typescript +await invoke('minimize_window'); +``` + +### `maximize_window` +Maximize or unmaximize the window. + +```typescript +await invoke('maximize_window'); +``` + +### `close_window` +Close the window (minimizes to tray on macOS). + +```typescript +await invoke('close_window'); +``` + +### `set_window_title` +Set the window title. + +```typescript +await invoke('set_window_title', { title: 'New Title' }); +``` + +## Events (Rust → Frontend) + +These events are emitted by Rust and can be listened to in the frontend: + +### Socket Events + +| Event | Payload | Description | +|-------|---------|-------------| +| `socket:connected` | `()` | Socket connected | +| `socket:disconnected` | `()` | Socket disconnected | +| `socket:error` | `string` | Socket error occurred | +| `socket:message` | `{ event: string, data: any }` | Socket message received | +| `socket:state_changed` | `SocketState` | Socket state changed | +| `socket:should_connect` | `{ backendUrl: string, token: string }` | Request to connect | +| `socket:should_disconnect` | `()` | Request to disconnect | + +### Listening to Events + +```typescript +import { listen } from '@tauri-apps/api/event'; + +// Listen for socket connection request +await listen('socket:should_connect', (event) => { + const { backendUrl, token } = event.payload; + socketService.connect(backendUrl, token); +}); + +// Listen for state changes +await listen('socket:state_changed', (event) => { + const state = event.payload as SocketState; + dispatch(setSocketStatus(state.status)); +}); +``` + +## Type Definitions + +```typescript +interface AuthState { + is_authenticated: boolean; + user: User | null; +} + +interface User { + id: string; + firstName?: string; + lastName?: string; + username?: string; + email?: string; + telegramId?: string; +} + +interface SocketState { + status: 'connected' | 'connecting' | 'disconnected' | 'reconnecting' | 'error'; + socket_id: string | null; + error?: string; +} +``` + +--- + +*Previous: [Architecture](./01-architecture.md) | Next: [Services](./03-services.md)* diff --git a/docs/src-tauri/03-services.md b/docs/src-tauri/03-services.md new file mode 100644 index 000000000..7175bed3f --- /dev/null +++ b/docs/src-tauri/03-services.md @@ -0,0 +1,304 @@ +# Services Documentation + +This document describes the background services in the Rust backend. + +## SessionService + +Manages user sessions with secure OS keychain storage. + +### Location +`src-tauri/src/services/session_service.rs` + +### Purpose +- Store authentication tokens securely in OS keychain +- Cache session in memory for fast access +- Persist sessions across app restarts + +### API + +```rust +pub struct SessionService { + // ... +} + +impl SessionService { + /// Create a new SessionService (loads from keychain) + pub fn new() -> Self; + + /// Store a new session + pub fn store_session(&self, token: &str, user: &User) -> Result<(), String>; + + /// Get the current session token + pub fn get_token(&self) -> Option; + + /// Get the current session + pub fn get_session(&self) -> Option; + + /// Get the current user + pub fn get_user(&self) -> Option; + + /// Check if there's an active session + pub fn is_authenticated(&self) -> bool; + + /// Clear the current session (logout) + pub fn clear_session(&self) -> Result<(), String>; +} +``` + +### Keychain Storage + +| Platform | Storage Backend | +|----------|-----------------| +| macOS | Keychain | +| Windows | Credential Manager | +| Linux | Secret Service (libsecret) | + +### Stored Data + +```json +{ + "token": "jwt-session-token", + "user_id": "user-uuid", + "user": { + "id": "user-uuid", + "firstName": "John", + "lastName": "Doe" + }, + "created_at": 1706540000, + "expires_at": null +} +``` + +### Usage + +```rust +use crate::commands::auth::SESSION_SERVICE; + +// Store session +SESSION_SERVICE.store_session("token", &user)?; + +// Get token +if let Some(token) = SESSION_SERVICE.get_token() { + // Use token +} + +// Check auth +if SESSION_SERVICE.is_authenticated() { + // User is logged in +} + +// Logout +SESSION_SERVICE.clear_session()?; +``` + +--- + +## SocketService + +Manages Socket.io connection state and coordinates with the frontend. + +### Location +`src-tauri/src/services/socket_service.rs` + +### Purpose +- Track socket connection state +- Store connection parameters for reconnection +- Emit events to frontend for connection control +- Enable background socket persistence + +### Architecture + +The actual Socket.io client runs in the frontend (JavaScript). The Rust service: +1. Stores connection parameters (URL, token) +2. Tracks state reported by frontend +3. Emits events to request connect/disconnect +4. Enables socket to persist when window is hidden + +### API + +```rust +pub struct SocketService { + // ... +} + +impl SocketService { + /// Create a new SocketService + pub fn new() -> Self; + + /// Set app handle for event emission + pub fn set_app_handle(&self, handle: AppHandle); + + /// Get current connection status + pub fn get_status(&self) -> ConnectionStatus; + + /// Get current socket state + pub fn get_state(&self) -> SocketState; + + /// Check if connected + pub fn is_connected(&self) -> bool; + + /// Request frontend to connect + pub fn request_connect(&self, backend_url: &str, token: &str) -> Result<(), String>; + + /// Request frontend to disconnect + pub fn request_disconnect(&self) -> Result<(), String>; + + /// Update status (called by frontend via command) + pub fn update_status(&self, status: ConnectionStatus, socket_id: Option); + + /// Report connection (called by frontend) + pub fn report_connected(&self, socket_id: Option); + + /// Report disconnection (called by frontend) + pub fn report_disconnected(&self); + + /// Report error (called by frontend) + pub fn report_error(&self, error: &str); + + /// Get stored connection params for reconnection + pub fn get_connection_params(&self) -> Option<(String, String)>; + + /// Clear stored credentials + pub fn clear_credentials(&self); +} +``` + +### Events Emitted + +| Event | Payload | When | +|-------|---------|------| +| `socket:should_connect` | `{ backendUrl, token }` | `request_connect` called | +| `socket:should_disconnect` | `()` | `request_disconnect` called | +| `socket:state_changed` | `SocketState` | State changes | +| `socket:error` | `string` | Error reported | + +### Connection States + +```rust +pub enum ConnectionStatus { + Disconnected, + Connecting, + Connected, + Reconnecting, + Error, +} +``` + +### Usage + +```rust +use crate::services::socket_service::SOCKET_SERVICE; + +// Initialize with app handle +SOCKET_SERVICE.set_app_handle(app.handle()); + +// Request connection (emits event to frontend) +SOCKET_SERVICE.request_connect("https://api.example.com", "token")?; + +// Check status +if SOCKET_SERVICE.is_connected() { + // Socket is connected +} + +// Frontend reports status via commands +// invoke('report_socket_connected', { socketId: 'abc' }) +``` + +### Background Persistence + +When the window is hidden: +1. The Tauri app continues running (tray icon) +2. The WebView is not destroyed, just hidden +3. Socket.io connection in JavaScript stays active +4. Frontend continues receiving messages +5. User can show window to see updates + +--- + +## NotificationService + +Shows native desktop notifications. + +### Location +`src-tauri/src/services/notification_service.rs` + +### Purpose +- Show native notifications +- Check notification permission +- Request notification permission + +### API + +```rust +pub struct NotificationService; + +impl NotificationService { + /// Show a simple notification + pub fn show(app: &AppHandle, title: &str, body: &str) -> Result<(), String>; + + /// Show a notification with an icon + pub fn show_with_icon( + app: &AppHandle, + title: &str, + body: &str, + icon: &str, + ) -> Result<(), String>; + + /// Show a notification for a new message + pub fn show_message_notification( + app: &AppHandle, + sender: &str, + message: &str, + ) -> Result<(), String>; + + /// Check if notifications are permitted + pub fn is_permission_granted(app: &AppHandle) -> Result; + + /// Request notification permission + pub fn request_permission(app: &AppHandle) -> Result; +} +``` + +### Usage + +```rust +use crate::services::notification_service::NotificationService; + +// Show notification +NotificationService::show(&app, "New Message", "You have a new message")?; + +// Show message notification +NotificationService::show_message_notification(&app, "John", "Hey, how are you?")?; + +// Check permission +if NotificationService::is_permission_granted(&app)? { + // Can show notifications +} +``` + +--- + +## Service Initialization + +Services are initialized as singletons in their respective modules: + +```rust +// In auth.rs +pub static SESSION_SERVICE: Lazy> = + Lazy::new(|| Arc::new(SessionService::new())); + +// In socket_service.rs +pub static SOCKET_SERVICE: Lazy> = + Lazy::new(|| Arc::new(SocketService::new())); +``` + +The SocketService's app handle is set during app setup: + +```rust +// In lib.rs setup() +SOCKET_SERVICE.set_app_handle(app.handle().clone()); +``` + +--- + +*Previous: [Commands Reference](./02-commands.md) | [Back to Index](./README.md)* diff --git a/docs/src-tauri/README.md b/docs/src-tauri/README.md new file mode 100644 index 000000000..1d5755519 --- /dev/null +++ b/docs/src-tauri/README.md @@ -0,0 +1,374 @@ +# Rust Backend Documentation + +## Overview + +This documentation covers the Tauri Rust backend for the AlphaHuman desktop application. + +## Quick Reference + +| Document | Description | +|----------|-------------| +| [Architecture](./01-architecture.md) | System architecture and module structure | +| [Commands Reference](./02-commands.md) | All Tauri IPC commands | +| [Services](./03-services.md) | Background services documentation | + +## Features Implemented + +1. **System Tray** - Background execution with menu bar icon +2. **Telegram Widget Login** → Deep link session creation +3. **Socket.io State Management** - Persistent background connection +4. **Secure Session Storage** - OS Keychain integration +5. **Native Notifications** - Desktop notifications +6. **Cross-Platform** - macOS, Windows, Linux ready + +## Current State Analysis + +### Existing Implementation (`lib.rs`) +- ✅ System tray with show/hide/quit +- ✅ Deep link handling (`outsourced://` scheme) +- ✅ Token exchange command (CORS bypass) +- ✅ Autostart plugin (macOS LaunchAgent) +- ✅ Window minimize-to-tray on close (macOS) + +### Missing Features +- ❌ Socket.io client in Rust (background persistence) +- ❌ Telegram Widget integration +- ❌ Session management in Rust +- ❌ Background service architecture +- ❌ Notification system +- ❌ State persistence (keychain/secure storage) + +--- + +## Implementation Plan + +### Phase 1: Project Structure Refactoring + +**Goal**: Modular architecture for maintainability + +``` +src-tauri/src/ +├── lib.rs # Entry point, plugin registration +├── main.rs # Binary entry (unchanged) +├── commands/ # Tauri commands (IPC) +│ ├── mod.rs +│ ├── auth.rs # Token exchange, session management +│ ├── socket.rs # Socket connection control +│ └── telegram.rs # Telegram-specific commands +├── services/ # Background services +│ ├── mod.rs +│ ├── socket_service.rs # Persistent Socket.io client +│ ├── session_service.rs # Secure session storage +│ └── notification_service.rs # Native notifications +├── models/ # Data structures +│ ├── mod.rs +│ ├── auth.rs # Auth types +│ └── socket.rs # Socket message types +└── utils/ # Helpers + ├── mod.rs + └── config.rs # Environment configuration +``` + +### Phase 2: Socket.io Background Service + +**Goal**: Persistent WebSocket connection even when app is in background + +**Dependencies to add:** +```toml +[dependencies] +rust_socketio = "0.6" # Socket.io client +tokio = { version = "1", features = ["full", "sync"] } +once_cell = "1.19" # Lazy static for singleton +parking_lot = "0.12" # Fast mutexes +``` + +**Implementation:** +```rust +// services/socket_service.rs +pub struct SocketService { + client: Option, + auth_token: Option, + is_connected: AtomicBool, +} + +impl SocketService { + pub async fn connect(&self, token: &str) -> Result<(), Error>; + pub async fn disconnect(&self) -> Result<(), Error>; + pub async fn emit(&self, event: &str, data: Value) -> Result<(), Error>; + pub fn is_connected(&self) -> bool; +} +``` + +**Background Persistence:** +- Socket runs on Tokio runtime, independent of window state +- Connection survives window hide/minimize +- Auto-reconnect on network recovery +- Heartbeat/ping to keep connection alive + +### Phase 3: Telegram Widget Login Flow + +**Goal**: Web-based Telegram auth → Deep link callback → Native session + +**Flow:** +``` +1. User clicks "Login with Telegram" in desktop app + ↓ +2. App opens system browser to: + ${BACKEND_URL}/auth/telegram-widget?redirect=outsourced://auth + ↓ +3. Backend serves Telegram Login Widget HTML page + ↓ +4. User authenticates with Telegram + ↓ +5. Telegram callback → Backend validates → Creates loginToken + ↓ +6. Backend redirects to: outsourced://auth?token={loginToken} + ↓ +7. Desktop app catches deep link + ↓ +8. Rust `exchange_token` → Backend exchanges for sessionToken + ↓ +9. Session stored securely (Keychain on macOS) + ↓ +10. Socket connects with session token +``` + +**Backend Endpoint Needed:** +``` +GET /auth/telegram-widget?redirect={deeplink_scheme} +``` +Returns HTML page with Telegram Login Widget that redirects to the specified scheme. + +**Commands to implement:** +```rust +#[tauri::command] +async fn start_telegram_login(app: AppHandle) -> Result<(), String> { + // Open browser to Telegram widget page + let url = format!("{}/auth/telegram-widget?redirect=outsourced://auth", BACKEND_URL); + opener::open(&url)?; + Ok(()) +} + +#[tauri::command] +async fn get_session() -> Result, String> { + // Return current session from secure storage +} + +#[tauri::command] +async fn logout(app: AppHandle) -> Result<(), String> { + // Clear session, disconnect socket +} +``` + +### Phase 4: Secure Session Storage + +**Goal**: Store auth tokens securely using OS keychain + +**Dependencies:** +```toml +[dependencies] +keyring = "3" # Cross-platform keychain access +``` + +**Implementation:** +```rust +// services/session_service.rs +pub struct SessionService { + keyring: Entry, +} + +impl SessionService { + const SERVICE: &'static str = "com.megamind.tauri-app"; + + pub fn store_token(&self, token: &str) -> Result<(), Error>; + pub fn get_token(&self) -> Result, Error>; + pub fn clear_token(&self) -> Result<(), Error>; +} +``` + +**Platform Support:** +- macOS: Keychain +- Windows: Credential Manager +- Linux: Secret Service (libsecret) + +### Phase 5: Native Notifications + +**Goal**: Show notifications even when app is minimized + +**Dependencies:** +```toml +[dependencies] +tauri-plugin-notification = "2" +``` + +**Capability Addition:** +```json +{ + "permissions": [ + "notification:default", + "notification:allow-notify", + "notification:allow-request-permission" + ] +} +``` + +**Usage:** +```rust +// services/notification_service.rs +pub fn show_notification(title: &str, body: &str) -> Result<(), Error> { + Notification::new() + .title(title) + .body(body) + .show()?; + Ok(()) +} +``` + +### Phase 6: Event Bridge (Rust ↔ Frontend) + +**Goal**: Bidirectional communication between Rust services and React frontend + +**Events from Rust to Frontend:** +```rust +// Emit to frontend when socket receives message +app.emit("socket:message", payload)?; +app.emit("socket:connected", ())?; +app.emit("socket:disconnected", ())?; +app.emit("telegram:notification", notification)?; +``` + +**Frontend listening:** +```typescript +import { listen } from '@tauri-apps/api/event'; + +await listen('socket:message', (event) => { + // Handle message from Rust socket service +}); +``` + +### Phase 7: MCP Integration in Rust + +**Goal**: Run MCP tools from Rust for performance-critical operations + +**Approach:** +- Keep MCP tools in TypeScript for flexibility +- Rust handles socket transport +- Frontend dispatches tool calls +- Rust forwards via socket, returns results + +**Alternative (Full Rust MCP):** +- Implement tool handlers in Rust +- Higher performance, but more maintenance +- Consider for v2 + +--- + +## Implementation Order + +| Phase | Priority | Effort | Dependencies | +|-------|----------|--------|--------------| +| 1. Project Structure | High | 2h | None | +| 2. Socket.io Service | High | 4h | Phase 1 | +| 3. Telegram Widget Login | High | 3h | Backend endpoint | +| 4. Secure Storage | High | 2h | Phase 1 | +| 5. Notifications | Medium | 1h | Phase 2 | +| 6. Event Bridge | High | 2h | Phase 2 | +| 7. MCP Integration | Low | 4h+ | Phase 2, 6 | + +**Total Estimated Effort**: 18+ hours + +--- + +## Cross-Platform Considerations + +### macOS +- ✅ System tray (menu bar) +- ✅ LaunchAgent autostart +- ✅ Keychain storage +- ✅ Deep link via Info.plist +- ⚠️ Notarization for distribution + +### Windows +- ✅ System tray +- ✅ Registry autostart +- ✅ Credential Manager storage +- ✅ Deep link via registry +- ⚠️ Code signing for SmartScreen + +### Linux +- ✅ System tray (AppIndicator) +- ✅ Desktop file autostart +- ✅ Secret Service storage +- ⚠️ Deep link varies by desktop environment + +### Mobile (Future) +- ❌ No system tray +- ❌ Different auth flow +- ❌ Push notifications instead of socket +- Consider separate implementation + +--- + +## Testing Strategy + +### Unit Tests +```rust +#[cfg(test)] +mod tests { + #[tokio::test] + async fn test_socket_connect() { ... } + + #[test] + fn test_session_storage() { ... } +} +``` + +### Integration Tests +- Deep link flow end-to-end +- Socket reconnection scenarios +- Background persistence verification + +### Manual Testing +- Build debug `.app` bundle +- Test tray behavior +- Test window minimize/restore +- Test background socket + +--- + +## Files to Create/Modify + +### New Files +- `src-tauri/src/commands/mod.rs` +- `src-tauri/src/commands/auth.rs` +- `src-tauri/src/commands/socket.rs` +- `src-tauri/src/commands/telegram.rs` +- `src-tauri/src/services/mod.rs` +- `src-tauri/src/services/socket_service.rs` +- `src-tauri/src/services/session_service.rs` +- `src-tauri/src/services/notification_service.rs` +- `src-tauri/src/models/mod.rs` +- `src-tauri/src/models/auth.rs` +- `src-tauri/src/models/socket.rs` +- `src-tauri/src/utils/mod.rs` +- `src-tauri/src/utils/config.rs` + +### Modified Files +- `src-tauri/Cargo.toml` - Add dependencies +- `src-tauri/src/lib.rs` - Refactor, use modules +- `src-tauri/capabilities/default.json` - Add permissions + +--- + +## Success Criteria + +1. ✅ User can log in via Telegram widget +2. ✅ Session persists across app restarts +3. ✅ Socket stays connected when app is minimized +4. ✅ Notifications appear for new messages +5. ✅ All web features work in desktop app +6. ✅ Cross-platform compatible architecture + +--- + +*Plan created by stevenbaba - 2026-01-29* diff --git a/docs/src/01-architecture.md b/docs/src/01-architecture.md new file mode 100644 index 000000000..9d0ac2559 --- /dev/null +++ b/docs/src/01-architecture.md @@ -0,0 +1,175 @@ +# Architecture Overview + +## System Architecture + +The Outsourced platform is built on a layered architecture supporting: +- Redux-based state management with persistence +- Socket.io real-time communication +- Telegram MTProto integration via service layer +- 81-tool MCP (Model Context Protocol) system for AI interactions +- Multi-step onboarding flow +- URL-based settings modal system +- Deep link authentication handoff +- Cross-platform compatibility (desktop + mobile) + +## Entry Points + +| File | Purpose | +|------|---------| +| `main.tsx` | React root, polyfill imports, lazy deep link listener init | +| `App.tsx` | Provider chain: Redux → PersistGate → Socket → Telegram → HashRouter | +| `AppRoutes.tsx` | Route definitions with route guards | +| `polyfills.ts` | Node.js polyfills (Buffer, process, util) for telegram npm package | + +## Provider Chain + +The application wraps components in a specific order due to dependencies: + +``` +Redux Provider + └─ PersistGate (rehydrate auth + telegram state from localStorage) + └─ UserProvider + └─ SocketProvider (manages Socket.io connection + MCP init) + └─ TelegramProvider (manages MTProto connection) + └─ HashRouter + └─ AppRoutes (route definitions + SettingsModal overlay) +``` + +**Why this order matters:** +1. Redux must be outermost for state access +2. PersistGate rehydrates persisted state before rendering +3. SocketProvider depends on Redux auth token +4. TelegramProvider depends on Redux telegram state +5. HashRouter provides navigation context to all routes + +## Module Relationships + +``` +main.tsx (entry) + ↓ +App.tsx (providers chain) + ├─ Redux Store ←→ Persist + ├─ SocketProvider + │ ├─ listens to auth.token changes + │ ├─ calls socketService.connect(token) + │ └─ init MCP server when socket connected + ├─ TelegramProvider + │ ├─ listens to telegram state + │ ├─ calls mtprotoService.initialize(userId) + │ └─ exposes useTelegram hook + └─ HashRouter + AppRoutes + ├─ ProtectedRoute ← checks Redux auth + onboarded + ├─ PublicRoute ← redirects authenticated users + ├─ pages/Home + │ ├─ uses useUser hook → calls userApi + │ └─ uses useNavigate → opens /settings + ├─ pages/Login + │ ├─ uses TelegramLoginButton + │ └─ calls deeplink.ts functions + └─ SettingsModal + ├─ listens to location.pathname + ├─ uses useSettingsNavigation hook + └─ renders panels with Redux state +``` + +## Services Layer + +``` +Services Layer: + ├─ apiClient (singleton) + │ ├─ reads auth.token from Redux + │ └─ makes HTTP requests to BACKEND_URL + ├─ socketService (singleton) + │ ├─ manages Socket.io connection + │ └─ emits/listens for MCP messages + └─ mtprotoService (singleton) + ├─ manages TelegramClient + └─ stores session in Redux telegram.byUser[userId].sessionString +``` + +## MCP System + +``` +MCP System: + ├─ TelegramMCPServer (instantiated in SocketProvider) + ├─ SocketIOMCPTransport (wraps socketService) + └─ 81 tools in telegram/tools/ + ├─ each tool calls mtprotoService + └─ results returned via socket transport to backend MCP client +``` + +## Data Flow + +### Authentication Flow (Deep Link) +1. User authenticates in browser → receives `loginToken` +2. Browser redirects to `outsourced://auth?token=` +3. Desktop app catches deep link via Tauri plugin +4. `desktopDeepLinkListener` invokes Rust `exchange_token` command +5. Rust calls backend `POST /auth/desktop-exchange` (CORS-free) +6. Backend returns `{ sessionToken, user }` +7. App stores session in Redux, navigates to `/onboarding` or `/home` + +### Socket.io Connection Flow +1. SocketProvider detects `auth.token` change +2. Calls `socketService.connect(token)` +3. On successful connection, initializes MCP server +4. MCP server registers 81 Telegram tools +5. Backend can invoke tools via JSON-RPC over Socket.io + +### Telegram Connection Flow +1. TelegramProvider detects auth state +2. Calls `mtprotoService.initialize(userId)` and `connect()` in parallel +3. MTProto client authenticates with Telegram servers +4. Session string stored in Redux `telegram.byUser[userId].sessionString` +5. Chats/messages fetched and stored in Redux + +## Key Patterns + +### No localStorage Usage +- **Rule**: Avoid `localStorage`; use Redux with persistence instead +- **Exceptions**: Redux-persist's storage adapter (persistence layer) +- **Telegram session**: Stored in `telegram.byUser[userId].sessionString` + +### Route Guard Pattern +- **PublicRoute**: Redirects authenticated users away +- **ProtectedRoute**: Requires `token` and optionally `isOnboarded` +- **DefaultRedirect**: Fallback based on auth state + +### Settings Modal Pattern +- Renders via `createPortal` when `location.pathname.startsWith('/settings')` +- URL-based navigation without affecting main route +- Uses `useSettingsNavigation` hook + +### MCP Tool Pattern +Each tool exports a handler: +```typescript +export const toolName: TelegramMCPToolHandler = { + call: async (args, { telegramClient, userId }) => { + // Perform Telegram API operation + return { success: true, data: result }; + } +} +``` + +## File Organization + +### Feature-Based Structure +- `pages/` - Full-page route components +- `pages/onboarding/` - Onboarding flow with steps +- `components/settings/` - Settings modal system +- `lib/mcp/telegram/tools/` - Individual MCP tools + +### Slice-Based State +- `store/authSlice.ts` - Authentication +- `store/socketSlice.ts` - Socket connection +- `store/userSlice.ts` - User profile +- `store/telegram/` - Complex Telegram state (5 files) + +### Singleton Services +- `services/socketService.ts` - Socket.io +- `services/mtprotoService.ts` - Telegram MTProto +- `services/apiClient.ts` - HTTP REST + +--- + +*Next: [State Management](./02-state-management.md)* diff --git a/docs/src/02-state-management.md b/docs/src/02-state-management.md new file mode 100644 index 000000000..5a0d55d0c --- /dev/null +++ b/docs/src/02-state-management.md @@ -0,0 +1,243 @@ +# State Management + +The application uses Redux Toolkit with Redux-Persist for robust state management. + +## Store Configuration + +**File:** `store/index.ts` + +```typescript +// Combines all slices with persistence +const persistConfig = { + key: 'root', + storage, + whitelist: ['auth', 'telegram'] // Persisted slices +}; +``` + +## Redux State Structure + +```typescript +RootState = { + auth: { + token: string | null, // JWT (persisted) + isOnboardedByUser: Record // Per-user flag (persisted) + }, + socket: { + byUser: Record + }, + user: { + profile: User | null, + loading: boolean, + error: string | null + }, + telegram: { + byUser: Record // Per Telegram user (persisted) + } +} +``` + +## Slices + +### Auth Slice (`store/authSlice.ts`) + +Manages JWT token and per-user onboarding status. + +**State:** +```typescript +interface AuthState { + token: string | null; + isOnboardedByUser: Record; +} +``` + +**Actions:** +- `setToken(token: string)` - Store JWT after login +- `clearToken()` - Remove token on logout +- `setOnboarded({ userId, isOnboarded })` - Mark user as onboarded + +**Selectors (`store/authSelectors.ts`):** +- `selectToken` - Get current JWT +- `selectIsOnboarded(userId)` - Check if user completed onboarding + +### Socket Slice (`store/socketSlice.ts`) + +Tracks Socket.io connection status per user. + +**State:** +```typescript +interface SocketState { + byUser: Record; +} +``` + +**Actions:** +- `setSocketStatus({ userId, status })` - Update connection status +- `setSocketId({ userId, socketId })` - Store socket ID +- `clearSocketState(userId)` - Clear user's socket state + +**Selectors (`store/socketSelectors.ts`):** +- `selectSocketStatus(userId)` - Get connection status +- `selectIsSocketConnected(userId)` - Boolean connected check + +### User Slice (`store/userSlice.ts`) + +Stores user profile data. + +**State:** +```typescript +interface UserState { + profile: User | null; + loading: boolean; + error: string | null; +} +``` + +**Actions:** +- `setUser(user)` - Store user profile +- `setUserLoading(loading)` - Set loading state +- `setUserError(error)` - Set error state +- `clearUser()` - Clear profile on logout + +### Telegram Slice (`store/telegram/`) + +Complex nested state management for Telegram integration. + +**Files:** +- `index.ts` - Slice exports (actions, thunks) +- `types.ts` - Entity and state interfaces +- `reducers.ts` - Synchronous reducers +- `extraReducers.ts` - Async thunk handlers +- `thunks.ts` - Async operations + +**State Structure:** +```typescript +telegram.byUser[telegramUserId] = { + connectionStatus: "disconnected" | "connecting" | "connected" | "error", + authStatus: "not_authenticated" | "authenticating" | "authenticated" | "error", + currentUser: TelegramUser | null, + sessionString: string | null, // Stored here, NOT localStorage + chats: Record, + chatsOrder: string[], + messages: Record>, + threads: Record +} +``` + +**Reducers:** +- `setCurrentUser` - Store authenticated Telegram user +- `setSessionString` - Store MTProto session (for persistence) +- `setConnectionStatus` - Update connection state +- `setAuthStatus` - Update authentication state +- `addChat` / `updateChat` - Manage chat list +- `addMessage` / `updateMessage` - Manage message history +- `setThreads` - Store thread data + +**Thunks (`store/telegram/thunks.ts`):** +- `initializeTelegram(userId)` - Initialize MTProto client +- `connectTelegram(userId)` - Establish Telegram connection +- `fetchChats(userId)` - Load chat list +- `fetchMessages({ userId, chatId })` - Load message history +- `disconnectTelegram(userId)` - Clean disconnect + +**Selectors (`store/telegramSelectors.ts`):** +- `selectTelegramState(userId)` - Get full Telegram state +- `selectTelegramConnectionStatus(userId)` - Get connection status +- `selectTelegramAuthStatus(userId)` - Get auth status +- `selectTelegramChats(userId)` - Get chat list +- `selectTelegramMessages(userId, chatId)` - Get messages for chat + +## Typed Hooks + +**File:** `store/hooks.ts` + +```typescript +// Use these instead of plain useDispatch/useSelector +export const useAppDispatch: () => AppDispatch = useDispatch; +export const useAppSelector: TypedUseSelectorHook = useSelector; +``` + +## Persistence Configuration + +### What's Persisted +- `auth.token` - JWT for authentication +- `auth.isOnboardedByUser` - Per-user onboarding status +- `telegram.byUser` - Telegram state (sessions, chats, etc.) + +### What's NOT Persisted +- `socket` - Connection state (reconnects on app start) +- `user.loading` / `user.error` - Transient UI states +- Telegram loading/error states + +### Storage Backend +Redux-Persist uses localStorage adapter by default. This is the ONLY acceptable use of localStorage in the application. + +## Usage Examples + +### Reading State +```typescript +import { useAppSelector } from '../store/hooks'; + +function MyComponent() { + const token = useAppSelector((state) => state.auth.token); + const isConnected = useAppSelector((state) => + state.socket.byUser[userId]?.status === 'connected' + ); + const chats = useAppSelector((state) => + state.telegram.byUser[userId]?.chats + ); +} +``` + +### Dispatching Actions +```typescript +import { useAppDispatch } from '../store/hooks'; +import { setToken, clearToken } from '../store/authSlice'; +import { initializeTelegram } from '../store/telegram/thunks'; + +function MyComponent() { + const dispatch = useAppDispatch(); + + // Sync action + const handleLogin = (token: string) => { + dispatch(setToken(token)); + }; + + // Async thunk + const handleConnect = async () => { + await dispatch(initializeTelegram(userId)).unwrap(); + }; +} +``` + +### Using Selectors +```typescript +import { useAppSelector } from '../store/hooks'; +import { selectIsOnboarded } from '../store/authSelectors'; +import { selectTelegramConnectionStatus } from '../store/telegramSelectors'; + +function MyComponent({ userId }) { + const isOnboarded = useAppSelector((state) => selectIsOnboarded(state, userId)); + const connectionStatus = useAppSelector((state) => + selectTelegramConnectionStatus(state, userId) + ); +} +``` + +## Best Practices + +1. **Always use typed hooks** - `useAppDispatch` and `useAppSelector` +2. **Use selectors for derived state** - Memoized and testable +3. **Keep thunks in separate files** - Better organization +4. **Per-user state scoping** - Key state by user ID +5. **Avoid localStorage** - Use Redux-Persist instead + +--- + +*Previous: [Architecture Overview](./01-architecture.md) | Next: [Services Layer](./03-services.md)* diff --git a/docs/src/03-services.md b/docs/src/03-services.md new file mode 100644 index 000000000..973c10f73 --- /dev/null +++ b/docs/src/03-services.md @@ -0,0 +1,297 @@ +# Services Layer + +The application uses singleton services for external communication. This prevents connection leaks and provides consistent API access. + +## Service Architecture + +``` +Services Layer + ├─ apiClient (HTTP REST) + │ ├─ reads auth.token from Redux + │ └─ makes requests to BACKEND_URL + ├─ socketService (Socket.io) + │ ├─ manages real-time connection + │ └─ emits/listens for MCP messages + └─ mtprotoService (Telegram) + ├─ manages TelegramClient + └─ stores session in Redux +``` + +## API Client (`services/apiClient.ts`) + +HTTP REST client for backend communication. + +### Features +- Fetch-based implementation +- Auto-injects JWT from Redux store +- Typed request/response handling +- Error handling with typed errors + +### Usage +```typescript +import apiClient from '../services/apiClient'; + +// GET request +const user = await apiClient.get('/users/me'); + +// POST request +const result = await apiClient.post('/auth/login', { + email, + password +}); + +// With custom headers +const data = await apiClient.get('/endpoint', { + headers: { 'X-Custom': 'value' } +}); +``` + +### Configuration +Reads `VITE_BACKEND_URL` from environment or uses default: +```typescript +const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'https://api.example.com'; +``` + +## API Endpoints (`services/api/`) + +### Auth API (`services/api/authApi.ts`) + +Authentication-related endpoints. + +```typescript +import { authApi } from '../services/api/authApi'; + +// Login +const { token, user } = await authApi.login(credentials); + +// Token exchange (for deep link flow) +const { sessionToken, user } = await authApi.exchangeToken(loginToken); + +// Logout +await authApi.logout(); +``` + +### User API (`services/api/userApi.ts`) + +User profile endpoints. + +```typescript +import { userApi } from '../services/api/userApi'; + +// Get current user +const user = await userApi.getCurrentUser(); + +// Update profile +const updated = await userApi.updateProfile({ firstName, lastName }); + +// Get settings +const settings = await userApi.getSettings(); +``` + +## Socket Service (`services/socketService.ts`) + +Socket.io client singleton for real-time communication. + +### Features +- Singleton pattern - single connection per app +- Auth token passed in socket `auth` object +- Transports: polling first, then WebSocket upgrade +- Auto-reconnection handling + +### API +```typescript +import socketService from '../services/socketService'; + +// Connect with auth token +socketService.connect(token); + +// Disconnect +socketService.disconnect(); + +// Emit event +socketService.emit('event-name', data); + +// Listen for events +socketService.on('event-name', (data) => { + // Handle event +}); + +// Remove listener +socketService.off('event-name', handler); + +// One-time listener +socketService.once('event-name', (data) => { + // Handle once +}); + +// Get socket instance +const socket = socketService.getSocket(); + +// Check connection status +const isConnected = socketService.isConnected(); +``` + +### Connection Flow +```typescript +// In SocketProvider.tsx +useEffect(() => { + if (token) { + socketService.connect(token); + + socketService.on('connect', () => { + dispatch(setSocketStatus({ userId, status: 'connected' })); + dispatch(setSocketId({ userId, socketId: socket.id })); + // Initialize MCP server + initMCPServer(socketService.getSocket()); + }); + + socketService.on('disconnect', () => { + dispatch(setSocketStatus({ userId, status: 'disconnected' })); + }); + } + + return () => { + socketService.disconnect(); + }; +}, [token]); +``` + +### Configuration +```typescript +const socket = io(BACKEND_URL, { + auth: { token }, + transports: ['polling', 'websocket'], + reconnection: true, + reconnectionAttempts: 5, + reconnectionDelay: 1000 +}); +``` + +## MTProto Service (`services/mtprotoService.ts`) + +Telegram MTProto client singleton. + +### Features +- Singleton pattern - one client per user +- Session persistence via Redux (not localStorage) +- Auto-retry for FLOOD_WAIT up to 60s +- Supports QR login and phone auth + +### Initialization +```typescript +import mtprotoService from '../services/mtprotoService'; + +// Get or create instance for user +const client = await mtprotoService.getInstance().initialize(userId); + +// Set session string (from Redux) +await client.setSession(sessionString); + +// Connect to Telegram +await client.connect(); +``` + +### Session Management +```typescript +// Session is stored in Redux, not localStorage +// In TelegramProvider: +const sessionString = useAppSelector((state) => + state.telegram.byUser[userId]?.sessionString +); + +// When session updates +useEffect(() => { + if (client && sessionString) { + client.setSession(sessionString); + } +}, [sessionString]); + +// Save session after auth +const newSession = await client.getSession(); +dispatch(setSessionString({ userId, sessionString: newSession })); +``` + +### API Operations +```typescript +// Get current user +const me = await client.getMe(); + +// Get dialogs (chats) +const dialogs = await client.getDialogs({ limit: 20 }); + +// Send message +await client.sendMessage(peer, { message: 'Hello!' }); + +// Get history +const messages = await client.getMessages(peer, { limit: 50 }); +``` + +### Error Handling +```typescript +try { + await client.connect(); +} catch (error) { + if (error.message.includes('FLOOD_WAIT')) { + const seconds = parseInt(error.message.match(/\d+/)?.[0] || '60'); + if (seconds <= 60) { + // Auto-retry after wait + await new Promise(r => setTimeout(r, seconds * 1000)); + await client.connect(); + } + } + throw error; +} +``` + +## Service Integration with Providers + +### SocketProvider +```typescript +// providers/SocketProvider.tsx +export function SocketProvider({ children }) { + const token = useAppSelector((state) => state.auth.token); + + useEffect(() => { + if (token) { + socketService.connect(token); + // On connect, initialize MCP + } + return () => socketService.disconnect(); + }, [token]); + + return {children}; +} +``` + +### TelegramProvider +```typescript +// providers/TelegramProvider.tsx +export function TelegramProvider({ children }) { + const dispatch = useAppDispatch(); + const userId = useAppSelector((state) => state.user.profile?.id); + + useEffect(() => { + if (userId) { + // Parallel init + connect for faster startup + Promise.all([ + dispatch(initializeTelegram(userId)), + dispatch(connectTelegram(userId)) + ]); + } + }, [userId]); + + return {children}; +} +``` + +## Best Practices + +1. **Use singletons** - Never create multiple service instances +2. **Store sessions in Redux** - Not localStorage +3. **Clean up on unmount** - Disconnect in useEffect cleanup +4. **Handle errors gracefully** - Retry for transient failures +5. **Pass auth via proper channels** - Socket auth object, not query string + +--- + +*Previous: [State Management](./02-state-management.md) | Next: [MCP System](./04-mcp-system.md)* diff --git a/docs/src/04-mcp-system.md b/docs/src/04-mcp-system.md new file mode 100644 index 000000000..6c50981f4 --- /dev/null +++ b/docs/src/04-mcp-system.md @@ -0,0 +1,437 @@ +# MCP System + +The Model Context Protocol (MCP) system enables AI-driven Telegram interactions through 81 specialized tools. + +## Overview + +``` +MCP System Architecture +├── lib/mcp/ +│ ├── index.ts # Singleton lifecycle management +│ ├── types.ts # MCP interfaces +│ ├── transport.ts # Socket.IO JSON-RPC 2.0 transport +│ ├── logger.ts # Logging utilities +│ ├── errorHandler.ts # Error handling +│ ├── validation.ts # Input validation +│ │ +│ └── telegram/ +│ ├── index.ts # Server initialization +│ ├── server.ts # TelegramMCPServer (81 tools) +│ ├── types.ts # Tool name types +│ ├── telegramApi.ts # Telegram API layer +│ ├── apiCastHelpers.ts # Type casting +│ ├── apiResultTypes.ts # Result types +│ ├── args.ts # Argument schemas +│ ├── toolActionParser.ts # Human-readable parsing +│ │ +│ └── tools/ # 81 individual tool files +``` + +## Core Components + +### MCP Types (`lib/mcp/types.ts`) + +```typescript +interface MCPTool { + name: string; + description: string; + inputSchema: JSONSchema; +} + +interface MCPRequest { + jsonrpc: '2.0'; + id: string | number; + method: string; + params?: unknown; +} + +interface MCPResponse { + jsonrpc: '2.0'; + id: string | number; + result?: unknown; + error?: MCPError; +} + +interface MCPError { + code: number; + message: string; + data?: unknown; +} +``` + +### Transport Layer (`lib/mcp/transport.ts`) + +Socket.IO-based JSON-RPC 2.0 transport. + +**Features:** +- Request ID tracking +- 30-second timeout +- Error handling +- Event emission + +```typescript +class SocketIOMCPTransport { + constructor(socket: Socket); + + // Send request and await response + async send(request: MCPRequest): Promise; + + // Register handler for incoming requests + onRequest(handler: (req: MCPRequest) => Promise): void; + + // Clean up listeners + dispose(): void; +} +``` + +### MCP Singleton (`lib/mcp/index.ts`) + +Lifecycle management for MCP server. + +```typescript +// Initialize MCP server with socket +initMCPServer(socket: Socket): TelegramMCPServer; + +// Get existing server instance +getMCPServer(): TelegramMCPServer | null; + +// Update socket reference +updateMCPSocket(socket: Socket): void; + +// Clean up +cleanupMCP(): void; +``` + +## Telegram MCP Server + +### Server Class (`lib/mcp/telegram/server.ts`) + +```typescript +class TelegramMCPServer { + constructor(transport: SocketIOMCPTransport, userId: string); + + // Register all 81 tools + async initialize(): Promise; + + // List available tools + listTools(): MCPTool[]; + + // Execute a tool + async executeTool(name: string, args: unknown): Promise; + + // Handle incoming JSON-RPC request + async handleRequest(request: MCPRequest): Promise; + + // Clean up + dispose(): void; +} +``` + +### Tool Handler Interface + +Each tool file exports a handler: + +```typescript +interface TelegramMCPToolHandler { + name: string; + description: string; + inputSchema: JSONSchema; + call: ( + args: unknown, + context: { + telegramClient: TelegramClient; + userId: string; + } + ) => Promise; +} + +interface ToolResult { + success: boolean; + data?: unknown; + error?: string; +} +``` + +## Tool Categories + +### 81 Telegram Tools + +#### User & Profile (5 tools) +| Tool | Description | +|------|-------------| +| `getMe` | Get current authenticated user | +| `getUserInfo` | Get info about any user | +| `getUserPhotos` | Get user profile photos | +| `getUserStatus` | Get online status | +| `setUserStatus` | Update own status | + +#### Chats & Dialogs (12 tools) +| Tool | Description | +|------|-------------| +| `getChats` | Fetch chat list | +| `getChatInfo` | Get chat details | +| `createGroup` | Create new group | +| `createChannel` | Create new channel | +| `leaveChat` | Leave chat/group/channel | +| `deleteChat` | Delete chat | +| `muteChat` | Mute notifications | +| `unmuteChat` | Unmute notifications | +| `pinChat` | Pin chat to top | +| `unpinChat` | Unpin chat | +| `archiveChat` | Archive chat | +| `unarchiveChat` | Unarchive chat | + +#### Messages (15 tools) +| Tool | Description | +|------|-------------| +| `getHistory` | Fetch message history | +| `sendMessage` | Send text message | +| `replyToMessage` | Reply to specific message | +| `editMessage` | Edit sent message | +| `deleteMessage` | Delete message | +| `forwardMessage` | Forward to another chat | +| `pinMessage` | Pin message in chat | +| `unpinMessage` | Unpin message | +| `markAsRead` | Mark messages as read | +| `searchMessages` | Search in chat | +| `translateMessage` | Translate message text | +| `getMessageReactions` | Get reactions | +| `addReaction` | React to message | +| `removeReaction` | Remove reaction | +| `reportMessage` | Report spam/abuse | + +#### Media (10 tools) +| Tool | Description | +|------|-------------| +| `sendPhoto` | Send image | +| `sendVideo` | Send video | +| `sendDocument` | Send file | +| `sendVoice` | Send voice message | +| `sendAudio` | Send audio file | +| `sendSticker` | Send sticker | +| `sendGif` | Send animation | +| `sendLocation` | Send location | +| `sendContact` | Send contact card | +| `downloadMedia` | Download media file | + +#### Contacts (8 tools) +| Tool | Description | +|------|-------------| +| `addContact` | Add new contact | +| `deleteContact` | Remove contact | +| `getContacts` | Get contact list | +| `searchContacts` | Search contacts | +| `importContacts` | Bulk import | +| `exportContacts` | Export contact list | +| `blockUser` | Block user | +| `unblockUser` | Unblock user | + +#### Groups & Channels (15 tools) +| Tool | Description | +|------|-------------| +| `getAdmins` | Get admin list | +| `getMembers` | Get member list | +| `addMember` | Add user to group | +| `removeMember` | Remove from group | +| `banUser` | Ban user | +| `unbanUser` | Unban user | +| `promoteAdmin` | Promote to admin | +| `demoteAdmin` | Remove admin rights | +| `setGroupTitle` | Change group name | +| `setGroupPhoto` | Change group photo | +| `setGroupDescription` | Change description | +| `subscribePublicChannel` | Join public channel | +| `inviteToChannel` | Invite user | +| `getInviteLink` | Get invite link | +| `revokeInviteLink` | Revoke invite link | + +#### Polls & Interactive (5 tools) +| Tool | Description | +|------|-------------| +| `createPoll` | Create poll | +| `votePoll` | Vote on poll | +| `stopPoll` | Close poll | +| `pressInlineButton` | Click inline button | +| `answerCallback` | Respond to callback | + +#### Drafts (3 tools) +| Tool | Description | +|------|-------------| +| `saveDraft` | Save draft message | +| `getDrafts` | Get all drafts | +| `deleteDraft` | Delete draft | + +#### Privacy & Settings (5 tools) +| Tool | Description | +|------|-------------| +| `getBlockedUsers` | Get blocked list | +| `getPrivacySettings` | Get privacy config | +| `updatePrivacy` | Update privacy | +| `get2FAStatus` | Check 2FA status | +| `getActiveSessions` | Get login sessions | + +#### Misc (3 tools) +| Tool | Description | +|------|-------------| +| `resolveUsername` | Resolve @username | +| `checkUsername` | Check availability | +| `getWebPage` | Get link preview | + +## Tool Implementation Example + +```typescript +// lib/mcp/telegram/tools/sendMessage.ts +import { TelegramMCPToolHandler } from '../types'; + +export const sendMessage: TelegramMCPToolHandler = { + name: 'sendMessage', + description: 'Send a text message to a chat', + inputSchema: { + type: 'object', + properties: { + chatId: { + type: 'string', + description: 'Chat ID to send message to' + }, + text: { + type: 'string', + description: 'Message text' + }, + replyToMsgId: { + type: 'string', + description: 'Optional message ID to reply to' + } + }, + required: ['chatId', 'text'] + }, + + call: async (args, { telegramClient, userId }) => { + const { chatId, text, replyToMsgId } = args as { + chatId: string; + text: string; + replyToMsgId?: string; + }; + + try { + // Use big-integer for chat ID (Telegram IDs exceed Number.MAX_SAFE_INTEGER) + const peer = await telegramClient.getEntity(bigInt(chatId)); + + const result = await telegramClient.sendMessage(peer, { + message: text, + replyTo: replyToMsgId ? parseInt(replyToMsgId) : undefined + }); + + return { + success: true, + data: { + messageId: result.id.toString(), + date: result.date + } + }; + } catch (error) { + return { + success: false, + error: error.message + }; + } + } +}; +``` + +## MCP Initialization Flow + +```typescript +// In SocketProvider.tsx +useEffect(() => { + if (socket && socket.connected) { + // Initialize MCP server + const mcpServer = initMCPServer(socket); + + // Server registers all 81 tools + mcpServer.initialize(); + + // Listen for tool execution requests + mcpServer.onRequest(async (request) => { + if (request.method === 'tools/call') { + const { name, arguments: args } = request.params; + return mcpServer.executeTool(name, args); + } + return { error: { code: -32601, message: 'Method not found' } }; + }); + } + + return () => cleanupMCP(); +}, [socket?.connected]); +``` + +## JSON-RPC Protocol + +### Request Format +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "sendMessage", + "arguments": { + "chatId": "123456789", + "text": "Hello from AI!" + } + } +} +``` + +### Response Format (Success) +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "success": true, + "data": { + "messageId": "12345", + "date": 1706540000 + } + } +} +``` + +### Response Format (Error) +```json +{ + "jsonrpc": "2.0", + "id": 1, + "error": { + "code": -32000, + "message": "Chat not found", + "data": { "chatId": "invalid" } + } +} +``` + +## Big Integer Handling + +Telegram IDs exceed JavaScript's `Number.MAX_SAFE_INTEGER`. Use `big-integer` library: + +```typescript +import bigInt from 'big-integer'; + +// Convert string ID to big integer +const chatId = bigInt(args.chatId); + +// Convert big integer back to string +const idString = chatId.toString(); +``` + +## Best Practices + +1. **Use big-integer for IDs** - All Telegram IDs should use big-integer +2. **Validate inputs** - Use JSON Schema validation before execution +3. **Handle errors gracefully** - Return structured error responses +4. **Log operations** - Use MCP logger for debugging +5. **Timeout long operations** - 30s timeout for transport + +--- + +*Previous: [Services Layer](./03-services.md) | Next: [Pages & Routing](./05-pages-routing.md)* diff --git a/docs/src/05-pages-routing.md b/docs/src/05-pages-routing.md new file mode 100644 index 000000000..20d86ea7c --- /dev/null +++ b/docs/src/05-pages-routing.md @@ -0,0 +1,403 @@ +# Pages & Routing + +The application uses HashRouter with protected and public route guards. + +## Route Structure + +``` +/ → Welcome (public) +/login → Login (public) +/onboarding → Onboarding (protected, requires auth, not yet onboarded) +/home → Home (protected, requires auth + onboarded) +/settings → Settings modal overlay +/settings/* → Settings sub-panels +* → DefaultRedirect (fallback) +``` + +## Route Configuration (`AppRoutes.tsx`) + +```typescript +export function AppRoutes() { + return ( + <> + + {/* Public routes - redirect if authenticated */} + }> + } /> + } /> + + + {/* Protected routes - require authentication */} + }> + } /> + + + {/* Protected + onboarded routes */} + }> + } /> + + + {/* Fallback redirect */} + } /> + + + {/* Settings modal overlay - renders on top of routes */} + + + ); +} +``` + +## Route Guards + +### PublicRoute (`components/PublicRoute.tsx`) + +Redirects authenticated users away from public pages. + +```typescript +export function PublicRoute() { + const token = useAppSelector((state) => state.auth.token); + const isOnboarded = useAppSelector((state) => + selectIsOnboarded(state, userId) + ); + + if (token) { + // Authenticated - redirect to appropriate page + return ; + } + + return ; +} +``` + +### ProtectedRoute (`components/ProtectedRoute.tsx`) + +Enforces authentication and optionally onboarding status. + +```typescript +interface ProtectedRouteProps { + requireOnboarded?: boolean; +} + +export function ProtectedRoute({ requireOnboarded = false }) { + const token = useAppSelector((state) => state.auth.token); + const isOnboarded = useAppSelector((state) => + selectIsOnboarded(state, userId) + ); + + if (!token) { + return ; + } + + if (requireOnboarded && !isOnboarded) { + return ; + } + + return ; +} +``` + +### DefaultRedirect (`components/DefaultRedirect.tsx`) + +Fallback route that redirects based on auth state. + +```typescript +export function DefaultRedirect() { + const token = useAppSelector((state) => state.auth.token); + const isOnboarded = useAppSelector((state) => + selectIsOnboarded(state, userId) + ); + + if (!token) { + return ; + } + + if (!isOnboarded) { + return ; + } + + return ; +} +``` + +## Pages + +### Welcome Page (`pages/Welcome.tsx`) + +Landing page for unauthenticated users. + +**Features:** +- App introduction and branding +- CTA to login/signup +- Public route (redirects if authenticated) + +### Login Page (`pages/Login.tsx`) + +Authentication page. + +**Features:** +- Telegram OAuth button +- Opens `/auth/telegram?platform=desktop` in browser +- Handles deep link callback + +```typescript +export function Login() { + const handleTelegramLogin = () => { + // Opens Telegram OAuth in system browser + openUrl(`${BACKEND_URL}/auth/telegram?platform=desktop`); + }; + + return ( +
+ +
+ ); +} +``` + +### Home Page (`pages/Home.tsx`) + +Main dashboard after authentication. + +**Features:** +- Protected route (requires auth + onboarded) +- Connection status indicators +- Navigation to settings modal +- Future: Chat list, messages, etc. + +```typescript +export function Home() { + const navigate = useNavigate(); + const user = useAppSelector((state) => state.user.profile); + const telegramStatus = useAppSelector((state) => + selectTelegramConnectionStatus(state, user?.id) + ); + + return ( +
+
+

Welcome, {user?.firstName}

+ +
+ + + + + {/* Main content */} +
+ ); +} +``` + +## Onboarding Flow (`pages/onboarding/`) + +Multi-step onboarding process. + +### Structure +``` +pages/onboarding/ +├── Onboarding.tsx # Flow controller +└── steps/ + ├── GetStartedStep.tsx # Welcome + ├── PrivacyStep.tsx # Privacy policy + ├── AnalyticsStep.tsx # Analytics opt-in + ├── ConnectStep.tsx # Telegram connection + └── FeaturesStep.tsx # Features overview +``` + +### Onboarding Controller (`Onboarding.tsx`) + +```typescript +const STEPS = [ + { id: 'get-started', component: GetStartedStep }, + { id: 'privacy', component: PrivacyStep }, + { id: 'analytics', component: AnalyticsStep }, + { id: 'connect', component: ConnectStep }, + { id: 'features', component: FeaturesStep } +]; + +export function Onboarding() { + const [currentStep, setCurrentStep] = useState(0); + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + + const handleNext = () => { + if (currentStep < STEPS.length - 1) { + setCurrentStep(currentStep + 1); + } else { + // Complete onboarding + dispatch(setOnboarded({ userId, isOnboarded: true })); + navigate('/home'); + } + }; + + const handleBack = () => { + if (currentStep > 0) { + setCurrentStep(currentStep - 1); + } + }; + + const StepComponent = STEPS[currentStep].component; + + return ( +
+ + +
+ ); +} +``` + +### Step Components + +Each step receives `onNext` and `onBack` callbacks: + +```typescript +interface StepProps { + onNext: () => void; + onBack: () => void; +} + +export function ConnectStep({ onNext, onBack }: StepProps) { + const [showModal, setShowModal] = useState(false); + const telegramStatus = useAppSelector(/* ... */); + + return ( +
+

Connect Your Accounts

+ + {connectOptions.map((option) => ( + option.id === 'telegram' && setShowModal(true)} + /> + ))} + + setShowModal(false)} + /> + +
+ + +
+
+ ); +} +``` + +## Settings Modal Routing + +The settings modal overlays existing content using URL-based routing. + +### Modal Detection +```typescript +// In SettingsModal.tsx +const location = useLocation(); +const isOpen = location.pathname.startsWith('/settings'); +``` + +### Sub-Routes +``` +/settings → SettingsHome (main menu) +/settings/connections → ConnectionsPanel +/settings/messaging → MessagingPanel (future) +/settings/privacy → PrivacyPanel (future) +/settings/profile → ProfilePanel (future) +/settings/advanced → AdvancedPanel (future) +/settings/billing → BillingPanel (future) +``` + +### Navigation +```typescript +import { useSettingsNavigation } from './hooks/useSettingsNavigation'; + +function SettingsHome() { + const { navigateTo, closeModal } = useSettingsNavigation(); + + return ( +
+ navigateTo('connections')} + /> + +
+ ); +} +``` + +## HashRouter vs BrowserRouter + +The app uses HashRouter for desktop compatibility: + +```typescript +// App.tsx +import { HashRouter } from 'react-router-dom'; + +// URLs look like: app://localhost/#/home +// Instead of: app://localhost/home +``` + +**Why HashRouter:** +1. Tauri deep links work with hash-based URLs +2. No server configuration needed +3. Works with file:// protocol +4. Prevents 404 on direct URL access + +## Deep Link Handling + +Deep links are handled before routing: + +```typescript +// main.tsx +import('./utils/desktopDeepLinkListener').then((m) => { + m.setupDesktopDeepLinkListener().catch(console.error); +}); +``` + +The listener intercepts `outsourced://auth?token=...` and: +1. Exchanges token via Rust command +2. Stores session in Redux +3. Navigates to `/onboarding` or `/home` + +## Navigation Patterns + +### Programmatic Navigation +```typescript +import { useNavigate } from 'react-router-dom'; + +const navigate = useNavigate(); + +// Navigate to route +navigate('/home'); + +// Replace history entry +navigate('/login', { replace: true }); + +// Go back +navigate(-1); +``` + +### Link Component +```typescript +import { Link } from 'react-router-dom'; + +Settings +``` + +### State Transfer +```typescript +// Pass state to route +navigate('/details', { state: { itemId: 123 } }); + +// Receive state +const location = useLocation(); +const { itemId } = location.state; +``` + +--- + +*Previous: [MCP System](./04-mcp-system.md) | Next: [Components](./06-components.md)* diff --git a/docs/src/06-components.md b/docs/src/06-components.md new file mode 100644 index 000000000..df2561368 --- /dev/null +++ b/docs/src/06-components.md @@ -0,0 +1,511 @@ +# Components + +Reusable React components organized by feature. + +## Component Structure + +``` +components/ +├── Route Guards +│ ├── ProtectedRoute.tsx +│ ├── PublicRoute.tsx +│ └── DefaultRedirect.tsx +│ +├── Authentication +│ └── TelegramLoginButton.tsx +│ +├── Connection Status +│ ├── ConnectionIndicator.tsx +│ ├── TelegramConnectionIndicator.tsx +│ ├── TelegramConnectionModal.tsx +│ └── GmailConnectionIndicator.tsx +│ +├── Onboarding +│ ├── ProgressIndicator.tsx +│ └── LottieAnimation.tsx +│ +├── Settings Modal (16 files) +│ ├── SettingsModal.tsx +│ ├── SettingsLayout.tsx +│ ├── SettingsHome.tsx +│ ├── panels/ +│ ├── components/ +│ └── hooks/ +│ +└── Development + └── DesignSystemShowcase.tsx +``` + +## Route Guard Components + +### ProtectedRoute + +Requires authentication and optionally onboarding. + +```typescript +interface ProtectedRouteProps { + requireOnboarded?: boolean; +} + +// Usage in AppRoutes.tsx +}> + } /> + + +}> + } /> + +``` + +### PublicRoute + +Redirects authenticated users away. + +```typescript +// Usage in AppRoutes.tsx +}> + } /> + } /> + +``` + +### DefaultRedirect + +Fallback that routes based on auth state. + +```typescript +// Redirects to: +// - "/" if not authenticated +// - "/onboarding" if authenticated but not onboarded +// - "/home" if authenticated and onboarded +``` + +## Authentication Components + +### TelegramLoginButton + +OAuth login button for Telegram. + +```typescript +interface TelegramLoginButtonProps { + onClick: () => void; + disabled?: boolean; +} + +// Usage + openUrl(`${BACKEND_URL}/auth/telegram?platform=desktop`)} +/> +``` + +## Connection Status Components + +### ConnectionIndicator + +Generic connection status badge. + +```typescript +interface ConnectionIndicatorProps { + status: 'connected' | 'connecting' | 'disconnected' | 'error'; + label?: string; +} + + +``` + +### TelegramConnectionIndicator + +Telegram-specific status display. + +```typescript +interface TelegramConnectionIndicatorProps { + status: 'connected' | 'connecting' | 'disconnected' | 'error'; +} + +// Usage with Redux state +const telegramStatus = useAppSelector((state) => + selectTelegramConnectionStatus(state, userId) +); + + +``` + +### TelegramConnectionModal + +Modal for setting up Telegram connection. + +```typescript +interface TelegramConnectionModalProps { + isOpen: boolean; + onClose: () => void; +} + +// Usage in onboarding/settings +const [showModal, setShowModal] = useState(false); + + setShowModal(false)} +/> +``` + +**Features:** +- QR code login flow +- Phone number login flow +- Connection status display +- Error handling + +### GmailConnectionIndicator + +Gmail status badge (future integration). + +```typescript + +``` + +## Onboarding Components + +### ProgressIndicator + +Visual progress through onboarding steps. + +```typescript +interface ProgressIndicatorProps { + current: number; + total: number; +} + + +``` + +### LottieAnimation + +Lottie animation player for onboarding. + +```typescript +interface LottieAnimationProps { + animationData: object; + loop?: boolean; + autoplay?: boolean; + className?: string; +} + +import welcomeAnimation from '../assets/animations/welcome.json'; + + +``` + +## Settings Modal System + +Complete modal system with URL-based routing. + +### File Structure +``` +components/settings/ +├── SettingsModal.tsx # Route-based container +├── SettingsLayout.tsx # Portal + backdrop wrapper +├── SettingsHome.tsx # Main menu with profile +├── panels/ +│ ├── ConnectionsPanel.tsx # Connection management +│ ├── MessagingPanel.tsx # (Future) +│ ├── PrivacyPanel.tsx # (Future) +│ ├── ProfilePanel.tsx # (Future) +│ ├── AdvancedPanel.tsx # (Future) +│ └── BillingPanel.tsx # (Future) +├── components/ +│ ├── SettingsHeader.tsx # User profile section +│ ├── SettingsMenuItem.tsx # Menu item component +│ ├── SettingsBackButton.tsx # Back navigation +│ └── SettingsPanelLayout.tsx# Panel wrapper +└── hooks/ + ├── useSettingsNavigation.ts # URL routing + └── useSettingsAnimation.ts # Animation state +``` + +### SettingsModal + +Main container that renders based on URL. + +```typescript +export function SettingsModal() { + const location = useLocation(); + const isOpen = location.pathname.startsWith('/settings'); + + if (!isOpen) return null; + + return ( + + {/* Route to appropriate panel */} + {location.pathname === '/settings' && } + {location.pathname === '/settings/connections' && } + {/* ... more panels */} + + ); +} +``` + +### SettingsLayout + +Portal-based modal wrapper. + +```typescript +export function SettingsLayout({ children }) { + const { closeModal } = useSettingsNavigation(); + + return createPortal( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+
+ {children} +
+
+
, + document.body + ); +} +``` + +### SettingsHome + +Main menu with user profile. + +```typescript +export function SettingsHome() { + const { navigateTo, closeModal } = useSettingsNavigation(); + const user = useAppSelector((state) => state.user.profile); + + const menuItems = [ + { id: 'connections', label: 'Connections', icon: LinkIcon }, + { id: 'messaging', label: 'Messaging', icon: MessageIcon }, + { id: 'privacy', label: 'Privacy', icon: ShieldIcon }, + // ... more items + ]; + + return ( +
+ + + {menuItems.map((item) => ( + navigateTo(item.id)} + /> + ))} +
+ ); +} +``` + +### ConnectionsPanel + +Connection management interface. + +```typescript +export function ConnectionsPanel() { + const { navigateBack } = useSettingsNavigation(); + const [telegramModalOpen, setTelegramModalOpen] = useState(false); + + const telegramStatus = useAppSelector((state) => + selectTelegramConnectionStatus(state, userId) + ); + + // Reuses connectOptions from onboarding + const connections = connectOptions.map((opt) => ({ + ...opt, + status: opt.id === 'telegram' ? telegramStatus : 'coming-soon' + })); + + return ( + + {connections.map((conn) => ( + conn.id === 'telegram' && setTelegramModalOpen(true)} + /> + ))} + + setTelegramModalOpen(false)} + /> + + ); +} +``` + +### Settings Hooks + +#### useSettingsNavigation + +URL-based navigation for settings modal. + +```typescript +interface UseSettingsNavigationReturn { + currentRoute: string; + navigateTo: (panel: string) => void; + navigateBack: () => void; + closeModal: () => void; +} + +const { navigateTo, navigateBack, closeModal } = useSettingsNavigation(); + +// Navigate to panel +navigateTo('connections'); // → /settings/connections + +// Go back +navigateBack(); // → /settings + +// Close modal +closeModal(); // → previous non-settings route +``` + +#### useSettingsAnimation + +Animation state management. + +```typescript +interface UseSettingsAnimationReturn { + isEntering: boolean; + isExiting: boolean; + animationClass: string; +} + +const { animationClass } = useSettingsAnimation(); + +
+ {/* Content */} +
+``` + +### Settings Components + +#### SettingsHeader + +User profile section at top of settings. + +```typescript +interface SettingsHeaderProps { + user: User | null; + onClose: () => void; +} + + +``` + +#### SettingsMenuItem + +Individual menu item with icon and chevron. + +```typescript +interface SettingsMenuItemProps { + label: string; + icon: React.ComponentType; + onClick: () => void; + badge?: string; + disabled?: boolean; +} + + navigateTo('connections')} + badge="2" +/> +``` + +#### SettingsBackButton + +Back navigation button. + +```typescript +interface SettingsBackButtonProps { + onClick: () => void; +} + + +``` + +#### SettingsPanelLayout + +Wrapper for settings panels. + +```typescript +interface SettingsPanelLayoutProps { + title: string; + onBack: () => void; + children: React.ReactNode; +} + + + {/* Panel content */} + +``` + +## Component Patterns + +### Reusing Connection Options + +The `connectOptions` array is shared between onboarding and settings: + +```typescript +// Defined in ConnectStep.tsx, imported elsewhere +export const connectOptions = [ + { + id: 'telegram', + label: 'Telegram', + icon: TelegramIcon, + description: 'Connect your Telegram account' + }, + { + id: 'gmail', + label: 'Gmail', + icon: GmailIcon, + description: 'Connect your Gmail account', + comingSoon: true + } +]; +``` + +### Modal via Portal + +Settings modal uses `createPortal` to render outside the component tree: + +```typescript +return createPortal( +
+ {/* Modal content */} +
, + document.body +); +``` + +### Controlled vs Uncontrolled + +Connection modals are controlled components: + +```typescript +// Parent controls open state +const [isOpen, setIsOpen] = useState(false); + + setIsOpen(false)} +/> +``` + +--- + +*Previous: [Pages & Routing](./05-pages-routing.md) | Next: [Providers](./07-providers.md)* diff --git a/docs/src/07-providers.md b/docs/src/07-providers.md new file mode 100644 index 000000000..5bfc0be13 --- /dev/null +++ b/docs/src/07-providers.md @@ -0,0 +1,404 @@ +# Providers + +React context providers manage service lifecycle and provide shared state. + +## Provider Chain + +The providers wrap the application in a specific order: + +```typescript +// App.tsx + + + + + + + + + + + + + +``` + +**Order matters because:** +1. Redux must be outermost for state access +2. PersistGate rehydrates state before rendering children +3. SocketProvider depends on Redux auth token +4. TelegramProvider depends on Redux telegram state +5. HashRouter provides navigation to all routes + +## SocketProvider (`providers/SocketProvider.tsx`) + +Manages Socket.io connection lifecycle and MCP initialization. + +### Responsibilities +- Auto-connect when auth token is available +- Auto-disconnect when token is cleared +- Initialize MCP server when socket connects +- Update Redux with connection status + +### Implementation + +```typescript +interface SocketContextValue { + socket: Socket | null; + isConnected: boolean; + emit: (event: string, data: unknown) => void; + on: (event: string, handler: Function) => void; + off: (event: string, handler: Function) => void; +} + +export function SocketProvider({ children }) { + const token = useAppSelector((state) => state.auth.token); + const userId = useAppSelector((state) => state.user.profile?.id); + const dispatch = useAppDispatch(); + + useEffect(() => { + if (!token || !userId) { + socketService.disconnect(); + dispatch(setSocketStatus({ userId, status: 'disconnected' })); + return; + } + + // Connect with auth token + socketService.connect(token); + dispatch(setSocketStatus({ userId, status: 'connecting' })); + + // Handle connection events + socketService.on('connect', () => { + dispatch(setSocketStatus({ userId, status: 'connected' })); + dispatch(setSocketId({ userId, socketId: socketService.getSocket()?.id })); + + // Initialize MCP server + initMCPServer(socketService.getSocket()); + }); + + socketService.on('disconnect', () => { + dispatch(setSocketStatus({ userId, status: 'disconnected' })); + cleanupMCP(); + }); + + socketService.on('connect_error', (error) => { + console.error('Socket connection error:', error); + dispatch(setSocketStatus({ userId, status: 'disconnected' })); + }); + + return () => { + socketService.disconnect(); + cleanupMCP(); + }; + }, [token, userId]); + + const contextValue: SocketContextValue = { + socket: socketService.getSocket(), + isConnected: socketService.isConnected(), + emit: socketService.emit.bind(socketService), + on: socketService.on.bind(socketService), + off: socketService.off.bind(socketService) + }; + + return ( + + {children} + + ); +} +``` + +### Usage + +```typescript +import { useSocket } from '../providers/SocketProvider'; + +function MyComponent() { + const { socket, isConnected, emit, on, off } = useSocket(); + + useEffect(() => { + const handler = (data) => console.log('Received:', data); + on('event-name', handler); + return () => off('event-name', handler); + }, [on, off]); + + const sendMessage = () => { + emit('send-message', { text: 'Hello!' }); + }; + + return ( +
+ Status: {isConnected ? 'Connected' : 'Disconnected'} + +
+ ); +} +``` + +## TelegramProvider (`providers/TelegramProvider.tsx`) + +Manages Telegram MTProto connection lifecycle. + +### Responsibilities +- Initialize MTProto client when user is authenticated +- Connect to Telegram servers +- Store session string in Redux +- Provide Telegram context to children + +### Implementation + +```typescript +interface TelegramContextValue { + client: TelegramClient | null; + connectionStatus: ConnectionStatus; + authStatus: AuthStatus; + connect: () => Promise; + disconnect: () => Promise; +} + +export function TelegramProvider({ children }) { + const dispatch = useAppDispatch(); + const userId = useAppSelector((state) => state.user.profile?.id); + const telegramState = useAppSelector((state) => + state.telegram.byUser[userId] + ); + + useEffect(() => { + if (!userId) return; + + // Parallel initialization for faster startup + const init = async () => { + try { + // Initialize and connect in parallel + await Promise.all([ + dispatch(initializeTelegram(userId)).unwrap(), + dispatch(connectTelegram(userId)).unwrap() + ]); + } catch (error) { + console.error('Telegram initialization failed:', error); + } + }; + + init(); + + return () => { + dispatch(disconnectTelegram(userId)); + }; + }, [userId]); + + // Restore session from persisted state + useEffect(() => { + if (telegramState?.sessionString) { + const client = mtprotoService.getInstance().getClient(); + if (client) { + client.setSession(telegramState.sessionString); + } + } + }, [telegramState?.sessionString]); + + const contextValue: TelegramContextValue = { + client: mtprotoService.getInstance().getClient(), + connectionStatus: telegramState?.connectionStatus || 'disconnected', + authStatus: telegramState?.authStatus || 'not_authenticated', + connect: () => dispatch(connectTelegram(userId)).unwrap(), + disconnect: () => dispatch(disconnectTelegram(userId)).unwrap() + }; + + return ( + + {children} + + ); +} +``` + +### Usage + +```typescript +import { useTelegram } from '../providers/TelegramProvider'; + +function ChatList() { + const { client, connectionStatus, authStatus } = useTelegram(); + const [chats, setChats] = useState([]); + + useEffect(() => { + if (connectionStatus === 'connected' && authStatus === 'authenticated') { + const fetchChats = async () => { + const dialogs = await client.getDialogs({ limit: 20 }); + setChats(dialogs); + }; + fetchChats(); + } + }, [client, connectionStatus, authStatus]); + + if (connectionStatus !== 'connected') { + return
Connecting to Telegram...
; + } + + return ( +
    + {chats.map((chat) => ( +
  • {chat.title}
  • + ))} +
+ ); +} +``` + +## UserProvider (`providers/UserProvider.tsx`) + +Minimal user context provider (most user state is in Redux). + +### Responsibilities +- Legacy user context for compatibility +- May be deprecated in favor of Redux + +### Implementation + +```typescript +interface UserContextValue { + user: User | null; + loading: boolean; +} + +export function UserProvider({ children }) { + const user = useAppSelector((state) => state.user.profile); + const loading = useAppSelector((state) => state.user.loading); + + return ( + + {children} + + ); +} +``` + +### Usage + +```typescript +import { useUserContext } from '../providers/UserProvider'; + +function Header() { + const { user, loading } = useUserContext(); + + if (loading) return ; + if (!user) return null; + + return Welcome, {user.firstName}; +} +``` + +## Provider Patterns + +### Effect-Based Lifecycle + +Providers use `useEffect` to manage service lifecycle: + +```typescript +useEffect(() => { + // Setup on mount or dependency change + service.connect(); + + // Cleanup on unmount or dependency change + return () => { + service.disconnect(); + }; +}, [dependencies]); +``` + +### Redux Integration + +Providers read from and dispatch to Redux: + +```typescript +// Read state +const token = useAppSelector((state) => state.auth.token); + +// Dispatch actions +const dispatch = useAppDispatch(); +dispatch(setStatus({ userId, status: 'connected' })); +``` + +### Parallel Initialization + +TelegramProvider runs init and connect in parallel: + +```typescript +await Promise.all([ + dispatch(initializeTelegram(userId)).unwrap(), + dispatch(connectTelegram(userId)).unwrap() +]); +``` + +This reduces startup time compared to sequential operations. + +### Session Restoration + +Providers restore persisted state on mount: + +```typescript +useEffect(() => { + if (persistedSession) { + service.restoreSession(persistedSession); + } +}, [persistedSession]); +``` + +## Context vs Redux + +| Use Context For | Use Redux For | +|-----------------|---------------| +| Service instances (socket, client) | Serializable state (status, data) | +| Methods (emit, on, off) | Persisted state (sessions, tokens) | +| Derived values | Complex state logic | + +Example: +- `SocketContext` provides `socket` instance and `emit` method +- Redux stores `socketStatus` and `socketId` + +## Testing Providers + +### Mock Provider for Tests + +```typescript +// test-utils.tsx +const mockSocketContext: SocketContextValue = { + socket: null, + isConnected: true, + emit: jest.fn(), + on: jest.fn(), + off: jest.fn() +}; + +export function TestProviders({ children }) { + return ( + + + {children} + + + ); +} +``` + +### Testing Provider Effects + +```typescript +test('SocketProvider connects when token is available', () => { + const store = createTestStore({ auth: { token: 'test-token' } }); + + render( + + + + + + ); + + expect(socketService.connect).toHaveBeenCalledWith('test-token'); +}); +``` + +--- + +*Previous: [Components](./06-components.md) | Next: [Hooks & Utils](./08-hooks-utils.md)* diff --git a/docs/src/08-hooks-utils.md b/docs/src/08-hooks-utils.md new file mode 100644 index 000000000..7ff41ffbb --- /dev/null +++ b/docs/src/08-hooks-utils.md @@ -0,0 +1,505 @@ +# Hooks & Utilities + +Custom React hooks and utility functions. + +## Custom Hooks + +### useSocket (`hooks/useSocket.ts`) + +Access Socket.io functionality from any component. + +```typescript +interface UseSocketReturn { + socket: Socket | null; + isConnected: boolean; + emit: (event: string, data: unknown) => void; + on: (event: string, handler: Function) => void; + off: (event: string, handler: Function) => void; + once: (event: string, handler: Function) => void; +} + +function useSocket(): UseSocketReturn; +``` + +**Usage:** + +```typescript +import { useSocket } from '../hooks/useSocket'; + +function ChatInput() { + const { emit, isConnected } = useSocket(); + + const sendMessage = (text: string) => { + if (isConnected) { + emit('chat:message', { text }); + } + }; + + return ( + e.key === 'Enter' && sendMessage(e.target.value)} + /> + ); +} +``` + +**With event listeners:** + +```typescript +function Notifications() { + const { on, off } = useSocket(); + const [notifications, setNotifications] = useState([]); + + useEffect(() => { + const handler = (notification) => { + setNotifications((prev) => [...prev, notification]); + }; + + on('notification', handler); + return () => off('notification', handler); + }, [on, off]); + + return ; +} +``` + +### useUser (`hooks/useUser.ts`) + +Access user profile data and loading state. + +```typescript +interface UseUserReturn { + user: User | null; + loading: boolean; + error: string | null; + refetch: () => Promise; +} + +function useUser(): UseUserReturn; +``` + +**Usage:** + +```typescript +import { useUser } from '../hooks/useUser'; + +function ProfileHeader() { + const { user, loading, error, refetch } = useUser(); + + if (loading) return ; + if (error) return ; + if (!user) return null; + + return ( +
+ + {user.firstName} {user.lastName} +
+ ); +} +``` + +### Settings Modal Hooks + +#### useSettingsNavigation (`components/settings/hooks/useSettingsNavigation.ts`) + +URL-based navigation for settings modal. + +```typescript +interface UseSettingsNavigationReturn { + currentRoute: string; // Current settings path + navigateTo: (panel: string) => void; // Navigate to panel + navigateBack: () => void; // Go back one level + closeModal: () => void; // Close settings entirely +} + +function useSettingsNavigation(): UseSettingsNavigationReturn; +``` + +**Usage:** + +```typescript +import { useSettingsNavigation } from './hooks/useSettingsNavigation'; + +function SettingsMenu() { + const { navigateTo, closeModal } = useSettingsNavigation(); + + return ( + + ); +} +``` + +#### useSettingsAnimation (`components/settings/hooks/useSettingsAnimation.ts`) + +Animation state management for settings modal. + +```typescript +interface UseSettingsAnimationReturn { + isEntering: boolean; // Modal is animating in + isExiting: boolean; // Modal is animating out + animationClass: string; // CSS class for current state +} + +function useSettingsAnimation(): UseSettingsAnimationReturn; +``` + +**Usage:** + +```typescript +import { useSettingsAnimation } from './hooks/useSettingsAnimation'; + +function SettingsModal() { + const { animationClass, isExiting } = useSettingsAnimation(); + + return ( +
+ {/* Content */} +
+ ); +} +``` + +## Utilities + +### Configuration (`utils/config.ts`) + +Environment variable access with defaults. + +```typescript +// Backend URL +export const BACKEND_URL = + import.meta.env.VITE_BACKEND_URL || 'https://api.example.com'; + +// Telegram configuration +export const TELEGRAM_API_ID = import.meta.env.VITE_TELEGRAM_API_ID; +export const TELEGRAM_API_HASH = import.meta.env.VITE_TELEGRAM_API_HASH; +export const TELEGRAM_BOT_USERNAME = import.meta.env.VITE_TELEGRAM_BOT_USERNAME; +export const TELEGRAM_BOT_ID = import.meta.env.VITE_TELEGRAM_BOT_ID; + +// Debug mode +export const DEBUG = import.meta.env.VITE_DEBUG === 'true'; +``` + +**Usage:** + +```typescript +import { BACKEND_URL, DEBUG } from '../utils/config'; + +const response = await fetch(`${BACKEND_URL}/api/users`); + +if (DEBUG) { + console.log('Response:', response); +} +``` + +### Deep Link (`utils/deeplink.ts`) + +Build deep link URLs for authentication handoff. + +```typescript +// Build auth deep link +function buildAuthDeepLink(token: string): string; + +// Parse deep link URL +function parseDeepLink(url: string): { path: string; params: URLSearchParams }; +``` + +**Usage:** + +```typescript +import { buildAuthDeepLink } from '../utils/deeplink'; + +// Build URL for browser redirect +const deepLink = buildAuthDeepLink(loginToken); +// → "outsourced://auth?token=abc123" + +// In web frontend after auth: +window.location.href = deepLink; +``` + +### Desktop Deep Link Listener (`utils/desktopDeepLinkListener.ts`) + +Handle incoming deep links in desktop app. + +```typescript +// Setup listener for deep link events +async function setupDesktopDeepLinkListener(): Promise; +``` + +**Called in main.tsx:** + +```typescript +// Lazy import to ensure Tauri IPC is ready +import('./utils/desktopDeepLinkListener').then((m) => { + m.setupDesktopDeepLinkListener().catch(console.error); +}); +``` + +**What it does:** +1. Listens for `onOpenUrl` events from Tauri deep-link plugin +2. Parses `outsourced://auth?token=...` URLs +3. Calls Rust `exchange_token` command (bypasses CORS) +4. Stores session in Redux +5. Navigates to `/onboarding` or `/home` + +**Loop prevention:** +```typescript +// Set flag before navigation to prevent reprocessing +localStorage.setItem('deepLinkHandled', 'true'); +window.location.replace('/'); + +// On next load, clear flag +if (localStorage.getItem('deepLinkHandled') === 'true') { + localStorage.removeItem('deepLinkHandled'); + return; // Don't process again +} +``` + +### URL Opener (`utils/openUrl.ts`) + +Cross-platform URL opening. + +```typescript +// Open URL in system browser +async function openUrl(url: string): Promise; +``` + +**Usage:** + +```typescript +import { openUrl } from '../utils/openUrl'; + +// Opens in system browser (not in-app WebView) +await openUrl('https://telegram.org/auth'); +``` + +**Implementation:** +```typescript +export async function openUrl(url: string): Promise { + try { + // Try Tauri opener plugin first + const { open } = await import('@tauri-apps/plugin-opener'); + await open(url); + } catch { + // Fallback to browser API + window.open(url, '_blank'); + } +} +``` + +## Polyfills (`polyfills.ts`) + +Node.js polyfills for browser environment. + +The `telegram` npm package requires Node.js APIs. These are polyfilled: + +```typescript +// polyfills.ts +import { Buffer } from 'buffer'; +import process from 'process'; +import util from 'util'; + +window.Buffer = Buffer; +window.process = process; +window.util = util; +``` + +**Imported at app entry:** + +```typescript +// main.tsx +import './polyfills'; +// ... rest of app +``` + +**Vite configuration:** + +```typescript +// vite.config.ts +export default defineConfig({ + resolve: { + alias: { + buffer: 'buffer', + process: 'process/browser', + util: 'util' + } + }, + define: { + 'process.env': {}, + global: 'globalThis' + } +}); +``` + +## Types + +### API Types (`types/api.ts`) + +```typescript +// API response wrapper +interface ApiResponse { + success: boolean; + data?: T; + error?: string; +} + +// API error +interface ApiError { + code: string; + message: string; + details?: unknown; +} + +// User interface +interface User { + id: string; + firstName: string; + lastName?: string; + username?: string; + email?: string; + avatar?: string; + telegramId?: string; + subscription?: SubscriptionInfo; + usage?: UsageInfo; + createdAt: string; + updatedAt: string; +} +``` + +### Onboarding Types (`types/onboarding.ts`) + +```typescript +// Onboarding step definition +interface OnboardingStep { + id: string; + title: string; + component: React.ComponentType; +} + +// Step component props +interface StepProps { + onNext: () => void; + onBack: () => void; +} + +// Connection option +interface ConnectionOption { + id: string; + label: string; + icon: React.ComponentType; + description: string; + comingSoon?: boolean; +} +``` + +## Static Data + +### Countries (`data/countries.ts`) + +Country list for phone number input. + +```typescript +interface Country { + code: string; // "US" + name: string; // "United States" + dialCode: string; // "+1" + flag: string; // "🇺🇸" +} + +export const countries: Country[]; +``` + +**Usage:** + +```typescript +import { countries } from '../data/countries'; + +function PhoneInput() { + const [country, setCountry] = useState(countries[0]); + + return ( +
+ + +
+ ); +} +``` + +## Best Practices + +### Hook Dependencies + +Always include dependencies in useEffect: + +```typescript +// Good +useEffect(() => { + on('event', handler); + return () => off('event', handler); +}, [on, off, handler]); + +// Bad - missing dependencies +useEffect(() => { + on('event', handler); + return () => off('event', handler); +}, []); +``` + +### Cleanup Functions + +Always clean up subscriptions: + +```typescript +useEffect(() => { + const subscription = subscribe(); + return () => subscription.unsubscribe(); +}, []); +``` + +### Error Boundaries + +Wrap utility calls in try-catch: + +```typescript +try { + await openUrl(url); +} catch (error) { + console.error('Failed to open URL:', error); + // Fallback behavior +} +``` + +### Type Safety + +Use TypeScript generics for API calls: + +```typescript +const user = await apiClient.get('/users/me'); +// user is typed as User +``` + +--- + +*Previous: [Providers](./07-providers.md) | [Back to Index](./README.md)* diff --git a/docs/src/README.md b/docs/src/README.md new file mode 100644 index 000000000..e326cc8c6 --- /dev/null +++ b/docs/src/README.md @@ -0,0 +1,78 @@ +# Source Code Documentation + +This documentation covers the `/src` folder structure of the Outsourced Crypto Community Platform. + +## Quick Reference + +| Document | Description | +|----------|-------------| +| [Architecture Overview](./01-architecture.md) | High-level system architecture and provider chain | +| [State Management](./02-state-management.md) | Redux Toolkit slices, persistence, and selectors | +| [Services Layer](./03-services.md) | Singleton services (Socket.io, MTProto, API client) | +| [MCP System](./04-mcp-system.md) | Model Context Protocol with 81 Telegram tools | +| [Pages & Routing](./05-pages-routing.md) | Route definitions, guards, and page components | +| [Components](./06-components.md) | Reusable UI components and settings modal system | +| [Providers](./07-providers.md) | React context providers and lifecycle management | +| [Hooks & Utils](./08-hooks-utils.md) | Custom hooks and utility functions | + +## File Count Summary + +| Category | Files | Purpose | +|----------|-------|---------| +| Entry & Configuration | 7 | App init, routing, styles, types | +| State Management | 13 | Redux slices, selectors, hooks | +| Providers | 3 | Socket, Telegram, User contexts | +| Services | 5 | Singleton API clients | +| Pages | 9 | Full-page route components | +| Components | 28 | Reusable UI + settings modal (16 files) | +| MCP Core | 14 | MCP interfaces, transport, logging | +| MCP Telegram Tools | 81 | Individual Telegram API operations | +| Hooks | 2 | Custom React hooks | +| Types | 2 | TypeScript interfaces | +| Utils | 4 | Config, deep link, URL utilities | +| Data | 1 | Static data (countries) | +| Assets | 10+ | Icons and images | +| **TOTAL** | **171+** | Complete frontend application | + +## Directory Structure + +``` +src/ +├── App.tsx # Root component with provider chain +├── AppRoutes.tsx # Route definitions +├── main.tsx # Entry point +├── polyfills.ts # Node.js polyfills for browser +├── index.css # Global styles +│ +├── store/ # Redux state management (13 files) +├── providers/ # Context providers (3 files) +├── services/ # Singleton services (5 files) +├── lib/mcp/ # MCP system (95+ files) +├── pages/ # Page components (9 files) +├── components/ # UI components (28 files) +├── hooks/ # Custom hooks (2 files) +├── types/ # TypeScript types (2 files) +├── utils/ # Utilities (4 files) +├── data/ # Static data (1 file) +└── assets/ # Icons and images +``` + +## Key Architectural Decisions + +1. **HashRouter over BrowserRouter** - Required for Tauri deep link compatibility +2. **Redux Toolkit with Persistence** - Robust state management with rehydration +3. **Singleton Services** - Prevents connection leaks for Socket.io and MTProto +4. **Per-User State Scoping** - Telegram/socket state keyed by user ID +5. **Portal-Based Settings Modal** - URL routing without affecting main routes +6. **81-Tool MCP System** - Comprehensive Telegram API coverage + +## Getting Started + +1. Read [Architecture Overview](./01-architecture.md) for the big picture +2. Understand [State Management](./02-state-management.md) for data flow +3. Review [Services Layer](./03-services.md) for backend communication +4. Explore [MCP System](./04-mcp-system.md) for AI tool integration + +--- + +*Documentation maintained by stevenbaba* diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e4e9167d1..3e17434f2 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -41,6 +41,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.100" @@ -452,6 +502,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + [[package]] name = "combine" version = "4.6.7" @@ -903,6 +959,29 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1845,6 +1924,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.17" @@ -1874,6 +1959,30 @@ dependencies = [ "system-deps", ] +[[package]] +name = "jiff" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67e8da4c49d6d9909fe03361f9b620f58898859f5c7aded68351e85e71ecf50" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c84ee7f197eca9a86c6fd6cb771e55eb991632f15f2bc3ca6ec838929e6e78" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "jni" version = "0.21.1" @@ -1939,6 +2048,16 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "log", + "zeroize", +] + [[package]] name = "kuchikiki" version = "0.8.8-speedreader" @@ -2040,6 +2159,18 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "mac-notification-sys" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65fd3f75411f4725061682ed91f131946e912859d0044d39c4ec0aac818d7621" +dependencies = [ + "cc", + "objc2", + "objc2-foundation", + "time", +] + [[package]] name = "markup5ever" version = "0.14.1" @@ -2193,6 +2324,20 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "notify-rust" +version = "4.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6442248665a5aa2514e794af3b39661a8e73033b1cc5e59899e1276117ee4400" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + [[package]] name = "num-conv" version = "0.2.0" @@ -2448,6 +2593,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "open" version = "5.3.3" @@ -2767,7 +2918,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", "indexmap 2.13.0", - "quick-xml", + "quick-xml 0.38.4", "serde", "time", ] @@ -2799,6 +2950,21 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "portable-atomic" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -2897,6 +3063,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.38.4" @@ -2946,6 +3121,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -2966,6 +3151,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.5.1" @@ -2984,6 +3179,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_hc" version = "0.2.0" @@ -3875,6 +4079,11 @@ dependencies = [ name = "tauri-app" version = "0.1.0" dependencies = [ + "env_logger", + "keyring", + "log", + "once_cell", + "parking_lot", "reqwest", "serde", "serde_json", @@ -3882,6 +4091,7 @@ dependencies = [ "tauri-build", "tauri-plugin-autostart", "tauri-plugin-deep-link", + "tauri-plugin-notification", "tauri-plugin-opener", "tokio", ] @@ -4001,6 +4211,25 @@ dependencies = [ "windows-result 0.3.4", ] +[[package]] +name = "tauri-plugin-notification" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc" +dependencies = [ + "log", + "notify-rust", + "rand 0.9.2", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "time", + "url", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.3" @@ -4124,6 +4353,18 @@ dependencies = [ "toml 0.9.11+spec-1.1.0", ] +[[package]] +name = "tauri-winrt-notification" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" +dependencies = [ + "quick-xml 0.37.5", + "thiserror 2.0.18", + "windows", + "windows-version", +] + [[package]] name = "tempfile" version = "3.24.0" @@ -4618,6 +4859,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.20.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 42c61e023..dee2eac64 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "tauri-app" version = "0.1.0" -description = "A Tauri App" -authors = ["you"] +description = "AlphaHuman - Crypto Community Platform" +authors = ["MegaMind"] edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -18,12 +18,37 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] +# Tauri core and plugins tauri = { version = "2", features = ["tray-icon", "macos-private-api"] } tauri-plugin-opener = "2" tauri-plugin-deep-link = "2.0.0" tauri-plugin-autostart = "2" +tauri-plugin-notification = "2" + +# Serialization serde = { version = "1", features = ["derive"] } serde_json = "1" -reqwest = { version = "0.12", features = ["json"] } -tokio = { version = "1", features = ["full"] } +# HTTP client +reqwest = { version = "0.12", features = ["json"] } + +# Async runtime +tokio = { version = "1", features = ["full", "sync"] } + +# Secure storage (keychain) +keyring = "3" + +# Concurrency utilities +once_cell = "1.19" +parking_lot = "0.12" + +# Logging +log = "0.4" +env_logger = "0.11" + +[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] +# Desktop-only dependencies can go here + +[features] +# This feature is used for production builds or when a dev server is not specified, DO NOT REMOVE!! +custom-protocol = ["tauri/custom-protocol"] diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 9fa7d5e67..154794486 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -11,6 +11,10 @@ "autostart:default", "autostart:allow-enable", "autostart:allow-disable", - "autostart:allow-is-enabled" + "autostart:allow-is-enabled", + "notification:default", + "notification:allow-notify", + "notification:allow-request-permission", + "notification:allow-is-permission-granted" ] } diff --git a/src-tauri/src/commands/auth.rs b/src-tauri/src/commands/auth.rs new file mode 100644 index 000000000..7d9f569e3 --- /dev/null +++ b/src-tauri/src/commands/auth.rs @@ -0,0 +1,99 @@ +use crate::models::auth::{AuthState, TokenExchangeResponse, User}; +use crate::services::session_service::SessionService; +use crate::services::socket_service::SOCKET_SERVICE; +use once_cell::sync::Lazy; +use std::sync::Arc; + +// Global session service instance +pub static SESSION_SERVICE: Lazy> = + Lazy::new(|| Arc::new(SessionService::new())); + +/// Exchange a login token for a session token +/// This is called after the user authenticates via deep link +#[tauri::command] +pub async fn exchange_token( + backend_url: String, + token: String, +) -> Result { + let client = reqwest::Client::new(); + let url = format!("{}/auth/desktop-exchange", backend_url); + + let response = client + .post(&url) + .header("Content-Type", "application/json") + .header("ngrok-skip-browser-warning", "true") + .json(&serde_json::json!({ "token": token })) + .send() + .await + .map_err(|e| format!("Request failed: {}", e))?; + + let status = response.status().as_u16(); + let body: serde_json::Value = response + .json() + .await + .map_err(|e| format!("Failed to parse response: {}", e))?; + + if status != 200 { + let error = body + .get("error") + .and_then(|e| e.as_str()) + .unwrap_or("Unknown error"); + return Err(format!("Exchange failed ({}): {}", status, error)); + } + + // Try to parse and store session + if let Ok(exchange_response) = serde_json::from_value::(body.clone()) { + // Store session securely + let _ = SESSION_SERVICE.store_session(&exchange_response.session_token, &exchange_response.user); + } + + Ok(body) +} + +/// Get the current authentication state +#[tauri::command] +pub fn get_auth_state() -> AuthState { + AuthState { + is_authenticated: SESSION_SERVICE.is_authenticated(), + user: SESSION_SERVICE.get_user(), + } +} + +/// Get the current session token +#[tauri::command] +pub fn get_session_token() -> Option { + SESSION_SERVICE.get_token() +} + +/// Get the current user +#[tauri::command] +pub fn get_current_user() -> Option { + SESSION_SERVICE.get_user() +} + +/// Check if the user is authenticated +#[tauri::command] +pub fn is_authenticated() -> bool { + SESSION_SERVICE.is_authenticated() +} + +/// Logout and clear session +#[tauri::command] +pub fn logout() -> Result<(), String> { + // Request socket to disconnect + let _ = SOCKET_SERVICE.request_disconnect(); + + // Clear session + SESSION_SERVICE.clear_session()?; + + // Clear socket credentials + SOCKET_SERVICE.clear_credentials(); + + Ok(()) +} + +/// Store session manually (used by frontend if needed) +#[tauri::command] +pub fn store_session(token: String, user: User) -> Result<(), String> { + SESSION_SERVICE.store_session(&token, &user) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs new file mode 100644 index 000000000..f47252d34 --- /dev/null +++ b/src-tauri/src/commands/mod.rs @@ -0,0 +1,10 @@ +pub mod auth; +pub mod socket; +pub mod telegram; +pub mod window; + +// Re-export all commands for registration +pub use auth::*; +pub use socket::*; +pub use telegram::*; +pub use window::*; diff --git a/src-tauri/src/commands/socket.rs b/src-tauri/src/commands/socket.rs new file mode 100644 index 000000000..ad9246507 --- /dev/null +++ b/src-tauri/src/commands/socket.rs @@ -0,0 +1,66 @@ +use crate::models::socket::{ConnectionStatus, SocketState}; +use crate::services::socket_service::SOCKET_SERVICE; +use tauri::AppHandle; + +/// Request the frontend to connect to the socket server +#[tauri::command] +pub fn socket_connect( + app: AppHandle, + backend_url: String, + token: String, +) -> Result<(), String> { + // Set app handle for event emission + SOCKET_SERVICE.set_app_handle(app); + + // Request frontend to connect + SOCKET_SERVICE.request_connect(&backend_url, &token) +} + +/// Request the frontend to disconnect from the socket server +#[tauri::command] +pub fn socket_disconnect() -> Result<(), String> { + SOCKET_SERVICE.request_disconnect() +} + +/// Get current socket state +#[tauri::command] +pub fn get_socket_state() -> SocketState { + SOCKET_SERVICE.get_state() +} + +/// Check if socket is connected +#[tauri::command] +pub fn is_socket_connected() -> bool { + SOCKET_SERVICE.is_connected() +} + +/// Report socket connected (called by frontend) +#[tauri::command] +pub fn report_socket_connected(socket_id: Option) { + SOCKET_SERVICE.report_connected(socket_id); +} + +/// Report socket disconnected (called by frontend) +#[tauri::command] +pub fn report_socket_disconnected() { + SOCKET_SERVICE.report_disconnected(); +} + +/// Report socket error (called by frontend) +#[tauri::command] +pub fn report_socket_error(error: String) { + SOCKET_SERVICE.report_error(&error); +} + +/// Update socket status (called by frontend) +#[tauri::command] +pub fn update_socket_status(status: String, socket_id: Option) { + let status = match status.as_str() { + "connected" => ConnectionStatus::Connected, + "connecting" => ConnectionStatus::Connecting, + "reconnecting" => ConnectionStatus::Reconnecting, + "error" => ConnectionStatus::Error, + _ => ConnectionStatus::Disconnected, + }; + SOCKET_SERVICE.update_status(status, socket_id); +} diff --git a/src-tauri/src/commands/telegram.rs b/src-tauri/src/commands/telegram.rs new file mode 100644 index 000000000..a545db310 --- /dev/null +++ b/src-tauri/src/commands/telegram.rs @@ -0,0 +1,35 @@ +use crate::utils::config::get_telegram_widget_url; +use tauri::AppHandle; +use tauri_plugin_opener::OpenerExt; + +/// Start Telegram login flow by opening the widget in system browser +/// The widget will redirect back to the app via deep link after auth +#[tauri::command] +pub async fn start_telegram_login(app: AppHandle) -> Result<(), String> { + let url = get_telegram_widget_url(); + + // Open in system browser using the opener plugin + app.opener() + .open_url(&url, None::<&str>) + .map_err(|e| format!("Failed to open browser: {}", e))?; + + Ok(()) +} + +/// Start Telegram login with a custom backend URL +#[tauri::command] +pub async fn start_telegram_login_with_url( + app: AppHandle, + backend_url: String, +) -> Result<(), String> { + let url = format!( + "{}/auth/telegram-widget?redirect=outsourced://auth", + backend_url + ); + + app.opener() + .open_url(&url, None::<&str>) + .map_err(|e| format!("Failed to open browser: {}", e))?; + + Ok(()) +} diff --git a/src-tauri/src/commands/window.rs b/src-tauri/src/commands/window.rs new file mode 100644 index 000000000..cd3eb227e --- /dev/null +++ b/src-tauri/src/commands/window.rs @@ -0,0 +1,80 @@ +use tauri::{AppHandle, Manager}; + +/// Show the main window +#[tauri::command] +pub fn show_window(app: AppHandle) { + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + } +} + +/// Hide the main window +#[tauri::command] +pub fn hide_window(app: AppHandle) { + if let Some(window) = app.get_webview_window("main") { + let _ = window.hide(); + } +} + +/// Toggle window visibility +#[tauri::command] +pub fn toggle_window(app: AppHandle) { + if let Some(window) = app.get_webview_window("main") { + match window.is_visible() { + Ok(true) => { + let _ = window.hide(); + } + Ok(false) | Err(_) => { + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + } + } + } +} + +/// Check if window is visible +#[tauri::command] +pub fn is_window_visible(app: AppHandle) -> bool { + app.get_webview_window("main") + .and_then(|w| w.is_visible().ok()) + .unwrap_or(false) +} + +/// Minimize the main window +#[tauri::command] +pub fn minimize_window(app: AppHandle) { + if let Some(window) = app.get_webview_window("main") { + let _ = window.minimize(); + } +} + +/// Maximize or unmaximize the main window +#[tauri::command] +pub fn maximize_window(app: AppHandle) { + if let Some(window) = app.get_webview_window("main") { + if window.is_maximized().unwrap_or(false) { + let _ = window.unmaximize(); + } else { + let _ = window.maximize(); + } + } +} + +/// Close the main window (triggers minimize on macOS if configured) +#[tauri::command] +pub fn close_window(app: AppHandle) { + if let Some(window) = app.get_webview_window("main") { + let _ = window.close(); + } +} + +/// Set window title +#[tauri::command] +pub fn set_window_title(app: AppHandle, title: String) { + if let Some(window) = app.get_webview_window("main") { + let _ = window.set_title(&title); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7a423f2e7..cfff8d8ab 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,51 +1,37 @@ +//! AlphaHuman Desktop Application +//! +//! This is the Rust backend for the cross-platform crypto community platform. +//! It provides: +//! - System tray with background execution +//! - Deep link authentication +//! - Persistent Socket.io connection +//! - Secure session storage +//! - Native notifications + +mod commands; +mod models; +mod services; +mod utils; + +use commands::*; +use services::socket_service::SOCKET_SERVICE; use tauri::{ menu::{Menu, MenuItem}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, - Manager, AppHandle, RunEvent, + AppHandle, Manager, RunEvent, }; #[cfg(any(windows, target_os = "linux"))] use tauri_plugin_deep_link::DeepLinkExt; +/// Demo command - can be removed in production #[tauri::command] fn greet(name: &str) -> String { format!("Hello, {}! You've been greeted from Rust!", name) } - -#[tauri::command] -async fn exchange_token(backend_url: String, token: String) -> Result { - let client = reqwest::Client::new(); - let url = format!("{}/auth/desktop-exchange", backend_url); - - let response = client - .post(&url) - .header("Content-Type", "application/json") - .header("ngrok-skip-browser-warning", "true") - .json(&serde_json::json!({ "token": token })) - .send() - .await - .map_err(|e| format!("Request failed: {}", e))?; - - let status = response.status().as_u16(); - let body: serde_json::Value = response - .json() - .await - .map_err(|e| format!("Failed to parse response: {}", e))?; - - if status != 200 { - let error = body - .get("error") - .and_then(|e| e.as_str()) - .unwrap_or("Unknown error"); - return Err(format!("Exchange failed ({}): {}", status, error)); - } - - Ok(body) -} - // Helper function to show the window -fn show_window(app: &AppHandle) { +fn show_main_window(app: &AppHandle) { if let Some(window) = app.get_webview_window("main") { let _ = window.show(); let _ = window.unminimize(); @@ -54,18 +40,18 @@ fn show_window(app: &AppHandle) { } // Helper function to toggle window visibility -fn toggle_window_visibility(app: &AppHandle) { +fn toggle_main_window_visibility(app: &AppHandle) { if let Some(window) = app.get_webview_window("main") { match window.is_visible() { Ok(true) => { let _ = window.hide(); } Ok(false) => { - show_window(app); + show_main_window(app); } Err(_) => { // If we can't determine visibility, try to show it - show_window(app); + show_main_window(app); } } } else { @@ -74,6 +60,7 @@ fn toggle_window_visibility(app: &AppHandle) { } // Setup system tray with menu +#[cfg(desktop)] fn setup_tray(app: &AppHandle) -> Result<(), Box> { let show_hide_item = MenuItem::with_id(app, "show_hide", "Show/Hide Window", true, None::<&str>)?; let quit_item = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; @@ -84,36 +71,34 @@ fn setup_tray(app: &AppHandle) -> Result<(), Box> { .icon(app.default_window_icon().unwrap().clone()) .menu(&menu) .tooltip("AlphaHuman") - .on_menu_event(move |app, event| { - match event.id().as_ref() { - "show_hide" => { - toggle_window_visibility(app); - } - "quit" => { - app.exit(0); - } - _ => {} + .on_menu_event(move |app, event| match event.id().as_ref() { + "show_hide" => { + toggle_main_window_visibility(app); } + "quit" => { + // Cleanup before exit - request frontend to disconnect + let _ = SOCKET_SERVICE.request_disconnect(); + app.exit(0); + } + _ => {} }) - .on_tray_icon_event(|tray, event| { - match event { - TrayIconEvent::Click { - button: MouseButton::Left, - button_state: MouseButtonState::Up, - .. - } => { - let app = tray.app_handle(); - toggle_window_visibility(app); - } - TrayIconEvent::DoubleClick { - button: MouseButton::Left, - .. - } => { - let app = tray.app_handle(); - show_window(app); - } - _ => {} + .on_tray_icon_event(|tray, event| match event { + TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } => { + let app = tray.app_handle(); + toggle_main_window_visibility(app); } + TrayIconEvent::DoubleClick { + button: MouseButton::Left, + .. + } => { + let app = tray.app_handle(); + show_main_window(app); + } + _ => {} }) .build(app)?; @@ -122,14 +107,28 @@ fn setup_tray(app: &AppHandle) -> Result<(), Box> { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - tauri::Builder::default() + let mut builder = tauri::Builder::default() + // Plugins .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_autostart::init( tauri_plugin_autostart::MacosLauncher::LaunchAgent, - Some(vec!["--flag1", "--flag2"]), - )) + Some(vec!["--minimized"]), + )); + + // Add notification plugin on desktop only + #[cfg(desktop)] + { + builder = builder.plugin(tauri_plugin_notification::init()); + } + + builder + // Setup .setup(|app| { + // Initialize socket service with app handle + SOCKET_SERVICE.set_app_handle(app.handle().clone()); + + // Register deep link handlers (Windows/Linux) #[cfg(any(windows, target_os = "linux"))] { app.deep_link().register_all()?; @@ -160,14 +159,51 @@ pub fn run() { Ok(()) }) - .invoke_handler(tauri::generate_handler![greet, exchange_token]) + // Register all commands + .invoke_handler(tauri::generate_handler![ + // Demo + greet, + // Auth commands + exchange_token, + 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, + // Telegram commands + start_telegram_login, + start_telegram_login_with_url, + // Window commands + show_window, + hide_window, + toggle_window, + is_window_visible, + minimize_window, + maximize_window, + close_window, + set_window_title, + ]) .build(tauri::generate_context!()) .expect("error while building tauri application") .run(|app_handle, event| { // Handle macOS Dock icon click (reopen event) #[cfg(target_os = "macos")] if let RunEvent::Reopen { .. } = event { - show_window(app_handle); + show_main_window(app_handle); } + + // Suppress unused variable warning on non-macOS + #[cfg(not(target_os = "macos"))] + let _ = (app_handle, event); }); } diff --git a/src-tauri/src/models/auth.rs b/src-tauri/src/models/auth.rs new file mode 100644 index 000000000..490a3dfaa --- /dev/null +++ b/src-tauri/src/models/auth.rs @@ -0,0 +1,56 @@ +use serde::{Deserialize, Serialize}; + +/// User session information +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Session { + /// JWT session token + pub token: String, + /// User ID + pub user_id: String, + /// When the session was created (Unix timestamp) + pub created_at: u64, + /// When the session expires (Unix timestamp) + pub expires_at: Option, +} + +/// User profile information +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct User { + pub id: String, + #[serde(rename = "firstName")] + pub first_name: Option, + #[serde(rename = "lastName")] + pub last_name: Option, + pub username: Option, + pub email: Option, + #[serde(rename = "telegramId")] + pub telegram_id: Option, +} + +/// Token exchange request payload +#[derive(Debug, Serialize)] +pub struct TokenExchangeRequest { + pub token: String, +} + +/// Token exchange response from backend +#[derive(Debug, Deserialize)] +pub struct TokenExchangeResponse { + #[serde(rename = "sessionToken")] + pub session_token: String, + pub user: User, +} + +/// Auth error response from backend +#[derive(Debug, Deserialize)] +pub struct AuthErrorResponse { + pub success: bool, + pub error: Option, +} + +/// Auth state that can be emitted to frontend +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthState { + pub is_authenticated: bool, + pub user: Option, +} diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs new file mode 100644 index 000000000..321ecbfb7 --- /dev/null +++ b/src-tauri/src/models/mod.rs @@ -0,0 +1,2 @@ +pub mod auth; +pub mod socket; diff --git a/src-tauri/src/models/socket.rs b/src-tauri/src/models/socket.rs new file mode 100644 index 000000000..c3b5416f2 --- /dev/null +++ b/src-tauri/src/models/socket.rs @@ -0,0 +1,74 @@ +use serde::{Deserialize, Serialize}; + +/// Socket connection status +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ConnectionStatus { + Disconnected, + Connecting, + Connected, + Reconnecting, + Error, +} + +impl Default for ConnectionStatus { + fn default() -> Self { + Self::Disconnected + } +} + +/// Socket connection state emitted to frontend +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SocketState { + pub status: ConnectionStatus, + pub socket_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl Default for SocketState { + fn default() -> Self { + Self { + status: ConnectionStatus::Disconnected, + socket_id: None, + error: None, + } + } +} + +/// Generic socket message wrapper +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SocketMessage { + pub event: String, + pub data: serde_json::Value, +} + +/// MCP request structure (JSON-RPC 2.0) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct McpRequest { + pub jsonrpc: String, + pub id: serde_json::Value, + pub method: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub params: Option, +} + +/// MCP response structure (JSON-RPC 2.0) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct McpResponse { + pub jsonrpc: String, + pub id: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// MCP error structure +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct McpError { + pub code: i32, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs new file mode 100644 index 000000000..ef4d1aed2 --- /dev/null +++ b/src-tauri/src/services/mod.rs @@ -0,0 +1,11 @@ +pub mod session_service; +pub mod socket_service; + +#[cfg(desktop)] +pub mod notification_service; + +pub use session_service::SessionService; +pub use socket_service::SocketService; + +#[cfg(desktop)] +pub use notification_service::NotificationService; diff --git a/src-tauri/src/services/notification_service.rs b/src-tauri/src/services/notification_service.rs new file mode 100644 index 000000000..5740291b3 --- /dev/null +++ b/src-tauri/src/services/notification_service.rs @@ -0,0 +1,58 @@ +use tauri::{AppHandle, Manager}; +use tauri_plugin_notification::NotificationExt; + +/// Service for managing native notifications +pub struct NotificationService; + +impl NotificationService { + /// Show a simple notification + pub fn show(app: &AppHandle, title: &str, body: &str) -> Result<(), String> { + app.notification() + .builder() + .title(title) + .body(body) + .show() + .map_err(|e| format!("Failed to show notification: {}", e)) + } + + /// Show a notification with an icon + pub fn show_with_icon( + app: &AppHandle, + title: &str, + body: &str, + icon: &str, + ) -> Result<(), String> { + app.notification() + .builder() + .title(title) + .body(body) + .icon(icon) + .show() + .map_err(|e| format!("Failed to show notification: {}", e)) + } + + /// Show a notification for a new message + pub fn show_message_notification( + app: &AppHandle, + sender: &str, + message: &str, + ) -> Result<(), String> { + Self::show(app, &format!("New message from {}", sender), message) + } + + /// Check if notifications are permitted + pub fn is_permission_granted(app: &AppHandle) -> Result { + app.notification() + .permission_state() + .map(|state| state == tauri_plugin_notification::PermissionState::Granted) + .map_err(|e| format!("Failed to check permission: {}", e)) + } + + /// Request notification permission + pub fn request_permission(app: &AppHandle) -> Result { + app.notification() + .request_permission() + .map(|state| state == tauri_plugin_notification::PermissionState::Granted) + .map_err(|e| format!("Failed to request permission: {}", e)) + } +} diff --git a/src-tauri/src/services/session_service.rs b/src-tauri/src/services/session_service.rs new file mode 100644 index 000000000..61841c988 --- /dev/null +++ b/src-tauri/src/services/session_service.rs @@ -0,0 +1,180 @@ +use crate::models::auth::{Session, User}; +use crate::utils::config::{APP_IDENTIFIER, KEYCHAIN_SERVICE}; +use serde::{Deserialize, Serialize}; +use std::sync::RwLock; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Session data stored in keychain +#[derive(Debug, Clone, Serialize, Deserialize)] +struct StoredSession { + token: String, + user_id: String, + user: Option, + created_at: u64, + expires_at: Option, +} + +/// Service for managing user sessions with secure storage +pub struct SessionService { + /// In-memory cache of the current session + cached_session: RwLock>, +} + +impl SessionService { + /// Create a new SessionService instance + pub fn new() -> Self { + let service = Self { + cached_session: RwLock::new(None), + }; + // Try to load existing session from keychain + if let Ok(session) = service.load_from_keychain() { + if let Ok(mut cache) = service.cached_session.write() { + *cache = Some(session); + } + } + service + } + + /// Store a new session + pub fn store_session(&self, token: &str, user: &User) -> Result<(), String> { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| e.to_string())? + .as_secs(); + + let session = StoredSession { + token: token.to_string(), + user_id: user.id.clone(), + user: Some(user.clone()), + created_at: now, + expires_at: None, // Can be set from token if needed + }; + + // Store in keychain + self.save_to_keychain(&session)?; + + // Update cache + if let Ok(mut cache) = self.cached_session.write() { + *cache = Some(session); + } + + Ok(()) + } + + /// Get the current session token + pub fn get_token(&self) -> Option { + self.cached_session + .read() + .ok() + .and_then(|cache| cache.as_ref().map(|s| s.token.clone())) + } + + /// Get the current session + pub fn get_session(&self) -> Option { + self.cached_session.read().ok().and_then(|cache| { + cache.as_ref().map(|s| Session { + token: s.token.clone(), + user_id: s.user_id.clone(), + created_at: s.created_at, + expires_at: s.expires_at, + }) + }) + } + + /// Get the current user + pub fn get_user(&self) -> Option { + self.cached_session + .read() + .ok() + .and_then(|cache| cache.as_ref().and_then(|s| s.user.clone())) + } + + /// Check if there's an active session + pub fn is_authenticated(&self) -> bool { + self.cached_session + .read() + .ok() + .map(|cache| cache.is_some()) + .unwrap_or(false) + } + + /// Clear the current session (logout) + pub fn clear_session(&self) -> Result<(), String> { + // Clear from keychain + self.delete_from_keychain()?; + + // Clear cache + if let Ok(mut cache) = self.cached_session.write() { + *cache = None; + } + + Ok(()) + } + + /// Save session to OS keychain + #[cfg(not(target_os = "ios"))] + fn save_to_keychain(&self, session: &StoredSession) -> Result<(), String> { + let entry = keyring::Entry::new(KEYCHAIN_SERVICE, APP_IDENTIFIER) + .map_err(|e| format!("Failed to create keyring entry: {}", e))?; + + let json = serde_json::to_string(session) + .map_err(|e| format!("Failed to serialize session: {}", e))?; + + entry + .set_password(&json) + .map_err(|e| format!("Failed to store in keychain: {}", e))?; + + Ok(()) + } + + /// Load session from OS keychain + #[cfg(not(target_os = "ios"))] + fn load_from_keychain(&self) -> Result { + let entry = keyring::Entry::new(KEYCHAIN_SERVICE, APP_IDENTIFIER) + .map_err(|e| format!("Failed to create keyring entry: {}", e))?; + + let json = entry + .get_password() + .map_err(|e| format!("Failed to load from keychain: {}", e))?; + + let session: StoredSession = serde_json::from_str(&json) + .map_err(|e| format!("Failed to deserialize session: {}", e))?; + + Ok(session) + } + + /// Delete session from OS keychain + #[cfg(not(target_os = "ios"))] + fn delete_from_keychain(&self) -> Result<(), String> { + let entry = keyring::Entry::new(KEYCHAIN_SERVICE, APP_IDENTIFIER) + .map_err(|e| format!("Failed to create keyring entry: {}", e))?; + + // Ignore error if entry doesn't exist + let _ = entry.delete_credential(); + + Ok(()) + } + + // iOS fallback implementations (keyring not supported) + #[cfg(target_os = "ios")] + fn save_to_keychain(&self, _session: &StoredSession) -> Result<(), String> { + // iOS uses different secure storage, handled by frontend + Ok(()) + } + + #[cfg(target_os = "ios")] + fn load_from_keychain(&self) -> Result { + Err("Not implemented for iOS".to_string()) + } + + #[cfg(target_os = "ios")] + fn delete_from_keychain(&self) -> Result<(), String> { + Ok(()) + } +} + +impl Default for SessionService { + fn default() -> Self { + Self::new() + } +} diff --git a/src-tauri/src/services/socket_service.rs b/src-tauri/src/services/socket_service.rs new file mode 100644 index 000000000..1954edf36 --- /dev/null +++ b/src-tauri/src/services/socket_service.rs @@ -0,0 +1,182 @@ +//! Socket.io state management service +//! +//! This service manages socket connection state and communicates with the frontend. +//! The actual Socket.io client runs in the frontend (JavaScript), while this Rust +//! service: +//! - Tracks connection state +//! - Emits events to the frontend +//! - Manages background execution (socket stays connected when window is hidden) +//! +//! For background execution, the Tauri app keeps running in the system tray, +//! and the frontend's Socket.io connection persists because the WebView is not +//! destroyed when the window is hidden. + +use crate::models::socket::{ConnectionStatus, SocketState}; +use parking_lot::RwLock; +use std::sync::Arc; +use tauri::{AppHandle, Emitter}; + +/// Events emitted to the frontend +pub mod events { + pub const SOCKET_CONNECTED: &str = "socket:connected"; + pub const SOCKET_DISCONNECTED: &str = "socket:disconnected"; + pub const SOCKET_ERROR: &str = "socket:error"; + pub const SOCKET_MESSAGE: &str = "socket:message"; + pub const SOCKET_STATE_CHANGED: &str = "socket:state_changed"; + pub const SOCKET_SHOULD_CONNECT: &str = "socket:should_connect"; + pub const SOCKET_SHOULD_DISCONNECT: &str = "socket:should_disconnect"; +} + +/// Socket state management service +/// +/// This service tracks socket connection state and provides an interface +/// for the frontend to report connection status and for the backend to +/// request connection/disconnection. +pub struct SocketService { + /// Current connection status (reported by frontend) + status: RwLock, + /// Socket ID once connected (reported by frontend) + socket_id: RwLock>, + /// Auth token for connection + auth_token: RwLock>, + /// Backend URL + backend_url: RwLock, + /// App handle for emitting events + app_handle: RwLock>, +} + +impl SocketService { + /// Create a new SocketService + pub fn new() -> Self { + Self { + status: RwLock::new(ConnectionStatus::Disconnected), + socket_id: RwLock::new(None), + auth_token: RwLock::new(None), + backend_url: RwLock::new(String::new()), + app_handle: RwLock::new(None), + } + } + + /// Set the app handle for emitting events + pub fn set_app_handle(&self, handle: AppHandle) { + *self.app_handle.write() = Some(handle); + } + + /// Get current connection status + pub fn get_status(&self) -> ConnectionStatus { + *self.status.read() + } + + /// Get current socket state + pub fn get_state(&self) -> SocketState { + SocketState { + status: *self.status.read(), + socket_id: self.socket_id.read().clone(), + error: None, + } + } + + /// Check if connected + pub fn is_connected(&self) -> bool { + *self.status.read() == ConnectionStatus::Connected + } + + /// Store connection parameters and tell frontend to connect + pub fn request_connect(&self, backend_url: &str, token: &str) -> Result<(), String> { + *self.backend_url.write() = backend_url.to_string(); + *self.auth_token.write() = Some(token.to_string()); + + // Tell frontend to connect + if let Some(ref app) = *self.app_handle.read() { + app.emit( + events::SOCKET_SHOULD_CONNECT, + serde_json::json!({ + "backendUrl": backend_url, + "token": token + }), + ) + .map_err(|e| format!("Failed to emit connect event: {}", e))?; + } + + Ok(()) + } + + /// Tell frontend to disconnect + pub fn request_disconnect(&self) -> Result<(), String> { + if let Some(ref app) = *self.app_handle.read() { + app.emit(events::SOCKET_SHOULD_DISCONNECT, ()) + .map_err(|e| format!("Failed to emit disconnect event: {}", e))?; + } + + Ok(()) + } + + /// Update connection status (called by frontend via command) + pub fn update_status(&self, status: ConnectionStatus, socket_id: Option) { + *self.status.write() = status; + *self.socket_id.write() = socket_id.clone(); + + // Emit state change to any listeners + if let Some(ref app) = *self.app_handle.read() { + let state = SocketState { + status, + socket_id, + error: None, + }; + let _ = app.emit(events::SOCKET_STATE_CHANGED, &state); + } + } + + /// Report connection (called by frontend) + pub fn report_connected(&self, socket_id: Option) { + self.update_status(ConnectionStatus::Connected, socket_id); + } + + /// Report disconnection (called by frontend) + pub fn report_disconnected(&self) { + self.update_status(ConnectionStatus::Disconnected, None); + } + + /// Report error (called by frontend) + pub fn report_error(&self, error: &str) { + *self.status.write() = ConnectionStatus::Error; + + if let Some(ref app) = *self.app_handle.read() { + let state = SocketState { + status: ConnectionStatus::Error, + socket_id: None, + error: Some(error.to_string()), + }; + let _ = app.emit(events::SOCKET_STATE_CHANGED, &state); + let _ = app.emit(events::SOCKET_ERROR, error); + } + } + + /// Get stored connection parameters for reconnection + pub fn get_connection_params(&self) -> Option<(String, String)> { + let backend_url = self.backend_url.read().clone(); + let token = self.auth_token.read().clone(); + + if !backend_url.is_empty() { + token.map(|t| (backend_url, t)) + } else { + None + } + } + + /// Clear stored credentials + pub fn clear_credentials(&self) { + *self.auth_token.write() = None; + *self.backend_url.write() = String::new(); + } +} + +impl Default for SocketService { + fn default() -> Self { + Self::new() + } +} + +// Global singleton instance +use once_cell::sync::Lazy; +pub static SOCKET_SERVICE: Lazy> = Lazy::new(|| Arc::new(SocketService::new())); diff --git a/src-tauri/src/utils/config.rs b/src-tauri/src/utils/config.rs new file mode 100644 index 000000000..03b5638a9 --- /dev/null +++ b/src-tauri/src/utils/config.rs @@ -0,0 +1,38 @@ +/// Configuration constants and environment utilities +/// +/// This module provides configuration values that can be +/// overridden via environment variables at runtime. + +use std::env; + +/// Default backend URL (can be overridden via BACKEND_URL env var) +pub const DEFAULT_BACKEND_URL: &str = "https://api.alphahuman.io"; + +/// Application identifier for keychain storage +pub const APP_IDENTIFIER: &str = "com.megamind.tauri-app"; + +/// Service name for keychain +pub const KEYCHAIN_SERVICE: &str = "AlphaHuman"; + +/// Deep link scheme +pub const DEEP_LINK_SCHEME: &str = "outsourced"; + +/// Socket.io reconnection settings +pub const SOCKET_RECONNECT_ATTEMPTS: u32 = 5; +pub const SOCKET_RECONNECT_DELAY_MS: u64 = 1000; +pub const SOCKET_PING_INTERVAL_MS: u64 = 25000; + +/// Get the backend URL from environment or use default +pub fn get_backend_url() -> String { + env::var("BACKEND_URL").unwrap_or_else(|_| DEFAULT_BACKEND_URL.to_string()) +} + +/// Get the Telegram widget auth URL +pub fn get_telegram_widget_url() -> String { + format!("{}/auth/telegram-widget?redirect={}://auth", get_backend_url(), DEEP_LINK_SCHEME) +} + +/// Get the token exchange endpoint URL +pub fn get_token_exchange_url() -> String { + format!("{}/auth/desktop-exchange", get_backend_url()) +} diff --git a/src-tauri/src/utils/mod.rs b/src-tauri/src/utils/mod.rs new file mode 100644 index 000000000..ef68c3694 --- /dev/null +++ b/src-tauri/src/utils/mod.rs @@ -0,0 +1 @@ +pub mod config; diff --git a/src/providers/SocketProvider.tsx b/src/providers/SocketProvider.tsx index e3aa2bc31..5884e0c9e 100644 --- a/src/providers/SocketProvider.tsx +++ b/src/providers/SocketProvider.tsx @@ -9,17 +9,44 @@ import { updateTelegramMCPServerSocket, cleanupTelegramMCPServer, } from "../lib/mcp/telegram"; +import { + isTauri, + setupTauriSocketListeners, + cleanupTauriSocketListeners, + reportSocketConnected, + reportSocketDisconnected, + reportSocketError, + updateSocketStatus, +} from "../utils/tauriSocket"; /** * SocketProvider manages the socket connection based on JWT token * - Connects when token is set * - Disconnects when token is unset + * - Integrates with Tauri for background persistence */ const SocketProvider = ({ children }: { children: React.ReactNode }) => { const token = useAppSelector((state) => state.auth.token); const socketStatus = useAppSelector(selectSocketStatus); const previousTokenRef = useRef(null); + const tauriListenersSetup = useRef(false); + // Setup Tauri event listeners once + useEffect(() => { + if (isTauri() && !tauriListenersSetup.current) { + setupTauriSocketListeners(); + tauriListenersSetup.current = true; + } + + return () => { + if (isTauri() && tauriListenersSetup.current) { + cleanupTauriSocketListeners(); + tauriListenersSetup.current = false; + } + }; + }, []); + + // Handle socket connection based on token useEffect(() => { const previousToken = previousTokenRef.current; @@ -27,6 +54,11 @@ const SocketProvider = ({ children }: { children: React.ReactNode }) => { if (token && token !== previousToken) { socketService.connect(token); previousTokenRef.current = token; + + // Report to Rust that we're connecting + if (isTauri()) { + updateSocketStatus("connecting"); + } } // Token was unset - disconnect @@ -34,10 +66,15 @@ const SocketProvider = ({ children }: { children: React.ReactNode }) => { socketService.disconnect(); cleanupTelegramMCPServer(); previousTokenRef.current = null; + + // Report to Rust + if (isTauri()) { + reportSocketDisconnected(); + } } }, [token]); - // Handle MCP initialization when socket connects + // Handle MCP initialization and Tauri status reporting useEffect(() => { if (socketStatus === "connected") { const socket = socketService.getSocket(); @@ -48,18 +85,58 @@ const SocketProvider = ({ children }: { children: React.ReactNode }) => { } else { initTelegramMCPServer(socket); } + + // Report to Rust + if (isTauri()) { + reportSocketConnected(socket?.id); + } } else if (socketStatus === "disconnected") { cleanupTelegramMCPServer(); + + // Report to Rust + if (isTauri()) { + reportSocketDisconnected(); + } + } else if (socketStatus === "connecting") { + // Report connecting status to Rust + if (isTauri()) { + updateSocketStatus("connecting"); + } } }, [socketStatus]); + // Listen for socket errors and report to Rust + useEffect(() => { + const socket = socketService.getSocket(); + if (!socket) return; + + const handleError = (error: Error) => { + if (isTauri()) { + reportSocketError(error.message || "Socket error"); + } + }; + + const handleConnectError = (error: Error) => { + if (isTauri()) { + reportSocketError(error.message || "Connection error"); + updateSocketStatus("error"); + } + }; + + socket.on("error", handleError); + socket.on("connect_error", handleConnectError); + + return () => { + socket.off("error", handleError); + socket.off("connect_error", handleConnectError); + }; + }, [socketStatus]); + // Cleanup on unmount only - // Note: This should only run when the entire app unmounts, not on re-renders useEffect(() => { return () => { // Only disconnect on actual unmount (e.g., app closing) // Don't disconnect on re-renders or route changes - // Check if token still exists - if it does, don't disconnect (might be a re-render) const currentToken = store.getState().auth.token; if (!currentToken) { socketService.disconnect(); diff --git a/src/utils/tauriCommands.ts b/src/utils/tauriCommands.ts new file mode 100644 index 000000000..d00ed6178 --- /dev/null +++ b/src/utils/tauriCommands.ts @@ -0,0 +1,188 @@ +/** + * Tauri Commands + * + * Helper functions for invoking Tauri commands from the frontend. + */ + +import { invoke } from '@tauri-apps/api/core'; + +// Check if we're running in Tauri +export const isTauri = (): boolean => { + return typeof window !== 'undefined' && '__TAURI__' in window; +}; + +/** + * Start Telegram login via the widget in the system browser + * The backend will redirect back to the app via deep link + */ +export async function startTelegramLogin(): Promise { + if (!isTauri()) { + throw new Error('Not running in Tauri'); + } + + await invoke('start_telegram_login'); +} + +/** + * Start Telegram login with a custom backend URL + */ +export async function startTelegramLoginWithUrl(backendUrl: string): Promise { + if (!isTauri()) { + throw new Error('Not running in Tauri'); + } + + await invoke('start_telegram_login_with_url', { backendUrl }); +} + +/** + * Exchange a login token for a session token + */ +export async function exchangeToken( + backendUrl: string, + token: string +): Promise<{ sessionToken: string; user: object }> { + if (!isTauri()) { + throw new Error('Not running in Tauri'); + } + + return await invoke('exchange_token', { backendUrl, token }); +} + +/** + * Get the current authentication state from Rust + */ +export async function getAuthState(): Promise<{ + is_authenticated: boolean; + user: object | null; +}> { + if (!isTauri()) { + return { is_authenticated: false, user: null }; + } + + return await invoke('get_auth_state'); +} + +/** + * Get the session token from secure storage + */ +export async function getSessionToken(): Promise { + if (!isTauri()) { + return null; + } + + return await invoke('get_session_token'); +} + +/** + * Logout and clear session + */ +export async function logout(): Promise { + if (!isTauri()) { + return; + } + + await invoke('logout'); +} + +/** + * Store session in secure storage + */ +export async function storeSession( + token: string, + user: object +): Promise { + if (!isTauri()) { + return; + } + + await invoke('store_session', { token, user }); +} + +/** + * Show the main window + */ +export async function showWindow(): Promise { + if (!isTauri()) { + return; + } + + await invoke('show_window'); +} + +/** + * Hide the main window + */ +export async function hideWindow(): Promise { + if (!isTauri()) { + return; + } + + await invoke('hide_window'); +} + +/** + * Toggle window visibility + */ +export async function toggleWindow(): Promise { + if (!isTauri()) { + return; + } + + await invoke('toggle_window'); +} + +/** + * Check if window is visible + */ +export async function isWindowVisible(): Promise { + if (!isTauri()) { + return true; // In browser, window is always visible + } + + return await invoke('is_window_visible'); +} + +/** + * Minimize the window + */ +export async function minimizeWindow(): Promise { + if (!isTauri()) { + return; + } + + await invoke('minimize_window'); +} + +/** + * Maximize or unmaximize the window + */ +export async function maximizeWindow(): Promise { + if (!isTauri()) { + return; + } + + await invoke('maximize_window'); +} + +/** + * Close the window (minimizes to tray on macOS) + */ +export async function closeWindow(): Promise { + if (!isTauri()) { + return; + } + + await invoke('close_window'); +} + +/** + * Set the window title + */ +export async function setWindowTitle(title: string): Promise { + if (!isTauri()) { + document.title = title; + return; + } + + await invoke('set_window_title', { title }); +} diff --git a/src/utils/tauriSocket.ts b/src/utils/tauriSocket.ts new file mode 100644 index 000000000..c6a1d010d --- /dev/null +++ b/src/utils/tauriSocket.ts @@ -0,0 +1,185 @@ +/** + * Tauri Socket Integration + * + * This module provides integration between the Rust backend and the frontend + * Socket.io service. In Tauri, the socket stays connected even when the + * window is hidden because the WebView is not destroyed. + * + * The Rust backend emits events to coordinate: + * - socket:should_connect - When Rust wants frontend to connect + * - socket:should_disconnect - When Rust wants frontend to disconnect + * + * The frontend reports status back: + * - report_socket_connected - When connection established + * - report_socket_disconnected - When disconnected + * - report_socket_error - When error occurs + */ + +import { listen, UnlistenFn } from '@tauri-apps/api/event'; +import { invoke } from '@tauri-apps/api/core'; +import { socketService } from '../services/socketService'; + +// Check if we're running in Tauri +export const isTauri = (): boolean => { + return typeof window !== 'undefined' && '__TAURI__' in window; +}; + +let unlistenConnect: UnlistenFn | null = null; +let unlistenDisconnect: UnlistenFn | null = null; + +/** + * Setup Tauri socket event listeners + * This should be called once when the app starts + */ +export async function setupTauriSocketListeners(): Promise { + if (!isTauri()) { + return; + } + + try { + // Listen for connect requests from Rust + unlistenConnect = await listen<{ backendUrl: string; token: string }>( + 'socket:should_connect', + async (event) => { + console.log('[TauriSocket] Received connect request'); + const { token } = event.payload; + + try { + socketService.connect(token); + // Note: We'll report connected status when socket actually connects + } catch (error) { + console.error('[TauriSocket] Failed to connect:', error); + await invoke('report_socket_error', { + error: error instanceof Error ? error.message : 'Connection failed' + }); + } + } + ); + + // Listen for disconnect requests from Rust + unlistenDisconnect = await listen( + 'socket:should_disconnect', + async () => { + console.log('[TauriSocket] Received disconnect request'); + socketService.disconnect(); + await invoke('report_socket_disconnected'); + } + ); + + console.log('[TauriSocket] Event listeners setup complete'); + } catch (error) { + console.error('[TauriSocket] Failed to setup listeners:', error); + } +} + +/** + * Cleanup Tauri socket event listeners + */ +export function cleanupTauriSocketListeners(): void { + if (unlistenConnect) { + unlistenConnect(); + unlistenConnect = null; + } + if (unlistenDisconnect) { + unlistenDisconnect(); + unlistenDisconnect = null; + } +} + +/** + * Report socket connected status to Rust + */ +export async function reportSocketConnected(socketId?: string): Promise { + if (!isTauri()) { + return; + } + + try { + await invoke('report_socket_connected', { socketId: socketId ?? null }); + console.log('[TauriSocket] Reported connected'); + } catch (error) { + console.error('[TauriSocket] Failed to report connected:', error); + } +} + +/** + * Report socket disconnected status to Rust + */ +export async function reportSocketDisconnected(): Promise { + if (!isTauri()) { + return; + } + + try { + await invoke('report_socket_disconnected'); + console.log('[TauriSocket] Reported disconnected'); + } catch (error) { + console.error('[TauriSocket] Failed to report disconnected:', error); + } +} + +/** + * Report socket error to Rust + */ +export async function reportSocketError(error: string): Promise { + if (!isTauri()) { + return; + } + + try { + await invoke('report_socket_error', { error }); + console.log('[TauriSocket] Reported error:', error); + } catch (error) { + console.error('[TauriSocket] Failed to report error:', error); + } +} + +/** + * Update socket status in Rust + */ +export async function updateSocketStatus( + status: 'connected' | 'connecting' | 'disconnected' | 'reconnecting' | 'error', + socketId?: string +): Promise { + if (!isTauri()) { + return; + } + + try { + await invoke('update_socket_status', { status, socketId: socketId ?? null }); + } catch (error) { + console.error('[TauriSocket] Failed to update status:', error); + } +} + +/** + * Get session token from Rust secure storage + */ +export async function getSecureToken(): Promise { + if (!isTauri()) { + return null; + } + + try { + return await invoke('get_session_token'); + } catch (error) { + console.error('[TauriSocket] Failed to get secure token:', error); + return null; + } +} + +/** + * Check if user is authenticated (from Rust) + */ +export async function isAuthenticatedFromRust(): Promise { + if (!isTauri()) { + return false; + } + + try { + return await invoke('is_authenticated'); + } catch (error) { + console.error('[TauriSocket] Failed to check auth:', error); + return false; + } +}