diff options
| author | Dustin Mierau <dustin@mierau.me> | 2025-10-26 23:06:59 -0700 |
|---|---|---|
| committer | Dustin Mierau <dustin@mierau.me> | 2025-10-26 23:06:59 -0700 |
| commit | b7e909cccfaa8611522f488134f9f28f643b83eb (patch) | |
| tree | 688db0c0db74248c8a8a8ab6d63a508c6fb41c9d /Hotline | |
| parent | 0220cb22c0d92b76233f57d3f32462ffbefdf354 (diff) | |
Rewrite HotlineTrackerClient using new async NetSocket as proof of concept.
Diffstat (limited to 'Hotline')
| -rw-r--r-- | Hotline/Hotline/HotlineTrackerClient.swift | 389 | ||||
| -rw-r--r-- | Hotline/Library/NetSocketNew.swift | 37 | ||||
| -rw-r--r-- | Hotline/Models/Bookmark.swift | 7 | ||||
| -rw-r--r-- | Hotline/Models/Hotline.swift | 29 | ||||
| -rw-r--r-- | Hotline/macOS/TrackerView.swift | 56 |
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() |