diff options
| author | Dustin Mierau <dustin@mierau.me> | 2024-01-17 23:18:27 -0800 |
|---|---|---|
| committer | Dustin Mierau <dustin@mierau.me> | 2024-01-17 23:18:27 -0800 |
| commit | 1b303803e53dbeb24dab1a7c714d71ee14f002db (patch) | |
| tree | 4616861b23642d26c9b58b5925315232c1fdb975 /Hotline | |
| parent | 8e85fbbdadcb993cd270ff4bf60d2280b9e7ad56 (diff) | |
Added server and tracker bookmarks. Reordering in Servers window. Delete. All stored securely in Keychain.
Diffstat (limited to 'Hotline')
| -rw-r--r-- | Hotline/Application.swift | 5 | ||||
| -rw-r--r-- | Hotline/Assets.xcassets/hotlineRed.colorset/Contents.json | 38 | ||||
| -rw-r--r-- | Hotline/Models/Bookmarks.swift | 126 | ||||
| -rw-r--r-- | Hotline/Models/Hotline.swift | 67 | ||||
| -rw-r--r-- | Hotline/Models/Tracker.swift | 7 | ||||
| -rw-r--r-- | Hotline/Utility/DAKeychain.swift | 81 | ||||
| -rw-r--r-- | Hotline/iOS/TrackerView.swift | 6 | ||||
| -rw-r--r-- | Hotline/macOS/ServerView.swift | 87 | ||||
| -rw-r--r-- | Hotline/macOS/TrackerView.swift | 568 |
9 files changed, 639 insertions, 346 deletions
diff --git a/Hotline/Application.swift b/Hotline/Application.swift index fdada40..a8b4098 100644 --- a/Hotline/Application.swift +++ b/Hotline/Application.swift @@ -14,6 +14,7 @@ struct Application: App { @State private var preferences = Prefs() @State private var soundEffects = SoundEffectPlayer() + @State private var bookmarks = Bookmarks() @FocusedValue(\.activeHotlineModel) private var activeHotline: Hotline? @FocusedValue(\.activeServerState) private var activeServerState: ServerState? @@ -29,6 +30,7 @@ struct Application: App { Window("Servers", id: "servers") { TrackerView() .frame(minWidth: 250, minHeight: 250) + .environment(bookmarks) } .keyboardShortcut(.init(.init("R"), modifiers: .command)) .defaultSize(width: 700, height: 550) @@ -37,9 +39,10 @@ struct Application: App { // MARK: Server Window WindowGroup(id: "server", for: Server.self) { server in ServerView(server: server) - .frame(minWidth: 400, minHeight: 300) + .frame(minWidth: 430, minHeight: 300) .environment(preferences) .environment(soundEffects) + .environment(bookmarks) } defaultValue: { Server(name: nil, description: nil, address: "") } diff --git a/Hotline/Assets.xcassets/hotlineRed.colorset/Contents.json b/Hotline/Assets.xcassets/hotlineRed.colorset/Contents.json new file mode 100644 index 0000000..1f6e488 --- /dev/null +++ b/Hotline/Assets.xcassets/hotlineRed.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x00", + "green" : "0x00", + "red" : "0xE1" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x00", + "green" : "0x00", + "red" : "0xE1" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Hotline/Models/Bookmarks.swift b/Hotline/Models/Bookmarks.swift new file mode 100644 index 0000000..3f4971c --- /dev/null +++ b/Hotline/Models/Bookmarks.swift @@ -0,0 +1,126 @@ +import SwiftUI + +extension NSNotification { + static let BookmarkAdded = Notification.Name("BookmarkAdded") + static let BookmarkRemoved = Notification.Name("BookmarkRemoved") +} + +enum BookmarkType: String, Codable { + case tracker = "tracker" + case server = "server" +} + +struct Bookmark: Codable, Equatable { + let type: BookmarkType + let name: String + let address: String + let port: Int + let login: String? + let password: String? + + init(type: BookmarkType, name: String, address: String, port: Int = HotlinePorts.DefaultServerPort, login: String? = nil, password: String? = nil) { + self.type = type + self.name = name + self.address = address + self.port = port + self.login = login + self.password = password + } +} + +@Observable final class Bookmarks { + var bookmarks: [Bookmark]? = nil + + static let DefaultBookmarks: [Bookmark] = [ + Bookmark(type: .server, name: "System 7 Today", address: "hotline.system7today.com"), + Bookmark(type: .server, name: "The Mobius Strip", address: "67.174.208.111"), + Bookmark(type: .tracker, name: "Featured Servers", address: "hltracker.com"), + ] + + init() { + self.load() + } + + func apply(_ newBookmarks: [Bookmark], save shouldSave: Bool = true) { + self.bookmarks = newBookmarks + if shouldSave { + self.save() + } + } + + func load() { + let jsonString: String? = DAKeychain.shared["Hotline Bookmarks"] + let jsonData: Data? = jsonString?.data(using: .utf8, allowLossyConversion: false) + + let decoder = JSONDecoder() + var decodedBookmarks = try? decoder.decode([Bookmark].self, from: jsonData ?? Data()) + if decodedBookmarks == nil || decodedBookmarks?.isEmpty == true { + print("Bookmarks: using default bookmarks") + decodedBookmarks = Bookmarks.DefaultBookmarks + } + else { + print("Bookmarks: using saved bookmarks") + } + + self.bookmarks = [Bookmark](decodedBookmarks!) + } + + func save() { + var bookmarksToSave = self.bookmarks + if bookmarksToSave == Bookmarks.DefaultBookmarks { + print("Bookmarks: skipping saving default bookmarks") + bookmarksToSave = [] + } + + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + if let jsonData = try? encoder.encode(bookmarksToSave) { + if let jsonString = String(data: jsonData, encoding: .utf8) { + DAKeychain.shared["Hotline Bookmarks"] = jsonString + } + } + } + + // MARK: - + + func add(_ bookmark: Bookmark, save shouldSave: Bool = true) { + self.bookmarks?.insert(bookmark, at: 0) + + if shouldSave { + self.save() + } + + DispatchQueue.main.async { + NotificationCenter.default.post(name: NSNotification.BookmarkAdded, object: nil, userInfo: ["index": 0]) + } + } + + func delete(_ bookmark: Bookmark, save shouldSave: Bool = true) -> Bool { + if let i = self.bookmarks?.firstIndex(where: { b in b.address.lowercased() == bookmark.address.lowercased() && b.port == bookmark.port }) { + self.bookmarks?.remove(at: i) + + if shouldSave { + self.save() + } + + DispatchQueue.main.async { + NotificationCenter.default.post(name: NSNotification.BookmarkRemoved, object: nil, userInfo: ["index": i]) + } + return true + } + + return false + } + + func update(_ bookmark: Bookmark, save shouldSave: Bool = true) -> Bool { + if let i = self.bookmarks?.firstIndex(where: { b in b.address.lowercased() == bookmark.address.lowercased() && b.port == bookmark.port }) { + self.bookmarks?[i] = bookmark + if shouldSave { + self.save() + } + return true + } + + return false + } +} diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift index 77d4d59..6b5e3a7 100644 --- a/Hotline/Models/Hotline.swift +++ b/Hotline/Models/Hotline.swift @@ -103,73 +103,6 @@ final class Hotline: HotlineClientDelegate, HotlineFileClientDelegate { 6029, 6030, 6031, 6032, 6033, 6034, 6035 ] - static let defaultIconSet: [Int: String] = [ - 414: "๐", - 2000: "๐", - 2001: "๐", - 2002: "๐ชฉ", - 2003: "๐ฅ", - 2004: "๐", - 2014: "๐", - 2006: "๐ ", - 2007: "๐ฆ ", - 2008: "๐ช", - 2009: "๐", - 2010: "๐", - 2011: "๐", - 2012: "๐ฆ", - 145: "๐", - 2015: "๐ป", - 2016: "๐ป", - 2017: "โ๏ธ", - 2018: "โก๏ธ", - 417: "๐งโโ๏ธ", - 140: "๐จ", - 141: "๐ฝ", - 142: "๐", - 143: "๐ท๏ธ", - 138: "๐บ", - 146: "๐
", - 149: "๐ฎ", - 150: "๐ฆ", - 151: "๐งป", - 154: "๐", - 182: "โ", - 207: "โ ๏ธ", - 2061: "โ๏ธ", - 2063: "๐ฎ", - 2064: "๐", - 2065: "๐", - 2066: "๐ญ", - 2067: "๐ญ", - 2013: "๐ง", - 2037: "โ ๏ธ", - 2055: "โก๏ธ", - 2400: "๐จ๐ฆ", - 2036: "โฃ๏ธ", - 4134: "๐ฆ", - 4247: "๐", - 135: "โฏ๏ธ", - 137: "๐", - 144: "๐", - 165: "๐ถ", - 166: "โค๏ธ", - 2549: "๐ฎ๐ฑ", - 2553: "๐บ๐ธ", - 2555: "๐จ๐ฆ", - 2552: "๐ฎ๐ณ", - 2556: "๐ฆ๐บ", - 2565: "๐ฌ๐ง", - 2567: "๐ฏ๐ต", - 2566: "๐ซ๐ท", - 2564: "๐ฉ๐ช", - 2563: "๐ฎ๐น", - 2550: "๐ญ๐บ", - 2551: "๐ต๐ฑ", - 2560: "๐ช๐ธ", - 2561: "๐ธ๐ช", - ] - var status: HotlineClientStatus = .disconnected var server: Server? { diff --git a/Hotline/Models/Tracker.swift b/Hotline/Models/Tracker.swift index e9961e9..171cb35 100644 --- a/Hotline/Models/Tracker.swift +++ b/Hotline/Models/Tracker.swift @@ -8,4 +8,11 @@ import SwiftUI self.address = address self.port = port } + + static func parseTrackerAddressAndPort(_ address: String) -> (String, Int) { + let url = URL(string: "hotlinetracker://\(address)") + let port = url?.port ?? HotlinePorts.DefaultTrackerPort + let host = url?.host(percentEncoded: false) ?? "" + return (host.lowercased().trimmingCharacters(in: .whitespacesAndNewlines), port) + } } diff --git a/Hotline/Utility/DAKeychain.swift b/Hotline/Utility/DAKeychain.swift new file mode 100644 index 0000000..8031886 --- /dev/null +++ b/Hotline/Utility/DAKeychain.swift @@ -0,0 +1,81 @@ +// +// DAKeychain.swift +// DAKeychain +// +// Created by Dejan on 28/02/2017. +// Copyright ยฉ 2017 Dejan. All rights reserved. +// + +import Foundation + +open class DAKeychain { + + open var loggingEnabled = false + + private init() {} + public static let shared = DAKeychain() + + open subscript(key: String) -> String? { + get { + return load(withKey: key) + } set { + DispatchQueue.global().sync(flags: .barrier) { + self.save(newValue, forKey: key) + } + } + } + + private func save(_ string: String?, forKey key: String) { + let query = keychainQuery(withKey: key) + let objectData: Data? = string?.data(using: .utf8, allowLossyConversion: false) + + if SecItemCopyMatching(query, nil) == noErr { + if let dictData = objectData { + let status = SecItemUpdate(query, NSDictionary(dictionary: [kSecValueData: dictData])) + logPrint("Update status: ", status) + } else { + let status = SecItemDelete(query) + logPrint("Delete status: ", status) + } + } else { + if let dictData = objectData { + query.setValue(dictData, forKey: kSecValueData as String) + let status = SecItemAdd(query, nil) + logPrint("Update status: ", status) + } + } + } + + private func load(withKey key: String) -> String? { + let query = keychainQuery(withKey: key) + query.setValue(kCFBooleanTrue, forKey: kSecReturnData as String) + query.setValue(kCFBooleanTrue, forKey: kSecReturnAttributes as String) + + var result: CFTypeRef? + let status = SecItemCopyMatching(query, &result) + + guard + let resultsDict = result as? NSDictionary, + let resultsData = resultsDict.value(forKey: kSecValueData as String) as? Data, + status == noErr + else { + logPrint("Load status: ", status) + return nil + } + return String(data: resultsData, encoding: .utf8) + } + + private func keychainQuery(withKey key: String) -> NSMutableDictionary { + let result = NSMutableDictionary() + result.setValue(kSecClassGenericPassword, forKey: kSecClass as String) + result.setValue(key, forKey: kSecAttrService as String) + result.setValue(kSecAttrAccessibleWhenUnlocked, forKey: kSecAttrAccessible as String) + return result + } + + private func logPrint(_ items: Any...) { + if loggingEnabled { + print(items) + } + } +} diff --git a/Hotline/iOS/TrackerView.swift b/Hotline/iOS/TrackerView.swift index a40ab03..2a3583b 100644 --- a/Hotline/iOS/TrackerView.swift +++ b/Hotline/iOS/TrackerView.swift @@ -149,15 +149,9 @@ struct TrackerConnectView: View { } struct TrackerView: View { - - // @Environment(\.modelContext) private var modelContext - // @Query private var items: [Item] - @Environment(Hotline.self) private var model: Hotline @Environment(\.colorScheme) var colorScheme - // @State private var tracker = Tracker(address: "hltracker.com", service: trackerService) - @State private var servers: [Server] = [] @State private var selectedServer: Server? @State private var scrollOffset: CGFloat = CGFloat.zero diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index 52d82d6..8176704 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -117,6 +117,7 @@ enum ServerNavigationType: Identifiable, Hashable, Equatable { struct ServerView: View { @Environment(Prefs.self) private var preferences: Prefs @Environment(SoundEffectPlayer.self) private var soundEffects: SoundEffectPlayer + @Environment(Bookmarks.self) private var bookmarks: Bookmarks @Environment(\.dismiss) var dismiss @Environment(\.colorScheme) private var colorScheme @Environment(\.controlActiveState) private var controlActiveState @@ -130,6 +131,8 @@ struct ServerView: View { @State private var connectAddress: String = "" @State private var connectLogin: String = "" @State private var connectPassword: String = "" + @State private var connectNameSheetPresented: Bool = false + @State private var connectName: String = "" @Binding var server: Server @@ -161,62 +164,46 @@ struct ServerView: View { 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")) { + TextField(text: $connectLogin, prompt: Text("Optional")) { Text("Login:") } .focused($focusedField, equals: .login) - SecureField(text: $connectPassword, prompt: Text("optional")) { + SecureField(text: $connectPassword, prompt: Text("Optional")) { Text("Password:") } .focused($focusedField, equals: .password) } .textFieldStyle(.roundedBorder) - .controlSize(.regular) + .controlSize(.large) HStack { - Button { - print("SAVE BOOKMARK... SOMEHOW") - } label: { - Text("Save...") + Button("Save...") { + if !connectAddress.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + connectNameSheetPresented = true + } } + .disabled(connectAddress.isEmpty) .controlSize(.regular) .buttonStyle(.automatic) - .help("Save server as bookmark") + .help("Bookmark server") Spacer() - Button { + Button("Cancel") { dismiss() - } label: { - Text("Cancel") } .controlSize(.regular) .buttonStyle(.automatic) .keyboardShortcut(.cancelAction) - Button { - - // if var s = server { - // print("CHANGING EXISTING SERVER") - // s.name = newServer.name - // s.description = newServer.description - // s.users = newServer.users - // s.address = newServer.address - // s.port = newServer.port - // s.login = newServer.login - // s.password = newServer.password - // } - // else { - // server = newServer - // } - + Button("Connect") { Task { await connectToServer() } - } label: { - Text("Connect") } + .controlSize(.regular) .buttonStyle(.automatic) .keyboardShortcut(.defaultAction) @@ -237,13 +224,51 @@ struct ServerView: View { server.password = connectPassword } } -// .padding(.top, 8) .onAppear { focusedField = .address } } - .frame(maxWidth: 350) + .frame(maxWidth: 380) .padding() + .sheet(isPresented: $connectNameSheetPresented) { + VStack(alignment: .leading) { + Text("Name this server bookmark:") + .foregroundStyle(.secondary) + .padding(.bottom, 4) + TextField("Bookmark Name", text: $connectName) + .textFieldStyle(.roundedBorder) + .controlSize(.large) + } + .frame(width: 250) + .padding() + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + connectNameSheetPresented = false + connectName = "" + } + } + + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + let name = String(connectName.trimmingCharacters(in: .whitespacesAndNewlines)) + 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 + + if !host.isEmpty { + let _ = bookmarks.add(Bookmark(type: .server, name: name, address: host, port: port, login: login, password: password)) + } + } + } + } + } + } + } } var navigationList: some View { diff --git a/Hotline/macOS/TrackerView.swift b/Hotline/macOS/TrackerView.swift index 6b8f330..073ecdf 100644 --- a/Hotline/macOS/TrackerView.swift +++ b/Hotline/macOS/TrackerView.swift @@ -1,104 +1,274 @@ import SwiftUI -enum TrackerBookmarkType: String { - case tracker = "tracker" - case server = "server" -} - -struct TrackerBookmark { - let type: TrackerBookmarkType - let name: String - let address: String - let port: Int = HotlinePorts.DefaultServerPort -} - -@Observable -class TrackerItem: Identifiable, Hashable { - let id: UUID = UUID() - let bookmark: TrackerBookmark? - let server: Server? - - var servers: [TrackerItem]? - - var expanded: Bool = false - var loading: Bool = false +struct TrackerView: View { + @Environment(Bookmarks.self) private var bookmarks: Bookmarks + @Environment(\.colorScheme) private var colorScheme + @Environment(\.openWindow) private var openWindow + @Environment(\.controlActiveState) private var controlActiveState - var displayAddress: String? { - if let s = server { - if s.port == HotlinePorts.DefaultServerPort { - return s.address + @MainActor + func refresh() async { + + // When a tracker is selected, refresh only that tracker. + if + let selectedItem = selection, + let bookmark = selectedItem.bookmark, + bookmark.type == .tracker { + if !selectedItem.expanded { + selectedItem.expanded = true } else { - return "\(s.address):\(s.port)" + await selectedItem.loadServers() } + return } - else if let b = bookmark { - if b.port == HotlinePorts.DefaultServerPort { - return b.address - } - else { - return "\(b.address):\(b.port)" + + // Otherwise refresh/expand all trackers. + for server in self.servers { + if + let bookmark = server.bookmark, + bookmark.type == .tracker { + if !server.expanded { + server.expanded = true + } + else { + Task { + await server.loadServers() + } + } } } - - return nil } - init(bookmark: TrackerBookmark) { - self.bookmark = bookmark - self.server = nil - self.servers = nil - - if bookmark.type == .tracker { - self.servers = [] - } - } + @State private var servers: [TrackerItem] = [] + @State private var selection: TrackerItem? = nil - init(server: Server) { - self.server = server - self.servers = nil - self.bookmark = nil - } + @State private var scrollOffset: CGFloat = CGFloat.zero + @State private var initialLoadComplete = false + @State private var refreshing = false + @State private var topBarOpacity: Double = 1.0 + @State private var connectVisible = false + @State private var connectDismissed = true + @State private var serverVisible = false - static func == (lhs: TrackerItem, rhs: TrackerItem) -> Bool { - return lhs.id == rhs.id - } + @State private var trackerSheetPresented: Bool = false + @State private var trackerName: String = "" + @State private var trackerAddress: String = "" - func hash(into hasher: inout Hasher) { - hasher.combine(self.id) + var trackerBookmarkSheet: some View { + VStack(alignment: .leading) { + Text("Type the address and name of a Hotline Tracker:") + .foregroundStyle(.secondary) + .padding(.bottom, 8) + Form { + Group { + TextField(text: $trackerAddress) { + Text("Address:") + } + TextField(text: $trackerName, prompt: Text("Optional")) { + Text("Name:") + } + } + .textFieldStyle(.roundedBorder) + .controlSize(.large) + } + } + .frame(width: 300) + .fixedSize(horizontal: true, vertical: true) + .padding() + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Add Tracker") { + var displayName = trackerName.trimmingCharacters(in: .whitespacesAndNewlines) + let (host, port) = Tracker.parseTrackerAddressAndPort(trackerAddress) + + if displayName.isEmpty { + displayName = host + } + + if !displayName.isEmpty && !host.isEmpty { + if !host.isEmpty { + let _ = bookmarks.add(Bookmark(type: .tracker, name: displayName, address: host, port: port)) + trackerSheetPresented = false + trackerName = "" + trackerAddress = "" + } + } + } + } + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + trackerSheetPresented = false + trackerName = "" + trackerAddress = "" + } + } + } } - - @MainActor - func loadServers() async { - guard - let bookmark = self.bookmark, - bookmark.type == .tracker - else { - self.loading = false - return + var body: some View { + List($servers, id: \.self, editActions: [.move], selection: $selection) { $item in + TrackerItemView(item: item, depth: 0) + .deleteDisabled(!item.editable) + .moveDisabled(!item.editable) + .tag(item) } - - let client = HotlineTrackerClient() - - self.loading = true - - if let fetchedServers: [HotlineServer] = try? await client.fetchServers(address: bookmark.address, port: HotlinePorts.DefaultTrackerPort) { - var newItems: [TrackerItem] = [] - - for s in fetchedServers { - if let serverName = s.name { - let server = Server(name: serverName, description: s.description, address: s.address, port: Int(s.port), users: Int(s.users)) - newItems.append(TrackerItem(server: server)) + .environment(\.defaultMinListRowHeight, 34) + .listStyle(.inset) + .alternatingRowBackgrounds(.enabled) + .onDeleteCommand { + if let sel = selection, let bookmark = sel.bookmark { + let _ = bookmarks.delete(bookmark) + if let i = self.servers.firstIndex(where: { $0.id == sel.id }) { + self.servers.remove(at: i) } + selection = nil + } + } + .contextMenu(forSelectionType: TrackerItem.self) { items in + if let item = items.first { + if let server = item.server { + Button { + let _ = bookmarks.add(Bookmark(type: .server, name: server.name ?? server.address, address: server.address, port: server.port)) + } label: { + Label("Bookmark", systemImage: "bookmark") + } + } + + if let address = item.displayAddress { + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(address, forType: .string) + } label: { + Label("Copy Address", systemImage: "doc.on.doc") + } + } + } + } primaryAction: { items in + guard let clickedItem = items.first else { + return + } + + if + let bookmark = clickedItem.bookmark, + bookmark.type == .tracker { + clickedItem.expanded.toggle() + } + else if let server = clickedItem.server { + openWindow(id: "server", value: server) + } + else if let bookmark = clickedItem.bookmark, bookmark.type == .server { + let server = Server(name: bookmark.name, description: nil, address: bookmark.address, port: HotlinePorts.DefaultServerPort) + openWindow(id: "server", value: server) + } + } + .onKeyPress(.rightArrow) { + if + let selectedItem = selection, + let bookmark = selectedItem.bookmark, + bookmark.type == .tracker { + selectedItem.expanded = true + return .handled + } + return .ignored + } + .onKeyPress(.leftArrow) { + if + let selectedItem = selection, + let bookmark = selectedItem.bookmark, + bookmark.type == .tracker { + selectedItem.expanded = false + return .handled + } + return .ignored + } + .sheet(isPresented: $trackerSheetPresented) { + trackerBookmarkSheet + } + .navigationTitle("Servers") + .toolbar { + ToolbarItem(placement: .navigation) { + Image("Hotline") + .resizable() + .renderingMode(.template) + .scaledToFit() + .foregroundColor(Color(hex: 0xE10000)) + .frame(width: 9) + .opacity(controlActiveState == .inactive ? 0.5 : 1.0) + } + ToolbarItem(placement: .primaryAction) { + Button { + Task { + initialLoadComplete = false + await refresh() + initialLoadComplete = true + } + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .help("Refresh Trackers") + } + + ToolbarItem(placement: .primaryAction) { + Button { + trackerSheetPresented = true + } label: { + Label("Add Tracker", systemImage: "point.3.filled.connected.trianglepath.dotted") + } + .help("Add Tracker") + } + + ToolbarItem(placement: .primaryAction) { + Button { + openWindow(id: "server") + } label: { + Label("Connect to Server", systemImage: "globe.americas.fill") + } + .help("Connect to Server") + } + } + .task { + guard let bookmarks = bookmarks.bookmarks else { + return + } + // Add initial items to tracker list. + var items: [TrackerItem] = [] + for bookmark in bookmarks { + items.append(TrackerItem(bookmark: bookmark)) + } + self.servers = items + } + .onChange(of: servers) { + Task { + saveBookmarks() + } + } + .onReceive(NotificationCenter.default.publisher(for: NSNotification.BookmarkAdded)) { notification in + guard let bookmarks = bookmarks.bookmarks, let userInfo = notification.userInfo else { + return + } + + if let i = userInfo["index"] as? Int, bookmarks.count > i { + self.servers.insert(TrackerItem(bookmark: bookmarks[i]), at: i) + } + } + .onOpenURL(perform: { url in + if let s = Server(url: url) { + openWindow(id: "server", value: s) + } + }) + } + + private func saveBookmarks() { + var newBookmarks: [Bookmark] = [] + for server in self.servers { + if let b = server.bookmark { + newBookmarks.append(b) } - - self.servers = newItems } - self.loading = false + bookmarks.apply(newBookmarks) } - } struct TrackerItemView: View { @@ -217,190 +387,106 @@ struct TrackerItemView: View { item.expanded, let servers = item.servers { ForEach(servers, id: \.self) { serverItem in - TrackerItemView(item: serverItem, depth: self.depth + 1).tag(serverItem) + TrackerItemView(item: serverItem, depth: self.depth + 1) + .tag(serverItem) + .deleteDisabled(!serverItem.editable) + .moveDisabled(!serverItem.editable) } } } } -struct TrackerView: View { - @Environment(\.colorScheme) private var colorScheme - @Environment(\.openWindow) private var openWindow - @Environment(\.controlActiveState) private var controlActiveState +@Observable +class TrackerItem: Identifiable, Hashable { + let id: UUID = UUID() + let bookmark: Bookmark? + let server: Server? - var bookmarks: [TrackerBookmark] = [ - TrackerBookmark(type: .server, name: "RetroMac", address: "82.32.130.173"), - TrackerBookmark(type: .server, name: "System 7 Today", address: "hotline.system7today.com"), //host has dynamic ip - TrackerBookmark(type: .tracker, name: "Featured Servers", address: "hltracker.com"), - TrackerBookmark(type: .tracker, name: "Agent79", address: "tracked.agent79.org"), - TrackerBookmark(type: .tracker, name: "Preterhuman", address: "tracker.preterhuman.net"), - - // "hltracker.com" - // "tracker.preterhuman.net" - // "hotline.ubersoft.org" - // "tracked.nailbat.com" - // "hotline.duckdns.org" - // "tracked.agent79.org" - - ] + var editable: Bool = true - @MainActor - func refresh() async { - - // When a tracker is selected, refresh only that tracker. - if - let selectedItem = selection, - let bookmark = selectedItem.bookmark, - bookmark.type == .tracker { - if !selectedItem.expanded { - selectedItem.expanded.toggle() + var servers: [TrackerItem]? + + var expanded: Bool = false + var loading: Bool = false + + var displayAddress: String? { + if let s = server { + if s.port == HotlinePorts.DefaultServerPort { + return s.address } else { - await selectedItem.loadServers() + return "\(s.address):\(s.port)" } - return } - - // Otherwise refresh/expand all trackers. - for server in self.servers { - if - let bookmark = server.bookmark, - bookmark.type == .tracker { - if !server.expanded { - server.expanded.toggle() - } - else { - Task { - await server.loadServers() - } - } + else if let b = bookmark { + if b.port == HotlinePorts.DefaultServerPort { + return b.address + } + else { + return "\(b.address):\(b.port)" } } + + return nil } - @State private var servers: [TrackerItem] = [] - @State private var selection: TrackerItem? = nil + init(bookmark: Bookmark) { + self.bookmark = bookmark + self.server = nil + self.servers = nil + + if bookmark.type == .tracker { + self.servers = [] + } + } - @State private var scrollOffset: CGFloat = CGFloat.zero - @State private var initialLoadComplete = false - @State private var refreshing = false - @State private var topBarOpacity: Double = 1.0 - @State private var connectVisible = false - @State private var connectDismissed = true - @State private var serverVisible = false + init(server: Server) { + self.server = server + self.servers = nil + self.bookmark = nil + } - var body: some View { - List(self.servers, id: \.self, selection: $selection) { item in - TrackerItemView(item: item, depth: 0) - .tag(item) - } - .environment(\.defaultMinListRowHeight, 34) - .listStyle(.inset) - .alternatingRowBackgrounds(.enabled) - .contextMenu(forSelectionType: TrackerItem.self) { items in - if let item = items.first { - if let address = item.displayAddress { - Button { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(address, forType: .string) - } label: { - Label("Copy Server Address", systemImage: "doc.on.doc") - } - } - } - } primaryAction: { items in - guard let clickedItem = items.first else { - return - } - - if - let bookmark = clickedItem.bookmark, - bookmark.type == .tracker { - clickedItem.expanded.toggle() - } - else if let server = clickedItem.server { - openWindow(id: "server", value: server) - } - else if let bookmark = clickedItem.bookmark, bookmark.type == .server { - let server = Server(name: bookmark.name, description: nil, address: bookmark.address, port: HotlinePorts.DefaultServerPort) - openWindow(id: "server", value: server) - } - } - .onKeyPress(.rightArrow) { - if - let selectedItem = selection, - let bookmark = selectedItem.bookmark, - bookmark.type == .tracker { - selectedItem.expanded = true - return .handled - } - return .ignored - } - .onKeyPress(.leftArrow) { - if - let selectedItem = selection, - let bookmark = selectedItem.bookmark, - bookmark.type == .tracker { - selectedItem.expanded = false - return .handled - } - return .ignored + static func == (lhs: TrackerItem, rhs: TrackerItem) -> Bool { + return lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(self.id) + } + + + @MainActor + func loadServers() async { + guard + let bookmark = self.bookmark, + bookmark.type == .tracker + else { + self.loading = false + return } - .navigationTitle("Servers") - .toolbar { - ToolbarItem(placement: .navigation) { - Image("Hotline") - .resizable() - .renderingMode(.template) - .scaledToFit() - .foregroundColor(Color(hex: 0xE10000)) - .frame(width: 9) - .opacity(controlActiveState == .inactive ? 0.5 : 1.0) - } - ToolbarItem(placement: .primaryAction) { - Button { - Task { - initialLoadComplete = false - await refresh() - initialLoadComplete = true - } - } label: { - Label("Refresh", systemImage: "arrow.clockwise") - } - .help("Refresh Trackers") - } - - ToolbarItem(placement: .primaryAction) { - Button { - } label: { - Label("Add Tracker...", systemImage: "point.3.filled.connected.trianglepath.dotted") - } - .help("Add Tracker") - } - - ToolbarItem(placement: .primaryAction) { - Button { - openWindow(id: "server") - } label: { - Label("Add Server Bookmark...", systemImage: "globe.americas.fill") + + let client = HotlineTrackerClient() + + self.loading = true + + if let fetchedServers: [HotlineServer] = try? await client.fetchServers(address: bookmark.address, port: HotlinePorts.DefaultTrackerPort) { + var newItems: [TrackerItem] = [] + + for s in fetchedServers { + if let serverName = s.name { + let server = Server(name: serverName, description: s.description, address: s.address, port: Int(s.port), users: Int(s.users)) + let item = TrackerItem(server: server) + item.editable = false + newItems.append(item) } - .help("Connect to Server...") - } - } - .task { - // Add initial items to tracker list. - var items: [TrackerItem] = [] - for bookmark in self.bookmarks { - items.append(TrackerItem(bookmark: bookmark)) } - self.servers = items + + self.servers = newItems } - .onOpenURL(perform: { url in - if let s = Server(url: url) { - openWindow(id: "server", value: s) - } - }) + + self.loading = false } + } #Preview { |