Complete app features and relay cleanup
This commit is contained in:
parent
53fa92d222
commit
2bd9c1b3ae
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
122
FchatiApp/Sources/FchatiApp/Features/Chat/ChatView.swift
Normal file
122
FchatiApp/Sources/FchatiApp/Features/Chat/ChatView.swift
Normal file
@ -0,0 +1,122 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
struct ChatView: View {
|
||||
@ObservedObject private var session: AppSession
|
||||
@StateObject private var viewModel: ChatViewModel
|
||||
|
||||
init(session: AppSession) {
|
||||
self.session = session
|
||||
_viewModel = StateObject(wrappedValue: ChatViewModel(session: session))
|
||||
}
|
||||
|
||||
init() {
|
||||
self.init(session: AppSession.shared)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
ForEach(session.messages) { message in
|
||||
MessageBubble(
|
||||
message: message,
|
||||
isSent: message.fromName == "You"
|
||||
)
|
||||
.id(message.id)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.onAppear {
|
||||
scrollToLatest(using: proxy)
|
||||
}
|
||||
.onChange(of: session.messages.last?.id) { _, _ in
|
||||
scrollToLatest(using: proxy)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if let errorMessage = viewModel.errorMessage {
|
||||
Label(errorMessage, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
Button {
|
||||
chooseFile()
|
||||
} label: {
|
||||
Image(systemName: "paperclip")
|
||||
}
|
||||
.help("Choose a file")
|
||||
.disabled(viewModel.isSending || viewModel.isRecording)
|
||||
|
||||
TextField("Message", text: $viewModel.draft)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onSubmit {
|
||||
Task {
|
||||
await viewModel.sendText()
|
||||
}
|
||||
}
|
||||
|
||||
Image(systemName: viewModel.isRecording ? "mic.fill" : "mic")
|
||||
.foregroundStyle(viewModel.isRecording ? .red : .primary)
|
||||
.frame(width: 28, height: 28)
|
||||
.contentShape(Circle())
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { _ in
|
||||
viewModel.startRecording()
|
||||
}
|
||||
.onEnded { _ in
|
||||
viewModel.stopRecordingAndSend()
|
||||
}
|
||||
)
|
||||
.help("Hold to record audio")
|
||||
|
||||
Button("Send") {
|
||||
Task {
|
||||
await viewModel.sendText()
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(
|
||||
viewModel.isSending ||
|
||||
viewModel.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
private func chooseFile() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseDirectories = false
|
||||
panel.canChooseFiles = true
|
||||
panel.allowsMultipleSelection = false
|
||||
|
||||
guard panel.runModal() == .OK, let url = panel.url else {
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
await viewModel.sendFile(url: url)
|
||||
}
|
||||
}
|
||||
|
||||
private func scrollToLatest(using proxy: ScrollViewProxy) {
|
||||
guard let latestID = session.messages.last?.id else {
|
||||
return
|
||||
}
|
||||
|
||||
withAnimation {
|
||||
proxy.scrollTo(latestID, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
138
FchatiApp/Sources/FchatiApp/Features/Chat/ChatViewModel.swift
Normal file
138
FchatiApp/Sources/FchatiApp/Features/Chat/ChatViewModel.swift
Normal file
@ -0,0 +1,138 @@
|
||||
import AVFoundation
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class ChatViewModel: ObservableObject {
|
||||
@Published var draft = ""
|
||||
@Published private(set) var isSending = false
|
||||
@Published private(set) var isRecording = false
|
||||
@Published private(set) var errorMessage: String?
|
||||
|
||||
private let session: AppSession
|
||||
private var recorder: AVAudioRecorder?
|
||||
private var recordingURL: URL?
|
||||
|
||||
init(session: AppSession) {
|
||||
self.session = session
|
||||
}
|
||||
|
||||
func sendText() async {
|
||||
let messageBody = draft
|
||||
guard !messageBody.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
isSending = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
try await session.sendText(messageBody)
|
||||
draft = ""
|
||||
} catch {
|
||||
errorMessage = message(for: error)
|
||||
}
|
||||
|
||||
isSending = false
|
||||
}
|
||||
|
||||
func sendFile(url: URL, removeAfterSending: Bool = false) async {
|
||||
isSending = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
try await session.sendFile(url: url)
|
||||
|
||||
if removeAfterSending {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
} catch {
|
||||
errorMessage = message(for: error)
|
||||
}
|
||||
|
||||
isSending = false
|
||||
}
|
||||
|
||||
func startRecording() {
|
||||
guard !isRecording, !isSending else {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let url = try makeRecordingURL()
|
||||
let settings: [String: Any] = [
|
||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||
AVSampleRateKey: 44_100,
|
||||
AVNumberOfChannelsKey: 1,
|
||||
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue,
|
||||
]
|
||||
|
||||
let recorder = try AVAudioRecorder(url: url, settings: settings)
|
||||
recorder.prepareToRecord()
|
||||
|
||||
guard recorder.record() else {
|
||||
throw ChatViewModelError.recordingUnavailable
|
||||
}
|
||||
|
||||
self.recorder = recorder
|
||||
recordingURL = url
|
||||
isRecording = true
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
errorMessage = message(for: error)
|
||||
}
|
||||
}
|
||||
|
||||
func stopRecordingAndSend() {
|
||||
guard isRecording, let recorder, let recordingURL else {
|
||||
return
|
||||
}
|
||||
|
||||
recorder.stop()
|
||||
self.recorder = nil
|
||||
self.recordingURL = nil
|
||||
isRecording = false
|
||||
|
||||
Task {
|
||||
await sendFile(url: recordingURL, removeAfterSending: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeRecordingURL() throws -> URL {
|
||||
let cachesDirectory = try FileManager.default.url(
|
||||
for: .cachesDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
let recordingsDirectory = cachesDirectory.appendingPathComponent(
|
||||
"FchatiRecordings",
|
||||
isDirectory: true
|
||||
)
|
||||
|
||||
try FileManager.default.createDirectory(
|
||||
at: recordingsDirectory,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
|
||||
return recordingsDirectory
|
||||
.appendingPathComponent(UUID().uuidString)
|
||||
.appendingPathExtension("m4a")
|
||||
}
|
||||
|
||||
private func message(for error: Error) -> String {
|
||||
if let error = error as? LocalizedError, let description = error.errorDescription {
|
||||
return description
|
||||
}
|
||||
|
||||
return "Unable to send the message."
|
||||
}
|
||||
}
|
||||
|
||||
enum ChatViewModelError: LocalizedError {
|
||||
case recordingUnavailable
|
||||
|
||||
var errorDescription: String? {
|
||||
"Unable to start recording."
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
141
FchatiApp/Sources/FchatiApp/Features/Pairing/PairingView.swift
Normal file
141
FchatiApp/Sources/FchatiApp/Features/Pairing/PairingView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
102
FchatiApp/Sources/FchatiApp/Features/Settings/SettingsView.swift
Normal file
102
FchatiApp/Sources/FchatiApp/Features/Settings/SettingsView.swift
Normal file
@ -0,0 +1,102 @@
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
struct SettingsView: View {
|
||||
@ObservedObject private var session: AppSession
|
||||
@State private var displayName: String
|
||||
@State private var isEditingName = false
|
||||
|
||||
init(session: AppSession) {
|
||||
self.session = session
|
||||
_displayName = State(initialValue: KeychainStore.peerName ?? "")
|
||||
}
|
||||
|
||||
init() {
|
||||
self.init(session: AppSession.shared)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Profile") {
|
||||
LabeledContent("Display name") {
|
||||
if isEditingName {
|
||||
TextField("Display name", text: $displayName)
|
||||
.frame(maxWidth: 180)
|
||||
} else {
|
||||
Text(displayName.isEmpty ? "Not set" : displayName)
|
||||
.foregroundStyle(displayName.isEmpty ? .secondary : .primary)
|
||||
}
|
||||
}
|
||||
|
||||
Button(isEditingName ? "Save" : "Edit") {
|
||||
if isEditingName {
|
||||
saveDisplayName()
|
||||
}
|
||||
isEditingName.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
Section("Connection") {
|
||||
LabeledContent("Status") {
|
||||
Text(connectionStatus)
|
||||
.foregroundStyle(connectionColor)
|
||||
}
|
||||
|
||||
Button("Unpair", role: .destructive) {
|
||||
session.unpair()
|
||||
}
|
||||
.disabled(isUnpaired)
|
||||
}
|
||||
|
||||
Section("About") {
|
||||
LabeledContent("Version") {
|
||||
Text(appVersion)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.padding()
|
||||
}
|
||||
|
||||
private var connectionStatus: String {
|
||||
switch session.state {
|
||||
case .unpaired:
|
||||
"Not connected"
|
||||
case .connecting:
|
||||
"Connecting..."
|
||||
case .connected(_, let peerName):
|
||||
"Connected to \(peerName)"
|
||||
case .error(let message):
|
||||
message
|
||||
}
|
||||
}
|
||||
|
||||
private var connectionColor: Color {
|
||||
switch session.state {
|
||||
case .connected:
|
||||
.green
|
||||
case .error:
|
||||
.red
|
||||
case .unpaired, .connecting:
|
||||
.secondary
|
||||
}
|
||||
}
|
||||
|
||||
private var isUnpaired: Bool {
|
||||
if case .unpaired = session.state {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private var appVersion: String {
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
|
||||
}
|
||||
|
||||
private func saveDisplayName() {
|
||||
let trimmedName = displayName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
displayName = trimmedName
|
||||
KeychainStore.peerName = trimmedName.isEmpty ? nil : trimmedName
|
||||
}
|
||||
}
|
||||
164
FchatiApp/Sources/FchatiApp/Networking/RelayAPI.swift
Normal file
164
FchatiApp/Sources/FchatiApp/Networking/RelayAPI.swift
Normal file
@ -0,0 +1,164 @@
|
||||
import Foundation
|
||||
|
||||
actor RelayAPI {
|
||||
static let shared = RelayAPI()
|
||||
|
||||
func createPairing(displayName: String) async throws -> PairingCreateResponse {
|
||||
var request = URLRequest(url: endpoint(path: "pairing/create"))
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try JSONEncoder().encode(PairingRequest(displayName: displayName))
|
||||
|
||||
return try await executeJSON(request)
|
||||
}
|
||||
|
||||
func joinPairing(code: String, displayName: String) async throws -> PairingJoinResponse {
|
||||
var request = URLRequest(url: endpoint(path: "pairing/join"))
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try JSONEncoder().encode(
|
||||
PairingJoinRequest(code: code, displayName: displayName)
|
||||
)
|
||||
|
||||
return try await executeJSON(request)
|
||||
}
|
||||
|
||||
func uploadFile(url: URL, token: String) async throws -> FileUploadResponse {
|
||||
let boundary = "Boundary-\(UUID().uuidString)"
|
||||
let fileData: Data
|
||||
|
||||
do {
|
||||
fileData = try Data(contentsOf: url)
|
||||
} catch {
|
||||
throw RelayAPIError.networkError(error)
|
||||
}
|
||||
|
||||
var request = URLRequest(url: endpoint(path: "files"))
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue(
|
||||
"multipart/form-data; boundary=\(boundary)",
|
||||
forHTTPHeaderField: "Content-Type"
|
||||
)
|
||||
request.httpBody = multipartBody(
|
||||
fileData: fileData,
|
||||
fileName: safeFileName(from: url),
|
||||
boundary: boundary
|
||||
)
|
||||
|
||||
return try await executeJSON(request)
|
||||
}
|
||||
|
||||
func downloadFile(id: String, token: String) async throws -> Data {
|
||||
var request = URLRequest(url: endpoint(path: "files/\(id)"))
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
return try await execute(request)
|
||||
}
|
||||
|
||||
private let baseURL = URL(string: "https://fchati.diyaa.de")!
|
||||
private let session = URLSession.shared
|
||||
|
||||
private init() {}
|
||||
|
||||
private func endpoint(path: String) -> URL {
|
||||
baseURL.appendingPathComponent(path)
|
||||
}
|
||||
|
||||
private func executeJSON<Response: Decodable>(_ request: URLRequest) async throws -> Response {
|
||||
let data = try await execute(request)
|
||||
|
||||
do {
|
||||
return try JSONDecoder().decode(Response.self, from: data)
|
||||
} catch {
|
||||
throw RelayAPIError.decodingError(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func execute(_ request: URLRequest) async throws -> Data {
|
||||
do {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
|
||||
guard let response = response as? HTTPURLResponse else {
|
||||
throw RelayAPIError.networkError(URLError(.badServerResponse))
|
||||
}
|
||||
|
||||
guard (200 ... 299).contains(response.statusCode) else {
|
||||
throw RelayAPIError.serverError(response.statusCode)
|
||||
}
|
||||
|
||||
return data
|
||||
} catch let error as RelayAPIError {
|
||||
throw error
|
||||
} catch {
|
||||
throw RelayAPIError.networkError(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func multipartBody(fileData: Data, fileName: String, boundary: String) -> Data {
|
||||
var body = Data()
|
||||
let escapedFileName = fileName
|
||||
.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
.replacingOccurrences(of: "\"", with: "\\\"")
|
||||
.replacingOccurrences(of: "\r", with: "")
|
||||
.replacingOccurrences(of: "\n", with: "")
|
||||
|
||||
append("--\(boundary)\r\n", to: &body)
|
||||
append(
|
||||
"Content-Disposition: form-data; name=\"file\"; filename=\"\(escapedFileName)\"\r\n",
|
||||
to: &body
|
||||
)
|
||||
append("Content-Type: application/octet-stream\r\n\r\n", to: &body)
|
||||
body.append(fileData)
|
||||
append("\r\n--\(boundary)--\r\n", to: &body)
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
private func safeFileName(from url: URL) -> String {
|
||||
let name = url.lastPathComponent
|
||||
return name.isEmpty ? "upload" : name
|
||||
}
|
||||
|
||||
private func append(_ string: String, to data: inout Data) {
|
||||
data.append(Data(string.utf8))
|
||||
}
|
||||
}
|
||||
|
||||
struct PairingCreateResponse: Codable, Equatable {
|
||||
let code: String
|
||||
let token: String
|
||||
let peerID: String
|
||||
let expiresAt: String
|
||||
}
|
||||
|
||||
struct PairingJoinResponse: Codable, Equatable {
|
||||
let token: String
|
||||
let peerID: String
|
||||
let peer: PeerInfo
|
||||
}
|
||||
|
||||
struct PeerInfo: Codable, Equatable {
|
||||
let id: String
|
||||
let displayName: String
|
||||
}
|
||||
|
||||
struct FileUploadResponse: Codable, Equatable {
|
||||
let id: String
|
||||
let name: String
|
||||
let size: Int
|
||||
}
|
||||
|
||||
enum RelayAPIError: Error {
|
||||
case networkError(Error)
|
||||
case serverError(Int)
|
||||
case decodingError(Error)
|
||||
}
|
||||
|
||||
private struct PairingRequest: Encodable {
|
||||
let displayName: String
|
||||
}
|
||||
|
||||
private struct PairingJoinRequest: Encodable {
|
||||
let code: String
|
||||
let displayName: String
|
||||
}
|
||||
359
FchatiApp/Sources/FchatiApp/Networking/WSClient.swift
Normal file
359
FchatiApp/Sources/FchatiApp/Networking/WSClient.swift
Normal file
@ -0,0 +1,359 @@
|
||||
import Foundation
|
||||
|
||||
actor WSClient {
|
||||
private let url: URL
|
||||
private let session: URLSession
|
||||
private let incomingStream: AsyncStream<WSMessage>
|
||||
private let incomingContinuation: AsyncStream<WSMessage>.Continuation
|
||||
|
||||
private var webSocketTask: URLSessionWebSocketTask?
|
||||
private var receiveTask: Task<Void, Never>?
|
||||
private var pingTask: Task<Void, Never>?
|
||||
private var reconnectTask: Task<Void, Never>?
|
||||
private var reconnectID: UUID?
|
||||
private var authenticationTimedOutTask: URLSessionWebSocketTask?
|
||||
private var authToken: String?
|
||||
private var isAuthenticated = false
|
||||
private var isDisconnecting = false
|
||||
|
||||
init(url: URL) {
|
||||
self.url = url
|
||||
self.session = URLSession(configuration: .default)
|
||||
|
||||
let stream = AsyncStream<WSMessage>.makeStream()
|
||||
self.incomingStream = stream.stream
|
||||
self.incomingContinuation = stream.continuation
|
||||
}
|
||||
|
||||
deinit {
|
||||
webSocketTask?.cancel(with: .goingAway, reason: nil)
|
||||
receiveTask?.cancel()
|
||||
pingTask?.cancel()
|
||||
reconnectTask?.cancel()
|
||||
incomingContinuation.finish()
|
||||
}
|
||||
|
||||
var incoming: AsyncStream<WSMessage> {
|
||||
incomingStream
|
||||
}
|
||||
|
||||
func connect(token: String) async throws {
|
||||
disconnect()
|
||||
isDisconnecting = false
|
||||
authToken = token
|
||||
|
||||
try await establishConnection(token: token)
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
isDisconnecting = true
|
||||
authToken = nil
|
||||
isAuthenticated = false
|
||||
reconnectID = nil
|
||||
authenticationTimedOutTask = nil
|
||||
|
||||
receiveTask?.cancel()
|
||||
receiveTask = nil
|
||||
pingTask?.cancel()
|
||||
pingTask = nil
|
||||
reconnectTask?.cancel()
|
||||
reconnectTask = nil
|
||||
|
||||
webSocketTask?.cancel(with: .goingAway, reason: nil)
|
||||
webSocketTask = nil
|
||||
}
|
||||
|
||||
func send(_ message: WSMessage) async throws {
|
||||
guard isAuthenticated, let task = webSocketTask else {
|
||||
throw WSClientError.notConnected
|
||||
}
|
||||
|
||||
let encodedMessage: String
|
||||
do {
|
||||
let data = try JSONEncoder().encode(message)
|
||||
guard let string = String(data: data, encoding: .utf8) else {
|
||||
throw WSClientError.encodingError
|
||||
}
|
||||
encodedMessage = string
|
||||
} catch let error as WSClientError {
|
||||
throw error
|
||||
} catch {
|
||||
throw WSClientError.encodingError
|
||||
}
|
||||
|
||||
do {
|
||||
try await task.send(.string(encodedMessage))
|
||||
} catch {
|
||||
connectionDidFail(for: task)
|
||||
throw WSClientError.transportError(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func establishConnection(token: String) async throws {
|
||||
let task = session.webSocketTask(with: url)
|
||||
webSocketTask = task
|
||||
task.resume()
|
||||
|
||||
do {
|
||||
try await sendAuthentication(token: token, using: task)
|
||||
let authResponse = try await receiveAuthenticationMessage(from: task)
|
||||
|
||||
guard authResponse.type == "auth.ok" else {
|
||||
throw WSClientError.authenticationFailed(authResponse.reason)
|
||||
}
|
||||
|
||||
guard webSocketTask === task, !isDisconnecting else {
|
||||
throw WSClientError.notConnected
|
||||
}
|
||||
|
||||
isAuthenticated = true
|
||||
startReceiveLoop(for: task)
|
||||
startPingLoop(for: task)
|
||||
} catch let error as WSClientError {
|
||||
closeFailedConnection(task)
|
||||
throw error
|
||||
} catch {
|
||||
closeFailedConnection(task)
|
||||
throw WSClientError.transportError(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func sendAuthentication(token: String, using task: URLSessionWebSocketTask) async throws {
|
||||
let authMessage = WSMessage(type: "auth", token: token)
|
||||
let data = try JSONEncoder().encode(authMessage)
|
||||
|
||||
guard let string = String(data: data, encoding: .utf8) else {
|
||||
throw WSClientError.encodingError
|
||||
}
|
||||
|
||||
try await task.send(.string(string))
|
||||
}
|
||||
|
||||
private func receiveAuthenticationMessage(
|
||||
from task: URLSessionWebSocketTask
|
||||
) async throws -> WSMessage {
|
||||
let timeoutTask = Task { [weak self] in
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: 10_000_000_000)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
await self?.timeoutAuthentication(for: task)
|
||||
}
|
||||
defer { timeoutTask.cancel() }
|
||||
|
||||
do {
|
||||
let message = try await task.receive()
|
||||
return try decode(message)
|
||||
} catch {
|
||||
if authenticationTimedOutTask === task {
|
||||
authenticationTimedOutTask = nil
|
||||
throw WSClientError.authenticationTimedOut
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func timeoutAuthentication(for task: URLSessionWebSocketTask) {
|
||||
guard webSocketTask === task, !isAuthenticated else {
|
||||
return
|
||||
}
|
||||
|
||||
authenticationTimedOutTask = task
|
||||
task.cancel(with: .policyViolation, reason: nil)
|
||||
}
|
||||
|
||||
private func startReceiveLoop(for task: URLSessionWebSocketTask) {
|
||||
receiveTask?.cancel()
|
||||
receiveTask = Task { [weak self] in
|
||||
await self?.receiveMessages(from: task)
|
||||
}
|
||||
}
|
||||
|
||||
private func receiveMessages(from task: URLSessionWebSocketTask) async {
|
||||
do {
|
||||
while !Task.isCancelled, webSocketTask === task {
|
||||
let message = try await task.receive()
|
||||
incomingContinuation.yield(try decode(message))
|
||||
}
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
connectionDidFail(for: task)
|
||||
}
|
||||
}
|
||||
|
||||
private func startPingLoop(for task: URLSessionWebSocketTask) {
|
||||
pingTask?.cancel()
|
||||
pingTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: 30_000_000_000)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
await self?.sendPing(using: task)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sendPing(using task: URLSessionWebSocketTask) async {
|
||||
guard isAuthenticated, webSocketTask === task else {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try await task.send(.string("{\"type\":\"ping\"}"))
|
||||
} catch {
|
||||
connectionDidFail(for: task)
|
||||
}
|
||||
}
|
||||
|
||||
private func connectionDidFail(for task: URLSessionWebSocketTask) {
|
||||
guard webSocketTask === task, !isDisconnecting else {
|
||||
return
|
||||
}
|
||||
|
||||
isAuthenticated = false
|
||||
authenticationTimedOutTask = nil
|
||||
webSocketTask = nil
|
||||
receiveTask?.cancel()
|
||||
receiveTask = nil
|
||||
pingTask?.cancel()
|
||||
pingTask = nil
|
||||
task.cancel(with: .abnormalClosure, reason: nil)
|
||||
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
private func scheduleReconnect() {
|
||||
guard reconnectTask == nil, let token = authToken, !isDisconnecting else {
|
||||
return
|
||||
}
|
||||
|
||||
let id = UUID()
|
||||
reconnectID = id
|
||||
reconnectTask = Task { [weak self] in
|
||||
await self?.reconnect(using: token, id: id)
|
||||
}
|
||||
}
|
||||
|
||||
private func reconnect(using token: String, id: UUID) async {
|
||||
let delays: [UInt64] = [2, 4, 8, 16, 32]
|
||||
|
||||
for delay in delays {
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: delay * 1_000_000_000)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
guard reconnectID == id, !isDisconnecting else {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try await establishConnection(token: token)
|
||||
if reconnectID == id {
|
||||
reconnectID = nil
|
||||
reconnectTask = nil
|
||||
}
|
||||
return
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if reconnectID == id {
|
||||
reconnectID = nil
|
||||
reconnectTask = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func closeFailedConnection(_ task: URLSessionWebSocketTask) {
|
||||
guard webSocketTask === task else {
|
||||
return
|
||||
}
|
||||
|
||||
task.cancel(with: .policyViolation, reason: nil)
|
||||
webSocketTask = nil
|
||||
isAuthenticated = false
|
||||
authenticationTimedOutTask = nil
|
||||
}
|
||||
|
||||
private func decode(_ message: URLSessionWebSocketTask.Message) throws -> WSMessage {
|
||||
let data: Data
|
||||
|
||||
switch message {
|
||||
case .data(let messageData):
|
||||
data = messageData
|
||||
case .string(let string):
|
||||
data = Data(string.utf8)
|
||||
@unknown default:
|
||||
throw WSClientError.decodingError
|
||||
}
|
||||
|
||||
do {
|
||||
return try JSONDecoder().decode(WSMessage.self, from: data)
|
||||
} catch {
|
||||
throw WSClientError.decodingError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct WSMessage: Codable, Equatable {
|
||||
var type: String
|
||||
var id: String?
|
||||
var body: String?
|
||||
var to: String?
|
||||
var from: String?
|
||||
var fromName: String?
|
||||
var token: String?
|
||||
var peerID: String?
|
||||
var reason: String?
|
||||
var messageIDs: [String]? // used by read receipts: { type: "read", messageIDs: [...] }
|
||||
var attachment: WSAttachment? // file metadata attached to a chat.message
|
||||
|
||||
init(
|
||||
type: String,
|
||||
id: String? = nil,
|
||||
body: String? = nil,
|
||||
to: String? = nil,
|
||||
from: String? = nil,
|
||||
fromName: String? = nil,
|
||||
token: String? = nil,
|
||||
peerID: String? = nil,
|
||||
reason: String? = nil,
|
||||
messageIDs: [String]? = nil,
|
||||
attachment: WSAttachment? = nil
|
||||
) {
|
||||
self.type = type
|
||||
self.id = id
|
||||
self.body = body
|
||||
self.to = to
|
||||
self.from = from
|
||||
self.fromName = fromName
|
||||
self.token = token
|
||||
self.peerID = peerID
|
||||
self.reason = reason
|
||||
self.messageIDs = messageIDs
|
||||
self.attachment = attachment
|
||||
}
|
||||
}
|
||||
|
||||
struct WSAttachment: Codable, Equatable {
|
||||
let fileID: String
|
||||
let name: String
|
||||
let size: Int
|
||||
}
|
||||
|
||||
enum WSClientError: Error {
|
||||
case notConnected
|
||||
case authenticationTimedOut
|
||||
case authenticationFailed(String?)
|
||||
case transportError(Error)
|
||||
case encodingError
|
||||
case decodingError
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
284
FchatiApp/Sources/FchatiApp/Session/AppSession.swift
Normal file
284
FchatiApp/Sources/FchatiApp/Session/AppSession.swift
Normal file
@ -0,0 +1,284 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
enum AppState: Equatable {
|
||||
case unpaired
|
||||
case connecting
|
||||
case connected(peerID: String, peerName: String)
|
||||
case error(String)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class AppSession: ObservableObject {
|
||||
static let shared = AppSession()
|
||||
|
||||
@Published var state: AppState = .unpaired
|
||||
@Published var messages: [ChatMessage] = []
|
||||
|
||||
private let relayAPI = RelayAPI.shared
|
||||
private let webSocket = WSClient(url: URL(string: "wss://fchati.diyaa.de/ws")!)
|
||||
private let messageStore = MessageStore.shared
|
||||
private var incomingMessagesTask: Task<Void, Never>?
|
||||
|
||||
private init() {
|
||||
Task { [weak self] in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
|
||||
await NotificationManager.shared.requestPermission()
|
||||
await loadStoredMessages()
|
||||
await restoreSession()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
incomingMessagesTask?.cancel()
|
||||
}
|
||||
|
||||
func createPairingCode(displayName: String) async throws -> String {
|
||||
state = .connecting
|
||||
|
||||
do {
|
||||
let response = try await relayAPI.createPairing(displayName: displayName)
|
||||
KeychainStore.authToken = response.token
|
||||
KeychainStore.peerID = nil
|
||||
KeychainStore.peerName = nil
|
||||
|
||||
try await webSocket.connect(token: response.token)
|
||||
startIncomingMessages()
|
||||
return response.code
|
||||
} catch {
|
||||
state = .error(message(for: error))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func joinWithCode(_ code: String, displayName: String) async throws {
|
||||
state = .connecting
|
||||
|
||||
do {
|
||||
let response = try await relayAPI.joinPairing(code: code, displayName: displayName)
|
||||
KeychainStore.authToken = response.token
|
||||
KeychainStore.peerID = response.peer.id
|
||||
KeychainStore.peerName = response.peer.displayName
|
||||
|
||||
try await webSocket.connect(token: response.token)
|
||||
startIncomingMessages()
|
||||
state = .connected(peerID: response.peer.id, peerName: response.peer.displayName)
|
||||
} catch {
|
||||
state = .error(message(for: error))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func sendText(_ body: String) async throws {
|
||||
let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedBody.isEmpty else {
|
||||
throw AppSessionError.emptyMessage
|
||||
}
|
||||
|
||||
let message = outgoingMessage(body: trimmedBody, attachment: nil)
|
||||
try await sendAndStore(message)
|
||||
}
|
||||
|
||||
func sendFile(url: URL) async throws {
|
||||
guard let token = KeychainStore.authToken else {
|
||||
throw AppSessionError.notPaired
|
||||
}
|
||||
|
||||
let uploadedFile = try await relayAPI.uploadFile(url: url, token: token)
|
||||
let attachment = AttachmentInfo(
|
||||
fileID: uploadedFile.id,
|
||||
name: uploadedFile.name,
|
||||
size: uploadedFile.size
|
||||
)
|
||||
let message = outgoingMessage(
|
||||
body: "Shared a file: \(uploadedFile.name)",
|
||||
attachment: attachment
|
||||
)
|
||||
|
||||
try await sendAndStore(message)
|
||||
}
|
||||
|
||||
func unpair() {
|
||||
incomingMessagesTask?.cancel()
|
||||
incomingMessagesTask = nil
|
||||
KeychainStore.clearAll()
|
||||
let webSocket = webSocket
|
||||
Task {
|
||||
await webSocket.disconnect()
|
||||
}
|
||||
state = .unpaired
|
||||
}
|
||||
|
||||
private func restoreSession() async {
|
||||
guard
|
||||
let token = KeychainStore.authToken,
|
||||
let peerID = KeychainStore.peerID,
|
||||
let peerName = KeychainStore.peerName
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
state = .connecting
|
||||
|
||||
do {
|
||||
try await webSocket.connect(token: token)
|
||||
startIncomingMessages()
|
||||
state = .connected(peerID: peerID, peerName: peerName)
|
||||
} catch {
|
||||
state = .error(message(for: error))
|
||||
}
|
||||
}
|
||||
|
||||
private func loadStoredMessages() async {
|
||||
do {
|
||||
messages = try await messageStore.loadAll()
|
||||
} catch {
|
||||
state = .error("Unable to load saved messages.")
|
||||
}
|
||||
}
|
||||
|
||||
private func startIncomingMessages() {
|
||||
guard incomingMessagesTask == nil else {
|
||||
return
|
||||
}
|
||||
|
||||
let webSocket = webSocket
|
||||
incomingMessagesTask = Task { [weak self] in
|
||||
let stream = await webSocket.incoming
|
||||
|
||||
for await message in stream {
|
||||
guard !Task.isCancelled else {
|
||||
return
|
||||
}
|
||||
|
||||
await self?.handleIncomingMessage(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleIncomingMessage(_ message: WSMessage) async {
|
||||
switch message.type {
|
||||
case "chat.message":
|
||||
let attachment = message.attachment.map {
|
||||
AttachmentInfo(fileID: $0.fileID, name: $0.name, size: $0.size)
|
||||
}
|
||||
let chatMessage = ChatMessage(
|
||||
id: message.id ?? UUID().uuidString,
|
||||
from: message.from ?? "unknown",
|
||||
fromName: message.fromName ?? "Unknown",
|
||||
body: message.body ?? "",
|
||||
sentAt: Date(),
|
||||
attachment: attachment,
|
||||
isRead: false
|
||||
)
|
||||
|
||||
await storeIncomingMessage(chatMessage)
|
||||
NotificationManager.shared.notify(from: chatMessage.fromName, body: chatMessage.body)
|
||||
|
||||
case "read":
|
||||
for id in message.messageIDs ?? [] {
|
||||
markMessageAsRead(id: id)
|
||||
}
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func sendAndStore(_ message: ChatMessage) async throws {
|
||||
let attachment = message.attachment.map {
|
||||
WSAttachment(fileID: $0.fileID, name: $0.name, size: $0.size)
|
||||
}
|
||||
let webSocketMessage = WSMessage(
|
||||
type: "chat.message",
|
||||
id: message.id,
|
||||
body: message.body,
|
||||
attachment: attachment
|
||||
)
|
||||
|
||||
try await webSocket.send(webSocketMessage)
|
||||
await storeOutgoingMessage(message)
|
||||
}
|
||||
|
||||
private func outgoingMessage(body: String, attachment: AttachmentInfo?) -> ChatMessage {
|
||||
ChatMessage(
|
||||
id: UUID().uuidString,
|
||||
from: KeychainStore.installationID,
|
||||
fromName: "You",
|
||||
body: body,
|
||||
sentAt: Date(),
|
||||
attachment: attachment,
|
||||
isRead: true
|
||||
)
|
||||
}
|
||||
|
||||
private func storeIncomingMessage(_ message: ChatMessage) async {
|
||||
upsert(message)
|
||||
|
||||
do {
|
||||
try await messageStore.save(message)
|
||||
} catch {
|
||||
state = .error("Unable to save an incoming message.")
|
||||
}
|
||||
}
|
||||
|
||||
private func storeOutgoingMessage(_ message: ChatMessage) async {
|
||||
upsert(message)
|
||||
|
||||
do {
|
||||
try await messageStore.save(message)
|
||||
} catch {
|
||||
state = .error("Unable to save an outgoing message.")
|
||||
}
|
||||
}
|
||||
|
||||
private func markMessageAsRead(id: String) {
|
||||
guard let index = messages.firstIndex(where: { $0.id == id }) else {
|
||||
return
|
||||
}
|
||||
|
||||
messages[index].isRead = true
|
||||
|
||||
Task {
|
||||
do {
|
||||
try await messageStore.markRead(id: id)
|
||||
} catch {
|
||||
state = .error("Unable to update a message receipt.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func upsert(_ message: ChatMessage) {
|
||||
if let index = messages.firstIndex(where: { $0.id == message.id }) {
|
||||
messages[index] = message
|
||||
} else {
|
||||
messages.append(message)
|
||||
messages.sort { $0.sentAt < $1.sentAt }
|
||||
}
|
||||
}
|
||||
|
||||
private func message(for error: Error) -> String {
|
||||
if let error = error as? LocalizedError, let description = error.errorDescription {
|
||||
return description
|
||||
}
|
||||
|
||||
return String(describing: error)
|
||||
}
|
||||
}
|
||||
|
||||
enum AppSessionError: LocalizedError {
|
||||
case emptyMessage
|
||||
case notPaired
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .emptyMessage:
|
||||
"A message cannot be empty."
|
||||
case .notPaired:
|
||||
"Pair with someone before sending a file."
|
||||
}
|
||||
}
|
||||
}
|
||||
127
FchatiApp/Sources/FchatiApp/Storage/KeychainStore.swift
Normal file
127
FchatiApp/Sources/FchatiApp/Storage/KeychainStore.swift
Normal 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).")
|
||||
}
|
||||
}
|
||||
97
FchatiApp/Sources/FchatiApp/Storage/MessageStore.swift
Normal file
97
FchatiApp/Sources/FchatiApp/Storage/MessageStore.swift
Normal file
@ -0,0 +1,97 @@
|
||||
import Foundation
|
||||
|
||||
struct ChatMessage: Codable, Identifiable, Equatable {
|
||||
let id: String
|
||||
let from: String
|
||||
let fromName: String
|
||||
let body: String
|
||||
let sentAt: Date
|
||||
let attachment: AttachmentInfo?
|
||||
var isRead: Bool
|
||||
}
|
||||
|
||||
struct AttachmentInfo: Codable, Equatable {
|
||||
let fileID: String
|
||||
let name: String
|
||||
let size: Int
|
||||
}
|
||||
|
||||
actor MessageStore {
|
||||
static let shared = MessageStore()
|
||||
|
||||
func save(_ message: ChatMessage) throws {
|
||||
var messages = try loadAll()
|
||||
|
||||
if let index = messages.firstIndex(where: { $0.id == message.id }) {
|
||||
messages[index] = message
|
||||
} else {
|
||||
messages.append(message)
|
||||
}
|
||||
|
||||
messages.sort { $0.sentAt < $1.sentAt }
|
||||
try write(messages)
|
||||
}
|
||||
|
||||
func loadAll() throws -> [ChatMessage] {
|
||||
let url = try messagesURL()
|
||||
|
||||
guard fileManager.fileExists(atPath: url.path) else {
|
||||
return []
|
||||
}
|
||||
|
||||
let data = try Data(contentsOf: url)
|
||||
return try decoder.decode([ChatMessage].self, from: data)
|
||||
.sorted { $0.sentAt < $1.sentAt }
|
||||
}
|
||||
|
||||
func markRead(id: String) throws {
|
||||
var messages = try loadAll()
|
||||
|
||||
guard let index = messages.firstIndex(where: { $0.id == id }) else {
|
||||
return
|
||||
}
|
||||
|
||||
messages[index].isRead = true
|
||||
try write(messages)
|
||||
}
|
||||
|
||||
var unreadCount: Int {
|
||||
get async {
|
||||
(try? loadAll().filter { !$0.isRead }.count) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
private let fileManager = FileManager.default
|
||||
private let encoder: JSONEncoder = {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
return encoder
|
||||
}()
|
||||
private let decoder: JSONDecoder = {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
return decoder
|
||||
}()
|
||||
|
||||
private func messagesURL() throws -> URL {
|
||||
let applicationSupportDirectory = try fileManager.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
let directory = applicationSupportDirectory.appendingPathComponent(
|
||||
"de.diyaa.fchati",
|
||||
isDirectory: true
|
||||
)
|
||||
|
||||
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
return directory.appendingPathComponent("messages.json")
|
||||
}
|
||||
|
||||
private func write(_ messages: [ChatMessage]) throws {
|
||||
let data = try encoder.encode(messages)
|
||||
try data.write(to: messagesURL(), options: .atomic)
|
||||
}
|
||||
}
|
||||
19
FchatiApp/Tests/FchatiAppTests/ChatMessageBubbleTests.swift
Normal file
19
FchatiApp/Tests/FchatiAppTests/ChatMessageBubbleTests.swift
Normal file
@ -0,0 +1,19 @@
|
||||
import XCTest
|
||||
@testable import FchatiApp
|
||||
|
||||
final class ChatMessageBubbleTests: XCTestCase {
|
||||
func testMessageKeepsMarkdownBodyForRendering() {
|
||||
let message = ChatMessage(
|
||||
id: "message-id",
|
||||
from: "peer-id",
|
||||
fromName: "Alex",
|
||||
body: "Hello, **world**",
|
||||
sentAt: Date(),
|
||||
attachment: nil,
|
||||
isRead: false
|
||||
)
|
||||
|
||||
XCTAssertEqual(message.body, "Hello, **world**")
|
||||
XCTAssertNotNil(try? AttributedString(markdown: message.body))
|
||||
}
|
||||
}
|
||||
27
FchatiApp/Tests/FchatiAppTests/MessageStoreModelTests.swift
Normal file
27
FchatiApp/Tests/FchatiAppTests/MessageStoreModelTests.swift
Normal file
@ -0,0 +1,27 @@
|
||||
import XCTest
|
||||
@testable import FchatiApp
|
||||
|
||||
final class MessageStoreModelTests: XCTestCase {
|
||||
func testMessageRoundTripPreservesAttachmentAndReadStatus() throws {
|
||||
let message = ChatMessage(
|
||||
id: "message-id",
|
||||
from: "peer-id",
|
||||
fromName: "Alex",
|
||||
body: "Shared a file: photo.jpg",
|
||||
sentAt: Date(timeIntervalSince1970: 1_721_995_200),
|
||||
attachment: AttachmentInfo(fileID: "file-id", name: "photo.jpg", size: 128),
|
||||
isRead: false
|
||||
)
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
let encodedMessage = try encoder.encode(message)
|
||||
let decodedMessage = try decoder.decode(ChatMessage.self, from: encodedMessage)
|
||||
|
||||
XCTAssertEqual(decodedMessage.id, message.id)
|
||||
XCTAssertEqual(decodedMessage.attachment, message.attachment)
|
||||
XCTAssertEqual(decodedMessage.isRead, message.isRead)
|
||||
}
|
||||
}
|
||||
10
FchatiApp/Tests/FchatiAppTests/PairingViewModelTests.swift
Normal file
10
FchatiApp/Tests/FchatiAppTests/PairingViewModelTests.swift
Normal file
@ -0,0 +1,10 @@
|
||||
import XCTest
|
||||
@testable import FchatiApp
|
||||
|
||||
final class PairingViewModelTests: XCTestCase {
|
||||
func testPairingCodeIsUppercasedAndPrefixed() {
|
||||
XCTAssertEqual(PairingViewModel.normalizedCode(from: "ab3k7q"), "FCHT-AB3K7Q")
|
||||
XCTAssertEqual(PairingViewModel.normalizedCode(from: "fcht-ab3k7q"), "FCHT-AB3K7Q")
|
||||
XCTAssertEqual(PairingViewModel.normalizedCode(from: " FCHT-ab3k7q "), "FCHT-AB3K7Q")
|
||||
}
|
||||
}
|
||||
36
FchatiApp/Tests/FchatiAppTests/RelayAPIResponseTests.swift
Normal file
36
FchatiApp/Tests/FchatiAppTests/RelayAPIResponseTests.swift
Normal file
@ -0,0 +1,36 @@
|
||||
import XCTest
|
||||
@testable import FchatiApp
|
||||
|
||||
final class RelayAPIResponseTests: XCTestCase {
|
||||
func testPairingCreateResponseDecodesServerPayload() throws {
|
||||
let data = Data(
|
||||
"""
|
||||
{"code":"FCHT-AB3K7Q","token":"creator-token","peerID":"creator-id","expiresAt":"2026-07-26T12:00:00Z"}
|
||||
""".utf8
|
||||
)
|
||||
|
||||
let response = try JSONDecoder().decode(PairingCreateResponse.self, from: data)
|
||||
|
||||
XCTAssertEqual(response.code, "FCHT-AB3K7Q")
|
||||
XCTAssertEqual(response.peerID, "creator-id")
|
||||
}
|
||||
|
||||
func testPairingJoinAndFileUploadResponsesDecodeServerPayloads() throws {
|
||||
let pairingData = Data(
|
||||
"""
|
||||
{"token":"joiner-token","peerID":"joiner-id","peer":{"id":"creator-id","displayName":"Alex"}}
|
||||
""".utf8
|
||||
)
|
||||
let fileData = Data(
|
||||
"""
|
||||
{"id":"file-id","name":"photo.jpg","size":128}
|
||||
""".utf8
|
||||
)
|
||||
|
||||
let pairingResponse = try JSONDecoder().decode(PairingJoinResponse.self, from: pairingData)
|
||||
let fileResponse = try JSONDecoder().decode(FileUploadResponse.self, from: fileData)
|
||||
|
||||
XCTAssertEqual(pairingResponse.peer, PeerInfo(id: "creator-id", displayName: "Alex"))
|
||||
XCTAssertEqual(fileResponse, FileUploadResponse(id: "file-id", name: "photo.jpg", size: 128))
|
||||
}
|
||||
}
|
||||
21
FchatiApp/Tests/FchatiAppTests/WSMessageTests.swift
Normal file
21
FchatiApp/Tests/FchatiAppTests/WSMessageTests.swift
Normal file
@ -0,0 +1,21 @@
|
||||
import XCTest
|
||||
@testable import FchatiApp
|
||||
|
||||
final class WSMessageTests: XCTestCase {
|
||||
func testMessageRoundTripPreservesServerFields() throws {
|
||||
let message = WSMessage(
|
||||
type: "chat.message",
|
||||
id: "message-id",
|
||||
body: "Hello",
|
||||
from: "peer-id",
|
||||
fromName: "Alex"
|
||||
)
|
||||
|
||||
let decodedMessage = try JSONDecoder().decode(
|
||||
WSMessage.self,
|
||||
from: JSONEncoder().encode(message)
|
||||
)
|
||||
|
||||
XCTAssertEqual(decodedMessage, message)
|
||||
}
|
||||
}
|
||||
@ -1,50 +1,194 @@
|
||||
# حالة التنفيذ
|
||||
# Implementation Status
|
||||
|
||||
## آخر تحديث
|
||||
## TASK-01 — Swift Package + App Shell ✅
|
||||
|
||||
تم إنشاء الهيكل الأولي لتطبيق ماك، مع سكربت محلي لاختباره وبنائه وتثبيته وفتحه.
|
||||
**Completed by:** Codex agent
|
||||
**Fixes applied by:** Claude (LSUIElement, entitlements in codesign)
|
||||
|
||||
## ما تم إنجازه
|
||||
### What was built
|
||||
|
||||
- إنشاء حزمة سويفت لتطبيق ماك يدعم نظام ماك 14 فما فوق.
|
||||
- إنشاء تطبيق شريط القوائم مع واجهة بعرض 360 نقطة وارتفاع أدنى 480 نقطة.
|
||||
- إضافة تبويبات للدردشة والاقتران والإعدادات، مع حالات بداية مؤقتة لكل تبويب.
|
||||
- إضافة صلاحيات الشبكة وصندوق الحماية للتطبيق.
|
||||
- إضافة اختبار يتحقق من وجود التبويبات الثلاثة وأيقوناتها.
|
||||
- إضافة ملف معلومات لتجميع الملف التنفيذي داخل حزمة تطبيق قابلة للتشغيل.
|
||||
- إضافة سكربت للتنظيف والاختبار والبناء والتثبيت والفتح.
|
||||
- `FchatiApp/Package.swift` — SPM package, macOS 14+, no Xcode
|
||||
- `FchatiApp/Sources/FchatiApp/FchatiApp.swift` — MenuBarExtra (.window style), 360pt wide, 480pt min height, three tabs (Chat / Pairing / Settings) with segmented picker
|
||||
- `FchatiApp/FchatiApp.entitlements` — sandbox + outgoing/incoming network
|
||||
- `FchatiApp/Info.plist` — bundle ID `de.diyaa.fchati`, version 0.1.0, `LSUIElement = YES` (hides Dock icon)
|
||||
- `FchatiApp/Tests/FchatiAppTests/AppTabTests.swift` — verifies all three tabs and their icons exist
|
||||
- `scripts/run-macos-app.sh` — clean → test → release build → bundle → codesign → install to /Applications → open
|
||||
|
||||
## السكربت المحلي
|
||||
### Verified
|
||||
|
||||
المسار:
|
||||
- `swift build` passes (debug)
|
||||
- `swift test` passes (1 test)
|
||||
- Script installs and opens `/Applications/Fchati.app` successfully
|
||||
|
||||
`scripts/run-macos-app.sh`
|
||||
### Fixes applied after agent review
|
||||
|
||||
ينفذ السكربت الخطوات التالية بالترتيب:
|
||||
- Added `LSUIElement = YES` to `Info.plist` (was missing — app was appearing in Dock)
|
||||
- Added `--entitlements` flag to `codesign` in the build script (sandbox permissions were not being applied)
|
||||
|
||||
1. يحذف نواتج البناء الحالية الخاصة بتطبيق ماك فقط.
|
||||
2. يشغّل الاختبارات.
|
||||
3. يبني نسخة إصدار من التطبيق.
|
||||
4. ينشئ حزمة تطبيق ماك مؤقتة ويوقعها محليًا.
|
||||
5. يحذف النسخة الموجودة من التطبيق في مجلد التطبيقات، إن وجدت.
|
||||
6. ينقل النسخة الجديدة إلى مجلد التطبيقات.
|
||||
7. يفتح التطبيق الجديد.
|
||||
---
|
||||
|
||||
مسار التثبيت الناتج:
|
||||
## TASK-02 — KeychainStore ✅
|
||||
|
||||
`/Applications/Fchati.app`
|
||||
**Completed by:** Codex agent
|
||||
|
||||
## التحقق المنفذ
|
||||
### What was built
|
||||
|
||||
- نجح اختبار الحزمة، مع اختبار واحد ناجح ودون إخفاقات.
|
||||
- تم تنفيذ سكربت التثبيت كاملًا بنجاح، بما في ذلك البناء والتثبيت والفتح.
|
||||
- تمت إضافة اختبار للحفاظ على عقد واجهة البداية.
|
||||
- لم يتم تعديل ملف المهام الموجود مسبقًا.
|
||||
- `FchatiApp/Sources/FchatiApp/Storage/KeychainStore.swift` — direct Security.framework wrapper for the installation ID, auth token, peer ID, and peer name.
|
||||
- The installation ID is created once as a UUID and persisted in Keychain.
|
||||
- Token and peer properties support read, update, and deletion through Swift property syntax.
|
||||
- `clearAll()` removes pairing credentials while preserving the installation ID.
|
||||
|
||||
## الحالة الحالية
|
||||
### Verified
|
||||
|
||||
واجهة البداية وحزمة التطبيق جاهزتان للتشغيل المحلي.
|
||||
- `swift build` passes from `FchatiApp/`.
|
||||
|
||||
لا تزال ميزات التخزين الآمن والاقتران والاتصال بالخادم والدردشة الفعلية غير منفذة.
|
||||
## TASK-03 — RelayAPI (HTTP) ✅
|
||||
|
||||
المهمة التالية المقترحة هي إضافة التخزين الآمن للهوية والرموز، ثم ربط شاشة الاقتران بالخادم.
|
||||
**Completed by:** Codex agent
|
||||
|
||||
### What was built
|
||||
|
||||
- `FchatiApp/Sources/FchatiApp/Networking/RelayAPI.swift` — actor-based HTTP client for pairing creation, pairing join, file upload, and file download.
|
||||
- Pairing requests use JSON and decode the exact relay response payloads.
|
||||
- File uploads use `multipart/form-data` with the required `file` field and bearer-token authorization.
|
||||
- Non-success HTTP responses, transport failures, and response decoding failures map to `RelayAPIError`.
|
||||
- `FchatiApp/Tests/FchatiAppTests/RelayAPIResponseTests.swift` — response-decoding coverage for relay pairing and upload payloads.
|
||||
|
||||
### Verified
|
||||
|
||||
- `swift build` passes from `FchatiApp/`.
|
||||
- `swift test` passes with the relay response tests.
|
||||
|
||||
## TASK-04 — WSClient (WebSocket) ✅
|
||||
|
||||
**Completed by:** Codex agent
|
||||
|
||||
### What was built
|
||||
|
||||
- `FchatiApp/Sources/FchatiApp/Networking/WSClient.swift` — actor-based WebSocket client using `URLSessionWebSocketTask`.
|
||||
- Connect sends authentication immediately, requires an `auth.ok` response within 10 seconds, and exposes authenticated incoming messages through `AsyncStream`.
|
||||
- The client sends a ping every 30 seconds and reconnects after unexpected disconnects with delays of 2, 4, 8, 16, and 32 seconds.
|
||||
- Explicit disconnects cancel reconnect attempts and retain the incoming stream for a later connection.
|
||||
- `FchatiApp/Tests/FchatiAppTests/WSMessageTests.swift` — serialization coverage for relay WebSocket messages.
|
||||
|
||||
### Verified
|
||||
|
||||
- `swift build` passes from `FchatiApp/`.
|
||||
- `swift test` passes with the WebSocket message test.
|
||||
|
||||
## TASK-05 — AppSession (State Machine) ✅
|
||||
|
||||
**Completed by:** Codex agent
|
||||
|
||||
### What was built
|
||||
|
||||
- `FchatiApp/Sources/FchatiApp/Session/AppSession.swift` — main-actor application state for pairing, session restoration, message sending, file sending, read receipts, and incoming message handling.
|
||||
- Existing credentials restore the WebSocket connection at launch.
|
||||
- Pairing and message failures update the published state with an error description.
|
||||
- File uploads are converted to attachment metadata and forwarded through relay chat messages.
|
||||
|
||||
## TASK-08 — MessageStore (Local Persistence) ✅
|
||||
|
||||
**Completed by:** Codex agent
|
||||
|
||||
### What was built
|
||||
|
||||
- `FchatiApp/Sources/FchatiApp/Storage/MessageStore.swift` — actor-backed JSON persistence at the required Application Support location.
|
||||
- Message writes are atomic, deduplicated by message ID, sorted by timestamp, and retain read-receipt state.
|
||||
- `FchatiApp/Tests/FchatiAppTests/MessageStoreModelTests.swift` — serialization coverage for messages and attachment metadata.
|
||||
|
||||
## TASK-10 — New Message Notifications ✅
|
||||
|
||||
**Completed by:** Codex agent
|
||||
|
||||
### What was built
|
||||
|
||||
- `FchatiApp/Sources/FchatiApp/Notifications/NotificationManager.swift` — notification permission request and background message notification delivery.
|
||||
- Notifications use the sender name, limit the preview to 100 characters, and are suppressed while the app is active.
|
||||
|
||||
### Verified
|
||||
|
||||
- `swift build` passes from `FchatiApp/`.
|
||||
- `swift test` passes with session storage coverage.
|
||||
|
||||
## TASK-06 — PairingView ✅
|
||||
|
||||
**Completed by:** Codex agent
|
||||
|
||||
### What was built
|
||||
|
||||
- `FchatiApp/Sources/FchatiApp/Features/Pairing/PairingView.swift` — create and join pairing screens with a segmented mode selector, code copy action, loading states, and inline errors.
|
||||
- `FchatiApp/Sources/FchatiApp/Features/Pairing/PairingViewModel.swift` — pairing request orchestration, code normalization, and display-state management.
|
||||
- Join codes are uppercased automatically and accept values with or without the `FCHT-` prefix.
|
||||
- A connection callback allows the app shell to switch to the chat screen when pairing succeeds.
|
||||
- `FchatiApp/Tests/FchatiAppTests/PairingViewModelTests.swift` — normalization coverage for pairing code input.
|
||||
|
||||
### Verified
|
||||
|
||||
- `swift build` passes from `FchatiApp/`.
|
||||
- `swift test` passes with pairing view-model coverage.
|
||||
|
||||
## TASK-07 — ChatView ✅
|
||||
|
||||
**Completed by:** Codex agent
|
||||
|
||||
### What was built
|
||||
|
||||
- `FchatiApp/Sources/FchatiApp/Features/Chat/ChatView.swift` — scrollable chat UI with automatic scrolling, text submission on Return, file selection, and hold-to-record voice input.
|
||||
- `FchatiApp/Sources/FchatiApp/Features/Chat/ChatViewModel.swift` — message and file sending, `.m4a` recording lifecycle, temporary recording cleanup, and inline send errors.
|
||||
- `FchatiApp/Sources/FchatiApp/Features/Chat/MessageBubble.swift` — left and right message bubbles, Markdown body rendering, attachment metadata, and timestamps.
|
||||
- `FchatiApp/FchatiApp.entitlements` and `FchatiApp/Info.plist` — microphone sandbox entitlement and privacy usage description required for voice recording.
|
||||
- `FchatiApp/Tests/FchatiAppTests/ChatMessageBubbleTests.swift` — Markdown rendering coverage for message bodies.
|
||||
|
||||
### Verified
|
||||
|
||||
- `swift build` passes from `FchatiApp/`.
|
||||
- `swift test` passes with chat view coverage.
|
||||
|
||||
## TASK-09 — SettingsView ✅
|
||||
|
||||
**Completed by:** Codex agent
|
||||
|
||||
### What was built
|
||||
|
||||
- `FchatiApp/Sources/FchatiApp/Features/Settings/SettingsView.swift` — editable display name, published session connection state, destructive unpair action, and bundled application version display.
|
||||
- The view reads and writes the display name through `KeychainStore.peerName` and calls `AppSession.unpair()` to clear pairing credentials.
|
||||
|
||||
### Verified
|
||||
|
||||
- `swift build` passes from `FchatiApp/`.
|
||||
- `swift test` passes with all current application tests.
|
||||
|
||||
## Integration Update ✅
|
||||
|
||||
**Completed by:** Codex agent
|
||||
|
||||
### What was updated
|
||||
|
||||
- `FchatiApp/Sources/FchatiApp/FchatiApp.swift` now renders `PairingView`, `ChatView`, and `SettingsView` in the menu-bar tabs instead of placeholder content.
|
||||
- The initial tab is pairing for first-time setup.
|
||||
- A successful pairing switches the selected tab to chat.
|
||||
|
||||
### Verified
|
||||
|
||||
- `swift build` passes from `FchatiApp/`.
|
||||
- `swift test` passes with all current application tests.
|
||||
|
||||
## Server File Cleanup ✅
|
||||
|
||||
**Completed by:** Codex agent
|
||||
|
||||
### What was built
|
||||
|
||||
- `relay-server/src/index.js` — removes expired uploaded files at startup and once every 24 hours. The retention period defaults to 30 days and is configurable with `FILE_TTL_DAYS`.
|
||||
- `POST /admin/cleanup` — protected manual cleanup endpoint. It accepts an optional JSON `days` value between 1 and 3650 and returns the number of deleted files and bytes.
|
||||
- `relay-server/.env.example` — documents the retention setting and required `ADMIN_TOKEN` bearer secret.
|
||||
|
||||
### Security
|
||||
|
||||
- The manual cleanup endpoint returns unavailable until `ADMIN_TOKEN` is configured.
|
||||
- Requests must use `Authorization: Bearer <ADMIN_TOKEN>`.
|
||||
|
||||
## Pending
|
||||
|
||||
See `TASKS.md` for all remaining tasks.
|
||||
|
||||
213
TASKS.md
213
TASKS.md
@ -6,20 +6,82 @@
|
||||
|
||||
---
|
||||
|
||||
## Rules for every agent
|
||||
|
||||
These rules apply to every agent that works on this project, without exception.
|
||||
|
||||
**1. No Arabic anywhere.**
|
||||
All code, comments, string literals, log messages, error messages, and documentation files must be in English only.
|
||||
|
||||
**2. Mark your task as done.**
|
||||
When you finish a task, find it in this file and change its status line from `[ ]` to `[x]` and add today's date. Example:
|
||||
```
|
||||
- [x] TASK-02 — KeychainStore (done 2026-07-26)
|
||||
```
|
||||
This tells the next agent not to redo your work.
|
||||
|
||||
**3. Document what you did.**
|
||||
Update `IMPLEMENTATION_STATUS.md` with: what files you created or changed, what was verified (tests, build), and anything you found missing or left incomplete. Write in English. This file is how the next session picks up exactly where you left off.
|
||||
|
||||
**4. Verify before finishing.**
|
||||
Run `swift build` from `FchatiApp/` before declaring the task complete. If it does not compile, fix it. Do not leave broken code.
|
||||
|
||||
**5. Do not touch completed tasks.**
|
||||
If a task is marked `[x]`, do not modify those files unless explicitly instructed.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Decisions (source of truth)
|
||||
|
||||
These are decisions made during planning — do not change without revisiting the reasoning.
|
||||
|
||||
**Transport:** WebSocket (WSS) over Traefik. Traefik handles TLS via Let's Encrypt. The app uses `URLSessionWebSocketTask` — no custom TCP stack, no certificate pinning, no OpenSSL.
|
||||
|
||||
**Server role:** The relay is a dumb pipe. It does not store conversation history. It only holds undelivered messages temporarily (offline queue). Once delivered, messages are deleted from the server.
|
||||
|
||||
**Conversation history:** Stored locally on each device only. The app saves every message it sends or receives to a local JSON file. This is the permanent record — the server is not the archive.
|
||||
|
||||
**Offline delivery:** Messages sent to an offline peer are queued on disk on the server (not in RAM). When the peer reconnects, all queued messages are flushed immediately. Queue TTL: 7 days. Queue is disk-backed so it survives server restarts.
|
||||
|
||||
**Pairing sessions:** Also persisted on disk. Tokens survive server restarts — users never need to re-pair after a server update.
|
||||
|
||||
**Read receipts:** When a peer opens and reads messages, the app sends `{ type: "read", messageIDs: [...] }`. The server forwards this to the sender (or queues it if offline). The sender's app then shows "read" status on those messages.
|
||||
|
||||
**File storage:** Uploaded files (images, voice, documents) live on the server at `/data/files`. Text message JSON is negligible in size (a year of heavy messaging ≈ tens of MB). Files are the real storage concern — photos/voice accumulate at ~1–2 GB/month with heavy use.
|
||||
|
||||
**File cleanup:** Files older than 30 days should be auto-deleted by the server. A manual "Clean now" button will also be available in Settings. Text messages on the device are never auto-deleted.
|
||||
|
||||
**Server requirements:** A $5/month VPS (1 vCPU, 1 GB RAM) is more than sufficient for a small group of friends. The relay holds connections open but does no heavy processing.
|
||||
|
||||
**No iCloud, no third-party services:** Everything flows through the self-hosted relay. Apple has no access to messages.
|
||||
|
||||
**Mac app distribution:** Not via App Store. Distribution options TBD (direct `.app` download, notarization, or TestFlight).
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
### Done
|
||||
|
||||
| Part | Details |
|
||||
|------|---------|
|
||||
| `relay-server/` | Full Node.js server — Pairing, WebSocket, file upload/download |
|
||||
| `Dockerfile` | Ready, small Alpine image |
|
||||
| `docker-compose.yml` | Ready with Traefik labels for fchati.diyaa.de |
|
||||
| Git repo | Pushed to git.mohfarawati.de/diyaa/fchaty |
|
||||
| `relay-server/src/index.js` | Node.js relay — Pairing, WebSocket, offline queue (disk-backed), read receipts, file upload/download |
|
||||
| `relay-server/Dockerfile` | Multi-stage Alpine build |
|
||||
| `relay-server/docker-compose.yml` | Traefik labels for `fchati.diyaa.de`, three persistent volumes (files, queue, pairing) |
|
||||
| `relay-server/.env.example` | Environment variable template |
|
||||
| Git repo | `git.mohfarawati.de/diyaa/fchaty` |
|
||||
|
||||
### Not built yet
|
||||
|
||||
The Mac app does not exist yet.
|
||||
The Mac app (`FchatiApp/`) does not exist yet.
|
||||
|
||||
### Pending server tasks
|
||||
|
||||
| Task | Details |
|
||||
|------|---------|
|
||||
| File auto-cleanup | ✅ Done 2026-07-26 — deletes files older than `FILE_TTL_DAYS` on startup and daily. |
|
||||
| Manual cleanup endpoint | ✅ Done 2026-07-26 — protected `POST /admin/cleanup` deletes files older than a requested number of days. |
|
||||
| Deploy to server | `git pull && docker compose up -d --build` on the production server. Not done yet — server is still being set up. |
|
||||
|
||||
---
|
||||
|
||||
@ -63,11 +125,10 @@ f-chaty-native-new/
|
||||
|
||||
---
|
||||
|
||||
### TASK-01 — Swift Package + App Shell
|
||||
### TASK-01 — Swift Package + App Shell ✅ done 2026-07-26
|
||||
|
||||
**Priority:** First — everything else depends on this
|
||||
**Files:** `FchatiApp/Package.swift`, `FchatiApp/Sources/FchatiApp/FchatiApp.swift`, `FchatiApp.entitlements`
|
||||
**Independent:** Yes
|
||||
**Completed.** See `IMPLEMENTATION_STATUS.md` for details.
|
||||
**Post-review fixes:** Added `LSUIElement` to `Info.plist`, applied entitlements in codesign script.
|
||||
|
||||
**Requirements:**
|
||||
|
||||
@ -115,134 +176,30 @@ Verify: `swift build` succeeds from `FchatiApp/` directory.
|
||||
|
||||
---
|
||||
|
||||
### TASK-02 — KeychainStore
|
||||
### TASK-02 — KeychainStore ✅ done 2026-07-26
|
||||
|
||||
**Priority:** Second
|
||||
**File:** `FchatiApp/Sources/FchatiApp/Storage/KeychainStore.swift`
|
||||
**Independent:** Yes — no dependencies on other app files
|
||||
|
||||
**Requirements:**
|
||||
|
||||
Write `KeychainStore` as an enum with static methods. Use `Security.framework` directly, no third-party libraries.
|
||||
|
||||
Service name: `"de.diyaa.fchati"`
|
||||
|
||||
```swift
|
||||
// Required interface:
|
||||
KeychainStore.installationID // String — generated once, persists forever (UUID)
|
||||
KeychainStore.authToken // String? — get/set/delete
|
||||
KeychainStore.peerID // String? — get/set/delete
|
||||
KeychainStore.peerName // String? — get/set/delete
|
||||
KeychainStore.clearAll() // removes token, peerID, peerName (keeps installationID)
|
||||
```
|
||||
|
||||
`installationID` must auto-generate and save on first access.
|
||||
All properties must be gettable and settable via Swift property syntax.
|
||||
**Reviewed:** No issues. Clean Security.framework usage, correct access policy (`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`), `clearAll()` correctly preserves installationID.
|
||||
|
||||
---
|
||||
|
||||
### TASK-03 — RelayAPI (HTTP)
|
||||
### TASK-03 — RelayAPI (HTTP) ✅ done 2026-07-26
|
||||
|
||||
**Priority:** Second (parallel with TASK-02)
|
||||
**File:** `FchatiApp/Sources/FchatiApp/Networking/RelayAPI.swift`
|
||||
**Independent:** Yes
|
||||
|
||||
**Server base URL:** `https://fchati.diyaa.de`
|
||||
|
||||
**Requirements:**
|
||||
|
||||
Write `RelayAPI` as an actor using `URLSession` with async/await. No third-party libraries.
|
||||
|
||||
```swift
|
||||
// Required interface:
|
||||
actor RelayAPI {
|
||||
static let shared: RelayAPI
|
||||
|
||||
func createPairing(displayName: String) async throws -> PairingCreateResponse
|
||||
func joinPairing(code: String, displayName: String) async throws -> PairingJoinResponse
|
||||
func uploadFile(url: URL, token: String) async throws -> FileUploadResponse
|
||||
func downloadFile(id: String, token: String) async throws -> Data
|
||||
}
|
||||
|
||||
struct PairingCreateResponse: Codable {
|
||||
let code: String
|
||||
let token: String
|
||||
let peerID: String
|
||||
let expiresAt: String
|
||||
}
|
||||
|
||||
struct PairingJoinResponse: Codable {
|
||||
let token: String
|
||||
let peerID: String
|
||||
let peer: PeerInfo
|
||||
}
|
||||
|
||||
struct PeerInfo: Codable {
|
||||
let id: String
|
||||
let displayName: String
|
||||
}
|
||||
|
||||
struct FileUploadResponse: Codable {
|
||||
let id: String
|
||||
let name: String
|
||||
let size: Int
|
||||
}
|
||||
|
||||
enum RelayAPIError: Error {
|
||||
case networkError(Error)
|
||||
case serverError(Int)
|
||||
case decodingError(Error)
|
||||
}
|
||||
```
|
||||
|
||||
File upload uses `multipart/form-data`, field name `"file"`.
|
||||
**Reviewed:** Good. All endpoints correct, multipart/form-data properly formatted, error types match spec.
|
||||
**Note for future:** `uploadFile` loads entire file into memory — acceptable for now, revisit if large video support is needed.
|
||||
|
||||
---
|
||||
|
||||
### TASK-04 — WSClient (WebSocket)
|
||||
### TASK-04 — WSClient (WebSocket) ✅ done 2026-07-26
|
||||
|
||||
**Priority:** Second (parallel)
|
||||
**File:** `FchatiApp/Sources/FchatiApp/Networking/WSClient.swift`
|
||||
**Independent:** Yes
|
||||
|
||||
**Requirements:**
|
||||
|
||||
Write `WSClient` as an actor using `URLSessionWebSocketTask`. No third-party libraries. Deployment target: macOS 14.
|
||||
|
||||
```swift
|
||||
// Required interface:
|
||||
actor WSClient {
|
||||
init(url: URL)
|
||||
func connect(token: String) async throws
|
||||
func disconnect()
|
||||
func send(_ message: WSMessage) async throws
|
||||
var incoming: AsyncStream<WSMessage> { get }
|
||||
}
|
||||
|
||||
struct WSMessage: Codable {
|
||||
var type: String
|
||||
var id: String?
|
||||
var body: String?
|
||||
var to: String?
|
||||
var from: String?
|
||||
var fromName: String?
|
||||
var token: String?
|
||||
var peerID: String?
|
||||
var reason: String?
|
||||
}
|
||||
```
|
||||
|
||||
Behavior:
|
||||
1. On `connect`: open WebSocket, immediately send `{ type: "auth", token: "..." }`
|
||||
2. Wait for `{ type: "auth.ok" }` — throw if not received within 10 seconds
|
||||
3. After auth: all incoming messages flow into `incoming` AsyncStream
|
||||
4. Send `{ type: "ping" }` every 30 seconds to keep connection alive
|
||||
5. On disconnect: attempt reconnect with backoff: 2s, 4s, 8s, 16s, 32s, then stop
|
||||
**Reviewed:** Excellent. Reconnect backoff correct, auth timeout clean, race conditions protected via task identity.
|
||||
**Fix applied:** Added `messageIDs: [String]?` and `attachment: WSAttachment?` to `WSMessage` — required for read receipts and file messages.
|
||||
|
||||
---
|
||||
|
||||
### TASK-05 — AppSession (State Machine)
|
||||
|
||||
- [x] TASK-05 — AppSession (State Machine) (done 2026-07-26)
|
||||
|
||||
**Priority:** Third — after TASK-02, TASK-03, TASK-04
|
||||
**File:** `FchatiApp/Sources/FchatiApp/Session/AppSession.swift`
|
||||
**Depends on:** `KeychainStore`, `RelayAPI`, `WSClient`
|
||||
@ -284,6 +241,8 @@ On incoming `chat.message`: append to `messages` and trigger a `UNUserNotificati
|
||||
|
||||
### TASK-06 — PairingView
|
||||
|
||||
- [x] TASK-06 — PairingView (done 2026-07-26)
|
||||
|
||||
**Priority:** Fourth — after TASK-05
|
||||
**Files:** `Features/Pairing/PairingView.swift`, `PairingViewModel.swift`
|
||||
|
||||
@ -310,6 +269,8 @@ Both modes observe `AppSession.shared.state` and navigate to `ChatView` when sta
|
||||
|
||||
### TASK-07 — ChatView
|
||||
|
||||
- [x] TASK-07 — ChatView (done 2026-07-26)
|
||||
|
||||
**Priority:** Fourth (parallel with TASK-06)
|
||||
**Files:** `Features/Chat/ChatView.swift`, `ChatViewModel.swift`, `MessageBubble.swift`
|
||||
|
||||
@ -332,6 +293,8 @@ Both modes observe `AppSession.shared.state` and navigate to `ChatView` when sta
|
||||
|
||||
### TASK-08 — MessageStore (Local Persistence)
|
||||
|
||||
- [x] TASK-08 — MessageStore (Local Persistence) (done 2026-07-26)
|
||||
|
||||
**Priority:** Fourth (parallel)
|
||||
**File:** `FchatiApp/Sources/FchatiApp/Storage/MessageStore.swift`
|
||||
**Independent:** Yes
|
||||
@ -372,6 +335,8 @@ Create the directory if it does not exist.
|
||||
|
||||
### TASK-09 — SettingsView
|
||||
|
||||
- [x] TASK-09 — SettingsView (done 2026-07-26)
|
||||
|
||||
**Priority:** Fifth
|
||||
**File:** `Features/Settings/SettingsView.swift`
|
||||
|
||||
@ -387,6 +352,8 @@ Simple settings screen:
|
||||
|
||||
### TASK-10 — New Message Notifications
|
||||
|
||||
- [x] TASK-10 — New Message Notifications (done 2026-07-26)
|
||||
|
||||
**Priority:** Fifth (independent)
|
||||
**File:** `FchatiApp/Sources/FchatiApp/Notifications/NotificationManager.swift`
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
PORT=3000
|
||||
MAX_FILE_SIZE_MB=25
|
||||
UPLOADS_DIR=/data/files
|
||||
FILE_TTL_DAYS=30
|
||||
ADMIN_TOKEN=8545851a901067a271e8e941297acb4c3d8d68c83eddb2809a8184071e8bc46f
|
||||
|
||||
@ -16,6 +16,9 @@ const PAIRING_DIR = process.env.PAIRING_DIR ?? '/data/pairing';
|
||||
const PAIRING_TTL_MS = 5 * 60 * 1000;
|
||||
const QUEUE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const QUEUE_MAX = 500;
|
||||
const FILE_TTL_DAYS = positiveInteger(process.env.FILE_TTL_DAYS, 30);
|
||||
const FILE_CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
const ADMIN_TOKEN = process.env.ADMIN_TOKEN?.trim() ?? '';
|
||||
|
||||
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
|
||||
fs.mkdirSync(QUEUE_DIR, { recursive: true });
|
||||
@ -38,6 +41,49 @@ const pairingSessions = new Map(); // code -> session
|
||||
const connections = new Map(); // peerID -> { ws, name }
|
||||
const fileRegistry = new Map(); // fileID -> { diskPath, originalName, size }
|
||||
|
||||
function positiveInteger(value, fallback) {
|
||||
const parsed = Number.parseInt(value ?? '', 10);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function cleanupFilesOlderThan(days = FILE_TTL_DAYS) {
|
||||
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||
let deletedFiles = 0;
|
||||
let deletedBytes = 0;
|
||||
|
||||
for (const entry of fs.readdirSync(UPLOADS_DIR, { withFileTypes: true })) {
|
||||
if (!entry.isFile()) continue;
|
||||
|
||||
const diskPath = path.join(UPLOADS_DIR, entry.name);
|
||||
try {
|
||||
const stats = fs.statSync(diskPath);
|
||||
if (stats.mtimeMs >= cutoff) continue;
|
||||
|
||||
fs.unlinkSync(diskPath);
|
||||
fileRegistry.delete(entry.name);
|
||||
deletedFiles += 1;
|
||||
deletedBytes += stats.size;
|
||||
} catch (error) {
|
||||
console.error(`[cleanup] failed to remove ${entry.name}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { days, deletedFiles, deletedBytes };
|
||||
}
|
||||
|
||||
function logCleanupResult(result, source) {
|
||||
if (result.deletedFiles > 0) {
|
||||
console.log(
|
||||
`[cleanup] ${source}: removed ${result.deletedFiles} file(s), ${result.deletedBytes} byte(s), older than ${result.days} day(s)`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logCleanupResult(cleanupFilesOlderThan(), 'startup');
|
||||
setInterval(() => {
|
||||
logCleanupResult(cleanupFilesOlderThan(), 'scheduled');
|
||||
}, FILE_CLEANUP_INTERVAL_MS);
|
||||
|
||||
// ─── Persistent queue (disk-backed) ──────────────────────────────────────────
|
||||
//
|
||||
// Layout on disk:
|
||||
@ -219,6 +265,19 @@ function requireToken(req, res, next) {
|
||||
next();
|
||||
}
|
||||
|
||||
function requireAdmin(req, res, next) {
|
||||
if (!ADMIN_TOKEN) {
|
||||
return res.status(503).json({ error: 'Admin cleanup is not configured' });
|
||||
}
|
||||
|
||||
const auth = req.headers.authorization ?? '';
|
||||
if (auth !== `Bearer ${ADMIN_TOKEN}`) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
// ─── Pairing routes ───────────────────────────────────────────────────────────
|
||||
|
||||
app.post('/pairing/create', (req, res) => {
|
||||
@ -298,6 +357,21 @@ app.get('/files/:id', requireToken, (req, res) => {
|
||||
res.sendFile(meta.diskPath);
|
||||
});
|
||||
|
||||
// ─── Admin routes ───────────────────────────────────────────────────────────
|
||||
|
||||
app.post('/admin/cleanup', requireAdmin, (req, res) => {
|
||||
const requestedDays = req.body?.days;
|
||||
const days = requestedDays === undefined ? FILE_TTL_DAYS : Number(requestedDays);
|
||||
|
||||
if (!Number.isInteger(days) || days < 1 || days > 3650) {
|
||||
return res.status(400).json({ error: 'days must be an integer between 1 and 3650' });
|
||||
}
|
||||
|
||||
const result = cleanupFilesOlderThan(days);
|
||||
logCleanupResult(result, 'manual');
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// ─── Health check ─────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/health', (_req, res) => res.json({
|
||||
|
||||
@ -44,7 +44,7 @@ echo "Creating the application bundle..."
|
||||
mkdir -p "$STAGING_APP/Contents/MacOS"
|
||||
cp "$PROJECT_DIR/Info.plist" "$STAGING_APP/Contents/Info.plist"
|
||||
cp "$EXECUTABLE_PATH" "$STAGING_APP/Contents/MacOS/$PRODUCT_NAME"
|
||||
codesign --force --sign - --timestamp=none "$STAGING_APP"
|
||||
codesign --force --sign - --timestamp=none --entitlements "$PROJECT_DIR/FchatiApp.entitlements" "$STAGING_APP"
|
||||
|
||||
if [[ -d "$INSTALL_PATH" ]]; then
|
||||
echo "Removing the installed application..."
|
||||
|
||||
Loading…
Reference in New Issue
Block a user