feat: persistent queue, pairing sessions, read receipts
This commit is contained in:
parent
215c17f8ee
commit
1f7e696f1c
427
TASKS.md
Normal file
427
TASKS.md
Normal file
@ -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
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.app-sandbox</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.network.client</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.network.server</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
```
|
||||||
|
|
||||||
|
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<WSMessage> { 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
|
||||||
|
```
|
||||||
@ -1,10 +1,8 @@
|
|||||||
# المرحلة الأولى: تنزيل الـ dependencies فقط
|
|
||||||
FROM node:20-alpine AS deps
|
FROM node:20-alpine AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json ./
|
COPY package.json ./
|
||||||
RUN npm install --omit=dev
|
RUN npm install --omit=dev
|
||||||
|
|
||||||
# المرحلة الثانية: الـ image النهائي
|
|
||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@ -6,20 +6,22 @@ services:
|
|||||||
env_file: .env
|
env_file: .env
|
||||||
volumes:
|
volumes:
|
||||||
- fchati-files:/data/files
|
- fchati-files:/data/files
|
||||||
|
- fchati-queue:/data/queue
|
||||||
|
- fchati-pairing:/data/pairing
|
||||||
networks:
|
networks:
|
||||||
- traefik-net
|
- traefik-net
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.fchati.rule=Host(`fchati.diyaa.de`)"
|
- "traefik.http.routers.fchati.rule=Host(`fchati.diyaa.de`)"
|
||||||
- "traefik.http.routers.fchati.entrypoints=websecure"
|
- "traefik.http.routers.fchati.entrypoints=websecure"
|
||||||
# غيّر letsencrypt إذا كان اسم certresolver عندك مختلف
|
|
||||||
- "traefik.http.routers.fchati.tls.certresolver=letsencrypt"
|
- "traefik.http.routers.fchati.tls.certresolver=letsencrypt"
|
||||||
- "traefik.http.services.fchati.loadbalancer.server.port=3000"
|
- "traefik.http.services.fchati.loadbalancer.server.port=3000"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
fchati-files:
|
fchati-files:
|
||||||
|
fchati-queue:
|
||||||
|
fchati-pairing:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
traefik-net:
|
traefik-net:
|
||||||
# غيّر هذا ليطابق اسم network Traefik عندك
|
|
||||||
external: true
|
external: true
|
||||||
|
|||||||
@ -3,22 +3,28 @@ const { WebSocketServer } = require('ws');
|
|||||||
const { createServer } = require('http');
|
const { createServer } = require('http');
|
||||||
const { randomBytes, randomUUID } = require('crypto');
|
const { randomBytes, randomUUID } = require('crypto');
|
||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
// ─── إعداد ───────────────────────────────────────────────────────────────────
|
// ─── Config ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
const MAX_FILE_BYTES = parseInt(process.env.MAX_FILE_SIZE_MB ?? '25') * 1024 * 1024;
|
const MAX_FILE_BYTES = parseInt(process.env.MAX_FILE_SIZE_MB ?? '25') * 1024 * 1024;
|
||||||
const UPLOADS_DIR = process.env.UPLOADS_DIR ?? '/data/files';
|
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(UPLOADS_DIR, { recursive: true });
|
||||||
|
fs.mkdirSync(QUEUE_DIR, { recursive: true });
|
||||||
|
fs.mkdirSync(PAIRING_DIR, { recursive: true });
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
// ─── رفع الملفات ─────────────────────────────────────────────────────────────
|
// ─── File storage ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const storage = multer.diskStorage({
|
const storage = multer.diskStorage({
|
||||||
destination: UPLOADS_DIR,
|
destination: UPLOADS_DIR,
|
||||||
@ -26,18 +32,135 @@ const storage = multer.diskStorage({
|
|||||||
});
|
});
|
||||||
const upload = multer({ storage, limits: { fileSize: MAX_FILE_BYTES } });
|
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(); // code -> session
|
||||||
const pairingSessions = new Map();
|
const connections = new Map(); // peerID -> { ws, name }
|
||||||
|
const fileRegistry = new Map(); // fileID -> { diskPath, originalName, size }
|
||||||
|
|
||||||
// peerID → { ws, name }
|
// ─── Persistent queue (disk-backed) ──────────────────────────────────────────
|
||||||
const connections = new Map();
|
//
|
||||||
|
// 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 }
|
function queueDir(peerID) {
|
||||||
const fileRegistry = new Map();
|
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() {
|
function generateCode() {
|
||||||
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||||
@ -62,39 +185,50 @@ function peerIDFromToken(session, token) {
|
|||||||
return session.creatorToken === token ? session.creatorID : session.joinerID;
|
return session.creatorToken === token ? session.creatorID : session.joinerID;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanExpiredSessions() {
|
function peerPartnerID(session, peerID) {
|
||||||
const now = Date.now();
|
return session.creatorID === peerID ? session.joinerID : session.creatorID;
|
||||||
for (const [code, s] of pairingSessions) {
|
|
||||||
if (s.expiresAt < now) pairingSessions.delete(code);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
setInterval(cleanExpiredSessions, 60_000);
|
|
||||||
|
|
||||||
function send(ws, obj) {
|
function send(ws, obj) {
|
||||||
if (ws.readyState === 1) ws.send(JSON.stringify(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) {
|
function requireToken(req, res, next) {
|
||||||
const auth = req.headers.authorization ?? '';
|
const auth = req.headers.authorization ?? '';
|
||||||
if (!auth.startsWith('Bearer ')) return res.status(401).json({ error: 'Unauthorized' });
|
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' });
|
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();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── مسارات الـ Pairing ───────────────────────────────────────────────────────
|
// ─── Pairing routes ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.post('/pairing/create', (req, res) => {
|
app.post('/pairing/create', (req, res) => {
|
||||||
const name = req.body?.displayName?.trim();
|
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;
|
let code, tries = 0;
|
||||||
do {
|
do {
|
||||||
code = generateCode();
|
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));
|
} while (pairingSessions.has(code));
|
||||||
|
|
||||||
const session = {
|
const session = {
|
||||||
@ -108,6 +242,7 @@ app.post('/pairing/create', (req, res) => {
|
|||||||
expiresAt: Date.now() + PAIRING_TTL_MS,
|
expiresAt: Date.now() + PAIRING_TTL_MS,
|
||||||
};
|
};
|
||||||
pairingSessions.set(code, session);
|
pairingSessions.set(code, session);
|
||||||
|
persistSession(session);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
code,
|
code,
|
||||||
@ -120,55 +255,56 @@ app.post('/pairing/create', (req, res) => {
|
|||||||
app.post('/pairing/join', (req, res) => {
|
app.post('/pairing/join', (req, res) => {
|
||||||
const code = req.body?.code?.trim().toUpperCase();
|
const code = req.body?.code?.trim().toUpperCase();
|
||||||
const name = req.body?.displayName?.trim();
|
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);
|
const session = pairingSessions.get(code);
|
||||||
if (!session || Date.now() > session.expiresAt) {
|
if (!session || (!session.joinerID && Date.now() > session.expiresAt)) {
|
||||||
pairingSessions.delete(code);
|
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.joinerID = randomUUID();
|
||||||
session.joinerName = name;
|
session.joinerName = name;
|
||||||
session.joinerToken = generateToken();
|
session.joinerToken = generateToken();
|
||||||
|
delete session.expiresAt; // paired sessions never expire
|
||||||
|
persistSession(session);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
token: session.joinerToken,
|
token: session.joinerToken,
|
||||||
peerID: session.joinerID,
|
peerID: session.joinerID,
|
||||||
peer: {
|
peer: { id: session.creatorID, displayName: session.creatorName },
|
||||||
id: session.creatorID,
|
|
||||||
displayName: session.creatorName,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── مسارات الملفات ───────────────────────────────────────────────────────────
|
// ─── File routes ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.post('/files', requireToken, upload.single('file'), (req, res) => {
|
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;
|
const fileID = req.file.filename;
|
||||||
fileRegistry.set(fileID, {
|
fileRegistry.set(fileID, {
|
||||||
diskPath: req.file.path,
|
diskPath: req.file.path,
|
||||||
originalName: req.file.originalname,
|
originalName: req.file.originalname,
|
||||||
size: req.file.size,
|
size: req.file.size,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ id: fileID, name: 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) => {
|
app.get('/files/:id', requireToken, (req, res) => {
|
||||||
const meta = fileRegistry.get(req.params.id);
|
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.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(meta.originalName)}"`);
|
||||||
res.sendFile(meta.diskPath);
|
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 ────────────────────────────────────────────────────────────────
|
// ─── WebSocket ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -178,6 +314,7 @@ const wss = new WebSocketServer({ server, path: '/ws' });
|
|||||||
wss.on('connection', (ws) => {
|
wss.on('connection', (ws) => {
|
||||||
let peerID = null;
|
let peerID = null;
|
||||||
let peerName = null;
|
let peerName = null;
|
||||||
|
let session = null;
|
||||||
let authed = false;
|
let authed = false;
|
||||||
|
|
||||||
const authTimeout = setTimeout(() => {
|
const authTimeout = setTimeout(() => {
|
||||||
@ -187,14 +324,15 @@ wss.on('connection', (ws) => {
|
|||||||
ws.on('message', (raw) => {
|
ws.on('message', (raw) => {
|
||||||
let msg;
|
let msg;
|
||||||
try { msg = JSON.parse(raw.toString()); }
|
try { msg = JSON.parse(raw.toString()); }
|
||||||
catch { return ws.close(1003, 'JSON غير صالح'); }
|
catch { return ws.close(1003, 'Invalid JSON'); }
|
||||||
|
|
||||||
|
// ─── Auth ─────────────────────────────────────────────────────────────────
|
||||||
if (!authed) {
|
if (!authed) {
|
||||||
if (msg.type !== 'auth' || !msg.token) {
|
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);
|
session = sessionByToken(msg.token);
|
||||||
if (!session) return ws.close(1008, 'Token غير صالح');
|
if (!session) return ws.close(1008, 'Invalid token');
|
||||||
|
|
||||||
clearTimeout(authTimeout);
|
clearTimeout(authTimeout);
|
||||||
authed = true;
|
authed = true;
|
||||||
@ -203,21 +341,48 @@ wss.on('connection', (ws) => {
|
|||||||
connections.set(peerID, { ws, name: peerName });
|
connections.set(peerID, { ws, name: peerName });
|
||||||
|
|
||||||
send(ws, { type: 'auth.ok', peerID });
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Chat message ─────────────────────────────────────────────────────────
|
||||||
if (msg.type === '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) {
|
if (target?.ws.readyState === 1) {
|
||||||
send(target.ws, { ...msg, from: peerID, fromName: peerName });
|
send(target.ws, outgoing);
|
||||||
send(ws, { type: 'delivered', id: msg.id });
|
send(ws, { type: 'delivered', id: messageID });
|
||||||
} else {
|
} else {
|
||||||
send(ws, { type: 'not_delivered', id: msg.id, reason: 'peer_offline' });
|
enqueue(partnerID, messageID, outgoing);
|
||||||
|
send(ws, { type: 'queued', id: messageID });
|
||||||
}
|
}
|
||||||
return;
|
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') {
|
if (msg.type === 'ping') {
|
||||||
send(ws, { type: 'pong' });
|
send(ws, { type: 'pong' });
|
||||||
}
|
}
|
||||||
@ -226,18 +391,18 @@ wss.on('connection', (ws) => {
|
|||||||
ws.on('close', () => {
|
ws.on('close', () => {
|
||||||
if (peerID) {
|
if (peerID) {
|
||||||
connections.delete(peerID);
|
connections.delete(peerID);
|
||||||
console.log(`[ws] انقطع: ${peerName} (${peerID})`);
|
console.log(`[ws] disconnected: ${peerName} (${peerID})`);
|
||||||
}
|
}
|
||||||
clearTimeout(authTimeout);
|
clearTimeout(authTimeout);
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on('error', (err) => {
|
ws.on('error', (err) => {
|
||||||
console.error(`[ws] خطأ:`, err.message);
|
console.error('[ws] error:', err.message);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── تشغيل ────────────────────────────────────────────────────────────────────
|
// ─── Start ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
server.listen(PORT, () => {
|
||||||
console.log(`Fchati Relay يعمل على المنفذ ${PORT}`);
|
console.log(`Fchati Relay listening on port ${PORT}`);
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user