diff options
| author | Dustin Mierau <dustin@mierau.me> | 2025-11-07 21:03:53 -0800 |
|---|---|---|
| committer | Dustin Mierau <dustin@mierau.me> | 2025-11-07 21:03:53 -0800 |
| commit | 2f332ee497af925db1a2583135e9dfcdaff84794 (patch) | |
| tree | ff22f357a87cfa9dd577b4273f7f62a89906d83b /Hotline | |
| parent | 637cd9af54a58db0c5dc2062b6c20291afd31147 (diff) | |
Add Bonjour discovery for Hotline servers on the local network to the Servers window.
Diffstat (limited to 'Hotline')
| -rw-r--r-- | Hotline/Models/Server.swift | 9 | ||||
| -rw-r--r-- | Hotline/State/AppState.swift | 2 | ||||
| -rw-r--r-- | Hotline/State/BonjourState.swift | 256 | ||||
| -rw-r--r-- | Hotline/macOS/Trackers/BonjourServerRow.swift | 19 | ||||
| -rw-r--r-- | Hotline/macOS/Trackers/TrackerItemView.swift | 2 | ||||
| -rw-r--r-- | Hotline/macOS/Trackers/TrackerView.swift | 114 |
6 files changed, 398 insertions, 4 deletions
diff --git a/Hotline/Models/Server.swift b/Hotline/Models/Server.swift index e0fe38b..7d38752 100644 --- a/Hotline/Models/Server.swift +++ b/Hotline/Models/Server.swift @@ -10,6 +10,15 @@ struct Server: Codable { var login: String var password: String + var displayAddress: String { + if self.port == HotlinePorts.DefaultServerPort { + return self.address + } + else { + return "\(self.address):\(String(self.port))" + } + } + init(name: String?, description: String?, address: String, port: Int = HotlinePorts.DefaultServerPort, users: Int = 0, login: String? = nil, password: String? = nil) { self.name = name self.description = description diff --git a/Hotline/State/AppState.swift b/Hotline/State/AppState.swift index 558af2a..3aadf08 100644 --- a/Hotline/State/AppState.swift +++ b/Hotline/State/AppState.swift @@ -11,6 +11,8 @@ final class AppState { private init() { } + + var bonjourState = BonjourState() var activeHotline: HotlineState? = nil var activeServerState: ServerState? = nil diff --git a/Hotline/State/BonjourState.swift b/Hotline/State/BonjourState.swift new file mode 100644 index 0000000..aa7cf44 --- /dev/null +++ b/Hotline/State/BonjourState.swift @@ -0,0 +1,256 @@ +import Foundation +import Network + +@Observable +class BonjourState { + var isExpanded: Bool = false + var isBrowsing: Bool = false + var discoveredServers: [BonjourServer] = [] + + private var browser: NWBrowser? + private var resolutionTasks: [UUID: Task<Void, Never>] = [:] + + private actor ConnectionResolverState { + var completed = false + func markComplete() { + self.completed = true + } + } + + struct BonjourServer: Identifiable, Hashable { + let id = UUID() + let serviceName: String + let name: String + let address: String? + let port: UInt16? + let txtRecords: [String: String] + + var displayName: String { + // Use the advertised name, fall back to service name + self.name.isEmpty ? self.serviceName : self.name + } + + var server: Server? { + guard let address = self.address, + let port = self.port else { + return nil + } + return Server(name: self.displayName, description: nil, address: address, port: Int(port)) + } + + var isLoopback: Bool { + guard let address = self.address else { return false } + return address.hasPrefix("127.") || address.hasPrefix("::1") + } + + static func == (lhs: BonjourServer, rhs: BonjourServer) -> Bool { + lhs.address == rhs.address && lhs.port == rhs.port + } + + func hash(into hasher: inout Hasher) { + hasher.combine(self.id) + } + } + + func startBrowsing() { + guard !self.isBrowsing else { + return + } + + self.isBrowsing = true + self.discoveredServers.removeAll() + + let parameters = NWParameters() + parameters.includePeerToPeer = true + + self.browser = NWBrowser(for: .bonjourWithTXTRecord(type: "_hotline._tcp", domain: nil), using: parameters) + + self.browser?.stateUpdateHandler = { [weak self] newState in + Task { @MainActor in + switch newState { + case .ready: + print("BonjourState: Browser ready") + case .failed(let error): + print("BonjourState: Browser failed: \(error)") + self?.stopBrowsing() + case .cancelled: + print("BonjourState: Browser cancelled") + self?.isBrowsing = false + default: + break + } + } + } + + self.browser?.browseResultsChangedHandler = { [weak self] results, changes in + guard let self = self else { + return + } + + Task { @MainActor in + print("BonjourState: Browse results changed, found \(results.count) services") + + // Handle removed services + for change in changes { + if case .removed(let result) = change { + if case .service(let name, _, _, _) = result.endpoint { + self.discoveredServers.removeAll { $0.serviceName == name } + print("BonjourState: Removed service: \(name)") + } + } + } + + // Handle added/updated services + for change in changes { + if case .added(let result) = change, case .service = result.endpoint { + await self.resolveService(result) + } else if case .changed(_, let new, _) = change, case .service = new.endpoint { + await self.resolveService(new) + } + } + } + } + + self.browser?.start(queue: .main) + } + + private func cleanAddress(_ addressString: String) -> String { + // For link-local IPv6 (fe80::), keep zone ID as it's required + if addressString.hasPrefix("fe80:") { + return addressString + } + + // For everything else, strip zone identifier + return addressString.components(separatedBy: "%").first ?? addressString + } + + private func resolveService(_ result: NWBrowser.Result) async { + guard case .service(let name, _, _, _) = result.endpoint else { + return + } + + // Create a connection to resolve the service + let connection = NWConnection(to: result.endpoint, using: .tcp) + let resolver = ConnectionResolverState() + + await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in + connection.stateUpdateHandler = { [weak self] state in + Task { @MainActor in + if Task.isCancelled { + await resolver.markComplete() + return + } + + if await resolver.completed { + return + } + + switch state { + case .ready: + await resolver.markComplete() + + // Extract address and port + var address: String? + var port: UInt16? + + guard let path = connection.currentPath, let endpoint = path.remoteEndpoint else { + return + } + + var isLoopback: Bool = false + if path.usesInterfaceType(.loopback) { + isLoopback = true + } + + if case .hostPort(let host, let nwPort) = endpoint { + switch host { + case .ipv4(let ipv4): + address = self?.cleanAddress(ipv4.debugDescription) + case .ipv6(let ipv6): + address = self?.cleanAddress(ipv6.debugDescription) + case .name(let hostname, _): + address = hostname + @unknown default: + break + } + + if isLoopback { + address = "127.0.0.1" + } + port = nwPort.rawValue + } + + // Parse TXT records + var txtRecords: [String: String] = [:] + if case .bonjour(let txtRecord) = result.metadata { + for (key, value) in txtRecord.dictionary { + txtRecords[key] = value + } + } + + let server = BonjourServer( + serviceName: name, + name: name, + address: address, + port: port, + txtRecords: txtRecords + ) + + // Update or add server + if let index = self?.discoveredServers.firstIndex(where: { $0.serviceName == + name }) { + self?.discoveredServers[index] = server + } else { + self?.discoveredServers.append(server) + } + + connection.cancel() + continuation.resume() + + case .failed(let error): + await resolver.markComplete() + + print("BonjourState: Failed to resolve \(name): \(error)") + connection.cancel() + continuation.resume() + + default: + break + } + } + } + + connection.start(queue: .main) + + // Timeout after 5 seconds + Task { + try? await Task.sleep(nanoseconds: 5_000_000_000) + + if await resolver.completed == false { + await resolver.markComplete() + connection.cancel() + continuation.resume() + } + } + } + } + + func stopBrowsing() { + guard self.isBrowsing else { + return + } + + print("BonjourState: Stopping Bonjour browsing") + + // Cancel all resolution tasks + for (_, task) in self.resolutionTasks { + task.cancel() + } + self.resolutionTasks.removeAll() + + self.browser?.cancel() + self.browser = nil + self.isBrowsing = false + self.discoveredServers.removeAll() + } +} diff --git a/Hotline/macOS/Trackers/BonjourServerRow.swift b/Hotline/macOS/Trackers/BonjourServerRow.swift new file mode 100644 index 0000000..d99b23b --- /dev/null +++ b/Hotline/macOS/Trackers/BonjourServerRow.swift @@ -0,0 +1,19 @@ +import SwiftUI + +struct BonjourServerRow: View { + @Environment(\.openWindow) private var openWindow + + let server: BonjourState.BonjourServer + + var body: some View { + HStack(alignment: .center, spacing: 6) { + Image("Server") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16, alignment: .center) + Text(self.server.displayName).lineLimit(1).truncationMode(.tail) + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/Hotline/macOS/Trackers/TrackerItemView.swift b/Hotline/macOS/Trackers/TrackerItemView.swift index 6e62518..37093d2 100644 --- a/Hotline/macOS/Trackers/TrackerItemView.swift +++ b/Hotline/macOS/Trackers/TrackerItemView.swift @@ -36,7 +36,7 @@ struct TrackerItemView: View { if isLoading { ProgressView() .padding([.leading, .trailing], 2) - .controlSize(.small) + .controlSize(.mini) } Spacer(minLength: 0) if isExpanded && count > 0 { diff --git a/Hotline/macOS/Trackers/TrackerView.swift b/Hotline/macOS/Trackers/TrackerView.swift index ddc0a67..0eb4a12 100644 --- a/Hotline/macOS/Trackers/TrackerView.swift +++ b/Hotline/macOS/Trackers/TrackerView.swift @@ -6,11 +6,15 @@ import UniformTypeIdentifiers enum TrackerSelection: Hashable { case bookmark(Bookmark) case bookmarkServer(BookmarkServer) + case bonjourGroup + case bonjourServer(BonjourState.BonjourServer) var server: Server? { switch self { - case .bookmark(let b): return b.server - case .bookmarkServer(let t): return t.server + case .bookmark(let b): b.server + case .bookmarkServer(let t): t.server + case .bonjourGroup: nil + case .bonjourServer(let b): b.server } } } @@ -85,6 +89,62 @@ struct TrackerView: View { } } } + + var bonjourRowView: some View { + HStack(alignment: .center, spacing: 6) { + Button { + AppState.shared.bonjourState.isExpanded.toggle() + } label: { + Text(Image(systemName: AppState.shared.bonjourState.isExpanded ? "chevron.down" : "chevron.right")) + .bold() + .font(.system(size: 10)) + .opacity(0.5) + .frame(alignment: .center) + } + .buttonStyle(.plain) + .frame(width: 10) + .padding(.leading, 4) + .padding(.trailing, 2) + + Image(systemName: "bonjour") + .resizable() + .scaledToFit() + .symbolRenderingMode(.multicolor) + .frame(width: 16, height: 16, alignment: .center) + Text("Bonjour").bold().lineLimit(1).truncationMode(.tail) + + if AppState.shared.bonjourState.isBrowsing { + ProgressView() + .controlSize(.mini) + } + + Spacer(minLength: 0) + + if AppState.shared.bonjourState.isExpanded && !AppState.shared.bonjourState.discoveredServers.isEmpty { + HStack(spacing: 4) { + Text(String(AppState.shared.bonjourState.discoveredServers.count)) + + SpinningGlobeView() + .fontWeight(.semibold) + .frame(width: 12, height: 12) + } + .padding(.horizontal, 6) + .padding(.vertical, 2) + .foregroundStyle(.secondary) +// .background(.quinary) + .clipShape(.capsule) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onChange(of: AppState.shared.bonjourState.isExpanded) { oldState, newState in + if newState { + AppState.shared.bonjourState.startBrowsing() + } + else { + AppState.shared.bonjourState.stopBrowsing() + } + } + } var body: some View { List(selection: $selection) { @@ -115,6 +175,17 @@ struct TrackerView: View { .onDelete { deletedIndexes in Bookmark.delete(at: deletedIndexes, context: modelContext) } + + self.bonjourRowView + .tag(TrackerSelection.bonjourGroup) + + if AppState.shared.bonjourState.isExpanded { + ForEach(AppState.shared.bonjourState.discoveredServers, id: \.self) { record in + BonjourServerRow(server: record) + .tag(TrackerSelection.bonjourServer(record)) + .padding(.leading, 16 + 8 + 10) + } + } } .onDeleteCommand { switch self.selection { @@ -155,6 +226,10 @@ struct TrackerView: View { self.bookmarkContextMenu(bookmark) case .bookmarkServer(let server): self.bookmarkServerContextMenu(server) + case .bonjourGroup: + EmptyView() + case .bonjourServer(let bonjourServer): + self.bonjourServerContextMenu(bonjourServer) } } } primaryAction: { items in @@ -180,6 +255,15 @@ struct TrackerView: View { case .bookmarkServer(let bookmarkServer): openWindow(id: "server", value: bookmarkServer.server) + + case .bonjourGroup: + AppState.shared.bonjourState.isExpanded.toggle() + + case .bonjourServer(let bonjourServer): + if let server = bonjourServer.server { + openWindow(id: "server", value: server) + } + } } .fileExporter(isPresented: $bookmarkExportActive, document: bookmarkExport, contentTypes: [.data], defaultFilename: "\(bookmarkExport?.bookmark.name ?? "Hotline Bookmark").hlbm", onCompletion: { result in @@ -380,8 +464,32 @@ struct TrackerView: View { Label(bookmark.type == .tracker ? "Delete Tracker" : "Delete Bookmark", systemImage: "trash") } } - + @ViewBuilder + func bonjourServerContextMenu(_ bonjourServer: BonjourState.BonjourServer) -> some View { + Button { + guard let server = bonjourServer.server else { + return + } + let newBookmark = Bookmark(type: .server, name: server.name ?? server.address, address: server.address, port: server.port, login: nil, password: nil) + Bookmark.add(newBookmark, context: modelContext) + } label: { + Label("Bookmark", systemImage: "bookmark") + } + + Divider() + + Button { + guard let server = bonjourServer.server else { + return + } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(server.displayAddress, forType: .string) + } label: { + Label("Copy Address", systemImage: "doc.on.doc") + } + } + func refresh() { // When a tracker is selected, refresh only that tracker. if let trackerSelection = self.selection { |