fchaty/TASKS.md

12 KiB

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-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 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"

// 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.

// 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.

// 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:

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:

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:

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