diff options
| -rw-r--r-- | Hotline.xcodeproj/project.pbxproj | 2 | ||||
| -rw-r--r-- | Hotline/Library/NetSocket/NetSocket.swift | 19 | ||||
| -rw-r--r-- | Hotline/Models/Server.swift | 50 | ||||
| -rw-r--r-- | Hotline/State/BonjourState.swift | 22 | ||||
| -rw-r--r-- | Hotline/macOS/ServerView.swift | 151 | ||||
| -rw-r--r-- | Hotline/macOS/Trackers/TrackerView.swift | 50 |
6 files changed, 195 insertions, 99 deletions
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 = "<group>"; 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)..<closeBracketIndex]) + + // Check if there's a port after the bracket + if hostEndIndex < trimmed.endIndex && trimmed[closeBracketIndex...].contains(":") { + let portString = trimmed[trimmed.index(after: hostEndIndex)...] + if let port = Int(portString) { + return (host.lowercased(), port) + } + } + + return (host.lowercased(), HotlinePorts.DefaultServerPort) + } + } + + // Check if it's an IPv6 address without brackets + // IPv6 addresses have multiple colons, IPv4:port has only one + // Note: IPv6 may also have a zone identifier like fe80::1%en1 + let colonCount = trimmed.filter { $0 == ":" }.count + if colonCount > 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,11 +419,31 @@ 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) } label: { @@ -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() { |