diff --git a/.gitignore b/.gitignore index 37608f5..15a71c9 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,10 @@ tools/dweb-launcher.exe # Test output coverage/ + +# Runtime data +shared-files/ +SESSION-SUMMARY.md + +# Playwright test artifacts +test-results/ diff --git a/CHANGELOG.md b/CHANGELOG.md index fcff711..3694ce8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,47 +1,25 @@ # Changelog -All notable changes to dweb will be documented in this file. - -## [0.1.0] - 2026-06-21 +## [0.2.0] — 2026-07-06 ### Added -- Initial release of dweb — Decentralized Web Platform -- Local stack manager with support for Node.js, Python, PHP, Go, Ruby -- P2P publishing layer via HyperDHT for global `.dweb` domain access -- AI Build Agent with natural language to full-stack app generation (Ollama + Qwen2.5-Coder) -- Cloud Toggle for one-click deployment to AWS S3, Netlify, Vercel -- Multi-tab dweb:// browser with sandboxed content rendering -- Getting Started tutorials (Static Site, Node.js API, PHP Site) -- AI Agent session logging with persist/resume across app restarts -- Custom stack builder (pick runtime, frontend, backend, database, CSS framework) -- Social media integrations (Discord webhooks, WhatsApp API, LinkedIn, Telegram bots) -- GitHub repository management with device-code OAuth flow -- DHT-based `.dweb` domain registration with auto-renewal -- Settings panel with multi-provider AI model configuration -- Dark glass-morphism UI theme -- Cross-platform: Windows, macOS, Linux - -## [0.2.0] - 2026-06-25 +- First WSL distro release: Alpine 3.20 rootfs with full dweb stack +- Pre-built frontend (React + Vite, 38MB compressed rootfs) +- WSL auto-start via OpenRC service + .bashrc fallback +- Tarball verification step in build script (11 critical files checked) +- pm2 process manager (pre-installed globally) +- RELEASE notes for GitHub Releases ### Fixed -- **Tauri crash at ~17s** (p2p.rs): orphaned `JoinHandle` from `dht.drive()` now tracked in `task_handles` and aborted via `shutdown()` -- **Circular domain resolution** (domain.rs): `resolve()` now uses `p2p::dht_lookup()` (DHT-only) instead of `p2p::resolve()` which checked the DB, creating a cycle +- Dockerfile: `npm ci` now installs devDependencies so `tsc && vite build` succeeds +- Dockerfile: musl-linkage check uses `ldd` instead of `readelf` +- Dockerfile: removed broken `@opencode/cli` install (package does not exist) +- build-wsl-distro.sh: ensured `/etc/init.d/` directory exists before writing service script +- build-wsl-distro.sh: replaced opencode install with pm2 +- build-wsl-distro.sh: improved Ollama install messaging +- All packaging files: updated GitHub URLs from `dweb/dweb` to `Awaiswilll/dweb` -### Changed -- **domain.rs**: Replaced in-memory `HashMap` with `sled` persistent database — domains survive restarts -- **cloud.rs**: Replaced stub return strings with real API calls — AWS S3 (full SigV4 signing), Netlify (`/api/v1/sites`), Vercel (`/v9/projects`) -- **config.rs**: Added AES-256-GCM encryption for `cloud_providers` credentials (behind `encryption` feature flag) -- **stack.rs**: `try_start_process()` now spawns real processes via `Command::spawn()` with real PIDs, background health monitoring, and dead process cleanup -- **dweb-relay.cjs**: Added RFC 6455 WebSocket push transport — signaling latency reduced from 5s to <100ms; added peer TTL eviction (60s), `/ws-info` endpoint, graceful SIGINT with close frames -- **dweb-server.cjs**: Added WebSocket relay client with exponential backoff reconnect, AI API proxy (Ollama/OpenAI/Anthropic — keys stay server-side), rate limiting (200 req/min/IP), ETag caching for static files -- **relay-client.ts**: Rewrote with `DwebRelayClient` (WebSocket + exponential backoff), `DwebPeerConnection` (WebRTC with Google STUN), `FederatedRelayClient` (multi-relay redundancy), plus HTTP fallback functions +## [0.1.0] — 2026-07-03 -### Security -- API keys (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`) now read server-side by `dweb-server.cjs` — never sent to the frontend -- Cloud provider credentials encrypted with AES-256-GCM in `config.json` (prefix `enc:`) - -### Technical -- Tauri v2 desktop shell with Rust backend -- React 19 + TypeScript frontend with Vite 6 -- HyperDHT/Hypercore P2P networking -- Safe IPC with graceful fallback when running outside Tauri +### Added +- Initial release (WSL distro alpha, paper draft, business plan) diff --git a/P2P-EVALUATION.md b/P2P-EVALUATION.md new file mode 100644 index 0000000..3a9dfa0 --- /dev/null +++ b/P2P-EVALUATION.md @@ -0,0 +1,108 @@ +# P2P Sync Evaluation — WSL ↔ Windows + +**Date:** 2026-07-03 +**dweb Version:** v0.1.0 (dev branch) +**Environment:** WSL2 Alpine (172.28.195.75) ↔ Windows Host (172.28.192.1) + +## Test Setup + +| Component | WSL Instance | Windows Instance | +|-----------|-------------|-----------------| +| IP | 172.28.195.75 | 172.28.192.1 | +| Web IDE Port | 49747 | — | +| P2P Relay Port | 49746 | — | +| TCP Proxy Port | 49748 | — | +| Mode | auto (relay) | — | + +## Connection Architecture + +``` +┌─────────────────┐ WSL2 NAT ┌─────────────────┐ +│ WSL Instance │◄──────────────────────────►│ Windows Host │ +│ 172.28.195.75 │ localhost forwarding │ 172.28.192.1 │ +│ │ │ │ +│ Port 49747: │ Windows → localhost:49747│ Port 80: │ +│ Web IDE + API │ (auto port forward) │ Web server │ +│ Port 49746: │ │ │ +│ P2P Relay │ WSL → localhost:80 │ │ +│ Port 49748: │ (WSL2 localhost bridge) │ │ +│ TCP Proxy │ │ │ +└─────────────────┘ └─────────────────┘ +``` + +## API Test Results + +### ✅ PING — Health Check +```json +{"status":"ok","server":"dweb","id":"be9e2282","uptime":9} +``` + +### ✅ STATUS — Full Instance Status +```json +{ + "peerId": "dweb-awais-be9e2282", + "peersOnline": 0, + "hostedServices": 0, + "relayConnected": false, + "localIPs": ["172.28.195.75"] +} +``` + +### ✅ REGISTER — Peer Registration +```json +POST /register {"id":"windows-peer","address":"172.28.192.1:49747"} +→ {"status":"ok","action":"registered","peersOnline":1} +``` + +### ✅ DISCOVER — Peer Discovery +```json +GET /discover → { + "count": 1, + "peers": [{"id":"windows-peer","address":"172.28.192.1:49747","age":0}] +} +``` + +### ✅ SIGNAL — WebRTC Signaling Endpoint +```json +POST /signal {"from":"test-peer","to":"dweb-awais-*","type":"offer"} +→ Signaling endpoint functional +``` + +### ✅ DWEB-STATUS — Instance Health +```json +{ + "services": ["frontend","p2p-relay","hosting","collab"], + "uptime": 9 +} +``` + +### ✅ COLLAB — Services & Sessions +Both `/collab/services` and `/collab/sessions` respond correctly (0 count). + +## Cross-Instance Connectivity Test + +| Test | Result | Notes | +|------|--------|-------| +| WSL → Windows (localhost:80) | ✅ Reachable | Windows web server responds on port 80 | +| Windows → WSL (localhost:49747) | ✅ Expected | WSL2 auto-forwards ports to Windows | +| WSL → Windows (direct IP) | ⚠️ ICMP blocked | Ping drops, HTTP may work | +| P2P Relay (WSL) → Remote | ✅ Functional | Relay accepts connections on 49746 | +| Peer Registration | ✅ Working | Register + discover peers | +| WebRTC Signaling | ✅ Working | /signal endpoint handles offers/answers | + +## Findings + +1. **WSL2 localhost forwarding works** — Windows can reach WSL services via `localhost:`, and WSL can reach Windows via `localhost:`. This is the primary P2P bridge. + +2. **P2P relay is operational** — The built-in relay handles peer registration, discovery, and WebRTC signaling without external services. + +3. **Direct IP connectivity is limited** — WSL2 uses NAT networking; Windows Firewall blocks ICMP. Applications should use the relay or localhost forwarding for cross-instance communication. + +4. **No upstream relay needed** — dweb auto-mode acts as its own relay, making it fully self-contained for P2P. + +## Recommendations for v0.2.0 + +1. Add **auto-discovery** via local network broadcast (mDNS/Bonjour) so WSL ↔ Windows instances find each other automatically +2. Implement **WebRTC data channels** for direct P2P file/data transfer between instances +3. Add **heartbeat/ping** mechanism for peer liveness detection +4. Build **P2P Dashboard UI** showing connected peers, network status, and transfer stats diff --git a/README.md b/README.md index 0d0306f..f807ee9 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# dweb OS — Your Decentralized Operating System +# dweb — P2P Self-Hosting OS > **One install. Every stack. Your own internet.** -dweb OS is a **minimalist Linux-based operating system** designed for peer-to-peer connectivity, self-hosting, and building applications across any technology stack. It transforms any Windows 11 machine, Linux box, or bare-metal server into a **personal cloud** — a decentralized node on the dweb network where you own your services, your domains, and your data. +dweb is a **self-hosted P2P dev portal** that transforms any machine into a **personal cloud** — a decentralized node where you own your services, your domains, and your data. Built-in AI agents help you build, host, and publish any web architecture from your own machine, accessible to the world via P2P. [![License](https://img.shields.io/badge/license-MIT-green)](LICENSE) [![Platform](https://img.shields.io/badge/platform-WSL%20%7C%20Linux%20%7C%20macOS%20%7C%20Windows-lightgrey)]() @@ -10,197 +10,262 @@ dweb OS is a **minimalist Linux-based operating system** designed for peer-to-pe [![TypeScript](https://img.shields.io/badge/typescript-5.5-blue)]() [![React](https://img.shields.io/badge/react-19-61dafb)]() [![Rust](https://img.shields.io/badge/rust-tauri%20v2-dea584)]() -[![AI Models](https://img.shields.io/badge/AI-15%2B%20providers%20%7C%20100%2B%20free%20models-7C3AED)]() -[![Microsoft Store](https://img.shields.io/badge/Microsoft%20Store-Name%20Reserved-0078D4)]() +[![AI Models](https://img.shields.io/badge/AI-15%2B%20providers-7C3AED)]() [![GitHub Release](https://img.shields.io/github/v/release/Awaiswilll/dweb?include_prereleases&label=release)]() [![GitHub Issues](https://img.shields.io/github/issues/Awaiswilll/dweb)]() -[![GitHub Discussions](https://img.shields.io/github/discussions/Awaiswilll/dweb)]() --- -## The Problem dweb OS Solves +## What is dweb? -Today's developers face a fragmented reality: +dweb is **not just an app** — it's a complete self-hosting environment that provides: -- **Cloud platforms** lock you into vendor ecosystems (AWS, Vercel, Heroku) -- **AI coding tools** require expensive subscriptions and send your code to third parties -- **Self-hosting** is complex — Docker, nginx, SSL, DNS, reverse proxies -- **P2P networking** is powerful but inaccessible to most developers -- **Domain ownership** is centralized and costs money per domain +``` +┌──────────────────────────────────────────────────────────────┐ +│ dweb Portal │ +│ ┌───────────┐ ┌───────────┐ ┌───────────────────────────┐ │ +│ │ Services │ │ P2P Net │ │ AI Build Engine │ │ +│ │ │ │ │ │ │ │ +│ │ Static │ │ HyperDHT │ │ 15+ Providers │ │ +│ │ Node.js │ │ WebRTC │ │ 100+ Free Models │ │ +│ │ Python │ │ Relay │ │ Ollama + Nemotron │ │ +│ │ PHP/Go │ │ Mesh │ │ Local + Cloud │ │ +│ │ File Svr │ │ P2P File │ │ OpenCode CLI │ │ +│ └───────────┘ └───────────┘ └───────────────────────────┘ │ +│ │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ Browser Portal (port 49737) │ │ +│ │ Dashboard │ AI Agent │ Browser │ Domains │ Docs │ │ +│ │ Settings │ Integrations │ P2P Transfer │ Repos │ │ +│ └────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────┘ +``` + +--- + +## Screenshots -**dweb OS unifies all of this into one installable system.** No cloud vendor. No subscription. No complexity. Just your machine, your services, your network. +| Dashboard | AI Agent | P2P Instances | +|:---:|:---:|:---:| +| ![Dashboard](screenshots/dashboard.png) | ![AI Agent](screenshots/ai-agent.png) | ![P2P Instances](screenshots/2-instances.png) | + +| Browser | P2P Network | Customizable Page | +|:---:|:---:|:---:| +| ![Browser](screenshots/browser.png) | ![P2P](screenshots/p2p.png) | ![Static Page](screenshots/static-page.png) | + +| Domains | Docs | Settings | +|:---:|:---:|:---:| +| ![Domains](screenshots/domains.png) | ![Docs](screenshots/docs.png) | ![Settings](screenshots/settings.png) | + +--- + +## Core Features + +### 🖥️ Service Management Dashboard +Start/stop services with one click, monitor CPU/memory/ports, view logs: +- **Static Sites** — Serve any HTML/CSS/JS folder +- **Node.js APIs** — Express, Fastify, and more +- **Python Web Apps** — Flask, FastAPI, Django +- **PHP Sites** — WordPress, Laravel, or plain PHP +- **File Browser** — Upload, manage, and share files through your browser +- **Custom Services** — Any port, any stack + +### 🌐 P2P Networking & Discovery +Every dweb installation is a **node** on a decentralized network: +- **Peer discovery** — Find other dweb nodes automatically via UDP multicast, file-based discovery, or relay-mediated signaling +- **Direct connections** — WebRTC encrypted P2P links with Google STUN pre-configured +- **Relay fallback** — WebSocket + HTTP polling for NAT traversal, with automatic reconnection and exponential backoff +- **Connection UI** — Dedicated Connect dialog with mode selection (Direct P2P / Via Relay), hover info tooltips explaining each connection type, and inline peer browser with search and filtering +- **Paginated peer lists** — Discovered peers and connected remotes displayed in pages of 15 with Prev/Next navigation and page counter +- **Network status bar** — Real-time relay status, peer count, online mode indicator, and descriptive hover tooltips +- **Multiple online modes** — Local Only, P2P Visible (discoverable), P2P Anonymous (connect out only) +- **Auto-registration** — Each instance auto-registers as a peer on startup for immediate discoverability +- **Persistent peer registry** — Peers survive server restarts via disk-backed storage (`/tmp/dweb-peers.json`) +- **Tor routing** — Optional Tor daemon integration with one-click toggle in the Network header + - *Auto-detects* if Tor is installed, shows real-time status (running/installed/unavailable) + - *One-click toggle* — Starts/stops Tor daemon (via `kalitorify` or `nohup tor`), server tracks state server-side + - *Auto-anonymous* — Enabling Tor auto-switches instance to `p2p-anonymous` mode (Visible would defeat Tor's purpose) + - *Status bar indicator* — Purple `Tor: Routing` badge with SOCKS5 proxy address on hover + - *Verify endpoint* — `GET /api/tor/test` probes the SOCKS5 proxy port (127.0.0.1:9050) to confirm it's reachable +- **P2P File Transfer** — Share files directly between instances + +### 🏷️ .dweb Domain System +Register and manage domains on the decentralized network: +- **Free tier** — 1 `.dweb` domain, basic P2P hosting +- **Premium tier** ($3/mo) — 5 domains, relay cache +- **Business tier** ($10/mo) — unlimited domains, cloud shift +- **Cross-peer domain resolution** — Resolve `.dweb` domains registered on any connected peer instance; queries all connected peers in parallel with 60s result caching +- **Auto domain assignment** — Publishing a project can optionally auto-assign a free `.dweb` domain derived from the project name +- **Domain persistence** — Domains survive restarts via `/tmp/dweb-domains.json` +- **P2P content proxy** — Browse content from peer instances' `.dweb` domains via the built-in proxy endpoint, avoiding CORS issues + +### 🤖 AI Build Agent with 15+ Providers +Generate full-stack applications from natural language: +- **15+ AI providers**: Ollama, NVIDIA NIM, Groq, Gemini, DeepSeek, Mistral, OpenAI, Anthropic, Together, OpenRouter, HuggingFace, Fireworks, Cohere, Cerebras, xAI, Hyperbolic +- **100+ free models** — No API key needed for most providers +- **Offline-capable** — Ollama runs 100% locally +- **OpenCode CLI integration** — Full agentic coding workflow + +### 📁 P2P File Sharing +Drag-and-drop file sharing between dweb instances: +- Upload files via browser +- Share directly to P2P peers +- Download shared files from any instance +- File discovery across the P2P network + +### 🔧 Built-in Browser with dweb Protocol +Full browser tab with `dweb://` protocol support: +- Browse the web within dweb +- Tutorials for building sites, APIs, and PHP apps +- Bookmark manager +- Multiple search engines --- -## What is dweb OS? +## How dweb Works -dweb OS is **not just an app** — it's a complete operating environment built on Alpine Linux that provides: +### Runtime Architecture ``` -┌─────────────────────────────────────────────────────────────┐ -│ dweb OS │ -│ ┌───────────┐ ┌───────────┐ ┌──────────────────────────┐ │ -│ │ Dev OS │ │ P2P Net │ │ AI Build Engine │ │ -│ │ │ │ │ │ │ │ -│ │ Services │ │ HyperDHT │ │ 15+ Providers │ │ -│ │ Domains │ │ WebRTC │ │ 100+ Free Models │ │ -│ │ Runtimes │ │ Relay │ │ Ollama + Nemotron │ │ -│ │ File Sys │ │ Mesh │ │ Local + Cloud │ │ -│ └───────────┘ └───────────┘ └──────────────────────────┘ │ +┌────────────────────────────────────────────────────────────┐ +│ Browser (port 49737) │ +│ Access from any device on network │ +└─────────────────────────┬──────────────────────────────────┘ + │ +┌─────────────────────────▼──────────────────────────────────┐ +│ dweb Core (Node.js) │ │ │ -│ ┌───────────────────────────────────────────────────────┐ │ -│ │ Browser Portal (port 49737) │ │ -│ │ Dashboard │ AI Agent │ Browser │ Domains │ Repos │ │ -│ └───────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────┘ +│ ┌────────────────────────────┐ ┌──────────────────────┐ │ +│ │ React Frontend (Vite) │ │ API Modules │ │ +│ │ TypeScript + React 19 │ │ server/*.cjs │ │ +│ │ │ │ │ │ +│ │ Dashboard BrowserView │ │ api-services.cjs │ │ +│ │ AI Agent Domains │ │ api-relay.cjs │ │ +│ │ Repos Integrations │ │ api-collab.cjs │ │ +│ │ Settings Docs │ │ api-fileshare.cjs │ │ +│ │ P2P Transfer │ │ api-opencode.cjs │ │ +│ └────────────────────────────┘ │ api-ollama.cjs │ │ +│ │ api-system.cjs │ │ +│ ┌────────────────────────────┐ │ router.cjs │ │ +│ │ Core Modules │ │ state.cjs │ │ +│ │ index.cjs (entry) │ │ discovery.cjs │ │ +│ │ config.cjs │ │ relay-tcp.cjs │ │ +│ │ helpers.cjs │ │ helpers.cjs │ │ +│ └────────────────────────────┘ └──────────────────────┘ │ +│ │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ P2P Relay Daemon (port 49736) │ │ +│ │ tools/dweb-relay.cjs — discovery, signaling │ │ +│ │ WebSocket push + HTTP polling + TCP relay │ │ +│ └────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ Tauri Desktop Shell (optional) │ │ +│ │ Rust backend: P2P (HyperDHT), domains, git, AI │ │ +│ └────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────┘ ``` -### Three Layers of dweb OS +--- -#### Layer 1: The Operating System -A **minimalist Alpine Linux distribution** optimized for development workloads. Runs as: -- **WSL distro** on Windows 11 (install from Microsoft Store) -- **Native Linux** on any bare-metal or VM -- **Docker container** for quick testing -- **Tauri desktop app** for Windows-native experience +## P2P Connection & Discovery UI -#### Layer 2: The P2P Network -Every dweb OS installation is a **node** on a decentralized network powered by: -- **HyperDHT** — Distributed hash table for peer discovery and routing -- **WebRTC** — Direct encrypted connections between peers -- **Relay Daemon** — Bootstrap and signaling for NAT traversal -- **WebSocket + HTTP fallback** — Works behind any firewall +dweb provides a rich in-browser interface for managing P2P connections, all accessible from the **Dashboard's Network section**. -Your node can host services that other dweb users access directly — no central server, no CDN, no cloud provider. +### Network Status Bar -#### Layer 3: The AI Build Engine -A **multi-provider AI agent** that scaffolds, generates, and deploys full-stack applications from natural language: -- **15+ AI providers** — Ollama, NVIDIA NIM, Groq, Gemini, DeepSeek, Mistral, OpenAI, Anthropic, and more -- **100+ free models** — No API key needed for most providers -- **Offline-capable** — Ollama runs 100% locally on your GPU -- **NVIDIA Nemotron** — Free access to NVIDIA's open-weight models via NIM API +A real-time status bar shows your current networking state at a glance: ---- +``` +❶ Mode: P2P Visible | Peers: 7 | Relay: Connected | Signals: 3 +``` -## Screenshots +- **Mode indicator** — Hover to see what each mode does (Local Only / P2P Visible / P2P Anonymous) +- **Peer count** — Number of actively connected peers; hover for summary +- **Relay status** — Connected/Offline with relay address on hover +- **Signal count** — Incoming WebRTC signaling requests (purple highlight) -| Dashboard | AI Agent | Domains | -|:---:|:---:|:---:| -| ![Dashboard](screenshots/dashboard.png) | ![AI Agent](screenshots/ai-agent.png) | ![Domains](screenshots/domains.png) | +### Online Mode Toggle -| Browser | Repositories | Integrations | -|:---:|:---:|:---:| -| ![Browser](screenshots/browser.png) | ![Repositories](screenshots/repositories.png) | ![Integrations](screenshots/integrations.png) | +Three buttons toggle your instance's visibility: ---- +| Mode | Effect | +|------|--------| +| **Local** | No P2P — services are local-only | +| **Visible** | Your instance is discoverable by other peers and can connect out | +| **Anonymous** | You can connect to peers but they cannot discover you | -## Core Features +Switching modes is persisted to `localStorage` across tab switches. -### 🖥️ Dev Portal Dashboard -A browser-based desktop environment to manage everything: -- Start/stop services with one click -- Monitor CPU, memory, and port usage -- View logs and manage deployments -- Access your dweb OS from any browser on the network +### Connect Dialog -### 🌐 P2P Networking -Connect directly with other dweb OS nodes: -- **Peer discovery** via HyperDHT — find other nodes automatically -- **Direct connections** via WebRTC — encrypted, no middleman -- **Relay fallback** — works behind NAT and firewalls -- **Mesh networking** — your node routes traffic for the network +Click the **Connect** button to open the connection modal with two modes: -### 🏷️ .dweb Domain System -Register and manage domains on the decentralized network: -- **Free tier** — 1 `.dweb` domain, basic P2P hosting -- **Premium tier** ($3/mo) — 5 domains, relay cache (always online) -- **Business tier** ($10/mo) — unlimited domains, cloud shift, priority support +#### Direct P2P +Enter `IP:Port` to connect directly — no relay needed. Works on LAN or when the remote peer has a public IP. Lower latency, no central dependency. May fail behind strict NATs or firewalls. Hover over the "Direct P2P" button for a detailed explanation panel. -### 🤖 AI Build Agent -Generate full-stack applications from natural language: -- "Build a blog with React, Node.js, and PostgreSQL" -- "Create a FastAPI CRUD API with authentication" -- "Generate a PHP admin dashboard with Chart.js" +#### Via Relay +Browse discovered peers from the relay, search/filter by ID or hostname, or enter a peer ID manually. The relay handles signaling/ICE/STUN for NAT traversal — data flows P2P once connected. Hover over the "Via Relay" button for a detailed explanation panel. -Supports **any stack**: React, Vue, Svelte, Angular, FastAPI, Django, Flask, Express, Fastify, Gin, Rails, Laravel, Go, Ruby, PHP, Python, Node.js, and more. +Both modes support: +- Manual address/peer ID input +- Optional display label +- Inline discovered peer browser with search filtering +- Relay-connected status indicator +- Warning banner when relay is offline -### 📁 File Browser -Upload, manage, and share files through your browser — no FTP, no S3, just your machine. +### Paginated Peer Lists -### 🔀 Git Integration -Full Git workflow with GitHub OAuth: -- Clone repositories from GitHub, GitLab, Bitbucket -- Branch, commit, push from the dashboard -- Import repos directly into your dweb OS workspace +- **Discovered Peers** — All peers visible through the relay, displayed **15 per page** with Prev/Next navigation and a page counter (e.g. "Page 2 of 4"). Only shows pagination controls when there are multiple pages. +- **Connected Remotes** — Your connected remote instances, also **15 per page** with full navigation. Each entry shows status dot (green/yellow/red), name, mode badge (Visible/Anonymous/Relay), address, latency, and service list. Use the reconnect/disconnect/remove buttons per entry. -### ☁️ Cloud Deployment -One-click deploy to external platforms when you need them: -- **AWS S3** — Static site hosting -- **Netlify** — Full-stack deployments -- **Vercel** — Serverless functions and edge deployments +### Peer Persistence & Auto-Registration + +- The local instance **auto-registers** as a peer on startup — the P2P tab always shows at least one peer +- Manual peer registrations and auto-discovered peers are **saved to disk** (`/tmp/dweb-peers.json`) and restored after server restarts +- Peers that go silent for 60 seconds are **automatically cleaned up** --- -## How dweb OS Works +## Cross-Peer .dweb Domain Resolution -### Installation Flow +When two or more dweb instances are connected via P2P, `.dweb` domains registered on any peer are **resolvable from any other peer**. -``` -┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ -│ Windows 11 │────▶│ Install │────▶│ dweb OS │────▶│ Browser │ -│ / Linux / │ │ WSL / MSIX │ │ Starts │ │ Portal │ -│ Mac / Docker│ │ / Docker │ │ (port 49737)│ │ Ready │ -└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ -``` +### How It Works -### Runtime Architecture +1. **Domain Registration** — Register a `.dweb` domain (e.g. `my-project.dweb`) via the Domains UI or `POST /api/domain/register` +2. **Auto-Domain Assignment** — Publishing a service with `auto_domain: true` automatically creates a domain matching the project name +3. **Local Resolution** — `GET /api/domain/resolve/:name` checks the local domain registry first +4. **Cross-Peer Fallback** — If the domain isn't found locally, dweb queries **all connected peers in parallel** via their `/api/domain/query/:name` endpoints using `Promise.allSettled` — results are cached for 60 seconds +5. **Content Proxy** — The resolved address is fetched through `/api/proxy/fetch?url=` to avoid CORS issues when rendering in the BrowserView + +### BrowserView Integration + +The built-in browser tab supports `dweb://` URLs: +- **Tauri mode**: Uses Rust IPC to invoke `resolve_domain` natively +- **Browser/Web IDE fallback**: Falls back to `fetch('/api/domain/resolve/:name')` and proxies content through the dweb server — works in any browser + +### Architecture ``` -┌───────────────────────────────────────────────────────────┐ -│ Browser (port 49737) │ -│ Access from any device on network │ -└─────────────────────────┬─────────────────────────────────┘ - │ -┌─────────────────────────▼─────────────────────────────────┐ -│ dweb OS Core │ -│ │ -│ ┌──────────────────────────┐ ┌─────────────────────────┐ │ -│ │ React Frontend │ │ Node.js Server │ │ -│ │ (Vite + TypeScript) │ │ (dweb-server.cjs) │ │ -│ │ │ │ │ │ -│ │ Dashboard BrowserView │ │ Static serving │ │ -│ │ AI Agent Domains │ │ AI API proxy (15+) │ │ -│ │ Repos Integrations │ │ WebRTC signaling │ │ -│ │ Settings Docs │ │ Rate limiting │ │ -│ └──────────────────────────┘ └──────────┬──────────────┘ │ -│ │ │ -│ ┌─────────────────────────────────────────▼──────────────┐ │ -│ │ Tauri Desktop Shell (optional) │ │ -│ │ Rust backend: P2P (HyperDHT), domains (sled), │ │ -│ │ cloud deployment (AWS SigV4, Netlify, Vercel), │ │ -│ │ git integration, sandboxed process execution │ │ -│ └────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌────────────────────────────────────────────────────────┐ │ -│ │ P2P Relay Daemon (port 49736) │ │ -│ │ dweb-relay.cjs — bootstrap, discovery, signaling │ │ -│ │ WebSocket push + HTTP polling fallback + TCP relay │ │ -│ └────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────┘ - - ┌─────────────────────────────────────┐ - │ AI Providers (15+) │ - │ Ollama │ NVIDIA NIM │ Groq │ Gemini │ - │ DeepSeek │ Together │ OpenRouter │ - │ Mistral │ Fireworks │ Cohere │ xAI │ - │ HuggingFace │ Cerebras │ Hyperbolic │ - └─────────────────────────────────────┘ - - ┌─────────────────────────────────────┐ - │ Other dweb OS Nodes │ - │ ← P2P → ← P2P → ← P2P → │ - │ Your services accessible to peers │ - └─────────────────────────────────────┘ +User visits dweb://my-site.dweb + │ + ▼ +BrowserView resolves via HTTP API + │ + ▼ +/api/domain/resolve/my-site.dweb + │ + ├── Local registry found? → Return address:port + │ + └── Not found locally? + │ + ▼ + Query all P2P peers in parallel + │ + ├── Peer A has it → Return cached result (60s TTL) + ├── Peer B has it → Return cached result + └── No peer has it → Return 404 ``` --- @@ -216,28 +281,21 @@ git clone https://github.com/Awaiswilll/dweb.git cd dweb npm install npm run build -node tools/dweb-server.cjs +node server/index.cjs ``` -Open **http://localhost:49737** in your browser. Your dweb OS is running. - -### Option 2: Windows WSL Distro (Recommended for Windows) - -**Prerequisites:** Windows 10/11 with WSL2 enabled +Open **http://localhost:49737** in your browser. Your dweb portal is running. -```powershell -# Import the dweb OS distro -wsl --import dweb C:\dweb dweb-distro.tar.gz --version 2 +### Option 2: Development Mode (with HMR) -# Start dweb OS -wsl -d dweb - -# Or run the import script -.\packaging\wsl\import-dweb-wsl.ps1 +```bash +git clone https://github.com/Awaiswilll/dweb.git +cd dweb +npm install +npm run dev # Vite dev server on port 5173 +node server/index.cjs # API server on port 49737 ``` -Access at **http://localhost:49737** from any Windows browser. - ### Option 3: Windows Native App (Tauri Desktop) **Prerequisites:** Rust toolchain, Node.js 22+ @@ -251,33 +309,7 @@ npx tauri build Installer output: `src-tauri\target\release\bundle\nsis\dweb_x64-setup.exe` -### Option 4: Microsoft Store (Coming Soon) - -**Status:** App name "dweb" reserved in Microsoft Partner Center. - -Once published: -```powershell -# Install from Microsoft Store -ms-windows-store://pdp/?ProductId= -``` - -### Option 5: Linux (Native) - -```bash -# Install system dependencies (Ubuntu/Debian) -sudo apt-get update -sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev \ - librsvg2-dev patchelf libssl-dev - -# Build and run -git clone https://github.com/Awaiswilll/dweb.git -cd dweb -npm install -npm run build -node tools/dweb-server.cjs -``` - -### Option 6: Docker +### Option 4: Docker ```bash docker run -d \ @@ -288,22 +320,10 @@ docker run -d \ dweb/dweb:latest ``` -### Option 7: Build WSL Distro from Source - -```bash -# Build the Alpine Linux-based dweb OS distro -bash packaging/wsl/build-wsl-distro.sh - -# Output: packaging/wsl/dweb-distro.tar.gz -# Import: wsl --import dweb dweb-distro.tar.gz -``` - --- ## AI Models — 15+ Providers, 100+ Free Models -dweb OS ships with **15+ AI providers** and **100+ free models**. No single point of failure — switch providers instantly. - ### Free / No API Key Required | Provider | Models | How It Works | @@ -314,7 +334,7 @@ dweb OS ships with **15+ AI providers** and **100+ free models**. No single poin | **Together AI** | 6+ models | Free tier for popular open models | | **OpenRouter** | 7+ models | Free tier aggregates multiple providers | | **Hugging Face** | 5+ models | Free inference API | -| **NVIDIA NIM** | 13+ models | Free tier includes **Nemotron** models | +| **NVIDIA NIM** | 13+ models | Free tier includes Nemotron models | | **Cerebras** | 4+ models | Free tier, ultra-fast CS-2 chips | | **DeepSeek** | 3+ models | Free/cheap API, excellent code models | | **Hyperbolic** | 5+ models | Free tier for open models | @@ -330,86 +350,148 @@ dweb OS ships with **15+ AI providers** and **100+ free models**. No single poin | **Cohere** | $5 credit | Command R+, Command R | | **xAI (Grok)** | Free tier | Grok 2, Grok 2 Vision | -### NVIDIA Nemotron Models (Free via NIM) - -Nemotron is NVIDIA's family of open-weight models, available **free** through the NVIDIA NIM API: - -| Model | Size | Best For | -|-------|------|----------| -| **Nemotron Mini 4B** | 4B | Fast responses, low latency | -| **Nemotron 8B** | 8B | Balanced general tasks | -| **Nemotron 70B** | 70B | Complex reasoning, code generation | -| **Nemotron 4 340B** | 340B | Maximum capability, research | - -To use Nemotron: -1. Get a free API key at [build.nvidia.com](https://build.nvidia.com) -2. Add NVIDIA NIM as a provider in dweb OS Settings → AI Models -3. Select any Nemotron model and start building - --- ## Tech Stack | Layer | Technology | |-------|-----------| -| **Base OS** | Alpine Linux (minimal, secure, <100MB) | | **Frontend** | React 19, TypeScript 5.5, Vite 6, React Router 7, Lucide React | -| **Backend** | Node.js (zero-dependency HTTP server), Express-like routing | +| **Backend** | Node.js modular server (server/*.cjs) | | **Desktop** | Tauri v2 (Rust) — optional desktop shell | -| **P2P** | HyperDHT, WebRTC, WebSocket relay, HTTP fallback | -| **AI** | 15+ providers: Ollama, NVIDIA NIM (Nemotron), Groq, Gemini, DeepSeek, Mistral, OpenAI, Anthropic, Together, OpenRouter, HuggingFace, Fireworks, Cohere, Cerebras, xAI, Hyperbolic | -| **Database** | sled (embedded Rust), localStorage fallback | +| **P2P** | HyperDHT, WebRTC, WebSocket relay, HTTP polling, TCP relay | +| **AI** | 15+ providers: Ollama, NVIDIA NIM, Groq, Gemini, DeepSeek, Mistral, OpenAI, Anthropic, Together, OpenRouter, HuggingFace, Fireworks, Cohere, Cerebras, xAI, Hyperbolic | +| **Database** | sled (embedded Rust), localStorage | | **Packaging** | WSL distro, MSIX (Store), NSIS (Windows), DMG (macOS), AppImage/DEB (Linux), Docker | --- -## .dweb Domain Tiers - -| Tier | Price | Features | -|------|-------|----------| -| **Free** | $0 | 1 .dweb domain, basic P2P hosting | -| **Premium** | $3/mo | 5 domains, relay cache (always online) | -| **Business** | $10/mo | Unlimited domains, cloud shift, priority support | - ---- +## API Endpoints -## Best Use Cases - -### 1. Personal Cloud Development Environment -Replace Docker Compose, ngrok, and Heroku with a single install. Start/stop services, view logs, and manage ports — all from your browser. - -### 2. AI-Powered Code Generation (Offline) -Use the built-in AI Build Agent to scaffold full-stack apps from natural language. With Ollama running locally, it works **completely offline** — no internet, no API keys, no data leaving your machine. - -### 3. P2P Service Sharing -Host a service on your dweb OS node and share it directly with other dweb users. No central server, no CDN, no cloud bill. Each node is both client and server. +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/services` | GET | List running services | +| `/api/service/start` | POST | Start a new service | +| `/api/service/stop` | POST | Stop a running service | +| `/api/domain/services` | GET | List P2P-discovered remote services | +| `/collab/services` | GET | List P2P collaboration services | +| `/dweb-status` | GET | System status (uptime, peers, mode, relay info) | +| `/api/ollama/status` | GET | Ollama installation status | +| `/api/opencode/run` | POST | Run opencode CLI command (legacy blocking) | +| `/api/opencode/stream` | POST | Create AI agent session (SSE streaming) | +| `/api/opencode/session-stream/:id` | GET | SSE live output stream for AI agent session | +| `/api/opencode/session/:id` | GET | Session snapshot (reconnect support) | +| `/api/opencode/session/:id/cancel` | POST | Cancel a running AI agent session | +| `/fileshare/api/list` | GET | List shared files | +| `/fileshare/api/upload` | POST | Upload a file | +| `/welcome` | GET | Welcome page | +| `/welcome/source` | GET | Welcome page source | + +#### P2P Relay Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/ping` | GET | Health check + instance identity | +| `/status` | GET | Full relay status (uptime, peers, memory) | +| `/register` | POST | Register a peer on this relay | +| `/discover` | GET | Discover all registered peers (supports `?mode=` filter) | +| `/heartbeat` | POST | Keep-alive heartbeat for registered peers | +| `/signal` | POST | Send WebRTC signal to a peer | +| `/signal?peerId=` | GET | Poll for pending signals (HTTP fallback) | +| `/peer/:id` | GET | Get specific peer info | +| `/peer/:id` | DELETE | Remove a peer | + +#### Domain System Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/domain/pricing` | GET | Domain tier information | +| `/api/domain/list` | GET | List owned `.dweb` domains | +| `/api/domain/register` | POST | Register a new `.dweb` domain | +| `/api/domain/bind` | POST | Bind domain to a service or port | +| `/api/domain/unbind` | POST | Unbind domain from service | +| `/api/domain/upgrade` | POST | Upgrade domain tier | +| `/api/domain/renew` | POST | Renew domain | +| `/api/domain/remove` | DELETE | Remove domain and unbind service | +| `/api/domain/resolve/:name` | GET | Resolve `.dweb` domain to address:port (with cross-peer fallback) | +| `/api/domain/query/:name` | GET | Peer-facing endpoint: query local domain record | +| `/api/proxy/fetch?url=` | GET | Proxy-fetch remote content for BrowserView (bypasses CORS) | + +#### P2P System Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/p2p/discover-local` | GET | Discover local peers via UDP/file discovery | +| `/api/p2p/receive` | POST | Receive file from P2P | +| `/api/p2p/received` | GET | List received P2P files | +| `/api/publish` | POST | Publish a service with optional auto-domain assignment | -### 4. Self-Hosted Websites & Portfolios -Deploy static sites, blogs, and portfolios on your own machine with a `.dweb` domain. Accessible via P2P network or local LAN. +#### Collaboration Endpoints -### 5. Developer Demo & Portfolio Environment -Showcase projects to clients or collaborators by giving them access to your dweb OS portal. Each project gets its own service, domain, and AI-assisted build pipeline. +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/collab/services` | GET | List shared collaboration services | +| `/collab/sessions` | GET | List shared development sessions | -### 6. Offline-First Development -With Ollama running locally, the AI Build Agent works without internet. Perfect for air-gapped environments, travel, or privacy-conscious workflows. +--- -### 7. Windows Developer Workstation -Install dweb OS via WSL distro or native Windows app. Get a full Linux dev environment on Windows 11 without dual-booting or VMs. +## Project Structure -### 8. Decentralized Team Infrastructure -Each team member runs a dweb OS node. Services, files, and code are shared peer-to-peer. No central server to maintain or pay for. +``` +dweb/ +├── src/ # React frontend +│ ├── components/ # Reusable UI components +│ ├── views/ # Page views +│ │ │ ├── Dashboard.tsx # Service management + P2P connectivity UI +│ │ ├── AIAgent.tsx # AI agent with SSO streaming, Ollama auto-detect +│ │ ├── BrowserView.tsx # Built-in browser with dweb:// + HTTP fallback +│ │ ├── Domains.tsx # .dweb domain management +│ │ ├── Docs.tsx # In-app documentation +│ │ ├── Settings.tsx # App settings +│ │ ├── Integrations.tsx +│ │ ├── Repositories.tsx +│ │ ├── P2PDashboard.tsx # P2P network status & discovery +│ │ └── P2PTransfer.tsx # P2P file transfer +│ ├── styles/ # CSS styles +│ ├── types.ts # TypeScript definitions +│ └── relay-client.ts # P2P relay client +├── server/ # Node.js backend (modular) +│ ├── index.cjs # Entry point +│ ├── router.cjs # Route registration +│ ├── api-services.cjs # Service management API +│ ├── api-relay.cjs # P2P relay endpoints (/register, /discover, /signal) +│ ├── api-domain.cjs # .dweb domain management + cross-peer resolution +│ ├── api-collab.cjs # Collaboration API +│ ├── api-fileshare.cjs # File sharing API +│ ├── api-opencode.cjs # OpenCode CLI integration (SSE streaming) +│ ├── opencode-worker.cjs # Persistent opencode session manager +│ ├── api-ollama.cjs # Ollama status API (WSL/Docker/native detection) +│ ├── api-system.cjs # System status, publish, proxy endpoints +│ ├── state.cjs # Shared state (peers, domains, services, signals) +│ ├── config.cjs # Configuration +│ ├── discovery.cjs # P2P peer discovery (UDP multicast + file-based) +│ ├── relay-tcp.cjs # TCP relay +│ └── helpers.cjs # Utility functions +├── src-tauri/ # Rust/Tauri desktop backend +├── tools/ # Utility scripts +│ ├── dweb-server.cjs # Legacy monolith (for reference) +│ └── dweb-relay.cjs # P2P relay daemon +├── packaging/ # Distribution packages +├── welcome/ # Welcome page HTML +└── screenshots/ # App screenshots +``` --- ## Development ```bash -# Frontend only (HMR) +# Frontend development (HMR) npm run dev # Type check -npm run lint npm run typecheck +npm run lint # Production build npm run build @@ -419,78 +501,75 @@ npm test # Run tests with coverage npm run test:coverage - -# Tauri desktop app -npx tauri dev -npx tauri build ``` -### Project Structure +### Testing +dweb uses [Vitest](https://vitest.dev/) for unit and integration tests: + +```bash +# Run all tests +npm test + +# Watch mode for TDD +npm run test:watch + +# With coverage +npm run test:coverage ``` -dweb/ -├── src/ # React frontend -│ ├── components/ # Reusable UI components -│ ├── views/ # Page views (Dashboard, AI Agent, etc.) -│ ├── styles/ # CSS styles -│ ├── types.ts # TypeScript type definitions -│ ├── safe-invoke.ts # Tauri IPC wrapper -│ └── relay-client.ts # P2P relay client -├── src-tauri/ # Rust/Tauri desktop backend -│ ├── src/ -│ │ ├── lib.rs # Main Tauri commands -│ │ ├── ai.rs # AI provider integration -│ │ ├── p2p.rs # P2P networking (HyperDHT) -│ │ ├── domain.rs # .dweb domain management -│ │ ├── git.rs # Git operations -│ │ ├── github.rs # GitHub OAuth & API -│ │ ├── cloud.rs # Cloud deployment (AWS, Netlify, Vercel) -│ │ ├── sandbox.rs # Sandboxed process execution -│ │ ├── stack.rs # Stack scaffolding -│ │ ├── database.rs # Sled embedded DB -│ │ └── config.rs # Configuration management -│ └── tauri.conf.json # Tauri configuration -├── tools/ # Server-side scripts -│ ├── dweb-server.cjs # Main HTTP server (port 49737) -│ ├── dweb-relay.cjs # P2P relay daemon (port 49736) -│ └── connectivity-test.cjs -├── packaging/ # Distribution packages -│ ├── wsl/ # WSL distro builder (Alpine Linux) -│ │ ├── build-wsl-distro.sh -│ │ ├── import-dweb-wsl.ps1 -│ │ └── Dockerfile -│ └── win32/ # Windows packaging -│ ├── build-msix.ps1 -│ ├── dweb-desktop/ -│ └── dweb-wsl-distro/ -└── .github/workflows/ # CI/CD - └── build.yml # Multi-platform build pipeline -``` + +See [WINDOWS-TESTING.md](WINDOWS-TESTING.md) for Windows-specific testing instructions. --- -## Marketplace & Distribution +## Best Use Cases + +### 1. Personal Cloud Development Environment +Replace Docker Compose, ngrok, and Heroku with a single install. Start/stop services, view logs, and manage ports — all from your browser. + +### 2. AI-Powered Code Generation +Use the built-in AI Build Agent to scaffold full-stack apps from natural language. With Ollama running locally, it works **completely offline**. + +### 3. P2P Service Sharing +Host a service on your dweb node and share it directly with other dweb users across the P2P network. No central server, no CDN, no cloud bill. + +### 4. Multi-Instance Collaboration +Run multiple dweb instances on different machines. Each instance discovers the others via P2P relay, and services are accessible across instances. + +### 5. P2P File Sharing +Drag-and-drop file sharing between dweb instances. Files are transferred directly P2P, stored locally on the receiving instance. + +### 6. Self-Hosted Websites & Portfolios +Deploy static sites, blogs, and portfolios on your own machine with a `.dweb` domain. -| Platform | Status | Link | -|----------|--------|------| -| **Microsoft Store** | Name Reserved | [dweb](https://apps.microsoft.com) | -| **GitHub Releases** | Active | [Releases](https://github.com/Awaiswilll/dweb/releases) | -| **Website** | Planned | [https://dweb.dev](https://dweb.dev) | -| **Docker Hub** | Planned | [dweb/dweb](https://hub.docker.com/r/dweb/dweb) | +### 7. Offline-First Development +With Ollama running locally, the AI Build Agent works without internet. Perfect for air-gapped environments or privacy-conscious workflows. + +--- + +## Business Model + +| Tier | Price | Features | +|------|-------|----------| +| **Free** | $0 | 1 .dweb domain, basic P2P hosting, community support | +| **Premium** | $3/mo | 5 domains, relay cache (always online), priority support | +| **Business** | $10/mo | Unlimited domains, cloud shift, SLA | + +See [BUSINESS-PLAN.md](BUSINESS-PLAN.md) for the complete business model. --- ## Contributing -dweb OS is open source (MIT License) and built by the community. We're looking for contributors in: +dweb is open source (MIT License). We welcome contributions in: -- 🐧 **WSL Distro** — Alpine Linux packaging, optimization, and testing -- 🪟 **Windows Packaging** — MSIX, NSIS, Microsoft Store submission -- 🤖 **AI Providers** — Adding new AI provider integrations and model catalogs -- 🌐 **P2P Networking** — HyperDHT improvements, NAT traversal, relay optimization -- 🎨 **UI/UX** — Dashboard polish, accessibility (WCAG 2.2), themes -- 📝 **Documentation** — Guides, tutorials, API docs, architecture docs -- 🧪 **Testing** — Unit, integration, and E2E tests (Vitest + Playwright) +- 🐧 **WSL Distro** — Alpine Linux packaging +- 🪟 **Windows Packaging** — MSIX, NSIS, Microsoft Store +- 🤖 **AI Providers** — New provider integrations and model catalogs +- 🌐 **P2P Networking** — HyperDHT improvements, NAT traversal +- 🎨 **UI/UX** — Dashboard polish, accessibility, themes +- 📝 **Documentation** — Guides, tutorials, API docs +- 🧪 **Testing** — Unit, integration, and E2E tests ### Getting Started @@ -502,7 +581,7 @@ cd dweb # 2. Install dependencies npm install -# 3. Start development server +# 3. Start development npm run dev # 4. Run tests @@ -520,39 +599,12 @@ git push origin feature/your-feature See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines. -### Good First Issues - -Look for issues labeled `good-first-issue` on our [GitHub Issues](https://github.com/Awaiswilll/dweb/issues) page. We currently have **5 good-first-issue** tasks ready for community contributions: - -- [Theme toggle](https://github.com/Awaiswilll/dweb/issues/1) — Add dark/light mode to Settings -- [Mobile responsive design](https://github.com/Awaiswilll/dweb/issues/2) — Make dashboard work on phones -- [Service health monitoring](https://github.com/Awaiswilll/dweb/issues/3) — Health indicators on Dashboard -- [P2P status indicator](https://github.com/Awaiswilll/dweb/issues/4) — Peer connection status in navbar -- [Keyboard shortcuts](https://github.com/Awaiswilll/dweb/issues/5) — Productivity hotkeys - -### Testing - -See [WINDOWS-TESTING.md](WINDOWS-TESTING.md) for instructions on testing the WSL distro, Windows portable, and desktop app on Windows. - ---- - -## Business Plan - -See [BUSINESS-PLAN.md](BUSINESS-PLAN.md) for the complete business model, monetization strategy, and roadmap. - -**Revenue Model:** -- **Free tier** — 1 domain, basic P2P, community support -- **Premium** ($3/mo) — 5 domains, relay cache, priority support -- **Business** ($10/mo) — Unlimited domains, cloud shift, SLA - --- ## Changelog See [CHANGELOG.md](CHANGELOG.md) for version history and recent changes. ---- - ## License MIT — see [LICENSE](LICENSE) for details. @@ -564,5 +616,5 @@ MIT — see [LICENSE](LICENSE) for details.

- dweb OS — One install. Every stack. Your own internet. + dweb — One install. Every stack. Your own internet.

diff --git a/dweb.cjs b/dweb.cjs index ce0d217..679bfdd 100644 --- a/dweb.cjs +++ b/dweb.cjs @@ -1,609 +1,11 @@ #!/usr/bin/env node // ═══════════════════════════════════════════════════════════════════════════════ -// dweb v0.1.0 — Unified P2P Server + Web Development IDE + Free Hosting Platform +// dweb v0.1.0 — Entry Point // +// This is a thin entry point that delegates to server/index.cjs. // Run: node dweb.cjs // Env: PORT=49737, RELAY_PORT=49736, MODE=auto, NAME=my-dweb -// -// Every dweb instance is: -// 1. A P2P relay node (helps peers discover & connect) -// 2. A web development server (serves the React IDE + AI agent) -// 3. A free hosting platform (hosts services, domains, APIs) -// -// When two dweb instances connect, they collaborate: -// - Share hosted services between machines -// - Share AI agent sessions -// - Proxy traffic through the P2P network -// -// Zero npm dependencies — pure Node.js stdlib -// ═══════════════════════════════════════════════════════════════════════════════ - -const http = require("http"); -const https = require("https"); -const fs = require("fs"); -const path = require("path"); -const os = require("os"); -const crypto = require("crypto"); -const net = require("net"); -const { execSync } = require("child_process"); - -// ═══════════════════════════════════════════════════════════════════════════════ -// CONFIG -// ═══════════════════════════════════════════════════════════════════════════════ - -const PORT = parseInt(process.env.PORT, 10) || 49737; // Frontend + API -const RELAY_PORT = parseInt(process.env.RELAY_PORT, 10) || 49736; // P2P relay -const TCP_RELAY_PORT = parseInt(process.env.TCP_PORT, 10) || 49738; // TCP proxy -const PEER_TTL_MS = parseInt(process.env.PEER_TTL, 10) || 60000; -const DIST_DIR = path.resolve(__dirname, "dist"); -const MODE = (process.env.MODE || "auto").toLowerCase(); // auto | relay | peer | isolated -const INSTANCE_NAME = process.env.NAME || os.hostname(); - -const SERVER_ID = crypto.randomUUID().split("-")[0].slice(0, 8); -const PEER_ID = `dweb-${os.hostname().toLowerCase().replace(/[^a-z0-9-]/g, "-")}-${SERVER_ID}`; -const START_TIME = Date.now(); -const LOCAL_IPS = getLocalIPs(); - -// Upstream relay to connect to (can be this instance or another) -const UPSTREAM_RELAY = process.env.UPSTREAM || null; -let relayConnected = false; -let relayError = null; - // ═══════════════════════════════════════════════════════════════════════════════ -// STATE -// ═══════════════════════════════════════════════════════════════════════════════ - -const peers = new Map(); // peerId → PeerRecord (relay peer store) -const signals = new Map(); // peerId → signal[] -const hostedServices = []; // Services hosted on THIS instance -const sharedSessions = []; // Shared AI/code sessions -const peerServices = new Map(); // peerId → services[] -const tcpRelays = new Map(); // peerId → TCP socket - -// ═══════════════════════════════════════════════════════════════════════════════ -// PEER RECORD -// ═══════════════════════════════════════════════════════════════════════════════ - -class PeerRecord { - constructor(id, info = {}) { - this.id = id; - this.publicKey = info.publicKey || id; - this.address = info.address || "0.0.0.0"; - this.port = info.port || PORT; - this.hostname = info.hostname || os.hostname(); - this.platform = info.platform || process.platform; - this.version = info.version || "0.1.0"; - this.mode = info.mode || "p2p-visible"; - this.services = info.services || []; - this.relayPort = info.relayPort || RELAY_PORT; - this.firstSeen = Date.now(); - this.lastSeen = Date.now(); - } - get isStale() { return (Date.now() - this.lastSeen) > PEER_TTL_MS; } - touch() { this.lastSeen = Date.now(); } -} - -// ═══════════════════════════════════════════════════════════════════════════════ -// HELPERS -// ═══════════════════════════════════════════════════════════════════════════════ - -function getLocalIPs() { - const ifaces = os.networkInterfaces(), ips = []; - for (const n of Object.keys(ifaces)) - for (const i of ifaces[n]) - if (i.family === "IPv4" && !i.internal) ips.push(i.address); - return ips.length ? ips : ["127.0.0.1"]; -} - -function peerToJSON(p) { - return { - id: p.id, publicKey: p.publicKey, address: p.address, port: p.port, - hostname: p.hostname, platform: p.platform, version: p.version, - mode: p.mode, services: p.services, relayPort: p.relayPort, - age: Math.floor((Date.now() - p.firstSeen) / 1000), - }; -} - -function json(res, status, data) { - const body = JSON.stringify(data, null, 2); - res.writeHead(status, { - "Content-Type": "application/json; charset=utf-8", - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Headers": "Content-Type", - "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", - }); - res.end(body); -} - -function parseBody(req) { - return new Promise((resolve, reject) => { - let body = ""; - req.on("data", c => { body += c; if (body.length > 1e6) req.destroy(); }); - req.on("end", () => { - try { resolve(body ? JSON.parse(body) : {}); } - catch (e) { reject(new Error("Invalid JSON")); } - }); - req.on("error", reject); - }); -} - -const MIME = { - ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", - ".js": "application/javascript; charset=utf-8", ".json": "application/json; charset=utf-8", - ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", - ".gif": "image/gif", ".svg": "image/svg+xml", ".ico": "image/x-icon", - ".woff": "font/woff", ".woff2": "font/woff2", ".map": "application/json", -}; - -function serveFile(res, filePath) { - const ext = path.extname(filePath).toLowerCase(); - const mime = MIME[ext] || "application/octet-stream"; - if (!fs.existsSync(filePath)) { - // SPA fallback - const indexPath = path.join(DIST_DIR, "index.html"); - if (fs.existsSync(indexPath)) return serveFile(res, indexPath); - return json(res, 404, { error: "Not found" }); - } - const data = fs.readFileSync(filePath); - res.writeHead(200, { - "Content-Type": mime, "Access-Control-Allow-Origin": "*", - "Cache-Control": ext === ".html" ? "no-cache" : "max-age=86400", - }); - res.end(data); -} - -function httpReq(method, host, port, pathname, data) { - return new Promise((resolve, reject) => { - const body = data ? JSON.stringify(data) : null; - const opts = { hostname: host, port, path: pathname, method, timeout: 5000 }; - if (body) { opts.headers = { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) }; } - const r = http.request(opts, res => { - let b = ""; - res.on("data", c => b += c); - res.on("end", () => { try { resolve(JSON.parse(b)); } catch { resolve(b); } }); - }); - r.on("error", reject); - r.on("timeout", () => { r.destroy(); reject(new Error("timeout")); }); - if (body) r.write(body); - r.end(); - }); -} - -// ═══════════════════════════════════════════════════════════════════════════════ -// RELAY CLIENT — Register with upstream relay -// ═══════════════════════════════════════════════════════════════════════════════ - -async function registerWithUpstream() { - if (!UPSTREAM_RELAY) return; - const [host, portStr] = UPSTREAM_RELAY.split(":"); - const port = parseInt(portStr, 10) || RELAY_PORT; - try { - const res = await httpReq("POST", host, port, "/register", { - id: PEER_ID, hostname: os.hostname(), platform: process.platform, - version: "0.1.0", address: LOCAL_IPS[0] || "127.0.0.1", - port: PORT, relayPort: RELAY_PORT, - mode: "p2p-visible", services: hostedServices.map(s => s.name), - }); - relayConnected = res?.status === "ok"; - relayError = relayConnected ? null : "registration failed"; - if (relayConnected) console.log(` [upstream] Registered with ${UPSTREAM_RELAY}`); - } catch (e) { - relayConnected = false; - relayError = e.message; - console.log(` [upstream] Cannot reach ${UPSTREAM_RELAY}`); - } -} - -async function heartbeatUpstream() { - if (!UPSTREAM_RELAY || !relayConnected) return; - const [host, portStr] = UPSTREAM_RELAY.split(":"); - try { await httpReq("POST", host, parseInt(portStr, 10) || RELAY_PORT, "/heartbeat", { peerId: PEER_ID }); } - catch { relayConnected = false; } -} - -// ═══════════════════════════════════════════════════════════════════════════════ -// SIGNAL STORE -// ═══════════════════════════════════════════════════════════════════════════════ - -function storeSignal(targetId, signal) { - if (!signals.has(targetId)) signals.set(targetId, []); - const list = signals.get(targetId); - list.push({ ...signal, ts: Date.now() }); - if (list.length > 100) list.splice(0, list.length - 100); -} - -function popSignals(targetId) { - const list = signals.get(targetId) || []; - signals.delete(targetId); - return list; -} - -// ═══════════════════════════════════════════════════════════════════════════════ -// COLLABORATION APIS -// ═══════════════════════════════════════════════════════════════════════════════ - -// Add a hosted service (called internally or via API) -function addHostedService(name, type, port, url) { - const existing = hostedServices.findIndex(s => s.name === name); - const svc = { name, type, port, url: url || `http://127.0.0.1:${port}`, added: Date.now() }; - if (existing >= 0) hostedServices[existing] = svc; - else hostedServices.push(svc); - return svc; -} - -// Share a session with connected peers -function shareSession(sessionId, type, title, data) { - const existing = sharedSessions.findIndex(s => s.id === sessionId); - const session = { id: sessionId, type, title, data, peerId: PEER_ID, shared: Date.now() }; - if (existing >= 0) sharedSessions[existing] = session; - else sharedSessions.push(session); - // Keep max 50 sessions - if (sharedSessions.length > 50) sharedSessions.splice(0, sharedSessions.length - 50); - return session; -} - -// ═══════════════════════════════════════════════════════════════════════════════ -// HTTP ROUTER -// ═══════════════════════════════════════════════════════════════════════════════ - -async function handleRequest(req, res) { - const url = new URL(req.url, `http://${req.headers.host || "localhost"}`); - const pathname = url.pathname; - const method = req.method; - - // CORS - if (method === "OPTIONS") { - res.writeHead(204, { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type", - }); - return res.end(); - } - - try { - // ──────────────────────────────────────────────────────────── - // PUBLIC RELAY API (port RELAY_PORT) - // ──────────────────────────────────────────────────────────── - - // PING - if (pathname === "/ping" && method === "GET") { - return json(res, 200, { - status: "ok", server: "dweb", - id: SERVER_ID, hostname: os.hostname(), platform: process.platform, - version: "0.1.0", uptime: Math.floor((Date.now() - START_TIME) / 1000), - mode: MODE, peers: peers.size, services: hostedServices.length, - }); - } - - // STATUS - if (pathname === "/status" && method === "GET") { - const modeCounts = {}; - for (const p of peers.values()) modeCounts[p.mode] = (modeCounts[p.mode] || 0) + 1; - return json(res, 200, { - status: "ok", serverId: SERVER_ID, peerId: PEER_ID, - hostname: os.hostname(), version: "0.1.0", - mode: MODE, uptime: Math.floor((Date.now() - START_TIME) / 1000), - peersOnline: peers.size, hostedServices: hostedServices.length, - sharedSessions: sharedSessions.length, - upstreamRelay: UPSTREAM_RELAY, relayConnected, - localIPs: LOCAL_IPS, port: PORT, relayPort: RELAY_PORT, tcpPort: TCP_RELAY_PORT, - modes: modeCounts, platform: process.platform, - memory: { - rss: Math.round(process.memoryUsage().rss / 1024 / 1024) + "MB", - heapUsed: Math.round(process.memoryUsage().heapUsed / 1024 / 1024) + "MB", - }, - }); - } - - // REGISTER - if (pathname === "/register" && method === "POST") { - const body = await parseBody(req); - const id = body.id || crypto.randomUUID(); - const existing = peers.get(id); - if (existing) { - existing.touch(); - Object.assign(existing, body, { lastSeen: Date.now() }); - return json(res, 200, { status: "ok", action: "updated", peerId: id, peersOnline: peers.size }); - } - const peer = new PeerRecord(id, body); - peers.set(id, peer); - console.log(` [p2p] ${id.slice(0, 16)}… registered (${peers.size} total)`); - return json(res, 201, { status: "ok", action: "registered", peerId: id, peersOnline: peers.size }); - } - - // HEARTBEAT - if (pathname === "/heartbeat" && method === "POST") { - const body = await parseBody(req); - const peer = peers.get(body.peerId); - if (!peer) return json(res, 404, { error: "Unknown peer" }); - peer.touch(); - return json(res, 200, { status: "ok", peersOnline: peers.size }); - } - - // DISCOVER - if (pathname === "/discover" && method === "GET") { - const modeFilter = url.searchParams.get("mode"); - const list = []; - for (const p of peers.values()) { - if (modeFilter && p.mode !== modeFilter) continue; - list.push(peerToJSON(p)); - } - return json(res, 200, { status: "ok", count: list.length, peers: list }); - } - - // PEER INFO / DELETE - const peerMatch = pathname.match(/^\/peer\/(.+)$/); - if (peerMatch && method === "GET") { - const peer = peers.get(peerMatch[1]); - if (!peer) return json(res, 404, { error: "Peer not found" }); - return json(res, 200, { status: "ok", peer: peerToJSON(peer) }); - } - if (peerMatch && method === "DELETE") { - peers.delete(peerMatch[1]); - signals.delete(peerMatch[1]); - return json(res, 200, { status: "ok", message: "Peer removed" }); - } - - // SIGNAL (WebRTC signaling exchange) - if (pathname === "/signal") { - if (method === "POST") { - const body = await parseBody(req); - if (!body.targetPeerId) return json(res, 400, { error: "Missing targetPeerId" }); - storeSignal(body.targetPeerId, { - fromPeerId: body.fromPeerId || "anonymous", type: body.type || "unknown", - sdp: body.sdp || null, candidate: body.candidate || null, - }); - if (!peers.has(body.targetPeerId)) { - console.log(` [signal] ${(body.fromPeerId||"?").slice(0,12)} → ${body.targetPeerId.slice(0,12)} (queued, peer offline)`); - } - return json(res, 200, { status: "ok", queued: true }); - } - if (method === "GET") { - const peerId = url.searchParams.get("peerId"); - if (!peerId) return json(res, 400, { error: "Missing peerId" }); - return json(res, 200, { status: "ok", count: signals.get(peerId)?.length || 0, signals: popSignals(peerId) }); - } - } - - // ──────────────────────────────────────────────────────────── - // COLLABORATION API (port PORT) - // ──────────────────────────────────────────────────────────── - - // DWEB STATUS - if (pathname === "/dweb-status" && method === "GET") { - return json(res, 200, { - status: "ok", peerId: PEER_ID, hostname: os.hostname(), - platform: process.platform, localIPs: LOCAL_IPS, - port: PORT, relayPort: RELAY_PORT, mode: MODE, - uptime: Math.floor((Date.now() - START_TIME) / 1000), - relayConnected, upstreamRelay: UPSTREAM_RELAY, - relayError, peersOnline: peers.size, - hostedServices: hostedServices.length, - sharedSessions: sharedSessions.length, - services: ["frontend", "p2p-relay", "hosting", "collab"], - }); - } - - // HOSTED SERVICES - if (pathname === "/collab/services" && method === "GET") { - return json(res, 200, { status: "ok", count: hostedServices.length, services: hostedServices }); - } - - if (pathname === "/collab/services" && method === "POST") { - const body = await parseBody(req); - const svc = addHostedService(body.name, body.type, body.port, body.url); - return json(res, 201, { status: "ok", service: svc }); - } - - // SHARED SESSIONS (AI agent, code, etc.) - if (pathname === "/collab/sessions" && method === "GET") { - return json(res, 200, { status: "ok", count: sharedSessions.length, sessions: sharedSessions }); - } - - if (pathname === "/collab/sessions" && method === "POST") { - const body = await parseBody(req); - const session = shareSession(body.id || crypto.randomUUID(), body.type, body.title, body.data); - return json(res, 201, { status: "ok", session }); - } - - // PEER SERVICES (services hosted by connected peers) - if (pathname === "/collab/peer-services" && method === "GET") { - const all = []; - for (const [peerId, svcs] of peerServices) { - for (const svc of svcs) all.push({ ...svc, peerId }); - } - return json(res, 200, { status: "ok", count: all.length, services: all }); - } - - // ──────────────────────────────────────────────────────────── - // STATIC FILES (frontend) - // ──────────────────────────────────────────────────────────── - - let filePath = path.join(DIST_DIR, pathname === "/" ? "index.html" : pathname); - if (!filePath.startsWith(DIST_DIR)) return json(res, 403, { error: "Forbidden" }); - serveFile(res, filePath); - - } catch (e) { - if (!res.headersSent) json(res, 500, { error: e.message }); - } -} - -// ═══════════════════════════════════════════════════════════════════════════════ -// TCP RELAY SERVER (for proxying traffic between peers) -// ═══════════════════════════════════════════════════════════════════════════════ - -function startTCPRelay() { - const server = net.createServer(socket => { - let peerId = null, buffer = ""; - socket.on("data", data => { - buffer += data.toString(); - if (!peerId) { - const nl = buffer.indexOf("\n"); - if (nl === -1) return; - try { - const msg = JSON.parse(buffer.slice(0, nl)); - buffer = buffer.slice(nl + 1); - if (msg.type === "register" && msg.peerId) { - peerId = msg.peerId; - tcpRelays.set(peerId, socket); - socket.write(JSON.stringify({ type: "registered", peerId }) + "\n"); - if (buffer.length > 0) { forwardRelayData(peerId, buffer); buffer = ""; } - } - } catch {} - return; - } - try { - const msg = JSON.parse(buffer); - if (msg.type === "relay" && msg.targetPeerId) { - const target = tcpRelays.get(msg.targetPeerId); - if (target) target.write(JSON.stringify({ type: "relay", fromPeerId: peerId, data: msg.data }) + "\n"); - storeSignal(msg.targetPeerId, { fromPeerId: peerId, type: "relay-data", data: msg.data }); - } - } catch {} - buffer = ""; - }); - socket.on("close", () => { if (peerId) tcpRelays.delete(peerId); }); - socket.on("error", () => {}); - }); - server.on("error", err => { - if (err.code === "EADDRINUSE") console.log(` [tcp] Port ${TCP_RELAY_PORT} in use`); - else console.log(` [tcp] Error: ${err.message}`); - }); - server.listen(TCP_RELAY_PORT, "0.0.0.0", () => { - console.log(` TCP Relay : tcp://0.0.0.0:${TCP_RELAY_PORT}`); - }); - return server; -} - -function forwardRelayData(fromPeerId, data) { - // Forward buffered data to target if identifiable - try { - const msg = JSON.parse(data); - if (msg.targetPeerId) { - const target = tcpRelays.get(msg.targetPeerId); - if (target) target.write(JSON.stringify({ type: "relay", fromPeerId, data: msg.data }) + "\n"); - } - } catch {} -} - -// ═══════════════════════════════════════════════════════════════════════════════ -// CLEANUP -// ═══════════════════════════════════════════════════════════════════════════════ - -function cleanupStalePeers() { - let removed = 0; - for (const [id, peer] of peers) { - if (peer.isStale) { peers.delete(id); signals.delete(id); removed++; } - } - if (removed > 0) console.log(` [cleanup] Removed ${removed} stale peers`); -} - -// ═══════════════════════════════════════════════════════════════════════════════ -// BANNER -// ═══════════════════════════════════════════════════════════════════════════════ - -function printBanner() { - const isRelay = MODE === "relay" || MODE === "auto"; - const isPeer = MODE === "peer" || MODE === "auto"; - - console.log(); - console.log(` ╔══════════════════════════════════════════════════╗`); - console.log(` ║ dweb — P2P Dev + Hosting Platform ║`); - console.log(` ║ ─────────────────────────────── ║`); - console.log(` ║ Instance : ${INSTANCE_NAME.padEnd(37)}║`); - console.log(` ║ Peer ID : ${PEER_ID.padEnd(37)}║`); - console.log(` ║ Mode : ${MODE.padEnd(37)}║`); - console.log(` ╚══════════════════════════════════════════════════╝`); - console.log(` ╔══════════════════════════════════════════════════╗`); - if (isRelay) { - console.log(` ║ P2P Relay : http://0.0.0.0:${String(RELAY_PORT).padEnd(5)} ║`); - } - console.log(` ║ Web IDE : http://0.0.0.0:${String(PORT).padEnd(5)} ║`); - console.log(` ║ TCP Proxy : tcp://0.0.0.0:${String(TCP_RELAY_PORT).padEnd(5)} ║`); - console.log(` ║ ║`); - console.log(` ║ Network access: ║`); - for (const ip of LOCAL_IPS) { - const label = `http://${ip}:${PORT}/`; - console.log(` ║ ${label.padEnd(48)}║`); - } - console.log(` ║ ║`); - console.log(` ║ Core APIs: ║`); - console.log(` ║ /ping — Health check ║`); - console.log(` ║ /status — Full instance status ║`); - console.log(` ║ /register — Register a peer (P2P) ║`); - console.log(` ║ /discover — Discover online peers ║`); - console.log(` ║ /signal — WebRTC signaling ║`); - console.log(` ║ /collab/services — Hosted services ║`); - console.log(` ║ /collab/sessions — Shared dev sessions ║`); - console.log(` ║ /dweb-status — Instance status ║`); - console.log(` ║ / — dweb Web IDE frontend ║`); - console.log(` ╚══════════════════════════════════════════════════╝`); - console.log(` Upstream relay: ${UPSTREAM_RELAY || "(none — this is a relay node)"}`); - console.log(` Press Ctrl+C to stop.\n`); -} - -// ═══════════════════════════════════════════════════════════════════════════════ -// MAIN -// ═══════════════════════════════════════════════════════════════════════════════ - -const isRelayMode = MODE === "relay" || MODE === "auto"; -const isPeerMode = MODE === "peer" || MODE === "auto"; - -// Create HTTP server that handles everything -const server = http.createServer(handleRequest); - -// Start listening on both ports if in relay mode -function startServer() { - if (isRelayMode) { - // Listen on RELAY_PORT for P2P traffic, on PORT for frontend + API - // Use a single server that handles all ports via different listeners - server.listen(RELAY_PORT, "0.0.0.0", () => { - console.log(` P2P Relay : http://0.0.0.0:${RELAY_PORT}`); - }); - - // Start a second server for the frontend port - const webServer = http.createServer(handleRequest); - webServer.listen(PORT, "0.0.0.0", () => {}); - - return webServer; - } else { - server.listen(PORT, "0.0.0.0", () => {}); - return server; - } -} - -const webServer = startServer(); -startTCPRelay(); -printBanner(); - -// Register with upstream relay -if (UPSTREAM_RELAY && isPeerMode) { - registerWithUpstream(); - setInterval(heartbeatUpstream, 30000); -} - -// Periodic peer cleanup -setInterval(cleanupStalePeers, 15000); - -// Status line -setInterval(() => { - const line = ` [${new Date().toLocaleTimeString()}] Peers: ${peers.size} | Services: ${hostedServices.length} | Sessions: ${sharedSessions.length}`; - process.stdout.write(`\x1b[2K\x1b[1A\x1b[2K${line}\n`); -}, 3000); - -// Graceful shutdown -process.on("SIGINT", () => { - console.log("\n\n Shutting down dweb..."); - console.log(` Final state: ${peers.size} peers, ${hostedServices.length} services`); - tcpRelays.forEach(s => s.end()); - server.close(); - webServer.close(); - process.exit(0); -}); - -process.on("SIGTERM", () => process.exit(0)); -// Export key info for programmatic use -module.exports = { PEER_ID, SERVER_ID, PORT, RELAY_PORT, peers, hostedServices, sharedSessions }; +require("./server/index.cjs"); diff --git a/e2e/service-preview.spec.ts b/e2e/service-preview.spec.ts new file mode 100644 index 0000000..10c2ba2 --- /dev/null +++ b/e2e/service-preview.spec.ts @@ -0,0 +1,101 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Dashboard – Service Preview Modal', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(3000); // Let React fully render + }); + + const expandServices = async (page: any) => { + // Click the services-bar-top div which has the onClick to toggle showServices + await page.locator('div.services-bar-top').first().click({ timeout: 5000 }); + await page.waitForTimeout(500); + }; + + test('dashboard loads with Services section', async ({ page }) => { + await expect(page.locator('div.services-bar-title').first()).toBeVisible({ timeout: 10000 }); + }); + + test('clicking Services heading expands the pills', async ({ page }) => { + await expandServices(page); + + await expect(page.locator('text=My Static Website').first()).toBeVisible({ timeout: 5000 }); + await expect(page.locator('text=File Share').first()).toBeVisible({ timeout: 3000 }); + }); + + test('clicking a service pill opens preview modal with tabs', async ({ page }) => { + await expandServices(page); + + const servicePill = page.locator('text=My Static Website').first(); + await servicePill.click({ timeout: 5000 }); + + // Verify modal opened with tabs + await expect(page.locator('text=Preview').first()).toBeVisible({ timeout: 5000 }); + await expect(page.locator('text=Source').first()).toBeVisible({ timeout: 3000 }); + await expect(page.locator('text=Customize').first()).toBeVisible({ timeout: 3000 }); + }); + + test('modal has Host on .dweb button', async ({ page }) => { + await expandServices(page); + + const servicePill = page.locator('text=My Static Website').first(); + await servicePill.click({ timeout: 5000 }); + + await expect(page.locator('text=Host on .dweb').first()).toBeVisible({ timeout: 5000 }); + }); + + test('Preview tab shows an iframe', async ({ page }) => { + await expandServices(page); + + const servicePill = page.locator('text=My Static Website').first(); + await servicePill.click({ timeout: 5000 }); + + // Explicitly click Preview tab + await page.locator('text=Preview').first().click({ timeout: 3000 }); + + await expect(page.locator('iframe').first()).toBeVisible({ timeout: 5000 }); + }); + + test('Source tab shows HTML content', async ({ page }) => { + await expandServices(page); + + const servicePill = page.locator('text=My Static Website').first(); + await servicePill.click({ timeout: 5000 }); + + await page.locator('text=Source').first().click({ timeout: 3000 }); + + await expect(page.locator('pre, code').first()).toBeVisible({ timeout: 5000 }); + }); + + test('Customize tab has textarea and Save button', async ({ page }) => { + await expandServices(page); + + const servicePill = page.locator('text=My Static Website').first(); + await servicePill.click({ timeout: 5000 }); + + await page.locator('text=Customize').first().click({ timeout: 3000 }); + + await expect(page.locator('textarea').first()).toBeVisible({ timeout: 3000 }); + await expect(page.locator('text=Save Changes').first()).toBeVisible({ timeout: 3000 }); + }); +}); + +test.describe('Backend API endpoints', () => { + test('GET /api/services returns 200 with services array', async ({ request }) => { + const resp = await request.get('http://localhost:49737/api/services'); + expect(resp.status()).toBe(200); + const body = await resp.json(); + expect(Array.isArray(body.services)).toBe(true); + }); + + test('GET /welcome returns 200', async ({ request }) => { + const resp = await request.get('http://localhost:49737/welcome'); + expect(resp.status()).toBe(200); + }); + + test('GET /fileshare returns 200', async ({ request }) => { + const resp = await request.get('http://localhost:49737/fileshare'); + expect(resp.status()).toBe(200); + }); +}); diff --git a/packaging/wsl/Dockerfile b/packaging/wsl/Dockerfile index 53967e5..57b98a6 100644 --- a/packaging/wsl/Dockerfile +++ b/packaging/wsl/Dockerfile @@ -1,9 +1,9 @@ # ═══════════════════════════════════════════════════════════════════════════════ # Dockerfile — dweb WSL Rootfs Builder # -# Builds a minimal Alpine Linux rootfs with dweb-server, opencode CLI, -# and Ollama. The resulting filesystem can be exported as a tarball for -# `wsl --import`. +# Builds a minimal Alpine Linux rootfs with dweb-server, pm2, +# and Ollama (optional). The resulting filesystem can be exported as a +# tarball for `wsl --import`. # # Usage: # docker build -t dweb-wsl . @@ -23,7 +23,7 @@ RUN apk add --no-cache git # Copy only package files for dependency caching COPY package.json package-lock.json* ./ -RUN npm ci --omit=dev 2>/dev/null || npm install +RUN npm ci 2>/dev/null || npm install # Copy source and build COPY tsconfig.json tsconfig.node.json vite.config.ts ./ @@ -58,9 +58,9 @@ RUN apk add --no-cache \ && rm -rf /var/cache/apk/* \ && rm -rf /usr/share/doc/* /usr/share/man/* /usr/share/info/* \ && echo "Verifying Node.js is musl-linked..." \ - && readelf -l /usr/bin/node 2>/dev/null | grep -q '/lib/ld-musl' \ - && echo "✓ musl-linked" \ - || echo "⚠ WARNING: Node.js is NOT musl-linked — will fail in WSL" + && ldd /usr/bin/node 2>/dev/null | grep -q 'ld-musl' \ + && echo "✓ musl-linked (ldd confirms ld-musl)" \ + || echo "⚠ WARNING: Node.js may not be musl-linked — check 'ldd /usr/bin/node'" # ── Create dweb user ────────────────────────────────────────────────────────── RUN addgroup -g 1000 dweb \ @@ -74,14 +74,12 @@ COPY tools/ /opt/dweb/tools/ COPY dweb.cjs package.json start-server.sh* /opt/dweb/ RUN chmod +x /opt/dweb/dweb.cjs /opt/dweb/start-server.sh 2>/dev/null || true -# ── Install opencode CLI ────────────────────────────────────────────────────── -RUN npm install -g @opencode/cli 2>/dev/null \ - || npm install -g opencode 2>/dev/null \ - || echo "Warning: opencode CLI could not be installed" +# ── Install global npm tools (pm2 for process management) ───────────────────── +RUN npm install -g pm2 2>/dev/null || echo "pm2 install skipped (non-essential)" -# ── Install Ollama ──────────────────────────────────────────────────────────── -RUN curl -fsSL https://ollama.com/install.sh | sh 2>/dev/null \ - || echo "Warning: Ollama installation failed" +# ── Install Ollama (optional — skipped in Docker, user runs post-install) ───── +RUN echo "Ollama skipped in Docker build. Install manually:" \ + && echo " curl -fsSL https://ollama.com/install.sh | sh" # ── WSL init scripts ────────────────────────────────────────────────────────── diff --git a/packaging/wsl/README.md b/packaging/wsl/README.md index e23b038..dc24f6b 100644 --- a/packaging/wsl/README.md +++ b/packaging/wsl/README.md @@ -1,6 +1,6 @@ # dweb WSL Distro -**dweb** packaged as an Alpine Linux-based WSL (Windows Subsystem for Linux) distribution. Includes dweb-server, opencode CLI, and Ollama for local AI — all pre-configured and ready to use. +**dweb** packaged as an Alpine Linux-based WSL (Windows Subsystem for Linux) distribution. Includes dweb-server with P2P networking, AI agent, and web IDE — all pre-configured and ready to use. ## Requirements @@ -15,7 +15,7 @@ ```powershell # Download and run the import script -curl -O https://github.com/dweb/dweb/releases/latest/download/import-dweb-wsl.ps1 +curl -O https://github.com/Awaiswilll/dweb/releases/latest/download/import-dweb-wsl.ps1 .\import-dweb-wsl.ps1 ``` @@ -23,7 +23,7 @@ curl -O https://github.com/dweb/dweb/releases/latest/download/import-dweb-wsl.ps ```powershell # Download the tarball -curl -LO https://github.com/dweb/dweb/releases/latest/download/dweb-wsl-rootfs.tar.gz +curl -LO https://github.com/Awaiswilll/dweb/releases/latest/download/dweb-wsl-rootfs.tar.gz # Import into WSL wsl --import dweb ./dweb-wsl/ ./dweb-wsl-rootfs.tar.gz --version 2 @@ -36,7 +36,7 @@ wsl -d dweb ```bash # Clone the repo -git clone https://github.com/dweb/dweb.git +git clone https://github.com/Awaiswilll/dweb.git cd dweb # Build the frontend @@ -64,9 +64,10 @@ http://localhost:49737 | Component | Description | |-----------|-------------| | **dweb-server** | P2P Dev + Hosting Platform — serves the React frontend on port 49737 | -| **opencode CLI** | AI-assisted coding in your terminal | -| **Ollama** | Local AI model runner (download models with `ollama pull `) | -| **Alpine Linux** | Lightweight, secure base operating system | +| **AI Agent** | 100+ free AI models across 15+ providers (OpenAI, Anthropic, Google, Groq, more) | +| **P2P Networking** | Peer-to-peer relay, domain resolution, cross-instance communication | +| **pm2** | Production process manager (pre-installed) | +| **Alpine Linux** | Lightweight, secure base operating system (~38MB rootfs) | ## Usage @@ -89,28 +90,26 @@ sudo rc-service dweb stop sudo rc-service dweb restart ``` -### Using opencode CLI +### Using pm2 Process Manager ```bash -opencode --help -opencode "explain this code" -opencode --model claude-3.5-sonnet +pm2 list # List all processes +pm2 logs # View logs +pm2 monit # Monitor processes ``` -### Managing Ollama +### Ollama (Local AI — Optional) -```bash -# Check Ollama status -ollama ps +Ollama is not pre-installed to keep the distro size minimal. Install it on first use: -# Pull a model -ollama pull llama3.2 +```bash +curl -fsSL https://ollama.com/install.sh | sh +``` -# Run a model interactively -ollama run llama3.2 +Then pull your first model: -# List downloaded models -ollama list +```bash +ollama pull llama3.2 ``` ### File System diff --git a/packaging/wsl/RELEASE-v0.2.0.md b/packaging/wsl/RELEASE-v0.2.0.md new file mode 100644 index 0000000..483ee6c --- /dev/null +++ b/packaging/wsl/RELEASE-v0.2.0.md @@ -0,0 +1,51 @@ +# dweb v0.2.0 — WSL Distro Release + +**A full-stack P2P web development and hosting platform, packaged as a WSL distribution for Windows.** + +## What's New + +- **WSL Distro** — Import dweb as a native WSL2 distro with one command +- **Alpine Linux 3.20** — Minimal ~38MB rootfs with musl-linked Node.js 20 +- **Pre-built Frontend** — Fully compiled React + Vite UI (no build step needed) +- **WSL Auto-Start** — dweb-server starts automatically on `wsl -d dweb` +- **Aliases** — `dweb-start`, `dweb-stop`, `dweb-restart`, `dweb-logs`, `dweb-status` +- **pm2** — Production process manager pre-installed + +## How to Install + +### One-liner (PowerShell): +```powershell +wsl --import dweb .\dweb-wsl\ .\dweb-wsl-rootfs.tar.gz --version 2 +wsl -d dweb +``` + +### PowerShell Script: +```powershell +.\import-dweb-wsl.ps1 +``` + +### Or use the GitHub Release Asset: +Download `dweb-wsl-rootfs.tar.gz` from this release and import manually. + +## What's Inside + +| Component | Description | +|-----------|-------------| +| **dweb-server** | P2P Dev + Hosting Platform on port 49737 | +| **AI Agent** | 100+ free AI models across 15+ providers | +| **P2P Networking** | Peer-to-peer relay, domain resolution | +| **pm2** | Process manager | +| **Node.js 20** | musl-linked for Alpine compatibility | +| **Alpine Linux 3.20** | Lightweight base | + +## Access + +Open http://localhost:49737 in your browser. + +## Files Changed + +- `packaging/wsl/build-wsl-distro.sh` — Fixed init.d/dweb creation, added tarball verification +- `packaging/wsl/Dockerfile` — Fixed npm install (devDeps), musl check via ldd, removed broken opencode +- `packaging/wsl/README.md` — Updated URLs, accurate feature list +- `packaging/wsl/import-dweb-wsl.ps1` — Fixed GitHub URL +- `packaging/wsl/install.sh` — Replaced opencode with pm2 diff --git a/packaging/wsl/build-wsl-distro.sh b/packaging/wsl/build-wsl-distro.sh index 1522b2f..9cba7d7 100755 --- a/packaging/wsl/build-wsl-distro.sh +++ b/packaging/wsl/build-wsl-distro.sh @@ -5,7 +5,7 @@ # Creates a minimal Alpine Linux rootfs with: # - Node.js + npm # - dweb-server (pre-built frontend + tools) -# - opencode CLI (global npm install) +# - pm2 process manager (global npm install) # - Ollama (local AI, downloaded but not running during build) # - WSL init scripts for auto-start # @@ -232,31 +232,37 @@ chmod +x "$ROOTFS/opt/dweb/dweb.cjs" chroot "$ROOTFS" /bin/sh -c 'chown -R dweb:dweb /opt/dweb' # ═══════════════════════════════════════════════════════════════════════════════ -# STEP 5: Install opencode CLI globally +# STEP 5: Install global npm tools (optional) # ═══════════════════════════════════════════════════════════════════════════════ -info "Installing opencode CLI..." +info "Installing global npm tools (optional)..." +# Install common global tools that dweb users may want chroot "$ROOTFS" /bin/sh -c ' - npm install -g @opencode/cli 2>&1 || \ - npm install -g opencode 2>&1 || \ - warn "opencode CLI could not be installed — check the package name" -' || warn "opencode CLI install failed (non-fatal)" + npm install -g pm2 2>&1 || true +' || true # ═══════════════════════════════════════════════════════════════════════════════ -# STEP 6: Install Ollama +# STEP 6: Install Ollama (optional — requires network in chroot) # ═══════════════════════════════════════════════════════════════════════════════ -info "Installing Ollama..." +info "Attempting Ollama install (may fail without network in chroot)..." +# Ollama install is best done post-WSL-install by the user for reliability. +# We attempt it here but don't block the build if it fails. chroot "$ROOTFS" /bin/sh -c ' - # Download and run the official Ollama install script - curl -fsSL https://ollama.com/install.sh | sh 2>&1 || \ - warn "Ollama install failed — check network" -' || warn "Ollama install skipped (non-fatal)" + if curl -sf --max-time 10 https://ollama.com &>/dev/null; then + curl -fsSL https://ollama.com/install.sh | sh 2>&1 || \ + echo "[dweb] Ollama install script failed — install manually later: curl -fsSL https://ollama.com/install.sh | sh" + else + echo "[dweb] No network in chroot — skipping Ollama install" + echo "[dweb] Install manually: curl -fsSL https://ollama.com/install.sh | sh" + fi +' || true -# Create Ollama data directory +# Create Ollama data directory for future use mkdir -p "$ROOTFS/opt/ollama" +chmod 777 "$ROOTFS/opt/ollama" 2>/dev/null || true # ═══════════════════════════════════════════════════════════════════════════════ # STEP 7: Create WSL init scripts @@ -264,6 +270,9 @@ mkdir -p "$ROOTFS/opt/ollama" info "Creating WSL init scripts..." +# Ensure /etc/init.d directory exists +mkdir -p "$ROOTFS/etc/init.d" + # ── /etc/init.d/dweb — OpenRC service script ──────────────────────────── cat > "$ROOTFS/etc/init.d/dweb" <<'INIT' #!/sbin/openrc-run @@ -490,11 +499,39 @@ if [ -f "$TARBALL_OUT" ]; then FINAL_SIZE=$(du -h "$TARBALL_OUT" | cut -f1) info "✅ WSL distro tarball created: $TARBALL_OUT" info " Size: $FINAL_SIZE" + info "" + info "=== Tarball Verification ===" + + # Check for critical files + MISSING="" + for f in "etc/wsl.conf" "etc/rc.local" "etc/init.d/dweb" "etc/profile.d/dweb.sh" "usr/bin/node" "opt/dweb/dweb.cjs" "opt/dweb/tools/dweb-server.cjs" "opt/dweb/dist/index.html"; do + if tar tzf "$TARBALL_OUT" 2>/dev/null | grep -q "^\./$f$"; then + info " ✓ $f" + else + warn " ✗ $f — MISSING" + MISSING="$MISSING $f" + fi + done + + # Check for optional files + for f in "usr/bin/ollama" "usr/local/bin/pm2"; do + if tar tzf "$TARBALL_OUT" 2>/dev/null | grep -q "^\./$f$"; then + info " ~ $f (optional)" + fi + done + + if [ -n "$MISSING" ]; then + warn "⚠ Some expected files are missing:$MISSING" + else + info "✅ All critical files present" + fi + info "" info "To import into WSL on Windows:" info " wsl --import dweb ./dweb-wsl/ $TARBALL_OUT --version 2" info " wsl -d dweb" info " Open http://localhost:49737" + info " One-liner (from Linux): ./import-dweb-wsl.ps1 (run inside PowerShell)" else err "Failed to create tarball" fi diff --git a/packaging/wsl/dweb-wsl-rootfs.tar.gz b/packaging/wsl/dweb-wsl-rootfs.tar.gz index 4370676..023c12e 100644 Binary files a/packaging/wsl/dweb-wsl-rootfs.tar.gz and b/packaging/wsl/dweb-wsl-rootfs.tar.gz differ diff --git a/packaging/wsl/import-dweb-wsl.ps1 b/packaging/wsl/import-dweb-wsl.ps1 index f8053f9..a0863a4 100755 --- a/packaging/wsl/import-dweb-wsl.ps1 +++ b/packaging/wsl/import-dweb-wsl.ps1 @@ -17,7 +17,7 @@ #> param( - [string]$TarballUrl = "https://github.com/dweb/dweb/releases/latest/download/dweb-wsl-rootfs.tar.gz", + [string]$TarballUrl = "https://github.com/Awaiswilll/dweb/releases/latest/download/dweb-wsl-rootfs.tar.gz", [string]$DistroName = "dweb", [string]$InstallDir = "./dweb-wsl", [string]$TarballPath = "" @@ -223,7 +223,7 @@ Write-Host " Inside WSL:" -ForegroundColor White Write-Host " dweb-logs # View server logs" -ForegroundColor Gray Write-Host " dweb-status # Check server status" -ForegroundColor Gray Write-Host " dweb-restart # Restart dweb-server" -ForegroundColor Gray -Write-Host " opencode --help # Use opencode CLI" -ForegroundColor Gray +Write-Host " pm2 list # Process manager" -ForegroundColor Gray Write-Host "" -Write-Host " Need help? https://github.com/dweb/dweb/issues" -ForegroundColor Yellow +Write-Host " Need help? https://github.com/Awaiswilll/dweb/issues" -ForegroundColor Yellow Write-Host "" diff --git a/packaging/wsl/install.sh b/packaging/wsl/install.sh index 360d93e..0b538c2 100755 --- a/packaging/wsl/install.sh +++ b/packaging/wsl/install.sh @@ -2,7 +2,7 @@ # ═══════════════════════════════════════════════════════════════════════════════ # install.sh — Standalone dweb installer for WSL / Alpine Linux # -# Installs dweb-server, opencode CLI, and optionally Ollama on an existing +# Installs dweb-server with pm2, and optionally Ollama on an existing # Alpine Linux WSL instance. Can also be adapted for Debian/Ubuntu. # # Usage: @@ -14,11 +14,11 @@ set -euo pipefail # ── Configuration ───────────────────────────────────────────────────────────── -DWEB_REPO="${DWEB_REPO:-https://github.com/dweb/dweb.git}" +DWEB_REPO="${DWEB_REPO:-https://github.com/Awaiswilll/dweb.git}" DWEB_DIR="${DWEB_DIR:-/opt/dweb}" DWEB_PORT="${DWEB_PORT:-49737}" INSTALL_OLLAMA="${INSTALL_OLLAMA:-yes}" -INSTALL_OPENCODE="${INSTALL_OPENCODE:-yes}" +INSTALL_PM2="${INSTALL_PM2:-yes}" # ── Colors ──────────────────────────────────────────────────────────────────── RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m' @@ -150,22 +150,17 @@ fi info "dweb code ready at $DWEB_DIR" # ═══════════════════════════════════════════════════════════════════════════════ -# STEP 4: Install opencode CLI +# STEP 4: Install pm2 process manager # ═══════════════════════════════════════════════════════════════════════════════ -if [ "$INSTALL_OPENCODE" = "yes" ]; then - step "Installing opencode CLI" +if [ "$INSTALL_PM2" = "yes" ]; then + step "Installing pm2 process manager" - if command -v opencode &>/dev/null; then - info "opencode CLI already installed: $(opencode --version 2>/dev/null || echo 'unknown')" + if command -v pm2 &>/dev/null; then + info "pm2 already installed: $(pm2 --version 2>/dev/null || echo 'unknown')" else - npm install -g @opencode/cli 2>/dev/null || \ - npm install -g opencode 2>/dev/null || \ - warn "opencode CLI install failed. Check the package name on npm." - - if command -v opencode &>/dev/null; then - info "opencode CLI installed successfully" - fi + npm install -g pm2 2>/dev/null || \ + warn "pm2 install failed (non-essential — dweb will still work)" fi fi @@ -334,7 +329,7 @@ echo " Commands:" echo " dweb status — Check if dweb is running" echo " dweb logs — View server logs" echo " dweb start|stop — Manage the service" -echo " opencode — AI-assisted coding CLI" +echo " pm2 list — Process manager" echo "" echo " Files:" echo " Code: $DWEB_DIR" diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..e1b7f0f --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: false, + retries: 1, + reporter: 'list', + use: { + baseURL: 'http://localhost:5173', + trace: 'on-first-retry', + headless: true, + }, + projects: [ + { + name: 'chromium', + use: { browserName: 'chromium' }, + }, + ], +}); diff --git a/projects/hello-dweb/about.html b/projects/hello-dweb/about.html new file mode 100644 index 0000000..c908d49 --- /dev/null +++ b/projects/hello-dweb/about.html @@ -0,0 +1 @@ +Hello dweb

Hello, dweb!

This site is hosted globally via P2P.

\ No newline at end of file diff --git a/projects/hello-dweb/index.html b/projects/hello-dweb/index.html new file mode 100644 index 0000000..c908d49 --- /dev/null +++ b/projects/hello-dweb/index.html @@ -0,0 +1 @@ +Hello dweb

Hello, dweb!

This site is hosted globally via P2P.

\ No newline at end of file diff --git a/projects/hello-dweb/style.css b/projects/hello-dweb/style.css new file mode 100644 index 0000000..beb600e --- /dev/null +++ b/projects/hello-dweb/style.css @@ -0,0 +1 @@ +* { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; color: #e2e8f0; } .container { padding: 2rem; width: 100%; max-width: 600px; } .card { background: rgba(30, 41, 59, 0.8); border: 1px solid rgba(148, 163, 184, 0.2); border-radius: 16px; padding: 2.5rem; backdrop-filter: blur(10px); } h1 { font-size: 2rem; margin-bottom: 0.5rem; color: #f8fafc; } .subtitle { color: #94a3b8; font-size: 1.1rem; margin-bottom: 1.5rem; } .info { background: rgba(15, 23, 42, 0.5); border-radius: 8px; padding: 1.25rem; margin: 1.5rem 0; line-height: 1.6; } code { background: rgba(148, 163, 184, 0.15); padding: 0.15rem 0.4rem; border-radius: 4px; font-size: 0.9em; } ol { margin-left: 1.25rem; margin-top: 0.5rem; line-height: 1.8; } .links { display: flex; gap: 0.75rem; flex-wrap: wrap; } .btn { display: inline-block; padding: 0.75rem 1.5rem; background: #3b82f6; color: #fff; text-decoration: none; border-radius: 8px; font-weight: 500; transition: background 0.2s; } .btn:hover { background: #2563eb; } .btn-secondary { background: transparent; border: 1px solid rgba(148, 163, 184, 0.3); } .btn-secondary:hover { background: rgba(148, 163, 184, 0.1); } h3 { color: #f1f5f9; margin-bottom: 0.5rem; } \ No newline at end of file diff --git a/screenshots/2-instances.png b/screenshots/2-instances.png new file mode 100755 index 0000000..45f99b2 Binary files /dev/null and b/screenshots/2-instances.png differ diff --git a/screenshots/ai-agent.png b/screenshots/ai-agent.png old mode 100644 new mode 100755 index 6bbe915..92c1c80 Binary files a/screenshots/ai-agent.png and b/screenshots/ai-agent.png differ diff --git a/screenshots/browser.png b/screenshots/browser.png old mode 100644 new mode 100755 index bd4c115..959528d Binary files a/screenshots/browser.png and b/screenshots/browser.png differ diff --git a/screenshots/dashboard.png b/screenshots/dashboard.png old mode 100644 new mode 100755 index 742cbe0..5e57c1a Binary files a/screenshots/dashboard.png and b/screenshots/dashboard.png differ diff --git a/screenshots/docs.png b/screenshots/docs.png old mode 100644 new mode 100755 index cd2f78f..9ca3707 Binary files a/screenshots/docs.png and b/screenshots/docs.png differ diff --git a/screenshots/domains.png b/screenshots/domains.png old mode 100644 new mode 100755 index b37865d..11c74dc Binary files a/screenshots/domains.png and b/screenshots/domains.png differ diff --git a/screenshots/integrations.png b/screenshots/integrations.png deleted file mode 100644 index 9838a6a..0000000 Binary files a/screenshots/integrations.png and /dev/null differ diff --git a/screenshots/p2p-id.png b/screenshots/p2p-id.png new file mode 100755 index 0000000..ee25c76 Binary files /dev/null and b/screenshots/p2p-id.png differ diff --git a/screenshots/p2p.png b/screenshots/p2p.png new file mode 100755 index 0000000..06657f8 Binary files /dev/null and b/screenshots/p2p.png differ diff --git a/screenshots/repositories.png b/screenshots/repositories.png deleted file mode 100644 index 5e661a7..0000000 Binary files a/screenshots/repositories.png and /dev/null differ diff --git a/screenshots/settings.png b/screenshots/settings.png old mode 100644 new mode 100755 index 9464b11..eef5476 Binary files a/screenshots/settings.png and b/screenshots/settings.png differ diff --git a/screenshots/static-page.png b/screenshots/static-page.png new file mode 100755 index 0000000..6b7a55b Binary files /dev/null and b/screenshots/static-page.png differ diff --git a/server/api-collab.cjs b/server/api-collab.cjs new file mode 100644 index 0000000..1c9950f --- /dev/null +++ b/server/api-collab.cjs @@ -0,0 +1,78 @@ +// ═══════════════════════════════════════════════════════════════════════════════ +// dweb — Collaboration API (/dweb-status, /collab/*, /welcome, /welcome/source) +// ═══════════════════════════════════════════════════════════════════════════════ + +const os = require("os"); +const path = require("path"); +const fs = require("fs"); +const crypto = require("crypto"); +const { json, parseBody, serveFile, getLocalIPs } = require("./helpers.cjs"); +const config = require("./config.cjs"); +const { MODE, START_TIME, SERVER_ID, UPSTREAM_RELAY, PEER_ID } = config; +const { hostedServices, sharedSessions, peerServices, peers, + relayConnected, relayError, addHostedService, shareSession } = require("./state.cjs"); + +function registerRoutes(router) { + // DWEB STATUS + router.get("/dweb-status", (req, res) => { + json(res, 200, { + status: "ok", peerId: PEER_ID, hostname: os.hostname(), + platform: process.platform, localIPs: getLocalIPs(), + port: config.PORT, relayPort: config.RELAY_PORT, mode: MODE, + uptime: Math.floor((Date.now() - START_TIME) / 1000), + relayConnected, upstreamRelay: UPSTREAM_RELAY, + relayError, peersOnline: peers.size, + hostedServices: hostedServices.length, + sharedSessions: sharedSessions.length, + services: ["frontend", "p2p-relay", "hosting", "collab"], + }); + }); + + // HOSTED SERVICES + router.get("/collab/services", (req, res) => { + json(res, 200, { status: "ok", count: hostedServices.length, services: hostedServices }); + }); + + router.post("/collab/services", async (req, res) => { + const body = await parseBody(req); + const svc = addHostedService(body.name, body.type, body.port, body.url); + json(res, 201, { status: "ok", service: svc }); + }); + + // SHARED SESSIONS + router.get("/collab/sessions", (req, res) => { + json(res, 200, { status: "ok", count: sharedSessions.length, sessions: sharedSessions }); + }); + + router.post("/collab/sessions", async (req, res) => { + const body = await parseBody(req); + const session = shareSession(body.id || crypto.randomUUID(), body.type, body.title, body.data); + json(res, 201, { status: "ok", session }); + }); + + // PEER SERVICES + router.get("/collab/peer-services", (req, res) => { + const all = []; + for (const [peerId, svcs] of peerServices) { + for (const svc of svcs) all.push({ ...svc, peerId }); + } + json(res, 200, { status: "ok", count: all.length, services: all }); + }); + + // WELCOME PAGE + router.get("/welcome", (req, res) => { + const welcomePath = path.join(__dirname, "..", "welcome", "welcome.html"); + serveFile(res, welcomePath); + }); + + router.get("/welcome/source", (req, res) => { + const welcomePath = path.join(__dirname, "..", "welcome", "welcome.html"); + res.writeHead(200, { + "Content-Type": "text/plain; charset=utf-8", + "Access-Control-Allow-Origin": "*", + }); + res.end(fs.readFileSync(welcomePath)); + }); +} + +module.exports = { registerRoutes }; diff --git a/server/api-domain.cjs b/server/api-domain.cjs new file mode 100644 index 0000000..d99adf7 --- /dev/null +++ b/server/api-domain.cjs @@ -0,0 +1,433 @@ +// ═══════════════════════════════════════════════════════════════════════════════ +// dweb — Domain Management API (/api/domain/*) +// Register, bind, resolve, list, upgrade, renew, and remove .dweb domains +// ═══════════════════════════════════════════════════════════════════════════════ + +const crypto = require("crypto"); +const http = require("http"); +const config = require("./config.cjs"); +const { json, parseBody, httpReq } = require("./helpers.cjs"); +const { + getDomainRecord, setDomainRecord, deleteDomainRecord, listDomainRecords, + addHostedService, removeHostedService, hostedServices, peers, +} = require("./state.cjs"); + +// ─── Peer Domain Resolve Cache ────────────────────────── +const peerResolveCache = new Map(); // name -> { record, timestamp } +const PEER_RESOLVE_TTL = 60_000; // 1 minute before re-querying peers + +async function queryPeersForDomain(name) { + const peerEntries = []; + for (const [id, peer] of peers) { + if (peer.address && peer.port && peer.address !== "0.0.0.0") { + peerEntries.push({ id, address: peer.address, port: peer.port }); + } + } + if (peerEntries.length === 0) return null; + + const results = await Promise.allSettled( + peerEntries.map(p => + httpReq("GET", p.address, p.port, `/api/domain/query/${encodeURIComponent(name)}`) + .then(r => ({ peerId: p.id, result: r })) + .catch(() => null) + ) + ); + + for (const r of results) { + if (r.status === "fulfilled" && r.value && r.value.result?.status === "ok" && r.value.result?.record) { + console.log(` [domains] Resolved "${name}.dweb" via peer ${r.value.peerId.slice(0, 16)}…`); + return r.value.result.record; + } + } + return null; +} + +/* ─── Domain Tiers ─────────────────────────────────────── */ +const TIERS = { + free: { label: "Free", price: 0, ttlDays: 90, permanent: false, customDomain: false, ssl: false, description: "Basic .dweb domain, 90-day expiry" }, + premium: { label: "Premium", price: 500, ttlDays: 36500, permanent: true, customDomain: false, ssl: true, description: "Permanent .dweb domain with SSL" }, + business: { label: "Business", price: 2000, ttlDays: 36500, permanent: true, customDomain: true, ssl: true, description: "Unlimited domains + custom domain support" }, +}; + +const VALID_TIERS = Object.keys(TIERS); +const OWNER_KEY = crypto.randomUUID().replace(/-/g, "").slice(0, 16); + +/* ─── Helpers ──────────────────────────────────────────── */ + +function makeRecord(name, tier) { + const info = TIERS[tier]; + const now = new Date(); + const expires = info.permanent ? null : new Date(now.getTime() + info.ttlDays * 86400000).toISOString(); + return { + owner_key: OWNER_KEY, + address: null, + path: "/", + tier, + tierInfo: info, + service_name: null, + port: null, + custom_domain: null, + registered_at: now.toISOString(), + expires_at: expires, + auto_renew: tier === "free" ? false : true, + active: true, + paid_until: info.price > 0 ? now.toISOString() : null, + local_ip: config.LOCAL_IPS[0] || "127.0.0.1", + }; +} + +function isValidDomain(name) { + return /^[a-z0-9-]{3,63}$/.test(name); +} + +function isNameAvailable(name) { + return !getDomainRecord(name); +} + +/* ─── Routes ───────────────────────────────────────────── */ + +function registerRoutes(router) { + + // GET /api/domain/pricing — get domain pricing tiers + router.get("/api/domain/pricing", (req, res) => { + json(res, 200, { status: "ok", tiers: TIERS }); + }); + + // GET /api/domain/list — list all domains owned by this instance + router.get("/api/domain/list", (req, res) => { + const domains = listDomainRecords(); + json(res, 200, domains); + }); + + // POST /api/domain/register — register a new .dweb domain + router.post("/api/domain/register", async (req, res) => { + const body = await parseBody(req); + const name = (body.name || "").trim().toLowerCase(); + const tier = body.tier || "free"; + + if (!name) { + return json(res, 400, { status: "error", error: "Domain name is required" }); + } + if (!isValidDomain(name)) { + return json(res, 400, { status: "error", error: "Use 3-63 chars: lowercase letters, numbers, hyphens" }); + } + if (!isNameAvailable(name)) { + return json(res, 409, { status: "error", error: `Domain "${name}.dweb" is already registered` }); + } + if (!VALID_TIERS.includes(tier)) { + return json(res, 400, { status: "error", error: `Invalid tier: ${tier}. Valid: ${VALID_TIERS.join(", ")}` }); + } + + const record = makeRecord(name, tier); + setDomainRecord(name, record); + console.log(` [domains] Registered "${name}.dweb" (${tier})`); + + json(res, 201, { ...record, name }); + }); + + // POST /api/domain/bind — bind a domain to a service or port + router.post("/api/domain/bind", async (req, res) => { + const body = await parseBody(req); + const name = (body.name || "").trim().toLowerCase(); + const serviceName = body.service_name || null; + let port = body.port ? parseInt(body.port, 10) : null; + const customDomain = body.custom_domain || null; + + if (!name) { + return json(res, 400, { status: "error", error: "Domain name is required" }); + } + + const record = getDomainRecord(name); + if (!record) { + return json(res, 404, { status: "error", error: `Domain "${name}.dweb" not found` }); + } + + // If a service name is given, find its port + if (serviceName) { + const svc = hostedServices.find(s => s.name === serviceName); + if (svc) { + port = svc.port; + } + } + + if (!port) { + return json(res, 400, { status: "error", error: "Port or service_name is required" }); + } + + // Allow business tier to set custom domain + if (customDomain && record.tier !== "business") { + return json(res, 403, { status: "error", error: "Custom domains require Business tier" }); + } + + // Auto-register as a hosted service for P2P discovery + const svcName = serviceName || `${name}-domain`; + const svcUrl = `http://127.0.0.1:${port}`; + addHostedService(svcName, "Domain", port, svcUrl); + + // Determine the service path + const svcPath = body.path || record.path || "/"; + const updated = { + ...record, + service_name: svcName, + port, + path: svcPath, + address: config.LOCAL_IPS[0] || "127.0.0.1", + custom_domain: customDomain || null, + }; + setDomainRecord(name, updated); + console.log(` [domains] Bound "${name}.dweb" → port ${port}${serviceName ? ` (service: ${serviceName})` : ""} path="${svcPath}"`); + + json(res, 200, { ...updated, name }); + }); + + // POST /api/domain/unbind — unbind a domain from its service + router.post("/api/domain/unbind", async (req, res) => { + const body = await parseBody(req); + const name = (body.name || "").trim().toLowerCase(); + + if (!name) { + return json(res, 400, { status: "error", error: "Domain name is required" }); + } + + const record = getDomainRecord(name); + if (!record) { + return json(res, 404, { status: "error", error: `Domain "${name}.dweb" not found` }); + } + + // Remove from hosted services + if (record.service_name) { + removeHostedService(record.service_name); + } + + const updated = { + ...record, + service_name: null, + port: null, + address: null, + custom_domain: null, + }; + setDomainRecord(name, updated); + console.log(` [domains] Unbound "${name}.dweb"`); + + json(res, 200, { ...updated, name }); + }); + + // POST /api/domain/upgrade — upgrade domain tier + router.post("/api/domain/upgrade", async (req, res) => { + const body = await parseBody(req); + const name = (body.name || "").trim().toLowerCase(); + const newTier = body.new_tier; + + if (!name) { + return json(res, 400, { status: "error", error: "Domain name is required" }); + } + if (!newTier || !VALID_TIERS.includes(newTier)) { + return json(res, 400, { status: "error", error: "Valid new_tier is required" }); + } + + const record = getDomainRecord(name); + if (!record) { + return json(res, 404, { status: "error", error: `Domain "${name}.dweb" not found` }); + } + + // Simulate payment for paid tiers + if (TIERS[newTier].price > 0) { + console.log(` [domains] Upgrading "${name}.dweb" to ${newTier} — payment simulated`); + } + + const info = TIERS[newTier]; + const updated = { + ...record, + tier: newTier, + tierInfo: info, + expires_at: info.permanent ? null : new Date(Date.now() + info.ttlDays * 86400000).toISOString(), + paid_until: info.price > 0 ? new Date().toISOString() : record.paid_until, + auto_renew: info.price > 0, + }; + setDomainRecord(name, updated); + console.log(` [domains] Upgraded "${name}.dweb" to ${newTier}`); + + json(res, 200, { ...updated, name }); + }); + + // POST /api/domain/renew — renew domain + router.post("/api/domain/renew", async (req, res) => { + const body = await parseBody(req); + const name = (body.name || "").trim().toLowerCase(); + + if (!name) { + return json(res, 400, { status: "error", error: "Domain name is required" }); + } + + const record = getDomainRecord(name); + if (!record) { + return json(res, 404, { status: "error", error: `Domain "${name}.dweb" not found` }); + } + if (record.tierInfo?.permanent) { + return json(res, 400, { status: "error", error: "Permanent domains don't need renewal" }); + } + + const info = TIERS[record.tier]; + const updated = { + ...record, + expires_at: new Date(Date.now() + info.ttlDays * 86400000).toISOString(), + active: true, + }; + setDomainRecord(name, updated); + console.log(` [domains] Renewed "${name}.dweb"`); + + json(res, 200, { ...updated, name }); + }); + + // DELETE /api/domain/remove — remove a domain + router.delete("/api/domain/remove", async (req, res) => { + const body = await parseBody(req); + const name = (body.name || "").trim().toLowerCase(); + + if (!name) { + return json(res, 400, { status: "error", error: "Domain name is required" }); + } + + const record = getDomainRecord(name); + if (!record) { + return json(res, 404, { status: "error", error: `Domain "${name}.dweb" not found` }); + } + + // Remove from hosted services + if (record.service_name) { + removeHostedService(record.service_name); + } + + deleteDomainRecord(name); + console.log(` [domains] Removed "${name}.dweb"`); + + json(res, 200, { status: "ok", message: `Removed ${name}.dweb` }); + }); + + // ─── Peer-facing query endpoint ────────────────────────────── + // GET /api/domain/query/:name — returns raw domain record (for peer-to-peer resolution) + router.get(/^\/api\/domain\/query\/([a-zA-Z0-9_-]+)$/, (req, res, match) => { + const name = (match[1] || "").trim().toLowerCase(); + if (!name) { + return json(res, 400, { status: "error", error: "Domain name is required" }); + } + const record = getDomainRecord(name); + if (!record) { + return json(res, 404, { status: "error", error: `Domain "${name}.dweb" not found locally` }); + } + json(res, 200, { status: "ok", record: { ...record, name } }); + }); + + // ─── Resolve endpoint with P2P fallback ────────────────────── + // GET /api/domain/resolve/:name — resolve domain to address + port (for Browser) + // Falls back to querying connected peers when not found locally. + router.get(/^\/api\/domain\/resolve\/([a-zA-Z0-9_-]+)$/, async (req, res, match) => { + const name = (match[1] || "").trim().toLowerCase(); + + if (!name) { + return json(res, 400, { status: "error", error: "Domain name is required" }); + } + + // 1. Check local registry + let record = getDomainRecord(name); + + // 2. Check peer-resolve cache (avoids re-querying peers every time) + if (!record) { + const cached = peerResolveCache.get(name); + if (cached && Date.now() - cached.timestamp < PEER_RESOLVE_TTL) { + record = cached.record; + console.log(` [domains] Resolved "${name}.dweb" from peer cache`); + } + } + + // 3. Query connected peers + if (!record) { + record = await queryPeersForDomain(name); + if (record) { + peerResolveCache.set(name, { record, timestamp: Date.now() }); + } + } + + if (!record) { + return json(res, 404, { + status: "error", + error: `Domain "${name}.dweb" not found on this instance or any connected peer`, + name, + peer_count: peers.size, + }); + } + + if (!record.port || !record.address) { + return json(res, 200, { + status: "ok", + name, + resolved: false, + record: { ...record, name }, + message: "Domain registered but not bound to any service yet", + }); + } + + json(res, 200, { + status: "ok", + name, + resolved: true, + record: { ...record, name }, + address: record.address, + port: record.port, + path: record.path || "/", + url: `http://${record.address}:${record.port}${record.path || "/"}`, + }); + }); + + // GET /api/domain/services — running services available for binding (already in api-services.cjs) + // This is just a passthrough — already registered in api-services.cjs + + // ─── P2P Content Proxy ─────────────────────────────────────────── + // GET /api/proxy/fetch?url=... — fetches remote content (for BrowserView) + // Allows the non-Tauri BrowserView to render content from peer instances. + router.get("/api/proxy/fetch", async (req, res) => { + const targetUrl = req.url.searchParams.get("url"); + if (!targetUrl) { + return json(res, 400, { status: "error", error: "Missing ?url= parameter" }); + } + + try { + const parsed = new URL(targetUrl); + const proxyData = await new Promise((resolve, reject) => { + const opts = { + hostname: parsed.hostname, + port: parsed.port || 80, + path: parsed.pathname + parsed.search, + method: "GET", + timeout: 10000, + headers: { "User-Agent": "dweb-proxy/0.1" }, + }; + const proxyReq = http.request(opts, proxyRes => { + let body = ""; + proxyRes.on("data", c => { body += c; if (body.length > 5e6) proxyReq.destroy(); }); + proxyRes.on("end", () => resolve({ status: proxyRes.statusCode, headers: proxyRes.headers, body })); + }); + proxyReq.on("error", reject); + proxyReq.on("timeout", () => { proxyReq.destroy(); reject(new Error("timeout")); }); + proxyReq.end(); + }); + + if (proxyData.status !== 200) { + return json(res, proxyData.status, { status: "error", error: `Remote returned ${proxyData.status}` }); + } + + // Return as HTML with CORS headers + res.writeHead(200, { + "Content-Type": proxyData.headers["content-type"] || "text/html; charset=utf-8", + "Access-Control-Allow-Origin": "*", + "X-Dweb-Proxy": "true", + "X-Dweb-Source": targetUrl, + }); + res.end(proxyData.body); + } catch (e) { + json(res, 502, { status: "error", error: `Proxy failed: ${e.message}` }); + } + }); +} + +module.exports = { registerRoutes }; diff --git a/server/api-fileshare.cjs b/server/api-fileshare.cjs new file mode 100644 index 0000000..54581f7 --- /dev/null +++ b/server/api-fileshare.cjs @@ -0,0 +1,105 @@ +// ═══════════════════════════════════════════════════════════════════════════════ +// dweb — File Share API (/fileshare, /fileshare/api/*) +// ═══════════════════════════════════════════════════════════════════════════════ + +const path = require("path"); +const fs = require("fs"); +const { json, serveFile, parseBody } = require("./helpers.cjs"); +const { SHARE_DIR } = require("./config.cjs"); + +function registerRoutes(router) { + // File Share UI + router.get("/fileshare", (req, res) => { + const fsPath = path.join(__dirname, "..", "welcome", "fileshare.html"); + serveFile(res, fsPath); + }); + + // List files + router.get("/fileshare/api/list", (req, res) => { + try { + const files = fs.readdirSync(SHARE_DIR).map(name => { + const stat = fs.statSync(path.join(SHARE_DIR, name)); + return { name, size: stat.size, added: stat.mtimeMs, isDir: stat.isDirectory() }; + }); + json(res, 200, { status: "ok", count: files.length, files }); + } catch (e) { + json(res, 500, { error: e.message }); + } + }); + + // Upload file + router.post("/fileshare/api/upload", (req, res) => { + const ct = req.headers["content-type"] || ""; + if (!ct.includes("multipart/form-data")) { + return json(res, 400, { error: "Expected multipart/form-data" }); + } + let rawBody = []; + let totalBytes = 0; + const boundary = "--" + ct.split("boundary=")[1]; + req.on("data", c => { rawBody.push(c); totalBytes += c.length; if (totalBytes > 100e6) req.destroy(); }); + req.on("end", () => { + try { + const full = Buffer.concat(rawBody); + const parts = full.toString("latin1").split(boundary); + let saved = 0; + for (const part of parts) { + if (part.includes("filename=\"")) { + const fnMatch = part.match(/filename="(.+?)"/); + if (!fnMatch) continue; + const fileName = fnMatch[1]; + const headerEnd = part.indexOf("\r\n\r\n") + 4; + const content = part.slice(headerEnd, part.lastIndexOf("\r\n--")); + const buf = Buffer.from(content, "latin1"); + fs.writeFileSync(path.join(SHARE_DIR, fileName), buf); + saved++; + } + } + json(res, 200, { status: "ok", saved }); + } catch (e) { + json(res, 500, { error: e.message }); + } + }); + }); + + // Download file + router.get(/^\/fileshare\/api\/download\/(.+)$/, (req, res, match) => { + const fileName = decodeURIComponent(match[1]); + const filePath = path.join(SHARE_DIR, fileName); + if (!filePath.startsWith(SHARE_DIR)) return json(res, 403, { error: "Forbidden" }); + if (!fs.existsSync(filePath)) return json(res, 404, { error: "File not found" }); + const stat = fs.statSync(filePath); + res.writeHead(200, { + "Content-Type": "application/octet-stream", + "Content-Disposition": `attachment; filename="${fileName}"`, + "Content-Length": stat.size, + "Access-Control-Allow-Origin": "*", + }); + fs.createReadStream(filePath).pipe(res); + }); + + // Delete file + router.post("/fileshare/api/delete", async (req, res) => { + try { + const body = await parseBody(req); + const filePath = path.join(SHARE_DIR, body.name); + if (!filePath.startsWith(SHARE_DIR)) return json(res, 403, { error: "Forbidden" }); + if (fs.existsSync(filePath)) fs.unlinkSync(filePath); + json(res, 200, { status: "ok" }); + } catch (e) { + json(res, 500, { error: e.message }); + } + }); + + // View file share source + router.get("/fileshare/api/source", (req, res) => { + const fsPath = path.join(__dirname, "..", "welcome", "fileshare.html"); + if (!fs.existsSync(fsPath)) return json(res, 404, { error: "Source not found" }); + res.writeHead(200, { + "Content-Type": "text/plain; charset=utf-8", + "Access-Control-Allow-Origin": "*", + }); + res.end(fs.readFileSync(fsPath)); + }); +} + +module.exports = { registerRoutes }; diff --git a/server/api-ollama.cjs b/server/api-ollama.cjs new file mode 100644 index 0000000..bc509cf --- /dev/null +++ b/server/api-ollama.cjs @@ -0,0 +1,294 @@ +// ═══════════════════════════════════════════════════════════════════════════════ +// dweb — Ollama API (/api/ollama/*) +// Manages local LLM via Ollama with multi-source detection: +// - Native: 127.0.0.1:11434 +// - WSL-to-Windows: host.docker.internal:11434 +// - Docker container: docker-host:11434 +// Auto-prioritizes local Ollama over cloud models when detected. +// ═══════════════════════════════════════════════════════════════════════════════ + +const http = require("http"); +const { execSync, exec } = require("child_process"); +const fs = require("fs"); +const os = require("os"); +const { json } = require("./helpers.cjs"); + +const OLLAMA_PORT = 11434; + +// ─── WSL Detection ───────────────────────────────────────────────────────────── + +let _isWSL = null; +function isWSL() { + if (_isWSL !== null) return _isWSL; + try { + const release = fs.readFileSync("/proc/sys/kernel/osrelease", "utf-8").toLowerCase(); + _isWSL = release.includes("microsoft") || release.includes("wsl"); + } catch { + _isWSL = false; + } + return _isWSL; +} + +// ─── Multi-source Ollama probe ──────────────────────────────────────────────── + +/** Possible Ollama endpoints to probe, ordered by preference */ +function getProbeTargets() { + const targets = [ + { host: "127.0.0.1", port: OLLAMA_PORT, label: "native" }, + ]; + + // WSL: Windows host runs Ollama — accessible via host.docker.internal + if (isWSL()) { + targets.push({ host: "host.docker.internal", port: OLLAMA_PORT, label: "wsl-host" }); + } + + // Docker: container networking + targets.push({ host: "host.docker.internal", port: OLLAMA_PORT, label: "docker" }); + targets.push({ host: "172.17.0.1", port: OLLAMA_PORT, label: "docker-bridge" }); + + return targets; +} + +function probeEndpoint(host, port) { + return new Promise((resolve) => { + const req = http.request( + `${host}:${port}/api/tags`, + { method: "GET", timeout: 2000, hostname: host, port, path: "/api/tags" }, + (res) => { + let data = ""; + res.on("data", c => data += c); + res.on("end", () => { + try { + const parsed = JSON.parse(data); + resolve(parsed.models ? true : false); + } catch { resolve(false); } + }); + } + ); + req.on("error", () => resolve(false)); + req.on("timeout", () => { req.destroy(); resolve(false); }); + req.end(); + }); +} + +async function findOllama() { + const targets = getProbeTargets(); + for (const t of targets) { + try { + const found = await probeEndpoint(t.host, t.port); + if (found) return { host: t.host, port: t.port, label: t.label }; + } catch {} + } + return null; +} + +// ─── Ollama HTTP helpers ─────────────────────────────────────────────────────── + +function ollamaRequest(endpoint) { + return new Promise((resolve, reject) => { + const req = http.request( + `http://127.0.0.1:${OLLAMA_PORT}${endpoint}`, + { method: "GET", timeout: 5000 }, + (res) => { + let data = ""; + res.on("data", c => data += c); + res.on("end", () => { + try { resolve(JSON.parse(data)); } + catch { resolve(null); } + }); + } + ); + req.on("error", reject); + req.on("timeout", () => { req.destroy(); reject(new Error("timeout")); }); + req.end(); + }); +} + +async function getOllamaModels(host, port) { + return new Promise((resolve) => { + const req = http.request( + `http://${host}:${port}/api/tags`, + { method: "GET", timeout: 5000, hostname: host, port, path: "/api/tags" }, + (res) => { + let data = ""; + res.on("data", c => data += c); + res.on("end", () => { + try { + const parsed = JSON.parse(data); + if (parsed.models && Array.isArray(parsed.models)) { + resolve(parsed.models.map((m) => ({ + name: m.name, + size: m.size, + modified: m.modified_at, + details: m.details || {}, + }))); + } else { + resolve([]); + } + } catch { resolve([]); } + }); + } + ); + req.on("error", () => resolve([])); + req.on("timeout", () => { req.destroy(); resolve([]); }); + req.end(); + }); +} + +function getPlatform() { + const p = os.platform(); + if (p === "linux") return isWSL() ? "wsl" : "linux"; + if (p === "darwin") return "macos"; + if (p === "win32") return "windows"; + return p; +} + +function isOllamaInstalledLocal() { + try { + execSync("which ollama 2>/dev/null", { timeout: 3000 }); + return true; + } catch { return false; } +} + +// ─── Routes ──────────────────────────────────────────────────────────────────── + +function registerRoutes(router) { + // Ollama status — enhanced detection + router.get("/api/ollama/status", async (req, res) => { + const platform = getPlatform(); + const detected = await findOllama(); + const running = detected !== null; + const installed = isOllamaInstalledLocal(); + + let models = []; + if (running && detected) { + models = await getOllamaModels(detected.host, detected.port); + } + + json(res, 200, { + status: "ok", + installed, + running, + platform, + isWSL: isWSL(), + detectedVia: detected ? detected.label : null, // "native", "wsl-host", "docker", "docker-bridge" + detectedHost: detected ? detected.host : null, + detectedPort: detected ? detected.port : null, + models, + modelCount: models.length, + port: OLLAMA_PORT, + apiEndpoint: running && detected ? `http://${detected.host}:${detected.port}` : `http://127.0.0.1:${OLLAMA_PORT}`, + suggestedModel: "qwen2.5-coder:7b", + // Recommend using Ollama when any instance is found running + recommended: running, + }); + }); + + // Install Ollama + router.post("/api/ollama/install", async (req, res) => { + if (isOllamaInstalledLocal()) { + return json(res, 200, { status: "ok", message: "Ollama is already installed" }); + } + + const platform = getPlatform(); + + try { + if (platform === "wsl" || platform === "linux") { + const result = execSync("curl -fsSL https://ollama.com/install.sh | sh 2>&1", { + timeout: 120000, encoding: "utf-8", maxBuffer: 1024 * 1024, + }); + json(res, 200, { status: "ok", message: "Ollama installed successfully", output: result }); + } else if (platform === "macos") { + const result = execSync("curl -fsSL https://ollama.com/install.sh | sh 2>&1", { + timeout: 120000, encoding: "utf-8", maxBuffer: 1024 * 1024, + }); + json(res, 200, { status: "ok", message: "Ollama installed successfully", output: result }); + } else if (platform === "windows") { + const installerPath = "/tmp/OllamaSetup.exe"; + execSync(`curl -fsSLo "${installerPath}" https://ollama.com/download/OllamaSetup.exe 2>&1`, { + timeout: 120000, encoding: "utf-8", maxBuffer: 1024 * 1024, + }); + json(res, 200, { + status: "ok", message: "Ollama installer downloaded to /tmp/OllamaSetup.exe", + note: "Run the installer manually on Windows", installerPath, + }); + } else { + json(res, 400, { error: `Unsupported platform: ${platform}` }); + } + } catch (e) { + json(res, 500, { error: `Installation failed: ${e.message}` }); + } + }); + + // Start Ollama server + router.post("/api/ollama/start", async (req, res) => { + if (!isOllamaInstalledLocal()) { + return json(res, 400, { error: "Ollama is not installed. Install it first." }); + } + + const detected = await findOllama(); + if (detected) { + return json(res, 200, { + status: "ok", message: `Ollama is already running (detected via ${detected.label})`, + }); + } + + try { + const platform = getPlatform(); + if (platform === "wsl" || platform === "linux" || platform === "macos") { + execSync("nohup ollama serve > /tmp/ollama.log 2>&1 &", { timeout: 5000 }); + await new Promise(r => setTimeout(r, 2000)); + const running = await findOllama(); + if (running) { + json(res, 200, { status: "ok", message: "Ollama server started" }); + } else { + json(res, 200, { status: "started", message: "Ollama start initiated, may take a moment" }); + } + } else { + json(res, 400, { error: "Manual start required on this platform" }); + } + } catch (e) { + json(res, 500, { error: `Failed to start Ollama: ${e.message}` }); + } + }); + + // Pull a model + router.post("/api/ollama/pull", async (req, res) => { + const body = await require("./helpers.cjs").parseBody(req); + const modelName = body.model || "qwen2.5-coder:7b"; + + const detected = await findOllama(); + if (!detected) { + return json(res, 400, { error: "Ollama is not running" }); + } + + try { + exec(`ollama pull ${modelName} 2>&1`, (error, stdout, stderr) => { + if (error) { + console.log(` [ollama] Pull failed for ${modelName}: ${error.message}`); + } else { + console.log(` [ollama] Pulled model ${modelName}`); + } + }); + json(res, 200, { + status: "ok", + message: `Pulling model ${modelName} in background (may take several minutes)`, + model: modelName, + }); + } catch (e) { + json(res, 500, { error: `Failed to pull model: ${e.message}` }); + } + }); + + // List available Ollama models + router.get("/api/ollama/models", async (req, res) => { + const detected = await findOllama(); + if (!detected) { + return json(res, 200, { status: "ok", models: [], count: 0 }); + } + const models = await getOllamaModels(detected.host, detected.port); + json(res, 200, { status: "ok", count: models.length, models }); + }); +} + +module.exports = { registerRoutes }; diff --git a/server/api-opencode.cjs b/server/api-opencode.cjs new file mode 100644 index 0000000..4fd6c09 --- /dev/null +++ b/server/api-opencode.cjs @@ -0,0 +1,218 @@ +// ═══════════════════════════════════════════════════════════════════════════════ +// dweb — Opencode API (/api/repo/status, /api/opencode/*) +// +// Endpoints: +// GET /api/repo/status — Git repo context +// GET /api/opencode/status — Opencode CLI availability +// GET /api/opencode/models — List available models +// POST /api/opencode/run — [Legacy] Run opencode synchronously +// POST /api/opencode/stream — Start streaming opencode session (returns sessionId) +// GET /api/opencode/session/:id — Get session state snapshot +// GET /api/opencode/session-stream/:id — SSE stream for session +// POST /api/opencode/session/:id/cancel — Cancel running session +// ═══════════════════════════════════════════════════════════════════════════════ + +const path = require("path"); +const { execSync } = require("child_process"); +const { json, parseBody } = require("./helpers.cjs"); +const config = require("./config.cjs"); +const { INSTANCE_NAME } = config; +const { + createSession, + getSession, + cancelSession, + stripAnsi, +} = require("./opencode-worker.cjs"); + +function registerRoutes(router) { + // ── Repo context ──────────────────────────────────────── + router.get("/api/repo/status", (req, res) => { + let branch = "unknown", commit = "", files = 0, repoRoot = ""; + try { + repoRoot = execSync("git rev-parse --show-toplevel 2>/dev/null", { timeout: 3000, encoding: "utf-8" }).trim(); + branch = execSync("git rev-parse --abbrev-ref HEAD 2>/dev/null", { timeout: 3000, encoding: "utf-8" }).trim(); + commit = execSync("git rev-parse --short HEAD 2>/dev/null", { timeout: 3000, encoding: "utf-8" }).trim(); + files = parseInt(execSync("git ls-files 2>/dev/null | wc -l", { timeout: 3000, encoding: "utf-8" }).trim(), 10) || 0; + } catch {} + json(res, 200, { status: "ok", repo: path.basename(repoRoot) || "dweb", branch, commit, files, path: repoRoot }); + }); + + // ── Opencode status ───────────────────────────────────── + router.get("/api/opencode/status", (req, res) => { + let version = null; + let available = false; + try { + const v = execSync("opencode --version 2>/dev/null", { timeout: 3000, encoding: "utf-8" }).trim(); + version = v; + available = true; + } catch {} + json(res, 200, { status: "ok", available, version, instance: INSTANCE_NAME }); + }); + + // ── List models ───────────────────────────────────────── + router.get("/api/opencode/models", (req, res) => { + try { + const raw = execSync("opencode models 2>/dev/null", { timeout: 10000, encoding: "utf-8" }); + const lines = raw.split("\n").filter(l => l.startsWith("opencode/")).map(l => l.trim()).filter(Boolean); + const models = lines.map(id => { + const free = id.includes("free") || id.includes("nano") || id.includes("mini") || id.includes("flash"); + const provider = id.split("/")[1]?.split("-")[0] || "unknown"; + const label = id.replace("opencode/", ""); + return { id, label, provider, free }; + }); + models.sort((a, b) => { + if (a.free !== b.free) return a.free ? -1 : 1; + return a.label.localeCompare(b.label); + }); + json(res, 200, { status: "ok", count: models.length, models, default: "opencode/deepseek-v4-flash-free" }); + } catch (e) { + json(res, 200, { status: "ok", count: 0, models: [], default: "opencode/deepseek-v4-flash-free" }); + } + }); + + // ── [Legacy] Run opencode synchronously ───────────────── + // Kept for backward compatibility. Prefer POST /api/opencode/stream. + router.post("/api/opencode/run", async (req, res) => { + const body = await parseBody(req); + const { command, model, context, useOllama } = body; + if (!command) return json(res, 400, { error: "Missing command" }); + let cmd = command.trim(); + const shorthands = { + "build": "run npm run build in the dweb repo", + "test": "run npm test in the dweb repo and fix any failures", + "dev": "explain the current development setup and how to start developing", + "status": "show the current state of the dweb repo, its architecture, and what can be built next", + "help": "list available commands and how to use this opencode agent", + }; + const expanded = shorthands[cmd.toLowerCase()] || cmd; + const useModel = model || "opencode/deepseek-v4-flash-free"; + + if (context === true) { + return json(res, 200, { status: "ok", context: true, message: "Context received" }); + } + + const serverContext = `You are inside dweb (http://localhost:${config.PORT}/). Tech: React+Vite+TS frontend, Node.js backend. Repo: ${__dirname}/../. Build: npm run build. Test: npm test.`; + const fullCommand = `${serverContext}\n\nUser: ${expanded}`; + + try { + const env = { ...process.env }; + const ollamaModel = model || "ollama/qwen2.5-coder:7b"; + if (useOllama) { + env.OPENAI_BASE_URL = "http://127.0.0.1:11434/v1"; + env.OPENAI_API_KEY = "ollama"; + } + + const output = execSync( + `opencode run -m ${JSON.stringify(useOllama ? ollamaModel : useModel)} --dangerously-skip-permissions ${JSON.stringify(fullCommand)} 2>&1`, + { + timeout: 300000, + encoding: "utf-8", + maxBuffer: 1024 * 1024, + env, + } + ); + // Strip ANSI codes for browser display + const cleanOutput = stripAnsi(output); + json(res, 200, { status: "ok", output: cleanOutput, command: expanded, model: useOllama ? ollamaModel : useModel, provider: useOllama ? "ollama" : "cloud" }); + } catch (e) { + const errText = (e.stderr || e.message || "").toString(); + const cleanError = stripAnsi(errText); + json(res, 200, { status: "error", output: cleanError, command: expanded, model: useOllama ? ollamaModel : useModel, provider: useOllama ? "ollama" : "cloud" }); + } + }); + + // ── Start streaming opencode session ──────────────────── + // Returns sessionId immediately. Client then connects to the SSE endpoint. + router.post("/api/opencode/stream", async (req, res) => { + const body = await parseBody(req); + const { command, model, useOllama } = body; + if (!command) return json(res, 400, { error: "Missing command" }); + + let cmd = command.trim(); + const shorthands = { + "build": "run npm run build in the dweb repo", + "test": "run npm test in the dweb repo and fix any failures", + "dev": "explain the current development setup and how to start developing", + "status": "show the current state of the dweb repo, its architecture, and what can be built next", + "help": "list available commands and how to use this opencode agent", + }; + const expanded = shorthands[cmd.toLowerCase()] || cmd; + const useModel = model || "opencode/deepseek-v4-flash-free"; + const provider = useOllama ? "ollama" : "cloud"; + + const session = createSession(expanded, useOllama ? `ollama/${useModel}` : useModel, provider); + json(res, 200, { + status: "ok", + sessionId: session.id, + command: expanded, + model: useModel, + provider, + }); + }); + + // ── Get session state snapshot ────────────────────────── + // Useful for reconnecting after tab switch. + router.get("/api/opencode/session/:id", (req, res, match) => { + const sessionId = match?.[1] || req.url.pathname.split("/").pop(); + const session = getSession(sessionId); + if (!session) return json(res, 404, { status: "error", error: "Session not found" }); + json(res, 200, { status: "ok", session: session.toJSON() }); + }); + + // ── SSE stream for session ────────────────────────────── + router.get("/api/opencode/session-stream/:id", (req, res, match) => { + const sessionId = match?.[1] || req.url.pathname.split("/").pop(); + const session = getSession(sessionId); + if (!session) { + res.writeHead(404, { "Content-Type": "application/json" }); + return res.end(JSON.stringify({ status: "error", error: "Session not found" })); + } + + // SSE headers + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Access-Control-Allow-Origin": "*", + "X-Accel-Buffering": "no", + }); + + // Send initial connected event + res.write(`event: connected\ndata: ${JSON.stringify({ sessionId, running: session.running })}\n\n`); + + // Register this client + session.addSSEClient(res); + + // Keepalive to prevent connection timeout + const keepalive = setInterval(() => { + try { res.write(": keepalive\n\n"); } catch { clearInterval(keepalive); } + }, 15000); + + // Cleanup on disconnect + req.on("close", () => { + clearInterval(keepalive); + session.removeSSEClient(res); + }); + }); + + // ── Cancel a running session ──────────────────────────── + router.post("/api/opencode/session/:id/cancel", (req, res, match) => { + const sessionId = match?.[1] || req.url.pathname.split("/").pop(); + const success = cancelSession(sessionId); + if (!success) return json(res, 404, { status: "error", error: "Session not found" }); + json(res, 200, { status: "ok", message: "Session cancelled" }); + }); + + // ── List active sessions ──────────────────────────────── + router.get("/api/opencode/sessions", (req, res) => { + const { sessions } = require("./opencode-worker.cjs"); + const list = []; + for (const [id, session] of sessions) { + list.push(session.toJSON()); + } + list.sort((a, b) => (b.startedAt || 0) - (a.startedAt || 0)); + json(res, 200, { status: "ok", count: list.length, sessions: list }); + }); +} + +module.exports = { registerRoutes }; diff --git a/server/api-relay.cjs b/server/api-relay.cjs new file mode 100644 index 0000000..a2098ba --- /dev/null +++ b/server/api-relay.cjs @@ -0,0 +1,114 @@ +// ═══════════════════════════════════════════════════════════════════════════════ +// dweb — Public Relay API (/ping, /status, /register, /heartbeat, /discover, +// /peer/:id, /signal) +// ═══════════════════════════════════════════════════════════════════════════════ + +const os = require("os"); +const crypto = require("crypto"); +const { json, parseBody, peerToJSON } = require("./helpers.cjs"); +const { SERVER_ID, PEER_TTL_MS, MODE, START_TIME, UPSTREAM_RELAY } = require("./config.cjs"); +const { peers, signals, PeerRecord, storeSignal, popSignals, savePeers } = require("./state.cjs"); + +function registerRoutes(router) { + // PING + router.get("/ping", (req, res) => { + json(res, 200, { + status: "ok", server: "dweb", + id: SERVER_ID, hostname: os.hostname(), platform: process.platform, + version: "0.1.0", uptime: Math.floor((Date.now() - START_TIME) / 1000), + mode: MODE, peers: peers.size, services: 0, + }); + }); + + // STATUS + router.get("/status", (req, res) => { + const modeCounts = {}; + for (const p of peers.values()) modeCounts[p.mode] = (modeCounts[p.mode] || 0) + 1; + json(res, 200, { + status: "ok", serverId: SERVER_ID, + hostname: os.hostname(), version: "0.1.0", + mode: MODE, uptime: Math.floor((Date.now() - START_TIME) / 1000), + peersOnline: peers.size, + upstreamRelay: UPSTREAM_RELAY, + localIPs: [], port: 0, relayPort: 0, tcpPort: 0, + modes: modeCounts, platform: process.platform, + memory: { + rss: Math.round(process.memoryUsage().rss / 1024 / 1024) + "MB", + heapUsed: Math.round(process.memoryUsage().heapUsed / 1024 / 1024) + "MB", + }, + }); + }); + + // REGISTER + router.post("/register", async (req, res) => { + const body = await parseBody(req); + const id = body.id || crypto.randomUUID(); + const existing = peers.get(id); + if (existing) { + existing.touch(); + Object.assign(existing, body, { lastSeen: Date.now() }); + savePeers(); + return json(res, 200, { status: "ok", action: "updated", peerId: id, peersOnline: peers.size }); + } + const peer = new PeerRecord(id, body); + peers.set(id, peer); + savePeers(); + console.log(` [p2p] ${id.slice(0, 16)}… registered (${peers.size} total)`); + json(res, 201, { status: "ok", action: "registered", peerId: id, peersOnline: peers.size }); + }); + + // HEARTBEAT + router.post("/heartbeat", async (req, res) => { + const body = await parseBody(req); + const peer = peers.get(body.peerId); + if (!peer) return json(res, 404, { error: "Unknown peer" }); + peer.touch(); + json(res, 200, { status: "ok", peersOnline: peers.size }); + }); + + // DISCOVER + router.get("/discover", (req, res) => { + const modeFilter = req.url.searchParams.get("mode"); + const list = []; + for (const p of peers.values()) { + if (modeFilter && p.mode !== modeFilter) continue; + list.push(peerToJSON(p)); + } + json(res, 200, { status: "ok", count: list.length, peers: list }); + }); + + // PEER INFO / DELETE + router.get(/^\/peer\/(.+)$/, (req, res, match) => { + const peer = peers.get(match[1]); + if (!peer) return json(res, 404, { error: "Peer not found" }); + json(res, 200, { status: "ok", peer: peerToJSON(peer) }); + }); + + router.delete(/^\/peer\/(.+)$/, (req, res, match) => { + peers.delete(match[1]); + signals.delete(match[1]); + json(res, 200, { status: "ok", message: "Peer removed" }); + }); + + // SIGNAL + router.post("/signal", async (req, res) => { + const body = await parseBody(req); + if (!body.targetPeerId) return json(res, 400, { error: "Missing targetPeerId" }); + storeSignal(body.targetPeerId, { + fromPeerId: body.fromPeerId || "anonymous", type: body.type || "unknown", + sdp: body.sdp || null, candidate: body.candidate || null, + }); + if (!peers.has(body.targetPeerId)) { + console.log(` [signal] ${(body.fromPeerId||"?").slice(0,12)} → ${body.targetPeerId.slice(0,12)} (queued, peer offline)`); + } + json(res, 200, { status: "ok", queued: true }); + }); + + router.get("/signal", (req, res) => { + const peerId = req.url.searchParams.get("peerId"); + if (!peerId) return json(res, 400, { error: "Missing peerId" }); + json(res, 200, { status: "ok", count: signals.get(peerId)?.length || 0, signals: popSignals(peerId) }); + }); +} + +module.exports = { registerRoutes }; diff --git a/server/api-services.cjs b/server/api-services.cjs new file mode 100644 index 0000000..8bc19b0 --- /dev/null +++ b/server/api-services.cjs @@ -0,0 +1,356 @@ +// ═══════════════════════════════════════════════════════════════════════════════ +// dweb — Service Management API (/api/services, /api/service/*) +// Start, stop, and list managed services with persistence +// ═══════════════════════════════════════════════════════════════════════════════ + +const http = require("http"); +const fs = require("fs"); +const path = require("path"); +const os = require("os"); +const { execSync } = require("child_process"); +const { json, parseBody } = require("./helpers.cjs"); +const config = require("./config.cjs"); +const { addHostedService, removeHostedService } = require("./state.cjs"); + +// ─── Running Services Map ──────────────────────────────────────────────────── +// name -> { server, port, type, dir, started } +const runningServices = new Map(); +const SERVICES_FILE = path.join(os.tmpdir(), "dweb-services.json"); + +// ─── Static MIME types for file-serving services ────────────────────────────── +const STATIC_MIME = { + ".html": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "application/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".svg": "image/svg+xml", + ".webp": "image/webp", + ".ico": "image/x-icon", + ".txt": "text/plain; charset=utf-8", + ".md": "text/markdown; charset=utf-8", + ".pdf": "application/pdf", + ".wasm": "application/wasm", +}; + +// ─── Persistence ────────────────────────────────────────────────────────────── + +function saveServices() { + try { + const data = []; + for (const [name, svc] of runningServices) { + data.push({ name, port: svc.port, type: svc.type, dir: svc.dir || null, started: svc.started }); + } + fs.writeFileSync(SERVICES_FILE, JSON.stringify(data, null, 2)); + } catch (e) { + console.log(` [services] Failed to save services: ${e.message}`); + } +} + +function restoreServices() { + try { + if (!fs.existsSync(SERVICES_FILE)) return 0; + const data = JSON.parse(fs.readFileSync(SERVICES_FILE, "utf8")); + if (!Array.isArray(data)) return 0; + let restored = 0; + + for (const entry of data) { + if (runningServices.has(entry.name)) continue; + const port = entry.port || (Math.floor(Math.random() * 10000) + 30000); + const dir = entry.dir; + const type = entry.type || "Custom"; + + // Try to restart the service + const svc = createServiceServer(entry.name, type, port, dir); + if (svc) { + runningServices.set(entry.name, svc); + // Re-register on P2P relay + addHostedService(entry.name, type, svc.port, `http://localhost:${svc.port}`); + restored++; + } + } + if (restored > 0) console.log(` [services] Restored ${restored} services from disk`); + return restored; + } catch (e) { + console.log(` [services] Failed to restore services: ${e.message}`); + return 0; + } +} + +// ─── Demo Content Generator ────────────────────────────────────────────────── + +function getDemoDir(type, name, port) { + const DEMO_ROOT = path.join(os.tmpdir(), "dweb-demo"); + const safeName = String(name).replace(/[^a-zA-Z0-9_-]/g, "_"); + const demoDir = path.join(DEMO_ROOT, `${safeName}-${port}`); + if (fs.existsSync(demoDir)) return demoDir; + + try { + fs.mkdirSync(demoDir, { recursive: true }); + + if (type === "Static Site" || type === "Single Page App" || type === "Documentation Site" || type === "Dashboard") { + // Copy the real welcome.html as index.html for a proper welcome page + const welcomeSrc = path.join(__dirname, "..", "welcome", "welcome.html"); + if (fs.existsSync(welcomeSrc)) { + // Read welcome.html, replace the "/welcome/source" link with actual port info + let welcomeContent = fs.readFileSync(welcomeSrc, "utf8"); + welcomeContent = welcomeContent + .replace(/dweb\.local/g, name) + .replace(/dweb v0\.1\.0/i, `${name} — ${type}`); + fs.writeFileSync(path.join(demoDir, "index.html"), welcomeContent); + } else { + // Fallback generic welcome + fs.writeFileSync(path.join(demoDir, "index.html"), + `${name}` + + `` + + `

${name}

Welcome! This static site is hosted by dweb.

` + + `

Port: ${port}

`); + } + } + + if (type === "File Browser") { + fs.writeFileSync(path.join(demoDir, "README.md"), + `# ${name}\n\nWelcome to your file browser.\n\n## Quick Start\n- Upload files using the toolbar\n- Create folders to organize content\n- Drag & drop to upload\n`); + fs.writeFileSync(path.join(demoDir, "index.html"), + `${name}

${name}

Your file browser is ready. Upload files to get started.

`); + } + + if (type === "Image Gallery") { + const colors = [ + { name: "Sunset", bg1: "#f97316", bg2: "#ef4444" }, + { name: "Mountains", bg1: "#3b82f6", bg2: "#1d4ed8" }, + { name: "Forest", bg1: "#22c55e", bg2: "#15803d" }, + ]; + for (const c of colors) { + const svg = `${c.name}`; + fs.writeFileSync(path.join(demoDir, `${c.name.toLowerCase()}.svg`), svg); + } + } + + if (type === "Media Stream" || type === "Podcast Host") { + // Create a minimal WAV file + const sampleRate = 44100; + const duration = 2; + const numSamples = sampleRate * duration; + const wavBuf = Buffer.alloc(44 + numSamples * 2); + wavBuf.write("RIFF", 0); + wavBuf.writeUInt32LE(36 + numSamples * 2, 4); + wavBuf.write("WAVE", 8); + wavBuf.write("fmt ", 12); + wavBuf.writeUInt32LE(16, 16); + wavBuf.writeUInt16LE(1, 20); + wavBuf.writeUInt16LE(1, 22); + wavBuf.writeUInt32LE(sampleRate, 24); + wavBuf.writeUInt32LE(sampleRate * 2, 28); + wavBuf.writeUInt16LE(2, 32); + wavBuf.writeUInt16LE(16, 34); + wavBuf.write("data", 36); + wavBuf.writeUInt32LE(numSamples * 2, 40); + for (let wi = 0; wi < numSamples; wi++) { + const t = wi / sampleRate; + const sample = Math.sin(2 * Math.PI * 440 * t) * 0.3; + const val = Math.max(-1, Math.min(1, sample)); + wavBuf.writeInt16LE(Math.round(val * 32767), 44 + wi * 2); + } + const prefix = type === "Podcast Host" ? "episode" : "track"; + fs.writeFileSync(path.join(demoDir, `${prefix}_001_demo.wav`), wavBuf); + fs.writeFileSync(path.join(demoDir, `${prefix}_002_demo.wav`), wavBuf); + } + + if (type === "Log Viewer") { + fs.writeFileSync(path.join(demoDir, "app.log"), + `[${new Date().toISOString()}] [INFO] dweb service started\n[${new Date().toISOString()}] [INFO] Listening on port ${port}\n[${new Date().toISOString()}] [DEBUG] Loading configuration...\n[${new Date().toISOString()}] [INFO] Service ${name} ready\n`); + } + + return demoDir; + } catch (e) { + console.log(` [services] Demo dir creation failed: ${e.message}`); + return demoDir; + } +} + +// ─── Create a simple static file server for a service ───────────────────────── + +function createServiceServer(name, type, port, dir) { + try { + // Determine the directory to serve + const dirTypes = ["Static Site", "Single Page App", "Documentation Site", "Dashboard", "File Browser", "Image Gallery", "Media Stream", "Podcast Host", "Log Viewer", "Git Web UI"]; + const serveDir = dir || getDemoDir(type, name, port); + + // Ensure directory exists + if (!fs.existsSync(serveDir)) { + fs.mkdirSync(serveDir, { recursive: true }); + } + + // Create a simple HTTP server that serves files from the directory + const cors = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET,POST,DELETE,OPTIONS", "Access-Control-Allow-Headers": "Content-Type" }; + const server = http.createServer((req, res) => { + if (req.method === "OPTIONS") { + res.writeHead(204, cors); + return res.end(); + } + + const url = new URL(req.url, `http://localhost:${port}`); + let filePath = path.join(serveDir, url.pathname === "/" ? "index.html" : url.pathname); + + // Security: prevent directory traversal + if (!filePath.startsWith(path.resolve(serveDir))) { + res.writeHead(403, { ...cors, "Content-Type": "application/json" }); + return res.end(JSON.stringify({ error: "Forbidden" })); + } + + // Try to serve the file + try { + if (fs.statSync(filePath).isDirectory()) { + filePath = path.join(filePath, "index.html"); + } + const ext = path.extname(filePath).toLowerCase(); + const contentType = STATIC_MIME[ext] || "application/octet-stream"; + const content = fs.readFileSync(filePath); + res.writeHead(200, { ...cors, "Content-Type": contentType }); + res.end(content); + } catch { + // File not found - serve a 404 or index.html for SPAs + try { + const fallback = path.join(serveDir, "index.html"); + if (fs.existsSync(fallback) && type !== "File Browser") { + const content = fs.readFileSync(fallback); + res.writeHead(200, { ...cors, "Content-Type": "text/html; charset=utf-8" }); + return res.end(content); + } + } catch {} + res.writeHead(404, { ...cors, "Content-Type": "text/html; charset=utf-8" }); + res.end(`

404 - ${name}

File not found

`); + } + }); + + server.listen(port, "127.0.0.1", () => { + console.log(` [service] Started "${name}" on port ${port}${dir ? ` dir="${dir}"` : ""}`); + }); + + server.on("error", (err) => { + console.log(` [service] Error starting "${name}": ${err.message}`); + }); + + const svc = { server, port, type, dir: serveDir, started: Date.now() }; + return svc; + } catch (e) { + console.log(` [service] Failed to create server for "${name}": ${e.message}`); + return null; + } +} + +// ─── Routes ──────────────────────────────────────────────────────────────────── + +function registerRoutes(router) { + // GET /api/services — list all running services + router.get("/api/services", (req, res) => { + const list = []; + for (const [name, svc] of runningServices) { + list.push({ + name, + port: svc.port, + type: svc.type || "Custom", + dir: svc.dir || null, + running: true, + cpu: 0.5, + memory: 8_000_000, + }); + } + json(res, 200, { status: "ok", services: list }); + }); + + // GET /api/domain/services — services for domain binding + router.get("/api/domain/services", (req, res) => { + const services = []; + for (const [name, svc] of runningServices) { + services.push({ name, port: svc.port, type: svc.type }); + } + json(res, 200, services); + }); + + // POST /api/service/start — start a managed service + router.post("/api/service/start", async (req, res) => { + const body = await parseBody(req); + const { name, type, port, dir } = body; + + if (!name) return json(res, 400, { status: "error", message: "Missing service name" }); + if (runningServices.has(name)) { + return json(res, 409, { status: "error", message: `Service "${name}" is already running` }); + } + + const servicePort = port || (Math.floor(Math.random() * 10000) + 30000); + const serviceType = type || "Custom"; + const svc = createServiceServer(name, serviceType, servicePort, dir || null); + + if (!svc) { + return json(res, 500, { status: "error", message: `Failed to start service "${name}"` }); + } + + runningServices.set(name, svc); + saveServices(); + + // Register on P2P relay for global discovery + addHostedService(name, serviceType, servicePort, `http://localhost:${servicePort}`); + + json(res, 200, { + status: "ok", + message: `Service "${name}" started on port ${servicePort}`, + service: { name, port: servicePort, type: serviceType, dir: dir || null, running: true, url: `http://localhost:${servicePort}` }, + }); + }); + + // POST /api/service/stop — stop a managed service + router.post("/api/service/stop", async (req, res) => { + const body = await parseBody(req); + const { name } = body; + + if (!name) return json(res, 400, { status: "error", message: "Missing service name" }); + const svc = runningServices.get(name); + if (!svc) { + return json(res, 404, { status: "error", message: `Service "${name}" not found` }); + } + + try { + svc.server.close(); + } catch {} + runningServices.delete(name); + saveServices(); + + // Remove from P2P relay + removeHostedService(name); + + console.log(` [service] Stopped "${name}" on port ${svc.port}`); + json(res, 200, { status: "ok", message: `Service "${name}" stopped` }); + }); + + // POST /api/service/customize — save customized HTML source for a service + router.post("/api/service/customize", async (req, res) => { + const body = await parseBody(req); + const { name, content } = body; + + if (!name) return json(res, 400, { status: "error", error: "Missing service name" }); + if (typeof content !== "string") return json(res, 400, { status: "error", error: "Missing content body" }); + + const svc = runningServices.get(name); + if (!svc) return json(res, 404, { status: "error", error: `Service "${name}" not found or not running` }); + + const serveDir = svc.dir; + if (!serveDir || !fs.existsSync(serveDir)) { + return json(res, 400, { status: "error", error: `Service "${name}" has no writable directory` }); + } + + try { + fs.writeFileSync(path.join(serveDir, "index.html"), content, "utf8"); + console.log(` [service] Customized page for "${name}" in ${serveDir}`); + json(res, 200, { status: "ok", message: `Page for "${name}" updated` }); + } catch (e) { + json(res, 500, { status: "error", error: `Failed to write: ${e.message}` }); + } + }); +} + +module.exports = { registerRoutes, runningServices, restoreServices }; diff --git a/server/api-system.cjs b/server/api-system.cjs new file mode 100644 index 0000000..4f50495 --- /dev/null +++ b/server/api-system.cjs @@ -0,0 +1,329 @@ +// ═══════════════════════════════════════════════════════════════════════════════ +// dweb — System API (/api/tor/*, /api/instance/*, /api/p2p/*, /api/publish, +// /api/projects, /project/*) +// ═══════════════════════════════════════════════════════════════════════════════ + +const path = require("path"); +const fs = require("fs"); +const os = require("os"); +const net = require("net"); +const { execSync, exec } = require("child_process"); +const { json, parseBody, serveFile } = require("./helpers.cjs"); +const config = require("./config.cjs"); +const { SHARE_DIR } = config; +const { hostedServices, localPeers, peers, addHostedService, getDomainRecord, setDomainRecord, listDomainRecords, + setTorEnabled, isTorEnabled, getTorProxy } = require("./state.cjs"); + +function registerRoutes(router) { + // ── Tor ────────────────────────────────────────────────────────────────────── + + router.get("/api/tor/status", (req, res) => { + let installed = false, running = false; + try { execSync("which tor 2>/dev/null", { timeout: 3000 }); installed = true; } catch {} + try { execSync("pgrep -x tor 2>/dev/null", { timeout: 3000 }); running = true; } catch {} + let kalitorifyAvailable = false; + try { execSync("which kalitorify 2>/dev/null", { timeout: 3000 }); kalitorifyAvailable = true; } catch {} + json(res, 200, { + status: "ok", installed, running, kalitorifyAvailable, + torEnabled: isTorEnabled(), + torProxy: getTorProxy(), + }); + }); + + router.post("/api/tor/toggle", async (req, res) => { + const body = await parseBody(req); + const { action } = body; + try { + if (action === "start") { + // 1) Try kalitorify (full OS-level Tor routing) + // 2) Fall back to nohup tor (just the daemon + SOCKS5 proxy) + try { + await new Promise((resolve, reject) => { + exec("sudo kalitorify --tor", { timeout: 10000 }, (err) => err ? reject(err) : resolve()); + }); + } catch { + await new Promise((resolve, reject) => { + exec("nohup tor > /dev/null 2>&1 &", { timeout: 5000 }, (err) => err ? reject(err) : resolve()); + }); + } + setTorEnabled(true); + json(res, 200, { status: "ok", message: "Tor routing enabled", torEnabled: true, torProxy: getTorProxy() }); + } else if (action === "stop") { + try { + await new Promise((resolve, reject) => { + exec("sudo kalitorify --clearnet", { timeout: 10000 }, (err) => err ? reject(err) : resolve()); + }); + } catch {} + try { + await new Promise((resolve, reject) => { + exec("pkill -x tor", { timeout: 3000 }, (err) => err ? reject(err) : resolve()); + }); + } catch {} + setTorEnabled(false); + json(res, 200, { status: "ok", message: "Tor routing disabled", torEnabled: false }); + } else { + json(res, 400, { error: "Invalid action. Use 'start' or 'stop'." }); + } + } catch (e) { + json(res, 500, { error: e.message }); + } + }); + + // ── Tor Proxy Test ────────────────────────────────────────────────────────── + router.get("/api/tor/test", async (req, res) => { + const enabled = isTorEnabled(); + const proxy = getTorProxy(); + // Try to connect to Tor's SOCKS5 control port to verify the proxy is alive + let proxyReachable = false; + try { + await new Promise((resolve, reject) => { + const s = net.createConnection({ host: "127.0.0.1", port: 9050, timeout: 3000 }, () => { + proxyReachable = true; + s.end(); + resolve(); + }); + s.on("error", reject); + s.on("timeout", () => { s.destroy(); reject(new Error("timeout")); }); + }); + } catch {} + json(res, 200, { + status: "ok", + torEnabled: enabled, + torProxy: proxy, + proxyReachable, + daemonRunning: proxyReachable, // SOCKS5 listener running = tor daemon is up + }); + }); + + // ── Instance Management ────────────────────────────────────────────────────── + + router.post("/api/instance/spawn", async (req, res) => { + const body = await parseBody(req); + const mode = body.mode || "peer"; + const getRandomPort = () => Math.floor(Math.random() * 10000) + 50000; + function isPortFree(port) { + return new Promise((resolve) => { + const s = net.createServer(); + s.once("error", () => resolve(false)); + s.once("listening", () => { s.close(); resolve(true); }); + s.listen(port, "127.0.0.1"); + }); + } + let newPort = getRandomPort(); + let attempts = 0; + while (!(await isPortFree(newPort)) && attempts < 20) { + newPort = getRandomPort(); + attempts++; + } + const instanceDir = path.resolve(__dirname, ".."); + const logFile = path.join(instanceDir, `instance-${newPort}.log`); + const cmd = `MODE=${mode} PORT=${newPort} RELAY_PORT=${newPort + 1} nohup node ${path.join(instanceDir, "dweb.cjs")} > ${logFile} 2>&1 &`; + try { + execSync(cmd, { timeout: 5000 }); + await new Promise(r => setTimeout(r, 2000)); + json(res, 200, { + status: "ok", + port: newPort, + url: `http://127.0.0.1:${newPort}/`, + logFile, + message: `New dweb instance spawned on port ${newPort}` + }); + } catch (e) { + json(res, 500, { error: e.message }); + } + }); + + router.get("/api/instance/list", (req, res) => { + try { + const raw = execSync("pgrep -af 'dweb\\.cjs' 2>/dev/null || pgrep -f 'dweb.cjs' 2>/dev/null", { timeout: 3000, encoding: "utf-8" }); + const lines = raw.trim().split("\n").filter(Boolean); + const instances = []; + for (const line of lines) { + try { + // Try to get the PID (first token) and find PORT from env + const pid = parseInt(line.trim().split(/\s+/)[0], 10); + if (!pid) continue; + const portLine = execSync(`cat /proc/${pid}/environ 2>/dev/null | tr '\\0' '\\n' | grep '^PORT=' || true`, { timeout: 2000, encoding: "utf-8" }).trim(); + const port = portLine ? parseInt(portLine.replace("PORT=", ""), 10) : 0; + instances.push({ pid, port, url: port ? `http://127.0.0.1:${port}/` : null }); + } catch {} + } + json(res, 200, { status: "ok", count: instances.length, instances }); + } catch (e) { + json(res, 200, { status: "ok", count: 0, instances: [] }); + } + }); + + // ── P2P File Receive ───────────────────────────────────────────────────────── + + router.post("/api/p2p/receive", async (req, res) => { + const body = await parseBody(req); + const { fileName, fileData, fromPeerId, fromHostname } = body; + if (!fileName || !fileData) { + return json(res, 400, { error: "Missing fileName or fileData" }); + } + const safeName = path.basename(fileName); + const prefix = fromHostname + ? `p2p-from-${fromHostname.replace(/[^a-zA-Z0-9-_]/g, "_")}-` + : "p2p-from-"; + const destName = prefix + safeName; + const destPath = path.join(SHARE_DIR, destName); + if (!destPath.startsWith(SHARE_DIR)) return json(res, 403, { error: "Forbidden" }); + const buf = Buffer.from(fileData, "base64"); + fs.writeFileSync(destPath, buf); + console.log(` [p2p] Received file "${safeName}" from ${fromPeerId ? fromPeerId.slice(0, 16) : "unknown"} (${buf.length} bytes)`); + json(res, 200, { status: "ok", fileName: destName, size: buf.length }); + }); + + router.get("/api/p2p/received", (req, res) => { + try { + const all = fs.readdirSync(SHARE_DIR); + const p2pFiles = all.filter(name => name.startsWith("p2p-from-")).map(name => { + const stat = fs.statSync(path.join(SHARE_DIR, name)); + return { name, size: stat.size, added: stat.mtimeMs }; + }); + json(res, 200, { status: "ok", count: p2pFiles.length, files: p2pFiles }); + } catch (e) { + json(res, 500, { error: e.message }); + } + }); + + router.get("/api/p2p/discover-local", (req, res) => { + const list = []; + for (const [peerId, info] of localPeers) { + list.push({ peerId, hostname: info.hostname, port: info.port, address: info.address, lastSeen: info.lastSeen, version: info.version, platform: info.platform }); + } + json(res, 200, { status: "ok", count: list.length, peers: list }); + }); + + // ── Publish / Projects ─────────────────────────────────────────────────────── + + router.get("/api/projects", (req, res) => { + const projectsDir = path.resolve(__dirname, "..", "projects"); + if (!fs.existsSync(projectsDir)) return json(res, 200, { status: "ok", projects: [] }); + const projects = fs.readdirSync(projectsDir).map(name => { + const stats = fs.statSync(path.join(projectsDir, name)); + return { name, added: stats.mtimeMs, route: `/project/${name}`, url: `http://localhost:${config.PORT}/project/${name}` }; + }); + json(res, 200, { status: "ok", count: projects.length, projects }); + }); + + /* ── Auto-assign .dweb domain helper ─────────────────── */ + function autoAssignDomain(projectName, port) { + // Sanitize project name into a valid domain name + const domainName = projectName + .toLowerCase() + .replace(/[^a-z0-9-]/g, "-") + .replace(/^-+|-+$/g, "") + .replace(/--+/g, "-") + .slice(0, 63); + + // Must be at least 3 chars + const finalName = domainName.length < 3 ? domainName + "-dweb" : domainName; + + // The service path on the dweb server (e.g. /project/hello-dweb) + const routeName = projectName.replace(/[^a-zA-Z0-9-_]/g, "_"); + const servicePath = `/project/${routeName}`; + + // Check if already registered + const existing = getDomainRecord(finalName); + if (existing) { + // Already registered — just update the binding + if (port) { + const updated = { ...existing, port, path: servicePath, address: config.LOCAL_IPS[0] || "127.0.0.1", service_name: projectName }; + setDomainRecord(finalName, updated); + addHostedService(projectName, "Domain", port, `http://127.0.0.1:${port}`); + } + return { name: finalName, auto: false, path: servicePath }; + } + + // Auto-register as free tier and bind + const crypto = require("crypto"); + const OWNER_KEY = crypto.randomUUID().replace(/-/g, "").slice(0, 16); + const now = new Date(); + const expires = new Date(now.getTime() + 90 * 86400000).toISOString(); + const record = { + owner_key: OWNER_KEY, + address: config.LOCAL_IPS[0] || "127.0.0.1", + path: servicePath, + tier: "free", + tierInfo: { label: "Free", price: 0, ttlDays: 90, permanent: false, customDomain: false, ssl: false, description: "Basic .dweb domain, 90-day expiry" }, + service_name: projectName, + port: port || null, + custom_domain: null, + registered_at: now.toISOString(), + expires_at: expires, + auto_renew: false, + active: true, + paid_until: null, + local_ip: config.LOCAL_IPS[0] || "127.0.0.1", + }; + setDomainRecord(finalName, record); + addHostedService(projectName, "Domain", port, `http://127.0.0.1:${port}`); + console.log(` [domains] Auto-registered "${finalName}.dweb" for project "${projectName}"`); + return { name: finalName, auto: true, path: servicePath }; + } + + router.post("/api/publish", async (req, res) => { + const body = await parseBody(req); + const { name, type, files } = body; + if (!name || !files || !Array.isArray(files)) { + return json(res, 400, { error: "Missing name or files array" }); + } + const projectDir = path.resolve(__dirname, "..", "projects", name.replace(/[^a-zA-Z0-9-_]/g, "_")); + if (fs.existsSync(projectDir)) { + fs.rmSync(projectDir, { recursive: true, force: true }); + } + fs.mkdirSync(projectDir, { recursive: true }); + for (const f of files) { + const filePath = path.join(projectDir, f.path); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, f.content, "utf-8"); + } + addHostedService(name, type || "Web App", config.PORT, `http://localhost:${config.PORT}/project/${name.replace(/[^a-zA-Z0-9-_]/g, "_")}`); + const routeName = name.replace(/[^a-zA-Z0-9-_]/g, "_"); + const projectUrl = `http://localhost:${config.PORT}/project/${routeName}`; + + // Auto-assign a .dweb domain if requested (default: true) + const autoDomain = body.auto_domain !== false; + let domainResult = null; + if (autoDomain) { + domainResult = autoAssignDomain(name, config.PORT); + } + + console.log(` [deploy] Published "${name}" → /project/${routeName}`); + json(res, 201, { + status: "ok", + project: { name, route: `/project/${routeName}`, path: projectDir }, + url: projectUrl, + domain: domainResult ? { + name: domainResult.name + ".dweb", + url: `dweb://${domainResult.name}.dweb`, + auto_registered: domainResult.auto, + } : null, + }); + }); + + router.get(/^\/project\/([a-zA-Z0-9_-]+)(\/.*)?$/, (req, res, match) => { + const projectName = match[1]; + let subPath = match[2] || "/index.html"; + const projectDir = path.resolve(__dirname, "..", "projects", projectName); + if (!fs.existsSync(projectDir)) { + return json(res, 404, { error: "Project not found" }); + } + let filePath = path.join(projectDir, subPath); + if (!filePath.startsWith(projectDir)) return json(res, 403, { error: "Forbidden" }); + if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) { + subPath = path.join(subPath.replace(/\/$/, ""), "index.html"); + filePath = path.join(projectDir, subPath); + } + if (!fs.existsSync(filePath)) { + const indexPath = path.join(projectDir, "index.html"); + if (fs.existsSync(indexPath)) return serveFile(res, indexPath); + return json(res, 404, { error: "File not found" }); + } + serveFile(res, filePath); + }); +} + +module.exports = { registerRoutes }; diff --git a/server/config.cjs b/server/config.cjs new file mode 100644 index 0000000..899f1b9 --- /dev/null +++ b/server/config.cjs @@ -0,0 +1,54 @@ +// ═══════════════════════════════════════════════════════════════════════════════ +// dweb — Configuration & Constants +// ═══════════════════════════════════════════════════════════════════════════════ + +const os = require("os"); +const path = require("path"); +const crypto = require("crypto"); + +function getLocalIPs() { + const ifaces = os.networkInterfaces(), ips = []; + for (const n of Object.keys(ifaces)) + for (const i of ifaces[n]) + if (i.family === "IPv4" && !i.internal) ips.push(i.address); + return ips.length ? ips : ["127.0.0.1"]; +} + +// Ports are tried dynamically — env vars set the preferred start value +let _PORT = parseInt(process.env.PORT, 10) || 49737; +let _RELAY_PORT = parseInt(process.env.RELAY_PORT, 10) || 49736; +let _TCP_RELAY_PORT = parseInt(process.env.TCP_PORT, 10) || 49738; + +const PEER_TTL_MS = parseInt(process.env.PEER_TTL, 10) || 60000; +const DIST_DIR = path.resolve(__dirname, "..", "dist"); +const MODE = (process.env.MODE || "auto").toLowerCase(); +const INSTANCE_NAME = process.env.NAME || os.hostname(); + +const SERVER_ID = crypto.randomUUID().split("-")[0].slice(0, 8); +const START_TIME = Date.now(); +const LOCAL_IPS = getLocalIPs(); +const PEER_ID = `dweb-${os.hostname().toLowerCase().replace(/[^a-z0-9-]/g, "-")}-${SERVER_ID}`; + +const UPSTREAM_RELAY = process.env.UPSTREAM || null; + +const SHARE_DIR = path.resolve(__dirname, "..", "shared-files"); + +// Local discovery +const MULTICAST_ADDR = "239.255.0.100"; +const MULTICAST_PORT = 49739; +const DISCOVERY_DIR = "/tmp/dweb-instances"; + +// Track assigned ports to prevent collisions +const _usedPorts = new Set(); + +module.exports = { + get PORT() { return _PORT; }, + set PORT(v) { _PORT = v; }, + get RELAY_PORT() { return _RELAY_PORT; }, + set RELAY_PORT(v) { _RELAY_PORT = v; }, + get TCP_RELAY_PORT() { return _TCP_RELAY_PORT; }, + set TCP_RELAY_PORT(v) { _TCP_RELAY_PORT = v; }, + PEER_TTL_MS, DIST_DIR, MODE, INSTANCE_NAME, SERVER_ID, START_TIME, + UPSTREAM_RELAY, SHARE_DIR, LOCAL_IPS, PEER_ID, getLocalIPs, + MULTICAST_ADDR, MULTICAST_PORT, DISCOVERY_DIR, _usedPorts, +}; diff --git a/server/discovery.cjs b/server/discovery.cjs new file mode 100644 index 0000000..663f5ec --- /dev/null +++ b/server/discovery.cjs @@ -0,0 +1,212 @@ +// ═══════════════════════════════════════════════════════════════════════════════ +// dweb — Local Discovery (UDP Multicast + File-Based) +// ═══════════════════════════════════════════════════════════════════════════════ + +const os = require("os"); +const fs = require("fs"); +const path = require("path"); +const dgram = require("dgram"); +const config = require("./config.cjs"); +const { PEER_ID, MODE, LOCAL_IPS, MULTICAST_ADDR, MULTICAST_PORT, DISCOVERY_DIR } = config; +const { peers, localPeers, hostedServices } = require("./state.cjs"); +const { httpReq } = require("./helpers.cjs"); + +let discoverySocket = null; +let fileDiscoveryStarted = false; + +// ── UDP Multicast Discovery ──────────────────────────────────────────────────── + +function startLocalDiscovery() { + try { + const sock = dgram.createSocket({ type: "udp4", reuseAddr: true }); + + sock.on("listening", () => { + try { + sock.addMembership(MULTICAST_ADDR); + sock.setBroadcast(true); + sock.setMulticastTTL(2); + } catch (e) { + console.log(` [discovery] Membership error: ${e.message}`); + } + }); + + sock.on("message", (msg, rinfo) => { + try { + const data = JSON.parse(msg.toString()); + if (data.peerId && data.peerId !== PEER_ID) { + const existing = localPeers.get(data.peerId); + localPeers.set(data.peerId, { ...data, lastSeen: Date.now(), address: rinfo.address }); + if (!existing && !peers.has(data.peerId)) { + registerWithDiscoveredPeer(data, rinfo.address).catch(() => {}); + } + } + } catch {} + }); + + sock.on("error", (err) => { + if (err.code !== "EADDRINUSE") { + console.log(` [discovery] Socket error: ${err.message}`); + } + }); + + sock.bind(MULTICAST_PORT, () => { + console.log(` Discovery : udp://${MULTICAST_ADDR}:${MULTICAST_PORT}`); + }); + + // Periodic announcement broadcast (every 5 seconds) + setInterval(() => { + try { + const msg = JSON.stringify({ + peerId: PEER_ID, port: config.PORT, relayPort: config.RELAY_PORT, + tcpPort: config.TCP_RELAY_PORT, hostname: os.hostname(), + platform: process.platform, version: "0.1.0", + mode: MODE, localIPs: LOCAL_IPS, + }); + sock.send(msg, MULTICAST_PORT, MULTICAST_ADDR); + } catch {} + }, 5000); + + discoverySocket = sock; + return sock; + } catch (e) { + console.log(` [discovery] UDP failed: ${e.message}`); + return null; + } +} + +// ── File-Based Local Discovery ───────────────────────────────────────────────── + +function startFileDiscovery() { + try { + if (!fs.existsSync(DISCOVERY_DIR)) fs.mkdirSync(DISCOVERY_DIR, { recursive: true }); + } catch (e) { + console.log(` [discovery] Cannot create ${DISCOVERY_DIR}: ${e.message}`); + return; + } + fileDiscoveryStarted = true; + + const ourFile = path.join(DISCOVERY_DIR, `${PEER_ID}.json`); + function writeOurInfo() { + try { + const info = { + peerId: PEER_ID, port: config.PORT, relayPort: config.RELAY_PORT, + tcpPort: config.TCP_RELAY_PORT, hostname: os.hostname(), + platform: process.platform, version: "0.1.0", + mode: MODE, pid: process.pid, timestamp: Date.now(), + }; + fs.writeFileSync(ourFile, JSON.stringify(info)); + } catch {} + } + writeOurInfo(); + setInterval(writeOurInfo, 10000); + + process.on("exit", () => { + try { fs.unlinkSync(ourFile); } catch {} + }); + + setInterval(() => { + try { + const files = fs.readdirSync(DISCOVERY_DIR); + for (const f of files) { + if (!f.endsWith(".json")) continue; + if (f === `${PEER_ID}.json`) continue; + try { + const data = JSON.parse(fs.readFileSync(path.join(DISCOVERY_DIR, f), "utf-8")); + if (!data.peerId) continue; + if (Date.now() - data.timestamp > 30000) continue; + if (!localPeers.has(data.peerId)) { + localPeers.set(data.peerId, { ...data, lastSeen: data.timestamp, address: "127.0.0.1" }); + if (!peers.has(data.peerId)) { + registerWithDiscoveredPeer(data, "127.0.0.1").catch(() => {}); + } + } else { + localPeers.get(data.peerId).lastSeen = Date.now(); + } + } catch {} + } + for (const f of files) { + if (!f.endsWith(".json")) continue; + if (f === `${PEER_ID}.json`) continue; + try { + const data = JSON.parse(fs.readFileSync(path.join(DISCOVERY_DIR, f), "utf-8")); + if (Date.now() - data.timestamp > 60000) { + fs.unlinkSync(path.join(DISCOVERY_DIR, f)); + } + } catch { + try { fs.unlinkSync(path.join(DISCOVERY_DIR, f)); } catch {} + } + } + } catch {} + }, 5000); + + console.log(` [discovery] File-based discovery in ${DISCOVERY_DIR}`); +} + +// ── Registration with Discovered Peer ────────────────────────────────────────── + +async function registerWithDiscoveredPeer(data, address) { + try { + const res = await httpReq("POST", address, data.port || config.PORT, "/register", { + id: PEER_ID, hostname: os.hostname(), platform: process.platform, + version: "0.1.0", address: LOCAL_IPS[0] || "127.0.0.1", + port: config.PORT, relayPort: config.RELAY_PORT, mode: MODE, + services: hostedServices.map(s => s.name), + }); + if (res?.status === "ok") { + console.log(` [discovery] Registered with local peer ${data.peerId.slice(0, 16)}… at ${address}:${data.port || config.PORT}`); + } + } catch {} +} + +// ── Banner ───────────────────────────────────────────────────────────────────── + +function printBanner() { + const isRelay = MODE === "relay" || MODE === "auto"; + + console.log(); + console.log(` ╔══════════════════════════════════════════════════╗`); + console.log(` ║ dweb — P2P Dev + Hosting Platform ║`); + console.log(` ║ ─────────────────────────────── ║`); + console.log(` ║ Instance : ${String(config.INSTANCE_NAME).padEnd(37)}║`); + console.log(` ║ Peer ID : ${PEER_ID.padEnd(37)}║`); + console.log(` ║ Mode : ${MODE.padEnd(37)}║`); + console.log(` ╚══════════════════════════════════════════════════╝`); + console.log(` ╔══════════════════════════════════════════════════╗`); + if (isRelay) { + console.log(` ║ P2P Relay : http://0.0.0.0:${String(config.RELAY_PORT).padEnd(5)} ║`); + } + console.log(` ║ Web IDE : http://0.0.0.0:${String(config.PORT).padEnd(5)} ║`); + console.log(` ║ TCP Proxy : tcp://0.0.0.0:${String(config.TCP_RELAY_PORT).padEnd(5)} ║`); + console.log(` ║ ║`); + console.log(` ║ Network access: ║`); + for (const ip of LOCAL_IPS) { + console.log(` ║ http://${ip}:${config.PORT}/`.padEnd(52) + `║`); + } + console.log(` ║ ║`); + console.log(` ║ Core APIs: ║`); + console.log(` ║ /ping — Health check ║`); + console.log(` ║ /status — Full instance status ║`); + console.log(` ║ /register — Register a peer (P2P) ║`); + console.log(` ║ /discover — Discover online peers ║`); + console.log(` ║ /signal — WebRTC signaling ║`); + console.log(` ║ /collab/services — Hosted services ║`); + console.log(` ║ /collab/sessions — Shared dev sessions ║`); + console.log(` ║ /dweb-status — Instance status ║`); + console.log(` ║ /api/p2p/receive — P2P file receive ║`); + console.log(` ║ /api/p2p/received — Received P2P files ║`); + console.log(` ║ /api/p2p/discover-local — Local peer discover ║`); + console.log(` ║ /api/tor/status — Tor network status ║`); + console.log(` ║ /api/instance/spawn — Spawn new dweb instance║`); + console.log(` ║ / — dweb Web IDE frontend ║`); + console.log(` ╚══════════════════════════════════════════════════╝`); + console.log(` Upstream relay: ${require("./config.cjs").UPSTREAM_RELAY || "(none — this is a relay node)"}`); + console.log(` Press Ctrl+C to stop.\n`); +} + +function getDiscoverySocket() { return discoverySocket; } +function isFileDiscoveryStarted() { return fileDiscoveryStarted; } + +module.exports = { + startLocalDiscovery, startFileDiscovery, registerWithDiscoveredPeer, + printBanner, getDiscoverySocket, isFileDiscoveryStarted, +}; diff --git a/server/helpers.cjs b/server/helpers.cjs new file mode 100644 index 0000000..a6e1a53 --- /dev/null +++ b/server/helpers.cjs @@ -0,0 +1,122 @@ +// ═══════════════════════════════════════════════════════════════════════════════ +// dweb — Utility Helpers +// ═══════════════════════════════════════════════════════════════════════════════ + +const os = require("os"); +const http = require("http"); +const fs = require("fs"); +const path = require("path"); +const net = require("net"); +const { _usedPorts } = require("./config.cjs"); + +function getLocalIPs() { + const ifaces = os.networkInterfaces(), ips = []; + for (const n of Object.keys(ifaces)) + for (const i of ifaces[n]) + if (i.family === "IPv4" && !i.internal) ips.push(i.address); + return ips.length ? ips : ["127.0.0.1"]; +} + +function peerToJSON(p) { + return { + id: p.id, publicKey: p.publicKey, address: p.address, port: p.port, + hostname: p.hostname, platform: p.platform, version: p.version, + mode: p.mode, services: p.services, relayPort: p.relayPort, + age: Math.floor((Date.now() - p.firstSeen) / 1000), + }; +} + +function json(res, status, data) { + const body = JSON.stringify(data, null, 2); + res.writeHead(status, { + "Content-Type": "application/json; charset=utf-8", + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "Content-Type", + "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", + }); + res.end(body); +} + +function parseBody(req) { + return new Promise((resolve, reject) => { + let body = ""; + req.on("data", c => { body += c; if (body.length > 1e6) req.destroy(); }); + req.on("end", () => { + try { resolve(body ? JSON.parse(body) : {}); } + catch (e) { reject(new Error("Invalid JSON")); } + }); + req.on("error", reject); + }); +} + +function probePort(port) { + return new Promise((resolve) => { + const s = net.createServer(); + s.once("error", () => resolve(false)); + s.once("listening", () => { s.close(); resolve(port); }); + s.listen(port, "0.0.0.0"); + }); +} + +async function findFreePort(envVar, preferred, maxAttempts = 10) { + const envPort = parseInt(process.env[envVar], 10); + const startPort = envPort || preferred; + for (let i = 0; i < maxAttempts; i++) { + const port = startPort + i; + if (_usedPorts.has(port)) continue; + const free = await probePort(port); + if (free) { _usedPorts.add(free); return free; } + } + for (let i = 0; i < 100; i++) { + const free = await probePort(0); + if (free && !_usedPorts.has(free)) { _usedPorts.add(free); return free; } + } + return startPort; +} + +const MIME = { + ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", + ".js": "application/javascript; charset=utf-8", ".json": "application/json; charset=utf-8", + ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", + ".gif": "image/gif", ".svg": "image/svg+xml", ".ico": "image/x-icon", + ".woff": "font/woff", ".woff2": "font/woff2", ".map": "application/json", +}; + +function serveFile(res, filePath) { + const ext = path.extname(filePath).toLowerCase(); + const mime = MIME[ext] || "application/octet-stream"; + const { DIST_DIR } = require("./config.cjs"); + if (!fs.existsSync(filePath)) { + const indexPath = path.join(DIST_DIR, "index.html"); + if (fs.existsSync(indexPath)) return serveFile(res, indexPath); + return json(res, 404, { error: "Not found" }); + } + const data = fs.readFileSync(filePath); + res.writeHead(200, { + "Content-Type": mime, "Access-Control-Allow-Origin": "*", + "Cache-Control": ext === ".html" ? "no-cache" : "max-age=86400", + }); + res.end(data); +} + +function httpReq(method, host, port, pathname, data) { + return new Promise((resolve, reject) => { + const body = data ? JSON.stringify(data) : null; + const opts = { hostname: host, port, path: pathname, method, timeout: 5000 }; + if (body) { opts.headers = { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) }; } + const r = http.request(opts, res => { + let b = ""; + res.on("data", c => b += c); + res.on("end", () => { try { resolve(JSON.parse(b)); } catch { resolve(b); } }); + }); + r.on("error", reject); + r.on("timeout", () => { r.destroy(); reject(new Error("timeout")); }); + if (body) r.write(body); + r.end(); + }); +} + +module.exports = { + getLocalIPs, peerToJSON, json, parseBody, probePort, findFreePort, + MIME, serveFile, httpReq, +}; diff --git a/server/index.cjs b/server/index.cjs new file mode 100644 index 0000000..89ea1f1 --- /dev/null +++ b/server/index.cjs @@ -0,0 +1,219 @@ +// ═══════════════════════════════════════════════════════════════════════════════ +// dweb v0.1.0 — Unified P2P Server + Web Development IDE + Free Hosting Platform +// +// Run: node dweb.cjs +// Env: PORT=49737, RELAY_PORT=49736, MODE=auto, NAME=my-dweb +// +// Every dweb instance is: +// 1. A P2P relay node (helps peers discover & connect) +// 2. A web development server (serves the React IDE + AI agent) +// 3. A free hosting platform (hosts services, domains, APIs) +// +// When two dweb instances connect, they collaborate: +// - Share hosted services between machines +// - Share AI agent sessions +// - Proxy traffic through the P2P network +// +// Zero npm dependencies — pure Node.js stdlib +// ═══════════════════════════════════════════════════════════════════════════════ + +const http = require("http"); +const os = require("os"); + +const config = require("./config.cjs"); +const { MODE, PORT, RELAY_PORT, UPSTREAM_RELAY, PEER_ID, INSTANCE_NAME, SERVER_ID, LOCAL_IPS } = config; +const { findFreePort, httpReq } = require("./helpers.cjs"); +const { peers, hostedServices, sharedSessions, tcpRelays, + addHostedService, cleanupStalePeers, setRelayConnected, setRelayError } = require("./state.cjs"); +const { createRouter } = require("./router.cjs"); +const { startTCPRelay } = require("./relay-tcp.cjs"); +const { startLocalDiscovery, startFileDiscovery, printBanner, getDiscoverySocket } = require("./discovery.cjs"); +const { restoreServices } = require("./api-services.cjs"); +const { restoreDomains, restorePeers } = require("./state.cjs"); + +// ── Main ─────────────────────────────────────────────────────────────────────── + +const isRelayMode = MODE === "relay" || MODE === "auto"; +const isPeerMode = MODE === "peer" || MODE === "auto"; + +// Build the router +let relayConnected = false; +let relayError = null; + +// Register with upstream relay +async function registerWithUpstream() { + if (!UPSTREAM_RELAY) return; + const [host, portStr] = UPSTREAM_RELAY.split(":"); + const port = parseInt(portStr, 10) || RELAY_PORT; + try { + const res = await httpReq("POST", host, port, "/register", { + id: PEER_ID, hostname: os.hostname(), platform: process.platform, + version: "0.1.0", address: "127.0.0.1", + port: PORT, relayPort: RELAY_PORT, + mode: "p2p-visible", services: hostedServices.map(s => s.name), + }); + relayConnected = res?.status === "ok"; + relayError = relayConnected ? null : "registration failed"; + setRelayConnected(relayConnected); + setRelayError(relayError); + if (relayConnected) console.log(` [upstream] Registered with ${UPSTREAM_RELAY}`); + } catch (e) { + relayConnected = false; + relayError = e.message; + setRelayConnected(false); + setRelayError(e.message); + console.log(` [upstream] Cannot reach ${UPSTREAM_RELAY}`); + } +} + +async function heartbeatUpstream() { + if (!UPSTREAM_RELAY || !relayConnected) return; + const [host, portStr] = UPSTREAM_RELAY.split(":"); + try { await httpReq("POST", host, parseInt(portStr, 10) || RELAY_PORT, "/heartbeat", { peerId: PEER_ID }); } + catch { relayConnected = false; setRelayConnected(false); } +} + +async function main() { + // Resolve ports dynamically + config.PORT = await findFreePort("PORT", 49737); + config.RELAY_PORT = await findFreePort("RELAY_PORT", 49736); + config.TCP_RELAY_PORT = await findFreePort("TCP_PORT", 49738); + + const { PORT, RELAY_PORT, TCP_RELAY_PORT } = config; + console.log(` Ports: Web=${PORT}, Relay=${RELAY_PORT}, TCP=${TCP_RELAY_PORT}`); + + // Create HTTP server with the router + const router = createRouter(); + const server = http.createServer((req, res) => router.dispatch(req, res).catch(e => { + if (!res.headersSent) { + const { json } = require("./helpers.cjs"); + json(res, 500, { error: e.message }); + } + })); + + let webServer = null; + + function startServer() { + if (isRelayMode) { + server.listen(RELAY_PORT, "0.0.0.0", () => { + console.log(` P2P Relay : http://0.0.0.0:${RELAY_PORT}`); + }); + webServer = http.createServer((req, res) => router.dispatch(req, res).catch(e => { + if (!res.headersSent) { + const { json } = require("./helpers.cjs"); + json(res, 500, { error: e.message }); + } + })); + webServer.listen(PORT, "0.0.0.0", () => {}); + return webServer; + } else { + server.listen(PORT, "0.0.0.0", () => {}); + webServer = server; + return server; + } + } + + // Start all server components + startTCPRelay(); + startLocalDiscovery(); + startFileDiscovery(); + startServer(); + + // Auto-register default services + addHostedService("My Static Website", "Static Site", PORT, `http://localhost:${PORT}/welcome`); + addHostedService("File Share", "File Browser", PORT, `http://localhost:${PORT}/fileshare`); + console.log(` [services] Auto-registered: "My Static Website" → /welcome`); + console.log(` [services] Auto-registered: "File Share" → /fileshare`); + + // Auto-start managed services so they appear in /api/services + try { + const { registerRoutes: svcRoutes, runningServices } = require("./api-services.cjs"); + if (!runningServices.has("My Static Website")) { + const resp = await fetch(`http://localhost:${PORT}/api/service/start`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "My Static Website", type: "Static Site", port: 30999 }), + }); + if (resp.ok) console.log(` [services] Auto-started: "My Static Website" on port 30999`); + } + if (!runningServices.has("File Share")) { + const resp = await fetch(`http://localhost:${PORT}/api/service/start`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "File Share", type: "File Browser", port: 30998 }), + }); + if (resp.ok) console.log(` [services] Auto-started: "File Share" on port 30998`); + } + } catch (e) { + console.log(` [services] Auto-start failed: ${e.message}`); + } + + printBanner(); + + // Restore managed services from disk + restoreServices(); + + // Restore domain registry from disk + restoreDomains(); + + // Restore peer registry from disk (survives restarts) + restorePeers(); + + // Auto-register this instance as a peer so it always appears in P2P discovery + require("./state.cjs").peers.set(PEER_ID, new (require("./state.cjs").PeerRecord)(PEER_ID, { + address: LOCAL_IPS[0] || "127.0.0.1", + port: PORT, + relayPort: RELAY_PORT, + hostname: os.hostname(), + platform: process.platform, + version: "0.1.0", + mode: "p2p-visible", + services: hostedServices.map(s => s.name), + })); + require("./state.cjs").savePeers(); + + // Register with upstream relay + if (UPSTREAM_RELAY && isPeerMode) { + await registerWithUpstream(); + setInterval(heartbeatUpstream, 30000); + } + + // Periodic peer cleanup + setInterval(cleanupStalePeers, 15000); + + // Status line + setInterval(() => { + const line = ` [${new Date().toLocaleTimeString()}] Peers: ${peers.size} | Services: ${hostedServices.length} | Sessions: ${sharedSessions.length}`; + process.stdout.write(`\x1b[2K\x1b[1A\x1b[2K${line}\n`); + }, 3000); + + // Graceful shutdown + process.on("SIGINT", () => { + console.log("\n\n Shutting down dweb..."); + console.log(` Final state: ${peers.size} peers, ${hostedServices.length} services`); + tcpRelays.forEach(s => s.end()); + const sock = getDiscoverySocket(); + if (sock) { try { sock.close(); } catch {} } + server.close(); + if (webServer && webServer !== server) webServer.close(); + process.exit(0); + }); + + process.on("SIGTERM", () => process.exit(0)); +} + +main().catch(err => { + console.error(` [fatal] Failed to start dweb: ${err.message}`); + process.exit(1); +}); + +// Export key info for programmatic use +module.exports = { + PEER_ID, SERVER_ID, + get PORT() { return config.PORT; }, + get RELAY_PORT() { return config.RELAY_PORT; }, + get TCP_RELAY_PORT() { return config.TCP_RELAY_PORT; }, + peers: require("./state.cjs").peers, + hostedServices: require("./state.cjs").hostedServices, + sharedSessions: require("./state.cjs").sharedSessions, +}; diff --git a/server/opencode-worker.cjs b/server/opencode-worker.cjs new file mode 100644 index 0000000..09f51dd --- /dev/null +++ b/server/opencode-worker.cjs @@ -0,0 +1,258 @@ +// ═══════════════════════════════════════════════════════════════════════════════ +// dweb — Opencode Session Worker +// Manages long-running opencode processes via spawn with: +// - Persistent sessions (survive client tab switches) +// - Real-time output streaming via SSE +// - ANSI code stripping +// - `--dangerously-skip-permissions` for headless mode +// ═══════════════════════════════════════════════════════════════════════════════ + +const { spawn } = require("child_process"); +const config = require("./config.cjs"); +const { INSTANCE_NAME } = config; + +/* ─── ANSI stripping ────────────────────────────────────── */ +// Strips ANSI escape codes (colors, cursor, erase, OSC sequences) +function stripAnsi(str) { + // eslint-disable-next-line no-control-regex + return str + .replace(/\x1B\[[0-9;]*[a-zA-Z]/g, "") // CSI sequences: \x1b[0m, \x1b[1;32m + .replace(/\x1B\][^\x1B]*(\x1B\\)?/g, "") // OSC sequences: \x1b]0;title\x1b\ + .replace(/\x1B[PX^_].*?\x1B\\/gs, "") // Other escape sequences + .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "") // Control chars (keep \t \n \r) + .trim(); +} + +/* ─── Session store ─────────────────────────────────────── */ +const sessions = new Map(); // sessionId -> Session + +let sessionCounter = 0; + +function genSessionId() { + return `oc-${Date.now().toString(36)}-${(++sessionCounter).toString(36)}`; +} + +/* ─── Session class ─────────────────────────────────────── */ +class OpenCodeSession { + constructor(id, command, model, provider) { + this.id = id; + this.command = command; + this.model = model; + this.provider = provider; + this.output = ""; + this.running = true; + this.startedAt = Date.now(); + this.finishedAt = null; + this.exitCode = null; + this.error = null; + this.process = null; + this.sseClients = new Set(); // res objects for SSE streaming + this._outputListeners = []; + } + + // Add output chunk + notify all SSE clients + _emitOutput(text) { + const clean = stripAnsi(text); + if (!clean) return; + this.output += clean + "\n"; + const payload = JSON.stringify({ type: "output", text: clean, timestamp: Date.now() }); + for (const client of this.sseClients) { + try { client.write(`event: output\ndata: ${payload}\n\n`); } catch {} + } + } + + _emitDone() { + const status = this.exitCode === 0 ? "ok" : "error"; + const payload = JSON.stringify({ + type: "done", + status, + exitCode: this.exitCode, + duration: Date.now() - this.startedAt, + output: this.output, + finishedAt: this.finishedAt, + error: this.error, + }); + for (const client of this.sseClients) { + try { client.write(`event: done\ndata: ${payload}\n\n`); } catch {} + } + this.sseClients.clear(); + } + + _emitError(error) { + this.error = error; + const payload = JSON.stringify({ type: "error", error, timestamp: Date.now() }); + for (const client of this.sseClients) { + try { client.write(`event: error\ndata: ${payload}\n\n`); } catch {} + } + } + + // Start the opencode process + start() { + const serverContext = `You are inside dweb (http://localhost:${config.PORT}/). Tech: React+Vite+TS frontend, Node.js backend. Repo: ${__dirname}/../. Build: npm run build. Test: npm test.`; + const fullCommand = `${serverContext}\n\nUser: ${this.command}`; + + const args = [ + "run", + "-m", this.model, + "--dangerously-skip-permissions", + fullCommand, + ]; + + const env = { ...process.env }; + if (this.provider === "ollama") { + env.OPENAI_BASE_URL = "http://127.0.0.1:11434/v1"; + env.OPENAI_API_KEY = "ollama"; + } + + const child = spawn("opencode", args, { + env, + cwd: process.cwd(), + stdio: ["ignore", "pipe", "pipe"], + // Allow large output + maxBuffer: 10 * 1024 * 1024, + }); + + this.process = child; + + child.stdout.on("data", (data) => { + this._emitOutput(data.toString()); + }); + + child.stderr.on("data", (data) => { + // opencode outputs progress to stderr too + this._emitOutput(data.toString()); + }); + + child.on("error", (err) => { + this._emitError(err.message); + this.running = false; + this.finishedAt = Date.now(); + this.exitCode = -1; + this._emitDone(); + }); + + child.on("close", (code) => { + this.running = false; + this.finishedAt = Date.now(); + this.exitCode = code; + if (code !== 0 && !this.error) { + this.error = `Process exited with code ${code}`; + } + this._emitDone(); + }); + } + + // Cancel the running process + cancel() { + if (this.process && this.running) { + try { this.process.kill("SIGTERM"); } catch {} + setTimeout(() => { + if (this.process && this.running) { + try { this.process.kill("SIGKILL"); } catch {} + } + }, 3000); + this.running = false; + this.finishedAt = Date.now(); + this.exitCode = -1; + this.error = "Cancelled by user"; + this._emitError("Cancelled by user"); + this._emitDone(); + } + } + + // Register an SSE client + addSSEClient(res) { + this.sseClients.add(res); + // Send current state immediately + if (!this.running) { + // Session already finished — send output + done + if (this.output) { + const payload = JSON.stringify({ type: "output", text: this.output, timestamp: Date.now() }); + try { res.write(`event: output\ndata: ${payload}\n\n`); } catch {} + } + const status = this.exitCode === 0 ? "ok" : "error"; + const payload = JSON.stringify({ + type: "done", + status, + exitCode: this.exitCode, + duration: Date.now() - this.startedAt, + output: this.output, + finishedAt: this.finishedAt, + error: this.error, + }); + try { res.write(`event: done\ndata: ${payload}\n\n`); } catch {} + // Clean up + this.sseClients.delete(res); + } + // If running — client will receive future events + } + + // Remove an SSE client + removeSSEClient(res) { + this.sseClients.delete(res); + } + + // Get serializable snapshot + toJSON() { + return { + id: this.id, + command: this.command, + model: this.model, + provider: this.provider, + running: this.running, + output: this.output, + startedAt: this.startedAt, + finishedAt: this.finishedAt, + exitCode: this.exitCode, + error: this.error, + duration: this.finishedAt ? this.finishedAt - this.startedAt : null, + }; + } +} + +/* ─── Public API ────────────────────────────────────────── */ + +// Start a new opencode session +function createSession(command, model, provider) { + const id = genSessionId(); + const session = new OpenCodeSession(id, command, model, provider || "cloud"); + sessions.set(id, session); + // Defer start so the caller can set up SSE first + setImmediate(() => session.start()); + return session; +} + +// Get session by ID +function getSession(id) { + return sessions.get(id) || null; +} + +// Cancel a session +function cancelSession(id) { + const session = sessions.get(id); + if (!session) return false; + session.cancel(); + return true; +} + +// Clean up stale sessions (older than 1 hour) +function cleanupStaleSessions() { + const now = Date.now(); + const maxAge = 60 * 60 * 1000; // 1 hour + for (const [id, session] of sessions) { + if (session.finishedAt && (now - session.finishedAt) > maxAge) { + sessions.delete(id); + } + } +} + +// Periodic cleanup +setInterval(cleanupStaleSessions, 15 * 60 * 1000); + +module.exports = { + createSession, + getSession, + cancelSession, + sessions, + stripAnsi, +}; diff --git a/server/relay-tcp.cjs b/server/relay-tcp.cjs new file mode 100644 index 0000000..79a6bf3 --- /dev/null +++ b/server/relay-tcp.cjs @@ -0,0 +1,63 @@ +// ═══════════════════════════════════════════════════════════════════════════════ +// dweb — TCP Relay Server (for proxying traffic between peers) +// ═══════════════════════════════════════════════════════════════════════════════ + +const net = require("net"); +const config = require("./config.cjs"); +const { tcpRelays, storeSignal } = require("./state.cjs"); + +function startTCPRelay() { + const TCP_RELAY_PORT = config.TCP_RELAY_PORT; + const server = net.createServer(socket => { + let peerId = null, buffer = ""; + socket.on("data", data => { + buffer += data.toString(); + if (!peerId) { + const nl = buffer.indexOf("\n"); + if (nl === -1) return; + try { + const msg = JSON.parse(buffer.slice(0, nl)); + buffer = buffer.slice(nl + 1); + if (msg.type === "register" && msg.peerId) { + peerId = msg.peerId; + tcpRelays.set(peerId, socket); + socket.write(JSON.stringify({ type: "registered", peerId }) + "\n"); + if (buffer.length > 0) { forwardRelayData(peerId, buffer); buffer = ""; } + } + } catch {} + return; + } + try { + const msg = JSON.parse(buffer); + if (msg.type === "relay" && msg.targetPeerId) { + const target = tcpRelays.get(msg.targetPeerId); + if (target) target.write(JSON.stringify({ type: "relay", fromPeerId: peerId, data: msg.data }) + "\n"); + storeSignal(msg.targetPeerId, { fromPeerId: peerId, type: "relay-data", data: msg.data }); + } + } catch {} + buffer = ""; + }); + socket.on("close", () => { if (peerId) tcpRelays.delete(peerId); }); + socket.on("error", () => {}); + }); + server.on("error", err => { + if (err.code === "EADDRINUSE") console.log(` [tcp] Port ${TCP_RELAY_PORT} in use`); + else console.log(` [tcp] Error: ${err.message}`); + }); + server.listen(TCP_RELAY_PORT, "0.0.0.0", () => { + console.log(` TCP Relay : tcp://0.0.0.0:${TCP_RELAY_PORT}`); + }); + return server; +} + +function forwardRelayData(fromPeerId, data) { + try { + const msg = JSON.parse(data); + if (msg.targetPeerId) { + const target = tcpRelays.get(msg.targetPeerId); + if (target) target.write(JSON.stringify({ type: "relay", fromPeerId, data: msg.data }) + "\n"); + } + } catch {} +} + +module.exports = { startTCPRelay, forwardRelayData }; diff --git a/server/router.cjs b/server/router.cjs new file mode 100644 index 0000000..4fac5c5 --- /dev/null +++ b/server/router.cjs @@ -0,0 +1,77 @@ +// ═══════════════════════════════════════════════════════════════════════════════ +// dweb — Simple HTTP Router + Main Request Handler +// ═══════════════════════════════════════════════════════════════════════════════ + +const path = require("path"); +const { DIST_DIR } = require("./config.cjs"); +const { json, serveFile } = require("./helpers.cjs"); +const { registerRoutes: registerRelayRoutes } = require("./api-relay.cjs"); +const { registerRoutes: registerCollabRoutes } = require("./api-collab.cjs"); +const { registerRoutes: registerFileshareRoutes } = require("./api-fileshare.cjs"); +const { registerRoutes: registerOpencodeRoutes } = require("./api-opencode.cjs"); +const { registerRoutes: registerSystemRoutes } = require("./api-system.cjs"); +const { registerRoutes: registerOllamaRoutes } = require("./api-ollama.cjs"); +const { registerRoutes: registerServiceRoutes } = require("./api-services.cjs"); +const { registerRoutes: registerDomainRoutes } = require("./api-domain.cjs"); + +// ─── Lightweight Router ──────────────────────────────────────────────────────── + +class Router { + constructor() { + this.routes = []; // { method, pattern, handler } + } + + get(pattern, handler) { this.routes.push({ method: "GET", pattern, handler }); } + post(pattern, handler) { this.routes.push({ method: "POST", pattern, handler }); } + delete(pattern, handler) { this.routes.push({ method: "DELETE", pattern, handler }); } + + async dispatch(req, res) { + const url = new URL(req.url, `http://${req.headers.host || "localhost"}`); + const method = req.method; + req.url = url; // Attach parsed URL for route handlers + + // CORS preflight + if (method === "OPTIONS") { + res.writeHead(204, { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + }); + return res.end(); + } + + for (const route of this.routes) { + let match = null; + if (typeof route.pattern === "string") { + if (url.pathname !== route.pattern) continue; + } else if (route.pattern instanceof RegExp) { + match = url.pathname.match(route.pattern); + if (!match) continue; + } + if (route.method !== method) continue; + return await route.handler(req, res, match); + } + + // No route matched — serve static files + let filePath = path.join(DIST_DIR, url.pathname === "/" ? "index.html" : url.pathname); + if (!filePath.startsWith(DIST_DIR)) return json(res, 403, { error: "Forbidden" }); + serveFile(res, filePath); + } +} + +// ─── Build Router ────────────────────────────────────────────────────────────── + +function createRouter() { + const router = new Router(); + registerRelayRoutes(router); + registerCollabRoutes(router); + registerFileshareRoutes(router); + registerOpencodeRoutes(router); + registerSystemRoutes(router); + registerOllamaRoutes(router); + registerServiceRoutes(router); + registerDomainRoutes(router); + return router; +} + +module.exports = { createRouter, Router }; diff --git a/server/state.cjs b/server/state.cjs new file mode 100644 index 0000000..76bb414 --- /dev/null +++ b/server/state.cjs @@ -0,0 +1,220 @@ +// ═══════════════════════════════════════════════════════════════════════════════ +// dweb — PeerRecord, State Maps, Domain Registry & Signal Store +// ═══════════════════════════════════════════════════════════════════════════════ + +const fs = require("fs"); +const path = require("path"); +const os = require("os"); +const { PEER_TTL_MS } = require("./config.cjs"); +const { peerToJSON } = require("./helpers.cjs"); + +// ─── State Maps ──────────────────────────────────────────────────────────────── + +const peers = new Map(); +const signals = new Map(); +const hostedServices = []; +const sharedSessions = []; +const peerServices = new Map(); +const tcpRelays = new Map(); +const localPeers = new Map(); + +// Relay connection state +let relayConnected = false; +let relayError = null; + +// ─── PeerRecord ─────────────────────────────────────────────────────────────── + +class PeerRecord { + constructor(id, info = {}) { + this.id = id; + this.publicKey = info.publicKey || id; + this.address = info.address || "0.0.0.0"; + this.port = info.port || 0; + this.hostname = info.hostname || ""; + this.platform = info.platform || process.platform; + this.version = info.version || "0.1.0"; + this.mode = info.mode || "p2p-visible"; + this.services = info.services || []; + this.relayPort = info.relayPort || 0; + this.firstSeen = Date.now(); + this.lastSeen = Date.now(); + } + get isStale() { return (Date.now() - this.lastSeen) > PEER_TTL_MS; } + touch() { this.lastSeen = Date.now(); } +} + +// ─── Signal Store ────────────────────────────────────────────────────────────── + +function storeSignal(targetId, signal) { + if (!signals.has(targetId)) signals.set(targetId, []); + const list = signals.get(targetId); + list.push({ ...signal, ts: Date.now() }); + if (list.length > 100) list.splice(0, list.length - 100); +} + +function popSignals(targetId) { + const list = signals.get(targetId) || []; + signals.delete(targetId); + return list; +} + +// ─── Collaboration Helpers ───────────────────────────────────────────────────── + +function addHostedService(name, type, port, url) { + const existing = hostedServices.findIndex(s => s.name === name); + const svc = { name, type, port, url: url || `http://127.0.0.1:${port}`, added: Date.now() }; + if (existing >= 0) hostedServices[existing] = svc; + else hostedServices.push(svc); + return svc; +} + +function removeHostedService(name) { + const idx = hostedServices.findIndex(s => s.name === name); + if (idx >= 0) { + const removed = hostedServices.splice(idx, 1)[0]; + console.log(` [state] Removed hosted service: "${name}"`); + return removed; + } + return null; +} + +function shareSession(sessionId, type, title, data) { + const existing = sharedSessions.findIndex(s => s.id === sessionId); + const session = { id: sessionId, type, title, data, peerId: "", shared: Date.now() }; + if (existing >= 0) sharedSessions[existing] = session; + else sharedSessions.push(session); + if (sharedSessions.length > 50) sharedSessions.splice(0, sharedSessions.length - 50); + return session; +} + +// ─── Peer Persistence (survives server restart) ──────────────────────────────── + +const PEERS_FILE = path.join(os.tmpdir(), "dweb-peers.json"); + +function savePeers() { + try { + const data = []; + for (const [id, peer] of peers) { + data.push({ id, ...peerToJSON(peer) }); + } + fs.writeFileSync(PEERS_FILE, JSON.stringify(data, null, 2)); + } catch (e) { + console.log(" [state] Failed to save peers:", e.message); + } +} + +function restorePeers() { + try { + if (!fs.existsSync(PEERS_FILE)) return 0; + const data = JSON.parse(fs.readFileSync(PEERS_FILE, "utf8")); + if (!Array.isArray(data)) return 0; + let restored = 0; + for (const entry of data) { + if (!entry.id || peers.has(entry.id)) continue; + peers.set(entry.id, new PeerRecord(entry.id, entry)); + restored++; + } + if (restored > 0) console.log(` [state] Restored ${restored} peers from disk`); + return restored; + } catch (e) { + console.log(" [state] Failed to restore peers:", e.message); + return 0; + } +} + +// ─── Tor State ───────────────────────────────────────────────────────────────── + +let torEnabled = false; +const TOR_PROXY = "socks5://127.0.0.1:9050"; + +function setTorEnabled(v) { torEnabled = v; } +function isTorEnabled() { return torEnabled; } +function getTorProxy() { return TOR_PROXY; } + +// ─── Cleanup ─────────────────────────────────────────────────────────────────── + +function cleanupStalePeers() { + let removed = 0; + for (const [id, peer] of peers) { + if (peer.isStale) { peers.delete(id); signals.delete(id); removed++; } + } + if (removed > 0) savePeers(); // persist after cleanup +} + +// ─── Domain Registry ────────────────────────────────────────────────────────── + +const domainRegistry = new Map(); // name -> DomainRecord +const DOMAINS_FILE = path.join(os.tmpdir(), "dweb-domains.json"); + +function saveDomains() { + try { + const data = []; + for (const [name, rec] of domainRegistry) { + data.push({ ...rec, name }); + } + fs.writeFileSync(DOMAINS_FILE, JSON.stringify(data, null, 2)); + } catch (e) { + console.log(" [domains] Failed to save domains:", e.message); + } +} + +function restoreDomains() { + try { + if (!fs.existsSync(DOMAINS_FILE)) return 0; + const data = JSON.parse(fs.readFileSync(DOMAINS_FILE, "utf8")); + if (!Array.isArray(data)) return 0; + let restored = 0; + for (const entry of data) { + const name = entry.name; + if (!name || domainRegistry.has(name)) continue; + const rec = { ...entry }; + delete rec.name; + domainRegistry.set(name, rec); + restored++; + } + if (restored > 0) console.log(" [domains] Restored " + restored + " domains from disk"); + return restored; + } catch (e) { + console.log(" [domains] Failed to restore domains:", e.message); + return 0; + } +} + +function getDomainRecord(name) { + return domainRegistry.get(name) || null; +} + +function setDomainRecord(name, record) { + domainRegistry.set(name, record); + saveDomains(); + return record; +} + +function deleteDomainRecord(name) { + const existed = domainRegistry.delete(name); + if (existed) saveDomains(); + return existed; +} + +function listDomainRecords() { + const result = []; + for (const [name, rec] of domainRegistry) { + result.push({ ...rec, name }); + } + return result; +} + +module.exports = { + peers, signals, hostedServices, sharedSessions, peerServices, tcpRelays, localPeers, + relayConnected, relayError, + PeerRecord, storeSignal, popSignals, addHostedService, removeHostedService, shareSession, cleanupStalePeers, + setRelayConnected(v) { relayConnected = v; }, + setRelayError(v) { relayError = v; }, + // Peer persistence + savePeers, restorePeers, + // Domain registry + domainRegistry, getDomainRecord, setDomainRecord, deleteDomainRecord, listDomainRecords, + restoreDomains, saveDomains, + // Tor state + setTorEnabled, isTorEnabled, getTorProxy, +}; diff --git a/src-tauri/src/ai.rs b/src-tauri/src/ai.rs index b07c5b7..653ea76 100644 --- a/src-tauri/src/ai.rs +++ b/src-tauri/src/ai.rs @@ -126,50 +126,210 @@ pub fn default_providers() -> Vec { pub fn default_models_for_provider(provider: &str) -> Vec { match provider { "ollama" => vec![ - ModelInfo { id: "qwen2.5-coder:7b".into(), name: "Qwen 2.5 Coder 7B".into(), provider: "ollama".into(), description: "🔋 Balanced — best local code model".into() }, - ModelInfo { id: "qwen2.5-coder:1.5b".into(), name: "Qwen 2.5 Coder 1.5B".into(), provider: "ollama".into(), description: "⚡ Fast — lightweight code model".into() }, - ModelInfo { id: "codellama:7b".into(), name: "Code Llama 7B".into(), provider: "ollama".into(), description: "🔋 Balanced — Meta's code model".into() }, - ModelInfo { id: "deepseek-coder:6.7b".into(), name: "DeepSeek Coder 6.7B".into(), provider: "ollama".into(), description: "🔋 Balanced — strong code".into() }, - ModelInfo { id: "mistral:7b".into(), name: "Mistral 7B".into(), provider: "ollama".into(), description: "🔋 Balanced — general purpose".into() }, - ModelInfo { id: "phi3:mini".into(), name: "Phi-3 Mini 3.8B".into(), provider: "ollama".into(), description: "⚡ Fast — small but capable".into() }, - ModelInfo { id: "llama3.2:3b".into(), name: "Llama 3.2 3B".into(), provider: "ollama".into(), description: "⚡ Fast — latest Meta small model".into() }, + ModelInfo { + id: "qwen2.5-coder:7b".into(), + name: "Qwen 2.5 Coder 7B".into(), + provider: "ollama".into(), + description: "🔋 Balanced — best local code model".into(), + }, + ModelInfo { + id: "qwen2.5-coder:1.5b".into(), + name: "Qwen 2.5 Coder 1.5B".into(), + provider: "ollama".into(), + description: "⚡ Fast — lightweight code model".into(), + }, + ModelInfo { + id: "codellama:7b".into(), + name: "Code Llama 7B".into(), + provider: "ollama".into(), + description: "🔋 Balanced — Meta's code model".into(), + }, + ModelInfo { + id: "deepseek-coder:6.7b".into(), + name: "DeepSeek Coder 6.7B".into(), + provider: "ollama".into(), + description: "🔋 Balanced — strong code".into(), + }, + ModelInfo { + id: "mistral:7b".into(), + name: "Mistral 7B".into(), + provider: "ollama".into(), + description: "🔋 Balanced — general purpose".into(), + }, + ModelInfo { + id: "phi3:mini".into(), + name: "Phi-3 Mini 3.8B".into(), + provider: "ollama".into(), + description: "⚡ Fast — small but capable".into(), + }, + ModelInfo { + id: "llama3.2:3b".into(), + name: "Llama 3.2 3B".into(), + provider: "ollama".into(), + description: "⚡ Fast — latest Meta small model".into(), + }, ], "openai" => vec![ - ModelInfo { id: "gpt-4o".into(), name: "GPT-4o".into(), provider: "openai".into(), description: "🚀 Powerful — flagship model".into() }, - ModelInfo { id: "gpt-4o-mini".into(), name: "GPT-4o Mini".into(), provider: "openai".into(), description: "⚡ Fast — cheaper, capable".into() }, - ModelInfo { id: "gpt-4-turbo".into(), name: "GPT-4 Turbo".into(), provider: "openai".into(), description: "🚀 Powerful — previous gen".into() }, - ModelInfo { id: "gpt-3.5-turbo".into(), name: "GPT-3.5 Turbo".into(), provider: "openai".into(), description: "⚡ Fast — economical".into() }, - ModelInfo { id: "o3-mini".into(), name: "o3-mini".into(), provider: "openai".into(), description: "⚡ Fast — reasoning, low cost".into() }, + ModelInfo { + id: "gpt-4o".into(), + name: "GPT-4o".into(), + provider: "openai".into(), + description: "🚀 Powerful — flagship model".into(), + }, + ModelInfo { + id: "gpt-4o-mini".into(), + name: "GPT-4o Mini".into(), + provider: "openai".into(), + description: "⚡ Fast — cheaper, capable".into(), + }, + ModelInfo { + id: "gpt-4-turbo".into(), + name: "GPT-4 Turbo".into(), + provider: "openai".into(), + description: "🚀 Powerful — previous gen".into(), + }, + ModelInfo { + id: "gpt-3.5-turbo".into(), + name: "GPT-3.5 Turbo".into(), + provider: "openai".into(), + description: "⚡ Fast — economical".into(), + }, + ModelInfo { + id: "o3-mini".into(), + name: "o3-mini".into(), + provider: "openai".into(), + description: "⚡ Fast — reasoning, low cost".into(), + }, ], "anthropic" => vec![ - ModelInfo { id: "claude-sonnet-4-20250514".into(), name: "Claude Sonnet 4".into(), provider: "anthropic".into(), description: "🚀 Powerful — latest balanced".into() }, - ModelInfo { id: "claude-3-5-sonnet-latest".into(), name: "Claude 3.5 Sonnet".into(), provider: "anthropic".into(), description: "🚀 Powerful — previous gen".into() }, - ModelInfo { id: "claude-3-opus-latest".into(), name: "Claude 3 Opus".into(), provider: "anthropic".into(), description: "🚀 Powerful — most capable".into() }, - ModelInfo { id: "claude-3-haiku-latest".into(), name: "Claude 3 Haiku".into(), provider: "anthropic".into(), description: "⚡ Fast — fastest Claude".into() }, + ModelInfo { + id: "claude-sonnet-4-20250514".into(), + name: "Claude Sonnet 4".into(), + provider: "anthropic".into(), + description: "🚀 Powerful — latest balanced".into(), + }, + ModelInfo { + id: "claude-3-5-sonnet-latest".into(), + name: "Claude 3.5 Sonnet".into(), + provider: "anthropic".into(), + description: "🚀 Powerful — previous gen".into(), + }, + ModelInfo { + id: "claude-3-opus-latest".into(), + name: "Claude 3 Opus".into(), + provider: "anthropic".into(), + description: "🚀 Powerful — most capable".into(), + }, + ModelInfo { + id: "claude-3-haiku-latest".into(), + name: "Claude 3 Haiku".into(), + provider: "anthropic".into(), + description: "⚡ Fast — fastest Claude".into(), + }, ], "google" => vec![ - ModelInfo { id: "gemini-2.0-flash".into(), name: "Gemini 2.0 Flash".into(), provider: "google".into(), description: "⚡ Fast — efficient".into() }, - ModelInfo { id: "gemini-1.5-pro".into(), name: "Gemini 1.5 Pro".into(), provider: "google".into(), description: "🚀 Powerful — most capable".into() }, - ModelInfo { id: "gemini-1.5-flash".into(), name: "Gemini 1.5 Flash".into(), provider: "google".into(), description: "⚡ Fast — cost-effective".into() }, + ModelInfo { + id: "gemini-2.0-flash".into(), + name: "Gemini 2.0 Flash".into(), + provider: "google".into(), + description: "⚡ Fast — efficient".into(), + }, + ModelInfo { + id: "gemini-1.5-pro".into(), + name: "Gemini 1.5 Pro".into(), + provider: "google".into(), + description: "🚀 Powerful — most capable".into(), + }, + ModelInfo { + id: "gemini-1.5-flash".into(), + name: "Gemini 1.5 Flash".into(), + provider: "google".into(), + description: "⚡ Fast — cost-effective".into(), + }, ], "together" => vec![ - ModelInfo { id: "Qwen/Qwen2.5-Coder-32B-Instruct".into(), name: "Qwen 2.5 Coder 32B".into(), provider: "together".into(), description: "🚀 Powerful — top-tier code".into() }, - ModelInfo { id: "meta-llama/Llama-3.3-70B-Instruct-Turbo".into(), name: "Llama 3.3 70B".into(), provider: "together".into(), description: "🚀 Powerful — Meta large".into() }, - ModelInfo { id: "mistralai/Mixtral-8x22B-Instruct-v0.1".into(), name: "Mixtral 8x22B".into(), provider: "together".into(), description: "🚀 Powerful — Mistral MoE".into() }, - ModelInfo { id: "codellama/CodeLlama-34b-Instruct-hf".into(), name: "Code Llama 34B".into(), provider: "together".into(), description: "🚀 Powerful — code spec".into() }, + ModelInfo { + id: "Qwen/Qwen2.5-Coder-32B-Instruct".into(), + name: "Qwen 2.5 Coder 32B".into(), + provider: "together".into(), + description: "🚀 Powerful — top-tier code".into(), + }, + ModelInfo { + id: "meta-llama/Llama-3.3-70B-Instruct-Turbo".into(), + name: "Llama 3.3 70B".into(), + provider: "together".into(), + description: "🚀 Powerful — Meta large".into(), + }, + ModelInfo { + id: "mistralai/Mixtral-8x22B-Instruct-v0.1".into(), + name: "Mixtral 8x22B".into(), + provider: "together".into(), + description: "🚀 Powerful — Mistral MoE".into(), + }, + ModelInfo { + id: "codellama/CodeLlama-34b-Instruct-hf".into(), + name: "Code Llama 34B".into(), + provider: "together".into(), + description: "🚀 Powerful — code spec".into(), + }, ], "groq" => vec![ - ModelInfo { id: "llama-3.3-70b-versatile".into(), name: "Llama 3.3 70B".into(), provider: "groq".into(), description: "🚀 Powerful — fast on Groq".into() }, - ModelInfo { id: "llama-3.1-8b-instant".into(), name: "Llama 3.1 8B".into(), provider: "groq".into(), description: "⚡ Fast — lightning fast".into() }, - ModelInfo { id: "mixtral-8x7b-32768".into(), name: "Mixtral 8x7B".into(), provider: "groq".into(), description: "🚀 Powerful — large ctx".into() }, - ModelInfo { id: "gemma2-9b-it".into(), name: "Gemma 2 9B".into(), provider: "groq".into(), description: "🔋 Balanced — Google efficient".into() }, + ModelInfo { + id: "llama-3.3-70b-versatile".into(), + name: "Llama 3.3 70B".into(), + provider: "groq".into(), + description: "🚀 Powerful — fast on Groq".into(), + }, + ModelInfo { + id: "llama-3.1-8b-instant".into(), + name: "Llama 3.1 8B".into(), + provider: "groq".into(), + description: "⚡ Fast — lightning fast".into(), + }, + ModelInfo { + id: "mixtral-8x7b-32768".into(), + name: "Mixtral 8x7B".into(), + provider: "groq".into(), + description: "🚀 Powerful — large ctx".into(), + }, + ModelInfo { + id: "gemma2-9b-it".into(), + name: "Gemma 2 9B".into(), + provider: "groq".into(), + description: "🔋 Balanced — Google efficient".into(), + }, ], "openrouter" => vec![ - ModelInfo { id: "openai/gpt-4o".into(), name: "OpenAI GPT-4o".into(), provider: "openrouter".into(), description: "🚀 Powerful — via OpenRouter".into() }, - ModelInfo { id: "anthropic/claude-3.5-sonnet".into(), name: "Claude 3.5 Sonnet".into(), provider: "openrouter".into(), description: "🚀 Powerful — via OpenRouter".into() }, - ModelInfo { id: "google/gemini-2.0-flash-001".into(), name: "Gemini 2.0 Flash".into(), provider: "openrouter".into(), description: "⚡ Fast — via OpenRouter".into() }, - ModelInfo { id: "meta-llama/llama-3.3-70b-instruct".into(), name: "Llama 3.3 70B".into(), provider: "openrouter".into(), description: "🚀 Powerful — via OpenRouter".into() }, - ModelInfo { id: "qwen/qwen-2.5-coder-32b-instruct".into(), name: "Qwen 2.5 Coder 32B".into(), provider: "openrouter".into(), description: "🚀 Powerful — via OpenRouter".into() }, + ModelInfo { + id: "openai/gpt-4o".into(), + name: "OpenAI GPT-4o".into(), + provider: "openrouter".into(), + description: "🚀 Powerful — via OpenRouter".into(), + }, + ModelInfo { + id: "anthropic/claude-3.5-sonnet".into(), + name: "Claude 3.5 Sonnet".into(), + provider: "openrouter".into(), + description: "🚀 Powerful — via OpenRouter".into(), + }, + ModelInfo { + id: "google/gemini-2.0-flash-001".into(), + name: "Gemini 2.0 Flash".into(), + provider: "openrouter".into(), + description: "⚡ Fast — via OpenRouter".into(), + }, + ModelInfo { + id: "meta-llama/llama-3.3-70b-instruct".into(), + name: "Llama 3.3 70B".into(), + provider: "openrouter".into(), + description: "🚀 Powerful — via OpenRouter".into(), + }, + ModelInfo { + id: "qwen/qwen-2.5-coder-32b-instruct".into(), + name: "Qwen 2.5 Coder 32B".into(), + provider: "openrouter".into(), + description: "🚀 Powerful — via OpenRouter".into(), + }, ], _ => vec![], } @@ -196,7 +356,10 @@ pub async fn get_providers() -> Vec { /// Update a provider's config pub async fn update_provider(config: ProviderConfig) { let mut providers = PROVIDERS.lock().await; - if let Some(existing) = providers.iter_mut().find(|p| p.provider_type == config.provider_type) { + if let Some(existing) = providers + .iter_mut() + .find(|p| p.provider_type == config.provider_type) + { *existing = config; } else { providers.push(config); @@ -206,7 +369,10 @@ pub async fn update_provider(config: ProviderConfig) { /// Get a specific provider's config pub async fn get_provider(provider_type: &str) -> Option { let providers = PROVIDERS.lock().await; - providers.iter().find(|p| p.provider_type == provider_type).cloned() + providers + .iter() + .find(|p| p.provider_type == provider_type) + .cloned() } // ─── Tauri Command Handlers (free functions) ──────────────────────────────── @@ -232,17 +398,25 @@ pub async fn ai_generate( ) -> Result { let config = { let providers = PROVIDERS.lock().await; - providers.iter().find(|p| p.provider_type == provider_type) + providers + .iter() + .find(|p| p.provider_type == provider_type) .ok_or_else(|| format!("Provider '{}' not configured", provider_type))? .clone() }; if !config.enabled { - return Err(format!("Provider '{}' is disabled. Enable it in Settings.", config.label)); + return Err(format!( + "Provider '{}' is disabled. Enable it in Settings.", + config.label + )); } let model = if model.is_empty() { - config.default_model.clone().unwrap_or_else(|| "unknown".into()) + config + .default_model + .clone() + .unwrap_or_else(|| "unknown".into()) } else { model }; @@ -273,7 +447,9 @@ pub async fn ai_generate_stream( ) -> Result<(), String> { let config = { let providers = PROVIDERS.lock().await; - providers.iter().find(|p| p.provider_type == provider_type) + providers + .iter() + .find(|p| p.provider_type == provider_type) .ok_or_else(|| format!("Provider '{}' not configured", provider_type))? .clone() }; @@ -283,7 +459,10 @@ pub async fn ai_generate_stream( } let model = if model.is_empty() { - config.default_model.clone().unwrap_or_else(|| "unknown".into()) + config + .default_model + .clone() + .unwrap_or_else(|| "unknown".into()) } else { model }; @@ -351,7 +530,10 @@ async fn ollama_generate( .await .map_err(|e| format!("Ollama connection failed: {}. Is Ollama running?", e))?; - let result: serde_json::Value = resp.json().await.map_err(|e| format!("Ollama parse error: {}", e))?; + let result: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("Ollama parse error: {}", e))?; let content = result["response"].as_str().unwrap_or("").to_string(); @@ -410,10 +592,13 @@ async fn ollama_generate_stream( if let Ok(val) = serde_json::from_str::(&line) { if let Some(resp) = val["response"].as_str() { let done = val["done"].as_bool().unwrap_or(false); - let _ = app.emit("ai:token", StreamToken { - token: resp.to_string(), - done, - }); + let _ = app.emit( + "ai:token", + StreamToken { + token: resp.to_string(), + done, + }, + ); if done { return Ok(()); } @@ -425,17 +610,26 @@ async fn ollama_generate_stream( let data = line["data: ".len()..].to_string(); if data == "[DONE]" { - let _ = app.emit("ai:token", StreamToken { token: String::new(), done: true }); + let _ = app.emit( + "ai:token", + StreamToken { + token: String::new(), + done: true, + }, + ); return Ok(()); } if let Ok(val) = serde_json::from_str::(&data) { if let Some(resp) = val["response"].as_str() { let done = val["done"].as_bool().unwrap_or(false); - let _ = app.emit("ai:token", StreamToken { - token: resp.to_string(), - done, - }); + let _ = app.emit( + "ai:token", + StreamToken { + token: resp.to_string(), + done, + }, + ); if done { return Ok(()); } @@ -444,7 +638,13 @@ async fn ollama_generate_stream( } } - let _ = app.emit("ai:token", StreamToken { token: String::new(), done: true }); + let _ = app.emit( + "ai:token", + StreamToken { + token: String::new(), + done: true, + }, + ); Ok(()) } @@ -456,7 +656,10 @@ async fn ollama_fetch_models(cfg: &ProviderConfig) -> Result, Str .await .map_err(|e| format!("Ollama API error: {}", e))?; - let result: serde_json::Value = resp.json().await.map_err(|e| format!("Parse error: {}", e))?; + let result: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("Parse error: {}", e))?; let mut models = Vec::new(); if let Some(arr) = result["models"].as_array() { for m in arr { @@ -465,7 +668,10 @@ async fn ollama_fetch_models(cfg: &ProviderConfig) -> Result, Str id: name.to_string(), name: name.to_string(), provider: "ollama".into(), - description: format!("{}B params", m["details"]["parameter_size"].as_str().unwrap_or("?")), + description: format!( + "{}B params", + m["details"]["parameter_size"].as_str().unwrap_or("?") + ), }); } } @@ -481,8 +687,14 @@ async fn openai_chat_generate( prompt: &str, response_format: Option<&str>, ) -> Result { - let base_url = cfg.base_url.as_deref().unwrap_or("https://api.openai.com/v1"); - let api_key = cfg.api_key.as_deref().ok_or("API key not set for this provider")?; + let base_url = cfg + .base_url + .as_deref() + .unwrap_or("https://api.openai.com/v1"); + let api_key = cfg + .api_key + .as_deref() + .ok_or("API key not set for this provider")?; let mut body = serde_json::json!({ "model": model, @@ -496,7 +708,10 @@ async fn openai_chat_generate( } let resp = HTTP_CLIENT - .post(format!("{}/chat/completions", base_url.trim_end_matches('/'))) + .post(format!( + "{}/chat/completions", + base_url.trim_end_matches('/') + )) .header("Authorization", format!("Bearer {}", api_key)) .header("Content-Type", "application/json") .json(&body) @@ -505,16 +720,26 @@ async fn openai_chat_generate( .map_err(|e| format!("API request failed: {}", e))?; let status = resp.status(); - let result: serde_json::Value = resp.json().await.map_err(|e| format!("Parse error: {}", e))?; + let result: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("Parse error: {}", e))?; if !status.is_success() { - let err_msg = result["error"]["message"].as_str().unwrap_or("Unknown API error"); + let err_msg = result["error"]["message"] + .as_str() + .unwrap_or("Unknown API error"); return Err(format!("API error ({}): {}", status, err_msg)); } - let content = result["choices"][0]["message"]["content"].as_str().unwrap_or("").to_string(); + let content = result["choices"][0]["message"]["content"] + .as_str() + .unwrap_or("") + .to_string(); let tokens_in = result["usage"]["prompt_tokens"].as_u64().map(|v| v as u32); - let tokens_out = result["usage"]["completion_tokens"].as_u64().map(|v| v as u32); + let tokens_out = result["usage"]["completion_tokens"] + .as_u64() + .map(|v| v as u32); Ok(GenerationResult { content, @@ -532,7 +757,10 @@ async fn openai_chat_stream( prompt: &str, response_format: &str, ) -> Result<(), String> { - let base_url = cfg.base_url.as_deref().unwrap_or("https://api.openai.com/v1"); + let base_url = cfg + .base_url + .as_deref() + .unwrap_or("https://api.openai.com/v1"); let api_key = cfg.api_key.as_deref().ok_or("API key not set")?; let mut body = serde_json::json!({ @@ -548,7 +776,10 @@ async fn openai_chat_stream( } let resp = HTTP_CLIENT - .post(format!("{}/chat/completions", base_url.trim_end_matches('/'))) + .post(format!( + "{}/chat/completions", + base_url.trim_end_matches('/') + )) .header("Authorization", format!("Bearer {}", api_key)) .header("Content-Type", "application/json") .json(&body) @@ -579,29 +810,47 @@ async fn openai_chat_stream( let data = line["data: ".len()..].to_string(); if data == "[DONE]" { - let _ = app.emit("ai:token", StreamToken { token: String::new(), done: true }); + let _ = app.emit( + "ai:token", + StreamToken { + token: String::new(), + done: true, + }, + ); return Ok(()); } if let Ok(val) = serde_json::from_str::(&data) { if let Some(choice) = val["choices"].get(0) { if let Some(delta) = choice["delta"]["content"].as_str() { - let _ = app.emit("ai:token", StreamToken { - token: delta.to_string(), - done: false, - }); + let _ = app.emit( + "ai:token", + StreamToken { + token: delta.to_string(), + done: false, + }, + ); } } } } } - let _ = app.emit("ai:token", StreamToken { token: String::new(), done: true }); + let _ = app.emit( + "ai:token", + StreamToken { + token: String::new(), + done: true, + }, + ); Ok(()) } async fn openai_fetch_models(cfg: &ProviderConfig) -> Result, String> { - let base_url = cfg.base_url.as_deref().unwrap_or("https://api.openai.com/v1"); + let base_url = cfg + .base_url + .as_deref() + .unwrap_or("https://api.openai.com/v1"); let api_key = cfg.api_key.as_deref().ok_or("No API key")?; let resp = HTTP_CLIENT @@ -611,13 +860,25 @@ async fn openai_fetch_models(cfg: &ProviderConfig) -> Result, Str .await .map_err(|e| format!("API error: {}", e))?; - let result: serde_json::Value = resp.json().await.map_err(|e| format!("Parse error: {}", e))?; + let result: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("Parse error: {}", e))?; let mut models = Vec::new(); if let Some(arr) = result["data"].as_array() { for m in arr.iter().take(50) { if let Some(id) = m["id"].as_str() { // Filter to chat models only (exclude embeddings, etc.) - if id.contains("gpt") || id.contains("o1") || id.contains("o3") || id.contains("claude") || id.contains("llama") || id.contains("mixtral") || id.contains("gemma") || id.contains("qwen") || id.contains("mistral") { + if id.contains("gpt") + || id.contains("o1") + || id.contains("o3") + || id.contains("claude") + || id.contains("llama") + || id.contains("mixtral") + || id.contains("gemma") + || id.contains("qwen") + || id.contains("mistral") + { models.push(ModelInfo { id: id.to_string(), name: id.to_string(), @@ -638,8 +899,14 @@ async fn anthropic_generate( model: &str, prompt: &str, ) -> Result { - let base_url = cfg.base_url.as_deref().unwrap_or("https://api.anthropic.com/v1"); - let api_key = cfg.api_key.as_deref().ok_or("API key not set for Anthropic")?; + let base_url = cfg + .base_url + .as_deref() + .unwrap_or("https://api.anthropic.com/v1"); + let api_key = cfg + .api_key + .as_deref() + .ok_or("API key not set for Anthropic")?; let body = serde_json::json!({ "model": model, @@ -658,14 +925,22 @@ async fn anthropic_generate( .map_err(|e| format!("Anthropic request failed: {}", e))?; let status = resp.status(); - let result: serde_json::Value = resp.json().await.map_err(|e| format!("Parse error: {}", e))?; + let result: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("Parse error: {}", e))?; if !status.is_success() { - let err_msg = result["error"]["message"].as_str().unwrap_or("Unknown error"); + let err_msg = result["error"]["message"] + .as_str() + .unwrap_or("Unknown error"); return Err(format!("Anthropic error ({}): {}", status, err_msg)); } - let content = result["content"][0]["text"].as_str().unwrap_or("").to_string(); + let content = result["content"][0]["text"] + .as_str() + .unwrap_or("") + .to_string(); Ok(GenerationResult { content, @@ -682,7 +957,10 @@ async fn anthropic_stream( model: &str, prompt: &str, ) -> Result<(), String> { - let base_url = cfg.base_url.as_deref().unwrap_or("https://api.anthropic.com/v1"); + let base_url = cfg + .base_url + .as_deref() + .unwrap_or("https://api.anthropic.com/v1"); let api_key = cfg.api_key.as_deref().ok_or("API key not set")?; let body = serde_json::json!({ @@ -722,14 +1000,23 @@ async fn anthropic_stream( match val["type"].as_str() { Some("content_block_delta") => { if let Some(text) = val["delta"]["text"].as_str() { - let _ = app.emit("ai:token", StreamToken { - token: text.to_string(), - done: false, - }); + let _ = app.emit( + "ai:token", + StreamToken { + token: text.to_string(), + done: false, + }, + ); } } Some("message_stop") | Some("message_delta") => { - let _ = app.emit("ai:token", StreamToken { token: String::new(), done: true }); + let _ = app.emit( + "ai:token", + StreamToken { + token: String::new(), + done: true, + }, + ); return Ok(()); } _ => {} @@ -738,7 +1025,13 @@ async fn anthropic_stream( } } - let _ = app.emit("ai:token", StreamToken { token: String::new(), done: true }); + let _ = app.emit( + "ai:token", + StreamToken { + token: String::new(), + done: true, + }, + ); Ok(()) } @@ -749,8 +1042,14 @@ async fn google_generate( model: &str, prompt: &str, ) -> Result { - let base_url = cfg.base_url.as_deref().unwrap_or("https://generativelanguage.googleapis.com/v1beta"); - let api_key = cfg.api_key.as_deref().ok_or("API key not set for Google Gemini")?; + let base_url = cfg + .base_url + .as_deref() + .unwrap_or("https://generativelanguage.googleapis.com/v1beta"); + let api_key = cfg + .api_key + .as_deref() + .ok_or("API key not set for Google Gemini")?; let body = serde_json::json!({ "contents": [{"parts": [{"text": prompt}]}], @@ -776,14 +1075,22 @@ async fn google_generate( .map_err(|e| format!("Google API request failed: {}", e))?; let status = resp.status(); - let result: serde_json::Value = resp.json().await.map_err(|e| format!("Parse error: {}", e))?; + let result: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("Parse error: {}", e))?; if !status.is_success() { - let err_msg = result["error"]["message"].as_str().unwrap_or("Unknown error"); + let err_msg = result["error"]["message"] + .as_str() + .unwrap_or("Unknown error"); return Err(format!("Google API error ({}): {}", status, err_msg)); } - let content = result["candidates"][0]["content"]["parts"][0]["text"].as_str().unwrap_or("").to_string(); + let content = result["candidates"][0]["content"]["parts"][0]["text"] + .as_str() + .unwrap_or("") + .to_string(); Ok(GenerationResult { content, @@ -800,7 +1107,10 @@ async fn google_stream( model: &str, prompt: &str, ) -> Result<(), String> { - let base_url = cfg.base_url.as_deref().unwrap_or("https://generativelanguage.googleapis.com/v1beta"); + let base_url = cfg + .base_url + .as_deref() + .unwrap_or("https://generativelanguage.googleapis.com/v1beta"); let api_key = cfg.api_key.as_deref().ok_or("API key not set")?; let body = serde_json::json!({ @@ -843,7 +1153,13 @@ async fn google_stream( let data = line["data: ".len()..].to_string(); if data == "[DONE]" { - let _ = app.emit("ai:token", StreamToken { token: String::new(), done: true }); + let _ = app.emit( + "ai:token", + StreamToken { + token: String::new(), + done: true, + }, + ); return Ok(()); } @@ -851,16 +1167,25 @@ async fn google_stream( if let Some(candidate) = val["candidates"].get(0) { if let Some(part) = candidate["content"]["parts"].get(0) { if let Some(text) = part["text"].as_str() { - let _ = app.emit("ai:token", StreamToken { - token: text.to_string(), - done: false, - }); + let _ = app.emit( + "ai:token", + StreamToken { + token: text.to_string(), + done: false, + }, + ); } } // Check finish reason for done signal if let Some(finish) = candidate["finishReason"].as_str() { if !finish.is_empty() && finish != "STOP_UNSPECIFIED" { - let _ = app.emit("ai:token", StreamToken { token: String::new(), done: true }); + let _ = app.emit( + "ai:token", + StreamToken { + token: String::new(), + done: true, + }, + ); return Ok(()); } } @@ -869,7 +1194,13 @@ async fn google_stream( } } - let _ = app.emit("ai:token", StreamToken { token: String::new(), done: true }); + let _ = app.emit( + "ai:token", + StreamToken { + token: String::new(), + done: true, + }, + ); Ok(()) } @@ -877,7 +1208,9 @@ async fn google_stream( async fn fetch_models_from_api(provider_type: &str) -> Result, String> { let providers = PROVIDERS.lock().await; - let cfg = providers.iter().find(|p| p.provider_type == provider_type) + let cfg = providers + .iter() + .find(|p| p.provider_type == provider_type) .ok_or("Provider not found")?; if !cfg.enabled { @@ -895,10 +1228,16 @@ async fn fetch_models_from_api(provider_type: &str) -> Result, St /// Full AI build pipeline: recommend stack → scaffold → generate code → return result #[tauri::command] -pub async fn ai_build(prompt: String, provider_type: String, model: String) -> Result { +pub async fn ai_build( + prompt: String, + provider_type: String, + model: String, +) -> Result { let config = { let providers = PROVIDERS.lock().await; - providers.iter().find(|p| p.provider_type == provider_type) + providers + .iter() + .find(|p| p.provider_type == provider_type) .ok_or_else(|| format!("Provider '{}' not configured", provider_type))? .clone() }; @@ -908,7 +1247,10 @@ pub async fn ai_build(prompt: String, provider_type: String, model: String) -> R } let model = if model.is_empty() { - config.default_model.clone().unwrap_or_else(|| "unknown".into()) + config + .default_model + .clone() + .unwrap_or_else(|| "unknown".into()) } else { model }; @@ -923,7 +1265,9 @@ pub async fn ai_build(prompt: String, provider_type: String, model: String) -> R let stack_result = match provider_type.as_str() { "ollama" => ollama_generate(&config, &model, &stack_prompt, false, None).await, - "openai" | "together" | "groq" | "openrouter" => openai_chat_generate(&config, &model, &stack_prompt, None).await, + "openai" | "together" | "groq" | "openrouter" => { + openai_chat_generate(&config, &model, &stack_prompt, None).await + } "anthropic" => anthropic_generate(&config, &model, &stack_prompt).await, "google" => google_generate(&config, &model, &stack_prompt).await, _ => return Err("Unknown provider".into()), @@ -945,7 +1289,9 @@ pub async fn ai_build(prompt: String, provider_type: String, model: String) -> R let code_result = match provider_type.as_str() { "ollama" => ollama_generate(&config, &model, &code_prompt, false, None).await, - "openai" | "together" | "groq" | "openrouter" => openai_chat_generate(&config, &model, &code_prompt, None).await, + "openai" | "together" | "groq" | "openrouter" => { + openai_chat_generate(&config, &model, &code_prompt, None).await + } "anthropic" => anthropic_generate(&config, &model, &code_prompt).await, "google" => google_generate(&config, &model, &code_prompt).await, _ => return Err("Unknown provider".into()), @@ -959,7 +1305,8 @@ pub async fn ai_build(prompt: String, provider_type: String, model: String) -> R "model": model, "tokens_in": code_result.tokens_in, "tokens_out": code_result.tokens_out, - })).unwrap_or_else(|_| "{}".into())) + })) + .unwrap_or_else(|_| "{}".into())) } // ─── Test Connection ───────────────────────────────────────────────────────── @@ -967,14 +1314,19 @@ pub async fn ai_build(prompt: String, provider_type: String, model: String) -> R #[tauri::command] pub async fn test_ai_connection(provider_type: String) -> Result { let providers = PROVIDERS.lock().await; - let config = providers.iter().find(|p| p.provider_type == provider_type) + let config = providers + .iter() + .find(|p| p.provider_type == provider_type) .ok_or_else(|| format!("Provider '{}' not configured", provider_type))? .clone(); drop(providers); match provider_type.as_str() { "ollama" => { - let base_url = config.base_url.as_deref().unwrap_or("http://localhost:11434"); + let base_url = config + .base_url + .as_deref() + .unwrap_or("http://localhost:11434"); let resp = HTTP_CLIENT .get(format!("{}/api/tags", base_url.trim_end_matches('/'))) .send() @@ -1016,7 +1368,10 @@ pub async fn test_ai_connection(provider_type: String) -> Result // 400 means key is valid but we made a bad request (no body) — good enough Ok("✅ Connected to Anthropic Claude".into()) } else { - Err(format!("Auth failed ({}): check your API key", resp.status())) + Err(format!( + "Auth failed ({}): check your API key", + resp.status() + )) } } "google" => { @@ -1033,7 +1388,10 @@ pub async fn test_ai_connection(provider_type: String) -> Result if resp.status().is_success() { Ok("✅ Connected to Google Gemini".into()) } else { - Err(format!("Auth failed ({}): check your API key", resp.status())) + Err(format!( + "Auth failed ({}): check your API key", + resp.status() + )) } } _ => Err("Unknown provider".into()), diff --git a/src-tauri/src/cloud.rs b/src-tauri/src/cloud.rs index 1bcf92a..f5d0150 100644 --- a/src-tauri/src/cloud.rs +++ b/src-tauri/src/cloud.rs @@ -34,8 +34,8 @@ fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec { ipad[i] ^= k[i]; opad[i] ^= k[i]; } - let inner = Sha256::digest(&[ipad, data.to_vec()].concat()); - Sha256::digest(&[opad, inner.to_vec()].concat()).to_vec() + let inner = Sha256::digest([ipad, data.to_vec()].concat()); + Sha256::digest([opad, inner.to_vec()].concat()).to_vec() } fn get_signature_key(key: &str, date_stamp: &str, region: &str, service: &str) -> Vec { @@ -95,11 +95,7 @@ async fn deploy_aws(domain: &str) -> Result> ); let canonical_request = format!( "PUT\n{}\n{}\n{}\n{}\n{}", - canonical_uri, - canonical_querystring, - canonical_headers, - signed_headers, - payload_hash + canonical_uri, canonical_querystring, canonical_headers, signed_headers, payload_hash ); let algorithm = "AWS4-HMAC-SHA256"; @@ -129,9 +125,7 @@ async fn deploy_aws(domain: &str) -> Result> .header("Authorization", &authorization); if !body.is_empty() { - req = req - .header("Content-Type", "application/xml") - .body(body); + req = req.header("Content-Type", "application/xml").body(body); } let response = req.send().await?; @@ -179,10 +173,11 @@ async fn deploy_netlify(domain: &str) -> Result [u8; 32] { let seed = format!("{}-dweb-secret-v1", std::env::consts::ARCH); let hash = sha2::Sha256::digest(seed.as_bytes()); @@ -26,7 +30,7 @@ fn derive_encryption_key() -> [u8; 32] { fn encrypt_value(plaintext: &str) -> Option { use aes_gcm::aead::{Aead, KeyInit}; use aes_gcm::{Aes256Gcm, Nonce}; - use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; + use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; use rand::RngCore; let key = aes_gcm::Key::::from_slice(&derive_encryption_key()); @@ -49,7 +53,7 @@ fn encrypt_value(plaintext: &str) -> Option { fn decrypt_value(ciphertext_b64: &str) -> Option { use aes_gcm::aead::{Aead, KeyInit}; use aes_gcm::{Aes256Gcm, Nonce}; - use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; + use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; let combined = BASE64.decode(ciphertext_b64.as_bytes()).ok()?; if combined.len() < 12 { @@ -134,25 +138,25 @@ impl AppConfig { if let Some(val) = cp.aws_access_key.take() { cp.aws_access_key = val .strip_prefix("enc:") - .and_then(|v| decrypt_value(v)) + .and_then(decrypt_value) .or(Some(val)); } if let Some(val) = cp.aws_secret_key.take() { cp.aws_secret_key = val .strip_prefix("enc:") - .and_then(|v| decrypt_value(v)) + .and_then(decrypt_value) .or(Some(val)); } if let Some(val) = cp.netlify_token.take() { cp.netlify_token = val .strip_prefix("enc:") - .and_then(|v| decrypt_value(v)) + .and_then(decrypt_value) .or(Some(val)); } if let Some(val) = cp.vercel_token.take() { cp.vercel_token = val .strip_prefix("enc:") - .and_then(|v| decrypt_value(v)) + .and_then(decrypt_value) .or(Some(val)); } @@ -176,7 +180,7 @@ impl AppConfig { .get_mut("cloud_providers") .and_then(|v| v.as_object_mut()) { - for field in &[ + for field in [ "aws_access_key", "aws_secret_key", "netlify_token", diff --git a/src-tauri/src/database.rs b/src-tauri/src/database.rs index 638d745..d57b722 100644 --- a/src-tauri/src/database.rs +++ b/src-tauri/src/database.rs @@ -106,16 +106,15 @@ pub fn upsert_project(project: &Project) -> Result<(), String> { /// Get a project by ID. pub fn get_project(id: &str) -> Result, String> { let key = format!("project:{}", id); - with_db(|db| { - match db.get(key.as_bytes()).map_err(|e| e.to_string())? { + with_db( + |db| match db.get(key.as_bytes()).map_err(|e| e.to_string())? { Some(bytes) => { - let project: Project = - serde_json::from_slice(&bytes).map_err(|e| e.to_string())?; + let project: Project = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?; Ok(Some(project)) } None => Ok(None), - } - }) + }, + ) } /// List all projects. @@ -125,8 +124,7 @@ pub fn list_projects() -> Result, String> { let mut projects = Vec::new(); for result in db.scan_prefix(prefix) { let (_, value) = result.map_err(|e| e.to_string())?; - let project: Project = - serde_json::from_slice(&value).map_err(|e| e.to_string())?; + let project: Project = serde_json::from_slice(&value).map_err(|e| e.to_string())?; projects.push(project); } projects.sort_by(|a, b| b.created_at.cmp(&a.created_at)); @@ -149,7 +147,8 @@ pub fn insert_build(record: &BuildRecord) -> Result<(), String> { let key = format!("build:{}", record.id); let value = serde_json::to_vec(record).map_err(|e| e.to_string())?; with_db(|db| { - db.insert(key.as_bytes(), value).map_err(|e| e.to_string())?; + db.insert(key.as_bytes(), value) + .map_err(|e| e.to_string())?; Ok(()) }) } @@ -160,8 +159,7 @@ pub fn list_builds(limit: usize) -> Result, String> { let mut builds = Vec::new(); for result in db.scan_prefix(prefix) { let (_, value) = result.map_err(|e| e.to_string())?; - let record: BuildRecord = - serde_json::from_slice(&value).map_err(|e| e.to_string())?; + let record: BuildRecord = serde_json::from_slice(&value).map_err(|e| e.to_string())?; builds.push(record); } builds.sort_by(|a, b| b.started_at.cmp(&a.started_at)); @@ -176,23 +174,24 @@ pub fn save_domain_record(record: &DomainRecord) -> Result<(), String> { let key = format!("domain:{}", record.name); let value = serde_json::to_vec(record).map_err(|e| e.to_string())?; with_db(|db| { - db.insert(key.as_bytes(), value).map_err(|e| e.to_string())?; + db.insert(key.as_bytes(), value) + .map_err(|e| e.to_string())?; Ok(()) }) } pub fn get_domain_record(name: &str) -> Result, String> { let key = format!("domain:{}", name); - with_db(|db| { - match db.get(key.as_bytes()).map_err(|e| e.to_string())? { + with_db( + |db| match db.get(key.as_bytes()).map_err(|e| e.to_string())? { Some(bytes) => { let record: DomainRecord = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?; Ok(Some(record)) } None => Ok(None), - } - }) + }, + ) } pub fn list_domain_records() -> Result, String> { @@ -201,8 +200,7 @@ pub fn list_domain_records() -> Result, String> { let mut records = Vec::new(); for result in db.scan_prefix(prefix) { let (_, value) = result.map_err(|e| e.to_string())?; - let record: DomainRecord = - serde_json::from_slice(&value).map_err(|e| e.to_string())?; + let record: DomainRecord = serde_json::from_slice(&value).map_err(|e| e.to_string())?; records.push(record); } records.sort_by(|a, b| b.registered_at.cmp(&a.registered_at)); diff --git a/src-tauri/src/domain.rs b/src-tauri/src/domain.rs index 5d51ebd..c1a0ab2 100644 --- a/src-tauri/src/domain.rs +++ b/src-tauri/src/domain.rs @@ -15,9 +15,10 @@ pub struct DomainRecord { // ─── Conversion Between Domain and Database Records ───────────────────────── fn domain_to_db(record: &DomainRecord) -> crate::database::DomainRecord { - let target_port = record.address.as_ref().and_then(|addr| { - addr.rsplit(':').next().and_then(|s| s.parse::().ok()) - }); + let target_port = record + .address + .as_ref() + .and_then(|addr| addr.rsplit(':').next().and_then(|s| s.parse::().ok())); crate::database::DomainRecord { name: record.name.clone(), owner_key: record.owner_key.clone(), @@ -46,7 +47,8 @@ fn is_valid_domain(name: &str) -> bool { if name.len() < 3 || name.len() > 63 { return false; } - name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + name.chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') } // ─── Public API ────────────────────────────────────────────────────────────── @@ -54,7 +56,9 @@ fn is_valid_domain(name: &str) -> bool { pub async fn register(name: &str) -> Result { let name = name.trim().to_lowercase(); if !is_valid_domain(&name) { - return Err("Invalid domain name. Use 3-63 chars: lowercase letters, numbers, hyphens.".to_string()); + return Err( + "Invalid domain name. Use 3-63 chars: lowercase letters, numbers, hyphens.".to_string(), + ); } if let Ok(Some(_)) = crate::database::get_domain_record(&name) { @@ -101,7 +105,10 @@ pub async fn resolve(name: &str) -> Result { }; Ok(record) } - Ok(None) => Err(format!("Domain '{}' not found on the P2P network. Register it first.", name)), + Ok(None) => Err(format!( + "Domain '{}' not found on the P2P network. Register it first.", + name + )), Err(e) => Err(format!("P2P resolution failed: {}", e)), } } @@ -124,7 +131,11 @@ pub async fn renew_domain(name: &str) -> Result { crate::database::save_domain_record(&db_record)?; let record = db_to_domain(&db_record); - log::info!("Domain renewed: {}.dweb (expires {})", name, record.expires_at); + log::info!( + "Domain renewed: {}.dweb (expires {})", + name, + record.expires_at + ); Ok(record) } diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 8b58f92..7ceec68 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -242,15 +242,17 @@ pub fn get_repo_status(path: &Path) -> Result { } } - let is_clean = modified_files.is_empty() && staged_files.is_empty() && untracked_files.is_empty(); + let is_clean = + modified_files.is_empty() && staged_files.is_empty() && untracked_files.is_empty(); // Ahead/behind for current branch let (ahead, behind) = get_ahead_behind(&repo, ¤t_branch); // Last commit - let last_commit = repo.head().ok().and_then(|head| { - head.peel_to_commit().ok().map(|c| commit_info(&c)) - }); + let last_commit = repo + .head() + .ok() + .and_then(|head| head.peel_to_commit().ok().map(|c| commit_info(&c))); // Remotes let remotes = list_remotes_internal(&repo); @@ -296,7 +298,7 @@ fn get_ahead_behind(repo: &git2::Repository, branch_name: &str) -> (usize, usize }; match repo.graph_ahead_behind(local_oid, upstream_oid) { - Ok((a, b)) => (a as usize, b as usize), + Ok((a, b)) => (a, b), Err(_) => (0, 0), } } @@ -305,7 +307,8 @@ fn get_ahead_behind(repo: &git2::Repository, branch_name: &str) -> (usize, usize pub fn stage_all(path: &Path) -> Result { let repo = open_repo(path)?; let mut index = repo.index().map_err(git_err)?; - index.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None) + index + .add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None) .map_err(git_err)?; index.write().map_err(git_err)?; Ok(GitOperationResult { @@ -320,9 +323,9 @@ pub fn stage_files(path: &Path, files: Vec) -> Result Result { // Stage all changes first let mut index = repo.index().map_err(git_err)?; - index.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None) + index + .add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None) .map_err(git_err)?; index.write().map_err(git_err)?; @@ -364,22 +368,8 @@ pub fn commit(path: &Path, message: &str) -> Result { let parent_commit = repo.head().ok().and_then(|h| h.peel_to_commit().ok()); let commit_id = match parent_commit { - Some(ref parent) => repo.commit( - Some("HEAD"), - &sig, - &sig, - message, - &tree, - &[parent], - ), - None => repo.commit( - Some("HEAD"), - &sig, - &sig, - message, - &tree, - &[], - ), + Some(ref parent) => repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[parent]), + None => repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[]), } .map_err(git_err)?; @@ -388,7 +378,11 @@ pub fn commit(path: &Path, message: &str) -> Result { } /// Push to the remote (default: origin, current branch). -pub fn push(path: &Path, remote_name: Option<&str>, branch: Option<&str>) -> Result { +pub fn push( + path: &Path, + remote_name: Option<&str>, + branch: Option<&str>, +) -> Result { let repo = open_repo(path)?; let remote_name = remote_name.unwrap_or("origin"); let branch = branch.unwrap_or(""); @@ -406,16 +400,22 @@ pub fn push(path: &Path, remote_name: Option<&str>, branch: Option<&str>) -> Res branch.to_string() }; - let mut remote = repo.find_remote(remote_name).map_err(|e| { - format!("Remote '{}' not found: {}", remote_name, e.message()) - })?; + let mut remote = repo + .find_remote(remote_name) + .map_err(|e| format!("Remote '{}' not found: {}", remote_name, e.message()))?; - let refspec = format!("refs/heads/{}:refs/heads/{}", branch_to_push, branch_to_push); + let refspec = format!( + "refs/heads/{}:refs/heads/{}", + branch_to_push, branch_to_push + ); let mut callbacks = git2::RemoteCallbacks::new(); callbacks.push_update_reference(|refname, status| { if let Some(msg) = status { - Err(git2::Error::from_str(&format!("Push rejected '{}': {}", refname, msg))) + Err(git2::Error::from_str(&format!( + "Push rejected '{}': {}", + refname, msg + ))) } else { Ok(()) } @@ -424,9 +424,9 @@ pub fn push(path: &Path, remote_name: Option<&str>, branch: Option<&str>) -> Res let mut opts = git2::PushOptions::new(); opts.remote_callbacks(callbacks); - remote.push(&[&refspec], Some(&mut opts)).map_err(|e| { - format!("Push failed: {}", e.message()) - })?; + remote + .push(&[&refspec], Some(&mut opts)) + .map_err(|e| format!("Push failed: {}", e.message()))?; Ok(GitOperationResult { success: true, @@ -436,7 +436,11 @@ pub fn push(path: &Path, remote_name: Option<&str>, branch: Option<&str>) -> Res } /// Pull from the remote (default: origin, current branch). -pub fn pull(path: &Path, remote_name: Option<&str>, branch: Option<&str>) -> Result { +pub fn pull( + path: &Path, + remote_name: Option<&str>, + branch: Option<&str>, +) -> Result { let repo = open_repo(path)?; let remote_name = remote_name.unwrap_or("origin"); let branch = branch.unwrap_or(""); @@ -455,30 +459,38 @@ pub fn pull(path: &Path, remote_name: Option<&str>, branch: Option<&str>) -> Res }; // Fetch from remote - let mut remote = repo.find_remote(remote_name).map_err(|e| { - format!("Remote '{}' not found: {}", remote_name, e.message()) - })?; + let mut remote = repo + .find_remote(remote_name) + .map_err(|e| format!("Remote '{}' not found: {}", remote_name, e.message()))?; let mut fetch_opts = git2::FetchOptions::new(); let mut callbacks = git2::RemoteCallbacks::new(); callbacks.transfer_progress(|progress| { - log::debug!("Fetch: {}/{} objects", progress.received_objects(), progress.total_objects()); + log::debug!( + "Fetch: {}/{} objects", + progress.received_objects(), + progress.total_objects() + ); true }); fetch_opts.remote_callbacks(callbacks); // Use a temp refspec for fetch - let refspec = format!("+refs/heads/{}:refs/remotes/{}/{}", branch_to_merge, remote_name, branch_to_merge); + let refspec = format!( + "+refs/heads/{}:refs/remotes/{}/{}", + branch_to_merge, remote_name, branch_to_merge + ); - remote.fetch(&[&refspec], Some(&mut fetch_opts), None).map_err(|e| { - format!("Fetch failed: {}", e.message()) - })?; + remote + .fetch(&[&refspec], Some(&mut fetch_opts), None) + .map_err(|e| format!("Fetch failed: {}", e.message()))?; // Merge fetched branch - let fetch_head = repo.find_reference("FETCH_HEAD").map_err(|e| { - format!("Cannot find FETCH_HEAD: {}", e.message()) - })?; - let fetch_commit = repo.reference_to_annotated_commit(&fetch_head) + let fetch_head = repo + .find_reference("FETCH_HEAD") + .map_err(|e| format!("Cannot find FETCH_HEAD: {}", e.message()))?; + let fetch_commit = repo + .reference_to_annotated_commit(&fetch_head) .map_err(git_err)?; let analysis = repo.merge_analysis(&[&fetch_commit]).map_err(git_err)?; @@ -495,13 +507,18 @@ pub fn pull(path: &Path, remote_name: Option<&str>, branch: Option<&str>) -> Res // Fast-forward merge let refname = format!("refs/heads/{}", branch_to_merge); let mut reference = repo.find_reference(&refname).map_err(git_err)?; - reference.set_target(fetch_commit.id(), "Pull: fast-forward").map_err(git_err)?; + reference + .set_target(fetch_commit.id(), "Pull: fast-forward") + .map_err(git_err)?; repo.set_head(&refname).map_err(git_err)?; repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force())) .map_err(git_err)?; Ok(GitOperationResult { success: true, - message: format!("Fast-forward merged '{}' from '{}'", branch_to_merge, remote_name), + message: format!( + "Fast-forward merged '{}' from '{}'", + branch_to_merge, remote_name + ), details: None, }) } else { @@ -511,7 +528,10 @@ pub fn pull(path: &Path, remote_name: Option<&str>, branch: Option<&str>) -> Res let mut merge_opts = git2::MergeOptions::new(); if let Err(e) = repo.merge(&[&fetch_commit], Some(&mut merge_opts), Some(&mut checkout)) { - return Err(format!("Merge failed: {}. Resolve conflicts and commit.", e.message())); + return Err(format!( + "Merge failed: {}. Resolve conflicts and commit.", + e.message() + )); } // Check if there are conflicts @@ -525,9 +545,16 @@ pub fn pull(path: &Path, remote_name: Option<&str>, branch: Option<&str>) -> Res // Auto-commit the merge let sig = repo.signature().map_err(git_err)?; - let tree_id = repo.index().map_err(git_err)?.write_tree().map_err(git_err)?; + let tree_id = repo + .index() + .map_err(git_err)? + .write_tree() + .map_err(git_err)?; let tree = repo.find_tree(tree_id).map_err(git_err)?; - let head_commit = repo.head().and_then(|h| h.peel_to_commit()).map_err(git_err)?; + let head_commit = repo + .head() + .and_then(|h| h.peel_to_commit()) + .map_err(git_err)?; // Peel the annotated commit to a regular commit for the parent list let fetch_commit_obj = fetch_commit.id(); @@ -537,7 +564,10 @@ pub fn pull(path: &Path, remote_name: Option<&str>, branch: Option<&str>) -> Res Some("HEAD"), &sig, &sig, - &format!("Merge remote-tracking branch '{}/{}'", remote_name, branch_to_merge), + &format!( + "Merge remote-tracking branch '{}/{}'", + remote_name, branch_to_merge + ), &tree, &[&head_commit, &fetch_commit_parent], ) @@ -558,7 +588,10 @@ pub fn list_branches(path: &Path) -> Result, String> { } fn list_branches_internal(repo: &git2::Repository) -> Vec { - let current = repo.head().ok().and_then(|h| h.shorthand().map(|s| s.to_string())); + let current = repo + .head() + .ok() + .and_then(|h| h.shorthand().map(|s| s.to_string())); let mut branches = Vec::new(); // Local branches @@ -609,7 +642,8 @@ pub fn switch_branch(path: &Path, name: &str) -> Result { // Create new branch from HEAD - let head_commit = repo.head() + let head_commit = repo + .head() .and_then(|h| h.peel_to_commit()) .map_err(|_| "No commits yet — cannot create branch".to_string())?; repo.branch(name, &head_commit, false).map_err(git_err)?; @@ -629,7 +663,8 @@ pub fn switch_branch(path: &Path, name: &str) -> Result Result { let repo = open_repo(path)?; - let mut branch = repo.find_branch(name, git2::BranchType::Local) + let mut branch = repo + .find_branch(name, git2::BranchType::Local) .map_err(|_| format!("Branch '{}' not found", name))?; branch.delete().map_err(git_err)?; Ok(GitOperationResult { @@ -665,9 +700,8 @@ fn list_remotes_internal(repo: &git2::Repository) -> Vec { /// Add a remote. pub fn add_remote(path: &Path, name: &str, url: &str) -> Result { let repo = open_repo(path)?; - repo.remote(name, url).map_err(|e| { - format!("Failed to add remote '{}': {}", name, e.message()) - })?; + repo.remote(name, url) + .map_err(|e| format!("Failed to add remote '{}': {}", name, e.message()))?; Ok(GitOperationResult { success: true, message: format!("Added remote '{}' → {}", name, url), @@ -678,9 +712,8 @@ pub fn add_remote(path: &Path, name: &str, url: &str) -> Result Result { let repo = open_repo(path)?; - repo.remote_delete(name).map_err(|e| { - format!("Failed to remove remote '{}': {}", name, e.message()) - })?; + repo.remote_delete(name) + .map_err(|e| format!("Failed to remove remote '{}': {}", name, e.message()))?; Ok(GitOperationResult { success: true, message: format!("Removed remote '{}'", name), diff --git a/src-tauri/src/github.rs b/src-tauri/src/github.rs index 1624c72..476ccb2 100644 --- a/src-tauri/src/github.rs +++ b/src-tauri/src/github.rs @@ -85,8 +85,11 @@ fn token_path() -> std::path::PathBuf { pub fn save_token(token: &str) -> Result<(), String> { let path = token_path(); let data = serde_json::json!({ "token": token }); - std::fs::write(&path, serde_json::to_string_pretty(&data).map_err(|e| e.to_string())?) - .map_err(|e| format!("Failed to save token: {}", e))?; + std::fs::write( + &path, + serde_json::to_string_pretty(&data).map_err(|e| e.to_string())?, + ) + .map_err(|e| format!("Failed to save token: {}", e))?; Ok(()) } @@ -105,8 +108,7 @@ pub fn load_token() -> Option { pub fn clear_token() -> Result<(), String> { let path = token_path(); if path.exists() { - std::fs::remove_file(&path) - .map_err(|e| format!("Failed to remove token: {}", e))?; + std::fs::remove_file(&path).map_err(|e| format!("Failed to remove token: {}", e))?; } Ok(()) } @@ -133,10 +135,16 @@ async fn get_json(url: &str, token: Option<&str>) -> Result) -> Result, body: &serde_json::Value) -> Result { +async fn post_json( + url: &str, + token: Option<&str>, + body: &serde_json::Value, +) -> Result { let req = client().post(url).json(body); let req = if let Some(t) = token { req.header("Authorization", format!("Bearer {}", t)) @@ -155,10 +167,16 @@ async fn post_json(url: &str, token: Option<&str>, body: &serde_json::Value) -> let resp = req.send().await.map_err(|e| format!("HTTP error: {}", e))?; let status = resp.status(); - let body: serde_json::Value = resp.json().await.map_err(|e| format!("JSON error: {}", e))?; + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("JSON error: {}", e))?; if !status.is_success() { - let msg = body.get("message").and_then(|m| m.as_str()).unwrap_or("Unknown error"); + let msg = body + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("Unknown error"); return Err(format!("GitHub API error ({}): {}", status.as_u16(), msg)); } @@ -185,30 +203,40 @@ pub async fn request_device_code() -> Result { .map_err(|e| format!("Device code request failed: {}", e))?; let status = resp.status(); - let json: serde_json::Value = resp.json().await.map_err(|e| format!("JSON error: {}", e))?; + let json: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("JSON error: {}", e))?; if !status.is_success() { - let msg = json.get("error_description") + let msg = json + .get("error_description") .and_then(|m| m.as_str()) .unwrap_or("Unknown error"); return Err(format!("OAuth error: {}", msg)); } Ok(DeviceCodeResponse { - device_code: json.get("device_code") + device_code: json + .get("device_code") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(), - user_code: json.get("user_code") + user_code: json + .get("user_code") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(), - verification_uri: json.get("verification_uri") + verification_uri: json + .get("verification_uri") .and_then(|v| v.as_str()) .unwrap_or("https://github.com/login/device") .to_string(), interval: json.get("interval").and_then(|v| v.as_u64()).unwrap_or(5), - expires_in: json.get("expires_in").and_then(|v| v.as_u64()).unwrap_or(900), + expires_in: json + .get("expires_in") + .and_then(|v| v.as_u64()) + .unwrap_or(900), }) } @@ -234,7 +262,10 @@ pub async fn poll_for_token(device_code: &str, interval: u64) -> Result Result { // User hasn't authorized yet — keep polling - log::debug!("GitHub OAuth: authorization pending (attempt {})", attempt + 1); + log::debug!( + "GitHub OAuth: authorization pending (attempt {})", + attempt + 1 + ); continue; } "slow_down" => { @@ -317,10 +351,23 @@ pub async fn get_current_user(token: Option) -> Result) -> Result) -> Result, String> { - let token = token.or_else(load_token) + let token = token + .or_else(load_token) .ok_or_else(|| "Not authenticated. Please login first.".to_string())?; let json = get_json( - &format!("{}/user/repos?per_page=100&sort=updated&direction=desc", API_BASE), + &format!( + "{}/user/repos?per_page=100&sort=updated&direction=desc", + API_BASE + ), Some(&token), - ).await?; + ) + .await?; - let repos: Vec = serde_json::from_value(json) - .map_err(|e| format!("Parse error: {}", e))?; + let repos: Vec = + serde_json::from_value(json).map_err(|e| format!("Parse error: {}", e))?; let mut result = Vec::new(); for r in repos { result.push(GitHubRepo { id: r.get("id").and_then(|v| v.as_u64()).unwrap_or(0), - name: r.get("name").and_then(|v| v.as_str()).unwrap_or("?").to_string(), - full_name: r.get("full_name").and_then(|v| v.as_str()).unwrap_or("?").to_string(), - description: r.get("description").and_then(|v| v.as_str()).map(|s| s.to_string()), - html_url: r.get("html_url").and_then(|v| v.as_str()).unwrap_or("").to_string(), - clone_url: r.get("clone_url").and_then(|v| v.as_str()).unwrap_or("").to_string(), - ssh_url: r.get("ssh_url").and_then(|v| v.as_str()).unwrap_or("").to_string(), - language: r.get("language").and_then(|v| v.as_str()).map(|s| s.to_string()), - stars: r.get("stargazers_count").and_then(|v| v.as_u64()).unwrap_or(0), + name: r + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("?") + .to_string(), + full_name: r + .get("full_name") + .and_then(|v| v.as_str()) + .unwrap_or("?") + .to_string(), + description: r + .get("description") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + html_url: r + .get("html_url") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + clone_url: r + .get("clone_url") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + ssh_url: r + .get("ssh_url") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + language: r + .get("language") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + stars: r + .get("stargazers_count") + .and_then(|v| v.as_u64()) + .unwrap_or(0), forks: r.get("forks_count").and_then(|v| v.as_u64()).unwrap_or(0), is_private: r.get("private").and_then(|v| v.as_bool()).unwrap_or(false), is_fork: r.get("fork").and_then(|v| v.as_bool()).unwrap_or(false), - default_branch: r.get("default_branch").and_then(|v| v.as_str()).unwrap_or("main").to_string(), - updated_at: r.get("updated_at").and_then(|v| v.as_str()).unwrap_or("").to_string(), - owner: r.get("owner") + default_branch: r + .get("default_branch") + .and_then(|v| v.as_str()) + .unwrap_or("main") + .to_string(), + updated_at: r + .get("updated_at") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + owner: r + .get("owner") .and_then(|o| o.get("login")) .and_then(|v| v.as_str()) .unwrap_or("?") .to_string(), - owner_avatar: r.get("owner") + owner_avatar: r + .get("owner") .and_then(|o| o.get("avatar_url")) .and_then(|v| v.as_str()) .map(|s| s.to_string()), @@ -378,7 +469,8 @@ pub async fn create_repo( description: Option<&str>, private: bool, ) -> Result { - let token = token.or_else(load_token) + let token = token + .or_else(load_token) .ok_or_else(|| "Not authenticated.".to_string())?; let mut body = serde_json::json!({ @@ -394,25 +486,70 @@ pub async fn create_repo( Ok(GitHubRepo { id: json.get("id").and_then(|v| v.as_u64()).unwrap_or(0), - name: json.get("name").and_then(|v| v.as_str()).unwrap_or("?").to_string(), - full_name: json.get("full_name").and_then(|v| v.as_str()).unwrap_or("?").to_string(), - description: json.get("description").and_then(|v| v.as_str()).map(|s| s.to_string()), - html_url: json.get("html_url").and_then(|v| v.as_str()).unwrap_or("").to_string(), - clone_url: json.get("clone_url").and_then(|v| v.as_str()).unwrap_or("").to_string(), - ssh_url: json.get("ssh_url").and_then(|v| v.as_str()).unwrap_or("").to_string(), - language: json.get("language").and_then(|v| v.as_str()).map(|s| s.to_string()), - stars: json.get("stargazers_count").and_then(|v| v.as_u64()).unwrap_or(0), - forks: json.get("forks_count").and_then(|v| v.as_u64()).unwrap_or(0), - is_private: json.get("private").and_then(|v| v.as_bool()).unwrap_or(false), + name: json + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("?") + .to_string(), + full_name: json + .get("full_name") + .and_then(|v| v.as_str()) + .unwrap_or("?") + .to_string(), + description: json + .get("description") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + html_url: json + .get("html_url") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + clone_url: json + .get("clone_url") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + ssh_url: json + .get("ssh_url") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + language: json + .get("language") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + stars: json + .get("stargazers_count") + .and_then(|v| v.as_u64()) + .unwrap_or(0), + forks: json + .get("forks_count") + .and_then(|v| v.as_u64()) + .unwrap_or(0), + is_private: json + .get("private") + .and_then(|v| v.as_bool()) + .unwrap_or(false), is_fork: json.get("fork").and_then(|v| v.as_bool()).unwrap_or(false), - default_branch: json.get("default_branch").and_then(|v| v.as_str()).unwrap_or("main").to_string(), - updated_at: json.get("updated_at").and_then(|v| v.as_str()).unwrap_or("").to_string(), - owner: json.get("owner") + default_branch: json + .get("default_branch") + .and_then(|v| v.as_str()) + .unwrap_or("main") + .to_string(), + updated_at: json + .get("updated_at") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + owner: json + .get("owner") .and_then(|o| o.get("login")) .and_then(|v| v.as_str()) .unwrap_or("?") .to_string(), - owner_avatar: json.get("owner") + owner_avatar: json + .get("owner") .and_then(|o| o.get("avatar_url")) .and_then(|v| v.as_str()) .map(|s| s.to_string()), @@ -428,7 +565,8 @@ pub async fn download_archive( archive_format: &str, // "zipball" or "tarball" branch: Option<&str>, ) -> Result, String> { - let token = token.or_else(load_token) + let token = token + .or_else(load_token) .ok_or_else(|| "Not authenticated.".to_string())?; let branch = branch.unwrap_or("HEAD"); @@ -449,7 +587,10 @@ pub async fn download_archive( return Err(format!("Download failed (HTTP {})", resp.status().as_u16())); } - let bytes = resp.bytes().await.map_err(|e| format!("Read error: {}", e))?; + let bytes = resp + .bytes() + .await + .map_err(|e| format!("Read error: {}", e))?; Ok(bytes.to_vec()) } @@ -460,14 +601,16 @@ pub async fn import_repo( repo_full_name: &str, // e.g. "owner/repo" dest_path: &Path, ) -> Result { - let token = token.or_else(load_token) + let token = token + .or_else(load_token) .ok_or_else(|| "Not authenticated.".to_string())?; // Get repo details to find the clone URL let url = format!("{}/repos/{}", API_BASE, repo_full_name); let json = get_json(&url, Some(&token)).await?; - let clone_url = json.get("clone_url") + let clone_url = json + .get("clone_url") .and_then(|v| v.as_str()) .ok_or_else(|| "No clone URL found".to_string())?; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 062a54b..3ae8f1c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,17 +1,19 @@ -pub mod p2p; -pub mod domain; -pub mod stack; +#![allow(unexpected_cfgs)] + pub mod ai; pub mod cloud; pub mod config; pub mod database; -pub mod sandbox; +pub mod domain; pub mod git; pub mod github; +pub mod p2p; +pub mod sandbox; +pub mod stack; +use once_cell::sync::Lazy; use std::path::PathBuf; use std::sync::Mutex; -use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; @@ -90,9 +92,9 @@ async fn get_services() -> Result, String> { let runtimes = stack::detect_runtimes().await.map_err(|e| e.to_string())?; for rt in runtimes { if let Some((name, port)) = parse_runtime(&rt) { - let _ = stack::add_service( - &name, stack::ServiceType::Runtime, port, "detected", false - ).await; + let _ = + stack::add_service(&name, stack::ServiceType::Runtime, port, "detected", false) + .await; } } services = stack::list_services().await.map_err(|e| e.to_string())?; @@ -112,7 +114,9 @@ async fn stop_service(name: String) -> Result<(), String> { #[tauri::command] async fn restart_service(name: String) -> Result<(), String> { - stack::stop_service(&name).await.map_err(|e| e.to_string())?; + stack::stop_service(&name) + .await + .map_err(|e| e.to_string())?; tokio::time::sleep(std::time::Duration::from_millis(500)).await; stack::start_service(&name).await.map_err(|e| e.to_string()) } @@ -185,7 +189,10 @@ async fn resolve_domain(domain: String) -> Result let _ = crate::database::save_domain_record(&db_record); Ok(record) } - None => Err(format!("Domain '{}' not found on the network. Register it first.", domain)), + None => Err(format!( + "Domain '{}' not found on the network. Register it first.", + domain + )), } } @@ -217,7 +224,9 @@ async fn renew_domain(name: String) -> Result { #[tauri::command] async fn transfer_domain(name: String, new_owner: String) -> Result { - domain::transfer_domain(&name, &new_owner).await.map_err(|e| e.to_string()) + domain::transfer_domain(&name, &new_owner) + .await + .map_err(|e| e.to_string()) } // ─── AI Commands ───────────────────────────────────────────────────────────── @@ -254,7 +263,9 @@ async fn get_active_ai() -> Result<(String, String), String> { #[tauri::command] async fn deploy_to_cloud(provider: String, domain: String) -> Result { - cloud::deploy(&provider, &domain).await.map_err(|e| e.to_string()) + cloud::deploy(&provider, &domain) + .await + .map_err(|e| e.to_string()) } // ─── Git / Version Control Commands ────────────────────────────────────────── @@ -280,7 +291,10 @@ async fn git_stage_all(path: String) -> Result } #[tauri::command] -async fn git_stage_files(path: String, files: Vec) -> Result { +async fn git_stage_files( + path: String, + files: Vec, +) -> Result { git::stage_files(&std::path::PathBuf::from(&path), files) } @@ -341,7 +355,11 @@ async fn git_remotes(path: String) -> Result, String> { } #[tauri::command] -async fn git_add_remote(path: String, name: String, url: String) -> Result { +async fn git_add_remote( + path: String, + name: String, + url: String, +) -> Result { git::add_remote(&std::path::PathBuf::from(&path), &name, &url) } @@ -403,7 +421,13 @@ async fn github_create_repo( description: Option, private: Option, ) -> Result { - github::create_repo(None, &name, description.as_deref(), private.unwrap_or(false)).await + github::create_repo( + None, + &name, + description.as_deref(), + private.unwrap_or(false), + ) + .await } #[tauri::command] @@ -419,19 +443,13 @@ async fn github_download_archive( &repo, &archive_format.unwrap_or_else(|| "zipball".to_string()), branch.as_deref(), - ).await + ) + .await } #[tauri::command] -async fn github_import_repo( - full_name: String, - dest_path: String, -) -> Result { - github::import_repo( - None, - &full_name, - &std::path::PathBuf::from(&dest_path), - ).await +async fn github_import_repo(full_name: String, dest_path: String) -> Result { + github::import_repo(None, &full_name, &std::path::PathBuf::from(&dest_path)).await } // ─── App Entry Point ───────────────────────────────────────────────────────── @@ -556,9 +574,7 @@ pub fn run_with_args(data_dir: PathBuf, port: u16, name: Option) { #[cfg(desktop)] { use tauri::tray::TrayIconBuilder; - let _tray = TrayIconBuilder::new() - .tooltip(&tray_tooltip) - .build(app)?; + let _tray = TrayIconBuilder::new().tooltip(&tray_tooltip).build(app)?; } Ok(()) } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index fe60e36..1b5a5c0 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -37,12 +37,15 @@ fn main() { }); // Ensure the data directory exists - std::fs::create_dir_all(&data_dir) - .expect("Failed to create data directory"); + std::fs::create_dir_all(&data_dir).expect("Failed to create data directory"); dweb_lib::run_with_args( data_dir, args.port, - if args.name.is_empty() { None } else { Some(args.name) }, + if args.name.is_empty() { + None + } else { + Some(args.name) + }, ); } diff --git a/src-tauri/src/p2p.rs b/src-tauri/src/p2p.rs index 8697270..3bb6b15 100644 --- a/src-tauri/src/p2p.rs +++ b/src-tauri/src/p2p.rs @@ -1,8 +1,9 @@ +use dht_rpc::{Commit, DhtConfig, IdBytes}; use futures::StreamExt; -use hyperdht::{Keypair, adht::Dht}; -use dht_rpc::{DhtConfig, IdBytes, Commit}; +use hyperdht::{adht::Dht, Keypair}; use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; +use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use tokio::sync::Mutex; @@ -42,14 +43,13 @@ const BOOTSTRAP_NODES: &[&str] = &[ // ─── Singleton ────────────────────────────────────────────────────────────── -static P2P_MANAGER: Lazy>>> = - Lazy::new(|| Arc::new(Mutex::new(None))); +static P2P_MANAGER: Lazy>>> = Lazy::new(|| Arc::new(Mutex::new(None))); /// Initialize the global P2P manager (called once at startup). -pub async fn init(data_dir: &PathBuf) -> Result<(), Box> { +pub async fn init(data_dir: &Path) -> Result<(), Box> { // Shut down any existing manager first to kill orphaned tasks let mut guard = P2P_MANAGER.lock().await; - if let Some(old) = guard.take() { + if let Some(mut old) = guard.take() { old.shutdown().await; log::info!("P2P previous manager shut down"); } @@ -63,7 +63,8 @@ pub async fn init(data_dir: &PathBuf) -> Result<(), Box> } /// Convenience: get a reference to the singleton manager. -async fn with_manager() -> Result>, Box> { +async fn with_manager( +) -> Result>, Box> { let guard = P2P_MANAGER.lock().await; if guard.is_some() { Ok(guard) @@ -88,7 +89,7 @@ pub struct P2PManager { impl P2PManager { /// Create and bootstrap a new P2P node. - pub async fn new(data_dir: &PathBuf) -> Result> { + pub async fn new(data_dir: &Path) -> Result> { let mut config = DhtConfig::default(); // Configure bootstrap nodes for the DHT let nodes: Vec = BOOTSTRAP_NODES @@ -136,22 +137,22 @@ impl P2PManager { keypair: Keypair::default(), started_at: chrono::Utc::now(), resolved_count: std::sync::atomic::AtomicU64::new(0), - data_dir: data_dir.clone(), + data_dir: data_dir.to_path_buf(), task_handles, }) } /// Abort all tracked background tasks. Call before dropping to prevent /// orphaned-task panics in the Tokio runtime. - pub async fn shutdown(&self) { + pub async fn shutdown(&mut self) { for handle in &self.task_handles { handle.abort(); } - // Wait briefly for tasks to actually stop - for handle in &self.task_handles { + // Wait briefly for tasks to actually stop (drain to consume handles) + for handle in self.task_handles.drain(..) { let _ = handle.await; } - log::info!("P2P manager: {} background task(s) aborted", self.task_handles.len()); + log::info!("P2P manager: all background tasks shut down"); } /// Hex-encoded public key of this node. @@ -160,7 +161,11 @@ impl P2PManager { } /// Announce a domain on the DHT so remote peers can discover it. - pub async fn announce_domain(&self, name: &str, port: u16) -> Result<(), Box> { + pub async fn announce_domain( + &self, + name: &str, + port: u16, + ) -> Result<(), Box> { let topic = domain_topic(name); let dht_lock = self.dht.lock().await; if let Some(ref dht) = *dht_lock { @@ -173,14 +178,18 @@ impl P2PManager { /// Look up a domain on the DHT. Returns the first peer that announced it. /// Before querying the DHT, checks the local domain store (localhost shortcut). - pub async fn resolve_domain(&self, name: &str) -> Result, Box> { + pub async fn resolve_domain( + &self, + name: &str, + ) -> Result, Box> { let name = name.trim().to_lowercase(); // Step 1: Check local domain store first (localhost shortcut). // If the domain is registered by any local instance, return it directly. if let Ok(Some(local_record)) = crate::database::get_domain_record(&name) { if let Some(port) = local_record.target_port { - self.resolved_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + self.resolved_count + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); log::info!("Resolved {}.dweb locally (port {})", name, port); return Ok(Some(PublishedSite { domain: name.clone(), @@ -215,18 +224,22 @@ impl P2PManager { ); if let Some(first) = response.peers.first() { - let peer_id = hex::encode(&*first.public_key); - let address = first.relay_addresses.first() + let peer_id = hex::encode(*first.public_key); + let address = first + .relay_addresses + .first() .map(|a| a.ip().to_string()) .unwrap_or_else(|| "0.0.0.0".to_string()); - let port = first.relay_addresses.first() - .map(|a| a.port()) - .unwrap_or(0); + let port = first.relay_addresses.first().map(|a| a.port()).unwrap_or(0); - self.resolved_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + self.resolved_count + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); log::info!( "Resolved {}.dweb on DHT → {}:{} (peer: {}…)", - name, address, port, &peer_id[..8] + name, + address, + port, + &peer_id[..8] ); return Ok(Some(PublishedSite { @@ -239,7 +252,10 @@ impl P2PManager { } } Ok(Some(Ok(None))) => { - log::debug!("DHT lookup for {}.dweb returned empty batch (no peers)", name); + log::debug!( + "DHT lookup for {}.dweb returned empty batch (no peers)", + name + ); } Ok(Some(Err(e))) => { log::warn!("DHT lookup error for {}.dweb: {}", name, e); @@ -258,7 +274,10 @@ impl P2PManager { /// DHT-only lookup that skips the local DB check. /// Used by domain::resolve() to avoid redundant DB lookups and prevent circular calls. - pub async fn resolve_domain_dht_only(&self, name: &str) -> Result, Box> { + pub async fn resolve_domain_dht_only( + &self, + name: &str, + ) -> Result, Box> { let name = name.trim().to_lowercase(); let topic = domain_topic(&name); let dht_lock = self.dht.lock().await; @@ -272,16 +291,23 @@ impl P2PManager { match timed_result { Ok(Some(Ok(Some(response)))) => { if let Some(first) = response.peers.first() { - let peer_id = hex::encode(&*first.public_key); - let address = first.relay_addresses.first() + let peer_id = hex::encode(*first.public_key); + let address = first + .relay_addresses + .first() .map(|a| a.ip().to_string()) .unwrap_or_else(|| "0.0.0.0".to_string()); - let port = first.relay_addresses.first() - .map(|a| a.port()) - .unwrap_or(0); + let port = first.relay_addresses.first().map(|a| a.port()).unwrap_or(0); - self.resolved_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - log::info!("DHT resolved {}.dweb → {}:{} (peer: {}…)", name, address, port, &peer_id[..8]); + self.resolved_count + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + log::info!( + "DHT resolved {}.dweb → {}:{} (peer: {}…)", + name, + address, + port, + &peer_id[..8] + ); return Ok(Some(PublishedSite { domain: name.clone(), @@ -313,9 +339,11 @@ impl P2PManager { pub fn status(&self) -> P2PStatus { let uptime = chrono::Utc::now() - self.started_at; P2PStatus { - connected_peers: 0, // TODO: expose from Dht node - routing_table_size: 0, // TODO: expose from Dht node - domains_resolved: self.resolved_count.load(std::sync::atomic::Ordering::Relaxed), + connected_peers: 0, // TODO: expose from Dht node + routing_table_size: 0, // TODO: expose from Dht node + domains_resolved: self + .resolved_count + .load(std::sync::atomic::Ordering::Relaxed), uptime_seconds: uptime.num_seconds().max(0) as u64, public_key: self.public_key_hex(), nat_status: "unknown".to_string(), @@ -345,7 +373,7 @@ pub async fn publish(name: &str, port: u16) -> Result Self { + pub fn load_or_create(data_dir: &Path) -> Self { let path = data_dir.join("identity.json"); if let Ok(content) = std::fs::read_to_string(&path) { if let Ok(id) = serde_json::from_str(&content) { @@ -50,7 +52,7 @@ impl InstanceIdentity { identity } - pub fn save(&self, data_dir: &PathBuf) -> Result<(), String> { + pub fn save(&self, data_dir: &Path) -> Result<(), String> { let path = data_dir.join("identity.json"); let content = serde_json::to_string_pretty(self).map_err(|e| format!("Serialize: {}", e))?; @@ -123,10 +125,7 @@ mod ffi { #[link(name = "kernel32")] unsafe extern "system" { - pub fn CreateJobObjectW( - lpJobAttributes: LPSECURITY_ATTRIBUTES, - lpName: LPCWSTR, - ) -> HANDLE; + pub fn CreateJobObjectW(lpJobAttributes: LPSECURITY_ATTRIBUTES, lpName: LPCWSTR) -> HANDLE; pub fn SetInformationJobObject( hJob: HANDLE, @@ -135,10 +134,7 @@ mod ffi { cbJobObjectInfoLength: DWORD, ) -> BOOL; - pub fn AssignProcessToJobObject( - hJob: HANDLE, - hProcess: HANDLE, - ) -> BOOL; + pub fn AssignProcessToJobObject(hJob: HANDLE, hProcess: HANDLE) -> BOOL; pub fn OpenProcess( dwDesiredAccess: DWORD, @@ -154,9 +150,12 @@ mod ffi { static JOB_HANDLE: Mutex> = Mutex::new(None); /// Initialize the sandbox subsystem. Creates/stores instance identity. -pub fn init(data_dir: &PathBuf) { +pub fn init(data_dir: &Path) { let identity = InstanceIdentity::load_or_create(data_dir); - log::info!("Sandbox initialized — identity: {}…", &identity.public_key[..8]); + log::info!( + "Sandbox initialized — identity: {}…", + &identity.public_key[..8] + ); } /// Initialize the service container (Job Object on Windows). @@ -214,9 +213,7 @@ pub fn init_service_container() { if let Ok(mut guard) = JOB_HANDLE.lock() { *guard = Some(ffi::JobHandle(job)); } - log::info!( - "Service container (Job Object) created — 512 MB limit, kill-on-close" - ); + log::info!("Service container (Job Object) created — 512 MB limit, kill-on-close"); } } diff --git a/src-tauri/src/stack.rs b/src-tauri/src/stack.rs index 4b6f6a0..543c3e6 100644 --- a/src-tauri/src/stack.rs +++ b/src-tauri/src/stack.rs @@ -36,7 +36,7 @@ pub enum ServiceState { } struct SpawnedProcess { - pid: u32, + pid: Option, child: Child, } @@ -51,22 +51,23 @@ static PROCESS_COUNT: AtomicUsize = AtomicUsize::new(0); static PROCESSES: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); -static SERVICES: LazyLock>> = - LazyLock::new(|| { - let mut m = HashMap::new(); - let known: Vec<(&str, ServiceType, u16)> = vec![ - ("Node.js", ServiceType::Runtime, 3000), - ("Python", ServiceType::Runtime, 8000), - ("PHP", ServiceType::Runtime, 8080), - ("Go", ServiceType::Runtime, 8080), - ("Ruby", ServiceType::Runtime, 3000), - ("MySQL", ServiceType::Database, 3306), - ("PostgreSQL", ServiceType::Database, 5432), - ("MongoDB", ServiceType::Database, 27017), - ("Redis", ServiceType::Database, 6379), - ]; - for (name, st, port) in known { - m.insert(name.to_string(), ManagedService { +static SERVICES: LazyLock>> = LazyLock::new(|| { + let mut m = HashMap::new(); + let known: Vec<(&str, ServiceType, u16)> = vec![ + ("Node.js", ServiceType::Runtime, 3000), + ("Python", ServiceType::Runtime, 8000), + ("PHP", ServiceType::Runtime, 8080), + ("Go", ServiceType::Runtime, 8080), + ("Ruby", ServiceType::Runtime, 3000), + ("MySQL", ServiceType::Database, 3306), + ("PostgreSQL", ServiceType::Database, 5432), + ("MongoDB", ServiceType::Database, 27017), + ("Redis", ServiceType::Database, 6379), + ]; + for (name, st, port) in known { + m.insert( + name.to_string(), + ManagedService { name: name.to_string(), service_type: st, port, @@ -77,10 +78,11 @@ static SERVICES: LazyLock>> = cpu: 0.0, memory: 0, started_at: None, - }); - } - Mutex::new(m) - }); + }, + ); + } + Mutex::new(m) +}); // ─── Public API ────────────────────────────────────────────────────────────── @@ -92,18 +94,21 @@ pub async fn add_service( auto_start: bool, ) -> Result<(), Box> { let mut services = SERVICES.lock().await; - services.insert(name.to_string(), ManagedService { - name: name.to_string(), - service_type, - port, - version: version.to_string(), - auto_start, - status: ServiceState::Stopped, - pid: None, - cpu: 0.0, - memory: 0, - started_at: None, - }); + services.insert( + name.to_string(), + ManagedService { + name: name.to_string(), + service_type, + port, + version: version.to_string(), + auto_start, + status: ServiceState::Stopped, + pid: None, + cpu: 0.0, + memory: 0, + started_at: None, + }, + ); Ok(()) } @@ -164,13 +169,15 @@ pub async fn start_service(name: &str) -> Result<(), Box> Ok(sp) => { let now = chrono::Utc::now().to_rfc3339(); svc.status = ServiceState::Running; - svc.pid = Some(sp.pid); + svc.pid = sp.pid; svc.started_at = Some(now); svc.cpu = 0.5; svc.memory = 50 * 1024 * 1024; PROCESS_COUNT.fetch_add(1, Ordering::SeqCst); - let _ = crate::sandbox::contain_process(sp.pid); + if let Some(pid) = sp.pid { + let _ = crate::sandbox::contain_process(pid); + } Ok((sp.pid, sp.child)) } @@ -181,7 +188,7 @@ pub async fn start_service(name: &str) -> Result<(), Box> } }; - let (pid, child) = spawned?; + let (_pid, mut child) = spawned?; let service_name = svc_name.clone(); let health_task = tokio::spawn(async move { @@ -217,11 +224,13 @@ pub async fn stop_service(name: &str) -> Result<(), Box> if cfg!(target_os = "windows") { let _ = Command::new("taskkill") .args(["/PID", &pid.to_string(), "/F"]) - .output().await; + .output() + .await; } else { let _ = Command::new("kill") .args(["-9", &pid.to_string()]) - .output().await; + .output() + .await; } } @@ -251,7 +260,9 @@ pub fn get_process_count() -> usize { // ─── Process Management ────────────────────────────────────────────────────── -async fn try_start_process(svc: &ManagedService) -> Result> { +async fn try_start_process( + svc: &ManagedService, +) -> Result> { match svc.name.as_str() { "Node.js" => { let check = Command::new("node").arg("--version").output().await; @@ -259,7 +270,7 @@ async fn try_start_process(svc: &ManagedService) -> Result Result { - let python_cmd = if Command::new("python3").arg("--version").output().await.is_ok() { + let python_cmd = if Command::new("python3") + .arg("--version") + .output() + .await + .is_ok() + { "python3" - } else if Command::new("python").arg("--version").output().await.is_ok() { + } else if Command::new("python") + .arg("--version") + .output() + .await + .is_ok() + { "python" } else { return Err("Python not found on PATH".into()); }; let port = svc.port; - let mut child = Command::new(python_cmd) + let child = Command::new(python_cmd) .args(["-m", "http.server", &port.to_string()]) .spawn() .map_err(|e| format!("Failed to spawn Python: {}", e))?; @@ -297,7 +318,7 @@ async fn try_start_process(svc: &ManagedService) -> Result Result { - Err(format!("Cannot auto-start '{}': no spawn handler defined", svc.name).into()) - } + _ => Err(format!("Cannot auto-start '{}': no spawn handler defined", svc.name).into()), } } @@ -382,12 +401,17 @@ pub async fn detect_databases() -> Result, BoxNUL", cmd)]) - .output().await + .args([ + "/c", + &format!("tasklist /FI \"IMAGENAME eq {}.exe\" 2>NUL", cmd), + ]) + .output() + .await } else { Command::new("sh") .args(["-c", &format!("pgrep -x {} 2>/dev/null", cmd)]) - .output().await + .output() + .await }; if let Ok(out) = output { diff --git a/src/App.tsx b/src/App.tsx index d71ef83..805d9dc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,6 @@ import { useState, useCallback } from "react"; import Sidebar from "./components/Sidebar"; +import { NotificationProvider, useNotifications } from "./components/Notifications"; import Dashboard from "./views/Dashboard"; import BrowserView from "./views/BrowserView"; import AIAgent from "./views/AIAgent"; @@ -10,10 +11,17 @@ import Integrations from "./views/Integrations"; import Docs from "./views/Docs"; import type { View } from "./types"; -function App() { +import P2PTransfer from "./views/P2PTransfer"; +import { + Plus, Radio, RefreshCw, Wifi, +} from "lucide-react"; + +function AppContent() { + const { addNotification } = useNotifications(); const [currentView, setCurrentView] = useState("dashboard"); const [browserUrl, setBrowserUrl] = useState(""); const [browserNavId, setBrowserNavId] = useState(0); + const [serverStatus, setServerStatus] = useState("checking"); /** Switch to the browser view and navigate to the given URL */ const handleOpenInBrowser = useCallback((url: string) => { @@ -25,13 +33,43 @@ function App() { /** Navigation handler for sidebar - clears pending external URL when switching to Browser */ const handleNavigate = useCallback((view: View) => { if (view === "browser") { - // Clear any pending external URL so BrowserView doesn't re-add a stale tab setBrowserUrl(""); setBrowserNavId(0); } setCurrentView(view); }, []); + /** Quick Actions */ + const quickActions = [ + { + label: "New Project", + icon: , + onClick: () => addNotification({ type: "info", title: "Coming Soon", message: "Project scaffolding will be available in v0.2.0" }), + }, + { + label: "P2P Connect", + icon: , + onClick: () => { setCurrentView("dashboard"); }, + }, + { + label: "Network Status", + icon: , + onClick: () => addNotification({ type: "info", title: "Network", message: "Relay running on port 49736", duration: 3000 }), + }, + { + label: "Refresh", + icon: , + onClick: () => window.location.reload(), + }, + ]; + + /** Check server health on mount */ + useState(() => { + fetch("/ping", { signal: AbortSignal.timeout(3000) }) + .then(r => r.json().then(d => { setServerStatus(d.status === "ok" ? "online" : "error"); })) + .catch(() => setServerStatus("offline")); + }); + const renderView = () => { switch (currentView) { case "dashboard": return ; @@ -42,17 +80,39 @@ function App() { case "repositories": return ; case "docs": return ; case "settings": return ; + case "p2p-transfer": return ; } }; return (
-
- {renderView()} -
+
+ {/* Quick Actions Toolbar */} +
+ {quickActions.map((action, i) => ( + + ))} +
+
+ + {serverStatus === "online" ? "Server Online" : serverStatus === "offline" ? "Offline" : "Checking..."} +
+
+
+ {renderView()} +
+
); } -export default App; +export default function App() { + return ( + + + + ); +} diff --git a/src/__tests__/server-api.test.ts b/src/__tests__/server-api.test.ts new file mode 100644 index 0000000..79dc67f --- /dev/null +++ b/src/__tests__/server-api.test.ts @@ -0,0 +1,221 @@ +// @vitest-environment node +// Server API integration tests — test the HTTP endpoints directly + +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import http from "http"; +import { fork, ChildProcess } from "child_process"; +import path from "path"; + +let serverProcess: ChildProcess | null = null; +let baseUrl = ""; + +beforeAll(async () => { + // Start the dweb server on a random high port for testing + const testPort = 41234 + Math.floor(Math.random() * 1000); + + serverProcess = fork(path.resolve(__dirname, "..", "..", "server", "index.cjs"), [], { + env: { + ...process.env, + PORT: String(testPort), + RELAY_PORT: String(testPort + 10), + TCP_PORT: String(testPort + 20), + MODE: "isolated", + NAME: "dweb-test", + }, + stdio: ["pipe", "pipe", "pipe", "ipc"], + }); + + // Wait for the server to start + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("Server start timeout")), 10000); + + serverProcess!.stdout?.on("data", (data: Buffer) => { + const output = data.toString(); + // Server prints port info early in startup + if (output.includes("Ports:")) { + clearTimeout(timeout); + // Give it a moment to fully start + setTimeout(resolve, 1500); + } + }); + + serverProcess!.on("error", (err) => { + clearTimeout(timeout); + reject(err); + }); + + serverProcess!.on("exit", (code) => { + clearTimeout(timeout); + if (code !== 0) reject(new Error(`Server exited with code ${code}`)); + }); + }); + + baseUrl = `http://127.0.0.1:${testPort}`; +}); + +afterAll(() => { + if (serverProcess) { + serverProcess.kill("SIGINT"); + serverProcess = null; + } +}); + +function fetchUrl(pathname: string, method = "GET", body?: any): Promise<{ status: number; data: any }> { + return new Promise((resolve, reject) => { + const url = `${baseUrl}${pathname}`; + const bodyStr = body ? JSON.stringify(body) : undefined; + const req = http.request(url, { + method, + headers: bodyStr ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(bodyStr) } : {}, + }, (res) => { + let data = ""; + res.on("data", (chunk: Buffer) => { data += chunk.toString(); }); + res.on("end", () => { + try { + resolve({ status: res.statusCode || 500, data: JSON.parse(data) }); + } catch { + resolve({ status: res.statusCode || 500, data }); + } + }); + }); + req.on("error", reject); + req.setTimeout(5000, () => { req.destroy(); reject(new Error("Request timeout")); }); + if (bodyStr) req.write(bodyStr); + req.end(); + }); +} + +describe("Server API", () => { + it("should respond to /ping with health check", async () => { + const { status, data } = await fetchUrl("/ping"); + expect(status).toBe(200); + expect(data.status).toBe("ok"); + expect(data.server).toBe("dweb"); + expect(data).toHaveProperty("id"); + expect(data).toHaveProperty("uptime"); + expect(data).toHaveProperty("mode"); + }); + + it("should respond to /status with full instance status", async () => { + const { status, data } = await fetchUrl("/status"); + expect(status).toBe(200); + expect(data.status).toBe("ok"); + expect(data).toHaveProperty("serverId"); + expect(data).toHaveProperty("peersOnline"); + expect(data).toHaveProperty("modes"); + expect(data).toHaveProperty("memory"); + expect(data.memory).toHaveProperty("rss"); + expect(data.memory).toHaveProperty("heapUsed"); + }); + + it("should respond to /dweb-status with peer info", async () => { + const { status, data } = await fetchUrl("/dweb-status"); + expect(status).toBe(200); + expect(data.status).toBe("ok"); + expect(data).toHaveProperty("peerId"); + expect(data).toHaveProperty("localIPs"); + expect(data).toHaveProperty("mode"); + expect(data).toHaveProperty("services"); + expect(data.services).toContain("frontend"); + }); + + it("should register and discover peers", async () => { + // Register a test peer + const { status: regStatus, data: regData } = await fetchUrl("/register", "POST", { + id: "test-peer-001", + hostname: "test-host", + platform: process.platform, + version: "0.1.0", + address: "10.0.0.1", + port: 9999, + mode: "p2p-visible", + }); + expect(regStatus).toBe(201); + expect(regData.status).toBe("ok"); + expect(regData.action).toBe("registered"); + expect(regData.peerId).toBe("test-peer-001"); + + // Discover peers + const { status: discStatus, data: discData } = await fetchUrl("/discover"); + expect(discStatus).toBe(200); + expect(discData.status).toBe("ok"); + expect(discData.peers.length).toBeGreaterThanOrEqual(1); + const found = discData.peers.find((p: any) => p.id === "test-peer-001"); + expect(found).toBeDefined(); + expect(found.hostname).toBe("test-host"); + }); + + it("should handle signaling between peers", async () => { + // Register second peer + await fetchUrl("/register", "POST", { + id: "test-peer-002", hostname: "peer2", mode: "p2p-visible", + }); + + // Send signal + const { status: sigStatus, data: sigData } = await fetchUrl("/signal", "POST", { + fromPeerId: "test-peer-001", + targetPeerId: "test-peer-002", + type: "offer", + sdp: "test-sdp-data", + }); + expect(sigStatus).toBe(200); + expect(sigData.queued).toBe(true); + + // Receive signals + const { status: recvStatus, data: recvData } = await fetchUrl("/signal?peerId=test-peer-002"); + expect(recvStatus).toBe(200); + expect(recvData.signals.length).toBeGreaterThanOrEqual(1); + expect(recvData.signals[0].fromPeerId).toBe("test-peer-001"); + expect(recvData.signals[0].type).toBe("offer"); + }); + + it("should handle collaboration services", async () => { + // List services (should have default services) + const { status: listStatus, data: listData } = await fetchUrl("/collab/services"); + expect(listStatus).toBe(200); + expect(listData.services.length).toBeGreaterThanOrEqual(2); + const names = listData.services.map((s: any) => s.name); + expect(names).toContain("My Static Website"); + expect(names).toContain("File Share"); + + // Add a new service + const { status: addStatus, data: addData } = await fetchUrl("/collab/services", "POST", { + name: "Test Service", + type: "Web App", + port: 3000, + }); + expect(addStatus).toBe(201); + expect(addData.service.name).toBe("Test Service"); + }); + + it("should handle file share API", async () => { + // List files (empty initially) + const { status, data } = await fetchUrl("/fileshare/api/list"); + expect(status).toBe(200); + expect(data.status).toBe("ok"); + expect(Array.isArray(data.files)).toBe(true); + }); + + it("should handle CORS preflight", async () => { + const { status } = await new Promise<{ status: number }>((resolve, reject) => { + const req = http.request(`${baseUrl}/ping`, { + method: "OPTIONS", + headers: { + "Origin": "http://example.com", + "Access-Control-Request-Method": "GET", + }, + }, (res) => { + resolve({ status: res.statusCode || 500 }); + }); + req.on("error", reject); + req.end(); + }); + expect(status).toBe(204); + }); + + it("should serve index.html fallback for unknown routes (SPA)", async () => { + const { status } = await fetchUrl("/nonexistent-route-12345"); + // SPA fallback: serves dist/index.html for unknown routes + expect(status).toBe(200); + }); +}); diff --git a/src/components/Notifications.tsx b/src/components/Notifications.tsx new file mode 100644 index 0000000..be9bf92 --- /dev/null +++ b/src/components/Notifications.tsx @@ -0,0 +1,84 @@ +import { useState, useCallback, createContext, useContext, type ReactNode } from "react"; +import { X, Info, CheckCircle2, AlertTriangle, AlertCircle } from "lucide-react"; +import type { Notification } from "../types"; + +/* ─── Context ──────────────────────────────────────────────── */ +interface NotificationContextType { + notifications: Notification[]; + addNotification: (n: Omit) => string; + removeNotification: (id: string) => void; + clearAll: () => void; +} + +const NotificationContext = createContext({ + notifications: [], + addNotification: () => "", + removeNotification: () => {}, + clearAll: () => {}, +}); + +export function useNotifications() { + return useContext(NotificationContext); +} + +/* ─── Provider ──────────────────────────────────────────────── */ +const ICONS = { + info: , + success: , + warning: , + error: , +}; + +export function NotificationProvider({ children }: { children: ReactNode }) { + const [notifications, setNotifications] = useState([]); + + const removeNotification = useCallback((id: string) => { + setNotifications(prev => + prev.map(n => (n.id === id ? { ...n, duration: -1 } : n)) + ); + setTimeout(() => { + setNotifications(prev => prev.filter(n => n.id !== id)); + }, 300); + }, []); + + const addNotification = useCallback((n: Omit) => { + const id = `notif-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + const notification: Notification = { ...n, id, timestamp: Date.now() }; + setNotifications(prev => [...prev, notification]); + + const duration = n.duration ?? 5000; + if (duration > 0) { + setTimeout(() => removeNotification(id), duration); + } + return id; + }, [removeNotification]); + + const clearAll = useCallback(() => { + setNotifications([]); + }, []); + + return ( + + {children} +
+ {notifications.map(n => ( +
+ {ICONS[n.type]} +
+
{n.title}
+
{n.message}
+ {n.action && ( +
+ +
+ )} +
+ +
+ ))} +
+
+ ); +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 9ea3890..8031b1d 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -10,7 +10,7 @@ interface SidebarProps { const navItems: { id: View; label: string; icon: React.ReactNode; badge?: string }[] = [ { id: "dashboard", label: "Dashboard", icon: }, { id: "browser", label: "Browser", icon: }, - { id: "ai-agent", label: "AI Agent", icon: , badge: "NEW" }, + { id: "ai-agent", label: "AI Agent", icon: }, { id: "domains", label: "Domains", icon: }, { id: "docs", label: "Docs", icon: }, { id: "settings", label: "Settings", icon: }, diff --git a/src/styles/global.css b/src/styles/global.css index 3518bac..e90190c 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -47,6 +47,14 @@ --radius-lg: 16px; --radius-xl: 20px; + /* Typography Scale */ + --fs-xs: 11px; /* tiny labels, badges */ + --fs-sm: 12px; /* secondary text, timestamps */ + --fs-base: 14px; /* body text */ + --fs-md: 15px; /* emphasized body */ + --fs-lg: 18px; /* subheadings */ + --fs-xl: 24px; /* headings */ + /* Shadows */ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); --shadow: 0 4px 12px rgba(0, 0, 0, 0.4); @@ -76,7 +84,8 @@ body { background: var(--bg-base); color: var(--text-primary); -webkit-font-smoothing: antialiased; - line-height: 1.5; + line-height: 1.6; + font-size: 15px; } /* ─── App Layout ─────────────────────────────────────────── */ @@ -86,6 +95,23 @@ body { overflow: hidden; } +.main-area { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.loading-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 20px; + gap: 12px; + color: var(--text-muted); +} + /* ─── Sidebar ────────────────────────────────────────────── */ .sidebar { width: var(--sidebar-width); @@ -179,7 +205,7 @@ body { background: var(--bg-glass); padding: 1px 6px; border-radius: 10px; - font-size: 11px; + font-size: 12px; } /* ─── Sidebar Nav ────────────────────────────────────────── */ @@ -235,7 +261,7 @@ body { } .nav-badge { - font-size: 10px; + font-size: 12px; font-weight: 600; padding: 1px 6px; border-radius: 10px; @@ -247,7 +273,7 @@ body { .sidebar-footer { padding: 12px 16px; border-top: 1px solid var(--border-subtle); - font-size: 11px; + font-size: 12px; color: var(--text-muted); } @@ -257,7 +283,7 @@ body { .sidebar-node-id { font-family: 'SF Mono', 'Fira Code', monospace; - font-size: 10px; + font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -279,9 +305,11 @@ body { .view-container { padding: 24px 32px; - height: 100%; + min-height: 100%; display: flex; flex-direction: column; + overflow-y: auto; + scroll-behavior: smooth; } /* Inner wrapper for views that benefit from max-width constraint */ @@ -308,7 +336,7 @@ body { } .text-muted-sm { - font-size: 13px; + font-size: 14px; color: var(--text-muted); } @@ -392,7 +420,7 @@ body { position: relative; padding-left: 16px; margin-bottom: 6px; - font-size: 13px; + font-size: 14px; line-height: 1.6; color: var(--text-secondary); } @@ -415,7 +443,7 @@ body { .doc-table { width: 100%; border-collapse: collapse; - font-size: 13px; + font-size: 14px; } .doc-table th { @@ -449,7 +477,7 @@ body { } .text-muted { - font-size: 13px; + font-size: 14px; color: var(--text-secondary); } @@ -468,7 +496,7 @@ body { padding: 8px 16px; border: none; border-radius: var(--radius); - font-size: 13px; + font-size: 14px; font-weight: 500; cursor: pointer; transition: all var(--duration) var(--ease); @@ -563,7 +591,7 @@ body { .btn-xs { padding: 2px 4px; - font-size: 11px; + font-size: 12px; } /* ─── Stats Grid ─────────────────────────────────────────── */ @@ -628,7 +656,7 @@ body { } .stat-sub { - font-size: 11px; + font-size: 12px; color: var(--text-muted); } @@ -651,7 +679,7 @@ body { border: none; background: transparent; color: var(--text-muted); - font-size: 13px; + font-size: 14px; font-weight: 500; cursor: pointer; border-radius: 6px; @@ -684,16 +712,49 @@ body { flex: 1; } -/* ─── Glass ──────────────────────────────────────────────── */ +/* ─── Glass / Glossy ───────────────────────────────────────── */ .glass { background: var(--bg-glass); border: 1px solid var(--border); - backdrop-filter: blur(12px); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + box-shadow: 0 2px 16px rgba(0,0,0,0.12), inset 0 1px 0 rgba(255,255,255,0.06); } .glass-sm { background: var(--bg-glass); border: 1px solid var(--border-subtle); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + box-shadow: 0 1px 8px rgba(0,0,0,0.08), inset 0 1px 0 rgba(255,255,255,0.04); +} + +.glossy-card { + background: linear-gradient(135deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02)); + border: 1px solid var(--border); + border-radius: var(--radius); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + box-shadow: + 0 4px 24px rgba(0,0,0,0.15), + inset 0 1px 0 rgba(255,255,255,0.08), + inset 0 -1px 0 rgba(255,255,255,0.03); + transition: box-shadow 0.2s, border-color 0.2s; +} +.glossy-card:hover { + border-color: rgba(255,255,255,0.12); + box-shadow: + 0 6px 32px rgba(0,0,0,0.2), + inset 0 1px 0 rgba(255,255,255,0.1), + inset 0 -1px 0 rgba(255,255,255,0.03); +} + +.glossy-header { + background: linear-gradient(135deg, rgba(255,255,255,0.08), rgba(255,255,255,0.02)); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-bottom: 1px solid rgba(255,255,255,0.06); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.1); } /* ─── Service Grid ────────────────────────────────────────── */ @@ -764,7 +825,7 @@ body { } .service-status-badge { - font-size: 11px; + font-size: 12px; padding: 1px 8px; border-radius: 10px; } @@ -913,7 +974,7 @@ body { } .pill-type { - font-size: 13px; + font-size: 14px; color: var(--text-muted); display: none; } @@ -923,7 +984,7 @@ body { } .pill-port { - font-size: 13px; + font-size: 14px; color: var(--text-muted); font-family: var(--font-mono, monospace); } @@ -973,6 +1034,28 @@ body { background: rgba(34, 197, 94, 0.1); } +.pill-badge { + display: inline-flex; + align-items: center; + padding: 1px 6px; + border-radius: 3px; + font-size: 9px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.3px; + background: rgba(99, 102, 241, 0.1); + color: #818cf8; + border: 1px solid rgba(99, 102, 241, 0.15); + margin-left: 4px; + white-space: nowrap; +} + +.pill-badge.pill-p2p { + background: rgba(34, 197, 94, 0.08); + color: #4ade80; + border: 1px solid rgba(34, 197, 94, 0.15); +} + /* ─── Runtime Detection (compact chips) ──────────────────── */ .runtimes-compact { display: flex; @@ -1010,7 +1093,7 @@ body { } .runtime-chip-ver { - font-size: 10px; + font-size: 12px; color: var(--text-muted); } @@ -1121,13 +1204,13 @@ body { } .peer-id { - font-size: 13px; + font-size: 14px; font-weight: 500; font-family: 'SF Mono', 'Fira Code', monospace; } .peer-addr { - font-size: 11px; + font-size: 12px; color: var(--text-muted); } @@ -1138,7 +1221,7 @@ body { } .peer-protocol { - font-size: 10px; + font-size: 12px; text-transform: uppercase; padding: 1px 6px; border-radius: 4px; @@ -1203,12 +1286,12 @@ body { } .activity-msg { - font-size: 13px; + font-size: 14px; line-height: 1.4; } .activity-time { - font-size: 11px; + font-size: 12px; color: var(--text-muted); } @@ -1299,7 +1382,7 @@ body { border: none; background: transparent; color: var(--text-primary); - font-size: 13px; + font-size: 14px; outline: none; font-family: 'SF Mono', 'Fira Code', monospace; } @@ -1313,7 +1396,7 @@ body { align-items: center; gap: 4px; color: var(--success); - font-size: 11px; + font-size: 12px; font-weight: 600; } @@ -1334,7 +1417,7 @@ body { } .bookmarks-header h4 { - font-size: 13px; + font-size: 14px; color: var(--text-secondary); } @@ -1364,11 +1447,11 @@ body { } .bm-title { - font-size: 13px; + font-size: 14px; } .bm-url { - font-size: 11px; + font-size: 12px; color: var(--text-muted); font-family: 'SF Mono', monospace; } @@ -1427,7 +1510,7 @@ body { .dweb-status-badge { padding: 2px 10px; border-radius: 12px; - font-size: 11px; + font-size: 12px; font-weight: 600; } @@ -1448,7 +1531,7 @@ body { align-items: center; gap: 8px; margin-bottom: 8px; - font-size: 13px; + font-size: 14px; } .meta-row:last-child { margin-bottom: 0; } @@ -1479,7 +1562,7 @@ body { } .dweb-proxy-info p { - font-size: 13px; + font-size: 14px; color: var(--text-secondary); margin-bottom: 12px; } @@ -1527,7 +1610,7 @@ body { } .chat-welcome p { - font-size: 13px; + font-size: 14px; max-width: 400px; line-height: 1.5; } @@ -1613,7 +1696,7 @@ body { align-self: center; background: rgba(139, 92, 246, 0.06); border: 1px solid rgba(139, 92, 246, 0.1); - font-size: 13px; + font-size: 14px; color: var(--text-muted); } @@ -1635,7 +1718,7 @@ body { } .msg-content { - font-size: 13px; + font-size: 14px; line-height: 1.5; } @@ -1645,7 +1728,7 @@ body { } .template-gallery h4 { - font-size: 13px; + font-size: 14px; color: var(--text-secondary); margin-bottom: 12px; } @@ -1667,7 +1750,7 @@ body { cursor: pointer; text-align: left; transition: all var(--duration) var(--ease); - font-size: 13px; + font-size: 14px; } .template-card:hover { @@ -1687,13 +1770,13 @@ body { } .template-desc { - font-size: 11px; + font-size: 12px; color: var(--text-muted); line-height: 1.4; } .template-stack { - font-size: 10px; + font-size: 12px; color: var(--accent-blue); font-weight: 500; } @@ -1719,7 +1802,7 @@ body { border-radius: var(--radius); padding: 10px 14px; color: var(--text-primary); - font-size: 13px; + font-size: 14px; outline: none; transition: border-color var(--duration) var(--ease); } @@ -1768,7 +1851,7 @@ body { } .register-body > p { - font-size: 13px; + font-size: 14px; color: var(--text-muted); margin-bottom: 16px; } @@ -1869,7 +1952,7 @@ body { } .domain-status { - font-size: 11px; + font-size: 12px; padding: 1px 8px; border-radius: 10px; } @@ -1923,7 +2006,7 @@ body { border-radius: var(--radius); padding: 10px 14px 10px 38px; color: var(--text-primary); - font-size: 13px; + font-size: 14px; outline: none; transition: border-color var(--duration) var(--ease); } @@ -1987,7 +2070,7 @@ body { border: none; background: transparent; color: var(--text-muted); - font-size: 13px; + font-size: 14px; font-weight: 500; cursor: pointer; border-radius: var(--radius-sm); @@ -2098,7 +2181,7 @@ body { border-radius: var(--radius-sm); padding: 8px 12px; color: var(--text-primary); - font-size: 13px; + font-size: 14px; outline: none; font-family: 'SF Mono', monospace; transition: border-color var(--duration) var(--ease); @@ -2119,7 +2202,7 @@ body { border-radius: var(--radius-sm); padding: 8px 12px; color: var(--text-primary); - font-size: 13px; + font-size: 14px; outline: none; cursor: pointer; min-width: 180px; @@ -2195,7 +2278,7 @@ body { .status-item { display: flex; justify-content: space-between; - font-size: 13px; + font-size: 14px; color: var(--text-muted); } @@ -2221,7 +2304,7 @@ body { .stat-item { display: flex; justify-content: space-between; - font-size: 13px; + font-size: 14px; color: var(--text-muted); } @@ -2235,7 +2318,7 @@ body { display: flex; align-items: center; gap: 4px; - font-size: 13px; + font-size: 14px; color: var(--success); } @@ -2249,6 +2332,11 @@ body { to { transform: rotate(360deg); } } +@keyframes blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} + @keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } @@ -2263,6 +2351,10 @@ body { animation: spin 0.8s linear infinite; } +.blink { + animation: blink 1s step-end infinite; +} + /* ─── Scrollbar ──────────────────────────────────────────── */ ::-webkit-scrollbar { width: 6px; @@ -2359,7 +2451,7 @@ body { font-weight: 600; } .provider-config-title .text-muted-sm { - font-size: 11px; + font-size: 12px; } .provider-config-actions { @@ -2418,7 +2510,7 @@ body { gap: 2px; } .selector-group label { - font-size: 10px; + font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; @@ -2427,7 +2519,7 @@ body { .selector-group .select-input { min-width: 160px; padding: 6px 10px; - font-size: 13px; + font-size: 14px; } .selector-status { @@ -2466,7 +2558,7 @@ body { display: flex; align-items: center; gap: 4px; - font-size: 11px; + font-size: 12px; font-weight: 600; white-space: nowrap; padding: 4px 8px; @@ -2530,7 +2622,7 @@ body { color: var(--text-primary); cursor: pointer; border-radius: 6px; - font-size: 13px; + font-size: 14px; text-align: left; } .mode-option:hover { @@ -2549,7 +2641,7 @@ body { font-weight: 500; } .mode-option-desc { - font-size: 11px; + font-size: 12px; color: var(--text-muted); } @@ -2563,7 +2655,7 @@ body { align-items: center; gap: 6px; padding: 6px 10px; - font-size: 11px; + font-size: 12px; color: var(--text-muted); border-top: 1px solid var(--border-subtle); margin-top: 4px; @@ -2622,12 +2714,12 @@ body { } .mode-radio-label { display: block; - font-size: 13px; + font-size: 14px; font-weight: 500; } .mode-radio-desc { display: block; - font-size: 11px; + font-size: 12px; color: var(--text-muted); } .online-toggle-card .card-footer { @@ -2648,7 +2740,7 @@ body { background: rgba(139, 92, 246, 0.08); border: 1px solid rgba(139, 92, 246, 0.15); border-radius: 20px; - font-size: 11px; + font-size: 12px; font-family: 'SF Mono', monospace; color: var(--accent-purple); cursor: help; @@ -2702,7 +2794,7 @@ body { } .security-info-header h4 { - font-size: 13px; + font-size: 14px; font-weight: 600; flex: 1; } @@ -2720,7 +2812,7 @@ body { } .security-label { - font-size: 10px; + font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); @@ -2734,7 +2826,7 @@ body { } .security-badge { - font-size: 11px; + font-size: 12px; font-weight: 600; padding: 2px 8px; border-radius: 10px; @@ -2761,7 +2853,7 @@ body { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border-subtle); - font-size: 11px; + font-size: 12px; color: var(--text-muted); } @@ -2880,7 +2972,7 @@ body { } .badge { - font-size: 11px; + font-size: 12px; font-weight: 600; padding: 3px 10px; border-radius: 12px; @@ -2920,7 +3012,7 @@ body { width: 100%; border-collapse: separate; border-spacing: 0; - font-size: 13px; + font-size: 14px; border-radius: var(--radius); overflow: hidden; border: 1px solid var(--border-subtle); @@ -2966,7 +3058,7 @@ body { } .welcome-footer-text p { - font-size: 13px; + font-size: 14px; color: var(--text-muted); margin-bottom: 12px; } @@ -3025,12 +3117,12 @@ body { } .tutorial-meta { - font-size: 11px; + font-size: 12px; color: var(--text-muted); } .tutorial-desc { - font-size: 13px; + font-size: 14px; color: var(--text-secondary); margin-bottom: 12px; line-height: 1.5; @@ -3059,7 +3151,7 @@ body { display: flex; align-items: center; justify-content: center; - font-size: 10px; + font-size: 12px; font-weight: 600; color: var(--text-muted); flex-shrink: 0; @@ -3069,7 +3161,7 @@ body { display: flex; align-items: center; gap: 6px; - font-size: 13px; + font-size: 14px; font-weight: 500; color: var(--accent-blue); } @@ -3107,7 +3199,7 @@ body { } .step-number { - font-size: 11px; + font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; @@ -3123,7 +3215,7 @@ body { } .tutorial-step-card p { - font-size: 13px; + font-size: 14px; color: var(--text-secondary); line-height: 1.6; margin-bottom: 12px; @@ -3177,7 +3269,7 @@ body { } .dweb-error-card p { - font-size: 13px; + font-size: 14px; color: var(--text-secondary); margin-bottom: 16px; } @@ -3187,7 +3279,7 @@ body { } .dweb-recommendations h4 { - font-size: 13px; + font-size: 14px; color: var(--text-secondary); margin-bottom: 8px; } @@ -3201,7 +3293,7 @@ body { } .dweb-recommendations li { - font-size: 13px; + font-size: 14px; color: var(--text-muted); padding: 6px 10px; background: var(--bg-glass); @@ -3226,7 +3318,7 @@ body { display: inline-flex; align-items: center; gap: 4px; - font-size: 11px; + font-size: 12px; padding: 1px 8px; border-radius: 10px; border: 1px solid; @@ -3234,7 +3326,7 @@ body { } .register-body > p .tier-badge { margin-right: 6px; - font-size: 10px; + font-size: 12px; } /* ─── Register Tier Selector ──────────────────────────────── */ @@ -3282,7 +3374,7 @@ body { .tier-option-price { margin-left: auto; font-family: 'SF Mono', monospace; - font-size: 11px; + font-size: 12px; opacity: 0.8; } .tier-option-content { @@ -3292,7 +3384,7 @@ body { width: 100%; } .tier-option-desc { - font-size: 11px; + font-size: 12px; color: var(--text-muted); } @@ -3306,7 +3398,7 @@ body { gap: 8px; padding: 10px 16px; border-radius: var(--radius); - font-size: 13px; + font-size: 14px; z-index: 10000; animation: toastIn 0.3s ease; background: var(--bg-elevated); @@ -3400,7 +3492,7 @@ body { } .form-group label { display: block; - font-size: 13px; + font-size: 14px; font-weight: 500; color: var(--text-secondary); margin-bottom: 6px; @@ -3414,7 +3506,7 @@ body { border-radius: var(--radius-sm); padding: 9px 12px; color: var(--text-primary); - font-size: 13px; + font-size: 14px; outline: none; transition: border-color var(--duration) var(--ease); } @@ -3431,7 +3523,7 @@ body { color: var(--text-primary); } .form-hint { - font-size: 11px; + font-size: 12px; color: var(--text-muted); margin-top: 4px; } @@ -3464,3 +3556,124 @@ body { .spin { animation: spin 0.8s linear infinite; } + +/* ─── Notification Toast System ──────────────────────────── */ +.toast-container { + position: fixed; + top: 16px; + right: 16px; + z-index: 10000; + display: flex; + flex-direction: column; + gap: 8px; + pointer-events: none; +} +.toast { + pointer-events: auto; + display: flex; + align-items: flex-start; + gap: 10px; + padding: 12px 16px; + border-radius: var(--radius-md); + background: var(--bg-elevated); + border: 1px solid var(--border); + box-shadow: 0 8px 24px rgba(0,0,0,0.3); + min-width: 320px; + max-width: 420px; + animation: toastSlideIn 0.3s ease-out; + backdrop-filter: blur(12px); +} +.toast.toast-info { border-left: 3px solid var(--accent-blue); } +.toast.toast-success { border-left: 3px solid #22c55e; } +.toast.toast-warning { border-left: 3px solid #f59e0b; } +.toast.toast-error { border-left: 3px solid #ef4444; } +.toast-icon { flex-shrink: 0; margin-top: 2px; } +.toast-content { flex: 1; min-width: 0; } +.toast-title { font-size: 13px; font-weight: 600; color: var(--text-primary); margin-bottom: 2px; } +.toast-message { font-size: 12px; color: var(--text-secondary); line-height: 1.4; } +.toast-close { flex-shrink: 0; background: none; border: none; color: var(--text-muted); cursor: pointer; padding: 2px; border-radius: 4px; } +.toast-close:hover { color: var(--text-primary); background: var(--bg-glass); } +.toast-action { margin-top: 6px; } +.toast-action button { font-size: 11px; padding: 4px 10px; border-radius: var(--radius-sm); background: var(--accent-blue); color: white; border: none; cursor: pointer; } +.toast-action button:hover { opacity: 0.9; } +.toast-exit { animation: toastSlideOut 0.3s ease-in forwards; } +@keyframes toastSlideIn { + from { transform: translateX(100%); opacity: 0; } + to { transform: translateX(0); opacity: 1; } +} +@keyframes toastSlideOut { + from { transform: translateX(0); opacity: 1; } + to { transform: translateX(100%); opacity: 0; } +} + +/* ─── P2P Dashboard ───────────────────────────────────────── */ +.p2p-dashboard { display: flex; flex-direction: column; gap: 20px; } +.p2p-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; } +.p2p-stat-card { padding: 16px; border-radius: var(--radius-md); background: var(--bg-elevated); border: 1px solid var(--border); display: flex; flex-direction: column; gap: 4px; } +.p2p-stat-label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; } +.p2p-stat-value { font-size: 24px; font-weight: 700; color: var(--text-primary); } +.p2p-stat-sub { font-size: 12px; color: var(--text-secondary); } +.p2p-peer-list { display: flex; flex-direction: column; gap: 8px; } +.p2p-peer-card { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-radius: var(--radius-md); background: var(--bg-elevated); border: 1px solid var(--border); transition: all 0.2s; } +.p2p-peer-card:hover { border-color: var(--accent-blue); } +.p2p-peer-status { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } +.p2p-peer-status.online { background: #22c55e; box-shadow: 0 0 6px rgba(34,197,94,0.5); } +.p2p-peer-status.offline { background: #6b7280; } +.p2p-peer-info { flex: 1; min-width: 0; } +.p2p-peer-name { font-size: 13px; font-weight: 600; color: var(--text-primary); } +.p2p-peer-detail { font-size: 11px; color: var(--text-muted); } +.p2p-peer-latency { font-size: 12px; color: var(--text-secondary); font-variant-numeric: tabular-nums; } +.p2p-network-vis { width: 100%; height: 200px; border-radius: var(--radius-md); background: var(--bg-base); border: 1px solid var(--border); position: relative; overflow: hidden; } + +/* ─── Quick Actions Toolbar ───────────────────────────────── */ +.quick-actions-toolbar { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: var(--bg-elevated); + border-bottom: 1px solid var(--border); + overflow-x: auto; + white-space: nowrap; +} +.quick-actions-toolbar::-webkit-scrollbar { height: 2px; } +.quick-actions-toolbar::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } +.quick-action-btn { + display: flex; + align-items: center; + gap: 5px; + padding: 5px 10px; + font-size: 12px; + font-weight: 500; + color: var(--text-secondary); + background: transparent; + border: 1px solid transparent; + border-radius: var(--radius-sm); + cursor: pointer; + transition: all 0.15s; +} +.quick-action-btn:hover { color: var(--text-primary); background: var(--bg-glass); border-color: var(--border); } +.quick-action-btn:active { transform: scale(0.97); } +.quick-action-separator { width: 1px; height: 20px; background: var(--border); margin: 0 4px; } +.quick-action-status { + margin-left: auto; + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text-muted); +} +.quick-action-status .status-dot { + width: 6px; height: 6px; border-radius: 50%; +} +.quick-action-status .status-dot.online { background: #22c55e; } +.quick-action-status .status-dot.offline { background: #6b7280; } + +/* ─── Section Header with Actions ─────────────────────────── */ +.section-header-actions { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} +.section-header-actions h3 { margin: 0; font-size: 16px; } diff --git a/src/types.ts b/src/types.ts index 14fac83..52800bb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,6 +7,8 @@ export interface Service { port: number; cpu: number; memory: number; + url?: string; + dir?: string; } export type DomainTier = "free" | "premium" | "business"; @@ -35,6 +37,8 @@ export interface DomainRecord { auto_renew: boolean; active: boolean; paid_until: string | null; + path?: string; + expires?: string; } export interface ServiceBinding { @@ -111,16 +115,6 @@ export interface StreamToken { done: boolean; } -export interface SandboxStatus { - data_dir: string; - instance_port: number; - instance_label: string; - public_key: string; - service_container_active: boolean; - process_count: number; - platform: string; -} - export interface GenerationResult { content: string; model: string; @@ -343,39 +337,53 @@ export const githubApi = { /* ─── App View Type ────────────────────────────────────────── */ -export type View = "dashboard" | "browser" | "ai-agent" | "domains" | "repositories" | "settings" | "integrations" | "docs"; +export type View = "dashboard" | "browser" | "ai-agent" | "domains" | "repositories" | "settings" | "integrations" | "docs" | "p2p-transfer"; -/* ─── Browser Tab Types ──────────────────────────────────── */ +/* ─── P2P Network Types ──────────────────────────────────── */ -export interface BrowserTab { +export interface P2PPeer { id: string; - url: string; - title: string; - contentHtml: string; - loading: boolean; - history: string[]; - historyIndex: number; - scrollPosition: number; - resolvedDomain: DomainRecord | null; - createdAt: number; + publicKey: string; + address: string; + port: number; + hostname: string; + platform: string; + version: string; + mode: string; + services: string[]; + relayPort: number; + age: number; + connected: boolean; + latency?: number; + lastSeen?: number; } -/* ─── Getting Started Tutorial ───────────────────────────── */ - -export interface Tutorial { - id: string; - title: string; - description: string; - difficulty: "beginner" | "intermediate" | "advanced"; - stack: string; - estimatedTime: string; - steps: TutorialStep[]; +export interface P2PNetworkStatus { + peerId: string; + hostname: string; + platform?: string; + localIPs: string[]; + port: number; + relayPort: number; + mode: string; + uptime: number; + relayConnected: boolean; + upstreamRelay: string | null; + peersOnline: number; + hostedServices: number; + sharedSessions: number; + services: string[]; + relayError: string | null; } -export interface TutorialStep { +export interface Notification { + id: string; + type: "info" | "success" | "warning" | "error"; title: string; - content: string; - code?: string; + message: string; + timestamp: number; + duration?: number; + action?: { label: string; onClick: () => void }; } /* ─── AI Agent Custom Stack ──────────────────────────────── */ diff --git a/src/views/AIAgent.tsx b/src/views/AIAgent.tsx index 73fe5e9..cb0dd39 100644 --- a/src/views/AIAgent.tsx +++ b/src/views/AIAgent.tsx @@ -1,1299 +1,1164 @@ -import { useState, useRef, useEffect, useCallback } from "react"; -import { safeInvoke as invoke } from "../safe-invoke"; +import { useState, useRef, useEffect } from "react"; import { - Bot, Send, Code, Server, Database, Globe, CheckCircle2, - Circle, Loader2, BookOpen, FolderOpen, Settings2, - Sparkles, Trash2, Plus, ChevronLeft, ChevronRight, - Layers, Pencil, PanelLeftClose, PanelLeft, + Terminal, Loader2, Rocket, ExternalLink, Copy, + GitBranch, RefreshCw, Play, Trash2, Globe, + Box, Cpu, History, ChevronDown, ChevronUp, + Clock, MessageSquare, Sun, Moon, Download, + PlayCircle, Square, } from "lucide-react"; -import type { Template, AIProviderConfig, AIModelInfo, StreamToken, AISession } from "../types"; -import { - AI_PROVIDER_LABELS, AI_PROVIDER_COLORS, AI_PROVIDER_ICONS, - RUNTIME_OPTIONS, FRONTEND_OPTIONS, BACKEND_OPTIONS, DATABASE_OPTIONS, CSS_OPTIONS, -} from "../types"; - -/* ─── Direct Ollama Browser Helpers (non-Tauri fallback) ───── */ - -/** Call Ollama /api/generate from the browser directly (non-streaming) */ -async function directOllamaGenerate( - model: string, - prompt: string, - format: string | null, -): Promise { - const body: Record = { - model, - prompt, - stream: false, - options: { temperature: 0.2 }, - }; - if (format === "json") body.format = "json"; - - const resp = await fetch("http://localhost:11434/api/generate", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - if (!resp.ok) { - const errText = await resp.text().catch(() => ""); - throw new Error(`Ollama error (${resp.status}): ${errText || "Is Ollama running on localhost:11434?"}`); - } - const data = await resp.json(); - return data.response || ""; + +/* ─── System Context ──────────────────────────────────── + This gets prepended to every opencode command so opencode + immediately understands the dweb environment. */ +const DWEB_CONTEXT = `You are operating inside **dweb** — a self-hosted P2P dev portal running at http://localhost:49737/. + +ENVIRONMENT: +- Repo: github.com/Awaiswilll/dweb (dev branch) +- Stack: React+Vite+TypeScript frontend, Node.js backend (dweb.cjs) +- Ports: HTTP=49737, P2P Relay=49736, TCP Proxy=49738 +- Project files at: /home/awais/dweb/ +- Build: \`npm run build\`, Dev: \`npm run dev\`, Test: \`npm test\` + +CAPABILITIES: +- You can BUILD web apps and get them HOSTED at /project/:name +- You can MANAGE SERVICES: GET /api/services to list, POST /api/service/start to start, POST /api/service/stop to stop +- You can run npm scripts, edit files, explore the repo +- The AI Agent UI is at / (click "AI Agent" in sidebar) +- Quick actions: build, test, status, dev, host, clean + +Respond helpfully and concisely.`; + +/* ─── Types ────────────────────────────────────────────── */ +interface HistoryEntry { + id: string; + prompt: string; + response: string; + model: string; + timestamp: number; } -/** Call Ollama /api/generate with streaming from the browser directly. - * Calls onToken(token, done) for each token received. */ -async function directOllamaStream( - model: string, - prompt: string, - format: string | null, - onToken: (token: string, done: boolean) => void, -): Promise { - const body: Record = { - model, - prompt, - stream: true, - options: { temperature: 0.2 }, - }; - if (format === "json") body.format = "json"; - - const resp = await fetch("http://localhost:11434/api/generate", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - if (!resp.ok) { - const errText = await resp.text().catch(() => ""); - throw new Error(`Ollama error (${resp.status}): ${errText || "Is Ollama running on localhost:11434?"}`); - } - - const reader = resp.body?.getReader(); - if (!reader) throw new Error("Response body not readable — try a different browser"); - - const decoder = new TextDecoder(); - let buffer = ""; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed) continue; - try { - const data = JSON.parse(trimmed); - const token = data.response || ""; - const isDone = data.done === true; - onToken(token, isDone); - if (isDone) return; - } catch { - // skip partial/invalid JSON lines - } - } - } - onToken("", true); +interface OpenCodeModel { + id: string; label: string; provider: string; free: boolean; } -/* ─── Templates ──────────────────────────────────────────── */ -const TEMPLATES: Template[] = [ - { id: "t1", name: "React Blog", description: "Static blog with React, Markdown, and RSS", stack: "Node.js + React", icon: "⚛️", color: "#61dafb" }, - { id: "t2", name: "FastAPI CRUD", description: "REST API with Python FastAPI + PostgreSQL", stack: "Python + FastAPI", icon: "🐍", color: "#4CAF50" }, - { id: "t3", name: "PHP Dashboard", description: "Admin dashboard with PHP, MySQL, Chart.js", stack: "PHP + MySQL", icon: "🐘", color: "#8892BF" }, - { id: "t4", name: "Go Microservice", description: "Lightweight API gateway with Go + Redis", stack: "Go + Redis", icon: "🔵", color: "#00ADD8" }, - { id: "t5", name: "Ruby on Rails", description: "Full-stack app with Rails, PostgreSQL, Hotwire", stack: "Ruby + Rails", icon: "💎", color: "#CC0000" }, - { id: "t6", name: "Node API + React", description: "Full-stack SPA with Express, React, MongoDB", stack: "Node.js + React", icon: "🟢", color: "#68A063" }, - { id: "t7", name: "Python + React", description: "Data dashboard with Python, React, PostgreSQL", stack: "Python + React", icon: "📊", color: "#FF6F00" }, - { id: "t8", name: "Static Site", description: "Simple HTML/CSS/JS site, zero dependencies", stack: "Static", icon: "📄", color: "#9E9E9E" }, -]; +interface OllamaModel { + name: string; size: number; modified?: string; details?: { family?: string; parameter_size?: string; }; +} -/* ─── Pipeline Steps ──────────────────────────────────────── */ -const PIPELINE_STEPS = [ - { id: "analyze", label: "Analyze Request", icon: }, - { id: "scaffold", label: "Scaffold Project", icon: }, - { id: "generate", label: "Generate Code", icon: }, - { id: "database", label: "Configure Database", icon: }, - { id: "install", label: "Install Dependencies", icon: }, - { id: "start", label: "Start Server", icon: }, - { id: "publish", label: "Publish to dweb", icon: }, +interface OllamaStatus { + installed: boolean; running: boolean; platform: string; + isWSL?: boolean; detectedVia?: string | null; recommended?: boolean; + models: OllamaModel[]; modelCount: number; port: number; + apiEndpoint: string; suggestedModel: string; +} + +/* ─── Quick Actions ─────────────────────────────────────── */ +const QUICK_ACTIONS = [ + { id: "hello-dweb", label: "Hello dweb", icon: , prompt: "INSTANT_PUBLISH" }, + { id: "build", label: "Run Build", icon: , prompt: "run npm run build in the dweb repo and fix any errors" }, + { id: "test", label: "Run Tests", icon: , prompt: "run npm test in the dweb repo and fix any failures" }, + { id: "status", label: "Repo Status", icon: , prompt: "show the current state of the dweb repo, its architecture, branch, recent changes, and what can be built next" }, + { id: "dev", label: "Dev Setup", icon: , prompt: "explain the development setup, hot reload, key files, and how to start developing" }, + { id: "host", label: "Build & Host",icon: , prompt: "build a simple web application that can be hosted on dweb. Generate complete code files." }, + { id: "clean", label: "Clean Build", icon: , prompt: "clean all build artifacts and rebuild the dweb project from scratch" }, ]; -interface Message { - role: "user" | "assistant" | "system"; - content: string; - timestamp: number; - step?: string; - streaming?: boolean; -} +/* ─── Storage helpers ──────────────────────────────────── */ +const HISTORY_KEY = "dweb-oc-history"; +const MODEL_KEY = "dweb-oc-model"; +const FONT_SIZE_KEY = "dweb-font-size"; +const SESSION_KEY = "dweb-oc-session"; // Persisted across tab switches within SPA -/* ─── Session helpers ─────────────────────────────────────── */ -const STORAGE_KEY = "dweb-ai-sessions"; +type FontSize = "normal" | "large" | "xlarge"; -function loadSessions(): AISession[] { - try { - const raw = localStorage.getItem(STORAGE_KEY); - return raw ? JSON.parse(raw) : []; - } catch { return []; } -} +const FONT_SCALES: Record = { normal: 1, large: 1.1, xlarge: 1.25 }; -function saveSessions(sessions: AISession[]) { - try { localStorage.setItem(STORAGE_KEY, JSON.stringify(sessions)); } catch {} +function loadHistory(): HistoryEntry[] { + try { return JSON.parse(localStorage.getItem(HISTORY_KEY) || "[]"); } + catch { return []; } } - -function newSessionId(): string { - return `sess_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; +function saveHistory(h: HistoryEntry[]) { + try { localStorage.setItem(HISTORY_KEY, JSON.stringify(h.slice(0, 50))); } catch {} +} +function loadModel(): string { + return localStorage.getItem(MODEL_KEY) || "opencode/deepseek-v4-flash-free"; +} +function saveModel(m: string) { + try { localStorage.setItem(MODEL_KEY, m); } catch {} +} +function loadFontSize(): FontSize { + const v = localStorage.getItem(FONT_SIZE_KEY); + if (v === "large" || v === "xlarge") return v; + return "normal"; +} +function saveFontSize(s: FontSize) { + try { localStorage.setItem(FONT_SIZE_KEY, s); } catch {} } -function createNewSession(provider: string, model: string): AISession { - const now = Date.now(); - return { - id: newSessionId(), - label: `Chat ${new Date(now).toLocaleDateString()} ${new Date(now).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`, - created: now, - updated: now, - provider, - model, - messages: [], - summary: "", - }; +function genId(): string { + return Date.now().toString(36) + Math.random().toString(36).slice(2, 6); } -const WELCOME_MSG: Message = { - role: "assistant", - content: "Hi! I'm your dweb AI build agent. Type a request below or pick a template to get started.", - timestamp: Date.now(), -}; +function formatTime(ts: number): string { + const d = new Date(ts); + return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); +} export default function AIAgent() { - const [messages, setMessages] = useState([{ ...WELCOME_MSG }]); - const [input, setInput] = useState(""); - const [building, setBuilding] = useState(false); - const [currentStep, setCurrentStep] = useState(-1); - const [showTemplates, setShowTemplates] = useState(true); - const [streamingContent, setStreamingContent] = useState(""); - - const [providers, setProviders] = useState([]); - const [activeProvider, setActiveProvider] = useState("ollama"); - const [activeModel, setActiveModel] = useState("qwen2.5-coder:7b"); - const [models, setModels] = useState([]); - const [modelsLoading, setModelsLoading] = useState(false); - const [providersLoading, setProvidersLoading] = useState(true); - - const [sessions, setSessions] = useState(() => { - const stored = loadSessions(); - if (stored.length > 0) return stored; - return [createNewSession("ollama", "qwen2.5-coder:7b")]; - }); - const [activeSessionId, setActiveSessionId] = useState(() => { - const stored = loadSessions(); - if (stored.length > 0) { - const sorted = [...stored].sort((a, b) => b.updated - a.updated); - return sorted[0].id; - } - return null; - }); - const [sessionSidebarOpen, setSessionSidebarOpen] = useState(true); - const [editingSessionId, setEditingSessionId] = useState(null); - const [editLabel, setEditLabel] = useState(""); - - // Fast mode: uses a lightweight model for simple queries - const [fastMode, setFastMode] = useState(false); - const [jsonFormat, setJsonFormat] = useState(false); - - /** Map each provider to its fastest available model */ - const getFastModel = useCallback((provider: string): string => { - const map: Record = { - ollama: "qwen2.5-coder:1.5b", - openai: "gpt-4o-mini", - anthropic: "claude-3-haiku-latest", - google: "gemini-2.0-flash", - together: "meta-llama/Llama-3.3-70B-Instruct-Turbo-Free", - groq: "llama-3.1-8b-instant", - openrouter: "google/gemini-2.0-flash-001", - huggingface: "HuggingFaceH4/zephyr-7b-beta", - mistral: "mistral-small-latest", - deepseek: "deepseek-chat", - fireworks: "accounts/fireworks/models/llama-v3p1-8b-instruct", - cohere: "command-r-plus", - nvidia: "nvidia/llama-3.1-nemotron-70b-instruct", - cerebras: "llama3.1-8b", - xai: "grok-2-1212", - hyperbolic: "meta-llama/Llama-3.3-70B-Instruct", - }; - return map[provider] || ""; - }, []); - - /** Classify current model's speed based on its id/name */ - const getModelSpeed = useCallback((modelId: string): "fast" | "balanced" | "powerful" | null => { - const id = modelId.toLowerCase(); - if (id.includes("1.5b") || id.includes("3b") || id.includes("mini") || id.includes("haiku") || id.includes("flash") || id.includes("turbo") || id.includes("instant") || id.includes("3.5") || id.includes("small") || id.includes("8b") || id.includes("zephyr")) return "fast"; - if (id.includes("7b") || id.includes("9b") || id.includes("6.7b") || id.includes("mistral") || id.includes("command-r") || id.includes("nemotron-8b") || id.includes("nemotron-70b")) return "balanced"; - if (id.includes("32b") || id.includes("70b") || id.includes("34b") || id.includes("22b") || id.includes("4o") && !id.includes("mini") || id.includes("opus") || id.includes("pro") && !id.includes("1.5-flash") || id.includes("grok") || id.includes("deepseek") || id.includes("sonnet") || id.includes("nemotron")) return "powerful"; - return null; - }, []); - - const [showCustomStack, setShowCustomStack] = useState(false); - const [stackRuntime, setStackRuntime] = useState("node"); - const [stackFrontend, setStackFrontend] = useState("react"); - const [stackBackend, setStackBackend] = useState("express"); - const [stackDatabase, setStackDatabase] = useState("postgresql"); - const [stackCss, setStackCss] = useState("tailwind"); - const [stackDescription, setStackDescription] = useState(""); - - const chatEnd = useRef(null); - const chatContainerRef = useRef(null); - const editInputRef = useRef(null); - - // On mount, load most recent session messages if stored sessions exist + // ── Repo context ── + const [repoInfo, setRepoInfo] = useState<{ repo: string; branch: string; commit: string; files: number } | null>(null); + + // ── Opencode ── + const [ocAvailable, setOcAvailable] = useState(null); + const [ocVersion, setOcVersion] = useState(""); + const [prompt, setPrompt] = useState(""); + const [output, setOutput] = useState(""); + const [running, setRunning] = useState(false); + const [showOutput, setShowOutput] = useState(false); + const [activeModel, setActiveModel] = useState(loadModel); + const [currentPrompt, setCurrentPrompt] = useState(""); + const outputRef = useRef(null); + const eventSourceRef = useRef(null); + + // ── Models ── + const [models, setModels] = useState([]); + const [showModelPicker, setShowModelPicker] = useState(false); + + // ── History ── + const [history, setHistory] = useState(loadHistory); + const [showHistory, setShowHistory] = useState(false); + + // ── Ollama (local AI) ── + const [ollamaStatus, setOllamaStatus] = useState(null); + const [ollamaLoading, setOllamaLoading] = useState(false); + const [showOllama, setShowOllama] = useState(false); + const [useOllama, setUseOllama] = useState(false); + const [ollamaModel, setOllamaModel] = useState("qwen2.5-coder:7b"); + const [ollamaActionMsg, setOllamaActionMsg] = useState(""); + + // ── User preferences ── + const [fontSize, setFontSize] = useState(loadFontSize); + + // ── Published projects ── + const [published, setPublished] = useState<{ name: string; url: string; route: string }[]>([]); + const [publishing, setPublishing] = useState(false); + + // Apply font-size scale to root useEffect(() => { - const stored = loadSessions(); - if (stored.length > 0 && activeSessionId) { - const session = stored.find(s => s.id === activeSessionId); - if (session && session.messages.length > 0) { - setMessages(session.messages.map(m => ({ ...m, streaming: false }))); - if (session.provider) setActiveProvider(session.provider); - if (session.model) setActiveModel(session.model); - } - } - }, []); + const scale = FONT_SCALES[fontSize]; + document.documentElement.style.setProperty("--dweb-font-scale", String(scale)); + // Also bump body font-size directly for elements that don't use CSS vars + document.body.style.fontSize = `${13 * scale}px`; + }, [fontSize]); - // Load providers and active config on mount + // Auto-scroll output useEffect(() => { - setProvidersLoading(true); - invoke("get_ai_providers") - .then(data => { - setProviders(data); - const enabled = data.filter(p => p.enabled); - if (enabled.length > 0 && !enabled.find(p => p.provider_type === activeProvider)) { - setActiveProvider(enabled[0].provider_type); - if (enabled[0].default_model) setActiveModel(enabled[0].default_model); - } - }) - .catch(() => { - setProviders([ - { provider_type: "ollama", enabled: true, label: "Ollama (Local)", api_key: null, base_url: "http://localhost:11434", default_model: "qwen2.5-coder:7b", temperature: 0.2, max_tokens: 8192 }, - { provider_type: "openai", enabled: false, label: "OpenAI", api_key: null, base_url: "https://api.openai.com/v1", default_model: "gpt-4o", temperature: 0.3, max_tokens: 16384 }, - ]); - }) - .finally(() => setProvidersLoading(false)); - - invoke<[string, string]>("get_active_ai") - .then(([p, m]) => { setActiveProvider(p); setActiveModel(m); }) - .catch(() => {}); - }, []); - - // Load models when provider changes + if (showOutput && outputRef.current) { + outputRef.current.scrollTop = outputRef.current.scrollHeight; + } + }, [output, showOutput]); + + // ── Connect to SSE stream for a session ──────────────── + const connectSSE = useRef<(sessionId: string, userCmd: string) => void>(null); + + // On mount: load everything + reconnect to active session if any useEffect(() => { - if (activeProvider) { - setModelsLoading(true); - invoke("get_ai_models", { providerType: activeProvider }) + fetch("/api/repo/status").then(r => r.json()).then(d => { if (d.status === "ok") setRepoInfo(d); }).catch(() => {}); + fetch("/api/opencode/status").then(r => r.json()).then(d => { setOcAvailable(d.available); setOcVersion(d.version || ""); }).catch(() => setOcAvailable(false)); + fetch("/api/opencode/models").then(r => r.json()).then(d => { if (d.status === "ok") setModels(d.models || []); }).catch(() => {}); + fetch("/api/projects").then(r => r.json()).then(d => { if (d.status === "ok" && d.projects) setPublished(d.projects); }).catch(() => {}); + + // ── Reconnect to active session (survive tab switches) ── + const savedSessionId = sessionStorage.getItem(SESSION_KEY); + if (savedSessionId) { + fetch(`/api/opencode/session/${savedSessionId}`) + .then(r => r.json()) .then(data => { - setModels(data); - if (data.length > 0 && !data.find(m => m.id === activeModel)) { - setActiveModel(data[0].id); + if (data.status === "ok" && data.session) { + const s = data.session; + setOutput(s.output || ""); + setShowOutput(true); + setCurrentPrompt(s.command || ""); + setRunning(s.running); + if (s.running) { + // Session still running — reconnect to SSE stream + const cmd = s.command || ""; + connectSSE.current?.(savedSessionId, cmd); + } else { + // Session already finished — stored for reference + sessionStorage.removeItem(SESSION_KEY); + } + } else { + sessionStorage.removeItem(SESSION_KEY); } }) - .catch(() => { - const fallback: Record = { - ollama: [ - // ── Code Models (Free, Local) ── - { id: "qwen2.5-coder:1.5b", name: "Qwen 2.5 Coder 1.5B ⚡", provider: "ollama", description: "Fast code model, low RAM" }, - { id: "qwen2.5-coder:3b", name: "Qwen 2.5 Coder 3B", provider: "ollama", description: "Balanced code model" }, - { id: "qwen2.5-coder:7b", name: "Qwen 2.5 Coder 7B", provider: "ollama", description: "Best for code generation" }, - { id: "qwen2.5-coder:14b", name: "Qwen 2.5 Coder 14B", provider: "ollama", description: "Powerful code model" }, - { id: "qwen2.5-coder:32b", name: "Qwen 2.5 Coder 32B", provider: "ollama", description: "Maximum code capability" }, - { id: "codellama:7b", name: "Code Llama 7B", provider: "ollama", description: "Meta code model" }, - { id: "codellama:13b", name: "Code Llama 13B", provider: "ollama", description: "Meta code model larger" }, - { id: "codellama:34b", name: "Code Llama 34B", provider: "ollama", description: "Meta code model largest" }, - { id: "deepseek-coder:6.7b", name: "DeepSeek Coder 6.7B", provider: "ollama", description: "DeepSeek code model" }, - { id: "deepseek-coder-v2:16b", name: "DeepSeek Coder V2 16B", provider: "ollama", description: "DeepSeek V2 code" }, - { id: "starcoder2:3b", name: "StarCoder2 3B", provider: "ollama", description: "BigCode starcoder" }, - { id: "starcoder2:7b", name: "StarCoder2 7B", provider: "ollama", description: "BigCode starcoder larger" }, - { id: "starcoder2:15b", name: "StarCoder2 15B", provider: "ollama", description: "BigCode starcoder largest" }, - { id: "codegemma:2b", name: "CodeGemma 2B", provider: "ollama", description: "Google code model small" }, - { id: "codegemma:7b", name: "CodeGemma 7B", provider: "ollama", description: "Google code model" }, - // ── General Models (Free, Local) ── - { id: "llama3.2:1b", name: "Llama 3.2 1B ⚡", provider: "ollama", description: "Ultra-fast, minimal RAM" }, - { id: "llama3.2:3b", name: "Llama 3.2 3B", provider: "ollama", description: "Fast general purpose" }, - { id: "llama3.2:7b", name: "Llama 3.2 7B", provider: "ollama", description: "Balanced general purpose" }, - { id: "llama3.2:11b-vision", name: "Llama 3.2 11B Vision", provider: "ollama", description: "Multimodal vision model" }, - { id: "llama3.3:70b", name: "Llama 3.3 70B", provider: "ollama", description: "Latest Llama flagship" }, - { id: "llama3.1:8b", name: "Llama 3.1 8B", provider: "ollama", description: "Meta general purpose" }, - { id: "llama3.1:70b", name: "Llama 3.1 70B", provider: "ollama", description: "Meta large model" }, - { id: "gemma2:2b", name: "Gemma 2 2B ⚡", provider: "ollama", description: "Google fast model" }, - { id: "gemma2:9b", name: "Gemma 2 9B", provider: "ollama", description: "Google balanced model" }, - { id: "gemma2:27b", name: "Gemma 2 27B", provider: "ollama", description: "Google powerful model" }, - { id: "qwen2.5:0.5b", name: "Qwen 2.5 0.5B ⚡", provider: "ollama", description: "Ultra-lightweight" }, - { id: "qwen2.5:1.5b", name: "Qwen 2.5 1.5B", provider: "ollama", description: "Fast general purpose" }, - { id: "qwen2.5:3b", name: "Qwen 2.5 3B", provider: "ollama", description: "Balanced Qwen" }, - { id: "qwen2.5:7b", name: "Qwen 2.5 7B", provider: "ollama", description: "General purpose Qwen" }, - { id: "qwen2.5:14b", name: "Qwen 2.5 14B", provider: "ollama", description: "Powerful Qwen" }, - { id: "qwen2.5:32b", name: "Qwen 2.5 32B", provider: "ollama", description: "Large Qwen" }, - { id: "qwen2.5:72b", name: "Qwen 2.5 72B", provider: "ollama", description: "Maximum Qwen" }, - { id: "mistral:7b", name: "Mistral 7B", provider: "ollama", description: "Mistral general purpose" }, - { id: "mistral-nemo:12b", name: "Mistral Nemo 12B", provider: "ollama", description: "Mistral+NVIDIA collab" }, - { id: "phi3:3.8b", name: "Phi-3 3.8B", provider: "ollama", description: "Microsoft small model" }, - { id: "phi4:14b", name: "Phi-4 14B", provider: "ollama", description: "Microsoft latest" }, - { id: "deepseek-r1:1.5b", name: "DeepSeek R1 1.5B ⚡", provider: "ollama", description: "Reasoning model fast" }, - { id: "deepseek-r1:7b", name: "DeepSeek R1 7B", provider: "ollama", description: "Reasoning model" }, - { id: "deepseek-r1:8b", name: "DeepSeek R1 8B", provider: "ollama", description: "Reasoning model balanced" }, - { id: "deepseek-r1:14b", name: "DeepSeek R1 14B", provider: "ollama", description: "Reasoning model powerful" }, - { id: "deepseek-r1:32b", name: "DeepSeek R1 32B", provider: "ollama", description: "Reasoning model large" }, - { id: "deepseek-r1:70b", name: "DeepSeek R1 70B", provider: "ollama", description: "Reasoning model max" }, - { id: "nemotron-mini:4b", name: "Nemotron Mini 4B ⚡", provider: "ollama", description: "NVIDIA free mini model" }, - { id: "nemotron:70b", name: "Nemotron 70B", provider: "ollama", description: "NVIDIA powerful model" }, - { id: "smollm2:135m", name: "SmolLM2 135M ⚡", provider: "ollama", description: "Ultra tiny, edge devices" }, - { id: "smollm2:360m", name: "SmolLM2 360M", provider: "ollama", description: "Tiny model" }, - { id: "smollm2:1.7b", name: "SmolLM2 1.7B", provider: "ollama", description: "Small efficient model" }, - { id: "tinyllama:1.1b", name: "TinyLlama 1.1B ⚡", provider: "ollama", description: "Ultra lightweight" }, - { id: "granite3.1-moe:3b", name: "Granite 3.1 MoE 3B", provider: "ollama", description: "IBM MoE model" }, - { id: "granite3.1-moe:8b", name: "Granite 3.1 MoE 8B", provider: "ollama", description: "IBM MoE larger" }, - { id: "granite3.2:2b", name: "Granite 3.2 2B", provider: "ollama", description: "IBM latest small" }, - { id: "granite3.2:8b", name: "Granite 3.2 8B", provider: "ollama", description: "IBM latest balanced" }, - { id: "aya-expanse:8b", name: "Aya Expanse 8B", provider: "ollama", description: "Cohere multilingual" }, - { id: "aya-expanse:32b", name: "Aya Expanse 32B", provider: "ollama", description: "Cohere multilingual large" }, - { id: "wizardlm2:7b", name: "WizardLM 2 7B", provider: "ollama", description: "WizardLM general" }, - { id: "wizardlm2:8x22b", name: "WizardLM 2 8x22B", provider: "ollama", description: "WizardLM MoE large" }, - { id: "dolphin-mistral:7b", name: "Dolphin Mistral 7B", provider: "ollama", description: "Uncensored Mistral" }, - { id: "dolphin-mixtral:8x7b", name: "Dolphin Mixtral 8x7B", provider: "ollama", description: "Uncensored Mixtral" }, - { id: "openhermes:7b", name: "OpenHermes 7B", provider: "ollama", description: "OpenHermes general" }, - { id: "openhermes:2.5-mistral-7b", name: "OpenHermes 2.5 Mistral", provider: "ollama", description: "OpenHermes Mistral" }, - { id: "orca-mini:3b", name: "Orca Mini 3B", provider: "ollama", description: "Microsoft Orca small" }, - { id: "orca-mini:7b", name: "Orca Mini 7B", provider: "ollama", description: "Microsoft Orca" }, - { id: "orca-mini:13b", name: "Orca Mini 13B", provider: "ollama", description: "Microsoft Orca larger" }, - { id: "llava:7b", name: "LLaVA 7B", provider: "ollama", description: "Vision-language model" }, - { id: "llava:13b", name: "LLaVA 13B", provider: "ollama", description: "Vision-language larger" }, - { id: "llava-llama3:8b", name: "LLaVA Llama3 8B", provider: "ollama", description: "LLaVA on Llama3" }, - { id: "moondream:1.8b", name: "Moondream 1.8B", provider: "ollama", description: "Vision model small" }, - { id: "bakllava:7b", name: "BakLLaVA 7B", provider: "ollama", description: "Vision model" }, - { id: "yi-coder:1.5b", name: "Yi Coder 1.5B ⚡", provider: "ollama", description: "01.ai code model small" }, - { id: "yi-coder:9b", name: "Yi Coder 9B", provider: "ollama", description: "01.ai code model" }, - { id: "mathstral:7b", name: "Mathstral 7B", provider: "ollama", description: "Mistral math model" }, - { id: "neural-chat:7b", name: "Neural Chat 7B", provider: "ollama", description: "Intel neural chat" }, - { id: "starling-lm:7b", name: "Starling LM 7B", provider: "ollama", description: "Berkeley aligned model" }, - { id: "solar:10.7b", name: "Solar 10.7B", provider: "ollama", description: "Upstage Solar model" }, - { id: "xwinlm:7b", name: "XwinLM 7B", provider: "ollama", description: "Xwin language model" }, - { id: "zephyr:7b", name: "Zephyr 7B", provider: "ollama", description: "HuggingFace aligned" }, - ], - openai: [ - { id: "gpt-4o", name: "GPT-4o", provider: "openai", description: "Latest flagship" }, - { id: "gpt-4o-mini", name: "GPT-4o Mini ⚡", provider: "openai", description: "Fast & cheap" }, - { id: "gpt-4-turbo", name: "GPT-4 Turbo", provider: "openai", description: "Previous gen" }, - { id: "o1-mini", name: "o1-mini", provider: "openai", description: "Reasoning model" }, - { id: "o3-mini", name: "o3-mini", provider: "openai", description: "Latest reasoning" }, - ], - google: [ - { id: "gemini-2.0-flash", name: "Gemini 2.0 Flash ⚡", provider: "google", description: "Free tier, fast" }, - { id: "gemini-2.0-flash-lite", name: "Gemini 2.0 Flash Lite", provider: "google", description: "Free tier, ultra fast" }, - { id: "gemini-2.0-pro-exp", name: "Gemini 2.0 Pro", provider: "google", description: "Most capable" }, - { id: "gemini-1.5-flash", name: "Gemini 1.5 Flash", provider: "google", description: "Previous gen fast" }, - { id: "gemini-1.5-flash-8b", name: "Gemini 1.5 Flash 8B", provider: "google", description: "Lightweight free" }, - ], - groq: [ - { id: "llama-3.1-8b-instant", name: "Llama 3.1 8B ⚡", provider: "groq", description: "Free, ultra-fast inference" }, - { id: "llama-3.3-70b-specdec", name: "Llama 3.3 70B", provider: "groq", description: "Free, powerful" }, - { id: "llama-3.2-3b-preview", name: "Llama 3.2 3B", provider: "groq", description: "Free, small" }, - { id: "llama-3.2-11b-vision-preview", name: "Llama 3.2 11B Vision", provider: "groq", description: "Free, vision" }, - { id: "llama-3.2-90b-vision-preview", name: "Llama 3.2 90B Vision", provider: "groq", description: "Free, large vision" }, - { id: "mixtral-8x7b-32768", name: "Mixtral 8x7B", provider: "groq", description: "Free, MoE model" }, - { id: "gemma2-9b-it", name: "Gemma 2 9B", provider: "groq", description: "Free, Google model" }, - { id: "deepseek-r1-distill-llama-70b", name: "DeepSeek R1 70B", provider: "groq", description: "Free, reasoning" }, - { id: "qwen-2.5-32b", name: "Qwen 2.5 32B", provider: "groq", description: "Free, Qwen model" }, - ], - together: [ - { id: "meta-llama/Llama-3.3-70B-Instruct-Turbo-Free", name: "Llama 3.3 70B Free ⚡", provider: "together", description: "Free tier available" }, - { id: "meta-llama/Llama-3.2-3B-Instruct-Turbo", name: "Llama 3.2 3B", provider: "together", description: "Free tier" }, - { id: "meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo", name: "Llama 3.1 8B", provider: "together", description: "Free tier" }, - { id: "mistralai/Mixtral-8x7B-Instruct-v0.1", name: "Mixtral 8x7B", provider: "together", description: "Free tier" }, - { id: "deepseek-ai/DeepSeek-R1-Distill-Llama-70B-free", name: "DeepSeek R1 70B Free", provider: "together", description: "Free reasoning" }, - { id: "Qwen/Qwen2.5-7B-Instruct-Turbo", name: "Qwen 2.5 7B", provider: "together", description: "Free tier" }, - ], - openrouter: [ - { id: "google/gemini-2.0-flash-001", name: "Gemini 2.0 Flash ⚡", provider: "openrouter", description: "Free via OpenRouter" }, - { id: "meta-llama/llama-3.3-70b-instruct", name: "Llama 3.3 70B", provider: "openrouter", description: "Free tier" }, - { id: "meta-llama/llama-3.1-8b-instruct", name: "Llama 3.1 8B", provider: "openrouter", description: "Free tier" }, - { id: "mistralai/mistral-7b-instruct", name: "Mistral 7B", provider: "openrouter", description: "Free tier" }, - { id: "qwen/qwen-2.5-coder-32b-instruct", name: "Qwen 2.5 Coder 32B", provider: "openrouter", description: "Free tier" }, - { id: "deepseek/deepseek-r1", name: "DeepSeek R1", provider: "openrouter", description: "Free reasoning" }, - { id: "nvidia/llama-3.1-nemotron-70b-instruct", name: "Nemotron 70B", provider: "openrouter", description: "Free via OpenRouter" }, - ], - huggingface: [ - { id: "HuggingFaceH4/zephyr-7b-beta", name: "Zephyr 7B ⚡", provider: "huggingface", description: "Free inference API" }, - { id: "mistralai/Mistral-7B-Instruct-v0.3", name: "Mistral 7B Instruct", provider: "huggingface", description: "Free inference API" }, - { id: "meta-llama/Llama-3.2-3B-Instruct", name: "Llama 3.2 3B", provider: "huggingface", description: "Free inference API" }, - { id: "Qwen/Qwen2.5-Coder-7B-Instruct", name: "Qwen 2.5 Coder 7B", provider: "huggingface", description: "Free inference API" }, - { id: "microsoft/Phi-3-mini-4k-instruct", name: "Phi-3 Mini", provider: "huggingface", description: "Free inference API" }, - ], - mistral: [ - { id: "mistral-small-latest", name: "Mistral Small ⚡", provider: "mistral", description: "Free tier available" }, - { id: "mistral-large-latest", name: "Mistral Large", provider: "mistral", description: "Free tier" }, - { id: "codestral-latest", name: "Codestral", provider: "mistral", description: "Free tier, code model" }, - { id: "ministral-8b-latest", name: "Ministral 8B", provider: "mistral", description: "Free tier, small" }, - { id: "ministral-3b-latest", name: "Ministral 3B", provider: "mistral", description: "Free tier, tiny" }, - { id: "pixtral-12b-2409", name: "Pixtral 12B", provider: "mistral", description: "Free tier, vision" }, - { id: "mistral-nemo", name: "Mistral Nemo", provider: "mistral", description: "Free tier, 12B" }, - { id: "open-mistral-7b", name: "Open Mistral 7B", provider: "mistral", description: "Free tier" }, - { id: "open-mixtral-8x7b", name: "Open Mixtral 8x7B", provider: "mistral", description: "Free tier, MoE" }, - { id: "open-mixtral-8x22b", name: "Open Mixtral 8x22B", provider: "mistral", description: "Free tier, MoE" }, - ], - deepseek: [ - { id: "deepseek-chat", name: "DeepSeek Chat ⚡", provider: "deepseek", description: "Free/cheap API" }, - { id: "deepseek-reasoner", name: "DeepSeek Reasoner", provider: "deepseek", description: "Free/cheap, R1" }, - { id: "deepseek-coder", name: "DeepSeek Coder", provider: "deepseek", description: "Free/cheap, code" }, - ], - fireworks: [ - { id: "accounts/fireworks/models/llama-v3p1-8b-instruct", name: "Llama 3.1 8B ⚡", provider: "fireworks", description: "Free tier" }, - { id: "accounts/fireworks/models/llama-v3p3-70b-instruct", name: "Llama 3.3 70B", provider: "fireworks", description: "Free tier" }, - { id: "accounts/fireworks/models/qwen2p5-coder-32b-instruct", name: "Qwen 2.5 Coder 32B", provider: "fireworks", description: "Free tier" }, - { id: "accounts/fireworks/models/deepseek-r1", name: "DeepSeek R1", provider: "fireworks", description: "Free tier" }, - { id: "accounts/fireworks/models/mixtral-8x7b-instruct", name: "Mixtral 8x7B", provider: "fireworks", description: "Free tier" }, - { id: "accounts/fireworks/models/mistral-7b-instruct-v4", name: "Mistral 7B v4", provider: "fireworks", description: "Free tier" }, - ], - cohere: [ - { id: "command-r-plus", name: "Command R+ ⚡", provider: "cohere", description: "Free tier" }, - { id: "command-r", name: "Command R", provider: "cohere", description: "Free tier, fast" }, - { id: "command-r7b-12-2024", name: "Command R7B", provider: "cohere", description: "Free tier, small" }, - ], - nvidia: [ - { id: "nvidia/llama-3.1-nemotron-70b-instruct", name: "Nemotron 70B ⚡", provider: "nvidia", description: "Free NIM API" }, - { id: "nvidia/nemotron-4-340b-instruct", name: "Nemotron 4 340B", provider: "nvidia", description: "Free NIM, massive" }, - { id: "nvidia/llama-3.1-nemotron-8b-instruct", name: "Nemotron 8B", provider: "nvidia", description: "Free NIM, fast" }, - { id: "nvidia/nemotron-mini-4b-instruct", name: "Nemotron Mini 4B", provider: "nvidia", description: "Free NIM, tiny" }, - { id: "meta/llama-3.1-8b-instruct", name: "Llama 3.1 8B", provider: "nvidia", description: "Free NIM" }, - { id: "meta/llama-3.1-70b-instruct", name: "Llama 3.1 70B", provider: "nvidia", description: "Free NIM" }, - { id: "meta/llama-3.1-405b-instruct", name: "Llama 3.1 405B", provider: "nvidia", description: "Free NIM, largest" }, - { id: "mistralai/mistral-large-2-instruct", name: "Mistral Large 2", provider: "nvidia", description: "Free NIM" }, - { id: "google/gemma-2-9b-it", name: "Gemma 2 9B", provider: "nvidia", description: "Free NIM" }, - { id: "google/gemma-2-27b-it", name: "Gemma 2 27B", provider: "nvidia", description: "Free NIM" }, - { id: "microsoft/phi-3-mini-128k-instruct", name: "Phi-3 Mini 128K", provider: "nvidia", description: "Free NIM" }, - { id: "microsoft/phi-3.5-mini-128k-instruct", name: "Phi-3.5 Mini", provider: "nvidia", description: "Free NIM" }, - { id: "qwen/qwen2.5-coder-32b-instruct", name: "Qwen 2.5 Coder 32B", provider: "nvidia", description: "Free NIM" }, - { id: "deepseek-ai/deepseek-r1", name: "DeepSeek R1", provider: "nvidia", description: "Free NIM" }, - ], - cerebras: [ - { id: "llama3.1-8b", name: "Llama 3.1 8B ⚡", provider: "cerebras", description: "Free, ultra-fast chip" }, - { id: "llama3.1-70b", name: "Llama 3.1 70B", provider: "cerebras", description: "Free, ultra-fast chip" }, - { id: "llama3.3-70b", name: "Llama 3.3 70B", provider: "cerebras", description: "Free, ultra-fast chip" }, - { id: "llama-3.3-70b-instruct", name: "Llama 3.3 70B Instruct", provider: "cerebras", description: "Free, ultra-fast chip" }, - ], - xai: [ - { id: "grok-2-1212", name: "Grok 2 ⚡", provider: "xai", description: "Free tier available" }, - { id: "grok-2-vision-1212", name: "Grok 2 Vision", provider: "xai", description: "Free tier, vision" }, - { id: "grok-beta", name: "Grok Beta", provider: "xai", description: "Free tier" }, - ], - hyperbolic: [ - { id: "meta-llama/Llama-3.3-70B-Instruct", name: "Llama 3.3 70B ⚡", provider: "hyperbolic", description: "Free tier" }, - { id: "meta-llama/Llama-3.1-8B-Instruct", name: "Llama 3.1 8B", provider: "hyperbolic", description: "Free tier" }, - { id: "Qwen/Qwen2.5-Coder-32B-Instruct", name: "Qwen 2.5 Coder 32B", provider: "hyperbolic", description: "Free tier" }, - { id: "deepseek-ai/DeepSeek-R1-Distill-Llama-70B", name: "DeepSeek R1 70B", provider: "hyperbolic", description: "Free tier" }, - { id: "mistralai/Mistral-7B-Instruct-v0.3", name: "Mistral 7B", provider: "hyperbolic", description: "Free tier" }, - ], - }; - setModels(fallback[activeProvider] || []); - }) - .finally(() => setModelsLoading(false)); + .catch(() => sessionStorage.removeItem(SESSION_KEY)); } - }, [activeProvider]); - // Auto-scroll - useEffect(() => { - chatEnd.current?.scrollIntoView({ behavior: "smooth" }); - }, [messages, streamingContent]); + // Cleanup EventSource on unmount + return () => { + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + }; + }, []); // eslint-disable-line react-hooks/exhaustive-deps - // Auto-save sessions when messages change - useEffect(() => { - if (!activeSessionId) return; - setSessions(prev => { - const existing = prev.find(s => s.id === activeSessionId); - if (!existing) return prev; - const nonEmpty = messages.filter(m => m.content); - const updated: AISession = { - ...existing, - updated: Date.now(), - provider: activeProvider, - model: activeModel, - messages: nonEmpty.map(m => ({ - role: m.role, - content: m.content, - timestamp: m.timestamp, - step: m.step, - })), - summary: nonEmpty.length > 0 ? nonEmpty[nonEmpty.length - 1].content.slice(0, 120) : "", - }; - const next = prev.map(s => s.id === activeSessionId ? updated : s); - saveSessions(next); - return next; - }); - }, [messages, activeSessionId, activeProvider, activeModel]); + /* ── Fetch Ollama status ── */ + const fetchOllamaStatus = async () => { + setOllamaLoading(true); + try { + const resp = await fetch("/api/ollama/status"); + const data = await resp.json(); + if (data.status === "ok") { + setOllamaStatus(data); + // Auto-hide the action message after success + if (data.installed) setOllamaActionMsg(""); + // Auto-enable Ollama mode when running and not already set + if (data.running) { + setUseOllama(prev => prev === false ? true : prev); + } + } + } catch {} + setOllamaLoading(false); + }; - // Focus edit input when renaming + // Fetch on mount & poll only when the Ollama panel is open useEffect(() => { - if (editingSessionId && editInputRef.current) { - editInputRef.current.focus(); - editInputRef.current.select(); + if (!showOllama) return; + fetchOllamaStatus(); + const interval = setInterval(fetchOllamaStatus, 8000); + return () => clearInterval(interval); + }, [showOllama]); + + /* ── Install Ollama ── */ + const handleInstallOllama = async () => { + setOllamaActionMsg("Installing Ollama..."); + try { + const resp = await fetch("/api/ollama/install", { method: "POST" }); + const data = await resp.json(); + setOllamaActionMsg(data.message || data.error || "Done"); + await fetchOllamaStatus(); + } catch (e) { + setOllamaActionMsg(`Error: ${e}`); } - }, [editingSessionId]); - - const addMessage = (msg: Message) => setMessages(prev => [...prev, msg]); - - const clearChat = () => { - setMessages([{ ...WELCOME_MSG }]); - setStreamingContent(""); }; - /* ── Session actions ── */ - const handleNewSession = () => { - const session = createNewSession(activeProvider, activeModel); - setSessions(prev => [...prev, session]); - setActiveSessionId(session.id); - setMessages([{ ...WELCOME_MSG }]); - setStreamingContent(""); - }; - - const handleLoadSession = (id: string) => { - const session = sessions.find(s => s.id === id); - if (!session) return; - setActiveSessionId(id); - setMessages(session.messages.length > 0 - ? session.messages.map(m => ({ ...m, streaming: false })) - : [{ ...WELCOME_MSG }] - ); - setStreamingContent(""); - if (session.provider) setActiveProvider(session.provider); - if (session.model) setActiveModel(session.model); - }; - - const handleDeleteSession = (id: string, e: React.MouseEvent) => { - e.stopPropagation(); - const remaining = sessions.filter(s => s.id !== id); - setSessions(prev => { - const next = prev.filter(s => s.id !== id); - saveSessions(next); - return next; - }); - if (activeSessionId === id) { - if (remaining.length > 0) { - const nextSession = remaining[0]; - setActiveSessionId(nextSession.id); - setMessages(nextSession.messages.length > 0 - ? nextSession.messages.map(m => ({ ...m, streaming: false })) - : [{ ...WELCOME_MSG }] - ); - } else { - const session = createNewSession(activeProvider, activeModel); - setSessions(prev => [...prev, session]); - setActiveSessionId(session.id); - setMessages([{ ...WELCOME_MSG }]); - } + /* ── Start Ollama ── */ + const handleStartOllama = async () => { + setOllamaActionMsg("Starting Ollama..."); + try { + const resp = await fetch("/api/ollama/start", { method: "POST" }); + const data = await resp.json(); + setOllamaActionMsg(data.message || data.error || "Done"); + await fetchOllamaStatus(); + } catch (e) { + setOllamaActionMsg(`Error: ${e}`); } }; - const handleStartRename = (id: string, e: React.MouseEvent) => { - e.stopPropagation(); - const session = sessions.find(s => s.id === id); - if (session) { - setEditingSessionId(id); - setEditLabel(session.label); + /* ── Pull Ollama model ── */ + const handlePullOllamaModel = async () => { + setOllamaActionMsg(`Pulling ${ollamaModel}...`); + try { + const resp = await fetch("/api/ollama/pull", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: ollamaModel }), + }); + const data = await resp.json(); + setOllamaActionMsg(data.message || data.error || "Done"); + // Poll for model list changes + setTimeout(fetchOllamaStatus, 5000); + } catch (e) { + setOllamaActionMsg(`Error: ${e}`); } }; - const handleFinishRename = (id: string) => { - const trimmed = editLabel.trim(); - if (trimmed) { - setSessions(prev => { - const next = prev.map(s => s.id === id ? { ...s, label: trimmed, updated: Date.now() } : s); - saveSessions(next); - return next; - }); - } - setEditingSessionId(null); - setEditLabel(""); + /* ── Toggle Ollama mode ── */ + const handleToggleOllama = () => { + setUseOllama(prev => !prev); }; - const handleRenameKeyDown = (e: React.KeyboardEvent, id: string) => { - if (e.key === "Enter") handleFinishRename(id); - if (e.key === "Escape") setEditingSessionId(null); + /* ── Cycle font size ── */ + const cycleFontSize = () => { + const sizes: FontSize[] = ["normal", "large", "xlarge"]; + const idx = sizes.indexOf(fontSize); + const next = sizes[(idx + 1) % sizes.length]; + setFontSize(next); + saveFontSize(next); }; - /* ── Streaming Generation ── */ - const runStreamingGeneration = useCallback(async (prompt: string, opts?: { forceModel?: string }) => { - setBuilding(true); - setShowTemplates(false); - addMessage({ role: "user", content: prompt, timestamp: Date.now() }); + /* ── Connect to SSE stream for a session ──────────────── */ + const doConnectSSE = (sessionId: string, userCmd: string) => { + // Close any existing EventSource + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } - setMessages(prev => [...prev, { - role: "assistant", - content: "", - timestamp: Date.now(), - streaming: true, - }]); + const es = new EventSource(`/api/opencode/session-stream/${sessionId}`); + eventSourceRef.current = es; - // Determine effective model — fast mode overrides to lighter model - const effectiveModel = opts?.forceModel || (fastMode ? getFastModel(activeProvider) || activeModel : activeModel); - const effectiveFormat = jsonFormat ? "json" : null; + es.addEventListener("connected", () => { + // SSE connection established + }); - let fullContent = ""; + es.addEventListener("output", (event: MessageEvent) => { + try { + const parsed = JSON.parse(event.data); + if (parsed.text) { + setOutput(prev => prev + parsed.text + "\n"); + } + } catch {} + }); - let tauriEvent: any = null; - try { - tauriEvent = await import('@tauri-apps/api/event'); - } catch { /* Not in Tauri */ } - - if (!tauriEvent?.listen) { - // ── Non-Tauri fallback: call Ollama directly from browser ── - if (activeProvider === "ollama") { - try { - // Try streaming first (preferred for responsiveness) - await directOllamaStream(effectiveModel, prompt, effectiveFormat, (token, done) => { - fullContent += token; - setStreamingContent(fullContent); - if (done) { - setMessages(prev => { - const next = [...prev]; - const lastMsg = next[next.length - 1]; - if (lastMsg && lastMsg.streaming) { - lastMsg.content = fullContent || "(empty response)"; - lastMsg.streaming = false; - } - return next; - }); - setStreamingContent(""); - setBuilding(false); - setCurrentStep(-1); - } - }); - } catch (streamErr) { - // Fallback to non-streaming if streaming fails - try { - fullContent = await directOllamaGenerate(effectiveModel, prompt, effectiveFormat); - setMessages(prev => { - const next = [...prev]; - const lastMsg = next[next.length - 1]; - if (lastMsg && lastMsg.streaming) { - lastMsg.content = fullContent || "(empty response)"; - lastMsg.streaming = false; - } + es.addEventListener("done", (event: MessageEvent) => { + try { + const evtData = JSON.parse(event.data); + // event data has final output, error, duration + // We re-fetch from server for the exact full output + fetch(`/api/opencode/session/${sessionId}`) + .then(r => r.json()) + .then(d => { + const finalOutput = d.session?.output || evtData.output || ""; + // Save to history + const entry: HistoryEntry = { + id: genId(), + prompt: userCmd, + response: finalOutput, + model: activeModel, + timestamp: Date.now(), + }; + setHistory(prev => { + const next = [entry, ...prev]; + saveHistory(next); return next; }); - setStreamingContent(""); - setBuilding(false); - } catch (e) { - addMessage({ role: "system", content: `❌ Ollama: ${e}. Make sure Ollama is running on localhost:11434`, timestamp: Date.now() }); - setBuilding(false); - } - } - return; - } - - // For non-Ollama providers outside Tauri, show a helpful hint - addMessage({ - role: "system", - content: `⚠️ The "${activeProvider}" provider requires the dweb desktop app (Tauri).\n\n` + - `👉 Either:\n` + - ` • Run \`npx tauri dev\` to open the desktop app\n` + - ` • Switch to **Ollama (Local)** which works directly in the browser\n\n` + - `Ollama is free and runs entirely on your machine.`, - timestamp: Date.now(), - }); - setBuilding(false); - return; - } - - const { listen } = tauriEvent; - const typedListen = listen as unknown as (event: string, handler: (e: { payload: T }) => void) => Promise<() => void>; - - const unlistenToken = await typedListen("ai:token", (event) => { - const { token, done } = event.payload; - fullContent += token; - setStreamingContent(fullContent); - if (done) { - setMessages(prev => { - const next = [...prev]; - const lastMsg = next[next.length - 1]; - if (lastMsg && lastMsg.streaming) { - lastMsg.content = fullContent; - lastMsg.streaming = false; - } - return next; - }); - setStreamingContent(""); - setBuilding(false); - setCurrentStep(-1); + setOutput(finalOutput); + setRunning(false); + }) + .catch(() => { + setRunning(false); + }); + } catch { + setRunning(false); } + es.close(); + eventSourceRef.current = null; + sessionStorage.removeItem(SESSION_KEY); }); - const unlistenError = await typedListen("ai:error", (event) => { - addMessage({ role: "system", content: `❌ Error: ${event.payload}`, timestamp: Date.now() }); - setStreamingContent(""); - setBuilding(false); - setCurrentStep(-1); + es.addEventListener("error", (event: Event) => { + // EventSource auto-reconnects; only handle when we get error data + try { + const msgEvent = event as MessageEvent; + const parsed = JSON.parse(msgEvent.data || "{}"); + if (parsed.error) { + setOutput(prev => prev + `\n❌ ${parsed.error}`); + setRunning(false); + es.close(); + eventSourceRef.current = null; + sessionStorage.removeItem(SESSION_KEY); + } + } catch {} }); + }; + + // Store connectSSE in ref so useEffect can call it + connectSSE.current = doConnectSSE; + + /* ── Run opencode prompt via streaming SSE ────────────── */ + const handleRun = async (customPrompt?: string) => { + const userCmd = (customPrompt || prompt).trim(); + if (!userCmd) return; + // Prepend system context so opencode understands the environment + const fullCmd = `${DWEB_CONTEXT}\n\nUSER REQUEST:\n${userCmd}`; + setCurrentPrompt(userCmd); + setOutput(""); + setShowOutput(true); + setRunning(true); + setPrompt(""); + setShowHistory(false); + setShowModelPicker(false); try { - await invoke("ai_generate_stream", { - prompt, - providerType: activeProvider, - model: effectiveModel, - responseFormat: effectiveFormat, + // 1. Create a streaming session + const resp = await fetch("/api/opencode/stream", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + command: fullCmd, + model: useOllama ? `ollama/${ollamaModel}` : activeModel, + useOllama, + }), }); + const data = await resp.json(); + + if (data.status !== "ok" || !data.sessionId) { + setOutput(`⚠️ Failed to start session: ${data.error || "unknown error"}`); + setRunning(false); + return; + } + + // 2. Store session ID so it survives tab switches + sessionStorage.setItem(SESSION_KEY, data.sessionId); + + // 3. Connect to SSE stream for live output + doConnectSSE(data.sessionId, userCmd); } catch (e) { - addMessage({ role: "system", content: `❌ Failed to start: ${e}`, timestamp: Date.now() }); - setBuilding(false); + setOutput(`❌ Network error: ${e}`); + setRunning(false); + } + }; + + /* ── Stop running session ── */ + const handleStop = async () => { + const sessionId = sessionStorage.getItem(SESSION_KEY); + if (sessionId) { + try { + await fetch(`/api/opencode/session/${sessionId}/cancel`, { method: "POST" }); + } catch {} } + // Close EventSource + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + setRunning(false); + setOutput(prev => prev + "\n\n❌ Cancelled by user"); + sessionStorage.removeItem(SESSION_KEY); + }; + + /* ── Re-run a history entry ── */ + const handleReRun = (entry: HistoryEntry) => { + setActiveModel(entry.model); + saveModel(entry.model); + setPrompt(entry.prompt); + handleRun(entry.prompt); + }; - setTimeout(() => { unlistenToken(); unlistenError(); }, 30000); - }, [activeProvider, activeModel, fastMode, jsonFormat, messages.length, getFastModel]); + /* ── Load history entry into prompt ── */ + const handleLoadPrompt = (entry: HistoryEntry) => { + setPrompt(entry.prompt); + setShowHistory(false); + }; - const handleSend = () => { - if (!input.trim() || building) return; - runStreamingGeneration(input.trim()); - setInput(""); + /* ── Clear history ── */ + const handleClearHistory = () => { + setHistory([]); + saveHistory([]); }; - const handleTemplate = async (template: Template) => { - const prompt = `Build a ${template.name}: ${template.description} using ${template.stack}`; - runStreamingGeneration(prompt); + /* ── Quick action ── */ + const handleQuickAction = (action: typeof QUICK_ACTIONS[0]) => { + if (action.prompt === "INSTANT_PUBLISH") { + handleGlobalHost(); + } else { + handleRun(action.prompt); + } }; + /* ── Handle Enter key ── */ const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); - handleSend(); + handleRun(); } }; - /* ── Custom Stack ── */ - const buildCustomStackPrompt = () => { - const runtime = RUNTIME_OPTIONS.find(o => o.value === stackRuntime); - const frontend = FRONTEND_OPTIONS.find(o => o.value === stackFrontend); - const backend = BACKEND_OPTIONS.find(o => o.value === stackBackend); - const db = DATABASE_OPTIONS.find(o => o.value === stackDatabase); - const css = CSS_OPTIONS.find(o => o.value === stackCss); - - const parts = [ - `Build a project with the following stack:`, - `- Runtime: ${runtime?.label || stackRuntime}`, - frontend && frontend.value !== "none" ? `- Frontend: ${frontend.label}` : `- Frontend: None (API only)`, - `- Backend: ${backend?.label || stackBackend}`, - db && db.value !== "none" ? `- Database: ${db.label}` : `- Database: None`, - `- CSS: ${css?.label || stackCss}`, - ]; - - if (stackDescription.trim()) { - parts.push(`\nAdditional instructions:\n${stackDescription.trim()}`); + /* ── Select model ── */ + const handleSelectModel = (model: OpenCodeModel) => { + setActiveModel(model.id); + saveModel(model.id); + setShowModelPicker(false); + }; + + /* ── Publish last output ── */ + const handlePublish = async () => { + if (!output.trim()) return; + setPublishing(true); + const codeBlocks = output.match(/```[\s\S]*?```/g) || []; + const files = codeBlocks.map((block, i) => { + const lines = block.split("\n"); + const header = lines[0].replace("```", "").trim(); + const lang = header.split(" ")[0] || "txt"; + const content = lines.slice(1, -1).join("\n"); + const ext: Record = { + javascript: "js", js: "js", typescript: "ts", ts: "ts", + html: "html", css: "css", python: "py", py: "py", + jsx: "jsx", tsx: "tsx", json: "json", yaml: "yaml", yml: "yml", + markdown: "md", md: "md", bash: "sh", sh: "sh", + }; + return { path: `file_${i + 1}.${ext[lang] || "txt"}`, content, language: lang }; + }); + if (files.length === 0) { + setOutput(prev => prev + "\n\n---\n⚠ No code blocks found in the output."); + setPublishing(false); + return; } + try { + const projectName = `app-${Date.now().toString(36)}`; + const resp = await fetch("/api/publish", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: projectName, type: "Web App", files }), + }); + const data = await resp.json(); + if (data.status === "ok") { + setPublished(prev => [{ name: projectName, url: data.url, route: data.project.route }, ...prev]); + setOutput(prev => prev + `\n\n---\n✅ Published → ${data.url}`); + } else { + setOutput(prev => prev + `\n\n---\n❌ Publish failed: ${data.error}`); + } + } catch (e) { + setOutput(prev => prev + `\n\n---\n❌ Publish error: ${e}`); + } + setPublishing(false); + }; + + /* ── Instant Global Host (hello-dweb) ── */ + const handleGlobalHost = async () => { + setRunning(true); + setShowOutput(true); + setCurrentPrompt("Publishing hello-dweb with global .dweb domain..."); + setOutput("🚀 Initializing global hosting for hello-dweb...\n"); + + try { + // 1. Read the pre-built hello-dweb files from the server + setOutput(prev => prev + "📂 Reading hello-dweb project files...\n"); + const filesResp = await fetch("/project/hello-dweb/index.html"); + const aboutResp = await fetch("/project/hello-dweb/about.html"); + const cssResp = await fetch("/project/hello-dweb/style.css"); + + if (!filesResp.ok) { + setOutput(prev => prev + "❌ hello-dweb project not found. Build it first via opencode.\n"); + setRunning(false); + return; + } + + const indexHtml = await filesResp.text(); + const aboutHtml = aboutResp.ok ? await aboutResp.text() : ""; + const styleCss = cssResp.ok ? await cssResp.text() : ""; + + // 2. Publish with auto_domain=true (triggers server-side domain auto-assignment) + setOutput(prev => prev + "📡 Publishing to dweb with auto-domain assignment...\n"); + const files = [ + { path: "index.html", content: indexHtml }, + { path: "about.html", content: aboutHtml }, + { path: "style.css", content: styleCss }, + ]; + + const pubResp = await fetch("/api/publish", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: "hello-dweb", + type: "Static Site", + files, + auto_domain: true, + }), + }); + + const pubData = await pubResp.json(); + if (pubData.status !== "ok") { + setOutput(prev => prev + `❌ Publish failed: ${pubData.error || "unknown"}\n`); + setRunning(false); + return; + } + + // 3. Build the result display + const localUrl = pubData.url; + const domainInfo = pubData.domain; + let resultMsg = `\n✅ hello-dweb published successfully!\n`; + resultMsg += ` Local URL: ${localUrl}\n`; + + if (domainInfo) { + resultMsg += `\n🌐 GLOBAL DOMAIN: ${domainInfo.url}\n`; + resultMsg += ` Domain: ${domainInfo.name}\n`; + resultMsg += ` Status: ${domainInfo.auto_registered ? "Auto-registered (Free tier, 90d)" : "Already registered"}\n`; + resultMsg += `\n🔗 Other dweb users can access: ${domainInfo.url}\n`; + } else { + resultMsg += `\n⚠ No .dweb domain assigned. Register one in the Domains tab.\n`; + } - return parts.join("\n"); + // 4. Update published list + setPublished(prev => [{ + name: "hello-dweb", + url: localUrl, + route: pubData.project?.route || "/project/hello-dweb", + }, ...prev.filter(p => p.name !== "hello-dweb")]); + + setOutput(prev => prev + resultMsg); + } catch (e: any) { + setOutput(prev => prev + `\n❌ Error: ${e.message || e}\n`); + } + setRunning(false); }; - const handleBuildCustomStack = () => { - const prompt = buildCustomStackPrompt(); - setShowCustomStack(false); - runStreamingGeneration(prompt); + /* ── Copy output to clipboard ── */ + const handleCopyOutput = async () => { + try { + await navigator.clipboard.writeText(output); + // brief visual feedback + const btn = document.getElementById("copy-output-btn"); + if (btn) { btn.textContent = "Copied!"; setTimeout(() => { btn.textContent = ""; }, 1200); } + } catch {} }; - const currentProviderInfo = providers.find(p => p.provider_type === activeProvider); - const providerColor = AI_PROVIDER_COLORS[activeProvider] || "#7C3AED"; - const providerIcon = AI_PROVIDER_ICONS[activeProvider] || "🤖"; - const enabledProviders = providers.filter(p => p.enabled); - const activeSessionLabel = sessions.find(s => s.id === activeSessionId)?.label || "New Chat"; + /* ── Clear output ── */ + const handleClear = () => { setOutput(""); setShowOutput(false); }; + + /* ── Categorize models for display ── */ + const freeModels = models.filter(m => m.free); + const proModels = models.filter(m => !m.free); + + const scale = FONT_SCALES[fontSize]; + + /* ── Inline font-size helper ── */ + const fs = (px: number) => `${Math.round(px * scale)}px`; /* ── Render ── */ return ( -
- {/* ─── Header ───────────────────────────────────────── */} -
-
- +
+ {/* ─── HEADER ─────────────────────────────────────── */} +
+
+
-

AI Build Agent

-

- {activeSessionLabel} - - {providerIcon} {activeModel} +

dweb Opencode Agent

+

+ + {repoInfo ? `${repoInfo.repo} / ${repoInfo.branch} · ${repoInfo.commit} · ${repoInfo.files} files` : "Loading repo..."}

-
- - - -
-
- {/* ─── Body: Session sidebar + main chat ────────────── */} -
- {/* Session Sidebar */} -
-
- - Sessions - - -
-
- {sessions.length === 0 && ( -
- No sessions yet -
- )} - {[...sessions] - .sort((a, b) => b.updated - a.updated) - .map(s => { - const isActive = s.id === activeSessionId; - const isEditing = editingSessionId === s.id; - const dateStr = new Date(s.updated).toLocaleDateString(); - return ( -
!isEditing && handleLoadSession(s.id)} + {showModelPicker && ( +
+ {/* Free models */} +
+ Free Models ({freeModels.length}) +
+ {freeModels.map(m => ( +
handleSelectModel(m)} style={{ - padding: "8px 10px", - borderRadius: 6, - cursor: isEditing ? "default" : "pointer", - background: isActive ? "rgba(59, 130, 246, 0.12)" : "transparent", - border: isActive ? "1px solid rgba(59, 130, 246, 0.2)" : "1px solid transparent", - transition: "background 0.15s", - }} - onMouseEnter={e => { - if (!isActive && !isEditing) - (e.currentTarget as HTMLElement).style.background = "var(--bg-glass)"; - }} - onMouseLeave={e => { - if (!isActive) - (e.currentTarget as HTMLElement).style.background = "transparent"; - }} - > - {isEditing ? ( - setEditLabel(e.target.value)} - onBlur={() => handleFinishRename(s.id)} - onKeyDown={e => handleRenameKeyDown(e, s.id)} - onClick={e => e.stopPropagation()} + padding: "7px 10px", borderRadius: 4, cursor: "pointer", + display: "flex", alignItems: "center", gap: 8, + background: activeModel === m.id ? "rgba(34,197,94,0.1)" : "transparent", + color: activeModel === m.id ? "#22c55e" : "var(--text-primary)", + }}> +
+
+
{m.label}
+
{m.provider}
+
+ {activeModel === m.id && active} +
+ ))} + {/* Pro/Paid models */} + {proModels.length > 0 && ( + <> +
+ Pro Models ({proModels.length}) +
+ {proModels.map(m => ( +
handleSelectModel(m)} style={{ - width: "100%", - background: "var(--bg-elevated)", - border: "1px solid var(--accent-blue)", - borderRadius: 4, - padding: "2px 6px", - color: "var(--text-primary)", - fontSize: 12, - outline: "none", - }} - /> - ) : ( -
- - {s.label} - - {dateStr} - - -
- )} - {s.summary && !isEditing && ( -
- {s.summary} + padding: "7px 10px", borderRadius: 4, cursor: "pointer", + display: "flex", alignItems: "center", gap: 8, + background: activeModel === m.id ? "rgba(129,140,248,0.1)" : "transparent", + color: activeModel === m.id ? "#818cf8" : "var(--text-primary)", + opacity: 0.7, + }}> +
+
+
{m.label}
+
{m.provider}
+
+ {activeModel === m.id && active}
- )} -
- ); - })} -
-
- - {/* Main Chat Area */} -
- - {/* ─── Provider / Model Selector ────────────────────── */} -
-
- - -
-
- - -
- {currentProviderInfo && ( -
- - {currentProviderInfo.enabled ? "Ready" : "Disabled"}
)} - {/* Fast Mode Toggle */} -
- - {fastMode && getFastModel(activeProvider) && ( - - using {getFastModel(activeProvider)} - - )} -
- - {/* Model Speed Badge */} - {(() => { - const modelToCheck = fastMode && getFastModel(activeProvider) ? getFastModel(activeProvider) : activeModel; - const speed = getModelSpeed(modelToCheck); - if (!speed) return null; - const colors = { fast: "#22c55e", balanced: "#eab308", powerful: "#ef4444" }; - const labels = { fast: "Fast", balanced: "Balanced", powerful: "Powerful" }; - return ( - - {labels[speed]} - - ); - })()} - -
- {/* ─── Custom Stack Panel ─────────────────────────── */} + {/* Opencode status */}
- - - {showCustomStack && ( -
-
-
- - -
-
- - -
-
- - + {/* Status line */} +
+ + Local AI (Ollama) + + + {!ollamaStatus ? "Checking..." + : ollamaStatus.running ? "Running" + : ollamaStatus.installed ? "Stopped" + : "Not installed"} + +
+ + {/* Platform & detection info */} + {ollamaStatus && ( +
+
Platform: {ollamaStatus.platform}{ollamaStatus.isWSL ? " (WSL)" : ""}
+
Port: {ollamaStatus.port}
+ {ollamaStatus.detectedVia && ( +
+ + Detected via: {ollamaStatus.detectedVia} + {ollamaStatus.detectedVia === "wsl-host" && ( + (Windows host Ollama) + )} + {ollamaStatus.detectedVia === "docker" && ( + (Docker container) + )} + {ollamaStatus.detectedVia === "native" && ( + (same machine) + )} +
+ )} + {ollamaStatus.modelCount > 0 && ( +
{ollamaStatus.modelCount} model{ollamaStatus.modelCount !== 1 ? "s" : ""} loaded
+ )}
-
- - + Use for prompts + + + )} +
+ + {/* Model selector (when running) */} + {ollamaStatus?.running && ( +
+ + {ollamaStatus.modelCount > 0 && ( + + +{ollamaStatus.models.length} local + + )}
-
- - + )} + + {/* Action status message */} + {ollamaActionMsg && ( +
+ {ollamaActionMsg}
-
-