aboutsummaryrefslogtreecommitdiff
path: root/Hotline
diff options
context:
space:
mode:
authorDustin Mierau <dustin@mierau.me>2025-10-26 23:06:59 -0700
committerDustin Mierau <dustin@mierau.me>2025-10-26 23:06:59 -0700
commitb7e909cccfaa8611522f488134f9f28f643b83eb (patch)
tree688db0c0db74248c8a8a8ab6d63a508c6fb41c9d /Hotline
parent0220cb22c0d92b76233f57d3f32462ffbefdf354 (diff)
Rewrite HotlineTrackerClient using new async NetSocket as proof of concept.
Diffstat (limited to 'Hotline')
-rw-r--r--Hotline/Hotline/HotlineTrackerClient.swift389
-rw-r--r--Hotline/Library/NetSocketNew.swift37
-rw-r--r--Hotline/Models/Bookmark.swift7
-rw-r--r--Hotline/Models/Hotline.swift29
-rw-r--r--Hotline/macOS/TrackerView.swift56
5 files changed, 319 insertions, 199 deletions
diff --git a/Hotline/Hotline/HotlineTrackerClient.swift b/Hotline/Hotline/HotlineTrackerClient.swift
index 3c1ad85..110ca21 100644
--- a/Hotline/Hotline/HotlineTrackerClient.swift
+++ b/Hotline/Hotline/HotlineTrackerClient.swift
@@ -5,224 +5,259 @@ struct HotlineTracker: Identifiable, Equatable {
let id: UUID = UUID()
var address: String
var port: UInt16
-
+
init(_ address: String, port: UInt16 = 5498) {
self.address = address
self.port = port
}
-
+
static func == (lhs: HotlineTracker, rhs: HotlineTracker) -> Bool {
return lhs.address == rhs.address && lhs.port == rhs.port
}
}
-enum HotlineTrackerStatus: Int {
- case disconnected
- case connecting
- case connected
-}
-
-private enum HotlineTrackerStage {
- case magic
- case header
- case listing
- case done
-}
+/// Client for Hotline trackers
+///
+/// The tracker protocol:
+/// 1. Client sends magic: "HTRK" + version (0x0001)
+/// 2. Server echoes magic back
+/// 3. Server sends header: message type, data length, server count
+/// 4. Server sends listing: array of server records
class HotlineTrackerClient {
static let MagicPacket: [UInt8] = [
0x48, 0x54, 0x52, 0x4B, // 'HTRK'
0x00, 0x01 // Version
]
-
+
private var tracker: HotlineTracker
- private var connectionStatus: HotlineTrackerStatus = .disconnected
- private var servers: [HotlineServer] = []
- private var socket: NetSocket = NetSocket()
- private var stage: HotlineTrackerStage = .magic
-
- private var serverAddress: String
- private var serverPort: Int
- private var expectedDataLength: Int = 0
- private var serverCount: Int = 0
-
- private var fetchContinuation: CheckedContinuation<[HotlineServer], any Error>?
-
init() {
- let t = HotlineTracker("hltracker.com")
- self.tracker = t
- self.serverAddress = t.address
- self.serverPort = Int(t.port)
- self.socket.delegate = self
+ self.tracker = HotlineTracker("hltracker.com")
}
-
+
init(tracker: HotlineTracker) {
self.tracker = tracker
- self.serverAddress = tracker.address
- self.serverPort = Int(tracker.port)
- self.socket.delegate = self
}
-
- @MainActor func fetchServers(address: String, port: Int) async throws -> [HotlineServer] {
- self.serverAddress = address
- self.serverPort = Int(port)
-
- self.reset()
-
- return try await withCheckedThrowingContinuation { [weak self] continuation in
- self?.fetchContinuation = continuation
- self?.connect()
+
+ /// Fetch server list from the tracker
+ /// - Parameters:
+ /// - address: Tracker hostname or IP
+ /// - port: Tracker port (default: 5498)
+ /// - Returns: AsyncThrowingStream of servers as they arrive from the tracker
+ /// - Throws: Network or protocol errors
+ func fetchServers(address: String, port: Int) -> AsyncThrowingStream<HotlineServer, Error> {
+ return AsyncThrowingStream { continuation in
+ let task = Task {
+ await self.fetchServersInternal(address: address, port: port, continuation: continuation)
+ }
+
+ continuation.onTermination = { @Sendable _ in
+ task.cancel()
+ }
}
}
-
- @MainActor func close() {
- self.socket.close()
- }
-
- // MARK: -
-
- @MainActor private func reset() {
- self.expectedDataLength = 0
- self.serverCount = 0
- self.servers = []
- }
-
- @MainActor private func connect() {
- self.socket.close()
-
- self.connectionStatus = .connecting
- self.socket.connect(host: self.serverAddress, port: self.serverPort)
- self.socket.write(HotlineTrackerClient.MagicPacket)
- }
-
- @MainActor private func receiveMagic() {
- guard self.stage == .magic, self.socket.available >= HotlineTrackerClient.MagicPacket.count else {
- return
- }
-
- let magic: [UInt8] = self.socket.read(count: HotlineTrackerClient.MagicPacket.count)
-
- if magic != HotlineTrackerClient.MagicPacket {
- self.socket.close()
- return
+
+ private func fetchServersInternal(address: String, port: Int, continuation: AsyncThrowingStream<HotlineServer, Error>.Continuation) async {
+ do {
+ // Add timeout wrapper
+ try await withTimeout(seconds: 30) {
+ try await self.doFetch(address: address, port: port, continuation: continuation)
+ }
+ } catch {
+ print("HotlineTrackerClient: Error in fetchServersInternal: \(error)")
+ continuation.finish(throwing: error)
}
-
- self.stage = .header
- self.receiveHeader()
}
-
- @MainActor private func receiveHeader() {
- guard self.stage == .header, self.socket.available >= 8 else {
- return
- }
-
- var header: [UInt8] = self.socket.read(count: 8)
- guard let messageType = header.consumeUInt16(),
- let dataLength = header.consumeUInt16(),
- let numberOfServers = header.consumeUInt16(),
- let numberOfServers2 = header.consumeUInt16() else {
- self.socket.close()
- return
+ private func doFetch(address: String, port: Int, continuation: AsyncThrowingStream<HotlineServer, Error>.Continuation) async throws {
+ // Connect to tracker (plaintext, no TLS)
+ let socket = try await NetSocketNew.connect(
+ host: address,
+ port: UInt16(port),
+ tls: .disabled
+ )
+ defer { Task { await socket.close() } }
+
+ // Send magic packet
+ try await socket.write(Data(HotlineTrackerClient.MagicPacket))
+
+ // Receive magic response (6 bytes: 'HTRK' + version)
+ let magicResponse = try await socket.readExactly(6)
+ let magic = magicResponse[0..<4]
+ let version = UInt16(magicResponse[4]) << 8 | UInt16(magicResponse[5])
+
+ // Validate magic ('HTRK')
+ guard magic == Data([0x48, 0x54, 0x52, 0x4B]) else {
+ throw NetSocketError.decodeFailed(
+ NSError(domain: "HotlineTracker", code: 1, userInfo: [
+ NSLocalizedDescriptionKey: "Invalid magic response from tracker (expected 'HTRK')"
+ ])
+ )
}
-
- print("HotlineTrackerClient: Received response header ", messageType, dataLength, numberOfServers, numberOfServers2)
-
- self.expectedDataLength = Int(dataLength)
- self.expectedDataLength -= 4 // Remove the size of the two server count fields
- self.serverCount = Int(numberOfServers)
-
- self.stage = .listing
- self.receiveListing()
+
+ print("HotlineTrackerClient: Connected to tracker (version \(version))")
+
+ // Read server listings (may span multiple batches)
+ var totalYielded = 0
+ var totalEntriesParsed = 0 // Includes separators
+ var totalExpectedEntries: Int = 0
+ var batchCount = 0
+
+ repeat {
+ batchCount += 1
+
+ // Receive server information header (8 bytes)
+ // Format: [message type: u16][data length: u16][server count: u16][server count 2: u16]
+ let messageType = try await socket.read(UInt16.self, endian: .big)
+ let dataLength = try await socket.read(UInt16.self, endian: .big)
+ let serverCount = try await socket.read(UInt16.self, endian: .big)
+ let serverCount2 = try await socket.read(UInt16.self, endian: .big)
+
+ // First header tells us the total expected entries (includes separators)
+ if totalExpectedEntries == 0 {
+ totalExpectedEntries = Int(serverCount)
+ }
+
+ print("HotlineTrackerClient: Batch #\(batchCount) - type: \(messageType), dataLen: \(dataLength), count1: \(serverCount), count2: \(serverCount2)")
+
+ // Calculate actual listing data length (excludes the two server count fields)
+ let listingLength = Int(dataLength) - 4
+ print("HotlineTrackerClient: About to read \(listingLength) bytes of listing data...")
+
+ // Receive listing data
+ let listingData = try await socket.readExactly(listingLength)
+ print("HotlineTrackerClient: Successfully read \(listingData.count) bytes")
+
+ // Parse and yield servers one at a time for true progressive streaming
+ var bytes = Array(listingData)
+ let trackerSeparatorRegex = /^[-]+$/
+ var batchEntriesParsed = 0
+ var batchServersYielded = 0
+
+ for _ in 0..<Int(serverCount2) {
+ guard
+ let ip1 = bytes.consumeUInt8(),
+ let ip2 = bytes.consumeUInt8(),
+ let ip3 = bytes.consumeUInt8(),
+ let ip4 = bytes.consumeUInt8(),
+ let port = bytes.consumeUInt16(),
+ let userCount = bytes.consumeUInt16(),
+ bytes.consume(2), // Skip unused bytes
+ let serverName = bytes.consumePString(),
+ let serverDescription = bytes.consumePString()
+ else {
+ print("HotlineTrackerClient: Insufficient data for entry #\(batchEntriesParsed + 1)")
+ break
+ }
+
+ batchEntriesParsed += 1
+ totalEntriesParsed += 1
+
+ // Filter out separator entries (servers with names like "-------")
+ let isSeparator = (try? trackerSeparatorRegex.prefixMatch(in: serverName)) != nil
+ if !isSeparator {
+ let server = HotlineServer(
+ address: "\(ip1).\(ip2).\(ip3).\(ip4)",
+ port: port,
+ users: userCount,
+ name: serverName,
+ description: serverDescription
+ )
+ continuation.yield(server)
+ batchServersYielded += 1
+ totalYielded += 1
+ }
+ }
+
+ print("HotlineTrackerClient: Batch #\(batchCount): parsed \(batchEntriesParsed) entries, yielded \(batchServersYielded) servers (filtered \(batchEntriesParsed - batchServersYielded) separators)")
+ print("HotlineTrackerClient: Progress: \(totalEntriesParsed)/\(totalExpectedEntries) entries, \(totalYielded) servers yielded")
+
+ // Safety: don't loop forever
+ if batchCount >= 100 {
+ print("HotlineTrackerClient: WARNING - Stopped after 100 batches")
+ break
+ }
+
+ } while totalEntriesParsed < totalExpectedEntries
+
+ print("HotlineTrackerClient: Completed - parsed \(totalEntriesParsed)/\(totalExpectedEntries) entries, yielded \(totalYielded) servers")
+ continuation.finish()
}
-
- @MainActor private func receiveListing() {
- guard self.stage == .listing, self.socket.available >= self.expectedDataLength else {
- return
+
+ private func withTimeout<T>(seconds: TimeInterval, operation: @escaping () async throws -> T) async throws -> T {
+ try await withThrowingTaskGroup(of: T.self) { group in
+ group.addTask {
+ try await operation()
+ }
+
+ group.addTask {
+ try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
+ throw NSError(domain: "HotlineTracker", code: 2, userInfo: [
+ NSLocalizedDescriptionKey: "Tracker request timed out after \(seconds) seconds"
+ ])
+ }
+
+ let result = try await group.next()!
+ group.cancelAll()
+ return result
}
-
- self.parseListing(self.socket.read(count: self.expectedDataLength))
}
-
- @MainActor private func parseListing(_ listingBytes: [UInt8]) {
- // IP address (4 bytes)
- // Port number (2 bytes)
- // Number of users (2 bytes)
- // Unused (2 bytes)
- // Name size (1 byte)
- // Name (name size)
- // Description size (1 byte)
- // Description (description size)
-
- var bytes: [UInt8] = listingBytes
+
+ /// Parse tracker listing data into array of servers
+ /// - Parameters:
+ /// - data: Raw listing data
+ /// - serverCount: Expected number of entries (including separators)
+ /// - Returns: Tuple of (servers array, total entries parsed including separators)
+ /// - Throws: Parsing errors
+ private func parseListing(_ data: Data, serverCount: Int) throws -> (servers: [HotlineServer], entriesParsed: Int) {
+ // Server record format:
+ // - IP address (4 bytes)
+ // - Port number (2 bytes)
+ // - Number of users (2 bytes)
+ // - Unused (2 bytes)
+ // - Name size (1 byte)
+ // - Name (name size)
+ // - Description size (1 byte)
+ // - Description (description size)
+
+ var bytes = Array(data)
let trackerSeparatorRegex = /^[-]+$/
- var foundServers: [HotlineServer] = []
-
- for _ in 1...self.serverCount {
+ var servers: [HotlineServer] = []
+ var entriesParsed = 0
+
+ for _ in 0..<serverCount {
guard
- let ip_1 = bytes.consumeUInt8(),
- let ip_2 = bytes.consumeUInt8(),
- let ip_3 = bytes.consumeUInt8(),
- let ip_4 = bytes.consumeUInt8(),
+ let ip1 = bytes.consumeUInt8(),
+ let ip2 = bytes.consumeUInt8(),
+ let ip3 = bytes.consumeUInt8(),
+ let ip4 = bytes.consumeUInt8(),
let port = bytes.consumeUInt16(),
let userCount = bytes.consumeUInt16(),
- bytes.consume(2),
+ bytes.consume(2), // Skip unused bytes
let serverName = bytes.consumePString(),
- let serverDescription = bytes.consumePString() else {
- print("HotlineTrackerClient: Data isn't long enough for next server")
+ let serverDescription = bytes.consumePString()
+ else {
+ print("HotlineTrackerClient: Insufficient data for server #\(entriesParsed + 1)")
break
}
-
- // Ignore servers that are just used as dividers in the tracker listing.
- let validName = try? trackerSeparatorRegex.prefixMatch(in: serverName)
- if validName == nil {
- let server = HotlineServer(address: "\(ip_1).\(ip_2).\(ip_3).\(ip_4)", port: port, users: userCount, name: serverName, description: serverDescription)
- foundServers.append(server)
- }
- }
-
- self.servers = foundServers
- self.stage = .done
- self.socket.close()
- }
-}
-// MARK: -
+ entriesParsed += 1
-extension HotlineTrackerClient: NetSocketDelegate {
- @MainActor func netsocketConnected(socket: NetSocket) {
- self.connectionStatus = .connected
- }
-
- @MainActor func netsocketDisconnected(socket: NetSocket, error: Error?) {
- self.stage = .magic
- self.connectionStatus = .disconnected
-
- let servers = self.servers
- self.reset()
-
- if let continuation = self.fetchContinuation {
- self.fetchContinuation = nil
- if let err = error {
- continuation.resume(throwing: err)
- }
- else {
- continuation.resume(returning: servers)
+ // Filter out separator entries (servers with names like "-------")
+ let isSeparator = (try? trackerSeparatorRegex.prefixMatch(in: serverName)) != nil
+ if !isSeparator {
+ let server = HotlineServer(
+ address: "\(ip1).\(ip2).\(ip3).\(ip4)",
+ port: port,
+ users: userCount,
+ name: serverName,
+ description: serverDescription
+ )
+ servers.append(server)
}
}
- }
-
- @MainActor func netsocketReceived(socket: NetSocket, bytes: [UInt8]) {
- switch self.stage {
- case .magic:
- self.receiveMagic()
- case .header:
- self.receiveHeader()
- case .listing:
- self.receiveListing()
- case .done:
- break
- }
+
+ return (servers: servers, entriesParsed: entriesParsed)
}
}
diff --git a/Hotline/Library/NetSocketNew.swift b/Hotline/Library/NetSocketNew.swift
index 776a371..909c44d 100644
--- a/Hotline/Library/NetSocketNew.swift
+++ b/Hotline/Library/NetSocketNew.swift
@@ -313,7 +313,9 @@ public actor NetSocketNew {
private nonisolated func startReceiveLoop() {
func loop(_ connection: NWConnection, chunk: Int, owner: NetSocketNew) {
+ print("NetSocketNew: Calling connection.receive() to request more data...")
connection.receive(minimumIncompleteLength: 1, maximumLength: chunk) { data, _, isComplete, error in
+ print("NetSocketNew: Receive callback - data: \(data?.count ?? 0) bytes, isComplete: \(isComplete), error: \(String(describing: error))")
if let error {
Task { await owner.handleReceiveError(error) }
return
@@ -344,9 +346,10 @@ public actor NetSocketNew {
}
private func append(_ data: Data) {
+ print("NetSocketNew: Received \(data.count) bytes from network, buffer now has \(buffer.count - head + data.count) available")
buffer.append(data)
if buffer.count - head > cfg.maxBufferBytes {
- // Hard stop: drop connection rather than OOM’ing.
+ // Hard stop: drop connection rather than OOM'ing.
isClosed = true
connection.cancel()
failAllWaiters(NetSocketError.framingExceeded(max: cfg.maxBufferBytes))
@@ -363,10 +366,13 @@ public actor NetSocketNew {
// MARK: Close
- /// Close the connection
+ /// Close the connection gracefully
///
- /// Cancels the underlying network connection and wakes all pending read/write operations
- /// with a `NetSocketError.closed` error. This method is idempotent.
+ /// Performs a graceful shutdown of the underlying network connection (e.g., TCP FIN)
+ /// and wakes all pending read/write operations with a `NetSocketError.closed` error.
+ /// This method is idempotent - subsequent calls are ignored.
+ ///
+ /// Use `forceClose()` for immediate non-graceful termination (e.g., TCP RST).
public func close() {
guard !isClosed else { return }
isClosed = true
@@ -375,6 +381,21 @@ public actor NetSocketNew {
resumeReadyWaiters(with: .failure(NetSocketError.closed))
}
+ /// Force close the connection immediately (non-graceful)
+ ///
+ /// Performs an immediate non-graceful shutdown of the underlying network connection
+ /// (e.g., TCP RST). Use this when you need to terminate the connection immediately
+ /// without waiting for graceful closure. For normal shutdown, use `close()` instead.
+ ///
+ /// This method is idempotent - subsequent calls are ignored.
+ public func forceClose() {
+ guard !isClosed else { return }
+ isClosed = true
+ connection.forceCancel()
+ resumeDataWaiters()
+ resumeReadyWaiters(with: .failure(NetSocketError.closed))
+ }
+
// MARK: Send (async)
/// Write raw data to the socket
@@ -597,6 +618,7 @@ public actor NetSocketNew {
/// - Throws: `NetSocketError.framingExceeded` if max bytes exceeded, or connection errors
public func readUntil(delimiter: Data, maxBytes: Int? = nil, includeDelimiter: Bool = false) async throws -> Data {
while true {
+ try Task.checkCancellation()
if let r = search(delimiter: delimiter) {
let consumeLen = r.upperBound - head
let data = try await readExactly(consumeLen)
@@ -639,6 +661,7 @@ public actor NetSocketNew {
/// Skip until delimiter is found (discards delimiter too)
public func skipUntil(delimiter: Data) async throws {
while true {
+ try Task.checkCancellation()
if let r = search(delimiter: delimiter) {
head = r.upperBound // Skip to end of delimiter
compactIfNeeded()
@@ -660,8 +683,9 @@ public actor NetSocketNew {
private var availableBytes: Int { buffer.count - head }
private func waitForData() async throws {
+ try Task.checkCancellation()
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
- if availableBytes > 0 || isClosed { cont.resume(); return }
+ if isClosed { cont.resume(); return }
dataWaiters.append(cont)
}
}
@@ -669,6 +693,7 @@ public actor NetSocketNew {
private func ensureReadable(_ count: Int) async throws {
try await ensureReady()
while availableBytes < count {
+ try Task.checkCancellation()
if isClosed { throw NetSocketError.insufficientData(expected: count, got: availableBytes) }
try await waitForData()
}
@@ -945,7 +970,7 @@ public extension NetSocketNew {
try Task.checkCancellation()
let n = Int(min(Int64(chunkSize), remaining))
let chunk = try await readExactly(n)
- try fh.write(chunk)
+ fh.write(chunk)
remaining -= Int64(n)
written += Int64(n)
progress?(.init(sent: written, total: length))
diff --git a/Hotline/Models/Bookmark.swift b/Hotline/Models/Bookmark.swift
index 80963f9..6063f48 100644
--- a/Hotline/Models/Bookmark.swift
+++ b/Hotline/Models/Bookmark.swift
@@ -341,13 +341,16 @@ final class Bookmark {
var fetchedBookmarks: [BookmarkServer] = []
let client = HotlineTrackerClient()
- if let fetchedServers: [HotlineServer] = try? await client.fetchServers(address: self.address, port: self.port) {
- for fetchedServer in fetchedServers {
+ do {
+ for try await fetchedServer in client.fetchServers(address: self.address, port: self.port) {
+ print("FETCHED SERVER", fetchedServer)
if let serverName = fetchedServer.name {
let server = Server(name: serverName, description: fetchedServer.description, address: fetchedServer.address, port: Int(fetchedServer.port), users: Int(fetchedServer.users))
fetchedBookmarks.append(BookmarkServer(server: server))
}
}
+ } catch {
+ print("Failed to fetch servers from tracker: \(error)")
}
return fetchedBookmarks
diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift
index 3d1f309..596df68 100644
--- a/Hotline/Models/Hotline.swift
+++ b/Hotline/Models/Hotline.swift
@@ -221,20 +221,35 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega
@MainActor func getServerList(tracker: String, port: Int = HotlinePorts.DefaultTrackerPort) async -> [Server] {
var servers: [Server] = []
-
- if let fetchedServers: [HotlineServer] = try? await self.trackerClient.fetchServers(address: tracker, port: port) {
- for s in fetchedServers {
- if let serverName = s.name {
- servers.append(Server(name: serverName, description: s.description, address: s.address, port: Int(s.port), users: Int(s.users)))
+ print("Hotline.getServerList: Starting fetch from \(tracker):\(port)")
+
+ do {
+ for try await hotlineServer in self.trackerClient.fetchServers(address: tracker, port: port) {
+ if let serverName = hotlineServer.name {
+ servers.append(Server(
+ name: serverName,
+ description: hotlineServer.description,
+ address: hotlineServer.address,
+ port: Int(hotlineServer.port),
+ users: Int(hotlineServer.users)
+ ))
+ if servers.count % 10 == 0 {
+ print("Hotline.getServerList: Collected \(servers.count) servers so far...")
+ }
}
}
+ } catch {
+ print("Hotline.getServerList: Error - \(error)")
}
-
+
+ print("Hotline.getServerList: Returning \(servers.count) servers")
return servers
}
@MainActor func disconnectTracker() {
- self.trackerClient.close()
+ // No-op: HotlineTrackerClient now uses async/await and manages
+ // connections internally. Each fetchServers() call opens and closes
+ // its own connection automatically.
}
@MainActor func login(server: Server, username: String, iconID: Int, callback: ((Bool) -> Void)? = nil) {
diff --git a/Hotline/macOS/TrackerView.swift b/Hotline/macOS/TrackerView.swift
index 5e848de..ae653b8 100644
--- a/Hotline/macOS/TrackerView.swift
+++ b/Hotline/macOS/TrackerView.swift
@@ -32,6 +32,7 @@ struct TrackerView: View {
@State private var expandedTrackers: Set<Bookmark> = []
@State private var trackerServers: [Bookmark: [BookmarkServer]] = [:]
@State private var loadingTrackers: Set<Bookmark> = []
+ @State private var fetchTasks: [Bookmark: Task<Void, Never>] = [:]
@State private var searchText: String = ""
@State private var isSearching = false
@@ -67,6 +68,7 @@ struct TrackerView: View {
private func filteredServers(for bookmark: Bookmark) -> [BookmarkServer] {
let servers = self.trackerServers[bookmark] ?? []
+ print("TrackerView.filteredServers: Looking up servers for \(bookmark.name), found \(servers.count) servers")
guard !self.searchText.isEmpty else {
return servers
@@ -90,7 +92,8 @@ struct TrackerView: View {
TrackerItemView(
bookmark: bookmark,
isExpanded: self.expandedTrackers.contains(bookmark),
- isLoading: self.loadingTrackers.contains(bookmark)
+ isLoading: self.loadingTrackers.contains(bookmark),
+ count: self.trackerServers[bookmark]?.count ?? 0
) {
self.toggleExpanded(for: bookmark)
}
@@ -410,10 +413,12 @@ struct TrackerView: View {
case .bookmark(let bookmark):
if bookmark.type == .tracker {
if self.expandedTrackers.contains(bookmark) {
- // Already expanded, just refresh the servers
- Task {
+ // Already expanded, cancel old fetch and start new one
+ self.fetchTasks[bookmark]?.cancel()
+ let task = Task {
await self.fetchServers(for: bookmark)
}
+ self.fetchTasks[bookmark] = task
} else {
// Not expanded, expand it (which also fetches)
self.setExpanded(true, for: bookmark)
@@ -430,10 +435,12 @@ struct TrackerView: View {
for bookmark in self.bookmarks {
if bookmark.type == .tracker {
if self.expandedTrackers.contains(bookmark) {
- // Already expanded, just refresh the servers
- Task {
+ // Already expanded, cancel old fetch and start new one
+ self.fetchTasks[bookmark]?.cancel()
+ let task = Task {
await self.fetchServers(for: bookmark)
}
+ self.fetchTasks[bookmark] = task
} else {
// Not expanded, expand it (which also fetches)
self.setExpanded(true, for: bookmark)
@@ -446,13 +453,19 @@ struct TrackerView: View {
guard bookmark.type == .tracker else { return }
if self.expandedTrackers.contains(bookmark) {
+ // Collapse: cancel ongoing fetch and clear data
+ self.fetchTasks[bookmark]?.cancel()
+ self.fetchTasks[bookmark] = nil
self.expandedTrackers.remove(bookmark)
self.trackerServers[bookmark] = nil
+ self.loadingTrackers.remove(bookmark)
} else {
+ // Expand: start fetch task
self.expandedTrackers.insert(bookmark)
- Task {
+ let task = Task {
await self.fetchServers(for: bookmark)
}
+ self.fetchTasks[bookmark] = task
}
}
@@ -461,21 +474,32 @@ struct TrackerView: View {
if expanded && !self.expandedTrackers.contains(bookmark) {
self.expandedTrackers.insert(bookmark)
- Task {
+ let task = Task {
await self.fetchServers(for: bookmark)
}
+ self.fetchTasks[bookmark] = task
} else if !expanded && self.expandedTrackers.contains(bookmark) {
+ // Cancel ongoing fetch and clear data
+ self.fetchTasks[bookmark]?.cancel()
+ self.fetchTasks[bookmark] = nil
self.expandedTrackers.remove(bookmark)
self.trackerServers[bookmark] = nil
+ self.loadingTrackers.remove(bookmark)
}
}
private func fetchServers(for bookmark: Bookmark) async {
+ print("TrackerView.fetchServers: Starting fetch for bookmark: \(bookmark.name)")
self.loadingTrackers.insert(bookmark)
let servers = await bookmark.fetchServers()
+ print("TrackerView.fetchServers: Got \(servers.count) servers from bookmark.fetchServers()")
await MainActor.run {
+ print("TrackerView.fetchServers: Assigning \(servers.count) servers to trackerServers[\(bookmark.name)]")
self.trackerServers[bookmark] = servers
self.loadingTrackers.remove(bookmark)
+ self.fetchTasks[bookmark] = nil // Clean up completed task
+ print("TrackerView.fetchServers: trackerServers now has \(self.trackerServers.count) entries")
+ print("TrackerView.fetchServers: Verification - trackerServers[\(bookmark.name)] now has \(self.trackerServers[bookmark]?.count ?? -1) servers")
}
}
}
@@ -694,6 +718,7 @@ struct TrackerItemView: View {
let bookmark: Bookmark
let isExpanded: Bool
let isLoading: Bool
+ let count: Int
let onToggleExpanded: () -> Void
var body: some View {
@@ -727,6 +752,23 @@ struct TrackerItemView: View {
.controlSize(.small)
}
Spacer(minLength: 0)
+ if isExpanded && count > 0 {
+ HStack(spacing: 4) {
+ Text(String(count))
+
+ Image(systemName: "globe.americas.fill")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 12, height: 12)
+ .opacity(0.5)
+ }
+
+ .padding(.horizontal, 8)
+ .padding(.vertical, 2)
+ .foregroundStyle(.secondary)
+ .background(.quinary)
+ .clipShape(.capsule)
+ }
case .server:
Image(systemName: "bookmark.fill")
.resizable()