428 lines
12 KiB
Markdown
428 lines
12 KiB
Markdown
# 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
|
|
```
|