Sorry Change name to Fchaty

This commit is contained in:
diyaa
2026-07-27 12:09:51 +02:00
parent 2e1d40caad
commit b32911dfb2
32 changed files with 172 additions and 142 deletions
+16
View File
@@ -0,0 +1,16 @@
<?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/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
</dict>
</plist>
+30
View File
@@ -0,0 +1,30 @@
<?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>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>FchatyApp</string>
<key>CFBundleIdentifier</key>
<string>de.diyaa.fchaty</string>
<key>CFBundleIconFile</key>
<string>Fchaty.icns</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Fchaty</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSMicrophoneUsageDescription</key>
<string>Fchaty uses the microphone to record voice messages.</string>
</dict>
</plist>
+18
View File
@@ -0,0 +1,18 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "FchatyApp",
platforms: [.macOS(.v14)],
targets: [
.executableTarget(
name: "FchatyApp",
path: "Sources/FchatyApp"
),
.testTarget(
name: "FchatyAppTests",
dependencies: ["FchatyApp"],
path: "Tests/FchatyAppTests"
)
]
)
Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

+106
View File
@@ -0,0 +1,106 @@
import AppKit
import AppKit
import SwiftUI
@main
struct FchatyApp: App {
var body: some Scene {
WindowGroup("Fchaty") {
RootView()
}
.defaultSize(width: 420, height: 560)
MenuBarExtra {
RootView()
.background(WindowConfigurator())
} label: {
Image(systemName: "message")
}
.menuBarExtraStyle(.window)
}
}
// Prevents the popover from closing when clicking outside.
private struct WindowConfigurator: NSViewRepresentable {
func makeNSView(context: Context) -> NSView {
let view = NSView()
DispatchQueue.main.async {
guard let window = view.window else { return }
window.level = .floating
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
window.isMovableByWindowBackground = false
}
return view
}
func updateNSView(_ nsView: NSView, context: Context) {}
}
enum AppTab: String, CaseIterable, Identifiable {
case chat = "Chat"
case pairing = "Pairing"
case settings = "Settings"
var id: Self { self }
var title: String {
rawValue
}
var icon: String {
switch self {
case .chat:
"message"
case .pairing:
"link"
case .settings:
"gearshape"
}
}
}
private struct RootView: View {
@State private var selectedTab: AppTab = .pairing
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 8) {
Picker("Section", selection: $selectedTab) {
ForEach(AppTab.allCases) { tab in
Label(tab.title, systemImage: tab.icon)
.tag(tab)
}
}
.pickerStyle(.segmented)
.labelsHidden()
Button {
NSApplication.shared.terminate(nil)
} label: {
Image(systemName: "power")
}
.accessibilityLabel("Quit Fchaty")
.help("Quit Fchaty")
}
.padding()
Divider()
Group {
switch selectedTab {
case .chat:
ChatView()
case .pairing:
PairingView {
selectedTab = .chat
}
case .settings:
SettingsView()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.frame(minWidth: 360, idealWidth: 420, maxWidth: 640)
.frame(minHeight: 480)
}
}
@@ -0,0 +1,143 @@
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 {
switch session.state {
case .unpaired:
notConnectedPlaceholder(icon: "person.2.slash", message: "Pair with someone to start chatting.")
case .connecting:
notConnectedPlaceholder(icon: "antenna.radiowaves.left.and.right", message: "Connecting...")
case .error(let msg):
notConnectedPlaceholder(icon: "exclamationmark.triangle", message: msg)
case .connected:
chatContent
}
}
private var chatContent: some View {
VStack(spacing: 0) {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 12) {
ForEach(session.messages) { message in
MessageBubble(
message: message,
isSent: message.from == KeychainStore.installationID
)
.id(message.id)
}
}
.padding()
}
.onAppear {
scrollToLatest(using: proxy)
Task { await session.markVisibleMessagesRead() }
}
.onChange(of: session.messages.last?.id) { _, _ in
scrollToLatest(using: proxy)
Task { await session.markVisibleMessagesRead() }
}
}
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 notConnectedPlaceholder(icon: String, message: String) -> some View {
VStack(spacing: 12) {
Image(systemName: icon)
.font(.system(size: 36))
.foregroundStyle(.secondary)
Text(message)
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.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)
}
}
}
@@ -0,0 +1,152 @@
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
}
// Request microphone permission before attempting to record.
AVCaptureDevice.requestAccess(for: .audio) { granted in
DispatchQueue.main.async {
if granted {
self.beginRecording()
} else {
self.errorMessage = "Microphone access denied. Enable it in System Settings."
}
}
}
}
private func beginRecording() {
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(
"FchatyRecordings",
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."
}
}
@@ -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)
}
}
@@ -0,0 +1,135 @@
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))
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("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)
}
}
@@ -0,0 +1,118 @@
import Combine
import Foundation
import AppKit
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 = Host.current().localizedName ?? ProcessInfo.processInfo.hostName
@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)
}
}
@@ -0,0 +1,108 @@
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)
}
}
Section {
Button("Quit Fchaty", role: .destructive) {
NSApplication.shared.terminate(nil)
}
}
}
.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
}
}
@@ -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://fchaty.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
}
@@ -0,0 +1,362 @@
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 sentAt: String? // ISO-8601 timestamp set by the sender; preserved through the relay
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,
sentAt: 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.sentAt = sentAt
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
}
@@ -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)
}
}
@@ -0,0 +1,319 @@
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://fchaty.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 markVisibleMessagesRead() async {
let unread = messages.filter { !$0.isRead && $0.from != KeychainStore.installationID }
guard !unread.isEmpty else { return }
let ids = unread.map { $0.id }
ids.forEach { markMessageAsRead(id: $0) }
let receipt = WSMessage(type: "read", messageIDs: ids)
try? await webSocket.send(receipt)
}
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 static let iso8601: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return f
}()
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)
}
// Use the timestamp the sender embedded; fall back to now only if missing.
let sentAt: Date
if let raw = message.sentAt,
let parsed = AppSession.iso8601.date(from: raw) {
sentAt = parsed
} else {
sentAt = Date()
}
let chatMessage = ChatMessage(
id: message.id ?? UUID().uuidString,
from: message.from ?? "unknown",
fromName: message.fromName ?? "Unknown",
body: message.body ?? "",
sentAt: sentAt,
attachment: attachment,
isRead: false
)
await storeIncomingMessage(chatMessage)
NotificationManager.shared.notify(from: chatMessage.fromName, body: chatMessage.body)
case "peer.joined":
// The partner just came online — save their identity and transition to connected.
if let peerID = message.peerID, let peerName = message.fromName {
KeychainStore.peerID = peerID
KeychainStore.peerName = peerName
state = .connected(peerID: peerID, peerName: peerName)
NotificationManager.shared.notify(from: "Fchaty", body: "\(peerName) connected")
}
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,
sentAt: AppSession.iso8601.string(from: message.sentAt),
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."
}
}
}
@@ -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.fchaty"
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).")
}
}
@@ -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.fchaty",
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)
}
}
@@ -0,0 +1,11 @@
import XCTest
@testable import FchatyApp
final class AppTabTests: XCTestCase {
func testTabsMatchTheAppShell() {
XCTAssertEqual(AppTab.allCases, [.chat, .pairing, .settings])
XCTAssertEqual(AppTab.chat.icon, "message")
XCTAssertEqual(AppTab.pairing.icon, "link")
XCTAssertEqual(AppTab.settings.icon, "gearshape")
}
}
@@ -0,0 +1,19 @@
import XCTest
@testable import FchatyApp
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))
}
}
@@ -0,0 +1,27 @@
import XCTest
@testable import FchatyApp
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)
}
}
@@ -0,0 +1,10 @@
import XCTest
@testable import FchatyApp
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")
}
}
@@ -0,0 +1,36 @@
import XCTest
@testable import FchatyApp
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))
}
}
@@ -0,0 +1,22 @@
import XCTest
@testable import FchatyApp
final class WSMessageTests: XCTestCase {
func testMessageRoundTripPreservesServerFields() throws {
let message = WSMessage(
type: "chat.message",
id: "message-id",
body: "Hello",
from: "peer-id",
fromName: "Alex",
sentAt: "2026-07-26T18:12:00.123Z"
)
let decodedMessage = try JSONDecoder().decode(
WSMessage.self,
from: JSONEncoder().encode(message)
)
XCTAssertEqual(decodedMessage, message)
}
}