# 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 ```