From 1f7e696f1c447b90b0c15ecd3396119c875b0613 Mon Sep 17 00:00:00 2001 From: diyaa Date: Sun, 26 Jul 2026 16:14:38 +0200 Subject: [PATCH] feat: persistent queue, pairing sessions, read receipts --- TASKS.md | 427 ++++++++++++++++++++++++++++++++ relay-server/Dockerfile | 2 - relay-server/docker-compose.yml | 6 +- relay-server/src/index.js | 271 ++++++++++++++++---- 4 files changed, 649 insertions(+), 57 deletions(-) create mode 100644 TASKS.md diff --git a/TASKS.md b/TASKS.md new file mode 100644 index 0000000..b5a0f42 --- /dev/null +++ b/TASKS.md @@ -0,0 +1,427 @@ +# Fchati — Current State & Tasks + +> **Important:** The Mac app must be built entirely without opening Xcode. +> Use `Package.swift` (Swift Package Manager) as the project structure. +> All tasks are agent-ready: copy the prompt and send it directly to your coding agent. + +--- + +## Current State + +### Done + +| Part | Details | +|------|---------| +| `relay-server/` | Full Node.js server — Pairing, WebSocket, file upload/download | +| `Dockerfile` | Ready, small Alpine image | +| `docker-compose.yml` | Ready with Traefik labels for fchati.diyaa.de | +| Git repo | Pushed to git.mohfarawati.de/diyaa/fchaty | + +### Not built yet + +The Mac app does not exist yet. + +--- + +## Target App Structure + +``` +f-chaty-native-new/ +├── relay-server/ ✅ done +└── FchatiApp/ + ├── Package.swift no Xcode — SPM only + ├── Sources/ + │ └── FchatiApp/ + │ ├── FchatiApp.swift + │ ├── Session/ + │ │ └── AppSession.swift + │ ├── Networking/ + │ │ ├── RelayAPI.swift + │ │ ├── WSClient.swift + │ │ └── MessageRouter.swift + │ ├── Storage/ + │ │ ├── KeychainStore.swift + │ │ └── MessageStore.swift + │ └── Features/ + │ ├── Chat/ + │ │ ├── ChatView.swift + │ │ ├── ChatViewModel.swift + │ │ └── MessageBubble.swift + │ ├── Pairing/ + │ │ ├── PairingView.swift + │ │ └── PairingViewModel.swift + │ └── Settings/ + │ └── SettingsView.swift + └── FchatiApp.entitlements +``` + +--- + +## Tasks + +> Each task is fully independent. Send any one to a coding agent without context from the others. + +--- + +### TASK-01 — Swift Package + App Shell + +**Priority:** First — everything else depends on this +**Files:** `FchatiApp/Package.swift`, `FchatiApp/Sources/FchatiApp/FchatiApp.swift`, `FchatiApp.entitlements` +**Independent:** Yes + +**Requirements:** + +Create `FchatiApp/Package.swift`: +```swift +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "FchatiApp", + platforms: [.macOS(.v14)], + targets: [ + .executableTarget( + name: "FchatiApp", + path: "Sources/FchatiApp" + ) + ] +) +``` + +Create `FchatiApp/Sources/FchatiApp/FchatiApp.swift`: +- `@main` SwiftUI App +- Uses `MenuBarExtra` with `.window` style +- Menu bar icon: `Image(systemName: "message")` +- Popover width: 360pt, min height: 480pt +- A root view with three tabs: Chat, Pairing, Settings (segmented picker at top) + +Create `FchatiApp/FchatiApp.entitlements`: +```xml + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + com.apple.security.network.server + + + +``` + +Verify: `swift build` succeeds from `FchatiApp/` directory. + +--- + +### TASK-02 — KeychainStore + +**Priority:** Second +**File:** `FchatiApp/Sources/FchatiApp/Storage/KeychainStore.swift` +**Independent:** Yes — no dependencies on other app files + +**Requirements:** + +Write `KeychainStore` as an enum with static methods. Use `Security.framework` directly, no third-party libraries. + +Service name: `"de.diyaa.fchati"` + +```swift +// Required interface: +KeychainStore.installationID // String — generated once, persists forever (UUID) +KeychainStore.authToken // String? — get/set/delete +KeychainStore.peerID // String? — get/set/delete +KeychainStore.peerName // String? — get/set/delete +KeychainStore.clearAll() // removes token, peerID, peerName (keeps installationID) +``` + +`installationID` must auto-generate and save on first access. +All properties must be gettable and settable via Swift property syntax. + +--- + +### TASK-03 — RelayAPI (HTTP) + +**Priority:** Second (parallel with TASK-02) +**File:** `FchatiApp/Sources/FchatiApp/Networking/RelayAPI.swift` +**Independent:** Yes + +**Server base URL:** `https://fchati.diyaa.de` + +**Requirements:** + +Write `RelayAPI` as an actor using `URLSession` with async/await. No third-party libraries. + +```swift +// Required interface: +actor RelayAPI { + static let shared: RelayAPI + + func createPairing(displayName: String) async throws -> PairingCreateResponse + func joinPairing(code: String, displayName: String) async throws -> PairingJoinResponse + func uploadFile(url: URL, token: String) async throws -> FileUploadResponse + func downloadFile(id: String, token: String) async throws -> Data +} + +struct PairingCreateResponse: Codable { + let code: String + let token: String + let peerID: String + let expiresAt: String +} + +struct PairingJoinResponse: Codable { + let token: String + let peerID: String + let peer: PeerInfo +} + +struct PeerInfo: Codable { + let id: String + let displayName: String +} + +struct FileUploadResponse: Codable { + let id: String + let name: String + let size: Int +} + +enum RelayAPIError: Error { + case networkError(Error) + case serverError(Int) + case decodingError(Error) +} +``` + +File upload uses `multipart/form-data`, field name `"file"`. + +--- + +### TASK-04 — WSClient (WebSocket) + +**Priority:** Second (parallel) +**File:** `FchatiApp/Sources/FchatiApp/Networking/WSClient.swift` +**Independent:** Yes + +**Requirements:** + +Write `WSClient` as an actor using `URLSessionWebSocketTask`. No third-party libraries. Deployment target: macOS 14. + +```swift +// Required interface: +actor WSClient { + init(url: URL) + func connect(token: String) async throws + func disconnect() + func send(_ message: WSMessage) async throws + var incoming: AsyncStream { get } +} + +struct WSMessage: Codable { + var type: String + var id: String? + var body: String? + var to: String? + var from: String? + var fromName: String? + var token: String? + var peerID: String? + var reason: String? +} +``` + +Behavior: +1. On `connect`: open WebSocket, immediately send `{ type: "auth", token: "..." }` +2. Wait for `{ type: "auth.ok" }` — throw if not received within 10 seconds +3. After auth: all incoming messages flow into `incoming` AsyncStream +4. Send `{ type: "ping" }` every 30 seconds to keep connection alive +5. On disconnect: attempt reconnect with backoff: 2s, 4s, 8s, 16s, 32s, then stop + +--- + +### TASK-05 — AppSession (State Machine) + +**Priority:** Third — after TASK-02, TASK-03, TASK-04 +**File:** `FchatiApp/Sources/FchatiApp/Session/AppSession.swift` +**Depends on:** `KeychainStore`, `RelayAPI`, `WSClient` + +**Requirements:** + +```swift +enum AppState: Equatable { + case unpaired + case connecting + case connected(peerID: String, peerName: String) + case error(String) +} + +@MainActor +final class AppSession: ObservableObject { + static let shared = AppSession() + + @Published var state: AppState = .unpaired + @Published var messages: [ChatMessage] = [] + + // Pairing + func createPairingCode(displayName: String) async throws -> String // returns code + func joinWithCode(_ code: String, displayName: String) async throws + + // Messaging + func sendText(_ body: String) async throws + func sendFile(url: URL) async throws + + // Lifecycle + func unpair() +} +``` + +On init: read token from `KeychainStore`. If present, attempt `WSClient.connect` immediately. +On incoming `chat.message`: append to `messages` and trigger a `UNUserNotification` if app is in background. + +--- + +### TASK-06 — PairingView + +**Priority:** Fourth — after TASK-05 +**Files:** `Features/Pairing/PairingView.swift`, `PairingViewModel.swift` + +**Requirements:** + +Two modes switchable via a segmented picker: "Create Code" / "Enter Code". + +**Create Code mode:** +- Text field for display name +- "Create Code" button → calls `AppSession.shared.createPairingCode` +- Shows the returned code (e.g. `FCHT-AB3K7Q`) in a monospaced font with a Copy button +- Shows "Code expires in 5 minutes" +- Shows a spinner while waiting for the other peer to join + +**Enter Code mode:** +- Text field for display name +- Text field for code — auto-uppercases, accepts with or without `FCHT-` prefix +- "Connect" button → calls `AppSession.shared.joinWithCode` +- Shows inline error if code is wrong or expired + +Both modes observe `AppSession.shared.state` and navigate to `ChatView` when state becomes `.connected`. + +--- + +### TASK-07 — ChatView + +**Priority:** Fourth (parallel with TASK-06) +**Files:** `Features/Chat/ChatView.swift`, `ChatViewModel.swift`, `MessageBubble.swift` + +**Requirements:** + +`MessageBubble`: +- Sent messages: right-aligned, blue bubble +- Received messages: left-aligned, gray bubble +- Render message body using `AttributedString(markdown:)` for Markdown support +- Show timestamp below bubble + +`ChatView`: +- `ScrollViewReader` with auto-scroll to latest message +- Message list from `AppSession.shared.messages` +- Text input bar at bottom + Send button (also triggers on Return key) +- Paperclip button 📎 opens `NSOpenPanel` for file selection → calls `AppSession.shared.sendFile` +- Microphone button 🎙 — hold to record (`AVAudioRecorder`, `.m4a`), release to send as file + +--- + +### TASK-08 — MessageStore (Local Persistence) + +**Priority:** Fourth (parallel) +**File:** `FchatiApp/Sources/FchatiApp/Storage/MessageStore.swift` +**Independent:** Yes + +**Requirements:** + +```swift +struct ChatMessage: Codable, Identifiable, Equatable { + let id: String + let from: String + let fromName: String + let body: String + let sentAt: Date + let attachment: AttachmentInfo? + var isRead: Bool +} + +struct AttachmentInfo: Codable, Equatable { + let fileID: String + let name: String + let size: Int +} + +actor MessageStore { + static let shared: MessageStore + + func save(_ message: ChatMessage) throws + func loadAll() throws -> [ChatMessage] + func markRead(id: String) throws + var unreadCount: Int { get async } +} +``` + +Storage: JSON file at `FileManager.default.urls(for: .applicationSupportDirectory, ...).first!.appendingPathComponent("de.diyaa.fchati/messages.json")`. +Create the directory if it does not exist. + +--- + +### TASK-09 — SettingsView + +**Priority:** Fifth +**File:** `Features/Settings/SettingsView.swift` + +**Requirements:** + +Simple settings screen: +- Display current user name (read from `KeychainStore.peerName`) with an Edit button +- Show connection status: "Connected to [peer name]" or "Not connected" +- "Unpair" button → calls `AppSession.shared.unpair()` and clears Keychain +- App version at the bottom (read from `Bundle.main.infoDictionary["CFBundleShortVersionString"]`) + +--- + +### TASK-10 — New Message Notifications + +**Priority:** Fifth (independent) +**File:** `FchatiApp/Sources/FchatiApp/Notifications/NotificationManager.swift` + +**Requirements:** + +```swift +final class NotificationManager { + static let shared = NotificationManager() + + func requestPermission() async + func notify(from senderName: String, body: String) +} +``` + +- `requestPermission()` called once on first launch +- `notify` fires a `UNUserNotificationRequest` with title = sender name, body = first 100 chars of message body +- Do not send a notification if the menu bar popover is currently visible + +--- + +## How to Send a Task to a Coding Agent + +Copy the block below, fill in the task number and paste it to your agent: + +``` +Context: +- Repo: https://git.mohfarawati.de/diyaa/fchaty.git +- App folder: FchatiApp/ (Swift Package, no Xcode, SPM only) +- macOS 14+, Swift 5.9+, no third-party libraries +- Relay server is already running at https://fchati.diyaa.de + +Task: [paste the full task section here, e.g. TASK-04] + +After writing the code: +1. Make sure it compiles: run `swift build` from the FchatiApp/ directory +2. Fix any compiler errors before finishing +3. Do not open Xcode or create any .xcodeproj or .xcworkspace files +``` diff --git a/relay-server/Dockerfile b/relay-server/Dockerfile index fdf88f3..769771f 100644 --- a/relay-server/Dockerfile +++ b/relay-server/Dockerfile @@ -1,10 +1,8 @@ -# المرحلة الأولى: تنزيل الـ dependencies فقط FROM node:20-alpine AS deps WORKDIR /app COPY package.json ./ RUN npm install --omit=dev -# المرحلة الثانية: الـ image النهائي FROM node:20-alpine WORKDIR /app diff --git a/relay-server/docker-compose.yml b/relay-server/docker-compose.yml index 2c03bae..d6ca630 100644 --- a/relay-server/docker-compose.yml +++ b/relay-server/docker-compose.yml @@ -6,20 +6,22 @@ services: env_file: .env volumes: - fchati-files:/data/files + - fchati-queue:/data/queue + - fchati-pairing:/data/pairing networks: - traefik-net labels: - "traefik.enable=true" - "traefik.http.routers.fchati.rule=Host(`fchati.diyaa.de`)" - "traefik.http.routers.fchati.entrypoints=websecure" - # غيّر letsencrypt إذا كان اسم certresolver عندك مختلف - "traefik.http.routers.fchati.tls.certresolver=letsencrypt" - "traefik.http.services.fchati.loadbalancer.server.port=3000" volumes: fchati-files: + fchati-queue: + fchati-pairing: networks: traefik-net: - # غيّر هذا ليطابق اسم network Traefik عندك external: true diff --git a/relay-server/src/index.js b/relay-server/src/index.js index 1d21375..123871c 100644 --- a/relay-server/src/index.js +++ b/relay-server/src/index.js @@ -3,22 +3,28 @@ const { WebSocketServer } = require('ws'); const { createServer } = require('http'); const { randomBytes, randomUUID } = require('crypto'); const multer = require('multer'); -const path = require('path'); const fs = require('fs'); +const path = require('path'); -// ─── إعداد ─────────────────────────────────────────────────────────────────── +// ─── Config ─────────────────────────────────────────────────────────────────── const PORT = process.env.PORT || 3000; const MAX_FILE_BYTES = parseInt(process.env.MAX_FILE_SIZE_MB ?? '25') * 1024 * 1024; const UPLOADS_DIR = process.env.UPLOADS_DIR ?? '/data/files'; -const PAIRING_TTL_MS = 5 * 60 * 1000; // 5 دقائق +const QUEUE_DIR = process.env.QUEUE_DIR ?? '/data/queue'; +const PAIRING_DIR = process.env.PAIRING_DIR ?? '/data/pairing'; +const PAIRING_TTL_MS = 5 * 60 * 1000; +const QUEUE_TTL_MS = 7 * 24 * 60 * 60 * 1000; +const QUEUE_MAX = 500; fs.mkdirSync(UPLOADS_DIR, { recursive: true }); +fs.mkdirSync(QUEUE_DIR, { recursive: true }); +fs.mkdirSync(PAIRING_DIR, { recursive: true }); const app = express(); app.use(express.json()); -// ─── رفع الملفات ───────────────────────────────────────────────────────────── +// ─── File storage ───────────────────────────────────────────────────────────── const storage = multer.diskStorage({ destination: UPLOADS_DIR, @@ -26,18 +32,135 @@ const storage = multer.diskStorage({ }); const upload = multer({ storage, limits: { fileSize: MAX_FILE_BYTES } }); -// ─── الذاكرة الداخلية ───────────────────────────────────────────────────────── +// ─── In-memory state ────────────────────────────────────────────────────────── -// code → { code, creatorID, creatorName, creatorToken, joinerID, joinerName, joinerToken, expiresAt } -const pairingSessions = new Map(); +const pairingSessions = new Map(); // code -> session +const connections = new Map(); // peerID -> { ws, name } +const fileRegistry = new Map(); // fileID -> { diskPath, originalName, size } -// peerID → { ws, name } -const connections = new Map(); +// ─── Persistent queue (disk-backed) ────────────────────────────────────────── +// +// Layout on disk: +// /data/queue/{peerID}/{messageID}.json +// +// A message is written to disk the moment it is queued. +// It is deleted from disk the moment it is delivered. +// On server startup all existing files are loaded back into memory. -// fileID → { diskPath, originalName, size } -const fileRegistry = new Map(); +function queueDir(peerID) { + return path.join(QUEUE_DIR, peerID); +} -// ─── أدوات مساعدة ───────────────────────────────────────────────────────────── +function queuePath(peerID, messageID) { + return path.join(queueDir(peerID), `${messageID}.json`); +} + +function persistMessage(peerID, messageID, envelope) { + fs.mkdirSync(queueDir(peerID), { recursive: true }); + fs.writeFileSync( + queuePath(peerID, messageID), + JSON.stringify({ envelope, queuedAt: Date.now() }) + ); +} + +function deletePersistedMessage(peerID, messageID) { + try { fs.unlinkSync(queuePath(peerID, messageID)); } catch (_) {} +} + +function loadQueueFromDisk() { + const queues = new Map(); + if (!fs.existsSync(QUEUE_DIR)) return queues; + + for (const peerID of fs.readdirSync(QUEUE_DIR)) { + const dir = queueDir(peerID); + if (!fs.statSync(dir).isDirectory()) continue; + const entries = []; + for (const file of fs.readdirSync(dir)) { + if (!file.endsWith('.json')) continue; + try { + const raw = fs.readFileSync(path.join(dir, file), 'utf8'); + const { envelope, queuedAt } = JSON.parse(raw); + // Drop messages older than TTL + if (Date.now() - queuedAt > QUEUE_TTL_MS) { + fs.unlinkSync(path.join(dir, file)); + continue; + } + const messageID = file.replace('.json', ''); + entries.push({ messageID, envelope, queuedAt }); + } catch (_) {} + } + if (entries.length > 0) { + entries.sort((a, b) => a.queuedAt - b.queuedAt); + queues.set(peerID, entries); + } + } + return queues; +} + +// In-memory queue mirrors disk — both are always in sync +const offlineQueues = loadQueueFromDisk(); +console.log(`[queue] loaded ${[...offlineQueues.values()].reduce((s, q) => s + q.length, 0)} queued messages from disk`); + +function enqueue(peerID, messageID, envelope) { + if (!offlineQueues.has(peerID)) offlineQueues.set(peerID, []); + const queue = offlineQueues.get(peerID); + if (queue.length >= QUEUE_MAX) { + const dropped = queue.shift(); + deletePersistedMessage(peerID, dropped.messageID); + } + queue.push({ messageID, envelope, queuedAt: Date.now() }); + persistMessage(peerID, messageID, envelope); +} + +function flushQueue(peerID, ws) { + const queue = offlineQueues.get(peerID); + if (!queue || queue.length === 0) return; + const now = Date.now(); + for (const { messageID, envelope, queuedAt } of queue) { + if (now - queuedAt < QUEUE_TTL_MS) send(ws, envelope); + deletePersistedMessage(peerID, messageID); + } + offlineQueues.delete(peerID); +} + +// ─── Persistent pairing sessions (disk-backed) ──────────────────────────────── +// +// Pairing sessions are written to disk so tokens survive a server restart. +// Without this, everyone would need to re-pair after every server update. + +function pairingPath(code) { + return path.join(PAIRING_DIR, `${code}.json`); +} + +function persistSession(session) { + fs.writeFileSync(pairingPath(session.code), JSON.stringify(session)); +} + +function deleteSession(code) { + try { fs.unlinkSync(pairingPath(code)); } catch (_) {} +} + +function loadSessionsFromDisk() { + if (!fs.existsSync(PAIRING_DIR)) return; + const now = Date.now(); + for (const file of fs.readdirSync(PAIRING_DIR)) { + if (!file.endsWith('.json')) continue; + try { + const session = JSON.parse(fs.readFileSync(path.join(PAIRING_DIR, file), 'utf8')); + if (session.expiresAt && session.expiresAt < now) { + fs.unlinkSync(path.join(PAIRING_DIR, file)); + continue; + } + // Permanent sessions (joined pairs) have no expiry — keep them forever + pairingSessions.set(session.code, session); + } catch (_) {} + } +} + +loadSessionsFromDisk(); +console.log(`[pairing] loaded ${pairingSessions.size} sessions from disk`); + +// ─── Helpers ────────────────────────────────────────────────────────────────── function generateCode() { const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; @@ -62,39 +185,50 @@ function peerIDFromToken(session, token) { return session.creatorToken === token ? session.creatorID : session.joinerID; } -function cleanExpiredSessions() { - const now = Date.now(); - for (const [code, s] of pairingSessions) { - if (s.expiresAt < now) pairingSessions.delete(code); - } +function peerPartnerID(session, peerID) { + return session.creatorID === peerID ? session.joinerID : session.creatorID; } -setInterval(cleanExpiredSessions, 60_000); function send(ws, obj) { if (ws.readyState === 1) ws.send(JSON.stringify(obj)); } -// ─── Middleware مصادقة HTTP ─────────────────────────────────────────────────── +function cleanExpiredSessions() { + const now = Date.now(); + for (const [code, s] of pairingSessions) { + // Only remove pending (not yet joined) sessions that expired + if (!s.joinerID && s.expiresAt < now) { + pairingSessions.delete(code); + deleteSession(code); + } + } +} + +setInterval(cleanExpiredSessions, 60_000); + +// ─── Auth middleware (HTTP) ─────────────────────────────────────────────────── function requireToken(req, res, next) { const auth = req.headers.authorization ?? ''; if (!auth.startsWith('Bearer ')) return res.status(401).json({ error: 'Unauthorized' }); - const session = sessionByToken(auth.slice(7)); + const token = auth.slice(7); + const session = sessionByToken(token); if (!session) return res.status(401).json({ error: 'Invalid token' }); - req.peerID = peerIDFromToken(session, auth.slice(7)); + req.peerID = peerIDFromToken(session, token); + req.session = session; next(); } -// ─── مسارات الـ Pairing ─────────────────────────────────────────────────────── +// ─── Pairing routes ─────────────────────────────────────────────────────────── app.post('/pairing/create', (req, res) => { const name = req.body?.displayName?.trim(); - if (!name) return res.status(400).json({ error: 'displayName مطلوب' }); + if (!name) return res.status(400).json({ error: 'displayName is required' }); let code, tries = 0; do { code = generateCode(); - if (++tries > 200) return res.status(503).json({ error: 'حاول مجدداً' }); + if (++tries > 200) return res.status(503).json({ error: 'Please try again' }); } while (pairingSessions.has(code)); const session = { @@ -108,6 +242,7 @@ app.post('/pairing/create', (req, res) => { expiresAt: Date.now() + PAIRING_TTL_MS, }; pairingSessions.set(code, session); + persistSession(session); res.json({ code, @@ -120,55 +255,56 @@ app.post('/pairing/create', (req, res) => { app.post('/pairing/join', (req, res) => { const code = req.body?.code?.trim().toUpperCase(); const name = req.body?.displayName?.trim(); - if (!code || !name) return res.status(400).json({ error: 'code و displayName مطلوبان' }); + if (!code || !name) return res.status(400).json({ error: 'code and displayName are required' }); const session = pairingSessions.get(code); - if (!session || Date.now() > session.expiresAt) { + if (!session || (!session.joinerID && Date.now() > session.expiresAt)) { pairingSessions.delete(code); - return res.status(404).json({ error: 'الكود غير موجود أو انتهت صلاحيته' }); + deleteSession(code); + return res.status(404).json({ error: 'Code not found or expired' }); } - if (session.joinerID) return res.status(409).json({ error: 'الكود استُخدم مسبقاً' }); + if (session.joinerID) return res.status(409).json({ error: 'Code already used' }); session.joinerID = randomUUID(); session.joinerName = name; session.joinerToken = generateToken(); + delete session.expiresAt; // paired sessions never expire + persistSession(session); res.json({ token: session.joinerToken, peerID: session.joinerID, - peer: { - id: session.creatorID, - displayName: session.creatorName, - }, + peer: { id: session.creatorID, displayName: session.creatorName }, }); }); -// ─── مسارات الملفات ─────────────────────────────────────────────────────────── +// ─── File routes ────────────────────────────────────────────────────────────── app.post('/files', requireToken, upload.single('file'), (req, res) => { - if (!req.file) return res.status(400).json({ error: 'لم يُرسَل أي ملف' }); - + if (!req.file) return res.status(400).json({ error: 'No file provided' }); const fileID = req.file.filename; fileRegistry.set(fileID, { diskPath: req.file.path, originalName: req.file.originalname, size: req.file.size, }); - res.json({ id: fileID, name: req.file.originalname, size: req.file.size }); }); app.get('/files/:id', requireToken, (req, res) => { const meta = fileRegistry.get(req.params.id); - if (!meta) return res.status(404).json({ error: 'الملف غير موجود' }); - + if (!meta) return res.status(404).json({ error: 'File not found' }); res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(meta.originalName)}"`); res.sendFile(meta.diskPath); }); -// ─── فحص الصحة ─────────────────────────────────────────────────────────────── +// ─── Health check ───────────────────────────────────────────────────────────── -app.get('/health', (_req, res) => res.json({ ok: true, connections: connections.size })); +app.get('/health', (_req, res) => res.json({ + ok: true, + connections: connections.size, + queued: [...offlineQueues.values()].reduce((sum, q) => sum + q.length, 0), +})); // ─── WebSocket ──────────────────────────────────────────────────────────────── @@ -178,6 +314,7 @@ const wss = new WebSocketServer({ server, path: '/ws' }); wss.on('connection', (ws) => { let peerID = null; let peerName = null; + let session = null; let authed = false; const authTimeout = setTimeout(() => { @@ -187,14 +324,15 @@ wss.on('connection', (ws) => { ws.on('message', (raw) => { let msg; try { msg = JSON.parse(raw.toString()); } - catch { return ws.close(1003, 'JSON غير صالح'); } + catch { return ws.close(1003, 'Invalid JSON'); } + // ─── Auth ───────────────────────────────────────────────────────────────── if (!authed) { if (msg.type !== 'auth' || !msg.token) { - return ws.close(1008, 'أرسل { type: "auth", token: "..." } أولاً'); + return ws.close(1008, 'Send { type: "auth", token: "..." } first'); } - const session = sessionByToken(msg.token); - if (!session) return ws.close(1008, 'Token غير صالح'); + session = sessionByToken(msg.token); + if (!session) return ws.close(1008, 'Invalid token'); clearTimeout(authTimeout); authed = true; @@ -203,21 +341,48 @@ wss.on('connection', (ws) => { connections.set(peerID, { ws, name: peerName }); send(ws, { type: 'auth.ok', peerID }); - console.log(`[ws] متصل: ${peerName} (${peerID})`); + console.log(`[ws] connected: ${peerName} (${peerID})`); + + // Deliver messages that arrived while this peer was offline + flushQueue(peerID, ws); return; } + // ─── Chat message ───────────────────────────────────────────────────────── if (msg.type === 'chat.message') { - const target = connections.get(msg.to); + const partnerID = peerPartnerID(session, peerID); + const messageID = msg.id ?? randomUUID(); + const outgoing = { ...msg, id: messageID, from: peerID, fromName: peerName }; + const target = connections.get(partnerID); + if (target?.ws.readyState === 1) { - send(target.ws, { ...msg, from: peerID, fromName: peerName }); - send(ws, { type: 'delivered', id: msg.id }); + send(target.ws, outgoing); + send(ws, { type: 'delivered', id: messageID }); } else { - send(ws, { type: 'not_delivered', id: msg.id, reason: 'peer_offline' }); + enqueue(partnerID, messageID, outgoing); + send(ws, { type: 'queued', id: messageID }); } return; } + // ─── Read receipt ───────────────────────────────────────────────────────── + // Client sends: { type: "read", messageIDs: ["id1", "id2", ...] } + // Server forwards to the sender of those messages so they see "read" ticks + if (msg.type === 'read') { + const partnerID = peerPartnerID(session, peerID); + const receipt = { type: 'read', messageIDs: msg.messageIDs, by: peerID }; + const target = connections.get(partnerID); + + if (target?.ws.readyState === 1) { + send(target.ws, receipt); + } else { + // Queue the read receipt too — partner deserves to know + enqueue(partnerID, `read-${randomUUID()}`, receipt); + } + return; + } + + // ─── Ping ───────────────────────────────────────────────────────────────── if (msg.type === 'ping') { send(ws, { type: 'pong' }); } @@ -226,18 +391,18 @@ wss.on('connection', (ws) => { ws.on('close', () => { if (peerID) { connections.delete(peerID); - console.log(`[ws] انقطع: ${peerName} (${peerID})`); + console.log(`[ws] disconnected: ${peerName} (${peerID})`); } clearTimeout(authTimeout); }); ws.on('error', (err) => { - console.error(`[ws] خطأ:`, err.message); + console.error('[ws] error:', err.message); }); }); -// ─── تشغيل ──────────────────────────────────────────────────────────────────── +// ─── Start ──────────────────────────────────────────────────────────────────── server.listen(PORT, () => { - console.log(`Fchati Relay يعمل على المنفذ ${PORT}`); + console.log(`Fchati Relay listening on port ${PORT}`); });