aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Hotline.xcodeproj/project.pbxproj86
-rw-r--r--Hotline.xcodeproj/xcshareddata/xcschemes/Hotline.xcscheme1
-rw-r--r--Hotline/Hotline/HotlineClientNew.swift1061
-rw-r--r--Hotline/Hotline/HotlineExtensions.swift143
-rw-r--r--Hotline/Hotline/HotlineProtocol.swift317
-rw-r--r--Hotline/Hotline/HotlineTransferClient.swift3618
-rw-r--r--Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift291
-rw-r--r--Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift163
-rw-r--r--Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift265
-rw-r--r--Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift486
-rw-r--r--Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift538
-rw-r--r--Hotline/Info.plist6
-rw-r--r--Hotline/Library/HotlinePanel.swift6
-rw-r--r--Hotline/Library/NetSocket/FileProgress.swift58
-rw-r--r--Hotline/Library/NetSocket/NetSocketNew.swift (renamed from Hotline/Library/NetSocketNew.swift)1201
-rw-r--r--Hotline/Library/NetSocket/TransferRateEstimator.swift135
-rw-r--r--Hotline/Library/QuickLookPreviewView.swift22
-rw-r--r--Hotline/Library/URLAdditions.swift29
-rw-r--r--Hotline/MacApp.swift37
-rw-r--r--Hotline/Models/FileDetails.swift4
-rw-r--r--Hotline/Models/FileInfo.swift16
-rw-r--r--Hotline/Models/FilePreview.swift226
-rw-r--r--Hotline/Models/Hotline.swift8
-rw-r--r--Hotline/Models/HotlineState.swift2468
-rw-r--r--Hotline/Models/PreviewFileInfo.swift6
-rw-r--r--Hotline/Models/TransferInfo.swift26
-rw-r--r--Hotline/State/AppState.swift58
-rw-r--r--Hotline/State/FilePreviewState.swift207
-rw-r--r--Hotline/State/ServerState.swift4
-rw-r--r--Hotline/Utility/ColorArt.swift147
-rw-r--r--Hotline/Utility/SwiftUIExtensions.swift25
-rw-r--r--Hotline/iOS/ChatView.swift2
-rw-r--r--Hotline/iOS/ServerView.swift2
-rw-r--r--Hotline/macOS/Accounts/AccountManagerView.swift44
-rw-r--r--Hotline/macOS/Board/MessageBoardEditorView.swift14
-rw-r--r--Hotline/macOS/Board/MessageBoardView.swift6
-rw-r--r--Hotline/macOS/Chat/ChatView.swift77
-rw-r--r--Hotline/macOS/Files/FileDetailsView.swift6
-rw-r--r--Hotline/macOS/Files/FileItemView.swift2
-rw-r--r--Hotline/macOS/Files/FilePreviewImageView.swift11
-rw-r--r--Hotline/macOS/Files/FilePreviewQuickLookView.swift131
-rw-r--r--Hotline/macOS/Files/FilePreviewTextView.swift9
-rw-r--r--Hotline/macOS/Files/FilesView.swift103
-rw-r--r--Hotline/macOS/Files/FolderItemView.swift6
-rw-r--r--Hotline/macOS/HotlinePanelView.swift52
-rw-r--r--Hotline/macOS/MessageView.swift10
-rw-r--r--Hotline/macOS/News/NewsEditorView.swift19
-rw-r--r--Hotline/macOS/News/NewsItemView.swift8
-rw-r--r--Hotline/macOS/News/NewsView.swift12
-rw-r--r--Hotline/macOS/ServerView.swift165
-rw-r--r--Hotline/macOS/Settings/IconSettingsView.swift2
-rw-r--r--Hotline/macOS/TransfersView.swift169
52 files changed, 9523 insertions, 2985 deletions
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 = "<group>"; };
DA32CD4C2B2931B50053B98B /* ChatMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessage.swift; sourceTree = "<group>"; };
DA32CD4E2B2931CC0053B98B /* NewsInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsInfo.swift; sourceTree = "<group>"; };
+ DA3429AD2EB9C0220010784E /* HotlineFileUploadClientNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFileUploadClientNew.swift; sourceTree = "<group>"; };
+ DA3429AF2EBA70790010784E /* HotlineFolderUploadClientNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFolderUploadClientNew.swift; sourceTree = "<group>"; };
+ DA3429B22EBA7ADE0010784E /* HotlineFilePreviewClientNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFilePreviewClientNew.swift; sourceTree = "<group>"; };
+ DA3429B42EBA8A450010784E /* FilePreviewState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePreviewState.swift; sourceTree = "<group>"; };
+ DA3429B62EBAB1750010784E /* QuickLookPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickLookPreviewView.swift; sourceTree = "<group>"; };
+ DA3429B82EBAB2130010784E /* FilePreviewQuickLookView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePreviewQuickLookView.swift; sourceTree = "<group>"; };
DA43205D2B1D615600FC8843 /* ServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerView.swift; sourceTree = "<group>"; };
DA4930BC2B4F8DD700822D0B /* NetSocket.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetSocket.swift; sourceTree = "<group>"; };
DA4B8F392EA6FB3C00CBFD53 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = "<group>"; };
@@ -132,6 +148,13 @@
DA5268A42EB0743000DCB941 /* FileItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileItemView.swift; sourceTree = "<group>"; };
DA5268AA2EB11EA300DCB941 /* ColorArt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorArt.swift; sourceTree = "<group>"; };
DA5268AC2EB12FE200DCB941 /* ServerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerState.swift; sourceTree = "<group>"; };
+ DA5268AE2EB2682B00DCB941 /* HotlineClientNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineClientNew.swift; sourceTree = "<group>"; };
+ DA5268B02EB2708E00DCB941 /* HotlineState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineState.swift; sourceTree = "<group>"; };
+ DA5268B22EB6806E00DCB941 /* HotlineFileDownloadClientNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFileDownloadClientNew.swift; sourceTree = "<group>"; };
+ DA5268B42EB6840A00DCB941 /* TransfersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransfersView.swift; sourceTree = "<group>"; };
+ DA5268B72EB916AF00DCB941 /* TransferRateEstimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransferRateEstimator.swift; sourceTree = "<group>"; };
+ DA5268B92EB91B5E00DCB941 /* FileProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProgress.swift; sourceTree = "<group>"; };
+ DA5268BB2EB95A7700DCB941 /* HotlineFolderDownloadClientNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFolderDownloadClientNew.swift; sourceTree = "<group>"; };
DA55AC722BE42AF000034857 /* AsyncLinkPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncLinkPreview.swift; sourceTree = "<group>"; };
DA55AC742BE4888300034857 /* InstantMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstantMessage.swift; sourceTree = "<group>"; };
DA55AC762BE589F700034857 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; };
@@ -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 = "<group>";
+ };
DA4B8F3B2EA6FB5F00CBFD53 /* State */ = {
isa = PBXGroup;
children = (
@@ -219,6 +254,7 @@
DA5268AC2EB12FE200DCB941 /* ServerState.swift */,
DA2863D92B37BF6E00A7D050 /* Preferences.swift */,
DACCE5E02EABE4B4008CDD92 /* AppUpdate.swift */,
+ DA3429B42EBA8A450010784E /* FilePreviewState.swift */,
);
path = State;
sourceTree = "<group>";
@@ -265,6 +301,7 @@
11A726092BE06759000C1DA7 /* FileDetailsView.swift */,
DAAEE66E2B47625600A5BA07 /* FilePreviewImageView.swift */,
DAB4D87D2B4C8BCA0048A05C /* FilePreviewTextView.swift */,
+ DA3429B82EBAB2130010784E /* FilePreviewQuickLookView.swift */,
);
path = Files;
sourceTree = "<group>";
@@ -306,6 +343,16 @@
path = News;
sourceTree = "<group>";
};
+ DA5268B62EB916AB00DCB941 /* NetSocket */ = {
+ isa = PBXGroup;
+ children = (
+ DAC6B2E12EAEE9EF004E2CBA /* NetSocketNew.swift */,
+ DA5268B92EB91B5E00DCB941 /* FileProgress.swift */,
+ DA5268B72EB916AF00DCB941 /* TransferRateEstimator.swift */,
+ );
+ path = NetSocket;
+ sourceTree = "<group>";
+ };
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<HotlineEvent>.Continuation
+ public let events: AsyncStream<HotlineEvent>
+
+ // Transaction tracking for request/reply pattern
+ private var pendingTransactions: [UInt32: CheckedContinuation<HotlineTransaction, Error>] = [:]
+
+ private enum TransactionWaitError: Error {
+ case timeout
+ }
+
+ // Receive loop task
+ private var receiveTask: Task<Void, Never>?
+
+ // Keep-alive timer
+ private var keepAliveTask: Task<Void, Never>?
+
+ // 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<HotlineEvent>.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<HotlineTransaction, Error>) {
+ 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<T>(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..<fieldCount {
+ guard
+ let fieldID = fieldBytes.consumeUInt16(),
+ let fieldSize = fieldBytes.consumeUInt16()
+ else { break }
+
+ guard let fieldData: [UInt8] = fieldBytes.consumeBytes(Int(fieldSize)) else {
+ break
+ }
+
+ if let fieldType = HotlineTransactionFieldType(rawValue: fieldID) {
+ self.fields.append(HotlineTransactionField(type: fieldType, dataSize: fieldSize, data: fieldData))
+ }
+ }
+ }
+}
+
enum HotlineTransactionFieldType: UInt16 {
case errorText = 100 // String
case data = 101 // String
diff --git a/Hotline/Hotline/HotlineTransferClient.swift b/Hotline/Hotline/HotlineTransferClient.swift
index 6bac8db..30fe694 100644
--- a/Hotline/Hotline/HotlineTransferClient.swift
+++ b/Hotline/Hotline/HotlineTransferClient.swift
@@ -4,8 +4,7 @@ import UniformTypeIdentifiers
enum HotlineFileClientError: Error {
case failedToConnect
- case failedToDownload
- case failedToUpload
+ case failedToTransfer
}
enum HotlineFileFork {
@@ -34,61 +33,7 @@ enum HotlineFileForkType: UInt32 {
case resource = 1296122706 // 'MACR'
}
-protocol HotlineTransferDelegate: AnyObject {
- func hotlineTransferStatusChanged(client: HotlineTransferClient, reference: UInt32, status: HotlineTransferStatus, timeRemaining: TimeInterval)
-}
-
-protocol HotlineFileDownloadClientDelegate: HotlineTransferDelegate {
- func hotlineFileDownloadReceivedInfo(client: HotlineFileDownloadClient, reference: UInt32, info: HotlineFileInfoFork)
- func hotlineFileDownloadComplete(client: HotlineFileDownloadClient, reference: UInt32, at: URL)
-}
-
-protocol HotlineFilePreviewClientDelegate: HotlineTransferDelegate {
- func hotlineFilePreviewComplete(client: HotlineFilePreviewClient, reference: UInt32, data: Data)
-}
-
-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
- 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
-}
-
-private enum HotlineFolderAction: UInt16 {
+public enum HotlineFolderAction: UInt16 {
case sendFile = 1
case resumeFile = 2
case nextFile = 3
@@ -104,893 +49,6 @@ protocol HotlineTransferClient {
func cancel()
}
-// 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<Void, Never>?
-
- 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..<HotlineFileHeader.DataSize)
- self.fileHeader = header
- self.transferStage = .fileForkHeader
- keepProcessing = true
- }
- case .fileForkHeader:
- if let forkHeader = HotlineFileForkHeader(from: self.fileBytes) {
- // let fileForkHeader = HotlineFileForkHeader(from: self.fileBytes)
- self.fileBytes.removeSubrange(0..<HotlineFileForkHeader.DataSize)
- self.fileCurrentForkHeader = forkHeader
- self.fileCurrentForkBytesLeft = Int(forkHeader.dataSize)
-
- if forkHeader.isInfoFork {
- print("HotlineFileClient: Downloading info fork")
- self.transferStage = .fileInfoFork
- }
- else if forkHeader.isDataFork {
- print("HotlineFileClient: Downloading data fork")
- self.transferStage = .fileDataFork
- }
- else if forkHeader.isResourceFork {
- print("HotlineFileClient: Downloading resource fork")
- self.fileResourceBytes = Data()
- self.transferStage = .fileResourceFork
- }
- else {
- print("HotlineFileClient: Downloading unsupported fork")
- self.transferStage = .fileUnsupportedFork
- }
-
- keepProcessing = true
- }
- case .fileInfoFork:
- if let infoForkDataSize = self.fileCurrentForkHeader?.dataSize, self.fileBytes.count >= infoForkDataSize {
- let infoForkData = self.fileBytes.subdata(in: 0..<Int(infoForkDataSize))
- if let infoFork = HotlineFileInfoFork(from: infoForkData) {
- self.fileInfoFork = infoFork
-
- self.fileBytes.removeSubrange(0..<infoFork.headerSize)
-
- self.transferStage = .fileForkHeader
- self.fileCurrentForkBytesLeft -= Int(infoForkDataSize)
- self.fileCurrentForkHeader = nil
-
- print("INFO FORK STUFF:", infoFork)
-
- // Create file in Downloads folder if we don't have a destination already.
- if !self.prepareDownloadFile(name: infoFork.name) {
- print("FAILED TO CREATE FILE ON DISK")
- }
-
- let reference = self.referenceNumber
- DispatchQueue.main.async {
- self.delegate?.hotlineFileDownloadReceivedInfo(client: self, reference: reference, info: infoFork)
- }
- }
-
- keepProcessing = true
- }
- case .fileDataFork:
- if self.fileBytes.count > 0 {
- if let f = self.fileHandle {
- do {
- var dataToWrite = self.fileBytes
-
- if dataToWrite.count >= self.fileCurrentForkBytesLeft {
- dataToWrite = self.fileBytes.subdata(in: 0..<self.fileCurrentForkBytesLeft)
- self.fileBytes.removeSubrange(0..<self.fileCurrentForkBytesLeft)
-
- self.transferStage = .fileForkHeader
- self.fileCurrentForkBytesLeft = 0
- self.fileCurrentForkHeader = nil
-
- keepProcessing = true
- }
- else {
- self.fileCurrentForkBytesLeft -= dataToWrite.count
- self.fileBytes = Data()
- }
-
- try f.write(contentsOf: dataToWrite)
- }
- catch {
- print("DOWNLOAD WRITE ERROR", error)
- }
- }
- }
- case .fileResourceFork:
- if self.fileBytes.count > 0 {
- var dataToWrite = self.fileBytes
-
- if dataToWrite.count >= self.fileCurrentForkBytesLeft {
- dataToWrite = self.fileBytes.subdata(in: 0..<self.fileCurrentForkBytesLeft)
- self.fileBytes.removeSubrange(0..<self.fileCurrentForkBytesLeft)
-
- self.transferStage = .fileForkHeader
- self.fileCurrentForkBytesLeft = 0
- self.fileCurrentForkHeader = nil
-
- keepProcessing = true
- }
- else {
- self.fileCurrentForkBytesLeft -= dataToWrite.count
- self.fileBytes = Data()
- }
-
- print("WRITING TO RESOURCE FORK", dataToWrite.count)
-
- self.fileResourceBytes.append(dataToWrite)
- }
- case .fileUnsupportedFork:
- if self.fileBytes.count > 0 {
- var dataToWrite = self.fileBytes
-
- if dataToWrite.count >= self.fileCurrentForkBytesLeft {
- dataToWrite = self.fileBytes.subdata(in: 0..<self.fileCurrentForkBytesLeft)
- self.fileBytes.removeSubrange(0..<self.fileCurrentForkBytesLeft)
-
- self.transferStage = .fileForkHeader
- self.fileCurrentForkBytesLeft = 0
- self.fileCurrentForkHeader = nil
-
- keepProcessing = true
- }
- else {
- self.fileCurrentForkBytesLeft -= dataToWrite.count
- self.fileBytes = Data()
- }
- }
- }
- } while keepProcessing
-
- if self.fileBytesTransferred < Int(self.referenceDataSize) {
- // If we still have more to download, then receive more data.
- self.receiveFile()
- return
- }
- else {
- self.invalidate()
-
- if self.fileResourceBytes.count > 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..<Int(count) {
- guard headerData.count >= 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..<toDrop)
- }
- return toDrop
- }
-
- // If we can't find FILP yet, wait for more data.
- return 0
- }
-
- private func sendMagic() {
- print("HotlineFolderDownloadClient: sendMagic() called")
- guard let c = connection, self.status == .connected else {
- self.invalidate()
- print("HotlineFolderDownloadClient: invalid connection to send header.")
- return
- }
-
- // Folder transfer initial record: HTXF + ref + dataSize(0) + type(1) + rsvd(0) + action(NextFile)
- // Classic clients send a zero data size and append the action immediately after the header.
- let headerData = Data(endian: .big) {
- "HTXF".fourCharCode()
- self.referenceNumber
- UInt32.zero // data size = 0 (action appended implicitly)
- UInt16(1) // type = 1 (folder transfer)
- UInt16.zero // reserved = 0
- UInt16(HotlineFolderAction.nextFile.rawValue) // action = 3 (next file)
- }
-
- print("HotlineFolderDownloadClient: Sending HTXF magic with reference: \(self.referenceNumber)")
-
- c.send(content: headerData, completion: .contentProcessed { [weak self] (error) in
- guard let self = self else {
- print("HotlineFolderDownloadClient: self is nil in sendMagic completion")
- return
- }
-
- guard error == nil else {
- print("HotlineFolderDownloadClient: Error sending magic: \(error!)")
- self.status = .failed(.failedToConnect)
- self.invalidate()
- return
- }
-
- print("HotlineFolderDownloadClient: Magic sent successfully, expecting item header")
- self.status = .progress(0.0)
- self.currentItemNumber = 0
- self.transferStage = .itemHeader
- self.receiveFolder()
- })
- }
-
- private func sendAction(_ action: HotlineFolderAction, resumeData: Data? = nil) {
- guard let c = connection else {
- self.invalidate()
- print("HotlineFolderDownloadClient: invalid connection to send action.")
- return
- }
-
- var actionData = Data(endian: .big) {
- UInt16(action.rawValue)
- }
-
- if let resume = resumeData, !resume.isEmpty {
- actionData.append(Data(endian: .big) { UInt16(resume.count) })
- actionData.append(resume)
- }
-
- print("HotlineFolderDownloadClient: Sending action: \(action)")
-
- c.send(content: actionData, completion: .contentProcessed { [weak self] (error) in
- guard let self = self else {
- return
- }
-
- guard error == nil else {
- print("HotlineFolderDownloadClient: Error sending action: \(error!)")
- self.status = .failed(.failedToConnect)
- self.invalidate()
- return
- }
-
- print("HotlineFolderDownloadClient: Action sent successfully")
- // Don't call receiveFolder() here - the main receive loop handles it
- })
- }
-
- private func receiveFolder() {
- print("HotlineFolderDownloadClient: receiveFolder() called, status: \(self.status)")
- guard let c = self.connection else {
- print("HotlineFolderDownloadClient: No connection in receiveFolder")
- return
- }
-
- print("HotlineFolderDownloadClient: Connection state: \(c.state)")
- print("HotlineFolderDownloadClient: About to call c.receive()")
-
- c.receive(minimumIncompleteLength: 1, maximumLength: Int(UInt16.max)) { [weak self] (data, context, isComplete, error) in
- print("HotlineFolderDownloadClient: Receive completion handler called! isComplete: \(isComplete), error: \(String(describing: error)), dataCount: \(data?.count ?? 0)")
-
- guard let self = self else {
- print("HotlineFolderDownloadClient: self is nil in receiveFolder")
- return
- }
-
- if let error = error {
- if self.status == .completed,
- case .posix(let posixError) = error,
- posixError == .ENODATA {
- print("HotlineFolderDownloadClient: Ignoring ENODATA after completion")
- return
- }
-
- print("HotlineFolderDownloadClient: Error in receiveFolder: \(error)")
- self.status = .failed(.failedToDownload)
- self.invalidate()
- return
- }
-
- if let newData = data, !newData.isEmpty {
- print("HotlineFolderDownloadClient: Received \(newData.count) bytes (total: \(self.fileBytesTransferred + newData.count)/\(self.referenceDataSize))")
- self.fileBytesTransferred += newData.count
- self.fileBytes.append(newData)
- self.fileProgress.completedUnitCount = Int64(self.fileBytesTransferred)
- self.status = .progress(Double(self.fileBytesTransferred) / Double(self.referenceDataSize))
- }
- else {
- print("HotlineFolderDownloadClient: Received empty or nil data, isComplete: \(isComplete)")
- }
-
- // Process received data
- var keepProcessing = false
- repeat {
- keepProcessing = false
-
- switch self.transferStage {
- case .itemHeader:
- // Need 2-byte length + header data
- guard self.fileBytes.count >= 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..<HotlineFileHeader.DataSize)
- self.currentFileBytesRead += HotlineFileHeader.DataSize
- self.fileHeader = header
- self.fileForksRemaining = Int(header.forkCount)
- print("HotlineFolderDownloadClient: File has \(header.forkCount) forks")
- self.transferStage = .fileForkHeader
- keepProcessing = true
- }
- }
- case .fileForkHeader:
- let allForksConsumed = self.fileForksRemaining == 0 && self.fileCurrentForkHeader == nil && self.fileCurrentForkBytesLeft == 0
-
- if allForksConsumed {
- if self.currentFileBytesRead != Int(self.currentFileSize) {
- self.currentFileSize = UInt32(min(Int.max, self.currentFileBytesRead))
- }
-
- print("HotlineFolderDownloadClient: File complete (consumed all forks, bytes read: \(self.currentFileBytesRead)/\(self.currentFileSize))")
-
- self.finishCurrentFile()
- self.completedItemCount += 1
-
- // Check if we've completed all items
- if self.completedItemCount >= 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..<HotlineFileForkHeader.DataSize)
- self.currentFileBytesRead += HotlineFileForkHeader.DataSize
- self.fileCurrentForkHeader = forkHeader
- self.fileCurrentForkBytesLeft = Int(forkHeader.dataSize)
- self.fileForksRemaining -= 1 // Decrement fork count
-
- print("HotlineFolderDownloadClient: Read fork header (type: \(String(format: "0x%08X", forkHeader.forkType)), size: \(forkHeader.dataSize) bytes, \(self.fileForksRemaining) forks remaining)")
-
- // Handle zero-length forks immediately
- if forkHeader.dataSize == 0 {
- print("HotlineFolderDownloadClient: Skipping zero-length fork")
- self.fileCurrentForkHeader = nil
- self.fileCurrentForkBytesLeft = 0
- // Stay in .fileForkHeader to read next fork or complete file
- keepProcessing = true
- }
- else if forkHeader.isInfoFork {
- print("HotlineFolderDownloadClient: Downloading info fork for item \(self.currentItemNumber + 1)/\(self.folderItemCount)")
- self.transferStage = .fileInfoFork
- keepProcessing = true
- }
- else if forkHeader.isDataFork {
- print("HotlineFolderDownloadClient: Downloading data fork")
- self.transferStage = .fileDataFork
- keepProcessing = true
- }
- else if forkHeader.isResourceFork {
- print("HotlineFolderDownloadClient: Downloading resource fork")
- self.fileResourceBytes = Data()
- self.transferStage = .fileResourceFork
- keepProcessing = true
- }
- else {
- print("HotlineFolderDownloadClient: Downloading unsupported fork (type: \(String(format: "0x%08X", forkHeader.forkType)), size: \(forkHeader.dataSize) bytes)")
- self.transferStage = .fileUnsupportedFork
- keepProcessing = true
- }
- }
- // Not enough data yet for next fork header - wait for more data
-
- case .fileInfoFork:
- if let infoForkDataSize = self.fileCurrentForkHeader?.dataSize, self.fileBytes.count >= infoForkDataSize {
- let infoForkData = self.fileBytes.subdata(in: 0..<Int(infoForkDataSize))
- if let infoFork = HotlineFileInfoFork(from: infoForkData) {
- self.fileInfoFork = infoFork
-
- self.fileBytes.removeSubrange(0..<infoFork.headerSize)
- self.currentFileBytesRead += infoFork.headerSize
-
- self.fileCurrentForkBytesLeft -= Int(infoForkDataSize)
- self.fileCurrentForkHeader = nil
-
- print("HotlineFolderDownloadClient: Received info fork for \(infoFork.name)")
-
- // Create the file on disk using the filename we extracted from the item header
- if !self.prepareDownloadFile(name: self.currentFileName ?? infoFork.name) {
- print("HotlineFolderDownloadClient: Failed to create file on disk")
- }
-
- self.currentItemNumber += 1
-
- let reference = self.referenceNumber
- let fileName = infoFork.name
- let itemNum = self.currentItemNumber
- let totalItems = self.folderItemCount
- DispatchQueue.main.async {
- self.delegate?.hotlineFolderDownloadReceivedFileInfo(client: self, reference: reference, fileName: fileName, itemNumber: itemNum, totalItems: totalItems)
- }
-
- // Info fork complete, check for next fork
- self.transferStage = .fileForkHeader
- keepProcessing = true
- }
- }
- case .fileDataFork:
- if self.fileBytes.count > 0 {
- if let f = self.fileHandle {
- do {
- var dataToWrite = self.fileBytes
-
- if dataToWrite.count >= self.fileCurrentForkBytesLeft {
- dataToWrite = self.fileBytes.subdata(in: 0..<self.fileCurrentForkBytesLeft)
- self.fileBytes.removeSubrange(0..<self.fileCurrentForkBytesLeft)
- self.currentFileBytesRead += dataToWrite.count
-
- self.fileCurrentForkBytesLeft = 0
- self.fileCurrentForkHeader = nil
-
- // Write the final chunk of data
- try f.write(contentsOf: dataToWrite)
-
- print("HotlineFolderDownloadClient: Data fork complete")
-
- self.transferStage = .fileForkHeader
- keepProcessing = true
- }
- else {
- self.fileCurrentForkBytesLeft -= dataToWrite.count
- self.currentFileBytesRead += dataToWrite.count
- self.fileBytes = Data()
-
- // Write partial data
- try f.write(contentsOf: dataToWrite)
- }
- }
- catch {
- print("HotlineFolderDownloadClient: Download write error", error)
- }
- }
- }
- case .fileResourceFork:
- if self.fileBytes.count > 0 {
- var dataToWrite = self.fileBytes
-
- if dataToWrite.count >= self.fileCurrentForkBytesLeft {
- dataToWrite = self.fileBytes.subdata(in: 0..<self.fileCurrentForkBytesLeft)
- self.fileBytes.removeSubrange(0..<self.fileCurrentForkBytesLeft)
- self.currentFileBytesRead += dataToWrite.count
-
- self.fileCurrentForkBytesLeft = 0
- self.fileCurrentForkHeader = nil
-
- print("HotlineFolderDownloadClient: Writing to resource fork", dataToWrite.count)
- self.fileResourceBytes.append(dataToWrite)
-
- print("HotlineFolderDownloadClient: Resource fork complete")
-
- self.transferStage = .fileForkHeader
- keepProcessing = true
- }
- else {
- self.fileCurrentForkBytesLeft -= dataToWrite.count
- self.currentFileBytesRead += dataToWrite.count
- self.fileBytes = Data()
-
- print("HotlineFolderDownloadClient: Writing to resource fork", dataToWrite.count)
- self.fileResourceBytes.append(dataToWrite)
- }
- }
- case .fileUnsupportedFork:
- if self.fileBytes.count > 0 {
- var dataToWrite = self.fileBytes
-
- if dataToWrite.count >= self.fileCurrentForkBytesLeft {
- dataToWrite = self.fileBytes.subdata(in: 0..<self.fileCurrentForkBytesLeft)
- self.fileBytes.removeSubrange(0..<self.fileCurrentForkBytesLeft)
- self.currentFileBytesRead += dataToWrite.count
-
- self.fileCurrentForkBytesLeft = 0
- self.fileCurrentForkHeader = nil
-
- print("HotlineFolderDownloadClient: Unsupported fork complete (discarded \(dataToWrite.count) bytes)")
-
- self.transferStage = .fileForkHeader
- keepProcessing = true
- }
- else {
- self.fileCurrentForkBytesLeft -= dataToWrite.count
- self.currentFileBytesRead += dataToWrite.count
- print("HotlineFolderDownloadClient: Unsupported fork partial data (discarded \(dataToWrite.count) bytes, \(self.fileCurrentForkBytesLeft) bytes remaining)")
- self.fileBytes = Data()
- }
- }
- }
- } while keepProcessing
-
- // Continue receiving data - completion is handled in .fileForkHeader when all items are done
- if self.connection != nil && self.status != .completed {
- self.receiveFolder()
- }
- }
- }
-
- private func finishCurrentFile() {
- // Close current file handle
- if let fh = self.fileHandle {
- try? fh.close()
- self.fileHandle = nil
- }
-
- // Write resource fork if present
- if self.fileResourceBytes.count > 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<Void, Never>?
+//
+// 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..<HotlineFileHeader.DataSize)
+// self.fileHeader = header
+// self.transferStage = .fileForkHeader
+// keepProcessing = true
+// }
+// case .fileForkHeader:
+// if let forkHeader = HotlineFileForkHeader(from: self.fileBytes) {
+// // let fileForkHeader = HotlineFileForkHeader(from: self.fileBytes)
+// self.fileBytes.removeSubrange(0..<HotlineFileForkHeader.DataSize)
+// self.fileCurrentForkHeader = forkHeader
+// self.fileCurrentForkBytesLeft = Int(forkHeader.dataSize)
+//
+// if forkHeader.isInfoFork {
+// print("HotlineFileClient: Downloading info fork")
+// self.transferStage = .fileInfoFork
+// }
+// else if forkHeader.isDataFork {
+// print("HotlineFileClient: Downloading data fork")
+// self.transferStage = .fileDataFork
+// }
+// else if forkHeader.isResourceFork {
+// print("HotlineFileClient: Downloading resource fork")
+// self.fileResourceBytes = Data()
+// self.transferStage = .fileResourceFork
+// }
+// else {
+// print("HotlineFileClient: Downloading unsupported fork")
+// self.transferStage = .fileUnsupportedFork
+// }
+//
+// keepProcessing = true
+// }
+// case .fileInfoFork:
+// if let infoForkDataSize = self.fileCurrentForkHeader?.dataSize, self.fileBytes.count >= infoForkDataSize {
+// let infoForkData = self.fileBytes.subdata(in: 0..<Int(infoForkDataSize))
+// if let infoFork = HotlineFileInfoFork(from: infoForkData) {
+// self.fileInfoFork = infoFork
+//
+// self.fileBytes.removeSubrange(0..<infoFork.headerSize)
+//
+// self.transferStage = .fileForkHeader
+// self.fileCurrentForkBytesLeft -= Int(infoForkDataSize)
+// self.fileCurrentForkHeader = nil
+//
+// print("INFO FORK STUFF:", infoFork)
+//
+// // Create file in Downloads folder if we don't have a destination already.
+// if !self.prepareDownloadFile(name: infoFork.name) {
+// print("FAILED TO CREATE FILE ON DISK")
+// }
+//
+// let reference = self.referenceNumber
+// DispatchQueue.main.async {
+// self.delegate?.hotlineFileDownloadReceivedInfo(client: self, reference: reference, info: infoFork)
+// }
+// }
+//
+// keepProcessing = true
+// }
+// case .fileDataFork:
+// if self.fileBytes.count > 0 {
+// if let f = self.fileHandle {
+// do {
+// var dataToWrite = self.fileBytes
+//
+// if dataToWrite.count >= self.fileCurrentForkBytesLeft {
+// dataToWrite = self.fileBytes.subdata(in: 0..<self.fileCurrentForkBytesLeft)
+// self.fileBytes.removeSubrange(0..<self.fileCurrentForkBytesLeft)
+//
+// self.transferStage = .fileForkHeader
+// self.fileCurrentForkBytesLeft = 0
+// self.fileCurrentForkHeader = nil
+//
+// keepProcessing = true
+// }
+// else {
+// self.fileCurrentForkBytesLeft -= dataToWrite.count
+// self.fileBytes = Data()
+// }
+//
+// try f.write(contentsOf: dataToWrite)
+// }
+// catch {
+// print("DOWNLOAD WRITE ERROR", error)
+// }
+// }
+// }
+// case .fileResourceFork:
+// if self.fileBytes.count > 0 {
+// var dataToWrite = self.fileBytes
+//
+// if dataToWrite.count >= self.fileCurrentForkBytesLeft {
+// dataToWrite = self.fileBytes.subdata(in: 0..<self.fileCurrentForkBytesLeft)
+// self.fileBytes.removeSubrange(0..<self.fileCurrentForkBytesLeft)
+//
+// self.transferStage = .fileForkHeader
+// self.fileCurrentForkBytesLeft = 0
+// self.fileCurrentForkHeader = nil
+//
+// keepProcessing = true
+// }
+// else {
+// self.fileCurrentForkBytesLeft -= dataToWrite.count
+// self.fileBytes = Data()
+// }
+//
+// print("WRITING TO RESOURCE FORK", dataToWrite.count)
+//
+// self.fileResourceBytes.append(dataToWrite)
+// }
+// case .fileUnsupportedFork:
+// if self.fileBytes.count > 0 {
+// var dataToWrite = self.fileBytes
+//
+// if dataToWrite.count >= self.fileCurrentForkBytesLeft {
+// dataToWrite = self.fileBytes.subdata(in: 0..<self.fileCurrentForkBytesLeft)
+// self.fileBytes.removeSubrange(0..<self.fileCurrentForkBytesLeft)
+//
+// self.transferStage = .fileForkHeader
+// self.fileCurrentForkBytesLeft = 0
+// self.fileCurrentForkHeader = nil
+//
+// keepProcessing = true
+// }
+// else {
+// self.fileCurrentForkBytesLeft -= dataToWrite.count
+// self.fileBytes = Data()
+// }
+// }
+// }
+// } while keepProcessing
+//
+// if self.fileBytesTransferred < Int(self.referenceDataSize) {
+// // If we still have more to download, then receive more data.
+// self.receiveFile()
+// return
+// }
+// else {
+// self.invalidate()
+//
+// if self.fileResourceBytes.count > 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..<Int(count) {
+// guard headerData.count >= 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..<toDrop)
+// }
+// return toDrop
+// }
+//
+// // If we can't find FILP yet, wait for more data.
+// return 0
+// }
+//
+// private func sendMagic() {
+// print("HotlineFolderDownloadClient: sendMagic() called")
+// guard let c = connection, self.status == .connected else {
+// self.invalidate()
+// print("HotlineFolderDownloadClient: invalid connection to send header.")
+// return
+// }
+//
+// // Folder transfer initial record: HTXF + ref + dataSize(0) + type(1) + rsvd(0) + action(NextFile)
+// // Classic clients send a zero data size and append the action immediately after the header.
+// let headerData = Data(endian: .big) {
+// "HTXF".fourCharCode()
+// self.referenceNumber
+// UInt32.zero // data size = 0 (action appended implicitly)
+// UInt16(1) // type = 1 (folder transfer)
+// UInt16.zero // reserved = 0
+// UInt16(HotlineFolderAction.nextFile.rawValue) // action = 3 (next file)
+// }
+//
+// print("HotlineFolderDownloadClient: Sending HTXF magic with reference: \(self.referenceNumber)")
+//
+// c.send(content: headerData, completion: .contentProcessed { [weak self] (error) in
+// guard let self = self else {
+// print("HotlineFolderDownloadClient: self is nil in sendMagic completion")
+// return
+// }
+//
+// guard error == nil else {
+// print("HotlineFolderDownloadClient: Error sending magic: \(error!)")
+// self.status = .failed(.failedToConnect)
+// self.invalidate()
+// return
+// }
+//
+// print("HotlineFolderDownloadClient: Magic sent successfully, expecting item header")
+// self.status = .progress(0.0)
+// self.currentItemNumber = 0
+// self.transferStage = .itemHeader
+// self.receiveFolder()
+// })
+// }
+//
+// private func sendAction(_ action: HotlineFolderAction, resumeData: Data? = nil) {
+// guard let c = connection else {
+// self.invalidate()
+// print("HotlineFolderDownloadClient: invalid connection to send action.")
+// return
+// }
+//
+// var actionData = Data(endian: .big) {
+// UInt16(action.rawValue)
+// }
+//
+// if let resume = resumeData, !resume.isEmpty {
+// actionData.append(Data(endian: .big) { UInt16(resume.count) })
+// actionData.append(resume)
+// }
+//
+// print("HotlineFolderDownloadClient: Sending action: \(action)")
+//
+// c.send(content: actionData, completion: .contentProcessed { [weak self] (error) in
+// guard let self = self else {
+// return
+// }
+//
+// guard error == nil else {
+// print("HotlineFolderDownloadClient: Error sending action: \(error!)")
+// self.status = .failed(.failedToConnect)
+// self.invalidate()
+// return
+// }
+//
+// print("HotlineFolderDownloadClient: Action sent successfully")
+// // Don't call receiveFolder() here - the main receive loop handles it
+// })
+// }
+//
+// private func receiveFolder() {
+// print("HotlineFolderDownloadClient: receiveFolder() called, status: \(self.status)")
+// guard let c = self.connection else {
+// print("HotlineFolderDownloadClient: No connection in receiveFolder")
+// return
+// }
+//
+// print("HotlineFolderDownloadClient: Connection state: \(c.state)")
+// print("HotlineFolderDownloadClient: About to call c.receive()")
+//
+// c.receive(minimumIncompleteLength: 1, maximumLength: Int(UInt16.max)) { [weak self] (data, context, isComplete, error) in
+// print("HotlineFolderDownloadClient: Receive completion handler called! isComplete: \(isComplete), error: \(String(describing: error)), dataCount: \(data?.count ?? 0)")
+//
+// guard let self = self else {
+// print("HotlineFolderDownloadClient: self is nil in receiveFolder")
+// return
+// }
+//
+// if let error = error {
+// if self.status == .completed,
+// case .posix(let posixError) = error,
+// posixError == .ENODATA {
+// print("HotlineFolderDownloadClient: Ignoring ENODATA after completion")
+// return
+// }
+//
+// print("HotlineFolderDownloadClient: Error in receiveFolder: \(error)")
+// self.status = .failed(.failedToDownload)
+// self.invalidate()
+// return
+// }
+//
+// if let newData = data, !newData.isEmpty {
+// print("HotlineFolderDownloadClient: Received \(newData.count) bytes (total: \(self.fileBytesTransferred + newData.count)/\(self.referenceDataSize))")
+// self.fileBytesTransferred += newData.count
+// self.fileBytes.append(newData)
+// self.fileProgress.completedUnitCount = Int64(self.fileBytesTransferred)
+// self.status = .progress(Double(self.fileBytesTransferred) / Double(self.referenceDataSize))
+// }
+// else {
+// print("HotlineFolderDownloadClient: Received empty or nil data, isComplete: \(isComplete)")
+// }
+//
+// // Process received data
+// var keepProcessing = false
+// repeat {
+// keepProcessing = false
+//
+// switch self.transferStage {
+// case .itemHeader:
+// // Need 2-byte length + header data
+// guard self.fileBytes.count >= 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..<HotlineFileHeader.DataSize)
+// self.currentFileBytesRead += HotlineFileHeader.DataSize
+// self.fileHeader = header
+// self.fileForksRemaining = Int(header.forkCount)
+// print("HotlineFolderDownloadClient: File has \(header.forkCount) forks")
+// self.transferStage = .fileForkHeader
+// keepProcessing = true
+// }
+// }
+// case .fileForkHeader:
+// let allForksConsumed = self.fileForksRemaining == 0 && self.fileCurrentForkHeader == nil && self.fileCurrentForkBytesLeft == 0
+//
+// if allForksConsumed {
+// if self.currentFileBytesRead != Int(self.currentFileSize) {
+// self.currentFileSize = UInt32(min(Int.max, self.currentFileBytesRead))
+// }
+//
+// print("HotlineFolderDownloadClient: File complete (consumed all forks, bytes read: \(self.currentFileBytesRead)/\(self.currentFileSize))")
+//
+// self.finishCurrentFile()
+// self.completedItemCount += 1
+//
+// // Check if we've completed all items
+// if self.completedItemCount >= 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..<HotlineFileForkHeader.DataSize)
+// self.currentFileBytesRead += HotlineFileForkHeader.DataSize
+// self.fileCurrentForkHeader = forkHeader
+// self.fileCurrentForkBytesLeft = Int(forkHeader.dataSize)
+// self.fileForksRemaining -= 1 // Decrement fork count
+//
+// print("HotlineFolderDownloadClient: Read fork header (type: \(String(format: "0x%08X", forkHeader.forkType)), size: \(forkHeader.dataSize) bytes, \(self.fileForksRemaining) forks remaining)")
+//
+// // Handle zero-length forks immediately
+// if forkHeader.dataSize == 0 {
+// print("HotlineFolderDownloadClient: Skipping zero-length fork")
+// self.fileCurrentForkHeader = nil
+// self.fileCurrentForkBytesLeft = 0
+// // Stay in .fileForkHeader to read next fork or complete file
+// keepProcessing = true
+// }
+// else if forkHeader.isInfoFork {
+// print("HotlineFolderDownloadClient: Downloading info fork for item \(self.currentItemNumber + 1)/\(self.folderItemCount)")
+// self.transferStage = .fileInfoFork
+// keepProcessing = true
+// }
+// else if forkHeader.isDataFork {
+// print("HotlineFolderDownloadClient: Downloading data fork")
+// self.transferStage = .fileDataFork
+// keepProcessing = true
+// }
+// else if forkHeader.isResourceFork {
+// print("HotlineFolderDownloadClient: Downloading resource fork")
+// self.fileResourceBytes = Data()
+// self.transferStage = .fileResourceFork
+// keepProcessing = true
+// }
+// else {
+// print("HotlineFolderDownloadClient: Downloading unsupported fork (type: \(String(format: "0x%08X", forkHeader.forkType)), size: \(forkHeader.dataSize) bytes)")
+// self.transferStage = .fileUnsupportedFork
+// keepProcessing = true
+// }
+// }
+// // Not enough data yet for next fork header - wait for more data
+//
+// case .fileInfoFork:
+// if let infoForkDataSize = self.fileCurrentForkHeader?.dataSize, self.fileBytes.count >= infoForkDataSize {
+// let infoForkData = self.fileBytes.subdata(in: 0..<Int(infoForkDataSize))
+// if let infoFork = HotlineFileInfoFork(from: infoForkData) {
+// self.fileInfoFork = infoFork
+//
+// self.fileBytes.removeSubrange(0..<infoFork.headerSize)
+// self.currentFileBytesRead += infoFork.headerSize
+//
+// self.fileCurrentForkBytesLeft -= Int(infoForkDataSize)
+// self.fileCurrentForkHeader = nil
+//
+// print("HotlineFolderDownloadClient: Received info fork for \(infoFork.name)")
+//
+// // Create the file on disk using the filename we extracted from the item header
+// if !self.prepareDownloadFile(name: self.currentFileName ?? infoFork.name) {
+// print("HotlineFolderDownloadClient: Failed to create file on disk")
+// }
+//
+// self.currentItemNumber += 1
+//
+// let reference = self.referenceNumber
+// let fileName = infoFork.name
+// let itemNum = self.currentItemNumber
+// let totalItems = self.folderItemCount
+// DispatchQueue.main.async {
+// self.delegate?.hotlineFolderDownloadReceivedFileInfo(client: self, reference: reference, fileName: fileName, itemNumber: itemNum, totalItems: totalItems)
+// }
+//
+// // Info fork complete, check for next fork
+// self.transferStage = .fileForkHeader
+// keepProcessing = true
+// }
+// }
+// case .fileDataFork:
+// if self.fileBytes.count > 0 {
+// if let f = self.fileHandle {
+// do {
+// var dataToWrite = self.fileBytes
+//
+// if dataToWrite.count >= self.fileCurrentForkBytesLeft {
+// dataToWrite = self.fileBytes.subdata(in: 0..<self.fileCurrentForkBytesLeft)
+// self.fileBytes.removeSubrange(0..<self.fileCurrentForkBytesLeft)
+// self.currentFileBytesRead += dataToWrite.count
+//
+// self.fileCurrentForkBytesLeft = 0
+// self.fileCurrentForkHeader = nil
+//
+// // Write the final chunk of data
+// try f.write(contentsOf: dataToWrite)
+//
+// print("HotlineFolderDownloadClient: Data fork complete")
+//
+// self.transferStage = .fileForkHeader
+// keepProcessing = true
+// }
+// else {
+// self.fileCurrentForkBytesLeft -= dataToWrite.count
+// self.currentFileBytesRead += dataToWrite.count
+// self.fileBytes = Data()
+//
+// // Write partial data
+// try f.write(contentsOf: dataToWrite)
+// }
+// }
+// catch {
+// print("HotlineFolderDownloadClient: Download write error", error)
+// }
+// }
+// }
+// case .fileResourceFork:
+// if self.fileBytes.count > 0 {
+// var dataToWrite = self.fileBytes
+//
+// if dataToWrite.count >= self.fileCurrentForkBytesLeft {
+// dataToWrite = self.fileBytes.subdata(in: 0..<self.fileCurrentForkBytesLeft)
+// self.fileBytes.removeSubrange(0..<self.fileCurrentForkBytesLeft)
+// self.currentFileBytesRead += dataToWrite.count
+//
+// self.fileCurrentForkBytesLeft = 0
+// self.fileCurrentForkHeader = nil
+//
+// print("HotlineFolderDownloadClient: Writing to resource fork", dataToWrite.count)
+// self.fileResourceBytes.append(dataToWrite)
+//
+// print("HotlineFolderDownloadClient: Resource fork complete")
+//
+// self.transferStage = .fileForkHeader
+// keepProcessing = true
+// }
+// else {
+// self.fileCurrentForkBytesLeft -= dataToWrite.count
+// self.currentFileBytesRead += dataToWrite.count
+// self.fileBytes = Data()
+//
+// print("HotlineFolderDownloadClient: Writing to resource fork", dataToWrite.count)
+// self.fileResourceBytes.append(dataToWrite)
+// }
+// }
+// case .fileUnsupportedFork:
+// if self.fileBytes.count > 0 {
+// var dataToWrite = self.fileBytes
+//
+// if dataToWrite.count >= self.fileCurrentForkBytesLeft {
+// dataToWrite = self.fileBytes.subdata(in: 0..<self.fileCurrentForkBytesLeft)
+// self.fileBytes.removeSubrange(0..<self.fileCurrentForkBytesLeft)
+// self.currentFileBytesRead += dataToWrite.count
+//
+// self.fileCurrentForkBytesLeft = 0
+// self.fileCurrentForkHeader = nil
+//
+// print("HotlineFolderDownloadClient: Unsupported fork complete (discarded \(dataToWrite.count) bytes)")
+//
+// self.transferStage = .fileForkHeader
+// keepProcessing = true
+// }
+// else {
+// self.fileCurrentForkBytesLeft -= dataToWrite.count
+// self.currentFileBytesRead += dataToWrite.count
+// print("HotlineFolderDownloadClient: Unsupported fork partial data (discarded \(dataToWrite.count) bytes, \(self.fileCurrentForkBytesLeft) bytes remaining)")
+// self.fileBytes = Data()
+// }
+// }
+// }
+// } while keepProcessing
+//
+// // Continue receiving data - completion is handled in .fileForkHeader when all items are done
+// if self.connection != nil && self.status != .completed {
+// self.receiveFolder()
+// }
+// }
+// }
+//
+// private func finishCurrentFile() {
+// // Close current file handle
+// if let fh = self.fileHandle {
+// try? fh.close()
+// self.fileHandle = nil
+// }
+//
+// // Write resource fork if present
+// if self.fileResourceBytes.count > 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<URL, Error>?
+
+ // 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<URL, Error> {
+ 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..<Int(header.forkCount) {
+ // Read fork header
+ let forkHeaderData = try await socket.read(HotlineFileForkHeader.DataSize)
+ guard let forkHeader = HotlineFileForkHeader(from: forkHeaderData) else {
+ throw HotlineFileClientError.failedToTransfer
+ }
+
+ // Handle whichever fork is being sent.
+ if forkHeader.isInfoFork {
+ // Read info fork
+ let infoData = try await socket.read(Int(forkHeader.dataSize))
+ guard let info = HotlineFileInfoFork(from: infoData) else {
+ throw HotlineFileClientError.failedToTransfer
+ }
+ self.transferSize += infoData.count
+
+ // Prepare temporary file for atomic write
+ try? fm.removeItem(at: destinationURL)
+
+ // Create file with metadata
+ fileHandle = try fm.createHotlineFile(at: destinationURL, infoFork: info)
+
+ // Create and configure progress
+ self.transferProgress.fileURL = destinationURL
+ self.transferProgress.fileOperationKind = .downloading
+ self.transferProgress.publish()
+
+ // Update progress
+ self.updateProgress(sent: infoData.count)
+ }
+ else if forkHeader.isDataFork {
+ guard let fh = fileHandle else {
+ throw HotlineFileClientError.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))
+ }
+ }
+ 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))
+
+ } else {
+ // Skip unsupported fork
+ let dataSize = Int(forkHeader.dataSize)
+ try await socket.skip(dataSize)
+
+ self.updateProgress(sent: dataSize)
+ }
+
+ progressHandler?(.transfer(name: destinationFilename, size: self.transferSize, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: nil, estimate: nil))
+ }
+
+ self.transferProgress.unpublish()
+
+ // Close file handle
+ try fileHandle?.close()
+ fileHandle = nil
+
+ // Write resource fork if present
+ if let rsrcData = resourceForkData, !rsrcData.isEmpty {
+ try writeResourceFork(data: rsrcData, to: destinationURL)
+ }
+
+ progressHandler?(.completed(url: destinationURL))
+
+ return destinationURL
+
+ } catch {
+ // Cleanup on failure
+ try? fileHandle?.close()
+ try? fm.removeItem(at: destinationURL)
+ self.transferProgress.unpublish()
+
+ progressHandler?(.error(error))
+
+ throw error
+ }
+ }
+
+ // MARK: - Helper Methods
+
+ private func connectToTransferServer() async throws -> 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<URL, Error>?
+ 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<URL, Error> {
+ 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<Void, Error>?
+
+ // 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<Void, Error> {
+ 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<URL, Error>?
+ 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<URL, Error> {
+ 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..<Int(count) {
+ guard headerData.count >= 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..<Int(header.forkCount) {
+ // Read fork header
+ let forkHeaderData = try await socket.read(HotlineFileForkHeader.DataSize)
+ guard let forkHeader = HotlineFileForkHeader(from: forkHeaderData) else {
+ throw HotlineFileClientError.failedToTransfer
+ }
+ bytesRead += HotlineFileForkHeader.DataSize
+
+ // Update folder progress for fork header
+ let totalBytesNow = totalBytesTransferredSoFar + bytesRead
+ self.folderProgress?.completedUnitCount = Int64(totalBytesNow)
+
+ if forkHeader.isInfoFork {
+ // Read INFO fork
+ print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Reading INFO fork (\(forkHeader.dataSize) bytes)")
+ 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)
+
+ guard let info = HotlineFileInfoFork(from: infoData) else {
+ throw HotlineFileClientError.failedToTransfer
+ }
+
+ // Create parent folders
+ let parentFolderURL = destinationFolder.appendingPathComponents(parentPath)
+ if !fm.fileExists(atPath: parentFolderURL.path) {
+ try fm.createDirectory(at: parentFolderURL, withIntermediateDirectories: true)
+ }
+
+ // Determine final file path
+ let finalURL = parentFolderURL.appendingPathComponent(fileName)
+ filePath = finalURL
+
+ // Remove existing file if present and create with metadata
+ try? fm.removeItem(at: finalURL)
+ fileHandle = try fm.createHotlineFile(at: finalURL, infoFork: info)
+
+ } else if forkHeader.isDataFork {
+ // Stream DATA fork to disk
+ print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Reading DATA fork (\(forkHeader.dataSize) bytes)")
+
+ guard let fh = fileHandle else {
+ throw HotlineFileClientError.failedToTransfer
+ }
+
+ fileDataForkSize = Int(forkHeader.dataSize)
+
+ // Stream data fork using NetSocketNew's optimized file streaming
+ 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
+ }
+
+ // 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<Void, Error>?
+
+ // 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<Void, Error> {
+ 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 @@
<key>CFBundleTypeName</key>
<string>Hotline Bookmark</string>
<key>LSHandlerRank</key>
- <string>None</string>
+ <string>Owner</string>
<key>LSItemContentTypes</key>
<array>
- <string>&apos;HTbm&apos;</string>
+ <string>'HTbm'</string>
</array>
</dict>
</array>
@@ -46,7 +46,7 @@
<key>UTTypeIconFiles</key>
<array/>
<key>UTTypeIdentifier</key>
- <string>&apos;HTbm&apos;</string>
+ <string>'HTbm'</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
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/NetSocketNew.swift b/Hotline/Library/NetSocket/NetSocketNew.swift
index 8873ee6..7b24b37 100644
--- a/Hotline/Library/NetSocketNew.swift
+++ b/Hotline/Library/NetSocket/NetSocketNew.swift
@@ -1,12 +1,9 @@
-
-// NetSocketNew.swift
-// Created by Dustin Mierau • @mierau
+// NetSocketNew.swift
+// 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)
@@ -15,31 +12,6 @@ public enum Endian {
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
@@ -121,10 +93,8 @@ public enum NetSocketError: Error, CustomStringConvertible, Sendable {
/// 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
@@ -139,46 +109,40 @@ public actor NetSocketNew {
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
+ private let connectionID: String // For logging
// Buffer with compaction
private var buffer = Data()
private var head = 0 // start of unread bytes
- private let cfg: Config
+ private let config: Config
// Waiters for data/ready
private var dataWaiters: [CheckedContinuation<Void, Error>] = []
private var readyWaiters: [CheckedContinuation<Void, Error>] = []
- // 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
+ // MARK: Init
private init(connection: NWConnection, config: Config) {
self.connection = connection
- self.cfg = config
+ 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
@@ -210,163 +174,7 @@ public actor 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<Void, Error>) in
- readyWaiters.append(cont)
- }
- }
-
- private func resumeReadyWaiters(with result: Result<Void, Error>) {
- 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() }
+ return try await self.connect(host: .name(host, nil), port: nwPort, tls: tls, config: config)
}
// MARK: Close
@@ -401,7 +209,7 @@ public actor NetSocketNew {
resumeReadyWaiters(with: .failure(NetSocketError.closed))
}
- // MARK: Send (async)
+ // MARK: Send Data
/// Write raw data to the socket
///
@@ -409,12 +217,13 @@ public actor NetSocketNew {
///
/// - Parameter data: Raw bytes to send
/// - Throws: `NetSocketError` if connection is not ready or send fails
- public func write(_ data: Data) async throws {
+ @discardableResult
+ public func write(_ data: Data) async throws -> Int {
try await ensureReady()
- try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
+ return try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Int, Error>) in
connection.send(content: data, completion: .contentProcessed { error in
if let error { cont.resume(throwing: NetSocketError.failed(underlying: error)) }
- else { cont.resume() }
+ else { cont.resume(returning: data.count) }
})
}
}
@@ -425,7 +234,8 @@ public actor NetSocketNew {
/// - value: The integer value to write
/// - endian: Byte order (default: big-endian)
/// - Throws: `NetSocketError` if write fails
- public func write<T: FixedWidthInteger>(_ value: T, endian: Endian = .big) async throws {
+ @discardableResult
+ public func write<T: FixedWidthInteger>(_ value: T, endian: Endian = .big) async throws -> Int {
var v = value
switch endian {
case .big: v = T(bigEndian: value)
@@ -437,155 +247,96 @@ public actor NetSocketNew {
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
- public func write(_ value: Bool) async throws {
- try await write(value ? UInt8(0x01) : UInt8(0x00))
+ @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)
- public func write(_ value: Float, endian: Endian = .big) async throws {
- try await write(value.bitPattern, endian: 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)
- public func write(_ value: Double, endian: Endian = .big) async throws {
- try await write(value.bitPattern, endian: 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
- /// - prefix: Optional length prefix (if provided, string is sent as a framed message)
/// - encoding: Text encoding (default: UTF-8)
+ /// - allowLossyConversion: Allow lossy encoding if necessary (default: false)
/// - 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 {
+ @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))
}
- if let prefix { try await sendFrame(data, prefix: prefix) }
- else { try await write(data) }
+ return try await write(data)
}
- // MARK: Frames & Codable
+ // MARK: Receive Data
- /// 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
+ /// Read data until a delimiter is found
///
- /// Uses the configured encoder (default: JSON) to serialize the value.
+ /// 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:
- /// - value: Value to encode and send
- /// - prefix: Length prefix type (default: u32 big-endian)
- /// - Throws: `NetSocketError.encodeFailed` if encoding fails
- public func send<T: Encodable>(_ value: T, prefix: LengthPrefix = .u32()) async throws {
- do {
- let data = try encodeValue(value)
- try await sendFrame(data, prefix: prefix)
- } catch {
- throw NetSocketError.encodeFailed(error)
+ /// - 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 }
}
}
- /// Receive and decode a length-prefixed value
+ /// Read exactly N bytes from the socket
///
- /// Uses the configured decoder (default: JSON) to deserialize the value.
+ /// 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.
///
- /// - 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<T: Decodable>(_ 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)
- }
+ /// - 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..<end]
+ self.head = end
+ self.compactIfNeeded()
+ return Data(slice)
}
-
- // MARK: Read typed & utilities
-
+
/// Read a fixed-width integer from the socket
///
/// - Parameters:
@@ -595,7 +346,7 @@ public actor NetSocketNew {
/// - Throws: `NetSocketError` if insufficient data or connection closed
public func read<T: FixedWidthInteger>(_ type: T.Type = T.self, endian: Endian = .big) async throws -> T {
let size = MemoryLayout<T>.size
- let data = try await read(size)
+ let data = try await self.read(size)
let value: T = data.withUnsafeBytes { raw in
raw.load(as: T.self)
}
@@ -613,8 +364,10 @@ public actor NetSocketNew {
/// - 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()) }
+ let data = try await self.read(length)
+ guard let s = String(data: data, encoding: encoding) else {
+ throw NetSocketError.decodeFailed(NSError())
+ }
return s
}
@@ -632,118 +385,6 @@ public actor NetSocketNew {
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..<end]
- head = end
- compactIfNeeded()
- return Data(slice)
- }
-
- /// 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 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.
@@ -782,236 +423,261 @@ public actor NetSocketNew {
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 }
+ // MARK: Peek Data
- private func waitForData() async throws {
- try Task.checkCancellation()
- try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
- if isClosed { cont.resume(); return }
- dataWaiters.append(cont)
+ 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
}
- 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()
+ 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)
}
- private func ensureReady() async throws {
- if isClosed { throw NetSocketError.closed }
- if !ready { try await waitUntilReady() }
+ 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
}
- private func compactIfNeeded() {
- // Avoid unbounded memory as head advances
- if head > 64 * 1024 && head > buffer.count / 2 {
- buffer.removeSubrange(0..<head)
- head = 0
- }
+ // 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()
}
- private func search(delimiter: Data) -> Range<Int>? {
- guard !delimiter.isEmpty, availableBytes >= delimiter.count else { return nil }
- let hay = buffer[head..<buffer.count]
-
- // Fast path for single-byte delimiters
- if delimiter.count == 1, let byte = delimiter.first {
- if let idx = hay.firstIndex(of: byte) {
- let pos = head + hay.distance(from: hay.startIndex, to: idx)
- return pos..<(pos + 1)
+ /// 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
}
- return nil
- }
-
- // General case
- if let r = hay.firstRange(of: delimiter) {
- let lower = head + hay.distance(from: hay.startIndex, to: r.lowerBound)
- let upper = head + hay.distance(from: hay.startIndex, to: r.upperBound)
- return lower..<upper
- }
-
- return nil
- }
-}
-
-// MARK: - Small helpers
-
-private extension Data {
- mutating func appendInteger<T: FixedWidthInteger>(_ 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: &copy) { ptr in
- self.append(contentsOf: UnsafeRawBufferPointer(start: ptr, count: MemoryLayout<T>.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)
+
+ // MARK: Files
+
+ /// Upload a file from a URL, yielding progress as an AsyncSequence.
///
- /// Reads and writes the file in chunks to limit memory usage. Each chunk waits for network
- /// backpressure via `.contentProcessed` before reading the next chunk.
+ /// 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: 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))
+ /// - 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<FileProgress, Error> {
+ // 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()
+ }
}
- return sent
}
- /// Upload a file as a length-prefixed frame without buffering the entire file in memory
+ /// Upload a file from an open FileHandle, yielding progress as an AsyncSequence.
///
- /// Sends the file size as a length prefix, then streams the file content in chunks.
- /// Memory-efficient for large files.
+ /// 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:
- /// - 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)
+ /// - 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<FileProgress, Error> {
+ precondition(length >= 0, "length must be >= 0")
- // 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)
+ if length == 0 {
+ return AsyncThrowingStream { continuation in
+ continuation.yield(.init(sent: 0, total: 0, bytesPerSecond: 0, estimatedTimeRemaining: 0))
+ continuation.finish()
+ }
}
- 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 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()
+ }
}
- return sent
}
- /// Download a length-prefixed file and write it to disk in chunks (bounded memory)
+ /// Receive a file of known length and yield progress updates as an AsyncSequence.
///
- /// 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.
+ /// 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:
- /// - 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() }
+ /// - 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<FileProgress, Error> {
+ precondition(length >= 0, "length must be >= 0")
- // 3) Stream chunks from the socket into the file
- var remaining = total64
- var written: Int64 = 0
+ if length == 0 {
+ return AsyncThrowingStream { continuation in
+ continuation.yield(.init(sent: 0, total: 0, bytesPerSecond: 0, estimatedTimeRemaining: 0))
+ continuation.finish()
+ }
+ }
- 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 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()
+ }
}
- 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.
+ /// 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.
@@ -1030,60 +696,53 @@ public extension NetSocketNew {
/// ```swift
/// // Hotline protocol: file size comes from transaction header
/// let transaction = try await socket.receive(HotlineTransaction.self)
- /// try await socket.receiveFileKnownLength(
+ /// try await socket.receiveFile(
/// to: destinationURL,
/// length: transaction.fileSize
/// )
/// ```
@discardableResult
- func receiveFileKnownLength(
+ func receiveFile(
to url: URL,
- length: Int64,
+ length: Int,
chunkSize: Int = 256 * 1024,
overwrite: Bool = true,
atomic: Bool = true,
progress: (@Sendable (FileProgress) -> Void)? = nil
- ) async throws -> Int64 {
+ ) async throws -> Int {
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
-
+ 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
-
+
+ var remaining: Int = length
+ var written: Int = 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)
+ 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 {
@@ -1091,20 +750,162 @@ public extension NetSocketNew {
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 {
+
+ // 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<Void, Error>) 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..<self.head)
+ self.head = 0
+ }
+ }
+
+ private func search(delimiter: Data) -> Range<Int>? {
+ guard !delimiter.isEmpty, availableBytes >= delimiter.count else { return nil }
+ let hay = buffer[head..<buffer.count]
+
+ // Fast path for single-byte delimiters
+ if delimiter.count == 1, let byte = delimiter.first {
+ if let idx = hay.firstIndex(of: byte) {
+ let pos = head + hay.distance(from: hay.startIndex, to: idx)
+ return pos..<(pos + 1)
+ }
+ return nil
+ }
+
+ // General case
+ if let r = hay.firstRange(of: delimiter) {
+ let lower = head + hay.distance(from: hay.startIndex, to: r.lowerBound)
+ let upper = head + hay.distance(from: hay.startIndex, to: r.upperBound)
+ return lower..<upper
+ }
+
+ return nil
+ }
+
+ private static 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(
@@ -1114,29 +915,79 @@ fileprivate extension NetSocketNew {
}
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 }
+ 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
+ private func waitUntilReady() async throws {
+ guard !self.ready else { return }
+ try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
+ self.readyWaiters.append(cont)
+ }
+ }
+
+ private func resumeReadyWaiters(with result: Result<Void, Error>) {
+ 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)
}
- }()
- if length > max {
- throw NetSocketError.framingExceeded(max: Int(max))
+ }
+ }
+
+ 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<T: FixedWidthInteger>(_ 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: &copy) { ptr in
+ self.append(contentsOf: UnsafeRawBufferPointer(start: ptr, count: MemoryLayout<T>.size))
}
}
}
-// MARK: - Stream-based Encoding/Decoding
+// MARK: - NetSocketEncodable
/// Protocol for types that can encode themselves to binary data
///
@@ -1243,7 +1094,7 @@ public extension NetSocketNew {
/// - Throws: Encoding or network errors
func send<T: NetSocketEncodable>(_ value: T, endian: Endian = .big) async throws {
let data = try value.encode(endian: endian)
- try await write(data)
+ try await self.write(data)
}
/// Receive and decode a value directly from the socket stream (no length prefix)
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/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<Void, Never>? = 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<String> = []
+
+ // 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<Void, Never>?
+ @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..<path.count-1])
+ }
+
+ return try await client.getFileInfo(name: fileName, path: fullPath)
+ }
+
+ @MainActor
+ func deleteFile(_ fileName: String, path: [String]) async throws -> Bool {
+ guard let client = self.client else {
+ throw HotlineClientError.notConnected
+ }
+
+ var fullPath: [String] = []
+ if path.count > 1 {
+ fullPath = Array(path[0..<path.count-1])
+ }
+
+ let success = try await client.deleteFile(name: fileName, path: fullPath)
+
+ if success {
+ self.invalidateFileListCache(for: fullPath, includingAncestors: true)
+ }
+
+ 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..<path.count-1])
+// }
+//
+// Task { @MainActor in
+// guard let result = try? await client.downloadFile(name: fileName, path: fullPath),
+// let server = self.server,
+// let address = server.address as String?,
+// let port = server.port as Int?
+// else {
+// return
+// }
+//
+// let fileClient = HotlineFileDownloadClient(
+// address: address,
+// port: UInt16(port),
+// reference: result.referenceNumber,
+// size: UInt32(result.transferSize)
+// )
+// fileClient.delegate = self
+// self.downloads.append(fileClient)
+//
+// let transfer = TransferInfo(
+// id: result.referenceNumber,
+// title: fileName,
+// size: UInt(result.transferSize),
+// serverID: self.id,
+// serverName: self.serverName ?? self.serverTitle
+// )
+// transfer.downloadCallback = callback
+// transfer.progressCallback = progressCallback
+// AppState.shared.transfers.append(transfer)
+//
+// if let destination {
+// fileClient.start(to: destination)
+// } else {
+// fileClient.start()
+// }
+// }
+// }
+
+ /// Modern async/await file download using HotlineFileDownloadClientNew
+ ///
+ /// This is a cleaner implementation that uses the new async/await download client.
+ /// Unlike the old version, it doesn't use delegates and returns the file URL directly.
+ ///
+ /// - Parameters:
+ /// - fileName: Name of the file to download
+ /// - path: Path components to the file (includes the filename as last component)
+ /// - destination: Optional destination URL. If nil, downloads to Downloads folder.
+ /// - 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) {
+ guard let client = self.client else { return }
+
+ var fullPath: [String] = []
+ if path.count > 1 {
+ fullPath = Array(path[0..<path.count-1])
+ }
+
+ 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 server = self.server,
+ let address = server.address as String?,
+ let port = server.port as Int?
+ else {
+ return
+ }
+
+ let referenceNumber = result.referenceNumber
+
+ // Create transfer info for tracking (stored globally in AppState)
+ let transfer = TransferInfo(
+ reference: referenceNumber,
+ title: fileName,
+ size: UInt(result.transferSize),
+ serverID: self.id,
+ serverName: self.serverName ?? self.serverTitle
+ )
+ transfer.downloadCallback = callback
+ transfer.progressCallback = progressCallback
+ AppState.shared.addTransfer(transfer)
+
+ // Create download client
+ let downloadClient = HotlineFileDownloadClientNew(
+ address: address,
+ port: UInt16(port),
+ reference: referenceNumber,
+ size: UInt32(result.transferSize)
+ )
+
+ // Create and store the download task
+ let downloadTask = Task { @MainActor [weak self] in
+ guard self != nil else { return }
+
+ do {
+ let fileURL: URL
+
+ // Download file with progress tracking
+ let location: HotlineDownloadLocation = if let destination {
+ .url(destination)
+ } else {
+ .downloads(fileName)
+ }
+
+ fileURL = try await downloadClient.download(to: location) { progress in
+ switch progress {
+ case .preparing:
+ break
+ case .unconnected, .connected, .connecting:
+ transfer.progressCallback?(transfer)
+ 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: let url):
+ transfer.completed = true
+ transfer.fileURL = url
+ }
+ }
+
+ // Mark as completed
+ transfer.progress = 1.0
+
+ // Call completion callback
+ transfer.downloadCallback?(transfer)
+
+ fileURL.notifyDownloadFinished()
+
+ print("HotlineState: Download complete - \(fileURL.path)")
+
+ } catch is CancellationError {
+ // Download was cancelled
+ print("HotlineState: Download cancelled")
+ } catch {
+ // Mark as failed
+ transfer.failed = true
+ print("HotlineState: Download failed - \(error)")
+ }
+
+ AppState.shared.unregisterTransferTask(for: transfer.id)
+ }
+
+ // Store the task in AppState so it can be cancelled later
+ AppState.shared.registerTransferTask(downloadTask, transferID: transfer.id)
+ }
+ }
+
+ /// Cancel all downloads for this server only
+// @MainActor
+// func cancelAllDownloads() {
+// let myTransfers = self.transfers
+// for transfer in myTransfers {
+// AppState.shared.cancelDownload(transfer.id)
+// }
+// }
+
+// @MainActor
+// func downloadFolder(_ folderName: String, path: [String], complete callback: ((TransferInfo) -> Void)? = nil) {
+// guard let client = self.client else { return }
+//
+// var fullPath: [String] = []
+// if path.count > 1 {
+// fullPath = Array(path[0..<path.count-1])
+// }
+//
+// Task { @MainActor in
+// guard let result = try? await client.downloadFolder(name: folderName, path: fullPath),
+// let server = self.server,
+// let address = server.address as String?,
+// let port = server.port as Int?
+// else {
+// return
+// }
+//
+// let folderClient = HotlineFolderDownloadClient(
+// address: address,
+// port: UInt16(port),
+// reference: result.referenceNumber,
+// size: UInt32(result.transferSize),
+// itemCount: result.itemCount
+// )
+// folderClient.delegate = self
+// self.downloads.append(folderClient)
+//
+// let transfer = TransferInfo(
+// id: result.referenceNumber,
+// title: folderName,
+// size: UInt(result.transferSize),
+// serverID: self.id,
+// serverName: self.serverName ?? self.serverTitle
+// )
+// transfer.isFolder = true
+// transfer.downloadCallback = callback
+// AppState.shared.transfers.append(transfer)
+//
+// folderClient.start()
+// }
+// }
+
+ /// Modern async/await folder download using HotlineFolderDownloadClientNew
+ ///
+ /// This is a cleaner implementation that uses the new async/await download client.
+ /// Unlike the old version, it doesn't use delegates and returns the folder URL directly.
+ ///
+ /// - Parameters:
+ /// - folderName: Name of the folder to download
+ /// - path: Path components to the folder (includes the foldername as last component)
+ /// - destination: Optional destination URL. If nil, downloads to Downloads folder.
+ /// - progressCallback: Optional callback for progress updates (receives TransferInfo)
+ /// - 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(
+ _ folderName: String,
+ path: [String],
+ to destination: URL? = nil,
+ progress progressCallback: ((TransferInfo) -> 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..<path.count-1])
+ }
+
+ Task { @MainActor [weak self] in
+ guard let self else { return }
+
+ // Request folder download from server
+ guard let result = try? await client.downloadFolder(name: folderName, path: fullPath),
+ let server = self.server,
+ let address = server.address as String?,
+ let port = server.port as Int?
+ else {
+ return
+ }
+
+ let referenceNumber = result.referenceNumber
+
+ // Create transfer info for tracking (stored globally in AppState)
+ let transfer = TransferInfo(
+ reference: referenceNumber,
+ title: folderName,
+ size: UInt(result.transferSize),
+ serverID: self.id,
+ serverName: self.serverName ?? self.serverTitle
+ )
+ transfer.isFolder = true
+ transfer.downloadCallback = callback
+ transfer.progressCallback = progressCallback
+ AppState.shared.addTransfer(transfer)
+
+ // Create download client
+ let downloadClient = HotlineFolderDownloadClientNew(
+ address: address,
+ port: UInt16(port),
+ reference: referenceNumber,
+ size: UInt32(result.transferSize),
+ itemCount: result.itemCount
+ )
+
+ // Create and store the download task
+ let downloadTask = Task { @MainActor [weak self] in
+ guard self != nil else { return }
+
+ do {
+ let folderURL: URL
+
+ // Download folder with progress tracking
+ let location: HotlineDownloadLocation = if let destination {
+ .url(destination)
+ } else {
+ .downloads(folderName)
+ }
+
+ folderURL = try await downloadClient.download(to: location, progress: { progress in
+ switch progress {
+ case .preparing:
+ break
+ case .unconnected, .connected, .connecting:
+ transfer.progressCallback?(transfer)
+ 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: let url):
+ 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)
+ })
+
+ // Mark as completed
+ transfer.progress = 1.0
+ transfer.title = folderName // Reset title to folder name
+
+ // Call completion callback
+ transfer.downloadCallback?(transfer)
+
+ folderURL.notifyDownloadFinished()
+
+ print("HotlineState: Folder download complete - \(folderURL.path)")
+
+ } catch is CancellationError {
+ // Download was cancelled
+ print("HotlineState: Folder download cancelled")
+ } catch {
+ // Mark as failed
+ transfer.failed = true
+ print("HotlineState: Folder download failed - \(error)")
+ }
+
+ AppState.shared.unregisterTransferTask(for: transfer.id)
+ }
+
+ // Store transfer
+ AppState.shared.registerTransferTask(downloadTask, transferID: transfer.id)
+ }
+ }
+
+ /// Modern async/await folder upload using HotlineFolderUploadClientNew
+ ///
+ /// - Parameters:
+ /// - folderURL: URL to the folder on disk to upload
+ /// - path: Destination path on the server where the folder should be uploaded
+ /// - progressCallback: Optional callback for progress updates (receives TransferInfo)
+ /// - itemProgressCallback: Optional callback for per-item updates (receives TransferInfo with current file info)
+ /// - callback: Optional completion callback (receives TransferInfo when upload is complete)
+ @MainActor
+ func uploadFolder(
+ url folderURL: URL,
+ path: [String],
+ progress progressCallback: ((TransferInfo) -> 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..<path.count-1])
+ }
+
+ Task { @MainActor in
+ guard let result = try? await client.downloadFile(name: fileName, path: fullPath, preview: true),
+ let server = self.server,
+ let address = server.address as String?,
+ let port = server.port as Int?
+ else {
+ callback?(nil)
+ return
+ }
+
+ let info = PreviewFileInfo(
+ id: result.referenceNumber,
+ address: address,
+ port: port,
+ size: result.transferSize,
+ name: fileName
+ )
+
+ callback?(info)
+ }
+ }
+
+ // MARK: - User Administration
+
+ @MainActor
+ func getAccounts() async throws -> [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..<range.lowerBound]), at: 0)
+ } else {
+ self.messageBoard.insert(message, at: 0)
+ }
+
+ SoundEffectPlayer.shared.playSoundEffect(.newNews)
+ }
+
+ // MARK: - User Management
+
+ private func addOrUpdateHotlineUser(_ user: HotlineUser) {
+ print("HotlineState: users: \n\(self.users)")
+
+ if let i = self.users.firstIndex(where: { $0.id == user.id }) {
+ print("HotlineState: 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("HotlineState: 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)
+ }
+ }
+ }
+
+ // MARK: - Chat Persistence
+
+ 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) {
+ 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..<excess {
+ let key = sortedKeys[index].key
+ self.fileListCache.removeValue(forKey: key)
+ }
+ }
+
+ private func invalidateFileListCache(for path: [String], includingAncestors: Bool = false) {
+ guard !self.fileListCache.isEmpty else {
+ return
+ }
+
+ var currentPath = path
+ while true {
+ let key = self.searchPathKey(for: currentPath)
+ self.fileListCache.removeValue(forKey: key)
+
+ if !includingAncestors || currentPath.isEmpty {
+ break
+ }
+
+ currentPath.removeLast()
+ if currentPath.isEmpty {
+ let rootKey = self.searchPathKey(for: currentPath)
+ self.fileListCache.removeValue(forKey: rootKey)
+ break
+ }
+ }
+ }
+
+ // MARK: - Transfer Delegates
+
+// nonisolated func hotlineTransferStatusChanged(client: HotlineTransferClient, reference: UInt32, status: HotlineTransferStatus, timeRemaining: TimeInterval) {
+// Task { @MainActor in
+// // Update transfer info with progress
+// guard let transferIndex = self.transfers.firstIndex(where: { $0.id == reference }) else {
+// return
+// }
+//
+// let transfer = self.transfers[transferIndex]
+// transfer.timeRemaining = timeRemaining
+//
+// switch status {
+// case .progress(let p):
+// transfer.progress = p
+// transfer.progressCallback?(transfer)
+// case .completed:
+// transfer.completed = true
+// transfer.progress = 1.0
+// case .failed(_):
+// transfer.failed = true
+// default:
+// break
+// }
+// }
+// }
+
+// nonisolated func hotlineFileDownloadReceivedInfo(client: HotlineFileDownloadClient, reference: UInt32, info: HotlineFileInfoFork) {
+// // Info fork received (file metadata)
+// }
+
+// nonisolated func hotlineFileDownloadComplete(client: HotlineFileDownloadClient, reference: UInt32, at url: URL) {
+// Task { @MainActor in
+// // Find and remove the download client
+// if let downloadIndex = self.downloads.firstIndex(where: { $0.referenceNumber == reference }) {
+// self.downloads.remove(at: downloadIndex)
+// }
+//
+// // Find and complete the transfer
+// guard let transferIndex = AppState.shared.transfers.firstIndex(where: { $0.id == reference }) else {
+// return
+// }
+//
+// let transfer = AppState.shared.transfers[transferIndex]
+// transfer.fileURL = url
+// transfer.downloadCallback?(transfer)
+// AppState.shared.transfers.remove(at: transferIndex)
+// }
+// }
+
+// nonisolated func hotlineFolderDownloadReceivedFileInfo(client: HotlineFolderDownloadClient, reference: UInt32, fileName: String, itemNumber: Int, totalItems: Int) {
+// Task { @MainActor in
+// // Update transfer info with current file being downloaded
+// guard let transferIndex = AppState.shared.transfers.firstIndex(where: { $0.id == reference }) else {
+// return
+// }
+//
+// let transfer = AppState.shared.transfers[transferIndex]
+// transfer.title = "\(fileName) (\(itemNumber)/\(totalItems))"
+// }
+// }
+
+// nonisolated func hotlineFolderDownloadComplete(client: HotlineFolderDownloadClient, reference: UInt32, at url: URL) {
+// Task { @MainActor in
+// // Find and remove the download client
+// if let downloadIndex = self.downloads.firstIndex(where: { $0.referenceNumber == reference }) {
+// self.downloads.remove(at: downloadIndex)
+// }
+//
+// // Find and complete the transfer
+// guard let transferIndex = AppState.shared.transfers.firstIndex(where: { $0.id == reference }) else {
+// return
+// }
+//
+// let transfer = AppState.shared.transfers[transferIndex]
+// transfer.fileURL = url
+// transfer.downloadCallback?(transfer)
+// AppState.shared.transfers.remove(at: transferIndex)
+// }
+// }
+}
+
+// MARK: - File Search Session
+
+@MainActor
+final class HotlineStateFileSearchSession {
+ private struct FolderTask {
+ let path: [String]
+ let depth: Int
+ let isHot: Bool
+ }
+
+ private weak var hotlineState: HotlineState?
+ private let queryTokens: [String]
+ private let config: FileSearchConfig
+
+ private var queue: [FolderTask] = []
+ private var visited: Set<String> = []
+ 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..<self.queue.count {
+ let candidate = self.queue[index]
+ if candidate.depth == currentDepth {
+ lastSameDepthIndex = index
+ if candidate.isHot {
+ hotIndices.append(index)
+ }
+ } else {
+ break
+ }
+ }
+
+ let selectionPool: [Int]
+ if !hotIndices.isEmpty {
+ selectionPool = hotIndices
+ } else {
+ selectionPool = Array(0...lastSameDepthIndex)
+ }
+
+ let randomIndex = selectionPool.randomElement() ?? 0
+ return self.queue.remove(at: randomIndex)
+ }
+
+ private func shouldSkip(path: [String], depth: Int) -> 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<Void, Never>] = [:]
+
+ /// 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<Void, Never>, 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<Void, Never>?
+
+ 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<Hotline> {
+ private var bindableModel: Bindable<HotlineState> {
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..<file.path.count-1])
}
-
- if await model.deleteFile(file.name, path: file.path) {
- let _ = await model.getFileList(path: parentPath)
+
+ if (try? await model.deleteFile(file.name, path: file.path)) == true {
+ let _ = try? await model.getFileList(path: parentPath)
}
}
var body: some View {
NavigationStack {
- List(displayedFiles, id: \.self, selection: $selection) { file in
+ List(self.displayedFiles, id: \.self, selection: self.$selection) { file in
if file.isFolder {
FolderItemView(file: file, depth: 0).tag(file.id)
}
@@ -140,9 +166,36 @@ struct FilesView: View {
.environment(\.defaultMinListRowHeight, 28)
.listStyle(.inset)
.alternatingRowBackgrounds(.enabled)
+ .onDrop(of: [.fileURL], isTargeted: self.$dragOver) { items in
+ guard self.model.access?.contains(.canUploadFiles) == true,
+ 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) {
+
+ // Access security-scoped resource for drag-and-drop
+ let didStartAccessing = fileURL.startAccessingSecurityScopedResource()
+ defer {
+ if didStartAccessing {
+ fileURL.stopAccessingSecurityScopedResource()
+ }
+ }
+
+ self.upload(file: fileURL, to: [])
+ }
+ }
+ }
+
+ return true
+ }
.task {
- if !model.filesLoaded {
- let _ = await model.getFileList()
+ if !self.model.filesLoaded {
+ let _ = try? await self.model.getFileList()
}
}
.contextMenu(forSelectionType: FileInfo.self) { items in
@@ -246,7 +299,7 @@ struct FilesView: View {
Label("Preview", systemImage: "eye")
}
.help("Preview")
- .disabled(selection == nil || selection?.isPreviewable == false)
+ .disabled(selection == nil || selection?.isPreviewable != true)
Button {
if let selectedFile = selection {
@@ -264,10 +317,7 @@ struct FilesView: View {
Label("Upload", systemImage: "arrow.up")
}
.help("Upload")
- .disabled(
- model.access?.contains(.canUploadFiles) != true ||
- (model.fileSearchStatus.isActive && !(selection?.isFolder ?? false))
- )
+ .disabled(model.access?.contains(.canUploadFiles) != true)
Button {
if let selectedFile = selection {
@@ -284,17 +334,15 @@ struct FilesView: View {
.sheet(item: $fileDetails ) { item in
FileDetailsView(fd: item)
}
- .fileImporter(isPresented: $uploadFileSelectorDisplayed, allowedContentTypes: [.data], allowsMultipleSelection: false, onCompletion: { results in
+ .fileImporter(isPresented: $uploadFileSelectorDisplayed, allowedContentTypes: [.data, .folder], allowsMultipleSelection: false, onCompletion: { results in
switch results {
case .success(let fileURLS):
- guard fileURLS.count > 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)
+}