Complete app features and relay cleanup

This commit is contained in:
diyaa 2026-07-26 17:36:46 +02:00
parent 53fa92d222
commit 2bd9c1b3ae
25 changed files with 2204 additions and 188 deletions

View File

@ -8,5 +8,7 @@
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>

View File

@ -22,5 +22,9 @@
<string>14.0</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSMicrophoneUsageDescription</key>
<string>Fchati uses the microphone to record voice messages.</string>
<key>LSUIElement</key>
<true/>
</dict>
</plist>

View File

@ -34,7 +34,7 @@ enum AppTab: String, CaseIterable, Identifiable {
}
private struct RootView: View {
@State private var selectedTab: AppTab = .chat
@State private var selectedTab: AppTab = .pairing
var body: some View {
VStack(spacing: 0) {
@ -53,23 +53,13 @@ private struct RootView: View {
Group {
switch selectedTab {
case .chat:
PlaceholderView(
icon: "message",
title: "No messages yet",
description: "Pair with someone to start chatting."
)
ChatView()
case .pairing:
PlaceholderView(
icon: "link",
title: "Pair a device",
description: "Create or join a pairing session."
)
PairingView {
selectedTab = .chat
}
case .settings:
PlaceholderView(
icon: "gearshape",
title: "Settings",
description: "Your preferences will appear here."
)
SettingsView()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
@ -78,18 +68,3 @@ private struct RootView: View {
.frame(minHeight: 480)
}
}
private struct PlaceholderView: View {
let icon: String
let title: String
let description: String
var body: some View {
ContentUnavailableView(
title,
systemImage: icon,
description: Text(description)
)
.padding()
}
}

View File

@ -0,0 +1,122 @@
import AppKit
import SwiftUI
@MainActor
struct ChatView: View {
@ObservedObject private var session: AppSession
@StateObject private var viewModel: ChatViewModel
init(session: AppSession) {
self.session = session
_viewModel = StateObject(wrappedValue: ChatViewModel(session: session))
}
init() {
self.init(session: AppSession.shared)
}
var body: some View {
VStack(spacing: 0) {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 12) {
ForEach(session.messages) { message in
MessageBubble(
message: message,
isSent: message.fromName == "You"
)
.id(message.id)
}
}
.padding()
}
.onAppear {
scrollToLatest(using: proxy)
}
.onChange(of: session.messages.last?.id) { _, _ in
scrollToLatest(using: proxy)
}
}
Divider()
VStack(alignment: .leading, spacing: 8) {
if let errorMessage = viewModel.errorMessage {
Label(errorMessage, systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.red)
}
HStack(alignment: .center, spacing: 8) {
Button {
chooseFile()
} label: {
Image(systemName: "paperclip")
}
.help("Choose a file")
.disabled(viewModel.isSending || viewModel.isRecording)
TextField("Message", text: $viewModel.draft)
.textFieldStyle(.roundedBorder)
.onSubmit {
Task {
await viewModel.sendText()
}
}
Image(systemName: viewModel.isRecording ? "mic.fill" : "mic")
.foregroundStyle(viewModel.isRecording ? .red : .primary)
.frame(width: 28, height: 28)
.contentShape(Circle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in
viewModel.startRecording()
}
.onEnded { _ in
viewModel.stopRecordingAndSend()
}
)
.help("Hold to record audio")
Button("Send") {
Task {
await viewModel.sendText()
}
}
.buttonStyle(.borderedProminent)
.disabled(
viewModel.isSending ||
viewModel.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
)
}
}
.padding()
}
}
private func chooseFile() {
let panel = NSOpenPanel()
panel.canChooseDirectories = false
panel.canChooseFiles = true
panel.allowsMultipleSelection = false
guard panel.runModal() == .OK, let url = panel.url else {
return
}
Task {
await viewModel.sendFile(url: url)
}
}
private func scrollToLatest(using proxy: ScrollViewProxy) {
guard let latestID = session.messages.last?.id else {
return
}
withAnimation {
proxy.scrollTo(latestID, anchor: .bottom)
}
}
}

View File

@ -0,0 +1,138 @@
import AVFoundation
import Combine
import Foundation
@MainActor
final class ChatViewModel: ObservableObject {
@Published var draft = ""
@Published private(set) var isSending = false
@Published private(set) var isRecording = false
@Published private(set) var errorMessage: String?
private let session: AppSession
private var recorder: AVAudioRecorder?
private var recordingURL: URL?
init(session: AppSession) {
self.session = session
}
func sendText() async {
let messageBody = draft
guard !messageBody.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return
}
isSending = true
errorMessage = nil
do {
try await session.sendText(messageBody)
draft = ""
} catch {
errorMessage = message(for: error)
}
isSending = false
}
func sendFile(url: URL, removeAfterSending: Bool = false) async {
isSending = true
errorMessage = nil
do {
try await session.sendFile(url: url)
if removeAfterSending {
try? FileManager.default.removeItem(at: url)
}
} catch {
errorMessage = message(for: error)
}
isSending = false
}
func startRecording() {
guard !isRecording, !isSending else {
return
}
do {
let url = try makeRecordingURL()
let settings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 44_100,
AVNumberOfChannelsKey: 1,
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue,
]
let recorder = try AVAudioRecorder(url: url, settings: settings)
recorder.prepareToRecord()
guard recorder.record() else {
throw ChatViewModelError.recordingUnavailable
}
self.recorder = recorder
recordingURL = url
isRecording = true
errorMessage = nil
} catch {
errorMessage = message(for: error)
}
}
func stopRecordingAndSend() {
guard isRecording, let recorder, let recordingURL else {
return
}
recorder.stop()
self.recorder = nil
self.recordingURL = nil
isRecording = false
Task {
await sendFile(url: recordingURL, removeAfterSending: true)
}
}
private func makeRecordingURL() throws -> URL {
let cachesDirectory = try FileManager.default.url(
for: .cachesDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let recordingsDirectory = cachesDirectory.appendingPathComponent(
"FchatiRecordings",
isDirectory: true
)
try FileManager.default.createDirectory(
at: recordingsDirectory,
withIntermediateDirectories: true
)
return recordingsDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension("m4a")
}
private func message(for error: Error) -> String {
if let error = error as? LocalizedError, let description = error.errorDescription {
return description
}
return "Unable to send the message."
}
}
enum ChatViewModelError: LocalizedError {
case recordingUnavailable
var errorDescription: String? {
"Unable to start recording."
}
}

View File

@ -0,0 +1,51 @@
import SwiftUI
struct MessageBubble: View {
let message: ChatMessage
let isSent: Bool
var body: some View {
VStack(alignment: isSent ? .trailing : .leading, spacing: 4) {
HStack {
if isSent {
Spacer(minLength: 44)
}
VStack(alignment: .leading, spacing: 8) {
Text(markdownBody)
.textSelection(.enabled)
if let attachment = message.attachment {
Label(
"\(attachment.name) · \(formattedSize(attachment.size))",
systemImage: "paperclip"
)
.font(.footnote)
.foregroundStyle(isSent ? .white.opacity(0.85) : .secondary)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 9)
.foregroundStyle(isSent ? .white : .primary)
.background(isSent ? Color.accentColor : Color.gray.opacity(0.18))
.clipShape(RoundedRectangle(cornerRadius: 16))
if !isSent {
Spacer(minLength: 44)
}
}
Text(message.sentAt.formatted(date: .omitted, time: .shortened))
.font(.caption2)
.foregroundStyle(.secondary)
}
}
private var markdownBody: AttributedString {
(try? AttributedString(markdown: message.body)) ?? AttributedString(message.body)
}
private func formattedSize(_ size: Int) -> String {
ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file)
}
}

View File

@ -0,0 +1,141 @@
import AppKit
import SwiftUI
@MainActor
struct PairingView: View {
@ObservedObject private var session: AppSession
@StateObject private var viewModel: PairingViewModel
private let onConnected: () -> Void
init(
session: AppSession,
onConnected: @escaping () -> Void = {}
) {
self.session = session
self.onConnected = onConnected
_viewModel = StateObject(wrappedValue: PairingViewModel(session: session))
}
init(onConnected: @escaping () -> Void = {}) {
self.init(session: AppSession.shared, onConnected: onConnected)
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Picker("Pairing mode", selection: $viewModel.mode) {
ForEach(PairingMode.allCases) { mode in
Text(mode.rawValue).tag(mode)
}
}
.pickerStyle(.segmented)
switch viewModel.mode {
case .create:
createCodeContent
case .join:
enterCodeContent
}
if let errorMessage = viewModel.errorMessage {
Label(errorMessage, systemImage: "exclamationmark.triangle.fill")
.font(.callout)
.foregroundStyle(.red)
}
Spacer(minLength: 0)
}
.padding()
.onChange(of: session.state) { _, newState in
if case .connected = newState {
onConnected()
}
}
}
private var createCodeContent: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Pair a new device")
.font(.title3.weight(.semibold))
TextField("Your display name", text: $viewModel.displayName)
.textFieldStyle(.roundedBorder)
if let generatedCode = viewModel.generatedCode {
VStack(alignment: .leading, spacing: 8) {
Text("Share this code")
.font(.headline)
HStack {
Text(generatedCode)
.font(.system(.title2, design: .monospaced).weight(.semibold))
.textSelection(.enabled)
Spacer()
Button("Copy") {
copy(generatedCode)
}
}
Text("Code expires in 5 minutes")
.font(.footnote)
.foregroundStyle(.secondary)
}
.padding()
.background(.quaternary, in: RoundedRectangle(cornerRadius: 10))
} else {
Button("Create Code") {
Task {
await viewModel.createCode()
}
}
.buttonStyle(.borderedProminent)
.disabled(!viewModel.canSubmit || viewModel.isSubmitting)
}
if viewModel.isSubmitting || viewModel.isWaitingForPeer {
HStack(spacing: 8) {
ProgressView()
.controlSize(.small)
Text(viewModel.isWaitingForPeer ? "Waiting for the other person..." : "Creating code...")
.foregroundStyle(.secondary)
}
}
}
}
private var enterCodeContent: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Join a pairing")
.font(.title3.weight(.semibold))
TextField("Your display name", text: $viewModel.displayName)
.textFieldStyle(.roundedBorder)
TextField("Pairing code", text: $viewModel.code)
.textFieldStyle(.roundedBorder)
Button("Connect") {
Task {
await viewModel.joinCode()
}
}
.buttonStyle(.borderedProminent)
.disabled(!viewModel.canSubmit || viewModel.isSubmitting)
if viewModel.isSubmitting {
HStack(spacing: 8) {
ProgressView()
.controlSize(.small)
Text("Connecting...")
.foregroundStyle(.secondary)
}
}
}
}
private func copy(_ code: String) {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(code, forType: .string)
}
}

View File

@ -0,0 +1,117 @@
import Combine
import Foundation
enum PairingMode: String, CaseIterable, Identifiable {
case create = "Create Code"
case join = "Enter Code"
var id: Self { self }
}
@MainActor
final class PairingViewModel: ObservableObject {
@Published var mode: PairingMode = .create {
didSet {
errorMessage = nil
generatedCode = nil
isWaitingForPeer = false
}
}
@Published var displayName = ""
@Published var code = "" {
didSet {
let uppercasedCode = code.uppercased()
if code != uppercasedCode {
code = uppercasedCode
}
}
}
@Published private(set) var generatedCode: String?
@Published private(set) var errorMessage: String?
@Published private(set) var isSubmitting = false
@Published private(set) var isWaitingForPeer = false
private let session: AppSession
init(session: AppSession) {
self.session = session
}
var canSubmit: Bool {
guard !displayName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return false
}
if mode == .join {
return !code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
return true
}
func createCode() async {
guard canSubmit else {
return
}
isSubmitting = true
errorMessage = nil
do {
generatedCode = try await session.createPairingCode(displayName: displayName.trimmed)
isWaitingForPeer = true
} catch {
errorMessage = message(for: error)
}
isSubmitting = false
}
func joinCode() async {
guard canSubmit else {
return
}
isSubmitting = true
errorMessage = nil
do {
try await session.joinWithCode(normalizedCode, displayName: displayName.trimmed)
} catch {
errorMessage = message(for: error)
}
isSubmitting = false
}
var normalizedCode: String {
Self.normalizedCode(from: code)
}
nonisolated static func normalizedCode(from code: String) -> String {
let compactCode = code
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: " ", with: "")
.uppercased()
guard !compactCode.hasPrefix("FCHT-") else {
return compactCode
}
return "FCHT-\(compactCode)"
}
private func message(for error: Error) -> String {
if let error = error as? LocalizedError, let description = error.errorDescription {
return description
}
return "Unable to complete pairing."
}
}
private extension String {
var trimmed: String {
trimmingCharacters(in: .whitespacesAndNewlines)
}
}

View File

@ -0,0 +1,102 @@
import SwiftUI
@MainActor
struct SettingsView: View {
@ObservedObject private var session: AppSession
@State private var displayName: String
@State private var isEditingName = false
init(session: AppSession) {
self.session = session
_displayName = State(initialValue: KeychainStore.peerName ?? "")
}
init() {
self.init(session: AppSession.shared)
}
var body: some View {
Form {
Section("Profile") {
LabeledContent("Display name") {
if isEditingName {
TextField("Display name", text: $displayName)
.frame(maxWidth: 180)
} else {
Text(displayName.isEmpty ? "Not set" : displayName)
.foregroundStyle(displayName.isEmpty ? .secondary : .primary)
}
}
Button(isEditingName ? "Save" : "Edit") {
if isEditingName {
saveDisplayName()
}
isEditingName.toggle()
}
}
Section("Connection") {
LabeledContent("Status") {
Text(connectionStatus)
.foregroundStyle(connectionColor)
}
Button("Unpair", role: .destructive) {
session.unpair()
}
.disabled(isUnpaired)
}
Section("About") {
LabeledContent("Version") {
Text(appVersion)
}
}
}
.formStyle(.grouped)
.padding()
}
private var connectionStatus: String {
switch session.state {
case .unpaired:
"Not connected"
case .connecting:
"Connecting..."
case .connected(_, let peerName):
"Connected to \(peerName)"
case .error(let message):
message
}
}
private var connectionColor: Color {
switch session.state {
case .connected:
.green
case .error:
.red
case .unpaired, .connecting:
.secondary
}
}
private var isUnpaired: Bool {
if case .unpaired = session.state {
return true
}
return false
}
private var appVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
}
private func saveDisplayName() {
let trimmedName = displayName.trimmingCharacters(in: .whitespacesAndNewlines)
displayName = trimmedName
KeychainStore.peerName = trimmedName.isEmpty ? nil : trimmedName
}
}

View File

@ -0,0 +1,164 @@
import Foundation
actor RelayAPI {
static let shared = RelayAPI()
func createPairing(displayName: String) async throws -> PairingCreateResponse {
var request = URLRequest(url: endpoint(path: "pairing/create"))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(PairingRequest(displayName: displayName))
return try await executeJSON(request)
}
func joinPairing(code: String, displayName: String) async throws -> PairingJoinResponse {
var request = URLRequest(url: endpoint(path: "pairing/join"))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(
PairingJoinRequest(code: code, displayName: displayName)
)
return try await executeJSON(request)
}
func uploadFile(url: URL, token: String) async throws -> FileUploadResponse {
let boundary = "Boundary-\(UUID().uuidString)"
let fileData: Data
do {
fileData = try Data(contentsOf: url)
} catch {
throw RelayAPIError.networkError(error)
}
var request = URLRequest(url: endpoint(path: "files"))
request.httpMethod = "POST"
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue(
"multipart/form-data; boundary=\(boundary)",
forHTTPHeaderField: "Content-Type"
)
request.httpBody = multipartBody(
fileData: fileData,
fileName: safeFileName(from: url),
boundary: boundary
)
return try await executeJSON(request)
}
func downloadFile(id: String, token: String) async throws -> Data {
var request = URLRequest(url: endpoint(path: "files/\(id)"))
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
return try await execute(request)
}
private let baseURL = URL(string: "https://fchati.diyaa.de")!
private let session = URLSession.shared
private init() {}
private func endpoint(path: String) -> URL {
baseURL.appendingPathComponent(path)
}
private func executeJSON<Response: Decodable>(_ request: URLRequest) async throws -> Response {
let data = try await execute(request)
do {
return try JSONDecoder().decode(Response.self, from: data)
} catch {
throw RelayAPIError.decodingError(error)
}
}
private func execute(_ request: URLRequest) async throws -> Data {
do {
let (data, response) = try await session.data(for: request)
guard let response = response as? HTTPURLResponse else {
throw RelayAPIError.networkError(URLError(.badServerResponse))
}
guard (200 ... 299).contains(response.statusCode) else {
throw RelayAPIError.serverError(response.statusCode)
}
return data
} catch let error as RelayAPIError {
throw error
} catch {
throw RelayAPIError.networkError(error)
}
}
private func multipartBody(fileData: Data, fileName: String, boundary: String) -> Data {
var body = Data()
let escapedFileName = fileName
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
.replacingOccurrences(of: "\r", with: "")
.replacingOccurrences(of: "\n", with: "")
append("--\(boundary)\r\n", to: &body)
append(
"Content-Disposition: form-data; name=\"file\"; filename=\"\(escapedFileName)\"\r\n",
to: &body
)
append("Content-Type: application/octet-stream\r\n\r\n", to: &body)
body.append(fileData)
append("\r\n--\(boundary)--\r\n", to: &body)
return body
}
private func safeFileName(from url: URL) -> String {
let name = url.lastPathComponent
return name.isEmpty ? "upload" : name
}
private func append(_ string: String, to data: inout Data) {
data.append(Data(string.utf8))
}
}
struct PairingCreateResponse: Codable, Equatable {
let code: String
let token: String
let peerID: String
let expiresAt: String
}
struct PairingJoinResponse: Codable, Equatable {
let token: String
let peerID: String
let peer: PeerInfo
}
struct PeerInfo: Codable, Equatable {
let id: String
let displayName: String
}
struct FileUploadResponse: Codable, Equatable {
let id: String
let name: String
let size: Int
}
enum RelayAPIError: Error {
case networkError(Error)
case serverError(Int)
case decodingError(Error)
}
private struct PairingRequest: Encodable {
let displayName: String
}
private struct PairingJoinRequest: Encodable {
let code: String
let displayName: String
}

View File

@ -0,0 +1,359 @@
import Foundation
actor WSClient {
private let url: URL
private let session: URLSession
private let incomingStream: AsyncStream<WSMessage>
private let incomingContinuation: AsyncStream<WSMessage>.Continuation
private var webSocketTask: URLSessionWebSocketTask?
private var receiveTask: Task<Void, Never>?
private var pingTask: Task<Void, Never>?
private var reconnectTask: Task<Void, Never>?
private var reconnectID: UUID?
private var authenticationTimedOutTask: URLSessionWebSocketTask?
private var authToken: String?
private var isAuthenticated = false
private var isDisconnecting = false
init(url: URL) {
self.url = url
self.session = URLSession(configuration: .default)
let stream = AsyncStream<WSMessage>.makeStream()
self.incomingStream = stream.stream
self.incomingContinuation = stream.continuation
}
deinit {
webSocketTask?.cancel(with: .goingAway, reason: nil)
receiveTask?.cancel()
pingTask?.cancel()
reconnectTask?.cancel()
incomingContinuation.finish()
}
var incoming: AsyncStream<WSMessage> {
incomingStream
}
func connect(token: String) async throws {
disconnect()
isDisconnecting = false
authToken = token
try await establishConnection(token: token)
}
func disconnect() {
isDisconnecting = true
authToken = nil
isAuthenticated = false
reconnectID = nil
authenticationTimedOutTask = nil
receiveTask?.cancel()
receiveTask = nil
pingTask?.cancel()
pingTask = nil
reconnectTask?.cancel()
reconnectTask = nil
webSocketTask?.cancel(with: .goingAway, reason: nil)
webSocketTask = nil
}
func send(_ message: WSMessage) async throws {
guard isAuthenticated, let task = webSocketTask else {
throw WSClientError.notConnected
}
let encodedMessage: String
do {
let data = try JSONEncoder().encode(message)
guard let string = String(data: data, encoding: .utf8) else {
throw WSClientError.encodingError
}
encodedMessage = string
} catch let error as WSClientError {
throw error
} catch {
throw WSClientError.encodingError
}
do {
try await task.send(.string(encodedMessage))
} catch {
connectionDidFail(for: task)
throw WSClientError.transportError(error)
}
}
private func establishConnection(token: String) async throws {
let task = session.webSocketTask(with: url)
webSocketTask = task
task.resume()
do {
try await sendAuthentication(token: token, using: task)
let authResponse = try await receiveAuthenticationMessage(from: task)
guard authResponse.type == "auth.ok" else {
throw WSClientError.authenticationFailed(authResponse.reason)
}
guard webSocketTask === task, !isDisconnecting else {
throw WSClientError.notConnected
}
isAuthenticated = true
startReceiveLoop(for: task)
startPingLoop(for: task)
} catch let error as WSClientError {
closeFailedConnection(task)
throw error
} catch {
closeFailedConnection(task)
throw WSClientError.transportError(error)
}
}
private func sendAuthentication(token: String, using task: URLSessionWebSocketTask) async throws {
let authMessage = WSMessage(type: "auth", token: token)
let data = try JSONEncoder().encode(authMessage)
guard let string = String(data: data, encoding: .utf8) else {
throw WSClientError.encodingError
}
try await task.send(.string(string))
}
private func receiveAuthenticationMessage(
from task: URLSessionWebSocketTask
) async throws -> WSMessage {
let timeoutTask = Task { [weak self] in
do {
try await Task.sleep(nanoseconds: 10_000_000_000)
} catch {
return
}
await self?.timeoutAuthentication(for: task)
}
defer { timeoutTask.cancel() }
do {
let message = try await task.receive()
return try decode(message)
} catch {
if authenticationTimedOutTask === task {
authenticationTimedOutTask = nil
throw WSClientError.authenticationTimedOut
}
throw error
}
}
private func timeoutAuthentication(for task: URLSessionWebSocketTask) {
guard webSocketTask === task, !isAuthenticated else {
return
}
authenticationTimedOutTask = task
task.cancel(with: .policyViolation, reason: nil)
}
private func startReceiveLoop(for task: URLSessionWebSocketTask) {
receiveTask?.cancel()
receiveTask = Task { [weak self] in
await self?.receiveMessages(from: task)
}
}
private func receiveMessages(from task: URLSessionWebSocketTask) async {
do {
while !Task.isCancelled, webSocketTask === task {
let message = try await task.receive()
incomingContinuation.yield(try decode(message))
}
} catch is CancellationError {
return
} catch {
connectionDidFail(for: task)
}
}
private func startPingLoop(for task: URLSessionWebSocketTask) {
pingTask?.cancel()
pingTask = Task { [weak self] in
while !Task.isCancelled {
do {
try await Task.sleep(nanoseconds: 30_000_000_000)
} catch {
return
}
await self?.sendPing(using: task)
}
}
}
private func sendPing(using task: URLSessionWebSocketTask) async {
guard isAuthenticated, webSocketTask === task else {
return
}
do {
try await task.send(.string("{\"type\":\"ping\"}"))
} catch {
connectionDidFail(for: task)
}
}
private func connectionDidFail(for task: URLSessionWebSocketTask) {
guard webSocketTask === task, !isDisconnecting else {
return
}
isAuthenticated = false
authenticationTimedOutTask = nil
webSocketTask = nil
receiveTask?.cancel()
receiveTask = nil
pingTask?.cancel()
pingTask = nil
task.cancel(with: .abnormalClosure, reason: nil)
scheduleReconnect()
}
private func scheduleReconnect() {
guard reconnectTask == nil, let token = authToken, !isDisconnecting else {
return
}
let id = UUID()
reconnectID = id
reconnectTask = Task { [weak self] in
await self?.reconnect(using: token, id: id)
}
}
private func reconnect(using token: String, id: UUID) async {
let delays: [UInt64] = [2, 4, 8, 16, 32]
for delay in delays {
do {
try await Task.sleep(nanoseconds: delay * 1_000_000_000)
} catch {
return
}
guard reconnectID == id, !isDisconnecting else {
return
}
do {
try await establishConnection(token: token)
if reconnectID == id {
reconnectID = nil
reconnectTask = nil
}
return
} catch {
continue
}
}
if reconnectID == id {
reconnectID = nil
reconnectTask = nil
}
}
private func closeFailedConnection(_ task: URLSessionWebSocketTask) {
guard webSocketTask === task else {
return
}
task.cancel(with: .policyViolation, reason: nil)
webSocketTask = nil
isAuthenticated = false
authenticationTimedOutTask = nil
}
private func decode(_ message: URLSessionWebSocketTask.Message) throws -> WSMessage {
let data: Data
switch message {
case .data(let messageData):
data = messageData
case .string(let string):
data = Data(string.utf8)
@unknown default:
throw WSClientError.decodingError
}
do {
return try JSONDecoder().decode(WSMessage.self, from: data)
} catch {
throw WSClientError.decodingError
}
}
}
struct WSMessage: Codable, Equatable {
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?
var messageIDs: [String]? // used by read receipts: { type: "read", messageIDs: [...] }
var attachment: WSAttachment? // file metadata attached to a chat.message
init(
type: String,
id: String? = nil,
body: String? = nil,
to: String? = nil,
from: String? = nil,
fromName: String? = nil,
token: String? = nil,
peerID: String? = nil,
reason: String? = nil,
messageIDs: [String]? = nil,
attachment: WSAttachment? = nil
) {
self.type = type
self.id = id
self.body = body
self.to = to
self.from = from
self.fromName = fromName
self.token = token
self.peerID = peerID
self.reason = reason
self.messageIDs = messageIDs
self.attachment = attachment
}
}
struct WSAttachment: Codable, Equatable {
let fileID: String
let name: String
let size: Int
}
enum WSClientError: Error {
case notConnected
case authenticationTimedOut
case authenticationFailed(String?)
case transportError(Error)
case encodingError
case decodingError
}

View File

@ -0,0 +1,33 @@
import AppKit
import Foundation
import UserNotifications
final class NotificationManager {
static let shared = NotificationManager()
private let notificationCenter = UNUserNotificationCenter.current()
private init() {}
func requestPermission() async {
_ = try? await notificationCenter.requestAuthorization(options: [.alert, .sound])
}
func notify(from senderName: String, body: String) {
guard !NSApp.isActive else {
return
}
let content = UNMutableNotificationContent()
content.title = senderName
content.body = String(body.prefix(100))
content.sound = .default
let request = UNNotificationRequest(
identifier: UUID().uuidString,
content: content,
trigger: nil
)
notificationCenter.add(request)
}
}

View File

@ -0,0 +1,284 @@
import Combine
import Foundation
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] = []
private let relayAPI = RelayAPI.shared
private let webSocket = WSClient(url: URL(string: "wss://fchati.diyaa.de/ws")!)
private let messageStore = MessageStore.shared
private var incomingMessagesTask: Task<Void, Never>?
private init() {
Task { [weak self] in
guard let self else {
return
}
await NotificationManager.shared.requestPermission()
await loadStoredMessages()
await restoreSession()
}
}
deinit {
incomingMessagesTask?.cancel()
}
func createPairingCode(displayName: String) async throws -> String {
state = .connecting
do {
let response = try await relayAPI.createPairing(displayName: displayName)
KeychainStore.authToken = response.token
KeychainStore.peerID = nil
KeychainStore.peerName = nil
try await webSocket.connect(token: response.token)
startIncomingMessages()
return response.code
} catch {
state = .error(message(for: error))
throw error
}
}
func joinWithCode(_ code: String, displayName: String) async throws {
state = .connecting
do {
let response = try await relayAPI.joinPairing(code: code, displayName: displayName)
KeychainStore.authToken = response.token
KeychainStore.peerID = response.peer.id
KeychainStore.peerName = response.peer.displayName
try await webSocket.connect(token: response.token)
startIncomingMessages()
state = .connected(peerID: response.peer.id, peerName: response.peer.displayName)
} catch {
state = .error(message(for: error))
throw error
}
}
func sendText(_ body: String) async throws {
let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedBody.isEmpty else {
throw AppSessionError.emptyMessage
}
let message = outgoingMessage(body: trimmedBody, attachment: nil)
try await sendAndStore(message)
}
func sendFile(url: URL) async throws {
guard let token = KeychainStore.authToken else {
throw AppSessionError.notPaired
}
let uploadedFile = try await relayAPI.uploadFile(url: url, token: token)
let attachment = AttachmentInfo(
fileID: uploadedFile.id,
name: uploadedFile.name,
size: uploadedFile.size
)
let message = outgoingMessage(
body: "Shared a file: \(uploadedFile.name)",
attachment: attachment
)
try await sendAndStore(message)
}
func unpair() {
incomingMessagesTask?.cancel()
incomingMessagesTask = nil
KeychainStore.clearAll()
let webSocket = webSocket
Task {
await webSocket.disconnect()
}
state = .unpaired
}
private func restoreSession() async {
guard
let token = KeychainStore.authToken,
let peerID = KeychainStore.peerID,
let peerName = KeychainStore.peerName
else {
return
}
state = .connecting
do {
try await webSocket.connect(token: token)
startIncomingMessages()
state = .connected(peerID: peerID, peerName: peerName)
} catch {
state = .error(message(for: error))
}
}
private func loadStoredMessages() async {
do {
messages = try await messageStore.loadAll()
} catch {
state = .error("Unable to load saved messages.")
}
}
private func startIncomingMessages() {
guard incomingMessagesTask == nil else {
return
}
let webSocket = webSocket
incomingMessagesTask = Task { [weak self] in
let stream = await webSocket.incoming
for await message in stream {
guard !Task.isCancelled else {
return
}
await self?.handleIncomingMessage(message)
}
}
}
private func handleIncomingMessage(_ message: WSMessage) async {
switch message.type {
case "chat.message":
let attachment = message.attachment.map {
AttachmentInfo(fileID: $0.fileID, name: $0.name, size: $0.size)
}
let chatMessage = ChatMessage(
id: message.id ?? UUID().uuidString,
from: message.from ?? "unknown",
fromName: message.fromName ?? "Unknown",
body: message.body ?? "",
sentAt: Date(),
attachment: attachment,
isRead: false
)
await storeIncomingMessage(chatMessage)
NotificationManager.shared.notify(from: chatMessage.fromName, body: chatMessage.body)
case "read":
for id in message.messageIDs ?? [] {
markMessageAsRead(id: id)
}
default:
break
}
}
private func sendAndStore(_ message: ChatMessage) async throws {
let attachment = message.attachment.map {
WSAttachment(fileID: $0.fileID, name: $0.name, size: $0.size)
}
let webSocketMessage = WSMessage(
type: "chat.message",
id: message.id,
body: message.body,
attachment: attachment
)
try await webSocket.send(webSocketMessage)
await storeOutgoingMessage(message)
}
private func outgoingMessage(body: String, attachment: AttachmentInfo?) -> ChatMessage {
ChatMessage(
id: UUID().uuidString,
from: KeychainStore.installationID,
fromName: "You",
body: body,
sentAt: Date(),
attachment: attachment,
isRead: true
)
}
private func storeIncomingMessage(_ message: ChatMessage) async {
upsert(message)
do {
try await messageStore.save(message)
} catch {
state = .error("Unable to save an incoming message.")
}
}
private func storeOutgoingMessage(_ message: ChatMessage) async {
upsert(message)
do {
try await messageStore.save(message)
} catch {
state = .error("Unable to save an outgoing message.")
}
}
private func markMessageAsRead(id: String) {
guard let index = messages.firstIndex(where: { $0.id == id }) else {
return
}
messages[index].isRead = true
Task {
do {
try await messageStore.markRead(id: id)
} catch {
state = .error("Unable to update a message receipt.")
}
}
}
private func upsert(_ message: ChatMessage) {
if let index = messages.firstIndex(where: { $0.id == message.id }) {
messages[index] = message
} else {
messages.append(message)
messages.sort { $0.sentAt < $1.sentAt }
}
}
private func message(for error: Error) -> String {
if let error = error as? LocalizedError, let description = error.errorDescription {
return description
}
return String(describing: error)
}
}
enum AppSessionError: LocalizedError {
case emptyMessage
case notPaired
var errorDescription: String? {
switch self {
case .emptyMessage:
"A message cannot be empty."
case .notPaired:
"Pair with someone before sending a file."
}
}
}

View File

@ -0,0 +1,127 @@
import Foundation
import Security
enum KeychainStore {
static var installationID: String {
if let existingID = value(for: .installationID) {
return existingID
}
let newID = UUID().uuidString
store(newID, for: .installationID)
return newID
}
static var authToken: String? {
get { value(for: .authToken) }
set { set(newValue, for: .authToken) }
}
static var peerID: String? {
get { value(for: .peerID) }
set { set(newValue, for: .peerID) }
}
static var peerName: String? {
get { value(for: .peerName) }
set { set(newValue, for: .peerName) }
}
static func clearAll() {
delete(.authToken)
delete(.peerID)
delete(.peerName)
}
private static let service = "de.diyaa.fchati"
private enum Key: String {
case installationID
case authToken
case peerID
case peerName
}
private static func set(_ value: String?, for key: Key) {
guard let value else {
delete(key)
return
}
store(value, for: key)
}
private static func value(for key: Key) -> String? {
var query = baseQuery(for: key)
query[kSecMatchLimit] = kSecMatchLimitOne
query[kSecReturnData] = true
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status != errSecItemNotFound else {
return nil
}
guard status == errSecSuccess, let data = result as? Data else {
reportFailure(operation: "read", status: status)
return nil
}
guard let value = String(data: data, encoding: .utf8) else {
assertionFailure("Keychain value is not valid UTF-8.")
return nil
}
return value
}
private static func store(_ value: String, for key: Key) {
guard let data = value.data(using: .utf8) else {
assertionFailure("Keychain value cannot be encoded as UTF-8.")
return
}
let query = baseQuery(for: key)
let attributes = [kSecValueData: data] as CFDictionary
let updateStatus = SecItemUpdate(query as CFDictionary, attributes)
if updateStatus == errSecItemNotFound {
var addQuery = query
addQuery[kSecValueData] = data
addQuery[kSecAttrAccessible] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
guard addStatus == errSecSuccess else {
reportFailure(operation: "add", status: addStatus)
return
}
return
}
guard updateStatus == errSecSuccess else {
reportFailure(operation: "update", status: updateStatus)
return
}
}
private static func delete(_ key: Key) {
let status = SecItemDelete(baseQuery(for: key) as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
reportFailure(operation: "delete", status: status)
return
}
}
private static func baseQuery(for key: Key) -> [CFString: Any] {
[
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: key.rawValue,
]
}
private static func reportFailure(operation: String, status: OSStatus) {
assertionFailure("Keychain \(operation) failed: \(status).")
}
}

View File

@ -0,0 +1,97 @@
import Foundation
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 {
var messages = try loadAll()
if let index = messages.firstIndex(where: { $0.id == message.id }) {
messages[index] = message
} else {
messages.append(message)
}
messages.sort { $0.sentAt < $1.sentAt }
try write(messages)
}
func loadAll() throws -> [ChatMessage] {
let url = try messagesURL()
guard fileManager.fileExists(atPath: url.path) else {
return []
}
let data = try Data(contentsOf: url)
return try decoder.decode([ChatMessage].self, from: data)
.sorted { $0.sentAt < $1.sentAt }
}
func markRead(id: String) throws {
var messages = try loadAll()
guard let index = messages.firstIndex(where: { $0.id == id }) else {
return
}
messages[index].isRead = true
try write(messages)
}
var unreadCount: Int {
get async {
(try? loadAll().filter { !$0.isRead }.count) ?? 0
}
}
private let fileManager = FileManager.default
private let encoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
return encoder
}()
private let decoder: JSONDecoder = {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return decoder
}()
private func messagesURL() throws -> URL {
let applicationSupportDirectory = try fileManager.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let directory = applicationSupportDirectory.appendingPathComponent(
"de.diyaa.fchati",
isDirectory: true
)
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
return directory.appendingPathComponent("messages.json")
}
private func write(_ messages: [ChatMessage]) throws {
let data = try encoder.encode(messages)
try data.write(to: messagesURL(), options: .atomic)
}
}

View File

@ -0,0 +1,19 @@
import XCTest
@testable import FchatiApp
final class ChatMessageBubbleTests: XCTestCase {
func testMessageKeepsMarkdownBodyForRendering() {
let message = ChatMessage(
id: "message-id",
from: "peer-id",
fromName: "Alex",
body: "Hello, **world**",
sentAt: Date(),
attachment: nil,
isRead: false
)
XCTAssertEqual(message.body, "Hello, **world**")
XCTAssertNotNil(try? AttributedString(markdown: message.body))
}
}

View File

@ -0,0 +1,27 @@
import XCTest
@testable import FchatiApp
final class MessageStoreModelTests: XCTestCase {
func testMessageRoundTripPreservesAttachmentAndReadStatus() throws {
let message = ChatMessage(
id: "message-id",
from: "peer-id",
fromName: "Alex",
body: "Shared a file: photo.jpg",
sentAt: Date(timeIntervalSince1970: 1_721_995_200),
attachment: AttachmentInfo(fileID: "file-id", name: "photo.jpg", size: 128),
isRead: false
)
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let encodedMessage = try encoder.encode(message)
let decodedMessage = try decoder.decode(ChatMessage.self, from: encodedMessage)
XCTAssertEqual(decodedMessage.id, message.id)
XCTAssertEqual(decodedMessage.attachment, message.attachment)
XCTAssertEqual(decodedMessage.isRead, message.isRead)
}
}

View File

@ -0,0 +1,10 @@
import XCTest
@testable import FchatiApp
final class PairingViewModelTests: XCTestCase {
func testPairingCodeIsUppercasedAndPrefixed() {
XCTAssertEqual(PairingViewModel.normalizedCode(from: "ab3k7q"), "FCHT-AB3K7Q")
XCTAssertEqual(PairingViewModel.normalizedCode(from: "fcht-ab3k7q"), "FCHT-AB3K7Q")
XCTAssertEqual(PairingViewModel.normalizedCode(from: " FCHT-ab3k7q "), "FCHT-AB3K7Q")
}
}

View File

@ -0,0 +1,36 @@
import XCTest
@testable import FchatiApp
final class RelayAPIResponseTests: XCTestCase {
func testPairingCreateResponseDecodesServerPayload() throws {
let data = Data(
"""
{"code":"FCHT-AB3K7Q","token":"creator-token","peerID":"creator-id","expiresAt":"2026-07-26T12:00:00Z"}
""".utf8
)
let response = try JSONDecoder().decode(PairingCreateResponse.self, from: data)
XCTAssertEqual(response.code, "FCHT-AB3K7Q")
XCTAssertEqual(response.peerID, "creator-id")
}
func testPairingJoinAndFileUploadResponsesDecodeServerPayloads() throws {
let pairingData = Data(
"""
{"token":"joiner-token","peerID":"joiner-id","peer":{"id":"creator-id","displayName":"Alex"}}
""".utf8
)
let fileData = Data(
"""
{"id":"file-id","name":"photo.jpg","size":128}
""".utf8
)
let pairingResponse = try JSONDecoder().decode(PairingJoinResponse.self, from: pairingData)
let fileResponse = try JSONDecoder().decode(FileUploadResponse.self, from: fileData)
XCTAssertEqual(pairingResponse.peer, PeerInfo(id: "creator-id", displayName: "Alex"))
XCTAssertEqual(fileResponse, FileUploadResponse(id: "file-id", name: "photo.jpg", size: 128))
}
}

View File

@ -0,0 +1,21 @@
import XCTest
@testable import FchatiApp
final class WSMessageTests: XCTestCase {
func testMessageRoundTripPreservesServerFields() throws {
let message = WSMessage(
type: "chat.message",
id: "message-id",
body: "Hello",
from: "peer-id",
fromName: "Alex"
)
let decodedMessage = try JSONDecoder().decode(
WSMessage.self,
from: JSONEncoder().encode(message)
)
XCTAssertEqual(decodedMessage, message)
}
}

View File

@ -1,50 +1,194 @@
# حالة التنفيذ
# Implementation Status
## آخر تحديث
## TASK-01 — Swift Package + App Shell ✅
تم إنشاء الهيكل الأولي لتطبيق ماك، مع سكربت محلي لاختباره وبنائه وتثبيته وفتحه.
**Completed by:** Codex agent
**Fixes applied by:** Claude (LSUIElement, entitlements in codesign)
## ما تم إنجازه
### What was built
- إنشاء حزمة سويفت لتطبيق ماك يدعم نظام ماك 14 فما فوق.
- إنشاء تطبيق شريط القوائم مع واجهة بعرض 360 نقطة وارتفاع أدنى 480 نقطة.
- إضافة تبويبات للدردشة والاقتران والإعدادات، مع حالات بداية مؤقتة لكل تبويب.
- إضافة صلاحيات الشبكة وصندوق الحماية للتطبيق.
- إضافة اختبار يتحقق من وجود التبويبات الثلاثة وأيقوناتها.
- إضافة ملف معلومات لتجميع الملف التنفيذي داخل حزمة تطبيق قابلة للتشغيل.
- إضافة سكربت للتنظيف والاختبار والبناء والتثبيت والفتح.
- `FchatiApp/Package.swift` — SPM package, macOS 14+, no Xcode
- `FchatiApp/Sources/FchatiApp/FchatiApp.swift` — MenuBarExtra (.window style), 360pt wide, 480pt min height, three tabs (Chat / Pairing / Settings) with segmented picker
- `FchatiApp/FchatiApp.entitlements` — sandbox + outgoing/incoming network
- `FchatiApp/Info.plist` — bundle ID `de.diyaa.fchati`, version 0.1.0, `LSUIElement = YES` (hides Dock icon)
- `FchatiApp/Tests/FchatiAppTests/AppTabTests.swift` — verifies all three tabs and their icons exist
- `scripts/run-macos-app.sh` — clean → test → release build → bundle → codesign → install to /Applications → open
## السكربت المحلي
### Verified
المسار:
- `swift build` passes (debug)
- `swift test` passes (1 test)
- Script installs and opens `/Applications/Fchati.app` successfully
`scripts/run-macos-app.sh`
### Fixes applied after agent review
ينفذ السكربت الخطوات التالية بالترتيب:
- Added `LSUIElement = YES` to `Info.plist` (was missing — app was appearing in Dock)
- Added `--entitlements` flag to `codesign` in the build script (sandbox permissions were not being applied)
1. يحذف نواتج البناء الحالية الخاصة بتطبيق ماك فقط.
2. يشغّل الاختبارات.
3. يبني نسخة إصدار من التطبيق.
4. ينشئ حزمة تطبيق ماك مؤقتة ويوقعها محليًا.
5. يحذف النسخة الموجودة من التطبيق في مجلد التطبيقات، إن وجدت.
6. ينقل النسخة الجديدة إلى مجلد التطبيقات.
7. يفتح التطبيق الجديد.
---
مسار التثبيت الناتج:
## TASK-02 — KeychainStore ✅
`/Applications/Fchati.app`
**Completed by:** Codex agent
## التحقق المنفذ
### What was built
- نجح اختبار الحزمة، مع اختبار واحد ناجح ودون إخفاقات.
- تم تنفيذ سكربت التثبيت كاملًا بنجاح، بما في ذلك البناء والتثبيت والفتح.
- تمت إضافة اختبار للحفاظ على عقد واجهة البداية.
- لم يتم تعديل ملف المهام الموجود مسبقًا.
- `FchatiApp/Sources/FchatiApp/Storage/KeychainStore.swift` — direct Security.framework wrapper for the installation ID, auth token, peer ID, and peer name.
- The installation ID is created once as a UUID and persisted in Keychain.
- Token and peer properties support read, update, and deletion through Swift property syntax.
- `clearAll()` removes pairing credentials while preserving the installation ID.
## الحالة الحالية
### Verified
واجهة البداية وحزمة التطبيق جاهزتان للتشغيل المحلي.
- `swift build` passes from `FchatiApp/`.
لا تزال ميزات التخزين الآمن والاقتران والاتصال بالخادم والدردشة الفعلية غير منفذة.
## TASK-03 — RelayAPI (HTTP) ✅
المهمة التالية المقترحة هي إضافة التخزين الآمن للهوية والرموز، ثم ربط شاشة الاقتران بالخادم.
**Completed by:** Codex agent
### What was built
- `FchatiApp/Sources/FchatiApp/Networking/RelayAPI.swift` — actor-based HTTP client for pairing creation, pairing join, file upload, and file download.
- Pairing requests use JSON and decode the exact relay response payloads.
- File uploads use `multipart/form-data` with the required `file` field and bearer-token authorization.
- Non-success HTTP responses, transport failures, and response decoding failures map to `RelayAPIError`.
- `FchatiApp/Tests/FchatiAppTests/RelayAPIResponseTests.swift` — response-decoding coverage for relay pairing and upload payloads.
### Verified
- `swift build` passes from `FchatiApp/`.
- `swift test` passes with the relay response tests.
## TASK-04 — WSClient (WebSocket) ✅
**Completed by:** Codex agent
### What was built
- `FchatiApp/Sources/FchatiApp/Networking/WSClient.swift` — actor-based WebSocket client using `URLSessionWebSocketTask`.
- Connect sends authentication immediately, requires an `auth.ok` response within 10 seconds, and exposes authenticated incoming messages through `AsyncStream`.
- The client sends a ping every 30 seconds and reconnects after unexpected disconnects with delays of 2, 4, 8, 16, and 32 seconds.
- Explicit disconnects cancel reconnect attempts and retain the incoming stream for a later connection.
- `FchatiApp/Tests/FchatiAppTests/WSMessageTests.swift` — serialization coverage for relay WebSocket messages.
### Verified
- `swift build` passes from `FchatiApp/`.
- `swift test` passes with the WebSocket message test.
## TASK-05 — AppSession (State Machine) ✅
**Completed by:** Codex agent
### What was built
- `FchatiApp/Sources/FchatiApp/Session/AppSession.swift` — main-actor application state for pairing, session restoration, message sending, file sending, read receipts, and incoming message handling.
- Existing credentials restore the WebSocket connection at launch.
- Pairing and message failures update the published state with an error description.
- File uploads are converted to attachment metadata and forwarded through relay chat messages.
## TASK-08 — MessageStore (Local Persistence) ✅
**Completed by:** Codex agent
### What was built
- `FchatiApp/Sources/FchatiApp/Storage/MessageStore.swift` — actor-backed JSON persistence at the required Application Support location.
- Message writes are atomic, deduplicated by message ID, sorted by timestamp, and retain read-receipt state.
- `FchatiApp/Tests/FchatiAppTests/MessageStoreModelTests.swift` — serialization coverage for messages and attachment metadata.
## TASK-10 — New Message Notifications ✅
**Completed by:** Codex agent
### What was built
- `FchatiApp/Sources/FchatiApp/Notifications/NotificationManager.swift` — notification permission request and background message notification delivery.
- Notifications use the sender name, limit the preview to 100 characters, and are suppressed while the app is active.
### Verified
- `swift build` passes from `FchatiApp/`.
- `swift test` passes with session storage coverage.
## TASK-06 — PairingView ✅
**Completed by:** Codex agent
### What was built
- `FchatiApp/Sources/FchatiApp/Features/Pairing/PairingView.swift` — create and join pairing screens with a segmented mode selector, code copy action, loading states, and inline errors.
- `FchatiApp/Sources/FchatiApp/Features/Pairing/PairingViewModel.swift` — pairing request orchestration, code normalization, and display-state management.
- Join codes are uppercased automatically and accept values with or without the `FCHT-` prefix.
- A connection callback allows the app shell to switch to the chat screen when pairing succeeds.
- `FchatiApp/Tests/FchatiAppTests/PairingViewModelTests.swift` — normalization coverage for pairing code input.
### Verified
- `swift build` passes from `FchatiApp/`.
- `swift test` passes with pairing view-model coverage.
## TASK-07 — ChatView ✅
**Completed by:** Codex agent
### What was built
- `FchatiApp/Sources/FchatiApp/Features/Chat/ChatView.swift` — scrollable chat UI with automatic scrolling, text submission on Return, file selection, and hold-to-record voice input.
- `FchatiApp/Sources/FchatiApp/Features/Chat/ChatViewModel.swift` — message and file sending, `.m4a` recording lifecycle, temporary recording cleanup, and inline send errors.
- `FchatiApp/Sources/FchatiApp/Features/Chat/MessageBubble.swift` — left and right message bubbles, Markdown body rendering, attachment metadata, and timestamps.
- `FchatiApp/FchatiApp.entitlements` and `FchatiApp/Info.plist` — microphone sandbox entitlement and privacy usage description required for voice recording.
- `FchatiApp/Tests/FchatiAppTests/ChatMessageBubbleTests.swift` — Markdown rendering coverage for message bodies.
### Verified
- `swift build` passes from `FchatiApp/`.
- `swift test` passes with chat view coverage.
## TASK-09 — SettingsView ✅
**Completed by:** Codex agent
### What was built
- `FchatiApp/Sources/FchatiApp/Features/Settings/SettingsView.swift` — editable display name, published session connection state, destructive unpair action, and bundled application version display.
- The view reads and writes the display name through `KeychainStore.peerName` and calls `AppSession.unpair()` to clear pairing credentials.
### Verified
- `swift build` passes from `FchatiApp/`.
- `swift test` passes with all current application tests.
## Integration Update ✅
**Completed by:** Codex agent
### What was updated
- `FchatiApp/Sources/FchatiApp/FchatiApp.swift` now renders `PairingView`, `ChatView`, and `SettingsView` in the menu-bar tabs instead of placeholder content.
- The initial tab is pairing for first-time setup.
- A successful pairing switches the selected tab to chat.
### Verified
- `swift build` passes from `FchatiApp/`.
- `swift test` passes with all current application tests.
## Server File Cleanup ✅
**Completed by:** Codex agent
### What was built
- `relay-server/src/index.js` — removes expired uploaded files at startup and once every 24 hours. The retention period defaults to 30 days and is configurable with `FILE_TTL_DAYS`.
- `POST /admin/cleanup` — protected manual cleanup endpoint. It accepts an optional JSON `days` value between 1 and 3650 and returns the number of deleted files and bytes.
- `relay-server/.env.example` — documents the retention setting and required `ADMIN_TOKEN` bearer secret.
### Security
- The manual cleanup endpoint returns unavailable until `ADMIN_TOKEN` is configured.
- Requests must use `Authorization: Bearer <ADMIN_TOKEN>`.
## Pending
See `TASKS.md` for all remaining tasks.

213
TASKS.md
View File

@ -6,20 +6,82 @@
---
## Rules for every agent
These rules apply to every agent that works on this project, without exception.
**1. No Arabic anywhere.**
All code, comments, string literals, log messages, error messages, and documentation files must be in English only.
**2. Mark your task as done.**
When you finish a task, find it in this file and change its status line from `[ ]` to `[x]` and add today's date. Example:
```
- [x] TASK-02 — KeychainStore (done 2026-07-26)
```
This tells the next agent not to redo your work.
**3. Document what you did.**
Update `IMPLEMENTATION_STATUS.md` with: what files you created or changed, what was verified (tests, build), and anything you found missing or left incomplete. Write in English. This file is how the next session picks up exactly where you left off.
**4. Verify before finishing.**
Run `swift build` from `FchatiApp/` before declaring the task complete. If it does not compile, fix it. Do not leave broken code.
**5. Do not touch completed tasks.**
If a task is marked `[x]`, do not modify those files unless explicitly instructed.
---
## Architecture Decisions (source of truth)
These are decisions made during planning — do not change without revisiting the reasoning.
**Transport:** WebSocket (WSS) over Traefik. Traefik handles TLS via Let's Encrypt. The app uses `URLSessionWebSocketTask` — no custom TCP stack, no certificate pinning, no OpenSSL.
**Server role:** The relay is a dumb pipe. It does not store conversation history. It only holds undelivered messages temporarily (offline queue). Once delivered, messages are deleted from the server.
**Conversation history:** Stored locally on each device only. The app saves every message it sends or receives to a local JSON file. This is the permanent record — the server is not the archive.
**Offline delivery:** Messages sent to an offline peer are queued on disk on the server (not in RAM). When the peer reconnects, all queued messages are flushed immediately. Queue TTL: 7 days. Queue is disk-backed so it survives server restarts.
**Pairing sessions:** Also persisted on disk. Tokens survive server restarts — users never need to re-pair after a server update.
**Read receipts:** When a peer opens and reads messages, the app sends `{ type: "read", messageIDs: [...] }`. The server forwards this to the sender (or queues it if offline). The sender's app then shows "read" status on those messages.
**File storage:** Uploaded files (images, voice, documents) live on the server at `/data/files`. Text message JSON is negligible in size (a year of heavy messaging ≈ tens of MB). Files are the real storage concern — photos/voice accumulate at ~12 GB/month with heavy use.
**File cleanup:** Files older than 30 days should be auto-deleted by the server. A manual "Clean now" button will also be available in Settings. Text messages on the device are never auto-deleted.
**Server requirements:** A $5/month VPS (1 vCPU, 1 GB RAM) is more than sufficient for a small group of friends. The relay holds connections open but does no heavy processing.
**No iCloud, no third-party services:** Everything flows through the self-hosted relay. Apple has no access to messages.
**Mac app distribution:** Not via App Store. Distribution options TBD (direct `.app` download, notarization, or TestFlight).
---
## 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 |
| `relay-server/src/index.js` | Node.js relay — Pairing, WebSocket, offline queue (disk-backed), read receipts, file upload/download |
| `relay-server/Dockerfile` | Multi-stage Alpine build |
| `relay-server/docker-compose.yml` | Traefik labels for `fchati.diyaa.de`, three persistent volumes (files, queue, pairing) |
| `relay-server/.env.example` | Environment variable template |
| Git repo | `git.mohfarawati.de/diyaa/fchaty` |
### Not built yet
The Mac app does not exist yet.
The Mac app (`FchatiApp/`) does not exist yet.
### Pending server tasks
| Task | Details |
|------|---------|
| File auto-cleanup | ✅ Done 2026-07-26 — deletes files older than `FILE_TTL_DAYS` on startup and daily. |
| Manual cleanup endpoint | ✅ Done 2026-07-26 — protected `POST /admin/cleanup` deletes files older than a requested number of days. |
| Deploy to server | `git pull && docker compose up -d --build` on the production server. Not done yet — server is still being set up. |
---
@ -63,11 +125,10 @@ f-chaty-native-new/
---
### TASK-01 — Swift Package + App Shell
### TASK-01 — Swift Package + App Shell ✅ done 2026-07-26
**Priority:** First — everything else depends on this
**Files:** `FchatiApp/Package.swift`, `FchatiApp/Sources/FchatiApp/FchatiApp.swift`, `FchatiApp.entitlements`
**Independent:** Yes
**Completed.** See `IMPLEMENTATION_STATUS.md` for details.
**Post-review fixes:** Added `LSUIElement` to `Info.plist`, applied entitlements in codesign script.
**Requirements:**
@ -115,134 +176,30 @@ Verify: `swift build` succeeds from `FchatiApp/` directory.
---
### TASK-02 — KeychainStore
### TASK-02 — KeychainStore ✅ done 2026-07-26
**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.
**Reviewed:** No issues. Clean Security.framework usage, correct access policy (`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`), `clearAll()` correctly preserves installationID.
---
### TASK-03 — RelayAPI (HTTP)
### TASK-03 — RelayAPI (HTTP) ✅ done 2026-07-26
**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"`.
**Reviewed:** Good. All endpoints correct, multipart/form-data properly formatted, error types match spec.
**Note for future:** `uploadFile` loads entire file into memory — acceptable for now, revisit if large video support is needed.
---
### TASK-04 — WSClient (WebSocket)
### TASK-04 — WSClient (WebSocket) ✅ done 2026-07-26
**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
**Reviewed:** Excellent. Reconnect backoff correct, auth timeout clean, race conditions protected via task identity.
**Fix applied:** Added `messageIDs: [String]?` and `attachment: WSAttachment?` to `WSMessage` — required for read receipts and file messages.
---
### TASK-05 — AppSession (State Machine)
- [x] TASK-05 — AppSession (State Machine) (done 2026-07-26)
**Priority:** Third — after TASK-02, TASK-03, TASK-04
**File:** `FchatiApp/Sources/FchatiApp/Session/AppSession.swift`
**Depends on:** `KeychainStore`, `RelayAPI`, `WSClient`
@ -284,6 +241,8 @@ On incoming `chat.message`: append to `messages` and trigger a `UNUserNotificati
### TASK-06 — PairingView
- [x] TASK-06 — PairingView (done 2026-07-26)
**Priority:** Fourth — after TASK-05
**Files:** `Features/Pairing/PairingView.swift`, `PairingViewModel.swift`
@ -310,6 +269,8 @@ Both modes observe `AppSession.shared.state` and navigate to `ChatView` when sta
### TASK-07 — ChatView
- [x] TASK-07 — ChatView (done 2026-07-26)
**Priority:** Fourth (parallel with TASK-06)
**Files:** `Features/Chat/ChatView.swift`, `ChatViewModel.swift`, `MessageBubble.swift`
@ -332,6 +293,8 @@ Both modes observe `AppSession.shared.state` and navigate to `ChatView` when sta
### TASK-08 — MessageStore (Local Persistence)
- [x] TASK-08 — MessageStore (Local Persistence) (done 2026-07-26)
**Priority:** Fourth (parallel)
**File:** `FchatiApp/Sources/FchatiApp/Storage/MessageStore.swift`
**Independent:** Yes
@ -372,6 +335,8 @@ Create the directory if it does not exist.
### TASK-09 — SettingsView
- [x] TASK-09 — SettingsView (done 2026-07-26)
**Priority:** Fifth
**File:** `Features/Settings/SettingsView.swift`
@ -387,6 +352,8 @@ Simple settings screen:
### TASK-10 — New Message Notifications
- [x] TASK-10 — New Message Notifications (done 2026-07-26)
**Priority:** Fifth (independent)
**File:** `FchatiApp/Sources/FchatiApp/Notifications/NotificationManager.swift`

View File

@ -1,3 +1,5 @@
PORT=3000
MAX_FILE_SIZE_MB=25
UPLOADS_DIR=/data/files
FILE_TTL_DAYS=30
ADMIN_TOKEN=8545851a901067a271e8e941297acb4c3d8d68c83eddb2809a8184071e8bc46f

View File

@ -16,6 +16,9 @@ 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;
const FILE_TTL_DAYS = positiveInteger(process.env.FILE_TTL_DAYS, 30);
const FILE_CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000;
const ADMIN_TOKEN = process.env.ADMIN_TOKEN?.trim() ?? '';
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
fs.mkdirSync(QUEUE_DIR, { recursive: true });
@ -38,6 +41,49 @@ const pairingSessions = new Map(); // code -> session
const connections = new Map(); // peerID -> { ws, name }
const fileRegistry = new Map(); // fileID -> { diskPath, originalName, size }
function positiveInteger(value, fallback) {
const parsed = Number.parseInt(value ?? '', 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
function cleanupFilesOlderThan(days = FILE_TTL_DAYS) {
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
let deletedFiles = 0;
let deletedBytes = 0;
for (const entry of fs.readdirSync(UPLOADS_DIR, { withFileTypes: true })) {
if (!entry.isFile()) continue;
const diskPath = path.join(UPLOADS_DIR, entry.name);
try {
const stats = fs.statSync(diskPath);
if (stats.mtimeMs >= cutoff) continue;
fs.unlinkSync(diskPath);
fileRegistry.delete(entry.name);
deletedFiles += 1;
deletedBytes += stats.size;
} catch (error) {
console.error(`[cleanup] failed to remove ${entry.name}: ${error.message}`);
}
}
return { days, deletedFiles, deletedBytes };
}
function logCleanupResult(result, source) {
if (result.deletedFiles > 0) {
console.log(
`[cleanup] ${source}: removed ${result.deletedFiles} file(s), ${result.deletedBytes} byte(s), older than ${result.days} day(s)`
);
}
}
logCleanupResult(cleanupFilesOlderThan(), 'startup');
setInterval(() => {
logCleanupResult(cleanupFilesOlderThan(), 'scheduled');
}, FILE_CLEANUP_INTERVAL_MS);
// ─── Persistent queue (disk-backed) ──────────────────────────────────────────
//
// Layout on disk:
@ -219,6 +265,19 @@ function requireToken(req, res, next) {
next();
}
function requireAdmin(req, res, next) {
if (!ADMIN_TOKEN) {
return res.status(503).json({ error: 'Admin cleanup is not configured' });
}
const auth = req.headers.authorization ?? '';
if (auth !== `Bearer ${ADMIN_TOKEN}`) {
return res.status(401).json({ error: 'Unauthorized' });
}
next();
}
// ─── Pairing routes ───────────────────────────────────────────────────────────
app.post('/pairing/create', (req, res) => {
@ -298,6 +357,21 @@ app.get('/files/:id', requireToken, (req, res) => {
res.sendFile(meta.diskPath);
});
// ─── Admin routes ───────────────────────────────────────────────────────────
app.post('/admin/cleanup', requireAdmin, (req, res) => {
const requestedDays = req.body?.days;
const days = requestedDays === undefined ? FILE_TTL_DAYS : Number(requestedDays);
if (!Number.isInteger(days) || days < 1 || days > 3650) {
return res.status(400).json({ error: 'days must be an integer between 1 and 3650' });
}
const result = cleanupFilesOlderThan(days);
logCleanupResult(result, 'manual');
res.json(result);
});
// ─── Health check ─────────────────────────────────────────────────────────────
app.get('/health', (_req, res) => res.json({

View File

@ -44,7 +44,7 @@ echo "Creating the application bundle..."
mkdir -p "$STAGING_APP/Contents/MacOS"
cp "$PROJECT_DIR/Info.plist" "$STAGING_APP/Contents/Info.plist"
cp "$EXECUTABLE_PATH" "$STAGING_APP/Contents/MacOS/$PRODUCT_NAME"
codesign --force --sign - --timestamp=none "$STAGING_APP"
codesign --force --sign - --timestamp=none --entitlements "$PROJECT_DIR/FchatiApp.entitlements" "$STAGING_APP"
if [[ -d "$INSTALL_PATH" ]]; then
echo "Removing the installed application..."