Sorry Change name to Fchaty
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 |
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user