Complete app features and relay cleanup
This commit is contained in:
@@ -8,5 +8,7 @@
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -22,5 +22,9 @@
|
||||
<string>14.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Fchati uses the microphone to record voice messages.</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -34,7 +34,7 @@ enum AppTab: String, CaseIterable, Identifiable {
|
||||
}
|
||||
|
||||
private struct RootView: View {
|
||||
@State private var selectedTab: AppTab = .chat
|
||||
@State private var selectedTab: AppTab = .pairing
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -53,23 +53,13 @@ private struct RootView: View {
|
||||
Group {
|
||||
switch selectedTab {
|
||||
case .chat:
|
||||
PlaceholderView(
|
||||
icon: "message",
|
||||
title: "No messages yet",
|
||||
description: "Pair with someone to start chatting."
|
||||
)
|
||||
ChatView()
|
||||
case .pairing:
|
||||
PlaceholderView(
|
||||
icon: "link",
|
||||
title: "Pair a device",
|
||||
description: "Create or join a pairing session."
|
||||
)
|
||||
PairingView {
|
||||
selectedTab = .chat
|
||||
}
|
||||
case .settings:
|
||||
PlaceholderView(
|
||||
icon: "gearshape",
|
||||
title: "Settings",
|
||||
description: "Your preferences will appear here."
|
||||
)
|
||||
SettingsView()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
@@ -78,18 +68,3 @@ private struct RootView: View {
|
||||
.frame(minHeight: 480)
|
||||
}
|
||||
}
|
||||
|
||||
private struct PlaceholderView: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let description: String
|
||||
|
||||
var body: some View {
|
||||
ContentUnavailableView(
|
||||
title,
|
||||
systemImage: icon,
|
||||
description: Text(description)
|
||||
)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
struct ChatView: View {
|
||||
@ObservedObject private var session: AppSession
|
||||
@StateObject private var viewModel: ChatViewModel
|
||||
|
||||
init(session: AppSession) {
|
||||
self.session = session
|
||||
_viewModel = StateObject(wrappedValue: ChatViewModel(session: session))
|
||||
}
|
||||
|
||||
init() {
|
||||
self.init(session: AppSession.shared)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
ForEach(session.messages) { message in
|
||||
MessageBubble(
|
||||
message: message,
|
||||
isSent: message.fromName == "You"
|
||||
)
|
||||
.id(message.id)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.onAppear {
|
||||
scrollToLatest(using: proxy)
|
||||
}
|
||||
.onChange(of: session.messages.last?.id) { _, _ in
|
||||
scrollToLatest(using: proxy)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if let errorMessage = viewModel.errorMessage {
|
||||
Label(errorMessage, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
Button {
|
||||
chooseFile()
|
||||
} label: {
|
||||
Image(systemName: "paperclip")
|
||||
}
|
||||
.help("Choose a file")
|
||||
.disabled(viewModel.isSending || viewModel.isRecording)
|
||||
|
||||
TextField("Message", text: $viewModel.draft)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onSubmit {
|
||||
Task {
|
||||
await viewModel.sendText()
|
||||
}
|
||||
}
|
||||
|
||||
Image(systemName: viewModel.isRecording ? "mic.fill" : "mic")
|
||||
.foregroundStyle(viewModel.isRecording ? .red : .primary)
|
||||
.frame(width: 28, height: 28)
|
||||
.contentShape(Circle())
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { _ in
|
||||
viewModel.startRecording()
|
||||
}
|
||||
.onEnded { _ in
|
||||
viewModel.stopRecordingAndSend()
|
||||
}
|
||||
)
|
||||
.help("Hold to record audio")
|
||||
|
||||
Button("Send") {
|
||||
Task {
|
||||
await viewModel.sendText()
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(
|
||||
viewModel.isSending ||
|
||||
viewModel.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
private func chooseFile() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseDirectories = false
|
||||
panel.canChooseFiles = true
|
||||
panel.allowsMultipleSelection = false
|
||||
|
||||
guard panel.runModal() == .OK, let url = panel.url else {
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
await viewModel.sendFile(url: url)
|
||||
}
|
||||
}
|
||||
|
||||
private func scrollToLatest(using proxy: ScrollViewProxy) {
|
||||
guard let latestID = session.messages.last?.id else {
|
||||
return
|
||||
}
|
||||
|
||||
withAnimation {
|
||||
proxy.scrollTo(latestID, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import AVFoundation
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class ChatViewModel: ObservableObject {
|
||||
@Published var draft = ""
|
||||
@Published private(set) var isSending = false
|
||||
@Published private(set) var isRecording = false
|
||||
@Published private(set) var errorMessage: String?
|
||||
|
||||
private let session: AppSession
|
||||
private var recorder: AVAudioRecorder?
|
||||
private var recordingURL: URL?
|
||||
|
||||
init(session: AppSession) {
|
||||
self.session = session
|
||||
}
|
||||
|
||||
func sendText() async {
|
||||
let messageBody = draft
|
||||
guard !messageBody.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
isSending = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
try await session.sendText(messageBody)
|
||||
draft = ""
|
||||
} catch {
|
||||
errorMessage = message(for: error)
|
||||
}
|
||||
|
||||
isSending = false
|
||||
}
|
||||
|
||||
func sendFile(url: URL, removeAfterSending: Bool = false) async {
|
||||
isSending = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
try await session.sendFile(url: url)
|
||||
|
||||
if removeAfterSending {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
} catch {
|
||||
errorMessage = message(for: error)
|
||||
}
|
||||
|
||||
isSending = false
|
||||
}
|
||||
|
||||
func startRecording() {
|
||||
guard !isRecording, !isSending else {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let url = try makeRecordingURL()
|
||||
let settings: [String: Any] = [
|
||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||
AVSampleRateKey: 44_100,
|
||||
AVNumberOfChannelsKey: 1,
|
||||
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue,
|
||||
]
|
||||
|
||||
let recorder = try AVAudioRecorder(url: url, settings: settings)
|
||||
recorder.prepareToRecord()
|
||||
|
||||
guard recorder.record() else {
|
||||
throw ChatViewModelError.recordingUnavailable
|
||||
}
|
||||
|
||||
self.recorder = recorder
|
||||
recordingURL = url
|
||||
isRecording = true
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
errorMessage = message(for: error)
|
||||
}
|
||||
}
|
||||
|
||||
func stopRecordingAndSend() {
|
||||
guard isRecording, let recorder, let recordingURL else {
|
||||
return
|
||||
}
|
||||
|
||||
recorder.stop()
|
||||
self.recorder = nil
|
||||
self.recordingURL = nil
|
||||
isRecording = false
|
||||
|
||||
Task {
|
||||
await sendFile(url: recordingURL, removeAfterSending: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeRecordingURL() throws -> URL {
|
||||
let cachesDirectory = try FileManager.default.url(
|
||||
for: .cachesDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
let recordingsDirectory = cachesDirectory.appendingPathComponent(
|
||||
"FchatiRecordings",
|
||||
isDirectory: true
|
||||
)
|
||||
|
||||
try FileManager.default.createDirectory(
|
||||
at: recordingsDirectory,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
|
||||
return recordingsDirectory
|
||||
.appendingPathComponent(UUID().uuidString)
|
||||
.appendingPathExtension("m4a")
|
||||
}
|
||||
|
||||
private func message(for error: Error) -> String {
|
||||
if let error = error as? LocalizedError, let description = error.errorDescription {
|
||||
return description
|
||||
}
|
||||
|
||||
return "Unable to send the message."
|
||||
}
|
||||
}
|
||||
|
||||
enum ChatViewModelError: LocalizedError {
|
||||
case recordingUnavailable
|
||||
|
||||
var errorDescription: String? {
|
||||
"Unable to start recording."
|
||||
}
|
||||
}
|
||||
@@ -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,141 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
struct PairingView: View {
|
||||
@ObservedObject private var session: AppSession
|
||||
@StateObject private var viewModel: PairingViewModel
|
||||
private let onConnected: () -> Void
|
||||
|
||||
init(
|
||||
session: AppSession,
|
||||
onConnected: @escaping () -> Void = {}
|
||||
) {
|
||||
self.session = session
|
||||
self.onConnected = onConnected
|
||||
_viewModel = StateObject(wrappedValue: PairingViewModel(session: session))
|
||||
}
|
||||
|
||||
init(onConnected: @escaping () -> Void = {}) {
|
||||
self.init(session: AppSession.shared, onConnected: onConnected)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Picker("Pairing mode", selection: $viewModel.mode) {
|
||||
ForEach(PairingMode.allCases) { mode in
|
||||
Text(mode.rawValue).tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
switch viewModel.mode {
|
||||
case .create:
|
||||
createCodeContent
|
||||
case .join:
|
||||
enterCodeContent
|
||||
}
|
||||
|
||||
if let errorMessage = viewModel.errorMessage {
|
||||
Label(errorMessage, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding()
|
||||
.onChange(of: session.state) { _, newState in
|
||||
if case .connected = newState {
|
||||
onConnected()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var createCodeContent: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Pair a new device")
|
||||
.font(.title3.weight(.semibold))
|
||||
|
||||
TextField("Your display name", text: $viewModel.displayName)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
if let generatedCode = viewModel.generatedCode {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Share this code")
|
||||
.font(.headline)
|
||||
|
||||
HStack {
|
||||
Text(generatedCode)
|
||||
.font(.system(.title2, design: .monospaced).weight(.semibold))
|
||||
.textSelection(.enabled)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Copy") {
|
||||
copy(generatedCode)
|
||||
}
|
||||
}
|
||||
|
||||
Text("Code expires in 5 minutes")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding()
|
||||
.background(.quaternary, in: RoundedRectangle(cornerRadius: 10))
|
||||
} else {
|
||||
Button("Create Code") {
|
||||
Task {
|
||||
await viewModel.createCode()
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!viewModel.canSubmit || viewModel.isSubmitting)
|
||||
}
|
||||
|
||||
if viewModel.isSubmitting || viewModel.isWaitingForPeer {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
Text(viewModel.isWaitingForPeer ? "Waiting for the other person..." : "Creating code...")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var enterCodeContent: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Join a pairing")
|
||||
.font(.title3.weight(.semibold))
|
||||
|
||||
TextField("Your display name", text: $viewModel.displayName)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
TextField("Pairing code", text: $viewModel.code)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
Button("Connect") {
|
||||
Task {
|
||||
await viewModel.joinCode()
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!viewModel.canSubmit || viewModel.isSubmitting)
|
||||
|
||||
if viewModel.isSubmitting {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
Text("Connecting...")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func copy(_ code: String) {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(code, forType: .string)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
enum PairingMode: String, CaseIterable, Identifiable {
|
||||
case create = "Create Code"
|
||||
case join = "Enter Code"
|
||||
|
||||
var id: Self { self }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class PairingViewModel: ObservableObject {
|
||||
@Published var mode: PairingMode = .create {
|
||||
didSet {
|
||||
errorMessage = nil
|
||||
generatedCode = nil
|
||||
isWaitingForPeer = false
|
||||
}
|
||||
}
|
||||
@Published var displayName = ""
|
||||
@Published var code = "" {
|
||||
didSet {
|
||||
let uppercasedCode = code.uppercased()
|
||||
if code != uppercasedCode {
|
||||
code = uppercasedCode
|
||||
}
|
||||
}
|
||||
}
|
||||
@Published private(set) var generatedCode: String?
|
||||
@Published private(set) var errorMessage: String?
|
||||
@Published private(set) var isSubmitting = false
|
||||
@Published private(set) var isWaitingForPeer = false
|
||||
|
||||
private let session: AppSession
|
||||
|
||||
init(session: AppSession) {
|
||||
self.session = session
|
||||
}
|
||||
|
||||
var canSubmit: Bool {
|
||||
guard !displayName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
return false
|
||||
}
|
||||
|
||||
if mode == .join {
|
||||
return !code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func createCode() async {
|
||||
guard canSubmit else {
|
||||
return
|
||||
}
|
||||
|
||||
isSubmitting = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
generatedCode = try await session.createPairingCode(displayName: displayName.trimmed)
|
||||
isWaitingForPeer = true
|
||||
} catch {
|
||||
errorMessage = message(for: error)
|
||||
}
|
||||
|
||||
isSubmitting = false
|
||||
}
|
||||
|
||||
func joinCode() async {
|
||||
guard canSubmit else {
|
||||
return
|
||||
}
|
||||
|
||||
isSubmitting = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
try await session.joinWithCode(normalizedCode, displayName: displayName.trimmed)
|
||||
} catch {
|
||||
errorMessage = message(for: error)
|
||||
}
|
||||
|
||||
isSubmitting = false
|
||||
}
|
||||
|
||||
var normalizedCode: String {
|
||||
Self.normalizedCode(from: code)
|
||||
}
|
||||
|
||||
nonisolated static func normalizedCode(from code: String) -> String {
|
||||
let compactCode = code
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.replacingOccurrences(of: " ", with: "")
|
||||
.uppercased()
|
||||
|
||||
guard !compactCode.hasPrefix("FCHT-") else {
|
||||
return compactCode
|
||||
}
|
||||
|
||||
return "FCHT-\(compactCode)"
|
||||
}
|
||||
|
||||
private func message(for error: Error) -> String {
|
||||
if let error = error as? LocalizedError, let description = error.errorDescription {
|
||||
return description
|
||||
}
|
||||
|
||||
return "Unable to complete pairing."
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var trimmed: String {
|
||||
trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
struct SettingsView: View {
|
||||
@ObservedObject private var session: AppSession
|
||||
@State private var displayName: String
|
||||
@State private var isEditingName = false
|
||||
|
||||
init(session: AppSession) {
|
||||
self.session = session
|
||||
_displayName = State(initialValue: KeychainStore.peerName ?? "")
|
||||
}
|
||||
|
||||
init() {
|
||||
self.init(session: AppSession.shared)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Profile") {
|
||||
LabeledContent("Display name") {
|
||||
if isEditingName {
|
||||
TextField("Display name", text: $displayName)
|
||||
.frame(maxWidth: 180)
|
||||
} else {
|
||||
Text(displayName.isEmpty ? "Not set" : displayName)
|
||||
.foregroundStyle(displayName.isEmpty ? .secondary : .primary)
|
||||
}
|
||||
}
|
||||
|
||||
Button(isEditingName ? "Save" : "Edit") {
|
||||
if isEditingName {
|
||||
saveDisplayName()
|
||||
}
|
||||
isEditingName.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
Section("Connection") {
|
||||
LabeledContent("Status") {
|
||||
Text(connectionStatus)
|
||||
.foregroundStyle(connectionColor)
|
||||
}
|
||||
|
||||
Button("Unpair", role: .destructive) {
|
||||
session.unpair()
|
||||
}
|
||||
.disabled(isUnpaired)
|
||||
}
|
||||
|
||||
Section("About") {
|
||||
LabeledContent("Version") {
|
||||
Text(appVersion)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.padding()
|
||||
}
|
||||
|
||||
private var connectionStatus: String {
|
||||
switch session.state {
|
||||
case .unpaired:
|
||||
"Not connected"
|
||||
case .connecting:
|
||||
"Connecting..."
|
||||
case .connected(_, let peerName):
|
||||
"Connected to \(peerName)"
|
||||
case .error(let message):
|
||||
message
|
||||
}
|
||||
}
|
||||
|
||||
private var connectionColor: Color {
|
||||
switch session.state {
|
||||
case .connected:
|
||||
.green
|
||||
case .error:
|
||||
.red
|
||||
case .unpaired, .connecting:
|
||||
.secondary
|
||||
}
|
||||
}
|
||||
|
||||
private var isUnpaired: Bool {
|
||||
if case .unpaired = session.state {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private var appVersion: String {
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
|
||||
}
|
||||
|
||||
private func saveDisplayName() {
|
||||
let trimmedName = displayName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
displayName = trimmedName
|
||||
KeychainStore.peerName = trimmedName.isEmpty ? nil : trimmedName
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import Foundation
|
||||
|
||||
actor RelayAPI {
|
||||
static let shared = RelayAPI()
|
||||
|
||||
func createPairing(displayName: String) async throws -> PairingCreateResponse {
|
||||
var request = URLRequest(url: endpoint(path: "pairing/create"))
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try JSONEncoder().encode(PairingRequest(displayName: displayName))
|
||||
|
||||
return try await executeJSON(request)
|
||||
}
|
||||
|
||||
func joinPairing(code: String, displayName: String) async throws -> PairingJoinResponse {
|
||||
var request = URLRequest(url: endpoint(path: "pairing/join"))
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try JSONEncoder().encode(
|
||||
PairingJoinRequest(code: code, displayName: displayName)
|
||||
)
|
||||
|
||||
return try await executeJSON(request)
|
||||
}
|
||||
|
||||
func uploadFile(url: URL, token: String) async throws -> FileUploadResponse {
|
||||
let boundary = "Boundary-\(UUID().uuidString)"
|
||||
let fileData: Data
|
||||
|
||||
do {
|
||||
fileData = try Data(contentsOf: url)
|
||||
} catch {
|
||||
throw RelayAPIError.networkError(error)
|
||||
}
|
||||
|
||||
var request = URLRequest(url: endpoint(path: "files"))
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue(
|
||||
"multipart/form-data; boundary=\(boundary)",
|
||||
forHTTPHeaderField: "Content-Type"
|
||||
)
|
||||
request.httpBody = multipartBody(
|
||||
fileData: fileData,
|
||||
fileName: safeFileName(from: url),
|
||||
boundary: boundary
|
||||
)
|
||||
|
||||
return try await executeJSON(request)
|
||||
}
|
||||
|
||||
func downloadFile(id: String, token: String) async throws -> Data {
|
||||
var request = URLRequest(url: endpoint(path: "files/\(id)"))
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
return try await execute(request)
|
||||
}
|
||||
|
||||
private let baseURL = URL(string: "https://fchati.diyaa.de")!
|
||||
private let session = URLSession.shared
|
||||
|
||||
private init() {}
|
||||
|
||||
private func endpoint(path: String) -> URL {
|
||||
baseURL.appendingPathComponent(path)
|
||||
}
|
||||
|
||||
private func executeJSON<Response: Decodable>(_ request: URLRequest) async throws -> Response {
|
||||
let data = try await execute(request)
|
||||
|
||||
do {
|
||||
return try JSONDecoder().decode(Response.self, from: data)
|
||||
} catch {
|
||||
throw RelayAPIError.decodingError(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func execute(_ request: URLRequest) async throws -> Data {
|
||||
do {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
|
||||
guard let response = response as? HTTPURLResponse else {
|
||||
throw RelayAPIError.networkError(URLError(.badServerResponse))
|
||||
}
|
||||
|
||||
guard (200 ... 299).contains(response.statusCode) else {
|
||||
throw RelayAPIError.serverError(response.statusCode)
|
||||
}
|
||||
|
||||
return data
|
||||
} catch let error as RelayAPIError {
|
||||
throw error
|
||||
} catch {
|
||||
throw RelayAPIError.networkError(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func multipartBody(fileData: Data, fileName: String, boundary: String) -> Data {
|
||||
var body = Data()
|
||||
let escapedFileName = fileName
|
||||
.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
.replacingOccurrences(of: "\"", with: "\\\"")
|
||||
.replacingOccurrences(of: "\r", with: "")
|
||||
.replacingOccurrences(of: "\n", with: "")
|
||||
|
||||
append("--\(boundary)\r\n", to: &body)
|
||||
append(
|
||||
"Content-Disposition: form-data; name=\"file\"; filename=\"\(escapedFileName)\"\r\n",
|
||||
to: &body
|
||||
)
|
||||
append("Content-Type: application/octet-stream\r\n\r\n", to: &body)
|
||||
body.append(fileData)
|
||||
append("\r\n--\(boundary)--\r\n", to: &body)
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
private func safeFileName(from url: URL) -> String {
|
||||
let name = url.lastPathComponent
|
||||
return name.isEmpty ? "upload" : name
|
||||
}
|
||||
|
||||
private func append(_ string: String, to data: inout Data) {
|
||||
data.append(Data(string.utf8))
|
||||
}
|
||||
}
|
||||
|
||||
struct PairingCreateResponse: Codable, Equatable {
|
||||
let code: String
|
||||
let token: String
|
||||
let peerID: String
|
||||
let expiresAt: String
|
||||
}
|
||||
|
||||
struct PairingJoinResponse: Codable, Equatable {
|
||||
let token: String
|
||||
let peerID: String
|
||||
let peer: PeerInfo
|
||||
}
|
||||
|
||||
struct PeerInfo: Codable, Equatable {
|
||||
let id: String
|
||||
let displayName: String
|
||||
}
|
||||
|
||||
struct FileUploadResponse: Codable, Equatable {
|
||||
let id: String
|
||||
let name: String
|
||||
let size: Int
|
||||
}
|
||||
|
||||
enum RelayAPIError: Error {
|
||||
case networkError(Error)
|
||||
case serverError(Int)
|
||||
case decodingError(Error)
|
||||
}
|
||||
|
||||
private struct PairingRequest: Encodable {
|
||||
let displayName: String
|
||||
}
|
||||
|
||||
private struct PairingJoinRequest: Encodable {
|
||||
let code: String
|
||||
let displayName: String
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
import Foundation
|
||||
|
||||
actor WSClient {
|
||||
private let url: URL
|
||||
private let session: URLSession
|
||||
private let incomingStream: AsyncStream<WSMessage>
|
||||
private let incomingContinuation: AsyncStream<WSMessage>.Continuation
|
||||
|
||||
private var webSocketTask: URLSessionWebSocketTask?
|
||||
private var receiveTask: Task<Void, Never>?
|
||||
private var pingTask: Task<Void, Never>?
|
||||
private var reconnectTask: Task<Void, Never>?
|
||||
private var reconnectID: UUID?
|
||||
private var authenticationTimedOutTask: URLSessionWebSocketTask?
|
||||
private var authToken: String?
|
||||
private var isAuthenticated = false
|
||||
private var isDisconnecting = false
|
||||
|
||||
init(url: URL) {
|
||||
self.url = url
|
||||
self.session = URLSession(configuration: .default)
|
||||
|
||||
let stream = AsyncStream<WSMessage>.makeStream()
|
||||
self.incomingStream = stream.stream
|
||||
self.incomingContinuation = stream.continuation
|
||||
}
|
||||
|
||||
deinit {
|
||||
webSocketTask?.cancel(with: .goingAway, reason: nil)
|
||||
receiveTask?.cancel()
|
||||
pingTask?.cancel()
|
||||
reconnectTask?.cancel()
|
||||
incomingContinuation.finish()
|
||||
}
|
||||
|
||||
var incoming: AsyncStream<WSMessage> {
|
||||
incomingStream
|
||||
}
|
||||
|
||||
func connect(token: String) async throws {
|
||||
disconnect()
|
||||
isDisconnecting = false
|
||||
authToken = token
|
||||
|
||||
try await establishConnection(token: token)
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
isDisconnecting = true
|
||||
authToken = nil
|
||||
isAuthenticated = false
|
||||
reconnectID = nil
|
||||
authenticationTimedOutTask = nil
|
||||
|
||||
receiveTask?.cancel()
|
||||
receiveTask = nil
|
||||
pingTask?.cancel()
|
||||
pingTask = nil
|
||||
reconnectTask?.cancel()
|
||||
reconnectTask = nil
|
||||
|
||||
webSocketTask?.cancel(with: .goingAway, reason: nil)
|
||||
webSocketTask = nil
|
||||
}
|
||||
|
||||
func send(_ message: WSMessage) async throws {
|
||||
guard isAuthenticated, let task = webSocketTask else {
|
||||
throw WSClientError.notConnected
|
||||
}
|
||||
|
||||
let encodedMessage: String
|
||||
do {
|
||||
let data = try JSONEncoder().encode(message)
|
||||
guard let string = String(data: data, encoding: .utf8) else {
|
||||
throw WSClientError.encodingError
|
||||
}
|
||||
encodedMessage = string
|
||||
} catch let error as WSClientError {
|
||||
throw error
|
||||
} catch {
|
||||
throw WSClientError.encodingError
|
||||
}
|
||||
|
||||
do {
|
||||
try await task.send(.string(encodedMessage))
|
||||
} catch {
|
||||
connectionDidFail(for: task)
|
||||
throw WSClientError.transportError(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func establishConnection(token: String) async throws {
|
||||
let task = session.webSocketTask(with: url)
|
||||
webSocketTask = task
|
||||
task.resume()
|
||||
|
||||
do {
|
||||
try await sendAuthentication(token: token, using: task)
|
||||
let authResponse = try await receiveAuthenticationMessage(from: task)
|
||||
|
||||
guard authResponse.type == "auth.ok" else {
|
||||
throw WSClientError.authenticationFailed(authResponse.reason)
|
||||
}
|
||||
|
||||
guard webSocketTask === task, !isDisconnecting else {
|
||||
throw WSClientError.notConnected
|
||||
}
|
||||
|
||||
isAuthenticated = true
|
||||
startReceiveLoop(for: task)
|
||||
startPingLoop(for: task)
|
||||
} catch let error as WSClientError {
|
||||
closeFailedConnection(task)
|
||||
throw error
|
||||
} catch {
|
||||
closeFailedConnection(task)
|
||||
throw WSClientError.transportError(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func sendAuthentication(token: String, using task: URLSessionWebSocketTask) async throws {
|
||||
let authMessage = WSMessage(type: "auth", token: token)
|
||||
let data = try JSONEncoder().encode(authMessage)
|
||||
|
||||
guard let string = String(data: data, encoding: .utf8) else {
|
||||
throw WSClientError.encodingError
|
||||
}
|
||||
|
||||
try await task.send(.string(string))
|
||||
}
|
||||
|
||||
private func receiveAuthenticationMessage(
|
||||
from task: URLSessionWebSocketTask
|
||||
) async throws -> WSMessage {
|
||||
let timeoutTask = Task { [weak self] in
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: 10_000_000_000)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
await self?.timeoutAuthentication(for: task)
|
||||
}
|
||||
defer { timeoutTask.cancel() }
|
||||
|
||||
do {
|
||||
let message = try await task.receive()
|
||||
return try decode(message)
|
||||
} catch {
|
||||
if authenticationTimedOutTask === task {
|
||||
authenticationTimedOutTask = nil
|
||||
throw WSClientError.authenticationTimedOut
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func timeoutAuthentication(for task: URLSessionWebSocketTask) {
|
||||
guard webSocketTask === task, !isAuthenticated else {
|
||||
return
|
||||
}
|
||||
|
||||
authenticationTimedOutTask = task
|
||||
task.cancel(with: .policyViolation, reason: nil)
|
||||
}
|
||||
|
||||
private func startReceiveLoop(for task: URLSessionWebSocketTask) {
|
||||
receiveTask?.cancel()
|
||||
receiveTask = Task { [weak self] in
|
||||
await self?.receiveMessages(from: task)
|
||||
}
|
||||
}
|
||||
|
||||
private func receiveMessages(from task: URLSessionWebSocketTask) async {
|
||||
do {
|
||||
while !Task.isCancelled, webSocketTask === task {
|
||||
let message = try await task.receive()
|
||||
incomingContinuation.yield(try decode(message))
|
||||
}
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
connectionDidFail(for: task)
|
||||
}
|
||||
}
|
||||
|
||||
private func startPingLoop(for task: URLSessionWebSocketTask) {
|
||||
pingTask?.cancel()
|
||||
pingTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: 30_000_000_000)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
await self?.sendPing(using: task)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sendPing(using task: URLSessionWebSocketTask) async {
|
||||
guard isAuthenticated, webSocketTask === task else {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try await task.send(.string("{\"type\":\"ping\"}"))
|
||||
} catch {
|
||||
connectionDidFail(for: task)
|
||||
}
|
||||
}
|
||||
|
||||
private func connectionDidFail(for task: URLSessionWebSocketTask) {
|
||||
guard webSocketTask === task, !isDisconnecting else {
|
||||
return
|
||||
}
|
||||
|
||||
isAuthenticated = false
|
||||
authenticationTimedOutTask = nil
|
||||
webSocketTask = nil
|
||||
receiveTask?.cancel()
|
||||
receiveTask = nil
|
||||
pingTask?.cancel()
|
||||
pingTask = nil
|
||||
task.cancel(with: .abnormalClosure, reason: nil)
|
||||
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
private func scheduleReconnect() {
|
||||
guard reconnectTask == nil, let token = authToken, !isDisconnecting else {
|
||||
return
|
||||
}
|
||||
|
||||
let id = UUID()
|
||||
reconnectID = id
|
||||
reconnectTask = Task { [weak self] in
|
||||
await self?.reconnect(using: token, id: id)
|
||||
}
|
||||
}
|
||||
|
||||
private func reconnect(using token: String, id: UUID) async {
|
||||
let delays: [UInt64] = [2, 4, 8, 16, 32]
|
||||
|
||||
for delay in delays {
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: delay * 1_000_000_000)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
guard reconnectID == id, !isDisconnecting else {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try await establishConnection(token: token)
|
||||
if reconnectID == id {
|
||||
reconnectID = nil
|
||||
reconnectTask = nil
|
||||
}
|
||||
return
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if reconnectID == id {
|
||||
reconnectID = nil
|
||||
reconnectTask = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func closeFailedConnection(_ task: URLSessionWebSocketTask) {
|
||||
guard webSocketTask === task else {
|
||||
return
|
||||
}
|
||||
|
||||
task.cancel(with: .policyViolation, reason: nil)
|
||||
webSocketTask = nil
|
||||
isAuthenticated = false
|
||||
authenticationTimedOutTask = nil
|
||||
}
|
||||
|
||||
private func decode(_ message: URLSessionWebSocketTask.Message) throws -> WSMessage {
|
||||
let data: Data
|
||||
|
||||
switch message {
|
||||
case .data(let messageData):
|
||||
data = messageData
|
||||
case .string(let string):
|
||||
data = Data(string.utf8)
|
||||
@unknown default:
|
||||
throw WSClientError.decodingError
|
||||
}
|
||||
|
||||
do {
|
||||
return try JSONDecoder().decode(WSMessage.self, from: data)
|
||||
} catch {
|
||||
throw WSClientError.decodingError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct WSMessage: Codable, Equatable {
|
||||
var type: String
|
||||
var id: String?
|
||||
var body: String?
|
||||
var to: String?
|
||||
var from: String?
|
||||
var fromName: String?
|
||||
var token: String?
|
||||
var peerID: String?
|
||||
var reason: String?
|
||||
var messageIDs: [String]? // used by read receipts: { type: "read", messageIDs: [...] }
|
||||
var attachment: WSAttachment? // file metadata attached to a chat.message
|
||||
|
||||
init(
|
||||
type: String,
|
||||
id: String? = nil,
|
||||
body: String? = nil,
|
||||
to: String? = nil,
|
||||
from: String? = nil,
|
||||
fromName: String? = nil,
|
||||
token: String? = nil,
|
||||
peerID: String? = nil,
|
||||
reason: String? = nil,
|
||||
messageIDs: [String]? = nil,
|
||||
attachment: WSAttachment? = nil
|
||||
) {
|
||||
self.type = type
|
||||
self.id = id
|
||||
self.body = body
|
||||
self.to = to
|
||||
self.from = from
|
||||
self.fromName = fromName
|
||||
self.token = token
|
||||
self.peerID = peerID
|
||||
self.reason = reason
|
||||
self.messageIDs = messageIDs
|
||||
self.attachment = attachment
|
||||
}
|
||||
}
|
||||
|
||||
struct WSAttachment: Codable, Equatable {
|
||||
let fileID: String
|
||||
let name: String
|
||||
let size: Int
|
||||
}
|
||||
|
||||
enum WSClientError: Error {
|
||||
case notConnected
|
||||
case authenticationTimedOut
|
||||
case authenticationFailed(String?)
|
||||
case transportError(Error)
|
||||
case encodingError
|
||||
case decodingError
|
||||
}
|
||||
@@ -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,284 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
enum AppState: Equatable {
|
||||
case unpaired
|
||||
case connecting
|
||||
case connected(peerID: String, peerName: String)
|
||||
case error(String)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class AppSession: ObservableObject {
|
||||
static let shared = AppSession()
|
||||
|
||||
@Published var state: AppState = .unpaired
|
||||
@Published var messages: [ChatMessage] = []
|
||||
|
||||
private let relayAPI = RelayAPI.shared
|
||||
private let webSocket = WSClient(url: URL(string: "wss://fchati.diyaa.de/ws")!)
|
||||
private let messageStore = MessageStore.shared
|
||||
private var incomingMessagesTask: Task<Void, Never>?
|
||||
|
||||
private init() {
|
||||
Task { [weak self] in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
|
||||
await NotificationManager.shared.requestPermission()
|
||||
await loadStoredMessages()
|
||||
await restoreSession()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
incomingMessagesTask?.cancel()
|
||||
}
|
||||
|
||||
func createPairingCode(displayName: String) async throws -> String {
|
||||
state = .connecting
|
||||
|
||||
do {
|
||||
let response = try await relayAPI.createPairing(displayName: displayName)
|
||||
KeychainStore.authToken = response.token
|
||||
KeychainStore.peerID = nil
|
||||
KeychainStore.peerName = nil
|
||||
|
||||
try await webSocket.connect(token: response.token)
|
||||
startIncomingMessages()
|
||||
return response.code
|
||||
} catch {
|
||||
state = .error(message(for: error))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func joinWithCode(_ code: String, displayName: String) async throws {
|
||||
state = .connecting
|
||||
|
||||
do {
|
||||
let response = try await relayAPI.joinPairing(code: code, displayName: displayName)
|
||||
KeychainStore.authToken = response.token
|
||||
KeychainStore.peerID = response.peer.id
|
||||
KeychainStore.peerName = response.peer.displayName
|
||||
|
||||
try await webSocket.connect(token: response.token)
|
||||
startIncomingMessages()
|
||||
state = .connected(peerID: response.peer.id, peerName: response.peer.displayName)
|
||||
} catch {
|
||||
state = .error(message(for: error))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func sendText(_ body: String) async throws {
|
||||
let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedBody.isEmpty else {
|
||||
throw AppSessionError.emptyMessage
|
||||
}
|
||||
|
||||
let message = outgoingMessage(body: trimmedBody, attachment: nil)
|
||||
try await sendAndStore(message)
|
||||
}
|
||||
|
||||
func sendFile(url: URL) async throws {
|
||||
guard let token = KeychainStore.authToken else {
|
||||
throw AppSessionError.notPaired
|
||||
}
|
||||
|
||||
let uploadedFile = try await relayAPI.uploadFile(url: url, token: token)
|
||||
let attachment = AttachmentInfo(
|
||||
fileID: uploadedFile.id,
|
||||
name: uploadedFile.name,
|
||||
size: uploadedFile.size
|
||||
)
|
||||
let message = outgoingMessage(
|
||||
body: "Shared a file: \(uploadedFile.name)",
|
||||
attachment: attachment
|
||||
)
|
||||
|
||||
try await sendAndStore(message)
|
||||
}
|
||||
|
||||
func unpair() {
|
||||
incomingMessagesTask?.cancel()
|
||||
incomingMessagesTask = nil
|
||||
KeychainStore.clearAll()
|
||||
let webSocket = webSocket
|
||||
Task {
|
||||
await webSocket.disconnect()
|
||||
}
|
||||
state = .unpaired
|
||||
}
|
||||
|
||||
private func restoreSession() async {
|
||||
guard
|
||||
let token = KeychainStore.authToken,
|
||||
let peerID = KeychainStore.peerID,
|
||||
let peerName = KeychainStore.peerName
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
state = .connecting
|
||||
|
||||
do {
|
||||
try await webSocket.connect(token: token)
|
||||
startIncomingMessages()
|
||||
state = .connected(peerID: peerID, peerName: peerName)
|
||||
} catch {
|
||||
state = .error(message(for: error))
|
||||
}
|
||||
}
|
||||
|
||||
private func loadStoredMessages() async {
|
||||
do {
|
||||
messages = try await messageStore.loadAll()
|
||||
} catch {
|
||||
state = .error("Unable to load saved messages.")
|
||||
}
|
||||
}
|
||||
|
||||
private func startIncomingMessages() {
|
||||
guard incomingMessagesTask == nil else {
|
||||
return
|
||||
}
|
||||
|
||||
let webSocket = webSocket
|
||||
incomingMessagesTask = Task { [weak self] in
|
||||
let stream = await webSocket.incoming
|
||||
|
||||
for await message in stream {
|
||||
guard !Task.isCancelled else {
|
||||
return
|
||||
}
|
||||
|
||||
await self?.handleIncomingMessage(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleIncomingMessage(_ message: WSMessage) async {
|
||||
switch message.type {
|
||||
case "chat.message":
|
||||
let attachment = message.attachment.map {
|
||||
AttachmentInfo(fileID: $0.fileID, name: $0.name, size: $0.size)
|
||||
}
|
||||
let chatMessage = ChatMessage(
|
||||
id: message.id ?? UUID().uuidString,
|
||||
from: message.from ?? "unknown",
|
||||
fromName: message.fromName ?? "Unknown",
|
||||
body: message.body ?? "",
|
||||
sentAt: Date(),
|
||||
attachment: attachment,
|
||||
isRead: false
|
||||
)
|
||||
|
||||
await storeIncomingMessage(chatMessage)
|
||||
NotificationManager.shared.notify(from: chatMessage.fromName, body: chatMessage.body)
|
||||
|
||||
case "read":
|
||||
for id in message.messageIDs ?? [] {
|
||||
markMessageAsRead(id: id)
|
||||
}
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func sendAndStore(_ message: ChatMessage) async throws {
|
||||
let attachment = message.attachment.map {
|
||||
WSAttachment(fileID: $0.fileID, name: $0.name, size: $0.size)
|
||||
}
|
||||
let webSocketMessage = WSMessage(
|
||||
type: "chat.message",
|
||||
id: message.id,
|
||||
body: message.body,
|
||||
attachment: attachment
|
||||
)
|
||||
|
||||
try await webSocket.send(webSocketMessage)
|
||||
await storeOutgoingMessage(message)
|
||||
}
|
||||
|
||||
private func outgoingMessage(body: String, attachment: AttachmentInfo?) -> ChatMessage {
|
||||
ChatMessage(
|
||||
id: UUID().uuidString,
|
||||
from: KeychainStore.installationID,
|
||||
fromName: "You",
|
||||
body: body,
|
||||
sentAt: Date(),
|
||||
attachment: attachment,
|
||||
isRead: true
|
||||
)
|
||||
}
|
||||
|
||||
private func storeIncomingMessage(_ message: ChatMessage) async {
|
||||
upsert(message)
|
||||
|
||||
do {
|
||||
try await messageStore.save(message)
|
||||
} catch {
|
||||
state = .error("Unable to save an incoming message.")
|
||||
}
|
||||
}
|
||||
|
||||
private func storeOutgoingMessage(_ message: ChatMessage) async {
|
||||
upsert(message)
|
||||
|
||||
do {
|
||||
try await messageStore.save(message)
|
||||
} catch {
|
||||
state = .error("Unable to save an outgoing message.")
|
||||
}
|
||||
}
|
||||
|
||||
private func markMessageAsRead(id: String) {
|
||||
guard let index = messages.firstIndex(where: { $0.id == id }) else {
|
||||
return
|
||||
}
|
||||
|
||||
messages[index].isRead = true
|
||||
|
||||
Task {
|
||||
do {
|
||||
try await messageStore.markRead(id: id)
|
||||
} catch {
|
||||
state = .error("Unable to update a message receipt.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func upsert(_ message: ChatMessage) {
|
||||
if let index = messages.firstIndex(where: { $0.id == message.id }) {
|
||||
messages[index] = message
|
||||
} else {
|
||||
messages.append(message)
|
||||
messages.sort { $0.sentAt < $1.sentAt }
|
||||
}
|
||||
}
|
||||
|
||||
private func message(for error: Error) -> String {
|
||||
if let error = error as? LocalizedError, let description = error.errorDescription {
|
||||
return description
|
||||
}
|
||||
|
||||
return String(describing: error)
|
||||
}
|
||||
}
|
||||
|
||||
enum AppSessionError: LocalizedError {
|
||||
case emptyMessage
|
||||
case notPaired
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .emptyMessage:
|
||||
"A message cannot be empty."
|
||||
case .notPaired:
|
||||
"Pair with someone before sending a file."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
enum KeychainStore {
|
||||
static var installationID: String {
|
||||
if let existingID = value(for: .installationID) {
|
||||
return existingID
|
||||
}
|
||||
|
||||
let newID = UUID().uuidString
|
||||
store(newID, for: .installationID)
|
||||
return newID
|
||||
}
|
||||
|
||||
static var authToken: String? {
|
||||
get { value(for: .authToken) }
|
||||
set { set(newValue, for: .authToken) }
|
||||
}
|
||||
|
||||
static var peerID: String? {
|
||||
get { value(for: .peerID) }
|
||||
set { set(newValue, for: .peerID) }
|
||||
}
|
||||
|
||||
static var peerName: String? {
|
||||
get { value(for: .peerName) }
|
||||
set { set(newValue, for: .peerName) }
|
||||
}
|
||||
|
||||
static func clearAll() {
|
||||
delete(.authToken)
|
||||
delete(.peerID)
|
||||
delete(.peerName)
|
||||
}
|
||||
|
||||
private static let service = "de.diyaa.fchati"
|
||||
|
||||
private enum Key: String {
|
||||
case installationID
|
||||
case authToken
|
||||
case peerID
|
||||
case peerName
|
||||
}
|
||||
|
||||
private static func set(_ value: String?, for key: Key) {
|
||||
guard let value else {
|
||||
delete(key)
|
||||
return
|
||||
}
|
||||
|
||||
store(value, for: key)
|
||||
}
|
||||
|
||||
private static func value(for key: Key) -> String? {
|
||||
var query = baseQuery(for: key)
|
||||
query[kSecMatchLimit] = kSecMatchLimitOne
|
||||
query[kSecReturnData] = true
|
||||
|
||||
var result: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
|
||||
guard status != errSecItemNotFound else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard status == errSecSuccess, let data = result as? Data else {
|
||||
reportFailure(operation: "read", status: status)
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let value = String(data: data, encoding: .utf8) else {
|
||||
assertionFailure("Keychain value is not valid UTF-8.")
|
||||
return nil
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
private static func store(_ value: String, for key: Key) {
|
||||
guard let data = value.data(using: .utf8) else {
|
||||
assertionFailure("Keychain value cannot be encoded as UTF-8.")
|
||||
return
|
||||
}
|
||||
|
||||
let query = baseQuery(for: key)
|
||||
let attributes = [kSecValueData: data] as CFDictionary
|
||||
let updateStatus = SecItemUpdate(query as CFDictionary, attributes)
|
||||
|
||||
if updateStatus == errSecItemNotFound {
|
||||
var addQuery = query
|
||||
addQuery[kSecValueData] = data
|
||||
addQuery[kSecAttrAccessible] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
|
||||
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
|
||||
guard addStatus == errSecSuccess else {
|
||||
reportFailure(operation: "add", status: addStatus)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard updateStatus == errSecSuccess else {
|
||||
reportFailure(operation: "update", status: updateStatus)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private static func delete(_ key: Key) {
|
||||
let status = SecItemDelete(baseQuery(for: key) as CFDictionary)
|
||||
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||||
reportFailure(operation: "delete", status: status)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private static func baseQuery(for key: Key) -> [CFString: Any] {
|
||||
[
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: service,
|
||||
kSecAttrAccount: key.rawValue,
|
||||
]
|
||||
}
|
||||
|
||||
private static func reportFailure(operation: String, status: OSStatus) {
|
||||
assertionFailure("Keychain \(operation) failed: \(status).")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import Foundation
|
||||
|
||||
struct ChatMessage: Codable, Identifiable, Equatable {
|
||||
let id: String
|
||||
let from: String
|
||||
let fromName: String
|
||||
let body: String
|
||||
let sentAt: Date
|
||||
let attachment: AttachmentInfo?
|
||||
var isRead: Bool
|
||||
}
|
||||
|
||||
struct AttachmentInfo: Codable, Equatable {
|
||||
let fileID: String
|
||||
let name: String
|
||||
let size: Int
|
||||
}
|
||||
|
||||
actor MessageStore {
|
||||
static let shared = MessageStore()
|
||||
|
||||
func save(_ message: ChatMessage) throws {
|
||||
var messages = try loadAll()
|
||||
|
||||
if let index = messages.firstIndex(where: { $0.id == message.id }) {
|
||||
messages[index] = message
|
||||
} else {
|
||||
messages.append(message)
|
||||
}
|
||||
|
||||
messages.sort { $0.sentAt < $1.sentAt }
|
||||
try write(messages)
|
||||
}
|
||||
|
||||
func loadAll() throws -> [ChatMessage] {
|
||||
let url = try messagesURL()
|
||||
|
||||
guard fileManager.fileExists(atPath: url.path) else {
|
||||
return []
|
||||
}
|
||||
|
||||
let data = try Data(contentsOf: url)
|
||||
return try decoder.decode([ChatMessage].self, from: data)
|
||||
.sorted { $0.sentAt < $1.sentAt }
|
||||
}
|
||||
|
||||
func markRead(id: String) throws {
|
||||
var messages = try loadAll()
|
||||
|
||||
guard let index = messages.firstIndex(where: { $0.id == id }) else {
|
||||
return
|
||||
}
|
||||
|
||||
messages[index].isRead = true
|
||||
try write(messages)
|
||||
}
|
||||
|
||||
var unreadCount: Int {
|
||||
get async {
|
||||
(try? loadAll().filter { !$0.isRead }.count) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
private let fileManager = FileManager.default
|
||||
private let encoder: JSONEncoder = {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
return encoder
|
||||
}()
|
||||
private let decoder: JSONDecoder = {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
return decoder
|
||||
}()
|
||||
|
||||
private func messagesURL() throws -> URL {
|
||||
let applicationSupportDirectory = try fileManager.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
let directory = applicationSupportDirectory.appendingPathComponent(
|
||||
"de.diyaa.fchati",
|
||||
isDirectory: true
|
||||
)
|
||||
|
||||
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
return directory.appendingPathComponent("messages.json")
|
||||
}
|
||||
|
||||
private func write(_ messages: [ChatMessage]) throws {
|
||||
let data = try encoder.encode(messages)
|
||||
try data.write(to: messagesURL(), options: .atomic)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import XCTest
|
||||
@testable import FchatiApp
|
||||
|
||||
final class ChatMessageBubbleTests: XCTestCase {
|
||||
func testMessageKeepsMarkdownBodyForRendering() {
|
||||
let message = ChatMessage(
|
||||
id: "message-id",
|
||||
from: "peer-id",
|
||||
fromName: "Alex",
|
||||
body: "Hello, **world**",
|
||||
sentAt: Date(),
|
||||
attachment: nil,
|
||||
isRead: false
|
||||
)
|
||||
|
||||
XCTAssertEqual(message.body, "Hello, **world**")
|
||||
XCTAssertNotNil(try? AttributedString(markdown: message.body))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import XCTest
|
||||
@testable import FchatiApp
|
||||
|
||||
final class MessageStoreModelTests: XCTestCase {
|
||||
func testMessageRoundTripPreservesAttachmentAndReadStatus() throws {
|
||||
let message = ChatMessage(
|
||||
id: "message-id",
|
||||
from: "peer-id",
|
||||
fromName: "Alex",
|
||||
body: "Shared a file: photo.jpg",
|
||||
sentAt: Date(timeIntervalSince1970: 1_721_995_200),
|
||||
attachment: AttachmentInfo(fileID: "file-id", name: "photo.jpg", size: 128),
|
||||
isRead: false
|
||||
)
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
let encodedMessage = try encoder.encode(message)
|
||||
let decodedMessage = try decoder.decode(ChatMessage.self, from: encodedMessage)
|
||||
|
||||
XCTAssertEqual(decodedMessage.id, message.id)
|
||||
XCTAssertEqual(decodedMessage.attachment, message.attachment)
|
||||
XCTAssertEqual(decodedMessage.isRead, message.isRead)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import XCTest
|
||||
@testable import FchatiApp
|
||||
|
||||
final class PairingViewModelTests: XCTestCase {
|
||||
func testPairingCodeIsUppercasedAndPrefixed() {
|
||||
XCTAssertEqual(PairingViewModel.normalizedCode(from: "ab3k7q"), "FCHT-AB3K7Q")
|
||||
XCTAssertEqual(PairingViewModel.normalizedCode(from: "fcht-ab3k7q"), "FCHT-AB3K7Q")
|
||||
XCTAssertEqual(PairingViewModel.normalizedCode(from: " FCHT-ab3k7q "), "FCHT-AB3K7Q")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import XCTest
|
||||
@testable import FchatiApp
|
||||
|
||||
final class RelayAPIResponseTests: XCTestCase {
|
||||
func testPairingCreateResponseDecodesServerPayload() throws {
|
||||
let data = Data(
|
||||
"""
|
||||
{"code":"FCHT-AB3K7Q","token":"creator-token","peerID":"creator-id","expiresAt":"2026-07-26T12:00:00Z"}
|
||||
""".utf8
|
||||
)
|
||||
|
||||
let response = try JSONDecoder().decode(PairingCreateResponse.self, from: data)
|
||||
|
||||
XCTAssertEqual(response.code, "FCHT-AB3K7Q")
|
||||
XCTAssertEqual(response.peerID, "creator-id")
|
||||
}
|
||||
|
||||
func testPairingJoinAndFileUploadResponsesDecodeServerPayloads() throws {
|
||||
let pairingData = Data(
|
||||
"""
|
||||
{"token":"joiner-token","peerID":"joiner-id","peer":{"id":"creator-id","displayName":"Alex"}}
|
||||
""".utf8
|
||||
)
|
||||
let fileData = Data(
|
||||
"""
|
||||
{"id":"file-id","name":"photo.jpg","size":128}
|
||||
""".utf8
|
||||
)
|
||||
|
||||
let pairingResponse = try JSONDecoder().decode(PairingJoinResponse.self, from: pairingData)
|
||||
let fileResponse = try JSONDecoder().decode(FileUploadResponse.self, from: fileData)
|
||||
|
||||
XCTAssertEqual(pairingResponse.peer, PeerInfo(id: "creator-id", displayName: "Alex"))
|
||||
XCTAssertEqual(fileResponse, FileUploadResponse(id: "file-id", name: "photo.jpg", size: 128))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import XCTest
|
||||
@testable import FchatiApp
|
||||
|
||||
final class WSMessageTests: XCTestCase {
|
||||
func testMessageRoundTripPreservesServerFields() throws {
|
||||
let message = WSMessage(
|
||||
type: "chat.message",
|
||||
id: "message-id",
|
||||
body: "Hello",
|
||||
from: "peer-id",
|
||||
fromName: "Alex"
|
||||
)
|
||||
|
||||
let decodedMessage = try JSONDecoder().decode(
|
||||
WSMessage.self,
|
||||
from: JSONEncoder().encode(message)
|
||||
)
|
||||
|
||||
XCTAssertEqual(decodedMessage, message)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user