From 2f332ee497af925db1a2583135e9dfcdaff84794 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 7 Nov 2025 21:03:53 -0800 Subject: Add Bonjour discovery for Hotline servers on the local network to the Servers window. --- Hotline/State/BonjourState.swift | 256 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 Hotline/State/BonjourState.swift (limited to 'Hotline/State/BonjourState.swift') 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] = [:] + + 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) 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() + } +} -- cgit From 286c408370681b022deaabd254d499aefec28add Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Sat, 8 Nov 2025 12:33:00 -0800 Subject: Some work on making it possible to connect to servers with an IPv6 address. Improve connect form a bit. Add Copy Link to context menu for servers and trackers. --- Hotline.xcodeproj/project.pbxproj | 2 +- Hotline/Library/NetSocket/NetSocket.swift | 19 +++- Hotline/Models/Server.swift | 50 +++++++++- Hotline/State/BonjourState.swift | 22 +++-- Hotline/macOS/ServerView.swift | 151 +++++++++++++++--------------- Hotline/macOS/Trackers/TrackerView.swift | 50 ++++++++-- 6 files changed, 195 insertions(+), 99 deletions(-) (limited to 'Hotline/State/BonjourState.swift') diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index f94cba2..3e600a4 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -309,12 +309,12 @@ DA501BE52EBE9520001714F8 /* Trackers */ = { isa = PBXGroup; children = ( - DA501BF12EBEF415001714F8 /* BonjourServerRow.swift */, DAE734FA2B2E41F9000C56F6 /* TrackerView.swift */, DA501BE82EBE9589001714F8 /* TrackerItemView.swift */, DA501BEA2EBE95B4001714F8 /* TrackerBookmarkServerView.swift */, DA501BE62EBE9542001714F8 /* TrackerBookmarkSheet.swift */, DA501BE32EBE9517001714F8 /* ServerBookmarkSheet.swift */, + DA501BF12EBEF415001714F8 /* BonjourServerRow.swift */, ); path = Trackers; sourceTree = ""; diff --git a/Hotline/Library/NetSocket/NetSocket.swift b/Hotline/Library/NetSocket/NetSocket.swift index 5c7d185..e66ef3f 100644 --- a/Hotline/Library/NetSocket/NetSocket.swift +++ b/Hotline/Library/NetSocket/NetSocket.swift @@ -173,7 +173,24 @@ public actor NetSocket { guard let nwPort = NWEndpoint.Port(rawValue: port) else { throw NetSocketError.invalidPort } - return try await self.connect(host: .name(host, nil), port: nwPort, tls: tls, config: config) + + // Parse the host string to create the appropriate NWEndpoint.Host + let nwHost: NWEndpoint.Host + + // Try parsing as IPv6 without zone + if let ipv6Addr = IPv6Address(host) { + nwHost = .ipv6(ipv6Addr) + } + // Try parsing as IPv4 + else if let ipv4Addr = IPv4Address(host) { + nwHost = .ipv4(ipv4Addr) + } + // Fall back to treating as hostname + else { + nwHost = .name(host, nil) + } + + return try await self.connect(host: nwHost, port: nwPort, tls: tls, config: config) } // MARK: Close diff --git a/Hotline/Models/Server.swift b/Hotline/Models/Server.swift index 7d38752..3ad3375 100644 --- a/Hotline/Models/Server.swift +++ b/Hotline/Models/Server.swift @@ -15,7 +15,12 @@ struct Server: Codable { return self.address } else { - return "\(self.address):\(String(self.port))" + // Wrap IPv6 addresses in brackets when displaying with port + if self.address.contains(":") { + return "[\(self.address)]:\(String(self.port))" + } else { + return "\(self.address):\(String(self.port))" + } } } @@ -50,10 +55,49 @@ struct Server: Codable { } static func parseServerAddressAndPort(_ address: String) -> (String, Int) { - let url = URL(string: "hotline://\(address)") + let trimmed = address.trimmingCharacters(in: .whitespacesAndNewlines) + + // Check if this looks like an IPv6 address (contains colons but no port delimiter) + // IPv6 addresses can be: + // - fe80::1234 + // - [fe80::1234]:5500 (with port) + // - 2001:db8::1 + // - [2001:db8::1]:6500 (with port) + + // If it starts with [, it's bracketed IPv6 with optional port + if trimmed.hasPrefix("[") { + // Find the closing bracket + if let closeBracketIndex = trimmed.firstIndex(of: "]") { + let hostEndIndex = trimmed.index(after: closeBracketIndex) + let host = String(trimmed[trimmed.index(after: trimmed.startIndex).. 1 { + // This is likely an IPv6 address without a port + // Keep it as-is, including any zone identifier (e.g., %en1 for link-local) + return (trimmed.lowercased(), HotlinePorts.DefaultServerPort) + } + + // Otherwise use URL parsing for IPv4 or hostnames + let url = URL(string: "hotline://\(trimmed)") let port = url?.port ?? HotlinePorts.DefaultServerPort let host = url?.host(percentEncoded: false) ?? "" - return (host.lowercased().trimmingCharacters(in: .whitespacesAndNewlines), port) + return (host.lowercased(), port) } } diff --git a/Hotline/State/BonjourState.swift b/Hotline/State/BonjourState.swift index aa7cf44..b0bb42c 100644 --- a/Hotline/State/BonjourState.swift +++ b/Hotline/State/BonjourState.swift @@ -115,12 +115,13 @@ class BonjourState { } private func cleanAddress(_ addressString: String) -> String { - // For link-local IPv6 (fe80::), keep zone ID as it's required + // For link-local IPv6 addresses (fe80::), we MUST keep the zone identifier + // because it tells the system which network interface to use for routing + // For all other addresses (global IPv6, IPv4), strip the zone identifier if addressString.hasPrefix("fe80:") { return addressString } - - // For everything else, strip zone identifier + return addressString.components(separatedBy: "%").first ?? addressString } @@ -128,7 +129,7 @@ class BonjourState { 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() @@ -148,32 +149,33 @@ class BonjourState { 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) + let ipv6String = ipv6.debugDescription + address = self?.cleanAddress(ipv6String) case .name(let hostname, _): address = hostname @unknown default: break } - + if isLoopback { address = "127.0.0.1" } diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index a66a071..d2c503f 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -116,8 +116,13 @@ struct ServerView: View { var body: some View { Group { if model.status == .disconnected { - connectForm - .navigationTitle("Connect to Server") + VStack(alignment: .center) { + Spacer() + self.connectForm + Spacer() + } +// .frame(maxWidth: .infinity, maxHeight: .infinity) + .navigationTitle("Connect to Server") } else if case .failed(let error) = model.status { VStack { @@ -245,79 +250,80 @@ struct ServerView: View { } var connectForm: some View { - VStack(alignment: .center) { - GroupBox { - Form { - Group { - TextField(text: $connectAddress) { - Text("Address:") - } - .focused($focusedField, equals: .address) - - Text("Type the address of the Hotline server you would like to connect to. If you have an account on that server, type your login and password too.") - .font(.caption) - .foregroundStyle(.secondary) - .padding(.bottom, 4) - - TextField(text: $connectLogin, prompt: Text("Optional")) { - Text("Login:") - } - .focused($focusedField, equals: .login) - SecureField(text: $connectPassword, prompt: Text("Optional")) { - Text("Password:") - } - .focused($focusedField, equals: .password) - } - .textFieldStyle(.roundedBorder) - .controlSize(.large) - - HStack { - Button("Save...") { - if !connectAddress.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - connectNameSheetPresented = true - } - } - .disabled(connectAddress.isEmpty) - .controlSize(.regular) - .buttonStyle(.automatic) - .help("Bookmark server") - - Spacer() - - Button("Cancel") { - dismiss() - } - .controlSize(.regular) - .buttonStyle(.automatic) - .keyboardShortcut(.cancelAction) - - Button("Connect") { - connectToServer() - } - - .controlSize(.regular) - .buttonStyle(.automatic) - .keyboardShortcut(.defaultAction) - } - .padding(.top, 8) - + Form { + HStack(alignment: .top, spacing: 10) { + Image("Server Large") + .resizable() + .scaledToFit() + .frame(width: 28, height: 28) + + VStack(alignment: .leading) { + Text("Connect to Server") + Text("Enter the address of a Hotline server to connect to.") + .foregroundStyle(.secondary) + .font(.subheadline) } - .padding() - .onChange(of: connectAddress) { - let (a, p) = Server.parseServerAddressAndPort(connectAddress) - server.address = a - server.port = p + } + + TextField(text: $connectAddress) { + Text("Address:") + } + .focused($focusedField, equals: .address) + + TextField(text: $connectLogin, prompt: Text("Optional")) { + Text("Login:") + } + .focused($focusedField, equals: .login) + SecureField(text: $connectPassword, prompt: Text("Optional")) { + Text("Password:") + } + .focused($focusedField, equals: .password) + + HStack { + Button("Save...") { + if !connectAddress.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + connectNameSheetPresented = true + } } - .onChange(of: connectLogin) { - server.login = connectLogin.trimmingCharacters(in: .whitespacesAndNewlines) + .disabled(connectAddress.isEmpty) + .controlSize(.regular) + .buttonStyle(.automatic) + .help("Bookmark server") + + Spacer() + + Button("Cancel") { + dismiss() } - .onChange(of: connectPassword) { - server.password = connectPassword + .controlSize(.regular) + .buttonStyle(.automatic) + .keyboardShortcut(.cancelAction) + + Button("Connect") { + connectToServer() } + + .controlSize(.regular) + .buttonStyle(.automatic) + .keyboardShortcut(.defaultAction) } - .onAppear { - focusedField = .address - } + .padding(.top, 8) + } + .formStyle(.grouped) + .fixedSize(horizontal: false, vertical: true) + .onChange(of: connectAddress) { + let (a, p) = Server.parseServerAddressAndPort(connectAddress) + server.address = a + server.port = p + } + .onChange(of: connectLogin) { + server.login = connectLogin.trimmingCharacters(in: .whitespacesAndNewlines) + } + .onChange(of: connectPassword) { + server.password = connectPassword + } + .onAppear { + focusedField = .address } .frame(maxWidth: 380) .padding() @@ -346,8 +352,7 @@ struct ServerView: View { if !name.isEmpty { connectNameSheetPresented = false connectName = "" -// Task.detached { - + let (host, port) = Server.parseServerAddressAndPort(connectAddress) let login: String? = connectLogin.isEmpty ? nil : connectLogin let password: String? = connectPassword.isEmpty ? nil : connectPassword @@ -356,8 +361,6 @@ struct ServerView: View { let newBookmark = Bookmark(type: .server, name: name, address: host, port: port, login: login, password: password) Bookmark.add(newBookmark, context: modelContext) } - -// } } } } diff --git a/Hotline/macOS/Trackers/TrackerView.swift b/Hotline/macOS/Trackers/TrackerView.swift index 0eb4a12..dbbacaa 100644 --- a/Hotline/macOS/Trackers/TrackerView.swift +++ b/Hotline/macOS/Trackers/TrackerView.swift @@ -405,14 +405,13 @@ struct TrackerView: View { @ViewBuilder func bookmarkServerContextMenu(_ server: BookmarkServer) -> some View { Button { - 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) + NSPasteboard.general.clearContents() + let displayAddress = (server.port == HotlinePorts.DefaultServerPort) ? server.address : "\(server.address):\(server.port)" + NSPasteboard.general.setString("hotline://\(displayAddress)", forType: .string) } label: { - Label("Bookmark", systemImage: "bookmark") + Label("Copy Link", systemImage: "link") } - Divider() - Button { NSPasteboard.general.clearContents() let displayAddress = (server.port == HotlinePorts.DefaultServerPort) ? server.address : "\(server.address):\(server.port)" @@ -420,10 +419,30 @@ struct TrackerView: View { } label: { Label("Copy Address", systemImage: "doc.on.doc") } + + Divider() + + Button { + 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") + } } @ViewBuilder func bookmarkContextMenu(_ bookmark: Bookmark) -> some View { + Button { + let linkString: String = switch bookmark.type { + case .tracker: "hotlinetracker://\(bookmark.displayAddress)" + case .server: "hotline://\(bookmark.displayAddress)" + } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(linkString, forType: .string) + } label: { + Label("Copy Link", systemImage: "link") + } + Button { NSPasteboard.general.clearContents() NSPasteboard.general.setString(bookmark.displayAddress, forType: .string) @@ -471,14 +490,13 @@ struct TrackerView: View { 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) + NSPasteboard.general.clearContents() + let displayAddress = (server.port == HotlinePorts.DefaultServerPort) ? server.address : "\(server.address):\(server.port)" + NSPasteboard.general.setString("hotline://\(displayAddress)", forType: .string) } label: { - Label("Bookmark", systemImage: "bookmark") + Label("Copy Link", systemImage: "link") } - Divider() - Button { guard let server = bonjourServer.server else { return @@ -488,6 +506,18 @@ struct TrackerView: View { } label: { Label("Copy Address", systemImage: "doc.on.doc") } + + Divider() + + 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") + } } func refresh() { -- cgit