aboutsummaryrefslogtreecommitdiff
path: root/Hotline
diff options
context:
space:
mode:
authorDustin Mierau <dustin@mierau.me>2025-11-07 21:03:53 -0800
committerDustin Mierau <dustin@mierau.me>2025-11-07 21:03:53 -0800
commit2f332ee497af925db1a2583135e9dfcdaff84794 (patch)
treeff22f357a87cfa9dd577b4273f7f62a89906d83b /Hotline
parent637cd9af54a58db0c5dc2062b6c20291afd31147 (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.swift9
-rw-r--r--Hotline/State/AppState.swift2
-rw-r--r--Hotline/State/BonjourState.swift256
-rw-r--r--Hotline/macOS/Trackers/BonjourServerRow.swift19
-rw-r--r--Hotline/macOS/Trackers/TrackerItemView.swift2
-rw-r--r--Hotline/macOS/Trackers/TrackerView.swift114
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 {