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; + } +}