aboutsummaryrefslogtreecommitdiff
path: root/Hotline
diff options
context:
space:
mode:
authorDustin Mierau <dustin@mierau.me>2024-01-17 23:18:27 -0800
committerDustin Mierau <dustin@mierau.me>2024-01-17 23:18:27 -0800
commit1b303803e53dbeb24dab1a7c714d71ee14f002db (patch)
tree4616861b23642d26c9b58b5925315232c1fdb975 /Hotline
parent8e85fbbdadcb993cd270ff4bf60d2280b9e7ad56 (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.swift5
-rw-r--r--Hotline/Assets.xcassets/hotlineRed.colorset/Contents.json38
-rw-r--r--Hotline/Models/Bookmarks.swift126
-rw-r--r--Hotline/Models/Hotline.swift67
-rw-r--r--Hotline/Models/Tracker.swift7
-rw-r--r--Hotline/Utility/DAKeychain.swift81
-rw-r--r--Hotline/iOS/TrackerView.swift6
-rw-r--r--Hotline/macOS/ServerView.swift87
-rw-r--r--Hotline/macOS/TrackerView.swift568
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 {