import Foundation import SwiftData enum BookmarkType: String, Codable { case tracker = "tracker" case server = "server" } struct BookmarkServer: Hashable, Identifiable { let id = UUID() let name: String? let address: String let port: Int let description: String? let users: Int var server: Server { Server(name: self.name, description: self.description, address: self.address, port: self.port, users: self.users) } init(server: Server) { self.name = server.name self.address = server.address self.port = server.port self.description = server.description self.users = server.users } } @Model final class Bookmark { var type: BookmarkType = BookmarkType.server var order: Int = 0 var name: String = "" var address: String = "" var port: Int = HotlinePorts.DefaultServerPort @Attribute(.allowsCloudEncryption) var login: String? @Attribute(.allowsCloudEncryption) var password: String? @Attribute var autoconnect: Bool = false @Transient var displayAddress: String { switch self.type { case .tracker: if self.port == HotlinePorts.DefaultTrackerPort { return self.address } else { return "\(self.address):\(String(self.port))" } case .server: if self.port == HotlinePorts.DefaultServerPort { return self.address } else { return "\(self.address):\(String(self.port))" } } } @Transient var server: Server? { switch self.type { case .tracker: return nil case .server: return Server(name: self.name, description: nil, address: self.address, port: self.port, login: self.login, password: self.password, autoconnect: self.autoconnect) } } static let DefaultBookmarks: [Bookmark] = [ Bookmark(type: .server, name: "The Mobius Strip", address: "mobius.trtphotl.com", port: HotlinePorts.DefaultServerPort), Bookmark(type: .server, name: "MacDomain", address: "hotline.system7today.com", port: HotlinePorts.DefaultServerPort), Bookmark(type: .tracker, name: "Featured Servers", address: "hltracker.com", port: HotlinePorts.DefaultTrackerPort) ] init( type: BookmarkType, name: String, address: String, port: Int, login: String? = nil, password: String? = nil, autoconnect: Bool = false ) { self.type = type self.name = name self.address = address self.port = port self.login = login self.password = password self.autoconnect = autoconnect } init?(fileData: Data, name: String? = nil) { guard fileData.count <= 2000 else { return nil } var fileDataArray: [UInt8] = [UInt8](fileData) guard let headerValue = fileDataArray.consumeUInt32(), headerValue.fourCharCode() == "HTsc", let versionNumber = fileDataArray.consumeUInt16(), versionNumber == 1, fileDataArray.consume(128), // Skip 128 reserved bytes. let loginLength = fileDataArray.consumeUInt16(), let loginData: Data = fileDataArray.consumeBytes(32), let passwordLength = fileDataArray.consumeUInt16(), let passwordData: Data = fileDataArray.consumeBytes(32), let addressLength = fileDataArray.consumeUInt16(), let addressData: Data = fileDataArray.consumeBytes(256), let addressString = String(data: addressData[0.. Data? { guard let addressData = self.displayAddress.data(using: .ascii) else { return nil } let loginData: Data = self.login?.data(using: .ascii) ?? Data() let passwordData: Data = self.password?.data(using: .ascii) ?? Data() var fileData: Data = Data() fileData.appendUInt32("HTsc".fourCharCode()) // magic fileData.appendUInt16(0x0001) // version fileData.append(Data(repeating: 0x00, count: 128)) // reserved fileData.appendUInt16(UInt16(loginData.count)) fileData.append(loginData) if loginData.count < 32 { // Pad login data to 32 bytes fileData.append(Data(repeating: 0x00, count: 32 - loginData.count)) } fileData.appendUInt16(UInt16(passwordData.count)) fileData.append(passwordData) if passwordData.count < 32 { // Pad password data to 32 bytes fileData.append(Data(repeating: 0x00, count: 32 - passwordData.count)) } fileData.appendUInt16(UInt16(addressData.count)) fileData.append(addressData) if passwordData.count < 256 { // Pad address data to 256 bytes fileData.append(Data(repeating: 0x00, count: 256 - addressData.count)) } return fileData } static func populateDefaults(force: Bool = false, context: ModelContext) { if force || Bookmark.fetchCount(context: context) == 0 { Bookmark.add(Bookmark.DefaultBookmarks, context: context) } } static func fetchAll(context: ModelContext) -> [Bookmark] { let fetchDescriptor = FetchDescriptor(sortBy: [.init(\.order)]) do { let bookmarks: [Bookmark] = try context.fetch(fetchDescriptor) return bookmarks } catch { return [] } } static func fetchCount(context: ModelContext) -> Int { let descriptor = FetchDescriptor() return (try? context.fetchCount(descriptor)) ?? 0 } static func deleteAll(context: ModelContext) { try? context.delete(model: Bookmark.self) } static func add(_ bookmark: Bookmark, context: ModelContext) { let existingBookmarks = Bookmark.fetchAll(context: context) // Reindex bookmarks before insert. for existingBookmark in existingBookmarks { existingBookmark.order += 1 } // Insert new bookmark at start. bookmark.order = 0 context.insert(bookmark) } static func add(_ bookmarks: [Bookmark], context: ModelContext) { let existingBookmarks = Bookmark.fetchAll(context: context) // Reindex bookmarks before insert. for existingBookmark in existingBookmarks { existingBookmark.order += bookmarks.count } // Insert new bookmarks at start. var bookmarkIndex = 0 for newBookmark in bookmarks { newBookmark.order = bookmarkIndex context.insert(newBookmark) bookmarkIndex += 1 print("Bookmark: added \(newBookmark.name)") } } static func delete(_ bookmark: Bookmark, context: ModelContext) { // Delete bookmark context.delete(bookmark) // Reindex bookmarks let existingBookmarks = Bookmark.fetchAll(context: context) var index = 0 for existingBookmark in existingBookmarks { existingBookmark.order = index index += 1 } } static func delete(at indexes: IndexSet, context: ModelContext) { var existingBookmarks = Bookmark.fetchAll(context: context) // existingBookmarks.remove(atOffsets: indexes) let bookmarksToDelete = indexes.map { existingBookmarks[$0] } // Delete bookmark for bookmark in bookmarksToDelete { context.delete(bookmark) } // Reindex bookmarks var index = 0 existingBookmarks.remove(atOffsets: indexes) for existingBookmark in existingBookmarks { existingBookmark.order = index index += 1 } do { try context.save() } catch { print("Bookmark: Failed to save bookmark deletions") } } static func move(_ indexes: IndexSet, to newIndex: Int, context: ModelContext) { guard Bookmark.fetchCount(context: context) >= indexes.count else { print("Bookmark: Not enough bookmarks to move requested set") return } // Perform move var existingBookmarks = Bookmark.fetchAll(context: context) existingBookmarks.move(fromOffsets: indexes, toOffset: newIndex) // Reindex bookmarks var index = 0 for existingBookmark in existingBookmarks { existingBookmark.order = index index += 1 } do { try context.save() } catch { print("Bookmark: Failed to save bookmark reordering") } } func fetchServers() async -> [BookmarkServer] { guard self.type == .tracker else { return [] } var fetchedBookmarks: [BookmarkServer] = [] let client = HotlineTrackerClient() 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 } }