From ede41868962ffed386b0da694d14cdfe6cfdb34f Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Mon, 20 Oct 2025 22:15:48 -0700 Subject: Cleanup and a bunch of work to fix issues syncing, loading, and general bookmark states in TrackerView. Added search field (substring conjunctive filter on server/bookmark names and descriptions). --- Hotline.xcodeproj/project.pbxproj | 50 +- .../xcshareddata/swiftpm/Package.resolved | 17 +- Hotline/Application-macOS.swift | 303 ------------- Hotline/Assets/Sounds/chat-message.aiff | Bin 0 -> 4554 bytes Hotline/Assets/Sounds/error.aiff | Bin 0 -> 4610 bytes Hotline/Assets/Sounds/logged-in.aiff | Bin 0 -> 4166 bytes Hotline/Assets/Sounds/new-news.aiff | Bin 0 -> 17514 bytes Hotline/Assets/Sounds/server-message.aiff | Bin 0 -> 7914 bytes Hotline/Assets/Sounds/transfer-complete.aiff | Bin 0 -> 21378 bytes Hotline/Assets/Sounds/user-login.aiff | Bin 0 -> 6186 bytes Hotline/Assets/Sounds/user-logout.aiff | Bin 0 -> 7080 bytes Hotline/Library/AsyncLinkPreview.swift | 57 +++ Hotline/Library/DataAdditions.swift | 23 + Hotline/Library/FileIconView.swift | 74 +++ Hotline/Library/FileImageView.swift | 98 ++++ Hotline/Library/HotlinePanel.swift | 64 +++ Hotline/Library/NetSocket.swift | 269 +++++++++++ Hotline/Library/TextView.swift | 119 +++++ Hotline/Library/URLAdditions.swift | 26 ++ Hotline/Library/VisualEffectView.swift | 22 + Hotline/MacApp.swift | 303 +++++++++++++ Hotline/Models/ApplicationState.swift | 16 - Hotline/Models/Bookmark.swift | 100 ++-- Hotline/Models/Preferences.swift | 134 ------ Hotline/Shared/AsyncLinkPreview.swift | 57 --- Hotline/Shared/DataAdditions.swift | 23 - Hotline/Shared/FileIconView.swift | 74 --- Hotline/Shared/FileImageView.swift | 98 ---- Hotline/Shared/HotlinePanel.swift | 64 --- Hotline/Shared/NetSocket.swift | 269 ----------- Hotline/Shared/TextView.swift | 119 ----- Hotline/Shared/URLAdditions.swift | 26 -- Hotline/Shared/VisualEffectView.swift | 22 - Hotline/Sounds/Application-iOS.swift | 18 - Hotline/Sounds/chat-message.aiff | Bin 4554 -> 0 bytes Hotline/Sounds/error.aiff | Bin 4610 -> 0 bytes Hotline/Sounds/logged-in.aiff | Bin 4166 -> 0 bytes Hotline/Sounds/new-news.aiff | Bin 17514 -> 0 bytes Hotline/Sounds/server-message.aiff | Bin 7914 -> 0 bytes Hotline/Sounds/transfer-complete.aiff | Bin 21378 -> 0 bytes Hotline/Sounds/user-login.aiff | Bin 6186 -> 0 bytes Hotline/Sounds/user-logout.aiff | Bin 7080 -> 0 bytes Hotline/State/AppState.swift | 24 + Hotline/State/Preferences.swift | 138 ++++++ Hotline/iOSApp.swift | 18 + Hotline/macOS/HotlinePanelView.swift | 24 +- Hotline/macOS/TrackerView.swift | 503 ++++++++++++++------- 47 files changed, 1680 insertions(+), 1472 deletions(-) delete mode 100644 Hotline/Application-macOS.swift create mode 100644 Hotline/Assets/Sounds/chat-message.aiff create mode 100644 Hotline/Assets/Sounds/error.aiff create mode 100644 Hotline/Assets/Sounds/logged-in.aiff create mode 100644 Hotline/Assets/Sounds/new-news.aiff create mode 100644 Hotline/Assets/Sounds/server-message.aiff create mode 100644 Hotline/Assets/Sounds/transfer-complete.aiff create mode 100644 Hotline/Assets/Sounds/user-login.aiff create mode 100644 Hotline/Assets/Sounds/user-logout.aiff create mode 100644 Hotline/Library/AsyncLinkPreview.swift create mode 100644 Hotline/Library/DataAdditions.swift create mode 100644 Hotline/Library/FileIconView.swift create mode 100644 Hotline/Library/FileImageView.swift create mode 100644 Hotline/Library/HotlinePanel.swift create mode 100644 Hotline/Library/NetSocket.swift create mode 100644 Hotline/Library/TextView.swift create mode 100644 Hotline/Library/URLAdditions.swift create mode 100644 Hotline/Library/VisualEffectView.swift create mode 100644 Hotline/MacApp.swift delete mode 100644 Hotline/Models/ApplicationState.swift delete mode 100644 Hotline/Models/Preferences.swift delete mode 100644 Hotline/Shared/AsyncLinkPreview.swift delete mode 100644 Hotline/Shared/DataAdditions.swift delete mode 100644 Hotline/Shared/FileIconView.swift delete mode 100644 Hotline/Shared/FileImageView.swift delete mode 100644 Hotline/Shared/HotlinePanel.swift delete mode 100644 Hotline/Shared/NetSocket.swift delete mode 100644 Hotline/Shared/TextView.swift delete mode 100644 Hotline/Shared/URLAdditions.swift delete mode 100644 Hotline/Shared/VisualEffectView.swift delete mode 100644 Hotline/Sounds/Application-iOS.swift delete mode 100644 Hotline/Sounds/chat-message.aiff delete mode 100644 Hotline/Sounds/error.aiff delete mode 100644 Hotline/Sounds/logged-in.aiff delete mode 100644 Hotline/Sounds/new-news.aiff delete mode 100644 Hotline/Sounds/server-message.aiff delete mode 100644 Hotline/Sounds/transfer-complete.aiff delete mode 100644 Hotline/Sounds/user-login.aiff delete mode 100644 Hotline/Sounds/user-logout.aiff create mode 100644 Hotline/State/AppState.swift create mode 100644 Hotline/State/Preferences.swift create mode 100644 Hotline/iOSApp.swift diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index cf71fdd..a188bfd 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -24,6 +24,7 @@ DA32CD4F2B2931CC0053B98B /* NewsInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32CD4E2B2931CC0053B98B /* NewsInfo.swift */; }; DA43205E2B1D615600FC8843 /* ServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA43205D2B1D615600FC8843 /* ServerView.swift */; platformFilter = ios; }; DA4930BD2B4F8DD700822D0B /* NetSocket.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4930BC2B4F8DD700822D0B /* NetSocket.swift */; }; + DA4B8F3A2EA6FB3C00CBFD53 /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4B8F392EA6FB3C00CBFD53 /* iOSApp.swift */; platformFilter = ios; }; DA4F2BF82B16A17200D8ADDC /* HotlineProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4F2BF72B16A17200D8ADDC /* HotlineProtocol.swift */; }; DA4F2C012B1A558E00D8ADDC /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4F2C002B1A558E00D8ADDC /* ChatView.swift */; platformFilter = ios; }; DA55AC732BE42AF000034857 /* AsyncLinkPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA55AC722BE42AF000034857 /* AsyncLinkPreview.swift */; platformFilters = (macos, ); }; @@ -47,7 +48,7 @@ DA872B132BDDBF78008B1012 /* HotlinePanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA872B122BDDBF78008B1012 /* HotlinePanel.swift */; platformFilters = (macos, ); }; DA872B152BDDEE1A008B1012 /* VisualEffectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA872B142BDDEE1A008B1012 /* VisualEffectView.swift */; platformFilters = (macos, ); }; DA99218E2C51AA5D0058FA6C /* HotlineDataBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA99218D2C51AA5D0058FA6C /* HotlineDataBuilder.swift */; }; - DA9CAFBB2B126D5700CDA197 /* Application-macOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA9CAFBA2B126D5700CDA197 /* Application-macOS.swift */; platformFilters = (macos, ); }; + DA9CAFBB2B126D5700CDA197 /* MacApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA9CAFBA2B126D5700CDA197 /* MacApp.swift */; platformFilters = (macos, ); }; DA9CAFBD2B126D5700CDA197 /* TrackerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA9CAFBC2B126D5700CDA197 /* TrackerView.swift */; platformFilter = ios; }; DA9CAFC12B126D5800CDA197 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DA9CAFC02B126D5800CDA197 /* Assets.xcassets */; }; DA9CAFC42B126D5800CDA197 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DA9CAFC32B126D5800CDA197 /* Preview Assets.xcassets */; }; @@ -72,8 +73,7 @@ DABE8C042B57940B00884D28 /* DAKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABE8C032B57940A00884D28 /* DAKeychain.swift */; }; DABFCC292B1530DC009F40D2 /* FoundationExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABFCC282B1530DC009F40D2 /* FoundationExtensions.swift */; }; DAC002192B21630900A6C290 /* SwiftUIExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC002182B21630900A6C290 /* SwiftUIExtensions.swift */; }; - DAC3D97E2BC0F1ED00A727C9 /* Application-iOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC3D97D2BC0F1ED00A727C9 /* Application-iOS.swift */; platformFilter = ios; }; - DAC3D9832BC33FD000A727C9 /* ApplicationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC3D9822BC33FD000A727C9 /* ApplicationState.swift */; }; + DAC3D9832BC33FD000A727C9 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC3D9822BC33FD000A727C9 /* AppState.swift */; }; DAC87F072C5010E80060FADF /* HotlineExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC87F062C5010E80060FADF /* HotlineExtensions.swift */; }; DADDB28B2B22B31F0024040D /* Tracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADDB28A2B22B31F0024040D /* Tracker.swift */; }; DADDB28D2B22B5920024040D /* Server.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADDB28C2B22B5920024040D /* Server.swift */; }; @@ -109,6 +109,7 @@ DA32CD4E2B2931CC0053B98B /* NewsInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsInfo.swift; sourceTree = ""; }; DA43205D2B1D615600FC8843 /* ServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerView.swift; sourceTree = ""; }; DA4930BC2B4F8DD700822D0B /* NetSocket.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetSocket.swift; sourceTree = ""; }; + DA4B8F392EA6FB3C00CBFD53 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; }; DA4F2BF72B16A17200D8ADDC /* HotlineProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineProtocol.swift; sourceTree = ""; }; DA4F2C002B1A558E00D8ADDC /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = ""; }; DA55AC722BE42AF000034857 /* AsyncLinkPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncLinkPreview.swift; sourceTree = ""; }; @@ -132,7 +133,7 @@ DA872B142BDDEE1A008B1012 /* VisualEffectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualEffectView.swift; sourceTree = ""; }; DA99218D2C51AA5D0058FA6C /* HotlineDataBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineDataBuilder.swift; sourceTree = ""; }; DA9CAFB72B126D5700CDA197 /* Hotline.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Hotline.app; sourceTree = BUILT_PRODUCTS_DIR; }; - DA9CAFBA2B126D5700CDA197 /* Application-macOS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Application-macOS.swift"; sourceTree = ""; }; + DA9CAFBA2B126D5700CDA197 /* MacApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacApp.swift; sourceTree = ""; }; DA9CAFBC2B126D5700CDA197 /* TrackerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackerView.swift; sourceTree = ""; }; DA9CAFC02B126D5800CDA197 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; DA9CAFC32B126D5800CDA197 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; @@ -156,8 +157,7 @@ DABE8C032B57940A00884D28 /* DAKeychain.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DAKeychain.swift; sourceTree = ""; }; DABFCC282B1530DC009F40D2 /* FoundationExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoundationExtensions.swift; sourceTree = ""; }; DAC002182B21630900A6C290 /* SwiftUIExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIExtensions.swift; sourceTree = ""; }; - DAC3D97D2BC0F1ED00A727C9 /* Application-iOS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = "Application-iOS.swift"; path = "Sounds/Application-iOS.swift"; sourceTree = ""; }; - DAC3D9822BC33FD000A727C9 /* ApplicationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationState.swift; sourceTree = ""; }; + DAC3D9822BC33FD000A727C9 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; DAC87F062C5010E80060FADF /* HotlineExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineExtensions.swift; sourceTree = ""; }; DADDB28A2B22B31F0024040D /* Tracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tracker.swift; sourceTree = ""; }; DADDB28C2B22B5920024040D /* Server.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Server.swift; sourceTree = ""; }; @@ -186,6 +186,23 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + DA4B8F3B2EA6FB5F00CBFD53 /* State */ = { + isa = PBXGroup; + children = ( + DAC3D9822BC33FD000A727C9 /* AppState.swift */, + DA2863D92B37BF6E00A7D050 /* Preferences.swift */, + ); + path = State; + sourceTree = ""; + }; + DA4B8F3C2EA6FDCE00CBFD53 /* Assets */ = { + isa = PBXGroup; + children = ( + DABE8BF22B55DBFC00884D28 /* Sounds */, + ); + path = Assets; + sourceTree = ""; + }; DA4F2BF92B1A516F00D8ADDC /* iOS */ = { isa = PBXGroup; children = ( @@ -219,15 +236,16 @@ DA9CAFB92B126D5700CDA197 /* Hotline */ = { isa = PBXGroup; children = ( - DA9CAFBA2B126D5700CDA197 /* Application-macOS.swift */, - DAC3D97D2BC0F1ED00A727C9 /* Application-iOS.swift */, - DABE8BF22B55DBFC00884D28 /* Sounds */, + DA9CAFBA2B126D5700CDA197 /* MacApp.swift */, + DA4B8F392EA6FB3C00CBFD53 /* iOSApp.swift */, DA4F2BF92B1A516F00D8ADDC /* iOS */, DAE734F72B2E4126000C56F6 /* macOS */, + DA4B8F3B2EA6FB5F00CBFD53 /* State */, DADDB2892B22B2C60024040D /* Models */, DABFCC262B1530AE009F40D2 /* Hotline */, - DAB4D87C2B4B783A0048A05C /* Shared */, + DAB4D87C2B4B783A0048A05C /* Library */, DABFCC272B1530BE009F40D2 /* Utility */, + DA4B8F3C2EA6FDCE00CBFD53 /* Assets */, DA9CAFC02B126D5800CDA197 /* Assets.xcassets */, DA32CD472B28EE640053B98B /* Info.plist */, DA2863DB2B37DBE000A7D050 /* Hotline.entitlements */, @@ -244,7 +262,7 @@ path = "Preview Content"; sourceTree = ""; }; - DAB4D87C2B4B783A0048A05C /* Shared */ = { + DAB4D87C2B4B783A0048A05C /* Library */ = { isa = PBXGroup; children = ( DAB4D87A2B4B78310048A05C /* FileImageView.swift */, @@ -257,7 +275,7 @@ DA55AC722BE42AF000034857 /* AsyncLinkPreview.swift */, DA6980782BF80525003E434B /* TextView.swift */, ); - path = Shared; + path = Library; sourceTree = ""; }; DABE8BF22B55DBFC00884D28 /* Sounds */ = { @@ -309,8 +327,6 @@ children = ( DADDB28E2B238D850024040D /* Hotline.swift */, DADDB28A2B22B31F0024040D /* Tracker.swift */, - DAC3D9822BC33FD000A727C9 /* ApplicationState.swift */, - DA2863D92B37BF6E00A7D050 /* Preferences.swift */, DADDB28C2B22B5920024040D /* Server.swift */, DA32CD482B2931640053B98B /* User.swift */, 11A726072BE0672A000C1DA7 /* FileDetails.swift */, @@ -443,12 +459,11 @@ DA6300972B24036B0034CBFD /* HotlineClient.swift in Sources */, DA43205E2B1D615600FC8843 /* ServerView.swift in Sources */, DA0D69912B1E894800C71DF5 /* FilesView.swift in Sources */, - DAC3D9832BC33FD000A727C9 /* ApplicationState.swift in Sources */, + DAC3D9832BC33FD000A727C9 /* AppState.swift in Sources */, DA65499C2BEC3FBD00EDB697 /* ServerAgreementView.swift in Sources */, DAB4D8802B4C8E9A0048A05C /* URLAdditions.swift in Sources */, DA2863DD2B3E8B7000A7D050 /* FilePreview.swift in Sources */, DA2863D82B37AD1C00A7D050 /* SettingsView.swift in Sources */, - DAC3D97E2BC0F1ED00A727C9 /* Application-iOS.swift in Sources */, DA7725412B21435B006C5ABB /* ObservableScrollView.swift in Sources */, DAE734FF2B2E6750000C56F6 /* ChatView.swift in Sources */, 11F8288B2BF9428100216BA0 /* AccountManagerView.swift in Sources */, @@ -473,7 +488,7 @@ DA9CAFCB2B126E3300CDA197 /* HotlineTrackerClient.swift in Sources */, DA2863DA2B37BF6E00A7D050 /* Preferences.swift in Sources */, DA32CD4F2B2931CC0053B98B /* NewsInfo.swift in Sources */, - DA9CAFBB2B126D5700CDA197 /* Application-macOS.swift in Sources */, + DA9CAFBB2B126D5700CDA197 /* MacApp.swift in Sources */, DAAEE66B2B3FBC2100A5BA07 /* TransferInfo.swift in Sources */, DA0D698F2B1E841600C71DF5 /* MessageBoardView.swift in Sources */, DAE735072B3251B3000C56F6 /* SoundEffects.swift in Sources */, @@ -488,6 +503,7 @@ DA77253F2B21176D006C5ABB /* NewsView.swift in Sources */, DA99218E2C51AA5D0058FA6C /* HotlineDataBuilder.swift in Sources */, DA872B132BDDBF78008B1012 /* HotlinePanel.swift in Sources */, + DA4B8F3A2EA6FB3C00CBFD53 /* iOSApp.swift in Sources */, DA4F2BF82B16A17200D8ADDC /* HotlineProtocol.swift in Sources */, DA5753682B33E88A00FAC277 /* HotlineTransferClient.swift in Sources */, DAB4D8822B4C8FED0048A05C /* FileIconView.swift in Sources */, diff --git a/Hotline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Hotline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 13c2046..28a7003 100644 --- a/Hotline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Hotline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/gonzalezreal/NetworkImage", "state" : { - "revision" : "7aff8d1b31148d32c5933d75557d42f6323ee3d1", - "version" : "6.0.0" + "revision" : "2849f5323265386e200484b0d0f896e73c3411b9", + "version" : "6.0.1" } }, { @@ -19,13 +19,22 @@ "version" : "3.5.3" } }, + { + "identity" : "swift-cmark", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-cmark", + "state" : { + "revision" : "b97d09472e847a416629f026eceae0e2afcfad65", + "version" : "0.7.0" + } + }, { "identity" : "swift-markdown-ui", "kind" : "remoteSourceControl", "location" : "https://github.com/gonzalezreal/swift-markdown-ui", "state" : { - "revision" : "9a8119b37e09a770367eeb26e05267c75d854053", - "version" : "2.3.1" + "revision" : "5f613358148239d0292c0cef674a3c2314737f9e", + "version" : "2.4.1" } } ], diff --git a/Hotline/Application-macOS.swift b/Hotline/Application-macOS.swift deleted file mode 100644 index fe3a828..0000000 --- a/Hotline/Application-macOS.swift +++ /dev/null @@ -1,303 +0,0 @@ -import SwiftUI -import SwiftData -import CloudKit -import UniformTypeIdentifiers - -@Observable -final class AppLaunchState { - static let shared = AppLaunchState() - - enum LaunchState { - case loading - case launched - case terminated - } - - var launchState = LaunchState.loading -} - -class AppDelegate: NSObject, NSApplicationDelegate { - private var cloudKitObserverToken: Any? = nil - - func applicationDidFinishLaunching(_ notification: Notification) { - AppLaunchState.shared.launchState = .launched - - CKContainer.default().accountStatus { status, error in - switch status { - case .noAccount: - print("iCloud Unavailable") - - // We mark CloudKit has available now since we're not waiting on - // a server sync or anything. - ApplicationState.shared.cloudKitReady = true - default: - print("iCloud Available") - - self.cloudKitObserverToken = NotificationCenter.default.addObserver(forName: NSPersistentCloudKitContainer.eventChangedNotification, object: nil, queue: OperationQueue.main) { [weak self] note in - print("iCloud Changed!") - ApplicationState.shared.cloudKitReady = true - - guard let token = self?.cloudKitObserverToken else { return } - NotificationCenter.default.removeObserver(token) - } - } - } - -// if FileManager.default.ubiquityIdentityToken == nil { -// print("iCloud Unavailable") -// -// // We mark CloudKit has available now since we're not waiting on -// // a server sync or anything. -// ApplicationState.shared.cloudKitReady = true -// } -// else { -// print("iCloud Available") -// -// self.cloudKitObserverToken = NotificationCenter.default.addObserver(forName: NSPersistentCloudKitContainer.eventChangedNotification, object: nil, queue: OperationQueue.main) { [weak self] note in -// print("iCloud Changed!") -// ApplicationState.shared.cloudKitReady = true -// -// guard let token = self?.cloudKitObserverToken else { return } -// NotificationCenter.default.removeObserver(token) -// } -// } - } - - func applicationWillTerminate(_ notification: Notification) { - AppLaunchState.shared.launchState = .terminated - } -} - -@main -struct Application: App { - @Environment(\.scenePhase) private var scenePhase - @Environment(\.openWindow) private var openWindow - @Environment(\.openURL) private var openURL - - @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate - - @State private var hotlinePanel: HotlinePanel? = nil - @State private var selection: Bookmark? = nil - - @FocusedValue(\.activeHotlineModel) private var activeHotline: Hotline? - @FocusedValue(\.activeServerState) private var activeServerState: ServerState? - - private var modelContainer: ModelContainer = { - let schema = Schema([ - Bookmark.self - ]) - let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false, cloudKitDatabase: .private("iCloud.co.goodmake.hotline")) - let modelContainer = try! ModelContainer(for: schema, configurations: [config]) - - // Print local SwiftData sqlite file. -// print(modelContainer.configurations.first?.url.path(percentEncoded: false)) - - return modelContainer - }() - - var body: some Scene { - // MARK: Tracker Window - Window("Servers", id: "servers") { - TrackerView(selection: $selection) - .frame(minWidth: 250, minHeight: 250) - } - .modelContainer(self.modelContainer) - .defaultSize(width: 700, height: 550) - .defaultPosition(.center) - .keyboardShortcut(.init("R"), modifiers: .command) - .onChange(of: AppLaunchState.shared.launchState) { - if AppLaunchState.shared.launchState == .launched { - if Prefs.shared.showBannerToolbar { - showBannerWindow() - } - } - } - - // MARK: About Box - Window("About", id: "about") { - AboutView() - .ignoresSafeArea() - .background(Color.hotlineRed) - } - .windowResizability(.contentSize) - .windowStyle(.hiddenTitleBar) - .defaultPosition(.center) - .commandsRemoved() // Remove About that was automatically added to Window menu. - .commands { - CommandGroup(replacing: CommandGroupPlacement.appInfo) { - Button("About Hotline") { - openWindow(id: "about") - } - } - } - - // MARK: Server Window - WindowGroup(id: "server", for: Server.self) { server in - ServerView(server: server) - .frame(minWidth: 430, minHeight: 300) - } defaultValue: { - Server(name: nil, description: nil, address: "") - } - .modelContainer(self.modelContainer) - .defaultSize(width: 750, height: 700) - .defaultPosition(.center) - .onChange(of: activeServerState) { - ApplicationState.shared.activeServerState = activeServerState - } - .onChange(of: activeHotline) { - ApplicationState.shared.activeHotline = activeHotline - } - .onChange(of: activeHotline?.serverTitle) { - if let hotline = activeHotline { - ApplicationState.shared.activeServerName = hotline.serverTitle - } - } - .onChange(of: activeHotline?.bannerImage) { - withAnimation { - ApplicationState.shared.activeServerBanner = activeHotline?.bannerImage - } - } - .onChange(of: activeHotline) { - ApplicationState.shared.activeHotline = activeHotline - if let hotline = activeHotline { - ApplicationState.shared.activeServerName = hotline.serverTitle - } - } - .commands { - CommandGroup(replacing: .newItem) { - Button("Connect to Server...") { - openWindow(id: "server") - } - .keyboardShortcut(.init("K"), modifiers: .command) - } - CommandGroup(after: .singleWindowList) { - Button("Toolbar") { - toggleBannerWindow() - } - .keyboardShortcut(.init("\\"), modifiers: [.shift, .command]) - } - CommandGroup(after: .help) { - Divider() - Button("Request Feature...") { - if let url = URL(string: "https://github.com/mierau/hotline/issues/new?labels=enhancement") { - openURL(url) - } - } - Button("Report Bug...") { - if let url = URL(string: "https://github.com/mierau/hotline/issues/new?labels=bug") { - openURL(url) - } - } - Divider() - Button("Download Latest...") { - if let url = URL(string: "https://github.com/mierau/hotline/releases/latest") { - openURL(url) - } - } - } - CommandMenu("Server") { - Button("Connect") { - guard let selection else { - return - } - connect(to: selection) - } - .disabled(selection == nil || selection?.server == nil) - .keyboardShortcut(.downArrow, modifiers: .command) - Button("Disconnect") { - activeHotline?.disconnect() - } - .disabled(activeHotline?.status == .disconnected) - Divider() - Button("Broadcast Message...") { - // TODO: Implement broadcast message when user is allowed. - } - .disabled(true) - .keyboardShortcut(.init("B"), modifiers: .command) - Divider() - Button("Show Chat") { - activeServerState?.selection = .chat - } - .disabled(activeHotline?.status != .loggedIn) - .keyboardShortcut(.init("1"), modifiers: .command) - Button("Show News") { - activeServerState?.selection = .news - } - .disabled(activeHotline?.status != .loggedIn || (activeHotline?.serverVersion ?? 0) < 151) - .keyboardShortcut(.init("2"), modifiers: .command) - Button("Show Message Board") { - activeServerState?.selection = .board - } - .disabled(activeHotline?.status != .loggedIn) - .keyboardShortcut(.init("3"), modifiers: .command) - Button("Show Files") { - activeServerState?.selection = .files - } - .disabled(activeHotline?.status != .loggedIn) - .keyboardShortcut(.init("4"), modifiers: .command) - Button("Show Accounts") { - activeServerState?.selection = .accounts - } - .disabled(activeHotline?.status != .loggedIn || activeHotline?.access?.contains(.canOpenUsers) != true ) - .keyboardShortcut(.init("5"), modifiers: .command) - } - } - - // MARK: Settings Window - Settings { - SettingsView() - } - - // MARK: Image Preview Window - WindowGroup(id: "preview-image", for: PreviewFileInfo.self) { $info in - FilePreviewImageView(info: $info) - } - .windowResizability(.contentSize) - .windowStyle(.titleBar) - .windowToolbarStyle(.unifiedCompact(showsTitle: true)) - .defaultSize(width: 350, height: 150) - .defaultPosition(.center) - - // MARK: Text Preview Window - WindowGroup(id: "preview-text", for: PreviewFileInfo.self) { $info in - FilePreviewTextView(info: $info) - } - .windowResizability(.automatic) - .windowStyle(.titleBar) - .windowToolbarStyle(.unifiedCompact(showsTitle: true)) - .defaultSize(width: 450, height: 550) - .defaultPosition(.center) - } - - func connect(to item: Bookmark) { - if let server = item.server { - openWindow(id: "server", value: server) - } - } - - func showBannerWindow() { - if hotlinePanel == nil { - hotlinePanel = HotlinePanel(HotlinePanelView()) - } - - if hotlinePanel?.isVisible == false { - hotlinePanel?.orderFront(nil) - Prefs.shared.showBannerToolbar = true - } - } - - func toggleBannerWindow() { - if hotlinePanel == nil { - hotlinePanel = HotlinePanel(HotlinePanelView()) - } - - if hotlinePanel?.isVisible == true { - hotlinePanel?.orderOut(nil) - Prefs.shared.showBannerToolbar = false - } - else { - hotlinePanel?.orderFront(nil) - Prefs.shared.showBannerToolbar = true - } - } -} diff --git a/Hotline/Assets/Sounds/chat-message.aiff b/Hotline/Assets/Sounds/chat-message.aiff new file mode 100644 index 0000000..73ea391 Binary files /dev/null and b/Hotline/Assets/Sounds/chat-message.aiff differ diff --git a/Hotline/Assets/Sounds/error.aiff b/Hotline/Assets/Sounds/error.aiff new file mode 100644 index 0000000..8a9f7f6 Binary files /dev/null and b/Hotline/Assets/Sounds/error.aiff differ diff --git a/Hotline/Assets/Sounds/logged-in.aiff b/Hotline/Assets/Sounds/logged-in.aiff new file mode 100644 index 0000000..5f59de0 Binary files /dev/null and b/Hotline/Assets/Sounds/logged-in.aiff differ diff --git a/Hotline/Assets/Sounds/new-news.aiff b/Hotline/Assets/Sounds/new-news.aiff new file mode 100644 index 0000000..5c56582 Binary files /dev/null and b/Hotline/Assets/Sounds/new-news.aiff differ diff --git a/Hotline/Assets/Sounds/server-message.aiff b/Hotline/Assets/Sounds/server-message.aiff new file mode 100644 index 0000000..152bebe Binary files /dev/null and b/Hotline/Assets/Sounds/server-message.aiff differ diff --git a/Hotline/Assets/Sounds/transfer-complete.aiff b/Hotline/Assets/Sounds/transfer-complete.aiff new file mode 100644 index 0000000..b22e0ae Binary files /dev/null and b/Hotline/Assets/Sounds/transfer-complete.aiff differ diff --git a/Hotline/Assets/Sounds/user-login.aiff b/Hotline/Assets/Sounds/user-login.aiff new file mode 100644 index 0000000..0dcfb6f Binary files /dev/null and b/Hotline/Assets/Sounds/user-login.aiff differ diff --git a/Hotline/Assets/Sounds/user-logout.aiff b/Hotline/Assets/Sounds/user-logout.aiff new file mode 100644 index 0000000..8999edc Binary files /dev/null and b/Hotline/Assets/Sounds/user-logout.aiff differ diff --git a/Hotline/Library/AsyncLinkPreview.swift b/Hotline/Library/AsyncLinkPreview.swift new file mode 100644 index 0000000..89b186f --- /dev/null +++ b/Hotline/Library/AsyncLinkPreview.swift @@ -0,0 +1,57 @@ +import SwiftUI +import LinkPresentation + +fileprivate class CustomLinkView: LPLinkView { + override var intrinsicContentSize: CGSize { CGSize(width: 0, height: super.intrinsicContentSize.height) } +} + +struct AsyncLinkPreview: View { + @State private var metadata: LPLinkMetadata? + @State private var isLoading = true + let url: URL? + + func fetchMetadata() async { + guard let url else { + self.isLoading = false + return + } + do { + let provider = LPMetadataProvider() + let metadata = try await provider.startFetchingMetadata(for: url) + self.metadata = metadata + self.isLoading = false + } catch { + self.isLoading = false + } + } + + var body: some View { + if isLoading { + ProgressView() + .controlSize(.small) + .task { + await self.fetchMetadata() + } + } else if let metadata = metadata { + LinkView(metadata: metadata) + .frame(width: 200) + } else { + Text(LocalizedStringKey(url!.absoluteString)) + .multilineTextAlignment(.leading) + .tint(Color("Link Color")) + } + } +} + +struct LinkView: NSViewRepresentable { + var metadata: LPLinkMetadata + + func makeNSView(context: Context) -> LPLinkView { + let linkView = CustomLinkView(metadata: metadata) + return linkView + } + + func updateNSView(_ nsView: LPLinkView, context: Context) { + // Nothing required + } +} diff --git a/Hotline/Library/DataAdditions.swift b/Hotline/Library/DataAdditions.swift new file mode 100644 index 0000000..0e9dd96 --- /dev/null +++ b/Hotline/Library/DataAdditions.swift @@ -0,0 +1,23 @@ +import Foundation + +extension Data { + func saveAsFileToDownloads(filename: String, bounceDock: Bool = true) -> Bool { + let folderURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0] + let filePath = folderURL.generateUniqueFilePath(filename: filename) + if FileManager.default.createFile(atPath: filePath, contents: nil) { + if let h = FileHandle(forWritingAtPath: filePath) { + try? h.write(contentsOf: self) + try? h.close() + if bounceDock { + #if os(macOS) + var downloadURL = URL(filePath: filePath) + downloadURL.resolveSymlinksInPath() + DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path) + #endif + } + return true + } + } + return false + } +} diff --git a/Hotline/Library/FileIconView.swift b/Hotline/Library/FileIconView.swift new file mode 100644 index 0000000..d0949fa --- /dev/null +++ b/Hotline/Library/FileIconView.swift @@ -0,0 +1,74 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct FolderIconView: View { + private func folderIcon() -> Image { +#if os(iOS) + return Image(systemName: "folder.fill") +#elseif os(macOS) + return Image(nsImage: NSWorkspace.shared.icon(for: UTType.folder)) +#endif + } + + var body: some View { + folderIcon() + .resizable() + .scaledToFit() + } +} + +struct FileIconView: View { + let filename: String + let fileType: String? + + #if os(iOS) + private func fileIcon() -> Image { + let fileExtension = (self.filename as NSString).pathExtension + if let fileType = UTType(filenameExtension: fileExtension) { + if fileType.isSubtype(of: .movie) { + return Image(systemName: "play.rectangle") + } + else if fileType.isSubtype(of: .image) { + return Image(systemName: "photo") + } + else if fileType.isSubtype(of: .archive) { + return Image(systemName: "doc.zipper") + } + else if fileType.isSubtype(of: .text) { + return Image(systemName: "doc.text") + } + else { + return Image(systemName: "doc") + } + } + + return Image(systemName: "doc") + } + #elseif os(macOS) + private func fileIcon() -> Image { + let fileExtension = (self.filename as NSString).pathExtension + + if !fileExtension.isEmpty, + let uttype = UTType(filenameExtension: fileExtension) { + return Image(nsImage: NSWorkspace.shared.icon(for: uttype)) + } + else if let fileType = self.fileType, + let fileTypeExtension = FileManager.HFSTypeToExtension[fileType.lowercased()], + let uttype = UTType(filenameExtension: fileTypeExtension) { + return Image(nsImage: NSWorkspace.shared.icon(for: uttype)) + } + else { + return Image(nsImage: NSWorkspace.shared.icon(for: UTType.data)) + } + +// Image(nsImage: NSWorkspace.shared.icon(for: UTType(filenameExtension: (filename as NSString).pathExtension) ?? UTType.content)) + } + #endif + + + var body: some View { + fileIcon() + .resizable() + .scaledToFit() + } +} diff --git a/Hotline/Library/FileImageView.swift b/Hotline/Library/FileImageView.swift new file mode 100644 index 0000000..0cc50f1 --- /dev/null +++ b/Hotline/Library/FileImageView.swift @@ -0,0 +1,98 @@ +import SwiftUI + +struct FileImageView: NSViewRepresentable { + var image: NSImage? + + let minimumSize: CGSize = CGSize(width: 350, height: 350) + let presentationPaddingRatio: Double = 0.5 + + func makeNSView(context: Context) -> NSImageView { + let imageView = NSImageView() + imageView.imageScaling = .scaleProportionallyUpOrDown + imageView.animates = true + imageView.isEditable = false + imageView.allowsCutCopyPaste = true + imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + if let img = self.image { + imageView.image = img + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.resizeWindowForImageView(imageView) + } + } + + return imageView + } + + func updateNSView(_ nsView: NSImageView, context: Context) { + nsView.image = self.image + } + + // MARK: - + + func resizeWindowForImageView(_ imageView: NSImageView) { + guard let window = imageView.window, let img = imageView.image else { + return + } + + var windowRect = window.contentLayoutRect + let windowChromeSize = CGSize(width: window.frame.width - windowRect.width, height: window.frame.height - windowRect.height) + + var windowMinSize: CGSize = windowRect.size + let centerPoint = CGPoint(x: window.frame.midX, y: window.frame.midY) + + windowRect.size = img.size + + if let screen = window.screen { + var paddedScreenSize = screen.frame.size + paddedScreenSize.width *= self.presentationPaddingRatio + paddedScreenSize.height *= self.presentationPaddingRatio + + windowMinSize = aspectFit(source: windowRect.size, bounds: self.minimumSize) + windowRect.size = aspectFit(source: windowRect.size, bounds: paddedScreenSize, minimum: windowMinSize) + } + + windowRect.size.width += windowChromeSize.width + windowRect.size.height += windowChromeSize.height + + windowRect.origin.x = centerPoint.x - windowRect.width / 2.0 + windowRect.origin.y = centerPoint.y - windowRect.height / 2.0 + + window.setFrame(windowRect, display: true, animate: true) + +// Do these APIs even work?? +// window.aspectRatio = windowRect.size +// window.contentAspectRatio = windowRect.size + } + + func aspectFit(source sourceSize: CGSize, bounds boundingSize: CGSize, minimum minSize: CGSize? = nil) -> CGSize { + let sourceAspectRatio = sourceSize.width / sourceSize.height + + var fitSize: CGSize = sourceSize + + if fitSize.width > boundingSize.width { + fitSize.width = boundingSize.width + fitSize.height = fitSize.width / sourceAspectRatio + } + + if fitSize.height > boundingSize.height { + fitSize.height = boundingSize.height + fitSize.width = fitSize.height * sourceAspectRatio + } + + if let m = minSize { + if fitSize.width < m.width { + fitSize.width = m.width + fitSize.height = fitSize.width / sourceAspectRatio + } + + if fitSize.height < m.height { + fitSize.height = m.height + fitSize.width = fitSize.height * sourceAspectRatio + } + } + + return fitSize + } +} diff --git a/Hotline/Library/HotlinePanel.swift b/Hotline/Library/HotlinePanel.swift new file mode 100644 index 0000000..191e89a --- /dev/null +++ b/Hotline/Library/HotlinePanel.swift @@ -0,0 +1,64 @@ +import Cocoa +import SwiftUI + +fileprivate let HOTLINE_PANEL_SIZE: CGSize = CGSizeMake(468, 114 - 10) + +class HotlinePanel: NSPanel { + init(_ view: HotlinePanelView) { + super.init(contentRect: NSRect(x: 0, y: 0, width: HOTLINE_PANEL_SIZE.width, height: HOTLINE_PANEL_SIZE.height), styleMask: [.nonactivatingPanel, .titled, .closable, .utilityWindow, .fullSizeContentView], backing: .buffered, defer: false) + + // Make sure that the panel is in front of almost all other windows + self.isFloatingPanel = false + self.level = .floating + self.hidesOnDeactivate = true + self.animationBehavior = .utilityWindow + + // Allow the panel to appear in a fullscreen space +// self.collectionBehavior.insert(.fullScreenAuxiliary) + self.collectionBehavior.insert(.canJoinAllSpaces) + self.collectionBehavior.insert(.ignoresCycle) + +// self.appearance = NSAppearance(named: .vibrantDark) + + // Don't delete panel state when it's closed. + self.isReleasedWhenClosed = false + + self.standardWindowButton(.closeButton)?.isHidden = true + self.standardWindowButton(.zoomButton)?.isHidden = true + self.standardWindowButton(.miniaturizeButton)?.isHidden = true + + // Make it transparent, the view inside will have to set the background. + // This is necessary because otherwise, we will have some space for the titlebar on top of the height of the view itself which we don't want. + self.isOpaque = false + self.backgroundColor = .clear + + // Since we don't show a statusbar, this allows us to drag the window by its background instead of the titlebar. + self.isMovableByWindowBackground = true + self.titlebarAppearsTransparent = true + + let hostingView = NSHostingView(rootView: view.edgesIgnoringSafeArea(.top)) + hostingView.sizingOptions = [.preferredContentSize] + + let visualEffectView = NSVisualEffectView(frame: NSRect(x: 0, y: 0, width: HOTLINE_PANEL_SIZE.width, height: HOTLINE_PANEL_SIZE.height)) + visualEffectView.material = .sidebar + visualEffectView.blendingMode = .behindWindow + visualEffectView.state = NSVisualEffectView.State.active + visualEffectView.autoresizingMask = [.width, .height] + visualEffectView.autoresizesSubviews = true + visualEffectView.addSubview(hostingView) + + self.contentView = visualEffectView + + hostingView.frame = visualEffectView.bounds + + self.cascadeTopLeft(from: NSMakePoint(16, 16)) + } + + override var canBecomeKey: Bool { + return false + } + + override var canBecomeMain: Bool { + return false + } +} diff --git a/Hotline/Library/NetSocket.swift b/Hotline/Library/NetSocket.swift new file mode 100644 index 0000000..a7d44ce --- /dev/null +++ b/Hotline/Library/NetSocket.swift @@ -0,0 +1,269 @@ + +// NetSocket.swift +// A simple delegate based buffered read/write TCP socket. +// Created by Dustin Mierau + +import Foundation + +protocol NetSocketDelegate: AnyObject { + func netsocketConnected(socket: NetSocket) + func netsocketDisconnected(socket: NetSocket, error: Error?) + func netsocketReceived(socket: NetSocket, bytes: [UInt8]) + func netsocketSent(socket: NetSocket, count: Int) +} + +extension NetSocketDelegate { + func netsocketConnected(socket: NetSocket) {} + func netsocketDisconnected(socket: NetSocket, error: Error?) {} + func netsocketReceived(socket: NetSocket, bytes: [UInt8]) {} + func netsocketSent(socket: NetSocket, count: Int) {} +} + +enum NetSocketStatus { + case disconnected + case connecting + case connected +} + +final class NetSocket: NSObject, StreamDelegate { + weak var delegate: NetSocketDelegate? = nil + + private var output: OutputStream? = nil + private var input: InputStream? = nil + + private var outputBuffer: [UInt8] = [] + private var inputBuffer: [UInt8] = [] + + private var readBuffer: [UInt8] = Array(repeating: 0, count: 4 * 1024) + + public func peek() -> [UInt8] { self.inputBuffer } + public var available: Int { self.inputBuffer.count } + + private var status: NetSocketStatus = .disconnected + + @MainActor public func has(_ length: Int) -> Bool { + return (self.available >= length) + } + + override init() {} + + @MainActor public func connect(host: String, port: Int) { + self.close() + + var outputStream: OutputStream? = nil + var inputStream: InputStream? = nil + + self.status = .connecting + + Stream.getStreamsToHost(withName: host, port: port, inputStream: &inputStream, outputStream: &outputStream) + + self.input = inputStream + self.output = outputStream + + inputStream?.delegate = self + outputStream?.delegate = self + + inputStream?.schedule(in: .current, forMode: .default) + outputStream?.schedule(in: .current, forMode: .default) + + inputStream?.open() + outputStream?.open() + } + + @MainActor public func close(_ err: Error? = nil) { + print("NetSocket: Closed") + + let disconnected = (self.status != .disconnected) + + self.status = .disconnected + + self.input?.delegate = nil + self.output?.delegate = nil + self.input?.close() + self.output?.close() + self.input?.remove(from: .current, forMode: .default) + self.output?.remove(from: .current, forMode: .default) + self.input = nil + self.output = nil + self.inputBuffer = [] + self.outputBuffer = [] + + if disconnected { + self.delegate?.netsocketDisconnected(socket: self, error: err) + } + } + + @MainActor public func write(_ data: Data) { + guard let output = self.output else { + return + } + + self.outputBuffer.append(contentsOf: data) + + if output.hasSpaceAvailable { + self.writeBufferToStream() + } + } + + @MainActor public func write(_ data: [UInt8]) { + guard let output = self.output else { + return + } + + self.outputBuffer.append(contentsOf: data) + + if output.hasSpaceAvailable { + self.writeBufferToStream() + } + } + + @MainActor public func read(count: Int) -> [UInt8] { + guard self.inputBuffer.count > 0, count > 0 else { + return [] + } + + let amountToRead = min(count, self.inputBuffer.count) + let dataRead: [UInt8] = Array(self.inputBuffer[0.. Data { + guard self.inputBuffer.count > 0, count > 0 else { + return Data() + } + + let amountToRead = min(count, self.inputBuffer.count) + + let dataRead: Data = Data(self.inputBuffer[0.. [UInt8] { + guard self.inputBuffer.count > 0 else { + return [] + } + + let dataRead: [UInt8] = Array(self.inputBuffer) + self.inputBuffer = [] + + return dataRead + } + + @MainActor public func readAll() -> Data { + guard self.inputBuffer.count > 0 else { + return Data() + } + + let dataRead: Data = Data(self.inputBuffer) + self.inputBuffer = [] + + return dataRead + } + + @MainActor private func writeBufferToStream() { + guard let output = self.output, self.outputBuffer.count > 0 else { + return + } + + let bytesWritten = output.write(self.outputBuffer, maxLength: self.outputBuffer.count) + print("NetSocket => \(bytesWritten) bytes") + if bytesWritten > 0 { + self.outputBuffer.removeFirst(bytesWritten) + self.delegate?.netsocketSent(socket: self, count: bytesWritten) + } + else if bytesWritten == -1 { + self.close(output.streamError) + } + } + + @MainActor private func readStreamToBuffer() { + guard let input = self.input else { + return + } + + let bytesRead = input.read(&self.readBuffer, maxLength: 4 * 1024) + print("NetSocket <= \(bytesRead) bytes") + if bytesRead > 0 { + self.inputBuffer.append(contentsOf: self.readBuffer[0.. Void] = [] + + init(text: Binding) { + self._text = text + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + func makeNSView(context: Context) -> NSScrollView { + let scrollview = NSTextView.scrollablePlainDocumentContentTextView() + let textview = scrollview.documentView as! NSTextView + + textview.string = self.text + textview.delegate = context.coordinator + +// let p = NSMutableParagraphStyle() +// p.lineSpacing = self.lineSpacing +//// textview.defaultParagraphStyle = p +// textview.typingAttributes = [ +// .paragraphStyle: p +// ] + + textview.isEditable = true + textview.isRichText = false + textview.allowsUndo = true + textview.isFieldEditor = false + textview.usesAdaptiveColorMappingForDarkAppearance = true + textview.drawsBackground = false // true + textview.usesRuler = false + textview.usesFindBar = false + textview.isIncrementalSearchingEnabled = false + textview.isAutomaticQuoteSubstitutionEnabled = false + textview.isAutomaticDashSubstitutionEnabled = false + textview.isAutomaticSpellingCorrectionEnabled = true + textview.isAutomaticDataDetectionEnabled = false + textview.isAutomaticLinkDetectionEnabled = false + textview.usesInspectorBar = false + textview.usesFontPanel = false + textview.importsGraphics = false + textview.allowsImageEditing = false + textview.displaysLinkToolTips = true + textview.backgroundColor = NSColor.textBackgroundColor + textview.textContainerInset = NSSize(width: 16, height: 16) + textview.isContinuousSpellCheckingEnabled = true + textview.setSelectedRange(NSMakeRange(0, 0)) + self.customizations.forEach { $0(textview) } + + scrollview.scrollerStyle = .overlay + + return scrollview + } + + func updateNSView(_ nsView: NSScrollView, context: Context) { + let textview = nsView.documentView as! NSTextView + + if textview.string != text { + textview.string = text + } + + self.customizations.forEach { $0(textview) } + } + + func betterEditorFont(_ font: NSFont) -> Self { + self.customized { $0.font = font } + } + + func betterEditorParagraphStyle(_ paragraphStyle: NSParagraphStyle) -> Self { + self.customized { $0.defaultParagraphStyle = paragraphStyle } + } + + func betterEditorAutomaticDashSubstitution(_ enabled: Bool) -> Self { + self.customized { $0.isAutomaticDashSubstitutionEnabled = enabled } + } + + func betterEditorAutomaticQuoteSubstitution(_ enabled: Bool) -> Self { + self.customized { $0.isAutomaticQuoteSubstitutionEnabled = enabled } + } + + func betterEditorAutomaticSpellingCorrection(_ enabled: Bool) -> Self { + self.customized { $0.isAutomaticSpellingCorrectionEnabled = enabled } + } + + func betterEditorTextInset(_ size: NSSize) -> Self { + self.customized { $0.textContainerInset = size } + } + + class Coordinator: NSObject, NSTextViewDelegate { + var parent: BetterTextEditor + + init(_ parent: BetterTextEditor) { + self.parent = parent + } + + func textDidChange(_ notification: Notification) { + guard let textview = notification.object as? NSTextView else { + return + } + self.parent.text = textview.string + } + } +} + +private extension BetterTextEditor { + private func customized(_ customization: @escaping (NSTextView) -> Void) -> Self { + var copy = self + copy.customizations.append(customization) + return copy + } +} diff --git a/Hotline/Library/URLAdditions.swift b/Hotline/Library/URLAdditions.swift new file mode 100644 index 0000000..1f25541 --- /dev/null +++ b/Hotline/Library/URLAdditions.swift @@ -0,0 +1,26 @@ +import Foundation + +extension URL { + func generateUniqueFilePath(filename base: String) -> String { + let fileManager = FileManager.default + var finalName = base + var counter = 2 + + // Helper function to generate a new filename with a counter + func makeFileName() -> String { + let baseName = (base as NSString).deletingPathExtension + let extensionName = (base as NSString).pathExtension + return extensionName.isEmpty ? "\(baseName) \(counter)" : "\(baseName) \(counter).\(extensionName)" + } + + // Check if file exists and append counter until a unique name is found + var filePath = self.appending(component: finalName).path(percentEncoded: false) + while fileManager.fileExists(atPath: filePath) { + finalName = makeFileName() + filePath = self.appending(component: finalName).path(percentEncoded: false) + counter += 1 + } + + return filePath + } +} diff --git a/Hotline/Library/VisualEffectView.swift b/Hotline/Library/VisualEffectView.swift new file mode 100644 index 0000000..e595c55 --- /dev/null +++ b/Hotline/Library/VisualEffectView.swift @@ -0,0 +1,22 @@ +import SwiftUI + +struct VisualEffectView: NSViewRepresentable +{ + let material: NSVisualEffectView.Material + let blendingMode: NSVisualEffectView.BlendingMode + + func makeNSView(context: Context) -> NSVisualEffectView + { + let visualEffectView = NSVisualEffectView() + visualEffectView.material = material + visualEffectView.blendingMode = blendingMode + visualEffectView.state = NSVisualEffectView.State.active + return visualEffectView + } + + func updateNSView(_ visualEffectView: NSVisualEffectView, context: Context) + { + visualEffectView.material = material + visualEffectView.blendingMode = blendingMode + } +} diff --git a/Hotline/MacApp.swift b/Hotline/MacApp.swift new file mode 100644 index 0000000..a4ff3cb --- /dev/null +++ b/Hotline/MacApp.swift @@ -0,0 +1,303 @@ +import SwiftUI +import SwiftData +import CloudKit +import UniformTypeIdentifiers + +@Observable +final class AppLaunchState { + static let shared = AppLaunchState() + + enum LaunchState { + case loading + case launched + case terminated + } + + var launchState = LaunchState.loading +} + +class AppDelegate: NSObject, NSApplicationDelegate { + private var cloudKitObserverToken: Any? = nil + + func applicationDidFinishLaunching(_ notification: Notification) { + AppLaunchState.shared.launchState = .launched + + CKContainer.default().accountStatus { status, error in + switch status { + case .noAccount: + print("iCloud Unavailable") + + // We mark CloudKit has available now since we're not waiting on + // a server sync or anything. + AppState.shared.cloudKitReady = true + default: + print("iCloud Available") + + self.cloudKitObserverToken = NotificationCenter.default.addObserver(forName: NSPersistentCloudKitContainer.eventChangedNotification, object: nil, queue: OperationQueue.main) { [weak self] note in + print("iCloud Changed!") + AppState.shared.cloudKitReady = true + + guard let token = self?.cloudKitObserverToken else { return } + NotificationCenter.default.removeObserver(token) + } + } + } + +// if FileManager.default.ubiquityIdentityToken == nil { +// print("iCloud Unavailable") +// +// // We mark CloudKit has available now since we're not waiting on +// // a server sync or anything. +// AppState.shared.cloudKitReady = true +// } +// else { +// print("iCloud Available") +// +// self.cloudKitObserverToken = NotificationCenter.default.addObserver(forName: NSPersistentCloudKitContainer.eventChangedNotification, object: nil, queue: OperationQueue.main) { [weak self] note in +// print("iCloud Changed!") +// AppState.shared.cloudKitReady = true +// +// guard let token = self?.cloudKitObserverToken else { return } +// NotificationCenter.default.removeObserver(token) +// } +// } + } + + func applicationWillTerminate(_ notification: Notification) { + AppLaunchState.shared.launchState = .terminated + } +} + +@main +struct Application: App { + @Environment(\.scenePhase) private var scenePhase + @Environment(\.openWindow) private var openWindow + @Environment(\.openURL) private var openURL + + @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + + @State private var hotlinePanel: HotlinePanel? = nil + @State private var selection: TrackerSelection? = nil + + @FocusedValue(\.activeHotlineModel) private var activeHotline: Hotline? + @FocusedValue(\.activeServerState) private var activeServerState: ServerState? + + private var modelContainer: ModelContainer = { + let schema = Schema([ + Bookmark.self + ]) + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false, cloudKitDatabase: .private("iCloud.co.goodmake.hotline")) + let modelContainer = try! ModelContainer(for: schema, configurations: [config]) + + // Print local SwiftData sqlite file. +// print(modelContainer.configurations.first?.url.path(percentEncoded: false)) + + return modelContainer + }() + + var body: some Scene { + // MARK: Tracker Window + Window("Servers", id: "servers") { + TrackerView(selection: $selection) + .frame(minWidth: 250, minHeight: 250) + } + .modelContainer(self.modelContainer) + .defaultSize(width: 700, height: 550) + .defaultPosition(.center) + .keyboardShortcut(.init("R"), modifiers: .command) + .onChange(of: AppLaunchState.shared.launchState) { + if AppLaunchState.shared.launchState == .launched { + if Prefs.shared.showBannerToolbar { + showBannerWindow() + } + } + } + + // MARK: About Box + Window("About", id: "about") { + AboutView() + .ignoresSafeArea() + .background(Color.hotlineRed) + } + .windowResizability(.contentSize) + .windowStyle(.hiddenTitleBar) + .defaultPosition(.center) + .commandsRemoved() // Remove About that was automatically added to Window menu. + .commands { + CommandGroup(replacing: CommandGroupPlacement.appInfo) { + Button("About Hotline") { + openWindow(id: "about") + } + } + } + + // MARK: Server Window + WindowGroup(id: "server", for: Server.self) { server in + ServerView(server: server) + .frame(minWidth: 430, minHeight: 300) + } defaultValue: { + Server(name: nil, description: nil, address: "") + } + .modelContainer(self.modelContainer) + .defaultSize(width: 750, height: 700) + .defaultPosition(.center) + .onChange(of: activeServerState) { + AppState.shared.activeServerState = activeServerState + } + .onChange(of: activeHotline) { + AppState.shared.activeHotline = activeHotline + } + .onChange(of: activeHotline?.serverTitle) { + if let hotline = activeHotline { + AppState.shared.activeServerName = hotline.serverTitle + } + } + .onChange(of: activeHotline?.bannerImage) { + withAnimation { + AppState.shared.activeServerBanner = activeHotline?.bannerImage + } + } + .onChange(of: activeHotline) { + AppState.shared.activeHotline = activeHotline + if let hotline = activeHotline { + AppState.shared.activeServerName = hotline.serverTitle + } + } + .commands { + CommandGroup(replacing: .newItem) { + Button("Connect to Server...") { + openWindow(id: "server") + } + .keyboardShortcut(.init("K"), modifiers: .command) + } + CommandGroup(after: .singleWindowList) { + Button("Toolbar") { + toggleBannerWindow() + } + .keyboardShortcut(.init("\\"), modifiers: [.shift, .command]) + } + CommandGroup(after: .help) { + Divider() + Button("Request Feature...") { + if let url = URL(string: "https://github.com/mierau/hotline/issues/new?labels=enhancement") { + openURL(url) + } + } + Button("Report Bug...") { + if let url = URL(string: "https://github.com/mierau/hotline/issues/new?labels=bug") { + openURL(url) + } + } + Divider() + Button("Download Latest...") { + if let url = URL(string: "https://github.com/mierau/hotline/releases/latest") { + openURL(url) + } + } + } + CommandMenu("Server") { + Button("Connect") { + guard let selection else { + return + } + connect(to: selection) + } + .disabled(selection == nil || selection?.server == nil) + .keyboardShortcut(.downArrow, modifiers: .command) + Button("Disconnect") { + activeHotline?.disconnect() + } + .disabled(activeHotline?.status == .disconnected) + Divider() + Button("Broadcast Message...") { + // TODO: Implement broadcast message when user is allowed. + } + .disabled(true) + .keyboardShortcut(.init("B"), modifiers: .command) + Divider() + Button("Show Chat") { + activeServerState?.selection = .chat + } + .disabled(activeHotline?.status != .loggedIn) + .keyboardShortcut(.init("1"), modifiers: .command) + Button("Show News") { + activeServerState?.selection = .news + } + .disabled(activeHotline?.status != .loggedIn || (activeHotline?.serverVersion ?? 0) < 151) + .keyboardShortcut(.init("2"), modifiers: .command) + Button("Show Message Board") { + activeServerState?.selection = .board + } + .disabled(activeHotline?.status != .loggedIn) + .keyboardShortcut(.init("3"), modifiers: .command) + Button("Show Files") { + activeServerState?.selection = .files + } + .disabled(activeHotline?.status != .loggedIn) + .keyboardShortcut(.init("4"), modifiers: .command) + Button("Show Accounts") { + activeServerState?.selection = .accounts + } + .disabled(activeHotline?.status != .loggedIn || activeHotline?.access?.contains(.canOpenUsers) != true ) + .keyboardShortcut(.init("5"), modifiers: .command) + } + } + + // MARK: Settings Window + Settings { + SettingsView() + } + + // MARK: Image Preview Window + WindowGroup(id: "preview-image", for: PreviewFileInfo.self) { $info in + FilePreviewImageView(info: $info) + } + .windowResizability(.contentSize) + .windowStyle(.titleBar) + .windowToolbarStyle(.unifiedCompact(showsTitle: true)) + .defaultSize(width: 350, height: 150) + .defaultPosition(.center) + + // MARK: Text Preview Window + WindowGroup(id: "preview-text", for: PreviewFileInfo.self) { $info in + FilePreviewTextView(info: $info) + } + .windowResizability(.automatic) + .windowStyle(.titleBar) + .windowToolbarStyle(.unifiedCompact(showsTitle: true)) + .defaultSize(width: 450, height: 550) + .defaultPosition(.center) + } + + func connect(to item: TrackerSelection) { + if let server = item.server { + openWindow(id: "server", value: server) + } + } + + func showBannerWindow() { + if hotlinePanel == nil { + hotlinePanel = HotlinePanel(HotlinePanelView()) + } + + if hotlinePanel?.isVisible == false { + hotlinePanel?.orderFront(nil) + Prefs.shared.showBannerToolbar = true + } + } + + func toggleBannerWindow() { + if hotlinePanel == nil { + hotlinePanel = HotlinePanel(HotlinePanelView()) + } + + if hotlinePanel?.isVisible == true { + hotlinePanel?.orderOut(nil) + Prefs.shared.showBannerToolbar = false + } + else { + hotlinePanel?.orderFront(nil) + Prefs.shared.showBannerToolbar = true + } + } +} diff --git a/Hotline/Models/ApplicationState.swift b/Hotline/Models/ApplicationState.swift deleted file mode 100644 index 6e077c5..0000000 --- a/Hotline/Models/ApplicationState.swift +++ /dev/null @@ -1,16 +0,0 @@ -import SwiftUI - -@Observable -final class ApplicationState { - static let shared = ApplicationState() - - var activeHotline: Hotline? = nil - var activeServerState: ServerState? = nil - - // Frontmost server window information - var activeServerID: UUID? = nil - var activeServerBanner: NSImage? = nil - var activeServerName: String? = nil - - var cloudKitReady: Bool = false -} diff --git a/Hotline/Models/Bookmark.swift b/Hotline/Models/Bookmark.swift index 733a6eb..394f9f6 100644 --- a/Hotline/Models/Bookmark.swift +++ b/Hotline/Models/Bookmark.swift @@ -4,7 +4,27 @@ import SwiftData enum BookmarkType: String, Codable { case tracker = "tracker" case server = "server" - case temporary = "temporary" +} + +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 @@ -18,29 +38,10 @@ final class Bookmark { @Attribute(.allowsCloudEncryption) var login: String? - + @Attribute(.allowsCloudEncryption) var password: String? - - @Attribute(.ephemeral) - var expanded: Bool = false - - @Attribute(.ephemeral) - var loading: Bool = false - - @Attribute(.ephemeral) - var serverDescription: String? = nil - - @Attribute(.ephemeral) - var serverUserCount: Int? = nil - - @Transient - var servers: [Bookmark] = [] - - func hash(into hasher: inout Hasher) { - - } - + @Transient var displayAddress: String { switch self.type { @@ -51,8 +52,8 @@ final class Bookmark { else { return "\(self.address):\(String(self.port))" } - - case .server, .temporary: + + case .server: if self.port == HotlinePorts.DefaultServerPort { return self.address } @@ -67,9 +68,9 @@ final class Bookmark { switch self.type { case .tracker: return nil - - case .server, .temporary: - return Server(name: self.name, description: self.serverDescription, address: self.address, port: self.port, login: self.login, password: self.password) + + case .server: + return Server(name: self.name, description: nil, address: self.address, port: self.port, login: self.login, password: self.password) } } @@ -84,22 +85,11 @@ final class Bookmark { self.name = name self.address = address self.port = port - + self.login = login self.password = password } - - init(temporaryServer server: Server) { - self.type = .temporary - - self.name = server.name ?? server.address - self.address = server.address - self.port = server.port - - self.serverDescription = server.description - self.serverUserCount = server.users - } - + init?(fileData: Data, name: String? = nil) { guard fileData.count <= 2000 else { return nil @@ -248,11 +238,6 @@ final class Bookmark { } static func add(_ bookmark: Bookmark, context: ModelContext) { - guard bookmark.type != .temporary else { - print("Bookmark: Attempting to add temporary bookmark to store. Aborting.") - return - } - let existingBookmarks = Bookmark.fetchAll(context: context) // Reindex bookmarks before insert. @@ -348,32 +333,23 @@ final class Bookmark { } } - func fetchServers() async { + func fetchServers() async -> [BookmarkServer] { guard self.type == .tracker else { - // self.loading = false - return - } - - DispatchQueue.main.sync { - self.loading = true + return [] } - - var fetchedBookmarks: [Bookmark] = [] - + + var fetchedBookmarks: [BookmarkServer] = [] + let client = HotlineTrackerClient() if let fetchedServers: [HotlineServer] = try? await client.fetchServers(address: self.address, port: self.port) { for fetchedServer in fetchedServers { 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(Bookmark(temporaryServer: server)) + fetchedBookmarks.append(BookmarkServer(server: server)) } } } - - let newServers = fetchedBookmarks - DispatchQueue.main.sync { - self.servers = newServers - self.loading = false - } + + return fetchedBookmarks } } diff --git a/Hotline/Models/Preferences.swift b/Hotline/Models/Preferences.swift deleted file mode 100644 index 207ce91..0000000 --- a/Hotline/Models/Preferences.swift +++ /dev/null @@ -1,134 +0,0 @@ -import SwiftUI - -enum PrefsKeys: String { - case username = "username" - case userIconID = "user icon id" - case refusePrivateMessages = "refuse private messages" - case refusePrivateChat = "refuse private chat" - case enableAutomaticMessage = "enable automatic message" - case automaticMessage = "automatic message" - case playSounds = "play sounds" - case playChatSound = "play chat sound" - case playFileTransferCompleteSound = "play file transfer complete sound" - case playPrivateMessageSound = "play private message sound" - case playJoinSound = "play join sound" - case playLeaveSound = "play leave sound" - case playLoggedInSound = "play logged in sound" - case playErrorSound = "play error sound" - case playChatInvitationSound = "play chat invitation sound" - case showBannerToolbar = "show banner toolbar" - case showJoinLeaveMessages = "show join leave messages" -} - -@Observable -class Prefs { - init() { - UserDefaults.standard.register(defaults:[ - PrefsKeys.username.rawValue: "guest", - PrefsKeys.userIconID.rawValue: 191, - PrefsKeys.refusePrivateMessages.rawValue: false, - PrefsKeys.refusePrivateChat.rawValue: false, - PrefsKeys.enableAutomaticMessage.rawValue: false, - PrefsKeys.automaticMessage.rawValue: "", - PrefsKeys.playSounds.rawValue: true, - PrefsKeys.playChatSound.rawValue: true, - PrefsKeys.playFileTransferCompleteSound.rawValue: true, - PrefsKeys.playPrivateMessageSound.rawValue: true, - PrefsKeys.playJoinSound.rawValue: true, - PrefsKeys.playLeaveSound.rawValue: true, - PrefsKeys.playLoggedInSound.rawValue: true, - PrefsKeys.playErrorSound.rawValue: true, - PrefsKeys.playChatInvitationSound.rawValue: true, - PrefsKeys.showBannerToolbar.rawValue: true, - PrefsKeys.showJoinLeaveMessages.rawValue: true, - ]) - - self.username = UserDefaults.standard.string(forKey: PrefsKeys.username.rawValue)! - self.userIconID = UserDefaults.standard.integer(forKey: PrefsKeys.userIconID.rawValue) - self.refusePrivateMessages = UserDefaults.standard.bool(forKey: PrefsKeys.refusePrivateMessages.rawValue) - self.refusePrivateChat = UserDefaults.standard.bool(forKey: PrefsKeys.refusePrivateChat.rawValue) - self.enableAutomaticMessage = UserDefaults.standard.bool(forKey: PrefsKeys.enableAutomaticMessage.rawValue) - self.automaticMessage = UserDefaults.standard.string(forKey: PrefsKeys.automaticMessage.rawValue)! - self.playSounds = UserDefaults.standard.bool(forKey: PrefsKeys.playSounds.rawValue) - self.playChatSound = UserDefaults.standard.bool(forKey: PrefsKeys.playChatSound.rawValue) - self.playFileTransferCompleteSound = UserDefaults.standard.bool(forKey: PrefsKeys.playFileTransferCompleteSound.rawValue) - self.playPrivateMessageSound = UserDefaults.standard.bool(forKey: PrefsKeys.playPrivateMessageSound.rawValue) - self.playJoinSound = UserDefaults.standard.bool(forKey: PrefsKeys.playJoinSound.rawValue) - self.playLeaveSound = UserDefaults.standard.bool(forKey: PrefsKeys.playLeaveSound.rawValue) - self.playLoggedInSound = UserDefaults.standard.bool(forKey: PrefsKeys.playLoggedInSound.rawValue) - self.playErrorSound = UserDefaults.standard.bool(forKey: PrefsKeys.playErrorSound.rawValue) - self.playChatInvitationSound = UserDefaults.standard.bool(forKey: PrefsKeys.playChatInvitationSound.rawValue) - self.showBannerToolbar = UserDefaults.standard.bool(forKey: PrefsKeys.showBannerToolbar.rawValue) - self.showJoinLeaveMessages = UserDefaults.standard.bool(forKey: PrefsKeys.showJoinLeaveMessages.rawValue) - } - - public static let shared = Prefs() - - var username: String { - didSet { UserDefaults.standard.set(self.username, forKey: PrefsKeys.username.rawValue) } - } - - var userIconID: Int { - didSet { UserDefaults.standard.set(self.userIconID, forKey: PrefsKeys.userIconID.rawValue) } - } - - var refusePrivateMessages: Bool { - didSet { UserDefaults.standard.set(self.refusePrivateMessages, forKey: PrefsKeys.refusePrivateMessages.rawValue) } - } - - var playSounds: Bool { - didSet { UserDefaults.standard.set(self.playSounds, forKey: PrefsKeys.playSounds.rawValue) } - } - - var playChatSound: Bool { - didSet { UserDefaults.standard.set(self.playChatSound, forKey: PrefsKeys.playChatSound.rawValue) } - } - - var playFileTransferCompleteSound: Bool { - didSet { UserDefaults.standard.set(self.playFileTransferCompleteSound, forKey: PrefsKeys.playFileTransferCompleteSound.rawValue) } - } - - var playPrivateMessageSound: Bool { - didSet { UserDefaults.standard.set(self.playPrivateMessageSound, forKey: PrefsKeys.playPrivateMessageSound.rawValue) } - } - - var playJoinSound: Bool { - didSet { UserDefaults.standard.set(self.playJoinSound, forKey: PrefsKeys.playJoinSound.rawValue) } - } - - var playLeaveSound: Bool { - didSet { UserDefaults.standard.set(self.playLeaveSound, forKey: PrefsKeys.playLeaveSound.rawValue) } - } - - var playLoggedInSound: Bool { - didSet { UserDefaults.standard.set(self.playLoggedInSound, forKey: PrefsKeys.playLoggedInSound.rawValue) } - } - - var playErrorSound: Bool { - didSet { UserDefaults.standard.set(self.playErrorSound, forKey: PrefsKeys.playErrorSound.rawValue) } - } - - var playChatInvitationSound: Bool { - didSet { UserDefaults.standard.set(self.playChatInvitationSound, forKey: PrefsKeys.playChatInvitationSound.rawValue) } - } - - var refusePrivateChat: Bool { - didSet { UserDefaults.standard.set(self.refusePrivateChat, forKey: PrefsKeys.refusePrivateChat.rawValue) } - } - - var enableAutomaticMessage: Bool { - didSet { UserDefaults.standard.set(self.enableAutomaticMessage, forKey: PrefsKeys.enableAutomaticMessage.rawValue) } - } - - var automaticMessage: String { - didSet { UserDefaults.standard.set(self.automaticMessage, forKey: PrefsKeys.automaticMessage.rawValue) } - } - - var showBannerToolbar: Bool { - didSet { UserDefaults.standard.set(self.showBannerToolbar, forKey: PrefsKeys.showBannerToolbar.rawValue) } - } - - var showJoinLeaveMessages: Bool { - didSet { UserDefaults.standard.set(self.showJoinLeaveMessages, forKey: PrefsKeys.showJoinLeaveMessages.rawValue) } - } -} diff --git a/Hotline/Shared/AsyncLinkPreview.swift b/Hotline/Shared/AsyncLinkPreview.swift deleted file mode 100644 index 89b186f..0000000 --- a/Hotline/Shared/AsyncLinkPreview.swift +++ /dev/null @@ -1,57 +0,0 @@ -import SwiftUI -import LinkPresentation - -fileprivate class CustomLinkView: LPLinkView { - override var intrinsicContentSize: CGSize { CGSize(width: 0, height: super.intrinsicContentSize.height) } -} - -struct AsyncLinkPreview: View { - @State private var metadata: LPLinkMetadata? - @State private var isLoading = true - let url: URL? - - func fetchMetadata() async { - guard let url else { - self.isLoading = false - return - } - do { - let provider = LPMetadataProvider() - let metadata = try await provider.startFetchingMetadata(for: url) - self.metadata = metadata - self.isLoading = false - } catch { - self.isLoading = false - } - } - - var body: some View { - if isLoading { - ProgressView() - .controlSize(.small) - .task { - await self.fetchMetadata() - } - } else if let metadata = metadata { - LinkView(metadata: metadata) - .frame(width: 200) - } else { - Text(LocalizedStringKey(url!.absoluteString)) - .multilineTextAlignment(.leading) - .tint(Color("Link Color")) - } - } -} - -struct LinkView: NSViewRepresentable { - var metadata: LPLinkMetadata - - func makeNSView(context: Context) -> LPLinkView { - let linkView = CustomLinkView(metadata: metadata) - return linkView - } - - func updateNSView(_ nsView: LPLinkView, context: Context) { - // Nothing required - } -} diff --git a/Hotline/Shared/DataAdditions.swift b/Hotline/Shared/DataAdditions.swift deleted file mode 100644 index 0e9dd96..0000000 --- a/Hotline/Shared/DataAdditions.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Foundation - -extension Data { - func saveAsFileToDownloads(filename: String, bounceDock: Bool = true) -> Bool { - let folderURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0] - let filePath = folderURL.generateUniqueFilePath(filename: filename) - if FileManager.default.createFile(atPath: filePath, contents: nil) { - if let h = FileHandle(forWritingAtPath: filePath) { - try? h.write(contentsOf: self) - try? h.close() - if bounceDock { - #if os(macOS) - var downloadURL = URL(filePath: filePath) - downloadURL.resolveSymlinksInPath() - DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path) - #endif - } - return true - } - } - return false - } -} diff --git a/Hotline/Shared/FileIconView.swift b/Hotline/Shared/FileIconView.swift deleted file mode 100644 index d0949fa..0000000 --- a/Hotline/Shared/FileIconView.swift +++ /dev/null @@ -1,74 +0,0 @@ -import SwiftUI -import UniformTypeIdentifiers - -struct FolderIconView: View { - private func folderIcon() -> Image { -#if os(iOS) - return Image(systemName: "folder.fill") -#elseif os(macOS) - return Image(nsImage: NSWorkspace.shared.icon(for: UTType.folder)) -#endif - } - - var body: some View { - folderIcon() - .resizable() - .scaledToFit() - } -} - -struct FileIconView: View { - let filename: String - let fileType: String? - - #if os(iOS) - private func fileIcon() -> Image { - let fileExtension = (self.filename as NSString).pathExtension - if let fileType = UTType(filenameExtension: fileExtension) { - if fileType.isSubtype(of: .movie) { - return Image(systemName: "play.rectangle") - } - else if fileType.isSubtype(of: .image) { - return Image(systemName: "photo") - } - else if fileType.isSubtype(of: .archive) { - return Image(systemName: "doc.zipper") - } - else if fileType.isSubtype(of: .text) { - return Image(systemName: "doc.text") - } - else { - return Image(systemName: "doc") - } - } - - return Image(systemName: "doc") - } - #elseif os(macOS) - private func fileIcon() -> Image { - let fileExtension = (self.filename as NSString).pathExtension - - if !fileExtension.isEmpty, - let uttype = UTType(filenameExtension: fileExtension) { - return Image(nsImage: NSWorkspace.shared.icon(for: uttype)) - } - else if let fileType = self.fileType, - let fileTypeExtension = FileManager.HFSTypeToExtension[fileType.lowercased()], - let uttype = UTType(filenameExtension: fileTypeExtension) { - return Image(nsImage: NSWorkspace.shared.icon(for: uttype)) - } - else { - return Image(nsImage: NSWorkspace.shared.icon(for: UTType.data)) - } - -// Image(nsImage: NSWorkspace.shared.icon(for: UTType(filenameExtension: (filename as NSString).pathExtension) ?? UTType.content)) - } - #endif - - - var body: some View { - fileIcon() - .resizable() - .scaledToFit() - } -} diff --git a/Hotline/Shared/FileImageView.swift b/Hotline/Shared/FileImageView.swift deleted file mode 100644 index 0cc50f1..0000000 --- a/Hotline/Shared/FileImageView.swift +++ /dev/null @@ -1,98 +0,0 @@ -import SwiftUI - -struct FileImageView: NSViewRepresentable { - var image: NSImage? - - let minimumSize: CGSize = CGSize(width: 350, height: 350) - let presentationPaddingRatio: Double = 0.5 - - func makeNSView(context: Context) -> NSImageView { - let imageView = NSImageView() - imageView.imageScaling = .scaleProportionallyUpOrDown - imageView.animates = true - imageView.isEditable = false - imageView.allowsCutCopyPaste = true - imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) - imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - - if let img = self.image { - imageView.image = img - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - self.resizeWindowForImageView(imageView) - } - } - - return imageView - } - - func updateNSView(_ nsView: NSImageView, context: Context) { - nsView.image = self.image - } - - // MARK: - - - func resizeWindowForImageView(_ imageView: NSImageView) { - guard let window = imageView.window, let img = imageView.image else { - return - } - - var windowRect = window.contentLayoutRect - let windowChromeSize = CGSize(width: window.frame.width - windowRect.width, height: window.frame.height - windowRect.height) - - var windowMinSize: CGSize = windowRect.size - let centerPoint = CGPoint(x: window.frame.midX, y: window.frame.midY) - - windowRect.size = img.size - - if let screen = window.screen { - var paddedScreenSize = screen.frame.size - paddedScreenSize.width *= self.presentationPaddingRatio - paddedScreenSize.height *= self.presentationPaddingRatio - - windowMinSize = aspectFit(source: windowRect.size, bounds: self.minimumSize) - windowRect.size = aspectFit(source: windowRect.size, bounds: paddedScreenSize, minimum: windowMinSize) - } - - windowRect.size.width += windowChromeSize.width - windowRect.size.height += windowChromeSize.height - - windowRect.origin.x = centerPoint.x - windowRect.width / 2.0 - windowRect.origin.y = centerPoint.y - windowRect.height / 2.0 - - window.setFrame(windowRect, display: true, animate: true) - -// Do these APIs even work?? -// window.aspectRatio = windowRect.size -// window.contentAspectRatio = windowRect.size - } - - func aspectFit(source sourceSize: CGSize, bounds boundingSize: CGSize, minimum minSize: CGSize? = nil) -> CGSize { - let sourceAspectRatio = sourceSize.width / sourceSize.height - - var fitSize: CGSize = sourceSize - - if fitSize.width > boundingSize.width { - fitSize.width = boundingSize.width - fitSize.height = fitSize.width / sourceAspectRatio - } - - if fitSize.height > boundingSize.height { - fitSize.height = boundingSize.height - fitSize.width = fitSize.height * sourceAspectRatio - } - - if let m = minSize { - if fitSize.width < m.width { - fitSize.width = m.width - fitSize.height = fitSize.width / sourceAspectRatio - } - - if fitSize.height < m.height { - fitSize.height = m.height - fitSize.width = fitSize.height * sourceAspectRatio - } - } - - return fitSize - } -} diff --git a/Hotline/Shared/HotlinePanel.swift b/Hotline/Shared/HotlinePanel.swift deleted file mode 100644 index 191e89a..0000000 --- a/Hotline/Shared/HotlinePanel.swift +++ /dev/null @@ -1,64 +0,0 @@ -import Cocoa -import SwiftUI - -fileprivate let HOTLINE_PANEL_SIZE: CGSize = CGSizeMake(468, 114 - 10) - -class HotlinePanel: NSPanel { - init(_ view: HotlinePanelView) { - super.init(contentRect: NSRect(x: 0, y: 0, width: HOTLINE_PANEL_SIZE.width, height: HOTLINE_PANEL_SIZE.height), styleMask: [.nonactivatingPanel, .titled, .closable, .utilityWindow, .fullSizeContentView], backing: .buffered, defer: false) - - // Make sure that the panel is in front of almost all other windows - self.isFloatingPanel = false - self.level = .floating - self.hidesOnDeactivate = true - self.animationBehavior = .utilityWindow - - // Allow the panel to appear in a fullscreen space -// self.collectionBehavior.insert(.fullScreenAuxiliary) - self.collectionBehavior.insert(.canJoinAllSpaces) - self.collectionBehavior.insert(.ignoresCycle) - -// self.appearance = NSAppearance(named: .vibrantDark) - - // Don't delete panel state when it's closed. - self.isReleasedWhenClosed = false - - self.standardWindowButton(.closeButton)?.isHidden = true - self.standardWindowButton(.zoomButton)?.isHidden = true - self.standardWindowButton(.miniaturizeButton)?.isHidden = true - - // Make it transparent, the view inside will have to set the background. - // This is necessary because otherwise, we will have some space for the titlebar on top of the height of the view itself which we don't want. - self.isOpaque = false - self.backgroundColor = .clear - - // Since we don't show a statusbar, this allows us to drag the window by its background instead of the titlebar. - self.isMovableByWindowBackground = true - self.titlebarAppearsTransparent = true - - let hostingView = NSHostingView(rootView: view.edgesIgnoringSafeArea(.top)) - hostingView.sizingOptions = [.preferredContentSize] - - let visualEffectView = NSVisualEffectView(frame: NSRect(x: 0, y: 0, width: HOTLINE_PANEL_SIZE.width, height: HOTLINE_PANEL_SIZE.height)) - visualEffectView.material = .sidebar - visualEffectView.blendingMode = .behindWindow - visualEffectView.state = NSVisualEffectView.State.active - visualEffectView.autoresizingMask = [.width, .height] - visualEffectView.autoresizesSubviews = true - visualEffectView.addSubview(hostingView) - - self.contentView = visualEffectView - - hostingView.frame = visualEffectView.bounds - - self.cascadeTopLeft(from: NSMakePoint(16, 16)) - } - - override var canBecomeKey: Bool { - return false - } - - override var canBecomeMain: Bool { - return false - } -} diff --git a/Hotline/Shared/NetSocket.swift b/Hotline/Shared/NetSocket.swift deleted file mode 100644 index a7d44ce..0000000 --- a/Hotline/Shared/NetSocket.swift +++ /dev/null @@ -1,269 +0,0 @@ - -// NetSocket.swift -// A simple delegate based buffered read/write TCP socket. -// Created by Dustin Mierau - -import Foundation - -protocol NetSocketDelegate: AnyObject { - func netsocketConnected(socket: NetSocket) - func netsocketDisconnected(socket: NetSocket, error: Error?) - func netsocketReceived(socket: NetSocket, bytes: [UInt8]) - func netsocketSent(socket: NetSocket, count: Int) -} - -extension NetSocketDelegate { - func netsocketConnected(socket: NetSocket) {} - func netsocketDisconnected(socket: NetSocket, error: Error?) {} - func netsocketReceived(socket: NetSocket, bytes: [UInt8]) {} - func netsocketSent(socket: NetSocket, count: Int) {} -} - -enum NetSocketStatus { - case disconnected - case connecting - case connected -} - -final class NetSocket: NSObject, StreamDelegate { - weak var delegate: NetSocketDelegate? = nil - - private var output: OutputStream? = nil - private var input: InputStream? = nil - - private var outputBuffer: [UInt8] = [] - private var inputBuffer: [UInt8] = [] - - private var readBuffer: [UInt8] = Array(repeating: 0, count: 4 * 1024) - - public func peek() -> [UInt8] { self.inputBuffer } - public var available: Int { self.inputBuffer.count } - - private var status: NetSocketStatus = .disconnected - - @MainActor public func has(_ length: Int) -> Bool { - return (self.available >= length) - } - - override init() {} - - @MainActor public func connect(host: String, port: Int) { - self.close() - - var outputStream: OutputStream? = nil - var inputStream: InputStream? = nil - - self.status = .connecting - - Stream.getStreamsToHost(withName: host, port: port, inputStream: &inputStream, outputStream: &outputStream) - - self.input = inputStream - self.output = outputStream - - inputStream?.delegate = self - outputStream?.delegate = self - - inputStream?.schedule(in: .current, forMode: .default) - outputStream?.schedule(in: .current, forMode: .default) - - inputStream?.open() - outputStream?.open() - } - - @MainActor public func close(_ err: Error? = nil) { - print("NetSocket: Closed") - - let disconnected = (self.status != .disconnected) - - self.status = .disconnected - - self.input?.delegate = nil - self.output?.delegate = nil - self.input?.close() - self.output?.close() - self.input?.remove(from: .current, forMode: .default) - self.output?.remove(from: .current, forMode: .default) - self.input = nil - self.output = nil - self.inputBuffer = [] - self.outputBuffer = [] - - if disconnected { - self.delegate?.netsocketDisconnected(socket: self, error: err) - } - } - - @MainActor public func write(_ data: Data) { - guard let output = self.output else { - return - } - - self.outputBuffer.append(contentsOf: data) - - if output.hasSpaceAvailable { - self.writeBufferToStream() - } - } - - @MainActor public func write(_ data: [UInt8]) { - guard let output = self.output else { - return - } - - self.outputBuffer.append(contentsOf: data) - - if output.hasSpaceAvailable { - self.writeBufferToStream() - } - } - - @MainActor public func read(count: Int) -> [UInt8] { - guard self.inputBuffer.count > 0, count > 0 else { - return [] - } - - let amountToRead = min(count, self.inputBuffer.count) - let dataRead: [UInt8] = Array(self.inputBuffer[0.. Data { - guard self.inputBuffer.count > 0, count > 0 else { - return Data() - } - - let amountToRead = min(count, self.inputBuffer.count) - - let dataRead: Data = Data(self.inputBuffer[0.. [UInt8] { - guard self.inputBuffer.count > 0 else { - return [] - } - - let dataRead: [UInt8] = Array(self.inputBuffer) - self.inputBuffer = [] - - return dataRead - } - - @MainActor public func readAll() -> Data { - guard self.inputBuffer.count > 0 else { - return Data() - } - - let dataRead: Data = Data(self.inputBuffer) - self.inputBuffer = [] - - return dataRead - } - - @MainActor private func writeBufferToStream() { - guard let output = self.output, self.outputBuffer.count > 0 else { - return - } - - let bytesWritten = output.write(self.outputBuffer, maxLength: self.outputBuffer.count) - print("NetSocket => \(bytesWritten) bytes") - if bytesWritten > 0 { - self.outputBuffer.removeFirst(bytesWritten) - self.delegate?.netsocketSent(socket: self, count: bytesWritten) - } - else if bytesWritten == -1 { - self.close(output.streamError) - } - } - - @MainActor private func readStreamToBuffer() { - guard let input = self.input else { - return - } - - let bytesRead = input.read(&self.readBuffer, maxLength: 4 * 1024) - print("NetSocket <= \(bytesRead) bytes") - if bytesRead > 0 { - self.inputBuffer.append(contentsOf: self.readBuffer[0.. Void] = [] - - init(text: Binding) { - self._text = text - } - - func makeCoordinator() -> Coordinator { - Coordinator(self) - } - - func makeNSView(context: Context) -> NSScrollView { - let scrollview = NSTextView.scrollablePlainDocumentContentTextView() - let textview = scrollview.documentView as! NSTextView - - textview.string = self.text - textview.delegate = context.coordinator - -// let p = NSMutableParagraphStyle() -// p.lineSpacing = self.lineSpacing -//// textview.defaultParagraphStyle = p -// textview.typingAttributes = [ -// .paragraphStyle: p -// ] - - textview.isEditable = true - textview.isRichText = false - textview.allowsUndo = true - textview.isFieldEditor = false - textview.usesAdaptiveColorMappingForDarkAppearance = true - textview.drawsBackground = false // true - textview.usesRuler = false - textview.usesFindBar = false - textview.isIncrementalSearchingEnabled = false - textview.isAutomaticQuoteSubstitutionEnabled = false - textview.isAutomaticDashSubstitutionEnabled = false - textview.isAutomaticSpellingCorrectionEnabled = true - textview.isAutomaticDataDetectionEnabled = false - textview.isAutomaticLinkDetectionEnabled = false - textview.usesInspectorBar = false - textview.usesFontPanel = false - textview.importsGraphics = false - textview.allowsImageEditing = false - textview.displaysLinkToolTips = true - textview.backgroundColor = NSColor.textBackgroundColor - textview.textContainerInset = NSSize(width: 16, height: 16) - textview.isContinuousSpellCheckingEnabled = true - textview.setSelectedRange(NSMakeRange(0, 0)) - self.customizations.forEach { $0(textview) } - - scrollview.scrollerStyle = .overlay - - return scrollview - } - - func updateNSView(_ nsView: NSScrollView, context: Context) { - let textview = nsView.documentView as! NSTextView - - if textview.string != text { - textview.string = text - } - - self.customizations.forEach { $0(textview) } - } - - func betterEditorFont(_ font: NSFont) -> Self { - self.customized { $0.font = font } - } - - func betterEditorParagraphStyle(_ paragraphStyle: NSParagraphStyle) -> Self { - self.customized { $0.defaultParagraphStyle = paragraphStyle } - } - - func betterEditorAutomaticDashSubstitution(_ enabled: Bool) -> Self { - self.customized { $0.isAutomaticDashSubstitutionEnabled = enabled } - } - - func betterEditorAutomaticQuoteSubstitution(_ enabled: Bool) -> Self { - self.customized { $0.isAutomaticQuoteSubstitutionEnabled = enabled } - } - - func betterEditorAutomaticSpellingCorrection(_ enabled: Bool) -> Self { - self.customized { $0.isAutomaticSpellingCorrectionEnabled = enabled } - } - - func betterEditorTextInset(_ size: NSSize) -> Self { - self.customized { $0.textContainerInset = size } - } - - class Coordinator: NSObject, NSTextViewDelegate { - var parent: BetterTextEditor - - init(_ parent: BetterTextEditor) { - self.parent = parent - } - - func textDidChange(_ notification: Notification) { - guard let textview = notification.object as? NSTextView else { - return - } - self.parent.text = textview.string - } - } -} - -private extension BetterTextEditor { - private func customized(_ customization: @escaping (NSTextView) -> Void) -> Self { - var copy = self - copy.customizations.append(customization) - return copy - } -} diff --git a/Hotline/Shared/URLAdditions.swift b/Hotline/Shared/URLAdditions.swift deleted file mode 100644 index 1f25541..0000000 --- a/Hotline/Shared/URLAdditions.swift +++ /dev/null @@ -1,26 +0,0 @@ -import Foundation - -extension URL { - func generateUniqueFilePath(filename base: String) -> String { - let fileManager = FileManager.default - var finalName = base - var counter = 2 - - // Helper function to generate a new filename with a counter - func makeFileName() -> String { - let baseName = (base as NSString).deletingPathExtension - let extensionName = (base as NSString).pathExtension - return extensionName.isEmpty ? "\(baseName) \(counter)" : "\(baseName) \(counter).\(extensionName)" - } - - // Check if file exists and append counter until a unique name is found - var filePath = self.appending(component: finalName).path(percentEncoded: false) - while fileManager.fileExists(atPath: filePath) { - finalName = makeFileName() - filePath = self.appending(component: finalName).path(percentEncoded: false) - counter += 1 - } - - return filePath - } -} diff --git a/Hotline/Shared/VisualEffectView.swift b/Hotline/Shared/VisualEffectView.swift deleted file mode 100644 index e595c55..0000000 --- a/Hotline/Shared/VisualEffectView.swift +++ /dev/null @@ -1,22 +0,0 @@ -import SwiftUI - -struct VisualEffectView: NSViewRepresentable -{ - let material: NSVisualEffectView.Material - let blendingMode: NSVisualEffectView.BlendingMode - - func makeNSView(context: Context) -> NSVisualEffectView - { - let visualEffectView = NSVisualEffectView() - visualEffectView.material = material - visualEffectView.blendingMode = blendingMode - visualEffectView.state = NSVisualEffectView.State.active - return visualEffectView - } - - func updateNSView(_ visualEffectView: NSVisualEffectView, context: Context) - { - visualEffectView.material = material - visualEffectView.blendingMode = blendingMode - } -} diff --git a/Hotline/Sounds/Application-iOS.swift b/Hotline/Sounds/Application-iOS.swift deleted file mode 100644 index 2fb6ad2..0000000 --- a/Hotline/Sounds/Application-iOS.swift +++ /dev/null @@ -1,18 +0,0 @@ -import SwiftUI -import SwiftData -import UniformTypeIdentifiers - -@main -struct Application: App { - private var model = Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient()) - - @FocusedValue(\.activeHotlineModel) private var activeHotline: Hotline? - @FocusedValue(\.activeServerState) private var activeServerState: ServerState? - - var body: some Scene { - WindowGroup { - TrackerView() - .environment(model) - } - } -} diff --git a/Hotline/Sounds/chat-message.aiff b/Hotline/Sounds/chat-message.aiff deleted file mode 100644 index 73ea391..0000000 Binary files a/Hotline/Sounds/chat-message.aiff and /dev/null differ diff --git a/Hotline/Sounds/error.aiff b/Hotline/Sounds/error.aiff deleted file mode 100644 index 8a9f7f6..0000000 Binary files a/Hotline/Sounds/error.aiff and /dev/null differ diff --git a/Hotline/Sounds/logged-in.aiff b/Hotline/Sounds/logged-in.aiff deleted file mode 100644 index 5f59de0..0000000 Binary files a/Hotline/Sounds/logged-in.aiff and /dev/null differ diff --git a/Hotline/Sounds/new-news.aiff b/Hotline/Sounds/new-news.aiff deleted file mode 100644 index 5c56582..0000000 Binary files a/Hotline/Sounds/new-news.aiff and /dev/null differ diff --git a/Hotline/Sounds/server-message.aiff b/Hotline/Sounds/server-message.aiff deleted file mode 100644 index 152bebe..0000000 Binary files a/Hotline/Sounds/server-message.aiff and /dev/null differ diff --git a/Hotline/Sounds/transfer-complete.aiff b/Hotline/Sounds/transfer-complete.aiff deleted file mode 100644 index b22e0ae..0000000 Binary files a/Hotline/Sounds/transfer-complete.aiff and /dev/null differ diff --git a/Hotline/Sounds/user-login.aiff b/Hotline/Sounds/user-login.aiff deleted file mode 100644 index 0dcfb6f..0000000 Binary files a/Hotline/Sounds/user-login.aiff and /dev/null differ diff --git a/Hotline/Sounds/user-logout.aiff b/Hotline/Sounds/user-logout.aiff deleted file mode 100644 index 8999edc..0000000 Binary files a/Hotline/Sounds/user-logout.aiff and /dev/null differ diff --git a/Hotline/State/AppState.swift b/Hotline/State/AppState.swift new file mode 100644 index 0000000..11bae74 --- /dev/null +++ b/Hotline/State/AppState.swift @@ -0,0 +1,24 @@ +import SwiftUI + +extension EnvironmentValues { + @Entry var appState: AppState = AppState.shared +} + +@Observable +final class AppState { + static let shared = AppState() + + private init() { + + } + + var activeHotline: Hotline? = nil + var activeServerState: ServerState? = nil + + // Frontmost server window information + var activeServerID: UUID? = nil + var activeServerBanner: NSImage? = nil + var activeServerName: String? = nil + + var cloudKitReady: Bool = false +} diff --git a/Hotline/State/Preferences.swift b/Hotline/State/Preferences.swift new file mode 100644 index 0000000..fef2b9a --- /dev/null +++ b/Hotline/State/Preferences.swift @@ -0,0 +1,138 @@ +import SwiftUI + +extension EnvironmentValues { + @Entry var preferences: Prefs = Prefs.shared +} + +enum PrefsKeys: String { + case username = "username" + case userIconID = "user icon id" + case refusePrivateMessages = "refuse private messages" + case refusePrivateChat = "refuse private chat" + case enableAutomaticMessage = "enable automatic message" + case automaticMessage = "automatic message" + case playSounds = "play sounds" + case playChatSound = "play chat sound" + case playFileTransferCompleteSound = "play file transfer complete sound" + case playPrivateMessageSound = "play private message sound" + case playJoinSound = "play join sound" + case playLeaveSound = "play leave sound" + case playLoggedInSound = "play logged in sound" + case playErrorSound = "play error sound" + case playChatInvitationSound = "play chat invitation sound" + case showBannerToolbar = "show banner toolbar" + case showJoinLeaveMessages = "show join leave messages" +} + +@Observable +class Prefs { + static let shared = Prefs() + + private init() { + UserDefaults.standard.register(defaults:[ + PrefsKeys.username.rawValue: "guest", + PrefsKeys.userIconID.rawValue: 191, + PrefsKeys.refusePrivateMessages.rawValue: false, + PrefsKeys.refusePrivateChat.rawValue: false, + PrefsKeys.enableAutomaticMessage.rawValue: false, + PrefsKeys.automaticMessage.rawValue: "", + PrefsKeys.playSounds.rawValue: true, + PrefsKeys.playChatSound.rawValue: true, + PrefsKeys.playFileTransferCompleteSound.rawValue: true, + PrefsKeys.playPrivateMessageSound.rawValue: true, + PrefsKeys.playJoinSound.rawValue: true, + PrefsKeys.playLeaveSound.rawValue: true, + PrefsKeys.playLoggedInSound.rawValue: true, + PrefsKeys.playErrorSound.rawValue: true, + PrefsKeys.playChatInvitationSound.rawValue: true, + PrefsKeys.showBannerToolbar.rawValue: true, + PrefsKeys.showJoinLeaveMessages.rawValue: true, + ]) + + self.username = UserDefaults.standard.string(forKey: PrefsKeys.username.rawValue)! + self.userIconID = UserDefaults.standard.integer(forKey: PrefsKeys.userIconID.rawValue) + self.refusePrivateMessages = UserDefaults.standard.bool(forKey: PrefsKeys.refusePrivateMessages.rawValue) + self.refusePrivateChat = UserDefaults.standard.bool(forKey: PrefsKeys.refusePrivateChat.rawValue) + self.enableAutomaticMessage = UserDefaults.standard.bool(forKey: PrefsKeys.enableAutomaticMessage.rawValue) + self.automaticMessage = UserDefaults.standard.string(forKey: PrefsKeys.automaticMessage.rawValue)! + self.playSounds = UserDefaults.standard.bool(forKey: PrefsKeys.playSounds.rawValue) + self.playChatSound = UserDefaults.standard.bool(forKey: PrefsKeys.playChatSound.rawValue) + self.playFileTransferCompleteSound = UserDefaults.standard.bool(forKey: PrefsKeys.playFileTransferCompleteSound.rawValue) + self.playPrivateMessageSound = UserDefaults.standard.bool(forKey: PrefsKeys.playPrivateMessageSound.rawValue) + self.playJoinSound = UserDefaults.standard.bool(forKey: PrefsKeys.playJoinSound.rawValue) + self.playLeaveSound = UserDefaults.standard.bool(forKey: PrefsKeys.playLeaveSound.rawValue) + self.playLoggedInSound = UserDefaults.standard.bool(forKey: PrefsKeys.playLoggedInSound.rawValue) + self.playErrorSound = UserDefaults.standard.bool(forKey: PrefsKeys.playErrorSound.rawValue) + self.playChatInvitationSound = UserDefaults.standard.bool(forKey: PrefsKeys.playChatInvitationSound.rawValue) + self.showBannerToolbar = UserDefaults.standard.bool(forKey: PrefsKeys.showBannerToolbar.rawValue) + self.showJoinLeaveMessages = UserDefaults.standard.bool(forKey: PrefsKeys.showJoinLeaveMessages.rawValue) + } + + var username: String { + didSet { UserDefaults.standard.set(self.username, forKey: PrefsKeys.username.rawValue) } + } + + var userIconID: Int { + didSet { UserDefaults.standard.set(self.userIconID, forKey: PrefsKeys.userIconID.rawValue) } + } + + var refusePrivateMessages: Bool { + didSet { UserDefaults.standard.set(self.refusePrivateMessages, forKey: PrefsKeys.refusePrivateMessages.rawValue) } + } + + var playSounds: Bool { + didSet { UserDefaults.standard.set(self.playSounds, forKey: PrefsKeys.playSounds.rawValue) } + } + + var playChatSound: Bool { + didSet { UserDefaults.standard.set(self.playChatSound, forKey: PrefsKeys.playChatSound.rawValue) } + } + + var playFileTransferCompleteSound: Bool { + didSet { UserDefaults.standard.set(self.playFileTransferCompleteSound, forKey: PrefsKeys.playFileTransferCompleteSound.rawValue) } + } + + var playPrivateMessageSound: Bool { + didSet { UserDefaults.standard.set(self.playPrivateMessageSound, forKey: PrefsKeys.playPrivateMessageSound.rawValue) } + } + + var playJoinSound: Bool { + didSet { UserDefaults.standard.set(self.playJoinSound, forKey: PrefsKeys.playJoinSound.rawValue) } + } + + var playLeaveSound: Bool { + didSet { UserDefaults.standard.set(self.playLeaveSound, forKey: PrefsKeys.playLeaveSound.rawValue) } + } + + var playLoggedInSound: Bool { + didSet { UserDefaults.standard.set(self.playLoggedInSound, forKey: PrefsKeys.playLoggedInSound.rawValue) } + } + + var playErrorSound: Bool { + didSet { UserDefaults.standard.set(self.playErrorSound, forKey: PrefsKeys.playErrorSound.rawValue) } + } + + var playChatInvitationSound: Bool { + didSet { UserDefaults.standard.set(self.playChatInvitationSound, forKey: PrefsKeys.playChatInvitationSound.rawValue) } + } + + var refusePrivateChat: Bool { + didSet { UserDefaults.standard.set(self.refusePrivateChat, forKey: PrefsKeys.refusePrivateChat.rawValue) } + } + + var enableAutomaticMessage: Bool { + didSet { UserDefaults.standard.set(self.enableAutomaticMessage, forKey: PrefsKeys.enableAutomaticMessage.rawValue) } + } + + var automaticMessage: String { + didSet { UserDefaults.standard.set(self.automaticMessage, forKey: PrefsKeys.automaticMessage.rawValue) } + } + + var showBannerToolbar: Bool { + didSet { UserDefaults.standard.set(self.showBannerToolbar, forKey: PrefsKeys.showBannerToolbar.rawValue) } + } + + var showJoinLeaveMessages: Bool { + didSet { UserDefaults.standard.set(self.showJoinLeaveMessages, forKey: PrefsKeys.showJoinLeaveMessages.rawValue) } + } +} diff --git a/Hotline/iOSApp.swift b/Hotline/iOSApp.swift new file mode 100644 index 0000000..2fb6ad2 --- /dev/null +++ b/Hotline/iOSApp.swift @@ -0,0 +1,18 @@ +import SwiftUI +import SwiftData +import UniformTypeIdentifiers + +@main +struct Application: App { + private var model = Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient()) + + @FocusedValue(\.activeHotlineModel) private var activeHotline: Hotline? + @FocusedValue(\.activeServerState) private var activeServerState: ServerState? + + var body: some Scene { + WindowGroup { + TrackerView() + .environment(model) + } + } +} diff --git a/Hotline/macOS/HotlinePanelView.swift b/Hotline/macOS/HotlinePanelView.swift index dc58698..fd43c15 100644 --- a/Hotline/macOS/HotlinePanelView.swift +++ b/Hotline/macOS/HotlinePanelView.swift @@ -6,7 +6,7 @@ struct HotlinePanelView: View { var body: some View { VStack(spacing: 0) { - Image(nsImage: ApplicationState.shared.activeServerBanner ?? NSImage(named: "Default Banner")!) + Image(nsImage: AppState.shared.activeServerBanner ?? NSImage(named: "Default Banner")!) .interpolation(.high) .resizable() .scaledToFill() @@ -36,7 +36,7 @@ struct HotlinePanelView: View { .help("Hotline Servers") Button { - ApplicationState.shared.activeServerState?.selection = .chat + AppState.shared.activeServerState?.selection = .chat } label: { Image("Section Chat") @@ -45,11 +45,11 @@ struct HotlinePanelView: View { } .buttonStyle(.plain) .frame(width: 20, height: 20) - .disabled(ApplicationState.shared.activeServerState == nil) + .disabled(AppState.shared.activeServerState == nil) .help("Public Chat") Button { - ApplicationState.shared.activeServerState?.selection = .board + AppState.shared.activeServerState?.selection = .board } label: { Image("Section Board") @@ -58,11 +58,11 @@ struct HotlinePanelView: View { } .buttonStyle(.plain) .frame(width: 20, height: 20) - .disabled(ApplicationState.shared.activeServerState == nil) + .disabled(AppState.shared.activeServerState == nil) .help("Message Board") Button { - ApplicationState.shared.activeServerState?.selection = .news + AppState.shared.activeServerState?.selection = .news } label: { Image("Section News") @@ -71,11 +71,11 @@ struct HotlinePanelView: View { } .buttonStyle(.plain) .frame(width: 20, height: 20) - .disabled(ApplicationState.shared.activeServerState == nil || (ApplicationState.shared.activeHotline?.serverVersion ?? 0) < 151) + .disabled(AppState.shared.activeServerState == nil || (AppState.shared.activeHotline?.serverVersion ?? 0) < 151) .help("News") Button { - ApplicationState.shared.activeServerState?.selection = .files + AppState.shared.activeServerState?.selection = .files } label: { Image("Section Files") @@ -84,14 +84,14 @@ struct HotlinePanelView: View { } .buttonStyle(.plain) .frame(width: 20, height: 20) - .disabled(ApplicationState.shared.activeServerState == nil) + .disabled(AppState.shared.activeServerState == nil) .help("Files") Spacer() - if ApplicationState.shared.activeHotline?.access?.contains(.canOpenUsers) == true { + if AppState.shared.activeHotline?.access?.contains(.canOpenUsers) == true { Button { - ApplicationState.shared.activeServerState?.selection = .accounts + AppState.shared.activeServerState?.selection = .accounts } label: { Image("Section Users") @@ -100,7 +100,7 @@ struct HotlinePanelView: View { } .buttonStyle(.plain) .frame(width: 20, height: 20) - .disabled(ApplicationState.shared.activeServerState == nil) + .disabled(AppState.shared.activeServerState == nil) .help("Accounts") } diff --git a/Hotline/macOS/TrackerView.swift b/Hotline/macOS/TrackerView.swift index 0fac8b0..2fbce85 100644 --- a/Hotline/macOS/TrackerView.swift +++ b/Hotline/macOS/TrackerView.swift @@ -3,6 +3,18 @@ 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 @@ -16,22 +28,78 @@ struct TrackerView: View { @State private var fileDropActive = false @State private var bookmarkExportActive = false @State private var bookmarkExport: BookmarkDocument? = nil - + @State private var expandedTrackers: Set = [] + @State private var trackerServers: [Bookmark: [BookmarkServer]] = [:] + @State private var loadingTrackers: Set = [] + @State private var searchText: String = "" + @Query(sort: \Bookmark.order) private var bookmarks: [Bookmark] - @Binding var selection: 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] ?? [] + + 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(bookmarks, id: \.self) { bookmark in - TrackerItemView(bookmark: bookmark) - .tag(bookmark) - - if bookmark.type == .tracker && bookmark.expanded { - ForEach(bookmark.servers, id: \.self) { trackedServer in - TrackerItemView(bookmark: trackedServer) + ForEach(filteredBookmarks, id: \.self) { bookmark in + TrackerItemView( + bookmark: bookmark, + isExpanded: self.expandedTrackers.contains(bookmark), + isLoading: self.loadingTrackers.contains(bookmark) + ) { + 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(trackedServer) + .tag(TrackerSelection.bookmarkServer(trackedServer)) } } } @@ -43,15 +111,22 @@ struct TrackerView: View { } } .onDeleteCommand { - if let bookmark = selection, - bookmark.type != .temporary { + 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: ApplicationState.shared.cloudKitReady) { + .onChange(of: AppState.shared.cloudKitReady) { if attemptedPrepopulate { print("Tracker: Already attempted to prepopulate bookmarks") return @@ -67,53 +142,13 @@ struct TrackerView: View { .onAppear { // Bookmark.deleteAll(context: modelContext) } - .contextMenu(forSelectionType: Bookmark.self) { items in + .contextMenu(forSelectionType: TrackerSelection.self) { items in if let item = items.first { - if item.type == .temporary { - Button { - let newBookmark = Bookmark(type: .server, name: item.name, address: item.address, port: item.port, login: item.login, password: item.password) - Bookmark.add(newBookmark, context: modelContext) - } label: { - Label("Bookmark", systemImage: "bookmark") - } - - Divider() - } - - Button { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(item.displayAddress, forType: .string) - } label: { - Label("Copy Address", systemImage: "doc.on.doc") - } - - if item.type == .tracker || item.type == .server { - Divider() - - if item.type == .tracker { - Button { - trackerSheetBookmark = item - } label: { - Label("Edit Tracker...", systemImage: "pencil") - } - } - - if item.type == .server { - Button { - bookmarkExport = BookmarkDocument(bookmark: item) - bookmarkExportActive = true - } label: { - Label("Export Bookmark...", systemImage: "bookmark.square") - } - } - - Divider() - - Button { - Bookmark.delete(item, context: modelContext) - } label: { - Label(item.type == .tracker ? "Delete Tracker" : "Delete Bookmark", systemImage: "trash") - } + switch item { + case .bookmark(let bookmark): + self.bookmarkContextMenu(bookmark) + case .bookmarkServer(let server): + self.bookmarkServerContextMenu(server) } } } primaryAction: { items in @@ -121,17 +156,37 @@ struct TrackerView: View { return } - if clickedItem.type == .tracker { - if NSEvent.modifierFlags.contains(.option) { - trackerSheetBookmark = clickedItem + switch clickedItem { + case .bookmark(let bookmark): + if bookmark.type == .server { + if let s = bookmark.server { + openWindow(id: "server", value: s) + } } - else { - clickedItem.expanded.toggle() + 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) } - else if let server = clickedItem.server { - openWindow(id: "server", value: server) - } + +// if clickedItem.type == .tracker { +// if NSEvent.modifierFlags.contains(.option) { +// trackerSheetBookmark = clickedItem +// } +// else { +// clickedItem.expanded.toggle() +// } +// } +// else if let server = clickedItem.server { +// openWindow(id: "server", value: server) +// } } .fileExporter(isPresented: $bookmarkExportActive, document: bookmarkExport, contentTypes: [.data], defaultFilename: "\(bookmarkExport?.bookmark.name ?? "Hotline Bookmark").hlbm", onCompletion: { result in switch result { @@ -145,21 +200,41 @@ struct TrackerView: View { bookmarkExportActive = false }, onCancellation: {}) .onKeyPress(.rightArrow) { - if - let bookmark = selection, - bookmark.type == .tracker { - bookmark.expanded = true - return .handled + switch self.selection { + case .bookmark(let bookmark): + if bookmark.type == .tracker { + self.expandedTrackers.insert(bookmark) + return .handled + } + default: + break } + +// if +// let bookmark = selection, +// bookmark.type == .tracker { +// bookmark.expanded = true +// return .handled +// } return .ignored } .onKeyPress(.leftArrow) { - if - let bookmark = selection, - bookmark.type == .tracker { - bookmark.expanded = false - return .handled + switch self.selection { + case .bookmark(let bookmark): + if bookmark.type == .tracker { + self.expandedTrackers.remove(bookmark) + return .handled + } + default: + break } + +// if +// let bookmark = selection, +// bookmark.type == .tracker { +// bookmark.expanded = false +// return .handled +// } return .ignored } .onDrop(of: [UTType.fileURL], isTargeted: $fileDropActive) { providers, dropPoint in @@ -198,20 +273,26 @@ struct TrackerView: View { .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) + let image = Image("Hotline") + .resizable() + .renderingMode(.template) + .scaledToFit() + .foregroundColor(Color(hex: 0xE10000)) + .frame(width: 9) + .opacity(controlActiveState == .inactive ? 0.5 : 1.0) + +// if #available(macOS 26, *) { +// image.sharedBackgroundVisibility(.hidden) +// } else { + image +// } } ToolbarItem(placement: .primaryAction) { Button { - refreshing = true - refresh() - refreshing = false + self.refreshing = true + self.refresh() + self.refreshing = false } label: { Label("Refresh", systemImage: "arrow.clockwise") } @@ -242,38 +323,142 @@ struct TrackerView: View { openWindow(id: "server", value: s) } }) + .searchable(text: $searchText, placement: .automatic, prompt: "Search") } + @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 { + bookmarkExport = BookmarkDocument(bookmark: bookmark) + bookmarkExportActive = true + } label: { + Label("Export Bookmark...", systemImage: "bookmark.square") + } + } + + 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 selectedBookmark = selection, - selectedBookmark.type == .tracker { - if !selectedBookmark.expanded { - selectedBookmark.expanded = true - } - else { - Task { - await selectedBookmark.fetchServers() + if let trackerSelection = self.selection { + switch trackerSelection { + case .bookmark(let bookmark): + if bookmark.type == .tracker { + if self.expandedTrackers.contains(bookmark) { + // Already expanded, just refresh the servers + Task { + await self.fetchServers(for: bookmark) + } + } else { + // Not expanded, expand it (which also fetches) + self.setExpanded(true, for: bookmark) + } } + return + default: + break } - return } - + // Otherwise refresh/expand all trackers. for bookmark in self.bookmarks { if bookmark.type == .tracker { - if !bookmark.expanded { - bookmark.expanded = true - } - else { + if self.expandedTrackers.contains(bookmark) { + // Already expanded, just refresh the servers Task { - await bookmark.fetchServers() + await self.fetchServers(for: bookmark) } + } else { + // Not expanded, expand it (which also fetches) + self.setExpanded(true, for: bookmark) } } } } + + func toggleExpanded(for bookmark: Bookmark) { + guard bookmark.type == .tracker else { return } + + if self.expandedTrackers.contains(bookmark) { + self.expandedTrackers.remove(bookmark) + self.trackerServers[bookmark] = nil + } else { + self.expandedTrackers.insert(bookmark) + Task { + await self.fetchServers(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) + Task { + await self.fetchServers(for: bookmark) + } + } else if !expanded && self.expandedTrackers.contains(bookmark) { + self.expandedTrackers.remove(bookmark) + self.trackerServers[bookmark] = nil + } + } + + private func fetchServers(for bookmark: Bookmark) async { + self.loadingTrackers.insert(bookmark) + let servers = await bookmark.fetchServers() + await MainActor.run { + self.trackerServers[bookmark] = servers + self.loadingTrackers.remove(bookmark) + } + } } struct TrackerBookmarkSheet: View { @@ -359,18 +544,60 @@ struct TrackerBookmarkSheet: View { } } +struct TrackerBookmarkServerView: View { + let server: BookmarkServer + + var body: some View { + HStack(alignment: .center, spacing: 6) { + Spacer() + .frame(width: 14 + 8 + 16) + Image("Server") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16, alignment: .center) + Text(self.server.name ?? "Server").lineLimit(1).truncationMode(.tail) + if let serverDescription = self.server.description { + Text(serverDescription) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + Spacer(minLength: 0) + if self.server.users > 0 { + Text(String(self.server.users)) + .foregroundStyle(.secondary) + .lineLimit(1) + + Circle() + .fill(.fileComplete) + .frame(width: 7, height: 7) + .keyframeAnimator(initialValue: 1.0, repeating: true) { content, opacity in + content.opacity(opacity) + } keyframes: { _ in + CubicKeyframe(1.0, duration: 2.0) // Stay visible for 1 second + CubicKeyframe(0.75, duration: 0.5) // Fade out quickly + CubicKeyframe(1.0, duration: 0.5) // Fade in quickly + } + .padding(.trailing, 6) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + struct TrackerItemView: View { let bookmark: Bookmark - - @State private var onlineAnimationMaxState: Bool = true - + let isExpanded: Bool + let isLoading: Bool + let onToggleExpanded: () -> Void + var body: some View { HStack(alignment: .center, spacing: 6) { if bookmark.type == .tracker { Button { - bookmark.expanded.toggle() + self.onToggleExpanded() } label: { - Text(Image(systemName: bookmark.expanded ? "chevron.down" : "chevron.right")) + Text(Image(systemName: self.isExpanded ? "chevron.down" : "chevron.right")) .bold() .font(.system(size: 10)) .opacity(0.5) @@ -381,7 +608,7 @@ struct TrackerItemView: View { .padding(.leading, 4) .padding(.trailing, 2) } - + switch bookmark.type { case .tracker: Image("Tracker") @@ -389,7 +616,7 @@ struct TrackerItemView: View { .scaledToFit() .frame(width: 16, height: 16, alignment: .center) Text(bookmark.name).bold().lineLimit(1).truncationMode(.tail) - if bookmark.loading { + if isLoading { ProgressView() .padding([.leading, .trailing], 2) .controlSize(.small) @@ -410,58 +637,26 @@ struct TrackerItemView: View { .frame(width: 16, height: 16, alignment: .center) Text(bookmark.name).lineLimit(1).truncationMode(.tail) Spacer(minLength: 0) - case .temporary: - Spacer() - .frame(width: 14 + 8 + 16) - Image("Server") - .resizable() - .scaledToFit() - .frame(width: 16, height: 16, alignment: .center) - Text(bookmark.name).lineLimit(1).truncationMode(.tail) - if let serverDescription = bookmark.serverDescription { - Text(serverDescription) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) - } - Spacer(minLength: 0) - if let serverUserCount = bookmark.serverUserCount, - serverUserCount > 0 { - Text(String(serverUserCount)) - .foregroundStyle(.secondary) - .lineLimit(1) - - Circle() - .fill(.fileComplete) - .frame(width: 7, height: 7) - .opacity(onlineAnimationMaxState ? 1.0 : 0.2) - .onAppear { - withAnimation(.easeInOut(duration: 1.0).repeatForever()) { - onlineAnimationMaxState.toggle() - } - } - .padding(.trailing, 6) - } } } .frame(maxWidth: .infinity, maxHeight: .infinity) - .onChange(of: bookmark.expanded) { - guard bookmark.type == .tracker else { - return - } - - if bookmark.expanded { - Task { - await bookmark.fetchServers() - } - } - } +// .onChange(of: self.isExpanded) { +// guard bookmark.type == .tracker else { +// return +// } +// +// if self.isExpanded { +// Task { +// await bookmark.fetchServers() +// } +// } +// } } } #if DEBUG private struct TrackerViewPreview: View { - @State var selection: Bookmark? = nil + @State var selection: TrackerSelection? = nil var body: some View { TrackerView(selection: $selection) -- cgit From 926c92be7a32e374c8855b2eb8b5a49485dec072 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Tue, 21 Oct 2025 10:17:23 -0700 Subject: Add bookmark editing to TrackerView (you can add login/pass to bookmarks now). Fix macOS 26 border around images in toolbar. --- Hotline/Models/Bookmark.swift | 2 +- Hotline/macOS/ServerView.swift | 26 +++-- Hotline/macOS/TrackerView.swift | 205 +++++++++++++++++++++++++++++----------- 3 files changed, 167 insertions(+), 66 deletions(-) diff --git a/Hotline/Models/Bookmark.swift b/Hotline/Models/Bookmark.swift index 394f9f6..80963f9 100644 --- a/Hotline/Models/Bookmark.swift +++ b/Hotline/Models/Bookmark.swift @@ -75,7 +75,7 @@ final class Bookmark { } static let DefaultBookmarks: [Bookmark] = [ - Bookmark(type: .server, name: "The Mobius Strip", address: "67.174.208.111", port: HotlinePorts.DefaultServerPort), + Bookmark(type: .server, name: "The Mobius Strip", address: "24.6.82.54", port: HotlinePorts.DefaultServerPort), Bookmark(type: .server, name: "System 7 Today", address: "hotline.system7today.com", port: HotlinePorts.DefaultServerPort), Bookmark(type: .tracker, name: "Featured Servers", address: "hltracker.com", port: HotlinePorts.DefaultTrackerPort) ] diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index e9f4a96..154bd7c 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -212,14 +212,24 @@ struct ServerView: View { .onChange(of: Prefs.shared.enableAutomaticMessage) { sendPreferences() } .onChange(of: Prefs.shared.automaticMessage) { sendPreferences() } .toolbar { - ToolbarItem(placement: .navigation) { - Image("Server Large") -// .renderingMode(.template) - - .resizable() - .scaledToFit() - .frame(width: 28) - .opacity(controlActiveState == .inactive ? 0.4 : 1.0) + if #available(macOS 26.0, *) { + ToolbarItem(placement: .navigation) { + Image("Server Large") + .resizable() + .scaledToFit() + .frame(width: 28) + .opacity(controlActiveState == .inactive ? 0.4 : 1.0) + } + .sharedBackgroundVisibility(.hidden) + } + else { + ToolbarItem(placement: .navigation) { + Image("Server Large") + .resizable() + .scaledToFit() + .frame(width: 28) + .opacity(controlActiveState == .inactive ? 0.4 : 1.0) + } } } } diff --git a/Hotline/macOS/TrackerView.swift b/Hotline/macOS/TrackerView.swift index 2fbce85..83a56b8 100644 --- a/Hotline/macOS/TrackerView.swift +++ b/Hotline/macOS/TrackerView.swift @@ -24,6 +24,7 @@ struct TrackerView: View { @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 @@ -270,22 +271,21 @@ struct TrackerView: View { .sheet(isPresented: $trackerSheetPresented) { TrackerBookmarkSheet() } + .sheet(item: $serverSheetBookmark) { item in + ServerBookmarkSheet(item) + } .navigationTitle("Servers") .toolbar { - ToolbarItem(placement: .navigation) { - let image = Image("Hotline") - .resizable() - .renderingMode(.template) - .scaledToFit() - .foregroundColor(Color(hex: 0xE10000)) - .frame(width: 9) - .opacity(controlActiveState == .inactive ? 0.5 : 1.0) - -// if #available(macOS 26, *) { -// image.sharedBackgroundVisibility(.hidden) -// } else { - image -// } + if #available(macOS 26.0, *) { + ToolbarItem(placement: .navigation) { + self.hotlineLogoImage + } + .sharedBackgroundVisibility(.hidden) + } + else { + ToolbarItem(placement: .navigation) { + self.hotlineLogoImage + } } ToolbarItem(placement: .primaryAction) { @@ -326,6 +326,16 @@ struct TrackerView: View { .searchable(text: $searchText, placement: .automatic, prompt: "Search") } + 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 { @@ -367,11 +377,17 @@ struct TrackerView: View { } 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: "bookmark.square") + Label("Export Bookmark...", systemImage: "square.and.arrow.down") } } @@ -481,9 +497,12 @@ struct TrackerBookmarkSheet: View { var body: some View { VStack(alignment: .leading) { - Text("Type the address and name of a Hotline Tracker:") - .foregroundStyle(.secondary) - .padding(.bottom, 8) + if self.bookmark == nil { + Text("Type the address and name of a Hotline Tracker:") + .foregroundStyle(.secondary) + .padding(.bottom, 8) + } + Form { Group { TextField(text: $trackerAddress) { @@ -502,33 +521,14 @@ struct TrackerBookmarkSheet: View { .padding() .toolbar { ToolbarItem(placement: .confirmationAction) { - Button(self.bookmark != nil ? "Save Tracker" : "Add Tracker") { - var displayName = trackerName.trimmingCharacters(in: .whitespacesAndNewlines) - let (host, port) = Tracker.parseTrackerAddressAndPort(trackerAddress) - - if displayName.isEmpty { - displayName = host + Button { + self.saveTracker() + } label: { + if self.bookmark != nil { + Text("Save Tracker") } - - if !displayName.isEmpty && !host.isEmpty { - if !host.isEmpty { - if self.bookmark != nil { - // We're editing an existing bookmark. - self.bookmark?.name = displayName - self.bookmark?.address = host - self.bookmark?.port = port - } - else { - // We're creating a new bookmark. - let newBookmark = Bookmark(type: .tracker, name: displayName, address: host, port: port) - Bookmark.add(newBookmark, context: modelContext) - } - - self.trackerName = "" - self.trackerAddress = "" - - dismiss() - } + else { + Text("Add Tracker") } } } @@ -537,7 +537,109 @@ struct TrackerBookmarkSheet: View { self.trackerName = "" self.trackerAddress = "" - dismiss() + self.dismiss() + } + } + } + } + + private func saveTracker() { + 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 { + if self.bookmark != nil { + // We're editing an existing bookmark. + self.bookmark?.name = displayName + self.bookmark?.address = host + self.bookmark?.port = port + } + else { + // We're creating a new bookmark. + let newBookmark = Bookmark(type: .tracker, name: displayName, address: host, port: port) + Bookmark.add(newBookmark, context: modelContext) + } + + self.trackerName = "" + self.trackerAddress = "" + + self.dismiss() + } + } + } +} + +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 { + VStack(alignment: .leading) { + Form { + Group { + TextField(text: $serverName) { + Text("Name:") + } + .padding(.bottom) + + TextField(text: $serverAddress) { + Text("Address:") + } + TextField(text: $serverLogin, prompt: Text("Optional")) { + Text("Login:") + } + SecureField(text: $serverPassword, prompt: Text("Optional")) { + Text("Password:") + } + } + .textFieldStyle(.roundedBorder) + .controlSize(.large) + } + } + .frame(width: 300) + .fixedSize(horizontal: true, vertical: true) + .padding() + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Save Bookmark") { + 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() } } } @@ -575,7 +677,7 @@ struct TrackerBookmarkServerView: View { content.opacity(opacity) } keyframes: { _ in CubicKeyframe(1.0, duration: 2.0) // Stay visible for 1 second - CubicKeyframe(0.75, duration: 0.5) // Fade out quickly + CubicKeyframe(0.6, duration: 0.5) // Fade out quickly CubicKeyframe(1.0, duration: 0.5) // Fade in quickly } .padding(.trailing, 6) @@ -640,17 +742,6 @@ struct TrackerItemView: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity) -// .onChange(of: self.isExpanded) { -// guard bookmark.type == .tracker else { -// return -// } -// -// if self.isExpanded { -// Task { -// await bookmark.fetchServers() -// } -// } -// } } } -- cgit From 54b85a88efcd455d5c12b7ca65d425d095a675f1 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Tue, 21 Oct 2025 23:09:53 -0700 Subject: Add initial support for folder downloading. --- Hotline/Hotline/HotlineClient.swift | 26 +- Hotline/Hotline/HotlineTransferClient.swift | 880 +++++++++++++++++++++++++++- Hotline/Models/Hotline.swift | 76 ++- Hotline/Models/TransferInfo.swift | 5 +- Hotline/macOS/FilesView.swift | 17 +- Hotline/macOS/ServerView.swift | 20 +- Research/HLProtocol.pdf | Bin 0 -> 1117544 bytes 7 files changed, 985 insertions(+), 39 deletions(-) create mode 100644 Research/HLProtocol.pdf diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index 434285f..99aa167 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -804,9 +804,33 @@ class HotlineClient: NetSocketDelegate { } } + @MainActor func sendDownloadFolder(name folderName: String, path folderPath: [String], callback: ((Bool, UInt32?, Int?, Int?, Int?) -> Void)? = nil) { + var t = HotlineTransaction(type: .downloadFolder) + t.setFieldString(type: .fileName, val: folderName) + t.setFieldPath(type: .filePath, val: folderPath) + + self.sendPacket(t) { reply, err in + guard err == nil, + let transferSizeField = reply.getField(type: .transferSize), + let transferSize = transferSizeField.getInteger(), + let transferReferenceField = reply.getField(type: .referenceNumber), + let referenceNumber = transferReferenceField.getUInt32() else { + callback?(false, nil, nil, nil, nil) + return + } + + let folderItemCountField = reply.getField(type: .folderItemCount) + let folderItemCount = folderItemCountField?.getInteger() + let transferWaitingCountField = reply.getField(type: .waitingCount) + let transferWaitingCount = transferWaitingCountField?.getInteger() + + callback?(true, referenceNumber, transferSize, folderItemCount, transferWaitingCount) + } + } + @MainActor func sendDownloadBanner(callback: ((Bool, UInt32?, Int?) -> Void)? = nil) { let t = HotlineTransaction(type: .downloadBanner) - + self.sendPacket(t) { reply, err in guard err == nil, let transferSizeField = reply.getField(type: .transferSize), diff --git a/Hotline/Hotline/HotlineTransferClient.swift b/Hotline/Hotline/HotlineTransferClient.swift index 4322c70..40dd598 100644 --- a/Hotline/Hotline/HotlineTransferClient.swift +++ b/Hotline/Hotline/HotlineTransferClient.swift @@ -51,6 +51,11 @@ protocol HotlineFileUploadClientDelegate: HotlineTransferDelegate { func hotlineFileUploadComplete(client: HotlineFileUploadClient, reference: UInt32) } +protocol HotlineFolderDownloadClientDelegate: HotlineTransferDelegate { + func hotlineFolderDownloadReceivedFileInfo(client: HotlineFolderDownloadClient, reference: UInt32, fileName: String, itemNumber: Int, totalItems: Int) + func hotlineFolderDownloadComplete(client: HotlineFolderDownloadClient, reference: UInt32, at: URL) +} + enum HotlineFileTransferStage: Int { case fileHeader = 1 case fileForkHeader = 2 @@ -72,6 +77,23 @@ enum HotlineFileUploadStage: Int { case fileComplete = 9 } +enum HotlineFolderDownloadStage: Int { + case itemHeader = 0 // Read 2-byte length + item header + case waitingForFileSize = 1 // Read 4-byte file size before FILP + case fileHeader = 2 + case fileForkHeader = 3 + case fileInfoFork = 4 + case fileDataFork = 5 + case fileResourceFork = 6 + case fileUnsupportedFork = 7 +} + +private enum HotlineFolderAction: UInt16 { + case sendFile = 1 + case resumeFile = 2 + case nextFile = 3 +} + protocol HotlineTransferClient { var serverAddress: NWEndpoint.Host { get } var serverPort: NWEndpoint.Port { get } @@ -242,7 +264,7 @@ class HotlineFileUploadClient: HotlineTransferClient { } self.status = .completing - + c.send(content: nil, contentContext: .finalMessage, completion: .contentProcessed({ error in })) } @@ -291,11 +313,11 @@ class HotlineFileUploadClient: HotlineTransferClient { self.payloadSize UInt32.zero } -// var magicData = Data() -// magicData.appendUInt32("HTXF".fourCharCode()) -// magicData.appendUInt32(self.referenceNumber) -// magicData.appendUInt32(self.payloadSize) -// magicData.appendUInt32(0) + // var magicData = Data() + // magicData.appendUInt32("HTXF".fourCharCode()) + // magicData.appendUInt32(self.referenceNumber) + // magicData.appendUInt32(self.payloadSize) + // magicData.appendUInt32(0) self.stage = .fileHeader self.sendFileData(magicData) @@ -359,7 +381,7 @@ class HotlineFileUploadClient: HotlineTransferClient { self.fileHandle = nil fallthrough } - + print("Upload: Sending data Fork \(String(describing: fileData?.count))") self.sendFileData(fileData!) } @@ -561,7 +583,7 @@ class HotlineFilePreviewClient: HotlineTransferClient { guard let c = self.connection else { return } - + c.receive(minimumIncompleteLength: 1, maximumLength: Int(UInt16.max)) { [weak self] (data, context, isComplete, error) in guard let self = self else { return @@ -644,12 +666,12 @@ class HotlineFileDownloadClient: HotlineTransferClient { deinit { self.invalidate() } - + func start() { guard self.status == .unconnected else { return } - + self.filePath = nil self.connect() } @@ -810,7 +832,7 @@ class HotlineFileDownloadClient: HotlineTransferClient { } case .fileForkHeader: if let forkHeader = HotlineFileForkHeader(from: self.fileBytes) { -// let fileForkHeader = HotlineFileForkHeader(from: self.fileBytes) + // let fileForkHeader = HotlineFileForkHeader(from: self.fileBytes) self.fileBytes.removeSubrange(0..= HotlineFileInfoFork.BaseDataSize else { @@ -1273,3 +1295,829 @@ struct HotlineFileInfoFork { return data } } + +// MARK: - + +class HotlineFolderDownloadClient: HotlineTransferClient { + let serverAddress: NWEndpoint.Host + let serverPort: NWEndpoint.Port + let referenceNumber: UInt32 + + private var connection: NWConnection? + private var transferStage: HotlineFolderDownloadStage = .fileHeader + + weak var delegate: HotlineFolderDownloadClientDelegate? = nil + + var status: HotlineTransferStatus = .unconnected { + didSet { + DispatchQueue.main.async { + self.delegate?.hotlineTransferStatusChanged(client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0) + } + } + } + + private let referenceDataSize: UInt32 + private let folderItemCount: Int + private var fileBytes = Data() + private var fileResourceBytes = Data() + + private var fileHeader: HotlineFileHeader? = nil + private var fileCurrentForkHeader: HotlineFileForkHeader? = nil + private var fileCurrentForkBytesLeft: Int = 0 + private var fileForksRemaining: Int = 0 + private var currentFileSize: UInt32 = 0 // Size of current file from server + private var currentFileBytesRead: Int = 0 // Bytes read for current file + private var fileInfoFork: HotlineFileInfoFork? = nil + private var fileHandle: FileHandle? = nil + private var currentFilePath: String? = nil + private var fileBytesTransferred: Int = 0 + private var fileProgress: Progress + + private var currentItemNumber: Int = 0 + private var completedItemCount: Int = 0 // Track actually completed files + private var folderPath: String? = nil + private var currentItemRelativePath: [String] = [] + private var currentFileName: String? = nil + + private let FILP_MAGIC: UInt32 = "FILP".fourCharCode() + + init(address: String, port: UInt16, reference: UInt32, size: UInt32, itemCount: Int) { + self.serverAddress = NWEndpoint.Host(address) + self.serverPort = NWEndpoint.Port(rawValue: port + 1)! + self.referenceNumber = reference + self.referenceDataSize = size + self.folderItemCount = itemCount + self.transferStage = .fileHeader + self.fileProgress = Progress(totalUnitCount: Int64(size)) + } + + deinit { + self.invalidate() + } + + func start() { + guard self.status == .unconnected else { + return + } + + self.folderPath = nil + self.connect() + } + + func start(to folderURL: URL) { + print("HotlineFolderDownloadClient: start(to:) called with path: \(folderURL.path)") + guard self.status == .unconnected else { + print("HotlineFolderDownloadClient: Already connected, status: \(self.status)") + return + } + + self.folderPath = folderURL.path + print("HotlineFolderDownloadClient: Calling connect()") + self.connect() + } + + func cancel() { + self.delegate = nil + + if self.status == .unconnected { + return + } + + // Close file before we try to potentially delete it. + if let fh = self.fileHandle { + try? fh.close() + self.fileHandle = nil + } + + if let downloadPath = self.folderPath { + print("HotlineFolderDownloadClient: Deleting folder fragment at", downloadPath) + if FileManager.default.isDeletableFile(atPath: downloadPath) { + try? FileManager.default.removeItem(atPath: downloadPath) + } + self.folderPath = nil + } + + self.invalidate() + + print("HotlineFolderDownloadClient: Cancelled transfer") + } + + private func connect() { + print("HotlineFolderDownloadClient: connect() called, connecting to \(self.serverAddress):\(self.serverPort)") + self.connection = NWConnection(host: self.serverAddress, port: self.serverPort, using: .tcp) + print("HotlineFolderDownloadClient: NWConnection created") + self.connection?.stateUpdateHandler = { [weak self] (newState: NWConnection.State) in + switch newState { + case .ready: + print("HotlineFolderDownloadClient: Connection ready!") + self?.status = .connected + self?.sendMagic() + case .waiting(let err): + print("HotlineFolderDownloadClient: Waiting", err) + case .cancelled: + print("HotlineFolderDownloadClient: Cancelled") + self?.invalidate() + case .failed(let err): + print("HotlineFolderDownloadClient: Connection error \(err)") + switch self?.status { + case .connecting: + print("HotlineFolderDownloadClient: Failed to connect to file transfer server.") + self?.invalidate() + self?.status = .failed(.failedToConnect) + case .connected, .progress(_): + print("HotlineFolderDownloadClient: Failed to finish transfer.") + self?.invalidate() + self?.status = .failed(.failedToDownload) + default: + break + } + default: + return + } + } + + self.status = .connecting + self.connection?.start(queue: .global()) + } + + func invalidate() { + if let c = self.connection { + c.stateUpdateHandler = nil + c.cancel() + + self.connection = nil + } + + self.fileBytes = Data() + + if let fh = self.fileHandle { + try? fh.close() + self.fileHandle = nil + } + + self.fileProgress.unpublish() + } + + private func parseItemHeaderPath(_ headerData: Data) -> (type: UInt16, components: [String])? { + // Need at least: type(2) + count(2) + guard headerData.count >= 4, + let type = headerData.readUInt16(at: 0), + let count = headerData.readUInt16(at: 2) else { return nil } + + var ofs = 4 + var comps: [String] = [] + for _ in 0..= ofs + 3 else { return nil } + // per Hotline path encoding: reserved(2) then nameLen(1) then name + ofs += 2 // reserved == 0 + let nameLen = Int(headerData.readUInt8(at: ofs)!) + ofs += 1 + guard headerData.count >= ofs + nameLen else { return nil } + let nameData = headerData.subdata(in: ofs..<(ofs + nameLen)) + ofs += nameLen + + let name = String(data: nameData, encoding: .macOSRoman) + ?? String(data: nameData, encoding: .utf8) + ?? "" + comps.append(name) + } + return (type, comps) + } + + /// Find and align the buffer to the start of a FILP header. + /// Returns the number of bytes dropped. + @discardableResult + private func alignBufferToFILP() -> Int { + // We need at least the 4-byte magic to try + guard self.fileBytes.count >= 4 else { return 0 } + let magicData = Data([0x46, 0x49, 0x4C, 0x50]) // "FILP" + if self.fileBytes.starts(with: magicData) { return 0 } + + if let r = self.fileBytes.firstRange(of: magicData) { + let toDrop = r.lowerBound + if toDrop > 0 { + print("HotlineFolderDownloadClient: Resync — dropping \(toDrop) stray bytes before FILP") + self.fileBytes.removeSubrange(0..= 2 else { break } + let headerLen = Int(self.fileBytes.readUInt16(at: 0)!) + guard self.fileBytes.count >= 2 + headerLen else { break } + + let headerData = self.fileBytes.subdata(in: 2..<(2 + headerLen)) + + guard let parsed = parseItemHeaderPath(headerData) else { + print("HotlineFolderDownloadClient: Invalid item header; waiting for more data") + break + } + + // Consume the header from the buffer + self.fileBytes.removeSubrange(0..<(2 + headerLen)) + + let itemType = parsed.type + let comps = parsed.components + let joinedPath = comps.joined(separator: "/") + print("HotlineFolderDownloadClient: item type=\(itemType) path=\(joinedPath)") + + guard !comps.isEmpty else { + print("HotlineFolderDownloadClient: Empty path components for item type \(itemType); requesting next item") + self.sendAction(.nextFile) + keepProcessing = !self.fileBytes.isEmpty + break + } + + if itemType == 1 { + // Folder entries: create the directory locally and continue. + self.currentItemRelativePath = comps + + if let base = self.folderPath { + var dir = base + for c in comps { dir = (dir as NSString).appendingPathComponent(c) } + do { + try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + print("HotlineFolderDownloadClient: Created folder at \(dir)") + } catch { + print("HotlineFolderDownloadClient: Failed to create subfolder: \(error)") + } + } + + self.completedItemCount += 1 + + if self.completedItemCount >= self.folderItemCount { + self.handleAllItemsDownloaded() + return + } + + self.sendAction(.nextFile) + keepProcessing = !self.fileBytes.isEmpty + break + } + else if itemType == 0 { + // File entries include the full path; split parent components from filename. + self.currentItemRelativePath = Array(comps.dropLast()) + self.currentFileName = comps.last + + self.transferStage = .waitingForFileSize + print("HotlineFolderDownloadClient: Requesting file download for '\(self.currentFileName ?? "?")'") + self.sendAction(.sendFile) + + if self.fileBytes.count >= 4 { + keepProcessing = true + } + break + } + else { + print("HotlineFolderDownloadClient: Unknown item type \(itemType); skipping") + self.sendAction(.nextFile) + keepProcessing = !self.fileBytes.isEmpty + break + } + + case .waitingForFileSize: + // Read 4-byte file size that comes before FILP in folder mode + guard self.fileBytes.count >= 4 else { break } + let fileSize = self.fileBytes.readUInt32(at: 0)! + self.fileBytes.removeSubrange(0..<4) + + print("HotlineFolderDownloadClient: File size: \(fileSize) bytes") + + self.currentFileSize = fileSize + self.currentFileBytesRead = 0 + + // Align to FILP boundary before decoding the file header + let dropped = self.alignBufferToFILP() + if dropped > 0 { + // These bytes were in the stream for this file but not part of FILP. + // Keep byte-accounting consistent by shrinking the expected size. + if self.currentFileSize >= UInt32(dropped) { + self.currentFileSize -= UInt32(dropped) + } else { + // Defensive: if weird, treat as zero-size to avoid underflow + self.currentFileSize = 0 + } + } + + self.transferStage = .fileHeader + keepProcessing = true + + case .fileHeader: + // Make sure we're actually at a FILP header + if self.fileBytes.count >= HotlineFileHeader.DataSize { + // If the 4-byte magic doesn't match, try one more resync here + if self.fileBytes.readUInt32(at: 0)! != FILP_MAGIC { + let dropped = self.alignBufferToFILP() + if dropped > 0 { + if self.currentFileSize >= UInt32(dropped) { self.currentFileSize -= UInt32(dropped) } + } + // If still not aligned or not enough bytes, wait for more data + guard self.fileBytes.count >= HotlineFileHeader.DataSize, + self.fileBytes.readUInt32(at: 0)! == FILP_MAGIC else { break } + } + + if let header = HotlineFileHeader(from: self.fileBytes) { + // Sanity gate: version and fork count + if header.format != FILP_MAGIC || header.version == 0 || header.forkCount > 3 { + print("HotlineFolderDownloadClient: Invalid FILP header (fmt=\(String(format:"0x%08X", header.format)), ver=\(header.version), forks=\(header.forkCount)). Resyncing.") + // Try resync and wait for more data + let dropped = self.alignBufferToFILP() + if dropped > 0 { if self.currentFileSize >= UInt32(dropped) { self.currentFileSize -= UInt32(dropped) } } + break + } + + self.fileBytes.removeSubrange(0..= self.folderItemCount { + self.handleAllItemsDownloaded() + return + } + + // More files to download + // Check if server has pipelined all remaining data (we've received more than expected total) + print("HotlineFolderDownloadClient: Completed \(self.completedItemCount)/\(self.folderItemCount) items, requesting next entry") + self.transferStage = .itemHeader + self.sendAction(.nextFile) + keepProcessing = !self.fileBytes.isEmpty + } + // File not complete - try to read next fork header + else if let forkHeader = HotlineFileForkHeader(from: self.fileBytes) { + // Quick validation: first fork must be INFO, and sizes must be plausible + let remainingForThisFile = max(0, Int(self.currentFileSize) - self.currentFileBytesRead) + let plausible = forkHeader.dataSize <= UInt32(remainingForThisFile) + let knownType = forkHeader.isInfoFork || forkHeader.isDataFork || forkHeader.isResourceFork + + if !plausible || (!knownType && self.currentFileBytesRead == HotlineFileHeader.DataSize) { + print("HotlineFolderDownloadClient: Implausible fork header (type=\(String(format:"0x%08X", forkHeader.forkType)), size=\(forkHeader.dataSize), remaining=\(remainingForThisFile)). Resyncing.") + let dropped = self.alignBufferToFILP() + if dropped > 0 { if self.currentFileSize >= UInt32(dropped) { self.currentFileSize -= UInt32(dropped) } } + break + } + + self.fileBytes.removeSubrange(0..= infoForkDataSize { + let infoForkData = self.fileBytes.subdata(in: 0.. 0 { + if let f = self.fileHandle { + do { + var dataToWrite = self.fileBytes + + if dataToWrite.count >= self.fileCurrentForkBytesLeft { + dataToWrite = self.fileBytes.subdata(in: 0.. 0 { + var dataToWrite = self.fileBytes + + if dataToWrite.count >= self.fileCurrentForkBytesLeft { + dataToWrite = self.fileBytes.subdata(in: 0.. 0 { + var dataToWrite = self.fileBytes + + if dataToWrite.count >= self.fileCurrentForkBytesLeft { + dataToWrite = self.fileBytes.subdata(in: 0.. 0 { + let _ = self.writeResourceFork() + self.fileResourceBytes = Data() + } + + self.fileCurrentForkHeader = nil + self.fileCurrentForkBytesLeft = 0 + self.fileForksRemaining = 0 + self.currentFileBytesRead = 0 + self.currentFileSize = 0 + self.currentFilePath = nil + self.fileInfoFork = nil + self.fileHeader = nil + self.currentFileName = nil + } + + private func handleAllItemsDownloaded() { + guard self.status != .completed else { + return + } + + print("HotlineFolderDownloadClient: All \(self.folderItemCount) items downloaded") + + self.invalidate() + self.status = .completed + + if let downloadPath = self.folderPath { + DispatchQueue.main.sync { + print("HotlineFolderDownloadClient: Folder download complete", downloadPath) + + var downloadURL = URL(filePath: downloadPath) + downloadURL.resolveSymlinksInPath() + + self.delegate?.hotlineFolderDownloadComplete(client: self, reference: self.referenceNumber, at: downloadURL) + +#if os(macOS) + DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path) +#endif + } + } + } + + private func writeResourceFork() -> Bool { + guard let filePath = self.currentFilePath else { + return false + } + + var resolvedFileURL = URL(filePath: filePath) + resolvedFileURL.resolveSymlinksInPath() + + let resourceFilePath = resolvedFileURL.appendingPathComponent("..namedfork/rsrc") + + do { + try self.fileResourceBytes.write(to: resourceFilePath) + } + catch { + return false + } + + return true + } + + private func prepareDownloadFile(name: String) -> Bool { + var filePath: String + + if self.folderPath != nil { + // Build the full path including subfolders + var fullPath = self.folderPath! + for component in self.currentItemRelativePath { + fullPath = (fullPath as NSString).appendingPathComponent(component) + } + + let folderURL = URL(filePath: fullPath) + + // Create folder if it doesn't exist + if !FileManager.default.fileExists(atPath: folderURL.path) { + do { + try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) + } + catch { + print("HotlineFolderDownloadClient: Failed to create folder", error) + return false + } + } + + filePath = folderURL.appendingPathComponent(name).path + print("HotlineFolderDownloadClient: Creating file at \(filePath)") + } + else { + let downloadsURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0] + filePath = downloadsURL.generateUniqueFilePath(filename: name) + } + + var fileAttributes: [FileAttributeKey: Any] = [:] + if let creatorCode = self.fileInfoFork?.creator { + fileAttributes[.hfsCreatorCode] = creatorCode as NSNumber + } + if let typeCode = self.fileInfoFork?.type { + fileAttributes[.hfsTypeCode] = typeCode as NSNumber + } + if let createdDate = self.fileInfoFork?.createdDate { + fileAttributes[.creationDate] = createdDate as NSDate + } + if let modifiedDate = self.fileInfoFork?.modifiedDate { + fileAttributes[.modificationDate] = modifiedDate as NSDate + } + if let comment = self.fileInfoFork?.comment { + if let commentPlistData = try? PropertyListSerialization.data(fromPropertyList: comment, format: .binary, options: 0) { + fileAttributes[FileAttributeKey(rawValue: "NSFileExtendedAttributes")] = [ + FileAttributeKey(rawValue: "com.apple.metadata:kMDItemFinderComment"): commentPlistData + ] + } + } + + if FileManager.default.createFile(atPath: filePath, contents: nil, attributes: fileAttributes) { + if let h = FileHandle(forWritingAtPath: filePath) { + self.currentFilePath = filePath + self.fileHandle = h + + // Only set file progress on first file + if self.currentItemNumber == 1 && self.folderPath != nil { + self.fileProgress.fileURL = URL(filePath: self.folderPath!).resolvingSymlinksInPath() + self.fileProgress.fileOperationKind = .downloading + self.fileProgress.publish() + } + + return true + } + } + + return false + } +} diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift index 9ea29c5..48cae54 100644 --- a/Hotline/Models/Hotline.swift +++ b/Hotline/Models/Hotline.swift @@ -1,7 +1,7 @@ import SwiftUI @Observable -class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelegate, HotlineFilePreviewClientDelegate, HotlineFileUploadClientDelegate { +class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelegate, HotlineFilePreviewClientDelegate, HotlineFileUploadClientDelegate, HotlineFolderDownloadClientDelegate { let id: UUID = UUID() let trackerClient: HotlineTrackerClient let client: HotlineClient @@ -559,7 +559,51 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega } } } - + + @MainActor func downloadFolder(_ folderName: String, path: [String], complete callback: ((TransferInfo, URL) -> Void)? = nil) { + var fullPath: [String] = [] + if path.count > 1 { + fullPath = Array(path[0.. Void)? = nil) { let fileName = fileURL.lastPathComponent @@ -1003,12 +1047,36 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega SoundEffectPlayer.shared.playSoundEffect(.transferComplete) } } - + if let i = self.downloads.firstIndex(where: { $0.referenceNumber == reference }) { self.downloads.remove(at: i) } } - + + func hotlineFolderDownloadReceivedFileInfo(client: HotlineFolderDownloadClient, reference: UInt32, fileName: String, itemNumber: Int, totalItems: Int) { + // Optional: Update transfer title to show current file being downloaded + if let i = self.transfers.firstIndex(where: { $0.id == reference }) { + let transfer = self.transfers[i] + // You could update the title here to show progress: "FolderName (file 3 of 10)" + // transfer.title = "\(originalFolderName) (\(itemNumber) of \(totalItems))" + } + } + + func hotlineFolderDownloadComplete(client: HotlineFolderDownloadClient, reference: UInt32, at: URL) { + if let i = self.transfers.firstIndex(where: { $0.id == reference }) { + let transfer = self.transfers[i] + transfer.fileURL = at + transfer.downloadCallback?(transfer, at) + if Prefs.shared.playSounds && Prefs.shared.playFileTransferCompleteSound { + SoundEffectPlayer.shared.playSoundEffect(.transferComplete) + } + } + + if let i = self.downloads.firstIndex(where: { $0.referenceNumber == reference }) { + self.downloads.remove(at: i) + } + } + // MARK: - Utilities func updateServerTitle() { diff --git a/Hotline/Models/TransferInfo.swift b/Hotline/Models/TransferInfo.swift index 50b23f5..ba25793 100644 --- a/Hotline/Models/TransferInfo.swift +++ b/Hotline/Models/TransferInfo.swift @@ -3,14 +3,15 @@ import SwiftUI @Observable class TransferInfo: Identifiable, Equatable, Hashable { var id: UInt32 - + var title: String var size: UInt var progress: Double = 0.0 var timeRemaining: TimeInterval = 0.0 var completed: Bool = false var failed: Bool = false - + var isFolder: Bool = false + // For file based transfers (i.e. not previews) var fileURL: URL? = nil diff --git a/Hotline/macOS/FilesView.swift b/Hotline/macOS/FilesView.swift index 2befa18..f399372 100644 --- a/Hotline/macOS/FilesView.swift +++ b/Hotline/macOS/FilesView.swift @@ -236,11 +236,12 @@ struct FilesView: View { } @MainActor private func downloadFile(_ file: FileInfo) { - guard !file.isFolder else { - return + if file.isFolder { + model.downloadFolder(file.name, path: file.path) + } + else { + model.downloadFile(file.name, path: file.path) } - - model.downloadFile(file.name, path: file.path) } @MainActor private func uploadFile(file fileURL: URL, to path: [String]) { @@ -297,13 +298,13 @@ struct FilesView: View { let selectedFile = items.first Button { - if let s = selectedFile, !s.isFolder { + if let s = selectedFile { downloadFile(s) } } label: { Label("Download", systemImage: "arrow.down") } - .disabled(selectedFile == nil || (selectedFile != nil && selectedFile!.isFolder)) + .disabled(selectedFile == nil) Divider() @@ -419,14 +420,14 @@ struct FilesView: View { ToolbarItem(placement: .primaryAction) { Button { - if let selectedFile = selection, !selectedFile.isFolder { + if let selectedFile = selection { downloadFile(selectedFile) } } label: { Label("Download", systemImage: "arrow.down") } .help("Download") - .disabled(selection == nil || selection?.isFolder == true || model.access?.contains(.canDownloadFiles) != true) + .disabled(selection == nil || model.access?.contains(.canDownloadFiles) != true) } } } diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index 154bd7c..115709d 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -585,10 +585,6 @@ struct ServerView: View { } } -//#Preview { -// ServerView(server: Server(name: "", description: "", address: "", port: 0)) -//} - struct TransferItemView: View { let transfer: TransferInfo @@ -619,10 +615,18 @@ struct TransferItemView: View { HStack(alignment: .center, spacing: 5) { HStack(spacing: 0) { Spacer() - FileIconView(filename: transfer.title, fileType: nil) - .frame(width: 16, height: 16) - .opacity(controlActiveState == .inactive ? 0.5 : 1.0) -// .padding(.leading, 2) + if transfer.isFolder { + Image("Folder") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + .opacity(controlActiveState == .inactive ? 0.5 : 1.0) + } + else { + FileIconView(filename: transfer.title, fileType: nil) + .frame(width: 16, height: 16) + .opacity(controlActiveState == .inactive ? 0.5 : 1.0) + } Spacer() } .frame(width: 20) diff --git a/Research/HLProtocol.pdf b/Research/HLProtocol.pdf new file mode 100644 index 0000000..aa2a197 Binary files /dev/null and b/Research/HLProtocol.pdf differ -- cgit From e2389dee5255661313b2b3c4fc23f4b5b6dd9d22 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Tue, 21 Oct 2025 23:14:19 -0700 Subject: Double click finished transfers to reveal in finder. --- Hotline/macOS/ServerView.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index 115709d..81c993a 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -1,5 +1,6 @@ import SwiftUI import UniformTypeIdentifiers +import AppKit @Observable class ServerState: Equatable { @@ -689,6 +690,13 @@ struct TransferItemView: View { self.hovered = hovered } } + .onTapGesture(count: 2) { + guard transfer.completed, let url = transfer.fileURL else { + return + } + + NSWorkspace.shared.activateFileViewerSelecting([url]) + } .help(formattedProgressHelp()) } } -- cgit From 7faa0f834c6e51270dee509045dcbea5159e0ac1 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Wed, 22 Oct 2025 08:38:41 -0700 Subject: Update README with new image and folder download status Check off Folder Downloading :D --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index cf55a3c..cc7e619 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -![Hotline](https://github.com/mierau/hotline/assets/55453/165beea1-a760-4cd5-baad-c2917048e383) +GitHub Banner # Hotline An attempt to resurrect Hotline for modern Apple systems (iOS, macOS, etc) by completely recreating it in Swift and SwiftUI. @@ -56,7 +56,7 @@ And this project is an attempt to create a modern open source version of Hotline | File uploading | ✓ | | | | | File info | ✓ | | | | | File management | | | | | -| Folder downloading | | | | | +| Folder downloading | ✓ | | | | | Folder uploading | | | | | | Custom icon sets | | | | | -- cgit From 55424109019bd46f91fed97484efd1a7f2f1c5c6 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Wed, 22 Oct 2025 08:41:22 -0700 Subject: Bump build number to 22. --- Hotline.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index a188bfd..54adc37 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -656,7 +656,7 @@ CODE_SIGN_ENTITLEMENTS = Hotline/Hotline.entitlements; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 21; + CURRENT_PROJECT_VERSION = 22; DEVELOPMENT_ASSET_PATHS = "\"Hotline/Preview Content\""; DEVELOPMENT_TEAM = 5AEEV7QB2U; "ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = YES; @@ -696,7 +696,7 @@ CODE_SIGN_ENTITLEMENTS = Hotline/Hotline.entitlements; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 21; + CURRENT_PROJECT_VERSION = 22; DEVELOPMENT_ASSET_PATHS = "\"Hotline/Preview Content\""; DEVELOPMENT_TEAM = 5AEEV7QB2U; "ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = YES; -- cgit From 46213b6e09b8b23bfa039634ff5af4919c758473 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Wed, 22 Oct 2025 17:32:59 -0700 Subject: First pass at file search in files view. --- Hotline/Hotline/HotlineClient.swift | 38 +++-- Hotline/Models/Hotline.swift | 295 +++++++++++++++++++++++++++++++++++- Hotline/macOS/FilesView.swift | 139 +++++++++++++++-- Hotline/macOS/ServerView.swift | 11 ++ 4 files changed, 454 insertions(+), 29 deletions(-) diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index 99aa167..3c6a643 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -89,7 +89,13 @@ class HotlineClient: NetSocketDelegate { private var serverAddress: String? = nil private var serverPort: UInt16? = nil - private var transactionLog: [UInt32:(HotlineTransactionType, ((HotlineTransaction, HotlineTransactionError?) -> Void)?)] = [:] + private struct TransactionContext { + let type: HotlineTransactionType + let callback: ((HotlineTransaction, HotlineTransactionError?) -> Void)? + let suppressErrors: Bool + } + + private var transactionLog: [UInt32: TransactionContext] = [:] private var socket: NetSocket? private var stage: HotlineClientStage = .handshake @@ -208,15 +214,15 @@ class HotlineClient: NetSocketDelegate { // MARK: - Packets - @MainActor private func sendPacket(_ t: HotlineTransaction, callback: ((HotlineTransaction, HotlineTransactionError?) -> Void)? = nil) { + @MainActor private func sendPacket(_ t: HotlineTransaction, suppressErrors: Bool = false, callback: ((HotlineTransaction, HotlineTransactionError?) -> Void)? = nil) { guard let socket = self.socket else { return } print("HotlineClient => \(t.id) \(t.type)") - if callback != nil { - self.transactionLog[t.id] = (t.type, callback) + if callback != nil || suppressErrors { + self.transactionLog[t.id] = TransactionContext(type: t.type, callback: callback, suppressErrors: suppressErrors) } socket.write(t.encoded()) @@ -375,22 +381,22 @@ class HotlineClient: NetSocketDelegate { return } + let context = self.transactionLog[packet.id] + self.transactionLog[packet.id] = nil + if packet.errorCode != 0 { let errorField: HotlineTransactionField? = packet.getField(type: .errorText) print("HotlineClient 😵 \(packet.errorCode): \(errorField?.getString() ?? "")") - self.delegate?.hotlineReceivedErrorMessage(code: packet.errorCode, message: errorField?.getString()) + if context?.suppressErrors != true { + self.delegate?.hotlineReceivedErrorMessage(code: packet.errorCode, message: errorField?.getString()) + } } - - guard let replyCallbackInfo = self.transactionLog[packet.id] else { - print("Hmm, no reply waiting though") - return + + if let context { + print("HotlineClient reply in response to \(context.type)") } - self.transactionLog[packet.id] = nil - - print("HotlineClient reply in response to \(replyCallbackInfo.0)") - - let replyCallback = replyCallbackInfo.1 + let replyCallback = context?.callback guard packet.errorCode == 0 else { let errorField: HotlineTransactionField? = packet.getField(type: .errorText) @@ -630,13 +636,13 @@ class HotlineClient: NetSocketDelegate { } } - @MainActor func sendGetFileList(path: [String] = [], callback: (([HotlineFile]) -> Void)? = nil) { + @MainActor func sendGetFileList(path: [String] = [], suppressErrors: Bool = false, callback: (([HotlineFile]) -> Void)? = nil) { var t = HotlineTransaction(type: .getFileNameList) if !path.isEmpty { t.setFieldPath(type: .filePath, val: path) } - self.sendPacket(t) { reply, err in + self.sendPacket(t, suppressErrors: suppressErrors) { reply, err in guard err == nil else { callback?([]) return diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift index 48cae54..b93fb55 100644 --- a/Hotline/Models/Hotline.swift +++ b/Hotline/Models/Hotline.swift @@ -1,5 +1,29 @@ import SwiftUI +struct FileSearchConfig: Equatable { + var initialBurstCount: Int = 8 + var initialDelay: TimeInterval = 0.05 + var backoffMultiplier: Double = 1.6 + var maxDelay: TimeInterval = 1.5 + var maxDepth: Int = 40 + var loopRepetitionLimit: Int = 4 +} + +enum FileSearchStatus: Equatable { + case idle + case searching(processed: Int, pending: Int) + case completed(processed: Int) + case cancelled(processed: Int) + case failed(String) + + var isActive: Bool { + if case .searching = self { + return true + } + return false + } +} + @Observable class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelegate, HotlineFilePreviewClientDelegate, HotlineFileUploadClientDelegate, HotlineFolderDownloadClientDelegate { let id: UUID = UUID() @@ -131,6 +155,13 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega var messageBoardLoaded: Bool = false var files: [FileInfo] = [] var filesLoaded: Bool = false + var fileSearchResults: [FileInfo] = [] + var fileSearchStatus: FileSearchStatus = .idle + var fileSearchQuery: String = "" + var fileSearchConfig = FileSearchConfig() + var fileSearchScannedFolders: Int = 0 + @ObservationIgnored private var fileSearchSession: FileSearchSession? = nil + @ObservationIgnored private var fileSearchResultKeys: Set = [] var news: [NewsInfo] = [] private var newsLookup: [String:NewsInfo] = [:] var newsLoaded: Bool = false @@ -209,6 +240,9 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega @MainActor func disconnect() { self.client.disconnect() self.bannerClient?.cancel() + self.fileSearchSession?.cancel() + self.fileSearchSession = nil + self.resetFileSearchState() } @MainActor func sendAgree() { @@ -264,9 +298,9 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega self.client.sendPostMessageBoard(text: text) } - @MainActor func getFileList(path: [String] = []) async -> [FileInfo] { + @MainActor func getFileList(path: [String] = [], suppressErrors: Bool = false) async -> [FileInfo] { return await withCheckedContinuation { [weak self] continuation in - self?.client.sendGetFileList(path: path) { [weak self] files in + self?.client.sendGetFileList(path: path, suppressErrors: suppressErrors) { [weak self] files in let parentFile = self?.findFile(in: self?.files ?? [], at: path) var newFiles: [FileInfo] = [] @@ -289,6 +323,92 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega } } + @MainActor func startFileSearch(query: String) { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + cancelFileSearch() + return + } + + fileSearchSession?.cancel() + resetFileSearchState() + fileSearchQuery = trimmed + fileSearchStatus = .searching(processed: 0, pending: 0) + fileSearchScannedFolders = 0 + + let session = FileSearchSession(hotline: self, query: trimmed, config: fileSearchConfig) + fileSearchSession = session + + Task { await session.start() } + } + + @MainActor func cancelFileSearch(clearResults: Bool = true) { + guard let session = fileSearchSession else { + if clearResults { + resetFileSearchState() + } else if !fileSearchResults.isEmpty { + fileSearchStatus = .cancelled(processed: fileSearchScannedFolders) + } + return + } + + session.cancel() + fileSearchSession = nil + if clearResults { + resetFileSearchState() + } + else { + fileSearchStatus = .cancelled(processed: fileSearchScannedFolders) + } + } + + @MainActor fileprivate func searchSession(_ session: FileSearchSession, didEmit matches: [FileInfo], processed: Int, pending: Int) { + guard fileSearchSession === session else { + return + } + + var appended: [FileInfo] = [] + for match in matches { + let key = searchPathKey(for: match.path) + if fileSearchResultKeys.insert(key).inserted { + appended.append(match) + } + } + + if !appended.isEmpty { + fileSearchResults.append(contentsOf: appended) + } + + fileSearchScannedFolders = processed + fileSearchStatus = .searching(processed: processed, pending: pending) + } + + @MainActor fileprivate func searchSessionDidFinish(_ session: FileSearchSession, processed: Int, pending: Int, completed: Bool) { + guard fileSearchSession === session else { + return + } + + fileSearchScannedFolders = processed + fileSearchSession = nil + if completed { + fileSearchStatus = .completed(processed: processed) + } else { + fileSearchStatus = .cancelled(processed: processed) + } + } + + private func resetFileSearchState() { + fileSearchResults = [] + fileSearchResultKeys.removeAll(keepingCapacity: true) + fileSearchStatus = .idle + fileSearchQuery = "" + fileSearchScannedFolders = 0 + } + + private func searchPathKey(for path: [String]) -> String { + path.joined(separator: "\u{001F}") + } + @MainActor func getNewsArticle(id articleID: UInt, at path: [String], flavor: String) async -> String? { return await withCheckedContinuation { [weak self] continuation in self?.client.sendGetNewsArticle(id: UInt32(articleID), path: path, flavor: flavor) { articleText in @@ -809,6 +929,10 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega self.serverName = nil self.access = nil + self.fileSearchSession?.cancel() + self.fileSearchSession = nil + self.resetFileSearchState() + self.users = [] self.chat = [] self.messageBoard = [] @@ -1156,3 +1280,170 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega } } } + +@MainActor +final class FileSearchSession { + private struct FolderTask { + let path: [String] + let depth: Int + } + + private weak var hotline: Hotline? + private let queryTokens: [String] + private let config: FileSearchConfig + + private var queue: [FolderTask] = [] + private var visited: Set = [] + private var loopHistogram: [String: Int] = [:] + + private var processedCount: Int = 0 + private var currentDelay: TimeInterval + private var isCancelled = false + + init(hotline: Hotline, query: String, config: FileSearchConfig) { + self.hotline = hotline + self.queryTokens = query.lowercased().split(separator: " ").map(String.init) + self.config = config + self.currentDelay = config.initialDelay + } + + func start() async { + guard let hotline else { + return + } + + await Task.yield() + + if !hotline.filesLoaded { + let rootFiles = await hotline.getFileList(path: [], suppressErrors: true) + processedCount = max(processedCount, 1) + processListing(rootFiles, depth: 0) + } + else { + processedCount = max(processedCount, 1) + processListing(hotline.files, depth: 0) + } + + while !queue.isEmpty && !isCancelled { + await Task.yield() + + let task = queue.removeFirst() + if shouldSkip(path: task.path, depth: task.depth) { + hotline.searchSession(self, didEmit: [], processed: processedCount, pending: queue.count) + continue + } + + visited.insert(pathKey(for: task.path)) + + let children = await hotline.getFileList(path: task.path, suppressErrors: true) + processedCount += 1 + + if isCancelled { + break + } + + processListing(children, depth: task.depth) + + await applyBackoff() + } + + hotline.searchSessionDidFinish(self, processed: processedCount, pending: queue.count, completed: !isCancelled) + } + + func cancel() { + isCancelled = true + } + + private func processListing(_ items: [FileInfo], depth: Int) { + guard let hotline else { + return + } + + var matches: [FileInfo] = [] + + for file in items { + if nameMatchesQuery(file.name) { + matches.append(file) + } + + if file.isFolder { + enqueueFolder(file, depth: depth + 1) + } + } + + hotline.searchSession(self, didEmit: matches, processed: processedCount, pending: queue.count) + } + + private func enqueueFolder(_ folder: FileInfo, depth: Int) { + guard !isCancelled else { return } + guard depth <= config.maxDepth else { return } + + let path = folder.path + let key = pathKey(for: path) + guard !visited.contains(key) else { return } + + if exceedsLoopThreshold(for: path) { + return + } + + queue.append(FolderTask(path: path, depth: depth)) + } + + private func shouldSkip(path: [String], depth: Int) -> Bool { + if isCancelled { + return true + } + + if depth > config.maxDepth { + return true + } + + let key = pathKey(for: path) + if visited.contains(key) { + return true + } + + return false + } + + private func nameMatchesQuery(_ name: String) -> Bool { + guard !queryTokens.isEmpty else { return false } + let lowercased = name.lowercased() + return queryTokens.allSatisfy { lowercased.contains($0) } + } + + private func exceedsLoopThreshold(for path: [String]) -> Bool { + guard config.loopRepetitionLimit > 0 else { return false } + guard let last = path.last else { return false } + let parent = path.dropLast() + + guard let previousIndex = parent.lastIndex(of: last) else { + return false + } + + let suffix = Array(path[previousIndex...]) + let key = suffix.joined(separator: "\u{001F}") + let count = (loopHistogram[key] ?? 0) + 1 + loopHistogram[key] = count + return count > config.loopRepetitionLimit + } + + private func pathKey(for path: [String]) -> String { + path.joined(separator: "\u{001F}") + } + + private func applyBackoff() async { + guard !isCancelled else { return } + + if processedCount > config.initialBurstCount { + currentDelay = min(config.maxDelay, max(config.initialDelay, currentDelay * config.backoffMultiplier)) + } + + guard currentDelay > 0 else { + return + } + + let nanoseconds = UInt64(currentDelay * 1_000_000_000) + try? await Task.sleep(nanoseconds: nanoseconds) + } +} diff --git a/Hotline/macOS/FilesView.swift b/Hotline/macOS/FilesView.swift index f399372..206e0a4 100644 --- a/Hotline/macOS/FilesView.swift +++ b/Hotline/macOS/FilesView.swift @@ -213,6 +213,48 @@ struct FilesView: View { @State private var selection: FileInfo? @State private var fileDetails: FileDetails? @State private var uploadFileSelectorDisplayed: Bool = false + @State private var searchText: String = "" + + private var isShowingSearchResults: Bool { + switch model.fileSearchStatus { + case .idle: + return !model.fileSearchResults.isEmpty + case .cancelled(_): + return !model.fileSearchResults.isEmpty + default: + return true + } + } + + private var displayedFiles: [FileInfo] { + isShowingSearchResults ? model.fileSearchResults : model.files + } + + private var searchStatusMessage: String? { + switch model.fileSearchStatus { + case .searching(let processed, let pending): + let scanned = processed == 1 ? "folder" : "folders" + return "Searched \(processed) \(scanned)..." + case .completed(let processed): + let count = model.fileSearchResults.count + let folderWord = processed == 1 ? "folder" : "folders" + if count == 0 { + return "No files found in \(processed) \(folderWord)" + } + return "\(count) file\(count == 1 ? "" : "s") found in \(processed) \(folderWord)" + case .cancelled(let processed): + if model.fileSearchResults.isEmpty { + return nil + } + let count = model.fileSearchResults.count + let folderWord = processed == 1 ? "folder" : "folders" + return "Search cancelled" + case .failed(let message): + return "Search failed: \(message)" + case .idle: + return nil + } + } private func openPreviewWindow(_ previewInfo: PreviewFileInfo) { switch previewInfo.previewType { @@ -278,7 +320,7 @@ struct FilesView: View { var body: some View { NavigationStack { - List(model.files, id: \.self, selection: $selection) { file in + List(displayedFiles, id: \.self, selection: $selection) { file in if file.isFolder { FolderView(file: file, depth: 0).tag(file.id) } @@ -383,8 +425,9 @@ struct FilesView: View { .frame(maxWidth: .infinity) } } + .searchable(text: $searchText, placement: .automatic, prompt: "Search") .toolbar { - ToolbarItem(placement: .primaryAction) { + ToolbarItemGroup(placement: .automatic) { Button { if let selectedFile = selection, selectedFile.isPreviewable { previewFile(selectedFile) @@ -394,9 +437,7 @@ struct FilesView: View { } .help("Preview") .disabled(selection == nil || selection?.isPreviewable == false) - } - - ToolbarItem(placement: .primaryAction) { + Button { if let selectedFile = selection { getFileInfo(selectedFile) @@ -406,9 +447,7 @@ struct FilesView: View { } .help("Get Info") .disabled(selection == nil) - } - - ToolbarItem(placement: .primaryAction) { + Button { uploadFileSelectorDisplayed = true } label: { @@ -416,9 +455,7 @@ struct FilesView: View { } .help("Upload") .disabled(model.access?.contains(.canUploadFiles) != true) - } - - ToolbarItem(placement: .primaryAction) { + Button { if let selectedFile = selection { downloadFile(selectedFile) @@ -464,6 +501,86 @@ struct FilesView: View { print(error) } }) + .onSubmit(of: .search) { + let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + model.cancelFileSearch() + return + } + searchText = trimmed + model.startFileSearch(query: trimmed) + } + .onChange(of: searchText) { _, newValue in + if newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + if isShowingSearchResults { + model.cancelFileSearch() + } + } + } + .onChange(of: model.fileSearchQuery) { _, newValue in + if newValue != searchText { + searchText = newValue + } + } + .onAppear { + if searchText != model.fileSearchQuery { + searchText = model.fileSearchQuery + } + } + .safeAreaInset(edge: .top) { + if isShowingSearchResults, let message = searchStatusMessage { + HStack { + Spacer() + + if case .searching(_, _) = model.fileSearchStatus { + ProgressView() + .controlSize(.small) + .tint(.red) + } + else if case .completed = model.fileSearchStatus { + Image(systemName: "checkmark.circle.fill") + .resizable() + .symbolRenderingMode(.palette) + .foregroundStyle(.white, .fileComplete) + .aspectRatio(contentMode: .fit) + .frame(width: 16, height: 16) + } + else if case .failed = model.fileSearchStatus { + Image(systemName: "exclamationmark.triangle.fill") + .resizable() + .symbolRenderingMode(.multicolor) + .aspectRatio(contentMode: .fit) + .frame(width: 16, height: 16) + } + + Text(message) + .font(.body) + .foregroundStyle(.white) + + Spacer() + } +// .overlay(alignment: .leading) { +// Button { +// model.cancelFileSearch(clearResults: true) +// } label: { +// Image(systemName: "xmark.circle.fill") +// .resizable() +// .scaledToFit() +// .foregroundStyle(.white) +// .opacity(0.8) +// } +// .buttonStyle(.plain) +// } + .padding(.horizontal, 8) + .padding(.leading, 4) + .padding(.vertical, 8) + .background { + Color(nsColor: .controlAccentColor) + .clipShape(.capsule(style: .continuous)) + } + .padding(.horizontal, 8) + } + } } } diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index 81c993a..49413fe 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -392,6 +392,16 @@ struct ServerView: View { ListItemView(icon: menuItem.image, title: menuItem.name, unread: false).tag(menuItem.type) } } + else if menuItem.type == .files { + ListItemView(icon: menuItem.image, title: menuItem.name, unread: false).tag(menuItem.type) + .overlay(alignment: .trailing) { + if case .searching(_, _) = model.fileSearchStatus { + ProgressView() + .controlSize(.mini) + .padding(.trailing, 4) + } + } + } else { ListItemView(icon: menuItem.image, title: menuItem.name, unread: false).tag(menuItem.type) } @@ -700,3 +710,4 @@ struct TransferItemView: View { .help(formattedProgressHelp()) } } + -- cgit From 485e4d981b6cd19a1e00ebf244ed1dd538fba284 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Wed, 22 Oct 2025 21:15:04 -0700 Subject: Improvements to file search UI and config. --- Hotline/Models/Hotline.swift | 25 +++++++++++++--- Hotline/macOS/FilesView.swift | 64 ++++++++++++++++++++++++++--------------- Hotline/macOS/TrackerView.swift | 3 +- 3 files changed, 64 insertions(+), 28 deletions(-) diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift index b93fb55..786fb0c 100644 --- a/Hotline/Models/Hotline.swift +++ b/Hotline/Models/Hotline.swift @@ -1,10 +1,10 @@ import SwiftUI struct FileSearchConfig: Equatable { - var initialBurstCount: Int = 8 - var initialDelay: TimeInterval = 0.05 - var backoffMultiplier: Double = 1.6 - var maxDelay: TimeInterval = 1.5 + var initialBurstCount: Int = 15 + var initialDelay: TimeInterval = 0.02 + var backoffMultiplier: Double = 1.1 + var maxDelay: TimeInterval = 1.3 var maxDepth: Int = 40 var loopRepetitionLimit: Int = 4 } @@ -160,6 +160,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega var fileSearchQuery: String = "" var fileSearchConfig = FileSearchConfig() var fileSearchScannedFolders: Int = 0 + var fileSearchCurrentPath: [String]? = nil @ObservationIgnored private var fileSearchSession: FileSearchSession? = nil @ObservationIgnored private var fileSearchResultKeys: Set = [] var news: [NewsInfo] = [] @@ -335,6 +336,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega fileSearchQuery = trimmed fileSearchStatus = .searching(processed: 0, pending: 0) fileSearchScannedFolders = 0 + fileSearchCurrentPath = [] let session = FileSearchSession(hotline: self, query: trimmed, config: fileSearchConfig) fileSearchSession = session @@ -348,12 +350,14 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega resetFileSearchState() } else if !fileSearchResults.isEmpty { fileSearchStatus = .cancelled(processed: fileSearchScannedFolders) + fileSearchCurrentPath = nil } return } session.cancel() fileSearchSession = nil + fileSearchCurrentPath = nil if clearResults { resetFileSearchState() } @@ -383,6 +387,14 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega fileSearchStatus = .searching(processed: processed, pending: pending) } + @MainActor fileprivate func searchSession(_ session: FileSearchSession, didFocusOn path: [String]) { + guard fileSearchSession === session else { + return + } + + fileSearchCurrentPath = path + } + @MainActor fileprivate func searchSessionDidFinish(_ session: FileSearchSession, processed: Int, pending: Int, completed: Bool) { guard fileSearchSession === session else { return @@ -390,6 +402,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega fileSearchScannedFolders = processed fileSearchSession = nil + fileSearchCurrentPath = nil if completed { fileSearchStatus = .completed(processed: processed) } else { @@ -403,6 +416,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega fileSearchStatus = .idle fileSearchQuery = "" fileSearchScannedFolders = 0 + fileSearchCurrentPath = nil } private func searchPathKey(for path: [String]) -> String { @@ -1315,11 +1329,13 @@ final class FileSearchSession { await Task.yield() if !hotline.filesLoaded { + hotline.searchSession(self, didFocusOn: []) let rootFiles = await hotline.getFileList(path: [], suppressErrors: true) processedCount = max(processedCount, 1) processListing(rootFiles, depth: 0) } else { + hotline.searchSession(self, didFocusOn: []) processedCount = max(processedCount, 1) processListing(hotline.files, depth: 0) } @@ -1333,6 +1349,7 @@ final class FileSearchSession { continue } + hotline.searchSession(self, didFocusOn: task.path) visited.insert(pathKey(for: task.path)) let children = await hotline.getFileList(path: task.path, suppressErrors: true) diff --git a/Hotline/macOS/FilesView.swift b/Hotline/macOS/FilesView.swift index 206e0a4..4498dc9 100644 --- a/Hotline/macOS/FilesView.swift +++ b/Hotline/macOS/FilesView.swift @@ -255,6 +255,16 @@ struct FilesView: View { return nil } } + + private var searchStatusPath: String? { + guard let path = model.fileSearchCurrentPath else { + return nil + } + if path.isEmpty { + return "/" + } + return "/" + path.joined(separator: "/") + "/" + } private func openPreviewWindow(_ previewInfo: PreviewFileInfo) { switch previewInfo.previewType { @@ -529,56 +539,64 @@ struct FilesView: View { } .safeAreaInset(edge: .top) { if isShowingSearchResults, let message = searchStatusMessage { - HStack { - Spacer() - + HStack(alignment: .center, spacing: 6) { if case .searching(_, _) = model.fileSearchStatus { ProgressView() .controlSize(.small) - .tint(.red) + .accentColor(.white) + .tint(.white) } else if case .completed = model.fileSearchStatus { Image(systemName: "checkmark.circle.fill") .resizable() - .symbolRenderingMode(.palette) - .foregroundStyle(.white, .fileComplete) + .symbolRenderingMode(.monochrome) + .foregroundStyle(.white) .aspectRatio(contentMode: .fit) .frame(width: 16, height: 16) } else if case .failed = model.fileSearchStatus { Image(systemName: "exclamationmark.triangle.fill") .resizable() - .symbolRenderingMode(.multicolor) + .symbolRenderingMode(.monochrome) + .foregroundStyle(.white) .aspectRatio(contentMode: .fit) .frame(width: 16, height: 16) } Text(message) + .lineLimit(1) .font(.body) .foregroundStyle(.white) Spacer() + + if let pathMessage = searchStatusPath { + Text(pathMessage) + .lineLimit(1) + .truncationMode(.tail) + .font(.footnote) +// .fontWeight(.semibold) + .foregroundStyle(.white) + .opacity(0.5) + .padding(.top, 2) + } } -// .overlay(alignment: .leading) { -// Button { -// model.cancelFileSearch(clearResults: true) -// } label: { -// Image(systemName: "xmark.circle.fill") -// .resizable() -// .scaledToFit() -// .foregroundStyle(.white) -// .opacity(0.8) -// } -// .buttonStyle(.plain) -// } - .padding(.horizontal, 8) - .padding(.leading, 4) + .padding(.trailing, 14) + .padding(.leading, 8) .padding(.vertical, 8) .background { - Color(nsColor: .controlAccentColor) - .clipShape(.capsule(style: .continuous)) + Group { + if case .completed = model.fileSearchStatus { + Color.fileComplete + } + else { + Color(nsColor: .controlAccentColor) + } + } + .clipShape(.capsule(style: .continuous)) } .padding(.horizontal, 8) + .padding(.top, 8) } } } diff --git a/Hotline/macOS/TrackerView.swift b/Hotline/macOS/TrackerView.swift index 83a56b8..c14f982 100644 --- a/Hotline/macOS/TrackerView.swift +++ b/Hotline/macOS/TrackerView.swift @@ -416,8 +416,9 @@ struct TrackerView: View { // Not expanded, expand it (which also fetches) self.setExpanded(true, for: bookmark) } + return } - return + break default: break } -- cgit From 5b4d615792a951979ff79506657e8407d2a48016 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Thu, 23 Oct 2025 11:24:06 -0700 Subject: Improve file search to move down the tree once current depth has been searched with exception now for hot spots where we burst into folders that contain matches. --- Hotline.xcodeproj/project.pbxproj | 4 +- Hotline/Models/Hotline.swift | 101 ++++++++++++++++++++++++++++++++++---- Hotline/macOS/FilesView.swift | 7 ++- 3 files changed, 98 insertions(+), 14 deletions(-) diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index 54adc37..d35440f 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -656,7 +656,7 @@ CODE_SIGN_ENTITLEMENTS = Hotline/Hotline.entitlements; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 22; + CURRENT_PROJECT_VERSION = 23; DEVELOPMENT_ASSET_PATHS = "\"Hotline/Preview Content\""; DEVELOPMENT_TEAM = 5AEEV7QB2U; "ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = YES; @@ -696,7 +696,7 @@ CODE_SIGN_ENTITLEMENTS = Hotline/Hotline.entitlements; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 22; + CURRENT_PROJECT_VERSION = 23; DEVELOPMENT_ASSET_PATHS = "\"Hotline/Preview Content\""; DEVELOPMENT_TEAM = 5AEEV7QB2U; "ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = YES; diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift index 786fb0c..1e2c6c5 100644 --- a/Hotline/Models/Hotline.swift +++ b/Hotline/Models/Hotline.swift @@ -1,12 +1,20 @@ import SwiftUI struct FileSearchConfig: Equatable { + /// Number of folders we process before we start applying delay backoff. var initialBurstCount: Int = 15 + /// Base delay applied between folder requests during the backoff phase. var initialDelay: TimeInterval = 0.02 + /// Multiplier used to increase the delay after each processed folder in backoff. var backoffMultiplier: Double = 1.1 - var maxDelay: TimeInterval = 1.3 + /// Maximum delay cap so searches don't stall out during long walks. + var maxDelay: TimeInterval = 1.0 + /// Maximum recursion depth allowed during file search. var maxDepth: Int = 40 + /// Limit for repeated folder loops (guards against circular server listings). var loopRepetitionLimit: Int = 4 + /// Number of child folders that get prioritized after a matching parent is found. + var hotBurstLimit: Int = 2 } enum FileSearchStatus: Equatable { @@ -1300,6 +1308,7 @@ final class FileSearchSession { private struct FolderTask { let path: [String] let depth: Int + let isHot: Bool } private weak var hotline: Hotline? @@ -1332,18 +1341,20 @@ final class FileSearchSession { hotline.searchSession(self, didFocusOn: []) let rootFiles = await hotline.getFileList(path: [], suppressErrors: true) processedCount = max(processedCount, 1) - processListing(rootFiles, depth: 0) + processListing(rootFiles, depth: 0, parentPath: [], parentIsHot: false) } else { hotline.searchSession(self, didFocusOn: []) processedCount = max(processedCount, 1) - processListing(hotline.files, depth: 0) + processListing(hotline.files, depth: 0, parentPath: [], parentIsHot: false) } while !queue.isEmpty && !isCancelled { await Task.yield() - let task = queue.removeFirst() + guard let task = dequeueNextTask() else { + continue + } if shouldSkip(path: task.path, depth: task.depth) { hotline.searchSession(self, didEmit: [], processed: processedCount, pending: queue.count) continue @@ -1359,7 +1370,7 @@ final class FileSearchSession { break } - processListing(children, depth: task.depth) + processListing(children, depth: task.depth, parentPath: task.path, parentIsHot: task.isHot) await applyBackoff() } @@ -1371,27 +1382,61 @@ final class FileSearchSession { isCancelled = true } - private func processListing(_ items: [FileInfo], depth: Int) { + private func processListing(_ items: [FileInfo], depth: Int, parentPath: [String], parentIsHot: Bool) { guard let hotline else { return } var matches: [FileInfo] = [] + var folderEntries: [(file: FileInfo, isHot: Bool)] = [] + var hasFileMatch = false for file in items { - if nameMatchesQuery(file.name) { + let matchesName = nameMatchesQuery(file.name) + + if matchesName { matches.append(file) + if !file.isFolder { + hasFileMatch = true + } } if file.isFolder { - enqueueFolder(file, depth: depth + 1) + folderEntries.append((file, matchesName)) + } + } + + var remainingBurst = 0 + if config.hotBurstLimit > 0 && (parentIsHot || hasFileMatch) { + remainingBurst = config.hotBurstLimit + } + + if remainingBurst > 0 { + var candidateIndices: [Int] = [] + for index in folderEntries.indices where !folderEntries[index].isHot { + candidateIndices.append(index) + } + + if !candidateIndices.isEmpty { + candidateIndices.shuffle() + for index in candidateIndices { + folderEntries[index].isHot = true + remainingBurst -= 1 + if remainingBurst == 0 { + break + } + } } } + for entry in folderEntries { + enqueueFolder(entry.file, depth: depth + 1, markHot: entry.isHot) + } + hotline.searchSession(self, didEmit: matches, processed: processedCount, pending: queue.count) } - private func enqueueFolder(_ folder: FileInfo, depth: Int) { + private func enqueueFolder(_ folder: FileInfo, depth: Int, markHot: Bool) { guard !isCancelled else { return } guard depth <= config.maxDepth else { return } @@ -1403,7 +1448,43 @@ final class FileSearchSession { return } - queue.append(FolderTask(path: path, depth: depth)) + queue.append(FolderTask(path: path, depth: depth, isHot: markHot)) + } + + private func dequeueNextTask() -> FolderTask? { + guard !queue.isEmpty else { + return nil + } + + if queue.count == 1 { + return queue.removeFirst() + } + + let currentDepth = queue[0].depth + var lastSameDepthIndex = 0 + var hotIndices: [Int] = [] + + for index in 0.. Bool { diff --git a/Hotline/macOS/FilesView.swift b/Hotline/macOS/FilesView.swift index 4498dc9..5d06b02 100644 --- a/Hotline/macOS/FilesView.swift +++ b/Hotline/macOS/FilesView.swift @@ -263,7 +263,7 @@ struct FilesView: View { if path.isEmpty { return "/" } - return "/" + path.joined(separator: "/") + "/" + return path.joined(separator: "/") } private func openPreviewWindow(_ previewInfo: PreviewFileInfo) { @@ -464,7 +464,10 @@ struct FilesView: View { Label("Upload", systemImage: "arrow.up") } .help("Upload") - .disabled(model.access?.contains(.canUploadFiles) != true) + .disabled( + model.access?.contains(.canUploadFiles) != true || + (model.fileSearchStatus.isActive && !(selection?.isFolder ?? false)) + ) Button { if let selectedFile = selection { -- cgit From 2df678753e2e6fe309de6b546925540f89e3d680 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Thu, 23 Oct 2025 12:11:22 -0700 Subject: Add a basic file cache that gets searched over first. TTL on entries. Force clear cache but holding shift+enter to search. Don't cache upload/dropbox folders. Don't search in .app folders. --- Hotline/Models/FileInfo.swift | 26 +++++-- Hotline/Models/Hotline.swift | 162 +++++++++++++++++++++++++++++++++++++++--- Hotline/macOS/FilesView.swift | 8 +++ 3 files changed, 182 insertions(+), 14 deletions(-) diff --git a/Hotline/Models/FileInfo.swift b/Hotline/Models/FileInfo.swift index 2da7378..62ec831 100644 --- a/Hotline/Models/FileInfo.swift +++ b/Hotline/Models/FileInfo.swift @@ -15,18 +15,36 @@ import UniformTypeIdentifiers let isUnavailable: Bool var isDropboxFolder: Bool { - guard self.isFolder, - (self.name.range(of: "upload", options: [.caseInsensitive]) != nil) || (self.name.range(of: "drop box", options: [.caseInsensitive]) != nil) - else { + guard self.isFolder else { return false } - return true + + if self.name.range(of: "upload", options: [.caseInsensitive]) != nil { + return true + } + + if self.name.range(of: "dropbox", options: [.caseInsensitive]) != nil { + return true + } + + if self.name.range(of: "drop box", options: [.caseInsensitive]) != nil { + return true + } + + return false } var isAdminDropboxFolder: Bool { self.isDropboxFolder && (self.name.range(of: "admin", options: [.caseInsensitive]) != nil) } + var isAppBundle: Bool { + guard self.isFolder else { + return false + } + return self.name.lowercased().hasSuffix(".app") + } + var expanded: Bool = false var children: [FileInfo]? = nil diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift index 1e2c6c5..054a1bc 100644 --- a/Hotline/Models/Hotline.swift +++ b/Hotline/Models/Hotline.swift @@ -15,6 +15,10 @@ struct FileSearchConfig: Equatable { var loopRepetitionLimit: Int = 4 /// Number of child folders that get prioritized after a matching parent is found. var hotBurstLimit: Int = 2 + /// Maximum age, in seconds, that a cached folder listing is treated as fresh. + var cacheTTL: TimeInterval = 60 * 15 + /// Upper bound on the number of folder listings retained in the cache. + var maxCachedFolders: Int = 1024 * 3 } enum FileSearchStatus: Equatable { @@ -171,6 +175,11 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega var fileSearchCurrentPath: [String]? = nil @ObservationIgnored private var fileSearchSession: FileSearchSession? = nil @ObservationIgnored private var fileSearchResultKeys: Set = [] + private struct FileListCacheEntry { + let files: [FileInfo] + let timestamp: Date + } + @ObservationIgnored private var fileListCache: [String: FileListCacheEntry] = [:] var news: [NewsInfo] = [] private var newsLookup: [String:NewsInfo] = [:] var newsLoaded: Bool = false @@ -307,7 +316,11 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega self.client.sendPostMessageBoard(text: text) } - @MainActor func getFileList(path: [String] = [], suppressErrors: Bool = false) async -> [FileInfo] { + @MainActor func getFileList(path: [String] = [], suppressErrors: Bool = false, preferCache: Bool = false) async -> [FileInfo] { + if preferCache, let cached = cachedFileList(for: path, ttl: fileSearchConfig.cacheTTL, allowStale: false) { + return cached.items + } + return await withCheckedContinuation { [weak self] continuation in self?.client.sendGetFileList(path: path, suppressErrors: suppressErrors) { [weak self] files in let parentFile = self?.findFile(in: self?.files ?? [], at: path) @@ -326,6 +339,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega self?.files = newFiles } + self?.storeFileListInCache(newFiles, for: path) continuation.resume(returning: newFiles) } } @@ -430,6 +444,119 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega private func searchPathKey(for path: [String]) -> String { path.joined(separator: "\u{001F}") } + + private func shouldBypassFileCache(for path: [String]) -> Bool { + guard let folderName = path.last else { + return false + } + + let trimmed = folderName.trimmingCharacters(in: .whitespacesAndNewlines) + + if trimmed.range(of: "upload", options: [.caseInsensitive]) != nil { + return true + } + + if trimmed.range(of: "dropbox", options: [.caseInsensitive]) != nil { + return true + } + + if trimmed.range(of: "drop box", options: [.caseInsensitive]) != nil { + return true + } + + return false + } + + private func cachedFileList(for path: [String], ttl: TimeInterval, allowStale: Bool) -> (items: [FileInfo], isFresh: Bool)? { + guard ttl > 0 else { + return nil + } + + if shouldBypassFileCache(for: path) { + return nil + } + + let key = searchPathKey(for: path) + guard let entry = fileListCache[key] else { + return nil + } + + let age = Date().timeIntervalSince(entry.timestamp) + let isFresh = age <= ttl + if !allowStale && !isFresh { + return nil + } + + return (entry.files, isFresh) + } + + private func storeFileListInCache(_ files: [FileInfo], for path: [String]) { + guard fileSearchConfig.cacheTTL > 0 else { + return + } + + if shouldBypassFileCache(for: path) { + return + } + + let key = searchPathKey(for: path) + fileListCache[key] = FileListCacheEntry(files: files, timestamp: Date()) + pruneFileListCacheIfNeeded() + } + + private func pruneFileListCacheIfNeeded() { + let limit = fileSearchConfig.maxCachedFolders + guard limit > 0, fileListCache.count > limit else { + return + } + + let excess = fileListCache.count - limit + guard excess > 0 else { return } + + let sortedKeys = fileListCache.sorted { lhs, rhs in + lhs.value.timestamp < rhs.value.timestamp + } + + for index in 0.. (items: [FileInfo], isFresh: Bool)? { + cachedFileList(for: path, ttl: ttl, allowStale: true) + } + + @MainActor func clearFileListCache() { + guard !fileListCache.isEmpty else { + return + } + + fileListCache.removeAll(keepingCapacity: false) + } @MainActor func getNewsArticle(id articleID: UInt, at path: [String], flavor: String) async -> String? { return await withCheckedContinuation { [weak self] continuation in @@ -748,7 +875,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega @MainActor func uploadFile(url fileURL: URL, path: [String], complete callback: ((TransferInfo) -> Void)? = nil) { let fileName = fileURL.lastPathComponent - + guard fileURL.isFileURL, !fileName.isEmpty else { print("NOT A FILE URL?") return @@ -789,9 +916,11 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega print("FILE IS EMPTY??") return } - + print("FILE SIZE: \(fileSize) NAME: \(fileName) PATH: \(path)") - + + invalidateFileListCache(for: path, includingAncestors: true) + self.client.sendUploadFile(name: fileName, path: path) { [weak self] success, uploadReferenceNumber in print("UPLOAD REFERENCE: \(String(describing: uploadReferenceNumber))") @@ -809,11 +938,11 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega let transfer = TransferInfo(id: referenceNumber, title: fileName, size: fileSize) transfer.uploadCallback = callback self.transfers.append(transfer) - + fileClient.start() } } - + } @MainActor func getFileDetails(_ fileName: String, path: [String]) async -> FileDetails? { @@ -834,9 +963,12 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega if path.count > 1 { fullPath = Array(path[0.. Date: Fri, 24 Oct 2025 14:16:30 -0700 Subject: Further along work towards auto updating. Show release notes for the builds that are more recent than yours. --- Hotline.xcodeproj/project.pbxproj | 35 ++- .../xcshareddata/swiftpm/Package.resolved | 11 +- Hotline/MacApp.swift | 56 +++- Hotline/State/AppUpdate.swift | 341 +++++++++++++++++++++ Hotline/Utility/NSWindowBridge.swift | 21 ++ Hotline/macOS/AboutView.swift | 339 +++++++++----------- Hotline/macOS/AppUpdateView.swift | 187 +++++++++++ 7 files changed, 788 insertions(+), 202 deletions(-) create mode 100644 Hotline/State/AppUpdate.swift create mode 100644 Hotline/macOS/AppUpdateView.swift diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index d35440f..642a329 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -36,7 +36,7 @@ DA6300972B24036B0034CBFD /* HotlineClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6300962B24036B0034CBFD /* HotlineClient.swift */; }; DA65499A2BEC280E00EDB697 /* ServerMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6549992BEC280E00EDB697 /* ServerMessageView.swift */; }; DA65499C2BEC3FBD00EDB697 /* ServerAgreementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA65499B2BEC3FBD00EDB697 /* ServerAgreementView.swift */; }; - DA65499E2BEC438A00EDB697 /* NSWindowBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA65499D2BEC438A00EDB697 /* NSWindowBridge.swift */; }; + DA65499E2BEC438A00EDB697 /* NSWindowBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA65499D2BEC438A00EDB697 /* NSWindowBridge.swift */; platformFilters = (macos, ); }; DA6980792BF80525003E434B /* TextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6980782BF80525003E434B /* TextView.swift */; }; DA69807E2BFD449B003E434B /* MessageBoardEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA69807D2BFD449B003E434B /* MessageBoardEditorView.swift */; }; DA6980832BFFD06C003E434B /* BookmarkDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6980822BFFD06C003E434B /* BookmarkDocument.swift */; }; @@ -75,6 +75,9 @@ DAC002192B21630900A6C290 /* SwiftUIExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC002182B21630900A6C290 /* SwiftUIExtensions.swift */; }; DAC3D9832BC33FD000A727C9 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC3D9822BC33FD000A727C9 /* AppState.swift */; }; DAC87F072C5010E80060FADF /* HotlineExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC87F062C5010E80060FADF /* HotlineExtensions.swift */; }; + DACCE5E12EABE4B4008CDD92 /* AppUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DACCE5E02EABE4B4008CDD92 /* AppUpdate.swift */; platformFilters = (macos, ); }; + DACCE5E32EABE86A008CDD92 /* AppUpdateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */; platformFilters = (macos, ); }; + DACCE5EF2EAC19C9008CDD92 /* SwiftUIIntrospect in Frameworks */ = {isa = PBXBuildFile; productRef = DACCE5EE2EAC19C9008CDD92 /* SwiftUIIntrospect */; }; DADDB28B2B22B31F0024040D /* Tracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADDB28A2B22B31F0024040D /* Tracker.swift */; }; DADDB28D2B22B5920024040D /* Server.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADDB28C2B22B5920024040D /* Server.swift */; }; DADDB28F2B238D850024040D /* Hotline.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADDB28E2B238D850024040D /* Hotline.swift */; }; @@ -159,6 +162,8 @@ DAC002182B21630900A6C290 /* SwiftUIExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIExtensions.swift; sourceTree = ""; }; DAC3D9822BC33FD000A727C9 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; DAC87F062C5010E80060FADF /* HotlineExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineExtensions.swift; sourceTree = ""; }; + DACCE5E02EABE4B4008CDD92 /* AppUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdate.swift; sourceTree = ""; }; + DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdateView.swift; sourceTree = ""; }; DADDB28A2B22B31F0024040D /* Tracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tracker.swift; sourceTree = ""; }; DADDB28C2B22B5920024040D /* Server.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Server.swift; sourceTree = ""; }; DADDB28E2B238D850024040D /* Hotline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Hotline.swift; sourceTree = ""; }; @@ -178,6 +183,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + DACCE5EF2EAC19C9008CDD92 /* SwiftUIIntrospect in Frameworks */, DA72A0E02B4DA8CA00A0F48A /* SplitView in Frameworks */, DAB4D8872B4CB3610048A05C /* MarkdownUI in Frameworks */, ); @@ -191,6 +197,7 @@ children = ( DAC3D9822BC33FD000A727C9 /* AppState.swift */, DA2863D92B37BF6E00A7D050 /* Preferences.swift */, + DACCE5E02EABE4B4008CDD92 /* AppUpdate.swift */, ); path = State; sourceTree = ""; @@ -362,6 +369,7 @@ DA2863D72B37AD1C00A7D050 /* SettingsView.swift */, DAE136B92B9D1147007D8307 /* HotlinePanelView.swift */, DA55AC762BE589F700034857 /* AboutView.swift */, + DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */, DA65499B2BEC3FBD00EDB697 /* ServerAgreementView.swift */, DA6549992BEC280E00EDB697 /* ServerMessageView.swift */, 11F8288A2BF9428100216BA0 /* AccountManagerView.swift */, @@ -388,6 +396,7 @@ packageProductDependencies = ( DAB4D8862B4CB3610048A05C /* MarkdownUI */, DA72A0DF2B4DA8CA00A0F48A /* SplitView */, + DACCE5EE2EAC19C9008CDD92 /* SwiftUIIntrospect */, ); productName = Hotline; productReference = DA9CAFB72B126D5700CDA197 /* Hotline.app */; @@ -420,6 +429,7 @@ packageReferences = ( DAB4D8852B4CB3610048A05C /* XCRemoteSwiftPackageReference "swift-markdown-ui" */, DA72A0DE2B4DA8CA00A0F48A /* XCRemoteSwiftPackageReference "SplitView" */, + DACCE5ED2EAC19C9008CDD92 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */, ); productRefGroup = DA9CAFB82B126D5700CDA197 /* Products */; projectDirPath = ""; @@ -500,6 +510,7 @@ DA72A0DD2B4CD0BF00A0F48A /* NewsEditorView.swift in Sources */, DABFCC292B1530DC009F40D2 /* FoundationExtensions.swift in Sources */, DA57536C2B36BA1D00FAC277 /* TextDocument.swift in Sources */, + DACCE5E12EABE4B4008CDD92 /* AppUpdate.swift in Sources */, DA77253F2B21176D006C5ABB /* NewsView.swift in Sources */, DA99218E2C51AA5D0058FA6C /* HotlineDataBuilder.swift in Sources */, DA872B132BDDBF78008B1012 /* HotlinePanel.swift in Sources */, @@ -522,6 +533,7 @@ DA6980832BFFD06C003E434B /* BookmarkDocument.swift in Sources */, DAB4D87E2B4C8BCA0048A05C /* FilePreviewTextView.swift in Sources */, DA4F2C012B1A558E00D8ADDC /* ChatView.swift in Sources */, + DACCE5E32EABE86A008CDD92 /* AppUpdateView.swift in Sources */, DA32CD492B2931640053B98B /* User.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -656,7 +668,7 @@ CODE_SIGN_ENTITLEMENTS = Hotline/Hotline.entitlements; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 23; + CURRENT_PROJECT_VERSION = 24; DEVELOPMENT_ASSET_PATHS = "\"Hotline/Preview Content\""; DEVELOPMENT_TEAM = 5AEEV7QB2U; "ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = YES; @@ -675,7 +687,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 14.3; + MACOSX_DEPLOYMENT_TARGET = 15.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = co.goodmake.hotline; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -696,7 +708,7 @@ CODE_SIGN_ENTITLEMENTS = Hotline/Hotline.entitlements; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 23; + CURRENT_PROJECT_VERSION = 24; DEVELOPMENT_ASSET_PATHS = "\"Hotline/Preview Content\""; DEVELOPMENT_TEAM = 5AEEV7QB2U; "ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = YES; @@ -715,7 +727,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 14.3; + MACOSX_DEPLOYMENT_TARGET = 15.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = co.goodmake.hotline; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -768,6 +780,14 @@ minimumVersion = 2.3.0; }; }; + DACCE5ED2EAC19C9008CDD92 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/siteline/SwiftUI-Introspect"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 26.0.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -781,6 +801,11 @@ package = DAB4D8852B4CB3610048A05C /* XCRemoteSwiftPackageReference "swift-markdown-ui" */; productName = MarkdownUI; }; + DACCE5EE2EAC19C9008CDD92 /* SwiftUIIntrospect */ = { + isa = XCSwiftPackageProductDependency; + package = DACCE5ED2EAC19C9008CDD92 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */; + productName = SwiftUIIntrospect; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = DA9CAFAF2B126D5700CDA197 /* Project object */; diff --git a/Hotline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Hotline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 28a7003..f112b32 100644 --- a/Hotline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Hotline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "e60904df125ffbce75adea70452a77ae65a068371a6b6c4cfe0e6c155c4bdcee", + "originHash" : "26db0d070cfc8e002044ea1380ec5e675c0718ebf212f6e8a57f12cc6c295723", "pins" : [ { "identity" : "networkimage", @@ -36,6 +36,15 @@ "revision" : "5f613358148239d0292c0cef674a3c2314737f9e", "version" : "2.4.1" } + }, + { + "identity" : "swiftui-introspect", + "kind" : "remoteSourceControl", + "location" : "https://github.com/siteline/SwiftUI-Introspect", + "state" : { + "revision" : "a08b87f96b41055577721a6e397562b21ad52454", + "version" : "26.0.0" + } } ], "version" : 3 diff --git a/Hotline/MacApp.swift b/Hotline/MacApp.swift index a4ff3cb..a91df52 100644 --- a/Hotline/MacApp.swift +++ b/Hotline/MacApp.swift @@ -61,6 +61,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { // NotificationCenter.default.removeObserver(token) // } // } + + Task { + await AppUpdate.shared.checkForUpdatesOnLaunch() + } } func applicationWillTerminate(_ notification: Notification) { @@ -78,6 +82,7 @@ struct Application: App { @State private var hotlinePanel: HotlinePanel? = nil @State private var selection: TrackerSelection? = nil + @Bindable private var update = AppUpdate.shared @FocusedValue(\.activeHotlineModel) private var activeHotline: Hotline? @FocusedValue(\.activeServerState) private var activeServerState: ServerState? @@ -112,15 +117,43 @@ struct Application: App { } } } + .onChange(of: self.update.showWindow) { + if self.update.showWindow { + self.openWindow(id: "update") + } + } // MARK: About Box Window("About", id: "about") { AboutView() - .ignoresSafeArea() - .background(Color.hotlineRed) + .background(Color.hotlineRed, ignoresSafeAreaEdges: .all) + .windowFullScreenBehavior(.disabled) + .toolbar(removing: .title) + .gesture(WindowDragGesture()) + .background( + WindowConfigurator { window in + window.titlebarAppearsTransparent = true + window.titlebarSeparatorStyle = .none + window.isMovableByWindowBackground = true + + if let closeButton = window.standardWindowButton(.closeButton) { + closeButton.isHidden = false // make sure it’s visible + closeButton.isEnabled = true + } + + if let btn = window.standardWindowButton(.zoomButton) { + btn.isHidden = true + } + + if let btn = window.standardWindowButton(.miniaturizeButton) { + btn.isHidden = true + } + } + ) } .windowResizability(.contentSize) .windowStyle(.hiddenTitleBar) + .restorationBehavior(.disabled) .defaultPosition(.center) .commandsRemoved() // Remove About that was automatically added to Window menu. .commands { @@ -128,9 +161,26 @@ struct Application: App { Button("About Hotline") { openWindow(id: "about") } + + Button("Check for Updates...") { + Task { + await AppUpdate.shared.checkForUpdatesManually() + } + } } } + // MARK: Update Window + Window("New Update", id: "update") { + AppUpdateView() + .windowFullScreenBehavior(.disabled) + } + .windowResizability(.contentSize) + .windowStyle(.hiddenTitleBar) + .restorationBehavior(.disabled) + .defaultPosition(.center) + .commandsRemoved() + // MARK: Server Window WindowGroup(id: "server", for: Server.self) { server in ServerView(server: server) @@ -189,7 +239,7 @@ struct Application: App { } } Divider() - Button("Download Latest...") { + Button("Open Latest Release Page...") { if let url = URL(string: "https://github.com/mierau/hotline/releases/latest") { openURL(url) } diff --git a/Hotline/State/AppUpdate.swift b/Hotline/State/AppUpdate.swift new file mode 100644 index 0000000..601859e --- /dev/null +++ b/Hotline/State/AppUpdate.swift @@ -0,0 +1,341 @@ +import Foundation +import Observation +import AppKit + +struct UpdateReleaseInfo: Equatable { + let tagName: String + let displayVersion: String + let versionNumber: Double + let buildNumber: Int + let notes: String + let downloadURL: URL + let assetName: String +} + +struct AppUpdateMessage: Equatable { + enum Kind { + case info + case success + case error + } + + let title: String + let detail: String + let kind: Kind +} + +@Observable +final class AppUpdate { + static let shared = AppUpdate() + + private init() {} + + private enum CheckTrigger { + case automatic + case manual + } + + // MARK: - Public State + + var isChecking = false + var isDownloading = false + var showWindow = false + var release: UpdateReleaseInfo? + var releases: [UpdateReleaseInfo] = [] + var message: AppUpdateMessage? + var userInitiated = false + var releaseNotesCombined: String? + + // MARK: - Configuration + + private let releasesURL = URL(string: "https://api.github.com/repos/mierau/hotline/releases?per_page=100")! + private let remindInterval: TimeInterval = 60 * 60 * 24 * 14 + + private let defaults = UserDefaults.standard + private let remindDateKey = "update.remind.date" + private let lastPromptedVersionKey = "update.last.prompt.version" + + // MARK: - Public API + + func checkForUpdatesOnLaunch() async { + await checkForUpdates(trigger: .automatic) + } + + func checkForUpdatesManually() async { + await checkForUpdates(trigger: .manual) + } + + @MainActor + func startDownload() { + guard let release, isDownloading == false else { return } + + isDownloading = true + message = nil + + Task(priority: .userInitiated) { + await self.downloadRelease(release) + } + } + + @MainActor + func remindLater() { + guard let release else { + resetAndCloseWindow() + return + } + + recordPrompt(for: release, remindLater: true) + resetAndCloseWindow() + } + + @MainActor + func acknowledgeMessage() { + resetAndCloseWindow() + } + + @MainActor + func handleWindowDismissed() { + guard showWindow else { return } + + resetAndCloseWindow() + } + + // MARK: - Internal Logic + + private func checkForUpdates(trigger: CheckTrigger) async { + await MainActor.run { + self.isChecking = true + self.userInitiated = (trigger == .manual) + self.message = nil + self.releases = [] + self.release = nil + self.releaseNotesCombined = nil + self.isDownloading = false + if trigger == .manual { + self.showWindow = false + } + } + + do { + let newerReleases = try await fetchNewerReleases() + let latestRelease = newerReleases.first + let shouldShow: Bool + switch trigger { + case .manual: + shouldShow = latestRelease != nil + case .automatic: + if let latestRelease { + shouldShow = shouldPrompt(for: latestRelease) + } else { + shouldShow = false + } + } + + await MainActor.run { + self.isChecking = false + if shouldShow, let latestRelease { + self.release = latestRelease + self.releases = newerReleases + self.releaseNotesCombined = combinedReleaseNotes(from: newerReleases) + self.isDownloading = false + self.message = nil + self.showWindow = true + } else { + self.release = nil + self.releases = [] + self.releaseNotesCombined = nil + self.isDownloading = false + if trigger == .manual { + self.message = AppUpdateMessage( + title: "Hotline is up to date", + detail: "You're running the latest and greatest.", + kind: .success + ) + self.showWindow = true + } else { + self.message = nil + self.showWindow = false + } + } + } + } catch { + await MainActor.run { + self.isChecking = false + self.release = nil + self.releases = [] + self.releaseNotesCombined = nil + self.isDownloading = false + if trigger == .manual { + self.message = AppUpdateMessage( + title: "Unable to Check for Updates", + detail: error.localizedDescription, + kind: .error + ) + self.showWindow = true + } + } + } + } + + private func fetchNewerReleases() async throws -> [UpdateReleaseInfo] { + let (data, _) = try await URLSession.shared.data(from: releasesURL) + guard let jsonArray = try JSONSerialization.jsonObject(with: data, options: []) as? [[String: Any]] else { + throw NSError(domain: "AppUpdate", code: -10, userInfo: [NSLocalizedDescriptionKey: "Malformed GitHub releases response."]) + } + + let parsed = jsonArray.compactMap(parseRelease) + let newer = parsed.filter(isReleaseNewer) + return newer + } + + private func parseRelease(_ json: [String: Any]) -> UpdateReleaseInfo? { + guard let tagName = json["tag_name"] as? String else { + return nil + } + + let notes = (json["body"] as? String) ?? "" + guard + let assets = json["assets"] as? [[String: Any]], + let asset = assets.first, + let downloadString = asset["browser_download_url"] as? String, + let downloadURL = URL(string: downloadString) + else { + return nil + } + + let assetName = (asset["name"] as? String) ?? downloadURL.lastPathComponent + + let versionPattern = #"^([0-9\.]+)beta([0-9]+)"# + guard let regex = try? NSRegularExpression(pattern: versionPattern, options: []) else { + return nil + } + + let range = NSRange(location: 0, length: tagName.utf16.count) + guard let match = regex.firstMatch(in: tagName, options: [], range: range), + match.numberOfRanges >= 3, + let versionRange = Range(match.range(at: 1), in: tagName), + let buildRange = Range(match.range(at: 2), in: tagName), + let versionNumber = Double(String(tagName[versionRange])), + let buildNumber = Int(String(tagName[buildRange])) + else { + return nil + } + + let versionComponent = String(tagName[versionRange]) + let buildComponent = String(tagName[buildRange]) + let displayVersion = "\(versionComponent)b\(buildComponent)" + + return UpdateReleaseInfo( + tagName: tagName, + displayVersion: displayVersion, + versionNumber: versionNumber, + buildNumber: buildNumber, + notes: notes, + downloadURL: downloadURL, + assetName: assetName + ) + } + + private func combinedReleaseNotes(from releases: [UpdateReleaseInfo]) -> String? { + guard let firstRelease = releases.first else { return nil } + + let firstNotes = firstRelease.notes.trimmingCharacters(in: .whitespacesAndNewlines) + let firstBody = firstNotes.isEmpty ? "_No release notes provided._" : firstNotes + + guard releases.count > 1 else { + return firstBody + } + + let olderSections = releases.dropFirst().map { release -> String in + let trimmed = release.notes.trimmingCharacters(in: .whitespacesAndNewlines) + let body = trimmed.isEmpty ? "_No release notes provided._" : trimmed + return "## Hotline \(release.displayVersion)\n\n\(body)" + } + + let olderCombined = olderSections.joined(separator: "\n\n---\n\n") + return "\(firstBody)\n\n---\n\n\(olderCombined)" + } + + private func downloadRelease(_ release: UpdateReleaseInfo) async { + do { + let (temporaryURL, _) = try await URLSession.shared.download(from: release.downloadURL) + let downloadsDirectory = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first! + let destinationURL = downloadsDirectory.appendingPathComponent(release.assetName) + + if FileManager.default.fileExists(atPath: destinationURL.path) { + try? FileManager.default.removeItem(at: destinationURL) + } + + try FileManager.default.moveItem(at: temporaryURL, to: destinationURL) + + await MainActor.run { + self.isDownloading = false + NSWorkspace.shared.activateFileViewerSelecting([destinationURL]) + self.resetAndCloseWindow() + } + } catch { + await MainActor.run { + self.isDownloading = false + self.message = AppUpdateMessage( + title: "Download Failed", + detail: error.localizedDescription, + kind: .error + ) + self.showWindow = true + } + } + } + + private func currentApplicationVersion() -> (version: Double, build: Int) { + let info = Bundle.main.infoDictionary ?? [:] + let versionString = info["CFBundleShortVersionString"] as? String ?? "0" + let buildString = info["CFBundleVersion"] as? String ?? "0" + let version = Double(versionString) ?? 0 + let build = Int(buildString) ?? 0 + return (version, build) + } + + private func isReleaseNewer(_ release: UpdateReleaseInfo) -> Bool { + let current = currentApplicationVersion() + if release.versionNumber > current.version { + return true + } + if release.versionNumber == current.version { + return release.buildNumber > current.build + } + return false + } + + private func shouldPrompt(for release: UpdateReleaseInfo) -> Bool { + if defaults.string(forKey: lastPromptedVersionKey) != release.tagName { + return true + } + guard let remindDate = defaults.object(forKey: remindDateKey) as? Date else { + return true + } + return remindDate <= Date() + } + + @MainActor + private func recordPrompt(for release: UpdateReleaseInfo, remindLater: Bool) { + defaults.set(release.tagName, forKey: lastPromptedVersionKey) + if remindLater { + let nextReminder = Date().addingTimeInterval(remindInterval) + defaults.set(nextReminder, forKey: remindDateKey) + } else { + defaults.removeObject(forKey: remindDateKey) + } + } + + @MainActor + private func resetAndCloseWindow() { + isChecking = false + isDownloading = false + release = nil + releases = [] + releaseNotesCombined = nil + message = nil + userInitiated = false + showWindow = false + } +} diff --git a/Hotline/Utility/NSWindowBridge.swift b/Hotline/Utility/NSWindowBridge.swift index 5cd7197..8f766d9 100644 --- a/Hotline/Utility/NSWindowBridge.swift +++ b/Hotline/Utility/NSWindowBridge.swift @@ -23,3 +23,24 @@ public struct NSWindowAccessor: NSViewRepresentable { public func makeNSView(context: Context) -> NSView { return NSWindowAccessorView( configCode ) } public func updateNSView(_ nsView: NSView, context: Context) {} } + + +//import SwiftUI + + /// A helper view you can embed once per window to run a closure +/// with the underlying NSWindow reference. +struct WindowConfigurator: NSViewRepresentable { + let configure: (NSWindow) -> Void + + func makeNSView(context: Context) -> NSView { + let view = NSView() + DispatchQueue.main.async { + if let window = view.window { + configure(window) + } + } + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { } +} diff --git a/Hotline/macOS/AboutView.swift b/Hotline/macOS/AboutView.swift index baafcf8..57f855e 100644 --- a/Hotline/macOS/AboutView.swift +++ b/Hotline/macOS/AboutView.swift @@ -1,11 +1,5 @@ import SwiftUI - -enum VersionCheckState { - case needToCheck - case checking - case upToDate - case updateAvailable(version: String) -} +import SwiftUIIntrospect struct AboutContributor: Identifiable { let id: UUID = UUID() @@ -14,164 +8,66 @@ struct AboutContributor: Identifiable { let pictureURL: URL? } -struct AboutView: View { +struct AboutContributorView: View { @Environment(\.openURL) private var openURL - @State private var versionCheck: VersionCheckState = .needToCheck - @State private var downloadURL: String = "https://github.com/mierau/hotline/releases/latest" - @State private var contributors: [AboutContributor] = [] + let contributor: AboutContributor var body: some View { - HStack(alignment: .center, spacing: 0) { - VStack(alignment: .center, spacing: 0) { - Spacer() - - Image("About Hotline") - .padding(.top, 44) - - Text("Hotline") - .font(.system(size: 28)) - .fontWeight(.bold) - .padding(.top, 12) - .kerning(-1.0) - .foregroundColor(.white) - - let appDetails = getAppVersionAndBuild() - Text("Version \(String(format: "%.1f", appDetails.version))b\(appDetails.build)") - .foregroundColor(.white) - .opacity(0.4) - - HStack(alignment: .center) { - switch versionCheck { - case .needToCheck: - Button("Check for Updates") { - Task { - await checkForUpdate() - } - } - .controlSize(.small) - case .checking: - HStack(spacing: 8) { - ProgressView() - .controlSize(.small) - Text("Checking for updates...") - .fontWeight(.semibold) - } - .foregroundStyle(.white) - .tint(.white) - case .upToDate: - Label("Hotline is up to date.", systemImage: "checkmark.circle.fill") - .foregroundStyle(.white) - .fontWeight(.semibold) - .tint(.white) - .onTapGesture { - versionCheck = .needToCheck - } - case .updateAvailable(let version): - Button("Download Latest \(version)") { - if let url = URL(string: downloadURL) { - openURL(url) - } - } - .controlSize(.small) + HStack { + if let pictureURL = contributor.pictureURL { + AsyncImage(url: pictureURL) { phase in + if let image = phase.image { + image + .interpolation(.high) + .resizable() + .scaledToFit() + .background(.white) + .frame(width: 32, height: 32) + } else if phase.error != nil { + Color.clear + .frame(width: 32, height: 32) + } else { + Color.white + .opacity(0.2) + .frame(width: 32, height: 32) } } - .frame(height: 40) - - Spacer() + .frame(width: 32, height: 32) + .clipShape(Circle()) } - .frame(width: 250) - Spacer() - - ScrollView(.vertical) { - VStack(alignment: .leading, spacing: 16) { - - VStack(alignment: .leading, spacing: 4) { - Link(destination: URL(string: "https://github.com/mierau/hotline")!) { - HStack(alignment: .center, spacing: 4) { - Text("Contributors") - .lineLimit(1) - .font(.system(size: 16)) - .fontWeight(.semibold) - .foregroundStyle(.black) - .opacity(0.75) + VStack(alignment: .leading, spacing: 2) { + Text(contributor.username) + .fontWeight(.semibold) + .foregroundStyle(.white) + .lineLimit(1) + + Text(contributor.webURL.absoluteString) + .lineLimit(1) + .truncationMode(.middle) + .font(.system(size: 11)) + .foregroundStyle(.white.opacity(0.4)) + } + } +// } +// .accessibilityAddTraits(.isLink) +// .pointerStyle(.link) + } +} - Image(systemName: "arrow.forward.circle.fill") - .resizable() - .fontWeight(.bold) - .scaledToFit() - .frame(width: 12, height: 12) - .foregroundStyle(.black) - .opacity(0.75) - } - } - .padding(.top, 24) - - Text("Hotline is an open source project made possible by its contributors.") - .font(.system(size: 11)) - .foregroundStyle(.black) - .blendMode(.overlay) - .padding(.trailing, 32) - } - .padding(.bottom, 8) - - ForEach(contributors) { contributor in - Link(destination: contributor.webURL) { - HStack { - if let pictureURL = contributor.pictureURL { - AsyncImage(url: pictureURL) { phase in - if let image = phase.image { - image - .interpolation(.high) - .resizable() - .scaledToFit() - .background(.white) - .frame(width: 32, height: 32) - } else if phase.error != nil { - Color.clear - .frame(width: 32, height: 32) - } else { - Color.white - .opacity(0.2) - .frame(width: 32, height: 32) - } - } - .frame(width: 32, height: 32) - .clipShape(Circle()) - -// AsyncImage(url: pictureURL) { img in -// img -// .interpolation(.high) -// .resizable() -// .scaledToFit() -// .background(.white) -// } placeholder: { -// Color.white.opacity(0.2) -// .frame(width: 32, height: 32) -// } -// .frame(width: 32, height: 32) -// .clipShape(Circle()) - } - - VStack(alignment: .leading, spacing: 2) { - Text(contributor.username) - .fontWeight(.semibold) - .foregroundStyle(.white) - .lineLimit(1) - - Text(contributor.webURL.absoluteString) - .lineLimit(1) - .truncationMode(.middle) - .font(.system(size: 11)) - .foregroundStyle(.white.opacity(0.4)) - } - } - } - } +struct AboutView: View { + @Environment(\.openURL) private var openURL + + @State private var contributors: [AboutContributor] = [] + + var body: some View { + HStack(alignment: .center, spacing: 0) { + self.brandView + self.contributorsList + .background { + Color.black.blendMode(.softLight).opacity(0.3).ignoresSafeArea() } - } - .scrollClipDisabled() } .frame(width: 570, height: 330) .background( @@ -184,12 +80,105 @@ struct AboutView: View { .offset(x: 250) } ) - .background(Color.hotlineRed) .task { await loadContributors() } } + private var brandView: some View { + VStack(alignment: .center, spacing: 4) { + Spacer() + + Image("About Hotline") + + Text("Hotline") + .font(.system(size: 28)) + .fontWeight(.bold) + .padding(.top, 12) + .kerning(-1.0) + .foregroundColor(.white) + + let appDetails = getAppVersionAndBuild() + Button { + self.openURL(URL(string: "https://github.com/mierau/hotline/releases/tag/\(appDetails.version)beta\(appDetails.build)")!) + } label: { + + Text("Version \(String(format: "%.1f", appDetails.version))b\(appDetails.build)") + .foregroundColor(.white.opacity(0.756)) + .padding(.vertical, 4) + .padding(.horizontal, 12) + .background { + Capsule() + .fill(.white.opacity(0.5)) + .blendMode(.softLight) + } + } + .buttonStyle(.plain) + .buttonBorderShape(.capsule) + .padding(.bottom, 16) + + Spacer() + } + .frame(width: 250) + } + + private var contributorsList: some View { + ScrollView(.vertical) { + VStack(alignment: .leading, spacing: 16) { + self.contributorHeaderView + ForEach(self.contributors) { contributor in + Button { + self.openURL(contributor.webURL) + } label: { + AboutContributorView(contributor: contributor) + } + .buttonStyle(.plain) + .accessibilityAddTraits(.isLink) + .pointerStyle(.link) + } + } + .frame(maxWidth: .infinity) + .padding() + } + .scrollClipDisabled() + .scrollContentBackground(.hidden) +// .introspect(.scrollView, on: .macOS(.v10_15, .v11, .v12, .v13, .v14, .v15, .v26)) { v in +// v.automaticallyAdjustsContentInsets = false +// } + } + + private var contributorHeaderView: some View { + VStack(alignment: .leading, spacing: 4) { + Link(destination: URL(string: "https://github.com/mierau/hotline")!) { + HStack(alignment: .center, spacing: 4) { + Text("Contributors") + .lineLimit(1) + .font(.system(size: 16)) + .fontWeight(.semibold) + .foregroundStyle(.black) + .opacity(0.8) + + Image(systemName: "arrow.forward.circle.fill") + .resizable() + .fontWeight(.bold) + .scaledToFit() + .frame(width: 12, height: 12) + .foregroundStyle(.black) + .opacity(0.4) + } + } + .accessibilityAddTraits(.isLink) + .pointerStyle(.link) + + Text("Hotline is an open source project made possible by its contributors.") + .font(.system(size: 11)) + .foregroundStyle(.black) + .opacity(0.5) + .padding(.trailing, 32) + } + .padding(.bottom, 8) + } + func loadContributors() async { var newContributors: [AboutContributor] = [] @@ -214,43 +203,7 @@ struct AboutView: View { contributors = newContributors } } - - func checkForUpdate() async { - let appDetails = getAppVersionAndBuild() - - self.versionCheck = .checking - - do { - let url = URL(string: "https://api.github.com/repos/mierau/hotline/releases/latest")! - let (data, _) = try await URLSession.shared.data(from: url) - let versionExpression = /^([0-9\.]+)beta([0-9]+)/ - - if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], - let tagName = json["tag_name"] as? String, - let assets = json["assets"] as? [[String: Any]], - let firstAsset = assets.first, - let assetDownloadURL = firstAsset["browser_download_url"] as? String, - let versionMatches = try? versionExpression.wholeMatch(in: tagName) { - if let versionNumber = Double(versionMatches.1), - let buildNumber = Int(versionMatches.2), - versionNumber > appDetails.version || buildNumber > appDetails.build { - let versionString = "\(versionMatches.1)b\(versionMatches.2)" - self.versionCheck = .updateAvailable(version: versionString) - downloadURL = assetDownloadURL - } - else { - self.versionCheck = .upToDate - } - } - else { - self.versionCheck = .needToCheck - } - } - catch { - self.versionCheck = .needToCheck - } - } - + func getAppVersionAndBuild() -> (version: Double, build: Int) { let infoDictionary = Bundle.main.infoDictionary! let version = Double(infoDictionary["CFBundleShortVersionString"]! as! String)! diff --git a/Hotline/macOS/AppUpdateView.swift b/Hotline/macOS/AppUpdateView.swift new file mode 100644 index 0000000..89312fc --- /dev/null +++ b/Hotline/macOS/AppUpdateView.swift @@ -0,0 +1,187 @@ +import SwiftUI +import MarkdownUI +import AppKit +import Observation + +struct AppUpdateView: View { + @Environment(\.dismiss) private var dismiss + @Bindable private var update = AppUpdate.shared + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + if let message = update.message { + messageOnlyView(message) + } + else if update.release != nil { + headerSection + releaseNotesSection + actionRow + } + else { + defaultPlaceholder + } + } + .padding(.horizontal, 24) + .padding(.bottom, 24) + .padding(.top, 8) + .frame(width: update.message != nil ? 380 : 520) + .frame(idealHeight: 360) + .onChange(of: update.showWindow) { _, show in + if !show { + dismiss() + } + } + .onDisappear { + update.handleWindowDismissed() + } + } + + private var headerSection: some View { + HStack(alignment: .center, spacing: 8) { + Image(nsImage: NSApplication.shared.applicationIconImage) + .resizable() + .scaledToFit() + .frame(width: 56, height: 56) + .shadow(color: Color.black.mix(with: .red, by: 0.4).opacity(0.15), radius: 3, y: 1.5) + + VStack(alignment: .leading, spacing: 2) { + if let release = update.release { + Text("Hotline \(release.displayVersion)") + .font(.title2) + .fontWeight(.semibold) + } + Text("A new version of Hotline is available. 🎉") + .foregroundStyle(.secondary) + } + } + } + + private var releaseNotesSection: some View { + ScrollView(.vertical) { + Markdown(releaseNotesMarkdown()) + .textSelection(.enabled) + .markdownTheme(.gitHub.text(text: { + FontSize(.em(0.85)) + })) + .font(.system(size: 14)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 12) + .padding(.horizontal, 12) + } + .frame(minHeight: 220, maxHeight: 260) + .background( + RoundedRectangle(cornerRadius: 12) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + } + + private var actionRow: some View { + HStack { + Button("Not Now") { + update.remindLater() + } + .buttonBorderShape(.capsule) + .keyboardShortcut(.escape, modifiers: []) + .controlSize(.large) + .disabled(update.isDownloading) + + Spacer() + + if update.isDownloading { + ProgressView() + .controlSize(.small) + .padding(.trailing, 12) + } + + Button("Download") { + update.startDownload() + } + .buttonBorderShape(.capsule) + .keyboardShortcut(.defaultAction) + .controlSize(.large) + .disabled(update.isDownloading) + } + } + + @ViewBuilder + private func messageOnlyView(_ message: AppUpdateMessage) -> some View { + let iconName = { + switch message.kind { + case .info: + return "info.circle" + case .success: + return "checkmark.circle.fill" + case .error: + return "exclamationmark.triangle.fill" + } + }() + + HStack(alignment: .center, spacing: 12) { + + if message.kind == .success { + Text("👍") + .font(.system(size: 42)) + .shadow(color: .yellow.mix(with: .black, by: 0.3).opacity(0.2), radius: 4, y: 1.5) + } + else { + Image(systemName: iconName) + .resizable() + .scaledToFit() + .symbolRenderingMode(.multicolor) + .frame(width: 48, height: 48) + } + + VStack(alignment: .leading, spacing: 2) { + Text(message.title) + .font(.title2) + .fontWeight(.semibold) + Text(message.detail) + .foregroundStyle(.secondary) + } + + Spacer() + } + } + + private var defaultPlaceholder: some View { + VStack(alignment: .center, spacing: 12) { + Text("No update information available.") + .font(.headline) + Button("Close") { + update.acknowledgeMessage() + } + .keyboardShortcut(.defaultAction) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func releaseNotesMarkdown() -> String { + if let combined = update.releaseNotesCombined? + .trimmingCharacters(in: .whitespacesAndNewlines), + combined.isEmpty == false { + return combined + } + + let fallback = update.release?.notes.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return fallback.isEmpty ? "_No release notes provided._" : fallback + } +} + +#Preview { + AppUpdate.shared.release = UpdateReleaseInfo( + tagName: "1.0beta1", + displayVersion: "1.0b1", + versionNumber: 1.0, + buildNumber: 1, + notes: """ + - Added support for release notes in Markdown. + - Improved the update workflow for macOS users. + """, + downloadURL: URL(string: "https://example.com")!, + assetName: "Hotline.zip" + ) + AppUpdate.shared.releases = [AppUpdate.shared.release!] + AppUpdate.shared.releaseNotesCombined = nil + AppUpdate.shared.showWindow = true + return AppUpdateView() +} -- cgit From 1118b224e4a6561e55e757b8b826780be1444f07 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 24 Oct 2025 14:20:11 -0700 Subject: Add keyboard shortcut to Tracker window to activate search (cmd-f) --- Hotline/macOS/TrackerView.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Hotline/macOS/TrackerView.swift b/Hotline/macOS/TrackerView.swift index c14f982..5e848de 100644 --- a/Hotline/macOS/TrackerView.swift +++ b/Hotline/macOS/TrackerView.swift @@ -33,6 +33,7 @@ struct TrackerView: View { @State private var trackerServers: [Bookmark: [BookmarkServer]] = [:] @State private var loadingTrackers: Set = [] @State private var searchText: String = "" + @State private var isSearching = false @Query(sort: \Bookmark.order) private var bookmarks: [Bookmark] @Binding var selection: TrackerSelection? @@ -323,7 +324,8 @@ struct TrackerView: View { openWindow(id: "server", value: s) } }) - .searchable(text: $searchText, placement: .automatic, prompt: "Search") + .searchable(text: $searchText, isPresented: $isSearching, placement: .automatic, prompt: "Search") + .background(Button("", action: { isSearching = true }).keyboardShortcut("f").hidden()) } private var hotlineLogoImage: some View { -- cgit From 9491be2c0e5e4d52258b691ffe232be5bd83268b Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 24 Oct 2025 14:21:34 -0700 Subject: Add cmd-f to activate search in Files. --- Hotline/macOS/FilesView.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Hotline/macOS/FilesView.swift b/Hotline/macOS/FilesView.swift index f9fb36f..3447b28 100644 --- a/Hotline/macOS/FilesView.swift +++ b/Hotline/macOS/FilesView.swift @@ -215,6 +215,7 @@ struct FilesView: View { @State private var fileDetails: FileDetails? @State private var uploadFileSelectorDisplayed: Bool = false @State private var searchText: String = "" + @State private var isSearching: Bool = false private var isShowingSearchResults: Bool { switch model.fileSearchStatus { @@ -436,7 +437,8 @@ struct FilesView: View { .frame(maxWidth: .infinity) } } - .searchable(text: $searchText, placement: .automatic, prompt: "Search") + .searchable(text: $searchText, isPresented: $isSearching, placement: .automatic, prompt: "Search") + .background(Button("", action: { isSearching = true }).keyboardShortcut("f").hidden()) .toolbar { ToolbarItemGroup(placement: .automatic) { Button { -- cgit From e8f8c4b5caf10d126c590985ae4ea629b54c21e6 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 24 Oct 2025 17:20:57 -0700 Subject: Update README with project goals and screenshots Added project goals and macOS screenshots to README. --- README.md | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index cc7e619..addcc0b 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,17 @@ Perhaps that’s why you can still find Hotline servers and trackers running tod And this project is an attempt to create a modern open source version of Hotline for modern Apple systems. Join in. Contribute. Run your own server! Perhaps Hotline can live on for another 25 years. :) +# Goals +- Build a Hotline client for modern Apple systems. +- Keep the HotlineProtocol, HotlineClient, HotlineTrackerClient, and HotlineFileClient Swift code reusable so people can use it in other Swift Hotline projects. +- Bring a modern Hotline client to iOS, iPadOS, and macOS using one codebase. +- Ressurect the Hotline brand which has been expunged from trademark databases for over a decade. Look, I want Hotline with the classic big red H and all that on my modern Apple devices, okay? ;) +- Document the Hotline protocol. +- Have fun. :) + +# macOS Screenshots +![CleanShot 2024-05-14 at 13 45 55@2x](https://github.com/mierau/hotline/assets/55453/44e02def-d457-4f29-ac5e-30438a7794c3) + # Status | Feature | macOS | iOS | iPadOS | visionOS | @@ -55,18 +66,8 @@ And this project is an attempt to create a modern open source version of Hotline | File downloading | ✓ | | | | | File uploading | ✓ | | | | | File info | ✓ | | | | +| File search | ✓ | | | | | File management | | | | | | Folder downloading | ✓ | | | | | Folder uploading | | | | | | Custom icon sets | | | | | - -# Goals -- Build a Hotline client for modern Apple systems. -- Keep the HotlineProtocol, HotlineClient, HotlineTrackerClient, and HotlineFileClient Swift code reusable so people can use it in other Swift Hotline projects. -- Bring a modern Hotline client to iOS, iPadOS, and macOS using one codebase. -- Ressurect the Hotline brand that has been expunged from trademark databases for over a decade. Hey, I want Hotline proper with the classic big red H and all on my modern Apple devices, okay? ;) -- Document the Hotline protocol. -- Have fun. :) - -# macOS Screenshots -![CleanShot 2024-05-14 at 13 45 55@2x](https://github.com/mierau/hotline/assets/55453/44e02def-d457-4f29-ac5e-30438a7794c3) -- cgit From 9216a6d2d46f13689541389239ebcb58ab63cc84 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 24 Oct 2025 17:36:22 -0700 Subject: Update GitHub Banner image in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index addcc0b..09ad715 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -GitHub Banner +GitHub Banner # Hotline An attempt to resurrect Hotline for modern Apple systems (iOS, macOS, etc) by completely recreating it in Swift and SwiftUI. -- cgit From e09a3c8f7a6f374b329cc9d292c5c1232ce23fef Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 24 Oct 2025 17:42:04 -0700 Subject: Update GitHub Banner image in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 09ad715..ed1e8d4 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -GitHub Banner +GitHub Banner # Hotline An attempt to resurrect Hotline for modern Apple systems (iOS, macOS, etc) by completely recreating it in Swift and SwiftUI. -- cgit From 75ebba6ad7e45e636cd5bb3400d25d040cabad6c Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 24 Oct 2025 17:53:05 -0700 Subject: Update GitHub Banner image in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ed1e8d4..a08dbfb 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -GitHub Banner +GitHub Banner # Hotline An attempt to resurrect Hotline for modern Apple systems (iOS, macOS, etc) by completely recreating it in Swift and SwiftUI. -- cgit From cf113ea053334175b93770b025c1f7d22eda6eab Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 24 Oct 2025 22:47:44 -0700 Subject: A first pass at chat persistence. Also some chat UI cleanup. --- Hotline.xcodeproj/project.pbxproj | 12 ++ Hotline/Hotline/HotlineClient.swift | 4 + Hotline/Managers/ChatStore.swift | 246 ++++++++++++++++++++++++ Hotline/Models/ChatMessage.swift | 45 +++++ Hotline/Models/Hotline.swift | 132 +++++++++++-- Hotline/iOS/ChatView.swift | 16 ++ Hotline/macOS/ChatView.swift | 288 ++++++++++++++++++++--------- Hotline/macOS/FilesView.swift | 3 +- Hotline/macOS/MessageBoardEditorView.swift | 2 +- Hotline/macOS/ServerAgreementView.swift | 27 ++- Hotline/macOS/ServerMessageView.swift | 2 +- Hotline/macOS/ServerView.swift | 4 +- Hotline/macOS/SettingsView.swift | 19 ++ 13 files changed, 671 insertions(+), 129 deletions(-) create mode 100644 Hotline/Managers/ChatStore.swift diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index 642a329..a31f398 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -74,6 +74,7 @@ DABFCC292B1530DC009F40D2 /* FoundationExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABFCC282B1530DC009F40D2 /* FoundationExtensions.swift */; }; DAC002192B21630900A6C290 /* SwiftUIExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC002182B21630900A6C290 /* SwiftUIExtensions.swift */; }; DAC3D9832BC33FD000A727C9 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC3D9822BC33FD000A727C9 /* AppState.swift */; }; + DAC6B2E02EAC6236004E2CBA /* ChatStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC6B2DE2EAC6236004E2CBA /* ChatStore.swift */; }; DAC87F072C5010E80060FADF /* HotlineExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC87F062C5010E80060FADF /* HotlineExtensions.swift */; }; DACCE5E12EABE4B4008CDD92 /* AppUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DACCE5E02EABE4B4008CDD92 /* AppUpdate.swift */; platformFilters = (macos, ); }; DACCE5E32EABE86A008CDD92 /* AppUpdateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */; platformFilters = (macos, ); }; @@ -161,6 +162,7 @@ DABFCC282B1530DC009F40D2 /* FoundationExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoundationExtensions.swift; sourceTree = ""; }; DAC002182B21630900A6C290 /* SwiftUIExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIExtensions.swift; sourceTree = ""; }; DAC3D9822BC33FD000A727C9 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; + DAC6B2DE2EAC6236004E2CBA /* ChatStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatStore.swift; sourceTree = ""; }; DAC87F062C5010E80060FADF /* HotlineExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineExtensions.swift; sourceTree = ""; }; DACCE5E02EABE4B4008CDD92 /* AppUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdate.swift; sourceTree = ""; }; DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdateView.swift; sourceTree = ""; }; @@ -247,6 +249,7 @@ DA4B8F392EA6FB3C00CBFD53 /* iOSApp.swift */, DA4F2BF92B1A516F00D8ADDC /* iOS */, DAE734F72B2E4126000C56F6 /* macOS */, + DAC6B2DF2EAC6236004E2CBA /* Managers */, DA4B8F3B2EA6FB5F00CBFD53 /* State */, DADDB2892B22B2C60024040D /* Models */, DABFCC262B1530AE009F40D2 /* Hotline */, @@ -329,6 +332,14 @@ path = Utility; sourceTree = ""; }; + DAC6B2DF2EAC6236004E2CBA /* Managers */ = { + isa = PBXGroup; + children = ( + DAC6B2DE2EAC6236004E2CBA /* ChatStore.swift */, + ); + path = Managers; + sourceTree = ""; + }; DADDB2892B22B2C60024040D /* Models */ = { isa = PBXGroup; children = ( @@ -534,6 +545,7 @@ DAB4D87E2B4C8BCA0048A05C /* FilePreviewTextView.swift in Sources */, DA4F2C012B1A558E00D8ADDC /* ChatView.swift in Sources */, DACCE5E32EABE86A008CDD92 /* AppUpdateView.swift in Sources */, + DAC6B2E02EAC6236004E2CBA /* ChatStore.swift in Sources */, DA32CD492B2931640053B98B /* User.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index 3c6a643..0e54872 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -209,7 +209,11 @@ class HotlineClient: NetSocketDelegate { } @MainActor func disconnect() { + let wasConnected = self.connectionStatus != .disconnected self.reset() + if wasConnected { + self.updateConnectionStatus(.disconnected) + } } // MARK: - Packets diff --git a/Hotline/Managers/ChatStore.swift b/Hotline/Managers/ChatStore.swift new file mode 100644 index 0000000..daf8ca0 --- /dev/null +++ b/Hotline/Managers/ChatStore.swift @@ -0,0 +1,246 @@ +import Foundation +import CryptoKit +import Security + +actor ChatStore { + static let shared = ChatStore() + static let historyClearedNotification = Notification.Name("ChatStoreHistoryCleared") + + struct SessionKey: Hashable { + let address: String + let port: Int + + var identifier: String { "\(address):\(port)" } + } + + struct Metadata: Codable { + let address: String + let port: Int + var serverName: String? + var createdAt: Date + var updatedAt: Date + + mutating func update(serverName: String?, timestamp: Date) { + if let serverName, !serverName.isEmpty { + self.serverName = serverName + } + self.updatedAt = timestamp + } + } + + struct Entry: Codable { + let id: UUID + let body: String + let username: String? + let type: String + let date: Date + } + + struct LoadResult { + let entries: [Entry] + let metadata: Metadata? + } + + private struct LogFile: Codable { + var metadata: Metadata + var entries: [Entry] + } + + private enum StoreError: Error { + case encryptionFailed + case invalidCombinedCiphertext + case keyGenerationFailed + } + + private let keychainKey = "chatlog-encryption-key" + private let applicationFolderName = "Hotline" + private let logsFolderName = "ChatLogs" + private let fileExtension = "hlchat" + private let maxEntries = 2000 + + private var cache: [SessionKey: LogFile] = [:] + private var cachedDirectory: URL? + private var cachedKey: SymmetricKey? + + func append(entry: Entry, for key: SessionKey, serverName: String?) async { + do { + var logFile = try loadLogFile(for: key) ?? newLogFile(for: key, serverName: serverName) + + logFile.entries.append(entry) + if logFile.entries.count > maxEntries { + logFile.entries = Array(logFile.entries.suffix(maxEntries)) + } + + logFile.metadata.update(serverName: serverName, timestamp: entry.date) + cache[key] = logFile + + try persist(logFile, for: key) + } + catch { + print("ChatStore: failed to append entry —", error) + } + } + + func loadHistory(for key: SessionKey, limit: Int? = nil) async -> LoadResult { + do { + let logFile = try loadLogFile(for: key) + guard let logFile else { + return LoadResult(entries: [], metadata: nil) + } + + let entries: [Entry] + if let limit, limit < logFile.entries.count { + entries = Array(logFile.entries.suffix(limit)) + } + else { + entries = logFile.entries + } + + return LoadResult(entries: entries, metadata: logFile.metadata) + } + catch { + print("ChatStore: failed to load history —", error) + return LoadResult(entries: [], metadata: nil) + } + } + + func clearAll() async { + let fm = FileManager.default + if let dir = try? directoryURL(), fm.fileExists(atPath: dir.path) { + do { + try fm.removeItem(at: dir) + } + catch { + print("ChatStore: failed to clear chat logs —", error) + } + } + + cache.removeAll() + cachedDirectory = nil + + await MainActor.run { + NotificationCenter.default.post(name: Self.historyClearedNotification, object: nil) + } + } + + static func digest(for string: String) -> String { + let hash = SHA256.hash(data: Data(string.utf8)) + return hash.compactMap { String(format: "%02x", $0) }.joined() + } + + private func newLogFile(for key: SessionKey, serverName: String?) -> LogFile { + let now = Date() + var metadata = Metadata(address: key.address, port: key.port, serverName: nil, createdAt: now, updatedAt: now) + metadata.update(serverName: serverName, timestamp: now) + let logFile = LogFile(metadata: metadata, entries: []) + cache[key] = logFile + return logFile + } + + private func loadLogFile(for key: SessionKey) throws -> LogFile? { + if let cached = cache[key] { + return cached + } + + let url = try fileURL(for: key) + let fm = FileManager.default + guard fm.fileExists(atPath: url.path) else { + return nil + } + + let encryptedData = try Data(contentsOf: url) + let decryptedData = try decrypt(encryptedData) + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let logFile = try decoder.decode(LogFile.self, from: decryptedData) + cache[key] = logFile + return logFile + } + + private func persist(_ logFile: LogFile, for key: SessionKey) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(logFile) + let encrypted = try encrypt(data) + let url = try fileURL(for: key) + let fm = FileManager.default + let directory = url.deletingLastPathComponent() + if !fm.fileExists(atPath: directory.path) { + try fm.createDirectory(at: directory, withIntermediateDirectories: true) + } + try encrypted.write(to: url, options: .atomic) + } + + private func directoryURL() throws -> URL { + if let cachedDirectory { + return cachedDirectory + } + + let fm = FileManager.default + guard let base = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { + throw StoreError.keyGenerationFailed + } + + let appDirectory = base.appendingPathComponent(applicationFolderName, isDirectory: true) + let logsDirectory = appDirectory.appendingPathComponent(logsFolderName, isDirectory: true) + + if !fm.fileExists(atPath: appDirectory.path) { + try fm.createDirectory(at: appDirectory, withIntermediateDirectories: true) + } + if !fm.fileExists(atPath: logsDirectory.path) { + try fm.createDirectory(at: logsDirectory, withIntermediateDirectories: true) + } + + cachedDirectory = logsDirectory + return logsDirectory + } + + private func fileURL(for key: SessionKey) throws -> URL { + let directory = try directoryURL() + let digest = Self.digest(for: key.identifier) + return directory.appendingPathComponent(digest).appendingPathExtension(fileExtension) + } + + private func encrypt(_ data: Data) throws -> Data { + let key = try symmetricKey() + let sealedBox = try AES.GCM.seal(data, using: key) + guard let combined = sealedBox.combined else { + throw StoreError.encryptionFailed + } + return combined + } + + private func decrypt(_ data: Data) throws -> Data { + let key = try symmetricKey() + let sealedBox = try AES.GCM.SealedBox(combined: data) + return try AES.GCM.open(sealedBox, using: key) + } + + private func symmetricKey() throws -> SymmetricKey { + if let cachedKey { + return cachedKey + } + + if let stored = DAKeychain.shared[keychainKey], + let storedData = Data(base64Encoded: stored), + storedData.count == 32 { + let key = SymmetricKey(data: storedData) + cachedKey = key + return key + } + + var bytes = [UInt8](repeating: 0, count: 32) + let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + guard status == errSecSuccess else { + throw StoreError.keyGenerationFailed + } + + let data = Data(bytes) + let key = SymmetricKey(data: data) + DAKeychain.shared[keychainKey] = data.base64EncodedString() + cachedKey = key + return key + } +} diff --git a/Hotline/Models/ChatMessage.swift b/Hotline/Models/ChatMessage.swift index e585cdf..365f91f 100644 --- a/Hotline/Models/ChatMessage.swift +++ b/Hotline/Models/ChatMessage.swift @@ -3,8 +3,53 @@ import SwiftUI enum ChatMessageType { case agreement case status + case joined + case left case message case server + case signOut +} + +extension ChatMessageType { + var storageKey: String { + switch self { + case .agreement: + return "agreement" + case .status: + return "status" + case .joined: + return "joined" + case .left: + return "left" + case .message: + return "message" + case .server: + return "server" + case .signOut: + return "signOut" + } + } + + init?(storageKey: String) { + switch storageKey { + case "agreement": + self = .agreement + case "status": + self = .status + case "joined": + self = .joined + case "left": + self = .left + case "message": + self = .message + case "server": + self = .server + case "signOut": + self = .signOut + default: + return nil + } + } } struct ChatMessage: Identifiable { diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift index 054a1bc..8cdeccd 100644 --- a/Hotline/Models/Hotline.swift +++ b/Hotline/Models/Hotline.swift @@ -191,8 +191,11 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega var unreadPublicChat: Bool = false var errorDisplayed: Bool = false var errorMessage: String? = nil - + @ObservationIgnored var bannerClient: HotlineFilePreviewClient? + @ObservationIgnored private var chatSessionKey: ChatStore.SessionKey? + @ObservationIgnored private var restoredChatSessionKey: ChatStore.SessionKey? + @ObservationIgnored private var chatHistoryObserver: NSObjectProtocol? #if os(macOS) var bannerImage: NSImage? = nil #elseif os(iOS) @@ -206,6 +209,10 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega self.trackerClient = trackerClient self.client = client self.client.delegate = self + + self.chatHistoryObserver = NotificationCenter.default.addObserver(forName: ChatStore.historyClearedNotification, object: nil, queue: .main) { [weak self] _ in + self?.handleChatHistoryCleared() + } } // MARK: - @@ -233,7 +240,13 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega self.serverName = server.name self.username = username self.iconID = iconID - + + let key = sessionKey(for: server) + self.chatSessionKey = key + self.restoredChatSessionKey = nil + self.chat = [] + self.restoreChatHistory(for: key) + self.client.login(address: server.address, port: server.port, login: server.login, password: server.password, username: username, iconID: UInt16(iconID)) { [weak self] err, serverName, serverVersion in self?.serverVersion = serverVersion ?? 123 if serverName != nil { @@ -258,10 +271,16 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega @MainActor func disconnect() { self.client.disconnect() self.bannerClient?.cancel() - self.fileSearchSession?.cancel() + self.fileSearchSession?.cancel() self.fileSearchSession = nil self.resetFileSearchState() } + + deinit { + if let observer = chatHistoryObserver { + NotificationCenter.default.removeObserver(observer) + } + } @MainActor func sendAgree() { self.client.sendAgree(username: self.username, iconID: UInt16(self.iconID), options: .none) @@ -1077,12 +1096,19 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega @MainActor func hotlineStatusChanged(status: HotlineClientStatus) { print("Hotline: Connection status changed to: \(status)") - + let previousStatus = self.status + let previousTitle = self.serverTitle + if status == .disconnected { + if previousStatus == .loggedIn { + let message = ChatMessage(text: "Disconnected", type: .signOut, date: Date()) + self.recordChatMessage(message, persist: true, display: false) + } + self.serverVersion = 123 self.serverName = nil self.access = nil - + self.fileSearchSession?.cancel() self.fileSearchSession = nil self.resetFileSearchState() @@ -1095,30 +1121,34 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega self.filesLoaded = false self.news = [] self.newsLoaded = false - + self.bannerImage = nil if let b = self.bannerClient { b.cancel() self.bannerClient = nil } - + self.deleteAllTransfers() + + self.chatSessionKey = nil + self.restoredChatSessionKey = nil } else if status == .loggedIn { if Prefs.shared.playSounds && Prefs.shared.playLoggedInSound { SoundEffectPlayer.shared.playSoundEffect(.loggedIn) } } - + self.status = status } func hotlineGetUserInfo() -> (String, UInt16) { return (self.username, UInt16(self.iconID)) } - + func hotlineReceivedAgreement(text: String) { - self.chat.append(ChatMessage(text: text, type: .agreement, date: Date())) + let message = ChatMessage(text: text, type: .agreement, date: Date()) + self.recordChatMessage(message, persist: false) } func hotlineReceivedNewsPost(message: String) { @@ -1139,9 +1169,10 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega if Prefs.shared.playChatSound && Prefs.shared.playChatSound { SoundEffectPlayer.shared.playSoundEffect(.serverMessage) } - + print("Hotline: received server message:\n\(message)") - self.chat.append(ChatMessage(text: message, type: .server, date: Date())) + let chatMessage = ChatMessage(text: message, type: .server, date: Date()) + self.recordChatMessage(chatMessage) } func hotlineReceivedPrivateMessage(userID: UInt16, message: String) { @@ -1173,7 +1204,8 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega if Prefs.shared.playSounds && Prefs.shared.playChatSound { SoundEffectPlayer.shared.playSoundEffect(.chatMessage) } - self.chat.append(ChatMessage(text: message, type: .message, date: Date())) + let chatMessage = ChatMessage(text: message, type: .message, date: Date()) + self.recordChatMessage(chatMessage) self.unreadPublicChat = true } @@ -1208,11 +1240,12 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega func hotlineUserDisconnected(userID: UInt16) { if let existingUserIndex = self.users.firstIndex(where: { $0.id == UInt(userID) }) { let user = self.users.remove(at: existingUserIndex) - + if Prefs.shared.showJoinLeaveMessages { - self.chat.append(ChatMessage(text: "\(user.name) left", type: .status, date: Date())) + let chatMessage = ChatMessage(text: "\(user.name) left", type: .left, date: Date()) + self.recordChatMessage(chatMessage) } - + if Prefs.shared.playSounds && Prefs.shared.playLeaveSound { SoundEffectPlayer.shared.playSoundEffect(.userLogout) } @@ -1356,11 +1389,71 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega } // MARK: - Utilities - + + private func sessionKey(for server: Server) -> ChatStore.SessionKey { + ChatStore.SessionKey(address: server.address.lowercased(), port: server.port) + } + + private func recordChatMessage(_ message: ChatMessage, persist: Bool = true, display: Bool = true) { + if display { + self.chat.append(message) + } + + let shouldPersist = persist && message.type != .agreement + guard shouldPersist, let key = chatSessionKey else { return } + let entry = ChatStore.Entry( + id: message.id, + body: message.text, + username: message.username, + type: message.type.storageKey, + date: message.date + ) + let serverName = self.serverName ?? self.server?.name + + Task { + await ChatStore.shared.append(entry: entry, for: key, serverName: serverName) + } + } + + private func restoreChatHistory(for key: ChatStore.SessionKey) { + if restoredChatSessionKey == key { + return + } + + Task { [weak self] in + guard let self else { return } + let result = await ChatStore.shared.loadHistory(for: key) + await MainActor.run { + guard self.chatSessionKey == key, self.restoredChatSessionKey != key else { return } + let currentMessages = self.chat + let historyMessages = result.entries.compactMap { entry -> ChatMessage? in + guard let chatType = ChatMessageType(storageKey: entry.type) else { return nil } + let renderedText: String + if chatType == .message, let username = entry.username, !username.isEmpty { + renderedText = "\(username): \(entry.body)" + } + else { + renderedText = entry.body + } + return ChatMessage(text: renderedText, type: chatType, date: entry.date) + } + self.chat = historyMessages + currentMessages + self.unreadPublicChat = false + self.restoredChatSessionKey = key + } + } + } + + private func handleChatHistoryCleared() { + self.chat = [] + self.unreadPublicChat = false + self.restoredChatSessionKey = nil + } + func updateServerTitle() { self.serverTitle = self.serverName ?? self.server?.name ?? server?.address ?? "Server" } - + private func addOrUpdateHotlineUser(_ user: HotlineUser) { print("Hotline: users: \n\(self.users)") if let i = self.users.firstIndex(where: { $0.id == user.id }) { @@ -1377,7 +1470,8 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega print("Hotline: added user: \(user.name)") self.users.append(User(hotlineUser: user)) if Prefs.shared.showJoinLeaveMessages { - self.chat.append(ChatMessage(text: "\(user.name) joined", type: .status, date: Date())) + let chatMessage = ChatMessage(text: "\(user.name) joined", type: .joined, date: Date()) + self.recordChatMessage(chatMessage) } } } diff --git a/Hotline/iOS/ChatView.swift b/Hotline/iOS/ChatView.swift index dadd5e6..0ad41e0 100644 --- a/Hotline/iOS/ChatView.swift +++ b/Hotline/iOS/ChatView.swift @@ -63,6 +63,22 @@ struct ChatView: View { } .padding() } + else if msg.type == .signOut { + HStack { + Spacer() + Label { + Text(msg.text) + .font(.footnote) + } icon: { + Image(systemName: "arrow.up.circle.fill") + } + .labelStyle(.titleAndIcon) + .foregroundStyle(Color.red) + .opacity(0.8) + Spacer() + } + .padding(.vertical, 6) + } else { HStack(alignment: .firstTextBaseline) { if let username = msg.username { diff --git a/Hotline/macOS/ChatView.swift b/Hotline/macOS/ChatView.swift index cd84369..12e6c94 100644 --- a/Hotline/macOS/ChatView.swift +++ b/Hotline/macOS/ChatView.swift @@ -4,6 +4,150 @@ enum FocusedField: Int, Hashable { case chatInput } +struct ChatStatusMessageView: View { + let message: ChatMessage + + var body: some View { + HStack(alignment: .center, spacing: 8) { + Image(systemName: "arrow.right") + .resizable() + .scaledToFit() + .fontWeight(.semibold) + .foregroundStyle(.primary) + .frame(width: 14, height: 14) + + Text(message.text) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.disabled) + + Spacer() + } + .opacity(0.3) + } +} + + +struct ChatJoinedMessageView: View { + let message: ChatMessage + + var body: some View { + HStack(alignment: .center, spacing: 8) { + Image(systemName: "arrow.right") + .resizable() + .scaledToFit() + .fontWeight(.semibold) + .foregroundStyle(.primary) + .frame(width: 12, height: 12) + + Text(message.text) + .lineLimit(1) + .truncationMode(.middle) + .fontWeight(.semibold) + .textSelection(.disabled) + + Spacer() + } + .opacity(0.3) + } +} + +struct ChatLeftMessageView: View { + let message: ChatMessage + + var body: some View { + HStack(alignment: .center, spacing: 8) { + Image(systemName: "arrow.left") + .resizable() + .scaledToFit() + .fontWeight(.semibold) + .foregroundStyle(.primary) + .frame(width: 12, height: 12) + + Text(message.text) + .lineLimit(1) + .truncationMode(.middle) + .fontWeight(.semibold) + .textSelection(.disabled) + + Spacer() + } + .opacity(0.3) + } +} + + +struct ChatDisconnectedMessageView: View { + let message: ChatMessage + + var body: some View { + HStack { + Spacer() + } + .frame(height: 5) + .background(Color.primary) + .clipShape(.capsule) + .opacity(0.1) + } +} + +struct ChatMessageView: View { + let message: ChatMessage + + var body: some View { + HStack(alignment: .firstTextBaseline) { + if let username = message.username { + // if msg.text.isImageURL() { + // HStack(alignment: .bottom) { + // Text("**\(username):** ") + // + // let imageURL = URL(string: msg.text)! + // AsyncImage(url: imageURL) { phase in + // switch phase { + // case .failure: + // Text(LocalizedStringKey(msg.text.convertLinksToMarkdown())) + // .lineSpacing(4) + // .multilineTextAlignment(.leading) + // .textSelection(.enabled) + // .tint(Color("Link Color")) + // case .success(let img): + // Link(destination: imageURL) { + // img + // .resizable() + // .scaledToFit() + // .frame(maxWidth: 250, maxHeight: 150, alignment: .leading) + // .onAppear { + // reader.scrollTo(bottomID, anchor: .bottom) + // } + // } + // default: + // ProgressView().controlSize(.small) + // } + // } + // + // Spacer() + // } + // } + // else { + Text(LocalizedStringKey("**\(username):** \(message.text)".convertingLinksToMarkdown())) + .lineSpacing(4) + .multilineTextAlignment(.leading) + .textSelection(.enabled) + .tint(Color("Link Color")) + // } + } + else { + Text(message.text) + .lineSpacing(4) + .multilineTextAlignment(.leading) + .textSelection(.enabled) + .tint(Color("Link Color")) + } + Spacer() + } + } +} + struct ChatView: View { @Environment(Hotline.self) private var model: Hotline @Environment(\.colorScheme) var colorScheme @@ -20,7 +164,7 @@ struct ChatView: View { @State private var showingExporter: Bool = false @State private var chatDocument: TextFile = TextFile() - + var body: some View { NavigationStack { ScrollViewReader { reader in @@ -29,99 +173,64 @@ struct ChatView: View { // MARK: Scroll View GeometryReader { gm in ScrollView(.vertical) { - LazyVStack(alignment: .leading) { + LazyVStack(alignment: .leading, spacing: 8) { ForEach(model.chat) { msg in - - // MARK: Agreement if msg.type == .agreement { -#if os(iOS) + VStack(alignment: .center, spacing: 16) { if let bannerImage = self.model.bannerImage { - Image(uiImage: bannerImage) - .resizable() - .scaledToFit() - .frame(maxWidth: 468.0) - .clipShape(RoundedRectangle(cornerRadius: 3)) + HStack(spacing: 0) { + Spacer(minLength: 0) + ZStack { + Image(nsImage: bannerImage) + .resizable() + .scaledToFit() + .frame(maxWidth: 468.0) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .offset(y: 1.5) + .blur(radius: 4) + .opacity(0.2) + + Image(nsImage: bannerImage) + .resizable() + .scaledToFit() + .frame(maxWidth: 468.0) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + + Spacer(minLength: 0) + } } -#endif - ServerAgreementView(text: msg.text) - .padding(.bottom, 16) + + ServerAgreementView(text: msg.text) + } + .padding(.vertical, 24) } // MARK: Server Message else if msg.type == .server { ServerMessageView(message: msg.text) - .padding(EdgeInsets(top: 8, leading: 0, bottom: 8, trailing: 0)) } // MARK: Status else if msg.type == .status { - HStack { - Spacer() - Text(msg.text) - .lineLimit(1) - .truncationMode(.middle) - .textSelection(.disabled) - .opacity(0.3) - Spacer() - } - .padding(EdgeInsets(top: 2, leading: 0, bottom: 2, trailing: 0)) + ChatStatusMessageView(message: msg) + } + else if msg.type == .joined { + ChatJoinedMessageView(message: msg) + } + else if msg.type == .left { + ChatLeftMessageView(message: msg) + } + else if msg.type == .signOut { + ChatDisconnectedMessageView(message: msg) + .padding(.vertical, 24) } else { - HStack(alignment: .firstTextBaseline) { - if let username = msg.username { -// if msg.text.isImageURL() { -// HStack(alignment: .bottom) { -// Text("**\(username):** ") -// -// let imageURL = URL(string: msg.text)! -// AsyncImage(url: imageURL) { phase in -// switch phase { -// case .failure: -// Text(LocalizedStringKey(msg.text.convertLinksToMarkdown())) -// .lineSpacing(4) -// .multilineTextAlignment(.leading) -// .textSelection(.enabled) -// .tint(Color("Link Color")) -// case .success(let img): -// Link(destination: imageURL) { -// img -// .resizable() -// .scaledToFit() -// .frame(maxWidth: 250, maxHeight: 150, alignment: .leading) -// .onAppear { -// reader.scrollTo(bottomID, anchor: .bottom) -// } -// } -// default: -// ProgressView().controlSize(.small) -// } -// } -// -// Spacer() -// } -// } -// else { - Text(LocalizedStringKey("**\(username):** \(msg.text)".convertingLinksToMarkdown())) - .lineSpacing(4) - .multilineTextAlignment(.leading) - .textSelection(.enabled) - .tint(Color("Link Color")) -// } - } - else { - Text(msg.text) - .lineSpacing(4) - .multilineTextAlignment(.leading) - .textSelection(.enabled) - .tint(Color("Link Color")) - } - Spacer() - } - .padding(EdgeInsets(top: 2, leading: 0, bottom: 2, trailing: 0)) + ChatMessageView(message: msg) } } } .padding() - + VStack(spacing: 0) {}.id(bottomID) } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -136,6 +245,9 @@ struct ChatView: View { .onChange(of: gm.size) { reader.scrollTo(bottomID, anchor: .bottom) } + .onChange(of: self.model.bannerImage) { + reader.scrollTo(bottomID, anchor: .bottom) + } } // MARK: Input Divider @@ -160,7 +272,7 @@ struct ChatView: View { .frame(maxWidth: .infinity, minHeight: 28) .padding(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) .overlay(alignment: .leadingLastTextBaseline) { - Image(systemName: "chevron.right").opacity(0.4).offset(x: 16) + Image(systemName: "chevron.right").fontWeight(.semibold).opacity(0.4).offset(x: 16) } .onContinuousHover { phase in switch phase { @@ -178,17 +290,17 @@ struct ChatView: View { } } .background(Color(nsColor: .textBackgroundColor)) -// .toolbar { -// ToolbarItem(placement: .primaryAction) { -// Button { -// if prepareChatDocument() { -// showingExporter = true -// } -// } label: { -// Image(systemName: "square.and.arrow.up") -// }.help("Save Chat...") -// } -// } + // .toolbar { + // ToolbarItem(placement: .primaryAction) { + // Button { + // if prepareChatDocument() { + // showingExporter = true + // } + // } label: { + // Image(systemName: "square.and.arrow.up") + // }.help("Save Chat...") + // } + // } .fileExporter(isPresented: $showingExporter, document: self.chatDocument, contentType: .utf8PlainText, defaultFilename: "\(self.model.serverTitle) Chat.txt") { result in switch result { case .success(let url): diff --git a/Hotline/macOS/FilesView.swift b/Hotline/macOS/FilesView.swift index 3447b28..00a4e1e 100644 --- a/Hotline/macOS/FilesView.swift +++ b/Hotline/macOS/FilesView.swift @@ -234,7 +234,7 @@ struct FilesView: View { private var searchStatusMessage: String? { switch model.fileSearchStatus { - case .searching(let processed, let pending): + case .searching(let processed, _): let scanned = processed == 1 ? "folder" : "folders" return "Searched \(processed) \(scanned)..." case .completed(let processed): @@ -248,7 +248,6 @@ struct FilesView: View { if model.fileSearchResults.isEmpty { return nil } - let count = model.fileSearchResults.count let folderWord = processed == 1 ? "folder" : "folders" return "Search cancelled" case .failed(let message): diff --git a/Hotline/macOS/MessageBoardEditorView.swift b/Hotline/macOS/MessageBoardEditorView.swift index 029616f..474384e 100644 --- a/Hotline/macOS/MessageBoardEditorView.swift +++ b/Hotline/macOS/MessageBoardEditorView.swift @@ -20,7 +20,7 @@ struct MessageBoardEditorView: View { let cleanedText = text.trimmingCharacters(in: .whitespacesAndNewlines) - await model.postToMessageBoard(text: cleanedText) + model.postToMessageBoard(text: cleanedText) let _ = await model.getMessageBoard() // let success = await model.postNewsArticle(title: title, body: text, at: path, parentID: parentID) diff --git a/Hotline/macOS/ServerAgreementView.swift b/Hotline/macOS/ServerAgreementView.swift index a1f96d9..e72de7d 100644 --- a/Hotline/macOS/ServerAgreementView.swift +++ b/Hotline/macOS/ServerAgreementView.swift @@ -1,6 +1,6 @@ import SwiftUI -fileprivate let MAX_AGREEMENT_HEIGHT: CGFloat = 280 +fileprivate let MAX_AGREEMENT_HEIGHT: CGFloat = 340 struct ServerAgreementView: View { let text: String @@ -42,7 +42,7 @@ struct ServerAgreementView: View { #elseif os(macOS) .background(VisualEffectView(material: .titlebar, blendingMode: .withinWindow)) #endif - .overlay( + .overlay(alignment: .bottomTrailing) { ZStack(alignment: .bottomTrailing) { Group { if !expandable || expanded { @@ -54,25 +54,22 @@ struct ServerAgreementView: View { expanded = true } }, label: { - Color.black - .opacity(0.00001) - .frame(width: 32, height: 32) - .overlay( - Image(systemName: "arrow.up.left.and.arrow.down.right") - .resizable() - .scaledToFit() - .fontWeight(.semibold) - .frame(width: 12, height: 12) - .foregroundColor(.primary.opacity(0.8)) - , alignment: .center) + Image(systemName: "arrow.up.and.down.circle.fill") + .resizable() + .scaledToFit() + .fontWeight(.semibold) + .frame(width: 16, height: 16) + .foregroundColor(.primary.opacity(0.5)) }) .buttonStyle(.plain) + .buttonBorderShape(.circle) .help("Expand Server Agreement") + .padding([.trailing, .bottom], 16) } } } - , alignment: .bottomTrailing) - .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) } } diff --git a/Hotline/macOS/ServerMessageView.swift b/Hotline/macOS/ServerMessageView.swift index 8413a87..6c3c60e 100644 --- a/Hotline/macOS/ServerMessageView.swift +++ b/Hotline/macOS/ServerMessageView.swift @@ -24,7 +24,7 @@ struct ServerMessageView: View { #elseif os(macOS) .background(VisualEffectView(material: .titlebar, blendingMode: .withinWindow)) #endif - .clipShape(RoundedRectangle(cornerRadius: 8)) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) } } diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index 49413fe..77b6dc8 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -307,9 +307,7 @@ struct ServerView: View { .keyboardShortcut(.cancelAction) Button("Connect") { - Task { - await connectToServer() - } + connectToServer() } .controlSize(.regular) diff --git a/Hotline/macOS/SettingsView.swift b/Hotline/macOS/SettingsView.swift index 81a2f8d..d301abd 100644 --- a/Hotline/macOS/SettingsView.swift +++ b/Hotline/macOS/SettingsView.swift @@ -3,6 +3,7 @@ import SwiftUI struct GeneralSettingsView: View { @State private var username: String = "" @State private var usernameChanged: Bool = false + @State private var showClearHistoryConfirmation: Bool = false let saveTimer = Timer.publish(every: 5, on: .main, in: .common).autoconnect() @@ -24,9 +25,27 @@ struct GeneralSettingsView: View { preferences.username = self.username } } + + Divider() + + Button(role: .destructive) { + showClearHistoryConfirmation = true + } label: { + Text("Clear Chat History…") + } } .padding() .frame(width: 392) + .confirmationDialog("Clear chat history?", isPresented: $showClearHistoryConfirmation, titleVisibility: .visible) { + Button("Clear Chat History", role: .destructive) { + Task { + await ChatStore.shared.clearAll() + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This removes all saved chat logs across servers. Active chats will repopulate only with new messages.") + } .onAppear { self.username = preferences.username self.usernameChanged = false -- cgit From fefede829c12d9f69db72d70231291d973b0c1a2 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 24 Oct 2025 23:02:12 -0700 Subject: Further polish on chat. Try not to persist back to back disconnect messages. --- Hotline/Models/ChatMessage.swift | 5 --- Hotline/Models/Hotline.swift | 14 +++++- Hotline/iOS/ChatView.swift | 11 ----- Hotline/macOS/ChatView.swift | 97 ++++++++++++++-------------------------- 4 files changed, 47 insertions(+), 80 deletions(-) diff --git a/Hotline/Models/ChatMessage.swift b/Hotline/Models/ChatMessage.swift index 365f91f..744a5d2 100644 --- a/Hotline/Models/ChatMessage.swift +++ b/Hotline/Models/ChatMessage.swift @@ -2,7 +2,6 @@ import SwiftUI enum ChatMessageType { case agreement - case status case joined case left case message @@ -15,8 +14,6 @@ extension ChatMessageType { switch self { case .agreement: return "agreement" - case .status: - return "status" case .joined: return "joined" case .left: @@ -34,8 +31,6 @@ extension ChatMessageType { switch storageKey { case "agreement": self = .agreement - case "status": - self = .status case "joined": self = .joined case "left": diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift index 8cdeccd..08b79a1 100644 --- a/Hotline/Models/Hotline.swift +++ b/Hotline/Models/Hotline.swift @@ -196,6 +196,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega @ObservationIgnored private var chatSessionKey: ChatStore.SessionKey? @ObservationIgnored private var restoredChatSessionKey: ChatStore.SessionKey? @ObservationIgnored private var chatHistoryObserver: NSObjectProtocol? + @ObservationIgnored private var lastPersistedMessageType: ChatMessageType? #if os(macOS) var bannerImage: NSImage? = nil #elseif os(iOS) @@ -244,6 +245,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega let key = sessionKey(for: server) self.chatSessionKey = key self.restoredChatSessionKey = nil + self.lastPersistedMessageType = nil self.chat = [] self.restoreChatHistory(for: key) @@ -1132,6 +1134,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega self.chatSessionKey = nil self.restoredChatSessionKey = nil + self.lastPersistedMessageType = nil } else if status == .loggedIn { if Prefs.shared.playSounds && Prefs.shared.playLoggedInSound { @@ -1395,12 +1398,19 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega } private func recordChatMessage(_ message: ChatMessage, persist: Bool = true, display: Bool = true) { + let shouldPersist = persist && message.type != .agreement + if shouldPersist, + message.type == .signOut, + lastPersistedMessageType == .signOut { + return + } + if display { self.chat.append(message) } - let shouldPersist = persist && message.type != .agreement guard shouldPersist, let key = chatSessionKey else { return } + self.lastPersistedMessageType = message.type let entry = ChatStore.Entry( id: message.id, body: message.text, @@ -1438,6 +1448,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega return ChatMessage(text: renderedText, type: chatType, date: entry.date) } self.chat = historyMessages + currentMessages + self.lastPersistedMessageType = historyMessages.last?.type self.unreadPublicChat = false self.restoredChatSessionKey = key } @@ -1448,6 +1459,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega self.chat = [] self.unreadPublicChat = false self.restoredChatSessionKey = nil + self.lastPersistedMessageType = nil } func updateServerTitle() { diff --git a/Hotline/iOS/ChatView.swift b/Hotline/iOS/ChatView.swift index 0ad41e0..e320dbf 100644 --- a/Hotline/iOS/ChatView.swift +++ b/Hotline/iOS/ChatView.swift @@ -52,17 +52,6 @@ struct ChatView: View { .frame(maxWidth: .infinity) .padding() } - else if msg.type == .status { - HStack { - Spacer() - Text(msg.text) - .lineLimit(1) - .truncationMode(.middle) - .opacity(0.3) - Spacer() - } - .padding() - } else if msg.type == .signOut { HStack { Spacer() diff --git a/Hotline/macOS/ChatView.swift b/Hotline/macOS/ChatView.swift index 12e6c94..a45e7ed 100644 --- a/Hotline/macOS/ChatView.swift +++ b/Hotline/macOS/ChatView.swift @@ -4,35 +4,11 @@ enum FocusedField: Int, Hashable { case chatInput } -struct ChatStatusMessageView: View { - let message: ChatMessage - - var body: some View { - HStack(alignment: .center, spacing: 8) { - Image(systemName: "arrow.right") - .resizable() - .scaledToFit() - .fontWeight(.semibold) - .foregroundStyle(.primary) - .frame(width: 14, height: 14) - - Text(message.text) - .lineLimit(1) - .truncationMode(.middle) - .textSelection(.disabled) - - Spacer() - } - .opacity(0.3) - } -} - - struct ChatJoinedMessageView: View { let message: ChatMessage var body: some View { - HStack(alignment: .center, spacing: 8) { + HStack(alignment: .center, spacing: 4) { Image(systemName: "arrow.right") .resizable() .scaledToFit() @@ -56,7 +32,7 @@ struct ChatLeftMessageView: View { let message: ChatMessage var body: some View { - HStack(alignment: .center, spacing: 8) { + HStack(alignment: .center, spacing: 4) { Image(systemName: "arrow.left") .resizable() .scaledToFit() @@ -206,14 +182,9 @@ struct ChatView: View { } .padding(.vertical, 24) } - // MARK: Server Message else if msg.type == .server { ServerMessageView(message: msg.text) } - // MARK: Status - else if msg.type == .status { - ChatStatusMessageView(message: msg) - } else if msg.type == .joined { ChatJoinedMessageView(message: msg) } @@ -313,38 +284,38 @@ struct ChatView: View { } } - private func prepareChatDocument() -> Bool { - var text: String = String() - - self.chatDocument.text = "" - for msg in model.chat { - if msg.type == .agreement { - text.append(msg.text) - text.append("\n\n") - } - else if msg.type == .message { - if let username = msg.username { - text.append("\(username): \(msg.text)") - } - else { - text.append(msg.text) - } - text.append("\n") - } - else if msg.type == .status { - text.append(msg.text) - text.append("\n") - } - } - - if text.isEmpty { - return false - } - - self.chatDocument.text = text - - return true - } +// private func prepareChatDocument() -> Bool { +// var text: String = String() +// +// self.chatDocument.text = "" +// for msg in model.chat { +// if msg.type == .agreement { +// text.append(msg.text) +// text.append("\n\n") +// } +// else if msg.type == .message { +// if let username = msg.username { +// text.append("\(username): \(msg.text)") +// } +// else { +// text.append(msg.text) +// } +// text.append("\n") +// } +// else if msg.type == .status { +// text.append(msg.text) +// text.append("\n") +// } +// } +// +// if text.isEmpty { +// return false +// } +// +// self.chatDocument.text = text +// +// return true +// } } #Preview { -- cgit From 54a659ad501270d2d3fe79a6831f0f00ca149d8d Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Sat, 25 Oct 2025 10:57:35 -0700 Subject: Update GitHub Sponsors username in FUNDING.yml --- .github/FUNDING.yml | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..d132689 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: mierau -- cgit From 9addf60f3f9da07eb71a3c9ec391df5d8c959f65 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Sat, 25 Oct 2025 11:03:15 -0700 Subject: Revise OS requirements and support policy Updated OS requirements and added support policy. --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a08dbfb..7076134 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,9 @@ An attempt to resurrect Hotline for modern Apple systems (iOS, macOS, etc) by co | macOS | iOS | iPadOS | visionOS | |----------------------------|-----------------------|-------------------|-------------------| -| Sonoma 14.2 or greater | iOS 17.2 or greater | Not yet available | Not yet available | +| Sequoia 15.7 or greater | iOS 18.7 or greater | Not yet available | Not yet available | + +To keep this software fresh and running on Apple's latest platforms, the intention is to support the last two major OS releases from Apple. This gives people time to move to the latest major OS release while also allowing this project to make use of the latest APIs. # What is Hotline? -- cgit From 65521893ac5f10ec035cd735747034c263e46d77 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Sat, 25 Oct 2025 11:05:40 -0700 Subject: Update project description for clarity --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7076134..8fc1b89 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ GitHub Banner # Hotline -An attempt to resurrect Hotline for modern Apple systems (iOS, macOS, etc) by completely recreating it in Swift and SwiftUI. +An attempt to resurrect Hotline for Apple's latest operating systems (iOS, macOS, etc) by completely recreating it in Swift and SwiftUI. [Download the Latest](https://github.com/mierau/hotline/releases/latest) -- cgit From 53686e30592fee566585e391738adee8b2ef2137 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Sat, 25 Oct 2025 15:26:05 -0700 Subject: Fixed some warnings. Add basic markdown formatting support to chat messages. Add some metadata support for chat log (though we're not using this yet). --- Hotline/Hotline/HotlineProtocol.swift | 4 +-- Hotline/Managers/ChatStore.swift | 26 ++++++++++++++++ Hotline/Models/ChatMessage.swift | 9 ++++-- Hotline/Models/Hotline.swift | 8 +++-- Hotline/Utility/FoundationExtensions.swift | 34 ++++++++++++++++----- Hotline/Utility/SwiftUIExtensions.swift | 26 ++++++++++++++++ Hotline/macOS/ChatView.swift | 49 ++++-------------------------- Hotline/macOS/FilesView.swift | 3 +- 8 files changed, 98 insertions(+), 61 deletions(-) diff --git a/Hotline/Hotline/HotlineProtocol.swift b/Hotline/Hotline/HotlineProtocol.swift index ff91672..9b3a812 100644 --- a/Hotline/Hotline/HotlineProtocol.swift +++ b/Hotline/Hotline/HotlineProtocol.swift @@ -241,11 +241,11 @@ struct HotlineAccount: Identifiable { } if fieldType == .userLogin { - self.login = field.getObfuscatedString()! + self.login = field.getObfuscatedString() ?? "" } if fieldType == .userPassword { - self.password = field.getObfuscatedString()! + self.password = field.getObfuscatedString() ?? "" } if fieldType == .userAccess, let opts = field.getUInt64(){ diff --git a/Hotline/Managers/ChatStore.swift b/Hotline/Managers/ChatStore.swift index daf8ca0..59c9484 100644 --- a/Hotline/Managers/ChatStore.swift +++ b/Hotline/Managers/ChatStore.swift @@ -28,12 +28,23 @@ actor ChatStore { } } + struct EntryMetadata: Codable { + var images: [ImageMetadata]? + + struct ImageMetadata: Codable { + let url: String + let width: CGFloat? + let height: CGFloat? + } + } + struct Entry: Codable { let id: UUID let body: String let username: String? let type: String let date: Date + var metadata: EntryMetadata? } struct LoadResult { @@ -81,6 +92,21 @@ actor ChatStore { } } + func updateMetadata(_ metadata: EntryMetadata, for entryID: UUID, key: SessionKey) async { + do { + guard var logFile = try loadLogFile(for: key) else { return } + + if let index = logFile.entries.firstIndex(where: { $0.id == entryID }) { + logFile.entries[index].metadata = metadata + cache[key] = logFile + try persist(logFile, for: key) + } + } + catch { + print("ChatStore: failed to update metadata —", error) + } + } + func loadHistory(for key: SessionKey, limit: Int? = nil) async -> LoadResult { do { let logFile = try loadLogFile(for: key) diff --git a/Hotline/Models/ChatMessage.swift b/Hotline/Models/ChatMessage.swift index 744a5d2..1c210ce 100644 --- a/Hotline/Models/ChatMessage.swift +++ b/Hotline/Models/ChatMessage.swift @@ -48,19 +48,22 @@ extension ChatMessageType { } struct ChatMessage: Identifiable { - let id = UUID() - + let id: UUID + let text: String let type: ChatMessageType let date: Date let username: String? + var metadata: ChatStore.EntryMetadata? static let parser = /^\s*([^\:]+):\s*([\s\S]+)$/ init(text: String, type: ChatMessageType, date: Date) { + self.id = UUID() self.type = type self.date = date - + self.metadata = nil + if type == .message, let match = text.firstMatch(of: ChatMessage.parser) { diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift index 08b79a1..3783ba0 100644 --- a/Hotline/Models/Hotline.swift +++ b/Hotline/Models/Hotline.swift @@ -1445,7 +1445,9 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega else { renderedText = entry.body } - return ChatMessage(text: renderedText, type: chatType, date: entry.date) + var message = ChatMessage(text: renderedText, type: chatType, date: entry.date) + message.metadata = entry.metadata + return message } self.chat = historyMessages + currentMessages self.lastPersistedMessageType = historyMessages.last?.type @@ -1530,12 +1532,12 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega guard let parent = self.findNews(in: self.news, at: path), !parent.children.isEmpty else { return nil } - + return parent.children.first { child in guard let childArticleID = child.articleID else { return false } - + return child.type == .article && child.articleID == childArticleID } } diff --git a/Hotline/Utility/FoundationExtensions.swift b/Hotline/Utility/FoundationExtensions.swift index 9fc9ae7..90a3032 100644 --- a/Hotline/Utility/FoundationExtensions.swift +++ b/Hotline/Utility/FoundationExtensions.swift @@ -8,6 +8,13 @@ enum Endianness { extension String { + func markdownToAttributedString() -> AttributedString { + let markdownText = self.convertingLinksToMarkdown() + let attr = (try? AttributedString(markdown: markdownText, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(self) + + return attr + } + func convertToAttributedStringWithLinks() -> AttributedString { let attributedString: NSMutableAttributedString = NSMutableAttributedString(string: self) let matches = self.ranges(of: RegularExpressions.relaxedLink) @@ -54,17 +61,28 @@ extension String { } func convertingLinksToMarkdown() -> String { - var cp = String(self) - cp.replace(RegularExpressions.relaxedLink) { match -> String in +// var cp = String(self) + + self.replacing(RegularExpressions.relaxedLink) { match in let linkText = self[match.range] - var injectedScheme = "https://" - if let _ = try? RegularExpressions.supportedLinkScheme.prefixMatch(in: linkText) { - injectedScheme = "" - } - return "[\(linkText)](\(injectedScheme)\(linkText))" + // Only add https:// if the link doesn't already have a scheme + let hasScheme = (try? RegularExpressions.supportedLinkScheme.prefixMatch(in: linkText)) != nil + let url = hasScheme ? String(linkText) : "https://\(linkText)" + + return "[\(linkText)](\(url))" } - return cp + +// cp.replace(RegularExpressions.relaxedLink) { match -> String in +// let linkText = self[match.range] +// var injectedScheme = "https://" +// if let _ = try? RegularExpressions.supportedLinkScheme.prefixMatch(in: linkText) { +// injectedScheme = "" +// } +// +// return "[\(linkText)](\(injectedScheme)\(linkText))" +// } +// return cp } } diff --git a/Hotline/Utility/SwiftUIExtensions.swift b/Hotline/Utility/SwiftUIExtensions.swift index 3b850e0..12217b2 100644 --- a/Hotline/Utility/SwiftUIExtensions.swift +++ b/Hotline/Utility/SwiftUIExtensions.swift @@ -1,7 +1,33 @@ import SwiftUI +import Foundation extension Color { init(hex: Int, opacity: Double = 1.0) { self.init(red: Double((hex >> 16) & 0xFF) / 255.0, green: Double((hex >> 8) & 0xFF) / 255.0, blue: Double(hex & 0xFF) / 255.0, opacity: opacity) } } + +extension AttributedString { + func setHangingIndent(firstLineHeadIndent: CGFloat = 0, otherLinesHeadIndent: CGFloat) -> AttributedString { +// var blah = self + +// guard var paragraph = self.paragraphStyle else { +// return +// } + + var p = self.paragraphStyle?.mutableCopy() as? NSMutableParagraphStyle + p?.headIndent = otherLinesHeadIndent + p?.firstLineHeadIndent = firstLineHeadIndent + +// paragraph.headIndent = otherLinesHeadIndent // indent for lines 2+ +// paragraph.firstLineHeadIndent = firstLineHeadIndent // usually 0 + + var blah = self + + + blah.paragraphStyle = p + + return blah + } +} + diff --git a/Hotline/macOS/ChatView.swift b/Hotline/macOS/ChatView.swift index a45e7ed..2ad9816 100644 --- a/Hotline/macOS/ChatView.swift +++ b/Hotline/macOS/ChatView.swift @@ -69,58 +69,21 @@ struct ChatDisconnectedMessageView: View { struct ChatMessageView: View { let message: ChatMessage - + var body: some View { HStack(alignment: .firstTextBaseline) { if let username = message.username { - // if msg.text.isImageURL() { - // HStack(alignment: .bottom) { - // Text("**\(username):** ") - // - // let imageURL = URL(string: msg.text)! - // AsyncImage(url: imageURL) { phase in - // switch phase { - // case .failure: - // Text(LocalizedStringKey(msg.text.convertLinksToMarkdown())) - // .lineSpacing(4) - // .multilineTextAlignment(.leading) - // .textSelection(.enabled) - // .tint(Color("Link Color")) - // case .success(let img): - // Link(destination: imageURL) { - // img - // .resizable() - // .scaledToFit() - // .frame(maxWidth: 250, maxHeight: 150, alignment: .leading) - // .onAppear { - // reader.scrollTo(bottomID, anchor: .bottom) - // } - // } - // default: - // ProgressView().controlSize(.small) - // } - // } - // - // Spacer() - // } - // } - // else { - Text(LocalizedStringKey("**\(username):** \(message.text)".convertingLinksToMarkdown())) - .lineSpacing(4) - .multilineTextAlignment(.leading) - .textSelection(.enabled) - .tint(Color("Link Color")) - // } + Text("\(username): ").fontWeight(.semibold) + Text(message.text.markdownToAttributedString()) } else { Text(message.text) - .lineSpacing(4) - .multilineTextAlignment(.leading) - .textSelection(.enabled) - .tint(Color("Link Color")) } Spacer() } + .lineSpacing(4) + .multilineTextAlignment(.leading) + .textSelection(.enabled) + .tint(Color("Link Color")) } } diff --git a/Hotline/macOS/FilesView.swift b/Hotline/macOS/FilesView.swift index 00a4e1e..42232af 100644 --- a/Hotline/macOS/FilesView.swift +++ b/Hotline/macOS/FilesView.swift @@ -244,11 +244,10 @@ struct FilesView: View { return "No files found in \(processed) \(folderWord)" } return "\(count) file\(count == 1 ? "" : "s") found in \(processed) \(folderWord)" - case .cancelled(let processed): + case .cancelled(_): if model.fileSearchResults.isEmpty { return nil } - let folderWord = processed == 1 ? "folder" : "folders" return "Search cancelled" case .failed(let message): return "Search failed: \(message)" -- cgit From 3b3b965842c47939ca54d0d1cbdf469346847f14 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Sat, 25 Oct 2025 16:46:52 -0700 Subject: Move chat input string to view model so its not lost when switching between tabs. Add chat search/filtering with suppport for live filtering. --- Hotline/MacApp.swift | 10 ++-- Hotline/Models/Hotline.swift | 64 +++++++++++++++++++++++ Hotline/macOS/ChatView.swift | 120 +++++++++++++++++++++++++++---------------- 3 files changed, 146 insertions(+), 48 deletions(-) diff --git a/Hotline/MacApp.swift b/Hotline/MacApp.swift index a91df52..0682c1d 100644 --- a/Hotline/MacApp.swift +++ b/Hotline/MacApp.swift @@ -270,15 +270,15 @@ struct Application: App { } .disabled(activeHotline?.status != .loggedIn) .keyboardShortcut(.init("1"), modifiers: .command) - Button("Show News") { - activeServerState?.selection = .news - } - .disabled(activeHotline?.status != .loggedIn || (activeHotline?.serverVersion ?? 0) < 151) - .keyboardShortcut(.init("2"), modifiers: .command) Button("Show Message Board") { activeServerState?.selection = .board } .disabled(activeHotline?.status != .loggedIn) + .keyboardShortcut(.init("2"), modifiers: .command) + Button("Show News") { + activeServerState?.selection = .news + } + .disabled(activeHotline?.status != .loggedIn || (activeHotline?.serverVersion ?? 0) < 151) .keyboardShortcut(.init("3"), modifiers: .command) Button("Show Files") { activeServerState?.selection = .files diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift index 3783ba0..3d1f309 100644 --- a/Hotline/Models/Hotline.swift +++ b/Hotline/Models/Hotline.swift @@ -163,6 +163,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega var users: [User] = [] var accounts: [HotlineAccount] = [] var chat: [ChatMessage] = [] + var chatInput: String = "" var messageBoard: [String] = [] var messageBoardLoaded: Bool = false var files: [FileInfo] = [] @@ -1464,6 +1465,69 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega self.lastPersistedMessageType = nil } + @MainActor func searchChat(query: String) -> [ChatMessage] { + guard !query.isEmpty else { + return [] + } + + // Create a map of all messages by ID to deduplicate (current chat includes restored history) + var messageMap: [UUID: ChatMessage] = [:] + + // Add current in-memory messages (includes both restored history and new messages) + for message in self.chat { + messageMap[message.id] = message + } + + // Filter messages based on query + let filteredMessages = messageMap.values.filter { message in + // Never include agreement messages + if message.type == .agreement { + return false + } + + // Always include disconnect messages to show session boundaries + let isDisconnect = message.type == .signOut + + // Search in text and username + let matchesText = message.text.localizedCaseInsensitiveContains(query) + let matchesUsername = message.username?.localizedCaseInsensitiveContains(query) == true + let matchesQuery = matchesText || matchesUsername + + return isDisconnect || matchesQuery + } + + // Sort by date to maintain chronological order + let sortedMessages = filteredMessages.sorted { $0.date < $1.date } + + // Remove consecutive disconnect messages to avoid visual clutter + var deduplicated: [ChatMessage] = [] + var lastWasDisconnect = false + + for message in sortedMessages { + let isDisconnect = message.type == .signOut + + if isDisconnect && lastWasDisconnect { + // Skip consecutive disconnect messages + continue + } + + deduplicated.append(message) + lastWasDisconnect = isDisconnect + } + + // Remove leading disconnect message + if deduplicated.first?.type == .signOut { + deduplicated.removeFirst() + } + + // Remove trailing disconnect message + if deduplicated.last?.type == .signOut { + deduplicated.removeLast() + } + + return deduplicated + } + func updateServerTitle() { self.serverTitle = self.serverName ?? self.server?.name ?? server?.address ?? "Server" } diff --git a/Hotline/macOS/ChatView.swift b/Hotline/macOS/ChatView.swift index 2ad9816..65087c6 100644 --- a/Hotline/macOS/ChatView.swift +++ b/Hotline/macOS/ChatView.swift @@ -91,30 +91,42 @@ struct ChatView: View { @Environment(Hotline.self) private var model: Hotline @Environment(\.colorScheme) var colorScheme @Environment(\.dismiss) var dismiss - - @State var input: String = "" + @State private var scrollPos: Int? @State private var contentHeight: CGFloat = 0 - + + @State private var searchQuery: String = "" + @State private var searchResults: [ChatMessage] = [] + @State private var isSearching: Bool = false + @FocusState private var focusedField: FocusedField? - + @Namespace var bottomID - - @State private var showingExporter: Bool = false - - @State private var chatDocument: TextFile = TextFile() - + + private var bindableModel: Bindable { + Bindable(model) + } + +// @State private var showingExporter: Bool = false +// +// @State private var chatDocument: TextFile = TextFile() + + var displayedMessages: [ChatMessage] { + searchQuery.isEmpty ? model.chat : searchResults + } + var body: some View { + @Bindable var bindModel = model + NavigationStack { ScrollViewReader { reader in VStack(alignment: .leading, spacing: 0) { - + // MARK: Scroll View - GeometryReader { gm in ScrollView(.vertical) { LazyVStack(alignment: .leading, spacing: 8) { - - ForEach(model.chat) { msg in + + ForEach(displayedMessages) { msg in if msg.type == .agreement { VStack(alignment: .center, spacing: 16) { if let bannerImage = self.model.bannerImage { @@ -168,37 +180,46 @@ struct ChatView: View { VStack(spacing: 0) {}.id(bottomID) } .frame(maxWidth: .infinity, maxHeight: .infinity) - .defaultScrollAnchor(.bottom) +// .defaultScrollAnchor(.bottom, for: .initialOffset) + .defaultScrollAnchor(.bottom, for: .alignment) + .defaultScrollAnchor(.bottom, for: .sizeChanges) .onChange(of: model.chat.count) { + // Re-run search when new messages arrive to keep filter active + if !searchQuery.isEmpty { + performSearch() + } reader.scrollTo(bottomID, anchor: .bottom) model.markPublicChatAsRead() } .onAppear { reader.scrollTo(bottomID, anchor: .bottom) + self.focusedField = .chatInput } - .onChange(of: gm.size) { + .onChange(of: self.model.bannerImage) { reader.scrollTo(bottomID, anchor: .bottom) } - .onChange(of: self.model.bannerImage) { + .onChange(of: searchQuery) { + reader.scrollTo(bottomID, anchor: .bottom) + } + .onChange(of: isSearching) { reader.scrollTo(bottomID, anchor: .bottom) } - } // MARK: Input Divider Divider() // MARK: Input Bar HStack(alignment: .lastTextBaseline, spacing: 0) { - TextField("", text: $input, axis: .vertical) + TextField("", text: $bindModel.chatInput, axis: .vertical) .focused($focusedField, equals: .chatInput) .textFieldStyle(.plain) .lineLimit(1...5) .multilineTextAlignment(.leading) .onSubmit { - if !self.input.isEmpty { - model.sendChat(self.input, announce: NSEvent.modifierFlags.contains(.shift)) + if !model.chatInput.isEmpty { + model.sendChat(model.chatInput, announce: NSEvent.modifierFlags.contains(.shift)) } - self.input = "" + model.chatInput = "" } .frame(maxWidth: .infinity) .padding() @@ -217,36 +238,49 @@ struct ChatView: View { break } } - .onTapGesture { + .onTapGesture(count: 1) { focusedField = .chatInput } } } + .searchable(text: $searchQuery, isPresented: $isSearching, placement: .toolbar, prompt: "Search") + .background(Button("", action: { isSearching = true }).keyboardShortcut("f").hidden()) } .background(Color(nsColor: .textBackgroundColor)) - // .toolbar { - // ToolbarItem(placement: .primaryAction) { - // Button { - // if prepareChatDocument() { - // showingExporter = true - // } - // } label: { - // Image(systemName: "square.and.arrow.up") - // }.help("Save Chat...") - // } - // } - .fileExporter(isPresented: $showingExporter, document: self.chatDocument, contentType: .utf8PlainText, defaultFilename: "\(self.model.serverTitle) Chat.txt") { result in - switch result { - case .success(let url): - print("Saved to \(url)") - - case .failure(let error): - print(error.localizedDescription) - } - self.chatDocument.text = "" +// .navigationTitle(model.serverTitle) + .onChange(of: searchQuery) { + performSearch() + } +// .toolbar { +// ToolbarItem(placement: .primaryAction) { +// Button { +// showingExporter = true +// } label: { +// Image(systemName: "square.and.arrow.up") +// }.help("Save Chat...") +// } +// } +// .fileExporter(isPresented: $showingExporter, document: self.chatDocument, contentType: .utf8PlainText, defaultFilename: "\(self.model.serverTitle) Chat.txt") { result in +// switch result { +// case .success(let url): +// print("Saved to \(url)") +// +// case .failure(let error): +// print(error.localizedDescription) +// } +// self.chatDocument.text = "" +// } + } + + private func performSearch() { + guard !searchQuery.isEmpty else { + searchResults = [] + return } + + searchResults = model.searchChat(query: searchQuery) } - + // private func prepareChatDocument() -> Bool { // var text: String = String() // -- cgit From 9857e39c81ce73d4347fa026a031c8389e42c3f3 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Sat, 25 Oct 2025 17:03:02 -0700 Subject: Experiement with better socket options. --- Hotline/Library/NetSocket.swift | 25 +++++++++++++++---------- Hotline/MacApp.swift | 2 +- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/Hotline/Library/NetSocket.swift b/Hotline/Library/NetSocket.swift index a7d44ce..b1f1742 100644 --- a/Hotline/Library/NetSocket.swift +++ b/Hotline/Library/NetSocket.swift @@ -254,16 +254,21 @@ final class NetSocket: NSObject, StreamDelegate { if setsockopt(socketHandle, SOL_SOCKET, SO_KEEPALIVE, &value, size) != 0 { print("NetSocket: failed to set SO_KEEPALIVE"); } -// // Number of keepalives before close (including first keepalive packet) -// value = 5 -// if setsockopt(socketHandle, IPPROTO_TCP, TCP_KEEPCNT, &value, size) != 0 { -// print("NetSocket: failed to set TCP_KEEPCNT"); -// } -// // Idle time used when SO_KEEPALIVE is enabled. Sets how long connection must be idle before keepalive is sent. -// value = 60 -// if setsockopt(socketHandle, IPPROTO_TCP, TCP_KEEPALIVE, &value, size) != 0 { -// print("NetSocket: failed to set TCP_KEEPALIVE") -// } + // Number of keepalives before close (including first keepalive packet) + value = 5 + if setsockopt(socketHandle, IPPROTO_TCP, TCP_KEEPCNT, &value, size) != 0 { + print("NetSocket: failed to set TCP_KEEPCNT"); + } + // Idle time used when SO_KEEPALIVE is enabled. Sets how long connection must be idle before keepalive is sent. + value = 30 + if setsockopt(socketHandle, IPPROTO_TCP, TCP_KEEPALIVE, &value, size) != 0 { + print("NetSocket: failed to set TCP_KEEPALIVE") + } + // Idle time used when TCP_KEEPINTVL is enabled. Sets how long to wait between probes. + value = 10 + if setsockopt(socketHandle, IPPROTO_TCP, TCP_KEEPINTVL, &value, size) != 0 { + print("NetSocket: failed to set TCP_KEEPINTVL") + } } } } diff --git a/Hotline/MacApp.swift b/Hotline/MacApp.swift index 0682c1d..5d1b912 100644 --- a/Hotline/MacApp.swift +++ b/Hotline/MacApp.swift @@ -189,7 +189,7 @@ struct Application: App { Server(name: nil, description: nil, address: "") } .modelContainer(self.modelContainer) - .defaultSize(width: 750, height: 700) + .defaultSize(width: 690, height: 760) .defaultPosition(.center) .onChange(of: activeServerState) { AppState.shared.activeServerState = activeServerState -- cgit From 0220cb22c0d92b76233f57d3f32462ffbefdf354 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Sun, 26 Oct 2025 19:56:49 -0700 Subject: Start of a new Swift 6 async NetSocket (No more CFStreams!) --- Hotline.xcodeproj/project.pbxproj | 4 + Hotline/Library/NetSocketNew.swift | 1378 ++++++++++++++++++++++++++++++++++++ 2 files changed, 1382 insertions(+) create mode 100644 Hotline/Library/NetSocketNew.swift diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index a31f398..2aafcfe 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -75,6 +75,7 @@ DAC002192B21630900A6C290 /* SwiftUIExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC002182B21630900A6C290 /* SwiftUIExtensions.swift */; }; DAC3D9832BC33FD000A727C9 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC3D9822BC33FD000A727C9 /* AppState.swift */; }; DAC6B2E02EAC6236004E2CBA /* ChatStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC6B2DE2EAC6236004E2CBA /* ChatStore.swift */; }; + DAC6B2E22EAEE9FE004E2CBA /* NetSocketNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC6B2E12EAEE9EF004E2CBA /* NetSocketNew.swift */; }; DAC87F072C5010E80060FADF /* HotlineExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC87F062C5010E80060FADF /* HotlineExtensions.swift */; }; DACCE5E12EABE4B4008CDD92 /* AppUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DACCE5E02EABE4B4008CDD92 /* AppUpdate.swift */; platformFilters = (macos, ); }; DACCE5E32EABE86A008CDD92 /* AppUpdateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */; platformFilters = (macos, ); }; @@ -163,6 +164,7 @@ DAC002182B21630900A6C290 /* SwiftUIExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIExtensions.swift; sourceTree = ""; }; DAC3D9822BC33FD000A727C9 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; DAC6B2DE2EAC6236004E2CBA /* ChatStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatStore.swift; sourceTree = ""; }; + DAC6B2E12EAEE9EF004E2CBA /* NetSocketNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetSocketNew.swift; sourceTree = ""; }; DAC87F062C5010E80060FADF /* HotlineExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineExtensions.swift; sourceTree = ""; }; DACCE5E02EABE4B4008CDD92 /* AppUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdate.swift; sourceTree = ""; }; DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdateView.swift; sourceTree = ""; }; @@ -275,6 +277,7 @@ DAB4D87C2B4B783A0048A05C /* Library */ = { isa = PBXGroup; children = ( + DAC6B2E12EAEE9EF004E2CBA /* NetSocketNew.swift */, DAB4D87A2B4B78310048A05C /* FileImageView.swift */, DAB4D8812B4C8FED0048A05C /* FileIconView.swift */, DAB4D8832B4CABEF0048A05C /* DataAdditions.swift */, @@ -523,6 +526,7 @@ DA57536C2B36BA1D00FAC277 /* TextDocument.swift in Sources */, DACCE5E12EABE4B4008CDD92 /* AppUpdate.swift in Sources */, DA77253F2B21176D006C5ABB /* NewsView.swift in Sources */, + DAC6B2E22EAEE9FE004E2CBA /* NetSocketNew.swift in Sources */, DA99218E2C51AA5D0058FA6C /* HotlineDataBuilder.swift in Sources */, DA872B132BDDBF78008B1012 /* HotlinePanel.swift in Sources */, DA4B8F3A2EA6FB3C00CBFD53 /* iOSApp.swift in Sources */, diff --git a/Hotline/Library/NetSocketNew.swift b/Hotline/Library/NetSocketNew.swift new file mode 100644 index 0000000..776a371 --- /dev/null +++ b/Hotline/Library/NetSocketNew.swift @@ -0,0 +1,1378 @@ + +// NetSocketNew.swift +// Created by Dustin Mierau @mierau + +import Foundation +import Network + +// MARK: - Endianness and Framing + +/// Byte order for multi-byte integer values in binary protocols +public enum Endian { + /// Big-endian (network byte order, most significant byte first) + case big + /// Little-endian (least significant byte first) + case little +} + +/// Length prefix types for framing variable-length data (strings, arrays, binary blobs) +/// +/// Used to encode the size of the following data as a fixed-width integer. +/// Each case can specify its own endianness. +public enum LengthPrefix { + /// 1-byte length prefix (0-255) + case u8 + /// 2-byte length prefix (0-65,535) + case u16(Endian = .big) + /// 4-byte length prefix (0-4,294,967,295) + case u32(Endian = .big) + /// 8-byte length prefix (0-2^64-1) + case u64(Endian = .big) + + /// Number of bytes used by this length prefix + var byteCount: Int { + switch self { + case .u8: return 1 + case .u16: return 2 + case .u32: return 4 + case .u64: return 8 + } + } +} + +/// Delimiter patterns for text-based protocols +public enum Delimiter { + /// Custom single byte delimiter + case byte(UInt8) + /// Null terminator (0x00) + case zeroByte + /// Line feed (\n, 0x0A) + case lineFeed + /// Carriage return + line feed (\r\n, 0x0D 0x0A) + case carriageReturnLineFeed + + /// Binary representation of this delimiter + var data: Data { + switch self { + case .byte(let b): return Data([b]) + case .zeroByte: return Data([0x00]) + case .lineFeed: return Data([0x0A]) + case .carriageReturnLineFeed: return Data([0x0D, 0x0A]) + } + } +} + +/// TLS/SSL encryption policy for socket connections +public struct TLSPolicy: Sendable { + /// Create a TLS-enabled policy with optional custom configuration + /// - Parameter configure: Optional closure to customize TLS options + public static func enabled(_ configure: (@Sendable (NWProtocolTLS.Options) -> Void)? = nil) -> TLSPolicy { + TLSPolicy(enabled: true, configure: configure) + } + + /// Create a policy with TLS disabled (plaintext connection) + public static var disabled: TLSPolicy { TLSPolicy(enabled: false, configure: nil) } + + /// Whether TLS is enabled + public let enabled: Bool + /// Optional TLS configuration closure + public let configure: (@Sendable (NWProtocolTLS.Options) -> Void)? +} + +// MARK: - Errors + +/// Errors that can occur during socket operations +public enum NetSocketError: Error, CustomStringConvertible, Sendable { + /// Socket is not yet in ready state + case notReady + /// Connection has been closed + case closed + /// Invalid port number provided + case invalidPort + /// Network operation failed with underlying error + case failed(underlying: Error) + /// Not enough data available to fulfill read request + case insufficientData(expected: Int, got: Int) + /// Frame size exceeds configured maximum + case framingExceeded(max: Int) + /// Failed to decode data + case decodeFailed(Error) + /// Failed to encode data + case encodeFailed(Error) + + public var description: String { + switch self { + case .notReady: return "Connection not ready." + case .closed: return "Connection closed." + case .invalidPort: return "Invalid port number." + case .failed(let e): return "Network failure: \(e.localizedDescription)" + case .insufficientData(let exp, let got): return "Insufficient data: need \(exp), have \(got)." + case .framingExceeded(let max): return "Frame length exceeded maximum \(max)." + case .decodeFailed(let e): return "Decoding failed: \(e)" + case .encodeFailed(let e): return "Encoding failed: \(e)" + } + } +} + +// MARK: - NetSocketNew + +/// An async/await TCP socket with automatic buffering and framing support +/// +/// NetSocketNew provides: +/// - Async connection management +/// - Automatic receive buffering with memory compaction +/// - Length-prefixed framing for messages +/// - Type-safe reading/writing of integers, strings, and custom types +/// - File upload/download with progress tracking +/// - Flexible encoder/decoder support (JSON, binary, etc.) +/// +/// Example usage: +/// ```swift +/// let socket = try await NetSocketNew.connect(host: "example.com", port: 80) +/// try await socket.write("Hello\n".data(using: .utf8)!) +/// let response = try await socket.readUntil(delimiter: .lineFeed) +/// ``` +public actor NetSocketNew { + /// Configuration options for the socket + public struct Config: Sendable { + /// Size of chunks to receive from network at once (default: 64 KB) + public var receiveChunk: Int = 64 * 1024 + /// Maximum bytes to buffer before disconnecting (default: 8 MB) + public var maxBufferBytes: Int = 8 * 1024 * 1024 + /// Maximum size for a single framed message (default: 4 MB) + public var maxFrameBytes: Int = 4 * 1024 * 1024 + public init() {} + } + + // Connection + state + private let connection: NWConnection + private let queue = DispatchQueue(label: "NetSocket.NWConnection") + private var ready = false + private var isClosed = false + + // Buffer with compaction + private var buffer = Data() + private var head = 0 // start of unread bytes + private let cfg: Config + + // Waiters for data/ready + private var dataWaiters: [CheckedContinuation] = [] + private var readyWaiters: [CheckedContinuation] = [] + + // Codable hooks - stored as closures for flexibility with any encoder/decoder + private var encodeValue: @Sendable (any Encodable) throws -> Data = { value in + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + return try encoder.encode(value) + } + + private var decodeValue: @Sendable (Data, any Decodable.Type) throws -> any Decodable = { data, type in + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(type, from: data) + } + + // MARK: Init/Connect + + private init(connection: NWConnection, config: Config) { + self.connection = connection + self.cfg = config + } + + /// Connect to a remote host and return a ready socket + /// + /// This method establishes a TCP connection and waits until the connection is in `.ready` state. + /// + /// - Parameters: + /// - host: Hostname or IP address to connect to + /// - port: Port number (0-65535) + /// - tls: TLS policy (default: enabled with default settings) + /// - config: Socket configuration (default: standard settings) + /// - Returns: A connected and ready `NetSocketNew` + /// - Throws: `NetSocketError.invalidPort` if port is invalid, or network errors + public static func connect(host: String, port: UInt16, tls: TLSPolicy = .enabled(), config: Config = .init()) async throws -> NetSocketNew { + let parameters = NWParameters.tcp + if tls.enabled { + let tlsOptions = NWProtocolTLS.Options() + tls.configure?(tlsOptions) + parameters.defaultProtocolStack.applicationProtocols.insert(tlsOptions, at: 0) + } + + guard let nwPort = NWEndpoint.Port(rawValue: port) else { + throw NetSocketError.invalidPort + } + + let conn = NWConnection(host: .name(host, nil), port: nwPort, using: parameters) + let socket = NetSocketNew(connection: conn, config: config) + try await socket.start() + return socket + } + + /// Inject custom encoding/decoding logic (supports any encoder/decoder: JSON, CBOR, MessagePack, etc.) + /// + /// Example with JSONEncoder: + /// ``` + /// let encoder = JSONEncoder() + /// socket.useCoders( + /// encode: { try encoder.encode($0) }, + /// decode: { data, type in try decoder.decode(type, from: data) } + /// ) + /// ``` + /// + /// Example with other encoders (pseudocode): + /// ``` + /// let cbor = CBOREncoder() + /// socket.useCoders( + /// encode: { try cbor.encode($0) }, + /// decode: { data, type in try CBORDecoder().decode(type, from: data) } + /// ) + /// ``` + public func useCoders( + encode: @escaping @Sendable (any Encodable) throws -> Data, + decode: @escaping @Sendable (Data, any Decodable.Type) throws -> any Decodable + ) { + self.encodeValue = encode + self.decodeValue = decode + } + + /// Convenience method to configure JSON encoding/decoding + /// + /// Sets up the socket to use the provided JSON encoder/decoder for `send()` and `receive()` calls. + /// + /// - Parameters: + /// - encoder: A configured `JSONEncoder` + /// - decoder: A configured `JSONDecoder` + public func useJSONCoders(encoder: JSONEncoder, decoder: JSONDecoder) { + self.encodeValue = { try encoder.encode($0) } + self.decodeValue = { data, type in try decoder.decode(type, from: data) } + } + + private func start() async throws { + self.connection.stateUpdateHandler = { state in + Task { [weak self] in + guard let self else { return } + switch state { + case .ready: + await self.setReady() + await self.resumeReadyWaiters(with: .success(())) + case .failed(let error): + await self.failAllWaiters(NetSocketError.failed(underlying: error)) + await self.setClosed() + case .waiting(let error): + // bubble as transient failure for awaiters; reconnect logic could live here + await self.resumeReadyWaiters(with: .failure(NetSocketError.failed(underlying: error))) + case .cancelled: + await self.failAllWaiters(NetSocketError.closed) + await self.setClosed() + default: + break + } + } + } + + // Kick off receive loop after .start + self.connection.start(queue: queue) + try await self.waitUntilReady() + self.startReceiveLoop() + } + + private func waitUntilReady() async throws { + guard !ready else { return } + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + readyWaiters.append(cont) + } + } + + private func resumeReadyWaiters(with result: Result) { + let waiters = readyWaiters + readyWaiters.removeAll() + for w in waiters { + switch result { + case .success: w.resume() + case .failure(let e): w.resume(throwing: e) + } + } + } + + private func failAllWaiters(_ error: Error) { + resumeReadyWaiters(with: .failure(error)) + let waiters = dataWaiters + dataWaiters.removeAll() + for w in waiters { w.resume(throwing: error) } + } + + private func setReady() { + ready = true + } + + private func setClosed() { + isClosed = true + } + + // MARK: Receive loop (runs on DispatchQueue, hops into actor) + + private nonisolated func startReceiveLoop() { + func loop(_ connection: NWConnection, chunk: Int, owner: NetSocketNew) { + connection.receive(minimumIncompleteLength: 1, maximumLength: chunk) { data, _, isComplete, error in + if let error { + Task { await owner.handleReceiveError(error) } + return + } + if let data, !data.isEmpty { + Task { await owner.append(data) } + } + if isComplete { + Task { await owner.handleEOF() } + return + } + loop(connection, chunk: chunk, owner: owner) + } + } + loop(connection, chunk: cfg.receiveChunk, owner: self) + } + + private func handleReceiveError(_ error: Error) { + isClosed = true + failAllWaiters(NetSocketError.failed(underlying: error)) + } + + private func handleEOF() { + isClosed = true + let waiters = dataWaiters + dataWaiters.removeAll() + for w in waiters { w.resume() } // wake so readers can observe closure + } + + private func append(_ data: Data) { + buffer.append(data) + if buffer.count - head > cfg.maxBufferBytes { + // Hard stop: drop connection rather than OOM’ing. + isClosed = true + connection.cancel() + failAllWaiters(NetSocketError.framingExceeded(max: cfg.maxBufferBytes)) + return + } + resumeDataWaiters() + } + + private func resumeDataWaiters() { + let waiters = dataWaiters + dataWaiters.removeAll() + for w in waiters { w.resume() } + } + + // MARK: Close + + /// Close the connection + /// + /// Cancels the underlying network connection and wakes all pending read/write operations + /// with a `NetSocketError.closed` error. This method is idempotent. + public func close() { + guard !isClosed else { return } + isClosed = true + connection.cancel() + resumeDataWaiters() + resumeReadyWaiters(with: .failure(NetSocketError.closed)) + } + + // MARK: Send (async) + + /// Write raw data to the socket + /// + /// Sends data and waits for confirmation that it has been processed by the network stack. + /// + /// - Parameter data: Raw bytes to send + /// - Throws: `NetSocketError` if connection is not ready or send fails + public func write(_ data: Data) async throws { + try await ensureReady() + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + connection.send(content: data, completion: .contentProcessed { error in + if let error { cont.resume(throwing: NetSocketError.failed(underlying: error)) } + else { cont.resume() } + }) + } + } + + /// Write a fixed-width integer to the socket + /// + /// - Parameters: + /// - value: The integer value to write + /// - endian: Byte order (default: big-endian) + /// - Throws: `NetSocketError` if write fails + public func write(_ value: T, endian: Endian = .big) async throws { + var v = value + switch endian { + case .big: v = T(bigEndian: value) + case .little: v = T(littleEndian: value) + } + var copy = v + let size = MemoryLayout.size + let bytes = withUnsafePointer(to: ©) { + Data(bytes: $0, count: size) + } + try await write(bytes) + } + + /// Write a string to the socket, optionally length-prefixed + /// + /// - Parameters: + /// - string: String to write + /// - prefix: Optional length prefix (if provided, string is sent as a framed message) + /// - encoding: Text encoding (default: UTF-8) + /// - Throws: `NetSocketError` if encoding fails or write fails + public func write(_ string: String, prefix: LengthPrefix? = nil, encoding: String.Encoding = .utf8) async throws { + guard let data = string.data(using: encoding) else { + throw NetSocketError.encodeFailed(NSError(domain: "StringEncoding", code: -1)) + } + if let prefix { try await sendFrame(data, prefix: prefix) } + else { try await write(data) } + } + + // MARK: Frames & Codable + + /// Send a length-prefixed frame + /// + /// Writes the payload size as a fixed-width integer, followed by the payload bytes. + /// + /// - Parameters: + /// - payload: Data to send + /// - prefix: Length prefix type (default: u32 big-endian) + /// - Throws: `NetSocketError.framingExceeded` if payload is too large for prefix type + public func sendFrame(_ payload: Data, prefix: LengthPrefix = .u32()) async throws { + // Ensure frame payload does not exceed max frame length. + switch prefix { + case .u8 where payload.count > Int(UInt8.max): + throw NetSocketError.framingExceeded(max: Int(UInt8.max)) + case .u16 where payload.count > Int(UInt16.max): + throw NetSocketError.framingExceeded(max: Int(UInt16.max)) + case .u32 where payload.count > Int(UInt32.max): + throw NetSocketError.framingExceeded(max: Int(UInt32.max)) + default: + break + } + + if payload.count > cfg.maxFrameBytes { throw NetSocketError.framingExceeded(max: cfg.maxFrameBytes) } + var header = Data() + switch prefix { + case .u8: header.append(UInt8(payload.count)) + case .u16(let e): + try header.appendInteger(UInt16(payload.count), endian: e) + case .u32(let e): + try header.appendInteger(UInt32(payload.count), endian: e) + case .u64(let e): + try header.appendInteger(UInt64(payload.count), endian: e) + } + try await write(header + payload) + } + + /// Receive a length-prefixed frame + /// + /// Reads a length prefix, then reads exactly that many bytes. Waits for data to arrive if needed. + /// + /// - Parameter prefix: Length prefix type (default: u32 big-endian) + /// - Returns: The frame payload + /// - Throws: `NetSocketError.framingExceeded` if frame size exceeds maximum + public func receiveFrame(prefix: LengthPrefix = .u32()) async throws -> Data { + let length: Int + switch prefix { + case .u8: + let v: UInt8 = try await read(UInt8.self) + length = Int(v) + case .u16(let e): + let v: UInt16 = try await read(UInt16.self, endian: e) + length = Int(v) + case .u32(let e): + let v: UInt32 = try await read(UInt32.self, endian: e) + length = Int(v) + case .u64(let e): + let v: UInt64 = try await read(UInt64.self, endian: e) + if v > UInt64(cfg.maxFrameBytes) { throw NetSocketError.framingExceeded(max: cfg.maxFrameBytes) } + length = Int(v) + } + if length > cfg.maxFrameBytes { throw NetSocketError.framingExceeded(max: cfg.maxFrameBytes) } + return try await readExactly(length) + } + + /// Send an encodable value as a length-prefixed frame + /// + /// Uses the configured encoder (default: JSON) to serialize the value. + /// + /// - Parameters: + /// - value: Value to encode and send + /// - prefix: Length prefix type (default: u32 big-endian) + /// - Throws: `NetSocketError.encodeFailed` if encoding fails + public func send(_ value: T, prefix: LengthPrefix = .u32()) async throws { + do { + let data = try encodeValue(value) + try await sendFrame(data, prefix: prefix) + } catch { + throw NetSocketError.encodeFailed(error) + } + } + + /// Receive and decode a length-prefixed value + /// + /// Uses the configured decoder (default: JSON) to deserialize the value. + /// + /// - Parameters: + /// - type: Type to decode + /// - prefix: Length prefix type (default: u32 big-endian) + /// - Returns: Decoded value + /// - Throws: `NetSocketError.decodeFailed` if decoding fails + public func receive(_ type: T.Type, prefix: LengthPrefix = .u32()) async throws -> T { + let data = try await receiveFrame(prefix: prefix) + do { + let decoded = try decodeValue(data, T.self) + guard let result = decoded as? T else { + throw NetSocketError.decodeFailed(NSError( + domain: "NetSocketNew", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Type mismatch in decode"] + )) + } + return result + } catch { + throw NetSocketError.decodeFailed(error) + } + } + + // MARK: Read typed & utilities + + /// Read a fixed-width integer from the socket + /// + /// - Parameters: + /// - type: Integer type to read + /// - endian: Byte order (default: big-endian) + /// - Returns: The integer value + /// - Throws: `NetSocketError` if insufficient data or connection closed + public func read(_ type: T.Type = T.self, endian: Endian = .big) async throws -> T { + let size = MemoryLayout.size + let data = try await readExactly(size) + let value: T = data.withUnsafeBytes { raw in + raw.load(as: T.self) + } + switch endian { + case .big: return T(bigEndian: value) + case .little: return T(littleEndian: value) + } + } + + /// Read a fixed-length string + /// + /// - Parameters: + /// - length: Number of bytes to read + /// - encoding: Text encoding (default: UTF-8) + /// - Returns: Decoded string + /// - Throws: `NetSocketError` if decoding fails or insufficient data + public func readString(length: Int, encoding: String.Encoding = .utf8) async throws -> String { + let data = try await readExactly(length) + guard let s = String(data: data, encoding: encoding) else { throw NetSocketError.decodeFailed(NSError()) } + return s + } + + /// Read a string until a delimiter is found + /// + /// - Parameters: + /// - delimiter: Delimiter pattern to search for + /// - maxBytes: Maximum bytes to read before throwing (default: no limit) + /// - includeDelimiter: Whether to include delimiter in result (default: false) + /// - Returns: String read from stream (delimiter consumed but not included unless specified) + /// - Throws: `NetSocketError` if decoding fails, max bytes exceeded, or connection closed + public func readString(until delimiter: Delimiter, maxBytes: Int? = nil, includeDelimiter: Bool = false) async throws -> String { + let bytes = try await readUntil(delimiter: delimiter.data, maxBytes: maxBytes, includeDelimiter: includeDelimiter) + guard let s = String(data: bytes, encoding: .utf8) else { throw NetSocketError.decodeFailed(NSError()) } + return s + } + + /// Read data until a delimiter is found + /// + /// Searches the buffer for the delimiter pattern and returns all data up to (and optionally including) + /// the delimiter. The delimiter is always consumed from the stream. + /// + /// - Parameters: + /// - delimiter: Binary delimiter pattern to search for + /// - maxBytes: Maximum bytes to read before throwing (default: no limit) + /// - includeDelimiter: Whether to include delimiter in result (default: false) + /// - Returns: Data read from stream + /// - Throws: `NetSocketError.framingExceeded` if max bytes exceeded, or connection errors + public func readUntil(delimiter: Data, maxBytes: Int? = nil, includeDelimiter: Bool = false) async throws -> Data { + while true { + if let r = search(delimiter: delimiter) { + let consumeLen = r.upperBound - head + let data = try await readExactly(consumeLen) + return includeDelimiter ? data : data.dropLast(delimiter.count) + } + if let maxBytes, availableBytes >= maxBytes { + throw NetSocketError.framingExceeded(max: maxBytes) + } + try await waitForData() + guard !isClosed || availableBytes > 0 else { throw NetSocketError.closed } + } + } + + /// Read exactly N bytes from the socket + /// + /// Waits for data to arrive if buffer doesn't contain enough bytes yet. The internal buffer + /// is automatically compacted after reading to prevent unbounded memory growth. + /// + /// - Parameter count: Number of bytes to read + /// - Returns: Exactly `count` bytes + /// - Throws: `NetSocketError.insufficientData` if connection closes before enough data arrives + public func readExactly(_ count: Int) async throws -> Data { + try await ensureReadable(count) + let start = head + let end = head + count + let slice = buffer[start.. 0 else { return } + try await ensureReadable(count) + head += count + compactIfNeeded() + } + + /// Skip until delimiter is found (discards delimiter too) + public func skipUntil(delimiter: Data) async throws { + while true { + if let r = search(delimiter: delimiter) { + head = r.upperBound // Skip to end of delimiter + compactIfNeeded() + return + } + try await waitForData() + guard !isClosed else { throw NetSocketError.closed } + } + } + + func peek(_ count: Int) async throws -> Data { + try await ensureReadable(count) + let slice = buffer[head..<(head + count)] + return Data(slice) // Don't advance head + } + + // MARK: Internals + + private var availableBytes: Int { buffer.count - head } + + private func waitForData() async throws { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + if availableBytes > 0 || isClosed { cont.resume(); return } + dataWaiters.append(cont) + } + } + + private func ensureReadable(_ count: Int) async throws { + try await ensureReady() + while availableBytes < count { + if isClosed { throw NetSocketError.insufficientData(expected: count, got: availableBytes) } + try await waitForData() + } + } + + private func ensureReady() async throws { + if isClosed { throw NetSocketError.closed } + if !ready { try await waitUntilReady() } + } + + private func compactIfNeeded() { + // Avoid unbounded memory as head advances + if head > 64 * 1024 && head > buffer.count / 2 { + buffer.removeSubrange(0.. Range? { + guard !delimiter.isEmpty, availableBytes >= delimiter.count else { return nil } + let hay = buffer[head..(_ value: T, endian: Endian) throws { + var v = value + switch endian { + case .big: v = T(bigEndian: value) + case .little: v = T(littleEndian: value) + } + var copy = v + withUnsafePointer(to: ©) { ptr in + self.append(contentsOf: UnsafeRawBufferPointer(start: ptr, count: MemoryLayout.size)) + } + } +} + +public extension NetSocketNew { + /// Progress information for file uploads/downloads + struct FileProgress: Sendable { + /// Number of bytes sent/received so far + public let sent: Int64 + /// Total file size (may be nil if unknown) + public let total: Int64? + } + + /// Upload a file to the socket without framing (raw byte stream) + /// + /// Reads and writes the file in chunks to limit memory usage. Each chunk waits for network + /// backpressure via `.contentProcessed` before reading the next chunk. + /// + /// - Parameters: + /// - url: File URL to upload + /// - chunkSize: Chunk size for reading/writing (default: 256 KB) + /// - progress: Optional progress callback + /// - Returns: Total bytes sent + /// - Throws: File I/O or network errors + @discardableResult + func writeFile( + from url: URL, + chunkSize: Int = 256 * 1024, + progress: (@Sendable (FileProgress) -> Void)? = nil + ) async throws -> Int64 { + try await ensureReady() + let total = try? self.fileLength(at: url) + + let fh = try FileHandle(forReadingFrom: url) + defer { try? fh.close() } + + var sent: Int64 = 0 + while true { + try Task.checkCancellation() + guard let chunk = try fh.read(upToCount: chunkSize), !chunk.isEmpty else { break } + try await write(chunk) // uses .contentProcessed completion inside + sent += Int64(chunk.count) + progress?(.init(sent: sent, total: total)) + } + return sent + } + + /// Upload a file as a length-prefixed frame without buffering the entire file in memory + /// + /// Sends the file size as a length prefix, then streams the file content in chunks. + /// Memory-efficient for large files. + /// + /// - Parameters: + /// - url: File URL to upload + /// - lengthPrefix: Length prefix type (default: u64 big-endian) + /// - chunkSize: Chunk size for reading/writing (default: 256 KB) + /// - progress: Optional progress callback + /// - Returns: Total bytes sent (not including length header) + /// - Throws: File I/O, framing, or network errors + @discardableResult + func sendFileFramed( + _ url: URL, + lengthPrefix: LengthPrefix = .u64(.big), + chunkSize: Int = 256 * 1024, + progress: (@Sendable (FileProgress) -> Void)? = nil + ) async throws -> Int64 { + let total = try fileLength(at: url) + try ensure(total, fitsIn: lengthPrefix) + + // 1) Send the length header + var header = Data() + switch lengthPrefix { + case .u8: + header.append(UInt8(truncatingIfNeeded: total)) + case .u16(let e): + try header.appendInteger(UInt16(truncatingIfNeeded: total), endian: e) + case .u32(let e): + try header.appendInteger(UInt32(truncatingIfNeeded: total), endian: e) + case .u64(let e): + try header.appendInteger(UInt64(total), endian: e) + } + try await write(header) + + // 2) Stream the file bytes (raw) right after the header + let sent = try await writeFile(from: url, chunkSize: chunkSize) { prog in + progress?(prog) + } + return sent + } + + /// Download a length-prefixed file and write it to disk in chunks (bounded memory) + /// + /// Reads the file size from a length prefix, then streams the content directly to disk + /// in chunks to avoid loading the entire file into memory. + /// + /// - Parameters: + /// - url: Destination file URL + /// - lengthPrefix: Length prefix type (default: u64 big-endian) + /// - chunkSize: Chunk size for reading/writing (default: 256 KB) + /// - overwrite: Whether to overwrite existing file (default: true) + /// - progress: Optional progress callback + /// - Returns: Total bytes written + /// - Throws: File I/O, framing, or network errors + @discardableResult + func receiveFile( + to url: URL, + lengthPrefix: LengthPrefix = .u64(.big), + chunkSize: Int = 256 * 1024, + overwrite: Bool = true, + progress: (@Sendable (FileProgress) -> Void)? = nil + ) async throws -> Int64 { + // 1) Read length header + let total64: Int64 = try await { + switch lengthPrefix { + case .u8: return Int64(try await read(UInt8.self)) + case .u16(let e): return Int64(try await read(UInt16.self, endian: e)) + case .u32(let e): return Int64(try await read(UInt32.self, endian: e)) + case .u64(let e): + let v: UInt64 = try await read(UInt64.self, endian: e) + guard v <= UInt64(Int64.max) else { + throw NetSocketError.framingExceeded(max: Int(Int64.max)) + } + return Int64(v) + } + }() + + // 2) Prepare destination file + if overwrite { try? FileManager.default.removeItem(at: url) } + FileManager.default.createFile(atPath: url.path, contents: nil, attributes: nil) + let fh = try FileHandle(forWritingTo: url) + defer { try? fh.close() } + + // 3) Stream chunks from the socket into the file + var remaining = total64 + var written: Int64 = 0 + + while remaining > 0 { + try Task.checkCancellation() + let n = Int(min(Int64(chunkSize), remaining)) + let chunk = try await readExactly(n) // reuses your internal buffer, bounded by n + fh.write(chunk) + remaining -= Int64(n) + written += Int64(n) + progress?(.init(sent: written, total: total64)) + } + return written + } + + /// Download a file of known length and write it to disk in chunks + /// + /// Unlike `receiveFile()`, this method does **not** read a length prefix. The caller must + /// provide the expected file size (e.g., from protocol metadata). The file is streamed + /// directly to disk to avoid loading it entirely into memory. + /// + /// Supports atomic writes: when enabled, data is written to a temporary `.part` file and + /// renamed on success. If an error occurs, the temporary file is automatically cleaned up. + /// + /// - Parameters: + /// - url: Destination file URL + /// - length: Exact number of bytes to read (must match what's on the wire) + /// - chunkSize: Chunk size for reading/writing (default: 256 KB) + /// - overwrite: Whether to overwrite existing file (default: true) + /// - atomic: Write to temporary file and rename on success (default: true) + /// - progress: Optional progress callback + /// - Returns: Total bytes written (equals `length` on success) + /// - Throws: File I/O or network errors. On atomic writes, partial files are cleaned up. + /// + /// Example: + /// ```swift + /// // Hotline protocol: file size comes from transaction header + /// let transaction = try await socket.receive(HotlineTransaction.self) + /// try await socket.receiveFileKnownLength( + /// to: destinationURL, + /// length: transaction.fileSize + /// ) + /// ``` + @discardableResult + func receiveFileKnownLength( + to url: URL, + length: Int64, + chunkSize: Int = 256 * 1024, + overwrite: Bool = true, + atomic: Bool = true, + progress: (@Sendable (FileProgress) -> Void)? = nil + ) async throws -> Int64 { + precondition(length >= 0, "length must be >= 0") + + // Validate length doesn't exceed configured maximum + guard length <= cfg.maxFrameBytes else { + throw NetSocketError.framingExceeded(max: cfg.maxFrameBytes) + } + + // Fast path: nothing to do + if length == 0 { + if overwrite { try? FileManager.default.removeItem(at: url) } + FileManager.default.createFile(atPath: url.path, contents: Data(), attributes: nil) + return 0 + } + + // Prepare destination (optionally atomic) + let fm = FileManager.default + let dir = url.deletingLastPathComponent() + let tmp = atomic + ? dir.appendingPathComponent(".\(url.lastPathComponent).part-\(UUID().uuidString)") + : url + + if overwrite { try? fm.removeItem(at: tmp) } + if overwrite, !atomic { try? fm.removeItem(at: url) } + + // Create and open the file for writing + fm.createFile(atPath: tmp.path, contents: nil, attributes: nil) + let fh = try FileHandle(forWritingTo: tmp) + defer { try? fh.close() } + + var remaining = length + var written: Int64 = 0 + + do { + while remaining > 0 { + try Task.checkCancellation() + let n = Int(min(Int64(chunkSize), remaining)) + let chunk = try await readExactly(n) + try fh.write(chunk) + remaining -= Int64(n) + written += Int64(n) + progress?(.init(sent: written, total: length)) + } + } catch { + // Cleanup partial file on failure if we were writing atomically + if atomic { try? fm.removeItem(at: tmp) } + throw error + } + + // Atomically move into place if requested + if atomic { + if overwrite { try? fm.removeItem(at: url) } + try fm.moveItem(at: tmp, to: url) + } + + return written + } +} + +// MARK: - Small helpers (private) +fileprivate extension NetSocketNew { + func fileLength(at url: URL) throws -> Int64 { + let values = try url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) + guard values.isRegularFile == true else { + throw NetSocketError.failed(underlying: NSError( + domain: "CleanSockets", code: 1001, + userInfo: [NSLocalizedDescriptionKey: "Not a regular file: \(url.path)"] + )) + } + if let s = values.fileSize { return Int64(s) } + let attrs = try FileManager.default.attributesOfItem(atPath: url.path) + if let n = attrs[.size] as? NSNumber { return n.int64Value } + throw NetSocketError.failed(underlying: NSError( + domain: "CleanSockets", code: 1002, + userInfo: [NSLocalizedDescriptionKey: "Unable to determine file size for \(url.lastPathComponent)"] + )) + } + + func ensure(_ length: Int64, fitsIn prefix: LengthPrefix) throws { + let max: Int64 = { + switch prefix { + case .u8: return Int64(UInt8.max) + case .u16: return Int64(UInt16.max) + case .u32: return Int64(UInt32.max) + case .u64: return Int64.max + } + }() + if length > max { + throw NetSocketError.framingExceeded(max: Int(max)) + } + } +} + +/// Errors that can occur during binary encoding/decoding +public enum BinaryError: Error, CustomStringConvertible, Sendable { + /// Attempted to read past end of data + case outOfBounds(need: Int, have: Int) + /// String encoding/decoding failed + case badStringEncoding + /// Invalid value encountered + case invalidValue(String) + + public var description: String { + switch self { + case .outOfBounds(let need, let have): return "Out of bounds: need \(need), have \(have)" + case .badStringEncoding: return "String decoding failed" + case .invalidValue(let s): return "Invalid value: \(s)" + } + } +} + +/// Protocol for types that can encode themselves to binary format +public protocol BinaryEncodable: Sendable { + /// Encode this value to a ByteWriter + /// - Parameters: + /// - w: Writer to encode to + /// - endian: Byte order for multi-byte values + func encode(to w: inout ByteWriter, endian: Endian) +} + +/// Protocol for types that can decode themselves from binary format +public protocol BinaryDecodable: Sendable { + /// Decode a value from a ByteReader + /// - Parameters: + /// - r: Reader to decode from + /// - endian: Byte order for multi-byte values + init(from r: inout ByteReader, endian: Endian) throws +} + +/// Convenience typealias for types that are both encodable and decodable +public typealias BinaryCodable = BinaryEncodable & BinaryDecodable + +extension UInt8: BinaryCodable { + public func encode(to w: inout ByteWriter, endian: Endian) { + w.write(self, endian: endian) + } + public init(from r: inout ByteReader, endian: Endian) throws { + self = try r.read(UInt8.self, endian: endian) + } +} + +extension UInt16: BinaryCodable { + public func encode(to w: inout ByteWriter, endian: Endian) { + w.write(self, endian: endian) + } + public init(from r: inout ByteReader, endian: Endian) throws { + self = try r.read(UInt16.self, endian: endian) + } +} + +extension UInt32: BinaryCodable { + public func encode(to w: inout ByteWriter, endian: Endian) { + w.write(self, endian: endian) + } + public init(from r: inout ByteReader, endian: Endian) throws { + self = try r.read(UInt32.self, endian: endian) + } +} + +/// A writer for building binary data structures +/// +/// ByteWriter provides methods to serialize integers, floats, strings, and arrays into binary format. +/// All multi-byte values are written in the specified endianness. +/// +/// Example: +/// ```swift +/// var w = ByteWriter() +/// w.write(UInt16(100), endian: .big) +/// try w.write("Hello", prefix: .u16(.big)) +/// let data = w.finalize() +/// ``` +public struct ByteWriter: Sendable { + /// The accumulated binary data + public private(set) var data = Data() + + public init() {} + + /// Write a fixed-width integer + /// - Parameters: + /// - v: Integer value + /// - endian: Byte order (default: big-endian) + public mutating func write(_ v: T, endian: Endian = .big) { + var x = v + switch endian { + case .big: x = T(bigEndian: v) + case .little: x = T(littleEndian: v) + } + withUnsafeBytes(of: x) { data.append(contentsOf: $0) } + } + + /// Write a boolean as a single byte (0 or 1) + /// - Parameter value: Boolean value + public mutating func write(_ value: Bool) { + data.append(value ? 1 : 0) + } + + /// Write a Float as its IEEE 754 bit pattern + /// - Parameters: + /// - value: Float value + /// - endian: Byte order (default: big-endian) + public mutating func write(_ value: Float, endian: Endian = .big) { + write(value.bitPattern, endian: endian) + } + + /// Write a Double as its IEEE 754 bit pattern + /// - Parameters: + /// - value: Double value + /// - endian: Byte order (default: big-endian) + public mutating func write(_ value: Double, endian: Endian = .big) { + write(value.bitPattern, endian: endian) + } + + /// Write raw bytes, optionally with a length prefix + /// - Parameters: + /// - bytes: Data to write + /// - prefix: Optional length prefix + /// - endian: Byte order for length prefix (ignored if prefix is nil) + /// - Throws: `BinaryError.invalidValue` if data size exceeds prefix capacity + public mutating func write(_ bytes: Data, prefix: LengthPrefix? = nil, endian: Endian = .big) throws { + if let prefix { + // Validate size fits in prefix type + switch prefix { + case .u8 where bytes.count > Int(UInt8.max): + throw BinaryError.invalidValue("Data size \(bytes.count) exceeds u8 max (255)") + case .u16 where bytes.count > Int(UInt16.max): + throw BinaryError.invalidValue("Data size \(bytes.count) exceeds u16 max (65535)") + case .u32 where bytes.count > Int(UInt32.max): + throw BinaryError.invalidValue("Data size \(bytes.count) exceeds u32 max") + default: + break + } + + switch prefix { + case .u8: data.append(UInt8(truncatingIfNeeded: bytes.count)) + case .u16(let e): write(UInt16(bytes.count), endian: e) + case .u32(let e): write(UInt32(bytes.count), endian: e) + case .u64(let e): write(UInt64(bytes.count), endian: e) + } + } + data.append(bytes) + } + + /// Write a length-prefixed string + /// - Parameters: + /// - string: String to write + /// - prefix: Length prefix type (default: u16 big-endian) + /// - encoding: Text encoding (default: UTF-8) + /// - Throws: `BinaryError.badStringEncoding` if encoding fails, or size validation errors + public mutating func write(_ string: String, prefix: LengthPrefix = .u16(), encoding: String.Encoding = .utf8) throws { + guard let d = string.data(using: encoding) else { + throw BinaryError.badStringEncoding + } + try write(d, prefix: prefix) + } + + /// Write an array of encodable elements with element count prefix + /// + /// The prefix contains the **number of elements**, not the byte size. Each element is + /// encoded by calling its `encode(to:endian:)` method. + /// + /// - Parameters: + /// - array: Array of elements to write + /// - prefix: Length prefix for element count (default: u32 big-endian) + /// - endian: Byte order for multi-byte values within elements + /// - Throws: `BinaryError.invalidValue` if array count exceeds prefix capacity + public mutating func write(_ array: [Element], prefix: LengthPrefix = .u32(), endian: Endian = .big) throws { + // Validate array count fits in prefix type + switch prefix { + case .u8 where array.count > Int(UInt8.max): + throw BinaryError.invalidValue("Array count \(array.count) exceeds u8 max (255)") + case .u16 where array.count > Int(UInt16.max): + throw BinaryError.invalidValue("Array count \(array.count) exceeds u16 max (65535)") + case .u32 where array.count > Int(UInt32.max): + throw BinaryError.invalidValue("Array count \(array.count) exceeds u32 max") + default: + break + } + + // Number of elements, not byte length: + switch prefix { + case .u8: data.append(UInt8(array.count)) + case .u16(let e): write(UInt16(array.count), endian: e) + case .u32(let e): write(UInt32(array.count), endian: e) + case .u64(let e): write(UInt64(array.count), endian: e) + } + for el in array { el.encode(to: &self, endian: endian) } + } + + /// Get the final binary data + /// - Returns: All accumulated binary data + public func finalize() -> Data { data } +} + +/// A reader for parsing binary data structures +/// +/// ByteReader provides methods to deserialize integers, floats, strings, and arrays from binary format. +/// The reader maintains an offset that automatically advances as data is read. +/// +/// Example: +/// ```swift +/// var r = ByteReader(data: binaryData) +/// let value = try r.read(UInt16.self, endian: .big) +/// let text = try r.readString(prefix: .u16(.big)) +/// ``` +public struct ByteReader: Sendable { + /// The binary data being read + public let data: Data + /// Current read position + public private(set) var offset: Int = 0 + + public init(data: Data) { self.data = data } + + @inline(__always) + private mutating func take(_ count: Int) throws -> UnsafeRawBufferPointer { + guard offset + count <= data.count else { throw BinaryError.outOfBounds(need: count, have: data.count - offset) } + let start = data.startIndex + offset + offset += count + return data[start..<(start + count)].withUnsafeBytes { $0 } + } + + /// Read a fixed-width integer + /// - Parameters: + /// - type: Integer type to read + /// - endian: Byte order (default: big-endian) + /// - Returns: Decoded integer value + /// - Throws: `BinaryError.outOfBounds` if insufficient data + public mutating func read(_ type: T.Type = T.self, endian: Endian = .big) throws -> T { + let size = MemoryLayout.size + let raw = try take(size) + let v = raw.load(as: T.self) + switch endian { + case .big: return T(bigEndian: v) + case .little: return T(littleEndian: v) + } + } + + /// Read a boolean (0 = false, non-zero = true) + /// - Returns: Boolean value + /// - Throws: `BinaryError.outOfBounds` if insufficient data + public mutating func readBool() throws -> Bool { + let b: UInt8 = try read() + return b != 0 + } + + /// Read a Float from its IEEE 754 bit pattern + /// - Parameters: + /// - type: Float type (for type inference) + /// - endian: Byte order (default: big-endian) + /// - Returns: Float value + /// - Throws: `BinaryError.outOfBounds` if insufficient data + public mutating func read(_ type: Float.Type = Float.self, endian: Endian = .big) throws -> Float { + let bits: UInt32 = try read(endian: endian) + return Float(bitPattern: bits) + } + + /// Read a Double from its IEEE 754 bit pattern + /// - Parameters: + /// - type: Double type (for type inference) + /// - endian: Byte order (default: big-endian) + /// - Returns: Double value + /// - Throws: `BinaryError.outOfBounds` if insufficient data + public mutating func read(_ type: Double.Type = Double.self, endian: Endian = .big) throws -> Double { + let bits: UInt64 = try read(endian: endian) + return Double(bitPattern: bits) + } + + /// Read a fixed number of raw bytes + /// - Parameter count: Number of bytes to read + /// - Returns: Data containing exactly `count` bytes + /// - Throws: `BinaryError.outOfBounds` if insufficient data + public mutating func readData(count: Int) throws -> Data { + let raw = try take(count) + return Data(raw) + } + + /// Read length-prefixed raw bytes + /// - Parameters: + /// - prefix: Length prefix type + /// - endian: Byte order for length prefix + /// - Returns: Data of the specified length + /// - Throws: `BinaryError` if prefix value is invalid or insufficient data + public mutating func readData(prefix: LengthPrefix, endian: Endian = .big) throws -> Data { + let len: Int + switch prefix { + case .u8: len = Int(try read(UInt8.self)) + case .u16(let e): len = Int(try read(UInt16.self, endian: e)) + case .u32(let e): len = Int(try read(UInt32.self, endian: e)) + case .u64(let e): + let v: UInt64 = try read(UInt64.self, endian: e) + if v > Int.max { throw BinaryError.invalidValue("u64 length too large") } + len = Int(v) + } + return try readData(count: len) + } + + /// Read a length-prefixed string + /// - Parameters: + /// - prefix: Length prefix type (default: u16 big-endian) + /// - encoding: Text encoding (default: UTF-8) + /// - Returns: Decoded string + /// - Throws: `BinaryError` if decoding fails or insufficient data + public mutating func readString(prefix: LengthPrefix = .u16(), + encoding: String.Encoding = .utf8) throws -> String { + let d = try readData(prefix: prefix) + guard let s = String(data: d, encoding: encoding) else { throw BinaryError.badStringEncoding } + return s + } + + /// Read an array of decodable elements + /// + /// The prefix contains the **number of elements**, not the byte size. + /// + /// - Parameters: + /// - type: Element type to decode + /// - prefix: Length prefix for element count (default: u32 big-endian) + /// - endian: Byte order for multi-byte values within elements + /// - Returns: Array of decoded elements + /// - Throws: `BinaryError` if count is invalid, decoding fails, or insufficient data + public mutating func readArray(_ type: Element.Type, + prefix: LengthPrefix = .u32(), + endian: Endian = .big) throws -> [Element] { + let count: Int + switch prefix { + case .u8: count = Int(try read(UInt8.self)) + case .u16(let e): count = Int(try read(UInt16.self, endian: e)) + case .u32(let e): count = Int(try read(UInt32.self, endian: e)) + case .u64(let e): + let v: UInt64 = try read(UInt64.self, endian: e) + if v > Int.max { throw BinaryError.invalidValue("array count too large") } + count = Int(v) + } + var out: [Element] = [] + out.reserveCapacity(count) + for _ in 0..= data.count } +} + +public extension NetSocketNew { + /// Send a binary-encodable value as a length-prefixed frame + /// - Parameters: + /// - value: Value conforming to BinaryEncodable + /// - endian: Byte order (default: big-endian) + /// - prefix: Length prefix type (default: u32 big-endian) + /// - Throws: Encoding or network errors + func sendBinary(_ value: T, endian: Endian = .big, prefix: LengthPrefix = .u32()) async throws { + var w = ByteWriter() + value.encode(to: &w, endian: endian) + try await sendFrame(w.finalize(), prefix: prefix) + } + + /// Receive and decode a binary value from a length-prefixed frame + /// - Parameters: + /// - type: Type conforming to BinaryDecodable + /// - endian: Byte order (default: big-endian) + /// - prefix: Length prefix type (default: u32 big-endian) + /// - Returns: Decoded value + /// - Throws: Decoding or network errors + func receiveBinary(_ type: T.Type, endian: Endian = .big, prefix: LengthPrefix = .u32()) async throws -> T { + let data = try await receiveFrame(prefix: prefix) + var r = ByteReader(data: data) + return try T(from: &r, endian: endian) + } +} -- cgit From b7e909cccfaa8611522f488134f9f28f643b83eb Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Sun, 26 Oct 2025 23:06:59 -0700 Subject: Rewrite HotlineTrackerClient using new async NetSocket as proof of concept. --- Hotline/Hotline/HotlineTrackerClient.swift | 393 ++++++++++++++++------------- Hotline/Library/NetSocketNew.swift | 37 ++- Hotline/Models/Bookmark.swift | 7 +- Hotline/Models/Hotline.swift | 29 ++- Hotline/macOS/TrackerView.swift | 56 +++- 5 files changed, 321 insertions(+), 201 deletions(-) diff --git a/Hotline/Hotline/HotlineTrackerClient.swift b/Hotline/Hotline/HotlineTrackerClient.swift index 3c1ad85..110ca21 100644 --- a/Hotline/Hotline/HotlineTrackerClient.swift +++ b/Hotline/Hotline/HotlineTrackerClient.swift @@ -5,224 +5,259 @@ struct HotlineTracker: Identifiable, Equatable { let id: UUID = UUID() var address: String var port: UInt16 - + init(_ address: String, port: UInt16 = 5498) { self.address = address self.port = port } - + static func == (lhs: HotlineTracker, rhs: HotlineTracker) -> Bool { return lhs.address == rhs.address && lhs.port == rhs.port } } -enum HotlineTrackerStatus: Int { - case disconnected - case connecting - case connected -} - -private enum HotlineTrackerStage { - case magic - case header - case listing - case done -} +/// Client for Hotline trackers +/// +/// The tracker protocol: +/// 1. Client sends magic: "HTRK" + version (0x0001) +/// 2. Server echoes magic back +/// 3. Server sends header: message type, data length, server count +/// 4. Server sends listing: array of server records class HotlineTrackerClient { static let MagicPacket: [UInt8] = [ 0x48, 0x54, 0x52, 0x4B, // 'HTRK' 0x00, 0x01 // Version ] - + private var tracker: HotlineTracker - private var connectionStatus: HotlineTrackerStatus = .disconnected - private var servers: [HotlineServer] = [] - - private var socket: NetSocket = NetSocket() - private var stage: HotlineTrackerStage = .magic - - private var serverAddress: String - private var serverPort: Int - private var expectedDataLength: Int = 0 - private var serverCount: Int = 0 - - private var fetchContinuation: CheckedContinuation<[HotlineServer], any Error>? - + init() { - let t = HotlineTracker("hltracker.com") - self.tracker = t - self.serverAddress = t.address - self.serverPort = Int(t.port) - self.socket.delegate = self + self.tracker = HotlineTracker("hltracker.com") } - + init(tracker: HotlineTracker) { self.tracker = tracker - self.serverAddress = tracker.address - self.serverPort = Int(tracker.port) - self.socket.delegate = self } - - @MainActor func fetchServers(address: String, port: Int) async throws -> [HotlineServer] { - self.serverAddress = address - self.serverPort = Int(port) - - self.reset() - - return try await withCheckedThrowingContinuation { [weak self] continuation in - self?.fetchContinuation = continuation - self?.connect() + + /// Fetch server list from the tracker + /// - Parameters: + /// - address: Tracker hostname or IP + /// - port: Tracker port (default: 5498) + /// - Returns: AsyncThrowingStream of servers as they arrive from the tracker + /// - Throws: Network or protocol errors + func fetchServers(address: String, port: Int) -> AsyncThrowingStream { + return AsyncThrowingStream { continuation in + let task = Task { + await self.fetchServersInternal(address: address, port: port, continuation: continuation) + } + + continuation.onTermination = { @Sendable _ in + task.cancel() + } } } - - @MainActor func close() { - self.socket.close() - } - - // MARK: - - - @MainActor private func reset() { - self.expectedDataLength = 0 - self.serverCount = 0 - self.servers = [] - } - - @MainActor private func connect() { - self.socket.close() - - self.connectionStatus = .connecting - self.socket.connect(host: self.serverAddress, port: self.serverPort) - self.socket.write(HotlineTrackerClient.MagicPacket) - } - - @MainActor private func receiveMagic() { - guard self.stage == .magic, self.socket.available >= HotlineTrackerClient.MagicPacket.count else { - return - } - - let magic: [UInt8] = self.socket.read(count: HotlineTrackerClient.MagicPacket.count) - - if magic != HotlineTrackerClient.MagicPacket { - self.socket.close() - return + + private func fetchServersInternal(address: String, port: Int, continuation: AsyncThrowingStream.Continuation) async { + do { + // Add timeout wrapper + try await withTimeout(seconds: 30) { + try await self.doFetch(address: address, port: port, continuation: continuation) + } + } catch { + print("HotlineTrackerClient: Error in fetchServersInternal: \(error)") + continuation.finish(throwing: error) } - - self.stage = .header - self.receiveHeader() } - - @MainActor private func receiveHeader() { - guard self.stage == .header, self.socket.available >= 8 else { - return - } - - var header: [UInt8] = self.socket.read(count: 8) - - guard let messageType = header.consumeUInt16(), - let dataLength = header.consumeUInt16(), - let numberOfServers = header.consumeUInt16(), - let numberOfServers2 = header.consumeUInt16() else { - self.socket.close() - return + + private func doFetch(address: String, port: Int, continuation: AsyncThrowingStream.Continuation) async throws { + // Connect to tracker (plaintext, no TLS) + let socket = try await NetSocketNew.connect( + host: address, + port: UInt16(port), + tls: .disabled + ) + defer { Task { await socket.close() } } + + // Send magic packet + try await socket.write(Data(HotlineTrackerClient.MagicPacket)) + + // Receive magic response (6 bytes: 'HTRK' + version) + let magicResponse = try await socket.readExactly(6) + let magic = magicResponse[0..<4] + let version = UInt16(magicResponse[4]) << 8 | UInt16(magicResponse[5]) + + // Validate magic ('HTRK') + guard magic == Data([0x48, 0x54, 0x52, 0x4B]) else { + throw NetSocketError.decodeFailed( + NSError(domain: "HotlineTracker", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Invalid magic response from tracker (expected 'HTRK')" + ]) + ) } - - print("HotlineTrackerClient: Received response header ", messageType, dataLength, numberOfServers, numberOfServers2) - - self.expectedDataLength = Int(dataLength) - self.expectedDataLength -= 4 // Remove the size of the two server count fields - self.serverCount = Int(numberOfServers) - - self.stage = .listing - self.receiveListing() + + print("HotlineTrackerClient: Connected to tracker (version \(version))") + + // Read server listings (may span multiple batches) + var totalYielded = 0 + var totalEntriesParsed = 0 // Includes separators + var totalExpectedEntries: Int = 0 + var batchCount = 0 + + repeat { + batchCount += 1 + + // Receive server information header (8 bytes) + // Format: [message type: u16][data length: u16][server count: u16][server count 2: u16] + let messageType = try await socket.read(UInt16.self, endian: .big) + let dataLength = try await socket.read(UInt16.self, endian: .big) + let serverCount = try await socket.read(UInt16.self, endian: .big) + let serverCount2 = try await socket.read(UInt16.self, endian: .big) + + // First header tells us the total expected entries (includes separators) + if totalExpectedEntries == 0 { + totalExpectedEntries = Int(serverCount) + } + + print("HotlineTrackerClient: Batch #\(batchCount) - type: \(messageType), dataLen: \(dataLength), count1: \(serverCount), count2: \(serverCount2)") + + // Calculate actual listing data length (excludes the two server count fields) + let listingLength = Int(dataLength) - 4 + print("HotlineTrackerClient: About to read \(listingLength) bytes of listing data...") + + // Receive listing data + let listingData = try await socket.readExactly(listingLength) + print("HotlineTrackerClient: Successfully read \(listingData.count) bytes") + + // Parse and yield servers one at a time for true progressive streaming + var bytes = Array(listingData) + let trackerSeparatorRegex = /^[-]+$/ + var batchEntriesParsed = 0 + var batchServersYielded = 0 + + for _ in 0..= 100 { + print("HotlineTrackerClient: WARNING - Stopped after 100 batches") + break + } + + } while totalEntriesParsed < totalExpectedEntries + + print("HotlineTrackerClient: Completed - parsed \(totalEntriesParsed)/\(totalExpectedEntries) entries, yielded \(totalYielded) servers") + continuation.finish() } - - @MainActor private func receiveListing() { - guard self.stage == .listing, self.socket.available >= self.expectedDataLength else { - return + + private func withTimeout(seconds: TimeInterval, operation: @escaping () async throws -> T) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { + try await operation() + } + + group.addTask { + try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + throw NSError(domain: "HotlineTracker", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "Tracker request timed out after \(seconds) seconds" + ]) + } + + let result = try await group.next()! + group.cancelAll() + return result } - - self.parseListing(self.socket.read(count: self.expectedDataLength)) } - - @MainActor private func parseListing(_ listingBytes: [UInt8]) { - // IP address (4 bytes) - // Port number (2 bytes) - // Number of users (2 bytes) - // Unused (2 bytes) - // Name size (1 byte) - // Name (name size) - // Description size (1 byte) - // Description (description size) - - var bytes: [UInt8] = listingBytes + + /// Parse tracker listing data into array of servers + /// - Parameters: + /// - data: Raw listing data + /// - serverCount: Expected number of entries (including separators) + /// - Returns: Tuple of (servers array, total entries parsed including separators) + /// - Throws: Parsing errors + private func parseListing(_ data: Data, serverCount: Int) throws -> (servers: [HotlineServer], entriesParsed: Int) { + // Server record format: + // - IP address (4 bytes) + // - Port number (2 bytes) + // - Number of users (2 bytes) + // - Unused (2 bytes) + // - Name size (1 byte) + // - Name (name size) + // - Description size (1 byte) + // - Description (description size) + + var bytes = Array(data) let trackerSeparatorRegex = /^[-]+$/ - var foundServers: [HotlineServer] = [] - - for _ in 1...self.serverCount { + var servers: [HotlineServer] = [] + var entriesParsed = 0 + + for _ in 0.. cfg.maxBufferBytes { - // Hard stop: drop connection rather than OOM’ing. + // Hard stop: drop connection rather than OOM'ing. isClosed = true connection.cancel() failAllWaiters(NetSocketError.framingExceeded(max: cfg.maxBufferBytes)) @@ -363,10 +366,13 @@ public actor NetSocketNew { // MARK: Close - /// Close the connection + /// Close the connection gracefully /// - /// Cancels the underlying network connection and wakes all pending read/write operations - /// with a `NetSocketError.closed` error. This method is idempotent. + /// Performs a graceful shutdown of the underlying network connection (e.g., TCP FIN) + /// and wakes all pending read/write operations with a `NetSocketError.closed` error. + /// This method is idempotent - subsequent calls are ignored. + /// + /// Use `forceClose()` for immediate non-graceful termination (e.g., TCP RST). public func close() { guard !isClosed else { return } isClosed = true @@ -375,6 +381,21 @@ public actor NetSocketNew { resumeReadyWaiters(with: .failure(NetSocketError.closed)) } + /// Force close the connection immediately (non-graceful) + /// + /// Performs an immediate non-graceful shutdown of the underlying network connection + /// (e.g., TCP RST). Use this when you need to terminate the connection immediately + /// without waiting for graceful closure. For normal shutdown, use `close()` instead. + /// + /// This method is idempotent - subsequent calls are ignored. + public func forceClose() { + guard !isClosed else { return } + isClosed = true + connection.forceCancel() + resumeDataWaiters() + resumeReadyWaiters(with: .failure(NetSocketError.closed)) + } + // MARK: Send (async) /// Write raw data to the socket @@ -597,6 +618,7 @@ public actor NetSocketNew { /// - Throws: `NetSocketError.framingExceeded` if max bytes exceeded, or connection errors public func readUntil(delimiter: Data, maxBytes: Int? = nil, includeDelimiter: Bool = false) async throws -> Data { while true { + try Task.checkCancellation() if let r = search(delimiter: delimiter) { let consumeLen = r.upperBound - head let data = try await readExactly(consumeLen) @@ -639,6 +661,7 @@ public actor NetSocketNew { /// Skip until delimiter is found (discards delimiter too) public func skipUntil(delimiter: Data) async throws { while true { + try Task.checkCancellation() if let r = search(delimiter: delimiter) { head = r.upperBound // Skip to end of delimiter compactIfNeeded() @@ -660,8 +683,9 @@ public actor NetSocketNew { private var availableBytes: Int { buffer.count - head } private func waitForData() async throws { + try Task.checkCancellation() try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in - if availableBytes > 0 || isClosed { cont.resume(); return } + if isClosed { cont.resume(); return } dataWaiters.append(cont) } } @@ -669,6 +693,7 @@ public actor NetSocketNew { private func ensureReadable(_ count: Int) async throws { try await ensureReady() while availableBytes < count { + try Task.checkCancellation() if isClosed { throw NetSocketError.insufficientData(expected: count, got: availableBytes) } try await waitForData() } @@ -945,7 +970,7 @@ public extension NetSocketNew { try Task.checkCancellation() let n = Int(min(Int64(chunkSize), remaining)) let chunk = try await readExactly(n) - try fh.write(chunk) + fh.write(chunk) remaining -= Int64(n) written += Int64(n) progress?(.init(sent: written, total: length)) diff --git a/Hotline/Models/Bookmark.swift b/Hotline/Models/Bookmark.swift index 80963f9..6063f48 100644 --- a/Hotline/Models/Bookmark.swift +++ b/Hotline/Models/Bookmark.swift @@ -341,13 +341,16 @@ final class Bookmark { var fetchedBookmarks: [BookmarkServer] = [] let client = HotlineTrackerClient() - if let fetchedServers: [HotlineServer] = try? await client.fetchServers(address: self.address, port: self.port) { - for fetchedServer in fetchedServers { + do { + for try await fetchedServer in client.fetchServers(address: self.address, port: self.port) { + print("FETCHED SERVER", fetchedServer) if let serverName = fetchedServer.name { let server = Server(name: serverName, description: fetchedServer.description, address: fetchedServer.address, port: Int(fetchedServer.port), users: Int(fetchedServer.users)) fetchedBookmarks.append(BookmarkServer(server: server)) } } + } catch { + print("Failed to fetch servers from tracker: \(error)") } return fetchedBookmarks diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift index 3d1f309..596df68 100644 --- a/Hotline/Models/Hotline.swift +++ b/Hotline/Models/Hotline.swift @@ -221,20 +221,35 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega @MainActor func getServerList(tracker: String, port: Int = HotlinePorts.DefaultTrackerPort) async -> [Server] { var servers: [Server] = [] - - if let fetchedServers: [HotlineServer] = try? await self.trackerClient.fetchServers(address: tracker, port: port) { - for s in fetchedServers { - if let serverName = s.name { - servers.append(Server(name: serverName, description: s.description, address: s.address, port: Int(s.port), users: Int(s.users))) + print("Hotline.getServerList: Starting fetch from \(tracker):\(port)") + + do { + for try await hotlineServer in self.trackerClient.fetchServers(address: tracker, port: port) { + if let serverName = hotlineServer.name { + servers.append(Server( + name: serverName, + description: hotlineServer.description, + address: hotlineServer.address, + port: Int(hotlineServer.port), + users: Int(hotlineServer.users) + )) + if servers.count % 10 == 0 { + print("Hotline.getServerList: Collected \(servers.count) servers so far...") + } } } + } catch { + print("Hotline.getServerList: Error - \(error)") } - + + print("Hotline.getServerList: Returning \(servers.count) servers") return servers } @MainActor func disconnectTracker() { - self.trackerClient.close() + // No-op: HotlineTrackerClient now uses async/await and manages + // connections internally. Each fetchServers() call opens and closes + // its own connection automatically. } @MainActor func login(server: Server, username: String, iconID: Int, callback: ((Bool) -> Void)? = nil) { diff --git a/Hotline/macOS/TrackerView.swift b/Hotline/macOS/TrackerView.swift index 5e848de..ae653b8 100644 --- a/Hotline/macOS/TrackerView.swift +++ b/Hotline/macOS/TrackerView.swift @@ -32,6 +32,7 @@ struct TrackerView: View { @State private var expandedTrackers: Set = [] @State private var trackerServers: [Bookmark: [BookmarkServer]] = [:] @State private var loadingTrackers: Set = [] + @State private var fetchTasks: [Bookmark: Task] = [:] @State private var searchText: String = "" @State private var isSearching = false @@ -67,6 +68,7 @@ struct TrackerView: View { private func filteredServers(for bookmark: Bookmark) -> [BookmarkServer] { let servers = self.trackerServers[bookmark] ?? [] + print("TrackerView.filteredServers: Looking up servers for \(bookmark.name), found \(servers.count) servers") guard !self.searchText.isEmpty else { return servers @@ -90,7 +92,8 @@ struct TrackerView: View { TrackerItemView( bookmark: bookmark, isExpanded: self.expandedTrackers.contains(bookmark), - isLoading: self.loadingTrackers.contains(bookmark) + isLoading: self.loadingTrackers.contains(bookmark), + count: self.trackerServers[bookmark]?.count ?? 0 ) { self.toggleExpanded(for: bookmark) } @@ -410,10 +413,12 @@ struct TrackerView: View { case .bookmark(let bookmark): if bookmark.type == .tracker { if self.expandedTrackers.contains(bookmark) { - // Already expanded, just refresh the servers - Task { + // Already expanded, cancel old fetch and start new one + self.fetchTasks[bookmark]?.cancel() + let task = Task { await self.fetchServers(for: bookmark) } + self.fetchTasks[bookmark] = task } else { // Not expanded, expand it (which also fetches) self.setExpanded(true, for: bookmark) @@ -430,10 +435,12 @@ struct TrackerView: View { for bookmark in self.bookmarks { if bookmark.type == .tracker { if self.expandedTrackers.contains(bookmark) { - // Already expanded, just refresh the servers - Task { + // Already expanded, cancel old fetch and start new one + self.fetchTasks[bookmark]?.cancel() + let task = Task { await self.fetchServers(for: bookmark) } + self.fetchTasks[bookmark] = task } else { // Not expanded, expand it (which also fetches) self.setExpanded(true, for: bookmark) @@ -446,13 +453,19 @@ struct TrackerView: View { guard bookmark.type == .tracker else { return } if self.expandedTrackers.contains(bookmark) { + // Collapse: cancel ongoing fetch and clear data + self.fetchTasks[bookmark]?.cancel() + self.fetchTasks[bookmark] = nil self.expandedTrackers.remove(bookmark) self.trackerServers[bookmark] = nil + self.loadingTrackers.remove(bookmark) } else { + // Expand: start fetch task self.expandedTrackers.insert(bookmark) - Task { + let task = Task { await self.fetchServers(for: bookmark) } + self.fetchTasks[bookmark] = task } } @@ -461,21 +474,32 @@ struct TrackerView: View { if expanded && !self.expandedTrackers.contains(bookmark) { self.expandedTrackers.insert(bookmark) - Task { + let task = Task { await self.fetchServers(for: bookmark) } + self.fetchTasks[bookmark] = task } else if !expanded && self.expandedTrackers.contains(bookmark) { + // Cancel ongoing fetch and clear data + self.fetchTasks[bookmark]?.cancel() + self.fetchTasks[bookmark] = nil self.expandedTrackers.remove(bookmark) self.trackerServers[bookmark] = nil + self.loadingTrackers.remove(bookmark) } } private func fetchServers(for bookmark: Bookmark) async { + print("TrackerView.fetchServers: Starting fetch for bookmark: \(bookmark.name)") self.loadingTrackers.insert(bookmark) let servers = await bookmark.fetchServers() + print("TrackerView.fetchServers: Got \(servers.count) servers from bookmark.fetchServers()") await MainActor.run { + print("TrackerView.fetchServers: Assigning \(servers.count) servers to trackerServers[\(bookmark.name)]") self.trackerServers[bookmark] = servers self.loadingTrackers.remove(bookmark) + self.fetchTasks[bookmark] = nil // Clean up completed task + print("TrackerView.fetchServers: trackerServers now has \(self.trackerServers.count) entries") + print("TrackerView.fetchServers: Verification - trackerServers[\(bookmark.name)] now has \(self.trackerServers[bookmark]?.count ?? -1) servers") } } } @@ -694,6 +718,7 @@ struct TrackerItemView: View { let bookmark: Bookmark let isExpanded: Bool let isLoading: Bool + let count: Int let onToggleExpanded: () -> Void var body: some View { @@ -727,6 +752,23 @@ struct TrackerItemView: View { .controlSize(.small) } Spacer(minLength: 0) + if isExpanded && count > 0 { + HStack(spacing: 4) { + Text(String(count)) + + Image(systemName: "globe.americas.fill") + .resizable() + .scaledToFit() + .frame(width: 12, height: 12) + .opacity(0.5) + } + + .padding(.horizontal, 8) + .padding(.vertical, 2) + .foregroundStyle(.secondary) + .background(.quinary) + .clipShape(.capsule) + } case .server: Image(systemName: "bookmark.fill") .resizable() -- cgit From 26c6e0ade508f319ed995a1e6c5a53bf7c610c34 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Mon, 27 Oct 2025 10:11:57 -0700 Subject: Further work on NetSocketNew. Added NetSocketDecodable/NetSocketEncodable so types can decode/encode themselves on/off the wire. --- Hotline/Hotline/HotlineProtocol.swift | 62 +++- Hotline/Hotline/HotlineTrackerClient.swift | 120 ++----- Hotline/Library/NetSocketNew.swift | 547 ++++++++++------------------- 3 files changed, 268 insertions(+), 461 deletions(-) diff --git a/Hotline/Hotline/HotlineProtocol.swift b/Hotline/Hotline/HotlineProtocol.swift index 9b3a812..5f859fb 100644 --- a/Hotline/Hotline/HotlineProtocol.swift +++ b/Hotline/Hotline/HotlineProtocol.swift @@ -164,21 +164,41 @@ struct HotlineUserAccessOptions: OptionSet { static let canSendMessages = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(40)) } -struct HotlineServer: Identifiable, Hashable { +struct HotlineServer: Identifiable, Hashable, NetSocketDecodable { let id = UUID() let address: String let port: UInt16 let users: UInt16 let name: String? let description: String? - + static func == (lhs: HotlineServer, rhs: HotlineServer) -> Bool { return lhs.id == rhs.id } - + func hash(into hasher: inout Hasher) { hasher.combine(self.id) } + + init(from socket: NetSocketNew, endian: Endian) async throws { + // Read IP address (4 individual bytes) + let ip1 = try await socket.read(UInt8.self) + let ip2 = try await socket.read(UInt8.self) + let ip3 = try await socket.read(UInt8.self) + let ip4 = try await socket.read(UInt8.self) + self.address = "\(ip1).\(ip2).\(ip3).\(ip4)" + + // Read port and user count (big-endian) + self.port = try await socket.read(UInt16.self, endian: endian) + self.users = try await socket.read(UInt16.self, endian: endian) + + // Skip 2 unused bytes + _ = try await socket.read(2) + + // Read pascal strings (auto-detects encoding) + self.name = try await socket.readPascalString() + self.description = try await socket.readPascalString() + } } struct HotlineNewsArticle: Identifiable { @@ -216,7 +236,6 @@ struct HotlineAccount: Identifiable { self.persisted = false } - mutating func decodeFields(from data: [UInt8]) { var fieldData = data @@ -967,6 +986,39 @@ enum HotlineTransactionType: UInt16 { case postNewsArticle = 410 case deleteNewsArticle = 411 case connectionKeepAlive = 500 - + case unknown = 15000 } + +// MARK: - NetSocketDecodable Conformance +// +//extension HotlineTrackerServer: NetSocketDecodable { +// /// Decode a tracker server entry directly from the socket stream +// /// +// /// Wire format: +// /// - IP address: 4 bytes (individual octets) +// /// - Port: UInt16 (big-endian) +// /// - User count: UInt16 (big-endian) +// /// - Unused: 2 bytes +// /// - Server name: Pascal string (1-byte length + data) +// /// - Server description: Pascal string (1-byte length + data) +// init(from socket: NetSocketNew, endian: Endian) async throws { +// // Read IP address (4 individual bytes) +// let ip1 = try await socket.read(UInt8.self) +// let ip2 = try await socket.read(UInt8.self) +// let ip3 = try await socket.read(UInt8.self) +// let ip4 = try await socket.read(UInt8.self) +// self.address = "\(ip1).\(ip2).\(ip3).\(ip4)" +// +// // Read port and user count (big-endian) +// self.port = try await socket.read(UInt16.self, endian: endian) +// self.users = try await socket.read(UInt16.self, endian: endian) +// +// // Skip 2 unused bytes +// _ = try await socket.read(2) +// +// // Read pascal strings (auto-detects encoding) +// self.name = try await socket.readPascalString() +// self.description = try await socket.readPascalString() +// } +//} diff --git a/Hotline/Hotline/HotlineTrackerClient.swift b/Hotline/Hotline/HotlineTrackerClient.swift index 110ca21..52d34c1 100644 --- a/Hotline/Hotline/HotlineTrackerClient.swift +++ b/Hotline/Hotline/HotlineTrackerClient.swift @@ -83,7 +83,7 @@ class HotlineTrackerClient { try await socket.write(Data(HotlineTrackerClient.MagicPacket)) // Receive magic response (6 bytes: 'HTRK' + version) - let magicResponse = try await socket.readExactly(6) + let magicResponse = try await socket.read(6) let magic = magicResponse[0..<4] let version = UInt16(magicResponse[4]) << 8 | UInt16(magicResponse[5]) @@ -121,53 +121,34 @@ class HotlineTrackerClient { print("HotlineTrackerClient: Batch #\(batchCount) - type: \(messageType), dataLen: \(dataLength), count1: \(serverCount), count2: \(serverCount2)") - // Calculate actual listing data length (excludes the two server count fields) - let listingLength = Int(dataLength) - 4 - print("HotlineTrackerClient: About to read \(listingLength) bytes of listing data...") - - // Receive listing data - let listingData = try await socket.readExactly(listingLength) - print("HotlineTrackerClient: Successfully read \(listingData.count) bytes") - - // Parse and yield servers one at a time for true progressive streaming - var bytes = Array(listingData) + // Parse servers directly from socket stream (no buffering!) let trackerSeparatorRegex = /^[-]+$/ var batchEntriesParsed = 0 var batchServersYielded = 0 for _ in 0.. (servers: [HotlineServer], entriesParsed: Int) { - // Server record format: - // - IP address (4 bytes) - // - Port number (2 bytes) - // - Number of users (2 bytes) - // - Unused (2 bytes) - // - Name size (1 byte) - // - Name (name size) - // - Description size (1 byte) - // - Description (description size) - - var bytes = Array(data) - let trackerSeparatorRegex = /^[-]+$/ - var servers: [HotlineServer] = [] - var entriesParsed = 0 - - for _ in 0.. cfg.maxFrameBytes { throw NetSocketError.framingExceeded(max: cfg.maxFrameBytes) } - return try await readExactly(length) + return try await read(length) } /// Send an encodable value as a length-prefixed frame @@ -568,7 +590,7 @@ public actor NetSocketNew { /// - Throws: `NetSocketError` if insufficient data or connection closed public func read(_ type: T.Type = T.self, endian: Endian = .big) async throws -> T { let size = MemoryLayout.size - let data = try await readExactly(size) + let data = try await read(size) let value: T = data.withUnsafeBytes { raw in raw.load(as: T.self) } @@ -585,8 +607,8 @@ public actor NetSocketNew { /// - encoding: Text encoding (default: UTF-8) /// - Returns: Decoded string /// - Throws: `NetSocketError` if decoding fails or insufficient data - public func readString(length: Int, encoding: String.Encoding = .utf8) async throws -> String { - let data = try await readExactly(length) + public func read(_ length: Int, encoding: String.Encoding = .utf8) async throws -> String { + let data = try await read(length) guard let s = String(data: data, encoding: encoding) else { throw NetSocketError.decodeFailed(NSError()) } return s } @@ -599,12 +621,57 @@ public actor NetSocketNew { /// - includeDelimiter: Whether to include delimiter in result (default: false) /// - Returns: String read from stream (delimiter consumed but not included unless specified) /// - Throws: `NetSocketError` if decoding fails, max bytes exceeded, or connection closed - public func readString(until delimiter: Delimiter, maxBytes: Int? = nil, includeDelimiter: Bool = false) async throws -> String { - let bytes = try await readUntil(delimiter: delimiter.data, maxBytes: maxBytes, includeDelimiter: includeDelimiter) + public func read(until delimiter: Delimiter, maxBytes: Int? = nil, includeDelimiter: Bool = false) async throws -> String { + let bytes = try await read(past: delimiter.data, maxBytes: maxBytes, includeDelimiter: includeDelimiter) guard let s = String(data: bytes, encoding: .utf8) else { throw NetSocketError.decodeFailed(NSError()) } return s } - + + /// Read a pascal string (1-byte length prefix followed by string data) + /// + /// This method reads a single byte for the length, then reads that many bytes and attempts + /// to decode them as a string. It tries multiple encodings for compatibility with legacy + /// protocols like Hotline: UTF-8, Shift-JIS, Windows-1251, and falls back to MacRoman. + /// + /// - Returns: The decoded string, or nil if length is 0 + /// - Throws: `NetSocketError` if reading fails or no encoding succeeds + public func readPascalString() async throws -> String? { + let length = try await read(UInt8.self) + guard length > 0 else { return nil } + + let data = try await read(Int(length)) + + // Try auto-detection with common encodings + let allowedEncodings = [ + String.Encoding.utf8.rawValue, + String.Encoding.shiftJIS.rawValue, + String.Encoding.unicode.rawValue, + String.Encoding.windowsCP1251.rawValue + ] + + var decodedString: NSString? + let detected = NSString.stringEncoding( + for: data, + encodingOptions: [.allowLossyKey: false], + convertedString: &decodedString, + usedLossyConversion: nil + ) + + if allowedEncodings.contains(detected), let str = decodedString as? String { + return str + } + + // Fallback to MacRoman for classic Mac compatibility + guard let str = String(data: data, encoding: .macOSRoman) else { + throw NetSocketError.decodeFailed(NSError( + domain: "NetSocketNew", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Failed to decode pascal string with any known encoding"] + )) + } + return str + } + /// Read data until a delimiter is found /// /// Searches the buffer for the delimiter pattern and returns all data up to (and optionally including) @@ -616,12 +683,12 @@ public actor NetSocketNew { /// - includeDelimiter: Whether to include delimiter in result (default: false) /// - Returns: Data read from stream /// - Throws: `NetSocketError.framingExceeded` if max bytes exceeded, or connection errors - public func readUntil(delimiter: Data, maxBytes: Int? = nil, includeDelimiter: Bool = false) async throws -> Data { + public func read(past delimiter: Data, maxBytes: Int? = nil, includeDelimiter: Bool = false) async throws -> Data { while true { try Task.checkCancellation() if let r = search(delimiter: delimiter) { let consumeLen = r.upperBound - head - let data = try await readExactly(consumeLen) + let data = try await read(consumeLen) return includeDelimiter ? data : data.dropLast(delimiter.count) } if let maxBytes, availableBytes >= maxBytes { @@ -640,7 +707,7 @@ public actor NetSocketNew { /// - Parameter count: Number of bytes to read /// - Returns: Exactly `count` bytes /// - Throws: `NetSocketError.insufficientData` if connection closes before enough data arrives - public func readExactly(_ count: Int) async throws -> Data { + public func read(_ count: Int) async throws -> Data { try await ensureReadable(count) let start = head let end = head + count @@ -659,7 +726,7 @@ public actor NetSocketNew { } /// Skip until delimiter is found (discards delimiter too) - public func skipUntil(delimiter: Data) async throws { + public func skip(past delimiter: Data) async throws { while true { try Task.checkCancellation() if let r = search(delimiter: delimiter) { @@ -887,7 +954,7 @@ public extension NetSocketNew { while remaining > 0 { try Task.checkCancellation() let n = Int(min(Int64(chunkSize), remaining)) - let chunk = try await readExactly(n) // reuses your internal buffer, bounded by n + let chunk = try await read(n) // reuses your internal buffer, bounded by n fh.write(chunk) remaining -= Int64(n) written += Int64(n) @@ -969,7 +1036,7 @@ public extension NetSocketNew { while remaining > 0 { try Task.checkCancellation() let n = Int(min(Int64(chunkSize), remaining)) - let chunk = try await readExactly(n) + let chunk = try await read(n) fh.write(chunk) remaining -= Int64(n) written += Int64(n) @@ -1025,379 +1092,143 @@ fileprivate extension NetSocketNew { } } -/// Errors that can occur during binary encoding/decoding -public enum BinaryError: Error, CustomStringConvertible, Sendable { - /// Attempted to read past end of data - case outOfBounds(need: Int, have: Int) - /// String encoding/decoding failed - case badStringEncoding - /// Invalid value encountered - case invalidValue(String) +// MARK: - Stream-based Encoding/Decoding - public var description: String { - switch self { - case .outOfBounds(let need, let have): return "Out of bounds: need \(need), have \(have)" - case .badStringEncoding: return "String decoding failed" - case .invalidValue(let s): return "Invalid value: \(s)" - } - } -} - -/// Protocol for types that can encode themselves to binary format -public protocol BinaryEncodable: Sendable { - /// Encode this value to a ByteWriter - /// - Parameters: - /// - w: Writer to encode to - /// - endian: Byte order for multi-byte values - func encode(to w: inout ByteWriter, endian: Endian) -} - -/// Protocol for types that can decode themselves from binary format -public protocol BinaryDecodable: Sendable { - /// Decode a value from a ByteReader - /// - Parameters: - /// - r: Reader to decode from - /// - endian: Byte order for multi-byte values - init(from r: inout ByteReader, endian: Endian) throws -} - -/// Convenience typealias for types that are both encodable and decodable -public typealias BinaryCodable = BinaryEncodable & BinaryDecodable - -extension UInt8: BinaryCodable { - public func encode(to w: inout ByteWriter, endian: Endian) { - w.write(self, endian: endian) - } - public init(from r: inout ByteReader, endian: Endian) throws { - self = try r.read(UInt8.self, endian: endian) - } -} - -extension UInt16: BinaryCodable { - public func encode(to w: inout ByteWriter, endian: Endian) { - w.write(self, endian: endian) - } - public init(from r: inout ByteReader, endian: Endian) throws { - self = try r.read(UInt16.self, endian: endian) - } -} - -extension UInt32: BinaryCodable { - public func encode(to w: inout ByteWriter, endian: Endian) { - w.write(self, endian: endian) - } - public init(from r: inout ByteReader, endian: Endian) throws { - self = try r.read(UInt32.self, endian: endian) - } -} - -/// A writer for building binary data structures +/// Protocol for types that can encode themselves to binary data /// -/// ByteWriter provides methods to serialize integers, floats, strings, and arrays into binary format. -/// All multi-byte values are written in the specified endianness. +/// Types conforming to `NetSocketEncodable` produce binary data that can be sent over +/// a socket. Unlike writing field-by-field to the socket, encodable types build complete +/// binary messages that are sent in a single write operation for efficiency. /// /// Example: /// ```swift -/// var w = ByteWriter() -/// w.write(UInt16(100), endian: .big) -/// try w.write("Hello", prefix: .u16(.big)) -/// let data = w.finalize() +/// struct MyMessage: NetSocketEncodable { +/// let id: UInt32 +/// let name: String +/// +/// func encode(endian: Endian) throws -> Data { +/// var data = Data() +/// // Encode fields to data... +/// return data +/// } +/// } +/// +/// try await socket.send(message) /// ``` -public struct ByteWriter: Sendable { - /// The accumulated binary data - public private(set) var data = Data() - - public init() {} - - /// Write a fixed-width integer - /// - Parameters: - /// - v: Integer value - /// - endian: Byte order (default: big-endian) - public mutating func write(_ v: T, endian: Endian = .big) { - var x = v - switch endian { - case .big: x = T(bigEndian: v) - case .little: x = T(littleEndian: v) - } - withUnsafeBytes(of: x) { data.append(contentsOf: $0) } - } - - /// Write a boolean as a single byte (0 or 1) - /// - Parameter value: Boolean value - public mutating func write(_ value: Bool) { - data.append(value ? 1 : 0) - } - - /// Write a Float as its IEEE 754 bit pattern - /// - Parameters: - /// - value: Float value - /// - endian: Byte order (default: big-endian) - public mutating func write(_ value: Float, endian: Endian = .big) { - write(value.bitPattern, endian: endian) - } - - /// Write a Double as its IEEE 754 bit pattern - /// - Parameters: - /// - value: Double value - /// - endian: Byte order (default: big-endian) - public mutating func write(_ value: Double, endian: Endian = .big) { - write(value.bitPattern, endian: endian) - } - - /// Write raw bytes, optionally with a length prefix - /// - Parameters: - /// - bytes: Data to write - /// - prefix: Optional length prefix - /// - endian: Byte order for length prefix (ignored if prefix is nil) - /// - Throws: `BinaryError.invalidValue` if data size exceeds prefix capacity - public mutating func write(_ bytes: Data, prefix: LengthPrefix? = nil, endian: Endian = .big) throws { - if let prefix { - // Validate size fits in prefix type - switch prefix { - case .u8 where bytes.count > Int(UInt8.max): - throw BinaryError.invalidValue("Data size \(bytes.count) exceeds u8 max (255)") - case .u16 where bytes.count > Int(UInt16.max): - throw BinaryError.invalidValue("Data size \(bytes.count) exceeds u16 max (65535)") - case .u32 where bytes.count > Int(UInt32.max): - throw BinaryError.invalidValue("Data size \(bytes.count) exceeds u32 max") - default: - break - } - - switch prefix { - case .u8: data.append(UInt8(truncatingIfNeeded: bytes.count)) - case .u16(let e): write(UInt16(bytes.count), endian: e) - case .u32(let e): write(UInt32(bytes.count), endian: e) - case .u64(let e): write(UInt64(bytes.count), endian: e) - } - } - data.append(bytes) - } - - /// Write a length-prefixed string - /// - Parameters: - /// - string: String to write - /// - prefix: Length prefix type (default: u16 big-endian) - /// - encoding: Text encoding (default: UTF-8) - /// - Throws: `BinaryError.badStringEncoding` if encoding fails, or size validation errors - public mutating func write(_ string: String, prefix: LengthPrefix = .u16(), encoding: String.Encoding = .utf8) throws { - guard let d = string.data(using: encoding) else { - throw BinaryError.badStringEncoding - } - try write(d, prefix: prefix) - } - - /// Write an array of encodable elements with element count prefix +public protocol NetSocketEncodable: Sendable { + /// Encode this value to binary data /// - /// The prefix contains the **number of elements**, not the byte size. Each element is - /// encoded by calling its `encode(to:endian:)` method. + /// Implementations should build a complete binary message and return it as Data. + /// The data will be sent to the socket in a single write operation. /// - /// - Parameters: - /// - array: Array of elements to write - /// - prefix: Length prefix for element count (default: u32 big-endian) - /// - endian: Byte order for multi-byte values within elements - /// - Throws: `BinaryError.invalidValue` if array count exceeds prefix capacity - public mutating func write(_ array: [Element], prefix: LengthPrefix = .u32(), endian: Endian = .big) throws { - // Validate array count fits in prefix type - switch prefix { - case .u8 where array.count > Int(UInt8.max): - throw BinaryError.invalidValue("Array count \(array.count) exceeds u8 max (255)") - case .u16 where array.count > Int(UInt16.max): - throw BinaryError.invalidValue("Array count \(array.count) exceeds u16 max (65535)") - case .u32 where array.count > Int(UInt32.max): - throw BinaryError.invalidValue("Array count \(array.count) exceeds u32 max") - default: - break - } - - // Number of elements, not byte length: - switch prefix { - case .u8: data.append(UInt8(array.count)) - case .u16(let e): write(UInt16(array.count), endian: e) - case .u32(let e): write(UInt32(array.count), endian: e) - case .u64(let e): write(UInt64(array.count), endian: e) - } - for el in array { el.encode(to: &self, endian: endian) } - } - - /// Get the final binary data - /// - Returns: All accumulated binary data - public func finalize() -> Data { data } + /// - Parameter endian: Byte order for multi-byte values + /// - Returns: Encoded binary data ready to send + /// - Throws: Encoding errors + func encode(endian: Endian) throws -> Data } -/// A reader for parsing binary data structures +/// Protocol for types that can decode themselves directly from a socket stream +/// +/// Types conforming to `NetSocketDecodable` read field-by-field directly from the socket +/// using async reads. This enables true streaming without buffering entire messages. /// -/// ByteReader provides methods to deserialize integers, floats, strings, and arrays from binary format. -/// The reader maintains an offset that automatically advances as data is read. +/// **Important**: If decoding throws after consuming some bytes (e.g., validation fails), +/// the socket will be left with those bytes consumed. In practice, this usually means the +/// connection should be closed. For most protocols this is acceptable since decode errors +/// indicate corrupt data or protocol violations. /// /// Example: /// ```swift -/// var r = ByteReader(data: binaryData) -/// let value = try r.read(UInt16.self, endian: .big) -/// let text = try r.readString(prefix: .u16(.big)) +/// struct MyMessage: NetSocketDecodable { +/// let id: UInt32 +/// let name: String +/// +/// init(from socket: NetSocketNew, endian: Endian) async throws { +/// self.id = try await socket.read(UInt32.self, endian: endian) +/// let nameLen = try await socket.read(UInt16.self, endian: endian) +/// let nameData = try await socket.readExactly(Int(nameLen)) +/// guard let name = String(data: nameData, encoding: .utf8) else { +/// throw NetSocketError.decodeFailed(NSError()) +/// } +/// self.name = name +/// } +/// } +/// +/// let message = try await socket.receive(MyMessage.self) /// ``` -public struct ByteReader: Sendable { - /// The binary data being read - public let data: Data - /// Current read position - public private(set) var offset: Int = 0 - - public init(data: Data) { self.data = data } - - @inline(__always) - private mutating func take(_ count: Int) throws -> UnsafeRawBufferPointer { - guard offset + count <= data.count else { throw BinaryError.outOfBounds(need: count, have: data.count - offset) } - let start = data.startIndex + offset - offset += count - return data[start..<(start + count)].withUnsafeBytes { $0 } - } - - /// Read a fixed-width integer - /// - Parameters: - /// - type: Integer type to read - /// - endian: Byte order (default: big-endian) - /// - Returns: Decoded integer value - /// - Throws: `BinaryError.outOfBounds` if insufficient data - public mutating func read(_ type: T.Type = T.self, endian: Endian = .big) throws -> T { - let size = MemoryLayout.size - let raw = try take(size) - let v = raw.load(as: T.self) - switch endian { - case .big: return T(bigEndian: v) - case .little: return T(littleEndian: v) - } - } - - /// Read a boolean (0 = false, non-zero = true) - /// - Returns: Boolean value - /// - Throws: `BinaryError.outOfBounds` if insufficient data - public mutating func readBool() throws -> Bool { - let b: UInt8 = try read() - return b != 0 - } - - /// Read a Float from its IEEE 754 bit pattern - /// - Parameters: - /// - type: Float type (for type inference) - /// - endian: Byte order (default: big-endian) - /// - Returns: Float value - /// - Throws: `BinaryError.outOfBounds` if insufficient data - public mutating func read(_ type: Float.Type = Float.self, endian: Endian = .big) throws -> Float { - let bits: UInt32 = try read(endian: endian) - return Float(bitPattern: bits) - } - - /// Read a Double from its IEEE 754 bit pattern - /// - Parameters: - /// - type: Double type (for type inference) - /// - endian: Byte order (default: big-endian) - /// - Returns: Double value - /// - Throws: `BinaryError.outOfBounds` if insufficient data - public mutating func read(_ type: Double.Type = Double.self, endian: Endian = .big) throws -> Double { - let bits: UInt64 = try read(endian: endian) - return Double(bitPattern: bits) - } - - /// Read a fixed number of raw bytes - /// - Parameter count: Number of bytes to read - /// - Returns: Data containing exactly `count` bytes - /// - Throws: `BinaryError.outOfBounds` if insufficient data - public mutating func readData(count: Int) throws -> Data { - let raw = try take(count) - return Data(raw) - } - - /// Read length-prefixed raw bytes - /// - Parameters: - /// - prefix: Length prefix type - /// - endian: Byte order for length prefix - /// - Returns: Data of the specified length - /// - Throws: `BinaryError` if prefix value is invalid or insufficient data - public mutating func readData(prefix: LengthPrefix, endian: Endian = .big) throws -> Data { - let len: Int - switch prefix { - case .u8: len = Int(try read(UInt8.self)) - case .u16(let e): len = Int(try read(UInt16.self, endian: e)) - case .u32(let e): len = Int(try read(UInt32.self, endian: e)) - case .u64(let e): - let v: UInt64 = try read(UInt64.self, endian: e) - if v > Int.max { throw BinaryError.invalidValue("u64 length too large") } - len = Int(v) - } - return try readData(count: len) - } - - /// Read a length-prefixed string - /// - Parameters: - /// - prefix: Length prefix type (default: u16 big-endian) - /// - encoding: Text encoding (default: UTF-8) - /// - Returns: Decoded string - /// - Throws: `BinaryError` if decoding fails or insufficient data - public mutating func readString(prefix: LengthPrefix = .u16(), - encoding: String.Encoding = .utf8) throws -> String { - let d = try readData(prefix: prefix) - guard let s = String(data: d, encoding: encoding) else { throw BinaryError.badStringEncoding } - return s - } - - /// Read an array of decodable elements +public protocol NetSocketDecodable: Sendable { + /// Decode a value by reading directly from the socket stream + /// + /// This initializer should read all necessary fields from the socket using + /// methods like `read(_:endian:)`, `readExactly(_:)`, `readString(length:)`, etc. /// - /// The prefix contains the **number of elements**, not the byte size. + /// The socket handles waiting for data to arrive, so you can read field by field + /// without worrying about buffering. /// /// - Parameters: - /// - type: Element type to decode - /// - prefix: Length prefix for element count (default: u32 big-endian) - /// - endian: Byte order for multi-byte values within elements - /// - Returns: Array of decoded elements - /// - Throws: `BinaryError` if count is invalid, decoding fails, or insufficient data - public mutating func readArray(_ type: Element.Type, - prefix: LengthPrefix = .u32(), - endian: Endian = .big) throws -> [Element] { - let count: Int - switch prefix { - case .u8: count = Int(try read(UInt8.self)) - case .u16(let e): count = Int(try read(UInt16.self, endian: e)) - case .u32(let e): count = Int(try read(UInt32.self, endian: e)) - case .u64(let e): - let v: UInt64 = try read(UInt64.self, endian: e) - if v > Int.max { throw BinaryError.invalidValue("array count too large") } - count = Int(v) - } - var out: [Element] = [] - out.reserveCapacity(count) - for _ in 0..= data.count } + /// - socket: Socket to read from + /// - endian: Byte order for multi-byte values + /// - Throws: Network errors, insufficient data, or custom decoding errors + init(from socket: NetSocketNew, endian: Endian) async throws } public extension NetSocketNew { - /// Send a binary-encodable value as a length-prefixed frame + /// Send an encodable value to the socket + /// + /// The type encodes itself to binary data, which is then sent in a single write operation. + /// + /// Example: + /// ```swift + /// struct MyMessage: NetSocketEncodable { + /// let id: UInt32 + /// let name: String + /// + /// func encode(endian: Endian) throws -> Data { + /// var data = Data() + /// // Build binary message... + /// return data + /// } + /// } + /// + /// try await socket.send(message) + /// ``` + /// /// - Parameters: - /// - value: Value conforming to BinaryEncodable + /// - value: Value conforming to NetSocketEncodable /// - endian: Byte order (default: big-endian) - /// - prefix: Length prefix type (default: u32 big-endian) /// - Throws: Encoding or network errors - func sendBinary(_ value: T, endian: Endian = .big, prefix: LengthPrefix = .u32()) async throws { - var w = ByteWriter() - value.encode(to: &w, endian: endian) - try await sendFrame(w.finalize(), prefix: prefix) + func send(_ value: T, endian: Endian = .big) async throws { + let data = try value.encode(endian: endian) + try await write(data) } - /// Receive and decode a binary value from a length-prefixed frame + /// Receive and decode a value directly from the socket stream (no length prefix) + /// + /// The type reads field-by-field from the socket as needed, enabling true streaming + /// without buffering entire messages. Useful for protocols where message size isn't + /// known upfront or for progressive decoding. + /// + /// Example: + /// ```swift + /// struct ServerEntry: NetSocketDecodable { + /// let id: UInt32 + /// let name: String + /// + /// init(from socket: NetSocketNew, endian: Endian) async throws { + /// self.id = try await socket.read(UInt32.self, endian: endian) + /// // Read variable-length string... + /// } + /// } + /// + /// let entry = try await socket.receive(ServerEntry.self) + /// ``` + /// /// - Parameters: - /// - type: Type conforming to BinaryDecodable + /// - type: Type conforming to NetSocketDecodable /// - endian: Byte order (default: big-endian) - /// - prefix: Length prefix type (default: u32 big-endian) /// - Returns: Decoded value /// - Throws: Decoding or network errors - func receiveBinary(_ type: T.Type, endian: Endian = .big, prefix: LengthPrefix = .u32()) async throws -> T { - let data = try await receiveFrame(prefix: prefix) - var r = ByteReader(data: data) - return try T(from: &r, endian: endian) + func receive(_ type: T.Type, endian: Endian = .big) async throws -> T { + return try await T(from: self, endian: endian) } } -- cgit From 2b278f662326b44d86d84ae3e11ff0b770757f45 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Mon, 27 Oct 2025 10:40:55 -0700 Subject: Refactor HotlineFilePreviewClient to use NetSocketNew. Code is much cleaner. --- Hotline/Hotline/HotlineTransferClient.swift | 197 +++++++++------------------- Hotline/Library/NetSocketNew.swift | 68 ++++++++-- 2 files changed, 120 insertions(+), 145 deletions(-) diff --git a/Hotline/Hotline/HotlineTransferClient.swift b/Hotline/Hotline/HotlineTransferClient.swift index 40dd598..86a1814 100644 --- a/Hotline/Hotline/HotlineTransferClient.swift +++ b/Hotline/Hotline/HotlineTransferClient.swift @@ -456,9 +456,9 @@ class HotlineFilePreviewClient: HotlineTransferClient { let serverPort: NWEndpoint.Port let referenceNumber: UInt32 let referenceDataSize: UInt32 - + weak var delegate: HotlineFilePreviewClientDelegate? = nil - + var status: HotlineTransferStatus = .unconnected { didSet { DispatchQueue.main.async { @@ -466,156 +466,87 @@ class HotlineFilePreviewClient: HotlineTransferClient { } } } - - private var connection: NWConnection? - private var transferStage: HotlineFileTransferStage = .fileHeader - private var fileBytes = Data() - private var fileBytesTransferred: Int = 0 - + + private var downloadTask: Task? + init(address: String, port: UInt16, reference: UInt32, size: UInt32) { self.serverAddress = NWEndpoint.Host(address) self.serverPort = NWEndpoint.Port(rawValue: port + 1)! self.referenceNumber = reference self.referenceDataSize = size } - + deinit { - self.invalidate() + downloadTask?.cancel() } - + func start() { - guard self.status == .unconnected else { + guard status == .unconnected else { return } - - self.connect() + + downloadTask = Task { + await self.download() + } } - + func cancel() { - self.delegate = nil - - if self.status == .unconnected { - return - } - - self.invalidate() - + downloadTask?.cancel() + downloadTask = nil + delegate = nil + print("HotlineFilePreviewClient: Cancelled preview transfer") } - - private func invalidate() { - if let c = self.connection { - c.stateUpdateHandler = nil - c.cancel() - - self.connection = nil - } - - self.fileBytes = Data() - } - - private func connect() { - self.connection = NWConnection(host: self.serverAddress, port: self.serverPort, using: .tcp) - self.connection?.stateUpdateHandler = { [weak self] (newState: NWConnection.State) in - switch newState { - case .ready: - self?.status = .connected - self?.sendMagic() - case .waiting(let err): - print("HotlineFilePreviewClient: Waiting", err) - case .cancelled: - print("HotlineFilePreviewClient: Cancelled") - self?.invalidate() - case .failed(let err): - print("HotlineFilePreviewClient: Connection error \(err)") - switch self?.status { - case .connecting: - print("HotlineFilePreviewClient: Failed to connect to file transfer server.") - self?.invalidate() - self?.status = .failed(.failedToConnect) - case .connected, .progress(_): - print("HotlineFilePreviewClient: Failed to finish transfer.") - self?.invalidate() - self?.status = .failed(.failedToDownload) - default: - break - } - default: - return + + private func download() async { + status = .connecting + + do { + // Connect to file transfer server (already includes +1 in serverPort from init) + let socket = try await NetSocketNew.connect( + host: serverAddress, + port: serverPort, + tls: .disabled + ) + defer { Task { await socket.close() } } + + status = .connected + + // Send magic header + let headerData = Data(endian: .big) { + "HTXF".fourCharCode() + self.referenceNumber + UInt32.zero + UInt32.zero } - } - - self.status = .connecting - self.connection?.start(queue: .global()) - } - - private func sendMagic() { - guard let c = connection, self.status == .connected else { - self.invalidate() - print("HotlineFileClient: invalid connection to send header.") - return - } - - let headerData = Data(endian: .big) { - "HTXF".fourCharCode() - self.referenceNumber - UInt32.zero - UInt32.zero - } - - c.send(content: headerData, completion: .contentProcessed { [weak self] (error) in - guard let self = self else { - return + try await socket.write(headerData) + + status = .progress(0.0) + + // Download file data with progress updates + let fileData = try await socket.read(Int(referenceDataSize)) { current, total in + self.status = .progress(Double(current) / Double(total)) } - - guard error == nil else { - self.status = .failed(.failedToConnect) - self.invalidate() - return + + print("HotlineFilePreviewClient: Complete") + status = .completed + + // Notify delegate on main thread + let reference = self.referenceNumber + await MainActor.run { + self.delegate?.hotlineFilePreviewComplete(client: self, reference: reference, data: fileData) } - - self.status = .progress(0.0) - self.receive() - }) - } - - private func receive() { - guard let c = self.connection else { + + } catch is CancellationError { + // Already handled in cancel() return - } - - c.receive(minimumIncompleteLength: 1, maximumLength: Int(UInt16.max)) { [weak self] (data, context, isComplete, error) in - guard let self = self else { - return - } - - guard error == nil else { - self.status = .failed(.failedToDownload) - self.invalidate() - return - } - - if let newData = data, !newData.isEmpty { - self.fileBytesTransferred += newData.count - self.fileBytes.append(newData) - self.status = .progress(Double(self.fileBytesTransferred) / Double(self.referenceDataSize)) - print("HotlineFilePreviewClient: Download progress", self.fileBytesTransferred, self.referenceDataSize, isComplete) - } - - if self.fileBytesTransferred < Int(self.referenceDataSize) { - self.receive() - } - else { - print("HotlineFilePreviewClient: Complete") - let data = self.fileBytes - - self.status = .completed - self.invalidate() - - let reference = self.referenceNumber - DispatchQueue.main.sync { - self.delegate?.hotlineFilePreviewComplete(client: self, reference: reference, data: data) - } + } catch { + print("HotlineFilePreviewClient: Download failed: \(error)") + + if status == .connecting { + status = .failed(.failedToConnect) + } else { + status = .failed(.failedToDownload) } } } diff --git a/Hotline/Library/NetSocketNew.swift b/Hotline/Library/NetSocketNew.swift index 753a201..d4507b8 100644 --- a/Hotline/Library/NetSocketNew.swift +++ b/Hotline/Library/NetSocketNew.swift @@ -181,16 +181,17 @@ public actor NetSocketNew { /// Connect to a remote host and return a ready socket /// - /// This method establishes a TCP connection and waits until the connection is in `.ready` state. + /// This method establishes a TCP connection using Network framework types and waits until + /// the connection is in `.ready` state. /// /// - Parameters: - /// - host: Hostname or IP address to connect to - /// - port: Port number (0-65535) + /// - host: Network framework host (e.g., `.name("example.com", nil)` or `.ipv4(...)`) + /// - port: Network framework port /// - tls: TLS policy (default: enabled with default settings) /// - config: Socket configuration (default: standard settings) /// - Returns: A connected and ready `NetSocketNew` - /// - Throws: `NetSocketError.invalidPort` if port is invalid, or network errors - public static func connect(host: String, port: UInt16, tls: TLSPolicy = .enabled(), config: Config = .init()) async throws -> NetSocketNew { + /// - Throws: Network errors or connection failures + public static func connect(host: NWEndpoint.Host, port: NWEndpoint.Port, tls: TLSPolicy = .enabled(), config: Config = .init()) async throws -> NetSocketNew { let parameters = NWParameters.tcp if tls.enabled { let tlsOptions = NWProtocolTLS.Options() @@ -198,16 +199,20 @@ public actor NetSocketNew { parameters.defaultProtocolStack.applicationProtocols.insert(tlsOptions, at: 0) } - guard let nwPort = NWEndpoint.Port(rawValue: port) else { - throw NetSocketError.invalidPort - } - - let conn = NWConnection(host: .name(host, nil), port: nwPort, using: parameters) + let conn = NWConnection(host: host, port: port, using: parameters) let socket = NetSocketNew(connection: conn, config: config) try await socket.start() return socket } - + + /// Convenience wrapper to connect using string hostname and integer port + public static func connect(host: String, port: UInt16, tls: TLSPolicy = .enabled(), config: Config = .init()) async throws -> NetSocketNew { + guard let nwPort = NWEndpoint.Port(rawValue: port) else { + throw NetSocketError.invalidPort + } + return try await connect(host: .name(host, nil), port: nwPort, tls: tls, config: config) + } + /// Inject custom encoding/decoding logic (supports any encoder/decoder: JSON, CBOR, MessagePack, etc.) /// /// Example with JSONEncoder: @@ -738,7 +743,46 @@ public actor NetSocketNew { guard !isClosed else { throw NetSocketError.closed } } } - + + /// Read exactly N bytes with progress callbacks + /// + /// Like `read(_:)`, but reads in chunks and reports progress after each chunk. + /// Useful for downloading large amounts of data where you want to update UI progress. + /// + /// Example: + /// ```swift + /// let data = try await socket.read(1_000_000) { current, total in + /// print("Progress: \(current)/\(total)") + /// } + /// ``` + /// + /// - Parameters: + /// - count: Number of bytes to read + /// - chunkSize: Size of chunks to read at a time (default: 8192) + /// - progress: Optional callback with (bytesReceived, totalBytes) + /// - Returns: Exactly `count` bytes + /// - Throws: `NetSocketError` if connection closes before enough data arrives + public func read( + _ count: Int, + chunkSize: Int = 8192, + progress: (@Sendable (Int, Int) -> Void)? = nil + ) async throws -> Data { + var data = Data() + data.reserveCapacity(count) + var received = 0 + + while received < count { + try Task.checkCancellation() + let toRead = min(chunkSize, count - received) + let chunk = try await read(toRead) + data.append(chunk) + received += chunk.count + progress?(received, count) + } + + return data + } + func peek(_ count: Int) async throws -> Data { try await ensureReadable(count) let slice = buffer[head..<(head + count)] -- cgit From 5924204ab2956d3947ca19c07585b81173226a4f Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Mon, 27 Oct 2025 11:03:49 -0700 Subject: Add spinning globe to TrackerView. Continue cleanup of NetSocketNew. --- Hotline.xcodeproj/project.pbxproj | 4 ++ Hotline/Library/NetSocketNew.swift | 4 +- Hotline/Library/SpinningGlobeView.swift | 90 +++++++++++++++++++++++++++++++++ Hotline/macOS/TrackerView.swift | 21 ++++---- 4 files changed, 106 insertions(+), 13 deletions(-) create mode 100644 Hotline/Library/SpinningGlobeView.swift diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index 2aafcfe..6368fe0 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -76,6 +76,7 @@ DAC3D9832BC33FD000A727C9 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC3D9822BC33FD000A727C9 /* AppState.swift */; }; DAC6B2E02EAC6236004E2CBA /* ChatStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC6B2DE2EAC6236004E2CBA /* ChatStore.swift */; }; DAC6B2E22EAEE9FE004E2CBA /* NetSocketNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC6B2E12EAEE9EF004E2CBA /* NetSocketNew.swift */; }; + DAC6B2E42EAFE92F004E2CBA /* SpinningGlobeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC6B2E32EAFE92F004E2CBA /* SpinningGlobeView.swift */; }; DAC87F072C5010E80060FADF /* HotlineExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC87F062C5010E80060FADF /* HotlineExtensions.swift */; }; DACCE5E12EABE4B4008CDD92 /* AppUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DACCE5E02EABE4B4008CDD92 /* AppUpdate.swift */; platformFilters = (macos, ); }; DACCE5E32EABE86A008CDD92 /* AppUpdateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */; platformFilters = (macos, ); }; @@ -165,6 +166,7 @@ DAC3D9822BC33FD000A727C9 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; DAC6B2DE2EAC6236004E2CBA /* ChatStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatStore.swift; sourceTree = ""; }; DAC6B2E12EAEE9EF004E2CBA /* NetSocketNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetSocketNew.swift; sourceTree = ""; }; + DAC6B2E32EAFE92F004E2CBA /* SpinningGlobeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpinningGlobeView.swift; sourceTree = ""; }; DAC87F062C5010E80060FADF /* HotlineExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineExtensions.swift; sourceTree = ""; }; DACCE5E02EABE4B4008CDD92 /* AppUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdate.swift; sourceTree = ""; }; DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdateView.swift; sourceTree = ""; }; @@ -278,6 +280,7 @@ isa = PBXGroup; children = ( DAC6B2E12EAEE9EF004E2CBA /* NetSocketNew.swift */, + DAC6B2E32EAFE92F004E2CBA /* SpinningGlobeView.swift */, DAB4D87A2B4B78310048A05C /* FileImageView.swift */, DAB4D8812B4C8FED0048A05C /* FileIconView.swift */, DAB4D8832B4CABEF0048A05C /* DataAdditions.swift */, @@ -491,6 +494,7 @@ DA7725412B21435B006C5ABB /* ObservableScrollView.swift in Sources */, DAE734FF2B2E6750000C56F6 /* ChatView.swift in Sources */, 11F8288B2BF9428100216BA0 /* AccountManagerView.swift in Sources */, + DAC6B2E42EAFE92F004E2CBA /* SpinningGlobeView.swift in Sources */, DA4930BD2B4F8DD700822D0B /* NetSocket.swift in Sources */, DAC002192B21630900A6C290 /* SwiftUIExtensions.swift in Sources */, DADDB28F2B238D850024040D /* Hotline.swift in Sources */, diff --git a/Hotline/Library/NetSocketNew.swift b/Hotline/Library/NetSocketNew.swift index d4507b8..8873ee6 100644 --- a/Hotline/Library/NetSocketNew.swift +++ b/Hotline/Library/NetSocketNew.swift @@ -1108,7 +1108,7 @@ fileprivate extension NetSocketNew { let values = try url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) guard values.isRegularFile == true else { throw NetSocketError.failed(underlying: NSError( - domain: "CleanSockets", code: 1001, + domain: "NetSocket", code: 1001, userInfo: [NSLocalizedDescriptionKey: "Not a regular file: \(url.path)"] )) } @@ -1116,7 +1116,7 @@ fileprivate extension NetSocketNew { let attrs = try FileManager.default.attributesOfItem(atPath: url.path) if let n = attrs[.size] as? NSNumber { return n.int64Value } throw NetSocketError.failed(underlying: NSError( - domain: "CleanSockets", code: 1002, + domain: "NetSocket", code: 1002, userInfo: [NSLocalizedDescriptionKey: "Unable to determine file size for \(url.lastPathComponent)"] )) } diff --git a/Hotline/Library/SpinningGlobeView.swift b/Hotline/Library/SpinningGlobeView.swift new file mode 100644 index 0000000..bccb969 --- /dev/null +++ b/Hotline/Library/SpinningGlobeView.swift @@ -0,0 +1,90 @@ +import SwiftUI + +/// An animated globe icon that cycles through different world regions +/// +/// Displays a spinning globe effect by cycling through SF Symbol images showing +/// different parts of the world. Useful for indicating network activity or global content. +/// +/// Example: +/// ```swift +/// SpinningGlobeView() +/// .frame(width: 16, height: 16) +/// +/// SpinningGlobeView(frameDelay: 0.5) +/// .frame(width: 24, height: 24) +/// ``` +struct SpinningGlobeView: View { + /// Delay between frames in seconds (default: 0.3) + let frameDelay: TimeInterval + + /// SF Symbol names for each frame of the globe animation + private let globeFrames = [ + "globe.americas.fill", + "globe.europe.africa.fill", + "globe.central.south.asia.fill", + "globe.asia.australia.fill" + ] + + @State private var currentFrameIndex = 0 + @State private var animationTask: Task? + + init(frameDelay: TimeInterval = 0.25) { + self.frameDelay = frameDelay + } + + var body: some View { + Image(systemName: globeFrames[currentFrameIndex]) + .onAppear { + startAnimation() + } + .onDisappear { + stopAnimation() + } + } + + private func startAnimation() { + animationTask = Task { + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: UInt64(frameDelay * 1_000_000_000)) + + guard !Task.isCancelled else { break } + + currentFrameIndex = (currentFrameIndex + 1) % globeFrames.count + } + } + } + + private func stopAnimation() { + animationTask?.cancel() + animationTask = nil + } +} + +#Preview { + VStack(spacing: 20) { + HStack(spacing: 20) { + SpinningGlobeView() + .frame(width: 12, height: 12) + + SpinningGlobeView() + .frame(width: 16, height: 16) + + SpinningGlobeView() + .frame(width: 24, height: 24) + } + + Text("Different frame delays:") + + HStack(spacing: 20) { + SpinningGlobeView(frameDelay: 0.1) + .frame(width: 16, height: 16) + + SpinningGlobeView(frameDelay: 0.3) + .frame(width: 16, height: 16) + + SpinningGlobeView(frameDelay: 0.6) + .frame(width: 16, height: 16) + } + } + .padding() +} diff --git a/Hotline/macOS/TrackerView.swift b/Hotline/macOS/TrackerView.swift index ae653b8..251248f 100644 --- a/Hotline/macOS/TrackerView.swift +++ b/Hotline/macOS/TrackerView.swift @@ -755,19 +755,18 @@ struct TrackerItemView: View { if isExpanded && count > 0 { HStack(spacing: 4) { Text(String(count)) - - Image(systemName: "globe.americas.fill") - .resizable() - .scaledToFit() + + SpinningGlobeView() + .fontWeight(.semibold) .frame(width: 12, height: 12) - .opacity(0.5) +// .opacity(0.5) } - - .padding(.horizontal, 8) - .padding(.vertical, 2) - .foregroundStyle(.secondary) - .background(.quinary) - .clipShape(.capsule) + + .padding(.horizontal, 8) + .padding(.vertical, 2) + .foregroundStyle(.secondary) + .background(.quinary) + .clipShape(.capsule) } case .server: Image(systemName: "bookmark.fill") -- cgit From 9d23439c2ad70cb2f0a70cd3af9a07f99f764ca1 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Mon, 27 Oct 2025 11:59:53 -0700 Subject: Add public chat features to README --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 8fc1b89..1920bf4 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,8 @@ And this project is an attempt to create a modern open source version of Hotline | Display server agreement | ✓ | ✓ | | | | Display server banner | ✓ | ✓ | | | | Public chat | ✓ | ✓ | | | +| Public chat search | ✓ | | | | +| Public chat persistence | ✓ | | | | | Private messages | ✓ | | | | | User list | ✓ | ✓ | | | | User icons | ✓ | ✓ | | | -- cgit From 924012e6ee03fff5570e9362e29be9b04816b777 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Mon, 27 Oct 2025 12:52:15 -0700 Subject: Fix default bookmark address for Mobius Strip. --- Hotline/Models/Bookmark.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Hotline/Models/Bookmark.swift b/Hotline/Models/Bookmark.swift index 6063f48..69217f3 100644 --- a/Hotline/Models/Bookmark.swift +++ b/Hotline/Models/Bookmark.swift @@ -75,7 +75,7 @@ final class Bookmark { } static let DefaultBookmarks: [Bookmark] = [ - Bookmark(type: .server, name: "The Mobius Strip", address: "24.6.82.54", port: HotlinePorts.DefaultServerPort), + Bookmark(type: .server, name: "The Mobius Strip", address: "mobius.trtphotl.com", port: HotlinePorts.DefaultServerPort), Bookmark(type: .server, name: "System 7 Today", address: "hotline.system7today.com", port: HotlinePorts.DefaultServerPort), Bookmark(type: .tracker, name: "Featured Servers", address: "hltracker.com", port: HotlinePorts.DefaultTrackerPort) ] -- cgit From 4bb0ffba596e41a8309ba50d222248a0ba3eb42e Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Mon, 27 Oct 2025 21:48:20 -0700 Subject: Random project housekeeping/organizing source files. --- Hotline.xcodeproj/project.pbxproj | 104 +++- Hotline/macOS/AccountManagerView.swift | 398 --------------- Hotline/macOS/Accounts/AccountManagerView.swift | 398 +++++++++++++++ Hotline/macOS/Board/MessageBoardEditorView.swift | 117 +++++ Hotline/macOS/Board/MessageBoardView.swift | 102 ++++ Hotline/macOS/Chat/ChatView.swift | 321 ++++++++++++ Hotline/macOS/Chat/ServerAgreementView.swift | 78 +++ Hotline/macOS/Chat/ServerMessageView.swift | 33 ++ Hotline/macOS/ChatView.swift | 321 ------------ Hotline/macOS/FileDetailsView.swift | 147 ------ Hotline/macOS/FilePreviewImageView.swift | 123 ----- Hotline/macOS/FilePreviewTextView.swift | 127 ----- Hotline/macOS/Files/FileDetailsView.swift | 147 ++++++ Hotline/macOS/Files/FileItemView.swift | 67 +++ Hotline/macOS/Files/FilePreviewImageView.swift | 123 +++++ Hotline/macOS/Files/FilePreviewTextView.swift | 127 +++++ Hotline/macOS/Files/FilesView.swift | 418 +++++++++++++++ Hotline/macOS/Files/FolderItemView.swift | 140 +++++ Hotline/macOS/FilesView.swift | 619 ----------------------- Hotline/macOS/MessageBoardEditorView.swift | 117 ----- Hotline/macOS/MessageBoardView.swift | 102 ---- Hotline/macOS/News/NewsEditorView.swift | 153 ++++++ Hotline/macOS/News/NewsItemView.swift | 152 ++++++ Hotline/macOS/News/NewsView.swift | 297 +++++++++++ Hotline/macOS/NewsEditorView.swift | 153 ------ Hotline/macOS/NewsItemView.swift | 152 ------ Hotline/macOS/NewsView.swift | 297 ----------- Hotline/macOS/ServerAgreementView.swift | 78 --- Hotline/macOS/ServerMessageView.swift | 33 -- Hotline/macOS/Settings/GeneralSettingsView.swift | 67 +++ Hotline/macOS/Settings/IconSettingsView.swift | 58 +++ Hotline/macOS/Settings/SettingsView.swift | 31 ++ Hotline/macOS/Settings/SoundSettingsView.swift | 40 ++ Hotline/macOS/SettingsView.swift | 193 ------- Hotline/macOS/TrackerView.swift | 64 +-- 35 files changed, 2966 insertions(+), 2931 deletions(-) delete mode 100644 Hotline/macOS/AccountManagerView.swift create mode 100644 Hotline/macOS/Accounts/AccountManagerView.swift create mode 100644 Hotline/macOS/Board/MessageBoardEditorView.swift create mode 100644 Hotline/macOS/Board/MessageBoardView.swift create mode 100644 Hotline/macOS/Chat/ChatView.swift create mode 100644 Hotline/macOS/Chat/ServerAgreementView.swift create mode 100644 Hotline/macOS/Chat/ServerMessageView.swift delete mode 100644 Hotline/macOS/ChatView.swift delete mode 100644 Hotline/macOS/FileDetailsView.swift delete mode 100644 Hotline/macOS/FilePreviewImageView.swift delete mode 100644 Hotline/macOS/FilePreviewTextView.swift create mode 100644 Hotline/macOS/Files/FileDetailsView.swift create mode 100644 Hotline/macOS/Files/FileItemView.swift create mode 100644 Hotline/macOS/Files/FilePreviewImageView.swift create mode 100644 Hotline/macOS/Files/FilePreviewTextView.swift create mode 100644 Hotline/macOS/Files/FilesView.swift create mode 100644 Hotline/macOS/Files/FolderItemView.swift delete mode 100644 Hotline/macOS/FilesView.swift delete mode 100644 Hotline/macOS/MessageBoardEditorView.swift delete mode 100644 Hotline/macOS/MessageBoardView.swift create mode 100644 Hotline/macOS/News/NewsEditorView.swift create mode 100644 Hotline/macOS/News/NewsItemView.swift create mode 100644 Hotline/macOS/News/NewsView.swift delete mode 100644 Hotline/macOS/NewsEditorView.swift delete mode 100644 Hotline/macOS/NewsItemView.swift delete mode 100644 Hotline/macOS/NewsView.swift delete mode 100644 Hotline/macOS/ServerAgreementView.swift delete mode 100644 Hotline/macOS/ServerMessageView.swift create mode 100644 Hotline/macOS/Settings/GeneralSettingsView.swift create mode 100644 Hotline/macOS/Settings/IconSettingsView.swift create mode 100644 Hotline/macOS/Settings/SettingsView.swift create mode 100644 Hotline/macOS/Settings/SoundSettingsView.swift delete mode 100644 Hotline/macOS/SettingsView.swift diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index 6368fe0..a173d55 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -8,7 +8,7 @@ /* Begin PBXBuildFile section */ 11A726082BE0672A000C1DA7 /* FileDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11A726072BE0672A000C1DA7 /* FileDetails.swift */; }; - 11A7260A2BE0675A000C1DA7 /* FileDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11A726092BE06759000C1DA7 /* FileDetailsView.swift */; }; + 11A7260A2BE0675A000C1DA7 /* FileDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11A726092BE06759000C1DA7 /* FileDetailsView.swift */; platformFilters = (macos, ); }; 11F8288B2BF9428100216BA0 /* AccountManagerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11F8288A2BF9428100216BA0 /* AccountManagerView.swift */; }; DA0D698D2B1E7CF700C71DF5 /* UsersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0D698C2B1E7CF700C71DF5 /* UsersView.swift */; platformFilter = ios; }; DA0D698F2B1E841600C71DF5 /* MessageBoardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0D698E2B1E841600C71DF5 /* MessageBoardView.swift */; platformFilter = ios; }; @@ -27,6 +27,11 @@ DA4B8F3A2EA6FB3C00CBFD53 /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4B8F392EA6FB3C00CBFD53 /* iOSApp.swift */; platformFilter = ios; }; DA4F2BF82B16A17200D8ADDC /* HotlineProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4F2BF72B16A17200D8ADDC /* HotlineProtocol.swift */; }; DA4F2C012B1A558E00D8ADDC /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4F2C002B1A558E00D8ADDC /* ChatView.swift */; platformFilter = ios; }; + 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, ); }; + DA5268A32EB0741B00DCB941 /* FolderItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268A22EB0741B00DCB941 /* FolderItemView.swift */; platformFilters = (macos, ); }; + DA5268A52EB0743000DCB941 /* FileItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268A42EB0743000DCB941 /* FileItemView.swift */; platformFilters = (macos, ); }; DA55AC732BE42AF000034857 /* AsyncLinkPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA55AC722BE42AF000034857 /* AsyncLinkPreview.swift */; platformFilters = (macos, ); }; DA55AC752BE4888300034857 /* InstantMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA55AC742BE4888300034857 /* InstantMessage.swift */; }; DA55AC772BE589F700034857 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA55AC762BE589F700034857 /* AboutView.swift */; platformFilters = (macos, ); }; @@ -118,6 +123,11 @@ DA4B8F392EA6FB3C00CBFD53 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; }; DA4F2BF72B16A17200D8ADDC /* HotlineProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineProtocol.swift; sourceTree = ""; }; DA4F2C002B1A558E00D8ADDC /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = ""; }; + DA52689B2EB0738B00DCB941 /* GeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsView.swift; sourceTree = ""; }; + DA52689D2EB073A400DCB941 /* IconSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconSettingsView.swift; sourceTree = ""; }; + DA52689F2EB073BC00DCB941 /* SoundSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundSettingsView.swift; sourceTree = ""; }; + DA5268A22EB0741B00DCB941 /* FolderItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderItemView.swift; sourceTree = ""; }; + DA5268A42EB0743000DCB941 /* FileItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileItemView.swift; sourceTree = ""; }; DA55AC722BE42AF000034857 /* AsyncLinkPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncLinkPreview.swift; sourceTree = ""; }; DA55AC742BE4888300034857 /* InstantMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstantMessage.swift; sourceTree = ""; }; DA55AC762BE589F700034857 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; @@ -230,6 +240,67 @@ path = iOS; sourceTree = ""; }; + DA52689A2EB0737400DCB941 /* Settings */ = { + isa = PBXGroup; + children = ( + DA2863D72B37AD1C00A7D050 /* SettingsView.swift */, + DA52689B2EB0738B00DCB941 /* GeneralSettingsView.swift */, + DA52689D2EB073A400DCB941 /* IconSettingsView.swift */, + DA52689F2EB073BC00DCB941 /* SoundSettingsView.swift */, + ); + path = Settings; + sourceTree = ""; + }; + DA5268A12EB073E600DCB941 /* Files */ = { + isa = PBXGroup; + children = ( + DAE735002B2E71F2000C56F6 /* FilesView.swift */, + DA5268A42EB0743000DCB941 /* FileItemView.swift */, + DA5268A22EB0741B00DCB941 /* FolderItemView.swift */, + 11A726092BE06759000C1DA7 /* FileDetailsView.swift */, + DAAEE66E2B47625600A5BA07 /* FilePreviewImageView.swift */, + DAB4D87D2B4C8BCA0048A05C /* FilePreviewTextView.swift */, + ); + path = Files; + sourceTree = ""; + }; + DA5268A62EB0762300DCB941 /* Board */ = { + isa = PBXGroup; + children = ( + DAE734FC2B2E65E9000C56F6 /* MessageBoardView.swift */, + DA69807D2BFD449B003E434B /* MessageBoardEditorView.swift */, + ); + path = Board; + sourceTree = ""; + }; + DA5268A72EB0812E00DCB941 /* Accounts */ = { + isa = PBXGroup; + children = ( + 11F8288A2BF9428100216BA0 /* AccountManagerView.swift */, + ); + path = Accounts; + sourceTree = ""; + }; + DA5268A82EB081AF00DCB941 /* Chat */ = { + isa = PBXGroup; + children = ( + DAE734FE2B2E6750000C56F6 /* ChatView.swift */, + DA6549992BEC280E00EDB697 /* ServerMessageView.swift */, + DA65499B2BEC3FBD00EDB697 /* ServerAgreementView.swift */, + ); + path = Chat; + sourceTree = ""; + }; + DA5268A92EB081DE00DCB941 /* News */ = { + isa = PBXGroup; + children = ( + DAE735042B3218D8000C56F6 /* NewsView.swift */, + DA2D98112BF29D5F0027E4BD /* NewsItemView.swift */, + DA72A0DC2B4CD0BF00A0F48A /* NewsEditorView.swift */, + ); + path = News; + sourceTree = ""; + }; DA9CAFAE2B126D5700CDA197 = { isa = PBXGroup; children = ( @@ -370,26 +441,18 @@ DAE734F72B2E4126000C56F6 /* macOS */ = { isa = PBXGroup; children = ( + DA55AC762BE589F700034857 /* AboutView.swift */, + DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */, + DAE136B92B9D1147007D8307 /* HotlinePanelView.swift */, DAE734FA2B2E41F9000C56F6 /* TrackerView.swift */, DAE734F82B2E4185000C56F6 /* ServerView.swift */, - DAE734FE2B2E6750000C56F6 /* ChatView.swift */, - DAE735042B3218D8000C56F6 /* NewsView.swift */, - DA2D98112BF29D5F0027E4BD /* NewsItemView.swift */, - DA72A0DC2B4CD0BF00A0F48A /* NewsEditorView.swift */, DAE735022B30C0BB000C56F6 /* MessageView.swift */, - DAE734FC2B2E65E9000C56F6 /* MessageBoardView.swift */, - DA69807D2BFD449B003E434B /* MessageBoardEditorView.swift */, - DAE735002B2E71F2000C56F6 /* FilesView.swift */, - DAAEE66E2B47625600A5BA07 /* FilePreviewImageView.swift */, - DAB4D87D2B4C8BCA0048A05C /* FilePreviewTextView.swift */, - 11A726092BE06759000C1DA7 /* FileDetailsView.swift */, - DA2863D72B37AD1C00A7D050 /* SettingsView.swift */, - DAE136B92B9D1147007D8307 /* HotlinePanelView.swift */, - DA55AC762BE589F700034857 /* AboutView.swift */, - DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */, - DA65499B2BEC3FBD00EDB697 /* ServerAgreementView.swift */, - DA6549992BEC280E00EDB697 /* ServerMessageView.swift */, - 11F8288A2BF9428100216BA0 /* AccountManagerView.swift */, + DA5268A82EB081AF00DCB941 /* Chat */, + DA5268A62EB0762300DCB941 /* Board */, + DA5268A92EB081DE00DCB941 /* News */, + DA5268A12EB073E600DCB941 /* Files */, + DA5268A72EB0812E00DCB941 /* Accounts */, + DA52689A2EB0737400DCB941 /* Settings */, ); path = macOS; sourceTree = ""; @@ -486,6 +549,7 @@ DA6300972B24036B0034CBFD /* HotlineClient.swift in Sources */, DA43205E2B1D615600FC8843 /* ServerView.swift in Sources */, DA0D69912B1E894800C71DF5 /* FilesView.swift in Sources */, + DA52689E2EB073A400DCB941 /* IconSettingsView.swift in Sources */, DAC3D9832BC33FD000A727C9 /* AppState.swift in Sources */, DA65499C2BEC3FBD00EDB697 /* ServerAgreementView.swift in Sources */, DAB4D8802B4C8E9A0048A05C /* URLAdditions.swift in Sources */, @@ -503,6 +567,7 @@ DA55AC772BE589F700034857 /* AboutView.swift in Sources */, DA72A0E22B4DAA4000A0F48A /* NewsArticle.swift in Sources */, DAE734FB2B2E41F9000C56F6 /* TrackerView.swift in Sources */, + DA5268A32EB0741B00DCB941 /* FolderItemView.swift in Sources */, DAE136BA2B9D1147007D8307 /* HotlinePanelView.swift in Sources */, 11A726082BE0672A000C1DA7 /* FileDetails.swift in Sources */, DA9CAFBD2B126D5700CDA197 /* TrackerView.swift in Sources */, @@ -524,8 +589,10 @@ DA69807E2BFD449B003E434B /* MessageBoardEditorView.swift in Sources */, DAE735012B2E71F2000C56F6 /* FilesView.swift in Sources */, DA55AC792BE6A1AD00034857 /* RegularExpressions.swift in Sources */, + DA5268A02EB073BC00DCB941 /* SoundSettingsView.swift in Sources */, 11A7260A2BE0675A000C1DA7 /* FileDetailsView.swift in Sources */, DA72A0DD2B4CD0BF00A0F48A /* NewsEditorView.swift in Sources */, + DA52689C2EB0738B00DCB941 /* GeneralSettingsView.swift in Sources */, DABFCC292B1530DC009F40D2 /* FoundationExtensions.swift in Sources */, DA57536C2B36BA1D00FAC277 /* TextDocument.swift in Sources */, DACCE5E12EABE4B4008CDD92 /* AppUpdate.swift in Sources */, @@ -544,6 +611,7 @@ DAB4D8842B4CABEF0048A05C /* DataAdditions.swift in Sources */, DA0D698D2B1E7CF700C71DF5 /* UsersView.swift in Sources */, DAE735052B3218D8000C56F6 /* NewsView.swift in Sources */, + DA5268A52EB0743000DCB941 /* FileItemView.swift in Sources */, DAE734F92B2E4185000C56F6 /* ServerView.swift in Sources */, DA2D98122BF29D5F0027E4BD /* NewsItemView.swift in Sources */, DADDB28B2B22B31F0024040D /* Tracker.swift in Sources */, diff --git a/Hotline/macOS/AccountManagerView.swift b/Hotline/macOS/AccountManagerView.swift deleted file mode 100644 index 57682cc..0000000 --- a/Hotline/macOS/AccountManagerView.swift +++ /dev/null @@ -1,398 +0,0 @@ -import SwiftUI - -struct AccountManagerView: View { - @Environment(Hotline.self) private var model: Hotline - - @State private var accounts: [HotlineAccount] = [] - @State private var selection: HotlineAccount? - @State private var loading: Bool = true - - @State private var pendingName: String = "" - @State private var pendingLogin: String = "" - @State private var pendingPassword: String = "" - @State private var pendingAccess = HotlineUserAccessOptions.defaultAccess - - @State private var toDelete: HotlineAccount? - - let placeholderPassword = "xxxxxxxxxxxxxxxxxx" - - var body: some View { - HStack(spacing: 0) { - ZStack { - accountList - if loading { - ProgressView() - } - } - if selection != nil { - accountDetails - } - else { - ZStack(alignment: .center) { - Text("No Account Selected") - .font(.title) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding() - } - .frame(maxWidth: .infinity) - } - } - .environment(\.defaultMinListRowHeight, 34) - .listStyle(.inset) - .alternatingRowBackgrounds(.enabled) - .task { - if loading { - accounts = await model.getAccounts() - loading = false - } - } - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button { - let newAccount = HotlineAccount("unnamed", "unnamed", HotlineUserAccessOptions.defaultAccess) - - pendingPassword = HotlineAccount.randomPassword() - accounts.append(newAccount) - selection = newAccount - } label: { - Label("New Account", systemImage: "plus") - } - .help("Create a new account") - .disabled(model.access?.contains(.canCreateUsers) != true) - } - - ToolbarItem(placement: .destructiveAction) { - Button { - toDelete = selection - } label: { - Label("Delete Account", systemImage: "trash") - } - .help("Delete account") - .disabled(selection == nil || model.access?.contains(.canDeleteUsers) != true) - } - } - } - - var accountDetails: some View { - VStack(alignment: .center, spacing: 0) { - ScrollView(.vertical) { - Form { - Section { - TextField(text: $pendingName) { - Text("Name") - } - TextField("Login", text: $pendingLogin) - .disabled(selection?.persisted == true) - if selection?.persisted == true { - SecureField("Password", text: $pendingPassword) - } else { - TextField("Password", text: $pendingPassword) - } - } - .textFieldStyle(.roundedBorder) - .controlSize(.large) - - Section("File System Maintenance") { - Toggle("Can Download Files", isOn: $pendingAccess.bind(.canDownloadFiles)) - .disabled(model.access?.contains(.canDownloadFiles) == false) - Toggle("Can Download Folders", isOn: $pendingAccess.bind(.canDownloadFolders)) - .disabled(model.access?.contains(.canDownloadFolders) == false) - Toggle("Can Upload Files", isOn: $pendingAccess.bind(.canUploadFiles)) - .disabled(model.access?.contains(.canUploadFiles) == false) - Toggle("Can Upload Folders", isOn: $pendingAccess.bind(.canUploadFolders)) - .disabled(model.access?.contains(.canUploadFolders) == false) - Toggle("Can Upload Anywhere", isOn: $pendingAccess.bind(.canUploadAnywhere)) - .disabled(model.access?.contains(.canUploadAnywhere) == false) - Toggle("Can Delete Files", isOn: $pendingAccess.bind(.canDeleteFiles)) - .disabled(model.access?.contains(.canDeleteFiles) == false) - Toggle("Can Rename Files", isOn: $pendingAccess.bind(.canRenameFiles)) - .disabled(model.access?.contains(.canRenameFiles) == false) - Toggle("Can Move Files", isOn: $pendingAccess.bind(.canMoveFiles)) - .disabled(model.access?.contains(.canMoveFiles) == false) - Toggle("Can Comment Files", isOn: $pendingAccess.bind(.canSetFileComment)) - .disabled(model.access?.contains(.canSetFileComment) == false) - Toggle("Can Create Folders", isOn: $pendingAccess.bind(.canCreateFolders)) - .disabled(model.access?.contains(.canCreateFolders) == false) - Toggle("Can Delete Folders", isOn: $pendingAccess.bind(.canDeleteFolders)) - .disabled(model.access?.contains(.canDeleteFolders) == false) - Toggle("Can Rename Folders", isOn: $pendingAccess.bind(.canRenameFolders)) - .disabled(model.access?.contains(.canRenameFolders) == false) - Toggle("Can Move Folders", isOn: $pendingAccess.bind(.canMoveFolders)) - .disabled(model.access?.contains(.canMoveFolders) == false) - Toggle("Can Comment Folders", isOn: $pendingAccess.bind(.canSetFolderComment)) - .disabled(model.access?.contains(.canSetFolderComment) == false) - Toggle("Can View Drop Boxes", isOn: $pendingAccess.bind(.canViewDropBoxes)) - .disabled(model.access?.contains(.canViewDropBoxes) == false) - Toggle("Can Make Aliases", isOn: $pendingAccess.bind(.canMakeAliases)) - .disabled(model.access?.contains(.canMakeAliases) == false) - } - - Section("User Maintenance") { - Toggle("Can Create Accounts", isOn: $pendingAccess.bind(.canCreateUsers)) - .disabled(model.access?.contains(.canCreateUsers) == false) - Toggle("Can Delete Accounts", isOn: $pendingAccess.bind(.canDeleteUsers)) - .disabled(model.access?.contains(.canDeleteUsers) == false) - Toggle("Can Read Accounts", isOn: $pendingAccess.bind(.canOpenUsers)) - .disabled(model.access?.contains(.canOpenUsers) == false) - Toggle("Can Modify Accounts", isOn: $pendingAccess.bind(.canModifyUsers)) - .disabled(model.access?.contains(.canModifyUsers) == false) - Toggle("Can Get User Info", isOn: $pendingAccess.bind(.canGetClientInfo)) - .disabled(model.access?.contains(.canGetClientInfo) == false) - - Toggle("Can Disconnect Users", isOn: $pendingAccess.bind(.canDisconnectUsers)) - .disabled(model.access?.contains(.canDisconnectUsers) == false) - Toggle("Cannot be Disconnected", isOn: $pendingAccess.bind(.cantBeDisconnected)) - .disabled(model.access?.contains(.cantBeDisconnected) == false) - } - - Section("Messaging") { - Toggle("Can Send Messages", isOn: $pendingAccess.bind(.canSendMessages)) - .disabled(model.access?.contains(.canSendMessages) == false) - Toggle("Can Broadcast", isOn: $pendingAccess.bind(.canBroadcast)) - .disabled(model.access?.contains(.canBroadcast) == false) - } - - Section("News") { - Toggle("Can Read Articles", isOn: $pendingAccess.bind(.canReadMessageBoard)) - .disabled(model.access?.contains(.canReadMessageBoard) == false) - Toggle("Can Post Articles", isOn: $pendingAccess.bind(.canPostMessageBoard)) - .disabled(model.access?.contains(.canPostMessageBoard) == false) - Toggle("Can Delete Articles", isOn: $pendingAccess.bind(.canDeleteNewsArticles)) - .disabled(model.access?.contains(.canDeleteNewsArticles) == false) - Toggle("Can Create Categories", isOn: $pendingAccess.bind(.canCreateNewsCategories)) - .disabled(model.access?.contains(.canCreateNewsCategories) == false) - Toggle("Can Delete Categories", isOn: $pendingAccess.bind(.canDeleteNewsCategories)) - .disabled(model.access?.contains(.canDeleteNewsCategories) == false) - Toggle("Can Create News Bundles", isOn: $pendingAccess.bind(.canCreateNewsFolders)) - .disabled(model.access?.contains(.canCreateNewsFolders) == false) - Toggle("Can Delete News Bundles", isOn: $pendingAccess.bind(.canDeleteNewsFolders)) - .disabled(model.access?.contains(.canDeleteNewsFolders) == false) - } - - Section("Chat") { - Toggle("Can Initiate Private Chat", isOn: $pendingAccess.bind(.canCreateChat)) - .disabled(model.access?.contains(.canCreateChat) == false) - Toggle("Can Read Chat", isOn: $pendingAccess.bind(.canReadChat)) - .disabled(model.access?.contains(.canReadChat) == false) - Toggle("Can Send Chat", isOn: $pendingAccess.bind(.canSendChat)) - .disabled(model.access?.contains(.canSendChat) == false) - } - - Section("Miscellaneous") { - Toggle("Can Use Any Name", isOn: $pendingAccess.bind(.canUseAnyName)) - .disabled(model.access?.contains(.canUseAnyName) == false) - Toggle("Don't Show Agreement", isOn: $pendingAccess.bind(.canSkipAgreement)) - .disabled(model.access?.contains(.canSkipAgreement) == false) - } - } - .disabled(model.access?.contains(.canModifyUsers) == false) - .formStyle(.grouped) - .onChange(of: selection) { - if let selection { - pendingName = selection.name - pendingLogin = selection.login - pendingAccess = selection.access - - if selection.persisted { - if selection.password == nil { - pendingPassword = "" - } else { - pendingPassword = placeholderPassword - } - } - } - } - .onAppear() { - if let selection { - pendingName = selection.name - pendingLogin = selection.login - pendingAccess = selection.access - - if selection.persisted { - if selection.password == nil { - pendingPassword = "" - } else { - pendingPassword = placeholderPassword - } - } else { - pendingPassword = HotlineAccount.randomPassword() - } - } - } - } - .frame(maxWidth: .infinity) - - Divider() - - HStack() { - Button("Revert") { - if let selection { - pendingAccess = selection.access - pendingName = selection.name - pendingLogin = selection.login - - if selection.password != nil { - pendingPassword = selection.password! - } - } - } - .controlSize(.large) - .frame(minWidth: 75) - .disabled(!self.isSaveable()) -// .padding() - - Spacer() - - Button("Save"){ - guard let selection else { - return - } - - // Update existing account - if selection.persisted == true { - - if pendingPassword == placeholderPassword { - Task { @MainActor in - model.client.sendSetUser(name: pendingName, login: pendingLogin, newLogin: nil, password: nil, access: pendingAccess.rawValue) - } - } else { - Task { @MainActor in - model.client.sendSetUser(name: pendingName, login: pendingLogin, newLogin: nil, password: pendingPassword, access: pendingAccess.rawValue) - } - } - - } else { - // Create new existing account - Task { @MainActor in - model.client.sendCreateUser(name: pendingName, login: pendingLogin, password: pendingPassword, access: pendingAccess.rawValue) - } - self.selection?.password = pendingPassword - pendingPassword = placeholderPassword - } - - var account = HotlineAccount(pendingName, pendingLogin, pendingAccess) - account.persisted = true - account.password = placeholderPassword - - accounts = accounts.filter { $0.persisted == true && $0.login != selection.login } - - // Add new account to list - accounts.append(account) - - // Re-sort accounts - accounts.sort { $0.login < $1.login } - self.selection = account - } - .controlSize(.large) - .frame(minWidth: 75) - .keyboardShortcut(.defaultAction) - .disabled(!self.isSaveable()) - } - .padding() - } - - } - - var accountList: some View { - List(accounts, id: \.self, selection: $selection) { account in - HStack(spacing: 5) { - if account.access.contains(.canDisconnectUsers) { - Image("User Admin") - .frame(width: 16, height: 16) - .opacity(account.persisted ? 1.0 : 0.25) - // .padding(.leading, 4) - Text(account.login) - .foregroundStyle(Color.hotlineRed) - } - else if account.access.rawValue == 0 { - Image("User") - .frame(width: 16, height: 16) - // .padding(.leading, 4) - Text(account.login) - .foregroundStyle(.secondary) - } - // else if account.persisted == false { - // HStack { - // Image("User") - // .frame(width: 16, height: 16) - //// .padding(.leading, 4) - // Text(account.login) - // .italic() - // } - // } - else { - Image("User") - .frame(width: 16, height: 16) - .opacity(account.persisted ? 1.0 : 0.5) - // .padding(.leading, 4) - Text(account.login) - } - } - } - .frame(width: 250) - .sheet(item: $toDelete) { item in - Form { - HStack{ - Image(systemName: "exclamationmark.triangle") - .font(.system(size: 30)) - Text("Delete account \"\(item.name)\" and all associated files?") - .lineSpacing(4) - } - } - .frame(minWidth: 300, idealWidth: 450, maxWidth: .infinity, minHeight: 100, idealHeight: 100, maxHeight: .infinity) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - toDelete = nil - } - } - - ToolbarItem(placement: .primaryAction) { - Button("Delete") { - guard let userToDelete = toDelete else { - return - } - - self.toDelete = nil - self.selection = nil - - if userToDelete.persisted { - Task { @MainActor in - model.client.sendDeleteUser(login: userToDelete.login) - } - } - - accounts = accounts.filter { $0.login != userToDelete.login } - - } - } - } - } - } - - - private func isSaveable() -> Bool { - guard let selection else { - return false - } - - // Disable save if login field is cleared - if pendingLogin == "" { - return false - } - - // If the account initial has a password and it was updated - if selection.password != nil && pendingPassword != placeholderPassword { - return true - } - - // If the account initial has no password, but was updated to have one - if selection.password == nil && pendingPassword != "" { - return true - } - - // If the access bits or user name have been changed - return pendingAccess.rawValue != selection.access.rawValue || selection.name != pendingName - } -} diff --git a/Hotline/macOS/Accounts/AccountManagerView.swift b/Hotline/macOS/Accounts/AccountManagerView.swift new file mode 100644 index 0000000..57682cc --- /dev/null +++ b/Hotline/macOS/Accounts/AccountManagerView.swift @@ -0,0 +1,398 @@ +import SwiftUI + +struct AccountManagerView: View { + @Environment(Hotline.self) private var model: Hotline + + @State private var accounts: [HotlineAccount] = [] + @State private var selection: HotlineAccount? + @State private var loading: Bool = true + + @State private var pendingName: String = "" + @State private var pendingLogin: String = "" + @State private var pendingPassword: String = "" + @State private var pendingAccess = HotlineUserAccessOptions.defaultAccess + + @State private var toDelete: HotlineAccount? + + let placeholderPassword = "xxxxxxxxxxxxxxxxxx" + + var body: some View { + HStack(spacing: 0) { + ZStack { + accountList + if loading { + ProgressView() + } + } + if selection != nil { + accountDetails + } + else { + ZStack(alignment: .center) { + Text("No Account Selected") + .font(.title) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding() + } + .frame(maxWidth: .infinity) + } + } + .environment(\.defaultMinListRowHeight, 34) + .listStyle(.inset) + .alternatingRowBackgrounds(.enabled) + .task { + if loading { + accounts = await model.getAccounts() + loading = false + } + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + let newAccount = HotlineAccount("unnamed", "unnamed", HotlineUserAccessOptions.defaultAccess) + + pendingPassword = HotlineAccount.randomPassword() + accounts.append(newAccount) + selection = newAccount + } label: { + Label("New Account", systemImage: "plus") + } + .help("Create a new account") + .disabled(model.access?.contains(.canCreateUsers) != true) + } + + ToolbarItem(placement: .destructiveAction) { + Button { + toDelete = selection + } label: { + Label("Delete Account", systemImage: "trash") + } + .help("Delete account") + .disabled(selection == nil || model.access?.contains(.canDeleteUsers) != true) + } + } + } + + var accountDetails: some View { + VStack(alignment: .center, spacing: 0) { + ScrollView(.vertical) { + Form { + Section { + TextField(text: $pendingName) { + Text("Name") + } + TextField("Login", text: $pendingLogin) + .disabled(selection?.persisted == true) + if selection?.persisted == true { + SecureField("Password", text: $pendingPassword) + } else { + TextField("Password", text: $pendingPassword) + } + } + .textFieldStyle(.roundedBorder) + .controlSize(.large) + + Section("File System Maintenance") { + Toggle("Can Download Files", isOn: $pendingAccess.bind(.canDownloadFiles)) + .disabled(model.access?.contains(.canDownloadFiles) == false) + Toggle("Can Download Folders", isOn: $pendingAccess.bind(.canDownloadFolders)) + .disabled(model.access?.contains(.canDownloadFolders) == false) + Toggle("Can Upload Files", isOn: $pendingAccess.bind(.canUploadFiles)) + .disabled(model.access?.contains(.canUploadFiles) == false) + Toggle("Can Upload Folders", isOn: $pendingAccess.bind(.canUploadFolders)) + .disabled(model.access?.contains(.canUploadFolders) == false) + Toggle("Can Upload Anywhere", isOn: $pendingAccess.bind(.canUploadAnywhere)) + .disabled(model.access?.contains(.canUploadAnywhere) == false) + Toggle("Can Delete Files", isOn: $pendingAccess.bind(.canDeleteFiles)) + .disabled(model.access?.contains(.canDeleteFiles) == false) + Toggle("Can Rename Files", isOn: $pendingAccess.bind(.canRenameFiles)) + .disabled(model.access?.contains(.canRenameFiles) == false) + Toggle("Can Move Files", isOn: $pendingAccess.bind(.canMoveFiles)) + .disabled(model.access?.contains(.canMoveFiles) == false) + Toggle("Can Comment Files", isOn: $pendingAccess.bind(.canSetFileComment)) + .disabled(model.access?.contains(.canSetFileComment) == false) + Toggle("Can Create Folders", isOn: $pendingAccess.bind(.canCreateFolders)) + .disabled(model.access?.contains(.canCreateFolders) == false) + Toggle("Can Delete Folders", isOn: $pendingAccess.bind(.canDeleteFolders)) + .disabled(model.access?.contains(.canDeleteFolders) == false) + Toggle("Can Rename Folders", isOn: $pendingAccess.bind(.canRenameFolders)) + .disabled(model.access?.contains(.canRenameFolders) == false) + Toggle("Can Move Folders", isOn: $pendingAccess.bind(.canMoveFolders)) + .disabled(model.access?.contains(.canMoveFolders) == false) + Toggle("Can Comment Folders", isOn: $pendingAccess.bind(.canSetFolderComment)) + .disabled(model.access?.contains(.canSetFolderComment) == false) + Toggle("Can View Drop Boxes", isOn: $pendingAccess.bind(.canViewDropBoxes)) + .disabled(model.access?.contains(.canViewDropBoxes) == false) + Toggle("Can Make Aliases", isOn: $pendingAccess.bind(.canMakeAliases)) + .disabled(model.access?.contains(.canMakeAliases) == false) + } + + Section("User Maintenance") { + Toggle("Can Create Accounts", isOn: $pendingAccess.bind(.canCreateUsers)) + .disabled(model.access?.contains(.canCreateUsers) == false) + Toggle("Can Delete Accounts", isOn: $pendingAccess.bind(.canDeleteUsers)) + .disabled(model.access?.contains(.canDeleteUsers) == false) + Toggle("Can Read Accounts", isOn: $pendingAccess.bind(.canOpenUsers)) + .disabled(model.access?.contains(.canOpenUsers) == false) + Toggle("Can Modify Accounts", isOn: $pendingAccess.bind(.canModifyUsers)) + .disabled(model.access?.contains(.canModifyUsers) == false) + Toggle("Can Get User Info", isOn: $pendingAccess.bind(.canGetClientInfo)) + .disabled(model.access?.contains(.canGetClientInfo) == false) + + Toggle("Can Disconnect Users", isOn: $pendingAccess.bind(.canDisconnectUsers)) + .disabled(model.access?.contains(.canDisconnectUsers) == false) + Toggle("Cannot be Disconnected", isOn: $pendingAccess.bind(.cantBeDisconnected)) + .disabled(model.access?.contains(.cantBeDisconnected) == false) + } + + Section("Messaging") { + Toggle("Can Send Messages", isOn: $pendingAccess.bind(.canSendMessages)) + .disabled(model.access?.contains(.canSendMessages) == false) + Toggle("Can Broadcast", isOn: $pendingAccess.bind(.canBroadcast)) + .disabled(model.access?.contains(.canBroadcast) == false) + } + + Section("News") { + Toggle("Can Read Articles", isOn: $pendingAccess.bind(.canReadMessageBoard)) + .disabled(model.access?.contains(.canReadMessageBoard) == false) + Toggle("Can Post Articles", isOn: $pendingAccess.bind(.canPostMessageBoard)) + .disabled(model.access?.contains(.canPostMessageBoard) == false) + Toggle("Can Delete Articles", isOn: $pendingAccess.bind(.canDeleteNewsArticles)) + .disabled(model.access?.contains(.canDeleteNewsArticles) == false) + Toggle("Can Create Categories", isOn: $pendingAccess.bind(.canCreateNewsCategories)) + .disabled(model.access?.contains(.canCreateNewsCategories) == false) + Toggle("Can Delete Categories", isOn: $pendingAccess.bind(.canDeleteNewsCategories)) + .disabled(model.access?.contains(.canDeleteNewsCategories) == false) + Toggle("Can Create News Bundles", isOn: $pendingAccess.bind(.canCreateNewsFolders)) + .disabled(model.access?.contains(.canCreateNewsFolders) == false) + Toggle("Can Delete News Bundles", isOn: $pendingAccess.bind(.canDeleteNewsFolders)) + .disabled(model.access?.contains(.canDeleteNewsFolders) == false) + } + + Section("Chat") { + Toggle("Can Initiate Private Chat", isOn: $pendingAccess.bind(.canCreateChat)) + .disabled(model.access?.contains(.canCreateChat) == false) + Toggle("Can Read Chat", isOn: $pendingAccess.bind(.canReadChat)) + .disabled(model.access?.contains(.canReadChat) == false) + Toggle("Can Send Chat", isOn: $pendingAccess.bind(.canSendChat)) + .disabled(model.access?.contains(.canSendChat) == false) + } + + Section("Miscellaneous") { + Toggle("Can Use Any Name", isOn: $pendingAccess.bind(.canUseAnyName)) + .disabled(model.access?.contains(.canUseAnyName) == false) + Toggle("Don't Show Agreement", isOn: $pendingAccess.bind(.canSkipAgreement)) + .disabled(model.access?.contains(.canSkipAgreement) == false) + } + } + .disabled(model.access?.contains(.canModifyUsers) == false) + .formStyle(.grouped) + .onChange(of: selection) { + if let selection { + pendingName = selection.name + pendingLogin = selection.login + pendingAccess = selection.access + + if selection.persisted { + if selection.password == nil { + pendingPassword = "" + } else { + pendingPassword = placeholderPassword + } + } + } + } + .onAppear() { + if let selection { + pendingName = selection.name + pendingLogin = selection.login + pendingAccess = selection.access + + if selection.persisted { + if selection.password == nil { + pendingPassword = "" + } else { + pendingPassword = placeholderPassword + } + } else { + pendingPassword = HotlineAccount.randomPassword() + } + } + } + } + .frame(maxWidth: .infinity) + + Divider() + + HStack() { + Button("Revert") { + if let selection { + pendingAccess = selection.access + pendingName = selection.name + pendingLogin = selection.login + + if selection.password != nil { + pendingPassword = selection.password! + } + } + } + .controlSize(.large) + .frame(minWidth: 75) + .disabled(!self.isSaveable()) +// .padding() + + Spacer() + + Button("Save"){ + guard let selection else { + return + } + + // Update existing account + if selection.persisted == true { + + if pendingPassword == placeholderPassword { + Task { @MainActor in + model.client.sendSetUser(name: pendingName, login: pendingLogin, newLogin: nil, password: nil, access: pendingAccess.rawValue) + } + } else { + Task { @MainActor in + model.client.sendSetUser(name: pendingName, login: pendingLogin, newLogin: nil, password: pendingPassword, access: pendingAccess.rawValue) + } + } + + } else { + // Create new existing account + Task { @MainActor in + model.client.sendCreateUser(name: pendingName, login: pendingLogin, password: pendingPassword, access: pendingAccess.rawValue) + } + self.selection?.password = pendingPassword + pendingPassword = placeholderPassword + } + + var account = HotlineAccount(pendingName, pendingLogin, pendingAccess) + account.persisted = true + account.password = placeholderPassword + + accounts = accounts.filter { $0.persisted == true && $0.login != selection.login } + + // Add new account to list + accounts.append(account) + + // Re-sort accounts + accounts.sort { $0.login < $1.login } + self.selection = account + } + .controlSize(.large) + .frame(minWidth: 75) + .keyboardShortcut(.defaultAction) + .disabled(!self.isSaveable()) + } + .padding() + } + + } + + var accountList: some View { + List(accounts, id: \.self, selection: $selection) { account in + HStack(spacing: 5) { + if account.access.contains(.canDisconnectUsers) { + Image("User Admin") + .frame(width: 16, height: 16) + .opacity(account.persisted ? 1.0 : 0.25) + // .padding(.leading, 4) + Text(account.login) + .foregroundStyle(Color.hotlineRed) + } + else if account.access.rawValue == 0 { + Image("User") + .frame(width: 16, height: 16) + // .padding(.leading, 4) + Text(account.login) + .foregroundStyle(.secondary) + } + // else if account.persisted == false { + // HStack { + // Image("User") + // .frame(width: 16, height: 16) + //// .padding(.leading, 4) + // Text(account.login) + // .italic() + // } + // } + else { + Image("User") + .frame(width: 16, height: 16) + .opacity(account.persisted ? 1.0 : 0.5) + // .padding(.leading, 4) + Text(account.login) + } + } + } + .frame(width: 250) + .sheet(item: $toDelete) { item in + Form { + HStack{ + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 30)) + Text("Delete account \"\(item.name)\" and all associated files?") + .lineSpacing(4) + } + } + .frame(minWidth: 300, idealWidth: 450, maxWidth: .infinity, minHeight: 100, idealHeight: 100, maxHeight: .infinity) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + toDelete = nil + } + } + + ToolbarItem(placement: .primaryAction) { + Button("Delete") { + guard let userToDelete = toDelete else { + return + } + + self.toDelete = nil + self.selection = nil + + if userToDelete.persisted { + Task { @MainActor in + model.client.sendDeleteUser(login: userToDelete.login) + } + } + + accounts = accounts.filter { $0.login != userToDelete.login } + + } + } + } + } + } + + + private func isSaveable() -> Bool { + guard let selection else { + return false + } + + // Disable save if login field is cleared + if pendingLogin == "" { + return false + } + + // If the account initial has a password and it was updated + if selection.password != nil && pendingPassword != placeholderPassword { + return true + } + + // If the account initial has no password, but was updated to have one + if selection.password == nil && pendingPassword != "" { + return true + } + + // If the access bits or user name have been changed + return pendingAccess.rawValue != selection.access.rawValue || selection.name != pendingName + } +} diff --git a/Hotline/macOS/Board/MessageBoardEditorView.swift b/Hotline/macOS/Board/MessageBoardEditorView.swift new file mode 100644 index 0000000..474384e --- /dev/null +++ b/Hotline/macOS/Board/MessageBoardEditorView.swift @@ -0,0 +1,117 @@ +import SwiftUI + +private enum FocusFields { + case body +} + +struct MessageBoardEditorView: View { + @Environment(\.controlActiveState) private var controlActiveState + @Environment(\.colorScheme) private var colorScheme + @Environment(\.dismiss) private var dismiss + @Environment(Hotline.self) private var model: Hotline + + @State private var text: String = "" + @State private var sending: Bool = false + + @FocusState private var focusedField: FocusFields? + + func sendPost() async { + sending = true + + let cleanedText = text.trimmingCharacters(in: .whitespacesAndNewlines) + + model.postToMessageBoard(text: cleanedText) + let _ = await model.getMessageBoard() + +// let success = await model.postNewsArticle(title: title, body: text, at: path, parentID: parentID) +// if success { +// await model.getNewsList(at: path) +// } + + sending = false + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .center, spacing: 0) { + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .resizable() + .scaledToFit() + .frame(width: 14, height: 14) + .opacity(0.5) + } + .buttonStyle(.plain) + .frame(width: 16, height: 16) + + Spacer() + +// Image("Message Board Post") +// .resizable() +// .scaledToFit() +// .frame(width: 16, height: 16) +// .padding(.trailing, 6) + + Text("New Post") + .fontWeight(.semibold) + .lineLimit(1) + .truncationMode(.middle) + + Spacer() + + if sending { + ProgressView() + .controlSize(.small) + .frame(width: 22, height: 22) + } + else { + Button { + sending = true + model.postToMessageBoard(text: text) + Task { + let _ = await model.getMessageBoard() + Task { @MainActor in + sending = false + dismiss() + } + } + } label: { + Image(systemName: "arrow.up.circle.fill") + .resizable() + .renderingMode(.template) + .scaledToFit() + .foregroundColor(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? .secondary : .accentColor) + } + .buttonStyle(.plain) + .frame(width: 22, height: 22) + .help("Post to Message Board") + .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + .frame(maxWidth: .infinity) + .padding() + + Divider() + + BetterTextEditor(text: $text) + .betterEditorFont(NSFont.systemFont(ofSize: 14.0)) + .betterEditorAutomaticSpellingCorrection(true) + .betterEditorTextInset(.init(width: 16, height: 18)) + .lineSpacing(20) + .background(Color(nsColor: .textBackgroundColor)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .focused($focusedField, equals: .body) + } + .frame(minWidth: 300, idealWidth: 450, maxWidth: .infinity, minHeight: 300, idealHeight: 500, maxHeight: .infinity) + .background(Color(nsColor: .textBackgroundColor)) + .presentationCompactAdaptation(.sheet) + .onAppear { + focusedField = .body + } + .onDisappear { + dismiss() + } + } +} diff --git a/Hotline/macOS/Board/MessageBoardView.swift b/Hotline/macOS/Board/MessageBoardView.swift new file mode 100644 index 0000000..f870b0c --- /dev/null +++ b/Hotline/macOS/Board/MessageBoardView.swift @@ -0,0 +1,102 @@ +import SwiftUI + +struct MessageBoardView: View { + @Environment(Hotline.self) private var model: Hotline + + @State private var composerDisplayed: Bool = false + @State private var composerText: String = "" + + var body: some View { + NavigationStack { + if model.access?.contains(.canReadMessageBoard) != false { + ScrollView { + LazyVStack(alignment: .leading) { + ForEach(model.messageBoard, id: \.self) { msg in + Text(LocalizedStringKey(msg)) + .tint(Color("Link Color")) + .lineLimit(100) + .lineSpacing(4) + .padding() + .textSelection(.enabled) + Divider() + } + } + Spacer() + } + .task { + if !model.messageBoardLoaded { + let _ = await model.getMessageBoard() + } + } + .overlay { + if !model.messageBoardLoaded { + VStack { + ProgressView() + .controlSize(.large) + } + .frame(maxWidth: .infinity) + } + } + .background(Color(nsColor: .textBackgroundColor)) + } + else { + ZStack(alignment: .center) { + Text("No Message Board") + .font(.title) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + .padding() + } + .frame(maxWidth: .infinity) + } + } + .sheet(isPresented: $composerDisplayed) { + MessageBoardEditorView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .frame(idealWidth: 450, idealHeight: 350) +// RichTextEditor(text: $composerText) +// .richEditorFont(NSFont.systemFont(ofSize: 16.0)) +// .richEditorAutomaticDashSubstitution(false) +// .richEditorAutomaticQuoteSubstitution(false) +// .richEditorAutomaticSpellingCorrection(false) +// .background(Color(nsColor: .textBackgroundColor)) +// .frame(maxWidth: .infinity, maxHeight: .infinity) +// .frame(idealWidth: 450, idealHeight: 350) +// .toolbar { +// ToolbarItem(placement: .cancellationAction) { +// Button("Cancel") { +// composerDisplayed.toggle() +// } +// } +// +// ToolbarItem(placement: .primaryAction) { +// Button("Post") { +// composerDisplayed.toggle() +// let text = composerText +// composerText = "" +// model.postToMessageBoard(text: text) +// Task { +// await model.getMessageBoard() +// } +// } +// } +// } + } + .toolbar { + ToolbarItem(placement:.primaryAction) { + Button { + composerDisplayed.toggle() + } label: { + Image(systemName: "square.and.pencil") + } + .disabled(model.access?.contains(.canPostMessageBoard) == false) + .help("Post to Message Board") + } + } + } +} + +#Preview { + MessageBoardView() + .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) +} diff --git a/Hotline/macOS/Chat/ChatView.swift b/Hotline/macOS/Chat/ChatView.swift new file mode 100644 index 0000000..65087c6 --- /dev/null +++ b/Hotline/macOS/Chat/ChatView.swift @@ -0,0 +1,321 @@ +import SwiftUI + +enum FocusedField: Int, Hashable { + case chatInput +} + +struct ChatJoinedMessageView: View { + let message: ChatMessage + + var body: some View { + HStack(alignment: .center, spacing: 4) { + Image(systemName: "arrow.right") + .resizable() + .scaledToFit() + .fontWeight(.semibold) + .foregroundStyle(.primary) + .frame(width: 12, height: 12) + + Text(message.text) + .lineLimit(1) + .truncationMode(.middle) + .fontWeight(.semibold) + .textSelection(.disabled) + + Spacer() + } + .opacity(0.3) + } +} + +struct ChatLeftMessageView: View { + let message: ChatMessage + + var body: some View { + HStack(alignment: .center, spacing: 4) { + Image(systemName: "arrow.left") + .resizable() + .scaledToFit() + .fontWeight(.semibold) + .foregroundStyle(.primary) + .frame(width: 12, height: 12) + + Text(message.text) + .lineLimit(1) + .truncationMode(.middle) + .fontWeight(.semibold) + .textSelection(.disabled) + + Spacer() + } + .opacity(0.3) + } +} + + +struct ChatDisconnectedMessageView: View { + let message: ChatMessage + + var body: some View { + HStack { + Spacer() + } + .frame(height: 5) + .background(Color.primary) + .clipShape(.capsule) + .opacity(0.1) + } +} + +struct ChatMessageView: View { + let message: ChatMessage + + var body: some View { + HStack(alignment: .firstTextBaseline) { + if let username = message.username { + Text("\(username): ").fontWeight(.semibold) + Text(message.text.markdownToAttributedString()) + } + else { + Text(message.text) + } + Spacer() + } + .lineSpacing(4) + .multilineTextAlignment(.leading) + .textSelection(.enabled) + .tint(Color("Link Color")) + } +} + +struct ChatView: View { + @Environment(Hotline.self) private var model: Hotline + @Environment(\.colorScheme) var colorScheme + @Environment(\.dismiss) var dismiss + + @State private var scrollPos: Int? + @State private var contentHeight: CGFloat = 0 + + @State private var searchQuery: String = "" + @State private var searchResults: [ChatMessage] = [] + @State private var isSearching: Bool = false + + @FocusState private var focusedField: FocusedField? + + @Namespace var bottomID + + private var bindableModel: Bindable { + Bindable(model) + } + +// @State private var showingExporter: Bool = false +// +// @State private var chatDocument: TextFile = TextFile() + + var displayedMessages: [ChatMessage] { + searchQuery.isEmpty ? model.chat : searchResults + } + + var body: some View { + @Bindable var bindModel = model + + NavigationStack { + ScrollViewReader { reader in + VStack(alignment: .leading, spacing: 0) { + + // MARK: Scroll View + ScrollView(.vertical) { + LazyVStack(alignment: .leading, spacing: 8) { + + ForEach(displayedMessages) { msg in + if msg.type == .agreement { + VStack(alignment: .center, spacing: 16) { + if let bannerImage = self.model.bannerImage { + HStack(spacing: 0) { + Spacer(minLength: 0) + ZStack { + Image(nsImage: bannerImage) + .resizable() + .scaledToFit() + .frame(maxWidth: 468.0) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .offset(y: 1.5) + .blur(radius: 4) + .opacity(0.2) + + Image(nsImage: bannerImage) + .resizable() + .scaledToFit() + .frame(maxWidth: 468.0) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + + Spacer(minLength: 0) + } + } + + ServerAgreementView(text: msg.text) + } + .padding(.vertical, 24) + } + else if msg.type == .server { + ServerMessageView(message: msg.text) + } + else if msg.type == .joined { + ChatJoinedMessageView(message: msg) + } + else if msg.type == .left { + ChatLeftMessageView(message: msg) + } + else if msg.type == .signOut { + ChatDisconnectedMessageView(message: msg) + .padding(.vertical, 24) + } + else { + ChatMessageView(message: msg) + } + } + } + .padding() + + VStack(spacing: 0) {}.id(bottomID) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) +// .defaultScrollAnchor(.bottom, for: .initialOffset) + .defaultScrollAnchor(.bottom, for: .alignment) + .defaultScrollAnchor(.bottom, for: .sizeChanges) + .onChange(of: model.chat.count) { + // Re-run search when new messages arrive to keep filter active + if !searchQuery.isEmpty { + performSearch() + } + reader.scrollTo(bottomID, anchor: .bottom) + model.markPublicChatAsRead() + } + .onAppear { + reader.scrollTo(bottomID, anchor: .bottom) + self.focusedField = .chatInput + } + .onChange(of: self.model.bannerImage) { + reader.scrollTo(bottomID, anchor: .bottom) + } + .onChange(of: searchQuery) { + reader.scrollTo(bottomID, anchor: .bottom) + } + .onChange(of: isSearching) { + reader.scrollTo(bottomID, anchor: .bottom) + } + + // MARK: Input Divider + Divider() + + // MARK: Input Bar + HStack(alignment: .lastTextBaseline, spacing: 0) { + TextField("", text: $bindModel.chatInput, axis: .vertical) + .focused($focusedField, equals: .chatInput) + .textFieldStyle(.plain) + .lineLimit(1...5) + .multilineTextAlignment(.leading) + .onSubmit { + if !model.chatInput.isEmpty { + model.sendChat(model.chatInput, announce: NSEvent.modifierFlags.contains(.shift)) + } + model.chatInput = "" + } + .frame(maxWidth: .infinity) + .padding() + } + .frame(maxWidth: .infinity, minHeight: 28) + .padding(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) + .overlay(alignment: .leadingLastTextBaseline) { + Image(systemName: "chevron.right").fontWeight(.semibold).opacity(0.4).offset(x: 16) + } + .onContinuousHover { phase in + switch phase { + case .active(_): + NSCursor.iBeam.set() + case .ended: + NSCursor.arrow.set() + break + } + } + .onTapGesture(count: 1) { + focusedField = .chatInput + } + } + } + .searchable(text: $searchQuery, isPresented: $isSearching, placement: .toolbar, prompt: "Search") + .background(Button("", action: { isSearching = true }).keyboardShortcut("f").hidden()) + } + .background(Color(nsColor: .textBackgroundColor)) +// .navigationTitle(model.serverTitle) + .onChange(of: searchQuery) { + performSearch() + } +// .toolbar { +// ToolbarItem(placement: .primaryAction) { +// Button { +// showingExporter = true +// } label: { +// Image(systemName: "square.and.arrow.up") +// }.help("Save Chat...") +// } +// } +// .fileExporter(isPresented: $showingExporter, document: self.chatDocument, contentType: .utf8PlainText, defaultFilename: "\(self.model.serverTitle) Chat.txt") { result in +// switch result { +// case .success(let url): +// print("Saved to \(url)") +// +// case .failure(let error): +// print(error.localizedDescription) +// } +// self.chatDocument.text = "" +// } + } + + private func performSearch() { + guard !searchQuery.isEmpty else { + searchResults = [] + return + } + + searchResults = model.searchChat(query: searchQuery) + } + +// private func prepareChatDocument() -> Bool { +// var text: String = String() +// +// self.chatDocument.text = "" +// for msg in model.chat { +// if msg.type == .agreement { +// text.append(msg.text) +// text.append("\n\n") +// } +// else if msg.type == .message { +// if let username = msg.username { +// text.append("\(username): \(msg.text)") +// } +// else { +// text.append(msg.text) +// } +// text.append("\n") +// } +// else if msg.type == .status { +// text.append(msg.text) +// text.append("\n") +// } +// } +// +// if text.isEmpty { +// return false +// } +// +// self.chatDocument.text = text +// +// return true +// } +} + +#Preview { + ChatView() + .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) +} diff --git a/Hotline/macOS/Chat/ServerAgreementView.swift b/Hotline/macOS/Chat/ServerAgreementView.swift new file mode 100644 index 0000000..e72de7d --- /dev/null +++ b/Hotline/macOS/Chat/ServerAgreementView.swift @@ -0,0 +1,78 @@ +import SwiftUI + +fileprivate let MAX_AGREEMENT_HEIGHT: CGFloat = 340 + +struct ServerAgreementView: View { + let text: String + + @State private var expandable: Bool = false + @State private var expanded: Bool = false + + var body: some View { + ScrollView(.vertical) { + HStack(alignment: .top) { + Spacer() + Text(text.convertToAttributedStringWithLinks()) + .font(.system(size: 12)) + .fontDesign(.monospaced) + .textSelection(.enabled) + .tint(Color("Link Color")) + .frame(maxWidth: 400) + .padding(16) + .background( + GeometryReader { geometry in + Color.clear.onAppear { + if geometry.size.height > MAX_AGREEMENT_HEIGHT { + expandable = true + } + else { + expandable = false + } + } + } + ) + Spacer() + } + } + .scrollIndicators(.never) + .frame(maxWidth: .infinity, maxHeight: (expandable && expanded) ? nil : MAX_AGREEMENT_HEIGHT) + .scrollBounceBehavior(.basedOnSize) +#if os(iOS) + .background(Color("Agreement Background")) +#elseif os(macOS) + .background(VisualEffectView(material: .titlebar, blendingMode: .withinWindow)) +#endif + .overlay(alignment: .bottomTrailing) { + ZStack(alignment: .bottomTrailing) { + Group { + if !expandable || expanded { + EmptyView() + } + else { + Button(action: { + withAnimation(.easeOut(duration: 0.15)) { + expanded = true + } + }, label: { + Image(systemName: "arrow.up.and.down.circle.fill") + .resizable() + .scaledToFit() + .fontWeight(.semibold) + .frame(width: 16, height: 16) + .foregroundColor(.primary.opacity(0.5)) + }) + .buttonStyle(.plain) + .buttonBorderShape(.circle) + .help("Expand Server Agreement") + .padding([.trailing, .bottom], 16) + } + } + } + } + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + } +} + +#Preview { + ServerAgreementView(text: "Hello there and welcome to this server.") +} diff --git a/Hotline/macOS/Chat/ServerMessageView.swift b/Hotline/macOS/Chat/ServerMessageView.swift new file mode 100644 index 0000000..6c3c60e --- /dev/null +++ b/Hotline/macOS/Chat/ServerMessageView.swift @@ -0,0 +1,33 @@ +import SwiftUI + +struct ServerMessageView: View { + let message: String + + var body: some View { + HStack(alignment: .center, spacing: 8) { + Image("Server Message") + .symbolRenderingMode(.multicolor) + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + Text(message) + .fontWeight(.semibold) + .lineSpacing(4) + .multilineTextAlignment(.leading) + .textSelection(.enabled) + Spacer() + } + .padding() + .frame(maxWidth: .infinity) +#if os(iOS) + .background(Color("Agreement Background")) +#elseif os(macOS) + .background(VisualEffectView(material: .titlebar, blendingMode: .withinWindow)) +#endif + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + } +} + +#Preview { + ServerMessageView(message: "This server has something important to say.") +} diff --git a/Hotline/macOS/ChatView.swift b/Hotline/macOS/ChatView.swift deleted file mode 100644 index 65087c6..0000000 --- a/Hotline/macOS/ChatView.swift +++ /dev/null @@ -1,321 +0,0 @@ -import SwiftUI - -enum FocusedField: Int, Hashable { - case chatInput -} - -struct ChatJoinedMessageView: View { - let message: ChatMessage - - var body: some View { - HStack(alignment: .center, spacing: 4) { - Image(systemName: "arrow.right") - .resizable() - .scaledToFit() - .fontWeight(.semibold) - .foregroundStyle(.primary) - .frame(width: 12, height: 12) - - Text(message.text) - .lineLimit(1) - .truncationMode(.middle) - .fontWeight(.semibold) - .textSelection(.disabled) - - Spacer() - } - .opacity(0.3) - } -} - -struct ChatLeftMessageView: View { - let message: ChatMessage - - var body: some View { - HStack(alignment: .center, spacing: 4) { - Image(systemName: "arrow.left") - .resizable() - .scaledToFit() - .fontWeight(.semibold) - .foregroundStyle(.primary) - .frame(width: 12, height: 12) - - Text(message.text) - .lineLimit(1) - .truncationMode(.middle) - .fontWeight(.semibold) - .textSelection(.disabled) - - Spacer() - } - .opacity(0.3) - } -} - - -struct ChatDisconnectedMessageView: View { - let message: ChatMessage - - var body: some View { - HStack { - Spacer() - } - .frame(height: 5) - .background(Color.primary) - .clipShape(.capsule) - .opacity(0.1) - } -} - -struct ChatMessageView: View { - let message: ChatMessage - - var body: some View { - HStack(alignment: .firstTextBaseline) { - if let username = message.username { - Text("\(username): ").fontWeight(.semibold) + Text(message.text.markdownToAttributedString()) - } - else { - Text(message.text) - } - Spacer() - } - .lineSpacing(4) - .multilineTextAlignment(.leading) - .textSelection(.enabled) - .tint(Color("Link Color")) - } -} - -struct ChatView: View { - @Environment(Hotline.self) private var model: Hotline - @Environment(\.colorScheme) var colorScheme - @Environment(\.dismiss) var dismiss - - @State private var scrollPos: Int? - @State private var contentHeight: CGFloat = 0 - - @State private var searchQuery: String = "" - @State private var searchResults: [ChatMessage] = [] - @State private var isSearching: Bool = false - - @FocusState private var focusedField: FocusedField? - - @Namespace var bottomID - - private var bindableModel: Bindable { - Bindable(model) - } - -// @State private var showingExporter: Bool = false -// -// @State private var chatDocument: TextFile = TextFile() - - var displayedMessages: [ChatMessage] { - searchQuery.isEmpty ? model.chat : searchResults - } - - var body: some View { - @Bindable var bindModel = model - - NavigationStack { - ScrollViewReader { reader in - VStack(alignment: .leading, spacing: 0) { - - // MARK: Scroll View - ScrollView(.vertical) { - LazyVStack(alignment: .leading, spacing: 8) { - - ForEach(displayedMessages) { msg in - if msg.type == .agreement { - VStack(alignment: .center, spacing: 16) { - if let bannerImage = self.model.bannerImage { - HStack(spacing: 0) { - Spacer(minLength: 0) - ZStack { - Image(nsImage: bannerImage) - .resizable() - .scaledToFit() - .frame(maxWidth: 468.0) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - .offset(y: 1.5) - .blur(radius: 4) - .opacity(0.2) - - Image(nsImage: bannerImage) - .resizable() - .scaledToFit() - .frame(maxWidth: 468.0) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - } - - Spacer(minLength: 0) - } - } - - ServerAgreementView(text: msg.text) - } - .padding(.vertical, 24) - } - else if msg.type == .server { - ServerMessageView(message: msg.text) - } - else if msg.type == .joined { - ChatJoinedMessageView(message: msg) - } - else if msg.type == .left { - ChatLeftMessageView(message: msg) - } - else if msg.type == .signOut { - ChatDisconnectedMessageView(message: msg) - .padding(.vertical, 24) - } - else { - ChatMessageView(message: msg) - } - } - } - .padding() - - VStack(spacing: 0) {}.id(bottomID) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) -// .defaultScrollAnchor(.bottom, for: .initialOffset) - .defaultScrollAnchor(.bottom, for: .alignment) - .defaultScrollAnchor(.bottom, for: .sizeChanges) - .onChange(of: model.chat.count) { - // Re-run search when new messages arrive to keep filter active - if !searchQuery.isEmpty { - performSearch() - } - reader.scrollTo(bottomID, anchor: .bottom) - model.markPublicChatAsRead() - } - .onAppear { - reader.scrollTo(bottomID, anchor: .bottom) - self.focusedField = .chatInput - } - .onChange(of: self.model.bannerImage) { - reader.scrollTo(bottomID, anchor: .bottom) - } - .onChange(of: searchQuery) { - reader.scrollTo(bottomID, anchor: .bottom) - } - .onChange(of: isSearching) { - reader.scrollTo(bottomID, anchor: .bottom) - } - - // MARK: Input Divider - Divider() - - // MARK: Input Bar - HStack(alignment: .lastTextBaseline, spacing: 0) { - TextField("", text: $bindModel.chatInput, axis: .vertical) - .focused($focusedField, equals: .chatInput) - .textFieldStyle(.plain) - .lineLimit(1...5) - .multilineTextAlignment(.leading) - .onSubmit { - if !model.chatInput.isEmpty { - model.sendChat(model.chatInput, announce: NSEvent.modifierFlags.contains(.shift)) - } - model.chatInput = "" - } - .frame(maxWidth: .infinity) - .padding() - } - .frame(maxWidth: .infinity, minHeight: 28) - .padding(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) - .overlay(alignment: .leadingLastTextBaseline) { - Image(systemName: "chevron.right").fontWeight(.semibold).opacity(0.4).offset(x: 16) - } - .onContinuousHover { phase in - switch phase { - case .active(_): - NSCursor.iBeam.set() - case .ended: - NSCursor.arrow.set() - break - } - } - .onTapGesture(count: 1) { - focusedField = .chatInput - } - } - } - .searchable(text: $searchQuery, isPresented: $isSearching, placement: .toolbar, prompt: "Search") - .background(Button("", action: { isSearching = true }).keyboardShortcut("f").hidden()) - } - .background(Color(nsColor: .textBackgroundColor)) -// .navigationTitle(model.serverTitle) - .onChange(of: searchQuery) { - performSearch() - } -// .toolbar { -// ToolbarItem(placement: .primaryAction) { -// Button { -// showingExporter = true -// } label: { -// Image(systemName: "square.and.arrow.up") -// }.help("Save Chat...") -// } -// } -// .fileExporter(isPresented: $showingExporter, document: self.chatDocument, contentType: .utf8PlainText, defaultFilename: "\(self.model.serverTitle) Chat.txt") { result in -// switch result { -// case .success(let url): -// print("Saved to \(url)") -// -// case .failure(let error): -// print(error.localizedDescription) -// } -// self.chatDocument.text = "" -// } - } - - private func performSearch() { - guard !searchQuery.isEmpty else { - searchResults = [] - return - } - - searchResults = model.searchChat(query: searchQuery) - } - -// private func prepareChatDocument() -> Bool { -// var text: String = String() -// -// self.chatDocument.text = "" -// for msg in model.chat { -// if msg.type == .agreement { -// text.append(msg.text) -// text.append("\n\n") -// } -// else if msg.type == .message { -// if let username = msg.username { -// text.append("\(username): \(msg.text)") -// } -// else { -// text.append(msg.text) -// } -// text.append("\n") -// } -// else if msg.type == .status { -// text.append(msg.text) -// text.append("\n") -// } -// } -// -// if text.isEmpty { -// return false -// } -// -// self.chatDocument.text = text -// -// return true -// } -} - -#Preview { - ChatView() - .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) -} diff --git a/Hotline/macOS/FileDetailsView.swift b/Hotline/macOS/FileDetailsView.swift deleted file mode 100644 index 34e083b..0000000 --- a/Hotline/macOS/FileDetailsView.swift +++ /dev/null @@ -1,147 +0,0 @@ -import Foundation -import SwiftUI - -struct FileDetailsView: View { - @Environment(Hotline.self) private var model: Hotline - @Environment(\.presentationMode) var presentationMode - - var fd: FileDetails - - @State private var comment: String = "" - @State private var filename: String = "" - - var body: some View { - VStack (alignment: .leading){ - Form { - HStack(alignment: .center){ - FileIconView(filename: fd.name, fileType: nil) - .frame(width: 32, height: 32) - TextField("", text: $filename) - .disabled(!self.canRename()) - } - HStack(alignment: .center){ - Text("Type:").bold().padding(.leading, 43) - Text(fd.type) - } - HStack(alignment: .center){ - Text("Creator:").bold().padding(.leading, 26) - Text(fd.creator) - } - HStack(alignment: .center){ - Text("Size:").bold().bold().padding(.leading, 48) - Text(self.formattedSize(byteCount: fd.size)) - } - HStack(alignment: .center){ - Text("Created:").bold().padding(.leading, 24) - Text("\(FileDetailsView.dateFormatter.string(from: fd.created))") - } - HStack(alignment: .center){ - Text("Modified:").bold().padding(.leading, 19) - Text("\(FileDetailsView.dateFormatter.string(from: fd.modified))") - } - HStack(alignment: .center){ - Text("Comments:").bold().padding(.top, 8) - .padding(.leading, 5) - } - - VStack(alignment: .trailing){ - TextEditor(text: $comment) - .padding(.leading, 2) - .padding(.top, 1) - .font(.system(size: 13)) - .background(Color(nsColor: .textBackgroundColor)) - .border(Color.secondary, width: 1) - .clipShape(RoundedRectangle(cornerRadius: 2, style: .continuous)) - .disabled(!self.canSetComment()) - } - } - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - presentationMode.wrappedValue.dismiss() - } - } - - ToolbarItem(placement: .primaryAction) { - Button{ - var editedFilename: String? - if filename != fd.name { - editedFilename = filename - } - - var editedComment: String? - if comment != fd.comment { - editedComment = comment - } - - model.client.sendSetFileInfo(fileName: fd.name, path: fd.path, fileNewName: editedFilename, comment: editedComment) - presentationMode.wrappedValue.dismiss() - - // TODO: Update the file list if the filename was changed - } label: { - Text("Save") - }.disabled(!isEdited()) - } - } - - .onAppear { - self.filename = fd.name - self.comment = fd.comment - } - .padding(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24)) - } - .frame(minWidth: 400, minHeight: 400) - } - - - static var dateFormatter: DateFormatter = { - var dateFormatter = DateFormatter() - dateFormatter.dateStyle = .long - dateFormatter.timeStyle = .short - - // Original format: Fri, Aug 20, 2021, 5:14:07 PM - return dateFormatter - }() - - static var byteCountSizeFormatter: NumberFormatter = { - let numberFormatter = NumberFormatter() - numberFormatter.numberStyle = .decimal - return numberFormatter - }() - - static let byteFormatter = ByteCountFormatter() - - private func formattedFileSize(_ fileSize: UInt) -> String { - FileView.byteFormatter.allowedUnits = [.useAll] - FileView.byteFormatter.countStyle = .file - return FileView.byteFormatter.string(fromByteCount: Int64(fileSize)) - } - - // Format byte count Int into string like: 23.4M (24,601,664 bytes) - private func formattedSize(byteCount: Int) -> String { - let formattedByteCount = FileDetailsView.byteCountSizeFormatter.string(from: NSNumber(value:byteCount)) ?? "0" - return "\(FileView.byteFormatter.string(fromByteCount: Int64(byteCount))) (\(formattedByteCount) bytes)" - } - - private func isEdited() -> Bool { - return self.filename != fd.name || self.comment != fd.comment - } - - private func canRename() -> Bool { - if self.fd.type == "fldr" { - return model.access?.contains(.canRenameFolders) == true - } - return model.access?.contains(.canRenameFiles) == true - } - - private func canSetComment() -> Bool { - if self.fd.type == "fldr" { - return model.access?.contains(.canSetFolderComment) == true - } - return model.access?.contains(.canSetFileComment) == true - } -} - -//#Preview { -// FileDetailsView(fd: FileDetails(name: "AppleWorks 6.sit", path: [""], size: 24601664, comment: "test comment", type: "SITD", creator: "SIT!", created: Date.now, modified: Date.now )) -//} diff --git a/Hotline/macOS/FilePreviewImageView.swift b/Hotline/macOS/FilePreviewImageView.swift deleted file mode 100644 index 9beeb80..0000000 --- a/Hotline/macOS/FilePreviewImageView.swift +++ /dev/null @@ -1,123 +0,0 @@ -import SwiftUI -import UniformTypeIdentifiers - -struct FilePreviewImageView: View { - enum FilePreviewFocus: Hashable { - case window - } - - @Environment(\.controlActiveState) private var controlActiveState - @Environment(\.colorScheme) private var colorScheme - @Environment(\.dismiss) var dismiss - - @Binding var info: PreviewFileInfo? - - @State var preview: FilePreview? = nil - @FocusState private var focusField: FilePreviewFocus? - - var body: some View { - Group { - if preview?.state != .loaded { - HStack(alignment: .center, spacing: 0) { - ProgressView(value: max(0.0, min(1.0, preview?.progress ?? 0.0))) - .focusable(false) - .progressViewStyle(.circular) - .controlSize(.extraLarge) - .tint(.white) - .frame(maxWidth: 300, alignment: .center) - } - .frame(minWidth: 350, maxWidth: 350, minHeight: 150, maxHeight: 150) - .padding() - } - else { - if let image = preview?.image { - FileImageView(image: image) - .frame(minWidth: 200, maxWidth: .infinity, minHeight: 200, maxHeight: .infinity) - } - else { - VStack(alignment: .center, spacing: 0) { - Spacer() - - Image(systemName: "eye.trianglebadge.exclamationmark") - .resizable() - .scaledToFit() - .frame(maxWidth: .infinity) - .frame(height: 48) - .padding(.bottom) - Group { - Text("This file type is not previewable") - .bold() - Text("Try downloading and opening this file in another application.") - .foregroundStyle(Color.secondary) - } - .font(.system(size: 14.0)) - .frame(maxWidth: 300) - .multilineTextAlignment(.center) - - Spacer() - } - .frame(minWidth: 350, maxWidth: 350, minHeight: 150, maxHeight: 150) - .padding() - } - } - } - .focusable() - .focusEffectDisabled() - .focused($focusField, equals: .window) - .preferredColorScheme(.dark) - .navigationTitle(info?.name ?? "Preview") - .background(.black) - .toolbar { - ToolbarItem(placement: .navigation) { - FileIconView(filename: info?.name ?? "", fileType: nil) - .frame(width: 16, height: 16) - .opacity(controlActiveState == .inactive ? 0.5 : 1.0) - } - - if let img = preview?.image { - if let info = info { - ToolbarItem(placement: .primaryAction) { - Button { - let _ = preview?.data?.saveAsFileToDownloads(filename: info.name) - } label: { - Label("Download Image...", systemImage: "arrow.down") - } - .help("Download Image") - } - - ToolbarItem(placement: .primaryAction) { - ShareLink(item: img, preview: SharePreview(info.name, image: img)) { - Label("Share Image...", systemImage: "square.and.arrow.up") - } - .help("Share Image") - } - } - } - } - .task { - if let info = info { - preview = FilePreview(info: info) - preview?.download() - } - } - .onAppear { - if info == nil { - Task { - dismiss() - } - return - } - - focusField = .window - } - .onDisappear { - preview?.cancel() - dismiss() - } - .onChange(of: preview?.state) { - if preview?.state == .failed { - dismiss() - } - } - } -} diff --git a/Hotline/macOS/FilePreviewTextView.swift b/Hotline/macOS/FilePreviewTextView.swift deleted file mode 100644 index c286381..0000000 --- a/Hotline/macOS/FilePreviewTextView.swift +++ /dev/null @@ -1,127 +0,0 @@ -import SwiftUI -import UniformTypeIdentifiers - -struct FilePreviewTextView: View { - enum FilePreviewFocus: Hashable { - case window - } - - @Environment(\.controlActiveState) private var controlActiveState - @Environment(\.colorScheme) private var colorScheme - @Environment(\.dismiss) var dismiss - - @Binding var info: PreviewFileInfo? - @State var preview: FilePreview? = nil - @FocusState private var focusField: FilePreviewFocus? - - var body: some View { - Group { - if preview?.state != .loaded { - VStack(alignment: .center, spacing: 0) { - Spacer() - ProgressView(value: max(0.0, min(1.0, preview?.progress ?? 0.0))) - .focusable(false) - .progressViewStyle(.circular) - .controlSize(.extraLarge) - .tint(.white) - .frame(maxWidth: 300, alignment: .center) - Spacer() - Spacer() - } - .background(Color(nsColor: .textBackgroundColor)) - .frame(minWidth: 350, maxWidth: .infinity, minHeight: 150, maxHeight: .infinity) - .padding() - } - else { - if let text = preview?.text { - TextEditor(text: .constant(text)) - .textEditorStyle(.plain) - .font(.system(size: 14)) - .lineSpacing(3) - .padding(16) - .contentMargins(.top, -16.0, for: .scrollIndicators) - .contentMargins(.bottom, -16.0, for: .scrollIndicators) - .contentMargins(.trailing, -16.0, for: .scrollIndicators) - .scrollClipDisabled() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - else { - VStack(alignment: .center, spacing: 0) { - Spacer() - - Image(systemName: "eye.trianglebadge.exclamationmark") - .resizable() - .scaledToFit() - .frame(maxWidth: .infinity) - .frame(height: 48) - .padding(.bottom) - Group { - Text("This file type is not previewable") - .bold() - Text("Try downloading and opening this file in another application.") - .foregroundStyle(Color.secondary) - } - .font(.system(size: 14.0)) - .frame(maxWidth: 300) - .multilineTextAlignment(.center) - - Spacer() - Spacer() - } - .frame(minWidth: 350, maxWidth: .infinity, minHeight: 150, maxHeight: .infinity) - .padding() - } - } - } - .focusable() - .focusEffectDisabled() - .background(Color(nsColor: .textBackgroundColor)) - .focused($focusField, equals: .window) - .navigationTitle(info?.name ?? "File Preview") - .toolbar { - ToolbarItem(placement: .navigation) { - FileIconView(filename: info?.name ?? "", fileType: nil) - .frame(width: 16, height: 16) - .opacity(controlActiveState == .inactive ? 0.5 : 1.0) - } - - if let _ = preview?.text { - if let info = info { - ToolbarItem(placement: .primaryAction) { - Button { - let _ = preview?.data?.saveAsFileToDownloads(filename: info.name) - } label: { - Label("Save Text File...", systemImage: "square.and.arrow.down") - } - .help("Save Text File") - } - } - } - } - .task { - if let info = info { - preview = FilePreview(info: info) - preview?.download() - } - } - .onAppear { - if info == nil { - Task { - dismiss() - } - return - } - - focusField = .window - } - .onDisappear { - preview?.cancel() - dismiss() - } - .onChange(of: preview?.state) { - if preview?.state == .failed { - dismiss() - } - } - } -} diff --git a/Hotline/macOS/Files/FileDetailsView.swift b/Hotline/macOS/Files/FileDetailsView.swift new file mode 100644 index 0000000..812f4e5 --- /dev/null +++ b/Hotline/macOS/Files/FileDetailsView.swift @@ -0,0 +1,147 @@ +import Foundation +import SwiftUI + +struct FileDetailsView: View { + @Environment(Hotline.self) private var model: Hotline + @Environment(\.presentationMode) var presentationMode + + var fd: FileDetails + + @State private var comment: String = "" + @State private var filename: String = "" + + var body: some View { + VStack (alignment: .leading){ + Form { + HStack(alignment: .center){ + FileIconView(filename: fd.name, fileType: nil) + .frame(width: 32, height: 32) + TextField("", text: $filename) + .disabled(!self.canRename()) + } + HStack(alignment: .center){ + Text("Type:").bold().padding(.leading, 43) + Text(fd.type) + } + HStack(alignment: .center){ + Text("Creator:").bold().padding(.leading, 26) + Text(fd.creator) + } + HStack(alignment: .center){ + Text("Size:").bold().bold().padding(.leading, 48) + Text(self.formattedSize(byteCount: fd.size)) + } + HStack(alignment: .center){ + Text("Created:").bold().padding(.leading, 24) + Text("\(FileDetailsView.dateFormatter.string(from: fd.created))") + } + HStack(alignment: .center){ + Text("Modified:").bold().padding(.leading, 19) + Text("\(FileDetailsView.dateFormatter.string(from: fd.modified))") + } + HStack(alignment: .center){ + Text("Comments:").bold().padding(.top, 8) + .padding(.leading, 5) + } + + VStack(alignment: .trailing){ + TextEditor(text: $comment) + .padding(.leading, 2) + .padding(.top, 1) + .font(.system(size: 13)) + .background(Color(nsColor: .textBackgroundColor)) + .border(Color.secondary, width: 1) + .clipShape(RoundedRectangle(cornerRadius: 2, style: .continuous)) + .disabled(!self.canSetComment()) + } + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + presentationMode.wrappedValue.dismiss() + } + } + + ToolbarItem(placement: .primaryAction) { + Button{ + var editedFilename: String? + if filename != fd.name { + editedFilename = filename + } + + var editedComment: String? + if comment != fd.comment { + editedComment = comment + } + + model.client.sendSetFileInfo(fileName: fd.name, path: fd.path, fileNewName: editedFilename, comment: editedComment) + presentationMode.wrappedValue.dismiss() + + // TODO: Update the file list if the filename was changed + } label: { + Text("Save") + }.disabled(!isEdited()) + } + } + + .onAppear { + self.filename = fd.name + self.comment = fd.comment + } + .padding(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24)) + } + .frame(minWidth: 400, minHeight: 400) + } + + + static var dateFormatter: DateFormatter = { + var dateFormatter = DateFormatter() + dateFormatter.dateStyle = .long + dateFormatter.timeStyle = .short + + // Original format: Fri, Aug 20, 2021, 5:14:07 PM + return dateFormatter + }() + + static var byteCountSizeFormatter: NumberFormatter = { + let numberFormatter = NumberFormatter() + numberFormatter.numberStyle = .decimal + return numberFormatter + }() + + static let byteFormatter = ByteCountFormatter() + + private func formattedFileSize(_ fileSize: UInt) -> String { + FileItemView.byteFormatter.allowedUnits = [.useAll] + FileItemView.byteFormatter.countStyle = .file + return FileItemView.byteFormatter.string(fromByteCount: Int64(fileSize)) + } + + // Format byte count Int into string like: 23.4M (24,601,664 bytes) + private func formattedSize(byteCount: Int) -> String { + let formattedByteCount = FileDetailsView.byteCountSizeFormatter.string(from: NSNumber(value:byteCount)) ?? "0" + return "\(FileItemView.byteFormatter.string(fromByteCount: Int64(byteCount))) (\(formattedByteCount) bytes)" + } + + private func isEdited() -> Bool { + return self.filename != fd.name || self.comment != fd.comment + } + + private func canRename() -> Bool { + if self.fd.type == "fldr" { + return model.access?.contains(.canRenameFolders) == true + } + return model.access?.contains(.canRenameFiles) == true + } + + private func canSetComment() -> Bool { + if self.fd.type == "fldr" { + return model.access?.contains(.canSetFolderComment) == true + } + return model.access?.contains(.canSetFileComment) == true + } +} + +//#Preview { +// FileDetailsView(fd: FileDetails(name: "AppleWorks 6.sit", path: [""], size: 24601664, comment: "test comment", type: "SITD", creator: "SIT!", created: Date.now, modified: Date.now )) +//} diff --git a/Hotline/macOS/Files/FileItemView.swift b/Hotline/macOS/Files/FileItemView.swift new file mode 100644 index 0000000..5da744f --- /dev/null +++ b/Hotline/macOS/Files/FileItemView.swift @@ -0,0 +1,67 @@ +import SwiftUI + +struct FileItemView: View { + @Environment(Hotline.self) private var model: Hotline + + var file: FileInfo + let depth: Int + + var body: some View { + HStack(alignment: .center, spacing: 0) { + Spacer() + .frame(width: CGFloat(depth * (12 + 2))) + + Spacer() + .frame(width: 10) + .padding(.leading, 4) + .padding(.trailing, 8) + + HStack(alignment: .center) { + if file.isUnavailable { + Image(systemName: "questionmark.app.fill") + .frame(width: 16, height: 16) + .opacity(0.5) + } + else { + FileIconView(filename: file.name, fileType: file.type) + .frame(width: 16, height: 16) + } + } + .frame(width: 16) + .padding(.trailing, 6) + + Text(file.name) + .lineLimit(1) + .truncationMode(.tail) + .opacity(file.isUnavailable ? 0.5 : 1.0) + + Spacer() + if !file.isUnavailable { + Text(formattedFileSize(file.fileSize)) + .foregroundStyle(.secondary) + .lineLimit(1) + .padding(.trailing, 6) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + if file.expanded { + ForEach(file.children!, id: \.self) { childFile in + if childFile.isFolder { + FolderItemView(file: childFile, depth: self.depth + 1).tag(file.id) + } + else { + FileItemView(file: childFile, depth: self.depth + 1).tag(file.id) + } + } + } + } + + static let byteFormatter = ByteCountFormatter() + + private func formattedFileSize(_ fileSize: UInt) -> String { + FileItemView.byteFormatter.allowedUnits = [.useAll] + FileItemView.byteFormatter.countStyle = .file + return FileItemView.byteFormatter.string(fromByteCount: Int64(fileSize)) + } +} diff --git a/Hotline/macOS/Files/FilePreviewImageView.swift b/Hotline/macOS/Files/FilePreviewImageView.swift new file mode 100644 index 0000000..9beeb80 --- /dev/null +++ b/Hotline/macOS/Files/FilePreviewImageView.swift @@ -0,0 +1,123 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct FilePreviewImageView: View { + enum FilePreviewFocus: Hashable { + case window + } + + @Environment(\.controlActiveState) private var controlActiveState + @Environment(\.colorScheme) private var colorScheme + @Environment(\.dismiss) var dismiss + + @Binding var info: PreviewFileInfo? + + @State var preview: FilePreview? = nil + @FocusState private var focusField: FilePreviewFocus? + + var body: some View { + Group { + if preview?.state != .loaded { + HStack(alignment: .center, spacing: 0) { + ProgressView(value: max(0.0, min(1.0, preview?.progress ?? 0.0))) + .focusable(false) + .progressViewStyle(.circular) + .controlSize(.extraLarge) + .tint(.white) + .frame(maxWidth: 300, alignment: .center) + } + .frame(minWidth: 350, maxWidth: 350, minHeight: 150, maxHeight: 150) + .padding() + } + else { + if let image = preview?.image { + FileImageView(image: image) + .frame(minWidth: 200, maxWidth: .infinity, minHeight: 200, maxHeight: .infinity) + } + else { + VStack(alignment: .center, spacing: 0) { + Spacer() + + Image(systemName: "eye.trianglebadge.exclamationmark") + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + .frame(height: 48) + .padding(.bottom) + Group { + Text("This file type is not previewable") + .bold() + Text("Try downloading and opening this file in another application.") + .foregroundStyle(Color.secondary) + } + .font(.system(size: 14.0)) + .frame(maxWidth: 300) + .multilineTextAlignment(.center) + + Spacer() + } + .frame(minWidth: 350, maxWidth: 350, minHeight: 150, maxHeight: 150) + .padding() + } + } + } + .focusable() + .focusEffectDisabled() + .focused($focusField, equals: .window) + .preferredColorScheme(.dark) + .navigationTitle(info?.name ?? "Preview") + .background(.black) + .toolbar { + ToolbarItem(placement: .navigation) { + FileIconView(filename: info?.name ?? "", fileType: nil) + .frame(width: 16, height: 16) + .opacity(controlActiveState == .inactive ? 0.5 : 1.0) + } + + if let img = preview?.image { + if let info = info { + ToolbarItem(placement: .primaryAction) { + Button { + let _ = preview?.data?.saveAsFileToDownloads(filename: info.name) + } label: { + Label("Download Image...", systemImage: "arrow.down") + } + .help("Download Image") + } + + ToolbarItem(placement: .primaryAction) { + ShareLink(item: img, preview: SharePreview(info.name, image: img)) { + Label("Share Image...", systemImage: "square.and.arrow.up") + } + .help("Share Image") + } + } + } + } + .task { + if let info = info { + preview = FilePreview(info: info) + preview?.download() + } + } + .onAppear { + if info == nil { + Task { + dismiss() + } + return + } + + focusField = .window + } + .onDisappear { + preview?.cancel() + dismiss() + } + .onChange(of: preview?.state) { + if preview?.state == .failed { + dismiss() + } + } + } +} diff --git a/Hotline/macOS/Files/FilePreviewTextView.swift b/Hotline/macOS/Files/FilePreviewTextView.swift new file mode 100644 index 0000000..c286381 --- /dev/null +++ b/Hotline/macOS/Files/FilePreviewTextView.swift @@ -0,0 +1,127 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct FilePreviewTextView: View { + enum FilePreviewFocus: Hashable { + case window + } + + @Environment(\.controlActiveState) private var controlActiveState + @Environment(\.colorScheme) private var colorScheme + @Environment(\.dismiss) var dismiss + + @Binding var info: PreviewFileInfo? + @State var preview: FilePreview? = nil + @FocusState private var focusField: FilePreviewFocus? + + var body: some View { + Group { + if preview?.state != .loaded { + VStack(alignment: .center, spacing: 0) { + Spacer() + ProgressView(value: max(0.0, min(1.0, preview?.progress ?? 0.0))) + .focusable(false) + .progressViewStyle(.circular) + .controlSize(.extraLarge) + .tint(.white) + .frame(maxWidth: 300, alignment: .center) + Spacer() + Spacer() + } + .background(Color(nsColor: .textBackgroundColor)) + .frame(minWidth: 350, maxWidth: .infinity, minHeight: 150, maxHeight: .infinity) + .padding() + } + else { + if let text = preview?.text { + TextEditor(text: .constant(text)) + .textEditorStyle(.plain) + .font(.system(size: 14)) + .lineSpacing(3) + .padding(16) + .contentMargins(.top, -16.0, for: .scrollIndicators) + .contentMargins(.bottom, -16.0, for: .scrollIndicators) + .contentMargins(.trailing, -16.0, for: .scrollIndicators) + .scrollClipDisabled() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + else { + VStack(alignment: .center, spacing: 0) { + Spacer() + + Image(systemName: "eye.trianglebadge.exclamationmark") + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + .frame(height: 48) + .padding(.bottom) + Group { + Text("This file type is not previewable") + .bold() + Text("Try downloading and opening this file in another application.") + .foregroundStyle(Color.secondary) + } + .font(.system(size: 14.0)) + .frame(maxWidth: 300) + .multilineTextAlignment(.center) + + Spacer() + Spacer() + } + .frame(minWidth: 350, maxWidth: .infinity, minHeight: 150, maxHeight: .infinity) + .padding() + } + } + } + .focusable() + .focusEffectDisabled() + .background(Color(nsColor: .textBackgroundColor)) + .focused($focusField, equals: .window) + .navigationTitle(info?.name ?? "File Preview") + .toolbar { + ToolbarItem(placement: .navigation) { + FileIconView(filename: info?.name ?? "", fileType: nil) + .frame(width: 16, height: 16) + .opacity(controlActiveState == .inactive ? 0.5 : 1.0) + } + + if let _ = preview?.text { + if let info = info { + ToolbarItem(placement: .primaryAction) { + Button { + let _ = preview?.data?.saveAsFileToDownloads(filename: info.name) + } label: { + Label("Save Text File...", systemImage: "square.and.arrow.down") + } + .help("Save Text File") + } + } + } + } + .task { + if let info = info { + preview = FilePreview(info: info) + preview?.download() + } + } + .onAppear { + if info == nil { + Task { + dismiss() + } + return + } + + focusField = .window + } + .onDisappear { + preview?.cancel() + dismiss() + } + .onChange(of: preview?.state) { + if preview?.state == .failed { + dismiss() + } + } + } +} diff --git a/Hotline/macOS/Files/FilesView.swift b/Hotline/macOS/Files/FilesView.swift new file mode 100644 index 0000000..de699ff --- /dev/null +++ b/Hotline/macOS/Files/FilesView.swift @@ -0,0 +1,418 @@ +import SwiftUI +import UniformTypeIdentifiers +import AppKit + + + + + +struct FilesView: View { + @Environment(Hotline.self) private var model: Hotline + @Environment(\.openWindow) private var openWindow + + @State private var selection: FileInfo? + @State private var fileDetails: FileDetails? + @State private var uploadFileSelectorDisplayed: Bool = false + @State private var searchText: String = "" + @State private var isSearching: Bool = false + + private var isShowingSearchResults: Bool { + switch model.fileSearchStatus { + case .idle: + return !model.fileSearchResults.isEmpty + case .cancelled(_): + return !model.fileSearchResults.isEmpty + default: + return true + } + } + + private var displayedFiles: [FileInfo] { + isShowingSearchResults ? model.fileSearchResults : model.files + } + + private var searchStatusMessage: String? { + switch model.fileSearchStatus { + case .searching(let processed, _): + let scanned = processed == 1 ? "folder" : "folders" + return "Searched \(processed) \(scanned)..." + case .completed(let processed): + let count = model.fileSearchResults.count + let folderWord = processed == 1 ? "folder" : "folders" + if count == 0 { + return "No files found in \(processed) \(folderWord)" + } + return "\(count) file\(count == 1 ? "" : "s") found in \(processed) \(folderWord)" + case .cancelled(_): + if model.fileSearchResults.isEmpty { + return nil + } + return "Search cancelled" + case .failed(let message): + return "Search failed: \(message)" + case .idle: + return nil + } + } + + private var searchStatusPath: String? { + guard let path = model.fileSearchCurrentPath else { + return nil + } + if path.isEmpty { + return "/" + } + return path.joined(separator: "/") + } + + private func openPreviewWindow(_ previewInfo: PreviewFileInfo) { + switch previewInfo.previewType { + case .image: + openWindow(id: "preview-image", value: previewInfo) + case .text: + openWindow(id: "preview-text", value: previewInfo) + default: + return + } + } + + @MainActor private func getFileInfo(_ file: FileInfo) { + Task { + if let fileInfo = await model.getFileDetails(file.name, path: file.path) { + Task { @MainActor in + self.fileDetails = fileInfo + } + } + } + } + + @MainActor private func downloadFile(_ file: FileInfo) { + if file.isFolder { + model.downloadFolder(file.name, path: file.path) + } + else { + model.downloadFile(file.name, path: file.path) + } + } + + @MainActor private func uploadFile(file fileURL: URL, to path: [String]) { + model.uploadFile(url: fileURL, path: path) { info in + Task { + // Refresh file listing to display newly uploaded file. + let _ = await model.getFileList(path: path) + } + } + } + + @MainActor private func previewFile(_ file: FileInfo) { + guard file.isPreviewable else { + return + } + + model.previewFile(file.name, path: file.path) { info in + if let info = info { + openPreviewWindow(info) + } + } + } + + private func deleteFile(_ file: FileInfo) async { + var parentPath: [String] = [] + if file.path.count > 1 { + parentPath = Array(file.path[0.. 0 else { + return + } + + let fileURL = fileURLS.first! + + print(fileURL) + + var uploadPath: [String] = [] + + if let selection = selection { + if selection.isFolder { + uploadPath = selection.path + } + else { + uploadPath = Array(selection.path) + uploadPath.removeLast() + } + } + + print("UPLOAD PATH: \(uploadPath)") + uploadFile(file: fileURL, to: uploadPath) + + case .failure(let error): + print(error) + } + }) + .onSubmit(of: .search) { + #if os(macOS) + let shiftPressed = NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false + if shiftPressed { + model.clearFileListCache() + } + #endif + + let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + model.cancelFileSearch() + return + } + searchText = trimmed + model.startFileSearch(query: trimmed) + } + .onChange(of: searchText) { _, newValue in + if newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + if isShowingSearchResults { + model.cancelFileSearch() + } + } + } + .onChange(of: model.fileSearchQuery) { _, newValue in + if newValue != searchText { + searchText = newValue + } + } + .onAppear { + if searchText != model.fileSearchQuery { + searchText = model.fileSearchQuery + } + } + .safeAreaInset(edge: .top) { + if isShowingSearchResults, let message = searchStatusMessage { + HStack(alignment: .center, spacing: 6) { + if case .searching(_, _) = model.fileSearchStatus { + ProgressView() + .controlSize(.small) + .accentColor(.white) + .tint(.white) + } + else if case .completed = model.fileSearchStatus { + Image(systemName: "checkmark.circle.fill") + .resizable() + .symbolRenderingMode(.monochrome) + .foregroundStyle(.white) + .aspectRatio(contentMode: .fit) + .frame(width: 16, height: 16) + } + else if case .failed = model.fileSearchStatus { + Image(systemName: "exclamationmark.triangle.fill") + .resizable() + .symbolRenderingMode(.monochrome) + .foregroundStyle(.white) + .aspectRatio(contentMode: .fit) + .frame(width: 16, height: 16) + } + + Text(message) + .lineLimit(1) + .font(.body) + .foregroundStyle(.white) + + Spacer() + + if let pathMessage = searchStatusPath { + Text(pathMessage) + .lineLimit(1) + .truncationMode(.tail) + .font(.footnote) +// .fontWeight(.semibold) + .foregroundStyle(.white) + .opacity(0.5) + .padding(.top, 2) + } + } + .padding(.trailing, 14) + .padding(.leading, 8) + .padding(.vertical, 8) + .background { + Group { + if case .completed = model.fileSearchStatus { + Color.fileComplete + } + else { + Color(nsColor: .controlAccentColor) + } + } + .clipShape(.capsule(style: .continuous)) + } + .padding(.horizontal, 8) + .padding(.top, 8) + } + } + } +} + +#Preview { + FilesView() + .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) +} diff --git a/Hotline/macOS/Files/FolderItemView.swift b/Hotline/macOS/Files/FolderItemView.swift new file mode 100644 index 0000000..4a08974 --- /dev/null +++ b/Hotline/macOS/Files/FolderItemView.swift @@ -0,0 +1,140 @@ +import SwiftUI + +struct FolderItemView: View { + @Environment(Hotline.self) private var model: Hotline + + @State var loading = false + @State var dragOver = false + + var file: FileInfo + let depth: Int + + @MainActor private func uploadFile(file fileURL: URL) { + var filePath: [String] = [String](self.file.path) + if !self.file.isFolder { + filePath.removeLast() + } + + print("UPLOADING TO PATH: ", filePath) + + model.uploadFile(url: fileURL, path: filePath) { info in + Task { + // Refresh file listing to display newly uploaded file. + let _ = await model.getFileList(path: filePath) + } + } + } + + var body: some View { + HStack(alignment: .center, spacing: 0) { + Spacer() + .frame(width: CGFloat(depth * (12 + 2))) + + Button { + if file.isFolder { + file.expanded.toggle() + } + } label: { + Text(Image(systemName: file.expanded ? "chevron.down" : "chevron.right")) + .bold() + .font(.system(size: 10)) + .foregroundStyle(dragOver ? Color.white : Color.primary) + .opacity(0.5) + } + .buttonStyle(.plain) + .frame(width: 10) + .padding(.leading, 4) + .padding(.trailing, 8) + + HStack(alignment: .center) { + if file.isUnavailable { + Image(systemName: "questionmark.app.fill") + .frame(width: 16, height: 16) + .opacity(0.5) + } + else if file.isAdminDropboxFolder { + Image("Admin Drop Box") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + } + else if file.isDropboxFolder { + Image("Drop Box") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + } + else { + Image("Folder") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + } + } + .frame(width: 16) + .padding(.trailing, 6) + + Text(file.name) + .lineLimit(1) + .truncationMode(.tail) + .foregroundStyle(dragOver ? Color.white : Color.primary) + .opacity(file.isUnavailable ? 0.5 : 1.0) + + if loading { + ProgressView().controlSize(.small).padding([.leading, .trailing], 5) + } + Spacer() + if !file.isUnavailable { + Text(file.fileSize == 0 ? "Empty" : "^[\(file.fileSize) \("file")](inflect: true)") + .foregroundStyle(dragOver ? Color.white.opacity(0.75) : Color.secondary) + .lineLimit(1) + .padding(.trailing, 6) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background( + RoundedRectangle(cornerRadius: 4.0) + .fill(dragOver ? Color(nsColor: NSColor.selectedContentBackgroundColor) : Color.clear) + .padding(.horizontal, -6) + .padding(.vertical, -4) + ) + .onChange(of: file.expanded) { + loading = false + if file.expanded && file.fileSize > 0 { + Task { + loading = true + let _ = await model.getFileList(path: file.path) + loading = false + } + } + } + .onDrop(of: [.fileURL], isTargeted: $dragOver) { items in + guard let item = items.first, + let identifier = item.registeredTypeIdentifiers.first else { + return false + } + + item.loadItem(forTypeIdentifier: identifier, options: nil) { (urlData, error) in + DispatchQueue.main.async { + if let urlData = urlData as? Data, + let fileURL = URL(dataRepresentation: urlData, relativeTo: nil, isAbsolute: true) { + uploadFile(file: fileURL) + } + } + } + + return true + } + + if file.expanded { + ForEach(file.children!, id: \.self) { childFile in + if childFile.isFolder { + FolderItemView(file: childFile, depth: self.depth + 1).tag(file.id) + } + else { + FileItemView(file: childFile, depth: self.depth + 1).tag(file.id) + } + } + } + } +} diff --git a/Hotline/macOS/FilesView.swift b/Hotline/macOS/FilesView.swift deleted file mode 100644 index 42232af..0000000 --- a/Hotline/macOS/FilesView.swift +++ /dev/null @@ -1,619 +0,0 @@ -import SwiftUI -import UniformTypeIdentifiers -import AppKit - -struct FolderView: View { - @Environment(Hotline.self) private var model: Hotline - - @State var loading = false - @State var dragOver = false - - var file: FileInfo - let depth: Int - - @MainActor private func uploadFile(file fileURL: URL) { - var filePath: [String] = [String](self.file.path) - if !self.file.isFolder { - filePath.removeLast() - } - - print("UPLOADING TO PATH: ", filePath) - - model.uploadFile(url: fileURL, path: filePath) { info in - Task { - // Refresh file listing to display newly uploaded file. - let _ = await model.getFileList(path: filePath) - } - } - } - - var body: some View { - HStack(alignment: .center, spacing: 0) { - Spacer() - .frame(width: CGFloat(depth * (12 + 2))) - - Button { - if file.isFolder { - file.expanded.toggle() - } - } label: { - Text(Image(systemName: file.expanded ? "chevron.down" : "chevron.right")) - .bold() - .font(.system(size: 10)) - .foregroundStyle(dragOver ? Color.white : Color.primary) - .opacity(0.5) - } - .buttonStyle(.plain) - .frame(width: 10) - .padding(.leading, 4) - .padding(.trailing, 8) - - HStack(alignment: .center) { - if file.isUnavailable { - Image(systemName: "questionmark.app.fill") - .frame(width: 16, height: 16) - .opacity(0.5) - } - else if file.isAdminDropboxFolder { - Image("Admin Drop Box") - .resizable() - .scaledToFit() - .frame(width: 16, height: 16) - } - else if file.isDropboxFolder { - Image("Drop Box") - .resizable() - .scaledToFit() - .frame(width: 16, height: 16) - } - else { - Image("Folder") - .resizable() - .scaledToFit() - .frame(width: 16, height: 16) - } - } - .frame(width: 16) - .padding(.trailing, 6) - - Text(file.name) - .lineLimit(1) - .truncationMode(.tail) - .foregroundStyle(dragOver ? Color.white : Color.primary) - .opacity(file.isUnavailable ? 0.5 : 1.0) - - if loading { - ProgressView().controlSize(.small).padding([.leading, .trailing], 5) - } - Spacer() - if !file.isUnavailable { - Text(file.fileSize == 0 ? "Empty" : "^[\(file.fileSize) \("file")](inflect: true)") - .foregroundStyle(dragOver ? Color.white.opacity(0.75) : Color.secondary) - .lineLimit(1) - .padding(.trailing, 6) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background( - RoundedRectangle(cornerRadius: 4.0) - .fill(dragOver ? Color(nsColor: NSColor.selectedContentBackgroundColor) : Color.clear) - .padding(.horizontal, -6) - .padding(.vertical, -4) - ) - .onChange(of: file.expanded) { - loading = false - if file.expanded && file.fileSize > 0 { - Task { - loading = true - let _ = await model.getFileList(path: file.path) - loading = false - } - } - } - .onDrop(of: [.fileURL], isTargeted: $dragOver) { items in - guard let item = items.first, - let identifier = item.registeredTypeIdentifiers.first else { - return false - } - - item.loadItem(forTypeIdentifier: identifier, options: nil) { (urlData, error) in - DispatchQueue.main.async { - if let urlData = urlData as? Data, - let fileURL = URL(dataRepresentation: urlData, relativeTo: nil, isAbsolute: true) { - uploadFile(file: fileURL) - } - } - } - - return true - } - - if file.expanded { - ForEach(file.children!, id: \.self) { childFile in - if childFile.isFolder { - FolderView(file: childFile, depth: self.depth + 1).tag(file.id) - } - else { - FileView(file: childFile, depth: self.depth + 1).tag(file.id) - } - } - } - } -} - -struct FileView: View { - @Environment(Hotline.self) private var model: Hotline - - var file: FileInfo - let depth: Int - - var body: some View { - HStack(alignment: .center, spacing: 0) { - Spacer() - .frame(width: CGFloat(depth * (12 + 2))) - - Spacer() - .frame(width: 10) - .padding(.leading, 4) - .padding(.trailing, 8) - - HStack(alignment: .center) { - if file.isUnavailable { - Image(systemName: "questionmark.app.fill") - .frame(width: 16, height: 16) - .opacity(0.5) - } - else { - FileIconView(filename: file.name, fileType: file.type) - .frame(width: 16, height: 16) - } - } - .frame(width: 16) - .padding(.trailing, 6) - - Text(file.name) - .lineLimit(1) - .truncationMode(.tail) - .opacity(file.isUnavailable ? 0.5 : 1.0) - - Spacer() - if !file.isUnavailable { - Text(formattedFileSize(file.fileSize)) - .foregroundStyle(.secondary) - .lineLimit(1) - .padding(.trailing, 6) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - - if file.expanded { - ForEach(file.children!, id: \.self) { childFile in - if childFile.isFolder { - FolderView(file: childFile, depth: self.depth + 1).tag(file.id) - } - else { - FileView(file: childFile, depth: self.depth + 1).tag(file.id) - } - } - } - } - - static let byteFormatter = ByteCountFormatter() - - private func formattedFileSize(_ fileSize: UInt) -> String { - FileView.byteFormatter.allowedUnits = [.useAll] - FileView.byteFormatter.countStyle = .file - return FileView.byteFormatter.string(fromByteCount: Int64(fileSize)) - } -} - -struct FilesView: View { - @Environment(Hotline.self) private var model: Hotline - @Environment(\.openWindow) private var openWindow - - @State private var selection: FileInfo? - @State private var fileDetails: FileDetails? - @State private var uploadFileSelectorDisplayed: Bool = false - @State private var searchText: String = "" - @State private var isSearching: Bool = false - - private var isShowingSearchResults: Bool { - switch model.fileSearchStatus { - case .idle: - return !model.fileSearchResults.isEmpty - case .cancelled(_): - return !model.fileSearchResults.isEmpty - default: - return true - } - } - - private var displayedFiles: [FileInfo] { - isShowingSearchResults ? model.fileSearchResults : model.files - } - - private var searchStatusMessage: String? { - switch model.fileSearchStatus { - case .searching(let processed, _): - let scanned = processed == 1 ? "folder" : "folders" - return "Searched \(processed) \(scanned)..." - case .completed(let processed): - let count = model.fileSearchResults.count - let folderWord = processed == 1 ? "folder" : "folders" - if count == 0 { - return "No files found in \(processed) \(folderWord)" - } - return "\(count) file\(count == 1 ? "" : "s") found in \(processed) \(folderWord)" - case .cancelled(_): - if model.fileSearchResults.isEmpty { - return nil - } - return "Search cancelled" - case .failed(let message): - return "Search failed: \(message)" - case .idle: - return nil - } - } - - private var searchStatusPath: String? { - guard let path = model.fileSearchCurrentPath else { - return nil - } - if path.isEmpty { - return "/" - } - return path.joined(separator: "/") - } - - private func openPreviewWindow(_ previewInfo: PreviewFileInfo) { - switch previewInfo.previewType { - case .image: - openWindow(id: "preview-image", value: previewInfo) - case .text: - openWindow(id: "preview-text", value: previewInfo) - default: - return - } - } - - @MainActor private func getFileInfo(_ file: FileInfo) { - Task { - if let fileInfo = await model.getFileDetails(file.name, path: file.path) { - Task { @MainActor in - self.fileDetails = fileInfo - } - } - } - } - - @MainActor private func downloadFile(_ file: FileInfo) { - if file.isFolder { - model.downloadFolder(file.name, path: file.path) - } - else { - model.downloadFile(file.name, path: file.path) - } - } - - @MainActor private func uploadFile(file fileURL: URL, to path: [String]) { - model.uploadFile(url: fileURL, path: path) { info in - Task { - // Refresh file listing to display newly uploaded file. - let _ = await model.getFileList(path: path) - } - } - } - - @MainActor private func previewFile(_ file: FileInfo) { - guard file.isPreviewable else { - return - } - - model.previewFile(file.name, path: file.path) { info in - if let info = info { - openPreviewWindow(info) - } - } - } - - private func deleteFile(_ file: FileInfo) async { - var parentPath: [String] = [] - if file.path.count > 1 { - parentPath = Array(file.path[0.. 0 else { - return - } - - let fileURL = fileURLS.first! - - print(fileURL) - - var uploadPath: [String] = [] - - if let selection = selection { - if selection.isFolder { - uploadPath = selection.path - } - else { - uploadPath = Array(selection.path) - uploadPath.removeLast() - } - } - - print("UPLOAD PATH: \(uploadPath)") - uploadFile(file: fileURL, to: uploadPath) - - case .failure(let error): - print(error) - } - }) - .onSubmit(of: .search) { - #if os(macOS) - let shiftPressed = NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false - if shiftPressed { - model.clearFileListCache() - } - #endif - - let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - model.cancelFileSearch() - return - } - searchText = trimmed - model.startFileSearch(query: trimmed) - } - .onChange(of: searchText) { _, newValue in - if newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - if isShowingSearchResults { - model.cancelFileSearch() - } - } - } - .onChange(of: model.fileSearchQuery) { _, newValue in - if newValue != searchText { - searchText = newValue - } - } - .onAppear { - if searchText != model.fileSearchQuery { - searchText = model.fileSearchQuery - } - } - .safeAreaInset(edge: .top) { - if isShowingSearchResults, let message = searchStatusMessage { - HStack(alignment: .center, spacing: 6) { - if case .searching(_, _) = model.fileSearchStatus { - ProgressView() - .controlSize(.small) - .accentColor(.white) - .tint(.white) - } - else if case .completed = model.fileSearchStatus { - Image(systemName: "checkmark.circle.fill") - .resizable() - .symbolRenderingMode(.monochrome) - .foregroundStyle(.white) - .aspectRatio(contentMode: .fit) - .frame(width: 16, height: 16) - } - else if case .failed = model.fileSearchStatus { - Image(systemName: "exclamationmark.triangle.fill") - .resizable() - .symbolRenderingMode(.monochrome) - .foregroundStyle(.white) - .aspectRatio(contentMode: .fit) - .frame(width: 16, height: 16) - } - - Text(message) - .lineLimit(1) - .font(.body) - .foregroundStyle(.white) - - Spacer() - - if let pathMessage = searchStatusPath { - Text(pathMessage) - .lineLimit(1) - .truncationMode(.tail) - .font(.footnote) -// .fontWeight(.semibold) - .foregroundStyle(.white) - .opacity(0.5) - .padding(.top, 2) - } - } - .padding(.trailing, 14) - .padding(.leading, 8) - .padding(.vertical, 8) - .background { - Group { - if case .completed = model.fileSearchStatus { - Color.fileComplete - } - else { - Color(nsColor: .controlAccentColor) - } - } - .clipShape(.capsule(style: .continuous)) - } - .padding(.horizontal, 8) - .padding(.top, 8) - } - } - } -} - -#Preview { - FilesView() - .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) -} diff --git a/Hotline/macOS/MessageBoardEditorView.swift b/Hotline/macOS/MessageBoardEditorView.swift deleted file mode 100644 index 474384e..0000000 --- a/Hotline/macOS/MessageBoardEditorView.swift +++ /dev/null @@ -1,117 +0,0 @@ -import SwiftUI - -private enum FocusFields { - case body -} - -struct MessageBoardEditorView: View { - @Environment(\.controlActiveState) private var controlActiveState - @Environment(\.colorScheme) private var colorScheme - @Environment(\.dismiss) private var dismiss - @Environment(Hotline.self) private var model: Hotline - - @State private var text: String = "" - @State private var sending: Bool = false - - @FocusState private var focusedField: FocusFields? - - func sendPost() async { - sending = true - - let cleanedText = text.trimmingCharacters(in: .whitespacesAndNewlines) - - model.postToMessageBoard(text: cleanedText) - let _ = await model.getMessageBoard() - -// let success = await model.postNewsArticle(title: title, body: text, at: path, parentID: parentID) -// if success { -// await model.getNewsList(at: path) -// } - - sending = false - } - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - HStack(alignment: .center, spacing: 0) { - Button { - dismiss() - } label: { - Image(systemName: "xmark") - .resizable() - .scaledToFit() - .frame(width: 14, height: 14) - .opacity(0.5) - } - .buttonStyle(.plain) - .frame(width: 16, height: 16) - - Spacer() - -// Image("Message Board Post") -// .resizable() -// .scaledToFit() -// .frame(width: 16, height: 16) -// .padding(.trailing, 6) - - Text("New Post") - .fontWeight(.semibold) - .lineLimit(1) - .truncationMode(.middle) - - Spacer() - - if sending { - ProgressView() - .controlSize(.small) - .frame(width: 22, height: 22) - } - else { - Button { - sending = true - model.postToMessageBoard(text: text) - Task { - let _ = await model.getMessageBoard() - Task { @MainActor in - sending = false - dismiss() - } - } - } label: { - Image(systemName: "arrow.up.circle.fill") - .resizable() - .renderingMode(.template) - .scaledToFit() - .foregroundColor(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? .secondary : .accentColor) - } - .buttonStyle(.plain) - .frame(width: 22, height: 22) - .help("Post to Message Board") - .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - } - .frame(maxWidth: .infinity) - .padding() - - Divider() - - BetterTextEditor(text: $text) - .betterEditorFont(NSFont.systemFont(ofSize: 14.0)) - .betterEditorAutomaticSpellingCorrection(true) - .betterEditorTextInset(.init(width: 16, height: 18)) - .lineSpacing(20) - .background(Color(nsColor: .textBackgroundColor)) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .focused($focusedField, equals: .body) - } - .frame(minWidth: 300, idealWidth: 450, maxWidth: .infinity, minHeight: 300, idealHeight: 500, maxHeight: .infinity) - .background(Color(nsColor: .textBackgroundColor)) - .presentationCompactAdaptation(.sheet) - .onAppear { - focusedField = .body - } - .onDisappear { - dismiss() - } - } -} diff --git a/Hotline/macOS/MessageBoardView.swift b/Hotline/macOS/MessageBoardView.swift deleted file mode 100644 index f870b0c..0000000 --- a/Hotline/macOS/MessageBoardView.swift +++ /dev/null @@ -1,102 +0,0 @@ -import SwiftUI - -struct MessageBoardView: View { - @Environment(Hotline.self) private var model: Hotline - - @State private var composerDisplayed: Bool = false - @State private var composerText: String = "" - - var body: some View { - NavigationStack { - if model.access?.contains(.canReadMessageBoard) != false { - ScrollView { - LazyVStack(alignment: .leading) { - ForEach(model.messageBoard, id: \.self) { msg in - Text(LocalizedStringKey(msg)) - .tint(Color("Link Color")) - .lineLimit(100) - .lineSpacing(4) - .padding() - .textSelection(.enabled) - Divider() - } - } - Spacer() - } - .task { - if !model.messageBoardLoaded { - let _ = await model.getMessageBoard() - } - } - .overlay { - if !model.messageBoardLoaded { - VStack { - ProgressView() - .controlSize(.large) - } - .frame(maxWidth: .infinity) - } - } - .background(Color(nsColor: .textBackgroundColor)) - } - else { - ZStack(alignment: .center) { - Text("No Message Board") - .font(.title) - .multilineTextAlignment(.center) - .foregroundStyle(.secondary) - .padding() - } - .frame(maxWidth: .infinity) - } - } - .sheet(isPresented: $composerDisplayed) { - MessageBoardEditorView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - .frame(idealWidth: 450, idealHeight: 350) -// RichTextEditor(text: $composerText) -// .richEditorFont(NSFont.systemFont(ofSize: 16.0)) -// .richEditorAutomaticDashSubstitution(false) -// .richEditorAutomaticQuoteSubstitution(false) -// .richEditorAutomaticSpellingCorrection(false) -// .background(Color(nsColor: .textBackgroundColor)) -// .frame(maxWidth: .infinity, maxHeight: .infinity) -// .frame(idealWidth: 450, idealHeight: 350) -// .toolbar { -// ToolbarItem(placement: .cancellationAction) { -// Button("Cancel") { -// composerDisplayed.toggle() -// } -// } -// -// ToolbarItem(placement: .primaryAction) { -// Button("Post") { -// composerDisplayed.toggle() -// let text = composerText -// composerText = "" -// model.postToMessageBoard(text: text) -// Task { -// await model.getMessageBoard() -// } -// } -// } -// } - } - .toolbar { - ToolbarItem(placement:.primaryAction) { - Button { - composerDisplayed.toggle() - } label: { - Image(systemName: "square.and.pencil") - } - .disabled(model.access?.contains(.canPostMessageBoard) == false) - .help("Post to Message Board") - } - } - } -} - -#Preview { - MessageBoardView() - .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) -} diff --git a/Hotline/macOS/News/NewsEditorView.swift b/Hotline/macOS/News/NewsEditorView.swift new file mode 100644 index 0000000..f69c846 --- /dev/null +++ b/Hotline/macOS/News/NewsEditorView.swift @@ -0,0 +1,153 @@ +import SwiftUI + +private enum FocusFields { + case title + case body +} + +struct NewsEditorView: View { + @Environment(\.controlActiveState) private var controlActiveState + @Environment(\.colorScheme) private var colorScheme + @Environment(\.dismiss) private var dismiss + @Environment(Hotline.self) private var model: Hotline + + let editorTitle: String + let isReply: Bool + let path: [String] + let parentID: UInt32 + + @State var title: String = "" + @State private var text: String = "" + @State private var sending: Bool = false + + @FocusState private var focusedField: FocusFields? + + func sendArticle() async -> Bool { + sending = true + + let success = await model.postNewsArticle(title: title, body: text, at: path, parentID: parentID) + if success { + await model.getNewsList(at: path) + } + + sending = false + + return success + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .center, spacing: 0) { + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .resizable() + .scaledToFit() + .frame(width: 14, height: 14) + .opacity(0.5) + } + .buttonStyle(.plain) + .frame(width: 16, height: 16) + + Spacer() + + if !isReply { + Image("News Category") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + .padding(.trailing, 6) + } + + Text(editorTitle) + .fontWeight(.semibold) + .lineLimit(1) + .truncationMode(.middle) + + Spacer() + + if sending { + ProgressView() + .controlSize(.small) + .frame(width: 22, height: 22) + } + else { + Button { + Task { + if await sendArticle() { + dismiss() + } + } + } label: { + Image(systemName: "arrow.up.circle.fill") + .resizable() + .renderingMode(.template) + .scaledToFit() + .foregroundColor((title.isEmpty || text.isEmpty) ? .secondary : .accentColor) + } + .buttonStyle(.plain) + .frame(width: 22, height: 22) + .help("Post News") + .disabled(title.isEmpty || text.isEmpty) + } + } + .frame(maxWidth: .infinity) + .padding([.leading, .top, .trailing]) + + TextField("Title", text: $title, axis: .vertical) + .textFieldStyle(.plain) + .lineLimit(3) + .padding() + .focusEffectDisabled() + .fontWeight(.semibold) + .frame(maxWidth: .infinity) + .border(Color.pink, width: 0) + .background(.tertiary.opacity(0.2)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .padding() + .focused($focusedField, equals: .title) + + Divider() + + BetterTextEditor(text: $text) + .betterEditorFont(NSFont.monospacedSystemFont(ofSize: 14.0, weight: .regular)) + .betterEditorAutomaticSpellingCorrection(true) + .betterEditorTextInset(.init(width: 16, height: 18)) + .background(Color(nsColor: .textBackgroundColor)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .focused($focusedField, equals: .body) + + HStack(alignment: .center) { + Spacer() + + Text(String("**bold** _italics_ [link name](url) ![image name](url)")) + .foregroundStyle(.secondary) + .font(.caption) + .fontDesign(.monospaced) + .lineLimit(1) + .truncationMode(.middle) + .padding() + + Spacer() + } + .frame(maxWidth: .infinity) + .background(.tertiary.opacity(0.15)) + } + .frame(minWidth: 300, idealWidth: 450, maxWidth: .infinity, minHeight: 300, idealHeight: 500, maxHeight: .infinity) + .background(Color(nsColor: .textBackgroundColor)) + .presentationCompactAdaptation(.sheet) + .toolbarTitleDisplayMode(.inlineLarge) + .onAppear { + if !title.isEmpty { + focusedField = .body + } + else { + focusedField = .title + } + } + .onDisappear { + dismiss() + } + } +} diff --git a/Hotline/macOS/News/NewsItemView.swift b/Hotline/macOS/News/NewsItemView.swift new file mode 100644 index 0000000..fc20e61 --- /dev/null +++ b/Hotline/macOS/News/NewsItemView.swift @@ -0,0 +1,152 @@ +import SwiftUI + +struct NewsItemView: View { + @Environment(Hotline.self) private var model: Hotline + + var news: NewsInfo + let depth: Int + + static var dateFormatter: DateFormatter = { + var dateFormatter = DateFormatter() + dateFormatter.dateStyle = .long + dateFormatter.timeStyle = .short + dateFormatter.timeZone = .gmt + return dateFormatter + }() + + static var relativeDateFormatter: RelativeDateTimeFormatter = { + var formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .full + formatter.dateTimeStyle = .named + formatter.formattingContext = .listItem + return formatter + }() + + var body: some View { + HStack(alignment: .center, spacing: 0) { + if news.expandable { + Button { + news.expanded.toggle() + } label: { + Text(Image(systemName: news.expanded ? "chevron.down" : "chevron.right")) + .bold() + .font(.system(size: 10)) + .opacity(0.5) + .frame(alignment: .center) + } + .buttonStyle(.plain) + .frame(width: 10) + .padding(.leading, 4) + .padding(.trailing, 8) + } + else { + Spacer() + .frame(width: 10) + .padding(.leading, 4) + .padding(.trailing, 8) + } + + // Tree indent + Spacer() + .frame(width: (CGFloat(depth) * 22)) + + switch news.type { + case .category: + Image("News Category") + .resizable() + .frame(width: 16, height: 16, alignment: .center) + .padding(.trailing, 6) + case .bundle: + Image("News Bundle") + .resizable() + .frame(width: 16, height: 16, alignment: .center) + .padding(.trailing, 6) + case .article: + EmptyView() + } + + Text(news.name) + .fontWeight((news.type == .bundle || news.type == .category || !news.read) ? .semibold : .regular) + .lineLimit(1) + .truncationMode(.tail) + + if news.type == .article && news.articleUsername != nil { + Text(news.articleUsername!) + .foregroundStyle(.secondary) + .lineLimit(1) + .padding(.leading, 8) + } + + Spacer() + + if news.type == .category && news.count > 0 { + Text("^[\(news.count) Post](inflect: true)") + .lineLimit(1) + .foregroundStyle(.secondary) + .padding(.trailing, 8) + } + else if news.type == .bundle && news.count > 0 { + Text("^[\(news.count) Category](inflect: true)") + .lineLimit(1) + .foregroundStyle(.secondary) + .padding(.trailing, 8) + } +// if news.type == .bundle { +// Text("\(news.count)") +// .lineLimit(1) +// .foregroundStyle(.tertiary) +// .padding(.trailing, 8) + +// ZStack { +// Text("^[\(news.count) \(news.type == .bundle ? "Category" : "Post")](inflect: true)") +// Text("\(news.count)") +// .foregroundStyle(.clear) +//// .font(.caption) +// .lineLimit(1) +// .padding([.leading, .trailing], 8) +// .padding([.top, .bottom], 2) +// .background(.secondary) +// .clipShape(Capsule()) +// +// Text("\(news.count)") +// .foregroundStyle(.white) +//// .font(.caption) +// .lineLimit(1) +// .padding([.leading, .trailing], 8) +// .padding([.top, .bottom], 2) +// .blendMode(.destinationOut) +// } +// .drawingGroup(opaque: false) +// } + else if news.type == .article && news.articleUsername != nil { + if let d = news.articleDate { + Text(NewsItemView.relativeDateFormatter.localizedString(for: d, relativeTo: Date.now)) + .lineLimit(1) + .foregroundStyle(.secondary) + .padding(.trailing, 8) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onChange(of: news.expanded) { + guard news.expanded, news.type == .bundle || news.type == .category else { + return + } + + Task { + await model.getNewsList(at: news.path) + } + } + + if news.expanded { + ForEach(news.children.reversed(), id: \.self) { childNews in + NewsItemView(news: childNews, depth: self.depth + 1).tag(childNews.id) + } + } + } +} + +#Preview { + NewsItemView(news: NewsInfo(hotlineNewsArticle: HotlineNewsArticle(id: 0, parentID: 0, flags: 0, title: "Title", username: "username", date: Date.now, flavors: [("", 1)], path: ["Guest"])), depth: 0) + .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) +} diff --git a/Hotline/macOS/News/NewsView.swift b/Hotline/macOS/News/NewsView.swift new file mode 100644 index 0000000..91bf2fe --- /dev/null +++ b/Hotline/macOS/News/NewsView.swift @@ -0,0 +1,297 @@ +import SwiftUI +import MarkdownUI +import SplitView + +struct NewsView: View { + @Environment(Hotline.self) private var model: Hotline + @Environment(\.openWindow) private var openWindow + @Environment(\.colorScheme) private var colorScheme + + @State private var selection: NewsInfo? + @State private var articleText: String? + @State private var splitHidden = SideHolder(.bottom) + @State private var splitFraction = FractionHolder.usingUserDefaults(0.25, key: "News Split Fraction") + @State private var editorOpen: Bool = false + @State private var replyOpen: Bool = false + @State private var loading: Bool = false + + var body: some View { + Group { + if model.serverVersion < 151 { + VStack { + Text("No News") + .bold() + .foregroundStyle(.secondary) + .font(.title3) + Text("This server has news turned off.") + .foregroundStyle(.tertiary) + .font(.system(size: 13)) + } + .padding() + } + else { + NavigationStack { + VSplit( + top: { + if !model.newsLoaded { + loadingIndicator + } + else if model.news.isEmpty { + ZStack(alignment: .center) { + Text("No News") + .font(.title) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + .padding() + } + .frame(maxWidth: .infinity) + } + else { + newsBrowser + } + }, + bottom: { + articleViewer + } + ) + .fraction(splitFraction) + .constraints(minPFraction: 0.1, minSFraction: 0.3) + .hide(splitHidden) + .styling(color: colorScheme == .dark ? .black : Splitter.defaultColor, inset: 0, visibleThickness: 0.5, invisibleThickness: 5, hideSplitter: true) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(nsColor: .textBackgroundColor)) + } + .task { + if !model.newsLoaded { + loading = true + await model.getNewsList() + loading = false + } + } + } + } + .sheet(isPresented: $editorOpen) { + } content: { + if let selection = selection { + switch selection.type { + case .article, .category: + NewsEditorView(editorTitle: selection.path.last ?? "New Post", isReply: false, path: selection.path, parentID: 0) + default: + EmptyView() + } + } + else { + EmptyView() + } + } + .sheet(isPresented: $replyOpen) { + } content: { + if let selection = selection, selection.type == .article { + NewsEditorView(editorTitle: "Reply to \(selection.articleUsername ?? "Post")", isReply: true, path: selection.path, parentID: UInt32(selection.articleID!), title: selection.name.replyToString()) + } + else { + EmptyView() + } + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + if selection?.type == .category || selection?.type == .article { + editorOpen = true + } + } label: { + Image(systemName: "square.and.pencil") + } + .help("New Post") + .disabled(selection?.type != .category && selection?.type != .article) + } + + ToolbarItem(placement: .primaryAction) { + Button { + if selection?.type == .article { + replyOpen = true + } + } label: { + Image(systemName: "arrowshape.turn.up.left") + } + .help("Reply to Post") + .disabled(selection?.type != .article) + } + + ToolbarItem(placement: .primaryAction) { + Button { + loading = true + if let selectionPath = selection?.path { + Task { + await model.getNewsList(at: selectionPath) + loading = false + } + } + else { + Task { + await model.getNewsList() + loading = false + } + } + } label: { + Image(systemName: "arrow.clockwise") + } + .help("Reload News") + .disabled(loading) + } + } + } + + var newsBrowser: some View { + List(model.news, id: \.self, selection: $selection) { newsItem in + NewsItemView(news: newsItem, depth: 0).tag(newsItem.id) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .environment(\.defaultMinListRowHeight, 28) + .listStyle(.inset) + .alternatingRowBackgrounds(.enabled) + .contextMenu(forSelectionType: NewsInfo.self) { items in + let selectedItem = items.first + + Button { + if selectedItem?.type == .article { + replyOpen = true + } + } label: { + Label("Reply to \(selectedItem?.articleUsername ?? "Post")", systemImage: "arrowshape.turn.up.left") + } + .disabled(selectedItem == nil || selectedItem?.type != .article) + + } primaryAction: { items in + guard let clickedNews = items.first else { + return + } + + self.selection = clickedNews + if clickedNews.type == .bundle || clickedNews.type == .category || clickedNews.children.count > 0 { + clickedNews.expanded.toggle() + } + } + .onChange(of: selection) { + self.articleText = nil + if let article = selection, article.type == .article { + article.read = true + if let articleFlavor = article.articleFlavors?.first, + let articleID = article.articleID { + Task { + if let articleText = await self.model.getNewsArticle(id: articleID, at: article.path, flavor: articleFlavor) { + self.articleText = articleText + } + } + if self.splitHidden.side != nil { + withAnimation(.easeOut(duration: 0.15)) { + self.splitHidden.side = nil + } + } + + } + } + else { + if self.splitHidden.side != .bottom { + withAnimation(.easeOut(duration: 0.25)) { + self.splitHidden.side = .bottom + } + } + } + } + .onKeyPress(.rightArrow) { + if let s = selection, s.expandable { + s.expanded = true + return .handled + } + return .ignored + } + .onKeyPress(.leftArrow) { + if let s = selection, s.expandable { + s.expanded = false + return .handled + } + return .ignored + } + } + + var loadingIndicator: some View { + VStack { + HStack { + ProgressView { + Text("Loading Newsgroups") + } + .controlSize(.regular) + } + } + .frame(maxWidth: .infinity) + } + + var articleViewer: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + if let selection = selection, selection.type == .article { + if let poster = selection.articleUsername, let postDate = selection.articleDate { + HStack(alignment: .firstTextBaseline) { + Text(poster) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .textSelection(.enabled) + .padding(.bottom, 16) + Spacer() + Text("\(NewsItemView.dateFormatter.string(from: postDate))") + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .textSelection(.enabled) + .padding(.bottom, 16) + } + } + + Divider() + + Text(selection.name).font(.title) + .textSelection(.enabled) + .padding(.bottom, 8) + .padding(.top, 16) + + if let newsText = self.articleText { + Markdown(newsText) + .markdownTheme(.basic) + .textSelection(.enabled) + .lineSpacing(6) + .padding(.top, 16) + } + } + else { + HStack(alignment: .center) { + Spacer() + HStack(alignment: .center, spacing: 8) { +// Image(systemName: "doc.append") +// .resizable() +// .scaledToFit() +// .foregroundStyle(.tertiary) +// .frame(width: 16, height: 16) + Text("Select a news post to read") + .foregroundStyle(.tertiary) + .font(.system(size: 13)) + } + Spacer() + } + .padding() + .padding(.top, 48) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .padding() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .transition(.move(edge: .bottom)) + } +} + +#Preview { + NewsView() + .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) +} diff --git a/Hotline/macOS/NewsEditorView.swift b/Hotline/macOS/NewsEditorView.swift deleted file mode 100644 index f69c846..0000000 --- a/Hotline/macOS/NewsEditorView.swift +++ /dev/null @@ -1,153 +0,0 @@ -import SwiftUI - -private enum FocusFields { - case title - case body -} - -struct NewsEditorView: View { - @Environment(\.controlActiveState) private var controlActiveState - @Environment(\.colorScheme) private var colorScheme - @Environment(\.dismiss) private var dismiss - @Environment(Hotline.self) private var model: Hotline - - let editorTitle: String - let isReply: Bool - let path: [String] - let parentID: UInt32 - - @State var title: String = "" - @State private var text: String = "" - @State private var sending: Bool = false - - @FocusState private var focusedField: FocusFields? - - func sendArticle() async -> Bool { - sending = true - - let success = await model.postNewsArticle(title: title, body: text, at: path, parentID: parentID) - if success { - await model.getNewsList(at: path) - } - - sending = false - - return success - } - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - HStack(alignment: .center, spacing: 0) { - Button { - dismiss() - } label: { - Image(systemName: "xmark") - .resizable() - .scaledToFit() - .frame(width: 14, height: 14) - .opacity(0.5) - } - .buttonStyle(.plain) - .frame(width: 16, height: 16) - - Spacer() - - if !isReply { - Image("News Category") - .resizable() - .scaledToFit() - .frame(width: 16, height: 16) - .padding(.trailing, 6) - } - - Text(editorTitle) - .fontWeight(.semibold) - .lineLimit(1) - .truncationMode(.middle) - - Spacer() - - if sending { - ProgressView() - .controlSize(.small) - .frame(width: 22, height: 22) - } - else { - Button { - Task { - if await sendArticle() { - dismiss() - } - } - } label: { - Image(systemName: "arrow.up.circle.fill") - .resizable() - .renderingMode(.template) - .scaledToFit() - .foregroundColor((title.isEmpty || text.isEmpty) ? .secondary : .accentColor) - } - .buttonStyle(.plain) - .frame(width: 22, height: 22) - .help("Post News") - .disabled(title.isEmpty || text.isEmpty) - } - } - .frame(maxWidth: .infinity) - .padding([.leading, .top, .trailing]) - - TextField("Title", text: $title, axis: .vertical) - .textFieldStyle(.plain) - .lineLimit(3) - .padding() - .focusEffectDisabled() - .fontWeight(.semibold) - .frame(maxWidth: .infinity) - .border(Color.pink, width: 0) - .background(.tertiary.opacity(0.2)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .padding() - .focused($focusedField, equals: .title) - - Divider() - - BetterTextEditor(text: $text) - .betterEditorFont(NSFont.monospacedSystemFont(ofSize: 14.0, weight: .regular)) - .betterEditorAutomaticSpellingCorrection(true) - .betterEditorTextInset(.init(width: 16, height: 18)) - .background(Color(nsColor: .textBackgroundColor)) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .focused($focusedField, equals: .body) - - HStack(alignment: .center) { - Spacer() - - Text(String("**bold** _italics_ [link name](url) ![image name](url)")) - .foregroundStyle(.secondary) - .font(.caption) - .fontDesign(.monospaced) - .lineLimit(1) - .truncationMode(.middle) - .padding() - - Spacer() - } - .frame(maxWidth: .infinity) - .background(.tertiary.opacity(0.15)) - } - .frame(minWidth: 300, idealWidth: 450, maxWidth: .infinity, minHeight: 300, idealHeight: 500, maxHeight: .infinity) - .background(Color(nsColor: .textBackgroundColor)) - .presentationCompactAdaptation(.sheet) - .toolbarTitleDisplayMode(.inlineLarge) - .onAppear { - if !title.isEmpty { - focusedField = .body - } - else { - focusedField = .title - } - } - .onDisappear { - dismiss() - } - } -} diff --git a/Hotline/macOS/NewsItemView.swift b/Hotline/macOS/NewsItemView.swift deleted file mode 100644 index fc20e61..0000000 --- a/Hotline/macOS/NewsItemView.swift +++ /dev/null @@ -1,152 +0,0 @@ -import SwiftUI - -struct NewsItemView: View { - @Environment(Hotline.self) private var model: Hotline - - var news: NewsInfo - let depth: Int - - static var dateFormatter: DateFormatter = { - var dateFormatter = DateFormatter() - dateFormatter.dateStyle = .long - dateFormatter.timeStyle = .short - dateFormatter.timeZone = .gmt - return dateFormatter - }() - - static var relativeDateFormatter: RelativeDateTimeFormatter = { - var formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .full - formatter.dateTimeStyle = .named - formatter.formattingContext = .listItem - return formatter - }() - - var body: some View { - HStack(alignment: .center, spacing: 0) { - if news.expandable { - Button { - news.expanded.toggle() - } label: { - Text(Image(systemName: news.expanded ? "chevron.down" : "chevron.right")) - .bold() - .font(.system(size: 10)) - .opacity(0.5) - .frame(alignment: .center) - } - .buttonStyle(.plain) - .frame(width: 10) - .padding(.leading, 4) - .padding(.trailing, 8) - } - else { - Spacer() - .frame(width: 10) - .padding(.leading, 4) - .padding(.trailing, 8) - } - - // Tree indent - Spacer() - .frame(width: (CGFloat(depth) * 22)) - - switch news.type { - case .category: - Image("News Category") - .resizable() - .frame(width: 16, height: 16, alignment: .center) - .padding(.trailing, 6) - case .bundle: - Image("News Bundle") - .resizable() - .frame(width: 16, height: 16, alignment: .center) - .padding(.trailing, 6) - case .article: - EmptyView() - } - - Text(news.name) - .fontWeight((news.type == .bundle || news.type == .category || !news.read) ? .semibold : .regular) - .lineLimit(1) - .truncationMode(.tail) - - if news.type == .article && news.articleUsername != nil { - Text(news.articleUsername!) - .foregroundStyle(.secondary) - .lineLimit(1) - .padding(.leading, 8) - } - - Spacer() - - if news.type == .category && news.count > 0 { - Text("^[\(news.count) Post](inflect: true)") - .lineLimit(1) - .foregroundStyle(.secondary) - .padding(.trailing, 8) - } - else if news.type == .bundle && news.count > 0 { - Text("^[\(news.count) Category](inflect: true)") - .lineLimit(1) - .foregroundStyle(.secondary) - .padding(.trailing, 8) - } -// if news.type == .bundle { -// Text("\(news.count)") -// .lineLimit(1) -// .foregroundStyle(.tertiary) -// .padding(.trailing, 8) - -// ZStack { -// Text("^[\(news.count) \(news.type == .bundle ? "Category" : "Post")](inflect: true)") -// Text("\(news.count)") -// .foregroundStyle(.clear) -//// .font(.caption) -// .lineLimit(1) -// .padding([.leading, .trailing], 8) -// .padding([.top, .bottom], 2) -// .background(.secondary) -// .clipShape(Capsule()) -// -// Text("\(news.count)") -// .foregroundStyle(.white) -//// .font(.caption) -// .lineLimit(1) -// .padding([.leading, .trailing], 8) -// .padding([.top, .bottom], 2) -// .blendMode(.destinationOut) -// } -// .drawingGroup(opaque: false) -// } - else if news.type == .article && news.articleUsername != nil { - if let d = news.articleDate { - Text(NewsItemView.relativeDateFormatter.localizedString(for: d, relativeTo: Date.now)) - .lineLimit(1) - .foregroundStyle(.secondary) - .padding(.trailing, 8) - } - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .onChange(of: news.expanded) { - guard news.expanded, news.type == .bundle || news.type == .category else { - return - } - - Task { - await model.getNewsList(at: news.path) - } - } - - if news.expanded { - ForEach(news.children.reversed(), id: \.self) { childNews in - NewsItemView(news: childNews, depth: self.depth + 1).tag(childNews.id) - } - } - } -} - -#Preview { - NewsItemView(news: NewsInfo(hotlineNewsArticle: HotlineNewsArticle(id: 0, parentID: 0, flags: 0, title: "Title", username: "username", date: Date.now, flavors: [("", 1)], path: ["Guest"])), depth: 0) - .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) -} diff --git a/Hotline/macOS/NewsView.swift b/Hotline/macOS/NewsView.swift deleted file mode 100644 index 91bf2fe..0000000 --- a/Hotline/macOS/NewsView.swift +++ /dev/null @@ -1,297 +0,0 @@ -import SwiftUI -import MarkdownUI -import SplitView - -struct NewsView: View { - @Environment(Hotline.self) private var model: Hotline - @Environment(\.openWindow) private var openWindow - @Environment(\.colorScheme) private var colorScheme - - @State private var selection: NewsInfo? - @State private var articleText: String? - @State private var splitHidden = SideHolder(.bottom) - @State private var splitFraction = FractionHolder.usingUserDefaults(0.25, key: "News Split Fraction") - @State private var editorOpen: Bool = false - @State private var replyOpen: Bool = false - @State private var loading: Bool = false - - var body: some View { - Group { - if model.serverVersion < 151 { - VStack { - Text("No News") - .bold() - .foregroundStyle(.secondary) - .font(.title3) - Text("This server has news turned off.") - .foregroundStyle(.tertiary) - .font(.system(size: 13)) - } - .padding() - } - else { - NavigationStack { - VSplit( - top: { - if !model.newsLoaded { - loadingIndicator - } - else if model.news.isEmpty { - ZStack(alignment: .center) { - Text("No News") - .font(.title) - .multilineTextAlignment(.center) - .foregroundStyle(.secondary) - .padding() - } - .frame(maxWidth: .infinity) - } - else { - newsBrowser - } - }, - bottom: { - articleViewer - } - ) - .fraction(splitFraction) - .constraints(minPFraction: 0.1, minSFraction: 0.3) - .hide(splitHidden) - .styling(color: colorScheme == .dark ? .black : Splitter.defaultColor, inset: 0, visibleThickness: 0.5, invisibleThickness: 5, hideSplitter: true) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(nsColor: .textBackgroundColor)) - } - .task { - if !model.newsLoaded { - loading = true - await model.getNewsList() - loading = false - } - } - } - } - .sheet(isPresented: $editorOpen) { - } content: { - if let selection = selection { - switch selection.type { - case .article, .category: - NewsEditorView(editorTitle: selection.path.last ?? "New Post", isReply: false, path: selection.path, parentID: 0) - default: - EmptyView() - } - } - else { - EmptyView() - } - } - .sheet(isPresented: $replyOpen) { - } content: { - if let selection = selection, selection.type == .article { - NewsEditorView(editorTitle: "Reply to \(selection.articleUsername ?? "Post")", isReply: true, path: selection.path, parentID: UInt32(selection.articleID!), title: selection.name.replyToString()) - } - else { - EmptyView() - } - } - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button { - if selection?.type == .category || selection?.type == .article { - editorOpen = true - } - } label: { - Image(systemName: "square.and.pencil") - } - .help("New Post") - .disabled(selection?.type != .category && selection?.type != .article) - } - - ToolbarItem(placement: .primaryAction) { - Button { - if selection?.type == .article { - replyOpen = true - } - } label: { - Image(systemName: "arrowshape.turn.up.left") - } - .help("Reply to Post") - .disabled(selection?.type != .article) - } - - ToolbarItem(placement: .primaryAction) { - Button { - loading = true - if let selectionPath = selection?.path { - Task { - await model.getNewsList(at: selectionPath) - loading = false - } - } - else { - Task { - await model.getNewsList() - loading = false - } - } - } label: { - Image(systemName: "arrow.clockwise") - } - .help("Reload News") - .disabled(loading) - } - } - } - - var newsBrowser: some View { - List(model.news, id: \.self, selection: $selection) { newsItem in - NewsItemView(news: newsItem, depth: 0).tag(newsItem.id) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .environment(\.defaultMinListRowHeight, 28) - .listStyle(.inset) - .alternatingRowBackgrounds(.enabled) - .contextMenu(forSelectionType: NewsInfo.self) { items in - let selectedItem = items.first - - Button { - if selectedItem?.type == .article { - replyOpen = true - } - } label: { - Label("Reply to \(selectedItem?.articleUsername ?? "Post")", systemImage: "arrowshape.turn.up.left") - } - .disabled(selectedItem == nil || selectedItem?.type != .article) - - } primaryAction: { items in - guard let clickedNews = items.first else { - return - } - - self.selection = clickedNews - if clickedNews.type == .bundle || clickedNews.type == .category || clickedNews.children.count > 0 { - clickedNews.expanded.toggle() - } - } - .onChange(of: selection) { - self.articleText = nil - if let article = selection, article.type == .article { - article.read = true - if let articleFlavor = article.articleFlavors?.first, - let articleID = article.articleID { - Task { - if let articleText = await self.model.getNewsArticle(id: articleID, at: article.path, flavor: articleFlavor) { - self.articleText = articleText - } - } - if self.splitHidden.side != nil { - withAnimation(.easeOut(duration: 0.15)) { - self.splitHidden.side = nil - } - } - - } - } - else { - if self.splitHidden.side != .bottom { - withAnimation(.easeOut(duration: 0.25)) { - self.splitHidden.side = .bottom - } - } - } - } - .onKeyPress(.rightArrow) { - if let s = selection, s.expandable { - s.expanded = true - return .handled - } - return .ignored - } - .onKeyPress(.leftArrow) { - if let s = selection, s.expandable { - s.expanded = false - return .handled - } - return .ignored - } - } - - var loadingIndicator: some View { - VStack { - HStack { - ProgressView { - Text("Loading Newsgroups") - } - .controlSize(.regular) - } - } - .frame(maxWidth: .infinity) - } - - var articleViewer: some View { - ScrollView { - VStack(alignment: .leading, spacing: 0) { - if let selection = selection, selection.type == .article { - if let poster = selection.articleUsername, let postDate = selection.articleDate { - HStack(alignment: .firstTextBaseline) { - Text(poster) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) - .textSelection(.enabled) - .padding(.bottom, 16) - Spacer() - Text("\(NewsItemView.dateFormatter.string(from: postDate))") - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) - .textSelection(.enabled) - .padding(.bottom, 16) - } - } - - Divider() - - Text(selection.name).font(.title) - .textSelection(.enabled) - .padding(.bottom, 8) - .padding(.top, 16) - - if let newsText = self.articleText { - Markdown(newsText) - .markdownTheme(.basic) - .textSelection(.enabled) - .lineSpacing(6) - .padding(.top, 16) - } - } - else { - HStack(alignment: .center) { - Spacer() - HStack(alignment: .center, spacing: 8) { -// Image(systemName: "doc.append") -// .resizable() -// .scaledToFit() -// .foregroundStyle(.tertiary) -// .frame(width: 16, height: 16) - Text("Select a news post to read") - .foregroundStyle(.tertiary) - .font(.system(size: 13)) - } - Spacer() - } - .padding() - .padding(.top, 48) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) - .padding() - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .transition(.move(edge: .bottom)) - } -} - -#Preview { - NewsView() - .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) -} diff --git a/Hotline/macOS/ServerAgreementView.swift b/Hotline/macOS/ServerAgreementView.swift deleted file mode 100644 index e72de7d..0000000 --- a/Hotline/macOS/ServerAgreementView.swift +++ /dev/null @@ -1,78 +0,0 @@ -import SwiftUI - -fileprivate let MAX_AGREEMENT_HEIGHT: CGFloat = 340 - -struct ServerAgreementView: View { - let text: String - - @State private var expandable: Bool = false - @State private var expanded: Bool = false - - var body: some View { - ScrollView(.vertical) { - HStack(alignment: .top) { - Spacer() - Text(text.convertToAttributedStringWithLinks()) - .font(.system(size: 12)) - .fontDesign(.monospaced) - .textSelection(.enabled) - .tint(Color("Link Color")) - .frame(maxWidth: 400) - .padding(16) - .background( - GeometryReader { geometry in - Color.clear.onAppear { - if geometry.size.height > MAX_AGREEMENT_HEIGHT { - expandable = true - } - else { - expandable = false - } - } - } - ) - Spacer() - } - } - .scrollIndicators(.never) - .frame(maxWidth: .infinity, maxHeight: (expandable && expanded) ? nil : MAX_AGREEMENT_HEIGHT) - .scrollBounceBehavior(.basedOnSize) -#if os(iOS) - .background(Color("Agreement Background")) -#elseif os(macOS) - .background(VisualEffectView(material: .titlebar, blendingMode: .withinWindow)) -#endif - .overlay(alignment: .bottomTrailing) { - ZStack(alignment: .bottomTrailing) { - Group { - if !expandable || expanded { - EmptyView() - } - else { - Button(action: { - withAnimation(.easeOut(duration: 0.15)) { - expanded = true - } - }, label: { - Image(systemName: "arrow.up.and.down.circle.fill") - .resizable() - .scaledToFit() - .fontWeight(.semibold) - .frame(width: 16, height: 16) - .foregroundColor(.primary.opacity(0.5)) - }) - .buttonStyle(.plain) - .buttonBorderShape(.circle) - .help("Expand Server Agreement") - .padding([.trailing, .bottom], 16) - } - } - } - } - .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) - } -} - -#Preview { - ServerAgreementView(text: "Hello there and welcome to this server.") -} diff --git a/Hotline/macOS/ServerMessageView.swift b/Hotline/macOS/ServerMessageView.swift deleted file mode 100644 index 6c3c60e..0000000 --- a/Hotline/macOS/ServerMessageView.swift +++ /dev/null @@ -1,33 +0,0 @@ -import SwiftUI - -struct ServerMessageView: View { - let message: String - - var body: some View { - HStack(alignment: .center, spacing: 8) { - Image("Server Message") - .symbolRenderingMode(.multicolor) - .resizable() - .scaledToFit() - .frame(width: 20, height: 20) - Text(message) - .fontWeight(.semibold) - .lineSpacing(4) - .multilineTextAlignment(.leading) - .textSelection(.enabled) - Spacer() - } - .padding() - .frame(maxWidth: .infinity) -#if os(iOS) - .background(Color("Agreement Background")) -#elseif os(macOS) - .background(VisualEffectView(material: .titlebar, blendingMode: .withinWindow)) -#endif - .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) - } -} - -#Preview { - ServerMessageView(message: "This server has something important to say.") -} diff --git a/Hotline/macOS/Settings/GeneralSettingsView.swift b/Hotline/macOS/Settings/GeneralSettingsView.swift new file mode 100644 index 0000000..6be73aa --- /dev/null +++ b/Hotline/macOS/Settings/GeneralSettingsView.swift @@ -0,0 +1,67 @@ +import SwiftUI + +struct GeneralSettingsView: View { + @State private var username: String = "" + @State private var usernameChanged: Bool = false + @State private var showClearHistoryConfirmation: Bool = false + + let saveTimer = Timer.publish(every: 5, on: .main, in: .common).autoconnect() + + var body: some View { + @Bindable var preferences = Prefs.shared + + Form { + TextField("Your Name", text: $username, prompt: Text("guest")) + Toggle("Show Join/Leave in Chat", isOn: $preferences.showJoinLeaveMessages) + Toggle("Refuse private messages", isOn: $preferences.refusePrivateMessages) + Toggle("Refuse private chat", isOn: $preferences.refusePrivateChat) + Toggle("Automatic Response", isOn: $preferences.enableAutomaticMessage) + if preferences.enableAutomaticMessage { + TextField("", text: $preferences.automaticMessage, prompt: Text("Write a response message")) + .lineLimit(2) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity) + .onSubmit(of: .text) { + preferences.username = self.username + } + } + + Divider() + + Button(role: .destructive) { + showClearHistoryConfirmation = true + } label: { + Text("Clear Chat History…") + } + } + .padding() + .frame(width: 392) + .confirmationDialog("Clear chat history?", isPresented: $showClearHistoryConfirmation, titleVisibility: .visible) { + Button("Clear Chat History", role: .destructive) { + Task { + await ChatStore.shared.clearAll() + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This removes all saved chat logs across servers. Active chats will repopulate only with new messages.") + } + .onAppear { + self.username = preferences.username + self.usernameChanged = false + } + .onDisappear { + preferences.username = self.username + self.usernameChanged = false + } + .onChange(of: username) { oldValue, newValue in + self.usernameChanged = true + } + .onReceive(saveTimer) { _ in + if self.usernameChanged { + self.usernameChanged = false + preferences.username = self.username + } + } + } +} diff --git a/Hotline/macOS/Settings/IconSettingsView.swift b/Hotline/macOS/Settings/IconSettingsView.swift new file mode 100644 index 0000000..98cbb09 --- /dev/null +++ b/Hotline/macOS/Settings/IconSettingsView.swift @@ -0,0 +1,58 @@ +import SwiftUI + +struct IconSettingsView: View { + @State private var hoveredUserIconID: Int = -1 + + var body: some View { + @Bindable var preferences = Prefs.shared + + Form { + ScrollViewReader { scrollProxy in + ScrollView { + LazyVGrid(columns: [ + GridItem(.fixed(4+32+4)), + GridItem(.fixed(4+32+4)), + GridItem(.fixed(4+32+4)), + GridItem(.fixed(4+32+4)), + GridItem(.fixed(4+32+4)), + GridItem(.fixed(4+32+4)), + GridItem(.fixed(4+32+4)) + ], spacing: 0) { + ForEach(Hotline.classicIconSet, id: \.self) { iconID in + HStack { + Image("Classic/\(iconID)") + .resizable() + .interpolation(.none) + .scaledToFit() + .frame(width: 32, height: 16) + .help("Icon \(String(iconID))") + } + .tag(iconID) + .frame(width: 32, height: 32) + .padding(4) + .background(iconID == preferences.userIconID ? Color.accentColor : (iconID == hoveredUserIconID ? Color.accentColor.opacity(0.1) : Color(nsColor: .textBackgroundColor))) + .clipShape(RoundedRectangle(cornerRadius: 5)) + .onTapGesture { + preferences.userIconID = iconID + } + .onHover { hovered in + if hovered { + self.hoveredUserIconID = iconID + } + } + } + } + .padding() + } + .background(Color(nsColor: .textBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color(nsColor: .separatorColor), lineWidth: 1)) + .onAppear { + scrollProxy.scrollTo(preferences.userIconID, anchor: .center) + } + .frame(height: 355) + } + } + .padding() + } +} diff --git a/Hotline/macOS/Settings/SettingsView.swift b/Hotline/macOS/Settings/SettingsView.swift new file mode 100644 index 0000000..b2a2948 --- /dev/null +++ b/Hotline/macOS/Settings/SettingsView.swift @@ -0,0 +1,31 @@ +import SwiftUI + +struct SettingsView: View { + private enum Tabs: Hashable { + case general, icon + } + + var body: some View { + TabView { + GeneralSettingsView() + .tabItem { + Label("General", systemImage: "person.text.rectangle") + } + .tag(Tabs.general) + IconSettingsView() + .tabItem { + Label("Icon", systemImage: "person") + } + .tag(Tabs.icon) + SoundSettingsView() + .tabItem { + Label("Sound", systemImage: "speaker.wave.3") + } + .tag(Tabs.icon) + } + } +} + +#Preview { + SettingsView() +} diff --git a/Hotline/macOS/Settings/SoundSettingsView.swift b/Hotline/macOS/Settings/SoundSettingsView.swift new file mode 100644 index 0000000..b99db77 --- /dev/null +++ b/Hotline/macOS/Settings/SoundSettingsView.swift @@ -0,0 +1,40 @@ +import SwiftUI + +struct SoundSettingsView: View { + var body: some View { + @Bindable var preferences = Prefs.shared + + Form { + Toggle("Enable Sounds", isOn: $preferences.playSounds) + .controlSize(.large) + + Section("Sounds") { + Toggle("Chat", isOn: $preferences.playChatSound) + .disabled(!preferences.playSounds) + + Toggle("File Transfers", isOn: $preferences.playFileTransferCompleteSound) + .disabled(!preferences.playSounds) + + Toggle("Private Message", isOn: $preferences.playPrivateMessageSound) + .disabled(!preferences.playSounds) + + Toggle("Join", isOn: $preferences.playJoinSound) + .disabled(!preferences.playSounds) + + Toggle("Leave", isOn: $preferences.playLeaveSound) + .disabled(!preferences.playSounds) + + Toggle("Logged in", isOn: $preferences.playLoggedInSound) + .disabled(!preferences.playSounds) + + Toggle("Error", isOn: $preferences.playErrorSound) + .disabled(!preferences.playSounds) + + Toggle("Chat Invitation", isOn: $preferences.playChatInvitationSound) + .disabled(!preferences.playSounds) + } + } + .formStyle(.grouped) + .frame(width: 392, height: 433) + } +} diff --git a/Hotline/macOS/SettingsView.swift b/Hotline/macOS/SettingsView.swift deleted file mode 100644 index d301abd..0000000 --- a/Hotline/macOS/SettingsView.swift +++ /dev/null @@ -1,193 +0,0 @@ -import SwiftUI - -struct GeneralSettingsView: View { - @State private var username: String = "" - @State private var usernameChanged: Bool = false - @State private var showClearHistoryConfirmation: Bool = false - - let saveTimer = Timer.publish(every: 5, on: .main, in: .common).autoconnect() - - var body: some View { - @Bindable var preferences = Prefs.shared - - Form { - TextField("Your Name", text: $username, prompt: Text("guest")) - Toggle("Show Join/Leave in Chat", isOn: $preferences.showJoinLeaveMessages) - Toggle("Refuse private messages", isOn: $preferences.refusePrivateMessages) - Toggle("Refuse private chat", isOn: $preferences.refusePrivateChat) - Toggle("Automatic Response", isOn: $preferences.enableAutomaticMessage) - if preferences.enableAutomaticMessage { - TextField("", text: $preferences.automaticMessage, prompt: Text("Write a response message")) - .lineLimit(2) - .multilineTextAlignment(.leading) - .frame(maxWidth: .infinity) - .onSubmit(of: .text) { - preferences.username = self.username - } - } - - Divider() - - Button(role: .destructive) { - showClearHistoryConfirmation = true - } label: { - Text("Clear Chat History…") - } - } - .padding() - .frame(width: 392) - .confirmationDialog("Clear chat history?", isPresented: $showClearHistoryConfirmation, titleVisibility: .visible) { - Button("Clear Chat History", role: .destructive) { - Task { - await ChatStore.shared.clearAll() - } - } - Button("Cancel", role: .cancel) {} - } message: { - Text("This removes all saved chat logs across servers. Active chats will repopulate only with new messages.") - } - .onAppear { - self.username = preferences.username - self.usernameChanged = false - } - .onDisappear { - preferences.username = self.username - self.usernameChanged = false - } - .onChange(of: username) { oldValue, newValue in - self.usernameChanged = true - } - .onReceive(saveTimer) { _ in - if self.usernameChanged { - self.usernameChanged = false - preferences.username = self.username - } - } - } -} - -struct IconSettingsView: View { - @State private var hoveredUserIconID: Int = -1 - - var body: some View { - @Bindable var preferences = Prefs.shared - - Form { - ScrollViewReader { scrollProxy in - ScrollView { - LazyVGrid(columns: [ - GridItem(.fixed(4+32+4)), - GridItem(.fixed(4+32+4)), - GridItem(.fixed(4+32+4)), - GridItem(.fixed(4+32+4)), - GridItem(.fixed(4+32+4)), - GridItem(.fixed(4+32+4)), - GridItem(.fixed(4+32+4)) - ], spacing: 0) { - ForEach(Hotline.classicIconSet, id: \.self) { iconID in - HStack { - Image("Classic/\(iconID)") - .resizable() - .interpolation(.none) - .scaledToFit() - .frame(width: 32, height: 16) - .help("Icon \(String(iconID))") - } - .tag(iconID) - .frame(width: 32, height: 32) - .padding(4) - .background(iconID == preferences.userIconID ? Color.accentColor : (iconID == hoveredUserIconID ? Color.accentColor.opacity(0.1) : Color(nsColor: .textBackgroundColor))) - .clipShape(RoundedRectangle(cornerRadius: 5)) - .onTapGesture { - preferences.userIconID = iconID - } - .onHover { hovered in - if hovered { - self.hoveredUserIconID = iconID - } - } - } - } - .padding() - } - .background(Color(nsColor: .textBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 6)) - .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color(nsColor: .separatorColor), lineWidth: 1)) - .onAppear { - scrollProxy.scrollTo(preferences.userIconID, anchor: .center) - } - .frame(height: 355) - } - } - .padding() - } -} - -struct SoundSettingsView: View { - var body: some View { - @Bindable var preferences = Prefs.shared - - Form { - Toggle("Enable Sounds", isOn: $preferences.playSounds) - .controlSize(.large) - - Section("Sounds") { - Toggle("Chat", isOn: $preferences.playChatSound) - .disabled(!preferences.playSounds) - - Toggle("File Transfers", isOn: $preferences.playFileTransferCompleteSound) - .disabled(!preferences.playSounds) - - Toggle("Private Message", isOn: $preferences.playPrivateMessageSound) - .disabled(!preferences.playSounds) - - Toggle("Join", isOn: $preferences.playJoinSound) - .disabled(!preferences.playSounds) - - Toggle("Leave", isOn: $preferences.playLeaveSound) - .disabled(!preferences.playSounds) - - Toggle("Logged in", isOn: $preferences.playLoggedInSound) - .disabled(!preferences.playSounds) - - Toggle("Error", isOn: $preferences.playErrorSound) - .disabled(!preferences.playSounds) - - Toggle("Chat Invitation", isOn: $preferences.playChatInvitationSound) - .disabled(!preferences.playSounds) - } - } - .formStyle(.grouped) - .frame(width: 392, height: 433) - } -} - -struct SettingsView: View { - private enum Tabs: Hashable { - case general, icon - } - - var body: some View { - TabView { - GeneralSettingsView() - .tabItem { - Label("General", systemImage: "person.text.rectangle") - } - .tag(Tabs.general) - IconSettingsView() - .tabItem { - Label("Icon", systemImage: "person") - } - .tag(Tabs.icon) - SoundSettingsView() - .tabItem { - Label("Sound", systemImage: "speaker.wave.3") - } - .tag(Tabs.icon) - } - } -} - -#Preview { - SettingsView() -} diff --git a/Hotline/macOS/TrackerView.swift b/Hotline/macOS/TrackerView.swift index 251248f..34f7a57 100644 --- a/Hotline/macOS/TrackerView.swift +++ b/Hotline/macOS/TrackerView.swift @@ -105,6 +105,7 @@ struct TrackerView: View { .moveDisabled(true) .deleteDisabled(true) .tag(TrackerSelection.bookmarkServer(trackedServer)) + .padding(.leading, 16 + 8 + 10) } } } @@ -180,18 +181,6 @@ struct TrackerView: View { case .bookmarkServer(let bookmarkServer): openWindow(id: "server", value: bookmarkServer.server) } - -// if clickedItem.type == .tracker { -// if NSEvent.modifierFlags.contains(.option) { -// trackerSheetBookmark = clickedItem -// } -// else { -// clickedItem.expanded.toggle() -// } -// } -// else if let server = clickedItem.server { -// openWindow(id: "server", value: server) -// } } .fileExporter(isPresented: $bookmarkExportActive, document: bookmarkExport, contentTypes: [.data], defaultFilename: "\(bookmarkExport?.bookmark.name ?? "Hotline Bookmark").hlbm", onCompletion: { result in switch result { @@ -208,38 +197,26 @@ struct TrackerView: View { switch self.selection { case .bookmark(let bookmark): if bookmark.type == .tracker { - self.expandedTrackers.insert(bookmark) + self.setExpanded(true, for: bookmark) return .handled } default: break } -// if -// let bookmark = selection, -// bookmark.type == .tracker { -// bookmark.expanded = true -// return .handled -// } return .ignored } .onKeyPress(.leftArrow) { switch self.selection { case .bookmark(let bookmark): if bookmark.type == .tracker { - self.expandedTrackers.remove(bookmark) + self.setExpanded(false, for: bookmark) return .handled } default: break } -// if -// let bookmark = selection, -// bookmark.type == .tracker { -// bookmark.expanded = false -// return .handled -// } return .ignored } .onDrop(of: [UTType.fileURL], isTargeted: $fileDropActive) { providers, dropPoint in @@ -354,8 +331,7 @@ struct TrackerView: View { Button { NSPasteboard.general.clearContents() - let displayAddress = server.port == HotlinePorts.DefaultServerPort ? - server.address : "\(server.address):\(server.port)" + 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") @@ -451,22 +427,7 @@ struct TrackerView: View { func toggleExpanded(for bookmark: Bookmark) { guard bookmark.type == .tracker else { return } - - if self.expandedTrackers.contains(bookmark) { - // Collapse: cancel ongoing fetch and clear data - self.fetchTasks[bookmark]?.cancel() - self.fetchTasks[bookmark] = nil - self.expandedTrackers.remove(bookmark) - self.trackerServers[bookmark] = nil - self.loadingTrackers.remove(bookmark) - } else { - // Expand: start fetch task - self.expandedTrackers.insert(bookmark) - let task = Task { - await self.fetchServers(for: bookmark) - } - self.fetchTasks[bookmark] = task - } + self.setExpanded(!self.expandedTrackers.contains(bookmark), for: bookmark) } func setExpanded(_ expanded: Bool, for bookmark: Bookmark) { @@ -678,8 +639,6 @@ struct TrackerBookmarkServerView: View { var body: some View { HStack(alignment: .center, spacing: 6) { - Spacer() - .frame(width: 14 + 8 + 16) Image("Server") .resizable() .scaledToFit() @@ -720,6 +679,7 @@ struct TrackerItemView: View { let isLoading: Bool let count: Int let onToggleExpanded: () -> Void + @Environment(\.appearsActive) private var appearsActive var body: some View { HStack(alignment: .center, spacing: 6) { @@ -759,22 +719,20 @@ struct TrackerItemView: View { SpinningGlobeView() .fontWeight(.semibold) .frame(width: 12, height: 12) -// .opacity(0.5) } - - .padding(.horizontal, 8) + .padding(.horizontal, 6) .padding(.vertical, 2) .foregroundStyle(.secondary) - .background(.quinary) +// .background(.quinary) .clipShape(.capsule) } case .server: Image(systemName: "bookmark.fill") .resizable() - .renderingMode(.template) - .aspectRatio(contentMode: .fit) + .scaledToFit() + .foregroundStyle(Color.secondary) .frame(width: 11, height: 11, alignment: .center) - .opacity(0.5) + .opacity(0.75) .padding(.leading, 3) .padding(.trailing, 2) Image("Server") -- cgit From d8a10c38a3c26a187d8f310eba56d4e9ca8c5b27 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Mon, 27 Oct 2025 21:51:31 -0700 Subject: Fix preview window not being centered every time. --- Hotline/MacApp.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Hotline/MacApp.swift b/Hotline/MacApp.swift index 5d1b912..dd7e190 100644 --- a/Hotline/MacApp.swift +++ b/Hotline/MacApp.swift @@ -307,6 +307,7 @@ struct Application: App { .windowToolbarStyle(.unifiedCompact(showsTitle: true)) .defaultSize(width: 350, height: 150) .defaultPosition(.center) + .restorationBehavior(.disabled) // MARK: Text Preview Window WindowGroup(id: "preview-text", for: PreviewFileInfo.self) { $info in @@ -317,6 +318,7 @@ struct Application: App { .windowToolbarStyle(.unifiedCompact(showsTitle: true)) .defaultSize(width: 450, height: 550) .defaultPosition(.center) + .restorationBehavior(.disabled) } func connect(to item: TrackerSelection) { -- cgit From d23d0ad9405c1622569415e6ce16d3768af2936a Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Tue, 28 Oct 2025 09:33:19 -0700 Subject: New default banner artwork. Theme toolbar to server's banner (if any). --- Hotline.xcodeproj/project.pbxproj | 4 + .../Default Banner.imageset/Contents.json | 6 +- .../Default Banner.imageset/Default Banner.png | Bin 0 -> 54979 bytes .../Default Banner.imageset/Default Banner@2x.png | Bin 0 -> 218553 bytes .../Default Banner.imageset/Default Banner@3x.png | Bin 0 -> 474621 bytes .../Default Banner.imageset/Frame 2.png | Bin 59733 -> 0 bytes .../Default Banner.imageset/Frame 2@2x.png | Bin 207508 -> 0 bytes .../Default Banner.imageset/Frame 2@3x.png | Bin 386312 -> 0 bytes Hotline/Utility/ColorArt.swift | 376 +++++++++++++++++++++ Hotline/macOS/Chat/ChatView.swift | 3 +- Hotline/macOS/HotlinePanelView.swift | 25 +- 11 files changed, 402 insertions(+), 12 deletions(-) create mode 100644 Hotline/Assets.xcassets/Default Banner.imageset/Default Banner.png create mode 100644 Hotline/Assets.xcassets/Default Banner.imageset/Default Banner@2x.png create mode 100644 Hotline/Assets.xcassets/Default Banner.imageset/Default Banner@3x.png delete mode 100644 Hotline/Assets.xcassets/Default Banner.imageset/Frame 2.png delete mode 100644 Hotline/Assets.xcassets/Default Banner.imageset/Frame 2@2x.png delete mode 100644 Hotline/Assets.xcassets/Default Banner.imageset/Frame 2@3x.png create mode 100644 Hotline/Utility/ColorArt.swift diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index a173d55..5c0b943 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -32,6 +32,7 @@ DA5268A02EB073BC00DCB941 /* SoundSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA52689F2EB073BC00DCB941 /* SoundSettingsView.swift */; platformFilters = (macos, ); }; DA5268A32EB0741B00DCB941 /* FolderItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268A22EB0741B00DCB941 /* FolderItemView.swift */; platformFilters = (macos, ); }; DA5268A52EB0743000DCB941 /* FileItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268A42EB0743000DCB941 /* FileItemView.swift */; platformFilters = (macos, ); }; + DA5268AB2EB11EA300DCB941 /* ColorArt.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268AA2EB11EA300DCB941 /* ColorArt.swift */; }; DA55AC732BE42AF000034857 /* AsyncLinkPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA55AC722BE42AF000034857 /* AsyncLinkPreview.swift */; platformFilters = (macos, ); }; DA55AC752BE4888300034857 /* InstantMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA55AC742BE4888300034857 /* InstantMessage.swift */; }; DA55AC772BE589F700034857 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA55AC762BE589F700034857 /* AboutView.swift */; platformFilters = (macos, ); }; @@ -128,6 +129,7 @@ DA52689F2EB073BC00DCB941 /* SoundSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundSettingsView.swift; sourceTree = ""; }; DA5268A22EB0741B00DCB941 /* FolderItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderItemView.swift; sourceTree = ""; }; DA5268A42EB0743000DCB941 /* FileItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileItemView.swift; sourceTree = ""; }; + DA5268AA2EB11EA300DCB941 /* ColorArt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorArt.swift; sourceTree = ""; }; DA55AC722BE42AF000034857 /* AsyncLinkPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncLinkPreview.swift; sourceTree = ""; }; DA55AC742BE4888300034857 /* InstantMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstantMessage.swift; sourceTree = ""; }; DA55AC762BE589F700034857 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; @@ -397,6 +399,7 @@ isa = PBXGroup; children = ( DABFCC282B1530DC009F40D2 /* FoundationExtensions.swift */, + DA5268AA2EB11EA300DCB941 /* ColorArt.swift */, DAC002182B21630900A6C290 /* SwiftUIExtensions.swift */, DA7725402B21435B006C5ABB /* ObservableScrollView.swift */, DAE735062B3251B3000C56F6 /* SoundEffects.swift */, @@ -554,6 +557,7 @@ DA65499C2BEC3FBD00EDB697 /* ServerAgreementView.swift in Sources */, DAB4D8802B4C8E9A0048A05C /* URLAdditions.swift in Sources */, DA2863DD2B3E8B7000A7D050 /* FilePreview.swift in Sources */, + DA5268AB2EB11EA300DCB941 /* ColorArt.swift in Sources */, DA2863D82B37AD1C00A7D050 /* SettingsView.swift in Sources */, DA7725412B21435B006C5ABB /* ObservableScrollView.swift in Sources */, DAE734FF2B2E6750000C56F6 /* ChatView.swift in Sources */, diff --git a/Hotline/Assets.xcassets/Default Banner.imageset/Contents.json b/Hotline/Assets.xcassets/Default Banner.imageset/Contents.json index 1abbabf..ba0af63 100644 --- a/Hotline/Assets.xcassets/Default Banner.imageset/Contents.json +++ b/Hotline/Assets.xcassets/Default Banner.imageset/Contents.json @@ -1,17 +1,17 @@ { "images" : [ { - "filename" : "Frame 2.png", + "filename" : "Default Banner.png", "idiom" : "universal", "scale" : "1x" }, { - "filename" : "Frame 2@2x.png", + "filename" : "Default Banner@2x.png", "idiom" : "universal", "scale" : "2x" }, { - "filename" : "Frame 2@3x.png", + "filename" : "Default Banner@3x.png", "idiom" : "universal", "scale" : "3x" } diff --git a/Hotline/Assets.xcassets/Default Banner.imageset/Default Banner.png b/Hotline/Assets.xcassets/Default Banner.imageset/Default Banner.png new file mode 100644 index 0000000..d960610 Binary files /dev/null and b/Hotline/Assets.xcassets/Default Banner.imageset/Default Banner.png differ diff --git a/Hotline/Assets.xcassets/Default Banner.imageset/Default Banner@2x.png b/Hotline/Assets.xcassets/Default Banner.imageset/Default Banner@2x.png new file mode 100644 index 0000000..4ce4eab Binary files /dev/null and b/Hotline/Assets.xcassets/Default Banner.imageset/Default Banner@2x.png differ diff --git a/Hotline/Assets.xcassets/Default Banner.imageset/Default Banner@3x.png b/Hotline/Assets.xcassets/Default Banner.imageset/Default Banner@3x.png new file mode 100644 index 0000000..13c2236 Binary files /dev/null and b/Hotline/Assets.xcassets/Default Banner.imageset/Default Banner@3x.png differ diff --git a/Hotline/Assets.xcassets/Default Banner.imageset/Frame 2.png b/Hotline/Assets.xcassets/Default Banner.imageset/Frame 2.png deleted file mode 100644 index fae0905..0000000 Binary files a/Hotline/Assets.xcassets/Default Banner.imageset/Frame 2.png and /dev/null differ diff --git a/Hotline/Assets.xcassets/Default Banner.imageset/Frame 2@2x.png b/Hotline/Assets.xcassets/Default Banner.imageset/Frame 2@2x.png deleted file mode 100644 index 163e634..0000000 Binary files a/Hotline/Assets.xcassets/Default Banner.imageset/Frame 2@2x.png and /dev/null differ diff --git a/Hotline/Assets.xcassets/Default Banner.imageset/Frame 2@3x.png b/Hotline/Assets.xcassets/Default Banner.imageset/Frame 2@3x.png deleted file mode 100644 index 3f2ef8f..0000000 Binary files a/Hotline/Assets.xcassets/Default Banner.imageset/Frame 2@3x.png and /dev/null differ diff --git a/Hotline/Utility/ColorArt.swift b/Hotline/Utility/ColorArt.swift new file mode 100644 index 0000000..abbe04b --- /dev/null +++ b/Hotline/Utility/ColorArt.swift @@ -0,0 +1,376 @@ + +// ColorArt.swift +// SLColorArt by Panic Inc. +// Swift translation by Dustin Mierau +// +// Copyright (C) 2012 Panic Inc. Code by Wade Cosgrove. All rights reserved. +// +// Redistribution and use, with or without modification, are permitted +// provided that the following conditions are met: +// +// - Redistributions must reproduce the above copyright notice, this list of +// conditions and the following disclaimer in the documentation and/or other +// materials provided with the distribution. +// +// - Neither the name of Panic Inc nor the names of its contributors may be used +// to endorse or promote works derived from this software without specific prior +// written permission from Panic Inc. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +// ARE DISCLAIMED. IN NO EVENT SHALL PANIC INC BE LIABLE FOR ANY DIRECT, INDIRECT, +// INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +// PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +// POSSIBILITY OF SUCH DAMAGE. + +import AppKit +import SwiftUI + +fileprivate let kColorThresholdMinimumPercentage: CGFloat = 0.001 + +struct ColorArt { + let backgroundColor: NSColor + let primaryColor: NSColor + let secondaryColor: NSColor + let detailColor: NSColor + let scaledImage: NSImage + + init?(image: NSImage, scaledSize: NSSize = .zero) { + let finalImage = Self.scaleImage(image, size: scaledSize) + self.scaledImage = finalImage + + guard let colors = Self.analyzeImage(finalImage) else { + return nil + } + + self.backgroundColor = colors.background + self.primaryColor = colors.primary + self.secondaryColor = colors.secondary + self.detailColor = colors.detail + } + + // MARK: - Image Scaling + + private static func scaleImage(_ image: NSImage, size scaledSize: NSSize) -> NSImage { + let imageSize = image.size + let squareImage = NSImage(size: NSSize(width: imageSize.width, height: imageSize.width)) + var drawRect: NSRect + + // Make the image square + if imageSize.height > imageSize.width { + drawRect = NSRect(x: 0, y: imageSize.height - imageSize.width, width: imageSize.width, height: imageSize.width) + } else { + drawRect = NSRect(x: 0, y: 0, width: imageSize.height, height: imageSize.height) + } + + // Use native square size if passed zero size + let finalScaledSize = scaledSize == .zero ? drawRect.size : scaledSize + let scaledImage = NSImage(size: finalScaledSize) + + squareImage.lockFocus() + image.draw(in: NSRect(x: 0, y: 0, width: imageSize.width, height: imageSize.width), from: drawRect, operation: .sourceOver, fraction: 1.0) + squareImage.unlockFocus() + + // Scale the image to the desired size + scaledImage.lockFocus() + squareImage.draw(in: NSRect(x: 0, y: 0, width: finalScaledSize.width, height: finalScaledSize.height), from: .zero, operation: .sourceOver, fraction: 1.0) + scaledImage.unlockFocus() + + // Convert back to readable bitmap data + guard let cgImage = scaledImage.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + return scaledImage + } + + let bitmapRep = NSBitmapImageRep(cgImage: cgImage) + let finalImage = NSImage(size: scaledImage.size) + finalImage.addRepresentation(bitmapRep) + + return finalImage + } + + // MARK: - Image Analysis + + private static func analyzeImage(_ image: NSImage) -> (background: NSColor, primary: NSColor, secondary: NSColor, detail: NSColor)? { + var imageColors: NSCountedSet? + guard let backgroundColor = self.findEdgeColor(image, imageColors: &imageColors), + let colors = imageColors + else { + return nil + } + + let darkBackground = backgroundColor.isDarkColor + var primaryColor: NSColor? + var secondaryColor: NSColor? + var detailColor: NSColor? + + self.findTextColors(colors, primaryColor: &primaryColor, secondaryColor: &secondaryColor, detailColor: &detailColor, backgroundColor: backgroundColor) + + // Fallback to black or white if colors not found + if primaryColor == nil { + primaryColor = darkBackground ? .white : .black + } + + if secondaryColor == nil { + secondaryColor = darkBackground ? .white : .black + } + + if detailColor == nil { + detailColor = darkBackground ? .white : .black + } + + return (backgroundColor, primaryColor!, secondaryColor!, detailColor!) + } + + // MARK: - Edge Color Detection + + private static func findEdgeColor(_ image: NSImage, imageColors: inout NSCountedSet?) -> NSColor? { + guard var imageRep = image.representations.last else { + return nil + } + + if !(imageRep is NSBitmapImageRep) { + image.lockFocus() + imageRep = NSBitmapImageRep(focusedViewRect: NSRect(x: 0, y: 0, width: image.size.width, height: image.size.height))! + image.unlockFocus() + } + + // Convert to RGB color space + guard let bitmapRep = (imageRep as? NSBitmapImageRep)?.converting(to: .genericRGB, renderingIntent: .default) else { + return nil + } + + let pixelsWide = bitmapRep.pixelsWide + let pixelsHigh = bitmapRep.pixelsHigh + + let colors = NSCountedSet(capacity: pixelsWide * pixelsHigh) + let leftEdgeColors = NSCountedSet(capacity: pixelsHigh) + var searchColumnX = 0 + + for x in 0.. 0.5 { + leftEdgeColors.add(color) + } + } + + if color.alphaComponent > CGFloat.ulpOfOne { + colors.add(color) + } + } + + // Background is clear, keep looking in next column for background color + if leftEdgeColors.count == 0 { + searchColumnX += 1 + } + } + + imageColors = colors + + var sortedColors: [CountedColor] = [] + + for color in leftEdgeColors { + guard let nsColor = color as? NSColor else { continue } + let colorCount = leftEdgeColors.count(for: nsColor) + + let randomColorsThreshold = Int(CGFloat(pixelsHigh) * kColorThresholdMinimumPercentage) + + if colorCount <= randomColorsThreshold { + continue + } + + sortedColors.append(CountedColor(color: nsColor, count: colorCount)) + } + + sortedColors.sort { $0.count > $1.count } + + guard var proposedEdgeColor = sortedColors.first else { + return nil + } + + // Want to choose color over black/white so we keep looking + if proposedEdgeColor.color.isBlackOrWhite { + for i in 1.. 0.3 { + if !nextProposedColor.color.isBlackOrWhite { + proposedEdgeColor = nextProposedColor + break + } + } else { + // Reached color threshold less than 30% of the original proposed edge color + break + } + } + } + + return proposedEdgeColor.color + } + + // MARK: - Text Color Detection + + private static func findTextColors(_ colors: NSCountedSet, primaryColor: inout NSColor?, secondaryColor: inout NSColor?, detailColor: inout NSColor?, backgroundColor: NSColor) { + var sortedColors: [CountedColor] = [] + let findDarkTextColor = !backgroundColor.isDarkColor + + for color in colors { + guard let nsColor = color as? NSColor else { continue } + let adjustedColor = nsColor.withMinimumSaturation(0.15) + + if adjustedColor.isDarkColor == findDarkTextColor { + let colorCount = colors.count(for: nsColor) + sortedColors.append(CountedColor(color: adjustedColor, count: colorCount)) + } + } + + sortedColors.sort { $0.count > $1.count } + + for container in sortedColors { + let curColor = container.color + + if primaryColor == nil { + if curColor.isContrasting(to: backgroundColor) { + primaryColor = curColor + } + } else if secondaryColor == nil { + if let primary = primaryColor, + primary.isDistinct(from: curColor) && curColor.isContrasting(to: backgroundColor) { + secondaryColor = curColor + } + } else if detailColor == nil { + if let primary = primaryColor, + let secondary = secondaryColor, + secondary.isDistinct(from: curColor) && + primary.isDistinct(from: curColor) && + curColor.isContrasting(to: backgroundColor) { + detailColor = curColor + break + } + } + } + } +} + +// MARK: - Helper Classes + +fileprivate struct CountedColor { + let color: NSColor + let count: Int +} + +// MARK: - NSColor Extensions + +extension NSColor { + var isDarkColor: Bool { + guard let convertedColor = usingColorSpace(.genericRGB) else { + return false + } + + var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + convertedColor.getRed(&r, green: &g, blue: &b, alpha: &a) + + let lum = 0.2126 * r + 0.7152 * g + 0.0722 * b + + return lum < 0.5 + } + + func isDistinct(from compareColor: NSColor) -> Bool { + guard let convertedColor = usingColorSpace(.genericRGB), + let convertedCompareColor = compareColor.usingColorSpace(.genericRGB) + else { + return false + } + + var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + var r1: CGFloat = 0, g1: CGFloat = 0, b1: CGFloat = 0, a1: CGFloat = 0 + + convertedColor.getRed(&r, green: &g, blue: &b, alpha: &a) + convertedCompareColor.getRed(&r1, green: &g1, blue: &b1, alpha: &a1) + + let threshold: CGFloat = 0.25 + + if abs(r - r1) > threshold || abs(g - g1) > threshold || abs(b - b1) > threshold || abs(a - a1) > threshold { + // Check for grays, prevent multiple gray colors + if abs(r - g) < 0.03 && abs(r - b) < 0.03 { + if abs(r1 - g1) < 0.03 && abs(r1 - b1) < 0.03 { + return false + } + } + + return true + } + + return false + } + + func withMinimumSaturation(_ minSaturation: CGFloat) -> NSColor { + guard let tempColor = usingColorSpace(.genericRGB) else { + return self + } + + var hue: CGFloat = 0, saturation: CGFloat = 0, brightness: CGFloat = 0, alpha: CGFloat = 0 + tempColor.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) + + if saturation < minSaturation { + return NSColor(calibratedHue: hue, saturation: minSaturation, brightness: brightness, alpha: alpha) + } + + return self + } + + var isBlackOrWhite: Bool { + guard let tempColor = usingColorSpace(.genericRGB) else { + return false + } + + var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + tempColor.getRed(&r, green: &g, blue: &b, alpha: &a) + + // White + if r > 0.91 && g > 0.91 && b > 0.91 { + return true + } + + // Black + if r < 0.09 && g < 0.09 && b < 0.09 { + return true + } + + return false + } + + func isContrasting(to color: NSColor) -> Bool { + guard let backgroundColor = usingColorSpace(.genericRGB), + let foregroundColor = color.usingColorSpace(.genericRGB) + else { + return true + } + + var br: CGFloat = 0, bg: CGFloat = 0, bb: CGFloat = 0, ba: CGFloat = 0 + var fr: CGFloat = 0, fg: CGFloat = 0, fb: CGFloat = 0, fa: CGFloat = 0 + + backgroundColor.getRed(&br, green: &bg, blue: &bb, alpha: &ba) + foregroundColor.getRed(&fr, green: &fg, blue: &fb, alpha: &fa) + + let bLum = 0.2126 * br + 0.7152 * bg + 0.0722 * bb + let fLum = 0.2126 * fr + 0.7152 * fg + 0.0722 * fb + + let contrast: CGFloat + if bLum > fLum { + contrast = (bLum + 0.05) / (fLum + 0.05) + } else { + contrast = (fLum + 0.05) / (bLum + 0.05) + } + + return contrast > 1.6 + } +} diff --git a/Hotline/macOS/Chat/ChatView.swift b/Hotline/macOS/Chat/ChatView.swift index 65087c6..0795856 100644 --- a/Hotline/macOS/Chat/ChatView.swift +++ b/Hotline/macOS/Chat/ChatView.swift @@ -192,8 +192,9 @@ struct ChatView: View { model.markPublicChatAsRead() } .onAppear { - reader.scrollTo(bottomID, anchor: .bottom) self.focusedField = .chatInput + model.markPublicChatAsRead() + reader.scrollTo(bottomID, anchor: .bottom) } .onChange(of: self.model.bannerImage) { reader.scrollTo(bottomID, anchor: .bottom) diff --git a/Hotline/macOS/HotlinePanelView.swift b/Hotline/macOS/HotlinePanelView.swift index fd43c15..4ae4924 100644 --- a/Hotline/macOS/HotlinePanelView.swift +++ b/Hotline/macOS/HotlinePanelView.swift @@ -3,7 +3,14 @@ import SwiftUI struct HotlinePanelView: View { @Environment(\.openWindow) var openWindow @Environment(\.colorScheme) var colorScheme - + + private var colorArt: ColorArt? { + if let banner = AppState.shared.activeServerBanner { + return ColorArt(image: banner, scaledSize: NSSize(width: 100, height: 100)) + } + return nil + } + var body: some View { VStack(spacing: 0) { Image(nsImage: AppState.shared.activeServerBanner ?? NSImage(named: "Default Banner")!) @@ -34,7 +41,7 @@ struct HotlinePanelView: View { .buttonStyle(.plain) .frame(width: 20, height: 20) .help("Hotline Servers") - + Button { AppState.shared.activeServerState?.selection = .chat } @@ -47,7 +54,7 @@ struct HotlinePanelView: View { .frame(width: 20, height: 20) .disabled(AppState.shared.activeServerState == nil) .help("Public Chat") - + Button { AppState.shared.activeServerState?.selection = .board } @@ -60,7 +67,7 @@ struct HotlinePanelView: View { .frame(width: 20, height: 20) .disabled(AppState.shared.activeServerState == nil) .help("Message Board") - + Button { AppState.shared.activeServerState?.selection = .news } @@ -73,7 +80,7 @@ struct HotlinePanelView: View { .frame(width: 20, height: 20) .disabled(AppState.shared.activeServerState == nil || (AppState.shared.activeHotline?.serverVersion ?? 0) < 151) .help("News") - + Button { AppState.shared.activeServerState?.selection = .files } @@ -86,9 +93,9 @@ struct HotlinePanelView: View { .frame(width: 20, height: 20) .disabled(AppState.shared.activeServerState == nil) .help("Files") - + Spacer() - + if AppState.shared.activeHotline?.access?.contains(.canOpenUsers) == true { Button { AppState.shared.activeServerState?.selection = .accounts @@ -103,7 +110,7 @@ struct HotlinePanelView: View { .disabled(AppState.shared.activeServerState == nil) .help("Accounts") } - + SettingsLink(label: { Image("Section Settings") .resizable() @@ -116,6 +123,8 @@ struct HotlinePanelView: View { .padding(.top, 12) .padding(.bottom, 12) .padding([.leading, .trailing], 12) + .background(colorArt.map { Color(nsColor: $0.backgroundColor) } ?? Color(nsColor: .controlBackgroundColor)) + .foregroundStyle(colorArt.map { Color(nsColor: $0.primaryColor) } ?? Color.primary) // .background(Color.red.opacity(0.5).blendMode(.multiply)) // GroupBox { -- cgit From c5165d348c1efbcc553b3c31dbeaa23353914fb6 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Tue, 28 Oct 2025 10:06:33 -0700 Subject: Some state cleanup and banner color caching in ServerState. --- Hotline/MacApp.swift | 16 +--------------- Hotline/State/AppState.swift | 13 ++++--------- Hotline/Utility/ColorArt.swift | 11 +++++++++-- Hotline/macOS/HotlinePanelView.swift | 13 +++---------- Hotline/macOS/ServerView.swift | 22 +++++++++++++++++++--- 5 files changed, 36 insertions(+), 39 deletions(-) diff --git a/Hotline/MacApp.swift b/Hotline/MacApp.swift index dd7e190..89db36e 100644 --- a/Hotline/MacApp.swift +++ b/Hotline/MacApp.swift @@ -192,26 +192,12 @@ struct Application: App { .defaultSize(width: 690, height: 760) .defaultPosition(.center) .onChange(of: activeServerState) { - AppState.shared.activeServerState = activeServerState - } - .onChange(of: activeHotline) { - AppState.shared.activeHotline = activeHotline - } - .onChange(of: activeHotline?.serverTitle) { - if let hotline = activeHotline { - AppState.shared.activeServerName = hotline.serverTitle - } - } - .onChange(of: activeHotline?.bannerImage) { withAnimation { - AppState.shared.activeServerBanner = activeHotline?.bannerImage + AppState.shared.activeServerState = activeServerState } } .onChange(of: activeHotline) { AppState.shared.activeHotline = activeHotline - if let hotline = activeHotline { - AppState.shared.activeServerName = hotline.serverTitle - } } .commands { CommandGroup(replacing: .newItem) { diff --git a/Hotline/State/AppState.swift b/Hotline/State/AppState.swift index 11bae74..3917ad0 100644 --- a/Hotline/State/AppState.swift +++ b/Hotline/State/AppState.swift @@ -7,18 +7,13 @@ extension EnvironmentValues { @Observable final class AppState { static let shared = AppState() - + private init() { - + } - + var activeHotline: Hotline? = nil var activeServerState: ServerState? = nil - - // Frontmost server window information - var activeServerID: UUID? = nil - var activeServerBanner: NSImage? = nil - var activeServerName: String? = nil - + var cloudKitReady: Bool = false } diff --git a/Hotline/Utility/ColorArt.swift b/Hotline/Utility/ColorArt.swift index abbe04b..1f99f0f 100644 --- a/Hotline/Utility/ColorArt.swift +++ b/Hotline/Utility/ColorArt.swift @@ -32,13 +32,20 @@ import SwiftUI fileprivate let kColorThresholdMinimumPercentage: CGFloat = 0.001 -struct ColorArt { +struct ColorArt: Equatable { let backgroundColor: NSColor let primaryColor: NSColor let secondaryColor: NSColor let detailColor: NSColor let scaledImage: NSImage - + + static func == (lhs: ColorArt, rhs: ColorArt) -> Bool { + return lhs.backgroundColor == rhs.backgroundColor && + lhs.primaryColor == rhs.primaryColor && + lhs.secondaryColor == rhs.secondaryColor && + lhs.detailColor == rhs.detailColor + } + init?(image: NSImage, scaledSize: NSSize = .zero) { let finalImage = Self.scaleImage(image, size: scaledSize) self.scaledImage = finalImage diff --git a/Hotline/macOS/HotlinePanelView.swift b/Hotline/macOS/HotlinePanelView.swift index 4ae4924..ee4d198 100644 --- a/Hotline/macOS/HotlinePanelView.swift +++ b/Hotline/macOS/HotlinePanelView.swift @@ -4,16 +4,9 @@ struct HotlinePanelView: View { @Environment(\.openWindow) var openWindow @Environment(\.colorScheme) var colorScheme - private var colorArt: ColorArt? { - if let banner = AppState.shared.activeServerBanner { - return ColorArt(image: banner, scaledSize: NSSize(width: 100, height: 100)) - } - return nil - } - var body: some View { VStack(spacing: 0) { - Image(nsImage: AppState.shared.activeServerBanner ?? NSImage(named: "Default Banner")!) + Image(nsImage: AppState.shared.activeServerState?.serverBanner ?? NSImage(named: "Default Banner")!) .interpolation(.high) .resizable() .scaledToFill() @@ -123,8 +116,8 @@ struct HotlinePanelView: View { .padding(.top, 12) .padding(.bottom, 12) .padding([.leading, .trailing], 12) - .background(colorArt.map { Color(nsColor: $0.backgroundColor) } ?? Color(nsColor: .controlBackgroundColor)) - .foregroundStyle(colorArt.map { Color(nsColor: $0.primaryColor) } ?? Color.primary) + .background(AppState.shared.activeServerState?.bannerColors.map { Color(nsColor: $0.backgroundColor) } ?? Color(nsColor: .controlBackgroundColor)) + .foregroundStyle(AppState.shared.activeServerState?.bannerColors.map { Color(nsColor: $0.primaryColor) } ?? Color.primary) // .background(Color.red.opacity(0.5).blendMode(.multiply)) // GroupBox { diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index 77b6dc8..6d5743c 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -6,11 +6,14 @@ import AppKit class ServerState: Equatable { var id: UUID = UUID() var selection: ServerNavigationType - + var serverName: String? = nil + var serverBanner: NSImage? = nil + var bannerColors: ColorArt? = nil + init(selection: ServerNavigationType) { self.selection = selection } - + static func == (lhs: ServerState, rhs: ServerState) -> Bool { return lhs.id == rhs.id } @@ -104,7 +107,7 @@ extension FocusedValues { get { self[ActiveHotlineModelFocusedValueKey.self] } set { self[ActiveHotlineModelFocusedValueKey.self] = newValue } } - + var activeServerState: ServerState? { get { self[ActiveServerStateFocusedValueKey.self] } set { self[ActiveServerStateFocusedValueKey.self] = newValue } @@ -238,6 +241,19 @@ struct ServerView: View { .onDisappear { model.disconnect() } + .onChange(of: model.serverTitle) { oldTitle, newTitle in + state.serverName = newTitle + } + .onChange(of: model.bannerImage) { oldBanner, newBanner in + withAnimation { + state.serverBanner = newBanner + if let banner = newBanner { + state.bannerColors = ColorArt(image: banner, scaledSize: NSSize(width: 100, height: 100)) + } else { + state.bannerColors = nil + } + } + } .alert(model.errorMessage ?? "Server Error", isPresented: $model.errorDisplayed) { Button("OK") {} } -- cgit From 8fdad8745921ea0bc43a9efd2beafc1a1ce3142b Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Tue, 28 Oct 2025 10:12:49 -0700 Subject: Pop ServerState out into its own file. --- Hotline.xcodeproj/project.pbxproj | 4 +++ Hotline/State/ServerState.swift | 44 ++++++++++++++++++++++++ Hotline/macOS/ServerView.swift | 71 ++------------------------------------- 3 files changed, 50 insertions(+), 69 deletions(-) create mode 100644 Hotline/State/ServerState.swift diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index 5c0b943..03f170c 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -33,6 +33,7 @@ DA5268A32EB0741B00DCB941 /* FolderItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268A22EB0741B00DCB941 /* FolderItemView.swift */; platformFilters = (macos, ); }; DA5268A52EB0743000DCB941 /* FileItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268A42EB0743000DCB941 /* FileItemView.swift */; platformFilters = (macos, ); }; DA5268AB2EB11EA300DCB941 /* ColorArt.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268AA2EB11EA300DCB941 /* ColorArt.swift */; }; + DA5268AD2EB12FE200DCB941 /* ServerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268AC2EB12FE200DCB941 /* ServerState.swift */; }; DA55AC732BE42AF000034857 /* AsyncLinkPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA55AC722BE42AF000034857 /* AsyncLinkPreview.swift */; platformFilters = (macos, ); }; DA55AC752BE4888300034857 /* InstantMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA55AC742BE4888300034857 /* InstantMessage.swift */; }; DA55AC772BE589F700034857 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA55AC762BE589F700034857 /* AboutView.swift */; platformFilters = (macos, ); }; @@ -130,6 +131,7 @@ DA5268A22EB0741B00DCB941 /* FolderItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderItemView.swift; sourceTree = ""; }; DA5268A42EB0743000DCB941 /* FileItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileItemView.swift; sourceTree = ""; }; DA5268AA2EB11EA300DCB941 /* ColorArt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorArt.swift; sourceTree = ""; }; + DA5268AC2EB12FE200DCB941 /* ServerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerState.swift; sourceTree = ""; }; DA55AC722BE42AF000034857 /* AsyncLinkPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncLinkPreview.swift; sourceTree = ""; }; DA55AC742BE4888300034857 /* InstantMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstantMessage.swift; sourceTree = ""; }; DA55AC762BE589F700034857 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; @@ -214,6 +216,7 @@ isa = PBXGroup; children = ( DAC3D9822BC33FD000A727C9 /* AppState.swift */, + DA5268AC2EB12FE200DCB941 /* ServerState.swift */, DA2863D92B37BF6E00A7D050 /* Preferences.swift */, DACCE5E02EABE4B4008CDD92 /* AppUpdate.swift */, ); @@ -600,6 +603,7 @@ DABFCC292B1530DC009F40D2 /* FoundationExtensions.swift in Sources */, DA57536C2B36BA1D00FAC277 /* TextDocument.swift in Sources */, DACCE5E12EABE4B4008CDD92 /* AppUpdate.swift in Sources */, + DA5268AD2EB12FE200DCB941 /* ServerState.swift in Sources */, DA77253F2B21176D006C5ABB /* NewsView.swift in Sources */, DAC6B2E22EAEE9FE004E2CBA /* NetSocketNew.swift in Sources */, DA99218E2C51AA5D0058FA6C /* HotlineDataBuilder.swift in Sources */, diff --git a/Hotline/State/ServerState.swift b/Hotline/State/ServerState.swift new file mode 100644 index 0000000..e2fa40f --- /dev/null +++ b/Hotline/State/ServerState.swift @@ -0,0 +1,44 @@ +import SwiftUI + +@Observable +class ServerState: Equatable { + var id: UUID = UUID() + var selection: ServerNavigationType + var serverName: String? = nil + var serverBanner: NSImage? = nil + var bannerColors: ColorArt? = nil + + init(selection: ServerNavigationType) { + self.selection = selection + } + + static func == (lhs: ServerState, rhs: ServerState) -> Bool { + return lhs.id == rhs.id + } +} + +enum ServerNavigationType: Identifiable, Hashable, Equatable { + var id: String { + switch self { + case .chat: + return "Chat" + case .news: + return "News" + case .board: + return "Board" + case .files: + return "Files" + case .accounts: + return "Accounts" + case .user(let userID): + return String(userID) + } + } + + case chat + case news + case board + case files + case accounts + case user(userID: UInt16) +} diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index 6d5743c..e18d630 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -2,32 +2,6 @@ import SwiftUI import UniformTypeIdentifiers import AppKit -@Observable -class ServerState: Equatable { - var id: UUID = UUID() - var selection: ServerNavigationType - var serverName: String? = nil - var serverBanner: NSImage? = nil - var bannerColors: ColorArt? = nil - - init(selection: ServerNavigationType) { - self.selection = selection - } - - static func == (lhs: ServerState, rhs: ServerState) -> Bool { - return lhs.id == rhs.id - } -} - -enum MenuItemType { - case chat - case news - case messageBoard - case files - case tasks - case user -} - struct ServerMenuItem: Identifiable, Hashable { let id: UUID let type: ServerNavigationType @@ -94,50 +68,9 @@ struct ListItemView: View { } } -struct ActiveHotlineModelFocusedValueKey: FocusedValueKey { - typealias Value = Hotline -} - -struct ActiveServerStateFocusedValueKey: FocusedValueKey { - typealias Value = ServerState -} - extension FocusedValues { - var activeHotlineModel: Hotline? { - get { self[ActiveHotlineModelFocusedValueKey.self] } - set { self[ActiveHotlineModelFocusedValueKey.self] = newValue } - } - - var activeServerState: ServerState? { - get { self[ActiveServerStateFocusedValueKey.self] } - set { self[ActiveServerStateFocusedValueKey.self] = newValue } - } -} - -enum ServerNavigationType: Identifiable, Hashable, Equatable { - var id: String { - switch self { - case .chat: - return "Chat" - case .news: - return "News" - case .board: - return "Board" - case .files: - return "Files" - case .accounts: - return "Accounts" - case .user(let userID): - return String(userID) - } - } - - case chat - case news - case board - case files - case accounts - case user(userID: UInt16) + @Entry var activeHotlineModel: Hotline? + @Entry var activeServerState: ServerState? } struct ServerView: View { -- cgit From 43e59e184f3941d02963d85fa5aeeb514fa4f22a Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Tue, 28 Oct 2025 10:19:09 -0700 Subject: Remove old CloudKit code. --- Hotline/MacApp.swift | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/Hotline/MacApp.swift b/Hotline/MacApp.swift index 89db36e..44ca308 100644 --- a/Hotline/MacApp.swift +++ b/Hotline/MacApp.swift @@ -42,25 +42,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } } - -// if FileManager.default.ubiquityIdentityToken == nil { -// print("iCloud Unavailable") -// -// // We mark CloudKit has available now since we're not waiting on -// // a server sync or anything. -// AppState.shared.cloudKitReady = true -// } -// else { -// print("iCloud Available") -// -// self.cloudKitObserverToken = NotificationCenter.default.addObserver(forName: NSPersistentCloudKitContainer.eventChangedNotification, object: nil, queue: OperationQueue.main) { [weak self] note in -// print("iCloud Changed!") -// AppState.shared.cloudKitReady = true -// -// guard let token = self?.cloudKitObserverToken else { return } -// NotificationCenter.default.removeObserver(token) -// } -// } Task { await AppUpdate.shared.checkForUpdatesOnLaunch() -- cgit From bcf36ef614aa46ae3d8e714add470f749fdf3714 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Wed, 29 Oct 2025 08:18:27 -0700 Subject: Bit of cleanup of HotlineFilePreviewClient. --- Hotline/Hotline/HotlineTransferClient.swift | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/Hotline/Hotline/HotlineTransferClient.swift b/Hotline/Hotline/HotlineTransferClient.swift index 86a1814..6bac8db 100644 --- a/Hotline/Hotline/HotlineTransferClient.swift +++ b/Hotline/Hotline/HotlineTransferClient.swift @@ -477,7 +477,7 @@ class HotlineFilePreviewClient: HotlineTransferClient { } deinit { - downloadTask?.cancel() + self.downloadTask?.cancel() } func start() { @@ -485,32 +485,32 @@ class HotlineFilePreviewClient: HotlineTransferClient { return } - downloadTask = Task { + self.downloadTask = Task { await self.download() } } func cancel() { - downloadTask?.cancel() - downloadTask = nil - delegate = nil + self.downloadTask?.cancel() + self.downloadTask = nil + self.delegate = nil print("HotlineFilePreviewClient: Cancelled preview transfer") } private func download() async { - status = .connecting + self.status = .connecting do { // Connect to file transfer server (already includes +1 in serverPort from init) let socket = try await NetSocketNew.connect( - host: serverAddress, - port: serverPort, + host: self.serverAddress, + port: self.serverPort, tls: .disabled ) defer { Task { await socket.close() } } - status = .connected + self.status = .connected // Send magic header let headerData = Data(endian: .big) { @@ -521,10 +521,10 @@ class HotlineFilePreviewClient: HotlineTransferClient { } try await socket.write(headerData) - status = .progress(0.0) + self.status = .progress(0.0) // Download file data with progress updates - let fileData = try await socket.read(Int(referenceDataSize)) { current, total in + let fileData = try await socket.read(Int(self.referenceDataSize)) { current, total in self.status = .progress(Double(current) / Double(total)) } @@ -543,10 +543,10 @@ class HotlineFilePreviewClient: HotlineTransferClient { } catch { print("HotlineFilePreviewClient: Download failed: \(error)") - if status == .connecting { - status = .failed(.failedToConnect) + if self.status == .connecting { + self.status = .failed(.failedToConnect) } else { - status = .failed(.failedToDownload) + self.status = .failed(.failedToDownload) } } } -- cgit From 87f08cf60a5d7c1cf618463916cbac4dab88e0f8 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 7 Nov 2025 10:19:42 -0800 Subject: Massive refactor of transfer cients (new folder upload implementation), brand new async version of NetSocket, and a rewritten Hotline view model. New cross-server transfers UI. --- Hotline.xcodeproj/project.pbxproj | 86 +- .../xcshareddata/xcschemes/Hotline.xcscheme | 1 + Hotline/Hotline/HotlineClientNew.swift | 1061 ++++++ Hotline/Hotline/HotlineExtensions.swift | 143 +- Hotline/Hotline/HotlineProtocol.swift | 317 +- Hotline/Hotline/HotlineTransferClient.swift | 3618 ++++++++++---------- .../Transfers/HotlineFileDownloadClientNew.swift | 291 ++ .../Transfers/HotlineFilePreviewClientNew.swift | 163 + .../Transfers/HotlineFileUploadClientNew.swift | 265 ++ .../Transfers/HotlineFolderDownloadClientNew.swift | 486 +++ .../Transfers/HotlineFolderUploadClientNew.swift | 538 +++ Hotline/Info.plist | 6 +- Hotline/Library/HotlinePanel.swift | 6 +- Hotline/Library/NetSocket/FileProgress.swift | 58 + Hotline/Library/NetSocket/NetSocketNew.swift | 1129 ++++++ .../Library/NetSocket/TransferRateEstimator.swift | 135 + Hotline/Library/NetSocketNew.swift | 1278 ------- Hotline/Library/QuickLookPreviewView.swift | 22 + Hotline/Library/URLAdditions.swift | 29 + Hotline/MacApp.swift | 37 +- Hotline/Models/FileDetails.swift | 4 +- Hotline/Models/FileInfo.swift | 16 +- Hotline/Models/FilePreview.swift | 226 +- Hotline/Models/Hotline.swift | 8 +- Hotline/Models/HotlineState.swift | 2468 +++++++++++++ Hotline/Models/PreviewFileInfo.swift | 6 - Hotline/Models/TransferInfo.swift | 26 +- Hotline/State/AppState.swift | 58 +- Hotline/State/FilePreviewState.swift | 207 ++ Hotline/State/ServerState.swift | 4 +- Hotline/Utility/ColorArt.swift | 147 +- Hotline/Utility/SwiftUIExtensions.swift | 25 - Hotline/iOS/ChatView.swift | 2 +- Hotline/iOS/ServerView.swift | 2 +- Hotline/macOS/Accounts/AccountManagerView.swift | 44 +- Hotline/macOS/Board/MessageBoardEditorView.swift | 14 +- Hotline/macOS/Board/MessageBoardView.swift | 6 +- Hotline/macOS/Chat/ChatView.swift | 77 +- Hotline/macOS/Files/FileDetailsView.swift | 6 +- Hotline/macOS/Files/FileItemView.swift | 2 +- Hotline/macOS/Files/FilePreviewImageView.swift | 11 +- Hotline/macOS/Files/FilePreviewQuickLookView.swift | 131 + Hotline/macOS/Files/FilePreviewTextView.swift | 9 +- Hotline/macOS/Files/FilesView.swift | 103 +- Hotline/macOS/Files/FolderItemView.swift | 6 +- Hotline/macOS/HotlinePanelView.swift | 52 +- Hotline/macOS/MessageView.swift | 10 +- Hotline/macOS/News/NewsEditorView.swift | 19 +- Hotline/macOS/News/NewsItemView.swift | 8 +- Hotline/macOS/News/NewsView.swift | 12 +- Hotline/macOS/ServerView.swift | 165 +- Hotline/macOS/Settings/IconSettingsView.swift | 2 +- Hotline/macOS/TransfersView.swift | 169 + 53 files changed, 10126 insertions(+), 3588 deletions(-) create mode 100644 Hotline/Hotline/HotlineClientNew.swift create mode 100644 Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift create mode 100644 Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift create mode 100644 Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift create mode 100644 Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift create mode 100644 Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift create mode 100644 Hotline/Library/NetSocket/FileProgress.swift create mode 100644 Hotline/Library/NetSocket/NetSocketNew.swift create mode 100644 Hotline/Library/NetSocket/TransferRateEstimator.swift delete mode 100644 Hotline/Library/NetSocketNew.swift create mode 100644 Hotline/Library/QuickLookPreviewView.swift create mode 100644 Hotline/Models/HotlineState.swift create mode 100644 Hotline/State/FilePreviewState.swift create mode 100644 Hotline/macOS/Files/FilePreviewQuickLookView.swift create mode 100644 Hotline/macOS/TransfersView.swift diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index 03f170c..a234fbc 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -22,8 +22,13 @@ DA32CD4B2B29318E0053B98B /* FileInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32CD4A2B29318E0053B98B /* FileInfo.swift */; }; DA32CD4D2B2931B50053B98B /* ChatMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32CD4C2B2931B50053B98B /* ChatMessage.swift */; }; DA32CD4F2B2931CC0053B98B /* NewsInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32CD4E2B2931CC0053B98B /* NewsInfo.swift */; }; + DA3429AE2EB9C0280010784E /* HotlineFileUploadClientNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429AD2EB9C0220010784E /* HotlineFileUploadClientNew.swift */; }; + DA3429B02EBA70790010784E /* HotlineFolderUploadClientNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429AF2EBA70790010784E /* HotlineFolderUploadClientNew.swift */; }; + DA3429B32EBA7ADF0010784E /* HotlineFilePreviewClientNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429B22EBA7ADE0010784E /* HotlineFilePreviewClientNew.swift */; }; + DA3429B52EBA8A450010784E /* FilePreviewState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429B42EBA8A450010784E /* FilePreviewState.swift */; }; + DA3429B72EBAB1750010784E /* QuickLookPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429B62EBAB1750010784E /* QuickLookPreviewView.swift */; platformFilters = (macos, ); }; + DA3429B92EBAB2130010784E /* FilePreviewQuickLookView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429B82EBAB2130010784E /* FilePreviewQuickLookView.swift */; }; DA43205E2B1D615600FC8843 /* ServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA43205D2B1D615600FC8843 /* ServerView.swift */; platformFilter = ios; }; - DA4930BD2B4F8DD700822D0B /* NetSocket.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4930BC2B4F8DD700822D0B /* NetSocket.swift */; }; DA4B8F3A2EA6FB3C00CBFD53 /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4B8F392EA6FB3C00CBFD53 /* iOSApp.swift */; platformFilter = ios; }; DA4F2BF82B16A17200D8ADDC /* HotlineProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4F2BF72B16A17200D8ADDC /* HotlineProtocol.swift */; }; DA4F2C012B1A558E00D8ADDC /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4F2C002B1A558E00D8ADDC /* ChatView.swift */; platformFilter = ios; }; @@ -34,13 +39,19 @@ DA5268A52EB0743000DCB941 /* FileItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268A42EB0743000DCB941 /* FileItemView.swift */; platformFilters = (macos, ); }; DA5268AB2EB11EA300DCB941 /* ColorArt.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268AA2EB11EA300DCB941 /* ColorArt.swift */; }; DA5268AD2EB12FE200DCB941 /* ServerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268AC2EB12FE200DCB941 /* ServerState.swift */; }; + DA5268AF2EB2682B00DCB941 /* HotlineClientNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268AE2EB2682B00DCB941 /* HotlineClientNew.swift */; }; + DA5268B12EB2708E00DCB941 /* HotlineState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268B02EB2708E00DCB941 /* HotlineState.swift */; }; + DA5268B32EB6806E00DCB941 /* HotlineFileDownloadClientNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268B22EB6806E00DCB941 /* HotlineFileDownloadClientNew.swift */; }; + DA5268B52EB6840A00DCB941 /* TransfersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268B42EB6840A00DCB941 /* TransfersView.swift */; }; + DA5268B82EB916AF00DCB941 /* TransferRateEstimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268B72EB916AF00DCB941 /* TransferRateEstimator.swift */; }; + DA5268BA2EB91B5E00DCB941 /* FileProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268B92EB91B5E00DCB941 /* FileProgress.swift */; }; + DA5268BC2EB95A7700DCB941 /* HotlineFolderDownloadClientNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268BB2EB95A7700DCB941 /* HotlineFolderDownloadClientNew.swift */; }; DA55AC732BE42AF000034857 /* AsyncLinkPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA55AC722BE42AF000034857 /* AsyncLinkPreview.swift */; platformFilters = (macos, ); }; DA55AC752BE4888300034857 /* InstantMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA55AC742BE4888300034857 /* InstantMessage.swift */; }; DA55AC772BE589F700034857 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA55AC762BE589F700034857 /* AboutView.swift */; platformFilters = (macos, ); }; DA55AC792BE6A1AD00034857 /* RegularExpressions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA55AC782BE6A1AD00034857 /* RegularExpressions.swift */; }; DA5753682B33E88A00FAC277 /* HotlineTransferClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5753672B33E88A00FAC277 /* HotlineTransferClient.swift */; }; DA57536C2B36BA1D00FAC277 /* TextDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA57536B2B36BA1D00FAC277 /* TextDocument.swift */; }; - DA6300972B24036B0034CBFD /* HotlineClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6300962B24036B0034CBFD /* HotlineClient.swift */; }; DA65499A2BEC280E00EDB697 /* ServerMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6549992BEC280E00EDB697 /* ServerMessageView.swift */; }; DA65499C2BEC3FBD00EDB697 /* ServerAgreementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA65499B2BEC3FBD00EDB697 /* ServerAgreementView.swift */; }; DA65499E2BEC438A00EDB697 /* NSWindowBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA65499D2BEC438A00EDB697 /* NSWindowBridge.swift */; platformFilters = (macos, ); }; @@ -90,7 +101,6 @@ DACCE5EF2EAC19C9008CDD92 /* SwiftUIIntrospect in Frameworks */ = {isa = PBXBuildFile; productRef = DACCE5EE2EAC19C9008CDD92 /* SwiftUIIntrospect */; }; DADDB28B2B22B31F0024040D /* Tracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADDB28A2B22B31F0024040D /* Tracker.swift */; }; DADDB28D2B22B5920024040D /* Server.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADDB28C2B22B5920024040D /* Server.swift */; }; - DADDB28F2B238D850024040D /* Hotline.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADDB28E2B238D850024040D /* Hotline.swift */; }; DAE136BA2B9D1147007D8307 /* HotlinePanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAE136B92B9D1147007D8307 /* HotlinePanelView.swift */; platformFilters = (macos, ); }; DAE734F92B2E4185000C56F6 /* ServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAE734F82B2E4185000C56F6 /* ServerView.swift */; platformFilters = (macos, ); }; DAE734FB2B2E41F9000C56F6 /* TrackerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAE734FA2B2E41F9000C56F6 /* TrackerView.swift */; platformFilters = (macos, ); }; @@ -120,6 +130,12 @@ DA32CD4A2B29318E0053B98B /* FileInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileInfo.swift; sourceTree = ""; }; DA32CD4C2B2931B50053B98B /* ChatMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessage.swift; sourceTree = ""; }; DA32CD4E2B2931CC0053B98B /* NewsInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsInfo.swift; sourceTree = ""; }; + DA3429AD2EB9C0220010784E /* HotlineFileUploadClientNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFileUploadClientNew.swift; sourceTree = ""; }; + DA3429AF2EBA70790010784E /* HotlineFolderUploadClientNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFolderUploadClientNew.swift; sourceTree = ""; }; + DA3429B22EBA7ADE0010784E /* HotlineFilePreviewClientNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFilePreviewClientNew.swift; sourceTree = ""; }; + DA3429B42EBA8A450010784E /* FilePreviewState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePreviewState.swift; sourceTree = ""; }; + DA3429B62EBAB1750010784E /* QuickLookPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickLookPreviewView.swift; sourceTree = ""; }; + DA3429B82EBAB2130010784E /* FilePreviewQuickLookView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePreviewQuickLookView.swift; sourceTree = ""; }; DA43205D2B1D615600FC8843 /* ServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerView.swift; sourceTree = ""; }; DA4930BC2B4F8DD700822D0B /* NetSocket.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetSocket.swift; sourceTree = ""; }; DA4B8F392EA6FB3C00CBFD53 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; }; @@ -132,6 +148,13 @@ DA5268A42EB0743000DCB941 /* FileItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileItemView.swift; sourceTree = ""; }; DA5268AA2EB11EA300DCB941 /* ColorArt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorArt.swift; sourceTree = ""; }; DA5268AC2EB12FE200DCB941 /* ServerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerState.swift; sourceTree = ""; }; + DA5268AE2EB2682B00DCB941 /* HotlineClientNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineClientNew.swift; sourceTree = ""; }; + DA5268B02EB2708E00DCB941 /* HotlineState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineState.swift; sourceTree = ""; }; + DA5268B22EB6806E00DCB941 /* HotlineFileDownloadClientNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFileDownloadClientNew.swift; sourceTree = ""; }; + DA5268B42EB6840A00DCB941 /* TransfersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransfersView.swift; sourceTree = ""; }; + DA5268B72EB916AF00DCB941 /* TransferRateEstimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransferRateEstimator.swift; sourceTree = ""; }; + DA5268B92EB91B5E00DCB941 /* FileProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProgress.swift; sourceTree = ""; }; + DA5268BB2EB95A7700DCB941 /* HotlineFolderDownloadClientNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFolderDownloadClientNew.swift; sourceTree = ""; }; DA55AC722BE42AF000034857 /* AsyncLinkPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncLinkPreview.swift; sourceTree = ""; }; DA55AC742BE4888300034857 /* InstantMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstantMessage.swift; sourceTree = ""; }; DA55AC762BE589F700034857 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; @@ -212,6 +235,18 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + DA3429B12EBA73730010784E /* Transfers */ = { + isa = PBXGroup; + children = ( + DA3429B22EBA7ADE0010784E /* HotlineFilePreviewClientNew.swift */, + DA5268B22EB6806E00DCB941 /* HotlineFileDownloadClientNew.swift */, + DA3429AD2EB9C0220010784E /* HotlineFileUploadClientNew.swift */, + DA5268BB2EB95A7700DCB941 /* HotlineFolderDownloadClientNew.swift */, + DA3429AF2EBA70790010784E /* HotlineFolderUploadClientNew.swift */, + ); + path = Transfers; + sourceTree = ""; + }; DA4B8F3B2EA6FB5F00CBFD53 /* State */ = { isa = PBXGroup; children = ( @@ -219,6 +254,7 @@ DA5268AC2EB12FE200DCB941 /* ServerState.swift */, DA2863D92B37BF6E00A7D050 /* Preferences.swift */, DACCE5E02EABE4B4008CDD92 /* AppUpdate.swift */, + DA3429B42EBA8A450010784E /* FilePreviewState.swift */, ); path = State; sourceTree = ""; @@ -265,6 +301,7 @@ 11A726092BE06759000C1DA7 /* FileDetailsView.swift */, DAAEE66E2B47625600A5BA07 /* FilePreviewImageView.swift */, DAB4D87D2B4C8BCA0048A05C /* FilePreviewTextView.swift */, + DA3429B82EBAB2130010784E /* FilePreviewQuickLookView.swift */, ); path = Files; sourceTree = ""; @@ -306,6 +343,16 @@ path = News; sourceTree = ""; }; + DA5268B62EB916AB00DCB941 /* NetSocket */ = { + isa = PBXGroup; + children = ( + DAC6B2E12EAEE9EF004E2CBA /* NetSocketNew.swift */, + DA5268B92EB91B5E00DCB941 /* FileProgress.swift */, + DA5268B72EB916AF00DCB941 /* TransferRateEstimator.swift */, + ); + path = NetSocket; + sourceTree = ""; + }; DA9CAFAE2B126D5700CDA197 = { isa = PBXGroup; children = ( @@ -355,12 +402,13 @@ DAB4D87C2B4B783A0048A05C /* Library */ = { isa = PBXGroup; children = ( - DAC6B2E12EAEE9EF004E2CBA /* NetSocketNew.swift */, + DA5268B62EB916AB00DCB941 /* NetSocket */, DAC6B2E32EAFE92F004E2CBA /* SpinningGlobeView.swift */, DAB4D87A2B4B78310048A05C /* FileImageView.swift */, DAB4D8812B4C8FED0048A05C /* FileIconView.swift */, DAB4D8832B4CABEF0048A05C /* DataAdditions.swift */, DAB4D87F2B4C8E9A0048A05C /* URLAdditions.swift */, + DA3429B62EBAB1750010784E /* QuickLookPreviewView.swift */, DA4930BC2B4F8DD700822D0B /* NetSocket.swift */, DA872B122BDDBF78008B1012 /* HotlinePanel.swift */, DA872B142BDDEE1A008B1012 /* VisualEffectView.swift */, @@ -389,8 +437,10 @@ isa = PBXGroup; children = ( DA6300962B24036B0034CBFD /* HotlineClient.swift */, + DA5268AE2EB2682B00DCB941 /* HotlineClientNew.swift */, DA9CAFCA2B126E3300CDA197 /* HotlineTrackerClient.swift */, DA5753672B33E88A00FAC277 /* HotlineTransferClient.swift */, + DA3429B12EBA73730010784E /* Transfers */, DA4F2BF72B16A17200D8ADDC /* HotlineProtocol.swift */, DAC87F062C5010E80060FADF /* HotlineExtensions.swift */, DA99218D2C51AA5D0058FA6C /* HotlineDataBuilder.swift */, @@ -427,6 +477,7 @@ isa = PBXGroup; children = ( DADDB28E2B238D850024040D /* Hotline.swift */, + DA5268B02EB2708E00DCB941 /* HotlineState.swift */, DADDB28A2B22B31F0024040D /* Tracker.swift */, DADDB28C2B22B5920024040D /* Server.swift */, DA32CD482B2931640053B98B /* User.swift */, @@ -453,6 +504,7 @@ DAE734FA2B2E41F9000C56F6 /* TrackerView.swift */, DAE734F82B2E4185000C56F6 /* ServerView.swift */, DAE735022B30C0BB000C56F6 /* MessageView.swift */, + DA5268B42EB6840A00DCB941 /* TransfersView.swift */, DA5268A82EB081AF00DCB941 /* Chat */, DA5268A62EB0762300DCB941 /* Board */, DA5268A92EB081DE00DCB941 /* News */, @@ -552,24 +604,27 @@ buildActionMask = 2147483647; files = ( DA32CD4D2B2931B50053B98B /* ChatMessage.swift in Sources */, - DA6300972B24036B0034CBFD /* HotlineClient.swift in Sources */, + DA3429B92EBAB2130010784E /* FilePreviewQuickLookView.swift in Sources */, DA43205E2B1D615600FC8843 /* ServerView.swift in Sources */, DA0D69912B1E894800C71DF5 /* FilesView.swift in Sources */, DA52689E2EB073A400DCB941 /* IconSettingsView.swift in Sources */, DAC3D9832BC33FD000A727C9 /* AppState.swift in Sources */, DA65499C2BEC3FBD00EDB697 /* ServerAgreementView.swift in Sources */, + DA5268BC2EB95A7700DCB941 /* HotlineFolderDownloadClientNew.swift in Sources */, DAB4D8802B4C8E9A0048A05C /* URLAdditions.swift in Sources */, DA2863DD2B3E8B7000A7D050 /* FilePreview.swift in Sources */, + DA3429B02EBA70790010784E /* HotlineFolderUploadClientNew.swift in Sources */, DA5268AB2EB11EA300DCB941 /* ColorArt.swift in Sources */, DA2863D82B37AD1C00A7D050 /* SettingsView.swift in Sources */, + DA5268BA2EB91B5E00DCB941 /* FileProgress.swift in Sources */, DA7725412B21435B006C5ABB /* ObservableScrollView.swift in Sources */, DAE734FF2B2E6750000C56F6 /* ChatView.swift in Sources */, + DA5268B52EB6840A00DCB941 /* TransfersView.swift in Sources */, 11F8288B2BF9428100216BA0 /* AccountManagerView.swift in Sources */, DAC6B2E42EAFE92F004E2CBA /* SpinningGlobeView.swift in Sources */, - DA4930BD2B4F8DD700822D0B /* NetSocket.swift in Sources */, DAC002192B21630900A6C290 /* SwiftUIExtensions.swift in Sources */, - DADDB28F2B238D850024040D /* Hotline.swift in Sources */, DADDB28D2B22B5920024040D /* Server.swift in Sources */, + DA5268AF2EB2682B00DCB941 /* HotlineClientNew.swift in Sources */, DABE8C042B57940B00884D28 /* DAKeychain.swift in Sources */, DA55AC772BE589F700034857 /* AboutView.swift in Sources */, DA72A0E22B4DAA4000A0F48A /* NewsArticle.swift in Sources */, @@ -578,13 +633,16 @@ DAE136BA2B9D1147007D8307 /* HotlinePanelView.swift in Sources */, 11A726082BE0672A000C1DA7 /* FileDetails.swift in Sources */, DA9CAFBD2B126D5700CDA197 /* TrackerView.swift in Sources */, + DA5268B82EB916AF00DCB941 /* TransferRateEstimator.swift in Sources */, DAB4D87B2B4B78310048A05C /* FileImageView.swift in Sources */, + DA3429B52EBA8A450010784E /* FilePreviewState.swift in Sources */, DA6980792BF80525003E434B /* TextView.swift in Sources */, DA55AC732BE42AF000034857 /* AsyncLinkPreview.swift in Sources */, DA65499E2BEC438A00EDB697 /* NSWindowBridge.swift in Sources */, DAE735032B30C0BB000C56F6 /* MessageView.swift in Sources */, DA32CD4B2B29318E0053B98B /* FileInfo.swift in Sources */, DA20BBE12BF5237600B94E7C /* Bookmark.swift in Sources */, + DA3429B32EBA7ADF0010784E /* HotlineFilePreviewClientNew.swift in Sources */, DA9CAFCB2B126E3300CDA197 /* HotlineTrackerClient.swift in Sources */, DA2863DA2B37BF6E00A7D050 /* Preferences.swift in Sources */, DA32CD4F2B2931CC0053B98B /* NewsInfo.swift in Sources */, @@ -595,6 +653,7 @@ DA55AC752BE4888300034857 /* InstantMessage.swift in Sources */, DA69807E2BFD449B003E434B /* MessageBoardEditorView.swift in Sources */, DAE735012B2E71F2000C56F6 /* FilesView.swift in Sources */, + DA5268B12EB2708E00DCB941 /* HotlineState.swift in Sources */, DA55AC792BE6A1AD00034857 /* RegularExpressions.swift in Sources */, DA5268A02EB073BC00DCB941 /* SoundSettingsView.swift in Sources */, 11A7260A2BE0675A000C1DA7 /* FileDetailsView.swift in Sources */, @@ -615,6 +674,7 @@ DA65499A2BEC280E00EDB697 /* ServerMessageView.swift in Sources */, DAAEE66F2B47625600A5BA07 /* FilePreviewImageView.swift in Sources */, DA872B152BDDEE1A008B1012 /* VisualEffectView.swift in Sources */, + DA3429AE2EB9C0280010784E /* HotlineFileUploadClientNew.swift in Sources */, DAAEE66D2B475F1400A5BA07 /* PreviewFileInfo.swift in Sources */, DAB4D8842B4CABEF0048A05C /* DataAdditions.swift in Sources */, DA0D698D2B1E7CF700C71DF5 /* UsersView.swift in Sources */, @@ -627,7 +687,9 @@ DAE734FD2B2E65E9000C56F6 /* MessageBoardView.swift in Sources */, DA6980832BFFD06C003E434B /* BookmarkDocument.swift in Sources */, DAB4D87E2B4C8BCA0048A05C /* FilePreviewTextView.swift in Sources */, + DA3429B72EBAB1750010784E /* QuickLookPreviewView.swift in Sources */, DA4F2C012B1A558E00D8ADDC /* ChatView.swift in Sources */, + DA5268B32EB6806E00DCB941 /* HotlineFileDownloadClientNew.swift in Sources */, DACCE5E32EABE86A008CDD92 /* AppUpdateView.swift in Sources */, DAC6B2E02EAC6236004E2CBA /* ChatStore.swift in Sources */, DA32CD492B2931640053B98B /* User.swift in Sources */, @@ -779,10 +841,7 @@ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; IPHONEOS_DEPLOYMENT_TARGET = 17.2; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); + LD_RUNPATH_SEARCH_PATHS = "$(inherited)"; MACOSX_DEPLOYMENT_TARGET = 15.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = co.goodmake.hotline; @@ -819,10 +878,7 @@ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; IPHONEOS_DEPLOYMENT_TARGET = 17.2; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); + LD_RUNPATH_SEARCH_PATHS = "$(inherited)"; MACOSX_DEPLOYMENT_TARGET = 15.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = co.goodmake.hotline; diff --git a/Hotline.xcodeproj/xcshareddata/xcschemes/Hotline.xcscheme b/Hotline.xcodeproj/xcshareddata/xcschemes/Hotline.xcscheme index 2ba63f6..91934ce 100644 --- a/Hotline.xcodeproj/xcshareddata/xcschemes/Hotline.xcscheme +++ b/Hotline.xcodeproj/xcshareddata/xcschemes/Hotline.xcscheme @@ -34,6 +34,7 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + enableThreadSanitizer = "YES" launchStyle = "0" useCustomWorkingDirectory = "NO" ignoresPersistentStateOnLaunch = "NO" diff --git a/Hotline/Hotline/HotlineClientNew.swift b/Hotline/Hotline/HotlineClientNew.swift new file mode 100644 index 0000000..9e60b5e --- /dev/null +++ b/Hotline/Hotline/HotlineClientNew.swift @@ -0,0 +1,1061 @@ +import Foundation +import Network + +// MARK: - Events + +/// Events that can be received from a Hotline server +/// +/// These are unsolicited messages sent by the server (not replies to requests). +/// Subscribe to the `events` stream to receive them. +public enum HotlineEvent: Sendable { + /// Server sent a chat message + case chatMessage(String) + /// A user's information changed (name, icon, status) + case userChanged(HotlineUser) + /// A user disconnected from the server + case userDisconnected(UInt16) + /// Server sent a broadcast message + case serverMessage(String) + /// Received a private message from a user + case privateMessage(userID: UInt16, message: String) + /// Server sent a news post notification + case newsPost(String) + /// Server is requesting agreement acceptance + case agreementRequired(String) + /// Server sent user access permissions + case userAccess(HotlineUserAccessOptions) +} + +// MARK: - Errors + +/// Errors that can occur during Hotline operations +public enum HotlineClientError: Error { + /// Connection failed + case connectionFailed(Error) + /// Server responded with an error code + case serverError(code: UInt32, message: String?) + /// Transaction timed out waiting for reply + case timeout + /// Client is not connected + case notConnected + /// Invalid response from server + case invalidResponse + /// Login failed + case loginFailed(String?) + + var userMessage: String { + switch self { + case .connectionFailed: + "Failed to connect to server" + case .serverError(let code, let message): + message ?? "Server error: \(code)" + case .timeout: + "Request could not be completed" + case .notConnected: + "Not connected" + case .invalidResponse: + "Server returned an invalid response" + case .loginFailed(let message): + message ?? "Login failed" + } + } +} + +// MARK: - Login Info + +/// Information needed to log in to a Hotline server +public struct HotlineLoginInfo: Sendable { + let login: String + let password: String + let username: String + let iconID: UInt16 + + public init(login: String, password: String, username: String, iconID: UInt16) { + self.login = login + self.password = password + self.username = username + self.iconID = iconID + } +} + +// MARK: - Server Info + +/// Information about the connected server +public struct HotlineServerInfo: Sendable { + let name: String + let version: UInt16 + + public init(name: String, version: UInt16) { + self.name = name + self.version = version + } +} + +// MARK: - Hotline Client + +/// Modern async/await-based Hotline protocol client +/// +/// Example usage: +/// ```swift +/// let client = try await HotlineClientNew.connect( +/// host: "server.example.com", +/// port: 5500, +/// login: HotlineLoginInfo(login: "guest", password: "", username: "John", iconID: 414) +/// ) +/// +/// // Listen for events +/// Task { +/// for await event in client.events { +/// switch event { +/// case .chatMessage(let text): +/// print("Chat: \(text)") +/// case .userChanged(let user): +/// print("User changed: \(user.name)") +/// default: +/// break +/// } +/// } +/// } +/// +/// // Send chat message +/// try await client.sendChat("Hello world!") +/// +/// // Get user list +/// let users = try await client.getUserList() +/// ``` +public actor HotlineClientNew { + // MARK: - Properties + + private let socket: NetSocketNew + private var serverInfo: HotlineServerInfo? + private var isConnected: Bool = true + + /// Information about the connected server (name and version) + public var server: HotlineServerInfo? { + return serverInfo + } + + // Event streaming + private let eventContinuation: AsyncStream.Continuation + public let events: AsyncStream + + // Transaction tracking for request/reply pattern + private var pendingTransactions: [UInt32: CheckedContinuation] = [:] + + private enum TransactionWaitError: Error { + case timeout + } + + // Receive loop task + private var receiveTask: Task? + + // Keep-alive timer + private var keepAliveTask: Task? + + // MARK: - Static Handshake + + private static let handshakeData = Data(endian: .big, { + "TRTP".fourCharCode() // 'TRTP' protocol ID + "HOTL".fourCharCode() // 'HOTL' sub-protocol ID + UInt16(0x0001) // Version + UInt16(0x0002) // Sub-version + }) + + // MARK: - Connection + + /// Connect to a Hotline server and log in + /// + /// This method: + /// 1. Establishes TCP connection + /// 2. Performs handshake + /// 3. Logs in with provided credentials + /// 4. Starts event streaming and keep-alive + /// + /// - Parameters: + /// - host: Server hostname or IP address + /// - port: Server port (default: 5500) + /// - login: Login credentials and user info + /// - tls: TLS policy (default: disabled for Hotline) + /// - Returns: Connected and logged-in client + /// - Throws: `HotlineClientError` if connection or login fails + public static func connect( + host: String, + port: UInt16 = 5500, + login: HotlineLoginInfo, + tls: TLSPolicy = .disabled + ) async throws -> HotlineClientNew { + print("HotlineClientNew.connect(): Starting connection to \(host):\(port) as '\(login.username)'") + + // Connect socket + print("HotlineClientNew.connect(): Connecting socket...") + let socket = try await NetSocketNew.connect(host: host, port: port, tls: tls) + print("HotlineClientNew.connect(): Socket connected") + + // Perform handshake + print("HotlineClientNew.connect(): Sending handshake...") + try await socket.write(handshakeData) + let handshakeResponse = try await socket.read(8) + print("HotlineClientNew.connect(): Handshake response received") + + // Verify handshake + guard handshakeResponse.prefix(4) == Data([0x54, 0x52, 0x54, 0x50]) else { + print("HotlineClientNew.connect(): Invalid handshake response") + throw HotlineClientError.connectionFailed( + NSError(domain: "HotlineClient", code: -1, userInfo: [ + NSLocalizedDescriptionKey: "Invalid handshake response" + ]) + ) + } + + let errorCode = handshakeResponse.withUnsafeBytes { $0.load(fromByteOffset: 4, as: UInt32.self) } + guard errorCode.bigEndian == 0 else { + print("HotlineClientNew.connect(): Handshake failed with error code \(errorCode)") + throw HotlineClientError.connectionFailed( + NSError(domain: "HotlineClient", code: Int(errorCode), userInfo: [ + NSLocalizedDescriptionKey: "Handshake failed with error code \(errorCode)" + ]) + ) + } + + // Create client + print("HotlineClientNew.connect(): Creating client instance") + let client = HotlineClientNew(socket: socket) + + // Start receive loop + print("HotlineClientNew.connect(): Starting receive loop") + await client.startReceiveLoop() + + // Perform login + print("HotlineClientNew.connect(): Performing login") + let serverInfo = try await client.performLogin(login) + await client.setServerInfo(serverInfo) + print("HotlineClientNew.connect(): Login successful") + + // Start keep-alive + print("HotlineClientNew.connect(): Starting keep-alive") + await client.startKeepAlive() + + print("HotlineClientNew.connect(): Connected to \(serverInfo.name) (v\(serverInfo.version))") + + return client + } + + private init(socket: NetSocketNew) { + self.socket = socket + + // Set up event stream + var continuation: AsyncStream.Continuation! + self.events = AsyncStream { cont in + continuation = cont + } + self.eventContinuation = continuation + } + + private func setServerInfo(_ info: HotlineServerInfo) { + self.serverInfo = info + } + + // MARK: - Login + + private func performLogin(_ login: HotlineLoginInfo) async throws -> HotlineServerInfo { + var transaction = HotlineTransaction(type: .login) + transaction.setFieldEncodedString(type: .userLogin, val: login.login) + transaction.setFieldEncodedString(type: .userPassword, val: login.password) + transaction.setFieldUInt16(type: .userIconID, val: login.iconID) + transaction.setFieldString(type: .userName, val: login.username) + transaction.setFieldUInt32(type: .versionNumber, val: 123) + + let reply = try await sendTransaction(transaction) + + guard reply.errorCode == 0 else { + let errorText = reply.getField(type: .errorText)?.getString() + throw HotlineClientError.loginFailed(errorText) + } + + let serverName = reply.getField(type: .serverName)?.getString() ?? "Unknown" + let serverVersion = reply.getField(type: .versionNumber)?.getUInt16() ?? 0 + + return HotlineServerInfo(name: serverName, version: serverVersion) + } + + // MARK: - Disconnect + + /// Disconnect from the server + /// + /// Closes the socket and stops all background tasks. + public func disconnect() async { + guard isConnected else { + return + } + + isConnected = false + + print("HotlineClientNew.disconnect(): Starting disconnect") + self.receiveTask?.cancel() + self.keepAliveTask?.cancel() + await self.socket.close() + self.failAllPendingTransactions(HotlineClientError.notConnected) + self.eventContinuation.finish() + print("HotlineClientNew.disconnect(): Disconnect complete") + } + + // MARK: - Receive Loop + + private func startReceiveLoop() { + print("HotlineClientNew.startReceiveLoop(): Creating receive task") + self.receiveTask = Task { [weak self] in + guard let self else { + return + } + + do { + while !Task.isCancelled { + // Read transaction from socket + let transaction = try await self.socket.receive(HotlineTransaction.self, endian: .big) + await self.handleTransaction(transaction) + } + print("HotlineClientNew.startReceiveLoop(): Task cancelled, exiting loop") + } catch { + if Task.isCancelled || error is CancellationError { + print("HotlineClientNew.startReceiveLoop(): Receive loop cancelled") + } else { + print("HotlineClientNew.startReceiveLoop(): Receive loop error: \(error)") + await self.disconnect() + } + } + print("HotlineClientNew.startReceiveLoop(): Receive loop ended") + } + } + + private func handleTransaction(_ transaction: HotlineTransaction) { + print("HotlineClientNew: <= \(transaction.type) [\(transaction.id)]") + + // Check if this is a reply to a pending transaction + if transaction.isReply == 1 || transaction.type == .reply { + handleReply(transaction) + return + } + + // Handle unsolicited server messages (events) + handleEvent(transaction) + } + + private func handleReply(_ transaction: HotlineTransaction) { + guard let continuation = pendingTransactions.removeValue(forKey: transaction.id) else { + print("HotlineClientNew: Received reply for unknown transaction \(transaction.id)") + return + } + + if transaction.errorCode != 0 { + let errorText = transaction.getField(type: .errorText)?.getString() + continuation.resume(throwing: HotlineClientError.serverError( + code: transaction.errorCode, + message: errorText + )) + } else { + print("HELLO") + continuation.resume(returning: transaction) + } + } + + private func handleEvent(_ transaction: HotlineTransaction) { + switch transaction.type { + case .chatMessage: + if let text = transaction.getField(type: .data)?.getString() { + eventContinuation.yield(.chatMessage(text)) + } + + case .notifyOfUserChange: + if let usernameField = transaction.getField(type: .userName), + let username = usernameField.getString(), + let userID = transaction.getField(type: .userID)?.getUInt16(), + let iconID = transaction.getField(type: .userIconID)?.getUInt16(), + let flags = transaction.getField(type: .userFlags)?.getUInt16() { + let user = HotlineUser(id: userID, iconID: iconID, status: flags, name: username) + eventContinuation.yield(.userChanged(user)) + } + + case .notifyOfUserDelete: + if let userID = transaction.getField(type: .userID)?.getUInt16() { + eventContinuation.yield(.userDisconnected(userID)) + } + + case .serverMessage: + if let message = transaction.getField(type: .data)?.getString() { + if let userID = transaction.getField(type: .userID)?.getUInt16() { + eventContinuation.yield(.privateMessage(userID: userID, message: message)) + } else { + eventContinuation.yield(.serverMessage(message)) + } + } + + case .showAgreement: + if transaction.getField(type: .noServerAgreement) == nil, + let agreementText = transaction.getField(type: .data)?.getString() { + eventContinuation.yield(.agreementRequired(agreementText)) + } + + case .userAccess: + if let accessValue = transaction.getField(type: .userAccess)?.getUInt64() { + eventContinuation.yield(.userAccess(HotlineUserAccessOptions(rawValue: accessValue))) + } + + case .newMessage: + if let message = transaction.getField(type: .data)?.getString() { + eventContinuation.yield(.newsPost(message)) + } + + case .disconnectMessage: + Task { + await self.disconnect() + } + + default: + print("HotlineClientNew: Unhandled event type \(transaction.type)") + } + } + + // MARK: - Transaction Sending + + private func sendTransaction(_ transaction: HotlineTransaction, timeout: TimeInterval = 30.0) async throws -> HotlineTransaction { + print("HotlineClientNew: => \(transaction.type) [\(transaction.id)]") + + let transactionID = transaction.id + + try await self.socket.send(transaction, endian: .big) + + do { + return try await withTimeout(seconds: timeout) { + try await self.awaitReply(for: transactionID) + } + } catch is TransactionWaitError { + throw HotlineClientError.timeout + } catch let error as HotlineClientError { + throw error + } catch { + throw error + } + } + + private func storePendingTransaction(id: UInt32, continuation: CheckedContinuation) { + self.pendingTransactions[id] = continuation + } + + private func awaitReply(for transactionID: UInt32) async throws -> HotlineTransaction { + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + self.storePendingTransaction(id: transactionID, continuation: continuation) + } + } onCancel: { [weak self] in + Task { await self?.failPendingTransaction(id: transactionID, error: HotlineClientError.timeout) } + } + } + + private func failPendingTransaction(id: UInt32, error: Error) { + guard let continuation = self.pendingTransactions.removeValue(forKey: id) else { return } + continuation.resume(throwing: error) + } + + private func failAllPendingTransactions(_ error: Error) { + guard !self.pendingTransactions.isEmpty else { return } + let continuations = self.pendingTransactions + self.pendingTransactions.removeAll() + for (_, continuation) in continuations { + continuation.resume(throwing: error) + } + } + + private func withTimeout(seconds: TimeInterval, operation: @escaping @Sendable () async throws -> T) async throws -> T { + if seconds <= 0 { + throw TransactionWaitError.timeout + } + + return try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { + try await operation() + } + + group.addTask { + try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + throw TransactionWaitError.timeout + } + + do { + let value = try await group.next()! + group.cancelAll() + return value + } catch { + group.cancelAll() + throw error + } + } + } + + // MARK: - Keep-Alive + + private func startKeepAlive() { + 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)") + } + } + } + } + + // MARK: - Public API - Chat + + /// Send a chat message to the server + /// + /// - Parameters: + /// - message: Text to send + /// - 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) + 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 + + /// 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 reply = try await sendTransaction(transaction) + + var users: [HotlineUser] = [] + for field in reply.getFieldList(type: .userNameWithInfo) { + users.append(field.getUser()) + } + + return users + } + + /// Send a private instant message to a user + /// + /// - Parameters: + /// - message: Text to send + /// - 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) + transaction.setFieldUInt16(type: .userID, val: userID) + transaction.setFieldUInt32(type: .options, val: 1) + transaction.setFieldString(type: .data, val: message, encoding: encoding) + + try await socket.send(transaction, endian: .big) + } + + /// Update this client's user info (name, icon, options) + /// + /// - Parameters: + /// - username: Display name + /// - iconID: Icon ID + /// - options: User options flags + /// - autoresponse: Optional auto-response text + public func setClientUserInfo( + username: String, + iconID: UInt16, + options: HotlineUserOptions = [], + autoresponse: String? = nil + ) async throws { + var transaction = HotlineTransaction(type: .setClientUserInfo) + transaction.setFieldString(type: .userName, val: username) + transaction.setFieldUInt16(type: .userIconID, val: iconID) + transaction.setFieldUInt16(type: .options, val: options.rawValue) + + if let autoresponse { + transaction.setFieldString(type: .automaticResponse, val: autoresponse) + } + + try await socket.send(transaction, endian: .big) + } + + // MARK: - Public API - Agreement + + /// Send agreement acceptance to the server + /// + /// Call this after receiving `.agreementRequired` event. + public func sendAgree() async throws { + let transaction = HotlineTransaction(type: .agreed) + try await socket.send(transaction, endian: .big) + } + + // MARK: - Public API - Files + + /// Get the file list for a directory + /// + /// - 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) + if !path.isEmpty { + transaction.setFieldPath(type: .filePath, val: path) + } + + let reply = try await sendTransaction(transaction) + + var files: [HotlineFile] = [] + for field in reply.getFieldList(type: .fileNameWithInfo) { + let file = field.getFile() + file.path = path + [file.name] + files.append(file) + } + + return files + } + + /// Request to download a file + /// + /// - Parameters: + /// - name: File name + /// - path: Directory path containing the file + /// - preview: Request preview/thumbnail instead of full file + /// - Returns: Transfer info (reference number, size, waiting count) + public func downloadFile( + name: String, + path: [String], + preview: Bool = false + ) async throws -> (referenceNumber: UInt32, size: Int, fileSize: Int?, waitingCount: Int?) { + var transaction = HotlineTransaction(type: .downloadFile) + transaction.setFieldString(type: .fileName, val: name) + transaction.setFieldPath(type: .filePath, val: path) + + if preview { + transaction.setFieldUInt32(type: .fileTransferOptions, val: 2) + } + + let reply = try await sendTransaction(transaction) + + guard + let transferSize = reply.getField(type: .transferSize)?.getInteger(), + let referenceNumber = reply.getField(type: .referenceNumber)?.getUInt32() + else { + throw HotlineClientError.invalidResponse + } + + let fileSize = reply.getField(type: .fileSize)?.getInteger() + let waitingCount = reply.getField(type: .waitingCount)?.getInteger() + + return (referenceNumber, transferSize, fileSize, waitingCount) + } + + // MARK: - Public API - News + + /// Get news categories at a path + /// + /// - 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) + if !path.isEmpty { + transaction.setFieldPath(type: .newsPath, val: path) + } + + let reply = try await sendTransaction(transaction) + + var categories: [HotlineNewsCategory] = [] + for field in reply.getFieldList(type: .newsCategoryListData15) { + var category = field.getNewsCategory() + category.path = path + [category.name] + categories.append(category) + } + + return categories + } + + /// Get news articles in a category + /// + /// - Parameter path: Category path + /// - Returns: Array of news articles + public func getNewsArticles(path: [String] = []) async throws -> [HotlineNewsArticle] { + var transaction = HotlineTransaction(type: .getNewsArticleNameList) + if !path.isEmpty { + transaction.setFieldPath(type: .newsPath, val: path) + } + + let reply = try await sendTransaction(transaction) + + guard let articleData = reply.getField(type: .newsArticleListData) else { + return [] + } + + let newsList = articleData.getNewsList() + return newsList.articles.map { article in + var a = article + a.path = path + return a + } + } + + /// Get the content of a news article + /// + /// - Parameters: + /// - id: Article ID + /// - path: Category path + /// - 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) + transaction.setFieldPath(type: .newsPath, val: path) + transaction.setFieldUInt32(type: .newsArticleID, val: id) + transaction.setFieldString(type: .newsArticleDataFlavor, val: flavor, encoding: .ascii) + + let reply = try await sendTransaction(transaction) + return reply.getField(type: .newsArticleData)?.getString() + } + + /// Post a news article + /// + /// - Parameters: + /// - title: Article title + /// - text: Article body + /// - path: Category path + /// - parentID: Parent article ID (for replies, default: 0) + public func postNewsArticle( + title: String, + text: String, + path: [String], + parentID: UInt32 = 0 + ) async throws { + guard !path.isEmpty else { + throw HotlineClientError.invalidResponse + } + + var transaction = HotlineTransaction(type: .postNewsArticle) + transaction.setFieldPath(type: .newsPath, val: path) + transaction.setFieldUInt32(type: .newsArticleID, val: parentID) + transaction.setFieldString(type: .newsArticleTitle, val: title) + transaction.setFieldString(type: .newsArticleDataFlavor, val: "text/plain") + transaction.setFieldUInt32(type: .newsArticleFlags, val: 0) + transaction.setFieldString(type: .newsArticleData, val: text) + + _ = try await sendTransaction(transaction) + } + + // MARK: - Public API - Message Board + + /// Get message board posts + /// + /// - Returns: Array of message strings + public func getMessageBoard() async throws -> [String] { + let transaction = HotlineTransaction(type: .getMessageBoard) + let reply = try await sendTransaction(transaction) + + guard let text = reply.getField(type: .data)?.getString() else { + return [] + } + + // Parse messages (separated by divider pattern) + // TODO: Implement proper divider parsing if needed + return [text] + } + + /// Post to the message board + /// + /// - Parameter text: Message text + public func postMessageBoard(_ text: String) async throws { + guard !text.isEmpty else { return } + + var transaction = HotlineTransaction(type: .oldPostNews) + transaction.setFieldString(type: .data, val: text, encoding: .macOSRoman) + + try await socket.send(transaction, endian: .big) + } + + // MARK: - Public API - File Operations + + /// Get detailed information about a file + /// + /// - Parameters: + /// - name: File name + /// - 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) + transaction.setFieldString(type: .fileName, val: name) + transaction.setFieldPath(type: .filePath, val: path) + + let reply = try await sendTransaction(transaction) + + guard + let fileName = reply.getField(type: .fileName)?.getString(), + let fileCreator = reply.getField(type: .fileCreatorString)?.getString(), + let fileType = reply.getField(type: .fileTypeString)?.getString(), + let fileCreateDate = reply.getField(type: .fileCreateDate)?.data.readDate(at: 0), + let fileModifyDate = reply.getField(type: .fileModifyDate)?.data.readDate(at: 0) + else { + return nil + } + + // Size field is not included in server reply for folders + let fileSize = reply.getField(type: .fileSize)?.getInteger() ?? 0 + let fileComment = reply.getField(type: .fileComment)?.getString() ?? "" + + return FileDetails( + name: fileName, + path: path, + size: fileSize, + comment: fileComment, + type: fileType, + creator: fileCreator, + created: fileCreateDate, + modified: fileModifyDate + ) + } + + /// Delete a file or folder + /// + /// - Parameters: + /// - name: File or folder name + /// - 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) + transaction.setFieldString(type: .fileName, val: name) + transaction.setFieldPath(type: .filePath, val: path) + + do { + _ = try await sendTransaction(transaction) + return true + } catch { + return false + } + } + + // MARK: - Public API - User 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 reply = try await sendTransaction(transaction) + + let accountFields = reply.getFieldList(type: .data) + var accounts: [HotlineAccount] = [] + + for data in accountFields { + accounts.append(data.getAcccount()) + } + + accounts.sort { $0.login < $1.login } + + return accounts + } + + /// Create a new user account (requires admin access) + /// + /// - Parameters: + /// - name: Display name for the user + /// - login: Login username + /// - 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) + + transaction.setFieldString(type: .userName, val: name) + transaction.setFieldEncodedString(type: .userLogin, val: login) + transaction.setFieldUInt64(type: .userAccess, val: access) + + if let password { + transaction.setFieldEncodedString(type: .userPassword, val: password) + } + + _ = try await sendTransaction(transaction) + } + + /// Update an existing user account (requires admin access) + /// + /// - Parameters: + /// - name: Display name for the user + /// - login: Current login username + /// - newLogin: New login username (nil to keep current) + /// - 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) + transaction.setFieldString(type: .userName, val: name) + transaction.setFieldUInt64(type: .userAccess, val: access) + + if let newLogin { + transaction.setFieldEncodedString(type: .data, val: login) + transaction.setFieldEncodedString(type: .userLogin, val: newLogin) + } else { + transaction.setFieldEncodedString(type: .userLogin, val: login) + } + + // Password field handling: + // - nil: Keep current password (send zero byte) + // - "": Remove password (omit field) + // - other: Set new password + if password == nil { + transaction.setFieldUInt8(type: .userPassword, val: 0) + } else if password != "" { + transaction.setFieldEncodedString(type: .userPassword, val: password!) + } + + _ = try await sendTransaction(transaction) + } + + /// Delete a user account (requires admin access) + /// + /// - Parameter login: Login username to delete + public func deleteUser(login: String) async throws { + var transaction = HotlineTransaction(type: .deleteUser) + transaction.setFieldEncodedString(type: .userLogin, val: login) + + _ = try await sendTransaction(transaction) + } + + // MARK: - Public API - Banner Download + + /// 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 reply = try await sendTransaction(transaction) + + guard + let transferSizeField = reply.getField(type: .transferSize), + let transferSize = transferSizeField.getInteger(), + let transferReferenceField = reply.getField(type: .referenceNumber), + let referenceNumber = transferReferenceField.getUInt32() + else { + return nil + } + + return (referenceNumber, transferSize) + } + + // MARK: Files + + /// Request to download a file + /// + /// - Parameters: + /// - name: File name to download + /// - path: Directory path containing the file + /// - 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) + transaction.setFieldString(type: .fileName, val: name) + transaction.setFieldPath(type: .filePath, val: path) + + if preview { + transaction.setFieldUInt32(type: .fileTransferOptions, val: 2) + } + + let reply = try await sendTransaction(transaction) + + guard + let transferSizeField = reply.getField(type: .transferSize), + let transferSize = transferSizeField.getInteger(), + let transferReferenceField = reply.getField(type: .referenceNumber), + let referenceNumber = transferReferenceField.getUInt32() + else { + return nil + } + + let fileSize = reply.getField(type: .fileSize)?.getInteger() ?? transferSize + let waitingCount = reply.getField(type: .waitingCount)?.getInteger() ?? 0 + + return (referenceNumber, transferSize, fileSize, waitingCount) + } + + /// Request to download a folder + /// + /// - Parameters: + /// - name: Folder name to download + /// - 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) + transaction.setFieldString(type: .fileName, val: name) + transaction.setFieldPath(type: .filePath, val: path) + + let reply = try await sendTransaction(transaction) + + guard + let transferSizeField = reply.getField(type: .transferSize), + let transferSize = transferSizeField.getInteger(), + let transferReferenceField = reply.getField(type: .referenceNumber), + let referenceNumber = transferReferenceField.getUInt32() + else { + return nil + } + + let itemCount = reply.getField(type: .folderItemCount)?.getInteger() ?? 0 + let waitingCount = reply.getField(type: .waitingCount)?.getInteger() ?? 0 + + return (referenceNumber, transferSize, itemCount, waitingCount) + } + + /// Uploads a file to the server + /// - Parameters: + /// - name: File name to upload + /// - 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) + transaction.setFieldString(type: .fileName, val: name) + transaction.setFieldPath(type: .filePath, val: path) + + let reply = try await sendTransaction(transaction) + + guard + let transferReferenceField = reply.getField(type: .referenceNumber), + let referenceNumber = transferReferenceField.getUInt32() + else { + return nil + } + + return referenceNumber + } + + /// Request to upload a folder + /// + /// - Parameters: + /// - name: Folder name to upload + /// - path: Directory path where the folder should be uploaded + /// - Returns: Reference number for the upload transfer + 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) + transaction.setFieldString(type: .fileName, val: name) + transaction.setFieldPath(type: .filePath, val: path) + transaction.setFieldUInt32(type: .transferSize, val: totalSize) + transaction.setFieldUInt16(type: .folderItemCount, val: UInt16(truncatingIfNeeded: fileCount)) + + let reply = try await sendTransaction(transaction) + + guard + let transferReferenceField = reply.getField(type: .referenceNumber), + let referenceNumber = transferReferenceField.getUInt32() + else { + return nil + } + + return referenceNumber + } +} diff --git a/Hotline/Hotline/HotlineExtensions.swift b/Hotline/Hotline/HotlineExtensions.swift index 81387bf..16e9583 100644 --- a/Hotline/Hotline/HotlineExtensions.swift +++ b/Hotline/Hotline/HotlineExtensions.swift @@ -11,6 +11,24 @@ extension URL { func urlForResourceFork() -> URL { self.appendingPathComponent("..namedfork/rsrc") } + + /// Append multiple path components to a URL by iterating through an array + /// - Parameter components: Array of path component strings + /// - Returns: New URL with all components appended + func appendingPathComponents(_ components: [String]) -> URL { + var path = self.path + for component in components { + path = (path as NSString).appendingPathComponent(component) + } + return URL(filePath: path) + } + +#if os(macOS) + func notifyDownloadFinished() { + print("DOWNLOAD FINISHED AT", self) + DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: self.path) + } +#endif } extension String { @@ -382,7 +400,7 @@ extension FileManager { // Get resource fork size. var resourceForkSize: UInt32 = 0 - let resourceFileURL: URL = fileURL.appendingPathComponent("..namedfork/rsrc") + let resourceFileURL: URL = fileURL.urlForResourceFork() let resourceFilePath: String = fileURL.path(percentEncoded: false) if self.fileExists(atPath: resourceFilePath) { let resourceFileAttributes: [FileAttributeKey: Any]? = try? self.attributesOfItem(atPath: resourceFileURL.path(percentEncoded: false)) @@ -394,6 +412,36 @@ extension FileManager { return (dataForkSize: dataForkSize, resourceForkSize: resourceForkSize) } + /// Create a file with metadata from a Hotline INFO fork + /// + /// - Parameters: + /// - url: Destination URL for the new file + /// - infoFork: Hotline file info containing metadata + /// - Returns: FileHandle open for writing + /// - Throws: Error if file creation fails + func createHotlineFile(at url: URL, infoFork: HotlineFileInfoFork) throws -> FileHandle { + var attributes: [FileAttributeKey: Any] = [:] + + if infoFork.creator != 0 { + attributes[.hfsCreatorCode] = infoFork.creator as NSNumber + } + if infoFork.type != 0 { + attributes[.hfsTypeCode] = infoFork.type as NSNumber + } + attributes[.creationDate] = infoFork.createdDate as NSDate + attributes[.modificationDate] = infoFork.modifiedDate as NSDate + + guard self.createFile(atPath: url.path, contents: nil, attributes: attributes) else { + throw HotlineFileClientError.failedToTransfer + } + + guard let handle = FileHandle(forWritingAtPath: url.path) else { + throw HotlineFileClientError.failedToTransfer + } + + return handle + } + func getFlattenedFileSize(_ fileURL: URL) -> UInt64? { var fileIsDirectory: ObjCBool = false let filePath: String = fileURL.path(percentEncoded: false) @@ -454,11 +502,52 @@ extension FileManager { // print("FOUND RESOURCE FORK: \(resourceForkSize)") // } // } -// +// // totalSize += resourceForkSize - + return totalSize } + + /// Get the total size of all files in a folder (recursively) + /// Returns the sum of flattened file sizes for all files in the folder, and the total count of all items (files + folders) + func getFolderSize(_ folderURL: URL) -> (size: UInt64, count: UInt32)? { + var isDirectory: ObjCBool = false + let folderPath = folderURL.path(percentEncoded: false) + + guard folderURL.isFileURL, + self.fileExists(atPath: folderPath, isDirectory: &isDirectory), + isDirectory.boolValue == true else { + return nil + } + + var totalSize: UInt64 = 0 + var itemCount: UInt32 = 0 + + guard let enumerator = self.enumerator(at: folderURL, includingPropertiesForKeys: [.isRegularFileKey, .isDirectoryKey], options: [.skipsHiddenFiles]) else { + return nil + } + + for case let fileURL as URL in enumerator { + guard let resourceValues = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .isDirectoryKey]) else { + continue + } + + let isRegularFile = resourceValues.isRegularFile ?? false + let isDirectory = resourceValues.isDirectory ?? false + + // Count all items (files and folders) + if isRegularFile || isDirectory { + itemCount += 1 + + // Only add size for files, not folders + if isRegularFile, let fileSize = self.getFlattenedFileSize(fileURL) { + totalSize += fileSize + } + } + } + + return (size: totalSize, count: itemCount) + } } extension Date { @@ -1016,3 +1105,51 @@ extension FourCharCode { return String(bytes: bytes, encoding: .ascii) ?? "" } } + + +extension NetSocketNew { + /// Read a pascal string (1-byte length prefix followed by string data) + /// + /// This method reads a single byte for the length, then reads that many bytes and attempts + /// to decode them as a string. It tries multiple encodings for compatibility with legacy + /// protocols like Hotline: UTF-8, Shift-JIS, Windows-1251, and falls back to MacRoman. + /// + /// - Returns: The decoded string, or nil if length is 0 + /// - Throws: `NetSocketError` if reading fails or no encoding succeeds + func readPascalString() async throws -> String? { + let length = try await read(UInt8.self) + guard length > 0 else { return nil } + + let data = try await read(Int(length)) + + // Try auto-detection with common encodings + let allowedEncodings = [ + String.Encoding.utf8.rawValue, + String.Encoding.shiftJIS.rawValue, + String.Encoding.unicode.rawValue, + String.Encoding.windowsCP1251.rawValue + ] + + var decodedString: NSString? + let detected = NSString.stringEncoding( + for: data, + encodingOptions: [.allowLossyKey: false], + convertedString: &decodedString, + usedLossyConversion: nil + ) + + if allowedEncodings.contains(detected), let str = decodedString as? String { + return str + } + + // Fallback to MacRoman for classic Mac compatibility + guard let str = String(data: data, encoding: .macOSRoman) else { + throw NetSocketError.decodeFailed(NSError( + domain: "NetSocketNew", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Failed to decode pascal string with any known encoding"] + )) + } + return str + } +} diff --git a/Hotline/Hotline/HotlineProtocol.swift b/Hotline/Hotline/HotlineProtocol.swift index 5f859fb..43f018e 100644 --- a/Hotline/Hotline/HotlineProtocol.swift +++ b/Hotline/Hotline/HotlineProtocol.swift @@ -5,18 +5,26 @@ struct HotlinePorts { static let DefaultTrackerPort: Int = 5498 } -struct HotlineUserOptions: OptionSet { - let rawValue: UInt16 - - static let none: HotlineUserOptions = [] - - static let refusePrivateMessages = HotlineUserOptions(rawValue: 1 << 0) - static let refusePrivateChat = HotlineUserOptions(rawValue: 1 << 1) - static let automaticResponse = HotlineUserOptions(rawValue: 1 << 2) +public struct HotlineUserOptions: OptionSet, Sendable { + public let rawValue: UInt16 + + public init(rawValue: UInt16) { + self.rawValue = rawValue + } + + public static let none: HotlineUserOptions = [] + + public static let refusePrivateMessages = HotlineUserOptions(rawValue: 1 << 0) + public static let refusePrivateChat = HotlineUserOptions(rawValue: 1 << 1) + public static let automaticResponse = HotlineUserOptions(rawValue: 1 << 2) } -struct HotlineUserAccessOptions: OptionSet { - let rawValue: UInt64 +public struct HotlineUserAccessOptions: OptionSet, Sendable { + public let rawValue: UInt64 + + public init(rawValue: UInt64) { + self.rawValue = rawValue + } static func accessIndexToBit(_ index: Int) -> Int { return 63 - index @@ -201,23 +209,23 @@ struct HotlineServer: Identifiable, Hashable, NetSocketDecodable { } } -struct HotlineNewsArticle: Identifiable { - let id: UInt32 - let parentID: UInt32 - let flags: UInt32 - let title: String - let username: String - let date: Date? - var flavors: [(String, UInt16)] = [] - var path: [String] = [] - - static func == (lhs: HotlineNewsArticle, rhs: HotlineNewsArticle) -> Bool { +public struct HotlineNewsArticle: Identifiable, Sendable { + public let id: UInt32 + public let parentID: UInt32 + public let flags: UInt32 + public let title: String + public let username: String + public let date: Date? + public var flavors: [(String, UInt16)] = [] + public var path: [String] = [] + + public static func == (lhs: HotlineNewsArticle, rhs: HotlineNewsArticle) -> Bool { return lhs.id == rhs.id } } -struct HotlineAccount: Identifiable { - let id: UUID = UUID() +public struct HotlineAccount: Identifiable { + public let id: UUID = UUID() var name: String = "" var login: String = "" var password: String? = nil @@ -279,11 +287,11 @@ struct HotlineAccount: Identifiable { } } } - - static func == (lhs: HotlineAccount, rhs: HotlineAccount) -> Bool { + + public static func == (lhs: HotlineAccount, rhs: HotlineAccount) -> Bool { return lhs.id == rhs.id } - + // Generate an initial random 21 character alphanumeric password, in the spirit of the original client static func randomPassword() -> String { return String((0..<20).map{_ in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".randomElement()!}) @@ -291,7 +299,7 @@ struct HotlineAccount: Identifiable { } extension HotlineAccount: Hashable { - func hash(into hasher: inout Hasher) { + public func hash(into hasher: inout Hasher) { hasher.combine(self.id) } } @@ -389,22 +397,22 @@ struct HotlineNewsList: Identifiable { } } -struct HotlineNewsCategory: Identifiable, Hashable { - let id = UUID() - let type: UInt16 - let count: UInt16 - let name: String - var path: [String] = [] - - static func == (lhs: HotlineNewsCategory, rhs: HotlineNewsCategory) -> Bool { +public struct HotlineNewsCategory: Identifiable, Hashable, Sendable { + public let id = UUID() + public let type: UInt16 + public let count: UInt16 + public let name: String + public var path: [String] = [] + + public static func == (lhs: HotlineNewsCategory, rhs: HotlineNewsCategory) -> Bool { return lhs.id == rhs.id } - - func hash(into hasher: inout Hasher) { + + public func hash(into hasher: inout Hasher) { hasher.combine(self.id) } - - init(type: UInt16, count: UInt16, name: String) { + + public init(type: UInt16, count: UInt16, name: String) { self.type = type self.count = count self.name = name @@ -438,32 +446,32 @@ struct HotlineNewsCategory: Identifiable, Hashable { @Observable -class HotlineFile: Identifiable, Hashable { - let id = UUID() - let type: String - let creator: String - let fileSize: UInt32 - let name: String - - var path: [String] = [] - var isExpanded: Bool = false - var files: [HotlineFile]? = nil - - let isFolder: Bool +public class HotlineFile: Identifiable, Hashable { + public let id = UUID() + public let type: String + public let creator: String + public let fileSize: UInt32 + public let name: String + + public var path: [String] = [] + public var isExpanded: Bool = false + public var files: [HotlineFile]? = nil + + public let isFolder: Bool - var isDropboxFolder: Bool { + public var isDropboxFolder: Bool { guard self.isFolder, (self.name.range(of: "upload", options: [.caseInsensitive]) != nil) || (self.name.range(of: "drop box", options: [.caseInsensitive]) != nil) else { return false } return true } - - static func == (lhs: HotlineFile, rhs: HotlineFile) -> Bool { + + public static func == (lhs: HotlineFile, rhs: HotlineFile) -> Bool { return lhs.id == rhs.id } - - func hash(into hasher: inout Hasher) { + + public func hash(into hasher: inout Hasher) { hasher.combine(self.id) } @@ -501,25 +509,25 @@ class HotlineFile: Identifiable, Hashable { } } -struct HotlineUser: Identifiable, Hashable { - let id: UInt16 - let iconID: UInt16 - let status: UInt16 - let name: String - - var isAdmin: Bool { +public struct HotlineUser: Identifiable, Hashable, Sendable { + public let id: UInt16 + public let iconID: UInt16 + public let status: UInt16 + public let name: String + + public var isAdmin: Bool { return ((self.status & 0x0002) != 0) } - - var isIdle: Bool { + + public var isIdle: Bool { return ((self.status & 0x0001) != 0) } - - static func == (lhs: HotlineUser, rhs: HotlineUser) -> Bool { + + public static func == (lhs: HotlineUser, rhs: HotlineUser) -> Bool { return lhs.id == rhs.id } - - init(id: UInt16, iconID: UInt16, status: UInt16, name: String) { + + public init(id: UInt16, iconID: UInt16, status: UInt16, name: String) { self.id = id self.iconID = iconID self.status = status @@ -535,10 +543,10 @@ struct HotlineUser: Identifiable, Hashable { self.name = data.readString(at: 8, length: userNameLength)! } - func hash(into hasher: inout Hasher) { + public func hash(into hasher: inout Hasher) { hasher.combine(self.id) } - + func encoded() -> [UInt8] { var data: [UInt8] = [] data.appendUInt16(self.id) @@ -819,6 +827,111 @@ struct HotlineTransaction { self.fields.append(HotlineTransactionField(type: type, pathComponents: val)) } + // MARK: - Subscript support for typed field access + // Replaces any existing field with the same type, or removes the field if value is set to nil + private mutating func replaceField(type: HotlineTransactionFieldType, with field: HotlineTransactionField?) { + // Remove existing fields of this type + self.fields.removeAll { $0.type == type } + // Append new field if provided + if let field = field { + self.fields.append(field) + } + } + + // UInt8 + subscript(_ type: HotlineTransactionFieldType) -> UInt8? { + get { self.getField(type: type)?.getUInt8() } + set { + if let v = newValue { + self.replaceField(type: type, with: HotlineTransactionField(type: type, val: v)) + } else { + self.replaceField(type: type, with: nil) + } + } + } + + // UInt16 + subscript(_ type: HotlineTransactionFieldType) -> UInt16? { + get { self.getField(type: type)?.getUInt16() } + set { + if let v = newValue { + self.replaceField(type: type, with: HotlineTransactionField(type: type, val: v)) + } else { + self.replaceField(type: type, with: nil) + } + } + } + + // UInt32 + subscript(_ type: HotlineTransactionFieldType) -> UInt32? { + get { self.getField(type: type)?.getUInt32() } + set { + if let v = newValue { + self.replaceField(type: type, with: HotlineTransactionField(type: type, val: v)) + } else { + self.replaceField(type: type, with: nil) + } + } + } + + // UInt64 + subscript(_ type: HotlineTransactionFieldType) -> UInt64? { + get { self.getField(type: type)?.getUInt64() } + set { + if let v = newValue { + self.replaceField(type: type, with: HotlineTransactionField(type: type, val: v)) + } else { + self.replaceField(type: type, with: nil) + } + } + } + + // String (plain, not obfuscated) + subscript(_ type: HotlineTransactionFieldType) -> String? { + get { self.getField(type: type)?.getString() } + set { + if let v = newValue { + self.replaceField(type: type, with: HotlineTransactionField(type: type, string: v, encoding: .utf8, encrypt: false)) + } else { + self.replaceField(type: type, with: nil) + } + } + } + + // [String] path components +// subscript(path type: HotlineTransactionFieldType) -> [String]? { +// get { +// self.getField(type: type)?.getString() +// } +// set { +// if let v = newValue { +// self.replaceField(type: type, with: HotlineTransactionField(type: type, pathComponents: v)) +// } else { +// self.replaceField(type: type, with: nil) +// } +// } +// } + + // Field list + subscript(_ type: HotlineTransactionFieldType) -> [HotlineTransactionField]? { + get { self.fields.filter { $0.type == type } } + set { + // Remove existing fields of this type + self.fields.removeAll { $0.type == type } + + if let v = newValue { + self.fields.append(contentsOf: v) + } + } + } + + // Field + subscript(_ type: HotlineTransactionFieldType) -> HotlineTransactionField? { + get { self.fields.first { $0.type == type } } + set { self.replaceField(type: type, with: newValue) } + } + + func getField(type: HotlineTransactionFieldType) -> HotlineTransactionField? { return self.fields.first { p in p.type == type @@ -838,7 +951,7 @@ struct HotlineTransaction { data.appendUInt16(self.isReply == 1 ? HotlineTransactionType.reply.rawValue : self.type.rawValue) data.appendUInt32(self.id) data.appendUInt32(self.errorCode) - + if self.fields.count > 0 { var fieldData: [UInt8] = [] fieldData.appendUInt16(UInt16(self.fields.count)) @@ -847,7 +960,7 @@ struct HotlineTransaction { fieldData.appendUInt16(f.dataSize) fieldData.appendData(f.data) } - + data.appendUInt32(UInt32(fieldData.count)) data.appendUInt32(UInt32(fieldData.count)) data.appendData(fieldData) @@ -861,6 +974,64 @@ struct HotlineTransaction { } } +// MARK: - NetSocket Protocol Conformance + +extension HotlineTransaction: NetSocketEncodable { + func encode(endian: Endian) throws -> Data { + return Data(self.encoded()) + } +} + +extension HotlineTransaction: NetSocketDecodable { + /// Decode a Hotline transaction directly from the socket stream + /// + /// Reads the 20-byte header, then reads and decodes the variable-length body with fields. + init(from socket: NetSocketNew, endian: Endian) async throws { + // Read 20-byte header + let flags = try await socket.read(UInt8.self) + let isReply = try await socket.read(UInt8.self) + let typeRaw = try await socket.read(UInt16.self, endian: endian) + let id = try await socket.read(UInt32.self, endian: endian) + let errorCode = try await socket.read(UInt32.self, endian: endian) + let totalSize = try await socket.read(UInt32.self, endian: endian) + let dataSize = try await socket.read(UInt32.self, endian: endian) + + // Initialize with header data + self.flags = flags + self.isReply = isReply + self.type = HotlineTransactionType(rawValue: typeRaw) ?? .unknown + self.id = id + self.errorCode = errorCode + self.totalSize = totalSize + self.dataSize = dataSize + self.fields = [] + + // Read body if present + guard dataSize > 0 else { return } + + let bodyData = try await socket.read(Int(dataSize)) + var fieldBytes = [UInt8](bodyData) + + // Decode fields + guard let fieldCount = fieldBytes.consumeUInt16(), fieldCount > 0 else { return } + + for _ in 0.. 0 { - self.fileResourceURL = fileURL.urlForResourceFork() - } - else { - self.fileResourceURL = nil - } - } - - deinit { - self.invalidate() - } - - func start() { - guard self.status == .unconnected else { - return - } - - let _ = self.fileURL.startAccessingSecurityScopedResource() - let _ = self.fileResourceURL?.stopAccessingSecurityScopedResource() - - self.bytesSent = 0 - self.connect() - } - - func cancel() { - self.delegate = nil - - if self.status == .unconnected { - return - } - - self.invalidate() - - print("HotlineFileUploadClient: Cancelled upload") - } - - private func connect() { - self.connection = NWConnection(host: self.serverAddress, port: self.serverPort, using: .tcp) - self.connection?.stateUpdateHandler = { [weak self] (newState: NWConnection.State) in - switch newState { - case .ready: - self?.status = .connected - self?.stage = .magic - self?.send() - case .waiting(let err): - print("HotlineFileClient: Waiting", err) - case .cancelled: - print("HotlineFileClient: Cancelled") - self?.invalidate() - case .failed(let err): - print("HotlineFileClient: Connection error \(err)") - switch self?.status { - case .connecting: - print("HotlineFileClient: Failed to connect to file transfer server.") - self?.invalidate() - self?.status = .failed(.failedToConnect) - case .connected, .progress(_): - print("HotlineFileClient: Failed to finish transfer.") - self?.invalidate() - self?.status = .failed(.failedToUpload) - case .completing: - print("HotlineFileClient: Completed.") - self?.invalidate() - self?.status = .completed - DispatchQueue.main.async { [weak self] in - if let s = self { - s.delegate?.hotlineFileUploadComplete(client: s, reference: s.referenceNumber) - } - } - case .completed: - self?.invalidate() - default: - break - } - default: - return - } - } - - self.status = .connecting - self.connection?.start(queue: .global()) - } - - private func invalidate() { - if let c = self.connection { - c.stateUpdateHandler = nil - c.cancel() - - self.connection = nil - } - - self.stage = .magic - - if let fh = self.fileHandle { - try? fh.close() - self.fileHandle = nil - } - - self.fileURL.stopAccessingSecurityScopedResource() - self.fileResourceURL?.stopAccessingSecurityScopedResource() - } - - private func sendComplete() { - guard let c = self.connection else { - self.invalidate() - print("HotlineFileUploadClient: invalid connection to send data.") - return - } - - self.status = .completing - - c.send(content: nil, contentContext: .finalMessage, completion: .contentProcessed({ error in - })) - } - - private func sendFileData(_ data: Data) { - guard let c = self.connection else { - self.invalidate() - - print("HotlineFileUploadClient: invalid connection to send data.") - return - } - - let dataSent: Int = data.count - c.send(content: data, completion: .contentProcessed({ [weak self] error in - guard let client = self, - error == nil else { - self?.status = .failed(.failedToConnect) - self?.invalidate() - return - } - - - client.bytesSent += dataSent - client.status = .progress(Double(client.bytesSent) / Double(client.payloadSize)) - - client.send() - })) - } - - private func send() { - guard let _ = self.connection else { - self.invalidate() - print("HotlineFileUploadClient: Invalid connection to send.") - return - } - - switch self.stage { - case .magic: - print("Upload: Starting upload for \(self.fileURL)") - print("Upload: Sending magic") - self.status = .progress(0.0) - - let magicData = Data(endian: .big) { - "HTXF".fourCharCode() - self.referenceNumber - self.payloadSize - UInt32.zero - } - // var magicData = Data() - // magicData.appendUInt32("HTXF".fourCharCode()) - // magicData.appendUInt32(self.referenceNumber) - // magicData.appendUInt32(self.payloadSize) - // magicData.appendUInt32(0) - self.stage = .fileHeader - self.sendFileData(magicData) - - case .fileHeader: - print("Upload: Sending file header") - if let header = HotlineFileHeader(file: self.fileURL) { - self.stage = .fileInfoForkHeader - self.sendFileData(header.data()) - } - - case .fileInfoForkHeader: - print("Upload: Sending info fork header") - let header = HotlineFileForkHeader(type: HotlineFileForkType.info.rawValue, dataSize: UInt32(self.infoForkData.count)) - self.stage = .fileInfoFork - self.sendFileData(header.data()) - - case .fileInfoFork: - print("Upload: Sending info fork") - self.stage = .fileDataForkHeader - self.sendFileData(self.infoForkData) - - case .fileDataForkHeader: - guard self.dataForkSize > 0 else { - print("Upload: Data fork empty, skipping to resource fork") - self.stage = .fileResourceForkHeader - fallthrough - } - - do { - let fh = try FileHandle(forReadingFrom: self.fileURL) - self.fileHandle = fh - self.stage = .fileDataFork - - let header = HotlineFileForkHeader(type: HotlineFileForkType.data.rawValue, dataSize: self.dataForkSize) - - print("Upload: Sending data fork header \(self.dataForkSize)") - self.sendFileData(header.data()) - } - catch { - print("Upload: Error opening data fork", error) - self.invalidate() - return - } - - case .fileDataFork: - guard self.dataForkSize > 0, - let fh = self.fileHandle else { - print("Upload: Data fork empty, skipping to resource fork") - self.stage = .fileResourceForkHeader - try? self.fileHandle?.close() - self.fileHandle = nil - fallthrough - } - - do { - let fileData = try fh.read(upToCount: 4 * 1024) - if fileData == nil || fileData?.isEmpty == true { - print("Upload: Finished data fork") - self.stage = .fileResourceForkHeader - try? fh.close() - self.fileHandle = nil - fallthrough - } - - print("Upload: Sending data Fork \(String(describing: fileData?.count))") - self.sendFileData(fileData!) - } - catch { - self.invalidate() - print("Upload: Error reading data fork", error) - return - } - - case .fileResourceForkHeader: - guard self.resourceForkSize > 0, - let resourceURL = self.fileResourceURL else { - print("Upload: Skipping resource fork header") - self.stage = .fileComplete - fallthrough - } - - print("Upload: Sending resource fork header") - guard let fh = try? FileHandle(forReadingFrom: resourceURL) else { - print("Upload: Error reading resource fork") - self.invalidate() - return - } - - let header = HotlineFileForkHeader(type: HotlineFileForkType.resource.rawValue, dataSize: self.resourceForkSize) - - self.fileHandle = fh - self.stage = .fileResourceFork - self.sendFileData(header.data()) - - case .fileResourceFork: - guard self.resourceForkSize > 0, - let fh = self.fileHandle else { - print("Upload: Resource fork empty, skipping to completion") - self.stage = .fileComplete - try? self.fileHandle?.close() - self.fileHandle = nil - fallthrough - } - - do { - let resourceData = try fh.read(upToCount: 4 * 1024) - if resourceData == nil || resourceData?.isEmpty == true { - print("Upload: Finished resource fork") - self.stage = .fileComplete - try? self.fileHandle?.close() - self.fileHandle = nil - fallthrough - } - - print("Upload: Sending resource fork \(String(describing: resourceData?.count))") - self.sendFileData(resourceData!) - } - catch { - self.invalidate() - print("Upload: Error reading resource fork", error) - return - } - break - - case .fileComplete: - print("Upload: Complete!") - self.sendComplete() - } - } -} - -// MARK: - - -class HotlineFilePreviewClient: HotlineTransferClient { - let serverAddress: NWEndpoint.Host - let serverPort: NWEndpoint.Port - let referenceNumber: UInt32 - let referenceDataSize: UInt32 - - weak var delegate: HotlineFilePreviewClientDelegate? = nil - - var status: HotlineTransferStatus = .unconnected { - didSet { - DispatchQueue.main.async { - self.delegate?.hotlineTransferStatusChanged(client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0) - } - } - } - - private var downloadTask: Task? - - init(address: String, port: UInt16, reference: UInt32, size: UInt32) { - self.serverAddress = NWEndpoint.Host(address) - self.serverPort = NWEndpoint.Port(rawValue: port + 1)! - self.referenceNumber = reference - self.referenceDataSize = size - } - - deinit { - self.downloadTask?.cancel() - } - - func start() { - guard status == .unconnected else { - return - } - - self.downloadTask = Task { - await self.download() - } - } - - func cancel() { - self.downloadTask?.cancel() - self.downloadTask = nil - self.delegate = nil - - print("HotlineFilePreviewClient: Cancelled preview transfer") - } - - private func download() async { - self.status = .connecting - - do { - // Connect to file transfer server (already includes +1 in serverPort from init) - let socket = try await NetSocketNew.connect( - host: self.serverAddress, - port: self.serverPort, - tls: .disabled - ) - defer { Task { await socket.close() } } - - self.status = .connected - - // Send magic header - let headerData = Data(endian: .big) { - "HTXF".fourCharCode() - self.referenceNumber - UInt32.zero - UInt32.zero - } - try await socket.write(headerData) - - self.status = .progress(0.0) - - // Download file data with progress updates - let fileData = try await socket.read(Int(self.referenceDataSize)) { current, total in - self.status = .progress(Double(current) / Double(total)) - } - - print("HotlineFilePreviewClient: Complete") - status = .completed - - // Notify delegate on main thread - let reference = self.referenceNumber - await MainActor.run { - self.delegate?.hotlineFilePreviewComplete(client: self, reference: reference, data: fileData) - } - - } catch is CancellationError { - // Already handled in cancel() - return - } catch { - print("HotlineFilePreviewClient: Download failed: \(error)") - - if self.status == .connecting { - self.status = .failed(.failedToConnect) - } else { - self.status = .failed(.failedToDownload) - } - } - } -} - -// MARK: - - -class HotlineFileDownloadClient: HotlineTransferClient { - let serverAddress: NWEndpoint.Host - let serverPort: NWEndpoint.Port - let referenceNumber: UInt32 - - private var connection: NWConnection? - private var transferStage: HotlineFileTransferStage = .fileHeader - - weak var delegate: HotlineFileDownloadClientDelegate? = nil - - var status: HotlineTransferStatus = .unconnected { - didSet { - DispatchQueue.main.async { - self.delegate?.hotlineTransferStatusChanged(client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0) - } - } - } - - private let referenceDataSize: UInt32 - private var fileBytes = Data() - private var fileResourceBytes = Data() - - private var fileHeader: HotlineFileHeader? = nil - private var fileCurrentForkHeader: HotlineFileForkHeader? = nil - private var fileCurrentForkBytesLeft: Int = 0 - private var fileInfoFork: HotlineFileInfoFork? = nil - private var fileHandle: FileHandle? = nil - private var filePath: String? = nil - private var fileBytesTransferred: Int = 0 - private var fileProgress: Progress - - init(address: String, port: UInt16, reference: UInt32, size: UInt32) { - self.serverAddress = NWEndpoint.Host(address) - self.serverPort = NWEndpoint.Port(rawValue: port + 1)! - self.referenceNumber = reference - self.referenceDataSize = size - self.transferStage = .fileHeader - self.fileProgress = Progress(totalUnitCount: Int64(size)) - } - - deinit { - self.invalidate() - } - - func start() { - guard self.status == .unconnected else { - return - } - - self.filePath = nil - self.connect() - } - - func start(to fileURL: URL) { - guard self.status == .unconnected else { - return - } - - self.filePath = fileURL.path - self.connect() - } - - func cancel() { - self.delegate = nil - - if self.status == .unconnected { - return - } - - // Close file before we try to potentionally delete it. - if let fh = self.fileHandle { - try? fh.close() - self.fileHandle = nil - } - - if let downloadPath = self.filePath { - print("HotlineFileClient: Deleting file fragment at", downloadPath) - if FileManager.default.isDeletableFile(atPath: downloadPath) { - try? FileManager.default.removeItem(atPath: downloadPath) - } - self.filePath = nil - } - - self.invalidate() - - print("HotlineFileClient: Cancelled transfer") - } - - private func connect() { - self.connection = NWConnection(host: self.serverAddress, port: self.serverPort, using: .tcp) - self.connection?.stateUpdateHandler = { [weak self] (newState: NWConnection.State) in - switch newState { - case .ready: - self?.status = .connected - self?.sendMagic() - case .waiting(let err): - print("HotlineFileClient: Waiting", err) - case .cancelled: - print("HotlineFileClient: Cancelled") - self?.invalidate() - case .failed(let err): - print("HotlineFileClient: Connection error \(err)") - switch self?.status { - case .connecting: - print("HotlineFileClient: Failed to connect to file transfer server.") - self?.invalidate() - self?.status = .failed(.failedToConnect) - case .connected, .progress(_): - print("HotlineFileClient: Failed to finish transfer.") - self?.invalidate() - self?.status = .failed(.failedToDownload) - default: - break - } - default: - return - } - } - - self.status = .connecting - self.connection?.start(queue: .global()) - } - - func invalidate() { - if let c = self.connection { - c.stateUpdateHandler = nil - c.cancel() - - self.connection = nil - } - - self.fileBytes = Data() - - if let fh = self.fileHandle { - try? fh.close() - self.fileHandle = nil - } - - self.fileProgress.unpublish() - } - - private func sendMagic() { - guard let c = connection, self.status == .connected else { - self.invalidate() - print("HotlineFileClient: invalid connection to send header.") - return - } - - let headerData = Data(endian: .big) { - "HTXF".fourCharCode() - self.referenceNumber - UInt32.zero - UInt32.zero - } - - c.send(content: headerData, completion: .contentProcessed { [weak self] (error) in - guard let self = self else { - return - } - - guard error == nil else { - self.status = .failed(.failedToConnect) - self.invalidate() - return - } - - self.status = .progress(0.0) - self.receiveFile() - }) - } - - private func receiveFile() { - guard let c = self.connection else { - return - } - - c.receive(minimumIncompleteLength: 1, maximumLength: Int(UInt16.max)) { [weak self] (data, context, isComplete, error) in - guard let self = self else { - return - } - - guard error == nil else { - self.status = .failed(.failedToDownload) - self.invalidate() - return - } - - if let newData = data, !newData.isEmpty { - self.fileBytesTransferred += newData.count - self.fileBytes.append(newData) - self.fileProgress.completedUnitCount = Int64(self.fileBytesTransferred) - self.status = .progress(Double(self.fileBytesTransferred) / Double(self.referenceDataSize)) - } - - // See if we need header data still. - var keepProcessing = false - repeat { - keepProcessing = false - - switch self.transferStage { - case .fileHeader: - if let header = HotlineFileHeader(from: self.fileBytes) { - self.fileBytes.removeSubrange(0..= infoForkDataSize { - let infoForkData = self.fileBytes.subdata(in: 0.. 0 { - if let f = self.fileHandle { - do { - var dataToWrite = self.fileBytes - - if dataToWrite.count >= self.fileCurrentForkBytesLeft { - dataToWrite = self.fileBytes.subdata(in: 0.. 0 { - var dataToWrite = self.fileBytes - - if dataToWrite.count >= self.fileCurrentForkBytesLeft { - dataToWrite = self.fileBytes.subdata(in: 0.. 0 { - var dataToWrite = self.fileBytes - - if dataToWrite.count >= self.fileCurrentForkBytesLeft { - dataToWrite = self.fileBytes.subdata(in: 0.. 0 { - let _ = self.writeResourceFork() - self.fileResourceBytes = Data() - } - - self.status = .completed - - if let downloadPath = self.filePath { - DispatchQueue.main.sync { - print("POSTING DOWNLOAD FILE FINISHED", downloadPath) - - var downloadURL = URL(filePath: downloadPath) - downloadURL.resolveSymlinksInPath() - print("FINAL PATH", downloadURL.path) - - self.delegate?.hotlineFileDownloadComplete(client: self, reference: self.referenceNumber, at: downloadURL) - -#if os(macOS) - // Bounce dock icon when download completes. Weird this is the only API to do so. - DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path) -#endif - } - } - } - } - } - - private func writeResourceFork() -> Bool { - guard let filePath = self.filePath else { - return false - } - - var resolvedFileURL = URL(filePath: filePath) - resolvedFileURL.resolveSymlinksInPath() - - let resourceFilePath = resolvedFileURL.appendingPathComponent("..namedfork/rsrc") - - do { - try self.fileResourceBytes.write(to: resourceFilePath) - } - catch { - return false - } - - return true - } - - private func prepareDownloadFile(name: String) -> Bool { - var filePath: String - - if self.filePath != nil { - filePath = self.filePath! - } - else { - let folderURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0] - filePath = folderURL.generateUniqueFilePath(filename: name) - } - - var fileAttributes: [FileAttributeKey: Any] = [:] - if let creatorCode = self.fileInfoFork?.creator { - fileAttributes[.hfsCreatorCode] = creatorCode as NSNumber - } - if let typeCode = self.fileInfoFork?.type { - fileAttributes[.hfsTypeCode] = typeCode as NSNumber - } - if let createdDate = self.fileInfoFork?.createdDate { - fileAttributes[.creationDate] = createdDate as NSDate - } - if let modifiedDate = self.fileInfoFork?.modifiedDate { - fileAttributes[.modificationDate] = modifiedDate as NSDate - } - if let comment = self.fileInfoFork?.comment { - if let commentPlistData = try? PropertyListSerialization.data(fromPropertyList: comment, format: .binary, options: 0) { - fileAttributes[FileAttributeKey(rawValue: "NSFileExtendedAttributes")] = [ - FileAttributeKey(rawValue: "com.apple.metadata:kMDItemFinderComment"): commentPlistData - ] - } - } - - if FileManager.default.createFile(atPath: filePath, contents: nil, attributes: fileAttributes) { - if let h = FileHandle(forWritingAtPath: filePath) { - self.filePath = filePath - self.fileHandle = h - self.fileProgress.fileURL = URL(filePath: filePath).resolvingSymlinksInPath() - self.fileProgress.fileOperationKind = .downloading - self.fileProgress.publish() - return true - } - } - - return false - } -} - -// MARK: - struct HotlineFileHeader { static let DataSize: Int = 4 + 2 + 16 + 2 @@ -1017,8 +75,8 @@ struct HotlineFileHeader { self.format = "FILP".fourCharCode() self.version = 1 - - let resourceURL = fileURL.appendingPathComponent("..namedfork/rsrc") + + let resourceURL = fileURL.urlForResourceFork() if FileManager.default.fileExists(atPath: resourceURL.path(percentEncoded: false)) { self.forkCount = 3 } @@ -1109,7 +167,7 @@ struct HotlineFileInfoFork { } self.platform = "AMAC".fourCharCode() - + if let hfsInfo = try? FileManager.default.getHFSTypeAndCreator(fileURL) { self.type = hfsInfo.hfsType self.creator = hfsInfo.hfsCreator @@ -1118,23 +176,23 @@ struct HotlineFileInfoFork { self.type = 0 self.creator = 0 } - + self.flags = 0 self.platformFlags = 0 - + let dateInfo = FileManager.default.getCreatedAndModifiedDates(fileURL) self.createdDate = dateInfo.createdDate self.modifiedDate = dateInfo.modifiedDate - + self.nameScript = 0 self.name = fileURL.lastPathComponent - + let fileComment = try? FileManager.default.getFinderComment(fileURL) self.comment = fileComment ?? "" - + self.headerSize = 0 } - + init?(from data: Data) { // Make sure we have at least enough data to read basic header data guard data.count >= HotlineFileInfoFork.BaseDataSize else { @@ -1227,828 +285,1836 @@ struct HotlineFileInfoFork { } } + +//protocol HotlineTransferDelegate: AnyObject { +// @MainActor func hotlineTransferStatusChanged(client: HotlineTransferClient, reference: UInt32, status: HotlineTransferStatus, timeRemaining: TimeInterval) +//} + +//protocol HotlineFileDownloadClientDelegate: HotlineTransferDelegate { +// @MainActor func hotlineFileDownloadReceivedInfo(client: HotlineFileDownloadClient, reference: UInt32, info: HotlineFileInfoFork) +// @MainActor func hotlineFileDownloadComplete(client: HotlineFileDownloadClient, reference: UInt32, at: URL) +//} + +//protocol HotlineFilePreviewClientDelegate: HotlineTransferDelegate { +// @MainActor func hotlineFilePreviewComplete(client: HotlineFilePreviewClient, reference: UInt32, data: Data) +//} + +//protocol HotlineFileUploadClientDelegate: HotlineTransferDelegate { +// @MainActor func hotlineFileUploadComplete(client: HotlineFileUploadClient, reference: UInt32) +//} + +//protocol HotlineFolderDownloadClientDelegate: HotlineTransferDelegate { +// @MainActor func hotlineFolderDownloadReceivedFileInfo(client: HotlineFolderDownloadClient, reference: UInt32, fileName: String, itemNumber: Int, totalItems: Int) +// @MainActor func hotlineFolderDownloadComplete(client: HotlineFolderDownloadClient, reference: UInt32, at: URL) +//} + +//enum HotlineFileTransferStage: Int { +// case fileHeader = 1 +// case fileForkHeader = 2 +// case fileInfoFork = 3 +// case fileDataFork = 4 +// case fileResourceFork = 5 +// case fileUnsupportedFork = 6 +//} + +//enum HotlineFileUploadStage: Int { +// case magic = 1 +// case fileHeader = 2 +// case fileInfoForkHeader = 3 +// case fileInfoFork = 4 +// case fileDataForkHeader = 5 +// case fileDataFork = 6 +// case fileResourceForkHeader = 7 +// case fileResourceFork = 8 +// case fileComplete = 9 +//} + +//enum HotlineFolderDownloadStage: Int { +// case itemHeader = 0 // Read 2-byte length + item header +// case waitingForFileSize = 1 // Read 4-byte file size before FILP +// case fileHeader = 2 +// case fileForkHeader = 3 +// case fileInfoFork = 4 +// case fileDataFork = 5 +// case fileResourceFork = 6 +// case fileUnsupportedFork = 7 +//} + + // MARK: - -class HotlineFolderDownloadClient: HotlineTransferClient { - let serverAddress: NWEndpoint.Host - let serverPort: NWEndpoint.Port - let referenceNumber: UInt32 - - private var connection: NWConnection? - private var transferStage: HotlineFolderDownloadStage = .fileHeader - - weak var delegate: HotlineFolderDownloadClientDelegate? = nil - - var status: HotlineTransferStatus = .unconnected { - didSet { - DispatchQueue.main.async { - self.delegate?.hotlineTransferStatusChanged(client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0) - } - } - } - - private let referenceDataSize: UInt32 - private let folderItemCount: Int - private var fileBytes = Data() - private var fileResourceBytes = Data() - - private var fileHeader: HotlineFileHeader? = nil - private var fileCurrentForkHeader: HotlineFileForkHeader? = nil - private var fileCurrentForkBytesLeft: Int = 0 - private var fileForksRemaining: Int = 0 - private var currentFileSize: UInt32 = 0 // Size of current file from server - private var currentFileBytesRead: Int = 0 // Bytes read for current file - private var fileInfoFork: HotlineFileInfoFork? = nil - private var fileHandle: FileHandle? = nil - private var currentFilePath: String? = nil - private var fileBytesTransferred: Int = 0 - private var fileProgress: Progress - - private var currentItemNumber: Int = 0 - private var completedItemCount: Int = 0 // Track actually completed files - private var folderPath: String? = nil - private var currentItemRelativePath: [String] = [] - private var currentFileName: String? = nil - - private let FILP_MAGIC: UInt32 = "FILP".fourCharCode() - - init(address: String, port: UInt16, reference: UInt32, size: UInt32, itemCount: Int) { - self.serverAddress = NWEndpoint.Host(address) - self.serverPort = NWEndpoint.Port(rawValue: port + 1)! - self.referenceNumber = reference - self.referenceDataSize = size - self.folderItemCount = itemCount - self.transferStage = .fileHeader - self.fileProgress = Progress(totalUnitCount: Int64(size)) - } - - deinit { - self.invalidate() - } - - func start() { - guard self.status == .unconnected else { - return - } - - self.folderPath = nil - self.connect() - } - - func start(to folderURL: URL) { - print("HotlineFolderDownloadClient: start(to:) called with path: \(folderURL.path)") - guard self.status == .unconnected else { - print("HotlineFolderDownloadClient: Already connected, status: \(self.status)") - return - } - - self.folderPath = folderURL.path - print("HotlineFolderDownloadClient: Calling connect()") - self.connect() - } - - func cancel() { - self.delegate = nil - - if self.status == .unconnected { - return - } - - // Close file before we try to potentially delete it. - if let fh = self.fileHandle { - try? fh.close() - self.fileHandle = nil - } - - if let downloadPath = self.folderPath { - print("HotlineFolderDownloadClient: Deleting folder fragment at", downloadPath) - if FileManager.default.isDeletableFile(atPath: downloadPath) { - try? FileManager.default.removeItem(atPath: downloadPath) - } - self.folderPath = nil - } - - self.invalidate() - - print("HotlineFolderDownloadClient: Cancelled transfer") - } - - private func connect() { - print("HotlineFolderDownloadClient: connect() called, connecting to \(self.serverAddress):\(self.serverPort)") - self.connection = NWConnection(host: self.serverAddress, port: self.serverPort, using: .tcp) - print("HotlineFolderDownloadClient: NWConnection created") - self.connection?.stateUpdateHandler = { [weak self] (newState: NWConnection.State) in - switch newState { - case .ready: - print("HotlineFolderDownloadClient: Connection ready!") - self?.status = .connected - self?.sendMagic() - case .waiting(let err): - print("HotlineFolderDownloadClient: Waiting", err) - case .cancelled: - print("HotlineFolderDownloadClient: Cancelled") - self?.invalidate() - case .failed(let err): - print("HotlineFolderDownloadClient: Connection error \(err)") - switch self?.status { - case .connecting: - print("HotlineFolderDownloadClient: Failed to connect to file transfer server.") - self?.invalidate() - self?.status = .failed(.failedToConnect) - case .connected, .progress(_): - print("HotlineFolderDownloadClient: Failed to finish transfer.") - self?.invalidate() - self?.status = .failed(.failedToDownload) - default: - break - } - default: - return - } - } - - self.status = .connecting - self.connection?.start(queue: .global()) - } - - func invalidate() { - if let c = self.connection { - c.stateUpdateHandler = nil - c.cancel() - - self.connection = nil - } - - self.fileBytes = Data() - - if let fh = self.fileHandle { - try? fh.close() - self.fileHandle = nil - } - - self.fileProgress.unpublish() - } - - private func parseItemHeaderPath(_ headerData: Data) -> (type: UInt16, components: [String])? { - // Need at least: type(2) + count(2) - guard headerData.count >= 4, - let type = headerData.readUInt16(at: 0), - let count = headerData.readUInt16(at: 2) else { return nil } - - var ofs = 4 - var comps: [String] = [] - for _ in 0..= ofs + 3 else { return nil } - // per Hotline path encoding: reserved(2) then nameLen(1) then name - ofs += 2 // reserved == 0 - let nameLen = Int(headerData.readUInt8(at: ofs)!) - ofs += 1 - guard headerData.count >= ofs + nameLen else { return nil } - let nameData = headerData.subdata(in: ofs..<(ofs + nameLen)) - ofs += nameLen - - let name = String(data: nameData, encoding: .macOSRoman) - ?? String(data: nameData, encoding: .utf8) - ?? "" - comps.append(name) - } - return (type, comps) - } - - /// Find and align the buffer to the start of a FILP header. - /// Returns the number of bytes dropped. - @discardableResult - private func alignBufferToFILP() -> Int { - // We need at least the 4-byte magic to try - guard self.fileBytes.count >= 4 else { return 0 } - let magicData = Data([0x46, 0x49, 0x4C, 0x50]) // "FILP" - if self.fileBytes.starts(with: magicData) { return 0 } - - if let r = self.fileBytes.firstRange(of: magicData) { - let toDrop = r.lowerBound - if toDrop > 0 { - print("HotlineFolderDownloadClient: Resync — dropping \(toDrop) stray bytes before FILP") - self.fileBytes.removeSubrange(0..= 2 else { break } - let headerLen = Int(self.fileBytes.readUInt16(at: 0)!) - guard self.fileBytes.count >= 2 + headerLen else { break } - - let headerData = self.fileBytes.subdata(in: 2..<(2 + headerLen)) - - guard let parsed = parseItemHeaderPath(headerData) else { - print("HotlineFolderDownloadClient: Invalid item header; waiting for more data") - break - } - - // Consume the header from the buffer - self.fileBytes.removeSubrange(0..<(2 + headerLen)) - - let itemType = parsed.type - let comps = parsed.components - let joinedPath = comps.joined(separator: "/") - print("HotlineFolderDownloadClient: item type=\(itemType) path=\(joinedPath)") - - guard !comps.isEmpty else { - print("HotlineFolderDownloadClient: Empty path components for item type \(itemType); requesting next item") - self.sendAction(.nextFile) - keepProcessing = !self.fileBytes.isEmpty - break - } - - if itemType == 1 { - // Folder entries: create the directory locally and continue. - self.currentItemRelativePath = comps - - if let base = self.folderPath { - var dir = base - for c in comps { dir = (dir as NSString).appendingPathComponent(c) } - do { - try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) - print("HotlineFolderDownloadClient: Created folder at \(dir)") - } catch { - print("HotlineFolderDownloadClient: Failed to create subfolder: \(error)") - } - } - - self.completedItemCount += 1 - - if self.completedItemCount >= self.folderItemCount { - self.handleAllItemsDownloaded() - return - } - - self.sendAction(.nextFile) - keepProcessing = !self.fileBytes.isEmpty - break - } - else if itemType == 0 { - // File entries include the full path; split parent components from filename. - self.currentItemRelativePath = Array(comps.dropLast()) - self.currentFileName = comps.last - - self.transferStage = .waitingForFileSize - print("HotlineFolderDownloadClient: Requesting file download for '\(self.currentFileName ?? "?")'") - self.sendAction(.sendFile) - - if self.fileBytes.count >= 4 { - keepProcessing = true - } - break - } - else { - print("HotlineFolderDownloadClient: Unknown item type \(itemType); skipping") - self.sendAction(.nextFile) - keepProcessing = !self.fileBytes.isEmpty - break - } - - case .waitingForFileSize: - // Read 4-byte file size that comes before FILP in folder mode - guard self.fileBytes.count >= 4 else { break } - let fileSize = self.fileBytes.readUInt32(at: 0)! - self.fileBytes.removeSubrange(0..<4) - - print("HotlineFolderDownloadClient: File size: \(fileSize) bytes") - - self.currentFileSize = fileSize - self.currentFileBytesRead = 0 - - // Align to FILP boundary before decoding the file header - let dropped = self.alignBufferToFILP() - if dropped > 0 { - // These bytes were in the stream for this file but not part of FILP. - // Keep byte-accounting consistent by shrinking the expected size. - if self.currentFileSize >= UInt32(dropped) { - self.currentFileSize -= UInt32(dropped) - } else { - // Defensive: if weird, treat as zero-size to avoid underflow - self.currentFileSize = 0 - } - } - - self.transferStage = .fileHeader - keepProcessing = true - - case .fileHeader: - // Make sure we're actually at a FILP header - if self.fileBytes.count >= HotlineFileHeader.DataSize { - // If the 4-byte magic doesn't match, try one more resync here - if self.fileBytes.readUInt32(at: 0)! != FILP_MAGIC { - let dropped = self.alignBufferToFILP() - if dropped > 0 { - if self.currentFileSize >= UInt32(dropped) { self.currentFileSize -= UInt32(dropped) } - } - // If still not aligned or not enough bytes, wait for more data - guard self.fileBytes.count >= HotlineFileHeader.DataSize, - self.fileBytes.readUInt32(at: 0)! == FILP_MAGIC else { break } - } - - if let header = HotlineFileHeader(from: self.fileBytes) { - // Sanity gate: version and fork count - if header.format != FILP_MAGIC || header.version == 0 || header.forkCount > 3 { - print("HotlineFolderDownloadClient: Invalid FILP header (fmt=\(String(format:"0x%08X", header.format)), ver=\(header.version), forks=\(header.forkCount)). Resyncing.") - // Try resync and wait for more data - let dropped = self.alignBufferToFILP() - if dropped > 0 { if self.currentFileSize >= UInt32(dropped) { self.currentFileSize -= UInt32(dropped) } } - break - } - - self.fileBytes.removeSubrange(0..= self.folderItemCount { - self.handleAllItemsDownloaded() - return - } - - // More files to download - // Check if server has pipelined all remaining data (we've received more than expected total) - print("HotlineFolderDownloadClient: Completed \(self.completedItemCount)/\(self.folderItemCount) items, requesting next entry") - self.transferStage = .itemHeader - self.sendAction(.nextFile) - keepProcessing = !self.fileBytes.isEmpty - } - // File not complete - try to read next fork header - else if let forkHeader = HotlineFileForkHeader(from: self.fileBytes) { - // Quick validation: first fork must be INFO, and sizes must be plausible - let remainingForThisFile = max(0, Int(self.currentFileSize) - self.currentFileBytesRead) - let plausible = forkHeader.dataSize <= UInt32(remainingForThisFile) - let knownType = forkHeader.isInfoFork || forkHeader.isDataFork || forkHeader.isResourceFork - - if !plausible || (!knownType && self.currentFileBytesRead == HotlineFileHeader.DataSize) { - print("HotlineFolderDownloadClient: Implausible fork header (type=\(String(format:"0x%08X", forkHeader.forkType)), size=\(forkHeader.dataSize), remaining=\(remainingForThisFile)). Resyncing.") - let dropped = self.alignBufferToFILP() - if dropped > 0 { if self.currentFileSize >= UInt32(dropped) { self.currentFileSize -= UInt32(dropped) } } - break - } - - self.fileBytes.removeSubrange(0..= infoForkDataSize { - let infoForkData = self.fileBytes.subdata(in: 0.. 0 { - if let f = self.fileHandle { - do { - var dataToWrite = self.fileBytes - - if dataToWrite.count >= self.fileCurrentForkBytesLeft { - dataToWrite = self.fileBytes.subdata(in: 0.. 0 { - var dataToWrite = self.fileBytes - - if dataToWrite.count >= self.fileCurrentForkBytesLeft { - dataToWrite = self.fileBytes.subdata(in: 0.. 0 { - var dataToWrite = self.fileBytes - - if dataToWrite.count >= self.fileCurrentForkBytesLeft { - dataToWrite = self.fileBytes.subdata(in: 0.. 0 { - let _ = self.writeResourceFork() - self.fileResourceBytes = Data() - } - - self.fileCurrentForkHeader = nil - self.fileCurrentForkBytesLeft = 0 - self.fileForksRemaining = 0 - self.currentFileBytesRead = 0 - self.currentFileSize = 0 - self.currentFilePath = nil - self.fileInfoFork = nil - self.fileHeader = nil - self.currentFileName = nil - } - - private func handleAllItemsDownloaded() { - guard self.status != .completed else { - return - } - - print("HotlineFolderDownloadClient: All \(self.folderItemCount) items downloaded") - - self.invalidate() - self.status = .completed - - if let downloadPath = self.folderPath { - DispatchQueue.main.sync { - print("HotlineFolderDownloadClient: Folder download complete", downloadPath) - - var downloadURL = URL(filePath: downloadPath) - downloadURL.resolveSymlinksInPath() - - self.delegate?.hotlineFolderDownloadComplete(client: self, reference: self.referenceNumber, at: downloadURL) - -#if os(macOS) - DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path) -#endif - } - } - } - - private func writeResourceFork() -> Bool { - guard let filePath = self.currentFilePath else { - return false - } - - var resolvedFileURL = URL(filePath: filePath) - resolvedFileURL.resolveSymlinksInPath() - - let resourceFilePath = resolvedFileURL.appendingPathComponent("..namedfork/rsrc") - - do { - try self.fileResourceBytes.write(to: resourceFilePath) - } - catch { - return false - } - - return true - } - - private func prepareDownloadFile(name: String) -> Bool { - var filePath: String - - if self.folderPath != nil { - // Build the full path including subfolders - var fullPath = self.folderPath! - for component in self.currentItemRelativePath { - fullPath = (fullPath as NSString).appendingPathComponent(component) - } - - let folderURL = URL(filePath: fullPath) - - // Create folder if it doesn't exist - if !FileManager.default.fileExists(atPath: folderURL.path) { - do { - try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) - } - catch { - print("HotlineFolderDownloadClient: Failed to create folder", error) - return false - } - } - - filePath = folderURL.appendingPathComponent(name).path - print("HotlineFolderDownloadClient: Creating file at \(filePath)") - } - else { - let downloadsURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0] - filePath = downloadsURL.generateUniqueFilePath(filename: name) - } - - var fileAttributes: [FileAttributeKey: Any] = [:] - if let creatorCode = self.fileInfoFork?.creator { - fileAttributes[.hfsCreatorCode] = creatorCode as NSNumber - } - if let typeCode = self.fileInfoFork?.type { - fileAttributes[.hfsTypeCode] = typeCode as NSNumber - } - if let createdDate = self.fileInfoFork?.createdDate { - fileAttributes[.creationDate] = createdDate as NSDate - } - if let modifiedDate = self.fileInfoFork?.modifiedDate { - fileAttributes[.modificationDate] = modifiedDate as NSDate - } - if let comment = self.fileInfoFork?.comment { - if let commentPlistData = try? PropertyListSerialization.data(fromPropertyList: comment, format: .binary, options: 0) { - fileAttributes[FileAttributeKey(rawValue: "NSFileExtendedAttributes")] = [ - FileAttributeKey(rawValue: "com.apple.metadata:kMDItemFinderComment"): commentPlistData - ] - } - } - - if FileManager.default.createFile(atPath: filePath, contents: nil, attributes: fileAttributes) { - if let h = FileHandle(forWritingAtPath: filePath) { - self.currentFilePath = filePath - self.fileHandle = h - - // Only set file progress on first file - if self.currentItemNumber == 1 && self.folderPath != nil { - self.fileProgress.fileURL = URL(filePath: self.folderPath!).resolvingSymlinksInPath() - self.fileProgress.fileOperationKind = .downloading - self.fileProgress.publish() - } - - return true - } - } - - return false - } -} +//class HotlineFileUploadClient: HotlineTransferClient { +// let serverAddress: NWEndpoint.Host +// let serverPort: NWEndpoint.Port +// let referenceNumber: UInt32 +// +// weak var delegate: HotlineFileUploadClientDelegate? = nil +// +// private var connection: NWConnection? +// private var stage: HotlineFileUploadStage = .magic +// private var payloadSize: UInt32 = 0 +// private let fileURL: URL +// private let fileResourceURL: URL? +// private var fileHandle: FileHandle? = nil +// private var bytesSent: Int = 0 +// private let infoForkData: Data +// private let dataForkSize: UInt32 +// private let resourceForkSize: UInt32 +// +// var status: HotlineTransferStatus = .unconnected { +// didSet { +// DispatchQueue.main.async { +// self.delegate?.hotlineTransferStatusChanged(client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0) +// } +// } +// } +// +// init?(upload fileURL: URL, address: String, port: UInt16, reference: UInt32) { +// guard let payloadSize = FileManager.default.getFlattenedFileSize(fileURL) else { +// return nil +// } +// +// guard let infoFork = HotlineFileInfoFork(file: fileURL) else { +// return nil +// } +// +// guard let forkSizes = try? FileManager.default.getFileForkSizes(fileURL) else { +// return nil +// } +// +// self.serverAddress = NWEndpoint.Host(address) +// self.serverPort = NWEndpoint.Port(rawValue: port + 1)! +// self.referenceNumber = reference +// self.stage = .magic +// self.payloadSize = UInt32(payloadSize) +// self.fileURL = fileURL +// self.infoForkData = infoFork.data() +// self.dataForkSize = forkSizes.dataForkSize +// self.resourceForkSize = forkSizes.resourceForkSize +// if forkSizes.resourceForkSize > 0 { +// self.fileResourceURL = fileURL.urlForResourceFork() +// } +// else { +// self.fileResourceURL = nil +// } +// } +// +// deinit { +// self.invalidate() +// } +// +// func start() { +// guard self.status == .unconnected else { +// return +// } +// +// let _ = self.fileURL.startAccessingSecurityScopedResource() +// let _ = self.fileResourceURL?.stopAccessingSecurityScopedResource() +// +// self.bytesSent = 0 +// self.connect() +// } +// +// func cancel() { +// self.delegate = nil +// +// if self.status == .unconnected { +// return +// } +// +// self.invalidate() +// +// print("HotlineFileUploadClient: Cancelled upload") +// } +// +// private func connect() { +// self.connection = NWConnection(host: self.serverAddress, port: self.serverPort, using: .tcp) +// self.connection?.stateUpdateHandler = { [weak self] (newState: NWConnection.State) in +// switch newState { +// case .ready: +// self?.status = .connected +// self?.stage = .magic +// self?.send() +// case .waiting(let err): +// print("HotlineFileClient: Waiting", err) +// case .cancelled: +// print("HotlineFileClient: Cancelled") +// self?.invalidate() +// case .failed(let err): +// print("HotlineFileClient: Connection error \(err)") +// switch self?.status { +// case .connecting: +// print("HotlineFileClient: Failed to connect to file transfer server.") +// self?.invalidate() +// self?.status = .failed(.failedToConnect) +// case .connected, .progress(_): +// print("HotlineFileClient: Failed to finish transfer.") +// self?.invalidate() +// self?.status = .failed(.failedToUpload) +// case .completing: +// print("HotlineFileClient: Completed.") +// self?.invalidate() +// self?.status = .completed +// DispatchQueue.main.async { [weak self] in +// if let s = self { +// s.delegate?.hotlineFileUploadComplete(client: s, reference: s.referenceNumber) +// } +// } +// case .completed: +// self?.invalidate() +// default: +// break +// } +// default: +// return +// } +// } +// +// self.status = .connecting +// self.connection?.start(queue: .global()) +// } +// +// private func invalidate() { +// if let c = self.connection { +// c.stateUpdateHandler = nil +// c.cancel() +// +// self.connection = nil +// } +// +// self.stage = .magic +// +// if let fh = self.fileHandle { +// try? fh.close() +// self.fileHandle = nil +// } +// +// self.fileURL.stopAccessingSecurityScopedResource() +// self.fileResourceURL?.stopAccessingSecurityScopedResource() +// } +// +// private func sendComplete() { +// guard let c = self.connection else { +// self.invalidate() +// print("HotlineFileUploadClient: invalid connection to send data.") +// return +// } +// +// self.status = .completing +// +// c.send(content: nil, contentContext: .finalMessage, completion: .contentProcessed({ error in +// })) +// } +// +// private func sendFileData(_ data: Data) { +// guard let c = self.connection else { +// self.invalidate() +// +// print("HotlineFileUploadClient: invalid connection to send data.") +// return +// } +// +// let dataSent: Int = data.count +// c.send(content: data, completion: .contentProcessed({ [weak self] error in +// guard let client = self, +// error == nil else { +// self?.status = .failed(.failedToConnect) +// self?.invalidate() +// return +// } +// +// +// client.bytesSent += dataSent +// client.status = .progress(Double(client.bytesSent) / Double(client.payloadSize)) +// +// client.send() +// })) +// } +// +// private func send() { +// guard let _ = self.connection else { +// self.invalidate() +// print("HotlineFileUploadClient: Invalid connection to send.") +// return +// } +// +// switch self.stage { +// case .magic: +// print("Upload: Starting upload for \(self.fileURL)") +// print("Upload: Sending magic") +// self.status = .progress(0.0) +// +// let magicData = Data(endian: .big) { +// "HTXF".fourCharCode() +// self.referenceNumber +// self.payloadSize +// UInt32.zero +// } +// // var magicData = Data() +// // magicData.appendUInt32("HTXF".fourCharCode()) +// // magicData.appendUInt32(self.referenceNumber) +// // magicData.appendUInt32(self.payloadSize) +// // magicData.appendUInt32(0) +// self.stage = .fileHeader +// self.sendFileData(magicData) +// +// case .fileHeader: +// print("Upload: Sending file header") +// if let header = HotlineFileHeader(file: self.fileURL) { +// self.stage = .fileInfoForkHeader +// self.sendFileData(header.data()) +// } +// +// case .fileInfoForkHeader: +// print("Upload: Sending info fork header") +// let header = HotlineFileForkHeader(type: HotlineFileForkType.info.rawValue, dataSize: UInt32(self.infoForkData.count)) +// self.stage = .fileInfoFork +// self.sendFileData(header.data()) +// +// case .fileInfoFork: +// print("Upload: Sending info fork") +// self.stage = .fileDataForkHeader +// self.sendFileData(self.infoForkData) +// +// case .fileDataForkHeader: +// guard self.dataForkSize > 0 else { +// print("Upload: Data fork empty, skipping to resource fork") +// self.stage = .fileResourceForkHeader +// fallthrough +// } +// +// do { +// let fh = try FileHandle(forReadingFrom: self.fileURL) +// self.fileHandle = fh +// self.stage = .fileDataFork +// +// let header = HotlineFileForkHeader(type: HotlineFileForkType.data.rawValue, dataSize: self.dataForkSize) +// +// print("Upload: Sending data fork header \(self.dataForkSize)") +// self.sendFileData(header.data()) +// } +// catch { +// print("Upload: Error opening data fork", error) +// self.invalidate() +// return +// } +// +// case .fileDataFork: +// guard self.dataForkSize > 0, +// let fh = self.fileHandle else { +// print("Upload: Data fork empty, skipping to resource fork") +// self.stage = .fileResourceForkHeader +// try? self.fileHandle?.close() +// self.fileHandle = nil +// fallthrough +// } +// +// do { +// let fileData = try fh.read(upToCount: 4 * 1024) +// if fileData == nil || fileData?.isEmpty == true { +// print("Upload: Finished data fork") +// self.stage = .fileResourceForkHeader +// try? fh.close() +// self.fileHandle = nil +// fallthrough +// } +// +// print("Upload: Sending data Fork \(String(describing: fileData?.count))") +// self.sendFileData(fileData!) +// } +// catch { +// self.invalidate() +// print("Upload: Error reading data fork", error) +// return +// } +// +// case .fileResourceForkHeader: +// guard self.resourceForkSize > 0, +// let resourceURL = self.fileResourceURL else { +// print("Upload: Skipping resource fork header") +// self.stage = .fileComplete +// fallthrough +// } +// +// print("Upload: Sending resource fork header") +// guard let fh = try? FileHandle(forReadingFrom: resourceURL) else { +// print("Upload: Error reading resource fork") +// self.invalidate() +// return +// } +// +// let header = HotlineFileForkHeader(type: HotlineFileForkType.resource.rawValue, dataSize: self.resourceForkSize) +// +// self.fileHandle = fh +// self.stage = .fileResourceFork +// self.sendFileData(header.data()) +// +// case .fileResourceFork: +// guard self.resourceForkSize > 0, +// let fh = self.fileHandle else { +// print("Upload: Resource fork empty, skipping to completion") +// self.stage = .fileComplete +// try? self.fileHandle?.close() +// self.fileHandle = nil +// fallthrough +// } +// +// do { +// let resourceData = try fh.read(upToCount: 4 * 1024) +// if resourceData == nil || resourceData?.isEmpty == true { +// print("Upload: Finished resource fork") +// self.stage = .fileComplete +// try? self.fileHandle?.close() +// self.fileHandle = nil +// fallthrough +// } +// +// print("Upload: Sending resource fork \(String(describing: resourceData?.count))") +// self.sendFileData(resourceData!) +// } +// catch { +// self.invalidate() +// print("Upload: Error reading resource fork", error) +// return +// } +// break +// +// case .fileComplete: +// print("Upload: Complete!") +// self.sendComplete() +// } +// } +//} + +// MARK: - +// +//class HotlineFilePreviewClient: HotlineTransferClient { +// let serverAddress: NWEndpoint.Host +// let serverPort: NWEndpoint.Port +// let referenceNumber: UInt32 +// let referenceDataSize: UInt32 +// +// weak var delegate: HotlineFilePreviewClientDelegate? = nil +// +// var status: HotlineTransferStatus = .unconnected { +// didSet { +// DispatchQueue.main.async { +// self.delegate?.hotlineTransferStatusChanged(client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0) +// } +// } +// } +// +// private var downloadTask: Task? +// +// init(address: String, port: UInt16, reference: UInt32, size: UInt32) { +// self.serverAddress = NWEndpoint.Host(address) +// self.serverPort = NWEndpoint.Port(rawValue: port + 1)! +// self.referenceNumber = reference +// self.referenceDataSize = size +// } +// +// deinit { +// self.downloadTask?.cancel() +// } +// +// func start() { +// guard status == .unconnected else { +// return +// } +// +// self.downloadTask = Task { +// await self.download() +// } +// } +// +// func cancel() { +// self.downloadTask?.cancel() +// self.downloadTask = nil +// self.delegate = nil +// +// print("HotlineFilePreviewClient: Cancelled preview transfer") +// } +// +// private func download() async { +// await MainActor.run { self.status = .connecting } +// +// do { +// // Connect to file transfer server (already includes +1 in serverPort from init) +// let socket = try await NetSocketNew.connect( +// host: self.serverAddress, +// port: self.serverPort, +// tls: .disabled +// ) +// defer { Task { await socket.close() } } +// +// await MainActor.run { self.status = .connected } +// +// // Send magic header +// let headerData = Data(endian: .big) { +// "HTXF".fourCharCode() +// self.referenceNumber +// UInt32.zero +// UInt32.zero +// } +// try await socket.write(headerData) +// +// await MainActor.run { self.status = .progress(0.0) } +// +// // Download file data with progress updates +// let fileData = try await socket.read(Int(self.referenceDataSize)) { current, total in +// Task { @MainActor [weak self] in +// guard let self else { return } +// self.status = .progress(Double(current) / Double(total)) +// } +// } +// +// print("HotlineFilePreviewClient: Complete") +// await MainActor.run { self.status = .completed } +// +// // Notify delegate +// let reference = self.referenceNumber +// await MainActor.run { +// self.delegate?.hotlineFilePreviewComplete(client: self, reference: reference, data: fileData) +// } +// +// } catch is CancellationError { +// // Already handled in cancel() +// return +// } catch { +// print("HotlineFilePreviewClient: Download failed: \(error)") +// +// let failureStatus: HotlineTransferStatus = await MainActor.run { self.status } +// +// if failureStatus == .connecting { +// await MainActor.run { self.status = .failed(.failedToConnect) } +// } else { +// await MainActor.run { self.status = .failed(.failedToDownload) } +// } +// } +// } +// +// // status mutations are centralized via MainActor.run calls above +//} + +// MARK: - Async Preview Client (New) + +//enum HotlineFilePreviewClientNew { +// typealias ProgressHandler = @Sendable (NetSocketNew.FileProgress) -> Void +// +// struct Configuration { +// /// Chunk size used when reading the preview data stream. +// var chunkSize: Int = 256 * 1024 +// } +// +// /// Download preview data directly from the transfer server. +// /// - Parameters: +// /// - address: Hostname/IP of the Hotline server (preview runs on port+1). +// /// - port: Hotline base port. +// /// - reference: Transfer reference number returned by the server. +// /// - size: Expected payload size in bytes. +// /// - configuration: Optional tuning knobs (currently chunk size). +// /// - progress: Optional callback invoked as bytes stream in. +// /// - Returns: Raw preview data. +// static func download( +// address: String, +// port: UInt16, +// reference: UInt32, +// size: UInt32, +// configuration: Configuration = .init(), +// progress: ProgressHandler? = nil +// ) async throws -> Data { +// let host = NWEndpoint.Host(address) +// guard let transferPort = NWEndpoint.Port(rawValue: port + 1) else { +// throw NetSocketError.invalidPort +// } +// +// let socket = try await NetSocketNew.connect(host: host, port: transferPort, tls: .disabled) +// defer { Task { await socket.close() } } +// +// let header = Data(endian: .big) { +// "HTXF".fourCharCode() +// reference +// UInt32.zero +// UInt32.zero +// } +// +// try await socket.write(header) +// +// guard size > 0 else { +// return Data() +// } +// +// return try await socket.read(Int(size), chunkSize: configuration.chunkSize) { current, total in +// guard let progress else { return } +// let info = NetSocketNew.FileProgress(sent: current, total: total) +// progress(info) +// } +// } +//} + +// MARK: - + +//class HotlineFileDownloadClient: HotlineTransferClient { +// let serverAddress: NWEndpoint.Host +// let serverPort: NWEndpoint.Port +// let referenceNumber: UInt32 +// +// private var connection: NWConnection? +// private var transferStage: HotlineFileTransferStage = .fileHeader +// +// weak var delegate: HotlineFileDownloadClientDelegate? = nil +// +// var status: HotlineTransferStatus = .unconnected { +// didSet { +// DispatchQueue.main.async { +// self.delegate?.hotlineTransferStatusChanged(client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0) +// } +// } +// } +// +// private let referenceDataSize: UInt32 +// private var fileBytes = Data() +// private var fileResourceBytes = Data() +// +// private var fileHeader: HotlineFileHeader? = nil +// private var fileCurrentForkHeader: HotlineFileForkHeader? = nil +// private var fileCurrentForkBytesLeft: Int = 0 +// private var fileInfoFork: HotlineFileInfoFork? = nil +// private var fileHandle: FileHandle? = nil +// private var filePath: String? = nil +// private var fileBytesTransferred: Int = 0 +// private var fileProgress: Progress +// +// init(address: String, port: UInt16, reference: UInt32, size: UInt32) { +// self.serverAddress = NWEndpoint.Host(address) +// self.serverPort = NWEndpoint.Port(rawValue: port + 1)! +// self.referenceNumber = reference +// self.referenceDataSize = size +// self.transferStage = .fileHeader +// self.fileProgress = Progress(totalUnitCount: Int64(size)) +// } +// +// deinit { +// self.invalidate() +// } +// +// func start() { +// guard self.status == .unconnected else { +// return +// } +// +// self.filePath = nil +// self.connect() +// } +// +// func start(to fileURL: URL) { +// guard self.status == .unconnected else { +// return +// } +// +// self.filePath = fileURL.path +// self.connect() +// } +// +// func cancel() { +// self.delegate = nil +// +// if self.status == .unconnected { +// return +// } +// +// // Close file before we try to potentionally delete it. +// if let fh = self.fileHandle { +// try? fh.close() +// self.fileHandle = nil +// } +// +// if let downloadPath = self.filePath { +// print("HotlineFileClient: Deleting file fragment at", downloadPath) +// if FileManager.default.isDeletableFile(atPath: downloadPath) { +// try? FileManager.default.removeItem(atPath: downloadPath) +// } +// self.filePath = nil +// } +// +// self.invalidate() +// +// print("HotlineFileClient: Cancelled transfer") +// } +// +// private func connect() { +// self.connection = NWConnection(host: self.serverAddress, port: self.serverPort, using: .tcp) +// self.connection?.stateUpdateHandler = { [weak self] (newState: NWConnection.State) in +// switch newState { +// case .ready: +// self?.status = .connected +// self?.sendMagic() +// case .waiting(let err): +// print("HotlineFileClient: Waiting", err) +// case .cancelled: +// print("HotlineFileClient: Cancelled") +// self?.invalidate() +// case .failed(let err): +// print("HotlineFileClient: Connection error \(err)") +// switch self?.status { +// case .connecting: +// print("HotlineFileClient: Failed to connect to file transfer server.") +// self?.invalidate() +// self?.status = .failed(.failedToConnect) +// case .connected, .progress(_): +// print("HotlineFileClient: Failed to finish transfer.") +// self?.invalidate() +// self?.status = .failed(.failedToDownload) +// default: +// break +// } +// default: +// return +// } +// } +// +// self.status = .connecting +// self.connection?.start(queue: .global()) +// } +// +// func invalidate() { +// if let c = self.connection { +// c.stateUpdateHandler = nil +// c.cancel() +// +// self.connection = nil +// } +// +// self.fileBytes = Data() +// +// if let fh = self.fileHandle { +// try? fh.close() +// self.fileHandle = nil +// } +// +// self.fileProgress.unpublish() +// } +// +// private func sendMagic() { +// guard let c = connection, self.status == .connected else { +// self.invalidate() +// print("HotlineFileClient: invalid connection to send header.") +// return +// } +// +// let headerData = Data(endian: .big) { +// "HTXF".fourCharCode() +// self.referenceNumber +// UInt32.zero +// UInt32.zero +// } +// +// c.send(content: headerData, completion: .contentProcessed { [weak self] (error) in +// guard let self = self else { +// return +// } +// +// guard error == nil else { +// self.status = .failed(.failedToConnect) +// self.invalidate() +// return +// } +// +// self.status = .progress(0.0) +// self.receiveFile() +// }) +// } +// +// private func receiveFile() { +// guard let c = self.connection else { +// return +// } +// +// c.receive(minimumIncompleteLength: 1, maximumLength: Int(UInt16.max)) { [weak self] (data, context, isComplete, error) in +// guard let self = self else { +// return +// } +// +// guard error == nil else { +// self.status = .failed(.failedToDownload) +// self.invalidate() +// return +// } +// +// if let newData = data, !newData.isEmpty { +// self.fileBytesTransferred += newData.count +// self.fileBytes.append(newData) +// self.fileProgress.completedUnitCount = Int64(self.fileBytesTransferred) +// self.status = .progress(Double(self.fileBytesTransferred) / Double(self.referenceDataSize)) +// } +// +// // See if we need header data still. +// var keepProcessing = false +// repeat { +// keepProcessing = false +// +// switch self.transferStage { +// case .fileHeader: +// if let header = HotlineFileHeader(from: self.fileBytes) { +// self.fileBytes.removeSubrange(0..= infoForkDataSize { +// let infoForkData = self.fileBytes.subdata(in: 0.. 0 { +// if let f = self.fileHandle { +// do { +// var dataToWrite = self.fileBytes +// +// if dataToWrite.count >= self.fileCurrentForkBytesLeft { +// dataToWrite = self.fileBytes.subdata(in: 0.. 0 { +// var dataToWrite = self.fileBytes +// +// if dataToWrite.count >= self.fileCurrentForkBytesLeft { +// dataToWrite = self.fileBytes.subdata(in: 0.. 0 { +// var dataToWrite = self.fileBytes +// +// if dataToWrite.count >= self.fileCurrentForkBytesLeft { +// dataToWrite = self.fileBytes.subdata(in: 0.. 0 { +// let _ = self.writeResourceFork() +// self.fileResourceBytes = Data() +// } +// +// self.status = .completed +// +// if let downloadPath = self.filePath { +// DispatchQueue.main.sync { +// print("POSTING DOWNLOAD FILE FINISHED", downloadPath) +// +// var downloadURL = URL(filePath: downloadPath) +// downloadURL.resolveSymlinksInPath() +// print("FINAL PATH", downloadURL.path) +// +// self.delegate?.hotlineFileDownloadComplete(client: self, reference: self.referenceNumber, at: downloadURL) +// +//#if os(macOS) +// // Bounce dock icon when download completes. Weird this is the only API to do so. +// DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path) +//#endif +// } +// } +// } +// } +// } +// +// private func writeResourceFork() -> Bool { +// guard let filePath = self.filePath else { +// return false +// } +// +// var resolvedFileURL = URL(filePath: filePath) +// resolvedFileURL.resolveSymlinksInPath() +// +// let resourceFilePath = resolvedFileURL.appendingPathComponent("..namedfork/rsrc") +// +// do { +// try self.fileResourceBytes.write(to: resourceFilePath) +// } +// catch { +// return false +// } +// +// return true +// } +// +// private func prepareDownloadFile(name: String) -> Bool { +// var filePath: String +// +// if self.filePath != nil { +// filePath = self.filePath! +// } +// else { +// let folderURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0] +// filePath = folderURL.generateUniqueFilePath(filename: name) +// } +// +// var fileAttributes: [FileAttributeKey: Any] = [:] +// if let creatorCode = self.fileInfoFork?.creator { +// fileAttributes[.hfsCreatorCode] = creatorCode as NSNumber +// } +// if let typeCode = self.fileInfoFork?.type { +// fileAttributes[.hfsTypeCode] = typeCode as NSNumber +// } +// if let createdDate = self.fileInfoFork?.createdDate { +// fileAttributes[.creationDate] = createdDate as NSDate +// } +// if let modifiedDate = self.fileInfoFork?.modifiedDate { +// fileAttributes[.modificationDate] = modifiedDate as NSDate +// } +// if let comment = self.fileInfoFork?.comment { +// if let commentPlistData = try? PropertyListSerialization.data(fromPropertyList: comment, format: .binary, options: 0) { +// fileAttributes[FileAttributeKey(rawValue: "NSFileExtendedAttributes")] = [ +// FileAttributeKey(rawValue: "com.apple.metadata:kMDItemFinderComment"): commentPlistData +// ] +// } +// } +// +// if FileManager.default.createFile(atPath: filePath, contents: nil, attributes: fileAttributes) { +// if let h = FileHandle(forWritingAtPath: filePath) { +// self.filePath = filePath +// self.fileHandle = h +// self.fileProgress.fileURL = URL(filePath: filePath).resolvingSymlinksInPath() +// self.fileProgress.fileOperationKind = .downloading +// self.fileProgress.publish() +// return true +// } +// } +// +// return false +// } +//} + +// MARK: - + + +// MARK: - + +//class HotlineFolderDownloadClient: HotlineTransferClient { +// let serverAddress: NWEndpoint.Host +// let serverPort: NWEndpoint.Port +// let referenceNumber: UInt32 +// +// private var connection: NWConnection? +// private var transferStage: HotlineFolderDownloadStage = .fileHeader +// +// weak var delegate: HotlineFolderDownloadClientDelegate? = nil +// +// var status: HotlineTransferStatus = .unconnected { +// didSet { +// DispatchQueue.main.async { +// self.delegate?.hotlineTransferStatusChanged(client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0) +// } +// } +// } +// +// private let referenceDataSize: UInt32 +// private let folderItemCount: Int +// private var fileBytes = Data() +// private var fileResourceBytes = Data() +// +// private var fileHeader: HotlineFileHeader? = nil +// private var fileCurrentForkHeader: HotlineFileForkHeader? = nil +// private var fileCurrentForkBytesLeft: Int = 0 +// private var fileForksRemaining: Int = 0 +// private var currentFileSize: UInt32 = 0 // Size of current file from server +// private var currentFileBytesRead: Int = 0 // Bytes read for current file +// private var fileInfoFork: HotlineFileInfoFork? = nil +// private var fileHandle: FileHandle? = nil +// private var currentFilePath: String? = nil +// private var fileBytesTransferred: Int = 0 +// private var fileProgress: Progress +// +// private var currentItemNumber: Int = 0 +// private var completedItemCount: Int = 0 // Track actually completed files +// private var folderPath: String? = nil +// private var currentItemRelativePath: [String] = [] +// private var currentFileName: String? = nil +// +// private let FILP_MAGIC: UInt32 = "FILP".fourCharCode() +// +// init(address: String, port: UInt16, reference: UInt32, size: UInt32, itemCount: Int) { +// self.serverAddress = NWEndpoint.Host(address) +// self.serverPort = NWEndpoint.Port(rawValue: port + 1)! +// self.referenceNumber = reference +// self.referenceDataSize = size +// self.folderItemCount = itemCount +// self.transferStage = .fileHeader +// self.fileProgress = Progress(totalUnitCount: Int64(size)) +// } +// +// deinit { +// self.invalidate() +// } +// +// func start() { +// guard self.status == .unconnected else { +// return +// } +// +// self.folderPath = nil +// self.connect() +// } +// +// func start(to folderURL: URL) { +// print("HotlineFolderDownloadClient: start(to:) called with path: \(folderURL.path)") +// guard self.status == .unconnected else { +// print("HotlineFolderDownloadClient: Already connected, status: \(self.status)") +// return +// } +// +// self.folderPath = folderURL.path +// print("HotlineFolderDownloadClient: Calling connect()") +// self.connect() +// } +// +// func cancel() { +// self.delegate = nil +// +// if self.status == .unconnected { +// return +// } +// +// // Close file before we try to potentially delete it. +// if let fh = self.fileHandle { +// try? fh.close() +// self.fileHandle = nil +// } +// +// if let downloadPath = self.folderPath { +// print("HotlineFolderDownloadClient: Deleting folder fragment at", downloadPath) +// if FileManager.default.isDeletableFile(atPath: downloadPath) { +// try? FileManager.default.removeItem(atPath: downloadPath) +// } +// self.folderPath = nil +// } +// +// self.invalidate() +// +// print("HotlineFolderDownloadClient: Cancelled transfer") +// } +// +// private func connect() { +// print("HotlineFolderDownloadClient: connect() called, connecting to \(self.serverAddress):\(self.serverPort)") +// self.connection = NWConnection(host: self.serverAddress, port: self.serverPort, using: .tcp) +// print("HotlineFolderDownloadClient: NWConnection created") +// self.connection?.stateUpdateHandler = { [weak self] (newState: NWConnection.State) in +// switch newState { +// case .ready: +// print("HotlineFolderDownloadClient: Connection ready!") +// self?.status = .connected +// self?.sendMagic() +// case .waiting(let err): +// print("HotlineFolderDownloadClient: Waiting", err) +// case .cancelled: +// print("HotlineFolderDownloadClient: Cancelled") +// self?.invalidate() +// case .failed(let err): +// print("HotlineFolderDownloadClient: Connection error \(err)") +// switch self?.status { +// case .connecting: +// print("HotlineFolderDownloadClient: Failed to connect to file transfer server.") +// self?.invalidate() +// self?.status = .failed(.failedToConnect) +// case .connected, .progress(_): +// print("HotlineFolderDownloadClient: Failed to finish transfer.") +// self?.invalidate() +// self?.status = .failed(.failedToDownload) +// default: +// break +// } +// default: +// return +// } +// } +// +// self.status = .connecting +// self.connection?.start(queue: .global()) +// } +// +// func invalidate() { +// if let c = self.connection { +// c.stateUpdateHandler = nil +// c.cancel() +// +// self.connection = nil +// } +// +// self.fileBytes = Data() +// +// if let fh = self.fileHandle { +// try? fh.close() +// self.fileHandle = nil +// } +// +// self.fileProgress.unpublish() +// } +// +// private func parseItemHeaderPath(_ headerData: Data) -> (type: UInt16, components: [String])? { +// // Need at least: type(2) + count(2) +// guard headerData.count >= 4, +// let type = headerData.readUInt16(at: 0), +// let count = headerData.readUInt16(at: 2) else { return nil } +// +// var ofs = 4 +// var comps: [String] = [] +// for _ in 0..= ofs + 3 else { return nil } +// // per Hotline path encoding: reserved(2) then nameLen(1) then name +// ofs += 2 // reserved == 0 +// let nameLen = Int(headerData.readUInt8(at: ofs)!) +// ofs += 1 +// guard headerData.count >= ofs + nameLen else { return nil } +// let nameData = headerData.subdata(in: ofs..<(ofs + nameLen)) +// ofs += nameLen +// +// let name = String(data: nameData, encoding: .macOSRoman) +// ?? String(data: nameData, encoding: .utf8) +// ?? "" +// comps.append(name) +// } +// return (type, comps) +// } +// +// /// Find and align the buffer to the start of a FILP header. +// /// Returns the number of bytes dropped. +// @discardableResult +// private func alignBufferToFILP() -> Int { +// // We need at least the 4-byte magic to try +// guard self.fileBytes.count >= 4 else { return 0 } +// let magicData = Data([0x46, 0x49, 0x4C, 0x50]) // "FILP" +// if self.fileBytes.starts(with: magicData) { return 0 } +// +// if let r = self.fileBytes.firstRange(of: magicData) { +// let toDrop = r.lowerBound +// if toDrop > 0 { +// print("HotlineFolderDownloadClient: Resync — dropping \(toDrop) stray bytes before FILP") +// self.fileBytes.removeSubrange(0..= 2 else { break } +// let headerLen = Int(self.fileBytes.readUInt16(at: 0)!) +// guard self.fileBytes.count >= 2 + headerLen else { break } +// +// let headerData = self.fileBytes.subdata(in: 2..<(2 + headerLen)) +// +// guard let parsed = parseItemHeaderPath(headerData) else { +// print("HotlineFolderDownloadClient: Invalid item header; waiting for more data") +// break +// } +// +// // Consume the header from the buffer +// self.fileBytes.removeSubrange(0..<(2 + headerLen)) +// +// let itemType = parsed.type +// let comps = parsed.components +// let joinedPath = comps.joined(separator: "/") +// print("HotlineFolderDownloadClient: item type=\(itemType) path=\(joinedPath)") +// +// guard !comps.isEmpty else { +// print("HotlineFolderDownloadClient: Empty path components for item type \(itemType); requesting next item") +// self.sendAction(.nextFile) +// keepProcessing = !self.fileBytes.isEmpty +// break +// } +// +// if itemType == 1 { +// // Folder entries: create the directory locally and continue. +// self.currentItemRelativePath = comps +// +// if let base = self.folderPath { +// var dir = base +// for c in comps { dir = (dir as NSString).appendingPathComponent(c) } +// do { +// try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) +// print("HotlineFolderDownloadClient: Created folder at \(dir)") +// } catch { +// print("HotlineFolderDownloadClient: Failed to create subfolder: \(error)") +// } +// } +// +// self.completedItemCount += 1 +// +// if self.completedItemCount >= self.folderItemCount { +// self.handleAllItemsDownloaded() +// return +// } +// +// self.sendAction(.nextFile) +// keepProcessing = !self.fileBytes.isEmpty +// break +// } +// else if itemType == 0 { +// // File entries include the full path; split parent components from filename. +// self.currentItemRelativePath = Array(comps.dropLast()) +// self.currentFileName = comps.last +// +// self.transferStage = .waitingForFileSize +// print("HotlineFolderDownloadClient: Requesting file download for '\(self.currentFileName ?? "?")'") +// self.sendAction(.sendFile) +// +// if self.fileBytes.count >= 4 { +// keepProcessing = true +// } +// break +// } +// else { +// print("HotlineFolderDownloadClient: Unknown item type \(itemType); skipping") +// self.sendAction(.nextFile) +// keepProcessing = !self.fileBytes.isEmpty +// break +// } +// +// case .waitingForFileSize: +// // Read 4-byte file size that comes before FILP in folder mode +// guard self.fileBytes.count >= 4 else { break } +// let fileSize = self.fileBytes.readUInt32(at: 0)! +// self.fileBytes.removeSubrange(0..<4) +// +// print("HotlineFolderDownloadClient: File size: \(fileSize) bytes") +// +// self.currentFileSize = fileSize +// self.currentFileBytesRead = 0 +// +// // Align to FILP boundary before decoding the file header +// let dropped = self.alignBufferToFILP() +// if dropped > 0 { +// // These bytes were in the stream for this file but not part of FILP. +// // Keep byte-accounting consistent by shrinking the expected size. +// if self.currentFileSize >= UInt32(dropped) { +// self.currentFileSize -= UInt32(dropped) +// } else { +// // Defensive: if weird, treat as zero-size to avoid underflow +// self.currentFileSize = 0 +// } +// } +// +// self.transferStage = .fileHeader +// keepProcessing = true +// +// case .fileHeader: +// // Make sure we're actually at a FILP header +// if self.fileBytes.count >= HotlineFileHeader.DataSize { +// // If the 4-byte magic doesn't match, try one more resync here +// if self.fileBytes.readUInt32(at: 0)! != FILP_MAGIC { +// let dropped = self.alignBufferToFILP() +// if dropped > 0 { +// if self.currentFileSize >= UInt32(dropped) { self.currentFileSize -= UInt32(dropped) } +// } +// // If still not aligned or not enough bytes, wait for more data +// guard self.fileBytes.count >= HotlineFileHeader.DataSize, +// self.fileBytes.readUInt32(at: 0)! == FILP_MAGIC else { break } +// } +// +// if let header = HotlineFileHeader(from: self.fileBytes) { +// // Sanity gate: version and fork count +// if header.format != FILP_MAGIC || header.version == 0 || header.forkCount > 3 { +// print("HotlineFolderDownloadClient: Invalid FILP header (fmt=\(String(format:"0x%08X", header.format)), ver=\(header.version), forks=\(header.forkCount)). Resyncing.") +// // Try resync and wait for more data +// let dropped = self.alignBufferToFILP() +// if dropped > 0 { if self.currentFileSize >= UInt32(dropped) { self.currentFileSize -= UInt32(dropped) } } +// break +// } +// +// self.fileBytes.removeSubrange(0..= self.folderItemCount { +// self.handleAllItemsDownloaded() +// return +// } +// +// // More files to download +// // Check if server has pipelined all remaining data (we've received more than expected total) +// print("HotlineFolderDownloadClient: Completed \(self.completedItemCount)/\(self.folderItemCount) items, requesting next entry") +// self.transferStage = .itemHeader +// self.sendAction(.nextFile) +// keepProcessing = !self.fileBytes.isEmpty +// } +// // File not complete - try to read next fork header +// else if let forkHeader = HotlineFileForkHeader(from: self.fileBytes) { +// // Quick validation: first fork must be INFO, and sizes must be plausible +// let remainingForThisFile = max(0, Int(self.currentFileSize) - self.currentFileBytesRead) +// let plausible = forkHeader.dataSize <= UInt32(remainingForThisFile) +// let knownType = forkHeader.isInfoFork || forkHeader.isDataFork || forkHeader.isResourceFork +// +// if !plausible || (!knownType && self.currentFileBytesRead == HotlineFileHeader.DataSize) { +// print("HotlineFolderDownloadClient: Implausible fork header (type=\(String(format:"0x%08X", forkHeader.forkType)), size=\(forkHeader.dataSize), remaining=\(remainingForThisFile)). Resyncing.") +// let dropped = self.alignBufferToFILP() +// if dropped > 0 { if self.currentFileSize >= UInt32(dropped) { self.currentFileSize -= UInt32(dropped) } } +// break +// } +// +// self.fileBytes.removeSubrange(0..= infoForkDataSize { +// let infoForkData = self.fileBytes.subdata(in: 0.. 0 { +// if let f = self.fileHandle { +// do { +// var dataToWrite = self.fileBytes +// +// if dataToWrite.count >= self.fileCurrentForkBytesLeft { +// dataToWrite = self.fileBytes.subdata(in: 0.. 0 { +// var dataToWrite = self.fileBytes +// +// if dataToWrite.count >= self.fileCurrentForkBytesLeft { +// dataToWrite = self.fileBytes.subdata(in: 0.. 0 { +// var dataToWrite = self.fileBytes +// +// if dataToWrite.count >= self.fileCurrentForkBytesLeft { +// dataToWrite = self.fileBytes.subdata(in: 0.. 0 { +// let _ = self.writeResourceFork() +// self.fileResourceBytes = Data() +// } +// +// self.fileCurrentForkHeader = nil +// self.fileCurrentForkBytesLeft = 0 +// self.fileForksRemaining = 0 +// self.currentFileBytesRead = 0 +// self.currentFileSize = 0 +// self.currentFilePath = nil +// self.fileInfoFork = nil +// self.fileHeader = nil +// self.currentFileName = nil +// } +// +// private func handleAllItemsDownloaded() { +// guard self.status != .completed else { +// return +// } +// +// print("HotlineFolderDownloadClient: All \(self.folderItemCount) items downloaded") +// +// self.invalidate() +// self.status = .completed +// +// if let downloadPath = self.folderPath { +// DispatchQueue.main.sync { +// print("HotlineFolderDownloadClient: Folder download complete", downloadPath) +// +// var downloadURL = URL(filePath: downloadPath) +// downloadURL.resolveSymlinksInPath() +// +// self.delegate?.hotlineFolderDownloadComplete(client: self, reference: self.referenceNumber, at: downloadURL) +// +//#if os(macOS) +// DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path) +//#endif +// } +// } +// } +// +// private func writeResourceFork() -> Bool { +// guard let filePath = self.currentFilePath else { +// return false +// } +// +// var resolvedFileURL = URL(filePath: filePath) +// resolvedFileURL.resolveSymlinksInPath() +// +// let resourceFilePath = resolvedFileURL.urlForResourceFork() +// +// do { +// try self.fileResourceBytes.write(to: resourceFilePath) +// } +// catch { +// return false +// } +// +// return true +// } +// +// private func prepareDownloadFile(name: String) -> Bool { +// var filePath: String +// +// if self.folderPath != nil { +// // Build the full path including subfolders +// var fullPath = self.folderPath! +// for component in self.currentItemRelativePath { +// fullPath = (fullPath as NSString).appendingPathComponent(component) +// } +// +// let folderURL = URL(filePath: fullPath) +// +// // Create folder if it doesn't exist +// if !FileManager.default.fileExists(atPath: folderURL.path) { +// do { +// try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) +// } +// catch { +// print("HotlineFolderDownloadClient: Failed to create folder", error) +// return false +// } +// } +// +// filePath = folderURL.appendingPathComponent(name).path +// print("HotlineFolderDownloadClient: Creating file at \(filePath)") +// } +// else { +// let downloadsURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0] +// filePath = downloadsURL.generateUniqueFilePath(filename: name) +// } +// +// var fileAttributes: [FileAttributeKey: Any] = [:] +// if let creatorCode = self.fileInfoFork?.creator { +// fileAttributes[.hfsCreatorCode] = creatorCode as NSNumber +// } +// if let typeCode = self.fileInfoFork?.type { +// fileAttributes[.hfsTypeCode] = typeCode as NSNumber +// } +// if let createdDate = self.fileInfoFork?.createdDate { +// fileAttributes[.creationDate] = createdDate as NSDate +// } +// if let modifiedDate = self.fileInfoFork?.modifiedDate { +// fileAttributes[.modificationDate] = modifiedDate as NSDate +// } +// if let comment = self.fileInfoFork?.comment { +// if let commentPlistData = try? PropertyListSerialization.data(fromPropertyList: comment, format: .binary, options: 0) { +// fileAttributes[FileAttributeKey(rawValue: "NSFileExtendedAttributes")] = [ +// FileAttributeKey(rawValue: "com.apple.metadata:kMDItemFinderComment"): commentPlistData +// ] +// } +// } +// +// if FileManager.default.createFile(atPath: filePath, contents: nil, attributes: fileAttributes) { +// if let h = FileHandle(forWritingAtPath: filePath) { +// self.currentFilePath = filePath +// self.fileHandle = h +// +// // Only set file progress on first file +// if self.currentItemNumber == 1 && self.folderPath != nil { +// self.fileProgress.fileURL = URL(filePath: self.folderPath!).resolvingSymlinksInPath() +// self.fileProgress.fileOperationKind = .downloading +// self.fileProgress.publish() +// } +// +// return true +// } +// } +// +// return false +// } +//} diff --git a/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift new file mode 100644 index 0000000..ced79a1 --- /dev/null +++ b/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift @@ -0,0 +1,291 @@ +import Foundation +import Network + +public enum HotlineDownloadLocation: Sendable { + case url(URL) + case downloads(String) // filename +} + + +public enum HotlineTransferProgress: Sendable { + case error(Error) // An error occurred + case unconnected // Initial state + case preparing // Preparing to begin + case connecting // Connecting to server + case connected // Connected to server + case transfer(name: String, size: Int, total: Int, progress: Double, speed: Double?, estimate: TimeInterval?) // size transferred, total size, progress (0.0-1.0), speed (in bytes/sec), time remaining + case completed(url: URL?) // Download or upload complete (local url valid for downloads) +} + +/// Modern async/await file download client for Hotline protocol +@MainActor +public class HotlineFileDownloadClientNew { + // MARK: - Configuration + + public struct Configuration: Sendable { + public var chunkSize: Int = 256 * 1024 + public init() {} + } + + // MARK: - Properties + + private let serverAddress: String + private let serverPort: UInt16 + private let referenceNumber: UInt32 + + private let config: Configuration + + private var transferSize: Int + private let transferTotal: Int + private var transferProgress: Progress + + private var socket: NetSocketNew? + private var downloadTask: Task? + + // MARK: - Initialization + + public init( + address: String, + port: UInt16, + reference: UInt32, + size: UInt32, + configuration: Configuration = .init() + ) { + self.serverAddress = address + self.serverPort = port + self.referenceNumber = reference + self.config = configuration + + self.transferTotal = Int(size) + self.transferSize = 0 + self.transferProgress = Progress(totalUnitCount: Int64(self.transferTotal)) + } + + // MARK: - Public API + + public func download( + to location: HotlineDownloadLocation, + progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil + ) async throws -> URL { + self.downloadTask?.cancel() + + let task = Task { + try await performDownload(to: location, progressHandler: progressHandler) + } + self.downloadTask = task + + do { + let url = try await task.value + self.downloadTask = nil + return url + } catch { + print("FAILED TO DOWNLOAD!", error) + self.downloadTask = nil + progressHandler?(.error(error)) + throw error + } + } + + /// Cancel the current download + public func cancel() { + downloadTask?.cancel() + downloadTask = nil + + if let socket = socket { + Task { + await socket.close() + } + } + } + + // MARK: - Private Implementation + + private func updateProgress(sent: Int) { + self.transferSize = sent + self.transferProgress.completedUnitCount = Int64(sent) + + // People can cancel a transfer from the file icon in the Finder. + // This code handles that. + if self.transferProgress.isCancelled { + self.cancel() + } + } + + private func performDownload( + to destination: HotlineDownloadLocation, + progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? + ) async throws -> URL { + + let fm = FileManager.default + var fileHandle: FileHandle? + var resourceForkData: Data? + + progressHandler?(.preparing) + + // Determine the download name + // Determine destination URL based on location + let destinationURL: URL + let destinationFilename: String + switch destination { + case .url(let url): + destinationURL = url.resolvingSymlinksInPath() + destinationFilename = destinationURL.lastPathComponent + case .downloads(let filename): + var downloadsURL = fm.urls(for: .downloadsDirectory, in: .userDomainMask)[0] + downloadsURL = downloadsURL.resolvingSymlinksInPath() + destinationURL = URL(filePath: downloadsURL.generateUniqueFilePath(filename: filename)) + destinationFilename = destinationURL.lastPathComponent + } + + progressHandler?(.connecting) + + // Connect to transfer server + let socket = try await connectToTransferServer() + self.socket = socket + defer {Task { await socket.close() } } + + // Send magic header + try await socket.write(Data(endian: .big) { + "HTXF".fourCharCode() + self.referenceNumber + UInt32.zero + UInt32.zero + }) + + // Read file header + let headerData = try await socket.read(HotlineFileHeader.DataSize) + guard let header = HotlineFileHeader(from: headerData) else { + throw HotlineFileClientError.failedToTransfer + } + + // Connected + progressHandler?(.connected) + + do { + // Process each fork + for _ in 0.. NetSocketNew { + guard let transferPort = NWEndpoint.Port(rawValue: serverPort + 1) else { + throw NetSocketError.invalidPort + } + + print("HotlineFileDownloadClientNew[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)") + + let socket = try await NetSocketNew.connect( + host: .name(serverAddress, nil), + port: transferPort, + tls: .disabled + ) + + print("HotlineFileDownloadClientNew[\(referenceNumber)]: Connected!") + return socket + } + + private func sendMagicHeader(socket: NetSocketNew) async throws { + let header = Data(endian: .big) { + "HTXF".fourCharCode() + referenceNumber + UInt32.zero + UInt32.zero + } + + try await socket.write(header) + } + + private func writeResourceFork(data: Data, to url: URL) throws { + var resolvedURL = url + resolvedURL.resolveSymlinksInPath() + + let resourceURL = resolvedURL.urlForResourceFork() + try data.write(to: resourceURL) + + print("HotlineFileDownloadClientNew[\(referenceNumber)]: Wrote resource fork (\(data.count) bytes)") + } +} diff --git a/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift b/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift new file mode 100644 index 0000000..9839997 --- /dev/null +++ b/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift @@ -0,0 +1,163 @@ +import Foundation +import Network + +@MainActor +public class HotlineFilePreviewClientNew { + // MARK: - Properties + + private let serverAddress: String + private let serverPort: UInt16 + private let referenceNumber: UInt32 + private let fileName: String + private let transferSize: UInt32 + + private var downloadClient: HotlineFileDownloadClientNew? + private var previewTask: Task? + private var temporaryFileURL: URL? + + // MARK: - Initialization + + public init( + fileName: String, + address: String, + port: UInt16, + reference: UInt32, + size: UInt32 + ) { + self.fileName = fileName + self.serverAddress = address + self.serverPort = port + self.referenceNumber = reference + self.transferSize = size + } + + deinit { + // Cleanup in deinit - must be synchronous + if let tempURL = temporaryFileURL { + try? FileManager.default.removeItem(at: tempURL) + } + } + + // MARK: - Public API + + /// Download file to temporary location for preview + /// - Parameter progressHandler: Optional progress callback + /// - Returns: URL to temporary file for preview + public func preview( + progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil + ) async throws -> URL { + self.previewTask?.cancel() + + let task = Task { + try await performPreview(progressHandler: progressHandler) + } + self.previewTask = task + + do { + let url = try await task.value + self.previewTask = nil + return url + } catch { + print("HotlineFilePreviewClientNew[\(referenceNumber)]: Failed to preview file: \(error)") + self.previewTask = nil + progressHandler?(.error(error)) + throw error + } + } + + /// Cancel the current preview download + public func cancel() { + self.previewTask?.cancel() + self.previewTask = nil + self.downloadClient?.cancel() + } + + /// Manually cleanup temporary file + /// Call this when preview is complete and you no longer need the file + public func cleanup() { + self.cleanupTempFile() + } + + // MARK: - Private Implementation + + private func performPreview( + progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? + ) async throws -> URL { + + // Create temporary file path directly in system temp directory + let tempDir = FileManager.default.temporaryDirectory + let uniqueFileName = "\(UUID().uuidString)_\(self.fileName)" + let tempFileURL = tempDir.appendingPathComponent(uniqueFileName) + self.temporaryFileURL = tempFileURL + + print("HotlineFilePreviewClientNew[\(referenceNumber)]: Downloading to temp: \(tempFileURL.path)") + + progressHandler?(.connecting) + + // Connect to transfer server + guard let transferPort = NWEndpoint.Port(rawValue: serverPort + 1) else { + throw NetSocketError.invalidPort + } + + print("HotlineFilePreviewClientNew[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)") + + let socket = try await NetSocketNew.connect( + host: .name(serverAddress, nil), + port: transferPort, + tls: .disabled + ) + defer { Task { await socket.close() } } + + print("HotlineFilePreviewClientNew[\(referenceNumber)]: Connected!") + + // Send magic header for raw data download + print("HotlineFilePreviewClientNew[\(referenceNumber)]: Sending magic header") + try await socket.write(Data(endian: .big) { + "HTXF".fourCharCode() + self.referenceNumber + UInt32.zero + UInt32.zero + }) + + progressHandler?(.connected) + + // Stream raw data directly to temp file with progress tracking + print("HotlineFilePreviewClientNew[\(referenceNumber)]: Streaming \(transferSize) bytes to temp file") + + let totalSize = Int(transferSize) + + // Create empty file + FileManager.default.createFile(atPath: tempFileURL.path(percentEncoded: false), contents: nil) + + let fileHandle = try FileHandle(forWritingTo: tempFileURL) + defer { try? fileHandle.close() } + + let updates = await socket.receiveFile(to: fileHandle, length: totalSize) + for try await p in updates { + progressHandler?(.transfer( + name: uniqueFileName, + size: p.sent, + total: totalSize, + progress: totalSize > 0 ? Double(p.sent) / Double(totalSize) : 0.0, + speed: p.bytesPerSecond, + estimate: p.estimatedTimeRemaining + )) + } + + progressHandler?(.completed(url: tempFileURL)) + + print("HotlineFilePreviewClientNew[\(referenceNumber)]: Preview file ready at \(tempFileURL.path)") + + return tempFileURL + } + + private func cleanupTempFile() { + guard let tempURL = temporaryFileURL else { return } + + // Delete the temp file + try? FileManager.default.removeItem(at: tempURL) + + temporaryFileURL = nil + print("HotlineFilePreviewClientNew[\(referenceNumber)]: Cleaned up temp file") + } +} diff --git a/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift new file mode 100644 index 0000000..f065233 --- /dev/null +++ b/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift @@ -0,0 +1,265 @@ +// +// HotlineFileUploadClientNew.swift +// Hotline +// +// Modern async/await file upload client using NetSocketNew +// + +import Foundation +import Network + +/// Modern async/await file upload client for Hotline protocol +@MainActor +public class HotlineFileUploadClientNew { + // MARK: - Configuration + + public struct Configuration: Sendable { + public var chunkSize: Int = 256 * 1024 + public var publishProgress: Bool = true + public init() {} + } + + // MARK: - Properties + + private let serverAddress: String + private let serverPort: UInt16 + private let referenceNumber: UInt32 + private let fileURL: URL + + private let config: Configuration + + private var transferSize: Int + private let transferTotal: Int + private var transferProgress: Progress + + private var socket: NetSocketNew? + private var uploadTask: Task? + + // MARK: - Initialization + + public init?( + fileURL: URL, + address: String, + port: UInt16, + reference: UInt32, + configuration: Configuration = .init() + ) { + // Validate file and get total size + guard let payloadSize = FileManager.default.getFlattenedFileSize(fileURL) else { + return nil + } + + guard FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false)) else { + return nil + } + + self.serverAddress = address + self.serverPort = port + self.referenceNumber = reference + self.fileURL = fileURL + self.config = configuration + + self.transferTotal = Int(payloadSize) + self.transferSize = 0 + self.transferProgress = Progress(totalUnitCount: Int64(self.transferTotal)) + + print("HotlineFileUploadClientNew[\(reference)]: Preparing to upload '\(fileURL.lastPathComponent)' (\(payloadSize) bytes)") + } + + // MARK: - Public API + + public func upload( + progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil + ) async throws { + self.uploadTask?.cancel() + + let task = Task { + try await performUpload(progressHandler: progressHandler) + } + self.uploadTask = task + + do { + try await task.value + self.uploadTask = nil + } catch { + print("HotlineFileUploadClientNew[\(referenceNumber)]: Failed to upload file: \(error)") + self.uploadTask = nil + progressHandler?(.error(error)) + throw error + } + } + + /// Cancel the current upload + public func cancel() { + uploadTask?.cancel() + uploadTask = nil + + if let socket = socket { + Task { + await socket.close() + } + } + } + + // MARK: - Private Implementation + + private func updateProgress(sent: Int, speed: Double? = nil, estimate: TimeInterval? = nil) { + self.transferSize = sent + self.transferProgress.completedUnitCount = Int64(sent) + + if self.transferProgress.isCancelled { + self.cancel() + } + } + + private func performUpload( + progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? + ) async throws { + let filename = self.fileURL.lastPathComponent + + progressHandler?(.connecting) + + // Start accessing security-scoped resource + let didStartAccess = fileURL.startAccessingSecurityScopedResource() + defer { + if didStartAccess { + fileURL.stopAccessingSecurityScopedResource() + } + } + + // Connect to transfer server + let socket = try await connectToTransferServer() + self.socket = socket + defer { Task { await socket.close() } } + + // Get file metadata + guard let infoFork = HotlineFileInfoFork(file: fileURL) else { + throw HotlineFileClientError.failedToTransfer + } + + guard let header = HotlineFileHeader(file: fileURL) else { + throw HotlineFileClientError.failedToTransfer + } + + guard let forkSizes = try? FileManager.default.getFileForkSizes(fileURL) else { + throw HotlineFileClientError.failedToTransfer + } + + let infoForkData = infoFork.data() + let dataForkSize = forkSizes.dataForkSize + let resourceForkSize = forkSizes.resourceForkSize + + print("HotlineFileUploadClientNew[\(referenceNumber)]: File has dataFork=\(dataForkSize) bytes, resourceFork=\(resourceForkSize) bytes") + + // Connected + progressHandler?(.connected) + + // Send magic header + print("HotlineFileUploadClientNew[\(referenceNumber)]: Sending magic header") + try await socket.write(Data(endian: .big) { + "HTXF".fourCharCode() + self.referenceNumber + UInt32(self.transferTotal) + UInt32.zero + }) + + var totalBytesSent = 0 + + // Send file header + print("HotlineFileUploadClientNew[\(referenceNumber)]: Sending file header") + let headerData = header.data() + try await socket.write(headerData) + totalBytesSent += headerData.count + + // Send INFO fork header + print("HotlineFileUploadClientNew[\(referenceNumber)]: Sending INFO fork header") + let infoForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.info.rawValue, dataSize: UInt32(infoForkData.count)) + try await socket.write(infoForkHeader.data()) + totalBytesSent += HotlineFileForkHeader.DataSize + + // Send INFO fork data + print("HotlineFileUploadClientNew[\(referenceNumber)]: Sending INFO fork (\(infoForkData.count) bytes)") + try await socket.write(infoForkData) + totalBytesSent += infoForkData.count + + self.updateProgress(sent: totalBytesSent) + progressHandler?(.transfer(name: filename, size: self.transferSize, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: nil, estimate: nil)) + + // Configure progress for Finder if enabled + if config.publishProgress { + self.transferProgress.fileURL = fileURL.resolvingSymlinksInPath() + self.transferProgress.fileOperationKind = .uploading + self.transferProgress.publish() + } + + // Send DATA fork if present + if dataForkSize > 0 { + print("HotlineFileUploadClientNew[\(referenceNumber)]: Sending DATA fork header") + let dataForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.data.rawValue, dataSize: dataForkSize) + try await socket.write(dataForkHeader.data()) + totalBytesSent += HotlineFileForkHeader.DataSize + + // Stream DATA fork + print("HotlineFileUploadClientNew[\(referenceNumber)]: Streaming DATA fork (\(dataForkSize) bytes)") + let fileHandle = try FileHandle(forReadingFrom: fileURL) + defer { try? fileHandle.close() } + + let updates = await socket.writeFile(from: fileHandle, length: Int(dataForkSize)) + for try await p in updates { + let bytesSentNow = totalBytesSent + p.sent + self.updateProgress(sent: bytesSentNow, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining) + progressHandler?(.transfer(name: filename, size: bytesSentNow, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining)) + } + + totalBytesSent += Int(dataForkSize) + } + + // Send RESOURCE fork if present + if resourceForkSize > 0 { + let resourceURL = fileURL.urlForResourceFork() + + print("HotlineFileUploadClientNew[\(referenceNumber)]: Sending RESOURCE fork header") + let resourceForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.resource.rawValue, dataSize: resourceForkSize) + try await socket.write(resourceForkHeader.data()) + totalBytesSent += HotlineFileForkHeader.DataSize + + // Stream RESOURCE fork + print("HotlineFileUploadClientNew[\(referenceNumber)]: Streaming RESOURCE fork (\(resourceForkSize) bytes)") + let resourceHandle = try FileHandle(forReadingFrom: resourceURL) + defer { try? resourceHandle.close() } + + let updates = await socket.writeFile(from: resourceHandle, length: Int(resourceForkSize)) + for try await p in updates { + let bytesSentNow = totalBytesSent + p.sent + self.updateProgress(sent: bytesSentNow, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining) + progressHandler?(.transfer(name: filename, size: bytesSentNow, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining)) + } + + totalBytesSent += Int(resourceForkSize) + } + + self.transferProgress.unpublish() + progressHandler?(.completed(url: nil)) + + print("HotlineFileUploadClientNew[\(referenceNumber)]: Upload complete!") + } + + // MARK: - Helper Methods + + private func connectToTransferServer() async throws -> NetSocketNew { + guard let transferPort = NWEndpoint.Port(rawValue: serverPort + 1) else { + throw NetSocketError.invalidPort + } + + print("HotlineFileUploadClientNew[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)") + + let socket = try await NetSocketNew.connect( + host: .name(serverAddress, nil), + port: transferPort, + tls: .disabled + ) + + print("HotlineFileUploadClientNew[\(referenceNumber)]: Connected!") + return socket + } +} diff --git a/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift new file mode 100644 index 0000000..9dcb7c9 --- /dev/null +++ b/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift @@ -0,0 +1,486 @@ +// +// HotlineFolderDownloadClientNew.swift +// Hotline +// +// Modern async/await folder download client using NetSocketNew +// + +import Foundation +import Network + +/// Item progress callback for folder downloads +public struct HotlineFolderItemProgress: Sendable { + public let fileName: String + public let itemNumber: Int + public let totalItems: Int +} + +/// Modern async/await folder download client for Hotline protocol +@MainActor +public class HotlineFolderDownloadClientNew { + // MARK: - Configuration + + public struct Configuration: Sendable { + public var publishProgress: Bool = true + public init() {} + } + + // MARK: - Properties + + private let serverAddress: String + private let serverPort: UInt16 + private let referenceNumber: UInt32 + + private let config: Configuration + + private let transferTotal: Int + private let folderItemCount: Int + private var transferSize: Int = 0 + + private var socket: NetSocketNew? + private var downloadTask: Task? + private var folderProgress: Progress? + + // MARK: - Initialization + + public init( + address: String, + port: UInt16, + reference: UInt32, + size: UInt32, + itemCount: Int, + configuration: Configuration = .init() + ) { + self.serverAddress = address + self.serverPort = port + self.referenceNumber = reference + self.config = configuration + self.transferTotal = Int(size) + self.folderItemCount = itemCount + + print("HotlineFolderDownloadClientNew[\(reference)]: Server reported transferSize=\(size) bytes, folderItemCount=\(itemCount) items") + } + + // MARK: - Public API + + public func download( + to location: HotlineDownloadLocation, + progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil, + itemProgress itemProgressHandler: (@Sendable (HotlineFolderItemProgress) -> Void)? = nil + ) async throws -> URL { + self.downloadTask?.cancel() + + let task = Task { + try await performDownload( + to: location, + progressHandler: progressHandler, + itemProgressHandler: itemProgressHandler + ) + } + self.downloadTask = task + + do { + let url = try await task.value + self.downloadTask = nil + return url + } catch { + print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Failed to download folder: \(error)") + self.downloadTask = nil + progressHandler?(.error(error)) + throw error + } + } + + /// Cancel the current download + public func cancel() { + downloadTask?.cancel() + downloadTask = nil + + if let socket = socket { + Task { + await socket.close() + } + } + } + + // MARK: - Private Implementation + + private func performDownload( + to destination: HotlineDownloadLocation, + progressHandler: (@Sendable (HotlineTransferProgress) -> Void)?, + itemProgressHandler: (@Sendable (HotlineFolderItemProgress) -> Void)? + ) async throws -> URL { + + var destinationFilename: String + + progressHandler?(.connecting) + + // Connect to transfer server + let socket = try await connectToTransferServer() + self.socket = socket + defer { Task { await socket.close() } } + + // Determine destination folder URL + let fm = FileManager.default + let destinationURL: URL + + switch destination { + case .url(let url): + destinationURL = url + destinationFilename = url.lastPathComponent + case .downloads(let filename): + let downloadsURL = fm.urls(for: .downloadsDirectory, in: .userDomainMask)[0] + destinationURL = URL(filePath: downloadsURL.generateUniqueFilePath(filename: filename)) + destinationFilename = destinationURL.lastPathComponent + } + + print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Downloading folder to \(destinationURL.path)") + + // Create destination folder + try? fm.removeItem(at: destinationURL) + try fm.createDirectory(at: destinationURL, withIntermediateDirectories: true) + + // Create and publish progress for the entire folder (shows in Finder) + if config.publishProgress { + let progress = Progress(totalUnitCount: Int64(self.transferTotal)) + progress.fileURL = destinationURL + progress.fileOperationKind = .downloading + progress.publish() + self.folderProgress = progress + } + defer { + // Unpublish progress when folder download completes + self.folderProgress?.unpublish() + self.folderProgress = nil + } + + // Send initial magic header + print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Sending HTXF magic") + try await socket.write(Data(endian: .big) { + "HTXF".fourCharCode() + self.referenceNumber + UInt32.zero // data size = 0 + UInt16(1) // type = 1 (folder transfer) + UInt16.zero // reserved = 0 + HotlineFolderAction.nextFile.rawValue // action = 3 (next file) + }) + + progressHandler?(.connected) + progressHandler?(.transfer(name: destinationFilename, size: 0, total: self.transferTotal, progress: 0.0, speed: nil, estimate: nil)) + + var completedItemCount = 0 + var totalBytesTransferred = 0 + + // Process each item in the folder + while completedItemCount < folderItemCount { + // Read item header + let headerLenData = try await socket.read(2) + let headerLen = Int(headerLenData.readUInt16(at: 0)!) + let headerData = try await socket.read(headerLen) + + totalBytesTransferred += 2 + headerLen + + guard let (itemType, pathComponents) = parseItemHeaderPath(headerData) else { + throw HotlineFileClientError.failedToTransfer + } + + let joinedPath = pathComponents.joined(separator: "/") + print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Item type=\(itemType) path=\(joinedPath)") + + if itemType == 1 { + // Folder entry - no progress shown for folder creation + if !pathComponents.isEmpty { + let folderURL = destinationURL.appendingPathComponents(pathComponents) + try fm.createDirectory(at: folderURL, withIntermediateDirectories: true) + print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Created folder at \(folderURL.path)") + } + + completedItemCount += 1 + + // Request next item if not done + if completedItemCount < folderItemCount { + try await sendAction(socket: socket, action: .nextFile) // nextFile + } + + } else if itemType == 0 { + // File entry + let parentComponents = pathComponents.dropLast() + let fileName = pathComponents.last ?? "untitled" + + // Request file download + try await sendAction(socket: socket, action: .sendFile) // sendFile + + // Read file size + let fileSizeData = try await socket.read(4) + let fileSize = fileSizeData.readUInt32(at: 0)! + totalBytesTransferred += 4 + + print("HotlineFolderDownloadClientNew[\(referenceNumber)]: File '\(fileName)' size: \(fileSize) bytes") + + // Notify item progress before download starts + completedItemCount += 1 + itemProgressHandler?(HotlineFolderItemProgress( + fileName: fileName, + itemNumber: completedItemCount, + totalItems: folderItemCount + )) + + // Download the file with overall folder progress tracking + let (fileURL, fileBytesRead) = try await downloadFile( + socket: socket, + fileName: fileName, + parentPath: Array(parentComponents), + destinationFolder: destinationURL, + fileSize: fileSize, + itemNumber: completedItemCount, + totalItems: folderItemCount, + totalBytesTransferredSoFar: totalBytesTransferred, + progressHandler: progressHandler + ) + + totalBytesTransferred += fileBytesRead + self.transferSize = totalBytesTransferred + + print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Downloaded file to \(fileURL.path)") + + // Request next item if not done + if completedItemCount < folderItemCount { + try await sendAction(socket: socket, action: .nextFile) // nextFile + } + + } else { + // Unknown item type + print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Unknown item type \(itemType), skipping") + completedItemCount += 1 + + if completedItemCount < folderItemCount { + try await sendAction(socket: socket, action: .nextFile) // nextFile + } + } + } + + print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Download complete!") + + // Ensure folder progress shows 100% complete + self.folderProgress?.completedUnitCount = Int64(self.transferTotal) + + progressHandler?(.completed(url: destinationURL)) + + return destinationURL + } + + // MARK: - Helper Methods + + private func connectToTransferServer() async throws -> NetSocketNew { + guard let transferPort = NWEndpoint.Port(rawValue: serverPort + 1) else { + throw NetSocketError.invalidPort + } + + print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)") + + let socket = try await NetSocketNew.connect( + host: .name(serverAddress, nil), + port: transferPort, + tls: .disabled + ) + + print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Connected!") + return socket + } + + private func sendAction(socket: NetSocketNew, action: HotlineFolderAction) async throws { + let actionData = Data(endian: .big) { + action.rawValue + } + try await socket.write(actionData) + print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Sent action: \(action)") + } + + private func parseItemHeaderPath(_ headerData: Data) -> (type: UInt16, components: [String])? { + // Need at least: type(2) + count(2) + guard headerData.count >= 4, + let type = headerData.readUInt16(at: 0), + let count = headerData.readUInt16(at: 2) else { return nil } + + var ofs = 4 + var comps: [String] = [] + for _ in 0..= ofs + 3 else { return nil } + // per Hotline path encoding: reserved(2) then nameLen(1) then name + ofs += 2 // reserved == 0 + let nameLen = Int(headerData.readUInt8(at: ofs)!) + ofs += 1 + guard headerData.count >= ofs + nameLen else { return nil } + let nameData = headerData.subdata(in: ofs..<(ofs + nameLen)) + ofs += nameLen + + let name = String(data: nameData, encoding: .macOSRoman) + ?? String(data: nameData, encoding: .utf8) + ?? "" + comps.append(name) + } + return (type, comps) + } + + private func downloadFile( + socket: NetSocketNew, + fileName: String, + parentPath: [String], + destinationFolder: URL, + fileSize: UInt32, + itemNumber: Int, + totalItems: Int, + totalBytesTransferredSoFar: Int, + progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? + ) async throws -> (url: URL, bytesRead: Int) { + let fm = FileManager.default + var bytesRead = 0 + + // Read file header + let headerData = try await socket.read(HotlineFileHeader.DataSize) + guard let header = HotlineFileHeader(from: headerData) else { + throw HotlineFileClientError.failedToTransfer + } + bytesRead += HotlineFileHeader.DataSize + + // Update folder progress for file header + let totalBytesNow = totalBytesTransferredSoFar + bytesRead + self.folderProgress?.completedUnitCount = Int64(totalBytesNow) + + print("HotlineFolderDownloadClientNew[\(referenceNumber)]: File has \(header.forkCount) forks") + + var resourceForkData: Data? + var fileHandle: FileHandle? + var filePath: URL? + var fileDataForkSize: Int = 0 + + defer { + try? fileHandle?.close() + } + + // Process each fork + for _ in 0.. 0 ? Double(totalBytesNow) / Double(self.transferTotal) : 0.0 + let overallProgress = min(rawProgress, 1.0) // Clamp to 1.0 to avoid exceeding 100% + + // Update folder-level Finder progress + self.folderProgress?.completedUnitCount = Int64(totalBytesNow) + + // Calculate overall folder time estimate based on current speed + let remainingBytes = max(0, self.transferTotal - totalBytesNow) + let estimate: TimeInterval? = if let speed = p.bytesPerSecond, speed > 0, remainingBytes > 0 { + TimeInterval(remainingBytes) / speed + } else { + nil + } + + // Report overall folder progress to UI + progressHandler?(.transfer( + name: fileName, + size: totalBytesNow, + total: self.transferTotal, + progress: overallProgress, + speed: p.bytesPerSecond, + estimate: estimate + )) + } + bytesRead += fileDataForkSize + + } else if forkHeader.isResourceFork { + // Read RESOURCE fork + resourceForkData = try await socket.read(Int(forkHeader.dataSize)) + bytesRead += Int(forkHeader.dataSize) + + // Update folder progress for RESOURCE fork + let totalBytesNow = totalBytesTransferredSoFar + bytesRead + self.folderProgress?.completedUnitCount = Int64(totalBytesNow) + + } else { + // Skip unsupported fork + try await socket.skip(Int(forkHeader.dataSize)) + bytesRead += Int(forkHeader.dataSize) + + // Update folder progress for skipped fork + let totalBytesNow = totalBytesTransferredSoFar + bytesRead + self.folderProgress?.completedUnitCount = Int64(totalBytesNow) + } + } + + // Close file handle + try? fileHandle?.close() + fileHandle = nil + + guard let finalPath = filePath else { + throw HotlineFileClientError.failedToTransfer + } + + // Write resource fork if present + if let rsrcData = resourceForkData, !rsrcData.isEmpty { + try writeResourceFork(data: rsrcData, to: finalPath) + } + + return (finalPath, bytesRead) + } + + private func writeResourceFork(data: Data, to url: URL) throws { + var resolvedURL = url + resolvedURL.resolveSymlinksInPath() + + let resourceURL = resolvedURL.urlForResourceFork() + try data.write(to: resourceURL) + } +} diff --git a/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift new file mode 100644 index 0000000..0b78f33 --- /dev/null +++ b/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift @@ -0,0 +1,538 @@ +import Foundation +import Network + +/// Item progress callback for folder uploads +public struct HotlineFolderItemUploadProgress: Sendable { + public let fileName: String + public let itemNumber: Int + public let totalItems: Int +} + +/// Represents a file or folder in the upload queue +private struct FolderItem { + let url: URL + let pathComponents: [String] // Path relative to upload root + let isFolder: Bool +} + +/// Modern async/await folder upload client for Hotline protocol +@MainActor +public class HotlineFolderUploadClientNew { + // MARK: - Configuration + + public struct Configuration: Sendable { + public var chunkSize: Int = 256 * 1024 + public var publishProgress: Bool = true + public init() {} + } + + // MARK: - Properties + + private let serverAddress: String + private let serverPort: UInt16 + private let referenceNumber: UInt32 + private let folderURL: URL + + private let config: Configuration + + private var transferTotal: Int = 0 + private var transferSize: Int = 0 + private var folderItems: [FolderItem] = [] + private var totalItems: Int = 0 + + private var socket: NetSocketNew? + private var uploadTask: Task? + + // MARK: - Initialization + + public init?( + folderURL: URL, + address: String, + port: UInt16, + reference: UInt32, + configuration: Configuration = .init() + ) { + guard FileManager.default.fileExists(atPath: folderURL.path(percentEncoded: false)) else { + return nil + } + + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: folderURL.path(percentEncoded: false), isDirectory: &isDirectory), + isDirectory.boolValue else { + return nil + } + + self.serverAddress = address + self.serverPort = port + self.referenceNumber = reference + self.folderURL = folderURL + self.config = configuration + + print("HotlineFolderUploadClientNew[\(reference)]: Preparing to upload folder '\(folderURL.lastPathComponent)'") + } + + // MARK: - API + + public func upload( + progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil, + itemProgress itemProgressHandler: (@Sendable (HotlineFolderItemUploadProgress) -> Void)? = nil + ) async throws { + self.uploadTask?.cancel() + + let task = Task { + try await performUpload( + progressHandler: progressHandler, + itemProgressHandler: itemProgressHandler + ) + } + self.uploadTask = task + + do { + try await task.value + self.uploadTask = nil + } catch { + print("HotlineFolderUploadClientNew[\(referenceNumber)]: Failed to upload folder: \(error)") + self.uploadTask = nil + progressHandler?(.error(error)) + throw error + } + } + + /// Cancel the current upload + public func cancel() { + uploadTask?.cancel() + uploadTask = nil + + if let socket = socket { + Task { + await socket.close() + } + } + } + + // MARK: - + + private enum UploadStage { + case waitingForNextFile // Waiting for server to send .nextFile action + case sendingItemHeader // Sending item header to server + case waitingForFileAction // Waiting for server action after file header (.sendFile, .nextFile, .resumeFile) + case uploadingFile // Uploading file data + case done // All items uploaded + } + + private func performUpload( + progressHandler: (@Sendable (HotlineTransferProgress) -> Void)?, + itemProgressHandler: (@Sendable (HotlineFolderItemUploadProgress) -> Void)? + ) async throws { + + // Note that we're preparing now. + progressHandler?(.preparing) + + // Start accessing security-scoped resource + let didStartAccess = folderURL.startAccessingSecurityScopedResource() + defer { + if didStartAccess { + self.folderURL.stopAccessingSecurityScopedResource() + } + } + + // Build folder hierarchy (excluding root folder itself) + try buildFolderHierarchy() + + // Fast path if this is an empty folder + if self.totalItems == 0 { + progressHandler?(.completed(url: nil)) + return + } + + // Note that we're connecting now. + progressHandler?(.connecting) + + // Connect to transfer server + let socket = try await self.connect(address: self.serverAddress, port: self.serverPort) + self.socket = socket + defer { Task { await socket.close() } } + + // Send magic header for folder upload + try await socket.write(Data(endian: .big) { + "HTXF".fourCharCode() + self.referenceNumber + UInt32.zero // data size = 0 + UInt16(1) // type = 1 (folder transfer) + UInt16.zero // reserved = 0 + }) + + progressHandler?(.connected) +// progressHandler?(.transfer(size: 0, total: self.transferTotal, progress: 0.0, speed: nil, estimate: nil)) + + var completedItemCount = 0 + var totalBytesTransferred = 0 + var itemIndex = 0 + var stage: UploadStage = .waitingForNextFile + var currentItem: FolderItem? + + // State machine loop - matches C++ client's goto-based state machine + while stage != .done { + switch stage { + + case .waitingForNextFile: + // Wait for server to send .nextFile action + let action = try await self.readAction(socket: socket) + guard action == .nextFile else { + throw HotlineFileClientError.failedToTransfer + } + + // Check if we have more items to send + if itemIndex < self.folderItems.count { + currentItem = self.folderItems[itemIndex] + itemIndex += 1 + stage = .sendingItemHeader + } else { + // No more items + stage = .done + } + + case .sendingItemHeader: + // Send item header to server + guard let item = currentItem else { + throw HotlineFileClientError.failedToTransfer + } + + // Encode and send item header + totalBytesTransferred += try await socket.write(self.encodeItemHeader(item: item)) + + // Next: wait for server's response + if item.isFolder { + // For folders, we're done with this item (just creating the directory) + completedItemCount += 1 + // Server should immediately respond with .nextFile + stage = .waitingForNextFile + } else { + // For files, server will tell us what to do + stage = .waitingForFileAction + } + + case .waitingForFileAction: + // Wait for server action after file header (.sendFile, .nextFile, .resumeFile) + guard currentItem != nil else { + throw HotlineFileClientError.failedToTransfer + } + + let action = try await self.readAction(socket: socket) + switch action { + case .nextFile: + // Server wants to skip this file + completedItemCount += 1 + // The .nextFile action means send next item, check if we have more + if itemIndex < self.folderItems.count { + currentItem = self.folderItems[itemIndex] + itemIndex += 1 + stage = .sendingItemHeader + } else { + stage = .done + } + + case .sendFile: + // Server wants the file + completedItemCount += 1 + stage = .uploadingFile + + case .resumeFile: + // Server wants to resume + let resumeSizeData = try await socket.read(2) + let resumeSize = Int(resumeSizeData.readUInt16(at: 0)!) + let _ = try await socket.read(resumeSize) + completedItemCount += 1 + stage = .uploadingFile + } + + case .uploadingFile: + // Upload file data + guard let item = currentItem else { + throw HotlineFileClientError.failedToTransfer + } + + // Notify item progress + itemProgressHandler?(HotlineFolderItemUploadProgress( + fileName: item.url.lastPathComponent, + itemNumber: completedItemCount, + totalItems: self.totalItems + )) + + // Upload the file + let bytesUploaded = try await self.uploadFile( + socket: socket, + fileURL: item.url, + itemNumber: completedItemCount, + totalItems: self.totalItems, + totalBytesTransferredSoFar: totalBytesTransferred, + progressHandler: progressHandler + ) + + totalBytesTransferred += bytesUploaded + self.transferSize = totalBytesTransferred + + // After uploading, wait for server to send .nextFile + stage = .waitingForNextFile + + case .done: + break + } + } + + // All items processed + progressHandler?(.completed(url: nil)) + } + + private func connect(address: String, port: UInt16) async throws -> NetSocketNew { + guard let transferPort = NWEndpoint.Port(rawValue: port + 1) else { + throw NetSocketError.invalidPort + } + + return try await NetSocketNew.connect( + host: .name(address, nil), + port: transferPort, + tls: .disabled + ) + } + + private func buildFolderHierarchy() throws { + let fm = FileManager.default + folderItems = [] + transferTotal = 0 + + let rootFolderName = folderURL.lastPathComponent + + // Recursively walk the folder + func walkFolder(at url: URL, relativePath: [String]) throws { + let contents = try fm.contentsOfDirectory(at: url, includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey], options: [.skipsHiddenFiles]) + + for itemURL in contents { + let resourceValues = try itemURL.resourceValues(forKeys: [.isDirectoryKey]) + let isDirectory = resourceValues.isDirectory ?? false + let itemName = itemURL.lastPathComponent + let itemPath = relativePath + [itemName] + + if isDirectory { + // Add folder to list + folderItems.append(FolderItem(url: itemURL, pathComponents: itemPath, isFolder: true)) + + // Recurse into subfolder + try walkFolder(at: itemURL, relativePath: itemPath) + + } else { + // Add file to list and calculate size + if let fileSize = FileManager.default.getFlattenedFileSize(itemURL) { + folderItems.append(FolderItem(url: itemURL, pathComponents: itemPath, isFolder: false)) + transferTotal += Int(fileSize) + } + } + } + } + + // Following C++ client behavior: Build hierarchy with root folder name prepended to all paths + // Start from root folder with root name as first path component + try walkFolder(at: folderURL, relativePath: [rootFolderName]) + totalItems = folderItems.count + + print("BUILD HEIRARCHY (\(folderItems.count) items):\n", folderItems) + } + + private func encodeItemHeader(item: FolderItem) -> Data { + // Following C++ client behavior: Skip the first path component (root folder name) + // The C++ client does: startPtr += 2; startPtr += *startPtr + 1; pathCount--; + let strippedPath = item.pathComponents.count > 1 ? Array(item.pathComponents.dropFirst()) : item.pathComponents + let strippedPathCount = strippedPath.count + + // Build path components (Hotline format: reserved(2) + nameLen(1) + name) + var pathData = Data() + for component in strippedPath { + let nameData = component.data(using: .macOSRoman) ?? component.data(using: .utf8) ?? Data() + let nameLen = min(nameData.count, 255) + + pathData.append(contentsOf: [0, 0]) // reserved + pathData.append(UInt8(nameLen)) + pathData.append(nameData.prefix(nameLen)) + } + + // Calculate header size (this is what goes in the DataSize field) + // DataSize = isFolder(2) + pathCount(2) + pathData + let headerSize = 2 + 2 + pathData.count + + return Data(endian: .big) { + UInt16(headerSize) + UInt16(item.isFolder ? 1 : 0) + UInt16(strippedPathCount) + pathData + } + } + + private func readAction(socket: NetSocketNew) async throws -> HotlineFolderAction { + let actionData = try await socket.read(2) + guard let rawAction = actionData.readUInt16(at: 0), + let action = HotlineFolderAction(rawValue: rawAction) else { + throw HotlineFileClientError.failedToTransfer + } + return action + } + + private func uploadFile( + socket: NetSocketNew, + fileURL: URL, + itemNumber: Int, + totalItems: Int, + totalBytesTransferredSoFar: Int, + progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? + ) async throws -> Int { + var bytesUploaded = 0 + let filename = fileURL.lastPathComponent + + // Get file metadata + guard let infoFork = HotlineFileInfoFork(file: fileURL) else { + throw HotlineFileClientError.failedToTransfer + } + + guard let header = HotlineFileHeader(file: fileURL) else { + throw HotlineFileClientError.failedToTransfer + } + + guard let forkSizes = try? FileManager.default.getFileForkSizes(fileURL) else { + throw HotlineFileClientError.failedToTransfer + } + + let infoForkData = infoFork.data() + let dataForkSize = forkSizes.dataForkSize + let resourceForkSize = forkSizes.resourceForkSize + + // Calculate total flattened file size + guard let flattenedSize = FileManager.default.getFlattenedFileSize(fileURL) else { + throw HotlineFileClientError.failedToTransfer + } + let totalFileSize = Int(flattenedSize) + + // Send file size + let fileSizeData = Data(endian: .big) { + UInt32(totalFileSize) + } + try await socket.write(fileSizeData) + bytesUploaded += 4 + + // Send file header + let headerData = header.data() + try await socket.write(headerData) + bytesUploaded += headerData.count + + // Send INFO fork header + let infoForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.info.rawValue, dataSize: UInt32(infoForkData.count)) + try await socket.write(infoForkHeader.data()) + bytesUploaded += HotlineFileForkHeader.DataSize + + // Send INFO fork data + print("HotlineFolderUploadClientNew[\(referenceNumber)]: Sending INFO fork (\(infoForkData.count) bytes)") + try await socket.write(infoForkData) + bytesUploaded += infoForkData.count + + // Create per-file progress for Finder + var fileProgress: Progress? + if config.publishProgress { + let progress = Progress(totalUnitCount: Int64(totalFileSize)) + progress.fileURL = fileURL.resolvingSymlinksInPath() + progress.fileOperationKind = Progress.FileOperationKind.uploading + progress.publish() + fileProgress = progress + } + + defer { + fileProgress?.unpublish() + } + + // Send DATA fork if present + let dataForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.data.rawValue, dataSize: dataForkSize) + try await socket.write(dataForkHeader.data()) + bytesUploaded += HotlineFileForkHeader.DataSize + + if dataForkSize > 0 { + // Stream DATA fork + let fileHandle = try FileHandle(forReadingFrom: fileURL) + defer { try? fileHandle.close() } + + let updates = await socket.writeFile(from: fileHandle, length: Int(dataForkSize)) + for try await p in updates { + // Update per-file Finder progress + fileProgress?.completedUnitCount = Int64(bytesUploaded + p.sent) + + // Calculate overall folder progress + let totalBytesNow = totalBytesTransferredSoFar + bytesUploaded + p.sent + let rawProgress = self.transferTotal > 0 ? Double(totalBytesNow) / Double(self.transferTotal) : 0.0 + let overallProgress = min(rawProgress, 1.0) + + // Calculate overall time estimate + let remainingBytes = max(0, self.transferTotal - totalBytesNow) + let estimate: TimeInterval? = if let speed = p.bytesPerSecond, speed > 0, remainingBytes > 0 { + TimeInterval(remainingBytes) / speed + } else { + nil + } + + // Report overall folder progress + progressHandler?(.transfer( + name: filename, + size: totalBytesNow, + total: self.transferTotal, + progress: overallProgress, + speed: p.bytesPerSecond, + estimate: estimate + )) + } + + bytesUploaded += Int(dataForkSize) + } + + // Send RESOURCE fork if present + if resourceForkSize > 0 { + let resourceURL = fileURL.urlForResourceFork() + + let resourceForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.resource.rawValue, dataSize: resourceForkSize) + try await socket.write(resourceForkHeader.data()) + bytesUploaded += HotlineFileForkHeader.DataSize + + // Stream RESOURCE fork + let resourceHandle = try FileHandle(forReadingFrom: resourceURL) + defer { try? resourceHandle.close() } + + let updates = await socket.writeFile(from: resourceHandle, length: Int(resourceForkSize)) + for try await p in updates { + // Update per-file Finder progress + fileProgress?.completedUnitCount = Int64(bytesUploaded + p.sent) + + // Calculate overall folder progress + let totalBytesNow = totalBytesTransferredSoFar + bytesUploaded + p.sent + let rawProgress = self.transferTotal > 0 ? Double(totalBytesNow) / Double(self.transferTotal) : 0.0 + let overallProgress = min(rawProgress, 1.0) + + // Calculate overall time estimate + let remainingBytes = max(0, self.transferTotal - totalBytesNow) + let estimate: TimeInterval? = if let speed = p.bytesPerSecond, speed > 0, remainingBytes > 0 { + TimeInterval(remainingBytes) / speed + } else { + nil + } + + // Report overall folder progress + progressHandler?(.transfer( + name: filename, + size: totalBytesNow, + total: self.transferTotal, + progress: overallProgress, + speed: p.bytesPerSecond, + estimate: estimate + )) + } + + bytesUploaded += Int(resourceForkSize) + } + + print("HotlineFolderUploadClientNew[\(referenceNumber)]: File upload complete, \(bytesUploaded) bytes sent") + return bytesUploaded + } +} diff --git a/Hotline/Info.plist b/Hotline/Info.plist index b43d991..82ee6ac 100644 --- a/Hotline/Info.plist +++ b/Hotline/Info.plist @@ -8,10 +8,10 @@ CFBundleTypeName Hotline Bookmark LSHandlerRank - None + Owner LSItemContentTypes - 'HTbm' + 'HTbm' @@ -46,7 +46,7 @@ UTTypeIconFiles UTTypeIdentifier - 'HTbm' + 'HTbm' UTTypeTagSpecification public.filename-extension diff --git a/Hotline/Library/HotlinePanel.swift b/Hotline/Library/HotlinePanel.swift index 191e89a..d7d8284 100644 --- a/Hotline/Library/HotlinePanel.swift +++ b/Hotline/Library/HotlinePanel.swift @@ -8,12 +8,12 @@ class HotlinePanel: NSPanel { super.init(contentRect: NSRect(x: 0, y: 0, width: HOTLINE_PANEL_SIZE.width, height: HOTLINE_PANEL_SIZE.height), styleMask: [.nonactivatingPanel, .titled, .closable, .utilityWindow, .fullSizeContentView], backing: .buffered, defer: false) // Make sure that the panel is in front of almost all other windows - self.isFloatingPanel = false + self.isFloatingPanel = true self.level = .floating self.hidesOnDeactivate = true self.animationBehavior = .utilityWindow - // Allow the panel to appear in a fullscreen space + // Allow the panelto appear in a fullscreen space // self.collectionBehavior.insert(.fullScreenAuxiliary) self.collectionBehavior.insert(.canJoinAllSpaces) self.collectionBehavior.insert(.ignoresCycle) @@ -23,7 +23,7 @@ class HotlinePanel: NSPanel { // Don't delete panel state when it's closed. self.isReleasedWhenClosed = false - self.standardWindowButton(.closeButton)?.isHidden = true + self.standardWindowButton(.closeButton)?.isHidden = false self.standardWindowButton(.zoomButton)?.isHidden = true self.standardWindowButton(.miniaturizeButton)?.isHidden = true diff --git a/Hotline/Library/NetSocket/FileProgress.swift b/Hotline/Library/NetSocket/FileProgress.swift new file mode 100644 index 0000000..c086af7 --- /dev/null +++ b/Hotline/Library/NetSocket/FileProgress.swift @@ -0,0 +1,58 @@ +// NetSocketProgress +// Dustin Mierau • @mierau +// MIT License + +import Foundation + +public extension NetSocketNew { + + /// Progress information for file uploads/downloads + struct FileProgress: Sendable { + /// Number of bytes sent/received so far + public let sent: Int + /// Total file size (may be nil if unknown) + public let total: Int? + /// Smoothed transfer rate in bytes per second (EMA), if enough samples collected + public let bytesPerSecond: Double? + /// Estimated time remaining (seconds) based on smoothed rate, if available + public let estimatedTimeRemaining: TimeInterval? + + public init(sent: Int, total: Int?, bytesPerSecond: Double? = nil, estimatedTimeRemaining: TimeInterval? = nil) { + self.sent = sent + self.total = total + self.bytesPerSecond = bytesPerSecond + self.estimatedTimeRemaining = estimatedTimeRemaining + } + + /// Format transfer speed in human-readable format + /// + /// Automatically selects appropriate unit (B/sec, KB/sec, MB/sec, GB/sec) + /// based on the magnitude of the speed. + /// + /// - Returns: Formatted string like "45KB/sec", "5B/sec", "12.5MB/sec", or nil if speed unavailable + /// + /// Example: + /// ```swift + /// if let speedString = progress.formattedSpeed { + /// print(speedString) // "2.5MB/sec" + /// } + /// ``` + public var formattedSpeed: String? { + guard let bytesPerSecond = bytesPerSecond, bytesPerSecond > 0 else { return nil } + + let kb = 1024.0 + let mb = kb * 1024.0 + let gb = mb * 1024.0 + + if bytesPerSecond >= gb { + return String(format: "%.1fGB/sec", bytesPerSecond / gb) + } else if bytesPerSecond >= mb { + return String(format: "%.1fMB/sec", bytesPerSecond / mb) + } else if bytesPerSecond >= kb { + return String(format: "%.0fKB/sec", bytesPerSecond / kb) + } else { + return String(format: "%.0fB/sec", bytesPerSecond) + } + } + } +} diff --git a/Hotline/Library/NetSocket/NetSocketNew.swift b/Hotline/Library/NetSocket/NetSocketNew.swift new file mode 100644 index 0000000..7b24b37 --- /dev/null +++ b/Hotline/Library/NetSocket/NetSocketNew.swift @@ -0,0 +1,1129 @@ +// NetSocketNew.swift +// Dustin Mierau • @mierau + +import Foundation +import Network + +/// Byte order for multi-byte integer values in binary protocols +public enum Endian { + /// Big-endian (network byte order, most significant byte first) + case big + /// Little-endian (least significant byte first) + case little +} + +/// Delimiter patterns for text-based protocols +public enum Delimiter { + /// Custom single byte delimiter + case byte(UInt8) + /// Null terminator (0x00) + case zeroByte + /// Line feed (\n, 0x0A) + case lineFeed + /// Carriage return + line feed (\r\n, 0x0D 0x0A) + case carriageReturnLineFeed + + /// Binary representation of this delimiter + var data: Data { + switch self { + case .byte(let b): return Data([b]) + case .zeroByte: return Data([0x00]) + case .lineFeed: return Data([0x0A]) + case .carriageReturnLineFeed: return Data([0x0D, 0x0A]) + } + } +} + +/// TLS/SSL encryption policy for socket connections +public struct TLSPolicy: Sendable { + /// Create a TLS-enabled policy with optional custom configuration + /// - Parameter configure: Optional closure to customize TLS options + public static func enabled(_ configure: (@Sendable (NWProtocolTLS.Options) -> Void)? = nil) -> TLSPolicy { + TLSPolicy(enabled: true, configure: configure) + } + + /// Create a policy with TLS disabled (plaintext connection) + public static var disabled: TLSPolicy { TLSPolicy(enabled: false, configure: nil) } + + /// Whether TLS is enabled + public let enabled: Bool + /// Optional TLS configuration closure + public let configure: (@Sendable (NWProtocolTLS.Options) -> Void)? +} + +// MARK: - Errors + +/// Errors that can occur during socket operations +public enum NetSocketError: Error, CustomStringConvertible, Sendable { + /// Socket is not yet in ready state + case notReady + /// Connection has been closed + case closed + /// Invalid port number provided + case invalidPort + /// Network operation failed with underlying error + case failed(underlying: Error) + /// Not enough data available to fulfill read request + case insufficientData(expected: Int, got: Int) + /// Frame size exceeds configured maximum + case framingExceeded(max: Int) + /// Failed to decode data + case decodeFailed(Error) + /// Failed to encode data + case encodeFailed(Error) + + public var description: String { + switch self { + case .notReady: return "Connection not ready." + case .closed: return "Connection closed." + case .invalidPort: return "Invalid port number." + case .failed(let e): return "Network failure: \(e.localizedDescription)" + case .insufficientData(let exp, let got): return "Insufficient data: need \(exp), have \(got)." + case .framingExceeded(let max): return "Frame length exceeded maximum \(max)." + case .decodeFailed(let e): return "Decoding failed: \(e)" + case .encodeFailed(let e): return "Encoding failed: \(e)" + } + } +} + +// MARK: - NetSocketNew + +/// An async/await TCP socket with automatic buffering and framing support +/// +/// NetSocketNew provides: +/// - Async connection management +/// - Automatic receive buffering with memory compaction +/// - Type-safe reading/writing of integers, strings, and custom types +/// - File upload/download with progress tracking +/// +/// Example usage: +/// ```swift +/// let socket = try await NetSocketNew.connect(host: "example.com", port: 80) +/// try await socket.write("Hello\n".data(using: .utf8)!) +/// let response = try await socket.readUntil(delimiter: .lineFeed) +/// ``` +public actor NetSocketNew { + /// Configuration options for the socket + public struct Config: Sendable { + /// Size of chunks to receive from network at once (default: 64 KB) + public var receiveChunk: Int = 64 * 1024 + /// Maximum bytes to buffer before disconnecting (default: 8 MB) + public var maxBufferBytes: Int = 8 * 1024 * 1024 + public init() {} + } + + // Connection + state + private let connection: NWConnection + private let queue = DispatchQueue(label: "NetSocket.NWConnection") + private var ready = false + private var isClosed = false + private let connectionID: String // For logging + + // Buffer with compaction + private var buffer = Data() + private var head = 0 // start of unread bytes + private let config: Config + + // Waiters for data/ready + private var dataWaiters: [CheckedContinuation] = [] + private var readyWaiters: [CheckedContinuation] = [] + + // MARK: Init + + private init(connection: NWConnection, config: Config) { + self.connection = connection + self.config = config + // Create a human-readable connection ID for logging + if case .hostPort(host: let h, port: let p) = connection.endpoint { + self.connectionID = "\(h):\(p)" + } else { + self.connectionID = "unknown" + } + } + + // MARK: Connect + + /// Connect to a remote host and return a ready socket + /// + /// This method establishes a TCP connection using Network framework types and waits until + /// the connection is in `.ready` state. + /// + /// - Parameters: + /// - host: Network framework host (e.g., `.name("example.com", nil)` or `.ipv4(...)`) + /// - port: Network framework port + /// - tls: TLS policy (default: enabled with default settings) + /// - config: Socket configuration (default: standard settings) + /// - Returns: A connected and ready `NetSocketNew` + /// - Throws: Network errors or connection failures + public static func connect(host: NWEndpoint.Host, port: NWEndpoint.Port, tls: TLSPolicy = .enabled(), config: Config = .init()) async throws -> NetSocketNew { + let parameters = NWParameters.tcp + if tls.enabled { + let tlsOptions = NWProtocolTLS.Options() + tls.configure?(tlsOptions) + parameters.defaultProtocolStack.applicationProtocols.insert(tlsOptions, at: 0) + } + + let conn = NWConnection(host: host, port: port, using: parameters) + let socket = NetSocketNew(connection: conn, config: config) + try await socket.start() + return socket + } + + /// Convenience wrapper to connect using string hostname and integer port + public static func connect(host: String, port: UInt16, tls: TLSPolicy = .enabled(), config: Config = .init()) async throws -> NetSocketNew { + guard let nwPort = NWEndpoint.Port(rawValue: port) else { + throw NetSocketError.invalidPort + } + return try await self.connect(host: .name(host, nil), port: nwPort, tls: tls, config: config) + } + + // MARK: Close + + /// Close the connection gracefully + /// + /// Performs a graceful shutdown of the underlying network connection (e.g., TCP FIN) + /// and wakes all pending read/write operations with a `NetSocketError.closed` error. + /// This method is idempotent - subsequent calls are ignored. + /// + /// Use `forceClose()` for immediate non-graceful termination (e.g., TCP RST). + public func close() { + guard !isClosed else { return } + isClosed = true + connection.cancel() + resumeDataWaiters() + resumeReadyWaiters(with: .failure(NetSocketError.closed)) + } + + /// Force close the connection immediately (non-graceful) + /// + /// Performs an immediate non-graceful shutdown of the underlying network connection + /// (e.g., TCP RST). Use this when you need to terminate the connection immediately + /// without waiting for graceful closure. For normal shutdown, use `close()` instead. + /// + /// This method is idempotent - subsequent calls are ignored. + public func forceClose() { + guard !isClosed else { return } + isClosed = true + connection.forceCancel() + resumeDataWaiters() + resumeReadyWaiters(with: .failure(NetSocketError.closed)) + } + + // MARK: Send Data + + /// Write raw data to the socket + /// + /// Sends data and waits for confirmation that it has been processed by the network stack. + /// + /// - Parameter data: Raw bytes to send + /// - Throws: `NetSocketError` if connection is not ready or send fails + @discardableResult + public func write(_ data: Data) async throws -> Int { + try await ensureReady() + return try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + connection.send(content: data, completion: .contentProcessed { error in + if let error { cont.resume(throwing: NetSocketError.failed(underlying: error)) } + else { cont.resume(returning: data.count) } + }) + } + } + + /// Write a fixed-width integer to the socket + /// + /// - Parameters: + /// - value: The integer value to write + /// - endian: Byte order (default: big-endian) + /// - Throws: `NetSocketError` if write fails + @discardableResult + public func write(_ value: T, endian: Endian = .big) async throws -> Int { + var v = value + switch endian { + case .big: v = T(bigEndian: value) + case .little: v = T(littleEndian: value) + } + var copy = v + let size = MemoryLayout.size + let bytes = withUnsafePointer(to: ©) { + Data(bytes: $0, count: size) + } + try await write(bytes) + return bytes.count + } + + /// Write a boolean as a single byte (0 or 1) + /// - Parameter value: Boolean value + @discardableResult + public func write(_ value: Bool) async throws -> Int { + return try await write(UInt8(value ? 0x01 : 0x00)) + } + + /// Write a Float as its IEEE 754 bit pattern + /// - Parameters: + /// - value: Float value + /// - endian: Byte order (default: big-endian) + @discardableResult + public func write(_ value: Float, endian: Endian = .big) async throws -> Int { + return try await write(value.bitPattern, endian: endian) + } + + /// Write a Double as its IEEE 754 bit pattern + /// - Parameters: + /// - value: Double value + /// - endian: Byte order (default: big-endian) + @discardableResult + public func write(_ value: Double, endian: Endian = .big) async throws -> Int { + return try await write(value.bitPattern, endian: endian) + } + + /// Write a string to the socket, optionally length-prefixed + /// + /// - Parameters: + /// - string: String to write + /// - encoding: Text encoding (default: UTF-8) + /// - allowLossyConversion: Allow lossy encoding if necessary (default: false) + /// - Throws: `NetSocketError` if encoding fails or write fails + @discardableResult + public func write(_ string: String, encoding: String.Encoding = .utf8, allowLossyConversion: Bool = false) async throws -> Int { + guard let data = string.data(using: encoding, allowLossyConversion: allowLossyConversion) else { + throw NetSocketError.encodeFailed(NSError(domain: "StringEncoding", code: -1)) + } + return try await write(data) + } + + // MARK: Receive Data + + /// Read data until a delimiter is found + /// + /// Searches the buffer for the delimiter pattern and returns all data up to (and optionally including) + /// the delimiter. The delimiter is always consumed from the stream. + /// + /// - Parameters: + /// - delimiter: Binary delimiter pattern to search for + /// - maxBytes: Maximum bytes to read before throwing (default: no limit) + /// - includeDelimiter: Whether to include delimiter in result (default: false) + /// - Returns: Data read from stream + /// - Throws: `NetSocketError.framingExceeded` if max bytes exceeded, or connection errors + public func read(past delimiter: Data, maxBytes: Int? = nil, includeDelimiter: Bool = false) async throws -> Data { + while true { + try Task.checkCancellation() + if let r = search(delimiter: delimiter) { + let consumeLen = r.upperBound - head + let data = try await read(consumeLen) + return includeDelimiter ? data : data.dropLast(delimiter.count) + } + if let maxBytes, availableBytes >= maxBytes { + throw NetSocketError.framingExceeded(max: maxBytes) + } + try await waitForData() + guard !isClosed || availableBytes > 0 else { throw NetSocketError.closed } + } + } + + /// Read exactly N bytes from the socket + /// + /// Waits for data to arrive if buffer doesn't contain enough bytes yet. The internal buffer + /// is automatically compacted after reading to prevent unbounded memory growth. + /// + /// - Parameter count: Number of bytes to read + /// - Returns: Exactly `count` bytes + /// - Throws: `NetSocketError.insufficientData` if connection closes before enough data arrives + public func read(_ count: Int) async throws -> Data { + try await self.ensureReadable(count) + let start = self.head + let end = self.head + count + let slice = self.buffer[start..(_ type: T.Type = T.self, endian: Endian = .big) async throws -> T { + let size = MemoryLayout.size + let data = try await self.read(size) + let value: T = data.withUnsafeBytes { raw in + raw.load(as: T.self) + } + switch endian { + case .big: return T(bigEndian: value) + case .little: return T(littleEndian: value) + } + } + + /// Read a fixed-length string + /// + /// - Parameters: + /// - length: Number of bytes to read + /// - encoding: Text encoding (default: UTF-8) + /// - Returns: Decoded string + /// - Throws: `NetSocketError` if decoding fails or insufficient data + public func read(_ length: Int, encoding: String.Encoding = .utf8) async throws -> String { + let data = try await self.read(length) + guard let s = String(data: data, encoding: encoding) else { + throw NetSocketError.decodeFailed(NSError()) + } + return s + } + + /// Read a string until a delimiter is found + /// + /// - Parameters: + /// - delimiter: Delimiter pattern to search for + /// - maxBytes: Maximum bytes to read before throwing (default: no limit) + /// - includeDelimiter: Whether to include delimiter in result (default: false) + /// - Returns: String read from stream (delimiter consumed but not included unless specified) + /// - Throws: `NetSocketError` if decoding fails, max bytes exceeded, or connection closed + public func read(until delimiter: Delimiter, maxBytes: Int? = nil, includeDelimiter: Bool = false) async throws -> String { + let bytes = try await read(past: delimiter.data, maxBytes: maxBytes, includeDelimiter: includeDelimiter) + guard let s = String(data: bytes, encoding: .utf8) else { throw NetSocketError.decodeFailed(NSError()) } + return s + } + + /// Read exactly N bytes with progress callbacks + /// + /// Like `read(_:)`, but reads in chunks and reports progress after each chunk. + /// Useful for downloading large amounts of data where you want to update UI progress. + /// + /// Example: + /// ```swift + /// let data = try await socket.read(1_000_000) { current, total in + /// print("Progress: \(current)/\(total)") + /// } + /// ``` + /// + /// - Parameters: + /// - count: Number of bytes to read + /// - chunkSize: Size of chunks to read at a time (default: 8192) + /// - progress: Optional callback with (bytesReceived, totalBytes) + /// - Returns: Exactly `count` bytes + /// - Throws: `NetSocketError` if connection closes before enough data arrives + public func read( + _ count: Int, + chunkSize: Int = 8192, + progress: (@Sendable (Int, Int) -> Void)? = nil + ) async throws -> Data { + var data = Data() + data.reserveCapacity(count) + var received = 0 + + while received < count { + try Task.checkCancellation() + let toRead = min(chunkSize, count - received) + let chunk = try await read(toRead) + data.append(chunk) + received += chunk.count + progress?(received, count) + } + + return data + } + + // MARK: Peek Data + + public var availableBytes: Int { self.buffer.count - self.head } + + public func peek(_ count: Int) -> Data? { + guard self.availableBytes >= count else { + return nil + } + + let slice = self.buffer[self.head..<(self.head + count)] + return Data(slice) // Don't advance head + } + + public func peek(upto count: Int) -> Data { + let amount = min(self.availableBytes, count) + guard amount > 0 else { + return Data() + } + + let slice = self.buffer[self.head..<(self.head + amount)] + return Data(slice) + } + + public func peek(awaiting count: Int) async throws -> Data { + try await self.ensureReadable(count) + let slice = self.buffer[self.head..<(self.head + count)] + return Data(slice) // Don't advance head + } + + // MARK: Skip Data + + /// Skip/discard exactly N bytes from the stream without allocating memory + public func skip(_ count: Int) async throws { + guard count > 0 else { return } + try await self.ensureReadable(count) + self.head += count + self.compactIfNeeded() + } + + /// Skip until delimiter is found (discards delimiter too) + public func skip(past delimiter: Data) async throws { + while true { + try Task.checkCancellation() + if let r = self.search(delimiter: delimiter) { + self.head = r.upperBound // Skip to end of delimiter + self.compactIfNeeded() + return + } + try await self.waitForData() + guard !self.isClosed else { + throw NetSocketError.closed + } + } + } + + // MARK: Files + + /// Upload a file from a URL, yielding progress as an AsyncSequence. + /// + /// Iterating this sequence drives the transfer. Each yielded value reports + /// the total bytes sent so far and the known total. Cancel the consuming + /// task to cancel the transfer. + /// + /// This method handles opening and closing the file handle automatically. + /// + /// - Parameters: + /// - url: File URL to upload. + /// - chunkSize: Size of each read chunk. + /// - Returns: An `AsyncThrowingStream` of `FileProgress` updates. + func writeFile(from url: URL, chunkSize: Int = 256 * 1024) -> AsyncThrowingStream { + // This stream wrapper manages the FileHandle's lifetime. + return AsyncThrowingStream(bufferingPolicy: .bufferingOldest(1)) { continuation in + // Capture self (the actor) to use in detached task + let actor = self + + // Open file on a background thread (file I/O is blocking) + let task = Task.detached { + let fh: FileHandle + let total: Int + + // 1. Open file and get length (blocking I/O, done off-actor) + do { + total = Int(try NetSocketNew.fileLength(at: url)) + fh = try FileHandle(forReadingFrom: url) + } catch { + continuation.finish(throwing: NetSocketError.failed(underlying: error)) + return + } + + // 2. Now switch to the actor context to call the actor-isolated method + let stream = await actor.writeFile( + from: fh, + length: total, + chunkSize: chunkSize + ) + + // 3. Forward all elements from the underlying stream to our stream + do { + for try await progress in stream { + try Task.checkCancellation() // Exit early if cancelled + continuation.yield(progress) + } + try? fh.close() + continuation.finish() + } catch is CancellationError { + try? fh.close() + continuation.finish() + } catch { + try? fh.close() + continuation.finish(throwing: error) + } + } + + // If the *consumer* cancels the stream, we cancel our managing task. + continuation.onTermination = { @Sendable _ in + task.cancel() + } + } + } + + /// Upload a file from an open FileHandle, yielding progress as an AsyncSequence. + /// + /// Iterating this sequence drives the transfer. Each yielded value reports + /// the total bytes sent so far and the known total. Cancel the consuming + /// task to cancel the transfer. + /// + /// **Note:** The caller is responsible for opening and closing the `fileHandle`. + /// + /// - Parameters: + /// - fileHandle: Open `FileHandle` for reading. + /// - length: Exact number of bytes to send (total file size). + /// - chunkSize: Size of each read chunk. + /// - Returns: An `AsyncThrowingStream` of `FileProgress` updates. + func writeFile(from fileHandle: FileHandle, length: Int, chunkSize: Int = 256 * 1024) -> AsyncThrowingStream { + precondition(length >= 0, "length must be >= 0") + + if length == 0 { + return AsyncThrowingStream { continuation in + continuation.yield(.init(sent: 0, total: 0, bytesPerSecond: 0, estimatedTimeRemaining: 0)) + continuation.finish() + } + } + + return AsyncThrowingStream(bufferingPolicy: .bufferingOldest(1)) { continuation in + let task = Task { [weak self] in + guard let self else { + continuation.finish() + return + } + + var estimator = TransferRateEstimator(total: Int(length)) + + do { + try await self.ensureReady() + + while estimator.transferred < length { + try Task.checkCancellation() + + let toRead = Int(min(chunkSize, length - estimator.transferred)) + + // Read from disk + guard let chunk = try fileHandle.read(upToCount: toRead), !chunk.isEmpty else { + if estimator.transferred < length { + throw NetSocketError.failed(underlying: NSError( + domain: "NetSocket", code: 9001, + userInfo: [NSLocalizedDescriptionKey: "File read ended prematurely. Expected \(length) bytes, got \(estimator.transferred)."] + )) + } + break + } + + // Write to network + try await self.write(chunk) + + // Update estimator and yield progress + let progress = estimator.update(bytes: chunk.count) + continuation.yield(progress) + } + + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { @Sendable _ in + task.cancel() + } + } + } + + /// Receive a file of known length and yield progress updates as an AsyncSequence. + /// + /// Iterating this sequence drives the transfer. Each yielded value reports + /// the total bytes written so far and the known total. Cancel the consuming + /// task to cancel the transfer. + /// + /// - Parameters: + /// - fileHandle: Open `FileHandle` for writing (caller must close). + /// - length: Exact number of bytes expected. + /// - chunkSize: Size of each read chunk. + /// - Returns: An `AsyncThrowingStream` of `FileProgress` updates. + func receiveFile(to fileHandle: FileHandle, length: Int, chunkSize: Int = 256 * 1024) -> AsyncThrowingStream { + precondition(length >= 0, "length must be >= 0") + + if length == 0 { + return AsyncThrowingStream { continuation in + continuation.yield(.init(sent: 0, total: 0, bytesPerSecond: 0, estimatedTimeRemaining: 0)) + continuation.finish() + } + } + + return AsyncThrowingStream(bufferingPolicy: .bufferingOldest(1)) { continuation in + let task = Task { [weak self] in + guard let self else { + continuation.finish() + return + } + + var estimator = TransferRateEstimator(total: length) + + do { + var remaining: Int = length + + while remaining > 0 { + try Task.checkCancellation() + let n = min(chunkSize, remaining) + + let chunk = try await self.read(n) + try fileHandle.write(contentsOf: chunk) + + let chunkSize = Int(chunk.count) + remaining -= chunkSize + let progress = estimator.update(bytes: chunkSize) + continuation.yield(progress) + } + + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { @Sendable _ in + task.cancel() + } + } + } + + /// Download a file of known length and write it to disk in chunks + /// + /// This method does **not** read a length prefix. The caller must provide the expected + /// file size (e.g., from protocol metadata). The file is streamed directly to disk to + /// avoid loading it entirely into memory. + /// + /// Supports atomic writes: when enabled, data is written to a temporary `.part` file and + /// renamed on success. If an error occurs, the temporary file is automatically cleaned up. + /// + /// - Parameters: + /// - url: Destination file URL + /// - length: Exact number of bytes to read (must match what's on the wire) + /// - chunkSize: Chunk size for reading/writing (default: 256 KB) + /// - overwrite: Whether to overwrite existing file (default: true) + /// - atomic: Write to temporary file and rename on success (default: true) + /// - progress: Optional progress callback + /// - Returns: Total bytes written (equals `length` on success) + /// - Throws: File I/O or network errors. On atomic writes, partial files are cleaned up. + /// + /// Example: + /// ```swift + /// // Hotline protocol: file size comes from transaction header + /// let transaction = try await socket.receive(HotlineTransaction.self) + /// try await socket.receiveFile( + /// to: destinationURL, + /// length: transaction.fileSize + /// ) + /// ``` + @discardableResult + func receiveFile( + to url: URL, + length: Int, + chunkSize: Int = 256 * 1024, + overwrite: Bool = true, + atomic: Bool = true, + progress: (@Sendable (FileProgress) -> Void)? = nil + ) async throws -> Int { + precondition(length >= 0, "length must be >= 0") + + // Fast path: nothing to do + if length == 0 { + if overwrite { try? FileManager.default.removeItem(at: url) } + FileManager.default.createFile(atPath: url.path, contents: Data(), attributes: nil) + return 0 + } + + // Prepare destination (optionally atomic) + let fm = FileManager.default + let dir = url.deletingLastPathComponent() + let tmp = atomic ? dir.appendingPathComponent(".\(url.lastPathComponent).part-\(UUID().uuidString)") : url + + if overwrite { try? fm.removeItem(at: tmp) } + if overwrite, !atomic { try? fm.removeItem(at: url) } + + // Create and open the file for writing + fm.createFile(atPath: tmp.path, contents: nil, attributes: nil) + let fh = try FileHandle(forWritingTo: tmp) + defer { try? fh.close() } + + var remaining: Int = length + var written: Int = 0 + + do { + while remaining > 0 { + try Task.checkCancellation() + let n = Int(min(chunkSize, remaining)) + let chunk = try await self.read(n) + try fh.write(contentsOf: chunk) + remaining -= n + written += Int(n) + progress?(.init(sent: written, total: length)) + } + } catch { + // Cleanup partial file on failure if we were writing atomically + if atomic { try? fm.removeItem(at: tmp) } + throw error + } + + // Atomically move into place if requested + if atomic { + if overwrite { try? fm.removeItem(at: url) } + try fm.moveItem(at: tmp, to: url) + } + + return written + } + + // MARK: Internals + + private func start() async throws { + self.connection.stateUpdateHandler = { state in + Task { [weak self] in + guard let self else { return } + switch state { + case .ready: + await self.setReady() + await self.resumeReadyWaiters(with: .success(())) + case .failed(let error): + await self.failAllWaiters(NetSocketError.failed(underlying: error)) + await self.setClosed() + case .waiting(let error): + // bubble as transient failure for awaiters; reconnect logic could live here + await self.resumeReadyWaiters(with: .failure(NetSocketError.failed(underlying: error))) + case .cancelled: + await self.failAllWaiters(NetSocketError.closed) + await self.setClosed() + default: + break + } + } + } + + // Kick off receive loop after .start + self.connection.start(queue: queue) + try await self.waitUntilReady() + self.startReceiveLoop() + } + + private func startReceiveLoop() { + @Sendable func loop(_ connection: NWConnection, chunk: Int, owner: NetSocketNew, connID: String) { + print("NetSocketNew[\(connID)]: Calling connection.receive(\(chunk)) to request more data...") + + connection.receive(minimumIncompleteLength: 1, maximumLength: chunk) { [weak owner] data, _, isComplete, error in + print("NetSocketNew[\(connID)]: Receive callback - data: \(data?.count ?? 0) bytes, isComplete: \(isComplete), error: \(String(describing: error))") + Task { + guard let o = owner else { + return + } + + if let error { + await o.handleReceiveError(error) + return + } + if let data, !data.isEmpty { + await o.append(data, connID: connID) + } + if isComplete { + print("NetSocketNew[\(connID)]: EOF from peer.") + await o.handleEOF() + return + } + loop(connection, chunk: chunk, owner: o, connID: connID) + } + } + } + loop(connection, chunk: self.config.receiveChunk, owner: self, connID: connectionID) + } + + private func handleReceiveError(_ error: Error) { + self.isClosed = true + self.failAllWaiters(NetSocketError.failed(underlying: error)) + } + + private func handleEOF() { + self.isClosed = true + let waiters = self.dataWaiters + self.dataWaiters.removeAll() + for w in waiters { + w.resume() + } // wake so readers can observe closure + } + + private func setReady() { + self.ready = true + } + + private func setClosed() { + self.isClosed = true + } + + private func ensureReady() async throws { + if self.isClosed { + throw NetSocketError.closed + } + if !self.ready { + try await self.waitUntilReady() + } + } + + private func ensureReadable(_ count: Int) async throws { + try await self.ensureReady() + while self.availableBytes < count { + try Task.checkCancellation() + if self.isClosed { + throw NetSocketError.insufficientData(expected: count, got: self.availableBytes) + } + try await self.waitForData() + } + } + + private func waitForData() async throws { + try Task.checkCancellation() + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + if self.isClosed { + cont.resume() + return + } + self.dataWaiters.append(cont) + } + } + + private func compactIfNeeded() { + // Avoid unbounded memory as head advances + if self.head > 64 * 1024 && self.head > self.buffer.count / 2 { + self.buffer.removeSubrange(0.. Range? { + guard !delimiter.isEmpty, availableBytes >= delimiter.count else { return nil } + let hay = buffer[head.. Int64 { + let values = try url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) + guard values.isRegularFile == true else { + throw NetSocketError.failed(underlying: NSError( + domain: "NetSocket", code: 1001, + userInfo: [NSLocalizedDescriptionKey: "Not a regular file: \(url.path)"] + )) + } + if let s = values.fileSize { return Int64(s) } + let attrs = try FileManager.default.attributesOfItem(atPath: url.path) + if let n = attrs[.size] as? NSNumber { + return n.int64Value + } + throw NetSocketError.failed(underlying: NSError( + domain: "NetSocket", code: 1002, + userInfo: [NSLocalizedDescriptionKey: "Unable to determine file size for \(url.lastPathComponent)"] + )) + } + + private func waitUntilReady() async throws { + guard !self.ready else { return } + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + self.readyWaiters.append(cont) + } + } + + private func resumeReadyWaiters(with result: Result) { + let waiters = self.readyWaiters + self.readyWaiters.removeAll() + for w in waiters { + switch result { + case .success: w.resume() + case .failure(let e): w.resume(throwing: e) + } + } + } + + private func failAllWaiters(_ error: Error) { + self.resumeReadyWaiters(with: .failure(error)) + let waiters = self.dataWaiters + self.dataWaiters.removeAll() + for w in waiters { + w.resume(throwing: error) + } + } + + private func append(_ data: Data, connID: String) { + print("NetSocketNew[\(connID)]: Received \(data.count) bytes from network, buffer now has \(buffer.count - head + data.count) available") + buffer.append(data) + if buffer.count - head > config.maxBufferBytes { + // Hard stop: drop connection rather than OOM'ing. + isClosed = true + connection.cancel() + failAllWaiters(NetSocketError.framingExceeded(max: config.maxBufferBytes)) + return + } + resumeDataWaiters() + } + + private func resumeDataWaiters() { + let waiters = dataWaiters + dataWaiters.removeAll() + for w in waiters { w.resume() } + } +} + +// MARK: - Utilities + +private extension Data { + mutating func appendInteger(_ value: T, endian: Endian) throws { + var v = value + switch endian { + case .big: v = T(bigEndian: value) + case .little: v = T(littleEndian: value) + } + var copy = v + withUnsafePointer(to: ©) { ptr in + self.append(contentsOf: UnsafeRawBufferPointer(start: ptr, count: MemoryLayout.size)) + } + } +} + +// MARK: - NetSocketEncodable + +/// Protocol for types that can encode themselves to binary data +/// +/// Types conforming to `NetSocketEncodable` produce binary data that can be sent over +/// a socket. Unlike writing field-by-field to the socket, encodable types build complete +/// binary messages that are sent in a single write operation for efficiency. +/// +/// Example: +/// ```swift +/// struct MyMessage: NetSocketEncodable { +/// let id: UInt32 +/// let name: String +/// +/// func encode(endian: Endian) throws -> Data { +/// var data = Data() +/// // Encode fields to data... +/// return data +/// } +/// } +/// +/// try await socket.send(message) +/// ``` +public protocol NetSocketEncodable: Sendable { + /// Encode this value to binary data + /// + /// Implementations should build a complete binary message and return it as Data. + /// The data will be sent to the socket in a single write operation. + /// + /// - Parameter endian: Byte order for multi-byte values + /// - Returns: Encoded binary data ready to send + /// - Throws: Encoding errors + func encode(endian: Endian) throws -> Data +} + +/// Protocol for types that can decode themselves directly from a socket stream +/// +/// Types conforming to `NetSocketDecodable` read field-by-field directly from the socket +/// using async reads. This enables true streaming without buffering entire messages. +/// +/// **Important**: If decoding throws after consuming some bytes (e.g., validation fails), +/// the socket will be left with those bytes consumed. In practice, this usually means the +/// connection should be closed. For most protocols this is acceptable since decode errors +/// indicate corrupt data or protocol violations. +/// +/// Example: +/// ```swift +/// struct MyMessage: NetSocketDecodable { +/// let id: UInt32 +/// let name: String +/// +/// init(from socket: NetSocketNew, endian: Endian) async throws { +/// self.id = try await socket.read(UInt32.self, endian: endian) +/// let nameLen = try await socket.read(UInt16.self, endian: endian) +/// let nameData = try await socket.readExactly(Int(nameLen)) +/// guard let name = String(data: nameData, encoding: .utf8) else { +/// throw NetSocketError.decodeFailed(NSError()) +/// } +/// self.name = name +/// } +/// } +/// +/// let message = try await socket.receive(MyMessage.self) +/// ``` +public protocol NetSocketDecodable: Sendable { + /// Decode a value by reading directly from the socket stream + /// + /// This initializer should read all necessary fields from the socket using + /// methods like `read(_:endian:)`, `readExactly(_:)`, `readString(length:)`, etc. + /// + /// The socket handles waiting for data to arrive, so you can read field by field + /// without worrying about buffering. + /// + /// - Parameters: + /// - socket: Socket to read from + /// - endian: Byte order for multi-byte values + /// - Throws: Network errors, insufficient data, or custom decoding errors + init(from socket: NetSocketNew, endian: Endian) async throws +} + +public extension NetSocketNew { + /// Send an encodable value to the socket + /// + /// The type encodes itself to binary data, which is then sent in a single write operation. + /// + /// Example: + /// ```swift + /// struct MyMessage: NetSocketEncodable { + /// let id: UInt32 + /// let name: String + /// + /// func encode(endian: Endian) throws -> Data { + /// var data = Data() + /// // Build binary message... + /// return data + /// } + /// } + /// + /// try await socket.send(message) + /// ``` + /// + /// - Parameters: + /// - value: Value conforming to NetSocketEncodable + /// - endian: Byte order (default: big-endian) + /// - Throws: Encoding or network errors + func send(_ value: T, endian: Endian = .big) async throws { + let data = try value.encode(endian: endian) + try await self.write(data) + } + + /// Receive and decode a value directly from the socket stream (no length prefix) + /// + /// The type reads field-by-field from the socket as needed, enabling true streaming + /// without buffering entire messages. Useful for protocols where message size isn't + /// known upfront or for progressive decoding. + /// + /// Example: + /// ```swift + /// struct ServerEntry: NetSocketDecodable { + /// let id: UInt32 + /// let name: String + /// + /// init(from socket: NetSocketNew, endian: Endian) async throws { + /// self.id = try await socket.read(UInt32.self, endian: endian) + /// // Read variable-length string... + /// } + /// } + /// + /// let entry = try await socket.receive(ServerEntry.self) + /// ``` + /// + /// - Parameters: + /// - type: Type conforming to NetSocketDecodable + /// - endian: Byte order (default: big-endian) + /// - Returns: Decoded value + /// - Throws: Decoding or network errors + func receive(_ type: T.Type, endian: Endian = .big) async throws -> T { + return try await T(from: self, endian: endian) + } +} diff --git a/Hotline/Library/NetSocket/TransferRateEstimator.swift b/Hotline/Library/NetSocket/TransferRateEstimator.swift new file mode 100644 index 0000000..7d16904 --- /dev/null +++ b/Hotline/Library/NetSocket/TransferRateEstimator.swift @@ -0,0 +1,135 @@ +// TransferRateEstimator +// Dustin Mierau • @mierau +// MIT License + +import Foundation + +/// Transfer rate estimator using exponential moving average (EMA) +/// +/// Tracks transfer speed and estimates time remaining. Designed to smooth out +/// network jitter and provide stable estimates after collecting enough samples. +/// +/// Example: +/// ```swift +/// var estimator = TransferRateEstimator(total: fileSize) +/// +/// while transferring { +/// let chunk = try await receiveData() +/// let progress = estimator.update(bytes: chunk.count) +/// print("Speed: \(progress.bytesPerSecond ?? 0) B/s, ETA: \(progress.estimatedTimeRemaining ?? 0)s") +/// } +/// ``` +public struct TransferRateEstimator { + /// Total bytes to transfer (nil if unknown) + public let total: Int? + + /// Exponential moving average of transfer rate (bytes/second) + private var emaBytesPerSecond: Double = 0 + + /// Smoothing factor for EMA (0 < alpha ≤ 1) + /// Higher = more responsive to recent changes, lower = more smoothing + private let alpha: Double + + /// Number of samples collected + private var sampleCount: Int = 0 + + /// Timestamp of first sample (for elapsed time calculation) + private var startTime: ContinuousClock.Instant? + + /// Timestamp of last update (for calculating sample duration) + private var lastUpdateTime: ContinuousClock.Instant? + + /// Minimum elapsed time before trusting estimates (seconds) + private let minElapsedTime: TimeInterval + + /// Minimum number of samples before trusting estimates + private let minSamples: Int + + /// Current number of bytes transferred + public private(set) var transferred: Int = 0 + + /// Create a new transfer rate estimator + /// + /// - Parameters: + /// - total: Total bytes to transfer (nil if unknown) + /// - alpha: EMA smoothing factor (default: 0.2). Range: 0.0-1.0 + /// - minElapsedTime: Minimum elapsed time before estimates are reliable (default: 2.0s) + /// - minSamples: Minimum samples before estimates are reliable (default: 4) + public init( + total: Int? = nil, + alpha: Double = 0.2, + minElapsedTime: TimeInterval = 2.0, + minSamples: Int = 8 + ) { + precondition(alpha > 0 && alpha <= 1, "alpha must be in range (0, 1]") + precondition(minSamples >= 0, "minSamples must be >= 0") + + self.total = total + self.alpha = alpha + self.minElapsedTime = minElapsedTime + self.minSamples = minSamples + } + + public mutating func update(total: Int) -> NetSocketNew.FileProgress { + return self.update(bytes: max(0, total - self.transferred)) + } + + /// Update the estimator with a new data sample + /// + /// Automatically calculates the duration since the last update. + /// + /// - Parameter bytes: Number of bytes transferred in this sample + /// - Returns: Current progress with speed and ETA estimates + public mutating func update(bytes: Int) -> NetSocketNew.FileProgress { + let clock = ContinuousClock() + let now = clock.now + + // Record start time on first sample + if self.startTime == nil { + self.startTime = now + } + + // Calculate duration since last update + let duration = self.lastUpdateTime.map { now - $0 } ?? .zero + self.lastUpdateTime = now + + // Update transferred count + self.transferred += bytes + + // Calculate instantaneous rate for this sample + let seconds: Double = duration / .seconds(1.0) + if seconds > 0 { + let instantRate = Double(bytes) / seconds + self.sampleCount += 1 + + // Update EMA + if self.emaBytesPerSecond == 0 { + self.emaBytesPerSecond = instantRate + } else { + self.emaBytesPerSecond += self.alpha * (instantRate - self.emaBytesPerSecond) + } + } + + // Determine if we have enough data to trust the estimate + let elapsed = self.startTime.map { now - $0 } ?? .zero + let elapsedSeconds: Double = elapsed / .seconds(1.0) + let haveEstimate = (elapsedSeconds >= self.minElapsedTime || self.sampleCount >= self.minSamples) && self.emaBytesPerSecond > 0 + + // Calculate ETA if we have both an estimate and a known total + let eta: TimeInterval? + if haveEstimate, let total = self.total { + let remaining = total - self.transferred + eta = remaining > 0 ? TimeInterval(Double(remaining) / self.emaBytesPerSecond) : 0 + } else { + eta = nil + } + + return NetSocketNew.FileProgress( + sent: self.transferred, + total: self.total, + bytesPerSecond: haveEstimate ? self.emaBytesPerSecond : nil, + estimatedTimeRemaining: eta + ) + } +} + diff --git a/Hotline/Library/NetSocketNew.swift b/Hotline/Library/NetSocketNew.swift deleted file mode 100644 index 8873ee6..0000000 --- a/Hotline/Library/NetSocketNew.swift +++ /dev/null @@ -1,1278 +0,0 @@ - -// NetSocketNew.swift -// Created by Dustin Mierau • @mierau - -import Foundation -import Network - -// MARK: - Endianness and Framing - -/// Byte order for multi-byte integer values in binary protocols -public enum Endian { - /// Big-endian (network byte order, most significant byte first) - case big - /// Little-endian (least significant byte first) - case little -} - -/// Length prefix types for framing variable-length data (strings, arrays, binary blobs) -/// -/// Used to encode the size of the following data as a fixed-width integer. -/// Each case can specify its own endianness. -public enum LengthPrefix { - /// 1-byte length prefix (0-255) - case u8 - /// 2-byte length prefix (0-65,535) - case u16(Endian = .big) - /// 4-byte length prefix (0-4,294,967,295) - case u32(Endian = .big) - /// 8-byte length prefix (0-2^64-1) - case u64(Endian = .big) - - /// Number of bytes used by this length prefix - var byteCount: Int { - switch self { - case .u8: return 1 - case .u16: return 2 - case .u32: return 4 - case .u64: return 8 - } - } -} - -/// Delimiter patterns for text-based protocols -public enum Delimiter { - /// Custom single byte delimiter - case byte(UInt8) - /// Null terminator (0x00) - case zeroByte - /// Line feed (\n, 0x0A) - case lineFeed - /// Carriage return + line feed (\r\n, 0x0D 0x0A) - case carriageReturnLineFeed - - /// Binary representation of this delimiter - var data: Data { - switch self { - case .byte(let b): return Data([b]) - case .zeroByte: return Data([0x00]) - case .lineFeed: return Data([0x0A]) - case .carriageReturnLineFeed: return Data([0x0D, 0x0A]) - } - } -} - -/// TLS/SSL encryption policy for socket connections -public struct TLSPolicy: Sendable { - /// Create a TLS-enabled policy with optional custom configuration - /// - Parameter configure: Optional closure to customize TLS options - public static func enabled(_ configure: (@Sendable (NWProtocolTLS.Options) -> Void)? = nil) -> TLSPolicy { - TLSPolicy(enabled: true, configure: configure) - } - - /// Create a policy with TLS disabled (plaintext connection) - public static var disabled: TLSPolicy { TLSPolicy(enabled: false, configure: nil) } - - /// Whether TLS is enabled - public let enabled: Bool - /// Optional TLS configuration closure - public let configure: (@Sendable (NWProtocolTLS.Options) -> Void)? -} - -// MARK: - Errors - -/// Errors that can occur during socket operations -public enum NetSocketError: Error, CustomStringConvertible, Sendable { - /// Socket is not yet in ready state - case notReady - /// Connection has been closed - case closed - /// Invalid port number provided - case invalidPort - /// Network operation failed with underlying error - case failed(underlying: Error) - /// Not enough data available to fulfill read request - case insufficientData(expected: Int, got: Int) - /// Frame size exceeds configured maximum - case framingExceeded(max: Int) - /// Failed to decode data - case decodeFailed(Error) - /// Failed to encode data - case encodeFailed(Error) - - public var description: String { - switch self { - case .notReady: return "Connection not ready." - case .closed: return "Connection closed." - case .invalidPort: return "Invalid port number." - case .failed(let e): return "Network failure: \(e.localizedDescription)" - case .insufficientData(let exp, let got): return "Insufficient data: need \(exp), have \(got)." - case .framingExceeded(let max): return "Frame length exceeded maximum \(max)." - case .decodeFailed(let e): return "Decoding failed: \(e)" - case .encodeFailed(let e): return "Encoding failed: \(e)" - } - } -} - -// MARK: - NetSocketNew - -/// An async/await TCP socket with automatic buffering and framing support -/// -/// NetSocketNew provides: -/// - Async connection management -/// - Automatic receive buffering with memory compaction -/// - Length-prefixed framing for messages -/// - Type-safe reading/writing of integers, strings, and custom types -/// - File upload/download with progress tracking -/// - Flexible encoder/decoder support (JSON, binary, etc.) -/// -/// Example usage: -/// ```swift -/// let socket = try await NetSocketNew.connect(host: "example.com", port: 80) -/// try await socket.write("Hello\n".data(using: .utf8)!) -/// let response = try await socket.readUntil(delimiter: .lineFeed) -/// ``` -public actor NetSocketNew { - /// Configuration options for the socket - public struct Config: Sendable { - /// Size of chunks to receive from network at once (default: 64 KB) - public var receiveChunk: Int = 64 * 1024 - /// Maximum bytes to buffer before disconnecting (default: 8 MB) - public var maxBufferBytes: Int = 8 * 1024 * 1024 - /// Maximum size for a single framed message (default: 4 MB) - public var maxFrameBytes: Int = 4 * 1024 * 1024 - public init() {} - } - - // Connection + state - private let connection: NWConnection - private let queue = DispatchQueue(label: "NetSocket.NWConnection") - private var ready = false - private var isClosed = false - - // Buffer with compaction - private var buffer = Data() - private var head = 0 // start of unread bytes - private let cfg: Config - - // Waiters for data/ready - private var dataWaiters: [CheckedContinuation] = [] - private var readyWaiters: [CheckedContinuation] = [] - - // Codable hooks - stored as closures for flexibility with any encoder/decoder - private var encodeValue: @Sendable (any Encodable) throws -> Data = { value in - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - return try encoder.encode(value) - } - - private var decodeValue: @Sendable (Data, any Decodable.Type) throws -> any Decodable = { data, type in - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - return try decoder.decode(type, from: data) - } - - // MARK: Init/Connect - - private init(connection: NWConnection, config: Config) { - self.connection = connection - self.cfg = config - } - - /// Connect to a remote host and return a ready socket - /// - /// This method establishes a TCP connection using Network framework types and waits until - /// the connection is in `.ready` state. - /// - /// - Parameters: - /// - host: Network framework host (e.g., `.name("example.com", nil)` or `.ipv4(...)`) - /// - port: Network framework port - /// - tls: TLS policy (default: enabled with default settings) - /// - config: Socket configuration (default: standard settings) - /// - Returns: A connected and ready `NetSocketNew` - /// - Throws: Network errors or connection failures - public static func connect(host: NWEndpoint.Host, port: NWEndpoint.Port, tls: TLSPolicy = .enabled(), config: Config = .init()) async throws -> NetSocketNew { - let parameters = NWParameters.tcp - if tls.enabled { - let tlsOptions = NWProtocolTLS.Options() - tls.configure?(tlsOptions) - parameters.defaultProtocolStack.applicationProtocols.insert(tlsOptions, at: 0) - } - - let conn = NWConnection(host: host, port: port, using: parameters) - let socket = NetSocketNew(connection: conn, config: config) - try await socket.start() - return socket - } - - /// Convenience wrapper to connect using string hostname and integer port - public static func connect(host: String, port: UInt16, tls: TLSPolicy = .enabled(), config: Config = .init()) async throws -> NetSocketNew { - guard let nwPort = NWEndpoint.Port(rawValue: port) else { - throw NetSocketError.invalidPort - } - return try await connect(host: .name(host, nil), port: nwPort, tls: tls, config: config) - } - - /// Inject custom encoding/decoding logic (supports any encoder/decoder: JSON, CBOR, MessagePack, etc.) - /// - /// Example with JSONEncoder: - /// ``` - /// let encoder = JSONEncoder() - /// socket.useCoders( - /// encode: { try encoder.encode($0) }, - /// decode: { data, type in try decoder.decode(type, from: data) } - /// ) - /// ``` - /// - /// Example with other encoders (pseudocode): - /// ``` - /// let cbor = CBOREncoder() - /// socket.useCoders( - /// encode: { try cbor.encode($0) }, - /// decode: { data, type in try CBORDecoder().decode(type, from: data) } - /// ) - /// ``` - public func useCoders( - encode: @escaping @Sendable (any Encodable) throws -> Data, - decode: @escaping @Sendable (Data, any Decodable.Type) throws -> any Decodable - ) { - self.encodeValue = encode - self.decodeValue = decode - } - - /// Convenience method to configure JSON encoding/decoding - /// - /// Sets up the socket to use the provided JSON encoder/decoder for `send()` and `receive()` calls. - /// - /// - Parameters: - /// - encoder: A configured `JSONEncoder` - /// - decoder: A configured `JSONDecoder` - public func useJSONCoders(encoder: JSONEncoder, decoder: JSONDecoder) { - self.encodeValue = { try encoder.encode($0) } - self.decodeValue = { data, type in try decoder.decode(type, from: data) } - } - - private func start() async throws { - self.connection.stateUpdateHandler = { state in - Task { [weak self] in - guard let self else { return } - switch state { - case .ready: - await self.setReady() - await self.resumeReadyWaiters(with: .success(())) - case .failed(let error): - await self.failAllWaiters(NetSocketError.failed(underlying: error)) - await self.setClosed() - case .waiting(let error): - // bubble as transient failure for awaiters; reconnect logic could live here - await self.resumeReadyWaiters(with: .failure(NetSocketError.failed(underlying: error))) - case .cancelled: - await self.failAllWaiters(NetSocketError.closed) - await self.setClosed() - default: - break - } - } - } - - // Kick off receive loop after .start - self.connection.start(queue: queue) - try await self.waitUntilReady() - self.startReceiveLoop() - } - - private func waitUntilReady() async throws { - guard !ready else { return } - try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in - readyWaiters.append(cont) - } - } - - private func resumeReadyWaiters(with result: Result) { - let waiters = readyWaiters - readyWaiters.removeAll() - for w in waiters { - switch result { - case .success: w.resume() - case .failure(let e): w.resume(throwing: e) - } - } - } - - private func failAllWaiters(_ error: Error) { - resumeReadyWaiters(with: .failure(error)) - let waiters = dataWaiters - dataWaiters.removeAll() - for w in waiters { w.resume(throwing: error) } - } - - private func setReady() { - ready = true - } - - private func setClosed() { - isClosed = true - } - - // MARK: Receive loop (runs on DispatchQueue, hops into actor) - - private nonisolated func startReceiveLoop() { - func loop(_ connection: NWConnection, chunk: Int, owner: NetSocketNew) { - print("NetSocketNew: Calling connection.receive() to request more data...") - connection.receive(minimumIncompleteLength: 1, maximumLength: chunk) { data, _, isComplete, error in - print("NetSocketNew: Receive callback - data: \(data?.count ?? 0) bytes, isComplete: \(isComplete), error: \(String(describing: error))") - if let error { - Task { await owner.handleReceiveError(error) } - return - } - if let data, !data.isEmpty { - Task { await owner.append(data) } - } - if isComplete { - Task { await owner.handleEOF() } - return - } - loop(connection, chunk: chunk, owner: owner) - } - } - loop(connection, chunk: cfg.receiveChunk, owner: self) - } - - private func handleReceiveError(_ error: Error) { - isClosed = true - failAllWaiters(NetSocketError.failed(underlying: error)) - } - - private func handleEOF() { - isClosed = true - let waiters = dataWaiters - dataWaiters.removeAll() - for w in waiters { w.resume() } // wake so readers can observe closure - } - - private func append(_ data: Data) { - print("NetSocketNew: Received \(data.count) bytes from network, buffer now has \(buffer.count - head + data.count) available") - buffer.append(data) - if buffer.count - head > cfg.maxBufferBytes { - // Hard stop: drop connection rather than OOM'ing. - isClosed = true - connection.cancel() - failAllWaiters(NetSocketError.framingExceeded(max: cfg.maxBufferBytes)) - return - } - resumeDataWaiters() - } - - private func resumeDataWaiters() { - let waiters = dataWaiters - dataWaiters.removeAll() - for w in waiters { w.resume() } - } - - // MARK: Close - - /// Close the connection gracefully - /// - /// Performs a graceful shutdown of the underlying network connection (e.g., TCP FIN) - /// and wakes all pending read/write operations with a `NetSocketError.closed` error. - /// This method is idempotent - subsequent calls are ignored. - /// - /// Use `forceClose()` for immediate non-graceful termination (e.g., TCP RST). - public func close() { - guard !isClosed else { return } - isClosed = true - connection.cancel() - resumeDataWaiters() - resumeReadyWaiters(with: .failure(NetSocketError.closed)) - } - - /// Force close the connection immediately (non-graceful) - /// - /// Performs an immediate non-graceful shutdown of the underlying network connection - /// (e.g., TCP RST). Use this when you need to terminate the connection immediately - /// without waiting for graceful closure. For normal shutdown, use `close()` instead. - /// - /// This method is idempotent - subsequent calls are ignored. - public func forceClose() { - guard !isClosed else { return } - isClosed = true - connection.forceCancel() - resumeDataWaiters() - resumeReadyWaiters(with: .failure(NetSocketError.closed)) - } - - // MARK: Send (async) - - /// Write raw data to the socket - /// - /// Sends data and waits for confirmation that it has been processed by the network stack. - /// - /// - Parameter data: Raw bytes to send - /// - Throws: `NetSocketError` if connection is not ready or send fails - public func write(_ data: Data) async throws { - try await ensureReady() - try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in - connection.send(content: data, completion: .contentProcessed { error in - if let error { cont.resume(throwing: NetSocketError.failed(underlying: error)) } - else { cont.resume() } - }) - } - } - - /// Write a fixed-width integer to the socket - /// - /// - Parameters: - /// - value: The integer value to write - /// - endian: Byte order (default: big-endian) - /// - Throws: `NetSocketError` if write fails - public func write(_ value: T, endian: Endian = .big) async throws { - var v = value - switch endian { - case .big: v = T(bigEndian: value) - case .little: v = T(littleEndian: value) - } - var copy = v - let size = MemoryLayout.size - let bytes = withUnsafePointer(to: ©) { - Data(bytes: $0, count: size) - } - try await write(bytes) - } - - /// Write a boolean as a single byte (0 or 1) - /// - Parameter value: Boolean value - public func write(_ value: Bool) async throws { - try await write(value ? UInt8(0x01) : UInt8(0x00)) - } - - /// Write a Float as its IEEE 754 bit pattern - /// - Parameters: - /// - value: Float value - /// - endian: Byte order (default: big-endian) - public func write(_ value: Float, endian: Endian = .big) async throws { - try await write(value.bitPattern, endian: endian) - } - - /// Write a Double as its IEEE 754 bit pattern - /// - Parameters: - /// - value: Double value - /// - endian: Byte order (default: big-endian) - public func write(_ value: Double, endian: Endian = .big) async throws { - try await write(value.bitPattern, endian: endian) - } - - /// Write a string to the socket, optionally length-prefixed - /// - /// - Parameters: - /// - string: String to write - /// - prefix: Optional length prefix (if provided, string is sent as a framed message) - /// - encoding: Text encoding (default: UTF-8) - /// - Throws: `NetSocketError` if encoding fails or write fails - public func write(_ string: String, prefix: LengthPrefix? = nil, encoding: String.Encoding = .utf8) async throws { - guard let data = string.data(using: encoding) else { - throw NetSocketError.encodeFailed(NSError(domain: "StringEncoding", code: -1)) - } - if let prefix { try await sendFrame(data, prefix: prefix) } - else { try await write(data) } - } - - // MARK: Frames & Codable - - /// Send a length-prefixed frame - /// - /// Writes the payload size as a fixed-width integer, followed by the payload bytes. - /// - /// - Parameters: - /// - payload: Data to send - /// - prefix: Length prefix type (default: u32 big-endian) - /// - Throws: `NetSocketError.framingExceeded` if payload is too large for prefix type - public func sendFrame(_ payload: Data, prefix: LengthPrefix = .u32()) async throws { - // Ensure frame payload does not exceed max frame length. - switch prefix { - case .u8 where payload.count > Int(UInt8.max): - throw NetSocketError.framingExceeded(max: Int(UInt8.max)) - case .u16 where payload.count > Int(UInt16.max): - throw NetSocketError.framingExceeded(max: Int(UInt16.max)) - case .u32 where payload.count > Int(UInt32.max): - throw NetSocketError.framingExceeded(max: Int(UInt32.max)) - default: - break - } - - if payload.count > cfg.maxFrameBytes { throw NetSocketError.framingExceeded(max: cfg.maxFrameBytes) } - var header = Data() - switch prefix { - case .u8: header.append(UInt8(payload.count)) - case .u16(let e): - try header.appendInteger(UInt16(payload.count), endian: e) - case .u32(let e): - try header.appendInteger(UInt32(payload.count), endian: e) - case .u64(let e): - try header.appendInteger(UInt64(payload.count), endian: e) - } - try await write(header + payload) - } - - /// Receive a length-prefixed frame - /// - /// Reads a length prefix, then reads exactly that many bytes. Waits for data to arrive if needed. - /// - /// - Parameter prefix: Length prefix type (default: u32 big-endian) - /// - Returns: The frame payload - /// - Throws: `NetSocketError.framingExceeded` if frame size exceeds maximum - public func receiveFrame(prefix: LengthPrefix = .u32()) async throws -> Data { - let length: Int - switch prefix { - case .u8: - let v: UInt8 = try await read(UInt8.self) - length = Int(v) - case .u16(let e): - let v: UInt16 = try await read(UInt16.self, endian: e) - length = Int(v) - case .u32(let e): - let v: UInt32 = try await read(UInt32.self, endian: e) - length = Int(v) - case .u64(let e): - let v: UInt64 = try await read(UInt64.self, endian: e) - if v > UInt64(cfg.maxFrameBytes) { throw NetSocketError.framingExceeded(max: cfg.maxFrameBytes) } - length = Int(v) - } - if length > cfg.maxFrameBytes { throw NetSocketError.framingExceeded(max: cfg.maxFrameBytes) } - return try await read(length) - } - - /// Send an encodable value as a length-prefixed frame - /// - /// Uses the configured encoder (default: JSON) to serialize the value. - /// - /// - Parameters: - /// - value: Value to encode and send - /// - prefix: Length prefix type (default: u32 big-endian) - /// - Throws: `NetSocketError.encodeFailed` if encoding fails - public func send(_ value: T, prefix: LengthPrefix = .u32()) async throws { - do { - let data = try encodeValue(value) - try await sendFrame(data, prefix: prefix) - } catch { - throw NetSocketError.encodeFailed(error) - } - } - - /// Receive and decode a length-prefixed value - /// - /// Uses the configured decoder (default: JSON) to deserialize the value. - /// - /// - Parameters: - /// - type: Type to decode - /// - prefix: Length prefix type (default: u32 big-endian) - /// - Returns: Decoded value - /// - Throws: `NetSocketError.decodeFailed` if decoding fails - public func receive(_ type: T.Type, prefix: LengthPrefix = .u32()) async throws -> T { - let data = try await receiveFrame(prefix: prefix) - do { - let decoded = try decodeValue(data, T.self) - guard let result = decoded as? T else { - throw NetSocketError.decodeFailed(NSError( - domain: "NetSocketNew", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "Type mismatch in decode"] - )) - } - return result - } catch { - throw NetSocketError.decodeFailed(error) - } - } - - // MARK: Read typed & utilities - - /// Read a fixed-width integer from the socket - /// - /// - Parameters: - /// - type: Integer type to read - /// - endian: Byte order (default: big-endian) - /// - Returns: The integer value - /// - Throws: `NetSocketError` if insufficient data or connection closed - public func read(_ type: T.Type = T.self, endian: Endian = .big) async throws -> T { - let size = MemoryLayout.size - let data = try await read(size) - let value: T = data.withUnsafeBytes { raw in - raw.load(as: T.self) - } - switch endian { - case .big: return T(bigEndian: value) - case .little: return T(littleEndian: value) - } - } - - /// Read a fixed-length string - /// - /// - Parameters: - /// - length: Number of bytes to read - /// - encoding: Text encoding (default: UTF-8) - /// - Returns: Decoded string - /// - Throws: `NetSocketError` if decoding fails or insufficient data - public func read(_ length: Int, encoding: String.Encoding = .utf8) async throws -> String { - let data = try await read(length) - guard let s = String(data: data, encoding: encoding) else { throw NetSocketError.decodeFailed(NSError()) } - return s - } - - /// Read a string until a delimiter is found - /// - /// - Parameters: - /// - delimiter: Delimiter pattern to search for - /// - maxBytes: Maximum bytes to read before throwing (default: no limit) - /// - includeDelimiter: Whether to include delimiter in result (default: false) - /// - Returns: String read from stream (delimiter consumed but not included unless specified) - /// - Throws: `NetSocketError` if decoding fails, max bytes exceeded, or connection closed - public func read(until delimiter: Delimiter, maxBytes: Int? = nil, includeDelimiter: Bool = false) async throws -> String { - let bytes = try await read(past: delimiter.data, maxBytes: maxBytes, includeDelimiter: includeDelimiter) - guard let s = String(data: bytes, encoding: .utf8) else { throw NetSocketError.decodeFailed(NSError()) } - return s - } - - /// Read a pascal string (1-byte length prefix followed by string data) - /// - /// This method reads a single byte for the length, then reads that many bytes and attempts - /// to decode them as a string. It tries multiple encodings for compatibility with legacy - /// protocols like Hotline: UTF-8, Shift-JIS, Windows-1251, and falls back to MacRoman. - /// - /// - Returns: The decoded string, or nil if length is 0 - /// - Throws: `NetSocketError` if reading fails or no encoding succeeds - public func readPascalString() async throws -> String? { - let length = try await read(UInt8.self) - guard length > 0 else { return nil } - - let data = try await read(Int(length)) - - // Try auto-detection with common encodings - let allowedEncodings = [ - String.Encoding.utf8.rawValue, - String.Encoding.shiftJIS.rawValue, - String.Encoding.unicode.rawValue, - String.Encoding.windowsCP1251.rawValue - ] - - var decodedString: NSString? - let detected = NSString.stringEncoding( - for: data, - encodingOptions: [.allowLossyKey: false], - convertedString: &decodedString, - usedLossyConversion: nil - ) - - if allowedEncodings.contains(detected), let str = decodedString as? String { - return str - } - - // Fallback to MacRoman for classic Mac compatibility - guard let str = String(data: data, encoding: .macOSRoman) else { - throw NetSocketError.decodeFailed(NSError( - domain: "NetSocketNew", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "Failed to decode pascal string with any known encoding"] - )) - } - return str - } - - /// Read data until a delimiter is found - /// - /// Searches the buffer for the delimiter pattern and returns all data up to (and optionally including) - /// the delimiter. The delimiter is always consumed from the stream. - /// - /// - Parameters: - /// - delimiter: Binary delimiter pattern to search for - /// - maxBytes: Maximum bytes to read before throwing (default: no limit) - /// - includeDelimiter: Whether to include delimiter in result (default: false) - /// - Returns: Data read from stream - /// - Throws: `NetSocketError.framingExceeded` if max bytes exceeded, or connection errors - public func read(past delimiter: Data, maxBytes: Int? = nil, includeDelimiter: Bool = false) async throws -> Data { - while true { - try Task.checkCancellation() - if let r = search(delimiter: delimiter) { - let consumeLen = r.upperBound - head - let data = try await read(consumeLen) - return includeDelimiter ? data : data.dropLast(delimiter.count) - } - if let maxBytes, availableBytes >= maxBytes { - throw NetSocketError.framingExceeded(max: maxBytes) - } - try await waitForData() - guard !isClosed || availableBytes > 0 else { throw NetSocketError.closed } - } - } - - /// Read exactly N bytes from the socket - /// - /// Waits for data to arrive if buffer doesn't contain enough bytes yet. The internal buffer - /// is automatically compacted after reading to prevent unbounded memory growth. - /// - /// - Parameter count: Number of bytes to read - /// - Returns: Exactly `count` bytes - /// - Throws: `NetSocketError.insufficientData` if connection closes before enough data arrives - public func read(_ count: Int) async throws -> Data { - try await ensureReadable(count) - let start = head - let end = head + count - let slice = buffer[start.. 0 else { return } - try await ensureReadable(count) - head += count - compactIfNeeded() - } - - /// Skip until delimiter is found (discards delimiter too) - public func skip(past delimiter: Data) async throws { - while true { - try Task.checkCancellation() - if let r = search(delimiter: delimiter) { - head = r.upperBound // Skip to end of delimiter - compactIfNeeded() - return - } - try await waitForData() - guard !isClosed else { throw NetSocketError.closed } - } - } - - /// Read exactly N bytes with progress callbacks - /// - /// Like `read(_:)`, but reads in chunks and reports progress after each chunk. - /// Useful for downloading large amounts of data where you want to update UI progress. - /// - /// Example: - /// ```swift - /// let data = try await socket.read(1_000_000) { current, total in - /// print("Progress: \(current)/\(total)") - /// } - /// ``` - /// - /// - Parameters: - /// - count: Number of bytes to read - /// - chunkSize: Size of chunks to read at a time (default: 8192) - /// - progress: Optional callback with (bytesReceived, totalBytes) - /// - Returns: Exactly `count` bytes - /// - Throws: `NetSocketError` if connection closes before enough data arrives - public func read( - _ count: Int, - chunkSize: Int = 8192, - progress: (@Sendable (Int, Int) -> Void)? = nil - ) async throws -> Data { - var data = Data() - data.reserveCapacity(count) - var received = 0 - - while received < count { - try Task.checkCancellation() - let toRead = min(chunkSize, count - received) - let chunk = try await read(toRead) - data.append(chunk) - received += chunk.count - progress?(received, count) - } - - return data - } - - func peek(_ count: Int) async throws -> Data { - try await ensureReadable(count) - let slice = buffer[head..<(head + count)] - return Data(slice) // Don't advance head - } - - // MARK: Internals - - private var availableBytes: Int { buffer.count - head } - - private func waitForData() async throws { - try Task.checkCancellation() - try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in - if isClosed { cont.resume(); return } - dataWaiters.append(cont) - } - } - - private func ensureReadable(_ count: Int) async throws { - try await ensureReady() - while availableBytes < count { - try Task.checkCancellation() - if isClosed { throw NetSocketError.insufficientData(expected: count, got: availableBytes) } - try await waitForData() - } - } - - private func ensureReady() async throws { - if isClosed { throw NetSocketError.closed } - if !ready { try await waitUntilReady() } - } - - private func compactIfNeeded() { - // Avoid unbounded memory as head advances - if head > 64 * 1024 && head > buffer.count / 2 { - buffer.removeSubrange(0.. Range? { - guard !delimiter.isEmpty, availableBytes >= delimiter.count else { return nil } - let hay = buffer[head..(_ value: T, endian: Endian) throws { - var v = value - switch endian { - case .big: v = T(bigEndian: value) - case .little: v = T(littleEndian: value) - } - var copy = v - withUnsafePointer(to: ©) { ptr in - self.append(contentsOf: UnsafeRawBufferPointer(start: ptr, count: MemoryLayout.size)) - } - } -} - -public extension NetSocketNew { - /// Progress information for file uploads/downloads - struct FileProgress: Sendable { - /// Number of bytes sent/received so far - public let sent: Int64 - /// Total file size (may be nil if unknown) - public let total: Int64? - } - - /// Upload a file to the socket without framing (raw byte stream) - /// - /// Reads and writes the file in chunks to limit memory usage. Each chunk waits for network - /// backpressure via `.contentProcessed` before reading the next chunk. - /// - /// - Parameters: - /// - url: File URL to upload - /// - chunkSize: Chunk size for reading/writing (default: 256 KB) - /// - progress: Optional progress callback - /// - Returns: Total bytes sent - /// - Throws: File I/O or network errors - @discardableResult - func writeFile( - from url: URL, - chunkSize: Int = 256 * 1024, - progress: (@Sendable (FileProgress) -> Void)? = nil - ) async throws -> Int64 { - try await ensureReady() - let total = try? self.fileLength(at: url) - - let fh = try FileHandle(forReadingFrom: url) - defer { try? fh.close() } - - var sent: Int64 = 0 - while true { - try Task.checkCancellation() - guard let chunk = try fh.read(upToCount: chunkSize), !chunk.isEmpty else { break } - try await write(chunk) // uses .contentProcessed completion inside - sent += Int64(chunk.count) - progress?(.init(sent: sent, total: total)) - } - return sent - } - - /// Upload a file as a length-prefixed frame without buffering the entire file in memory - /// - /// Sends the file size as a length prefix, then streams the file content in chunks. - /// Memory-efficient for large files. - /// - /// - Parameters: - /// - url: File URL to upload - /// - lengthPrefix: Length prefix type (default: u64 big-endian) - /// - chunkSize: Chunk size for reading/writing (default: 256 KB) - /// - progress: Optional progress callback - /// - Returns: Total bytes sent (not including length header) - /// - Throws: File I/O, framing, or network errors - @discardableResult - func sendFileFramed( - _ url: URL, - lengthPrefix: LengthPrefix = .u64(.big), - chunkSize: Int = 256 * 1024, - progress: (@Sendable (FileProgress) -> Void)? = nil - ) async throws -> Int64 { - let total = try fileLength(at: url) - try ensure(total, fitsIn: lengthPrefix) - - // 1) Send the length header - var header = Data() - switch lengthPrefix { - case .u8: - header.append(UInt8(truncatingIfNeeded: total)) - case .u16(let e): - try header.appendInteger(UInt16(truncatingIfNeeded: total), endian: e) - case .u32(let e): - try header.appendInteger(UInt32(truncatingIfNeeded: total), endian: e) - case .u64(let e): - try header.appendInteger(UInt64(total), endian: e) - } - try await write(header) - - // 2) Stream the file bytes (raw) right after the header - let sent = try await writeFile(from: url, chunkSize: chunkSize) { prog in - progress?(prog) - } - return sent - } - - /// Download a length-prefixed file and write it to disk in chunks (bounded memory) - /// - /// Reads the file size from a length prefix, then streams the content directly to disk - /// in chunks to avoid loading the entire file into memory. - /// - /// - Parameters: - /// - url: Destination file URL - /// - lengthPrefix: Length prefix type (default: u64 big-endian) - /// - chunkSize: Chunk size for reading/writing (default: 256 KB) - /// - overwrite: Whether to overwrite existing file (default: true) - /// - progress: Optional progress callback - /// - Returns: Total bytes written - /// - Throws: File I/O, framing, or network errors - @discardableResult - func receiveFile( - to url: URL, - lengthPrefix: LengthPrefix = .u64(.big), - chunkSize: Int = 256 * 1024, - overwrite: Bool = true, - progress: (@Sendable (FileProgress) -> Void)? = nil - ) async throws -> Int64 { - // 1) Read length header - let total64: Int64 = try await { - switch lengthPrefix { - case .u8: return Int64(try await read(UInt8.self)) - case .u16(let e): return Int64(try await read(UInt16.self, endian: e)) - case .u32(let e): return Int64(try await read(UInt32.self, endian: e)) - case .u64(let e): - let v: UInt64 = try await read(UInt64.self, endian: e) - guard v <= UInt64(Int64.max) else { - throw NetSocketError.framingExceeded(max: Int(Int64.max)) - } - return Int64(v) - } - }() - - // 2) Prepare destination file - if overwrite { try? FileManager.default.removeItem(at: url) } - FileManager.default.createFile(atPath: url.path, contents: nil, attributes: nil) - let fh = try FileHandle(forWritingTo: url) - defer { try? fh.close() } - - // 3) Stream chunks from the socket into the file - var remaining = total64 - var written: Int64 = 0 - - while remaining > 0 { - try Task.checkCancellation() - let n = Int(min(Int64(chunkSize), remaining)) - let chunk = try await read(n) // reuses your internal buffer, bounded by n - fh.write(chunk) - remaining -= Int64(n) - written += Int64(n) - progress?(.init(sent: written, total: total64)) - } - return written - } - - /// Download a file of known length and write it to disk in chunks - /// - /// Unlike `receiveFile()`, this method does **not** read a length prefix. The caller must - /// provide the expected file size (e.g., from protocol metadata). The file is streamed - /// directly to disk to avoid loading it entirely into memory. - /// - /// Supports atomic writes: when enabled, data is written to a temporary `.part` file and - /// renamed on success. If an error occurs, the temporary file is automatically cleaned up. - /// - /// - Parameters: - /// - url: Destination file URL - /// - length: Exact number of bytes to read (must match what's on the wire) - /// - chunkSize: Chunk size for reading/writing (default: 256 KB) - /// - overwrite: Whether to overwrite existing file (default: true) - /// - atomic: Write to temporary file and rename on success (default: true) - /// - progress: Optional progress callback - /// - Returns: Total bytes written (equals `length` on success) - /// - Throws: File I/O or network errors. On atomic writes, partial files are cleaned up. - /// - /// Example: - /// ```swift - /// // Hotline protocol: file size comes from transaction header - /// let transaction = try await socket.receive(HotlineTransaction.self) - /// try await socket.receiveFileKnownLength( - /// to: destinationURL, - /// length: transaction.fileSize - /// ) - /// ``` - @discardableResult - func receiveFileKnownLength( - to url: URL, - length: Int64, - chunkSize: Int = 256 * 1024, - overwrite: Bool = true, - atomic: Bool = true, - progress: (@Sendable (FileProgress) -> Void)? = nil - ) async throws -> Int64 { - precondition(length >= 0, "length must be >= 0") - - // Validate length doesn't exceed configured maximum - guard length <= cfg.maxFrameBytes else { - throw NetSocketError.framingExceeded(max: cfg.maxFrameBytes) - } - - // Fast path: nothing to do - if length == 0 { - if overwrite { try? FileManager.default.removeItem(at: url) } - FileManager.default.createFile(atPath: url.path, contents: Data(), attributes: nil) - return 0 - } - - // Prepare destination (optionally atomic) - let fm = FileManager.default - let dir = url.deletingLastPathComponent() - let tmp = atomic - ? dir.appendingPathComponent(".\(url.lastPathComponent).part-\(UUID().uuidString)") - : url - - if overwrite { try? fm.removeItem(at: tmp) } - if overwrite, !atomic { try? fm.removeItem(at: url) } - - // Create and open the file for writing - fm.createFile(atPath: tmp.path, contents: nil, attributes: nil) - let fh = try FileHandle(forWritingTo: tmp) - defer { try? fh.close() } - - var remaining = length - var written: Int64 = 0 - - do { - while remaining > 0 { - try Task.checkCancellation() - let n = Int(min(Int64(chunkSize), remaining)) - let chunk = try await read(n) - fh.write(chunk) - remaining -= Int64(n) - written += Int64(n) - progress?(.init(sent: written, total: length)) - } - } catch { - // Cleanup partial file on failure if we were writing atomically - if atomic { try? fm.removeItem(at: tmp) } - throw error - } - - // Atomically move into place if requested - if atomic { - if overwrite { try? fm.removeItem(at: url) } - try fm.moveItem(at: tmp, to: url) - } - - return written - } -} - -// MARK: - Small helpers (private) -fileprivate extension NetSocketNew { - func fileLength(at url: URL) throws -> Int64 { - let values = try url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) - guard values.isRegularFile == true else { - throw NetSocketError.failed(underlying: NSError( - domain: "NetSocket", code: 1001, - userInfo: [NSLocalizedDescriptionKey: "Not a regular file: \(url.path)"] - )) - } - if let s = values.fileSize { return Int64(s) } - let attrs = try FileManager.default.attributesOfItem(atPath: url.path) - if let n = attrs[.size] as? NSNumber { return n.int64Value } - throw NetSocketError.failed(underlying: NSError( - domain: "NetSocket", code: 1002, - userInfo: [NSLocalizedDescriptionKey: "Unable to determine file size for \(url.lastPathComponent)"] - )) - } - - func ensure(_ length: Int64, fitsIn prefix: LengthPrefix) throws { - let max: Int64 = { - switch prefix { - case .u8: return Int64(UInt8.max) - case .u16: return Int64(UInt16.max) - case .u32: return Int64(UInt32.max) - case .u64: return Int64.max - } - }() - if length > max { - throw NetSocketError.framingExceeded(max: Int(max)) - } - } -} - -// MARK: - Stream-based Encoding/Decoding - -/// Protocol for types that can encode themselves to binary data -/// -/// Types conforming to `NetSocketEncodable` produce binary data that can be sent over -/// a socket. Unlike writing field-by-field to the socket, encodable types build complete -/// binary messages that are sent in a single write operation for efficiency. -/// -/// Example: -/// ```swift -/// struct MyMessage: NetSocketEncodable { -/// let id: UInt32 -/// let name: String -/// -/// func encode(endian: Endian) throws -> Data { -/// var data = Data() -/// // Encode fields to data... -/// return data -/// } -/// } -/// -/// try await socket.send(message) -/// ``` -public protocol NetSocketEncodable: Sendable { - /// Encode this value to binary data - /// - /// Implementations should build a complete binary message and return it as Data. - /// The data will be sent to the socket in a single write operation. - /// - /// - Parameter endian: Byte order for multi-byte values - /// - Returns: Encoded binary data ready to send - /// - Throws: Encoding errors - func encode(endian: Endian) throws -> Data -} - -/// Protocol for types that can decode themselves directly from a socket stream -/// -/// Types conforming to `NetSocketDecodable` read field-by-field directly from the socket -/// using async reads. This enables true streaming without buffering entire messages. -/// -/// **Important**: If decoding throws after consuming some bytes (e.g., validation fails), -/// the socket will be left with those bytes consumed. In practice, this usually means the -/// connection should be closed. For most protocols this is acceptable since decode errors -/// indicate corrupt data or protocol violations. -/// -/// Example: -/// ```swift -/// struct MyMessage: NetSocketDecodable { -/// let id: UInt32 -/// let name: String -/// -/// init(from socket: NetSocketNew, endian: Endian) async throws { -/// self.id = try await socket.read(UInt32.self, endian: endian) -/// let nameLen = try await socket.read(UInt16.self, endian: endian) -/// let nameData = try await socket.readExactly(Int(nameLen)) -/// guard let name = String(data: nameData, encoding: .utf8) else { -/// throw NetSocketError.decodeFailed(NSError()) -/// } -/// self.name = name -/// } -/// } -/// -/// let message = try await socket.receive(MyMessage.self) -/// ``` -public protocol NetSocketDecodable: Sendable { - /// Decode a value by reading directly from the socket stream - /// - /// This initializer should read all necessary fields from the socket using - /// methods like `read(_:endian:)`, `readExactly(_:)`, `readString(length:)`, etc. - /// - /// The socket handles waiting for data to arrive, so you can read field by field - /// without worrying about buffering. - /// - /// - Parameters: - /// - socket: Socket to read from - /// - endian: Byte order for multi-byte values - /// - Throws: Network errors, insufficient data, or custom decoding errors - init(from socket: NetSocketNew, endian: Endian) async throws -} - -public extension NetSocketNew { - /// Send an encodable value to the socket - /// - /// The type encodes itself to binary data, which is then sent in a single write operation. - /// - /// Example: - /// ```swift - /// struct MyMessage: NetSocketEncodable { - /// let id: UInt32 - /// let name: String - /// - /// func encode(endian: Endian) throws -> Data { - /// var data = Data() - /// // Build binary message... - /// return data - /// } - /// } - /// - /// try await socket.send(message) - /// ``` - /// - /// - Parameters: - /// - value: Value conforming to NetSocketEncodable - /// - endian: Byte order (default: big-endian) - /// - Throws: Encoding or network errors - func send(_ value: T, endian: Endian = .big) async throws { - let data = try value.encode(endian: endian) - try await write(data) - } - - /// Receive and decode a value directly from the socket stream (no length prefix) - /// - /// The type reads field-by-field from the socket as needed, enabling true streaming - /// without buffering entire messages. Useful for protocols where message size isn't - /// known upfront or for progressive decoding. - /// - /// Example: - /// ```swift - /// struct ServerEntry: NetSocketDecodable { - /// let id: UInt32 - /// let name: String - /// - /// init(from socket: NetSocketNew, endian: Endian) async throws { - /// self.id = try await socket.read(UInt32.self, endian: endian) - /// // Read variable-length string... - /// } - /// } - /// - /// let entry = try await socket.receive(ServerEntry.self) - /// ``` - /// - /// - Parameters: - /// - type: Type conforming to NetSocketDecodable - /// - endian: Byte order (default: big-endian) - /// - Returns: Decoded value - /// - Throws: Decoding or network errors - func receive(_ type: T.Type, endian: Endian = .big) async throws -> T { - return try await T(from: self, endian: endian) - } -} diff --git a/Hotline/Library/QuickLookPreviewView.swift b/Hotline/Library/QuickLookPreviewView.swift new file mode 100644 index 0000000..6ba154e --- /dev/null +++ b/Hotline/Library/QuickLookPreviewView.swift @@ -0,0 +1,22 @@ +import SwiftUI +import Quartz + +/// Embeddable QuickLook preview view for macOS +/// +/// This view uses QLPreviewView to display file previews inline, without showing a modal. +/// Supports all file types that QuickLook supports (images, PDFs, videos, documents, etc.) +struct QuickLookPreviewView: NSViewRepresentable { + let fileURL: URL + + func makeNSView(context: Context) -> QLPreviewView { + let preview = QLPreviewView(frame: .zero, style: .normal)! + preview.autostarts = true + preview.shouldCloseWithWindow = true + preview.previewItem = fileURL as QLPreviewItem + return preview + } + + func updateNSView(_ nsView: QLPreviewView, context: Context) { + nsView.previewItem = fileURL as QLPreviewItem + } +} diff --git a/Hotline/Library/URLAdditions.swift b/Hotline/Library/URLAdditions.swift index 1f25541..0ba2100 100644 --- a/Hotline/Library/URLAdditions.swift +++ b/Hotline/Library/URLAdditions.swift @@ -1,4 +1,5 @@ import Foundation +import UniformTypeIdentifiers extension URL { func generateUniqueFilePath(filename base: String) -> String { @@ -24,3 +25,31 @@ extension URL { return filePath } } + +extension UTType { + var canBePreviewedByQuickLook: Bool { + // QuickLook supports most common document types + let supportedSupertypes: [UTType] = [ + .image, + .movie, + .audio, + .pdf, + .font, + .usdz, + .text, + .sourceCode, + .spreadsheet, + .presentation, + +// Microsoft Office + .init(filenameExtension: "doc")!, + .init(filenameExtension: "docx")!, + .init(filenameExtension: "xls")!, + .init(filenameExtension: "xlsx")!, + .init(filenameExtension: "ppt")!, + .init(filenameExtension: "pptx")!, + ] + + return supportedSupertypes.contains { self.conforms(to: $0) } + } +} diff --git a/Hotline/MacApp.swift b/Hotline/MacApp.swift index 44ca308..b1cc1af 100644 --- a/Hotline/MacApp.swift +++ b/Hotline/MacApp.swift @@ -2,6 +2,7 @@ import SwiftUI import SwiftData import CloudKit import UniformTypeIdentifiers +import Darwin @Observable final class AppLaunchState { @@ -65,7 +66,7 @@ struct Application: App { @State private var selection: TrackerSelection? = nil @Bindable private var update = AppUpdate.shared - @FocusedValue(\.activeHotlineModel) private var activeHotline: Hotline? + @FocusedValue(\.activeHotlineModel) private var activeHotline: HotlineState? @FocusedValue(\.activeServerState) private var activeServerState: ServerState? private var modelContainer: ModelContainer = { @@ -173,9 +174,7 @@ struct Application: App { .defaultSize(width: 690, height: 760) .defaultPosition(.center) .onChange(of: activeServerState) { - withAnimation { - AppState.shared.activeServerState = activeServerState - } + AppState.shared.activeServerState = activeServerState } .onChange(of: activeHotline) { AppState.shared.activeHotline = activeHotline @@ -187,7 +186,7 @@ struct Application: App { } .keyboardShortcut(.init("K"), modifiers: .command) } - CommandGroup(after: .singleWindowList) { + CommandGroup(before: .singleWindowList) { Button("Toolbar") { toggleBannerWindow() } @@ -222,7 +221,11 @@ struct Application: App { .disabled(selection == nil || selection?.server == nil) .keyboardShortcut(.downArrow, modifiers: .command) Button("Disconnect") { - activeHotline?.disconnect() + if let hotline = activeHotline { + Task { + await hotline.disconnect() + } + } } .disabled(activeHotline?.status == .disconnected) Divider() @@ -264,6 +267,15 @@ struct Application: App { Settings { SettingsView() } + + // MARK: Transfers Window + Window("Transfers", id: "transfers") { + TransfersView() + .frame(minWidth: 500, minHeight: 200) + } + .defaultSize(width: 600, height: 400) + .defaultPosition(.center) + .keyboardShortcut(.init("T"), modifiers: [.shift, .command]) // MARK: Image Preview Window WindowGroup(id: "preview-image", for: PreviewFileInfo.self) { $info in @@ -286,6 +298,18 @@ struct Application: App { .defaultSize(width: 450, height: 550) .defaultPosition(.center) .restorationBehavior(.disabled) + + // MARK: QuickLook Preview Window + WindowGroup(id: "preview-quicklook", for: PreviewFileInfo.self) { $info in + FilePreviewQuickLookView(info: $info) + } + .windowManagerRole(.associated) + .windowResizability(.automatic) + .windowStyle(.titleBar) + .windowToolbarStyle(.unifiedCompact(showsTitle: true)) + .defaultSize(width: 450, height: 550) + .defaultPosition(.center) + .restorationBehavior(.disabled) } func connect(to item: TrackerSelection) { @@ -320,3 +344,4 @@ struct Application: App { } } } + diff --git a/Hotline/Models/FileDetails.swift b/Hotline/Models/FileDetails.swift index 20b1ee2..6164151 100644 --- a/Hotline/Models/FileDetails.swift +++ b/Hotline/Models/FileDetails.swift @@ -1,7 +1,7 @@ import UniformTypeIdentifiers -struct FileDetails:Identifiable { - let id = UUID() +public struct FileDetails:Identifiable { + public let id = UUID() var name: String var path: [String] var size: Int diff --git a/Hotline/Models/FileInfo.swift b/Hotline/Models/FileInfo.swift index 62ec831..e3081c4 100644 --- a/Hotline/Models/FileInfo.swift +++ b/Hotline/Models/FileInfo.swift @@ -49,11 +49,25 @@ import UniformTypeIdentifiers var children: [FileInfo]? = nil var isPreviewable: Bool { - let fileExtension = (self.name as NSString).pathExtension + let fileExtension = (self.name as NSString).pathExtension.lowercased() if let fileType = UTType(filenameExtension: fileExtension) { + if fileType.canBePreviewedByQuickLook { + return true + } + + print("FILE TYPE?", fileType, fileExtension, fileType.isSubtype(of: .pdf), fileType.isSupertype(of: .pdf)) if fileType.isSubtype(of: .image) { return true } + else if fileType.isSubtype(of: .pdf) || fileExtension == "pdf" { + return true + } + else if fileType.isSubtype(of: .audio) { + return true + } + else if fileType.isSubtype(of: .video) { + return true + } else if fileType.isSubtype(of: .text) { return true } diff --git a/Hotline/Models/FilePreview.swift b/Hotline/Models/FilePreview.swift index e5f49a3..a142823 100644 --- a/Hotline/Models/FilePreview.swift +++ b/Hotline/Models/FilePreview.swift @@ -1,115 +1,115 @@ import SwiftUI import UniformTypeIdentifiers - -enum FilePreviewState: Equatable { - case unloaded - case loading - case loaded - case failed -} - -enum FilePreviewType: Equatable { - case unknown - case image - case text -} - -@Observable -final class FilePreview: HotlineFilePreviewClientDelegate { - @ObservationIgnored let info: PreviewFileInfo - @ObservationIgnored var client: HotlineFilePreviewClient? = nil - - var state: FilePreviewState = .unloaded - var progress: Double = 0.0 - - var data: Data? = nil - - #if os(iOS) - var image: UIImage? = nil - #elseif os(macOS) - var image: NSImage? = nil - #endif - - var text: String? = nil - var styledText: NSAttributedString? = nil - - var previewType: FilePreviewType { - let fileExtension = (info.name as NSString).pathExtension - if let fileType = UTType(filenameExtension: fileExtension) { - if fileType.isSubtype(of: .image) { - return .image - } - else if fileType.isSubtype(of: .text) { - return .text - } - } - return .unknown - } - - init(info: PreviewFileInfo) { - self.info = info - - self.client = HotlineFilePreviewClient(address: info.address, port: UInt16(info.port), reference: info.id, size: UInt32(info.size)) - self.client?.delegate = self - } - - func download() { - self.client?.start() - } - - func cancel() { - self.client?.cancel() - } - - func hotlineTransferStatusChanged(client: any HotlineTransferClient, reference: UInt32, status: HotlineTransferStatus, timeRemaining: TimeInterval) { - print("FilePreview: Download status changed:", status) - - switch status { - case .unconnected: - state = .unloaded - progress = 0.0 - case .connecting: - state = .loading - progress = 0.0 - case .connected: - state = .loading - progress = 0.0 - case .progress(let p): - state = .loading - progress = p - case .failed(_): - state = .failed - progress = 0.0 - case .completing: - state = .loading - progress = 1.0 - case .completed: - state = .loaded - progress = 1.0 - } - } - - func hotlineFilePreviewComplete(client: HotlineFilePreviewClient, reference: UInt32, data: Data) { - self.state = .loaded - self.data = data - - switch self.previewType { - case .image: - #if os(iOS) - self.image = UIImage(data: data) - #elseif os(macOS) - self.image = NSImage(data: data) - #endif - case .text: - let encoding: UInt = NSString.stringEncoding(for: data, convertedString: nil, usedLossyConversion: nil) - if encoding != 0 { - self.text = String(data: data, encoding: String.Encoding(rawValue: encoding)) - } - else { - self.text = String(data: data, encoding: .utf8) - } - case .unknown: - return - } - } -} +// +//enum FilePreviewState: Equatable { +// case unloaded +// case loading +// case loaded +// case failed +//} +// +//enum FilePreviewType: Equatable { +// case unknown +// case image +// case text +//} +// +//@Observable +//final class FilePreview: HotlineFilePreviewClientDelegate { +// @ObservationIgnored let info: PreviewFileInfo +// @ObservationIgnored var client: HotlineFilePreviewClient? = nil +// +// var state: FilePreviewState = .unloaded +// var progress: Double = 0.0 +// +// var data: Data? = nil +// +// #if os(iOS) +// var image: UIImage? = nil +// #elseif os(macOS) +// var image: NSImage? = nil +// #endif +// +// var text: String? = nil +// var styledText: NSAttributedString? = nil +// +// var previewType: FilePreviewType { +// let fileExtension = (info.name as NSString).pathExtension +// if let fileType = UTType(filenameExtension: fileExtension) { +// if fileType.isSubtype(of: .image) { +// return .image +// } +// else if fileType.isSubtype(of: .text) { +// return .text +// } +// } +// return .unknown +// } +// +// init(info: PreviewFileInfo) { +// self.info = info +// +// self.client = HotlineFilePreviewClient(address: info.address, port: UInt16(info.port), reference: info.id, size: UInt32(info.size)) +// self.client?.delegate = self +// } +// +// func download() { +// self.client?.start() +// } +// +// func cancel() { +// self.client?.cancel() +// } +// +// func hotlineTransferStatusChanged(client: any HotlineTransferClient, reference: UInt32, status: HotlineTransferStatus, timeRemaining: TimeInterval) { +// print("FilePreview: Download status changed:", status) +// +// switch status { +// case .unconnected: +// state = .unloaded +// progress = 0.0 +// case .connecting: +// state = .loading +// progress = 0.0 +// case .connected: +// state = .loading +// progress = 0.0 +// case .progress(let p): +// state = .loading +// progress = p +// case .failed(_): +// state = .failed +// progress = 0.0 +// case .completing: +// state = .loading +// progress = 1.0 +// case .completed: +// state = .loaded +// progress = 1.0 +// } +// } +// +// func hotlineFilePreviewComplete(client: HotlineFilePreviewClient, reference: UInt32, data: Data) { +// self.state = .loaded +// self.data = data +// +// switch self.previewType { +// case .image: +// #if os(iOS) +// self.image = UIImage(data: data) +// #elseif os(macOS) +// self.image = NSImage(data: data) +// #endif +// case .text: +// let encoding: UInt = NSString.stringEncoding(for: data, convertedString: nil, usedLossyConversion: nil) +// if encoding != 0 { +// self.text = String(data: data, encoding: String.Encoding(rawValue: encoding)) +// } +// else { +// self.text = String(data: data, encoding: .utf8) +// } +// case .unknown: +// return +// } +// } +//} diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift index 596df68..0246fbb 100644 --- a/Hotline/Models/Hotline.swift +++ b/Hotline/Models/Hotline.swift @@ -822,7 +822,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega fileClient.delegate = self self.downloads.append(fileClient) - let transfer = TransferInfo(id: referenceNumber, title: fileName, size: UInt(transferSize)) + let transfer = TransferInfo(reference: referenceNumber, title: fileName, size: UInt(transferSize)) transfer.downloadCallback = callback self.transfers.append(transfer) @@ -856,7 +856,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega fileClient.delegate = self self.downloads.append(fileClient) - let transfer = TransferInfo(id: referenceNumber, title: fileName, size: UInt(transferSize)) + let transfer = TransferInfo(reference: referenceNumber, title: fileName, size: UInt(transferSize)) transfer.downloadCallback = callback transfer.progressCallback = progressCallback self.transfers.append(transfer) @@ -894,7 +894,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega folderClient.delegate = self self.downloads.append(folderClient) - let transfer = TransferInfo(id: referenceNumber, title: folderName, size: UInt(transferSize)) + let transfer = TransferInfo(reference: referenceNumber, title: folderName, size: UInt(transferSize)) transfer.isFolder = true transfer.downloadCallback = callback self.transfers.append(transfer) @@ -972,7 +972,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega fileClient.delegate = self self.downloads.append(fileClient) - let transfer = TransferInfo(id: referenceNumber, title: fileName, size: fileSize) + let transfer = TransferInfo(reference: referenceNumber, title: fileName, size: fileSize) transfer.uploadCallback = callback self.transfers.append(transfer) diff --git a/Hotline/Models/HotlineState.swift b/Hotline/Models/HotlineState.swift new file mode 100644 index 0000000..d5ab438 --- /dev/null +++ b/Hotline/Models/HotlineState.swift @@ -0,0 +1,2468 @@ +import SwiftUI + +// MARK: - Connection Status + +enum HotlineConnectionStatus: Equatable { + case disconnected + case connecting + case connected + case loggedIn + case failed(String) + + var isConnected: Bool { + return self == .connected || self == .loggedIn + } +} + +struct FileSearchConfig: Equatable { + /// Number of folders we process before we start applying delay backoff. + var initialBurstCount: Int = 15 + /// Base delay applied between folder requests during the backoff phase. + var initialDelay: TimeInterval = 0.02 + /// Multiplier used to increase the delay after each processed folder in backoff. + var backoffMultiplier: Double = 1.1 + /// Maximum delay cap so searches don't stall out during long walks. + var maxDelay: TimeInterval = 1.0 + /// Maximum recursion depth allowed during file search. + var maxDepth: Int = 40 + /// Limit for repeated folder loops (guards against circular server listings). + var loopRepetitionLimit: Int = 4 + /// Number of child folders that get prioritized after a matching parent is found. + var hotBurstLimit: Int = 2 + /// Maximum age, in seconds, that a cached folder listing is treated as fresh. + var cacheTTL: TimeInterval = 60 * 15 + /// Upper bound on the number of folder listings retained in the cache. + var maxCachedFolders: Int = 1024 * 3 +} + +enum FileSearchStatus: Equatable { + case idle + case searching(processed: Int, pending: Int) + case completed(processed: Int) + case cancelled(processed: Int) + case failed(String) + + var isActive: Bool { + if case .searching = self { + return true + } + return false + } +} + +// MARK: - HotlineState + +@Observable @MainActor +class HotlineState: Equatable { + let id: UUID = UUID() + + nonisolated static func == (lhs: HotlineState, rhs: HotlineState) -> Bool { + return lhs.id == rhs.id + } + + // MARK: - Static Icon Data + + #if os(macOS) + static func getClassicIcon(_ index: Int) -> NSImage? { + return NSImage(named: "Classic/\(index)") + } + #elseif os(iOS) + static func getClassicIcon(_ index: Int) -> UIImage? { + return UIImage(named: "Classic/\(index)") + } + #endif + + static let classicIconSet: [Int] = [ + 141, 149, 150, 151, 172, 184, 204, + 2013, 2036, 2037, 2055, 2400, 2505, 2534, + 2578, 2592, 4004, 4015, 4022, 4104, 4131, + 4134, 4136, 4169, 4183, 4197, 4240, 4247, + 128, 129, 130, 131, 132, 133, 134, + 135, 136, 137, 138, 139, 140, 142, + 143, 144, 145, 146, 147, 148, 152, + 153, 154, 155, 156, 157, 158, 159, + 160, 161, 162, 163, 164, 165, 166, + 167, 168, 169, 170, 171, 173, 174, + 175, 176, 177, 178, 179, 180, 181, + 182, 183, 185, 186, 187, 188, 189, + 190, 191, 192, 193, 194, 195, 196, + 197, 198, 199, 200, 201, 202, 203, + 205, 206, 207, 208, 209, 212, 214, + 215, 220, 233, 236, 237, 243, 244, + 277, 410, 414, 500, 666, 1250, 1251, + 1968, 1969, 2000, 2001, 2002, 2003, 2004, + 2006, 2007, 2008, 2009, 2010, 2011, 2012, + 2014, 2015, 2016, 2017, 2018, 2019, 2020, + 2021, 2022, 2023, 2024, 2025, 2026, 2027, + 2028, 2029, 2030, 2031, 2032, 2033, 2034, + 2035, 2038, 2040, 2041, 2042, 2043, 2044, + 2045, 2046, 2047, 2048, 2049, 2050, 2051, + 2052, 2053, 2054, 2056, 2057, 2058, 2059, + 2060, 2061, 2062, 2063, 2064, 2065, 2066, + 2067, 2070, 2071, 2072, 2073, 2075, 2079, + 2098, 2100, 2101, 2102, 2103, 2104, 2105, + 2106, 2107, 2108, 2109, 2110, 2112, 2113, + 2115, 2116, 2117, 2118, 2119, 2120, 2121, + 2122, 2123, 2124, 2125, 2126, 4150, 2223, + 2401, 2402, 2403, 2404, 2500, 2501, 2502, + 2503, 2504, 2506, 2507, 2528, 2529, 2530, + 2531, 2532, 2533, 2535, 2536, 2537, 2538, + 2539, 2540, 2541, 2542, 2543, 2544, 2545, + 2546, 2547, 2548, 2549, 2550, 2551, 2552, + 2553, 2554, 2555, 2556, 2557, 2558, 2559, + 2560, 2561, 2562, 2563, 2564, 2565, 2566, + 2567, 2568, 2569, 2570, 2571, 2572, 2573, + 2574, 2575, 2576, 2577, 2579, 2580, 2581, + 2582, 2583, 2584, 2585, 2586, 2587, 2588, + 2589, 2590, 2591, 2593, 2594, 2595, 2596, + 2597, 2598, 2599, 2600, 4000, 4001, 4002, + 4003, 4005, 4006, 4007, 4008, 4009, 4010, + 4011, 4012, 4013, 4014, 4016, 4017, 4018, + 4019, 4020, 4021, 4023, 4024, 4025, 4026, + 4027, 4028, 4029, 4030, 4031, 4032, 4033, + 4034, 4035, 4036, 4037, 4038, 4039, 4040, + 4041, 4042, 4043, 4044, 4045, 4046, 4047, + 4048, 4049, 4050, 4051, 4052, 4053, 4054, + 4055, 4056, 4057, 4058, 4059, 4060, 4061, + 4062, 4063, 4064, 4065, 4066, 4067, 4068, + 4069, 4070, 4071, 4072, 4073, 4074, 4075, + 4076, 4077, 4078, 4079, 4080, 4081, 4082, + 4083, 4084, 4085, 4086, 4087, 4088, 4089, + 4090, 4091, 4092, 4093, 4094, 4095, 4096, + 4097, 4098, 4099, 4100, 4101, 4102, 4103, + 4105, 4106, 4107, 4108, 4109, 4110, 4111, + 4112, 4113, 4114, 4115, 4116, 4117, 4118, + 4119, 4120, 4121, 4122, 4123, 4124, 4125, + 4126, 4127, 4128, 4129, 4130, 4132, 4133, + 4135, 4137, 4138, 4139, 4140, 4141, 4142, + 4143, 4144, 4145, 4146, 4147, 4148, 4149, + 4151, 4152, 4153, 4154, 4155, 4156, 4157, + 4158, 4159, 4160, 4161, 4162, 4163, 4164, + 4165, 4166, 4167, 4168, 4170, 4171, 4172, + 4173, 4174, 4175, 4176, 4177, 4178, 4179, + 4180, 4181, 4182, 4184, 4185, 4186, 4187, + 4188, 4189, 4190, 4191, 4192, 4193, 4194, + 4195, 4196, 4198, 4199, 4200, 4201, 4202, + 4203, 4204, 4205, 4206, 4207, 4208, 4209, + 4210, 4211, 4212, 4213, 4214, 4215, 4216, + 4217, 4218, 4219, 4220, 4221, 4222, 4223, + 4224, 4225, 4226, 4227, 4228, 4229, 4230, + 4231, 4232, 4233, 4234, 4235, 4236, 4238, + 4241, 4242, 4243, 4244, 4245, 4246, 4248, + 4249, 4250, 4251, 4252, 4253, 4254, 31337, + 6001, 6002, 6003, 6004, 6005, 6008, 6009, + 6010, 6011, 6012, 6013, 6014, 6015, 6016, + 6017, 6018, 6023, 6025, 6026, 6027, 6028, + 6029, 6030, 6031, 6032, 6033, 6034, 6035 + ] + + // MARK: - Observable State + + var status: HotlineConnectionStatus = .disconnected + var server: Server? { + didSet { + self.updateServerTitle() + } + } + var serverVersion: UInt16 = 123 + var serverName: String? { + didSet { + self.updateServerTitle() + } + } + var serverTitle: String = "Server" + var username: String = "guest" + var iconID: Int = 414 + var access: HotlineUserAccessOptions? + var agreed: Bool = false + + // Users + var users: [User] = [] + + // Chat + var chat: [ChatMessage] = [] + var chatInput: String = "" + var unreadPublicChat: Bool = false + + // Instant Messages + var instantMessages: [UInt16:[InstantMessage]] = [:] + var unreadInstantMessages: [UInt16:UInt16] = [:] + + // Message Board + var messageBoard: [String] = [] + var messageBoardLoaded: Bool = false + + // News + var news: [NewsInfo] = [] + var newsLoaded: Bool = false + private var newsLookup: [String:NewsInfo] = [:] + + // Files + var files: [FileInfo] = [] + var filesLoaded: Bool = false + + // Accounts + var accounts: [HotlineAccount] = [] + var accountsLoaded: Bool = false + + // Banner + #if os(macOS) + var bannerImage: Image? = nil + var bannerColors: ColorArt? = nil + #elseif os(iOS) + var bannerImage: UIImage? = nil + #endif + + // Transfers (now stored globally in AppState) + /// Returns all transfers associated with this server + var transfers: [TransferInfo] { + AppState.shared.transfers.filter { $0.serverID == self.id } + } + + // Legacy transfer tracking (for old delegate-based downloads) +// @ObservationIgnored private var downloads: [HotlineTransferClient] = [] + @ObservationIgnored private var bannerDownloadTask: Task? = nil + + // File Search + var fileSearchResults: [FileInfo] = [] + var fileSearchStatus: FileSearchStatus = .idle + var fileSearchQuery: String = "" + var fileSearchConfig = FileSearchConfig() + var fileSearchScannedFolders: Int = 0 + var fileSearchCurrentPath: [String]? = nil + @ObservationIgnored private var fileSearchSession: HotlineStateFileSearchSession? = nil + @ObservationIgnored private var fileSearchResultKeys: Set = [] + + // File List Cache + private struct FileListCacheEntry { + let files: [FileInfo] + let timestamp: Date + } + @ObservationIgnored private var fileListCache: [String: FileListCacheEntry] = [:] + + // Error Display + var errorDisplayed: Bool = false + var errorMessage: String? = nil + + // MARK: - Private State + + @ObservationIgnored private var client: HotlineClientNew? + @ObservationIgnored private var eventTask: Task? + @ObservationIgnored private var chatSessionKey: ChatStore.SessionKey? + @ObservationIgnored private var restoredChatSessionKey: ChatStore.SessionKey? + @ObservationIgnored private var chatHistoryObserver: NSObjectProtocol? + @ObservationIgnored private var lastPersistedMessageType: ChatMessageType? + + // MARK: - Initialization + + init() { + self.chatHistoryObserver = NotificationCenter.default.addObserver( + forName: ChatStore.historyClearedNotification, + object: nil, + queue: .main + ) { @MainActor [weak self] _ in + self?.handleChatHistoryCleared() + } + } + + deinit { + if let observer = self.chatHistoryObserver { + NotificationCenter.default.removeObserver(observer) + } + } + + // MARK: - Connection + + @MainActor + func login(server: Server, username: String, iconID: Int) async throws { + print("HotlineState.login(): Starting login to \(server.address):\(server.port)") + self.server = server + self.username = username + self.iconID = iconID + self.status = .connecting + print("HotlineState.login(): Status set to connecting") + + // Set up chat session + let key = self.sessionKey(for: server) + self.chatSessionKey = key + self.restoredChatSessionKey = nil + self.lastPersistedMessageType = nil + self.chat = [] + self.restoreChatHistory(for: key) + print("HotlineState.login(): Chat session set up") + + do { + // Connect and login + let loginInfo = HotlineLoginInfo( + login: server.login, + password: server.password, + username: username, + iconID: UInt16(iconID) + ) + + print("HotlineState.login(): Calling HotlineClientNew.connect()...") + let client = try await HotlineClientNew.connect( + host: server.address, + port: UInt16(server.port), + login: loginInfo + ) + print("HotlineState.login(): HotlineClientNew.connect() returned") + + self.client = client + print("HotlineState.login(): Client stored") + + // Get server info + print("HotlineState.login(): Getting server info...") + if let serverInfo = await client.server { + self.serverVersion = serverInfo.version + if !serverInfo.name.isEmpty { + self.serverName = serverInfo.name + } + print("HotlineState.login(): Server info retrieved: \(serverInfo.name) v\(serverInfo.version)") + } + + self.status = .connected + print("HotlineState.login(): Status set to connected") + + // Request initial data before starting event loop + print("HotlineState.login(): Requesting user list...") + try await self.getUserList() + + self.status = .loggedIn + print("HotlineState.login(): Status set to loggedIn") + + if Prefs.shared.playSounds && Prefs.shared.playLoggedInSound { + SoundEffectPlayer.shared.playSoundEffect(.loggedIn) + } + + print("HotlineState.login(): Connected to \(self.serverTitle)") + print("HotlineState.login(): Scheduling post-login tasks...") + + // Defer event loop and post-login work to avoid layout recursion + // This allows login() to return and SwiftUI to complete its layout pass + // before we start receiving events that trigger state changes + Task { @MainActor in + print("HotlineState: Post-login: Starting event loop...") + self.startEventLoop() + + print("HotlineState: Post-login: Sending preferences...") + try? await self.sendUserPreferences() + + print("HotlineState: Post-login: Downloading banner...") + self.downloadBanner() + } + + } catch { + print("HotlineState.login(): Login failed with error: \(error)") + if let client = self.client { + await client.disconnect() + self.client = nil + } + self.status = .failed(error.localizedDescription) + self.errorDisplayed = true + self.errorMessage = error.localizedDescription + throw error + } + } + + /// Disconnect from the server (user-initiated) + @MainActor + func disconnect() async { + print("HotlineState.disconnect(): Called") + guard let client = self.client else { + print("HotlineState.disconnect(): No client, returning") + return + } + + // Stop event loop + print("HotlineState.disconnect(): Cancelling event task...") + self.eventTask?.cancel() + self.eventTask = nil + print("HotlineState.disconnect(): Event task cancelled") + + // Explicitly close the connection + print("HotlineState.disconnect(): Calling client.disconnect()...") + await client.disconnect() + print("HotlineState.disconnect(): client.disconnect() returned") + + // Clean up state + print("HotlineState.disconnect(): Calling handleConnectionClosed()...") + self.handleConnectionClosed() + print("HotlineState.disconnect(): disconnect() complete") + } + + /// Handle connection closure (server-initiated or after user disconnect) + @MainActor + private func handleConnectionClosed() { + print("HotlineState: handleConnectionClosed() entered") + guard self.client != nil else { + print("HotlineState: handleConnectionClosed() - client already nil, returning") + return + } + + print("HotlineState: Handling connection closure - recording chat...") + + // Record disconnect in chat history + if self.status == .loggedIn { + let message = ChatMessage(text: "Disconnected", type: .signOut, date: Date()) + self.recordChatMessage(message, persist: true, display: false) + } + + print("HotlineState: Cancelling banner and downloads...") + + self.bannerDownloadTask?.cancel() + self.bannerDownloadTask = nil + + // Cancel all downloads (both old delegate-based and new async downloads) +// self.downloads = [] + + // Cancel all transfers for this server +// self.cancelAllDownloads() + + // Cancel file search + self.fileSearchSession?.cancel() + self.fileSearchSession = nil + + // Clear client reference + self.client = nil + + print("HotlineState: Resetting state properties...") + + // Reset state immediately (constraint loop was caused by something else) + self.status = .disconnected + self.serverVersion = 123 + self.serverName = nil + self.access = nil + self.agreed = false + self.users = [] + self.chat = [] + self.instantMessages = [:] + self.unreadInstantMessages = [:] + self.unreadPublicChat = false + self.messageBoard = [] + self.messageBoardLoaded = false + self.news = [] + self.newsLoaded = false + self.newsLookup = [:] + self.files = [] + self.filesLoaded = false + self.accounts = [] + self.accountsLoaded = false + self.bannerImage = nil + self.bannerColors = nil + + print("HotlineState: Resetting file search...") + self.resetFileSearchState() + + self.chatSessionKey = nil + self.restoredChatSessionKey = nil + self.lastPersistedMessageType = nil + + print("HotlineState: Disconnected") + } + + @MainActor + func downloadBanner(force: Bool = false) { + guard self.serverVersion >= 150 else { + return + } + + if force { + self.bannerDownloadTask?.cancel() + self.bannerDownloadTask = nil + self.bannerImage = nil + self.bannerColors = nil + } else if self.bannerDownloadTask != nil || self.bannerImage != nil { + return + } + + let task = Task { @MainActor [weak self] in + defer { + self?.bannerDownloadTask = nil + } + + guard let self else { return } + guard let client = self.client, + let server = self.server, + let result = try? await client.downloadBanner(), + let address = server.address as String?, + let port = server.port as Int? + else { + return + } + + do { + print("HotlineState: Banner download info - reference: \(result.referenceNumber), transferSize: \(result.transferSize)") + + let previewClient = HotlineFilePreviewClientNew( + fileName: "banner", + address: address, + port: UInt16(port), + reference: result.referenceNumber, + size: UInt32(result.transferSize) + ) + + let fileURL = try await previewClient.preview() + defer { + previewClient.cleanup() + } + + guard self.client != nil else { return } + + let data = try Data(contentsOf: fileURL) + print("HotlineState: Banner download complete, data size: \(data.count) bytes") + +#if os(macOS) + guard let image = NSImage(data: data) else { + print("HotlineState: Failed to create NSImage from banner data") + return + } + let blah = Image(nsImage: image) +#elseif os(iOS) + guard let image = UIImage(data: data) else { + print("HotlineState: Failed to create UIImage from banner data") + return + } + self.bannerImage = Image(uiImage: image) +#endif + self.bannerImage = blah + self.bannerColors = ColorArt.analyze(image: image) + + } catch { + print("HotlineState: Banner download failed: \(error)") + } + } + + self.bannerDownloadTask = task + } + + // MARK: - Event Loop + + private func startEventLoop() { + print("HotlineState.startEventLoop(): Called") + guard let client = self.client else { + print("HotlineState.startEventLoop(): No client, returning") + return + } + + print("HotlineState.startEventLoop(): Creating event loop task") + self.eventTask = Task { @MainActor [weak self, client] in + guard let self else { + print("HotlineState.startEventLoop(): Self is nil in task, exiting") + return + } + + print("HotlineState.startEventLoop(): Event loop started, awaiting events...") + for await event in client.events { + print("HotlineState.startEventLoop(): Received event: \(event)") + self.handleEvent(event) + } + + // Event stream ended - server disconnected us + print("HotlineState.startEventLoop(): Event stream ended, calling handleConnectionClosed()...") + self.handleConnectionClosed() + print("HotlineState.startEventLoop(): handleConnectionClosed() returned, event loop task complete") + } + print("HotlineState.startEventLoop(): Event loop task created") + } + + @MainActor + private func handleEvent(_ event: HotlineEvent) { + switch event { + case .chatMessage(let text): + self.handleChatMessage(text) + + case .userChanged(let user): + self.handleUserChanged(user) + + case .userDisconnected(let userID): + self.handleUserDisconnected(userID) + + case .serverMessage(let message): + self.handleServerMessage(message) + + case .privateMessage(let userID, let message): + self.handlePrivateMessage(userID: userID, message: message) + + case .newsPost(let message): + self.handleNewsPost(message) + + case .agreementRequired(let text): + let message = ChatMessage(text: text, type: .agreement, date: Date()) + self.recordChatMessage(message, persist: false) + + case .userAccess(let options): + self.access = options + print("HotlineState: Got access options") + HotlineUserAccessOptions.printAccessOptions(options) + } + } + + // MARK: - Chat + + @MainActor + func sendChat(_ text: String, announce: Bool = false) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + try await client.sendChat(text, announce: announce) + } + + @MainActor + func sendInstantMessage(_ text: String, userID: UInt16) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + let message = InstantMessage( + direction: .outgoing, + text: text.convertingLinksToMarkdown(), + type: .message, + date: Date() + ) + + if self.instantMessages[userID] == nil { + self.instantMessages[userID] = [message] + } else { + self.instantMessages[userID]!.append(message) + } + + try await client.sendInstantMessage(text, to: userID) + + if Prefs.shared.playPrivateMessageSound { + SoundEffectPlayer.shared.playSoundEffect(.chatMessage) + } + } + + @MainActor + func sendAgree() async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + try await client.sendAgree() + self.agreed = true + } + + @MainActor + /// Send current user preferences from Prefs to the server + func sendUserPreferences() async throws { + var options: HotlineUserOptions = [] + + if Prefs.shared.refusePrivateMessages { + options.update(with: .refusePrivateMessages) + } + + if Prefs.shared.refusePrivateChat { + options.update(with: .refusePrivateChat) + } + + if Prefs.shared.enableAutomaticMessage { + options.update(with: .automaticResponse) + } + + print("HotlineState.sendUserPreferences(): Updating user info with server") + + try await self.sendUserInfo( + username: Prefs.shared.username, + iconID: Prefs.shared.userIconID, + options: options, + autoresponse: Prefs.shared.automaticMessage + ) + } + + func sendUserInfo(username: String, iconID: Int, options: HotlineUserOptions = [], autoresponse: String? = nil) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + self.username = username + self.iconID = iconID + + try await client.setClientUserInfo( + username: username, + iconID: UInt16(iconID), + options: options, + autoresponse: autoresponse + ) + } + + func markPublicChatAsRead() { + self.unreadPublicChat = false + } + + func hasUnreadInstantMessages(userID: UInt16) -> Bool { + return self.unreadInstantMessages[userID] != nil + } + + func markInstantMessagesAsRead(userID: UInt16) { + self.unreadInstantMessages.removeValue(forKey: userID) + } + + @MainActor + func searchChat(query: String) -> [ChatMessage] { + guard !query.isEmpty else { + return [] + } + + // Create a map of all messages by ID to deduplicate + var messageMap: [UUID: ChatMessage] = [:] + + // Add current in-memory messages + for message in self.chat { + messageMap[message.id] = message + } + + // Filter messages based on query + let filteredMessages = messageMap.values.filter { message in + // Never include agreement messages + if message.type == .agreement { + return false + } + + // Always include disconnect messages to show session boundaries + let isDisconnect = message.type == .signOut + + // Search in text and username + let matchesText = message.text.localizedCaseInsensitiveContains(query) + let matchesUsername = message.username?.localizedCaseInsensitiveContains(query) == true + let matchesQuery = matchesText || matchesUsername + + return isDisconnect || matchesQuery + } + + // Sort by date to maintain chronological order + let sortedMessages = filteredMessages.sorted { $0.date < $1.date } + + // Remove consecutive disconnect messages to avoid visual clutter + var deduplicated: [ChatMessage] = [] + var lastWasDisconnect = false + + for message in sortedMessages { + let isDisconnect = message.type == .signOut + + if isDisconnect && lastWasDisconnect { + continue + } + + deduplicated.append(message) + lastWasDisconnect = isDisconnect + } + + // Remove leading disconnect message + if deduplicated.first?.type == .signOut { + deduplicated.removeFirst() + } + + // Remove trailing disconnect message + if deduplicated.last?.type == .signOut { + deduplicated.removeLast() + } + + return deduplicated + } + + // MARK: - Users + + @MainActor + func getUserList() async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + let hotlineUsers = try await client.getUserList() + self.users = hotlineUsers.map { User(hotlineUser: $0) } + } + + // MARK: - Files (Basic) + + @MainActor + @discardableResult + func getFileList(path: [String] = [], suppressErrors: Bool = false, preferCache: Bool = false) async throws -> [FileInfo] { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + // Check cache first if preferred + if preferCache, let cached = self.cachedFileList(for: path, ttl: self.fileSearchConfig.cacheTTL, allowStale: false) { + return cached.items + } + + let hotlineFiles = try await client.getFileList(path: path) + let newFiles = hotlineFiles.map { FileInfo(hotlineFile: $0) } + + // Update UI state + if path.isEmpty { + self.filesLoaded = true + self.files = newFiles + } else { + // Update parent's children + let parentFile = self.findFile(in: self.files, at: path) + parentFile?.children = newFiles + } + + // Cache the result + self.storeFileListInCache(newFiles, for: path) + + return newFiles + } + + @MainActor + func getFileDetails(_ fileName: String, path: [String]) async throws -> FileDetails? { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + var fullPath: [String] = [] + if path.count > 1 { + fullPath = Array(path[0.. Bool { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + var fullPath: [String] = [] + if path.count > 1 { + fullPath = Array(path[0.. Void)? = nil, complete callback: ((TransferInfo, URL) -> Void)? = nil) { +// guard let client = self.client else { return } +// +// var fullPath: [String] = [] +// if path.count > 1 { +// fullPath = Array(path[0.. Void)? = nil, complete callback: ((TransferInfo) -> Void)? = nil) { + guard let client = self.client else { return } + + var fullPath: [String] = [] + if path.count > 1 { + fullPath = Array(path[0.. Void)? = nil) { +// guard let client = self.client else { return } +// +// var fullPath: [String] = [] +// if path.count > 1 { +// fullPath = Array(path[0.. Void)? = nil, + itemProgress itemProgressCallback: ((TransferInfo, String, Int, Int) -> Void)? = nil, + complete callback: ((TransferInfo) -> Void)? = nil + ) { + guard let client = self.client else { return } + + var fullPath: [String] = [] + if path.count > 1 { + fullPath = Array(path[0.. Void)? = nil, + itemProgress itemProgressCallback: ((TransferInfo, String, Int, Int) -> Void)? = nil, + complete callback: ((TransferInfo) -> Void)? = nil + ) { + guard let client = self.client else { return } + + let folderName = folderURL.lastPathComponent + + guard folderURL.isFileURL, !folderName.isEmpty else { + print("HotlineState: Not a valid folder URL") + return + } + + let folderPath = folderURL.path(percentEncoded: false) + + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: folderPath, isDirectory: &isDirectory), + isDirectory.boolValue == true else { + print("HotlineState: URL is not a folder") + return + } + + // Get the total size of the folder (all files) + guard let (folderSize, fileCount) = FileManager.default.getFolderSize(folderURL) else { + print("HotlineState: Could not determine folder size") + return + } + + print("HotlineState: Requesting upload for folder '\(folderName)' - \(fileCount) items, \(folderSize) bytes total") + + Task { @MainActor [weak self] in + guard let self else { return } + + // Request folder upload from server. + // The enumerator already omits the root folder, so report the full item count the server should expect. + let reportedItemCount = fileCount + print("HotlineState: Reporting \(reportedItemCount) items to server (enumerated count)") + guard let referenceNumber = try? await client.uploadFolder(name: folderName, path: path, fileCount: reportedItemCount, totalSize: UInt32(folderSize)), + let server = self.server, + let address = server.address as String?, + let port = server.port as Int? + else { + print("HotlineState: Failed to get upload reference from server") + return + } + + // Invalidate cache for the upload destination + self.invalidateFileListCache(for: path, includingAncestors: true) + + print("HotlineState: Got folder upload reference: \(referenceNumber)") + + // Create upload client + guard let uploadClient = HotlineFolderUploadClientNew( + folderURL: folderURL, + address: address, + port: UInt16(port), + reference: referenceNumber + ) else { + print("HotlineState: Failed to create folder upload client") + return + } + + // Create transfer info for tracking (stored globally in AppState) + let transfer = TransferInfo( + reference: referenceNumber, + title: folderName, + size: UInt(folderSize), + serverID: self.id, + serverName: self.serverName ?? self.serverTitle + ) + transfer.isFolder = true + transfer.uploadCallback = callback + transfer.progressCallback = progressCallback + AppState.shared.addTransfer(transfer) + + // Create and store the upload task + let uploadTask = Task { @MainActor [weak self] in + guard self != nil else { return } + + do { + // Upload folder with progress tracking + try await uploadClient.upload(progress: { progress in + switch progress { + case .preparing: + break + case .unconnected, .connected, .connecting: + break + case .transfer(name: _, size: _, total: _, progress: let progress, speed: let speed, estimate: let estimate): + transfer.timeRemaining = estimate + transfer.speed = speed + transfer.progress = progress + transfer.progressCallback?(transfer) + case .error(_): + transfer.failed = true + case .completed(url: _): + transfer.completed = true + } + }, itemProgress: { itemInfo in + // Update transfer title with current file being uploaded + transfer.title = "\(itemInfo.fileName) (\(itemInfo.itemNumber)/\(itemInfo.totalItems))" + itemProgressCallback?(transfer, itemInfo.fileName, itemInfo.itemNumber, itemInfo.totalItems) + }) + + // Mark as completed + transfer.progress = 1.0 + transfer.title = folderName // Reset title to folder name + + // Call completion callback + transfer.uploadCallback?(transfer) + + print("HotlineState: Folder upload complete - \(folderName)") + + } catch is CancellationError { + // Upload was cancelled + print("HotlineState: Folder upload cancelled") + } catch { + // Mark as failed + transfer.failed = true + print("HotlineState: Folder upload failed - \(error)") + } + + AppState.shared.unregisterTransferTask(for: transfer.id) + } + + // Store the task in AppState so it can be cancelled later + AppState.shared.registerTransferTask(uploadTask, transferID: transfer.id) + } + } + + func uploadFile(url fileURL: URL, path: [String], complete callback: ((TransferInfo) -> Void)? = nil) { + guard let client = self.client else { return } + + let fileName = fileURL.lastPathComponent + + guard fileURL.isFileURL, !fileName.isEmpty else { + print("HotlineState: Not a valid file URL") + return + } + + let filePath = fileURL.path(percentEncoded: false) + + var fileIsDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: filePath, isDirectory: &fileIsDirectory), + fileIsDirectory.boolValue == false else { + print("HotlineState: File is a directory") + return + } + + // Get the flattened file size (includes all forks and headers) + guard let payloadSize = FileManager.default.getFlattenedFileSize(fileURL) else { + print("HotlineState: Could not determine file size") + return + } + + Task { @MainActor [weak self] in + guard let self else { return } + + // Request upload from server + guard let referenceNumber = try? await client.uploadFile(name: fileName, path: path), + let server = self.server, + let address = server.address as String?, + let port = server.port as Int? + else { + print("HotlineState: Failed to get upload reference from server") + return + } + + // Invalidate cache for the upload destination + self.invalidateFileListCache(for: path, includingAncestors: true) + + print("HotlineState: Got upload reference: \(referenceNumber)") + + // Create upload client + guard let uploadClient = HotlineFileUploadClientNew( + fileURL: fileURL, + address: address, + port: UInt16(port), + reference: referenceNumber + ) else { + print("HotlineState: Failed to create upload client") + return + } + + // Create transfer info for tracking (stored globally in AppState) + let transfer = TransferInfo( + reference: referenceNumber, + title: fileName, + size: UInt(payloadSize), + serverID: self.id, + serverName: self.serverName ?? self.serverTitle + ) + transfer.uploadCallback = callback + AppState.shared.addTransfer(transfer) + + // Create and store the upload task + let uploadTask = Task { @MainActor [weak self] in + guard self != nil else { return } + + do { + // Upload file with progress tracking + try await uploadClient.upload { progress in + switch progress { + case .preparing: + break + case .unconnected, .connected, .connecting: + break + case .transfer(name: _, size: _, total: _, progress: let progress, speed: let speed, estimate: let estimate): + transfer.timeRemaining = estimate + transfer.speed = speed + transfer.progress = progress + case .error(_): + transfer.failed = true + case .completed(url: _): + transfer.completed = true + } + } + + // Mark as completed + transfer.progress = 1.0 + + // Call completion callback + transfer.uploadCallback?(transfer) + + print("HotlineState: Upload complete - \(fileName)") + + } catch is CancellationError { + // Upload was cancelled + print("HotlineState: Upload cancelled") + } catch { + // Mark as failed + transfer.failed = true + print("HotlineState: Upload failed - \(error)") + } + + AppState.shared.unregisterTransferTask(for: transfer.id) + } + + // Store the transfer + AppState.shared.registerTransferTask(uploadTask, transferID: transfer.id) + } + } + + func setFileInfo(fileName: String, path filePath: [String], fileNewName: String?, comment: String?, encoding: String.Encoding = .utf8) { + // TODO: Implement setFileInfo in HotlineClientNew + // This method updates file metadata (name and/or comment) + print("setFileInfo not yet implemented in HotlineState/HotlineClientNew") + } + + @MainActor + func previewFile(_ fileName: String, path: [String], complete callback: ((PreviewFileInfo?) -> Void)? = nil) { + guard let client = self.client else { + callback?(nil) + return + } + + var fullPath: [String] = [] + if path.count > 1 { + fullPath = Array(path[0.. [HotlineAccount] { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + self.accounts = try await client.getAccounts() + self.accountsLoaded = true + return self.accounts + } + + @MainActor + func createUser(name: String, login: String, password: String?, access: UInt64) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + try await client.createUser(name: name, login: login, password: password, access: access) + + // Refresh accounts list + self.accounts = try await client.getAccounts() + } + + @MainActor + func setUser(name: String, login: String, newLogin: String?, password: String?, access: UInt64) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + try await client.setUser(name: name, login: login, newLogin: newLogin, password: password, access: access) + + // Refresh accounts list + self.accounts = try await client.getAccounts() + } + + @MainActor + func deleteUser(login: String) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + try await client.deleteUser(login: login) + + // Refresh accounts list + self.accounts = try await client.getAccounts() + } + + // MARK: - Message Board + + @MainActor + func getMessageBoard() async throws -> [String] { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + self.messageBoard = try await client.getMessageBoard() + self.messageBoardLoaded = true + return self.messageBoard + } + + @MainActor + func postToMessageBoard(text: String) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + try await client.postMessageBoard(text) + } + + // MARK: - News + + @MainActor + func getNewsList(at path: [String] = []) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + let parentNewsGroup = self.findNews(in: self.news, at: path) + + // Send a categories request for bundle paths or root (empty path) + if path.isEmpty || parentNewsGroup?.type == .bundle { + print("HotlineState: Requesting categories at: /\(path.joined(separator: "/"))") + + let categories = try await client.getNewsCategories(path: path) + + // Create info for each category returned + var newCategoryInfos: [NewsInfo] = [] + + // Transform hotline categories into NewsInfo objects + for category in categories { + var newsCategoryInfo = NewsInfo(hotlineNewsCategory: category) + + if let lookupPath = newsCategoryInfo.lookupPath { + // Merge returned category info with existing category info + if let existingCategoryInfo = self.newsLookup[lookupPath] { + print("HotlineState: Merging category into existing category at \(lookupPath)") + + existingCategoryInfo.count = newsCategoryInfo.count + existingCategoryInfo.name = newsCategoryInfo.name + existingCategoryInfo.path = newsCategoryInfo.path + existingCategoryInfo.categoryID = newsCategoryInfo.categoryID + newsCategoryInfo = existingCategoryInfo + } else { + print("HotlineState: New category added at \(lookupPath)") + self.newsLookup[lookupPath] = newsCategoryInfo + } + } + + newCategoryInfos.append(newsCategoryInfo) + } + + if let parent = parentNewsGroup { + parent.children = newCategoryInfos + } else if path.isEmpty { + self.newsLoaded = true + self.news = newCategoryInfos + } + } else { + print("HotlineState: Requesting articles at: /\(path.joined(separator: "/"))") + + let articles = try await client.getNewsArticles(path: path) + + print("HotlineState: Organizing news at \(path.joined(separator: "/"))") + + // Create info for each article returned + var newArticleInfos: [NewsInfo] = [] + + for article in articles { + var newsArticleInfo = NewsInfo(hotlineNewsArticle: article) + + if let lookupPath = newsArticleInfo.lookupPath { + // Merge returned category info with existing category info + if let existingArticleInfo = self.newsLookup[lookupPath] { + print("HotlineState: Merging article into existing article at \(lookupPath)") + + existingArticleInfo.count = newsArticleInfo.count + existingArticleInfo.name = newsArticleInfo.name + existingArticleInfo.path = newsArticleInfo.path + existingArticleInfo.articleUsername = newsArticleInfo.articleUsername + existingArticleInfo.articleDate = newsArticleInfo.articleDate + existingArticleInfo.articleFlavors = newsArticleInfo.articleFlavors + existingArticleInfo.articleID = newsArticleInfo.articleID + newsArticleInfo = existingArticleInfo + } else { + print("HotlineState: New article added at \(lookupPath)") + self.newsLookup[lookupPath] = newsArticleInfo + } + } + + newArticleInfos.append(newsArticleInfo) + } + + let organizedNewsArticles: [NewsInfo] = self.organizeNewsArticles(newArticleInfos) + if let parent = parentNewsGroup { + parent.children = organizedNewsArticles + } + } + } + + @MainActor + func getNewsArticle(id articleID: UInt, at path: [String], flavor: String = "text/plain") async throws -> String? { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + return try await client.getNewsArticle(id: UInt32(articleID), path: path, flavor: flavor) + } + + @MainActor + func postNewsArticle(title: String, body: String, at path: [String], parentID: UInt32 = 0) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + try await client.postNewsArticle(title: title, text: body, path: path, parentID: parentID) + print("HotlineState: News article posted") + } + + // MARK: - File Search + + @MainActor + func startFileSearch(query: String) { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + self.cancelFileSearch() + return + } + + self.fileSearchSession?.cancel() + self.resetFileSearchState() + self.fileSearchQuery = trimmed + self.fileSearchStatus = .searching(processed: 0, pending: 0) + self.fileSearchScannedFolders = 0 + self.fileSearchCurrentPath = [] + + let session = HotlineStateFileSearchSession(hotlineState: self, query: trimmed, config: self.fileSearchConfig) + self.fileSearchSession = session + + Task { await session.start() } + } + + @MainActor + func cancelFileSearch(clearResults: Bool = true) { + guard let session = self.fileSearchSession else { + if clearResults { + self.resetFileSearchState() + } else if !self.fileSearchResults.isEmpty { + self.fileSearchStatus = .cancelled(processed: self.fileSearchScannedFolders) + self.fileSearchCurrentPath = nil + } + return + } + + session.cancel() + self.fileSearchSession = nil + self.fileSearchCurrentPath = nil + + if clearResults { + self.resetFileSearchState() + } else { + self.fileSearchStatus = .cancelled(processed: self.fileSearchScannedFolders) + } + } + + @MainActor + func clearFileListCache() { + guard !self.fileListCache.isEmpty else { + return + } + + self.fileListCache.removeAll(keepingCapacity: false) + } + + @MainActor + fileprivate func searchSession(_ session: HotlineStateFileSearchSession, didEmit matches: [FileInfo], processed: Int, pending: Int) { + guard self.fileSearchSession === session else { + return + } + + var appended: [FileInfo] = [] + for match in matches { + let key = self.searchPathKey(for: match.path) + if self.fileSearchResultKeys.insert(key).inserted { + appended.append(match) + } + } + + if !appended.isEmpty { + self.fileSearchResults.append(contentsOf: appended) + } + + self.fileSearchScannedFolders = processed + self.fileSearchStatus = .searching(processed: processed, pending: pending) + } + + @MainActor + fileprivate func searchSession(_ session: HotlineStateFileSearchSession, didFocusOn path: [String]) { + guard self.fileSearchSession === session else { + return + } + + self.fileSearchCurrentPath = path + } + + @MainActor + fileprivate func searchSessionDidFinish(_ session: HotlineStateFileSearchSession, processed: Int, pending: Int, completed: Bool) { + guard self.fileSearchSession === session else { + return + } + + self.fileSearchScannedFolders = processed + self.fileSearchSession = nil + self.fileSearchCurrentPath = nil + + if completed { + self.fileSearchStatus = .completed(processed: processed) + } else { + self.fileSearchStatus = .cancelled(processed: processed) + } + } + + fileprivate func cachedListingForSearch(path: [String], ttl: TimeInterval) -> (items: [FileInfo], isFresh: Bool)? { + self.cachedFileList(for: path, ttl: ttl, allowStale: true) + } + + // MARK: - Event Handlers + + private func handleChatMessage(_ text: String) { + if Prefs.shared.playSounds && Prefs.shared.playChatSound { + SoundEffectPlayer.shared.playSoundEffect(.chatMessage) + } + + let chatMessage = ChatMessage(text: text, type: .message, date: Date()) + self.recordChatMessage(chatMessage) + self.unreadPublicChat = true + } + + private func handleUserChanged(_ user: HotlineUser) { + self.addOrUpdateHotlineUser(user) + } + + private func handleUserDisconnected(_ userID: UInt16) { + if let existingUserIndex = self.users.firstIndex(where: { $0.id == UInt(userID) }) { + let user = self.users.remove(at: existingUserIndex) + + if Prefs.shared.showJoinLeaveMessages { + let chatMessage = ChatMessage(text: "\(user.name) left", type: .left, date: Date()) + self.recordChatMessage(chatMessage) + } + + if Prefs.shared.playSounds && Prefs.shared.playLeaveSound { + SoundEffectPlayer.shared.playSoundEffect(.userLogout) + } + } + } + + private func handleServerMessage(_ message: String) { + if Prefs.shared.playSounds && Prefs.shared.playChatSound { + SoundEffectPlayer.shared.playSoundEffect(.serverMessage) + } + + print("HotlineState: received server message:\n\(message)") + let chatMessage = ChatMessage(text: message, type: .server, date: Date()) + self.recordChatMessage(chatMessage) + } + + private func handlePrivateMessage(userID: UInt16, message: String) { + if let existingUserIndex = self.users.firstIndex(where: { $0.id == UInt(userID) }) { + let user = self.users[existingUserIndex] + print("HotlineState: received private message from \(user.name): \(message)") + + if Prefs.shared.playPrivateMessageSound { + if self.unreadInstantMessages[userID] == nil { + SoundEffectPlayer.shared.playSoundEffect(.serverMessage) + } else { + SoundEffectPlayer.shared.playSoundEffect(.chatMessage) + } + } + + let instantMessage = InstantMessage( + direction: .incoming, + text: message.convertingLinksToMarkdown(), + type: .message, + date: Date() + ) + + if self.instantMessages[userID] == nil { + self.instantMessages[userID] = [instantMessage] + } else { + self.instantMessages[userID]!.append(instantMessage) + } + + self.unreadInstantMessages[userID] = userID + } + } + + private func handleNewsPost(_ message: String) { + let messageBoardRegex = /([\s\r\n]*[_\-]+[\s\r\n]+)/ + let matches = message.matches(of: messageBoardRegex) + + if matches.count == 1 { + let range = matches[0].range + self.messageBoard.insert(String(message[message.startIndex.. ChatStore.SessionKey { + ChatStore.SessionKey(address: server.address.lowercased(), port: server.port) + } + + private func recordChatMessage(_ message: ChatMessage, persist: Bool = true, display: Bool = true) { + let shouldPersist = persist && message.type != .agreement + if shouldPersist, + message.type == .signOut, + self.lastPersistedMessageType == .signOut { + return + } + + if display { + self.chat.append(message) + } + + guard shouldPersist, let key = self.chatSessionKey else { return } + self.lastPersistedMessageType = message.type + + let entry = ChatStore.Entry( + id: message.id, + body: message.text, + username: message.username, + type: message.type.storageKey, + date: message.date + ) + let serverName = self.serverName ?? self.server?.name + + Task { + await ChatStore.shared.append(entry: entry, for: key, serverName: serverName) + } + } + + private func restoreChatHistory(for key: ChatStore.SessionKey) { + if self.restoredChatSessionKey == key { + return + } + + Task { [weak self] in + guard let self else { return } + let result = await ChatStore.shared.loadHistory(for: key) + + await MainActor.run { + guard self.chatSessionKey == key, self.restoredChatSessionKey != key else { return } + + let currentMessages = self.chat + let historyMessages = result.entries.compactMap { entry -> ChatMessage? in + guard let chatType = ChatMessageType(storageKey: entry.type) else { return nil } + + let renderedText: String + if chatType == .message, let username = entry.username, !username.isEmpty { + renderedText = "\(username): \(entry.body)" + } else { + renderedText = entry.body + } + + var message = ChatMessage(text: renderedText, type: chatType, date: entry.date) + message.metadata = entry.metadata + return message + } + + self.chat = historyMessages + currentMessages + self.lastPersistedMessageType = historyMessages.last?.type + self.unreadPublicChat = false + self.restoredChatSessionKey = key + } + } + } + + private func handleChatHistoryCleared() { + self.chat = [] + self.unreadPublicChat = false + self.restoredChatSessionKey = nil + self.lastPersistedMessageType = nil + } + + // MARK: - Utilities + + func updateServerTitle() { + self.serverTitle = self.serverName ?? self.server?.name ?? self.server?.address ?? "Server" + } + + // News helpers + func organizeNewsArticles(_ flatArticles: [NewsInfo]) -> [NewsInfo] { + // Place articles under their parent + var organized: [NewsInfo] = [] + for article in flatArticles { + if let parentLookupPath = article.parentArticleLookupPath, + let parentArticle = self.newsLookup[parentLookupPath] { + if parentArticle.children.firstIndex(of: article) == nil { + article.expanded = true + parentArticle.children.append(article) + } + } else { + organized.append(article) + } + } + + return organized + } + + private func findNews(in newsToSearch: [NewsInfo], at path: [String]) -> NewsInfo? { + guard !path.isEmpty, !newsToSearch.isEmpty, let currentName = path.first else { return nil } + + for news in newsToSearch { + if news.name == currentName { + if path.count == 1 { + return news + } else if !news.children.isEmpty { + let remainingPath = Array(path[1...]) + return self.findNews(in: news.children, at: remainingPath) + } + } + } + + return nil + } + + // File helpers + private func findFile(in filesToSearch: [FileInfo], at path: [String]) -> FileInfo? { + guard !path.isEmpty, !filesToSearch.isEmpty else { return nil } + + let currentName = path[0] + + for file in filesToSearch { + if file.name == currentName { + if path.count == 1 { + return file + } else if let subfiles = file.children { + let remainingPath = Array(path[1...]) + return self.findFile(in: subfiles, at: remainingPath) + } + } + } + + return nil + } + + // File search helpers + private func searchPathKey(for path: [String]) -> String { + path.joined(separator: "\u{001F}") + } + + private func resetFileSearchState() { + self.fileSearchResults = [] + self.fileSearchResultKeys.removeAll(keepingCapacity: true) + self.fileSearchStatus = .idle + self.fileSearchQuery = "" + self.fileSearchScannedFolders = 0 + self.fileSearchCurrentPath = nil + } + + // File cache helpers + private func shouldBypassFileCache(for path: [String]) -> Bool { + guard let folderName = path.last else { + return false + } + + let trimmed = folderName.trimmingCharacters(in: .whitespacesAndNewlines) + + if trimmed.range(of: "upload", options: [.caseInsensitive]) != nil { + return true + } + + if trimmed.range(of: "dropbox", options: [.caseInsensitive]) != nil { + return true + } + + if trimmed.range(of: "drop box", options: [.caseInsensitive]) != nil { + return true + } + + return false + } + + private func cachedFileList(for path: [String], ttl: TimeInterval, allowStale: Bool) -> (items: [FileInfo], isFresh: Bool)? { + guard ttl > 0 else { + return nil + } + + if self.shouldBypassFileCache(for: path) { + return nil + } + + let key = self.searchPathKey(for: path) + guard let entry = self.fileListCache[key] else { + return nil + } + + let age = Date().timeIntervalSince(entry.timestamp) + let isFresh = age <= ttl + if !allowStale && !isFresh { + return nil + } + + return (entry.files, isFresh) + } + + private func storeFileListInCache(_ files: [FileInfo], for path: [String]) { + guard self.fileSearchConfig.cacheTTL > 0 else { + return + } + + if self.shouldBypassFileCache(for: path) { + return + } + + let key = self.searchPathKey(for: path) + self.fileListCache[key] = FileListCacheEntry(files: files, timestamp: Date()) + self.pruneFileListCacheIfNeeded() + } + + private func pruneFileListCacheIfNeeded() { + let limit = self.fileSearchConfig.maxCachedFolders + guard limit > 0, self.fileListCache.count > limit else { + return + } + + let excess = self.fileListCache.count - limit + guard excess > 0 else { return } + + let sortedKeys = self.fileListCache.sorted { lhs, rhs in + lhs.value.timestamp < rhs.value.timestamp + } + + for index in 0.. = [] + private var loopHistogram: [String: Int] = [:] + + private var processedCount: Int = 0 + private var currentDelay: TimeInterval + private var isCancelled = false + + init(hotlineState: HotlineState, query: String, config: FileSearchConfig) { + self.hotlineState = hotlineState + self.queryTokens = query.lowercased().split(separator: " ").map(String.init) + self.config = config + self.currentDelay = config.initialDelay + } + + func start() async { + guard let hotlineState else { + return + } + + await Task.yield() + + if !hotlineState.filesLoaded { + hotlineState.searchSession(self, didFocusOn: []) + let rootFiles = try? await hotlineState.getFileList(path: [], suppressErrors: true, preferCache: true) + self.processedCount = max(self.processedCount, 1) + self.processListing(rootFiles ?? [], depth: 0, parentPath: [], parentIsHot: false) + } else { + hotlineState.searchSession(self, didFocusOn: []) + self.processedCount = max(self.processedCount, 1) + self.processListing(hotlineState.files, depth: 0, parentPath: [], parentIsHot: false) + } + + while !self.queue.isEmpty && !self.isCancelled { + await Task.yield() + + guard let task = self.dequeueNextTask() else { + continue + } + + if self.shouldSkip(path: task.path, depth: task.depth) { + hotlineState.searchSession(self, didEmit: [], processed: self.processedCount, pending: self.queue.count) + continue + } + + hotlineState.searchSession(self, didFocusOn: task.path) + self.visited.insert(self.pathKey(for: task.path)) + + if let cached = hotlineState.cachedListingForSearch(path: task.path, ttl: self.config.cacheTTL) { + if cached.isFresh { + self.processedCount += 1 + self.processListing(cached.items, depth: task.depth, parentPath: task.path, parentIsHot: task.isHot) + continue + } else { + self.processListing(cached.items, depth: task.depth, parentPath: task.path, parentIsHot: task.isHot) + } + } + + let children = try? await hotlineState.getFileList(path: task.path, suppressErrors: true) + self.processedCount += 1 + + if self.isCancelled { + break + } + + self.processListing(children ?? [], depth: task.depth, parentPath: task.path, parentIsHot: task.isHot) + + await self.applyBackoff() + } + + hotlineState.searchSessionDidFinish(self, processed: self.processedCount, pending: self.queue.count, completed: !self.isCancelled) + } + + func cancel() { + self.isCancelled = true + } + + private func processListing(_ items: [FileInfo], depth: Int, parentPath: [String], parentIsHot: Bool) { + guard let hotlineState else { + return + } + + var matches: [FileInfo] = [] + var folderEntries: [(file: FileInfo, isHot: Bool)] = [] + var hasFileMatch = false + + for file in items { + let matchesName = self.nameMatchesQuery(file.name) + + if matchesName { + matches.append(file) + if !file.isFolder { + hasFileMatch = true + } + } + + if file.isFolder && !file.isAppBundle { + folderEntries.append((file, matchesName)) + } + } + + var remainingBurst = 0 + if self.config.hotBurstLimit > 0 && (parentIsHot || hasFileMatch) { + remainingBurst = self.config.hotBurstLimit + } + + if remainingBurst > 0 { + var candidateIndices: [Int] = [] + for index in folderEntries.indices where !folderEntries[index].isHot { + candidateIndices.append(index) + } + + if !candidateIndices.isEmpty { + candidateIndices.shuffle() + for index in candidateIndices { + folderEntries[index].isHot = true + remainingBurst -= 1 + if remainingBurst == 0 { + break + } + } + } + } + + for entry in folderEntries { + self.enqueueFolder(entry.file, depth: depth + 1, markHot: entry.isHot) + } + + hotlineState.searchSession(self, didEmit: matches, processed: self.processedCount, pending: self.queue.count) + } + + private func enqueueFolder(_ folder: FileInfo, depth: Int, markHot: Bool) { + guard !self.isCancelled else { return } + guard depth <= self.config.maxDepth else { return } + + let path = folder.path + let key = self.pathKey(for: path) + guard !self.visited.contains(key) else { return } + + if self.exceedsLoopThreshold(for: path) { + return + } + + self.queue.append(FolderTask(path: path, depth: depth, isHot: markHot)) + } + + private func dequeueNextTask() -> FolderTask? { + guard !self.queue.isEmpty else { + return nil + } + + if self.queue.count == 1 { + return self.queue.removeFirst() + } + + let currentDepth = self.queue[0].depth + var lastSameDepthIndex = 0 + var hotIndices: [Int] = [] + + for index in 0.. Bool { + if self.isCancelled { + return true + } + + if depth > self.config.maxDepth { + return true + } + + let key = self.pathKey(for: path) + if self.visited.contains(key) { + return true + } + + return false + } + + private func nameMatchesQuery(_ name: String) -> Bool { + guard !self.queryTokens.isEmpty else { return false } + let lowercased = name.lowercased() + return self.queryTokens.allSatisfy { lowercased.contains($0) } + } + + private func exceedsLoopThreshold(for path: [String]) -> Bool { + guard self.config.loopRepetitionLimit > 0 else { return false } + guard let last = path.last else { return false } + let parent = path.dropLast() + + guard let previousIndex = parent.lastIndex(of: last) else { + return false + } + + let suffix = Array(path[previousIndex...]) + let key = suffix.joined(separator: "\u{001F}") + let count = (self.loopHistogram[key] ?? 0) + 1 + self.loopHistogram[key] = count + return count > self.config.loopRepetitionLimit + } + + private func pathKey(for path: [String]) -> String { + path.joined(separator: "\u{001F}") + } + + private func applyBackoff() async { + guard !self.isCancelled else { return } + + if self.processedCount > self.config.initialBurstCount { + self.currentDelay = min(self.config.maxDelay, max(self.config.initialDelay, self.currentDelay * self.config.backoffMultiplier)) + } + + guard self.currentDelay > 0 else { + return + } + + let nanoseconds = UInt64(self.currentDelay * 1_000_000_000) + try? await Task.sleep(nanoseconds: nanoseconds) + } +} diff --git a/Hotline/Models/PreviewFileInfo.swift b/Hotline/Models/PreviewFileInfo.swift index 7dd692f..dcf5314 100644 --- a/Hotline/Models/PreviewFileInfo.swift +++ b/Hotline/Models/PreviewFileInfo.swift @@ -1,11 +1,5 @@ import UniformTypeIdentifiers -enum PreviewFileType: Equatable { - case unknown - case image - case text -} - struct PreviewFileInfo: Identifiable, Codable { var id: UInt32 var address: String diff --git a/Hotline/Models/TransferInfo.swift b/Hotline/Models/TransferInfo.swift index ba25793..cb2b0fd 100644 --- a/Hotline/Models/TransferInfo.swift +++ b/Hotline/Models/TransferInfo.swift @@ -2,28 +2,36 @@ import SwiftUI @Observable class TransferInfo: Identifiable, Equatable, Hashable { - var id: UInt32 - + var id: UUID = UUID() + + var referenceNumber: UInt32 var title: String var size: UInt var progress: Double = 0.0 - var timeRemaining: TimeInterval = 0.0 + var speed: Double? = nil + var timeRemaining: TimeInterval? = nil var completed: Bool = false var failed: Bool = false var isFolder: Bool = false + // Server association - tracks which HotlineState this transfer belongs to + var serverID: UUID + var serverName: String? + // For file based transfers (i.e. not previews) var fileURL: URL? = nil - - var progressCallback: ((TransferInfo, Double) -> Void)? = nil - var downloadCallback: ((TransferInfo, URL) -> Void)? = nil + + var progressCallback: ((TransferInfo) -> Void)? = nil + var downloadCallback: ((TransferInfo) -> Void)? = nil var uploadCallback: ((TransferInfo) -> Void)? = nil var previewCallback: ((TransferInfo, Data) -> Void)? = nil - - init(id: UInt32, title: String, size: UInt) { - self.id = id + + init(reference: UInt32, title: String, size: UInt, serverID: UUID, serverName: String? = nil) { + self.referenceNumber = reference self.title = title self.size = size + self.serverID = serverID + self.serverName = serverName } static func == (lhs: TransferInfo, rhs: TransferInfo) -> Bool { diff --git a/Hotline/State/AppState.swift b/Hotline/State/AppState.swift index 3917ad0..558af2a 100644 --- a/Hotline/State/AppState.swift +++ b/Hotline/State/AppState.swift @@ -12,8 +12,64 @@ final class AppState { } - var activeHotline: Hotline? = nil + var activeHotline: HotlineState? = nil var activeServerState: ServerState? = nil var cloudKitReady: Bool = false + + // MARK: - Transfers + + /// All active transfers across all servers + /// Transfers persist even if you disconnect from the server + var transfers: [TransferInfo] = [] + + /// Track download tasks by reference number for cancellation + @ObservationIgnored private var transferTasks: [UUID: Task] = [:] + + /// Add a transfer to the transfer list + @MainActor + func addTransfer(_ transfer: TransferInfo) { + self.transfers.append(transfer) + } + + /// Cancel a transfer by transfer ID + @MainActor + func cancelTransfer(id: UUID) { + guard let transferIndex = self.transfers.firstIndex(where: { $0.id == id }) else { + return + } + + // Cancel the task if it exists + if let task = self.transferTasks[id] { + task.cancel() + self.transferTasks.removeValue(forKey: id) + } + + // Remove from transfers list + self.transfers.remove(at: transferIndex) + } + + /// Cancel all active transfers + @MainActor + func cancelAllTransfers() { + for (_, task) in self.transferTasks { + task.cancel() + } + self.transferTasks.removeAll() + + // Clear transfers + self.transfers.removeAll() + } + + /// Register a transfer task + @MainActor + func registerTransferTask(_ task: Task, transferID: UUID) { + self.transferTasks[transferID] = task + } + + /// Unregister a download task (called on completion/failure) + @MainActor + func unregisterTransferTask(for transferID: UUID) { + self.transferTasks.removeValue(forKey: transferID) + } } diff --git a/Hotline/State/FilePreviewState.swift b/Hotline/State/FilePreviewState.swift new file mode 100644 index 0000000..97bd907 --- /dev/null +++ b/Hotline/State/FilePreviewState.swift @@ -0,0 +1,207 @@ +// +// FilePreviewState.swift +// Hotline +// +// Modern file preview state using HotlineFilePreviewClientNew +// + +import SwiftUI +import UniformTypeIdentifiers + +// MARK: - Preview Type + +enum FilePreviewType: Equatable { + case unknown + case image + case text +} + +/// State for a file preview download +@MainActor +@Observable +final class FilePreviewState { + // MARK: - Properties + + let info: PreviewFileInfo + + private var previewClient: HotlineFilePreviewClientNew? + private var previewTask: Task? + + var state: LoadState = .unloaded + var progress: Double = 0.0 + + var fileURL: URL? = nil + + #if os(iOS) + var image: UIImage? = nil + #elseif os(macOS) + var image: NSImage? = nil + #endif + + var text: String? = nil + var styledText: NSAttributedString? = nil + + // MARK: - Computed Properties + + var previewType: FilePreviewType { + info.previewType + } + + // MARK: - Initialization + + init(info: PreviewFileInfo) { + self.info = info + } + + nonisolated deinit { + // Note: Can't access @MainActor properties from deinit + // Cleanup will happen when previewClient is deallocated + } + + // MARK: - Public API + + func download() { + // Cancel any existing download + previewTask?.cancel() + previewClient?.cleanup() + + let task = Task { @MainActor in + do { + let client = HotlineFilePreviewClientNew( + fileName: info.name, + address: info.address, + port: UInt16(info.port), + reference: info.id, + size: UInt32(info.size) + ) + self.previewClient = client + + self.state = .loading + self.progress = 0.0 + + let url = try await client.preview { [weak self] progress in + guard let self else { return } + + Task { @MainActor in + switch progress { + case .preparing: + self.state = .loading + self.progress = 0.0 + + case .connecting: + self.state = .loading + self.progress = 0.0 + + case .connected: + self.state = .loading + self.progress = 0.0 + + case .transfer(name: _, size: _, total: _, progress: let p, speed: _, estimate: _): + self.state = .loading + self.progress = p + + case .completed(url: let url): + self.state = .loaded + self.progress = 1.0 + self.fileURL = url +// self.loadPreview(from: url) + + case .error(let error): + self.state = .failed + self.progress = 0.0 + print("FilePreviewState: Download failed: \(error)") + + case .unconnected: + break + } + } + } + + // Final load if not already loaded + if self.state != .loaded { + self.state = .loaded + self.progress = 1.0 + self.fileURL = url +// self.loadPreview(from: url) + } + + } catch is CancellationError { + // Cancelled, do nothing + return + } catch { + self.state = .failed + self.progress = 0.0 + print("FilePreviewState: Download error: \(error)") + } + } + + self.previewTask = task + } + + func cancel() { + previewTask?.cancel() + previewTask = nil + previewClient?.cancel() + } + + func cleanup() { + previewClient?.cleanup() + previewClient = nil + fileURL = nil + image = nil + text = nil + styledText = nil + } + + // MARK: - Private Implementation + + private func loadPreview(from url: URL) { + guard let data = try? Data(contentsOf: url) else { + self.state = .failed + print("FilePreviewState: Failed to read preview data from \(url.path)") + return + } + + switch self.previewType { + case .image: + #if os(iOS) + self.image = UIImage(data: data) + #elseif os(macOS) + self.image = NSImage(data: data) + #endif + + if self.image == nil { + self.state = .failed + print("FilePreviewState: Failed to create image from data") + } + + case .text: + let encoding: UInt = NSString.stringEncoding(for: data, convertedString: nil, usedLossyConversion: nil) + if encoding != 0 { + self.text = String(data: data, encoding: String.Encoding(rawValue: encoding)) + } else { + self.text = String(data: data, encoding: .utf8) + } + + if self.text == nil { + self.state = .failed + print("FilePreviewState: Failed to decode text data") + } + + case .unknown: + print("FilePreviewState: Unknown preview type for \(info.name)") + break + } + } +} + +// MARK: - Load State + +extension FilePreviewState { + enum LoadState: Equatable { + case unloaded + case loading + case loaded + case failed + } +} diff --git a/Hotline/State/ServerState.swift b/Hotline/State/ServerState.swift index e2fa40f..5913bf5 100644 --- a/Hotline/State/ServerState.swift +++ b/Hotline/State/ServerState.swift @@ -5,8 +5,8 @@ class ServerState: Equatable { var id: UUID = UUID() var selection: ServerNavigationType var serverName: String? = nil - var serverBanner: NSImage? = nil - var bannerColors: ColorArt? = nil +// var serverBanner: NSImage? = nil +// var bannerBackgroundColor: Color? = nil init(selection: ServerNavigationType) { self.selection = selection diff --git a/Hotline/Utility/ColorArt.swift b/Hotline/Utility/ColorArt.swift index 1f99f0f..93889a3 100644 --- a/Hotline/Utility/ColorArt.swift +++ b/Hotline/Utility/ColorArt.swift @@ -1,7 +1,11 @@ +// Swift translation and modernization +// by Dustin Mierau +// +// of: +// // ColorArt.swift // SLColorArt by Panic Inc. -// Swift translation by Dustin Mierau // // Copyright (C) 2012 Panic Inc. Code by Wade Cosgrove. All rights reserved. // @@ -32,12 +36,14 @@ import SwiftUI fileprivate let kColorThresholdMinimumPercentage: CGFloat = 0.001 +// ColorArt.analyze(image: img) -> ColorArt? + struct ColorArt: Equatable { let backgroundColor: NSColor let primaryColor: NSColor let secondaryColor: NSColor let detailColor: NSColor - let scaledImage: NSImage +// let scaledImage: NSImage static func == (lhs: ColorArt, rhs: ColorArt) -> Bool { return lhs.backgroundColor == rhs.backgroundColor && @@ -45,57 +51,80 @@ struct ColorArt: Equatable { lhs.secondaryColor == rhs.secondaryColor && lhs.detailColor == rhs.detailColor } + + static func analyze(image: NSImage) -> ColorArt? { + print("ColorArt.analyze: Starting, image size: \(image.size)") + // Scale image to a reasonable size for analysis + // This is important because: + // 1. Makes analysis faster (fewer pixels) + // 2. Normalizes weird image dimensions + // 3. Ensures CGImage conversion succeeds + print("ColorArt.analyze: Calling scaleImage...") + let finalImage = Self.scaleImage(image, size: NSSize(width: 100, height: 100)) + print("ColorArt.analyze: scaleImage returned, scaled size: \(finalImage.size)") - init?(image: NSImage, scaledSize: NSSize = .zero) { - let finalImage = Self.scaleImage(image, size: scaledSize) - self.scaledImage = finalImage - guard let colors = Self.analyzeImage(finalImage) else { + print("ColorArt.analyze: failed with no colors") return nil } - self.backgroundColor = colors.background - self.primaryColor = colors.primary - self.secondaryColor = colors.secondary - self.detailColor = colors.detail + print("ColorArt.analyze: returning colors", colors) + + return ColorArt(backgroundColor: colors.background, + primaryColor: colors.primary, + secondaryColor: colors.secondary, + detailColor: colors.detail) } // MARK: - Image Scaling private static func scaleImage(_ image: NSImage, size scaledSize: NSSize) -> NSImage { - let imageSize = image.size - let squareImage = NSImage(size: NSSize(width: imageSize.width, height: imageSize.width)) - var drawRect: NSRect - - // Make the image square - if imageSize.height > imageSize.width { - drawRect = NSRect(x: 0, y: imageSize.height - imageSize.width, width: imageSize.width, height: imageSize.width) - } else { - drawRect = NSRect(x: 0, y: 0, width: imageSize.height, height: imageSize.height) + print("ColorArt.scaleImage: Entered, input: \(image.size), target: \(scaledSize)") + // Get CGImage directly without using lockFocus + print("ColorArt.scaleImage: Getting CGImage...") + guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + print("ColorArt.scaleImage: Failed to get CGImage, returning original") + return image } - + print("ColorArt.scaleImage: Got CGImage") + + let imageSize = image.size + let squareSize = min(imageSize.width, imageSize.height) + // Use native square size if passed zero size - let finalScaledSize = scaledSize == .zero ? drawRect.size : scaledSize - let scaledImage = NSImage(size: finalScaledSize) - - squareImage.lockFocus() - image.draw(in: NSRect(x: 0, y: 0, width: imageSize.width, height: imageSize.width), from: drawRect, operation: .sourceOver, fraction: 1.0) - squareImage.unlockFocus() - - // Scale the image to the desired size - scaledImage.lockFocus() - squareImage.draw(in: NSRect(x: 0, y: 0, width: finalScaledSize.width, height: finalScaledSize.height), from: .zero, operation: .sourceOver, fraction: 1.0) - scaledImage.unlockFocus() - - // Convert back to readable bitmap data - guard let cgImage = scaledImage.cgImage(forProposedRect: nil, context: nil, hints: nil) else { - return scaledImage + let finalScaledSize = scaledSize == .zero ? NSSize(width: squareSize, height: squareSize) : scaledSize + + // Create bitmap context for drawing + let width = Int(finalScaledSize.width) + let height = Int(finalScaledSize.height) + let colorSpace = CGColorSpaceCreateDeviceRGB() + let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue) + + guard let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width * 4, + space: colorSpace, + bitmapInfo: bitmapInfo.rawValue + ) else { + return image } - - let bitmapRep = NSBitmapImageRep(cgImage: cgImage) - let finalImage = NSImage(size: scaledImage.size) + + // Draw the image scaled + context.interpolationQuality = .high + context.draw(cgImage, in: CGRect(x: 0, y: 0, width: finalScaledSize.width, height: finalScaledSize.height)) + + // Create NSImage from context + guard let scaledCGImage = context.makeImage() else { + return image + } + + let bitmapRep = NSBitmapImageRep(cgImage: scaledCGImage) + let finalImage = NSImage(size: finalScaledSize) finalImage.addRepresentation(bitmapRep) - + return finalImage } @@ -120,33 +149,45 @@ struct ColorArt: Equatable { if primaryColor == nil { primaryColor = darkBackground ? .white : .black } - + if secondaryColor == nil { secondaryColor = darkBackground ? .white : .black } - + if detailColor == nil { detailColor = darkBackground ? .white : .black } - - return (backgroundColor, primaryColor!, secondaryColor!, detailColor!) + + // Convert all colors to calibrated RGB color space for consistency + // This ensures all colors are in the same color space and prevents + // any color space conversion issues when used in SwiftUI + let rgbColorSpace = NSColorSpace.genericRGB + let finalBackground = backgroundColor.usingColorSpace(rgbColorSpace) ?? backgroundColor + let finalPrimary = primaryColor!.usingColorSpace(rgbColorSpace) ?? primaryColor! + let finalSecondary = secondaryColor!.usingColorSpace(rgbColorSpace) ?? secondaryColor! + let finalDetail = detailColor!.usingColorSpace(rgbColorSpace) ?? detailColor! + + return (finalBackground, finalPrimary, finalSecondary, finalDetail) } // MARK: - Edge Color Detection private static func findEdgeColor(_ image: NSImage, imageColors: inout NSCountedSet?) -> NSColor? { - guard var imageRep = image.representations.last else { - return nil - } - - if !(imageRep is NSBitmapImageRep) { - image.lockFocus() - imageRep = NSBitmapImageRep(focusedViewRect: NSRect(x: 0, y: 0, width: image.size.width, height: image.size.height))! - image.unlockFocus() + var bitmapRep: NSBitmapImageRep? + + // Try to get existing bitmap representation + if let existingRep = image.representations.last as? NSBitmapImageRep { + bitmapRep = existingRep + } else { + // Create bitmap rep from CGImage instead of using lockFocus + guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + return nil + } + bitmapRep = NSBitmapImageRep(cgImage: cgImage) } - + // Convert to RGB color space - guard let bitmapRep = (imageRep as? NSBitmapImageRep)?.converting(to: .genericRGB, renderingIntent: .default) else { + guard let bitmapRep = bitmapRep?.converting(to: .genericRGB, renderingIntent: .default) else { return nil } diff --git a/Hotline/Utility/SwiftUIExtensions.swift b/Hotline/Utility/SwiftUIExtensions.swift index 12217b2..d0dfff4 100644 --- a/Hotline/Utility/SwiftUIExtensions.swift +++ b/Hotline/Utility/SwiftUIExtensions.swift @@ -6,28 +6,3 @@ extension Color { self.init(red: Double((hex >> 16) & 0xFF) / 255.0, green: Double((hex >> 8) & 0xFF) / 255.0, blue: Double(hex & 0xFF) / 255.0, opacity: opacity) } } - -extension AttributedString { - func setHangingIndent(firstLineHeadIndent: CGFloat = 0, otherLinesHeadIndent: CGFloat) -> AttributedString { -// var blah = self - -// guard var paragraph = self.paragraphStyle else { -// return -// } - - var p = self.paragraphStyle?.mutableCopy() as? NSMutableParagraphStyle - p?.headIndent = otherLinesHeadIndent - p?.firstLineHeadIndent = firstLineHeadIndent - -// paragraph.headIndent = otherLinesHeadIndent // indent for lines 2+ -// paragraph.firstLineHeadIndent = firstLineHeadIndent // usually 0 - - var blah = self - - - blah.paragraphStyle = p - - return blah - } -} - diff --git a/Hotline/iOS/ChatView.swift b/Hotline/iOS/ChatView.swift index e320dbf..d4d9d03 100644 --- a/Hotline/iOS/ChatView.swift +++ b/Hotline/iOS/ChatView.swift @@ -27,7 +27,7 @@ struct ChatView: View { VStack(alignment: .center) { if let bannerImage = self.model.bannerImage { - Image(uiImage: bannerImage) + bannerImage .resizable() .scaledToFit() .frame(maxWidth: 468.0) diff --git a/Hotline/iOS/ServerView.swift b/Hotline/iOS/ServerView.swift index 1ce0a9c..8ae0fa7 100644 --- a/Hotline/iOS/ServerView.swift +++ b/Hotline/iOS/ServerView.swift @@ -1,7 +1,7 @@ import SwiftUI struct ServerView: View { - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState @Environment(\.colorScheme) var colorScheme enum Tab { diff --git a/Hotline/macOS/Accounts/AccountManagerView.swift b/Hotline/macOS/Accounts/AccountManagerView.swift index 57682cc..c45ba18 100644 --- a/Hotline/macOS/Accounts/AccountManagerView.swift +++ b/Hotline/macOS/Accounts/AccountManagerView.swift @@ -1,7 +1,7 @@ import SwiftUI struct AccountManagerView: View { - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState @State private var accounts: [HotlineAccount] = [] @State private var selection: HotlineAccount? @@ -43,7 +43,7 @@ struct AccountManagerView: View { .alternatingRowBackgrounds(.enabled) .task { if loading { - accounts = await model.getAccounts() + accounts = (try? await model.getAccounts()) ?? [] loading = false } } @@ -243,47 +243,49 @@ struct AccountManagerView: View { // .padding() Spacer() - - Button("Save"){ + + Button(action: { guard let selection else { return } - + // Update existing account if selection.persisted == true { if pendingPassword == placeholderPassword { Task { @MainActor in - model.client.sendSetUser(name: pendingName, login: pendingLogin, newLogin: nil, password: nil, access: pendingAccess.rawValue) + try? await model.setUser(name: pendingName, login: pendingLogin, newLogin: nil, password: nil, access: pendingAccess.rawValue) } } else { Task { @MainActor in - model.client.sendSetUser(name: pendingName, login: pendingLogin, newLogin: nil, password: pendingPassword, access: pendingAccess.rawValue) + try? await model.setUser(name: pendingName, login: pendingLogin, newLogin: nil, password: pendingPassword, access: pendingAccess.rawValue) } } } else { // Create new existing account Task { @MainActor in - model.client.sendCreateUser(name: pendingName, login: pendingLogin, password: pendingPassword, access: pendingAccess.rawValue) + try? await model.createUser(name: pendingName, login: pendingLogin, password: pendingPassword, access: pendingAccess.rawValue) } self.selection?.password = pendingPassword pendingPassword = placeholderPassword } - + var account = HotlineAccount(pendingName, pendingLogin, pendingAccess) account.persisted = true account.password = placeholderPassword - + accounts = accounts.filter { $0.persisted == true && $0.login != selection.login } - + // Add new account to list accounts.append(account) - + // Re-sort accounts accounts.sort { $0.login < $1.login } self.selection = account - } + }, label: { + Text("Save") + }) .controlSize(.large) .frame(minWidth: 75) .keyboardShortcut(.defaultAction) @@ -349,23 +351,25 @@ struct AccountManagerView: View { } ToolbarItem(placement: .primaryAction) { - Button("Delete") { + Button(action: { guard let userToDelete = toDelete else { return } - + self.toDelete = nil self.selection = nil - + if userToDelete.persisted { Task { @MainActor in - model.client.sendDeleteUser(login: userToDelete.login) + try? await model.deleteUser(login: userToDelete.login) } } - + accounts = accounts.filter { $0.login != userToDelete.login } - - } + + }, label: { + Text("Delete") + }) } } } diff --git a/Hotline/macOS/Board/MessageBoardEditorView.swift b/Hotline/macOS/Board/MessageBoardEditorView.swift index 474384e..ea1754c 100644 --- a/Hotline/macOS/Board/MessageBoardEditorView.swift +++ b/Hotline/macOS/Board/MessageBoardEditorView.swift @@ -8,7 +8,7 @@ struct MessageBoardEditorView: View { @Environment(\.controlActiveState) private var controlActiveState @Environment(\.colorScheme) private var colorScheme @Environment(\.dismiss) private var dismiss - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState @State private var text: String = "" @State private var sending: Bool = false @@ -17,11 +17,11 @@ struct MessageBoardEditorView: View { func sendPost() async { sending = true - + let cleanedText = text.trimmingCharacters(in: .whitespacesAndNewlines) - - model.postToMessageBoard(text: cleanedText) - let _ = await model.getMessageBoard() + + try? await model.postToMessageBoard(text: cleanedText) + let _ = try? await model.getMessageBoard() // let success = await model.postNewsArticle(title: title, body: text, at: path, parentID: parentID) // if success { @@ -69,9 +69,9 @@ struct MessageBoardEditorView: View { else { Button { sending = true - model.postToMessageBoard(text: text) Task { - let _ = await model.getMessageBoard() + try? await model.postToMessageBoard(text: text) + let _ = try? await model.getMessageBoard() Task { @MainActor in sending = false dismiss() diff --git a/Hotline/macOS/Board/MessageBoardView.swift b/Hotline/macOS/Board/MessageBoardView.swift index f870b0c..8788d66 100644 --- a/Hotline/macOS/Board/MessageBoardView.swift +++ b/Hotline/macOS/Board/MessageBoardView.swift @@ -1,7 +1,7 @@ import SwiftUI struct MessageBoardView: View { - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState @State private var composerDisplayed: Bool = false @State private var composerText: String = "" @@ -25,7 +25,7 @@ struct MessageBoardView: View { } .task { if !model.messageBoardLoaded { - let _ = await model.getMessageBoard() + let _ = try? await model.getMessageBoard() } } .overlay { @@ -98,5 +98,5 @@ struct MessageBoardView: View { #Preview { MessageBoardView() - .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) + .environment(HotlineState()) } diff --git a/Hotline/macOS/Chat/ChatView.swift b/Hotline/macOS/Chat/ChatView.swift index 0795856..e1ec73a 100644 --- a/Hotline/macOS/Chat/ChatView.swift +++ b/Hotline/macOS/Chat/ChatView.swift @@ -88,7 +88,7 @@ struct ChatMessageView: View { } struct ChatView: View { - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState @Environment(\.colorScheme) var colorScheme @Environment(\.dismiss) var dismiss @@ -98,12 +98,14 @@ struct ChatView: View { @State private var searchQuery: String = "" @State private var searchResults: [ChatMessage] = [] @State private var isSearching: Bool = false + + @State private var stableBannerImage: Image? @FocusState private var focusedField: FocusedField? @Namespace var bottomID - private var bindableModel: Bindable { + private var bindableModel: Bindable { Bindable(model) } @@ -114,7 +116,36 @@ struct ChatView: View { var displayedMessages: [ChatMessage] { searchQuery.isEmpty ? model.chat : searchResults } - + +// private var blurredBannerImage: some View { +// self.stableBannerImage? +// .resizable() +// .scaledToFit() +// .frame(maxWidth: 468.0) +// .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) +// .offset(y: 1.5) +// .blur(radius: 4) +// .opacity(0.2) +// } + + private var bannerView: some View { + ZStack { + self.stableBannerImage? + .resizable() + .scaledToFit() + .frame(maxWidth: 468.0) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .offset(y: 1.5) + .blur(radius: 4) + .opacity(0.2) + self.stableBannerImage? + .resizable() + .scaledToFit() + .frame(maxWidth: 468.0) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + } + var body: some View { @Bindable var bindModel = model @@ -129,28 +160,10 @@ struct ChatView: View { ForEach(displayedMessages) { msg in if msg.type == .agreement { VStack(alignment: .center, spacing: 16) { - if let bannerImage = self.model.bannerImage { - HStack(spacing: 0) { - Spacer(minLength: 0) - ZStack { - Image(nsImage: bannerImage) - .resizable() - .scaledToFit() - .frame(maxWidth: 468.0) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - .offset(y: 1.5) - .blur(radius: 4) - .opacity(0.2) - - Image(nsImage: bannerImage) - .resizable() - .scaledToFit() - .frame(maxWidth: 468.0) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - } - - Spacer(minLength: 0) - } + HStack(spacing: 0) { + Spacer(minLength: 0) + self.bannerView + Spacer(minLength: 0) } ServerAgreementView(text: msg.text) @@ -218,7 +231,11 @@ struct ChatView: View { .multilineTextAlignment(.leading) .onSubmit { if !model.chatInput.isEmpty { - model.sendChat(model.chatInput, announce: NSEvent.modifierFlags.contains(.shift)) + let message = model.chatInput + let announce = NSEvent.modifierFlags.contains(.shift) + Task { + try? await model.sendChat(message, announce: announce) + } } model.chatInput = "" } @@ -252,6 +269,12 @@ struct ChatView: View { .onChange(of: searchQuery) { performSearch() } + .onChange(of: model.bannerImage) { oldValue, newValue in + stableBannerImage = newValue + } + .onAppear { + stableBannerImage = model.bannerImage + } // .toolbar { // ToolbarItem(placement: .primaryAction) { // Button { @@ -318,5 +341,5 @@ struct ChatView: View { #Preview { ChatView() - .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) + .environment(HotlineState()) } diff --git a/Hotline/macOS/Files/FileDetailsView.swift b/Hotline/macOS/Files/FileDetailsView.swift index 812f4e5..d001df2 100644 --- a/Hotline/macOS/Files/FileDetailsView.swift +++ b/Hotline/macOS/Files/FileDetailsView.swift @@ -2,7 +2,7 @@ import Foundation import SwiftUI struct FileDetailsView: View { - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState @Environment(\.presentationMode) var presentationMode var fd: FileDetails @@ -73,8 +73,8 @@ struct FileDetailsView: View { if comment != fd.comment { editedComment = comment } - - model.client.sendSetFileInfo(fileName: fd.name, path: fd.path, fileNewName: editedFilename, comment: editedComment) + + model.setFileInfo(fileName: fd.name, path: fd.path, fileNewName: editedFilename, comment: editedComment) presentationMode.wrappedValue.dismiss() // TODO: Update the file list if the filename was changed diff --git a/Hotline/macOS/Files/FileItemView.swift b/Hotline/macOS/Files/FileItemView.swift index 5da744f..31a8af7 100644 --- a/Hotline/macOS/Files/FileItemView.swift +++ b/Hotline/macOS/Files/FileItemView.swift @@ -1,7 +1,7 @@ import SwiftUI struct FileItemView: View { - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState var file: FileInfo let depth: Int diff --git a/Hotline/macOS/Files/FilePreviewImageView.swift b/Hotline/macOS/Files/FilePreviewImageView.swift index 9beeb80..c5899bd 100644 --- a/Hotline/macOS/Files/FilePreviewImageView.swift +++ b/Hotline/macOS/Files/FilePreviewImageView.swift @@ -12,7 +12,7 @@ struct FilePreviewImageView: View { @Binding var info: PreviewFileInfo? - @State var preview: FilePreview? = nil + @State var preview: FilePreviewState? = nil @FocusState private var focusField: FilePreviewFocus? var body: some View { @@ -78,13 +78,16 @@ struct FilePreviewImageView: View { if let info = info { ToolbarItem(placement: .primaryAction) { Button { - let _ = preview?.data?.saveAsFileToDownloads(filename: info.name) + if let fileURL = preview?.fileURL, + let data = try? Data(contentsOf: fileURL) { + let _ = data.saveAsFileToDownloads(filename: info.name) + } } label: { Label("Download Image...", systemImage: "arrow.down") } .help("Download Image") } - + ToolbarItem(placement: .primaryAction) { ShareLink(item: img, preview: SharePreview(info.name, image: img)) { Label("Share Image...", systemImage: "square.and.arrow.up") @@ -96,7 +99,7 @@ struct FilePreviewImageView: View { } .task { if let info = info { - preview = FilePreview(info: info) + preview = FilePreviewState(info: info) preview?.download() } } diff --git a/Hotline/macOS/Files/FilePreviewQuickLookView.swift b/Hotline/macOS/Files/FilePreviewQuickLookView.swift new file mode 100644 index 0000000..0323fdd --- /dev/null +++ b/Hotline/macOS/Files/FilePreviewQuickLookView.swift @@ -0,0 +1,131 @@ +// +// FilePreviewQuickLookView.swift +// Hotline +// +// QuickLook-based file preview window for all supported file types +// + +import SwiftUI +import UniformTypeIdentifiers + +struct FilePreviewQuickLookView: View { + enum FilePreviewFocus: Hashable { + case window + } + + @Environment(\.controlActiveState) private var controlActiveState + @Environment(\.colorScheme) private var colorScheme + @Environment(\.dismiss) var dismiss + + @Binding var info: PreviewFileInfo? + @State var preview: FilePreviewState? = nil + @FocusState private var focusField: FilePreviewFocus? + + var body: some View { + Group { + if preview?.state != .loaded { + VStack(alignment: .center, spacing: 0) { + Spacer() + ProgressView(value: max(0.0, min(1.0, preview?.progress ?? 0.0))) + .focusable(false) + .progressViewStyle(.circular) + .controlSize(.extraLarge) + .frame(maxWidth: 300, alignment: .center) + .padding(.bottom, 48) + Spacer() + } + .background(Color(nsColor: .textBackgroundColor)) + .frame(minWidth: 350, maxWidth: .infinity, minHeight: 150, maxHeight: .infinity) + .padding() + } + else { + if let fileURL = preview?.fileURL { + QuickLookPreviewView(fileURL: fileURL) + .frame(minWidth: 400, maxWidth: .infinity, minHeight: 400, maxHeight: .infinity) + } + else { + VStack(alignment: .center, spacing: 0) { + Spacer() + + Image(systemName: "eye.trianglebadge.exclamationmark") + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + .frame(height: 48) + .padding(.bottom) + Group { + Text("This file type is not previewable") + .bold() + Text("Try downloading and opening this file in another application.") + .foregroundStyle(Color.secondary) + } + .font(.system(size: 14.0)) + .frame(maxWidth: 300) + .multilineTextAlignment(.center) + + Spacer() + Spacer() + } + .frame(minWidth: 350, maxWidth: .infinity, minHeight: 150, maxHeight: .infinity) + .padding() + } + } + } + .focusable() + .focusEffectDisabled() + .background(Color(nsColor: .textBackgroundColor)) + .focused($focusField, equals: .window) + .navigationTitle(info?.name ?? "File Preview") + .background( + WindowConfigurator { window in + if let fileURL = preview?.fileURL { + window.representedURL = fileURL + window.standardWindowButton(.documentIconButton)?.isHidden = false + } + } + ) + .toolbar { + if let _ = preview?.fileURL { + if let info = info { + ToolbarItem(placement: .primaryAction) { + Button { + if let fileURL = preview?.fileURL, + let data = try? Data(contentsOf: fileURL) { + let _ = data.saveAsFileToDownloads(filename: info.name) + } + } label: { + Label("Download File...", systemImage: "arrow.down") + } + .help("Download File") + } + } + } + } + .task { + if let info = info { + preview = FilePreviewState(info: info) + preview?.download() + } + } + .onAppear { + if info == nil { + Task { + dismiss() + } + return + } + + focusField = .window + } + .onDisappear { + preview?.cancel() + dismiss() + } + .onChange(of: preview?.state) { + if preview?.state == .failed { + dismiss() + } + } + .preferredColorScheme(.dark) + } +} diff --git a/Hotline/macOS/Files/FilePreviewTextView.swift b/Hotline/macOS/Files/FilePreviewTextView.swift index c286381..4e3a719 100644 --- a/Hotline/macOS/Files/FilePreviewTextView.swift +++ b/Hotline/macOS/Files/FilePreviewTextView.swift @@ -11,7 +11,7 @@ struct FilePreviewTextView: View { @Environment(\.dismiss) var dismiss @Binding var info: PreviewFileInfo? - @State var preview: FilePreview? = nil + @State var preview: FilePreviewState? = nil @FocusState private var focusField: FilePreviewFocus? var body: some View { @@ -89,7 +89,10 @@ struct FilePreviewTextView: View { if let info = info { ToolbarItem(placement: .primaryAction) { Button { - let _ = preview?.data?.saveAsFileToDownloads(filename: info.name) + if let fileURL = preview?.fileURL, + let data = try? Data(contentsOf: fileURL) { + let _ = data.saveAsFileToDownloads(filename: info.name) + } } label: { Label("Save Text File...", systemImage: "square.and.arrow.down") } @@ -100,7 +103,7 @@ struct FilePreviewTextView: View { } .task { if let info = info { - preview = FilePreview(info: info) + preview = FilePreviewState(info: info) preview?.download() } } diff --git a/Hotline/macOS/Files/FilesView.swift b/Hotline/macOS/Files/FilesView.swift index de699ff..b499fe6 100644 --- a/Hotline/macOS/Files/FilesView.swift +++ b/Hotline/macOS/Files/FilesView.swift @@ -7,7 +7,7 @@ import AppKit struct FilesView: View { - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState @Environment(\.openWindow) private var openWindow @State private var selection: FileInfo? @@ -15,6 +15,7 @@ struct FilesView: View { @State private var uploadFileSelectorDisplayed: Bool = false @State private var searchText: String = "" @State private var isSearching: Bool = false + @State private var dragOver: Bool = false private var isShowingSearchResults: Bool { switch model.fileSearchStatus { @@ -68,17 +69,18 @@ struct FilesView: View { private func openPreviewWindow(_ previewInfo: PreviewFileInfo) { switch previewInfo.previewType { case .image: - openWindow(id: "preview-image", value: previewInfo) + openWindow(id: "preview-quicklook", value: previewInfo) case .text: - openWindow(id: "preview-text", value: previewInfo) - default: + openWindow(id: "preview-quicklook", value: previewInfo) + case .unknown: + openWindow(id: "preview-quicklook", value: previewInfo) return } } @MainActor private func getFileInfo(_ file: FileInfo) { Task { - if let fileInfo = await model.getFileDetails(file.name, path: file.path) { + if let fileInfo = try? await model.getFileDetails(file.name, path: file.path) { Task { @MainActor in self.fileDetails = fileInfo } @@ -88,10 +90,10 @@ struct FilesView: View { @MainActor private func downloadFile(_ file: FileInfo) { if file.isFolder { - model.downloadFolder(file.name, path: file.path) + model.downloadFolderNew(file.name, path: file.path) } else { - model.downloadFile(file.name, path: file.path) + model.downloadFileNew(file.name, path: file.path) } } @@ -99,7 +101,31 @@ struct FilesView: View { model.uploadFile(url: fileURL, path: path) { info in Task { // Refresh file listing to display newly uploaded file. - let _ = await model.getFileList(path: path) + let _ = try? await model.getFileList(path: path) + } + } + } + + @MainActor private func upload(file fileURL: URL, to path: [String]) { + var fileIsDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false), isDirectory: &fileIsDirectory) else { + return + } + + if fileIsDirectory.boolValue { + self.model.uploadFolder(url: fileURL, path: path, complete: { info in + Task { + // Refresh file listing to display newly uploaded file. + try? await model.getFileList(path: path) + } + }) + } + else { + self.model.uploadFile(url: fileURL, path: path) { info in + Task { + // Refresh file listing to display newly uploaded file. + try? await model.getFileList(path: path) + } } } } @@ -121,15 +147,15 @@ struct FilesView: View { if file.path.count > 1 { parentPath = Array(file.path[0.. 0 else { + guard fileURLS.count > 0, + let fileURL = fileURLS.first + else { return } - let fileURL = fileURLS.first! - - print(fileURL) - var uploadPath: [String] = [] if let selection = selection { @@ -308,7 +356,8 @@ struct FilesView: View { } print("UPLOAD PATH: \(uploadPath)") - uploadFile(file: fileURL, to: uploadPath) + self.upload(file: fileURL, to: uploadPath) +// uploadFile(file: fileURL, to: uploadPath) case .failure(let error): print(error) @@ -414,5 +463,5 @@ struct FilesView: View { #Preview { FilesView() - .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) + .environment(HotlineState()) } diff --git a/Hotline/macOS/Files/FolderItemView.swift b/Hotline/macOS/Files/FolderItemView.swift index 4a08974..2b1b695 100644 --- a/Hotline/macOS/Files/FolderItemView.swift +++ b/Hotline/macOS/Files/FolderItemView.swift @@ -1,7 +1,7 @@ import SwiftUI struct FolderItemView: View { - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState @State var loading = false @State var dragOver = false @@ -20,7 +20,7 @@ struct FolderItemView: View { model.uploadFile(url: fileURL, path: filePath) { info in Task { // Refresh file listing to display newly uploaded file. - let _ = await model.getFileList(path: filePath) + let _ = try? await model.getFileList(path: filePath) } } } @@ -103,7 +103,7 @@ struct FolderItemView: View { if file.expanded && file.fileSize > 0 { Task { loading = true - let _ = await model.getFileList(path: file.path) + let _ = try? await model.getFileList(path: file.path) loading = false } } diff --git a/Hotline/macOS/HotlinePanelView.swift b/Hotline/macOS/HotlinePanelView.swift index ee4d198..7819c2f 100644 --- a/Hotline/macOS/HotlinePanelView.swift +++ b/Hotline/macOS/HotlinePanelView.swift @@ -3,10 +3,27 @@ import SwiftUI struct HotlinePanelView: View { @Environment(\.openWindow) var openWindow @Environment(\.colorScheme) var colorScheme + @Environment(\.appState) private var appState + + private var activeServerState: ServerState? { + self.appState.activeServerState + } + + private var activeHotline: HotlineState? { + self.appState.activeHotline + } + + private var bannerImage: Image { + self.activeHotline?.bannerImage ?? Image("Default Banner") + } + + private var backgroundColor: Color { + Color(nsColor: self.activeHotline?.bannerColors?.backgroundColor ?? NSColor.controlBackgroundColor) + } var body: some View { VStack(spacing: 0) { - Image(nsImage: AppState.shared.activeServerState?.serverBanner ?? NSImage(named: "Default Banner")!) + self.bannerImage .interpolation(.high) .resizable() .scaledToFill() @@ -14,8 +31,7 @@ struct HotlinePanelView: View { .frame(minWidth: 468, maxWidth: 468, minHeight: 60, maxHeight: 60) .clipped() .background(.black) -// .clipShape(RoundedRectangle(cornerRadius: 6.0)) -// .padding([.top, .leading, .trailing], 4) + .animation(.default, value: self.bannerImage) HStack(spacing: 12) { Button { @@ -36,7 +52,7 @@ struct HotlinePanelView: View { .help("Hotline Servers") Button { - AppState.shared.activeServerState?.selection = .chat + self.activeServerState?.selection = .chat } label: { Image("Section Chat") @@ -45,11 +61,11 @@ struct HotlinePanelView: View { } .buttonStyle(.plain) .frame(width: 20, height: 20) - .disabled(AppState.shared.activeServerState == nil) + .disabled(self.activeServerState == nil) .help("Public Chat") Button { - AppState.shared.activeServerState?.selection = .board + self.activeServerState?.selection = .board } label: { Image("Section Board") @@ -58,11 +74,11 @@ struct HotlinePanelView: View { } .buttonStyle(.plain) .frame(width: 20, height: 20) - .disabled(AppState.shared.activeServerState == nil) + .disabled(self.activeServerState == nil) .help("Message Board") Button { - AppState.shared.activeServerState?.selection = .news + self.activeServerState?.selection = .news } label: { Image("Section News") @@ -71,11 +87,11 @@ struct HotlinePanelView: View { } .buttonStyle(.plain) .frame(width: 20, height: 20) - .disabled(AppState.shared.activeServerState == nil || (AppState.shared.activeHotline?.serverVersion ?? 0) < 151) + .disabled(self.activeServerState == nil || (self.activeHotline?.serverVersion ?? 0) < 151) .help("News") Button { - AppState.shared.activeServerState?.selection = .files + self.activeServerState?.selection = .files } label: { Image("Section Files") @@ -84,14 +100,14 @@ struct HotlinePanelView: View { } .buttonStyle(.plain) .frame(width: 20, height: 20) - .disabled(AppState.shared.activeServerState == nil) + .disabled(self.activeServerState == nil) .help("Files") Spacer() - if AppState.shared.activeHotline?.access?.contains(.canOpenUsers) == true { + if self.activeHotline?.access?.contains(.canOpenUsers) == true { Button { - AppState.shared.activeServerState?.selection = .accounts + self.activeServerState?.selection = .accounts } label: { Image("Section Users") @@ -100,7 +116,7 @@ struct HotlinePanelView: View { } .buttonStyle(.plain) .frame(width: 20, height: 20) - .disabled(AppState.shared.activeServerState == nil) + .disabled(self.activeServerState == nil) .help("Accounts") } @@ -116,9 +132,9 @@ struct HotlinePanelView: View { .padding(.top, 12) .padding(.bottom, 12) .padding([.leading, .trailing], 12) - .background(AppState.shared.activeServerState?.bannerColors.map { Color(nsColor: $0.backgroundColor) } ?? Color(nsColor: .controlBackgroundColor)) - .foregroundStyle(AppState.shared.activeServerState?.bannerColors.map { Color(nsColor: $0.primaryColor) } ?? Color.primary) -// .background(Color.red.opacity(0.5).blendMode(.multiply)) + .background(self.backgroundColor) + .foregroundStyle(.primary) + .animation(.default, value: self.backgroundColor) // GroupBox { // HStack(spacing: 0) { @@ -146,5 +162,5 @@ struct HotlinePanelView: View { #Preview { HotlinePanelView() - .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) + .environment(HotlineState()) } diff --git a/Hotline/macOS/MessageView.swift b/Hotline/macOS/MessageView.swift index 0a8b071..b0c8baa 100644 --- a/Hotline/macOS/MessageView.swift +++ b/Hotline/macOS/MessageView.swift @@ -1,7 +1,7 @@ import SwiftUI struct MessageView: View { - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState @Environment(\.colorScheme) private var colorScheme @State private var input: String = "" @@ -75,7 +75,11 @@ struct MessageView: View { .multilineTextAlignment(.leading) .onSubmit { if !self.input.isEmpty { - model.sendInstantMessage(self.input, userID: self.userID) + let message = self.input + let uid = self.userID + Task { + try? await model.sendInstantMessage(message, userID: uid) + } } self.input = "" } @@ -107,5 +111,5 @@ struct MessageView: View { #Preview { ChatView() - .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) + .environment(HotlineState()) } diff --git a/Hotline/macOS/News/NewsEditorView.swift b/Hotline/macOS/News/NewsEditorView.swift index f69c846..cc3e7d7 100644 --- a/Hotline/macOS/News/NewsEditorView.swift +++ b/Hotline/macOS/News/NewsEditorView.swift @@ -9,7 +9,7 @@ struct NewsEditorView: View { @Environment(\.controlActiveState) private var controlActiveState @Environment(\.colorScheme) private var colorScheme @Environment(\.dismiss) private var dismiss - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState let editorTitle: String let isReply: Bool @@ -24,15 +24,16 @@ struct NewsEditorView: View { func sendArticle() async -> Bool { sending = true - - let success = await model.postNewsArticle(title: title, body: text, at: path, parentID: parentID) - if success { - await model.getNewsList(at: path) + + do { + try await model.postNewsArticle(title: title, body: text, at: path, parentID: parentID) + try? await model.getNewsList(at: path) + sending = false + return true + } catch { + sending = false + return false } - - sending = false - - return success } var body: some View { diff --git a/Hotline/macOS/News/NewsItemView.swift b/Hotline/macOS/News/NewsItemView.swift index fc20e61..29c0e7d 100644 --- a/Hotline/macOS/News/NewsItemView.swift +++ b/Hotline/macOS/News/NewsItemView.swift @@ -1,7 +1,7 @@ import SwiftUI struct NewsItemView: View { - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState var news: NewsInfo let depth: Int @@ -132,9 +132,9 @@ struct NewsItemView: View { guard news.expanded, news.type == .bundle || news.type == .category else { return } - + Task { - await model.getNewsList(at: news.path) + try? await model.getNewsList(at: news.path) } } @@ -148,5 +148,5 @@ struct NewsItemView: View { #Preview { NewsItemView(news: NewsInfo(hotlineNewsArticle: HotlineNewsArticle(id: 0, parentID: 0, flags: 0, title: "Title", username: "username", date: Date.now, flavors: [("", 1)], path: ["Guest"])), depth: 0) - .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) + .environment(HotlineState()) } diff --git a/Hotline/macOS/News/NewsView.swift b/Hotline/macOS/News/NewsView.swift index 91bf2fe..dfc5ab2 100644 --- a/Hotline/macOS/News/NewsView.swift +++ b/Hotline/macOS/News/NewsView.swift @@ -3,7 +3,7 @@ import MarkdownUI import SplitView struct NewsView: View { - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState @Environment(\.openWindow) private var openWindow @Environment(\.colorScheme) private var colorScheme @@ -64,7 +64,7 @@ struct NewsView: View { .task { if !model.newsLoaded { loading = true - await model.getNewsList() + try? await model.getNewsList() loading = false } } @@ -123,13 +123,13 @@ struct NewsView: View { loading = true if let selectionPath = selection?.path { Task { - await model.getNewsList(at: selectionPath) + try? await model.getNewsList(at: selectionPath) loading = false } } else { Task { - await model.getNewsList() + try? await model.getNewsList() loading = false } } @@ -179,7 +179,7 @@ struct NewsView: View { if let articleFlavor = article.articleFlavors?.first, let articleID = article.articleID { Task { - if let articleText = await self.model.getNewsArticle(id: articleID, at: article.path, flavor: articleFlavor) { + if let articleText = try? await self.model.getNewsArticle(id: articleID, at: article.path, flavor: articleFlavor) { self.articleText = articleText } } @@ -293,5 +293,5 @@ struct NewsView: View { #Preview { NewsView() - .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) + .environment(HotlineState()) } diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index e18d630..df3e54b 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -69,7 +69,7 @@ struct ListItemView: View { } extension FocusedValues { - @Entry var activeHotlineModel: Hotline? + @Entry var activeHotlineModel: HotlineState? @Entry var activeServerState: ServerState? } @@ -80,7 +80,7 @@ struct ServerView: View { @Environment(\.scenePhase) private var scenePhase @Environment(\.modelContext) private var modelContext - @State private var model: Hotline = Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient()) + @State private var model: HotlineState = HotlineState() @State private var state: ServerState = ServerState(selection: .chat) @State private var agreementShown: Bool = false @State private var connectAddress: String = "" @@ -119,6 +119,27 @@ struct ServerView: View { connectForm .navigationTitle("Connect to Server") } + else if case .failed(let error) = model.status { + VStack { + Image("Hotline") + .resizable() + .renderingMode(.template) + .scaledToFit() + .foregroundColor(Color(hex: 0xE10000)) + .frame(width: 18) + .opacity(controlActiveState == .inactive ? 0.5 : 1.0) + .padding(.trailing, 4) + + Text("Connection Failed") + .font(.headline) + Text(error) + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: 300) + .padding() + .navigationTitle("Connection Failed") + } else if model.status != .loggedIn { HStack { Image("Hotline") @@ -129,7 +150,7 @@ struct ServerView: View { .frame(width: 18) .opacity(controlActiveState == .inactive ? 0.5 : 1.0) .padding(.trailing, 4) - + ProgressView(value: connectionStatusToProgress(status: model.status)) { Text(connectionStatusToLabel(status: model.status)) } @@ -142,12 +163,24 @@ struct ServerView: View { else { serverView .environment(model) - .onChange(of: Prefs.shared.userIconID) { sendPreferences() } - .onChange(of: Prefs.shared.username) { sendPreferences() } - .onChange(of: Prefs.shared.refusePrivateMessages) { sendPreferences() } - .onChange(of: Prefs.shared.refusePrivateChat) { sendPreferences() } - .onChange(of: Prefs.shared.enableAutomaticMessage) { sendPreferences() } - .onChange(of: Prefs.shared.automaticMessage) { sendPreferences() } + .onChange(of: Prefs.shared.userIconID) { + Task { try? await model.sendUserPreferences() } + } + .onChange(of: Prefs.shared.username) { + Task { try? await model.sendUserPreferences() } + } + .onChange(of: Prefs.shared.refusePrivateMessages) { + Task { try? await model.sendUserPreferences() } + } + .onChange(of: Prefs.shared.refusePrivateChat) { + Task { try? await model.sendUserPreferences() } + } + .onChange(of: Prefs.shared.enableAutomaticMessage) { + Task { try? await model.sendUserPreferences() } + } + .onChange(of: Prefs.shared.automaticMessage) { + Task { try? await model.sendUserPreferences() } + } .toolbar { if #available(macOS 26.0, *) { ToolbarItem(placement: .navigation) { @@ -172,21 +205,23 @@ struct ServerView: View { } } .onDisappear { - model.disconnect() - } - .onChange(of: model.serverTitle) { oldTitle, newTitle in - state.serverName = newTitle - } - .onChange(of: model.bannerImage) { oldBanner, newBanner in - withAnimation { - state.serverBanner = newBanner - if let banner = newBanner { - state.bannerColors = ColorArt(image: banner, scaledSize: NSSize(width: 100, height: 100)) - } else { - state.bannerColors = nil - } + Task { + await model.disconnect() } } + .onChange(of: model.serverTitle) { + state.serverName = model.serverTitle + } +// .onChange(of: model.bannerImage) { +// state.serverBanner = model.bannerImage +// } +// .onChange(of: model.bannerColors) { +// guard let backgroundColor = model.bannerColors?.backgroundColor else { +// state.bannerBackgroundColor = nil +// return +// } +// state.bannerBackgroundColor = Color(nsColor: backgroundColor) +// } .alert(model.errorMessage ?? "Server Error", isPresented: $model.errorDisplayed) { Button("OK") {} } @@ -310,16 +345,18 @@ struct ServerView: View { 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 newBookmark = Bookmark(type: .server, name: name, address: host, port: port, login: login, password: password) - Bookmark.add(newBookmark, context: modelContext) - } +// 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 newBookmark = Bookmark(type: .server, name: name, address: host, port: port, login: login, password: password) + Bookmark.add(newBookmark, context: modelContext) } + +// } } } } @@ -390,7 +427,7 @@ struct ServerView: View { // Section("\(model.users.count) Online") { ForEach(model.users) { user in HStack(spacing: 5) { - if let iconImage = Hotline.getClassicIcon(Int(user.iconID)) { + if let iconImage = HotlineState.getClassicIcon(Int(user.iconID)) { Image(nsImage: iconImage) .frame(width: 16, height: 16) .padding(.leading, 2) @@ -476,35 +513,37 @@ struct ServerView: View { guard !server.address.isEmpty else { return } - model.login(server: server, username: Prefs.shared.username, iconID: Prefs.shared.userIconID) { success in - if !success { - print("FAILED LOGIN??") - model.disconnect() - } - else { - sendPreferences() - model.getUserList() - model.downloadBanner() + + Task { @MainActor in + do { + // login() handles everything: connect, getUserList, sendPreferences, downloadBanner + try await model.login( + server: server, + username: Prefs.shared.username, + iconID: Prefs.shared.userIconID + ) + } catch { + print("ServerView: Login failed: \(error)") } } } - private func connectionStatusToProgress(status: HotlineClientStatus) -> Double { + private func connectionStatusToProgress(status: HotlineConnectionStatus) -> Double { switch status { case .disconnected: return 0.0 case .connecting: return 0.4 case .connected: - return 0.75 - case .loggingIn: return 0.9 case .loggedIn: return 1.0 + case .failed: + return 0.0 } } - - private func connectionStatusToLabel(status: HotlineClientStatus) -> String { + + private func connectionStatusToLabel(status: HotlineConnectionStatus) -> String { let n = server.name ?? server.address switch status { case .disconnected: @@ -512,42 +551,21 @@ struct ServerView: View { case .connecting: return "Connecting to \(n)..." case .connected: - return "Connected to \(n)" - case .loggingIn: return "Logging in to \(n)..." case .loggedIn: return "Logged in to \(n)" + case .failed(let error): + return "Failed: \(error)" } } - @MainActor func sendPreferences() { - if self.model.status == .loggedIn { - var options: HotlineUserOptions = HotlineUserOptions() - - if Prefs.shared.refusePrivateMessages { - options.update(with: .refusePrivateMessages) - } - - if Prefs.shared.refusePrivateChat { - options.update(with: .refusePrivateChat) - } - - if Prefs.shared.enableAutomaticMessage { - options.update(with: .automaticResponse) - } - - print("Updating preferences with server") - - self.model.sendUserInfo(username: Prefs.shared.username, iconID: Prefs.shared.userIconID, options: options, autoresponse: Prefs.shared.automaticMessage) - } - } } struct TransferItemView: View { let transfer: TransferInfo @Environment(\.controlActiveState) private var controlActiveState - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState @State private var hovered: Bool = false @State private var buttonHovered: Bool = false @@ -559,8 +577,8 @@ struct TransferItemView: View { return "File transfer failed" } else if self.transfer.progress > 0.0 { - if self.transfer.timeRemaining > 0.0 { - return "\(round(self.transfer.progress * 100.0))% – \(self.transfer.timeRemaining) seconds left" + if let estimate = self.transfer.timeRemaining, estimate > 0.0 { + return "\(round(self.transfer.progress * 100.0))% – \(estimate) seconds left" } else { return "\(round(self.transfer.progress * 100.0))% complete" @@ -597,7 +615,7 @@ struct TransferItemView: View { if self.hovered { Button { - model.deleteTransfer(id: transfer.id) + AppState.shared.cancelTransfer(id: transfer.id) } label: { Image(systemName: self.buttonHovered ? "xmark.circle.fill" : "xmark.circle") .resizable() @@ -657,4 +675,3 @@ struct TransferItemView: View { .help(formattedProgressHelp()) } } - diff --git a/Hotline/macOS/Settings/IconSettingsView.swift b/Hotline/macOS/Settings/IconSettingsView.swift index 98cbb09..2bb92c4 100644 --- a/Hotline/macOS/Settings/IconSettingsView.swift +++ b/Hotline/macOS/Settings/IconSettingsView.swift @@ -18,7 +18,7 @@ struct IconSettingsView: View { GridItem(.fixed(4+32+4)), GridItem(.fixed(4+32+4)) ], spacing: 0) { - ForEach(Hotline.classicIconSet, id: \.self) { iconID in + ForEach(HotlineState.classicIconSet, id: \.self) { iconID in HStack { Image("Classic/\(iconID)") .resizable() diff --git a/Hotline/macOS/TransfersView.swift b/Hotline/macOS/TransfersView.swift new file mode 100644 index 0000000..9932c0a --- /dev/null +++ b/Hotline/macOS/TransfersView.swift @@ -0,0 +1,169 @@ +import SwiftUI + +struct TransfersView: View { + @Environment(\.appState) private var appState + + var body: some View { + VStack(spacing: 0) { + if appState.transfers.isEmpty { + emptyState + } else { + transfersList + } + } + .frame(minWidth: 500, minHeight: 200) + .navigationTitle("Transfers") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + appState.cancelAllTransfers() + } label: { + Label("Cancel All", systemImage: "xmark.circle") + } + .disabled(appState.transfers.isEmpty) + } + } + } + + // MARK: - Empty State + + private var emptyState: some View { + ContentUnavailableView { + Label("No Transfers", systemImage: "arrow.up.arrow.down") + } description: { + Text("Your Hotline file transfers will appear here") + } + } + + // MARK: - Transfers List + + private var transfersList: some View { + List { + ForEach(appState.transfers) { transfer in + TransferRow(transfer: transfer) + } + } + .listStyle(.inset(alternatesRowBackgrounds: true)) + } +} + +// MARK: - Transfer Row + +struct TransferRow: View { + @Bindable var transfer: TransferInfo + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + // File name and server + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(transfer.title) + .font(.system(.body, design: .default, weight: .medium)) + + if let serverName = transfer.serverName { + Text(serverName) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer() + + // Cancel button + Button { + AppState.shared.cancelTransfer(id: transfer.id) + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("Cancel download") + } + + // Progress bar and status + VStack(alignment: .leading, spacing: 4) { + if transfer.failed { + Label("Failed", systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(.red) + } else if transfer.completed { + Label("Complete", systemImage: "checkmark.circle.fill") + .font(.caption) + .foregroundStyle(.green) + } else { + // Progress bar + ProgressView(value: transfer.progress, total: 1.0) + .progressViewStyle(.linear) + + // Progress info + HStack(spacing: 8) { + // Progress percentage + Text("\(Int(transfer.progress * 100))%") + .font(.caption) + .foregroundStyle(.secondary) + .monospacedDigit() + + // File size + Text(formatSize(transfer.size)) + .font(.caption) + .foregroundStyle(.secondary) + + // Speed + if let speed = transfer.speed { + Text(formatSpeed(speed)) + .font(.caption) + .foregroundStyle(.secondary) + .monospacedDigit() + } + + // Time remaining + if let timeRemaining = transfer.timeRemaining { + Text(formatTimeRemaining(timeRemaining)) + .font(.caption) + .foregroundStyle(.secondary) + .monospacedDigit() + } + } + } + } + } + .padding(.vertical, 4) + } + + // MARK: - Formatting + + private func formatSize(_ bytes: UInt) -> String { + let formatter = ByteCountFormatter() + formatter.countStyle = .file + formatter.allowedUnits = [.useKB, .useMB, .useGB] + return formatter.string(fromByteCount: Int64(bytes)) + } + + private func formatSpeed(_ bytesPerSecond: Double) -> String { + let formatter = ByteCountFormatter() + formatter.countStyle = .file + formatter.allowedUnits = [.useKB, .useMB, .useGB] + return "\(formatter.string(fromByteCount: Int64(bytesPerSecond)))/s" + } + + private func formatTimeRemaining(_ seconds: TimeInterval) -> String { + if seconds < 60 { + return "\(Int(seconds))s" + } else if seconds < 3600 { + let minutes = Int(seconds / 60) + let secs = Int(seconds.truncatingRemainder(dividingBy: 60)) + return "\(minutes)m \(secs)s" + } else { + let hours = Int(seconds / 3600) + let minutes = Int((seconds.truncatingRemainder(dividingBy: 3600)) / 60) + return "\(hours)h \(minutes)m" + } + } +} + +// MARK: - Preview + +#Preview { + TransfersView() + .environment(AppState.shared) +} -- cgit From 39f51fd902bb34272c78ffdfb872c22095382f70 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 7 Nov 2025 10:39:48 -0800 Subject: Further cleanup of previous refactor. Removing old view model and NetSocket. Added some empty states for newsgroups and message board. --- Hotline.xcodeproj/project.pbxproj | 8 +- Hotline/Hotline/HotlineClient.swift | 878 ------- Hotline/Hotline/HotlineTransferClient.swift | 1834 --------------- .../Transfers/HotlineFileUploadClientNew.swift | 18 +- .../Transfers/HotlineFolderDownloadClientNew.swift | 38 +- .../Transfers/HotlineFolderUploadClientNew.swift | 28 +- Hotline/Library/NetSocket.swift | 274 --- Hotline/Models/Hotline.swift | 1876 --------------- Hotline/Models/HotlineState.swift | 2468 -------------------- Hotline/State/HotlineState.swift | 2468 ++++++++++++++++++++ Hotline/macOS/Board/MessageBoardView.swift | 121 +- Hotline/macOS/Files/FilesView.swift | 272 +-- Hotline/macOS/News/NewsView.swift | 42 +- 13 files changed, 2700 insertions(+), 7625 deletions(-) delete mode 100644 Hotline/Hotline/HotlineClient.swift delete mode 100644 Hotline/Library/NetSocket.swift delete mode 100644 Hotline/Models/Hotline.swift delete mode 100644 Hotline/Models/HotlineState.swift create mode 100644 Hotline/State/HotlineState.swift diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index a234fbc..cb621a0 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -137,7 +137,6 @@ DA3429B62EBAB1750010784E /* QuickLookPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickLookPreviewView.swift; sourceTree = ""; }; DA3429B82EBAB2130010784E /* FilePreviewQuickLookView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePreviewQuickLookView.swift; sourceTree = ""; }; DA43205D2B1D615600FC8843 /* ServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerView.swift; sourceTree = ""; }; - DA4930BC2B4F8DD700822D0B /* NetSocket.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetSocket.swift; sourceTree = ""; }; DA4B8F392EA6FB3C00CBFD53 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; }; DA4F2BF72B16A17200D8ADDC /* HotlineProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineProtocol.swift; sourceTree = ""; }; DA4F2C002B1A558E00D8ADDC /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = ""; }; @@ -161,7 +160,6 @@ DA55AC782BE6A1AD00034857 /* RegularExpressions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegularExpressions.swift; sourceTree = ""; }; DA5753672B33E88A00FAC277 /* HotlineTransferClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineTransferClient.swift; sourceTree = ""; }; DA57536B2B36BA1D00FAC277 /* TextDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextDocument.swift; sourceTree = ""; }; - DA6300962B24036B0034CBFD /* HotlineClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineClient.swift; sourceTree = ""; }; DA6549992BEC280E00EDB697 /* ServerMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerMessageView.swift; sourceTree = ""; }; DA65499B2BEC3FBD00EDB697 /* ServerAgreementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerAgreementView.swift; sourceTree = ""; }; DA65499D2BEC438A00EDB697 /* NSWindowBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSWindowBridge.swift; sourceTree = ""; }; @@ -209,7 +207,6 @@ DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdateView.swift; sourceTree = ""; }; DADDB28A2B22B31F0024040D /* Tracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tracker.swift; sourceTree = ""; }; DADDB28C2B22B5920024040D /* Server.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Server.swift; sourceTree = ""; }; - DADDB28E2B238D850024040D /* Hotline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Hotline.swift; sourceTree = ""; }; DAE136B92B9D1147007D8307 /* HotlinePanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlinePanelView.swift; sourceTree = ""; }; DAE734F82B2E4185000C56F6 /* ServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerView.swift; sourceTree = ""; }; DAE734FA2B2E41F9000C56F6 /* TrackerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackerView.swift; sourceTree = ""; }; @@ -255,6 +252,7 @@ DA2863D92B37BF6E00A7D050 /* Preferences.swift */, DACCE5E02EABE4B4008CDD92 /* AppUpdate.swift */, DA3429B42EBA8A450010784E /* FilePreviewState.swift */, + DA5268B02EB2708E00DCB941 /* HotlineState.swift */, ); path = State; sourceTree = ""; @@ -409,7 +407,6 @@ DAB4D8832B4CABEF0048A05C /* DataAdditions.swift */, DAB4D87F2B4C8E9A0048A05C /* URLAdditions.swift */, DA3429B62EBAB1750010784E /* QuickLookPreviewView.swift */, - DA4930BC2B4F8DD700822D0B /* NetSocket.swift */, DA872B122BDDBF78008B1012 /* HotlinePanel.swift */, DA872B142BDDEE1A008B1012 /* VisualEffectView.swift */, DA55AC722BE42AF000034857 /* AsyncLinkPreview.swift */, @@ -436,7 +433,6 @@ DABFCC262B1530AE009F40D2 /* Hotline */ = { isa = PBXGroup; children = ( - DA6300962B24036B0034CBFD /* HotlineClient.swift */, DA5268AE2EB2682B00DCB941 /* HotlineClientNew.swift */, DA9CAFCA2B126E3300CDA197 /* HotlineTrackerClient.swift */, DA5753672B33E88A00FAC277 /* HotlineTransferClient.swift */, @@ -476,8 +472,6 @@ DADDB2892B22B2C60024040D /* Models */ = { isa = PBXGroup; children = ( - DADDB28E2B238D850024040D /* Hotline.swift */, - DA5268B02EB2708E00DCB941 /* HotlineState.swift */, DADDB28A2B22B31F0024040D /* Tracker.swift */, DADDB28C2B22B5920024040D /* Server.swift */, DA32CD482B2931640053B98B /* User.swift */, diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift deleted file mode 100644 index 0e54872..0000000 --- a/Hotline/Hotline/HotlineClient.swift +++ /dev/null @@ -1,878 +0,0 @@ -import Foundation -import Network -import RegexBuilder - -enum HotlineClientStatus: Int { - case disconnected - case connecting - case connected - case loggingIn - case loggedIn -} - -enum HotlineTransactionError: Error { - case networkFailure - case timeout - case error(UInt32, String?) - case invalidMessage(UInt32, String?) -} - -struct HotlineTransactionInfo { - let type: HotlineTransactionType - let callback: ((HotlineTransaction) -> Void)? - let reply: ((HotlineTransaction) -> Void)? -} - -private struct HotlineLogin { - let login: String? - let password: String? - let username: String - let iconID: UInt16 - let callback: ((HotlineTransactionError?, String?, UInt16?) -> Void)? -} - -protocol HotlineClientDelegate: AnyObject { - func hotlineGetUserInfo() -> (String, UInt16) - func hotlineStatusChanged(status: HotlineClientStatus) - func hotlineReceivedAgreement(text: String) - func hotlineReceivedErrorMessage(code: UInt32, message: String?) - func hotlineReceivedChatMessage(message: String) - func hotlineReceivedUserList(users: [HotlineUser]) - func hotlineReceivedServerMessage(message: String) - func hotlineReceivedPrivateMessage(userID: UInt16, message: String) - func hotlineReceivedUserAccess(options: HotlineUserAccessOptions) - func hotlineUserChanged(user: HotlineUser) - func hotlineUserDisconnected(userID: UInt16) - func hotlineReceivedNewsPost(message: String) -} - -extension HotlineClientDelegate { - func hotlineStatusChanged(status: HotlineClientStatus) {} - func hotlineReceivedAgreement(text: String) {} - func hotlineReceivedErrorMessage(code: UInt32, message: String?) {} - func hotlineReceivedChatMessage(message: String) {} - func hotlineReceivedUserList(users: [HotlineUser]) {} - func hotlineReceivedServerMessage(message: String) {} - func hotlineReceivedPrivateMessage(userID: UInt16, message: String) {} - func hotlineReceivedUserAccess(options: HotlineUserAccessOptions) {} - func hotlineUserChanged(user: HotlineUser) {} - func hotlineUserDisconnected(userID: UInt16) {} - func hotlineReceivedNewsPost(message: String) {} -} - -enum HotlineClientStage { - case handshake - case packetHeader - case packetBody -} - -class HotlineClient: NetSocketDelegate { - static let handshakePacket = Data([ - 0x54, 0x52, 0x54, 0x50, // 'TRTP' protocol ID - 0x48, 0x4F, 0x54, 0x4C, // Sub-protocol ID - 0x00, 0x01, // Version - 0x00, 0x02, // Sub-version - ]) - - static let HandshakePacket: [UInt8] = [ - 0x54, 0x52, 0x54, 0x50, // 'TRTP' protocol ID - 0x48, 0x4F, 0x54, 0x4C, // Sub-protocol ID - 0x00, 0x01, // Version - 0x00, 0x02, // Sub-version - ] - - weak var delegate: HotlineClientDelegate? - - var connectionStatus: HotlineClientStatus = .disconnected - var connectCallback: ((Bool) -> Void)? - - private var serverAddress: String? = nil - private var serverPort: UInt16? = nil - - private struct TransactionContext { - let type: HotlineTransactionType - let callback: ((HotlineTransaction, HotlineTransactionError?) -> Void)? - let suppressErrors: Bool - } - - private var transactionLog: [UInt32: TransactionContext] = [:] - - private var socket: NetSocket? - private var stage: HotlineClientStage = .handshake - private var packet: HotlineTransaction? = nil - private var serverVersion: UInt16? = nil - private var loginDetails: HotlineLogin? = nil - private var keepAliveTimer: Timer? = nil - - init() {} - - // MARK: - NetSocket Delegate - - @MainActor func netsocketConnected(socket: NetSocket) { - self.updateConnectionStatus(.loggingIn) - self.stage = .handshake - } - - @MainActor func netsocketDisconnected(socket: NetSocket, error: Error?) { - self.reset() - self.updateConnectionStatus(.disconnected) - self.stage = .handshake - } - - @MainActor func netsocketReceived(socket: NetSocket, bytes: [UInt8]) { - switch self.stage { - case .handshake: - self.receiveHandshake() - case .packetHeader: - self.receivePacket() - case .packetBody: - self.receivePacket() - } - } - - // MARK: - Connect - - @MainActor func login(address: String, port: Int, login: String?, password: String?, username: String, iconID: UInt16, callback: ((HotlineTransactionError?, String?, UInt16?) -> Void)?) { - if self.socket != nil { - self.socket?.delegate = nil - self.socket?.close() - self.socket = nil - } - self.packet = nil - - self.loginDetails = HotlineLogin(login: login, password: password, username: username, iconID: iconID, callback: callback) - - self.socket = NetSocket() - self.socket?.delegate = self - - self.updateConnectionStatus(.connecting) - self.socket?.connect(host: address, port: port) - self.socket?.write(HotlineClient.HandshakePacket) - } - - @MainActor private func startKeepAliveTimer() { - self.keepAliveTimer = Timer.scheduledTimer(withTimeInterval: 60 * 3, repeats: true) { [weak self] _ in - DispatchQueue.main.async { [weak self] in - self?.sendKeepAlive() - } - } - } - - @MainActor func receiveHandshake() { - guard let socket = self.socket, - self.stage == .handshake, - socket.available >= 8 else { - return - } - - var handshake: [UInt8] = socket.read(count: 8) - - // Verify handshake data - guard let protocolID = handshake.consumeUInt32(), - protocolID == "TRTP".fourCharCode() else { - // TODO: Close with appropriate error - socket.close() - return - } - - // Check for error code - guard let errorCode = handshake.consumeUInt32(), - errorCode == 0 else { - // TODO: Close with wrapped error - socket.close() - return - } - - self.stage = .packetHeader - - let session = self.loginDetails! - self.loginDetails = nil - self.sendLogin(login: session.login ?? "", password: session.password ?? "", username: session.username, iconID: session.iconID) { [weak self] err, serverName, serverVersion in - self?.serverVersion = serverVersion - self?.startKeepAliveTimer() - session.callback?(err, serverName, serverVersion) - } - - self.receivePacket() - } - - @MainActor private func reset() { - self.transactionLog = [:] - self.packet = nil - - self.keepAliveTimer?.invalidate() - self.keepAliveTimer = nil - - self.socket?.close() - self.socket?.delegate = nil - self.socket = nil - } - - @MainActor func disconnect() { - let wasConnected = self.connectionStatus != .disconnected - self.reset() - if wasConnected { - self.updateConnectionStatus(.disconnected) - } - } - - // MARK: - Packets - - @MainActor private func sendPacket(_ t: HotlineTransaction, suppressErrors: Bool = false, callback: ((HotlineTransaction, HotlineTransactionError?) -> Void)? = nil) { - guard let socket = self.socket else { - return - } - - print("HotlineClient => \(t.id) \(t.type)") - - if callback != nil || suppressErrors { - self.transactionLog[t.id] = TransactionContext(type: t.type, callback: callback, suppressErrors: suppressErrors) - } - - socket.write(t.encoded()) - } - - @MainActor private func receivePacket() { - guard let socket = self.socket else { - return - } - - var done: Bool = false - repeat { - switch self.stage { - case .packetHeader: - guard socket.has(HotlineTransaction.headerSize) else { - done = true - break - } - - let headerData: [UInt8] = socket.read(count: HotlineTransaction.headerSize) - guard let packet = HotlineTransaction(from: headerData) else { - done = true - break - } - - self.packet = packet - if packet.dataSize == 0 { - self.stage = .packetHeader - self.processPacket() - } - else { - self.stage = .packetBody - } - - case .packetBody: - guard let packet = self.packet, socket.has(Int(packet.dataSize)) else { - done = true - break - } - - let bodyData: [UInt8] = socket.read(count: Int(packet.dataSize)) - self.packet?.decodeFields(from: bodyData) - self.stage = .packetHeader - self.processPacket() - - default: - done = true - break - } - } while !done - } - - @MainActor private func processPacket() { - guard let packet = self.packet else { - return - } - - if packet.type == .reply || packet.isReply == 1 { - print("HotlineClient <= \(packet.type) to \(packet.id):") - } - else { - print("HotlineClient <= \(packet.type) \(packet.id)") - } - - if packet.isReply == 1 || packet.type == .reply { - self.processReplyPacket() - return - } - - // Mark packet is processed - self.packet = nil - - switch(packet.type) { - case .chatMessage: - if - let chatTextParam = packet.getField(type: .data), - let chatText = chatTextParam.getString() - { - print("HotlineClient: \(chatText)") - self.delegate?.hotlineReceivedChatMessage(message: chatText) - } - - case .notifyOfUserChange: - if let usernameField = packet.getField(type: .userName), - let username = usernameField.getString(), - let userIDField = packet.getField(type: .userID), - let userID = userIDField.getUInt16(), - let userIconIDField = packet.getField(type: .userIconID), - let userIconID = userIconIDField.getUInt16(), - let userFlagsField = packet.getField(type: .userFlags), - let userFlags = userFlagsField.getUInt16() { - print("HotlineClient: User changed \(userID) \(username) icon: \(userIconID)") - - let user = HotlineUser(id: userID, iconID: userIconID, status: userFlags, name: username) - self.delegate?.hotlineUserChanged(user: user) - } - - case .notifyOfUserDelete: - if let userIDField = packet.getField(type: .userID), - let userID = userIDField.getUInt16() { - self.delegate?.hotlineUserDisconnected(userID: userID) - } - - case .disconnectMessage: - // Server disconnected us. - print("HotlineClient ❌") - self.disconnect() - - case .serverMessage: - if let messageField = packet.getField(type: .data), - let message = messageField.getString() { - - if let userIDField = packet.getField(type: .userID), - let userID = userIDField.getUInt16() { - self.delegate?.hotlineReceivedPrivateMessage(userID: userID, message: message) - } - else { - self.delegate?.hotlineReceivedServerMessage(message: message) - } - } - - case .showAgreement: - if let _ = packet.getField(type: .noServerAgreement) { - // Server told us there is no agreement to show. - return - } - if let agreementParam = packet.getField(type: .data) { - if let agreementText = agreementParam.getString() { - self.delegate?.hotlineReceivedAgreement(text: agreementText) - } - } - - case .userAccess: - print("HotlineClient: user access info \(packet.getField(type: .userAccess).debugDescription)") - if let accessParam = packet.getField(type: .userAccess) { - if let accessValue = accessParam.getUInt64() { - let accessOptions = HotlineUserAccessOptions(rawValue: accessValue) - self.delegate?.hotlineReceivedUserAccess(options: accessOptions) - } - } - - case .newMessage: - if let messageField = packet.getField(type: .data), - let message = messageField.getString() { - self.delegate?.hotlineReceivedNewsPost(message: message) - } - - default: - print("HotlineClient: UNKNOWN transaction \(packet.type) with \(packet.fields.count) parameters") - print(packet.fields) - } - } - - @MainActor private func processReplyPacket() { - guard let packet = self.packet else { - return - } - - let context = self.transactionLog[packet.id] - self.transactionLog[packet.id] = nil - - if packet.errorCode != 0 { - let errorField: HotlineTransactionField? = packet.getField(type: .errorText) - print("HotlineClient 😵 \(packet.errorCode): \(errorField?.getString() ?? "")") - if context?.suppressErrors != true { - self.delegate?.hotlineReceivedErrorMessage(code: packet.errorCode, message: errorField?.getString()) - } - } - - if let context { - print("HotlineClient reply in response to \(context.type)") - } - - let replyCallback = context?.callback - - guard packet.errorCode == 0 else { - let errorField: HotlineTransactionField? = packet.getField(type: .errorText) - replyCallback?(packet, .error(packet.errorCode, errorField?.getString())) - return - } - - replyCallback?(packet, nil) - } - - // MARK: - Messages - - @MainActor func sendLogin(login: String, password: String, username: String, iconID: UInt16, callback: ((HotlineTransactionError?, String?, UInt16?) -> Void)?) { - var t = HotlineTransaction(type: .login) - t.setFieldEncodedString(type: .userLogin, val: login) - t.setFieldEncodedString(type: .userPassword, val: password) - t.setFieldUInt16(type: .userIconID, val: iconID) - t.setFieldString(type: .userName, val: username) - t.setFieldUInt32(type: .versionNumber, val: 123) - - self.sendPacket(t) { [weak self] reply, err in - self?.updateConnectionStatus(.loggedIn) - - var serverVersion: UInt16? - var serverName: String? - - if - let serverVersionField = reply.getField(type: .versionNumber), - let serverVersionValue = serverVersionField.getUInt16() { - serverVersion = serverVersionValue - print("SERVER VERSION: \(serverVersionValue)") - } - - if - let serverNameField = reply.getField(type: .serverName), - let serverNameValue = serverNameField.getString() { - serverName = serverNameValue - print("SERVER NAME: \(serverNameValue)") - } - - callback?(err, serverName, serverVersion) - } - } - - @MainActor func sendSetClientUserInfo(username: String, iconID: UInt16, options: HotlineUserOptions = [], autoresponse: String? = nil) { - var t = HotlineTransaction(type: .setClientUserInfo) - t.setFieldString(type: .userName, val: username) - t.setFieldUInt16(type: .userIconID, val: iconID) - t.setFieldUInt16(type: .options, val: options.rawValue) - if let text = autoresponse { - t.setFieldString(type: .automaticResponse, val: text) - } - - self.sendPacket(t) - } - - @MainActor func sendAgree(username: String, iconID: UInt16, options: HotlineUserOptions) { - let t = HotlineTransaction(type: .agreed) -// t.setFieldString(type: .userName, val: username) -// t.setFieldUInt16(type: .userIconID, val: iconID) -// t.setFieldUInt8(type: .options, val: options.rawValue) - self.sendPacket(t) - } - - @MainActor func sendChat(message: String, encoding: String.Encoding = .utf8, announce: Bool = false) { - var t = HotlineTransaction(type: .sendChat) - t.setFieldString(type: .data, val: message, encoding: encoding) - t.setFieldUInt16(type: .chatOptions, val: announce ? 1 : 0) - self.sendPacket(t) - } - - @MainActor func sendInstantMessage(message: String, userID: UInt16, encoding: String.Encoding = .utf8) { - var t = HotlineTransaction(type: .sendInstantMessage) - t.setFieldUInt16(type: .userID, val: userID) - t.setFieldUInt32(type: .options, val: 1) - t.setFieldString(type: .data, val: message, encoding: encoding) - self.sendPacket(t) - } - - @MainActor func sendGetUserList() { - let t = HotlineTransaction(type: .getUserNameList) - self.sendPacket(t) { [weak self] reply, err in - var newUsers: [UInt16:HotlineUser] = [:] - var newUserList: [HotlineUser] = [] - for u in reply.getFieldList(type: .userNameWithInfo) { - let user = u.getUser() - newUsers[user.id] = user - newUserList.append(user) - } - self?.delegate?.hotlineReceivedUserList(users: newUserList) - } - } - - @MainActor func sendGetMessageBoard(callback: ((HotlineTransactionError?, [String]) -> Void)?) { - let t = HotlineTransaction(type: .getMessageBoard) - self.sendPacket(t) { reply, err in - guard err == nil, - let textField = reply.getField(type: .data), - let text = textField.getString() else { - callback?(err, []) - return - } - - var messages: [String] = [] - let matches = text.matches(of: RegularExpressions.messageBoardDivider) - var start = text.startIndex - - if matches.count > 0 { - for match in matches { - let range = match.range - let messageText = String(text[start.. 0 else { - return - } - - var t = HotlineTransaction(type: .oldPostNews) - t.setFieldString(type: .data, val: text.convertingLineEndings(to: .cr), encoding: .macOSRoman) - self.sendPacket(t) - } - - @MainActor func sendGetNewsCategories(path: [String] = [], callback: (([HotlineNewsCategory]) -> Void)?) { - var t = HotlineTransaction(type: .getNewsCategoryNameList) - if !path.isEmpty { - t.setFieldPath(type: .newsPath, val: path) - } - - self.sendPacket(t) { reply, err in - var categories: [HotlineNewsCategory] = [] - for categoryListItem in reply.getFieldList(type: .newsCategoryListData15) { - var c = categoryListItem.getNewsCategory() - c.path = path + [c.name] - categories.append(c) - } - callback?(categories) - } - } - - @MainActor func sendGetNewsArticle(id articleID: UInt32, path: [String], flavor: String, callback: ((String?) -> Void)? = nil) { - var t = HotlineTransaction(type: .getNewsArticleData) - t.setFieldPath(type: .newsPath, val: path) - t.setFieldUInt32(type: .newsArticleID, val: articleID) - t.setFieldString(type: .newsArticleDataFlavor, val: flavor, encoding: .ascii) - - self.sendPacket(t) { reply, err in - guard err == nil, - let articleData = reply.getField(type: .newsArticleData), - let articleString = articleData.getString() else { - callback?(nil) - return - } - - callback?(articleString) - } - } - - @MainActor func postNewsArticle(title: String, text: String, path: [String] = [], parentID: UInt32 = 0, callback: ((Bool) -> Void)? = nil) { - guard !path.isEmpty else { - callback?(false) - return - } - - var t = HotlineTransaction(type: .postNewsArticle) - t.setFieldPath(type: .newsPath, val: path) - t.setFieldUInt32(type: .newsArticleID, val: parentID) - t.setFieldString(type: .newsArticleTitle, val: title) - t.setFieldString(type: .newsArticleDataFlavor, val: "text/plain") - t.setFieldUInt32(type: .newsArticleFlags, val: 0) - t.setFieldString(type: .newsArticleData, val: text.convertingLineEndings(to: .cr)) - - print("HotlineClient postings \(title) under \(parentID)") - - self.sendPacket(t) { reply, err in - guard err == nil else { - callback?(false) - return - } - callback?(true) - } - } - - @MainActor func sendGetAccounts(callback: (([HotlineAccount]) -> Void)? = nil) { - let t = HotlineTransaction(type: .getAccounts) - - self.sendPacket(t) { reply, err in - guard err == nil else { - callback?([]) - return - } - - let accountFields = reply.getFieldList(type: .data) - - var accounts: [HotlineAccount] = [] - for data in accountFields { - accounts.append(data.getAcccount()) - } - - accounts.sort { $0.login < $1.login } - - callback?(accounts) - } - } - - @MainActor func sendGetNewsArticles(path: [String] = [], callback: (([HotlineNewsArticle]) -> Void)? = nil) { - var t = HotlineTransaction(type: .getNewsArticleNameList) - if !path.isEmpty { - t.setFieldPath(type: .newsPath, val: path) - } - self.sendPacket(t) { reply, err in - guard err == nil, - let articleData = reply.getField(type: .newsArticleListData) else { - callback?([]) - return - } - - var articles: [HotlineNewsArticle] = [] - let newsList = articleData.getNewsList() - for art in newsList.articles { - var blah = art - blah.path = path - articles.append(blah) - } - - callback?(articles) - } - } - - @MainActor func sendGetFileList(path: [String] = [], suppressErrors: Bool = false, callback: (([HotlineFile]) -> Void)? = nil) { - var t = HotlineTransaction(type: .getFileNameList) - if !path.isEmpty { - t.setFieldPath(type: .filePath, val: path) - } - - self.sendPacket(t, suppressErrors: suppressErrors) { reply, err in - guard err == nil else { - callback?([]) - return - } - - var files: [HotlineFile] = [] - for fi in reply.getFieldList(type: .fileNameWithInfo) { - let file = fi.getFile() - file.path = path + [file.name] - files.append(file) - } - - callback?(files) - } - } - - @MainActor func sendDeleteFile(name fileName: String, path filePath: [String], callback: ((Bool) -> Void)? = nil) { - var t = HotlineTransaction(type: .deleteFile) - t.setFieldString(type: .fileName, val: fileName) - t.setFieldPath(type: .filePath, val: filePath) - self.sendPacket(t) { reply, err in - callback?(err == nil) - } - } - - @MainActor func sendGetFileInfo(name fileName: String, path filePath: [String], callback: ((FileDetails?) -> Void)? = nil) { - var t = HotlineTransaction(type: .getFileInfo) - t.setFieldString(type: .fileName, val: fileName) - t.setFieldPath(type: .filePath, val: filePath) - - self.sendPacket(t) { reply, err in - guard err == nil, - let fileName = reply.getField(type: .fileName)?.getString(), - let fileCreator = reply.getField(type: .fileCreatorString)?.getString(), - let fileType = reply.getField(type: .fileTypeString)?.getString(), - let _ = reply.getField(type: .fileTypeString)?.getString(), - let fileCreateDate = reply.getField(type: .fileCreateDate)?.data.readDate(at: 0), - let fileModifyDate = reply.getField(type: .fileModifyDate)?.data.readDate(at: 0) - else { - callback?(nil) - return - } - - - // Size field is not included in server reply for folders - let fileSize = reply.getField(type: .fileSize)?.getInteger() ?? 0 - - // Comment field is not included for if no comment present - let fileComment = reply.getField(type: .fileComment)?.getString() ?? "" - - callback?(FileDetails(name: fileName, path: filePath, size: fileSize, comment: fileComment, type: fileType, creator: fileCreator, - created: fileCreateDate, modified: fileModifyDate)) - } - } - - - @MainActor func sendCreateUser(name: String, login: String, password: String?, access: uint64) { - var t = HotlineTransaction(type: .newUser) - - t.setFieldString(type: .userName, val: name) - t.setFieldEncodedString(type: .userLogin, val: login) - t.setFieldUInt64(type: .userAccess, val: access) - - if let password { - t.setFieldEncodedString(type: .userPassword, val: password) - } - - self.sendPacket(t) - // TODO: handle errors - } - - @MainActor func sendSetUser(name: String, login: String, newLogin: String?, password: String?, access: uint64) { - var t = HotlineTransaction(type: .setUser) - t.setFieldString(type: .userName, val: name) - t.setFieldUInt64(type: .userAccess, val: access) - - if let newLogin { - t.setFieldEncodedString(type: .data, val: login) - t.setFieldEncodedString(type: .userLogin, val: newLogin) - } else { - t.setFieldEncodedString(type: .userLogin, val: login) - } - - // In the setUser transaction, there are 3 possibilities for the password field: - // 1. If the password was not modified, the password field is sent with a zero byte. - if password == nil { - t.setFieldUInt8(type: .userPassword, val: 0) - } - - // 2. If the transaction should update the password, the password field is sent with the new password. - if let password, password != "" { - t.setFieldEncodedString(type: .userPassword, val: password) - } - - // 3) If the transaction should remove the password, the password field is omitted from the transaction. - self.sendPacket(t) - // TODO: handle errors - } - - @MainActor func sendDeleteUser(login: String) { - var t = HotlineTransaction(type: .deleteUser) - t.setFieldEncodedString(type: .userLogin, val: login) - - self.sendPacket(t) - // TODO: handle errors - } - - @MainActor func sendSetFileInfo(fileName: String, path filePath: [String], fileNewName: String?, comment: String?, encoding: String.Encoding = .utf8) { - var t = HotlineTransaction(type: .setFileInfo) - t.setFieldString(type: .fileName, val: fileName, encoding: encoding) - t.setFieldPath(type: .filePath, val: filePath) - - if fileNewName != nil { - t.setFieldString(type: .fileNewName, val: fileNewName!, encoding: encoding) - } - - if comment != nil { - t.setFieldString(type: .fileComment, val: comment!, encoding: encoding) - } - - self.sendPacket(t) - } - - @MainActor func sendDownloadFile(name fileName: String, path filePath: [String], preview: Bool = false, callback: ((Bool, UInt32?, Int?, Int?, Int?) -> Void)? = nil) { - var t = HotlineTransaction(type: .downloadFile) - t.setFieldString(type: .fileName, val: fileName) - t.setFieldPath(type: .filePath, val: filePath) - if preview { - t.setFieldUInt32(type: .fileTransferOptions, val: 2) - } - - self.sendPacket(t) { reply, err in - guard err == nil, - let transferSizeField = reply.getField(type: .transferSize), - let transferSize = transferSizeField.getInteger(), - let transferReferenceField = reply.getField(type: .referenceNumber), - let referenceNumber = transferReferenceField.getUInt32() else { - callback?(false, nil, nil, nil, nil) - return - } - - let transferFileSizeField = reply.getField(type: .fileSize) - let transferFileSize = transferFileSizeField?.getInteger() - let transferWaitingCountField = reply.getField(type: .waitingCount) - let transferWaitingCount = transferWaitingCountField?.getInteger() - - callback?(true, referenceNumber, transferSize, transferFileSize ?? transferSize, transferWaitingCount) - } - } - - @MainActor func sendUploadFile(name fileName: String, path filePath: [String], callback: ((Bool, UInt32?) -> Void)? = nil) { - var t = HotlineTransaction(type: .uploadFile) - t.setFieldString(type: .fileName, val: fileName) - t.setFieldPath(type: .filePath, val: filePath) - - self.sendPacket(t) { reply, err in - guard err == nil, - let transferReferenceField = reply.getField(type: .referenceNumber), - let referenceNumber = transferReferenceField.getUInt32() else { - callback?(false, nil) - return - } - - callback?(true, referenceNumber) - } - } - - @MainActor func sendDownloadFolder(name folderName: String, path folderPath: [String], callback: ((Bool, UInt32?, Int?, Int?, Int?) -> Void)? = nil) { - var t = HotlineTransaction(type: .downloadFolder) - t.setFieldString(type: .fileName, val: folderName) - t.setFieldPath(type: .filePath, val: folderPath) - - self.sendPacket(t) { reply, err in - guard err == nil, - let transferSizeField = reply.getField(type: .transferSize), - let transferSize = transferSizeField.getInteger(), - let transferReferenceField = reply.getField(type: .referenceNumber), - let referenceNumber = transferReferenceField.getUInt32() else { - callback?(false, nil, nil, nil, nil) - return - } - - let folderItemCountField = reply.getField(type: .folderItemCount) - let folderItemCount = folderItemCountField?.getInteger() - let transferWaitingCountField = reply.getField(type: .waitingCount) - let transferWaitingCount = transferWaitingCountField?.getInteger() - - callback?(true, referenceNumber, transferSize, folderItemCount, transferWaitingCount) - } - } - - @MainActor func sendDownloadBanner(callback: ((Bool, UInt32?, Int?) -> Void)? = nil) { - let t = HotlineTransaction(type: .downloadBanner) - - self.sendPacket(t) { reply, err in - guard err == nil, - let transferSizeField = reply.getField(type: .transferSize), - let transferSize = transferSizeField.getInteger(), - let transferReferenceField = reply.getField(type: .referenceNumber), - let referenceNumber = transferReferenceField.getUInt32() else { - callback?(false, nil, nil) - return - } - - callback?(true, referenceNumber, transferSize) - } - } - - @MainActor private func sendKeepAlive() { - print("HotlineClient: Sending keep alive") - if let v = self.serverVersion, v >= 185 { - let t = HotlineTransaction(type: .connectionKeepAlive) - self.sendPacket(t) - } - else { - let t = HotlineTransaction(type: .getUserNameList) - self.sendPacket(t) - } - } - - - // MARK: - Utility - - @MainActor private func updateConnectionStatus(_ status: HotlineClientStatus) { - self.connectionStatus = status - self.delegate?.hotlineStatusChanged(status: status) - } - -} diff --git a/Hotline/Hotline/HotlineTransferClient.swift b/Hotline/Hotline/HotlineTransferClient.swift index 30fe694..156b2b0 100644 --- a/Hotline/Hotline/HotlineTransferClient.swift +++ b/Hotline/Hotline/HotlineTransferClient.swift @@ -284,1837 +284,3 @@ struct HotlineFileInfoFork { return data } } - - -//protocol HotlineTransferDelegate: AnyObject { -// @MainActor func hotlineTransferStatusChanged(client: HotlineTransferClient, reference: UInt32, status: HotlineTransferStatus, timeRemaining: TimeInterval) -//} - -//protocol HotlineFileDownloadClientDelegate: HotlineTransferDelegate { -// @MainActor func hotlineFileDownloadReceivedInfo(client: HotlineFileDownloadClient, reference: UInt32, info: HotlineFileInfoFork) -// @MainActor func hotlineFileDownloadComplete(client: HotlineFileDownloadClient, reference: UInt32, at: URL) -//} - -//protocol HotlineFilePreviewClientDelegate: HotlineTransferDelegate { -// @MainActor func hotlineFilePreviewComplete(client: HotlineFilePreviewClient, reference: UInt32, data: Data) -//} - -//protocol HotlineFileUploadClientDelegate: HotlineTransferDelegate { -// @MainActor func hotlineFileUploadComplete(client: HotlineFileUploadClient, reference: UInt32) -//} - -//protocol HotlineFolderDownloadClientDelegate: HotlineTransferDelegate { -// @MainActor func hotlineFolderDownloadReceivedFileInfo(client: HotlineFolderDownloadClient, reference: UInt32, fileName: String, itemNumber: Int, totalItems: Int) -// @MainActor func hotlineFolderDownloadComplete(client: HotlineFolderDownloadClient, reference: UInt32, at: URL) -//} - -//enum HotlineFileTransferStage: Int { -// case fileHeader = 1 -// case fileForkHeader = 2 -// case fileInfoFork = 3 -// case fileDataFork = 4 -// case fileResourceFork = 5 -// case fileUnsupportedFork = 6 -//} - -//enum HotlineFileUploadStage: Int { -// case magic = 1 -// case fileHeader = 2 -// case fileInfoForkHeader = 3 -// case fileInfoFork = 4 -// case fileDataForkHeader = 5 -// case fileDataFork = 6 -// case fileResourceForkHeader = 7 -// case fileResourceFork = 8 -// case fileComplete = 9 -//} - -//enum HotlineFolderDownloadStage: Int { -// case itemHeader = 0 // Read 2-byte length + item header -// case waitingForFileSize = 1 // Read 4-byte file size before FILP -// case fileHeader = 2 -// case fileForkHeader = 3 -// case fileInfoFork = 4 -// case fileDataFork = 5 -// case fileResourceFork = 6 -// case fileUnsupportedFork = 7 -//} - - -// MARK: - - -//class HotlineFileUploadClient: HotlineTransferClient { -// let serverAddress: NWEndpoint.Host -// let serverPort: NWEndpoint.Port -// let referenceNumber: UInt32 -// -// weak var delegate: HotlineFileUploadClientDelegate? = nil -// -// private var connection: NWConnection? -// private var stage: HotlineFileUploadStage = .magic -// private var payloadSize: UInt32 = 0 -// private let fileURL: URL -// private let fileResourceURL: URL? -// private var fileHandle: FileHandle? = nil -// private var bytesSent: Int = 0 -// private let infoForkData: Data -// private let dataForkSize: UInt32 -// private let resourceForkSize: UInt32 -// -// var status: HotlineTransferStatus = .unconnected { -// didSet { -// DispatchQueue.main.async { -// self.delegate?.hotlineTransferStatusChanged(client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0) -// } -// } -// } -// -// init?(upload fileURL: URL, address: String, port: UInt16, reference: UInt32) { -// guard let payloadSize = FileManager.default.getFlattenedFileSize(fileURL) else { -// return nil -// } -// -// guard let infoFork = HotlineFileInfoFork(file: fileURL) else { -// return nil -// } -// -// guard let forkSizes = try? FileManager.default.getFileForkSizes(fileURL) else { -// return nil -// } -// -// self.serverAddress = NWEndpoint.Host(address) -// self.serverPort = NWEndpoint.Port(rawValue: port + 1)! -// self.referenceNumber = reference -// self.stage = .magic -// self.payloadSize = UInt32(payloadSize) -// self.fileURL = fileURL -// self.infoForkData = infoFork.data() -// self.dataForkSize = forkSizes.dataForkSize -// self.resourceForkSize = forkSizes.resourceForkSize -// if forkSizes.resourceForkSize > 0 { -// self.fileResourceURL = fileURL.urlForResourceFork() -// } -// else { -// self.fileResourceURL = nil -// } -// } -// -// deinit { -// self.invalidate() -// } -// -// func start() { -// guard self.status == .unconnected else { -// return -// } -// -// let _ = self.fileURL.startAccessingSecurityScopedResource() -// let _ = self.fileResourceURL?.stopAccessingSecurityScopedResource() -// -// self.bytesSent = 0 -// self.connect() -// } -// -// func cancel() { -// self.delegate = nil -// -// if self.status == .unconnected { -// return -// } -// -// self.invalidate() -// -// print("HotlineFileUploadClient: Cancelled upload") -// } -// -// private func connect() { -// self.connection = NWConnection(host: self.serverAddress, port: self.serverPort, using: .tcp) -// self.connection?.stateUpdateHandler = { [weak self] (newState: NWConnection.State) in -// switch newState { -// case .ready: -// self?.status = .connected -// self?.stage = .magic -// self?.send() -// case .waiting(let err): -// print("HotlineFileClient: Waiting", err) -// case .cancelled: -// print("HotlineFileClient: Cancelled") -// self?.invalidate() -// case .failed(let err): -// print("HotlineFileClient: Connection error \(err)") -// switch self?.status { -// case .connecting: -// print("HotlineFileClient: Failed to connect to file transfer server.") -// self?.invalidate() -// self?.status = .failed(.failedToConnect) -// case .connected, .progress(_): -// print("HotlineFileClient: Failed to finish transfer.") -// self?.invalidate() -// self?.status = .failed(.failedToUpload) -// case .completing: -// print("HotlineFileClient: Completed.") -// self?.invalidate() -// self?.status = .completed -// DispatchQueue.main.async { [weak self] in -// if let s = self { -// s.delegate?.hotlineFileUploadComplete(client: s, reference: s.referenceNumber) -// } -// } -// case .completed: -// self?.invalidate() -// default: -// break -// } -// default: -// return -// } -// } -// -// self.status = .connecting -// self.connection?.start(queue: .global()) -// } -// -// private func invalidate() { -// if let c = self.connection { -// c.stateUpdateHandler = nil -// c.cancel() -// -// self.connection = nil -// } -// -// self.stage = .magic -// -// if let fh = self.fileHandle { -// try? fh.close() -// self.fileHandle = nil -// } -// -// self.fileURL.stopAccessingSecurityScopedResource() -// self.fileResourceURL?.stopAccessingSecurityScopedResource() -// } -// -// private func sendComplete() { -// guard let c = self.connection else { -// self.invalidate() -// print("HotlineFileUploadClient: invalid connection to send data.") -// return -// } -// -// self.status = .completing -// -// c.send(content: nil, contentContext: .finalMessage, completion: .contentProcessed({ error in -// })) -// } -// -// private func sendFileData(_ data: Data) { -// guard let c = self.connection else { -// self.invalidate() -// -// print("HotlineFileUploadClient: invalid connection to send data.") -// return -// } -// -// let dataSent: Int = data.count -// c.send(content: data, completion: .contentProcessed({ [weak self] error in -// guard let client = self, -// error == nil else { -// self?.status = .failed(.failedToConnect) -// self?.invalidate() -// return -// } -// -// -// client.bytesSent += dataSent -// client.status = .progress(Double(client.bytesSent) / Double(client.payloadSize)) -// -// client.send() -// })) -// } -// -// private func send() { -// guard let _ = self.connection else { -// self.invalidate() -// print("HotlineFileUploadClient: Invalid connection to send.") -// return -// } -// -// switch self.stage { -// case .magic: -// print("Upload: Starting upload for \(self.fileURL)") -// print("Upload: Sending magic") -// self.status = .progress(0.0) -// -// let magicData = Data(endian: .big) { -// "HTXF".fourCharCode() -// self.referenceNumber -// self.payloadSize -// UInt32.zero -// } -// // var magicData = Data() -// // magicData.appendUInt32("HTXF".fourCharCode()) -// // magicData.appendUInt32(self.referenceNumber) -// // magicData.appendUInt32(self.payloadSize) -// // magicData.appendUInt32(0) -// self.stage = .fileHeader -// self.sendFileData(magicData) -// -// case .fileHeader: -// print("Upload: Sending file header") -// if let header = HotlineFileHeader(file: self.fileURL) { -// self.stage = .fileInfoForkHeader -// self.sendFileData(header.data()) -// } -// -// case .fileInfoForkHeader: -// print("Upload: Sending info fork header") -// let header = HotlineFileForkHeader(type: HotlineFileForkType.info.rawValue, dataSize: UInt32(self.infoForkData.count)) -// self.stage = .fileInfoFork -// self.sendFileData(header.data()) -// -// case .fileInfoFork: -// print("Upload: Sending info fork") -// self.stage = .fileDataForkHeader -// self.sendFileData(self.infoForkData) -// -// case .fileDataForkHeader: -// guard self.dataForkSize > 0 else { -// print("Upload: Data fork empty, skipping to resource fork") -// self.stage = .fileResourceForkHeader -// fallthrough -// } -// -// do { -// let fh = try FileHandle(forReadingFrom: self.fileURL) -// self.fileHandle = fh -// self.stage = .fileDataFork -// -// let header = HotlineFileForkHeader(type: HotlineFileForkType.data.rawValue, dataSize: self.dataForkSize) -// -// print("Upload: Sending data fork header \(self.dataForkSize)") -// self.sendFileData(header.data()) -// } -// catch { -// print("Upload: Error opening data fork", error) -// self.invalidate() -// return -// } -// -// case .fileDataFork: -// guard self.dataForkSize > 0, -// let fh = self.fileHandle else { -// print("Upload: Data fork empty, skipping to resource fork") -// self.stage = .fileResourceForkHeader -// try? self.fileHandle?.close() -// self.fileHandle = nil -// fallthrough -// } -// -// do { -// let fileData = try fh.read(upToCount: 4 * 1024) -// if fileData == nil || fileData?.isEmpty == true { -// print("Upload: Finished data fork") -// self.stage = .fileResourceForkHeader -// try? fh.close() -// self.fileHandle = nil -// fallthrough -// } -// -// print("Upload: Sending data Fork \(String(describing: fileData?.count))") -// self.sendFileData(fileData!) -// } -// catch { -// self.invalidate() -// print("Upload: Error reading data fork", error) -// return -// } -// -// case .fileResourceForkHeader: -// guard self.resourceForkSize > 0, -// let resourceURL = self.fileResourceURL else { -// print("Upload: Skipping resource fork header") -// self.stage = .fileComplete -// fallthrough -// } -// -// print("Upload: Sending resource fork header") -// guard let fh = try? FileHandle(forReadingFrom: resourceURL) else { -// print("Upload: Error reading resource fork") -// self.invalidate() -// return -// } -// -// let header = HotlineFileForkHeader(type: HotlineFileForkType.resource.rawValue, dataSize: self.resourceForkSize) -// -// self.fileHandle = fh -// self.stage = .fileResourceFork -// self.sendFileData(header.data()) -// -// case .fileResourceFork: -// guard self.resourceForkSize > 0, -// let fh = self.fileHandle else { -// print("Upload: Resource fork empty, skipping to completion") -// self.stage = .fileComplete -// try? self.fileHandle?.close() -// self.fileHandle = nil -// fallthrough -// } -// -// do { -// let resourceData = try fh.read(upToCount: 4 * 1024) -// if resourceData == nil || resourceData?.isEmpty == true { -// print("Upload: Finished resource fork") -// self.stage = .fileComplete -// try? self.fileHandle?.close() -// self.fileHandle = nil -// fallthrough -// } -// -// print("Upload: Sending resource fork \(String(describing: resourceData?.count))") -// self.sendFileData(resourceData!) -// } -// catch { -// self.invalidate() -// print("Upload: Error reading resource fork", error) -// return -// } -// break -// -// case .fileComplete: -// print("Upload: Complete!") -// self.sendComplete() -// } -// } -//} - -// MARK: - -// -//class HotlineFilePreviewClient: HotlineTransferClient { -// let serverAddress: NWEndpoint.Host -// let serverPort: NWEndpoint.Port -// let referenceNumber: UInt32 -// let referenceDataSize: UInt32 -// -// weak var delegate: HotlineFilePreviewClientDelegate? = nil -// -// var status: HotlineTransferStatus = .unconnected { -// didSet { -// DispatchQueue.main.async { -// self.delegate?.hotlineTransferStatusChanged(client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0) -// } -// } -// } -// -// private var downloadTask: Task? -// -// init(address: String, port: UInt16, reference: UInt32, size: UInt32) { -// self.serverAddress = NWEndpoint.Host(address) -// self.serverPort = NWEndpoint.Port(rawValue: port + 1)! -// self.referenceNumber = reference -// self.referenceDataSize = size -// } -// -// deinit { -// self.downloadTask?.cancel() -// } -// -// func start() { -// guard status == .unconnected else { -// return -// } -// -// self.downloadTask = Task { -// await self.download() -// } -// } -// -// func cancel() { -// self.downloadTask?.cancel() -// self.downloadTask = nil -// self.delegate = nil -// -// print("HotlineFilePreviewClient: Cancelled preview transfer") -// } -// -// private func download() async { -// await MainActor.run { self.status = .connecting } -// -// do { -// // Connect to file transfer server (already includes +1 in serverPort from init) -// let socket = try await NetSocketNew.connect( -// host: self.serverAddress, -// port: self.serverPort, -// tls: .disabled -// ) -// defer { Task { await socket.close() } } -// -// await MainActor.run { self.status = .connected } -// -// // Send magic header -// let headerData = Data(endian: .big) { -// "HTXF".fourCharCode() -// self.referenceNumber -// UInt32.zero -// UInt32.zero -// } -// try await socket.write(headerData) -// -// await MainActor.run { self.status = .progress(0.0) } -// -// // Download file data with progress updates -// let fileData = try await socket.read(Int(self.referenceDataSize)) { current, total in -// Task { @MainActor [weak self] in -// guard let self else { return } -// self.status = .progress(Double(current) / Double(total)) -// } -// } -// -// print("HotlineFilePreviewClient: Complete") -// await MainActor.run { self.status = .completed } -// -// // Notify delegate -// let reference = self.referenceNumber -// await MainActor.run { -// self.delegate?.hotlineFilePreviewComplete(client: self, reference: reference, data: fileData) -// } -// -// } catch is CancellationError { -// // Already handled in cancel() -// return -// } catch { -// print("HotlineFilePreviewClient: Download failed: \(error)") -// -// let failureStatus: HotlineTransferStatus = await MainActor.run { self.status } -// -// if failureStatus == .connecting { -// await MainActor.run { self.status = .failed(.failedToConnect) } -// } else { -// await MainActor.run { self.status = .failed(.failedToDownload) } -// } -// } -// } -// -// // status mutations are centralized via MainActor.run calls above -//} - -// MARK: - Async Preview Client (New) - -//enum HotlineFilePreviewClientNew { -// typealias ProgressHandler = @Sendable (NetSocketNew.FileProgress) -> Void -// -// struct Configuration { -// /// Chunk size used when reading the preview data stream. -// var chunkSize: Int = 256 * 1024 -// } -// -// /// Download preview data directly from the transfer server. -// /// - Parameters: -// /// - address: Hostname/IP of the Hotline server (preview runs on port+1). -// /// - port: Hotline base port. -// /// - reference: Transfer reference number returned by the server. -// /// - size: Expected payload size in bytes. -// /// - configuration: Optional tuning knobs (currently chunk size). -// /// - progress: Optional callback invoked as bytes stream in. -// /// - Returns: Raw preview data. -// static func download( -// address: String, -// port: UInt16, -// reference: UInt32, -// size: UInt32, -// configuration: Configuration = .init(), -// progress: ProgressHandler? = nil -// ) async throws -> Data { -// let host = NWEndpoint.Host(address) -// guard let transferPort = NWEndpoint.Port(rawValue: port + 1) else { -// throw NetSocketError.invalidPort -// } -// -// let socket = try await NetSocketNew.connect(host: host, port: transferPort, tls: .disabled) -// defer { Task { await socket.close() } } -// -// let header = Data(endian: .big) { -// "HTXF".fourCharCode() -// reference -// UInt32.zero -// UInt32.zero -// } -// -// try await socket.write(header) -// -// guard size > 0 else { -// return Data() -// } -// -// return try await socket.read(Int(size), chunkSize: configuration.chunkSize) { current, total in -// guard let progress else { return } -// let info = NetSocketNew.FileProgress(sent: current, total: total) -// progress(info) -// } -// } -//} - -// MARK: - - -//class HotlineFileDownloadClient: HotlineTransferClient { -// let serverAddress: NWEndpoint.Host -// let serverPort: NWEndpoint.Port -// let referenceNumber: UInt32 -// -// private var connection: NWConnection? -// private var transferStage: HotlineFileTransferStage = .fileHeader -// -// weak var delegate: HotlineFileDownloadClientDelegate? = nil -// -// var status: HotlineTransferStatus = .unconnected { -// didSet { -// DispatchQueue.main.async { -// self.delegate?.hotlineTransferStatusChanged(client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0) -// } -// } -// } -// -// private let referenceDataSize: UInt32 -// private var fileBytes = Data() -// private var fileResourceBytes = Data() -// -// private var fileHeader: HotlineFileHeader? = nil -// private var fileCurrentForkHeader: HotlineFileForkHeader? = nil -// private var fileCurrentForkBytesLeft: Int = 0 -// private var fileInfoFork: HotlineFileInfoFork? = nil -// private var fileHandle: FileHandle? = nil -// private var filePath: String? = nil -// private var fileBytesTransferred: Int = 0 -// private var fileProgress: Progress -// -// init(address: String, port: UInt16, reference: UInt32, size: UInt32) { -// self.serverAddress = NWEndpoint.Host(address) -// self.serverPort = NWEndpoint.Port(rawValue: port + 1)! -// self.referenceNumber = reference -// self.referenceDataSize = size -// self.transferStage = .fileHeader -// self.fileProgress = Progress(totalUnitCount: Int64(size)) -// } -// -// deinit { -// self.invalidate() -// } -// -// func start() { -// guard self.status == .unconnected else { -// return -// } -// -// self.filePath = nil -// self.connect() -// } -// -// func start(to fileURL: URL) { -// guard self.status == .unconnected else { -// return -// } -// -// self.filePath = fileURL.path -// self.connect() -// } -// -// func cancel() { -// self.delegate = nil -// -// if self.status == .unconnected { -// return -// } -// -// // Close file before we try to potentionally delete it. -// if let fh = self.fileHandle { -// try? fh.close() -// self.fileHandle = nil -// } -// -// if let downloadPath = self.filePath { -// print("HotlineFileClient: Deleting file fragment at", downloadPath) -// if FileManager.default.isDeletableFile(atPath: downloadPath) { -// try? FileManager.default.removeItem(atPath: downloadPath) -// } -// self.filePath = nil -// } -// -// self.invalidate() -// -// print("HotlineFileClient: Cancelled transfer") -// } -// -// private func connect() { -// self.connection = NWConnection(host: self.serverAddress, port: self.serverPort, using: .tcp) -// self.connection?.stateUpdateHandler = { [weak self] (newState: NWConnection.State) in -// switch newState { -// case .ready: -// self?.status = .connected -// self?.sendMagic() -// case .waiting(let err): -// print("HotlineFileClient: Waiting", err) -// case .cancelled: -// print("HotlineFileClient: Cancelled") -// self?.invalidate() -// case .failed(let err): -// print("HotlineFileClient: Connection error \(err)") -// switch self?.status { -// case .connecting: -// print("HotlineFileClient: Failed to connect to file transfer server.") -// self?.invalidate() -// self?.status = .failed(.failedToConnect) -// case .connected, .progress(_): -// print("HotlineFileClient: Failed to finish transfer.") -// self?.invalidate() -// self?.status = .failed(.failedToDownload) -// default: -// break -// } -// default: -// return -// } -// } -// -// self.status = .connecting -// self.connection?.start(queue: .global()) -// } -// -// func invalidate() { -// if let c = self.connection { -// c.stateUpdateHandler = nil -// c.cancel() -// -// self.connection = nil -// } -// -// self.fileBytes = Data() -// -// if let fh = self.fileHandle { -// try? fh.close() -// self.fileHandle = nil -// } -// -// self.fileProgress.unpublish() -// } -// -// private func sendMagic() { -// guard let c = connection, self.status == .connected else { -// self.invalidate() -// print("HotlineFileClient: invalid connection to send header.") -// return -// } -// -// let headerData = Data(endian: .big) { -// "HTXF".fourCharCode() -// self.referenceNumber -// UInt32.zero -// UInt32.zero -// } -// -// c.send(content: headerData, completion: .contentProcessed { [weak self] (error) in -// guard let self = self else { -// return -// } -// -// guard error == nil else { -// self.status = .failed(.failedToConnect) -// self.invalidate() -// return -// } -// -// self.status = .progress(0.0) -// self.receiveFile() -// }) -// } -// -// private func receiveFile() { -// guard let c = self.connection else { -// return -// } -// -// c.receive(minimumIncompleteLength: 1, maximumLength: Int(UInt16.max)) { [weak self] (data, context, isComplete, error) in -// guard let self = self else { -// return -// } -// -// guard error == nil else { -// self.status = .failed(.failedToDownload) -// self.invalidate() -// return -// } -// -// if let newData = data, !newData.isEmpty { -// self.fileBytesTransferred += newData.count -// self.fileBytes.append(newData) -// self.fileProgress.completedUnitCount = Int64(self.fileBytesTransferred) -// self.status = .progress(Double(self.fileBytesTransferred) / Double(self.referenceDataSize)) -// } -// -// // See if we need header data still. -// var keepProcessing = false -// repeat { -// keepProcessing = false -// -// switch self.transferStage { -// case .fileHeader: -// if let header = HotlineFileHeader(from: self.fileBytes) { -// self.fileBytes.removeSubrange(0..= infoForkDataSize { -// let infoForkData = self.fileBytes.subdata(in: 0.. 0 { -// if let f = self.fileHandle { -// do { -// var dataToWrite = self.fileBytes -// -// if dataToWrite.count >= self.fileCurrentForkBytesLeft { -// dataToWrite = self.fileBytes.subdata(in: 0.. 0 { -// var dataToWrite = self.fileBytes -// -// if dataToWrite.count >= self.fileCurrentForkBytesLeft { -// dataToWrite = self.fileBytes.subdata(in: 0.. 0 { -// var dataToWrite = self.fileBytes -// -// if dataToWrite.count >= self.fileCurrentForkBytesLeft { -// dataToWrite = self.fileBytes.subdata(in: 0.. 0 { -// let _ = self.writeResourceFork() -// self.fileResourceBytes = Data() -// } -// -// self.status = .completed -// -// if let downloadPath = self.filePath { -// DispatchQueue.main.sync { -// print("POSTING DOWNLOAD FILE FINISHED", downloadPath) -// -// var downloadURL = URL(filePath: downloadPath) -// downloadURL.resolveSymlinksInPath() -// print("FINAL PATH", downloadURL.path) -// -// self.delegate?.hotlineFileDownloadComplete(client: self, reference: self.referenceNumber, at: downloadURL) -// -//#if os(macOS) -// // Bounce dock icon when download completes. Weird this is the only API to do so. -// DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path) -//#endif -// } -// } -// } -// } -// } -// -// private func writeResourceFork() -> Bool { -// guard let filePath = self.filePath else { -// return false -// } -// -// var resolvedFileURL = URL(filePath: filePath) -// resolvedFileURL.resolveSymlinksInPath() -// -// let resourceFilePath = resolvedFileURL.appendingPathComponent("..namedfork/rsrc") -// -// do { -// try self.fileResourceBytes.write(to: resourceFilePath) -// } -// catch { -// return false -// } -// -// return true -// } -// -// private func prepareDownloadFile(name: String) -> Bool { -// var filePath: String -// -// if self.filePath != nil { -// filePath = self.filePath! -// } -// else { -// let folderURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0] -// filePath = folderURL.generateUniqueFilePath(filename: name) -// } -// -// var fileAttributes: [FileAttributeKey: Any] = [:] -// if let creatorCode = self.fileInfoFork?.creator { -// fileAttributes[.hfsCreatorCode] = creatorCode as NSNumber -// } -// if let typeCode = self.fileInfoFork?.type { -// fileAttributes[.hfsTypeCode] = typeCode as NSNumber -// } -// if let createdDate = self.fileInfoFork?.createdDate { -// fileAttributes[.creationDate] = createdDate as NSDate -// } -// if let modifiedDate = self.fileInfoFork?.modifiedDate { -// fileAttributes[.modificationDate] = modifiedDate as NSDate -// } -// if let comment = self.fileInfoFork?.comment { -// if let commentPlistData = try? PropertyListSerialization.data(fromPropertyList: comment, format: .binary, options: 0) { -// fileAttributes[FileAttributeKey(rawValue: "NSFileExtendedAttributes")] = [ -// FileAttributeKey(rawValue: "com.apple.metadata:kMDItemFinderComment"): commentPlistData -// ] -// } -// } -// -// if FileManager.default.createFile(atPath: filePath, contents: nil, attributes: fileAttributes) { -// if let h = FileHandle(forWritingAtPath: filePath) { -// self.filePath = filePath -// self.fileHandle = h -// self.fileProgress.fileURL = URL(filePath: filePath).resolvingSymlinksInPath() -// self.fileProgress.fileOperationKind = .downloading -// self.fileProgress.publish() -// return true -// } -// } -// -// return false -// } -//} - -// MARK: - - - -// MARK: - - -//class HotlineFolderDownloadClient: HotlineTransferClient { -// let serverAddress: NWEndpoint.Host -// let serverPort: NWEndpoint.Port -// let referenceNumber: UInt32 -// -// private var connection: NWConnection? -// private var transferStage: HotlineFolderDownloadStage = .fileHeader -// -// weak var delegate: HotlineFolderDownloadClientDelegate? = nil -// -// var status: HotlineTransferStatus = .unconnected { -// didSet { -// DispatchQueue.main.async { -// self.delegate?.hotlineTransferStatusChanged(client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0) -// } -// } -// } -// -// private let referenceDataSize: UInt32 -// private let folderItemCount: Int -// private var fileBytes = Data() -// private var fileResourceBytes = Data() -// -// private var fileHeader: HotlineFileHeader? = nil -// private var fileCurrentForkHeader: HotlineFileForkHeader? = nil -// private var fileCurrentForkBytesLeft: Int = 0 -// private var fileForksRemaining: Int = 0 -// private var currentFileSize: UInt32 = 0 // Size of current file from server -// private var currentFileBytesRead: Int = 0 // Bytes read for current file -// private var fileInfoFork: HotlineFileInfoFork? = nil -// private var fileHandle: FileHandle? = nil -// private var currentFilePath: String? = nil -// private var fileBytesTransferred: Int = 0 -// private var fileProgress: Progress -// -// private var currentItemNumber: Int = 0 -// private var completedItemCount: Int = 0 // Track actually completed files -// private var folderPath: String? = nil -// private var currentItemRelativePath: [String] = [] -// private var currentFileName: String? = nil -// -// private let FILP_MAGIC: UInt32 = "FILP".fourCharCode() -// -// init(address: String, port: UInt16, reference: UInt32, size: UInt32, itemCount: Int) { -// self.serverAddress = NWEndpoint.Host(address) -// self.serverPort = NWEndpoint.Port(rawValue: port + 1)! -// self.referenceNumber = reference -// self.referenceDataSize = size -// self.folderItemCount = itemCount -// self.transferStage = .fileHeader -// self.fileProgress = Progress(totalUnitCount: Int64(size)) -// } -// -// deinit { -// self.invalidate() -// } -// -// func start() { -// guard self.status == .unconnected else { -// return -// } -// -// self.folderPath = nil -// self.connect() -// } -// -// func start(to folderURL: URL) { -// print("HotlineFolderDownloadClient: start(to:) called with path: \(folderURL.path)") -// guard self.status == .unconnected else { -// print("HotlineFolderDownloadClient: Already connected, status: \(self.status)") -// return -// } -// -// self.folderPath = folderURL.path -// print("HotlineFolderDownloadClient: Calling connect()") -// self.connect() -// } -// -// func cancel() { -// self.delegate = nil -// -// if self.status == .unconnected { -// return -// } -// -// // Close file before we try to potentially delete it. -// if let fh = self.fileHandle { -// try? fh.close() -// self.fileHandle = nil -// } -// -// if let downloadPath = self.folderPath { -// print("HotlineFolderDownloadClient: Deleting folder fragment at", downloadPath) -// if FileManager.default.isDeletableFile(atPath: downloadPath) { -// try? FileManager.default.removeItem(atPath: downloadPath) -// } -// self.folderPath = nil -// } -// -// self.invalidate() -// -// print("HotlineFolderDownloadClient: Cancelled transfer") -// } -// -// private func connect() { -// print("HotlineFolderDownloadClient: connect() called, connecting to \(self.serverAddress):\(self.serverPort)") -// self.connection = NWConnection(host: self.serverAddress, port: self.serverPort, using: .tcp) -// print("HotlineFolderDownloadClient: NWConnection created") -// self.connection?.stateUpdateHandler = { [weak self] (newState: NWConnection.State) in -// switch newState { -// case .ready: -// print("HotlineFolderDownloadClient: Connection ready!") -// self?.status = .connected -// self?.sendMagic() -// case .waiting(let err): -// print("HotlineFolderDownloadClient: Waiting", err) -// case .cancelled: -// print("HotlineFolderDownloadClient: Cancelled") -// self?.invalidate() -// case .failed(let err): -// print("HotlineFolderDownloadClient: Connection error \(err)") -// switch self?.status { -// case .connecting: -// print("HotlineFolderDownloadClient: Failed to connect to file transfer server.") -// self?.invalidate() -// self?.status = .failed(.failedToConnect) -// case .connected, .progress(_): -// print("HotlineFolderDownloadClient: Failed to finish transfer.") -// self?.invalidate() -// self?.status = .failed(.failedToDownload) -// default: -// break -// } -// default: -// return -// } -// } -// -// self.status = .connecting -// self.connection?.start(queue: .global()) -// } -// -// func invalidate() { -// if let c = self.connection { -// c.stateUpdateHandler = nil -// c.cancel() -// -// self.connection = nil -// } -// -// self.fileBytes = Data() -// -// if let fh = self.fileHandle { -// try? fh.close() -// self.fileHandle = nil -// } -// -// self.fileProgress.unpublish() -// } -// -// private func parseItemHeaderPath(_ headerData: Data) -> (type: UInt16, components: [String])? { -// // Need at least: type(2) + count(2) -// guard headerData.count >= 4, -// let type = headerData.readUInt16(at: 0), -// let count = headerData.readUInt16(at: 2) else { return nil } -// -// var ofs = 4 -// var comps: [String] = [] -// for _ in 0..= ofs + 3 else { return nil } -// // per Hotline path encoding: reserved(2) then nameLen(1) then name -// ofs += 2 // reserved == 0 -// let nameLen = Int(headerData.readUInt8(at: ofs)!) -// ofs += 1 -// guard headerData.count >= ofs + nameLen else { return nil } -// let nameData = headerData.subdata(in: ofs..<(ofs + nameLen)) -// ofs += nameLen -// -// let name = String(data: nameData, encoding: .macOSRoman) -// ?? String(data: nameData, encoding: .utf8) -// ?? "" -// comps.append(name) -// } -// return (type, comps) -// } -// -// /// Find and align the buffer to the start of a FILP header. -// /// Returns the number of bytes dropped. -// @discardableResult -// private func alignBufferToFILP() -> Int { -// // We need at least the 4-byte magic to try -// guard self.fileBytes.count >= 4 else { return 0 } -// let magicData = Data([0x46, 0x49, 0x4C, 0x50]) // "FILP" -// if self.fileBytes.starts(with: magicData) { return 0 } -// -// if let r = self.fileBytes.firstRange(of: magicData) { -// let toDrop = r.lowerBound -// if toDrop > 0 { -// print("HotlineFolderDownloadClient: Resync — dropping \(toDrop) stray bytes before FILP") -// self.fileBytes.removeSubrange(0..= 2 else { break } -// let headerLen = Int(self.fileBytes.readUInt16(at: 0)!) -// guard self.fileBytes.count >= 2 + headerLen else { break } -// -// let headerData = self.fileBytes.subdata(in: 2..<(2 + headerLen)) -// -// guard let parsed = parseItemHeaderPath(headerData) else { -// print("HotlineFolderDownloadClient: Invalid item header; waiting for more data") -// break -// } -// -// // Consume the header from the buffer -// self.fileBytes.removeSubrange(0..<(2 + headerLen)) -// -// let itemType = parsed.type -// let comps = parsed.components -// let joinedPath = comps.joined(separator: "/") -// print("HotlineFolderDownloadClient: item type=\(itemType) path=\(joinedPath)") -// -// guard !comps.isEmpty else { -// print("HotlineFolderDownloadClient: Empty path components for item type \(itemType); requesting next item") -// self.sendAction(.nextFile) -// keepProcessing = !self.fileBytes.isEmpty -// break -// } -// -// if itemType == 1 { -// // Folder entries: create the directory locally and continue. -// self.currentItemRelativePath = comps -// -// if let base = self.folderPath { -// var dir = base -// for c in comps { dir = (dir as NSString).appendingPathComponent(c) } -// do { -// try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) -// print("HotlineFolderDownloadClient: Created folder at \(dir)") -// } catch { -// print("HotlineFolderDownloadClient: Failed to create subfolder: \(error)") -// } -// } -// -// self.completedItemCount += 1 -// -// if self.completedItemCount >= self.folderItemCount { -// self.handleAllItemsDownloaded() -// return -// } -// -// self.sendAction(.nextFile) -// keepProcessing = !self.fileBytes.isEmpty -// break -// } -// else if itemType == 0 { -// // File entries include the full path; split parent components from filename. -// self.currentItemRelativePath = Array(comps.dropLast()) -// self.currentFileName = comps.last -// -// self.transferStage = .waitingForFileSize -// print("HotlineFolderDownloadClient: Requesting file download for '\(self.currentFileName ?? "?")'") -// self.sendAction(.sendFile) -// -// if self.fileBytes.count >= 4 { -// keepProcessing = true -// } -// break -// } -// else { -// print("HotlineFolderDownloadClient: Unknown item type \(itemType); skipping") -// self.sendAction(.nextFile) -// keepProcessing = !self.fileBytes.isEmpty -// break -// } -// -// case .waitingForFileSize: -// // Read 4-byte file size that comes before FILP in folder mode -// guard self.fileBytes.count >= 4 else { break } -// let fileSize = self.fileBytes.readUInt32(at: 0)! -// self.fileBytes.removeSubrange(0..<4) -// -// print("HotlineFolderDownloadClient: File size: \(fileSize) bytes") -// -// self.currentFileSize = fileSize -// self.currentFileBytesRead = 0 -// -// // Align to FILP boundary before decoding the file header -// let dropped = self.alignBufferToFILP() -// if dropped > 0 { -// // These bytes were in the stream for this file but not part of FILP. -// // Keep byte-accounting consistent by shrinking the expected size. -// if self.currentFileSize >= UInt32(dropped) { -// self.currentFileSize -= UInt32(dropped) -// } else { -// // Defensive: if weird, treat as zero-size to avoid underflow -// self.currentFileSize = 0 -// } -// } -// -// self.transferStage = .fileHeader -// keepProcessing = true -// -// case .fileHeader: -// // Make sure we're actually at a FILP header -// if self.fileBytes.count >= HotlineFileHeader.DataSize { -// // If the 4-byte magic doesn't match, try one more resync here -// if self.fileBytes.readUInt32(at: 0)! != FILP_MAGIC { -// let dropped = self.alignBufferToFILP() -// if dropped > 0 { -// if self.currentFileSize >= UInt32(dropped) { self.currentFileSize -= UInt32(dropped) } -// } -// // If still not aligned or not enough bytes, wait for more data -// guard self.fileBytes.count >= HotlineFileHeader.DataSize, -// self.fileBytes.readUInt32(at: 0)! == FILP_MAGIC else { break } -// } -// -// if let header = HotlineFileHeader(from: self.fileBytes) { -// // Sanity gate: version and fork count -// if header.format != FILP_MAGIC || header.version == 0 || header.forkCount > 3 { -// print("HotlineFolderDownloadClient: Invalid FILP header (fmt=\(String(format:"0x%08X", header.format)), ver=\(header.version), forks=\(header.forkCount)). Resyncing.") -// // Try resync and wait for more data -// let dropped = self.alignBufferToFILP() -// if dropped > 0 { if self.currentFileSize >= UInt32(dropped) { self.currentFileSize -= UInt32(dropped) } } -// break -// } -// -// self.fileBytes.removeSubrange(0..= self.folderItemCount { -// self.handleAllItemsDownloaded() -// return -// } -// -// // More files to download -// // Check if server has pipelined all remaining data (we've received more than expected total) -// print("HotlineFolderDownloadClient: Completed \(self.completedItemCount)/\(self.folderItemCount) items, requesting next entry") -// self.transferStage = .itemHeader -// self.sendAction(.nextFile) -// keepProcessing = !self.fileBytes.isEmpty -// } -// // File not complete - try to read next fork header -// else if let forkHeader = HotlineFileForkHeader(from: self.fileBytes) { -// // Quick validation: first fork must be INFO, and sizes must be plausible -// let remainingForThisFile = max(0, Int(self.currentFileSize) - self.currentFileBytesRead) -// let plausible = forkHeader.dataSize <= UInt32(remainingForThisFile) -// let knownType = forkHeader.isInfoFork || forkHeader.isDataFork || forkHeader.isResourceFork -// -// if !plausible || (!knownType && self.currentFileBytesRead == HotlineFileHeader.DataSize) { -// print("HotlineFolderDownloadClient: Implausible fork header (type=\(String(format:"0x%08X", forkHeader.forkType)), size=\(forkHeader.dataSize), remaining=\(remainingForThisFile)). Resyncing.") -// let dropped = self.alignBufferToFILP() -// if dropped > 0 { if self.currentFileSize >= UInt32(dropped) { self.currentFileSize -= UInt32(dropped) } } -// break -// } -// -// self.fileBytes.removeSubrange(0..= infoForkDataSize { -// let infoForkData = self.fileBytes.subdata(in: 0.. 0 { -// if let f = self.fileHandle { -// do { -// var dataToWrite = self.fileBytes -// -// if dataToWrite.count >= self.fileCurrentForkBytesLeft { -// dataToWrite = self.fileBytes.subdata(in: 0.. 0 { -// var dataToWrite = self.fileBytes -// -// if dataToWrite.count >= self.fileCurrentForkBytesLeft { -// dataToWrite = self.fileBytes.subdata(in: 0.. 0 { -// var dataToWrite = self.fileBytes -// -// if dataToWrite.count >= self.fileCurrentForkBytesLeft { -// dataToWrite = self.fileBytes.subdata(in: 0.. 0 { -// let _ = self.writeResourceFork() -// self.fileResourceBytes = Data() -// } -// -// self.fileCurrentForkHeader = nil -// self.fileCurrentForkBytesLeft = 0 -// self.fileForksRemaining = 0 -// self.currentFileBytesRead = 0 -// self.currentFileSize = 0 -// self.currentFilePath = nil -// self.fileInfoFork = nil -// self.fileHeader = nil -// self.currentFileName = nil -// } -// -// private func handleAllItemsDownloaded() { -// guard self.status != .completed else { -// return -// } -// -// print("HotlineFolderDownloadClient: All \(self.folderItemCount) items downloaded") -// -// self.invalidate() -// self.status = .completed -// -// if let downloadPath = self.folderPath { -// DispatchQueue.main.sync { -// print("HotlineFolderDownloadClient: Folder download complete", downloadPath) -// -// var downloadURL = URL(filePath: downloadPath) -// downloadURL.resolveSymlinksInPath() -// -// self.delegate?.hotlineFolderDownloadComplete(client: self, reference: self.referenceNumber, at: downloadURL) -// -//#if os(macOS) -// DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path) -//#endif -// } -// } -// } -// -// private func writeResourceFork() -> Bool { -// guard let filePath = self.currentFilePath else { -// return false -// } -// -// var resolvedFileURL = URL(filePath: filePath) -// resolvedFileURL.resolveSymlinksInPath() -// -// let resourceFilePath = resolvedFileURL.urlForResourceFork() -// -// do { -// try self.fileResourceBytes.write(to: resourceFilePath) -// } -// catch { -// return false -// } -// -// return true -// } -// -// private func prepareDownloadFile(name: String) -> Bool { -// var filePath: String -// -// if self.folderPath != nil { -// // Build the full path including subfolders -// var fullPath = self.folderPath! -// for component in self.currentItemRelativePath { -// fullPath = (fullPath as NSString).appendingPathComponent(component) -// } -// -// let folderURL = URL(filePath: fullPath) -// -// // Create folder if it doesn't exist -// if !FileManager.default.fileExists(atPath: folderURL.path) { -// do { -// try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) -// } -// catch { -// print("HotlineFolderDownloadClient: Failed to create folder", error) -// return false -// } -// } -// -// filePath = folderURL.appendingPathComponent(name).path -// print("HotlineFolderDownloadClient: Creating file at \(filePath)") -// } -// else { -// let downloadsURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0] -// filePath = downloadsURL.generateUniqueFilePath(filename: name) -// } -// -// var fileAttributes: [FileAttributeKey: Any] = [:] -// if let creatorCode = self.fileInfoFork?.creator { -// fileAttributes[.hfsCreatorCode] = creatorCode as NSNumber -// } -// if let typeCode = self.fileInfoFork?.type { -// fileAttributes[.hfsTypeCode] = typeCode as NSNumber -// } -// if let createdDate = self.fileInfoFork?.createdDate { -// fileAttributes[.creationDate] = createdDate as NSDate -// } -// if let modifiedDate = self.fileInfoFork?.modifiedDate { -// fileAttributes[.modificationDate] = modifiedDate as NSDate -// } -// if let comment = self.fileInfoFork?.comment { -// if let commentPlistData = try? PropertyListSerialization.data(fromPropertyList: comment, format: .binary, options: 0) { -// fileAttributes[FileAttributeKey(rawValue: "NSFileExtendedAttributes")] = [ -// FileAttributeKey(rawValue: "com.apple.metadata:kMDItemFinderComment"): commentPlistData -// ] -// } -// } -// -// if FileManager.default.createFile(atPath: filePath, contents: nil, attributes: fileAttributes) { -// if let h = FileHandle(forWritingAtPath: filePath) { -// self.currentFilePath = filePath -// self.fileHandle = h -// -// // Only set file progress on first file -// if self.currentItemNumber == 1 && self.folderPath != nil { -// self.fileProgress.fileURL = URL(filePath: self.folderPath!).resolvingSymlinksInPath() -// self.fileProgress.fileOperationKind = .downloading -// self.fileProgress.publish() -// } -// -// return true -// } -// } -// -// return false -// } -//} diff --git a/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift index f065233..10934e7 100644 --- a/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift @@ -1,21 +1,13 @@ -// -// HotlineFileUploadClientNew.swift -// Hotline -// -// Modern async/await file upload client using NetSocketNew -// - import Foundation import Network -/// Modern async/await file upload client for Hotline protocol @MainActor public class HotlineFileUploadClientNew { // MARK: - Configuration public struct Configuration: Sendable { public var chunkSize: Int = 256 * 1024 - public var publishProgress: Bool = true + public init() {} } @@ -186,11 +178,9 @@ public class HotlineFileUploadClientNew { progressHandler?(.transfer(name: filename, size: self.transferSize, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: nil, estimate: nil)) // Configure progress for Finder if enabled - if config.publishProgress { - self.transferProgress.fileURL = fileURL.resolvingSymlinksInPath() - self.transferProgress.fileOperationKind = .uploading - self.transferProgress.publish() - } + self.transferProgress.fileURL = fileURL.resolvingSymlinksInPath() + self.transferProgress.fileOperationKind = .uploading + self.transferProgress.publish() // Send DATA fork if present if dataForkSize > 0 { diff --git a/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift index 9dcb7c9..4ad35e7 100644 --- a/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift @@ -1,10 +1,3 @@ -// -// HotlineFolderDownloadClientNew.swift -// Hotline -// -// Modern async/await folder download client using NetSocketNew -// - import Foundation import Network @@ -15,24 +8,14 @@ public struct HotlineFolderItemProgress: Sendable { public let totalItems: Int } -/// Modern async/await folder download client for Hotline protocol @MainActor public class HotlineFolderDownloadClientNew { - // MARK: - Configuration - - public struct Configuration: Sendable { - public var publishProgress: Bool = true - public init() {} - } - // MARK: - Properties private let serverAddress: String private let serverPort: UInt16 private let referenceNumber: UInt32 - private let config: Configuration - private let transferTotal: Int private let folderItemCount: Int private var transferSize: Int = 0 @@ -48,13 +31,11 @@ public class HotlineFolderDownloadClientNew { port: UInt16, reference: UInt32, size: UInt32, - itemCount: Int, - configuration: Configuration = .init() + itemCount: Int ) { self.serverAddress = address self.serverPort = port self.referenceNumber = reference - self.config = configuration self.transferTotal = Int(size) self.folderItemCount = itemCount @@ -141,18 +122,11 @@ public class HotlineFolderDownloadClientNew { try fm.createDirectory(at: destinationURL, withIntermediateDirectories: true) // Create and publish progress for the entire folder (shows in Finder) - if config.publishProgress { - let progress = Progress(totalUnitCount: Int64(self.transferTotal)) - progress.fileURL = destinationURL - progress.fileOperationKind = .downloading - progress.publish() - self.folderProgress = progress - } - defer { - // Unpublish progress when folder download completes - self.folderProgress?.unpublish() - self.folderProgress = nil - } + let progress = Progress(totalUnitCount: Int64(self.transferTotal)) + progress.fileURL = destinationURL + progress.fileOperationKind = .downloading + progress.publish() + self.folderProgress = progress // Send initial magic header print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Sending HTXF magic") diff --git a/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift index 0b78f33..2efced2 100644 --- a/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift @@ -15,14 +15,12 @@ private struct FolderItem { let isFolder: Bool } -/// Modern async/await folder upload client for Hotline protocol @MainActor public class HotlineFolderUploadClientNew { // MARK: - Configuration public struct Configuration: Sendable { public var chunkSize: Int = 256 * 1024 - public var publishProgress: Bool = true public init() {} } @@ -163,7 +161,6 @@ public class HotlineFolderUploadClientNew { }) progressHandler?(.connected) -// progressHandler?(.transfer(size: 0, total: self.transferTotal, progress: 0.0, speed: nil, estimate: nil)) var completedItemCount = 0 var totalBytesTransferred = 0 @@ -171,7 +168,7 @@ public class HotlineFolderUploadClientNew { var stage: UploadStage = .waitingForNextFile var currentItem: FolderItem? - // State machine loop - matches C++ client's goto-based state machine + // State machine loop while stage != .done { switch stage { @@ -330,7 +327,6 @@ public class HotlineFolderUploadClientNew { } } - // Following C++ client behavior: Build hierarchy with root folder name prepended to all paths // Start from root folder with root name as first path component try walkFolder(at: folderURL, relativePath: [rootFolderName]) totalItems = folderItems.count @@ -339,8 +335,6 @@ public class HotlineFolderUploadClientNew { } private func encodeItemHeader(item: FolderItem) -> Data { - // Following C++ client behavior: Skip the first path component (root folder name) - // The C++ client does: startPtr += 2; startPtr += *startPtr + 1; pathCount--; let strippedPath = item.pathComponents.count > 1 ? Array(item.pathComponents.dropFirst()) : item.pathComponents let strippedPathCount = strippedPath.count @@ -428,22 +422,17 @@ public class HotlineFolderUploadClientNew { bytesUploaded += HotlineFileForkHeader.DataSize // Send INFO fork data - print("HotlineFolderUploadClientNew[\(referenceNumber)]: Sending INFO fork (\(infoForkData.count) bytes)") try await socket.write(infoForkData) bytesUploaded += infoForkData.count // Create per-file progress for Finder - var fileProgress: Progress? - if config.publishProgress { - let progress = Progress(totalUnitCount: Int64(totalFileSize)) - progress.fileURL = fileURL.resolvingSymlinksInPath() - progress.fileOperationKind = Progress.FileOperationKind.uploading - progress.publish() - fileProgress = progress - } + let fileProgress = Progress(totalUnitCount: Int64(totalFileSize)) + fileProgress.fileURL = fileURL.resolvingSymlinksInPath() + fileProgress.fileOperationKind = Progress.FileOperationKind.uploading + fileProgress.publish() defer { - fileProgress?.unpublish() + fileProgress.unpublish() } // Send DATA fork if present @@ -459,7 +448,7 @@ public class HotlineFolderUploadClientNew { let updates = await socket.writeFile(from: fileHandle, length: Int(dataForkSize)) for try await p in updates { // Update per-file Finder progress - fileProgress?.completedUnitCount = Int64(bytesUploaded + p.sent) + fileProgress.completedUnitCount = Int64(bytesUploaded + p.sent) // Calculate overall folder progress let totalBytesNow = totalBytesTransferredSoFar + bytesUploaded + p.sent @@ -503,7 +492,7 @@ public class HotlineFolderUploadClientNew { let updates = await socket.writeFile(from: resourceHandle, length: Int(resourceForkSize)) for try await p in updates { // Update per-file Finder progress - fileProgress?.completedUnitCount = Int64(bytesUploaded + p.sent) + fileProgress.completedUnitCount = Int64(bytesUploaded + p.sent) // Calculate overall folder progress let totalBytesNow = totalBytesTransferredSoFar + bytesUploaded + p.sent @@ -532,7 +521,6 @@ public class HotlineFolderUploadClientNew { bytesUploaded += Int(resourceForkSize) } - print("HotlineFolderUploadClientNew[\(referenceNumber)]: File upload complete, \(bytesUploaded) bytes sent") return bytesUploaded } } diff --git a/Hotline/Library/NetSocket.swift b/Hotline/Library/NetSocket.swift deleted file mode 100644 index b1f1742..0000000 --- a/Hotline/Library/NetSocket.swift +++ /dev/null @@ -1,274 +0,0 @@ - -// NetSocket.swift -// A simple delegate based buffered read/write TCP socket. -// Created by Dustin Mierau - -import Foundation - -protocol NetSocketDelegate: AnyObject { - func netsocketConnected(socket: NetSocket) - func netsocketDisconnected(socket: NetSocket, error: Error?) - func netsocketReceived(socket: NetSocket, bytes: [UInt8]) - func netsocketSent(socket: NetSocket, count: Int) -} - -extension NetSocketDelegate { - func netsocketConnected(socket: NetSocket) {} - func netsocketDisconnected(socket: NetSocket, error: Error?) {} - func netsocketReceived(socket: NetSocket, bytes: [UInt8]) {} - func netsocketSent(socket: NetSocket, count: Int) {} -} - -enum NetSocketStatus { - case disconnected - case connecting - case connected -} - -final class NetSocket: NSObject, StreamDelegate { - weak var delegate: NetSocketDelegate? = nil - - private var output: OutputStream? = nil - private var input: InputStream? = nil - - private var outputBuffer: [UInt8] = [] - private var inputBuffer: [UInt8] = [] - - private var readBuffer: [UInt8] = Array(repeating: 0, count: 4 * 1024) - - public func peek() -> [UInt8] { self.inputBuffer } - public var available: Int { self.inputBuffer.count } - - private var status: NetSocketStatus = .disconnected - - @MainActor public func has(_ length: Int) -> Bool { - return (self.available >= length) - } - - override init() {} - - @MainActor public func connect(host: String, port: Int) { - self.close() - - var outputStream: OutputStream? = nil - var inputStream: InputStream? = nil - - self.status = .connecting - - Stream.getStreamsToHost(withName: host, port: port, inputStream: &inputStream, outputStream: &outputStream) - - self.input = inputStream - self.output = outputStream - - inputStream?.delegate = self - outputStream?.delegate = self - - inputStream?.schedule(in: .current, forMode: .default) - outputStream?.schedule(in: .current, forMode: .default) - - inputStream?.open() - outputStream?.open() - } - - @MainActor public func close(_ err: Error? = nil) { - print("NetSocket: Closed") - - let disconnected = (self.status != .disconnected) - - self.status = .disconnected - - self.input?.delegate = nil - self.output?.delegate = nil - self.input?.close() - self.output?.close() - self.input?.remove(from: .current, forMode: .default) - self.output?.remove(from: .current, forMode: .default) - self.input = nil - self.output = nil - self.inputBuffer = [] - self.outputBuffer = [] - - if disconnected { - self.delegate?.netsocketDisconnected(socket: self, error: err) - } - } - - @MainActor public func write(_ data: Data) { - guard let output = self.output else { - return - } - - self.outputBuffer.append(contentsOf: data) - - if output.hasSpaceAvailable { - self.writeBufferToStream() - } - } - - @MainActor public func write(_ data: [UInt8]) { - guard let output = self.output else { - return - } - - self.outputBuffer.append(contentsOf: data) - - if output.hasSpaceAvailable { - self.writeBufferToStream() - } - } - - @MainActor public func read(count: Int) -> [UInt8] { - guard self.inputBuffer.count > 0, count > 0 else { - return [] - } - - let amountToRead = min(count, self.inputBuffer.count) - let dataRead: [UInt8] = Array(self.inputBuffer[0.. Data { - guard self.inputBuffer.count > 0, count > 0 else { - return Data() - } - - let amountToRead = min(count, self.inputBuffer.count) - - let dataRead: Data = Data(self.inputBuffer[0.. [UInt8] { - guard self.inputBuffer.count > 0 else { - return [] - } - - let dataRead: [UInt8] = Array(self.inputBuffer) - self.inputBuffer = [] - - return dataRead - } - - @MainActor public func readAll() -> Data { - guard self.inputBuffer.count > 0 else { - return Data() - } - - let dataRead: Data = Data(self.inputBuffer) - self.inputBuffer = [] - - return dataRead - } - - @MainActor private func writeBufferToStream() { - guard let output = self.output, self.outputBuffer.count > 0 else { - return - } - - let bytesWritten = output.write(self.outputBuffer, maxLength: self.outputBuffer.count) - print("NetSocket => \(bytesWritten) bytes") - if bytesWritten > 0 { - self.outputBuffer.removeFirst(bytesWritten) - self.delegate?.netsocketSent(socket: self, count: bytesWritten) - } - else if bytesWritten == -1 { - self.close(output.streamError) - } - } - - @MainActor private func readStreamToBuffer() { - guard let input = self.input else { - return - } - - let bytesRead = input.read(&self.readBuffer, maxLength: 4 * 1024) - print("NetSocket <= \(bytesRead) bytes") - if bytesRead > 0 { - self.inputBuffer.append(contentsOf: self.readBuffer[0.. Bool { - return lhs.id == rhs.id - } - - #if os(macOS) - static func getClassicIcon(_ index: Int) -> NSImage? { - return NSImage(named: "Classic/\(index)") - } - #elseif os(iOS) - static func getClassicIcon(_ index: Int) -> UIImage? { - return UIImage(named: "Classic/\(index)") - } - #endif - - // The icon ordering here was painsakenly pulled manually - // from the original Hotline client to display the classic icons - // in the same order as the original client. - static let classicIconSet: [Int] = [ - 141, 149, 150, 151, 172, 184, 204, - 2013, 2036, 2037, 2055, 2400, 2505, 2534, - 2578, 2592, 4004, 4015, 4022, 4104, 4131, - 4134, 4136, 4169, 4183, 4197, 4240, 4247, - 128, 129, 130, 131, 132, 133, 134, - 135, 136, 137, 138, 139, 140, 142, - 143, 144, 145, 146, 147, 148, 152, - 153, 154, 155, 156, 157, 158, 159, - 160, 161, 162, 163, 164, 165, 166, - 167, 168, 169, 170, 171, 173, 174, - 175, 176, 177, 178, 179, 180, 181, - 182, 183, 185, 186, 187, 188, 189, - 190, 191, 192, 193, 194, 195, 196, - 197, 198, 199, 200, 201, 202, 203, - 205, 206, 207, 208, 209, 212, 214, - 215, 220, 233, 236, 237, 243, 244, - 277, 410, 414, 500, 666, 1250, 1251, - 1968, 1969, 2000, 2001, 2002, 2003, 2004, - 2006, 2007, 2008, 2009, 2010, 2011, 2012, - 2014, 2015, 2016, 2017, 2018, 2019, 2020, - 2021, 2022, 2023, 2024, 2025, 2026, 2027, - 2028, 2029, 2030, 2031, 2032, 2033, 2034, - 2035, 2038, 2040, 2041, 2042, 2043, 2044, - 2045, 2046, 2047, 2048, 2049, 2050, 2051, - 2052, 2053, 2054, 2056, 2057, 2058, 2059, - 2060, 2061, 2062, 2063, 2064, 2065, 2066, - 2067, 2070, 2071, 2072, 2073, 2075, 2079, - 2098, 2100, 2101, 2102, 2103, 2104, 2105, - 2106, 2107, 2108, 2109, 2110, 2112, 2113, - 2115, 2116, 2117, 2118, 2119, 2120, 2121, - 2122, 2123, 2124, 2125, 2126, 4150, 2223, - 2401, 2402, 2403, 2404, 2500, 2501, 2502, - 2503, 2504, 2506, 2507, 2528, 2529, 2530, - 2531, 2532, 2533, 2535, 2536, 2537, 2538, - 2539, 2540, 2541, 2542, 2543, 2544, 2545, - 2546, 2547, 2548, 2549, 2550, 2551, 2552, - 2553, 2554, 2555, 2556, 2557, 2558, 2559, - 2560, 2561, 2562, 2563, 2564, 2565, 2566, - 2567, 2568, 2569, 2570, 2571, 2572, 2573, - 2574, 2575, 2576, 2577, 2579, 2580, 2581, - 2582, 2583, 2584, 2585, 2586, 2587, 2588, - 2589, 2590, 2591, 2593, 2594, 2595, 2596, - 2597, 2598, 2599, 2600, 4000, 4001, 4002, - 4003, 4005, 4006, 4007, 4008, 4009, 4010, - 4011, 4012, 4013, 4014, 4016, 4017, 4018, - 4019, 4020, 4021, 4023, 4024, 4025, 4026, - 4027, 4028, 4029, 4030, 4031, 4032, 4033, - 4034, 4035, 4036, 4037, 4038, 4039, 4040, - 4041, 4042, 4043, 4044, 4045, 4046, 4047, - 4048, 4049, 4050, 4051, 4052, 4053, 4054, - 4055, 4056, 4057, 4058, 4059, 4060, 4061, - 4062, 4063, 4064, 4065, 4066, 4067, 4068, - 4069, 4070, 4071, 4072, 4073, 4074, 4075, - 4076, 4077, 4078, 4079, 4080, 4081, 4082, - 4083, 4084, 4085, 4086, 4087, 4088, 4089, - 4090, 4091, 4092, 4093, 4094, 4095, 4096, - 4097, 4098, 4099, 4100, 4101, 4102, 4103, - 4105, 4106, 4107, 4108, 4109, 4110, 4111, - 4112, 4113, 4114, 4115, 4116, 4117, 4118, - 4119, 4120, 4121, 4122, 4123, 4124, 4125, - 4126, 4127, 4128, 4129, 4130, 4132, 4133, - 4135, 4137, 4138, 4139, 4140, 4141, 4142, - 4143, 4144, 4145, 4146, 4147, 4148, 4149, - 4151, 4152, 4153, 4154, 4155, 4156, 4157, - 4158, 4159, 4160, 4161, 4162, 4163, 4164, - 4165, 4166, 4167, 4168, 4170, 4171, 4172, - 4173, 4174, 4175, 4176, 4177, 4178, 4179, - 4180, 4181, 4182, 4184, 4185, 4186, 4187, - 4188, 4189, 4190, 4191, 4192, 4193, 4194, - 4195, 4196, 4198, 4199, 4200, 4201, 4202, - 4203, 4204, 4205, 4206, 4207, 4208, 4209, - 4210, 4211, 4212, 4213, 4214, 4215, 4216, - 4217, 4218, 4219, 4220, 4221, 4222, 4223, - 4224, 4225, 4226, 4227, 4228, 4229, 4230, - 4231, 4232, 4233, 4234, 4235, 4236, 4238, - 4241, 4242, 4243, 4244, 4245, 4246, 4248, - 4249, 4250, 4251, 4252, 4253, 4254, 31337, - 6001, 6002, 6003, 6004, 6005, 6008, 6009, - 6010, 6011, 6012, 6013, 6014, 6015, 6016, - 6017, 6018, 6023, 6025, 6026, 6027, 6028, - 6029, 6030, 6031, 6032, 6033, 6034, 6035 - ] - - var status: HotlineClientStatus = .disconnected - var server: Server? { - didSet { - self.updateServerTitle() - } - } - var serverVersion: UInt16 = 123 - var serverName: String? { - didSet { - self.updateServerTitle() - } - } - var serverTitle: String = "Server" - var username: String = "guest" - var iconID: Int = 414 - var access: HotlineUserAccessOptions? - var agreed: Bool = false - var users: [User] = [] - var accounts: [HotlineAccount] = [] - var chat: [ChatMessage] = [] - var chatInput: String = "" - var messageBoard: [String] = [] - var messageBoardLoaded: Bool = false - var files: [FileInfo] = [] - var filesLoaded: Bool = false - var fileSearchResults: [FileInfo] = [] - var fileSearchStatus: FileSearchStatus = .idle - var fileSearchQuery: String = "" - var fileSearchConfig = FileSearchConfig() - var fileSearchScannedFolders: Int = 0 - var fileSearchCurrentPath: [String]? = nil - @ObservationIgnored private var fileSearchSession: FileSearchSession? = nil - @ObservationIgnored private var fileSearchResultKeys: Set = [] - private struct FileListCacheEntry { - let files: [FileInfo] - let timestamp: Date - } - @ObservationIgnored private var fileListCache: [String: FileListCacheEntry] = [:] - var news: [NewsInfo] = [] - private var newsLookup: [String:NewsInfo] = [:] - var newsLoaded: Bool = false - var accountsLoaded: Bool = false - var instantMessages: [UInt16:[InstantMessage]] = [:] - var transfers: [TransferInfo] = [] - var downloads: [HotlineTransferClient] = [] - var unreadInstantMessages: [UInt16:UInt16] = [:] - var unreadPublicChat: Bool = false - var errorDisplayed: Bool = false - var errorMessage: String? = nil - - @ObservationIgnored var bannerClient: HotlineFilePreviewClient? - @ObservationIgnored private var chatSessionKey: ChatStore.SessionKey? - @ObservationIgnored private var restoredChatSessionKey: ChatStore.SessionKey? - @ObservationIgnored private var chatHistoryObserver: NSObjectProtocol? - @ObservationIgnored private var lastPersistedMessageType: ChatMessageType? - #if os(macOS) - var bannerImage: NSImage? = nil - #elseif os(iOS) - var bannerImage: UIImage? = nil - #endif - - - // MARK: - - - init(trackerClient: HotlineTrackerClient, client: HotlineClient) { - self.trackerClient = trackerClient - self.client = client - self.client.delegate = self - - self.chatHistoryObserver = NotificationCenter.default.addObserver(forName: ChatStore.historyClearedNotification, object: nil, queue: .main) { [weak self] _ in - self?.handleChatHistoryCleared() - } - } - - // MARK: - - - @MainActor func getServerList(tracker: String, port: Int = HotlinePorts.DefaultTrackerPort) async -> [Server] { - var servers: [Server] = [] - print("Hotline.getServerList: Starting fetch from \(tracker):\(port)") - - do { - for try await hotlineServer in self.trackerClient.fetchServers(address: tracker, port: port) { - if let serverName = hotlineServer.name { - servers.append(Server( - name: serverName, - description: hotlineServer.description, - address: hotlineServer.address, - port: Int(hotlineServer.port), - users: Int(hotlineServer.users) - )) - if servers.count % 10 == 0 { - print("Hotline.getServerList: Collected \(servers.count) servers so far...") - } - } - } - } catch { - print("Hotline.getServerList: Error - \(error)") - } - - print("Hotline.getServerList: Returning \(servers.count) servers") - return servers - } - - @MainActor func disconnectTracker() { - // No-op: HotlineTrackerClient now uses async/await and manages - // connections internally. Each fetchServers() call opens and closes - // its own connection automatically. - } - - @MainActor func login(server: Server, username: String, iconID: Int, callback: ((Bool) -> Void)? = nil) { - self.server = server - self.serverName = server.name - self.username = username - self.iconID = iconID - - let key = sessionKey(for: server) - self.chatSessionKey = key - self.restoredChatSessionKey = nil - self.lastPersistedMessageType = nil - self.chat = [] - self.restoreChatHistory(for: key) - - self.client.login(address: server.address, port: server.port, login: server.login, password: server.password, username: username, iconID: UInt16(iconID)) { [weak self] err, serverName, serverVersion in - self?.serverVersion = serverVersion ?? 123 - if serverName != nil { - self?.serverName = serverName - } - - callback?(err == nil) - } - } - - @MainActor func sendUserInfo(username: String, iconID: Int, options: HotlineUserOptions = [], autoresponse: String? = nil) { - self.username = username - self.iconID = iconID - - self.client.sendSetClientUserInfo(username: username, iconID: UInt16(iconID), options: options, autoresponse: autoresponse) - } - - @MainActor func getUserList() { - self.client.sendGetUserList() - } - - @MainActor func disconnect() { - self.client.disconnect() - self.bannerClient?.cancel() - self.fileSearchSession?.cancel() - self.fileSearchSession = nil - self.resetFileSearchState() - } - - deinit { - if let observer = chatHistoryObserver { - NotificationCenter.default.removeObserver(observer) - } - } - - @MainActor func sendAgree() { - self.client.sendAgree(username: self.username, iconID: UInt16(self.iconID), options: .none) - } - - @MainActor func sendInstantMessage(_ text: String, userID: UInt16) { - let message = InstantMessage(direction: .outgoing, text: text.convertingLinksToMarkdown(), type: .message, date: Date()) - - if self.instantMessages[userID] == nil { - self.instantMessages[userID] = [message] - } - else { - self.instantMessages[userID]!.append(message) - } - - self.client.sendInstantMessage(message: text, userID: userID) - - if Prefs.shared.playPrivateMessageSound && Prefs.shared.playPrivateMessageSound { - SoundEffectPlayer.shared.playSoundEffect(.chatMessage) - } - } - - func markPublicChatAsRead() { - self.unreadPublicChat = false - } - - func hasUnreadInstantMessages(userID: UInt16) -> Bool { - return self.unreadInstantMessages[userID] != nil - } - - func markInstantMessagesAsRead(userID: UInt16) { - self.unreadInstantMessages.removeValue(forKey: userID) - } - - @MainActor func sendChat(_ text: String, announce: Bool = false) { - self.client.sendChat(message: text, announce: announce) - } - - @MainActor func getMessageBoard() async -> [String] { - self.messageBoard = await withCheckedContinuation { [weak self] continuation in - self?.client.sendGetMessageBoard() { err, messages in - continuation.resume(returning: (err != nil ? [] : messages)) - } - } - - self.messageBoardLoaded = true - - return self.messageBoard - } - - @MainActor func postToMessageBoard(text: String) { - self.client.sendPostMessageBoard(text: text) - } - - @MainActor func getFileList(path: [String] = [], suppressErrors: Bool = false, preferCache: Bool = false) async -> [FileInfo] { - if preferCache, let cached = cachedFileList(for: path, ttl: fileSearchConfig.cacheTTL, allowStale: false) { - return cached.items - } - - return await withCheckedContinuation { [weak self] continuation in - self?.client.sendGetFileList(path: path, suppressErrors: suppressErrors) { [weak self] files in - let parentFile = self?.findFile(in: self?.files ?? [], at: path) - - var newFiles: [FileInfo] = [] - for f in files { - newFiles.append(FileInfo(hotlineFile: f)) - } - - DispatchQueue.main.async { - if let parent = parentFile { - parent.children = newFiles - } - else if path.isEmpty { - self?.filesLoaded = true - self?.files = newFiles - } - - self?.storeFileListInCache(newFiles, for: path) - continuation.resume(returning: newFiles) - } - } - } - } - - @MainActor func startFileSearch(query: String) { - let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - cancelFileSearch() - return - } - - fileSearchSession?.cancel() - resetFileSearchState() - fileSearchQuery = trimmed - fileSearchStatus = .searching(processed: 0, pending: 0) - fileSearchScannedFolders = 0 - fileSearchCurrentPath = [] - - let session = FileSearchSession(hotline: self, query: trimmed, config: fileSearchConfig) - fileSearchSession = session - - Task { await session.start() } - } - - @MainActor func cancelFileSearch(clearResults: Bool = true) { - guard let session = fileSearchSession else { - if clearResults { - resetFileSearchState() - } else if !fileSearchResults.isEmpty { - fileSearchStatus = .cancelled(processed: fileSearchScannedFolders) - fileSearchCurrentPath = nil - } - return - } - - session.cancel() - fileSearchSession = nil - fileSearchCurrentPath = nil - if clearResults { - resetFileSearchState() - } - else { - fileSearchStatus = .cancelled(processed: fileSearchScannedFolders) - } - } - - @MainActor fileprivate func searchSession(_ session: FileSearchSession, didEmit matches: [FileInfo], processed: Int, pending: Int) { - guard fileSearchSession === session else { - return - } - - var appended: [FileInfo] = [] - for match in matches { - let key = searchPathKey(for: match.path) - if fileSearchResultKeys.insert(key).inserted { - appended.append(match) - } - } - - if !appended.isEmpty { - fileSearchResults.append(contentsOf: appended) - } - - fileSearchScannedFolders = processed - fileSearchStatus = .searching(processed: processed, pending: pending) - } - - @MainActor fileprivate func searchSession(_ session: FileSearchSession, didFocusOn path: [String]) { - guard fileSearchSession === session else { - return - } - - fileSearchCurrentPath = path - } - - @MainActor fileprivate func searchSessionDidFinish(_ session: FileSearchSession, processed: Int, pending: Int, completed: Bool) { - guard fileSearchSession === session else { - return - } - - fileSearchScannedFolders = processed - fileSearchSession = nil - fileSearchCurrentPath = nil - if completed { - fileSearchStatus = .completed(processed: processed) - } else { - fileSearchStatus = .cancelled(processed: processed) - } - } - - private func resetFileSearchState() { - fileSearchResults = [] - fileSearchResultKeys.removeAll(keepingCapacity: true) - fileSearchStatus = .idle - fileSearchQuery = "" - fileSearchScannedFolders = 0 - fileSearchCurrentPath = nil - } - - private func searchPathKey(for path: [String]) -> String { - path.joined(separator: "\u{001F}") - } - - private func shouldBypassFileCache(for path: [String]) -> Bool { - guard let folderName = path.last else { - return false - } - - let trimmed = folderName.trimmingCharacters(in: .whitespacesAndNewlines) - - if trimmed.range(of: "upload", options: [.caseInsensitive]) != nil { - return true - } - - if trimmed.range(of: "dropbox", options: [.caseInsensitive]) != nil { - return true - } - - if trimmed.range(of: "drop box", options: [.caseInsensitive]) != nil { - return true - } - - return false - } - - private func cachedFileList(for path: [String], ttl: TimeInterval, allowStale: Bool) -> (items: [FileInfo], isFresh: Bool)? { - guard ttl > 0 else { - return nil - } - - if shouldBypassFileCache(for: path) { - return nil - } - - let key = searchPathKey(for: path) - guard let entry = fileListCache[key] else { - return nil - } - - let age = Date().timeIntervalSince(entry.timestamp) - let isFresh = age <= ttl - if !allowStale && !isFresh { - return nil - } - - return (entry.files, isFresh) - } - - private func storeFileListInCache(_ files: [FileInfo], for path: [String]) { - guard fileSearchConfig.cacheTTL > 0 else { - return - } - - if shouldBypassFileCache(for: path) { - return - } - - let key = searchPathKey(for: path) - fileListCache[key] = FileListCacheEntry(files: files, timestamp: Date()) - pruneFileListCacheIfNeeded() - } - - private func pruneFileListCacheIfNeeded() { - let limit = fileSearchConfig.maxCachedFolders - guard limit > 0, fileListCache.count > limit else { - return - } - - let excess = fileListCache.count - limit - guard excess > 0 else { return } - - let sortedKeys = fileListCache.sorted { lhs, rhs in - lhs.value.timestamp < rhs.value.timestamp - } - - for index in 0.. (items: [FileInfo], isFresh: Bool)? { - cachedFileList(for: path, ttl: ttl, allowStale: true) - } - - @MainActor func clearFileListCache() { - guard !fileListCache.isEmpty else { - return - } - - fileListCache.removeAll(keepingCapacity: false) - } - - @MainActor func getNewsArticle(id articleID: UInt, at path: [String], flavor: String) async -> String? { - return await withCheckedContinuation { [weak self] continuation in - self?.client.sendGetNewsArticle(id: UInt32(articleID), path: path, flavor: flavor) { articleText in -// let parentNews = self?.findNews(in: self?.news ?? [], at: path) - -// var newCategories: [NewsInfo] = [] -// for category in categories { -// newCategories.append(NewsInfo(hotlineNewsCategory: category)) -// } -// -// if let parent = existingNewsItem { -// parent.children = newCategories -// } -// else if path.isEmpty { -// self?.news = newCategories -// } - - continuation.resume(returning: articleText) - } - } - - } - - @MainActor func getAccounts() async -> [HotlineAccount] { - return await withCheckedContinuation { [weak self] continuation in - self?.client.sendGetAccounts() { articles in - continuation.resume(returning: articles) - } - } - } - - @MainActor func getNewsList(at path: [String] = []) async { - return await withCheckedContinuation { [weak self] continuation in - let parentNewsGroup = self?.findNews(in: self?.news ?? [], at: path) - - // Send a categories request for bundle paths or root (empty path) - if path.isEmpty || parentNewsGroup?.type == .bundle { - print("Hotline: Requesting categories at: /\(path.joined(separator: "/"))") - - self?.client.sendGetNewsCategories(path: path) { @MainActor [weak self] categories in - // Create info for each category returned. - var newCategoryInfos: [NewsInfo] = [] - - // Transform hotline categories into NewsInfo objects. - for category in categories { - var newsCategoryInfo = NewsInfo(hotlineNewsCategory: category) - - if let lookupPath = newsCategoryInfo.lookupPath { - // Merge returned category info with existing category info. - if let existingCategoryInfo = self?.newsLookup[lookupPath] { - print("Hotline: Merging category into existing category at \(lookupPath)") - - existingCategoryInfo.count = newsCategoryInfo.count - existingCategoryInfo.name = newsCategoryInfo.name - existingCategoryInfo.path = newsCategoryInfo.path - existingCategoryInfo.categoryID = newsCategoryInfo.categoryID - newsCategoryInfo = existingCategoryInfo - } - else { - print("Hotline: New category added at \(lookupPath)") - self?.newsLookup[lookupPath] = newsCategoryInfo - } - } - - newCategoryInfos.append(newsCategoryInfo) - } - - if let parent = parentNewsGroup { - parent.children = newCategoryInfos - } - else if path.isEmpty { - self?.newsLoaded = true - self?.news = newCategoryInfos - } - - continuation.resume() - } - } - else { - print("Hotline: Requesting articles at: /\(path.joined(separator: "/"))") - - self?.client.sendGetNewsArticles(path: path) { @MainActor [weak self] articles in - print("Hotline: Organizing news at \(path.joined(separator: "/"))") - - // Create info for each article returned. - var newArticleInfos: [NewsInfo] = [] - - for article in articles { - var newsArticleInfo = NewsInfo(hotlineNewsArticle: article) - - if let lookupPath = newsArticleInfo.lookupPath { - // Merge returned category info with existing category info. - if let existingArticleInfo = self?.newsLookup[lookupPath] { - print("Hotline: Merging article into existing article at \(lookupPath)") - - existingArticleInfo.count = newsArticleInfo.count - existingArticleInfo.name = newsArticleInfo.name - existingArticleInfo.path = newsArticleInfo.path - existingArticleInfo.articleUsername = newsArticleInfo.articleUsername - existingArticleInfo.articleDate = newsArticleInfo.articleDate - existingArticleInfo.articleFlavors = newsArticleInfo.articleFlavors - existingArticleInfo.articleID = newsArticleInfo.articleID - newsArticleInfo = existingArticleInfo - } - else { - print("Hotline: New article added at \(lookupPath)") - self?.newsLookup[lookupPath] = newsArticleInfo - } - } - - newArticleInfos.append(newsArticleInfo) - } - - let organizedNewsArticles: [NewsInfo] = self?.organizeNewsArticles(newArticleInfos) ?? [] - if let parent = parentNewsGroup { - parent.children = organizedNewsArticles - } - - continuation.resume() - } - } - } - } - - func organizeNewsArticles(_ flatArticles: [NewsInfo]) -> [NewsInfo] { - // Place articles under their parent. - var organized: [NewsInfo] = [] - for article in flatArticles { - if let parentLookupPath = article.parentArticleLookupPath, - let parentArticle = self.newsLookup[parentLookupPath] { -// article.expanded = true - if parentArticle.children.firstIndex(of: article) == nil { - article.expanded = true - parentArticle.children.append(article) - } - } - else { - organized.append(article) - } - } - - return organized - } - - @MainActor func postNewsArticle(title: String, body: String, at path: [String], parentID: UInt32 = 0) async -> Bool { - - - return await withCheckedContinuation { [weak self] continuation in - guard let client = self?.client else { - continuation.resume(returning: false) - return - } - - client.postNewsArticle(title: title, text: body, path: path, parentID: parentID, callback: { success in - print("Hotline: News article posted? \(success)") - continuation.resume(returning: success) - }) - } - } - -// @MainActor func getNewsCategories(at path: [String] = []) async -> [NewsInfo] { -// return await withCheckedContinuation { [weak self] continuation in -// guard let client = self?.client else { -// continuation.resume(returning: []) -// return -// } -// -// client.sendGetNewsCategories(path: path) { [weak self] categories in -// let parentNews = self?.findNews(in: self?.news ?? [], at: path) -// -// var newCategories: [NewsInfo] = [] -// for category in categories { -// let categoryInfo: NewsInfo = NewsInfo(hotlineNewsCategory: category) -// newCategories.append(categoryInfo) -// self?.newsLookup[categoryInfo.path.joined(separator: "/")] = categoryInfo -// } -// -// DispatchQueue.main.async { -// if let parent = parentNews { -// parent.children = newCategories -// } -// else if path.isEmpty { -// self?.news = newCategories -// } -// -// continuation.resume(returning: newCategories) -// } -// } -// } -// } - - @MainActor func getArticles(at path: [String]) async -> [NewsInfo] { - return await withCheckedContinuation { [weak self] continuation in - self?.client.sendGetNewsArticles(path: path) { articles in - continuation.resume(returning: []) - } - } - } - - @MainActor func downloadFile(_ fileName: String, path: [String], complete callback: ((TransferInfo, URL) -> Void)? = nil) { - var fullPath: [String] = [] - if path.count > 1 { - fullPath = Array(path[0.. Void)? = nil, complete callback: ((TransferInfo, URL) -> Void)? = nil) { - var fullPath: [String] = [] - if path.count > 1 { - fullPath = Array(path[0.. Void)? = nil) { - var fullPath: [String] = [] - if path.count > 1 { - fullPath = Array(path[0.. Void)? = nil) { - let fileName = fileURL.lastPathComponent - - guard fileURL.isFileURL, !fileName.isEmpty else { - print("NOT A FILE URL?") - return - } - - let filePath = fileURL.path(percentEncoded: false) - - var fileIsDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: filePath, isDirectory: &fileIsDirectory), - fileIsDirectory.boolValue == false else { - print("FILE IS A DIRECTORY?") - return - } - - var fileSize: UInt = 0 - - // Data size - let fileAttributes = try? FileManager.default.attributesOfItem(atPath: filePath) - if let sizeAttribute = fileAttributes?[.size] as? NSNumber { - print("DATA SIZE \(sizeAttribute.uintValue)") - fileSize += sizeAttribute.uintValue - } - - // Resource size - let resourceURL = fileURL.appendingPathComponent("..namedfork/rsrc") - - - print("RESOURCE PATH \(resourceURL)") - let resourceAttributes = try? FileManager.default.attributesOfItem(atPath: resourceURL.path(percentEncoded: false)) - if let sizeAttribute = resourceAttributes?[.size] as? NSNumber { - print("RESOURCE SIZE \(sizeAttribute.uintValue)") - fileSize += sizeAttribute.uintValue - } - - print("FILE SIZE? \(fileSize)") - - guard fileSize > 0 else { - print("FILE IS EMPTY??") - return - } - - print("FILE SIZE: \(fileSize) NAME: \(fileName) PATH: \(path)") - - invalidateFileListCache(for: path, includingAncestors: true) - - self.client.sendUploadFile(name: fileName, path: path) { [weak self] success, uploadReferenceNumber in - print("UPLOAD REFERENCE: \(String(describing: uploadReferenceNumber))") - - if let self = self, - let address = self.server?.address, - let port = self.server?.port, - let referenceNumber = uploadReferenceNumber, - let fileClient = HotlineFileUploadClient(upload: fileURL, address: address, port: UInt16(port), reference: referenceNumber) { - - print("GOING TO UPLOAD") - - fileClient.delegate = self - self.downloads.append(fileClient) - - let transfer = TransferInfo(reference: referenceNumber, title: fileName, size: fileSize) - transfer.uploadCallback = callback - self.transfers.append(transfer) - - fileClient.start() - } - } - - } - - @MainActor func getFileDetails(_ fileName: String, path: [String]) async -> FileDetails? { - var fullPath: [String] = [] - if path.count > 1 { - fullPath = Array(path[0.. Bool { - var fullPath: [String] = [] - if path.count > 1 { - fullPath = Array(path[0.. Void)? = nil) { - var fullPath: [String] = [] - if path.count > 1 { - fullPath = Array(path[0..= 150 else { - return - } - - if self.bannerClient != nil || force { - self.bannerClient?.delegate = nil - self.bannerClient?.cancel() - self.bannerClient = nil - - if force { - self.bannerImage = nil - } - } - - if self.bannerImage != nil { - return - } - - self.client.sendDownloadBanner { [weak self] success, downloadReferenceNumber, downloadTransferSize in - if !success { - return - } - - if - let self = self, - let address = self.server?.address, - let port = self.server?.port, - let referenceNumber = downloadReferenceNumber, - let transferSize = downloadTransferSize { - self.bannerClient = HotlineFilePreviewClient(address: address, port: UInt16(port), reference: referenceNumber, size: UInt32(transferSize)) - self.bannerClient?.delegate = self - self.bannerClient?.start() - } - } - } - - // MARK: - Hotline Delegate - - @MainActor func hotlineStatusChanged(status: HotlineClientStatus) { - print("Hotline: Connection status changed to: \(status)") - let previousStatus = self.status - let previousTitle = self.serverTitle - - if status == .disconnected { - if previousStatus == .loggedIn { - let message = ChatMessage(text: "Disconnected", type: .signOut, date: Date()) - self.recordChatMessage(message, persist: true, display: false) - } - - self.serverVersion = 123 - self.serverName = nil - self.access = nil - - self.fileSearchSession?.cancel() - self.fileSearchSession = nil - self.resetFileSearchState() - - self.users = [] - self.chat = [] - self.messageBoard = [] - self.messageBoardLoaded = false - self.files = [] - self.filesLoaded = false - self.news = [] - self.newsLoaded = false - - self.bannerImage = nil - if let b = self.bannerClient { - b.cancel() - self.bannerClient = nil - } - - self.deleteAllTransfers() - - self.chatSessionKey = nil - self.restoredChatSessionKey = nil - self.lastPersistedMessageType = nil - } - else if status == .loggedIn { - if Prefs.shared.playSounds && Prefs.shared.playLoggedInSound { - SoundEffectPlayer.shared.playSoundEffect(.loggedIn) - } - } - - self.status = status - } - - func hotlineGetUserInfo() -> (String, UInt16) { - return (self.username, UInt16(self.iconID)) - } - - func hotlineReceivedAgreement(text: String) { - let message = ChatMessage(text: text, type: .agreement, date: Date()) - self.recordChatMessage(message, persist: false) - } - - func hotlineReceivedNewsPost(message: String) { - let messageBoardRegex = /([\s\r\n]*[_\-]+[\s\r\n]+)/ - let matches = message.matches(of: messageBoardRegex) - - if matches.count == 1 { - let range = matches[0].range - self.messageBoard.insert(String(message[message.startIndex.. ChatStore.SessionKey { - ChatStore.SessionKey(address: server.address.lowercased(), port: server.port) - } - - private func recordChatMessage(_ message: ChatMessage, persist: Bool = true, display: Bool = true) { - let shouldPersist = persist && message.type != .agreement - if shouldPersist, - message.type == .signOut, - lastPersistedMessageType == .signOut { - return - } - - if display { - self.chat.append(message) - } - - guard shouldPersist, let key = chatSessionKey else { return } - self.lastPersistedMessageType = message.type - let entry = ChatStore.Entry( - id: message.id, - body: message.text, - username: message.username, - type: message.type.storageKey, - date: message.date - ) - let serverName = self.serverName ?? self.server?.name - - Task { - await ChatStore.shared.append(entry: entry, for: key, serverName: serverName) - } - } - - private func restoreChatHistory(for key: ChatStore.SessionKey) { - if restoredChatSessionKey == key { - return - } - - Task { [weak self] in - guard let self else { return } - let result = await ChatStore.shared.loadHistory(for: key) - await MainActor.run { - guard self.chatSessionKey == key, self.restoredChatSessionKey != key else { return } - let currentMessages = self.chat - let historyMessages = result.entries.compactMap { entry -> ChatMessage? in - guard let chatType = ChatMessageType(storageKey: entry.type) else { return nil } - let renderedText: String - if chatType == .message, let username = entry.username, !username.isEmpty { - renderedText = "\(username): \(entry.body)" - } - else { - renderedText = entry.body - } - var message = ChatMessage(text: renderedText, type: chatType, date: entry.date) - message.metadata = entry.metadata - return message - } - self.chat = historyMessages + currentMessages - self.lastPersistedMessageType = historyMessages.last?.type - self.unreadPublicChat = false - self.restoredChatSessionKey = key - } - } - } - - private func handleChatHistoryCleared() { - self.chat = [] - self.unreadPublicChat = false - self.restoredChatSessionKey = nil - self.lastPersistedMessageType = nil - } - - @MainActor func searchChat(query: String) -> [ChatMessage] { - guard !query.isEmpty else { - return [] - } - - // Create a map of all messages by ID to deduplicate (current chat includes restored history) - var messageMap: [UUID: ChatMessage] = [:] - - // Add current in-memory messages (includes both restored history and new messages) - for message in self.chat { - messageMap[message.id] = message - } - - // Filter messages based on query - let filteredMessages = messageMap.values.filter { message in - // Never include agreement messages - if message.type == .agreement { - return false - } - - // Always include disconnect messages to show session boundaries - let isDisconnect = message.type == .signOut - - // Search in text and username - let matchesText = message.text.localizedCaseInsensitiveContains(query) - let matchesUsername = message.username?.localizedCaseInsensitiveContains(query) == true - let matchesQuery = matchesText || matchesUsername - - return isDisconnect || matchesQuery - } - - // Sort by date to maintain chronological order - let sortedMessages = filteredMessages.sorted { $0.date < $1.date } - - // Remove consecutive disconnect messages to avoid visual clutter - var deduplicated: [ChatMessage] = [] - var lastWasDisconnect = false - - for message in sortedMessages { - let isDisconnect = message.type == .signOut - - if isDisconnect && lastWasDisconnect { - // Skip consecutive disconnect messages - continue - } - - deduplicated.append(message) - lastWasDisconnect = isDisconnect - } - - // Remove leading disconnect message - if deduplicated.first?.type == .signOut { - deduplicated.removeFirst() - } - - // Remove trailing disconnect message - if deduplicated.last?.type == .signOut { - deduplicated.removeLast() - } - - return deduplicated - } - - func updateServerTitle() { - self.serverTitle = self.serverName ?? self.server?.name ?? server?.address ?? "Server" - } - - private func addOrUpdateHotlineUser(_ user: HotlineUser) { - print("Hotline: users: \n\(self.users)") - if let i = self.users.firstIndex(where: { $0.id == user.id }) { - print("Hotline: updating user \(self.users[i].name)") - self.users[i] = User(hotlineUser: user) - } - else { - if !self.users.isEmpty { - if Prefs.shared.playSounds && Prefs.shared.playJoinSound { - SoundEffectPlayer.shared.playSoundEffect(.userLogin) - } - } - - print("Hotline: added user: \(user.name)") - self.users.append(User(hotlineUser: user)) - if Prefs.shared.showJoinLeaveMessages { - let chatMessage = ChatMessage(text: "\(user.name) joined", type: .joined, date: Date()) - self.recordChatMessage(chatMessage) - } - } - } - - private func findFile(in filesToSearch: [FileInfo], at path: [String]) -> FileInfo? { - guard !path.isEmpty, !filesToSearch.isEmpty else { return nil } - - let currentName = path[0] - - for file in filesToSearch { - if file.name == currentName { - if path.count == 1 { - return file - } - else if let subfiles = file.children { - let remainingPath = Array(path[1...]) - return self.findFile(in: subfiles, at: remainingPath) - } - } - } - - return nil - } - - private func findNews(in newsToSearch: [NewsInfo], at path: [String]) -> NewsInfo? { - guard !path.isEmpty, !newsToSearch.isEmpty, let currentName = path.first else { return nil } - - for news in newsToSearch { - if news.name == currentName { - if path.count == 1 { - return news - } - else if !news.children.isEmpty { - let remainingPath = Array(path[1...]) - return self.findNews(in: news.children, at: remainingPath) - } - } - } - - return nil - } - - private func findNewsArticle(id articleID: UInt32, at path: [String]) -> NewsInfo? { - guard let parent = self.findNews(in: self.news, at: path), !parent.children.isEmpty else { - return nil - } - - return parent.children.first { child in - guard let childArticleID = child.articleID else { - return false - } - - return child.type == .article && child.articleID == childArticleID - } - } -} - -@MainActor -final class FileSearchSession { - private struct FolderTask { - let path: [String] - let depth: Int - let isHot: Bool - } - - private weak var hotline: Hotline? - private let queryTokens: [String] - private let config: FileSearchConfig - - private var queue: [FolderTask] = [] - private var visited: Set = [] - private var loopHistogram: [String: Int] = [:] - - private var processedCount: Int = 0 - private var currentDelay: TimeInterval - private var isCancelled = false - - init(hotline: Hotline, query: String, config: FileSearchConfig) { - self.hotline = hotline - self.queryTokens = query.lowercased().split(separator: " ").map(String.init) - self.config = config - self.currentDelay = config.initialDelay - } - - func start() async { - guard let hotline else { - return - } - - await Task.yield() - - if !hotline.filesLoaded { - hotline.searchSession(self, didFocusOn: []) - let rootFiles = await hotline.getFileList(path: [], suppressErrors: true, preferCache: true) - processedCount = max(processedCount, 1) - processListing(rootFiles, depth: 0, parentPath: [], parentIsHot: false) - } - else { - hotline.searchSession(self, didFocusOn: []) - processedCount = max(processedCount, 1) - processListing(hotline.files, depth: 0, parentPath: [], parentIsHot: false) - } - - while !queue.isEmpty && !isCancelled { - await Task.yield() - - guard let task = dequeueNextTask() else { - continue - } - if shouldSkip(path: task.path, depth: task.depth) { - hotline.searchSession(self, didEmit: [], processed: processedCount, pending: queue.count) - continue - } - - hotline.searchSession(self, didFocusOn: task.path) - visited.insert(pathKey(for: task.path)) - - if let cached = hotline.cachedListingForSearch(path: task.path, ttl: config.cacheTTL) { - if cached.isFresh { - processedCount += 1 - processListing(cached.items, depth: task.depth, parentPath: task.path, parentIsHot: task.isHot) - continue - } else { - processListing(cached.items, depth: task.depth, parentPath: task.path, parentIsHot: task.isHot) - } - } - - let children = await hotline.getFileList(path: task.path, suppressErrors: true) - processedCount += 1 - - if isCancelled { - break - } - - processListing(children, depth: task.depth, parentPath: task.path, parentIsHot: task.isHot) - - await applyBackoff() - } - - hotline.searchSessionDidFinish(self, processed: processedCount, pending: queue.count, completed: !isCancelled) - } - - func cancel() { - isCancelled = true - } - - private func processListing(_ items: [FileInfo], depth: Int, parentPath: [String], parentIsHot: Bool) { - guard let hotline else { - return - } - - var matches: [FileInfo] = [] - var folderEntries: [(file: FileInfo, isHot: Bool)] = [] - var hasFileMatch = false - - for file in items { - let matchesName = nameMatchesQuery(file.name) - - if matchesName { - matches.append(file) - if !file.isFolder { - hasFileMatch = true - } - } - - if file.isFolder && !file.isAppBundle { - folderEntries.append((file, matchesName)) - } - } - - var remainingBurst = 0 - if config.hotBurstLimit > 0 && (parentIsHot || hasFileMatch) { - remainingBurst = config.hotBurstLimit - } - - if remainingBurst > 0 { - var candidateIndices: [Int] = [] - for index in folderEntries.indices where !folderEntries[index].isHot { - candidateIndices.append(index) - } - - if !candidateIndices.isEmpty { - candidateIndices.shuffle() - for index in candidateIndices { - folderEntries[index].isHot = true - remainingBurst -= 1 - if remainingBurst == 0 { - break - } - } - } - } - - for entry in folderEntries { - enqueueFolder(entry.file, depth: depth + 1, markHot: entry.isHot) - } - - hotline.searchSession(self, didEmit: matches, processed: processedCount, pending: queue.count) - } - - private func enqueueFolder(_ folder: FileInfo, depth: Int, markHot: Bool) { - guard !isCancelled else { return } - guard depth <= config.maxDepth else { return } - - let path = folder.path - let key = pathKey(for: path) - guard !visited.contains(key) else { return } - - if exceedsLoopThreshold(for: path) { - return - } - - queue.append(FolderTask(path: path, depth: depth, isHot: markHot)) - } - - private func dequeueNextTask() -> FolderTask? { - guard !queue.isEmpty else { - return nil - } - - if queue.count == 1 { - return queue.removeFirst() - } - - let currentDepth = queue[0].depth - var lastSameDepthIndex = 0 - var hotIndices: [Int] = [] - - for index in 0.. Bool { - if isCancelled { - return true - } - - if depth > config.maxDepth { - return true - } - - let key = pathKey(for: path) - if visited.contains(key) { - return true - } - - return false - } - - private func nameMatchesQuery(_ name: String) -> Bool { - guard !queryTokens.isEmpty else { return false } - let lowercased = name.lowercased() - return queryTokens.allSatisfy { lowercased.contains($0) } - } - - private func exceedsLoopThreshold(for path: [String]) -> Bool { - guard config.loopRepetitionLimit > 0 else { return false } - guard let last = path.last else { return false } - let parent = path.dropLast() - - guard let previousIndex = parent.lastIndex(of: last) else { - return false - } - - let suffix = Array(path[previousIndex...]) - let key = suffix.joined(separator: "\u{001F}") - let count = (loopHistogram[key] ?? 0) + 1 - loopHistogram[key] = count - return count > config.loopRepetitionLimit - } - - private func pathKey(for path: [String]) -> String { - path.joined(separator: "\u{001F}") - } - - private func applyBackoff() async { - guard !isCancelled else { return } - - if processedCount > config.initialBurstCount { - currentDelay = min(config.maxDelay, max(config.initialDelay, currentDelay * config.backoffMultiplier)) - } - - guard currentDelay > 0 else { - return - } - - let nanoseconds = UInt64(currentDelay * 1_000_000_000) - try? await Task.sleep(nanoseconds: nanoseconds) - } -} diff --git a/Hotline/Models/HotlineState.swift b/Hotline/Models/HotlineState.swift deleted file mode 100644 index d5ab438..0000000 --- a/Hotline/Models/HotlineState.swift +++ /dev/null @@ -1,2468 +0,0 @@ -import SwiftUI - -// MARK: - Connection Status - -enum HotlineConnectionStatus: Equatable { - case disconnected - case connecting - case connected - case loggedIn - case failed(String) - - var isConnected: Bool { - return self == .connected || self == .loggedIn - } -} - -struct FileSearchConfig: Equatable { - /// Number of folders we process before we start applying delay backoff. - var initialBurstCount: Int = 15 - /// Base delay applied between folder requests during the backoff phase. - var initialDelay: TimeInterval = 0.02 - /// Multiplier used to increase the delay after each processed folder in backoff. - var backoffMultiplier: Double = 1.1 - /// Maximum delay cap so searches don't stall out during long walks. - var maxDelay: TimeInterval = 1.0 - /// Maximum recursion depth allowed during file search. - var maxDepth: Int = 40 - /// Limit for repeated folder loops (guards against circular server listings). - var loopRepetitionLimit: Int = 4 - /// Number of child folders that get prioritized after a matching parent is found. - var hotBurstLimit: Int = 2 - /// Maximum age, in seconds, that a cached folder listing is treated as fresh. - var cacheTTL: TimeInterval = 60 * 15 - /// Upper bound on the number of folder listings retained in the cache. - var maxCachedFolders: Int = 1024 * 3 -} - -enum FileSearchStatus: Equatable { - case idle - case searching(processed: Int, pending: Int) - case completed(processed: Int) - case cancelled(processed: Int) - case failed(String) - - var isActive: Bool { - if case .searching = self { - return true - } - return false - } -} - -// MARK: - HotlineState - -@Observable @MainActor -class HotlineState: Equatable { - let id: UUID = UUID() - - nonisolated static func == (lhs: HotlineState, rhs: HotlineState) -> Bool { - return lhs.id == rhs.id - } - - // MARK: - Static Icon Data - - #if os(macOS) - static func getClassicIcon(_ index: Int) -> NSImage? { - return NSImage(named: "Classic/\(index)") - } - #elseif os(iOS) - static func getClassicIcon(_ index: Int) -> UIImage? { - return UIImage(named: "Classic/\(index)") - } - #endif - - static let classicIconSet: [Int] = [ - 141, 149, 150, 151, 172, 184, 204, - 2013, 2036, 2037, 2055, 2400, 2505, 2534, - 2578, 2592, 4004, 4015, 4022, 4104, 4131, - 4134, 4136, 4169, 4183, 4197, 4240, 4247, - 128, 129, 130, 131, 132, 133, 134, - 135, 136, 137, 138, 139, 140, 142, - 143, 144, 145, 146, 147, 148, 152, - 153, 154, 155, 156, 157, 158, 159, - 160, 161, 162, 163, 164, 165, 166, - 167, 168, 169, 170, 171, 173, 174, - 175, 176, 177, 178, 179, 180, 181, - 182, 183, 185, 186, 187, 188, 189, - 190, 191, 192, 193, 194, 195, 196, - 197, 198, 199, 200, 201, 202, 203, - 205, 206, 207, 208, 209, 212, 214, - 215, 220, 233, 236, 237, 243, 244, - 277, 410, 414, 500, 666, 1250, 1251, - 1968, 1969, 2000, 2001, 2002, 2003, 2004, - 2006, 2007, 2008, 2009, 2010, 2011, 2012, - 2014, 2015, 2016, 2017, 2018, 2019, 2020, - 2021, 2022, 2023, 2024, 2025, 2026, 2027, - 2028, 2029, 2030, 2031, 2032, 2033, 2034, - 2035, 2038, 2040, 2041, 2042, 2043, 2044, - 2045, 2046, 2047, 2048, 2049, 2050, 2051, - 2052, 2053, 2054, 2056, 2057, 2058, 2059, - 2060, 2061, 2062, 2063, 2064, 2065, 2066, - 2067, 2070, 2071, 2072, 2073, 2075, 2079, - 2098, 2100, 2101, 2102, 2103, 2104, 2105, - 2106, 2107, 2108, 2109, 2110, 2112, 2113, - 2115, 2116, 2117, 2118, 2119, 2120, 2121, - 2122, 2123, 2124, 2125, 2126, 4150, 2223, - 2401, 2402, 2403, 2404, 2500, 2501, 2502, - 2503, 2504, 2506, 2507, 2528, 2529, 2530, - 2531, 2532, 2533, 2535, 2536, 2537, 2538, - 2539, 2540, 2541, 2542, 2543, 2544, 2545, - 2546, 2547, 2548, 2549, 2550, 2551, 2552, - 2553, 2554, 2555, 2556, 2557, 2558, 2559, - 2560, 2561, 2562, 2563, 2564, 2565, 2566, - 2567, 2568, 2569, 2570, 2571, 2572, 2573, - 2574, 2575, 2576, 2577, 2579, 2580, 2581, - 2582, 2583, 2584, 2585, 2586, 2587, 2588, - 2589, 2590, 2591, 2593, 2594, 2595, 2596, - 2597, 2598, 2599, 2600, 4000, 4001, 4002, - 4003, 4005, 4006, 4007, 4008, 4009, 4010, - 4011, 4012, 4013, 4014, 4016, 4017, 4018, - 4019, 4020, 4021, 4023, 4024, 4025, 4026, - 4027, 4028, 4029, 4030, 4031, 4032, 4033, - 4034, 4035, 4036, 4037, 4038, 4039, 4040, - 4041, 4042, 4043, 4044, 4045, 4046, 4047, - 4048, 4049, 4050, 4051, 4052, 4053, 4054, - 4055, 4056, 4057, 4058, 4059, 4060, 4061, - 4062, 4063, 4064, 4065, 4066, 4067, 4068, - 4069, 4070, 4071, 4072, 4073, 4074, 4075, - 4076, 4077, 4078, 4079, 4080, 4081, 4082, - 4083, 4084, 4085, 4086, 4087, 4088, 4089, - 4090, 4091, 4092, 4093, 4094, 4095, 4096, - 4097, 4098, 4099, 4100, 4101, 4102, 4103, - 4105, 4106, 4107, 4108, 4109, 4110, 4111, - 4112, 4113, 4114, 4115, 4116, 4117, 4118, - 4119, 4120, 4121, 4122, 4123, 4124, 4125, - 4126, 4127, 4128, 4129, 4130, 4132, 4133, - 4135, 4137, 4138, 4139, 4140, 4141, 4142, - 4143, 4144, 4145, 4146, 4147, 4148, 4149, - 4151, 4152, 4153, 4154, 4155, 4156, 4157, - 4158, 4159, 4160, 4161, 4162, 4163, 4164, - 4165, 4166, 4167, 4168, 4170, 4171, 4172, - 4173, 4174, 4175, 4176, 4177, 4178, 4179, - 4180, 4181, 4182, 4184, 4185, 4186, 4187, - 4188, 4189, 4190, 4191, 4192, 4193, 4194, - 4195, 4196, 4198, 4199, 4200, 4201, 4202, - 4203, 4204, 4205, 4206, 4207, 4208, 4209, - 4210, 4211, 4212, 4213, 4214, 4215, 4216, - 4217, 4218, 4219, 4220, 4221, 4222, 4223, - 4224, 4225, 4226, 4227, 4228, 4229, 4230, - 4231, 4232, 4233, 4234, 4235, 4236, 4238, - 4241, 4242, 4243, 4244, 4245, 4246, 4248, - 4249, 4250, 4251, 4252, 4253, 4254, 31337, - 6001, 6002, 6003, 6004, 6005, 6008, 6009, - 6010, 6011, 6012, 6013, 6014, 6015, 6016, - 6017, 6018, 6023, 6025, 6026, 6027, 6028, - 6029, 6030, 6031, 6032, 6033, 6034, 6035 - ] - - // MARK: - Observable State - - var status: HotlineConnectionStatus = .disconnected - var server: Server? { - didSet { - self.updateServerTitle() - } - } - var serverVersion: UInt16 = 123 - var serverName: String? { - didSet { - self.updateServerTitle() - } - } - var serverTitle: String = "Server" - var username: String = "guest" - var iconID: Int = 414 - var access: HotlineUserAccessOptions? - var agreed: Bool = false - - // Users - var users: [User] = [] - - // Chat - var chat: [ChatMessage] = [] - var chatInput: String = "" - var unreadPublicChat: Bool = false - - // Instant Messages - var instantMessages: [UInt16:[InstantMessage]] = [:] - var unreadInstantMessages: [UInt16:UInt16] = [:] - - // Message Board - var messageBoard: [String] = [] - var messageBoardLoaded: Bool = false - - // News - var news: [NewsInfo] = [] - var newsLoaded: Bool = false - private var newsLookup: [String:NewsInfo] = [:] - - // Files - var files: [FileInfo] = [] - var filesLoaded: Bool = false - - // Accounts - var accounts: [HotlineAccount] = [] - var accountsLoaded: Bool = false - - // Banner - #if os(macOS) - var bannerImage: Image? = nil - var bannerColors: ColorArt? = nil - #elseif os(iOS) - var bannerImage: UIImage? = nil - #endif - - // Transfers (now stored globally in AppState) - /// Returns all transfers associated with this server - var transfers: [TransferInfo] { - AppState.shared.transfers.filter { $0.serverID == self.id } - } - - // Legacy transfer tracking (for old delegate-based downloads) -// @ObservationIgnored private var downloads: [HotlineTransferClient] = [] - @ObservationIgnored private var bannerDownloadTask: Task? = nil - - // File Search - var fileSearchResults: [FileInfo] = [] - var fileSearchStatus: FileSearchStatus = .idle - var fileSearchQuery: String = "" - var fileSearchConfig = FileSearchConfig() - var fileSearchScannedFolders: Int = 0 - var fileSearchCurrentPath: [String]? = nil - @ObservationIgnored private var fileSearchSession: HotlineStateFileSearchSession? = nil - @ObservationIgnored private var fileSearchResultKeys: Set = [] - - // File List Cache - private struct FileListCacheEntry { - let files: [FileInfo] - let timestamp: Date - } - @ObservationIgnored private var fileListCache: [String: FileListCacheEntry] = [:] - - // Error Display - var errorDisplayed: Bool = false - var errorMessage: String? = nil - - // MARK: - Private State - - @ObservationIgnored private var client: HotlineClientNew? - @ObservationIgnored private var eventTask: Task? - @ObservationIgnored private var chatSessionKey: ChatStore.SessionKey? - @ObservationIgnored private var restoredChatSessionKey: ChatStore.SessionKey? - @ObservationIgnored private var chatHistoryObserver: NSObjectProtocol? - @ObservationIgnored private var lastPersistedMessageType: ChatMessageType? - - // MARK: - Initialization - - init() { - self.chatHistoryObserver = NotificationCenter.default.addObserver( - forName: ChatStore.historyClearedNotification, - object: nil, - queue: .main - ) { @MainActor [weak self] _ in - self?.handleChatHistoryCleared() - } - } - - deinit { - if let observer = self.chatHistoryObserver { - NotificationCenter.default.removeObserver(observer) - } - } - - // MARK: - Connection - - @MainActor - func login(server: Server, username: String, iconID: Int) async throws { - print("HotlineState.login(): Starting login to \(server.address):\(server.port)") - self.server = server - self.username = username - self.iconID = iconID - self.status = .connecting - print("HotlineState.login(): Status set to connecting") - - // Set up chat session - let key = self.sessionKey(for: server) - self.chatSessionKey = key - self.restoredChatSessionKey = nil - self.lastPersistedMessageType = nil - self.chat = [] - self.restoreChatHistory(for: key) - print("HotlineState.login(): Chat session set up") - - do { - // Connect and login - let loginInfo = HotlineLoginInfo( - login: server.login, - password: server.password, - username: username, - iconID: UInt16(iconID) - ) - - print("HotlineState.login(): Calling HotlineClientNew.connect()...") - let client = try await HotlineClientNew.connect( - host: server.address, - port: UInt16(server.port), - login: loginInfo - ) - print("HotlineState.login(): HotlineClientNew.connect() returned") - - self.client = client - print("HotlineState.login(): Client stored") - - // Get server info - print("HotlineState.login(): Getting server info...") - if let serverInfo = await client.server { - self.serverVersion = serverInfo.version - if !serverInfo.name.isEmpty { - self.serverName = serverInfo.name - } - print("HotlineState.login(): Server info retrieved: \(serverInfo.name) v\(serverInfo.version)") - } - - self.status = .connected - print("HotlineState.login(): Status set to connected") - - // Request initial data before starting event loop - print("HotlineState.login(): Requesting user list...") - try await self.getUserList() - - self.status = .loggedIn - print("HotlineState.login(): Status set to loggedIn") - - if Prefs.shared.playSounds && Prefs.shared.playLoggedInSound { - SoundEffectPlayer.shared.playSoundEffect(.loggedIn) - } - - print("HotlineState.login(): Connected to \(self.serverTitle)") - print("HotlineState.login(): Scheduling post-login tasks...") - - // Defer event loop and post-login work to avoid layout recursion - // This allows login() to return and SwiftUI to complete its layout pass - // before we start receiving events that trigger state changes - Task { @MainActor in - print("HotlineState: Post-login: Starting event loop...") - self.startEventLoop() - - print("HotlineState: Post-login: Sending preferences...") - try? await self.sendUserPreferences() - - print("HotlineState: Post-login: Downloading banner...") - self.downloadBanner() - } - - } catch { - print("HotlineState.login(): Login failed with error: \(error)") - if let client = self.client { - await client.disconnect() - self.client = nil - } - self.status = .failed(error.localizedDescription) - self.errorDisplayed = true - self.errorMessage = error.localizedDescription - throw error - } - } - - /// Disconnect from the server (user-initiated) - @MainActor - func disconnect() async { - print("HotlineState.disconnect(): Called") - guard let client = self.client else { - print("HotlineState.disconnect(): No client, returning") - return - } - - // Stop event loop - print("HotlineState.disconnect(): Cancelling event task...") - self.eventTask?.cancel() - self.eventTask = nil - print("HotlineState.disconnect(): Event task cancelled") - - // Explicitly close the connection - print("HotlineState.disconnect(): Calling client.disconnect()...") - await client.disconnect() - print("HotlineState.disconnect(): client.disconnect() returned") - - // Clean up state - print("HotlineState.disconnect(): Calling handleConnectionClosed()...") - self.handleConnectionClosed() - print("HotlineState.disconnect(): disconnect() complete") - } - - /// Handle connection closure (server-initiated or after user disconnect) - @MainActor - private func handleConnectionClosed() { - print("HotlineState: handleConnectionClosed() entered") - guard self.client != nil else { - print("HotlineState: handleConnectionClosed() - client already nil, returning") - return - } - - print("HotlineState: Handling connection closure - recording chat...") - - // Record disconnect in chat history - if self.status == .loggedIn { - let message = ChatMessage(text: "Disconnected", type: .signOut, date: Date()) - self.recordChatMessage(message, persist: true, display: false) - } - - print("HotlineState: Cancelling banner and downloads...") - - self.bannerDownloadTask?.cancel() - self.bannerDownloadTask = nil - - // Cancel all downloads (both old delegate-based and new async downloads) -// self.downloads = [] - - // Cancel all transfers for this server -// self.cancelAllDownloads() - - // Cancel file search - self.fileSearchSession?.cancel() - self.fileSearchSession = nil - - // Clear client reference - self.client = nil - - print("HotlineState: Resetting state properties...") - - // Reset state immediately (constraint loop was caused by something else) - self.status = .disconnected - self.serverVersion = 123 - self.serverName = nil - self.access = nil - self.agreed = false - self.users = [] - self.chat = [] - self.instantMessages = [:] - self.unreadInstantMessages = [:] - self.unreadPublicChat = false - self.messageBoard = [] - self.messageBoardLoaded = false - self.news = [] - self.newsLoaded = false - self.newsLookup = [:] - self.files = [] - self.filesLoaded = false - self.accounts = [] - self.accountsLoaded = false - self.bannerImage = nil - self.bannerColors = nil - - print("HotlineState: Resetting file search...") - self.resetFileSearchState() - - self.chatSessionKey = nil - self.restoredChatSessionKey = nil - self.lastPersistedMessageType = nil - - print("HotlineState: Disconnected") - } - - @MainActor - func downloadBanner(force: Bool = false) { - guard self.serverVersion >= 150 else { - return - } - - if force { - self.bannerDownloadTask?.cancel() - self.bannerDownloadTask = nil - self.bannerImage = nil - self.bannerColors = nil - } else if self.bannerDownloadTask != nil || self.bannerImage != nil { - return - } - - let task = Task { @MainActor [weak self] in - defer { - self?.bannerDownloadTask = nil - } - - guard let self else { return } - guard let client = self.client, - let server = self.server, - let result = try? await client.downloadBanner(), - let address = server.address as String?, - let port = server.port as Int? - else { - return - } - - do { - print("HotlineState: Banner download info - reference: \(result.referenceNumber), transferSize: \(result.transferSize)") - - let previewClient = HotlineFilePreviewClientNew( - fileName: "banner", - address: address, - port: UInt16(port), - reference: result.referenceNumber, - size: UInt32(result.transferSize) - ) - - let fileURL = try await previewClient.preview() - defer { - previewClient.cleanup() - } - - guard self.client != nil else { return } - - let data = try Data(contentsOf: fileURL) - print("HotlineState: Banner download complete, data size: \(data.count) bytes") - -#if os(macOS) - guard let image = NSImage(data: data) else { - print("HotlineState: Failed to create NSImage from banner data") - return - } - let blah = Image(nsImage: image) -#elseif os(iOS) - guard let image = UIImage(data: data) else { - print("HotlineState: Failed to create UIImage from banner data") - return - } - self.bannerImage = Image(uiImage: image) -#endif - self.bannerImage = blah - self.bannerColors = ColorArt.analyze(image: image) - - } catch { - print("HotlineState: Banner download failed: \(error)") - } - } - - self.bannerDownloadTask = task - } - - // MARK: - Event Loop - - private func startEventLoop() { - print("HotlineState.startEventLoop(): Called") - guard let client = self.client else { - print("HotlineState.startEventLoop(): No client, returning") - return - } - - print("HotlineState.startEventLoop(): Creating event loop task") - self.eventTask = Task { @MainActor [weak self, client] in - guard let self else { - print("HotlineState.startEventLoop(): Self is nil in task, exiting") - return - } - - print("HotlineState.startEventLoop(): Event loop started, awaiting events...") - for await event in client.events { - print("HotlineState.startEventLoop(): Received event: \(event)") - self.handleEvent(event) - } - - // Event stream ended - server disconnected us - print("HotlineState.startEventLoop(): Event stream ended, calling handleConnectionClosed()...") - self.handleConnectionClosed() - print("HotlineState.startEventLoop(): handleConnectionClosed() returned, event loop task complete") - } - print("HotlineState.startEventLoop(): Event loop task created") - } - - @MainActor - private func handleEvent(_ event: HotlineEvent) { - switch event { - case .chatMessage(let text): - self.handleChatMessage(text) - - case .userChanged(let user): - self.handleUserChanged(user) - - case .userDisconnected(let userID): - self.handleUserDisconnected(userID) - - case .serverMessage(let message): - self.handleServerMessage(message) - - case .privateMessage(let userID, let message): - self.handlePrivateMessage(userID: userID, message: message) - - case .newsPost(let message): - self.handleNewsPost(message) - - case .agreementRequired(let text): - let message = ChatMessage(text: text, type: .agreement, date: Date()) - self.recordChatMessage(message, persist: false) - - case .userAccess(let options): - self.access = options - print("HotlineState: Got access options") - HotlineUserAccessOptions.printAccessOptions(options) - } - } - - // MARK: - Chat - - @MainActor - func sendChat(_ text: String, announce: Bool = false) async throws { - guard let client = self.client else { - throw HotlineClientError.notConnected - } - - try await client.sendChat(text, announce: announce) - } - - @MainActor - func sendInstantMessage(_ text: String, userID: UInt16) async throws { - guard let client = self.client else { - throw HotlineClientError.notConnected - } - - let message = InstantMessage( - direction: .outgoing, - text: text.convertingLinksToMarkdown(), - type: .message, - date: Date() - ) - - if self.instantMessages[userID] == nil { - self.instantMessages[userID] = [message] - } else { - self.instantMessages[userID]!.append(message) - } - - try await client.sendInstantMessage(text, to: userID) - - if Prefs.shared.playPrivateMessageSound { - SoundEffectPlayer.shared.playSoundEffect(.chatMessage) - } - } - - @MainActor - func sendAgree() async throws { - guard let client = self.client else { - throw HotlineClientError.notConnected - } - - try await client.sendAgree() - self.agreed = true - } - - @MainActor - /// Send current user preferences from Prefs to the server - func sendUserPreferences() async throws { - var options: HotlineUserOptions = [] - - if Prefs.shared.refusePrivateMessages { - options.update(with: .refusePrivateMessages) - } - - if Prefs.shared.refusePrivateChat { - options.update(with: .refusePrivateChat) - } - - if Prefs.shared.enableAutomaticMessage { - options.update(with: .automaticResponse) - } - - print("HotlineState.sendUserPreferences(): Updating user info with server") - - try await self.sendUserInfo( - username: Prefs.shared.username, - iconID: Prefs.shared.userIconID, - options: options, - autoresponse: Prefs.shared.automaticMessage - ) - } - - func sendUserInfo(username: String, iconID: Int, options: HotlineUserOptions = [], autoresponse: String? = nil) async throws { - guard let client = self.client else { - throw HotlineClientError.notConnected - } - - self.username = username - self.iconID = iconID - - try await client.setClientUserInfo( - username: username, - iconID: UInt16(iconID), - options: options, - autoresponse: autoresponse - ) - } - - func markPublicChatAsRead() { - self.unreadPublicChat = false - } - - func hasUnreadInstantMessages(userID: UInt16) -> Bool { - return self.unreadInstantMessages[userID] != nil - } - - func markInstantMessagesAsRead(userID: UInt16) { - self.unreadInstantMessages.removeValue(forKey: userID) - } - - @MainActor - func searchChat(query: String) -> [ChatMessage] { - guard !query.isEmpty else { - return [] - } - - // Create a map of all messages by ID to deduplicate - var messageMap: [UUID: ChatMessage] = [:] - - // Add current in-memory messages - for message in self.chat { - messageMap[message.id] = message - } - - // Filter messages based on query - let filteredMessages = messageMap.values.filter { message in - // Never include agreement messages - if message.type == .agreement { - return false - } - - // Always include disconnect messages to show session boundaries - let isDisconnect = message.type == .signOut - - // Search in text and username - let matchesText = message.text.localizedCaseInsensitiveContains(query) - let matchesUsername = message.username?.localizedCaseInsensitiveContains(query) == true - let matchesQuery = matchesText || matchesUsername - - return isDisconnect || matchesQuery - } - - // Sort by date to maintain chronological order - let sortedMessages = filteredMessages.sorted { $0.date < $1.date } - - // Remove consecutive disconnect messages to avoid visual clutter - var deduplicated: [ChatMessage] = [] - var lastWasDisconnect = false - - for message in sortedMessages { - let isDisconnect = message.type == .signOut - - if isDisconnect && lastWasDisconnect { - continue - } - - deduplicated.append(message) - lastWasDisconnect = isDisconnect - } - - // Remove leading disconnect message - if deduplicated.first?.type == .signOut { - deduplicated.removeFirst() - } - - // Remove trailing disconnect message - if deduplicated.last?.type == .signOut { - deduplicated.removeLast() - } - - return deduplicated - } - - // MARK: - Users - - @MainActor - func getUserList() async throws { - guard let client = self.client else { - throw HotlineClientError.notConnected - } - - let hotlineUsers = try await client.getUserList() - self.users = hotlineUsers.map { User(hotlineUser: $0) } - } - - // MARK: - Files (Basic) - - @MainActor - @discardableResult - func getFileList(path: [String] = [], suppressErrors: Bool = false, preferCache: Bool = false) async throws -> [FileInfo] { - guard let client = self.client else { - throw HotlineClientError.notConnected - } - - // Check cache first if preferred - if preferCache, let cached = self.cachedFileList(for: path, ttl: self.fileSearchConfig.cacheTTL, allowStale: false) { - return cached.items - } - - let hotlineFiles = try await client.getFileList(path: path) - let newFiles = hotlineFiles.map { FileInfo(hotlineFile: $0) } - - // Update UI state - if path.isEmpty { - self.filesLoaded = true - self.files = newFiles - } else { - // Update parent's children - let parentFile = self.findFile(in: self.files, at: path) - parentFile?.children = newFiles - } - - // Cache the result - self.storeFileListInCache(newFiles, for: path) - - return newFiles - } - - @MainActor - func getFileDetails(_ fileName: String, path: [String]) async throws -> FileDetails? { - guard let client = self.client else { - throw HotlineClientError.notConnected - } - - var fullPath: [String] = [] - if path.count > 1 { - fullPath = Array(path[0.. Bool { - guard let client = self.client else { - throw HotlineClientError.notConnected - } - - var fullPath: [String] = [] - if path.count > 1 { - fullPath = Array(path[0.. Void)? = nil, complete callback: ((TransferInfo, URL) -> Void)? = nil) { -// guard let client = self.client else { return } -// -// var fullPath: [String] = [] -// if path.count > 1 { -// fullPath = Array(path[0.. Void)? = nil, complete callback: ((TransferInfo) -> Void)? = nil) { - guard let client = self.client else { return } - - var fullPath: [String] = [] - if path.count > 1 { - fullPath = Array(path[0.. Void)? = nil) { -// guard let client = self.client else { return } -// -// var fullPath: [String] = [] -// if path.count > 1 { -// fullPath = Array(path[0.. Void)? = nil, - itemProgress itemProgressCallback: ((TransferInfo, String, Int, Int) -> Void)? = nil, - complete callback: ((TransferInfo) -> Void)? = nil - ) { - guard let client = self.client else { return } - - var fullPath: [String] = [] - if path.count > 1 { - fullPath = Array(path[0.. Void)? = nil, - itemProgress itemProgressCallback: ((TransferInfo, String, Int, Int) -> Void)? = nil, - complete callback: ((TransferInfo) -> Void)? = nil - ) { - guard let client = self.client else { return } - - let folderName = folderURL.lastPathComponent - - guard folderURL.isFileURL, !folderName.isEmpty else { - print("HotlineState: Not a valid folder URL") - return - } - - let folderPath = folderURL.path(percentEncoded: false) - - var isDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: folderPath, isDirectory: &isDirectory), - isDirectory.boolValue == true else { - print("HotlineState: URL is not a folder") - return - } - - // Get the total size of the folder (all files) - guard let (folderSize, fileCount) = FileManager.default.getFolderSize(folderURL) else { - print("HotlineState: Could not determine folder size") - return - } - - print("HotlineState: Requesting upload for folder '\(folderName)' - \(fileCount) items, \(folderSize) bytes total") - - Task { @MainActor [weak self] in - guard let self else { return } - - // Request folder upload from server. - // The enumerator already omits the root folder, so report the full item count the server should expect. - let reportedItemCount = fileCount - print("HotlineState: Reporting \(reportedItemCount) items to server (enumerated count)") - guard let referenceNumber = try? await client.uploadFolder(name: folderName, path: path, fileCount: reportedItemCount, totalSize: UInt32(folderSize)), - let server = self.server, - let address = server.address as String?, - let port = server.port as Int? - else { - print("HotlineState: Failed to get upload reference from server") - return - } - - // Invalidate cache for the upload destination - self.invalidateFileListCache(for: path, includingAncestors: true) - - print("HotlineState: Got folder upload reference: \(referenceNumber)") - - // Create upload client - guard let uploadClient = HotlineFolderUploadClientNew( - folderURL: folderURL, - address: address, - port: UInt16(port), - reference: referenceNumber - ) else { - print("HotlineState: Failed to create folder upload client") - return - } - - // Create transfer info for tracking (stored globally in AppState) - let transfer = TransferInfo( - reference: referenceNumber, - title: folderName, - size: UInt(folderSize), - serverID: self.id, - serverName: self.serverName ?? self.serverTitle - ) - transfer.isFolder = true - transfer.uploadCallback = callback - transfer.progressCallback = progressCallback - AppState.shared.addTransfer(transfer) - - // Create and store the upload task - let uploadTask = Task { @MainActor [weak self] in - guard self != nil else { return } - - do { - // Upload folder with progress tracking - try await uploadClient.upload(progress: { progress in - switch progress { - case .preparing: - break - case .unconnected, .connected, .connecting: - break - case .transfer(name: _, size: _, total: _, progress: let progress, speed: let speed, estimate: let estimate): - transfer.timeRemaining = estimate - transfer.speed = speed - transfer.progress = progress - transfer.progressCallback?(transfer) - case .error(_): - transfer.failed = true - case .completed(url: _): - transfer.completed = true - } - }, itemProgress: { itemInfo in - // Update transfer title with current file being uploaded - transfer.title = "\(itemInfo.fileName) (\(itemInfo.itemNumber)/\(itemInfo.totalItems))" - itemProgressCallback?(transfer, itemInfo.fileName, itemInfo.itemNumber, itemInfo.totalItems) - }) - - // Mark as completed - transfer.progress = 1.0 - transfer.title = folderName // Reset title to folder name - - // Call completion callback - transfer.uploadCallback?(transfer) - - print("HotlineState: Folder upload complete - \(folderName)") - - } catch is CancellationError { - // Upload was cancelled - print("HotlineState: Folder upload cancelled") - } catch { - // Mark as failed - transfer.failed = true - print("HotlineState: Folder upload failed - \(error)") - } - - AppState.shared.unregisterTransferTask(for: transfer.id) - } - - // Store the task in AppState so it can be cancelled later - AppState.shared.registerTransferTask(uploadTask, transferID: transfer.id) - } - } - - func uploadFile(url fileURL: URL, path: [String], complete callback: ((TransferInfo) -> Void)? = nil) { - guard let client = self.client else { return } - - let fileName = fileURL.lastPathComponent - - guard fileURL.isFileURL, !fileName.isEmpty else { - print("HotlineState: Not a valid file URL") - return - } - - let filePath = fileURL.path(percentEncoded: false) - - var fileIsDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: filePath, isDirectory: &fileIsDirectory), - fileIsDirectory.boolValue == false else { - print("HotlineState: File is a directory") - return - } - - // Get the flattened file size (includes all forks and headers) - guard let payloadSize = FileManager.default.getFlattenedFileSize(fileURL) else { - print("HotlineState: Could not determine file size") - return - } - - Task { @MainActor [weak self] in - guard let self else { return } - - // Request upload from server - guard let referenceNumber = try? await client.uploadFile(name: fileName, path: path), - let server = self.server, - let address = server.address as String?, - let port = server.port as Int? - else { - print("HotlineState: Failed to get upload reference from server") - return - } - - // Invalidate cache for the upload destination - self.invalidateFileListCache(for: path, includingAncestors: true) - - print("HotlineState: Got upload reference: \(referenceNumber)") - - // Create upload client - guard let uploadClient = HotlineFileUploadClientNew( - fileURL: fileURL, - address: address, - port: UInt16(port), - reference: referenceNumber - ) else { - print("HotlineState: Failed to create upload client") - return - } - - // Create transfer info for tracking (stored globally in AppState) - let transfer = TransferInfo( - reference: referenceNumber, - title: fileName, - size: UInt(payloadSize), - serverID: self.id, - serverName: self.serverName ?? self.serverTitle - ) - transfer.uploadCallback = callback - AppState.shared.addTransfer(transfer) - - // Create and store the upload task - let uploadTask = Task { @MainActor [weak self] in - guard self != nil else { return } - - do { - // Upload file with progress tracking - try await uploadClient.upload { progress in - switch progress { - case .preparing: - break - case .unconnected, .connected, .connecting: - break - case .transfer(name: _, size: _, total: _, progress: let progress, speed: let speed, estimate: let estimate): - transfer.timeRemaining = estimate - transfer.speed = speed - transfer.progress = progress - case .error(_): - transfer.failed = true - case .completed(url: _): - transfer.completed = true - } - } - - // Mark as completed - transfer.progress = 1.0 - - // Call completion callback - transfer.uploadCallback?(transfer) - - print("HotlineState: Upload complete - \(fileName)") - - } catch is CancellationError { - // Upload was cancelled - print("HotlineState: Upload cancelled") - } catch { - // Mark as failed - transfer.failed = true - print("HotlineState: Upload failed - \(error)") - } - - AppState.shared.unregisterTransferTask(for: transfer.id) - } - - // Store the transfer - AppState.shared.registerTransferTask(uploadTask, transferID: transfer.id) - } - } - - func setFileInfo(fileName: String, path filePath: [String], fileNewName: String?, comment: String?, encoding: String.Encoding = .utf8) { - // TODO: Implement setFileInfo in HotlineClientNew - // This method updates file metadata (name and/or comment) - print("setFileInfo not yet implemented in HotlineState/HotlineClientNew") - } - - @MainActor - func previewFile(_ fileName: String, path: [String], complete callback: ((PreviewFileInfo?) -> Void)? = nil) { - guard let client = self.client else { - callback?(nil) - return - } - - var fullPath: [String] = [] - if path.count > 1 { - fullPath = Array(path[0.. [HotlineAccount] { - guard let client = self.client else { - throw HotlineClientError.notConnected - } - - self.accounts = try await client.getAccounts() - self.accountsLoaded = true - return self.accounts - } - - @MainActor - func createUser(name: String, login: String, password: String?, access: UInt64) async throws { - guard let client = self.client else { - throw HotlineClientError.notConnected - } - - try await client.createUser(name: name, login: login, password: password, access: access) - - // Refresh accounts list - self.accounts = try await client.getAccounts() - } - - @MainActor - func setUser(name: String, login: String, newLogin: String?, password: String?, access: UInt64) async throws { - guard let client = self.client else { - throw HotlineClientError.notConnected - } - - try await client.setUser(name: name, login: login, newLogin: newLogin, password: password, access: access) - - // Refresh accounts list - self.accounts = try await client.getAccounts() - } - - @MainActor - func deleteUser(login: String) async throws { - guard let client = self.client else { - throw HotlineClientError.notConnected - } - - try await client.deleteUser(login: login) - - // Refresh accounts list - self.accounts = try await client.getAccounts() - } - - // MARK: - Message Board - - @MainActor - func getMessageBoard() async throws -> [String] { - guard let client = self.client else { - throw HotlineClientError.notConnected - } - - self.messageBoard = try await client.getMessageBoard() - self.messageBoardLoaded = true - return self.messageBoard - } - - @MainActor - func postToMessageBoard(text: String) async throws { - guard let client = self.client else { - throw HotlineClientError.notConnected - } - - try await client.postMessageBoard(text) - } - - // MARK: - News - - @MainActor - func getNewsList(at path: [String] = []) async throws { - guard let client = self.client else { - throw HotlineClientError.notConnected - } - - let parentNewsGroup = self.findNews(in: self.news, at: path) - - // Send a categories request for bundle paths or root (empty path) - if path.isEmpty || parentNewsGroup?.type == .bundle { - print("HotlineState: Requesting categories at: /\(path.joined(separator: "/"))") - - let categories = try await client.getNewsCategories(path: path) - - // Create info for each category returned - var newCategoryInfos: [NewsInfo] = [] - - // Transform hotline categories into NewsInfo objects - for category in categories { - var newsCategoryInfo = NewsInfo(hotlineNewsCategory: category) - - if let lookupPath = newsCategoryInfo.lookupPath { - // Merge returned category info with existing category info - if let existingCategoryInfo = self.newsLookup[lookupPath] { - print("HotlineState: Merging category into existing category at \(lookupPath)") - - existingCategoryInfo.count = newsCategoryInfo.count - existingCategoryInfo.name = newsCategoryInfo.name - existingCategoryInfo.path = newsCategoryInfo.path - existingCategoryInfo.categoryID = newsCategoryInfo.categoryID - newsCategoryInfo = existingCategoryInfo - } else { - print("HotlineState: New category added at \(lookupPath)") - self.newsLookup[lookupPath] = newsCategoryInfo - } - } - - newCategoryInfos.append(newsCategoryInfo) - } - - if let parent = parentNewsGroup { - parent.children = newCategoryInfos - } else if path.isEmpty { - self.newsLoaded = true - self.news = newCategoryInfos - } - } else { - print("HotlineState: Requesting articles at: /\(path.joined(separator: "/"))") - - let articles = try await client.getNewsArticles(path: path) - - print("HotlineState: Organizing news at \(path.joined(separator: "/"))") - - // Create info for each article returned - var newArticleInfos: [NewsInfo] = [] - - for article in articles { - var newsArticleInfo = NewsInfo(hotlineNewsArticle: article) - - if let lookupPath = newsArticleInfo.lookupPath { - // Merge returned category info with existing category info - if let existingArticleInfo = self.newsLookup[lookupPath] { - print("HotlineState: Merging article into existing article at \(lookupPath)") - - existingArticleInfo.count = newsArticleInfo.count - existingArticleInfo.name = newsArticleInfo.name - existingArticleInfo.path = newsArticleInfo.path - existingArticleInfo.articleUsername = newsArticleInfo.articleUsername - existingArticleInfo.articleDate = newsArticleInfo.articleDate - existingArticleInfo.articleFlavors = newsArticleInfo.articleFlavors - existingArticleInfo.articleID = newsArticleInfo.articleID - newsArticleInfo = existingArticleInfo - } else { - print("HotlineState: New article added at \(lookupPath)") - self.newsLookup[lookupPath] = newsArticleInfo - } - } - - newArticleInfos.append(newsArticleInfo) - } - - let organizedNewsArticles: [NewsInfo] = self.organizeNewsArticles(newArticleInfos) - if let parent = parentNewsGroup { - parent.children = organizedNewsArticles - } - } - } - - @MainActor - func getNewsArticle(id articleID: UInt, at path: [String], flavor: String = "text/plain") async throws -> String? { - guard let client = self.client else { - throw HotlineClientError.notConnected - } - - return try await client.getNewsArticle(id: UInt32(articleID), path: path, flavor: flavor) - } - - @MainActor - func postNewsArticle(title: String, body: String, at path: [String], parentID: UInt32 = 0) async throws { - guard let client = self.client else { - throw HotlineClientError.notConnected - } - - try await client.postNewsArticle(title: title, text: body, path: path, parentID: parentID) - print("HotlineState: News article posted") - } - - // MARK: - File Search - - @MainActor - func startFileSearch(query: String) { - let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - self.cancelFileSearch() - return - } - - self.fileSearchSession?.cancel() - self.resetFileSearchState() - self.fileSearchQuery = trimmed - self.fileSearchStatus = .searching(processed: 0, pending: 0) - self.fileSearchScannedFolders = 0 - self.fileSearchCurrentPath = [] - - let session = HotlineStateFileSearchSession(hotlineState: self, query: trimmed, config: self.fileSearchConfig) - self.fileSearchSession = session - - Task { await session.start() } - } - - @MainActor - func cancelFileSearch(clearResults: Bool = true) { - guard let session = self.fileSearchSession else { - if clearResults { - self.resetFileSearchState() - } else if !self.fileSearchResults.isEmpty { - self.fileSearchStatus = .cancelled(processed: self.fileSearchScannedFolders) - self.fileSearchCurrentPath = nil - } - return - } - - session.cancel() - self.fileSearchSession = nil - self.fileSearchCurrentPath = nil - - if clearResults { - self.resetFileSearchState() - } else { - self.fileSearchStatus = .cancelled(processed: self.fileSearchScannedFolders) - } - } - - @MainActor - func clearFileListCache() { - guard !self.fileListCache.isEmpty else { - return - } - - self.fileListCache.removeAll(keepingCapacity: false) - } - - @MainActor - fileprivate func searchSession(_ session: HotlineStateFileSearchSession, didEmit matches: [FileInfo], processed: Int, pending: Int) { - guard self.fileSearchSession === session else { - return - } - - var appended: [FileInfo] = [] - for match in matches { - let key = self.searchPathKey(for: match.path) - if self.fileSearchResultKeys.insert(key).inserted { - appended.append(match) - } - } - - if !appended.isEmpty { - self.fileSearchResults.append(contentsOf: appended) - } - - self.fileSearchScannedFolders = processed - self.fileSearchStatus = .searching(processed: processed, pending: pending) - } - - @MainActor - fileprivate func searchSession(_ session: HotlineStateFileSearchSession, didFocusOn path: [String]) { - guard self.fileSearchSession === session else { - return - } - - self.fileSearchCurrentPath = path - } - - @MainActor - fileprivate func searchSessionDidFinish(_ session: HotlineStateFileSearchSession, processed: Int, pending: Int, completed: Bool) { - guard self.fileSearchSession === session else { - return - } - - self.fileSearchScannedFolders = processed - self.fileSearchSession = nil - self.fileSearchCurrentPath = nil - - if completed { - self.fileSearchStatus = .completed(processed: processed) - } else { - self.fileSearchStatus = .cancelled(processed: processed) - } - } - - fileprivate func cachedListingForSearch(path: [String], ttl: TimeInterval) -> (items: [FileInfo], isFresh: Bool)? { - self.cachedFileList(for: path, ttl: ttl, allowStale: true) - } - - // MARK: - Event Handlers - - private func handleChatMessage(_ text: String) { - if Prefs.shared.playSounds && Prefs.shared.playChatSound { - SoundEffectPlayer.shared.playSoundEffect(.chatMessage) - } - - let chatMessage = ChatMessage(text: text, type: .message, date: Date()) - self.recordChatMessage(chatMessage) - self.unreadPublicChat = true - } - - private func handleUserChanged(_ user: HotlineUser) { - self.addOrUpdateHotlineUser(user) - } - - private func handleUserDisconnected(_ userID: UInt16) { - if let existingUserIndex = self.users.firstIndex(where: { $0.id == UInt(userID) }) { - let user = self.users.remove(at: existingUserIndex) - - if Prefs.shared.showJoinLeaveMessages { - let chatMessage = ChatMessage(text: "\(user.name) left", type: .left, date: Date()) - self.recordChatMessage(chatMessage) - } - - if Prefs.shared.playSounds && Prefs.shared.playLeaveSound { - SoundEffectPlayer.shared.playSoundEffect(.userLogout) - } - } - } - - private func handleServerMessage(_ message: String) { - if Prefs.shared.playSounds && Prefs.shared.playChatSound { - SoundEffectPlayer.shared.playSoundEffect(.serverMessage) - } - - print("HotlineState: received server message:\n\(message)") - let chatMessage = ChatMessage(text: message, type: .server, date: Date()) - self.recordChatMessage(chatMessage) - } - - private func handlePrivateMessage(userID: UInt16, message: String) { - if let existingUserIndex = self.users.firstIndex(where: { $0.id == UInt(userID) }) { - let user = self.users[existingUserIndex] - print("HotlineState: received private message from \(user.name): \(message)") - - if Prefs.shared.playPrivateMessageSound { - if self.unreadInstantMessages[userID] == nil { - SoundEffectPlayer.shared.playSoundEffect(.serverMessage) - } else { - SoundEffectPlayer.shared.playSoundEffect(.chatMessage) - } - } - - let instantMessage = InstantMessage( - direction: .incoming, - text: message.convertingLinksToMarkdown(), - type: .message, - date: Date() - ) - - if self.instantMessages[userID] == nil { - self.instantMessages[userID] = [instantMessage] - } else { - self.instantMessages[userID]!.append(instantMessage) - } - - self.unreadInstantMessages[userID] = userID - } - } - - private func handleNewsPost(_ message: String) { - let messageBoardRegex = /([\s\r\n]*[_\-]+[\s\r\n]+)/ - let matches = message.matches(of: messageBoardRegex) - - if matches.count == 1 { - let range = matches[0].range - self.messageBoard.insert(String(message[message.startIndex.. ChatStore.SessionKey { - ChatStore.SessionKey(address: server.address.lowercased(), port: server.port) - } - - private func recordChatMessage(_ message: ChatMessage, persist: Bool = true, display: Bool = true) { - let shouldPersist = persist && message.type != .agreement - if shouldPersist, - message.type == .signOut, - self.lastPersistedMessageType == .signOut { - return - } - - if display { - self.chat.append(message) - } - - guard shouldPersist, let key = self.chatSessionKey else { return } - self.lastPersistedMessageType = message.type - - let entry = ChatStore.Entry( - id: message.id, - body: message.text, - username: message.username, - type: message.type.storageKey, - date: message.date - ) - let serverName = self.serverName ?? self.server?.name - - Task { - await ChatStore.shared.append(entry: entry, for: key, serverName: serverName) - } - } - - private func restoreChatHistory(for key: ChatStore.SessionKey) { - if self.restoredChatSessionKey == key { - return - } - - Task { [weak self] in - guard let self else { return } - let result = await ChatStore.shared.loadHistory(for: key) - - await MainActor.run { - guard self.chatSessionKey == key, self.restoredChatSessionKey != key else { return } - - let currentMessages = self.chat - let historyMessages = result.entries.compactMap { entry -> ChatMessage? in - guard let chatType = ChatMessageType(storageKey: entry.type) else { return nil } - - let renderedText: String - if chatType == .message, let username = entry.username, !username.isEmpty { - renderedText = "\(username): \(entry.body)" - } else { - renderedText = entry.body - } - - var message = ChatMessage(text: renderedText, type: chatType, date: entry.date) - message.metadata = entry.metadata - return message - } - - self.chat = historyMessages + currentMessages - self.lastPersistedMessageType = historyMessages.last?.type - self.unreadPublicChat = false - self.restoredChatSessionKey = key - } - } - } - - private func handleChatHistoryCleared() { - self.chat = [] - self.unreadPublicChat = false - self.restoredChatSessionKey = nil - self.lastPersistedMessageType = nil - } - - // MARK: - Utilities - - func updateServerTitle() { - self.serverTitle = self.serverName ?? self.server?.name ?? self.server?.address ?? "Server" - } - - // News helpers - func organizeNewsArticles(_ flatArticles: [NewsInfo]) -> [NewsInfo] { - // Place articles under their parent - var organized: [NewsInfo] = [] - for article in flatArticles { - if let parentLookupPath = article.parentArticleLookupPath, - let parentArticle = self.newsLookup[parentLookupPath] { - if parentArticle.children.firstIndex(of: article) == nil { - article.expanded = true - parentArticle.children.append(article) - } - } else { - organized.append(article) - } - } - - return organized - } - - private func findNews(in newsToSearch: [NewsInfo], at path: [String]) -> NewsInfo? { - guard !path.isEmpty, !newsToSearch.isEmpty, let currentName = path.first else { return nil } - - for news in newsToSearch { - if news.name == currentName { - if path.count == 1 { - return news - } else if !news.children.isEmpty { - let remainingPath = Array(path[1...]) - return self.findNews(in: news.children, at: remainingPath) - } - } - } - - return nil - } - - // File helpers - private func findFile(in filesToSearch: [FileInfo], at path: [String]) -> FileInfo? { - guard !path.isEmpty, !filesToSearch.isEmpty else { return nil } - - let currentName = path[0] - - for file in filesToSearch { - if file.name == currentName { - if path.count == 1 { - return file - } else if let subfiles = file.children { - let remainingPath = Array(path[1...]) - return self.findFile(in: subfiles, at: remainingPath) - } - } - } - - return nil - } - - // File search helpers - private func searchPathKey(for path: [String]) -> String { - path.joined(separator: "\u{001F}") - } - - private func resetFileSearchState() { - self.fileSearchResults = [] - self.fileSearchResultKeys.removeAll(keepingCapacity: true) - self.fileSearchStatus = .idle - self.fileSearchQuery = "" - self.fileSearchScannedFolders = 0 - self.fileSearchCurrentPath = nil - } - - // File cache helpers - private func shouldBypassFileCache(for path: [String]) -> Bool { - guard let folderName = path.last else { - return false - } - - let trimmed = folderName.trimmingCharacters(in: .whitespacesAndNewlines) - - if trimmed.range(of: "upload", options: [.caseInsensitive]) != nil { - return true - } - - if trimmed.range(of: "dropbox", options: [.caseInsensitive]) != nil { - return true - } - - if trimmed.range(of: "drop box", options: [.caseInsensitive]) != nil { - return true - } - - return false - } - - private func cachedFileList(for path: [String], ttl: TimeInterval, allowStale: Bool) -> (items: [FileInfo], isFresh: Bool)? { - guard ttl > 0 else { - return nil - } - - if self.shouldBypassFileCache(for: path) { - return nil - } - - let key = self.searchPathKey(for: path) - guard let entry = self.fileListCache[key] else { - return nil - } - - let age = Date().timeIntervalSince(entry.timestamp) - let isFresh = age <= ttl - if !allowStale && !isFresh { - return nil - } - - return (entry.files, isFresh) - } - - private func storeFileListInCache(_ files: [FileInfo], for path: [String]) { - guard self.fileSearchConfig.cacheTTL > 0 else { - return - } - - if self.shouldBypassFileCache(for: path) { - return - } - - let key = self.searchPathKey(for: path) - self.fileListCache[key] = FileListCacheEntry(files: files, timestamp: Date()) - self.pruneFileListCacheIfNeeded() - } - - private func pruneFileListCacheIfNeeded() { - let limit = self.fileSearchConfig.maxCachedFolders - guard limit > 0, self.fileListCache.count > limit else { - return - } - - let excess = self.fileListCache.count - limit - guard excess > 0 else { return } - - let sortedKeys = self.fileListCache.sorted { lhs, rhs in - lhs.value.timestamp < rhs.value.timestamp - } - - for index in 0.. = [] - private var loopHistogram: [String: Int] = [:] - - private var processedCount: Int = 0 - private var currentDelay: TimeInterval - private var isCancelled = false - - init(hotlineState: HotlineState, query: String, config: FileSearchConfig) { - self.hotlineState = hotlineState - self.queryTokens = query.lowercased().split(separator: " ").map(String.init) - self.config = config - self.currentDelay = config.initialDelay - } - - func start() async { - guard let hotlineState else { - return - } - - await Task.yield() - - if !hotlineState.filesLoaded { - hotlineState.searchSession(self, didFocusOn: []) - let rootFiles = try? await hotlineState.getFileList(path: [], suppressErrors: true, preferCache: true) - self.processedCount = max(self.processedCount, 1) - self.processListing(rootFiles ?? [], depth: 0, parentPath: [], parentIsHot: false) - } else { - hotlineState.searchSession(self, didFocusOn: []) - self.processedCount = max(self.processedCount, 1) - self.processListing(hotlineState.files, depth: 0, parentPath: [], parentIsHot: false) - } - - while !self.queue.isEmpty && !self.isCancelled { - await Task.yield() - - guard let task = self.dequeueNextTask() else { - continue - } - - if self.shouldSkip(path: task.path, depth: task.depth) { - hotlineState.searchSession(self, didEmit: [], processed: self.processedCount, pending: self.queue.count) - continue - } - - hotlineState.searchSession(self, didFocusOn: task.path) - self.visited.insert(self.pathKey(for: task.path)) - - if let cached = hotlineState.cachedListingForSearch(path: task.path, ttl: self.config.cacheTTL) { - if cached.isFresh { - self.processedCount += 1 - self.processListing(cached.items, depth: task.depth, parentPath: task.path, parentIsHot: task.isHot) - continue - } else { - self.processListing(cached.items, depth: task.depth, parentPath: task.path, parentIsHot: task.isHot) - } - } - - let children = try? await hotlineState.getFileList(path: task.path, suppressErrors: true) - self.processedCount += 1 - - if self.isCancelled { - break - } - - self.processListing(children ?? [], depth: task.depth, parentPath: task.path, parentIsHot: task.isHot) - - await self.applyBackoff() - } - - hotlineState.searchSessionDidFinish(self, processed: self.processedCount, pending: self.queue.count, completed: !self.isCancelled) - } - - func cancel() { - self.isCancelled = true - } - - private func processListing(_ items: [FileInfo], depth: Int, parentPath: [String], parentIsHot: Bool) { - guard let hotlineState else { - return - } - - var matches: [FileInfo] = [] - var folderEntries: [(file: FileInfo, isHot: Bool)] = [] - var hasFileMatch = false - - for file in items { - let matchesName = self.nameMatchesQuery(file.name) - - if matchesName { - matches.append(file) - if !file.isFolder { - hasFileMatch = true - } - } - - if file.isFolder && !file.isAppBundle { - folderEntries.append((file, matchesName)) - } - } - - var remainingBurst = 0 - if self.config.hotBurstLimit > 0 && (parentIsHot || hasFileMatch) { - remainingBurst = self.config.hotBurstLimit - } - - if remainingBurst > 0 { - var candidateIndices: [Int] = [] - for index in folderEntries.indices where !folderEntries[index].isHot { - candidateIndices.append(index) - } - - if !candidateIndices.isEmpty { - candidateIndices.shuffle() - for index in candidateIndices { - folderEntries[index].isHot = true - remainingBurst -= 1 - if remainingBurst == 0 { - break - } - } - } - } - - for entry in folderEntries { - self.enqueueFolder(entry.file, depth: depth + 1, markHot: entry.isHot) - } - - hotlineState.searchSession(self, didEmit: matches, processed: self.processedCount, pending: self.queue.count) - } - - private func enqueueFolder(_ folder: FileInfo, depth: Int, markHot: Bool) { - guard !self.isCancelled else { return } - guard depth <= self.config.maxDepth else { return } - - let path = folder.path - let key = self.pathKey(for: path) - guard !self.visited.contains(key) else { return } - - if self.exceedsLoopThreshold(for: path) { - return - } - - self.queue.append(FolderTask(path: path, depth: depth, isHot: markHot)) - } - - private func dequeueNextTask() -> FolderTask? { - guard !self.queue.isEmpty else { - return nil - } - - if self.queue.count == 1 { - return self.queue.removeFirst() - } - - let currentDepth = self.queue[0].depth - var lastSameDepthIndex = 0 - var hotIndices: [Int] = [] - - for index in 0.. Bool { - if self.isCancelled { - return true - } - - if depth > self.config.maxDepth { - return true - } - - let key = self.pathKey(for: path) - if self.visited.contains(key) { - return true - } - - return false - } - - private func nameMatchesQuery(_ name: String) -> Bool { - guard !self.queryTokens.isEmpty else { return false } - let lowercased = name.lowercased() - return self.queryTokens.allSatisfy { lowercased.contains($0) } - } - - private func exceedsLoopThreshold(for path: [String]) -> Bool { - guard self.config.loopRepetitionLimit > 0 else { return false } - guard let last = path.last else { return false } - let parent = path.dropLast() - - guard let previousIndex = parent.lastIndex(of: last) else { - return false - } - - let suffix = Array(path[previousIndex...]) - let key = suffix.joined(separator: "\u{001F}") - let count = (self.loopHistogram[key] ?? 0) + 1 - self.loopHistogram[key] = count - return count > self.config.loopRepetitionLimit - } - - private func pathKey(for path: [String]) -> String { - path.joined(separator: "\u{001F}") - } - - private func applyBackoff() async { - guard !self.isCancelled else { return } - - if self.processedCount > self.config.initialBurstCount { - self.currentDelay = min(self.config.maxDelay, max(self.config.initialDelay, self.currentDelay * self.config.backoffMultiplier)) - } - - guard self.currentDelay > 0 else { - return - } - - let nanoseconds = UInt64(self.currentDelay * 1_000_000_000) - try? await Task.sleep(nanoseconds: nanoseconds) - } -} diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift new file mode 100644 index 0000000..d5ab438 --- /dev/null +++ b/Hotline/State/HotlineState.swift @@ -0,0 +1,2468 @@ +import SwiftUI + +// MARK: - Connection Status + +enum HotlineConnectionStatus: Equatable { + case disconnected + case connecting + case connected + case loggedIn + case failed(String) + + var isConnected: Bool { + return self == .connected || self == .loggedIn + } +} + +struct FileSearchConfig: Equatable { + /// Number of folders we process before we start applying delay backoff. + var initialBurstCount: Int = 15 + /// Base delay applied between folder requests during the backoff phase. + var initialDelay: TimeInterval = 0.02 + /// Multiplier used to increase the delay after each processed folder in backoff. + var backoffMultiplier: Double = 1.1 + /// Maximum delay cap so searches don't stall out during long walks. + var maxDelay: TimeInterval = 1.0 + /// Maximum recursion depth allowed during file search. + var maxDepth: Int = 40 + /// Limit for repeated folder loops (guards against circular server listings). + var loopRepetitionLimit: Int = 4 + /// Number of child folders that get prioritized after a matching parent is found. + var hotBurstLimit: Int = 2 + /// Maximum age, in seconds, that a cached folder listing is treated as fresh. + var cacheTTL: TimeInterval = 60 * 15 + /// Upper bound on the number of folder listings retained in the cache. + var maxCachedFolders: Int = 1024 * 3 +} + +enum FileSearchStatus: Equatable { + case idle + case searching(processed: Int, pending: Int) + case completed(processed: Int) + case cancelled(processed: Int) + case failed(String) + + var isActive: Bool { + if case .searching = self { + return true + } + return false + } +} + +// MARK: - HotlineState + +@Observable @MainActor +class HotlineState: Equatable { + let id: UUID = UUID() + + nonisolated static func == (lhs: HotlineState, rhs: HotlineState) -> Bool { + return lhs.id == rhs.id + } + + // MARK: - Static Icon Data + + #if os(macOS) + static func getClassicIcon(_ index: Int) -> NSImage? { + return NSImage(named: "Classic/\(index)") + } + #elseif os(iOS) + static func getClassicIcon(_ index: Int) -> UIImage? { + return UIImage(named: "Classic/\(index)") + } + #endif + + static let classicIconSet: [Int] = [ + 141, 149, 150, 151, 172, 184, 204, + 2013, 2036, 2037, 2055, 2400, 2505, 2534, + 2578, 2592, 4004, 4015, 4022, 4104, 4131, + 4134, 4136, 4169, 4183, 4197, 4240, 4247, + 128, 129, 130, 131, 132, 133, 134, + 135, 136, 137, 138, 139, 140, 142, + 143, 144, 145, 146, 147, 148, 152, + 153, 154, 155, 156, 157, 158, 159, + 160, 161, 162, 163, 164, 165, 166, + 167, 168, 169, 170, 171, 173, 174, + 175, 176, 177, 178, 179, 180, 181, + 182, 183, 185, 186, 187, 188, 189, + 190, 191, 192, 193, 194, 195, 196, + 197, 198, 199, 200, 201, 202, 203, + 205, 206, 207, 208, 209, 212, 214, + 215, 220, 233, 236, 237, 243, 244, + 277, 410, 414, 500, 666, 1250, 1251, + 1968, 1969, 2000, 2001, 2002, 2003, 2004, + 2006, 2007, 2008, 2009, 2010, 2011, 2012, + 2014, 2015, 2016, 2017, 2018, 2019, 2020, + 2021, 2022, 2023, 2024, 2025, 2026, 2027, + 2028, 2029, 2030, 2031, 2032, 2033, 2034, + 2035, 2038, 2040, 2041, 2042, 2043, 2044, + 2045, 2046, 2047, 2048, 2049, 2050, 2051, + 2052, 2053, 2054, 2056, 2057, 2058, 2059, + 2060, 2061, 2062, 2063, 2064, 2065, 2066, + 2067, 2070, 2071, 2072, 2073, 2075, 2079, + 2098, 2100, 2101, 2102, 2103, 2104, 2105, + 2106, 2107, 2108, 2109, 2110, 2112, 2113, + 2115, 2116, 2117, 2118, 2119, 2120, 2121, + 2122, 2123, 2124, 2125, 2126, 4150, 2223, + 2401, 2402, 2403, 2404, 2500, 2501, 2502, + 2503, 2504, 2506, 2507, 2528, 2529, 2530, + 2531, 2532, 2533, 2535, 2536, 2537, 2538, + 2539, 2540, 2541, 2542, 2543, 2544, 2545, + 2546, 2547, 2548, 2549, 2550, 2551, 2552, + 2553, 2554, 2555, 2556, 2557, 2558, 2559, + 2560, 2561, 2562, 2563, 2564, 2565, 2566, + 2567, 2568, 2569, 2570, 2571, 2572, 2573, + 2574, 2575, 2576, 2577, 2579, 2580, 2581, + 2582, 2583, 2584, 2585, 2586, 2587, 2588, + 2589, 2590, 2591, 2593, 2594, 2595, 2596, + 2597, 2598, 2599, 2600, 4000, 4001, 4002, + 4003, 4005, 4006, 4007, 4008, 4009, 4010, + 4011, 4012, 4013, 4014, 4016, 4017, 4018, + 4019, 4020, 4021, 4023, 4024, 4025, 4026, + 4027, 4028, 4029, 4030, 4031, 4032, 4033, + 4034, 4035, 4036, 4037, 4038, 4039, 4040, + 4041, 4042, 4043, 4044, 4045, 4046, 4047, + 4048, 4049, 4050, 4051, 4052, 4053, 4054, + 4055, 4056, 4057, 4058, 4059, 4060, 4061, + 4062, 4063, 4064, 4065, 4066, 4067, 4068, + 4069, 4070, 4071, 4072, 4073, 4074, 4075, + 4076, 4077, 4078, 4079, 4080, 4081, 4082, + 4083, 4084, 4085, 4086, 4087, 4088, 4089, + 4090, 4091, 4092, 4093, 4094, 4095, 4096, + 4097, 4098, 4099, 4100, 4101, 4102, 4103, + 4105, 4106, 4107, 4108, 4109, 4110, 4111, + 4112, 4113, 4114, 4115, 4116, 4117, 4118, + 4119, 4120, 4121, 4122, 4123, 4124, 4125, + 4126, 4127, 4128, 4129, 4130, 4132, 4133, + 4135, 4137, 4138, 4139, 4140, 4141, 4142, + 4143, 4144, 4145, 4146, 4147, 4148, 4149, + 4151, 4152, 4153, 4154, 4155, 4156, 4157, + 4158, 4159, 4160, 4161, 4162, 4163, 4164, + 4165, 4166, 4167, 4168, 4170, 4171, 4172, + 4173, 4174, 4175, 4176, 4177, 4178, 4179, + 4180, 4181, 4182, 4184, 4185, 4186, 4187, + 4188, 4189, 4190, 4191, 4192, 4193, 4194, + 4195, 4196, 4198, 4199, 4200, 4201, 4202, + 4203, 4204, 4205, 4206, 4207, 4208, 4209, + 4210, 4211, 4212, 4213, 4214, 4215, 4216, + 4217, 4218, 4219, 4220, 4221, 4222, 4223, + 4224, 4225, 4226, 4227, 4228, 4229, 4230, + 4231, 4232, 4233, 4234, 4235, 4236, 4238, + 4241, 4242, 4243, 4244, 4245, 4246, 4248, + 4249, 4250, 4251, 4252, 4253, 4254, 31337, + 6001, 6002, 6003, 6004, 6005, 6008, 6009, + 6010, 6011, 6012, 6013, 6014, 6015, 6016, + 6017, 6018, 6023, 6025, 6026, 6027, 6028, + 6029, 6030, 6031, 6032, 6033, 6034, 6035 + ] + + // MARK: - Observable State + + var status: HotlineConnectionStatus = .disconnected + var server: Server? { + didSet { + self.updateServerTitle() + } + } + var serverVersion: UInt16 = 123 + var serverName: String? { + didSet { + self.updateServerTitle() + } + } + var serverTitle: String = "Server" + var username: String = "guest" + var iconID: Int = 414 + var access: HotlineUserAccessOptions? + var agreed: Bool = false + + // Users + var users: [User] = [] + + // Chat + var chat: [ChatMessage] = [] + var chatInput: String = "" + var unreadPublicChat: Bool = false + + // Instant Messages + var instantMessages: [UInt16:[InstantMessage]] = [:] + var unreadInstantMessages: [UInt16:UInt16] = [:] + + // Message Board + var messageBoard: [String] = [] + var messageBoardLoaded: Bool = false + + // News + var news: [NewsInfo] = [] + var newsLoaded: Bool = false + private var newsLookup: [String:NewsInfo] = [:] + + // Files + var files: [FileInfo] = [] + var filesLoaded: Bool = false + + // Accounts + var accounts: [HotlineAccount] = [] + var accountsLoaded: Bool = false + + // Banner + #if os(macOS) + var bannerImage: Image? = nil + var bannerColors: ColorArt? = nil + #elseif os(iOS) + var bannerImage: UIImage? = nil + #endif + + // Transfers (now stored globally in AppState) + /// Returns all transfers associated with this server + var transfers: [TransferInfo] { + AppState.shared.transfers.filter { $0.serverID == self.id } + } + + // Legacy transfer tracking (for old delegate-based downloads) +// @ObservationIgnored private var downloads: [HotlineTransferClient] = [] + @ObservationIgnored private var bannerDownloadTask: Task? = nil + + // File Search + var fileSearchResults: [FileInfo] = [] + var fileSearchStatus: FileSearchStatus = .idle + var fileSearchQuery: String = "" + var fileSearchConfig = FileSearchConfig() + var fileSearchScannedFolders: Int = 0 + var fileSearchCurrentPath: [String]? = nil + @ObservationIgnored private var fileSearchSession: HotlineStateFileSearchSession? = nil + @ObservationIgnored private var fileSearchResultKeys: Set = [] + + // File List Cache + private struct FileListCacheEntry { + let files: [FileInfo] + let timestamp: Date + } + @ObservationIgnored private var fileListCache: [String: FileListCacheEntry] = [:] + + // Error Display + var errorDisplayed: Bool = false + var errorMessage: String? = nil + + // MARK: - Private State + + @ObservationIgnored private var client: HotlineClientNew? + @ObservationIgnored private var eventTask: Task? + @ObservationIgnored private var chatSessionKey: ChatStore.SessionKey? + @ObservationIgnored private var restoredChatSessionKey: ChatStore.SessionKey? + @ObservationIgnored private var chatHistoryObserver: NSObjectProtocol? + @ObservationIgnored private var lastPersistedMessageType: ChatMessageType? + + // MARK: - Initialization + + init() { + self.chatHistoryObserver = NotificationCenter.default.addObserver( + forName: ChatStore.historyClearedNotification, + object: nil, + queue: .main + ) { @MainActor [weak self] _ in + self?.handleChatHistoryCleared() + } + } + + deinit { + if let observer = self.chatHistoryObserver { + NotificationCenter.default.removeObserver(observer) + } + } + + // MARK: - Connection + + @MainActor + func login(server: Server, username: String, iconID: Int) async throws { + print("HotlineState.login(): Starting login to \(server.address):\(server.port)") + self.server = server + self.username = username + self.iconID = iconID + self.status = .connecting + print("HotlineState.login(): Status set to connecting") + + // Set up chat session + let key = self.sessionKey(for: server) + self.chatSessionKey = key + self.restoredChatSessionKey = nil + self.lastPersistedMessageType = nil + self.chat = [] + self.restoreChatHistory(for: key) + print("HotlineState.login(): Chat session set up") + + do { + // Connect and login + let loginInfo = HotlineLoginInfo( + login: server.login, + password: server.password, + username: username, + iconID: UInt16(iconID) + ) + + print("HotlineState.login(): Calling HotlineClientNew.connect()...") + let client = try await HotlineClientNew.connect( + host: server.address, + port: UInt16(server.port), + login: loginInfo + ) + print("HotlineState.login(): HotlineClientNew.connect() returned") + + self.client = client + print("HotlineState.login(): Client stored") + + // Get server info + print("HotlineState.login(): Getting server info...") + if let serverInfo = await client.server { + self.serverVersion = serverInfo.version + if !serverInfo.name.isEmpty { + self.serverName = serverInfo.name + } + print("HotlineState.login(): Server info retrieved: \(serverInfo.name) v\(serverInfo.version)") + } + + self.status = .connected + print("HotlineState.login(): Status set to connected") + + // Request initial data before starting event loop + print("HotlineState.login(): Requesting user list...") + try await self.getUserList() + + self.status = .loggedIn + print("HotlineState.login(): Status set to loggedIn") + + if Prefs.shared.playSounds && Prefs.shared.playLoggedInSound { + SoundEffectPlayer.shared.playSoundEffect(.loggedIn) + } + + print("HotlineState.login(): Connected to \(self.serverTitle)") + print("HotlineState.login(): Scheduling post-login tasks...") + + // Defer event loop and post-login work to avoid layout recursion + // This allows login() to return and SwiftUI to complete its layout pass + // before we start receiving events that trigger state changes + Task { @MainActor in + print("HotlineState: Post-login: Starting event loop...") + self.startEventLoop() + + print("HotlineState: Post-login: Sending preferences...") + try? await self.sendUserPreferences() + + print("HotlineState: Post-login: Downloading banner...") + self.downloadBanner() + } + + } catch { + print("HotlineState.login(): Login failed with error: \(error)") + if let client = self.client { + await client.disconnect() + self.client = nil + } + self.status = .failed(error.localizedDescription) + self.errorDisplayed = true + self.errorMessage = error.localizedDescription + throw error + } + } + + /// Disconnect from the server (user-initiated) + @MainActor + func disconnect() async { + print("HotlineState.disconnect(): Called") + guard let client = self.client else { + print("HotlineState.disconnect(): No client, returning") + return + } + + // Stop event loop + print("HotlineState.disconnect(): Cancelling event task...") + self.eventTask?.cancel() + self.eventTask = nil + print("HotlineState.disconnect(): Event task cancelled") + + // Explicitly close the connection + print("HotlineState.disconnect(): Calling client.disconnect()...") + await client.disconnect() + print("HotlineState.disconnect(): client.disconnect() returned") + + // Clean up state + print("HotlineState.disconnect(): Calling handleConnectionClosed()...") + self.handleConnectionClosed() + print("HotlineState.disconnect(): disconnect() complete") + } + + /// Handle connection closure (server-initiated or after user disconnect) + @MainActor + private func handleConnectionClosed() { + print("HotlineState: handleConnectionClosed() entered") + guard self.client != nil else { + print("HotlineState: handleConnectionClosed() - client already nil, returning") + return + } + + print("HotlineState: Handling connection closure - recording chat...") + + // Record disconnect in chat history + if self.status == .loggedIn { + let message = ChatMessage(text: "Disconnected", type: .signOut, date: Date()) + self.recordChatMessage(message, persist: true, display: false) + } + + print("HotlineState: Cancelling banner and downloads...") + + self.bannerDownloadTask?.cancel() + self.bannerDownloadTask = nil + + // Cancel all downloads (both old delegate-based and new async downloads) +// self.downloads = [] + + // Cancel all transfers for this server +// self.cancelAllDownloads() + + // Cancel file search + self.fileSearchSession?.cancel() + self.fileSearchSession = nil + + // Clear client reference + self.client = nil + + print("HotlineState: Resetting state properties...") + + // Reset state immediately (constraint loop was caused by something else) + self.status = .disconnected + self.serverVersion = 123 + self.serverName = nil + self.access = nil + self.agreed = false + self.users = [] + self.chat = [] + self.instantMessages = [:] + self.unreadInstantMessages = [:] + self.unreadPublicChat = false + self.messageBoard = [] + self.messageBoardLoaded = false + self.news = [] + self.newsLoaded = false + self.newsLookup = [:] + self.files = [] + self.filesLoaded = false + self.accounts = [] + self.accountsLoaded = false + self.bannerImage = nil + self.bannerColors = nil + + print("HotlineState: Resetting file search...") + self.resetFileSearchState() + + self.chatSessionKey = nil + self.restoredChatSessionKey = nil + self.lastPersistedMessageType = nil + + print("HotlineState: Disconnected") + } + + @MainActor + func downloadBanner(force: Bool = false) { + guard self.serverVersion >= 150 else { + return + } + + if force { + self.bannerDownloadTask?.cancel() + self.bannerDownloadTask = nil + self.bannerImage = nil + self.bannerColors = nil + } else if self.bannerDownloadTask != nil || self.bannerImage != nil { + return + } + + let task = Task { @MainActor [weak self] in + defer { + self?.bannerDownloadTask = nil + } + + guard let self else { return } + guard let client = self.client, + let server = self.server, + let result = try? await client.downloadBanner(), + let address = server.address as String?, + let port = server.port as Int? + else { + return + } + + do { + print("HotlineState: Banner download info - reference: \(result.referenceNumber), transferSize: \(result.transferSize)") + + let previewClient = HotlineFilePreviewClientNew( + fileName: "banner", + address: address, + port: UInt16(port), + reference: result.referenceNumber, + size: UInt32(result.transferSize) + ) + + let fileURL = try await previewClient.preview() + defer { + previewClient.cleanup() + } + + guard self.client != nil else { return } + + let data = try Data(contentsOf: fileURL) + print("HotlineState: Banner download complete, data size: \(data.count) bytes") + +#if os(macOS) + guard let image = NSImage(data: data) else { + print("HotlineState: Failed to create NSImage from banner data") + return + } + let blah = Image(nsImage: image) +#elseif os(iOS) + guard let image = UIImage(data: data) else { + print("HotlineState: Failed to create UIImage from banner data") + return + } + self.bannerImage = Image(uiImage: image) +#endif + self.bannerImage = blah + self.bannerColors = ColorArt.analyze(image: image) + + } catch { + print("HotlineState: Banner download failed: \(error)") + } + } + + self.bannerDownloadTask = task + } + + // MARK: - Event Loop + + private func startEventLoop() { + print("HotlineState.startEventLoop(): Called") + guard let client = self.client else { + print("HotlineState.startEventLoop(): No client, returning") + return + } + + print("HotlineState.startEventLoop(): Creating event loop task") + self.eventTask = Task { @MainActor [weak self, client] in + guard let self else { + print("HotlineState.startEventLoop(): Self is nil in task, exiting") + return + } + + print("HotlineState.startEventLoop(): Event loop started, awaiting events...") + for await event in client.events { + print("HotlineState.startEventLoop(): Received event: \(event)") + self.handleEvent(event) + } + + // Event stream ended - server disconnected us + print("HotlineState.startEventLoop(): Event stream ended, calling handleConnectionClosed()...") + self.handleConnectionClosed() + print("HotlineState.startEventLoop(): handleConnectionClosed() returned, event loop task complete") + } + print("HotlineState.startEventLoop(): Event loop task created") + } + + @MainActor + private func handleEvent(_ event: HotlineEvent) { + switch event { + case .chatMessage(let text): + self.handleChatMessage(text) + + case .userChanged(let user): + self.handleUserChanged(user) + + case .userDisconnected(let userID): + self.handleUserDisconnected(userID) + + case .serverMessage(let message): + self.handleServerMessage(message) + + case .privateMessage(let userID, let message): + self.handlePrivateMessage(userID: userID, message: message) + + case .newsPost(let message): + self.handleNewsPost(message) + + case .agreementRequired(let text): + let message = ChatMessage(text: text, type: .agreement, date: Date()) + self.recordChatMessage(message, persist: false) + + case .userAccess(let options): + self.access = options + print("HotlineState: Got access options") + HotlineUserAccessOptions.printAccessOptions(options) + } + } + + // MARK: - Chat + + @MainActor + func sendChat(_ text: String, announce: Bool = false) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + try await client.sendChat(text, announce: announce) + } + + @MainActor + func sendInstantMessage(_ text: String, userID: UInt16) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + let message = InstantMessage( + direction: .outgoing, + text: text.convertingLinksToMarkdown(), + type: .message, + date: Date() + ) + + if self.instantMessages[userID] == nil { + self.instantMessages[userID] = [message] + } else { + self.instantMessages[userID]!.append(message) + } + + try await client.sendInstantMessage(text, to: userID) + + if Prefs.shared.playPrivateMessageSound { + SoundEffectPlayer.shared.playSoundEffect(.chatMessage) + } + } + + @MainActor + func sendAgree() async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + try await client.sendAgree() + self.agreed = true + } + + @MainActor + /// Send current user preferences from Prefs to the server + func sendUserPreferences() async throws { + var options: HotlineUserOptions = [] + + if Prefs.shared.refusePrivateMessages { + options.update(with: .refusePrivateMessages) + } + + if Prefs.shared.refusePrivateChat { + options.update(with: .refusePrivateChat) + } + + if Prefs.shared.enableAutomaticMessage { + options.update(with: .automaticResponse) + } + + print("HotlineState.sendUserPreferences(): Updating user info with server") + + try await self.sendUserInfo( + username: Prefs.shared.username, + iconID: Prefs.shared.userIconID, + options: options, + autoresponse: Prefs.shared.automaticMessage + ) + } + + func sendUserInfo(username: String, iconID: Int, options: HotlineUserOptions = [], autoresponse: String? = nil) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + self.username = username + self.iconID = iconID + + try await client.setClientUserInfo( + username: username, + iconID: UInt16(iconID), + options: options, + autoresponse: autoresponse + ) + } + + func markPublicChatAsRead() { + self.unreadPublicChat = false + } + + func hasUnreadInstantMessages(userID: UInt16) -> Bool { + return self.unreadInstantMessages[userID] != nil + } + + func markInstantMessagesAsRead(userID: UInt16) { + self.unreadInstantMessages.removeValue(forKey: userID) + } + + @MainActor + func searchChat(query: String) -> [ChatMessage] { + guard !query.isEmpty else { + return [] + } + + // Create a map of all messages by ID to deduplicate + var messageMap: [UUID: ChatMessage] = [:] + + // Add current in-memory messages + for message in self.chat { + messageMap[message.id] = message + } + + // Filter messages based on query + let filteredMessages = messageMap.values.filter { message in + // Never include agreement messages + if message.type == .agreement { + return false + } + + // Always include disconnect messages to show session boundaries + let isDisconnect = message.type == .signOut + + // Search in text and username + let matchesText = message.text.localizedCaseInsensitiveContains(query) + let matchesUsername = message.username?.localizedCaseInsensitiveContains(query) == true + let matchesQuery = matchesText || matchesUsername + + return isDisconnect || matchesQuery + } + + // Sort by date to maintain chronological order + let sortedMessages = filteredMessages.sorted { $0.date < $1.date } + + // Remove consecutive disconnect messages to avoid visual clutter + var deduplicated: [ChatMessage] = [] + var lastWasDisconnect = false + + for message in sortedMessages { + let isDisconnect = message.type == .signOut + + if isDisconnect && lastWasDisconnect { + continue + } + + deduplicated.append(message) + lastWasDisconnect = isDisconnect + } + + // Remove leading disconnect message + if deduplicated.first?.type == .signOut { + deduplicated.removeFirst() + } + + // Remove trailing disconnect message + if deduplicated.last?.type == .signOut { + deduplicated.removeLast() + } + + return deduplicated + } + + // MARK: - Users + + @MainActor + func getUserList() async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + let hotlineUsers = try await client.getUserList() + self.users = hotlineUsers.map { User(hotlineUser: $0) } + } + + // MARK: - Files (Basic) + + @MainActor + @discardableResult + func getFileList(path: [String] = [], suppressErrors: Bool = false, preferCache: Bool = false) async throws -> [FileInfo] { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + // Check cache first if preferred + if preferCache, let cached = self.cachedFileList(for: path, ttl: self.fileSearchConfig.cacheTTL, allowStale: false) { + return cached.items + } + + let hotlineFiles = try await client.getFileList(path: path) + let newFiles = hotlineFiles.map { FileInfo(hotlineFile: $0) } + + // Update UI state + if path.isEmpty { + self.filesLoaded = true + self.files = newFiles + } else { + // Update parent's children + let parentFile = self.findFile(in: self.files, at: path) + parentFile?.children = newFiles + } + + // Cache the result + self.storeFileListInCache(newFiles, for: path) + + return newFiles + } + + @MainActor + func getFileDetails(_ fileName: String, path: [String]) async throws -> FileDetails? { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + var fullPath: [String] = [] + if path.count > 1 { + fullPath = Array(path[0.. Bool { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + var fullPath: [String] = [] + if path.count > 1 { + fullPath = Array(path[0.. Void)? = nil, complete callback: ((TransferInfo, URL) -> Void)? = nil) { +// guard let client = self.client else { return } +// +// var fullPath: [String] = [] +// if path.count > 1 { +// fullPath = Array(path[0.. Void)? = nil, complete callback: ((TransferInfo) -> Void)? = nil) { + guard let client = self.client else { return } + + var fullPath: [String] = [] + if path.count > 1 { + fullPath = Array(path[0.. Void)? = nil) { +// guard let client = self.client else { return } +// +// var fullPath: [String] = [] +// if path.count > 1 { +// fullPath = Array(path[0.. Void)? = nil, + itemProgress itemProgressCallback: ((TransferInfo, String, Int, Int) -> Void)? = nil, + complete callback: ((TransferInfo) -> Void)? = nil + ) { + guard let client = self.client else { return } + + var fullPath: [String] = [] + if path.count > 1 { + fullPath = Array(path[0.. Void)? = nil, + itemProgress itemProgressCallback: ((TransferInfo, String, Int, Int) -> Void)? = nil, + complete callback: ((TransferInfo) -> Void)? = nil + ) { + guard let client = self.client else { return } + + let folderName = folderURL.lastPathComponent + + guard folderURL.isFileURL, !folderName.isEmpty else { + print("HotlineState: Not a valid folder URL") + return + } + + let folderPath = folderURL.path(percentEncoded: false) + + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: folderPath, isDirectory: &isDirectory), + isDirectory.boolValue == true else { + print("HotlineState: URL is not a folder") + return + } + + // Get the total size of the folder (all files) + guard let (folderSize, fileCount) = FileManager.default.getFolderSize(folderURL) else { + print("HotlineState: Could not determine folder size") + return + } + + print("HotlineState: Requesting upload for folder '\(folderName)' - \(fileCount) items, \(folderSize) bytes total") + + Task { @MainActor [weak self] in + guard let self else { return } + + // Request folder upload from server. + // The enumerator already omits the root folder, so report the full item count the server should expect. + let reportedItemCount = fileCount + print("HotlineState: Reporting \(reportedItemCount) items to server (enumerated count)") + guard let referenceNumber = try? await client.uploadFolder(name: folderName, path: path, fileCount: reportedItemCount, totalSize: UInt32(folderSize)), + let server = self.server, + let address = server.address as String?, + let port = server.port as Int? + else { + print("HotlineState: Failed to get upload reference from server") + return + } + + // Invalidate cache for the upload destination + self.invalidateFileListCache(for: path, includingAncestors: true) + + print("HotlineState: Got folder upload reference: \(referenceNumber)") + + // Create upload client + guard let uploadClient = HotlineFolderUploadClientNew( + folderURL: folderURL, + address: address, + port: UInt16(port), + reference: referenceNumber + ) else { + print("HotlineState: Failed to create folder upload client") + return + } + + // Create transfer info for tracking (stored globally in AppState) + let transfer = TransferInfo( + reference: referenceNumber, + title: folderName, + size: UInt(folderSize), + serverID: self.id, + serverName: self.serverName ?? self.serverTitle + ) + transfer.isFolder = true + transfer.uploadCallback = callback + transfer.progressCallback = progressCallback + AppState.shared.addTransfer(transfer) + + // Create and store the upload task + let uploadTask = Task { @MainActor [weak self] in + guard self != nil else { return } + + do { + // Upload folder with progress tracking + try await uploadClient.upload(progress: { progress in + switch progress { + case .preparing: + break + case .unconnected, .connected, .connecting: + break + case .transfer(name: _, size: _, total: _, progress: let progress, speed: let speed, estimate: let estimate): + transfer.timeRemaining = estimate + transfer.speed = speed + transfer.progress = progress + transfer.progressCallback?(transfer) + case .error(_): + transfer.failed = true + case .completed(url: _): + transfer.completed = true + } + }, itemProgress: { itemInfo in + // Update transfer title with current file being uploaded + transfer.title = "\(itemInfo.fileName) (\(itemInfo.itemNumber)/\(itemInfo.totalItems))" + itemProgressCallback?(transfer, itemInfo.fileName, itemInfo.itemNumber, itemInfo.totalItems) + }) + + // Mark as completed + transfer.progress = 1.0 + transfer.title = folderName // Reset title to folder name + + // Call completion callback + transfer.uploadCallback?(transfer) + + print("HotlineState: Folder upload complete - \(folderName)") + + } catch is CancellationError { + // Upload was cancelled + print("HotlineState: Folder upload cancelled") + } catch { + // Mark as failed + transfer.failed = true + print("HotlineState: Folder upload failed - \(error)") + } + + AppState.shared.unregisterTransferTask(for: transfer.id) + } + + // Store the task in AppState so it can be cancelled later + AppState.shared.registerTransferTask(uploadTask, transferID: transfer.id) + } + } + + func uploadFile(url fileURL: URL, path: [String], complete callback: ((TransferInfo) -> Void)? = nil) { + guard let client = self.client else { return } + + let fileName = fileURL.lastPathComponent + + guard fileURL.isFileURL, !fileName.isEmpty else { + print("HotlineState: Not a valid file URL") + return + } + + let filePath = fileURL.path(percentEncoded: false) + + var fileIsDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: filePath, isDirectory: &fileIsDirectory), + fileIsDirectory.boolValue == false else { + print("HotlineState: File is a directory") + return + } + + // Get the flattened file size (includes all forks and headers) + guard let payloadSize = FileManager.default.getFlattenedFileSize(fileURL) else { + print("HotlineState: Could not determine file size") + return + } + + Task { @MainActor [weak self] in + guard let self else { return } + + // Request upload from server + guard let referenceNumber = try? await client.uploadFile(name: fileName, path: path), + let server = self.server, + let address = server.address as String?, + let port = server.port as Int? + else { + print("HotlineState: Failed to get upload reference from server") + return + } + + // Invalidate cache for the upload destination + self.invalidateFileListCache(for: path, includingAncestors: true) + + print("HotlineState: Got upload reference: \(referenceNumber)") + + // Create upload client + guard let uploadClient = HotlineFileUploadClientNew( + fileURL: fileURL, + address: address, + port: UInt16(port), + reference: referenceNumber + ) else { + print("HotlineState: Failed to create upload client") + return + } + + // Create transfer info for tracking (stored globally in AppState) + let transfer = TransferInfo( + reference: referenceNumber, + title: fileName, + size: UInt(payloadSize), + serverID: self.id, + serverName: self.serverName ?? self.serverTitle + ) + transfer.uploadCallback = callback + AppState.shared.addTransfer(transfer) + + // Create and store the upload task + let uploadTask = Task { @MainActor [weak self] in + guard self != nil else { return } + + do { + // Upload file with progress tracking + try await uploadClient.upload { progress in + switch progress { + case .preparing: + break + case .unconnected, .connected, .connecting: + break + case .transfer(name: _, size: _, total: _, progress: let progress, speed: let speed, estimate: let estimate): + transfer.timeRemaining = estimate + transfer.speed = speed + transfer.progress = progress + case .error(_): + transfer.failed = true + case .completed(url: _): + transfer.completed = true + } + } + + // Mark as completed + transfer.progress = 1.0 + + // Call completion callback + transfer.uploadCallback?(transfer) + + print("HotlineState: Upload complete - \(fileName)") + + } catch is CancellationError { + // Upload was cancelled + print("HotlineState: Upload cancelled") + } catch { + // Mark as failed + transfer.failed = true + print("HotlineState: Upload failed - \(error)") + } + + AppState.shared.unregisterTransferTask(for: transfer.id) + } + + // Store the transfer + AppState.shared.registerTransferTask(uploadTask, transferID: transfer.id) + } + } + + func setFileInfo(fileName: String, path filePath: [String], fileNewName: String?, comment: String?, encoding: String.Encoding = .utf8) { + // TODO: Implement setFileInfo in HotlineClientNew + // This method updates file metadata (name and/or comment) + print("setFileInfo not yet implemented in HotlineState/HotlineClientNew") + } + + @MainActor + func previewFile(_ fileName: String, path: [String], complete callback: ((PreviewFileInfo?) -> Void)? = nil) { + guard let client = self.client else { + callback?(nil) + return + } + + var fullPath: [String] = [] + if path.count > 1 { + fullPath = Array(path[0.. [HotlineAccount] { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + self.accounts = try await client.getAccounts() + self.accountsLoaded = true + return self.accounts + } + + @MainActor + func createUser(name: String, login: String, password: String?, access: UInt64) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + try await client.createUser(name: name, login: login, password: password, access: access) + + // Refresh accounts list + self.accounts = try await client.getAccounts() + } + + @MainActor + func setUser(name: String, login: String, newLogin: String?, password: String?, access: UInt64) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + try await client.setUser(name: name, login: login, newLogin: newLogin, password: password, access: access) + + // Refresh accounts list + self.accounts = try await client.getAccounts() + } + + @MainActor + func deleteUser(login: String) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + try await client.deleteUser(login: login) + + // Refresh accounts list + self.accounts = try await client.getAccounts() + } + + // MARK: - Message Board + + @MainActor + func getMessageBoard() async throws -> [String] { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + self.messageBoard = try await client.getMessageBoard() + self.messageBoardLoaded = true + return self.messageBoard + } + + @MainActor + func postToMessageBoard(text: String) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + try await client.postMessageBoard(text) + } + + // MARK: - News + + @MainActor + func getNewsList(at path: [String] = []) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + let parentNewsGroup = self.findNews(in: self.news, at: path) + + // Send a categories request for bundle paths or root (empty path) + if path.isEmpty || parentNewsGroup?.type == .bundle { + print("HotlineState: Requesting categories at: /\(path.joined(separator: "/"))") + + let categories = try await client.getNewsCategories(path: path) + + // Create info for each category returned + var newCategoryInfos: [NewsInfo] = [] + + // Transform hotline categories into NewsInfo objects + for category in categories { + var newsCategoryInfo = NewsInfo(hotlineNewsCategory: category) + + if let lookupPath = newsCategoryInfo.lookupPath { + // Merge returned category info with existing category info + if let existingCategoryInfo = self.newsLookup[lookupPath] { + print("HotlineState: Merging category into existing category at \(lookupPath)") + + existingCategoryInfo.count = newsCategoryInfo.count + existingCategoryInfo.name = newsCategoryInfo.name + existingCategoryInfo.path = newsCategoryInfo.path + existingCategoryInfo.categoryID = newsCategoryInfo.categoryID + newsCategoryInfo = existingCategoryInfo + } else { + print("HotlineState: New category added at \(lookupPath)") + self.newsLookup[lookupPath] = newsCategoryInfo + } + } + + newCategoryInfos.append(newsCategoryInfo) + } + + if let parent = parentNewsGroup { + parent.children = newCategoryInfos + } else if path.isEmpty { + self.newsLoaded = true + self.news = newCategoryInfos + } + } else { + print("HotlineState: Requesting articles at: /\(path.joined(separator: "/"))") + + let articles = try await client.getNewsArticles(path: path) + + print("HotlineState: Organizing news at \(path.joined(separator: "/"))") + + // Create info for each article returned + var newArticleInfos: [NewsInfo] = [] + + for article in articles { + var newsArticleInfo = NewsInfo(hotlineNewsArticle: article) + + if let lookupPath = newsArticleInfo.lookupPath { + // Merge returned category info with existing category info + if let existingArticleInfo = self.newsLookup[lookupPath] { + print("HotlineState: Merging article into existing article at \(lookupPath)") + + existingArticleInfo.count = newsArticleInfo.count + existingArticleInfo.name = newsArticleInfo.name + existingArticleInfo.path = newsArticleInfo.path + existingArticleInfo.articleUsername = newsArticleInfo.articleUsername + existingArticleInfo.articleDate = newsArticleInfo.articleDate + existingArticleInfo.articleFlavors = newsArticleInfo.articleFlavors + existingArticleInfo.articleID = newsArticleInfo.articleID + newsArticleInfo = existingArticleInfo + } else { + print("HotlineState: New article added at \(lookupPath)") + self.newsLookup[lookupPath] = newsArticleInfo + } + } + + newArticleInfos.append(newsArticleInfo) + } + + let organizedNewsArticles: [NewsInfo] = self.organizeNewsArticles(newArticleInfos) + if let parent = parentNewsGroup { + parent.children = organizedNewsArticles + } + } + } + + @MainActor + func getNewsArticle(id articleID: UInt, at path: [String], flavor: String = "text/plain") async throws -> String? { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + return try await client.getNewsArticle(id: UInt32(articleID), path: path, flavor: flavor) + } + + @MainActor + func postNewsArticle(title: String, body: String, at path: [String], parentID: UInt32 = 0) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + try await client.postNewsArticle(title: title, text: body, path: path, parentID: parentID) + print("HotlineState: News article posted") + } + + // MARK: - File Search + + @MainActor + func startFileSearch(query: String) { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + self.cancelFileSearch() + return + } + + self.fileSearchSession?.cancel() + self.resetFileSearchState() + self.fileSearchQuery = trimmed + self.fileSearchStatus = .searching(processed: 0, pending: 0) + self.fileSearchScannedFolders = 0 + self.fileSearchCurrentPath = [] + + let session = HotlineStateFileSearchSession(hotlineState: self, query: trimmed, config: self.fileSearchConfig) + self.fileSearchSession = session + + Task { await session.start() } + } + + @MainActor + func cancelFileSearch(clearResults: Bool = true) { + guard let session = self.fileSearchSession else { + if clearResults { + self.resetFileSearchState() + } else if !self.fileSearchResults.isEmpty { + self.fileSearchStatus = .cancelled(processed: self.fileSearchScannedFolders) + self.fileSearchCurrentPath = nil + } + return + } + + session.cancel() + self.fileSearchSession = nil + self.fileSearchCurrentPath = nil + + if clearResults { + self.resetFileSearchState() + } else { + self.fileSearchStatus = .cancelled(processed: self.fileSearchScannedFolders) + } + } + + @MainActor + func clearFileListCache() { + guard !self.fileListCache.isEmpty else { + return + } + + self.fileListCache.removeAll(keepingCapacity: false) + } + + @MainActor + fileprivate func searchSession(_ session: HotlineStateFileSearchSession, didEmit matches: [FileInfo], processed: Int, pending: Int) { + guard self.fileSearchSession === session else { + return + } + + var appended: [FileInfo] = [] + for match in matches { + let key = self.searchPathKey(for: match.path) + if self.fileSearchResultKeys.insert(key).inserted { + appended.append(match) + } + } + + if !appended.isEmpty { + self.fileSearchResults.append(contentsOf: appended) + } + + self.fileSearchScannedFolders = processed + self.fileSearchStatus = .searching(processed: processed, pending: pending) + } + + @MainActor + fileprivate func searchSession(_ session: HotlineStateFileSearchSession, didFocusOn path: [String]) { + guard self.fileSearchSession === session else { + return + } + + self.fileSearchCurrentPath = path + } + + @MainActor + fileprivate func searchSessionDidFinish(_ session: HotlineStateFileSearchSession, processed: Int, pending: Int, completed: Bool) { + guard self.fileSearchSession === session else { + return + } + + self.fileSearchScannedFolders = processed + self.fileSearchSession = nil + self.fileSearchCurrentPath = nil + + if completed { + self.fileSearchStatus = .completed(processed: processed) + } else { + self.fileSearchStatus = .cancelled(processed: processed) + } + } + + fileprivate func cachedListingForSearch(path: [String], ttl: TimeInterval) -> (items: [FileInfo], isFresh: Bool)? { + self.cachedFileList(for: path, ttl: ttl, allowStale: true) + } + + // MARK: - Event Handlers + + private func handleChatMessage(_ text: String) { + if Prefs.shared.playSounds && Prefs.shared.playChatSound { + SoundEffectPlayer.shared.playSoundEffect(.chatMessage) + } + + let chatMessage = ChatMessage(text: text, type: .message, date: Date()) + self.recordChatMessage(chatMessage) + self.unreadPublicChat = true + } + + private func handleUserChanged(_ user: HotlineUser) { + self.addOrUpdateHotlineUser(user) + } + + private func handleUserDisconnected(_ userID: UInt16) { + if let existingUserIndex = self.users.firstIndex(where: { $0.id == UInt(userID) }) { + let user = self.users.remove(at: existingUserIndex) + + if Prefs.shared.showJoinLeaveMessages { + let chatMessage = ChatMessage(text: "\(user.name) left", type: .left, date: Date()) + self.recordChatMessage(chatMessage) + } + + if Prefs.shared.playSounds && Prefs.shared.playLeaveSound { + SoundEffectPlayer.shared.playSoundEffect(.userLogout) + } + } + } + + private func handleServerMessage(_ message: String) { + if Prefs.shared.playSounds && Prefs.shared.playChatSound { + SoundEffectPlayer.shared.playSoundEffect(.serverMessage) + } + + print("HotlineState: received server message:\n\(message)") + let chatMessage = ChatMessage(text: message, type: .server, date: Date()) + self.recordChatMessage(chatMessage) + } + + private func handlePrivateMessage(userID: UInt16, message: String) { + if let existingUserIndex = self.users.firstIndex(where: { $0.id == UInt(userID) }) { + let user = self.users[existingUserIndex] + print("HotlineState: received private message from \(user.name): \(message)") + + if Prefs.shared.playPrivateMessageSound { + if self.unreadInstantMessages[userID] == nil { + SoundEffectPlayer.shared.playSoundEffect(.serverMessage) + } else { + SoundEffectPlayer.shared.playSoundEffect(.chatMessage) + } + } + + let instantMessage = InstantMessage( + direction: .incoming, + text: message.convertingLinksToMarkdown(), + type: .message, + date: Date() + ) + + if self.instantMessages[userID] == nil { + self.instantMessages[userID] = [instantMessage] + } else { + self.instantMessages[userID]!.append(instantMessage) + } + + self.unreadInstantMessages[userID] = userID + } + } + + private func handleNewsPost(_ message: String) { + let messageBoardRegex = /([\s\r\n]*[_\-]+[\s\r\n]+)/ + let matches = message.matches(of: messageBoardRegex) + + if matches.count == 1 { + let range = matches[0].range + self.messageBoard.insert(String(message[message.startIndex.. ChatStore.SessionKey { + ChatStore.SessionKey(address: server.address.lowercased(), port: server.port) + } + + private func recordChatMessage(_ message: ChatMessage, persist: Bool = true, display: Bool = true) { + let shouldPersist = persist && message.type != .agreement + if shouldPersist, + message.type == .signOut, + self.lastPersistedMessageType == .signOut { + return + } + + if display { + self.chat.append(message) + } + + guard shouldPersist, let key = self.chatSessionKey else { return } + self.lastPersistedMessageType = message.type + + let entry = ChatStore.Entry( + id: message.id, + body: message.text, + username: message.username, + type: message.type.storageKey, + date: message.date + ) + let serverName = self.serverName ?? self.server?.name + + Task { + await ChatStore.shared.append(entry: entry, for: key, serverName: serverName) + } + } + + private func restoreChatHistory(for key: ChatStore.SessionKey) { + if self.restoredChatSessionKey == key { + return + } + + Task { [weak self] in + guard let self else { return } + let result = await ChatStore.shared.loadHistory(for: key) + + await MainActor.run { + guard self.chatSessionKey == key, self.restoredChatSessionKey != key else { return } + + let currentMessages = self.chat + let historyMessages = result.entries.compactMap { entry -> ChatMessage? in + guard let chatType = ChatMessageType(storageKey: entry.type) else { return nil } + + let renderedText: String + if chatType == .message, let username = entry.username, !username.isEmpty { + renderedText = "\(username): \(entry.body)" + } else { + renderedText = entry.body + } + + var message = ChatMessage(text: renderedText, type: chatType, date: entry.date) + message.metadata = entry.metadata + return message + } + + self.chat = historyMessages + currentMessages + self.lastPersistedMessageType = historyMessages.last?.type + self.unreadPublicChat = false + self.restoredChatSessionKey = key + } + } + } + + private func handleChatHistoryCleared() { + self.chat = [] + self.unreadPublicChat = false + self.restoredChatSessionKey = nil + self.lastPersistedMessageType = nil + } + + // MARK: - Utilities + + func updateServerTitle() { + self.serverTitle = self.serverName ?? self.server?.name ?? self.server?.address ?? "Server" + } + + // News helpers + func organizeNewsArticles(_ flatArticles: [NewsInfo]) -> [NewsInfo] { + // Place articles under their parent + var organized: [NewsInfo] = [] + for article in flatArticles { + if let parentLookupPath = article.parentArticleLookupPath, + let parentArticle = self.newsLookup[parentLookupPath] { + if parentArticle.children.firstIndex(of: article) == nil { + article.expanded = true + parentArticle.children.append(article) + } + } else { + organized.append(article) + } + } + + return organized + } + + private func findNews(in newsToSearch: [NewsInfo], at path: [String]) -> NewsInfo? { + guard !path.isEmpty, !newsToSearch.isEmpty, let currentName = path.first else { return nil } + + for news in newsToSearch { + if news.name == currentName { + if path.count == 1 { + return news + } else if !news.children.isEmpty { + let remainingPath = Array(path[1...]) + return self.findNews(in: news.children, at: remainingPath) + } + } + } + + return nil + } + + // File helpers + private func findFile(in filesToSearch: [FileInfo], at path: [String]) -> FileInfo? { + guard !path.isEmpty, !filesToSearch.isEmpty else { return nil } + + let currentName = path[0] + + for file in filesToSearch { + if file.name == currentName { + if path.count == 1 { + return file + } else if let subfiles = file.children { + let remainingPath = Array(path[1...]) + return self.findFile(in: subfiles, at: remainingPath) + } + } + } + + return nil + } + + // File search helpers + private func searchPathKey(for path: [String]) -> String { + path.joined(separator: "\u{001F}") + } + + private func resetFileSearchState() { + self.fileSearchResults = [] + self.fileSearchResultKeys.removeAll(keepingCapacity: true) + self.fileSearchStatus = .idle + self.fileSearchQuery = "" + self.fileSearchScannedFolders = 0 + self.fileSearchCurrentPath = nil + } + + // File cache helpers + private func shouldBypassFileCache(for path: [String]) -> Bool { + guard let folderName = path.last else { + return false + } + + let trimmed = folderName.trimmingCharacters(in: .whitespacesAndNewlines) + + if trimmed.range(of: "upload", options: [.caseInsensitive]) != nil { + return true + } + + if trimmed.range(of: "dropbox", options: [.caseInsensitive]) != nil { + return true + } + + if trimmed.range(of: "drop box", options: [.caseInsensitive]) != nil { + return true + } + + return false + } + + private func cachedFileList(for path: [String], ttl: TimeInterval, allowStale: Bool) -> (items: [FileInfo], isFresh: Bool)? { + guard ttl > 0 else { + return nil + } + + if self.shouldBypassFileCache(for: path) { + return nil + } + + let key = self.searchPathKey(for: path) + guard let entry = self.fileListCache[key] else { + return nil + } + + let age = Date().timeIntervalSince(entry.timestamp) + let isFresh = age <= ttl + if !allowStale && !isFresh { + return nil + } + + return (entry.files, isFresh) + } + + private func storeFileListInCache(_ files: [FileInfo], for path: [String]) { + guard self.fileSearchConfig.cacheTTL > 0 else { + return + } + + if self.shouldBypassFileCache(for: path) { + return + } + + let key = self.searchPathKey(for: path) + self.fileListCache[key] = FileListCacheEntry(files: files, timestamp: Date()) + self.pruneFileListCacheIfNeeded() + } + + private func pruneFileListCacheIfNeeded() { + let limit = self.fileSearchConfig.maxCachedFolders + guard limit > 0, self.fileListCache.count > limit else { + return + } + + let excess = self.fileListCache.count - limit + guard excess > 0 else { return } + + let sortedKeys = self.fileListCache.sorted { lhs, rhs in + lhs.value.timestamp < rhs.value.timestamp + } + + for index in 0.. = [] + private var loopHistogram: [String: Int] = [:] + + private var processedCount: Int = 0 + private var currentDelay: TimeInterval + private var isCancelled = false + + init(hotlineState: HotlineState, query: String, config: FileSearchConfig) { + self.hotlineState = hotlineState + self.queryTokens = query.lowercased().split(separator: " ").map(String.init) + self.config = config + self.currentDelay = config.initialDelay + } + + func start() async { + guard let hotlineState else { + return + } + + await Task.yield() + + if !hotlineState.filesLoaded { + hotlineState.searchSession(self, didFocusOn: []) + let rootFiles = try? await hotlineState.getFileList(path: [], suppressErrors: true, preferCache: true) + self.processedCount = max(self.processedCount, 1) + self.processListing(rootFiles ?? [], depth: 0, parentPath: [], parentIsHot: false) + } else { + hotlineState.searchSession(self, didFocusOn: []) + self.processedCount = max(self.processedCount, 1) + self.processListing(hotlineState.files, depth: 0, parentPath: [], parentIsHot: false) + } + + while !self.queue.isEmpty && !self.isCancelled { + await Task.yield() + + guard let task = self.dequeueNextTask() else { + continue + } + + if self.shouldSkip(path: task.path, depth: task.depth) { + hotlineState.searchSession(self, didEmit: [], processed: self.processedCount, pending: self.queue.count) + continue + } + + hotlineState.searchSession(self, didFocusOn: task.path) + self.visited.insert(self.pathKey(for: task.path)) + + if let cached = hotlineState.cachedListingForSearch(path: task.path, ttl: self.config.cacheTTL) { + if cached.isFresh { + self.processedCount += 1 + self.processListing(cached.items, depth: task.depth, parentPath: task.path, parentIsHot: task.isHot) + continue + } else { + self.processListing(cached.items, depth: task.depth, parentPath: task.path, parentIsHot: task.isHot) + } + } + + let children = try? await hotlineState.getFileList(path: task.path, suppressErrors: true) + self.processedCount += 1 + + if self.isCancelled { + break + } + + self.processListing(children ?? [], depth: task.depth, parentPath: task.path, parentIsHot: task.isHot) + + await self.applyBackoff() + } + + hotlineState.searchSessionDidFinish(self, processed: self.processedCount, pending: self.queue.count, completed: !self.isCancelled) + } + + func cancel() { + self.isCancelled = true + } + + private func processListing(_ items: [FileInfo], depth: Int, parentPath: [String], parentIsHot: Bool) { + guard let hotlineState else { + return + } + + var matches: [FileInfo] = [] + var folderEntries: [(file: FileInfo, isHot: Bool)] = [] + var hasFileMatch = false + + for file in items { + let matchesName = self.nameMatchesQuery(file.name) + + if matchesName { + matches.append(file) + if !file.isFolder { + hasFileMatch = true + } + } + + if file.isFolder && !file.isAppBundle { + folderEntries.append((file, matchesName)) + } + } + + var remainingBurst = 0 + if self.config.hotBurstLimit > 0 && (parentIsHot || hasFileMatch) { + remainingBurst = self.config.hotBurstLimit + } + + if remainingBurst > 0 { + var candidateIndices: [Int] = [] + for index in folderEntries.indices where !folderEntries[index].isHot { + candidateIndices.append(index) + } + + if !candidateIndices.isEmpty { + candidateIndices.shuffle() + for index in candidateIndices { + folderEntries[index].isHot = true + remainingBurst -= 1 + if remainingBurst == 0 { + break + } + } + } + } + + for entry in folderEntries { + self.enqueueFolder(entry.file, depth: depth + 1, markHot: entry.isHot) + } + + hotlineState.searchSession(self, didEmit: matches, processed: self.processedCount, pending: self.queue.count) + } + + private func enqueueFolder(_ folder: FileInfo, depth: Int, markHot: Bool) { + guard !self.isCancelled else { return } + guard depth <= self.config.maxDepth else { return } + + let path = folder.path + let key = self.pathKey(for: path) + guard !self.visited.contains(key) else { return } + + if self.exceedsLoopThreshold(for: path) { + return + } + + self.queue.append(FolderTask(path: path, depth: depth, isHot: markHot)) + } + + private func dequeueNextTask() -> FolderTask? { + guard !self.queue.isEmpty else { + return nil + } + + if self.queue.count == 1 { + return self.queue.removeFirst() + } + + let currentDepth = self.queue[0].depth + var lastSameDepthIndex = 0 + var hotIndices: [Int] = [] + + for index in 0.. Bool { + if self.isCancelled { + return true + } + + if depth > self.config.maxDepth { + return true + } + + let key = self.pathKey(for: path) + if self.visited.contains(key) { + return true + } + + return false + } + + private func nameMatchesQuery(_ name: String) -> Bool { + guard !self.queryTokens.isEmpty else { return false } + let lowercased = name.lowercased() + return self.queryTokens.allSatisfy { lowercased.contains($0) } + } + + private func exceedsLoopThreshold(for path: [String]) -> Bool { + guard self.config.loopRepetitionLimit > 0 else { return false } + guard let last = path.last else { return false } + let parent = path.dropLast() + + guard let previousIndex = parent.lastIndex(of: last) else { + return false + } + + let suffix = Array(path[previousIndex...]) + let key = suffix.joined(separator: "\u{001F}") + let count = (self.loopHistogram[key] ?? 0) + 1 + self.loopHistogram[key] = count + return count > self.config.loopRepetitionLimit + } + + private func pathKey(for path: [String]) -> String { + path.joined(separator: "\u{001F}") + } + + private func applyBackoff() async { + guard !self.isCancelled else { return } + + if self.processedCount > self.config.initialBurstCount { + self.currentDelay = min(self.config.maxDelay, max(self.config.initialDelay, self.currentDelay * self.config.backoffMultiplier)) + } + + guard self.currentDelay > 0 else { + return + } + + let nanoseconds = UInt64(self.currentDelay * 1_000_000_000) + try? await Task.sleep(nanoseconds: nanoseconds) + } +} diff --git a/Hotline/macOS/Board/MessageBoardView.swift b/Hotline/macOS/Board/MessageBoardView.swift index 8788d66..710ff20 100644 --- a/Hotline/macOS/Board/MessageBoardView.swift +++ b/Hotline/macOS/Board/MessageBoardView.swift @@ -8,91 +8,82 @@ struct MessageBoardView: View { var body: some View { NavigationStack { - if model.access?.contains(.canReadMessageBoard) != false { - ScrollView { - LazyVStack(alignment: .leading) { - ForEach(model.messageBoard, id: \.self) { msg in - Text(LocalizedStringKey(msg)) - .tint(Color("Link Color")) - .lineLimit(100) - .lineSpacing(4) - .padding() - .textSelection(.enabled) - Divider() - } - } - Spacer() + if self.model.access?.contains(.canReadMessageBoard) != false { + if self.model.messageBoardLoaded && self.model.messageBoard.isEmpty { + self.emptyBoardView } - .task { - if !model.messageBoardLoaded { - let _ = try? await model.getMessageBoard() - } + else { + self.messageBoardView } - .overlay { - if !model.messageBoardLoaded { - VStack { - ProgressView() - .controlSize(.large) - } - .frame(maxWidth: .infinity) - } - } - .background(Color(nsColor: .textBackgroundColor)) } else { - ZStack(alignment: .center) { - Text("No Message Board") - .font(.title) - .multilineTextAlignment(.center) - .foregroundStyle(.secondary) - .padding() - } - .frame(maxWidth: .infinity) + self.disabledBoardView } } .sheet(isPresented: $composerDisplayed) { MessageBoardEditorView() .frame(maxWidth: .infinity, maxHeight: .infinity) .frame(idealWidth: 450, idealHeight: 350) -// RichTextEditor(text: $composerText) -// .richEditorFont(NSFont.systemFont(ofSize: 16.0)) -// .richEditorAutomaticDashSubstitution(false) -// .richEditorAutomaticQuoteSubstitution(false) -// .richEditorAutomaticSpellingCorrection(false) -// .background(Color(nsColor: .textBackgroundColor)) -// .frame(maxWidth: .infinity, maxHeight: .infinity) -// .frame(idealWidth: 450, idealHeight: 350) -// .toolbar { -// ToolbarItem(placement: .cancellationAction) { -// Button("Cancel") { -// composerDisplayed.toggle() -// } -// } -// -// ToolbarItem(placement: .primaryAction) { -// Button("Post") { -// composerDisplayed.toggle() -// let text = composerText -// composerText = "" -// model.postToMessageBoard(text: text) -// Task { -// await model.getMessageBoard() -// } -// } -// } -// } } .toolbar { ToolbarItem(placement:.primaryAction) { Button { - composerDisplayed.toggle() + self.composerDisplayed.toggle() } label: { Image(systemName: "square.and.pencil") } - .disabled(model.access?.contains(.canPostMessageBoard) == false) + .disabled(self.model.access?.contains(.canPostMessageBoard) == false) .help("Post to Message Board") } } + .task { + if !self.model.messageBoardLoaded { + let _ = try? await self.model.getMessageBoard() + } + } + } + + private var disabledBoardView: some View { + ContentUnavailableView { + Label("Message Board Disabled", systemImage: "quote.bubble") + } description: { + Text("This server has turned off the message board") + } + } + + private var emptyBoardView: some View { + ContentUnavailableView { + Label("No Posts", systemImage: "quote.bubble") + } description: { + Text("Message board posts will appear here") + } + } + + private var messageBoardView: some View { + ScrollView { + LazyVStack(alignment: .leading) { + ForEach(self.model.messageBoard, id: \.self) { msg in + Text(LocalizedStringKey(msg)) + .tint(Color("Link Color")) + .lineLimit(100) + .lineSpacing(4) + .padding() + .textSelection(.enabled) + Divider() + } + } + Spacer() + } + .overlay { + if !self.model.messageBoardLoaded { + VStack { + ProgressView() + .controlSize(.large) + } + .frame(maxWidth: .infinity) + } + } + .background(Color(nsColor: .textBackgroundColor)) } } diff --git a/Hotline/macOS/Files/FilesView.swift b/Hotline/macOS/Files/FilesView.swift index b499fe6..d4ba5c8 100644 --- a/Hotline/macOS/Files/FilesView.swift +++ b/Hotline/macOS/Files/FilesView.swift @@ -16,142 +16,6 @@ struct FilesView: View { @State private var searchText: String = "" @State private var isSearching: Bool = false @State private var dragOver: Bool = false - - private var isShowingSearchResults: Bool { - switch model.fileSearchStatus { - case .idle: - return !model.fileSearchResults.isEmpty - case .cancelled(_): - return !model.fileSearchResults.isEmpty - default: - return true - } - } - - private var displayedFiles: [FileInfo] { - isShowingSearchResults ? model.fileSearchResults : model.files - } - - private var searchStatusMessage: String? { - switch model.fileSearchStatus { - case .searching(let processed, _): - let scanned = processed == 1 ? "folder" : "folders" - return "Searched \(processed) \(scanned)..." - case .completed(let processed): - let count = model.fileSearchResults.count - let folderWord = processed == 1 ? "folder" : "folders" - if count == 0 { - return "No files found in \(processed) \(folderWord)" - } - return "\(count) file\(count == 1 ? "" : "s") found in \(processed) \(folderWord)" - case .cancelled(_): - if model.fileSearchResults.isEmpty { - return nil - } - return "Search cancelled" - case .failed(let message): - return "Search failed: \(message)" - case .idle: - return nil - } - } - - private var searchStatusPath: String? { - guard let path = model.fileSearchCurrentPath else { - return nil - } - if path.isEmpty { - return "/" - } - return path.joined(separator: "/") - } - - private func openPreviewWindow(_ previewInfo: PreviewFileInfo) { - switch previewInfo.previewType { - case .image: - openWindow(id: "preview-quicklook", value: previewInfo) - case .text: - openWindow(id: "preview-quicklook", value: previewInfo) - case .unknown: - openWindow(id: "preview-quicklook", value: previewInfo) - return - } - } - - @MainActor private func getFileInfo(_ file: FileInfo) { - Task { - if let fileInfo = try? await model.getFileDetails(file.name, path: file.path) { - Task { @MainActor in - self.fileDetails = fileInfo - } - } - } - } - - @MainActor private func downloadFile(_ file: FileInfo) { - if file.isFolder { - model.downloadFolderNew(file.name, path: file.path) - } - else { - model.downloadFileNew(file.name, path: file.path) - } - } - - @MainActor private func uploadFile(file fileURL: URL, to path: [String]) { - model.uploadFile(url: fileURL, path: path) { info in - Task { - // Refresh file listing to display newly uploaded file. - let _ = try? await model.getFileList(path: path) - } - } - } - - @MainActor private func upload(file fileURL: URL, to path: [String]) { - var fileIsDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false), isDirectory: &fileIsDirectory) else { - return - } - - if fileIsDirectory.boolValue { - self.model.uploadFolder(url: fileURL, path: path, complete: { info in - Task { - // Refresh file listing to display newly uploaded file. - try? await model.getFileList(path: path) - } - }) - } - else { - self.model.uploadFile(url: fileURL, path: path) { info in - Task { - // Refresh file listing to display newly uploaded file. - try? await model.getFileList(path: path) - } - } - } - } - - @MainActor private func previewFile(_ file: FileInfo) { - guard file.isPreviewable else { - return - } - - model.previewFile(file.name, path: file.path) { info in - if let info = info { - openPreviewWindow(info) - } - } - } - - private func deleteFile(_ file: FileInfo) async { - var parentPath: [String] = [] - if file.path.count > 1 { - parentPath = Array(file.path[0.. 1 { + parentPath = Array(file.path[0.. Date: Fri, 7 Nov 2025 11:35:59 -0800 Subject: Rename NetSocketNew as NetSocket (old CFStream based NetSocket is gone!) --- Hotline/Hotline/HotlineClientNew.swift | 6 ++-- Hotline/Hotline/HotlineExtensions.swift | 4 +-- Hotline/Hotline/HotlineProtocol.swift | 6 ++-- Hotline/Hotline/HotlineTrackerClient.swift | 2 +- .../Transfers/HotlineFileDownloadClientNew.swift | 8 ++--- .../Transfers/HotlineFilePreviewClientNew.swift | 2 +- .../Transfers/HotlineFileUploadClientNew.swift | 6 ++-- .../Transfers/HotlineFolderDownloadClientNew.swift | 12 +++---- .../Transfers/HotlineFolderUploadClientNew.swift | 10 +++--- Hotline/Library/NetSocket/FileProgress.swift | 2 +- Hotline/Library/NetSocket/NetSocketNew.swift | 38 ++++++++++------------ .../Library/NetSocket/TransferRateEstimator.swift | 8 ++--- Hotline/macOS/ServerView.swift | 1 + 13 files changed, 52 insertions(+), 53 deletions(-) diff --git a/Hotline/Hotline/HotlineClientNew.swift b/Hotline/Hotline/HotlineClientNew.swift index 9e60b5e..8642d42 100644 --- a/Hotline/Hotline/HotlineClientNew.swift +++ b/Hotline/Hotline/HotlineClientNew.swift @@ -126,7 +126,7 @@ public struct HotlineServerInfo: Sendable { public actor HotlineClientNew { // MARK: - Properties - private let socket: NetSocketNew + private let socket: NetSocket private var serverInfo: HotlineServerInfo? private var isConnected: Bool = true @@ -188,7 +188,7 @@ public actor HotlineClientNew { // Connect socket print("HotlineClientNew.connect(): Connecting socket...") - let socket = try await NetSocketNew.connect(host: host, port: port, tls: tls) + let socket = try await NetSocket.connect(host: host, port: port, tls: tls) print("HotlineClientNew.connect(): Socket connected") // Perform handshake @@ -240,7 +240,7 @@ public actor HotlineClientNew { return client } - private init(socket: NetSocketNew) { + private init(socket: NetSocket) { self.socket = socket // Set up event stream diff --git a/Hotline/Hotline/HotlineExtensions.swift b/Hotline/Hotline/HotlineExtensions.swift index 16e9583..049ebf9 100644 --- a/Hotline/Hotline/HotlineExtensions.swift +++ b/Hotline/Hotline/HotlineExtensions.swift @@ -1107,7 +1107,7 @@ extension FourCharCode { } -extension NetSocketNew { +extension NetSocket { /// Read a pascal string (1-byte length prefix followed by string data) /// /// This method reads a single byte for the length, then reads that many bytes and attempts @@ -1145,7 +1145,7 @@ extension NetSocketNew { // Fallback to MacRoman for classic Mac compatibility guard let str = String(data: data, encoding: .macOSRoman) else { throw NetSocketError.decodeFailed(NSError( - domain: "NetSocketNew", + domain: "NetSocket", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to decode pascal string with any known encoding"] )) diff --git a/Hotline/Hotline/HotlineProtocol.swift b/Hotline/Hotline/HotlineProtocol.swift index 43f018e..927cf69 100644 --- a/Hotline/Hotline/HotlineProtocol.swift +++ b/Hotline/Hotline/HotlineProtocol.swift @@ -188,7 +188,7 @@ struct HotlineServer: Identifiable, Hashable, NetSocketDecodable { hasher.combine(self.id) } - init(from socket: NetSocketNew, endian: Endian) async throws { + init(from socket: NetSocket, endian: Endian) async throws { // Read IP address (4 individual bytes) let ip1 = try await socket.read(UInt8.self) let ip2 = try await socket.read(UInt8.self) @@ -986,7 +986,7 @@ extension HotlineTransaction: NetSocketDecodable { /// Decode a Hotline transaction directly from the socket stream /// /// Reads the 20-byte header, then reads and decodes the variable-length body with fields. - init(from socket: NetSocketNew, endian: Endian) async throws { + init(from socket: NetSocket, endian: Endian) async throws { // Read 20-byte header let flags = try await socket.read(UInt8.self) let isReply = try await socket.read(UInt8.self) @@ -1173,7 +1173,7 @@ enum HotlineTransactionType: UInt16 { // /// - Unused: 2 bytes // /// - Server name: Pascal string (1-byte length + data) // /// - Server description: Pascal string (1-byte length + data) -// init(from socket: NetSocketNew, endian: Endian) async throws { +// init(from socket: NetSocket, endian: Endian) async throws { // // Read IP address (4 individual bytes) // let ip1 = try await socket.read(UInt8.self) // let ip2 = try await socket.read(UInt8.self) diff --git a/Hotline/Hotline/HotlineTrackerClient.swift b/Hotline/Hotline/HotlineTrackerClient.swift index 52d34c1..f72735d 100644 --- a/Hotline/Hotline/HotlineTrackerClient.swift +++ b/Hotline/Hotline/HotlineTrackerClient.swift @@ -72,7 +72,7 @@ class HotlineTrackerClient { private func doFetch(address: String, port: Int, continuation: AsyncThrowingStream.Continuation) async throws { // Connect to tracker (plaintext, no TLS) - let socket = try await NetSocketNew.connect( + let socket = try await NetSocket.connect( host: address, port: UInt16(port), tls: .disabled diff --git a/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift index ced79a1..77d2e38 100644 --- a/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift @@ -39,7 +39,7 @@ public class HotlineFileDownloadClientNew { private let transferTotal: Int private var transferProgress: Progress - private var socket: NetSocketNew? + private var socket: NetSocket? private var downloadTask: Task? // MARK: - Initialization @@ -251,14 +251,14 @@ public class HotlineFileDownloadClientNew { // MARK: - Helper Methods - private func connectToTransferServer() async throws -> NetSocketNew { + private func connectToTransferServer() async throws -> NetSocket { guard let transferPort = NWEndpoint.Port(rawValue: serverPort + 1) else { throw NetSocketError.invalidPort } print("HotlineFileDownloadClientNew[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)") - let socket = try await NetSocketNew.connect( + let socket = try await NetSocket.connect( host: .name(serverAddress, nil), port: transferPort, tls: .disabled @@ -268,7 +268,7 @@ public class HotlineFileDownloadClientNew { return socket } - private func sendMagicHeader(socket: NetSocketNew) async throws { + private func sendMagicHeader(socket: NetSocket) async throws { let header = Data(endian: .big) { "HTXF".fourCharCode() referenceNumber diff --git a/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift b/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift index 9839997..63fe099 100644 --- a/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift @@ -101,7 +101,7 @@ public class HotlineFilePreviewClientNew { print("HotlineFilePreviewClientNew[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)") - let socket = try await NetSocketNew.connect( + let socket = try await NetSocket.connect( host: .name(serverAddress, nil), port: transferPort, tls: .disabled diff --git a/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift index 10934e7..5acff2a 100644 --- a/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift @@ -24,7 +24,7 @@ public class HotlineFileUploadClientNew { private let transferTotal: Int private var transferProgress: Progress - private var socket: NetSocketNew? + private var socket: NetSocket? private var uploadTask: Task? // MARK: - Initialization @@ -236,14 +236,14 @@ public class HotlineFileUploadClientNew { // MARK: - Helper Methods - private func connectToTransferServer() async throws -> NetSocketNew { + private func connectToTransferServer() async throws -> NetSocket { guard let transferPort = NWEndpoint.Port(rawValue: serverPort + 1) else { throw NetSocketError.invalidPort } print("HotlineFileUploadClientNew[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)") - let socket = try await NetSocketNew.connect( + let socket = try await NetSocket.connect( host: .name(serverAddress, nil), port: transferPort, tls: .disabled diff --git a/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift index 4ad35e7..254a1c6 100644 --- a/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift @@ -20,7 +20,7 @@ public class HotlineFolderDownloadClientNew { private let folderItemCount: Int private var transferSize: Int = 0 - private var socket: NetSocketNew? + private var socket: NetSocket? private var downloadTask: Task? private var folderProgress: Progress? @@ -245,14 +245,14 @@ public class HotlineFolderDownloadClientNew { // MARK: - Helper Methods - private func connectToTransferServer() async throws -> NetSocketNew { + private func connectToTransferServer() async throws -> NetSocket { guard let transferPort = NWEndpoint.Port(rawValue: serverPort + 1) else { throw NetSocketError.invalidPort } print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)") - let socket = try await NetSocketNew.connect( + let socket = try await NetSocket.connect( host: .name(serverAddress, nil), port: transferPort, tls: .disabled @@ -262,7 +262,7 @@ public class HotlineFolderDownloadClientNew { return socket } - private func sendAction(socket: NetSocketNew, action: HotlineFolderAction) async throws { + private func sendAction(socket: NetSocket, action: HotlineFolderAction) async throws { let actionData = Data(endian: .big) { action.rawValue } @@ -297,7 +297,7 @@ public class HotlineFolderDownloadClientNew { } private func downloadFile( - socket: NetSocketNew, + socket: NetSocket, fileName: String, parentPath: [String], destinationFolder: URL, @@ -383,7 +383,7 @@ public class HotlineFolderDownloadClientNew { fileDataForkSize = Int(forkHeader.dataSize) - // Stream data fork using NetSocketNew's optimized file streaming + // Stream data fork using NetSocket's optimized file streaming let updates = await socket.receiveFile(to: fh, length: fileDataForkSize) for try await p in updates { // Calculate overall folder progress diff --git a/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift index 2efced2..38532f1 100644 --- a/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift @@ -38,7 +38,7 @@ public class HotlineFolderUploadClientNew { private var folderItems: [FolderItem] = [] private var totalItems: Int = 0 - private var socket: NetSocketNew? + private var socket: NetSocket? private var uploadTask: Task? // MARK: - Initialization @@ -281,12 +281,12 @@ public class HotlineFolderUploadClientNew { progressHandler?(.completed(url: nil)) } - private func connect(address: String, port: UInt16) async throws -> NetSocketNew { + private func connect(address: String, port: UInt16) async throws -> NetSocket { guard let transferPort = NWEndpoint.Port(rawValue: port + 1) else { throw NetSocketError.invalidPort } - return try await NetSocketNew.connect( + return try await NetSocket.connect( host: .name(address, nil), port: transferPort, tls: .disabled @@ -361,7 +361,7 @@ public class HotlineFolderUploadClientNew { } } - private func readAction(socket: NetSocketNew) async throws -> HotlineFolderAction { + private func readAction(socket: NetSocket) async throws -> HotlineFolderAction { let actionData = try await socket.read(2) guard let rawAction = actionData.readUInt16(at: 0), let action = HotlineFolderAction(rawValue: rawAction) else { @@ -371,7 +371,7 @@ public class HotlineFolderUploadClientNew { } private func uploadFile( - socket: NetSocketNew, + socket: NetSocket, fileURL: URL, itemNumber: Int, totalItems: Int, diff --git a/Hotline/Library/NetSocket/FileProgress.swift b/Hotline/Library/NetSocket/FileProgress.swift index c086af7..1cc0228 100644 --- a/Hotline/Library/NetSocket/FileProgress.swift +++ b/Hotline/Library/NetSocket/FileProgress.swift @@ -4,7 +4,7 @@ import Foundation -public extension NetSocketNew { +public extension NetSocket { /// Progress information for file uploads/downloads struct FileProgress: Sendable { diff --git a/Hotline/Library/NetSocket/NetSocketNew.swift b/Hotline/Library/NetSocket/NetSocketNew.swift index 7b24b37..12f4271 100644 --- a/Hotline/Library/NetSocket/NetSocketNew.swift +++ b/Hotline/Library/NetSocket/NetSocketNew.swift @@ -1,4 +1,4 @@ -// NetSocketNew.swift +// NetSocket.swift // Dustin Mierau • @mierau import Foundation @@ -86,11 +86,9 @@ public enum NetSocketError: Error, CustomStringConvertible, Sendable { } } -// MARK: - NetSocketNew - /// An async/await TCP socket with automatic buffering and framing support /// -/// NetSocketNew provides: +/// NetSocket provides: /// - Async connection management /// - Automatic receive buffering with memory compaction /// - Type-safe reading/writing of integers, strings, and custom types @@ -98,11 +96,11 @@ public enum NetSocketError: Error, CustomStringConvertible, Sendable { /// /// Example usage: /// ```swift -/// let socket = try await NetSocketNew.connect(host: "example.com", port: 80) +/// let socket = try await NetSocket.connect(host: "example.com", port: 80) /// try await socket.write("Hello\n".data(using: .utf8)!) /// let response = try await socket.readUntil(delimiter: .lineFeed) /// ``` -public actor NetSocketNew { +public actor NetSocket { /// Configuration options for the socket public struct Config: Sendable { /// Size of chunks to receive from network at once (default: 64 KB) @@ -153,9 +151,9 @@ public actor NetSocketNew { /// - port: Network framework port /// - tls: TLS policy (default: enabled with default settings) /// - config: Socket configuration (default: standard settings) - /// - Returns: A connected and ready `NetSocketNew` + /// - Returns: A connected and ready `NetSocket` /// - Throws: Network errors or connection failures - public static func connect(host: NWEndpoint.Host, port: NWEndpoint.Port, tls: TLSPolicy = .enabled(), config: Config = .init()) async throws -> NetSocketNew { + public static func connect(host: NWEndpoint.Host, port: NWEndpoint.Port, tls: TLSPolicy = .enabled(), config: Config = .init()) async throws -> NetSocket { let parameters = NWParameters.tcp if tls.enabled { let tlsOptions = NWProtocolTLS.Options() @@ -164,13 +162,13 @@ public actor NetSocketNew { } let conn = NWConnection(host: host, port: port, using: parameters) - let socket = NetSocketNew(connection: conn, config: config) + let socket = NetSocket(connection: conn, config: config) try await socket.start() return socket } /// Convenience wrapper to connect using string hostname and integer port - public static func connect(host: String, port: UInt16, tls: TLSPolicy = .enabled(), config: Config = .init()) async throws -> NetSocketNew { + public static func connect(host: String, port: UInt16, tls: TLSPolicy = .enabled(), config: Config = .init()) async throws -> NetSocket { guard let nwPort = NWEndpoint.Port(rawValue: port) else { throw NetSocketError.invalidPort } @@ -506,7 +504,7 @@ public actor NetSocketNew { // 1. Open file and get length (blocking I/O, done off-actor) do { - total = Int(try NetSocketNew.fileLength(at: url)) + total = Int(try NetSocket.fileLength(at: url)) fh = try FileHandle(forReadingFrom: url) } catch { continuation.finish(throwing: NetSocketError.failed(underlying: error)) @@ -792,11 +790,11 @@ public actor NetSocketNew { } private func startReceiveLoop() { - @Sendable func loop(_ connection: NWConnection, chunk: Int, owner: NetSocketNew, connID: String) { - print("NetSocketNew[\(connID)]: Calling connection.receive(\(chunk)) to request more data...") + @Sendable func loop(_ connection: NWConnection, chunk: Int, owner: NetSocket, connID: String) { + print("NetSocket[\(connID)]: Calling connection.receive(\(chunk)) to request more data...") connection.receive(minimumIncompleteLength: 1, maximumLength: chunk) { [weak owner] data, _, isComplete, error in - print("NetSocketNew[\(connID)]: Receive callback - data: \(data?.count ?? 0) bytes, isComplete: \(isComplete), error: \(String(describing: error))") + print("NetSocket[\(connID)]: Receive callback - data: \(data?.count ?? 0) bytes, isComplete: \(isComplete), error: \(String(describing: error))") Task { guard let o = owner else { return @@ -810,7 +808,7 @@ public actor NetSocketNew { await o.append(data, connID: connID) } if isComplete { - print("NetSocketNew[\(connID)]: EOF from peer.") + print("NetSocket[\(connID)]: EOF from peer.") await o.handleEOF() return } @@ -952,7 +950,7 @@ public actor NetSocketNew { } private func append(_ data: Data, connID: String) { - print("NetSocketNew[\(connID)]: Received \(data.count) bytes from network, buffer now has \(buffer.count - head + data.count) available") + print("NetSocket[\(connID)]: Received \(data.count) bytes from network, buffer now has \(buffer.count - head + data.count) available") buffer.append(data) if buffer.count - head > config.maxBufferBytes { // Hard stop: drop connection rather than OOM'ing. @@ -1038,7 +1036,7 @@ public protocol NetSocketEncodable: Sendable { /// let id: UInt32 /// let name: String /// -/// init(from socket: NetSocketNew, endian: Endian) async throws { +/// init(from socket: NetSocket, endian: Endian) async throws { /// self.id = try await socket.read(UInt32.self, endian: endian) /// let nameLen = try await socket.read(UInt16.self, endian: endian) /// let nameData = try await socket.readExactly(Int(nameLen)) @@ -1064,10 +1062,10 @@ public protocol NetSocketDecodable: Sendable { /// - socket: Socket to read from /// - endian: Byte order for multi-byte values /// - Throws: Network errors, insufficient data, or custom decoding errors - init(from socket: NetSocketNew, endian: Endian) async throws + init(from socket: NetSocket, endian: Endian) async throws } -public extension NetSocketNew { +public extension NetSocket { /// Send an encodable value to the socket /// /// The type encodes itself to binary data, which is then sent in a single write operation. @@ -1109,7 +1107,7 @@ public extension NetSocketNew { /// let id: UInt32 /// let name: String /// - /// init(from socket: NetSocketNew, endian: Endian) async throws { + /// init(from socket: NetSocket, endian: Endian) async throws { /// self.id = try await socket.read(UInt32.self, endian: endian) /// // Read variable-length string... /// } diff --git a/Hotline/Library/NetSocket/TransferRateEstimator.swift b/Hotline/Library/NetSocket/TransferRateEstimator.swift index 7d16904..60647a7 100644 --- a/Hotline/Library/NetSocket/TransferRateEstimator.swift +++ b/Hotline/Library/NetSocket/TransferRateEstimator.swift @@ -70,7 +70,7 @@ public struct TransferRateEstimator { self.minSamples = minSamples } - public mutating func update(total: Int) -> NetSocketNew.FileProgress { + public mutating func update(total: Int) -> NetSocket.FileProgress { return self.update(bytes: max(0, total - self.transferred)) } @@ -80,7 +80,7 @@ public struct TransferRateEstimator { /// /// - Parameter bytes: Number of bytes transferred in this sample /// - Returns: Current progress with speed and ETA estimates - public mutating func update(bytes: Int) -> NetSocketNew.FileProgress { + public mutating func update(bytes: Int) -> NetSocket.FileProgress { let clock = ContinuousClock() let now = clock.now @@ -102,7 +102,7 @@ public struct TransferRateEstimator { let instantRate = Double(bytes) / seconds self.sampleCount += 1 - // Update EMA + // Update EMANetSocket if self.emaBytesPerSecond == 0 { self.emaBytesPerSecond = instantRate } else { @@ -124,7 +124,7 @@ public struct TransferRateEstimator { eta = nil } - return NetSocketNew.FileProgress( + return NetSocket.FileProgress( sent: self.transferred, total: self.total, bytesPerSecond: haveEstimate ? self.emaBytesPerSecond : nil, diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index df3e54b..a66a071 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -135,6 +135,7 @@ struct ServerView: View { Text(error) .font(.caption) .foregroundStyle(.secondary) + .multilineTextAlignment(.center) } .frame(maxWidth: 300) .padding() -- cgit From a55318fa8d643160900bec3e6b14e7404c63497a Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 7 Nov 2025 12:44:55 -0800 Subject: Organize Library folder a bit. Update Tracker add/edit sheet design. --- Hotline.xcodeproj/project.pbxproj | 72 +- Hotline/Hotline/HotlineProtocol.swift | 5 + Hotline/Library/AsyncLinkPreview.swift | 57 - Hotline/Library/BookmarkDocument.swift | 32 + Hotline/Library/ColorArt.swift | 424 ++++++++ Hotline/Library/DataAdditions.swift | 23 - Hotline/Library/Extensions.swift | 192 ++++ Hotline/Library/FileIconView.swift | 74 -- Hotline/Library/FileImageView.swift | 98 -- Hotline/Library/HotlinePanel.swift | 64 -- Hotline/Library/NetSocket/FileProgress.swift | 2 +- Hotline/Library/NetSocket/NetSocket.swift | 1123 +++++++++++++++++++ Hotline/Library/NetSocket/NetSocketNew.swift | 1127 -------------------- .../Library/NetSocket/TransferRateEstimator.swift | 2 +- Hotline/Library/QuickLookPreviewView.swift | 22 - Hotline/Library/RegularExpressions.swift | 235 ++++ Hotline/Library/SoundEffects.swift | 50 + Hotline/Library/SpinningGlobeView.swift | 90 -- Hotline/Library/TextView.swift | 119 --- Hotline/Library/URLAdditions.swift | 55 - Hotline/Library/Utility/DAKeychain.swift | 81 ++ Hotline/Library/Utility/NSWindowBridge.swift | 46 + Hotline/Library/Utility/ObservableScrollView.swift | 38 + Hotline/Library/Utility/TextDocument.swift | 31 + Hotline/Library/Views/AsyncLinkPreview.swift | 57 + Hotline/Library/Views/BetterTextEditor.swift | 119 +++ Hotline/Library/Views/FileIconView.swift | 74 ++ Hotline/Library/Views/FileImageView.swift | 98 ++ Hotline/Library/Views/GroupedIconView.swift | 23 + Hotline/Library/Views/HotlinePanel.swift | 64 ++ Hotline/Library/Views/QuickLookPreviewView.swift | 22 + Hotline/Library/Views/SpinningGlobeView.swift | 90 ++ Hotline/Library/Views/VisualEffectView.swift | 22 + Hotline/Library/VisualEffectView.swift | 22 - Hotline/Utility/BookmarkDocument.swift | 32 - Hotline/Utility/ColorArt.swift | 424 -------- Hotline/Utility/DAKeychain.swift | 81 -- Hotline/Utility/FoundationExtensions.swift | 107 -- Hotline/Utility/NSWindowBridge.swift | 46 - Hotline/Utility/ObservableScrollView.swift | 38 - Hotline/Utility/RegularExpressions.swift | 235 ---- Hotline/Utility/SoundEffects.swift | 50 - Hotline/Utility/SwiftUIExtensions.swift | 8 - Hotline/Utility/TextDocument.swift | 31 - Hotline/macOS/TrackerView.swift | 49 +- 45 files changed, 2900 insertions(+), 2854 deletions(-) delete mode 100644 Hotline/Library/AsyncLinkPreview.swift create mode 100644 Hotline/Library/BookmarkDocument.swift create mode 100644 Hotline/Library/ColorArt.swift delete mode 100644 Hotline/Library/DataAdditions.swift create mode 100644 Hotline/Library/Extensions.swift delete mode 100644 Hotline/Library/FileIconView.swift delete mode 100644 Hotline/Library/FileImageView.swift delete mode 100644 Hotline/Library/HotlinePanel.swift create mode 100644 Hotline/Library/NetSocket/NetSocket.swift delete mode 100644 Hotline/Library/NetSocket/NetSocketNew.swift delete mode 100644 Hotline/Library/QuickLookPreviewView.swift create mode 100644 Hotline/Library/RegularExpressions.swift create mode 100644 Hotline/Library/SoundEffects.swift delete mode 100644 Hotline/Library/SpinningGlobeView.swift delete mode 100644 Hotline/Library/TextView.swift delete mode 100644 Hotline/Library/URLAdditions.swift create mode 100644 Hotline/Library/Utility/DAKeychain.swift create mode 100644 Hotline/Library/Utility/NSWindowBridge.swift create mode 100644 Hotline/Library/Utility/ObservableScrollView.swift create mode 100644 Hotline/Library/Utility/TextDocument.swift create mode 100644 Hotline/Library/Views/AsyncLinkPreview.swift create mode 100644 Hotline/Library/Views/BetterTextEditor.swift create mode 100644 Hotline/Library/Views/FileIconView.swift create mode 100644 Hotline/Library/Views/FileImageView.swift create mode 100644 Hotline/Library/Views/GroupedIconView.swift create mode 100644 Hotline/Library/Views/HotlinePanel.swift create mode 100644 Hotline/Library/Views/QuickLookPreviewView.swift create mode 100644 Hotline/Library/Views/SpinningGlobeView.swift create mode 100644 Hotline/Library/Views/VisualEffectView.swift delete mode 100644 Hotline/Library/VisualEffectView.swift delete mode 100644 Hotline/Utility/BookmarkDocument.swift delete mode 100644 Hotline/Utility/ColorArt.swift delete mode 100644 Hotline/Utility/DAKeychain.swift delete mode 100644 Hotline/Utility/FoundationExtensions.swift delete mode 100644 Hotline/Utility/NSWindowBridge.swift delete mode 100644 Hotline/Utility/ObservableScrollView.swift delete mode 100644 Hotline/Utility/RegularExpressions.swift delete mode 100644 Hotline/Utility/SoundEffects.swift delete mode 100644 Hotline/Utility/SwiftUIExtensions.swift delete mode 100644 Hotline/Utility/TextDocument.swift diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index cb621a0..c63109a 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -32,6 +32,7 @@ DA4B8F3A2EA6FB3C00CBFD53 /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4B8F392EA6FB3C00CBFD53 /* iOSApp.swift */; platformFilter = ios; }; 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 */; }; 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, ); }; @@ -55,7 +56,7 @@ DA65499A2BEC280E00EDB697 /* ServerMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6549992BEC280E00EDB697 /* ServerMessageView.swift */; }; DA65499C2BEC3FBD00EDB697 /* ServerAgreementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA65499B2BEC3FBD00EDB697 /* ServerAgreementView.swift */; }; DA65499E2BEC438A00EDB697 /* NSWindowBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA65499D2BEC438A00EDB697 /* NSWindowBridge.swift */; platformFilters = (macos, ); }; - DA6980792BF80525003E434B /* TextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6980782BF80525003E434B /* TextView.swift */; }; + DA6980792BF80525003E434B /* BetterTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6980782BF80525003E434B /* BetterTextEditor.swift */; }; DA69807E2BFD449B003E434B /* MessageBoardEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA69807D2BFD449B003E434B /* MessageBoardEditorView.swift */; }; DA6980832BFFD06C003E434B /* BookmarkDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6980822BFFD06C003E434B /* BookmarkDocument.swift */; }; DA72A0DD2B4CD0BF00A0F48A /* NewsEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA72A0DC2B4CD0BF00A0F48A /* NewsEditorView.swift */; }; @@ -76,9 +77,8 @@ DAAEE66F2B47625600A5BA07 /* FilePreviewImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAAEE66E2B47625600A5BA07 /* FilePreviewImageView.swift */; platformFilters = (macos, ); }; DAB4D87B2B4B78310048A05C /* FileImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAB4D87A2B4B78310048A05C /* FileImageView.swift */; platformFilters = (macos, ); }; DAB4D87E2B4C8BCA0048A05C /* FilePreviewTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAB4D87D2B4C8BCA0048A05C /* FilePreviewTextView.swift */; platformFilters = (macos, ); }; - DAB4D8802B4C8E9A0048A05C /* URLAdditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAB4D87F2B4C8E9A0048A05C /* URLAdditions.swift */; }; DAB4D8822B4C8FED0048A05C /* FileIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAB4D8812B4C8FED0048A05C /* FileIconView.swift */; }; - DAB4D8842B4CABEF0048A05C /* DataAdditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAB4D8832B4CABEF0048A05C /* DataAdditions.swift */; }; + DAB4D8842B4CABEF0048A05C /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAB4D8832B4CABEF0048A05C /* Extensions.swift */; }; DAB4D8872B4CB3610048A05C /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = DAB4D8862B4CB3610048A05C /* MarkdownUI */; }; DABE8BF42B55DC0A00884D28 /* transfer-complete.aiff in Resources */ = {isa = PBXBuildFile; fileRef = DABE8BF32B55DC0A00884D28 /* transfer-complete.aiff */; }; DABE8BF62B55DC2E00884D28 /* chat-message.aiff in Resources */ = {isa = PBXBuildFile; fileRef = DABE8BF52B55DC2E00884D28 /* chat-message.aiff */; }; @@ -89,11 +89,9 @@ DABE8C002B55E69800884D28 /* server-message.aiff in Resources */ = {isa = PBXBuildFile; fileRef = DABE8BFF2B55E69800884D28 /* server-message.aiff */; }; DABE8C022B55E69D00884D28 /* new-news.aiff in Resources */ = {isa = PBXBuildFile; fileRef = DABE8C012B55E69D00884D28 /* new-news.aiff */; }; DABE8C042B57940B00884D28 /* DAKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABE8C032B57940A00884D28 /* DAKeychain.swift */; }; - DABFCC292B1530DC009F40D2 /* FoundationExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABFCC282B1530DC009F40D2 /* FoundationExtensions.swift */; }; - DAC002192B21630900A6C290 /* SwiftUIExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC002182B21630900A6C290 /* SwiftUIExtensions.swift */; }; DAC3D9832BC33FD000A727C9 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC3D9822BC33FD000A727C9 /* AppState.swift */; }; DAC6B2E02EAC6236004E2CBA /* ChatStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC6B2DE2EAC6236004E2CBA /* ChatStore.swift */; }; - DAC6B2E22EAEE9FE004E2CBA /* NetSocketNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC6B2E12EAEE9EF004E2CBA /* NetSocketNew.swift */; }; + DAC6B2E22EAEE9FE004E2CBA /* NetSocket.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC6B2E12EAEE9EF004E2CBA /* NetSocket.swift */; }; DAC6B2E42EAFE92F004E2CBA /* SpinningGlobeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC6B2E32EAFE92F004E2CBA /* SpinningGlobeView.swift */; }; DAC87F072C5010E80060FADF /* HotlineExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC87F062C5010E80060FADF /* HotlineExtensions.swift */; }; DACCE5E12EABE4B4008CDD92 /* AppUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DACCE5E02EABE4B4008CDD92 /* AppUpdate.swift */; platformFilters = (macos, ); }; @@ -140,6 +138,7 @@ DA4B8F392EA6FB3C00CBFD53 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; }; DA4F2BF72B16A17200D8ADDC /* HotlineProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineProtocol.swift; sourceTree = ""; }; DA4F2C002B1A558E00D8ADDC /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = ""; }; + DA501BE12EBE9018001714F8 /* GroupedIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupedIconView.swift; sourceTree = ""; }; DA52689B2EB0738B00DCB941 /* GeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsView.swift; sourceTree = ""; }; DA52689D2EB073A400DCB941 /* IconSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconSettingsView.swift; sourceTree = ""; }; DA52689F2EB073BC00DCB941 /* SoundSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundSettingsView.swift; sourceTree = ""; }; @@ -163,7 +162,7 @@ DA6549992BEC280E00EDB697 /* ServerMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerMessageView.swift; sourceTree = ""; }; DA65499B2BEC3FBD00EDB697 /* ServerAgreementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerAgreementView.swift; sourceTree = ""; }; DA65499D2BEC438A00EDB697 /* NSWindowBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSWindowBridge.swift; sourceTree = ""; }; - DA6980782BF80525003E434B /* TextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextView.swift; sourceTree = ""; }; + DA6980782BF80525003E434B /* BetterTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BetterTextEditor.swift; sourceTree = ""; }; DA69807D2BFD449B003E434B /* MessageBoardEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageBoardEditorView.swift; sourceTree = ""; }; DA6980822BFFD06C003E434B /* BookmarkDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkDocument.swift; sourceTree = ""; }; DA72A0DC2B4CD0BF00A0F48A /* NewsEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsEditorView.swift; sourceTree = ""; }; @@ -184,9 +183,8 @@ DAAEE66E2B47625600A5BA07 /* FilePreviewImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePreviewImageView.swift; sourceTree = ""; }; DAB4D87A2B4B78310048A05C /* FileImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileImageView.swift; sourceTree = ""; }; DAB4D87D2B4C8BCA0048A05C /* FilePreviewTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePreviewTextView.swift; sourceTree = ""; }; - DAB4D87F2B4C8E9A0048A05C /* URLAdditions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLAdditions.swift; sourceTree = ""; }; DAB4D8812B4C8FED0048A05C /* FileIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileIconView.swift; sourceTree = ""; }; - DAB4D8832B4CABEF0048A05C /* DataAdditions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataAdditions.swift; sourceTree = ""; }; + DAB4D8832B4CABEF0048A05C /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = ""; }; DABE8BF32B55DC0A00884D28 /* transfer-complete.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = "transfer-complete.aiff"; sourceTree = ""; }; DABE8BF52B55DC2E00884D28 /* chat-message.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = "chat-message.aiff"; sourceTree = ""; }; DABE8BF72B55DC6100884D28 /* logged-in.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = "logged-in.aiff"; sourceTree = ""; }; @@ -196,11 +194,9 @@ DABE8BFF2B55E69800884D28 /* server-message.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = "server-message.aiff"; sourceTree = ""; }; DABE8C012B55E69D00884D28 /* new-news.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = "new-news.aiff"; sourceTree = ""; }; DABE8C032B57940A00884D28 /* DAKeychain.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DAKeychain.swift; sourceTree = ""; }; - DABFCC282B1530DC009F40D2 /* FoundationExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoundationExtensions.swift; sourceTree = ""; }; - DAC002182B21630900A6C290 /* SwiftUIExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIExtensions.swift; sourceTree = ""; }; DAC3D9822BC33FD000A727C9 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; DAC6B2DE2EAC6236004E2CBA /* ChatStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatStore.swift; sourceTree = ""; }; - DAC6B2E12EAEE9EF004E2CBA /* NetSocketNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetSocketNew.swift; sourceTree = ""; }; + DAC6B2E12EAEE9EF004E2CBA /* NetSocket.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetSocket.swift; sourceTree = ""; }; DAC6B2E32EAFE92F004E2CBA /* SpinningGlobeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpinningGlobeView.swift; sourceTree = ""; }; DAC87F062C5010E80060FADF /* HotlineExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineExtensions.swift; sourceTree = ""; }; DACCE5E02EABE4B4008CDD92 /* AppUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdate.swift; sourceTree = ""; }; @@ -279,6 +275,22 @@ path = iOS; sourceTree = ""; }; + DA501BE02EBE844F001714F8 /* Views */ = { + isa = PBXGroup; + children = ( + DA501BE12EBE9018001714F8 /* GroupedIconView.swift */, + DA872B122BDDBF78008B1012 /* HotlinePanel.swift */, + DAB4D87A2B4B78310048A05C /* FileImageView.swift */, + DAB4D8812B4C8FED0048A05C /* FileIconView.swift */, + DA3429B62EBAB1750010784E /* QuickLookPreviewView.swift */, + DAC6B2E32EAFE92F004E2CBA /* SpinningGlobeView.swift */, + DA872B142BDDEE1A008B1012 /* VisualEffectView.swift */, + DA55AC722BE42AF000034857 /* AsyncLinkPreview.swift */, + DA6980782BF80525003E434B /* BetterTextEditor.swift */, + ); + path = Views; + sourceTree = ""; + }; DA52689A2EB0737400DCB941 /* Settings */ = { isa = PBXGroup; children = ( @@ -344,7 +356,7 @@ DA5268B62EB916AB00DCB941 /* NetSocket */ = { isa = PBXGroup; children = ( - DAC6B2E12EAEE9EF004E2CBA /* NetSocketNew.swift */, + DAC6B2E12EAEE9EF004E2CBA /* NetSocket.swift */, DA5268B92EB91B5E00DCB941 /* FileProgress.swift */, DA5268B72EB916AF00DCB941 /* TransferRateEstimator.swift */, ); @@ -379,7 +391,6 @@ DADDB2892B22B2C60024040D /* Models */, DABFCC262B1530AE009F40D2 /* Hotline */, DAB4D87C2B4B783A0048A05C /* Library */, - DABFCC272B1530BE009F40D2 /* Utility */, DA4B8F3C2EA6FDCE00CBFD53 /* Assets */, DA9CAFC02B126D5800CDA197 /* Assets.xcassets */, DA32CD472B28EE640053B98B /* Info.plist */, @@ -400,17 +411,14 @@ DAB4D87C2B4B783A0048A05C /* Library */ = { isa = PBXGroup; children = ( + DAB4D8832B4CABEF0048A05C /* Extensions.swift */, + DA5268AA2EB11EA300DCB941 /* ColorArt.swift */, + DAE735062B3251B3000C56F6 /* SoundEffects.swift */, + DA55AC782BE6A1AD00034857 /* RegularExpressions.swift */, + DA6980822BFFD06C003E434B /* BookmarkDocument.swift */, + DA501BE02EBE844F001714F8 /* Views */, DA5268B62EB916AB00DCB941 /* NetSocket */, - DAC6B2E32EAFE92F004E2CBA /* SpinningGlobeView.swift */, - DAB4D87A2B4B78310048A05C /* FileImageView.swift */, - DAB4D8812B4C8FED0048A05C /* FileIconView.swift */, - DAB4D8832B4CABEF0048A05C /* DataAdditions.swift */, - DAB4D87F2B4C8E9A0048A05C /* URLAdditions.swift */, - DA3429B62EBAB1750010784E /* QuickLookPreviewView.swift */, - DA872B122BDDBF78008B1012 /* HotlinePanel.swift */, - DA872B142BDDEE1A008B1012 /* VisualEffectView.swift */, - DA55AC722BE42AF000034857 /* AsyncLinkPreview.swift */, - DA6980782BF80525003E434B /* TextView.swift */, + DABFCC272B1530BE009F40D2 /* Utility */, ); path = Library; sourceTree = ""; @@ -447,16 +455,10 @@ DABFCC272B1530BE009F40D2 /* Utility */ = { isa = PBXGroup; children = ( - DABFCC282B1530DC009F40D2 /* FoundationExtensions.swift */, - DA5268AA2EB11EA300DCB941 /* ColorArt.swift */, - DAC002182B21630900A6C290 /* SwiftUIExtensions.swift */, DA7725402B21435B006C5ABB /* ObservableScrollView.swift */, - DAE735062B3251B3000C56F6 /* SoundEffects.swift */, DA57536B2B36BA1D00FAC277 /* TextDocument.swift */, DABE8C032B57940A00884D28 /* DAKeychain.swift */, - DA55AC782BE6A1AD00034857 /* RegularExpressions.swift */, DA65499D2BEC438A00EDB697 /* NSWindowBridge.swift */, - DA6980822BFFD06C003E434B /* BookmarkDocument.swift */, ); path = Utility; sourceTree = ""; @@ -605,7 +607,6 @@ DAC3D9832BC33FD000A727C9 /* AppState.swift in Sources */, DA65499C2BEC3FBD00EDB697 /* ServerAgreementView.swift in Sources */, DA5268BC2EB95A7700DCB941 /* HotlineFolderDownloadClientNew.swift in Sources */, - DAB4D8802B4C8E9A0048A05C /* URLAdditions.swift in Sources */, DA2863DD2B3E8B7000A7D050 /* FilePreview.swift in Sources */, DA3429B02EBA70790010784E /* HotlineFolderUploadClientNew.swift in Sources */, DA5268AB2EB11EA300DCB941 /* ColorArt.swift in Sources */, @@ -616,7 +617,6 @@ DA5268B52EB6840A00DCB941 /* TransfersView.swift in Sources */, 11F8288B2BF9428100216BA0 /* AccountManagerView.swift in Sources */, DAC6B2E42EAFE92F004E2CBA /* SpinningGlobeView.swift in Sources */, - DAC002192B21630900A6C290 /* SwiftUIExtensions.swift in Sources */, DADDB28D2B22B5920024040D /* Server.swift in Sources */, DA5268AF2EB2682B00DCB941 /* HotlineClientNew.swift in Sources */, DABE8C042B57940B00884D28 /* DAKeychain.swift in Sources */, @@ -625,12 +625,13 @@ DAE734FB2B2E41F9000C56F6 /* TrackerView.swift in Sources */, DA5268A32EB0741B00DCB941 /* FolderItemView.swift in Sources */, DAE136BA2B9D1147007D8307 /* HotlinePanelView.swift in Sources */, + DA501BE22EBE9018001714F8 /* GroupedIconView.swift in Sources */, 11A726082BE0672A000C1DA7 /* FileDetails.swift in Sources */, DA9CAFBD2B126D5700CDA197 /* TrackerView.swift in Sources */, DA5268B82EB916AF00DCB941 /* TransferRateEstimator.swift in Sources */, DAB4D87B2B4B78310048A05C /* FileImageView.swift in Sources */, DA3429B52EBA8A450010784E /* FilePreviewState.swift in Sources */, - DA6980792BF80525003E434B /* TextView.swift in Sources */, + DA6980792BF80525003E434B /* BetterTextEditor.swift in Sources */, DA55AC732BE42AF000034857 /* AsyncLinkPreview.swift in Sources */, DA65499E2BEC438A00EDB697 /* NSWindowBridge.swift in Sources */, DAE735032B30C0BB000C56F6 /* MessageView.swift in Sources */, @@ -653,12 +654,11 @@ 11A7260A2BE0675A000C1DA7 /* FileDetailsView.swift in Sources */, DA72A0DD2B4CD0BF00A0F48A /* NewsEditorView.swift in Sources */, DA52689C2EB0738B00DCB941 /* GeneralSettingsView.swift in Sources */, - DABFCC292B1530DC009F40D2 /* FoundationExtensions.swift in Sources */, DA57536C2B36BA1D00FAC277 /* TextDocument.swift in Sources */, DACCE5E12EABE4B4008CDD92 /* AppUpdate.swift in Sources */, DA5268AD2EB12FE200DCB941 /* ServerState.swift in Sources */, DA77253F2B21176D006C5ABB /* NewsView.swift in Sources */, - DAC6B2E22EAEE9FE004E2CBA /* NetSocketNew.swift in Sources */, + DAC6B2E22EAEE9FE004E2CBA /* NetSocket.swift in Sources */, DA99218E2C51AA5D0058FA6C /* HotlineDataBuilder.swift in Sources */, DA872B132BDDBF78008B1012 /* HotlinePanel.swift in Sources */, DA4B8F3A2EA6FB3C00CBFD53 /* iOSApp.swift in Sources */, @@ -670,7 +670,7 @@ DA872B152BDDEE1A008B1012 /* VisualEffectView.swift in Sources */, DA3429AE2EB9C0280010784E /* HotlineFileUploadClientNew.swift in Sources */, DAAEE66D2B475F1400A5BA07 /* PreviewFileInfo.swift in Sources */, - DAB4D8842B4CABEF0048A05C /* DataAdditions.swift in Sources */, + DAB4D8842B4CABEF0048A05C /* Extensions.swift in Sources */, DA0D698D2B1E7CF700C71DF5 /* UsersView.swift in Sources */, DAE735052B3218D8000C56F6 /* NewsView.swift in Sources */, DA5268A52EB0743000DCB941 /* FileItemView.swift in Sources */, diff --git a/Hotline/Hotline/HotlineProtocol.swift b/Hotline/Hotline/HotlineProtocol.swift index 927cf69..3870261 100644 --- a/Hotline/Hotline/HotlineProtocol.swift +++ b/Hotline/Hotline/HotlineProtocol.swift @@ -1,5 +1,10 @@ import Foundation +enum Endianness { + case big + case little +} + struct HotlinePorts { static let DefaultServerPort: Int = 5500 static let DefaultTrackerPort: Int = 5498 diff --git a/Hotline/Library/AsyncLinkPreview.swift b/Hotline/Library/AsyncLinkPreview.swift deleted file mode 100644 index 89b186f..0000000 --- a/Hotline/Library/AsyncLinkPreview.swift +++ /dev/null @@ -1,57 +0,0 @@ -import SwiftUI -import LinkPresentation - -fileprivate class CustomLinkView: LPLinkView { - override var intrinsicContentSize: CGSize { CGSize(width: 0, height: super.intrinsicContentSize.height) } -} - -struct AsyncLinkPreview: View { - @State private var metadata: LPLinkMetadata? - @State private var isLoading = true - let url: URL? - - func fetchMetadata() async { - guard let url else { - self.isLoading = false - return - } - do { - let provider = LPMetadataProvider() - let metadata = try await provider.startFetchingMetadata(for: url) - self.metadata = metadata - self.isLoading = false - } catch { - self.isLoading = false - } - } - - var body: some View { - if isLoading { - ProgressView() - .controlSize(.small) - .task { - await self.fetchMetadata() - } - } else if let metadata = metadata { - LinkView(metadata: metadata) - .frame(width: 200) - } else { - Text(LocalizedStringKey(url!.absoluteString)) - .multilineTextAlignment(.leading) - .tint(Color("Link Color")) - } - } -} - -struct LinkView: NSViewRepresentable { - var metadata: LPLinkMetadata - - func makeNSView(context: Context) -> LPLinkView { - let linkView = CustomLinkView(metadata: metadata) - return linkView - } - - func updateNSView(_ nsView: LPLinkView, context: Context) { - // Nothing required - } -} diff --git a/Hotline/Library/BookmarkDocument.swift b/Hotline/Library/BookmarkDocument.swift new file mode 100644 index 0000000..47fa442 --- /dev/null +++ b/Hotline/Library/BookmarkDocument.swift @@ -0,0 +1,32 @@ +import SwiftUI +import Foundation +import UniformTypeIdentifiers + +struct BookmarkDocument: FileDocument { + static var readableContentTypes: [UTType] { [.data, UTType(filenameExtension: "hlbm")!] } + static var writableContentTypes: [UTType] { [.data, UTType(filenameExtension: "hlbm")!] } + + var bookmark: Bookmark + + init(bookmark: Bookmark) { + self.bookmark = bookmark + } + + init(configuration: ReadConfiguration) throws { + guard configuration.file.isRegularFile, + let data = configuration.file.regularFileContents, + let fileName = configuration.file.preferredFilename, + let bookmark = Bookmark(fileData: data, name: (fileName as NSString).deletingPathExtension) + else { + throw CocoaError(.fileReadCorruptFile) + } + self.bookmark = bookmark + } + + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + let wrapper = FileWrapper(regularFileWithContents: self.bookmark.bookmarkFileData()!) + wrapper.fileAttributes[FileAttributeKey.hfsCreatorCode.rawValue] = "HTLC".fourCharCode() + wrapper.fileAttributes[FileAttributeKey.hfsTypeCode.rawValue] = "HTbm".fourCharCode() + return wrapper + } +} diff --git a/Hotline/Library/ColorArt.swift b/Hotline/Library/ColorArt.swift new file mode 100644 index 0000000..93889a3 --- /dev/null +++ b/Hotline/Library/ColorArt.swift @@ -0,0 +1,424 @@ + +// Swift translation and modernization +// by Dustin Mierau +// +// of: +// +// ColorArt.swift +// SLColorArt by Panic Inc. +// +// Copyright (C) 2012 Panic Inc. Code by Wade Cosgrove. All rights reserved. +// +// Redistribution and use, with or without modification, are permitted +// provided that the following conditions are met: +// +// - Redistributions must reproduce the above copyright notice, this list of +// conditions and the following disclaimer in the documentation and/or other +// materials provided with the distribution. +// +// - Neither the name of Panic Inc nor the names of its contributors may be used +// to endorse or promote works derived from this software without specific prior +// written permission from Panic Inc. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +// ARE DISCLAIMED. IN NO EVENT SHALL PANIC INC BE LIABLE FOR ANY DIRECT, INDIRECT, +// INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +// PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +// POSSIBILITY OF SUCH DAMAGE. + +import AppKit +import SwiftUI + +fileprivate let kColorThresholdMinimumPercentage: CGFloat = 0.001 + +// ColorArt.analyze(image: img) -> ColorArt? + +struct ColorArt: Equatable { + let backgroundColor: NSColor + let primaryColor: NSColor + let secondaryColor: NSColor + let detailColor: NSColor +// let scaledImage: NSImage + + static func == (lhs: ColorArt, rhs: ColorArt) -> Bool { + return lhs.backgroundColor == rhs.backgroundColor && + lhs.primaryColor == rhs.primaryColor && + lhs.secondaryColor == rhs.secondaryColor && + lhs.detailColor == rhs.detailColor + } + + static func analyze(image: NSImage) -> ColorArt? { + print("ColorArt.analyze: Starting, image size: \(image.size)") + // Scale image to a reasonable size for analysis + // This is important because: + // 1. Makes analysis faster (fewer pixels) + // 2. Normalizes weird image dimensions + // 3. Ensures CGImage conversion succeeds + print("ColorArt.analyze: Calling scaleImage...") + let finalImage = Self.scaleImage(image, size: NSSize(width: 100, height: 100)) + print("ColorArt.analyze: scaleImage returned, scaled size: \(finalImage.size)") + + guard let colors = Self.analyzeImage(finalImage) else { + print("ColorArt.analyze: failed with no colors") + return nil + } + + print("ColorArt.analyze: returning colors", colors) + + return ColorArt(backgroundColor: colors.background, + primaryColor: colors.primary, + secondaryColor: colors.secondary, + detailColor: colors.detail) + } + + // MARK: - Image Scaling + + private static func scaleImage(_ image: NSImage, size scaledSize: NSSize) -> NSImage { + print("ColorArt.scaleImage: Entered, input: \(image.size), target: \(scaledSize)") + // Get CGImage directly without using lockFocus + print("ColorArt.scaleImage: Getting CGImage...") + guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + print("ColorArt.scaleImage: Failed to get CGImage, returning original") + return image + } + print("ColorArt.scaleImage: Got CGImage") + + let imageSize = image.size + let squareSize = min(imageSize.width, imageSize.height) + + // Use native square size if passed zero size + let finalScaledSize = scaledSize == .zero ? NSSize(width: squareSize, height: squareSize) : scaledSize + + // Create bitmap context for drawing + let width = Int(finalScaledSize.width) + let height = Int(finalScaledSize.height) + let colorSpace = CGColorSpaceCreateDeviceRGB() + let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue) + + guard let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width * 4, + space: colorSpace, + bitmapInfo: bitmapInfo.rawValue + ) else { + return image + } + + // Draw the image scaled + context.interpolationQuality = .high + context.draw(cgImage, in: CGRect(x: 0, y: 0, width: finalScaledSize.width, height: finalScaledSize.height)) + + // Create NSImage from context + guard let scaledCGImage = context.makeImage() else { + return image + } + + let bitmapRep = NSBitmapImageRep(cgImage: scaledCGImage) + let finalImage = NSImage(size: finalScaledSize) + finalImage.addRepresentation(bitmapRep) + + return finalImage + } + + // MARK: - Image Analysis + + private static func analyzeImage(_ image: NSImage) -> (background: NSColor, primary: NSColor, secondary: NSColor, detail: NSColor)? { + var imageColors: NSCountedSet? + guard let backgroundColor = self.findEdgeColor(image, imageColors: &imageColors), + let colors = imageColors + else { + return nil + } + + let darkBackground = backgroundColor.isDarkColor + var primaryColor: NSColor? + var secondaryColor: NSColor? + var detailColor: NSColor? + + self.findTextColors(colors, primaryColor: &primaryColor, secondaryColor: &secondaryColor, detailColor: &detailColor, backgroundColor: backgroundColor) + + // Fallback to black or white if colors not found + if primaryColor == nil { + primaryColor = darkBackground ? .white : .black + } + + if secondaryColor == nil { + secondaryColor = darkBackground ? .white : .black + } + + if detailColor == nil { + detailColor = darkBackground ? .white : .black + } + + // Convert all colors to calibrated RGB color space for consistency + // This ensures all colors are in the same color space and prevents + // any color space conversion issues when used in SwiftUI + let rgbColorSpace = NSColorSpace.genericRGB + let finalBackground = backgroundColor.usingColorSpace(rgbColorSpace) ?? backgroundColor + let finalPrimary = primaryColor!.usingColorSpace(rgbColorSpace) ?? primaryColor! + let finalSecondary = secondaryColor!.usingColorSpace(rgbColorSpace) ?? secondaryColor! + let finalDetail = detailColor!.usingColorSpace(rgbColorSpace) ?? detailColor! + + return (finalBackground, finalPrimary, finalSecondary, finalDetail) + } + + // MARK: - Edge Color Detection + + private static func findEdgeColor(_ image: NSImage, imageColors: inout NSCountedSet?) -> NSColor? { + var bitmapRep: NSBitmapImageRep? + + // Try to get existing bitmap representation + if let existingRep = image.representations.last as? NSBitmapImageRep { + bitmapRep = existingRep + } else { + // Create bitmap rep from CGImage instead of using lockFocus + guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + return nil + } + bitmapRep = NSBitmapImageRep(cgImage: cgImage) + } + + // Convert to RGB color space + guard let bitmapRep = bitmapRep?.converting(to: .genericRGB, renderingIntent: .default) else { + return nil + } + + let pixelsWide = bitmapRep.pixelsWide + let pixelsHigh = bitmapRep.pixelsHigh + + let colors = NSCountedSet(capacity: pixelsWide * pixelsHigh) + let leftEdgeColors = NSCountedSet(capacity: pixelsHigh) + var searchColumnX = 0 + + for x in 0.. 0.5 { + leftEdgeColors.add(color) + } + } + + if color.alphaComponent > CGFloat.ulpOfOne { + colors.add(color) + } + } + + // Background is clear, keep looking in next column for background color + if leftEdgeColors.count == 0 { + searchColumnX += 1 + } + } + + imageColors = colors + + var sortedColors: [CountedColor] = [] + + for color in leftEdgeColors { + guard let nsColor = color as? NSColor else { continue } + let colorCount = leftEdgeColors.count(for: nsColor) + + let randomColorsThreshold = Int(CGFloat(pixelsHigh) * kColorThresholdMinimumPercentage) + + if colorCount <= randomColorsThreshold { + continue + } + + sortedColors.append(CountedColor(color: nsColor, count: colorCount)) + } + + sortedColors.sort { $0.count > $1.count } + + guard var proposedEdgeColor = sortedColors.first else { + return nil + } + + // Want to choose color over black/white so we keep looking + if proposedEdgeColor.color.isBlackOrWhite { + for i in 1.. 0.3 { + if !nextProposedColor.color.isBlackOrWhite { + proposedEdgeColor = nextProposedColor + break + } + } else { + // Reached color threshold less than 30% of the original proposed edge color + break + } + } + } + + return proposedEdgeColor.color + } + + // MARK: - Text Color Detection + + private static func findTextColors(_ colors: NSCountedSet, primaryColor: inout NSColor?, secondaryColor: inout NSColor?, detailColor: inout NSColor?, backgroundColor: NSColor) { + var sortedColors: [CountedColor] = [] + let findDarkTextColor = !backgroundColor.isDarkColor + + for color in colors { + guard let nsColor = color as? NSColor else { continue } + let adjustedColor = nsColor.withMinimumSaturation(0.15) + + if adjustedColor.isDarkColor == findDarkTextColor { + let colorCount = colors.count(for: nsColor) + sortedColors.append(CountedColor(color: adjustedColor, count: colorCount)) + } + } + + sortedColors.sort { $0.count > $1.count } + + for container in sortedColors { + let curColor = container.color + + if primaryColor == nil { + if curColor.isContrasting(to: backgroundColor) { + primaryColor = curColor + } + } else if secondaryColor == nil { + if let primary = primaryColor, + primary.isDistinct(from: curColor) && curColor.isContrasting(to: backgroundColor) { + secondaryColor = curColor + } + } else if detailColor == nil { + if let primary = primaryColor, + let secondary = secondaryColor, + secondary.isDistinct(from: curColor) && + primary.isDistinct(from: curColor) && + curColor.isContrasting(to: backgroundColor) { + detailColor = curColor + break + } + } + } + } +} + +// MARK: - Helper Classes + +fileprivate struct CountedColor { + let color: NSColor + let count: Int +} + +// MARK: - NSColor Extensions + +extension NSColor { + var isDarkColor: Bool { + guard let convertedColor = usingColorSpace(.genericRGB) else { + return false + } + + var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + convertedColor.getRed(&r, green: &g, blue: &b, alpha: &a) + + let lum = 0.2126 * r + 0.7152 * g + 0.0722 * b + + return lum < 0.5 + } + + func isDistinct(from compareColor: NSColor) -> Bool { + guard let convertedColor = usingColorSpace(.genericRGB), + let convertedCompareColor = compareColor.usingColorSpace(.genericRGB) + else { + return false + } + + var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + var r1: CGFloat = 0, g1: CGFloat = 0, b1: CGFloat = 0, a1: CGFloat = 0 + + convertedColor.getRed(&r, green: &g, blue: &b, alpha: &a) + convertedCompareColor.getRed(&r1, green: &g1, blue: &b1, alpha: &a1) + + let threshold: CGFloat = 0.25 + + if abs(r - r1) > threshold || abs(g - g1) > threshold || abs(b - b1) > threshold || abs(a - a1) > threshold { + // Check for grays, prevent multiple gray colors + if abs(r - g) < 0.03 && abs(r - b) < 0.03 { + if abs(r1 - g1) < 0.03 && abs(r1 - b1) < 0.03 { + return false + } + } + + return true + } + + return false + } + + func withMinimumSaturation(_ minSaturation: CGFloat) -> NSColor { + guard let tempColor = usingColorSpace(.genericRGB) else { + return self + } + + var hue: CGFloat = 0, saturation: CGFloat = 0, brightness: CGFloat = 0, alpha: CGFloat = 0 + tempColor.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) + + if saturation < minSaturation { + return NSColor(calibratedHue: hue, saturation: minSaturation, brightness: brightness, alpha: alpha) + } + + return self + } + + var isBlackOrWhite: Bool { + guard let tempColor = usingColorSpace(.genericRGB) else { + return false + } + + var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + tempColor.getRed(&r, green: &g, blue: &b, alpha: &a) + + // White + if r > 0.91 && g > 0.91 && b > 0.91 { + return true + } + + // Black + if r < 0.09 && g < 0.09 && b < 0.09 { + return true + } + + return false + } + + func isContrasting(to color: NSColor) -> Bool { + guard let backgroundColor = usingColorSpace(.genericRGB), + let foregroundColor = color.usingColorSpace(.genericRGB) + else { + return true + } + + var br: CGFloat = 0, bg: CGFloat = 0, bb: CGFloat = 0, ba: CGFloat = 0 + var fr: CGFloat = 0, fg: CGFloat = 0, fb: CGFloat = 0, fa: CGFloat = 0 + + backgroundColor.getRed(&br, green: &bg, blue: &bb, alpha: &ba) + foregroundColor.getRed(&fr, green: &fg, blue: &fb, alpha: &fa) + + let bLum = 0.2126 * br + 0.7152 * bg + 0.0722 * bb + let fLum = 0.2126 * fr + 0.7152 * fg + 0.0722 * fb + + let contrast: CGFloat + if bLum > fLum { + contrast = (bLum + 0.05) / (fLum + 0.05) + } else { + contrast = (fLum + 0.05) / (bLum + 0.05) + } + + return contrast > 1.6 + } +} diff --git a/Hotline/Library/DataAdditions.swift b/Hotline/Library/DataAdditions.swift deleted file mode 100644 index 0e9dd96..0000000 --- a/Hotline/Library/DataAdditions.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Foundation - -extension Data { - func saveAsFileToDownloads(filename: String, bounceDock: Bool = true) -> Bool { - let folderURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0] - let filePath = folderURL.generateUniqueFilePath(filename: filename) - if FileManager.default.createFile(atPath: filePath, contents: nil) { - if let h = FileHandle(forWritingAtPath: filePath) { - try? h.write(contentsOf: self) - try? h.close() - if bounceDock { - #if os(macOS) - var downloadURL = URL(filePath: filePath) - downloadURL.resolveSymlinksInPath() - DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path) - #endif - } - return true - } - } - return false - } -} diff --git a/Hotline/Library/Extensions.swift b/Hotline/Library/Extensions.swift new file mode 100644 index 0000000..469676c --- /dev/null +++ b/Hotline/Library/Extensions.swift @@ -0,0 +1,192 @@ +import Foundation +import SwiftUI +import UniformTypeIdentifiers + +extension Data { + func saveAsFileToDownloads(filename: String, bounceDock: Bool = true) -> Bool { + let folderURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0] + let filePath = folderURL.generateUniqueFilePath(filename: filename) + if FileManager.default.createFile(atPath: filePath, contents: nil) { + if let h = FileHandle(forWritingAtPath: filePath) { + try? h.write(contentsOf: self) + try? h.close() + if bounceDock { + #if os(macOS) + var downloadURL = URL(filePath: filePath) + downloadURL.resolveSymlinksInPath() + DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path) + #endif + } + return true + } + } + return false + } +} + +// MARK: - + +extension URL { + func generateUniqueFilePath(filename base: String) -> String { + let fileManager = FileManager.default + var finalName = base + var counter = 2 + + // Helper function to generate a new filename with a counter + func makeFileName() -> String { + let baseName = (base as NSString).deletingPathExtension + let extensionName = (base as NSString).pathExtension + return extensionName.isEmpty ? "\(baseName) \(counter)" : "\(baseName) \(counter).\(extensionName)" + } + + // Check if file exists and append counter until a unique name is found + var filePath = self.appending(component: finalName).path(percentEncoded: false) + while fileManager.fileExists(atPath: filePath) { + finalName = makeFileName() + filePath = self.appending(component: finalName).path(percentEncoded: false) + counter += 1 + } + + return filePath + } +} + +// MARK: - + +extension UTType { + var canBePreviewedByQuickLook: Bool { + // QuickLook supports most common document types + let supportedSupertypes: [UTType] = [ + .image, + .movie, + .audio, + .pdf, + .font, + .usdz, + .text, + .sourceCode, + .spreadsheet, + .presentation, + +// Microsoft Office + .init(filenameExtension: "doc")!, + .init(filenameExtension: "docx")!, + .init(filenameExtension: "xls")!, + .init(filenameExtension: "xlsx")!, + .init(filenameExtension: "ppt")!, + .init(filenameExtension: "pptx")!, + ] + + return supportedSupertypes.contains { self.conforms(to: $0) } + } +} + +// MARK: - + +extension String { + + func markdownToAttributedString() -> AttributedString { + let markdownText = self.convertingLinksToMarkdown() + let attr = (try? AttributedString(markdown: markdownText, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(self) + + return attr + } + + func convertToAttributedStringWithLinks() -> AttributedString { + let attributedString: NSMutableAttributedString = NSMutableAttributedString(string: self) + let matches = self.ranges(of: RegularExpressions.relaxedLink) + for match in matches { + let matchString = String(self[match]) + if matchString.isEmailAddress() { + attributedString.addAttribute(.link, value: "mailto:\(matchString)", range: NSRange(match, in: self)) + } + else { + attributedString.addAttribute(.link, value: matchString, range: NSRange(match, in: self)) + } +// attributedString.addAttribute(.underlineStyle, value: 1, range: NSRange(match, in: self)) + } + return AttributedString(attributedString) + } + + func isEmailAddress() -> Bool { + self.wholeMatch(of: RegularExpressions.emailAddress) != nil + } + + func isWebURL() -> Bool { + guard let url = URL(string: self) else { + return false + } + switch url.scheme?.lowercased() { + case "http", "https": + return true + default: + return false + } + } + + func isImageURL() -> Bool { + guard let url = URL(string: self) else { + return false + } + + switch url.pathExtension.lowercased() { + case "jpg", "jpeg", "png", "gif": + return true + default: + return false + } + } + + func convertingLinksToMarkdown() -> String { +// var cp = String(self) + + self.replacing(RegularExpressions.relaxedLink) { match in + let linkText = self[match.range] + + // Only add https:// if the link doesn't already have a scheme + let hasScheme = (try? RegularExpressions.supportedLinkScheme.prefixMatch(in: linkText)) != nil + let url = hasScheme ? String(linkText) : "https://\(linkText)" + + return "[\(linkText)](\(url))" + } + +// cp.replace(RegularExpressions.relaxedLink) { match -> String in +// let linkText = self[match.range] +// var injectedScheme = "https://" +// if let _ = try? RegularExpressions.supportedLinkScheme.prefixMatch(in: linkText) { +// injectedScheme = "" +// } +// +// return "[\(linkText)](\(injectedScheme)\(linkText))" +// } +// return cp + } +} + +// MARK: - + +extension Binding where Value: OptionSet, Value == Value.Element { + func bindedValue(_ options: Value) -> Bool { + return wrappedValue.contains(options) + } + + func bind(_ options: Value) -> Binding { + return .init { () -> Bool in + self.wrappedValue.contains(options) + } set: { newValue in + if newValue { + self.wrappedValue.insert(options) + } else { + self.wrappedValue.remove(options) + } + } + } +} + +// MARK: - + +extension Color { + init(hex: Int, opacity: Double = 1.0) { + self.init(red: Double((hex >> 16) & 0xFF) / 255.0, green: Double((hex >> 8) & 0xFF) / 255.0, blue: Double(hex & 0xFF) / 255.0, opacity: opacity) + } +} diff --git a/Hotline/Library/FileIconView.swift b/Hotline/Library/FileIconView.swift deleted file mode 100644 index d0949fa..0000000 --- a/Hotline/Library/FileIconView.swift +++ /dev/null @@ -1,74 +0,0 @@ -import SwiftUI -import UniformTypeIdentifiers - -struct FolderIconView: View { - private func folderIcon() -> Image { -#if os(iOS) - return Image(systemName: "folder.fill") -#elseif os(macOS) - return Image(nsImage: NSWorkspace.shared.icon(for: UTType.folder)) -#endif - } - - var body: some View { - folderIcon() - .resizable() - .scaledToFit() - } -} - -struct FileIconView: View { - let filename: String - let fileType: String? - - #if os(iOS) - private func fileIcon() -> Image { - let fileExtension = (self.filename as NSString).pathExtension - if let fileType = UTType(filenameExtension: fileExtension) { - if fileType.isSubtype(of: .movie) { - return Image(systemName: "play.rectangle") - } - else if fileType.isSubtype(of: .image) { - return Image(systemName: "photo") - } - else if fileType.isSubtype(of: .archive) { - return Image(systemName: "doc.zipper") - } - else if fileType.isSubtype(of: .text) { - return Image(systemName: "doc.text") - } - else { - return Image(systemName: "doc") - } - } - - return Image(systemName: "doc") - } - #elseif os(macOS) - private func fileIcon() -> Image { - let fileExtension = (self.filename as NSString).pathExtension - - if !fileExtension.isEmpty, - let uttype = UTType(filenameExtension: fileExtension) { - return Image(nsImage: NSWorkspace.shared.icon(for: uttype)) - } - else if let fileType = self.fileType, - let fileTypeExtension = FileManager.HFSTypeToExtension[fileType.lowercased()], - let uttype = UTType(filenameExtension: fileTypeExtension) { - return Image(nsImage: NSWorkspace.shared.icon(for: uttype)) - } - else { - return Image(nsImage: NSWorkspace.shared.icon(for: UTType.data)) - } - -// Image(nsImage: NSWorkspace.shared.icon(for: UTType(filenameExtension: (filename as NSString).pathExtension) ?? UTType.content)) - } - #endif - - - var body: some View { - fileIcon() - .resizable() - .scaledToFit() - } -} diff --git a/Hotline/Library/FileImageView.swift b/Hotline/Library/FileImageView.swift deleted file mode 100644 index 0cc50f1..0000000 --- a/Hotline/Library/FileImageView.swift +++ /dev/null @@ -1,98 +0,0 @@ -import SwiftUI - -struct FileImageView: NSViewRepresentable { - var image: NSImage? - - let minimumSize: CGSize = CGSize(width: 350, height: 350) - let presentationPaddingRatio: Double = 0.5 - - func makeNSView(context: Context) -> NSImageView { - let imageView = NSImageView() - imageView.imageScaling = .scaleProportionallyUpOrDown - imageView.animates = true - imageView.isEditable = false - imageView.allowsCutCopyPaste = true - imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) - imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - - if let img = self.image { - imageView.image = img - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - self.resizeWindowForImageView(imageView) - } - } - - return imageView - } - - func updateNSView(_ nsView: NSImageView, context: Context) { - nsView.image = self.image - } - - // MARK: - - - func resizeWindowForImageView(_ imageView: NSImageView) { - guard let window = imageView.window, let img = imageView.image else { - return - } - - var windowRect = window.contentLayoutRect - let windowChromeSize = CGSize(width: window.frame.width - windowRect.width, height: window.frame.height - windowRect.height) - - var windowMinSize: CGSize = windowRect.size - let centerPoint = CGPoint(x: window.frame.midX, y: window.frame.midY) - - windowRect.size = img.size - - if let screen = window.screen { - var paddedScreenSize = screen.frame.size - paddedScreenSize.width *= self.presentationPaddingRatio - paddedScreenSize.height *= self.presentationPaddingRatio - - windowMinSize = aspectFit(source: windowRect.size, bounds: self.minimumSize) - windowRect.size = aspectFit(source: windowRect.size, bounds: paddedScreenSize, minimum: windowMinSize) - } - - windowRect.size.width += windowChromeSize.width - windowRect.size.height += windowChromeSize.height - - windowRect.origin.x = centerPoint.x - windowRect.width / 2.0 - windowRect.origin.y = centerPoint.y - windowRect.height / 2.0 - - window.setFrame(windowRect, display: true, animate: true) - -// Do these APIs even work?? -// window.aspectRatio = windowRect.size -// window.contentAspectRatio = windowRect.size - } - - func aspectFit(source sourceSize: CGSize, bounds boundingSize: CGSize, minimum minSize: CGSize? = nil) -> CGSize { - let sourceAspectRatio = sourceSize.width / sourceSize.height - - var fitSize: CGSize = sourceSize - - if fitSize.width > boundingSize.width { - fitSize.width = boundingSize.width - fitSize.height = fitSize.width / sourceAspectRatio - } - - if fitSize.height > boundingSize.height { - fitSize.height = boundingSize.height - fitSize.width = fitSize.height * sourceAspectRatio - } - - if let m = minSize { - if fitSize.width < m.width { - fitSize.width = m.width - fitSize.height = fitSize.width / sourceAspectRatio - } - - if fitSize.height < m.height { - fitSize.height = m.height - fitSize.width = fitSize.height * sourceAspectRatio - } - } - - return fitSize - } -} diff --git a/Hotline/Library/HotlinePanel.swift b/Hotline/Library/HotlinePanel.swift deleted file mode 100644 index d7d8284..0000000 --- a/Hotline/Library/HotlinePanel.swift +++ /dev/null @@ -1,64 +0,0 @@ -import Cocoa -import SwiftUI - -fileprivate let HOTLINE_PANEL_SIZE: CGSize = CGSizeMake(468, 114 - 10) - -class HotlinePanel: NSPanel { - init(_ view: HotlinePanelView) { - super.init(contentRect: NSRect(x: 0, y: 0, width: HOTLINE_PANEL_SIZE.width, height: HOTLINE_PANEL_SIZE.height), styleMask: [.nonactivatingPanel, .titled, .closable, .utilityWindow, .fullSizeContentView], backing: .buffered, defer: false) - - // Make sure that the panel is in front of almost all other windows - self.isFloatingPanel = true - self.level = .floating - self.hidesOnDeactivate = true - self.animationBehavior = .utilityWindow - - // Allow the panelto appear in a fullscreen space -// self.collectionBehavior.insert(.fullScreenAuxiliary) - self.collectionBehavior.insert(.canJoinAllSpaces) - self.collectionBehavior.insert(.ignoresCycle) - -// self.appearance = NSAppearance(named: .vibrantDark) - - // Don't delete panel state when it's closed. - self.isReleasedWhenClosed = false - - self.standardWindowButton(.closeButton)?.isHidden = false - self.standardWindowButton(.zoomButton)?.isHidden = true - self.standardWindowButton(.miniaturizeButton)?.isHidden = true - - // Make it transparent, the view inside will have to set the background. - // This is necessary because otherwise, we will have some space for the titlebar on top of the height of the view itself which we don't want. - self.isOpaque = false - self.backgroundColor = .clear - - // Since we don't show a statusbar, this allows us to drag the window by its background instead of the titlebar. - self.isMovableByWindowBackground = true - self.titlebarAppearsTransparent = true - - let hostingView = NSHostingView(rootView: view.edgesIgnoringSafeArea(.top)) - hostingView.sizingOptions = [.preferredContentSize] - - let visualEffectView = NSVisualEffectView(frame: NSRect(x: 0, y: 0, width: HOTLINE_PANEL_SIZE.width, height: HOTLINE_PANEL_SIZE.height)) - visualEffectView.material = .sidebar - visualEffectView.blendingMode = .behindWindow - visualEffectView.state = NSVisualEffectView.State.active - visualEffectView.autoresizingMask = [.width, .height] - visualEffectView.autoresizesSubviews = true - visualEffectView.addSubview(hostingView) - - self.contentView = visualEffectView - - hostingView.frame = visualEffectView.bounds - - self.cascadeTopLeft(from: NSMakePoint(16, 16)) - } - - override var canBecomeKey: Bool { - return false - } - - override var canBecomeMain: Bool { - return false - } -} diff --git a/Hotline/Library/NetSocket/FileProgress.swift b/Hotline/Library/NetSocket/FileProgress.swift index 1cc0228..c2306f3 100644 --- a/Hotline/Library/NetSocket/FileProgress.swift +++ b/Hotline/Library/NetSocket/FileProgress.swift @@ -1,4 +1,4 @@ -// NetSocketProgress +// NetSocket FileProgress // Dustin Mierau • @mierau // MIT License diff --git a/Hotline/Library/NetSocket/NetSocket.swift b/Hotline/Library/NetSocket/NetSocket.swift new file mode 100644 index 0000000..5c7d185 --- /dev/null +++ b/Hotline/Library/NetSocket/NetSocket.swift @@ -0,0 +1,1123 @@ +// NetSocket.swift +// Dustin Mierau • @mierau +// MIT License + +import Foundation +import Network + +/// Byte order for multi-byte integer values in binary protocols +public enum Endian { + /// Big-endian (network byte order, most significant byte first) + case big + /// Little-endian (least significant byte first) + case little +} + +/// Delimiter patterns for text-based protocols +public enum Delimiter { + /// Custom single byte delimiter + case byte(UInt8) + /// Null terminator (0x00) + case zeroByte + /// Line feed (\n, 0x0A) + case lineFeed + /// Carriage return + line feed (\r\n, 0x0D 0x0A) + case carriageReturnLineFeed + + /// Binary representation of this delimiter + var data: Data { + switch self { + case .byte(let b): return Data([b]) + case .zeroByte: return Data([0x00]) + case .lineFeed: return Data([0x0A]) + case .carriageReturnLineFeed: return Data([0x0D, 0x0A]) + } + } +} + +/// TLS/SSL encryption policy for socket connections +public struct TLSPolicy: Sendable { + /// Create a TLS-enabled policy with optional custom configuration + /// - Parameter configure: Optional closure to customize TLS options + public static func enabled(_ configure: (@Sendable (NWProtocolTLS.Options) -> Void)? = nil) -> TLSPolicy { + TLSPolicy(enabled: true, configure: configure) + } + + /// Create a policy with TLS disabled (plaintext connection) + public static var disabled: TLSPolicy { TLSPolicy(enabled: false, configure: nil) } + + /// Whether TLS is enabled + public let enabled: Bool + /// Optional TLS configuration closure + public let configure: (@Sendable (NWProtocolTLS.Options) -> Void)? +} + +// MARK: - Errors + +/// Errors that can occur during socket operations +public enum NetSocketError: Error, CustomStringConvertible, Sendable { + /// Socket is not yet in ready state + case notReady + /// Connection has been closed + case closed + /// Invalid port number provided + case invalidPort + /// Network operation failed with underlying error + case failed(underlying: Error) + /// Not enough data available to fulfill read request + case insufficientData(expected: Int, got: Int) + /// Frame size exceeds configured maximum + case framingExceeded(max: Int) + /// Failed to decode data + case decodeFailed(Error) + /// Failed to encode data + case encodeFailed(Error) + + public var description: String { + switch self { + case .notReady: return "Connection not ready." + case .closed: return "Connection closed." + case .invalidPort: return "Invalid port number." + case .failed(let e): return "Network failure: \(e.localizedDescription)" + case .insufficientData(let exp, let got): return "Insufficient data: need \(exp), have \(got)." + case .framingExceeded(let max): return "Frame length exceeded maximum \(max)." + case .decodeFailed(let e): return "Decoding failed: \(e)" + case .encodeFailed(let e): return "Encoding failed: \(e)" + } + } +} + +/// An async/await TCP socket with automatic buffering and framing support +/// +/// NetSocket provides: +/// - Async connection management +/// - Automatic receive buffering with memory compaction +/// - Type-safe reading/writing of integers, strings, and custom types +/// - File upload/download with progress tracking +/// +/// Example usage: +/// ```swift +/// let socket = try await NetSocket.connect(host: "example.com", port: 80) +/// try await socket.write("Hello\n".data(using: .utf8)!) +/// let response = try await socket.readUntil(delimiter: .lineFeed) +/// ``` +public actor NetSocket { + /// Configuration options for the socket + public struct Config: Sendable { + /// Size of chunks to receive from network at once (default: 64 KB) + public var receiveChunk: Int = 64 * 1024 + /// Maximum bytes to buffer before disconnecting (default: 8 MB) + public var maxBufferBytes: Int = 8 * 1024 * 1024 + public init() {} + } + + // Connection + state + private let connection: NWConnection + private let queue = DispatchQueue(label: "NetSocket.NWConnection") + private var ready = false + private var isClosed = false + private let connectionID: String // For logging + + // Buffer with compaction + private var buffer = Data() + private var head = 0 // start of unread bytes + private let config: Config + + // Waiters for data/ready + private var dataWaiters: [CheckedContinuation] = [] + private var readyWaiters: [CheckedContinuation] = [] + + // MARK: Init + + private init(connection: NWConnection, config: Config) { + self.connection = connection + self.config = config + // Create a human-readable connection ID for logging + if case .hostPort(host: let h, port: let p) = connection.endpoint { + self.connectionID = "\(h):\(p)" + } else { + self.connectionID = "unknown" + } + } + + // MARK: Connect + + /// Connect to a remote host and return a ready socket + /// + /// This method establishes a TCP connection using Network framework types and waits until + /// the connection is in `.ready` state. + /// + /// - Parameters: + /// - host: Network framework host (e.g., `.name("example.com", nil)` or `.ipv4(...)`) + /// - port: Network framework port + /// - tls: TLS policy (default: enabled with default settings) + /// - config: Socket configuration (default: standard settings) + /// - Returns: A connected and ready `NetSocket` + /// - Throws: Network errors or connection failures + public static func connect(host: NWEndpoint.Host, port: NWEndpoint.Port, tls: TLSPolicy = .enabled(), config: Config = .init()) async throws -> NetSocket { + let parameters = NWParameters.tcp + if tls.enabled { + let tlsOptions = NWProtocolTLS.Options() + tls.configure?(tlsOptions) + parameters.defaultProtocolStack.applicationProtocols.insert(tlsOptions, at: 0) + } + + let conn = NWConnection(host: host, port: port, using: parameters) + let socket = NetSocket(connection: conn, config: config) + try await socket.start() + return socket + } + + /// Convenience wrapper to connect using string hostname and integer port + public static func connect(host: String, port: UInt16, tls: TLSPolicy = .enabled(), config: Config = .init()) async throws -> NetSocket { + guard let nwPort = NWEndpoint.Port(rawValue: port) else { + throw NetSocketError.invalidPort + } + return try await self.connect(host: .name(host, nil), port: nwPort, tls: tls, config: config) + } + + // MARK: Close + + /// Close the connection gracefully + /// + /// Performs a graceful shutdown of the underlying network connection (e.g., TCP FIN) + /// and wakes all pending read/write operations with a `NetSocketError.closed` error. + /// This method is idempotent - subsequent calls are ignored. + /// + /// Use `forceClose()` for immediate non-graceful termination (e.g., TCP RST). + public func close() { + guard !isClosed else { return } + isClosed = true + connection.cancel() + resumeDataWaiters() + resumeReadyWaiters(with: .failure(NetSocketError.closed)) + } + + /// Force close the connection immediately (non-graceful) + /// + /// Performs an immediate non-graceful shutdown of the underlying network connection + /// (e.g., TCP RST). Use this when you need to terminate the connection immediately + /// without waiting for graceful closure. For normal shutdown, use `close()` instead. + /// + /// This method is idempotent - subsequent calls are ignored. + public func forceClose() { + guard !isClosed else { return } + isClosed = true + connection.forceCancel() + resumeDataWaiters() + resumeReadyWaiters(with: .failure(NetSocketError.closed)) + } + + // MARK: Send Data + + /// Write raw data to the socket + /// + /// Sends data and waits for confirmation that it has been processed by the network stack. + /// + /// - Parameter data: Raw bytes to send + /// - Throws: `NetSocketError` if connection is not ready or send fails + @discardableResult + public func write(_ data: Data) async throws -> Int { + try await ensureReady() + return try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + connection.send(content: data, completion: .contentProcessed { error in + if let error { cont.resume(throwing: NetSocketError.failed(underlying: error)) } + else { cont.resume(returning: data.count) } + }) + } + } + + /// Write a fixed-width integer to the socket + /// + /// - Parameters: + /// - value: The integer value to write + /// - endian: Byte order (default: big-endian) + /// - Throws: `NetSocketError` if write fails + @discardableResult + public func write(_ value: T, endian: Endian = .big) async throws -> Int { + var v = value + switch endian { + case .big: v = T(bigEndian: value) + case .little: v = T(littleEndian: value) + } + var copy = v + let size = MemoryLayout.size + let bytes = withUnsafePointer(to: ©) { + Data(bytes: $0, count: size) + } + try await write(bytes) + return bytes.count + } + + /// Write a boolean as a single byte (0 or 1) + /// - Parameter value: Boolean value + @discardableResult + public func write(_ value: Bool) async throws -> Int { + return try await write(UInt8(value ? 0x01 : 0x00)) + } + + /// Write a Float as its IEEE 754 bit pattern + /// - Parameters: + /// - value: Float value + /// - endian: Byte order (default: big-endian) + @discardableResult + public func write(_ value: Float, endian: Endian = .big) async throws -> Int { + return try await write(value.bitPattern, endian: endian) + } + + /// Write a Double as its IEEE 754 bit pattern + /// - Parameters: + /// - value: Double value + /// - endian: Byte order (default: big-endian) + @discardableResult + public func write(_ value: Double, endian: Endian = .big) async throws -> Int { + return try await write(value.bitPattern, endian: endian) + } + + /// Write a string to the socket, optionally length-prefixed + /// + /// - Parameters: + /// - string: String to write + /// - encoding: Text encoding (default: UTF-8) + /// - allowLossyConversion: Allow lossy encoding if necessary (default: false) + /// - Throws: `NetSocketError` if encoding fails or write fails + @discardableResult + public func write(_ string: String, encoding: String.Encoding = .utf8, allowLossyConversion: Bool = false) async throws -> Int { + guard let data = string.data(using: encoding, allowLossyConversion: allowLossyConversion) else { + throw NetSocketError.encodeFailed(NSError(domain: "StringEncoding", code: -1)) + } + return try await write(data) + } + + // MARK: Receive Data + + /// Read data until a delimiter is found + /// + /// Searches the buffer for the delimiter pattern and returns all data up to (and optionally including) + /// the delimiter. The delimiter is always consumed from the stream. + /// + /// - Parameters: + /// - delimiter: Binary delimiter pattern to search for + /// - maxBytes: Maximum bytes to read before throwing (default: no limit) + /// - includeDelimiter: Whether to include delimiter in result (default: false) + /// - Returns: Data read from stream + /// - Throws: `NetSocketError.framingExceeded` if max bytes exceeded, or connection errors + public func read(past delimiter: Data, maxBytes: Int? = nil, includeDelimiter: Bool = false) async throws -> Data { + while true { + try Task.checkCancellation() + if let r = search(delimiter: delimiter) { + let consumeLen = r.upperBound - head + let data = try await read(consumeLen) + return includeDelimiter ? data : data.dropLast(delimiter.count) + } + if let maxBytes, availableBytes >= maxBytes { + throw NetSocketError.framingExceeded(max: maxBytes) + } + try await waitForData() + guard !isClosed || availableBytes > 0 else { throw NetSocketError.closed } + } + } + + /// Read exactly N bytes from the socket + /// + /// Waits for data to arrive if buffer doesn't contain enough bytes yet. The internal buffer + /// is automatically compacted after reading to prevent unbounded memory growth. + /// + /// - Parameter count: Number of bytes to read + /// - Returns: Exactly `count` bytes + /// - Throws: `NetSocketError.insufficientData` if connection closes before enough data arrives + public func read(_ count: Int) async throws -> Data { + try await self.ensureReadable(count) + let start = self.head + let end = self.head + count + let slice = self.buffer[start..(_ type: T.Type = T.self, endian: Endian = .big) async throws -> T { + let size = MemoryLayout.size + let data = try await self.read(size) + let value: T = data.withUnsafeBytes { raw in + raw.load(as: T.self) + } + switch endian { + case .big: return T(bigEndian: value) + case .little: return T(littleEndian: value) + } + } + + /// Read a fixed-length string + /// + /// - Parameters: + /// - length: Number of bytes to read + /// - encoding: Text encoding (default: UTF-8) + /// - Returns: Decoded string + /// - Throws: `NetSocketError` if decoding fails or insufficient data + public func read(_ length: Int, encoding: String.Encoding = .utf8) async throws -> String { + let data = try await self.read(length) + guard let s = String(data: data, encoding: encoding) else { + throw NetSocketError.decodeFailed(NSError()) + } + return s + } + + /// Read a string until a delimiter is found + /// + /// - Parameters: + /// - delimiter: Delimiter pattern to search for + /// - maxBytes: Maximum bytes to read before throwing (default: no limit) + /// - includeDelimiter: Whether to include delimiter in result (default: false) + /// - Returns: String read from stream (delimiter consumed but not included unless specified) + /// - Throws: `NetSocketError` if decoding fails, max bytes exceeded, or connection closed + public func read(until delimiter: Delimiter, maxBytes: Int? = nil, includeDelimiter: Bool = false) async throws -> String { + let bytes = try await read(past: delimiter.data, maxBytes: maxBytes, includeDelimiter: includeDelimiter) + guard let s = String(data: bytes, encoding: .utf8) else { throw NetSocketError.decodeFailed(NSError()) } + return s + } + + /// Read exactly N bytes with progress callbacks + /// + /// Like `read(_:)`, but reads in chunks and reports progress after each chunk. + /// Useful for downloading large amounts of data where you want to update UI progress. + /// + /// Example: + /// ```swift + /// let data = try await socket.read(1_000_000) { current, total in + /// print("Progress: \(current)/\(total)") + /// } + /// ``` + /// + /// - Parameters: + /// - count: Number of bytes to read + /// - chunkSize: Size of chunks to read at a time (default: 8192) + /// - progress: Optional callback with (bytesReceived, totalBytes) + /// - Returns: Exactly `count` bytes + /// - Throws: `NetSocketError` if connection closes before enough data arrives + public func read( + _ count: Int, + chunkSize: Int = 8192, + progress: (@Sendable (Int, Int) -> Void)? = nil + ) async throws -> Data { + var data = Data() + data.reserveCapacity(count) + var received = 0 + + while received < count { + try Task.checkCancellation() + let toRead = min(chunkSize, count - received) + let chunk = try await read(toRead) + data.append(chunk) + received += chunk.count + progress?(received, count) + } + + return data + } + + // MARK: Peek Data + + public var availableBytes: Int { self.buffer.count - self.head } + + public func peek(_ count: Int) -> Data? { + guard self.availableBytes >= count else { + return nil + } + + let slice = self.buffer[self.head..<(self.head + count)] + return Data(slice) // Don't advance head + } + + public func peek(upto count: Int) -> Data { + let amount = min(self.availableBytes, count) + guard amount > 0 else { + return Data() + } + + let slice = self.buffer[self.head..<(self.head + amount)] + return Data(slice) + } + + public func peek(awaiting count: Int) async throws -> Data { + try await self.ensureReadable(count) + let slice = self.buffer[self.head..<(self.head + count)] + return Data(slice) // Don't advance head + } + + // MARK: Skip Data + + /// Skip/discard exactly N bytes from the stream without allocating memory + public func skip(_ count: Int) async throws { + guard count > 0 else { return } + try await self.ensureReadable(count) + self.head += count + self.compactIfNeeded() + } + + /// Skip until delimiter is found (discards delimiter too) + public func skip(past delimiter: Data) async throws { + while true { + try Task.checkCancellation() + if let r = self.search(delimiter: delimiter) { + self.head = r.upperBound // Skip to end of delimiter + self.compactIfNeeded() + return + } + try await self.waitForData() + guard !self.isClosed else { + throw NetSocketError.closed + } + } + } + + // MARK: Files + + /// Upload a file from a URL, yielding progress as an AsyncSequence. + /// + /// Iterating this sequence drives the transfer. Each yielded value reports + /// the total bytes sent so far and the known total. Cancel the consuming + /// task to cancel the transfer. + /// + /// This method handles opening and closing the file handle automatically. + /// + /// - Parameters: + /// - url: File URL to upload. + /// - chunkSize: Size of each read chunk. + /// - Returns: An `AsyncThrowingStream` of `FileProgress` updates. + func writeFile(from url: URL, chunkSize: Int = 256 * 1024) -> AsyncThrowingStream { + // This stream wrapper manages the FileHandle's lifetime. + return AsyncThrowingStream(bufferingPolicy: .bufferingOldest(1)) { continuation in + // Capture self (the actor) to use in detached task + let actor = self + + // Open file on a background thread (file I/O is blocking) + let task = Task.detached { + let fh: FileHandle + let total: Int + + // 1. Open file and get length (blocking I/O, done off-actor) + do { + total = Int(try NetSocket.fileLength(at: url)) + fh = try FileHandle(forReadingFrom: url) + } catch { + continuation.finish(throwing: NetSocketError.failed(underlying: error)) + return + } + + // 2. Now switch to the actor context to call the actor-isolated method + let stream = await actor.writeFile( + from: fh, + length: total, + chunkSize: chunkSize + ) + + // 3. Forward all elements from the underlying stream to our stream + do { + for try await progress in stream { + try Task.checkCancellation() // Exit early if cancelled + continuation.yield(progress) + } + try? fh.close() + continuation.finish() + } catch is CancellationError { + try? fh.close() + continuation.finish() + } catch { + try? fh.close() + continuation.finish(throwing: error) + } + } + + // If the *consumer* cancels the stream, we cancel our managing task. + continuation.onTermination = { @Sendable _ in + task.cancel() + } + } + } + + /// Upload a file from an open FileHandle, yielding progress as an AsyncSequence. + /// + /// Iterating this sequence drives the transfer. Each yielded value reports + /// the total bytes sent so far and the known total. Cancel the consuming + /// task to cancel the transfer. + /// + /// **Note:** The caller is responsible for opening and closing the `fileHandle`. + /// + /// - Parameters: + /// - fileHandle: Open `FileHandle` for reading. + /// - length: Exact number of bytes to send (total file size). + /// - chunkSize: Size of each read chunk. + /// - Returns: An `AsyncThrowingStream` of `FileProgress` updates. + func writeFile(from fileHandle: FileHandle, length: Int, chunkSize: Int = 256 * 1024) -> AsyncThrowingStream { + precondition(length >= 0, "length must be >= 0") + + if length == 0 { + return AsyncThrowingStream { continuation in + continuation.yield(.init(sent: 0, total: 0, bytesPerSecond: 0, estimatedTimeRemaining: 0)) + continuation.finish() + } + } + + return AsyncThrowingStream(bufferingPolicy: .bufferingOldest(1)) { continuation in + let task = Task { [weak self] in + guard let self else { + continuation.finish() + return + } + + var estimator = TransferRateEstimator(total: Int(length)) + + do { + try await self.ensureReady() + + while estimator.transferred < length { + try Task.checkCancellation() + + let toRead = Int(min(chunkSize, length - estimator.transferred)) + + // Read from disk + guard let chunk = try fileHandle.read(upToCount: toRead), !chunk.isEmpty else { + if estimator.transferred < length { + throw NetSocketError.failed(underlying: NSError( + domain: "NetSocket", code: 9001, + userInfo: [NSLocalizedDescriptionKey: "File read ended prematurely. Expected \(length) bytes, got \(estimator.transferred)."] + )) + } + break + } + + // Write to network + try await self.write(chunk) + + // Update estimator and yield progress + let progress = estimator.update(bytes: chunk.count) + continuation.yield(progress) + } + + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { @Sendable _ in + task.cancel() + } + } + } + + /// Receive a file of known length and yield progress updates as an AsyncSequence. + /// + /// Iterating this sequence drives the transfer. Each yielded value reports + /// the total bytes written so far and the known total. Cancel the consuming + /// task to cancel the transfer. + /// + /// - Parameters: + /// - fileHandle: Open `FileHandle` for writing (caller must close). + /// - length: Exact number of bytes expected. + /// - chunkSize: Size of each read chunk. + /// - Returns: An `AsyncThrowingStream` of `FileProgress` updates. + func receiveFile(to fileHandle: FileHandle, length: Int, chunkSize: Int = 256 * 1024) -> AsyncThrowingStream { + precondition(length >= 0, "length must be >= 0") + + if length == 0 { + return AsyncThrowingStream { continuation in + continuation.yield(.init(sent: 0, total: 0, bytesPerSecond: 0, estimatedTimeRemaining: 0)) + continuation.finish() + } + } + + return AsyncThrowingStream(bufferingPolicy: .bufferingOldest(1)) { continuation in + let task = Task { [weak self] in + guard let self else { + continuation.finish() + return + } + + var estimator = TransferRateEstimator(total: length) + + do { + var remaining: Int = length + + while remaining > 0 { + try Task.checkCancellation() + let n = min(chunkSize, remaining) + + let chunk = try await self.read(n) + try fileHandle.write(contentsOf: chunk) + + let chunkSize = Int(chunk.count) + remaining -= chunkSize + let progress = estimator.update(bytes: chunkSize) + continuation.yield(progress) + } + + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { @Sendable _ in + task.cancel() + } + } + } + + /// Download a file of known length and write it to disk in chunks + /// + /// This method does **not** read a length prefix. The caller must provide the expected + /// file size (e.g., from protocol metadata). The file is streamed directly to disk to + /// avoid loading it entirely into memory. + /// + /// Supports atomic writes: when enabled, data is written to a temporary `.part` file and + /// renamed on success. If an error occurs, the temporary file is automatically cleaned up. + /// + /// - Parameters: + /// - url: Destination file URL + /// - length: Exact number of bytes to read (must match what's on the wire) + /// - chunkSize: Chunk size for reading/writing (default: 256 KB) + /// - overwrite: Whether to overwrite existing file (default: true) + /// - atomic: Write to temporary file and rename on success (default: true) + /// - progress: Optional progress callback + /// - Returns: Total bytes written (equals `length` on success) + /// - Throws: File I/O or network errors. On atomic writes, partial files are cleaned up. + /// + /// Example: + /// ```swift + /// // Hotline protocol: file size comes from transaction header + /// let transaction = try await socket.receive(HotlineTransaction.self) + /// try await socket.receiveFile( + /// to: destinationURL, + /// length: transaction.fileSize + /// ) + /// ``` + @discardableResult + func receiveFile( + to url: URL, + length: Int, + chunkSize: Int = 256 * 1024, + overwrite: Bool = true, + atomic: Bool = true, + progress: (@Sendable (FileProgress) -> Void)? = nil + ) async throws -> Int { + precondition(length >= 0, "length must be >= 0") + + // Fast path: nothing to do + if length == 0 { + if overwrite { try? FileManager.default.removeItem(at: url) } + FileManager.default.createFile(atPath: url.path, contents: Data(), attributes: nil) + return 0 + } + + // Prepare destination (optionally atomic) + let fm = FileManager.default + let dir = url.deletingLastPathComponent() + let tmp = atomic ? dir.appendingPathComponent(".\(url.lastPathComponent).part-\(UUID().uuidString)") : url + + if overwrite { try? fm.removeItem(at: tmp) } + if overwrite, !atomic { try? fm.removeItem(at: url) } + + // Create and open the file for writing + fm.createFile(atPath: tmp.path, contents: nil, attributes: nil) + let fh = try FileHandle(forWritingTo: tmp) + defer { try? fh.close() } + + var remaining: Int = length + var written: Int = 0 + + do { + while remaining > 0 { + try Task.checkCancellation() + let n = Int(min(chunkSize, remaining)) + let chunk = try await self.read(n) + try fh.write(contentsOf: chunk) + remaining -= n + written += Int(n) + progress?(.init(sent: written, total: length)) + } + } catch { + // Cleanup partial file on failure if we were writing atomically + if atomic { try? fm.removeItem(at: tmp) } + throw error + } + + // Atomically move into place if requested + if atomic { + if overwrite { try? fm.removeItem(at: url) } + try fm.moveItem(at: tmp, to: url) + } + + return written + } + + // MARK: Internals + + private func start() async throws { + self.connection.stateUpdateHandler = { state in + Task { [weak self] in + guard let self else { return } + switch state { + case .ready: + await self.setReady() + await self.resumeReadyWaiters(with: .success(())) + case .failed(let error): + await self.failAllWaiters(NetSocketError.failed(underlying: error)) + await self.setClosed() + case .waiting(let error): + // bubble as transient failure for awaiters; reconnect logic could live here + await self.resumeReadyWaiters(with: .failure(NetSocketError.failed(underlying: error))) + case .cancelled: + await self.failAllWaiters(NetSocketError.closed) + await self.setClosed() + default: + break + } + } + } + + // Kick off receive loop after .start + self.connection.start(queue: queue) + try await self.waitUntilReady() + self.startReceiveLoop() + } + + private func startReceiveLoop() { + @Sendable func loop(_ connection: NWConnection, chunk: Int, owner: NetSocket, connID: String) { + connection.receive(minimumIncompleteLength: 1, maximumLength: chunk) { [weak owner] data, _, isComplete, error in + Task { + guard let o = owner else { + return + } + + if let error { + await o.handleReceiveError(error) + return + } + if let data, !data.isEmpty { + await o.append(data, connID: connID) + } + if isComplete { + await o.handleEOF() + return + } + loop(connection, chunk: chunk, owner: o, connID: connID) + } + } + } + loop(connection, chunk: self.config.receiveChunk, owner: self, connID: connectionID) + } + + private func handleReceiveError(_ error: Error) { + self.isClosed = true + self.failAllWaiters(NetSocketError.failed(underlying: error)) + } + + private func handleEOF() { + self.isClosed = true + let waiters = self.dataWaiters + self.dataWaiters.removeAll() + for w in waiters { + w.resume() + } // wake so readers can observe closure + } + + private func setReady() { + self.ready = true + } + + private func setClosed() { + self.isClosed = true + } + + private func ensureReady() async throws { + if self.isClosed { + throw NetSocketError.closed + } + if !self.ready { + try await self.waitUntilReady() + } + } + + private func ensureReadable(_ count: Int) async throws { + try await self.ensureReady() + while self.availableBytes < count { + try Task.checkCancellation() + if self.isClosed { + throw NetSocketError.insufficientData(expected: count, got: self.availableBytes) + } + try await self.waitForData() + } + } + + private func waitForData() async throws { + try Task.checkCancellation() + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + if self.isClosed { + cont.resume() + return + } + self.dataWaiters.append(cont) + } + } + + private func compactIfNeeded() { + // Avoid unbounded memory as head advances + if self.head > 64 * 1024 && self.head > self.buffer.count / 2 { + self.buffer.removeSubrange(0.. Range? { + guard !delimiter.isEmpty, availableBytes >= delimiter.count else { return nil } + let hay = buffer[head.. Int64 { + let values = try url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) + guard values.isRegularFile == true else { + throw NetSocketError.failed(underlying: NSError( + domain: "NetSocket", code: 1001, + userInfo: [NSLocalizedDescriptionKey: "Not a regular file: \(url.path)"] + )) + } + if let s = values.fileSize { return Int64(s) } + let attrs = try FileManager.default.attributesOfItem(atPath: url.path) + if let n = attrs[.size] as? NSNumber { + return n.int64Value + } + throw NetSocketError.failed(underlying: NSError( + domain: "NetSocket", code: 1002, + userInfo: [NSLocalizedDescriptionKey: "Unable to determine file size for \(url.lastPathComponent)"] + )) + } + + private func waitUntilReady() async throws { + guard !self.ready else { return } + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + self.readyWaiters.append(cont) + } + } + + private func resumeReadyWaiters(with result: Result) { + let waiters = self.readyWaiters + self.readyWaiters.removeAll() + for w in waiters { + switch result { + case .success: w.resume() + case .failure(let e): w.resume(throwing: e) + } + } + } + + private func failAllWaiters(_ error: Error) { + self.resumeReadyWaiters(with: .failure(error)) + let waiters = self.dataWaiters + self.dataWaiters.removeAll() + for w in waiters { + w.resume(throwing: error) + } + } + + private func append(_ data: Data, connID: String) { + buffer.append(data) + if buffer.count - head > config.maxBufferBytes { + // Hard stop: drop connection rather than OOM'ing. + isClosed = true + connection.cancel() + failAllWaiters(NetSocketError.framingExceeded(max: config.maxBufferBytes)) + return + } + resumeDataWaiters() + } + + private func resumeDataWaiters() { + let waiters = dataWaiters + dataWaiters.removeAll() + for w in waiters { w.resume() } + } +} + +// MARK: - Utilities + +private extension Data { + mutating func appendInteger(_ value: T, endian: Endian) throws { + var v = value + switch endian { + case .big: v = T(bigEndian: value) + case .little: v = T(littleEndian: value) + } + var copy = v + withUnsafePointer(to: ©) { ptr in + self.append(contentsOf: UnsafeRawBufferPointer(start: ptr, count: MemoryLayout.size)) + } + } +} + +// MARK: - NetSocketEncodable + +/// Protocol for types that can encode themselves to binary data +/// +/// Types conforming to `NetSocketEncodable` produce binary data that can be sent over +/// a socket. Unlike writing field-by-field to the socket, encodable types build complete +/// binary messages that are sent in a single write operation for efficiency. +/// +/// Example: +/// ```swift +/// struct MyMessage: NetSocketEncodable { +/// let id: UInt32 +/// let name: String +/// +/// func encode(endian: Endian) throws -> Data { +/// var data = Data() +/// // Encode fields to data... +/// return data +/// } +/// } +/// +/// try await socket.send(message) +/// ``` +public protocol NetSocketEncodable: Sendable { + /// Encode this value to binary data + /// + /// Implementations should build a complete binary message and return it as Data. + /// The data will be sent to the socket in a single write operation. + /// + /// - Parameter endian: Byte order for multi-byte values + /// - Returns: Encoded binary data ready to send + /// - Throws: Encoding errors + func encode(endian: Endian) throws -> Data +} + +/// Protocol for types that can decode themselves directly from a socket stream +/// +/// Types conforming to `NetSocketDecodable` read field-by-field directly from the socket +/// using async reads. This enables true streaming without buffering entire messages. +/// +/// **Important**: If decoding throws after consuming some bytes (e.g., validation fails), +/// the socket will be left with those bytes consumed. In practice, this usually means the +/// connection should be closed. For most protocols this is acceptable since decode errors +/// indicate corrupt data or protocol violations. +/// +/// Example: +/// ```swift +/// struct MyMessage: NetSocketDecodable { +/// let id: UInt32 +/// let name: String +/// +/// init(from socket: NetSocket, endian: Endian) async throws { +/// self.id = try await socket.read(UInt32.self, endian: endian) +/// let nameLen = try await socket.read(UInt16.self, endian: endian) +/// let nameData = try await socket.readExactly(Int(nameLen)) +/// guard let name = String(data: nameData, encoding: .utf8) else { +/// throw NetSocketError.decodeFailed(NSError()) +/// } +/// self.name = name +/// } +/// } +/// +/// let message = try await socket.receive(MyMessage.self) +/// ``` +public protocol NetSocketDecodable: Sendable { + /// Decode a value by reading directly from the socket stream + /// + /// This initializer should read all necessary fields from the socket using + /// methods like `read(_:endian:)`, `readExactly(_:)`, `readString(length:)`, etc. + /// + /// The socket handles waiting for data to arrive, so you can read field by field + /// without worrying about buffering. + /// + /// - Parameters: + /// - socket: Socket to read from + /// - endian: Byte order for multi-byte values + /// - Throws: Network errors, insufficient data, or custom decoding errors + init(from socket: NetSocket, endian: Endian) async throws +} + +public extension NetSocket { + /// Send an encodable value to the socket + /// + /// The type encodes itself to binary data, which is then sent in a single write operation. + /// + /// Example: + /// ```swift + /// struct MyMessage: NetSocketEncodable { + /// let id: UInt32 + /// let name: String + /// + /// func encode(endian: Endian) throws -> Data { + /// var data = Data() + /// // Build binary message... + /// return data + /// } + /// } + /// + /// try await socket.send(message) + /// ``` + /// + /// - Parameters: + /// - value: Value conforming to NetSocketEncodable + /// - endian: Byte order (default: big-endian) + /// - Throws: Encoding or network errors + func send(_ value: T, endian: Endian = .big) async throws { + let data = try value.encode(endian: endian) + try await self.write(data) + } + + /// Receive and decode a value directly from the socket stream (no length prefix) + /// + /// The type reads field-by-field from the socket as needed, enabling true streaming + /// without buffering entire messages. Useful for protocols where message size isn't + /// known upfront or for progressive decoding. + /// + /// Example: + /// ```swift + /// struct ServerEntry: NetSocketDecodable { + /// let id: UInt32 + /// let name: String + /// + /// init(from socket: NetSocket, endian: Endian) async throws { + /// self.id = try await socket.read(UInt32.self, endian: endian) + /// // Read variable-length string... + /// } + /// } + /// + /// let entry = try await socket.receive(ServerEntry.self) + /// ``` + /// + /// - Parameters: + /// - type: Type conforming to NetSocketDecodable + /// - endian: Byte order (default: big-endian) + /// - Returns: Decoded value + /// - Throws: Decoding or network errors + func receive(_ type: T.Type, endian: Endian = .big) async throws -> T { + return try await T(from: self, endian: endian) + } +} diff --git a/Hotline/Library/NetSocket/NetSocketNew.swift b/Hotline/Library/NetSocket/NetSocketNew.swift deleted file mode 100644 index 12f4271..0000000 --- a/Hotline/Library/NetSocket/NetSocketNew.swift +++ /dev/null @@ -1,1127 +0,0 @@ -// NetSocket.swift -// Dustin Mierau • @mierau - -import Foundation -import Network - -/// Byte order for multi-byte integer values in binary protocols -public enum Endian { - /// Big-endian (network byte order, most significant byte first) - case big - /// Little-endian (least significant byte first) - case little -} - -/// Delimiter patterns for text-based protocols -public enum Delimiter { - /// Custom single byte delimiter - case byte(UInt8) - /// Null terminator (0x00) - case zeroByte - /// Line feed (\n, 0x0A) - case lineFeed - /// Carriage return + line feed (\r\n, 0x0D 0x0A) - case carriageReturnLineFeed - - /// Binary representation of this delimiter - var data: Data { - switch self { - case .byte(let b): return Data([b]) - case .zeroByte: return Data([0x00]) - case .lineFeed: return Data([0x0A]) - case .carriageReturnLineFeed: return Data([0x0D, 0x0A]) - } - } -} - -/// TLS/SSL encryption policy for socket connections -public struct TLSPolicy: Sendable { - /// Create a TLS-enabled policy with optional custom configuration - /// - Parameter configure: Optional closure to customize TLS options - public static func enabled(_ configure: (@Sendable (NWProtocolTLS.Options) -> Void)? = nil) -> TLSPolicy { - TLSPolicy(enabled: true, configure: configure) - } - - /// Create a policy with TLS disabled (plaintext connection) - public static var disabled: TLSPolicy { TLSPolicy(enabled: false, configure: nil) } - - /// Whether TLS is enabled - public let enabled: Bool - /// Optional TLS configuration closure - public let configure: (@Sendable (NWProtocolTLS.Options) -> Void)? -} - -// MARK: - Errors - -/// Errors that can occur during socket operations -public enum NetSocketError: Error, CustomStringConvertible, Sendable { - /// Socket is not yet in ready state - case notReady - /// Connection has been closed - case closed - /// Invalid port number provided - case invalidPort - /// Network operation failed with underlying error - case failed(underlying: Error) - /// Not enough data available to fulfill read request - case insufficientData(expected: Int, got: Int) - /// Frame size exceeds configured maximum - case framingExceeded(max: Int) - /// Failed to decode data - case decodeFailed(Error) - /// Failed to encode data - case encodeFailed(Error) - - public var description: String { - switch self { - case .notReady: return "Connection not ready." - case .closed: return "Connection closed." - case .invalidPort: return "Invalid port number." - case .failed(let e): return "Network failure: \(e.localizedDescription)" - case .insufficientData(let exp, let got): return "Insufficient data: need \(exp), have \(got)." - case .framingExceeded(let max): return "Frame length exceeded maximum \(max)." - case .decodeFailed(let e): return "Decoding failed: \(e)" - case .encodeFailed(let e): return "Encoding failed: \(e)" - } - } -} - -/// An async/await TCP socket with automatic buffering and framing support -/// -/// NetSocket provides: -/// - Async connection management -/// - Automatic receive buffering with memory compaction -/// - Type-safe reading/writing of integers, strings, and custom types -/// - File upload/download with progress tracking -/// -/// Example usage: -/// ```swift -/// let socket = try await NetSocket.connect(host: "example.com", port: 80) -/// try await socket.write("Hello\n".data(using: .utf8)!) -/// let response = try await socket.readUntil(delimiter: .lineFeed) -/// ``` -public actor NetSocket { - /// Configuration options for the socket - public struct Config: Sendable { - /// Size of chunks to receive from network at once (default: 64 KB) - public var receiveChunk: Int = 64 * 1024 - /// Maximum bytes to buffer before disconnecting (default: 8 MB) - public var maxBufferBytes: Int = 8 * 1024 * 1024 - public init() {} - } - - // Connection + state - private let connection: NWConnection - private let queue = DispatchQueue(label: "NetSocket.NWConnection") - private var ready = false - private var isClosed = false - private let connectionID: String // For logging - - // Buffer with compaction - private var buffer = Data() - private var head = 0 // start of unread bytes - private let config: Config - - // Waiters for data/ready - private var dataWaiters: [CheckedContinuation] = [] - private var readyWaiters: [CheckedContinuation] = [] - - // MARK: Init - - private init(connection: NWConnection, config: Config) { - self.connection = connection - self.config = config - // Create a human-readable connection ID for logging - if case .hostPort(host: let h, port: let p) = connection.endpoint { - self.connectionID = "\(h):\(p)" - } else { - self.connectionID = "unknown" - } - } - - // MARK: Connect - - /// Connect to a remote host and return a ready socket - /// - /// This method establishes a TCP connection using Network framework types and waits until - /// the connection is in `.ready` state. - /// - /// - Parameters: - /// - host: Network framework host (e.g., `.name("example.com", nil)` or `.ipv4(...)`) - /// - port: Network framework port - /// - tls: TLS policy (default: enabled with default settings) - /// - config: Socket configuration (default: standard settings) - /// - Returns: A connected and ready `NetSocket` - /// - Throws: Network errors or connection failures - public static func connect(host: NWEndpoint.Host, port: NWEndpoint.Port, tls: TLSPolicy = .enabled(), config: Config = .init()) async throws -> NetSocket { - let parameters = NWParameters.tcp - if tls.enabled { - let tlsOptions = NWProtocolTLS.Options() - tls.configure?(tlsOptions) - parameters.defaultProtocolStack.applicationProtocols.insert(tlsOptions, at: 0) - } - - let conn = NWConnection(host: host, port: port, using: parameters) - let socket = NetSocket(connection: conn, config: config) - try await socket.start() - return socket - } - - /// Convenience wrapper to connect using string hostname and integer port - public static func connect(host: String, port: UInt16, tls: TLSPolicy = .enabled(), config: Config = .init()) async throws -> NetSocket { - guard let nwPort = NWEndpoint.Port(rawValue: port) else { - throw NetSocketError.invalidPort - } - return try await self.connect(host: .name(host, nil), port: nwPort, tls: tls, config: config) - } - - // MARK: Close - - /// Close the connection gracefully - /// - /// Performs a graceful shutdown of the underlying network connection (e.g., TCP FIN) - /// and wakes all pending read/write operations with a `NetSocketError.closed` error. - /// This method is idempotent - subsequent calls are ignored. - /// - /// Use `forceClose()` for immediate non-graceful termination (e.g., TCP RST). - public func close() { - guard !isClosed else { return } - isClosed = true - connection.cancel() - resumeDataWaiters() - resumeReadyWaiters(with: .failure(NetSocketError.closed)) - } - - /// Force close the connection immediately (non-graceful) - /// - /// Performs an immediate non-graceful shutdown of the underlying network connection - /// (e.g., TCP RST). Use this when you need to terminate the connection immediately - /// without waiting for graceful closure. For normal shutdown, use `close()` instead. - /// - /// This method is idempotent - subsequent calls are ignored. - public func forceClose() { - guard !isClosed else { return } - isClosed = true - connection.forceCancel() - resumeDataWaiters() - resumeReadyWaiters(with: .failure(NetSocketError.closed)) - } - - // MARK: Send Data - - /// Write raw data to the socket - /// - /// Sends data and waits for confirmation that it has been processed by the network stack. - /// - /// - Parameter data: Raw bytes to send - /// - Throws: `NetSocketError` if connection is not ready or send fails - @discardableResult - public func write(_ data: Data) async throws -> Int { - try await ensureReady() - return try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in - connection.send(content: data, completion: .contentProcessed { error in - if let error { cont.resume(throwing: NetSocketError.failed(underlying: error)) } - else { cont.resume(returning: data.count) } - }) - } - } - - /// Write a fixed-width integer to the socket - /// - /// - Parameters: - /// - value: The integer value to write - /// - endian: Byte order (default: big-endian) - /// - Throws: `NetSocketError` if write fails - @discardableResult - public func write(_ value: T, endian: Endian = .big) async throws -> Int { - var v = value - switch endian { - case .big: v = T(bigEndian: value) - case .little: v = T(littleEndian: value) - } - var copy = v - let size = MemoryLayout.size - let bytes = withUnsafePointer(to: ©) { - Data(bytes: $0, count: size) - } - try await write(bytes) - return bytes.count - } - - /// Write a boolean as a single byte (0 or 1) - /// - Parameter value: Boolean value - @discardableResult - public func write(_ value: Bool) async throws -> Int { - return try await write(UInt8(value ? 0x01 : 0x00)) - } - - /// Write a Float as its IEEE 754 bit pattern - /// - Parameters: - /// - value: Float value - /// - endian: Byte order (default: big-endian) - @discardableResult - public func write(_ value: Float, endian: Endian = .big) async throws -> Int { - return try await write(value.bitPattern, endian: endian) - } - - /// Write a Double as its IEEE 754 bit pattern - /// - Parameters: - /// - value: Double value - /// - endian: Byte order (default: big-endian) - @discardableResult - public func write(_ value: Double, endian: Endian = .big) async throws -> Int { - return try await write(value.bitPattern, endian: endian) - } - - /// Write a string to the socket, optionally length-prefixed - /// - /// - Parameters: - /// - string: String to write - /// - encoding: Text encoding (default: UTF-8) - /// - allowLossyConversion: Allow lossy encoding if necessary (default: false) - /// - Throws: `NetSocketError` if encoding fails or write fails - @discardableResult - public func write(_ string: String, encoding: String.Encoding = .utf8, allowLossyConversion: Bool = false) async throws -> Int { - guard let data = string.data(using: encoding, allowLossyConversion: allowLossyConversion) else { - throw NetSocketError.encodeFailed(NSError(domain: "StringEncoding", code: -1)) - } - return try await write(data) - } - - // MARK: Receive Data - - /// Read data until a delimiter is found - /// - /// Searches the buffer for the delimiter pattern and returns all data up to (and optionally including) - /// the delimiter. The delimiter is always consumed from the stream. - /// - /// - Parameters: - /// - delimiter: Binary delimiter pattern to search for - /// - maxBytes: Maximum bytes to read before throwing (default: no limit) - /// - includeDelimiter: Whether to include delimiter in result (default: false) - /// - Returns: Data read from stream - /// - Throws: `NetSocketError.framingExceeded` if max bytes exceeded, or connection errors - public func read(past delimiter: Data, maxBytes: Int? = nil, includeDelimiter: Bool = false) async throws -> Data { - while true { - try Task.checkCancellation() - if let r = search(delimiter: delimiter) { - let consumeLen = r.upperBound - head - let data = try await read(consumeLen) - return includeDelimiter ? data : data.dropLast(delimiter.count) - } - if let maxBytes, availableBytes >= maxBytes { - throw NetSocketError.framingExceeded(max: maxBytes) - } - try await waitForData() - guard !isClosed || availableBytes > 0 else { throw NetSocketError.closed } - } - } - - /// Read exactly N bytes from the socket - /// - /// Waits for data to arrive if buffer doesn't contain enough bytes yet. The internal buffer - /// is automatically compacted after reading to prevent unbounded memory growth. - /// - /// - Parameter count: Number of bytes to read - /// - Returns: Exactly `count` bytes - /// - Throws: `NetSocketError.insufficientData` if connection closes before enough data arrives - public func read(_ count: Int) async throws -> Data { - try await self.ensureReadable(count) - let start = self.head - let end = self.head + count - let slice = self.buffer[start..(_ type: T.Type = T.self, endian: Endian = .big) async throws -> T { - let size = MemoryLayout.size - let data = try await self.read(size) - let value: T = data.withUnsafeBytes { raw in - raw.load(as: T.self) - } - switch endian { - case .big: return T(bigEndian: value) - case .little: return T(littleEndian: value) - } - } - - /// Read a fixed-length string - /// - /// - Parameters: - /// - length: Number of bytes to read - /// - encoding: Text encoding (default: UTF-8) - /// - Returns: Decoded string - /// - Throws: `NetSocketError` if decoding fails or insufficient data - public func read(_ length: Int, encoding: String.Encoding = .utf8) async throws -> String { - let data = try await self.read(length) - guard let s = String(data: data, encoding: encoding) else { - throw NetSocketError.decodeFailed(NSError()) - } - return s - } - - /// Read a string until a delimiter is found - /// - /// - Parameters: - /// - delimiter: Delimiter pattern to search for - /// - maxBytes: Maximum bytes to read before throwing (default: no limit) - /// - includeDelimiter: Whether to include delimiter in result (default: false) - /// - Returns: String read from stream (delimiter consumed but not included unless specified) - /// - Throws: `NetSocketError` if decoding fails, max bytes exceeded, or connection closed - public func read(until delimiter: Delimiter, maxBytes: Int? = nil, includeDelimiter: Bool = false) async throws -> String { - let bytes = try await read(past: delimiter.data, maxBytes: maxBytes, includeDelimiter: includeDelimiter) - guard let s = String(data: bytes, encoding: .utf8) else { throw NetSocketError.decodeFailed(NSError()) } - return s - } - - /// Read exactly N bytes with progress callbacks - /// - /// Like `read(_:)`, but reads in chunks and reports progress after each chunk. - /// Useful for downloading large amounts of data where you want to update UI progress. - /// - /// Example: - /// ```swift - /// let data = try await socket.read(1_000_000) { current, total in - /// print("Progress: \(current)/\(total)") - /// } - /// ``` - /// - /// - Parameters: - /// - count: Number of bytes to read - /// - chunkSize: Size of chunks to read at a time (default: 8192) - /// - progress: Optional callback with (bytesReceived, totalBytes) - /// - Returns: Exactly `count` bytes - /// - Throws: `NetSocketError` if connection closes before enough data arrives - public func read( - _ count: Int, - chunkSize: Int = 8192, - progress: (@Sendable (Int, Int) -> Void)? = nil - ) async throws -> Data { - var data = Data() - data.reserveCapacity(count) - var received = 0 - - while received < count { - try Task.checkCancellation() - let toRead = min(chunkSize, count - received) - let chunk = try await read(toRead) - data.append(chunk) - received += chunk.count - progress?(received, count) - } - - return data - } - - // MARK: Peek Data - - public var availableBytes: Int { self.buffer.count - self.head } - - public func peek(_ count: Int) -> Data? { - guard self.availableBytes >= count else { - return nil - } - - let slice = self.buffer[self.head..<(self.head + count)] - return Data(slice) // Don't advance head - } - - public func peek(upto count: Int) -> Data { - let amount = min(self.availableBytes, count) - guard amount > 0 else { - return Data() - } - - let slice = self.buffer[self.head..<(self.head + amount)] - return Data(slice) - } - - public func peek(awaiting count: Int) async throws -> Data { - try await self.ensureReadable(count) - let slice = self.buffer[self.head..<(self.head + count)] - return Data(slice) // Don't advance head - } - - // MARK: Skip Data - - /// Skip/discard exactly N bytes from the stream without allocating memory - public func skip(_ count: Int) async throws { - guard count > 0 else { return } - try await self.ensureReadable(count) - self.head += count - self.compactIfNeeded() - } - - /// Skip until delimiter is found (discards delimiter too) - public func skip(past delimiter: Data) async throws { - while true { - try Task.checkCancellation() - if let r = self.search(delimiter: delimiter) { - self.head = r.upperBound // Skip to end of delimiter - self.compactIfNeeded() - return - } - try await self.waitForData() - guard !self.isClosed else { - throw NetSocketError.closed - } - } - } - - // MARK: Files - - /// Upload a file from a URL, yielding progress as an AsyncSequence. - /// - /// Iterating this sequence drives the transfer. Each yielded value reports - /// the total bytes sent so far and the known total. Cancel the consuming - /// task to cancel the transfer. - /// - /// This method handles opening and closing the file handle automatically. - /// - /// - Parameters: - /// - url: File URL to upload. - /// - chunkSize: Size of each read chunk. - /// - Returns: An `AsyncThrowingStream` of `FileProgress` updates. - func writeFile(from url: URL, chunkSize: Int = 256 * 1024) -> AsyncThrowingStream { - // This stream wrapper manages the FileHandle's lifetime. - return AsyncThrowingStream(bufferingPolicy: .bufferingOldest(1)) { continuation in - // Capture self (the actor) to use in detached task - let actor = self - - // Open file on a background thread (file I/O is blocking) - let task = Task.detached { - let fh: FileHandle - let total: Int - - // 1. Open file and get length (blocking I/O, done off-actor) - do { - total = Int(try NetSocket.fileLength(at: url)) - fh = try FileHandle(forReadingFrom: url) - } catch { - continuation.finish(throwing: NetSocketError.failed(underlying: error)) - return - } - - // 2. Now switch to the actor context to call the actor-isolated method - let stream = await actor.writeFile( - from: fh, - length: total, - chunkSize: chunkSize - ) - - // 3. Forward all elements from the underlying stream to our stream - do { - for try await progress in stream { - try Task.checkCancellation() // Exit early if cancelled - continuation.yield(progress) - } - try? fh.close() - continuation.finish() - } catch is CancellationError { - try? fh.close() - continuation.finish() - } catch { - try? fh.close() - continuation.finish(throwing: error) - } - } - - // If the *consumer* cancels the stream, we cancel our managing task. - continuation.onTermination = { @Sendable _ in - task.cancel() - } - } - } - - /// Upload a file from an open FileHandle, yielding progress as an AsyncSequence. - /// - /// Iterating this sequence drives the transfer. Each yielded value reports - /// the total bytes sent so far and the known total. Cancel the consuming - /// task to cancel the transfer. - /// - /// **Note:** The caller is responsible for opening and closing the `fileHandle`. - /// - /// - Parameters: - /// - fileHandle: Open `FileHandle` for reading. - /// - length: Exact number of bytes to send (total file size). - /// - chunkSize: Size of each read chunk. - /// - Returns: An `AsyncThrowingStream` of `FileProgress` updates. - func writeFile(from fileHandle: FileHandle, length: Int, chunkSize: Int = 256 * 1024) -> AsyncThrowingStream { - precondition(length >= 0, "length must be >= 0") - - if length == 0 { - return AsyncThrowingStream { continuation in - continuation.yield(.init(sent: 0, total: 0, bytesPerSecond: 0, estimatedTimeRemaining: 0)) - continuation.finish() - } - } - - return AsyncThrowingStream(bufferingPolicy: .bufferingOldest(1)) { continuation in - let task = Task { [weak self] in - guard let self else { - continuation.finish() - return - } - - var estimator = TransferRateEstimator(total: Int(length)) - - do { - try await self.ensureReady() - - while estimator.transferred < length { - try Task.checkCancellation() - - let toRead = Int(min(chunkSize, length - estimator.transferred)) - - // Read from disk - guard let chunk = try fileHandle.read(upToCount: toRead), !chunk.isEmpty else { - if estimator.transferred < length { - throw NetSocketError.failed(underlying: NSError( - domain: "NetSocket", code: 9001, - userInfo: [NSLocalizedDescriptionKey: "File read ended prematurely. Expected \(length) bytes, got \(estimator.transferred)."] - )) - } - break - } - - // Write to network - try await self.write(chunk) - - // Update estimator and yield progress - let progress = estimator.update(bytes: chunk.count) - continuation.yield(progress) - } - - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - } - - continuation.onTermination = { @Sendable _ in - task.cancel() - } - } - } - - /// Receive a file of known length and yield progress updates as an AsyncSequence. - /// - /// Iterating this sequence drives the transfer. Each yielded value reports - /// the total bytes written so far and the known total. Cancel the consuming - /// task to cancel the transfer. - /// - /// - Parameters: - /// - fileHandle: Open `FileHandle` for writing (caller must close). - /// - length: Exact number of bytes expected. - /// - chunkSize: Size of each read chunk. - /// - Returns: An `AsyncThrowingStream` of `FileProgress` updates. - func receiveFile(to fileHandle: FileHandle, length: Int, chunkSize: Int = 256 * 1024) -> AsyncThrowingStream { - precondition(length >= 0, "length must be >= 0") - - if length == 0 { - return AsyncThrowingStream { continuation in - continuation.yield(.init(sent: 0, total: 0, bytesPerSecond: 0, estimatedTimeRemaining: 0)) - continuation.finish() - } - } - - return AsyncThrowingStream(bufferingPolicy: .bufferingOldest(1)) { continuation in - let task = Task { [weak self] in - guard let self else { - continuation.finish() - return - } - - var estimator = TransferRateEstimator(total: length) - - do { - var remaining: Int = length - - while remaining > 0 { - try Task.checkCancellation() - let n = min(chunkSize, remaining) - - let chunk = try await self.read(n) - try fileHandle.write(contentsOf: chunk) - - let chunkSize = Int(chunk.count) - remaining -= chunkSize - let progress = estimator.update(bytes: chunkSize) - continuation.yield(progress) - } - - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - } - - continuation.onTermination = { @Sendable _ in - task.cancel() - } - } - } - - /// Download a file of known length and write it to disk in chunks - /// - /// This method does **not** read a length prefix. The caller must provide the expected - /// file size (e.g., from protocol metadata). The file is streamed directly to disk to - /// avoid loading it entirely into memory. - /// - /// Supports atomic writes: when enabled, data is written to a temporary `.part` file and - /// renamed on success. If an error occurs, the temporary file is automatically cleaned up. - /// - /// - Parameters: - /// - url: Destination file URL - /// - length: Exact number of bytes to read (must match what's on the wire) - /// - chunkSize: Chunk size for reading/writing (default: 256 KB) - /// - overwrite: Whether to overwrite existing file (default: true) - /// - atomic: Write to temporary file and rename on success (default: true) - /// - progress: Optional progress callback - /// - Returns: Total bytes written (equals `length` on success) - /// - Throws: File I/O or network errors. On atomic writes, partial files are cleaned up. - /// - /// Example: - /// ```swift - /// // Hotline protocol: file size comes from transaction header - /// let transaction = try await socket.receive(HotlineTransaction.self) - /// try await socket.receiveFile( - /// to: destinationURL, - /// length: transaction.fileSize - /// ) - /// ``` - @discardableResult - func receiveFile( - to url: URL, - length: Int, - chunkSize: Int = 256 * 1024, - overwrite: Bool = true, - atomic: Bool = true, - progress: (@Sendable (FileProgress) -> Void)? = nil - ) async throws -> Int { - precondition(length >= 0, "length must be >= 0") - - // Fast path: nothing to do - if length == 0 { - if overwrite { try? FileManager.default.removeItem(at: url) } - FileManager.default.createFile(atPath: url.path, contents: Data(), attributes: nil) - return 0 - } - - // Prepare destination (optionally atomic) - let fm = FileManager.default - let dir = url.deletingLastPathComponent() - let tmp = atomic ? dir.appendingPathComponent(".\(url.lastPathComponent).part-\(UUID().uuidString)") : url - - if overwrite { try? fm.removeItem(at: tmp) } - if overwrite, !atomic { try? fm.removeItem(at: url) } - - // Create and open the file for writing - fm.createFile(atPath: tmp.path, contents: nil, attributes: nil) - let fh = try FileHandle(forWritingTo: tmp) - defer { try? fh.close() } - - var remaining: Int = length - var written: Int = 0 - - do { - while remaining > 0 { - try Task.checkCancellation() - let n = Int(min(chunkSize, remaining)) - let chunk = try await self.read(n) - try fh.write(contentsOf: chunk) - remaining -= n - written += Int(n) - progress?(.init(sent: written, total: length)) - } - } catch { - // Cleanup partial file on failure if we were writing atomically - if atomic { try? fm.removeItem(at: tmp) } - throw error - } - - // Atomically move into place if requested - if atomic { - if overwrite { try? fm.removeItem(at: url) } - try fm.moveItem(at: tmp, to: url) - } - - return written - } - - // MARK: Internals - - private func start() async throws { - self.connection.stateUpdateHandler = { state in - Task { [weak self] in - guard let self else { return } - switch state { - case .ready: - await self.setReady() - await self.resumeReadyWaiters(with: .success(())) - case .failed(let error): - await self.failAllWaiters(NetSocketError.failed(underlying: error)) - await self.setClosed() - case .waiting(let error): - // bubble as transient failure for awaiters; reconnect logic could live here - await self.resumeReadyWaiters(with: .failure(NetSocketError.failed(underlying: error))) - case .cancelled: - await self.failAllWaiters(NetSocketError.closed) - await self.setClosed() - default: - break - } - } - } - - // Kick off receive loop after .start - self.connection.start(queue: queue) - try await self.waitUntilReady() - self.startReceiveLoop() - } - - private func startReceiveLoop() { - @Sendable func loop(_ connection: NWConnection, chunk: Int, owner: NetSocket, connID: String) { - print("NetSocket[\(connID)]: Calling connection.receive(\(chunk)) to request more data...") - - connection.receive(minimumIncompleteLength: 1, maximumLength: chunk) { [weak owner] data, _, isComplete, error in - print("NetSocket[\(connID)]: Receive callback - data: \(data?.count ?? 0) bytes, isComplete: \(isComplete), error: \(String(describing: error))") - Task { - guard let o = owner else { - return - } - - if let error { - await o.handleReceiveError(error) - return - } - if let data, !data.isEmpty { - await o.append(data, connID: connID) - } - if isComplete { - print("NetSocket[\(connID)]: EOF from peer.") - await o.handleEOF() - return - } - loop(connection, chunk: chunk, owner: o, connID: connID) - } - } - } - loop(connection, chunk: self.config.receiveChunk, owner: self, connID: connectionID) - } - - private func handleReceiveError(_ error: Error) { - self.isClosed = true - self.failAllWaiters(NetSocketError.failed(underlying: error)) - } - - private func handleEOF() { - self.isClosed = true - let waiters = self.dataWaiters - self.dataWaiters.removeAll() - for w in waiters { - w.resume() - } // wake so readers can observe closure - } - - private func setReady() { - self.ready = true - } - - private func setClosed() { - self.isClosed = true - } - - private func ensureReady() async throws { - if self.isClosed { - throw NetSocketError.closed - } - if !self.ready { - try await self.waitUntilReady() - } - } - - private func ensureReadable(_ count: Int) async throws { - try await self.ensureReady() - while self.availableBytes < count { - try Task.checkCancellation() - if self.isClosed { - throw NetSocketError.insufficientData(expected: count, got: self.availableBytes) - } - try await self.waitForData() - } - } - - private func waitForData() async throws { - try Task.checkCancellation() - try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in - if self.isClosed { - cont.resume() - return - } - self.dataWaiters.append(cont) - } - } - - private func compactIfNeeded() { - // Avoid unbounded memory as head advances - if self.head > 64 * 1024 && self.head > self.buffer.count / 2 { - self.buffer.removeSubrange(0.. Range? { - guard !delimiter.isEmpty, availableBytes >= delimiter.count else { return nil } - let hay = buffer[head.. Int64 { - let values = try url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) - guard values.isRegularFile == true else { - throw NetSocketError.failed(underlying: NSError( - domain: "NetSocket", code: 1001, - userInfo: [NSLocalizedDescriptionKey: "Not a regular file: \(url.path)"] - )) - } - if let s = values.fileSize { return Int64(s) } - let attrs = try FileManager.default.attributesOfItem(atPath: url.path) - if let n = attrs[.size] as? NSNumber { - return n.int64Value - } - throw NetSocketError.failed(underlying: NSError( - domain: "NetSocket", code: 1002, - userInfo: [NSLocalizedDescriptionKey: "Unable to determine file size for \(url.lastPathComponent)"] - )) - } - - private func waitUntilReady() async throws { - guard !self.ready else { return } - try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in - self.readyWaiters.append(cont) - } - } - - private func resumeReadyWaiters(with result: Result) { - let waiters = self.readyWaiters - self.readyWaiters.removeAll() - for w in waiters { - switch result { - case .success: w.resume() - case .failure(let e): w.resume(throwing: e) - } - } - } - - private func failAllWaiters(_ error: Error) { - self.resumeReadyWaiters(with: .failure(error)) - let waiters = self.dataWaiters - self.dataWaiters.removeAll() - for w in waiters { - w.resume(throwing: error) - } - } - - private func append(_ data: Data, connID: String) { - print("NetSocket[\(connID)]: Received \(data.count) bytes from network, buffer now has \(buffer.count - head + data.count) available") - buffer.append(data) - if buffer.count - head > config.maxBufferBytes { - // Hard stop: drop connection rather than OOM'ing. - isClosed = true - connection.cancel() - failAllWaiters(NetSocketError.framingExceeded(max: config.maxBufferBytes)) - return - } - resumeDataWaiters() - } - - private func resumeDataWaiters() { - let waiters = dataWaiters - dataWaiters.removeAll() - for w in waiters { w.resume() } - } -} - -// MARK: - Utilities - -private extension Data { - mutating func appendInteger(_ value: T, endian: Endian) throws { - var v = value - switch endian { - case .big: v = T(bigEndian: value) - case .little: v = T(littleEndian: value) - } - var copy = v - withUnsafePointer(to: ©) { ptr in - self.append(contentsOf: UnsafeRawBufferPointer(start: ptr, count: MemoryLayout.size)) - } - } -} - -// MARK: - NetSocketEncodable - -/// Protocol for types that can encode themselves to binary data -/// -/// Types conforming to `NetSocketEncodable` produce binary data that can be sent over -/// a socket. Unlike writing field-by-field to the socket, encodable types build complete -/// binary messages that are sent in a single write operation for efficiency. -/// -/// Example: -/// ```swift -/// struct MyMessage: NetSocketEncodable { -/// let id: UInt32 -/// let name: String -/// -/// func encode(endian: Endian) throws -> Data { -/// var data = Data() -/// // Encode fields to data... -/// return data -/// } -/// } -/// -/// try await socket.send(message) -/// ``` -public protocol NetSocketEncodable: Sendable { - /// Encode this value to binary data - /// - /// Implementations should build a complete binary message and return it as Data. - /// The data will be sent to the socket in a single write operation. - /// - /// - Parameter endian: Byte order for multi-byte values - /// - Returns: Encoded binary data ready to send - /// - Throws: Encoding errors - func encode(endian: Endian) throws -> Data -} - -/// Protocol for types that can decode themselves directly from a socket stream -/// -/// Types conforming to `NetSocketDecodable` read field-by-field directly from the socket -/// using async reads. This enables true streaming without buffering entire messages. -/// -/// **Important**: If decoding throws after consuming some bytes (e.g., validation fails), -/// the socket will be left with those bytes consumed. In practice, this usually means the -/// connection should be closed. For most protocols this is acceptable since decode errors -/// indicate corrupt data or protocol violations. -/// -/// Example: -/// ```swift -/// struct MyMessage: NetSocketDecodable { -/// let id: UInt32 -/// let name: String -/// -/// init(from socket: NetSocket, endian: Endian) async throws { -/// self.id = try await socket.read(UInt32.self, endian: endian) -/// let nameLen = try await socket.read(UInt16.self, endian: endian) -/// let nameData = try await socket.readExactly(Int(nameLen)) -/// guard let name = String(data: nameData, encoding: .utf8) else { -/// throw NetSocketError.decodeFailed(NSError()) -/// } -/// self.name = name -/// } -/// } -/// -/// let message = try await socket.receive(MyMessage.self) -/// ``` -public protocol NetSocketDecodable: Sendable { - /// Decode a value by reading directly from the socket stream - /// - /// This initializer should read all necessary fields from the socket using - /// methods like `read(_:endian:)`, `readExactly(_:)`, `readString(length:)`, etc. - /// - /// The socket handles waiting for data to arrive, so you can read field by field - /// without worrying about buffering. - /// - /// - Parameters: - /// - socket: Socket to read from - /// - endian: Byte order for multi-byte values - /// - Throws: Network errors, insufficient data, or custom decoding errors - init(from socket: NetSocket, endian: Endian) async throws -} - -public extension NetSocket { - /// Send an encodable value to the socket - /// - /// The type encodes itself to binary data, which is then sent in a single write operation. - /// - /// Example: - /// ```swift - /// struct MyMessage: NetSocketEncodable { - /// let id: UInt32 - /// let name: String - /// - /// func encode(endian: Endian) throws -> Data { - /// var data = Data() - /// // Build binary message... - /// return data - /// } - /// } - /// - /// try await socket.send(message) - /// ``` - /// - /// - Parameters: - /// - value: Value conforming to NetSocketEncodable - /// - endian: Byte order (default: big-endian) - /// - Throws: Encoding or network errors - func send(_ value: T, endian: Endian = .big) async throws { - let data = try value.encode(endian: endian) - try await self.write(data) - } - - /// Receive and decode a value directly from the socket stream (no length prefix) - /// - /// The type reads field-by-field from the socket as needed, enabling true streaming - /// without buffering entire messages. Useful for protocols where message size isn't - /// known upfront or for progressive decoding. - /// - /// Example: - /// ```swift - /// struct ServerEntry: NetSocketDecodable { - /// let id: UInt32 - /// let name: String - /// - /// init(from socket: NetSocket, endian: Endian) async throws { - /// self.id = try await socket.read(UInt32.self, endian: endian) - /// // Read variable-length string... - /// } - /// } - /// - /// let entry = try await socket.receive(ServerEntry.self) - /// ``` - /// - /// - Parameters: - /// - type: Type conforming to NetSocketDecodable - /// - endian: Byte order (default: big-endian) - /// - Returns: Decoded value - /// - Throws: Decoding or network errors - func receive(_ type: T.Type, endian: Endian = .big) async throws -> T { - return try await T(from: self, endian: endian) - } -} diff --git a/Hotline/Library/NetSocket/TransferRateEstimator.swift b/Hotline/Library/NetSocket/TransferRateEstimator.swift index 60647a7..badf87a 100644 --- a/Hotline/Library/NetSocket/TransferRateEstimator.swift +++ b/Hotline/Library/NetSocket/TransferRateEstimator.swift @@ -1,4 +1,4 @@ -// TransferRateEstimator +// NetSocket: TransferRateEstimator // Dustin Mierau • @mierau // MIT License diff --git a/Hotline/Library/QuickLookPreviewView.swift b/Hotline/Library/QuickLookPreviewView.swift deleted file mode 100644 index 6ba154e..0000000 --- a/Hotline/Library/QuickLookPreviewView.swift +++ /dev/null @@ -1,22 +0,0 @@ -import SwiftUI -import Quartz - -/// Embeddable QuickLook preview view for macOS -/// -/// This view uses QLPreviewView to display file previews inline, without showing a modal. -/// Supports all file types that QuickLook supports (images, PDFs, videos, documents, etc.) -struct QuickLookPreviewView: NSViewRepresentable { - let fileURL: URL - - func makeNSView(context: Context) -> QLPreviewView { - let preview = QLPreviewView(frame: .zero, style: .normal)! - preview.autostarts = true - preview.shouldCloseWithWindow = true - preview.previewItem = fileURL as QLPreviewItem - return preview - } - - func updateNSView(_ nsView: QLPreviewView, context: Context) { - nsView.previewItem = fileURL as QLPreviewItem - } -} diff --git a/Hotline/Library/RegularExpressions.swift b/Hotline/Library/RegularExpressions.swift new file mode 100644 index 0000000..c1f2a18 --- /dev/null +++ b/Hotline/Library/RegularExpressions.swift @@ -0,0 +1,235 @@ +import RegexBuilder + +struct RegularExpressions { + static let messageBoardDivider = Regex { + Capture { + OneOrMore { + CharacterClass(.newlineSequence) + } + ZeroOrMore { + CharacterClass(.whitespace, .newlineSequence) + } + Repeat(2...) { + CharacterClass(.anyOf("_-")) + } + ZeroOrMore { + CharacterClass(.whitespace) + } + OneOrMore { + CharacterClass(.newlineSequence) + } + } + } + + static let supportedLinkScheme = Regex { + Anchor.startOfLine + ChoiceOf { + "hotline" + "http" + "https" + } + "://" + }.ignoresCase().anchorsMatchLineEndings() + + static let relaxedLink = Regex { + ChoiceOf { + Anchor.startOfLine + Anchor.wordBoundary + } + Capture { + // scheme (optional) + Optionally { + ChoiceOf { + "hotline://" + "http://" + "https://" + } + } + // domain name + OneOrMore { + CharacterClass( + .anyOf(".-@"), + ("a"..."z"), + ("0"..."9") + ) + } + // top-level domain name + "." + ChoiceOf { + "com" + "net" + "org" + "edu" + "gov" + "mil" + "aero" + "asia" + "biz" + "cat" + "coop" + "info" + "int" + "jobs" + "mobi" + "museum" + "name" + "pizza" + "post" + "pro" + "red" + "tel" + "today" + "travel" + "garden" + "online" + "ai" + "be" + "by" + "ca" + "co" + "de" + "er" + "es" + "fr" + "gs" + "ie" + "im" + "in" + "io" + "is" + "it" + "jp" + "la" + "ly" + "ma" + "md" + "me" + "my" + "nl" + "ps" + "pt" + "ja" + "st" + "to" + "tv" + "uk" + "ws" + } + // Port + Optionally { + ":" + OneOrMore { + CharacterClass(.digit) + } + } + // path + ZeroOrMore { + CharacterClass( + .anyOf("#_-/.?=&%\\()[]"), + ("a"..."z"), + ("0"..."9") + ) + } + } + ChoiceOf { + Anchor.endOfLine + Anchor.wordBoundary + } + } + .anchorsMatchLineEndings() + .ignoresCase() + + static let emailAddress = Regex { + ChoiceOf { + Anchor.startOfLine + Anchor.wordBoundary + } + Capture { + // username + OneOrMore { + CharacterClass( + .anyOf(".-_"), + ("a"..."z"), + ("0"..."9") + ) + } + "@" + // domain name + OneOrMore { + CharacterClass( + .anyOf(".-"), + ("a"..."z"), + ("0"..."9") + ) + } + // top-level domain name + "." + ChoiceOf { + "com" + "net" + "org" + "edu" + "gov" + "mil" + "aero" + "asia" + "biz" + "cat" + "coop" + "info" + "int" + "jobs" + "mobi" + "museum" + "name" + "pizza" + "post" + "pro" + "red" + "tel" + "today" + "travel" + "garden" + "online" + "ai" + "be" + "by" + "ca" + "co" + "de" + "er" + "es" + "fr" + "gs" + "ie" + "im" + "in" + "io" + "is" + "it" + "jp" + "la" + "ly" + "ma" + "md" + "me" + "my" + "nl" + "ps" + "pt" + "ja" + "st" + "to" + "tv" + "uk" + "ws" + } + } + ChoiceOf { + Anchor.endOfLine + Anchor.wordBoundary + } + } + .anchorsMatchLineEndings() + .ignoresCase() +} diff --git a/Hotline/Library/SoundEffects.swift b/Hotline/Library/SoundEffects.swift new file mode 100644 index 0000000..93f8b9a --- /dev/null +++ b/Hotline/Library/SoundEffects.swift @@ -0,0 +1,50 @@ +import Foundation +import AVFAudio + +enum SoundEffects: String { + case loggedIn = "logged-in" + case chatMessage = "chat-message" + case transferComplete = "transfer-complete" + case userLogin = "user-login" + case userLogout = "user-logout" + case newNews = "new-news" + case serverMessage = "server-message" + case error = "error" +} + +@Observable +class SoundEffectPlayer: NSObject, AVAudioPlayerDelegate { + static let shared = SoundEffectPlayer() + + private var activeSounds: [AVAudioPlayer] = [] + + func playSoundEffect(_ name: SoundEffects) { + // Load a local sound file + guard let soundFileURL = Bundle.main.url( + forResource: name.rawValue, + withExtension: "aiff" + ) else { + return + } + + DispatchQueue.main.async { [weak self] in + guard let self = self else { + return + } + + if let soundEffect = try? AVAudioPlayer(contentsOf: soundFileURL) { + soundEffect.delegate = self + soundEffect.volume = 0.75 + soundEffect.play() + + self.activeSounds.append(soundEffect) + } + } + } + + func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { + if let i = self.activeSounds.firstIndex(of: player) { + self.activeSounds.remove(at: i) + } + } +} diff --git a/Hotline/Library/SpinningGlobeView.swift b/Hotline/Library/SpinningGlobeView.swift deleted file mode 100644 index bccb969..0000000 --- a/Hotline/Library/SpinningGlobeView.swift +++ /dev/null @@ -1,90 +0,0 @@ -import SwiftUI - -/// An animated globe icon that cycles through different world regions -/// -/// Displays a spinning globe effect by cycling through SF Symbol images showing -/// different parts of the world. Useful for indicating network activity or global content. -/// -/// Example: -/// ```swift -/// SpinningGlobeView() -/// .frame(width: 16, height: 16) -/// -/// SpinningGlobeView(frameDelay: 0.5) -/// .frame(width: 24, height: 24) -/// ``` -struct SpinningGlobeView: View { - /// Delay between frames in seconds (default: 0.3) - let frameDelay: TimeInterval - - /// SF Symbol names for each frame of the globe animation - private let globeFrames = [ - "globe.americas.fill", - "globe.europe.africa.fill", - "globe.central.south.asia.fill", - "globe.asia.australia.fill" - ] - - @State private var currentFrameIndex = 0 - @State private var animationTask: Task? - - init(frameDelay: TimeInterval = 0.25) { - self.frameDelay = frameDelay - } - - var body: some View { - Image(systemName: globeFrames[currentFrameIndex]) - .onAppear { - startAnimation() - } - .onDisappear { - stopAnimation() - } - } - - private func startAnimation() { - animationTask = Task { - while !Task.isCancelled { - try? await Task.sleep(nanoseconds: UInt64(frameDelay * 1_000_000_000)) - - guard !Task.isCancelled else { break } - - currentFrameIndex = (currentFrameIndex + 1) % globeFrames.count - } - } - } - - private func stopAnimation() { - animationTask?.cancel() - animationTask = nil - } -} - -#Preview { - VStack(spacing: 20) { - HStack(spacing: 20) { - SpinningGlobeView() - .frame(width: 12, height: 12) - - SpinningGlobeView() - .frame(width: 16, height: 16) - - SpinningGlobeView() - .frame(width: 24, height: 24) - } - - Text("Different frame delays:") - - HStack(spacing: 20) { - SpinningGlobeView(frameDelay: 0.1) - .frame(width: 16, height: 16) - - SpinningGlobeView(frameDelay: 0.3) - .frame(width: 16, height: 16) - - SpinningGlobeView(frameDelay: 0.6) - .frame(width: 16, height: 16) - } - } - .padding() -} diff --git a/Hotline/Library/TextView.swift b/Hotline/Library/TextView.swift deleted file mode 100644 index 47746e2..0000000 --- a/Hotline/Library/TextView.swift +++ /dev/null @@ -1,119 +0,0 @@ -import SwiftUI - -struct BetterTextEditor: NSViewRepresentable { - - @Environment(\.lineSpacing) private var lineSpacing - - @Binding private var text: String - - private var customizations: [(NSTextView) -> Void] = [] - - init(text: Binding) { - self._text = text - } - - func makeCoordinator() -> Coordinator { - Coordinator(self) - } - - func makeNSView(context: Context) -> NSScrollView { - let scrollview = NSTextView.scrollablePlainDocumentContentTextView() - let textview = scrollview.documentView as! NSTextView - - textview.string = self.text - textview.delegate = context.coordinator - -// let p = NSMutableParagraphStyle() -// p.lineSpacing = self.lineSpacing -//// textview.defaultParagraphStyle = p -// textview.typingAttributes = [ -// .paragraphStyle: p -// ] - - textview.isEditable = true - textview.isRichText = false - textview.allowsUndo = true - textview.isFieldEditor = false - textview.usesAdaptiveColorMappingForDarkAppearance = true - textview.drawsBackground = false // true - textview.usesRuler = false - textview.usesFindBar = false - textview.isIncrementalSearchingEnabled = false - textview.isAutomaticQuoteSubstitutionEnabled = false - textview.isAutomaticDashSubstitutionEnabled = false - textview.isAutomaticSpellingCorrectionEnabled = true - textview.isAutomaticDataDetectionEnabled = false - textview.isAutomaticLinkDetectionEnabled = false - textview.usesInspectorBar = false - textview.usesFontPanel = false - textview.importsGraphics = false - textview.allowsImageEditing = false - textview.displaysLinkToolTips = true - textview.backgroundColor = NSColor.textBackgroundColor - textview.textContainerInset = NSSize(width: 16, height: 16) - textview.isContinuousSpellCheckingEnabled = true - textview.setSelectedRange(NSMakeRange(0, 0)) - self.customizations.forEach { $0(textview) } - - scrollview.scrollerStyle = .overlay - - return scrollview - } - - func updateNSView(_ nsView: NSScrollView, context: Context) { - let textview = nsView.documentView as! NSTextView - - if textview.string != text { - textview.string = text - } - - self.customizations.forEach { $0(textview) } - } - - func betterEditorFont(_ font: NSFont) -> Self { - self.customized { $0.font = font } - } - - func betterEditorParagraphStyle(_ paragraphStyle: NSParagraphStyle) -> Self { - self.customized { $0.defaultParagraphStyle = paragraphStyle } - } - - func betterEditorAutomaticDashSubstitution(_ enabled: Bool) -> Self { - self.customized { $0.isAutomaticDashSubstitutionEnabled = enabled } - } - - func betterEditorAutomaticQuoteSubstitution(_ enabled: Bool) -> Self { - self.customized { $0.isAutomaticQuoteSubstitutionEnabled = enabled } - } - - func betterEditorAutomaticSpellingCorrection(_ enabled: Bool) -> Self { - self.customized { $0.isAutomaticSpellingCorrectionEnabled = enabled } - } - - func betterEditorTextInset(_ size: NSSize) -> Self { - self.customized { $0.textContainerInset = size } - } - - class Coordinator: NSObject, NSTextViewDelegate { - var parent: BetterTextEditor - - init(_ parent: BetterTextEditor) { - self.parent = parent - } - - func textDidChange(_ notification: Notification) { - guard let textview = notification.object as? NSTextView else { - return - } - self.parent.text = textview.string - } - } -} - -private extension BetterTextEditor { - private func customized(_ customization: @escaping (NSTextView) -> Void) -> Self { - var copy = self - copy.customizations.append(customization) - return copy - } -} diff --git a/Hotline/Library/URLAdditions.swift b/Hotline/Library/URLAdditions.swift deleted file mode 100644 index 0ba2100..0000000 --- a/Hotline/Library/URLAdditions.swift +++ /dev/null @@ -1,55 +0,0 @@ -import Foundation -import UniformTypeIdentifiers - -extension URL { - func generateUniqueFilePath(filename base: String) -> String { - let fileManager = FileManager.default - var finalName = base - var counter = 2 - - // Helper function to generate a new filename with a counter - func makeFileName() -> String { - let baseName = (base as NSString).deletingPathExtension - let extensionName = (base as NSString).pathExtension - return extensionName.isEmpty ? "\(baseName) \(counter)" : "\(baseName) \(counter).\(extensionName)" - } - - // Check if file exists and append counter until a unique name is found - var filePath = self.appending(component: finalName).path(percentEncoded: false) - while fileManager.fileExists(atPath: filePath) { - finalName = makeFileName() - filePath = self.appending(component: finalName).path(percentEncoded: false) - counter += 1 - } - - return filePath - } -} - -extension UTType { - var canBePreviewedByQuickLook: Bool { - // QuickLook supports most common document types - let supportedSupertypes: [UTType] = [ - .image, - .movie, - .audio, - .pdf, - .font, - .usdz, - .text, - .sourceCode, - .spreadsheet, - .presentation, - -// Microsoft Office - .init(filenameExtension: "doc")!, - .init(filenameExtension: "docx")!, - .init(filenameExtension: "xls")!, - .init(filenameExtension: "xlsx")!, - .init(filenameExtension: "ppt")!, - .init(filenameExtension: "pptx")!, - ] - - return supportedSupertypes.contains { self.conforms(to: $0) } - } -} diff --git a/Hotline/Library/Utility/DAKeychain.swift b/Hotline/Library/Utility/DAKeychain.swift new file mode 100644 index 0000000..8031886 --- /dev/null +++ b/Hotline/Library/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/Library/Utility/NSWindowBridge.swift b/Hotline/Library/Utility/NSWindowBridge.swift new file mode 100644 index 0000000..8f766d9 --- /dev/null +++ b/Hotline/Library/Utility/NSWindowBridge.swift @@ -0,0 +1,46 @@ +import SwiftUI + +fileprivate class NSWindowAccessorView: NSView { + let executeBlock: (_ window: NSWindow? ) -> () + + init(_ inConfigFunction: @escaping (_ window: NSWindow? ) -> () ) { + executeBlock = inConfigFunction + super.init( frame: NSRect() ) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + public override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + executeBlock( self.window ) // We pass it through even if it is nil. + } +} + +public struct NSWindowAccessor: NSViewRepresentable { + var configCode: (_ window: NSWindow? ) -> () + + public init(_ configCode: @escaping (_: NSWindow?) -> Void) { self.configCode = configCode } + public func makeNSView(context: Context) -> NSView { return NSWindowAccessorView( configCode ) } + public func updateNSView(_ nsView: NSView, context: Context) {} +} + + +//import SwiftUI + + /// A helper view you can embed once per window to run a closure +/// with the underlying NSWindow reference. +struct WindowConfigurator: NSViewRepresentable { + let configure: (NSWindow) -> Void + + func makeNSView(context: Context) -> NSView { + let view = NSView() + DispatchQueue.main.async { + if let window = view.window { + configure(window) + } + } + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { } +} diff --git a/Hotline/Library/Utility/ObservableScrollView.swift b/Hotline/Library/Utility/ObservableScrollView.swift new file mode 100644 index 0000000..a6f46cc --- /dev/null +++ b/Hotline/Library/Utility/ObservableScrollView.swift @@ -0,0 +1,38 @@ +import SwiftUI + +struct ScrollViewOffsetPreferenceKey: PreferenceKey { + typealias Value = CGFloat + static var defaultValue = CGFloat.zero + static func reduce(value: inout Value, nextValue: () -> Value) { + value += nextValue() + } +} + +struct ObservableScrollView: View where Content : View { + @Namespace var scrollSpace + @Binding var scrollOffset: CGFloat + let content: () -> Content + + init(scrollOffset: Binding, + @ViewBuilder content: @escaping () -> Content) { + _scrollOffset = scrollOffset + self.content = content + } + + var body: some View { + ScrollView { + content() + .background(GeometryReader { geo in + let offset = -geo.frame(in: .named(scrollSpace)).minY + Color.clear + .preference(key: ScrollViewOffsetPreferenceKey.self, + value: offset) + }) + + } + .coordinateSpace(name: scrollSpace) + .onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { value in + scrollOffset = value + } + } +} diff --git a/Hotline/Library/Utility/TextDocument.swift b/Hotline/Library/Utility/TextDocument.swift new file mode 100644 index 0000000..82727de --- /dev/null +++ b/Hotline/Library/Utility/TextDocument.swift @@ -0,0 +1,31 @@ +import Foundation +import UniformTypeIdentifiers +import SwiftUI + +struct TextFile: FileDocument { + // tell the system we support only plain text + static var readableContentTypes = [UTType.plainText, UTType.utf8PlainText] + + // by default our document is empty + var text = "" + + // a simple initializer that creates new, empty documents + init(initialText: String = "") { + text = initialText + } + + // this initializer loads data that has been saved previously + init(configuration: ReadConfiguration) throws { + + if let data = configuration.file.regularFileContents { + if let str = String(data: data, encoding: .utf8) { + self.text = str + } + } + } + + // this will be called when the system wants to write our data to disk + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + return FileWrapper(regularFileWithContents: self.text.data(using: .utf8)!) + } +} diff --git a/Hotline/Library/Views/AsyncLinkPreview.swift b/Hotline/Library/Views/AsyncLinkPreview.swift new file mode 100644 index 0000000..89b186f --- /dev/null +++ b/Hotline/Library/Views/AsyncLinkPreview.swift @@ -0,0 +1,57 @@ +import SwiftUI +import LinkPresentation + +fileprivate class CustomLinkView: LPLinkView { + override var intrinsicContentSize: CGSize { CGSize(width: 0, height: super.intrinsicContentSize.height) } +} + +struct AsyncLinkPreview: View { + @State private var metadata: LPLinkMetadata? + @State private var isLoading = true + let url: URL? + + func fetchMetadata() async { + guard let url else { + self.isLoading = false + return + } + do { + let provider = LPMetadataProvider() + let metadata = try await provider.startFetchingMetadata(for: url) + self.metadata = metadata + self.isLoading = false + } catch { + self.isLoading = false + } + } + + var body: some View { + if isLoading { + ProgressView() + .controlSize(.small) + .task { + await self.fetchMetadata() + } + } else if let metadata = metadata { + LinkView(metadata: metadata) + .frame(width: 200) + } else { + Text(LocalizedStringKey(url!.absoluteString)) + .multilineTextAlignment(.leading) + .tint(Color("Link Color")) + } + } +} + +struct LinkView: NSViewRepresentable { + var metadata: LPLinkMetadata + + func makeNSView(context: Context) -> LPLinkView { + let linkView = CustomLinkView(metadata: metadata) + return linkView + } + + func updateNSView(_ nsView: LPLinkView, context: Context) { + // Nothing required + } +} diff --git a/Hotline/Library/Views/BetterTextEditor.swift b/Hotline/Library/Views/BetterTextEditor.swift new file mode 100644 index 0000000..47746e2 --- /dev/null +++ b/Hotline/Library/Views/BetterTextEditor.swift @@ -0,0 +1,119 @@ +import SwiftUI + +struct BetterTextEditor: NSViewRepresentable { + + @Environment(\.lineSpacing) private var lineSpacing + + @Binding private var text: String + + private var customizations: [(NSTextView) -> Void] = [] + + init(text: Binding) { + self._text = text + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + func makeNSView(context: Context) -> NSScrollView { + let scrollview = NSTextView.scrollablePlainDocumentContentTextView() + let textview = scrollview.documentView as! NSTextView + + textview.string = self.text + textview.delegate = context.coordinator + +// let p = NSMutableParagraphStyle() +// p.lineSpacing = self.lineSpacing +//// textview.defaultParagraphStyle = p +// textview.typingAttributes = [ +// .paragraphStyle: p +// ] + + textview.isEditable = true + textview.isRichText = false + textview.allowsUndo = true + textview.isFieldEditor = false + textview.usesAdaptiveColorMappingForDarkAppearance = true + textview.drawsBackground = false // true + textview.usesRuler = false + textview.usesFindBar = false + textview.isIncrementalSearchingEnabled = false + textview.isAutomaticQuoteSubstitutionEnabled = false + textview.isAutomaticDashSubstitutionEnabled = false + textview.isAutomaticSpellingCorrectionEnabled = true + textview.isAutomaticDataDetectionEnabled = false + textview.isAutomaticLinkDetectionEnabled = false + textview.usesInspectorBar = false + textview.usesFontPanel = false + textview.importsGraphics = false + textview.allowsImageEditing = false + textview.displaysLinkToolTips = true + textview.backgroundColor = NSColor.textBackgroundColor + textview.textContainerInset = NSSize(width: 16, height: 16) + textview.isContinuousSpellCheckingEnabled = true + textview.setSelectedRange(NSMakeRange(0, 0)) + self.customizations.forEach { $0(textview) } + + scrollview.scrollerStyle = .overlay + + return scrollview + } + + func updateNSView(_ nsView: NSScrollView, context: Context) { + let textview = nsView.documentView as! NSTextView + + if textview.string != text { + textview.string = text + } + + self.customizations.forEach { $0(textview) } + } + + func betterEditorFont(_ font: NSFont) -> Self { + self.customized { $0.font = font } + } + + func betterEditorParagraphStyle(_ paragraphStyle: NSParagraphStyle) -> Self { + self.customized { $0.defaultParagraphStyle = paragraphStyle } + } + + func betterEditorAutomaticDashSubstitution(_ enabled: Bool) -> Self { + self.customized { $0.isAutomaticDashSubstitutionEnabled = enabled } + } + + func betterEditorAutomaticQuoteSubstitution(_ enabled: Bool) -> Self { + self.customized { $0.isAutomaticQuoteSubstitutionEnabled = enabled } + } + + func betterEditorAutomaticSpellingCorrection(_ enabled: Bool) -> Self { + self.customized { $0.isAutomaticSpellingCorrectionEnabled = enabled } + } + + func betterEditorTextInset(_ size: NSSize) -> Self { + self.customized { $0.textContainerInset = size } + } + + class Coordinator: NSObject, NSTextViewDelegate { + var parent: BetterTextEditor + + init(_ parent: BetterTextEditor) { + self.parent = parent + } + + func textDidChange(_ notification: Notification) { + guard let textview = notification.object as? NSTextView else { + return + } + self.parent.text = textview.string + } + } +} + +private extension BetterTextEditor { + private func customized(_ customization: @escaping (NSTextView) -> Void) -> Self { + var copy = self + copy.customizations.append(customization) + return copy + } +} diff --git a/Hotline/Library/Views/FileIconView.swift b/Hotline/Library/Views/FileIconView.swift new file mode 100644 index 0000000..d0949fa --- /dev/null +++ b/Hotline/Library/Views/FileIconView.swift @@ -0,0 +1,74 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct FolderIconView: View { + private func folderIcon() -> Image { +#if os(iOS) + return Image(systemName: "folder.fill") +#elseif os(macOS) + return Image(nsImage: NSWorkspace.shared.icon(for: UTType.folder)) +#endif + } + + var body: some View { + folderIcon() + .resizable() + .scaledToFit() + } +} + +struct FileIconView: View { + let filename: String + let fileType: String? + + #if os(iOS) + private func fileIcon() -> Image { + let fileExtension = (self.filename as NSString).pathExtension + if let fileType = UTType(filenameExtension: fileExtension) { + if fileType.isSubtype(of: .movie) { + return Image(systemName: "play.rectangle") + } + else if fileType.isSubtype(of: .image) { + return Image(systemName: "photo") + } + else if fileType.isSubtype(of: .archive) { + return Image(systemName: "doc.zipper") + } + else if fileType.isSubtype(of: .text) { + return Image(systemName: "doc.text") + } + else { + return Image(systemName: "doc") + } + } + + return Image(systemName: "doc") + } + #elseif os(macOS) + private func fileIcon() -> Image { + let fileExtension = (self.filename as NSString).pathExtension + + if !fileExtension.isEmpty, + let uttype = UTType(filenameExtension: fileExtension) { + return Image(nsImage: NSWorkspace.shared.icon(for: uttype)) + } + else if let fileType = self.fileType, + let fileTypeExtension = FileManager.HFSTypeToExtension[fileType.lowercased()], + let uttype = UTType(filenameExtension: fileTypeExtension) { + return Image(nsImage: NSWorkspace.shared.icon(for: uttype)) + } + else { + return Image(nsImage: NSWorkspace.shared.icon(for: UTType.data)) + } + +// Image(nsImage: NSWorkspace.shared.icon(for: UTType(filenameExtension: (filename as NSString).pathExtension) ?? UTType.content)) + } + #endif + + + var body: some View { + fileIcon() + .resizable() + .scaledToFit() + } +} diff --git a/Hotline/Library/Views/FileImageView.swift b/Hotline/Library/Views/FileImageView.swift new file mode 100644 index 0000000..0cc50f1 --- /dev/null +++ b/Hotline/Library/Views/FileImageView.swift @@ -0,0 +1,98 @@ +import SwiftUI + +struct FileImageView: NSViewRepresentable { + var image: NSImage? + + let minimumSize: CGSize = CGSize(width: 350, height: 350) + let presentationPaddingRatio: Double = 0.5 + + func makeNSView(context: Context) -> NSImageView { + let imageView = NSImageView() + imageView.imageScaling = .scaleProportionallyUpOrDown + imageView.animates = true + imageView.isEditable = false + imageView.allowsCutCopyPaste = true + imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + if let img = self.image { + imageView.image = img + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.resizeWindowForImageView(imageView) + } + } + + return imageView + } + + func updateNSView(_ nsView: NSImageView, context: Context) { + nsView.image = self.image + } + + // MARK: - + + func resizeWindowForImageView(_ imageView: NSImageView) { + guard let window = imageView.window, let img = imageView.image else { + return + } + + var windowRect = window.contentLayoutRect + let windowChromeSize = CGSize(width: window.frame.width - windowRect.width, height: window.frame.height - windowRect.height) + + var windowMinSize: CGSize = windowRect.size + let centerPoint = CGPoint(x: window.frame.midX, y: window.frame.midY) + + windowRect.size = img.size + + if let screen = window.screen { + var paddedScreenSize = screen.frame.size + paddedScreenSize.width *= self.presentationPaddingRatio + paddedScreenSize.height *= self.presentationPaddingRatio + + windowMinSize = aspectFit(source: windowRect.size, bounds: self.minimumSize) + windowRect.size = aspectFit(source: windowRect.size, bounds: paddedScreenSize, minimum: windowMinSize) + } + + windowRect.size.width += windowChromeSize.width + windowRect.size.height += windowChromeSize.height + + windowRect.origin.x = centerPoint.x - windowRect.width / 2.0 + windowRect.origin.y = centerPoint.y - windowRect.height / 2.0 + + window.setFrame(windowRect, display: true, animate: true) + +// Do these APIs even work?? +// window.aspectRatio = windowRect.size +// window.contentAspectRatio = windowRect.size + } + + func aspectFit(source sourceSize: CGSize, bounds boundingSize: CGSize, minimum minSize: CGSize? = nil) -> CGSize { + let sourceAspectRatio = sourceSize.width / sourceSize.height + + var fitSize: CGSize = sourceSize + + if fitSize.width > boundingSize.width { + fitSize.width = boundingSize.width + fitSize.height = fitSize.width / sourceAspectRatio + } + + if fitSize.height > boundingSize.height { + fitSize.height = boundingSize.height + fitSize.width = fitSize.height * sourceAspectRatio + } + + if let m = minSize { + if fitSize.width < m.width { + fitSize.width = m.width + fitSize.height = fitSize.width / sourceAspectRatio + } + + if fitSize.height < m.height { + fitSize.height = m.height + fitSize.width = fitSize.height * sourceAspectRatio + } + } + + return fitSize + } +} diff --git a/Hotline/Library/Views/GroupedIconView.swift b/Hotline/Library/Views/GroupedIconView.swift new file mode 100644 index 0000000..313fac6 --- /dev/null +++ b/Hotline/Library/Views/GroupedIconView.swift @@ -0,0 +1,23 @@ +import SwiftUI + +struct GroupedIconView: View { + let color: Color + let systemName: String + var padding: CGFloat = 6.0 + var cornerRadius: CGFloat = 8.0 + + var body: some View { + RoundedRectangle(cornerRadius: self.cornerRadius, style: .continuous) + .fill(LinearGradient(colors: [self.color.mix(with: .white, by: 0.2), self.color], startPoint: .top, endPoint: .bottom)) + .shadow(color: self.color.mix(with: .black, by: 0.5).opacity(0.2), radius: 1, x: 0, y: 1) + .overlay { + Image(systemName: self.systemName) + .resizable() + .scaledToFit() + .symbolRenderingMode(.palette) + .foregroundStyle(.white, .white.opacity(0.4)) + .padding(self.padding) + .shadow(color: self.color.mix(with: .black, by: 0.5).opacity(0.2), radius: 0, x: 0, y: -1) + } + } +} diff --git a/Hotline/Library/Views/HotlinePanel.swift b/Hotline/Library/Views/HotlinePanel.swift new file mode 100644 index 0000000..d7d8284 --- /dev/null +++ b/Hotline/Library/Views/HotlinePanel.swift @@ -0,0 +1,64 @@ +import Cocoa +import SwiftUI + +fileprivate let HOTLINE_PANEL_SIZE: CGSize = CGSizeMake(468, 114 - 10) + +class HotlinePanel: NSPanel { + init(_ view: HotlinePanelView) { + super.init(contentRect: NSRect(x: 0, y: 0, width: HOTLINE_PANEL_SIZE.width, height: HOTLINE_PANEL_SIZE.height), styleMask: [.nonactivatingPanel, .titled, .closable, .utilityWindow, .fullSizeContentView], backing: .buffered, defer: false) + + // Make sure that the panel is in front of almost all other windows + self.isFloatingPanel = true + self.level = .floating + self.hidesOnDeactivate = true + self.animationBehavior = .utilityWindow + + // Allow the panelto appear in a fullscreen space +// self.collectionBehavior.insert(.fullScreenAuxiliary) + self.collectionBehavior.insert(.canJoinAllSpaces) + self.collectionBehavior.insert(.ignoresCycle) + +// self.appearance = NSAppearance(named: .vibrantDark) + + // Don't delete panel state when it's closed. + self.isReleasedWhenClosed = false + + self.standardWindowButton(.closeButton)?.isHidden = false + self.standardWindowButton(.zoomButton)?.isHidden = true + self.standardWindowButton(.miniaturizeButton)?.isHidden = true + + // Make it transparent, the view inside will have to set the background. + // This is necessary because otherwise, we will have some space for the titlebar on top of the height of the view itself which we don't want. + self.isOpaque = false + self.backgroundColor = .clear + + // Since we don't show a statusbar, this allows us to drag the window by its background instead of the titlebar. + self.isMovableByWindowBackground = true + self.titlebarAppearsTransparent = true + + let hostingView = NSHostingView(rootView: view.edgesIgnoringSafeArea(.top)) + hostingView.sizingOptions = [.preferredContentSize] + + let visualEffectView = NSVisualEffectView(frame: NSRect(x: 0, y: 0, width: HOTLINE_PANEL_SIZE.width, height: HOTLINE_PANEL_SIZE.height)) + visualEffectView.material = .sidebar + visualEffectView.blendingMode = .behindWindow + visualEffectView.state = NSVisualEffectView.State.active + visualEffectView.autoresizingMask = [.width, .height] + visualEffectView.autoresizesSubviews = true + visualEffectView.addSubview(hostingView) + + self.contentView = visualEffectView + + hostingView.frame = visualEffectView.bounds + + self.cascadeTopLeft(from: NSMakePoint(16, 16)) + } + + override var canBecomeKey: Bool { + return false + } + + override var canBecomeMain: Bool { + return false + } +} diff --git a/Hotline/Library/Views/QuickLookPreviewView.swift b/Hotline/Library/Views/QuickLookPreviewView.swift new file mode 100644 index 0000000..6ba154e --- /dev/null +++ b/Hotline/Library/Views/QuickLookPreviewView.swift @@ -0,0 +1,22 @@ +import SwiftUI +import Quartz + +/// Embeddable QuickLook preview view for macOS +/// +/// This view uses QLPreviewView to display file previews inline, without showing a modal. +/// Supports all file types that QuickLook supports (images, PDFs, videos, documents, etc.) +struct QuickLookPreviewView: NSViewRepresentable { + let fileURL: URL + + func makeNSView(context: Context) -> QLPreviewView { + let preview = QLPreviewView(frame: .zero, style: .normal)! + preview.autostarts = true + preview.shouldCloseWithWindow = true + preview.previewItem = fileURL as QLPreviewItem + return preview + } + + func updateNSView(_ nsView: QLPreviewView, context: Context) { + nsView.previewItem = fileURL as QLPreviewItem + } +} diff --git a/Hotline/Library/Views/SpinningGlobeView.swift b/Hotline/Library/Views/SpinningGlobeView.swift new file mode 100644 index 0000000..bccb969 --- /dev/null +++ b/Hotline/Library/Views/SpinningGlobeView.swift @@ -0,0 +1,90 @@ +import SwiftUI + +/// An animated globe icon that cycles through different world regions +/// +/// Displays a spinning globe effect by cycling through SF Symbol images showing +/// different parts of the world. Useful for indicating network activity or global content. +/// +/// Example: +/// ```swift +/// SpinningGlobeView() +/// .frame(width: 16, height: 16) +/// +/// SpinningGlobeView(frameDelay: 0.5) +/// .frame(width: 24, height: 24) +/// ``` +struct SpinningGlobeView: View { + /// Delay between frames in seconds (default: 0.3) + let frameDelay: TimeInterval + + /// SF Symbol names for each frame of the globe animation + private let globeFrames = [ + "globe.americas.fill", + "globe.europe.africa.fill", + "globe.central.south.asia.fill", + "globe.asia.australia.fill" + ] + + @State private var currentFrameIndex = 0 + @State private var animationTask: Task? + + init(frameDelay: TimeInterval = 0.25) { + self.frameDelay = frameDelay + } + + var body: some View { + Image(systemName: globeFrames[currentFrameIndex]) + .onAppear { + startAnimation() + } + .onDisappear { + stopAnimation() + } + } + + private func startAnimation() { + animationTask = Task { + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: UInt64(frameDelay * 1_000_000_000)) + + guard !Task.isCancelled else { break } + + currentFrameIndex = (currentFrameIndex + 1) % globeFrames.count + } + } + } + + private func stopAnimation() { + animationTask?.cancel() + animationTask = nil + } +} + +#Preview { + VStack(spacing: 20) { + HStack(spacing: 20) { + SpinningGlobeView() + .frame(width: 12, height: 12) + + SpinningGlobeView() + .frame(width: 16, height: 16) + + SpinningGlobeView() + .frame(width: 24, height: 24) + } + + Text("Different frame delays:") + + HStack(spacing: 20) { + SpinningGlobeView(frameDelay: 0.1) + .frame(width: 16, height: 16) + + SpinningGlobeView(frameDelay: 0.3) + .frame(width: 16, height: 16) + + SpinningGlobeView(frameDelay: 0.6) + .frame(width: 16, height: 16) + } + } + .padding() +} diff --git a/Hotline/Library/Views/VisualEffectView.swift b/Hotline/Library/Views/VisualEffectView.swift new file mode 100644 index 0000000..e595c55 --- /dev/null +++ b/Hotline/Library/Views/VisualEffectView.swift @@ -0,0 +1,22 @@ +import SwiftUI + +struct VisualEffectView: NSViewRepresentable +{ + let material: NSVisualEffectView.Material + let blendingMode: NSVisualEffectView.BlendingMode + + func makeNSView(context: Context) -> NSVisualEffectView + { + let visualEffectView = NSVisualEffectView() + visualEffectView.material = material + visualEffectView.blendingMode = blendingMode + visualEffectView.state = NSVisualEffectView.State.active + return visualEffectView + } + + func updateNSView(_ visualEffectView: NSVisualEffectView, context: Context) + { + visualEffectView.material = material + visualEffectView.blendingMode = blendingMode + } +} diff --git a/Hotline/Library/VisualEffectView.swift b/Hotline/Library/VisualEffectView.swift deleted file mode 100644 index e595c55..0000000 --- a/Hotline/Library/VisualEffectView.swift +++ /dev/null @@ -1,22 +0,0 @@ -import SwiftUI - -struct VisualEffectView: NSViewRepresentable -{ - let material: NSVisualEffectView.Material - let blendingMode: NSVisualEffectView.BlendingMode - - func makeNSView(context: Context) -> NSVisualEffectView - { - let visualEffectView = NSVisualEffectView() - visualEffectView.material = material - visualEffectView.blendingMode = blendingMode - visualEffectView.state = NSVisualEffectView.State.active - return visualEffectView - } - - func updateNSView(_ visualEffectView: NSVisualEffectView, context: Context) - { - visualEffectView.material = material - visualEffectView.blendingMode = blendingMode - } -} diff --git a/Hotline/Utility/BookmarkDocument.swift b/Hotline/Utility/BookmarkDocument.swift deleted file mode 100644 index 47fa442..0000000 --- a/Hotline/Utility/BookmarkDocument.swift +++ /dev/null @@ -1,32 +0,0 @@ -import SwiftUI -import Foundation -import UniformTypeIdentifiers - -struct BookmarkDocument: FileDocument { - static var readableContentTypes: [UTType] { [.data, UTType(filenameExtension: "hlbm")!] } - static var writableContentTypes: [UTType] { [.data, UTType(filenameExtension: "hlbm")!] } - - var bookmark: Bookmark - - init(bookmark: Bookmark) { - self.bookmark = bookmark - } - - init(configuration: ReadConfiguration) throws { - guard configuration.file.isRegularFile, - let data = configuration.file.regularFileContents, - let fileName = configuration.file.preferredFilename, - let bookmark = Bookmark(fileData: data, name: (fileName as NSString).deletingPathExtension) - else { - throw CocoaError(.fileReadCorruptFile) - } - self.bookmark = bookmark - } - - func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { - let wrapper = FileWrapper(regularFileWithContents: self.bookmark.bookmarkFileData()!) - wrapper.fileAttributes[FileAttributeKey.hfsCreatorCode.rawValue] = "HTLC".fourCharCode() - wrapper.fileAttributes[FileAttributeKey.hfsTypeCode.rawValue] = "HTbm".fourCharCode() - return wrapper - } -} diff --git a/Hotline/Utility/ColorArt.swift b/Hotline/Utility/ColorArt.swift deleted file mode 100644 index 93889a3..0000000 --- a/Hotline/Utility/ColorArt.swift +++ /dev/null @@ -1,424 +0,0 @@ - -// Swift translation and modernization -// by Dustin Mierau -// -// of: -// -// ColorArt.swift -// SLColorArt by Panic Inc. -// -// Copyright (C) 2012 Panic Inc. Code by Wade Cosgrove. All rights reserved. -// -// Redistribution and use, with or without modification, are permitted -// provided that the following conditions are met: -// -// - Redistributions must reproduce the above copyright notice, this list of -// conditions and the following disclaimer in the documentation and/or other -// materials provided with the distribution. -// -// - Neither the name of Panic Inc nor the names of its contributors may be used -// to endorse or promote works derived from this software without specific prior -// written permission from Panic Inc. -// -// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -// ARE DISCLAIMED. IN NO EVENT SHALL PANIC INC BE LIABLE FOR ANY DIRECT, INDIRECT, -// INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR -// PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, -// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE -// POSSIBILITY OF SUCH DAMAGE. - -import AppKit -import SwiftUI - -fileprivate let kColorThresholdMinimumPercentage: CGFloat = 0.001 - -// ColorArt.analyze(image: img) -> ColorArt? - -struct ColorArt: Equatable { - let backgroundColor: NSColor - let primaryColor: NSColor - let secondaryColor: NSColor - let detailColor: NSColor -// let scaledImage: NSImage - - static func == (lhs: ColorArt, rhs: ColorArt) -> Bool { - return lhs.backgroundColor == rhs.backgroundColor && - lhs.primaryColor == rhs.primaryColor && - lhs.secondaryColor == rhs.secondaryColor && - lhs.detailColor == rhs.detailColor - } - - static func analyze(image: NSImage) -> ColorArt? { - print("ColorArt.analyze: Starting, image size: \(image.size)") - // Scale image to a reasonable size for analysis - // This is important because: - // 1. Makes analysis faster (fewer pixels) - // 2. Normalizes weird image dimensions - // 3. Ensures CGImage conversion succeeds - print("ColorArt.analyze: Calling scaleImage...") - let finalImage = Self.scaleImage(image, size: NSSize(width: 100, height: 100)) - print("ColorArt.analyze: scaleImage returned, scaled size: \(finalImage.size)") - - guard let colors = Self.analyzeImage(finalImage) else { - print("ColorArt.analyze: failed with no colors") - return nil - } - - print("ColorArt.analyze: returning colors", colors) - - return ColorArt(backgroundColor: colors.background, - primaryColor: colors.primary, - secondaryColor: colors.secondary, - detailColor: colors.detail) - } - - // MARK: - Image Scaling - - private static func scaleImage(_ image: NSImage, size scaledSize: NSSize) -> NSImage { - print("ColorArt.scaleImage: Entered, input: \(image.size), target: \(scaledSize)") - // Get CGImage directly without using lockFocus - print("ColorArt.scaleImage: Getting CGImage...") - guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { - print("ColorArt.scaleImage: Failed to get CGImage, returning original") - return image - } - print("ColorArt.scaleImage: Got CGImage") - - let imageSize = image.size - let squareSize = min(imageSize.width, imageSize.height) - - // Use native square size if passed zero size - let finalScaledSize = scaledSize == .zero ? NSSize(width: squareSize, height: squareSize) : scaledSize - - // Create bitmap context for drawing - let width = Int(finalScaledSize.width) - let height = Int(finalScaledSize.height) - let colorSpace = CGColorSpaceCreateDeviceRGB() - let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue) - - guard let context = CGContext( - data: nil, - width: width, - height: height, - bitsPerComponent: 8, - bytesPerRow: width * 4, - space: colorSpace, - bitmapInfo: bitmapInfo.rawValue - ) else { - return image - } - - // Draw the image scaled - context.interpolationQuality = .high - context.draw(cgImage, in: CGRect(x: 0, y: 0, width: finalScaledSize.width, height: finalScaledSize.height)) - - // Create NSImage from context - guard let scaledCGImage = context.makeImage() else { - return image - } - - let bitmapRep = NSBitmapImageRep(cgImage: scaledCGImage) - let finalImage = NSImage(size: finalScaledSize) - finalImage.addRepresentation(bitmapRep) - - return finalImage - } - - // MARK: - Image Analysis - - private static func analyzeImage(_ image: NSImage) -> (background: NSColor, primary: NSColor, secondary: NSColor, detail: NSColor)? { - var imageColors: NSCountedSet? - guard let backgroundColor = self.findEdgeColor(image, imageColors: &imageColors), - let colors = imageColors - else { - return nil - } - - let darkBackground = backgroundColor.isDarkColor - var primaryColor: NSColor? - var secondaryColor: NSColor? - var detailColor: NSColor? - - self.findTextColors(colors, primaryColor: &primaryColor, secondaryColor: &secondaryColor, detailColor: &detailColor, backgroundColor: backgroundColor) - - // Fallback to black or white if colors not found - if primaryColor == nil { - primaryColor = darkBackground ? .white : .black - } - - if secondaryColor == nil { - secondaryColor = darkBackground ? .white : .black - } - - if detailColor == nil { - detailColor = darkBackground ? .white : .black - } - - // Convert all colors to calibrated RGB color space for consistency - // This ensures all colors are in the same color space and prevents - // any color space conversion issues when used in SwiftUI - let rgbColorSpace = NSColorSpace.genericRGB - let finalBackground = backgroundColor.usingColorSpace(rgbColorSpace) ?? backgroundColor - let finalPrimary = primaryColor!.usingColorSpace(rgbColorSpace) ?? primaryColor! - let finalSecondary = secondaryColor!.usingColorSpace(rgbColorSpace) ?? secondaryColor! - let finalDetail = detailColor!.usingColorSpace(rgbColorSpace) ?? detailColor! - - return (finalBackground, finalPrimary, finalSecondary, finalDetail) - } - - // MARK: - Edge Color Detection - - private static func findEdgeColor(_ image: NSImage, imageColors: inout NSCountedSet?) -> NSColor? { - var bitmapRep: NSBitmapImageRep? - - // Try to get existing bitmap representation - if let existingRep = image.representations.last as? NSBitmapImageRep { - bitmapRep = existingRep - } else { - // Create bitmap rep from CGImage instead of using lockFocus - guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { - return nil - } - bitmapRep = NSBitmapImageRep(cgImage: cgImage) - } - - // Convert to RGB color space - guard let bitmapRep = bitmapRep?.converting(to: .genericRGB, renderingIntent: .default) else { - return nil - } - - let pixelsWide = bitmapRep.pixelsWide - let pixelsHigh = bitmapRep.pixelsHigh - - let colors = NSCountedSet(capacity: pixelsWide * pixelsHigh) - let leftEdgeColors = NSCountedSet(capacity: pixelsHigh) - var searchColumnX = 0 - - for x in 0.. 0.5 { - leftEdgeColors.add(color) - } - } - - if color.alphaComponent > CGFloat.ulpOfOne { - colors.add(color) - } - } - - // Background is clear, keep looking in next column for background color - if leftEdgeColors.count == 0 { - searchColumnX += 1 - } - } - - imageColors = colors - - var sortedColors: [CountedColor] = [] - - for color in leftEdgeColors { - guard let nsColor = color as? NSColor else { continue } - let colorCount = leftEdgeColors.count(for: nsColor) - - let randomColorsThreshold = Int(CGFloat(pixelsHigh) * kColorThresholdMinimumPercentage) - - if colorCount <= randomColorsThreshold { - continue - } - - sortedColors.append(CountedColor(color: nsColor, count: colorCount)) - } - - sortedColors.sort { $0.count > $1.count } - - guard var proposedEdgeColor = sortedColors.first else { - return nil - } - - // Want to choose color over black/white so we keep looking - if proposedEdgeColor.color.isBlackOrWhite { - for i in 1.. 0.3 { - if !nextProposedColor.color.isBlackOrWhite { - proposedEdgeColor = nextProposedColor - break - } - } else { - // Reached color threshold less than 30% of the original proposed edge color - break - } - } - } - - return proposedEdgeColor.color - } - - // MARK: - Text Color Detection - - private static func findTextColors(_ colors: NSCountedSet, primaryColor: inout NSColor?, secondaryColor: inout NSColor?, detailColor: inout NSColor?, backgroundColor: NSColor) { - var sortedColors: [CountedColor] = [] - let findDarkTextColor = !backgroundColor.isDarkColor - - for color in colors { - guard let nsColor = color as? NSColor else { continue } - let adjustedColor = nsColor.withMinimumSaturation(0.15) - - if adjustedColor.isDarkColor == findDarkTextColor { - let colorCount = colors.count(for: nsColor) - sortedColors.append(CountedColor(color: adjustedColor, count: colorCount)) - } - } - - sortedColors.sort { $0.count > $1.count } - - for container in sortedColors { - let curColor = container.color - - if primaryColor == nil { - if curColor.isContrasting(to: backgroundColor) { - primaryColor = curColor - } - } else if secondaryColor == nil { - if let primary = primaryColor, - primary.isDistinct(from: curColor) && curColor.isContrasting(to: backgroundColor) { - secondaryColor = curColor - } - } else if detailColor == nil { - if let primary = primaryColor, - let secondary = secondaryColor, - secondary.isDistinct(from: curColor) && - primary.isDistinct(from: curColor) && - curColor.isContrasting(to: backgroundColor) { - detailColor = curColor - break - } - } - } - } -} - -// MARK: - Helper Classes - -fileprivate struct CountedColor { - let color: NSColor - let count: Int -} - -// MARK: - NSColor Extensions - -extension NSColor { - var isDarkColor: Bool { - guard let convertedColor = usingColorSpace(.genericRGB) else { - return false - } - - var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 - convertedColor.getRed(&r, green: &g, blue: &b, alpha: &a) - - let lum = 0.2126 * r + 0.7152 * g + 0.0722 * b - - return lum < 0.5 - } - - func isDistinct(from compareColor: NSColor) -> Bool { - guard let convertedColor = usingColorSpace(.genericRGB), - let convertedCompareColor = compareColor.usingColorSpace(.genericRGB) - else { - return false - } - - var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 - var r1: CGFloat = 0, g1: CGFloat = 0, b1: CGFloat = 0, a1: CGFloat = 0 - - convertedColor.getRed(&r, green: &g, blue: &b, alpha: &a) - convertedCompareColor.getRed(&r1, green: &g1, blue: &b1, alpha: &a1) - - let threshold: CGFloat = 0.25 - - if abs(r - r1) > threshold || abs(g - g1) > threshold || abs(b - b1) > threshold || abs(a - a1) > threshold { - // Check for grays, prevent multiple gray colors - if abs(r - g) < 0.03 && abs(r - b) < 0.03 { - if abs(r1 - g1) < 0.03 && abs(r1 - b1) < 0.03 { - return false - } - } - - return true - } - - return false - } - - func withMinimumSaturation(_ minSaturation: CGFloat) -> NSColor { - guard let tempColor = usingColorSpace(.genericRGB) else { - return self - } - - var hue: CGFloat = 0, saturation: CGFloat = 0, brightness: CGFloat = 0, alpha: CGFloat = 0 - tempColor.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) - - if saturation < minSaturation { - return NSColor(calibratedHue: hue, saturation: minSaturation, brightness: brightness, alpha: alpha) - } - - return self - } - - var isBlackOrWhite: Bool { - guard let tempColor = usingColorSpace(.genericRGB) else { - return false - } - - var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 - tempColor.getRed(&r, green: &g, blue: &b, alpha: &a) - - // White - if r > 0.91 && g > 0.91 && b > 0.91 { - return true - } - - // Black - if r < 0.09 && g < 0.09 && b < 0.09 { - return true - } - - return false - } - - func isContrasting(to color: NSColor) -> Bool { - guard let backgroundColor = usingColorSpace(.genericRGB), - let foregroundColor = color.usingColorSpace(.genericRGB) - else { - return true - } - - var br: CGFloat = 0, bg: CGFloat = 0, bb: CGFloat = 0, ba: CGFloat = 0 - var fr: CGFloat = 0, fg: CGFloat = 0, fb: CGFloat = 0, fa: CGFloat = 0 - - backgroundColor.getRed(&br, green: &bg, blue: &bb, alpha: &ba) - foregroundColor.getRed(&fr, green: &fg, blue: &fb, alpha: &fa) - - let bLum = 0.2126 * br + 0.7152 * bg + 0.0722 * bb - let fLum = 0.2126 * fr + 0.7152 * fg + 0.0722 * fb - - let contrast: CGFloat - if bLum > fLum { - contrast = (bLum + 0.05) / (fLum + 0.05) - } else { - contrast = (fLum + 0.05) / (bLum + 0.05) - } - - return contrast > 1.6 - } -} diff --git a/Hotline/Utility/DAKeychain.swift b/Hotline/Utility/DAKeychain.swift deleted file mode 100644 index 8031886..0000000 --- a/Hotline/Utility/DAKeychain.swift +++ /dev/null @@ -1,81 +0,0 @@ -// -// 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/Utility/FoundationExtensions.swift b/Hotline/Utility/FoundationExtensions.swift deleted file mode 100644 index 90a3032..0000000 --- a/Hotline/Utility/FoundationExtensions.swift +++ /dev/null @@ -1,107 +0,0 @@ -import Foundation -import SwiftUI - -enum Endianness { - case big - case little -} - -extension String { - - func markdownToAttributedString() -> AttributedString { - let markdownText = self.convertingLinksToMarkdown() - let attr = (try? AttributedString(markdown: markdownText, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(self) - - return attr - } - - func convertToAttributedStringWithLinks() -> AttributedString { - let attributedString: NSMutableAttributedString = NSMutableAttributedString(string: self) - let matches = self.ranges(of: RegularExpressions.relaxedLink) - for match in matches { - let matchString = String(self[match]) - if matchString.isEmailAddress() { - attributedString.addAttribute(.link, value: "mailto:\(matchString)", range: NSRange(match, in: self)) - } - else { - attributedString.addAttribute(.link, value: matchString, range: NSRange(match, in: self)) - } -// attributedString.addAttribute(.underlineStyle, value: 1, range: NSRange(match, in: self)) - } - return AttributedString(attributedString) - } - - func isEmailAddress() -> Bool { - self.wholeMatch(of: RegularExpressions.emailAddress) != nil - } - - func isWebURL() -> Bool { - guard let url = URL(string: self) else { - return false - } - switch url.scheme?.lowercased() { - case "http", "https": - return true - default: - return false - } - } - - func isImageURL() -> Bool { - guard let url = URL(string: self) else { - return false - } - - switch url.pathExtension.lowercased() { - case "jpg", "jpeg", "png", "gif": - return true - default: - return false - } - } - - func convertingLinksToMarkdown() -> String { -// var cp = String(self) - - self.replacing(RegularExpressions.relaxedLink) { match in - let linkText = self[match.range] - - // Only add https:// if the link doesn't already have a scheme - let hasScheme = (try? RegularExpressions.supportedLinkScheme.prefixMatch(in: linkText)) != nil - let url = hasScheme ? String(linkText) : "https://\(linkText)" - - return "[\(linkText)](\(url))" - } - -// cp.replace(RegularExpressions.relaxedLink) { match -> String in -// let linkText = self[match.range] -// var injectedScheme = "https://" -// if let _ = try? RegularExpressions.supportedLinkScheme.prefixMatch(in: linkText) { -// injectedScheme = "" -// } -// -// return "[\(linkText)](\(injectedScheme)\(linkText))" -// } -// return cp - } -} - - - -extension Binding where Value: OptionSet, Value == Value.Element { - func bindedValue(_ options: Value) -> Bool { - return wrappedValue.contains(options) - } - - func bind(_ options: Value) -> Binding { - return .init { () -> Bool in - self.wrappedValue.contains(options) - } set: { newValue in - if newValue { - self.wrappedValue.insert(options) - } else { - self.wrappedValue.remove(options) - } - } - } -} diff --git a/Hotline/Utility/NSWindowBridge.swift b/Hotline/Utility/NSWindowBridge.swift deleted file mode 100644 index 8f766d9..0000000 --- a/Hotline/Utility/NSWindowBridge.swift +++ /dev/null @@ -1,46 +0,0 @@ -import SwiftUI - -fileprivate class NSWindowAccessorView: NSView { - let executeBlock: (_ window: NSWindow? ) -> () - - init(_ inConfigFunction: @escaping (_ window: NSWindow? ) -> () ) { - executeBlock = inConfigFunction - super.init( frame: NSRect() ) - } - - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - - public override func viewDidMoveToWindow() { - super.viewDidMoveToWindow() - executeBlock( self.window ) // We pass it through even if it is nil. - } -} - -public struct NSWindowAccessor: NSViewRepresentable { - var configCode: (_ window: NSWindow? ) -> () - - public init(_ configCode: @escaping (_: NSWindow?) -> Void) { self.configCode = configCode } - public func makeNSView(context: Context) -> NSView { return NSWindowAccessorView( configCode ) } - public func updateNSView(_ nsView: NSView, context: Context) {} -} - - -//import SwiftUI - - /// A helper view you can embed once per window to run a closure -/// with the underlying NSWindow reference. -struct WindowConfigurator: NSViewRepresentable { - let configure: (NSWindow) -> Void - - func makeNSView(context: Context) -> NSView { - let view = NSView() - DispatchQueue.main.async { - if let window = view.window { - configure(window) - } - } - return view - } - - func updateNSView(_ nsView: NSView, context: Context) { } -} diff --git a/Hotline/Utility/ObservableScrollView.swift b/Hotline/Utility/ObservableScrollView.swift deleted file mode 100644 index a6f46cc..0000000 --- a/Hotline/Utility/ObservableScrollView.swift +++ /dev/null @@ -1,38 +0,0 @@ -import SwiftUI - -struct ScrollViewOffsetPreferenceKey: PreferenceKey { - typealias Value = CGFloat - static var defaultValue = CGFloat.zero - static func reduce(value: inout Value, nextValue: () -> Value) { - value += nextValue() - } -} - -struct ObservableScrollView: View where Content : View { - @Namespace var scrollSpace - @Binding var scrollOffset: CGFloat - let content: () -> Content - - init(scrollOffset: Binding, - @ViewBuilder content: @escaping () -> Content) { - _scrollOffset = scrollOffset - self.content = content - } - - var body: some View { - ScrollView { - content() - .background(GeometryReader { geo in - let offset = -geo.frame(in: .named(scrollSpace)).minY - Color.clear - .preference(key: ScrollViewOffsetPreferenceKey.self, - value: offset) - }) - - } - .coordinateSpace(name: scrollSpace) - .onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { value in - scrollOffset = value - } - } -} diff --git a/Hotline/Utility/RegularExpressions.swift b/Hotline/Utility/RegularExpressions.swift deleted file mode 100644 index c1f2a18..0000000 --- a/Hotline/Utility/RegularExpressions.swift +++ /dev/null @@ -1,235 +0,0 @@ -import RegexBuilder - -struct RegularExpressions { - static let messageBoardDivider = Regex { - Capture { - OneOrMore { - CharacterClass(.newlineSequence) - } - ZeroOrMore { - CharacterClass(.whitespace, .newlineSequence) - } - Repeat(2...) { - CharacterClass(.anyOf("_-")) - } - ZeroOrMore { - CharacterClass(.whitespace) - } - OneOrMore { - CharacterClass(.newlineSequence) - } - } - } - - static let supportedLinkScheme = Regex { - Anchor.startOfLine - ChoiceOf { - "hotline" - "http" - "https" - } - "://" - }.ignoresCase().anchorsMatchLineEndings() - - static let relaxedLink = Regex { - ChoiceOf { - Anchor.startOfLine - Anchor.wordBoundary - } - Capture { - // scheme (optional) - Optionally { - ChoiceOf { - "hotline://" - "http://" - "https://" - } - } - // domain name - OneOrMore { - CharacterClass( - .anyOf(".-@"), - ("a"..."z"), - ("0"..."9") - ) - } - // top-level domain name - "." - ChoiceOf { - "com" - "net" - "org" - "edu" - "gov" - "mil" - "aero" - "asia" - "biz" - "cat" - "coop" - "info" - "int" - "jobs" - "mobi" - "museum" - "name" - "pizza" - "post" - "pro" - "red" - "tel" - "today" - "travel" - "garden" - "online" - "ai" - "be" - "by" - "ca" - "co" - "de" - "er" - "es" - "fr" - "gs" - "ie" - "im" - "in" - "io" - "is" - "it" - "jp" - "la" - "ly" - "ma" - "md" - "me" - "my" - "nl" - "ps" - "pt" - "ja" - "st" - "to" - "tv" - "uk" - "ws" - } - // Port - Optionally { - ":" - OneOrMore { - CharacterClass(.digit) - } - } - // path - ZeroOrMore { - CharacterClass( - .anyOf("#_-/.?=&%\\()[]"), - ("a"..."z"), - ("0"..."9") - ) - } - } - ChoiceOf { - Anchor.endOfLine - Anchor.wordBoundary - } - } - .anchorsMatchLineEndings() - .ignoresCase() - - static let emailAddress = Regex { - ChoiceOf { - Anchor.startOfLine - Anchor.wordBoundary - } - Capture { - // username - OneOrMore { - CharacterClass( - .anyOf(".-_"), - ("a"..."z"), - ("0"..."9") - ) - } - "@" - // domain name - OneOrMore { - CharacterClass( - .anyOf(".-"), - ("a"..."z"), - ("0"..."9") - ) - } - // top-level domain name - "." - ChoiceOf { - "com" - "net" - "org" - "edu" - "gov" - "mil" - "aero" - "asia" - "biz" - "cat" - "coop" - "info" - "int" - "jobs" - "mobi" - "museum" - "name" - "pizza" - "post" - "pro" - "red" - "tel" - "today" - "travel" - "garden" - "online" - "ai" - "be" - "by" - "ca" - "co" - "de" - "er" - "es" - "fr" - "gs" - "ie" - "im" - "in" - "io" - "is" - "it" - "jp" - "la" - "ly" - "ma" - "md" - "me" - "my" - "nl" - "ps" - "pt" - "ja" - "st" - "to" - "tv" - "uk" - "ws" - } - } - ChoiceOf { - Anchor.endOfLine - Anchor.wordBoundary - } - } - .anchorsMatchLineEndings() - .ignoresCase() -} diff --git a/Hotline/Utility/SoundEffects.swift b/Hotline/Utility/SoundEffects.swift deleted file mode 100644 index 93f8b9a..0000000 --- a/Hotline/Utility/SoundEffects.swift +++ /dev/null @@ -1,50 +0,0 @@ -import Foundation -import AVFAudio - -enum SoundEffects: String { - case loggedIn = "logged-in" - case chatMessage = "chat-message" - case transferComplete = "transfer-complete" - case userLogin = "user-login" - case userLogout = "user-logout" - case newNews = "new-news" - case serverMessage = "server-message" - case error = "error" -} - -@Observable -class SoundEffectPlayer: NSObject, AVAudioPlayerDelegate { - static let shared = SoundEffectPlayer() - - private var activeSounds: [AVAudioPlayer] = [] - - func playSoundEffect(_ name: SoundEffects) { - // Load a local sound file - guard let soundFileURL = Bundle.main.url( - forResource: name.rawValue, - withExtension: "aiff" - ) else { - return - } - - DispatchQueue.main.async { [weak self] in - guard let self = self else { - return - } - - if let soundEffect = try? AVAudioPlayer(contentsOf: soundFileURL) { - soundEffect.delegate = self - soundEffect.volume = 0.75 - soundEffect.play() - - self.activeSounds.append(soundEffect) - } - } - } - - func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { - if let i = self.activeSounds.firstIndex(of: player) { - self.activeSounds.remove(at: i) - } - } -} diff --git a/Hotline/Utility/SwiftUIExtensions.swift b/Hotline/Utility/SwiftUIExtensions.swift deleted file mode 100644 index d0dfff4..0000000 --- a/Hotline/Utility/SwiftUIExtensions.swift +++ /dev/null @@ -1,8 +0,0 @@ -import SwiftUI -import Foundation - -extension Color { - init(hex: Int, opacity: Double = 1.0) { - self.init(red: Double((hex >> 16) & 0xFF) / 255.0, green: Double((hex >> 8) & 0xFF) / 255.0, blue: Double(hex & 0xFF) / 255.0, opacity: opacity) - } -} diff --git a/Hotline/Utility/TextDocument.swift b/Hotline/Utility/TextDocument.swift deleted file mode 100644 index 82727de..0000000 --- a/Hotline/Utility/TextDocument.swift +++ /dev/null @@ -1,31 +0,0 @@ -import Foundation -import UniformTypeIdentifiers -import SwiftUI - -struct TextFile: FileDocument { - // tell the system we support only plain text - static var readableContentTypes = [UTType.plainText, UTType.utf8PlainText] - - // by default our document is empty - var text = "" - - // a simple initializer that creates new, empty documents - init(initialText: String = "") { - text = initialText - } - - // this initializer loads data that has been saved previously - init(configuration: ReadConfiguration) throws { - - if let data = configuration.file.regularFileContents { - if let str = String(data: data, encoding: .utf8) { - self.text = str - } - } - } - - // this will be called when the system wants to write our data to disk - func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { - return FileWrapper(regularFileWithContents: self.text.data(using: .utf8)!) - } -} diff --git a/Hotline/macOS/TrackerView.swift b/Hotline/macOS/TrackerView.swift index 34f7a57..6fab091 100644 --- a/Hotline/macOS/TrackerView.swift +++ b/Hotline/macOS/TrackerView.swift @@ -485,38 +485,61 @@ struct TrackerBookmarkSheet: View { var body: some View { VStack(alignment: .leading) { - if self.bookmark == nil { - Text("Type the address and name of a Hotline Tracker:") - .foregroundStyle(.secondary) - .padding(.bottom, 8) - } - Form { + if self.bookmark == nil { + HStack(alignment: .top, spacing: 10) { + GroupedIconView(color: .blue, systemName: "point.3.filled.connected.trianglepath.dotted", padding: 5.0) + .frame(width: 28, height: 28) + + VStack(alignment: .leading) { + Text("Add a Hotline Tracker") + + Text("Enter the address and name of a Hotline Tracker you want to add.") + .foregroundStyle(.secondary) + .font(.subheadline) + } + } + } + else { + HStack(alignment: .top, spacing: 10) { + GroupedIconView(color: .blue, systemName: "point.3.filled.connected.trianglepath.dotted", padding: 5.0) + .frame(width: 28, height: 28) + + VStack(alignment: .leading) { + Text("Edit Hotline Tracker") + + Text("Change the address and name of your Hotline Tracker.") + .foregroundStyle(.secondary) + .font(.subheadline) + } + } + } + Group { TextField(text: $trackerAddress) { - Text("Address:") + Text("Address") } TextField(text: $trackerName, prompt: Text("Optional")) { - Text("Name:") + Text("Name") } } - .textFieldStyle(.roundedBorder) +// .textFieldStyle(.roundedBorder) .controlSize(.large) } + .formStyle(.grouped) } - .frame(width: 300) + .frame(width: 350) .fixedSize(horizontal: true, vertical: true) - .padding() .toolbar { ToolbarItem(placement: .confirmationAction) { Button { self.saveTracker() } label: { if self.bookmark != nil { - Text("Save Tracker") + Text("Save") } else { - Text("Add Tracker") + Text("Add") } } } -- cgit From e1566598c96601ebcf3229aab22fc7a593ee1904 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 7 Nov 2025 16:16:00 -0800 Subject: Further UI tweraks. Transfers button in toolbar. Better empty states in places. Fix race condition with transactions ids (yikes)! --- .../Section Transfers.imageset/Contents.json | 23 + .../Section Transfers.png | Bin 0 -> 883 bytes .../Section Transfers@2x.png | Bin 0 -> 2039 bytes .../Section Transfers@3x.png | Bin 0 -> 3584 bytes Hotline/macOS/TrackerView.swift | 785 --------------------- .../macOS/Trackers/TrackerBookmarkServerView.swift | 38 + Hotline/macOS/Trackers/TrackerBookmarkSheet.swift | 119 ++++ Hotline/macOS/Trackers/TrackerItemView.swift | 73 ++ 8 files changed, 253 insertions(+), 785 deletions(-) create mode 100644 Hotline/Assets.xcassets/Section Transfers.imageset/Contents.json create mode 100644 Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers.png create mode 100644 Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers@2x.png create mode 100644 Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers@3x.png delete mode 100644 Hotline/macOS/TrackerView.swift create mode 100644 Hotline/macOS/Trackers/TrackerBookmarkServerView.swift create mode 100644 Hotline/macOS/Trackers/TrackerBookmarkSheet.swift create mode 100644 Hotline/macOS/Trackers/TrackerItemView.swift diff --git a/Hotline/Assets.xcassets/Section Transfers.imageset/Contents.json b/Hotline/Assets.xcassets/Section Transfers.imageset/Contents.json new file mode 100644 index 0000000..490868e --- /dev/null +++ b/Hotline/Assets.xcassets/Section Transfers.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Section Transfers.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Section Transfers@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Section Transfers@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers.png b/Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers.png new file mode 100644 index 0000000..cbbd304 Binary files /dev/null and b/Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers.png differ diff --git a/Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers@2x.png b/Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers@2x.png new file mode 100644 index 0000000..2e8836b Binary files /dev/null and b/Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers@2x.png differ diff --git a/Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers@3x.png b/Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers@3x.png new file mode 100644 index 0000000..a8086b7 Binary files /dev/null and b/Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers@3x.png differ diff --git a/Hotline/macOS/TrackerView.swift b/Hotline/macOS/TrackerView.swift deleted file mode 100644 index 6fab091..0000000 --- a/Hotline/macOS/TrackerView.swift +++ /dev/null @@ -1,785 +0,0 @@ -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 = [] - @State private var trackerServers: [Bookmark: [BookmarkServer]] = [:] - @State private var loadingTrackers: Set = [] - @State private var fetchTasks: [Bookmark: Task] = [:] - @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") - } - } -} - -struct TrackerBookmarkSheet: View { - @Environment(\.dismiss) private var dismiss - @Environment(\.modelContext) private var modelContext - - @State private var bookmark: Bookmark? = nil - @State private var trackerAddress: String = "" - @State private var trackerName: String = "" - - init() { - - } - - init(_ editingBookmark: Bookmark) { - _bookmark = .init(initialValue: editingBookmark) - _trackerAddress = .init(initialValue: editingBookmark.displayAddress) - _trackerName = .init(initialValue: editingBookmark.name) - } - - var body: some View { - VStack(alignment: .leading) { - Form { - if self.bookmark == nil { - HStack(alignment: .top, spacing: 10) { - GroupedIconView(color: .blue, systemName: "point.3.filled.connected.trianglepath.dotted", padding: 5.0) - .frame(width: 28, height: 28) - - VStack(alignment: .leading) { - Text("Add a Hotline Tracker") - - Text("Enter the address and name of a Hotline Tracker you want to add.") - .foregroundStyle(.secondary) - .font(.subheadline) - } - } - } - else { - HStack(alignment: .top, spacing: 10) { - GroupedIconView(color: .blue, systemName: "point.3.filled.connected.trianglepath.dotted", padding: 5.0) - .frame(width: 28, height: 28) - - VStack(alignment: .leading) { - Text("Edit Hotline Tracker") - - Text("Change the address and name of your Hotline Tracker.") - .foregroundStyle(.secondary) - .font(.subheadline) - } - } - } - - Group { - TextField(text: $trackerAddress) { - Text("Address") - } - TextField(text: $trackerName, prompt: Text("Optional")) { - Text("Name") - } - } -// .textFieldStyle(.roundedBorder) - .controlSize(.large) - } - .formStyle(.grouped) - } - .frame(width: 350) - .fixedSize(horizontal: true, vertical: true) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button { - self.saveTracker() - } label: { - if self.bookmark != nil { - Text("Save") - } - else { - Text("Add") - } - } - } - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - self.trackerName = "" - self.trackerAddress = "" - - self.dismiss() - } - } - } - } - - private func saveTracker() { - 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 { - if self.bookmark != nil { - // We're editing an existing bookmark. - self.bookmark?.name = displayName - self.bookmark?.address = host - self.bookmark?.port = port - } - else { - // We're creating a new bookmark. - let newBookmark = Bookmark(type: .tracker, name: displayName, address: host, port: port) - Bookmark.add(newBookmark, context: modelContext) - } - - self.trackerName = "" - self.trackerAddress = "" - - self.dismiss() - } - } - } -} - -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 { - VStack(alignment: .leading) { - Form { - Group { - TextField(text: $serverName) { - Text("Name:") - } - .padding(.bottom) - - TextField(text: $serverAddress) { - Text("Address:") - } - TextField(text: $serverLogin, prompt: Text("Optional")) { - Text("Login:") - } - SecureField(text: $serverPassword, prompt: Text("Optional")) { - Text("Password:") - } - } - .textFieldStyle(.roundedBorder) - .controlSize(.large) - } - } - .frame(width: 300) - .fixedSize(horizontal: true, vertical: true) - .padding() - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("Save Bookmark") { - 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() - } - } - } - } -} - -struct TrackerBookmarkServerView: View { - let server: BookmarkServer - - var body: some View { - HStack(alignment: .center, spacing: 6) { - Image("Server") - .resizable() - .scaledToFit() - .frame(width: 16, height: 16, alignment: .center) - Text(self.server.name ?? "Server").lineLimit(1).truncationMode(.tail) - if let serverDescription = self.server.description { - Text(serverDescription) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) - } - Spacer(minLength: 0) - if self.server.users > 0 { - Text(String(self.server.users)) - .foregroundStyle(.secondary) - .lineLimit(1) - - Circle() - .fill(.fileComplete) - .frame(width: 7, height: 7) - .keyframeAnimator(initialValue: 1.0, repeating: true) { content, opacity in - content.opacity(opacity) - } keyframes: { _ in - CubicKeyframe(1.0, duration: 2.0) // Stay visible for 1 second - CubicKeyframe(0.6, duration: 0.5) // Fade out quickly - CubicKeyframe(1.0, duration: 0.5) // Fade in quickly - } - .padding(.trailing, 6) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } -} - -struct TrackerItemView: View { - let bookmark: Bookmark - let isExpanded: Bool - let isLoading: Bool - let count: Int - let onToggleExpanded: () -> Void - @Environment(\.appearsActive) private var appearsActive - - var body: some View { - HStack(alignment: .center, spacing: 6) { - if bookmark.type == .tracker { - Button { - self.onToggleExpanded() - } label: { - Text(Image(systemName: self.isExpanded ? "chevron.down" : "chevron.right")) - .bold() - .font(.system(size: 10)) - .opacity(0.5) - .frame(alignment: .center) - } - .buttonStyle(.plain) - .frame(width: 10) - .padding(.leading, 4) - .padding(.trailing, 2) - } - - switch bookmark.type { - case .tracker: - Image("Tracker") - .resizable() - .scaledToFit() - .frame(width: 16, height: 16, alignment: .center) - Text(bookmark.name).bold().lineLimit(1).truncationMode(.tail) - if isLoading { - ProgressView() - .padding([.leading, .trailing], 2) - .controlSize(.small) - } - Spacer(minLength: 0) - if isExpanded && count > 0 { - HStack(spacing: 4) { - Text(String(count)) - - SpinningGlobeView() - .fontWeight(.semibold) - .frame(width: 12, height: 12) - } - .padding(.horizontal, 6) - .padding(.vertical, 2) - .foregroundStyle(.secondary) -// .background(.quinary) - .clipShape(.capsule) - } - case .server: - Image(systemName: "bookmark.fill") - .resizable() - .scaledToFit() - .foregroundStyle(Color.secondary) - .frame(width: 11, height: 11, alignment: .center) - .opacity(0.75) - .padding(.leading, 3) - .padding(.trailing, 2) - Image("Server") - .resizable() - .scaledToFit() - .frame(width: 16, height: 16, alignment: .center) - Text(bookmark.name).lineLimit(1).truncationMode(.tail) - Spacer(minLength: 0) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } -} - -#if DEBUG -private struct TrackerViewPreview: View { - @State var selection: TrackerSelection? = nil - - var body: some View { - TrackerView(selection: $selection) - } -} - -#Preview { - TrackerViewPreview() -} -#endif diff --git a/Hotline/macOS/Trackers/TrackerBookmarkServerView.swift b/Hotline/macOS/Trackers/TrackerBookmarkServerView.swift new file mode 100644 index 0000000..dc42ea9 --- /dev/null +++ b/Hotline/macOS/Trackers/TrackerBookmarkServerView.swift @@ -0,0 +1,38 @@ +struct TrackerBookmarkServerView: View { + let server: BookmarkServer + + var body: some View { + HStack(alignment: .center, spacing: 6) { + Image("Server") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16, alignment: .center) + Text(self.server.name ?? "Server").lineLimit(1).truncationMode(.tail) + if let serverDescription = self.server.description { + Text(serverDescription) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + Spacer(minLength: 0) + if self.server.users > 0 { + Text(String(self.server.users)) + .foregroundStyle(.secondary) + .lineLimit(1) + + Circle() + .fill(.fileComplete) + .frame(width: 7, height: 7) + .keyframeAnimator(initialValue: 1.0, repeating: true) { content, opacity in + content.opacity(opacity) + } keyframes: { _ in + CubicKeyframe(1.0, duration: 2.0) // Stay visible for 1 second + CubicKeyframe(0.6, duration: 0.5) // Fade out quickly + CubicKeyframe(1.0, duration: 0.5) // Fade in quickly + } + .padding(.trailing, 6) + } + } + .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 new file mode 100644 index 0000000..4943016 --- /dev/null +++ b/Hotline/macOS/Trackers/TrackerBookmarkSheet.swift @@ -0,0 +1,119 @@ +struct TrackerBookmarkSheet: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + + @State private var bookmark: Bookmark? = nil + @State private var trackerAddress: String = "" + @State private var trackerName: String = "" + + init() { + + } + + init(_ editingBookmark: Bookmark) { + _bookmark = .init(initialValue: editingBookmark) + _trackerAddress = .init(initialValue: editingBookmark.displayAddress) + _trackerName = .init(initialValue: editingBookmark.name) + } + + var body: some View { + VStack(alignment: .leading) { + Form { + if self.bookmark == nil { + HStack(alignment: .top, spacing: 10) { + GroupedIconView(color: .blue, systemName: "point.3.filled.connected.trianglepath.dotted", padding: 5.0) + .frame(width: 28, height: 28) + + VStack(alignment: .leading) { + Text("Add a Hotline Tracker") + + Text("Enter the address and name of a Hotline Tracker you want to add.") + .foregroundStyle(.secondary) + .font(.subheadline) + } + } + } + else { + HStack(alignment: .top, spacing: 10) { + GroupedIconView(color: .blue, systemName: "point.3.filled.connected.trianglepath.dotted", padding: 5.0) + .frame(width: 28, height: 28) + + VStack(alignment: .leading) { + Text("Edit Hotline Tracker") + + Text("Change the address and name of your Hotline Tracker.") + .foregroundStyle(.secondary) + .font(.subheadline) + } + } + } + + Group { + TextField(text: $trackerAddress) { + Text("Address") + } + TextField(text: $trackerName, prompt: Text("Optional")) { + Text("Name") + } + } +// .textFieldStyle(.roundedBorder) + .controlSize(.large) + } + .formStyle(.grouped) + } + .frame(width: 350) + .fixedSize(horizontal: true, vertical: true) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button { + self.saveTracker() + } label: { + if self.bookmark != nil { + Text("Save") + } + else { + Text("Add") + } + } + } + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + self.trackerName = "" + self.trackerAddress = "" + + self.dismiss() + } + } + } + } + + private func saveTracker() { + 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 { + if self.bookmark != nil { + // We're editing an existing bookmark. + self.bookmark?.name = displayName + self.bookmark?.address = host + self.bookmark?.port = port + } + else { + // We're creating a new bookmark. + let newBookmark = Bookmark(type: .tracker, name: displayName, address: host, port: port) + Bookmark.add(newBookmark, context: modelContext) + } + + self.trackerName = "" + self.trackerAddress = "" + + self.dismiss() + } + } + } +} \ No newline at end of file diff --git a/Hotline/macOS/Trackers/TrackerItemView.swift b/Hotline/macOS/Trackers/TrackerItemView.swift new file mode 100644 index 0000000..59caad5 --- /dev/null +++ b/Hotline/macOS/Trackers/TrackerItemView.swift @@ -0,0 +1,73 @@ +struct TrackerItemView: View { + let bookmark: Bookmark + let isExpanded: Bool + let isLoading: Bool + let count: Int + let onToggleExpanded: () -> Void + @Environment(\.appearsActive) private var appearsActive + + var body: some View { + HStack(alignment: .center, spacing: 6) { + if bookmark.type == .tracker { + Button { + self.onToggleExpanded() + } label: { + Text(Image(systemName: self.isExpanded ? "chevron.down" : "chevron.right")) + .bold() + .font(.system(size: 10)) + .opacity(0.5) + .frame(alignment: .center) + } + .buttonStyle(.plain) + .frame(width: 10) + .padding(.leading, 4) + .padding(.trailing, 2) + } + + switch bookmark.type { + case .tracker: + Image("Tracker") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16, alignment: .center) + Text(bookmark.name).bold().lineLimit(1).truncationMode(.tail) + if isLoading { + ProgressView() + .padding([.leading, .trailing], 2) + .controlSize(.small) + } + Spacer(minLength: 0) + if isExpanded && count > 0 { + HStack(spacing: 4) { + Text(String(count)) + + SpinningGlobeView() + .fontWeight(.semibold) + .frame(width: 12, height: 12) + } + .padding(.horizontal, 6) + .padding(.vertical, 2) + .foregroundStyle(.secondary) +// .background(.quinary) + .clipShape(.capsule) + } + case .server: + Image(systemName: "bookmark.fill") + .resizable() + .scaledToFit() + .foregroundStyle(Color.secondary) + .frame(width: 11, height: 11, alignment: .center) + .opacity(0.75) + .padding(.leading, 3) + .padding(.trailing, 2) + Image("Server") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16, alignment: .center) + Text(bookmark.name).lineLimit(1).truncationMode(.tail) + Spacer(minLength: 0) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} \ No newline at end of file -- cgit From 6afa5551add4541f376867b3d6527df9a0f793f3 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 7 Nov 2025 16:16:31 -0800 Subject: Further UI tweraks. Transfers button in toolbar. Better empty states in places. Fix race condition with transactions ids (yikes)! --- Hotline.xcodeproj/project.pbxproj | 26 +- Hotline/Hotline/HotlineClientNew.swift | 100 +++-- Hotline/Hotline/HotlineProtocol.swift | 10 +- Hotline/macOS/HotlinePanelView.swift | 12 + Hotline/macOS/News/NewsView.swift | 2 +- Hotline/macOS/Trackers/ServerBookmarkSheet.swift | 70 +++ .../macOS/Trackers/TrackerBookmarkServerView.swift | 4 +- Hotline/macOS/Trackers/TrackerBookmarkSheet.swift | 4 +- Hotline/macOS/Trackers/TrackerItemView.swift | 4 +- Hotline/macOS/Trackers/TrackerView.swift | 480 +++++++++++++++++++++ 10 files changed, 653 insertions(+), 59 deletions(-) create mode 100644 Hotline/macOS/Trackers/ServerBookmarkSheet.swift create mode 100644 Hotline/macOS/Trackers/TrackerView.swift 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 = ""; }; DA4F2C002B1A558E00D8ADDC /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = ""; }; DA501BE12EBE9018001714F8 /* GroupedIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupedIconView.swift; sourceTree = ""; }; + DA501BE32EBE9517001714F8 /* ServerBookmarkSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerBookmarkSheet.swift; sourceTree = ""; }; + DA501BE62EBE9542001714F8 /* TrackerBookmarkSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackerBookmarkSheet.swift; sourceTree = ""; }; + DA501BE82EBE9589001714F8 /* TrackerItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackerItemView.swift; sourceTree = ""; }; + DA501BEA2EBE95B4001714F8 /* TrackerBookmarkServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackerBookmarkServerView.swift; sourceTree = ""; }; DA52689B2EB0738B00DCB941 /* GeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsView.swift; sourceTree = ""; }; DA52689D2EB073A400DCB941 /* IconSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconSettingsView.swift; sourceTree = ""; }; DA52689F2EB073BC00DCB941 /* SoundSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundSettingsView.swift; sourceTree = ""; }; @@ -291,6 +299,18 @@ path = Views; sourceTree = ""; }; + DA501BE52EBE9520001714F8 /* Trackers */ = { + isa = PBXGroup; + children = ( + DAE734FA2B2E41F9000C56F6 /* TrackerView.swift */, + DA501BE82EBE9589001714F8 /* TrackerItemView.swift */, + DA501BEA2EBE95B4001714F8 /* TrackerBookmarkServerView.swift */, + DA501BE62EBE9542001714F8 /* TrackerBookmarkSheet.swift */, + DA501BE32EBE9517001714F8 /* ServerBookmarkSheet.swift */, + ); + path = Trackers; + sourceTree = ""; + }; 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 = [] + @State private var trackerServers: [Bookmark: [BookmarkServer]] = [:] + @State private var loadingTrackers: Set = [] + @State private var fetchTasks: [Bookmark: Task] = [:] + @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 -- cgit From 637cd9af54a58db0c5dc2062b6c20291afd31147 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 7 Nov 2025 17:40:59 -0800 Subject: Add Kingfisher dependency. Add support for animated GIF banners. Add fast image format detection to Data. --- Hotline.xcodeproj/project.pbxproj | 17 ++++++ .../xcshareddata/swiftpm/Package.resolved | 11 +++- .../Transfers/HotlineFilePreviewClientNew.swift | 9 +-- Hotline/Library/Extensions.swift | 36 ++++++++++++ Hotline/State/HotlineState.swift | 14 ++++- Hotline/macOS/HotlinePanelView.swift | 64 +++++++++++++++++++--- 6 files changed, 130 insertions(+), 21 deletions(-) diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index 2861aab..cac321c 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -37,6 +37,7 @@ 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 */; }; + DA501BEE2EBEC42E001714F8 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = DA501BED2EBEC42E001714F8 /* Kingfisher */; }; 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, ); }; @@ -227,6 +228,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + DA501BEE2EBEC42E001714F8 /* Kingfisher in Frameworks */, DACCE5EF2EAC19C9008CDD92 /* SwiftUIIntrospect in Frameworks */, DA72A0E02B4DA8CA00A0F48A /* SplitView in Frameworks */, DAB4D8872B4CB3610048A05C /* MarkdownUI in Frameworks */, @@ -551,6 +553,7 @@ DAB4D8862B4CB3610048A05C /* MarkdownUI */, DA72A0DF2B4DA8CA00A0F48A /* SplitView */, DACCE5EE2EAC19C9008CDD92 /* SwiftUIIntrospect */, + DA501BED2EBEC42E001714F8 /* Kingfisher */, ); productName = Hotline; productReference = DA9CAFB72B126D5700CDA197 /* Hotline.app */; @@ -584,6 +587,7 @@ DAB4D8852B4CB3610048A05C /* XCRemoteSwiftPackageReference "swift-markdown-ui" */, DA72A0DE2B4DA8CA00A0F48A /* XCRemoteSwiftPackageReference "SplitView" */, DACCE5ED2EAC19C9008CDD92 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */, + DA501BEC2EBEC42E001714F8 /* XCRemoteSwiftPackageReference "Kingfisher" */, ); productRefGroup = DA9CAFB82B126D5700CDA197 /* Products */; projectDirPath = ""; @@ -934,6 +938,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + DA501BEC2EBEC42E001714F8 /* XCRemoteSwiftPackageReference "Kingfisher" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/onevcat/Kingfisher"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 8.6.1; + }; + }; DA72A0DE2B4DA8CA00A0F48A /* XCRemoteSwiftPackageReference "SplitView" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/stevengharris/SplitView"; @@ -961,6 +973,11 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + DA501BED2EBEC42E001714F8 /* Kingfisher */ = { + isa = XCSwiftPackageProductDependency; + package = DA501BEC2EBEC42E001714F8 /* XCRemoteSwiftPackageReference "Kingfisher" */; + productName = Kingfisher; + }; DA72A0DF2B4DA8CA00A0F48A /* SplitView */ = { isa = XCSwiftPackageProductDependency; package = DA72A0DE2B4DA8CA00A0F48A /* XCRemoteSwiftPackageReference "SplitView" */; diff --git a/Hotline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Hotline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index f112b32..3084548 100644 --- a/Hotline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Hotline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "26db0d070cfc8e002044ea1380ec5e675c0718ebf212f6e8a57f12cc6c295723", + "originHash" : "590f8ee2c1318c670012f5d30a51419d3387a50648e0dd04f9654da2b1b7cef5", "pins" : [ + { + "identity" : "kingfisher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/onevcat/Kingfisher", + "state" : { + "revision" : "4d75de347da985a70c63af4d799ed482021f6733", + "version" : "8.6.1" + } + }, { "identity" : "networkimage", "kind" : "remoteSourceControl", diff --git a/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift b/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift index 63fe099..93a68fc 100644 --- a/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift @@ -31,14 +31,7 @@ public class HotlineFilePreviewClientNew { self.transferSize = size } - deinit { - // Cleanup in deinit - must be synchronous - if let tempURL = temporaryFileURL { - try? FileManager.default.removeItem(at: tempURL) - } - } - - // MARK: - Public API + // MARK: - /// Download file to temporary location for preview /// - Parameter progressHandler: Optional progress callback diff --git a/Hotline/Library/Extensions.swift b/Hotline/Library/Extensions.swift index 469676c..b6a4b68 100644 --- a/Hotline/Library/Extensions.swift +++ b/Hotline/Library/Extensions.swift @@ -22,6 +22,42 @@ extension Data { } return false } + + enum ImageFormat { + case gif + case jpeg + case png + case webp + case unknown + } + + var detectedImageFormat: ImageFormat { + guard self.count >= 12 else { return .unknown } + + let bytes = [UInt8](self.prefix(12)) + + // GIF: "GIF8" + if bytes[0] == 0x47 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x38 { + return .gif + } + + // JPEG: FF D8 FF + if bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF { + return .jpeg + } + + // PNG: 89 50 4E 47 0D 0A 1A 0A + if bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47 && bytes[4] == 0x0D && bytes[5] == 0x0A && bytes[6] == 0x1A && bytes[7] == 0x0A { + return .png + } + + // WebP: "RIFF" at 0-3 and "WEBP" at 8-11 + if bytes[0] == 0x52 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x46 && bytes[8] == 0x57 && bytes[9] == 0x45 && bytes[10] == 0x42 && bytes[11] == 0x50 { + return .webp + } + + return .unknown + } } // MARK: - diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index d5ab438..4b74df7 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -206,6 +206,8 @@ class HotlineState: Equatable { var accountsLoaded: Bool = false // Banner + var bannerFileURL: URL? = nil + var bannerImageFormat: Data.ImageFormat = .unknown #if os(macOS) var bannerImage: Image? = nil var bannerColors: ColorArt? = nil @@ -471,8 +473,10 @@ class HotlineState: Equatable { self.bannerDownloadTask?.cancel() self.bannerDownloadTask = nil self.bannerImage = nil + self.bannerImageFormat = .unknown + self.bannerFileURL = nil self.bannerColors = nil - } else if self.bannerDownloadTask != nil || self.bannerImage != nil { + } else if self.bannerDownloadTask != nil || self.bannerFileURL != nil { return } @@ -503,13 +507,16 @@ class HotlineState: Equatable { ) let fileURL = try await previewClient.preview() - defer { - previewClient.cleanup() + + if let oldFileURL = self.bannerFileURL { + try? FileManager.default.removeItem(at: oldFileURL) } guard self.client != nil else { return } let data = try Data(contentsOf: fileURL) + self.bannerImageFormat = data.detectedImageFormat + print("HotlineState: Banner download complete, data size: \(data.count) bytes") #if os(macOS) @@ -525,6 +532,7 @@ class HotlineState: Equatable { } self.bannerImage = Image(uiImage: image) #endif + self.bannerFileURL = fileURL self.bannerImage = blah self.bannerColors = ColorArt.analyze(image: image) diff --git a/Hotline/macOS/HotlinePanelView.swift b/Hotline/macOS/HotlinePanelView.swift index 81c24b7..220ea97 100644 --- a/Hotline/macOS/HotlinePanelView.swift +++ b/Hotline/macOS/HotlinePanelView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Kingfisher struct HotlinePanelView: View { @Environment(\.openWindow) var openWindow @@ -20,19 +21,25 @@ struct HotlinePanelView: View { private var backgroundColor: Color { Color(nsColor: self.activeHotline?.bannerColors?.backgroundColor ?? NSColor.controlBackgroundColor) } + + private var bannerFileURL: URL? { + self.activeHotline?.bannerFileURL + } + + private var bannerIsAnimated: Bool { + self.activeHotline?.bannerImageFormat == .gif + } var body: some View { VStack(spacing: 0) { - self.bannerImage - .interpolation(.high) - .resizable() - .scaledToFill() - .frame(width: 468, height: 60) - .frame(minWidth: 468, maxWidth: 468, minHeight: 60, maxHeight: 60) - .clipped() - .background(.black) - .animation(.default, value: self.bannerImage) + self.bannerView + .id("banner image view") + .animation(.default, value: self.bannerFileURL) + .background { + Color.black + } + HStack(spacing: 12) { Button { if NSEvent.modifierFlags.contains(.option) { @@ -170,6 +177,45 @@ struct HotlinePanelView: View { // .cornerRadius(10.0) // ) } + + private var bannerView: some View { + ZStack { + if self.bannerIsAnimated { + KFAnimatedImage + .url(self.bannerFileURL) + .placeholder { + Image("Default Banner") + } + .cacheMemoryOnly() + .cacheOriginalImage() + .scaledToFill() + .frame(width: 468, height: 60) + .frame(minWidth: 468, maxWidth: 468, minHeight: 60, maxHeight: 60) + .clipped() + .transition(.opacity) + .id("animated banner \(self.bannerFileURL?.absoluteString ?? "")") + } + else { + KFImage + .url(self.bannerFileURL) + .resizable() + .interpolation(.high) + .placeholder { + Image("Default Banner") + } + .cacheMemoryOnly() + .cacheOriginalImage() + .scaledToFill() + .frame(width: 468, height: 60) + .frame(minWidth: 468, maxWidth: 468, minHeight: 60, maxHeight: 60) + .clipped() + .transition(.opacity) + .id("static banner \(self.bannerFileURL?.absoluteString ?? "")") + } + } + .animation(.default, value: self.bannerIsAnimated) + .animation(.default, value: self.bannerFileURL) + } } #Preview { -- cgit From 2f332ee497af925db1a2583135e9dfcdaff84794 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 7 Nov 2025 21:03:53 -0800 Subject: Add Bonjour discovery for Hotline servers on the local network to the Servers window. --- Hotline.xcodeproj/project.pbxproj | 8 + Hotline/Models/Server.swift | 9 + Hotline/State/AppState.swift | 2 + Hotline/State/BonjourState.swift | 256 ++++++++++++++++++++++++++ Hotline/macOS/Trackers/BonjourServerRow.swift | 19 ++ Hotline/macOS/Trackers/TrackerItemView.swift | 2 +- Hotline/macOS/Trackers/TrackerView.swift | 114 +++++++++++- 7 files changed, 406 insertions(+), 4 deletions(-) create mode 100644 Hotline/State/BonjourState.swift create mode 100644 Hotline/macOS/Trackers/BonjourServerRow.swift diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index cac321c..f94cba2 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -38,6 +38,8 @@ DA501BE92EBE9589001714F8 /* TrackerItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA501BE82EBE9589001714F8 /* TrackerItemView.swift */; }; DA501BEB2EBE95B4001714F8 /* TrackerBookmarkServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA501BEA2EBE95B4001714F8 /* TrackerBookmarkServerView.swift */; }; DA501BEE2EBEC42E001714F8 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = DA501BED2EBEC42E001714F8 /* Kingfisher */; }; + DA501BF02EBED848001714F8 /* BonjourState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA501BEF2EBED848001714F8 /* BonjourState.swift */; }; + DA501BF22EBEF415001714F8 /* BonjourServerRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA501BF12EBEF415001714F8 /* BonjourServerRow.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, ); }; @@ -148,6 +150,8 @@ DA501BE62EBE9542001714F8 /* TrackerBookmarkSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackerBookmarkSheet.swift; sourceTree = ""; }; DA501BE82EBE9589001714F8 /* TrackerItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackerItemView.swift; sourceTree = ""; }; DA501BEA2EBE95B4001714F8 /* TrackerBookmarkServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackerBookmarkServerView.swift; sourceTree = ""; }; + DA501BEF2EBED848001714F8 /* BonjourState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BonjourState.swift; sourceTree = ""; }; + DA501BF12EBEF415001714F8 /* BonjourServerRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BonjourServerRow.swift; sourceTree = ""; }; DA52689B2EB0738B00DCB941 /* GeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsView.swift; sourceTree = ""; }; DA52689D2EB073A400DCB941 /* IconSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconSettingsView.swift; sourceTree = ""; }; DA52689F2EB073BC00DCB941 /* SoundSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundSettingsView.swift; sourceTree = ""; }; @@ -259,6 +263,7 @@ DACCE5E02EABE4B4008CDD92 /* AppUpdate.swift */, DA3429B42EBA8A450010784E /* FilePreviewState.swift */, DA5268B02EB2708E00DCB941 /* HotlineState.swift */, + DA501BEF2EBED848001714F8 /* BonjourState.swift */, ); path = State; sourceTree = ""; @@ -304,6 +309,7 @@ DA501BE52EBE9520001714F8 /* Trackers */ = { isa = PBXGroup; children = ( + DA501BF12EBEF415001714F8 /* BonjourServerRow.swift */, DAE734FA2B2E41F9000C56F6 /* TrackerView.swift */, DA501BE82EBE9589001714F8 /* TrackerItemView.swift */, DA501BEA2EBE95B4001714F8 /* TrackerBookmarkServerView.swift */, @@ -693,6 +699,7 @@ DA5753682B33E88A00FAC277 /* HotlineTransferClient.swift in Sources */, DAB4D8822B4C8FED0048A05C /* FileIconView.swift in Sources */, DA65499A2BEC280E00EDB697 /* ServerMessageView.swift in Sources */, + DA501BF02EBED848001714F8 /* BonjourState.swift in Sources */, DAAEE66F2B47625600A5BA07 /* FilePreviewImageView.swift in Sources */, DA872B152BDDEE1A008B1012 /* VisualEffectView.swift in Sources */, DA3429AE2EB9C0280010784E /* HotlineFileUploadClientNew.swift in Sources */, @@ -707,6 +714,7 @@ DAC87F072C5010E80060FADF /* HotlineExtensions.swift in Sources */, DAE734FD2B2E65E9000C56F6 /* MessageBoardView.swift in Sources */, DA6980832BFFD06C003E434B /* BookmarkDocument.swift in Sources */, + DA501BF22EBEF415001714F8 /* BonjourServerRow.swift in Sources */, DA501BE72EBE9542001714F8 /* TrackerBookmarkSheet.swift in Sources */, DAB4D87E2B4C8BCA0048A05C /* FilePreviewTextView.swift in Sources */, DA3429B72EBAB1750010784E /* QuickLookPreviewView.swift in Sources */, diff --git a/Hotline/Models/Server.swift b/Hotline/Models/Server.swift index e0fe38b..7d38752 100644 --- a/Hotline/Models/Server.swift +++ b/Hotline/Models/Server.swift @@ -10,6 +10,15 @@ struct Server: Codable { var login: String var password: String + var displayAddress: String { + if self.port == HotlinePorts.DefaultServerPort { + return self.address + } + else { + return "\(self.address):\(String(self.port))" + } + } + init(name: String?, description: String?, address: String, port: Int = HotlinePorts.DefaultServerPort, users: Int = 0, login: String? = nil, password: String? = nil) { self.name = name self.description = description diff --git a/Hotline/State/AppState.swift b/Hotline/State/AppState.swift index 558af2a..3aadf08 100644 --- a/Hotline/State/AppState.swift +++ b/Hotline/State/AppState.swift @@ -11,6 +11,8 @@ final class AppState { private init() { } + + var bonjourState = BonjourState() var activeHotline: HotlineState? = nil var activeServerState: ServerState? = nil diff --git a/Hotline/State/BonjourState.swift b/Hotline/State/BonjourState.swift new file mode 100644 index 0000000..aa7cf44 --- /dev/null +++ b/Hotline/State/BonjourState.swift @@ -0,0 +1,256 @@ +import Foundation +import Network + +@Observable +class BonjourState { + var isExpanded: Bool = false + var isBrowsing: Bool = false + var discoveredServers: [BonjourServer] = [] + + private var browser: NWBrowser? + private var resolutionTasks: [UUID: Task] = [:] + + private actor ConnectionResolverState { + var completed = false + func markComplete() { + self.completed = true + } + } + + struct BonjourServer: Identifiable, Hashable { + let id = UUID() + let serviceName: String + let name: String + let address: String? + let port: UInt16? + let txtRecords: [String: String] + + var displayName: String { + // Use the advertised name, fall back to service name + self.name.isEmpty ? self.serviceName : self.name + } + + var server: Server? { + guard let address = self.address, + let port = self.port else { + return nil + } + return Server(name: self.displayName, description: nil, address: address, port: Int(port)) + } + + var isLoopback: Bool { + guard let address = self.address else { return false } + return address.hasPrefix("127.") || address.hasPrefix("::1") + } + + static func == (lhs: BonjourServer, rhs: BonjourServer) -> Bool { + lhs.address == rhs.address && lhs.port == rhs.port + } + + func hash(into hasher: inout Hasher) { + hasher.combine(self.id) + } + } + + func startBrowsing() { + guard !self.isBrowsing else { + return + } + + self.isBrowsing = true + self.discoveredServers.removeAll() + + let parameters = NWParameters() + parameters.includePeerToPeer = true + + self.browser = NWBrowser(for: .bonjourWithTXTRecord(type: "_hotline._tcp", domain: nil), using: parameters) + + self.browser?.stateUpdateHandler = { [weak self] newState in + Task { @MainActor in + switch newState { + case .ready: + print("BonjourState: Browser ready") + case .failed(let error): + print("BonjourState: Browser failed: \(error)") + self?.stopBrowsing() + case .cancelled: + print("BonjourState: Browser cancelled") + self?.isBrowsing = false + default: + break + } + } + } + + self.browser?.browseResultsChangedHandler = { [weak self] results, changes in + guard let self = self else { + return + } + + Task { @MainActor in + print("BonjourState: Browse results changed, found \(results.count) services") + + // Handle removed services + for change in changes { + if case .removed(let result) = change { + if case .service(let name, _, _, _) = result.endpoint { + self.discoveredServers.removeAll { $0.serviceName == name } + print("BonjourState: Removed service: \(name)") + } + } + } + + // Handle added/updated services + for change in changes { + if case .added(let result) = change, case .service = result.endpoint { + await self.resolveService(result) + } else if case .changed(_, let new, _) = change, case .service = new.endpoint { + await self.resolveService(new) + } + } + } + } + + self.browser?.start(queue: .main) + } + + private func cleanAddress(_ addressString: String) -> String { + // For link-local IPv6 (fe80::), keep zone ID as it's required + if addressString.hasPrefix("fe80:") { + return addressString + } + + // For everything else, strip zone identifier + return addressString.components(separatedBy: "%").first ?? addressString + } + + private func resolveService(_ result: NWBrowser.Result) async { + guard case .service(let name, _, _, _) = result.endpoint else { + return + } + + // Create a connection to resolve the service + let connection = NWConnection(to: result.endpoint, using: .tcp) + let resolver = ConnectionResolverState() + + await withCheckedContinuation { (continuation: CheckedContinuation) in + connection.stateUpdateHandler = { [weak self] state in + Task { @MainActor in + if Task.isCancelled { + await resolver.markComplete() + return + } + + if await resolver.completed { + return + } + + switch state { + case .ready: + await resolver.markComplete() + + // Extract address and port + var address: String? + var port: UInt16? + + guard let path = connection.currentPath, let endpoint = path.remoteEndpoint else { + return + } + + var isLoopback: Bool = false + if path.usesInterfaceType(.loopback) { + isLoopback = true + } + + if case .hostPort(let host, let nwPort) = endpoint { + switch host { + case .ipv4(let ipv4): + address = self?.cleanAddress(ipv4.debugDescription) + case .ipv6(let ipv6): + address = self?.cleanAddress(ipv6.debugDescription) + case .name(let hostname, _): + address = hostname + @unknown default: + break + } + + if isLoopback { + address = "127.0.0.1" + } + port = nwPort.rawValue + } + + // Parse TXT records + var txtRecords: [String: String] = [:] + if case .bonjour(let txtRecord) = result.metadata { + for (key, value) in txtRecord.dictionary { + txtRecords[key] = value + } + } + + let server = BonjourServer( + serviceName: name, + name: name, + address: address, + port: port, + txtRecords: txtRecords + ) + + // Update or add server + if let index = self?.discoveredServers.firstIndex(where: { $0.serviceName == + name }) { + self?.discoveredServers[index] = server + } else { + self?.discoveredServers.append(server) + } + + connection.cancel() + continuation.resume() + + case .failed(let error): + await resolver.markComplete() + + print("BonjourState: Failed to resolve \(name): \(error)") + connection.cancel() + continuation.resume() + + default: + break + } + } + } + + connection.start(queue: .main) + + // Timeout after 5 seconds + Task { + try? await Task.sleep(nanoseconds: 5_000_000_000) + + if await resolver.completed == false { + await resolver.markComplete() + connection.cancel() + continuation.resume() + } + } + } + } + + func stopBrowsing() { + guard self.isBrowsing else { + return + } + + print("BonjourState: Stopping Bonjour browsing") + + // Cancel all resolution tasks + for (_, task) in self.resolutionTasks { + task.cancel() + } + self.resolutionTasks.removeAll() + + self.browser?.cancel() + self.browser = nil + self.isBrowsing = false + self.discoveredServers.removeAll() + } +} diff --git a/Hotline/macOS/Trackers/BonjourServerRow.swift b/Hotline/macOS/Trackers/BonjourServerRow.swift new file mode 100644 index 0000000..d99b23b --- /dev/null +++ b/Hotline/macOS/Trackers/BonjourServerRow.swift @@ -0,0 +1,19 @@ +import SwiftUI + +struct BonjourServerRow: View { + @Environment(\.openWindow) private var openWindow + + let server: BonjourState.BonjourServer + + var body: some View { + HStack(alignment: .center, spacing: 6) { + Image("Server") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16, alignment: .center) + Text(self.server.displayName).lineLimit(1).truncationMode(.tail) + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/Hotline/macOS/Trackers/TrackerItemView.swift b/Hotline/macOS/Trackers/TrackerItemView.swift index 6e62518..37093d2 100644 --- a/Hotline/macOS/Trackers/TrackerItemView.swift +++ b/Hotline/macOS/Trackers/TrackerItemView.swift @@ -36,7 +36,7 @@ struct TrackerItemView: View { if isLoading { ProgressView() .padding([.leading, .trailing], 2) - .controlSize(.small) + .controlSize(.mini) } Spacer(minLength: 0) if isExpanded && count > 0 { diff --git a/Hotline/macOS/Trackers/TrackerView.swift b/Hotline/macOS/Trackers/TrackerView.swift index ddc0a67..0eb4a12 100644 --- a/Hotline/macOS/Trackers/TrackerView.swift +++ b/Hotline/macOS/Trackers/TrackerView.swift @@ -6,11 +6,15 @@ import UniformTypeIdentifiers enum TrackerSelection: Hashable { case bookmark(Bookmark) case bookmarkServer(BookmarkServer) + case bonjourGroup + case bonjourServer(BonjourState.BonjourServer) var server: Server? { switch self { - case .bookmark(let b): return b.server - case .bookmarkServer(let t): return t.server + case .bookmark(let b): b.server + case .bookmarkServer(let t): t.server + case .bonjourGroup: nil + case .bonjourServer(let b): b.server } } } @@ -85,6 +89,62 @@ struct TrackerView: View { } } } + + var bonjourRowView: some View { + HStack(alignment: .center, spacing: 6) { + Button { + AppState.shared.bonjourState.isExpanded.toggle() + } label: { + Text(Image(systemName: AppState.shared.bonjourState.isExpanded ? "chevron.down" : "chevron.right")) + .bold() + .font(.system(size: 10)) + .opacity(0.5) + .frame(alignment: .center) + } + .buttonStyle(.plain) + .frame(width: 10) + .padding(.leading, 4) + .padding(.trailing, 2) + + Image(systemName: "bonjour") + .resizable() + .scaledToFit() + .symbolRenderingMode(.multicolor) + .frame(width: 16, height: 16, alignment: .center) + Text("Bonjour").bold().lineLimit(1).truncationMode(.tail) + + if AppState.shared.bonjourState.isBrowsing { + ProgressView() + .controlSize(.mini) + } + + Spacer(minLength: 0) + + if AppState.shared.bonjourState.isExpanded && !AppState.shared.bonjourState.discoveredServers.isEmpty { + HStack(spacing: 4) { + Text(String(AppState.shared.bonjourState.discoveredServers.count)) + + SpinningGlobeView() + .fontWeight(.semibold) + .frame(width: 12, height: 12) + } + .padding(.horizontal, 6) + .padding(.vertical, 2) + .foregroundStyle(.secondary) +// .background(.quinary) + .clipShape(.capsule) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onChange(of: AppState.shared.bonjourState.isExpanded) { oldState, newState in + if newState { + AppState.shared.bonjourState.startBrowsing() + } + else { + AppState.shared.bonjourState.stopBrowsing() + } + } + } var body: some View { List(selection: $selection) { @@ -115,6 +175,17 @@ struct TrackerView: View { .onDelete { deletedIndexes in Bookmark.delete(at: deletedIndexes, context: modelContext) } + + self.bonjourRowView + .tag(TrackerSelection.bonjourGroup) + + if AppState.shared.bonjourState.isExpanded { + ForEach(AppState.shared.bonjourState.discoveredServers, id: \.self) { record in + BonjourServerRow(server: record) + .tag(TrackerSelection.bonjourServer(record)) + .padding(.leading, 16 + 8 + 10) + } + } } .onDeleteCommand { switch self.selection { @@ -155,6 +226,10 @@ struct TrackerView: View { self.bookmarkContextMenu(bookmark) case .bookmarkServer(let server): self.bookmarkServerContextMenu(server) + case .bonjourGroup: + EmptyView() + case .bonjourServer(let bonjourServer): + self.bonjourServerContextMenu(bonjourServer) } } } primaryAction: { items in @@ -180,6 +255,15 @@ struct TrackerView: View { case .bookmarkServer(let bookmarkServer): openWindow(id: "server", value: bookmarkServer.server) + + case .bonjourGroup: + AppState.shared.bonjourState.isExpanded.toggle() + + case .bonjourServer(let bonjourServer): + if let server = bonjourServer.server { + openWindow(id: "server", value: server) + } + } } .fileExporter(isPresented: $bookmarkExportActive, document: bookmarkExport, contentTypes: [.data], defaultFilename: "\(bookmarkExport?.bookmark.name ?? "Hotline Bookmark").hlbm", onCompletion: { result in @@ -380,8 +464,32 @@ struct TrackerView: View { Label(bookmark.type == .tracker ? "Delete Tracker" : "Delete Bookmark", systemImage: "trash") } } - + @ViewBuilder + func bonjourServerContextMenu(_ bonjourServer: BonjourState.BonjourServer) -> some View { + Button { + guard let server = bonjourServer.server else { + return + } + 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 { + guard let server = bonjourServer.server else { + return + } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(server.displayAddress, forType: .string) + } label: { + Label("Copy Address", systemImage: "doc.on.doc") + } + } + func refresh() { // When a tracker is selected, refresh only that tracker. if let trackerSelection = self.selection { -- cgit From 286c408370681b022deaabd254d499aefec28add Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Sat, 8 Nov 2025 12:33:00 -0800 Subject: Some work on making it possible to connect to servers with an IPv6 address. Improve connect form a bit. Add Copy Link to context menu for servers and trackers. --- Hotline.xcodeproj/project.pbxproj | 2 +- Hotline/Library/NetSocket/NetSocket.swift | 19 +++- Hotline/Models/Server.swift | 50 +++++++++- Hotline/State/BonjourState.swift | 22 +++-- Hotline/macOS/ServerView.swift | 151 +++++++++++++++--------------- Hotline/macOS/Trackers/TrackerView.swift | 50 ++++++++-- 6 files changed, 195 insertions(+), 99 deletions(-) diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index f94cba2..3e600a4 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -309,12 +309,12 @@ DA501BE52EBE9520001714F8 /* Trackers */ = { isa = PBXGroup; children = ( - DA501BF12EBEF415001714F8 /* BonjourServerRow.swift */, DAE734FA2B2E41F9000C56F6 /* TrackerView.swift */, DA501BE82EBE9589001714F8 /* TrackerItemView.swift */, DA501BEA2EBE95B4001714F8 /* TrackerBookmarkServerView.swift */, DA501BE62EBE9542001714F8 /* TrackerBookmarkSheet.swift */, DA501BE32EBE9517001714F8 /* ServerBookmarkSheet.swift */, + DA501BF12EBEF415001714F8 /* BonjourServerRow.swift */, ); path = Trackers; sourceTree = ""; diff --git a/Hotline/Library/NetSocket/NetSocket.swift b/Hotline/Library/NetSocket/NetSocket.swift index 5c7d185..e66ef3f 100644 --- a/Hotline/Library/NetSocket/NetSocket.swift +++ b/Hotline/Library/NetSocket/NetSocket.swift @@ -173,7 +173,24 @@ public actor NetSocket { guard let nwPort = NWEndpoint.Port(rawValue: port) else { throw NetSocketError.invalidPort } - return try await self.connect(host: .name(host, nil), port: nwPort, tls: tls, config: config) + + // Parse the host string to create the appropriate NWEndpoint.Host + let nwHost: NWEndpoint.Host + + // Try parsing as IPv6 without zone + if let ipv6Addr = IPv6Address(host) { + nwHost = .ipv6(ipv6Addr) + } + // Try parsing as IPv4 + else if let ipv4Addr = IPv4Address(host) { + nwHost = .ipv4(ipv4Addr) + } + // Fall back to treating as hostname + else { + nwHost = .name(host, nil) + } + + return try await self.connect(host: nwHost, port: nwPort, tls: tls, config: config) } // MARK: Close diff --git a/Hotline/Models/Server.swift b/Hotline/Models/Server.swift index 7d38752..3ad3375 100644 --- a/Hotline/Models/Server.swift +++ b/Hotline/Models/Server.swift @@ -15,7 +15,12 @@ struct Server: Codable { return self.address } else { - return "\(self.address):\(String(self.port))" + // Wrap IPv6 addresses in brackets when displaying with port + if self.address.contains(":") { + return "[\(self.address)]:\(String(self.port))" + } else { + return "\(self.address):\(String(self.port))" + } } } @@ -50,10 +55,49 @@ struct Server: Codable { } static func parseServerAddressAndPort(_ address: String) -> (String, Int) { - let url = URL(string: "hotline://\(address)") + let trimmed = address.trimmingCharacters(in: .whitespacesAndNewlines) + + // Check if this looks like an IPv6 address (contains colons but no port delimiter) + // IPv6 addresses can be: + // - fe80::1234 + // - [fe80::1234]:5500 (with port) + // - 2001:db8::1 + // - [2001:db8::1]:6500 (with port) + + // If it starts with [, it's bracketed IPv6 with optional port + if trimmed.hasPrefix("[") { + // Find the closing bracket + if let closeBracketIndex = trimmed.firstIndex(of: "]") { + let hostEndIndex = trimmed.index(after: closeBracketIndex) + let host = String(trimmed[trimmed.index(after: trimmed.startIndex).. 1 { + // This is likely an IPv6 address without a port + // Keep it as-is, including any zone identifier (e.g., %en1 for link-local) + return (trimmed.lowercased(), HotlinePorts.DefaultServerPort) + } + + // Otherwise use URL parsing for IPv4 or hostnames + let url = URL(string: "hotline://\(trimmed)") let port = url?.port ?? HotlinePorts.DefaultServerPort let host = url?.host(percentEncoded: false) ?? "" - return (host.lowercased().trimmingCharacters(in: .whitespacesAndNewlines), port) + return (host.lowercased(), port) } } diff --git a/Hotline/State/BonjourState.swift b/Hotline/State/BonjourState.swift index aa7cf44..b0bb42c 100644 --- a/Hotline/State/BonjourState.swift +++ b/Hotline/State/BonjourState.swift @@ -115,12 +115,13 @@ class BonjourState { } private func cleanAddress(_ addressString: String) -> String { - // For link-local IPv6 (fe80::), keep zone ID as it's required + // For link-local IPv6 addresses (fe80::), we MUST keep the zone identifier + // because it tells the system which network interface to use for routing + // For all other addresses (global IPv6, IPv4), strip the zone identifier if addressString.hasPrefix("fe80:") { return addressString } - - // For everything else, strip zone identifier + return addressString.components(separatedBy: "%").first ?? addressString } @@ -128,7 +129,7 @@ class BonjourState { guard case .service(let name, _, _, _) = result.endpoint else { return } - + // Create a connection to resolve the service let connection = NWConnection(to: result.endpoint, using: .tcp) let resolver = ConnectionResolverState() @@ -148,32 +149,33 @@ class BonjourState { switch state { case .ready: await resolver.markComplete() - + // Extract address and port var address: String? var port: UInt16? - + guard let path = connection.currentPath, let endpoint = path.remoteEndpoint else { return } - + var isLoopback: Bool = false if path.usesInterfaceType(.loopback) { isLoopback = true } - + if case .hostPort(let host, let nwPort) = endpoint { switch host { case .ipv4(let ipv4): address = self?.cleanAddress(ipv4.debugDescription) case .ipv6(let ipv6): - address = self?.cleanAddress(ipv6.debugDescription) + let ipv6String = ipv6.debugDescription + address = self?.cleanAddress(ipv6String) case .name(let hostname, _): address = hostname @unknown default: break } - + if isLoopback { address = "127.0.0.1" } diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index a66a071..d2c503f 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -116,8 +116,13 @@ struct ServerView: View { var body: some View { Group { if model.status == .disconnected { - connectForm - .navigationTitle("Connect to Server") + VStack(alignment: .center) { + Spacer() + self.connectForm + Spacer() + } +// .frame(maxWidth: .infinity, maxHeight: .infinity) + .navigationTitle("Connect to Server") } else if case .failed(let error) = model.status { VStack { @@ -245,79 +250,80 @@ struct ServerView: View { } var connectForm: some View { - VStack(alignment: .center) { - GroupBox { - Form { - Group { - TextField(text: $connectAddress) { - Text("Address:") - } - .focused($focusedField, equals: .address) - - 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")) { - Text("Login:") - } - .focused($focusedField, equals: .login) - SecureField(text: $connectPassword, prompt: Text("Optional")) { - Text("Password:") - } - .focused($focusedField, equals: .password) - } - .textFieldStyle(.roundedBorder) - .controlSize(.large) - - HStack { - Button("Save...") { - if !connectAddress.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - connectNameSheetPresented = true - } - } - .disabled(connectAddress.isEmpty) - .controlSize(.regular) - .buttonStyle(.automatic) - .help("Bookmark server") - - Spacer() - - Button("Cancel") { - dismiss() - } - .controlSize(.regular) - .buttonStyle(.automatic) - .keyboardShortcut(.cancelAction) - - Button("Connect") { - connectToServer() - } - - .controlSize(.regular) - .buttonStyle(.automatic) - .keyboardShortcut(.defaultAction) - } - .padding(.top, 8) - + Form { + HStack(alignment: .top, spacing: 10) { + Image("Server Large") + .resizable() + .scaledToFit() + .frame(width: 28, height: 28) + + VStack(alignment: .leading) { + Text("Connect to Server") + Text("Enter the address of a Hotline server to connect to.") + .foregroundStyle(.secondary) + .font(.subheadline) } - .padding() - .onChange(of: connectAddress) { - let (a, p) = Server.parseServerAddressAndPort(connectAddress) - server.address = a - server.port = p + } + + TextField(text: $connectAddress) { + Text("Address:") + } + .focused($focusedField, equals: .address) + + TextField(text: $connectLogin, prompt: Text("Optional")) { + Text("Login:") + } + .focused($focusedField, equals: .login) + SecureField(text: $connectPassword, prompt: Text("Optional")) { + Text("Password:") + } + .focused($focusedField, equals: .password) + + HStack { + Button("Save...") { + if !connectAddress.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + connectNameSheetPresented = true + } } - .onChange(of: connectLogin) { - server.login = connectLogin.trimmingCharacters(in: .whitespacesAndNewlines) + .disabled(connectAddress.isEmpty) + .controlSize(.regular) + .buttonStyle(.automatic) + .help("Bookmark server") + + Spacer() + + Button("Cancel") { + dismiss() } - .onChange(of: connectPassword) { - server.password = connectPassword + .controlSize(.regular) + .buttonStyle(.automatic) + .keyboardShortcut(.cancelAction) + + Button("Connect") { + connectToServer() } + + .controlSize(.regular) + .buttonStyle(.automatic) + .keyboardShortcut(.defaultAction) } - .onAppear { - focusedField = .address - } + .padding(.top, 8) + } + .formStyle(.grouped) + .fixedSize(horizontal: false, vertical: true) + .onChange(of: connectAddress) { + let (a, p) = Server.parseServerAddressAndPort(connectAddress) + server.address = a + server.port = p + } + .onChange(of: connectLogin) { + server.login = connectLogin.trimmingCharacters(in: .whitespacesAndNewlines) + } + .onChange(of: connectPassword) { + server.password = connectPassword + } + .onAppear { + focusedField = .address } .frame(maxWidth: 380) .padding() @@ -346,8 +352,7 @@ struct ServerView: View { 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 @@ -356,8 +361,6 @@ struct ServerView: View { let newBookmark = Bookmark(type: .server, name: name, address: host, port: port, login: login, password: password) Bookmark.add(newBookmark, context: modelContext) } - -// } } } } diff --git a/Hotline/macOS/Trackers/TrackerView.swift b/Hotline/macOS/Trackers/TrackerView.swift index 0eb4a12..dbbacaa 100644 --- a/Hotline/macOS/Trackers/TrackerView.swift +++ b/Hotline/macOS/Trackers/TrackerView.swift @@ -405,14 +405,13 @@ struct TrackerView: View { @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) + NSPasteboard.general.clearContents() + let displayAddress = (server.port == HotlinePorts.DefaultServerPort) ? server.address : "\(server.address):\(server.port)" + NSPasteboard.general.setString("hotline://\(displayAddress)", forType: .string) } label: { - Label("Bookmark", systemImage: "bookmark") + Label("Copy Link", systemImage: "link") } - Divider() - Button { NSPasteboard.general.clearContents() let displayAddress = (server.port == HotlinePorts.DefaultServerPort) ? server.address : "\(server.address):\(server.port)" @@ -420,10 +419,30 @@ struct TrackerView: View { } label: { Label("Copy Address", systemImage: "doc.on.doc") } + + Divider() + + 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") + } } @ViewBuilder func bookmarkContextMenu(_ bookmark: Bookmark) -> some View { + Button { + let linkString: String = switch bookmark.type { + case .tracker: "hotlinetracker://\(bookmark.displayAddress)" + case .server: "hotline://\(bookmark.displayAddress)" + } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(linkString, forType: .string) + } label: { + Label("Copy Link", systemImage: "link") + } + Button { NSPasteboard.general.clearContents() NSPasteboard.general.setString(bookmark.displayAddress, forType: .string) @@ -471,14 +490,13 @@ struct TrackerView: View { guard let server = bonjourServer.server else { return } - 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) + NSPasteboard.general.clearContents() + let displayAddress = (server.port == HotlinePorts.DefaultServerPort) ? server.address : "\(server.address):\(server.port)" + NSPasteboard.general.setString("hotline://\(displayAddress)", forType: .string) } label: { - Label("Bookmark", systemImage: "bookmark") + Label("Copy Link", systemImage: "link") } - Divider() - Button { guard let server = bonjourServer.server else { return @@ -488,6 +506,18 @@ struct TrackerView: View { } label: { Label("Copy Address", systemImage: "doc.on.doc") } + + Divider() + + Button { + guard let server = bonjourServer.server else { + return + } + 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") + } } func refresh() { -- cgit From 23dfc75e31a8666eaba14de9f87bf82ac95c05d9 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Sat, 8 Nov 2025 15:32:46 -0800 Subject: Thinking through how to store chat history in SwiftData instead. --- Hotline/MacApp.swift | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/Hotline/MacApp.swift b/Hotline/MacApp.swift index b1cc1af..9052c0b 100644 --- a/Hotline/MacApp.swift +++ b/Hotline/MacApp.swift @@ -71,10 +71,24 @@ struct Application: App { private var modelContainer: ModelContainer = { let schema = Schema([ - Bookmark.self + Bookmark.self, + // ChatMessage.self ]) - let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false, cloudKitDatabase: .private("iCloud.co.goodmake.hotline")) - let modelContainer = try! ModelContainer(for: schema, configurations: [config]) + + // For records we want shared across devices. + let cloudKitConfiguration = ModelConfiguration( + schema: Schema([Bookmark.self]), + isStoredInMemoryOnly: false, + cloudKitDatabase: .private("iCloud.co.goodmake.hotline") + ) + + // For records we only need stored locally. +// let localConfiguration = ModelConfiguration( +// schema: Schema([ChatMessage.self]), +// isStoredInMemoryOnly: false +// ) + + let modelContainer = try! ModelContainer(for: schema, configurations: [cloudKitConfiguration]) // Print local SwiftData sqlite file. // print(modelContainer.configurations.first?.url.path(percentEncoded: false)) -- cgit From 2112b181aa654fc9d05770d09aa0cd098cd76bc9 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Sat, 8 Nov 2025 15:37:25 -0800 Subject: Change default bookmark to MacDomain. --- Hotline/Models/Bookmark.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Hotline/Models/Bookmark.swift b/Hotline/Models/Bookmark.swift index 69217f3..f3b3822 100644 --- a/Hotline/Models/Bookmark.swift +++ b/Hotline/Models/Bookmark.swift @@ -76,7 +76,7 @@ final class Bookmark { static let DefaultBookmarks: [Bookmark] = [ Bookmark(type: .server, name: "The Mobius Strip", address: "mobius.trtphotl.com", port: HotlinePorts.DefaultServerPort), - Bookmark(type: .server, name: "System 7 Today", address: "hotline.system7today.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) ] -- cgit From 206e4bbdaba85062a38b32c16a9937ab42cddb47 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Sat, 8 Nov 2025 17:21:54 -0800 Subject: Improve sound effect management and use NSSound on macOS. --- Hotline/Library/SoundEffects.swift | 51 ++++++++++++++++---------------------- Hotline/State/HotlineState.swift | 18 +++++++------- 2 files changed, 30 insertions(+), 39 deletions(-) diff --git a/Hotline/Library/SoundEffects.swift b/Hotline/Library/SoundEffects.swift index 93f8b9a..4964b94 100644 --- a/Hotline/Library/SoundEffects.swift +++ b/Hotline/Library/SoundEffects.swift @@ -1,7 +1,7 @@ import Foundation -import AVFAudio +import AppKit -enum SoundEffects: String { +enum SoundEffect: String { case loggedIn = "logged-in" case chatMessage = "chat-message" case transferComplete = "transfer-complete" @@ -10,41 +10,32 @@ enum SoundEffects: String { case newNews = "new-news" case serverMessage = "server-message" case error = "error" + + static var all: [SoundEffect] = [.loggedIn, .chatMessage, .transferComplete, .userLogin, .userLogout, .newNews, .serverMessage, .error] } @Observable -class SoundEffectPlayer: NSObject, AVAudioPlayerDelegate { - static let shared = SoundEffectPlayer() +class SoundEffects { + static let shared = SoundEffects() - private var activeSounds: [AVAudioPlayer] = [] + private var preloadedSounds: [SoundEffect: NSSound] = [:] - func playSoundEffect(_ name: SoundEffects) { - // Load a local sound file - guard let soundFileURL = Bundle.main.url( - forResource: name.rawValue, - withExtension: "aiff" - ) else { - return - } - - DispatchQueue.main.async { [weak self] in - guard let self = self else { - return - } - - if let soundEffect = try? AVAudioPlayer(contentsOf: soundFileURL) { - soundEffect.delegate = self - soundEffect.volume = 0.75 - soundEffect.play() - - self.activeSounds.append(soundEffect) + private init() { + // Preload sound effects + for effect in SoundEffect.all { + if let soundFileURL = Bundle.main.url(forResource: effect.rawValue, withExtension: "aiff"), + let sound = NSSound(contentsOf: soundFileURL, byReference: true) { + sound.volume = 0.75 + self.preloadedSounds[effect] = sound } } } - - func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { - if let i = self.activeSounds.firstIndex(of: player) { - self.activeSounds.remove(at: i) - } + + static func play(_ name: SoundEffect) { + Self.shared.play(name) + } + + func play(_ name: SoundEffect) { + self.preloadedSounds[name]?.play() } } diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index 4b74df7..4e28af8 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -334,7 +334,7 @@ class HotlineState: Equatable { print("HotlineState.login(): Status set to loggedIn") if Prefs.shared.playSounds && Prefs.shared.playLoggedInSound { - SoundEffectPlayer.shared.playSoundEffect(.loggedIn) + SoundEffects.play(.loggedIn) } print("HotlineState.login(): Connected to \(self.serverTitle)") @@ -639,7 +639,7 @@ class HotlineState: Equatable { try await client.sendInstantMessage(text, to: userID) if Prefs.shared.playPrivateMessageSound { - SoundEffectPlayer.shared.playSoundEffect(.chatMessage) + SoundEffects.play(.chatMessage) } } @@ -1776,7 +1776,7 @@ class HotlineState: Equatable { private func handleChatMessage(_ text: String) { if Prefs.shared.playSounds && Prefs.shared.playChatSound { - SoundEffectPlayer.shared.playSoundEffect(.chatMessage) + SoundEffects.play(.chatMessage) } let chatMessage = ChatMessage(text: text, type: .message, date: Date()) @@ -1798,14 +1798,14 @@ class HotlineState: Equatable { } if Prefs.shared.playSounds && Prefs.shared.playLeaveSound { - SoundEffectPlayer.shared.playSoundEffect(.userLogout) + SoundEffects.play(.userLogout) } } } private func handleServerMessage(_ message: String) { if Prefs.shared.playSounds && Prefs.shared.playChatSound { - SoundEffectPlayer.shared.playSoundEffect(.serverMessage) + SoundEffects.play(.serverMessage) } print("HotlineState: received server message:\n\(message)") @@ -1820,9 +1820,9 @@ class HotlineState: Equatable { if Prefs.shared.playPrivateMessageSound { if self.unreadInstantMessages[userID] == nil { - SoundEffectPlayer.shared.playSoundEffect(.serverMessage) + SoundEffects.play(.serverMessage) } else { - SoundEffectPlayer.shared.playSoundEffect(.chatMessage) + SoundEffects.play(.chatMessage) } } @@ -1854,7 +1854,7 @@ class HotlineState: Equatable { self.messageBoard.insert(message, at: 0) } - SoundEffectPlayer.shared.playSoundEffect(.newNews) + SoundEffects.play(.newNews) } // MARK: - User Management @@ -1868,7 +1868,7 @@ class HotlineState: Equatable { } else { if !self.users.isEmpty { if Prefs.shared.playSounds && Prefs.shared.playJoinSound { - SoundEffectPlayer.shared.playSoundEffect(.userLogin) + SoundEffects.play(.userLogin) } } -- cgit From 2caf86e633c1a121c8e23d068ac817ed8e80f6a7 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Sat, 8 Nov 2025 19:51:48 -0800 Subject: Moved SoundEffects to Managers. Updated connect form in ServerView. Changed some icons in context menus for bookmarks. --- Hotline.xcodeproj/project.pbxproj | 2 +- Hotline/Library/SoundEffects.swift | 41 ------------------ Hotline/Managers/SoundEffects.swift | 40 +++++++++++++++++ Hotline/macOS/ServerView.swift | 73 +++++++++++++++++--------------- Hotline/macOS/Trackers/TrackerView.swift | 7 ++- 5 files changed, 85 insertions(+), 78 deletions(-) delete mode 100644 Hotline/Library/SoundEffects.swift create mode 100644 Hotline/Managers/SoundEffects.swift diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index 3e600a4..f91bcf8 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -441,7 +441,6 @@ children = ( DAB4D8832B4CABEF0048A05C /* Extensions.swift */, DA5268AA2EB11EA300DCB941 /* ColorArt.swift */, - DAE735062B3251B3000C56F6 /* SoundEffects.swift */, DA55AC782BE6A1AD00034857 /* RegularExpressions.swift */, DA6980822BFFD06C003E434B /* BookmarkDocument.swift */, DA501BE02EBE844F001714F8 /* Views */, @@ -494,6 +493,7 @@ DAC6B2DF2EAC6236004E2CBA /* Managers */ = { isa = PBXGroup; children = ( + DAE735062B3251B3000C56F6 /* SoundEffects.swift */, DAC6B2DE2EAC6236004E2CBA /* ChatStore.swift */, ); path = Managers; diff --git a/Hotline/Library/SoundEffects.swift b/Hotline/Library/SoundEffects.swift deleted file mode 100644 index 4964b94..0000000 --- a/Hotline/Library/SoundEffects.swift +++ /dev/null @@ -1,41 +0,0 @@ -import Foundation -import AppKit - -enum SoundEffect: String { - case loggedIn = "logged-in" - case chatMessage = "chat-message" - case transferComplete = "transfer-complete" - case userLogin = "user-login" - case userLogout = "user-logout" - case newNews = "new-news" - case serverMessage = "server-message" - case error = "error" - - static var all: [SoundEffect] = [.loggedIn, .chatMessage, .transferComplete, .userLogin, .userLogout, .newNews, .serverMessage, .error] -} - -@Observable -class SoundEffects { - static let shared = SoundEffects() - - private var preloadedSounds: [SoundEffect: NSSound] = [:] - - private init() { - // Preload sound effects - for effect in SoundEffect.all { - if let soundFileURL = Bundle.main.url(forResource: effect.rawValue, withExtension: "aiff"), - let sound = NSSound(contentsOf: soundFileURL, byReference: true) { - sound.volume = 0.75 - self.preloadedSounds[effect] = sound - } - } - } - - static func play(_ name: SoundEffect) { - Self.shared.play(name) - } - - func play(_ name: SoundEffect) { - self.preloadedSounds[name]?.play() - } -} diff --git a/Hotline/Managers/SoundEffects.swift b/Hotline/Managers/SoundEffects.swift new file mode 100644 index 0000000..85a1c0e --- /dev/null +++ b/Hotline/Managers/SoundEffects.swift @@ -0,0 +1,40 @@ +import Foundation +import AppKit + +enum SoundEffect: String { + case loggedIn = "logged-in" + case chatMessage = "chat-message" + case transferComplete = "transfer-complete" + case userLogin = "user-login" + case userLogout = "user-logout" + case newNews = "new-news" + case serverMessage = "server-message" + case error = "error" + + static var all: [SoundEffect] = [.loggedIn, .chatMessage, .transferComplete, .userLogin, .userLogout, .newNews, .serverMessage, .error] +} + +class SoundEffects { + static let shared = SoundEffects() + + static func play(_ name: SoundEffect) { + Self.shared.play(name) + } + + private var preloadedSounds: [SoundEffect: NSSound] = [:] + + private init() { + // Preload sound effects + for effect in SoundEffect.all { + if let soundFileURL = Bundle.main.url(forResource: effect.rawValue, withExtension: "aiff"), + let sound = NSSound(contentsOf: soundFileURL, byReference: true) { + sound.volume = 0.75 + self.preloadedSounds[effect] = sound + } + } + } + + func play(_ name: SoundEffect) { + self.preloadedSounds[name]?.play() + } +} diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index d2c503f..44274ea 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -121,8 +121,10 @@ struct ServerView: View { self.connectForm Spacer() } -// .frame(maxWidth: .infinity, maxHeight: .infinity) .navigationTitle("Connect to Server") + .onAppear { + self.focusedField = .address + } } else if case .failed(let error) = model.status { VStack { @@ -250,40 +252,47 @@ struct ServerView: View { } var connectForm: some View { - Form { - HStack(alignment: .top, spacing: 10) { - Image("Server Large") - .resizable() - .scaledToFit() - .frame(width: 28, height: 28) + VStack(alignment: .center, spacing: 0) { + Form { + HStack(alignment: .top, spacing: 10) { + Image("Server Large") + .resizable() + .scaledToFit() + .frame(width: 28, height: 28) + + VStack(alignment: .leading) { + Text("Connect to Server") + Text("Enter the address of a Hotline server to connect to.") + .foregroundStyle(.secondary) + .font(.subheadline) + } + } - VStack(alignment: .leading) { - Text("Connect to Server") - Text("Enter the address of a Hotline server to connect to.") - .foregroundStyle(.secondary) - .font(.subheadline) + TextField(text: $connectAddress) { + Text("Address") } + .focused($focusedField, equals: .address) + + TextField(text: $connectLogin, prompt: Text("Optional")) { + Text("Login") + } + .focused($focusedField, equals: .login) + + SecureField(text: $connectPassword, prompt: Text("Optional")) { + Text("Password") + } + .focused($focusedField, equals: .password) } - - TextField(text: $connectAddress) { - Text("Address:") - } - .focused($focusedField, equals: .address) - - TextField(text: $connectLogin, prompt: Text("Optional")) { - Text("Login:") - } - .focused($focusedField, equals: .login) - SecureField(text: $connectPassword, prompt: Text("Optional")) { - Text("Password:") - } - .focused($focusedField, equals: .password) + .formStyle(.grouped) + .fixedSize(horizontal: false, vertical: true) HStack { - Button("Save...") { + Button { if !connectAddress.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { connectNameSheetPresented = true } + } label: { + Image(systemName: "bookmark.fill") } .disabled(connectAddress.isEmpty) .controlSize(.regular) @@ -302,15 +311,12 @@ struct ServerView: View { Button("Connect") { connectToServer() } - .controlSize(.regular) .buttonStyle(.automatic) .keyboardShortcut(.defaultAction) } - .padding(.top, 8) + .padding(.horizontal, 20) } - .formStyle(.grouped) - .fixedSize(horizontal: false, vertical: true) .onChange(of: connectAddress) { let (a, p) = Server.parseServerAddressAndPort(connectAddress) server.address = a @@ -322,14 +328,11 @@ struct ServerView: View { .onChange(of: connectPassword) { server.password = connectPassword } - .onAppear { - focusedField = .address - } .frame(maxWidth: 380) .padding() .sheet(isPresented: $connectNameSheetPresented) { VStack(alignment: .leading) { - Text("Name this server bookmark:") + Text("Save Bookmark") .foregroundStyle(.secondary) .padding(.bottom, 4) TextField("Bookmark Name", text: $connectName) diff --git a/Hotline/macOS/Trackers/TrackerView.swift b/Hotline/macOS/Trackers/TrackerView.swift index dbbacaa..e0ca87d 100644 --- a/Hotline/macOS/Trackers/TrackerView.swift +++ b/Hotline/macOS/Trackers/TrackerView.swift @@ -480,7 +480,12 @@ struct TrackerView: View { Button { Bookmark.delete(bookmark, context: modelContext) } label: { - Label(bookmark.type == .tracker ? "Delete Tracker" : "Delete Bookmark", systemImage: "trash") + if bookmark.type == .tracker { + Label("Delete Tracker", systemImage: "xmark") + } + else { + Label("Delete Bookmark", systemImage: "bookmark.slash") + } } } -- cgit From 6cfa1db09055a3b4ea64d3586a0b439d13498db6 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Sat, 8 Nov 2025 22:40:58 -0800 Subject: Fixing cancel transfer from transfer window. Transfer window ux improvements. --- Hotline/Hotline/HotlineExtensions.swift | 4 +- Hotline/Hotline/HotlineTransferClient.swift | 26 +-- .../Transfers/HotlineFileDownloadClientNew.swift | 79 ++++--- .../Transfers/HotlineFileUploadClientNew.swift | 8 +- .../Transfers/HotlineFolderDownloadClientNew.swift | 14 +- .../Transfers/HotlineFolderUploadClientNew.swift | 20 +- Hotline/Models/TransferInfo.swift | 5 + Hotline/State/AppState.swift | 29 +++ Hotline/State/HotlineState.swift | 124 +--------- Hotline/macOS/TransfersView.swift | 255 +++++++++++++++------ 10 files changed, 303 insertions(+), 261 deletions(-) diff --git a/Hotline/Hotline/HotlineExtensions.swift b/Hotline/Hotline/HotlineExtensions.swift index 049ebf9..6782a41 100644 --- a/Hotline/Hotline/HotlineExtensions.swift +++ b/Hotline/Hotline/HotlineExtensions.swift @@ -432,11 +432,11 @@ extension FileManager { attributes[.modificationDate] = infoFork.modifiedDate as NSDate guard self.createFile(atPath: url.path, contents: nil, attributes: attributes) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } guard let handle = FileHandle(forWritingAtPath: url.path) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } return handle diff --git a/Hotline/Hotline/HotlineTransferClient.swift b/Hotline/Hotline/HotlineTransferClient.swift index 156b2b0..596155b 100644 --- a/Hotline/Hotline/HotlineTransferClient.swift +++ b/Hotline/Hotline/HotlineTransferClient.swift @@ -2,9 +2,20 @@ import Foundation import Network import UniformTypeIdentifiers -enum HotlineFileClientError: Error { +protocol HotlineTransferClient { +// var serverAddress: NWEndpoint.Host { get } +// var serverPort: NWEndpoint.Port { get } +// var referenceNumber: UInt32 { get } +// var status: HotlineTransferStatus { get set } + +// func start() + func cancel() +} + +enum HotlineTransferClientError: Error { case failedToConnect case failedToTransfer + case cancelled } enum HotlineFileFork { @@ -22,7 +33,7 @@ enum HotlineTransferStatus: Equatable { case progress(Double) case completing case completed - case failed(HotlineFileClientError) + case failed(HotlineTransferClientError) } enum HotlineFileForkType: UInt32 { @@ -39,17 +50,6 @@ public enum HotlineFolderAction: UInt16 { case nextFile = 3 } -protocol HotlineTransferClient { - var serverAddress: NWEndpoint.Host { get } - var serverPort: NWEndpoint.Port { get } - var referenceNumber: UInt32 { get } - var status: HotlineTransferStatus { get set } - - func start() - func cancel() -} - - struct HotlineFileHeader { static let DataSize: Int = 4 + 2 + 16 + 2 diff --git a/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift index 77d2e38..981965a 100644 --- a/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift @@ -6,7 +6,6 @@ public enum HotlineDownloadLocation: Sendable { case downloads(String) // filename } - public enum HotlineTransferProgress: Sendable { case error(Error) // An error occurred case unconnected // Initial state @@ -19,7 +18,7 @@ public enum HotlineTransferProgress: Sendable { /// Modern async/await file download client for Hotline protocol @MainActor -public class HotlineFileDownloadClientNew { +public class HotlineFileDownloadClientNew: @MainActor HotlineTransferClient { // MARK: - Configuration public struct Configuration: Sendable { @@ -61,11 +60,11 @@ public class HotlineFileDownloadClientNew { self.transferProgress = Progress(totalUnitCount: Int64(self.transferTotal)) } - // MARK: - Public API + // MARK: - API public func download( to location: HotlineDownloadLocation, - progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil + progress progressHandler: (@Sendable (HotlineTransferProgress) throws -> Void)? = nil ) async throws -> URL { self.downloadTask?.cancel() @@ -78,49 +77,50 @@ public class HotlineFileDownloadClientNew { let url = try await task.value self.downloadTask = nil return url - } catch { - print("FAILED TO DOWNLOAD!", error) + } + catch { self.downloadTask = nil - progressHandler?(.error(error)) + try? progressHandler?(.error(error)) throw error } } /// Cancel the current download public func cancel() { - downloadTask?.cancel() - downloadTask = nil - - if let socket = socket { - Task { - await socket.close() - } - } + self.downloadTask?.cancel() + self.downloadTask = nil } // MARK: - Private Implementation - private func updateProgress(sent: Int) { + private func updateProgress(sent: Int) throws { self.transferSize = sent self.transferProgress.completedUnitCount = Int64(sent) + try self.checkCancelled() + } + + private func checkCancelled() throws { + if Task.isCancelled { + throw CancellationError() + } // People can cancel a transfer from the file icon in the Finder. // This code handles that. if self.transferProgress.isCancelled { - self.cancel() + throw CancellationError() } } private func performDownload( to destination: HotlineDownloadLocation, - progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? + progressHandler: (@Sendable (HotlineTransferProgress) throws -> Void)? ) async throws -> URL { let fm = FileManager.default var fileHandle: FileHandle? var resourceForkData: Data? - progressHandler?(.preparing) + try progressHandler?(.preparing) // Determine the download name // Determine destination URL based on location @@ -137,12 +137,16 @@ public class HotlineFileDownloadClientNew { destinationFilename = destinationURL.lastPathComponent } - progressHandler?(.connecting) + try self.checkCancelled() + try progressHandler?(.connecting) // Connect to transfer server let socket = try await connectToTransferServer() self.socket = socket - defer {Task { await socket.close() } } + defer { Task { await socket.close() } } + + // See if we've been cancelled + try self.checkCancelled() // Send magic header try await socket.write(Data(endian: .big) { @@ -155,11 +159,11 @@ public class HotlineFileDownloadClientNew { // Read file header let headerData = try await socket.read(HotlineFileHeader.DataSize) guard let header = HotlineFileHeader(from: headerData) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } // Connected - progressHandler?(.connected) + try progressHandler?(.connected) do { // Process each fork @@ -167,7 +171,7 @@ public class HotlineFileDownloadClientNew { // Read fork header let forkHeaderData = try await socket.read(HotlineFileForkHeader.DataSize) guard let forkHeader = HotlineFileForkHeader(from: forkHeaderData) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } // Handle whichever fork is being sent. @@ -175,7 +179,7 @@ public class HotlineFileDownloadClientNew { // Read info fork let infoData = try await socket.read(Int(forkHeader.dataSize)) guard let info = HotlineFileInfoFork(from: infoData) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } self.transferSize += infoData.count @@ -191,35 +195,35 @@ public class HotlineFileDownloadClientNew { self.transferProgress.publish() // Update progress - self.updateProgress(sent: infoData.count) + try self.updateProgress(sent: infoData.count) } else if forkHeader.isDataFork { guard let fh = fileHandle else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } // Stream data fork to disk let updates = await socket.receiveFile(to: fh, length: Int(forkHeader.dataSize)) for try await p in updates { - self.updateProgress(sent: p.sent) - progressHandler?(.transfer(name: destinationFilename, size: self.transferSize, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining)) + try self.updateProgress(sent: p.sent) + try progressHandler?(.transfer(name: destinationFilename, size: self.transferSize, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining)) } } else if forkHeader.isResourceFork { // Read resource fork into memory let rsrcData = try await socket.read(Int(forkHeader.dataSize)) resourceForkData = rsrcData - self.updateProgress(sent: Int(rsrcData.count)) + try self.updateProgress(sent: Int(rsrcData.count)) } else { // Skip unsupported fork let dataSize = Int(forkHeader.dataSize) try await socket.skip(dataSize) - self.updateProgress(sent: dataSize) + try self.updateProgress(sent: dataSize) } - progressHandler?(.transfer(name: destinationFilename, size: self.transferSize, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: nil, estimate: nil)) + try progressHandler?(.transfer(name: destinationFilename, size: self.transferSize, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: nil, estimate: nil)) } self.transferProgress.unpublish() @@ -232,18 +236,22 @@ public class HotlineFileDownloadClientNew { if let rsrcData = resourceForkData, !rsrcData.isEmpty { try writeResourceFork(data: rsrcData, to: destinationURL) } + + // See if we've been cancelled + try self.checkCancelled() - progressHandler?(.completed(url: destinationURL)) + try progressHandler?(.completed(url: destinationURL)) return destinationURL - } catch { + } + catch { // Cleanup on failure try? fileHandle?.close() try? fm.removeItem(at: destinationURL) self.transferProgress.unpublish() - progressHandler?(.error(error)) + try? progressHandler?(.error(error)) throw error } @@ -289,3 +297,4 @@ public class HotlineFileDownloadClientNew { print("HotlineFileDownloadClientNew[\(referenceNumber)]: Wrote resource fork (\(data.count) bytes)") } } + diff --git a/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift index 5acff2a..3bf3339 100644 --- a/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift @@ -2,7 +2,7 @@ import Foundation import Network @MainActor -public class HotlineFileUploadClientNew { +public class HotlineFileUploadClientNew: @MainActor HotlineTransferClient { // MARK: - Configuration public struct Configuration: Sendable { @@ -126,15 +126,15 @@ public class HotlineFileUploadClientNew { // Get file metadata guard let infoFork = HotlineFileInfoFork(file: fileURL) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } guard let header = HotlineFileHeader(file: fileURL) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } guard let forkSizes = try? FileManager.default.getFileForkSizes(fileURL) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } let infoForkData = infoFork.data() diff --git a/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift index 254a1c6..8303d11 100644 --- a/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift @@ -9,7 +9,7 @@ public struct HotlineFolderItemProgress: Sendable { } @MainActor -public class HotlineFolderDownloadClientNew { +public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient { // MARK: - Properties private let serverAddress: String @@ -155,7 +155,7 @@ public class HotlineFolderDownloadClientNew { totalBytesTransferred += 2 + headerLen guard let (itemType, pathComponents) = parseItemHeaderPath(headerData) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } let joinedPath = pathComponents.joined(separator: "/") @@ -313,7 +313,7 @@ public class HotlineFolderDownloadClientNew { // Read file header let headerData = try await socket.read(HotlineFileHeader.DataSize) guard let header = HotlineFileHeader(from: headerData) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } bytesRead += HotlineFileHeader.DataSize @@ -337,7 +337,7 @@ public class HotlineFolderDownloadClientNew { // Read fork header let forkHeaderData = try await socket.read(HotlineFileForkHeader.DataSize) guard let forkHeader = HotlineFileForkHeader(from: forkHeaderData) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } bytesRead += HotlineFileForkHeader.DataSize @@ -356,7 +356,7 @@ public class HotlineFolderDownloadClientNew { self.folderProgress?.completedUnitCount = Int64(totalBytesNow) guard let info = HotlineFileInfoFork(from: infoData) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } // Create parent folders @@ -378,7 +378,7 @@ public class HotlineFolderDownloadClientNew { print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Reading DATA fork (\(forkHeader.dataSize) bytes)") guard let fh = fileHandle else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } fileDataForkSize = Int(forkHeader.dataSize) @@ -439,7 +439,7 @@ public class HotlineFolderDownloadClientNew { fileHandle = nil guard let finalPath = filePath else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } // Write resource fork if present diff --git a/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift index 38532f1..4b98b54 100644 --- a/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift @@ -16,7 +16,7 @@ private struct FolderItem { } @MainActor -public class HotlineFolderUploadClientNew { +public class HotlineFolderUploadClientNew: @MainActor HotlineTransferClient { // MARK: - Configuration public struct Configuration: Sendable { @@ -176,7 +176,7 @@ public class HotlineFolderUploadClientNew { // Wait for server to send .nextFile action let action = try await self.readAction(socket: socket) guard action == .nextFile else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } // Check if we have more items to send @@ -192,7 +192,7 @@ public class HotlineFolderUploadClientNew { case .sendingItemHeader: // Send item header to server guard let item = currentItem else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } // Encode and send item header @@ -212,7 +212,7 @@ public class HotlineFolderUploadClientNew { case .waitingForFileAction: // Wait for server action after file header (.sendFile, .nextFile, .resumeFile) guard currentItem != nil else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } let action = try await self.readAction(socket: socket) @@ -246,7 +246,7 @@ public class HotlineFolderUploadClientNew { case .uploadingFile: // Upload file data guard let item = currentItem else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } // Notify item progress @@ -365,7 +365,7 @@ public class HotlineFolderUploadClientNew { let actionData = try await socket.read(2) guard let rawAction = actionData.readUInt16(at: 0), let action = HotlineFolderAction(rawValue: rawAction) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } return action } @@ -383,15 +383,15 @@ public class HotlineFolderUploadClientNew { // Get file metadata guard let infoFork = HotlineFileInfoFork(file: fileURL) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } guard let header = HotlineFileHeader(file: fileURL) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } guard let forkSizes = try? FileManager.default.getFileForkSizes(fileURL) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } let infoForkData = infoFork.data() @@ -400,7 +400,7 @@ public class HotlineFolderUploadClientNew { // Calculate total flattened file size guard let flattenedSize = FileManager.default.getFlattenedFileSize(fileURL) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } let totalFileSize = Int(flattenedSize) diff --git a/Hotline/Models/TransferInfo.swift b/Hotline/Models/TransferInfo.swift index cb2b0fd..10535bf 100644 --- a/Hotline/Models/TransferInfo.swift +++ b/Hotline/Models/TransferInfo.swift @@ -12,7 +12,12 @@ class TransferInfo: Identifiable, Equatable, Hashable { var timeRemaining: TimeInterval? = nil var completed: Bool = false var failed: Bool = false + var cancelled: Bool = false var isFolder: Bool = false + + var done: Bool { + self.completed || self.failed || self.cancelled + } // Server association - tracks which HotlineState this transfer belongs to var serverID: UUID diff --git a/Hotline/State/AppState.swift b/Hotline/State/AppState.swift index 3aadf08..2d7820b 100644 --- a/Hotline/State/AppState.swift +++ b/Hotline/State/AppState.swift @@ -24,6 +24,8 @@ final class AppState { /// All active transfers across all servers /// Transfers persist even if you disconnect from the server var transfers: [TransferInfo] = [] + + @ObservationIgnored private var transferClients: [UUID: HotlineTransferClient] = [:] /// Track download tasks by reference number for cancellation @ObservationIgnored private var transferTasks: [UUID: Task] = [:] @@ -46,6 +48,11 @@ final class AppState { task.cancel() self.transferTasks.removeValue(forKey: id) } + + if let client = self.transferClients[id] { + client.cancel() + self.transferClients.removeValue(forKey: id) + } // Remove from transfers list self.transfers.remove(at: transferIndex) @@ -58,10 +65,25 @@ final class AppState { task.cancel() } self.transferTasks.removeAll() + + for (_, client) in self.transferClients { + client.cancel() + } + self.transferClients.removeAll() // Clear transfers self.transfers.removeAll() } + + /// Remove all completed transfers + @MainActor + func sweepTransfers() { + for t in self.transfers { + if t.done { + self.cancelTransfer(id: t.id) + } + } + } /// Register a transfer task @MainActor @@ -69,9 +91,16 @@ final class AppState { self.transferTasks[transferID] = task } + @MainActor + func registerTransferTask(_ task: Task, transferID: UUID, client: HotlineTransferClient) { + self.transferTasks[transferID] = task + self.transferClients[transferID] = client + } + /// Unregister a download task (called on completion/failure) @MainActor func unregisterTransferTask(for transferID: UUID) { self.transferTasks.removeValue(forKey: transferID) + self.transferClients.removeValue(forKey: transferID) } } diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index 4e28af8..14fb9aa 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -850,56 +850,7 @@ class HotlineState: Equatable { return success } -// @MainActor -// func downloadFile(_ fileName: String, path: [String], to destination: URL? = nil, progress progressCallback: ((TransferInfo, Double) -> Void)? = nil, complete callback: ((TransferInfo, URL) -> Void)? = nil) { -// guard let client = self.client else { return } -// -// var fullPath: [String] = [] -// if path.count > 1 { -// fullPath = Array(path[0.. Void)? = nil) { -// guard let client = self.client else { return } -// -// var fullPath: [String] = [] -// if path.count > 1 { -// fullPath = Array(path[0..() + var body: some View { VStack(spacing: 0) { - if appState.transfers.isEmpty { - emptyState + if self.appState.transfers.isEmpty { + self.emptyState } else { - transfersList + self.transfersList } } .frame(minWidth: 500, minHeight: 200) .navigationTitle("Transfers") .toolbar { +// ToolbarItem(placement: .primaryAction) { +// Button { +// self.appState.sweepTransfers() +// self.selectedTransfers = [] +// } label: { +// Label("Remove Completed", systemImage: "checklist") +// } +// .disabled(self.appState.transfers.isEmpty) +// } + ToolbarItem(placement: .primaryAction) { Button { - appState.cancelAllTransfers() + for transfer in self.selectedTransfers { + self.appState.cancelTransfer(id: transfer.id) + } + self.selectedTransfers = [] } label: { - Label("Cancel All", systemImage: "xmark.circle") + Label("Cancel Transfer", systemImage: "xmark") } - .disabled(appState.transfers.isEmpty) + .disabled(self.selectedTransfers.isEmpty) } } } @@ -38,96 +53,186 @@ struct TransfersView: View { // MARK: - Transfers List private var transfersList: some View { - List { - ForEach(appState.transfers) { transfer in + List(selection: self.$selectedTransfers) { + ForEach(self.appState.transfers) { transfer in TransferRow(transfer: transfer) + .id(transfer) + } + } + .listStyle(.inset) + .environment(\.defaultMinListRowHeight, 56) + .contextMenu(forSelectionType: TransferInfo.self) { items in + if let item = items.first { + if item.completed, + let fileURL = item.fileURL { + Button("Remove Transfer") { + self.appState.cancelTransfer(id: item.id) + } + + Divider() + + Button("Show in Finder") { + NSWorkspace.shared.activateFileViewerSelecting([fileURL]) + } + + Button("Open") { + NSWorkspace.shared.open(fileURL) + } + + Divider() + + Button("Move to Trash") { + NSWorkspace.shared.recycle([fileURL]) + } + } + else if !item.done { + Button("Cancel Transfer") { + self.appState.cancelTransfer(id: item.id) + } + } + } + } primaryAction: { items in + let fileURLs: [URL] = items.compactMap { $0.fileURL } + if !fileURLs.isEmpty { + NSWorkspace.shared.activateFileViewerSelecting(fileURLs) } } - .listStyle(.inset(alternatesRowBackgrounds: true)) } } // MARK: - Transfer Row struct TransferRow: View { + @Environment(\.appState) private var appState + @Bindable var transfer: TransferInfo + + private var statsView: some View { + HStack(spacing: 8) { + // Progress percentage +// Text("\(Int(self.transfer.progress * 100))%") + + // Speed + if let speed = self.transfer.speed { + Text(self.formatSpeed(speed)) + } + + // Time remaining + if let timeRemaining = self.transfer.timeRemaining { + Text(self.formatTimeRemaining(timeRemaining)) + } + + // File size + Text(self.formatSize(self.transfer.size)) + } + .font(.subheadline) + .foregroundStyle(.secondary) + .monospacedDigit() + } + + private var fileIconView: some View { + FileIconView(filename: self.transfer.title, fileType: nil) + .frame(width: 32, height: 32) + .overlay(alignment: .bottomTrailing) { + if self.transfer.cancelled || self.transfer.failed { + Image(systemName: "exclamationmark.triangle.fill") + .resizable() + .symbolRenderingMode(.multicolor) + .scaledToFit() + .frame(width: 16, height: 16) + } + else if self.transfer.completed { + Image(systemName: "checkmark.circle.fill") + .resizable() + .symbolRenderingMode(.palette) + .foregroundStyle(.white, .fileComplete) + .scaledToFit() + .frame(width: 16, height: 16) + } + } + } var body: some View { - VStack(alignment: .leading, spacing: 8) { - // File name and server - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(transfer.title) - .font(.system(.body, design: .default, weight: .medium)) - - if let serverName = transfer.serverName { - Text(serverName) - .font(.caption) - .foregroundStyle(.secondary) + HStack(alignment: .center, spacing: 8) { + self.fileIconView + + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(self.transfer.title) + .font(.headline) + .lineLimit(1) + .truncationMode(.tail) + + Spacer() + + if !self.transfer.done { + self.statsView } } - - Spacer() - - // Cancel button + + // Progress bar and status + if self.transfer.cancelled { + Text("Cancelled") + .font(.subheadline) + .foregroundStyle(.secondary) + } + else if self.transfer.failed { + Text("Failed") + .font(.subheadline) + .foregroundStyle(.secondary) + } + else if self.transfer.completed { + Text("Complete") + .font(.subheadline) + .foregroundStyle(.fileComplete) + } + else { + ProgressView(value: self.transfer.progress, total: 1.0) + .progressViewStyle(.linear) + .controlSize(.large) + } + } + + if self.transfer.completed { Button { - AppState.shared.cancelTransfer(id: transfer.id) + guard let fileURL = self.transfer.fileURL else { + return + } + + NSWorkspace.shared.activateFileViewerSelecting([fileURL]) } label: { - Image(systemName: "xmark.circle.fill") + Image(systemName: "eye.circle.fill") + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) .foregroundStyle(.secondary) } + .buttonBorderShape(.circle) .buttonStyle(.plain) - .help("Cancel download") } + } + +// VStack(alignment: .leading, spacing: 2) { + - // Progress bar and status - VStack(alignment: .leading, spacing: 4) { - if transfer.failed { - Label("Failed", systemImage: "exclamationmark.triangle.fill") - .font(.caption) - .foregroundStyle(.red) - } else if transfer.completed { - Label("Complete", systemImage: "checkmark.circle.fill") - .font(.caption) - .foregroundStyle(.green) - } else { - // Progress bar - ProgressView(value: transfer.progress, total: 1.0) - .progressViewStyle(.linear) +// if let serverName = self.transfer.serverName { +// Text(serverName) +// .font(.caption) +// .foregroundStyle(.secondary) +// } +// } - // Progress info - HStack(spacing: 8) { - // Progress percentage - Text("\(Int(transfer.progress * 100))%") - .font(.caption) - .foregroundStyle(.secondary) - .monospacedDigit() - - // File size - Text(formatSize(transfer.size)) - .font(.caption) - .foregroundStyle(.secondary) - - // Speed - if let speed = transfer.speed { - Text(formatSpeed(speed)) - .font(.caption) - .foregroundStyle(.secondary) - .monospacedDigit() - } - - // Time remaining - if let timeRemaining = transfer.timeRemaining { - Text(formatTimeRemaining(timeRemaining)) - .font(.caption) - .foregroundStyle(.secondary) - .monospacedDigit() - } - } - } - } - } - .padding(.vertical, 4) +// // Cancel button +// Button { +// self.appState.cancelTransfer(id: transfer.id) +// } label: { +// Image(systemName: "xmark.circle.fill") +// .foregroundStyle(.secondary) +// } +// .buttonStyle(.plain) +// .help("Cancel download") +// } +// } } // MARK: - Formatting -- cgit From 5098adcccc0a60e600bf4aeb01d74537d31ba111 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Sun, 9 Nov 2025 22:26:53 -0800 Subject: Further work on trnasfer context menus with Open With, Move to Trash, etc. and handling of multiple selection. --- Hotline/State/AppState.swift | 8 + Hotline/macOS/TransfersView.swift | 303 ++++++++++++++++++++++++++------------ 2 files changed, 219 insertions(+), 92 deletions(-) diff --git a/Hotline/State/AppState.swift b/Hotline/State/AppState.swift index 2d7820b..e268ba2 100644 --- a/Hotline/State/AppState.swift +++ b/Hotline/State/AppState.swift @@ -57,6 +57,14 @@ final class AppState { // Remove from transfers list self.transfers.remove(at: transferIndex) } + + /// Cancel specified transfers + @MainActor + func cancelTransfers(ids: [UUID]) { + for transferID in ids { + self.cancelTransfer(id: transferID) + } + } /// Cancel all active transfers @MainActor diff --git a/Hotline/macOS/TransfersView.swift b/Hotline/macOS/TransfersView.swift index 6b7a21b..489a50b 100644 --- a/Hotline/macOS/TransfersView.swift +++ b/Hotline/macOS/TransfersView.swift @@ -16,15 +16,24 @@ struct TransfersView: View { .frame(minWidth: 500, minHeight: 200) .navigationTitle("Transfers") .toolbar { -// ToolbarItem(placement: .primaryAction) { -// Button { -// self.appState.sweepTransfers() -// self.selectedTransfers = [] -// } label: { -// Label("Remove Completed", systemImage: "checklist") -// } -// .disabled(self.appState.transfers.isEmpty) -// } + ToolbarItem(placement: .primaryAction) { + Button { + if self.selectedTransfers.isEmpty { + if let downloadsURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first { + NSWorkspace.shared.open(downloadsURL) + } + } + else { + let fileURLs = self.selectedTransfers.compactMap(\.fileURL) + if !fileURLs.isEmpty { + NSWorkspace.shared.activateFileViewerSelecting(fileURLs) + } + } + } label: { + Label("Show Downloads", systemImage: "folder") + } + .help("Show Downloads") + } ToolbarItem(placement: .primaryAction) { Button { @@ -33,15 +42,16 @@ struct TransfersView: View { } self.selectedTransfers = [] } label: { - Label("Cancel Transfer", systemImage: "xmark") + Label(self.selectedTransfers.count == 1 ? "Remove Transfer" : "Remove Transfers", systemImage: "xmark") } .disabled(self.selectedTransfers.isEmpty) + .help(self.selectedTransfers.count == 1 ? "Remove Transfer" : "Remove Transfers") } } } - + // MARK: - Empty State - + private var emptyState: some View { ContentUnavailableView { Label("No Transfers", systemImage: "arrow.up.arrow.down") @@ -49,9 +59,9 @@ struct TransfersView: View { Text("Your Hotline file transfers will appear here") } } - + // MARK: - Transfers List - + private var transfersList: some View { List(selection: self.$selectedTransfers) { ForEach(self.appState.transfers) { transfer in @@ -62,40 +72,184 @@ struct TransfersView: View { .listStyle(.inset) .environment(\.defaultMinListRowHeight, 56) .contextMenu(forSelectionType: TransferInfo.self) { items in - if let item = items.first { - if item.completed, - let fileURL = item.fileURL { - Button("Remove Transfer") { - self.appState.cancelTransfer(id: item.id) + if items.allSatisfy(\.completed) { + let fileURLs: [URL] = items.compactMap(\.fileURL) + + Button("Remove Transfer\(items.count > 1 ? "s" : "")", systemImage: "xmark") { + self.appState.cancelTransfers(ids: items.map(\.id)) + self.selectedTransfers = [] + } + + Divider() + + Button("Open", systemImage: "arrow.up.right.square") { + for fileURL in fileURLs { + NSWorkspace.shared.open(fileURL) } + } + + self.openWithMenu(for: fileURLs) + + Button("Show in Finder", systemImage: "finder") { + NSWorkspace.shared.activateFileViewerSelecting(fileURLs) + } + + Divider() + + Button("Move to Trash", systemImage: "trash") { + self.appState.cancelTransfers(ids: items.map(\.id)) + NSWorkspace.shared.recycle(fileURLs) + self.selectedTransfers = [] + } + } + else { + Button("Remove Transfer\(items.count > 1 ? "s" : "")", systemImage: "xmark") { + self.appState.cancelTransfers(ids: items.map(\.id)) + self.selectedTransfers = [] + } + + Divider() + + Button("Move to Trash", systemImage: "trash") { + self.appState.cancelTransfers(ids: items.map(\.id)) - Divider() - - Button("Show in Finder") { - NSWorkspace.shared.activateFileViewerSelecting([fileURL]) + let fileURLs: [URL] = items.compactMap(\.fileURL) + if !fileURLs.isEmpty { + NSWorkspace.shared.recycle(fileURLs) } - - Button("Open") { - NSWorkspace.shared.open(fileURL) + + self.selectedTransfers = [] + } + } + } primaryAction: { items in + if let fileURL = items.first?.fileURL { + NSWorkspace.shared.open(fileURL) + } + } + } + + private func getOpenWithApps(for fileURLs: [URL], defaultAppURL: URL? = nil) -> [(name: String, url: URL)] { + // If no files provided, there is no common app to open them + guard !fileURLs.isEmpty else { return [] } + + // Build a list of app URL sets for each file URL + let appSets: [Set] = fileURLs.map { url in + let apps = NSWorkspace.shared.urlsForApplications(toOpen: url) + return Set(apps) + } + + // Compute the intersection across all file URL app sets + guard var intersection = appSets.first else { return [] } + for set in appSets.dropFirst() { + intersection.formIntersection(set) + } + + // Optionally remove the default app from the list + if let defaultAppURL { + intersection.remove(defaultAppURL) + } + + // Map to display names and sort by name + let result: [(name: String, url: URL)] = intersection.compactMap { url in + let appName = FileManager.default + .displayName(atPath: url.path) + .replacingOccurrences(of: ".app", with: "") + return (name: appName, url: url) + }.sorted { $0.name < $1.name } + + return result + } + + private func getDefaultApp(for fileURLs: [URL]) -> (name: String, url: URL)? { + // No files -> no default app + guard !fileURLs.isEmpty else { return nil } + + // Single file: use the system default directly + if fileURLs.count == 1, let url = NSWorkspace.shared.urlForApplication(toOpen: fileURLs[0]) { + let name = FileManager.default + .displayName(atPath: url.path) + .replacingOccurrences(of: ".app", with: "") + return (name, url) + } + + // Build the intersection of apps that can open ALL files + let appSets: [Set] = fileURLs.map { url in + Set(NSWorkspace.shared.urlsForApplications(toOpen: url)) + } + guard var intersection = appSets.first else { return nil } + for set in appSets.dropFirst() { + intersection.formIntersection(set) + if intersection.isEmpty { return nil } + } + + // Tally the system default app for each file + var defaultCounts: [URL: Int] = [:] + for fileURL in fileURLs { + if let def = NSWorkspace.shared.urlForApplication(toOpen: fileURL) { + defaultCounts[def, default: 0] += 1 + } + } + + // Prefer the app that's the default for the majority of files, provided it can open all + if let bestByMajority = intersection.max(by: { (a, b) -> Bool in + let ca = defaultCounts[a, default: 0] + let cb = defaultCounts[b, default: 0] + if ca == cb { + // Tie-breaker deferred to later + return false + } + return ca < cb + }), + defaultCounts[bestByMajority, default: 0] > 0 { + let name = FileManager.default + .displayName(atPath: bestByMajority.path) + .replacingOccurrences(of: ".app", with: "") + return (name, bestByMajority) + } + + return nil + } + + private func openWithMenu(for fileURLs: [URL]) -> some View { + Menu("Open With") { + let defaultApp: (name: String, url: URL)? = self.getDefaultApp(for: fileURLs) + let apps: [(name: String, url: URL)] = self.getOpenWithApps(for: fileURLs, defaultAppURL: defaultApp?.url) + + if let defaultApp { + Button { + NSWorkspace.shared.open(fileURLs, withApplicationAt: defaultApp.url, configuration: NSWorkspace.OpenConfiguration()) + } label: { + Label { + Text(defaultApp.name) + } icon: { + Image(nsImage: NSWorkspace.shared.icon(forFile: defaultApp.url.path)) + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) } - + } + + if !apps.isEmpty { Divider() - - Button("Move to Trash") { - NSWorkspace.shared.recycle([fileURL]) - } } - else if !item.done { - Button("Cancel Transfer") { - self.appState.cancelTransfer(id: item.id) + } + + if !apps.isEmpty { + ForEach(apps, id: \.url) { app in + Button { + NSWorkspace.shared.open(fileURLs, withApplicationAt: app.url, configuration: NSWorkspace.OpenConfiguration()) + } label: { + Label { + Text(app.name) + } icon: { + Image(nsImage: NSWorkspace.shared.icon(forFile: app.url.path)) + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + } } } } - } primaryAction: { items in - let fileURLs: [URL] = items.compactMap { $0.fileURL } - if !fileURLs.isEmpty { - NSWorkspace.shared.activateFileViewerSelecting(fileURLs) - } } } } @@ -110,20 +264,24 @@ struct TransferRow: View { private var statsView: some View { HStack(spacing: 8) { // Progress percentage -// Text("\(Int(self.transfer.progress * 100))%") + // Text("\(Int(self.transfer.progress * 100))%") // Speed if let speed = self.transfer.speed { - Text(self.formatSpeed(speed)) + // TODO: Use arrow.up for uploads. + Label(self.formatSpeed(speed), systemImage: "arrow.down") +// Text(self.formatSpeed(speed)) } - + // Time remaining if let timeRemaining = self.transfer.timeRemaining { - Text(self.formatTimeRemaining(timeRemaining)) + Label(self.formatTimeRemaining(timeRemaining), systemImage: "clock") +// Text(self.formatTimeRemaining(timeRemaining)) } // File size - Text(self.formatSize(self.transfer.size)) + Label(self.formatSize(self.transfer.size), systemImage: "document") +// Text(self.formatSize(self.transfer.size)) } .font(.subheadline) .foregroundStyle(.secondary) @@ -151,13 +309,13 @@ struct TransferRow: View { } } } - + var body: some View { HStack(alignment: .center, spacing: 8) { self.fileIconView - VStack(alignment: .leading, spacing: 4) { - HStack(alignment: .firstTextBaseline, spacing: 4) { + VStack(alignment: .leading, spacing: 2) { + HStack(alignment: .firstTextBaseline, spacing: 8) { Text(self.transfer.title) .font(.headline) .lineLimit(1) @@ -182,7 +340,7 @@ struct TransferRow: View { .foregroundStyle(.secondary) } else if self.transfer.completed { - Text("Complete") + Text("Downloaded") .font(.subheadline) .foregroundStyle(.fileComplete) } @@ -192,65 +350,25 @@ struct TransferRow: View { .controlSize(.large) } } - - if self.transfer.completed { - Button { - guard let fileURL = self.transfer.fileURL else { - return - } - - NSWorkspace.shared.activateFileViewerSelecting([fileURL]) - } label: { - Image(systemName: "eye.circle.fill") - .resizable() - .scaledToFit() - .frame(width: 24, height: 24) - .foregroundStyle(.secondary) - } - .buttonBorderShape(.circle) - .buttonStyle(.plain) - } } - -// VStack(alignment: .leading, spacing: 2) { - - -// if let serverName = self.transfer.serverName { -// Text(serverName) -// .font(.caption) -// .foregroundStyle(.secondary) -// } -// } - -// // Cancel button -// Button { -// self.appState.cancelTransfer(id: transfer.id) -// } label: { -// Image(systemName: "xmark.circle.fill") -// .foregroundStyle(.secondary) -// } -// .buttonStyle(.plain) -// .help("Cancel download") -// } -// } } - + // MARK: - Formatting - + private func formatSize(_ bytes: UInt) -> String { let formatter = ByteCountFormatter() formatter.countStyle = .file formatter.allowedUnits = [.useKB, .useMB, .useGB] return formatter.string(fromByteCount: Int64(bytes)) } - + private func formatSpeed(_ bytesPerSecond: Double) -> String { let formatter = ByteCountFormatter() formatter.countStyle = .file formatter.allowedUnits = [.useKB, .useMB, .useGB] return "\(formatter.string(fromByteCount: Int64(bytesPerSecond)))/s" } - + private func formatTimeRemaining(_ seconds: TimeInterval) -> String { if seconds < 60 { return "\(Int(seconds))s" @@ -272,3 +390,4 @@ struct TransferRow: View { TransfersView() .environment(AppState.shared) } + -- cgit From a4263aea6e2875fa77783685985e5c8f7991337f Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Mon, 10 Nov 2025 15:30:16 -0800 Subject: More cleanup and fix for connect view showing right before login UI displays. Move connect form to ConnectView. --- Hotline.xcodeproj/project.pbxproj | 6 +- Hotline/Hotline/HotlineClientNew.swift | 13 +- Hotline/Hotline/HotlineTrackerClient.swift | 3 +- Hotline/Hotline/HotlineTransferClient.swift | 286 --------------------- .../Transfers/HotlineFileDownloadClientNew.swift | 51 +--- .../Transfers/HotlineFilePreviewClientNew.swift | 46 ++-- .../Transfers/HotlineFileUploadClientNew.swift | 96 +++---- .../Transfers/HotlineFolderDownloadClientNew.swift | 21 +- .../Transfers/HotlineFolderUploadClientNew.swift | 18 +- .../Hotline/Transfers/HotlineTransferClient.swift | 286 +++++++++++++++++++++ Hotline/Library/Extensions.swift | 4 + Hotline/Library/NetSocket/NetSocket.swift | 35 +-- Hotline/MacApp.swift | 45 ++-- Hotline/State/FilePreviewState.swift | 11 +- Hotline/State/HotlineState.swift | 22 +- Hotline/State/ServerState.swift | 2 +- Hotline/iOS/TrackerView.swift | 216 ++++++++-------- Hotline/macOS/ConnectView.swift | 147 +++++++++++ Hotline/macOS/Files/FilePreviewQuickLookView.swift | 22 +- Hotline/macOS/HotlinePanelView.swift | 2 +- Hotline/macOS/ServerView.swift | 261 +++++-------------- 21 files changed, 748 insertions(+), 845 deletions(-) delete mode 100644 Hotline/Hotline/HotlineTransferClient.swift create mode 100644 Hotline/Hotline/Transfers/HotlineTransferClient.swift create mode 100644 Hotline/macOS/ConnectView.swift diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index f91bcf8..aa10d51 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -115,6 +115,7 @@ DAE735032B30C0BB000C56F6 /* MessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAE735022B30C0BB000C56F6 /* MessageView.swift */; platformFilters = (macos, ); }; DAE735052B3218D8000C56F6 /* NewsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAE735042B3218D8000C56F6 /* NewsView.swift */; platformFilters = (macos, ); }; DAE735072B3251B3000C56F6 /* SoundEffects.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAE735062B3251B3000C56F6 /* SoundEffects.swift */; }; + DAF5BC6C2EC2727700551E4D /* ConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF5BC6B2EC2727100551E4D /* ConnectView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -225,6 +226,7 @@ DAE735022B30C0BB000C56F6 /* MessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageView.swift; sourceTree = ""; }; DAE735042B3218D8000C56F6 /* NewsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsView.swift; sourceTree = ""; }; DAE735062B3251B3000C56F6 /* SoundEffects.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundEffects.swift; sourceTree = ""; }; + DAF5BC6B2EC2727100551E4D /* ConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -245,6 +247,7 @@ DA3429B12EBA73730010784E /* Transfers */ = { isa = PBXGroup; children = ( + DA5753672B33E88A00FAC277 /* HotlineTransferClient.swift */, DA3429B22EBA7ADE0010784E /* HotlineFilePreviewClientNew.swift */, DA5268B22EB6806E00DCB941 /* HotlineFileDownloadClientNew.swift */, DA3429AD2EB9C0220010784E /* HotlineFileUploadClientNew.swift */, @@ -470,7 +473,6 @@ children = ( DA5268AE2EB2682B00DCB941 /* HotlineClientNew.swift */, DA9CAFCA2B126E3300CDA197 /* HotlineTrackerClient.swift */, - DA5753672B33E88A00FAC277 /* HotlineTransferClient.swift */, DA3429B12EBA73730010784E /* Transfers */, DA4F2BF72B16A17200D8ADDC /* HotlineProtocol.swift */, DAC87F062C5010E80060FADF /* HotlineExtensions.swift */, @@ -526,6 +528,7 @@ DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */, DAE136B92B9D1147007D8307 /* HotlinePanelView.swift */, DAE734F82B2E4185000C56F6 /* ServerView.swift */, + DAF5BC6B2EC2727100551E4D /* ConnectView.swift */, DAE735022B30C0BB000C56F6 /* MessageView.swift */, DA5268B42EB6840A00DCB941 /* TransfersView.swift */, DA501BE52EBE9520001714F8 /* Trackers */, @@ -667,6 +670,7 @@ DA65499E2BEC438A00EDB697 /* NSWindowBridge.swift in Sources */, DAE735032B30C0BB000C56F6 /* MessageView.swift in Sources */, DA32CD4B2B29318E0053B98B /* FileInfo.swift in Sources */, + DAF5BC6C2EC2727700551E4D /* ConnectView.swift in Sources */, DA20BBE12BF5237600B94E7C /* Bookmark.swift in Sources */, DA3429B32EBA7ADF0010784E /* HotlineFilePreviewClientNew.swift in Sources */, DA9CAFCB2B126E3300CDA197 /* HotlineTrackerClient.swift in Sources */, diff --git a/Hotline/Hotline/HotlineClientNew.swift b/Hotline/Hotline/HotlineClientNew.swift index 303bd32..854c7ff 100644 --- a/Hotline/Hotline/HotlineClientNew.swift +++ b/Hotline/Hotline/HotlineClientNew.swift @@ -64,7 +64,7 @@ public enum HotlineClientError: Error { // MARK: - Login Info /// Information needed to log in to a Hotline server -public struct HotlineLoginInfo: Sendable { +public struct HotlineLogin: Sendable { let login: String let password: String let username: String @@ -100,7 +100,7 @@ public struct HotlineServerInfo: Sendable { /// let client = try await HotlineClientNew.connect( /// host: "server.example.com", /// port: 5500, -/// login: HotlineLoginInfo(login: "guest", password: "", username: "John", iconID: 414) +/// login: HotlineLogin(login: "guest", password: "", username: "John", iconID: 414) /// ) /// /// // Listen for events @@ -188,14 +188,13 @@ public actor HotlineClientNew { public static func connect( host: String, port: UInt16 = 5500, - login: HotlineLoginInfo, - tls: TLSPolicy = .disabled + login: HotlineLogin ) async throws -> HotlineClientNew { print("HotlineClientNew.connect(): Starting connection to \(host):\(port) as '\(login.username)'") // Connect socket print("HotlineClientNew.connect(): Connecting socket...") - let socket = try await NetSocket.connect(host: host, port: port, tls: tls) + let socket = try await NetSocket.connect(host: host, port: port) print("HotlineClientNew.connect(): Socket connected") // Perform handshake @@ -264,7 +263,7 @@ public actor HotlineClientNew { // MARK: - Login - private func performLogin(_ login: HotlineLoginInfo) async throws -> HotlineServerInfo { + private func performLogin(_ login: HotlineLogin) async throws -> HotlineServerInfo { var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .login) transaction.setFieldEncodedString(type: .userLogin, val: login.login) transaction.setFieldEncodedString(type: .userPassword, val: login.password) @@ -273,7 +272,7 @@ public actor HotlineClientNew { transaction.setFieldUInt32(type: .versionNumber, val: 123) let reply = try await sendTransaction(transaction) - + guard reply.errorCode == 0 else { let errorText = reply.getField(type: .errorText)?.getString() throw HotlineClientError.loginFailed(errorText) diff --git a/Hotline/Hotline/HotlineTrackerClient.swift b/Hotline/Hotline/HotlineTrackerClient.swift index f72735d..72ff233 100644 --- a/Hotline/Hotline/HotlineTrackerClient.swift +++ b/Hotline/Hotline/HotlineTrackerClient.swift @@ -74,8 +74,7 @@ class HotlineTrackerClient { // Connect to tracker (plaintext, no TLS) let socket = try await NetSocket.connect( host: address, - port: UInt16(port), - tls: .disabled + port: UInt16(port) ) defer { Task { await socket.close() } } diff --git a/Hotline/Hotline/HotlineTransferClient.swift b/Hotline/Hotline/HotlineTransferClient.swift deleted file mode 100644 index 596155b..0000000 --- a/Hotline/Hotline/HotlineTransferClient.swift +++ /dev/null @@ -1,286 +0,0 @@ -import Foundation -import Network -import UniformTypeIdentifiers - -protocol HotlineTransferClient { -// var serverAddress: NWEndpoint.Host { get } -// var serverPort: NWEndpoint.Port { get } -// var referenceNumber: UInt32 { get } -// var status: HotlineTransferStatus { get set } - -// func start() - func cancel() -} - -enum HotlineTransferClientError: Error { - case failedToConnect - case failedToTransfer - case cancelled -} - -enum HotlineFileFork { - case none - case info - case data - case resource - case unsupported -} - -enum HotlineTransferStatus: Equatable { - case unconnected - case connecting - case connected - case progress(Double) - case completing - case completed - case failed(HotlineTransferClientError) -} - -enum HotlineFileForkType: UInt32 { - case none = 0 - case unsupported = 1 - case info = 0x494E464F // 'INFO' - case data = 0x44415441 // 'DATA' - case resource = 1296122706 // 'MACR' -} - -public enum HotlineFolderAction: UInt16 { - case sendFile = 1 - case resumeFile = 2 - case nextFile = 3 -} - -struct HotlineFileHeader { - static let DataSize: Int = 4 + 2 + 16 + 2 - - let format: UInt32 - let version: UInt16 - let forkCount: UInt16 - - init?(from data: Data) { - guard data.count >= HotlineFileHeader.DataSize else { - return nil - } - - self.format = data.readUInt32(at: 0)! - self.version = data.readUInt16(at: 4)! - // 16 bytes of reserved data sits here. Skip it. - self.forkCount = data.readUInt16(at: 4 + 2 + 16)! - } - - init?(file fileURL: URL) { - guard FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false)) else { - return nil - } - - self.format = "FILP".fourCharCode() - self.version = 1 - - let resourceURL = fileURL.urlForResourceFork() - if FileManager.default.fileExists(atPath: resourceURL.path(percentEncoded: false)) { - self.forkCount = 3 - } - else { - self.forkCount = 2 - } - } - - func data() -> Data { - Data(endian: .big) { - self.format - self.version - Data(repeating: 0, count: 16) - self.forkCount - } - } -} - -// MARK: - - -struct HotlineFileForkHeader { - static let DataSize: Int = 4 + 4 + 4 + 4 - - let forkType: UInt32 - let compressionType: UInt32 - let dataSize: UInt32 - - init(type: UInt32, dataSize: UInt32) { - self.forkType = type - self.compressionType = 0 - self.dataSize = dataSize - } - - init?(from data: Data) { - guard data.count >= HotlineFileForkHeader.DataSize else { - return nil - } - - self.forkType = data.readUInt32(at: 0)! - self.compressionType = data.readUInt32(at: 4)! - // 4 bytes of reserved data sits here. Skip it. - // self.reserved = data.readUInt32(at: 4 + 4)! - self.dataSize = data.readUInt32(at: 4 + 4 + 4)! - } - - func data() -> Data { - Data(endian: .big) { - self.forkType - self.compressionType - UInt32.zero - self.dataSize - } - } - - var isInfoFork: Bool { - return self.forkType == HotlineFileForkType.info.rawValue - } - - var isDataFork: Bool { - return self.forkType == HotlineFileForkType.data.rawValue - } - - var isResourceFork: Bool { - return self.forkType == HotlineFileForkType.resource.rawValue - } -} - -// MARK: - - -struct HotlineFileInfoFork { - static let BaseDataSize: Int = 4 + 4 + 4 + 4 + 4 + 32 + 8 + 8 + 2 + 2 - - let platform: UInt32 - let type: UInt32 - let creator: UInt32 - let flags: UInt32 - let platformFlags: UInt32 - let createdDate: Date - let modifiedDate: Date - let nameScript: UInt16 - let name: String - let comment: String? - var headerSize: Int - - init?(file fileURL: URL) { - guard FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false)) else { - return nil - } - - self.platform = "AMAC".fourCharCode() - - if let hfsInfo = try? FileManager.default.getHFSTypeAndCreator(fileURL) { - self.type = hfsInfo.hfsType - self.creator = hfsInfo.hfsCreator - } - else { - self.type = 0 - self.creator = 0 - } - - self.flags = 0 - self.platformFlags = 0 - - let dateInfo = FileManager.default.getCreatedAndModifiedDates(fileURL) - self.createdDate = dateInfo.createdDate - self.modifiedDate = dateInfo.modifiedDate - - self.nameScript = 0 - self.name = fileURL.lastPathComponent - - let fileComment = try? FileManager.default.getFinderComment(fileURL) - self.comment = fileComment ?? "" - - self.headerSize = 0 - } - - init?(from data: Data) { - // Make sure we have at least enough data to read basic header data - guard data.count >= HotlineFileInfoFork.BaseDataSize else { - return nil - } - - if - let platform = data.readUInt32(at: 0), - let type = data.readUInt32(at: 4), - let creator = data.readUInt32(at: 4 + 4), - let flags = data.readUInt32(at: 4 + 4 + 4), - let platformFlags = data.readUInt32(at: 4 + 4 + 4 + 4), - // 32 bytes of reserved data sits here. Skip it. - let nameScript = data.readUInt16(at: 4 + 4 + 4 + 4 + 4 + 32 + 8 + 8) { - - let createdDate = data.readDate(at: 4 + 4 + 4 + 4 + 4 + 32) ?? Date.now - let modifiedDate = data.readDate(at: 4 + 4 + 4 + 4 + 4 + 32 + 8) ?? Date.now - - let (n, nl) = data.readLongPString(at: 4 + 4 + 4 + 4 + 4 + 32 + 8 + 8 + 2) - if let name = n { - self.platform = platform - self.type = type - self.creator = creator - self.flags = flags - self.platformFlags = platformFlags - self.createdDate = createdDate - self.modifiedDate = modifiedDate - self.nameScript = nameScript - self.name = name - - var calculatedHeaderSize: Int = HotlineFileInfoFork.BaseDataSize + nl - var commentRead: String? = nil - if data.count >= HotlineFileInfoFork.BaseDataSize + nl + 2 { - let commentLength = data.readUInt16(at: HotlineFileInfoFork.BaseDataSize + nl)! - var commentCorrupted = false - - // Some servers have incorrect data length for the INFO fork - // the length they send is what it should be but don't include - // the comment length in the actual data, so we end up with mismatched - // lengths. So here we test if the length we read is actually 'DA' - // or the first part of the "DATA" fork header. - // Needless to say, stuff like this makes for sad code but this ain't so bad. - if commentLength == 0x4441 { - commentCorrupted = true - } - - if !commentCorrupted { - let (c, cl) = data.readLongPString(at: HotlineFileInfoFork.BaseDataSize + nl) - calculatedHeaderSize += 2 - if cl > 0 { - calculatedHeaderSize += Int(cl) - if let ct = c, cl > 0 { - commentRead = ct - } - } - } - } - - self.comment = commentRead - self.headerSize = calculatedHeaderSize - return - } - } - - return nil - } - - func data() -> Data { - let fileName = self.name.data(using: .macOSRoman)! - - let data = Data(endian: .big) { - self.platform - self.type - self.creator - self.flags - self.platformFlags - Data(repeating: 0, count: 32) - self.createdDate - self.modifiedDate - self.nameScript - UInt16(fileName.count) - fileName - if let commentData = self.comment?.data(using: .macOSRoman) { - UInt16(commentData.count) - commentData - } - } - - return data - } -} diff --git a/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift index 981965a..82f61d4 100644 --- a/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift @@ -16,18 +16,14 @@ public enum HotlineTransferProgress: Sendable { case completed(url: URL?) // Download or upload complete (local url valid for downloads) } -/// Modern async/await file download client for Hotline protocol -@MainActor -public class HotlineFileDownloadClientNew: @MainActor HotlineTransferClient { - // MARK: - Configuration +@MainActor +public class HotlineFileDownloadClient: @MainActor HotlineTransferClient { public struct Configuration: Sendable { public var chunkSize: Int = 256 * 1024 public init() {} } - // MARK: - Properties - private let serverAddress: String private let serverPort: UInt16 private let referenceNumber: UInt32 @@ -41,8 +37,6 @@ public class HotlineFileDownloadClientNew: @MainActor HotlineTransferClient { private var socket: NetSocket? private var downloadTask: Task? - // MARK: - Initialization - public init( address: String, port: UInt16, @@ -91,7 +85,7 @@ public class HotlineFileDownloadClientNew: @MainActor HotlineTransferClient { self.downloadTask = nil } - // MARK: - Private Implementation + // MARK: - Implementation private func updateProgress(sent: Int) throws { self.transferSize = sent @@ -141,9 +135,12 @@ public class HotlineFileDownloadClientNew: @MainActor HotlineTransferClient { try progressHandler?(.connecting) // Connect to transfer server - let socket = try await connectToTransferServer() - self.socket = socket + let socket = try await NetSocket.connect( + host: self.serverAddress, + port: self.serverPort + 1 + ) defer { Task { await socket.close() } } + self.socket = socket // See if we've been cancelled try self.checkCancelled() @@ -257,35 +254,7 @@ public class HotlineFileDownloadClientNew: @MainActor HotlineTransferClient { } } - // MARK: - Helper Methods - - private func connectToTransferServer() async throws -> NetSocket { - guard let transferPort = NWEndpoint.Port(rawValue: serverPort + 1) else { - throw NetSocketError.invalidPort - } - - print("HotlineFileDownloadClientNew[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)") - - let socket = try await NetSocket.connect( - host: .name(serverAddress, nil), - port: transferPort, - tls: .disabled - ) - - print("HotlineFileDownloadClientNew[\(referenceNumber)]: Connected!") - return socket - } - - private func sendMagicHeader(socket: NetSocket) async throws { - let header = Data(endian: .big) { - "HTXF".fourCharCode() - referenceNumber - UInt32.zero - UInt32.zero - } - - try await socket.write(header) - } + // MARK: - Utility private func writeResourceFork(data: Data, to url: URL) throws { var resolvedURL = url @@ -294,7 +263,7 @@ public class HotlineFileDownloadClientNew: @MainActor HotlineTransferClient { let resourceURL = resolvedURL.urlForResourceFork() try data.write(to: resourceURL) - print("HotlineFileDownloadClientNew[\(referenceNumber)]: Wrote resource fork (\(data.count) bytes)") + print("HotlineFileDownloadClient[\(self.referenceNumber)]: Wrote resource fork (\(data.count) bytes)") } } diff --git a/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift b/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift index 93a68fc..5cf5628 100644 --- a/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift @@ -2,21 +2,17 @@ import Foundation import Network @MainActor -public class HotlineFilePreviewClientNew { - // MARK: - Properties - +public class HotlineFilePreviewClient { private let serverAddress: String private let serverPort: UInt16 private let referenceNumber: UInt32 private let fileName: String private let transferSize: UInt32 - private var downloadClient: HotlineFileDownloadClientNew? + private var downloadClient: HotlineFileDownloadClient? private var previewTask: Task? private var temporaryFileURL: URL? - // MARK: - Initialization - public init( fileName: String, address: String, @@ -31,7 +27,7 @@ public class HotlineFilePreviewClientNew { self.transferSize = size } - // MARK: - + // MARK: - API /// Download file to temporary location for preview /// - Parameter progressHandler: Optional progress callback @@ -51,7 +47,7 @@ public class HotlineFilePreviewClientNew { self.previewTask = nil return url } catch { - print("HotlineFilePreviewClientNew[\(referenceNumber)]: Failed to preview file: \(error)") + print("HotlineFilePreviewClient[\(referenceNumber)]: Failed to preview file: \(error)") self.previewTask = nil progressHandler?(.error(error)) throw error @@ -71,7 +67,7 @@ public class HotlineFilePreviewClientNew { self.cleanupTempFile() } - // MARK: - Private Implementation + // MARK: - Implementation private func performPreview( progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? @@ -83,28 +79,22 @@ public class HotlineFilePreviewClientNew { let tempFileURL = tempDir.appendingPathComponent(uniqueFileName) self.temporaryFileURL = tempFileURL - print("HotlineFilePreviewClientNew[\(referenceNumber)]: Downloading to temp: \(tempFileURL.path)") + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Downloading to temp: \(tempFileURL.path)") progressHandler?(.connecting) // Connect to transfer server - guard let transferPort = NWEndpoint.Port(rawValue: serverPort + 1) else { - throw NetSocketError.invalidPort - } - - print("HotlineFilePreviewClientNew[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)") - + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Connecting to \(self.serverAddress):\(self.serverPort + 1)") let socket = try await NetSocket.connect( - host: .name(serverAddress, nil), - port: transferPort, - tls: .disabled + host: self.serverAddress, + port: self.serverPort + 1 ) defer { Task { await socket.close() } } - print("HotlineFilePreviewClientNew[\(referenceNumber)]: Connected!") + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Connected!") // Send magic header for raw data download - print("HotlineFilePreviewClientNew[\(referenceNumber)]: Sending magic header") + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Sending magic header") try await socket.write(Data(endian: .big) { "HTXF".fourCharCode() self.referenceNumber @@ -115,7 +105,7 @@ public class HotlineFilePreviewClientNew { progressHandler?(.connected) // Stream raw data directly to temp file with progress tracking - print("HotlineFilePreviewClientNew[\(referenceNumber)]: Streaming \(transferSize) bytes to temp file") + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Streaming \(transferSize) bytes to temp file") let totalSize = Int(transferSize) @@ -139,18 +129,18 @@ public class HotlineFilePreviewClientNew { progressHandler?(.completed(url: tempFileURL)) - print("HotlineFilePreviewClientNew[\(referenceNumber)]: Preview file ready at \(tempFileURL.path)") + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Preview file ready at \(tempFileURL.path)") return tempFileURL } private func cleanupTempFile() { - guard let tempURL = temporaryFileURL else { return } - + guard let tempURL = self.temporaryFileURL else { return } + self.temporaryFileURL = nil + // Delete the temp file try? FileManager.default.removeItem(at: tempURL) - - temporaryFileURL = nil - print("HotlineFilePreviewClientNew[\(referenceNumber)]: Cleaned up temp file") + + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Cleaned up temp file") } } diff --git a/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift index 3bf3339..384e9bd 100644 --- a/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift @@ -2,17 +2,12 @@ import Foundation import Network @MainActor -public class HotlineFileUploadClientNew: @MainActor HotlineTransferClient { - // MARK: - Configuration - +public class HotlineFileUploadClient: @MainActor HotlineTransferClient { public struct Configuration: Sendable { public var chunkSize: Int = 256 * 1024 - public init() {} } - // MARK: - Properties - private let serverAddress: String private let serverPort: UInt16 private let referenceNumber: UInt32 @@ -27,8 +22,6 @@ public class HotlineFileUploadClientNew: @MainActor HotlineTransferClient { private var socket: NetSocket? private var uploadTask: Task? - // MARK: - Initialization - public init?( fileURL: URL, address: String, @@ -54,8 +47,6 @@ public class HotlineFileUploadClientNew: @MainActor HotlineTransferClient { self.transferTotal = Int(payloadSize) self.transferSize = 0 self.transferProgress = Progress(totalUnitCount: Int64(self.transferTotal)) - - print("HotlineFileUploadClientNew[\(reference)]: Preparing to upload '\(fileURL.lastPathComponent)' (\(payloadSize) bytes)") } // MARK: - Public API @@ -74,7 +65,7 @@ public class HotlineFileUploadClientNew: @MainActor HotlineTransferClient { try await task.value self.uploadTask = nil } catch { - print("HotlineFileUploadClientNew[\(referenceNumber)]: Failed to upload file: \(error)") + print("HotlineFileUploadClient[\(self.referenceNumber)]: Failed to upload file: \(error)") self.uploadTask = nil progressHandler?(.error(error)) throw error @@ -83,17 +74,17 @@ public class HotlineFileUploadClientNew: @MainActor HotlineTransferClient { /// Cancel the current upload public func cancel() { - uploadTask?.cancel() - uploadTask = nil + self.uploadTask?.cancel() + self.uploadTask = nil - if let socket = socket { + if let socket = self.socket { Task { await socket.close() } } } - // MARK: - Private Implementation + // MARK: - Implementation private func updateProgress(sent: Int, speed: Double? = nil, estimate: TimeInterval? = nil) { self.transferSize = sent @@ -120,34 +111,39 @@ public class HotlineFileUploadClientNew: @MainActor HotlineTransferClient { } // Connect to transfer server - let socket = try await connectToTransferServer() - self.socket = socket + let socket = try await NetSocket.connect( + host: self.serverAddress, + port: self.serverPort + 1 + ) defer { Task { await socket.close() } } + self.socket = socket // Get file metadata - guard let infoFork = HotlineFileInfoFork(file: fileURL) else { + guard let infoFork = HotlineFileInfoFork(file: self.fileURL) else { throw HotlineTransferClientError.failedToTransfer } - guard let header = HotlineFileHeader(file: fileURL) else { + guard let header = HotlineFileHeader(file: self.fileURL) else { throw HotlineTransferClientError.failedToTransfer } - guard let forkSizes = try? FileManager.default.getFileForkSizes(fileURL) else { + guard let forkSizes = try? FileManager.default.getFileForkSizes(self.fileURL) else { throw HotlineTransferClientError.failedToTransfer } let infoForkData = infoFork.data() let dataForkSize = forkSizes.dataForkSize let resourceForkSize = forkSizes.resourceForkSize - - print("HotlineFileUploadClientNew[\(referenceNumber)]: File has dataFork=\(dataForkSize) bytes, resourceFork=\(resourceForkSize) bytes") + + // Configure progress for Finder if enabled + self.transferProgress.fileURL = self.fileURL.resolvingSymlinksInPath() + self.transferProgress.fileOperationKind = .uploading + self.transferProgress.publish() // Connected progressHandler?(.connected) // Send magic header - print("HotlineFileUploadClientNew[\(referenceNumber)]: Sending magic header") try await socket.write(Data(endian: .big) { "HTXF".fourCharCode() self.referenceNumber @@ -157,41 +153,34 @@ public class HotlineFileUploadClientNew: @MainActor HotlineTransferClient { var totalBytesSent = 0 + // MARK: - Info Fork // Send file header - print("HotlineFileUploadClientNew[\(referenceNumber)]: Sending file header") let headerData = header.data() try await socket.write(headerData) totalBytesSent += headerData.count - // Send INFO fork header - print("HotlineFileUploadClientNew[\(referenceNumber)]: Sending INFO fork header") + // Send info fork header let infoForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.info.rawValue, dataSize: UInt32(infoForkData.count)) try await socket.write(infoForkHeader.data()) totalBytesSent += HotlineFileForkHeader.DataSize - // Send INFO fork data - print("HotlineFileUploadClientNew[\(referenceNumber)]: Sending INFO fork (\(infoForkData.count) bytes)") + // Send info fork try await socket.write(infoForkData) totalBytesSent += infoForkData.count self.updateProgress(sent: totalBytesSent) progressHandler?(.transfer(name: filename, size: self.transferSize, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: nil, estimate: nil)) - // Configure progress for Finder if enabled - self.transferProgress.fileURL = fileURL.resolvingSymlinksInPath() - self.transferProgress.fileOperationKind = .uploading - self.transferProgress.publish() - - // Send DATA fork if present + // MARK: - Data Fork + // Send data fork (if present) if dataForkSize > 0 { - print("HotlineFileUploadClientNew[\(referenceNumber)]: Sending DATA fork header") + // Data fork header let dataForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.data.rawValue, dataSize: dataForkSize) try await socket.write(dataForkHeader.data()) totalBytesSent += HotlineFileForkHeader.DataSize - // Stream DATA fork - print("HotlineFileUploadClientNew[\(referenceNumber)]: Streaming DATA fork (\(dataForkSize) bytes)") - let fileHandle = try FileHandle(forReadingFrom: fileURL) + // Stream data fork from disk + let fileHandle = try FileHandle(forReadingFrom: self.fileURL) defer { try? fileHandle.close() } let updates = await socket.writeFile(from: fileHandle, length: Int(dataForkSize)) @@ -204,17 +193,17 @@ public class HotlineFileUploadClientNew: @MainActor HotlineTransferClient { totalBytesSent += Int(dataForkSize) } - // Send RESOURCE fork if present + // MARK: - Resource Fork + // Send resource fork (if present) if resourceForkSize > 0 { - let resourceURL = fileURL.urlForResourceFork() + let resourceURL = self.fileURL.urlForResourceFork() - print("HotlineFileUploadClientNew[\(referenceNumber)]: Sending RESOURCE fork header") + // Resource fork header let resourceForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.resource.rawValue, dataSize: resourceForkSize) try await socket.write(resourceForkHeader.data()) totalBytesSent += HotlineFileForkHeader.DataSize - // Stream RESOURCE fork - print("HotlineFileUploadClientNew[\(referenceNumber)]: Streaming RESOURCE fork (\(resourceForkSize) bytes)") + // Stream resource fork from disk let resourceHandle = try FileHandle(forReadingFrom: resourceURL) defer { try? resourceHandle.close() } @@ -231,25 +220,6 @@ public class HotlineFileUploadClientNew: @MainActor HotlineTransferClient { self.transferProgress.unpublish() progressHandler?(.completed(url: nil)) - print("HotlineFileUploadClientNew[\(referenceNumber)]: Upload complete!") - } - - // MARK: - Helper Methods - - private func connectToTransferServer() async throws -> NetSocket { - guard let transferPort = NWEndpoint.Port(rawValue: serverPort + 1) else { - throw NetSocketError.invalidPort - } - - print("HotlineFileUploadClientNew[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)") - - let socket = try await NetSocket.connect( - host: .name(serverAddress, nil), - port: transferPort, - tls: .disabled - ) - - print("HotlineFileUploadClientNew[\(referenceNumber)]: Connected!") - return socket + print("HotlineFileUploadClient[\(self.referenceNumber)]: Complete!") } } diff --git a/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift index 8303d11..600b9f2 100644 --- a/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift @@ -10,8 +10,6 @@ public struct HotlineFolderItemProgress: Sendable { @MainActor public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient { - // MARK: - Properties - private let serverAddress: String private let serverPort: UInt16 private let referenceNumber: UInt32 @@ -129,14 +127,14 @@ public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient { self.folderProgress = progress // Send initial magic header - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Sending HTXF magic") + print("HotlineFolderDownloadClientNew[\(self.referenceNumber)]: Sending HTXF magic") try await socket.write(Data(endian: .big) { "HTXF".fourCharCode() self.referenceNumber UInt32.zero // data size = 0 UInt16(1) // type = 1 (folder transfer) UInt16.zero // reserved = 0 - HotlineFolderAction.nextFile.rawValue // action = 3 (next file) + HotlineFolderAction.nextFile.rawValue // action = 3 (next file) }) progressHandler?(.connected) @@ -146,7 +144,7 @@ public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient { var totalBytesTransferred = 0 // Process each item in the folder - while completedItemCount < folderItemCount { + while completedItemCount < self.folderItemCount { // Read item header let headerLenData = try await socket.read(2) let headerLen = Int(headerLenData.readUInt16(at: 0)!) @@ -154,7 +152,7 @@ public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient { totalBytesTransferred += 2 + headerLen - guard let (itemType, pathComponents) = parseItemHeaderPath(headerData) else { + guard let (itemType, pathComponents) = self.parseItemHeaderPath(headerData) else { throw HotlineTransferClientError.failedToTransfer } @@ -166,7 +164,7 @@ public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient { if !pathComponents.isEmpty { let folderURL = destinationURL.appendingPathComponents(pathComponents) try fm.createDirectory(at: folderURL, withIntermediateDirectories: true) - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Created folder at \(folderURL.path)") + print("HotlineFolderDownloadClientNew[\(self.referenceNumber)]: Created folder at \(folderURL.path)") } completedItemCount += 1 @@ -246,16 +244,11 @@ public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient { // MARK: - Helper Methods private func connectToTransferServer() async throws -> NetSocket { - guard let transferPort = NWEndpoint.Port(rawValue: serverPort + 1) else { - throw NetSocketError.invalidPort - } - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)") let socket = try await NetSocket.connect( - host: .name(serverAddress, nil), - port: transferPort, - tls: .disabled + host: self.serverAddress, + port: self.serverPort + 1 ) print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Connected!") diff --git a/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift index 4b98b54..15636f3 100644 --- a/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift @@ -147,7 +147,11 @@ public class HotlineFolderUploadClientNew: @MainActor HotlineTransferClient { progressHandler?(.connecting) // Connect to transfer server - let socket = try await self.connect(address: self.serverAddress, port: self.serverPort) + let socket = try await NetSocket.connect( + host: self.serverAddress, + port: self.serverPort + 1 + ) + self.socket = socket defer { Task { await socket.close() } } @@ -281,18 +285,6 @@ public class HotlineFolderUploadClientNew: @MainActor HotlineTransferClient { progressHandler?(.completed(url: nil)) } - private func connect(address: String, port: UInt16) async throws -> NetSocket { - guard let transferPort = NWEndpoint.Port(rawValue: port + 1) else { - throw NetSocketError.invalidPort - } - - return try await NetSocket.connect( - host: .name(address, nil), - port: transferPort, - tls: .disabled - ) - } - private func buildFolderHierarchy() throws { let fm = FileManager.default folderItems = [] diff --git a/Hotline/Hotline/Transfers/HotlineTransferClient.swift b/Hotline/Hotline/Transfers/HotlineTransferClient.swift new file mode 100644 index 0000000..596155b --- /dev/null +++ b/Hotline/Hotline/Transfers/HotlineTransferClient.swift @@ -0,0 +1,286 @@ +import Foundation +import Network +import UniformTypeIdentifiers + +protocol HotlineTransferClient { +// var serverAddress: NWEndpoint.Host { get } +// var serverPort: NWEndpoint.Port { get } +// var referenceNumber: UInt32 { get } +// var status: HotlineTransferStatus { get set } + +// func start() + func cancel() +} + +enum HotlineTransferClientError: Error { + case failedToConnect + case failedToTransfer + case cancelled +} + +enum HotlineFileFork { + case none + case info + case data + case resource + case unsupported +} + +enum HotlineTransferStatus: Equatable { + case unconnected + case connecting + case connected + case progress(Double) + case completing + case completed + case failed(HotlineTransferClientError) +} + +enum HotlineFileForkType: UInt32 { + case none = 0 + case unsupported = 1 + case info = 0x494E464F // 'INFO' + case data = 0x44415441 // 'DATA' + case resource = 1296122706 // 'MACR' +} + +public enum HotlineFolderAction: UInt16 { + case sendFile = 1 + case resumeFile = 2 + case nextFile = 3 +} + +struct HotlineFileHeader { + static let DataSize: Int = 4 + 2 + 16 + 2 + + let format: UInt32 + let version: UInt16 + let forkCount: UInt16 + + init?(from data: Data) { + guard data.count >= HotlineFileHeader.DataSize else { + return nil + } + + self.format = data.readUInt32(at: 0)! + self.version = data.readUInt16(at: 4)! + // 16 bytes of reserved data sits here. Skip it. + self.forkCount = data.readUInt16(at: 4 + 2 + 16)! + } + + init?(file fileURL: URL) { + guard FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false)) else { + return nil + } + + self.format = "FILP".fourCharCode() + self.version = 1 + + let resourceURL = fileURL.urlForResourceFork() + if FileManager.default.fileExists(atPath: resourceURL.path(percentEncoded: false)) { + self.forkCount = 3 + } + else { + self.forkCount = 2 + } + } + + func data() -> Data { + Data(endian: .big) { + self.format + self.version + Data(repeating: 0, count: 16) + self.forkCount + } + } +} + +// MARK: - + +struct HotlineFileForkHeader { + static let DataSize: Int = 4 + 4 + 4 + 4 + + let forkType: UInt32 + let compressionType: UInt32 + let dataSize: UInt32 + + init(type: UInt32, dataSize: UInt32) { + self.forkType = type + self.compressionType = 0 + self.dataSize = dataSize + } + + init?(from data: Data) { + guard data.count >= HotlineFileForkHeader.DataSize else { + return nil + } + + self.forkType = data.readUInt32(at: 0)! + self.compressionType = data.readUInt32(at: 4)! + // 4 bytes of reserved data sits here. Skip it. + // self.reserved = data.readUInt32(at: 4 + 4)! + self.dataSize = data.readUInt32(at: 4 + 4 + 4)! + } + + func data() -> Data { + Data(endian: .big) { + self.forkType + self.compressionType + UInt32.zero + self.dataSize + } + } + + var isInfoFork: Bool { + return self.forkType == HotlineFileForkType.info.rawValue + } + + var isDataFork: Bool { + return self.forkType == HotlineFileForkType.data.rawValue + } + + var isResourceFork: Bool { + return self.forkType == HotlineFileForkType.resource.rawValue + } +} + +// MARK: - + +struct HotlineFileInfoFork { + static let BaseDataSize: Int = 4 + 4 + 4 + 4 + 4 + 32 + 8 + 8 + 2 + 2 + + let platform: UInt32 + let type: UInt32 + let creator: UInt32 + let flags: UInt32 + let platformFlags: UInt32 + let createdDate: Date + let modifiedDate: Date + let nameScript: UInt16 + let name: String + let comment: String? + var headerSize: Int + + init?(file fileURL: URL) { + guard FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false)) else { + return nil + } + + self.platform = "AMAC".fourCharCode() + + if let hfsInfo = try? FileManager.default.getHFSTypeAndCreator(fileURL) { + self.type = hfsInfo.hfsType + self.creator = hfsInfo.hfsCreator + } + else { + self.type = 0 + self.creator = 0 + } + + self.flags = 0 + self.platformFlags = 0 + + let dateInfo = FileManager.default.getCreatedAndModifiedDates(fileURL) + self.createdDate = dateInfo.createdDate + self.modifiedDate = dateInfo.modifiedDate + + self.nameScript = 0 + self.name = fileURL.lastPathComponent + + let fileComment = try? FileManager.default.getFinderComment(fileURL) + self.comment = fileComment ?? "" + + self.headerSize = 0 + } + + init?(from data: Data) { + // Make sure we have at least enough data to read basic header data + guard data.count >= HotlineFileInfoFork.BaseDataSize else { + return nil + } + + if + let platform = data.readUInt32(at: 0), + let type = data.readUInt32(at: 4), + let creator = data.readUInt32(at: 4 + 4), + let flags = data.readUInt32(at: 4 + 4 + 4), + let platformFlags = data.readUInt32(at: 4 + 4 + 4 + 4), + // 32 bytes of reserved data sits here. Skip it. + let nameScript = data.readUInt16(at: 4 + 4 + 4 + 4 + 4 + 32 + 8 + 8) { + + let createdDate = data.readDate(at: 4 + 4 + 4 + 4 + 4 + 32) ?? Date.now + let modifiedDate = data.readDate(at: 4 + 4 + 4 + 4 + 4 + 32 + 8) ?? Date.now + + let (n, nl) = data.readLongPString(at: 4 + 4 + 4 + 4 + 4 + 32 + 8 + 8 + 2) + if let name = n { + self.platform = platform + self.type = type + self.creator = creator + self.flags = flags + self.platformFlags = platformFlags + self.createdDate = createdDate + self.modifiedDate = modifiedDate + self.nameScript = nameScript + self.name = name + + var calculatedHeaderSize: Int = HotlineFileInfoFork.BaseDataSize + nl + var commentRead: String? = nil + if data.count >= HotlineFileInfoFork.BaseDataSize + nl + 2 { + let commentLength = data.readUInt16(at: HotlineFileInfoFork.BaseDataSize + nl)! + var commentCorrupted = false + + // Some servers have incorrect data length for the INFO fork + // the length they send is what it should be but don't include + // the comment length in the actual data, so we end up with mismatched + // lengths. So here we test if the length we read is actually 'DA' + // or the first part of the "DATA" fork header. + // Needless to say, stuff like this makes for sad code but this ain't so bad. + if commentLength == 0x4441 { + commentCorrupted = true + } + + if !commentCorrupted { + let (c, cl) = data.readLongPString(at: HotlineFileInfoFork.BaseDataSize + nl) + calculatedHeaderSize += 2 + if cl > 0 { + calculatedHeaderSize += Int(cl) + if let ct = c, cl > 0 { + commentRead = ct + } + } + } + } + + self.comment = commentRead + self.headerSize = calculatedHeaderSize + return + } + } + + return nil + } + + func data() -> Data { + let fileName = self.name.data(using: .macOSRoman)! + + let data = Data(endian: .big) { + self.platform + self.type + self.creator + self.flags + self.platformFlags + Data(repeating: 0, count: 32) + self.createdDate + self.modifiedDate + self.nameScript + UInt16(fileName.count) + fileName + if let commentData = self.comment?.data(using: .macOSRoman) { + UInt16(commentData.count) + commentData + } + } + + return data + } +} diff --git a/Hotline/Library/Extensions.swift b/Hotline/Library/Extensions.swift index b6a4b68..bb28370 100644 --- a/Hotline/Library/Extensions.swift +++ b/Hotline/Library/Extensions.swift @@ -121,6 +121,10 @@ extension UTType { extension String { + var isBlank: Bool { + self.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + func markdownToAttributedString() -> AttributedString { let markdownText = self.convertingLinksToMarkdown() let attr = (try? AttributedString(markdown: markdownText, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(self) diff --git a/Hotline/Library/NetSocket/NetSocket.swift b/Hotline/Library/NetSocket/NetSocket.swift index e66ef3f..ffccf5c 100644 --- a/Hotline/Library/NetSocket/NetSocket.swift +++ b/Hotline/Library/NetSocket/NetSocket.swift @@ -87,7 +87,7 @@ public enum NetSocketError: Error, CustomStringConvertible, Sendable { } } -/// An async/await TCP socket with automatic buffering and framing support +/// An async TCP socket with automatic buffering /// /// NetSocket provides: /// - Async connection management @@ -99,7 +99,7 @@ public enum NetSocketError: Error, CustomStringConvertible, Sendable { /// ```swift /// let socket = try await NetSocket.connect(host: "example.com", port: 80) /// try await socket.write("Hello\n".data(using: .utf8)!) -/// let response = try await socket.readUntil(delimiter: .lineFeed) +/// let response = try await socket.read(until: .lineFeed) /// ``` public actor NetSocket { /// Configuration options for the socket @@ -150,18 +150,11 @@ public actor NetSocket { /// - Parameters: /// - host: Network framework host (e.g., `.name("example.com", nil)` or `.ipv4(...)`) /// - port: Network framework port - /// - tls: TLS policy (default: enabled with default settings) /// - config: Socket configuration (default: standard settings) + /// - parameters: NWParameters (default: .tcp) /// - Returns: A connected and ready `NetSocket` /// - Throws: Network errors or connection failures - public static func connect(host: NWEndpoint.Host, port: NWEndpoint.Port, tls: TLSPolicy = .enabled(), config: Config = .init()) async throws -> NetSocket { - let parameters = NWParameters.tcp - if tls.enabled { - let tlsOptions = NWProtocolTLS.Options() - tls.configure?(tlsOptions) - parameters.defaultProtocolStack.applicationProtocols.insert(tlsOptions, at: 0) - } - + public static func connect(host: NWEndpoint.Host, port: NWEndpoint.Port, config: Config = .init(), parameters: NWParameters = .tcp) async throws -> NetSocket { let conn = NWConnection(host: host, port: port, using: parameters) let socket = NetSocket(connection: conn, config: config) try await socket.start() @@ -169,28 +162,12 @@ public actor NetSocket { } /// Convenience wrapper to connect using string hostname and integer port - public static func connect(host: String, port: UInt16, tls: TLSPolicy = .enabled(), config: Config = .init()) async throws -> NetSocket { + public static func connect(host: String, port: UInt16, config: Config = .init()) async throws -> NetSocket { guard let nwPort = NWEndpoint.Port(rawValue: port) else { throw NetSocketError.invalidPort } - // Parse the host string to create the appropriate NWEndpoint.Host - let nwHost: NWEndpoint.Host - - // Try parsing as IPv6 without zone - if let ipv6Addr = IPv6Address(host) { - nwHost = .ipv6(ipv6Addr) - } - // Try parsing as IPv4 - else if let ipv4Addr = IPv4Address(host) { - nwHost = .ipv4(ipv4Addr) - } - // Fall back to treating as hostname - else { - nwHost = .name(host, nil) - } - - return try await self.connect(host: nwHost, port: nwPort, tls: tls, config: config) + return try await self.connect(host: NWEndpoint.Host(host), port: nwPort, config: config) } // MARK: Close diff --git a/Hotline/MacApp.swift b/Hotline/MacApp.swift index 9052c0b..9ac54c1 100644 --- a/Hotline/MacApp.swift +++ b/Hotline/MacApp.swift @@ -109,7 +109,7 @@ struct Application: App { .onChange(of: AppLaunchState.shared.launchState) { if AppLaunchState.shared.launchState == .launched { if Prefs.shared.showBannerToolbar { - showBannerWindow() + self.showBannerWindow() } } } @@ -196,13 +196,13 @@ struct Application: App { .commands { CommandGroup(replacing: .newItem) { Button("Connect to Server...") { - openWindow(id: "server") + self.openWindow(id: "server") } .keyboardShortcut(.init("K"), modifiers: .command) } CommandGroup(before: .singleWindowList) { Button("Toolbar") { - toggleBannerWindow() + self.toggleBannerWindow() } .keyboardShortcut(.init("\\"), modifiers: [.shift, .command]) } @@ -210,18 +210,18 @@ struct Application: App { Divider() Button("Request Feature...") { if let url = URL(string: "https://github.com/mierau/hotline/issues/new?labels=enhancement") { - openURL(url) + self.openURL(url) } } Button("Report Bug...") { if let url = URL(string: "https://github.com/mierau/hotline/issues/new?labels=bug") { - openURL(url) + self.openURL(url) } } Divider() Button("Open Latest Release Page...") { if let url = URL(string: "https://github.com/mierau/hotline/releases/latest") { - openURL(url) + self.openURL(url) } } } @@ -230,7 +230,7 @@ struct Application: App { guard let selection else { return } - connect(to: selection) + self.connect(to: selection) } .disabled(selection == nil || selection?.server == nil) .keyboardShortcut(.downArrow, modifiers: .command) @@ -242,38 +242,47 @@ struct Application: App { } } .disabled(activeHotline?.status == .disconnected) + Divider() + Button("Broadcast Message...") { // TODO: Implement broadcast message when user is allowed. } .disabled(true) .keyboardShortcut(.init("B"), modifiers: .command) + Divider() - Button("Show Chat") { + + Button("Chat") { activeServerState?.selection = .chat } .disabled(activeHotline?.status != .loggedIn) .keyboardShortcut(.init("1"), modifiers: .command) - Button("Show Message Board") { + Button("Board") { activeServerState?.selection = .board } .disabled(activeHotline?.status != .loggedIn) .keyboardShortcut(.init("2"), modifiers: .command) - Button("Show News") { + Button("News") { activeServerState?.selection = .news } .disabled(activeHotline?.status != .loggedIn || (activeHotline?.serverVersion ?? 0) < 151) .keyboardShortcut(.init("3"), modifiers: .command) - Button("Show Files") { + Button("Files") { activeServerState?.selection = .files } .disabled(activeHotline?.status != .loggedIn) .keyboardShortcut(.init("4"), modifiers: .command) - Button("Show Accounts") { - activeServerState?.selection = .accounts + + if activeHotline?.access?.contains(.canOpenUsers) == true { + Divider() + + Button("Accounts") { + activeServerState?.selection = .accounts + } + .disabled(activeHotline?.status != .loggedIn || activeHotline?.access?.contains(.canOpenUsers) != true ) + .keyboardShortcut(.init("5"), modifiers: .command) } - .disabled(activeHotline?.status != .loggedIn || activeHotline?.access?.contains(.canOpenUsers) != true ) - .keyboardShortcut(.init("5"), modifiers: .command) } } @@ -287,8 +296,8 @@ struct Application: App { TransfersView() .frame(minWidth: 500, minHeight: 200) } - .defaultSize(width: 600, height: 400) - .defaultPosition(.center) + .defaultSize(width: 500, height: 400) + .defaultPosition(.topTrailing) .keyboardShortcut(.init("T"), modifiers: [.shift, .command]) // MARK: Image Preview Window @@ -328,7 +337,7 @@ struct Application: App { func connect(to item: TrackerSelection) { if let server = item.server { - openWindow(id: "server", value: server) + self.openWindow(id: "server", value: server) } } diff --git a/Hotline/State/FilePreviewState.swift b/Hotline/State/FilePreviewState.swift index 97bd907..27f8199 100644 --- a/Hotline/State/FilePreviewState.swift +++ b/Hotline/State/FilePreviewState.swift @@ -1,10 +1,3 @@ -// -// FilePreviewState.swift -// Hotline -// -// Modern file preview state using HotlineFilePreviewClientNew -// - import SwiftUI import UniformTypeIdentifiers @@ -24,7 +17,7 @@ final class FilePreviewState { let info: PreviewFileInfo - private var previewClient: HotlineFilePreviewClientNew? + private var previewClient: HotlineFilePreviewClient? private var previewTask: Task? var state: LoadState = .unloaded @@ -67,7 +60,7 @@ final class FilePreviewState { let task = Task { @MainActor in do { - let client = HotlineFilePreviewClientNew( + let client = HotlineFilePreviewClient( fileName: info.name, address: info.address, port: UInt16(info.port), diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index 14fb9aa..e80571a 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -8,6 +8,10 @@ enum HotlineConnectionStatus: Equatable { case connected case loggedIn case failed(String) + + var isLoggingIn: Bool { + self == .connecting || self == .connected + } var isConnected: Bool { return self == .connected || self == .loggedIn @@ -254,7 +258,7 @@ class HotlineState: Equatable { @ObservationIgnored private var restoredChatSessionKey: ChatStore.SessionKey? @ObservationIgnored private var chatHistoryObserver: NSObjectProtocol? @ObservationIgnored private var lastPersistedMessageType: ChatMessageType? - + // MARK: - Initialization init() { @@ -262,8 +266,10 @@ class HotlineState: Equatable { forName: ChatStore.historyClearedNotification, object: nil, queue: .main - ) { @MainActor [weak self] _ in - self?.handleChatHistoryCleared() + ) { _ in + Task { @MainActor [weak self] in + self?.handleChatHistoryCleared() + } } } @@ -295,7 +301,7 @@ class HotlineState: Equatable { do { // Connect and login - let loginInfo = HotlineLoginInfo( + let loginInfo = HotlineLogin( login: server.login, password: server.password, username: username, @@ -360,7 +366,7 @@ class HotlineState: Equatable { await client.disconnect() self.client = nil } - self.status = .failed(error.localizedDescription) + self.status = .disconnected // .failed(error.localizedDescription) self.errorDisplayed = true self.errorMessage = error.localizedDescription throw error @@ -498,7 +504,7 @@ class HotlineState: Equatable { do { print("HotlineState: Banner download info - reference: \(result.referenceNumber), transferSize: \(result.transferSize)") - let previewClient = HotlineFilePreviewClientNew( + let previewClient = HotlineFilePreviewClient( fileName: "banner", address: address, port: UInt16(port), @@ -894,7 +900,7 @@ class HotlineState: Equatable { AppState.shared.addTransfer(transfer) // Create download client - let downloadClient = HotlineFileDownloadClientNew( + let downloadClient = HotlineFileDownloadClient( address: address, port: UInt16(port), reference: referenceNumber, @@ -1270,7 +1276,7 @@ class HotlineState: Equatable { print("HotlineState: Got upload reference: \(referenceNumber)") // Create upload client - guard let uploadClient = HotlineFileUploadClientNew( + guard let uploadClient = HotlineFileUploadClient( fileURL: fileURL, address: address, port: UInt16(port), diff --git a/Hotline/State/ServerState.swift b/Hotline/State/ServerState.swift index 5913bf5..805672d 100644 --- a/Hotline/State/ServerState.swift +++ b/Hotline/State/ServerState.swift @@ -29,7 +29,7 @@ enum ServerNavigationType: Identifiable, Hashable, Equatable { case .files: return "Files" case .accounts: - return "Accounts" + return "Admin" case .user(let userID): return String(userID) } diff --git a/Hotline/iOS/TrackerView.swift b/Hotline/iOS/TrackerView.swift index 2a3583b..8c846e5 100644 --- a/Hotline/iOS/TrackerView.swift +++ b/Hotline/iOS/TrackerView.swift @@ -9,7 +9,7 @@ struct TrackerConnectView: View { @State private var address = "" @State private var login = "" @State private var password = "" -// @State private var connecting = false + // @State private var connecting = false func connectionStatusToProgress(status: HotlineClientStatus) -> Double { switch status { @@ -27,116 +27,116 @@ struct TrackerConnectView: View { } var body: some View { - VStack(alignment: .leading) { - if self.model.status == .disconnected { - TextField("Server Address", text: $address) - .keyboardType(.URL) - .disableAutocorrection(true) - .frame(height: 48) - .textFieldStyle(.plain) - .padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) - .background { - Color.black.cornerRadius(8).blendMode(.overlay) - } - TextField("Login", text: $login) - .disableAutocorrection(true) - .frame(height: 48) - .textFieldStyle(.plain) - .padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) - .background { - Color.black.cornerRadius(8).blendMode(.overlay) - } - SecureField("Password", text: $password) - .disableAutocorrection(true) - .textFieldStyle(.plain) - .frame(height: 48) - .padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) - .background { - Color.black.cornerRadius(8).blendMode(.overlay) - } - } - else { - ProgressView(value: connectionStatusToProgress(status: model.status)) - .frame(minHeight: 10) - .accentColor(colorScheme == .dark ? .white : .black) - } - - Spacer() - - HStack { - Button { - dismiss() - server = nil - model.disconnect() - } label: { - Text("Cancel") + VStack(alignment: .leading) { + if self.model.status == .disconnected { + TextField("Server Address", text: $address) + .keyboardType(.URL) + .disableAutocorrection(true) + .frame(height: 48) + .textFieldStyle(.plain) + .padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) + .background { + Color.black.cornerRadius(8).blendMode(.overlay) } - .bold() - .padding(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24)) - .frame(maxWidth: .infinity) - .foregroundColor(colorScheme == .dark ? .white : .black) - .background( - colorScheme == .dark ? - LinearGradient(gradient: Gradient(colors: [Color(white: 0.4), Color(white: 0.3)]), startPoint: .top, endPoint: .bottom) - : - LinearGradient(gradient: Gradient(colors: [Color(white: 0.95), Color(white: 0.91)]), startPoint: .top, endPoint: .bottom) - ) - .overlay( - RoundedRectangle(cornerRadius: 10.0).stroke(.black, lineWidth: 3).opacity(colorScheme == .dark ? 0.0 : 0.2) - ) - .cornerRadius(10.0) - Button { - let s = Server(name: nil, description: nil, address: address, port: HotlinePorts.DefaultServerPort, users: 0, login: login, password: password) - server = s - self.model.login(server: s, username: "bolt", iconID: 128) { success in - if !success { - print("FAILED LOGIN??") - } - else { - self.model.sendUserInfo(username: "bolt", iconID: 128) - self.model.getUserList() - } + TextField("Login", text: $login) + .disableAutocorrection(true) + .frame(height: 48) + .textFieldStyle(.plain) + .padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) + .background { + Color.black.cornerRadius(8).blendMode(.overlay) + } + SecureField("Password", text: $password) + .disableAutocorrection(true) + .textFieldStyle(.plain) + .frame(height: 48) + .padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) + .background { + Color.black.cornerRadius(8).blendMode(.overlay) + } + } + else { + ProgressView(value: connectionStatusToProgress(status: model.status)) + .frame(minHeight: 10) + .accentColor(colorScheme == .dark ? .white : .black) + } + + Spacer() + + HStack { + Button { + dismiss() + server = nil + model.disconnect() + } label: { + Text("Cancel") + } + .bold() + .padding(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24)) + .frame(maxWidth: .infinity) + .foregroundColor(colorScheme == .dark ? .white : .black) + .background( + colorScheme == .dark ? + LinearGradient(gradient: Gradient(colors: [Color(white: 0.4), Color(white: 0.3)]), startPoint: .top, endPoint: .bottom) + : + LinearGradient(gradient: Gradient(colors: [Color(white: 0.95), Color(white: 0.91)]), startPoint: .top, endPoint: .bottom) + ) + .overlay( + RoundedRectangle(cornerRadius: 10.0).stroke(.black, lineWidth: 3).opacity(colorScheme == .dark ? 0.0 : 0.2) + ) + .cornerRadius(10.0) + Button { + let s = Server(name: nil, description: nil, address: address, port: HotlinePorts.DefaultServerPort, users: 0, login: login, password: password) + server = s + self.model.login(server: s, username: "bolt", iconID: 128) { success in + if !success { + print("FAILED LOGIN??") + } + else { + self.model.sendUserInfo(username: "bolt", iconID: 128) + self.model.getUserList() } - } label: { - Text("Connect") } - .disabled(self.model.status != .disconnected) - .bold() - .padding(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24)) - .frame(maxWidth: .infinity) - .foregroundColor(colorScheme == .dark ? .white : .black) - .background( - colorScheme == .dark ? - LinearGradient(gradient: Gradient(colors: [Color(white: 0.4), Color(white: 0.3)]), startPoint: .top, endPoint: .bottom) - : - LinearGradient(gradient: Gradient(colors: [Color(white: 0.95), Color(white: 0.91)]), startPoint: .top, endPoint: .bottom) - ) - .overlay( - RoundedRectangle(cornerRadius: 10.0).stroke(.black, lineWidth: 3).opacity(colorScheme == .dark ? 0.0 : 0.2) - ) - .cornerRadius(10.0) + } label: { + Text("Connect") } - .padding() + .disabled(self.model.status != .disconnected) + .bold() + .padding(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24)) + .frame(maxWidth: .infinity) + .foregroundColor(colorScheme == .dark ? .white : .black) + .background( + colorScheme == .dark ? + LinearGradient(gradient: Gradient(colors: [Color(white: 0.4), Color(white: 0.3)]), startPoint: .top, endPoint: .bottom) + : + LinearGradient(gradient: Gradient(colors: [Color(white: 0.95), Color(white: 0.91)]), startPoint: .top, endPoint: .bottom) + ) + .overlay( + RoundedRectangle(cornerRadius: 10.0).stroke(.black, lineWidth: 3).opacity(colorScheme == .dark ? 0.0 : 0.2) + ) + .cornerRadius(10.0) } .padding() - .onChange(of: model.status) { - print("MODEL STATUS CHANGED") - if model.server != nil && server != nil && model.server! == server! { - if model.status == .loggedIn { - dismiss() - } -// else { -// connecting = (model.status != .disconnected) -// } + } + .padding() + .onChange(of: self.model.status) { + print("MODEL STATUS CHANGED") + if self.model.server != nil && self.server != nil && self.model.server! == server! { + if self.model.status == .loggedIn { + self.dismiss() } + // else { + // connecting = (model.status != .disconnected) + // } } - .toolbar { - ToolbarItem(placement: .principal) { - Text("Connect to Server") - .font(.headline) - } + } + .toolbar { + ToolbarItem(placement: .principal) { + Text("Connect to Server") + .font(.headline) } -// .presentationBackground(.regularMaterial, in: Color(uiColor: .systemGroupedBackground)) + } + // .presentationBackground(.regularMaterial, in: Color(uiColor: .systemGroupedBackground)) .presentationBackground { Color.clear .background(Material.regular) @@ -190,12 +190,12 @@ struct TrackerView: View { } func updateServers() async { -// "hltracker.com" -// "tracker.preterhuman.net" -// "hotline.ubersoft.org" -// "tracked.nailbat.com" -// "hotline.duckdns.org" -// "tracked.agent79.org" + // "hltracker.com" + // "tracker.preterhuman.net" + // "hotline.ubersoft.org" + // "tracked.nailbat.com" + // "hotline.duckdns.org" + // "tracked.agent79.org" self.servers = await model.getServerList(tracker: "hltracker.com") } diff --git a/Hotline/macOS/ConnectView.swift b/Hotline/macOS/ConnectView.swift new file mode 100644 index 0000000..8419522 --- /dev/null +++ b/Hotline/macOS/ConnectView.swift @@ -0,0 +1,147 @@ +import SwiftUI + +struct ConnectView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + + @Binding var address: String + @Binding var login: String + @Binding var password: String + + var action: (() -> Void)? = nil + + @State private var bookmarkSheetPresented: Bool = false + @State private var bookmarkName: String = "" + + private enum FocusFields { + case address + case login + case password + } + + @FocusState private var focusedField: FocusFields? + + var body: some View { + VStack(alignment: .center, spacing: 0) { + Form { + HStack(alignment: .top, spacing: 10) { + Image("Server Large") + .resizable() + .scaledToFit() + .frame(width: 28, height: 28) + + VStack(alignment: .leading) { + Text("Connect to Server") + Text("Enter the address of a Hotline server to connect to.") + .foregroundStyle(.secondary) + .font(.subheadline) + } + } + + TextField(text: self.$address) { + Text("Address") + } + .focused($focusedField, equals: .address) + + TextField(text: self.$login, prompt: Text("Optional")) { + Text("Login") + } + .focused($focusedField, equals: .login) + + SecureField(text: self.$password, prompt: Text("Optional")) { + Text("Password") + } + .focused($focusedField, equals: .password) + } + .formStyle(.grouped) + .fixedSize(horizontal: false, vertical: true) + + HStack { + Button { + if !self.address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + self.bookmarkSheetPresented = true + } + } label: { + Image(systemName: "bookmark.fill") + } + .disabled(self.address.isEmpty) + .controlSize(.regular) + .buttonStyle(.automatic) + .help("Bookmark server") + + Spacer() + + Button("Cancel") { + self.dismiss() + } + .controlSize(.regular) + .buttonStyle(.automatic) + .keyboardShortcut(.cancelAction) + + Button("Connect") { + self.action?() +// self.connectToServer() + } + .controlSize(.regular) + .buttonStyle(.automatic) + .keyboardShortcut(.defaultAction) + } + .padding(.horizontal, 20) + } +// .onChange(of: self.address) { +// let (a, p) = Server.parseServerAddressAndPort(connectAddress) +// server.address = a +// server.port = p +// } +// .onChange(of: connectLogin) { +// server.login = connectLogin.trimmingCharacters(in: .whitespacesAndNewlines) +// } +// .onChange(of: connectPassword) { +// server.password = connectPassword +// } + .frame(maxWidth: 380) + .padding() + .onAppear { + self.focusedField = .address + } + .sheet(isPresented: self.$bookmarkSheetPresented) { + VStack(alignment: .leading) { + Text("Save Bookmark") + .foregroundStyle(.secondary) + .padding(.bottom, 4) + TextField("Bookmark Name", text: self.$bookmarkName) + .textFieldStyle(.roundedBorder) + .controlSize(.large) + } + .frame(width: 250) + .padding() + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + self.bookmarkSheetPresented = false + self.bookmarkName = "" + } + } + + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + let name = String(self.bookmarkName.trimmingCharacters(in: .whitespacesAndNewlines)) + if !name.isEmpty { + self.bookmarkSheetPresented = false + self.bookmarkName = "" + + let (host, port) = Server.parseServerAddressAndPort(self.address) + let login: String? = self.login.isBlank ? nil : self.login + let password: String? = self.password.isBlank ? nil : self.password + + if !host.isEmpty { + let newBookmark = Bookmark(type: .server, name: name, address: host, port: port, login: login, password: password) + Bookmark.add(newBookmark, context: modelContext) + } + } + } + } + } + } + } +} diff --git a/Hotline/macOS/Files/FilePreviewQuickLookView.swift b/Hotline/macOS/Files/FilePreviewQuickLookView.swift index 0323fdd..26bd286 100644 --- a/Hotline/macOS/Files/FilePreviewQuickLookView.swift +++ b/Hotline/macOS/Files/FilePreviewQuickLookView.swift @@ -1,10 +1,3 @@ -// -// FilePreviewQuickLookView.swift -// Hotline -// -// QuickLook-based file preview window for all supported file types -// - import SwiftUI import UniformTypeIdentifiers @@ -15,10 +8,11 @@ struct FilePreviewQuickLookView: View { @Environment(\.controlActiveState) private var controlActiveState @Environment(\.colorScheme) private var colorScheme - @Environment(\.dismiss) var dismiss + @Environment(\.dismiss) private var dismiss @Binding var info: PreviewFileInfo? @State var preview: FilePreviewState? = nil + @FocusState private var focusField: FilePreviewFocus? var body: some View { @@ -76,14 +70,14 @@ struct FilePreviewQuickLookView: View { .background(Color(nsColor: .textBackgroundColor)) .focused($focusField, equals: .window) .navigationTitle(info?.name ?? "File Preview") - .background( + .background { + if let fileURL = self.preview?.fileURL { WindowConfigurator { window in - if let fileURL = preview?.fileURL { - window.representedURL = fileURL - window.standardWindowButton(.documentIconButton)?.isHidden = false - } + window.representedURL = fileURL + window.standardWindowButton(.documentIconButton)?.isHidden = false } - ) + } + } .toolbar { if let _ = preview?.fileURL { if let info = info { diff --git a/Hotline/macOS/HotlinePanelView.swift b/Hotline/macOS/HotlinePanelView.swift index 220ea97..a56cd44 100644 --- a/Hotline/macOS/HotlinePanelView.swift +++ b/Hotline/macOS/HotlinePanelView.swift @@ -124,7 +124,7 @@ struct HotlinePanelView: View { .buttonStyle(.plain) .frame(width: 20, height: 20) .disabled(self.activeServerState == nil) - .help("Accounts") + .help("Administration") } Button { diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index 44274ea..757dbd8 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -47,10 +47,8 @@ struct ListItemView: View { if let i = icon { Image(i) .resizable() - // .renderingMode(.template) .scaledToFit() .frame(width: 20, height: 20) -// .padding(.leading, 2) .opacity(controlActiveState == .inactive ? 0.5 : 1.0) } @@ -74,29 +72,28 @@ extension FocusedValues { } struct ServerView: View { - @Environment(\.dismiss) var dismiss + @Environment(\.dismiss) private var dismiss @Environment(\.colorScheme) private var colorScheme @Environment(\.controlActiveState) private var controlActiveState @Environment(\.scenePhase) private var scenePhase @Environment(\.modelContext) private var modelContext + @Binding var server: Server + @State private var model: HotlineState = HotlineState() @State private var state: ServerState = ServerState(selection: .chat) @State private var agreementShown: Bool = false @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 + @State private var connectionDisplayed: Bool = false static var menuItems: [ServerMenuItem] = [ ServerMenuItem(type: .chat, name: "Chat", image: "Section Chat"), ServerMenuItem(type: .board, name: "Board", image: "Section Board"), ServerMenuItem(type: .news, name: "News", image: "Section News"), ServerMenuItem(type: .files, name: "Files", image: "Section Files"), - ServerMenuItem(type: .accounts, name: "Accounts", image: "Section Users"), + ServerMenuItem(type: .accounts, name: "Admin", image: "Section Users"), ] static var classicMenuItems: [ServerMenuItem] = [ @@ -105,50 +102,20 @@ struct ServerView: View { ServerMenuItem(type: .files, name: "Files", image: "Section Files"), ] - enum FocusFields { - case address - case login - case password - } - - @FocusState private var focusedField: FocusFields? - var body: some View { Group { - if model.status == .disconnected { + if self.model.status == .disconnected { VStack(alignment: .center) { Spacer() - self.connectForm +// if self.connectionDisplayed { + self.connectForm +// } Spacer() } .navigationTitle("Connect to Server") - .onAppear { - self.focusedField = .address - } - } - else if case .failed(let error) = model.status { - VStack { - Image("Hotline") - .resizable() - .renderingMode(.template) - .scaledToFit() - .foregroundColor(Color(hex: 0xE10000)) - .frame(width: 18) - .opacity(controlActiveState == .inactive ? 0.5 : 1.0) - .padding(.trailing, 4) - - Text("Connection Failed") - .font(.headline) - Text(error) - .font(.caption) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - .frame(maxWidth: 300) - .padding() - .navigationTitle("Connection Failed") +// .animation(.default.delay(0.25), value: self.connectionDisplayed) } - else if model.status != .loggedIn { + else if self.model.status.isLoggingIn { HStack { Image("Hotline") .resizable() @@ -156,38 +123,38 @@ struct ServerView: View { .scaledToFit() .foregroundColor(Color(hex: 0xE10000)) .frame(width: 18) - .opacity(controlActiveState == .inactive ? 0.5 : 1.0) + .opacity(self.controlActiveState == .inactive ? 0.5 : 1.0) .padding(.trailing, 4) - ProgressView(value: connectionStatusToProgress(status: model.status)) { - Text(connectionStatusToLabel(status: model.status)) + ProgressView(value: connectionStatusToProgress(status: self.model.status)) { + Text(connectionStatusToLabel(status: self.model.status)) } - .accentColor(colorScheme == .dark ? .white : .black) + .accentColor(self.colorScheme == .dark ? .white : .black) } .frame(maxWidth: 300) .padding() .navigationTitle("Connecting to Server") } - else { - serverView - .environment(model) + else if self.model.status == .loggedIn { + self.serverView + .environment(self.model) .onChange(of: Prefs.shared.userIconID) { - Task { try? await model.sendUserPreferences() } + Task { try? await self.model.sendUserPreferences() } } .onChange(of: Prefs.shared.username) { - Task { try? await model.sendUserPreferences() } + Task { try? await self.model.sendUserPreferences() } } .onChange(of: Prefs.shared.refusePrivateMessages) { - Task { try? await model.sendUserPreferences() } + Task { try? await self.model.sendUserPreferences() } } .onChange(of: Prefs.shared.refusePrivateChat) { - Task { try? await model.sendUserPreferences() } + Task { try? await self.model.sendUserPreferences() } } .onChange(of: Prefs.shared.enableAutomaticMessage) { - Task { try? await model.sendUserPreferences() } + Task { try? await self.model.sendUserPreferences() } } .onChange(of: Prefs.shared.automaticMessage) { - Task { try? await model.sendUserPreferences() } + Task { try? await self.model.sendUserPreferences() } } .toolbar { if #available(macOS 26.0, *) { @@ -196,7 +163,7 @@ struct ServerView: View { .resizable() .scaledToFit() .frame(width: 28) - .opacity(controlActiveState == .inactive ? 0.4 : 1.0) + .opacity(self.controlActiveState == .inactive ? 0.4 : 1.0) } .sharedBackgroundVisibility(.hidden) } @@ -206,7 +173,7 @@ struct ServerView: View { .resizable() .scaledToFit() .frame(width: 28) - .opacity(controlActiveState == .inactive ? 0.4 : 1.0) + .opacity(self.controlActiveState == .inactive ? 0.4 : 1.0) } } } @@ -214,164 +181,54 @@ struct ServerView: View { } .onDisappear { Task { - await model.disconnect() + await self.model.disconnect() } } - .onChange(of: model.serverTitle) { - state.serverName = model.serverTitle + .onChange(of: self.model.serverTitle) { + self.state.serverName = self.model.serverTitle } -// .onChange(of: model.bannerImage) { -// state.serverBanner = model.bannerImage -// } -// .onChange(of: model.bannerColors) { -// guard let backgroundColor = model.bannerColors?.backgroundColor else { -// state.bannerBackgroundColor = nil -// return -// } -// state.bannerBackgroundColor = Color(nsColor: backgroundColor) -// } - .alert(model.errorMessage ?? "Server Error", isPresented: $model.errorDisplayed) { + .alert(self.model.errorMessage ?? "Server Error", isPresented: self.$model.errorDisplayed) { Button("OK") {} } - .task { - var address = server.address - if server.port != HotlinePorts.DefaultServerPort { - address += ":\(server.port)" + .onAppear { + var address = self.server.address + if self.server.port != HotlinePorts.DefaultServerPort { + address += ":\(self.server.port)" } - connectAddress = server.address - connectLogin = server.login - connectPassword = server.password + self.connectAddress = self.server.address + self.connectLogin = self.server.login + self.connectPassword = self.server.password // Connect to server automatically unless the option key is held down. if !NSEvent.modifierFlags.contains(.option) { - connectToServer() + self.connectToServer() } + + self.connectionDisplayed = true } .focusedSceneValue(\.activeHotlineModel, model) .focusedSceneValue(\.activeServerState, state) } - var connectForm: some View { - VStack(alignment: .center, spacing: 0) { - Form { - HStack(alignment: .top, spacing: 10) { - Image("Server Large") - .resizable() - .scaledToFit() - .frame(width: 28, height: 28) - - VStack(alignment: .leading) { - Text("Connect to Server") - Text("Enter the address of a Hotline server to connect to.") - .foregroundStyle(.secondary) - .font(.subheadline) - } - } - - TextField(text: $connectAddress) { - Text("Address") - } - .focused($focusedField, equals: .address) - - TextField(text: $connectLogin, prompt: Text("Optional")) { - Text("Login") - } - .focused($focusedField, equals: .login) - - SecureField(text: $connectPassword, prompt: Text("Optional")) { - Text("Password") - } - .focused($focusedField, equals: .password) - } - .formStyle(.grouped) - .fixedSize(horizontal: false, vertical: true) - - HStack { - Button { - if !connectAddress.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - connectNameSheetPresented = true - } - } label: { - Image(systemName: "bookmark.fill") - } - .disabled(connectAddress.isEmpty) - .controlSize(.regular) - .buttonStyle(.automatic) - .help("Bookmark server") - - Spacer() - - Button("Cancel") { - dismiss() - } - .controlSize(.regular) - .buttonStyle(.automatic) - .keyboardShortcut(.cancelAction) - - Button("Connect") { - connectToServer() - } - .controlSize(.regular) - .buttonStyle(.automatic) - .keyboardShortcut(.defaultAction) - } - .padding(.horizontal, 20) + private var connectForm: some View { + ConnectView(address: self.$connectAddress, login: self.$connectLogin, password: self.$connectPassword) { + self.connectToServer() } - .onChange(of: connectAddress) { - let (a, p) = Server.parseServerAddressAndPort(connectAddress) - server.address = a - server.port = p + .focusSection() + .onChange(of: self.connectAddress) { + let (a, p) = Server.parseServerAddressAndPort(self.connectAddress) + self.server.address = a + self.server.port = p } - .onChange(of: connectLogin) { - server.login = connectLogin.trimmingCharacters(in: .whitespacesAndNewlines) + .onChange(of: self.connectLogin) { + self.server.login = self.connectLogin.trimmingCharacters(in: .whitespacesAndNewlines) } - .onChange(of: connectPassword) { - server.password = connectPassword - } - .frame(maxWidth: 380) - .padding() - .sheet(isPresented: $connectNameSheetPresented) { - VStack(alignment: .leading) { - Text("Save 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 = "" - - let (host, port) = Server.parseServerAddressAndPort(connectAddress) - let login: String? = connectLogin.isEmpty ? nil : connectLogin - let password: String? = connectPassword.isEmpty ? nil : connectPassword - - if !host.isEmpty { - let newBookmark = Bookmark(type: .server, name: name, address: host, port: port, login: login, password: password) - Bookmark.add(newBookmark, context: modelContext) - } - } - } - } - } + .onChange(of: self.connectPassword) { + self.server.password = self.connectPassword } } - var navigationList: some View { + private var navigationList: some View { List(selection: $state.selection) { // Don't show news on older servers. ForEach(model.serverVersion < 151 ? ServerView.classicMenuItems : ServerView.menuItems) { menuItem in @@ -496,7 +353,7 @@ struct ServerView: View { case .accounts: AccountManagerView() .navigationTitle(model.serverTitle) - .navigationSubtitle("Accounts") + .navigationSubtitle("Administration") .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .user(let userID): let user = model.users.first(where: { $0.id == userID }) @@ -510,21 +367,21 @@ struct ServerView: View { } } .toolbar(removing: .sidebarToggle) - } - // MARK: - @MainActor func connectToServer() { - guard !server.address.isEmpty else { + guard !self.server.address.isEmpty else { return } + + // Set status here so it's immediate (not waiting to enter task). + self.model.status = .connecting Task { @MainActor in do { - // login() handles everything: connect, getUserList, sendPreferences, downloadBanner - try await model.login( + try await self.model.login( server: server, username: Prefs.shared.username, iconID: Prefs.shared.userIconID -- cgit From ddb9c69b24a67ac140af9ff20f5c36bdef6fb51b Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Mon, 10 Nov 2025 21:00:43 -0800 Subject: Remove "New" suffix from our transfer clients. Further cleanup. Allow previewing certain files with HFS types (but no extensions). Cleanup preview files when window closes. --- Hotline.xcodeproj/project.pbxproj | 40 +- Hotline/Hotline/HotlineClientNew.swift | 108 ++--- .../Transfers/HotlineFileDownloadClient.swift | 269 +++++++++++ .../Transfers/HotlineFileDownloadClientNew.swift | 269 ----------- .../Transfers/HotlineFilePreviewClient.swift | 162 +++++++ .../Transfers/HotlineFilePreviewClientNew.swift | 146 ------ .../Transfers/HotlineFileUploadClient.swift | 225 +++++++++ .../Transfers/HotlineFileUploadClientNew.swift | 225 --------- .../Transfers/HotlineFolderDownloadClient.swift | 450 ++++++++++++++++++ .../Transfers/HotlineFolderDownloadClientNew.swift | 453 ------------------ .../Transfers/HotlineFolderUploadClient.swift | 512 ++++++++++++++++++++ .../Transfers/HotlineFolderUploadClientNew.swift | 518 --------------------- Hotline/Library/Extensions.swift | 90 +++- Hotline/Models/FileInfo.swift | 10 +- Hotline/Models/PreviewFileInfo.swift | 3 + Hotline/State/AppUpdate.swift | 9 +- Hotline/State/FilePreviewState.swift | 155 +++--- Hotline/State/HotlineState.swift | 22 +- Hotline/State/ServerState.swift | 2 +- Hotline/macOS/Files/FilePreviewQuickLookView.swift | 53 +-- Hotline/macOS/Files/FilesView.swift | 17 +- Hotline/macOS/Files/FolderItemView.swift | 2 +- Hotline/macOS/ServerView.swift | 72 ++- Hotline/macOS/TransfersView.swift | 4 +- 24 files changed, 1931 insertions(+), 1885 deletions(-) create mode 100644 Hotline/Hotline/Transfers/HotlineFileDownloadClient.swift delete mode 100644 Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift create mode 100644 Hotline/Hotline/Transfers/HotlineFilePreviewClient.swift delete mode 100644 Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift create mode 100644 Hotline/Hotline/Transfers/HotlineFileUploadClient.swift delete mode 100644 Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift create mode 100644 Hotline/Hotline/Transfers/HotlineFolderDownloadClient.swift delete mode 100644 Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift create mode 100644 Hotline/Hotline/Transfers/HotlineFolderUploadClient.swift delete mode 100644 Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index aa10d51..57e7032 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -22,9 +22,9 @@ DA32CD4B2B29318E0053B98B /* FileInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32CD4A2B29318E0053B98B /* FileInfo.swift */; }; DA32CD4D2B2931B50053B98B /* ChatMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32CD4C2B2931B50053B98B /* ChatMessage.swift */; }; DA32CD4F2B2931CC0053B98B /* NewsInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32CD4E2B2931CC0053B98B /* NewsInfo.swift */; }; - DA3429AE2EB9C0280010784E /* HotlineFileUploadClientNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429AD2EB9C0220010784E /* HotlineFileUploadClientNew.swift */; }; - DA3429B02EBA70790010784E /* HotlineFolderUploadClientNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429AF2EBA70790010784E /* HotlineFolderUploadClientNew.swift */; }; - DA3429B32EBA7ADF0010784E /* HotlineFilePreviewClientNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429B22EBA7ADE0010784E /* HotlineFilePreviewClientNew.swift */; }; + DA3429AE2EB9C0280010784E /* HotlineFileUploadClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429AD2EB9C0220010784E /* HotlineFileUploadClient.swift */; }; + DA3429B02EBA70790010784E /* HotlineFolderUploadClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429AF2EBA70790010784E /* HotlineFolderUploadClient.swift */; }; + DA3429B32EBA7ADF0010784E /* HotlineFilePreviewClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429B22EBA7ADE0010784E /* HotlineFilePreviewClient.swift */; }; DA3429B52EBA8A450010784E /* FilePreviewState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429B42EBA8A450010784E /* FilePreviewState.swift */; }; DA3429B72EBAB1750010784E /* QuickLookPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429B62EBAB1750010784E /* QuickLookPreviewView.swift */; platformFilters = (macos, ); }; DA3429B92EBAB2130010784E /* FilePreviewQuickLookView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429B82EBAB2130010784E /* FilePreviewQuickLookView.swift */; }; @@ -49,11 +49,11 @@ DA5268AD2EB12FE200DCB941 /* ServerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268AC2EB12FE200DCB941 /* ServerState.swift */; }; DA5268AF2EB2682B00DCB941 /* HotlineClientNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268AE2EB2682B00DCB941 /* HotlineClientNew.swift */; }; DA5268B12EB2708E00DCB941 /* HotlineState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268B02EB2708E00DCB941 /* HotlineState.swift */; }; - DA5268B32EB6806E00DCB941 /* HotlineFileDownloadClientNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268B22EB6806E00DCB941 /* HotlineFileDownloadClientNew.swift */; }; + DA5268B32EB6806E00DCB941 /* HotlineFileDownloadClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268B22EB6806E00DCB941 /* HotlineFileDownloadClient.swift */; }; DA5268B52EB6840A00DCB941 /* TransfersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268B42EB6840A00DCB941 /* TransfersView.swift */; }; DA5268B82EB916AF00DCB941 /* TransferRateEstimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268B72EB916AF00DCB941 /* TransferRateEstimator.swift */; }; DA5268BA2EB91B5E00DCB941 /* FileProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268B92EB91B5E00DCB941 /* FileProgress.swift */; }; - DA5268BC2EB95A7700DCB941 /* HotlineFolderDownloadClientNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268BB2EB95A7700DCB941 /* HotlineFolderDownloadClientNew.swift */; }; + DA5268BC2EB95A7700DCB941 /* HotlineFolderDownloadClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268BB2EB95A7700DCB941 /* HotlineFolderDownloadClient.swift */; }; DA55AC732BE42AF000034857 /* AsyncLinkPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA55AC722BE42AF000034857 /* AsyncLinkPreview.swift */; platformFilters = (macos, ); }; DA55AC752BE4888300034857 /* InstantMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA55AC742BE4888300034857 /* InstantMessage.swift */; }; DA55AC772BE589F700034857 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA55AC762BE589F700034857 /* AboutView.swift */; platformFilters = (macos, ); }; @@ -136,9 +136,9 @@ DA32CD4A2B29318E0053B98B /* FileInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileInfo.swift; sourceTree = ""; }; DA32CD4C2B2931B50053B98B /* ChatMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessage.swift; sourceTree = ""; }; DA32CD4E2B2931CC0053B98B /* NewsInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsInfo.swift; sourceTree = ""; }; - DA3429AD2EB9C0220010784E /* HotlineFileUploadClientNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFileUploadClientNew.swift; sourceTree = ""; }; - DA3429AF2EBA70790010784E /* HotlineFolderUploadClientNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFolderUploadClientNew.swift; sourceTree = ""; }; - DA3429B22EBA7ADE0010784E /* HotlineFilePreviewClientNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFilePreviewClientNew.swift; sourceTree = ""; }; + DA3429AD2EB9C0220010784E /* HotlineFileUploadClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFileUploadClient.swift; sourceTree = ""; }; + DA3429AF2EBA70790010784E /* HotlineFolderUploadClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFolderUploadClient.swift; sourceTree = ""; }; + DA3429B22EBA7ADE0010784E /* HotlineFilePreviewClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFilePreviewClient.swift; sourceTree = ""; }; DA3429B42EBA8A450010784E /* FilePreviewState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePreviewState.swift; sourceTree = ""; }; DA3429B62EBAB1750010784E /* QuickLookPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickLookPreviewView.swift; sourceTree = ""; }; DA3429B82EBAB2130010784E /* FilePreviewQuickLookView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePreviewQuickLookView.swift; sourceTree = ""; }; @@ -162,11 +162,11 @@ DA5268AC2EB12FE200DCB941 /* ServerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerState.swift; sourceTree = ""; }; DA5268AE2EB2682B00DCB941 /* HotlineClientNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineClientNew.swift; sourceTree = ""; }; DA5268B02EB2708E00DCB941 /* HotlineState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineState.swift; sourceTree = ""; }; - DA5268B22EB6806E00DCB941 /* HotlineFileDownloadClientNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFileDownloadClientNew.swift; sourceTree = ""; }; + DA5268B22EB6806E00DCB941 /* HotlineFileDownloadClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFileDownloadClient.swift; sourceTree = ""; }; DA5268B42EB6840A00DCB941 /* TransfersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransfersView.swift; sourceTree = ""; }; DA5268B72EB916AF00DCB941 /* TransferRateEstimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransferRateEstimator.swift; sourceTree = ""; }; DA5268B92EB91B5E00DCB941 /* FileProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProgress.swift; sourceTree = ""; }; - DA5268BB2EB95A7700DCB941 /* HotlineFolderDownloadClientNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFolderDownloadClientNew.swift; sourceTree = ""; }; + DA5268BB2EB95A7700DCB941 /* HotlineFolderDownloadClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFolderDownloadClient.swift; sourceTree = ""; }; DA55AC722BE42AF000034857 /* AsyncLinkPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncLinkPreview.swift; sourceTree = ""; }; DA55AC742BE4888300034857 /* InstantMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstantMessage.swift; sourceTree = ""; }; DA55AC762BE589F700034857 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; @@ -248,11 +248,11 @@ isa = PBXGroup; children = ( DA5753672B33E88A00FAC277 /* HotlineTransferClient.swift */, - DA3429B22EBA7ADE0010784E /* HotlineFilePreviewClientNew.swift */, - DA5268B22EB6806E00DCB941 /* HotlineFileDownloadClientNew.swift */, - DA3429AD2EB9C0220010784E /* HotlineFileUploadClientNew.swift */, - DA5268BB2EB95A7700DCB941 /* HotlineFolderDownloadClientNew.swift */, - DA3429AF2EBA70790010784E /* HotlineFolderUploadClientNew.swift */, + DA3429B22EBA7ADE0010784E /* HotlineFilePreviewClient.swift */, + DA5268B22EB6806E00DCB941 /* HotlineFileDownloadClient.swift */, + DA3429AD2EB9C0220010784E /* HotlineFileUploadClient.swift */, + DA5268BB2EB95A7700DCB941 /* HotlineFolderDownloadClient.swift */, + DA3429AF2EBA70790010784E /* HotlineFolderUploadClient.swift */, ); path = Transfers; sourceTree = ""; @@ -639,9 +639,9 @@ DA52689E2EB073A400DCB941 /* IconSettingsView.swift in Sources */, DAC3D9832BC33FD000A727C9 /* AppState.swift in Sources */, DA65499C2BEC3FBD00EDB697 /* ServerAgreementView.swift in Sources */, - DA5268BC2EB95A7700DCB941 /* HotlineFolderDownloadClientNew.swift in Sources */, + DA5268BC2EB95A7700DCB941 /* HotlineFolderDownloadClient.swift in Sources */, DA2863DD2B3E8B7000A7D050 /* FilePreview.swift in Sources */, - DA3429B02EBA70790010784E /* HotlineFolderUploadClientNew.swift in Sources */, + DA3429B02EBA70790010784E /* HotlineFolderUploadClient.swift in Sources */, DA5268AB2EB11EA300DCB941 /* ColorArt.swift in Sources */, DA2863D82B37AD1C00A7D050 /* SettingsView.swift in Sources */, DA5268BA2EB91B5E00DCB941 /* FileProgress.swift in Sources */, @@ -672,7 +672,7 @@ DA32CD4B2B29318E0053B98B /* FileInfo.swift in Sources */, DAF5BC6C2EC2727700551E4D /* ConnectView.swift in Sources */, DA20BBE12BF5237600B94E7C /* Bookmark.swift in Sources */, - DA3429B32EBA7ADF0010784E /* HotlineFilePreviewClientNew.swift in Sources */, + DA3429B32EBA7ADF0010784E /* HotlineFilePreviewClient.swift in Sources */, DA9CAFCB2B126E3300CDA197 /* HotlineTrackerClient.swift in Sources */, DA2863DA2B37BF6E00A7D050 /* Preferences.swift in Sources */, DA32CD4F2B2931CC0053B98B /* NewsInfo.swift in Sources */, @@ -706,7 +706,7 @@ DA501BF02EBED848001714F8 /* BonjourState.swift in Sources */, DAAEE66F2B47625600A5BA07 /* FilePreviewImageView.swift in Sources */, DA872B152BDDEE1A008B1012 /* VisualEffectView.swift in Sources */, - DA3429AE2EB9C0280010784E /* HotlineFileUploadClientNew.swift in Sources */, + DA3429AE2EB9C0280010784E /* HotlineFileUploadClient.swift in Sources */, DAAEE66D2B475F1400A5BA07 /* PreviewFileInfo.swift in Sources */, DAB4D8842B4CABEF0048A05C /* Extensions.swift in Sources */, DA0D698D2B1E7CF700C71DF5 /* UsersView.swift in Sources */, @@ -723,7 +723,7 @@ DAB4D87E2B4C8BCA0048A05C /* FilePreviewTextView.swift in Sources */, DA3429B72EBAB1750010784E /* QuickLookPreviewView.swift in Sources */, DA4F2C012B1A558E00D8ADDC /* ChatView.swift in Sources */, - DA5268B32EB6806E00DCB941 /* HotlineFileDownloadClientNew.swift in Sources */, + DA5268B32EB6806E00DCB941 /* HotlineFileDownloadClient.swift in Sources */, DACCE5E32EABE86A008CDD92 /* AppUpdateView.swift in Sources */, DAC6B2E02EAC6236004E2CBA /* ChatStore.swift in Sources */, DA32CD492B2931640053B98B /* User.swift in Sources */, diff --git a/Hotline/Hotline/HotlineClientNew.swift b/Hotline/Hotline/HotlineClientNew.swift index 854c7ff..cd34d9e 100644 --- a/Hotline/Hotline/HotlineClientNew.swift +++ b/Hotline/Hotline/HotlineClientNew.swift @@ -93,11 +93,11 @@ public struct HotlineServerInfo: Sendable { // MARK: - Hotline Client -/// Modern async/await-based Hotline protocol client +/// A client for connecting to and interacting with Hotline servers. /// /// Example usage: /// ```swift -/// let client = try await HotlineClientNew.connect( +/// let client = try await HotlineClient.connect( /// host: "server.example.com", /// port: 5500, /// login: HotlineLogin(login: "guest", password: "", username: "John", iconID: 414) @@ -123,7 +123,7 @@ public struct HotlineServerInfo: Sendable { /// // Get user list /// let users = try await client.getUserList() /// ``` -public actor HotlineClientNew { +public actor HotlineClient { // MARK: - Properties private let socket: NetSocket @@ -189,23 +189,23 @@ public actor HotlineClientNew { host: String, port: UInt16 = 5500, login: HotlineLogin - ) async throws -> HotlineClientNew { - print("HotlineClientNew.connect(): Starting connection to \(host):\(port) as '\(login.username)'") + ) async throws -> HotlineClient { + print("HotlineClient.connect(): Starting connection to \(host):\(port) as '\(login.username)'") // Connect socket - print("HotlineClientNew.connect(): Connecting socket...") + print("HotlineClient.connect(): Connecting socket...") let socket = try await NetSocket.connect(host: host, port: port) - print("HotlineClientNew.connect(): Socket connected") + print("HotlineClient.connect(): Socket connected") // Perform handshake - print("HotlineClientNew.connect(): Sending handshake...") + print("HotlineClient.connect(): Sending handshake...") try await socket.write(handshakeData) let handshakeResponse = try await socket.read(8) - print("HotlineClientNew.connect(): Handshake response received") + print("HotlineClient.connect(): Handshake response received") // Verify handshake guard handshakeResponse.prefix(4) == Data([0x54, 0x52, 0x54, 0x50]) else { - print("HotlineClientNew.connect(): Invalid handshake response") + print("HotlineClient.connect(): Invalid handshake response") throw HotlineClientError.connectionFailed( NSError(domain: "HotlineClient", code: -1, userInfo: [ NSLocalizedDescriptionKey: "Invalid handshake response" @@ -215,7 +215,7 @@ public actor HotlineClientNew { let errorCode = handshakeResponse.withUnsafeBytes { $0.load(fromByteOffset: 4, as: UInt32.self) } guard errorCode.bigEndian == 0 else { - print("HotlineClientNew.connect(): Handshake failed with error code \(errorCode)") + print("HotlineClient.connect(): Handshake failed with error code \(errorCode)") throw HotlineClientError.connectionFailed( NSError(domain: "HotlineClient", code: Int(errorCode), userInfo: [ NSLocalizedDescriptionKey: "Handshake failed with error code \(errorCode)" @@ -224,24 +224,24 @@ public actor HotlineClientNew { } // Create client - print("HotlineClientNew.connect(): Creating client instance") - let client = HotlineClientNew(socket: socket) + print("HotlineClient.connect(): Creating client instance") + let client = HotlineClient(socket: socket) // Start receive loop - print("HotlineClientNew.connect(): Starting receive loop") + print("HotlineClient.connect(): Starting receive loop") await client.startReceiveLoop() // Perform login - print("HotlineClientNew.connect(): Performing login") + print("HotlineClient.connect(): Performing login") let serverInfo = try await client.performLogin(login) await client.setServerInfo(serverInfo) - print("HotlineClientNew.connect(): Login successful") + print("HotlineClient.connect(): Login successful") // Start keep-alive - print("HotlineClientNew.connect(): Starting keep-alive") + print("HotlineClient.connect(): Starting keep-alive") await client.startKeepAlive() - print("HotlineClientNew.connect(): Connected to \(serverInfo.name) (v\(serverInfo.version))") + print("HotlineClient.connect(): Connected to \(serverInfo.name) (v\(serverInfo.version))") return client } @@ -296,19 +296,19 @@ public actor HotlineClientNew { isConnected = false - print("HotlineClientNew.disconnect(): Starting disconnect") + print("HotlineClient.disconnect(): Starting disconnect") self.receiveTask?.cancel() self.keepAliveTask?.cancel() await self.socket.close() self.failAllPendingTransactions(HotlineClientError.notConnected) self.eventContinuation.finish() - print("HotlineClientNew.disconnect(): Disconnect complete") + print("HotlineClient.disconnect(): Disconnect complete") } // MARK: - Receive Loop private func startReceiveLoop() { - print("HotlineClientNew.startReceiveLoop(): Creating receive task") + print("HotlineClient.startReceiveLoop(): Creating receive task") self.receiveTask = Task { [weak self] in guard let self else { return @@ -320,21 +320,21 @@ public actor HotlineClientNew { let transaction = try await self.socket.receive(HotlineTransaction.self, endian: .big) await self.handleTransaction(transaction) } - print("HotlineClientNew.startReceiveLoop(): Task cancelled, exiting loop") + print("HotlineClient.startReceiveLoop(): Task cancelled, exiting loop") } catch { if Task.isCancelled || error is CancellationError { - print("HotlineClientNew.startReceiveLoop(): Receive loop cancelled") + print("HotlineClient.startReceiveLoop(): Receive loop cancelled") } else { - print("HotlineClientNew.startReceiveLoop(): Receive loop error: \(error)") + print("HotlineClient.startReceiveLoop(): Receive loop error: \(error)") await self.disconnect() } } - print("HotlineClientNew.startReceiveLoop(): Receive loop ended") + print("HotlineClient.startReceiveLoop(): Receive loop ended") } } private func handleTransaction(_ transaction: HotlineTransaction) { - print("HotlineClientNew: <= \(transaction.type) [\(transaction.id)]") + print("HotlineClient: <= \(transaction.type) [\(transaction.id)]") // Check if this is a reply to a pending transaction if transaction.isReply == 1 || transaction.type == .reply { @@ -348,7 +348,7 @@ public actor HotlineClientNew { private func handleReply(_ transaction: HotlineTransaction) { guard let continuation = pendingTransactions.removeValue(forKey: transaction.id) else { - print("HotlineClientNew: Received reply for unknown transaction \(transaction.id)") + print("HotlineClient: Received reply for unknown transaction \(transaction.id)") return } @@ -359,7 +359,6 @@ public actor HotlineClientNew { message: errorText )) } else { - print("HELLO") continuation.resume(returning: transaction) } } @@ -417,14 +416,15 @@ public actor HotlineClientNew { } default: - print("HotlineClientNew: Unhandled event type \(transaction.type)") + print("HotlineClient: Unhandled event type \(transaction.type)") } } // MARK: - Transaction Sending + @discardableResult private func sendTransaction(_ transaction: HotlineTransaction, timeout: TimeInterval = 30.0) async throws -> HotlineTransaction { - print("HotlineClientNew: => \(transaction.type) [\(transaction.id)]") + print("HotlineClient: => \(transaction.type) [\(transaction.id)]") let transactionID = transaction.id @@ -518,7 +518,7 @@ public actor HotlineClientNew { let _ = try? await self.getUserList() } } catch { - print("HotlineClientNew: Keep-alive failed: \(error)") + print("HotlineClient: Keep-alive failed: \(error)") } } @@ -605,7 +605,7 @@ public actor HotlineClientNew { try await socket.send(transaction, endian: .big) } - // MARK: - Public API - Files + // MARK: - Files /// Get the file list for a directory /// @@ -617,7 +617,7 @@ public actor HotlineClientNew { transaction.setFieldPath(type: .filePath, val: path) } - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) var files: [HotlineFile] = [] for field in reply.getFieldList(type: .fileNameWithInfo) { @@ -649,7 +649,7 @@ public actor HotlineClientNew { transaction.setFieldUInt32(type: .fileTransferOptions, val: 2) } - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) guard let transferSize = reply.getField(type: .transferSize)?.getInteger(), @@ -664,7 +664,7 @@ public actor HotlineClientNew { return (referenceNumber, transferSize, fileSize, waitingCount) } - // MARK: - Public API - News + // MARK: - News /// Get news categories at a path /// @@ -676,7 +676,7 @@ public actor HotlineClientNew { transaction.setFieldPath(type: .newsPath, val: path) } - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) var categories: [HotlineNewsCategory] = [] for field in reply.getFieldList(type: .newsCategoryListData15) { @@ -698,7 +698,7 @@ public actor HotlineClientNew { transaction.setFieldPath(type: .newsPath, val: path) } - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) guard let articleData = reply.getField(type: .newsArticleListData) else { return [] @@ -725,7 +725,7 @@ public actor HotlineClientNew { transaction.setFieldUInt32(type: .newsArticleID, val: id) transaction.setFieldString(type: .newsArticleDataFlavor, val: flavor, encoding: .ascii) - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) return reply.getField(type: .newsArticleData)?.getString() } @@ -754,17 +754,17 @@ public actor HotlineClientNew { transaction.setFieldUInt32(type: .newsArticleFlags, val: 0) transaction.setFieldString(type: .newsArticleData, val: text) - _ = try await sendTransaction(transaction) + try await self.sendTransaction(transaction) } - // MARK: - Public API - Message Board + // MARK: - Message Board /// Get message board posts /// /// - Returns: Array of message strings public func getMessageBoard() async throws -> [String] { let transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getMessageBoard) - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) guard let text = reply.getField(type: .data)?.getString() else { return [] @@ -784,10 +784,10 @@ public actor HotlineClientNew { var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .oldPostNews) transaction.setFieldString(type: .data, val: text, encoding: .macOSRoman) - try await socket.send(transaction, endian: .big) + try await self.socket.send(transaction, endian: .big) } - // MARK: - Public API - File Operations + // MARK: - File Operations /// Get detailed information about a file /// @@ -840,7 +840,7 @@ public actor HotlineClientNew { transaction.setFieldPath(type: .filePath, val: path) do { - _ = try await sendTransaction(transaction) + try await self.sendTransaction(transaction) return true } catch { return false @@ -854,7 +854,7 @@ public actor HotlineClientNew { /// - Returns: Array of user accounts sorted by login public func getAccounts() async throws -> [HotlineAccount] { let transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getAccounts) - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) let accountFields = reply.getFieldList(type: .data) var accounts: [HotlineAccount] = [] @@ -886,7 +886,7 @@ public actor HotlineClientNew { transaction.setFieldEncodedString(type: .userPassword, val: password) } - _ = try await sendTransaction(transaction) + try await self.sendTransaction(transaction) } /// Update an existing user account (requires admin access) @@ -919,7 +919,7 @@ public actor HotlineClientNew { transaction.setFieldEncodedString(type: .userPassword, val: password!) } - _ = try await sendTransaction(transaction) + try await self.sendTransaction(transaction) } /// Delete a user account (requires admin access) @@ -929,7 +929,7 @@ public actor HotlineClientNew { var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .deleteUser) transaction.setFieldEncodedString(type: .userLogin, val: login) - _ = try await sendTransaction(transaction) + try await self.sendTransaction(transaction) } // MARK: - Banners @@ -940,7 +940,7 @@ public actor HotlineClientNew { /// - Throws: HotlineClientError if not connected or server doesn't support banners public func downloadBanner() async throws -> (referenceNumber: UInt32, transferSize: Int)? { let transaction = HotlineTransaction(id: self.generateTransactionID(), type: .downloadBanner) - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) guard let transferSizeField = reply.getField(type: .transferSize), @@ -972,7 +972,7 @@ public actor HotlineClientNew { transaction.setFieldUInt32(type: .fileTransferOptions, val: 2) } - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) guard let transferSizeField = reply.getField(type: .transferSize), @@ -1000,7 +1000,7 @@ public actor HotlineClientNew { transaction.setFieldString(type: .fileName, val: name) transaction.setFieldPath(type: .filePath, val: path) - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) guard let transferSizeField = reply.getField(type: .transferSize), @@ -1027,7 +1027,7 @@ public actor HotlineClientNew { transaction.setFieldString(type: .fileName, val: name) transaction.setFieldPath(type: .filePath, val: path) - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) guard let transferReferenceField = reply.getField(type: .referenceNumber), @@ -1046,7 +1046,7 @@ public actor HotlineClientNew { /// - path: Directory path where the folder should be uploaded /// - Returns: Reference number for the upload transfer 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)") + print("HotlineClient: uploadFolder request - name='\(name)', path=\(path), fileCount=\(fileCount), totalSize=\(totalSize)") var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .uploadFolder) transaction.setFieldString(type: .fileName, val: name) @@ -1054,7 +1054,7 @@ public actor HotlineClientNew { transaction.setFieldUInt32(type: .transferSize, val: totalSize) transaction.setFieldUInt16(type: .folderItemCount, val: UInt16(truncatingIfNeeded: fileCount)) - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) guard let transferReferenceField = reply.getField(type: .referenceNumber), diff --git a/Hotline/Hotline/Transfers/HotlineFileDownloadClient.swift b/Hotline/Hotline/Transfers/HotlineFileDownloadClient.swift new file mode 100644 index 0000000..82f61d4 --- /dev/null +++ b/Hotline/Hotline/Transfers/HotlineFileDownloadClient.swift @@ -0,0 +1,269 @@ +import Foundation +import Network + +public enum HotlineDownloadLocation: Sendable { + case url(URL) + case downloads(String) // filename +} + +public enum HotlineTransferProgress: Sendable { + case error(Error) // An error occurred + case unconnected // Initial state + case preparing // Preparing to begin + case connecting // Connecting to server + case connected // Connected to server + case transfer(name: String, size: Int, total: Int, progress: Double, speed: Double?, estimate: TimeInterval?) // size transferred, total size, progress (0.0-1.0), speed (in bytes/sec), time remaining + case completed(url: URL?) // Download or upload complete (local url valid for downloads) +} + + +@MainActor +public class HotlineFileDownloadClient: @MainActor HotlineTransferClient { + public struct Configuration: Sendable { + public var chunkSize: Int = 256 * 1024 + public init() {} + } + + private let serverAddress: String + private let serverPort: UInt16 + private let referenceNumber: UInt32 + + private let config: Configuration + + private var transferSize: Int + private let transferTotal: Int + private var transferProgress: Progress + + private var socket: NetSocket? + private var downloadTask: Task? + + public init( + address: String, + port: UInt16, + reference: UInt32, + size: UInt32, + configuration: Configuration = .init() + ) { + self.serverAddress = address + self.serverPort = port + self.referenceNumber = reference + self.config = configuration + + self.transferTotal = Int(size) + self.transferSize = 0 + self.transferProgress = Progress(totalUnitCount: Int64(self.transferTotal)) + } + + // MARK: - API + + public func download( + to location: HotlineDownloadLocation, + progress progressHandler: (@Sendable (HotlineTransferProgress) throws -> Void)? = nil + ) async throws -> URL { + self.downloadTask?.cancel() + + let task = Task { + try await performDownload(to: location, progressHandler: progressHandler) + } + self.downloadTask = task + + do { + let url = try await task.value + self.downloadTask = nil + return url + } + catch { + self.downloadTask = nil + try? progressHandler?(.error(error)) + throw error + } + } + + /// Cancel the current download + public func cancel() { + self.downloadTask?.cancel() + self.downloadTask = nil + } + + // MARK: - Implementation + + private func updateProgress(sent: Int) throws { + self.transferSize = sent + self.transferProgress.completedUnitCount = Int64(sent) + try self.checkCancelled() + } + + private func checkCancelled() throws { + if Task.isCancelled { + throw CancellationError() + } + + // People can cancel a transfer from the file icon in the Finder. + // This code handles that. + if self.transferProgress.isCancelled { + throw CancellationError() + } + } + + private func performDownload( + to destination: HotlineDownloadLocation, + progressHandler: (@Sendable (HotlineTransferProgress) throws -> Void)? + ) async throws -> URL { + + let fm = FileManager.default + var fileHandle: FileHandle? + var resourceForkData: Data? + + try progressHandler?(.preparing) + + // Determine the download name + // Determine destination URL based on location + let destinationURL: URL + let destinationFilename: String + switch destination { + case .url(let url): + destinationURL = url.resolvingSymlinksInPath() + destinationFilename = destinationURL.lastPathComponent + case .downloads(let filename): + var downloadsURL = fm.urls(for: .downloadsDirectory, in: .userDomainMask)[0] + downloadsURL = downloadsURL.resolvingSymlinksInPath() + destinationURL = URL(filePath: downloadsURL.generateUniqueFilePath(filename: filename)) + destinationFilename = destinationURL.lastPathComponent + } + + try self.checkCancelled() + try progressHandler?(.connecting) + + // Connect to transfer server + let socket = try await NetSocket.connect( + host: self.serverAddress, + port: self.serverPort + 1 + ) + defer { Task { await socket.close() } } + self.socket = socket + + // See if we've been cancelled + try self.checkCancelled() + + // Send magic header + try await socket.write(Data(endian: .big) { + "HTXF".fourCharCode() + self.referenceNumber + UInt32.zero + UInt32.zero + }) + + // Read file header + let headerData = try await socket.read(HotlineFileHeader.DataSize) + guard let header = HotlineFileHeader(from: headerData) else { + throw HotlineTransferClientError.failedToTransfer + } + + // Connected + try progressHandler?(.connected) + + do { + // Process each fork + for _ in 0..? - - public init( - address: String, - port: UInt16, - reference: UInt32, - size: UInt32, - configuration: Configuration = .init() - ) { - self.serverAddress = address - self.serverPort = port - self.referenceNumber = reference - self.config = configuration - - self.transferTotal = Int(size) - self.transferSize = 0 - self.transferProgress = Progress(totalUnitCount: Int64(self.transferTotal)) - } - - // MARK: - API - - public func download( - to location: HotlineDownloadLocation, - progress progressHandler: (@Sendable (HotlineTransferProgress) throws -> Void)? = nil - ) async throws -> URL { - self.downloadTask?.cancel() - - let task = Task { - try await performDownload(to: location, progressHandler: progressHandler) - } - self.downloadTask = task - - do { - let url = try await task.value - self.downloadTask = nil - return url - } - catch { - self.downloadTask = nil - try? progressHandler?(.error(error)) - throw error - } - } - - /// Cancel the current download - public func cancel() { - self.downloadTask?.cancel() - self.downloadTask = nil - } - - // MARK: - Implementation - - private func updateProgress(sent: Int) throws { - self.transferSize = sent - self.transferProgress.completedUnitCount = Int64(sent) - try self.checkCancelled() - } - - private func checkCancelled() throws { - if Task.isCancelled { - throw CancellationError() - } - - // People can cancel a transfer from the file icon in the Finder. - // This code handles that. - if self.transferProgress.isCancelled { - throw CancellationError() - } - } - - private func performDownload( - to destination: HotlineDownloadLocation, - progressHandler: (@Sendable (HotlineTransferProgress) throws -> Void)? - ) async throws -> URL { - - let fm = FileManager.default - var fileHandle: FileHandle? - var resourceForkData: Data? - - try progressHandler?(.preparing) - - // Determine the download name - // Determine destination URL based on location - let destinationURL: URL - let destinationFilename: String - switch destination { - case .url(let url): - destinationURL = url.resolvingSymlinksInPath() - destinationFilename = destinationURL.lastPathComponent - case .downloads(let filename): - var downloadsURL = fm.urls(for: .downloadsDirectory, in: .userDomainMask)[0] - downloadsURL = downloadsURL.resolvingSymlinksInPath() - destinationURL = URL(filePath: downloadsURL.generateUniqueFilePath(filename: filename)) - destinationFilename = destinationURL.lastPathComponent - } - - try self.checkCancelled() - try progressHandler?(.connecting) - - // Connect to transfer server - let socket = try await NetSocket.connect( - host: self.serverAddress, - port: self.serverPort + 1 - ) - defer { Task { await socket.close() } } - self.socket = socket - - // See if we've been cancelled - try self.checkCancelled() - - // Send magic header - try await socket.write(Data(endian: .big) { - "HTXF".fourCharCode() - self.referenceNumber - UInt32.zero - UInt32.zero - }) - - // Read file header - let headerData = try await socket.read(HotlineFileHeader.DataSize) - guard let header = HotlineFileHeader(from: headerData) else { - throw HotlineTransferClientError.failedToTransfer - } - - // Connected - try progressHandler?(.connected) - - do { - // Process each fork - for _ in 0..? + private var temporaryFileURL: URL? + + public init( + fileName: String, + address: String, + port: UInt16, + reference: UInt32, + size: UInt32, + fileType: String? = nil, + fileCreator: String? = nil + ) { + self.fileName = fileName + self.serverAddress = address + self.serverPort = port + self.referenceNumber = reference + self.transferSize = size + self.fileType = fileType + self.fileCreator = fileCreator + } + + // MARK: - API + + /// Download file to temporary location for preview + /// - Parameter progressHandler: Optional progress callback + /// - Returns: URL to temporary file for preview + public func preview( + progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil + ) async throws -> URL { + self.previewTask?.cancel() + + let task = Task { + try await performPreview(progressHandler: progressHandler) + } + self.previewTask = task + + do { + let url = try await task.value + self.previewTask = nil + return url + } catch { + print("HotlineFilePreviewClient[\(referenceNumber)]: Failed to preview file: \(error)") + self.previewTask = nil + progressHandler?(.error(error)) + throw error + } + } + + /// Cancel the current preview download + public func cancel() { + self.previewTask?.cancel() + self.previewTask = nil + self.downloadClient?.cancel() + } + + /// Manually cleanup temporary file + /// Call this when preview is complete and you no longer need the file + public func cleanup() { + self.cleanupTempFile() + } + + // MARK: - Implementation + + private func performPreview( + progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? + ) async throws -> URL { + + // Create temporary file path directly in system temp directory + let tempDir = FileManager.default.temporaryDirectory + let uniqueFileName = "\(UUID().uuidString)_\(self.fileName)" + let tempFileURL = tempDir.appendingPathComponent(uniqueFileName) + self.temporaryFileURL = tempFileURL + + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Downloading to temp: \(tempFileURL.path)") + + progressHandler?(.connecting) + + // Connect to transfer server + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Connecting to \(self.serverAddress):\(self.serverPort + 1)") + let socket = try await NetSocket.connect( + host: self.serverAddress, + port: self.serverPort + 1 + ) + defer { Task { await socket.close() } } + + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Connected!") + + // Send magic header for raw data download + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Sending magic header") + try await socket.write(Data(endian: .big) { + "HTXF".fourCharCode() + self.referenceNumber + UInt32.zero + UInt32.zero + }) + + progressHandler?(.connected) + + // Stream raw data directly to temp file with progress tracking + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Streaming \(self.transferSize) bytes to temp file") + + let totalSize = Int(self.transferSize) + + // Create empty file (with HFS attributes if available) + var attributes: [FileAttributeKey: Any] = [:] + if let creator = self.fileCreator, !creator.isBlank { + attributes[.hfsCreatorCode] = creator.fourCharCode() as NSNumber + } + if let type = self.fileType, !type.isBlank { + attributes[.hfsTypeCode] = type.fourCharCode() as NSNumber + } + + guard FileManager.default.createFile(atPath: tempFileURL.path, contents: nil, attributes: attributes) else { + throw HotlineTransferClientError.failedToTransfer + } + + let fileHandle = try FileHandle(forWritingTo: tempFileURL) + defer { try? fileHandle.close() } + + let updates = await socket.receiveFile(to: fileHandle, length: totalSize) + for try await p in updates { + progressHandler?(.transfer( + name: uniqueFileName, + size: p.sent, + total: totalSize, + progress: totalSize > 0 ? Double(p.sent) / Double(totalSize) : 0.0, + speed: p.bytesPerSecond, + estimate: p.estimatedTimeRemaining + )) + } + + progressHandler?(.completed(url: tempFileURL)) + + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Preview file ready at \(tempFileURL.path)") + + return tempFileURL + } + + private func cleanupTempFile() { + guard let tempURL = self.temporaryFileURL else { return } + self.temporaryFileURL = nil + + // Delete the temp file + try? FileManager.default.removeItem(at: tempURL) + + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Cleaned up temp file") + } +} diff --git a/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift b/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift deleted file mode 100644 index 5cf5628..0000000 --- a/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift +++ /dev/null @@ -1,146 +0,0 @@ -import Foundation -import Network - -@MainActor -public class HotlineFilePreviewClient { - private let serverAddress: String - private let serverPort: UInt16 - private let referenceNumber: UInt32 - private let fileName: String - private let transferSize: UInt32 - - private var downloadClient: HotlineFileDownloadClient? - private var previewTask: Task? - private var temporaryFileURL: URL? - - public init( - fileName: String, - address: String, - port: UInt16, - reference: UInt32, - size: UInt32 - ) { - self.fileName = fileName - self.serverAddress = address - self.serverPort = port - self.referenceNumber = reference - self.transferSize = size - } - - // MARK: - API - - /// Download file to temporary location for preview - /// - Parameter progressHandler: Optional progress callback - /// - Returns: URL to temporary file for preview - public func preview( - progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil - ) async throws -> URL { - self.previewTask?.cancel() - - let task = Task { - try await performPreview(progressHandler: progressHandler) - } - self.previewTask = task - - do { - let url = try await task.value - self.previewTask = nil - return url - } catch { - print("HotlineFilePreviewClient[\(referenceNumber)]: Failed to preview file: \(error)") - self.previewTask = nil - progressHandler?(.error(error)) - throw error - } - } - - /// Cancel the current preview download - public func cancel() { - self.previewTask?.cancel() - self.previewTask = nil - self.downloadClient?.cancel() - } - - /// Manually cleanup temporary file - /// Call this when preview is complete and you no longer need the file - public func cleanup() { - self.cleanupTempFile() - } - - // MARK: - Implementation - - private func performPreview( - progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? - ) async throws -> URL { - - // Create temporary file path directly in system temp directory - let tempDir = FileManager.default.temporaryDirectory - let uniqueFileName = "\(UUID().uuidString)_\(self.fileName)" - let tempFileURL = tempDir.appendingPathComponent(uniqueFileName) - self.temporaryFileURL = tempFileURL - - print("HotlineFilePreviewClient[\(self.referenceNumber)]: Downloading to temp: \(tempFileURL.path)") - - progressHandler?(.connecting) - - // Connect to transfer server - print("HotlineFilePreviewClient[\(self.referenceNumber)]: Connecting to \(self.serverAddress):\(self.serverPort + 1)") - let socket = try await NetSocket.connect( - host: self.serverAddress, - port: self.serverPort + 1 - ) - defer { Task { await socket.close() } } - - print("HotlineFilePreviewClient[\(self.referenceNumber)]: Connected!") - - // Send magic header for raw data download - print("HotlineFilePreviewClient[\(self.referenceNumber)]: Sending magic header") - try await socket.write(Data(endian: .big) { - "HTXF".fourCharCode() - self.referenceNumber - UInt32.zero - UInt32.zero - }) - - progressHandler?(.connected) - - // Stream raw data directly to temp file with progress tracking - print("HotlineFilePreviewClient[\(self.referenceNumber)]: Streaming \(transferSize) bytes to temp file") - - let totalSize = Int(transferSize) - - // Create empty file - FileManager.default.createFile(atPath: tempFileURL.path(percentEncoded: false), contents: nil) - - let fileHandle = try FileHandle(forWritingTo: tempFileURL) - defer { try? fileHandle.close() } - - let updates = await socket.receiveFile(to: fileHandle, length: totalSize) - for try await p in updates { - progressHandler?(.transfer( - name: uniqueFileName, - size: p.sent, - total: totalSize, - progress: totalSize > 0 ? Double(p.sent) / Double(totalSize) : 0.0, - speed: p.bytesPerSecond, - estimate: p.estimatedTimeRemaining - )) - } - - progressHandler?(.completed(url: tempFileURL)) - - print("HotlineFilePreviewClient[\(self.referenceNumber)]: Preview file ready at \(tempFileURL.path)") - - return tempFileURL - } - - private func cleanupTempFile() { - guard let tempURL = self.temporaryFileURL else { return } - self.temporaryFileURL = nil - - // Delete the temp file - try? FileManager.default.removeItem(at: tempURL) - - print("HotlineFilePreviewClient[\(self.referenceNumber)]: Cleaned up temp file") - } -} diff --git a/Hotline/Hotline/Transfers/HotlineFileUploadClient.swift b/Hotline/Hotline/Transfers/HotlineFileUploadClient.swift new file mode 100644 index 0000000..384e9bd --- /dev/null +++ b/Hotline/Hotline/Transfers/HotlineFileUploadClient.swift @@ -0,0 +1,225 @@ +import Foundation +import Network + +@MainActor +public class HotlineFileUploadClient: @MainActor HotlineTransferClient { + public struct Configuration: Sendable { + public var chunkSize: Int = 256 * 1024 + public init() {} + } + + private let serverAddress: String + private let serverPort: UInt16 + private let referenceNumber: UInt32 + private let fileURL: URL + + private let config: Configuration + + private var transferSize: Int + private let transferTotal: Int + private var transferProgress: Progress + + private var socket: NetSocket? + private var uploadTask: Task? + + public init?( + fileURL: URL, + address: String, + port: UInt16, + reference: UInt32, + configuration: Configuration = .init() + ) { + // Validate file and get total size + guard let payloadSize = FileManager.default.getFlattenedFileSize(fileURL) else { + return nil + } + + guard FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false)) else { + return nil + } + + self.serverAddress = address + self.serverPort = port + self.referenceNumber = reference + self.fileURL = fileURL + self.config = configuration + + self.transferTotal = Int(payloadSize) + self.transferSize = 0 + self.transferProgress = Progress(totalUnitCount: Int64(self.transferTotal)) + } + + // MARK: - Public API + + public func upload( + progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil + ) async throws { + self.uploadTask?.cancel() + + let task = Task { + try await performUpload(progressHandler: progressHandler) + } + self.uploadTask = task + + do { + try await task.value + self.uploadTask = nil + } catch { + print("HotlineFileUploadClient[\(self.referenceNumber)]: Failed to upload file: \(error)") + self.uploadTask = nil + progressHandler?(.error(error)) + throw error + } + } + + /// Cancel the current upload + public func cancel() { + self.uploadTask?.cancel() + self.uploadTask = nil + + if let socket = self.socket { + Task { + await socket.close() + } + } + } + + // MARK: - Implementation + + private func updateProgress(sent: Int, speed: Double? = nil, estimate: TimeInterval? = nil) { + self.transferSize = sent + self.transferProgress.completedUnitCount = Int64(sent) + + if self.transferProgress.isCancelled { + self.cancel() + } + } + + private func performUpload( + progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? + ) async throws { + let filename = self.fileURL.lastPathComponent + + progressHandler?(.connecting) + + // Start accessing security-scoped resource + let didStartAccess = fileURL.startAccessingSecurityScopedResource() + defer { + if didStartAccess { + fileURL.stopAccessingSecurityScopedResource() + } + } + + // Connect to transfer server + let socket = try await NetSocket.connect( + host: self.serverAddress, + port: self.serverPort + 1 + ) + defer { Task { await socket.close() } } + self.socket = socket + + // Get file metadata + guard let infoFork = HotlineFileInfoFork(file: self.fileURL) else { + throw HotlineTransferClientError.failedToTransfer + } + + guard let header = HotlineFileHeader(file: self.fileURL) else { + throw HotlineTransferClientError.failedToTransfer + } + + guard let forkSizes = try? FileManager.default.getFileForkSizes(self.fileURL) else { + throw HotlineTransferClientError.failedToTransfer + } + + let infoForkData = infoFork.data() + let dataForkSize = forkSizes.dataForkSize + let resourceForkSize = forkSizes.resourceForkSize + + // Configure progress for Finder if enabled + self.transferProgress.fileURL = self.fileURL.resolvingSymlinksInPath() + self.transferProgress.fileOperationKind = .uploading + self.transferProgress.publish() + + // Connected + progressHandler?(.connected) + + // Send magic header + try await socket.write(Data(endian: .big) { + "HTXF".fourCharCode() + self.referenceNumber + UInt32(self.transferTotal) + UInt32.zero + }) + + var totalBytesSent = 0 + + // MARK: - Info Fork + // Send file header + let headerData = header.data() + try await socket.write(headerData) + totalBytesSent += headerData.count + + // Send info fork header + let infoForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.info.rawValue, dataSize: UInt32(infoForkData.count)) + try await socket.write(infoForkHeader.data()) + totalBytesSent += HotlineFileForkHeader.DataSize + + // Send info fork + try await socket.write(infoForkData) + totalBytesSent += infoForkData.count + + self.updateProgress(sent: totalBytesSent) + progressHandler?(.transfer(name: filename, size: self.transferSize, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: nil, estimate: nil)) + + // MARK: - Data Fork + // Send data fork (if present) + if dataForkSize > 0 { + // Data fork header + let dataForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.data.rawValue, dataSize: dataForkSize) + try await socket.write(dataForkHeader.data()) + totalBytesSent += HotlineFileForkHeader.DataSize + + // Stream data fork from disk + let fileHandle = try FileHandle(forReadingFrom: self.fileURL) + defer { try? fileHandle.close() } + + let updates = await socket.writeFile(from: fileHandle, length: Int(dataForkSize)) + for try await p in updates { + let bytesSentNow = totalBytesSent + p.sent + self.updateProgress(sent: bytesSentNow, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining) + progressHandler?(.transfer(name: filename, size: bytesSentNow, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining)) + } + + totalBytesSent += Int(dataForkSize) + } + + // MARK: - Resource Fork + // Send resource fork (if present) + if resourceForkSize > 0 { + let resourceURL = self.fileURL.urlForResourceFork() + + // Resource fork header + let resourceForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.resource.rawValue, dataSize: resourceForkSize) + try await socket.write(resourceForkHeader.data()) + totalBytesSent += HotlineFileForkHeader.DataSize + + // Stream resource fork from disk + let resourceHandle = try FileHandle(forReadingFrom: resourceURL) + defer { try? resourceHandle.close() } + + let updates = await socket.writeFile(from: resourceHandle, length: Int(resourceForkSize)) + for try await p in updates { + let bytesSentNow = totalBytesSent + p.sent + self.updateProgress(sent: bytesSentNow, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining) + progressHandler?(.transfer(name: filename, size: bytesSentNow, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining)) + } + + totalBytesSent += Int(resourceForkSize) + } + + self.transferProgress.unpublish() + progressHandler?(.completed(url: nil)) + + print("HotlineFileUploadClient[\(self.referenceNumber)]: Complete!") + } +} diff --git a/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift deleted file mode 100644 index 384e9bd..0000000 --- a/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift +++ /dev/null @@ -1,225 +0,0 @@ -import Foundation -import Network - -@MainActor -public class HotlineFileUploadClient: @MainActor HotlineTransferClient { - public struct Configuration: Sendable { - public var chunkSize: Int = 256 * 1024 - public init() {} - } - - private let serverAddress: String - private let serverPort: UInt16 - private let referenceNumber: UInt32 - private let fileURL: URL - - private let config: Configuration - - private var transferSize: Int - private let transferTotal: Int - private var transferProgress: Progress - - private var socket: NetSocket? - private var uploadTask: Task? - - public init?( - fileURL: URL, - address: String, - port: UInt16, - reference: UInt32, - configuration: Configuration = .init() - ) { - // Validate file and get total size - guard let payloadSize = FileManager.default.getFlattenedFileSize(fileURL) else { - return nil - } - - guard FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false)) else { - return nil - } - - self.serverAddress = address - self.serverPort = port - self.referenceNumber = reference - self.fileURL = fileURL - self.config = configuration - - self.transferTotal = Int(payloadSize) - self.transferSize = 0 - self.transferProgress = Progress(totalUnitCount: Int64(self.transferTotal)) - } - - // MARK: - Public API - - public func upload( - progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil - ) async throws { - self.uploadTask?.cancel() - - let task = Task { - try await performUpload(progressHandler: progressHandler) - } - self.uploadTask = task - - do { - try await task.value - self.uploadTask = nil - } catch { - print("HotlineFileUploadClient[\(self.referenceNumber)]: Failed to upload file: \(error)") - self.uploadTask = nil - progressHandler?(.error(error)) - throw error - } - } - - /// Cancel the current upload - public func cancel() { - self.uploadTask?.cancel() - self.uploadTask = nil - - if let socket = self.socket { - Task { - await socket.close() - } - } - } - - // MARK: - Implementation - - private func updateProgress(sent: Int, speed: Double? = nil, estimate: TimeInterval? = nil) { - self.transferSize = sent - self.transferProgress.completedUnitCount = Int64(sent) - - if self.transferProgress.isCancelled { - self.cancel() - } - } - - private func performUpload( - progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? - ) async throws { - let filename = self.fileURL.lastPathComponent - - progressHandler?(.connecting) - - // Start accessing security-scoped resource - let didStartAccess = fileURL.startAccessingSecurityScopedResource() - defer { - if didStartAccess { - fileURL.stopAccessingSecurityScopedResource() - } - } - - // Connect to transfer server - let socket = try await NetSocket.connect( - host: self.serverAddress, - port: self.serverPort + 1 - ) - defer { Task { await socket.close() } } - self.socket = socket - - // Get file metadata - guard let infoFork = HotlineFileInfoFork(file: self.fileURL) else { - throw HotlineTransferClientError.failedToTransfer - } - - guard let header = HotlineFileHeader(file: self.fileURL) else { - throw HotlineTransferClientError.failedToTransfer - } - - guard let forkSizes = try? FileManager.default.getFileForkSizes(self.fileURL) else { - throw HotlineTransferClientError.failedToTransfer - } - - let infoForkData = infoFork.data() - let dataForkSize = forkSizes.dataForkSize - let resourceForkSize = forkSizes.resourceForkSize - - // Configure progress for Finder if enabled - self.transferProgress.fileURL = self.fileURL.resolvingSymlinksInPath() - self.transferProgress.fileOperationKind = .uploading - self.transferProgress.publish() - - // Connected - progressHandler?(.connected) - - // Send magic header - try await socket.write(Data(endian: .big) { - "HTXF".fourCharCode() - self.referenceNumber - UInt32(self.transferTotal) - UInt32.zero - }) - - var totalBytesSent = 0 - - // MARK: - Info Fork - // Send file header - let headerData = header.data() - try await socket.write(headerData) - totalBytesSent += headerData.count - - // Send info fork header - let infoForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.info.rawValue, dataSize: UInt32(infoForkData.count)) - try await socket.write(infoForkHeader.data()) - totalBytesSent += HotlineFileForkHeader.DataSize - - // Send info fork - try await socket.write(infoForkData) - totalBytesSent += infoForkData.count - - self.updateProgress(sent: totalBytesSent) - progressHandler?(.transfer(name: filename, size: self.transferSize, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: nil, estimate: nil)) - - // MARK: - Data Fork - // Send data fork (if present) - if dataForkSize > 0 { - // Data fork header - let dataForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.data.rawValue, dataSize: dataForkSize) - try await socket.write(dataForkHeader.data()) - totalBytesSent += HotlineFileForkHeader.DataSize - - // Stream data fork from disk - let fileHandle = try FileHandle(forReadingFrom: self.fileURL) - defer { try? fileHandle.close() } - - let updates = await socket.writeFile(from: fileHandle, length: Int(dataForkSize)) - for try await p in updates { - let bytesSentNow = totalBytesSent + p.sent - self.updateProgress(sent: bytesSentNow, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining) - progressHandler?(.transfer(name: filename, size: bytesSentNow, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining)) - } - - totalBytesSent += Int(dataForkSize) - } - - // MARK: - Resource Fork - // Send resource fork (if present) - if resourceForkSize > 0 { - let resourceURL = self.fileURL.urlForResourceFork() - - // Resource fork header - let resourceForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.resource.rawValue, dataSize: resourceForkSize) - try await socket.write(resourceForkHeader.data()) - totalBytesSent += HotlineFileForkHeader.DataSize - - // Stream resource fork from disk - let resourceHandle = try FileHandle(forReadingFrom: resourceURL) - defer { try? resourceHandle.close() } - - let updates = await socket.writeFile(from: resourceHandle, length: Int(resourceForkSize)) - for try await p in updates { - let bytesSentNow = totalBytesSent + p.sent - self.updateProgress(sent: bytesSentNow, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining) - progressHandler?(.transfer(name: filename, size: bytesSentNow, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining)) - } - - totalBytesSent += Int(resourceForkSize) - } - - self.transferProgress.unpublish() - progressHandler?(.completed(url: nil)) - - print("HotlineFileUploadClient[\(self.referenceNumber)]: Complete!") - } -} diff --git a/Hotline/Hotline/Transfers/HotlineFolderDownloadClient.swift b/Hotline/Hotline/Transfers/HotlineFolderDownloadClient.swift new file mode 100644 index 0000000..36193bd --- /dev/null +++ b/Hotline/Hotline/Transfers/HotlineFolderDownloadClient.swift @@ -0,0 +1,450 @@ +import Foundation +import Network + +/// Item progress callback for folder downloads +public struct HotlineFolderItemProgress: Sendable { + public let fileName: String + public let itemNumber: Int + public let totalItems: Int +} + +@MainActor +public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { + private let serverAddress: String + private let serverPort: UInt16 + private let referenceNumber: UInt32 + + private let transferTotal: Int + private let folderItemCount: Int + private var transferSize: Int = 0 + + private var socket: NetSocket? + private var downloadTask: Task? + private var folderProgress: Progress? + + public init( + address: String, + port: UInt16, + reference: UInt32, + size: UInt32, + itemCount: Int + ) { + self.serverAddress = address + self.serverPort = port + self.referenceNumber = reference + self.transferTotal = Int(size) + self.folderItemCount = itemCount + } + + // MARK: - API + + public func download( + to location: HotlineDownloadLocation, + progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil, + itemProgress itemProgressHandler: (@Sendable (HotlineFolderItemProgress) -> Void)? = nil + ) async throws -> URL { + self.downloadTask?.cancel() + + let task = Task { + try await performDownload( + to: location, + progressHandler: progressHandler, + itemProgressHandler: itemProgressHandler + ) + } + self.downloadTask = task + + do { + let url = try await task.value + self.downloadTask = nil + return url + } catch { + print("HotlineFolderDownloadClient[\(referenceNumber)]: Failed to download folder: \(error)") + self.downloadTask = nil + progressHandler?(.error(error)) + throw error + } + } + + /// Cancel the current download + public func cancel() { + downloadTask?.cancel() + downloadTask = nil + + if let socket = socket { + Task { + await socket.close() + } + } + } + + // MARK: - Implementation + + private func performDownload( + to destination: HotlineDownloadLocation, + progressHandler: (@Sendable (HotlineTransferProgress) -> Void)?, + itemProgressHandler: (@Sendable (HotlineFolderItemProgress) -> Void)? + ) async throws -> URL { + + var destinationFilename: String + + progressHandler?(.connecting) + + // Connect to transfer server + let socket = try await connectToTransferServer() + self.socket = socket + defer { Task { await socket.close() } } + + // Determine destination folder URL + let fm = FileManager.default + let destinationURL: URL + + switch destination { + case .url(let url): + destinationURL = url + destinationFilename = url.lastPathComponent + case .downloads(let filename): + let downloadsURL = fm.urls(for: .downloadsDirectory, in: .userDomainMask)[0] + destinationURL = URL(filePath: downloadsURL.generateUniqueFilePath(filename: filename)) + destinationFilename = destinationURL.lastPathComponent + } + + print("HotlineFolderDownloadClient[\(referenceNumber)]: Downloading folder to \(destinationURL.path)") + + // Create destination folder + try? fm.removeItem(at: destinationURL) + try fm.createDirectory(at: destinationURL, withIntermediateDirectories: true) + + // Create and publish progress for the entire folder (shows in Finder) + let progress = Progress(totalUnitCount: Int64(self.transferTotal)) + progress.fileURL = destinationURL + progress.fileOperationKind = .downloading + progress.publish() + self.folderProgress = progress + + // Send initial magic header + print("HotlineFolderDownloadClient[\(self.referenceNumber)]: Sending HTXF magic") + try await socket.write(Data(endian: .big) { + "HTXF".fourCharCode() + self.referenceNumber + UInt32.zero // data size = 0 + UInt16(1) // type = 1 (folder transfer) + UInt16.zero // reserved = 0 + HotlineFolderAction.nextFile.rawValue // action = 3 (next file) + }) + + progressHandler?(.connected) + progressHandler?(.transfer(name: destinationFilename, size: 0, total: self.transferTotal, progress: 0.0, speed: nil, estimate: nil)) + + var completedItemCount = 0 + var totalBytesTransferred = 0 + + // Process each item in the folder + while completedItemCount < self.folderItemCount { + // Read item header + let headerLenData = try await socket.read(2) + let headerLen = Int(headerLenData.readUInt16(at: 0)!) + let headerData = try await socket.read(headerLen) + + totalBytesTransferred += 2 + headerLen + + guard let (itemType, pathComponents) = self.parseItemHeaderPath(headerData) else { + throw HotlineTransferClientError.failedToTransfer + } + + let joinedPath = pathComponents.joined(separator: "/") + print("HotlineFolderDownloadClient[\(referenceNumber)]: Item type=\(itemType) path=\(joinedPath)") + + if itemType == 1 { + // Folder entry - no progress shown for folder creation + if !pathComponents.isEmpty { + let folderURL = destinationURL.appendingPathComponents(pathComponents) + try fm.createDirectory(at: folderURL, withIntermediateDirectories: true) + print("HotlineFolderDownloadClient[\(self.referenceNumber)]: Created folder at \(folderURL.path)") + } + + completedItemCount += 1 + + // Request next item if not done + if completedItemCount < folderItemCount { + try await sendAction(socket: socket, action: .nextFile) // nextFile + } + + } else if itemType == 0 { + // File entry + let parentComponents = pathComponents.dropLast() + let fileName = pathComponents.last ?? "untitled" + + // Request file download + try await sendAction(socket: socket, action: .sendFile) // sendFile + + // Read file size + let fileSizeData = try await socket.read(4) + let fileSize = fileSizeData.readUInt32(at: 0)! + totalBytesTransferred += 4 + + print("HotlineFolderDownloadClient[\(referenceNumber)]: File '\(fileName)' size: \(fileSize) bytes") + + // Notify item progress before download starts + completedItemCount += 1 + itemProgressHandler?(HotlineFolderItemProgress( + fileName: fileName, + itemNumber: completedItemCount, + totalItems: folderItemCount + )) + + // Download the file with overall folder progress tracking + let (fileURL, fileBytesRead) = try await downloadFile( + socket: socket, + fileName: fileName, + parentPath: Array(parentComponents), + destinationFolder: destinationURL, + fileSize: fileSize, + itemNumber: completedItemCount, + totalItems: folderItemCount, + totalBytesTransferredSoFar: totalBytesTransferred, + progressHandler: progressHandler + ) + + totalBytesTransferred += fileBytesRead + self.transferSize = totalBytesTransferred + + print("HotlineFolderDownloadClient[\(referenceNumber)]: Downloaded file to \(fileURL.path)") + + // Request next item if not done + if completedItemCount < folderItemCount { + try await sendAction(socket: socket, action: .nextFile) // nextFile + } + + } else { + // Unknown item type + print("HotlineFolderDownloadClient[\(referenceNumber)]: Unknown item type \(itemType), skipping") + completedItemCount += 1 + + if completedItemCount < folderItemCount { + try await sendAction(socket: socket, action: .nextFile) // nextFile + } + } + } + + print("HotlineFolderDownloadClient[\(referenceNumber)]: Download complete!") + + // Ensure folder progress shows 100% complete + self.folderProgress?.completedUnitCount = Int64(self.transferTotal) + + progressHandler?(.completed(url: destinationURL)) + + return destinationURL + } + + // MARK: - Helper Methods + + private func connectToTransferServer() async throws -> NetSocket { + print("HotlineFolderDownloadClient[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)") + + let socket = try await NetSocket.connect( + host: self.serverAddress, + port: self.serverPort + 1 + ) + + print("HotlineFolderDownloadClient[\(referenceNumber)]: Connected!") + return socket + } + + private func sendAction(socket: NetSocket, action: HotlineFolderAction) async throws { + let actionData = Data(endian: .big) { + action.rawValue + } + try await socket.write(actionData) + print("HotlineFolderDownloadClient[\(referenceNumber)]: Sent action: \(action)") + } + + private func parseItemHeaderPath(_ headerData: Data) -> (type: UInt16, components: [String])? { + // Need at least: type(2) + count(2) + guard headerData.count >= 4, + let type = headerData.readUInt16(at: 0), + let count = headerData.readUInt16(at: 2) else { return nil } + + var ofs = 4 + var comps: [String] = [] + for _ in 0..= ofs + 3 else { return nil } + // per Hotline path encoding: reserved(2) then nameLen(1) then name + ofs += 2 // reserved == 0 + let nameLen = Int(headerData.readUInt8(at: ofs)!) + ofs += 1 + guard headerData.count >= ofs + nameLen else { return nil } + let nameData = headerData.subdata(in: ofs..<(ofs + nameLen)) + ofs += nameLen + + let name = String(data: nameData, encoding: .macOSRoman) + ?? String(data: nameData, encoding: .utf8) + ?? "" + comps.append(name) + } + return (type, comps) + } + + private func downloadFile( + socket: NetSocket, + fileName: String, + parentPath: [String], + destinationFolder: URL, + fileSize: UInt32, + itemNumber: Int, + totalItems: Int, + totalBytesTransferredSoFar: Int, + progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? + ) async throws -> (url: URL, bytesRead: Int) { + let fm = FileManager.default + var bytesRead = 0 + + // Read file header + let headerData = try await socket.read(HotlineFileHeader.DataSize) + guard let header = HotlineFileHeader(from: headerData) else { + throw HotlineTransferClientError.failedToTransfer + } + bytesRead += HotlineFileHeader.DataSize + + // Update folder progress for file header + let totalBytesNow = totalBytesTransferredSoFar + bytesRead + self.folderProgress?.completedUnitCount = Int64(totalBytesNow) + + print("HotlineFolderDownloadClient[\(referenceNumber)]: File has \(header.forkCount) forks") + + var resourceForkData: Data? + var fileHandle: FileHandle? + var filePath: URL? + var fileDataForkSize: Int = 0 + + defer { + try? fileHandle?.close() + } + + // Process each fork + for _ in 0.. 0 ? Double(totalBytesNow) / Double(self.transferTotal) : 0.0 + let overallProgress = min(rawProgress, 1.0) // Clamp to 1.0 to avoid exceeding 100% + + // Update folder-level Finder progress + self.folderProgress?.completedUnitCount = Int64(totalBytesNow) + + // Calculate overall folder time estimate based on current speed + let remainingBytes = max(0, self.transferTotal - totalBytesNow) + let estimate: TimeInterval? = if let speed = p.bytesPerSecond, speed > 0, remainingBytes > 0 { + TimeInterval(remainingBytes) / speed + } else { + nil + } + + // Report overall folder progress to UI + progressHandler?(.transfer( + name: fileName, + size: totalBytesNow, + total: self.transferTotal, + progress: overallProgress, + speed: p.bytesPerSecond, + estimate: estimate + )) + } + bytesRead += fileDataForkSize + + } else if forkHeader.isResourceFork { + // Read RESOURCE fork + resourceForkData = try await socket.read(Int(forkHeader.dataSize)) + bytesRead += Int(forkHeader.dataSize) + + // Update folder progress for RESOURCE fork + let totalBytesNow = totalBytesTransferredSoFar + bytesRead + self.folderProgress?.completedUnitCount = Int64(totalBytesNow) + + } else { + // Skip unsupported fork + try await socket.skip(Int(forkHeader.dataSize)) + bytesRead += Int(forkHeader.dataSize) + + // Update folder progress for skipped fork + let totalBytesNow = totalBytesTransferredSoFar + bytesRead + self.folderProgress?.completedUnitCount = Int64(totalBytesNow) + } + } + + // Close file handle + try? fileHandle?.close() + fileHandle = nil + + guard let finalPath = filePath else { + throw HotlineTransferClientError.failedToTransfer + } + + // Write resource fork if present + if let rsrcData = resourceForkData, !rsrcData.isEmpty { + try writeResourceFork(data: rsrcData, to: finalPath) + } + + return (finalPath, bytesRead) + } + + private func writeResourceFork(data: Data, to url: URL) throws { + var resolvedURL = url + resolvedURL.resolveSymlinksInPath() + + let resourceURL = resolvedURL.urlForResourceFork() + try data.write(to: resourceURL) + } +} diff --git a/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift deleted file mode 100644 index 600b9f2..0000000 --- a/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift +++ /dev/null @@ -1,453 +0,0 @@ -import Foundation -import Network - -/// Item progress callback for folder downloads -public struct HotlineFolderItemProgress: Sendable { - public let fileName: String - public let itemNumber: Int - public let totalItems: Int -} - -@MainActor -public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient { - private let serverAddress: String - private let serverPort: UInt16 - private let referenceNumber: UInt32 - - private let transferTotal: Int - private let folderItemCount: Int - private var transferSize: Int = 0 - - private var socket: NetSocket? - private var downloadTask: Task? - private var folderProgress: Progress? - - // MARK: - Initialization - - public init( - address: String, - port: UInt16, - reference: UInt32, - size: UInt32, - itemCount: Int - ) { - self.serverAddress = address - self.serverPort = port - self.referenceNumber = reference - self.transferTotal = Int(size) - self.folderItemCount = itemCount - - print("HotlineFolderDownloadClientNew[\(reference)]: Server reported transferSize=\(size) bytes, folderItemCount=\(itemCount) items") - } - - // MARK: - Public API - - public func download( - to location: HotlineDownloadLocation, - progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil, - itemProgress itemProgressHandler: (@Sendable (HotlineFolderItemProgress) -> Void)? = nil - ) async throws -> URL { - self.downloadTask?.cancel() - - let task = Task { - try await performDownload( - to: location, - progressHandler: progressHandler, - itemProgressHandler: itemProgressHandler - ) - } - self.downloadTask = task - - do { - let url = try await task.value - self.downloadTask = nil - return url - } catch { - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Failed to download folder: \(error)") - self.downloadTask = nil - progressHandler?(.error(error)) - throw error - } - } - - /// Cancel the current download - public func cancel() { - downloadTask?.cancel() - downloadTask = nil - - if let socket = socket { - Task { - await socket.close() - } - } - } - - // MARK: - Private Implementation - - private func performDownload( - to destination: HotlineDownloadLocation, - progressHandler: (@Sendable (HotlineTransferProgress) -> Void)?, - itemProgressHandler: (@Sendable (HotlineFolderItemProgress) -> Void)? - ) async throws -> URL { - - var destinationFilename: String - - progressHandler?(.connecting) - - // Connect to transfer server - let socket = try await connectToTransferServer() - self.socket = socket - defer { Task { await socket.close() } } - - // Determine destination folder URL - let fm = FileManager.default - let destinationURL: URL - - switch destination { - case .url(let url): - destinationURL = url - destinationFilename = url.lastPathComponent - case .downloads(let filename): - let downloadsURL = fm.urls(for: .downloadsDirectory, in: .userDomainMask)[0] - destinationURL = URL(filePath: downloadsURL.generateUniqueFilePath(filename: filename)) - destinationFilename = destinationURL.lastPathComponent - } - - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Downloading folder to \(destinationURL.path)") - - // Create destination folder - try? fm.removeItem(at: destinationURL) - try fm.createDirectory(at: destinationURL, withIntermediateDirectories: true) - - // Create and publish progress for the entire folder (shows in Finder) - let progress = Progress(totalUnitCount: Int64(self.transferTotal)) - progress.fileURL = destinationURL - progress.fileOperationKind = .downloading - progress.publish() - self.folderProgress = progress - - // Send initial magic header - print("HotlineFolderDownloadClientNew[\(self.referenceNumber)]: Sending HTXF magic") - try await socket.write(Data(endian: .big) { - "HTXF".fourCharCode() - self.referenceNumber - UInt32.zero // data size = 0 - UInt16(1) // type = 1 (folder transfer) - UInt16.zero // reserved = 0 - HotlineFolderAction.nextFile.rawValue // action = 3 (next file) - }) - - progressHandler?(.connected) - progressHandler?(.transfer(name: destinationFilename, size: 0, total: self.transferTotal, progress: 0.0, speed: nil, estimate: nil)) - - var completedItemCount = 0 - var totalBytesTransferred = 0 - - // Process each item in the folder - while completedItemCount < self.folderItemCount { - // Read item header - let headerLenData = try await socket.read(2) - let headerLen = Int(headerLenData.readUInt16(at: 0)!) - let headerData = try await socket.read(headerLen) - - totalBytesTransferred += 2 + headerLen - - guard let (itemType, pathComponents) = self.parseItemHeaderPath(headerData) else { - throw HotlineTransferClientError.failedToTransfer - } - - let joinedPath = pathComponents.joined(separator: "/") - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Item type=\(itemType) path=\(joinedPath)") - - if itemType == 1 { - // Folder entry - no progress shown for folder creation - if !pathComponents.isEmpty { - let folderURL = destinationURL.appendingPathComponents(pathComponents) - try fm.createDirectory(at: folderURL, withIntermediateDirectories: true) - print("HotlineFolderDownloadClientNew[\(self.referenceNumber)]: Created folder at \(folderURL.path)") - } - - completedItemCount += 1 - - // Request next item if not done - if completedItemCount < folderItemCount { - try await sendAction(socket: socket, action: .nextFile) // nextFile - } - - } else if itemType == 0 { - // File entry - let parentComponents = pathComponents.dropLast() - let fileName = pathComponents.last ?? "untitled" - - // Request file download - try await sendAction(socket: socket, action: .sendFile) // sendFile - - // Read file size - let fileSizeData = try await socket.read(4) - let fileSize = fileSizeData.readUInt32(at: 0)! - totalBytesTransferred += 4 - - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: File '\(fileName)' size: \(fileSize) bytes") - - // Notify item progress before download starts - completedItemCount += 1 - itemProgressHandler?(HotlineFolderItemProgress( - fileName: fileName, - itemNumber: completedItemCount, - totalItems: folderItemCount - )) - - // Download the file with overall folder progress tracking - let (fileURL, fileBytesRead) = try await downloadFile( - socket: socket, - fileName: fileName, - parentPath: Array(parentComponents), - destinationFolder: destinationURL, - fileSize: fileSize, - itemNumber: completedItemCount, - totalItems: folderItemCount, - totalBytesTransferredSoFar: totalBytesTransferred, - progressHandler: progressHandler - ) - - totalBytesTransferred += fileBytesRead - self.transferSize = totalBytesTransferred - - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Downloaded file to \(fileURL.path)") - - // Request next item if not done - if completedItemCount < folderItemCount { - try await sendAction(socket: socket, action: .nextFile) // nextFile - } - - } else { - // Unknown item type - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Unknown item type \(itemType), skipping") - completedItemCount += 1 - - if completedItemCount < folderItemCount { - try await sendAction(socket: socket, action: .nextFile) // nextFile - } - } - } - - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Download complete!") - - // Ensure folder progress shows 100% complete - self.folderProgress?.completedUnitCount = Int64(self.transferTotal) - - progressHandler?(.completed(url: destinationURL)) - - return destinationURL - } - - // MARK: - Helper Methods - - private func connectToTransferServer() async throws -> NetSocket { - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)") - - let socket = try await NetSocket.connect( - host: self.serverAddress, - port: self.serverPort + 1 - ) - - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Connected!") - return socket - } - - private func sendAction(socket: NetSocket, action: HotlineFolderAction) async throws { - let actionData = Data(endian: .big) { - action.rawValue - } - try await socket.write(actionData) - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Sent action: \(action)") - } - - private func parseItemHeaderPath(_ headerData: Data) -> (type: UInt16, components: [String])? { - // Need at least: type(2) + count(2) - guard headerData.count >= 4, - let type = headerData.readUInt16(at: 0), - let count = headerData.readUInt16(at: 2) else { return nil } - - var ofs = 4 - var comps: [String] = [] - for _ in 0..= ofs + 3 else { return nil } - // per Hotline path encoding: reserved(2) then nameLen(1) then name - ofs += 2 // reserved == 0 - let nameLen = Int(headerData.readUInt8(at: ofs)!) - ofs += 1 - guard headerData.count >= ofs + nameLen else { return nil } - let nameData = headerData.subdata(in: ofs..<(ofs + nameLen)) - ofs += nameLen - - let name = String(data: nameData, encoding: .macOSRoman) - ?? String(data: nameData, encoding: .utf8) - ?? "" - comps.append(name) - } - return (type, comps) - } - - private func downloadFile( - socket: NetSocket, - fileName: String, - parentPath: [String], - destinationFolder: URL, - fileSize: UInt32, - itemNumber: Int, - totalItems: Int, - totalBytesTransferredSoFar: Int, - progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? - ) async throws -> (url: URL, bytesRead: Int) { - let fm = FileManager.default - var bytesRead = 0 - - // Read file header - let headerData = try await socket.read(HotlineFileHeader.DataSize) - guard let header = HotlineFileHeader(from: headerData) else { - throw HotlineTransferClientError.failedToTransfer - } - bytesRead += HotlineFileHeader.DataSize - - // Update folder progress for file header - let totalBytesNow = totalBytesTransferredSoFar + bytesRead - self.folderProgress?.completedUnitCount = Int64(totalBytesNow) - - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: File has \(header.forkCount) forks") - - var resourceForkData: Data? - var fileHandle: FileHandle? - var filePath: URL? - var fileDataForkSize: Int = 0 - - defer { - try? fileHandle?.close() - } - - // Process each fork - for _ in 0.. 0 ? Double(totalBytesNow) / Double(self.transferTotal) : 0.0 - let overallProgress = min(rawProgress, 1.0) // Clamp to 1.0 to avoid exceeding 100% - - // Update folder-level Finder progress - self.folderProgress?.completedUnitCount = Int64(totalBytesNow) - - // Calculate overall folder time estimate based on current speed - let remainingBytes = max(0, self.transferTotal - totalBytesNow) - let estimate: TimeInterval? = if let speed = p.bytesPerSecond, speed > 0, remainingBytes > 0 { - TimeInterval(remainingBytes) / speed - } else { - nil - } - - // Report overall folder progress to UI - progressHandler?(.transfer( - name: fileName, - size: totalBytesNow, - total: self.transferTotal, - progress: overallProgress, - speed: p.bytesPerSecond, - estimate: estimate - )) - } - bytesRead += fileDataForkSize - - } else if forkHeader.isResourceFork { - // Read RESOURCE fork - resourceForkData = try await socket.read(Int(forkHeader.dataSize)) - bytesRead += Int(forkHeader.dataSize) - - // Update folder progress for RESOURCE fork - let totalBytesNow = totalBytesTransferredSoFar + bytesRead - self.folderProgress?.completedUnitCount = Int64(totalBytesNow) - - } else { - // Skip unsupported fork - try await socket.skip(Int(forkHeader.dataSize)) - bytesRead += Int(forkHeader.dataSize) - - // Update folder progress for skipped fork - let totalBytesNow = totalBytesTransferredSoFar + bytesRead - self.folderProgress?.completedUnitCount = Int64(totalBytesNow) - } - } - - // Close file handle - try? fileHandle?.close() - fileHandle = nil - - guard let finalPath = filePath else { - throw HotlineTransferClientError.failedToTransfer - } - - // Write resource fork if present - if let rsrcData = resourceForkData, !rsrcData.isEmpty { - try writeResourceFork(data: rsrcData, to: finalPath) - } - - return (finalPath, bytesRead) - } - - private func writeResourceFork(data: Data, to url: URL) throws { - var resolvedURL = url - resolvedURL.resolveSymlinksInPath() - - let resourceURL = resolvedURL.urlForResourceFork() - try data.write(to: resourceURL) - } -} diff --git a/Hotline/Hotline/Transfers/HotlineFolderUploadClient.swift b/Hotline/Hotline/Transfers/HotlineFolderUploadClient.swift new file mode 100644 index 0000000..1947de6 --- /dev/null +++ b/Hotline/Hotline/Transfers/HotlineFolderUploadClient.swift @@ -0,0 +1,512 @@ +import Foundation +import Network + +/// Item progress callback for folder uploads +public struct HotlineFolderItemUploadProgress: Sendable { + public let fileName: String + public let itemNumber: Int + public let totalItems: Int +} + +/// Represents a file or folder in the upload queue +private struct FolderItem { + let url: URL + let pathComponents: [String] // Path relative to upload root + let isFolder: Bool +} + +@MainActor +public class HotlineFolderUploadClient: @MainActor HotlineTransferClient { + public struct Configuration: Sendable { + public var chunkSize: Int = 256 * 1024 + public init() {} + } + + private let serverAddress: String + private let serverPort: UInt16 + private let referenceNumber: UInt32 + private let folderURL: URL + + private let config: Configuration + + private var transferTotal: Int = 0 + private var transferSize: Int = 0 + private var folderItems: [FolderItem] = [] + private var totalItems: Int = 0 + + private var socket: NetSocket? + private var uploadTask: Task? + + public init?( + folderURL: URL, + address: String, + port: UInt16, + reference: UInt32, + configuration: Configuration = .init() + ) { + guard FileManager.default.fileExists(atPath: folderURL.path(percentEncoded: false)) else { + return nil + } + + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: folderURL.path(percentEncoded: false), isDirectory: &isDirectory), + isDirectory.boolValue else { + return nil + } + + self.serverAddress = address + self.serverPort = port + self.referenceNumber = reference + self.folderURL = folderURL + self.config = configuration + + print("HotlineFolderUploadClientNew[\(reference)]: Preparing to upload folder '\(folderURL.lastPathComponent)'") + } + + // MARK: - API + + public func upload( + progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil, + itemProgress itemProgressHandler: (@Sendable (HotlineFolderItemUploadProgress) -> Void)? = nil + ) async throws { + self.uploadTask?.cancel() + + let task = Task { + try await performUpload( + progressHandler: progressHandler, + itemProgressHandler: itemProgressHandler + ) + } + self.uploadTask = task + + do { + try await task.value + self.uploadTask = nil + } catch { + print("HotlineFolderUploadClientNew[\(referenceNumber)]: Failed to upload folder: \(error)") + self.uploadTask = nil + progressHandler?(.error(error)) + throw error + } + } + + /// Cancel the current upload + public func cancel() { + uploadTask?.cancel() + uploadTask = nil + + if let socket = socket { + Task { + await socket.close() + } + } + } + + // MARK: - Implementation + + private enum UploadStage { + case waitingForNextFile // Waiting for server to send .nextFile action + case sendingItemHeader // Sending item header to server + case waitingForFileAction // Waiting for server action after file header (.sendFile, .nextFile, .resumeFile) + case uploadingFile // Uploading file data + case done // All items uploaded + } + + private func performUpload( + progressHandler: (@Sendable (HotlineTransferProgress) -> Void)?, + itemProgressHandler: (@Sendable (HotlineFolderItemUploadProgress) -> Void)? + ) async throws { + + // Note that we're preparing now. + progressHandler?(.preparing) + + // Start accessing security-scoped resource + let didStartAccess = folderURL.startAccessingSecurityScopedResource() + defer { + if didStartAccess { + self.folderURL.stopAccessingSecurityScopedResource() + } + } + + // Build folder hierarchy (excluding root folder itself) + try buildFolderHierarchy() + + // Fast path if this is an empty folder + if self.totalItems == 0 { + progressHandler?(.completed(url: nil)) + return + } + + // Note that we're connecting now. + progressHandler?(.connecting) + + // Connect to transfer server + let socket = try await NetSocket.connect( + host: self.serverAddress, + port: self.serverPort + 1 + ) + + self.socket = socket + defer { Task { await socket.close() } } + + // Send magic header for folder upload + try await socket.write(Data(endian: .big) { + "HTXF".fourCharCode() + self.referenceNumber + UInt32.zero // data size = 0 + UInt16(1) // type = 1 (folder transfer) + UInt16.zero // reserved = 0 + }) + + progressHandler?(.connected) + + var completedItemCount = 0 + var totalBytesTransferred = 0 + var itemIndex = 0 + var stage: UploadStage = .waitingForNextFile + var currentItem: FolderItem? + + // State machine loop + while stage != .done { + switch stage { + + case .waitingForNextFile: + // Wait for server to send .nextFile action + let action = try await self.readAction(socket: socket) + guard action == .nextFile else { + throw HotlineTransferClientError.failedToTransfer + } + + // Check if we have more items to send + if itemIndex < self.folderItems.count { + currentItem = self.folderItems[itemIndex] + itemIndex += 1 + stage = .sendingItemHeader + } else { + // No more items + stage = .done + } + + case .sendingItemHeader: + // Send item header to server + guard let item = currentItem else { + throw HotlineTransferClientError.failedToTransfer + } + + // Encode and send item header + totalBytesTransferred += try await socket.write(self.encodeItemHeader(item: item)) + + // Next: wait for server's response + if item.isFolder { + // For folders, we're done with this item (just creating the directory) + completedItemCount += 1 + // Server should immediately respond with .nextFile + stage = .waitingForNextFile + } else { + // For files, server will tell us what to do + stage = .waitingForFileAction + } + + case .waitingForFileAction: + // Wait for server action after file header (.sendFile, .nextFile, .resumeFile) + guard currentItem != nil else { + throw HotlineTransferClientError.failedToTransfer + } + + let action = try await self.readAction(socket: socket) + switch action { + case .nextFile: + // Server wants to skip this file + completedItemCount += 1 + // The .nextFile action means send next item, check if we have more + if itemIndex < self.folderItems.count { + currentItem = self.folderItems[itemIndex] + itemIndex += 1 + stage = .sendingItemHeader + } else { + stage = .done + } + + case .sendFile: + // Server wants the file + completedItemCount += 1 + stage = .uploadingFile + + case .resumeFile: + // Server wants to resume + let resumeSizeData = try await socket.read(2) + let resumeSize = Int(resumeSizeData.readUInt16(at: 0)!) + let _ = try await socket.read(resumeSize) + completedItemCount += 1 + stage = .uploadingFile + } + + case .uploadingFile: + // Upload file data + guard let item = currentItem else { + throw HotlineTransferClientError.failedToTransfer + } + + // Notify item progress + itemProgressHandler?(HotlineFolderItemUploadProgress( + fileName: item.url.lastPathComponent, + itemNumber: completedItemCount, + totalItems: self.totalItems + )) + + // Upload the file + let bytesUploaded = try await self.uploadFile( + socket: socket, + fileURL: item.url, + itemNumber: completedItemCount, + totalItems: self.totalItems, + totalBytesTransferredSoFar: totalBytesTransferred, + progressHandler: progressHandler + ) + + totalBytesTransferred += bytesUploaded + self.transferSize = totalBytesTransferred + + // After uploading, wait for server to send .nextFile + stage = .waitingForNextFile + + case .done: + break + } + } + + // All items processed + progressHandler?(.completed(url: nil)) + } + + private func buildFolderHierarchy() throws { + let fm = FileManager.default + folderItems = [] + transferTotal = 0 + + let rootFolderName = folderURL.lastPathComponent + + // Recursively walk the folder + func walkFolder(at url: URL, relativePath: [String]) throws { + let contents = try fm.contentsOfDirectory(at: url, includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey], options: [.skipsHiddenFiles]) + + for itemURL in contents { + let resourceValues = try itemURL.resourceValues(forKeys: [.isDirectoryKey]) + let isDirectory = resourceValues.isDirectory ?? false + let itemName = itemURL.lastPathComponent + let itemPath = relativePath + [itemName] + + if isDirectory { + // Add folder to list + folderItems.append(FolderItem(url: itemURL, pathComponents: itemPath, isFolder: true)) + + // Recurse into subfolder + try walkFolder(at: itemURL, relativePath: itemPath) + + } else { + // Add file to list and calculate size + if let fileSize = FileManager.default.getFlattenedFileSize(itemURL) { + folderItems.append(FolderItem(url: itemURL, pathComponents: itemPath, isFolder: false)) + transferTotal += Int(fileSize) + } + } + } + } + + // Start from root folder with root name as first path component + try walkFolder(at: folderURL, relativePath: [rootFolderName]) + totalItems = folderItems.count + + print("BUILD HEIRARCHY (\(folderItems.count) items):\n", folderItems) + } + + private func encodeItemHeader(item: FolderItem) -> Data { + let strippedPath = item.pathComponents.count > 1 ? Array(item.pathComponents.dropFirst()) : item.pathComponents + let strippedPathCount = strippedPath.count + + // Build path components (Hotline format: reserved(2) + nameLen(1) + name) + var pathData = Data() + for component in strippedPath { + let nameData = component.data(using: .macOSRoman) ?? component.data(using: .utf8) ?? Data() + let nameLen = min(nameData.count, 255) + + pathData.append(contentsOf: [0, 0]) // reserved + pathData.append(UInt8(nameLen)) + pathData.append(nameData.prefix(nameLen)) + } + + // Calculate header size (this is what goes in the DataSize field) + // DataSize = isFolder(2) + pathCount(2) + pathData + let headerSize = 2 + 2 + pathData.count + + return Data(endian: .big) { + UInt16(headerSize) + UInt16(item.isFolder ? 1 : 0) + UInt16(strippedPathCount) + pathData + } + } + + private func readAction(socket: NetSocket) async throws -> HotlineFolderAction { + let actionData = try await socket.read(2) + guard let rawAction = actionData.readUInt16(at: 0), + let action = HotlineFolderAction(rawValue: rawAction) else { + throw HotlineTransferClientError.failedToTransfer + } + return action + } + + private func uploadFile( + socket: NetSocket, + fileURL: URL, + itemNumber: Int, + totalItems: Int, + totalBytesTransferredSoFar: Int, + progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? + ) async throws -> Int { + var bytesUploaded = 0 + let filename = fileURL.lastPathComponent + + // Get file metadata + guard let infoFork = HotlineFileInfoFork(file: fileURL) else { + throw HotlineTransferClientError.failedToTransfer + } + + guard let header = HotlineFileHeader(file: fileURL) else { + throw HotlineTransferClientError.failedToTransfer + } + + guard let forkSizes = try? FileManager.default.getFileForkSizes(fileURL) else { + throw HotlineTransferClientError.failedToTransfer + } + + let infoForkData = infoFork.data() + let dataForkSize = forkSizes.dataForkSize + let resourceForkSize = forkSizes.resourceForkSize + + // Calculate total flattened file size + guard let flattenedSize = FileManager.default.getFlattenedFileSize(fileURL) else { + throw HotlineTransferClientError.failedToTransfer + } + let totalFileSize = Int(flattenedSize) + + // Send file size + let fileSizeData = Data(endian: .big) { + UInt32(totalFileSize) + } + try await socket.write(fileSizeData) + bytesUploaded += 4 + + // Send file header + let headerData = header.data() + try await socket.write(headerData) + bytesUploaded += headerData.count + + // Send INFO fork header + let infoForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.info.rawValue, dataSize: UInt32(infoForkData.count)) + try await socket.write(infoForkHeader.data()) + bytesUploaded += HotlineFileForkHeader.DataSize + + // Send INFO fork data + try await socket.write(infoForkData) + bytesUploaded += infoForkData.count + + // Create per-file progress for Finder + let fileProgress = Progress(totalUnitCount: Int64(totalFileSize)) + fileProgress.fileURL = fileURL.resolvingSymlinksInPath() + fileProgress.fileOperationKind = Progress.FileOperationKind.uploading + fileProgress.publish() + + defer { + fileProgress.unpublish() + } + + // Send DATA fork if present + let dataForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.data.rawValue, dataSize: dataForkSize) + try await socket.write(dataForkHeader.data()) + bytesUploaded += HotlineFileForkHeader.DataSize + + if dataForkSize > 0 { + // Stream DATA fork + let fileHandle = try FileHandle(forReadingFrom: fileURL) + defer { try? fileHandle.close() } + + let updates = await socket.writeFile(from: fileHandle, length: Int(dataForkSize)) + for try await p in updates { + // Update per-file Finder progress + fileProgress.completedUnitCount = Int64(bytesUploaded + p.sent) + + // Calculate overall folder progress + let totalBytesNow = totalBytesTransferredSoFar + bytesUploaded + p.sent + let rawProgress = self.transferTotal > 0 ? Double(totalBytesNow) / Double(self.transferTotal) : 0.0 + let overallProgress = min(rawProgress, 1.0) + + // Calculate overall time estimate + let remainingBytes = max(0, self.transferTotal - totalBytesNow) + let estimate: TimeInterval? = if let speed = p.bytesPerSecond, speed > 0, remainingBytes > 0 { + TimeInterval(remainingBytes) / speed + } else { + nil + } + + // Report overall folder progress + progressHandler?(.transfer( + name: filename, + size: totalBytesNow, + total: self.transferTotal, + progress: overallProgress, + speed: p.bytesPerSecond, + estimate: estimate + )) + } + + bytesUploaded += Int(dataForkSize) + } + + // Send RESOURCE fork if present + if resourceForkSize > 0 { + let resourceURL = fileURL.urlForResourceFork() + + let resourceForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.resource.rawValue, dataSize: resourceForkSize) + try await socket.write(resourceForkHeader.data()) + bytesUploaded += HotlineFileForkHeader.DataSize + + // Stream RESOURCE fork + let resourceHandle = try FileHandle(forReadingFrom: resourceURL) + defer { try? resourceHandle.close() } + + let updates = await socket.writeFile(from: resourceHandle, length: Int(resourceForkSize)) + for try await p in updates { + // Update per-file Finder progress + fileProgress.completedUnitCount = Int64(bytesUploaded + p.sent) + + // Calculate overall folder progress + let totalBytesNow = totalBytesTransferredSoFar + bytesUploaded + p.sent + let rawProgress = self.transferTotal > 0 ? Double(totalBytesNow) / Double(self.transferTotal) : 0.0 + let overallProgress = min(rawProgress, 1.0) + + // Calculate overall time estimate + let remainingBytes = max(0, self.transferTotal - totalBytesNow) + let estimate: TimeInterval? = if let speed = p.bytesPerSecond, speed > 0, remainingBytes > 0 { + TimeInterval(remainingBytes) / speed + } else { + nil + } + + // Report overall folder progress + progressHandler?(.transfer( + name: filename, + size: totalBytesNow, + total: self.transferTotal, + progress: overallProgress, + speed: p.bytesPerSecond, + estimate: estimate + )) + } + + bytesUploaded += Int(resourceForkSize) + } + + return bytesUploaded + } +} diff --git a/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift deleted file mode 100644 index 15636f3..0000000 --- a/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift +++ /dev/null @@ -1,518 +0,0 @@ -import Foundation -import Network - -/// Item progress callback for folder uploads -public struct HotlineFolderItemUploadProgress: Sendable { - public let fileName: String - public let itemNumber: Int - public let totalItems: Int -} - -/// Represents a file or folder in the upload queue -private struct FolderItem { - let url: URL - let pathComponents: [String] // Path relative to upload root - let isFolder: Bool -} - -@MainActor -public class HotlineFolderUploadClientNew: @MainActor HotlineTransferClient { - // MARK: - Configuration - - public struct Configuration: Sendable { - public var chunkSize: Int = 256 * 1024 - public init() {} - } - - // MARK: - Properties - - private let serverAddress: String - private let serverPort: UInt16 - private let referenceNumber: UInt32 - private let folderURL: URL - - private let config: Configuration - - private var transferTotal: Int = 0 - private var transferSize: Int = 0 - private var folderItems: [FolderItem] = [] - private var totalItems: Int = 0 - - private var socket: NetSocket? - private var uploadTask: Task? - - // MARK: - Initialization - - public init?( - folderURL: URL, - address: String, - port: UInt16, - reference: UInt32, - configuration: Configuration = .init() - ) { - guard FileManager.default.fileExists(atPath: folderURL.path(percentEncoded: false)) else { - return nil - } - - var isDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: folderURL.path(percentEncoded: false), isDirectory: &isDirectory), - isDirectory.boolValue else { - return nil - } - - self.serverAddress = address - self.serverPort = port - self.referenceNumber = reference - self.folderURL = folderURL - self.config = configuration - - print("HotlineFolderUploadClientNew[\(reference)]: Preparing to upload folder '\(folderURL.lastPathComponent)'") - } - - // MARK: - API - - public func upload( - progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil, - itemProgress itemProgressHandler: (@Sendable (HotlineFolderItemUploadProgress) -> Void)? = nil - ) async throws { - self.uploadTask?.cancel() - - let task = Task { - try await performUpload( - progressHandler: progressHandler, - itemProgressHandler: itemProgressHandler - ) - } - self.uploadTask = task - - do { - try await task.value - self.uploadTask = nil - } catch { - print("HotlineFolderUploadClientNew[\(referenceNumber)]: Failed to upload folder: \(error)") - self.uploadTask = nil - progressHandler?(.error(error)) - throw error - } - } - - /// Cancel the current upload - public func cancel() { - uploadTask?.cancel() - uploadTask = nil - - if let socket = socket { - Task { - await socket.close() - } - } - } - - // MARK: - - - private enum UploadStage { - case waitingForNextFile // Waiting for server to send .nextFile action - case sendingItemHeader // Sending item header to server - case waitingForFileAction // Waiting for server action after file header (.sendFile, .nextFile, .resumeFile) - case uploadingFile // Uploading file data - case done // All items uploaded - } - - private func performUpload( - progressHandler: (@Sendable (HotlineTransferProgress) -> Void)?, - itemProgressHandler: (@Sendable (HotlineFolderItemUploadProgress) -> Void)? - ) async throws { - - // Note that we're preparing now. - progressHandler?(.preparing) - - // Start accessing security-scoped resource - let didStartAccess = folderURL.startAccessingSecurityScopedResource() - defer { - if didStartAccess { - self.folderURL.stopAccessingSecurityScopedResource() - } - } - - // Build folder hierarchy (excluding root folder itself) - try buildFolderHierarchy() - - // Fast path if this is an empty folder - if self.totalItems == 0 { - progressHandler?(.completed(url: nil)) - return - } - - // Note that we're connecting now. - progressHandler?(.connecting) - - // Connect to transfer server - let socket = try await NetSocket.connect( - host: self.serverAddress, - port: self.serverPort + 1 - ) - - self.socket = socket - defer { Task { await socket.close() } } - - // Send magic header for folder upload - try await socket.write(Data(endian: .big) { - "HTXF".fourCharCode() - self.referenceNumber - UInt32.zero // data size = 0 - UInt16(1) // type = 1 (folder transfer) - UInt16.zero // reserved = 0 - }) - - progressHandler?(.connected) - - var completedItemCount = 0 - var totalBytesTransferred = 0 - var itemIndex = 0 - var stage: UploadStage = .waitingForNextFile - var currentItem: FolderItem? - - // State machine loop - while stage != .done { - switch stage { - - case .waitingForNextFile: - // Wait for server to send .nextFile action - let action = try await self.readAction(socket: socket) - guard action == .nextFile else { - throw HotlineTransferClientError.failedToTransfer - } - - // Check if we have more items to send - if itemIndex < self.folderItems.count { - currentItem = self.folderItems[itemIndex] - itemIndex += 1 - stage = .sendingItemHeader - } else { - // No more items - stage = .done - } - - case .sendingItemHeader: - // Send item header to server - guard let item = currentItem else { - throw HotlineTransferClientError.failedToTransfer - } - - // Encode and send item header - totalBytesTransferred += try await socket.write(self.encodeItemHeader(item: item)) - - // Next: wait for server's response - if item.isFolder { - // For folders, we're done with this item (just creating the directory) - completedItemCount += 1 - // Server should immediately respond with .nextFile - stage = .waitingForNextFile - } else { - // For files, server will tell us what to do - stage = .waitingForFileAction - } - - case .waitingForFileAction: - // Wait for server action after file header (.sendFile, .nextFile, .resumeFile) - guard currentItem != nil else { - throw HotlineTransferClientError.failedToTransfer - } - - let action = try await self.readAction(socket: socket) - switch action { - case .nextFile: - // Server wants to skip this file - completedItemCount += 1 - // The .nextFile action means send next item, check if we have more - if itemIndex < self.folderItems.count { - currentItem = self.folderItems[itemIndex] - itemIndex += 1 - stage = .sendingItemHeader - } else { - stage = .done - } - - case .sendFile: - // Server wants the file - completedItemCount += 1 - stage = .uploadingFile - - case .resumeFile: - // Server wants to resume - let resumeSizeData = try await socket.read(2) - let resumeSize = Int(resumeSizeData.readUInt16(at: 0)!) - let _ = try await socket.read(resumeSize) - completedItemCount += 1 - stage = .uploadingFile - } - - case .uploadingFile: - // Upload file data - guard let item = currentItem else { - throw HotlineTransferClientError.failedToTransfer - } - - // Notify item progress - itemProgressHandler?(HotlineFolderItemUploadProgress( - fileName: item.url.lastPathComponent, - itemNumber: completedItemCount, - totalItems: self.totalItems - )) - - // Upload the file - let bytesUploaded = try await self.uploadFile( - socket: socket, - fileURL: item.url, - itemNumber: completedItemCount, - totalItems: self.totalItems, - totalBytesTransferredSoFar: totalBytesTransferred, - progressHandler: progressHandler - ) - - totalBytesTransferred += bytesUploaded - self.transferSize = totalBytesTransferred - - // After uploading, wait for server to send .nextFile - stage = .waitingForNextFile - - case .done: - break - } - } - - // All items processed - progressHandler?(.completed(url: nil)) - } - - private func buildFolderHierarchy() throws { - let fm = FileManager.default - folderItems = [] - transferTotal = 0 - - let rootFolderName = folderURL.lastPathComponent - - // Recursively walk the folder - func walkFolder(at url: URL, relativePath: [String]) throws { - let contents = try fm.contentsOfDirectory(at: url, includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey], options: [.skipsHiddenFiles]) - - for itemURL in contents { - let resourceValues = try itemURL.resourceValues(forKeys: [.isDirectoryKey]) - let isDirectory = resourceValues.isDirectory ?? false - let itemName = itemURL.lastPathComponent - let itemPath = relativePath + [itemName] - - if isDirectory { - // Add folder to list - folderItems.append(FolderItem(url: itemURL, pathComponents: itemPath, isFolder: true)) - - // Recurse into subfolder - try walkFolder(at: itemURL, relativePath: itemPath) - - } else { - // Add file to list and calculate size - if let fileSize = FileManager.default.getFlattenedFileSize(itemURL) { - folderItems.append(FolderItem(url: itemURL, pathComponents: itemPath, isFolder: false)) - transferTotal += Int(fileSize) - } - } - } - } - - // Start from root folder with root name as first path component - try walkFolder(at: folderURL, relativePath: [rootFolderName]) - totalItems = folderItems.count - - print("BUILD HEIRARCHY (\(folderItems.count) items):\n", folderItems) - } - - private func encodeItemHeader(item: FolderItem) -> Data { - let strippedPath = item.pathComponents.count > 1 ? Array(item.pathComponents.dropFirst()) : item.pathComponents - let strippedPathCount = strippedPath.count - - // Build path components (Hotline format: reserved(2) + nameLen(1) + name) - var pathData = Data() - for component in strippedPath { - let nameData = component.data(using: .macOSRoman) ?? component.data(using: .utf8) ?? Data() - let nameLen = min(nameData.count, 255) - - pathData.append(contentsOf: [0, 0]) // reserved - pathData.append(UInt8(nameLen)) - pathData.append(nameData.prefix(nameLen)) - } - - // Calculate header size (this is what goes in the DataSize field) - // DataSize = isFolder(2) + pathCount(2) + pathData - let headerSize = 2 + 2 + pathData.count - - return Data(endian: .big) { - UInt16(headerSize) - UInt16(item.isFolder ? 1 : 0) - UInt16(strippedPathCount) - pathData - } - } - - private func readAction(socket: NetSocket) async throws -> HotlineFolderAction { - let actionData = try await socket.read(2) - guard let rawAction = actionData.readUInt16(at: 0), - let action = HotlineFolderAction(rawValue: rawAction) else { - throw HotlineTransferClientError.failedToTransfer - } - return action - } - - private func uploadFile( - socket: NetSocket, - fileURL: URL, - itemNumber: Int, - totalItems: Int, - totalBytesTransferredSoFar: Int, - progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? - ) async throws -> Int { - var bytesUploaded = 0 - let filename = fileURL.lastPathComponent - - // Get file metadata - guard let infoFork = HotlineFileInfoFork(file: fileURL) else { - throw HotlineTransferClientError.failedToTransfer - } - - guard let header = HotlineFileHeader(file: fileURL) else { - throw HotlineTransferClientError.failedToTransfer - } - - guard let forkSizes = try? FileManager.default.getFileForkSizes(fileURL) else { - throw HotlineTransferClientError.failedToTransfer - } - - let infoForkData = infoFork.data() - let dataForkSize = forkSizes.dataForkSize - let resourceForkSize = forkSizes.resourceForkSize - - // Calculate total flattened file size - guard let flattenedSize = FileManager.default.getFlattenedFileSize(fileURL) else { - throw HotlineTransferClientError.failedToTransfer - } - let totalFileSize = Int(flattenedSize) - - // Send file size - let fileSizeData = Data(endian: .big) { - UInt32(totalFileSize) - } - try await socket.write(fileSizeData) - bytesUploaded += 4 - - // Send file header - let headerData = header.data() - try await socket.write(headerData) - bytesUploaded += headerData.count - - // Send INFO fork header - let infoForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.info.rawValue, dataSize: UInt32(infoForkData.count)) - try await socket.write(infoForkHeader.data()) - bytesUploaded += HotlineFileForkHeader.DataSize - - // Send INFO fork data - try await socket.write(infoForkData) - bytesUploaded += infoForkData.count - - // Create per-file progress for Finder - let fileProgress = Progress(totalUnitCount: Int64(totalFileSize)) - fileProgress.fileURL = fileURL.resolvingSymlinksInPath() - fileProgress.fileOperationKind = Progress.FileOperationKind.uploading - fileProgress.publish() - - defer { - fileProgress.unpublish() - } - - // Send DATA fork if present - let dataForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.data.rawValue, dataSize: dataForkSize) - try await socket.write(dataForkHeader.data()) - bytesUploaded += HotlineFileForkHeader.DataSize - - if dataForkSize > 0 { - // Stream DATA fork - let fileHandle = try FileHandle(forReadingFrom: fileURL) - defer { try? fileHandle.close() } - - let updates = await socket.writeFile(from: fileHandle, length: Int(dataForkSize)) - for try await p in updates { - // Update per-file Finder progress - fileProgress.completedUnitCount = Int64(bytesUploaded + p.sent) - - // Calculate overall folder progress - let totalBytesNow = totalBytesTransferredSoFar + bytesUploaded + p.sent - let rawProgress = self.transferTotal > 0 ? Double(totalBytesNow) / Double(self.transferTotal) : 0.0 - let overallProgress = min(rawProgress, 1.0) - - // Calculate overall time estimate - let remainingBytes = max(0, self.transferTotal - totalBytesNow) - let estimate: TimeInterval? = if let speed = p.bytesPerSecond, speed > 0, remainingBytes > 0 { - TimeInterval(remainingBytes) / speed - } else { - nil - } - - // Report overall folder progress - progressHandler?(.transfer( - name: filename, - size: totalBytesNow, - total: self.transferTotal, - progress: overallProgress, - speed: p.bytesPerSecond, - estimate: estimate - )) - } - - bytesUploaded += Int(dataForkSize) - } - - // Send RESOURCE fork if present - if resourceForkSize > 0 { - let resourceURL = fileURL.urlForResourceFork() - - let resourceForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.resource.rawValue, dataSize: resourceForkSize) - try await socket.write(resourceForkHeader.data()) - bytesUploaded += HotlineFileForkHeader.DataSize - - // Stream RESOURCE fork - let resourceHandle = try FileHandle(forReadingFrom: resourceURL) - defer { try? resourceHandle.close() } - - let updates = await socket.writeFile(from: resourceHandle, length: Int(resourceForkSize)) - for try await p in updates { - // Update per-file Finder progress - fileProgress.completedUnitCount = Int64(bytesUploaded + p.sent) - - // Calculate overall folder progress - let totalBytesNow = totalBytesTransferredSoFar + bytesUploaded + p.sent - let rawProgress = self.transferTotal > 0 ? Double(totalBytesNow) / Double(self.transferTotal) : 0.0 - let overallProgress = min(rawProgress, 1.0) - - // Calculate overall time estimate - let remainingBytes = max(0, self.transferTotal - totalBytesNow) - let estimate: TimeInterval? = if let speed = p.bytesPerSecond, speed > 0, remainingBytes > 0 { - TimeInterval(remainingBytes) / speed - } else { - nil - } - - // Report overall folder progress - progressHandler?(.transfer( - name: filename, - size: totalBytesNow, - total: self.transferTotal, - progress: overallProgress, - speed: p.bytesPerSecond, - estimate: estimate - )) - } - - bytesUploaded += Int(resourceForkSize) - } - - return bytesUploaded - } -} diff --git a/Hotline/Library/Extensions.swift b/Hotline/Library/Extensions.swift index bb28370..cf9f8ff 100644 --- a/Hotline/Library/Extensions.swift +++ b/Hotline/Library/Extensions.swift @@ -2,23 +2,85 @@ import Foundation import SwiftUI import UniformTypeIdentifiers +extension FileManager { + @discardableResult + func moveToDownloads(from sourceURL: URL, using filename: String, bounceDock: Bool = false) -> Bool { + let filePath = URL.downloadsDirectory.generateUniqueFilePath(filename: filename) + let destinationURL = URL(filePath: filePath).resolvingSymlinksInPath() + + do { + try FileManager.default.moveItem(at: sourceURL.resolvingSymlinksInPath(), to: destinationURL) + } + catch { + return false + } + + if bounceDock { + #if os(macOS) + DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: destinationURL.path) + #endif + } + + return true + } + + @discardableResult + func copyToDownloads(from sourceURL: URL, using filename: String, bounceDock: Bool = false) -> Bool { + let filePath = URL.downloadsDirectory.generateUniqueFilePath(filename: filename) + let destinationURL = URL(filePath: filePath).resolvingSymlinksInPath() + + do { + try FileManager.default.copyItem(at: sourceURL.resolvingSymlinksInPath(), to: destinationURL) + } + catch { + return false + } + + if bounceDock { + #if os(macOS) + DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: destinationURL.path) + #endif + } + + return true + } +} + +// MARK: - + +extension View { + @ViewBuilder + func applyNavigationDocumentIfPresent(_ url: URL?) -> some View { + if let url { + self.navigationDocument(url) + } else { + self + } + } +} + +// MARK: - + extension Data { func saveAsFileToDownloads(filename: String, bounceDock: Bool = true) -> Bool { - let folderURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0] - let filePath = folderURL.generateUniqueFilePath(filename: filename) - if FileManager.default.createFile(atPath: filePath, contents: nil) { - if let h = FileHandle(forWritingAtPath: filePath) { - try? h.write(contentsOf: self) - try? h.close() - if bounceDock { - #if os(macOS) - var downloadURL = URL(filePath: filePath) - downloadURL.resolveSymlinksInPath() - DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path) - #endif - } - return true + let filePath = URL.downloadsDirectory.generateUniqueFilePath(filename: filename) + + if FileManager.default.createFile(atPath: filePath, contents: self) { + if bounceDock { + #if os(macOS) + var downloadURL = URL(filePath: filePath) + downloadURL.resolveSymlinksInPath() + DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path) + #endif } + return true + +// if FileManager.default.createFile(atPath: filePath, contents: nil) { +// if let h = FileHandle(forWritingAtPath: filePath) { +// try? h.write(contentsOf: self) +// try? h.close() +// +// } } return false } diff --git a/Hotline/Models/FileInfo.swift b/Hotline/Models/FileInfo.swift index e3081c4..a2f0ea5 100644 --- a/Hotline/Models/FileInfo.swift +++ b/Hotline/Models/FileInfo.swift @@ -49,13 +49,19 @@ import UniformTypeIdentifiers var children: [FileInfo]? = nil var isPreviewable: Bool { - let fileExtension = (self.name as NSString).pathExtension.lowercased() + var fileExtension = (self.name as NSString).pathExtension.lowercased() + if fileExtension.isEmpty && !self.type.isEmpty { + let type = self.type.lowercased() + if let ext = FileManager.HFSTypeToExtension[type] { + fileExtension = ext + } + } + if let fileType = UTType(filenameExtension: fileExtension) { if fileType.canBePreviewedByQuickLook { return true } - print("FILE TYPE?", fileType, fileExtension, fileType.isSubtype(of: .pdf), fileType.isSupertype(of: .pdf)) if fileType.isSubtype(of: .image) { return true } diff --git a/Hotline/Models/PreviewFileInfo.swift b/Hotline/Models/PreviewFileInfo.swift index dcf5314..7e05a97 100644 --- a/Hotline/Models/PreviewFileInfo.swift +++ b/Hotline/Models/PreviewFileInfo.swift @@ -7,6 +7,9 @@ struct PreviewFileInfo: Identifiable, Codable { var size: Int var name: String + var type: String? = nil + var creator: String? = nil + var previewType: FilePreviewType { let fileExtension = (self.name as NSString).pathExtension if let fileType = UTType(filenameExtension: fileExtension) { diff --git a/Hotline/State/AppUpdate.swift b/Hotline/State/AppUpdate.swift index 601859e..558a296 100644 --- a/Hotline/State/AppUpdate.swift +++ b/Hotline/State/AppUpdate.swift @@ -35,8 +35,6 @@ final class AppUpdate { case manual } - // MARK: - Public State - var isChecking = false var isDownloading = false var showWindow = false @@ -55,7 +53,7 @@ final class AppUpdate { private let remindDateKey = "update.remind.date" private let lastPromptedVersionKey = "update.last.prompt.version" - // MARK: - Public API + // MARK: - API func checkForUpdatesOnLaunch() async { await checkForUpdates(trigger: .automatic) @@ -100,7 +98,7 @@ final class AppUpdate { resetAndCloseWindow() } - // MARK: - Internal Logic + // MARK: - Implementation private func checkForUpdates(trigger: CheckTrigger) async { await MainActor.run { @@ -259,8 +257,7 @@ final class AppUpdate { private func downloadRelease(_ release: UpdateReleaseInfo) async { do { let (temporaryURL, _) = try await URLSession.shared.download(from: release.downloadURL) - let downloadsDirectory = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first! - let destinationURL = downloadsDirectory.appendingPathComponent(release.assetName) + let destinationURL = URL.downloadsDirectory.appendingPathComponent(release.assetName) if FileManager.default.fileExists(atPath: destinationURL.path) { try? FileManager.default.removeItem(at: destinationURL) diff --git a/Hotline/State/FilePreviewState.swift b/Hotline/State/FilePreviewState.swift index 27f8199..ec79f66 100644 --- a/Hotline/State/FilePreviewState.swift +++ b/Hotline/State/FilePreviewState.swift @@ -1,8 +1,6 @@ import SwiftUI import UniformTypeIdentifiers -// MARK: - Preview Type - enum FilePreviewType: Equatable { case unknown case image @@ -13,13 +11,15 @@ enum FilePreviewType: Equatable { @MainActor @Observable final class FilePreviewState { - // MARK: - Properties - + enum LoadState: Equatable { + case unloaded + case loading + case loaded + case failed + } + let info: PreviewFileInfo - private var previewClient: HotlineFilePreviewClient? - private var previewTask: Task? - var state: LoadState = .unloaded var progress: Double = 0.0 @@ -33,39 +33,35 @@ final class FilePreviewState { var text: String? = nil var styledText: NSAttributedString? = nil - - // MARK: - Computed Properties + + @ObservationIgnored private var previewClient: HotlineFilePreviewClient? + @ObservationIgnored private var previewTask: Task? var previewType: FilePreviewType { - info.previewType + self.info.previewType } - // MARK: - Initialization - init(info: PreviewFileInfo) { self.info = info } - nonisolated deinit { - // Note: Can't access @MainActor properties from deinit - // Cleanup will happen when previewClient is deallocated - } - - // MARK: - Public API + // MARK: - API func download() { // Cancel any existing download - previewTask?.cancel() - previewClient?.cleanup() + self.previewTask?.cancel() + self.previewClient?.cleanup() let task = Task { @MainActor in do { let client = HotlineFilePreviewClient( - fileName: info.name, - address: info.address, - port: UInt16(info.port), - reference: info.id, - size: UInt32(info.size) + fileName: self.info.name, + address: self.info.address, + port: UInt16(self.info.port), + reference: self.info.id, + size: UInt32(self.info.size), + fileType: self.info.type, + fileCreator: self.info.creator ) self.previewClient = client @@ -132,69 +128,58 @@ final class FilePreviewState { } func cancel() { - previewTask?.cancel() - previewTask = nil - previewClient?.cancel() + self.previewTask?.cancel() + self.previewTask = nil + self.previewClient?.cancel() } func cleanup() { - previewClient?.cleanup() - previewClient = nil - fileURL = nil - image = nil - text = nil - styledText = nil - } - - // MARK: - Private Implementation - - private func loadPreview(from url: URL) { - guard let data = try? Data(contentsOf: url) else { - self.state = .failed - print("FilePreviewState: Failed to read preview data from \(url.path)") - return - } - - switch self.previewType { - case .image: - #if os(iOS) - self.image = UIImage(data: data) - #elseif os(macOS) - self.image = NSImage(data: data) - #endif - - if self.image == nil { - self.state = .failed - print("FilePreviewState: Failed to create image from data") - } - - case .text: - let encoding: UInt = NSString.stringEncoding(for: data, convertedString: nil, usedLossyConversion: nil) - if encoding != 0 { - self.text = String(data: data, encoding: String.Encoding(rawValue: encoding)) - } else { - self.text = String(data: data, encoding: .utf8) - } - - if self.text == nil { - self.state = .failed - print("FilePreviewState: Failed to decode text data") - } - - case .unknown: - print("FilePreviewState: Unknown preview type for \(info.name)") - break - } + self.previewClient?.cleanup() + self.previewClient = nil + self.fileURL = nil + self.image = nil + self.text = nil + self.styledText = nil } -} - -// MARK: - Load State -extension FilePreviewState { - enum LoadState: Equatable { - case unloaded - case loading - case loaded - case failed - } + // MARK: - Utility + +// private func loadPreview(from url: URL) { +// guard let data = try? Data(contentsOf: url) else { +// self.state = .failed +// print("FilePreviewState: Failed to read preview data from \(url.path)") +// return +// } +// +// switch self.previewType { +// case .image: +// #if os(iOS) +// self.image = UIImage(data: data) +// #elseif os(macOS) +// self.image = NSImage(data: data) +// #endif +// +// if self.image == nil { +// self.state = .failed +// print("FilePreviewState: Failed to create image from data") +// } +// +// case .text: +// let encoding: UInt = NSString.stringEncoding(for: data, convertedString: nil, usedLossyConversion: nil) +// if encoding != 0 { +// self.text = String(data: data, encoding: String.Encoding(rawValue: encoding)) +// } else { +// self.text = String(data: data, encoding: .utf8) +// } +// +// if self.text == nil { +// self.state = .failed +// print("FilePreviewState: Failed to decode text data") +// } +// +// case .unknown: +// print("FilePreviewState: Unknown preview type for \(info.name)") +// break +// } +// } } diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index e80571a..f814818 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -252,7 +252,7 @@ class HotlineState: Equatable { // MARK: - Private State - @ObservationIgnored private var client: HotlineClientNew? + @ObservationIgnored private var client: HotlineClient? @ObservationIgnored private var eventTask: Task? @ObservationIgnored private var chatSessionKey: ChatStore.SessionKey? @ObservationIgnored private var restoredChatSessionKey: ChatStore.SessionKey? @@ -308,13 +308,13 @@ class HotlineState: Equatable { iconID: UInt16(iconID) ) - print("HotlineState.login(): Calling HotlineClientNew.connect()...") - let client = try await HotlineClientNew.connect( + print("HotlineState.login(): Calling HotlineClient.connect()...") + let client = try await HotlineClient.connect( host: server.address, port: UInt16(server.port), login: loginInfo ) - print("HotlineState.login(): HotlineClientNew.connect() returned") + print("HotlineState.login(): HotlineClient.connect() returned") self.client = client print("HotlineState.login(): Client stored") @@ -865,7 +865,7 @@ class HotlineState: Equatable { /// - progressCallback: Optional callback for progress updates (receives TransferInfo and progress 0.0-1.0) /// - callback: Optional completion callback (receives TransferInfo and final file URL) @MainActor - func downloadFileNew(_ fileName: String, path: [String], to destination: URL? = nil, progress progressCallback: ((TransferInfo) -> Void)? = nil, complete callback: ((TransferInfo) -> Void)? = nil) { + func downloadFile(_ fileName: String, path: [String], to destination: URL? = nil, progress progressCallback: ((TransferInfo) -> Void)? = nil, complete callback: ((TransferInfo) -> Void)? = nil) { guard let client = self.client else { return } var fullPath: [String] = [] @@ -975,7 +975,7 @@ class HotlineState: Equatable { /// - itemProgressCallback: Optional callback for per-item updates (receives TransferInfo with current file info) /// - callback: Optional completion callback (receives TransferInfo and final folder URL) @MainActor - func downloadFolderNew( + func downloadFolder( _ folderName: String, path: [String], to destination: URL? = nil, @@ -1018,7 +1018,7 @@ class HotlineState: Equatable { AppState.shared.addTransfer(transfer) // Create download client - let downloadClient = HotlineFolderDownloadClientNew( + let downloadClient = HotlineFolderDownloadClient( address: address, port: UInt16(port), reference: referenceNumber, @@ -1091,7 +1091,7 @@ class HotlineState: Equatable { } } - /// Modern async/await folder upload using HotlineFolderUploadClientNew + /// Upload a folder to the server. /// /// - Parameters: /// - folderURL: URL to the folder on disk to upload @@ -1155,7 +1155,7 @@ class HotlineState: Equatable { print("HotlineState: Got folder upload reference: \(referenceNumber)") // Create upload client - guard let uploadClient = HotlineFolderUploadClientNew( + guard let uploadClient = HotlineFolderUploadClient( folderURL: folderURL, address: address, port: UInt16(port), @@ -1346,9 +1346,9 @@ class HotlineState: Equatable { } func setFileInfo(fileName: String, path filePath: [String], fileNewName: String?, comment: String?, encoding: String.Encoding = .utf8) { - // TODO: Implement setFileInfo in HotlineClientNew + // TODO: Implement setFileInfo in HotlineClient // This method updates file metadata (name and/or comment) - print("setFileInfo not yet implemented in HotlineState/HotlineClientNew") + print("setFileInfo not yet implemented in HotlineState/HotlineClient") } @MainActor diff --git a/Hotline/State/ServerState.swift b/Hotline/State/ServerState.swift index 805672d..5913bf5 100644 --- a/Hotline/State/ServerState.swift +++ b/Hotline/State/ServerState.swift @@ -29,7 +29,7 @@ enum ServerNavigationType: Identifiable, Hashable, Equatable { case .files: return "Files" case .accounts: - return "Admin" + return "Accounts" case .user(let userID): return String(userID) } diff --git a/Hotline/macOS/Files/FilePreviewQuickLookView.swift b/Hotline/macOS/Files/FilePreviewQuickLookView.swift index 26bd286..a504a7a 100644 --- a/Hotline/macOS/Files/FilePreviewQuickLookView.swift +++ b/Hotline/macOS/Files/FilePreviewQuickLookView.swift @@ -11,16 +11,16 @@ struct FilePreviewQuickLookView: View { @Environment(\.dismiss) private var dismiss @Binding var info: PreviewFileInfo? - @State var preview: FilePreviewState? = nil + @State private var preview: FilePreviewState? = nil @FocusState private var focusField: FilePreviewFocus? var body: some View { Group { - if preview?.state != .loaded { + if self.preview?.state != .loaded { VStack(alignment: .center, spacing: 0) { Spacer() - ProgressView(value: max(0.0, min(1.0, preview?.progress ?? 0.0))) + ProgressView(value: max(0.0, min(1.0, self.preview?.progress ?? 0.0))) .focusable(false) .progressViewStyle(.circular) .controlSize(.extraLarge) @@ -33,7 +33,7 @@ struct FilePreviewQuickLookView: View { .padding() } else { - if let fileURL = preview?.fileURL { + if let fileURL = self.preview?.fileURL { QuickLookPreviewView(fileURL: fileURL) .frame(minWidth: 400, maxWidth: .infinity, minHeight: 400, maxHeight: .infinity) } @@ -68,25 +68,15 @@ struct FilePreviewQuickLookView: View { .focusable() .focusEffectDisabled() .background(Color(nsColor: .textBackgroundColor)) - .focused($focusField, equals: .window) - .navigationTitle(info?.name ?? "File Preview") - .background { - if let fileURL = self.preview?.fileURL { - WindowConfigurator { window in - window.representedURL = fileURL - window.standardWindowButton(.documentIconButton)?.isHidden = false - } - } - } + .focused(self.$focusField, equals: .window) + .navigationTitle(self.info?.name ?? "File Preview") + .applyNavigationDocumentIfPresent(self.preview?.fileURL) .toolbar { - if let _ = preview?.fileURL { + if let fileURL = self.preview?.fileURL { if let info = info { ToolbarItem(placement: .primaryAction) { Button { - if let fileURL = preview?.fileURL, - let data = try? Data(contentsOf: fileURL) { - let _ = data.saveAsFileToDownloads(filename: info.name) - } + FileManager.default.copyToDownloads(from: fileURL, using: info.name, bounceDock: true) } label: { Label("Download File...", systemImage: "arrow.down") } @@ -96,28 +86,27 @@ struct FilePreviewQuickLookView: View { } } .task { - if let info = info { - preview = FilePreviewState(info: info) - preview?.download() + if let info = self.info { + self.preview = FilePreviewState(info: info) + self.preview?.download() } } .onAppear { - if info == nil { - Task { - dismiss() - } + guard self.info != nil else { + self.dismiss() return } - focusField = .window + self.focusField = .window } .onDisappear { - preview?.cancel() - dismiss() + self.preview?.cancel() + self.preview?.cleanup() + self.dismiss() } - .onChange(of: preview?.state) { - if preview?.state == .failed { - dismiss() + .onChange(of: self.preview?.state) { + if self.preview?.state == .failed { + self.dismiss() } } .preferredColorScheme(.dark) diff --git a/Hotline/macOS/Files/FilesView.swift b/Hotline/macOS/Files/FilesView.swift index d4ba5c8..984b120 100644 --- a/Hotline/macOS/Files/FilesView.swift +++ b/Hotline/macOS/Files/FilesView.swift @@ -376,11 +376,11 @@ struct FilesView: View { private func openPreviewWindow(_ previewInfo: PreviewFileInfo) { switch previewInfo.previewType { case .image: - openWindow(id: "preview-quicklook", value: previewInfo) + self.openWindow(id: "preview-quicklook", value: previewInfo) case .text: - openWindow(id: "preview-quicklook", value: previewInfo) + self.openWindow(id: "preview-quicklook", value: previewInfo) case .unknown: - openWindow(id: "preview-quicklook", value: previewInfo) + self.openWindow(id: "preview-quicklook", value: previewInfo) return } } @@ -397,10 +397,10 @@ struct FilesView: View { @MainActor private func downloadFile(_ file: FileInfo) { if file.isFolder { - model.downloadFolderNew(file.name, path: file.path) + model.downloadFolder(file.name, path: file.path) } else { - model.downloadFileNew(file.name, path: file.path) + model.downloadFile(file.name, path: file.path) } } @@ -442,9 +442,12 @@ struct FilesView: View { return } - model.previewFile(file.name, path: file.path) { info in + self.model.previewFile(file.name, path: file.path) { info in if let info = info { - openPreviewWindow(info) + var extendedInfo = info + extendedInfo.creator = file.creator + extendedInfo.type = file.type + self.openPreviewWindow(extendedInfo) } } } diff --git a/Hotline/macOS/Files/FolderItemView.swift b/Hotline/macOS/Files/FolderItemView.swift index 2b1b695..9b13cc0 100644 --- a/Hotline/macOS/Files/FolderItemView.swift +++ b/Hotline/macOS/Files/FolderItemView.swift @@ -81,7 +81,7 @@ struct FolderItemView: View { .opacity(file.isUnavailable ? 0.5 : 1.0) if loading { - ProgressView().controlSize(.small).padding([.leading, .trailing], 5) + ProgressView().controlSize(.mini).padding([.leading, .trailing], 5) } Spacer() if !file.isUnavailable { diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index 757dbd8..d4b3407 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -93,7 +93,7 @@ struct ServerView: View { ServerMenuItem(type: .board, name: "Board", image: "Section Board"), ServerMenuItem(type: .news, name: "News", image: "Section News"), ServerMenuItem(type: .files, name: "Files", image: "Section Files"), - ServerMenuItem(type: .accounts, name: "Admin", image: "Section Users"), + ServerMenuItem(type: .accounts, name: "Accounts", image: "Section Users"), ] static var classicMenuItems: [ServerMenuItem] = [ @@ -280,47 +280,43 @@ struct ServerView: View { } var transfersSection: some View { -// Section("Transfers") { - ForEach(model.transfers) { transfer in - TransferItemView(transfer: transfer) - } -// } + ForEach(model.transfers) { transfer in + TransferItemView(transfer: transfer) + } } var usersSection: some View { -// Section("\(model.users.count) Online") { - ForEach(model.users) { user in - HStack(spacing: 5) { - if let iconImage = HotlineState.getClassicIcon(Int(user.iconID)) { - Image(nsImage: iconImage) - .frame(width: 16, height: 16) - .padding(.leading, 2) - .padding(.trailing, 2) - } - else { - Image("User") - .frame(width: 16, height: 16) - .padding(.leading, 2) - .padding(.trailing, 2) - } - - Text(user.name) - .foregroundStyle(user.isAdmin ? Color.hotlineRed : .primary) - - Spacer() - - if model.hasUnreadInstantMessages(userID: user.id) { - Circle() - .frame(width: 6, height: 6) - .foregroundStyle(user.isAdmin ? Color.hotlineRed : .primary.opacity(0.5)) - .padding(EdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 2)) - } + ForEach(model.users) { user in + HStack(spacing: 5) { + if let iconImage = HotlineState.getClassicIcon(Int(user.iconID)) { + Image(nsImage: iconImage) + .frame(width: 16, height: 16) + .padding(.leading, 2) + .padding(.trailing, 2) + } + else { + Image("User") + .frame(width: 16, height: 16) + .padding(.leading, 2) + .padding(.trailing, 2) + } + + Text(user.name) + .foregroundStyle(user.isAdmin ? Color.hotlineRed : .primary) + + Spacer() + + if model.hasUnreadInstantMessages(userID: user.id) { + Circle() + .frame(width: 6, height: 6) + .foregroundStyle(user.isAdmin ? Color.hotlineRed : .primary.opacity(0.5)) + .padding(EdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 2)) } - .opacity(user.isIdle ? 0.5 : 1.0) - .opacity(controlActiveState == .inactive ? 0.5 : 1.0) - .tag(ServerNavigationType.user(userID: user.id)) } -// } + .opacity(user.isIdle ? 0.5 : 1.0) + .opacity(controlActiveState == .inactive ? 0.5 : 1.0) + .tag(ServerNavigationType.user(userID: user.id)) + } } var serverView: some View { @@ -353,7 +349,7 @@ struct ServerView: View { case .accounts: AccountManagerView() .navigationTitle(model.serverTitle) - .navigationSubtitle("Administration") + .navigationSubtitle("Accounts") .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .user(let userID): let user = model.users.first(where: { $0.id == userID }) diff --git a/Hotline/macOS/TransfersView.swift b/Hotline/macOS/TransfersView.swift index 489a50b..bf8c8bd 100644 --- a/Hotline/macOS/TransfersView.swift +++ b/Hotline/macOS/TransfersView.swift @@ -19,9 +19,7 @@ struct TransfersView: View { ToolbarItem(placement: .primaryAction) { Button { if self.selectedTransfers.isEmpty { - if let downloadsURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first { - NSWorkspace.shared.open(downloadsURL) - } + NSWorkspace.shared.open(URL.downloadsDirectory) } else { let fileURLs = self.selectedTransfers.compactMap(\.fileURL) -- cgit From c8f3bab11c44cba31780404bd7c4d41b8e8b3e2d Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Tue, 11 Nov 2025 13:29:45 -0800 Subject: Bit of cleanup in our tracker client. --- Hotline/Hotline/HotlineTrackerClient.swift | 166 ++++++++++++++--------------- 1 file changed, 81 insertions(+), 85 deletions(-) diff --git a/Hotline/Hotline/HotlineTrackerClient.swift b/Hotline/Hotline/HotlineTrackerClient.swift index 72ff233..6a7a5b3 100644 --- a/Hotline/Hotline/HotlineTrackerClient.swift +++ b/Hotline/Hotline/HotlineTrackerClient.swift @@ -5,41 +5,38 @@ struct HotlineTracker: Identifiable, Equatable { let id: UUID = UUID() var address: String var port: UInt16 - - init(_ address: String, port: UInt16 = 5498) { + + init(_ address: String, port: UInt16 = UInt16(HotlinePorts.DefaultTrackerPort)) { self.address = address self.port = port } - + static func == (lhs: HotlineTracker, rhs: HotlineTracker) -> Bool { return lhs.address == rhs.address && lhs.port == rhs.port } } - /// Client for Hotline trackers -/// -/// The tracker protocol: -/// 1. Client sends magic: "HTRK" + version (0x0001) -/// 2. Server echoes magic back -/// 3. Server sends header: message type, data length, server count -/// 4. Server sends listing: array of server records class HotlineTrackerClient { - static let MagicPacket: [UInt8] = [ - 0x48, 0x54, 0x52, 0x4B, // 'HTRK' - 0x00, 0x01 // Version - ] + static let MagicCode = Data(endian: .big) { + "HTRK".fourCharCode() + } + + static let MagicPacket = Data(endian: .big) { + HotlineTrackerClient.MagicCode + UInt16(0x0001) // Version + } private var tracker: HotlineTracker - + init() { self.tracker = HotlineTracker("hltracker.com") } - + init(tracker: HotlineTracker) { self.tracker = tracker } - + /// Fetch server list from the tracker /// - Parameters: /// - address: Tracker hostname or IP @@ -51,16 +48,15 @@ class HotlineTrackerClient { let task = Task { await self.fetchServersInternal(address: address, port: port, continuation: continuation) } - + continuation.onTermination = { @Sendable _ in task.cancel() } } } - + private func fetchServersInternal(address: String, port: Int, continuation: AsyncThrowingStream.Continuation) async { do { - // Add timeout wrapper try await withTimeout(seconds: 30) { try await self.doFetch(address: address, port: port, continuation: continuation) } @@ -69,7 +65,7 @@ class HotlineTrackerClient { continuation.finish(throwing: error) } } - + private func doFetch(address: String, port: Int, continuation: AsyncThrowingStream.Continuation) async throws { // Connect to tracker (plaintext, no TLS) let socket = try await NetSocket.connect( @@ -77,107 +73,107 @@ class HotlineTrackerClient { port: UInt16(port) ) defer { Task { await socket.close() } } - + // Send magic packet - try await socket.write(Data(HotlineTrackerClient.MagicPacket)) - + try await socket.write(HotlineTrackerClient.MagicPacket) + // Receive magic response (6 bytes: 'HTRK' + version) let magicResponse = try await socket.read(6) let magic = magicResponse[0..<4] let version = UInt16(magicResponse[4]) << 8 | UInt16(magicResponse[5]) - + // Validate magic ('HTRK') - guard magic == Data([0x48, 0x54, 0x52, 0x4B]) else { + guard magic == Self.MagicCode else { throw NetSocketError.decodeFailed( NSError(domain: "HotlineTracker", code: 1, userInfo: [ NSLocalizedDescriptionKey: "Invalid magic response from tracker (expected 'HTRK')" ]) ) } - + print("HotlineTrackerClient: Connected to tracker (version \(version))") - + // Read server listings (may span multiple batches) var totalYielded = 0 var totalEntriesParsed = 0 // Includes separators var totalExpectedEntries: Int = 0 var batchCount = 0 - + repeat { - batchCount += 1 - - // Receive server information header (8 bytes) - // Format: [message type: u16][data length: u16][server count: u16][server count 2: u16] - let messageType = try await socket.read(UInt16.self, endian: .big) - let dataLength = try await socket.read(UInt16.self, endian: .big) - let serverCount = try await socket.read(UInt16.self, endian: .big) - let serverCount2 = try await socket.read(UInt16.self, endian: .big) - - // First header tells us the total expected entries (includes separators) - if totalExpectedEntries == 0 { - totalExpectedEntries = Int(serverCount) - } - - print("HotlineTrackerClient: Batch #\(batchCount) - type: \(messageType), dataLen: \(dataLength), count1: \(serverCount), count2: \(serverCount2)") - - // Parse servers directly from socket stream (no buffering!) - let trackerSeparatorRegex = /^[-]+$/ - var batchEntriesParsed = 0 - var batchServersYielded = 0 - - for _ in 0..= 100 { - print("HotlineTrackerClient: WARNING - Stopped after 100 batches") + } catch { + print("HotlineTrackerClient: Failed to decode entry #\(batchEntriesParsed + 1): \(error)") break } - + } + + print("HotlineTrackerClient: Batch #\(batchCount): parsed \(batchEntriesParsed) entries, yielded \(batchServersYielded) servers (filtered \(batchEntriesParsed - batchServersYielded) separators)") + print("HotlineTrackerClient: Progress: \(totalEntriesParsed)/\(totalExpectedEntries) entries, \(totalYielded) servers yielded") + + // Safety: don't loop forever + if batchCount >= 100 { + print("HotlineTrackerClient: WARNING - Stopped after 100 batches") + break + } + } while totalEntriesParsed < totalExpectedEntries - + print("HotlineTrackerClient: Completed - parsed \(totalEntriesParsed)/\(totalExpectedEntries) entries, yielded \(totalYielded) servers") continuation.finish() } - + private func withTimeout(seconds: TimeInterval, operation: @escaping () async throws -> T) async throws -> T { try await withThrowingTaskGroup(of: T.self) { group in group.addTask { try await operation() } - + group.addTask { try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) throw NSError(domain: "HotlineTracker", code: 2, userInfo: [ NSLocalizedDescriptionKey: "Tracker request timed out after \(seconds) seconds" ]) } - + let result = try await group.next()! group.cancelAll() return result -- cgit From cdaff85ac22f45e39ab1f02474beaa7aada9ad9b Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Tue, 11 Nov 2025 13:30:38 -0800 Subject: Rename HotlineClientNew file to simply HotlineClient. --- Hotline.xcodeproj/project.pbxproj | 8 +- Hotline/Hotline/HotlineClient.swift | 1068 ++++++++++++++++++++++++++++++++ Hotline/Hotline/HotlineClientNew.swift | 1068 -------------------------------- 3 files changed, 1072 insertions(+), 1072 deletions(-) create mode 100644 Hotline/Hotline/HotlineClient.swift delete mode 100644 Hotline/Hotline/HotlineClientNew.swift diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index 57e7032..2579dbe 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -47,7 +47,7 @@ DA5268A52EB0743000DCB941 /* FileItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268A42EB0743000DCB941 /* FileItemView.swift */; platformFilters = (macos, ); }; DA5268AB2EB11EA300DCB941 /* ColorArt.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268AA2EB11EA300DCB941 /* ColorArt.swift */; }; DA5268AD2EB12FE200DCB941 /* ServerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268AC2EB12FE200DCB941 /* ServerState.swift */; }; - DA5268AF2EB2682B00DCB941 /* HotlineClientNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268AE2EB2682B00DCB941 /* HotlineClientNew.swift */; }; + DA5268AF2EB2682B00DCB941 /* HotlineClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268AE2EB2682B00DCB941 /* HotlineClient.swift */; }; DA5268B12EB2708E00DCB941 /* HotlineState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268B02EB2708E00DCB941 /* HotlineState.swift */; }; DA5268B32EB6806E00DCB941 /* HotlineFileDownloadClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268B22EB6806E00DCB941 /* HotlineFileDownloadClient.swift */; }; DA5268B52EB6840A00DCB941 /* TransfersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268B42EB6840A00DCB941 /* TransfersView.swift */; }; @@ -160,7 +160,7 @@ DA5268A42EB0743000DCB941 /* FileItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileItemView.swift; sourceTree = ""; }; DA5268AA2EB11EA300DCB941 /* ColorArt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorArt.swift; sourceTree = ""; }; DA5268AC2EB12FE200DCB941 /* ServerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerState.swift; sourceTree = ""; }; - DA5268AE2EB2682B00DCB941 /* HotlineClientNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineClientNew.swift; sourceTree = ""; }; + DA5268AE2EB2682B00DCB941 /* HotlineClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineClient.swift; sourceTree = ""; }; DA5268B02EB2708E00DCB941 /* HotlineState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineState.swift; sourceTree = ""; }; DA5268B22EB6806E00DCB941 /* HotlineFileDownloadClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFileDownloadClient.swift; sourceTree = ""; }; DA5268B42EB6840A00DCB941 /* TransfersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransfersView.swift; sourceTree = ""; }; @@ -471,7 +471,7 @@ DABFCC262B1530AE009F40D2 /* Hotline */ = { isa = PBXGroup; children = ( - DA5268AE2EB2682B00DCB941 /* HotlineClientNew.swift */, + DA5268AE2EB2682B00DCB941 /* HotlineClient.swift */, DA9CAFCA2B126E3300CDA197 /* HotlineTrackerClient.swift */, DA3429B12EBA73730010784E /* Transfers */, DA4F2BF72B16A17200D8ADDC /* HotlineProtocol.swift */, @@ -652,7 +652,7 @@ 11F8288B2BF9428100216BA0 /* AccountManagerView.swift in Sources */, DAC6B2E42EAFE92F004E2CBA /* SpinningGlobeView.swift in Sources */, DADDB28D2B22B5920024040D /* Server.swift in Sources */, - DA5268AF2EB2682B00DCB941 /* HotlineClientNew.swift in Sources */, + DA5268AF2EB2682B00DCB941 /* HotlineClient.swift in Sources */, DABE8C042B57940B00884D28 /* DAKeychain.swift in Sources */, DA55AC772BE589F700034857 /* AboutView.swift in Sources */, DA72A0E22B4DAA4000A0F48A /* NewsArticle.swift in Sources */, diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift new file mode 100644 index 0000000..cd34d9e --- /dev/null +++ b/Hotline/Hotline/HotlineClient.swift @@ -0,0 +1,1068 @@ +import Foundation +import Network + +// MARK: - Events + +/// Events that can be received from a Hotline server +/// +/// These are unsolicited messages sent by the server (not replies to requests). +/// Subscribe to the `events` stream to receive them. +public enum HotlineEvent: Sendable { + /// Server sent a chat message + case chatMessage(String) + /// A user's information changed (name, icon, status) + case userChanged(HotlineUser) + /// A user disconnected from the server + case userDisconnected(UInt16) + /// Server sent a broadcast message + case serverMessage(String) + /// Received a private message from a user + case privateMessage(userID: UInt16, message: String) + /// Server sent a news post notification + case newsPost(String) + /// Server is requesting agreement acceptance + case agreementRequired(String) + /// Server sent user access permissions + case userAccess(HotlineUserAccessOptions) +} + +// MARK: - Errors + +/// Errors that can occur during Hotline operations +public enum HotlineClientError: Error { + /// Connection failed + case connectionFailed(Error) + /// Server responded with an error code + case serverError(code: UInt32, message: String?) + /// Transaction timed out waiting for reply + case timeout + /// Client is not connected + case notConnected + /// Invalid response from server + case invalidResponse + /// Login failed + case loginFailed(String?) + + var userMessage: String { + switch self { + case .connectionFailed: + "Failed to connect to server" + case .serverError(let code, let message): + message ?? "Server error: \(code)" + case .timeout: + "Request could not be completed" + case .notConnected: + "Not connected" + case .invalidResponse: + "Server returned an invalid response" + case .loginFailed(let message): + message ?? "Login failed" + } + } +} + +// MARK: - Login Info + +/// Information needed to log in to a Hotline server +public struct HotlineLogin: Sendable { + let login: String + let password: String + let username: String + let iconID: UInt16 + + public init(login: String, password: String, username: String, iconID: UInt16) { + self.login = login + self.password = password + self.username = username + self.iconID = iconID + } +} + +// MARK: - Server Info + +/// Information about the connected server +public struct HotlineServerInfo: Sendable { + let name: String + let version: UInt16 + + public init(name: String, version: UInt16) { + self.name = name + self.version = version + } +} + +// MARK: - Hotline Client + +/// A client for connecting to and interacting with Hotline servers. +/// +/// Example usage: +/// ```swift +/// let client = try await HotlineClient.connect( +/// host: "server.example.com", +/// port: 5500, +/// login: HotlineLogin(login: "guest", password: "", username: "John", iconID: 414) +/// ) +/// +/// // Listen for events +/// Task { +/// for await event in client.events { +/// switch event { +/// case .chatMessage(let text): +/// print("Chat: \(text)") +/// case .userChanged(let user): +/// print("User changed: \(user.name)") +/// default: +/// break +/// } +/// } +/// } +/// +/// // Send chat message +/// try await client.sendChat("Hello world!") +/// +/// // Get user list +/// let users = try await client.getUserList() +/// ``` +public actor HotlineClient { + // MARK: - Properties + + private let socket: NetSocket + private var serverInfo: HotlineServerInfo? + private var isConnected: Bool = true + + /// Information about the connected server (name and version) + public var server: HotlineServerInfo? { + return serverInfo + } + + // Event streaming + private let eventContinuation: AsyncStream.Continuation + public let events: AsyncStream + + // Transaction tracking for request/reply pattern + private var pendingTransactions: [UInt32: CheckedContinuation] = [:] + + private enum TransactionWaitError: Error { + case timeout + } + + // Receive loop task + private var receiveTask: Task? + + // Keep-alive timer + private var keepAliveTask: Task? + + // MARK: - Static Handshake + + private static let handshakeData = Data(endian: .big, { + "TRTP".fourCharCode() // 'TRTP' protocol ID + "HOTL".fourCharCode() // 'HOTL' sub-protocol ID + 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 + + /// Connect to a Hotline server and log in + /// + /// This method: + /// 1. Establishes TCP connection + /// 2. Performs handshake + /// 3. Logs in with provided credentials + /// 4. Starts event streaming and keep-alive + /// + /// - Parameters: + /// - host: Server hostname or IP address + /// - port: Server port (default: 5500) + /// - login: Login credentials and user info + /// - tls: TLS policy (default: disabled for Hotline) + /// - Returns: Connected and logged-in client + /// - Throws: `HotlineClientError` if connection or login fails + public static func connect( + host: String, + port: UInt16 = 5500, + login: HotlineLogin + ) async throws -> HotlineClient { + print("HotlineClient.connect(): Starting connection to \(host):\(port) as '\(login.username)'") + + // Connect socket + print("HotlineClient.connect(): Connecting socket...") + let socket = try await NetSocket.connect(host: host, port: port) + print("HotlineClient.connect(): Socket connected") + + // Perform handshake + print("HotlineClient.connect(): Sending handshake...") + try await socket.write(handshakeData) + let handshakeResponse = try await socket.read(8) + print("HotlineClient.connect(): Handshake response received") + + // Verify handshake + guard handshakeResponse.prefix(4) == Data([0x54, 0x52, 0x54, 0x50]) else { + print("HotlineClient.connect(): Invalid handshake response") + throw HotlineClientError.connectionFailed( + NSError(domain: "HotlineClient", code: -1, userInfo: [ + NSLocalizedDescriptionKey: "Invalid handshake response" + ]) + ) + } + + let errorCode = handshakeResponse.withUnsafeBytes { $0.load(fromByteOffset: 4, as: UInt32.self) } + guard errorCode.bigEndian == 0 else { + print("HotlineClient.connect(): Handshake failed with error code \(errorCode)") + throw HotlineClientError.connectionFailed( + NSError(domain: "HotlineClient", code: Int(errorCode), userInfo: [ + NSLocalizedDescriptionKey: "Handshake failed with error code \(errorCode)" + ]) + ) + } + + // Create client + print("HotlineClient.connect(): Creating client instance") + let client = HotlineClient(socket: socket) + + // Start receive loop + print("HotlineClient.connect(): Starting receive loop") + await client.startReceiveLoop() + + // Perform login + print("HotlineClient.connect(): Performing login") + let serverInfo = try await client.performLogin(login) + await client.setServerInfo(serverInfo) + print("HotlineClient.connect(): Login successful") + + // Start keep-alive + print("HotlineClient.connect(): Starting keep-alive") + await client.startKeepAlive() + + print("HotlineClient.connect(): Connected to \(serverInfo.name) (v\(serverInfo.version))") + + return client + } + + private init(socket: NetSocket) { + self.socket = socket + + // Set up event stream + var continuation: AsyncStream.Continuation! + self.events = AsyncStream { cont in + continuation = cont + } + self.eventContinuation = continuation + } + + private func setServerInfo(_ info: HotlineServerInfo) { + self.serverInfo = info + } + + // MARK: - Login + + private func performLogin(_ login: HotlineLogin) async throws -> HotlineServerInfo { + 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) + transaction.setFieldString(type: .userName, val: login.username) + transaction.setFieldUInt32(type: .versionNumber, val: 123) + + let reply = try await sendTransaction(transaction) + + guard reply.errorCode == 0 else { + let errorText = reply.getField(type: .errorText)?.getString() + throw HotlineClientError.loginFailed(errorText) + } + + let serverName = reply.getField(type: .serverName)?.getString() ?? "Unknown" + let serverVersion = reply.getField(type: .versionNumber)?.getUInt16() ?? 0 + + return HotlineServerInfo(name: serverName, version: serverVersion) + } + + // MARK: - Disconnect + + /// Disconnect from the server + /// + /// Closes the socket and stops all background tasks. + public func disconnect() async { + guard isConnected else { + return + } + + isConnected = false + + print("HotlineClient.disconnect(): Starting disconnect") + self.receiveTask?.cancel() + self.keepAliveTask?.cancel() + await self.socket.close() + self.failAllPendingTransactions(HotlineClientError.notConnected) + self.eventContinuation.finish() + print("HotlineClient.disconnect(): Disconnect complete") + } + + // MARK: - Receive Loop + + private func startReceiveLoop() { + print("HotlineClient.startReceiveLoop(): Creating receive task") + self.receiveTask = Task { [weak self] in + guard let self else { + return + } + + do { + while !Task.isCancelled { + // Read transaction from socket + let transaction = try await self.socket.receive(HotlineTransaction.self, endian: .big) + await self.handleTransaction(transaction) + } + print("HotlineClient.startReceiveLoop(): Task cancelled, exiting loop") + } catch { + if Task.isCancelled || error is CancellationError { + print("HotlineClient.startReceiveLoop(): Receive loop cancelled") + } else { + print("HotlineClient.startReceiveLoop(): Receive loop error: \(error)") + await self.disconnect() + } + } + print("HotlineClient.startReceiveLoop(): Receive loop ended") + } + } + + private func handleTransaction(_ transaction: HotlineTransaction) { + print("HotlineClient: <= \(transaction.type) [\(transaction.id)]") + + // Check if this is a reply to a pending transaction + if transaction.isReply == 1 || transaction.type == .reply { + handleReply(transaction) + return + } + + // Handle unsolicited server messages (events) + handleEvent(transaction) + } + + private func handleReply(_ transaction: HotlineTransaction) { + guard let continuation = pendingTransactions.removeValue(forKey: transaction.id) else { + print("HotlineClient: Received reply for unknown transaction \(transaction.id)") + return + } + + if transaction.errorCode != 0 { + let errorText = transaction.getField(type: .errorText)?.getString() + continuation.resume(throwing: HotlineClientError.serverError( + code: transaction.errorCode, + message: errorText + )) + } else { + continuation.resume(returning: transaction) + } + } + + private func handleEvent(_ transaction: HotlineTransaction) { + switch transaction.type { + case .chatMessage: + if let text = transaction.getField(type: .data)?.getString() { + eventContinuation.yield(.chatMessage(text)) + } + + case .notifyOfUserChange: + if let usernameField = transaction.getField(type: .userName), + let username = usernameField.getString(), + let userID = transaction.getField(type: .userID)?.getUInt16(), + let iconID = transaction.getField(type: .userIconID)?.getUInt16(), + let flags = transaction.getField(type: .userFlags)?.getUInt16() { + let user = HotlineUser(id: userID, iconID: iconID, status: flags, name: username) + eventContinuation.yield(.userChanged(user)) + } + + case .notifyOfUserDelete: + if let userID = transaction.getField(type: .userID)?.getUInt16() { + eventContinuation.yield(.userDisconnected(userID)) + } + + case .serverMessage: + if let message = transaction.getField(type: .data)?.getString() { + if let userID = transaction.getField(type: .userID)?.getUInt16() { + eventContinuation.yield(.privateMessage(userID: userID, message: message)) + } else { + eventContinuation.yield(.serverMessage(message)) + } + } + + case .showAgreement: + if transaction.getField(type: .noServerAgreement) == nil, + let agreementText = transaction.getField(type: .data)?.getString() { + eventContinuation.yield(.agreementRequired(agreementText)) + } + + case .userAccess: + if let accessValue = transaction.getField(type: .userAccess)?.getUInt64() { + eventContinuation.yield(.userAccess(HotlineUserAccessOptions(rawValue: accessValue))) + } + + case .newMessage: + if let message = transaction.getField(type: .data)?.getString() { + eventContinuation.yield(.newsPost(message)) + } + + case .disconnectMessage: + Task { + await self.disconnect() + } + + default: + print("HotlineClient: Unhandled event type \(transaction.type)") + } + } + + // MARK: - Transaction Sending + + @discardableResult + private func sendTransaction(_ transaction: HotlineTransaction, timeout: TimeInterval = 30.0) async throws -> HotlineTransaction { + print("HotlineClient: => \(transaction.type) [\(transaction.id)]") + + let transactionID = transaction.id + + try await self.socket.send(transaction, endian: .big) + + do { + return try await withTimeout(seconds: timeout) { + try await self.awaitReply(for: transactionID) + } + } catch is TransactionWaitError { + throw HotlineClientError.timeout + } catch let error as HotlineClientError { + throw error + } catch { + throw error + } + } + + private func storePendingTransaction(id: UInt32, continuation: CheckedContinuation) { + self.pendingTransactions[id] = continuation + } + + private func awaitReply(for transactionID: UInt32) async throws -> HotlineTransaction { + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + self.storePendingTransaction(id: transactionID, continuation: continuation) + } + } onCancel: { [weak self] in + Task { await self?.failPendingTransaction(id: transactionID, error: HotlineClientError.timeout) } + } + } + + private func failPendingTransaction(id: UInt32, error: Error) { + guard let continuation = self.pendingTransactions.removeValue(forKey: id) else { return } + continuation.resume(throwing: error) + } + + private func failAllPendingTransactions(_ error: Error) { + guard !self.pendingTransactions.isEmpty else { return } + let continuations = self.pendingTransactions + self.pendingTransactions.removeAll() + for (_, continuation) in continuations { + continuation.resume(throwing: error) + } + } + + private func withTimeout(seconds: TimeInterval, operation: @escaping @Sendable () async throws -> T) async throws -> T { + if seconds <= 0 { + throw TransactionWaitError.timeout + } + + return try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { + try await operation() + } + + group.addTask { + try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + throw TransactionWaitError.timeout + } + + do { + let value = try await group.next()! + group.cancelAll() + return value + } catch { + group.cancelAll() + throw error + } + } + } + + // MARK: - Keep-Alive + + private func startKeepAlive() { + self.keepAliveTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 180_000_000_000) // 3 minutes + 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("HotlineClient: Keep-alive failed: \(error)") + } + } + + // MARK: - Chat + + /// Send a chat message to the server + /// + /// - Parameters: + /// - message: Text to send + /// - 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(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: - 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(id: self.generateTransactionID(), type: .getUserNameList) + let reply = try await sendTransaction(transaction) + + var users: [HotlineUser] = [] + for field in reply.getFieldList(type: .userNameWithInfo) { + users.append(field.getUser()) + } + + return users + } + + /// Send a private instant message to a user + /// + /// - Parameters: + /// - message: Text to send + /// - 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(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) + + try await socket.send(transaction, endian: .big) + } + + /// Update this client's user info (name, icon, options) + /// + /// - Parameters: + /// - username: Display name + /// - iconID: Icon ID + /// - options: User options flags + /// - autoresponse: Optional auto-response text + public func setClientUserInfo( + username: String, + iconID: UInt16, + options: HotlineUserOptions = [], + autoresponse: String? = nil + ) async throws { + 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) + + if let autoresponse { + transaction.setFieldString(type: .automaticResponse, val: autoresponse) + } + + try await socket.send(transaction, endian: .big) + } + + // MARK: - Agreement + + /// Send agreement acceptance to the server + /// + /// Call this after receiving `.agreementRequired` event. + public func sendAgree() async throws { + let transaction = HotlineTransaction(id: self.generateTransactionID(), type: .agreed) + try await socket.send(transaction, endian: .big) + } + + // MARK: - Files + + /// Get the file list for a directory + /// + /// - Parameter path: Directory path (empty for root) + /// - Returns: Array of files and folders + public func getFileList(path: [String] = []) async throws -> [HotlineFile] { + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getFileNameList) + if !path.isEmpty { + transaction.setFieldPath(type: .filePath, val: path) + } + + let reply = try await self.sendTransaction(transaction) + + var files: [HotlineFile] = [] + for field in reply.getFieldList(type: .fileNameWithInfo) { + let file = field.getFile() + file.path = path + [file.name] + files.append(file) + } + + return files + } + + /// Request to download a file + /// + /// - Parameters: + /// - name: File name + /// - path: Directory path containing the file + /// - preview: Request preview/thumbnail instead of full file + /// - Returns: Transfer info (reference number, size, waiting count) + public func downloadFile( + name: String, + path: [String], + preview: Bool = false + ) async throws -> (referenceNumber: UInt32, size: Int, fileSize: Int?, waitingCount: Int?) { + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .downloadFile) + transaction.setFieldString(type: .fileName, val: name) + transaction.setFieldPath(type: .filePath, val: path) + + if preview { + transaction.setFieldUInt32(type: .fileTransferOptions, val: 2) + } + + let reply = try await self.sendTransaction(transaction) + + guard + let transferSize = reply.getField(type: .transferSize)?.getInteger(), + let referenceNumber = reply.getField(type: .referenceNumber)?.getUInt32() + else { + throw HotlineClientError.invalidResponse + } + + let fileSize = reply.getField(type: .fileSize)?.getInteger() + let waitingCount = reply.getField(type: .waitingCount)?.getInteger() + + return (referenceNumber, transferSize, fileSize, waitingCount) + } + + // MARK: - News + + /// Get news categories at a path + /// + /// - Parameter path: Category path (empty for root) + /// - Returns: Array of news categories + public func getNewsCategories(path: [String] = []) async throws -> [HotlineNewsCategory] { + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getNewsCategoryNameList) + if !path.isEmpty { + transaction.setFieldPath(type: .newsPath, val: path) + } + + let reply = try await self.sendTransaction(transaction) + + var categories: [HotlineNewsCategory] = [] + for field in reply.getFieldList(type: .newsCategoryListData15) { + var category = field.getNewsCategory() + category.path = path + [category.name] + categories.append(category) + } + + return categories + } + + /// Get news articles in a category + /// + /// - Parameter path: Category path + /// - Returns: Array of news articles + public func getNewsArticles(path: [String] = []) async throws -> [HotlineNewsArticle] { + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getNewsArticleNameList) + if !path.isEmpty { + transaction.setFieldPath(type: .newsPath, val: path) + } + + let reply = try await self.sendTransaction(transaction) + + guard let articleData = reply.getField(type: .newsArticleListData) else { + return [] + } + + let newsList = articleData.getNewsList() + return newsList.articles.map { article in + var a = article + a.path = path + return a + } + } + + /// Get the content of a news article + /// + /// - Parameters: + /// - id: Article ID + /// - path: Category path + /// - 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(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) + + let reply = try await self.sendTransaction(transaction) + return reply.getField(type: .newsArticleData)?.getString() + } + + /// Post a news article + /// + /// - Parameters: + /// - title: Article title + /// - text: Article body + /// - path: Category path + /// - parentID: Parent article ID (for replies, default: 0) + public func postNewsArticle( + title: String, + text: String, + path: [String], + parentID: UInt32 = 0 + ) async throws { + guard !path.isEmpty else { + throw HotlineClientError.invalidResponse + } + + 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) + transaction.setFieldString(type: .newsArticleDataFlavor, val: "text/plain") + transaction.setFieldUInt32(type: .newsArticleFlags, val: 0) + transaction.setFieldString(type: .newsArticleData, val: text) + + try await self.sendTransaction(transaction) + } + + // MARK: - Message Board + + /// Get message board posts + /// + /// - Returns: Array of message strings + public func getMessageBoard() async throws -> [String] { + let transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getMessageBoard) + let reply = try await self.sendTransaction(transaction) + + guard let text = reply.getField(type: .data)?.getString() else { + return [] + } + + // Parse messages (separated by divider pattern) + // TODO: Implement proper divider parsing if needed + return [text] + } + + /// Post to the message board + /// + /// - Parameter text: Message text + public func postMessageBoard(_ text: String) async throws { + guard !text.isEmpty else { return } + + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .oldPostNews) + transaction.setFieldString(type: .data, val: text, encoding: .macOSRoman) + + try await self.socket.send(transaction, endian: .big) + } + + // MARK: - File Operations + + /// Get detailed information about a file + /// + /// - Parameters: + /// - name: File name + /// - 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(id: self.generateTransactionID(), type: .getFileInfo) + transaction.setFieldString(type: .fileName, val: name) + transaction.setFieldPath(type: .filePath, val: path) + + let reply = try await sendTransaction(transaction) + + guard + let fileName = reply.getField(type: .fileName)?.getString(), + let fileCreator = reply.getField(type: .fileCreatorString)?.getString(), + let fileType = reply.getField(type: .fileTypeString)?.getString(), + let fileCreateDate = reply.getField(type: .fileCreateDate)?.data.readDate(at: 0), + let fileModifyDate = reply.getField(type: .fileModifyDate)?.data.readDate(at: 0) + else { + return nil + } + + // Size field is not included in server reply for folders + let fileSize = reply.getField(type: .fileSize)?.getInteger() ?? 0 + let fileComment = reply.getField(type: .fileComment)?.getString() ?? "" + + return FileDetails( + name: fileName, + path: path, + size: fileSize, + comment: fileComment, + type: fileType, + creator: fileCreator, + created: fileCreateDate, + modified: fileModifyDate + ) + } + + /// Delete a file or folder + /// + /// - Parameters: + /// - name: File or folder name + /// - path: Directory path containing the item + /// - Returns: True if deletion succeeded + public func deleteFile(name: String, path: [String]) async throws -> Bool { + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .deleteFile) + transaction.setFieldString(type: .fileName, val: name) + transaction.setFieldPath(type: .filePath, val: path) + + do { + try await self.sendTransaction(transaction) + return true + } catch { + return false + } + } + + // 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(id: self.generateTransactionID(), type: .getAccounts) + let reply = try await self.sendTransaction(transaction) + + let accountFields = reply.getFieldList(type: .data) + var accounts: [HotlineAccount] = [] + + for data in accountFields { + accounts.append(data.getAcccount()) + } + + accounts.sort { $0.login < $1.login } + + return accounts + } + + /// Create a new user account (requires admin access) + /// + /// - Parameters: + /// - name: Display name for the user + /// - login: Login username + /// - 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(id: self.generateTransactionID(), type: .newUser) + + transaction.setFieldString(type: .userName, val: name) + transaction.setFieldEncodedString(type: .userLogin, val: login) + transaction.setFieldUInt64(type: .userAccess, val: access) + + if let password { + transaction.setFieldEncodedString(type: .userPassword, val: password) + } + + try await self.sendTransaction(transaction) + } + + /// Update an existing user account (requires admin access) + /// + /// - Parameters: + /// - name: Display name for the user + /// - login: Current login username + /// - newLogin: New login username (nil to keep current) + /// - 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(id: self.generateTransactionID(), type: .setUser) + transaction.setFieldString(type: .userName, val: name) + transaction.setFieldUInt64(type: .userAccess, val: access) + + if let newLogin { + transaction.setFieldEncodedString(type: .data, val: login) + transaction.setFieldEncodedString(type: .userLogin, val: newLogin) + } else { + transaction.setFieldEncodedString(type: .userLogin, val: login) + } + + // Password field handling: + // - nil: Keep current password (send zero byte) + // - "": Remove password (omit field) + // - other: Set new password + if password == nil { + transaction.setFieldUInt8(type: .userPassword, val: 0) + } else if password != "" { + transaction.setFieldEncodedString(type: .userPassword, val: password!) + } + + try await self.sendTransaction(transaction) + } + + /// Delete a user account (requires admin access) + /// + /// - Parameter login: Login username to delete + public func deleteUser(login: String) async throws { + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .deleteUser) + transaction.setFieldEncodedString(type: .userLogin, val: login) + + try await self.sendTransaction(transaction) + } + + // 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(id: self.generateTransactionID(), type: .downloadBanner) + let reply = try await self.sendTransaction(transaction) + + guard + let transferSizeField = reply.getField(type: .transferSize), + let transferSize = transferSizeField.getInteger(), + let transferReferenceField = reply.getField(type: .referenceNumber), + let referenceNumber = transferReferenceField.getUInt32() + else { + return nil + } + + return (referenceNumber, transferSize) + } + + // MARK: - Transfers + + /// Request to download a file + /// + /// - Parameters: + /// - name: File name to download + /// - path: Directory path containing the file + /// - 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(id: self.generateTransactionID(), type: .downloadFile) + transaction.setFieldString(type: .fileName, val: name) + transaction.setFieldPath(type: .filePath, val: path) + + if preview { + transaction.setFieldUInt32(type: .fileTransferOptions, val: 2) + } + + let reply = try await self.sendTransaction(transaction) + + guard + let transferSizeField = reply.getField(type: .transferSize), + let transferSize = transferSizeField.getInteger(), + let transferReferenceField = reply.getField(type: .referenceNumber), + let referenceNumber = transferReferenceField.getUInt32() + else { + return nil + } + + let fileSize = reply.getField(type: .fileSize)?.getInteger() ?? transferSize + let waitingCount = reply.getField(type: .waitingCount)?.getInteger() ?? 0 + + return (referenceNumber, transferSize, fileSize, waitingCount) + } + + /// Request to download a folder + /// + /// - Parameters: + /// - name: Folder name to download + /// - 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(id: self.generateTransactionID(), type: .downloadFolder) + transaction.setFieldString(type: .fileName, val: name) + transaction.setFieldPath(type: .filePath, val: path) + + let reply = try await self.sendTransaction(transaction) + + guard + let transferSizeField = reply.getField(type: .transferSize), + let transferSize = transferSizeField.getInteger(), + let transferReferenceField = reply.getField(type: .referenceNumber), + let referenceNumber = transferReferenceField.getUInt32() + else { + return nil + } + + let itemCount = reply.getField(type: .folderItemCount)?.getInteger() ?? 0 + let waitingCount = reply.getField(type: .waitingCount)?.getInteger() ?? 0 + + return (referenceNumber, transferSize, itemCount, waitingCount) + } + + /// Uploads a file to the server + /// - Parameters: + /// - name: File name to upload + /// - 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(id: self.generateTransactionID(), type: .uploadFile) + transaction.setFieldString(type: .fileName, val: name) + transaction.setFieldPath(type: .filePath, val: path) + + let reply = try await self.sendTransaction(transaction) + + guard + let transferReferenceField = reply.getField(type: .referenceNumber), + let referenceNumber = transferReferenceField.getUInt32() + else { + return nil + } + + return referenceNumber + } + + /// Request to upload a folder + /// + /// - Parameters: + /// - name: Folder name to upload + /// - path: Directory path where the folder should be uploaded + /// - Returns: Reference number for the upload transfer + public func uploadFolder(name: String, path: [String], fileCount: UInt32, totalSize: UInt32) async throws -> UInt32? { + print("HotlineClient: uploadFolder request - name='\(name)', path=\(path), fileCount=\(fileCount), totalSize=\(totalSize)") + + 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) + transaction.setFieldUInt16(type: .folderItemCount, val: UInt16(truncatingIfNeeded: fileCount)) + + let reply = try await self.sendTransaction(transaction) + + guard + let transferReferenceField = reply.getField(type: .referenceNumber), + let referenceNumber = transferReferenceField.getUInt32() + else { + return nil + } + + return referenceNumber + } +} diff --git a/Hotline/Hotline/HotlineClientNew.swift b/Hotline/Hotline/HotlineClientNew.swift deleted file mode 100644 index cd34d9e..0000000 --- a/Hotline/Hotline/HotlineClientNew.swift +++ /dev/null @@ -1,1068 +0,0 @@ -import Foundation -import Network - -// MARK: - Events - -/// Events that can be received from a Hotline server -/// -/// These are unsolicited messages sent by the server (not replies to requests). -/// Subscribe to the `events` stream to receive them. -public enum HotlineEvent: Sendable { - /// Server sent a chat message - case chatMessage(String) - /// A user's information changed (name, icon, status) - case userChanged(HotlineUser) - /// A user disconnected from the server - case userDisconnected(UInt16) - /// Server sent a broadcast message - case serverMessage(String) - /// Received a private message from a user - case privateMessage(userID: UInt16, message: String) - /// Server sent a news post notification - case newsPost(String) - /// Server is requesting agreement acceptance - case agreementRequired(String) - /// Server sent user access permissions - case userAccess(HotlineUserAccessOptions) -} - -// MARK: - Errors - -/// Errors that can occur during Hotline operations -public enum HotlineClientError: Error { - /// Connection failed - case connectionFailed(Error) - /// Server responded with an error code - case serverError(code: UInt32, message: String?) - /// Transaction timed out waiting for reply - case timeout - /// Client is not connected - case notConnected - /// Invalid response from server - case invalidResponse - /// Login failed - case loginFailed(String?) - - var userMessage: String { - switch self { - case .connectionFailed: - "Failed to connect to server" - case .serverError(let code, let message): - message ?? "Server error: \(code)" - case .timeout: - "Request could not be completed" - case .notConnected: - "Not connected" - case .invalidResponse: - "Server returned an invalid response" - case .loginFailed(let message): - message ?? "Login failed" - } - } -} - -// MARK: - Login Info - -/// Information needed to log in to a Hotline server -public struct HotlineLogin: Sendable { - let login: String - let password: String - let username: String - let iconID: UInt16 - - public init(login: String, password: String, username: String, iconID: UInt16) { - self.login = login - self.password = password - self.username = username - self.iconID = iconID - } -} - -// MARK: - Server Info - -/// Information about the connected server -public struct HotlineServerInfo: Sendable { - let name: String - let version: UInt16 - - public init(name: String, version: UInt16) { - self.name = name - self.version = version - } -} - -// MARK: - Hotline Client - -/// A client for connecting to and interacting with Hotline servers. -/// -/// Example usage: -/// ```swift -/// let client = try await HotlineClient.connect( -/// host: "server.example.com", -/// port: 5500, -/// login: HotlineLogin(login: "guest", password: "", username: "John", iconID: 414) -/// ) -/// -/// // Listen for events -/// Task { -/// for await event in client.events { -/// switch event { -/// case .chatMessage(let text): -/// print("Chat: \(text)") -/// case .userChanged(let user): -/// print("User changed: \(user.name)") -/// default: -/// break -/// } -/// } -/// } -/// -/// // Send chat message -/// try await client.sendChat("Hello world!") -/// -/// // Get user list -/// let users = try await client.getUserList() -/// ``` -public actor HotlineClient { - // MARK: - Properties - - private let socket: NetSocket - private var serverInfo: HotlineServerInfo? - private var isConnected: Bool = true - - /// Information about the connected server (name and version) - public var server: HotlineServerInfo? { - return serverInfo - } - - // Event streaming - private let eventContinuation: AsyncStream.Continuation - public let events: AsyncStream - - // Transaction tracking for request/reply pattern - private var pendingTransactions: [UInt32: CheckedContinuation] = [:] - - private enum TransactionWaitError: Error { - case timeout - } - - // Receive loop task - private var receiveTask: Task? - - // Keep-alive timer - private var keepAliveTask: Task? - - // MARK: - Static Handshake - - private static let handshakeData = Data(endian: .big, { - "TRTP".fourCharCode() // 'TRTP' protocol ID - "HOTL".fourCharCode() // 'HOTL' sub-protocol ID - 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 - - /// Connect to a Hotline server and log in - /// - /// This method: - /// 1. Establishes TCP connection - /// 2. Performs handshake - /// 3. Logs in with provided credentials - /// 4. Starts event streaming and keep-alive - /// - /// - Parameters: - /// - host: Server hostname or IP address - /// - port: Server port (default: 5500) - /// - login: Login credentials and user info - /// - tls: TLS policy (default: disabled for Hotline) - /// - Returns: Connected and logged-in client - /// - Throws: `HotlineClientError` if connection or login fails - public static func connect( - host: String, - port: UInt16 = 5500, - login: HotlineLogin - ) async throws -> HotlineClient { - print("HotlineClient.connect(): Starting connection to \(host):\(port) as '\(login.username)'") - - // Connect socket - print("HotlineClient.connect(): Connecting socket...") - let socket = try await NetSocket.connect(host: host, port: port) - print("HotlineClient.connect(): Socket connected") - - // Perform handshake - print("HotlineClient.connect(): Sending handshake...") - try await socket.write(handshakeData) - let handshakeResponse = try await socket.read(8) - print("HotlineClient.connect(): Handshake response received") - - // Verify handshake - guard handshakeResponse.prefix(4) == Data([0x54, 0x52, 0x54, 0x50]) else { - print("HotlineClient.connect(): Invalid handshake response") - throw HotlineClientError.connectionFailed( - NSError(domain: "HotlineClient", code: -1, userInfo: [ - NSLocalizedDescriptionKey: "Invalid handshake response" - ]) - ) - } - - let errorCode = handshakeResponse.withUnsafeBytes { $0.load(fromByteOffset: 4, as: UInt32.self) } - guard errorCode.bigEndian == 0 else { - print("HotlineClient.connect(): Handshake failed with error code \(errorCode)") - throw HotlineClientError.connectionFailed( - NSError(domain: "HotlineClient", code: Int(errorCode), userInfo: [ - NSLocalizedDescriptionKey: "Handshake failed with error code \(errorCode)" - ]) - ) - } - - // Create client - print("HotlineClient.connect(): Creating client instance") - let client = HotlineClient(socket: socket) - - // Start receive loop - print("HotlineClient.connect(): Starting receive loop") - await client.startReceiveLoop() - - // Perform login - print("HotlineClient.connect(): Performing login") - let serverInfo = try await client.performLogin(login) - await client.setServerInfo(serverInfo) - print("HotlineClient.connect(): Login successful") - - // Start keep-alive - print("HotlineClient.connect(): Starting keep-alive") - await client.startKeepAlive() - - print("HotlineClient.connect(): Connected to \(serverInfo.name) (v\(serverInfo.version))") - - return client - } - - private init(socket: NetSocket) { - self.socket = socket - - // Set up event stream - var continuation: AsyncStream.Continuation! - self.events = AsyncStream { cont in - continuation = cont - } - self.eventContinuation = continuation - } - - private func setServerInfo(_ info: HotlineServerInfo) { - self.serverInfo = info - } - - // MARK: - Login - - private func performLogin(_ login: HotlineLogin) async throws -> HotlineServerInfo { - 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) - transaction.setFieldString(type: .userName, val: login.username) - transaction.setFieldUInt32(type: .versionNumber, val: 123) - - let reply = try await sendTransaction(transaction) - - guard reply.errorCode == 0 else { - let errorText = reply.getField(type: .errorText)?.getString() - throw HotlineClientError.loginFailed(errorText) - } - - let serverName = reply.getField(type: .serverName)?.getString() ?? "Unknown" - let serverVersion = reply.getField(type: .versionNumber)?.getUInt16() ?? 0 - - return HotlineServerInfo(name: serverName, version: serverVersion) - } - - // MARK: - Disconnect - - /// Disconnect from the server - /// - /// Closes the socket and stops all background tasks. - public func disconnect() async { - guard isConnected else { - return - } - - isConnected = false - - print("HotlineClient.disconnect(): Starting disconnect") - self.receiveTask?.cancel() - self.keepAliveTask?.cancel() - await self.socket.close() - self.failAllPendingTransactions(HotlineClientError.notConnected) - self.eventContinuation.finish() - print("HotlineClient.disconnect(): Disconnect complete") - } - - // MARK: - Receive Loop - - private func startReceiveLoop() { - print("HotlineClient.startReceiveLoop(): Creating receive task") - self.receiveTask = Task { [weak self] in - guard let self else { - return - } - - do { - while !Task.isCancelled { - // Read transaction from socket - let transaction = try await self.socket.receive(HotlineTransaction.self, endian: .big) - await self.handleTransaction(transaction) - } - print("HotlineClient.startReceiveLoop(): Task cancelled, exiting loop") - } catch { - if Task.isCancelled || error is CancellationError { - print("HotlineClient.startReceiveLoop(): Receive loop cancelled") - } else { - print("HotlineClient.startReceiveLoop(): Receive loop error: \(error)") - await self.disconnect() - } - } - print("HotlineClient.startReceiveLoop(): Receive loop ended") - } - } - - private func handleTransaction(_ transaction: HotlineTransaction) { - print("HotlineClient: <= \(transaction.type) [\(transaction.id)]") - - // Check if this is a reply to a pending transaction - if transaction.isReply == 1 || transaction.type == .reply { - handleReply(transaction) - return - } - - // Handle unsolicited server messages (events) - handleEvent(transaction) - } - - private func handleReply(_ transaction: HotlineTransaction) { - guard let continuation = pendingTransactions.removeValue(forKey: transaction.id) else { - print("HotlineClient: Received reply for unknown transaction \(transaction.id)") - return - } - - if transaction.errorCode != 0 { - let errorText = transaction.getField(type: .errorText)?.getString() - continuation.resume(throwing: HotlineClientError.serverError( - code: transaction.errorCode, - message: errorText - )) - } else { - continuation.resume(returning: transaction) - } - } - - private func handleEvent(_ transaction: HotlineTransaction) { - switch transaction.type { - case .chatMessage: - if let text = transaction.getField(type: .data)?.getString() { - eventContinuation.yield(.chatMessage(text)) - } - - case .notifyOfUserChange: - if let usernameField = transaction.getField(type: .userName), - let username = usernameField.getString(), - let userID = transaction.getField(type: .userID)?.getUInt16(), - let iconID = transaction.getField(type: .userIconID)?.getUInt16(), - let flags = transaction.getField(type: .userFlags)?.getUInt16() { - let user = HotlineUser(id: userID, iconID: iconID, status: flags, name: username) - eventContinuation.yield(.userChanged(user)) - } - - case .notifyOfUserDelete: - if let userID = transaction.getField(type: .userID)?.getUInt16() { - eventContinuation.yield(.userDisconnected(userID)) - } - - case .serverMessage: - if let message = transaction.getField(type: .data)?.getString() { - if let userID = transaction.getField(type: .userID)?.getUInt16() { - eventContinuation.yield(.privateMessage(userID: userID, message: message)) - } else { - eventContinuation.yield(.serverMessage(message)) - } - } - - case .showAgreement: - if transaction.getField(type: .noServerAgreement) == nil, - let agreementText = transaction.getField(type: .data)?.getString() { - eventContinuation.yield(.agreementRequired(agreementText)) - } - - case .userAccess: - if let accessValue = transaction.getField(type: .userAccess)?.getUInt64() { - eventContinuation.yield(.userAccess(HotlineUserAccessOptions(rawValue: accessValue))) - } - - case .newMessage: - if let message = transaction.getField(type: .data)?.getString() { - eventContinuation.yield(.newsPost(message)) - } - - case .disconnectMessage: - Task { - await self.disconnect() - } - - default: - print("HotlineClient: Unhandled event type \(transaction.type)") - } - } - - // MARK: - Transaction Sending - - @discardableResult - private func sendTransaction(_ transaction: HotlineTransaction, timeout: TimeInterval = 30.0) async throws -> HotlineTransaction { - print("HotlineClient: => \(transaction.type) [\(transaction.id)]") - - let transactionID = transaction.id - - try await self.socket.send(transaction, endian: .big) - - do { - return try await withTimeout(seconds: timeout) { - try await self.awaitReply(for: transactionID) - } - } catch is TransactionWaitError { - throw HotlineClientError.timeout - } catch let error as HotlineClientError { - throw error - } catch { - throw error - } - } - - private func storePendingTransaction(id: UInt32, continuation: CheckedContinuation) { - self.pendingTransactions[id] = continuation - } - - private func awaitReply(for transactionID: UInt32) async throws -> HotlineTransaction { - try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { continuation in - self.storePendingTransaction(id: transactionID, continuation: continuation) - } - } onCancel: { [weak self] in - Task { await self?.failPendingTransaction(id: transactionID, error: HotlineClientError.timeout) } - } - } - - private func failPendingTransaction(id: UInt32, error: Error) { - guard let continuation = self.pendingTransactions.removeValue(forKey: id) else { return } - continuation.resume(throwing: error) - } - - private func failAllPendingTransactions(_ error: Error) { - guard !self.pendingTransactions.isEmpty else { return } - let continuations = self.pendingTransactions - self.pendingTransactions.removeAll() - for (_, continuation) in continuations { - continuation.resume(throwing: error) - } - } - - private func withTimeout(seconds: TimeInterval, operation: @escaping @Sendable () async throws -> T) async throws -> T { - if seconds <= 0 { - throw TransactionWaitError.timeout - } - - return try await withThrowingTaskGroup(of: T.self) { group in - group.addTask { - try await operation() - } - - group.addTask { - try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) - throw TransactionWaitError.timeout - } - - do { - let value = try await group.next()! - group.cancelAll() - return value - } catch { - group.cancelAll() - throw error - } - } - } - - // MARK: - Keep-Alive - - private func startKeepAlive() { - self.keepAliveTask = Task { [weak self] in - while !Task.isCancelled { - try? await Task.sleep(nanoseconds: 180_000_000_000) // 3 minutes - 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("HotlineClient: Keep-alive failed: \(error)") - } - } - - // MARK: - Chat - - /// Send a chat message to the server - /// - /// - Parameters: - /// - message: Text to send - /// - 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(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: - 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(id: self.generateTransactionID(), type: .getUserNameList) - let reply = try await sendTransaction(transaction) - - var users: [HotlineUser] = [] - for field in reply.getFieldList(type: .userNameWithInfo) { - users.append(field.getUser()) - } - - return users - } - - /// Send a private instant message to a user - /// - /// - Parameters: - /// - message: Text to send - /// - 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(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) - - try await socket.send(transaction, endian: .big) - } - - /// Update this client's user info (name, icon, options) - /// - /// - Parameters: - /// - username: Display name - /// - iconID: Icon ID - /// - options: User options flags - /// - autoresponse: Optional auto-response text - public func setClientUserInfo( - username: String, - iconID: UInt16, - options: HotlineUserOptions = [], - autoresponse: String? = nil - ) async throws { - 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) - - if let autoresponse { - transaction.setFieldString(type: .automaticResponse, val: autoresponse) - } - - try await socket.send(transaction, endian: .big) - } - - // MARK: - Agreement - - /// Send agreement acceptance to the server - /// - /// Call this after receiving `.agreementRequired` event. - public func sendAgree() async throws { - let transaction = HotlineTransaction(id: self.generateTransactionID(), type: .agreed) - try await socket.send(transaction, endian: .big) - } - - // MARK: - Files - - /// Get the file list for a directory - /// - /// - Parameter path: Directory path (empty for root) - /// - Returns: Array of files and folders - public func getFileList(path: [String] = []) async throws -> [HotlineFile] { - var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getFileNameList) - if !path.isEmpty { - transaction.setFieldPath(type: .filePath, val: path) - } - - let reply = try await self.sendTransaction(transaction) - - var files: [HotlineFile] = [] - for field in reply.getFieldList(type: .fileNameWithInfo) { - let file = field.getFile() - file.path = path + [file.name] - files.append(file) - } - - return files - } - - /// Request to download a file - /// - /// - Parameters: - /// - name: File name - /// - path: Directory path containing the file - /// - preview: Request preview/thumbnail instead of full file - /// - Returns: Transfer info (reference number, size, waiting count) - public func downloadFile( - name: String, - path: [String], - preview: Bool = false - ) async throws -> (referenceNumber: UInt32, size: Int, fileSize: Int?, waitingCount: Int?) { - var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .downloadFile) - transaction.setFieldString(type: .fileName, val: name) - transaction.setFieldPath(type: .filePath, val: path) - - if preview { - transaction.setFieldUInt32(type: .fileTransferOptions, val: 2) - } - - let reply = try await self.sendTransaction(transaction) - - guard - let transferSize = reply.getField(type: .transferSize)?.getInteger(), - let referenceNumber = reply.getField(type: .referenceNumber)?.getUInt32() - else { - throw HotlineClientError.invalidResponse - } - - let fileSize = reply.getField(type: .fileSize)?.getInteger() - let waitingCount = reply.getField(type: .waitingCount)?.getInteger() - - return (referenceNumber, transferSize, fileSize, waitingCount) - } - - // MARK: - News - - /// Get news categories at a path - /// - /// - Parameter path: Category path (empty for root) - /// - Returns: Array of news categories - public func getNewsCategories(path: [String] = []) async throws -> [HotlineNewsCategory] { - var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getNewsCategoryNameList) - if !path.isEmpty { - transaction.setFieldPath(type: .newsPath, val: path) - } - - let reply = try await self.sendTransaction(transaction) - - var categories: [HotlineNewsCategory] = [] - for field in reply.getFieldList(type: .newsCategoryListData15) { - var category = field.getNewsCategory() - category.path = path + [category.name] - categories.append(category) - } - - return categories - } - - /// Get news articles in a category - /// - /// - Parameter path: Category path - /// - Returns: Array of news articles - public func getNewsArticles(path: [String] = []) async throws -> [HotlineNewsArticle] { - var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getNewsArticleNameList) - if !path.isEmpty { - transaction.setFieldPath(type: .newsPath, val: path) - } - - let reply = try await self.sendTransaction(transaction) - - guard let articleData = reply.getField(type: .newsArticleListData) else { - return [] - } - - let newsList = articleData.getNewsList() - return newsList.articles.map { article in - var a = article - a.path = path - return a - } - } - - /// Get the content of a news article - /// - /// - Parameters: - /// - id: Article ID - /// - path: Category path - /// - 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(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) - - let reply = try await self.sendTransaction(transaction) - return reply.getField(type: .newsArticleData)?.getString() - } - - /// Post a news article - /// - /// - Parameters: - /// - title: Article title - /// - text: Article body - /// - path: Category path - /// - parentID: Parent article ID (for replies, default: 0) - public func postNewsArticle( - title: String, - text: String, - path: [String], - parentID: UInt32 = 0 - ) async throws { - guard !path.isEmpty else { - throw HotlineClientError.invalidResponse - } - - 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) - transaction.setFieldString(type: .newsArticleDataFlavor, val: "text/plain") - transaction.setFieldUInt32(type: .newsArticleFlags, val: 0) - transaction.setFieldString(type: .newsArticleData, val: text) - - try await self.sendTransaction(transaction) - } - - // MARK: - Message Board - - /// Get message board posts - /// - /// - Returns: Array of message strings - public func getMessageBoard() async throws -> [String] { - let transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getMessageBoard) - let reply = try await self.sendTransaction(transaction) - - guard let text = reply.getField(type: .data)?.getString() else { - return [] - } - - // Parse messages (separated by divider pattern) - // TODO: Implement proper divider parsing if needed - return [text] - } - - /// Post to the message board - /// - /// - Parameter text: Message text - public func postMessageBoard(_ text: String) async throws { - guard !text.isEmpty else { return } - - var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .oldPostNews) - transaction.setFieldString(type: .data, val: text, encoding: .macOSRoman) - - try await self.socket.send(transaction, endian: .big) - } - - // MARK: - File Operations - - /// Get detailed information about a file - /// - /// - Parameters: - /// - name: File name - /// - 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(id: self.generateTransactionID(), type: .getFileInfo) - transaction.setFieldString(type: .fileName, val: name) - transaction.setFieldPath(type: .filePath, val: path) - - let reply = try await sendTransaction(transaction) - - guard - let fileName = reply.getField(type: .fileName)?.getString(), - let fileCreator = reply.getField(type: .fileCreatorString)?.getString(), - let fileType = reply.getField(type: .fileTypeString)?.getString(), - let fileCreateDate = reply.getField(type: .fileCreateDate)?.data.readDate(at: 0), - let fileModifyDate = reply.getField(type: .fileModifyDate)?.data.readDate(at: 0) - else { - return nil - } - - // Size field is not included in server reply for folders - let fileSize = reply.getField(type: .fileSize)?.getInteger() ?? 0 - let fileComment = reply.getField(type: .fileComment)?.getString() ?? "" - - return FileDetails( - name: fileName, - path: path, - size: fileSize, - comment: fileComment, - type: fileType, - creator: fileCreator, - created: fileCreateDate, - modified: fileModifyDate - ) - } - - /// Delete a file or folder - /// - /// - Parameters: - /// - name: File or folder name - /// - path: Directory path containing the item - /// - Returns: True if deletion succeeded - public func deleteFile(name: String, path: [String]) async throws -> Bool { - var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .deleteFile) - transaction.setFieldString(type: .fileName, val: name) - transaction.setFieldPath(type: .filePath, val: path) - - do { - try await self.sendTransaction(transaction) - return true - } catch { - return false - } - } - - // 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(id: self.generateTransactionID(), type: .getAccounts) - let reply = try await self.sendTransaction(transaction) - - let accountFields = reply.getFieldList(type: .data) - var accounts: [HotlineAccount] = [] - - for data in accountFields { - accounts.append(data.getAcccount()) - } - - accounts.sort { $0.login < $1.login } - - return accounts - } - - /// Create a new user account (requires admin access) - /// - /// - Parameters: - /// - name: Display name for the user - /// - login: Login username - /// - 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(id: self.generateTransactionID(), type: .newUser) - - transaction.setFieldString(type: .userName, val: name) - transaction.setFieldEncodedString(type: .userLogin, val: login) - transaction.setFieldUInt64(type: .userAccess, val: access) - - if let password { - transaction.setFieldEncodedString(type: .userPassword, val: password) - } - - try await self.sendTransaction(transaction) - } - - /// Update an existing user account (requires admin access) - /// - /// - Parameters: - /// - name: Display name for the user - /// - login: Current login username - /// - newLogin: New login username (nil to keep current) - /// - 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(id: self.generateTransactionID(), type: .setUser) - transaction.setFieldString(type: .userName, val: name) - transaction.setFieldUInt64(type: .userAccess, val: access) - - if let newLogin { - transaction.setFieldEncodedString(type: .data, val: login) - transaction.setFieldEncodedString(type: .userLogin, val: newLogin) - } else { - transaction.setFieldEncodedString(type: .userLogin, val: login) - } - - // Password field handling: - // - nil: Keep current password (send zero byte) - // - "": Remove password (omit field) - // - other: Set new password - if password == nil { - transaction.setFieldUInt8(type: .userPassword, val: 0) - } else if password != "" { - transaction.setFieldEncodedString(type: .userPassword, val: password!) - } - - try await self.sendTransaction(transaction) - } - - /// Delete a user account (requires admin access) - /// - /// - Parameter login: Login username to delete - public func deleteUser(login: String) async throws { - var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .deleteUser) - transaction.setFieldEncodedString(type: .userLogin, val: login) - - try await self.sendTransaction(transaction) - } - - // 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(id: self.generateTransactionID(), type: .downloadBanner) - let reply = try await self.sendTransaction(transaction) - - guard - let transferSizeField = reply.getField(type: .transferSize), - let transferSize = transferSizeField.getInteger(), - let transferReferenceField = reply.getField(type: .referenceNumber), - let referenceNumber = transferReferenceField.getUInt32() - else { - return nil - } - - return (referenceNumber, transferSize) - } - - // MARK: - Transfers - - /// Request to download a file - /// - /// - Parameters: - /// - name: File name to download - /// - path: Directory path containing the file - /// - 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(id: self.generateTransactionID(), type: .downloadFile) - transaction.setFieldString(type: .fileName, val: name) - transaction.setFieldPath(type: .filePath, val: path) - - if preview { - transaction.setFieldUInt32(type: .fileTransferOptions, val: 2) - } - - let reply = try await self.sendTransaction(transaction) - - guard - let transferSizeField = reply.getField(type: .transferSize), - let transferSize = transferSizeField.getInteger(), - let transferReferenceField = reply.getField(type: .referenceNumber), - let referenceNumber = transferReferenceField.getUInt32() - else { - return nil - } - - let fileSize = reply.getField(type: .fileSize)?.getInteger() ?? transferSize - let waitingCount = reply.getField(type: .waitingCount)?.getInteger() ?? 0 - - return (referenceNumber, transferSize, fileSize, waitingCount) - } - - /// Request to download a folder - /// - /// - Parameters: - /// - name: Folder name to download - /// - 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(id: self.generateTransactionID(), type: .downloadFolder) - transaction.setFieldString(type: .fileName, val: name) - transaction.setFieldPath(type: .filePath, val: path) - - let reply = try await self.sendTransaction(transaction) - - guard - let transferSizeField = reply.getField(type: .transferSize), - let transferSize = transferSizeField.getInteger(), - let transferReferenceField = reply.getField(type: .referenceNumber), - let referenceNumber = transferReferenceField.getUInt32() - else { - return nil - } - - let itemCount = reply.getField(type: .folderItemCount)?.getInteger() ?? 0 - let waitingCount = reply.getField(type: .waitingCount)?.getInteger() ?? 0 - - return (referenceNumber, transferSize, itemCount, waitingCount) - } - - /// Uploads a file to the server - /// - Parameters: - /// - name: File name to upload - /// - 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(id: self.generateTransactionID(), type: .uploadFile) - transaction.setFieldString(type: .fileName, val: name) - transaction.setFieldPath(type: .filePath, val: path) - - let reply = try await self.sendTransaction(transaction) - - guard - let transferReferenceField = reply.getField(type: .referenceNumber), - let referenceNumber = transferReferenceField.getUInt32() - else { - return nil - } - - return referenceNumber - } - - /// Request to upload a folder - /// - /// - Parameters: - /// - name: Folder name to upload - /// - path: Directory path where the folder should be uploaded - /// - Returns: Reference number for the upload transfer - public func uploadFolder(name: String, path: [String], fileCount: UInt32, totalSize: UInt32) async throws -> UInt32? { - print("HotlineClient: uploadFolder request - name='\(name)', path=\(path), fileCount=\(fileCount), totalSize=\(totalSize)") - - 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) - transaction.setFieldUInt16(type: .folderItemCount, val: UInt16(truncatingIfNeeded: fileCount)) - - let reply = try await self.sendTransaction(transaction) - - guard - let transferReferenceField = reply.getField(type: .referenceNumber), - let referenceNumber = transferReferenceField.getUInt32() - else { - return nil - } - - return referenceNumber - } -} -- cgit From f87ff05d17cedf21845abbb8d750759ba725a263 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Tue, 11 Nov 2025 15:39:02 -0800 Subject: Folder download logic cleanup. Display folder icon on folder download transfers with small icon for the current file. Fix progress for folder downloads. --- .../Transfers/HotlineFolderDownloadClient.swift | 202 ++++++++----------- Hotline/Library/Extensions.swift | 5 + Hotline/Library/NetSocket/FileProgress.swift | 15 +- .../Library/NetSocket/TransferRateEstimator.swift | 3 + Hotline/Library/Views/FileIconView.swift | 12 +- Hotline/Models/TransferInfo.swift | 24 ++- Hotline/State/HotlineState.swift | 19 +- Hotline/macOS/TransfersView.swift | 224 ++++++++++++--------- 8 files changed, 270 insertions(+), 234 deletions(-) diff --git a/Hotline/Hotline/Transfers/HotlineFolderDownloadClient.swift b/Hotline/Hotline/Transfers/HotlineFolderDownloadClient.swift index 36193bd..a915043 100644 --- a/Hotline/Hotline/Transfers/HotlineFolderDownloadClient.swift +++ b/Hotline/Hotline/Transfers/HotlineFolderDownloadClient.swift @@ -14,13 +14,14 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { private let serverPort: UInt16 private let referenceNumber: UInt32 - private let transferTotal: Int - private let folderItemCount: Int - private var transferSize: Int = 0 + private let transferTotal: Int // Total byte size of all files in folder. + private let folderItemCount: Int // Total numbner of items in the folder hierarchy. private var socket: NetSocket? private var downloadTask: Task? private var folderProgress: Progress? + + private var estimator: TransferRateEstimator public init( address: String, @@ -34,6 +35,8 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { self.referenceNumber = reference self.transferTotal = Int(size) self.folderItemCount = itemCount + + self.estimator = TransferRateEstimator(total: self.transferTotal) } // MARK: - API @@ -41,8 +44,10 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { public func download( to location: HotlineDownloadLocation, progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil, - itemProgress itemProgressHandler: (@Sendable (HotlineFolderItemProgress) -> Void)? = nil + items itemProgressHandler: (@Sendable (HotlineFolderItemProgress) -> Void)? = nil ) async throws -> URL { + progressHandler?(.preparing) + self.downloadTask?.cancel() let task = Task { @@ -59,7 +64,7 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { self.downloadTask = nil return url } catch { - print("HotlineFolderDownloadClient[\(referenceNumber)]: Failed to download folder: \(error)") + print("HotlineFolderDownloadClient[\(self.referenceNumber)]: Failed to download folder: \(error)") self.downloadTask = nil progressHandler?(.error(error)) throw error @@ -68,10 +73,10 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { /// Cancel the current download public func cancel() { - downloadTask?.cancel() - downloadTask = nil + self.downloadTask?.cancel() + self.downloadTask = nil - if let socket = socket { + if let socket = self.socket { Task { await socket.close() } @@ -91,7 +96,10 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { progressHandler?(.connecting) // Connect to transfer server - let socket = try await connectToTransferServer() + let socket = try await NetSocket.connect( + host: self.serverAddress, + port: self.serverPort + 1 + ) self.socket = socket defer { Task { await socket.close() } } @@ -104,12 +112,11 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { destinationURL = url destinationFilename = url.lastPathComponent case .downloads(let filename): - let downloadsURL = fm.urls(for: .downloadsDirectory, in: .userDomainMask)[0] - destinationURL = URL(filePath: downloadsURL.generateUniqueFilePath(filename: filename)) + destinationURL = URL.downloadsDirectory.generateUniqueFileURL(filename: filename) destinationFilename = destinationURL.lastPathComponent } - print("HotlineFolderDownloadClient[\(referenceNumber)]: Downloading folder to \(destinationURL.path)") + print("HotlineFolderDownloadClient[\(self.referenceNumber)]: Downloading folder to \(destinationURL.path)") // Create destination folder try? fm.removeItem(at: destinationURL) @@ -120,10 +127,10 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { progress.fileURL = destinationURL progress.fileOperationKind = .downloading progress.publish() + defer { progress.unpublish() } self.folderProgress = progress // Send initial magic header - print("HotlineFolderDownloadClient[\(self.referenceNumber)]: Sending HTXF magic") try await socket.write(Data(endian: .big) { "HTXF".fourCharCode() self.referenceNumber @@ -137,7 +144,6 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { progressHandler?(.transfer(name: destinationFilename, size: 0, total: self.transferTotal, progress: 0.0, speed: nil, estimate: nil)) var completedItemCount = 0 - var totalBytesTransferred = 0 // Process each item in the folder while completedItemCount < self.folderItemCount { @@ -146,15 +152,12 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { let headerLen = Int(headerLenData.readUInt16(at: 0)!) let headerData = try await socket.read(headerLen) - totalBytesTransferred += 2 + headerLen + self.updateProgress(2 + headerLen) guard let (itemType, pathComponents) = self.parseItemHeaderPath(headerData) else { throw HotlineTransferClientError.failedToTransfer } - let joinedPath = pathComponents.joined(separator: "/") - print("HotlineFolderDownloadClient[\(referenceNumber)]: Item type=\(itemType) path=\(joinedPath)") - if itemType == 1 { // Folder entry - no progress shown for folder creation if !pathComponents.isEmpty { @@ -166,14 +169,14 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { completedItemCount += 1 // Request next item if not done - if completedItemCount < folderItemCount { + if completedItemCount < self.folderItemCount { try await sendAction(socket: socket, action: .nextFile) // nextFile } } else if itemType == 0 { // File entry let parentComponents = pathComponents.dropLast() - let fileName = pathComponents.last ?? "untitled" + let fileName = pathComponents.last ?? "Untitled" // Request file download try await sendAction(socket: socket, action: .sendFile) // sendFile @@ -181,47 +184,38 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { // Read file size let fileSizeData = try await socket.read(4) let fileSize = fileSizeData.readUInt32(at: 0)! - totalBytesTransferred += 4 - print("HotlineFolderDownloadClient[\(referenceNumber)]: File '\(fileName)' size: \(fileSize) bytes") + self.updateProgress(4) // Notify item progress before download starts completedItemCount += 1 itemProgressHandler?(HotlineFolderItemProgress( fileName: fileName, itemNumber: completedItemCount, - totalItems: folderItemCount + totalItems: self.folderItemCount )) // Download the file with overall folder progress tracking - let (fileURL, fileBytesRead) = try await downloadFile( + try await self.downloadFile( socket: socket, fileName: fileName, parentPath: Array(parentComponents), destinationFolder: destinationURL, fileSize: fileSize, - itemNumber: completedItemCount, - totalItems: folderItemCount, - totalBytesTransferredSoFar: totalBytesTransferred, progressHandler: progressHandler ) - totalBytesTransferred += fileBytesRead - self.transferSize = totalBytesTransferred - - print("HotlineFolderDownloadClient[\(referenceNumber)]: Downloaded file to \(fileURL.path)") - // Request next item if not done - if completedItemCount < folderItemCount { + if completedItemCount < self.folderItemCount { try await sendAction(socket: socket, action: .nextFile) // nextFile } } else { // Unknown item type - print("HotlineFolderDownloadClient[\(referenceNumber)]: Unknown item type \(itemType), skipping") + print("HotlineFolderDownloadClient[\(self.referenceNumber)]: Unknown item type \(itemType), skipping") completedItemCount += 1 - if completedItemCount < folderItemCount { + if completedItemCount < self.folderItemCount { try await sendAction(socket: socket, action: .nextFile) // nextFile } } @@ -231,25 +225,12 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { // Ensure folder progress shows 100% complete self.folderProgress?.completedUnitCount = Int64(self.transferTotal) - progressHandler?(.completed(url: destinationURL)) return destinationURL } - // MARK: - Helper Methods - - private func connectToTransferServer() async throws -> NetSocket { - print("HotlineFolderDownloadClient[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)") - - let socket = try await NetSocket.connect( - host: self.serverAddress, - port: self.serverPort + 1 - ) - - print("HotlineFolderDownloadClient[\(referenceNumber)]: Connected!") - return socket - } + // MARK: - private func sendAction(socket: NetSocket, action: HotlineFolderAction) async throws { let actionData = Data(endian: .big) { @@ -284,33 +265,34 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { } return (type, comps) } - + + @discardableResult + private func updateProgress(_ sent: Int) -> NetSocket.FileProgress { + let progress = self.estimator.update(bytes: sent) + self.folderProgress?.completedUnitCount = Int64(progress.sent) + return progress + } + + @discardableResult private func downloadFile( socket: NetSocket, fileName: String, parentPath: [String], destinationFolder: URL, fileSize: UInt32, - itemNumber: Int, - totalItems: Int, - totalBytesTransferredSoFar: Int, progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? - ) async throws -> (url: URL, bytesRead: Int) { + ) async throws -> URL { let fm = FileManager.default - var bytesRead = 0 +// var bytesRead = 0 // Read file header let headerData = try await socket.read(HotlineFileHeader.DataSize) guard let header = HotlineFileHeader(from: headerData) else { throw HotlineTransferClientError.failedToTransfer } - bytesRead += HotlineFileHeader.DataSize + self.updateProgress(HotlineFileHeader.DataSize) - // Update folder progress for file header - let totalBytesNow = totalBytesTransferredSoFar + bytesRead - self.folderProgress?.completedUnitCount = Int64(totalBytesNow) - - print("HotlineFolderDownloadClient[\(referenceNumber)]: File has \(header.forkCount) forks") + print("HotlineFolderDownloadClient[\(self.referenceNumber)]: File has \(header.forkCount) forks") var resourceForkData: Data? var fileHandle: FileHandle? @@ -328,27 +310,18 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { guard let forkHeader = HotlineFileForkHeader(from: forkHeaderData) else { throw HotlineTransferClientError.failedToTransfer } - bytesRead += HotlineFileForkHeader.DataSize - - // Update folder progress for fork header - let totalBytesNow = totalBytesTransferredSoFar + bytesRead - self.folderProgress?.completedUnitCount = Int64(totalBytesNow) + self.updateProgress(HotlineFileForkHeader.DataSize) if forkHeader.isInfoFork { - // Read INFO fork - print("HotlineFolderDownloadClient[\(referenceNumber)]: Reading INFO fork (\(forkHeader.dataSize) bytes)") + // Info fork let infoData = try await socket.read(Int(forkHeader.dataSize)) - bytesRead += infoData.count - - // Update folder progress for INFO fork - let totalBytesNow = totalBytesTransferredSoFar + bytesRead - self.folderProgress?.completedUnitCount = Int64(totalBytesNow) + self.updateProgress(infoData.count) guard let info = HotlineFileInfoFork(from: infoData) else { throw HotlineTransferClientError.failedToTransfer } - // Create parent folders + // Create parent folder let parentFolderURL = destinationFolder.appendingPathComponents(parentPath) if !fm.fileExists(atPath: parentFolderURL.path) { try fm.createDirectory(at: parentFolderURL, withIntermediateDirectories: true) @@ -364,63 +337,54 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { } else if forkHeader.isDataFork { - // Stream DATA fork to disk - print("HotlineFolderDownloadClient[\(referenceNumber)]: Reading DATA fork (\(forkHeader.dataSize) bytes)") - + // Data fork guard let fh = fileHandle else { throw HotlineTransferClientError.failedToTransfer } fileDataForkSize = Int(forkHeader.dataSize) - // Stream data fork using NetSocket's optimized file streaming + // Stream data fork to disk let updates = await socket.receiveFile(to: fh, length: fileDataForkSize) - for try await p in updates { - // Calculate overall folder progress - let totalBytesNow = totalBytesTransferredSoFar + bytesRead + p.sent - let rawProgress = self.transferTotal > 0 ? Double(totalBytesNow) / Double(self.transferTotal) : 0.0 - let overallProgress = min(rawProgress, 1.0) // Clamp to 1.0 to avoid exceeding 100% - - // Update folder-level Finder progress - self.folderProgress?.completedUnitCount = Int64(totalBytesNow) - - // Calculate overall folder time estimate based on current speed - let remainingBytes = max(0, self.transferTotal - totalBytesNow) - let estimate: TimeInterval? = if let speed = p.bytesPerSecond, speed > 0, remainingBytes > 0 { - TimeInterval(remainingBytes) / speed - } else { - nil - } - + for try await fileProgress in updates { + let progress = self.updateProgress(fileProgress.now) + // Report overall folder progress to UI progressHandler?(.transfer( name: fileName, - size: totalBytesNow, - total: self.transferTotal, - progress: overallProgress, - speed: p.bytesPerSecond, - estimate: estimate + size: progress.sent, + total: progress.total ?? 0, + progress: progress.progress, + speed: progress.bytesPerSecond, + estimate: progress.estimatedTimeRemaining )) } - bytesRead += fileDataForkSize } else if forkHeader.isResourceFork { - // Read RESOURCE fork + // Resource fork resourceForkData = try await socket.read(Int(forkHeader.dataSize)) - bytesRead += Int(forkHeader.dataSize) - - // Update folder progress for RESOURCE fork - let totalBytesNow = totalBytesTransferredSoFar + bytesRead - self.folderProgress?.completedUnitCount = Int64(totalBytesNow) + let progress = self.updateProgress(resourceForkData?.count ?? 0) + progressHandler?(.transfer( + name: fileName, + size: progress.sent, + total: progress.total ?? 0, + progress: progress.progress, + speed: progress.bytesPerSecond, + estimate: progress.estimatedTimeRemaining + )) } else { - // Skip unsupported fork + // Unsupported fork try await socket.skip(Int(forkHeader.dataSize)) - bytesRead += Int(forkHeader.dataSize) - - // Update folder progress for skipped fork - let totalBytesNow = totalBytesTransferredSoFar + bytesRead - self.folderProgress?.completedUnitCount = Int64(totalBytesNow) + let progress = self.updateProgress(Int(forkHeader.dataSize)) + progressHandler?(.transfer( + name: fileName, + size: progress.sent, + total: progress.total ?? 0, + progress: progress.progress, + speed: progress.bytesPerSecond, + estimate: progress.estimatedTimeRemaining + )) } } @@ -428,23 +392,19 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { try? fileHandle?.close() fileHandle = nil - guard let finalPath = filePath else { + guard let filePath else { throw HotlineTransferClientError.failedToTransfer } // Write resource fork if present if let rsrcData = resourceForkData, !rsrcData.isEmpty { - try writeResourceFork(data: rsrcData, to: finalPath) + try writeResourceFork(data: rsrcData, to: filePath) } - return (finalPath, bytesRead) + return filePath } private func writeResourceFork(data: Data, to url: URL) throws { - var resolvedURL = url - resolvedURL.resolveSymlinksInPath() - - let resourceURL = resolvedURL.urlForResourceFork() - try data.write(to: resourceURL) + try data.write(to: url.resolvingSymlinksInPath().urlForResourceFork()) } } diff --git a/Hotline/Library/Extensions.swift b/Hotline/Library/Extensions.swift index cf9f8ff..5ebc7db 100644 --- a/Hotline/Library/Extensions.swift +++ b/Hotline/Library/Extensions.swift @@ -147,6 +147,11 @@ extension URL { return filePath } + + func generateUniqueFileURL(filename base: String) -> URL { + let filePath = self.generateUniqueFilePath(filename: base) + return URL(filePath: filePath) + } } // MARK: - diff --git a/Hotline/Library/NetSocket/FileProgress.swift b/Hotline/Library/NetSocket/FileProgress.swift index c2306f3..2064c59 100644 --- a/Hotline/Library/NetSocket/FileProgress.swift +++ b/Hotline/Library/NetSocket/FileProgress.swift @@ -12,14 +12,27 @@ public extension NetSocket { public let sent: Int /// Total file size (may be nil if unknown) public let total: Int? + /// Size of most recent packet + public let now: Int + /// Total progress so far (0.0 to 1.0) + public let progress: Double /// Smoothed transfer rate in bytes per second (EMA), if enough samples collected public let bytesPerSecond: Double? /// Estimated time remaining (seconds) based on smoothed rate, if available public let estimatedTimeRemaining: TimeInterval? - public init(sent: Int, total: Int?, bytesPerSecond: Double? = nil, estimatedTimeRemaining: TimeInterval? = nil) { + public init(sent: Int, total: Int?, now: Int = 0, bytesPerSecond: Double? = nil, estimatedTimeRemaining: TimeInterval? = nil) { self.sent = sent self.total = total + self.now = now + + if let t = total { + self.progress = max(0.0, min(1.0, Double(sent) / Double(t))) + } + else { + self.progress = 0.0 + } + self.bytesPerSecond = bytesPerSecond self.estimatedTimeRemaining = estimatedTimeRemaining } diff --git a/Hotline/Library/NetSocket/TransferRateEstimator.swift b/Hotline/Library/NetSocket/TransferRateEstimator.swift index badf87a..61383ff 100644 --- a/Hotline/Library/NetSocket/TransferRateEstimator.swift +++ b/Hotline/Library/NetSocket/TransferRateEstimator.swift @@ -70,6 +70,7 @@ public struct TransferRateEstimator { self.minSamples = minSamples } + @discardableResult public mutating func update(total: Int) -> NetSocket.FileProgress { return self.update(bytes: max(0, total - self.transferred)) } @@ -80,6 +81,7 @@ public struct TransferRateEstimator { /// /// - Parameter bytes: Number of bytes transferred in this sample /// - Returns: Current progress with speed and ETA estimates + @discardableResult public mutating func update(bytes: Int) -> NetSocket.FileProgress { let clock = ContinuousClock() let now = clock.now @@ -127,6 +129,7 @@ public struct TransferRateEstimator { return NetSocket.FileProgress( sent: self.transferred, total: self.total, + now: bytes, bytesPerSecond: haveEstimate ? self.emaBytesPerSecond : nil, estimatedTimeRemaining: eta ) diff --git a/Hotline/Library/Views/FileIconView.swift b/Hotline/Library/Views/FileIconView.swift index d0949fa..836c990 100644 --- a/Hotline/Library/Views/FileIconView.swift +++ b/Hotline/Library/Views/FileIconView.swift @@ -2,7 +2,7 @@ import SwiftUI import UniformTypeIdentifiers struct FolderIconView: View { - private func folderIcon() -> Image { + private var folderIcon: Image { #if os(iOS) return Image(systemName: "folder.fill") #elseif os(macOS) @@ -11,7 +11,7 @@ struct FolderIconView: View { } var body: some View { - folderIcon() + self.folderIcon .resizable() .scaledToFit() } @@ -22,7 +22,7 @@ struct FileIconView: View { let fileType: String? #if os(iOS) - private func fileIcon() -> Image { + private var fileIcon: Image { let fileExtension = (self.filename as NSString).pathExtension if let fileType = UTType(filenameExtension: fileExtension) { if fileType.isSubtype(of: .movie) { @@ -45,7 +45,7 @@ struct FileIconView: View { return Image(systemName: "doc") } #elseif os(macOS) - private func fileIcon() -> Image { + private var fileIcon: Image { let fileExtension = (self.filename as NSString).pathExtension if !fileExtension.isEmpty, @@ -60,14 +60,12 @@ struct FileIconView: View { else { return Image(nsImage: NSWorkspace.shared.icon(for: UTType.data)) } - -// Image(nsImage: NSWorkspace.shared.icon(for: UTType(filenameExtension: (filename as NSString).pathExtension) ?? UTType.content)) } #endif var body: some View { - fileIcon() + self.fileIcon .resizable() .scaledToFit() } diff --git a/Hotline/Models/TransferInfo.swift b/Hotline/Models/TransferInfo.swift index 10535bf..ebe9f64 100644 --- a/Hotline/Models/TransferInfo.swift +++ b/Hotline/Models/TransferInfo.swift @@ -7,23 +7,35 @@ class TransferInfo: Identifiable, Equatable, Hashable { var referenceNumber: UInt32 var title: String var size: UInt - var progress: Double = 0.0 - var speed: Double? = nil - var timeRemaining: TimeInterval? = nil + + // Status var completed: Bool = false var failed: Bool = false var cancelled: Bool = false - var isFolder: Bool = false - var done: Bool { self.completed || self.failed || self.cancelled } + + var progress: Double = 0.0 + var speed: Double? = nil + var timeRemaining: TimeInterval? = nil + + var isUpload: Bool = false + var isDownload: Bool { + get { !self.isUpload } + set { self.isUpload = !newValue } + } + + // Folder transfers + var isFolder: Bool = false + var folderName: String? = nil + var fileName: String? = nil // Server association - tracks which HotlineState this transfer belongs to var serverID: UUID var serverName: String? - // For file based transfers (i.e. not previews) + // For file based transfers var fileURL: URL? = nil var progressCallback: ((TransferInfo) -> Void)? = nil diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index f814818..7335f37 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -895,6 +895,7 @@ class HotlineState: Equatable { serverID: self.id, serverName: self.serverName ?? self.serverTitle ) + transfer.isUpload = false transfer.downloadCallback = callback transfer.progressCallback = progressCallback AppState.shared.addTransfer(transfer) @@ -980,7 +981,7 @@ class HotlineState: Equatable { path: [String], to destination: URL? = nil, progress progressCallback: ((TransferInfo) -> Void)? = nil, - itemProgress itemProgressCallback: ((TransferInfo, String, Int, Int) -> Void)? = nil, +// itemProgress itemProgressCallback: ((TransferInfo, String, Int, Int) -> Void)? = nil, complete callback: ((TransferInfo) -> Void)? = nil ) { guard let client = self.client else { return } @@ -1013,6 +1014,8 @@ class HotlineState: Equatable { serverName: self.serverName ?? self.serverTitle ) transfer.isFolder = true + transfer.folderName = folderName + transfer.isUpload = false transfer.downloadCallback = callback transfer.progressCallback = progressCallback AppState.shared.addTransfer(transfer) @@ -1031,8 +1034,6 @@ class HotlineState: Equatable { guard self != nil else { return } do { - let folderURL: URL - // Download folder with progress tracking let location: HotlineDownloadLocation = if let destination { .url(destination) @@ -1040,7 +1041,7 @@ class HotlineState: Equatable { .downloads(folderName) } - folderURL = try await downloadClient.download(to: location, progress: { progress in + let folderURL = try await downloadClient.download(to: location, progress: { progress in switch progress { case .preparing: break @@ -1057,14 +1058,14 @@ class HotlineState: Equatable { transfer.completed = true transfer.fileURL = url } - }, itemProgress: { itemInfo in - // Update transfer title with current file being downloaded - transfer.title = "\(itemInfo.fileName) (\(itemInfo.itemNumber)/\(itemInfo.totalItems))" - itemProgressCallback?(transfer, itemInfo.fileName, itemInfo.itemNumber, itemInfo.totalItems) + }, items: { item in + transfer.title = item.fileName + transfer.fileName = item.fileName }) // Mark as completed transfer.progress = 1.0 + transfer.fileName = nil transfer.title = folderName // Reset title to folder name // Call completion callback @@ -1174,6 +1175,7 @@ class HotlineState: Equatable { serverName: self.serverName ?? self.serverTitle ) transfer.isFolder = true + transfer.isUpload = true transfer.uploadCallback = callback transfer.progressCallback = progressCallback AppState.shared.addTransfer(transfer) @@ -1294,6 +1296,7 @@ class HotlineState: Equatable { serverID: self.id, serverName: self.serverName ?? self.serverTitle ) + transfer.isUpload = true transfer.uploadCallback = callback AppState.shared.addTransfer(transfer) diff --git a/Hotline/macOS/TransfersView.swift b/Hotline/macOS/TransfersView.swift index bf8c8bd..5f9357d 100644 --- a/Hotline/macOS/TransfersView.swift +++ b/Hotline/macOS/TransfersView.swift @@ -70,58 +70,70 @@ struct TransfersView: View { .listStyle(.inset) .environment(\.defaultMinListRowHeight, 56) .contextMenu(forSelectionType: TransferInfo.self) { items in - if items.allSatisfy(\.completed) { - let fileURLs: [URL] = items.compactMap(\.fileURL) - - Button("Remove Transfer\(items.count > 1 ? "s" : "")", systemImage: "xmark") { - self.appState.cancelTransfers(ids: items.map(\.id)) - self.selectedTransfers = [] - } - - Divider() - - Button("Open", systemImage: "arrow.up.right.square") { - for fileURL in fileURLs { - NSWorkspace.shared.open(fileURL) - } - } - - self.openWithMenu(for: fileURLs) - - Button("Show in Finder", systemImage: "finder") { - NSWorkspace.shared.activateFileViewerSelecting(fileURLs) + self.contextMenuForItems(items) + } primaryAction: { items in + self.performPrimaryAction(for: items) + } + } + + // MARK: - Double Click + + private func performPrimaryAction(for items: Set) { + if let fileURL = items.first?.fileURL { + NSWorkspace.shared.open(fileURL) + } + } + + // MARK: - Context Menu + + @ViewBuilder + private func contextMenuForItems(_ items: Set) -> some View { + if items.allSatisfy(\.completed) { + let fileURLs: [URL] = items.compactMap(\.fileURL) + + Button("Remove Transfer\(items.count > 1 ? "s" : "")", systemImage: "xmark") { + self.appState.cancelTransfers(ids: items.map(\.id)) + self.selectedTransfers = [] + } + + Divider() + + Button("Open", systemImage: "arrow.up.right.square") { + for fileURL in fileURLs { + NSWorkspace.shared.open(fileURL) } + } + + self.openWithMenu(for: fileURLs) + + Button("Show in Finder", systemImage: "finder") { + NSWorkspace.shared.activateFileViewerSelecting(fileURLs) + } + + Divider() + + Button("Move to Trash", systemImage: "trash") { + self.appState.cancelTransfers(ids: items.map(\.id)) + NSWorkspace.shared.recycle(fileURLs) + self.selectedTransfers = [] + } + } else { + Button("Remove Transfer\(items.count > 1 ? "s" : "")", systemImage: "xmark") { + self.appState.cancelTransfers(ids: items.map(\.id)) + self.selectedTransfers = [] + } + + Divider() + + Button("Move to Trash", systemImage: "trash") { + self.appState.cancelTransfers(ids: items.map(\.id)) - Divider() - - Button("Move to Trash", systemImage: "trash") { - self.appState.cancelTransfers(ids: items.map(\.id)) + let fileURLs: [URL] = items.compactMap(\.fileURL) + if !fileURLs.isEmpty { NSWorkspace.shared.recycle(fileURLs) - self.selectedTransfers = [] } - } - else { - Button("Remove Transfer\(items.count > 1 ? "s" : "")", systemImage: "xmark") { - self.appState.cancelTransfers(ids: items.map(\.id)) - self.selectedTransfers = [] - } - - Divider() - Button("Move to Trash", systemImage: "trash") { - self.appState.cancelTransfers(ids: items.map(\.id)) - - let fileURLs: [URL] = items.compactMap(\.fileURL) - if !fileURLs.isEmpty { - NSWorkspace.shared.recycle(fileURLs) - } - - self.selectedTransfers = [] - } - } - } primaryAction: { items in - if let fileURL = items.first?.fileURL { - NSWorkspace.shared.open(fileURL) + self.selectedTransfers = [] } } } @@ -250,6 +262,7 @@ struct TransfersView: View { } } } + } // MARK: - Transfer Row @@ -259,6 +272,56 @@ struct TransferRow: View { @Bindable var transfer: TransferInfo + var body: some View { + HStack(alignment: .center, spacing: 8) { + if self.transfer.isFolder { + self.folderIconView + } + else { + self.fileIconView + } + + VStack(alignment: .leading, spacing: 2) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(self.transfer.title) + .font(.headline) + .lineLimit(1) + .truncationMode(.tail) + + Spacer() + + if !self.transfer.done { + self.statsView + } + } + + // Progress bar and status + if self.transfer.cancelled { + Text("Cancelled") + .font(.subheadline) + .foregroundStyle(.secondary) + } + else if self.transfer.failed { + Text("Failed") + .font(.subheadline) + .foregroundStyle(.secondary) + } + else if self.transfer.completed { + Text("Downloaded") + .font(.subheadline) + .foregroundStyle(.fileComplete) + } + else { + ProgressView(value: self.transfer.progress, total: 1.0) + .progressViewStyle(.linear) + .controlSize(.large) + } + } + } + } + + // MARK: - + private var statsView: some View { HStack(spacing: 8) { // Progress percentage @@ -266,28 +329,24 @@ struct TransferRow: View { // Speed if let speed = self.transfer.speed { - // TODO: Use arrow.up for uploads. - Label(self.formatSpeed(speed), systemImage: "arrow.down") -// Text(self.formatSpeed(speed)) + Label(self.formatSpeed(speed), systemImage: self.transfer.isUpload ? "arrow.up" : "arrow.down") } // Time remaining if let timeRemaining = self.transfer.timeRemaining { Label(self.formatTimeRemaining(timeRemaining), systemImage: "clock") -// Text(self.formatTimeRemaining(timeRemaining)) } // File size Label(self.formatSize(self.transfer.size), systemImage: "document") -// Text(self.formatSize(self.transfer.size)) } .font(.subheadline) .foregroundStyle(.secondary) .monospacedDigit() } - private var fileIconView: some View { - FileIconView(filename: self.transfer.title, fileType: nil) + private var folderIconView: some View { + FolderIconView() .frame(width: 32, height: 32) .overlay(alignment: .bottomTrailing) { if self.transfer.cancelled || self.transfer.failed { @@ -305,50 +364,33 @@ struct TransferRow: View { .scaledToFit() .frame(width: 16, height: 16) } + else { + FileIconView(filename: self.transfer.title, fileType: nil) + .frame(width: 16, height: 16) + } } } - var body: some View { - HStack(alignment: .center, spacing: 8) { - self.fileIconView - - VStack(alignment: .leading, spacing: 2) { - HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(self.transfer.title) - .font(.headline) - .lineLimit(1) - .truncationMode(.tail) - - Spacer() - - if !self.transfer.done { - self.statsView - } - } - - // Progress bar and status - if self.transfer.cancelled { - Text("Cancelled") - .font(.subheadline) - .foregroundStyle(.secondary) - } - else if self.transfer.failed { - Text("Failed") - .font(.subheadline) - .foregroundStyle(.secondary) + private var fileIconView: some View { + FileIconView(filename: self.transfer.title, fileType: nil) + .frame(width: 32, height: 32) + .overlay(alignment: .bottomTrailing) { + if self.transfer.cancelled || self.transfer.failed { + Image(systemName: "exclamationmark.triangle.fill") + .resizable() + .symbolRenderingMode(.multicolor) + .scaledToFit() + .frame(width: 16, height: 16) } else if self.transfer.completed { - Text("Downloaded") - .font(.subheadline) - .foregroundStyle(.fileComplete) - } - else { - ProgressView(value: self.transfer.progress, total: 1.0) - .progressViewStyle(.linear) - .controlSize(.large) + Image(systemName: "checkmark.circle.fill") + .resizable() + .symbolRenderingMode(.palette) + .foregroundStyle(.white, .fileComplete) + .scaledToFit() + .frame(width: 16, height: 16) } } - } } // MARK: - Formatting -- cgit From 86560ac84504f2da63c8fb08505857d8827684d4 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Tue, 11 Nov 2025 16:13:47 -0800 Subject: Use linear progress bar for transfers in the Server side bar. --- Hotline/Models/TransferInfo.swift | 43 ++++++++++++++++++ Hotline/macOS/ServerView.swift | 92 ++++++++++++++++++++++----------------- Hotline/macOS/TransfersView.swift | 54 +++++++---------------- 3 files changed, 111 insertions(+), 78 deletions(-) diff --git a/Hotline/Models/TransferInfo.swift b/Hotline/Models/TransferInfo.swift index ebe9f64..cac4b90 100644 --- a/Hotline/Models/TransferInfo.swift +++ b/Hotline/Models/TransferInfo.swift @@ -58,4 +58,47 @@ class TransferInfo: Identifiable, Equatable, Hashable { func hash(into hasher: inout Hasher) { hasher.combine(self.id) } + +// var formatSize(_ bytes: UInt) -> String { +// let formatter = ByteCountFormatter() +// formatter.countStyle = .file +// formatter.allowedUnits = [.useKB, .useMB, .useGB] +// return formatter.string(fromByteCount: Int64(bytes)) +// } + + var displaySize: String { + let formatter = ByteCountFormatter() + formatter.countStyle = .file + formatter.allowedUnits = [.useKB, .useMB, .useGB] + return formatter.string(fromByteCount: Int64(self.size)) + } + + var displaySpeed: String? { + guard let speed = self.speed else { + return nil + } + + let formatter = ByteCountFormatter() + formatter.countStyle = .file + formatter.allowedUnits = [.useKB, .useMB, .useGB] + return "\(formatter.string(fromByteCount: Int64(speed)))/s" + } + + var displayTimeRemaining: String? { + guard let timeRemaining = self.timeRemaining else { + return nil + } + + if timeRemaining < 60 { + return "\(Int(timeRemaining))s" + } else if timeRemaining < 3600 { + let minutes = Int(timeRemaining / 60) + let secs = Int(timeRemaining.truncatingRemainder(dividingBy: 60)) + return "\(minutes)m \(secs)s" + } else { + let hours = Int(timeRemaining / 3600) + let minutes = Int((timeRemaining.truncatingRemainder(dividingBy: 3600)) / 60) + return "\(hours)h \(minutes)m" + } + } } diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index d4b3407..98ab976 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -107,13 +107,10 @@ struct ServerView: View { if self.model.status == .disconnected { VStack(alignment: .center) { Spacer() -// if self.connectionDisplayed { - self.connectForm -// } + self.connectForm Spacer() } .navigationTitle("Connect to Server") -// .animation(.default.delay(0.25), value: self.connectionDisplayed) } else if self.model.status.isLoggingIn { HStack { @@ -281,7 +278,7 @@ struct ServerView: View { var transfersSection: some View { ForEach(model.transfers) { transfer in - TransferItemView(transfer: transfer) + ServerTransferRow(transfer: transfer) } } @@ -421,7 +418,7 @@ struct ServerView: View { } -struct TransferItemView: View { +struct ServerTransferRow: View { let transfer: TransferInfo @Environment(\.controlActiveState) private var controlActiveState @@ -429,29 +426,11 @@ struct TransferItemView: View { @State private var hovered: Bool = false @State private var buttonHovered: Bool = false - private func formattedProgressHelp() -> String { - if self.transfer.completed { - return "File transfer complete" - } - else if self.transfer.failed { - return "File transfer failed" - } - else if self.transfer.progress > 0.0 { - if let estimate = self.transfer.timeRemaining, estimate > 0.0 { - return "\(round(self.transfer.progress * 100.0))% – \(estimate) seconds left" - } - else { - return "\(round(self.transfer.progress * 100.0))% complete" - } - } - return "" - } - var body: some View { HStack(alignment: .center, spacing: 5) { HStack(spacing: 0) { Spacer() - if transfer.isFolder { + if self.transfer.isFolder { Image("Folder") .resizable() .scaledToFit() @@ -467,11 +446,26 @@ struct TransferItemView: View { } .frame(width: 20) - Text(transfer.title) + Text(self.transfer.folderName ?? self.transfer.title) .lineLimit(1) .truncationMode(.middle) - Spacer() + Spacer(minLength: 0) + + if !self.transfer.done { + if self.transfer.progress == 0.0 { + ProgressView() + .progressViewStyle(.linear) + .controlSize(.extraLarge) + .frame(maxWidth: 40) + } + else { + ProgressView(value: self.transfer.progress, total: 1.0) + .progressViewStyle(.linear) + .controlSize(.extraLarge) + .frame(maxWidth: 40) + } + } if self.hovered { Button { @@ -509,19 +503,9 @@ struct TransferItemView: View { .frame(width: 16, height: 16) .opacity(controlActiveState == .inactive ? 0.5 : 1.0) } - else if transfer.progress == 0.0 { - ProgressView() - .progressViewStyle(.circular) - .controlSize(.small) - } - else { - ProgressView(value: transfer.progress, total: 1.0) - .progressViewStyle(.circular) - .controlSize(.small) - } } .onHover { hovered in - withAnimation(.easeOut(duration: 0.25)) { + withAnimation(.snappy(duration: 0.25, extraBounce: 0.3)) { self.hovered = hovered } } @@ -529,9 +513,37 @@ struct TransferItemView: View { guard transfer.completed, let url = transfer.fileURL else { return } - + NSWorkspace.shared.activateFileViewerSelecting([url]) } - .help(formattedProgressHelp()) + .help(self.formattedProgressHelp) + } + + private var formattedProgressHelp: String { + if self.transfer.completed { + return "File transfer complete" + } + else if self.transfer.failed { + return "File transfer failed" + } + else if self.transfer.cancelled { + return "File transfer cancelled" + } + else if self.transfer.progress > 0.0 { + var parts: [String] = [] + + if let speed = self.transfer.displaySpeed { + parts.append(speed) + } + + if let timeRemaining = self.transfer.displayTimeRemaining { + parts.append(timeRemaining) + } + + if parts.count > 0 { + return parts.joined(separator: " • ") + } + } + return "" } } diff --git a/Hotline/macOS/TransfersView.swift b/Hotline/macOS/TransfersView.swift index 5f9357d..9bef02c 100644 --- a/Hotline/macOS/TransfersView.swift +++ b/Hotline/macOS/TransfersView.swift @@ -312,9 +312,17 @@ struct TransferRow: View { .foregroundStyle(.fileComplete) } else { - ProgressView(value: self.transfer.progress, total: 1.0) - .progressViewStyle(.linear) - .controlSize(.large) + if self.transfer.progress == 0 { + ProgressView() + .progressViewStyle(.linear) + .controlSize(.large) + } + else { + ProgressView(value: self.transfer.progress, total: 1.0) + .progressViewStyle(.linear) + .controlSize(.large) + } + } } } @@ -328,17 +336,17 @@ struct TransferRow: View { // Text("\(Int(self.transfer.progress * 100))%") // Speed - if let speed = self.transfer.speed { - Label(self.formatSpeed(speed), systemImage: self.transfer.isUpload ? "arrow.up" : "arrow.down") + if let speed = self.transfer.displaySpeed { + Label(speed, systemImage: self.transfer.isUpload ? "arrow.up" : "arrow.down") } // Time remaining - if let timeRemaining = self.transfer.timeRemaining { - Label(self.formatTimeRemaining(timeRemaining), systemImage: "clock") + if let timeRemaining = self.transfer.displayTimeRemaining { + Label(timeRemaining, systemImage: "clock") } // File size - Label(self.formatSize(self.transfer.size), systemImage: "document") + Label(self.transfer.displaySize, systemImage: "document") } .font(.subheadline) .foregroundStyle(.secondary) @@ -392,36 +400,6 @@ struct TransferRow: View { } } } - - // MARK: - Formatting - - private func formatSize(_ bytes: UInt) -> String { - let formatter = ByteCountFormatter() - formatter.countStyle = .file - formatter.allowedUnits = [.useKB, .useMB, .useGB] - return formatter.string(fromByteCount: Int64(bytes)) - } - - private func formatSpeed(_ bytesPerSecond: Double) -> String { - let formatter = ByteCountFormatter() - formatter.countStyle = .file - formatter.allowedUnits = [.useKB, .useMB, .useGB] - return "\(formatter.string(fromByteCount: Int64(bytesPerSecond)))/s" - } - - private func formatTimeRemaining(_ seconds: TimeInterval) -> String { - if seconds < 60 { - return "\(Int(seconds))s" - } else if seconds < 3600 { - let minutes = Int(seconds / 60) - let secs = Int(seconds.truncatingRemainder(dividingBy: 60)) - return "\(minutes)m \(secs)s" - } else { - let hours = Int(seconds / 3600) - let minutes = Int((seconds.truncatingRemainder(dividingBy: 3600)) / 60) - return "\(hours)h \(minutes)m" - } - } } // MARK: - Preview -- cgit From bcc7775dcace8593870e3afc12de21c871114d54 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Tue, 11 Nov 2025 17:14:13 -0800 Subject: Add confirmation for file/folder delete. Also add New Folder button to files. --- Hotline/Hotline/HotlineClient.swift | 160 ++++++++++++++++-------------------- Hotline/State/HotlineState.swift | 9 ++ Hotline/macOS/Files/FilesView.swift | 124 +++++++++++++++++++++++----- 3 files changed, 182 insertions(+), 111 deletions(-) diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index cd34d9e..1faaf55 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -152,15 +152,6 @@ public actor HotlineClient { // Keep-alive timer private var keepAliveTask: Task? - // MARK: - Static Handshake - - private static let handshakeData = Data(endian: .big, { - "TRTP".fourCharCode() // 'TRTP' protocol ID - "HOTL".fourCharCode() // 'HOTL' sub-protocol ID - UInt16(0x0001) // Version - UInt16(0x0002) // Sub-version - }) - // Transaction IDs private var nextTransactionID: UInt32 = 1 private func generateTransactionID() -> UInt32 { @@ -199,7 +190,12 @@ public actor HotlineClient { // Perform handshake print("HotlineClient.connect(): Sending handshake...") - try await socket.write(handshakeData) + try await socket.write(Data(endian: .big, { + "TRTP".fourCharCode() // 'TRTP' protocol ID + "HOTL".fourCharCode() // 'HOTL' sub-protocol ID + UInt16(0x0001) // Version + UInt16(0x0002) // Sub-version + })) let handshakeResponse = try await socket.read(8) print("HotlineClient.connect(): Handshake response received") @@ -628,40 +624,82 @@ public actor HotlineClient { return files } - - /// Request to download a file + + /// Get detailed information about a file /// /// - Parameters: /// - name: File name /// - path: Directory path containing the file - /// - preview: Request preview/thumbnail instead of full file - /// - Returns: Transfer info (reference number, size, waiting count) - public func downloadFile( - name: String, - path: [String], - preview: Bool = false - ) async throws -> (referenceNumber: UInt32, size: Int, fileSize: Int?, waitingCount: Int?) { - var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .downloadFile) + /// - Returns: File details or nil if not found + public func getFileInfo(name: String, path: [String]) async throws -> FileDetails? { + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getFileInfo) transaction.setFieldString(type: .fileName, val: name) transaction.setFieldPath(type: .filePath, val: path) - if preview { - transaction.setFieldUInt32(type: .fileTransferOptions, val: 2) - } - - let reply = try await self.sendTransaction(transaction) + let reply = try await sendTransaction(transaction) guard - let transferSize = reply.getField(type: .transferSize)?.getInteger(), - let referenceNumber = reply.getField(type: .referenceNumber)?.getUInt32() + let fileName = reply.getField(type: .fileName)?.getString(), + let fileCreator = reply.getField(type: .fileCreatorString)?.getString(), + let fileType = reply.getField(type: .fileTypeString)?.getString(), + let fileCreateDate = reply.getField(type: .fileCreateDate)?.data.readDate(at: 0), + let fileModifyDate = reply.getField(type: .fileModifyDate)?.data.readDate(at: 0) else { - throw HotlineClientError.invalidResponse + return nil } - let fileSize = reply.getField(type: .fileSize)?.getInteger() - let waitingCount = reply.getField(type: .waitingCount)?.getInteger() + // Size field is not included in server reply for folders + let fileSize = reply.getField(type: .fileSize)?.getInteger() ?? 0 + let fileComment = reply.getField(type: .fileComment)?.getString() ?? "" - return (referenceNumber, transferSize, fileSize, waitingCount) + return FileDetails( + name: fileName, + path: path, + size: fileSize, + comment: fileComment, + type: fileType, + creator: fileCreator, + created: fileCreateDate, + modified: fileModifyDate + ) + } + + /// Delete a file or folder + /// + /// - Parameters: + /// - name: File or folder name + /// - path: Directory path containing the item + /// - Returns: True if deletion succeeded + public func deleteFile(name: String, path: [String]) async throws -> Bool { + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .deleteFile) + transaction.setFieldString(type: .fileName, val: name) + transaction.setFieldPath(type: .filePath, val: path) + + do { + try await self.sendTransaction(transaction) + return true + } catch { + return false + } + } + + /// Create a folder + /// + /// - Parameters: + /// - name: New folder name + /// - path: Directory path for the new folder + /// - Returns: True if creation succeeded + public func newFolder(name: String, path: [String]) async throws -> Bool { + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .newFolder) + transaction.setFieldString(type: .fileName, val: name) + transaction.setFieldPath(type: .filePath, val: path) + + do { + try await self.sendTransaction(transaction) + return true + } catch { + return false + } } // MARK: - News @@ -787,66 +825,6 @@ public actor HotlineClient { try await self.socket.send(transaction, endian: .big) } - // MARK: - File Operations - - /// Get detailed information about a file - /// - /// - Parameters: - /// - name: File name - /// - 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(id: self.generateTransactionID(), type: .getFileInfo) - transaction.setFieldString(type: .fileName, val: name) - transaction.setFieldPath(type: .filePath, val: path) - - let reply = try await sendTransaction(transaction) - - guard - let fileName = reply.getField(type: .fileName)?.getString(), - let fileCreator = reply.getField(type: .fileCreatorString)?.getString(), - let fileType = reply.getField(type: .fileTypeString)?.getString(), - let fileCreateDate = reply.getField(type: .fileCreateDate)?.data.readDate(at: 0), - let fileModifyDate = reply.getField(type: .fileModifyDate)?.data.readDate(at: 0) - else { - return nil - } - - // Size field is not included in server reply for folders - let fileSize = reply.getField(type: .fileSize)?.getInteger() ?? 0 - let fileComment = reply.getField(type: .fileComment)?.getString() ?? "" - - return FileDetails( - name: fileName, - path: path, - size: fileSize, - comment: fileComment, - type: fileType, - creator: fileCreator, - created: fileCreateDate, - modified: fileModifyDate - ) - } - - /// Delete a file or folder - /// - /// - Parameters: - /// - name: File or folder name - /// - path: Directory path containing the item - /// - Returns: True if deletion succeeded - public func deleteFile(name: String, path: [String]) async throws -> Bool { - var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .deleteFile) - transaction.setFieldString(type: .fileName, val: name) - transaction.setFieldPath(type: .filePath, val: path) - - do { - try await self.sendTransaction(transaction) - return true - } catch { - return false - } - } - // MARK: - Administration /// Get list of user accounts (requires admin access) diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index 7335f37..5d6471c 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -835,6 +835,15 @@ class HotlineState: Equatable { return try await client.getFileInfo(name: fileName, path: fullPath) } + + @MainActor + func newFolder(name: String, parentPath: [String]) async throws -> Bool { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + return try await client.newFolder(name: name, path: parentPath) + } @MainActor func deleteFile(_ fileName: String, path: [String]) async throws -> Bool { diff --git a/Hotline/macOS/Files/FilesView.swift b/Hotline/macOS/Files/FilesView.swift index 984b120..3732d9a 100644 --- a/Hotline/macOS/Files/FilesView.swift +++ b/Hotline/macOS/Files/FilesView.swift @@ -2,9 +2,36 @@ import SwiftUI import UniformTypeIdentifiers import AppKit - - - +struct NewFolderSheet: View { + @Environment(\.dismiss) private var dismiss + + let action: ((String) -> Void)? + + @State private var folderName: String = "Untitled" + + var body: some View { + Form { + TextField(text: self.$folderName) { + Text("Folder Name") + } + } + .formStyle(.grouped) + .fixedSize(horizontal: false, vertical: true) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("New Folder") { + self.dismiss() + self.action?(self.folderName) + } + } + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + self.dismiss() + } + } + } + } +} struct FilesView: View { @Environment(HotlineState.self) private var model: HotlineState @@ -16,6 +43,8 @@ struct FilesView: View { @State private var searchText: String = "" @State private var isSearching: Bool = false @State private var dragOver: Bool = false + @State private var deleteConfirmationDisplayed: Bool = false + @State private var newFolderSheetDisplayed: Bool = false var body: some View { NavigationStack { @@ -98,13 +127,9 @@ struct FilesView: View { Divider() Button { - if let s = selectedFile { - Task { - await deleteFile(s) - } - } + self.deleteConfirmationDisplayed = true } label: { - Label("Delete", systemImage: "trash") + Label("Delete...", systemImage: "trash") } .disabled(selectedFile == nil) } @@ -154,38 +179,44 @@ struct FilesView: View { .searchable(text: $searchText, isPresented: $isSearching, placement: .automatic, prompt: "Search") .background(Button("", action: { isSearching = true }).keyboardShortcut("f").hidden()) .toolbar { - ToolbarItemGroup(placement: .automatic) { + ToolbarItem { Button { if let selectedFile = selection, selectedFile.isPreviewable { - previewFile(selectedFile) + self.previewFile(selectedFile) } } label: { Label("Preview", systemImage: "eye") } .help("Preview") .disabled(selection == nil || selection?.isPreviewable != true) - + } + + ToolbarItem { Button { if let selectedFile = selection { - getFileInfo(selectedFile) + self.getFileInfo(selectedFile) } } label: { Label("Get Info", systemImage: "info.circle") } .help("Get Info") .disabled(selection == nil) - + } + + ToolbarItem { Button { - uploadFileSelectorDisplayed = true + self.uploadFileSelectorDisplayed = true } label: { Label("Upload", systemImage: "arrow.up") } .help("Upload") .disabled(model.access?.contains(.canUploadFiles) != true) - + } + + ToolbarItem { Button { if let selectedFile = selection { - downloadFile(selectedFile) + self.downloadFile(selectedFile) } } label: { Label("Download", systemImage: "arrow.down") @@ -193,9 +224,48 @@ struct FilesView: View { .help("Download") .disabled(selection == nil || model.access?.contains(.canDownloadFiles) != true) } + + if #available(macOS 26.0, *) { + ToolbarSpacer() + } + + ToolbarItem { + Button { + self.newFolderSheetDisplayed = true + } label: { + Label("New Folder", systemImage: "folder.badge.plus") + } + .help("New Folder") + } + + ToolbarItem { + Button { + self.deleteConfirmationDisplayed = true + } label: { + Label("Delete", systemImage: "trash") + } + .disabled(self.selection == nil) + .help("Delete") + } + } + } + .alert("Are you sure you want to permanently delete \"\(self.selection?.name ?? "this file")\"?", isPresented: self.$deleteConfirmationDisplayed, actions: { + Button("Delete", role: .destructive) { + if let s = self.selection { + Task { + await self.deleteFile(s) + } + } + } + }, message: { + Text("You cannot undo this action.") + }) + .sheet(isPresented: self.$newFolderSheetDisplayed) { + NewFolderSheet { folderName in + self.newFolder(name: folderName, parent: self.selection) } } - .sheet(item: $fileDetails ) { item in + .sheet(item: $fileDetails) { item in FileDetailsView(fd: item) } .fileImporter(isPresented: $uploadFileSelectorDisplayed, allowedContentTypes: [.data, .folder], allowsMultipleSelection: false, onCompletion: { results in @@ -385,6 +455,20 @@ struct FilesView: View { } } + @MainActor private func newFolder(name: String, parent: FileInfo?) { + Task { + var parentFolder: FileInfo? = nil + if parent?.isFolder == true { + parentFolder = parent + } + + let path: [String] = parentFolder?.path ?? [] + if try await self.model.newFolder(name: name, parentPath: path) == true { + try await self.model.getFileList(path: path) + } + } + } + @MainActor private func getFileInfo(_ file: FileInfo) { Task { if let fileInfo = try? await model.getFileDetails(file.name, path: file.path) { @@ -405,10 +489,10 @@ struct FilesView: View { } @MainActor private func uploadFile(file fileURL: URL, to path: [String]) { - model.uploadFile(url: fileURL, path: path) { info in + self.model.uploadFile(url: fileURL, path: path) { info in Task { // Refresh file listing to display newly uploaded file. - let _ = try? await model.getFileList(path: path) + try? await self.model.getFileList(path: path) } } } -- cgit From b58129025413367d8237b3147a9fa96bd4e6924f Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Tue, 11 Nov 2025 17:46:31 -0800 Subject: Fix up File Info sheet. --- Hotline.xcodeproj/project.pbxproj | 12 ++- Hotline/macOS/Files/FileDetailsSheet.swift | 141 +++++++++++++++++++++++++++ Hotline/macOS/Files/FileDetailsView.swift | 147 ----------------------------- Hotline/macOS/Files/FilesView.swift | 35 +------ Hotline/macOS/Files/NewFolderSheet.swift | 32 +++++++ 5 files changed, 183 insertions(+), 184 deletions(-) create mode 100644 Hotline/macOS/Files/FileDetailsSheet.swift delete mode 100644 Hotline/macOS/Files/FileDetailsView.swift create mode 100644 Hotline/macOS/Files/NewFolderSheet.swift diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index 2579dbe..1f9bdef 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -8,7 +8,7 @@ /* Begin PBXBuildFile section */ 11A726082BE0672A000C1DA7 /* FileDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11A726072BE0672A000C1DA7 /* FileDetails.swift */; }; - 11A7260A2BE0675A000C1DA7 /* FileDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11A726092BE06759000C1DA7 /* FileDetailsView.swift */; platformFilters = (macos, ); }; + 11A7260A2BE0675A000C1DA7 /* FileDetailsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11A726092BE06759000C1DA7 /* FileDetailsSheet.swift */; platformFilters = (macos, ); }; 11F8288B2BF9428100216BA0 /* AccountManagerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11F8288A2BF9428100216BA0 /* AccountManagerView.swift */; }; DA0D698D2B1E7CF700C71DF5 /* UsersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0D698C2B1E7CF700C71DF5 /* UsersView.swift */; platformFilter = ios; }; DA0D698F2B1E841600C71DF5 /* MessageBoardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0D698E2B1E841600C71DF5 /* MessageBoardView.swift */; platformFilter = ios; }; @@ -22,6 +22,7 @@ DA32CD4B2B29318E0053B98B /* FileInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32CD4A2B29318E0053B98B /* FileInfo.swift */; }; DA32CD4D2B2931B50053B98B /* ChatMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32CD4C2B2931B50053B98B /* ChatMessage.swift */; }; DA32CD4F2B2931CC0053B98B /* NewsInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32CD4E2B2931CC0053B98B /* NewsInfo.swift */; }; + DA32F1CC2EC4175F00B243BC /* NewFolderSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32F1CB2EC4175F00B243BC /* NewFolderSheet.swift */; }; DA3429AE2EB9C0280010784E /* HotlineFileUploadClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429AD2EB9C0220010784E /* HotlineFileUploadClient.swift */; }; DA3429B02EBA70790010784E /* HotlineFolderUploadClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429AF2EBA70790010784E /* HotlineFolderUploadClient.swift */; }; DA3429B32EBA7ADF0010784E /* HotlineFilePreviewClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429B22EBA7ADE0010784E /* HotlineFilePreviewClient.swift */; }; @@ -120,7 +121,7 @@ /* Begin PBXFileReference section */ 11A726072BE0672A000C1DA7 /* FileDetails.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileDetails.swift; sourceTree = ""; }; - 11A726092BE06759000C1DA7 /* FileDetailsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileDetailsView.swift; sourceTree = ""; }; + 11A726092BE06759000C1DA7 /* FileDetailsSheet.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileDetailsSheet.swift; sourceTree = ""; }; 11F8288A2BF9428100216BA0 /* AccountManagerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountManagerView.swift; sourceTree = ""; }; DA0D698C2B1E7CF700C71DF5 /* UsersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsersView.swift; sourceTree = ""; }; DA0D698E2B1E841600C71DF5 /* MessageBoardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageBoardView.swift; sourceTree = ""; }; @@ -136,6 +137,7 @@ DA32CD4A2B29318E0053B98B /* FileInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileInfo.swift; sourceTree = ""; }; DA32CD4C2B2931B50053B98B /* ChatMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessage.swift; sourceTree = ""; }; DA32CD4E2B2931CC0053B98B /* NewsInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsInfo.swift; sourceTree = ""; }; + DA32F1CB2EC4175F00B243BC /* NewFolderSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewFolderSheet.swift; sourceTree = ""; }; DA3429AD2EB9C0220010784E /* HotlineFileUploadClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFileUploadClient.swift; sourceTree = ""; }; DA3429AF2EBA70790010784E /* HotlineFolderUploadClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFolderUploadClient.swift; sourceTree = ""; }; DA3429B22EBA7ADE0010784E /* HotlineFilePreviewClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFilePreviewClient.swift; sourceTree = ""; }; @@ -339,7 +341,8 @@ DAE735002B2E71F2000C56F6 /* FilesView.swift */, DA5268A42EB0743000DCB941 /* FileItemView.swift */, DA5268A22EB0741B00DCB941 /* FolderItemView.swift */, - 11A726092BE06759000C1DA7 /* FileDetailsView.swift */, + DA32F1CB2EC4175F00B243BC /* NewFolderSheet.swift */, + 11A726092BE06759000C1DA7 /* FileDetailsSheet.swift */, DAAEE66E2B47625600A5BA07 /* FilePreviewImageView.swift */, DAB4D87D2B4C8BCA0048A05C /* FilePreviewTextView.swift */, DA3429B82EBAB2130010784E /* FilePreviewQuickLookView.swift */, @@ -678,6 +681,7 @@ DA32CD4F2B2931CC0053B98B /* NewsInfo.swift in Sources */, DA9CAFBB2B126D5700CDA197 /* MacApp.swift in Sources */, DAAEE66B2B3FBC2100A5BA07 /* TransferInfo.swift in Sources */, + DA32F1CC2EC4175F00B243BC /* NewFolderSheet.swift in Sources */, DA0D698F2B1E841600C71DF5 /* MessageBoardView.swift in Sources */, DAE735072B3251B3000C56F6 /* SoundEffects.swift in Sources */, DA55AC752BE4888300034857 /* InstantMessage.swift in Sources */, @@ -686,7 +690,7 @@ DA5268B12EB2708E00DCB941 /* HotlineState.swift in Sources */, DA55AC792BE6A1AD00034857 /* RegularExpressions.swift in Sources */, DA5268A02EB073BC00DCB941 /* SoundSettingsView.swift in Sources */, - 11A7260A2BE0675A000C1DA7 /* FileDetailsView.swift in Sources */, + 11A7260A2BE0675A000C1DA7 /* FileDetailsSheet.swift in Sources */, DA72A0DD2B4CD0BF00A0F48A /* NewsEditorView.swift in Sources */, DA52689C2EB0738B00DCB941 /* GeneralSettingsView.swift in Sources */, DA501BE92EBE9589001714F8 /* TrackerItemView.swift in Sources */, diff --git a/Hotline/macOS/Files/FileDetailsSheet.swift b/Hotline/macOS/Files/FileDetailsSheet.swift new file mode 100644 index 0000000..d798fd4 --- /dev/null +++ b/Hotline/macOS/Files/FileDetailsSheet.swift @@ -0,0 +1,141 @@ +import Foundation +import SwiftUI + +struct FileDetailsSheet: View { + @Environment(HotlineState.self) private var model: HotlineState + @Environment(\.presentationMode) var presentationMode + + var fd: FileDetails + + @State private var comment: String = "" + @State private var filename: String = "" + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .center, spacing: 16){ + FileIconView(filename: fd.name, fileType: nil) + .frame(width: 32, height: 32) + TextField("", text: $filename) + .disabled(!self.canRename()) + } + + let rows: [(String, String)] = [ + ("Type", fd.type), + ("Creator", fd.creator), + ("Size", self.formattedSize(byteCount: fd.size)), + ("Created", Self.dateFormatter.string(from: fd.created)), + ("Modified", Self.dateFormatter.string(from: fd.modified)) + ] + + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { + ForEach(rows, id: \.0) { label, value in + GridRow { + Text(label) + .font(.body.bold()) + .gridColumnAlignment(.trailing) // right-align label column + Text(value) + .font(.body) + .gridColumnAlignment(.leading) // left-align value column + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 32 + 16) + + TextField(text: self.$comment, prompt: Text("Comments"), axis: .vertical) { + EmptyView() + } + .font(.body) + .lineLimit(10, reservesSpace: true) + .padding(.leading, 32 + 16) + .disabled(!self.canSetComment()) + } + .padding(.vertical, 24) + .padding(.horizontal, 24) + .frame(width: 400) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + presentationMode.wrappedValue.dismiss() + } + } + + ToolbarItem(placement: .primaryAction) { + Button{ + var editedFilename: String? + if filename != fd.name { + editedFilename = filename + } + + var editedComment: String? + if comment != fd.comment { + editedComment = comment + } + + model.setFileInfo(fileName: fd.name, path: fd.path, fileNewName: editedFilename, comment: editedComment) + presentationMode.wrappedValue.dismiss() + + // TODO: Update the file list if the filename was changed + } label: { + Text("Save") + }.disabled(!isEdited()) + } + } + .onAppear { + self.filename = fd.name + self.comment = fd.comment + } + } + + + static var dateFormatter: DateFormatter = { + var dateFormatter = DateFormatter() + dateFormatter.dateStyle = .long + dateFormatter.timeStyle = .short + + // Original format: Fri, Aug 20, 2021, 5:14:07 PM + return dateFormatter + }() + + static var byteCountSizeFormatter: NumberFormatter = { + let numberFormatter = NumberFormatter() + numberFormatter.numberStyle = .decimal + return numberFormatter + }() + + static let byteFormatter = ByteCountFormatter() + + private func formattedFileSize(_ fileSize: UInt) -> String { + FileItemView.byteFormatter.allowedUnits = [.useAll] + FileItemView.byteFormatter.countStyle = .file + return FileItemView.byteFormatter.string(fromByteCount: Int64(fileSize)) + } + + // Format byte count Int into string like: 23.4M (24,601,664 bytes) + private func formattedSize(byteCount: Int) -> String { + let formattedByteCount = Self.byteCountSizeFormatter.string(from: NSNumber(value:byteCount)) ?? "0" + return "\(FileItemView.byteFormatter.string(fromByteCount: Int64(byteCount))) (\(formattedByteCount) bytes)" + } + + private func isEdited() -> Bool { + return self.filename != fd.name || self.comment != fd.comment + } + + private func canRename() -> Bool { + if self.fd.type == "fldr" { + return model.access?.contains(.canRenameFolders) == true + } + return model.access?.contains(.canRenameFiles) == true + } + + private func canSetComment() -> Bool { + if self.fd.type == "fldr" { + return model.access?.contains(.canSetFolderComment) == true + } + return model.access?.contains(.canSetFileComment) == true + } +} + +//#Preview { +// FileDetailsView(fd: FileDetails(name: "AppleWorks 6.sit", path: [""], size: 24601664, comment: "test comment", type: "SITD", creator: "SIT!", created: Date.now, modified: Date.now )) +//} diff --git a/Hotline/macOS/Files/FileDetailsView.swift b/Hotline/macOS/Files/FileDetailsView.swift deleted file mode 100644 index d001df2..0000000 --- a/Hotline/macOS/Files/FileDetailsView.swift +++ /dev/null @@ -1,147 +0,0 @@ -import Foundation -import SwiftUI - -struct FileDetailsView: View { - @Environment(HotlineState.self) private var model: HotlineState - @Environment(\.presentationMode) var presentationMode - - var fd: FileDetails - - @State private var comment: String = "" - @State private var filename: String = "" - - var body: some View { - VStack (alignment: .leading){ - Form { - HStack(alignment: .center){ - FileIconView(filename: fd.name, fileType: nil) - .frame(width: 32, height: 32) - TextField("", text: $filename) - .disabled(!self.canRename()) - } - HStack(alignment: .center){ - Text("Type:").bold().padding(.leading, 43) - Text(fd.type) - } - HStack(alignment: .center){ - Text("Creator:").bold().padding(.leading, 26) - Text(fd.creator) - } - HStack(alignment: .center){ - Text("Size:").bold().bold().padding(.leading, 48) - Text(self.formattedSize(byteCount: fd.size)) - } - HStack(alignment: .center){ - Text("Created:").bold().padding(.leading, 24) - Text("\(FileDetailsView.dateFormatter.string(from: fd.created))") - } - HStack(alignment: .center){ - Text("Modified:").bold().padding(.leading, 19) - Text("\(FileDetailsView.dateFormatter.string(from: fd.modified))") - } - HStack(alignment: .center){ - Text("Comments:").bold().padding(.top, 8) - .padding(.leading, 5) - } - - VStack(alignment: .trailing){ - TextEditor(text: $comment) - .padding(.leading, 2) - .padding(.top, 1) - .font(.system(size: 13)) - .background(Color(nsColor: .textBackgroundColor)) - .border(Color.secondary, width: 1) - .clipShape(RoundedRectangle(cornerRadius: 2, style: .continuous)) - .disabled(!self.canSetComment()) - } - } - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - presentationMode.wrappedValue.dismiss() - } - } - - ToolbarItem(placement: .primaryAction) { - Button{ - var editedFilename: String? - if filename != fd.name { - editedFilename = filename - } - - var editedComment: String? - if comment != fd.comment { - editedComment = comment - } - - model.setFileInfo(fileName: fd.name, path: fd.path, fileNewName: editedFilename, comment: editedComment) - presentationMode.wrappedValue.dismiss() - - // TODO: Update the file list if the filename was changed - } label: { - Text("Save") - }.disabled(!isEdited()) - } - } - - .onAppear { - self.filename = fd.name - self.comment = fd.comment - } - .padding(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24)) - } - .frame(minWidth: 400, minHeight: 400) - } - - - static var dateFormatter: DateFormatter = { - var dateFormatter = DateFormatter() - dateFormatter.dateStyle = .long - dateFormatter.timeStyle = .short - - // Original format: Fri, Aug 20, 2021, 5:14:07 PM - return dateFormatter - }() - - static var byteCountSizeFormatter: NumberFormatter = { - let numberFormatter = NumberFormatter() - numberFormatter.numberStyle = .decimal - return numberFormatter - }() - - static let byteFormatter = ByteCountFormatter() - - private func formattedFileSize(_ fileSize: UInt) -> String { - FileItemView.byteFormatter.allowedUnits = [.useAll] - FileItemView.byteFormatter.countStyle = .file - return FileItemView.byteFormatter.string(fromByteCount: Int64(fileSize)) - } - - // Format byte count Int into string like: 23.4M (24,601,664 bytes) - private func formattedSize(byteCount: Int) -> String { - let formattedByteCount = FileDetailsView.byteCountSizeFormatter.string(from: NSNumber(value:byteCount)) ?? "0" - return "\(FileItemView.byteFormatter.string(fromByteCount: Int64(byteCount))) (\(formattedByteCount) bytes)" - } - - private func isEdited() -> Bool { - return self.filename != fd.name || self.comment != fd.comment - } - - private func canRename() -> Bool { - if self.fd.type == "fldr" { - return model.access?.contains(.canRenameFolders) == true - } - return model.access?.contains(.canRenameFiles) == true - } - - private func canSetComment() -> Bool { - if self.fd.type == "fldr" { - return model.access?.contains(.canSetFolderComment) == true - } - return model.access?.contains(.canSetFileComment) == true - } -} - -//#Preview { -// FileDetailsView(fd: FileDetails(name: "AppleWorks 6.sit", path: [""], size: 24601664, comment: "test comment", type: "SITD", creator: "SIT!", created: Date.now, modified: Date.now )) -//} diff --git a/Hotline/macOS/Files/FilesView.swift b/Hotline/macOS/Files/FilesView.swift index 3732d9a..a9b0b83 100644 --- a/Hotline/macOS/Files/FilesView.swift +++ b/Hotline/macOS/Files/FilesView.swift @@ -2,37 +2,6 @@ import SwiftUI import UniformTypeIdentifiers import AppKit -struct NewFolderSheet: View { - @Environment(\.dismiss) private var dismiss - - let action: ((String) -> Void)? - - @State private var folderName: String = "Untitled" - - var body: some View { - Form { - TextField(text: self.$folderName) { - Text("Folder Name") - } - } - .formStyle(.grouped) - .fixedSize(horizontal: false, vertical: true) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("New Folder") { - self.dismiss() - self.action?(self.folderName) - } - } - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - self.dismiss() - } - } - } - } -} - struct FilesView: View { @Environment(HotlineState.self) private var model: HotlineState @Environment(\.openWindow) private var openWindow @@ -265,8 +234,8 @@ struct FilesView: View { self.newFolder(name: folderName, parent: self.selection) } } - .sheet(item: $fileDetails) { item in - FileDetailsView(fd: item) + .sheet(item: self.$fileDetails) { item in + FileDetailsSheet(fd: item) } .fileImporter(isPresented: $uploadFileSelectorDisplayed, allowedContentTypes: [.data, .folder], allowsMultipleSelection: false, onCompletion: { results in switch results { diff --git a/Hotline/macOS/Files/NewFolderSheet.swift b/Hotline/macOS/Files/NewFolderSheet.swift new file mode 100644 index 0000000..a899f36 --- /dev/null +++ b/Hotline/macOS/Files/NewFolderSheet.swift @@ -0,0 +1,32 @@ +import SwiftUI + +struct NewFolderSheet: View { + @Environment(\.dismiss) private var dismiss + + let action: ((String) -> Void)? + + @State private var folderName: String = "Untitled" + + var body: some View { + Form { + TextField(text: self.$folderName) { + Text("Folder Name") + } + } + .formStyle(.grouped) + .fixedSize(horizontal: false, vertical: true) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("New Folder") { + self.dismiss() + self.action?(self.folderName) + } + } + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + self.dismiss() + } + } + } + } +} -- cgit From 5a3d06c527cf03d68149449965fade0dd5ea0ed2 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Tue, 11 Nov 2025 20:25:40 -0800 Subject: Fix warning in console about panel not restoring. Add popover for compact transfers list items. Remove push notification entitlement. --- Hotline/Hotline.entitlements | 4 ---- .../Transfers/HotlineFileDownloadClient.swift | 4 +--- Hotline/Info.plist | 10 ++++---- Hotline/Library/Views/HotlinePanel.swift | 5 +++- Hotline/macOS/ServerView.swift | 28 +++++++++++++++++++++- 5 files changed, 38 insertions(+), 13 deletions(-) diff --git a/Hotline/Hotline.entitlements b/Hotline/Hotline.entitlements index e15d27d..074bc56 100644 --- a/Hotline/Hotline.entitlements +++ b/Hotline/Hotline.entitlements @@ -2,10 +2,6 @@ - aps-environment - development - com.apple.developer.aps-environment - development com.apple.developer.icloud-container-identifiers iCloud.co.goodmake.hotline diff --git a/Hotline/Hotline/Transfers/HotlineFileDownloadClient.swift b/Hotline/Hotline/Transfers/HotlineFileDownloadClient.swift index 82f61d4..6441047 100644 --- a/Hotline/Hotline/Transfers/HotlineFileDownloadClient.swift +++ b/Hotline/Hotline/Transfers/HotlineFileDownloadClient.swift @@ -125,9 +125,7 @@ public class HotlineFileDownloadClient: @MainActor HotlineTransferClient { destinationURL = url.resolvingSymlinksInPath() destinationFilename = destinationURL.lastPathComponent case .downloads(let filename): - var downloadsURL = fm.urls(for: .downloadsDirectory, in: .userDomainMask)[0] - downloadsURL = downloadsURL.resolvingSymlinksInPath() - destinationURL = URL(filePath: downloadsURL.generateUniqueFilePath(filename: filename)) + destinationURL = URL.downloadsDirectory.resolvingSymlinksInPath().generateUniqueFileURL(filename: filename) destinationFilename = destinationURL.lastPathComponent } diff --git a/Hotline/Info.plist b/Hotline/Info.plist index 82ee6ac..59b1e50 100644 --- a/Hotline/Info.plist +++ b/Hotline/Info.plist @@ -7,11 +7,13 @@ CFBundleTypeName Hotline Bookmark + CFBundleTypeRole + Editor LSHandlerRank Owner LSItemContentTypes - 'HTbm' + 'HTbm' @@ -19,7 +21,7 @@ CFBundleTypeRole - Viewer + Editor CFBundleURLName co.goodmake.hotline CFBundleURLSchemes @@ -29,7 +31,7 @@ CFBundleTypeRole - Viewer + Editor CFBundleURLName co.goodmake.hotline CFBundleURLSchemes @@ -46,7 +48,7 @@ UTTypeIconFiles UTTypeIdentifier - 'HTbm' + 'HTbm' UTTypeTagSpecification public.filename-extension diff --git a/Hotline/Library/Views/HotlinePanel.swift b/Hotline/Library/Views/HotlinePanel.swift index d7d8284..f6d76fc 100644 --- a/Hotline/Library/Views/HotlinePanel.swift +++ b/Hotline/Library/Views/HotlinePanel.swift @@ -22,7 +22,10 @@ class HotlinePanel: NSPanel { // Don't delete panel state when it's closed. self.isReleasedWhenClosed = false - + + // Disable state restoration for this utility panel + self.isRestorable = false + self.standardWindowButton(.closeButton)?.isHidden = false self.standardWindowButton(.zoomButton)?.isHidden = true self.standardWindowButton(.miniaturizeButton)?.isHidden = true diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index 98ab976..399deba 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -425,6 +425,7 @@ struct ServerTransferRow: View { @Environment(HotlineState.self) private var model: HotlineState @State private var hovered: Bool = false @State private var buttonHovered: Bool = false + @State private var detailsShown: Bool = false var body: some View { HStack(alignment: .center, spacing: 5) { @@ -508,6 +509,7 @@ struct ServerTransferRow: View { withAnimation(.snappy(duration: 0.25, extraBounce: 0.3)) { self.hovered = hovered } + self.detailsShown = hovered } .onTapGesture(count: 2) { guard transfer.completed, let url = transfer.fileURL else { @@ -516,7 +518,31 @@ struct ServerTransferRow: View { NSWorkspace.shared.activateFileViewerSelecting([url]) } - .help(self.formattedProgressHelp) + .popover(isPresented: .constant(self.detailsShown && !self.transfer.done), arrowEdge: .trailing) { + let rows: [(String, String)] = [ + ("document", self.transfer.title), + ("info", self.transfer.displaySize), + (self.transfer.isUpload ? "arrow.up" : "arrow.down", self.transfer.displaySpeed ?? "--"), + ("clock", self.transfer.displayTimeRemaining ?? "--") + ] + + Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 8) { + ForEach(rows, id: \.0) { imageName, label in + GridRow { + Image(systemName: imageName) + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + .gridColumnAlignment(.trailing) + Text(label) + .monospacedDigit() + .gridColumnAlignment(.leading) + } + } + } + .frame(minWidth: 200, maxWidth: 350, alignment: .leading) + .padding() + } } private var formattedProgressHelp: String { -- cgit From 5eff79cc4891076d85223dcfd96c7cb8894c80f2 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Wed, 12 Nov 2025 18:55:25 -0800 Subject: Get Accounts out of the sidebar, redesign Account management. --- Hotline.xcodeproj/project.pbxproj | 8 +- Hotline/Hotline/HotlineClient.swift | 42 +- Hotline/Hotline/HotlineExtensions.swift | 44 +- Hotline/Hotline/HotlineProtocol.swift | 6 +- Hotline/Hotline/HotlineTrackerClient.swift | 21 +- Hotline/MacApp.swift | 6 +- Hotline/Models/FileDetails.swift | 4 +- Hotline/State/AppUpdate.swift | 20 +- Hotline/State/ServerState.swift | 7 +- Hotline/macOS/Accounts/AccountManagerView.swift | 686 ++++++++++++------------ Hotline/macOS/Files/FileDetailsSheet.swift | 10 +- Hotline/macOS/Files/FilesView.swift | 26 +- Hotline/macOS/Files/NewFolderPopover.swift | 51 ++ Hotline/macOS/Files/NewFolderSheet.swift | 32 -- Hotline/macOS/HotlinePanelView.swift | 5 +- Hotline/macOS/ServerView.swift | 59 +- Hotline/macOS/TransfersView.swift | 6 +- 17 files changed, 548 insertions(+), 485 deletions(-) create mode 100644 Hotline/macOS/Files/NewFolderPopover.swift delete mode 100644 Hotline/macOS/Files/NewFolderSheet.swift diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index 1f9bdef..7032420 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -22,7 +22,7 @@ DA32CD4B2B29318E0053B98B /* FileInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32CD4A2B29318E0053B98B /* FileInfo.swift */; }; DA32CD4D2B2931B50053B98B /* ChatMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32CD4C2B2931B50053B98B /* ChatMessage.swift */; }; DA32CD4F2B2931CC0053B98B /* NewsInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32CD4E2B2931CC0053B98B /* NewsInfo.swift */; }; - DA32F1CC2EC4175F00B243BC /* NewFolderSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32F1CB2EC4175F00B243BC /* NewFolderSheet.swift */; }; + DA32F1CC2EC4175F00B243BC /* NewFolderPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32F1CB2EC4175F00B243BC /* NewFolderPopover.swift */; }; DA3429AE2EB9C0280010784E /* HotlineFileUploadClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429AD2EB9C0220010784E /* HotlineFileUploadClient.swift */; }; DA3429B02EBA70790010784E /* HotlineFolderUploadClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429AF2EBA70790010784E /* HotlineFolderUploadClient.swift */; }; DA3429B32EBA7ADF0010784E /* HotlineFilePreviewClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429B22EBA7ADE0010784E /* HotlineFilePreviewClient.swift */; }; @@ -137,7 +137,7 @@ DA32CD4A2B29318E0053B98B /* FileInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileInfo.swift; sourceTree = ""; }; DA32CD4C2B2931B50053B98B /* ChatMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessage.swift; sourceTree = ""; }; DA32CD4E2B2931CC0053B98B /* NewsInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsInfo.swift; sourceTree = ""; }; - DA32F1CB2EC4175F00B243BC /* NewFolderSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewFolderSheet.swift; sourceTree = ""; }; + DA32F1CB2EC4175F00B243BC /* NewFolderPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewFolderPopover.swift; sourceTree = ""; }; DA3429AD2EB9C0220010784E /* HotlineFileUploadClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFileUploadClient.swift; sourceTree = ""; }; DA3429AF2EBA70790010784E /* HotlineFolderUploadClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFolderUploadClient.swift; sourceTree = ""; }; DA3429B22EBA7ADE0010784E /* HotlineFilePreviewClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFilePreviewClient.swift; sourceTree = ""; }; @@ -341,7 +341,7 @@ DAE735002B2E71F2000C56F6 /* FilesView.swift */, DA5268A42EB0743000DCB941 /* FileItemView.swift */, DA5268A22EB0741B00DCB941 /* FolderItemView.swift */, - DA32F1CB2EC4175F00B243BC /* NewFolderSheet.swift */, + DA32F1CB2EC4175F00B243BC /* NewFolderPopover.swift */, 11A726092BE06759000C1DA7 /* FileDetailsSheet.swift */, DAAEE66E2B47625600A5BA07 /* FilePreviewImageView.swift */, DAB4D87D2B4C8BCA0048A05C /* FilePreviewTextView.swift */, @@ -681,7 +681,7 @@ DA32CD4F2B2931CC0053B98B /* NewsInfo.swift in Sources */, DA9CAFBB2B126D5700CDA197 /* MacApp.swift in Sources */, DAAEE66B2B3FBC2100A5BA07 /* TransferInfo.swift in Sources */, - DA32F1CC2EC4175F00B243BC /* NewFolderSheet.swift in Sources */, + DA32F1CC2EC4175F00B243BC /* NewFolderPopover.swift in Sources */, DA0D698F2B1E841600C71DF5 /* MessageBoardView.swift in Sources */, DAE735072B3251B3000C56F6 /* SoundEffects.swift in Sources */, DA55AC752BE4888300034857 /* InstantMessage.swift in Sources */, diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index 1faaf55..ee99ed7 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -142,10 +142,6 @@ public actor HotlineClient { // Transaction tracking for request/reply pattern private var pendingTransactions: [UInt32: CheckedContinuation] = [:] - private enum TransactionWaitError: Error { - case timeout - } - // Receive loop task private var receiveTask: Task? @@ -427,10 +423,10 @@ public actor HotlineClient { try await self.socket.send(transaction, endian: .big) do { - return try await withTimeout(seconds: timeout) { + return try await Task.withTimeout(seconds: timeout) { try await self.awaitReply(for: transactionID) } - } catch is TransactionWaitError { + } catch is TaskTimeoutError { throw HotlineClientError.timeout } catch let error as HotlineClientError { throw error @@ -467,32 +463,6 @@ public actor HotlineClient { } } - private func withTimeout(seconds: TimeInterval, operation: @escaping @Sendable () async throws -> T) async throws -> T { - if seconds <= 0 { - throw TransactionWaitError.timeout - } - - return try await withThrowingTaskGroup(of: T.self) { group in - group.addTask { - try await operation() - } - - group.addTask { - try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) - throw TransactionWaitError.timeout - } - - do { - let value = try await group.next()! - group.cancelAll() - return value - } catch { - group.cancelAll() - throw error - } - } - } - // MARK: - Keep-Alive private func startKeepAlive() { @@ -841,7 +811,7 @@ public actor HotlineClient { accounts.append(data.getAcccount()) } - accounts.sort { $0.login < $1.login } + accounts.sort { $0.name < $1.name } return accounts } @@ -893,7 +863,11 @@ public actor HotlineClient { // - other: Set new password if password == nil { transaction.setFieldUInt8(type: .userPassword, val: 0) - } else if password != "" { + } + else if password == "" { + // Don't add password to transaction (password will be removed) + } + else { transaction.setFieldEncodedString(type: .userPassword, val: password!) } diff --git a/Hotline/Hotline/HotlineExtensions.swift b/Hotline/Hotline/HotlineExtensions.swift index 6782a41..bcee812 100644 --- a/Hotline/Hotline/HotlineExtensions.swift +++ b/Hotline/Hotline/HotlineExtensions.swift @@ -7,6 +7,8 @@ enum LineEnding { case cr // Classic Mac-style (\r) } +// MARK: - + extension URL { func urlForResourceFork() -> URL { self.appendingPathComponent("..namedfork/rsrc") @@ -31,6 +33,40 @@ extension URL { #endif } +// MARK: - + +public struct TaskTimeoutError: Error {} + +extension Task where Success == Never, Failure == Never { + public static func withTimeout(seconds: TimeInterval, operation: @escaping @Sendable () async throws -> T) async throws -> T { + if seconds <= 0 { + throw TaskTimeoutError() + } + + return try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { + try await operation() + } + + group.addTask { + try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + throw TaskTimeoutError() + } + + do { + let value = try await group.next()! + group.cancelAll() + return value + } catch { + group.cancelAll() + throw error + } + } + } +} + +// MARK: - + extension String { func convertingLineEndings(to targetEnding: LineEnding) -> String { let lf = "\n" @@ -38,16 +74,16 @@ extension String { let cr = "\r" // Normalize all line endings to LF (\n) - let normalizedString = self.replacingOccurrences(of: cr, with: lf).replacingOccurrences(of: crlf, with: lf) + let normalizedString = self.replacing(cr, with: lf).replacing(crlf, with: lf) // Replace normalized LF (\n) line endings with the target line ending switch targetEnding { case .lf: return normalizedString case .crlf: - return normalizedString.replacingOccurrences(of: lf, with: crlf) + return normalizedString.replacing(lf, with: crlf) case .cr: - return normalizedString.replacingOccurrences(of: lf, with: cr) + return normalizedString.replacing(lf, with: cr) } } @@ -67,6 +103,8 @@ extension String { } } +// MARK: - + extension FileManager { static var extensionToHFSCreator: [String: UInt32] = [ // Documents diff --git a/Hotline/Hotline/HotlineProtocol.swift b/Hotline/Hotline/HotlineProtocol.swift index 5fd06dc..4857d87 100644 --- a/Hotline/Hotline/HotlineProtocol.swift +++ b/Hotline/Hotline/HotlineProtocol.swift @@ -233,18 +233,20 @@ public struct HotlineAccount: Identifiable { public let id: UUID = UUID() var name: String = "" var login: String = "" - var password: String? = nil - var persisted: Bool = true + var password: String = "" + var persisted: Bool = false var access: HotlineUserAccessOptions = HotlineUserAccessOptions() var fields: [HotlineTransactionField] = [] init(from data: [UInt8]) { self.decodeFields(from: data) + self.persisted = true } init(_ name: String, _ login: String, _ access: HotlineUserAccessOptions) { self.name = name self.login = login + self.password = HotlineAccount.randomPassword() self.access = access self.persisted = false } diff --git a/Hotline/Hotline/HotlineTrackerClient.swift b/Hotline/Hotline/HotlineTrackerClient.swift index 6a7a5b3..8580cd7 100644 --- a/Hotline/Hotline/HotlineTrackerClient.swift +++ b/Hotline/Hotline/HotlineTrackerClient.swift @@ -57,7 +57,7 @@ class HotlineTrackerClient { private func fetchServersInternal(address: String, port: Int, continuation: AsyncThrowingStream.Continuation) async { do { - try await withTimeout(seconds: 30) { + try await Task.withTimeout(seconds: 30) { try await self.doFetch(address: address, port: port, continuation: continuation) } } catch { @@ -160,23 +160,4 @@ class HotlineTrackerClient { print("HotlineTrackerClient: Completed - parsed \(totalEntriesParsed)/\(totalExpectedEntries) entries, yielded \(totalYielded) servers") continuation.finish() } - - private func withTimeout(seconds: TimeInterval, operation: @escaping () async throws -> T) async throws -> T { - try await withThrowingTaskGroup(of: T.self) { group in - group.addTask { - try await operation() - } - - group.addTask { - try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) - throw NSError(domain: "HotlineTracker", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "Tracker request timed out after \(seconds) seconds" - ]) - } - - let result = try await group.next()! - group.cancelAll() - return result - } - } } diff --git a/Hotline/MacApp.swift b/Hotline/MacApp.swift index 9ac54c1..5a84da8 100644 --- a/Hotline/MacApp.swift +++ b/Hotline/MacApp.swift @@ -277,11 +277,11 @@ struct Application: App { if activeHotline?.access?.contains(.canOpenUsers) == true { Divider() - Button("Accounts") { - activeServerState?.selection = .accounts + Button("Manage Server...") { + activeServerState?.accountsShown = true } .disabled(activeHotline?.status != .loggedIn || activeHotline?.access?.contains(.canOpenUsers) != true ) - .keyboardShortcut(.init("5"), modifiers: .command) +// .keyboardShortcut(.init("5"), modifiers: .command) } } } diff --git a/Hotline/Models/FileDetails.swift b/Hotline/Models/FileDetails.swift index 6164151..71ddb6d 100644 --- a/Hotline/Models/FileDetails.swift +++ b/Hotline/Models/FileDetails.swift @@ -1,7 +1,7 @@ import UniformTypeIdentifiers -public struct FileDetails:Identifiable { - public let id = UUID() +public struct FileDetails: Identifiable { + public let id: UUID = UUID() var name: String var path: [String] var size: Int diff --git a/Hotline/State/AppUpdate.swift b/Hotline/State/AppUpdate.swift index 558a296..83006a2 100644 --- a/Hotline/State/AppUpdate.swift +++ b/Hotline/State/AppUpdate.swift @@ -144,12 +144,13 @@ final class AppUpdate { self.releaseNotesCombined = nil self.isDownloading = false if trigger == .manual { - self.message = AppUpdateMessage( - title: "Hotline is up to date", - detail: "You're running the latest and greatest.", - kind: .success - ) - self.showWindow = true + self.showUpToDateAlert() +// self.message = AppUpdateMessage( +// title: "Hotline is up to date", +// detail: "You're running the latest and greatest.", +// kind: .success +// ) +// self.showWindow = true } else { self.message = nil self.showWindow = false @@ -175,6 +176,13 @@ final class AppUpdate { } } + private func showUpToDateAlert() { + let alert = NSAlert() + alert.messageText = "No Update Available" + alert.informativeText = "You are already using the latest version of Hotline!" + alert.runModal() + } + private func fetchNewerReleases() async throws -> [UpdateReleaseInfo] { let (data, _) = try await URLSession.shared.data(from: releasesURL) guard let jsonArray = try JSONSerialization.jsonObject(with: data, options: []) as? [[String: Any]] else { diff --git a/Hotline/State/ServerState.swift b/Hotline/State/ServerState.swift index 5913bf5..8f8d3bf 100644 --- a/Hotline/State/ServerState.swift +++ b/Hotline/State/ServerState.swift @@ -5,6 +5,7 @@ class ServerState: Equatable { var id: UUID = UUID() var selection: ServerNavigationType var serverName: String? = nil + var accountsShown: Bool = false // var serverBanner: NSImage? = nil // var bannerBackgroundColor: Color? = nil @@ -28,8 +29,8 @@ enum ServerNavigationType: Identifiable, Hashable, Equatable { return "Board" case .files: return "Files" - case .accounts: - return "Accounts" +// case .accounts: +// return "Accounts" case .user(let userID): return String(userID) } @@ -39,6 +40,6 @@ enum ServerNavigationType: Identifiable, Hashable, Equatable { case news case board case files - case accounts +// case accounts case user(userID: UInt16) } diff --git a/Hotline/macOS/Accounts/AccountManagerView.swift b/Hotline/macOS/Accounts/AccountManagerView.swift index c45ba18..bbb909b 100644 --- a/Hotline/macOS/Accounts/AccountManagerView.swift +++ b/Hotline/macOS/Accounts/AccountManagerView.swift @@ -1,402 +1,418 @@ import SwiftUI +fileprivate let DEFAULT_ACCOUNT_NAME = "Untitled Account" +fileprivate let PASSWORD_PLACEHOLDER = "xxxxxxxxxxxxxxxxxx" + struct AccountManagerView: View { @Environment(HotlineState.self) private var model: HotlineState + @Environment(\.dismiss) private var dismiss @State private var accounts: [HotlineAccount] = [] @State private var selection: HotlineAccount? @State private var loading: Bool = true - @State private var pendingName: String = "" - @State private var pendingLogin: String = "" - @State private var pendingPassword: String = "" - @State private var pendingAccess = HotlineUserAccessOptions.defaultAccess + @State private var creatorShown: Bool = false + @State private var deleteConfirm: Bool = false + @State private var accountToEdit: HotlineAccount? = nil + @State private var accountToDelete: HotlineAccount? = nil - @State private var toDelete: HotlineAccount? + private func newAccount() { + self.creatorShown = true + } - let placeholderPassword = "xxxxxxxxxxxxxxxxxx" + private func editAccount(_ account: HotlineAccount) { + // Always get the latest version from the array to avoid stale data + if let currentAccount = self.accounts.first(where: { $0.id == account.id }) { + self.accountToEdit = currentAccount + } + } + + private func deleteAccount(_ account: HotlineAccount) { + self.accountToDelete = account + self.deleteConfirm = true + } var body: some View { - HStack(spacing: 0) { - ZStack { - accountList - if loading { - ProgressView() - } - } - if selection != nil { - accountDetails - } - else { - ZStack(alignment: .center) { - Text("No Account Selected") - .font(.title) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding() + VStack(spacing: 8) { + HStack(alignment: .firstTextBaseline) { + Text("Accounts") + .font(.headline) + + Spacer() + + HStack { + Button { + self.newAccount() + } label: { + Image(systemName: "plus") + .padding(4) + } + .buttonBorderShape(.circle) + .help("New Account") + + Button { + if let account = self.selection { + self.editAccount(account) + } + } label: { + Image(systemName: "pencil") + .padding(4) + } + .buttonBorderShape(.circle) + .disabled(self.selection == nil) + .help("Edit Account") + + Button { + if let account = self.selection { + self.deleteAccount(account) + } + } label: { + Image(systemName: "trash") + .padding(4) + } + .tint(.hotlineRed) + .buttonBorderShape(.circle) + .disabled(self.selection == nil) + .help("Delete Account") } - .frame(maxWidth: .infinity) } + .padding(.horizontal, 16) + .padding(.top, 24) + + self.accountList +// .overlay { +// if self.loading { +// ProgressView() +// .progressViewStyle(.linear) +// .controlSize(.extraLarge) +// .frame(width: 100) +// } +// } } .environment(\.defaultMinListRowHeight, 34) .listStyle(.inset) .alternatingRowBackgrounds(.enabled) .task { - if loading { - accounts = (try? await model.getAccounts()) ?? [] - loading = false + if self.loading { + do { + self.accounts = try await self.model.getAccounts() + } + catch { + self.dismiss() + } + + self.loading = false } } .toolbar { - ToolbarItem(placement: .primaryAction) { - Button { - let newAccount = HotlineAccount("unnamed", "unnamed", HotlineUserAccessOptions.defaultAccess) - - pendingPassword = HotlineAccount.randomPassword() - accounts.append(newAccount) - selection = newAccount - } label: { - Label("New Account", systemImage: "plus") + if self.loading { + ToolbarItem { + ProgressView() + .controlSize(.small) } - .help("Create a new account") - .disabled(model.access?.contains(.canCreateUsers) != true) } - ToolbarItem(placement: .destructiveAction) { + ToolbarItem(placement: .confirmationAction) { Button { - toDelete = selection + self.dismiss() } label: { - Label("Delete Account", systemImage: "trash") + Text("OK") } - .help("Delete account") - .disabled(selection == nil || model.access?.contains(.canDeleteUsers) != true) } } } - var accountDetails: some View { - VStack(alignment: .center, spacing: 0) { - ScrollView(.vertical) { - Form { - Section { - TextField(text: $pendingName) { - Text("Name") - } - TextField("Login", text: $pendingLogin) - .disabled(selection?.persisted == true) - if selection?.persisted == true { - SecureField("Password", text: $pendingPassword) - } else { - TextField("Password", text: $pendingPassword) - } - } - .textFieldStyle(.roundedBorder) - .controlSize(.large) - - Section("File System Maintenance") { - Toggle("Can Download Files", isOn: $pendingAccess.bind(.canDownloadFiles)) - .disabled(model.access?.contains(.canDownloadFiles) == false) - Toggle("Can Download Folders", isOn: $pendingAccess.bind(.canDownloadFolders)) - .disabled(model.access?.contains(.canDownloadFolders) == false) - Toggle("Can Upload Files", isOn: $pendingAccess.bind(.canUploadFiles)) - .disabled(model.access?.contains(.canUploadFiles) == false) - Toggle("Can Upload Folders", isOn: $pendingAccess.bind(.canUploadFolders)) - .disabled(model.access?.contains(.canUploadFolders) == false) - Toggle("Can Upload Anywhere", isOn: $pendingAccess.bind(.canUploadAnywhere)) - .disabled(model.access?.contains(.canUploadAnywhere) == false) - Toggle("Can Delete Files", isOn: $pendingAccess.bind(.canDeleteFiles)) - .disabled(model.access?.contains(.canDeleteFiles) == false) - Toggle("Can Rename Files", isOn: $pendingAccess.bind(.canRenameFiles)) - .disabled(model.access?.contains(.canRenameFiles) == false) - Toggle("Can Move Files", isOn: $pendingAccess.bind(.canMoveFiles)) - .disabled(model.access?.contains(.canMoveFiles) == false) - Toggle("Can Comment Files", isOn: $pendingAccess.bind(.canSetFileComment)) - .disabled(model.access?.contains(.canSetFileComment) == false) - Toggle("Can Create Folders", isOn: $pendingAccess.bind(.canCreateFolders)) - .disabled(model.access?.contains(.canCreateFolders) == false) - Toggle("Can Delete Folders", isOn: $pendingAccess.bind(.canDeleteFolders)) - .disabled(model.access?.contains(.canDeleteFolders) == false) - Toggle("Can Rename Folders", isOn: $pendingAccess.bind(.canRenameFolders)) - .disabled(model.access?.contains(.canRenameFolders) == false) - Toggle("Can Move Folders", isOn: $pendingAccess.bind(.canMoveFolders)) - .disabled(model.access?.contains(.canMoveFolders) == false) - Toggle("Can Comment Folders", isOn: $pendingAccess.bind(.canSetFolderComment)) - .disabled(model.access?.contains(.canSetFolderComment) == false) - Toggle("Can View Drop Boxes", isOn: $pendingAccess.bind(.canViewDropBoxes)) - .disabled(model.access?.contains(.canViewDropBoxes) == false) - Toggle("Can Make Aliases", isOn: $pendingAccess.bind(.canMakeAliases)) - .disabled(model.access?.contains(.canMakeAliases) == false) - } - - Section("User Maintenance") { - Toggle("Can Create Accounts", isOn: $pendingAccess.bind(.canCreateUsers)) - .disabled(model.access?.contains(.canCreateUsers) == false) - Toggle("Can Delete Accounts", isOn: $pendingAccess.bind(.canDeleteUsers)) - .disabled(model.access?.contains(.canDeleteUsers) == false) - Toggle("Can Read Accounts", isOn: $pendingAccess.bind(.canOpenUsers)) - .disabled(model.access?.contains(.canOpenUsers) == false) - Toggle("Can Modify Accounts", isOn: $pendingAccess.bind(.canModifyUsers)) - .disabled(model.access?.contains(.canModifyUsers) == false) - Toggle("Can Get User Info", isOn: $pendingAccess.bind(.canGetClientInfo)) - .disabled(model.access?.contains(.canGetClientInfo) == false) - - Toggle("Can Disconnect Users", isOn: $pendingAccess.bind(.canDisconnectUsers)) - .disabled(model.access?.contains(.canDisconnectUsers) == false) - Toggle("Cannot be Disconnected", isOn: $pendingAccess.bind(.cantBeDisconnected)) - .disabled(model.access?.contains(.cantBeDisconnected) == false) - } - - Section("Messaging") { - Toggle("Can Send Messages", isOn: $pendingAccess.bind(.canSendMessages)) - .disabled(model.access?.contains(.canSendMessages) == false) - Toggle("Can Broadcast", isOn: $pendingAccess.bind(.canBroadcast)) - .disabled(model.access?.contains(.canBroadcast) == false) - } - - Section("News") { - Toggle("Can Read Articles", isOn: $pendingAccess.bind(.canReadMessageBoard)) - .disabled(model.access?.contains(.canReadMessageBoard) == false) - Toggle("Can Post Articles", isOn: $pendingAccess.bind(.canPostMessageBoard)) - .disabled(model.access?.contains(.canPostMessageBoard) == false) - Toggle("Can Delete Articles", isOn: $pendingAccess.bind(.canDeleteNewsArticles)) - .disabled(model.access?.contains(.canDeleteNewsArticles) == false) - Toggle("Can Create Categories", isOn: $pendingAccess.bind(.canCreateNewsCategories)) - .disabled(model.access?.contains(.canCreateNewsCategories) == false) - Toggle("Can Delete Categories", isOn: $pendingAccess.bind(.canDeleteNewsCategories)) - .disabled(model.access?.contains(.canDeleteNewsCategories) == false) - Toggle("Can Create News Bundles", isOn: $pendingAccess.bind(.canCreateNewsFolders)) - .disabled(model.access?.contains(.canCreateNewsFolders) == false) - Toggle("Can Delete News Bundles", isOn: $pendingAccess.bind(.canDeleteNewsFolders)) - .disabled(model.access?.contains(.canDeleteNewsFolders) == false) - } - - Section("Chat") { - Toggle("Can Initiate Private Chat", isOn: $pendingAccess.bind(.canCreateChat)) - .disabled(model.access?.contains(.canCreateChat) == false) - Toggle("Can Read Chat", isOn: $pendingAccess.bind(.canReadChat)) - .disabled(model.access?.contains(.canReadChat) == false) - Toggle("Can Send Chat", isOn: $pendingAccess.bind(.canSendChat)) - .disabled(model.access?.contains(.canSendChat) == false) - } - - Section("Miscellaneous") { - Toggle("Can Use Any Name", isOn: $pendingAccess.bind(.canUseAnyName)) - .disabled(model.access?.contains(.canUseAnyName) == false) - Toggle("Don't Show Agreement", isOn: $pendingAccess.bind(.canSkipAgreement)) - .disabled(model.access?.contains(.canSkipAgreement) == false) - } - } - .disabled(model.access?.contains(.canModifyUsers) == false) - .formStyle(.grouped) - .onChange(of: selection) { - if let selection { - pendingName = selection.name - pendingLogin = selection.login - pendingAccess = selection.access - - if selection.persisted { - if selection.password == nil { - pendingPassword = "" - } else { - pendingPassword = placeholderPassword - } - } - } - } - .onAppear() { - if let selection { - pendingName = selection.name - pendingLogin = selection.login - pendingAccess = selection.access - - if selection.persisted { - if selection.password == nil { - pendingPassword = "" - } else { - pendingPassword = placeholderPassword - } - } else { - pendingPassword = HotlineAccount.randomPassword() - } - } + private var accountList: some View { + List(self.accounts, id: \.self, selection: self.$selection) { account in + HStack(spacing: 5) { + Image(account.access.contains(.canDisconnectUsers) ? "User Admin" : "User") + .frame(width: 16, height: 16) + .opacity((account.access.rawValue == 0) ? 0.5 : 1.0) + Text(account.name) + .foregroundStyle(account.access.contains(.canDisconnectUsers) ? Color.hotlineRed : ((account.access.rawValue == 0) ? Color.secondary : Color.primary)) + + Spacer() + + Text(account.login) + .lineLimit(1) + .foregroundStyle(.secondary) + } + } + .contextMenu(forSelectionType: HotlineAccount.self) { items in + Button { + if let item = items.first { + self.editAccount(item) } + } label: { + Label("Edit Account...", systemImage: "pencil") } - .frame(maxWidth: .infinity) + .disabled(items.isEmpty) Divider() - HStack() { - Button("Revert") { - if let selection { - pendingAccess = selection.access - pendingName = selection.name - pendingLogin = selection.login - - if selection.password != nil { - pendingPassword = selection.password! - } - } + Button(role: .destructive) { + if let item = items.first { + self.deleteAccount(item) } - .controlSize(.large) - .frame(minWidth: 75) - .disabled(!self.isSaveable()) -// .padding() - - Spacer() - - Button(action: { - guard let selection else { - return - } - - // Update existing account - if selection.persisted == true { - - if pendingPassword == placeholderPassword { - Task { @MainActor in - try? await model.setUser(name: pendingName, login: pendingLogin, newLogin: nil, password: nil, access: pendingAccess.rawValue) - } - } else { - Task { @MainActor in - try? await model.setUser(name: pendingName, login: pendingLogin, newLogin: nil, password: pendingPassword, access: pendingAccess.rawValue) - } - } - - } else { - // Create new existing account - Task { @MainActor in - try? await model.createUser(name: pendingName, login: pendingLogin, password: pendingPassword, access: pendingAccess.rawValue) - } - self.selection?.password = pendingPassword - pendingPassword = placeholderPassword - } - - var account = HotlineAccount(pendingName, pendingLogin, pendingAccess) - account.persisted = true - account.password = placeholderPassword - - accounts = accounts.filter { $0.persisted == true && $0.login != selection.login } - - // Add new account to list - accounts.append(account) - - // Re-sort accounts - accounts.sort { $0.login < $1.login } - self.selection = account - }, label: { - Text("Save") - }) - .controlSize(.large) - .frame(minWidth: 75) - .keyboardShortcut(.defaultAction) - .disabled(!self.isSaveable()) + } label: { + Label("Delete Account...", systemImage: "trash") + } + .disabled(items.isEmpty) + } primaryAction: { items in + if let account = items.first { + self.editAccount(account) } - .padding() } - - } - - var accountList: some View { - List(accounts, id: \.self, selection: $selection) { account in - HStack(spacing: 5) { - if account.access.contains(.canDisconnectUsers) { - Image("User Admin") - .frame(width: 16, height: 16) - .opacity(account.persisted ? 1.0 : 0.25) - // .padding(.leading, 4) - Text(account.login) - .foregroundStyle(Color.hotlineRed) + .alert("Are you sure you want to delete the \"\(self.accountToDelete?.name ?? "unknown")\" account?", isPresented: self.$deleteConfirm, actions: { + Button("Delete", role: .destructive) { + guard let account = self.accountToDelete else { + return } - else if account.access.rawValue == 0 { - Image("User") - .frame(width: 16, height: 16) - // .padding(.leading, 4) - Text(account.login) - .foregroundStyle(.secondary) + + self.accountToDelete = nil + + Task { + self.selection = nil + + if account.persisted { + try await self.model.deleteUser(login: account.login) + } + + self.accounts = self.accounts.filter { $0.id != account.id } + self.deleteConfirm = false } - // else if account.persisted == false { - // HStack { - // Image("User") - // .frame(width: 16, height: 16) - //// .padding(.leading, 4) - // Text(account.login) - // .italic() - // } - // } - else { - Image("User") - .frame(width: 16, height: 16) - .opacity(account.persisted ? 1.0 : 0.5) - // .padding(.leading, 4) - Text(account.login) + } + }, message: { + Text("You cannot undo this action.") + }) + .sheet(item: self.$accountToEdit) { account in + AccountDetailsView(account: account) { editedAccount in + if let i = self.accounts.firstIndex(of: editedAccount) { + self.accounts.remove(at: i) + self.accounts.insert(editedAccount, at: i) } + self.accounts.sort { $0.name < $1.name } + self.selection = editedAccount + self.accountToEdit = nil } + .id(account.id) + .environment(self.model) + .frame(width: 480) + .frame(minHeight: 300, idealHeight: 400) + .presentationSizing(.fitted) } - .frame(width: 250) - .sheet(item: $toDelete) { item in - Form { - HStack{ - Image(systemName: "exclamationmark.triangle") - .font(.system(size: 30)) - Text("Delete account \"\(item.name)\" and all associated files?") - .lineSpacing(4) + .sheet(isPresented: self.$creatorShown) { + AccountDetailsView { newAccount in + self.accounts.append(newAccount) + self.accounts.sort { $0.name < $1.name } + self.selection = newAccount + } + .environment(self.model) + .frame(width: 480) + .frame(minHeight: 300, idealHeight: 400) + .presentationSizing(.fitted) + } + } +} + + +struct AccountDetailsView: View { + @Environment(HotlineState.self) private var model: HotlineState + @Environment(\.dismiss) private var dismiss + + @State var account: HotlineAccount = HotlineAccount("Untitled Account", "", HotlineUserAccessOptions.defaultAccess) + + let saved: ((HotlineAccount) -> Void)? + + @State private var password: String = "" + @State private var saving: Bool = false + + var body: some View { + self.accountDetails + .onAppear { + // Display a placeholder for accounts that have been saved to the server + // because we don't have the account password on hand to display. + if self.account.persisted { + self.password = PASSWORD_PLACEHOLDER } } - .frame(minWidth: 300, idealWidth: 450, maxWidth: .infinity, minHeight: 100, idealHeight: 100, maxHeight: .infinity) .toolbar { + if self.saving { + ToolbarItem { + ProgressView() + .controlSize(.small) + } + } + ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - toDelete = nil + Button { + self.dismiss() + } label: { + Text("Cancel") } } - ToolbarItem(placement: .primaryAction) { - Button(action: { - guard let userToDelete = toDelete else { - return - } - - self.toDelete = nil - self.selection = nil - - if userToDelete.persisted { - Task { @MainActor in - try? await model.deleteUser(login: userToDelete.login) + ToolbarItem(placement: .confirmationAction) { + Button { + Task { + do { + try await self.save() + self.dismiss() + } + catch { + print("ERROR SAVING ACCOUNT: \(error)") } } - - accounts = accounts.filter { $0.login != userToDelete.login } - - }, label: { - Text("Delete") - }) + } label: { + Text(self.account.persisted ? "Save" : "Create") + } + .disabled(self.saving) } } - } } - - private func isSaveable() -> Bool { - guard let selection else { - return false - } + private func save() async throws { + self.saving = true + defer { self.saving = false } - // Disable save if login field is cleared - if pendingLogin == "" { - return false + var accountName: String = self.account.name + if accountName.isBlank { + accountName = DEFAULT_ACCOUNT_NAME } - // If the account initial has a password and it was updated - if selection.password != nil && pendingPassword != placeholderPassword { - return true + // Update existing account + if self.account.persisted { + if self.password == PASSWORD_PLACEHOLDER { + try await self.model.setUser(name: accountName, login: self.account.login, newLogin: nil, password: nil, access: self.account.access.rawValue) + } else { + try await model.setUser(name: accountName, login: self.account.login, newLogin: nil, password: self.password, access: self.account.access.rawValue) + } + + } else { + // Create new existing account + try await model.createUser(name: accountName, login: self.account.login, password: self.password, access: self.account.access.rawValue) + +// self.password = PASSWORD_PLACEHOLDER + self.account.persisted = true } - // If the account initial has no password, but was updated to have one - if selection.password == nil && pendingPassword != "" { - return true + self.account.name = accountName + self.saved?(self.account) + } + + var accountDetails: some View { + Form { + Section { + TextField(text: self.$account.name, prompt: Text(DEFAULT_ACCOUNT_NAME)) { + Text("Account") + } + } + + Section { + TextField("Login", text: self.$account.login, prompt: Text("Required")) + .disabled(self.account.persisted) + + if self.account.persisted { + SecureField("Password", text: self.$password, prompt: Text("Optional")) + } else { + TextField("Password", text: self.$password, prompt: Text("Optional")) + } + } + + Section("Files") { + Toggle("Download Files", isOn: self.$account.access.bind(.canDownloadFiles)) + .disabled(self.model.access?.contains(.canDownloadFiles) == false) + Toggle("Download Folders", isOn: self.$account.access.bind(.canDownloadFolders)) + .disabled(model.access?.contains(.canDownloadFolders) == false) + Toggle("Upload Files", isOn: self.$account.access.bind(.canUploadFiles)) + .disabled(model.access?.contains(.canUploadFiles) == false) + Toggle("Upload Folders", isOn: self.$account.access.bind(.canUploadFolders)) + .disabled(model.access?.contains(.canUploadFolders) == false) + Toggle("Upload Anywhere", isOn: self.$account.access.bind(.canUploadAnywhere)) + .disabled(model.access?.contains(.canUploadAnywhere) == false) + Toggle("Delete Files", isOn: self.$account.access.bind(.canDeleteFiles)) + .disabled(model.access?.contains(.canDeleteFiles) == false) + Toggle("Rename Files", isOn: self.$account.access.bind(.canRenameFiles)) + .disabled(model.access?.contains(.canRenameFiles) == false) + Toggle("Move Files", isOn: self.$account.access.bind(.canMoveFiles)) + .disabled(model.access?.contains(.canMoveFiles) == false) + Toggle("Comment Files", isOn: self.$account.access.bind(.canSetFileComment)) + .disabled(model.access?.contains(.canSetFileComment) == false) + Toggle("Create Folders", isOn: self.$account.access.bind(.canCreateFolders)) + .disabled(model.access?.contains(.canCreateFolders) == false) + Toggle("Delete Folders", isOn: self.$account.access.bind(.canDeleteFolders)) + .disabled(model.access?.contains(.canDeleteFolders) == false) + Toggle("Rename Folders", isOn: self.$account.access.bind(.canRenameFolders)) + .disabled(model.access?.contains(.canRenameFolders) == false) + Toggle("Move Folders", isOn: self.$account.access.bind(.canMoveFolders)) + .disabled(model.access?.contains(.canMoveFolders) == false) + Toggle("Comment Folders", isOn: self.$account.access.bind(.canSetFolderComment)) + .disabled(model.access?.contains(.canSetFolderComment) == false) + Toggle("View Drop Boxes", isOn: self.$account.access.bind(.canViewDropBoxes)) + .disabled(model.access?.contains(.canViewDropBoxes) == false) + Toggle("Make Aliases", isOn: self.$account.access.bind(.canMakeAliases)) + .disabled(model.access?.contains(.canMakeAliases) == false) + } + + Section("User Maintenance") { + Toggle("Create Accounts", isOn: self.$account.access.bind(.canCreateUsers)) + .disabled(model.access?.contains(.canCreateUsers) == false) + Toggle("Delete Accounts", isOn: self.$account.access.bind(.canDeleteUsers)) + .disabled(model.access?.contains(.canDeleteUsers) == false) + Toggle("Read Accounts", isOn: self.$account.access.bind(.canOpenUsers)) + .disabled(model.access?.contains(.canOpenUsers) == false) + Toggle("Modify Accounts", isOn: self.$account.access.bind(.canModifyUsers)) + .disabled(model.access?.contains(.canModifyUsers) == false) + Toggle("Get User Info", isOn: self.$account.access.bind(.canGetClientInfo)) + .disabled(model.access?.contains(.canGetClientInfo) == false) + + Toggle("Disconnect Users", isOn: self.$account.access.bind(.canDisconnectUsers)) + .disabled(model.access?.contains(.canDisconnectUsers) == false) + Toggle("Cannot be Disconnected", isOn: self.$account.access.bind(.cantBeDisconnected)) + .disabled(model.access?.contains(.cantBeDisconnected) == false) + } + + Section("Messaging") { + Toggle("Send Messages", isOn: self.$account.access.bind(.canSendMessages)) + .disabled(model.access?.contains(.canSendMessages) == false) + Toggle("Broadcast", isOn: self.$account.access.bind(.canBroadcast)) + .disabled(model.access?.contains(.canBroadcast) == false) + } + + Section("News") { + Toggle("Read Articles", isOn: self.$account.access.bind(.canReadMessageBoard)) + .disabled(model.access?.contains(.canReadMessageBoard) == false) + Toggle("Post Articles", isOn: self.$account.access.bind(.canPostMessageBoard)) + .disabled(model.access?.contains(.canPostMessageBoard) == false) + Toggle("Delete Articles", isOn: self.$account.access.bind(.canDeleteNewsArticles)) + .disabled(model.access?.contains(.canDeleteNewsArticles) == false) + Toggle("Create Categories", isOn: self.$account.access.bind(.canCreateNewsCategories)) + .disabled(model.access?.contains(.canCreateNewsCategories) == false) + Toggle("Delete Categories", isOn: self.$account.access.bind(.canDeleteNewsCategories)) + .disabled(model.access?.contains(.canDeleteNewsCategories) == false) + Toggle("Create News Bundles", isOn: self.$account.access.bind(.canCreateNewsFolders)) + .disabled(model.access?.contains(.canCreateNewsFolders) == false) + Toggle("Delete News Bundles", isOn: self.$account.access.bind(.canDeleteNewsFolders)) + .disabled(model.access?.contains(.canDeleteNewsFolders) == false) + } + + Section("Chat") { + Toggle("Initiate Private Chat", isOn: self.$account.access.bind(.canCreateChat)) + .disabled(model.access?.contains(.canCreateChat) == false) + Toggle("Read Chat", isOn: self.$account.access.bind(.canReadChat)) + .disabled(model.access?.contains(.canReadChat) == false) + Toggle("Send Chat", isOn: self.$account.access.bind(.canSendChat)) + .disabled(model.access?.contains(.canSendChat) == false) + } + + Section("Miscellaneous") { + Toggle("Use Any Name", isOn: self.$account.access.bind(.canUseAnyName)) + .disabled(model.access?.contains(.canUseAnyName) == false) + Toggle("Don't Show Agreement", isOn: self.$account.access.bind(.canSkipAgreement)) + .disabled(model.access?.contains(.canSkipAgreement) == false) + } } - - // If the access bits or user name have been changed - return pendingAccess.rawValue != selection.access.rawValue || selection.name != pendingName + .disabled(self.model.access?.contains(.canModifyUsers) == false) + .formStyle(.grouped) } } diff --git a/Hotline/macOS/Files/FileDetailsSheet.swift b/Hotline/macOS/Files/FileDetailsSheet.swift index d798fd4..2118518 100644 --- a/Hotline/macOS/Files/FileDetailsSheet.swift +++ b/Hotline/macOS/Files/FileDetailsSheet.swift @@ -13,8 +13,14 @@ struct FileDetailsSheet: View { var body: some View { VStack(alignment: .leading, spacing: 16) { HStack(alignment: .center, spacing: 16){ - FileIconView(filename: fd.name, fileType: nil) - .frame(width: 32, height: 32) + if self.fd.type == "Folder" { + FolderIconView() + .frame(width: 32, height: 32) + } + else { + FileIconView(filename: fd.name, fileType: nil) + .frame(width: 32, height: 32) + } TextField("", text: $filename) .disabled(!self.canRename()) } diff --git a/Hotline/macOS/Files/FilesView.swift b/Hotline/macOS/Files/FilesView.swift index a9b0b83..2386d2a 100644 --- a/Hotline/macOS/Files/FilesView.swift +++ b/Hotline/macOS/Files/FilesView.swift @@ -12,8 +12,8 @@ struct FilesView: View { @State private var searchText: String = "" @State private var isSearching: Bool = false @State private var dragOver: Bool = false - @State private var deleteConfirmationDisplayed: Bool = false - @State private var newFolderSheetDisplayed: Bool = false + @State private var confirmDeleteShown: Bool = false + @State private var newFolderShown: Bool = false var body: some View { NavigationStack { @@ -96,7 +96,7 @@ struct FilesView: View { Divider() Button { - self.deleteConfirmationDisplayed = true + self.confirmDeleteShown = true } label: { Label("Delete...", systemImage: "trash") } @@ -200,16 +200,21 @@ struct FilesView: View { ToolbarItem { Button { - self.newFolderSheetDisplayed = true + self.newFolderShown = true } label: { Label("New Folder", systemImage: "folder.badge.plus") } .help("New Folder") + .popover(isPresented: self.$newFolderShown, arrowEdge: .bottom) { + NewFolderPopover { folderName in + self.newFolder(name: folderName, parent: self.selection) + } + } } ToolbarItem { Button { - self.deleteConfirmationDisplayed = true + self.confirmDeleteShown = true } label: { Label("Delete", systemImage: "trash") } @@ -218,7 +223,7 @@ struct FilesView: View { } } } - .alert("Are you sure you want to permanently delete \"\(self.selection?.name ?? "this file")\"?", isPresented: self.$deleteConfirmationDisplayed, actions: { + .alert("Are you sure you want to permanently delete \"\(self.selection?.name ?? "this file")\"?", isPresented: self.$confirmDeleteShown, actions: { Button("Delete", role: .destructive) { if let s = self.selection { Task { @@ -229,11 +234,6 @@ struct FilesView: View { }, message: { Text("You cannot undo this action.") }) - .sheet(isPresented: self.$newFolderSheetDisplayed) { - NewFolderSheet { folderName in - self.newFolder(name: folderName, parent: self.selection) - } - } .sheet(item: self.$fileDetails) { item in FileDetailsSheet(fd: item) } @@ -441,9 +441,7 @@ struct FilesView: View { @MainActor private func getFileInfo(_ file: FileInfo) { Task { if let fileInfo = try? await model.getFileDetails(file.name, path: file.path) { - Task { @MainActor in - self.fileDetails = fileInfo - } + self.fileDetails = fileInfo } } } diff --git a/Hotline/macOS/Files/NewFolderPopover.swift b/Hotline/macOS/Files/NewFolderPopover.swift new file mode 100644 index 0000000..4e282f5 --- /dev/null +++ b/Hotline/macOS/Files/NewFolderPopover.swift @@ -0,0 +1,51 @@ +import SwiftUI + +struct NewFolderPopover: View { + @Environment(\.dismiss) private var dismiss + + let action: ((String) -> Void)? + + @State private var folderName: String = "Untitled Folder" + + var body: some View { + VStack(spacing: 16) { + TextField("Folder Name", text: self.$folderName) + .onSubmit(of: .text) { + self.createFolder() + } + + HStack(spacing: 8) { + Spacer() + + Button("Cancel", role: .cancel) { + self.dismiss() + } + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + + if #available(macOS 26.0, *) { + Button("New Folder", role: .confirm) { + self.createFolder() + } + .buttonStyle(.borderedProminent) + .buttonBorderShape(.capsule) + } + else { + Button("OK") { + self.dismiss() + self.action?(self.folderName) + } + .buttonStyle(.borderedProminent) + .buttonBorderShape(.capsule) + } + } + } + .frame(width: 250) + .padding() + } + + private func createFolder() { + self.dismiss() + self.action?(self.folderName) + } +} diff --git a/Hotline/macOS/Files/NewFolderSheet.swift b/Hotline/macOS/Files/NewFolderSheet.swift deleted file mode 100644 index a899f36..0000000 --- a/Hotline/macOS/Files/NewFolderSheet.swift +++ /dev/null @@ -1,32 +0,0 @@ -import SwiftUI - -struct NewFolderSheet: View { - @Environment(\.dismiss) private var dismiss - - let action: ((String) -> Void)? - - @State private var folderName: String = "Untitled" - - var body: some View { - Form { - TextField(text: self.$folderName) { - Text("Folder Name") - } - } - .formStyle(.grouped) - .fixedSize(horizontal: false, vertical: true) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("New Folder") { - self.dismiss() - self.action?(self.folderName) - } - } - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - self.dismiss() - } - } - } - } -} diff --git a/Hotline/macOS/HotlinePanelView.swift b/Hotline/macOS/HotlinePanelView.swift index a56cd44..6443366 100644 --- a/Hotline/macOS/HotlinePanelView.swift +++ b/Hotline/macOS/HotlinePanelView.swift @@ -114,7 +114,8 @@ struct HotlinePanelView: View { if self.activeHotline?.access?.contains(.canOpenUsers) == true { Button { - self.activeServerState?.selection = .accounts +// self.activeServerState?.selection = .accounts + self.activeServerState?.accountsShown = true } label: { Image("Section Users") @@ -124,7 +125,7 @@ struct HotlinePanelView: View { .buttonStyle(.plain) .frame(width: 20, height: 20) .disabled(self.activeServerState == nil) - .help("Administration") + .help("Manage Server") } Button { diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index 399deba..2fddee6 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -87,13 +87,14 @@ struct ServerView: View { @State private var connectLogin: String = "" @State private var connectPassword: String = "" @State private var connectionDisplayed: Bool = false +// @State private var accountsShown: Bool = false static var menuItems: [ServerMenuItem] = [ ServerMenuItem(type: .chat, name: "Chat", image: "Section Chat"), ServerMenuItem(type: .board, name: "Board", image: "Section Board"), ServerMenuItem(type: .news, name: "News", image: "Section News"), ServerMenuItem(type: .files, name: "Files", image: "Section Files"), - ServerMenuItem(type: .accounts, name: "Accounts", image: "Section Users"), +// ServerMenuItem(type: .accounts, name: "Accounts", image: "Section Users"), ] static var classicMenuItems: [ServerMenuItem] = [ @@ -110,7 +111,7 @@ struct ServerView: View { self.connectForm Spacer() } - .navigationTitle("Connect to Server") + .navigationTitle("New Connection") } else if self.model.status.isLoggingIn { HStack { @@ -130,7 +131,7 @@ struct ServerView: View { } .frame(maxWidth: 300) .padding() - .navigationTitle("Connecting to Server") + .navigationTitle("New Connection") } else if self.model.status == .loggedIn { self.serverView @@ -153,6 +154,12 @@ struct ServerView: View { .onChange(of: Prefs.shared.automaticMessage) { Task { try? await self.model.sendUserPreferences() } } + .sheet(isPresented: self.$state.accountsShown) { + AccountManagerView() + .environment(self.model) + .frame(width: 400, height: 450) + .presentationSizing(.fitted) + } .toolbar { if #available(macOS 26.0, *) { ToolbarItem(placement: .navigation) { @@ -232,11 +239,11 @@ struct ServerView: View { if menuItem.type == .chat { ListItemView(icon: menuItem.image, title: menuItem.name, unread: model.unreadPublicChat).tag(menuItem.type) } - else if menuItem.type == .accounts { - if model.access?.contains(.canOpenUsers) == true { - ListItemView(icon: menuItem.image, title: menuItem.name, unread: false).tag(menuItem.type) - } - } +// else if menuItem.type == .accounts { +// if model.access?.contains(.canOpenUsers) == true { +// ListItemView(icon: menuItem.image, title: menuItem.name, unread: false).tag(menuItem.type) +// } +// } else if menuItem.type == .files { ListItemView(icon: menuItem.image, title: menuItem.name, unread: false).tag(menuItem.type) .overlay(alignment: .trailing) { @@ -320,39 +327,51 @@ struct ServerView: View { NavigationSplitView { self.navigationList .frame(maxWidth: .infinity) - .navigationSplitViewColumnWidth(min: 150, ideal: 200, max: 500) + .navigationSplitViewColumnWidth(min: 200, ideal: 250, max: 500) + .toolbar { + if self.model.access?.contains(.canOpenUsers) == true { + ToolbarItem { + Button { + self.state.accountsShown = true + } label: { + Label("Manage Server", systemImage: "gear") + } + .help("Manage Server") + } + } + } } detail: { switch state.selection { case .chat: ChatView() .navigationTitle(model.serverTitle) - .navigationSubtitle("Public Chat") +// .navigationSubtitle("Public Chat") .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .news: NewsView() .navigationTitle(model.serverTitle) - .navigationSubtitle("Newsgroups") +// .navigationSubtitle("Newsgroups") .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .board: MessageBoardView() .navigationTitle(model.serverTitle) - .navigationSubtitle("Message Board") +// .navigationSubtitle("Message Board") .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .files: FilesView() .navigationTitle(model.serverTitle) - .navigationSubtitle("Shared Files") +// .navigationSubtitle("Shared Files") .navigationSplitViewColumnWidth(min: 250, ideal: 500) - case .accounts: - AccountManagerView() - .navigationTitle(model.serverTitle) - .navigationSubtitle("Accounts") - .navigationSplitViewColumnWidth(min: 250, ideal: 500) +// case .accounts: +// AccountManagerView() +// .navigationTitle(model.serverTitle) +//// .navigationSubtitle("Accounts") +// .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .user(let userID): - let user = model.users.first(where: { $0.id == userID }) +// let user = model.users.first(where: { $0.id == userID }) MessageView(userID: userID) .navigationTitle(model.serverTitle) - .navigationSubtitle(user?.name ?? "Private Message") +// .navigationSubtitle(user?.name ?? "Private Message") .navigationSplitViewColumnWidth(min: 250, ideal: 500) .onAppear { model.markInstantMessagesAsRead(userID: userID) diff --git a/Hotline/macOS/TransfersView.swift b/Hotline/macOS/TransfersView.swift index 9bef02c..1fefe5a 100644 --- a/Hotline/macOS/TransfersView.swift +++ b/Hotline/macOS/TransfersView.swift @@ -163,7 +163,7 @@ struct TransfersView: View { let result: [(name: String, url: URL)] = intersection.compactMap { url in let appName = FileManager.default .displayName(atPath: url.path) - .replacingOccurrences(of: ".app", with: "") + .replacing(".app", with: "") return (name: appName, url: url) }.sorted { $0.name < $1.name } @@ -178,7 +178,7 @@ struct TransfersView: View { if fileURLs.count == 1, let url = NSWorkspace.shared.urlForApplication(toOpen: fileURLs[0]) { let name = FileManager.default .displayName(atPath: url.path) - .replacingOccurrences(of: ".app", with: "") + .replacing(".app", with: "") return (name, url) } @@ -213,7 +213,7 @@ struct TransfersView: View { defaultCounts[bestByMajority, default: 0] > 0 { let name = FileManager.default .displayName(atPath: bestByMajority.path) - .replacingOccurrences(of: ".app", with: "") + .replacing(".app", with: "") return (name, bestByMajority) } -- cgit From 79a37fa509576c94b96ff896550426d8ebfdec67 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Wed, 12 Nov 2025 19:52:08 -0800 Subject: Update to latest project settings. Also split up Account management views. --- Hotline.xcodeproj/project.pbxproj | 20 +- .../xcshareddata/xcschemes/Hotline.xcscheme | 2 +- Hotline/Hotline.entitlements | 10 - Hotline/macOS/Accounts/AccountDetailsView.swift | 204 +++++++++++++++++++++ Hotline/macOS/Accounts/AccountManagerView.swift | 203 +------------------- 5 files changed, 223 insertions(+), 216 deletions(-) create mode 100644 Hotline/macOS/Accounts/AccountDetailsView.swift diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index 7032420..28b30f8 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -88,6 +88,7 @@ DAB4D8822B4C8FED0048A05C /* FileIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAB4D8812B4C8FED0048A05C /* FileIconView.swift */; }; DAB4D8842B4CABEF0048A05C /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAB4D8832B4CABEF0048A05C /* Extensions.swift */; }; DAB4D8872B4CB3610048A05C /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = DAB4D8862B4CB3610048A05C /* MarkdownUI */; }; + DABAFBEA2EC58C170015E889 /* AccountDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABAFBE92EC58C170015E889 /* AccountDetailsView.swift */; }; DABE8BF42B55DC0A00884D28 /* transfer-complete.aiff in Resources */ = {isa = PBXBuildFile; fileRef = DABE8BF32B55DC0A00884D28 /* transfer-complete.aiff */; }; DABE8BF62B55DC2E00884D28 /* chat-message.aiff in Resources */ = {isa = PBXBuildFile; fileRef = DABE8BF52B55DC2E00884D28 /* chat-message.aiff */; }; DABE8BF82B55DC6100884D28 /* logged-in.aiff in Resources */ = {isa = PBXBuildFile; fileRef = DABE8BF72B55DC6100884D28 /* logged-in.aiff */; }; @@ -201,6 +202,7 @@ DAB4D87D2B4C8BCA0048A05C /* FilePreviewTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePreviewTextView.swift; sourceTree = ""; }; DAB4D8812B4C8FED0048A05C /* FileIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileIconView.swift; sourceTree = ""; }; DAB4D8832B4CABEF0048A05C /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = ""; }; + DABAFBE92EC58C170015E889 /* AccountDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDetailsView.swift; sourceTree = ""; }; DABE8BF32B55DC0A00884D28 /* transfer-complete.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = "transfer-complete.aiff"; sourceTree = ""; }; DABE8BF52B55DC2E00884D28 /* chat-message.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = "chat-message.aiff"; sourceTree = ""; }; DABE8BF72B55DC6100884D28 /* logged-in.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = "logged-in.aiff"; sourceTree = ""; }; @@ -363,6 +365,7 @@ isa = PBXGroup; children = ( 11F8288A2BF9428100216BA0 /* AccountManagerView.swift */, + DABAFBE92EC58C170015E889 /* AccountDetailsView.swift */, ); path = Accounts; sourceTree = ""; @@ -579,7 +582,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1500; - LastUpgradeCheck = 1500; + LastUpgradeCheck = 2600; TargetAttributes = { DA9CAFB62B126D5700CDA197 = { CreatedOnToolsVersion = 15.0.1; @@ -642,6 +645,7 @@ DA52689E2EB073A400DCB941 /* IconSettingsView.swift in Sources */, DAC3D9832BC33FD000A727C9 /* AppState.swift in Sources */, DA65499C2BEC3FBD00EDB697 /* ServerAgreementView.swift in Sources */, + DABAFBEA2EC58C170015E889 /* AccountDetailsView.swift in Sources */, DA5268BC2EB95A7700DCB941 /* HotlineFolderDownloadClient.swift in Sources */, DA2863DD2B3E8B7000A7D050 /* FilePreview.swift in Sources */, DA3429B02EBA70790010784E /* HotlineFolderUploadClient.swift in Sources */, @@ -772,6 +776,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = 5AEEV7QB2U; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -795,6 +800,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; @@ -835,6 +841,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 5AEEV7QB2U; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -851,6 +858,7 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; VALIDATE_PRODUCT = YES; }; @@ -866,9 +874,12 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 24; DEVELOPMENT_ASSET_PATHS = "\"Hotline/Preview Content\""; - DEVELOPMENT_TEAM = 5AEEV7QB2U; + ENABLE_APP_SANDBOX = YES; + ENABLE_FILE_ACCESS_DOWNLOADS_FOLDER = readwrite; "ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = YES; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_PREVIEWS = YES; + ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Hotline/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Hotline; @@ -903,9 +914,12 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 24; DEVELOPMENT_ASSET_PATHS = "\"Hotline/Preview Content\""; - DEVELOPMENT_TEAM = 5AEEV7QB2U; + ENABLE_APP_SANDBOX = YES; + ENABLE_FILE_ACCESS_DOWNLOADS_FOLDER = readwrite; "ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = YES; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_PREVIEWS = YES; + ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Hotline/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Hotline; diff --git a/Hotline.xcodeproj/xcshareddata/xcschemes/Hotline.xcscheme b/Hotline.xcodeproj/xcshareddata/xcschemes/Hotline.xcscheme index 91934ce..0fe9b50 100644 --- a/Hotline.xcodeproj/xcshareddata/xcschemes/Hotline.xcscheme +++ b/Hotline.xcodeproj/xcshareddata/xcschemes/Hotline.xcscheme @@ -1,6 +1,6 @@ CloudKit - com.apple.security.app-sandbox - - com.apple.security.files.downloads.read-write - - com.apple.security.files.user-selected.read-only - - com.apple.security.files.user-selected.read-write - - com.apple.security.network.client - diff --git a/Hotline/macOS/Accounts/AccountDetailsView.swift b/Hotline/macOS/Accounts/AccountDetailsView.swift new file mode 100644 index 0000000..d4eab4e --- /dev/null +++ b/Hotline/macOS/Accounts/AccountDetailsView.swift @@ -0,0 +1,204 @@ +import SwiftUI + +fileprivate let PASSWORD_PLACEHOLDER = "xxxxxxxxxxxxxxxxxx" + +struct AccountDetailsView: View { + @Environment(HotlineState.self) private var model: HotlineState + @Environment(\.dismiss) private var dismiss + + @State var account: HotlineAccount = HotlineAccount(DEFAULT_ACCOUNT_NAME, "", HotlineUserAccessOptions.defaultAccess) + + let saved: ((HotlineAccount) -> Void)? + + @State private var password: String = "" + @State private var saving: Bool = false + + var body: some View { + self.accountDetails + .onAppear { + // Display a placeholder for accounts that have been saved to the server + // because we don't have the account password on hand to display. + if self.account.persisted { + self.password = PASSWORD_PLACEHOLDER + } + } + .toolbar { + if self.saving { + ToolbarItem { + ProgressView() + .controlSize(.small) + } + } + + ToolbarItem(placement: .cancellationAction) { + Button { + self.dismiss() + } label: { + Text("Cancel") + } + } + + ToolbarItem(placement: .confirmationAction) { + Button { + Task { + do { + try await self.save() + self.dismiss() + } + catch { + print("ERROR SAVING ACCOUNT: \(error)") + } + } + } label: { + Text(self.account.persisted ? "Save" : "Create") + } + .disabled(self.saving) + } + } + } + + private func save() async throws { + self.saving = true + defer { self.saving = false } + + var accountName: String = self.account.name + if accountName.isBlank { + accountName = DEFAULT_ACCOUNT_NAME + } + + // Update existing account + if self.account.persisted { + if self.password == PASSWORD_PLACEHOLDER { + try await self.model.setUser(name: accountName, login: self.account.login, newLogin: nil, password: nil, access: self.account.access.rawValue) + } else { + try await model.setUser(name: accountName, login: self.account.login, newLogin: nil, password: self.password, access: self.account.access.rawValue) + } + + } else { + // Create new existing account + try await model.createUser(name: accountName, login: self.account.login, password: self.password, access: self.account.access.rawValue) + +// self.password = PASSWORD_PLACEHOLDER + self.account.persisted = true + } + + self.account.name = accountName + self.saved?(self.account) + } + + var accountDetails: some View { + Form { + Section { + TextField(text: self.$account.name, prompt: Text(DEFAULT_ACCOUNT_NAME)) { + Text("Account") + } + } + + Section { + TextField("Login", text: self.$account.login, prompt: Text("Required")) + .disabled(self.account.persisted) + + if self.account.persisted { + SecureField("Password", text: self.$password, prompt: Text("Optional")) + } else { + TextField("Password", text: self.$password, prompt: Text("Optional")) + } + } + + Section("Files") { + Toggle("Download Files", isOn: self.$account.access.bind(.canDownloadFiles)) + .disabled(self.model.access?.contains(.canDownloadFiles) == false) + Toggle("Download Folders", isOn: self.$account.access.bind(.canDownloadFolders)) + .disabled(model.access?.contains(.canDownloadFolders) == false) + Toggle("Upload Files", isOn: self.$account.access.bind(.canUploadFiles)) + .disabled(model.access?.contains(.canUploadFiles) == false) + Toggle("Upload Folders", isOn: self.$account.access.bind(.canUploadFolders)) + .disabled(model.access?.contains(.canUploadFolders) == false) + Toggle("Upload Anywhere", isOn: self.$account.access.bind(.canUploadAnywhere)) + .disabled(model.access?.contains(.canUploadAnywhere) == false) + Toggle("Delete Files", isOn: self.$account.access.bind(.canDeleteFiles)) + .disabled(model.access?.contains(.canDeleteFiles) == false) + Toggle("Rename Files", isOn: self.$account.access.bind(.canRenameFiles)) + .disabled(model.access?.contains(.canRenameFiles) == false) + Toggle("Move Files", isOn: self.$account.access.bind(.canMoveFiles)) + .disabled(model.access?.contains(.canMoveFiles) == false) + Toggle("Comment Files", isOn: self.$account.access.bind(.canSetFileComment)) + .disabled(model.access?.contains(.canSetFileComment) == false) + Toggle("Create Folders", isOn: self.$account.access.bind(.canCreateFolders)) + .disabled(model.access?.contains(.canCreateFolders) == false) + Toggle("Delete Folders", isOn: self.$account.access.bind(.canDeleteFolders)) + .disabled(model.access?.contains(.canDeleteFolders) == false) + Toggle("Rename Folders", isOn: self.$account.access.bind(.canRenameFolders)) + .disabled(model.access?.contains(.canRenameFolders) == false) + Toggle("Move Folders", isOn: self.$account.access.bind(.canMoveFolders)) + .disabled(model.access?.contains(.canMoveFolders) == false) + Toggle("Comment Folders", isOn: self.$account.access.bind(.canSetFolderComment)) + .disabled(model.access?.contains(.canSetFolderComment) == false) + Toggle("View Drop Boxes", isOn: self.$account.access.bind(.canViewDropBoxes)) + .disabled(model.access?.contains(.canViewDropBoxes) == false) + Toggle("Make Aliases", isOn: self.$account.access.bind(.canMakeAliases)) + .disabled(model.access?.contains(.canMakeAliases) == false) + } + + Section("User Maintenance") { + Toggle("Create Accounts", isOn: self.$account.access.bind(.canCreateUsers)) + .disabled(model.access?.contains(.canCreateUsers) == false) + Toggle("Delete Accounts", isOn: self.$account.access.bind(.canDeleteUsers)) + .disabled(model.access?.contains(.canDeleteUsers) == false) + Toggle("Read Accounts", isOn: self.$account.access.bind(.canOpenUsers)) + .disabled(model.access?.contains(.canOpenUsers) == false) + Toggle("Modify Accounts", isOn: self.$account.access.bind(.canModifyUsers)) + .disabled(model.access?.contains(.canModifyUsers) == false) + Toggle("Get User Info", isOn: self.$account.access.bind(.canGetClientInfo)) + .disabled(model.access?.contains(.canGetClientInfo) == false) + + Toggle("Disconnect Users", isOn: self.$account.access.bind(.canDisconnectUsers)) + .disabled(model.access?.contains(.canDisconnectUsers) == false) + Toggle("Cannot be Disconnected", isOn: self.$account.access.bind(.cantBeDisconnected)) + .disabled(model.access?.contains(.cantBeDisconnected) == false) + } + + Section("Messaging") { + Toggle("Send Messages", isOn: self.$account.access.bind(.canSendMessages)) + .disabled(model.access?.contains(.canSendMessages) == false) + Toggle("Broadcast", isOn: self.$account.access.bind(.canBroadcast)) + .disabled(model.access?.contains(.canBroadcast) == false) + } + + Section("News") { + Toggle("Read Articles", isOn: self.$account.access.bind(.canReadMessageBoard)) + .disabled(model.access?.contains(.canReadMessageBoard) == false) + Toggle("Post Articles", isOn: self.$account.access.bind(.canPostMessageBoard)) + .disabled(model.access?.contains(.canPostMessageBoard) == false) + Toggle("Delete Articles", isOn: self.$account.access.bind(.canDeleteNewsArticles)) + .disabled(model.access?.contains(.canDeleteNewsArticles) == false) + Toggle("Create Categories", isOn: self.$account.access.bind(.canCreateNewsCategories)) + .disabled(model.access?.contains(.canCreateNewsCategories) == false) + Toggle("Delete Categories", isOn: self.$account.access.bind(.canDeleteNewsCategories)) + .disabled(model.access?.contains(.canDeleteNewsCategories) == false) + Toggle("Create News Bundles", isOn: self.$account.access.bind(.canCreateNewsFolders)) + .disabled(model.access?.contains(.canCreateNewsFolders) == false) + Toggle("Delete News Bundles", isOn: self.$account.access.bind(.canDeleteNewsFolders)) + .disabled(model.access?.contains(.canDeleteNewsFolders) == false) + } + + Section("Chat") { + Toggle("Initiate Private Chat", isOn: self.$account.access.bind(.canCreateChat)) + .disabled(model.access?.contains(.canCreateChat) == false) + Toggle("Read Chat", isOn: self.$account.access.bind(.canReadChat)) + .disabled(model.access?.contains(.canReadChat) == false) + Toggle("Send Chat", isOn: self.$account.access.bind(.canSendChat)) + .disabled(model.access?.contains(.canSendChat) == false) + } + + Section("Miscellaneous") { + Toggle("Use Any Name", isOn: self.$account.access.bind(.canUseAnyName)) + .disabled(model.access?.contains(.canUseAnyName) == false) + Toggle("Don't Show Agreement", isOn: self.$account.access.bind(.canSkipAgreement)) + .disabled(model.access?.contains(.canSkipAgreement) == false) + } + } + .disabled(self.model.access?.contains(.canModifyUsers) == false) + .formStyle(.grouped) + } +} diff --git a/Hotline/macOS/Accounts/AccountManagerView.swift b/Hotline/macOS/Accounts/AccountManagerView.swift index bbb909b..d288196 100644 --- a/Hotline/macOS/Accounts/AccountManagerView.swift +++ b/Hotline/macOS/Accounts/AccountManagerView.swift @@ -1,7 +1,6 @@ import SwiftUI -fileprivate let DEFAULT_ACCOUNT_NAME = "Untitled Account" -fileprivate let PASSWORD_PLACEHOLDER = "xxxxxxxxxxxxxxxxxx" +let DEFAULT_ACCOUNT_NAME = "Untitled Account" struct AccountManagerView: View { @Environment(HotlineState.self) private var model: HotlineState @@ -216,203 +215,3 @@ struct AccountManagerView: View { } -struct AccountDetailsView: View { - @Environment(HotlineState.self) private var model: HotlineState - @Environment(\.dismiss) private var dismiss - - @State var account: HotlineAccount = HotlineAccount("Untitled Account", "", HotlineUserAccessOptions.defaultAccess) - - let saved: ((HotlineAccount) -> Void)? - - @State private var password: String = "" - @State private var saving: Bool = false - - var body: some View { - self.accountDetails - .onAppear { - // Display a placeholder for accounts that have been saved to the server - // because we don't have the account password on hand to display. - if self.account.persisted { - self.password = PASSWORD_PLACEHOLDER - } - } - .toolbar { - if self.saving { - ToolbarItem { - ProgressView() - .controlSize(.small) - } - } - - ToolbarItem(placement: .cancellationAction) { - Button { - self.dismiss() - } label: { - Text("Cancel") - } - } - - ToolbarItem(placement: .confirmationAction) { - Button { - Task { - do { - try await self.save() - self.dismiss() - } - catch { - print("ERROR SAVING ACCOUNT: \(error)") - } - } - } label: { - Text(self.account.persisted ? "Save" : "Create") - } - .disabled(self.saving) - } - } - } - - private func save() async throws { - self.saving = true - defer { self.saving = false } - - var accountName: String = self.account.name - if accountName.isBlank { - accountName = DEFAULT_ACCOUNT_NAME - } - - // Update existing account - if self.account.persisted { - if self.password == PASSWORD_PLACEHOLDER { - try await self.model.setUser(name: accountName, login: self.account.login, newLogin: nil, password: nil, access: self.account.access.rawValue) - } else { - try await model.setUser(name: accountName, login: self.account.login, newLogin: nil, password: self.password, access: self.account.access.rawValue) - } - - } else { - // Create new existing account - try await model.createUser(name: accountName, login: self.account.login, password: self.password, access: self.account.access.rawValue) - -// self.password = PASSWORD_PLACEHOLDER - self.account.persisted = true - } - - self.account.name = accountName - self.saved?(self.account) - } - - var accountDetails: some View { - Form { - Section { - TextField(text: self.$account.name, prompt: Text(DEFAULT_ACCOUNT_NAME)) { - Text("Account") - } - } - - Section { - TextField("Login", text: self.$account.login, prompt: Text("Required")) - .disabled(self.account.persisted) - - if self.account.persisted { - SecureField("Password", text: self.$password, prompt: Text("Optional")) - } else { - TextField("Password", text: self.$password, prompt: Text("Optional")) - } - } - - Section("Files") { - Toggle("Download Files", isOn: self.$account.access.bind(.canDownloadFiles)) - .disabled(self.model.access?.contains(.canDownloadFiles) == false) - Toggle("Download Folders", isOn: self.$account.access.bind(.canDownloadFolders)) - .disabled(model.access?.contains(.canDownloadFolders) == false) - Toggle("Upload Files", isOn: self.$account.access.bind(.canUploadFiles)) - .disabled(model.access?.contains(.canUploadFiles) == false) - Toggle("Upload Folders", isOn: self.$account.access.bind(.canUploadFolders)) - .disabled(model.access?.contains(.canUploadFolders) == false) - Toggle("Upload Anywhere", isOn: self.$account.access.bind(.canUploadAnywhere)) - .disabled(model.access?.contains(.canUploadAnywhere) == false) - Toggle("Delete Files", isOn: self.$account.access.bind(.canDeleteFiles)) - .disabled(model.access?.contains(.canDeleteFiles) == false) - Toggle("Rename Files", isOn: self.$account.access.bind(.canRenameFiles)) - .disabled(model.access?.contains(.canRenameFiles) == false) - Toggle("Move Files", isOn: self.$account.access.bind(.canMoveFiles)) - .disabled(model.access?.contains(.canMoveFiles) == false) - Toggle("Comment Files", isOn: self.$account.access.bind(.canSetFileComment)) - .disabled(model.access?.contains(.canSetFileComment) == false) - Toggle("Create Folders", isOn: self.$account.access.bind(.canCreateFolders)) - .disabled(model.access?.contains(.canCreateFolders) == false) - Toggle("Delete Folders", isOn: self.$account.access.bind(.canDeleteFolders)) - .disabled(model.access?.contains(.canDeleteFolders) == false) - Toggle("Rename Folders", isOn: self.$account.access.bind(.canRenameFolders)) - .disabled(model.access?.contains(.canRenameFolders) == false) - Toggle("Move Folders", isOn: self.$account.access.bind(.canMoveFolders)) - .disabled(model.access?.contains(.canMoveFolders) == false) - Toggle("Comment Folders", isOn: self.$account.access.bind(.canSetFolderComment)) - .disabled(model.access?.contains(.canSetFolderComment) == false) - Toggle("View Drop Boxes", isOn: self.$account.access.bind(.canViewDropBoxes)) - .disabled(model.access?.contains(.canViewDropBoxes) == false) - Toggle("Make Aliases", isOn: self.$account.access.bind(.canMakeAliases)) - .disabled(model.access?.contains(.canMakeAliases) == false) - } - - Section("User Maintenance") { - Toggle("Create Accounts", isOn: self.$account.access.bind(.canCreateUsers)) - .disabled(model.access?.contains(.canCreateUsers) == false) - Toggle("Delete Accounts", isOn: self.$account.access.bind(.canDeleteUsers)) - .disabled(model.access?.contains(.canDeleteUsers) == false) - Toggle("Read Accounts", isOn: self.$account.access.bind(.canOpenUsers)) - .disabled(model.access?.contains(.canOpenUsers) == false) - Toggle("Modify Accounts", isOn: self.$account.access.bind(.canModifyUsers)) - .disabled(model.access?.contains(.canModifyUsers) == false) - Toggle("Get User Info", isOn: self.$account.access.bind(.canGetClientInfo)) - .disabled(model.access?.contains(.canGetClientInfo) == false) - - Toggle("Disconnect Users", isOn: self.$account.access.bind(.canDisconnectUsers)) - .disabled(model.access?.contains(.canDisconnectUsers) == false) - Toggle("Cannot be Disconnected", isOn: self.$account.access.bind(.cantBeDisconnected)) - .disabled(model.access?.contains(.cantBeDisconnected) == false) - } - - Section("Messaging") { - Toggle("Send Messages", isOn: self.$account.access.bind(.canSendMessages)) - .disabled(model.access?.contains(.canSendMessages) == false) - Toggle("Broadcast", isOn: self.$account.access.bind(.canBroadcast)) - .disabled(model.access?.contains(.canBroadcast) == false) - } - - Section("News") { - Toggle("Read Articles", isOn: self.$account.access.bind(.canReadMessageBoard)) - .disabled(model.access?.contains(.canReadMessageBoard) == false) - Toggle("Post Articles", isOn: self.$account.access.bind(.canPostMessageBoard)) - .disabled(model.access?.contains(.canPostMessageBoard) == false) - Toggle("Delete Articles", isOn: self.$account.access.bind(.canDeleteNewsArticles)) - .disabled(model.access?.contains(.canDeleteNewsArticles) == false) - Toggle("Create Categories", isOn: self.$account.access.bind(.canCreateNewsCategories)) - .disabled(model.access?.contains(.canCreateNewsCategories) == false) - Toggle("Delete Categories", isOn: self.$account.access.bind(.canDeleteNewsCategories)) - .disabled(model.access?.contains(.canDeleteNewsCategories) == false) - Toggle("Create News Bundles", isOn: self.$account.access.bind(.canCreateNewsFolders)) - .disabled(model.access?.contains(.canCreateNewsFolders) == false) - Toggle("Delete News Bundles", isOn: self.$account.access.bind(.canDeleteNewsFolders)) - .disabled(model.access?.contains(.canDeleteNewsFolders) == false) - } - - Section("Chat") { - Toggle("Initiate Private Chat", isOn: self.$account.access.bind(.canCreateChat)) - .disabled(model.access?.contains(.canCreateChat) == false) - Toggle("Read Chat", isOn: self.$account.access.bind(.canReadChat)) - .disabled(model.access?.contains(.canReadChat) == false) - Toggle("Send Chat", isOn: self.$account.access.bind(.canSendChat)) - .disabled(model.access?.contains(.canSendChat) == false) - } - - Section("Miscellaneous") { - Toggle("Use Any Name", isOn: self.$account.access.bind(.canUseAnyName)) - .disabled(model.access?.contains(.canUseAnyName) == false) - Toggle("Don't Show Agreement", isOn: self.$account.access.bind(.canSkipAgreement)) - .disabled(model.access?.contains(.canSkipAgreement) == false) - } - } - .disabled(self.model.access?.contains(.canModifyUsers) == false) - .formStyle(.grouped) - } -} -- cgit From 3795039436f741ec23f65e7cd0639023bac1f1ca Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Wed, 12 Nov 2025 21:45:13 -0800 Subject: More work on account management. Add Broadcast UI. --- Hotline.xcodeproj/project.pbxproj | 4 + Hotline/Hotline/HotlineClient.swift | 11 ++ Hotline/MacApp.swift | 17 +-- Hotline/State/HotlineState.swift | 10 ++ Hotline/State/ServerState.swift | 1 + Hotline/macOS/Accounts/AccountDetailsView.swift | 181 +++++++++++++++++------- Hotline/macOS/BroadcastMessageView.swift | 66 +++++++++ Hotline/macOS/HotlinePanelView.swift | 2 +- Hotline/macOS/News/NewsView.swift | 2 +- Hotline/macOS/ServerView.swift | 9 +- 10 files changed, 234 insertions(+), 69 deletions(-) create mode 100644 Hotline/macOS/BroadcastMessageView.swift diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index 28b30f8..e741f7f 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -89,6 +89,7 @@ DAB4D8842B4CABEF0048A05C /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAB4D8832B4CABEF0048A05C /* Extensions.swift */; }; DAB4D8872B4CB3610048A05C /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = DAB4D8862B4CB3610048A05C /* MarkdownUI */; }; DABAFBEA2EC58C170015E889 /* AccountDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABAFBE92EC58C170015E889 /* AccountDetailsView.swift */; }; + DABAFBEC2EC599700015E889 /* BroadcastMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABAFBEB2EC5996B0015E889 /* BroadcastMessageView.swift */; }; DABE8BF42B55DC0A00884D28 /* transfer-complete.aiff in Resources */ = {isa = PBXBuildFile; fileRef = DABE8BF32B55DC0A00884D28 /* transfer-complete.aiff */; }; DABE8BF62B55DC2E00884D28 /* chat-message.aiff in Resources */ = {isa = PBXBuildFile; fileRef = DABE8BF52B55DC2E00884D28 /* chat-message.aiff */; }; DABE8BF82B55DC6100884D28 /* logged-in.aiff in Resources */ = {isa = PBXBuildFile; fileRef = DABE8BF72B55DC6100884D28 /* logged-in.aiff */; }; @@ -203,6 +204,7 @@ DAB4D8812B4C8FED0048A05C /* FileIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileIconView.swift; sourceTree = ""; }; DAB4D8832B4CABEF0048A05C /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = ""; }; DABAFBE92EC58C170015E889 /* AccountDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDetailsView.swift; sourceTree = ""; }; + DABAFBEB2EC5996B0015E889 /* BroadcastMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BroadcastMessageView.swift; sourceTree = ""; }; DABE8BF32B55DC0A00884D28 /* transfer-complete.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = "transfer-complete.aiff"; sourceTree = ""; }; DABE8BF52B55DC2E00884D28 /* chat-message.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = "chat-message.aiff"; sourceTree = ""; }; DABE8BF72B55DC6100884D28 /* logged-in.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = "logged-in.aiff"; sourceTree = ""; }; @@ -530,6 +532,7 @@ DAE734F72B2E4126000C56F6 /* macOS */ = { isa = PBXGroup; children = ( + DABAFBEB2EC5996B0015E889 /* BroadcastMessageView.swift */, DA55AC762BE589F700034857 /* AboutView.swift */, DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */, DAE136B92B9D1147007D8307 /* HotlinePanelView.swift */, @@ -730,6 +733,7 @@ DA501BE72EBE9542001714F8 /* TrackerBookmarkSheet.swift in Sources */, DAB4D87E2B4C8BCA0048A05C /* FilePreviewTextView.swift in Sources */, DA3429B72EBAB1750010784E /* QuickLookPreviewView.swift in Sources */, + DABAFBEC2EC599700015E889 /* BroadcastMessageView.swift in Sources */, DA4F2C012B1A558E00D8ADDC /* ChatView.swift in Sources */, DA5268B32EB6806E00DCB941 /* HotlineFileDownloadClient.swift in Sources */, DACCE5E32EABE86A008CDD92 /* AppUpdateView.swift in Sources */, diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index ee99ed7..8d0e870 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -489,6 +489,17 @@ public actor HotlineClient { } // MARK: - Chat + + /// Broadcast a message to the server + /// + /// - Parameters: + /// - message: Text to send + /// - encoding: Text encoding (default: UTF-8) + public func sendBroadcast(_ message: String, encoding: String.Encoding = .utf8, announce: Bool = false) async throws { + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .userBroadcast) + transaction.setFieldString(type: .data, val: message, encoding: encoding) + try await socket.send(transaction, endian: .big) + } /// Send a chat message to the server /// diff --git a/Hotline/MacApp.swift b/Hotline/MacApp.swift index 5a84da8..7997a87 100644 --- a/Hotline/MacApp.swift +++ b/Hotline/MacApp.swift @@ -246,9 +246,9 @@ struct Application: App { Divider() Button("Broadcast Message...") { - // TODO: Implement broadcast message when user is allowed. + activeServerState?.broadcastShown = true } - .disabled(true) + .disabled(activeHotline?.access?.contains(.canBroadcast) != true) .keyboardShortcut(.init("B"), modifiers: .command) Divider() @@ -274,15 +274,12 @@ struct Application: App { .disabled(activeHotline?.status != .loggedIn) .keyboardShortcut(.init("4"), modifiers: .command) - if activeHotline?.access?.contains(.canOpenUsers) == true { - Divider() - - Button("Manage Server...") { - activeServerState?.accountsShown = true - } - .disabled(activeHotline?.status != .loggedIn || activeHotline?.access?.contains(.canOpenUsers) != true ) -// .keyboardShortcut(.init("5"), modifiers: .command) + Divider() + + Button("Manage Accounts...") { + activeServerState?.accountsShown = true } + .disabled(activeHotline?.status != .loggedIn || activeHotline?.access?.contains(.canOpenUsers) != true) } } diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index 5d6471c..627b6a0 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -184,6 +184,7 @@ class HotlineState: Equatable { var users: [User] = [] // Chat + var broadcastMessage: String = "" var chat: [ChatMessage] = [] var chatInput: String = "" var unreadPublicChat: Bool = false @@ -613,6 +614,15 @@ class HotlineState: Equatable { } // MARK: - Chat + + @MainActor + func sendBroadcast(_ message: String) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + try await client.sendBroadcast(message) + } @MainActor func sendChat(_ text: String, announce: Bool = false) async throws { diff --git a/Hotline/State/ServerState.swift b/Hotline/State/ServerState.swift index 8f8d3bf..3fe10f9 100644 --- a/Hotline/State/ServerState.swift +++ b/Hotline/State/ServerState.swift @@ -6,6 +6,7 @@ class ServerState: Equatable { var selection: ServerNavigationType var serverName: String? = nil var accountsShown: Bool = false + var broadcastShown: Bool = false // var serverBanner: NSImage? = nil // var bannerBackgroundColor: Color? = nil diff --git a/Hotline/macOS/Accounts/AccountDetailsView.swift b/Hotline/macOS/Accounts/AccountDetailsView.swift index d4eab4e..8514873 100644 --- a/Hotline/macOS/Accounts/AccountDetailsView.swift +++ b/Hotline/macOS/Accounts/AccountDetailsView.swift @@ -2,6 +2,25 @@ import SwiftUI fileprivate let PASSWORD_PLACEHOLDER = "xxxxxxxxxxxxxxxxxx" +enum AccountDetailsError: Error { + case noLogin + case failedToSave + + var alertTitle: String { + switch self { + case .noLogin: "A login is required" + case .failedToSave: "Failed to save account" + } + } + + var alertMessage: String { + switch self { + case .noLogin: "Users with accounts are required to have a login. Please add one and try again." + case .failedToSave: "An error occurred while saving this account. Please try again." + } + } +} + struct AccountDetailsView: View { @Environment(HotlineState.self) private var model: HotlineState @Environment(\.dismiss) private var dismiss @@ -12,9 +31,27 @@ struct AccountDetailsView: View { @State private var password: String = "" @State private var saving: Bool = false + @State private var alertTitle: String = "" + @State private var alertMessage: String = "" + @State private var alertShown: Bool = false var body: some View { - self.accountDetails + self.detailsView + .alert(self.alertTitle, isPresented: self.$alertShown, actions: { + if #available(macOS 26.0, *) { + Button("OK", role: .confirm) { + self.alertShown = false + } + } + else { + Button("OK") { + self.alertShown = false + } + } + + }, message: { + Text(self.alertMessage) + }) .onAppear { // Display a placeholder for accounts that have been saved to the server // because we don't have the account password on hand to display. @@ -43,10 +80,11 @@ struct AccountDetailsView: View { Task { do { try await self.save() - self.dismiss() } - catch { - print("ERROR SAVING ACCOUNT: \(error)") + catch let error as AccountDetailsError { + self.alertTitle = error.alertTitle + self.alertMessage = error.alertMessage + self.alertShown = true } } } label: { @@ -58,41 +96,58 @@ struct AccountDetailsView: View { } private func save() async throws { + guard !self.account.login.isBlank else { + throw AccountDetailsError.noLogin + } + + self.account.name = self.account.name.trimmingCharacters(in: .whitespacesAndNewlines) + self.account.login = self.account.login.trimmingCharacters(in: .whitespacesAndNewlines) + self.saving = true defer { self.saving = false } + // We create a name var here so we don't see the default account name + // flash in the UI while saving. var accountName: String = self.account.name if accountName.isBlank { accountName = DEFAULT_ACCOUNT_NAME } - // Update existing account - if self.account.persisted { - if self.password == PASSWORD_PLACEHOLDER { - try await self.model.setUser(name: accountName, login: self.account.login, newLogin: nil, password: nil, access: self.account.access.rawValue) + do { + // Update existing account + if self.account.persisted { + if self.password == PASSWORD_PLACEHOLDER { + try await self.model.setUser(name: accountName, login: self.account.login, newLogin: nil, password: nil, access: self.account.access.rawValue) + } else { + try await model.setUser(name: accountName, login: self.account.login, newLogin: nil, password: self.password, access: self.account.access.rawValue) + } + } else { - try await model.setUser(name: accountName, login: self.account.login, newLogin: nil, password: self.password, access: self.account.access.rawValue) + // Create new existing account + try await model.createUser(name: accountName, login: self.account.login, password: self.password, access: self.account.access.rawValue) } - - } else { - // Create new existing account - try await model.createUser(name: accountName, login: self.account.login, password: self.password, access: self.account.access.rawValue) - -// self.password = PASSWORD_PLACEHOLDER - self.account.persisted = true + } + catch { + throw AccountDetailsError.failedToSave } + self.account.persisted = true self.account.name = accountName self.saved?(self.account) } - var accountDetails: some View { + private var isEditable: Bool { + self.model.access?.contains(.canModifyUsers) == true + } + + private var detailsView: some View { Form { Section { TextField(text: self.$account.name, prompt: Text(DEFAULT_ACCOUNT_NAME)) { Text("Account") } } + .disabled(!self.isEditable) Section { TextField("Login", text: self.$account.login, prompt: Text("Required")) @@ -104,101 +159,117 @@ struct AccountDetailsView: View { TextField("Password", text: self.$password, prompt: Text("Optional")) } } + .sectionActions { + HStack { + Spacer() + Text("The following permissions define what users of this account can do on this server. Accounts that can disconnect other users are shown in red.") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + Spacer() + } + } + .disabled(!self.isEditable) Section("Files") { Toggle("Download Files", isOn: self.$account.access.bind(.canDownloadFiles)) - .disabled(self.model.access?.contains(.canDownloadFiles) == false) +// .disabled(self.model.access?.contains(.canDownloadFiles) == false) Toggle("Download Folders", isOn: self.$account.access.bind(.canDownloadFolders)) - .disabled(model.access?.contains(.canDownloadFolders) == false) +// .disabled(model.access?.contains(.canDownloadFolders) == false) Toggle("Upload Files", isOn: self.$account.access.bind(.canUploadFiles)) - .disabled(model.access?.contains(.canUploadFiles) == false) +// .disabled(model.access?.contains(.canUploadFiles) == false) Toggle("Upload Folders", isOn: self.$account.access.bind(.canUploadFolders)) - .disabled(model.access?.contains(.canUploadFolders) == false) +// .disabled(model.access?.contains(.canUploadFolders) == false) Toggle("Upload Anywhere", isOn: self.$account.access.bind(.canUploadAnywhere)) - .disabled(model.access?.contains(.canUploadAnywhere) == false) +// .disabled(model.access?.contains(.canUploadAnywhere) == false) Toggle("Delete Files", isOn: self.$account.access.bind(.canDeleteFiles)) - .disabled(model.access?.contains(.canDeleteFiles) == false) +// .disabled(model.access?.contains(.canDeleteFiles) == false) Toggle("Rename Files", isOn: self.$account.access.bind(.canRenameFiles)) - .disabled(model.access?.contains(.canRenameFiles) == false) +// .disabled(model.access?.contains(.canRenameFiles) == false) Toggle("Move Files", isOn: self.$account.access.bind(.canMoveFiles)) - .disabled(model.access?.contains(.canMoveFiles) == false) +// .disabled(model.access?.contains(.canMoveFiles) == false) Toggle("Comment Files", isOn: self.$account.access.bind(.canSetFileComment)) - .disabled(model.access?.contains(.canSetFileComment) == false) +// .disabled(model.access?.contains(.canSetFileComment) == false) Toggle("Create Folders", isOn: self.$account.access.bind(.canCreateFolders)) - .disabled(model.access?.contains(.canCreateFolders) == false) +// .disabled(model.access?.contains(.canCreateFolders) == false) Toggle("Delete Folders", isOn: self.$account.access.bind(.canDeleteFolders)) - .disabled(model.access?.contains(.canDeleteFolders) == false) +// .disabled(model.access?.contains(.canDeleteFolders) == false) Toggle("Rename Folders", isOn: self.$account.access.bind(.canRenameFolders)) - .disabled(model.access?.contains(.canRenameFolders) == false) +// .disabled(model.access?.contains(.canRenameFolders) == false) Toggle("Move Folders", isOn: self.$account.access.bind(.canMoveFolders)) - .disabled(model.access?.contains(.canMoveFolders) == false) +// .disabled(model.access?.contains(.canMoveFolders) == false) Toggle("Comment Folders", isOn: self.$account.access.bind(.canSetFolderComment)) - .disabled(model.access?.contains(.canSetFolderComment) == false) +// .disabled(model.access?.contains(.canSetFolderComment) == false) Toggle("View Drop Boxes", isOn: self.$account.access.bind(.canViewDropBoxes)) - .disabled(model.access?.contains(.canViewDropBoxes) == false) +// .disabled(model.access?.contains(.canViewDropBoxes) == false) Toggle("Make Aliases", isOn: self.$account.access.bind(.canMakeAliases)) - .disabled(model.access?.contains(.canMakeAliases) == false) +// .disabled(model.access?.contains(.canMakeAliases) == false) } + .disabled(!self.isEditable) Section("User Maintenance") { Toggle("Create Accounts", isOn: self.$account.access.bind(.canCreateUsers)) - .disabled(model.access?.contains(.canCreateUsers) == false) +// .disabled(model.access?.contains(.canCreateUsers) == false) Toggle("Delete Accounts", isOn: self.$account.access.bind(.canDeleteUsers)) - .disabled(model.access?.contains(.canDeleteUsers) == false) +// .disabled(model.access?.contains(.canDeleteUsers) == false) Toggle("Read Accounts", isOn: self.$account.access.bind(.canOpenUsers)) - .disabled(model.access?.contains(.canOpenUsers) == false) +// .disabled(model.access?.contains(.canOpenUsers) == false) Toggle("Modify Accounts", isOn: self.$account.access.bind(.canModifyUsers)) - .disabled(model.access?.contains(.canModifyUsers) == false) +// .disabled(model.access?.contains(.canModifyUsers) == false) Toggle("Get User Info", isOn: self.$account.access.bind(.canGetClientInfo)) - .disabled(model.access?.contains(.canGetClientInfo) == false) +// .disabled(model.access?.contains(.canGetClientInfo) == false) Toggle("Disconnect Users", isOn: self.$account.access.bind(.canDisconnectUsers)) - .disabled(model.access?.contains(.canDisconnectUsers) == false) +// .disabled(model.access?.contains(.canDisconnectUsers) == false) Toggle("Cannot be Disconnected", isOn: self.$account.access.bind(.cantBeDisconnected)) - .disabled(model.access?.contains(.cantBeDisconnected) == false) +// .disabled(model.access?.contains(.cantBeDisconnected) == false) } + .disabled(!self.isEditable) Section("Messaging") { Toggle("Send Messages", isOn: self.$account.access.bind(.canSendMessages)) - .disabled(model.access?.contains(.canSendMessages) == false) +// .disabled(model.access?.contains(.canSendMessages) == false) Toggle("Broadcast", isOn: self.$account.access.bind(.canBroadcast)) - .disabled(model.access?.contains(.canBroadcast) == false) +// .disabled(model.access?.contains(.canBroadcast) == false) } + .disabled(!self.isEditable) Section("News") { Toggle("Read Articles", isOn: self.$account.access.bind(.canReadMessageBoard)) - .disabled(model.access?.contains(.canReadMessageBoard) == false) +// .disabled(model.access?.contains(.canReadMessageBoard) == false) Toggle("Post Articles", isOn: self.$account.access.bind(.canPostMessageBoard)) - .disabled(model.access?.contains(.canPostMessageBoard) == false) +// .disabled(model.access?.contains(.canPostMessageBoard) == false) Toggle("Delete Articles", isOn: self.$account.access.bind(.canDeleteNewsArticles)) - .disabled(model.access?.contains(.canDeleteNewsArticles) == false) +// .disabled(model.access?.contains(.canDeleteNewsArticles) == false) Toggle("Create Categories", isOn: self.$account.access.bind(.canCreateNewsCategories)) - .disabled(model.access?.contains(.canCreateNewsCategories) == false) +// .disabled(model.access?.contains(.canCreateNewsCategories) == false) Toggle("Delete Categories", isOn: self.$account.access.bind(.canDeleteNewsCategories)) - .disabled(model.access?.contains(.canDeleteNewsCategories) == false) +// .disabled(model.access?.contains(.canDeleteNewsCategories) == false) Toggle("Create News Bundles", isOn: self.$account.access.bind(.canCreateNewsFolders)) - .disabled(model.access?.contains(.canCreateNewsFolders) == false) +// .disabled(model.access?.contains(.canCreateNewsFolders) == false) Toggle("Delete News Bundles", isOn: self.$account.access.bind(.canDeleteNewsFolders)) - .disabled(model.access?.contains(.canDeleteNewsFolders) == false) +// .disabled(model.access?.contains(.canDeleteNewsFolders) == false) } + .disabled(!self.isEditable) Section("Chat") { Toggle("Initiate Private Chat", isOn: self.$account.access.bind(.canCreateChat)) - .disabled(model.access?.contains(.canCreateChat) == false) +// .disabled(model.access?.contains(.canCreateChat) == false) Toggle("Read Chat", isOn: self.$account.access.bind(.canReadChat)) - .disabled(model.access?.contains(.canReadChat) == false) +// .disabled(model.access?.contains(.canReadChat) == false) Toggle("Send Chat", isOn: self.$account.access.bind(.canSendChat)) - .disabled(model.access?.contains(.canSendChat) == false) +// .disabled(model.access?.contains(.canSendChat) == false) } + .disabled(!self.isEditable) Section("Miscellaneous") { Toggle("Use Any Name", isOn: self.$account.access.bind(.canUseAnyName)) - .disabled(model.access?.contains(.canUseAnyName) == false) +// .disabled(model.access?.contains(.canUseAnyName) == false) Toggle("Don't Show Agreement", isOn: self.$account.access.bind(.canSkipAgreement)) - .disabled(model.access?.contains(.canSkipAgreement) == false) +// .disabled(model.access?.contains(.canSkipAgreement) == false) } + .disabled(!self.isEditable) } - .disabled(self.model.access?.contains(.canModifyUsers) == false) .formStyle(.grouped) } } diff --git a/Hotline/macOS/BroadcastMessageView.swift b/Hotline/macOS/BroadcastMessageView.swift new file mode 100644 index 0000000..a5f6fd2 --- /dev/null +++ b/Hotline/macOS/BroadcastMessageView.swift @@ -0,0 +1,66 @@ +import SwiftUI + +fileprivate let CHARACTER_LIMIT: Int = 255 + +struct BroadcastMessageView: View { + @Environment(HotlineState.self) private var model: HotlineState + @Environment(\.dismiss) private var dismiss + + @State private var sending: Bool = false + + private var message: String { + self.model.broadcastMessage.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var body: some View { + @Bindable var model = self.model + + VStack { + TextField("Write a message...", text: $model.broadcastMessage, axis: .vertical) + .textFieldStyle(.plain) + .lineLimit(5, reservesSpace: true) + } + .padding(.leading, 32) + .padding(.top, 2) + .overlay(alignment: .topLeading) { + Image("Server Message") + } + .padding(16) + .frame(width: 400) + .toolbar { + if self.sending { + ToolbarItem { + ProgressView() + .controlSize(.small) + } + } + + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + self.dismiss() + } + } + + ToolbarItem(placement: .confirmationAction) { + Button("Broadcast") { + let message = self.message + model.broadcastMessage = "" + + guard !message.isBlank else { + return + } + + Task { + self.sending = true + defer { self.sending = false } + + try await model.sendBroadcast(message) + + self.dismiss() + } + } + .disabled(self.message.isEmpty) + } + } + } +} diff --git a/Hotline/macOS/HotlinePanelView.swift b/Hotline/macOS/HotlinePanelView.swift index 6443366..aa97238 100644 --- a/Hotline/macOS/HotlinePanelView.swift +++ b/Hotline/macOS/HotlinePanelView.swift @@ -125,7 +125,7 @@ struct HotlinePanelView: View { .buttonStyle(.plain) .frame(width: 20, height: 20) .disabled(self.activeServerState == nil) - .help("Manage Server") + .help("Manage Accounts") } Button { diff --git a/Hotline/macOS/News/NewsView.swift b/Hotline/macOS/News/NewsView.swift index 976a985..a07a441 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 not created any newsgroups yet") + Text("This server has no newsgroups") } } diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index 2fddee6..6184749 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -154,6 +154,11 @@ struct ServerView: View { .onChange(of: Prefs.shared.automaticMessage) { Task { try? await self.model.sendUserPreferences() } } + .sheet(isPresented: self.$state.broadcastShown) { + BroadcastMessageView() + .environment(self.model) + .presentationSizing(.fitted) + } .sheet(isPresented: self.$state.accountsShown) { AccountManagerView() .environment(self.model) @@ -334,9 +339,9 @@ struct ServerView: View { Button { self.state.accountsShown = true } label: { - Label("Manage Server", systemImage: "gear") + Label("Manage Accounts", systemImage: "gear") } - .help("Manage Server") + .help("Manage Accounts") } } } -- cgit From 31d56dafdaa19e980e691af2c5b43ce6fb95e941 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Wed, 12 Nov 2025 21:45:38 -0800 Subject: Move file in project. --- Hotline.xcodeproj/project.pbxproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index e741f7f..15453da 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -532,7 +532,6 @@ DAE734F72B2E4126000C56F6 /* macOS */ = { isa = PBXGroup; children = ( - DABAFBEB2EC5996B0015E889 /* BroadcastMessageView.swift */, DA55AC762BE589F700034857 /* AboutView.swift */, DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */, DAE136B92B9D1147007D8307 /* HotlinePanelView.swift */, @@ -540,6 +539,7 @@ DAF5BC6B2EC2727100551E4D /* ConnectView.swift */, DAE735022B30C0BB000C56F6 /* MessageView.swift */, DA5268B42EB6840A00DCB941 /* TransfersView.swift */, + DABAFBEB2EC5996B0015E889 /* BroadcastMessageView.swift */, DA501BE52EBE9520001714F8 /* Trackers */, DA5268A82EB081AF00DCB941 /* Chat */, DA5268A62EB0762300DCB941 /* Board */, -- cgit From 5818df427f045f3aa54f648a0828c8d997327fbe Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Thu, 13 Nov 2025 10:44:42 -0800 Subject: 2 new high res user icons. --- .../Assets.xcassets/Classic/174.imageset/174@2x.png | Bin 0 -> 1096 bytes .../Assets.xcassets/Classic/174.imageset/174@3x.png | Bin 0 -> 1721 bytes .../Assets.xcassets/Classic/174.imageset/Contents.json | 2 ++ Hotline/Assets.xcassets/Classic/2012.imageset/2012.png | Bin 1812 -> 909 bytes .../Assets.xcassets/Classic/2012.imageset/2012@2x.png | Bin 0 -> 2424 bytes .../Assets.xcassets/Classic/2012.imageset/2012@3x.png | Bin 0 -> 4602 bytes .../Classic/2012.imageset/Contents.json | 2 ++ 7 files changed, 4 insertions(+) create mode 100644 Hotline/Assets.xcassets/Classic/174.imageset/174@2x.png create mode 100644 Hotline/Assets.xcassets/Classic/174.imageset/174@3x.png create mode 100644 Hotline/Assets.xcassets/Classic/2012.imageset/2012@2x.png create mode 100644 Hotline/Assets.xcassets/Classic/2012.imageset/2012@3x.png diff --git a/Hotline/Assets.xcassets/Classic/174.imageset/174@2x.png b/Hotline/Assets.xcassets/Classic/174.imageset/174@2x.png new file mode 100644 index 0000000..241ff7b Binary files /dev/null and b/Hotline/Assets.xcassets/Classic/174.imageset/174@2x.png differ diff --git a/Hotline/Assets.xcassets/Classic/174.imageset/174@3x.png b/Hotline/Assets.xcassets/Classic/174.imageset/174@3x.png new file mode 100644 index 0000000..0e55420 Binary files /dev/null and b/Hotline/Assets.xcassets/Classic/174.imageset/174@3x.png differ diff --git a/Hotline/Assets.xcassets/Classic/174.imageset/Contents.json b/Hotline/Assets.xcassets/Classic/174.imageset/Contents.json index bb76edf..bfba03a 100644 --- a/Hotline/Assets.xcassets/Classic/174.imageset/Contents.json +++ b/Hotline/Assets.xcassets/Classic/174.imageset/Contents.json @@ -6,10 +6,12 @@ "scale" : "1x" }, { + "filename" : "174@2x.png", "idiom" : "universal", "scale" : "2x" }, { + "filename" : "174@3x.png", "idiom" : "universal", "scale" : "3x" } diff --git a/Hotline/Assets.xcassets/Classic/2012.imageset/2012.png b/Hotline/Assets.xcassets/Classic/2012.imageset/2012.png index d7b591d..e3d7254 100644 Binary files a/Hotline/Assets.xcassets/Classic/2012.imageset/2012.png and b/Hotline/Assets.xcassets/Classic/2012.imageset/2012.png differ diff --git a/Hotline/Assets.xcassets/Classic/2012.imageset/2012@2x.png b/Hotline/Assets.xcassets/Classic/2012.imageset/2012@2x.png new file mode 100644 index 0000000..0362354 Binary files /dev/null and b/Hotline/Assets.xcassets/Classic/2012.imageset/2012@2x.png differ diff --git a/Hotline/Assets.xcassets/Classic/2012.imageset/2012@3x.png b/Hotline/Assets.xcassets/Classic/2012.imageset/2012@3x.png new file mode 100644 index 0000000..d067e9f Binary files /dev/null and b/Hotline/Assets.xcassets/Classic/2012.imageset/2012@3x.png differ diff --git a/Hotline/Assets.xcassets/Classic/2012.imageset/Contents.json b/Hotline/Assets.xcassets/Classic/2012.imageset/Contents.json index 02eeaf1..6466c2a 100644 --- a/Hotline/Assets.xcassets/Classic/2012.imageset/Contents.json +++ b/Hotline/Assets.xcassets/Classic/2012.imageset/Contents.json @@ -6,10 +6,12 @@ "scale" : "1x" }, { + "filename" : "2012@2x.png", "idiom" : "universal", "scale" : "2x" }, { + "filename" : "2012@3x.png", "idiom" : "universal", "scale" : "3x" } -- cgit From 467b53f143a0c3d7328fe85e9d1215eceb9b150a Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Thu, 13 Nov 2025 11:13:01 -0800 Subject: Start surfacing server errors properly to communicate permissions better. --- Hotline/Hotline/HotlineClient.swift | 10 +++------- Hotline/State/HotlineState.swift | 32 +++++++++++++++++++++++--------- Hotline/macOS/Files/FilesView.swift | 16 ++++++++++------ Hotline/macOS/Files/FolderItemView.swift | 4 ++-- Hotline/macOS/ServerView.swift | 7 ++++++- 5 files changed, 44 insertions(+), 25 deletions(-) diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index 8d0e870..7305a2f 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -429,6 +429,7 @@ public actor HotlineClient { } catch is TaskTimeoutError { throw HotlineClientError.timeout } catch let error as HotlineClientError { + print("Hotline Client Error: \(error)") throw error } catch { throw error @@ -651,17 +652,12 @@ public actor HotlineClient { /// - name: File or folder name /// - path: Directory path containing the item /// - Returns: True if deletion succeeded - public func deleteFile(name: String, path: [String]) async throws -> Bool { + public func deleteFile(name: String, path: [String]) async throws { var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .deleteFile) transaction.setFieldString(type: .fileName, val: name) transaction.setFieldPath(type: .filePath, val: path) - do { - try await self.sendTransaction(transaction) - return true - } catch { - return false - } + try await self.sendTransaction(transaction) } /// Create a folder diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index 627b6a0..1f8e2e5 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -799,7 +799,7 @@ class HotlineState: Equatable { self.users = hotlineUsers.map { User(hotlineUser: $0) } } - // MARK: - Files (Basic) + // MARK: - Files @MainActor @discardableResult @@ -856,7 +856,7 @@ class HotlineState: Equatable { } @MainActor - func deleteFile(_ fileName: String, path: [String]) async throws -> Bool { + func deleteFile(_ fileName: String, path: [String]) async throws { guard let client = self.client else { throw HotlineClientError.notConnected } @@ -865,14 +865,16 @@ class HotlineState: Equatable { if path.count > 1 { fullPath = Array(path[0.. 1 { parentPath = Array(file.path[0.. Date: Thu, 13 Nov 2025 11:33:43 -0800 Subject: Don't show accounts in sidebar to reduce clutter. Admins can use menu bar or toolbar. More error handling. --- Hotline/Hotline/HotlineClient.swift | 10 ++-------- Hotline/State/HotlineState.swift | 33 ++++++++++++++++++++++++++++----- Hotline/macOS/Files/FilesView.swift | 2 +- Hotline/macOS/ServerView.swift | 29 ++++++++++++++++------------- 4 files changed, 47 insertions(+), 27 deletions(-) diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index 7305a2f..6dbb460 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -666,17 +666,11 @@ public actor HotlineClient { /// - name: New folder name /// - path: Directory path for the new folder /// - Returns: True if creation succeeded - public func newFolder(name: String, path: [String]) async throws -> Bool { + public func newFolder(name: String, path: [String]) async throws { var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .newFolder) transaction.setFieldString(type: .fileName, val: name) transaction.setFieldPath(type: .filePath, val: path) - - do { - try await self.sendTransaction(transaction) - return true - } catch { - return false - } + try await self.sendTransaction(transaction) } // MARK: - News diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index 1f8e2e5..c9810ad 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -852,11 +852,22 @@ class HotlineState: Equatable { throw HotlineClientError.notConnected } - return try await client.newFolder(name: name, path: parentPath) + do { + try await client.newFolder(name: name, path: parentPath) + self.invalidateFileListCache(for: parentPath, includingAncestors: true) + return true + } + catch let error as HotlineClientError { + self.errorMessage = error.userMessage + self.errorDisplayed = true + } + + return false } + @discardableResult @MainActor - func deleteFile(_ fileName: String, path: [String]) async throws { + func deleteFile(_ fileName: String, path: [String]) async throws -> Bool { guard let client = self.client else { throw HotlineClientError.notConnected } @@ -869,12 +880,14 @@ class HotlineState: Equatable { do { try await client.deleteFile(name: fileName, path: fullPath) self.invalidateFileListCache(for: fullPath, includingAncestors: true) + return true } catch let error as HotlineClientError { self.errorMessage = error.userMessage self.errorDisplayed = true - return } + + return false } /// Download a file from the server. @@ -896,9 +909,19 @@ class HotlineState: Equatable { Task { @MainActor [weak self] in guard let self else { return } - + // Request download from server - guard let result = try? await client.downloadFile(name: fileName, path: fullPath), + let result: (referenceNumber: UInt32, transferSize: Int, fileSize: Int, waitingCount: Int)? + do { + result = try await client.downloadFile(name: fileName, path: fullPath) + } + catch let error as HotlineClientError { + self.errorMessage = error.userMessage + self.errorDisplayed = true + return + } + + guard let result, let server = self.server, let address = server.address as String?, let port = server.port as Int? diff --git a/Hotline/macOS/Files/FilesView.swift b/Hotline/macOS/Files/FilesView.swift index ab93cbc..325af8c 100644 --- a/Hotline/macOS/Files/FilesView.swift +++ b/Hotline/macOS/Files/FilesView.swift @@ -432,7 +432,7 @@ struct FilesView: View { } let path: [String] = parentFolder?.path ?? [] - if try await self.model.newFolder(name: name, parentPath: path) == true { + if try await self.model.newFolder(name: name, parentPath: path) { try await self.model.getFileList(path: path) } } diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index 1f85bd7..b8297fd 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -338,18 +338,19 @@ struct ServerView: View { self.navigationList .frame(maxWidth: .infinity) .navigationSplitViewColumnWidth(min: 200, ideal: 250, max: 500) - .toolbar { - if self.model.access?.contains(.canOpenUsers) == true { - ToolbarItem { - Button { - self.state.accountsShown = true - } label: { - Label("Manage Accounts", systemImage: "gear") - } - .help("Manage Accounts") - } - } - } + .toolbar(removing: .sidebarToggle) +// .toolbar { +// if self.model.access?.contains(.canOpenUsers) == true { +// ToolbarItem(placement: .primaryAction) { +// Button { +// self.state.accountsShown = true +// } label: { +// Label("Manage Accounts", systemImage: "gear") +// } +// .help("Manage Accounts") +// } +// } +// } } detail: { switch state.selection { case .chat: @@ -388,7 +389,9 @@ struct ServerView: View { } } } - .toolbar(removing: .sidebarToggle) + .background { + Color.red + } } // MARK: - -- cgit From 8d4cdce6ec00db6b329a3b9ae71cfc37cd642e9b Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Thu, 13 Nov 2025 20:15:46 -0800 Subject: You can now save file details (rename files/folders and comments) --- Hotline/Hotline/HotlineClient.swift | 23 ++++++++ Hotline/State/HotlineState.swift | 30 ++++++---- Hotline/macOS/Files/FileDetailsSheet.swift | 90 ++++++++++++++++++------------ Hotline/macOS/Files/FilesView.swift | 2 +- Hotline/macOS/ServerView.swift | 16 +++--- 5 files changed, 107 insertions(+), 54 deletions(-) diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index 6dbb460..98cf222 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -645,6 +645,29 @@ public actor HotlineClient { modified: fileModifyDate ) } + + /// Set a file's information (name/comment) + /// + /// - Parameters: + /// - name: File name + /// - path: Directory path containing the file + /// - newName: Name to set the file to + /// - comment: Comment to set on the file + public func setFileInfo(name: String, path: [String], newName: String? = nil, comment: String? = nil, encoding: String.Encoding = .utf8) async throws { + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .setFileInfo) + transaction.setFieldString(type: .fileName, val: name) + transaction.setFieldPath(type: .filePath, val: path) + + if let newName { + transaction.setFieldString(type: .fileNewName, val: newName) + } + + if let comment { + transaction.setFieldString(type: .fileComment, val: comment) + } + + try await sendTransaction(transaction) + } /// Delete a file or folder /// diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index c9810ad..c632f91 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -789,7 +789,6 @@ class HotlineState: Equatable { // MARK: - Users - @MainActor func getUserList() async throws { guard let client = self.client else { throw HotlineClientError.notConnected @@ -801,7 +800,6 @@ class HotlineState: Equatable { // MARK: - Files - @MainActor @discardableResult func getFileList(path: [String] = [], suppressErrors: Bool = false, preferCache: Bool = false) async throws -> [FileInfo] { guard let client = self.client else { @@ -832,7 +830,6 @@ class HotlineState: Equatable { return newFiles } - @MainActor func getFileDetails(_ fileName: String, path: [String]) async throws -> FileDetails? { guard let client = self.client else { throw HotlineClientError.notConnected @@ -846,7 +843,26 @@ class HotlineState: Equatable { return try await client.getFileInfo(name: fileName, path: fullPath) } - @MainActor + @discardableResult + func setFileInfo(fileName: String, path filePath: [String], fileNewName: String?, comment: String?) async throws -> Bool { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + do { + try await client.setFileInfo(name: fileName, path: filePath, newName: fileNewName, comment: comment) + self.invalidateFileListCache(for: filePath, includingAncestors: true) + return true + } + catch let error as HotlineClientError { + self.errorMessage = error.userMessage + self.errorDisplayed = true + } + + return false + } + + @discardableResult func newFolder(name: String, parentPath: [String]) async throws -> Bool { guard let client = self.client else { throw HotlineClientError.notConnected @@ -1404,12 +1420,6 @@ class HotlineState: Equatable { } } - func setFileInfo(fileName: String, path filePath: [String], fileNewName: String?, comment: String?, encoding: String.Encoding = .utf8) { - // TODO: Implement setFileInfo in HotlineClient - // This method updates file metadata (name and/or comment) - print("setFileInfo not yet implemented in HotlineState/HotlineClient") - } - @MainActor func previewFile(_ fileName: String, path: [String], complete callback: ((PreviewFileInfo?) -> Void)? = nil) { guard let client = self.client else { diff --git a/Hotline/macOS/Files/FileDetailsSheet.swift b/Hotline/macOS/Files/FileDetailsSheet.swift index 2118518..e86bf28 100644 --- a/Hotline/macOS/Files/FileDetailsSheet.swift +++ b/Hotline/macOS/Files/FileDetailsSheet.swift @@ -3,34 +3,35 @@ import SwiftUI struct FileDetailsSheet: View { @Environment(HotlineState.self) private var model: HotlineState - @Environment(\.presentationMode) var presentationMode + @Environment(\.dismiss) private var dismiss - var fd: FileDetails + var details: FileDetails + @State private var saving: Bool = false @State private var comment: String = "" @State private var filename: String = "" var body: some View { VStack(alignment: .leading, spacing: 16) { HStack(alignment: .center, spacing: 16){ - if self.fd.type == "Folder" { + if self.details.type == "Folder" { FolderIconView() .frame(width: 32, height: 32) } else { - FileIconView(filename: fd.name, fileType: nil) + FileIconView(filename: self.details.name, fileType: nil) .frame(width: 32, height: 32) } - TextField("", text: $filename) - .disabled(!self.canRename()) + TextField("File Name", text: $filename) + .disabled(!self.canRename) } let rows: [(String, String)] = [ - ("Type", fd.type), - ("Creator", fd.creator), - ("Size", self.formattedSize(byteCount: fd.size)), - ("Created", Self.dateFormatter.string(from: fd.created)), - ("Modified", Self.dateFormatter.string(from: fd.modified)) + ("Type", self.details.type), + ("Creator", self.details.creator), + ("Size", self.formattedSize(byteCount: self.details.size)), + ("Created", Self.dateFormatter.string(from: self.details.created)), + ("Modified", Self.dateFormatter.string(from: self.details.modified)) ] Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { @@ -54,46 +55,65 @@ struct FileDetailsSheet: View { .font(.body) .lineLimit(10, reservesSpace: true) .padding(.leading, 32 + 16) - .disabled(!self.canSetComment()) + .disabled(!self.canSetComment) } .padding(.vertical, 24) .padding(.horizontal, 24) .frame(width: 400) .toolbar { + if self.saving { + ToolbarItem { + ProgressView() + .controlSize(.small) + } + } + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { - presentationMode.wrappedValue.dismiss() + self.dismiss() } } ToolbarItem(placement: .primaryAction) { Button{ var editedFilename: String? - if filename != fd.name { - editedFilename = filename + if self.filename != self.details.name { + editedFilename = self.filename } var editedComment: String? - if comment != fd.comment { - editedComment = comment + if self.comment != self.details.comment { + editedComment = self.comment } - model.setFileInfo(fileName: fd.name, path: fd.path, fileNewName: editedFilename, comment: editedComment) - presentationMode.wrappedValue.dismiss() - - // TODO: Update the file list if the filename was changed + Task { + self.saving = true + defer { self.saving = false } + + if editedComment != nil || editedFilename != nil { + if try await self.model.setFileInfo(fileName: self.details.name, path: self.details.path, fileNewName: editedFilename, comment: editedComment) { + try await self.model.getFileList(path: self.details.path) + } + } + + // We dismiss even if there is an error for now + // This is not ideal as we may lose a user's written comment + // or new file name, but SwiftUI doesn't show the current + // alert above this sheet so we'll need a different way of + // handling errors to make this work. Until then... + self.dismiss() + } } label: { Text("Save") - }.disabled(!isEdited()) + } } } .onAppear { - self.filename = fd.name - self.comment = fd.comment + self.filename = self.details.name + self.comment = self.details.comment } } - static var dateFormatter: DateFormatter = { var dateFormatter = DateFormatter() dateFormatter.dateStyle = .long @@ -124,24 +144,24 @@ struct FileDetailsSheet: View { } private func isEdited() -> Bool { - return self.filename != fd.name || self.comment != fd.comment + return self.filename != self.details.name || self.comment != self.details.comment } - private func canRename() -> Bool { - if self.fd.type == "fldr" { - return model.access?.contains(.canRenameFolders) == true + private var canRename: Bool { + if self.details.type == "fldr" || self.details.type == "Folder" { + return self.model.access?.contains(.canRenameFolders) == true } - return model.access?.contains(.canRenameFiles) == true + return self.model.access?.contains(.canRenameFiles) == true } - private func canSetComment() -> Bool { - if self.fd.type == "fldr" { - return model.access?.contains(.canSetFolderComment) == true + private var canSetComment: Bool { + if self.details.type == "fldr" || self.details.type == "Folder" { + return self.model.access?.contains(.canSetFolderComment) == true } - return model.access?.contains(.canSetFileComment) == true + return self.model.access?.contains(.canSetFileComment) == true } } //#Preview { -// FileDetailsView(fd: FileDetails(name: "AppleWorks 6.sit", path: [""], size: 24601664, comment: "test comment", type: "SITD", creator: "SIT!", created: Date.now, modified: Date.now )) +// FileDetailsView(details: FileDetails(name: "AppleWorks 6.sit", path: [""], size: 24601664, comment: "test comment", type: "SITD", creator: "SIT!", created: Date.now, modified: Date.now )) //} diff --git a/Hotline/macOS/Files/FilesView.swift b/Hotline/macOS/Files/FilesView.swift index 325af8c..1ae99d9 100644 --- a/Hotline/macOS/Files/FilesView.swift +++ b/Hotline/macOS/Files/FilesView.swift @@ -235,7 +235,7 @@ struct FilesView: View { Text("You cannot undo this action.") }) .sheet(item: self.$fileDetails) { item in - FileDetailsSheet(fd: item) + FileDetailsSheet(details: item) } .fileImporter(isPresented: $uploadFileSelectorDisplayed, allowedContentTypes: [.data, .folder], allowsMultipleSelection: false, onCompletion: { results in switch results { diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index b8297fd..3794018 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -196,14 +196,6 @@ struct ServerView: View { .onChange(of: self.model.serverTitle) { self.state.serverName = self.model.serverTitle } - .alert("Something Went Wrong", isPresented: self.$model.errorDisplayed) { - Button("OK") {} - } message: { - if let message = self.model.errorMessage, - !message.isBlank { - Text(message) - } - } .onAppear { var address = self.server.address if self.server.port != HotlinePorts.DefaultServerPort { @@ -220,6 +212,14 @@ struct ServerView: View { self.connectionDisplayed = true } + .alert("Something Went Wrong", isPresented: self.$model.errorDisplayed) { + Button("OK") {} + } message: { + if let message = self.model.errorMessage, + !message.isBlank { + Text(message) + } + } .focusedSceneValue(\.activeHotlineModel, model) .focusedSceneValue(\.activeServerState, state) } -- cgit From 7ca2ece66a5d40964f5d640255d1b137433511a9 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Thu, 13 Nov 2025 20:53:39 -0800 Subject: UI to view user client info. --- Hotline/Hotline/HotlineClient.swift | 18 +++ Hotline/State/HotlineState.swift | 16 +++ Hotline/macOS/MessageView.swift | 224 +++++++++++++++++++++++------------- 3 files changed, 177 insertions(+), 81 deletions(-) diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index 98cf222..1394057 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -547,6 +547,24 @@ public actor HotlineClient { try await socket.send(transaction, endian: .big) } + + /// Get information text about a user + /// + /// - Parameters: + /// - userID: Target user ID + public func getClientInfoText(for userID: UInt16) async throws -> (username: String, info: String)? { + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getClientInfoText) + transaction.setFieldUInt16(type: .userID, val: userID) + + let reply = try await self.sendTransaction(transaction) + + if let username = reply.getField(type: .userName)?.getString(), + let info = reply.getField(type: .data)?.getString() { + return (username: username, info: info) + } + + return nil + } /// Update this client's user info (name, icon, options) /// diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index c632f91..318a468 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -797,6 +797,22 @@ class HotlineState: Equatable { let hotlineUsers = try await client.getUserList() self.users = hotlineUsers.map { User(hotlineUser: $0) } } + + func getClientInfoText(id userID: UInt16) async throws -> (username: String, info: String)? { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + do { + return try await client.getClientInfoText(for: userID) + } + catch let error as HotlineClientError { + self.errorMessage = error.userMessage + self.errorDisplayed = true + } + + return nil + } // MARK: - Files diff --git a/Hotline/macOS/MessageView.swift b/Hotline/macOS/MessageView.swift index b0c8baa..861a14a 100644 --- a/Hotline/macOS/MessageView.swift +++ b/Hotline/macOS/MessageView.swift @@ -1,5 +1,37 @@ import SwiftUI +struct UserInfo: Identifiable { + let username: String + let data: String + + var id: String { + self.username + } +} + +struct UserInfoView: View { + @Environment(\.dismiss) private var dismiss + + let info: UserInfo + + var body: some View { + ScrollView(.vertical) { + Text(self.info.data) + .fontDesign(.monospaced) + .textSelection(.enabled) + .padding() + } + .frame(width: 300, height: 400) + .toolbar { + if #available(macOS 26.0, *) { + Button(role: .close) { + self.dismiss() + } + } + } + } +} + struct MessageView: View { @Environment(HotlineState.self) private var model: HotlineState @Environment(\.colorScheme) private var colorScheme @@ -7,104 +39,134 @@ struct MessageView: View { @State private var input: String = "" @State private var scrollPos: Int? @State private var contentHeight: CGFloat = 0 + @State private var userInfo: UserInfo? + @Namespace private var bottomID @FocusState private var focusedField: FocusedField? var userID: UInt16 var body: some View { - ScrollViewReader { reader in - VStack(alignment: .leading, spacing: 0) { - - // MARK: Scroll View - GeometryReader { gm in - ScrollView(.vertical) { - LazyVStack(alignment: .leading) { - ForEach(model.instantMessages[userID] ?? [InstantMessage]()) { msg in - HStack(alignment: .firstTextBaseline) { - if msg.direction == .outgoing { - Spacer() - } - - Text(LocalizedStringKey(msg.text)) - .lineSpacing(4) - .multilineTextAlignment(.leading) - .textSelection(.enabled) - .tint(msg.direction == .outgoing ? Color("Outgoing Message Link") : Color("Link Color")) - .foregroundStyle(msg.direction == .outgoing ? Color("Outgoing Message Text") : Color("Incoming Message Text")) - .padding(EdgeInsets(top: 10, leading: 14, bottom: 10, trailing: 14)) - .background(msg.direction == .outgoing ? Color("Outgoing Message Background") : Color("Incoming Message Background")) - .clipShape(RoundedRectangle(cornerRadius: 16)) - - if msg.direction == .incoming { - Spacer() - } - } - .padding(EdgeInsets(top: 2, leading: 0, bottom: 2, trailing: 0)) - } + VStack(alignment: .leading, spacing: 0) { + + // MARK: Scroll View + self.messageList + + // MARK: Input Divider + Divider() + + // MARK: Input Bar + self.inputBar + } + .background(Color(nsColor: .textBackgroundColor)) + .toolbar { + ToolbarItem { + Button { + self.getUserInfo() + } label: { + Image(systemName: "info.circle") + } + } + } + .sheet(item: self.$userInfo) { info in + UserInfoView(info: info) + } + } + + private func getUserInfo() { + Task { + if let (username, info) = try await self.model.getClientInfoText(id: self.userID) { + self.userInfo = UserInfo(username: username, data: info) + } + } + } + + private var inputBar: some View { + HStack(alignment: .lastTextBaseline, spacing: 0) { + let user = self.model.users.first(where: { $0.id == userID }) + TextField("Message \(user?.name ?? "")", text: $input, axis: .vertical) + .focused($focusedField, equals: .chatInput) + .textFieldStyle(.plain) + .lineLimit(1...5) + .multilineTextAlignment(.leading) + .onSubmit { + if !self.input.isEmpty { + let message = self.input + let uid = self.userID + Task { + try? await self.model.sendInstantMessage(message, userID: uid) } - .padding() - - VStack(spacing: 0) {}.id(bottomID) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .defaultScrollAnchor(.bottom) - .onChange(of: model.instantMessages[userID]?.count) { - reader.scrollTo(bottomID, anchor: .bottom) - model.markInstantMessagesAsRead(userID: userID) - } - .onAppear { - reader.scrollTo(bottomID, anchor: .bottom) - model.markInstantMessagesAsRead(userID: userID) - } - .onChange(of: gm.size) { - reader.scrollTo(bottomID, anchor: .bottom) } + self.input = "" } - - // MARK: Input Divider - Divider() - - // MARK: Input Bar - HStack(alignment: .lastTextBaseline, spacing: 0) { - let user = model.users.first(where: { $0.id == userID }) - TextField("Message \(user?.name ?? "")", text: $input, axis: .vertical) - .focused($focusedField, equals: .chatInput) - .textFieldStyle(.plain) - .lineLimit(1...5) - .multilineTextAlignment(.leading) - .onSubmit { - if !self.input.isEmpty { - let message = self.input - let uid = self.userID - Task { - try? await model.sendInstantMessage(message, userID: uid) + .frame(maxWidth: .infinity) + .padding() + } + .frame(maxWidth: .infinity, minHeight: 28) + .padding(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) + .overlay(alignment: .leadingFirstTextBaseline) { + Image(systemName: "chevron.right").opacity(0.4).offset(x: 16) + } + .onContinuousHover { phase in + switch phase { + case .active(_): + NSCursor.iBeam.set() + case .ended: + NSCursor.arrow.set() + break + } + } + .onTapGesture { + focusedField = .chatInput + } + } + + private var messageList: some View { + ScrollViewReader { reader in + GeometryReader { gm in + ScrollView(.vertical) { + LazyVStack(alignment: .leading) { + ForEach(self.model.instantMessages[self.userID] ?? [InstantMessage]()) { msg in + HStack(alignment: .firstTextBaseline) { + if msg.direction == .outgoing { + Spacer() + } + + Text(LocalizedStringKey(msg.text)) + .lineSpacing(4) + .multilineTextAlignment(.leading) + .textSelection(.enabled) + .tint(msg.direction == .outgoing ? Color("Outgoing Message Link") : Color("Link Color")) + .foregroundStyle(msg.direction == .outgoing ? Color("Outgoing Message Text") : Color("Incoming Message Text")) + .padding(EdgeInsets(top: 10, leading: 14, bottom: 10, trailing: 14)) + .background(msg.direction == .outgoing ? Color("Outgoing Message Background") : Color("Incoming Message Background")) + .clipShape(RoundedRectangle(cornerRadius: 16)) + + if msg.direction == .incoming { + Spacer() } } - self.input = "" + .padding(EdgeInsets(top: 2, leading: 0, bottom: 2, trailing: 0)) } - .frame(maxWidth: .infinity) - .padding() + } + .padding() + + VStack(spacing: 0) {}.id(bottomID) } - .frame(maxWidth: .infinity, minHeight: 28) - .padding(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) - .overlay(alignment: .leadingFirstTextBaseline) { - Image(systemName: "chevron.right").opacity(0.4).offset(x: 16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .defaultScrollAnchor(.bottom) + .onChange(of: self.model.instantMessages[self.userID]?.count) { + reader.scrollTo(self.bottomID, anchor: .bottom) + self.model.markInstantMessagesAsRead(userID: self.userID) } - .onContinuousHover { phase in - switch phase { - case .active(_): - NSCursor.iBeam.set() - case .ended: - NSCursor.arrow.set() - break - } + .onAppear { + reader.scrollTo(self.bottomID, anchor: .bottom) + self.model.markInstantMessagesAsRead(userID: self.userID) } - .onTapGesture { - focusedField = .chatInput + .onChange(of: gm.size) { + reader.scrollTo(self.bottomID, anchor: .bottom) } } - .background(Color(nsColor: .textBackgroundColor)) } } } -- cgit From 46384d99b78cca150aa720eb915d161b5be8c08d Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 14 Nov 2025 08:38:32 -0800 Subject: Cleanup user client info sheet. Hide button if you don't have persmission to do so. --- Hotline.xcodeproj/project.pbxproj | 4 +++ Hotline/Hotline/HotlineClient.swift | 4 +-- Hotline/Hotline/HotlineProtocol.swift | 9 +++++++ Hotline/State/HotlineState.swift | 2 +- Hotline/macOS/MessageView.swift | 50 +++++++---------------------------- Hotline/macOS/UserInfo.swift | 25 ++++++++++++++++++ 6 files changed, 51 insertions(+), 43 deletions(-) create mode 100644 Hotline/macOS/UserInfo.swift diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index 15453da..2b3c8e9 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -99,6 +99,7 @@ DABE8C002B55E69800884D28 /* server-message.aiff in Resources */ = {isa = PBXBuildFile; fileRef = DABE8BFF2B55E69800884D28 /* server-message.aiff */; }; DABE8C022B55E69D00884D28 /* new-news.aiff in Resources */ = {isa = PBXBuildFile; fileRef = DABE8C012B55E69D00884D28 /* new-news.aiff */; }; DABE8C042B57940B00884D28 /* DAKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABE8C032B57940A00884D28 /* DAKeychain.swift */; }; + DAC0E4232EC78E1100E999DB /* UserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC0E4222EC78E1100E999DB /* UserInfo.swift */; }; DAC3D9832BC33FD000A727C9 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC3D9822BC33FD000A727C9 /* AppState.swift */; }; DAC6B2E02EAC6236004E2CBA /* ChatStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC6B2DE2EAC6236004E2CBA /* ChatStore.swift */; }; DAC6B2E22EAEE9FE004E2CBA /* NetSocket.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC6B2E12EAEE9EF004E2CBA /* NetSocket.swift */; }; @@ -214,6 +215,7 @@ DABE8BFF2B55E69800884D28 /* server-message.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = "server-message.aiff"; sourceTree = ""; }; DABE8C012B55E69D00884D28 /* new-news.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = "new-news.aiff"; sourceTree = ""; }; DABE8C032B57940A00884D28 /* DAKeychain.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DAKeychain.swift; sourceTree = ""; }; + DAC0E4222EC78E1100E999DB /* UserInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserInfo.swift; sourceTree = ""; }; DAC3D9822BC33FD000A727C9 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; DAC6B2DE2EAC6236004E2CBA /* ChatStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatStore.swift; sourceTree = ""; }; DAC6B2E12EAEE9EF004E2CBA /* NetSocket.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetSocket.swift; sourceTree = ""; }; @@ -532,6 +534,7 @@ DAE734F72B2E4126000C56F6 /* macOS */ = { isa = PBXGroup; children = ( + DAC0E4222EC78E1100E999DB /* UserInfo.swift */, DA55AC762BE589F700034857 /* AboutView.swift */, DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */, DAE136B92B9D1147007D8307 /* HotlinePanelView.swift */, @@ -738,6 +741,7 @@ DA5268B32EB6806E00DCB941 /* HotlineFileDownloadClient.swift in Sources */, DACCE5E32EABE86A008CDD92 /* AppUpdateView.swift in Sources */, DAC6B2E02EAC6236004E2CBA /* ChatStore.swift in Sources */, + DAC0E4232EC78E1100E999DB /* UserInfo.swift in Sources */, DA32CD492B2931640053B98B /* User.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index 1394057..1440b71 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -552,7 +552,7 @@ public actor HotlineClient { /// /// - Parameters: /// - userID: Target user ID - public func getClientInfoText(for userID: UInt16) async throws -> (username: String, info: String)? { + public func getClientInfoText(for userID: UInt16) async throws -> HotlineUserClientInfo? { var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getClientInfoText) transaction.setFieldUInt16(type: .userID, val: userID) @@ -560,7 +560,7 @@ public actor HotlineClient { if let username = reply.getField(type: .userName)?.getString(), let info = reply.getField(type: .data)?.getString() { - return (username: username, info: info) + return HotlineUserClientInfo(username: username, details: info) } return nil diff --git a/Hotline/Hotline/HotlineProtocol.swift b/Hotline/Hotline/HotlineProtocol.swift index 4857d87..92fdfd2 100644 --- a/Hotline/Hotline/HotlineProtocol.swift +++ b/Hotline/Hotline/HotlineProtocol.swift @@ -516,6 +516,15 @@ public class HotlineFile: Identifiable, Hashable { } } +public struct HotlineUserClientInfo: Identifiable { + public var username: String + public var details: String + + public var id: String { + self.username + } +} + public struct HotlineUser: Identifiable, Hashable, Sendable { public let id: UInt16 public let iconID: UInt16 diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index 318a468..e78a780 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -798,7 +798,7 @@ class HotlineState: Equatable { self.users = hotlineUsers.map { User(hotlineUser: $0) } } - func getClientInfoText(id userID: UInt16) async throws -> (username: String, info: String)? { + func getClientInfoText(id userID: UInt16) async throws -> HotlineUserClientInfo? { guard let client = self.client else { throw HotlineClientError.notConnected } diff --git a/Hotline/macOS/MessageView.swift b/Hotline/macOS/MessageView.swift index 861a14a..6690a88 100644 --- a/Hotline/macOS/MessageView.swift +++ b/Hotline/macOS/MessageView.swift @@ -1,37 +1,5 @@ import SwiftUI -struct UserInfo: Identifiable { - let username: String - let data: String - - var id: String { - self.username - } -} - -struct UserInfoView: View { - @Environment(\.dismiss) private var dismiss - - let info: UserInfo - - var body: some View { - ScrollView(.vertical) { - Text(self.info.data) - .fontDesign(.monospaced) - .textSelection(.enabled) - .padding() - } - .frame(width: 300, height: 400) - .toolbar { - if #available(macOS 26.0, *) { - Button(role: .close) { - self.dismiss() - } - } - } - } -} - struct MessageView: View { @Environment(HotlineState.self) private var model: HotlineState @Environment(\.colorScheme) private var colorScheme @@ -39,7 +7,7 @@ struct MessageView: View { @State private var input: String = "" @State private var scrollPos: Int? @State private var contentHeight: CGFloat = 0 - @State private var userInfo: UserInfo? + @State private var userInfo: HotlineUserClientInfo? @Namespace private var bottomID @FocusState private var focusedField: FocusedField? @@ -60,11 +28,13 @@ struct MessageView: View { } .background(Color(nsColor: .textBackgroundColor)) .toolbar { - ToolbarItem { - Button { - self.getUserInfo() - } label: { - Image(systemName: "info.circle") + if self.model.access?.contains(.canGetClientInfo) == true { + ToolbarItem { + Button { + self.getUserInfo() + } label: { + Image(systemName: "info.circle") + } } } } @@ -75,8 +45,8 @@ struct MessageView: View { private func getUserInfo() { Task { - if let (username, info) = try await self.model.getClientInfoText(id: self.userID) { - self.userInfo = UserInfo(username: username, data: info) + if let info = try await self.model.getClientInfoText(id: self.userID) { + self.userInfo = info } } } diff --git a/Hotline/macOS/UserInfo.swift b/Hotline/macOS/UserInfo.swift new file mode 100644 index 0000000..328fc02 --- /dev/null +++ b/Hotline/macOS/UserInfo.swift @@ -0,0 +1,25 @@ +import SwiftUI + +struct UserInfoView: View { + @Environment(\.dismiss) private var dismiss + + let info: HotlineUserClientInfo + + var body: some View { + ScrollView(.vertical) { + Text(self.info.details) + .fontDesign(.monospaced) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding() + } + .frame(width: 350, height: 400) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("OK") { + self.dismiss() + } + } + } + } +} -- cgit From da1d7001f5132115bfdbe19cd95e73b04ba76c95 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 14 Nov 2025 09:56:46 -0800 Subject: Add kick and improve client info display. Also improve error messages when can't connect (server down) or can't login (show server message). --- Hotline.xcodeproj/project.pbxproj | 16 ++++---- Hotline/Hotline/HotlineClient.swift | 27 +++++++++++- Hotline/Hotline/HotlineProtocol.swift | 5 +++ Hotline/MacApp.swift | 6 +-- Hotline/State/HotlineState.swift | 49 +++++++++++++++++++--- Hotline/macOS/BroadcastMessageSheet.swift | 66 ++++++++++++++++++++++++++++++ Hotline/macOS/BroadcastMessageView.swift | 66 ------------------------------ Hotline/macOS/Files/FileDetailsSheet.swift | 43 ++++++++++--------- Hotline/macOS/Files/FilesView.swift | 38 +++++++++-------- Hotline/macOS/MessageView.swift | 36 ++++++++++++++-- Hotline/macOS/ServerView.swift | 23 +++++------ Hotline/macOS/UserClientInfoSheet.swift | 25 +++++++++++ Hotline/macOS/UserInfo.swift | 25 ----------- 13 files changed, 264 insertions(+), 161 deletions(-) create mode 100644 Hotline/macOS/BroadcastMessageSheet.swift delete mode 100644 Hotline/macOS/BroadcastMessageView.swift create mode 100644 Hotline/macOS/UserClientInfoSheet.swift delete mode 100644 Hotline/macOS/UserInfo.swift diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index 2b3c8e9..6ff1f66 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -89,7 +89,7 @@ DAB4D8842B4CABEF0048A05C /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAB4D8832B4CABEF0048A05C /* Extensions.swift */; }; DAB4D8872B4CB3610048A05C /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = DAB4D8862B4CB3610048A05C /* MarkdownUI */; }; DABAFBEA2EC58C170015E889 /* AccountDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABAFBE92EC58C170015E889 /* AccountDetailsView.swift */; }; - DABAFBEC2EC599700015E889 /* BroadcastMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABAFBEB2EC5996B0015E889 /* BroadcastMessageView.swift */; }; + DABAFBEC2EC599700015E889 /* BroadcastMessageSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABAFBEB2EC5996B0015E889 /* BroadcastMessageSheet.swift */; }; DABE8BF42B55DC0A00884D28 /* transfer-complete.aiff in Resources */ = {isa = PBXBuildFile; fileRef = DABE8BF32B55DC0A00884D28 /* transfer-complete.aiff */; }; DABE8BF62B55DC2E00884D28 /* chat-message.aiff in Resources */ = {isa = PBXBuildFile; fileRef = DABE8BF52B55DC2E00884D28 /* chat-message.aiff */; }; DABE8BF82B55DC6100884D28 /* logged-in.aiff in Resources */ = {isa = PBXBuildFile; fileRef = DABE8BF72B55DC6100884D28 /* logged-in.aiff */; }; @@ -99,7 +99,7 @@ DABE8C002B55E69800884D28 /* server-message.aiff in Resources */ = {isa = PBXBuildFile; fileRef = DABE8BFF2B55E69800884D28 /* server-message.aiff */; }; DABE8C022B55E69D00884D28 /* new-news.aiff in Resources */ = {isa = PBXBuildFile; fileRef = DABE8C012B55E69D00884D28 /* new-news.aiff */; }; DABE8C042B57940B00884D28 /* DAKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABE8C032B57940A00884D28 /* DAKeychain.swift */; }; - DAC0E4232EC78E1100E999DB /* UserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC0E4222EC78E1100E999DB /* UserInfo.swift */; }; + DAC0E4232EC78E1100E999DB /* UserClientInfoSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC0E4222EC78E1100E999DB /* UserClientInfoSheet.swift */; }; DAC3D9832BC33FD000A727C9 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC3D9822BC33FD000A727C9 /* AppState.swift */; }; DAC6B2E02EAC6236004E2CBA /* ChatStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC6B2DE2EAC6236004E2CBA /* ChatStore.swift */; }; DAC6B2E22EAEE9FE004E2CBA /* NetSocket.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC6B2E12EAEE9EF004E2CBA /* NetSocket.swift */; }; @@ -205,7 +205,7 @@ DAB4D8812B4C8FED0048A05C /* FileIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileIconView.swift; sourceTree = ""; }; DAB4D8832B4CABEF0048A05C /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = ""; }; DABAFBE92EC58C170015E889 /* AccountDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDetailsView.swift; sourceTree = ""; }; - DABAFBEB2EC5996B0015E889 /* BroadcastMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BroadcastMessageView.swift; sourceTree = ""; }; + DABAFBEB2EC5996B0015E889 /* BroadcastMessageSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BroadcastMessageSheet.swift; sourceTree = ""; }; DABE8BF32B55DC0A00884D28 /* transfer-complete.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = "transfer-complete.aiff"; sourceTree = ""; }; DABE8BF52B55DC2E00884D28 /* chat-message.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = "chat-message.aiff"; sourceTree = ""; }; DABE8BF72B55DC6100884D28 /* logged-in.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = "logged-in.aiff"; sourceTree = ""; }; @@ -215,7 +215,7 @@ DABE8BFF2B55E69800884D28 /* server-message.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = "server-message.aiff"; sourceTree = ""; }; DABE8C012B55E69D00884D28 /* new-news.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = "new-news.aiff"; sourceTree = ""; }; DABE8C032B57940A00884D28 /* DAKeychain.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DAKeychain.swift; sourceTree = ""; }; - DAC0E4222EC78E1100E999DB /* UserInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserInfo.swift; sourceTree = ""; }; + DAC0E4222EC78E1100E999DB /* UserClientInfoSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserClientInfoSheet.swift; sourceTree = ""; }; DAC3D9822BC33FD000A727C9 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; DAC6B2DE2EAC6236004E2CBA /* ChatStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatStore.swift; sourceTree = ""; }; DAC6B2E12EAEE9EF004E2CBA /* NetSocket.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetSocket.swift; sourceTree = ""; }; @@ -534,15 +534,15 @@ DAE734F72B2E4126000C56F6 /* macOS */ = { isa = PBXGroup; children = ( - DAC0E4222EC78E1100E999DB /* UserInfo.swift */, DA55AC762BE589F700034857 /* AboutView.swift */, DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */, DAE136B92B9D1147007D8307 /* HotlinePanelView.swift */, DAE734F82B2E4185000C56F6 /* ServerView.swift */, DAF5BC6B2EC2727100551E4D /* ConnectView.swift */, DAE735022B30C0BB000C56F6 /* MessageView.swift */, + DAC0E4222EC78E1100E999DB /* UserClientInfoSheet.swift */, DA5268B42EB6840A00DCB941 /* TransfersView.swift */, - DABAFBEB2EC5996B0015E889 /* BroadcastMessageView.swift */, + DABAFBEB2EC5996B0015E889 /* BroadcastMessageSheet.swift */, DA501BE52EBE9520001714F8 /* Trackers */, DA5268A82EB081AF00DCB941 /* Chat */, DA5268A62EB0762300DCB941 /* Board */, @@ -736,12 +736,12 @@ DA501BE72EBE9542001714F8 /* TrackerBookmarkSheet.swift in Sources */, DAB4D87E2B4C8BCA0048A05C /* FilePreviewTextView.swift in Sources */, DA3429B72EBAB1750010784E /* QuickLookPreviewView.swift in Sources */, - DABAFBEC2EC599700015E889 /* BroadcastMessageView.swift in Sources */, + DABAFBEC2EC599700015E889 /* BroadcastMessageSheet.swift in Sources */, DA4F2C012B1A558E00D8ADDC /* ChatView.swift in Sources */, DA5268B32EB6806E00DCB941 /* HotlineFileDownloadClient.swift in Sources */, DACCE5E32EABE86A008CDD92 /* AppUpdateView.swift in Sources */, DAC6B2E02EAC6236004E2CBA /* ChatStore.swift in Sources */, - DAC0E4232EC78E1100E999DB /* UserInfo.swift in Sources */, + DAC0E4232EC78E1100E999DB /* UserClientInfoSheet.swift in Sources */, DA32CD492B2931640053B98B /* User.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index 1440b71..29b7827 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -181,7 +181,16 @@ public actor HotlineClient { // Connect socket print("HotlineClient.connect(): Connecting socket...") - let socket = try await NetSocket.connect(host: host, port: port) + let socket: NetSocket + do { + socket = try await NetSocket.connect(host: host, port: port) + } + catch let socketError as NetSocketError { + if case .failed(_) = socketError { + throw HotlineClientError.connectionFailed(socketError) + } + throw socketError + } print("HotlineClient.connect(): Socket connected") // Perform handshake @@ -590,6 +599,22 @@ public actor HotlineClient { try await socket.send(transaction, endian: .big) } + + /// Force a user to disconnect from the server + /// + /// - Parameters: + /// - userID: Target user ID + /// - options: If specified, temporarily or permanently ban the user + public func disconnectUser(userID: UInt16, options: HotlineUserDisconnectOptions? = nil) async throws { + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .disconnectUser) + + transaction.setFieldUInt16(type: .userID, val: userID) + if let options { + transaction.setFieldUInt16(type: .options, val: options.rawValue) + } + + try await self.sendTransaction(transaction) + } // MARK: - Agreement diff --git a/Hotline/Hotline/HotlineProtocol.swift b/Hotline/Hotline/HotlineProtocol.swift index 92fdfd2..a1a0f31 100644 --- a/Hotline/Hotline/HotlineProtocol.swift +++ b/Hotline/Hotline/HotlineProtocol.swift @@ -10,6 +10,11 @@ struct HotlinePorts { static let DefaultTrackerPort: Int = 5498 } +public enum HotlineUserDisconnectOptions: UInt16 { + case temporarilyBan = 1 + case permanentlyBan = 2 +} + public struct HotlineUserOptions: OptionSet, Sendable { public let rawValue: UInt16 diff --git a/Hotline/MacApp.swift b/Hotline/MacApp.swift index 7997a87..4f6c2af 100644 --- a/Hotline/MacApp.swift +++ b/Hotline/MacApp.swift @@ -185,13 +185,13 @@ struct Application: App { Server(name: nil, description: nil, address: "") } .modelContainer(self.modelContainer) - .defaultSize(width: 690, height: 760) + .defaultSize(width: 780, height: 640) .defaultPosition(.center) .onChange(of: activeServerState) { - AppState.shared.activeServerState = activeServerState + AppState.shared.activeServerState = self.activeServerState } .onChange(of: activeHotline) { - AppState.shared.activeHotline = activeHotline + AppState.shared.activeHotline = self.activeHotline } .commands { CommandGroup(replacing: .newItem) { diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index e78a780..7baa477 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -174,7 +174,7 @@ class HotlineState: Equatable { self.updateServerTitle() } } - var serverTitle: String = "Server" + var serverTitle: String = "" var username: String = "guest" var iconID: Int = 414 var access: HotlineUserAccessOptions? @@ -361,18 +361,41 @@ class HotlineState: Equatable { self.downloadBanner() } - } catch { + } + catch let clientError as HotlineClientError { + switch clientError { + case .connectionFailed(_): + self.displayError(clientError, message: "This server appears to be offline.") + case .loginFailed(let msg): + self.displayError(clientError, message: msg) + case .serverError(_, let msg): + self.displayError(clientError, message: msg) + default: + self.displayError(clientError) + } + if let client = self.client { + await client.disconnect() + self.client = nil + } + self.status = .disconnected + throw clientError + } + catch { print("HotlineState.login(): Login failed with error: \(error)") if let client = self.client { await client.disconnect() self.client = nil } - self.status = .disconnected // .failed(error.localizedDescription) - self.errorDisplayed = true - self.errorMessage = error.localizedDescription + self.status = .disconnected + self.displayError(error) throw error } } + + private func displayError(_ error: Error, message: String? = nil) { + self.errorDisplayed = true + self.errorMessage = message ?? error.localizedDescription + } /// Disconnect from the server (user-initiated) @MainActor @@ -813,6 +836,20 @@ class HotlineState: Equatable { return nil } + + func disconnectUser(id userID: UInt16, options: HotlineUserDisconnectOptions?) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + do { + try await client.disconnectUser(userID: userID, options: options) + } + catch let error as HotlineClientError { + self.errorMessage = error.userMessage + self.errorDisplayed = true + } + } // MARK: - Files @@ -1948,7 +1985,7 @@ class HotlineState: Equatable { // MARK: - Utilities func updateServerTitle() { - self.serverTitle = self.serverName ?? self.server?.name ?? self.server?.address ?? "Server" + self.serverTitle = self.serverName ?? self.server?.name ?? self.server?.address ?? "Hotline" } // News helpers diff --git a/Hotline/macOS/BroadcastMessageSheet.swift b/Hotline/macOS/BroadcastMessageSheet.swift new file mode 100644 index 0000000..b3bd7ea --- /dev/null +++ b/Hotline/macOS/BroadcastMessageSheet.swift @@ -0,0 +1,66 @@ +import SwiftUI + +fileprivate let CHARACTER_LIMIT: Int = 255 + +struct BroadcastMessageSheet: View { + @Environment(HotlineState.self) private var model: HotlineState + @Environment(\.dismiss) private var dismiss + + @State private var sending: Bool = false + + private var message: String { + self.model.broadcastMessage.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var body: some View { + @Bindable var model = self.model + + VStack { + TextField("Write a message...", text: $model.broadcastMessage, axis: .vertical) + .textFieldStyle(.plain) + .lineLimit(5, reservesSpace: true) + } + .padding(.leading, 32) + .padding(.top, 2) + .overlay(alignment: .topLeading) { + Image("Server Message") + } + .padding(16) + .frame(width: 400) + .toolbar { + if self.sending { + ToolbarItem { + ProgressView() + .controlSize(.small) + } + } + + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + self.dismiss() + } + } + + ToolbarItem(placement: .confirmationAction) { + Button("Broadcast") { + let message = self.message + model.broadcastMessage = "" + + guard !message.isBlank else { + return + } + + Task { + self.sending = true + defer { self.sending = false } + + try await model.sendBroadcast(message) + + self.dismiss() + } + } + .disabled(self.message.isEmpty) + } + } + } +} diff --git a/Hotline/macOS/BroadcastMessageView.swift b/Hotline/macOS/BroadcastMessageView.swift deleted file mode 100644 index a5f6fd2..0000000 --- a/Hotline/macOS/BroadcastMessageView.swift +++ /dev/null @@ -1,66 +0,0 @@ -import SwiftUI - -fileprivate let CHARACTER_LIMIT: Int = 255 - -struct BroadcastMessageView: View { - @Environment(HotlineState.self) private var model: HotlineState - @Environment(\.dismiss) private var dismiss - - @State private var sending: Bool = false - - private var message: String { - self.model.broadcastMessage.trimmingCharacters(in: .whitespacesAndNewlines) - } - - var body: some View { - @Bindable var model = self.model - - VStack { - TextField("Write a message...", text: $model.broadcastMessage, axis: .vertical) - .textFieldStyle(.plain) - .lineLimit(5, reservesSpace: true) - } - .padding(.leading, 32) - .padding(.top, 2) - .overlay(alignment: .topLeading) { - Image("Server Message") - } - .padding(16) - .frame(width: 400) - .toolbar { - if self.sending { - ToolbarItem { - ProgressView() - .controlSize(.small) - } - } - - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - self.dismiss() - } - } - - ToolbarItem(placement: .confirmationAction) { - Button("Broadcast") { - let message = self.message - model.broadcastMessage = "" - - guard !message.isBlank else { - return - } - - Task { - self.sending = true - defer { self.sending = false } - - try await model.sendBroadcast(message) - - self.dismiss() - } - } - .disabled(self.message.isEmpty) - } - } - } -} diff --git a/Hotline/macOS/Files/FileDetailsSheet.swift b/Hotline/macOS/Files/FileDetailsSheet.swift index e86bf28..1bfacb2 100644 --- a/Hotline/macOS/Files/FileDetailsSheet.swift +++ b/Hotline/macOS/Files/FileDetailsSheet.swift @@ -14,7 +14,7 @@ struct FileDetailsSheet: View { var body: some View { VStack(alignment: .leading, spacing: 16) { HStack(alignment: .center, spacing: 16){ - if self.details.type == "Folder" { + if self.isFolder { FolderIconView() .frame(width: 32, height: 32) } @@ -22,6 +22,7 @@ struct FileDetailsSheet: View { FileIconView(filename: self.details.name, fileType: nil) .frame(width: 32, height: 32) } + TextField("File Name", text: $filename) .disabled(!self.canRename) } @@ -114,6 +115,28 @@ struct FileDetailsSheet: View { } } + private var isFolder: Bool { + self.details.type == "Folder" || self.details.type == "fldr" + } + + private func isEdited() -> Bool { + return self.filename != self.details.name || self.comment != self.details.comment + } + + private var canRename: Bool { + if self.isFolder { + return self.model.access?.contains(.canRenameFolders) == true + } + return self.model.access?.contains(.canRenameFiles) == true + } + + private var canSetComment: Bool { + if self.isFolder { + return self.model.access?.contains(.canSetFolderComment) == true + } + return self.model.access?.contains(.canSetFileComment) == true + } + static var dateFormatter: DateFormatter = { var dateFormatter = DateFormatter() dateFormatter.dateStyle = .long @@ -142,24 +165,6 @@ struct FileDetailsSheet: View { let formattedByteCount = Self.byteCountSizeFormatter.string(from: NSNumber(value:byteCount)) ?? "0" return "\(FileItemView.byteFormatter.string(fromByteCount: Int64(byteCount))) (\(formattedByteCount) bytes)" } - - private func isEdited() -> Bool { - return self.filename != self.details.name || self.comment != self.details.comment - } - - private var canRename: Bool { - if self.details.type == "fldr" || self.details.type == "Folder" { - return self.model.access?.contains(.canRenameFolders) == true - } - return self.model.access?.contains(.canRenameFiles) == true - } - - private var canSetComment: Bool { - if self.details.type == "fldr" || self.details.type == "Folder" { - return self.model.access?.contains(.canSetFolderComment) == true - } - return self.model.access?.contains(.canSetFileComment) == true - } } //#Preview { diff --git a/Hotline/macOS/Files/FilesView.swift b/Hotline/macOS/Files/FilesView.swift index 1ae99d9..c9c4c24 100644 --- a/Hotline/macOS/Files/FilesView.swift +++ b/Hotline/macOS/Files/FilesView.swift @@ -198,28 +198,32 @@ struct FilesView: View { ToolbarSpacer() } - ToolbarItem { - Button { - self.newFolderShown = true - } label: { - Label("New Folder", systemImage: "folder.badge.plus") - } - .help("New Folder") - .popover(isPresented: self.$newFolderShown, arrowEdge: .bottom) { - NewFolderPopover { folderName in - self.newFolder(name: folderName, parent: self.selection) + if self.model.access?.contains(.canCreateFolders) == true { + ToolbarItem { + Button { + self.newFolderShown = true + } label: { + Label("New Folder", systemImage: "folder.badge.plus") + } + .help("New Folder") + .popover(isPresented: self.$newFolderShown, arrowEdge: .bottom) { + NewFolderPopover { folderName in + self.newFolder(name: folderName, parent: self.selection) + } } } } - ToolbarItem { - Button { - self.confirmDeleteShown = true - } label: { - Label("Delete", systemImage: "trash") + if self.model.access?.contains(.canDeleteFiles) == true || self.model.access?.contains(.canDeleteFolders) == true { + ToolbarItem { + Button { + self.confirmDeleteShown = true + } label: { + Label("Delete", systemImage: "trash") + } + .disabled(self.selection == nil) + .help("Delete") } - .disabled(self.selection == nil) - .help("Delete") } } } diff --git a/Hotline/macOS/MessageView.swift b/Hotline/macOS/MessageView.swift index 6690a88..6208d54 100644 --- a/Hotline/macOS/MessageView.swift +++ b/Hotline/macOS/MessageView.swift @@ -8,6 +8,8 @@ struct MessageView: View { @State private var scrollPos: Int? @State private var contentHeight: CGFloat = 0 @State private var userInfo: HotlineUserClientInfo? + @State private var disconnectConfirmShown: Bool = false + @State private var username: String? @Namespace private var bottomID @FocusState private var focusedField: FocusedField? @@ -27,6 +29,10 @@ struct MessageView: View { self.inputBar } .background(Color(nsColor: .textBackgroundColor)) + .onAppear { + let user = self.model.users.first(where: { $0.id == userID }) + self.username = user?.name + } .toolbar { if self.model.access?.contains(.canGetClientInfo) == true { ToolbarItem { @@ -35,11 +41,30 @@ struct MessageView: View { } label: { Image(systemName: "info.circle") } + .help("View \(self.username ?? "user")'s information") + } + } + + if self.model.access?.contains(.canDisconnectUsers) == true { + ToolbarItem { + Button { + self.disconnectConfirmShown = true + } label: { + Image(systemName: "nosign") + } + .help("Disconnect \(self.username ?? "this user")") } } } .sheet(item: self.$userInfo) { info in - UserInfoView(info: info) + UserClientInfoSheet(info: info) + } + .alert("Are you sure you want to disconnect \(self.username ?? "this user")?", isPresented: self.$disconnectConfirmShown) { + Button("Disconnect", role: .destructive) { + self.disconnectUser() + } + } message: { + Text("They will be disconnected from the server, but may reconnect.") } } @@ -51,10 +76,15 @@ struct MessageView: View { } } + private func disconnectUser() { + Task { + try await self.model.disconnectUser(id: self.userID, options: nil) + } + } + private var inputBar: some View { HStack(alignment: .lastTextBaseline, spacing: 0) { - let user = self.model.users.first(where: { $0.id == userID }) - TextField("Message \(user?.name ?? "")", text: $input, axis: .vertical) + TextField("Message \(self.username ?? "")", text: $input, axis: .vertical) .focused($focusedField, equals: .chatInput) .textFieldStyle(.plain) .lineLimit(1...5) diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index 3794018..ff92acc 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -111,7 +111,7 @@ struct ServerView: View { self.connectForm Spacer() } - .navigationTitle("New Connection") + .navigationTitle(self.model.serverTitle.isBlank ? "Hotline" : self.model.serverTitle) } else if self.model.status.isLoggingIn { HStack { @@ -131,7 +131,7 @@ struct ServerView: View { } .frame(maxWidth: 300) .padding() - .navigationTitle("New Connection") + .navigationTitle(self.model.serverTitle.isBlank ? "Hotline" : self.model.serverTitle) } else if self.model.status == .loggedIn { self.serverView @@ -155,7 +155,7 @@ struct ServerView: View { Task { try? await self.model.sendUserPreferences() } } .sheet(isPresented: self.$state.broadcastShown) { - BroadcastMessageView() + BroadcastMessageSheet() .environment(self.model) .presentationSizing(.fitted) } @@ -336,8 +336,8 @@ struct ServerView: View { var serverView: some View { NavigationSplitView { self.navigationList - .frame(maxWidth: .infinity) - .navigationSplitViewColumnWidth(min: 200, ideal: 250, max: 500) + .navigationSplitViewColumnWidth(200) +// .navigationSplitViewColumnWidth(min: 150, ideal: 200, max: 400) .toolbar(removing: .sidebarToggle) // .toolbar { // if self.model.access?.contains(.canOpenUsers) == true { @@ -357,22 +357,22 @@ struct ServerView: View { ChatView() .navigationTitle(model.serverTitle) // .navigationSubtitle("Public Chat") - .navigationSplitViewColumnWidth(min: 250, ideal: 500) +// .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .news: NewsView() .navigationTitle(model.serverTitle) // .navigationSubtitle("Newsgroups") - .navigationSplitViewColumnWidth(min: 250, ideal: 500) +// .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .board: MessageBoardView() .navigationTitle(model.serverTitle) // .navigationSubtitle("Message Board") - .navigationSplitViewColumnWidth(min: 250, ideal: 500) +// .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .files: FilesView() .navigationTitle(model.serverTitle) // .navigationSubtitle("Shared Files") - .navigationSplitViewColumnWidth(min: 250, ideal: 500) +// .navigationSplitViewColumnWidth(min: 250, ideal: 500) // case .accounts: // AccountManagerView() // .navigationTitle(model.serverTitle) @@ -383,15 +383,12 @@ struct ServerView: View { MessageView(userID: userID) .navigationTitle(model.serverTitle) // .navigationSubtitle(user?.name ?? "Private Message") - .navigationSplitViewColumnWidth(min: 250, ideal: 500) +// .navigationSplitViewColumnWidth(min: 250, ideal: 500) .onAppear { model.markInstantMessagesAsRead(userID: userID) } } } - .background { - Color.red - } } // MARK: - diff --git a/Hotline/macOS/UserClientInfoSheet.swift b/Hotline/macOS/UserClientInfoSheet.swift new file mode 100644 index 0000000..8fa4db8 --- /dev/null +++ b/Hotline/macOS/UserClientInfoSheet.swift @@ -0,0 +1,25 @@ +import SwiftUI + +struct UserClientInfoSheet: View { + @Environment(\.dismiss) private var dismiss + + let info: HotlineUserClientInfo + + var body: some View { + ScrollView(.vertical) { + Text(self.info.details) + .fontDesign(.monospaced) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding() + } + .frame(width: 350, height: 400) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("OK") { + self.dismiss() + } + } + } + } +} diff --git a/Hotline/macOS/UserInfo.swift b/Hotline/macOS/UserInfo.swift deleted file mode 100644 index 328fc02..0000000 --- a/Hotline/macOS/UserInfo.swift +++ /dev/null @@ -1,25 +0,0 @@ -import SwiftUI - -struct UserInfoView: View { - @Environment(\.dismiss) private var dismiss - - let info: HotlineUserClientInfo - - var body: some View { - ScrollView(.vertical) { - Text(self.info.details) - .fontDesign(.monospaced) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .topLeading) - .padding() - } - .frame(width: 350, height: 400) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("OK") { - self.dismiss() - } - } - } - } -} -- cgit From a1ec37248ce36e84cbdb896e226f241f39df8292 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 14 Nov 2025 16:02:56 -0800 Subject: Bit more work on error handling. --- Hotline/Hotline/HotlineClient.swift | 14 ++-- Hotline/Hotline/HotlineExtensions.swift | 94 ++++++++-------------- .../Transfers/HotlineFileUploadClient.swift | 4 +- .../Transfers/HotlineFolderUploadClient.swift | 5 +- .../Hotline/Transfers/HotlineTransferClient.swift | 6 +- Hotline/MacApp.swift | 4 +- Hotline/State/HotlineState.swift | 50 ++++++------ Hotline/macOS/Board/MessageBoardView.swift | 20 +++-- Hotline/macOS/Files/FolderItemView.swift | 2 +- Hotline/macOS/MessageView.swift | 8 +- Hotline/macOS/ServerView.swift | 33 ++------ 11 files changed, 109 insertions(+), 131 deletions(-) diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index 29b7827..2219330 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -82,10 +82,10 @@ public struct HotlineLogin: Sendable { /// Information about the connected server public struct HotlineServerInfo: Sendable { - let name: String + let name: String? let version: UInt16 - public init(name: String, version: UInt16) { + public init(name: String?, version: UInt16) { self.name = name self.version = version } @@ -242,7 +242,7 @@ public actor HotlineClient { print("HotlineClient.connect(): Starting keep-alive") await client.startKeepAlive() - print("HotlineClient.connect(): Connected to \(serverInfo.name) (v\(serverInfo.version))") + print("HotlineClient.connect(): Connected to \(serverInfo.name ?? "Server") (v\(serverInfo.version))") return client } @@ -279,8 +279,12 @@ public actor HotlineClient { throw HotlineClientError.loginFailed(errorText) } - let serverName = reply.getField(type: .serverName)?.getString() ?? "Unknown" - let serverVersion = reply.getField(type: .versionNumber)?.getUInt16() ?? 0 + // All servers send a server version. + let serverVersion = reply.getField(type: .versionNumber)?.getUInt16() ?? 123 + + // Later clients send a name and banner ID. + let serverName = reply.getField(type: .serverName)?.getString() +// let serverBannerID = reply.getField(type: .communityBannerID)?.getInteger() return HotlineServerInfo(name: serverName, version: serverVersion) } diff --git a/Hotline/Hotline/HotlineExtensions.swift b/Hotline/Hotline/HotlineExtensions.swift index bcee812..0d5d806 100644 --- a/Hotline/Hotline/HotlineExtensions.swift +++ b/Hotline/Hotline/HotlineExtensions.swift @@ -490,7 +490,7 @@ extension FileManager { return nil } - guard let fileName = fileURL.lastPathComponent.data(using: .macOSRoman) else { + guard let fileName = fileURL.lastPathComponent.data(using: .macOSRoman, allowLossyConversion: true) else { return nil } @@ -826,38 +826,11 @@ extension Array where Element == UInt8 { } func readString(at offset: Int, length: Int) -> String? { - guard let subdata: Data = self.readData(at: offset, length: length) else { + guard let data: Data = self.readData(at: offset, length: length) else { return nil } - if subdata.count == 0 { - return "" - } - - let allowedEncodings = [ - NSUTF8StringEncoding, - NSShiftJISStringEncoding, - NSUnicodeStringEncoding, - NSWindowsCP1251StringEncoding - ] - - var decodedNSString: NSString? - let rawValue = NSString.stringEncoding(for: subdata, encodingOptions: [.allowLossyKey: false], convertedString: &decodedNSString, usedLossyConversion: nil) - - if allowedEncodings.contains(rawValue) { - return decodedNSString as? String - } - - else if rawValue > 1 { - print("ENCODING FOUND \(rawValue)") - } - - var macStr = String(data: subdata, encoding: .macOSRoman) - if macStr == nil { - macStr = String(data: subdata, encoding: .nonLossyASCII) - } - - return macStr + return data.readString(at: 0, length: length) } func readPString(at offset: Int) -> (String?, Int) { @@ -1158,36 +1131,39 @@ extension NetSocket { let length = try await read(UInt8.self) guard length > 0 else { return nil } - let data = try await read(Int(length)) + let dataLength = Int(length) + let data = try await read(dataLength) + + return data.readString(at: 0, length: dataLength) // Try auto-detection with common encodings - let allowedEncodings = [ - String.Encoding.utf8.rawValue, - String.Encoding.shiftJIS.rawValue, - String.Encoding.unicode.rawValue, - String.Encoding.windowsCP1251.rawValue - ] - - var decodedString: NSString? - let detected = NSString.stringEncoding( - for: data, - encodingOptions: [.allowLossyKey: false], - convertedString: &decodedString, - usedLossyConversion: nil - ) - - if allowedEncodings.contains(detected), let str = decodedString as? String { - return str - } - - // Fallback to MacRoman for classic Mac compatibility - guard let str = String(data: data, encoding: .macOSRoman) else { - throw NetSocketError.decodeFailed(NSError( - domain: "NetSocket", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "Failed to decode pascal string with any known encoding"] - )) - } - return str +// let allowedEncodings = [ +// String.Encoding.utf8.rawValue, +// String.Encoding.shiftJIS.rawValue, +// String.Encoding.unicode.rawValue, +// String.Encoding.windowsCP1251.rawValue +// ] +// +// var decodedString: NSString? +// let detected = NSString.stringEncoding( +// for: data, +// encodingOptions: [.allowLossyKey: false], +// convertedString: &decodedString, +// usedLossyConversion: nil +// ) +// +// if allowedEncodings.contains(detected), let str = decodedString as? String { +// return str +// } +// +// // Fallback to MacRoman for classic Mac compatibility +// guard let str = String(data: data, encoding: .macOSRoman) else { +// throw NetSocketError.decodeFailed(NSError( +// domain: "NetSocket", +// code: -1, +// userInfo: [NSLocalizedDescriptionKey: "Failed to decode pascal string with any known encoding"] +// )) +// } +// return str } } diff --git a/Hotline/Hotline/Transfers/HotlineFileUploadClient.swift b/Hotline/Hotline/Transfers/HotlineFileUploadClient.swift index 384e9bd..937db6a 100644 --- a/Hotline/Hotline/Transfers/HotlineFileUploadClient.swift +++ b/Hotline/Hotline/Transfers/HotlineFileUploadClient.swift @@ -131,7 +131,9 @@ public class HotlineFileUploadClient: @MainActor HotlineTransferClient { throw HotlineTransferClientError.failedToTransfer } - let infoForkData = infoFork.data() + guard let infoForkData = infoFork.data() else { + throw HotlineTransferClientError.failedToTransfer + } let dataForkSize = forkSizes.dataForkSize let resourceForkSize = forkSizes.resourceForkSize diff --git a/Hotline/Hotline/Transfers/HotlineFolderUploadClient.swift b/Hotline/Hotline/Transfers/HotlineFolderUploadClient.swift index 1947de6..f8dfe02 100644 --- a/Hotline/Hotline/Transfers/HotlineFolderUploadClient.swift +++ b/Hotline/Hotline/Transfers/HotlineFolderUploadClient.swift @@ -380,7 +380,10 @@ public class HotlineFolderUploadClient: @MainActor HotlineTransferClient { throw HotlineTransferClientError.failedToTransfer } - let infoForkData = infoFork.data() + guard let infoForkData = infoFork.data() else { + throw HotlineTransferClientError.failedToTransfer + } + let dataForkSize = forkSizes.dataForkSize let resourceForkSize = forkSizes.resourceForkSize diff --git a/Hotline/Hotline/Transfers/HotlineTransferClient.swift b/Hotline/Hotline/Transfers/HotlineTransferClient.swift index 596155b..5f786fc 100644 --- a/Hotline/Hotline/Transfers/HotlineTransferClient.swift +++ b/Hotline/Hotline/Transfers/HotlineTransferClient.swift @@ -260,8 +260,10 @@ struct HotlineFileInfoFork { return nil } - func data() -> Data { - let fileName = self.name.data(using: .macOSRoman)! + func data() -> Data? { + guard let fileName = self.name.data(using: .macOSRoman, allowLossyConversion: true) else { + return nil + } let data = Data(endian: .big) { self.platform diff --git a/Hotline/MacApp.swift b/Hotline/MacApp.swift index 4f6c2af..bf816d7 100644 --- a/Hotline/MacApp.swift +++ b/Hotline/MacApp.swift @@ -184,9 +184,11 @@ struct Application: App { } defaultValue: { Server(name: nil, description: nil, address: "") } - .modelContainer(self.modelContainer) + .windowToolbarStyle(.unified) +// .windowStyle(.hiddenTitleBar) .defaultSize(width: 780, height: 640) .defaultPosition(.center) + .modelContainer(self.modelContainer) .onChange(of: activeServerState) { AppState.shared.activeServerState = self.activeServerState } diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index 7baa477..df8434d 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -324,10 +324,10 @@ class HotlineState: Equatable { print("HotlineState.login(): Getting server info...") if let serverInfo = await client.server { self.serverVersion = serverInfo.version - if !serverInfo.name.isEmpty { - self.serverName = serverInfo.name + if let name = serverInfo.name { + self.serverName = name } - print("HotlineState.login(): Server info retrieved: \(serverInfo.name) v\(serverInfo.version)") + print("HotlineState.login(): Server info retrieved: \(self.serverTitle) v\(serverInfo.version)") } self.status = .connected @@ -336,7 +336,7 @@ class HotlineState: Equatable { // Request initial data before starting event loop print("HotlineState.login(): Requesting user list...") try await self.getUserList() - + self.status = .loggedIn print("HotlineState.login(): Status set to loggedIn") @@ -398,7 +398,6 @@ class HotlineState: Equatable { } /// Disconnect from the server (user-initiated) - @MainActor func disconnect() async { print("HotlineState.disconnect(): Called") guard let client = self.client else { @@ -424,7 +423,6 @@ class HotlineState: Equatable { } /// Handle connection closure (server-initiated or after user disconnect) - @MainActor private func handleConnectionClosed() { print("HotlineState: handleConnectionClosed() entered") guard self.client != nil else { @@ -830,8 +828,7 @@ class HotlineState: Equatable { return try await client.getClientInfoText(for: userID) } catch let error as HotlineClientError { - self.errorMessage = error.userMessage - self.errorDisplayed = true + self.displayError(error, message: error.userMessage) } return nil @@ -846,25 +843,33 @@ class HotlineState: Equatable { try await client.disconnectUser(userID: userID, options: options) } catch let error as HotlineClientError { - self.errorMessage = error.userMessage - self.errorDisplayed = true + self.displayError(error, message: error.userMessage) } } // MARK: - Files @discardableResult - func getFileList(path: [String] = [], suppressErrors: Bool = false, preferCache: Bool = false) async throws -> [FileInfo] { + func getFileList(path: [String] = [], suppressErrors: Bool = false, preferCache: Bool = false) async throws -> [FileInfo]? { guard let client = self.client else { throw HotlineClientError.notConnected } - + // Check cache first if preferred if preferCache, let cached = self.cachedFileList(for: path, ttl: self.fileSearchConfig.cacheTTL, allowStale: false) { return cached.items } - - let hotlineFiles = try await client.getFileList(path: path) + + let hotlineFiles: [HotlineFile] + do { + hotlineFiles = try await client.getFileList(path: path) + } + catch let error as HotlineClientError { + self.displayError(error, message: error.userMessage) + self.filesLoaded = true + return nil + } + let newFiles = hotlineFiles.map { FileInfo(hotlineFile: $0) } // Update UI state @@ -908,8 +913,7 @@ class HotlineState: Equatable { return true } catch let error as HotlineClientError { - self.errorMessage = error.userMessage - self.errorDisplayed = true + self.displayError(error, message: error.userMessage) } return false @@ -927,8 +931,7 @@ class HotlineState: Equatable { return true } catch let error as HotlineClientError { - self.errorMessage = error.userMessage - self.errorDisplayed = true + self.displayError(error, message: error.userMessage) } return false @@ -952,8 +955,7 @@ class HotlineState: Equatable { return true } catch let error as HotlineClientError { - self.errorMessage = error.userMessage - self.errorDisplayed = true + self.displayError(error, message: error.userMessage) } return false @@ -985,8 +987,7 @@ class HotlineState: Equatable { result = try await client.downloadFile(name: fileName, path: fullPath) } catch let error as HotlineClientError { - self.errorMessage = error.userMessage - self.errorDisplayed = true + self.displayError(error, message: error.userMessage) return } @@ -1351,6 +1352,8 @@ class HotlineState: Equatable { guard let client = self.client else { return } let fileName = fileURL.lastPathComponent + + print("UPLOAD FILE: \(fileName) \(fileURL)") guard fileURL.isFileURL, !fileName.isEmpty else { print("HotlineState: Not a valid file URL") @@ -1381,8 +1384,7 @@ class HotlineState: Equatable { referenceNumber = try await client.uploadFile(name: fileName, path: path) } catch let error as HotlineClientError { - self.errorMessage = error.userMessage - self.errorDisplayed = true + self.displayError(error, message: error.userMessage) return } diff --git a/Hotline/macOS/Board/MessageBoardView.swift b/Hotline/macOS/Board/MessageBoardView.swift index 710ff20..be039d8 100644 --- a/Hotline/macOS/Board/MessageBoardView.swift +++ b/Hotline/macOS/Board/MessageBoardView.swift @@ -8,16 +8,14 @@ struct MessageBoardView: View { var body: some View { NavigationStack { - if self.model.access?.contains(.canReadMessageBoard) != false { - if self.model.messageBoardLoaded && self.model.messageBoard.isEmpty { - self.emptyBoardView - } - else { - self.messageBoardView - } + if self.model.access?.contains(.canReadMessageBoard) != true { + self.disabledBoardView + } + else if self.model.messageBoardLoaded && self.model.messageBoard.isEmpty { + self.emptyBoardView } else { - self.disabledBoardView + self.messageBoardView } } .sheet(isPresented: $composerDisplayed) { @@ -32,7 +30,7 @@ struct MessageBoardView: View { } label: { Image(systemName: "square.and.pencil") } - .disabled(self.model.access?.contains(.canPostMessageBoard) == false) + .disabled((self.model.access?.contains(.canPostMessageBoard) != true) || (self.model.access?.contains(.canReadMessageBoard) != true)) .help("Post to Message Board") } } @@ -45,9 +43,9 @@ struct MessageBoardView: View { private var disabledBoardView: some View { ContentUnavailableView { - Label("Message Board Disabled", systemImage: "quote.bubble") + Label("No Message Board", systemImage: "quote.bubble") } description: { - Text("This server has turned off the message board") + Text("This server has turned off their message board") } } diff --git a/Hotline/macOS/Files/FolderItemView.swift b/Hotline/macOS/Files/FolderItemView.swift index 18522af..f82bb11 100644 --- a/Hotline/macOS/Files/FolderItemView.swift +++ b/Hotline/macOS/Files/FolderItemView.swift @@ -20,7 +20,7 @@ struct FolderItemView: View { self.model.uploadFile(url: fileURL, path: filePath) { info in Task { // Refresh file listing to display newly uploaded file. - let _ = try? await model.getFileList(path: filePath) + try? await model.getFileList(path: filePath) } } } diff --git a/Hotline/macOS/MessageView.swift b/Hotline/macOS/MessageView.swift index 6208d54..858aa37 100644 --- a/Hotline/macOS/MessageView.swift +++ b/Hotline/macOS/MessageView.swift @@ -30,9 +30,15 @@ struct MessageView: View { } .background(Color(nsColor: .textBackgroundColor)) .onAppear { - let user = self.model.users.first(where: { $0.id == userID }) + self.model.markInstantMessagesAsRead(userID: self.userID) + + let user = self.model.users.first(where: { $0.id == self.userID }) self.username = user?.name } + + .onAppear { + self.model.markInstantMessagesAsRead(userID: userID) + } .toolbar { if self.model.access?.contains(.canGetClientInfo) == true { ToolbarItem { diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index ff92acc..696e08d 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -111,6 +111,7 @@ struct ServerView: View { self.connectForm Spacer() } + .presentedWindowToolbarStyle(.unified(showsTitle: false)) .navigationTitle(self.model.serverTitle.isBlank ? "Hotline" : self.model.serverTitle) } else if self.model.status.isLoggingIn { @@ -249,6 +250,11 @@ struct ServerView: View { if menuItem.type == .chat { ListItemView(icon: menuItem.image, title: menuItem.name, unread: model.unreadPublicChat).tag(menuItem.type) } +// else if menuItem.type == .board { +// if self.model.access?.contains(.canReadMessageBoard) == true { +// ListItemView(icon: menuItem.image, title: menuItem.name, unread: false).tag(menuItem.type) +// } +// } // else if menuItem.type == .accounts { // if model.access?.contains(.canOpenUsers) == true { // ListItemView(icon: menuItem.image, title: menuItem.name, unread: false).tag(menuItem.type) @@ -355,40 +361,17 @@ struct ServerView: View { switch state.selection { case .chat: ChatView() - .navigationTitle(model.serverTitle) -// .navigationSubtitle("Public Chat") -// .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .news: NewsView() - .navigationTitle(model.serverTitle) -// .navigationSubtitle("Newsgroups") -// .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .board: MessageBoardView() - .navigationTitle(model.serverTitle) -// .navigationSubtitle("Message Board") -// .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .files: FilesView() - .navigationTitle(model.serverTitle) -// .navigationSubtitle("Shared Files") -// .navigationSplitViewColumnWidth(min: 250, ideal: 500) -// case .accounts: -// AccountManagerView() -// .navigationTitle(model.serverTitle) -//// .navigationSubtitle("Accounts") -// .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .user(let userID): -// let user = model.users.first(where: { $0.id == userID }) MessageView(userID: userID) - .navigationTitle(model.serverTitle) -// .navigationSubtitle(user?.name ?? "Private Message") -// .navigationSplitViewColumnWidth(min: 250, ideal: 500) - .onAppear { - model.markInstantMessagesAsRead(userID: userID) - } } } + .navigationTitle(self.model.serverTitle) } // MARK: - @@ -538,7 +521,7 @@ struct ServerTransferRow: View { withAnimation(.snappy(duration: 0.25, extraBounce: 0.3)) { self.hovered = hovered } - self.detailsShown = hovered +// self.detailsShown = hovered } .onTapGesture(count: 2) { guard transfer.completed, let url = transfer.fileURL else { -- cgit From c04f8cee4447eb06d28e9032c0fded00bcd5345c Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 14 Nov 2025 16:24:43 -0800 Subject: Bump build number. --- Hotline.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index 6ff1f66..cd331c8 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -880,7 +880,7 @@ CODE_SIGN_ENTITLEMENTS = Hotline/Hotline.entitlements; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 24; + CURRENT_PROJECT_VERSION = 25; DEVELOPMENT_ASSET_PATHS = "\"Hotline/Preview Content\""; ENABLE_APP_SANDBOX = YES; ENABLE_FILE_ACCESS_DOWNLOADS_FOLDER = readwrite; @@ -920,7 +920,7 @@ CODE_SIGN_ENTITLEMENTS = Hotline/Hotline.entitlements; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 24; + CURRENT_PROJECT_VERSION = 25; DEVELOPMENT_ASSET_PATHS = "\"Hotline/Preview Content\""; ENABLE_APP_SANDBOX = YES; ENABLE_FILE_ACCESS_DOWNLOADS_FOLDER = readwrite; -- cgit From 7b86c071727e35573d06fa43d72e169d8f468d67 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 14 Nov 2025 20:49:22 -0800 Subject: Add folder uploading feature to README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1920bf4..fbda0e6 100644 --- a/README.md +++ b/README.md @@ -73,5 +73,5 @@ And this project is an attempt to create a modern open source version of Hotline | File search | ✓ | | | | | File management | | | | | | Folder downloading | ✓ | | | | -| Folder uploading | | | | | +| Folder uploading | ✓ | | | | | Custom icon sets | | | | | -- cgit From 6a95b53616a4abfa306ddce43151cf4fefbd20ed Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Mon, 17 Nov 2025 11:48:02 -0800 Subject: New macOS 26 app icon. Prepping NewsView for category/topic management. --- Hotline.xcodeproj/project.pbxproj | 4 + .../xcshareddata/swiftpm/Package.resolved | 10 +-- Hotline/Assets/App Icon.icon/Assets/hl.svg | 3 + Hotline/Assets/App Icon.icon/icon.json | 49 +++++++++++ Hotline/macOS/News/NewsView.swift | 96 +++++++++++++--------- 5 files changed, 119 insertions(+), 43 deletions(-) create mode 100644 Hotline/Assets/App Icon.icon/Assets/hl.svg create mode 100644 Hotline/Assets/App Icon.icon/icon.json diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index cd331c8..f1013ec 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -72,6 +72,7 @@ DA72A0E22B4DAA4000A0F48A /* NewsArticle.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA72A0E12B4DAA4000A0F48A /* NewsArticle.swift */; }; DA77253F2B21176D006C5ABB /* NewsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA77253E2B21176D006C5ABB /* NewsView.swift */; platformFilter = ios; }; DA7725412B21435B006C5ABB /* ObservableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA7725402B21435B006C5ABB /* ObservableScrollView.swift */; }; + DA81CA0E2ECBAA4200B198C5 /* App Icon.icon in Resources */ = {isa = PBXBuildFile; fileRef = DA81CA0D2ECBAA4200B198C5 /* App Icon.icon */; }; DA872B132BDDBF78008B1012 /* HotlinePanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA872B122BDDBF78008B1012 /* HotlinePanel.swift */; platformFilters = (macos, ); }; DA872B152BDDEE1A008B1012 /* VisualEffectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA872B142BDDEE1A008B1012 /* VisualEffectView.swift */; platformFilters = (macos, ); }; DA99218E2C51AA5D0058FA6C /* HotlineDataBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA99218D2C51AA5D0058FA6C /* HotlineDataBuilder.swift */; }; @@ -188,6 +189,7 @@ DA72A0E12B4DAA4000A0F48A /* NewsArticle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsArticle.swift; sourceTree = ""; }; DA77253E2B21176D006C5ABB /* NewsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsView.swift; sourceTree = ""; }; DA7725402B21435B006C5ABB /* ObservableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservableScrollView.swift; sourceTree = ""; }; + DA81CA0D2ECBAA4200B198C5 /* App Icon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = "App Icon.icon"; sourceTree = ""; }; DA872B122BDDBF78008B1012 /* HotlinePanel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HotlinePanel.swift; sourceTree = ""; }; DA872B142BDDEE1A008B1012 /* VisualEffectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualEffectView.swift; sourceTree = ""; }; DA99218D2C51AA5D0058FA6C /* HotlineDataBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineDataBuilder.swift; sourceTree = ""; }; @@ -282,6 +284,7 @@ DA4B8F3C2EA6FDCE00CBFD53 /* Assets */ = { isa = PBXGroup; children = ( + DA81CA0D2ECBAA4200B198C5 /* App Icon.icon */, DABE8BF22B55DBFC00884D28 /* Sounds */, ); path = Assets; @@ -627,6 +630,7 @@ DABE8BFE2B55E69000884D28 /* error.aiff in Resources */, DABE8BF42B55DC0A00884D28 /* transfer-complete.aiff in Resources */, DABE8C002B55E69800884D28 /* server-message.aiff in Resources */, + DA81CA0E2ECBAA4200B198C5 /* App Icon.icon in Resources */, DABE8C022B55E69D00884D28 /* new-news.aiff in Resources */, DABE8BFC2B55DC6800884D28 /* user-logout.aiff in Resources */, DABE8BF62B55DC2E00884D28 /* chat-message.aiff in Resources */, diff --git a/Hotline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Hotline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3084548..cf8aa9c 100644 --- a/Hotline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Hotline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "590f8ee2c1318c670012f5d30a51419d3387a50648e0dd04f9654da2b1b7cef5", + "originHash" : "b3995b8a3dcfa496e06e7cf496be577ccc3da760d703ce9c3d81b0e5644397fc", "pins" : [ { "identity" : "kingfisher", "kind" : "remoteSourceControl", "location" : "https://github.com/onevcat/Kingfisher", "state" : { - "revision" : "4d75de347da985a70c63af4d799ed482021f6733", - "version" : "8.6.1" + "revision" : "d30a5fad881137e2267f96a8e3fc35c58999bb94", + "version" : "8.6.2" } }, { @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-cmark", "state" : { - "revision" : "b97d09472e847a416629f026eceae0e2afcfad65", - "version" : "0.7.0" + "revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe", + "version" : "0.7.1" } }, { diff --git a/Hotline/Assets/App Icon.icon/Assets/hl.svg b/Hotline/Assets/App Icon.icon/Assets/hl.svg new file mode 100644 index 0000000..05a1bbb --- /dev/null +++ b/Hotline/Assets/App Icon.icon/Assets/hl.svg @@ -0,0 +1,3 @@ + + + diff --git a/Hotline/Assets/App Icon.icon/icon.json b/Hotline/Assets/App Icon.icon/icon.json new file mode 100644 index 0000000..e147fe7 --- /dev/null +++ b/Hotline/Assets/App Icon.icon/icon.json @@ -0,0 +1,49 @@ +{ + "fill" : { + "linear-gradient" : [ + "display-p3:0.98824,0.15686,0.15686,1.00000", + "srgb:0.83024,0.00000,0.00000,1.00000" + ], + "orientation" : { + "start" : { + "x" : 0.5, + "y" : 0 + }, + "stop" : { + "x" : 0.5, + "y" : 0.7 + } + } + }, + "groups" : [ + { + "layers" : [ + { + "image-name" : "hl.svg", + "name" : "hl", + "position" : { + "scale" : 1.33, + "translation-in-points" : [ + 0, + -4.509732002588862e-05 + ] + } + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} \ No newline at end of file diff --git a/Hotline/macOS/News/NewsView.swift b/Hotline/macOS/News/NewsView.swift index a07a441..39e8d68 100644 --- a/Hotline/macOS/News/NewsView.swift +++ b/Hotline/macOS/News/NewsView.swift @@ -9,7 +9,7 @@ struct NewsView: View { @State private var selection: NewsInfo? @State private var articleText: String? - @State private var splitHidden = SideHolder(.bottom) + @State private var splitHidden: SideHolder = SideHolder(.bottom) @State private var splitFraction = FractionHolder.usingUserDefaults(0.25, key: "News Split Fraction") @State private var editorOpen: Bool = false @State private var replyOpen: Bool = false @@ -20,19 +20,17 @@ struct NewsView: View { if model.serverVersion < 151 { disabledNewsView } + else if !model.newsLoaded { + loadingIndicator + } + else if model.news.isEmpty { + emptyNewsView + } else { NavigationStack { VSplit( top: { - if !model.newsLoaded { - loadingIndicator - } - else if model.news.isEmpty { - emptyNewsView - } - else { - newsBrowser - } + newsBrowser }, bottom: { articleViewer @@ -45,13 +43,13 @@ struct NewsView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(nsColor: .textBackgroundColor)) } - .task { - if !model.newsLoaded { - loading = true - try? await model.getNewsList() - loading = false - } - } + } + } + .task { + if !model.newsLoaded { + loading = true + try? await model.getNewsList() + loading = false } } .sheet(isPresented: $editorOpen) { @@ -78,7 +76,7 @@ struct NewsView: View { } } .toolbar { - ToolbarItem(placement: .primaryAction) { + ToolbarItem { Button { if selection?.type == .category || selection?.type == .article { editorOpen = true @@ -86,11 +84,11 @@ struct NewsView: View { } label: { Image(systemName: "square.and.pencil") } - .help("New Post") + .help("New post under current topic") .disabled(selection?.type != .category && selection?.type != .article) } - ToolbarItem(placement: .primaryAction) { + ToolbarItem { Button { if selection?.type == .article { replyOpen = true @@ -98,11 +96,37 @@ struct NewsView: View { } label: { Image(systemName: "arrowshape.turn.up.left") } - .help("Reply to Post") + .help("Reply to selected post") .disabled(selection?.type != .article) } - ToolbarItem(placement: .primaryAction) { + if #available(macOS 26.0, *) { + ToolbarSpacer(.fixed) + } + + ToolbarItem { + Button { +// if selection?.type == .category || selection?.type == .article { +// editorOpen = true +// } + } label: { + Image(systemName: "newspaper") + } + .help("Create a new topic") + } + + ToolbarItem { + Button { +// if selection?.type == .category || selection?.type == .article { +// editorOpen = true +// } + } label: { + Image(systemName: "tray") + } + .help("Create a new category") + } + + ToolbarItem { Button { loading = true if let selectionPath = selection?.path { @@ -120,7 +144,7 @@ struct NewsView: View { } label: { Image(systemName: "arrow.clockwise") } - .help("Reload News") + .help("Reload selected topic") .disabled(loading) } } @@ -265,22 +289,18 @@ struct NewsView: View { } } else { - HStack(alignment: .center) { - Spacer() - HStack(alignment: .center, spacing: 8) { -// Image(systemName: "doc.append") -// .resizable() -// .scaledToFit() + EmptyView() +// HStack(alignment: .center) { +// Spacer() +// HStack(alignment: .center, spacing: 8) { +// Text("Select a news article to read") // .foregroundStyle(.tertiary) -// .frame(width: 16, height: 16) - Text("Select a news post to read") - .foregroundStyle(.tertiary) - .font(.system(size: 13)) - } - Spacer() - } - .padding() - .padding(.top, 48) +// .font(.subheadline) +// } +// Spacer() +// } +// .padding() +// .padding(.top, 48) } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) -- cgit