Add comprehensive documentation for application architecture, state management, services layer, MCP system, pages and routing, components, and providers

- Introduced detailed architecture overview outlining system components and entry points.
- Documented state management using Redux Toolkit and Redux-Persist, including store configuration and slice structures.
- Described services layer architecture, detailing API client, socket service, and MTProto service functionalities.
- Explained the Model Context Protocol (MCP) system, including tool categories and implementation examples.
- Outlined routing structure and page components, emphasizing protected and public routes.
- Organized reusable components by feature, detailing their structure and usage.
- Provided insights into provider management for service lifecycle and shared state.

This documentation enhances understanding of the application's structure and improves onboarding for new developers, ensuring clarity in the system's design and functionality.
This commit is contained in:
M3gA-Mind
2026-01-29 21:21:07 +05:30
parent 28480ab531
commit 632ecc05b7
34 changed files with 5874 additions and 78 deletions
+143
View File
@@ -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)*
+268
View File
@@ -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<AuthState>('get_auth_state');
// Returns: { is_authenticated: boolean, user: User | null }
```
### `get_session_token`
Get the current session token.
```typescript
const token = await invoke<string | null>('get_session_token');
```
### `get_current_user`
Get the current authenticated user.
```typescript
const user = await invoke<User | null>('get_current_user');
```
### `is_authenticated`
Check if user is authenticated.
```typescript
const isAuth = await invoke<boolean>('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<SocketState>('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<boolean>('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<boolean>('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)*
+304
View File
@@ -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<String>;
/// Get the current session
pub fn get_session(&self) -> Option<Session>;
/// Get the current user
pub fn get_user(&self) -> Option<User>;
/// 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<String>);
/// Report connection (called by frontend)
pub fn report_connected(&self, socket_id: Option<String>);
/// 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<bool, String>;
/// Request notification permission
pub fn request_permission(app: &AppHandle) -> Result<bool, String>;
}
```
### 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<Arc<SessionService>> =
Lazy::new(|| Arc::new(SessionService::new()));
// In socket_service.rs
pub static SOCKET_SERVICE: Lazy<Arc<SocketService>> =
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)*
+374
View File
@@ -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<Client>,
auth_token: Option<String>,
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<Option<SessionInfo>, 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<Option<String>, 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*
+175
View File
@@ -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=<loginToken>`
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)*
+243
View File
@@ -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<string, boolean> // Per-user flag (persisted)
},
socket: {
byUser: Record<string, { // Per user ID
status: "connecting" | "connected" | "disconnected",
socketId: string | null
}>
},
user: {
profile: User | null,
loading: boolean,
error: string | null
},
telegram: {
byUser: Record<string, TelegramState> // 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<string, boolean>;
}
```
**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<string, {
status: 'connecting' | 'connected' | 'disconnected';
socketId: string | null;
}>;
}
```
**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<string, TelegramChat>,
chatsOrder: string[],
messages: Record<chatId, Record<msgId, TelegramMessage>>,
threads: Record<chatId, TelegramThread[]>
}
```
**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<RootState> = 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)*
+297
View File
@@ -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<User>('/users/me');
// POST request
const result = await apiClient.post<LoginResponse>('/auth/login', {
email,
password
});
// With custom headers
const data = await apiClient.get<Data>('/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 <SocketContext.Provider value={...}>{children}</SocketContext.Provider>;
}
```
### 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 <TelegramContext.Provider value={...}>{children}</TelegramContext.Provider>;
}
```
## 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)*
+437
View File
@@ -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<MCPResponse>;
// Register handler for incoming requests
onRequest(handler: (req: MCPRequest) => Promise<MCPResponse>): 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<void>;
// List available tools
listTools(): MCPTool[];
// Execute a tool
async executeTool(name: string, args: unknown): Promise<ToolResult>;
// Handle incoming JSON-RPC request
async handleRequest(request: MCPRequest): Promise<MCPResponse>;
// 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<ToolResult>;
}
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)*
+403
View File
@@ -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 (
<>
<Routes>
{/* Public routes - redirect if authenticated */}
<Route element={<PublicRoute />}>
<Route path="/" element={<Welcome />} />
<Route path="/login" element={<Login />} />
</Route>
{/* Protected routes - require authentication */}
<Route element={<ProtectedRoute />}>
<Route path="/onboarding/*" element={<Onboarding />} />
</Route>
{/* Protected + onboarded routes */}
<Route element={<ProtectedRoute requireOnboarded />}>
<Route path="/home" element={<Home />} />
</Route>
{/* Fallback redirect */}
<Route path="*" element={<DefaultRedirect />} />
</Routes>
{/* Settings modal overlay - renders on top of routes */}
<SettingsModal />
</>
);
}
```
## 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 <Navigate to={isOnboarded ? '/home' : '/onboarding'} replace />;
}
return <Outlet />;
}
```
### 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 <Navigate to="/login" replace />;
}
if (requireOnboarded && !isOnboarded) {
return <Navigate to="/onboarding" replace />;
}
return <Outlet />;
}
```
### 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 <Navigate to="/" replace />;
}
if (!isOnboarded) {
return <Navigate to="/onboarding" replace />;
}
return <Navigate to="/home" replace />;
}
```
## 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 (
<div className="login-page">
<TelegramLoginButton onClick={handleTelegramLogin} />
</div>
);
}
```
### 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 (
<div className="home-page">
<header>
<h1>Welcome, {user?.firstName}</h1>
<button onClick={() => navigate('/settings')}>Settings</button>
</header>
<TelegramConnectionIndicator status={telegramStatus} />
<ConnectionIndicator />
{/* Main content */}
</div>
);
}
```
## 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 (
<div className="onboarding">
<ProgressIndicator current={currentStep} total={STEPS.length} />
<StepComponent onNext={handleNext} onBack={handleBack} />
</div>
);
}
```
### 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 (
<div className="step">
<h2>Connect Your Accounts</h2>
{connectOptions.map((option) => (
<ConnectionOption
key={option.id}
{...option}
onClick={() => option.id === 'telegram' && setShowModal(true)}
/>
))}
<TelegramConnectionModal
isOpen={showModal}
onClose={() => setShowModal(false)}
/>
<div className="actions">
<button onClick={onBack}>Back</button>
<button onClick={onNext}>Continue</button>
</div>
</div>
);
}
```
## 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 (
<div>
<SettingsMenuItem
label="Connections"
onClick={() => navigateTo('connections')}
/>
<button onClick={closeModal}>Close</button>
</div>
);
}
```
## 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';
<Link to="/settings">Settings</Link>
```
### 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)*
+511
View File
@@ -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
<Route element={<ProtectedRoute />}>
<Route path="/onboarding/*" element={<Onboarding />} />
</Route>
<Route element={<ProtectedRoute requireOnboarded />}>
<Route path="/home" element={<Home />} />
</Route>
```
### PublicRoute
Redirects authenticated users away.
```typescript
// Usage in AppRoutes.tsx
<Route element={<PublicRoute />}>
<Route path="/" element={<Welcome />} />
<Route path="/login" element={<Login />} />
</Route>
```
### 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
<TelegramLoginButton
onClick={() => 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;
}
<ConnectionIndicator status="connected" label="Socket" />
```
### TelegramConnectionIndicator
Telegram-specific status display.
```typescript
interface TelegramConnectionIndicatorProps {
status: 'connected' | 'connecting' | 'disconnected' | 'error';
}
// Usage with Redux state
const telegramStatus = useAppSelector((state) =>
selectTelegramConnectionStatus(state, userId)
);
<TelegramConnectionIndicator status={telegramStatus} />
```
### TelegramConnectionModal
Modal for setting up Telegram connection.
```typescript
interface TelegramConnectionModalProps {
isOpen: boolean;
onClose: () => void;
}
// Usage in onboarding/settings
const [showModal, setShowModal] = useState(false);
<TelegramConnectionModal
isOpen={showModal}
onClose={() => setShowModal(false)}
/>
```
**Features:**
- QR code login flow
- Phone number login flow
- Connection status display
- Error handling
### GmailConnectionIndicator
Gmail status badge (future integration).
```typescript
<GmailConnectionIndicator status="coming-soon" />
```
## Onboarding Components
### ProgressIndicator
Visual progress through onboarding steps.
```typescript
interface ProgressIndicatorProps {
current: number;
total: number;
}
<ProgressIndicator current={2} total={5} />
```
### LottieAnimation
Lottie animation player for onboarding.
```typescript
interface LottieAnimationProps {
animationData: object;
loop?: boolean;
autoplay?: boolean;
className?: string;
}
import welcomeAnimation from '../assets/animations/welcome.json';
<LottieAnimation
animationData={welcomeAnimation}
loop={true}
autoplay={true}
/>
```
## 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 (
<SettingsLayout>
{/* Route to appropriate panel */}
{location.pathname === '/settings' && <SettingsHome />}
{location.pathname === '/settings/connections' && <ConnectionsPanel />}
{/* ... more panels */}
</SettingsLayout>
);
}
```
### SettingsLayout
Portal-based modal wrapper.
```typescript
export function SettingsLayout({ children }) {
const { closeModal } = useSettingsNavigation();
return createPortal(
<div className="fixed inset-0 z-50">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={closeModal}
/>
{/* Modal */}
<div className="absolute inset-4 flex items-center justify-center">
<div className="bg-white rounded-2xl w-full max-w-[520px] shadow-xl">
{children}
</div>
</div>
</div>,
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 (
<div>
<SettingsHeader user={user} onClose={closeModal} />
{menuItems.map((item) => (
<SettingsMenuItem
key={item.id}
{...item}
onClick={() => navigateTo(item.id)}
/>
))}
</div>
);
}
```
### 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 (
<SettingsPanelLayout title="Connections" onBack={navigateBack}>
{connections.map((conn) => (
<ConnectionItem
key={conn.id}
{...conn}
onConnect={() => conn.id === 'telegram' && setTelegramModalOpen(true)}
/>
))}
<TelegramConnectionModal
isOpen={telegramModalOpen}
onClose={() => setTelegramModalOpen(false)}
/>
</SettingsPanelLayout>
);
}
```
### 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();
<div className={`modal ${animationClass}`}>
{/* Content */}
</div>
```
### Settings Components
#### SettingsHeader
User profile section at top of settings.
```typescript
interface SettingsHeaderProps {
user: User | null;
onClose: () => void;
}
<SettingsHeader user={user} onClose={handleClose} />
```
#### SettingsMenuItem
Individual menu item with icon and chevron.
```typescript
interface SettingsMenuItemProps {
label: string;
icon: React.ComponentType;
onClick: () => void;
badge?: string;
disabled?: boolean;
}
<SettingsMenuItem
label="Connections"
icon={LinkIcon}
onClick={() => navigateTo('connections')}
badge="2"
/>
```
#### SettingsBackButton
Back navigation button.
```typescript
interface SettingsBackButtonProps {
onClick: () => void;
}
<SettingsBackButton onClick={navigateBack} />
```
#### SettingsPanelLayout
Wrapper for settings panels.
```typescript
interface SettingsPanelLayoutProps {
title: string;
onBack: () => void;
children: React.ReactNode;
}
<SettingsPanelLayout title="Connections" onBack={navigateBack}>
{/* Panel content */}
</SettingsPanelLayout>
```
## 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(
<div className="modal-container">
{/* Modal content */}
</div>,
document.body
);
```
### Controlled vs Uncontrolled
Connection modals are controlled components:
```typescript
// Parent controls open state
const [isOpen, setIsOpen] = useState(false);
<TelegramConnectionModal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
/>
```
---
*Previous: [Pages & Routing](./05-pages-routing.md) | Next: [Providers](./07-providers.md)*
+404
View File
@@ -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
<Provider store={store}>
<PersistGate loading={null} persistor={persistor}>
<UserProvider>
<SocketProvider>
<TelegramProvider>
<HashRouter>
<AppRoutes />
</HashRouter>
</TelegramProvider>
</SocketProvider>
</UserProvider>
</PersistGate>
</Provider>
```
**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 (
<SocketContext.Provider value={contextValue}>
{children}
</SocketContext.Provider>
);
}
```
### 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 (
<div>
<span>Status: {isConnected ? 'Connected' : 'Disconnected'}</span>
<button onClick={sendMessage}>Send</button>
</div>
);
}
```
## 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<void>;
disconnect: () => Promise<void>;
}
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 (
<TelegramContext.Provider value={contextValue}>
{children}
</TelegramContext.Provider>
);
}
```
### 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 <div>Connecting to Telegram...</div>;
}
return (
<ul>
{chats.map((chat) => (
<li key={chat.id}>{chat.title}</li>
))}
</ul>
);
}
```
## 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 (
<UserContext.Provider value={{ user, loading }}>
{children}
</UserContext.Provider>
);
}
```
### Usage
```typescript
import { useUserContext } from '../providers/UserProvider';
function Header() {
const { user, loading } = useUserContext();
if (loading) return <Skeleton />;
if (!user) return null;
return <span>Welcome, {user.firstName}</span>;
}
```
## 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 (
<Provider store={testStore}>
<SocketContext.Provider value={mockSocketContext}>
{children}
</SocketContext.Provider>
</Provider>
);
}
```
### Testing Provider Effects
```typescript
test('SocketProvider connects when token is available', () => {
const store = createTestStore({ auth: { token: 'test-token' } });
render(
<Provider store={store}>
<SocketProvider>
<TestComponent />
</SocketProvider>
</Provider>
);
expect(socketService.connect).toHaveBeenCalledWith('test-token');
});
```
---
*Previous: [Components](./06-components.md) | Next: [Hooks & Utils](./08-hooks-utils.md)*
+505
View File
@@ -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 (
<input
disabled={!isConnected}
onKeyDown={(e) => 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 <NotificationList items={notifications} />;
}
```
### useUser (`hooks/useUser.ts`)
Access user profile data and loading state.
```typescript
interface UseUserReturn {
user: User | null;
loading: boolean;
error: string | null;
refetch: () => Promise<void>;
}
function useUser(): UseUserReturn;
```
**Usage:**
```typescript
import { useUser } from '../hooks/useUser';
function ProfileHeader() {
const { user, loading, error, refetch } = useUser();
if (loading) return <Skeleton />;
if (error) return <Error message={error} onRetry={refetch} />;
if (!user) return null;
return (
<div className="profile">
<Avatar src={user.avatar} />
<span>{user.firstName} {user.lastName}</span>
</div>
);
}
```
### 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 (
<nav>
<button onClick={() => navigateTo('connections')}>
Connections
</button>
<button onClick={() => navigateTo('privacy')}>
Privacy
</button>
<button onClick={closeModal}>Close</button>
</nav>
);
}
```
#### 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 (
<div className={`modal ${animationClass}`}>
{/* Content */}
</div>
);
}
```
## 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<void>;
```
**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<void>;
```
**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<void> {
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<T> {
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<StepProps>;
}
// 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 (
<div>
<select
value={country.code}
onChange={(e) => setCountry(
countries.find((c) => c.code === e.target.value)
)}
>
{countries.map((c) => (
<option key={c.code} value={c.code}>
{c.flag} {c.name} ({c.dialCode})
</option>
))}
</select>
<input placeholder="Phone number" />
</div>
);
}
```
## 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<User>('/users/me');
// user is typed as User
```
---
*Previous: [Providers](./07-providers.md) | [Back to Index](./README.md)*
+78
View File
@@ -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*
+248 -1
View File
@@ -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"
+29 -4
View File
@@ -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"]
+5 -1
View File
@@ -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"
]
}
+99
View File
@@ -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<Arc<SessionService>> =
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<serde_json::Value, String> {
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::<TokenExchangeResponse>(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<String> {
SESSION_SERVICE.get_token()
}
/// Get the current user
#[tauri::command]
pub fn get_current_user() -> Option<User> {
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)
}
+10
View File
@@ -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::*;
+66
View File
@@ -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<String>) {
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<String>) {
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);
}
+35
View File
@@ -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(())
}
+80
View File
@@ -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);
}
}
+105 -69
View File
@@ -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<serde_json::Value, String> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
.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<dyn std::error::Error>> {
#[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);
});
}
+56
View File
@@ -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<u64>,
}
/// User profile information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
pub id: String,
#[serde(rename = "firstName")]
pub first_name: Option<String>,
#[serde(rename = "lastName")]
pub last_name: Option<String>,
pub username: Option<String>,
pub email: Option<String>,
#[serde(rename = "telegramId")]
pub telegram_id: Option<String>,
}
/// 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<String>,
}
/// Auth state that can be emitted to frontend
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthState {
pub is_authenticated: bool,
pub user: Option<User>,
}
+2
View File
@@ -0,0 +1,2 @@
pub mod auth;
pub mod socket;
+74
View File
@@ -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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
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<serde_json::Value>,
}
/// 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_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<McpError>,
}
/// 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<serde_json::Value>,
}
+11
View File
@@ -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;
@@ -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<bool, String> {
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<bool, String> {
app.notification()
.request_permission()
.map(|state| state == tauri_plugin_notification::PermissionState::Granted)
.map_err(|e| format!("Failed to request permission: {}", e))
}
}
+180
View File
@@ -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<User>,
created_at: u64,
expires_at: Option<u64>,
}
/// Service for managing user sessions with secure storage
pub struct SessionService {
/// In-memory cache of the current session
cached_session: RwLock<Option<StoredSession>>,
}
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<String> {
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<Session> {
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<User> {
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<StoredSession, String> {
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<StoredSession, String> {
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()
}
}
+182
View File
@@ -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<ConnectionStatus>,
/// Socket ID once connected (reported by frontend)
socket_id: RwLock<Option<String>>,
/// Auth token for connection
auth_token: RwLock<Option<String>>,
/// Backend URL
backend_url: RwLock<String>,
/// App handle for emitting events
app_handle: RwLock<Option<AppHandle>>,
}
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<String>) {
*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<String>) {
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<Arc<SocketService>> = Lazy::new(|| Arc::new(SocketService::new()));
+38
View File
@@ -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())
}
+1
View File
@@ -0,0 +1 @@
pub mod config;
+80 -3
View File
@@ -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<string | null>(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();
+188
View File
@@ -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<void> {
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<void> {
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<string | null> {
if (!isTauri()) {
return null;
}
return await invoke('get_session_token');
}
/**
* Logout and clear session
*/
export async function logout(): Promise<void> {
if (!isTauri()) {
return;
}
await invoke('logout');
}
/**
* Store session in secure storage
*/
export async function storeSession(
token: string,
user: object
): Promise<void> {
if (!isTauri()) {
return;
}
await invoke('store_session', { token, user });
}
/**
* Show the main window
*/
export async function showWindow(): Promise<void> {
if (!isTauri()) {
return;
}
await invoke('show_window');
}
/**
* Hide the main window
*/
export async function hideWindow(): Promise<void> {
if (!isTauri()) {
return;
}
await invoke('hide_window');
}
/**
* Toggle window visibility
*/
export async function toggleWindow(): Promise<void> {
if (!isTauri()) {
return;
}
await invoke('toggle_window');
}
/**
* Check if window is visible
*/
export async function isWindowVisible(): Promise<boolean> {
if (!isTauri()) {
return true; // In browser, window is always visible
}
return await invoke('is_window_visible');
}
/**
* Minimize the window
*/
export async function minimizeWindow(): Promise<void> {
if (!isTauri()) {
return;
}
await invoke('minimize_window');
}
/**
* Maximize or unmaximize the window
*/
export async function maximizeWindow(): Promise<void> {
if (!isTauri()) {
return;
}
await invoke('maximize_window');
}
/**
* Close the window (minimizes to tray on macOS)
*/
export async function closeWindow(): Promise<void> {
if (!isTauri()) {
return;
}
await invoke('close_window');
}
/**
* Set the window title
*/
export async function setWindowTitle(title: string): Promise<void> {
if (!isTauri()) {
document.title = title;
return;
}
await invoke('set_window_title', { title });
}
+185
View File
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<string | null> {
if (!isTauri()) {
return null;
}
try {
return await invoke<string | null>('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<boolean> {
if (!isTauri()) {
return false;
}
try {
return await invoke<boolean>('is_authenticated');
} catch (error) {
console.error('[TauriSocket] Failed to check auth:', error);
return false;
}
}