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:
@mainSwiftUI App- Uses
MenuBarExtrawith.windowstyle - 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:
- On
connect: open WebSocket, immediately send{ type: "auth", token: "..." } - Wait for
{ type: "auth.ok" }— throw if not received within 10 seconds - After auth: all incoming messages flow into
incomingAsyncStream - Send
{ type: "ping" }every 30 seconds to keep connection alive - 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:
ScrollViewReaderwith 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
NSOpenPanelfor file selection → callsAppSession.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 launchnotifyfires aUNUserNotificationRequestwith 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