aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Hotline.xcodeproj/project.pbxproj2
-rw-r--r--Hotline/Library/NetSocket/NetSocket.swift19
-rw-r--r--Hotline/Models/Server.swift50
-rw-r--r--Hotline/State/BonjourState.swift22
-rw-r--r--Hotline/macOS/ServerView.swift151
-rw-r--r--Hotline/macOS/Trackers/TrackerView.swift50
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() {