diff options
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>'HTbm'</string> + <string>'HTbm'</string> </array> </dict> </array> @@ -46,7 +46,7 @@ <key>UTTypeIconFiles</key> <array/> <key>UTTypeIdentifier</key> - <string>'HTbm'</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: ©) { 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: ©) { 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) +} |