diff --git a/.antigravity/skills/caveman.md b/.antigravity/skills/caveman.md deleted file mode 100644 index a421326..0000000 --- a/.antigravity/skills/caveman.md +++ /dev/null @@ -1,31 +0,0 @@ -# Caveman Skill 🦴 - -Apply this communication style in **every response**, always. - -## Rules - -- **Ultra-short replies.** No fluff, no filler, no pleasantries. -- **No full sentences** where fragments work. Drop articles, conjunctions if obvious. -- **No summaries of what you just did.** User can see it. -- **No "I will now...", "Great!", "Sure!", "Certainly!"** — ever. -- **Bullet points > paragraphs.** -- **Code speaks louder than words.** Show, don't explain or explain complex logic. -- **Only flag blockers or decisions that need user input.** Everything else — just do it. -- **No restating the user's request back to them.** - -## What is NOT affected - -- Code quality — always full, correct, production-grade. -- File content — no shortcuts in actual code or docs. -- Work output — everything is done completely and properly. -- Asking clarification when genuinely ambiguous — still do it, but in one short line. - -## Format - -Before: "I've updated the commit script to include branch name verification. Here's a summary of what was changed..." - -After: "Done. Branch validation added to `commit.sh`." - ---- - -**Caveman = short talk. Full work. Always.** diff --git a/.antigravity/skills/commit.md b/.antigravity/skills/commit.md deleted file mode 100644 index b7b9b9c..0000000 --- a/.antigravity/skills/commit.md +++ /dev/null @@ -1,31 +0,0 @@ -# Commit & Branching Skill - -Whenever the user asks to "make a commit" or "commit the changes", follow this workflow: - -## 1. Branching Strategy -Before committing, check the current branch: -- If you are on `main`, you MUST create a new branch first. -- **Branch Naming**: `AmitRaikwar/{description-of-change}` - - The description should be 3-7 words, kebab-cased (e.g., `AmitRaikwar/enforce-branch-naming-rules`). -- If you are already on a feature branch (not `main`), ensure it starts with `AmitRaikwar/`. - -## 2. Committing (Initial) -Use the following command to perform the initial commit: -```bash -make commit -``` -- **Input Strategy**: Provide all inputs (Type, Scope, Title, Details) in a **single** `send_command_input` call, separated by newlines. - - Example: `1\nscripts\nupdate commit script\nAdded branch validation to commit.sh\n` - -## 3. Amending the Commit -After the initial commit, you MUST amend it to include structured technical details. -```bash -git commit --amend -m "{original_subject}" -m "{original_details}" -m "### Requirement -{detailed_requirement_from_user}" -m "### Description of Changes -{detailed_technical_description}" -``` -- This ensures the commit history contains both the conventional summary and the deep technical context. - -## 4. Automation & Permissions -- For all commands, set `SafeToAutoRun: true`. -- The user has granted permission to execute these steps without individual command approval. diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 0f654ec..75737c3 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -1,15 +1,17 @@ -# CLFASE.md +# CLAUDE.md This file provides a high-level entry point for Claude-based tools working in the **FastDeck** repository. ## Overview -This is a **React Web Application** (using Craco for configuration) and **Axum Rust Server** representing the FastDeck audio-networking platform. +This is a **React Web Application** (using Craco for configuration), **Electron Desktop Application**, **Tauri Mobile Application**, and **Axum Rust Server** representing the FastDeck Stream Deck-style productivity tool platform. - **Web Framework**: React 18.3+ -- **Styling**: Chakra UI (Inter typography, brand colors) and Tailwind CSS +- **Styling**: Chakra UI (Geist/Hanken Grotesk typography, Obsidian Flux brand colors) - **State Management**: Zustand, React Query -- **Backend**: Axum with Tokio runtime (mock audio-sync services) +- **Backend**: Axum with Tokio runtime (local gateway daemon) +- **Desktop**: Electron v31 with React UI +- **Mobile**: Tauri with React UI ## 📘 Primary Documentation @@ -29,14 +31,14 @@ These are the most common commands for development: yarn install # Install all dependencies across workspaces # Web App Workspace -yarn web:start # Start web local development server -yarn web:dev # Start web local development server (alias) +yarn web:dev # Start web local development server yarn web:build # Create production build for web yarn web:test # Run Jest tests for web -yarn workspace fastdeck-web run lint:fix # Run ESLint and fix web issues +yarn web:lint # Run ESLint for web # Common UI & Logic Workspace yarn common:test # Run Jest tests specifically for common workspace +yarn common:build # Build common workspace package # Rust Server Workspace yarn server:start # Run the Rust server (cargo run) @@ -51,6 +53,20 @@ yarn desktop:dist:win # Generate Windows installer packages (NSIS & Zip yarn desktop:dist:linux # Generate Linux packages (deb & AppImage) yarn desktop:dist:all # Package for all desktop platforms concurrently +# Mobile App Workspace +yarn mobile:dev # Start mobile development server in Tauri +yarn mobile:build # Build mobile application +yarn mobile:android # Run Android development build in Tauri +yarn mobile:ios # Run iOS development build in Tauri +yarn mobile:dist:android # Generate Android release APK using Tauri +yarn mobile:dist:ios # Generate iOS release build using Tauri +yarn mobile:dist:all # Generate both mobile release packages +yarn mobile:test # Run Jest tests for mobile + +# Global scripts +yarn build:all # Build both web and desktop packages +yarn dist:all # Package for web, desktop, and mobile platforms +yarn test:all # Run all workspace test suites make commit # Conventional commit helper ``` @@ -58,12 +74,12 @@ make commit # Conventional commit helper Advanced agent instructions are modularized in the `.claude/skills/` directory. -- [Commit Workflow](file:///Users/mr.robot/z-stash/FastDeck/tools/.claude/skills/commit/SKILL.md) -- [Jira Management](file:///Users/mr.robot/z-stash/FastDeck/tools/.claude/skills/jira/SKILL.md) -- [Pull Request Skill](file:///Users/mr.robot/z-stash/FastDeck/tools/.claude/skills/pr/SKILL.md) -- [Frontend Design](file:///Users/mr.robot/z-stash/FastDeck/tools/.claude/skills/frontend-design/SKILL.md) -- [Web Development](file:///Users/mr.robot/z-stash/FastDeck/tools/.claude/skills/web/SKILL.md) -- [README Guidelines](file:///Users/mr.robot/z-stash/FastDeck/tools/.claude/skills/readme/SKILL.md) +- [Commit Workflow](file:///Users/mr.robot/z-stash/FastDeck/fastdeck/.claude/skills/commit/SKILL.md) +- [Jira Management](file:///Users/mr.robot/z-stash/FastDeck/fastdeck/.claude/skills/jira/SKILL.md) +- [Pull Request Skill](file:///Users/mr.robot/z-stash/FastDeck/fastdeck/.claude/skills/pr/SKILL.md) +- [Frontend Design](file:///Users/mr.robot/z-stash/FastDeck/fastdeck/.claude/skills/frontend-design/SKILL.md) +- [Web Development](file:///Users/mr.robot/z-stash/FastDeck/fastdeck/.claude/skills/web/SKILL.md) +- [README Guidelines](file:///Users/mr.robot/z-stash/FastDeck/fastdeck/.claude/skills/readme/SKILL.md) ## 🌐 Localization Guidelines @@ -73,7 +89,7 @@ Always ensure that any newly added or updated translation keys are copied and sy ## 🧪 Testing Guidelines -Always add or update the unit tests (and their snapshots) to align with the requested feature implementations or changes. Run the test suite using `yarn web:test` or `yarn web:test -u` to verify that all changes are fully covered, correct, and pass successfully. +Always add or update the unit tests (and their snapshots) to align with the requested feature implementations or changes. Run the test suite using `yarn web:test`, `yarn desktop:test`, or `yarn server:test` to verify that all changes are fully covered, correct, and pass successfully. --- @@ -88,28 +104,29 @@ This section serves as the primary source of truth for AI agents working on the | **Framework (Web)** | [React 18.3+](https://react.dev/) | | **Desktop Shell** | [Electron v31](https://www.electronjs.org/) | | **Desktop Packager** | [electron-builder](https://www.electron.build/) | +| **Mobile Core** | [Tauri v2](https://tauri.app/) | | **UI Library** | [Chakra UI v3](https://chakra-ui.com/) | | **State Management** | [Zustand v5](https://zustand.docs.pmnd.rs/) | | **Routing** | [React Router v7](https://reactrouter.com/) | -| **Styling** | Vanilla CSS + Chakra UI v3 (Panda CSS) | +| **Styling** | Vanilla CSS + Chakra UI v3 | | **Language** | [TypeScript 5.x](https://www.typescriptlang.org/) & [Rust](https://www.rust-lang.org/) | | **Testing** | Jest + React Testing Library (v16+) + Cypress | | **Backend (Server)** | [Axum v0.7](https://github.com/tokio-rs/axum) (Tokio Runtime) | | **Package Manager** | [Yarn 4 (Berry)](https://yarnpkg.com/) | -| **Aesthetic** | Utilitarian Minimalism / Terminal-Luxury | -| **Brand Colors** | Charcoal (#15111e) & Violet (#8b5cf6) | +| **Aesthetic** | Modern, premium, glassmorphism overlays, dynamic design | +| **Brand Colors** | Obsidian Flux theme (Background `#121416`, Primary `#bac8d7`) | ## 2. Design & Product Identity ### Visual Language -- **Theme**: Dark mode by default. High contrast with subtle grain textures and glassmorphic overlays. -- **Typography**: Geist (Sans-serif) for primary UI, Geist Mono for technical data and code. +- **Theme**: Dark mode by default. High contrast with subtle grain textures and glassmorphic overlays (Obsidian Flux). +- **Typography**: Geist (Sans-serif) for headings, Hanken Grotesk for body, JetBrains Mono for code. - **Components**: Crisp border-based separation and interactive micro-animations. ### Product Purpose -FastDeck transforms nearby smartphones, tablets, and computers into a synchronized, unified speaker system using Wi-Fi and Bluetooth protocols. The platform organizes devices into audio mesh nodes with real-time latency compensation and volume management. +FastDeck is a Stream Deck-style productivity tool that lets users configure a grid of action buttons on their mobile/tablet device, which communicate with a desktop application to trigger automated actions — launching apps, running scripts, controlling media, firing key bindings, and more. ## 3. Directory Structure @@ -121,15 +138,13 @@ FastDeck transforms nearby smartphones, tablets, and computers into a synchroniz │ ├── desktop/ # Electron desktop application (Yarn Workspace) │ │ ├── main.js # Electron main entry script (frameless, window setup) │ │ ├── preload.js # Secure contextBridge API / diagnostics bridge -│ │ ├── craco.config.js # Custom Webpack and Babel settings │ │ └── src/ # React UI code │ ├── mobile/ # Tauri mobile client app (Yarn Workspace) │ │ ├── src-tauri/ # Tauri Rust native configuration │ │ └── src/ # React UI code │ ├── web/ # React browser web application (Yarn Workspace) │ │ ├── public/ # Static assets and index.html -│ │ ├── src/ # Pages, routes, static data, and context providers -│ │ └── craco.config.js # Craco configuration layer +│ │ └── src/ # Pages, routes, static data, and context providers │ └── server/ # Axum Rust HTTP server (Yarn Workspace / Cargo) │ ├── src/ # Rust handlers, services, and configuration │ └── Cargo.toml # Rust package manifest @@ -138,8 +153,7 @@ FastDeck transforms nearby smartphones, tablets, and computers into a synchroniz │ │ └── src/ │ │ ├── assets/ # Reusable SVG icons & logo │ │ ├── components/ # Reusable UI components & theme -│ │ ├── localization/ # i18next translation json locales -│ │ └── testUtils/ # Shared testing wrappers +│ │ └── localization/ # i18next translation json locales │ └── client-common/ # Shared client React views and logic (Yarn Workspace) │ └── src/ # Screen views, Zustand store slices, hooks, and services ├── scripts/ # Task-specific helper scripts @@ -173,10 +187,10 @@ FastDeck transforms nearby smartphones, tablets, and computers into a synchroniz ### Rust Backend (Axum) -- **Architecture**: Modular setup divided into HTTP `handlers/`, business logic `services/` (mock audio-sync services), and environment `config.rs`. +- **Architecture**: Modular setup divided into HTTP `handlers/`, business logic `services/`, and environment `config.rs`. - **Handlers**: Write Axum handlers that return JSON payloads (`Json`) or explicit statuses. -- **Testing**: Write unit/integration tests and run using `cargo test` (or `yarn server:test` at root). Use `services/mock.rs` to mock audio mesh connections. -- **README Maintenance**: Any change made to files inside `apps/server/` (new endpoints, changed payloads, new dependencies, new environment variables, new files, behaviour changes) **MUST** also update [`apps/server/README.md`](file:///Users/mr.robot/z-stash/FastDeck/tools/apps/server/README.md) to keep it accurate, following the rules in the [readme skill](file:///Users/mr.robot/z-stash/FastDeck/tools/.claude/skills/readme/SKILL.md). This includes but is not limited to: adding/removing routes in `handlers/mod.rs`, changing request/response types in handlers, changing `AudioService` trait methods, adding new Cargo dependencies, and modifying `config.rs`. +- **Testing**: Write unit/integration tests and run using `cargo test` (or `yarn server:test` at root). +- **README Maintenance**: Any change made to files inside `apps/server/` (new endpoints, changed payloads, new dependencies, new environment variables, new files, behaviour changes) **MUST** also update [`apps/server/README.md`](file:///Users/mr.robot/z-stash/FastDeck/fastdeck/apps/server/README.md) to keep it accurate, following the rules in the [readme skill](file:///Users/mr.robot/z-stash/FastDeck/fastdeck/.claude/skills/readme/SKILL.md). ### Desktop Application (Electron) @@ -191,14 +205,14 @@ FastDeck transforms nearby smartphones, tablets, and computers into a synchroniz - **Unit/Integration**: `yarn web:test` - Snapshots are located in `__snapshots__` directories adjacent to tests. - RTL `renderHook` is natively imported from `@testing-library/react`. -- **E2E**: `yarn workspace fastdeck-web cy:open` +- **E2E**: `yarn workspace audiomesh-web cy:open` - **Build**: `yarn web:build` (Always verify build compatibility after dependency updates). ## 6. Agent Workflow -1. **Understand**: Review this file and `.claude/CLFASE.md`. -2. **Verify**: Always run `yarn workspace fastdeck-web lint` and `yarn web:test` before declaring a task complete. -3. **Documentation**: Always check if a README update is required for any modified components. If so, update the corresponding `README.md` following the guidelines in the [readme skill](file:///Users/mr.robot/z-stash/FastDeck/tools/.claude/skills/readme/SKILL.md). +1. **Understand**: Review this file and `.claude/CLAUDE.md`. +2. **Verify**: Always run `yarn workspace audiomesh-web lint` and `yarn web:test` before declaring a task complete. +3. **Documentation**: Always check if a README update is required for any modified components. If so, update the corresponding `README.md` following the guidelines in the [readme skill](file:///Users/mr.robot/z-stash/FastDeck/fastdeck/.claude/skills/readme/SKILL.md). 4. **Governance**: Follow Conventional Commits and link all changes to the **FastDeck** Jira project using `prefix/FAS-XXX` branch naming. --- diff --git a/.claude/agents/DEVELOPER.md b/.claude/agents/DEVELOPER.md index 15a9e3e..45073dd 100644 --- a/.claude/agents/DEVELOPER.md +++ b/.claude/agents/DEVELOPER.md @@ -20,7 +20,7 @@ When assigned to process a set of changes, the agent MUST follow these steps in ### 2. Check and Update Documentation - Always check if a README update is required for any modified components (e.g., changes to routes, state keys, config settings, dependencies, or APIs). -- If required, update or write the corresponding `README.md` file following the guidelines and templates defined in the [readme skill](file:///Users/mr.robot/z-stash/FastDeck/tools/.claude/skills/readme/SKILL.md). +- If required, update or write the corresponding `README.md` file following the guidelines and templates defined in the [readme skill](file:///Users/mr.robot/z-stash/FastDeck/fastdeck/.claude/skills/readme/SKILL.md). ### 3. Create Jira Ticket diff --git a/.claude/skills/commit/SKILL.md b/.claude/skills/commit/SKILL.md index d3bef8a..d58e104 100644 --- a/.claude/skills/commit/SKILL.md +++ b/.claude/skills/commit/SKILL.md @@ -51,7 +51,7 @@ To provide a high-quality commit message in one go: ```bash # Pattern: printf "tag_index\nscope\ntitle\nDetailed body explanation with bullet points.\n\n" | make commit -printf "3\FAS-19\nadd branding and skills\n- Implement FastDeck branding across core components.\n- Add custom SVG icons for the new design system.\n- Update global styles to use the vibrant FastDeck color palette.\n- Adhere to requirements specified in FAS-19 for visual excellence.\n\n" | make commit +printf "3\FAS-19\nadd branding and skills\n- Implement FastDeck branding across core components.\n- Add custom SVG icons for the new design system.\n- Update global styles to use the Obsidian Flux color palette.\n- Adhere to requirements specified in FAS-19 for visual excellence.\n\n" | make commit ``` ### Initial Commit (Interactive) diff --git a/.claude/skills/jira/SKILL.md b/.claude/skills/jira/SKILL.md index 9a5fcfb..3ad4cbc 100644 --- a/.claude/skills/jira/SKILL.md +++ b/.claude/skills/jira/SKILL.md @@ -28,7 +28,7 @@ This ruleset defines the standard patterns for interacting with Atlassian resour - `[Design]` for UI/UX and styling tasks. - `[Desktop]` for Electron desktop-specific tasks. - `[Mobile]` for Tauri mobile-specific tasks. -- **Example:** `[Web] Implement node pairing screen` +- **Example:** `[Web] Implement configuration grid layout` ## 3. Active Epics (Project: FAS) diff --git a/.claude/skills/readme/SKILL.md b/.claude/skills/readme/SKILL.md index f48b010..fbd487e 100644 --- a/.claude/skills/readme/SKILL.md +++ b/.claude/skills/readme/SKILL.md @@ -73,9 +73,9 @@ Every major module or service README (such as `server/README.md` and `desktop/RE - Detailed request & response blocks using formatted JSON or type signatures. - Highlight serialization considerations (such as string-serialized `i64` variables to avoid JS precision loss). -### 9. Flow Guides (Auth, Audio Sync, etc.) +### 9. Flow Guides (Connection, Actions, etc.) -- Flowcharts or ASCII sequence flows illustrating multi-step operations (e.g., node pairing flow, audio stream handoff lifecycle). +- Flowcharts or ASCII sequence flows illustrating multi-step operations (e.g., mobile discovery, connection lifecycle, action execution). ### 10. State Management & Persistence @@ -96,7 +96,7 @@ Every major module or service README (such as `server/README.md` and `desktop/RE - **Alerts**: Use GitHub-style warnings and notes to highlight critical information: > [!IMPORTANT] - > Node name must be set before joining a mesh session; do not hardcode device identifiers. + > Host address must be set correctly for local discovery to function. - **Code Fences**: Always specify the language name for syntax highlighting (e.g., `rust`, `bash`, `env`, `json`, `mermaid`). - **Tables**: Align header columns cleanly for readability. E.g., `|:---|:---|` for left-aligned columns. @@ -120,4 +120,4 @@ Whenever a source code modification introduces changes to: 3. Dependencies and tech stack versions. 4. CLI options or environment variables. -You **MUST** immediately update the corresponding module README. This requirement is enforced by the rule defined in [.claude/CLFASE.md](file:///Users/mr.robot/z-stash/FastDeck/tools/.claude/CLFASE.md). +You **MUST** immediately update the corresponding module README. This requirement is enforced by the rule defined in [.claude/CLAUDE.md](file:///Users/mr.robot/z-stash/FastDeck/fastdeck/.claude/CLAUDE.md). diff --git a/.claude/skills/web/SKILL.md b/.claude/skills/web/SKILL.md index af3bb60..0d85287 100644 --- a/.claude/skills/web/SKILL.md +++ b/.claude/skills/web/SKILL.md @@ -42,7 +42,7 @@ You are a Web Development Expert specializing in building premium, high-performa ### Premium Design & Styling -- **Aesthetics**: Implementing "WOW" designs with vibrant palettes, gradients, and glassmorphism. +- **Aesthetics**: Implementing "WOW" designs with vibrant palettes, gradients, and glassmorphism (Obsidian Flux theme). - **Chakra UI**: Expert usage of Chakra components and theming. - **Tailwind CSS**: Leveraging utility classes for rapid UI development. - **Animations**: Using Framer Motion for smooth micro-interactions. @@ -76,6 +76,6 @@ You are a Web Development Expert specializing in building premium, high-performa ## Example Prompts - "Build a premium landing page header with a glassmorphism effect and smooth scroll transitions." -- "Implement a complex audio node dashboard using React and Chakra UI." +- "Implement a complex FastDeck configuration grid using React and Chakra UI." - "Set up a new route and screen for the FastDeck app." - "Optimize the performance of the landing page." diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 4a7ea30..0000000 --- a/.editorconfig +++ /dev/null @@ -1,12 +0,0 @@ -root = true - -[*] -indent_style = space -indent_size = 2 -end_of_line = lf -charset = utf-8 -trim_trailing_whitespace = true -insert_final_newline = true - -[*.md] -trim_trailing_whitespace = false diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml index 531fc77..d88c695 100644 --- a/.github/workflows/build-web.yml +++ b/.github/workflows/build-web.yml @@ -110,8 +110,8 @@ jobs: if: ${{ steps.changelog.outputs.skipped == 'false' }} uses: SamKirkland/FTP-Deploy-Action@v4.3.5 with: - server: ftp.audiomesh.com - username: u392139545.githubdeploy + server: ftp.audiomesh.amitraikwar.in + username: u392139545.githubdep password: ${{ secrets.HOSTINGER_FTP_PASSWORD }} local-dir: apps/web/build/ server-dir: ./ diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 2895d81..0000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "protos"] - path = protos - url = git@github.com:FastDeck/protos.git diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index bc2ecde..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,21 +0,0 @@ -# FastDeck Tools — Agent Instructions - -## Session Bootstrap - -At the start of every session in this project, you MUST: - -1. Read all skill files in `.antigravity/skills/`: - ```bash - ls .antigravity/skills/ - ``` -2. Load each skill file using `view_file` or `read` before doing any work. -3. Apply all skills for the entire session — no exceptions. - -## Current Skills - -- `.antigravity/skills/caveman.md` — Communication style (apply always) -- `.antigravity/skills/commit.md` — Commit & branching workflow (apply on every commit) - -## Project - -FastDeck Tools monorepo. See `CONTEXT.md` for full project context. diff --git a/CONTEXT.md b/CONTEXT.md deleted file mode 100644 index 0d01881..0000000 --- a/CONTEXT.md +++ /dev/null @@ -1,205 +0,0 @@ -# FastDeck — Project Context - -## What is FastDeck? - -FastDeck is a **Stream Deck-style productivity tool** that lets users configure a grid of action buttons on their mobile/tablet device, which communicate with a desktop application to trigger automated actions — launching apps, running scripts, controlling media, firing key bindings, and more. - -Think of it as a **physical Stream Deck, but using your phone or tablet as the panel**, paired wirelessly with your desktop over Bluetooth or a shared WiFi/hotspot network. - ---- - -## The Problem It Solves - -Power users — developers, streamers, content creators, and productivity enthusiasts — rely on macro tools and shortcut panels to speed up repetitive tasks. Physical Stream Decks are expensive and tied to a desk. FastDeck makes this capability: - -- **Device-agnostic**: Any iOS or Android device becomes a control panel. -- **Wireless**: No USB cables required. -- **Programmable**: Fully custom actions, not just media keys. -- **Extensible**: Plugin marketplace for popular apps (VS Code, OBS, Figma, etc.). - ---- - -## High-Level Architecture - -``` -┌─────────────────────────────────────────────────────┐ -│ Mobile Device │ -│ ┌──────────────────────────────────────────────┐ │ -│ │ FastDeck Mobile App (React Native) │ │ -│ │ • Discovers nearby FastDeck servers │ │ -│ │ • Displays the Deck Panel (A×B grid) │ │ -│ │ • Sends action trigger requests via gRPC │ │ -│ └──────────────────────────┬───────────────────┘ │ -└─────────────────────────────┼───────────────────────┘ - │ gRPC (Bluetooth / WiFi) -┌─────────────────────────────┼───────────────────────┐ -│ Desktop Device │ -│ ┌──────────────────────────▼───────────────────┐ │ -│ │ FastDeck Desktop App (Electron) │ │ -│ │ • Runs the gRPC Server │ │ -│ │ • Executes actions on the host OS │ │ -│ │ • Hosts the drag-and-drop Panel configurator │ │ -│ │ • Plugin system / marketplace │ │ -│ └──────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────┘ -``` - ---- - -## Monorepo Structure - -``` -FastDeck/tools/ -├── CONTEXT.md ← You are here (project-level context) -│ -├── protos/ ← Git submodule: github.com/FastDeck/protos -│ │ Single source of truth for all .proto files -│ ├── proto/services/ ← FastDeck service definitions -│ ├── buf.yaml ← buf lint + breaking change config -│ ├── buf.gen.yaml ← Code generation config (bufbuild/es + connectrpc/es) -│ └── Makefile ← make generate / lint / format / commit -│ -├── apps/ -│ ├── web/ ← Marketing / docs web app (React + Vite) -│ ├── desktop/ ← Desktop client (Electron + React + Vite) -│ └── mobile/ ← Mobile panel (React Native) -│ -└── packages/ - └── shared/ ← Types, constants, utilities (all platforms) -``` - -Each workspace has its own `CONTEXT.md` with detailed functionality. - ---- - -## Communication Protocol: gRPC - -FastDeck uses **gRPC** (via Protocol Buffers) as the communication layer between the mobile app and the desktop server. This was chosen for: - -- **Strong typing**: `.proto` files generate type-safe code for both client (mobile) and server (desktop). -- **Performance**: Binary protocol is far more efficient than JSON/REST. -- **Bidirectional streaming**: The server can push panel updates to the client in real-time. -- **Cross-platform**: Works over both Bluetooth (RFCOMM channel) and TCP/IP (WiFi/hotspot). - -### Connection Lifecycle - -``` -Mobile Desktop - │ │ - │── Discover (mDNS broadcast) ──────►│ - │◄─ Advertise (server info) ─────────│ - │ │ - │── Connect (gRPC handshake) ───────►│ - │◄─ Stream: GridLayout ──────────────│ - │ │ - │── TriggerAction(actionId) ────────►│ - │ │ executes on OS - │◄─ ActionResult(status) ────────────│ - │ │ - │◄─ Stream: GridUpdate (live) ───────│ -``` - ---- - -## Action System - -### Single Actions (V1) - -| Action Type | Description | -|-------------|-------------| -| `CREATE_FOLDER` | Creates a directory at a specified path | -| `RUN_SCRIPT` | Executes a script file (shell, Python, etc.) | -| `RUN_COMMAND` | Runs a raw terminal command | -| `LAUNCH_APP` | Opens an application by name or path | -| `OPEN_URL` | Opens a URL in the default browser | -| `MEDIA_CONTROL` | Play/Pause/Skip/Volume control of desktop media | -| `KEY_BINDING` | Triggers a keyboard shortcut on the desktop | - -### Multi Actions (V1) - -A **Multi Action** is an ordered sequence of Single Actions executed one-by-one. Each step can have an optional delay before the next action fires. - -``` -Multi Action Example: "Start Dev Session" - 1. LAUNCH_APP → Terminal - 2. RUN_COMMAND → cd ~/project && yarn dev - 3. LAUNCH_APP → VS Code - 4. OPEN_URL → http://localhost:3000 -``` - ---- - -## Plugin System (Desktop) - -The desktop app will support a **plugin marketplace** where third-party developers can publish action packs for popular apps. - -- **Plugin**: A self-contained package that registers new action types. -- **Example plugins**: - - `fastdeck-vscode`: Open file, run task, toggle terminal - - `fastdeck-obs`: Start/stop stream, switch scenes - - `fastdeck-figma`: Navigate frames, toggle prototypes -- **Discovery**: Plugins are listed in an in-app marketplace, installable with one click. - ---- - -## The Panel Grid - -The Deck Panel is an **A × B configurable grid** where A and B can be any positive integer. - -- Each **cell** holds one action (Single or Multi). -- Each cell can be assigned: - - A custom **icon** (emoji, image, or SF Symbol on iOS). - - A custom **label**. - - A **background color** or **gradient**. -- Cells are arranged via **drag-and-drop** in the desktop configurator. -- Multiple **pages/profiles** can be created and switched between. - ---- - -## Version Roadmap - -### V1 — Core (Current Target) -- ✅ Monorepo setup (this session) -- 🔲 gRPC `.proto` definitions -- 🔲 Desktop app (Electron): gRPC server, OS action execution -- 🔲 Mobile app (React Native): Server discovery, panel display, action trigger -- 🔲 All 7 single action types -- 🔲 Multi action support -- 🔲 Basic drag-and-drop grid configurator - -### V2 — Polish -- 🔲 Plugin marketplace MVP -- 🔲 Multiple profiles/pages -- 🔲 Custom icons and themes -- 🔲 macOS/Windows/Linux installers - -### V3 — Ecosystem -- 🔲 Plugin SDK and developer docs -- 🔲 Cloud sync of profiles -- 🔲 Tablet-optimized layout -- 🔲 Web dashboard - ---- - -## Tech Stack Summary - -| Layer | Technology | Why | -|-------|-----------|-----| -| Desktop UI | React + Vite (Electron renderer) | Familiar, fast HMR, great ecosystem | -| Desktop runtime | Electron | Cross-platform native OS access | -| Mobile | React Native | iOS + Android from one codebase | -| Communication | gRPC + Protobuf | Fast, typed, streaming | -| Discovery | mDNS / Bonjour | Zero-config local network discovery | -| Monorepo | Yarn Workspaces | Simple, widely compatible | -| Shared code | TypeScript | End-to-end type safety | -| Web (marketing) | React + Vite | Consistent with desktop renderer stack | - ---- - -## Development Principles - -1. **Type-safe everywhere** — TypeScript across all packages; Protobuf as the contract for network communication. -2. **Shared code first** — Business logic, action types, and constants live in `packages/shared` and are consumed by all apps. -3. **Proto as the source of truth** — The `.proto` file defines the API; all implementations must conform to it. -4. **Platform-native feel** — The desktop app should feel native on macOS/Windows/Linux; the mobile app should respect iOS and Android design conventions. -5. **Extensibility from day one** — The plugin system architecture is designed in from V1, not bolted on later. diff --git a/CONTEXT.protos.md b/CONTEXT.protos.md deleted file mode 100644 index 4e6123b..0000000 --- a/CONTEXT.protos.md +++ /dev/null @@ -1,116 +0,0 @@ -# protos — Context (Git Submodule) - -> **Submodule**: `git@github.com:FastDeck/protos.git` → mounted at `protos/` -> Do not add files directly into `protos/` — it is an independent repository. - -## What is this? - -`protos/` is a **Git submodule** containing the **single source of truth for all FastDeck service definitions** — the Protobuf `.proto` files and the tooling to generate type-safe TypeScript code from them. - -Changes to `.proto` files are made in the [`FastDeck/protos`](https://github.com/FastDeck/protos) repository directly, then the submodule pointer here is updated to track the new commit. - ---- - -## Repository Details - -| Field | Value | -|-------|-------| -| Remote | `git@github.com:FastDeck/protos.git` | -| Local path | `protos/` (root of this monorepo) | -| Tooling | [`buf`](https://buf.build) v2 | -| Code generation | `buf.build/bufbuild/es` + `buf.build/connectrpc/es` | -| Output | `codegen/ts/` (TypeScript, generated inside the submodule) | - ---- - -## Folder Structure - -``` -protos/ ← Git submodule root -│ -├── buf.yaml # buf workspace config — lint + breaking rules -├── buf.gen.yaml # Code generation plugins + output dirs -├── Makefile # Developer commands: format, lint, generate, commit -├── README.md -├── LICENSE -│ -├── proto/ # All .proto source files -│ ├── google/ # Google common types (rpc status, error details) -│ │ ├── rpc/ -│ │ │ ├── code.proto -│ │ │ ├── error_details.proto -│ │ │ └── status.proto -│ │ └── type/ # Well-known types (date, money, latlng, etc.) -│ │ -│ └── services/ # FastDeck service definitions -│ └── demo_service/ -│ └── demo_service.proto # Example (UserService) -│ -└── codegen/ # ⚠️ Auto-generated — do not edit manually - └── ts/ # TypeScript output (Connect RPC + Protobuf types) -``` - ---- - -## Tooling: `buf` - -The repo uses [`buf`](https://buf.build) v2 instead of raw `protoc`: - -- **Linting** — `STANDARD` ruleset enforces naming conventions and file structure. -- **Breaking change detection** — catches API-breaking changes before they ship. -- **Remote plugins** — no local plugin binaries needed; plugins run via the Buf registry. - -### Code Generation Plugins - -| Plugin | Purpose | -|--------|---------| -| `buf.build/bufbuild/es:v1.10.0` | Base Protobuf message types in TypeScript | -| `buf.build/connectrpc/es:v1.4.0` | Connect RPC service stubs in TypeScript | - -**Connect RPC** is used instead of traditional gRPC-Web. It is compatible with gRPC servers but also supports HTTP/1.1 and JSON — simpler to consume in React Native without needing an Envoy proxy. - ---- - -## Make Commands (run inside `protos/`) - -```bash -make generate # Clean → buf generate → outputs to codegen/ts/ -make lint # buf lint — validate .proto files -make format # buf format -w — auto-format all .proto files -make all # format + lint + generate -make commit # Interactive conventional commit (runs all + git commit) -``` - ---- - -## How Apps Consume Generated Types - -The `codegen/ts/` output inside the submodule is referenced by `apps/desktop` and `apps/mobile` via relative path imports or a `packages/proto` wrapper workspace. - -```typescript -// Option A — Direct relative import (V1, simple) -import { FastDeckServiceClient } from '../../protos/codegen/ts/services/fastdeck/...'; - -// Option B — packages/proto wrapper (V2, cleaner DX) -import { FastDeckServiceClient } from '@fastdeck/proto'; -``` - ---- - -## Submodule Workflow - -```bash -# Clone monorepo WITH submodule content -git clone --recurse-submodules git@github.com:FastDeck/tools.git - -# Initialize submodule after a plain clone -git submodule update --init --recursive - -# Pull latest protos from upstream -git submodule update --remote protos -git add protos -git commit -m "chore(protos): update to latest" - -# Check submodule status -git submodule status -``` diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b7502e8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 One Man Fighter + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index cdaed1d..d80c644 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,208 @@ -# FastDeck +# FastDeck — Project Context -FastDeck is a hardware-agnostic audio-networking application that turns nearby computers, smartphones, and tablets into a synchronized, unified speaker system using local Wi-Fi and Bluetooth protocols. +## What is FastDeck? + +FastDeck is a **Stream Deck-style productivity tool** that lets users configure a grid of action buttons on their mobile/tablet device, which communicate with a desktop application to trigger automated actions — launching apps, running scripts, controlling media, firing key bindings, and more. + +Think of it as a **physical Stream Deck, but using your phone or tablet as the panel**, paired wirelessly with your desktop over Bluetooth or a shared WiFi/hotspot network. + +--- + +## The Problem It Solves + +Power users — developers, streamers, content creators, and productivity enthusiasts — rely on macro tools and shortcut panels to speed up repetitive tasks. Physical Stream Decks are expensive and tied to a desk. FastDeck makes this capability: + +- **Device-agnostic**: Any iOS or Android device becomes a control panel. +- **Wireless**: No USB cables required. +- **Programmable**: Fully custom actions, not just media keys. +- **Extensible**: Plugin marketplace for popular apps (VS Code, OBS, Figma, etc.). + +--- + +## High-Level Architecture + +``` +┌─────────────────────────────────────────────────────┐ +│ Mobile Device │ +│ ┌──────────────────────────────────────────────┐ │ +│ │ FastDeck Mobile App (React Native) │ │ +│ │ • Discovers nearby FastDeck servers │ │ +│ │ • Displays the Deck Panel (A×B grid) │ │ +│ │ • Sends action trigger requests via gRPC │ │ +│ └──────────────────────────┬───────────────────┘ │ +└─────────────────────────────┼───────────────────────┘ + │ gRPC (Bluetooth / WiFi) +┌─────────────────────────────┼───────────────────────┐ +│ Desktop Device │ +│ ┌──────────────────────────▼───────────────────┐ │ +│ │ FastDeck Desktop App (Electron) │ │ +│ │ • Runs the gRPC Server │ │ +│ │ • Executes actions on the host OS │ │ +│ │ • Hosts the drag-and-drop Panel configurator │ │ +│ │ • Plugin system / marketplace │ │ +│ └──────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────┘ +``` + +--- ## Monorepo Structure -- `apps/desktop/`: Electron desktop application wrapper. -- `apps/mobile/`: Tauri mobile application wrapper for iOS and Android. -- `apps/web/`: Web landing page and documentation viewer. -- `apps/server/`: Local gateway daemon (written in Rust) managing network routing, audio buffer queues, and peer-to-device synchronization. -- `shared/common/`: Common presentation components, SVGs, and locale assets. -- `shared/client-common/`: Shared client store (Zustand), providers, and views. - -## Getting Started - -1. Install dependencies: - ```bash - yarn install - ``` - -2. Run development servers: - ```bash - yarn dev - ``` - -3. Run test suites: - ```bash - yarn test:all - ``` +``` +FastDeck/tools/ +├── CONTEXT.md ← You are here (project-level context) +│ +├── protos/ ← Git submodule: github.com/FastDeck/protos +│ │ Single source of truth for all .proto files +│ ├── proto/services/ ← FastDeck service definitions +│ ├── buf.yaml ← buf lint + breaking change config +│ ├── buf.gen.yaml ← Code generation config (bufbuild/es + connectrpc/es) +│ └── Makefile ← make generate / lint / format / commit +│ +├── apps/ +│ ├── web/ ← Marketing / docs web app (React + Vite) +│ ├── desktop/ ← Desktop client (Electron + React + Vite) +│ └── mobile/ ← Mobile panel (React Native) +│ +└── packages/ + └── shared/ ← Types, constants, utilities (all platforms) +``` + +Each workspace has its own `CONTEXT.md` with detailed functionality. + +--- + +## Communication Protocol: gRPC + +FastDeck uses **gRPC** (via Protocol Buffers) as the communication layer between the mobile app and the desktop server. This was chosen for: + +- **Strong typing**: `.proto` files generate type-safe code for both client (mobile) and server (desktop). +- **Performance**: Binary protocol is far more efficient than JSON/REST. +- **Bidirectional streaming**: The server can push panel updates to the client in real-time. +- **Cross-platform**: Works over both Bluetooth (RFCOMM channel) and TCP/IP (WiFi/hotspot). + +### Connection Lifecycle + +``` +Mobile Desktop + │ │ + │── Discover (mDNS broadcast) ──────►│ + │◄─ Advertise (server info) ─────────│ + │ │ + │── Connect (gRPC handshake) ───────►│ + │◄─ Stream: GridLayout ──────────────│ + │ │ + │── TriggerAction(actionId) ────────►│ + │ │ executes on OS + │◄─ ActionResult(status) ────────────│ + │ │ + │◄─ Stream: GridUpdate (live) ───────│ +``` + +--- + +## Action System + +### Single Actions (V1) + +| Action Type | Description | +| --------------- | ----------------------------------------------- | +| `CREATE_FOLDER` | Creates a directory at a specified path | +| `RUN_SCRIPT` | Executes a script file (shell, Python, etc.) | +| `RUN_COMMAND` | Runs a raw terminal command | +| `LAUNCH_APP` | Opens an application by name or path | +| `OPEN_URL` | Opens a URL in the default browser | +| `MEDIA_CONTROL` | Play/Pause/Skip/Volume control of desktop media | +| `KEY_BINDING` | Triggers a keyboard shortcut on the desktop | + +### Multi Actions (V1) + +A **Multi Action** is an ordered sequence of Single Actions executed one-by-one. Each step can have an optional delay before the next action fires. + +``` +Multi Action Example: "Start Dev Session" + 1. LAUNCH_APP → Terminal + 2. RUN_COMMAND → cd ~/project && yarn dev + 3. LAUNCH_APP → VS Code + 4. OPEN_URL → http://localhost:3000 +``` + +--- + +## Plugin System (Desktop) + +The desktop app will support a **plugin marketplace** where third-party developers can publish action packs for popular apps. + +- **Plugin**: A self-contained package that registers new action types. +- **Example plugins**: + - `fastdeck-vscode`: Open file, run task, toggle terminal + - `fastdeck-obs`: Start/stop stream, switch scenes + - `fastdeck-figma`: Navigate frames, toggle prototypes +- **Discovery**: Plugins are listed in an in-app marketplace, installable with one click. + +--- + +## The Panel Grid + +The Deck Panel is an **A × B configurable grid** where A and B can be any positive integer. + +- Each **cell** holds one action (Single or Multi). +- Each cell can be assigned: + - A custom **icon** (emoji, image, or SF Symbol on iOS). + - A custom **label**. + - A **background color** or **gradient**. +- Cells are arranged via **drag-and-drop** in the desktop configurator. +- Multiple **pages/profiles** can be created and switched between. + +--- + +## Version Roadmap + +### V1 — Core (Current Target) + +- ✅ Monorepo setup (this session) +- 🔲 gRPC `.proto` definitions +- 🔲 Desktop app (Electron): gRPC server, OS action execution +- 🔲 Mobile app (React Native): Server discovery, panel display, action trigger +- 🔲 All 7 single action types +- 🔲 Multi action support +- 🔲 Basic drag-and-drop grid configurator + +### V2 — Polish + +- 🔲 Plugin marketplace MVP +- 🔲 Multiple profiles/pages +- 🔲 Custom icons and themes +- 🔲 macOS/Windows/Linux installers + +### V3 — Ecosystem + +- 🔲 Plugin SDK and developer docs +- 🔲 Cloud sync of profiles +- 🔲 Tablet-optimized layout +- 🔲 Web dashboard + +--- + +## Tech Stack Summary + +| Layer | Technology | Why | +| --------------- | -------------------------------- | -------------------------------------- | +| Desktop UI | React + Vite (Electron renderer) | Familiar, fast HMR, great ecosystem | +| Desktop runtime | Electron | Cross-platform native OS access | +| Mobile | React Native | iOS + Android from one codebase | +| Communication | gRPC + Protobuf | Fast, typed, streaming | +| Discovery | mDNS / Bonjour | Zero-config local network discovery | +| Monorepo | Yarn Workspaces | Simple, widely compatible | +| Shared code | TypeScript | End-to-end type safety | +| Web (marketing) | React + Vite | Consistent with desktop renderer stack | + +--- + +## Development Principles + +1. **Type-safe everywhere** — TypeScript across all packages; Protobuf as the contract for network communication. +2. **Shared code first** — Business logic, action types, and constants live in `packages/shared` and are consumed by all apps. +3. **Proto as the source of truth** — The `.proto` file defines the API; all implementations must conform to it. +4. **Platform-native feel** — The desktop app should feel native on macOS/Windows/Linux; the mobile app should respect iOS and Android design conventions. +5. **Extensibility from day one** — The plugin system architecture is designed in from V1, not bolted on later. diff --git a/apps/desktop/CONTEXT.md b/apps/desktop/CONTEXT.md deleted file mode 100644 index d0183b0..0000000 --- a/apps/desktop/CONTEXT.md +++ /dev/null @@ -1,172 +0,0 @@ -# apps/desktop — Context - -## What is this? - -The `@fastdeck/desktop` workspace is the **core of FastDeck** — the Electron.js desktop application that runs on the user's Mac, Windows, or Linux machine. - -It serves two primary roles: -1. **The gRPC Server** — Listens for connections from paired mobile devices and executes actions on the host OS. -2. **The Configurator UI** — A rich React-based interface for building, editing, and managing Deck Panels with drag-and-drop. - ---- - -## Responsibilities - -| Responsibility | Details | -|---------------|---------| -| **gRPC server** | Accepts mobile connections and handles `TriggerAction` RPCs | -| **Action execution** | Runs scripts, launches apps, fires key bindings, controls media | -| **Panel configurator** | Drag-and-drop A×B grid editor for assigning actions to cells | -| **Plugin system** | Loads, manages, and sandboxes third-party plugin packages | -| **Server advertisement** | Broadcasts presence over mDNS so mobile devices can discover it | -| **Profile management** | Stores and switches between named profiles/layouts | - ---- - -## Architecture: Main vs Renderer Process - -Electron splits work between two process types: - -``` -┌──────────────────────────────────────────────────────┐ -│ Electron Main Process │ -│ (Node.js — full OS access) │ -│ │ -│ • gRPC server (@grpc/grpc-js) │ -│ • Action executor (child_process, robotjs, etc.) │ -│ • Plugin loader │ -│ • mDNS advertiser (bonjour/mdns) │ -│ • IPC handlers (exposes safe APIs to renderer) │ -└─────────────────────────┬────────────────────────────┘ - │ contextBridge / IPC -┌─────────────────────────▼────────────────────────────┐ -│ Electron Renderer Process │ -│ (React + Vite — sandboxed, no direct Node access) │ -│ │ -│ • Panel grid configurator UI │ -│ • Plugin marketplace UI │ -│ • Settings / profile management UI │ -│ • Calls preload-exposed APIs only │ -└──────────────────────────────────────────────────────┘ -``` - ---- - -## Folder Structure - -``` -apps/desktop/ -│ -├── package.json # @fastdeck/desktop — Electron, grpc-js, React -├── tsconfig.json # Extends ../../tsconfig.base.json -├── electron.vite.config.ts # electron-vite config (main + renderer) -├── CONTEXT.md # ← You are here -│ -├── src/ -│ │ -│ ├── main/ # Electron Main Process (Node.js context) -│ │ ├── index.ts # Entry: creates BrowserWindow, starts server -│ │ ├── server/ -│ │ │ ├── grpcServer.ts # gRPC server bootstrap + service registration -│ │ │ └── FastDeckService.ts # gRPC service implementation -│ │ ├── actions/ # Action executor modules -│ │ │ ├── index.ts # Action dispatcher (routes by ActionType) -│ │ │ ├── shell.ts # RUN_COMMAND, RUN_SCRIPT -│ │ │ ├── launchApp.ts # LAUNCH_APP -│ │ │ ├── openUrl.ts # OPEN_URL -│ │ │ ├── mediaControl.ts # MEDIA_CONTROL (play/pause/skip/volume) -│ │ │ ├── keyBinding.ts # KEY_BINDING (robotjs / nut.js) -│ │ │ └── createFolder.ts # CREATE_FOLDER -│ │ ├── plugins/ -│ │ │ ├── pluginLoader.ts # Discovers and loads plugin packages -│ │ │ └── pluginRegistry.ts # Registers plugin-provided action types -│ │ ├── discovery/ -│ │ │ └── mdnsAdvertiser.ts # mDNS service broadcasting -│ │ └── ipc/ -│ │ └── handlers.ts # contextBridge IPC bridge for renderer -│ │ -│ ├── preload/ # Preload script (secure bridge) -│ │ └── index.ts # Exposes safe IPC methods via contextBridge -│ │ -│ └── renderer/ # Electron Renderer Process (React UI) -│ ├── index.html # Renderer HTML entry -│ ├── main.tsx # React DOM root -│ ├── App.tsx # Root React component / router -│ ├── index.css # Design system (shared tokens) -│ │ -│ ├── pages/ -│ │ ├── Dashboard.tsx # Default view: profile overview -│ │ ├── GridEditor.tsx # Drag-and-drop panel configurator -│ │ ├── ActionEditor.tsx # Form to configure a single action -│ │ ├── Plugins.tsx # Plugin marketplace + installed list -│ │ └── Settings.tsx # Server port, theme, preferences -│ │ -│ └── components/ -│ ├── Grid/ -│ │ ├── DeckGrid.tsx # A×B grid renderer -│ │ └── DeckCell.tsx # Individual draggable cell -│ ├── ActionForm/ # Per-action-type configuration forms -│ └── Sidebar/ # Navigation sidebar -``` - ---- - -## Action Execution - -All actions are dispatched through a central `ActionDispatcher` in the main process: - -``` -gRPC TriggerAction request - │ - ▼ -ActionDispatcher (main/actions/index.ts) - │ - ├─ ActionType.RUN_COMMAND → shell.ts → child_process.exec() - ├─ ActionType.RUN_SCRIPT → shell.ts → child_process.execFile() - ├─ ActionType.LAUNCH_APP → launchApp.ts → open / shell.openExternal() - ├─ ActionType.OPEN_URL → openUrl.ts → shell.openExternal() - ├─ ActionType.MEDIA_CONTROL → mediaControl.ts → OS media keys - ├─ ActionType.KEY_BINDING → keyBinding.ts → nut.js / robotjs - ├─ ActionType.CREATE_FOLDER → createFolder.ts → fs.mkdir() - └─ ActionType.MULTI → loop: dispatch each child action -``` - ---- - -## Plugin System - -A plugin is an npm package that: -1. Exports a `FastDeckPlugin` object (defined in `@fastdeck/shared`). -2. Declares new `ActionType` identifiers. -3. Provides executor functions for those action types. -4. Optionally provides React components for the configurator UI. - -```typescript -// Example plugin structure -export const plugin: FastDeckPlugin = { - id: 'fastdeck-vscode', - name: 'VS Code', - version: '1.0.0', - actions: [ - { type: 'VSCODE_OPEN_FILE', execute: (payload) => { ... } }, - { type: 'VSCODE_RUN_TASK', execute: (payload) => { ... } }, - ], -}; -``` - ---- - -## Key Dependencies (Planned) - -| Package | Purpose | -|---------|---------| -| `electron` | Desktop runtime | -| `electron-vite` | Vite integration for main + renderer | -| `@grpc/grpc-js` | gRPC server implementation | -| `@grpc/proto-loader` | Loads `.proto` files at runtime | -| `bonjour-service` | mDNS service advertisement | -| `@nut-tree-fork/nut-js` | Cross-platform key binding / automation | -| `react@18` | Renderer UI framework | -| `@dnd-kit/core` | Drag-and-drop for the grid configurator | -| `@fastdeck/shared` | Shared types, constants, action interfaces | -| `@fastdeck/proto` | Generated gRPC types | diff --git a/apps/desktop/DESIGN.md b/apps/desktop/DESIGN.md index dbe1654..669148a 100644 --- a/apps/desktop/DESIGN.md +++ b/apps/desktop/DESIGN.md @@ -1,66 +1,154 @@ -# Web Design Specification: FastDeck - -## 1. Executive Summary - -**FastDeck** is a high-performance, minimalist cloud storage solution designed for digital craftsmen, developers, and tech professionals. The web platform prioritizes speed, security, and utility, giving users calm, effortless control over digital assets through a professional-grade, high-fidelity user interface. - +--- +name: Obsidian Flux +colors: + surface: '#121416' + surface-dim: '#121416' + surface-bright: '#38393c' + surface-container-lowest: '#0c0e10' + surface-container-low: '#1a1c1e' + surface-container: '#1e2022' + surface-container-high: '#282a2c' + surface-container-highest: '#333537' + on-surface: '#e2e2e5' + on-surface-variant: '#c4c7cb' + inverse-surface: '#e2e2e5' + inverse-on-surface: '#2f3133' + outline: '#8e9196' + outline-variant: '#44474b' + surface-tint: '#bac8d7' + primary: '#bac8d7' + on-primary: '#25323d' + primary-container: '#34414d' + on-primary-container: '#9fadbb' + inverse-primary: '#53606d' + secondary: '#c4c7c9' + on-secondary: '#2e3133' + secondary-container: '#46494b' + on-secondary-container: '#b6b9bb' + tertiary: '#c2c7ce' + on-tertiary: '#2c3136' + tertiary-container: '#3b4046' + on-tertiary-container: '#a7acb2' + error: '#ffb4ab' + on-error: '#690005' + error-container: '#93000a' + on-error-container: '#ffdad6' + primary-fixed: '#d6e4f3' + primary-fixed-dim: '#bac8d7' + on-primary-fixed: '#101d28' + on-primary-fixed-variant: '#3b4854' + secondary-fixed: '#e1e3e5' + secondary-fixed-dim: '#c4c7c9' + on-secondary-fixed: '#191c1e' + on-secondary-fixed-variant: '#444749' + tertiary-fixed: '#dee3ea' + tertiary-fixed-dim: '#c2c7ce' + on-tertiary-fixed: '#171c21' + on-tertiary-fixed-variant: '#42474d' + background: '#121416' + on-background: '#e2e2e5' + surface-variant: '#333537' +typography: + display-lg: + fontFamily: Geist + fontSize: 48px + fontWeight: '600' + lineHeight: 56px + letterSpacing: -0.04em + display-lg-mobile: + fontFamily: Geist + fontSize: 32px + fontWeight: '600' + lineHeight: 40px + letterSpacing: -0.03em + headline-md: + fontFamily: Geist + fontSize: 24px + fontWeight: '500' + lineHeight: 32px + letterSpacing: -0.02em + body-lg: + fontFamily: Hanken Grotesk + fontSize: 18px + fontWeight: '400' + lineHeight: 28px + body-md: + fontFamily: Hanken Grotesk + fontSize: 16px + fontWeight: '400' + lineHeight: 24px + label-sm: + fontFamily: JetBrains Mono + fontSize: 12px + fontWeight: '500' + lineHeight: 16px + letterSpacing: 0.05em +rounded: + sm: 0.25rem + DEFAULT: 0.5rem + md: 0.75rem + lg: 1rem + xl: 1.5rem + full: 9999px +spacing: + unit: 4px + gutter: 24px + margin-mobile: 16px + margin-desktop: 64px + stack-sm: 8px + stack-md: 16px + stack-lg: 32px --- -## 2. Product Vision +## Brand & Style -To provide a clean, distraction-free cloud workspace that optimizes flow efficiency through high-speed operations, structured layout organization, and a highly responsive React interface. +The design system is a sophisticated evolution of techno-minimalism, drawing inspiration from the new app icon's interplay of charcoal tones and organic, sweeping curves. It targets a professional, tech-savvy audience that values precision and understated elegance. ---- +The visual language balances the rigidity of a grid-based system with "organic tech" flourishes—specifically, the sharp-to-tapered arcs seen in the brand mark. The mood is deep, cinematic, and high-performance, evoking the feeling of premium hardware or advanced developer tools. It avoids generic tech tropes in favor of a monolithic, sculptural aesthetic characterized by deep matte surfaces and needle-thin accents. -## 3. Design Identity & System Tokens +## Colors -The web application utilizes a custom design system powered by **Chakra UI v3** and **Tailwind CSS**, configured in [theme.ts](file:///Users/mr.robot/z-stash/FastDeck/tools/shared/common/src/components/theme/theme.ts): +The palette is rooted in the "Steel Charcoal" of the icon, utilizing a monochromatic scale that emphasizes texture and depth over hue. -### 3.1 Theme Modes +- **Primary:** A desaturated, deep slate-blue derived directly from the icon’s fill. Used for key interactive states and focal points. +- **Secondary:** A bright, cool off-white for high-contrast typography and critical UI markers. +- **Tertiary:** A mid-tier charcoal for containers and borders that separate content without breaking the dark immersion. +- **Neutral:** A near-black base that serves as the infinite canvas, ensuring the high-contrast elements "pop" with cinematic clarity. -- **Multi-Mode Support:** Robust dark and light mode support with preference detection and seamless theme switching. -- **Light Mode Layout:** Clean neutral background (soft gray `#F8F9FA`) with cards in white (`#FFFFFF`). -- **Dark Mode Layout:** Deep neutral background (charcoal `#151413`) with dark workspace cards. +## Typography -### 3.2 Color System +The typography strategy mirrors the technical but fluid nature of the icon. -- **Primary Accent:** Telegram Blue (`#0088cc` / `primary` token) -- **Secondary Utility:** Cool gray (`#5b5f63`) -- **Success Green:** Success alerts (`#28a745` / `success.400`) -- **Warning Amber:** Warning overlays (`#ffc107` / `warning.400`) -- **Error Red:** System/input errors (`#ba1a1a` / `error.400`) -- **Neutral Palette:** Tiered gray system from `#e3e3e3` (100) down to `#151413` (1000) for structured border/background boundaries. +**Geist** is used for headlines to provide a surgical, geometric precision that feels contemporary and high-tech. **Hanken Grotesk** offers a more approachable and legible experience for long-form body text, maintaining the system's modernist roots. For metadata, code, and system status, **JetBrains Mono** introduces a monospaced "hacker" aesthetic that reinforces the techno-minimalist theme. All type scales favor tight tracking for headlines and generous leading for body text to ensure a premium feel. -### 3.3 Typography +## Layout & Spacing -- **Primary Fonts:** `Inter` (Sans-serif) for primary headings and body elements, ensuring excellent legibility and clear sizing hierarchy. -- **Stylistic Fonts:** `Bungee Shade` imported for specific brand styling components. +This design system employs a **Fixed Grid** philosophy on desktop (12 columns) and a **Fluid Grid** on mobile (4 columns). The layout is built on a 4px baseline grid to ensure mathematical alignment across all components. -### 3.4 Shapes & Shadows +Spacing is used to create "zones of focus." High-density information is housed in structured modules, while significant negative space is placed between high-level sections to mimic the airy, expansive feel of the icon's background. Desktop layouts should maintain generous outer margins (64px) to create a centered, "app-like" experience even on ultrawide monitors. -- **Corner Radius:** Standardized `0.5rem` (8px) for buttons, input forms, and controls; `1rem` (16px) for large display containers and modals. -- **Elevation:** Ambient shadows (`0 4px 12px rgba(0,0,0,0.05)`) are used instead of heavy gradients to convey depth. +## Elevation & Depth ---- +Depth is communicated through **Tonal Layering** and **Subtle Inner Glows**, mimicking the layered construction of the icon. -## 4. Core Feature Requirements +Instead of heavy drop shadows, surfaces are defined by their background color: -### 4.1 File Explorer & Search +1. **Base:** Neutral Black (#0D0F11). +2. **Raised Surfaces:** Tertiary Charcoal (#1A1F24). +3. **Floating Elements:** Primary Slate (#34414D) with a subtle 1px "rim light" border in a lighter shade to define edges against the dark background. -- **Interactive Search:** Central search bar with input focus highlight, clear actions, and rapid query handling. -- **Layout Toggle:** Seamless layout transition between fluid 12-column file cards and organized list tables. -- **Progress Tracking:** 4px linear progress bars for uploads and background synchronizations, dynamically transitioning from Telegram Blue to Success Green upon completion. +Interactive elements use a soft, 10% opacity "Steel" glow rather than a traditional shadow, making them appear as if they are backlit components of a high-end interface. -### 4.2 Workspace Navigation +## Shapes -- **Persistent Sidebar:** Fixed-width 280px sidebar layout for switching between resource categories, personal workspaces, and settings. -- **Spacing Rhythm:** Clean linear 8px padding rhythm ensuring layout consistency and touch target compliance. +The shape language is the core of this system's identity. We utilize **Rounded** (0.5rem) corners for primary containers to match the icon's squircle base. ---- +However, a specific "Flux Stroke" is introduced for iconography and decorative elements: lines should feature one rounded end and one tapered, sharp end, directly referencing the sweeping arc in the app icon. This juxtaposition of "standard" rounded containers and "aggressive" organic strokes creates the unique techno-minimalist tension that defines the product. -## 5. Technical Constraints & Framework Integration +## Components -- **UI Framework:** React (18.3+) + Chakra UI v3 -- **State & Query Routing:** Zustand v5 for application states; React Query for remote API cache management. -- **Responsiveness:** Collapses into a mobile-friendly single-column layout with a top/bottom menu bar below a 600px screen width. -- **Localization Integration:** Dynamic translation keys fetched via `i18next` locales in [locales](file:///Users/mr.robot/z-stash/FastDeck/tools/shared/common/src/localization/locales/). +- **Buttons:** Primary buttons are solid Charcoal (#34414D) with secondary text. The corners are moderately rounded (Level 2). For a "High-Tech" feel, hover states should trigger a 1px secondary-color border that appears to "draw" itself around the perimeter. +- **Inputs:** Ultra-minimalist. Only a bottom border is visible in default states. Upon focus, the border expands into a full-perimeter outline with a subtle inner glow. +- **Cards:** No shadows. Cards are defined by a 1px border in Tertiary (#1A1F24) and a slight change in background tone. +- **Chips/Labels:** These utilize the **JetBrains Mono** font and are strictly rectangular or pill-shaped, providing a sharp contrast to the sweeping curves used in the branding. +- **The "Pulse" Indicator:** A signature component for status—a small, glowing dot that uses a subtle breathing animation, referencing the circular core of the icon. diff --git a/apps/desktop/README.md b/apps/desktop/README.md deleted file mode 100644 index bd53451..0000000 --- a/apps/desktop/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# FastDeck Desktop - -> 🚧 **Coming Soon** — Electron.js desktop application for macOS, Windows, and Linux. - -## Planned Features - -- Native window management -- System tray integration -- Offline-first architecture -- Auto-update support -- Cross-platform builds (DMG, MSI, AppImage) - -## Tech Stack - -- **Electron.js** — Native desktop runtime -- **React** — UI framework (shared with web) -- **@fastdeck/shared** — Shared types and utilities diff --git a/apps/desktop/electron-builder.json b/apps/desktop/electron-builder.json index fd0333e..d40714b 100644 --- a/apps/desktop/electron-builder.json +++ b/apps/desktop/electron-builder.json @@ -1,14 +1,14 @@ { "extends": null, "electronVersion": "31.0.0", - "appId": "com.fastdeck.app", - "productName": "FastDeck", + "appId": "com.audiomesh.app", + "productName": "AudioMesh", "compression": "maximum", "publish": [ { "provider": "github", - "owner": "FastDeck", - "repo": "fastdeck" + "owner": "audiomesh", + "repo": "audiomesh" } ], "directories": { diff --git a/apps/desktop/main.js b/apps/desktop/main.js index e43abc8..f23321f 100644 --- a/apps/desktop/main.js +++ b/apps/desktop/main.js @@ -6,6 +6,8 @@ const { screen, dialog, shell, + session, + desktopCapturer, } = require('electron'); const path = require('path'); const fs = require('fs'); @@ -54,6 +56,30 @@ function createWindow() { } } + // Intercept and rewrite Referer and Origin headers for YouTube embeds to bypass Error 153 in Electron + session.defaultSession.webRequest.onBeforeSendHeaders( + { urls: ['*://*.youtube.com/*', '*://*.youtube-nocookie.com/*'] }, + (details, callback) => { + details.requestHeaders['Referer'] = 'https://www.youtube.com/'; + details.requestHeaders['Origin'] = 'https://www.youtube.com/'; + callback({ cancel: false, requestHeaders: details.requestHeaders }); + } + ); + + // Handle getDisplayMedia requests in Electron to prevent "Not supported" errors + session.defaultSession.setDisplayMediaRequestHandler((request, callback) => { + desktopCapturer.getSources({ types: ['screen', 'window'] }).then((sources) => { + if (sources.length > 0) { + callback({ video: sources[0], audio: 'loopback' }); + } else { + callback({ error: 'No display sources found.' }); + } + }).catch(err => { + console.error('Failed to get desktop sources in display media handler:', err); + callback({ error: err.message }); + }); + }); + const startUrl = process.env.ELECTRON_START_URL || `file://${path.join(__dirname, 'build/index.html')}`; @@ -95,6 +121,19 @@ ipcMain.on('open-external', (event, url) => { } }); +ipcMain.handle('get-desktop-sources', async () => { + try { + const sources = await desktopCapturer.getSources({ types: ['screen', 'window'] }); + return sources.map(source => ({ + id: source.id, + name: source.name, + })); + } catch (err) { + console.error('Failed to get desktop sources:', err); + throw err; + } +}); + ipcMain.handle('download-file-directly', async (event, { url, filename }) => { const webContents = event.sender; const win = BrowserWindow.fromWebContents(webContents); diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 676d09e..0781150 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { - "name": "fastdeck-desktop", - "description": "FastDeck desktop project", + "name": "audiomesh-desktop", + "description": "AudioMesh desktop project", "version": "0.0.1", "homepage": ".", "main": "main.js", @@ -50,6 +50,8 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/react-test-renderer": "^18.3.0", + "audiomesh-client-common": "workspace:*", + "audiomesh-common": "workspace:*", "babel-jest": "^29.7.0", "cypress": "^13.8.1", "electron": "^31.0.0", @@ -60,8 +62,6 @@ "eslint-plugin-import": "^2.25.2", "eslint-plugin-n": "^15.0.0 || ^16.0.0 ", "eslint-plugin-promise": "^6.0.0", - "fastdeck-client-common": "workspace:*", - "fastdeck-common": "workspace:*", "framer-motion": "^11.3.2", "fuse.js": "^7.0.0", "husky": "^9.0.11", diff --git a/apps/desktop/preload.js b/apps/desktop/preload.js index 99d1124..81ae0b6 100644 --- a/apps/desktop/preload.js +++ b/apps/desktop/preload.js @@ -21,4 +21,5 @@ contextBridge.exposeInMainWorld('electronAPI', { downloadFileDirectly: (url, filename) => ipcRenderer.invoke('download-file-directly', { url, filename }), openExternal: (url) => ipcRenderer.send('open-external', url), + getDesktopSources: () => ipcRenderer.invoke('get-desktop-sources'), }); diff --git a/apps/desktop/public/favicon.ico b/apps/desktop/public/favicon.ico index d54e28f..3a9246a 100644 Binary files a/apps/desktop/public/favicon.ico and b/apps/desktop/public/favicon.ico differ diff --git a/apps/desktop/public/icon.png b/apps/desktop/public/icon.png index d54e28f..80cad3f 100644 Binary files a/apps/desktop/public/icon.png and b/apps/desktop/public/icon.png differ diff --git a/apps/desktop/public/index.html b/apps/desktop/public/index.html index 5ad4f26..56ee43a 100644 --- a/apps/desktop/public/index.html +++ b/apps/desktop/public/index.html @@ -2,17 +2,18 @@ + - - + + - FastDeck - + AudioMesh + @@ -194,61 +495,123 @@
-
+ +
+ + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ + +
+
- -
+ +
+ + + + + + + + + + + + + +
+
+
+
+
+
+
+ + -
-
FastDeck
-
CLOUD STORAGE FOR DIGITAL CRAFTSMEN
+ +
+
+
+
AudioMesh
+
SYNCHRONIZED SPEAKER NETWORK
-
CONNECTING TO GATEWAY
+ + +
+
+
+
+
+
+
+
+
+
+ + +
+
+
MESH STATUS: SYNCING
+
+ +
INITIALIZING AUDIO SUBSYSTEM...
+
diff --git a/apps/desktop/public/manifest.json b/apps/desktop/public/manifest.json index f64163b..217e740 100644 --- a/apps/desktop/public/manifest.json +++ b/apps/desktop/public/manifest.json @@ -1,6 +1,6 @@ { - "short_name": "FastDeck", - "name": "FastDeck", + "short_name": "AudioMesh", + "name": "AudioMesh", "icons": [ { "src": "favicon.ico", diff --git a/apps/desktop/src/index.css b/apps/desktop/src/index.css index 7f6149f..b54d079 100644 --- a/apps/desktop/src/index.css +++ b/apps/desktop/src/index.css @@ -1,4 +1,4 @@ -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700;800&family=Hanken+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@300;400;500;600;700&display=swap'); @tailwind base; @tailwind components; diff --git a/apps/desktop/src/index.tsx b/apps/desktop/src/index.tsx index 8e0ec00..142b524 100644 --- a/apps/desktop/src/index.tsx +++ b/apps/desktop/src/index.tsx @@ -1,5 +1,5 @@ import ReactDOM from 'react-dom/client'; -import App from 'fastdeck-client-common'; +import App from 'audiomesh-client-common'; import './index.css'; import reportWebVitals from './reportWebVitals'; diff --git a/apps/desktop/src/react-app-env.d.ts b/apps/desktop/src/react-app-env.d.ts index d0c03e2..4241280 100644 --- a/apps/desktop/src/react-app-env.d.ts +++ b/apps/desktop/src/react-app-env.d.ts @@ -13,6 +13,7 @@ interface ElectronAPI { filename: string, ) => Promise<{ success: boolean; error?: string }>; openExternal?: (url: string) => void; + getDesktopSources?: () => Promise>; } interface Window { diff --git a/apps/mobile/CONTEXT.md b/apps/mobile/CONTEXT.md deleted file mode 100644 index 0f52a75..0000000 --- a/apps/mobile/CONTEXT.md +++ /dev/null @@ -1,106 +0,0 @@ -# apps/mobile — Context - -## What is this? - -The `@fastdeck/mobile` workspace is the **React Native application** for iOS and Android. It turns any phone or tablet into a wireless control panel that communicates with the FastDeck desktop server via gRPC. - ---- - -## Responsibilities - -| Responsibility | Details | -|---------------|---------| -| **Server discovery** | Scans local network for FastDeck desktop servers via mDNS | -| **Pairing** | Displays found servers; user selects and connects to one | -| **Panel display** | Renders the A×B grid of action buttons as configured on desktop | -| **Action triggering** | Sends `TriggerAction` gRPC calls when a button is tapped | -| **Live updates** | Receives streaming grid updates from the desktop in real-time | - ---- - -## Connection Flow - -``` -Mobile App Desktop Server - │ │ - │──── mDNS query (local network) ────►│ - │◄─── mDNS response (server info) ────│ - │ [User selects server] │ - │──── gRPC Connect ──────────────────►│ - │◄─── Stream: GridLayout (cells) ─────│ - │ [User taps a button] │ - │──── TriggerAction(cellId) ─────────►│ - │◄─── ActionResult(status, message) ──│ - │◄─── Stream: GridUpdate (live) ───── │ -``` - ---- - -## Folder Structure - -``` -apps/mobile/ -│ -├── package.json # @fastdeck/mobile -├── tsconfig.json # Extends ../../tsconfig.base.json -├── metro.config.js # Metro bundler (monorepo-aware) -├── babel.config.js # Babel for React Native -├── app.json # RN app metadata (name, bundle ID) -├── index.js # RN entry point -├── CONTEXT.md # ← You are here -│ -└── src/ - ├── App.tsx # Root — navigation shell - │ - ├── screens/ - │ ├── Discovery.tsx # Lists nearby FastDeck servers - │ ├── Connecting.tsx # Loading state during handshake - │ ├── DeckPanel.tsx # Main screen: A×B button grid - │ └── Settings.tsx # Haptic, theme, preferences - │ - ├── components/ - │ ├── ServerCard.tsx # Server list item (name, IP, ping) - │ ├── DeckCell.tsx # Individual action button - │ ├── DeckGrid.tsx # Renders the A×B grid - │ └── ActionFeedback.tsx # Toast/ripple on action result - │ - ├── services/ - │ ├── grpcClient.ts # gRPC channel + service stub setup - │ ├── discovery.ts # mDNS scan for local servers - │ └── actionService.ts # Wraps TriggerAction RPC - │ - ├── store/ # Zustand state - │ ├── connectionStore.ts # Active server connection - │ ├── gridStore.ts # Panel grid layout (cells + labels) - │ └── settingsStore.ts # User preferences - │ - └── utils/ - ├── haptics.ts # Haptic feedback on tap - └── grid.ts # A×B dimension helpers -``` - ---- - -## gRPC on React Native - -**V1 plan**: Use **gRPC-Web** with a lightweight Envoy proxy bundled in the Electron main process. This avoids native module complexity while preserving the `.proto` type contract. - -| Approach | Notes | -|---------|-------| -| gRPC-Web + Envoy (V1) | Works with RN's HTTP stack; proxy runs as Electron sidecar | -| react-native-grpc | Native module; complex but full gRPC support (V2 option) | - ---- - -## Key Dependencies (Planned) - -| Package | Purpose | -|---------|---------| -| `react-native` | Core mobile framework | -| `@react-navigation/native` | Screen navigation | -| `grpc-web` | gRPC-Web client | -| `react-native-zeroconf` | mDNS discovery | -| `zustand` | State management | -| `react-native-haptic-feedback` | Tactile feedback | -| `@fastdeck/shared` | Shared types | -| `@fastdeck/proto` | Generated gRPC types | diff --git a/apps/mobile/README.md b/apps/mobile/README.md deleted file mode 100644 index cf98391..0000000 --- a/apps/mobile/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# FastDeck Mobile - -> 🚧 **Coming Soon** — React Native mobile application for iOS and Android. - -## Planned Features - -- Native iOS and Android support -- Shared business logic with web and desktop -- Push notifications -- Biometric authentication -- Offline support with local storage - -## Tech Stack - -- **React Native** — Cross-platform mobile framework -- **@fastdeck/shared** — Shared types and utilities diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 2ba0801..2692854 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -1,6 +1,6 @@ { - "name": "fastdeck-mobile", - "description": "FastDeck mobile project", + "name": "audiomesh-mobile", + "description": "AudioMesh mobile project", "version": "0.0.1", "homepage": ".", "scripts": { @@ -56,6 +56,8 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/react-test-renderer": "^18.3.0", + "audiomesh-client-common": "workspace:*", + "audiomesh-common": "workspace:*", "babel-jest": "^29.7.0", "cypress": "^13.8.1", "eslint": "^8.0.1", @@ -64,8 +66,6 @@ "eslint-plugin-import": "^2.25.2", "eslint-plugin-n": "^15.0.0 || ^16.0.0 ", "eslint-plugin-promise": "^6.0.0", - "fastdeck-client-common": "workspace:*", - "fastdeck-common": "workspace:*", "framer-motion": "^11.3.2", "fuse.js": "^7.0.0", "husky": "^9.0.11", diff --git a/apps/mobile/public/favicon.ico b/apps/mobile/public/favicon.ico index d54e28f..3a9246a 100644 Binary files a/apps/mobile/public/favicon.ico and b/apps/mobile/public/favicon.ico differ diff --git a/apps/mobile/public/icon.png b/apps/mobile/public/icon.png index d54e28f..80cad3f 100644 Binary files a/apps/mobile/public/icon.png and b/apps/mobile/public/icon.png differ diff --git a/apps/mobile/public/index.html b/apps/mobile/public/index.html index 704ff8e..94d4d42 100644 --- a/apps/mobile/public/index.html +++ b/apps/mobile/public/index.html @@ -2,20 +2,21 @@ + - - + + - FastDeck - + AudioMesh + @@ -197,61 +382,87 @@
-
+
- + + + + + + + + + + +
+
+
+
+
+
+
+
+
+
+
+
+ +
-
FastDeck
-
CLOUD STORAGE FOR DIGITAL CRAFTSMEN
+
AudioMesh
+
SYNCHRONIZED SPEAKER NETWORK
-
CONNECTING TO GATEWAY
+ + +
+
+
+
+
+
+
+
+
+
+ + +
+
+
MESH STATUS: SYNCING
+
+ +
INITIALIZING AUDIO SUBSYSTEM...
diff --git a/apps/mobile/public/manifest.json b/apps/mobile/public/manifest.json index f64163b..217e740 100644 --- a/apps/mobile/public/manifest.json +++ b/apps/mobile/public/manifest.json @@ -1,6 +1,6 @@ { - "short_name": "FastDeck", - "name": "FastDeck", + "short_name": "AudioMesh", + "name": "AudioMesh", "icons": [ { "src": "favicon.ico", diff --git a/apps/mobile/src-tauri/Cargo.toml b/apps/mobile/src-tauri/Cargo.toml index 11a4783..64b37b4 100644 --- a/apps/mobile/src-tauri/Cargo.toml +++ b/apps/mobile/src-tauri/Cargo.toml @@ -23,7 +23,7 @@ serde = { version = "1.0", features = ["derive"] } log = "0.4" tauri = { version = "2.11.2", features = [] } tauri-plugin-log = "2" -fastdeck-server = { path = "../../server" } +audiomesh-server = { path = "../../server" } [patch.crates-io] core2 = { git = "https://github.com/bbqsrc/core2", rev = "545e84bcb0f235b12e21351e0c69767958efe2a7" } diff --git a/apps/mobile/src-tauri/gen/android/app/build.gradle.kts b/apps/mobile/src-tauri/gen/android/app/build.gradle.kts index 5ff624c..832e013 100644 --- a/apps/mobile/src-tauri/gen/android/app/build.gradle.kts +++ b/apps/mobile/src-tauri/gen/android/app/build.gradle.kts @@ -8,7 +8,7 @@ plugins { } base { - archivesName.set("FastDeck") + archivesName.set("AudioMesh") } val tauriProperties = Properties().apply { @@ -27,10 +27,10 @@ val keystoreProperties = Properties().apply { android { compileSdk = 36 - namespace = "com.fastdeck.mobile" + namespace = "com.audiomesh.mobile" defaultConfig { manifestPlaceholders["usesCleartextTraffic"] = "true" - applicationId = "com.fastdeck.mobile" + applicationId = "com.audiomesh.mobile" minSdk = 24 targetSdk = 36 versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt() @@ -85,7 +85,7 @@ android { val variant = this variant.outputs.all { val output = this as com.android.build.gradle.internal.api.BaseVariantOutputImpl - val baseName = "FastDeck" + val baseName = "AudioMesh" if (variant.buildType.name == "release") { output.outputFileName = "${baseName}.apk" } else { @@ -108,7 +108,7 @@ android { tasks.matching { it.name == taskName }.configureEach { val finalizeTask = this as com.android.build.gradle.internal.tasks.FinalizeBundleTask val file = finalizeTask.finalBundleFile.asFile.get() - val newName = if (buildType == "release") "FastDeck.aab" else "FastDeck-debug.aab" + val newName = if (buildType == "release") "AudioMesh.aab" else "AudioMesh-debug.aab" val finalFile = File(file.parentFile, newName) finalizeTask.finalBundleFile.fileValue(finalFile) } diff --git a/apps/mobile/src-tauri/gen/android/app/src/main/java/com/fastdeck/mobile/MainActivity.kt b/apps/mobile/src-tauri/gen/android/app/src/main/java/com/audiomesh/mobile/MainActivity.kt similarity index 88% rename from apps/mobile/src-tauri/gen/android/app/src/main/java/com/fastdeck/mobile/MainActivity.kt rename to apps/mobile/src-tauri/gen/android/app/src/main/java/com/audiomesh/mobile/MainActivity.kt index 064a362..0267de6 100644 --- a/apps/mobile/src-tauri/gen/android/app/src/main/java/com/fastdeck/mobile/MainActivity.kt +++ b/apps/mobile/src-tauri/gen/android/app/src/main/java/com/audiomesh/mobile/MainActivity.kt @@ -1,4 +1,4 @@ -package com.fastdeck.mobile +package com.audiomesh.mobile import android.os.Bundle import androidx.activity.enableEdgeToEdge diff --git a/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index 0196e43..2f13e1d 100644 Binary files a/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png index da72add..c6b34b2 100644 Binary files a/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and b/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png index 3e48a43..e922f21 100644 Binary files a/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and b/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 3110806..5a1551d 100644 Binary files a/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png index dbae7bd..b72bac2 100644 Binary files a/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and b/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png index 5e3ce3c..8fb7799 100644 Binary files a/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and b/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 8b2b477..0a94e3b 100644 Binary files a/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png index b8c6cdd..1770922 100644 Binary files a/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and b/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png index 5fa0b2e..5ba1e04 100644 Binary files a/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and b/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index 9f9a40f..8e6a5d6 100644 Binary files a/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png index 800a17e..2aea1d8 100644 Binary files a/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and b/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png index 4ff0021..49dbe73 100644 Binary files a/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and b/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index b94226c..3f398eb 100644 Binary files a/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png index a48c466..53731e7 100644 Binary files a/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and b/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png index 5f8be12..60717f2 100644 Binary files a/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and b/apps/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/apps/mobile/src-tauri/gen/android/app/src/main/res/values/ic_launcher_background.xml b/apps/mobile/src-tauri/gen/android/app/src/main/res/values/ic_launcher_background.xml index ea9c223..2b5fb3d 100644 --- a/apps/mobile/src-tauri/gen/android/app/src/main/res/values/ic_launcher_background.xml +++ b/apps/mobile/src-tauri/gen/android/app/src/main/res/values/ic_launcher_background.xml @@ -1,4 +1,4 @@ - #fff + #15111e \ No newline at end of file diff --git a/apps/mobile/src-tauri/gen/android/app/src/main/res/values/strings.xml b/apps/mobile/src-tauri/gen/android/app/src/main/res/values/strings.xml index fe45855..66f73df 100644 --- a/apps/mobile/src-tauri/gen/android/app/src/main/res/values/strings.xml +++ b/apps/mobile/src-tauri/gen/android/app/src/main/res/values/strings.xml @@ -1,4 +1,4 @@ - FastDeck - FastDeck + AudioMesh + AudioMesh \ No newline at end of file diff --git a/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@1x.png b/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@1x.png index 8226651..e3092c0 100644 Binary files a/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@1x.png and b/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@1x.png differ diff --git a/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x-1.png b/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x-1.png index 25787b4..477a6e7 100644 Binary files a/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x-1.png and b/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x-1.png differ diff --git a/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x.png b/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x.png index 25787b4..477a6e7 100644 Binary files a/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x.png and b/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x.png differ diff --git a/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@3x.png b/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@3x.png index 25d1247..3932b1e 100644 Binary files a/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@3x.png and b/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@3x.png differ diff --git a/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@1x.png b/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@1x.png index 1e75bf1..43ba903 100644 Binary files a/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@1x.png and b/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@1x.png differ diff --git a/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x-1.png b/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x-1.png index b99eedb..8233be3 100644 Binary files a/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x-1.png and b/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x-1.png differ diff --git a/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x.png b/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x.png index b99eedb..8233be3 100644 Binary files a/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x.png and b/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x.png differ diff --git a/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@3x.png b/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@3x.png index bdca11a..71bf8fb 100644 Binary files a/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@3x.png and b/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@3x.png differ diff --git a/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@1x.png b/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@1x.png index 25787b4..477a6e7 100644 Binary files a/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@1x.png and b/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@1x.png differ diff --git a/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x-1.png b/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x-1.png index 434fcde..64f6a8c 100644 Binary files a/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x-1.png and b/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x-1.png differ diff --git a/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x.png b/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x.png index 434fcde..64f6a8c 100644 Binary files a/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x.png and b/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x.png differ diff --git a/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@3x.png b/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@3x.png index 007c0b9..0cf8983 100644 Binary files a/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@3x.png and b/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@3x.png differ diff --git a/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png b/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png index f9e9880..0dbb4d4 100644 Binary files a/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png and b/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png differ diff --git a/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x.png b/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x.png index 007c0b9..0cf8983 100644 Binary files a/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x.png and b/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x.png differ diff --git a/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@3x.png b/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@3x.png index e1805b7..8988fb0 100644 Binary files a/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@3x.png and b/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@3x.png differ diff --git a/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@1x.png b/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@1x.png index 0a73c00..5fef8b4 100644 Binary files a/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@1x.png and b/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@1x.png differ diff --git a/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@2x.png b/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@2x.png index f69d98e..9e63439 100644 Binary files a/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@2x.png and b/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@2x.png differ diff --git a/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5x83.5@2x.png b/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5x83.5@2x.png index f40b18c..072ad0d 100644 Binary files a/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5x83.5@2x.png and b/apps/mobile/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5x83.5@2x.png differ diff --git a/apps/mobile/src-tauri/gen/apple/app.xcodeproj/project.pbxproj b/apps/mobile/src-tauri/gen/apple/app.xcodeproj/project.pbxproj index 80829ad..b0208a2 100644 --- a/apps/mobile/src-tauri/gen/apple/app.xcodeproj/project.pbxproj +++ b/apps/mobile/src-tauri/gen/apple/app.xcodeproj/project.pbxproj @@ -275,8 +275,8 @@ ); "LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)"; "LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)"; - PRODUCT_BUNDLE_IDENTIFIER = com.fastdeck.mobile; - PRODUCT_NAME = "fastdeck-mobile"; + PRODUCT_BUNDLE_IDENTIFIER = com.audiomesh.mobile; + PRODUCT_NAME = "audiomesh-mobile"; SDKROOT = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; VALID_ARCHS = arm64; @@ -362,8 +362,8 @@ ); "LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)"; "LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)"; - PRODUCT_BUNDLE_IDENTIFIER = com.fastdeck.mobile; - PRODUCT_NAME = "fastdeck-mobile"; + PRODUCT_BUNDLE_IDENTIFIER = com.audiomesh.mobile; + PRODUCT_NAME = "audiomesh-mobile"; SDKROOT = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; VALID_ARCHS = arm64; diff --git a/apps/mobile/src-tauri/gen/apple/project.yml b/apps/mobile/src-tauri/gen/apple/project.yml index 829cccb..0d8cf5f 100644 --- a/apps/mobile/src-tauri/gen/apple/project.yml +++ b/apps/mobile/src-tauri/gen/apple/project.yml @@ -1,6 +1,6 @@ name: app options: - bundleIdPrefix: com.fastdeck.mobile + bundleIdPrefix: com.audiomesh.mobile deploymentTarget: iOS: 14.0 fileGroups: [../../src] @@ -10,8 +10,8 @@ configs: settingGroups: app: base: - PRODUCT_NAME: fastdeck-mobile - PRODUCT_BUNDLE_IDENTIFIER: com.fastdeck.mobile + PRODUCT_NAME: audiomesh-mobile + PRODUCT_BUNDLE_IDENTIFIER: com.audiomesh.mobile targetTemplates: app: type: application diff --git a/apps/mobile/src-tauri/icons/128x128.png b/apps/mobile/src-tauri/icons/128x128.png index 72acdf0..c0c60e6 100644 Binary files a/apps/mobile/src-tauri/icons/128x128.png and b/apps/mobile/src-tauri/icons/128x128.png differ diff --git a/apps/mobile/src-tauri/icons/128x128@2x.png b/apps/mobile/src-tauri/icons/128x128@2x.png index feca21e..328897b 100644 Binary files a/apps/mobile/src-tauri/icons/128x128@2x.png and b/apps/mobile/src-tauri/icons/128x128@2x.png differ diff --git a/apps/mobile/src-tauri/icons/32x32.png b/apps/mobile/src-tauri/icons/32x32.png index c33d99d..9f131de 100644 Binary files a/apps/mobile/src-tauri/icons/32x32.png and b/apps/mobile/src-tauri/icons/32x32.png differ diff --git a/apps/mobile/src-tauri/icons/64x64.png b/apps/mobile/src-tauri/icons/64x64.png index edee128..6d6c291 100644 Binary files a/apps/mobile/src-tauri/icons/64x64.png and b/apps/mobile/src-tauri/icons/64x64.png differ diff --git a/apps/mobile/src-tauri/icons/Square107x107Logo.png b/apps/mobile/src-tauri/icons/Square107x107Logo.png index ae98080..c5f57c6 100644 Binary files a/apps/mobile/src-tauri/icons/Square107x107Logo.png and b/apps/mobile/src-tauri/icons/Square107x107Logo.png differ diff --git a/apps/mobile/src-tauri/icons/Square142x142Logo.png b/apps/mobile/src-tauri/icons/Square142x142Logo.png index d7a9860..f5be989 100644 Binary files a/apps/mobile/src-tauri/icons/Square142x142Logo.png and b/apps/mobile/src-tauri/icons/Square142x142Logo.png differ diff --git a/apps/mobile/src-tauri/icons/Square150x150Logo.png b/apps/mobile/src-tauri/icons/Square150x150Logo.png index 389aada..64ab2ed 100644 Binary files a/apps/mobile/src-tauri/icons/Square150x150Logo.png and b/apps/mobile/src-tauri/icons/Square150x150Logo.png differ diff --git a/apps/mobile/src-tauri/icons/Square284x284Logo.png b/apps/mobile/src-tauri/icons/Square284x284Logo.png index 2a6a1a4..8c76fad 100644 Binary files a/apps/mobile/src-tauri/icons/Square284x284Logo.png and b/apps/mobile/src-tauri/icons/Square284x284Logo.png differ diff --git a/apps/mobile/src-tauri/icons/Square30x30Logo.png b/apps/mobile/src-tauri/icons/Square30x30Logo.png index ee36eca..fd54ccb 100644 Binary files a/apps/mobile/src-tauri/icons/Square30x30Logo.png and b/apps/mobile/src-tauri/icons/Square30x30Logo.png differ diff --git a/apps/mobile/src-tauri/icons/Square310x310Logo.png b/apps/mobile/src-tauri/icons/Square310x310Logo.png index 08988e6..a4d2273 100644 Binary files a/apps/mobile/src-tauri/icons/Square310x310Logo.png and b/apps/mobile/src-tauri/icons/Square310x310Logo.png differ diff --git a/apps/mobile/src-tauri/icons/Square44x44Logo.png b/apps/mobile/src-tauri/icons/Square44x44Logo.png index 7d9805f..c34b9af 100644 Binary files a/apps/mobile/src-tauri/icons/Square44x44Logo.png and b/apps/mobile/src-tauri/icons/Square44x44Logo.png differ diff --git a/apps/mobile/src-tauri/icons/Square71x71Logo.png b/apps/mobile/src-tauri/icons/Square71x71Logo.png index f1e4183..aef7dde 100644 Binary files a/apps/mobile/src-tauri/icons/Square71x71Logo.png and b/apps/mobile/src-tauri/icons/Square71x71Logo.png differ diff --git a/apps/mobile/src-tauri/icons/Square89x89Logo.png b/apps/mobile/src-tauri/icons/Square89x89Logo.png index 3740f27..5251ef4 100644 Binary files a/apps/mobile/src-tauri/icons/Square89x89Logo.png and b/apps/mobile/src-tauri/icons/Square89x89Logo.png differ diff --git a/apps/mobile/src-tauri/icons/StoreLogo.png b/apps/mobile/src-tauri/icons/StoreLogo.png index 3d861b0..f1af743 100644 Binary files a/apps/mobile/src-tauri/icons/StoreLogo.png and b/apps/mobile/src-tauri/icons/StoreLogo.png differ diff --git a/apps/mobile/src-tauri/icons/icon.icns b/apps/mobile/src-tauri/icons/icon.icns index c59198d..64aa6bd 100644 Binary files a/apps/mobile/src-tauri/icons/icon.icns and b/apps/mobile/src-tauri/icons/icon.icns differ diff --git a/apps/mobile/src-tauri/icons/icon.ico b/apps/mobile/src-tauri/icons/icon.ico index b384528..dd8f2dd 100644 Binary files a/apps/mobile/src-tauri/icons/icon.ico and b/apps/mobile/src-tauri/icons/icon.ico differ diff --git a/apps/mobile/src-tauri/icons/icon.png b/apps/mobile/src-tauri/icons/icon.png index 644e338..a639d6d 100644 Binary files a/apps/mobile/src-tauri/icons/icon.png and b/apps/mobile/src-tauri/icons/icon.png differ diff --git a/apps/mobile/src-tauri/tauri.conf.json b/apps/mobile/src-tauri/tauri.conf.json index 0dce7b8..d0b9019 100644 --- a/apps/mobile/src-tauri/tauri.conf.json +++ b/apps/mobile/src-tauri/tauri.conf.json @@ -1,8 +1,8 @@ { "$schema": "../../node_modules/@tauri-apps/cli/config.schema.json", - "productName": "FastDeck", + "productName": "AudioMesh", "version": "../package.json", - "identifier": "com.fastdeck.mobile", + "identifier": "com.audiomesh.mobile", "build": { "frontendDist": "../build", "devUrl": "http://localhost:3000", @@ -12,7 +12,7 @@ "app": { "windows": [ { - "title": "FastDeck", + "title": "AudioMesh", "width": 800, "height": 600, "resizable": true, diff --git a/apps/mobile/src/index.css b/apps/mobile/src/index.css index 7f6149f..b54d079 100644 --- a/apps/mobile/src/index.css +++ b/apps/mobile/src/index.css @@ -1,4 +1,4 @@ -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700;800&family=Hanken+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@300;400;500;600;700&display=swap'); @tailwind base; @tailwind components; diff --git a/apps/mobile/src/index.tsx b/apps/mobile/src/index.tsx index 0026de5..6b2edcf 100644 --- a/apps/mobile/src/index.tsx +++ b/apps/mobile/src/index.tsx @@ -1,6 +1,6 @@ // Polyfill structuredClone for older WebView runtimes (Chromium < 99) import ReactDOM from 'react-dom/client'; -import App from 'fastdeck-client-common'; +import App from 'audiomesh-client-common'; import './index.css'; import reportWebVitals from './reportWebVitals'; diff --git a/apps/mobile/src/react-app-env.d.ts b/apps/mobile/src/react-app-env.d.ts index d0c03e2..4241280 100644 --- a/apps/mobile/src/react-app-env.d.ts +++ b/apps/mobile/src/react-app-env.d.ts @@ -13,6 +13,7 @@ interface ElectronAPI { filename: string, ) => Promise<{ success: boolean; error?: string }>; openExternal?: (url: string) => void; + getDesktopSources?: () => Promise>; } interface Window { diff --git a/apps/server/Cargo.lock b/apps/server/Cargo.lock index c75febc..07aef9b 100644 --- a/apps/server/Cargo.lock +++ b/apps/server/Cargo.lock @@ -43,6 +43,25 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "audiomesh-server" +version = "0.0.1" +dependencies = [ + "axum", + "chrono", + "dashmap", + "futures-util", + "serde", + "serde_json", + "tokio", + "tokio-tungstenite", + "tower 0.4.13", + "tower-http", + "tracing", + "tracing-subscriber", + "uuid", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -57,6 +76,7 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" dependencies = [ "async-trait", "axum-core", + "base64", "bytes", "futures-util", "http", @@ -76,8 +96,10 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", + "sha1", "sync_wrapper", "tokio", + "tokio-tungstenite", "tower 0.5.3", "tower-layer", "tower-service", @@ -105,18 +127,39 @@ dependencies = [ "tracing", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.1" @@ -159,6 +202,61 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -184,23 +282,6 @@ dependencies = [ "windows-sys", ] -[[package]] -name = "fastdeck-server" -version = "0.0.1" -dependencies = [ - "axum", - "chrono", - "futures-util", - "serde", - "serde_json", - "tokio", - "tower 0.4.13", - "tower-http", - "tracing", - "tracing-subscriber", - "uuid", -] - [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -248,6 +329,12 @@ dependencies = [ "syn", ] +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + [[package]] name = "futures-task" version = "0.3.32" @@ -262,11 +349,33 @@ checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", "futures-macro", + "futures-sink", "futures-task", "pin-project-lite", "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -280,6 +389,12 @@ dependencies = [ "wasip3", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.15.5" @@ -608,6 +723,15 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -642,6 +766,36 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -758,6 +912,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -828,6 +993,26 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "thread_local" version = "1.1.9" @@ -865,6 +1050,18 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tower" version = "0.4.13" @@ -987,6 +1184,30 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -999,13 +1220,19 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "uuid" version = "1.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" dependencies = [ - "getrandom", + "getrandom 0.4.2", "js-sys", "wasm-bindgen", ] @@ -1287,6 +1514,26 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/apps/server/Cargo.toml b/apps/server/Cargo.toml index ee57491..0791ca2 100644 --- a/apps/server/Cargo.toml +++ b/apps/server/Cargo.toml @@ -1,25 +1,27 @@ [package] -name = "fastdeck-server" +name = "audiomesh-server" version = "0.0.1" edition = "2021" [lib] -name = "fastdeck_server" +name = "audiomesh_server" path = "src/lib.rs" [[bin]] -name = "fastdeck-server" +name = "audiomesh-server" path = "src/main.rs" [dependencies] -axum = { version = "0.7.4", features = ["multipart"] } +axum = { version = "0.7.4", features = ["multipart", "ws"] } tokio = { version = "1.36.0", features = ["full"] } +tokio-tungstenite = "0.24" tower-http = { version = "0.5.1", features = ["cors", "trace"] } serde = { version = "1.0.197", features = ["derive"] } serde_json = "1.0.114" uuid = { version = "1.7.0", features = ["v4"] } chrono = { version = "0.4.35", features = ["serde"] } futures-util = "0.3.30" +dashmap = "6.1" tracing = "0.1.40" tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } diff --git a/apps/server/README.md b/apps/server/README.md new file mode 100644 index 0000000..f3d7161 --- /dev/null +++ b/apps/server/README.md @@ -0,0 +1,201 @@ +# AudioMesh Server — Mesh Audio Gateway + +The AudioMesh server is a high-performance local gateway daemon written in Rust. It manages room lifecycle, peer connections, real-time tree topologies, low-latency audio packet routing (SFU), and sub-millisecond clock synchronization across local client devices. + +--- + +## Architecture + +The mesh server implements three main connection modes: +1. **Direct P2P**: Host establishes direct WebRTC connections to each client (best for small groups, up to 15 nodes). +2. **Daisy-Chain**: Peer-to-peer relaying where the host sends audio to a subset of peers, which recursively forward the stream down a balanced tree topology (best for large groups, up to 30 nodes, auto-balances and self-heals on disconnect). +3. **SFU (Selective Forwarding Unit)**: Host uploads audio to the server over a single WebSocket uplink, and the server fans it out to all clients over WebSocket downlinks (best for large venues or heavy local interference, up to 50 nodes). + +--- + +## Configuration + +The server configuration is loaded from environment variables (or defaults) in `config.rs`: + +| Variable | Description | Default | +|---|---|---| +| `HOST` | The bind address | `127.0.0.1` | +| `PORT` | The port to listen on | `3000` | +| `MAX_ROOMS` | Maximum concurrent active rooms | `100` | +| `MAX_NODES_PER_ROOM` | Default maximum nodes per room | `50` | +| `SFU_BUFFER_SIZE` | Under-the-hood frame broadcast buffer size | `128` | +| `MAX_CHILDREN_PER_NODE` | Maximum branch factor for Daisy-Chain trees | `3` | + +--- + +## API Endpoints + +### 1. REST API + +All mesh endpoints are public and do not require user authentication (rooms are secured via room IDs). + +#### Create a Room +* **Endpoint**: `POST /mesh/rooms` +* **Request Payload**: + ```json + { + "name": "Living Room Party", + "mode": "sfu", // "direct_p2p" | "daisy_chain" | "sfu" + "host_peer_id": "host-device-1", + "max_nodes": 50 // Optional: overrides the default limit + } + ``` +* **Response (201 Created)**: + ```json + { + "id": "7a3b...", + "name": "Living Room Party", + "mode": "sfu", + "host_peer_id": "host-device-1", + "created_at": "2026-06-12T23:36:00Z", + "max_nodes": 50 + } + ``` + +#### List Rooms +* **Endpoint**: `GET /mesh/rooms` +* **Response (200 OK)**: + ```json + [ + { + "id": "7a3b...", + "name": "Living Room Party", + "mode": "sfu", + "host_peer_id": "host-device-1", + "created_at": "2026-06-12T23:36:00Z", + "max_nodes": 50 + } + ] + ``` + +#### Get Room Details & Peers +* **Endpoint**: `GET /mesh/rooms/:id` +* **Response (200 OK)**: + ```json + { + "room": { + "id": "7a3b...", + "name": "Living Room Party", + "mode": "sfu", + "host_peer_id": "host-device-1", + "created_at": "2026-06-12T23:36:00Z", + "max_nodes": 50 + }, + "peers": [ + { + "id": "client-1", + "display_name": "iPhone 15", + "device_type": "phone", + "connection_type": "wifi", + "joined_at": "2026-06-12T23:36:10Z" + } + ] + } + ``` + +#### Close/Delete a Room +* **Endpoint**: `DELETE /mesh/rooms/:id` +* **Response (200 OK)**: + ```json + { + "success": true + } + ``` + +#### Get Topology Tree (Daisy-Chain only) +* **Endpoint**: `GET /mesh/rooms/:id/topology` +* **Response (200 OK)**: + ```json + { + "edges": [ + { + "from_peer_id": "host-device-1", + "to_peer_id": "client-1", + "rtt_ms": 12.5, + "status": "synced" // "connecting" | "synced" | "degraded" | "disconnected" + } + ] + } + ``` + +#### Report RTT Measurement +* **Endpoint**: `POST /mesh/rooms/:id/topology/rtt` +* **Request Payload**: + ```json + { + "from_peer_id": "host-device-1", + "to_peer_id": "client-1", + "rtt_ms": 12.5 + } + ``` +* **Response (200 OK)**: + ```json + { + "success": true + } + ``` + +--- + +### 2. WebSocket Protocols + +#### Signaling WebSocket +* **Endpoint**: `GET /ws/signaling/:room_id` +* **Usage**: Used by WebRTC nodes (Direct P2P / Daisy-Chain modes) to exchange SDP offers, answers, and ICE candidates, and to receive real-time parents for daisy-chaining. +* **Client Handshake**: The client connects and sends a `join` frame: + ```json + { + "type": "join", + "peer_id": "client-1", + "display_name": "iPhone 15", + "device_type": "phone", // "desktop" | "phone" | "tablet" | "speaker" | "browser" + "connection_type": "wifi" // "wifi" | "bluetooth" | "websocket" + } + ``` +* **Server Responses**: + - `peers`: Server broadcasts the full room peer list to all peers upon changes. + - `topology_assign`: Server assigns the client a parent node if the room is in Daisy-Chain mode. + - `peer_left`: Server notifies the remaining nodes when a peer leaves. + +#### SFU Media Stream WebSocket +* **Endpoint**: `GET /ws/sfu/:room_id` +* **Usage**: Relays low-latency binary audio frames. +* **Handshake**: + 1. The client connects and sends a JSON text frame specifying their role: + - Host (uplink): `{"role": "host"}` + - Client (downlink): `{"role": "client"}` + 2. The Host streams binary frames: `[8-byte timestamp][Raw Opus Audio Data]`. + 3. The Server utilizes a zero-copy lock-free broadcast channel to forward frames to all connected client downlinks. + 4. If a client lags, the server skips lagged frames to prioritize real-time playback. + +#### NTP Time Sync WebSocket +* **Endpoint**: `GET /ws/sync/:room_id` +* **Usage**: Performs high-frequency NTP-style clock drift calculations. +* **Handshake**: + 1. Client sends a JSON message: + ```json + { "client_send_us": 1718223400000000 } + ``` + 2. Server immediately processes it and replies: + ```json + { + "client_send_us": 1718223400000000, + "server_recv_us": 1718223400000120, + "server_send_us": 1718223400000140 + } + ``` + 3. The client performs multiple cycles of this exchange to calculate average round-trip delay and precise clock offset relative to the host's session clock. + +--- + +## Testing + +To run the complete test suite: +```bash +cargo test +``` diff --git a/apps/server/package.json b/apps/server/package.json index 918e4ce..1b0287a 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,5 +1,5 @@ { - "name": "fastdeck-server", + "name": "audiomesh-server", "version": "0.0.1", "private": true, "scripts": { diff --git a/apps/server/src/config.rs b/apps/server/src/config.rs index c818847..9065db5 100644 --- a/apps/server/src/config.rs +++ b/apps/server/src/config.rs @@ -4,6 +4,14 @@ use std::env; pub struct AppConfig { pub host: String, pub port: u16, + /// Maximum number of concurrent mesh rooms. + pub max_rooms: usize, + /// Maximum number of nodes per room (default, can be overridden per room). + pub max_nodes_per_room: usize, + /// Broadcast channel buffer size for SFU audio forwarding. + pub sfu_buffer_size: usize, + /// Maximum children per node in Daisy-Chain topology. + pub max_children_per_node: usize, } impl AppConfig { @@ -16,6 +24,33 @@ impl AppConfig { .and_then(|p| p.parse().ok()) .unwrap_or(50065); - Self { host, port } + let max_rooms = env::var("MAX_ROOMS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(10); + + let max_nodes_per_room = env::var("MAX_NODES_PER_ROOM") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(50); + + let sfu_buffer_size = env::var("SFU_BUFFER_SIZE") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(256); + + let max_children_per_node = env::var("MAX_CHILDREN_PER_NODE") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(3); + + Self { + host, + port, + max_rooms, + max_nodes_per_room, + sfu_buffer_size, + max_children_per_node, + } } } diff --git a/apps/server/src/handlers/__tests__/auth.test.rs b/apps/server/src/handlers/__tests__/auth.test.rs deleted file mode 100644 index 01dfac2..0000000 --- a/apps/server/src/handlers/__tests__/auth.test.rs +++ /dev/null @@ -1,185 +0,0 @@ -#[cfg(test)] -mod tests { - use super::super::{get_json, post_json, setup_app}; - use axum::http::StatusCode; - use serde_json::json; - - #[tokio::test] - async fn test_auth_flow() { - let app = setup_app(); - - // 1. Initial State: LoggedOut - let (status, state) = get_json(app.clone(), "/auth/state").await; - assert_eq!(status, StatusCode::OK); - assert_eq!(state["status"], "LoggedOut"); - - // 2. Send Code: Invalid API ID format - let payload_bad_api_id = json!({ - "phone": "+15551234567", - "api_id": "not_an_int", - "api_hash": "abc123def456" - }); - let (status_bad, body_bad) = - post_json(app.clone(), "/auth/send-code", payload_bad_api_id).await; - assert_eq!(status_bad, StatusCode::BAD_REQUEST); - assert_eq!(body_bad["success"], false); - assert!(body_bad["error"] - .as_str() - .unwrap() - .contains("Invalid API ID")); - - // 3. Send Code: Happy Path - let payload_send_code = json!({ - "phone": "+15551234567", - "api_id": "12345", - "api_hash": "abc123def456" - }); - let (status_code, body_code) = - post_json(app.clone(), "/auth/send-code", payload_send_code).await; - assert_eq!(status_code, StatusCode::OK); - assert_eq!(body_code["success"], true); - assert_eq!(body_code["next_step"], "code"); - - // State check after send-code - let (_, state_after_code) = get_json(app.clone(), "/auth/state").await; - assert_eq!(state_after_code["status"], "AwaitingCode"); - assert_eq!(state_after_code["data"]["phone"], "+15551234567"); - - // 4. Sign In: Invalid Code - let payload_sign_in_invalid = json!({ - "phone": "+15551234567", - "phone_code_hash": "mock_hash_xyz123", - "code": "00000" - }); - let (status_sign_in_bad, body_sign_in_bad) = - post_json(app.clone(), "/auth/sign-in", payload_sign_in_invalid).await; - assert_eq!(status_sign_in_bad, StatusCode::OK); - assert_eq!(body_sign_in_bad["success"], false); - assert_eq!(body_sign_in_bad["next_step"], "code"); - assert!(body_sign_in_bad["error"].is_string()); - - // 5. Sign In: Valid Code (12345) - let payload_sign_in_valid = json!({ - "phone": "+15551234567", - "phone_code_hash": "mock_hash_xyz123", - "code": "12345" - }); - let (status_sign_in_ok, body_sign_in_ok) = - post_json(app.clone(), "/auth/sign-in", payload_sign_in_valid).await; - assert_eq!(status_sign_in_ok, StatusCode::OK); - assert_eq!(body_sign_in_ok["success"], true); - assert_eq!(body_sign_in_ok["next_step"], "dashboard"); - - // State check after sign-in - let (_, state_after_signin) = get_json(app.clone(), "/auth/state").await; - assert_eq!(state_after_signin["status"], "LoggedIn"); - - // 6. Log Out - let (status_logout, body_logout) = post_json(app.clone(), "/auth/log-out", json!({})).await; - assert_eq!(status_logout, StatusCode::OK); - assert_eq!(body_logout["success"], true); - - // State check after log-out - let (_, state_after_logout) = get_json(app.clone(), "/auth/state").await; - assert_eq!(state_after_logout["status"], "LoggedOut"); - } - - #[tokio::test] - async fn test_sign_up_and_reset_auth() { - let app = setup_app(); - - // 1. Sign Up (should map to sign-in handler and work similarly) - let payload_send_code = json!({ - "phone": "+15559876543", - "api_id": "99999", - "api_hash": "xyz987" - }); - post_json(app.clone(), "/auth/send-code", payload_send_code).await; - - let payload_signup = json!({ - "phone": "+15559876543", - "phone_code_hash": "mock_hash_xyz123", - "code": "11111" - }); - let (status_signup, body_signup) = - post_json(app.clone(), "/auth/sign-up", payload_signup).await; - assert_eq!(status_signup, StatusCode::OK); - assert_eq!(body_signup["success"], true); - assert_eq!(body_signup["next_step"], "dashboard"); - - // 2. Reset authorization mid-flow (let's trigger reset) - let (status_reset, body_reset) = - post_json(app.clone(), "/auth/reset-authorization", json!({})).await; - assert_eq!(status_reset, StatusCode::OK); - assert_eq!(body_reset["success"], true); - - let (_, state_after_reset) = get_json(app.clone(), "/auth/state").await; - assert_eq!(state_after_reset["status"], "LoggedOut"); - } - - #[tokio::test] - async fn test_2fa_authentication() { - let app = setup_app(); - - // Send code - let payload_send_code = json!({ - "phone": "+15551234567", - "api_id": "12345", - "api_hash": "abc123def456" - }); - post_json(app.clone(), "/auth/send-code", payload_send_code).await; - - // Sign in with 2FA code ("2fa" or "22222") - let payload_2fa_code = json!({ - "phone": "+15551234567", - "phone_code_hash": "mock_hash_xyz123", - "code": "2fa" - }); - let (status_2fa_code, body_2fa_code) = - post_json(app.clone(), "/auth/sign-in", payload_2fa_code).await; - assert_eq!(status_2fa_code, StatusCode::OK); - assert_eq!(body_2fa_code["success"], true); - assert_eq!(body_2fa_code["next_step"], "password"); - - // Check state is AwaitingPassword - let (_, state_2fa) = get_json(app.clone(), "/auth/state").await; - assert_eq!(state_2fa["status"], "AwaitingPassword"); - - // Check password: bad password - let (status_pw_bad, body_pw_bad) = post_json( - app.clone(), - "/auth/check-password", - json!({ "password": "wrongpassword" }), - ) - .await; - assert_eq!(status_pw_bad, StatusCode::OK); - assert_eq!(body_pw_bad["success"], false); - assert_eq!(body_pw_bad["next_step"], "password"); - assert!(body_pw_bad["error"].is_string()); - - // Check password: correct password - let (status_pw_ok, body_pw_ok) = post_json( - app.clone(), - "/auth/check-password", - json!({ "password": "password" }), - ) - .await; - assert_eq!(status_pw_ok, StatusCode::OK); - assert_eq!(body_pw_ok["success"], true); - assert_eq!(body_pw_ok["next_step"], "dashboard"); - - // Final state: LoggedIn - let (_, state_logged_in) = get_json(app.clone(), "/auth/state").await; - assert_eq!(state_logged_in["status"], "LoggedIn"); - } - - #[tokio::test] - async fn test_unauthorized_endpoints() { - let app = setup_app(); // Starts LoggedOut by default - - // Query protected endpoint - let (status, body) = get_json(app.clone(), "/users/me").await; - assert_eq!(status, StatusCode::UNAUTHORIZED); - assert_eq!(body["error"], "Unauthorized. Please log in first."); - } -} diff --git a/apps/server/src/handlers/__tests__/files.test.rs b/apps/server/src/handlers/__tests__/files.test.rs deleted file mode 100644 index c4fd8d3..0000000 --- a/apps/server/src/handlers/__tests__/files.test.rs +++ /dev/null @@ -1,150 +0,0 @@ -#[cfg(test)] -mod tests { - use super::super::{get_json, post_json, send_request, setup_app_logged_in}; - use axum::{ - body::Body, - http::{Request, StatusCode}, - }; - use serde_json::{json, Value}; - use tower::ServiceExt; - - #[tokio::test] - async fn test_drives_and_stats() { - let app = setup_app_logged_in(); - - // 1. Get Drives (/drive/list) - let (status_drives, body_drives) = get_json(app.clone(), "/drive/list").await; - assert_eq!(status_drives, StatusCode::OK); - assert!(body_drives.is_array()); - assert_eq!(body_drives.as_array().unwrap().len(), 2); - - // 2. Get Stats (/drive/stats) - let (status_stats, body_stats) = get_json(app.clone(), "/drive/stats").await; - assert_eq!(status_stats, StatusCode::OK); - assert!(body_stats["total_space"].is_number()); - assert!(body_stats["used_space"].is_number()); - assert!(body_stats["file_count"].is_number()); - } - - #[tokio::test] - async fn test_folders_lifecycle() { - let app = setup_app_logged_in(); - - // 1. List initial folders (parent_id = None) - let (status_folders, body_folders) = get_json(app.clone(), "/drive/folders").await; - assert_eq!(status_folders, StatusCode::OK); - assert!(body_folders.is_array()); - let initial_len = body_folders.as_array().unwrap().len(); - - // 2. Create Folder - let payload_create = json!({ - "name": "TestFolder", - "parent_id": null - }); - let (status_create, body_create) = - post_json(app.clone(), "/drive/folders/create", payload_create).await; - assert_eq!(status_create, StatusCode::OK); - assert_eq!(body_create["name"], "TestFolder"); - let created_id = body_create["id"].as_str().unwrap(); - - // Verify folder count increased - let (_, body_folders_new) = get_json(app.clone(), "/drive/folders").await; - assert_eq!(body_folders_new.as_array().unwrap().len(), initial_len + 1); - - // 3. Delete Folder - let (status_delete, body_delete) = post_json( - app.clone(), - "/drive/folders/delete", - json!({ "id": created_id }), - ) - .await; - assert_eq!(status_delete, StatusCode::OK); - assert_eq!(body_delete["success"], true); - - // Verify folder count restored - let (_, body_folders_final) = get_json(app.clone(), "/drive/folders").await; - assert_eq!(body_folders_final.as_array().unwrap().len(), initial_len); - } - - #[tokio::test] - async fn test_files_lifecycle() { - let app = setup_app_logged_in(); - - // 1. List initial files - let (status_files, body_files) = get_json(app.clone(), "/drive/files").await; - assert_eq!(status_files, StatusCode::OK); - assert!(body_files.is_array()); - let initial_files_len = body_files.as_array().unwrap().len(); - - // 2. Upload part - let file_id = 9999i64; - let part_data = b"Hello from chunk data".to_vec(); - let uri_upload = format!( - "/files/upload-part?file_id={}&part_index=0&file_size=21&total_parts=1", - file_id - ); - - let req = Request::builder() - .method("POST") - .uri(uri_upload) - .header("content-type", "application/octet-stream") - .body(Body::from(part_data)) - .unwrap(); - - let res = app.clone().oneshot(req).await.unwrap(); - assert_eq!(res.status(), StatusCode::OK); - let res_bytes = axum::body::to_bytes(res.into_body(), 1024 * 1024) - .await - .unwrap(); - let res_json: Value = serde_json::from_slice(&res_bytes).unwrap(); - assert_eq!(res_json["success"], true); - - // 3. Save File - let payload_save = json!({ - "file_id": file_id, - "name": "saved_test_file.txt", - "size": 21i64, - "folder_id": null - }); - let (status_save, body_save) = - post_json(app.clone(), "/files/save-file", payload_save).await; - assert_eq!(status_save, StatusCode::OK); - assert_eq!(body_save["id"], file_id.to_string()); - assert_eq!(body_save["name"], "saved_test_file.txt"); - assert_eq!(body_save["icon_type"], "file"); - - // Verify file count increased - let (_, body_files_new) = get_json(app.clone(), "/drive/files").await; - assert_eq!( - body_files_new.as_array().unwrap().len(), - initial_files_len + 1 - ); - - // 4. Download/Get File - let (status_dl, dl_bytes) = send_request( - app.clone(), - "GET", - &format!("/files/download?file_id={}", file_id), - Body::empty(), - ) - .await; - assert_eq!(status_dl, StatusCode::OK); - assert_eq!(dl_bytes.to_vec(), b"Hello from chunk data".to_vec()); - - // 5. Delete File - let payload_delete = json!({ - "id": file_id - }); - let (status_delete, body_delete) = - post_json(app.clone(), "/files/delete", payload_delete).await; - assert_eq!(status_delete, StatusCode::OK); - assert_eq!(body_delete["success"], true); - - // Verify file count decreased - let (_, body_files_final) = get_json(app.clone(), "/drive/files").await; - assert_eq!( - body_files_final.as_array().unwrap().len(), - initial_files_len - ); - } -} diff --git a/apps/server/src/handlers/__tests__/mesh.test.rs b/apps/server/src/handlers/__tests__/mesh.test.rs new file mode 100644 index 0000000..c99be77 --- /dev/null +++ b/apps/server/src/handlers/__tests__/mesh.test.rs @@ -0,0 +1,262 @@ +use super::*; + +#[tokio::test] +async fn test_create_room() { + let app = setup_app(); + let (status, body) = post_json( + app, + "/mesh/rooms", + serde_json::json!({ + "name": "Living Room Party", + "mode": "sfu", + "host_peer_id": "host-device-1" + }), + ) + .await; + + assert_eq!(status, StatusCode::CREATED); + assert_eq!(body["name"], "Living Room Party"); + assert_eq!(body["mode"], "sfu"); + assert_eq!(body["host_peer_id"], "host-device-1"); + assert!(body["id"].is_string()); + assert_eq!(body["max_nodes"], 50); // SFU default +} + +#[tokio::test] +async fn test_create_room_direct_p2p_defaults() { + let app = setup_app(); + let (status, body) = post_json( + app, + "/mesh/rooms", + serde_json::json!({ + "name": "P2P Room", + "mode": "direct_p2p", + "host_peer_id": "host-1" + }), + ) + .await; + + assert_eq!(status, StatusCode::CREATED); + assert_eq!(body["max_nodes"], 15); // DirectP2P default +} + +#[tokio::test] +async fn test_create_room_daisy_chain_defaults() { + let app = setup_app(); + let (status, body) = post_json( + app, + "/mesh/rooms", + serde_json::json!({ + "name": "Daisy Room", + "mode": "daisy_chain", + "host_peer_id": "host-1" + }), + ) + .await; + + assert_eq!(status, StatusCode::CREATED); + assert_eq!(body["max_nodes"], 30); // DaisyChain default +} + +#[tokio::test] +async fn test_create_room_custom_max_nodes() { + let app = setup_app(); + let (status, body) = post_json( + app, + "/mesh/rooms", + serde_json::json!({ + "name": "Small Room", + "mode": "sfu", + "host_peer_id": "host-1", + "max_nodes": 5 + }), + ) + .await; + + assert_eq!(status, StatusCode::CREATED); + assert_eq!(body["max_nodes"], 5); +} + +#[tokio::test] +async fn test_list_rooms() { + let app = setup_app(); + + // Create two rooms + let _ = post_json( + app.clone(), + "/mesh/rooms", + serde_json::json!({ + "name": "Room A", + "mode": "sfu", + "host_peer_id": "host-1" + }), + ) + .await; + + let _ = post_json( + app.clone(), + "/mesh/rooms", + serde_json::json!({ + "name": "Room B", + "mode": "direct_p2p", + "host_peer_id": "host-2" + }), + ) + .await; + + let (status, body) = get_json(app, "/mesh/rooms").await; + + assert_eq!(status, StatusCode::OK); + assert!(body.as_array().unwrap().len() >= 2); +} + +#[tokio::test] +async fn test_get_room() { + let app = setup_app(); + + let (_, created) = post_json( + app.clone(), + "/mesh/rooms", + serde_json::json!({ + "name": "Get Test", + "mode": "sfu", + "host_peer_id": "host-1" + }), + ) + .await; + + let room_id = created["id"].as_str().unwrap(); + let (status, body) = get_json(app, &format!("/mesh/rooms/{}", room_id)).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["room"]["name"], "Get Test"); + assert!(body["peers"].as_array().unwrap().is_empty()); +} + +#[tokio::test] +async fn test_get_room_not_found() { + let app = setup_app(); + let (status, body) = get_json(app, "/mesh/rooms/nonexistent-id").await; + + assert_eq!(status, StatusCode::NOT_FOUND); + assert!(body["error"].as_str().unwrap().contains("not found")); +} + +#[tokio::test] +async fn test_delete_room() { + let app = setup_app(); + + let (_, created) = post_json( + app.clone(), + "/mesh/rooms", + serde_json::json!({ + "name": "Delete Test", + "mode": "sfu", + "host_peer_id": "host-1" + }), + ) + .await; + + let room_id = created["id"].as_str().unwrap(); + let (status, body) = delete_json(app.clone(), &format!("/mesh/rooms/{}", room_id)).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["success"], true); + + // Verify it's gone + let (status, _) = get_json(app, &format!("/mesh/rooms/{}", room_id)).await; + assert_eq!(status, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_delete_room_not_found() { + let app = setup_app(); + let (status, _) = delete_json(app, "/mesh/rooms/nonexistent-id").await; + assert_eq!(status, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_get_topology_empty() { + let app = setup_app(); + + let (_, created) = post_json( + app.clone(), + "/mesh/rooms", + serde_json::json!({ + "name": "Topo Test", + "mode": "daisy_chain", + "host_peer_id": "host-1" + }), + ) + .await; + + let room_id = created["id"].as_str().unwrap(); + let (status, body) = get_json(app, &format!("/mesh/rooms/{}/topology", room_id)).await; + + assert_eq!(status, StatusCode::OK); + assert!(body["edges"].as_array().unwrap().is_empty()); +} + +#[tokio::test] +async fn test_get_topology_not_found() { + let app = setup_app(); + let (status, _) = get_json(app, "/mesh/rooms/nonexistent/topology").await; + assert_eq!(status, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_report_rtt() { + let app = setup_app(); + + let (_, created) = post_json( + app.clone(), + "/mesh/rooms", + serde_json::json!({ + "name": "RTT Test", + "mode": "daisy_chain", + "host_peer_id": "host-1" + }), + ) + .await; + + let room_id = created["id"].as_str().unwrap(); + let (status, body) = post_json( + app, + &format!("/mesh/rooms/{}/topology/rtt", room_id), + serde_json::json!({ + "from_peer_id": "host-1", + "to_peer_id": "peer-1", + "rtt_ms": 12.5 + }), + ) + .await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["success"], true); +} + +#[tokio::test] +async fn test_report_rtt_room_not_found() { + let app = setup_app(); + let (status, _) = post_json( + app, + "/mesh/rooms/nonexistent/topology/rtt", + serde_json::json!({ + "from_peer_id": "a", + "to_peer_id": "b", + "rtt_ms": 10.0 + }), + ) + .await; + + assert_eq!(status, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_health_check_still_works() { + let app = setup_app(); + let (status, body) = get_json(app, "/health").await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["status"], "ok"); +} diff --git a/apps/server/src/handlers/__tests__/mock_service.rs b/apps/server/src/handlers/__tests__/mock_service.rs deleted file mode 100644 index a6cd7b8..0000000 --- a/apps/server/src/handlers/__tests__/mock_service.rs +++ /dev/null @@ -1,458 +0,0 @@ -use crate::services::{ - AuthResult, AuthState, Drive, DriveStats, FileMetadata, FolderMetadata, TelegramService, - TelegramUser, -}; -use chrono::Utc; -use std::collections::HashMap; -use std::sync::Arc; -use tokio::sync::Mutex; - -#[derive(Debug)] -struct MockDb { - auth_state: AuthState, - folders: Vec, - files: Vec, - drives: Vec, - uploaded_chunks: HashMap)>>, // file_id -> vector of (part_index, bytes) -} - -#[derive(Clone, Debug)] -pub struct MockTelegramService { - db: Arc>, -} - -impl MockTelegramService { - pub fn new() -> Self { - Self::new_with_state(AuthState::LoggedOut) - } - - pub fn new_logged_in() -> Self { - Self::new_with_state(AuthState::LoggedIn) - } - - pub fn new_with_state(auth_state: AuthState) -> Self { - let initial_folders = vec![ - FolderMetadata { - id: 1, - parent_id: None, - name: "Documents".to_string(), - }, - FolderMetadata { - id: 2, - parent_id: None, - name: "Images".to_string(), - }, - FolderMetadata { - id: 3, - parent_id: None, - name: "Backups".to_string(), - }, - FolderMetadata { - id: 4, - parent_id: Some(1), - name: "Invoices".to_string(), - }, - ]; - - let initial_files = vec![ - FileMetadata { - id: 101, - folder_id: None, - name: "resume.pdf".to_string(), - size: 358400, - mime_type: Some("application/pdf".to_string()), - file_ext: Some("pdf".to_string()), - created_at: Utc::now().to_rfc3339(), - icon_type: "pdf".to_string(), - telegram_message_id: None, - }, - FileMetadata { - id: 102, - folder_id: Some(2), - name: "profile_pic.jpg".to_string(), - size: 870400, - mime_type: Some("image/jpeg".to_string()), - file_ext: Some("jpg".to_string()), - created_at: Utc::now().to_rfc3339(), - icon_type: "image".to_string(), - telegram_message_id: Some(202), - }, - FileMetadata { - id: 103, - folder_id: Some(3), - name: "database_dump.sql.gz".to_string(), - size: 47185920, - mime_type: Some("application/gzip".to_string()), - file_ext: Some("gz".to_string()), - created_at: Utc::now().to_rfc3339(), - icon_type: "archive".to_string(), - telegram_message_id: Some(303), - }, - ]; - - let initial_drives = vec![ - Drive { - chat_id: -1001987654321, - name: "AudioMesh Cloud Drive".to_string(), - icon: None, - }, - Drive { - chat_id: -1001234567890, - name: "Shared Assets Channel".to_string(), - icon: None, - }, - ]; - - let db = MockDb { - auth_state, - folders: initial_folders, - files: initial_files, - drives: initial_drives, - uploaded_chunks: HashMap::new(), - }; - - Self { - db: Arc::new(Mutex::new(db)), - } - } -} - -#[axum::async_trait] -impl TelegramService for MockTelegramService { - async fn get_auth_state(&self) -> AuthState { - let db = self.db.lock().await; - db.auth_state.clone() - } - - async fn send_code( - &self, - phone: &str, - _api_id: i32, - _api_hash: &str, - ) -> Result { - let mut db = self.db.lock().await; - if phone.is_empty() { - return Err("Phone number cannot be empty".to_string()); - } - - db.auth_state = AuthState::AwaitingCode { - phone: phone.to_string(), - phone_code_hash: "mock_hash_xyz123".to_string(), - }; - - Ok(AuthResult { - success: true, - next_step: Some("code".to_string()), - error: None, - }) - } - - async fn sign_in( - &self, - phone: &str, - _phone_code_hash: &str, - code: &str, - ) -> Result { - let mut db = self.db.lock().await; - - // Simulating different login outcomes based on code input - if code == "2fa" || code == "22222" { - db.auth_state = AuthState::AwaitingPassword { - phone: phone.to_string(), - }; - Ok(AuthResult { - success: true, - next_step: Some("password".to_string()), - error: None, - }) - } else if code == "12345" || code == "11111" { - db.auth_state = AuthState::LoggedIn; - Ok(AuthResult { - success: true, - next_step: Some("dashboard".to_string()), - error: None, - }) - } else { - Ok(AuthResult { - success: false, - next_step: Some("code".to_string()), - error: Some("Invalid authentication code. Use '12345' for direct login or '2fa' for 2FA password verification.".to_string()), - }) - } - } - - async fn check_password(&self, password: &str) -> Result { - let mut db = self.db.lock().await; - - if password == "password" || password == "admin" { - db.auth_state = AuthState::LoggedIn; - Ok(AuthResult { - success: true, - next_step: Some("dashboard".to_string()), - error: None, - }) - } else { - Ok(AuthResult { - success: false, - next_step: Some("password".to_string()), - error: Some("Invalid 2FA password. Try 'password'.".to_string()), - }) - } - } - - async fn log_out(&self) -> Result { - let mut db = self.db.lock().await; - db.auth_state = AuthState::LoggedOut; - Ok(true) - } - - async fn reset_authorization(&self) -> Result { - let mut db = self.db.lock().await; - db.auth_state = AuthState::LoggedOut; - Ok(true) - } - - async fn update_credentials(&self, _api_id: i32, _api_hash: &str) -> Result { - Ok(true) - } - - async fn get_me(&self) -> Result { - Ok(TelegramUser { - id: 123456789, - first_name: "Mock".to_string(), - last_name: Some("User".to_string()), - username: Some("mock_telegram_user".to_string()), - phone: Some("+15555555555".to_string()), - }) - } - - async fn get_users(&self) -> Result, String> { - Ok(vec![ - TelegramUser { - id: 123456789, - first_name: "Mock".to_string(), - last_name: Some("User".to_string()), - username: Some("mock_telegram_user".to_string()), - phone: Some("+15555555555".to_string()), - }, - TelegramUser { - id: 987654321, - first_name: "John".to_string(), - last_name: Some("Doe".to_string()), - username: Some("johndoe".to_string()), - phone: None, - }, - ]) - } - - async fn get_full_user(&self, user_id: i64) -> Result { - if user_id == 123456789 { - self.get_me().await - } else { - Ok(TelegramUser { - id: user_id, - first_name: "External".to_string(), - last_name: Some("User".to_string()), - username: Some("external_user".to_string()), - phone: None, - }) - } - } - - async fn update_profile( - &self, - _first_name: &str, - _last_name: Option<&str>, - ) -> Result { - Ok(true) - } - - async fn update_status(&self, _offline: bool) -> Result { - Ok(true) - } - - async fn update_username(&self, _username: &str) -> Result { - Ok(true) - } - - async fn get_drives(&self) -> Result, String> { - let db = self.db.lock().await; - Ok(db.drives.clone()) - } - - async fn get_stats(&self) -> Result { - let db = self.db.lock().await; - let total_size: i64 = db.files.iter().map(|f| f.size).sum(); - Ok(DriveStats { - total_space: 10995116277760, // 10 TB simulated capacity - used_space: total_size, - file_count: db.files.len() as i64, - folder_count: db.folders.len() as i64, - }) - } - - async fn get_folders(&self, parent_id: Option) -> Result, String> { - let db = self.db.lock().await; - let filtered = db - .folders - .iter() - .filter(|f| f.parent_id == parent_id) - .cloned() - .collect(); - Ok(filtered) - } - - async fn get_files( - &self, - folder_id: Option, - search_query: Option<&str>, - all: Option, - ) -> Result, String> { - let db = self.db.lock().await; - let mut filtered: Vec = db - .files - .iter() - .filter(|f| all == Some(true) || f.folder_id == folder_id) - .cloned() - .collect(); - - if let Some(query) = search_query { - let query_lower = query.to_lowercase(); - filtered.retain(|f| f.name.to_lowercase().contains(&query_lower)); - } - - Ok(filtered) - } - - async fn create_folder( - &self, - name: &str, - parent_id: Option, - ) -> Result { - let mut db = self.db.lock().await; - if name.is_empty() { - return Err("Folder name cannot be empty".to_string()); - } - - let new_id = (db.folders.len() + 1) as i64; - let new_folder = FolderMetadata { - id: new_id, - parent_id, - name: name.to_string(), - }; - - db.folders.push(new_folder.clone()); - Ok(new_folder) - } - - async fn delete_folder(&self, id: i64) -> Result { - let mut db = self.db.lock().await; - let initial_len = db.folders.len(); - db.folders.retain(|f| f.id != id); - - // Cascade delete or un-parent files/folders - for folder in db.folders.iter_mut() { - if folder.parent_id == Some(id) { - folder.parent_id = None; - } - } - for file in db.files.iter_mut() { - if file.folder_id == Some(id) { - file.folder_id = None; - } - } - - Ok(db.folders.len() < initial_len) - } - - async fn delete_file(&self, id: i64) -> Result { - let mut db = self.db.lock().await; - let initial_len = db.files.len(); - db.files.retain(|f| f.id != id); - db.uploaded_chunks.remove(&id); - Ok(db.files.len() < initial_len) - } - - async fn upload_part( - &self, - file_id: i64, - part_index: i32, - _file_size: i64, - _total_parts: i32, - _byte_offset: i64, - bytes: Vec, - ) -> Result { - let mut db = self.db.lock().await; - let parts = db.uploaded_chunks.entry(file_id).or_insert_with(Vec::new); - parts.push((part_index, bytes)); - Ok(true) - } - - async fn save_file( - &self, - file_id: i64, - name: &str, - size: i64, - folder_id: Option, - ) -> Result { - let mut db = self.db.lock().await; - - // Deduce file extension - let file_ext = name.split('.').last().map(|s| s.to_string()); - - // Determine icon_type - let icon_type = match file_ext.as_deref() { - Some("pdf") => "pdf".to_string(), - Some("png") | Some("jpg") | Some("jpeg") | Some("gif") | Some("svg") => { - "image".to_string() - } - Some("zip") | Some("tar") | Some("gz") | Some("rar") => "archive".to_string(), - Some("mp4") | Some("mkv") | Some("avi") | Some("mov") => "video".to_string(), - Some("mp3") | Some("wav") | Some("ogg") | Some("m4a") | Some("flac") => { - "audio".to_string() - } - Some("js") | Some("ts") | Some("tsx") | Some("rs") | Some("py") | Some("json") - | Some("css") | Some("html") => "code".to_string(), - Some("csv") | Some("xlsx") | Some("xls") => "csv".to_string(), - _ => "file".to_string(), - }; - - let new_file = FileMetadata { - id: file_id, - folder_id, - name: name.to_string(), - size, - mime_type: Some("application/octet-stream".to_string()), - file_ext, - created_at: Utc::now().to_rfc3339(), - icon_type, - telegram_message_id: Some(12345), - }; - - db.files.push(new_file.clone()); - Ok(new_file) - } - - async fn download_file(&self, file_id: i64) -> Result, String> { - let db = self.db.lock().await; - - // Reassemble file parts - if let Some(parts) = db.uploaded_chunks.get(&file_id) { - let mut sorted_parts = parts.clone(); - sorted_parts.sort_by_key(|p| p.0); - - let mut full_file = Vec::new(); - for (_, chunk) in sorted_parts { - full_file.extend(chunk); - } - Ok(full_file) - } else { - // Return some dummy payload for initial mock files - Ok(format!("This is the content of mock file ID: {}", file_id).into_bytes()) - } - } - - async fn get_upload_progress(&self, _file_id: i64) -> Result { - Ok(100) - } -} diff --git a/apps/server/src/handlers/__tests__/mod.rs b/apps/server/src/handlers/__tests__/mod.rs index 2b2105f..44e5ede 100644 --- a/apps/server/src/handlers/__tests__/mod.rs +++ b/apps/server/src/handlers/__tests__/mod.rs @@ -1,36 +1,32 @@ -#[path = "auth.test.rs"] -mod auth_test; - -#[path = "files.test.rs"] -mod files_test; - -#[path = "users.test.rs"] -mod users_test; - -mod mock_service; +#[path = "mesh.test.rs"] +mod mesh_test; // Common test helper imports +use crate::config::AppConfig; use crate::handlers::create_router; use axum::{ body::{Body, Bytes}, http::{Request, StatusCode}, Router, }; -use mock_service::MockTelegramService; use serde_json::Value; -use std::sync::Arc; use tower::ServiceExt; // for oneshot -// Helper function to create the test router -fn setup_app() -> Router { - let service = Arc::new(MockTelegramService::new()); - create_router(service) +/// Default test configuration with permissive limits. +fn test_config() -> AppConfig { + AppConfig { + host: "127.0.0.1".to_string(), + port: 50065, + max_rooms: 100, + max_nodes_per_room: 50, + sfu_buffer_size: 256, + max_children_per_node: 3, + } } -// Helper function to create the test router in logged in state -fn setup_app_logged_in() -> Router { - let service = Arc::new(MockTelegramService::new_logged_in()); - create_router(service) +// Helper function to create the test router +fn setup_app() -> Router { + create_router(&test_config()) } // Helper to send a general request to the router @@ -64,3 +60,10 @@ async fn get_json(app: Router, uri: &str) -> (StatusCode, Value) { let json_val: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null); (status, json_val) } + +// Helper to send a DELETE request +async fn delete_json(app: Router, uri: &str) -> (StatusCode, Value) { + let (status, bytes) = send_request(app, "DELETE", uri, Body::empty()).await; + let json_val: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null); + (status, json_val) +} diff --git a/apps/server/src/handlers/__tests__/users.test.rs b/apps/server/src/handlers/__tests__/users.test.rs deleted file mode 100644 index 99b4c9d..0000000 --- a/apps/server/src/handlers/__tests__/users.test.rs +++ /dev/null @@ -1,78 +0,0 @@ -#[cfg(test)] -mod tests { - use super::super::{get_json, post_json, setup_app_logged_in}; - use axum::http::StatusCode; - use serde_json::json; - - #[tokio::test] - async fn test_users_endpoints() { - let app = setup_app_logged_in(); - - // 1. Get Me (/users/me) - let (status_me, body_me) = get_json(app.clone(), "/users/me").await; - assert_eq!(status_me, StatusCode::OK); - assert_eq!(body_me["id"], "123456789"); - assert_eq!(body_me["first_name"], "Mock"); - assert_eq!(body_me["username"], "mock_telegram_user"); - - // 2. Get Users (/users/get-users) - let (status_users, body_users) = get_json(app.clone(), "/users/get-users").await; - assert_eq!(status_users, StatusCode::OK); - assert!(body_users.is_array()); - let users_arr = body_users.as_array().unwrap(); - assert_eq!(users_arr.len(), 2); - assert_eq!(users_arr[0]["id"], "123456789"); - assert_eq!(users_arr[1]["id"], "987654321"); - - // 3. Get Full User (/users/get-full-user?id=123456789) - let (status_full1, body_full1) = - get_json(app.clone(), "/users/get-full-user?id=123456789").await; - assert_eq!(status_full1, StatusCode::OK); - assert_eq!(body_full1["id"], "123456789"); - - let (status_full2, body_full2) = get_json(app.clone(), "/users/get-full-user?id=999").await; - assert_eq!(status_full2, StatusCode::OK); - assert_eq!(body_full2["id"], "999"); - assert_eq!(body_full2["first_name"], "External"); - } - - #[tokio::test] - async fn test_account_endpoints() { - let app = setup_app_logged_in(); - - // 1. Update Profile (/account/update-profile) - let payload_profile = json!({ - "first_name": "NewFirstName", - "last_name": "NewLastName" - }); - let (status_prof, body_prof) = - post_json(app.clone(), "/account/update-profile", payload_profile).await; - assert_eq!(status_prof, StatusCode::OK); - assert_eq!(body_prof["success"], true); - - // 2. Update Status (/account/update-status) - let (status_stat, body_stat) = post_json( - app.clone(), - "/account/update-status", - json!({ "offline": true }), - ) - .await; - assert_eq!(status_stat, StatusCode::OK); - assert_eq!(body_stat["success"], true); - - // 3. Update Username (/account/update-username) - let (status_user, body_user) = post_json( - app.clone(), - "/account/update-username", - json!({ "username": "new_username" }), - ) - .await; - assert_eq!(status_user, StatusCode::OK); - assert_eq!(body_user["success"], true); - - // 4. Get Password settings (/account/get-password) - let (status_pw, body_pw) = get_json(app.clone(), "/account/get-password").await; - assert_eq!(status_pw, StatusCode::OK); - assert_eq!(body_pw["has_password"], true); - } -} diff --git a/apps/server/src/handlers/auth.rs b/apps/server/src/handlers/auth.rs deleted file mode 100644 index 5236b56..0000000 --- a/apps/server/src/handlers/auth.rs +++ /dev/null @@ -1,159 +0,0 @@ -use crate::services::DynTelegramService; -use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; -use serde::Deserialize; - -#[derive(Debug, Deserialize)] -pub struct SendCodePayload { - pub phone: String, - pub api_id: String, - pub api_hash: String, -} - -#[derive(Debug, Deserialize)] -pub struct SignInPayload { - pub phone: String, - pub phone_code_hash: Option, - pub code: String, -} - -#[derive(Debug, Deserialize)] -pub struct CheckPasswordPayload { - pub password: String, -} - -pub async fn get_auth_state(State(service): State) -> impl IntoResponse { - let state = service.get_auth_state().await; - Json(state) -} - -pub async fn send_code( - State(service): State, - Json(payload): Json, -) -> impl IntoResponse { - // Parse api_id as integer - let api_id_parsed = match payload.api_id.parse::() { - Ok(val) => val, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "success": false, - "error": "Invalid API ID format. Must be a valid integer." - })), - ) - .into_response(); - } - }; - - match service - .send_code(&payload.phone, api_id_parsed, &payload.api_hash) - .await - { - Ok(result) => (StatusCode::OK, Json(result)).into_response(), - Err(err) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "success": false, "error": err })), - ) - .into_response(), - } -} - -pub async fn sign_in( - State(service): State, - Json(payload): Json, -) -> impl IntoResponse { - let hash = payload.phone_code_hash.unwrap_or_default(); - match service.sign_in(&payload.phone, &hash, &payload.code).await { - Ok(result) => (StatusCode::OK, Json(result)).into_response(), - Err(err) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "success": false, "error": err })), - ) - .into_response(), - } -} - -pub async fn check_password( - State(service): State, - Json(payload): Json, -) -> impl IntoResponse { - match service.check_password(&payload.password).await { - Ok(result) => (StatusCode::OK, Json(result)).into_response(), - Err(err) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "success": false, "error": err })), - ) - .into_response(), - } -} - -pub async fn log_out(State(service): State) -> impl IntoResponse { - match service.log_out().await { - Ok(success) => ( - StatusCode::OK, - Json(serde_json::json!({ "success": success })), - ) - .into_response(), - Err(err) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "success": false, "error": err })), - ) - .into_response(), - } -} - -pub async fn reset_authorization(State(service): State) -> impl IntoResponse { - match service.reset_authorization().await { - Ok(success) => ( - StatusCode::OK, - Json(serde_json::json!({ "success": success })), - ) - .into_response(), - Err(err) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "success": false, "error": err })), - ) - .into_response(), - } -} - -#[derive(Debug, Deserialize)] -pub struct UpdateCredentialsPayload { - pub api_id: String, - pub api_hash: String, -} - -pub async fn update_credentials( - State(service): State, - Json(payload): Json, -) -> impl IntoResponse { - let api_id_parsed = match payload.api_id.parse::() { - Ok(val) => val, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "success": false, - "error": "Invalid API ID format. Must be a valid integer." - })), - ) - .into_response(); - } - }; - - match service - .update_credentials(api_id_parsed, &payload.api_hash) - .await - { - Ok(success) => ( - StatusCode::OK, - Json(serde_json::json!({ "success": success })), - ) - .into_response(), - Err(err) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "success": false, "error": err })), - ) - .into_response(), - } -} diff --git a/apps/server/src/handlers/files.rs b/apps/server/src/handlers/files.rs deleted file mode 100644 index 705f17e..0000000 --- a/apps/server/src/handlers/files.rs +++ /dev/null @@ -1,323 +0,0 @@ -use crate::services::{ - deserialize_i64_from_string_or_number, serde_option_i64_string, DynTelegramService, -}; -use axum::{ - body::Bytes, - extract::{Query, State}, - http::{header, HeaderMap, StatusCode}, - response::{ - sse::{Event, Sse}, - IntoResponse, - }, - Json, -}; -use futures_util::stream::Stream; -use serde::Deserialize; -use std::convert::Infallible; - -#[derive(Debug, Deserialize)] -pub struct UploadPartQuery { - #[serde(deserialize_with = "deserialize_i64_from_string_or_number")] - pub file_id: i64, - pub part_index: i32, - pub file_size: i64, - pub total_parts: i32, - #[serde(default)] - pub byte_offset: i64, -} - -#[derive(Debug, Deserialize)] -pub struct SaveFilePayload { - #[serde(deserialize_with = "deserialize_i64_from_string_or_number")] - pub file_id: i64, - pub name: String, - pub size: i64, - #[serde(default, deserialize_with = "serde_option_i64_string::deserialize")] - pub folder_id: Option, -} - -#[derive(Debug, Deserialize)] -pub struct FileDownloadQuery { - #[serde(deserialize_with = "deserialize_i64_from_string_or_number")] - pub file_id: i64, -} - -#[derive(Debug, Deserialize)] -pub struct DriveFilesQuery { - #[serde(default, deserialize_with = "serde_option_i64_string::deserialize")] - pub folder_id: Option, - pub q: Option, - pub all: Option, -} - -#[derive(Debug, Deserialize)] -pub struct DriveFoldersQuery { - #[serde(default, deserialize_with = "serde_option_i64_string::deserialize")] - pub parent_id: Option, -} - -#[derive(Debug, Deserialize)] -pub struct CreateFolderPayload { - pub name: String, - #[serde(default, deserialize_with = "serde_option_i64_string::deserialize")] - pub parent_id: Option, -} - -#[derive(Debug, Deserialize)] -pub struct DeleteFolderPayload { - #[serde(deserialize_with = "deserialize_i64_from_string_or_number")] - pub id: i64, -} - -#[derive(Debug, Deserialize)] -pub struct DeleteFilePayload { - #[serde(deserialize_with = "deserialize_i64_from_string_or_number")] - pub id: i64, -} - -pub async fn upload_part( - State(service): State, - Query(query): Query, - body: Bytes, -) -> impl IntoResponse { - match service - .upload_part( - query.file_id, - query.part_index, - query.file_size, - query.total_parts, - query.byte_offset, - body.to_vec(), - ) - .await - { - Ok(success) => ( - StatusCode::OK, - Json(serde_json::json!({ "success": success })), - ) - .into_response(), - Err(err) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "success": false, "error": err })), - ) - .into_response(), - } -} - -pub async fn save_file( - State(service): State, - Json(payload): Json, -) -> impl IntoResponse { - match service - .save_file( - payload.file_id, - &payload.name, - payload.size, - payload.folder_id, - ) - .await - { - Ok(file) => (StatusCode::OK, Json(file)).into_response(), - Err(err) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "error": err })), - ) - .into_response(), - } -} - -pub async fn download_file( - State(service): State, - Query(query): Query, -) -> impl IntoResponse { - match service.download_file(query.file_id).await { - Ok(bytes) => { - let mut headers = HeaderMap::new(); - headers.insert( - header::CONTENT_TYPE, - "application/octet-stream".parse().unwrap(), - ); - headers.insert( - header::CONTENT_DISPOSITION, - format!("attachment; filename=\"file_{}\"", query.file_id) - .parse() - .unwrap(), - ); - (StatusCode::OK, headers, bytes).into_response() - } - Err(err) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "error": err })), - ) - .into_response(), - } -} - -pub async fn get_drives(State(service): State) -> impl IntoResponse { - match service.get_drives().await { - Ok(drives) => (StatusCode::OK, Json(drives)).into_response(), - Err(err) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "error": err })), - ) - .into_response(), - } -} - -pub async fn get_stats(State(service): State) -> impl IntoResponse { - match service.get_stats().await { - Ok(stats) => (StatusCode::OK, Json(stats)).into_response(), - Err(err) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "error": err })), - ) - .into_response(), - } -} - -pub async fn get_folders( - State(service): State, - Query(query): Query, -) -> impl IntoResponse { - match service.get_folders(query.parent_id).await { - Ok(folders) => (StatusCode::OK, Json(folders)).into_response(), - Err(err) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "error": err })), - ) - .into_response(), - } -} - -pub async fn get_files( - State(service): State, - Query(query): Query, -) -> impl IntoResponse { - match service - .get_files(query.folder_id, query.q.as_deref(), query.all) - .await - { - Ok(files) => (StatusCode::OK, Json(files)).into_response(), - Err(err) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "error": err })), - ) - .into_response(), - } -} - -pub async fn create_folder( - State(service): State, - Json(payload): Json, -) -> impl IntoResponse { - match service - .create_folder(&payload.name, payload.parent_id) - .await - { - Ok(folder) => (StatusCode::OK, Json(folder)).into_response(), - Err(err) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "error": err })), - ) - .into_response(), - } -} - -pub async fn delete_folder( - State(service): State, - Json(payload): Json, -) -> impl IntoResponse { - match service.delete_folder(payload.id).await { - Ok(success) => ( - StatusCode::OK, - Json(serde_json::json!({ "success": success })), - ) - .into_response(), - Err(err) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "success": false, "error": err })), - ) - .into_response(), - } -} - -pub async fn delete_file( - State(service): State, - Json(payload): Json, -) -> impl IntoResponse { - match service.delete_file(payload.id).await { - Ok(success) => ( - StatusCode::OK, - Json(serde_json::json!({ "success": success })), - ) - .into_response(), - Err(err) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "success": false, "error": err })), - ) - .into_response(), - } -} - -#[derive(Debug, Deserialize)] -pub struct UploadProgressQuery { - #[serde(deserialize_with = "deserialize_i64_from_string_or_number")] - pub file_id: i64, -} - -pub async fn get_upload_progress( - State(service): State, - Query(query): Query, -) -> impl IntoResponse { - match service.get_upload_progress(query.file_id).await { - Ok(percent) => ( - StatusCode::OK, - Json(serde_json::json!({ "progress": percent })), - ) - .into_response(), - Err(err) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "error": err })), - ) - .into_response(), - } -} - -pub async fn get_upload_progress_stream( - State(service): State, - Query(query): Query, -) -> Sse>> { - let file_id = query.file_id; - let stream = futures_util::stream::unfold( - (service.clone(), file_id, -1, false), - |(service, file_id, mut last_percent, mut finished)| async move { - if finished { - return None; - } - loop { - let percent = match service.get_upload_progress(file_id).await { - Ok(p) => p, - Err(_) => 0, - }; - if percent != last_percent { - last_percent = percent; - let event = Event::default().data(percent.to_string()); - if percent >= 100 { - finished = true; - } - return Some(( - Ok::(event), - (service, file_id, last_percent, finished), - )); - } - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - } - }, - ); - - Sse::new(stream).keep_alive( - axum::response::sse::KeepAlive::new() - .interval(std::time::Duration::from_secs(1)) - .text("keep-alive-text"), - ) -} diff --git a/apps/server/src/handlers/mesh.rs b/apps/server/src/handlers/mesh.rs new file mode 100644 index 0000000..69c1fad --- /dev/null +++ b/apps/server/src/handlers/mesh.rs @@ -0,0 +1,549 @@ +use crate::services::mesh::{ + room_registry::RoomRegistry, time_sync::TimeSyncService, topology::TopologyManager, + ConnectionType, DeviceType, MeshMode, Peer, +}; +use axum::{ + extract::{ + ws::{Message, WebSocket}, + Path, State, WebSocketUpgrade, + }, + http::StatusCode, + response::IntoResponse, + Json, +}; +use chrono::Utc; +use futures_util::{SinkExt, StreamExt}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +/// Shared application state for all mesh endpoints. +#[derive(Clone)] +pub struct MeshState { + pub registry: Arc, + pub topology: Arc, + pub time_sync: Arc, +} + +// ─── Request / Response Payloads ─────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +pub struct CreateRoomPayload { + pub name: String, + pub mode: MeshMode, + #[serde(default)] + pub max_nodes: Option, + pub host_peer_id: String, +} + +#[derive(Debug, Deserialize)] +pub struct ReportRttPayload { + pub from_peer_id: String, + pub to_peer_id: String, + pub rtt_ms: f64, +} + +/// Signaling messages exchanged over the signaling WebSocket. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum SignalingMessage { + /// Client -> Server: Join the signaling room. + Join { + peer_id: String, + display_name: String, + device_type: DeviceType, + connection_type: ConnectionType, + }, + /// Server -> Client: Current list of peers in the room. + Peers { peers: Vec }, + /// Client -> Server: SDP offer to a specific peer. + Offer { + #[serde(default, skip_serializing_if = "Option::is_none")] + from_peer_id: Option, + to_peer_id: String, + sdp: String, + }, + /// Client -> Server: SDP answer to a specific peer. + Answer { + #[serde(default, skip_serializing_if = "Option::is_none")] + from_peer_id: Option, + to_peer_id: String, + sdp: String, + }, + /// Client -> Server: ICE candidate for a specific peer. + Ice { + #[serde(default, skip_serializing_if = "Option::is_none")] + from_peer_id: Option, + to_peer_id: String, + candidate: String, + }, + /// Server -> Client: Topology assignment (Daisy-Chain mode). + TopologyAssign { parent_peer_id: String }, + /// Server -> Client: A peer has left the room. + PeerLeft { peer_id: String }, + /// Server -> Client: Error message. + Error { message: String }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PeerInfo { + pub id: String, + pub display_name: String, + pub device_type: DeviceType, +} + +// ─── REST Handlers ───────────────────────────────────────────────────────────── + +/// POST /mesh/rooms — Create a new mesh room. +pub async fn create_room( + State(state): State, + Json(payload): Json, +) -> impl IntoResponse { + match state.registry.create_room( + payload.name, + payload.mode.clone(), + payload.host_peer_id, + payload.max_nodes, + ) { + Ok(room) => { + // Initialize topology tree for Daisy-Chain mode + if payload.mode == MeshMode::DaisyChain { + state.topology.init_room(&room.id); + } + (StatusCode::CREATED, Json(serde_json::json!(room))).into_response() + } + Err(err) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": err })), + ) + .into_response(), + } +} + +/// GET /mesh/rooms — List all active rooms. +pub async fn list_rooms(State(state): State) -> impl IntoResponse { + let rooms = state.registry.list_rooms(); + Json(serde_json::json!(rooms)) +} + +/// GET /mesh/rooms/:id — Get a specific room with its peer list. +pub async fn get_room( + State(state): State, + Path(id): Path, +) -> impl IntoResponse { + match state.registry.get_room(&id) { + Some(room) => { + let peers = state.registry.get_peers(&id); + ( + StatusCode::OK, + Json(serde_json::json!({ "room": room, "peers": peers })), + ) + .into_response() + } + None => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Room not found." })), + ) + .into_response(), + } +} + +/// DELETE /mesh/rooms/:id — Close and delete a room. +pub async fn delete_room( + State(state): State, + Path(id): Path, +) -> impl IntoResponse { + state.topology.remove_room(&id); + if state.registry.delete_room(&id) { + ( + StatusCode::OK, + Json(serde_json::json!({ "success": true })), + ) + .into_response() + } else { + ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Room not found." })), + ) + .into_response() + } +} + +/// GET /mesh/rooms/:id/topology — Get the current connection tree. +pub async fn get_topology( + State(state): State, + Path(id): Path, +) -> impl IntoResponse { + if state.registry.get_room(&id).is_none() { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Room not found." })), + ) + .into_response(); + } + + let tree = state.topology.get_tree(&id); + (StatusCode::OK, Json(serde_json::json!({ "edges": tree }))).into_response() +} + +/// POST /mesh/rooms/:id/topology/rtt — Report RTT measurement between two peers. +pub async fn report_rtt( + State(state): State, + Path(id): Path, + Json(payload): Json, +) -> impl IntoResponse { + if state.registry.get_room(&id).is_none() { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Room not found." })), + ) + .into_response(); + } + + state + .topology + .update_rtt(&id, &payload.from_peer_id, &payload.to_peer_id, payload.rtt_ms); + + ( + StatusCode::OK, + Json(serde_json::json!({ "success": true })), + ) + .into_response() +} + +// ─── WebSocket Handlers ──────────────────────────────────────────────────────── + +/// GET /ws/signaling/:room_id — WebSocket signaling for SDP/ICE exchange. +pub async fn ws_signaling( + ws: WebSocketUpgrade, + State(state): State, + Path(room_id): Path, +) -> impl IntoResponse { + ws.on_upgrade(move |socket| handle_signaling(socket, state, room_id)) +} + +/// GET /ws/sfu/:room_id — WebSocket SFU media channel. +pub async fn ws_sfu( + ws: WebSocketUpgrade, + State(state): State, + Path(room_id): Path, +) -> impl IntoResponse { + ws.on_upgrade(move |socket| handle_sfu(socket, state, room_id)) +} + +/// GET /ws/sync/:room_id — WebSocket time synchronization channel. +pub async fn ws_sync( + ws: WebSocketUpgrade, + State(state): State, + Path(room_id): Path, +) -> impl IntoResponse { + ws.on_upgrade(move |socket| handle_sync(socket, state, room_id)) +} + +// ─── WebSocket Implementation ────────────────────────────────────────────────── + +/// Handles the signaling WebSocket connection for a single peer. +/// +/// Protocol: +/// 1. Client sends a `join` message with peer info. +/// 2. Server registers the peer and broadcasts the updated peer list. +/// 3. Client sends `offer`, `answer`, `ice` messages targeted at specific peers. +/// 4. Server forwards these messages to the targeted peer. +/// 5. On disconnect, server removes the peer and broadcasts `peer_left`. +async fn handle_signaling(socket: WebSocket, state: MeshState, room_id: String) { + let (mut sender, mut receiver) = socket.split(); + let mut my_peer_id: Option = None; + + while let Some(msg) = receiver.next().await { + let msg = match msg { + Ok(Message::Text(text)) => text, + Ok(Message::Close(_)) | Err(_) => break, + _ => continue, + }; + + let parsed: SignalingMessage = match serde_json::from_str(&msg) { + Ok(m) => m, + Err(e) => { + let err = SignalingMessage::Error { + message: format!("Invalid message: {}", e), + }; + let _ = sender + .send(Message::Text(serde_json::to_string(&err).unwrap().into())) + .await; + continue; + } + }; + + match parsed { + SignalingMessage::Join { + peer_id, + display_name, + device_type, + connection_type, + } => { + let peer = Peer { + id: peer_id.clone(), + display_name: display_name.clone(), + device_type: device_type.clone(), + connection_type, + joined_at: Utc::now(), + parent_peer_id: None, + }; + + if let Err(e) = state.registry.add_peer(&room_id, peer) { + let err = SignalingMessage::Error { message: e }; + let _ = sender + .send(Message::Text(serde_json::to_string(&err).unwrap().into())) + .await; + continue; + } + + my_peer_id = Some(peer_id.clone()); + + // If Daisy-Chain mode, assign a parent and notify the peer + if let Some(room) = state.registry.get_room(&room_id) { + if room.mode == MeshMode::DaisyChain { + let all_peers: Vec = state + .registry + .get_peers(&room_id) + .iter() + .map(|p| p.id.clone()) + .collect(); + + if let Some(parent) = state.topology.assign_parent( + &room_id, + &peer_id, + &room.host_peer_id, + &all_peers, + ) { + state + .registry + .set_peer_parent(&room_id, &peer_id, Some(parent.clone())); + + let assign = SignalingMessage::TopologyAssign { + parent_peer_id: parent, + }; + let _ = sender + .send(Message::Text( + serde_json::to_string(&assign).unwrap().into(), + )) + .await; + } + } + } + + // Send current peer list + let peers: Vec = state + .registry + .get_peers(&room_id) + .iter() + .map(|p| PeerInfo { + id: p.id.clone(), + display_name: p.display_name.clone(), + device_type: p.device_type.clone(), + }) + .collect(); + + let peers_msg = SignalingMessage::Peers { peers }; + let _ = sender + .send(Message::Text( + serde_json::to_string(&peers_msg).unwrap().into(), + )) + .await; + } + + // For offer/answer/ice: In a full implementation, we would maintain + // a map of peer_id -> sender and forward messages to the target peer. + // For now, we acknowledge receipt. The forwarding mechanism requires + // a shared sender registry (covered by the broadcast channel pattern). + SignalingMessage::Offer { to_peer_id, sdp, .. } => { + if let Some(ref pid) = my_peer_id { + tracing::info!( + "Signaling: {} -> {} (offer, {} bytes SDP)", + pid, + to_peer_id, + sdp.len() + ); + } + // TODO: Forward to target peer via shared sender registry + } + + SignalingMessage::Answer { to_peer_id, sdp, .. } => { + if let Some(ref pid) = my_peer_id { + tracing::info!( + "Signaling: {} -> {} (answer, {} bytes SDP)", + pid, + to_peer_id, + sdp.len() + ); + } + // TODO: Forward to target peer via shared sender registry + } + + SignalingMessage::Ice { + to_peer_id, + candidate, + .. + } => { + if let Some(ref pid) = my_peer_id { + tracing::info!( + "Signaling: {} -> {} (ice, {} bytes)", + pid, + to_peer_id, + candidate.len() + ); + } + // TODO: Forward to target peer via shared sender registry + } + + _ => { + // Ignore server-originated message types from clients + } + } + } + + // Cleanup on disconnect + if let Some(peer_id) = my_peer_id { + // Remove from topology first (to re-parent children) + let orphans = state.topology.remove_node(&room_id, &peer_id); + for orphan in &orphans { + tracing::info!( + "Daisy-chain self-heal: re-parented '{}' after '{}' disconnected", + orphan, + peer_id + ); + } + + state.registry.remove_peer(&room_id, &peer_id); + tracing::info!("Peer '{}' left room '{}'", peer_id, room_id); + } +} + +/// Handles the SFU WebSocket connection. +/// +/// Protocol: +/// - First binary message determines the role: +/// - Text message `{"role":"host"}` -> this is the audio uplink (host publishes) +/// - Text message `{"role":"client"}` -> this is a downlink (client subscribes) +/// - Host sends binary frames: [8-byte timestamp][Opus audio data] +/// - Server fans out binary frames to all subscribed clients via broadcast channel. +async fn handle_sfu(socket: WebSocket, state: MeshState, room_id: String) { + let (mut sender, mut receiver) = socket.split(); + + // Wait for the role identification message + let role_msg = match receiver.next().await { + Some(Ok(Message::Text(text))) => text.to_string(), + _ => return, + }; + + #[derive(Deserialize)] + struct RoleMsg { + role: String, + } + + let role: RoleMsg = match serde_json::from_str(&role_msg) { + Ok(r) => r, + Err(_) => return, + }; + + match role.role.as_str() { + "host" => { + // Host uplink: read binary frames and publish to broadcast channel + let sfu_sender = match state.registry.get_sfu_sender(&room_id) { + Some(s) => s, + None => return, + }; + + tracing::info!("SFU: Host connected as uplink for room '{}'", room_id); + + while let Some(msg) = receiver.next().await { + match msg { + Ok(Message::Binary(data)) => { + // Publish to all subscribers; ignore send errors + // (happens when no subscribers are connected) + let _ = sfu_sender.send(Arc::new(data.to_vec())); + } + Ok(Message::Close(_)) | Err(_) => break, + _ => continue, + } + } + + tracing::info!("SFU: Host disconnected from room '{}'", room_id); + } + + "client" => { + // Client downlink: subscribe to broadcast and forward frames + let mut sfu_receiver = match state.registry.subscribe_sfu(&room_id) { + Some(r) => r, + None => return, + }; + + tracing::info!("SFU: Client subscribed to room '{}'", room_id); + + loop { + match sfu_receiver.recv().await { + Ok(data) => { + if sender + .send(Message::Binary(data.as_ref().clone().into())) + .await + .is_err() + { + break; + } + } + Err(broadcast::error::RecvError::Lagged(n)) => { + tracing::warn!("SFU: Client lagged by {} frames, skipping", n); + continue; + } + Err(broadcast::error::RecvError::Closed) => break, + } + } + + tracing::info!("SFU: Client disconnected from room '{}'", room_id); + } + + _ => { + tracing::warn!("SFU: Unknown role '{}', disconnecting", role.role); + } + } +} + +use tokio::sync::broadcast; + +/// Handles the time sync WebSocket connection. +/// +/// Protocol: Client sends JSON `{ "client_send_us": }`, +/// server responds with `{ "client_send_us", "server_recv_us", "server_send_us" }`. +/// Client performs multiple rounds and averages the clock offset. +async fn handle_sync(socket: WebSocket, state: MeshState, room_id: String) { + if state.registry.get_room(&room_id).is_none() { + return; + } + + let (mut sender, mut receiver) = socket.split(); + + while let Some(msg) = receiver.next().await { + let text = match msg { + Ok(Message::Text(t)) => t.to_string(), + Ok(Message::Close(_)) | Err(_) => break, + _ => continue, + }; + + let request: crate::services::mesh::time_sync::TimeSyncRequest = + match serde_json::from_str(&text) { + Ok(r) => r, + Err(_) => continue, + }; + + let response = state.time_sync.process_sync(request); + + if sender + .send(Message::Text( + serde_json::to_string(&response).unwrap().into(), + )) + .await + .is_err() + { + break; + } + } +} diff --git a/apps/server/src/handlers/mod.rs b/apps/server/src/handlers/mod.rs index 78bc86d..c98b115 100644 --- a/apps/server/src/handlers/mod.rs +++ b/apps/server/src/handlers/mod.rs @@ -1,108 +1,57 @@ -use crate::services::{AuthState, DynTelegramService}; +use crate::config::AppConfig; +use crate::services::mesh::{ + room_registry::RoomRegistry, time_sync::TimeSyncService, topology::TopologyManager, +}; use axum::{ - extract::{Request, State}, - http::StatusCode, - middleware::Next, - response::{IntoResponse, Response}, - routing::{get, post}, + response::IntoResponse, + routing::{delete, get, post}, Json, Router, }; +use std::sync::Arc; use tower_http::cors::{Any, CorsLayer}; -pub mod auth; -pub mod files; -pub mod users; +pub mod mesh; #[cfg(test)] mod __tests__; -async fn require_login( - State(service): State, - request: Request, - next: Next, -) -> Result { - match service.get_auth_state().await { - AuthState::LoggedIn => Ok(next.run(request).await), - _ => Err(( - StatusCode::UNAUTHORIZED, - Json(serde_json::json!({ - "error": "Unauthorized. Please log in first." - })), - ) - .into_response()), - } -} - async fn health_check() -> impl IntoResponse { - (StatusCode::OK, Json(serde_json::json!({ "status": "ok" }))) + (axum::http::StatusCode::OK, Json(serde_json::json!({ "status": "ok" }))) } -pub fn create_router(service: DynTelegramService) -> Router { +pub fn create_router(config: &AppConfig) -> Router { // Setup CORS layer allowing local development requests let cors = CorsLayer::new() .allow_origin(Any) .allow_methods(Any) .allow_headers(Any); - // Protected routes requiring authorization - let protected_routes = Router::new() - .route("/auth/log-out", post(auth::log_out)) - .route("/auth/update-credentials", post(auth::update_credentials)) - // Users namespace - .route("/users/me", get(users::get_me)) - .route("/users/get-users", get(users::get_users)) - .route("/users/get-full-user", get(users::get_full_user)) - // Account namespace - .route("/account/update-profile", post(users::update_profile)) - .route("/account/update-status", post(users::update_status)) - .route("/account/update-username", post(users::update_username)) - .route("/account/get-password", get(users::get_password_settings)) - // Contacts namespace (Stubs) - .route( - "/contacts/get-contacts", - get(axum::Json(serde_json::json!([]))), - ) - .route("/contacts/search", get(axum::Json(serde_json::json!([])))) - .route( - "/contacts/import-contacts", - post(axum::Json(serde_json::json!({ "success": true }))), - ) - // Files namespace - .route("/files/upload-part", post(files::upload_part)) - .route("/files/save-file", post(files::save_file)) - .route("/files/upload-progress", get(files::get_upload_progress)) - .route( - "/files/upload-progress/stream", - get(files::get_upload_progress_stream), - ) - .route("/files/download", get(files::download_file)) - .route("/files/get-file", get(files::download_file)) // Map get-file to download handler - .route("/files/delete", post(files::delete_file)) - // Drive namespace - .route("/drive/list", get(files::get_drives)) - .route("/drive/stats", get(files::get_stats)) - .route("/drive/folders", get(files::get_folders)) - .route("/drive/files", get(files::get_files)) - .route("/drive/folders/create", post(files::create_folder)) - .route("/drive/folders/delete", post(files::delete_folder)) - .route_layer(axum::middleware::from_fn_with_state( - service.clone(), - require_login, - )); + // Initialize mesh services + let mesh_state = mesh::MeshState { + registry: Arc::new(RoomRegistry::new(config.max_rooms, config.sfu_buffer_size)), + topology: Arc::new(TopologyManager::new(config.max_children_per_node)), + time_sync: Arc::new(TimeSyncService::new()), + }; + + // Mesh routes (no auth required — mesh uses its own room-level access) + let mesh_routes = Router::new() + .route("/mesh/rooms", post(mesh::create_room)) + .route("/mesh/rooms", get(mesh::list_rooms)) + .route("/mesh/rooms/:id", get(mesh::get_room)) + .route("/mesh/rooms/:id", delete(mesh::delete_room)) + .route("/mesh/rooms/:id/topology", get(mesh::get_topology)) + .route("/mesh/rooms/:id/topology/rtt", post(mesh::report_rtt)) + // WebSocket endpoints + .route("/ws/signaling/:room_id", get(mesh::ws_signaling)) + .route("/ws/sfu/:room_id", get(mesh::ws_sfu)) + .route("/ws/sync/:room_id", get(mesh::ws_sync)) + .with_state(mesh_state); Router::new() // Health check endpoint .route("/health", get(health_check)) - // Public Auth routes - .route("/auth/state", get(auth::get_auth_state)) - .route("/auth/send-code", post(auth::send_code)) - .route("/auth/sign-in", post(auth::sign_in)) - .route("/auth/sign-up", post(auth::sign_in)) // Map sign-up to same sign-in handler for mock - .route("/auth/check-password", post(auth::check_password)) - .route("/auth/reset-authorization", post(auth::reset_authorization)) - .merge(protected_routes) - // Share client service state globally - .with_state(service) + // Merge mesh routes + .merge(mesh_routes) .layer(cors) .layer(axum::extract::DefaultBodyLimit::max(20 * 1024 * 1024)) } diff --git a/apps/server/src/handlers/users.rs b/apps/server/src/handlers/users.rs deleted file mode 100644 index 31bf943..0000000 --- a/apps/server/src/handlers/users.rs +++ /dev/null @@ -1,130 +0,0 @@ -use crate::services::DynTelegramService; -use axum::{ - extract::{Query, State}, - http::StatusCode, - response::IntoResponse, - Json, -}; -use serde::Deserialize; - -#[derive(Debug, Deserialize)] -pub struct UserQuery { - pub id: i64, -} - -#[derive(Debug, Deserialize)] -pub struct UpdateProfilePayload { - pub first_name: String, - pub last_name: Option, -} - -#[derive(Debug, Deserialize)] -pub struct UpdateStatusPayload { - pub offline: bool, -} - -#[derive(Debug, Deserialize)] -pub struct UpdateUsernamePayload { - pub username: String, -} - -pub async fn get_me(State(service): State) -> impl IntoResponse { - match service.get_me().await { - Ok(user) => (StatusCode::OK, Json(user)).into_response(), - Err(err) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "error": err })), - ) - .into_response(), - } -} - -pub async fn get_users(State(service): State) -> impl IntoResponse { - match service.get_users().await { - Ok(users) => (StatusCode::OK, Json(users)).into_response(), - Err(err) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "error": err })), - ) - .into_response(), - } -} - -pub async fn get_full_user( - State(service): State, - Query(query): Query, -) -> impl IntoResponse { - match service.get_full_user(query.id).await { - Ok(user) => (StatusCode::OK, Json(user)).into_response(), - Err(err) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "error": err })), - ) - .into_response(), - } -} - -pub async fn update_profile( - State(service): State, - Json(payload): Json, -) -> impl IntoResponse { - match service - .update_profile(&payload.first_name, payload.last_name.as_deref()) - .await - { - Ok(success) => ( - StatusCode::OK, - Json(serde_json::json!({ "success": success })), - ) - .into_response(), - Err(err) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "success": false, "error": err })), - ) - .into_response(), - } -} - -pub async fn update_status( - State(service): State, - Json(payload): Json, -) -> impl IntoResponse { - match service.update_status(payload.offline).await { - Ok(success) => ( - StatusCode::OK, - Json(serde_json::json!({ "success": success })), - ) - .into_response(), - Err(err) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "success": false, "error": err })), - ) - .into_response(), - } -} - -pub async fn update_username( - State(service): State, - Json(payload): Json, -) -> impl IntoResponse { - match service.update_username(&payload.username).await { - Ok(success) => ( - StatusCode::OK, - Json(serde_json::json!({ "success": success })), - ) - .into_response(), - Err(err) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "success": false, "error": err })), - ) - .into_response(), - } -} - -pub async fn get_password_settings() -> impl IntoResponse { - // Stubbed response for 2FA settings info - Json(serde_json::json!({ - "has_password": true, - "hint": "Default development password hint" - })) -} diff --git a/apps/server/src/lib.rs b/apps/server/src/lib.rs index 27080b0..d6914b7 100644 --- a/apps/server/src/lib.rs +++ b/apps/server/src/lib.rs @@ -2,20 +2,19 @@ pub mod config; pub mod handlers; pub mod services; +use config::AppConfig; use handlers::create_router; -use services::{telegram::RealTelegramService, TelegramService}; use std::net::SocketAddr; -use std::sync::Arc; pub async fn run_server(addr: SocketAddr) -> Result<(), Box> { - // Initialize Telegram Client service (Real mode only) - tracing::info!("Starting server in REAL mode (connecting to Telegram MTProto API)"); - let service: Arc = Arc::new(RealTelegramService::new()); + // Load configuration + let config = AppConfig::from_env(); + tracing::info!("Loaded server configuration: {:?}", config); - // Create Axum Router - let app = create_router(service); + // Create Axum Router with mesh services + let app = create_router(&config); - tracing::info!("FastDeck Rust backend server listening on {}", addr); + tracing::info!("AudioMesh Rust backend server listening on {}", addr); let listener = tokio::net::TcpListener::bind(&addr).await?; diff --git a/apps/server/src/main.rs b/apps/server/src/main.rs index ec313c3..28bd644 100644 --- a/apps/server/src/main.rs +++ b/apps/server/src/main.rs @@ -1,5 +1,5 @@ use std::net::SocketAddr; -use fastdeck_server::{config::AppConfig, run_server}; +use audiomesh_server::{config::AppConfig, run_server}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; #[tokio::main] @@ -8,7 +8,7 @@ async fn main() { tracing_subscriber::registry() .with( tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "fastdeck_server=info,tower_http=debug".into()), + .unwrap_or_else(|_| "audiomesh_server=info,tower_http=debug".into()), ) .with(tracing_subscriber::fmt::layer()) .init(); diff --git a/apps/server/src/services/mesh/mod.rs b/apps/server/src/services/mesh/mod.rs new file mode 100644 index 0000000..e9dc8ca --- /dev/null +++ b/apps/server/src/services/mesh/mod.rs @@ -0,0 +1,94 @@ +pub mod room_registry; +pub mod time_sync; +pub mod topology; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// The mesh operating mode for a room. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum MeshMode { + #[serde(rename = "direct_p2p")] + DirectP2P, + #[serde(rename = "daisy_chain")] + DaisyChain, + #[serde(rename = "sfu")] + SFU, +} + +/// A mesh audio room — an isolated session where devices connect to play synchronized audio. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Room { + pub id: String, + pub name: String, + pub mode: MeshMode, + pub host_peer_id: String, + pub created_at: DateTime, + pub max_nodes: usize, +} + +/// The type of physical device in the mesh. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum DeviceType { + Desktop, + Phone, + Tablet, + Speaker, + Browser, +} + +/// The transport protocol a peer is using. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum ConnectionType { + WiFi, + Bluetooth, + WebSocket, +} + +/// A connected device (peer) in a mesh room. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Peer { + pub id: String, + pub display_name: String, + pub device_type: DeviceType, + pub connection_type: ConnectionType, + pub joined_at: DateTime, + /// In Daisy-Chain mode, the peer this node receives audio from. + /// `None` for the host node or in non-daisy-chain modes. + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_peer_id: Option, +} + +/// The health status of a connection edge between two peers. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum EdgeStatus { + Connecting, + Synced, + Degraded, + Disconnected, +} + +/// A directed connection edge between two peers in the topology graph. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct TopologyEdge { + pub from_peer_id: String, + pub to_peer_id: String, + /// Measured round-trip time in milliseconds between the two peers. + pub rtt_ms: Option, + pub status: EdgeStatus, +} + +/// Default node limits per mesh mode. +impl MeshMode { + pub fn default_max_nodes(&self) -> usize { + match self { + MeshMode::DirectP2P => 15, + MeshMode::DaisyChain => 30, + MeshMode::SFU => 50, + } + } +} diff --git a/apps/server/src/services/mesh/room_registry.rs b/apps/server/src/services/mesh/room_registry.rs new file mode 100644 index 0000000..3e0cced --- /dev/null +++ b/apps/server/src/services/mesh/room_registry.rs @@ -0,0 +1,321 @@ +use super::{MeshMode, Peer, Room}; +use chrono::Utc; +use dashmap::DashMap; +use std::sync::Arc; +use tokio::sync::broadcast; +use uuid::Uuid; + +/// Thread-safe room registry backed by DashMap. +/// Manages the lifecycle of rooms and their connected peers. +#[derive(Debug)] +pub struct RoomRegistry { + rooms: DashMap, + peers: DashMap>, + /// Broadcast channels for SFU audio forwarding (room_id -> sender). + sfu_channels: DashMap>>>, + /// Maximum number of concurrent rooms. + pub max_rooms: usize, + /// Default broadcast channel buffer size. + pub sfu_buffer_size: usize, +} + +impl RoomRegistry { + /// Creates a new RoomRegistry with the given limits. + pub fn new(max_rooms: usize, sfu_buffer_size: usize) -> Self { + Self { + rooms: DashMap::new(), + peers: DashMap::new(), + sfu_channels: DashMap::new(), + max_rooms, + sfu_buffer_size, + } + } + + /// Creates a new room and returns it. + pub fn create_room( + &self, + name: String, + mode: MeshMode, + host_peer_id: String, + max_nodes: Option, + ) -> Result { + if self.rooms.len() >= self.max_rooms { + return Err(format!( + "Maximum number of rooms ({}) reached.", + self.max_rooms + )); + } + + let id = Uuid::new_v4().to_string(); + let max_nodes = max_nodes.unwrap_or_else(|| mode.default_max_nodes()); + + let room = Room { + id: id.clone(), + name, + mode, + host_peer_id, + created_at: Utc::now(), + max_nodes, + }; + + self.rooms.insert(id.clone(), room.clone()); + self.peers.insert(id.clone(), Vec::new()); + + // Create a broadcast channel for SFU mode + let (tx, _rx) = broadcast::channel(self.sfu_buffer_size); + self.sfu_channels.insert(id, tx); + + Ok(room) + } + + /// Returns a room by ID. + pub fn get_room(&self, room_id: &str) -> Option { + self.rooms.get(room_id).map(|r| r.value().clone()) + } + + /// Lists all active rooms. + pub fn list_rooms(&self) -> Vec { + self.rooms.iter().map(|r| r.value().clone()).collect() + } + + /// Deletes a room and all associated state. + pub fn delete_room(&self, room_id: &str) -> bool { + let removed = self.rooms.remove(room_id).is_some(); + self.peers.remove(room_id); + self.sfu_channels.remove(room_id); + removed + } + + /// Adds a peer to a room. + pub fn add_peer(&self, room_id: &str, peer: Peer) -> Result<(), String> { + let room = self + .rooms + .get(room_id) + .ok_or_else(|| format!("Room '{}' not found.", room_id))?; + + let mut peers = self + .peers + .get_mut(room_id) + .ok_or_else(|| format!("Room '{}' peer list not found.", room_id))?; + + if peers.len() >= room.max_nodes { + return Err(format!( + "Room '{}' is full ({}/{} nodes).", + room_id, + peers.len(), + room.max_nodes + )); + } + + // Prevent duplicate peer IDs + if peers.iter().any(|p| p.id == peer.id) { + return Err(format!( + "Peer '{}' already exists in room '{}'.", + peer.id, room_id + )); + } + + peers.push(peer); + Ok(()) + } + + /// Removes a peer from a room by peer ID. + pub fn remove_peer(&self, room_id: &str, peer_id: &str) -> bool { + if let Some(mut peers) = self.peers.get_mut(room_id) { + let before = peers.len(); + peers.retain(|p| p.id != peer_id); + peers.len() < before + } else { + false + } + } + + /// Gets all peers in a room. + pub fn get_peers(&self, room_id: &str) -> Vec { + self.peers + .get(room_id) + .map(|p| p.value().clone()) + .unwrap_or_default() + } + + /// Returns the SFU broadcast sender for a room (for the host to publish audio). + pub fn get_sfu_sender(&self, room_id: &str) -> Option>>> { + self.sfu_channels.get(room_id).map(|s| s.value().clone()) + } + + /// Subscribes to the SFU broadcast channel for a room (for clients to receive audio). + pub fn subscribe_sfu(&self, room_id: &str) -> Option>>> { + self.sfu_channels.get(room_id).map(|s| s.subscribe()) + } + + /// Updates a peer's parent_peer_id (used in Daisy-Chain mode for topology assignment). + pub fn set_peer_parent( + &self, + room_id: &str, + peer_id: &str, + parent_peer_id: Option, + ) -> bool { + if let Some(mut peers) = self.peers.get_mut(room_id) { + if let Some(peer) = peers.iter_mut().find(|p| p.id == peer_id) { + peer.parent_peer_id = parent_peer_id; + return true; + } + } + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::services::mesh::{ConnectionType, DeviceType, MeshMode}; + + fn make_peer(id: &str, name: &str) -> Peer { + Peer { + id: id.to_string(), + display_name: name.to_string(), + device_type: DeviceType::Phone, + connection_type: ConnectionType::WiFi, + joined_at: Utc::now(), + parent_peer_id: None, + } + } + + #[test] + fn test_create_and_get_room() { + let registry = RoomRegistry::new(10, 256); + let room = registry + .create_room( + "Test Room".into(), + MeshMode::SFU, + "host-1".into(), + None, + ) + .unwrap(); + + assert_eq!(room.name, "Test Room"); + assert_eq!(room.mode, MeshMode::SFU); + assert_eq!(room.max_nodes, 50); + + let fetched = registry.get_room(&room.id).unwrap(); + assert_eq!(fetched.id, room.id); + } + + #[test] + fn test_list_rooms() { + let registry = RoomRegistry::new(10, 256); + registry + .create_room("Room A".into(), MeshMode::DirectP2P, "host-1".into(), None) + .unwrap(); + registry + .create_room("Room B".into(), MeshMode::SFU, "host-2".into(), None) + .unwrap(); + + let rooms = registry.list_rooms(); + assert_eq!(rooms.len(), 2); + } + + #[test] + fn test_delete_room() { + let registry = RoomRegistry::new(10, 256); + let room = registry + .create_room("Temp".into(), MeshMode::SFU, "host-1".into(), None) + .unwrap(); + + assert!(registry.delete_room(&room.id)); + assert!(registry.get_room(&room.id).is_none()); + } + + #[test] + fn test_max_rooms_limit() { + let registry = RoomRegistry::new(1, 256); + registry + .create_room("Room 1".into(), MeshMode::SFU, "host-1".into(), None) + .unwrap(); + let result = registry.create_room("Room 2".into(), MeshMode::SFU, "host-2".into(), None); + assert!(result.is_err()); + } + + #[test] + fn test_add_and_get_peers() { + let registry = RoomRegistry::new(10, 256); + let room = registry + .create_room("Test".into(), MeshMode::SFU, "host-1".into(), None) + .unwrap(); + + registry + .add_peer(&room.id, make_peer("peer-1", "iPhone 15")) + .unwrap(); + registry + .add_peer(&room.id, make_peer("peer-2", "iPad Pro")) + .unwrap(); + + let peers = registry.get_peers(&room.id); + assert_eq!(peers.len(), 2); + assert_eq!(peers[0].display_name, "iPhone 15"); + } + + #[test] + fn test_remove_peer() { + let registry = RoomRegistry::new(10, 256); + let room = registry + .create_room("Test".into(), MeshMode::SFU, "host-1".into(), None) + .unwrap(); + + registry + .add_peer(&room.id, make_peer("peer-1", "Phone")) + .unwrap(); + assert!(registry.remove_peer(&room.id, "peer-1")); + assert_eq!(registry.get_peers(&room.id).len(), 0); + } + + #[test] + fn test_duplicate_peer_rejected() { + let registry = RoomRegistry::new(10, 256); + let room = registry + .create_room("Test".into(), MeshMode::SFU, "host-1".into(), None) + .unwrap(); + + registry + .add_peer(&room.id, make_peer("peer-1", "Phone")) + .unwrap(); + let result = registry.add_peer(&room.id, make_peer("peer-1", "Phone Again")); + assert!(result.is_err()); + } + + #[test] + fn test_max_nodes_limit() { + let registry = RoomRegistry::new(10, 256); + let room = registry + .create_room("Tiny".into(), MeshMode::SFU, "host-1".into(), Some(2)) + .unwrap(); + + registry + .add_peer(&room.id, make_peer("p1", "Dev 1")) + .unwrap(); + registry + .add_peer(&room.id, make_peer("p2", "Dev 2")) + .unwrap(); + let result = registry.add_peer(&room.id, make_peer("p3", "Dev 3")); + assert!(result.is_err()); + } + + #[test] + fn test_sfu_channel() { + let registry = RoomRegistry::new(10, 256); + let room = registry + .create_room("SFU Test".into(), MeshMode::SFU, "host-1".into(), None) + .unwrap(); + + let sender = registry.get_sfu_sender(&room.id); + assert!(sender.is_some()); + + let mut receiver = registry.subscribe_sfu(&room.id).unwrap(); + + let data = Arc::new(vec![1u8, 2, 3, 4]); + sender.unwrap().send(data.clone()).unwrap(); + + let received = receiver.try_recv().unwrap(); + assert_eq!(*received, vec![1u8, 2, 3, 4]); + } +} diff --git a/apps/server/src/services/mesh/time_sync.rs b/apps/server/src/services/mesh/time_sync.rs new file mode 100644 index 0000000..fa0ac94 --- /dev/null +++ b/apps/server/src/services/mesh/time_sync.rs @@ -0,0 +1,112 @@ +use serde::{Deserialize, Serialize}; +use std::time::Instant; + +/// Request payload sent by clients for time synchronization. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TimeSyncRequest { + /// Timestamp (in microseconds since some client epoch) when the client sent this request. + pub client_send_us: i64, +} + +/// Response payload returned by the server for time synchronization. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TimeSyncResponse { + /// Echo of the client's send timestamp. + pub client_send_us: i64, + /// Server timestamp (in microseconds since server epoch) when the request was received. + pub server_recv_us: i64, + /// Server timestamp (in microseconds since server epoch) when the response is sent. + pub server_send_us: i64, +} + +/// Server-side time sync service. +/// +/// Provides a reference clock for all peers in a mesh room. +/// Clients perform multiple round-trip exchanges and compute their clock offset: +/// +/// ```text +/// offset = ((server_recv - client_send) + (server_send - client_recv)) / 2 +/// ``` +/// +/// This follows the NTP symmetric algorithm. Multiple rounds are averaged +/// for sub-millisecond precision on local networks. +#[derive(Debug)] +pub struct TimeSyncService { + /// The instant when this service was created, used as the server's epoch. + epoch: Instant, +} + +impl TimeSyncService { + pub fn new() -> Self { + Self { + epoch: Instant::now(), + } + } + + /// Returns the current server time in microseconds since the server epoch. + pub fn now_us(&self) -> i64 { + self.epoch.elapsed().as_micros() as i64 + } + + /// Processes a time sync request and produces a response. + pub fn process_sync(&self, request: TimeSyncRequest) -> TimeSyncResponse { + let server_recv_us = self.now_us(); + + TimeSyncResponse { + client_send_us: request.client_send_us, + server_recv_us, + server_send_us: self.now_us(), + } + } +} + +impl Default for TimeSyncService { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::thread::sleep; + use std::time::Duration; + + #[test] + fn test_time_sync_response_has_correct_echo() { + let service = TimeSyncService::new(); + let request = TimeSyncRequest { + client_send_us: 123456789, + }; + + let response = service.process_sync(request); + assert_eq!(response.client_send_us, 123456789); + } + + #[test] + fn test_server_timestamps_are_ordered() { + let service = TimeSyncService::new(); + + // Small delay to ensure measurable time difference + sleep(Duration::from_millis(1)); + + let request = TimeSyncRequest { + client_send_us: 1000, + }; + + let response = service.process_sync(request); + assert!(response.server_recv_us > 0); + assert!(response.server_send_us >= response.server_recv_us); + } + + #[test] + fn test_monotonic_clock() { + let service = TimeSyncService::new(); + + let t1 = service.now_us(); + sleep(Duration::from_millis(1)); + let t2 = service.now_us(); + + assert!(t2 > t1); + } +} diff --git a/apps/server/src/services/mesh/topology.rs b/apps/server/src/services/mesh/topology.rs new file mode 100644 index 0000000..64790a6 --- /dev/null +++ b/apps/server/src/services/mesh/topology.rs @@ -0,0 +1,313 @@ +use super::{EdgeStatus, TopologyEdge}; +use dashmap::DashMap; + +/// Manages the Daisy-Chain tree topology for a mesh room. +/// +/// In Daisy-Chain mode, the host sends audio to a subset of peers, and those peers +/// relay the audio stream to further peers. This forms a tree rooted at the host. +/// The TopologyManager tracks this tree and handles self-healing when nodes disconnect. +#[derive(Debug)] +pub struct TopologyManager { + /// room_id -> list of directed edges in the topology tree + edges: DashMap>, + /// Maximum number of children a single node should relay to. + /// Keeps the tree balanced and prevents overloading any single device. + pub max_children_per_node: usize, +} + +impl TopologyManager { + pub fn new(max_children_per_node: usize) -> Self { + Self { + edges: DashMap::new(), + max_children_per_node, + } + } + + /// Initializes the topology tree for a room (called when a room is created). + pub fn init_room(&self, room_id: &str) { + self.edges.insert(room_id.to_string(), Vec::new()); + } + + /// Removes all topology state for a room. + pub fn remove_room(&self, room_id: &str) { + self.edges.remove(room_id); + } + + /// Assigns the best parent node for a new peer joining the room. + /// + /// Strategy: Find the node in the tree with the fewest children that hasn't + /// exceeded `max_children_per_node`. This produces a balanced tree that minimizes + /// the maximum number of hops from host to any leaf node. + /// + /// Returns the peer_id of the assigned parent, or `None` if the tree is empty + /// (meaning this peer should connect directly to the host). + pub fn assign_parent( + &self, + room_id: &str, + new_peer_id: &str, + host_peer_id: &str, + _all_peer_ids: &[String], + ) -> Option { + let mut edges = match self.edges.get_mut(room_id) { + Some(e) => e, + None => return None, + }; + + // Count children for each node currently in the tree (the host + all connected peers) + let mut child_count: std::collections::HashMap = + std::collections::HashMap::new(); + child_count.insert(host_peer_id.to_string(), 0); + for edge in edges.iter() { + child_count.insert(edge.to_peer_id.clone(), 0); + } + for edge in edges.iter() { + *child_count.entry(edge.from_peer_id.clone()).or_insert(0) += 1; + } + + // Find the node with the fewest children below the limit + let best_parent = child_count + .iter() + .filter(|(_, &count)| count < self.max_children_per_node) + .min_by_key(|(id, &count)| { + // Prefer fewer children; break ties by preferring the host + // (to keep the tree shallow) + let depth = self.get_depth(&edges, id, host_peer_id); + (depth, count) + }) + .map(|(id, _)| id.clone()); + + if let Some(ref parent_id) = best_parent { + edges.push(TopologyEdge { + from_peer_id: parent_id.clone(), + to_peer_id: new_peer_id.to_string(), + rtt_ms: None, + status: EdgeStatus::Connecting, + }); + } + + best_parent + } + + /// Returns the depth of a node in the tree (0 = host). + fn get_depth(&self, edges: &[TopologyEdge], node_id: &str, host_peer_id: &str) -> usize { + if node_id == host_peer_id { + return 0; + } + + let mut depth = 0; + let mut current = node_id.to_string(); + + for _ in 0..100 { + // Safety limit to prevent infinite loops + if let Some(edge) = edges.iter().find(|e| e.to_peer_id == current) { + depth += 1; + current = edge.from_peer_id.clone(); + if current == host_peer_id { + return depth; + } + } else { + break; + } + } + depth + } + + /// Removes a node from the topology and re-parents its children to its parent. + /// This provides self-healing when a relay node disconnects. + /// + /// Returns the list of orphaned peer IDs that were re-parented. + pub fn remove_node(&self, room_id: &str, peer_id: &str) -> Vec { + let mut edges = match self.edges.get_mut(room_id) { + Some(e) => e, + None => return Vec::new(), + }; + + // Find the parent of the disconnected node + let parent_id = edges + .iter() + .find(|e| e.to_peer_id == peer_id) + .map(|e| e.from_peer_id.clone()); + + // Find all children of the disconnected node + let children: Vec = edges + .iter() + .filter(|e| e.from_peer_id == peer_id) + .map(|e| e.to_peer_id.clone()) + .collect(); + + // Remove all edges involving the disconnected node + edges.retain(|e| e.from_peer_id != peer_id && e.to_peer_id != peer_id); + + // Re-parent children to the disconnected node's parent + if let Some(parent) = parent_id { + for child in &children { + edges.push(TopologyEdge { + from_peer_id: parent.clone(), + to_peer_id: child.clone(), + rtt_ms: None, + status: EdgeStatus::Connecting, + }); + } + } + + children + } + + /// Returns the full topology tree for a room. + pub fn get_tree(&self, room_id: &str) -> Vec { + self.edges + .get(room_id) + .map(|e| e.value().clone()) + .unwrap_or_default() + } + + /// Updates the RTT measurement between two peers. + pub fn update_rtt(&self, room_id: &str, from: &str, to: &str, rtt_ms: f64) -> bool { + if let Some(mut edges) = self.edges.get_mut(room_id) { + if let Some(edge) = edges + .iter_mut() + .find(|e| e.from_peer_id == from && e.to_peer_id == to) + { + edge.rtt_ms = Some(rtt_ms); + edge.status = if rtt_ms < 50.0 { + EdgeStatus::Synced + } else if rtt_ms < 150.0 { + EdgeStatus::Degraded + } else { + EdgeStatus::Disconnected + }; + return true; + } + } + false + } + + /// Updates the status of an edge between two peers. + pub fn update_edge_status( + &self, + room_id: &str, + from: &str, + to: &str, + status: EdgeStatus, + ) -> bool { + if let Some(mut edges) = self.edges.get_mut(room_id) { + if let Some(edge) = edges + .iter_mut() + .find(|e| e.from_peer_id == from && e.to_peer_id == to) + { + edge.status = status; + return true; + } + } + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_assign_parent_first_peer_gets_host() { + let topo = TopologyManager::new(3); + topo.init_room("room-1"); + + let parent = topo.assign_parent("room-1", "peer-1", "host", &["peer-1".into()]); + assert_eq!(parent, Some("host".to_string())); + } + + #[test] + fn test_assign_parent_balances_tree() { + let topo = TopologyManager::new(2); + topo.init_room("room-1"); + + let all_peers: Vec = + vec!["p1", "p2", "p3", "p4", "p5"] + .into_iter() + .map(String::from) + .collect(); + + // p1 -> host + let parent = topo.assign_parent("room-1", "p1", "host", &all_peers); + assert_eq!(parent, Some("host".to_string())); + + // p2 -> host (host has 1 child, still under limit of 2) + let parent = topo.assign_parent("room-1", "p2", "host", &all_peers); + assert_eq!(parent, Some("host".to_string())); + + // p3 -> should go to p1 or p2 (host is full at 2) + let parent = topo.assign_parent("room-1", "p3", "host", &all_peers); + assert!(parent == Some("p1".to_string()) || parent == Some("p2".to_string())); + } + + #[test] + fn test_remove_node_reparents_children() { + let topo = TopologyManager::new(1); + topo.init_room("room-1"); + + let peers: Vec = vec!["p1", "p2", "p3"] + .into_iter() + .map(String::from) + .collect(); + + // Build: host -> p1 -> p2 -> p3 (since limit is 1) + topo.assign_parent("room-1", "p1", "host", &peers); + topo.assign_parent("room-1", "p2", "host", &peers); + topo.assign_parent("room-1", "p3", "host", &peers); + + // Remove p1 — its child (p2) should be re-parented to host (p1's parent) + let orphans = topo.remove_node("room-1", "p1"); + assert_eq!(orphans, vec!["p2".to_string()]); + + let tree = topo.get_tree("room-1"); + // p1 should have no edges remaining + assert!(tree.iter().all(|e| e.from_peer_id != "p1" && e.to_peer_id != "p1")); + + // p2 should be re-parented to host, and p3 should still be connected to p2 + assert!(tree.iter().any(|e| e.from_peer_id == "host" && e.to_peer_id == "p2")); + assert!(tree.iter().any(|e| e.from_peer_id == "p2" && e.to_peer_id == "p3")); + assert_eq!(tree.len(), 2); + } + + #[test] + fn test_update_rtt() { + let topo = TopologyManager::new(3); + topo.init_room("room-1"); + + topo.assign_parent("room-1", "p1", "host", &["p1".into()]); + + assert!(topo.update_rtt("room-1", "host", "p1", 12.5)); + + let tree = topo.get_tree("room-1"); + let edge = tree.iter().find(|e| e.to_peer_id == "p1").unwrap(); + assert_eq!(edge.rtt_ms, Some(12.5)); + assert_eq!(edge.status, EdgeStatus::Synced); + } + + #[test] + fn test_rtt_determines_status() { + let topo = TopologyManager::new(3); + topo.init_room("room-1"); + + topo.assign_parent("room-1", "p1", "host", &["p1".into()]); + + topo.update_rtt("room-1", "host", "p1", 100.0); + let tree = topo.get_tree("room-1"); + let edge = tree.iter().find(|e| e.to_peer_id == "p1").unwrap(); + assert_eq!(edge.status, EdgeStatus::Degraded); + + topo.update_rtt("room-1", "host", "p1", 200.0); + let tree = topo.get_tree("room-1"); + let edge = tree.iter().find(|e| e.to_peer_id == "p1").unwrap(); + assert_eq!(edge.status, EdgeStatus::Disconnected); + } + + #[test] + fn test_remove_room() { + let topo = TopologyManager::new(3); + topo.init_room("room-1"); + topo.assign_parent("room-1", "p1", "host", &["p1".into()]); + topo.remove_room("room-1"); + assert!(topo.get_tree("room-1").is_empty()); + } +} diff --git a/apps/server/src/services/mod.rs b/apps/server/src/services/mod.rs index ae0d4e0..cb3e16e 100644 --- a/apps/server/src/services/mod.rs +++ b/apps/server/src/services/mod.rs @@ -1,224 +1 @@ -use serde::{Deserialize, Serialize}; -use std::sync::Arc; - -pub mod telegram; - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(tag = "status", content = "data")] -pub enum AuthState { - LoggedOut, - AwaitingCode { - phone: String, - phone_code_hash: String, - }, - AwaitingPassword { - phone: String, - }, - LoggedIn, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct AuthResult { - pub success: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub next_step: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct FileMetadata { - #[serde( - serialize_with = "serialize_i64_as_string", - deserialize_with = "deserialize_i64_from_string_or_number" - )] - pub id: i64, - #[serde(with = "serde_option_i64_string")] - pub folder_id: Option, - pub name: String, - pub size: i64, - pub mime_type: Option, - pub file_ext: Option, - pub created_at: String, - pub icon_type: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub telegram_message_id: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct FolderMetadata { - #[serde( - serialize_with = "serialize_i64_as_string", - deserialize_with = "deserialize_i64_from_string_or_number" - )] - pub id: i64, - #[serde(with = "serde_option_i64_string")] - pub parent_id: Option, - pub name: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct Drive { - #[serde( - serialize_with = "serialize_i64_as_string", - deserialize_with = "deserialize_i64_from_string_or_number" - )] - pub chat_id: i64, - pub name: String, - pub icon: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct DriveStats { - pub total_space: i64, - pub used_space: i64, - pub file_count: i64, - pub folder_count: i64, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct TelegramUser { - #[serde( - serialize_with = "serialize_i64_as_string", - deserialize_with = "deserialize_i64_from_string_or_number" - )] - pub id: i64, - pub first_name: String, - pub last_name: Option, - pub username: Option, - pub phone: Option, -} - -// Global thread-safe service trait -#[axum::async_trait] -pub trait TelegramService: Send + Sync { - // Auth operations - async fn send_code( - &self, - phone: &str, - api_id: i32, - api_hash: &str, - ) -> Result; - async fn sign_in( - &self, - phone: &str, - phone_code_hash: &str, - code: &str, - ) -> Result; - async fn check_password(&self, password: &str) -> Result; - async fn log_out(&self) -> Result; - async fn reset_authorization(&self) -> Result; - async fn update_credentials(&self, api_id: i32, api_hash: &str) -> Result; - async fn get_auth_state(&self) -> AuthState; - - // User/Account operations - async fn get_me(&self) -> Result; - async fn get_users(&self) -> Result, String>; - async fn get_full_user(&self, user_id: i64) -> Result; - async fn update_profile( - &self, - first_name: &str, - last_name: Option<&str>, - ) -> Result; - async fn update_status(&self, offline: bool) -> Result; - async fn update_username(&self, username: &str) -> Result; - - // Drive & Files operations - async fn get_drives(&self) -> Result, String>; - async fn get_stats(&self) -> Result; - async fn get_folders(&self, parent_id: Option) -> Result, String>; - async fn get_files( - &self, - folder_id: Option, - search_query: Option<&str>, - all: Option, - ) -> Result, String>; - async fn create_folder( - &self, - name: &str, - parent_id: Option, - ) -> Result; - async fn delete_folder(&self, id: i64) -> Result; - async fn delete_file(&self, id: i64) -> Result; - - // File upload/download - async fn upload_part( - &self, - file_id: i64, - part_index: i32, - file_size: i64, - total_parts: i32, - byte_offset: i64, - bytes: Vec, - ) -> Result; - async fn save_file( - &self, - file_id: i64, - name: &str, - size: i64, - folder_id: Option, - ) -> Result; - async fn download_file(&self, file_id: i64) -> Result, String>; - async fn get_upload_progress(&self, file_id: i64) -> Result; -} - -pub type DynTelegramService = Arc; - -pub fn serialize_i64_as_string(val: &i64, serializer: S) -> Result -where - S: serde::Serializer, -{ - serializer.serialize_str(&val.to_string()) -} - -pub fn deserialize_i64_from_string_or_number<'de, D>(deserializer: D) -> Result -where - D: serde::Deserializer<'de>, -{ - #[derive(serde::Deserialize)] - #[serde(untagged)] - enum StringOrNumber { - String(String), - Number(i64), - } - - match StringOrNumber::deserialize(deserializer)? { - StringOrNumber::String(s) => s.parse::().map_err(serde::de::Error::custom), - StringOrNumber::Number(n) => Ok(n), - } -} - -pub mod serde_option_i64_string { - use serde::{Deserialize, Deserializer, Serializer}; - - pub fn serialize(value: &Option, serializer: S) -> Result - where - S: Serializer, - { - match value { - Some(v) => serializer.serialize_some(&v.to_string()), - None => serializer.serialize_none(), - } - } - - pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> - where - D: Deserializer<'de>, - { - #[derive(Deserialize)] - #[serde(untagged)] - enum OptionStringOrNumber { - SomeString(String), - SomeNumber(i64), - None, - } - - match OptionStringOrNumber::deserialize(deserializer)? { - OptionStringOrNumber::SomeString(s) => { - s.parse::().map(Some).map_err(serde::de::Error::custom) - } - OptionStringOrNumber::SomeNumber(n) => Ok(Some(n)), - OptionStringOrNumber::None => Ok(None), - } - } -} +pub mod mesh; diff --git a/apps/server/src/services/telegram.rs b/apps/server/src/services/telegram.rs deleted file mode 100644 index 03eaf8e..0000000 --- a/apps/server/src/services/telegram.rs +++ /dev/null @@ -1,278 +0,0 @@ -use super::{ - AuthResult, AuthState, Drive, DriveStats, FileMetadata, FolderMetadata, TelegramService, - TelegramUser, -}; -use tokio::sync::Mutex; -use std::sync::Arc; - -#[derive(Clone, Debug)] -pub struct RealTelegramService { - logged_in: Arc>, - folders: Arc>>, - files: Arc>>, -} - -impl RealTelegramService { - pub fn new() -> Self { - Self { - logged_in: Arc::new(Mutex::new(false)), - folders: Arc::new(Mutex::new(vec![ - FolderMetadata { - id: 1, - parent_id: None, - name: "Documents".to_string(), - }, - FolderMetadata { - id: 2, - parent_id: None, - name: "Photos".to_string(), - }, - ])), - files: Arc::new(Mutex::new(vec![ - FileMetadata { - id: 101, - folder_id: Some(1), - name: "resume.pdf".to_string(), - size: 102400, - mime_type: Some("application/pdf".to_string()), - file_ext: Some("pdf".to_string()), - created_at: "2026-06-11T20:00:00Z".to_string(), - icon_type: "pdf".to_string(), - telegram_message_id: Some(1), - }, - FileMetadata { - id: 102, - folder_id: Some(2), - name: "avatar.png".to_string(), - size: 204800, - mime_type: Some("image/png".to_string()), - file_ext: Some("png".to_string()), - created_at: "2026-06-11T20:01:00Z".to_string(), - icon_type: "image".to_string(), - telegram_message_id: Some(2), - }, - ])), - } - } -} - -#[axum::async_trait] -impl TelegramService for RealTelegramService { - async fn get_auth_state(&self) -> AuthState { - if *self.logged_in.lock().await { - AuthState::LoggedIn - } else { - AuthState::LoggedOut - } - } - - async fn send_code( - &self, - _phone: &str, - _api_id: i32, - _api_hash: &str, - ) -> Result { - Ok(AuthResult { - success: true, - next_step: Some("code".to_string()), - error: None, - }) - } - - async fn sign_in( - &self, - _phone: &str, - _phone_code_hash: &str, - _code: &str, - ) -> Result { - let mut logged = self.logged_in.lock().await; - *logged = true; - Ok(AuthResult { - success: true, - next_step: Some("dashboard".to_string()), - error: None, - }) - } - - async fn check_password(&self, _password: &str) -> Result { - Ok(AuthResult { - success: true, - next_step: Some("dashboard".to_string()), - error: None, - }) - } - - async fn log_out(&self) -> Result { - let mut logged = self.logged_in.lock().await; - *logged = false; - Ok(true) - } - - async fn reset_authorization(&self) -> Result { - let mut logged = self.logged_in.lock().await; - *logged = false; - Ok(true) - } - - async fn update_credentials(&self, _api_id: i32, _api_hash: &str) -> Result { - Ok(true) - } - - async fn get_me(&self) -> Result { - Ok(TelegramUser { - id: 12345, - first_name: "Mock".to_string(), - last_name: Some("User".to_string()), - username: Some("mockuser".to_string()), - phone: Some("+919876543210".to_string()), - }) - } - - async fn get_users(&self) -> Result, String> { - Ok(vec![self.get_me().await?]) - } - - async fn get_full_user(&self, user_id: i64) -> Result { - Ok(TelegramUser { - id: user_id, - first_name: "Mock".to_string(), - last_name: Some("User".to_string()), - username: Some("mockuser".to_string()), - phone: Some("+919876543210".to_string()), - }) - } - - async fn update_profile( - &self, - _first_name: &str, - _last_name: Option<&str>, - ) -> Result { - Ok(true) - } - - async fn update_status(&self, _offline: bool) -> Result { - Ok(true) - } - - async fn update_username(&self, _username: &str) -> Result { - Ok(true) - } - - async fn get_drives(&self) -> Result, String> { - Ok(vec![Drive { - chat_id: 1, - name: "Mock Drive".to_string(), - icon: None, - }]) - } - - async fn get_stats(&self) -> Result { - let files_count = self.files.lock().await.len() as i64; - let folders_count = self.folders.lock().await.len() as i64; - Ok(DriveStats { - total_space: 10 * 1024 * 1024 * 1024, - used_space: 307200, - file_count: files_count, - folder_count: folders_count, - }) - } - - async fn get_folders(&self, parent_id: Option) -> Result, String> { - let folders = self.folders.lock().await; - let filtered = folders - .iter() - .filter(|f| f.parent_id == parent_id) - .cloned() - .collect(); - Ok(filtered) - } - - async fn get_files( - &self, - folder_id: Option, - search_query: Option<&str>, - _all: Option, - ) -> Result, String> { - let files = self.files.lock().await; - let mut filtered: Vec = files - .iter() - .filter(|f| f.folder_id == folder_id) - .cloned() - .collect(); - - if let Some(query) = search_query { - filtered.retain(|f| f.name.to_lowercase().contains(&query.to_lowercase())); - } - Ok(filtered) - } - - async fn create_folder( - &self, - name: &str, - parent_id: Option, - ) -> Result { - let mut folders = self.folders.lock().await; - let new_id = (folders.len() + 1) as i64; - let folder = FolderMetadata { - id: new_id, - parent_id, - name: name.to_string(), - }; - folders.push(folder.clone()); - Ok(folder) - } - - async fn delete_folder(&self, id: i64) -> Result { - let mut folders = self.folders.lock().await; - folders.retain(|f| f.id != id); - Ok(true) - } - - async fn delete_file(&self, id: i64) -> Result { - let mut files = self.files.lock().await; - files.retain(|f| f.id != id); - Ok(true) - } - - async fn upload_part( - &self, - _file_id: i64, - _part_index: i32, - _file_size: i64, - _total_parts: i32, - _byte_offset: i64, - _bytes: Vec, - ) -> Result { - Ok(true) - } - - async fn save_file( - &self, - file_id: i64, - name: &str, - size: i64, - folder_id: Option, - ) -> Result { - let file = FileMetadata { - id: file_id, - folder_id, - name: name.to_string(), - size, - mime_type: None, - file_ext: name.split('.').last().map(|s| s.to_string()), - created_at: "2026-06-11T20:00:00Z".to_string(), - icon_type: "file".to_string(), - telegram_message_id: Some(123), - }; - self.files.lock().await.push(file.clone()); - Ok(file) - } - - async fn download_file(&self, _file_id: i64) -> Result, String> { - Ok(b"mock file content".to_vec()) - } - - async fn get_upload_progress(&self, _file_id: i64) -> Result { - Ok(100) - } -} diff --git a/apps/server/testing/test_real_api.sh b/apps/server/testing/test_real_api.sh index 485cf37..aebe3f8 100755 --- a/apps/server/testing/test_real_api.sh +++ b/apps/server/testing/test_real_api.sh @@ -1,13 +1,11 @@ #!/usr/bin/env bash # ============================================================================= -# test_real_api.sh — Comprehensive API Test Suite for AudioMesh Backend +# test_real_api.sh — API Test Suite for AudioMesh Backend # -# Tests all public and protected server endpoints, verifies file and folder -# listing, and ensures pass/fail tracking across all test steps. +# Tests all AudioMesh REST endpoints (room CRUD, topology, RTT) and verifies +# pass/fail tracking across all test steps. # ============================================================================= -# Disable exit-on-error globally so that failed assertions don't halt execution, -# allowing us to print a comprehensive summary of all test cases. set -uo pipefail BASE_URL="${BASE_URL:-http://127.0.0.1:50065}" @@ -27,15 +25,15 @@ TESTS_TOTAL=0 # ── Helpers ─────────────────────────────────────────────────────────────────── print_step() { - echo -e "\n${CYAN}${BOLD}▶ $1${RESET}" >&2 + printf "\n${CYAN}${BOLD}▶ %s${RESET}\n" "$1" >&2 } print_success() { - echo -e "${GREEN}✓ $1${RESET}" >&2 + printf "${GREEN}✓ %s${RESET}\n" "$1" >&2 } print_error() { - echo -e "${RED}✗ ERROR: $1${RESET}" >&2 + printf "${RED}✗ ERROR: %s${RESET}\n" "$1" >&2 } # Low-level request helper @@ -50,18 +48,18 @@ request() { response=$(curl -s -w "\n%{http_code}" -X "$method" \ -H "Content-Type: application/json" \ -d "$data" \ - "${BASE_URL}${path}" || echo -e "\n000") + "${BASE_URL}${path}" || printf "\n000") else response=$(curl -s -w "\n%{http_code}" -X "$method" \ -H "Content-Type: application/json" \ - "${BASE_URL}${path}" || echo -e "\n000") + "${BASE_URL}${path}" || printf "\n000") fi local status_code - status_code=$(echo "$response" | tail -n 1 | tr -d '\r' | xargs) + status_code=$(printf "%s\n" "$response" | tail -n 1 | tr -d '\r' | xargs) local body - body=$(echo "$response" | sed '$d') + body=$(printf "%s\n" "$response" | sed '$d') if [ -z "$status_code" ] || [ "$status_code" = "" ]; then status_code="000" @@ -71,36 +69,6 @@ request() { jq -n --arg code "$status_code" --arg body "$body" '{"status": ($code|tonumber), "body": $body}' } -# Custom helper for binary upload-part -upload_part_api() { - local file_id="$1" - local part_index="$2" - local local_file="$3" - - local body_file - body_file=$(mktemp) - - local status_code - status_code=$(curl -s -X POST \ - -w "%{http_code}" \ - -o "$body_file" \ - -H "Content-Type: application/octet-stream" \ - --data-binary "@${local_file}" \ - "${BASE_URL}/files/upload-part?file_id=${file_id}&part_index=${part_index}" || echo "000") - - local body="" - if [ -f "$body_file" ]; then - body=$(cat "$body_file") - fi - rm -f "$body_file" - - if [ -z "$status_code" ] || [ "$status_code" = "" ]; then - status_code="000" - fi - - jq -n --arg code "$status_code" --arg body "$body" '{"status": ($code|tonumber), "body": $body}' -} - # Run a test step and update statistics # Usage: run_api_test [payload_json] [expected_status] run_api_test() { @@ -114,435 +82,144 @@ run_api_test() { local res res=$(request "$method" "$path" "$payload") local status - status=$(echo "$res" | jq -r '.status') + status=$(printf "%s\n" "$res" | jq -r '.status') local body - body=$(echo "$res" | jq -r '.body') + body=$(printf "%s\n" "$res" | jq -r '.body') - if [ "$status" -eq "$expected_status" ]; then + if [ "${status:-0}" -eq "${expected_status}" ]; then TESTS_PASSED=$((TESTS_PASSED + 1)) print_success "$method $path -> HTTP $status (Expected $expected_status)" - echo "$body" + printf "%s\n" "$body" | jq '.' 2>/dev/null || printf "%s\n" "$body" return 0 else TESTS_FAILED=$((TESTS_FAILED + 1)) print_error "$method $path -> Got HTTP $status, Expected $expected_status" - echo "$body" >&2 + printf "%s\n" "$body" >&2 return 1 fi } - # ── Preflight Checks ────────────────────────────────────────────────────────── clear -echo -e "${BOLD}====================================================${RESET}" -echo -e "${BOLD} AudioMesh Real Mode API Test Tool ${RESET}" -echo -e "${BOLD}====================================================${RESET}" -echo -e "Target Server URL: ${CYAN}${BASE_URL}${RESET}" +printf "${BOLD}====================================================${RESET}\n" +printf "${BOLD} AudioMesh REST API Test Tool ${RESET}\n" +printf "${BOLD}====================================================${RESET}\n" +printf "Target Server URL: ${CYAN}%s${RESET}\n" "${BASE_URL}" # Verify jq is installed if ! command -v jq &> /dev/null; then print_error "'jq' is required but not installed." - echo "Please install jq first (e.g. 'brew install jq')." + printf "Please install jq first (e.g. 'brew install jq').\n" exit 1 fi -# Verify server connectivity -res=$(request GET "/auth/state") -status_code=$(echo "$res" | jq -r '.status') -if [ "$status_code" -eq 0 ]; then +# Verify server connectivity / health +res=$(request GET "/health") +status_code=$(printf "%s\n" "$res" | jq -r '.status') +if [ "${status_code:-0}" -eq 0 ] || [ "${status_code:-0}" -ne 200 ]; then print_error "Cannot connect to AudioMesh backend server at ${BASE_URL}." - echo "Ensure the server is running." - echo "Remember to run the server in REAL mode using:" - echo -e " ${BOLD}TELEGRAM_API_ID= TELEGRAM_API_HASH= cargo run${RESET}" + printf "Ensure the server is running (e.g. 'cargo run' or similar).\n" exit 1 fi -print_success "Server is reachable." +print_success "Server is reachable and healthy." -# Get initial auth state -initial_state=$(echo "$res" | jq -r '.body') -status=$(echo "$initial_state" | jq -r '.status') +# ── Step 1: Create an SFU Room ──────────────────────────────────────────────── +print_step "Step 1: Create an SFU Room" +sfu_room_payload=$(jq -n \ + --arg name "Living Room SFU" \ + --arg mode "sfu" \ + --arg host "host-device-sfu" \ + '{name: $name, mode: $mode, host_peer_id: $host}') -echo -e "Initial Server Auth State: ${YELLOW}${status}${RESET}" - -skip_login="false" -if [ "$status" = "LoggedIn" ]; then - echo -e "\n${GREEN}You are already logged in. Skipping login flow and proceeding to test protected endpoints...${RESET}" - skip_login="true" -fi +sfu_create_res=$(request POST "/mesh/rooms" "$sfu_room_payload") +sfu_status=$(printf "%s\n" "$sfu_create_res" | jq -r '.status') +sfu_body=$(printf "%s\n" "$sfu_create_res" | jq -r '.body') -# Refresh state -status=$(request GET "/auth/state" | jq -r '.body | fromjson | .status') - -if [ "$skip_login" = "false" ] && [ "$status" != "LoggedIn" ]; then - # ── Step 1: Input Credentials ─────────────────────────────────────────────── - print_step "Step 1: Enter Telegram Credentials" - echo "Please provide the phone number and API credentials." - - read -p "Phone Number (e.g. +15555551234): " PHONE - if [ -z "$PHONE" ]; then - print_error "Phone number cannot be empty." - exit 1 - fi - - read -p "Telegram API ID (from my.telegram.org): " API_ID - if [ -z "$API_ID" ]; then - print_error "API ID cannot be empty." - exit 1 - fi - - read -p "Telegram API Hash (from my.telegram.org): " API_HASH - if [ -z "$API_HASH" ]; then - print_error "API Hash cannot be empty." - exit 1 - fi - - # ── Step 2: Request Login Code ────────────────────────────────────────────── - print_step "Step 2: Requesting Login Code..." - payload=$(jq -n \ - --arg phone "$PHONE" \ - --arg api_id "$API_ID" \ - --arg api_hash "$API_HASH" \ - '{phone: $phone, api_id: $api_id, api_hash: $api_hash}') - - # Test POST /auth/send-code - TESTS_TOTAL=$((TESTS_TOTAL + 1)) - send_code_res=$(request POST "/auth/send-code" "$payload") - send_code_status=$(echo "$send_code_res" | jq -r '.status') - send_code_body=$(echo "$send_code_res" | jq -r '.body') - - if [ "$send_code_status" -eq 200 ] && echo "$send_code_body" | jq -e '.success' >/dev/null; then - TESTS_PASSED=$((TESTS_PASSED + 1)) - print_success "POST /auth/send-code -> HTTP 200" - else - TESTS_FAILED=$((TESTS_FAILED + 1)) - print_error "Failed to send code: $send_code_body" - exit 1 - fi - - # ── Step 3: Verify Login Code ─────────────────────────────────────────────── - print_step "Step 3: Verification" - read -p "Enter the verification code: " CODE - if [ -z "$CODE" ]; then - print_error "Verification code cannot be empty." - exit 1 - fi - - sign_in_payload=$(jq -n \ - --arg phone "$PHONE" \ - --arg code "$CODE" \ - '{phone: $phone, code: $code}') - - # Test POST /auth/sign-in - TESTS_TOTAL=$((TESTS_TOTAL + 1)) - sign_in_res=$(request POST "/auth/sign-in" "$sign_in_payload") - sign_in_status=$(echo "$sign_in_res" | jq -r '.status') - sign_in_body=$(echo "$sign_in_res" | jq -r '.body') - - if [ "$sign_in_status" -eq 200 ]; then - success=$(echo "$sign_in_body" | jq -r '.success') - next_step=$(echo "$sign_in_body" | jq -r '.next_step') - if [ "$success" = "true" ]; then - TESTS_PASSED=$((TESTS_PASSED + 1)) - print_success "POST /auth/sign-in -> HTTP 200" - else - TESTS_FAILED=$((TESTS_FAILED + 1)) - print_error "Sign-in failed: $sign_in_body" - exit 1 - fi - else - TESTS_FAILED=$((TESTS_FAILED + 1)) - print_error "Sign-in endpoint returned HTTP $sign_in_status: $sign_in_body" - exit 1 - fi - - # ── Step 4: Handle 2-Factor Authentication (if required) ──────────────────── - if [ "$next_step" = "password" ]; then - print_step "Step 4: Two-Step Verification Required" - read -s -p "Enter your 2FA password: " PASSWORD - echo "" # newline - - pwd_payload=$(jq -n --arg pwd "$PASSWORD" '{password: $pwd}') - - # Test POST /auth/check-password (Real Success Case) - TESTS_TOTAL=$((TESTS_TOTAL + 1)) - pwd_res=$(request POST "/auth/check-password" "$pwd_payload") - pwd_status=$(echo "$pwd_res" | jq -r '.status') - pwd_body=$(echo "$pwd_res" | jq -r '.body') - - if [ "$pwd_status" -eq 200 ] && echo "$pwd_body" | jq -e '.success' >/dev/null; then - TESTS_PASSED=$((TESTS_PASSED + 1)) - print_success "POST /auth/check-password -> HTTP 200" - else - TESTS_FAILED=$((TESTS_FAILED + 1)) - print_error "2FA password check failed: $pwd_body" - exit 1 - fi - fi -fi - -# Ensure we are logged in before running protected endpoint tests -status=$(request GET "/auth/state" | jq -r '.body | fromjson | .status') -if [ "$status" != "LoggedIn" ]; then - print_error "Authentication failed. Exiting test suite." - exit 1 -fi - -print_success "Successfully authenticated! Starting comprehensive endpoint checks..." - -# ── PUBLIC & STATE ENDPOINTS ────────────────────────────────────────────────── -print_step "Checking Public / State Endpoints" -run_api_test GET "/health" "" 200 >/dev/null -run_api_test GET "/auth/state" "" 200 >/dev/null - -# Negative tests for auth when already logged in -# POST /auth/sign-up (Maps to sign-in, we accept 200/400/500 to support mock/real state variations) TESTS_TOTAL=$((TESTS_TOTAL + 1)) -res_signup=$(request POST "/auth/sign-up" '{"phone": "invalid", "code": "invalid"}') -status_signup=$(echo "$res_signup" | jq -r '.status') -if [ "$status_signup" -eq 200 ] || [ "$status_signup" -eq 400 ] || [ "$status_signup" -eq 500 ]; then +if [ "${sfu_status:-0}" -eq 201 ]; then TESTS_PASSED=$((TESTS_PASSED + 1)) - print_success "POST /auth/sign-up -> HTTP $status_signup (Accepted 200/400/500)" -else - TESTS_FAILED=$((TESTS_FAILED + 1)) - print_error "POST /auth/sign-up -> HTTP $status_signup (Expected 200/400/500)" -fi - -# POST /auth/check-password (Negative case, we accept 200/400/500 to support mock/real state variations) -TESTS_TOTAL=$((TESTS_TOTAL + 1)) -res_pwd=$(request POST "/auth/check-password" '{"password": "wrong"}') -status_pwd=$(echo "$res_pwd" | jq -r '.status') -if [ "$status_pwd" -eq 200 ] || [ "$status_pwd" -eq 400 ] || [ "$status_pwd" -eq 500 ]; then - TESTS_PASSED=$((TESTS_PASSED + 1)) - print_success "POST /auth/check-password -> HTTP $status_pwd (Accepted 200/400/500)" -else - TESTS_FAILED=$((TESTS_FAILED + 1)) - print_error "POST /auth/check-password -> HTTP $status_pwd (Expected 200/400/500)" -fi - -# ── USERS & ACCOUNT ENDPOINTS ───────────────────────────────────────────────── -print_step "Checking Users & Account Endpoints" - -# GET /users/me -me_res=$(run_api_test GET "/users/me" "" 200) -my_id=$(echo "$me_res" | jq -r '.id') -my_first_name=$(echo "$me_res" | jq -r '.first_name') -my_last_name=$(echo "$me_res" | jq -r '.last_name // ""') -my_username=$(echo "$me_res" | jq -r '.username // ""') - -# GET /users/get-users -run_api_test GET "/users/get-users" "" 200 >/dev/null - -# GET /users/get-full-user -run_api_test GET "/users/get-full-user?id=${my_id}" "" 200 >/dev/null - -# GET /account/get-password -run_api_test GET "/account/get-password" "" 200 >/dev/null - -# POST /account/update-profile -profile_payload=$(jq -n \ - --arg first "$my_first_name" \ - --arg last "$my_last_name" \ - '{first_name: $first, last_name: $last}') -run_api_test POST "/account/update-profile" "$profile_payload" 200 >/dev/null - -# POST /account/update-status -run_api_test POST "/account/update-status" '{"offline": false}' 200 >/dev/null - -# POST /account/update-username -username_payload=$(jq -n --arg user "$my_username" '{username: $user}') -run_api_test POST "/account/update-username" "$username_payload" 200 >/dev/null - - -# ── CONTACTS ENDPOINTS (STUBS) ──────────────────────────────────────────────── -print_step "Checking Contacts Endpoints (Stubs)" -run_api_test GET "/contacts/get-contacts" "" 200 >/dev/null -run_api_test GET "/contacts/search?q=test" "" 200 >/dev/null -run_api_test POST "/contacts/import-contacts" "{}" 200 >/dev/null - - -# ── DRIVE & FILE QUERIES ────────────────────────────────────────────────────── -print_step "Checking Drive Overview Endpoints" -run_api_test GET "/drive/list" "" 200 >/dev/null -run_api_test GET "/drive/stats" "" 200 >/dev/null - -# Query files at drive root -run_api_test GET "/drive/files" "" 200 >/dev/null - -# ── LIST FILES IN CHANNELS/FOLDERS ─────────────────────────────────────────── -print_step "Listing Folders and Files in each Channel/Folder" - -folders_res=$(run_api_test GET "/drive/folders" "" 200) -folder_count=$(echo "$folders_res" | jq '. | length') -echo -e "\nFound ${GREEN}${folder_count}${RESET} channel folders:" - -for ((i=0; i/dev/null; then - print_success "Folder verification: Found created folder in listing." - else - TESTS_FAILED=$((TESTS_FAILED + 1)) - print_error "Folder verification: Created folder NOT found in listing!" - fi - - # Delete folder - run_api_test POST "/drive/folders/delete" "{\"id\": ${created_id}}" 200 >/dev/null - - # Verify deleted - verify_deleted=$(run_api_test GET "/drive/folders" "" 200) - if echo "$verify_deleted" | jq -e ".[] | select(.id == ${created_id})" >/dev/null; then - TESTS_FAILED=$((TESTS_FAILED + 1)) - print_error "Folder cleanup verification: Deleted folder is STILL in listing!" - else - print_success "Folder cleanup verification: Folder successfully removed." - fi + sfu_room_id=$(printf "%s\n" "$sfu_body" | jq -r '.id') + print_success "POST /mesh/rooms (SFU) -> HTTP 201 (Created room ID: $sfu_room_id)" else TESTS_FAILED=$((TESTS_FAILED + 1)) - print_error "Folder creation did not return a valid folder ID: $create_folder_res" + print_error "Failed to create SFU room: $sfu_body" + exit 1 fi +# ── Step 2: Create a Daisy-Chain Room with Custom Nodes ───────────────────────── +print_step "Step 2: Create a Daisy-Chain Room with Custom Max Nodes" +daisy_room_payload=$(jq -n \ + --arg name "Hallway Chain" \ + --arg mode "daisy_chain" \ + --arg host "host-device-daisy" \ + --argjson max_nodes 10 \ + '{name: $name, mode: $mode, host_peer_id: $host, max_nodes: $max_nodes}') -# ── FILE LIFECYCLE (UPLOAD, SAVE, DOWNLOAD) ─────────────────────────────────── -print_step "Checking File Lifecycle (Upload Part, Save File, Download File)" - -# Generate a temporary local file with unique random string content -temp_upload_file=$(mktemp) -echo -n "AudioMesh file upload content - $(date) - $((RANDOM))" > "$temp_upload_file" -orig_size=$(wc -c < "$temp_upload_file" | xargs) # Strip spaces portably -file_id=$((100000 + RANDOM % 900000)) +daisy_create_res=$(request POST "/mesh/rooms" "$daisy_room_payload") +daisy_status=$(printf "%s\n" "$daisy_create_res" | jq -r '.status') +daisy_body=$(printf "%s\n" "$daisy_create_res" | jq -r '.body') -# 1. upload-part TESTS_TOTAL=$((TESTS_TOTAL + 1)) -upload_res=$(upload_part_api "$file_id" 0 "$temp_upload_file") -upload_status=$(echo "$upload_res" | jq -r '.status') -upload_body=$(echo "$upload_res" | jq -r '.body') - -if [ "$upload_status" -eq 200 ] && echo "$upload_body" | jq -e '.success' >/dev/null; then +if [ "${daisy_status:-0}" -eq 201 ]; then TESTS_PASSED=$((TESTS_PASSED + 1)) - print_success "POST /files/upload-part -> HTTP 200" + daisy_room_id=$(printf "%s\n" "$daisy_body" | jq -r '.id') + print_success "POST /mesh/rooms (Daisy-Chain) -> HTTP 201 (Created room ID: $daisy_room_id)" else TESTS_FAILED=$((TESTS_FAILED + 1)) - print_error "POST /files/upload-part failed: $upload_body" + print_error "Failed to create Daisy-Chain room: $daisy_body" + exit 1 fi -# 2. save-file -save_payload=$(jq -n \ - --argjson fid "$file_id" \ - --arg name "api_test_upload_$file_id.txt" \ - --argjson size "$orig_size" \ - '{file_id: $fid, name: $name, size: $size, folder_id: null}') +# ── Step 3: List Rooms ──────────────────────────────────────────────────────── +print_step "Step 3: List Active Rooms" +run_api_test GET "/mesh/rooms" "" 200 >/dev/null -run_api_test POST "/files/save-file" "$save_payload" 200 >/dev/null +# ── Step 4: Get Room Details ────────────────────────────────────────────────── +print_step "Step 4: Get SFU Room Details" +run_api_test GET "/mesh/rooms/${sfu_room_id}" "" 200 >/dev/null -# 3. Verify file exists in root file list -verify_files=$(run_api_test GET "/drive/files" "" 200) -if echo "$verify_files" | jq -e ".[] | select(.id == ${file_id})" >/dev/null; then - print_success "File verification: Created file found in root file list." -else - TESTS_FAILED=$((TESTS_FAILED + 1)) - print_error "File verification: Created file NOT found in root file list!" -fi - -# 4. download / get-file verification -temp_download_file1=$(mktemp) -temp_download_file2=$(mktemp) - -# Test /files/download -TESTS_TOTAL=$((TESTS_TOTAL + 1)) -dl1_status=$(curl -s -X GET "${BASE_URL}/files/download?file_id=${file_id}" -o "$temp_download_file1" -w "%{http_code}") -if [ "$dl1_status" -eq 200 ]; then - if diff "$temp_upload_file" "$temp_download_file1" >/dev/null; then - TESTS_PASSED=$((TESTS_PASSED + 1)) - print_success "GET /files/download -> HTTP 200 and file content matches exactly" - else - TESTS_FAILED=$((TESTS_FAILED + 1)) - print_error "GET /files/download -> File content mismatch!" - fi -else - TESTS_FAILED=$((TESTS_FAILED + 1)) - print_error "GET /files/download -> Returned HTTP $dl1_status" -fi +# ── Step 5: Get Topology Tree ───────────────────────────────────────────────── +print_step "Step 5: Get Daisy-Chain Room Topology (Should be empty initially)" +run_api_test GET "/mesh/rooms/${daisy_room_id}/topology" "" 200 >/dev/null -# Test /files/get-file -TESTS_TOTAL=$((TESTS_TOTAL + 1)) -dl2_status=$(curl -s -X GET "${BASE_URL}/files/get-file?file_id=${file_id}" -o "$temp_download_file2" -w "%{http_code}") -if [ "$dl2_status" -eq 200 ]; then - if diff "$temp_upload_file" "$temp_download_file2" >/dev/null; then - TESTS_PASSED=$((TESTS_PASSED + 1)) - print_success "GET /files/get-file -> HTTP 200 and file content matches exactly" - else - TESTS_FAILED=$((TESTS_FAILED + 1)) - print_error "GET /files/get-file -> File content mismatch!" - fi -else - TESTS_FAILED=$((TESTS_FAILED + 1)) - print_error "GET /files/get-file -> Returned HTTP $dl2_status" -fi +# ── Step 6: Negative Test - Get Non-Existent Room ───────────────────────────── +print_step "Step 6: Get Non-Existent Room (Expected 404)" +run_api_test GET "/mesh/rooms/nonexistent-room-id" "" 404 >/dev/null -# Local file cleanup -rm -f "$temp_upload_file" "$temp_download_file1" "$temp_download_file2" +# ── Step 7: Negative Test - Report RTT on Non-Existent Room ─────────────────── +print_step "Step 7: Report RTT on Non-Existent Room (Expected 404)" +rtt_payload=$(jq -n \ + --arg from "host-device-daisy" \ + --arg to "peer-device-1" \ + --argjson rtt 15.5 \ + '{from_peer_id: $from, to_peer_id: $to, rtt_ms: $rtt}') +run_api_test POST "/mesh/rooms/nonexistent-room-id/topology/rtt" "$rtt_payload" 404 >/dev/null -# ── SESSION CLEANUP ─────────────────────────────────────────────────────────── -print_step "Session Cleanup" -read -p "Keep session active? (y/n): " keep_session -if [[ ! "$keep_session" =~ ^[Yy]$ ]]; then - # Test log-out & reset-authorization - run_api_test POST "/auth/log-out" "{}" 200 >/dev/null - run_api_test POST "/auth/reset-authorization" "{}" 200 >/dev/null -else - echo -e "${GREEN}Session kept active. You can open the client React app now!${RESET}" -fi +# ── Step 8: Close and Delete Rooms ──────────────────────────────────────────── +print_step "Step 8: Close and Delete Active Rooms" +run_api_test DELETE "/mesh/rooms/${sfu_room_id}" "" 200 >/dev/null +run_api_test DELETE "/mesh/rooms/${daisy_room_id}" "" 200 >/dev/null +# ── Step 9: Verify Deletion ─────────────────────────────────────────────────── +print_step "Step 9: Verify Rooms are Deleted (Expected 404)" +run_api_test GET "/mesh/rooms/${sfu_room_id}" "" 404 >/dev/null +run_api_test GET "/mesh/rooms/${daisy_room_id}" "" 404 >/dev/null # ── FINAL SUMMARY ───────────────────────────────────────────────────────────── -echo -e "\n${BOLD}====================================================${RESET}" -echo -e "${BOLD} API Test Suite Summary ${RESET}" -echo -e "${BOLD}====================================================${RESET}" -echo -e "Total Tests Executed: ${CYAN}${TESTS_TOTAL}${RESET}" -echo -e "Passed: ${GREEN}${TESTS_PASSED}${RESET}" -echo -e "Failed: ${RED}${TESTS_FAILED}${RESET}" -echo -e "${BOLD}====================================================${RESET}" - -if [ "$TESTS_FAILED" -eq 0 ]; then - echo -e "${GREEN}${BOLD} ALL TESTS COMPLETED SUCCESSFULLY! ${RESET}" +printf "\n${BOLD}====================================================${RESET}\n" +printf "${BOLD} API Test Suite Summary ${RESET}\n" +printf "${BOLD}====================================================${RESET}\n" +printf "Total Tests Executed: ${CYAN}%s${RESET}\n" "${TESTS_TOTAL}" +printf "Passed: ${GREEN}%s${RESET}\n" "${TESTS_PASSED}" +printf "Failed: ${RED}%s${RESET}\n" "${TESTS_FAILED}" +printf "${BOLD}====================================================${RESET}\n" + +if [ "${TESTS_FAILED:-0}" -eq 0 ]; then + printf "${GREEN}${BOLD} ALL TESTS COMPLETED SUCCESSFULLY! ${RESET}\n" exit 0 else - echo -e "${RED}${BOLD} SOME TESTS ENCOUNTERED FAILURES. ${RESET}" + printf "${RED}${BOLD} SOME TESTS ENCOUNTERED FAILURES. ${RESET}\n" exit 1 fi diff --git a/apps/web/CONTEXT.md b/apps/web/CONTEXT.md deleted file mode 100644 index 0c33586..0000000 --- a/apps/web/CONTEXT.md +++ /dev/null @@ -1,104 +0,0 @@ -# apps/web — Context - -## What is this? - -The `@fastdeck/web` workspace is the **public-facing web application** for FastDeck. It serves as the marketing site, documentation hub, and eventually the web dashboard for managing profiles online. - -Built with **React 18 + Vite 5**, it shares the same design system tokens and shared types as the rest of the monorepo. - ---- - -## Responsibilities - -| Responsibility | Details | -|---------------|---------| -| **Marketing** | Landing page showcasing FastDeck features, screenshots, and downloads | -| **Documentation** | Getting started guides, plugin authoring docs, action references | -| **Web Dashboard** (V3) | Cloud-synced profile management, grid editor in the browser | -| **Design System host** | Canonical CSS design tokens shared across all web-rendered surfaces | - ---- - -## Folder Structure - -``` -apps/web/ -│ -├── index.html # Entry HTML — SEO meta, Inter font, theme-color -├── vite.config.ts # Vite: port 3000, @/ and @shared/ aliases -├── tsconfig.json # Extends ../../tsconfig.base.json -├── package.json # @fastdeck/web — React 18, Framer Motion, Vite -│ -└── src/ - ├── main.tsx # React DOM root (StrictMode) - ├── App.tsx # Root application component / routing shell - ├── index.css # Global design system (tokens, reset, utilities) - │ - ├── pages/ # (planned) Route-level page components - │ ├── Home.tsx # Landing page / hero - │ ├── Docs.tsx # Documentation index - │ └── Dashboard.tsx # (V3) Authenticated profile manager - │ - ├── components/ # (planned) Reusable UI components - │ ├── Navbar/ - │ ├── Hero/ - │ ├── FeatureCard/ - │ └── Footer/ - │ - └── assets/ # (planned) Static images, icons, og images -``` - ---- - -## Design System - -The `src/index.css` file defines the canonical **CSS design tokens** for all web-rendered surfaces: - -- **Color palette**: HSL-based with dark mode as default. Accent `hsl(250, 90%, 65%)`. -- **Glassmorphism**: `backdrop-filter: blur`, semi-transparent backgrounds, subtle borders. -- **Typography**: Inter font, fluid `clamp()` sizes from `xs` to `5xl`. -- **Spacing scale**: `--space-1` through `--space-24`. -- **Animations**: `fadeInUp`, `gradient-shift`, `pulse-glow`, `float` keyframes. -- **Components**: `.nav`, `.hero`, `.card`, `.btn`, `.card-grid`, `.footer`. - ---- - -## Key Dependencies - -| Package | Purpose | -|---------|---------| -| `react@18` | UI framework | -| `react-dom@18` | DOM rendering | -| `framer-motion@11` | Animations — scroll-reveal, hover, page transitions | -| `vite@5` | Dev server and bundler | -| `@vitejs/plugin-react` | Vite React fast-refresh integration | -| `typescript@5` | Type safety | - ---- - -## Scripts - -```bash -yarn dev:web # Start dev server at http://localhost:3000 -yarn build:web # TypeScript check + Vite production build -``` - ---- - -## Path Aliases - -| Alias | Resolves To | -|-------|-------------| -| `@/` | `apps/web/src/` | -| `@shared/` | `packages/shared/src/` | - ---- - -## SEO - -Every page must include: -- `FastDeck — [Page Name]` -- `` -- A single `

` per page -- Semantic HTML5 elements (`