From 10e08435a7baeea2942503b1eb78d86e80bce95c Mon Sep 17 00:00:00 2001 From: Oschly Date: Sat, 16 May 2026 13:58:38 +0200 Subject: [PATCH] Bugfixes, simplifications --- Implementation/Peered/AllNotesScreen.swift | 2 +- .../NoteEditor/NoteEditingSessionServer.swift | 90 ++++++++++--------- .../Peered/NoteEditor/NoteEditorScreen.swift | 30 +++++-- .../Peered/NoteEditor/NoteTextEditor.swift | 67 +++----------- .../Peered/NoteEditor/SharedNoteEditor.swift | 35 ++++---- .../NotesList/NoteEditingSessionClient.swift | 88 ++++++++++-------- 6 files changed, 150 insertions(+), 162 deletions(-) diff --git a/Implementation/Peered/AllNotesScreen.swift b/Implementation/Peered/AllNotesScreen.swift index f044ea8..d6ff478 100644 --- a/Implementation/Peered/AllNotesScreen.swift +++ b/Implementation/Peered/AllNotesScreen.swift @@ -14,7 +14,7 @@ struct AllNotesScreen: View { @State private var ownPeer: OwnPeer? var isUsernameValid: Bool { - username.map(\.isEmpty) ?? false + !(username.map(\.isEmpty) ?? true) } var body: some View { diff --git a/Implementation/Peered/NoteEditor/NoteEditingSessionServer.swift b/Implementation/Peered/NoteEditor/NoteEditingSessionServer.swift index 4c4e988..5ccf955 100644 --- a/Implementation/Peered/NoteEditor/NoteEditingSessionServer.swift +++ b/Implementation/Peered/NoteEditor/NoteEditingSessionServer.swift @@ -4,8 +4,6 @@ import Combine struct OwnPeer { let peer: MCPeerID - - static var fallback: Self { Self(peer: .init(displayName: "fallback_user")) } } struct Peer: Identifiable { @@ -25,15 +23,15 @@ struct Peer: Identifiable { final class NoteEditingSessionServer: NSObject { private let session: MCSession private let browser: MCNearbyServiceBrowser - private let ownPeer: OwnPeer + private(set) var ownPeer: OwnPeer var visiblePeers: [Peer] = [] - let noteChangesEmitter = PassthroughSubject() + let noteChangesEmitter = PassthroughSubject() init(peer: OwnPeer) { ownPeer = peer browser = .init(peer: peer.peer, serviceType: "peered") - session = .init(peer: peer.peer) + session = .init(peer: peer.peer, securityIdentity: nil, encryptionPreference: .required) super.init() browser.delegate = self session.delegate = self @@ -56,26 +54,34 @@ final class NoteEditingSessionServer: NSObject { withContext: try! JSONEncoder().encode(note), timeout: 5 ) - - let idxToUpdate = visiblePeers.firstIndex(where: { $0.mcPeer == peer.mcPeer })! + guard let idxToUpdate = visiblePeers.firstIndex(where: { $0.mcPeer == peer.mcPeer }) else { return } visiblePeers[idxToUpdate].state = .invitationPending } + + func send(note: String, to peers: [MCPeerID]) { + let message = NoteMessage(senderID: ownPeer.peer.displayName, content: note) + guard !peers.isEmpty, let data = try? JSONEncoder().encode(message) else { return } + try? session.send(data, toPeers: peers, with: .reliable) + } } extension NoteEditingSessionServer: MCNearbyServiceBrowserDelegate { func browser( _ browser: MCNearbyServiceBrowser, foundPeer peerID: MCPeerID, - withDiscoveryInfo info: [String : String]? + withDiscoveryInfo info: [String: String]? ) { guard !visiblePeers.contains(where: { $0.mcPeer == peerID }) && peerID.displayName != ownPeer.peer.displayName else { return } - let newPeer = Peer(mcPeer: peerID, state: .available) - visiblePeers.append(newPeer) + DispatchQueue.main.async { + self.visiblePeers.append(Peer(mcPeer: peerID, state: .available)) + } } func browser(_ browser: MCNearbyServiceBrowser, lostPeer peerID: MCPeerID) { - guard let peerIdx = visiblePeers.firstIndex(where: { $0.mcPeer == peerID }) else { return } - visiblePeers.remove(at: peerIdx) + DispatchQueue.main.async { + guard let peerIdx = self.visiblePeers.firstIndex(where: { $0.mcPeer == peerID }) else { return } + self.visiblePeers.remove(at: peerIdx) + } } } @@ -85,39 +91,37 @@ extension NoteEditingSessionServer: MCSessionDelegate { peer peerID: MCPeerID, didChange state: MCSessionState ) { - - } - - func session( - _ session: MCSession, - didReceive stream: InputStream, - withName streamName: String, - fromPeer peerID: MCPeerID - ) { - - } - - func session( - _ session: MCSession, - didStartReceivingResourceWithName resourceName: String, - fromPeer peerID: MCPeerID, - with progress: Progress - ) { - - } - - func session( - _ session: MCSession, - didFinishReceivingResourceWithName resourceName: String, - fromPeer peerID: MCPeerID, - at localURL: URL?, - withError error: (any Error)? - ) { - + DispatchQueue.main.async { + guard let idx = self.visiblePeers.firstIndex(where: { $0.mcPeer == peerID }) else { return } + switch state { + case .connected: + self.visiblePeers[idx].state = .joined + case .notConnected: + let currentState = self.visiblePeers[idx].state + if currentState == .invitationPending || currentState == .joined { + self.visiblePeers[idx].state = .rejected + } + default: + break + } + } } func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) { - guard let note = String(data: data, encoding: .utf8) else { fatalError() } - noteChangesEmitter.send(note) + guard let message = try? JSONDecoder().decode(NoteMessage.self, from: data) else { return } + + // Broadcast to all other connected peers, preserving the original senderID + let otherPeers = session.connectedPeers.filter { $0 != peerID } + if !otherPeers.isEmpty { + try? session.send(data, toPeers: otherPeers, with: .reliable) + } + + DispatchQueue.main.async { + self.noteChangesEmitter.send(message) + } } + + func session(_ session: MCSession, didReceive stream: InputStream, withName streamName: String, fromPeer peerID: MCPeerID) {} + func session(_ session: MCSession, didStartReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, with progress: Progress) {} + func session(_ session: MCSession, didFinishReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, at localURL: URL?, withError error: (any Error)?) {} } diff --git a/Implementation/Peered/NoteEditor/NoteEditorScreen.swift b/Implementation/Peered/NoteEditor/NoteEditorScreen.swift index ca78c65..f28d602 100644 --- a/Implementation/Peered/NoteEditor/NoteEditorScreen.swift +++ b/Implementation/Peered/NoteEditor/NoteEditorScreen.swift @@ -12,17 +12,18 @@ struct NoteEditorScreen: View { let note: Note @State private var noteAdvertiser: NoteEditingSessionServer @State private var noteContent: String = "" + @State private var remoteNoteContent: String? = nil @State private var timer = Timer.publish(every: 5, on: .current, in: .common) .autoconnect() @State private var showManageMembers = false - + init(note: Note, peer: OwnPeer) { self.note = note self._noteAdvertiser = .init(initialValue: .init(peer: peer)) } - + var body: some View { - TextEditor(text: $noteContent) + NoteTextEditor(text: $noteContent, remoteText: remoteNoteContent) .toolbar { ToolbarItem(placement: .primaryAction) { Button("Manage members") { @@ -32,18 +33,29 @@ struct NoteEditorScreen: View { } .sheet(isPresented: $showManageMembers) { NavigationStack { - ManageMembersView( + ManageMembersScreen( noteAdvertiser: noteAdvertiser, noteTitle: note.name, noteContent: $noteContent ) } } - .onReceive(noteAdvertiser.noteChangesEmitter) { updatedNote in - self.noteContent = updatedNote + .onReceive(noteAdvertiser.noteChangesEmitter) { message in + guard message.senderID != noteAdvertiser.ownPeer.peer.displayName else { return } + remoteNoteContent = message.content + } + .task(id: noteContent) { + if noteContent == remoteNoteContent { return } + let connectedPeers = noteAdvertiser.visiblePeers + .filter { $0.state == .joined } + .map(\.mcPeer) + guard !connectedPeers.isEmpty else { return } + try? await Task.sleep(nanoseconds: 500_000_000) + guard !Task.isCancelled else { return } + noteAdvertiser.send(note: noteContent, to: connectedPeers) } .onAppear { - noteContent = try! String(contentsOf: note.path, encoding: .utf8) + noteContent = (try? String(contentsOf: note.path, encoding: .utf8)) ?? "" noteAdvertiser.startServer() } .onDisappear { @@ -54,8 +66,8 @@ struct NoteEditorScreen: View { saveNote() } } - + func saveNote() { - try! noteContent.write(to: note.path, atomically: true, encoding: .utf8) + try? noteContent.write(to: note.path, atomically: true, encoding: .utf8) } } diff --git a/Implementation/Peered/NoteEditor/NoteTextEditor.swift b/Implementation/Peered/NoteEditor/NoteTextEditor.swift index 0d32aa8..9380383 100644 --- a/Implementation/Peered/NoteEditor/NoteTextEditor.swift +++ b/Implementation/Peered/NoteEditor/NoteTextEditor.swift @@ -2,60 +2,15 @@ import SwiftUI struct NoteTextEditor: View { @Binding var text: String - let remoteText: String? - @State private var selection: TextSelection? = nil - - var body: some View { - TextEditor(text: $text, selection: $selection) - .onChange(of: remoteText) { oldValue, newValue in - guard let newValue, newValue != text else { return } - - let previousRemote = oldValue ?? text - - // Where the remote edit begins - let changeStart = commonPrefixUTF16Length(previousRemote, newValue) - - // How many UTF-16 units were inserted (positive) or removed (negative) - let delta = newValue.utf16.count - previousRemote.utf16.count - - // Apply remote text first - text = newValue - - // Get current cursor offset - guard - let selection, - case .selection(let range) = selection.indices, - let cursorPos = range.lowerBound.samePosition(in: text.utf16) - else { return } - - let cursorUTF16 = text.utf16.distance( - from: text.utf16.startIndex, - to: cursorPos - ) - - // Only shift if the change happened strictly before the cursor - guard changeStart < cursorUTF16 else { return } - - let newCursorUTF16 = max(0, min(cursorUTF16 + delta, newValue.utf16.count)) - let newUTF16 = newValue.utf16 - let newUTF16Index = newUTF16.index(newUTF16.startIndex, offsetBy: newCursorUTF16) - guard let newIndex = newUTF16Index.samePosition(in: newValue) else { return } - - self.selection = TextSelection(range: newIndex.. Int { - var count = 0 - let aUTF16 = a.utf16 - let bUTF16 = b.utf16 - let minLen = min(aUTF16.count, bUTF16.count) - while count < minLen { - let aIdx = aUTF16.index(aUTF16.startIndex, offsetBy: count) - let bIdx = bUTF16.index(bUTF16.startIndex, offsetBy: count) - guard aUTF16[aIdx] == bUTF16[bIdx] else { break } - count += 1 - } - return count - } + let remoteText: String? + @State private var selection: TextSelection? = nil + + var body: some View { + TextEditor(text: $text, selection: $selection) + .onChange(of: remoteText) { oldValue, newValue in + guard let newValue, newValue != text else { return } + // Apply remote text first + text = newValue + } + } } diff --git a/Implementation/Peered/NoteEditor/SharedNoteEditor.swift b/Implementation/Peered/NoteEditor/SharedNoteEditor.swift index 1fb6499..5c6e0e6 100644 --- a/Implementation/Peered/NoteEditor/SharedNoteEditor.swift +++ b/Implementation/Peered/NoteEditor/SharedNoteEditor.swift @@ -8,10 +8,10 @@ import SwiftUI struct SharedNoteEditor: View { @State var note: String? - @State var sendTask: Task? + @State var remoteNote: String? = nil @State var invitation: NoteInvitation @Bindable var noteClient: NoteEditingSessionClient - + init( invitation: NoteInvitation, noteClient: NoteEditingSessionClient @@ -19,27 +19,26 @@ struct SharedNoteEditor: View { self._invitation = .init(initialValue: invitation) self._noteClient = .init(noteClient) } - + var body: some View { ZStack { - if let note = Binding($note) { - TextEditor(text: note) - } else { - ProgressView { - Text("Fetching note...") - } - } - } - .onChange(of: note) { _, newValue in - sendTask?.cancel() - sendTask = Task { - try? await Task.sleep(nanoseconds: 500000000) - guard let note else { return } - DispatchQueue.main.async { - noteClient.send(note: note, to: invitation.invitatorID) + if let note = Binding($note) { + NoteTextEditor(text: note, remoteText: remoteNote) + } else { + ProgressView { + Text("Fetching note...") } } } + .onReceive(noteClient.noteChangesEmitter) { message in + guard message.senderID != noteClient.ownPeer.displayName else { return } + remoteNote = message.content + } + .task(id: note) { + try? await Task.sleep(nanoseconds: 500_000_000) + guard !Task.isCancelled, let note else { return } + noteClient.send(note: note, to: invitation.invitatorID) + } .onAppear { invitation.accept() note = invitation.note.noteSnapshot diff --git a/Implementation/Peered/NotesList/NoteEditingSessionClient.swift b/Implementation/Peered/NotesList/NoteEditingSessionClient.swift index c3795e7..571b92f 100644 --- a/Implementation/Peered/NotesList/NoteEditingSessionClient.swift +++ b/Implementation/Peered/NotesList/NoteEditingSessionClient.swift @@ -1,5 +1,6 @@ import MultipeerConnectivity import Foundation +import Combine struct NoteInvitation: Identifiable { struct NoteContent: Codable { @@ -8,18 +9,16 @@ struct NoteInvitation: Identifiable { } var id: MCPeerID { invitatorID } - let noteName: String + var noteName: String { note.title } let invitatorID: MCPeerID let note: NoteContent private var invitationHandler: ((Bool) -> Void)? init( - noteName: String, invitatorID: MCPeerID, note: NoteContent, invitationHandler: ((Bool) -> Void)? = nil ) { - self.noteName = noteName self.invitatorID = invitatorID self.note = note self.invitationHandler = invitationHandler @@ -40,13 +39,10 @@ struct NoteInvitation: Identifiable { final class NoteEditingSessionClient: NSObject { private let session: MCSession private let advertiser: MCNearbyServiceAdvertiser - private let ownPeer: MCPeerID + private(set) var ownPeer: MCPeerID - var invitations: [NoteInvitation] = [] { - didSet { - print(invitations) - } - } + var invitations: [NoteInvitation] = [] + let noteChangesEmitter = PassthroughSubject() init(peer: MCPeerID) { ownPeer = peer @@ -62,22 +58,22 @@ final class NoteEditingSessionClient: NSObject { ) super.init() advertiser.delegate = self + session.delegate = self } func startBrowsingForNotes() { - advertiser.startAdvertisingPeer() - } - - func stopBrowsingForNotes() { - advertiser.stopAdvertisingPeer() - } + advertiser.startAdvertisingPeer() + } + + func stopBrowsingForNotes() { + advertiser.stopAdvertisingPeer() + session.disconnect() + } func send(note: String, to peer: MCPeerID) { - try! session.send( - note.data(using: .utf8)!, - toPeers: [peer], - with: .reliable - ) + let message = NoteMessage(senderID: ownPeer.displayName, content: note) + guard let data = try? JSONEncoder().encode(message) else { return } + try? session.send(data, toPeers: [peer], with: .reliable) } } @@ -91,23 +87,45 @@ extension NoteEditingSessionClient: MCNearbyServiceAdvertiserDelegate { guard let context, let noteContent = try? JSONDecoder().decode(NoteInvitation.NoteContent.self, from: context) - else { fatalError() } + else { return } - invitations.append( - .init( - noteName: noteContent.title, - invitatorID: peerID, - note: noteContent, - invitationHandler: { [weak self, invitationHandler] accepted in - guard let self else { return } - invitationHandler(accepted, self.session) - - DispatchQueue.main.async { - guard let idx = self.invitations.firstIndex(where: { $0.id == peerID }) else { return } - self.invitations.remove(at: idx) + DispatchQueue.main.async { + self.invitations.append( + .init( + invitatorID: peerID, + note: noteContent, + invitationHandler: { [weak self, invitationHandler] accepted in + guard let self else { return } + invitationHandler(accepted, self.session) + DispatchQueue.main.async { + self.invitations.removeAll { $0.id == peerID } + } } - } + ) ) - ) + } } } + +extension NoteEditingSessionClient: MCSessionDelegate { + func session( + _ session: MCSession, + peer peerID: MCPeerID, + didChange state: MCSessionState + ) {} + + func session( + _ session: MCSession, + didReceive data: Data, + fromPeer peerID: MCPeerID + ) { + guard let message = try? JSONDecoder().decode(NoteMessage.self, from: data) else { return } + DispatchQueue.main.async { + self.noteChangesEmitter.send(message) + } + } + + func session(_ session: MCSession, didReceive stream: InputStream, withName streamName: String, fromPeer peerID: MCPeerID) {} + func session(_ session: MCSession, didStartReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, with progress: Progress) {} + func session(_ session: MCSession, didFinishReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, at localURL: URL?, withError error: (any Error)?) {} +}