aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDustin Mierau <dustin@mierau.me>2025-11-07 16:16:31 -0800
committerDustin Mierau <dustin@mierau.me>2025-11-07 16:16:31 -0800
commit6afa5551add4541f376867b3d6527df9a0f793f3 (patch)
treea1fbdb6c0a71f621a01c2821fe5d340d5853f1ca
parente1566598c96601ebcf3229aab22fc7a593ee1904 (diff)
Further UI tweraks. Transfers button in toolbar. Better empty states in places. Fix race condition with transactions ids (yikes)!
-rw-r--r--Hotline.xcodeproj/project.pbxproj26
-rw-r--r--Hotline/Hotline/HotlineClientNew.swift100
-rw-r--r--Hotline/Hotline/HotlineProtocol.swift10
-rw-r--r--Hotline/macOS/HotlinePanelView.swift12
-rw-r--r--Hotline/macOS/News/NewsView.swift2
-rw-r--r--Hotline/macOS/Trackers/ServerBookmarkSheet.swift70
-rw-r--r--Hotline/macOS/Trackers/TrackerBookmarkServerView.swift4
-rw-r--r--Hotline/macOS/Trackers/TrackerBookmarkSheet.swift4
-rw-r--r--Hotline/macOS/Trackers/TrackerItemView.swift4
-rw-r--r--Hotline/macOS/Trackers/TrackerView.swift480
10 files changed, 653 insertions, 59 deletions
diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj
index c63109a..2861aab 100644
--- a/Hotline.xcodeproj/project.pbxproj
+++ b/Hotline.xcodeproj/project.pbxproj
@@ -33,6 +33,10 @@
DA4F2BF82B16A17200D8ADDC /* HotlineProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4F2BF72B16A17200D8ADDC /* HotlineProtocol.swift */; };
DA4F2C012B1A558E00D8ADDC /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4F2C002B1A558E00D8ADDC /* ChatView.swift */; platformFilter = ios; };
DA501BE22EBE9018001714F8 /* GroupedIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA501BE12EBE9018001714F8 /* GroupedIconView.swift */; };
+ DA501BE42EBE9517001714F8 /* ServerBookmarkSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA501BE32EBE9517001714F8 /* ServerBookmarkSheet.swift */; };
+ DA501BE72EBE9542001714F8 /* TrackerBookmarkSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA501BE62EBE9542001714F8 /* TrackerBookmarkSheet.swift */; };
+ DA501BE92EBE9589001714F8 /* TrackerItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA501BE82EBE9589001714F8 /* TrackerItemView.swift */; };
+ DA501BEB2EBE95B4001714F8 /* TrackerBookmarkServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA501BEA2EBE95B4001714F8 /* TrackerBookmarkServerView.swift */; };
DA52689C2EB0738B00DCB941 /* GeneralSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA52689B2EB0738B00DCB941 /* GeneralSettingsView.swift */; platformFilters = (macos, ); };
DA52689E2EB073A400DCB941 /* IconSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA52689D2EB073A400DCB941 /* IconSettingsView.swift */; platformFilters = (macos, ); };
DA5268A02EB073BC00DCB941 /* SoundSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA52689F2EB073BC00DCB941 /* SoundSettingsView.swift */; platformFilters = (macos, ); };
@@ -139,6 +143,10 @@
DA4F2BF72B16A17200D8ADDC /* HotlineProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineProtocol.swift; sourceTree = "<group>"; };
DA4F2C002B1A558E00D8ADDC /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = "<group>"; };
DA501BE12EBE9018001714F8 /* GroupedIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupedIconView.swift; sourceTree = "<group>"; };
+ DA501BE32EBE9517001714F8 /* ServerBookmarkSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerBookmarkSheet.swift; sourceTree = "<group>"; };
+ DA501BE62EBE9542001714F8 /* TrackerBookmarkSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackerBookmarkSheet.swift; sourceTree = "<group>"; };
+ DA501BE82EBE9589001714F8 /* TrackerItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackerItemView.swift; sourceTree = "<group>"; };
+ DA501BEA2EBE95B4001714F8 /* TrackerBookmarkServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackerBookmarkServerView.swift; sourceTree = "<group>"; };
DA52689B2EB0738B00DCB941 /* GeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsView.swift; sourceTree = "<group>"; };
DA52689D2EB073A400DCB941 /* IconSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconSettingsView.swift; sourceTree = "<group>"; };
DA52689F2EB073BC00DCB941 /* SoundSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundSettingsView.swift; sourceTree = "<group>"; };
@@ -291,6 +299,18 @@
path = Views;
sourceTree = "<group>";
};
+ DA501BE52EBE9520001714F8 /* Trackers */ = {
+ isa = PBXGroup;
+ children = (
+ DAE734FA2B2E41F9000C56F6 /* TrackerView.swift */,
+ DA501BE82EBE9589001714F8 /* TrackerItemView.swift */,
+ DA501BEA2EBE95B4001714F8 /* TrackerBookmarkServerView.swift */,
+ DA501BE62EBE9542001714F8 /* TrackerBookmarkSheet.swift */,
+ DA501BE32EBE9517001714F8 /* ServerBookmarkSheet.swift */,
+ );
+ path = Trackers;
+ sourceTree = "<group>";
+ };
DA52689A2EB0737400DCB941 /* Settings */ = {
isa = PBXGroup;
children = (
@@ -497,10 +517,10 @@
DA55AC762BE589F700034857 /* AboutView.swift */,
DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */,
DAE136B92B9D1147007D8307 /* HotlinePanelView.swift */,
- DAE734FA2B2E41F9000C56F6 /* TrackerView.swift */,
DAE734F82B2E4185000C56F6 /* ServerView.swift */,
DAE735022B30C0BB000C56F6 /* MessageView.swift */,
DA5268B42EB6840A00DCB941 /* TransfersView.swift */,
+ DA501BE52EBE9520001714F8 /* Trackers */,
DA5268A82EB081AF00DCB941 /* Chat */,
DA5268A62EB0762300DCB941 /* Board */,
DA5268A92EB081DE00DCB941 /* News */,
@@ -615,6 +635,7 @@
DA7725412B21435B006C5ABB /* ObservableScrollView.swift in Sources */,
DAE734FF2B2E6750000C56F6 /* ChatView.swift in Sources */,
DA5268B52EB6840A00DCB941 /* TransfersView.swift in Sources */,
+ DA501BE42EBE9517001714F8 /* ServerBookmarkSheet.swift in Sources */,
11F8288B2BF9428100216BA0 /* AccountManagerView.swift in Sources */,
DAC6B2E42EAFE92F004E2CBA /* SpinningGlobeView.swift in Sources */,
DADDB28D2B22B5920024040D /* Server.swift in Sources */,
@@ -654,11 +675,13 @@
11A7260A2BE0675A000C1DA7 /* FileDetailsView.swift in Sources */,
DA72A0DD2B4CD0BF00A0F48A /* NewsEditorView.swift in Sources */,
DA52689C2EB0738B00DCB941 /* GeneralSettingsView.swift in Sources */,
+ DA501BE92EBE9589001714F8 /* TrackerItemView.swift in Sources */,
DA57536C2B36BA1D00FAC277 /* TextDocument.swift in Sources */,
DACCE5E12EABE4B4008CDD92 /* AppUpdate.swift in Sources */,
DA5268AD2EB12FE200DCB941 /* ServerState.swift in Sources */,
DA77253F2B21176D006C5ABB /* NewsView.swift in Sources */,
DAC6B2E22EAEE9FE004E2CBA /* NetSocket.swift in Sources */,
+ DA501BEB2EBE95B4001714F8 /* TrackerBookmarkServerView.swift in Sources */,
DA99218E2C51AA5D0058FA6C /* HotlineDataBuilder.swift in Sources */,
DA872B132BDDBF78008B1012 /* HotlinePanel.swift in Sources */,
DA4B8F3A2EA6FB3C00CBFD53 /* iOSApp.swift in Sources */,
@@ -680,6 +703,7 @@
DAC87F072C5010E80060FADF /* HotlineExtensions.swift in Sources */,
DAE734FD2B2E65E9000C56F6 /* MessageBoardView.swift in Sources */,
DA6980832BFFD06C003E434B /* BookmarkDocument.swift in Sources */,
+ DA501BE72EBE9542001714F8 /* TrackerBookmarkSheet.swift in Sources */,
DAB4D87E2B4C8BCA0048A05C /* FilePreviewTextView.swift in Sources */,
DA3429B72EBAB1750010784E /* QuickLookPreviewView.swift in Sources */,
DA4F2C012B1A558E00D8ADDC /* ChatView.swift in Sources */,
diff --git a/Hotline/Hotline/HotlineClientNew.swift b/Hotline/Hotline/HotlineClientNew.swift
index 8642d42..303bd32 100644
--- a/Hotline/Hotline/HotlineClientNew.swift
+++ b/Hotline/Hotline/HotlineClientNew.swift
@@ -160,6 +160,13 @@ public actor HotlineClientNew {
UInt16(0x0001) // Version
UInt16(0x0002) // Sub-version
})
+
+ // Transaction IDs
+ private var nextTransactionID: UInt32 = 1
+ private func generateTransactionID() -> UInt32 {
+ defer { self.nextTransactionID += 1 }
+ return self.nextTransactionID
+ }
// MARK: - Connection
@@ -258,7 +265,7 @@ public actor HotlineClientNew {
// MARK: - Login
private func performLogin(_ login: HotlineLoginInfo) async throws -> HotlineServerInfo {
- var transaction = HotlineTransaction(type: .login)
+ var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .login)
transaction.setFieldEncodedString(type: .userLogin, val: login.login)
transaction.setFieldEncodedString(type: .userPassword, val: login.password)
transaction.setFieldUInt16(type: .userIconID, val: login.iconID)
@@ -494,28 +501,29 @@ public actor HotlineClientNew {
// MARK: - Keep-Alive
private func startKeepAlive() {
- keepAliveTask = Task { [weak self] in
+ self.keepAliveTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 180_000_000_000) // 3 minutes
-
- guard let self else { return }
-
- do {
- if let version = await self.serverInfo?.version, version >= 185 {
- let transaction = HotlineTransaction(type: .connectionKeepAlive)
- try await self.socket.send(transaction, endian: .big)
- } else {
- // Older servers: send getUserNameList as keep-alive
- _ = try? await self.getUserList()
- }
- } catch {
- print("HotlineClientNew: Keep-alive failed: \(error)")
- }
+ await self?.sendKeepAlive()
}
}
}
+
+ private func sendKeepAlive() async {
+ do {
+ if let version = self.serverInfo?.version, version >= 185 {
+ let transaction = HotlineTransaction(id: self.generateTransactionID(), type: .connectionKeepAlive)
+ try await self.socket.send(transaction, endian: .big)
+ } else {
+ // Older servers: send getUserNameList as keep-alive
+ let _ = try? await self.getUserList()
+ }
+ } catch {
+ print("HotlineClientNew: Keep-alive failed: \(error)")
+ }
+ }
- // MARK: - Public API - Chat
+ // MARK: - Chat
/// Send a chat message to the server
///
@@ -524,20 +532,20 @@ public actor HotlineClientNew {
/// - encoding: Text encoding (default: UTF-8)
/// - announce: Whether this is an announcement (admin only, default: false)
public func sendChat(_ message: String, encoding: String.Encoding = .utf8, announce: Bool = false) async throws {
- var transaction = HotlineTransaction(type: .sendChat)
+ var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .sendChat)
transaction.setFieldString(type: .data, val: message, encoding: encoding)
transaction.setFieldUInt16(type: .chatOptions, val: announce ? 1 : 0)
try await socket.send(transaction, endian: .big)
}
- // MARK: - Public API - Users
+ // MARK: - Users
/// Get the list of users currently connected to the server
///
/// - Returns: Array of connected users
public func getUserList() async throws -> [HotlineUser] {
- let transaction = HotlineTransaction(type: .getUserNameList)
+ let transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getUserNameList)
let reply = try await sendTransaction(transaction)
var users: [HotlineUser] = []
@@ -555,7 +563,7 @@ public actor HotlineClientNew {
/// - userID: Target user ID
/// - encoding: Text encoding (default: UTF-8)
public func sendInstantMessage(_ message: String, to userID: UInt16, encoding: String.Encoding = .utf8) async throws {
- var transaction = HotlineTransaction(type: .sendInstantMessage)
+ var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .sendInstantMessage)
transaction.setFieldUInt16(type: .userID, val: userID)
transaction.setFieldUInt32(type: .options, val: 1)
transaction.setFieldString(type: .data, val: message, encoding: encoding)
@@ -576,7 +584,7 @@ public actor HotlineClientNew {
options: HotlineUserOptions = [],
autoresponse: String? = nil
) async throws {
- var transaction = HotlineTransaction(type: .setClientUserInfo)
+ var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .setClientUserInfo)
transaction.setFieldString(type: .userName, val: username)
transaction.setFieldUInt16(type: .userIconID, val: iconID)
transaction.setFieldUInt16(type: .options, val: options.rawValue)
@@ -588,13 +596,13 @@ public actor HotlineClientNew {
try await socket.send(transaction, endian: .big)
}
- // MARK: - Public API - Agreement
+ // MARK: - Agreement
/// Send agreement acceptance to the server
///
/// Call this after receiving `.agreementRequired` event.
public func sendAgree() async throws {
- let transaction = HotlineTransaction(type: .agreed)
+ let transaction = HotlineTransaction(id: self.generateTransactionID(), type: .agreed)
try await socket.send(transaction, endian: .big)
}
@@ -605,7 +613,7 @@ public actor HotlineClientNew {
/// - Parameter path: Directory path (empty for root)
/// - Returns: Array of files and folders
public func getFileList(path: [String] = []) async throws -> [HotlineFile] {
- var transaction = HotlineTransaction(type: .getFileNameList)
+ var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getFileNameList)
if !path.isEmpty {
transaction.setFieldPath(type: .filePath, val: path)
}
@@ -634,7 +642,7 @@ public actor HotlineClientNew {
path: [String],
preview: Bool = false
) async throws -> (referenceNumber: UInt32, size: Int, fileSize: Int?, waitingCount: Int?) {
- var transaction = HotlineTransaction(type: .downloadFile)
+ var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .downloadFile)
transaction.setFieldString(type: .fileName, val: name)
transaction.setFieldPath(type: .filePath, val: path)
@@ -664,7 +672,7 @@ public actor HotlineClientNew {
/// - Parameter path: Category path (empty for root)
/// - Returns: Array of news categories
public func getNewsCategories(path: [String] = []) async throws -> [HotlineNewsCategory] {
- var transaction = HotlineTransaction(type: .getNewsCategoryNameList)
+ var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getNewsCategoryNameList)
if !path.isEmpty {
transaction.setFieldPath(type: .newsPath, val: path)
}
@@ -686,7 +694,7 @@ public actor HotlineClientNew {
/// - Parameter path: Category path
/// - Returns: Array of news articles
public func getNewsArticles(path: [String] = []) async throws -> [HotlineNewsArticle] {
- var transaction = HotlineTransaction(type: .getNewsArticleNameList)
+ var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getNewsArticleNameList)
if !path.isEmpty {
transaction.setFieldPath(type: .newsPath, val: path)
}
@@ -713,7 +721,7 @@ public actor HotlineClientNew {
/// - flavor: Content flavor (default: "text/plain")
/// - Returns: Article content as string
public func getNewsArticle(id: UInt32, path: [String], flavor: String = "text/plain") async throws -> String? {
- var transaction = HotlineTransaction(type: .getNewsArticleData)
+ var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getNewsArticleData)
transaction.setFieldPath(type: .newsPath, val: path)
transaction.setFieldUInt32(type: .newsArticleID, val: id)
transaction.setFieldString(type: .newsArticleDataFlavor, val: flavor, encoding: .ascii)
@@ -739,7 +747,7 @@ public actor HotlineClientNew {
throw HotlineClientError.invalidResponse
}
- var transaction = HotlineTransaction(type: .postNewsArticle)
+ var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .postNewsArticle)
transaction.setFieldPath(type: .newsPath, val: path)
transaction.setFieldUInt32(type: .newsArticleID, val: parentID)
transaction.setFieldString(type: .newsArticleTitle, val: title)
@@ -756,7 +764,7 @@ public actor HotlineClientNew {
///
/// - Returns: Array of message strings
public func getMessageBoard() async throws -> [String] {
- let transaction = HotlineTransaction(type: .getMessageBoard)
+ let transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getMessageBoard)
let reply = try await sendTransaction(transaction)
guard let text = reply.getField(type: .data)?.getString() else {
@@ -774,7 +782,7 @@ public actor HotlineClientNew {
public func postMessageBoard(_ text: String) async throws {
guard !text.isEmpty else { return }
- var transaction = HotlineTransaction(type: .oldPostNews)
+ var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .oldPostNews)
transaction.setFieldString(type: .data, val: text, encoding: .macOSRoman)
try await socket.send(transaction, endian: .big)
@@ -789,7 +797,7 @@ public actor HotlineClientNew {
/// - path: Directory path containing the file
/// - Returns: File details or nil if not found
public func getFileInfo(name: String, path: [String]) async throws -> FileDetails? {
- var transaction = HotlineTransaction(type: .getFileInfo)
+ var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getFileInfo)
transaction.setFieldString(type: .fileName, val: name)
transaction.setFieldPath(type: .filePath, val: path)
@@ -828,7 +836,7 @@ public actor HotlineClientNew {
/// - path: Directory path containing the item
/// - Returns: True if deletion succeeded
public func deleteFile(name: String, path: [String]) async throws -> Bool {
- var transaction = HotlineTransaction(type: .deleteFile)
+ var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .deleteFile)
transaction.setFieldString(type: .fileName, val: name)
transaction.setFieldPath(type: .filePath, val: path)
@@ -840,13 +848,13 @@ public actor HotlineClientNew {
}
}
- // MARK: - Public API - User Administration
+ // MARK: - Administration
/// Get list of user accounts (requires admin access)
///
/// - Returns: Array of user accounts sorted by login
public func getAccounts() async throws -> [HotlineAccount] {
- let transaction = HotlineTransaction(type: .getAccounts)
+ let transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getAccounts)
let reply = try await sendTransaction(transaction)
let accountFields = reply.getFieldList(type: .data)
@@ -869,7 +877,7 @@ public actor HotlineClientNew {
/// - password: Optional password (nil for no password)
/// - access: Access permissions bitmask
public func createUser(name: String, login: String, password: String?, access: UInt64) async throws {
- var transaction = HotlineTransaction(type: .newUser)
+ var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .newUser)
transaction.setFieldString(type: .userName, val: name)
transaction.setFieldEncodedString(type: .userLogin, val: login)
@@ -891,7 +899,7 @@ public actor HotlineClientNew {
/// - password: Password update - nil to keep current, "" to remove, or new password string
/// - access: Access permissions bitmask
public func setUser(name: String, login: String, newLogin: String?, password: String?, access: UInt64) async throws {
- var transaction = HotlineTransaction(type: .setUser)
+ var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .setUser)
transaction.setFieldString(type: .userName, val: name)
transaction.setFieldUInt64(type: .userAccess, val: access)
@@ -919,20 +927,20 @@ public actor HotlineClientNew {
///
/// - Parameter login: Login username to delete
public func deleteUser(login: String) async throws {
- var transaction = HotlineTransaction(type: .deleteUser)
+ var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .deleteUser)
transaction.setFieldEncodedString(type: .userLogin, val: login)
_ = try await sendTransaction(transaction)
}
- // MARK: - Public API - Banner Download
+ // MARK: - Banners
/// Request to download the server banner image
///
/// - Returns: Tuple of (referenceNumber, transferSize) for the banner download
/// - Throws: HotlineClientError if not connected or server doesn't support banners
public func downloadBanner() async throws -> (referenceNumber: UInt32, transferSize: Int)? {
- let transaction = HotlineTransaction(type: .downloadBanner)
+ let transaction = HotlineTransaction(id: self.generateTransactionID(), type: .downloadBanner)
let reply = try await sendTransaction(transaction)
guard
@@ -947,7 +955,7 @@ public actor HotlineClientNew {
return (referenceNumber, transferSize)
}
- // MARK: Files
+ // MARK: - Transfers
/// Request to download a file
///
@@ -957,7 +965,7 @@ public actor HotlineClientNew {
/// - preview: If true, request preview mode (smaller transfer)
/// - Returns: Tuple of (referenceNumber, transferSize, fileSize, waitingCount) for the download
public func downloadFile(name: String, path: [String], preview: Bool = false) async throws -> (referenceNumber: UInt32, transferSize: Int, fileSize: Int, waitingCount: Int)? {
- var transaction = HotlineTransaction(type: .downloadFile)
+ var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .downloadFile)
transaction.setFieldString(type: .fileName, val: name)
transaction.setFieldPath(type: .filePath, val: path)
@@ -989,7 +997,7 @@ public actor HotlineClientNew {
/// - path: Directory path containing the folder
/// - Returns: Tuple of (referenceNumber, transferSize, itemCount, waitingCount) for the download
public func downloadFolder(name: String, path: [String]) async throws -> (referenceNumber: UInt32, transferSize: Int, itemCount: Int, waitingCount: Int)? {
- var transaction = HotlineTransaction(type: .downloadFolder)
+ var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .downloadFolder)
transaction.setFieldString(type: .fileName, val: name)
transaction.setFieldPath(type: .filePath, val: path)
@@ -1016,7 +1024,7 @@ public actor HotlineClientNew {
/// - path: Directory path where the file should be uploaded
/// - Returns: Reference number for the upload transfer
public func uploadFile(name: String, path: [String]) async throws -> UInt32? {
- var transaction = HotlineTransaction(type: .uploadFile)
+ var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .uploadFile)
transaction.setFieldString(type: .fileName, val: name)
transaction.setFieldPath(type: .filePath, val: path)
@@ -1041,7 +1049,7 @@ public actor HotlineClientNew {
public func uploadFolder(name: String, path: [String], fileCount: UInt32, totalSize: UInt32) async throws -> UInt32? {
print("HotlineClientNew: uploadFolder request - name='\(name)', path=\(path), fileCount=\(fileCount), totalSize=\(totalSize)")
- var transaction = HotlineTransaction(type: .uploadFolder)
+ var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .uploadFolder)
transaction.setFieldString(type: .fileName, val: name)
transaction.setFieldPath(type: .filePath, val: path)
transaction.setFieldUInt32(type: .transferSize, val: totalSize)
diff --git a/Hotline/Hotline/HotlineProtocol.swift b/Hotline/Hotline/HotlineProtocol.swift
index 3870261..5fd06dc 100644
--- a/Hotline/Hotline/HotlineProtocol.swift
+++ b/Hotline/Hotline/HotlineProtocol.swift
@@ -731,12 +731,6 @@ struct HotlineTransactionField {
struct HotlineTransaction {
static let headerSize = 20
- static var sequenceID: UInt32 = 1
-
- static func nextID() -> UInt32 {
- HotlineTransaction.sequenceID += 1
- return HotlineTransaction.sequenceID
- }
var flags: UInt8 = 0
var isReply: UInt8 = 0
@@ -748,9 +742,9 @@ struct HotlineTransaction {
var fields: [HotlineTransactionField] = []
- init(type: HotlineTransactionType) {
+ init(id: UInt32, type: HotlineTransactionType) {
self.type = type
- self.id = HotlineTransaction.nextID()
+ self.id = id
}
init?(from data: [UInt8]) {
diff --git a/Hotline/macOS/HotlinePanelView.swift b/Hotline/macOS/HotlinePanelView.swift
index 7819c2f..81c24b7 100644
--- a/Hotline/macOS/HotlinePanelView.swift
+++ b/Hotline/macOS/HotlinePanelView.swift
@@ -119,6 +119,18 @@ struct HotlinePanelView: View {
.disabled(self.activeServerState == nil)
.help("Accounts")
}
+
+ Button {
+ self.openWindow(id: "transfers")
+ }
+ label: {
+ Image("Section Transfers")
+ .resizable()
+ .scaledToFit()
+ }
+ .buttonStyle(.plain)
+ .frame(width: 20, height: 20)
+ .help("File Transfers")
SettingsLink(label: {
Image("Section Settings")
diff --git a/Hotline/macOS/News/NewsView.swift b/Hotline/macOS/News/NewsView.swift
index a07a441..976a985 100644
--- a/Hotline/macOS/News/NewsView.swift
+++ b/Hotline/macOS/News/NewsView.swift
@@ -138,7 +138,7 @@ struct NewsView: View {
ContentUnavailableView {
Label("No News", systemImage: "newspaper")
} description: {
- Text("This server has no newsgroups")
+ Text("This server has not created any newsgroups yet")
}
}
diff --git a/Hotline/macOS/Trackers/ServerBookmarkSheet.swift b/Hotline/macOS/Trackers/ServerBookmarkSheet.swift
new file mode 100644
index 0000000..6ad1657
--- /dev/null
+++ b/Hotline/macOS/Trackers/ServerBookmarkSheet.swift
@@ -0,0 +1,70 @@
+import SwiftUI
+
+struct ServerBookmarkSheet: View {
+ @Environment(\.dismiss) private var dismiss
+ @Environment(\.modelContext) private var modelContext
+
+ @State private var bookmark: Bookmark
+ @State private var serverName: String = ""
+ @State private var serverAddress: String = ""
+ @State private var serverLogin: String = ""
+ @State private var serverPassword: String = ""
+
+ init(_ editingBookmark: Bookmark) {
+ _bookmark = .init(initialValue: editingBookmark)
+ _serverName = .init(initialValue: editingBookmark.name)
+ _serverAddress = .init(initialValue: editingBookmark.displayAddress)
+ _serverLogin = .init(initialValue: editingBookmark.login ?? "")
+ _serverPassword = .init(initialValue: editingBookmark.password ?? "")
+ }
+
+ var body: some View {
+ Form {
+ Section {
+ TextField(text: $serverName) {
+ Text("Name")
+ }
+ }
+
+ Section {
+ TextField(text: $serverAddress) {
+ Text("Address")
+ }
+ TextField(text: $serverLogin, prompt: Text("Optional")) {
+ Text("Login")
+ }
+ SecureField(text: $serverPassword, prompt: Text("Optional")) {
+ Text("Password")
+ }
+ }
+ }
+ .formStyle(.grouped)
+ .frame(width: 350)
+ .fixedSize(horizontal: true, vertical: true)
+ .toolbar {
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Save") {
+ let displayName = self.serverName.trimmingCharacters(in: .whitespacesAndNewlines)
+ let (host, port) = Server.parseServerAddressAndPort(self.serverAddress)
+ let login = self.serverLogin.trimmingCharacters(in: .whitespacesAndNewlines)
+ let password = self.serverPassword
+
+ if !displayName.isEmpty && !host.isEmpty {
+ self.bookmark.name = displayName
+ self.bookmark.address = host
+ self.bookmark.port = port
+ self.bookmark.login = login.isEmpty ? nil : login
+ self.bookmark.password = password.isEmpty ? nil : password
+
+ self.dismiss()
+ }
+ }
+ }
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ self.dismiss()
+ }
+ }
+ }
+ }
+}
diff --git a/Hotline/macOS/Trackers/TrackerBookmarkServerView.swift b/Hotline/macOS/Trackers/TrackerBookmarkServerView.swift
index dc42ea9..93aea15 100644
--- a/Hotline/macOS/Trackers/TrackerBookmarkServerView.swift
+++ b/Hotline/macOS/Trackers/TrackerBookmarkServerView.swift
@@ -1,3 +1,5 @@
+import SwiftUI
+
struct TrackerBookmarkServerView: View {
let server: BookmarkServer
@@ -35,4 +37,4 @@ struct TrackerBookmarkServerView: View {
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
-} \ No newline at end of file
+}
diff --git a/Hotline/macOS/Trackers/TrackerBookmarkSheet.swift b/Hotline/macOS/Trackers/TrackerBookmarkSheet.swift
index 4943016..d66a466 100644
--- a/Hotline/macOS/Trackers/TrackerBookmarkSheet.swift
+++ b/Hotline/macOS/Trackers/TrackerBookmarkSheet.swift
@@ -1,3 +1,5 @@
+import SwiftUI
+
struct TrackerBookmarkSheet: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext
@@ -116,4 +118,4 @@ struct TrackerBookmarkSheet: View {
}
}
}
-} \ No newline at end of file
+}
diff --git a/Hotline/macOS/Trackers/TrackerItemView.swift b/Hotline/macOS/Trackers/TrackerItemView.swift
index 59caad5..6e62518 100644
--- a/Hotline/macOS/Trackers/TrackerItemView.swift
+++ b/Hotline/macOS/Trackers/TrackerItemView.swift
@@ -1,3 +1,5 @@
+import SwiftUI
+
struct TrackerItemView: View {
let bookmark: Bookmark
let isExpanded: Bool
@@ -70,4 +72,4 @@ struct TrackerItemView: View {
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
-} \ No newline at end of file
+}
diff --git a/Hotline/macOS/Trackers/TrackerView.swift b/Hotline/macOS/Trackers/TrackerView.swift
new file mode 100644
index 0000000..ddc0a67
--- /dev/null
+++ b/Hotline/macOS/Trackers/TrackerView.swift
@@ -0,0 +1,480 @@
+import SwiftUI
+import SwiftData
+import Foundation
+import UniformTypeIdentifiers
+
+enum TrackerSelection: Hashable {
+ case bookmark(Bookmark)
+ case bookmarkServer(BookmarkServer)
+
+ var server: Server? {
+ switch self {
+ case .bookmark(let b): return b.server
+ case .bookmarkServer(let t): return t.server
+ }
+ }
+}
+
+struct TrackerView: View {
+ @Environment(\.colorScheme) private var colorScheme
+ @Environment(\.openWindow) private var openWindow
+ @Environment(\.controlActiveState) private var controlActiveState
+ @Environment(\.modelContext) private var modelContext
+
+ @State private var refreshing = false
+ @State private var trackerSheetPresented: Bool = false
+ @State private var trackerSheetBookmark: Bookmark? = nil
+ @State private var serverSheetBookmark: Bookmark? = nil
+ @State private var attemptedPrepopulate: Bool = false
+ @State private var fileDropActive = false
+ @State private var bookmarkExportActive = false
+ @State private var bookmarkExport: BookmarkDocument? = nil
+ @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
+
+ @Query(sort: \Bookmark.order) private var bookmarks: [Bookmark]
+ @Binding var selection: TrackerSelection?
+
+ private var filteredBookmarks: [Bookmark] {
+ guard !self.searchText.isEmpty else {
+ return self.bookmarks
+ }
+
+ let searchWords = self.searchText.lowercased().split(separator: " ").map(String.init)
+
+ return self.bookmarks.filter { bookmark in
+ // Always show tracker bookmarks (filter only their servers)
+ if bookmark.type == .tracker {
+ return true
+ }
+
+ // Filter server bookmarks by search text
+ return self.bookmarkMatchesSearch(bookmark, searchWords: searchWords)
+ }
+ }
+
+ private func bookmarkMatchesSearch(_ bookmark: Bookmark, searchWords: [String]) -> Bool {
+ let searchableText = "\(bookmark.name) \(bookmark.address)".lowercased()
+
+ // All search words must match
+ return searchWords.allSatisfy { word in
+ searchableText.contains(word)
+ }
+ }
+
+ 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
+ }
+
+ let searchWords = self.searchText.lowercased().split(separator: " ").map(String.init)
+
+ return servers.filter { server in
+ let searchableText = "\(server.name ?? "") \(server.address) \(server.description ?? "")".lowercased()
+
+ // All search words must match
+ return searchWords.allSatisfy { word in
+ searchableText.contains(word)
+ }
+ }
+ }
+
+ var body: some View {
+ List(selection: $selection) {
+ ForEach(filteredBookmarks, id: \.self) { bookmark in
+ TrackerItemView(
+ bookmark: bookmark,
+ isExpanded: self.expandedTrackers.contains(bookmark),
+ isLoading: self.loadingTrackers.contains(bookmark),
+ count: self.trackerServers[bookmark]?.count ?? 0
+ ) {
+ self.toggleExpanded(for: bookmark)
+ }
+ .tag(TrackerSelection.bookmark(bookmark))
+
+ if bookmark.type == .tracker && self.expandedTrackers.contains(bookmark) {
+ ForEach(self.filteredServers(for: bookmark), id: \.self) { trackedServer in
+ TrackerBookmarkServerView(server: trackedServer)
+ .moveDisabled(true)
+ .deleteDisabled(true)
+ .tag(TrackerSelection.bookmarkServer(trackedServer))
+ .padding(.leading, 16 + 8 + 10)
+ }
+ }
+ }
+ .onMove { movedIndexes, destinationIndex in
+ Bookmark.move(movedIndexes, to: destinationIndex, context: modelContext)
+ }
+ .onDelete { deletedIndexes in
+ Bookmark.delete(at: deletedIndexes, context: modelContext)
+ }
+ }
+ .onDeleteCommand {
+ switch self.selection {
+ case .bookmark(let bookmark):
+ Bookmark.delete(bookmark, context: modelContext)
+ default:
+ break
+ }
+
+// if let bookmark = selection,
+// bookmark.type != .temporary {
+// Bookmark.delete(bookmark, context: modelContext)
+// }
+ }
+ .environment(\.defaultMinListRowHeight, 34)
+ .listStyle(.inset)
+ .alternatingRowBackgrounds(.enabled)
+ .onChange(of: AppState.shared.cloudKitReady) {
+ if attemptedPrepopulate {
+ print("Tracker: Already attempted to prepopulate bookmarks")
+ return
+ }
+
+ print("Tracker: Prepopulating bookmarks")
+
+ attemptedPrepopulate = true
+
+ // Make sure default bookmarks are there when empty.
+ Bookmark.populateDefaults(context: modelContext)
+ }
+ .onAppear {
+// Bookmark.deleteAll(context: modelContext)
+ }
+ .contextMenu(forSelectionType: TrackerSelection.self) { items in
+ if let item = items.first {
+ switch item {
+ case .bookmark(let bookmark):
+ self.bookmarkContextMenu(bookmark)
+ case .bookmarkServer(let server):
+ self.bookmarkServerContextMenu(server)
+ }
+ }
+ } primaryAction: { items in
+ guard let clickedItem = items.first else {
+ return
+ }
+
+ switch clickedItem {
+ case .bookmark(let bookmark):
+ if bookmark.type == .server {
+ if let s = bookmark.server {
+ openWindow(id: "server", value: s)
+ }
+ }
+ else if bookmark.type == .tracker {
+ if NSEvent.modifierFlags.contains(.option) {
+ trackerSheetBookmark = bookmark
+ }
+ else {
+ self.toggleExpanded(for: bookmark)
+ }
+ }
+
+ case .bookmarkServer(let bookmarkServer):
+ openWindow(id: "server", value: bookmarkServer.server)
+ }
+ }
+ .fileExporter(isPresented: $bookmarkExportActive, document: bookmarkExport, contentTypes: [.data], defaultFilename: "\(bookmarkExport?.bookmark.name ?? "Hotline Bookmark").hlbm", onCompletion: { result in
+ switch result {
+ case .success(let fileURL):
+ print("Hotline Bookmark: Successfully exported:", fileURL)
+ case .failure(let err):
+ print("Hotline Bookmark: Failed to export:", err)
+ }
+
+ bookmarkExport = nil
+ bookmarkExportActive = false
+ }, onCancellation: {})
+ .onKeyPress(.rightArrow) {
+ switch self.selection {
+ case .bookmark(let bookmark):
+ if bookmark.type == .tracker {
+ self.setExpanded(true, for: bookmark)
+ return .handled
+ }
+ default:
+ break
+ }
+
+ return .ignored
+ }
+ .onKeyPress(.leftArrow) {
+ switch self.selection {
+ case .bookmark(let bookmark):
+ if bookmark.type == .tracker {
+ self.setExpanded(false, for: bookmark)
+ return .handled
+ }
+ default:
+ break
+ }
+
+ return .ignored
+ }
+ .onDrop(of: [UTType.fileURL], isTargeted: $fileDropActive) { providers, dropPoint in
+ for provider in providers {
+ let _ = provider.loadDataRepresentation(for: UTType.fileURL) { dataRepresentation, err in
+ // HOTLINE CREATOR CODE: 1213484099
+ // HOTLINE BOOKMARK TYPE CODE: 1213489773
+
+ if let filePathData = dataRepresentation,
+ let filePath = String(data: filePathData, encoding: .utf8),
+ let fileURL = URL(string: filePath) {
+
+ print("Hotline Bookmark: Dropped from ", fileURL.path(percentEncoded: false))
+
+ DispatchQueue.main.async {
+ if let newBookmark = Bookmark(fileURL: fileURL) {
+ print("Hotline Bookmark: Added bookmark.")
+ Bookmark.add(newBookmark, context: modelContext)
+ }
+ else {
+ print("Hotline Bookmark: Failed to parse.")
+ }
+ }
+ }
+ }
+ }
+
+ return true
+ }
+ .sheet(item: $trackerSheetBookmark) { item in
+ TrackerBookmarkSheet(item)
+ }
+ .sheet(isPresented: $trackerSheetPresented) {
+ TrackerBookmarkSheet()
+ }
+ .sheet(item: $serverSheetBookmark) { item in
+ ServerBookmarkSheet(item)
+ }
+ .navigationTitle("Servers")
+ .toolbar {
+ if #available(macOS 26.0, *) {
+ ToolbarItem(placement: .navigation) {
+ self.hotlineLogoImage
+ }
+ .sharedBackgroundVisibility(.hidden)
+ }
+ else {
+ ToolbarItem(placement: .navigation) {
+ self.hotlineLogoImage
+ }
+ }
+
+ ToolbarItem(placement: .primaryAction) {
+ Button {
+ self.refreshing = true
+ self.refresh()
+ self.refreshing = false
+ } label: {
+ Label("Refresh", systemImage: "arrow.clockwise")
+ }
+ .disabled(refreshing)
+ .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")
+ }
+ }
+ .onOpenURL(perform: { url in
+ if let s = Server(url: url) {
+ openWindow(id: "server", value: s)
+ }
+ })
+ .searchable(text: $searchText, isPresented: $isSearching, placement: .automatic, prompt: "Search")
+ .background(Button("", action: { isSearching = true }).keyboardShortcut("f").hidden())
+ }
+
+ private var hotlineLogoImage: some View {
+ Image("Hotline")
+ .resizable()
+ .renderingMode(.template)
+ .scaledToFit()
+ .foregroundColor(Color(hex: 0xE10000))
+ .frame(width: 9)
+ .opacity(controlActiveState == .inactive ? 0.5 : 1.0)
+ }
+
+ @ViewBuilder
+ func bookmarkServerContextMenu(_ server: BookmarkServer) -> some View {
+ Button {
+ let newBookmark = Bookmark(type: .server, name: server.name ?? server.address, address: server.address, port: server.port, login: nil, password: nil)
+ Bookmark.add(newBookmark, context: modelContext)
+ } label: {
+ Label("Bookmark", systemImage: "bookmark")
+ }
+
+ Divider()
+
+ Button {
+ NSPasteboard.general.clearContents()
+ let displayAddress = (server.port == HotlinePorts.DefaultServerPort) ? server.address : "\(server.address):\(server.port)"
+ NSPasteboard.general.setString(displayAddress, forType: .string)
+ } label: {
+ Label("Copy Address", systemImage: "doc.on.doc")
+ }
+ }
+
+ @ViewBuilder
+ func bookmarkContextMenu(_ bookmark: Bookmark) -> some View {
+ Button {
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(bookmark.displayAddress, forType: .string)
+ } label: {
+ Label("Copy Address", systemImage: "doc.on.doc")
+ }
+
+ Divider()
+
+ if bookmark.type == .tracker {
+ Button {
+ trackerSheetBookmark = bookmark
+ } label: {
+ Label("Edit Tracker...", systemImage: "pencil")
+ }
+ }
+
+ if bookmark.type == .server {
+ Button {
+ serverSheetBookmark = bookmark
+ } label: {
+ Label("Edit Bookmark...", systemImage: "pencil")
+ }
+
+ Button {
+ bookmarkExport = BookmarkDocument(bookmark: bookmark)
+ bookmarkExportActive = true
+ } label: {
+ Label("Export Bookmark...", systemImage: "square.and.arrow.down")
+ }
+ }
+
+ Divider()
+
+ Button {
+ Bookmark.delete(bookmark, context: modelContext)
+ } label: {
+ Label(bookmark.type == .tracker ? "Delete Tracker" : "Delete Bookmark", systemImage: "trash")
+ }
+ }
+
+
+ func refresh() {
+ // When a tracker is selected, refresh only that tracker.
+ if let trackerSelection = self.selection {
+ switch trackerSelection {
+ case .bookmark(let bookmark):
+ if bookmark.type == .tracker {
+ if self.expandedTrackers.contains(bookmark) {
+ // 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)
+ }
+ return
+ }
+ break
+ default:
+ break
+ }
+ }
+
+ // Otherwise refresh/expand all trackers.
+ for bookmark in self.bookmarks {
+ if bookmark.type == .tracker {
+ if self.expandedTrackers.contains(bookmark) {
+ // 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)
+ }
+ }
+ }
+ }
+
+ func toggleExpanded(for bookmark: Bookmark) {
+ guard bookmark.type == .tracker else { return }
+ self.setExpanded(!self.expandedTrackers.contains(bookmark), for: bookmark)
+ }
+
+ func setExpanded(_ expanded: Bool, for bookmark: Bookmark) {
+ guard bookmark.type == .tracker else { return }
+
+ if expanded && !self.expandedTrackers.contains(bookmark) {
+ self.expandedTrackers.insert(bookmark)
+ 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")
+ }
+ }
+}
+
+#if DEBUG
+private struct TrackerViewPreview: View {
+ @State var selection: TrackerSelection? = nil
+
+ var body: some View {
+ TrackerView(selection: $selection)
+ }
+}
+
+#Preview {
+ TrackerViewPreview()
+}
+#endif