From 4bb0ffba596e41a8309ba50d222248a0ba3eb42e Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Mon, 27 Oct 2025 21:48:20 -0700 Subject: Random project housekeeping/organizing source files. --- Hotline/macOS/Files/FilesView.swift | 418 ++++++++++++++++++++++++++++++++++++ 1 file changed, 418 insertions(+) create mode 100644 Hotline/macOS/Files/FilesView.swift (limited to 'Hotline/macOS/Files/FilesView.swift') diff --git a/Hotline/macOS/Files/FilesView.swift b/Hotline/macOS/Files/FilesView.swift new file mode 100644 index 0000000..de699ff --- /dev/null +++ b/Hotline/macOS/Files/FilesView.swift @@ -0,0 +1,418 @@ +import SwiftUI +import UniformTypeIdentifiers +import AppKit + + + + + +struct FilesView: View { + @Environment(Hotline.self) private var model: Hotline + @Environment(\.openWindow) private var openWindow + + @State private var selection: FileInfo? + @State private var fileDetails: FileDetails? + @State private var uploadFileSelectorDisplayed: Bool = false + @State private var searchText: String = "" + @State private var isSearching: Bool = false + + private var isShowingSearchResults: Bool { + switch model.fileSearchStatus { + case .idle: + return !model.fileSearchResults.isEmpty + case .cancelled(_): + return !model.fileSearchResults.isEmpty + default: + return true + } + } + + private var displayedFiles: [FileInfo] { + isShowingSearchResults ? model.fileSearchResults : model.files + } + + private var searchStatusMessage: String? { + switch model.fileSearchStatus { + case .searching(let processed, _): + let scanned = processed == 1 ? "folder" : "folders" + return "Searched \(processed) \(scanned)..." + case .completed(let processed): + let count = model.fileSearchResults.count + let folderWord = processed == 1 ? "folder" : "folders" + if count == 0 { + return "No files found in \(processed) \(folderWord)" + } + return "\(count) file\(count == 1 ? "" : "s") found in \(processed) \(folderWord)" + case .cancelled(_): + if model.fileSearchResults.isEmpty { + return nil + } + return "Search cancelled" + case .failed(let message): + return "Search failed: \(message)" + case .idle: + return nil + } + } + + private var searchStatusPath: String? { + guard let path = model.fileSearchCurrentPath else { + return nil + } + if path.isEmpty { + return "/" + } + return path.joined(separator: "/") + } + + private func openPreviewWindow(_ previewInfo: PreviewFileInfo) { + switch previewInfo.previewType { + case .image: + openWindow(id: "preview-image", value: previewInfo) + case .text: + openWindow(id: "preview-text", value: previewInfo) + default: + return + } + } + + @MainActor private func getFileInfo(_ file: FileInfo) { + Task { + if let fileInfo = await model.getFileDetails(file.name, path: file.path) { + Task { @MainActor in + self.fileDetails = fileInfo + } + } + } + } + + @MainActor private func downloadFile(_ file: FileInfo) { + if file.isFolder { + model.downloadFolder(file.name, path: file.path) + } + else { + model.downloadFile(file.name, path: file.path) + } + } + + @MainActor private func uploadFile(file fileURL: URL, to path: [String]) { + model.uploadFile(url: fileURL, path: path) { info in + Task { + // Refresh file listing to display newly uploaded file. + let _ = await model.getFileList(path: path) + } + } + } + + @MainActor private func previewFile(_ file: FileInfo) { + guard file.isPreviewable else { + return + } + + model.previewFile(file.name, path: file.path) { info in + if let info = info { + openPreviewWindow(info) + } + } + } + + private func deleteFile(_ file: FileInfo) async { + var parentPath: [String] = [] + if file.path.count > 1 { + parentPath = Array(file.path[0.. 0 else { + return + } + + let fileURL = fileURLS.first! + + print(fileURL) + + var uploadPath: [String] = [] + + if let selection = selection { + if selection.isFolder { + uploadPath = selection.path + } + else { + uploadPath = Array(selection.path) + uploadPath.removeLast() + } + } + + print("UPLOAD PATH: \(uploadPath)") + uploadFile(file: fileURL, to: uploadPath) + + case .failure(let error): + print(error) + } + }) + .onSubmit(of: .search) { + #if os(macOS) + let shiftPressed = NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false + if shiftPressed { + model.clearFileListCache() + } + #endif + + let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + model.cancelFileSearch() + return + } + searchText = trimmed + model.startFileSearch(query: trimmed) + } + .onChange(of: searchText) { _, newValue in + if newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + if isShowingSearchResults { + model.cancelFileSearch() + } + } + } + .onChange(of: model.fileSearchQuery) { _, newValue in + if newValue != searchText { + searchText = newValue + } + } + .onAppear { + if searchText != model.fileSearchQuery { + searchText = model.fileSearchQuery + } + } + .safeAreaInset(edge: .top) { + if isShowingSearchResults, let message = searchStatusMessage { + HStack(alignment: .center, spacing: 6) { + if case .searching(_, _) = model.fileSearchStatus { + ProgressView() + .controlSize(.small) + .accentColor(.white) + .tint(.white) + } + else if case .completed = model.fileSearchStatus { + Image(systemName: "checkmark.circle.fill") + .resizable() + .symbolRenderingMode(.monochrome) + .foregroundStyle(.white) + .aspectRatio(contentMode: .fit) + .frame(width: 16, height: 16) + } + else if case .failed = model.fileSearchStatus { + Image(systemName: "exclamationmark.triangle.fill") + .resizable() + .symbolRenderingMode(.monochrome) + .foregroundStyle(.white) + .aspectRatio(contentMode: .fit) + .frame(width: 16, height: 16) + } + + Text(message) + .lineLimit(1) + .font(.body) + .foregroundStyle(.white) + + Spacer() + + if let pathMessage = searchStatusPath { + Text(pathMessage) + .lineLimit(1) + .truncationMode(.tail) + .font(.footnote) +// .fontWeight(.semibold) + .foregroundStyle(.white) + .opacity(0.5) + .padding(.top, 2) + } + } + .padding(.trailing, 14) + .padding(.leading, 8) + .padding(.vertical, 8) + .background { + Group { + if case .completed = model.fileSearchStatus { + Color.fileComplete + } + else { + Color(nsColor: .controlAccentColor) + } + } + .clipShape(.capsule(style: .continuous)) + } + .padding(.horizontal, 8) + .padding(.top, 8) + } + } + } +} + +#Preview { + FilesView() + .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) +} -- cgit From 87f08cf60a5d7c1cf618463916cbac4dab88e0f8 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 7 Nov 2025 10:19:42 -0800 Subject: Massive refactor of transfer cients (new folder upload implementation), brand new async version of NetSocket, and a rewritten Hotline view model. New cross-server transfers UI. --- Hotline.xcodeproj/project.pbxproj | 86 +- .../xcshareddata/xcschemes/Hotline.xcscheme | 1 + Hotline/Hotline/HotlineClientNew.swift | 1061 ++++++ Hotline/Hotline/HotlineExtensions.swift | 143 +- Hotline/Hotline/HotlineProtocol.swift | 317 +- Hotline/Hotline/HotlineTransferClient.swift | 3618 ++++++++++---------- .../Transfers/HotlineFileDownloadClientNew.swift | 291 ++ .../Transfers/HotlineFilePreviewClientNew.swift | 163 + .../Transfers/HotlineFileUploadClientNew.swift | 265 ++ .../Transfers/HotlineFolderDownloadClientNew.swift | 486 +++ .../Transfers/HotlineFolderUploadClientNew.swift | 538 +++ Hotline/Info.plist | 6 +- Hotline/Library/HotlinePanel.swift | 6 +- Hotline/Library/NetSocket/FileProgress.swift | 58 + Hotline/Library/NetSocket/NetSocketNew.swift | 1129 ++++++ .../Library/NetSocket/TransferRateEstimator.swift | 135 + Hotline/Library/NetSocketNew.swift | 1278 ------- Hotline/Library/QuickLookPreviewView.swift | 22 + Hotline/Library/URLAdditions.swift | 29 + Hotline/MacApp.swift | 37 +- Hotline/Models/FileDetails.swift | 4 +- Hotline/Models/FileInfo.swift | 16 +- Hotline/Models/FilePreview.swift | 226 +- Hotline/Models/Hotline.swift | 8 +- Hotline/Models/HotlineState.swift | 2468 +++++++++++++ Hotline/Models/PreviewFileInfo.swift | 6 - Hotline/Models/TransferInfo.swift | 26 +- Hotline/State/AppState.swift | 58 +- Hotline/State/FilePreviewState.swift | 207 ++ Hotline/State/ServerState.swift | 4 +- Hotline/Utility/ColorArt.swift | 147 +- Hotline/Utility/SwiftUIExtensions.swift | 25 - Hotline/iOS/ChatView.swift | 2 +- Hotline/iOS/ServerView.swift | 2 +- Hotline/macOS/Accounts/AccountManagerView.swift | 44 +- Hotline/macOS/Board/MessageBoardEditorView.swift | 14 +- Hotline/macOS/Board/MessageBoardView.swift | 6 +- Hotline/macOS/Chat/ChatView.swift | 77 +- Hotline/macOS/Files/FileDetailsView.swift | 6 +- Hotline/macOS/Files/FileItemView.swift | 2 +- Hotline/macOS/Files/FilePreviewImageView.swift | 11 +- Hotline/macOS/Files/FilePreviewQuickLookView.swift | 131 + Hotline/macOS/Files/FilePreviewTextView.swift | 9 +- Hotline/macOS/Files/FilesView.swift | 103 +- Hotline/macOS/Files/FolderItemView.swift | 6 +- Hotline/macOS/HotlinePanelView.swift | 52 +- Hotline/macOS/MessageView.swift | 10 +- Hotline/macOS/News/NewsEditorView.swift | 19 +- Hotline/macOS/News/NewsItemView.swift | 8 +- Hotline/macOS/News/NewsView.swift | 12 +- Hotline/macOS/ServerView.swift | 165 +- Hotline/macOS/Settings/IconSettingsView.swift | 2 +- Hotline/macOS/TransfersView.swift | 169 + 53 files changed, 10126 insertions(+), 3588 deletions(-) create mode 100644 Hotline/Hotline/HotlineClientNew.swift create mode 100644 Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift create mode 100644 Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift create mode 100644 Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift create mode 100644 Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift create mode 100644 Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift create mode 100644 Hotline/Library/NetSocket/FileProgress.swift create mode 100644 Hotline/Library/NetSocket/NetSocketNew.swift create mode 100644 Hotline/Library/NetSocket/TransferRateEstimator.swift delete mode 100644 Hotline/Library/NetSocketNew.swift create mode 100644 Hotline/Library/QuickLookPreviewView.swift create mode 100644 Hotline/Models/HotlineState.swift create mode 100644 Hotline/State/FilePreviewState.swift create mode 100644 Hotline/macOS/Files/FilePreviewQuickLookView.swift create mode 100644 Hotline/macOS/TransfersView.swift (limited to 'Hotline/macOS/Files/FilesView.swift') diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index 03f170c..a234fbc 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -22,8 +22,13 @@ DA32CD4B2B29318E0053B98B /* FileInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32CD4A2B29318E0053B98B /* FileInfo.swift */; }; DA32CD4D2B2931B50053B98B /* ChatMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32CD4C2B2931B50053B98B /* ChatMessage.swift */; }; DA32CD4F2B2931CC0053B98B /* NewsInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32CD4E2B2931CC0053B98B /* NewsInfo.swift */; }; + DA3429AE2EB9C0280010784E /* HotlineFileUploadClientNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429AD2EB9C0220010784E /* HotlineFileUploadClientNew.swift */; }; + DA3429B02EBA70790010784E /* HotlineFolderUploadClientNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429AF2EBA70790010784E /* HotlineFolderUploadClientNew.swift */; }; + DA3429B32EBA7ADF0010784E /* HotlineFilePreviewClientNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429B22EBA7ADE0010784E /* HotlineFilePreviewClientNew.swift */; }; + DA3429B52EBA8A450010784E /* FilePreviewState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429B42EBA8A450010784E /* FilePreviewState.swift */; }; + DA3429B72EBAB1750010784E /* QuickLookPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429B62EBAB1750010784E /* QuickLookPreviewView.swift */; platformFilters = (macos, ); }; + DA3429B92EBAB2130010784E /* FilePreviewQuickLookView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429B82EBAB2130010784E /* FilePreviewQuickLookView.swift */; }; DA43205E2B1D615600FC8843 /* ServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA43205D2B1D615600FC8843 /* ServerView.swift */; platformFilter = ios; }; - DA4930BD2B4F8DD700822D0B /* NetSocket.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4930BC2B4F8DD700822D0B /* NetSocket.swift */; }; DA4B8F3A2EA6FB3C00CBFD53 /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4B8F392EA6FB3C00CBFD53 /* iOSApp.swift */; platformFilter = ios; }; DA4F2BF82B16A17200D8ADDC /* HotlineProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4F2BF72B16A17200D8ADDC /* HotlineProtocol.swift */; }; DA4F2C012B1A558E00D8ADDC /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4F2C002B1A558E00D8ADDC /* ChatView.swift */; platformFilter = ios; }; @@ -34,13 +39,19 @@ DA5268A52EB0743000DCB941 /* FileItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268A42EB0743000DCB941 /* FileItemView.swift */; platformFilters = (macos, ); }; DA5268AB2EB11EA300DCB941 /* ColorArt.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268AA2EB11EA300DCB941 /* ColorArt.swift */; }; DA5268AD2EB12FE200DCB941 /* ServerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268AC2EB12FE200DCB941 /* ServerState.swift */; }; + DA5268AF2EB2682B00DCB941 /* HotlineClientNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268AE2EB2682B00DCB941 /* HotlineClientNew.swift */; }; + DA5268B12EB2708E00DCB941 /* HotlineState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268B02EB2708E00DCB941 /* HotlineState.swift */; }; + DA5268B32EB6806E00DCB941 /* HotlineFileDownloadClientNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268B22EB6806E00DCB941 /* HotlineFileDownloadClientNew.swift */; }; + DA5268B52EB6840A00DCB941 /* TransfersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268B42EB6840A00DCB941 /* TransfersView.swift */; }; + DA5268B82EB916AF00DCB941 /* TransferRateEstimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268B72EB916AF00DCB941 /* TransferRateEstimator.swift */; }; + DA5268BA2EB91B5E00DCB941 /* FileProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268B92EB91B5E00DCB941 /* FileProgress.swift */; }; + DA5268BC2EB95A7700DCB941 /* HotlineFolderDownloadClientNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268BB2EB95A7700DCB941 /* HotlineFolderDownloadClientNew.swift */; }; DA55AC732BE42AF000034857 /* AsyncLinkPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA55AC722BE42AF000034857 /* AsyncLinkPreview.swift */; platformFilters = (macos, ); }; DA55AC752BE4888300034857 /* InstantMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA55AC742BE4888300034857 /* InstantMessage.swift */; }; DA55AC772BE589F700034857 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA55AC762BE589F700034857 /* AboutView.swift */; platformFilters = (macos, ); }; DA55AC792BE6A1AD00034857 /* RegularExpressions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA55AC782BE6A1AD00034857 /* RegularExpressions.swift */; }; DA5753682B33E88A00FAC277 /* HotlineTransferClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5753672B33E88A00FAC277 /* HotlineTransferClient.swift */; }; DA57536C2B36BA1D00FAC277 /* TextDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA57536B2B36BA1D00FAC277 /* TextDocument.swift */; }; - DA6300972B24036B0034CBFD /* HotlineClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6300962B24036B0034CBFD /* HotlineClient.swift */; }; DA65499A2BEC280E00EDB697 /* ServerMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6549992BEC280E00EDB697 /* ServerMessageView.swift */; }; DA65499C2BEC3FBD00EDB697 /* ServerAgreementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA65499B2BEC3FBD00EDB697 /* ServerAgreementView.swift */; }; DA65499E2BEC438A00EDB697 /* NSWindowBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA65499D2BEC438A00EDB697 /* NSWindowBridge.swift */; platformFilters = (macos, ); }; @@ -90,7 +101,6 @@ DACCE5EF2EAC19C9008CDD92 /* SwiftUIIntrospect in Frameworks */ = {isa = PBXBuildFile; productRef = DACCE5EE2EAC19C9008CDD92 /* SwiftUIIntrospect */; }; DADDB28B2B22B31F0024040D /* Tracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADDB28A2B22B31F0024040D /* Tracker.swift */; }; DADDB28D2B22B5920024040D /* Server.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADDB28C2B22B5920024040D /* Server.swift */; }; - DADDB28F2B238D850024040D /* Hotline.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADDB28E2B238D850024040D /* Hotline.swift */; }; DAE136BA2B9D1147007D8307 /* HotlinePanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAE136B92B9D1147007D8307 /* HotlinePanelView.swift */; platformFilters = (macos, ); }; DAE734F92B2E4185000C56F6 /* ServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAE734F82B2E4185000C56F6 /* ServerView.swift */; platformFilters = (macos, ); }; DAE734FB2B2E41F9000C56F6 /* TrackerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAE734FA2B2E41F9000C56F6 /* TrackerView.swift */; platformFilters = (macos, ); }; @@ -120,6 +130,12 @@ DA32CD4A2B29318E0053B98B /* FileInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileInfo.swift; sourceTree = ""; }; DA32CD4C2B2931B50053B98B /* ChatMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessage.swift; sourceTree = ""; }; DA32CD4E2B2931CC0053B98B /* NewsInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsInfo.swift; sourceTree = ""; }; + DA3429AD2EB9C0220010784E /* HotlineFileUploadClientNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFileUploadClientNew.swift; sourceTree = ""; }; + DA3429AF2EBA70790010784E /* HotlineFolderUploadClientNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFolderUploadClientNew.swift; sourceTree = ""; }; + DA3429B22EBA7ADE0010784E /* HotlineFilePreviewClientNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFilePreviewClientNew.swift; sourceTree = ""; }; + DA3429B42EBA8A450010784E /* FilePreviewState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePreviewState.swift; sourceTree = ""; }; + DA3429B62EBAB1750010784E /* QuickLookPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickLookPreviewView.swift; sourceTree = ""; }; + DA3429B82EBAB2130010784E /* FilePreviewQuickLookView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePreviewQuickLookView.swift; sourceTree = ""; }; DA43205D2B1D615600FC8843 /* ServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerView.swift; sourceTree = ""; }; DA4930BC2B4F8DD700822D0B /* NetSocket.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetSocket.swift; sourceTree = ""; }; DA4B8F392EA6FB3C00CBFD53 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; }; @@ -132,6 +148,13 @@ DA5268A42EB0743000DCB941 /* FileItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileItemView.swift; sourceTree = ""; }; DA5268AA2EB11EA300DCB941 /* ColorArt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorArt.swift; sourceTree = ""; }; DA5268AC2EB12FE200DCB941 /* ServerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerState.swift; sourceTree = ""; }; + DA5268AE2EB2682B00DCB941 /* HotlineClientNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineClientNew.swift; sourceTree = ""; }; + DA5268B02EB2708E00DCB941 /* HotlineState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineState.swift; sourceTree = ""; }; + DA5268B22EB6806E00DCB941 /* HotlineFileDownloadClientNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFileDownloadClientNew.swift; sourceTree = ""; }; + DA5268B42EB6840A00DCB941 /* TransfersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransfersView.swift; sourceTree = ""; }; + DA5268B72EB916AF00DCB941 /* TransferRateEstimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransferRateEstimator.swift; sourceTree = ""; }; + DA5268B92EB91B5E00DCB941 /* FileProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProgress.swift; sourceTree = ""; }; + DA5268BB2EB95A7700DCB941 /* HotlineFolderDownloadClientNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFolderDownloadClientNew.swift; sourceTree = ""; }; DA55AC722BE42AF000034857 /* AsyncLinkPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncLinkPreview.swift; sourceTree = ""; }; DA55AC742BE4888300034857 /* InstantMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstantMessage.swift; sourceTree = ""; }; DA55AC762BE589F700034857 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; @@ -212,6 +235,18 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + DA3429B12EBA73730010784E /* Transfers */ = { + isa = PBXGroup; + children = ( + DA3429B22EBA7ADE0010784E /* HotlineFilePreviewClientNew.swift */, + DA5268B22EB6806E00DCB941 /* HotlineFileDownloadClientNew.swift */, + DA3429AD2EB9C0220010784E /* HotlineFileUploadClientNew.swift */, + DA5268BB2EB95A7700DCB941 /* HotlineFolderDownloadClientNew.swift */, + DA3429AF2EBA70790010784E /* HotlineFolderUploadClientNew.swift */, + ); + path = Transfers; + sourceTree = ""; + }; DA4B8F3B2EA6FB5F00CBFD53 /* State */ = { isa = PBXGroup; children = ( @@ -219,6 +254,7 @@ DA5268AC2EB12FE200DCB941 /* ServerState.swift */, DA2863D92B37BF6E00A7D050 /* Preferences.swift */, DACCE5E02EABE4B4008CDD92 /* AppUpdate.swift */, + DA3429B42EBA8A450010784E /* FilePreviewState.swift */, ); path = State; sourceTree = ""; @@ -265,6 +301,7 @@ 11A726092BE06759000C1DA7 /* FileDetailsView.swift */, DAAEE66E2B47625600A5BA07 /* FilePreviewImageView.swift */, DAB4D87D2B4C8BCA0048A05C /* FilePreviewTextView.swift */, + DA3429B82EBAB2130010784E /* FilePreviewQuickLookView.swift */, ); path = Files; sourceTree = ""; @@ -306,6 +343,16 @@ path = News; sourceTree = ""; }; + DA5268B62EB916AB00DCB941 /* NetSocket */ = { + isa = PBXGroup; + children = ( + DAC6B2E12EAEE9EF004E2CBA /* NetSocketNew.swift */, + DA5268B92EB91B5E00DCB941 /* FileProgress.swift */, + DA5268B72EB916AF00DCB941 /* TransferRateEstimator.swift */, + ); + path = NetSocket; + sourceTree = ""; + }; DA9CAFAE2B126D5700CDA197 = { isa = PBXGroup; children = ( @@ -355,12 +402,13 @@ DAB4D87C2B4B783A0048A05C /* Library */ = { isa = PBXGroup; children = ( - DAC6B2E12EAEE9EF004E2CBA /* NetSocketNew.swift */, + DA5268B62EB916AB00DCB941 /* NetSocket */, DAC6B2E32EAFE92F004E2CBA /* SpinningGlobeView.swift */, DAB4D87A2B4B78310048A05C /* FileImageView.swift */, DAB4D8812B4C8FED0048A05C /* FileIconView.swift */, DAB4D8832B4CABEF0048A05C /* DataAdditions.swift */, DAB4D87F2B4C8E9A0048A05C /* URLAdditions.swift */, + DA3429B62EBAB1750010784E /* QuickLookPreviewView.swift */, DA4930BC2B4F8DD700822D0B /* NetSocket.swift */, DA872B122BDDBF78008B1012 /* HotlinePanel.swift */, DA872B142BDDEE1A008B1012 /* VisualEffectView.swift */, @@ -389,8 +437,10 @@ isa = PBXGroup; children = ( DA6300962B24036B0034CBFD /* HotlineClient.swift */, + DA5268AE2EB2682B00DCB941 /* HotlineClientNew.swift */, DA9CAFCA2B126E3300CDA197 /* HotlineTrackerClient.swift */, DA5753672B33E88A00FAC277 /* HotlineTransferClient.swift */, + DA3429B12EBA73730010784E /* Transfers */, DA4F2BF72B16A17200D8ADDC /* HotlineProtocol.swift */, DAC87F062C5010E80060FADF /* HotlineExtensions.swift */, DA99218D2C51AA5D0058FA6C /* HotlineDataBuilder.swift */, @@ -427,6 +477,7 @@ isa = PBXGroup; children = ( DADDB28E2B238D850024040D /* Hotline.swift */, + DA5268B02EB2708E00DCB941 /* HotlineState.swift */, DADDB28A2B22B31F0024040D /* Tracker.swift */, DADDB28C2B22B5920024040D /* Server.swift */, DA32CD482B2931640053B98B /* User.swift */, @@ -453,6 +504,7 @@ DAE734FA2B2E41F9000C56F6 /* TrackerView.swift */, DAE734F82B2E4185000C56F6 /* ServerView.swift */, DAE735022B30C0BB000C56F6 /* MessageView.swift */, + DA5268B42EB6840A00DCB941 /* TransfersView.swift */, DA5268A82EB081AF00DCB941 /* Chat */, DA5268A62EB0762300DCB941 /* Board */, DA5268A92EB081DE00DCB941 /* News */, @@ -552,24 +604,27 @@ buildActionMask = 2147483647; files = ( DA32CD4D2B2931B50053B98B /* ChatMessage.swift in Sources */, - DA6300972B24036B0034CBFD /* HotlineClient.swift in Sources */, + DA3429B92EBAB2130010784E /* FilePreviewQuickLookView.swift in Sources */, DA43205E2B1D615600FC8843 /* ServerView.swift in Sources */, DA0D69912B1E894800C71DF5 /* FilesView.swift in Sources */, DA52689E2EB073A400DCB941 /* IconSettingsView.swift in Sources */, DAC3D9832BC33FD000A727C9 /* AppState.swift in Sources */, DA65499C2BEC3FBD00EDB697 /* ServerAgreementView.swift in Sources */, + DA5268BC2EB95A7700DCB941 /* HotlineFolderDownloadClientNew.swift in Sources */, DAB4D8802B4C8E9A0048A05C /* URLAdditions.swift in Sources */, DA2863DD2B3E8B7000A7D050 /* FilePreview.swift in Sources */, + DA3429B02EBA70790010784E /* HotlineFolderUploadClientNew.swift in Sources */, DA5268AB2EB11EA300DCB941 /* ColorArt.swift in Sources */, DA2863D82B37AD1C00A7D050 /* SettingsView.swift in Sources */, + DA5268BA2EB91B5E00DCB941 /* FileProgress.swift in Sources */, DA7725412B21435B006C5ABB /* ObservableScrollView.swift in Sources */, DAE734FF2B2E6750000C56F6 /* ChatView.swift in Sources */, + DA5268B52EB6840A00DCB941 /* TransfersView.swift in Sources */, 11F8288B2BF9428100216BA0 /* AccountManagerView.swift in Sources */, DAC6B2E42EAFE92F004E2CBA /* SpinningGlobeView.swift in Sources */, - DA4930BD2B4F8DD700822D0B /* NetSocket.swift in Sources */, DAC002192B21630900A6C290 /* SwiftUIExtensions.swift in Sources */, - DADDB28F2B238D850024040D /* Hotline.swift in Sources */, DADDB28D2B22B5920024040D /* Server.swift in Sources */, + DA5268AF2EB2682B00DCB941 /* HotlineClientNew.swift in Sources */, DABE8C042B57940B00884D28 /* DAKeychain.swift in Sources */, DA55AC772BE589F700034857 /* AboutView.swift in Sources */, DA72A0E22B4DAA4000A0F48A /* NewsArticle.swift in Sources */, @@ -578,13 +633,16 @@ DAE136BA2B9D1147007D8307 /* HotlinePanelView.swift in Sources */, 11A726082BE0672A000C1DA7 /* FileDetails.swift in Sources */, DA9CAFBD2B126D5700CDA197 /* TrackerView.swift in Sources */, + DA5268B82EB916AF00DCB941 /* TransferRateEstimator.swift in Sources */, DAB4D87B2B4B78310048A05C /* FileImageView.swift in Sources */, + DA3429B52EBA8A450010784E /* FilePreviewState.swift in Sources */, DA6980792BF80525003E434B /* TextView.swift in Sources */, DA55AC732BE42AF000034857 /* AsyncLinkPreview.swift in Sources */, DA65499E2BEC438A00EDB697 /* NSWindowBridge.swift in Sources */, DAE735032B30C0BB000C56F6 /* MessageView.swift in Sources */, DA32CD4B2B29318E0053B98B /* FileInfo.swift in Sources */, DA20BBE12BF5237600B94E7C /* Bookmark.swift in Sources */, + DA3429B32EBA7ADF0010784E /* HotlineFilePreviewClientNew.swift in Sources */, DA9CAFCB2B126E3300CDA197 /* HotlineTrackerClient.swift in Sources */, DA2863DA2B37BF6E00A7D050 /* Preferences.swift in Sources */, DA32CD4F2B2931CC0053B98B /* NewsInfo.swift in Sources */, @@ -595,6 +653,7 @@ DA55AC752BE4888300034857 /* InstantMessage.swift in Sources */, DA69807E2BFD449B003E434B /* MessageBoardEditorView.swift in Sources */, DAE735012B2E71F2000C56F6 /* FilesView.swift in Sources */, + DA5268B12EB2708E00DCB941 /* HotlineState.swift in Sources */, DA55AC792BE6A1AD00034857 /* RegularExpressions.swift in Sources */, DA5268A02EB073BC00DCB941 /* SoundSettingsView.swift in Sources */, 11A7260A2BE0675A000C1DA7 /* FileDetailsView.swift in Sources */, @@ -615,6 +674,7 @@ DA65499A2BEC280E00EDB697 /* ServerMessageView.swift in Sources */, DAAEE66F2B47625600A5BA07 /* FilePreviewImageView.swift in Sources */, DA872B152BDDEE1A008B1012 /* VisualEffectView.swift in Sources */, + DA3429AE2EB9C0280010784E /* HotlineFileUploadClientNew.swift in Sources */, DAAEE66D2B475F1400A5BA07 /* PreviewFileInfo.swift in Sources */, DAB4D8842B4CABEF0048A05C /* DataAdditions.swift in Sources */, DA0D698D2B1E7CF700C71DF5 /* UsersView.swift in Sources */, @@ -627,7 +687,9 @@ DAE734FD2B2E65E9000C56F6 /* MessageBoardView.swift in Sources */, DA6980832BFFD06C003E434B /* BookmarkDocument.swift in Sources */, DAB4D87E2B4C8BCA0048A05C /* FilePreviewTextView.swift in Sources */, + DA3429B72EBAB1750010784E /* QuickLookPreviewView.swift in Sources */, DA4F2C012B1A558E00D8ADDC /* ChatView.swift in Sources */, + DA5268B32EB6806E00DCB941 /* HotlineFileDownloadClientNew.swift in Sources */, DACCE5E32EABE86A008CDD92 /* AppUpdateView.swift in Sources */, DAC6B2E02EAC6236004E2CBA /* ChatStore.swift in Sources */, DA32CD492B2931640053B98B /* User.swift in Sources */, @@ -779,10 +841,7 @@ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; IPHONEOS_DEPLOYMENT_TARGET = 17.2; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); + LD_RUNPATH_SEARCH_PATHS = "$(inherited)"; MACOSX_DEPLOYMENT_TARGET = 15.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = co.goodmake.hotline; @@ -819,10 +878,7 @@ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; IPHONEOS_DEPLOYMENT_TARGET = 17.2; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); + LD_RUNPATH_SEARCH_PATHS = "$(inherited)"; MACOSX_DEPLOYMENT_TARGET = 15.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = co.goodmake.hotline; diff --git a/Hotline.xcodeproj/xcshareddata/xcschemes/Hotline.xcscheme b/Hotline.xcodeproj/xcshareddata/xcschemes/Hotline.xcscheme index 2ba63f6..91934ce 100644 --- a/Hotline.xcodeproj/xcshareddata/xcschemes/Hotline.xcscheme +++ b/Hotline.xcodeproj/xcshareddata/xcschemes/Hotline.xcscheme @@ -34,6 +34,7 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + enableThreadSanitizer = "YES" launchStyle = "0" useCustomWorkingDirectory = "NO" ignoresPersistentStateOnLaunch = "NO" diff --git a/Hotline/Hotline/HotlineClientNew.swift b/Hotline/Hotline/HotlineClientNew.swift new file mode 100644 index 0000000..9e60b5e --- /dev/null +++ b/Hotline/Hotline/HotlineClientNew.swift @@ -0,0 +1,1061 @@ +import Foundation +import Network + +// MARK: - Events + +/// Events that can be received from a Hotline server +/// +/// These are unsolicited messages sent by the server (not replies to requests). +/// Subscribe to the `events` stream to receive them. +public enum HotlineEvent: Sendable { + /// Server sent a chat message + case chatMessage(String) + /// A user's information changed (name, icon, status) + case userChanged(HotlineUser) + /// A user disconnected from the server + case userDisconnected(UInt16) + /// Server sent a broadcast message + case serverMessage(String) + /// Received a private message from a user + case privateMessage(userID: UInt16, message: String) + /// Server sent a news post notification + case newsPost(String) + /// Server is requesting agreement acceptance + case agreementRequired(String) + /// Server sent user access permissions + case userAccess(HotlineUserAccessOptions) +} + +// MARK: - Errors + +/// Errors that can occur during Hotline operations +public enum HotlineClientError: Error { + /// Connection failed + case connectionFailed(Error) + /// Server responded with an error code + case serverError(code: UInt32, message: String?) + /// Transaction timed out waiting for reply + case timeout + /// Client is not connected + case notConnected + /// Invalid response from server + case invalidResponse + /// Login failed + case loginFailed(String?) + + var userMessage: String { + switch self { + case .connectionFailed: + "Failed to connect to server" + case .serverError(let code, let message): + message ?? "Server error: \(code)" + case .timeout: + "Request could not be completed" + case .notConnected: + "Not connected" + case .invalidResponse: + "Server returned an invalid response" + case .loginFailed(let message): + message ?? "Login failed" + } + } +} + +// MARK: - Login Info + +/// Information needed to log in to a Hotline server +public struct HotlineLoginInfo: Sendable { + let login: String + let password: String + let username: String + let iconID: UInt16 + + public init(login: String, password: String, username: String, iconID: UInt16) { + self.login = login + self.password = password + self.username = username + self.iconID = iconID + } +} + +// MARK: - Server Info + +/// Information about the connected server +public struct HotlineServerInfo: Sendable { + let name: String + let version: UInt16 + + public init(name: String, version: UInt16) { + self.name = name + self.version = version + } +} + +// MARK: - Hotline Client + +/// Modern async/await-based Hotline protocol client +/// +/// Example usage: +/// ```swift +/// let client = try await HotlineClientNew.connect( +/// host: "server.example.com", +/// port: 5500, +/// login: HotlineLoginInfo(login: "guest", password: "", username: "John", iconID: 414) +/// ) +/// +/// // Listen for events +/// Task { +/// for await event in client.events { +/// switch event { +/// case .chatMessage(let text): +/// print("Chat: \(text)") +/// case .userChanged(let user): +/// print("User changed: \(user.name)") +/// default: +/// break +/// } +/// } +/// } +/// +/// // Send chat message +/// try await client.sendChat("Hello world!") +/// +/// // Get user list +/// let users = try await client.getUserList() +/// ``` +public actor HotlineClientNew { + // MARK: - Properties + + private let socket: NetSocketNew + private var serverInfo: HotlineServerInfo? + private var isConnected: Bool = true + + /// Information about the connected server (name and version) + public var server: HotlineServerInfo? { + return serverInfo + } + + // Event streaming + private let eventContinuation: AsyncStream.Continuation + public let events: AsyncStream + + // Transaction tracking for request/reply pattern + private var pendingTransactions: [UInt32: CheckedContinuation] = [:] + + private enum TransactionWaitError: Error { + case timeout + } + + // Receive loop task + private var receiveTask: Task? + + // Keep-alive timer + private var keepAliveTask: Task? + + // MARK: - Static Handshake + + private static let handshakeData = Data(endian: .big, { + "TRTP".fourCharCode() // 'TRTP' protocol ID + "HOTL".fourCharCode() // 'HOTL' sub-protocol ID + UInt16(0x0001) // Version + UInt16(0x0002) // Sub-version + }) + + // MARK: - Connection + + /// Connect to a Hotline server and log in + /// + /// This method: + /// 1. Establishes TCP connection + /// 2. Performs handshake + /// 3. Logs in with provided credentials + /// 4. Starts event streaming and keep-alive + /// + /// - Parameters: + /// - host: Server hostname or IP address + /// - port: Server port (default: 5500) + /// - login: Login credentials and user info + /// - tls: TLS policy (default: disabled for Hotline) + /// - Returns: Connected and logged-in client + /// - Throws: `HotlineClientError` if connection or login fails + public static func connect( + host: String, + port: UInt16 = 5500, + login: HotlineLoginInfo, + tls: TLSPolicy = .disabled + ) async throws -> HotlineClientNew { + print("HotlineClientNew.connect(): Starting connection to \(host):\(port) as '\(login.username)'") + + // Connect socket + print("HotlineClientNew.connect(): Connecting socket...") + let socket = try await NetSocketNew.connect(host: host, port: port, tls: tls) + print("HotlineClientNew.connect(): Socket connected") + + // Perform handshake + print("HotlineClientNew.connect(): Sending handshake...") + try await socket.write(handshakeData) + let handshakeResponse = try await socket.read(8) + print("HotlineClientNew.connect(): Handshake response received") + + // Verify handshake + guard handshakeResponse.prefix(4) == Data([0x54, 0x52, 0x54, 0x50]) else { + print("HotlineClientNew.connect(): Invalid handshake response") + throw HotlineClientError.connectionFailed( + NSError(domain: "HotlineClient", code: -1, userInfo: [ + NSLocalizedDescriptionKey: "Invalid handshake response" + ]) + ) + } + + let errorCode = handshakeResponse.withUnsafeBytes { $0.load(fromByteOffset: 4, as: UInt32.self) } + guard errorCode.bigEndian == 0 else { + print("HotlineClientNew.connect(): Handshake failed with error code \(errorCode)") + throw HotlineClientError.connectionFailed( + NSError(domain: "HotlineClient", code: Int(errorCode), userInfo: [ + NSLocalizedDescriptionKey: "Handshake failed with error code \(errorCode)" + ]) + ) + } + + // Create client + print("HotlineClientNew.connect(): Creating client instance") + let client = HotlineClientNew(socket: socket) + + // Start receive loop + print("HotlineClientNew.connect(): Starting receive loop") + await client.startReceiveLoop() + + // Perform login + print("HotlineClientNew.connect(): Performing login") + let serverInfo = try await client.performLogin(login) + await client.setServerInfo(serverInfo) + print("HotlineClientNew.connect(): Login successful") + + // Start keep-alive + print("HotlineClientNew.connect(): Starting keep-alive") + await client.startKeepAlive() + + print("HotlineClientNew.connect(): Connected to \(serverInfo.name) (v\(serverInfo.version))") + + return client + } + + private init(socket: NetSocketNew) { + self.socket = socket + + // Set up event stream + var continuation: AsyncStream.Continuation! + self.events = AsyncStream { cont in + continuation = cont + } + self.eventContinuation = continuation + } + + private func setServerInfo(_ info: HotlineServerInfo) { + self.serverInfo = info + } + + // MARK: - Login + + private func performLogin(_ login: HotlineLoginInfo) async throws -> HotlineServerInfo { + var transaction = HotlineTransaction(type: .login) + transaction.setFieldEncodedString(type: .userLogin, val: login.login) + transaction.setFieldEncodedString(type: .userPassword, val: login.password) + transaction.setFieldUInt16(type: .userIconID, val: login.iconID) + transaction.setFieldString(type: .userName, val: login.username) + transaction.setFieldUInt32(type: .versionNumber, val: 123) + + let reply = try await sendTransaction(transaction) + + guard reply.errorCode == 0 else { + let errorText = reply.getField(type: .errorText)?.getString() + throw HotlineClientError.loginFailed(errorText) + } + + let serverName = reply.getField(type: .serverName)?.getString() ?? "Unknown" + let serverVersion = reply.getField(type: .versionNumber)?.getUInt16() ?? 0 + + return HotlineServerInfo(name: serverName, version: serverVersion) + } + + // MARK: - Disconnect + + /// Disconnect from the server + /// + /// Closes the socket and stops all background tasks. + public func disconnect() async { + guard isConnected else { + return + } + + isConnected = false + + print("HotlineClientNew.disconnect(): Starting disconnect") + self.receiveTask?.cancel() + self.keepAliveTask?.cancel() + await self.socket.close() + self.failAllPendingTransactions(HotlineClientError.notConnected) + self.eventContinuation.finish() + print("HotlineClientNew.disconnect(): Disconnect complete") + } + + // MARK: - Receive Loop + + private func startReceiveLoop() { + print("HotlineClientNew.startReceiveLoop(): Creating receive task") + self.receiveTask = Task { [weak self] in + guard let self else { + return + } + + do { + while !Task.isCancelled { + // Read transaction from socket + let transaction = try await self.socket.receive(HotlineTransaction.self, endian: .big) + await self.handleTransaction(transaction) + } + print("HotlineClientNew.startReceiveLoop(): Task cancelled, exiting loop") + } catch { + if Task.isCancelled || error is CancellationError { + print("HotlineClientNew.startReceiveLoop(): Receive loop cancelled") + } else { + print("HotlineClientNew.startReceiveLoop(): Receive loop error: \(error)") + await self.disconnect() + } + } + print("HotlineClientNew.startReceiveLoop(): Receive loop ended") + } + } + + private func handleTransaction(_ transaction: HotlineTransaction) { + print("HotlineClientNew: <= \(transaction.type) [\(transaction.id)]") + + // Check if this is a reply to a pending transaction + if transaction.isReply == 1 || transaction.type == .reply { + handleReply(transaction) + return + } + + // Handle unsolicited server messages (events) + handleEvent(transaction) + } + + private func handleReply(_ transaction: HotlineTransaction) { + guard let continuation = pendingTransactions.removeValue(forKey: transaction.id) else { + print("HotlineClientNew: Received reply for unknown transaction \(transaction.id)") + return + } + + if transaction.errorCode != 0 { + let errorText = transaction.getField(type: .errorText)?.getString() + continuation.resume(throwing: HotlineClientError.serverError( + code: transaction.errorCode, + message: errorText + )) + } else { + print("HELLO") + continuation.resume(returning: transaction) + } + } + + private func handleEvent(_ transaction: HotlineTransaction) { + switch transaction.type { + case .chatMessage: + if let text = transaction.getField(type: .data)?.getString() { + eventContinuation.yield(.chatMessage(text)) + } + + case .notifyOfUserChange: + if let usernameField = transaction.getField(type: .userName), + let username = usernameField.getString(), + let userID = transaction.getField(type: .userID)?.getUInt16(), + let iconID = transaction.getField(type: .userIconID)?.getUInt16(), + let flags = transaction.getField(type: .userFlags)?.getUInt16() { + let user = HotlineUser(id: userID, iconID: iconID, status: flags, name: username) + eventContinuation.yield(.userChanged(user)) + } + + case .notifyOfUserDelete: + if let userID = transaction.getField(type: .userID)?.getUInt16() { + eventContinuation.yield(.userDisconnected(userID)) + } + + case .serverMessage: + if let message = transaction.getField(type: .data)?.getString() { + if let userID = transaction.getField(type: .userID)?.getUInt16() { + eventContinuation.yield(.privateMessage(userID: userID, message: message)) + } else { + eventContinuation.yield(.serverMessage(message)) + } + } + + case .showAgreement: + if transaction.getField(type: .noServerAgreement) == nil, + let agreementText = transaction.getField(type: .data)?.getString() { + eventContinuation.yield(.agreementRequired(agreementText)) + } + + case .userAccess: + if let accessValue = transaction.getField(type: .userAccess)?.getUInt64() { + eventContinuation.yield(.userAccess(HotlineUserAccessOptions(rawValue: accessValue))) + } + + case .newMessage: + if let message = transaction.getField(type: .data)?.getString() { + eventContinuation.yield(.newsPost(message)) + } + + case .disconnectMessage: + Task { + await self.disconnect() + } + + default: + print("HotlineClientNew: Unhandled event type \(transaction.type)") + } + } + + // MARK: - Transaction Sending + + private func sendTransaction(_ transaction: HotlineTransaction, timeout: TimeInterval = 30.0) async throws -> HotlineTransaction { + print("HotlineClientNew: => \(transaction.type) [\(transaction.id)]") + + let transactionID = transaction.id + + try await self.socket.send(transaction, endian: .big) + + do { + return try await withTimeout(seconds: timeout) { + try await self.awaitReply(for: transactionID) + } + } catch is TransactionWaitError { + throw HotlineClientError.timeout + } catch let error as HotlineClientError { + throw error + } catch { + throw error + } + } + + private func storePendingTransaction(id: UInt32, continuation: CheckedContinuation) { + self.pendingTransactions[id] = continuation + } + + private func awaitReply(for transactionID: UInt32) async throws -> HotlineTransaction { + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + self.storePendingTransaction(id: transactionID, continuation: continuation) + } + } onCancel: { [weak self] in + Task { await self?.failPendingTransaction(id: transactionID, error: HotlineClientError.timeout) } + } + } + + private func failPendingTransaction(id: UInt32, error: Error) { + guard let continuation = self.pendingTransactions.removeValue(forKey: id) else { return } + continuation.resume(throwing: error) + } + + private func failAllPendingTransactions(_ error: Error) { + guard !self.pendingTransactions.isEmpty else { return } + let continuations = self.pendingTransactions + self.pendingTransactions.removeAll() + for (_, continuation) in continuations { + continuation.resume(throwing: error) + } + } + + private func withTimeout(seconds: TimeInterval, operation: @escaping @Sendable () async throws -> T) async throws -> T { + if seconds <= 0 { + throw TransactionWaitError.timeout + } + + return try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { + try await operation() + } + + group.addTask { + try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + throw TransactionWaitError.timeout + } + + do { + let value = try await group.next()! + group.cancelAll() + return value + } catch { + group.cancelAll() + throw error + } + } + } + + // MARK: - Keep-Alive + + private func startKeepAlive() { + keepAliveTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 180_000_000_000) // 3 minutes + + guard let self else { return } + + do { + if let version = await self.serverInfo?.version, version >= 185 { + let transaction = HotlineTransaction(type: .connectionKeepAlive) + try await self.socket.send(transaction, endian: .big) + } else { + // Older servers: send getUserNameList as keep-alive + _ = try? await self.getUserList() + } + } catch { + print("HotlineClientNew: Keep-alive failed: \(error)") + } + } + } + } + + // MARK: - Public API - Chat + + /// Send a chat message to the server + /// + /// - Parameters: + /// - message: Text to send + /// - encoding: Text encoding (default: UTF-8) + /// - announce: Whether this is an announcement (admin only, default: false) + public func sendChat(_ message: String, encoding: String.Encoding = .utf8, announce: Bool = false) async throws { + var transaction = HotlineTransaction(type: .sendChat) + transaction.setFieldString(type: .data, val: message, encoding: encoding) + transaction.setFieldUInt16(type: .chatOptions, val: announce ? 1 : 0) + + try await socket.send(transaction, endian: .big) + } + + // MARK: - Public API - Users + + /// Get the list of users currently connected to the server + /// + /// - Returns: Array of connected users + public func getUserList() async throws -> [HotlineUser] { + let transaction = HotlineTransaction(type: .getUserNameList) + let reply = try await sendTransaction(transaction) + + var users: [HotlineUser] = [] + for field in reply.getFieldList(type: .userNameWithInfo) { + users.append(field.getUser()) + } + + return users + } + + /// Send a private instant message to a user + /// + /// - Parameters: + /// - message: Text to send + /// - userID: Target user ID + /// - encoding: Text encoding (default: UTF-8) + public func sendInstantMessage(_ message: String, to userID: UInt16, encoding: String.Encoding = .utf8) async throws { + var transaction = HotlineTransaction(type: .sendInstantMessage) + transaction.setFieldUInt16(type: .userID, val: userID) + transaction.setFieldUInt32(type: .options, val: 1) + transaction.setFieldString(type: .data, val: message, encoding: encoding) + + try await socket.send(transaction, endian: .big) + } + + /// Update this client's user info (name, icon, options) + /// + /// - Parameters: + /// - username: Display name + /// - iconID: Icon ID + /// - options: User options flags + /// - autoresponse: Optional auto-response text + public func setClientUserInfo( + username: String, + iconID: UInt16, + options: HotlineUserOptions = [], + autoresponse: String? = nil + ) async throws { + var transaction = HotlineTransaction(type: .setClientUserInfo) + transaction.setFieldString(type: .userName, val: username) + transaction.setFieldUInt16(type: .userIconID, val: iconID) + transaction.setFieldUInt16(type: .options, val: options.rawValue) + + if let autoresponse { + transaction.setFieldString(type: .automaticResponse, val: autoresponse) + } + + try await socket.send(transaction, endian: .big) + } + + // MARK: - Public API - Agreement + + /// Send agreement acceptance to the server + /// + /// Call this after receiving `.agreementRequired` event. + public func sendAgree() async throws { + let transaction = HotlineTransaction(type: .agreed) + try await socket.send(transaction, endian: .big) + } + + // MARK: - Public API - Files + + /// Get the file list for a directory + /// + /// - Parameter path: Directory path (empty for root) + /// - Returns: Array of files and folders + public func getFileList(path: [String] = []) async throws -> [HotlineFile] { + var transaction = HotlineTransaction(type: .getFileNameList) + if !path.isEmpty { + transaction.setFieldPath(type: .filePath, val: path) + } + + let reply = try await sendTransaction(transaction) + + var files: [HotlineFile] = [] + for field in reply.getFieldList(type: .fileNameWithInfo) { + let file = field.getFile() + file.path = path + [file.name] + files.append(file) + } + + return files + } + + /// Request to download a file + /// + /// - Parameters: + /// - name: File name + /// - path: Directory path containing the file + /// - preview: Request preview/thumbnail instead of full file + /// - Returns: Transfer info (reference number, size, waiting count) + public func downloadFile( + name: String, + path: [String], + preview: Bool = false + ) async throws -> (referenceNumber: UInt32, size: Int, fileSize: Int?, waitingCount: Int?) { + var transaction = HotlineTransaction(type: .downloadFile) + transaction.setFieldString(type: .fileName, val: name) + transaction.setFieldPath(type: .filePath, val: path) + + if preview { + transaction.setFieldUInt32(type: .fileTransferOptions, val: 2) + } + + let reply = try await sendTransaction(transaction) + + guard + let transferSize = reply.getField(type: .transferSize)?.getInteger(), + let referenceNumber = reply.getField(type: .referenceNumber)?.getUInt32() + else { + throw HotlineClientError.invalidResponse + } + + let fileSize = reply.getField(type: .fileSize)?.getInteger() + let waitingCount = reply.getField(type: .waitingCount)?.getInteger() + + return (referenceNumber, transferSize, fileSize, waitingCount) + } + + // MARK: - Public API - News + + /// Get news categories at a path + /// + /// - Parameter path: Category path (empty for root) + /// - Returns: Array of news categories + public func getNewsCategories(path: [String] = []) async throws -> [HotlineNewsCategory] { + var transaction = HotlineTransaction(type: .getNewsCategoryNameList) + if !path.isEmpty { + transaction.setFieldPath(type: .newsPath, val: path) + } + + let reply = try await sendTransaction(transaction) + + var categories: [HotlineNewsCategory] = [] + for field in reply.getFieldList(type: .newsCategoryListData15) { + var category = field.getNewsCategory() + category.path = path + [category.name] + categories.append(category) + } + + return categories + } + + /// Get news articles in a category + /// + /// - Parameter path: Category path + /// - Returns: Array of news articles + public func getNewsArticles(path: [String] = []) async throws -> [HotlineNewsArticle] { + var transaction = HotlineTransaction(type: .getNewsArticleNameList) + if !path.isEmpty { + transaction.setFieldPath(type: .newsPath, val: path) + } + + let reply = try await sendTransaction(transaction) + + guard let articleData = reply.getField(type: .newsArticleListData) else { + return [] + } + + let newsList = articleData.getNewsList() + return newsList.articles.map { article in + var a = article + a.path = path + return a + } + } + + /// Get the content of a news article + /// + /// - Parameters: + /// - id: Article ID + /// - path: Category path + /// - flavor: Content flavor (default: "text/plain") + /// - Returns: Article content as string + public func getNewsArticle(id: UInt32, path: [String], flavor: String = "text/plain") async throws -> String? { + var transaction = HotlineTransaction(type: .getNewsArticleData) + transaction.setFieldPath(type: .newsPath, val: path) + transaction.setFieldUInt32(type: .newsArticleID, val: id) + transaction.setFieldString(type: .newsArticleDataFlavor, val: flavor, encoding: .ascii) + + let reply = try await sendTransaction(transaction) + return reply.getField(type: .newsArticleData)?.getString() + } + + /// Post a news article + /// + /// - Parameters: + /// - title: Article title + /// - text: Article body + /// - path: Category path + /// - parentID: Parent article ID (for replies, default: 0) + public func postNewsArticle( + title: String, + text: String, + path: [String], + parentID: UInt32 = 0 + ) async throws { + guard !path.isEmpty else { + throw HotlineClientError.invalidResponse + } + + var transaction = HotlineTransaction(type: .postNewsArticle) + transaction.setFieldPath(type: .newsPath, val: path) + transaction.setFieldUInt32(type: .newsArticleID, val: parentID) + transaction.setFieldString(type: .newsArticleTitle, val: title) + transaction.setFieldString(type: .newsArticleDataFlavor, val: "text/plain") + transaction.setFieldUInt32(type: .newsArticleFlags, val: 0) + transaction.setFieldString(type: .newsArticleData, val: text) + + _ = try await sendTransaction(transaction) + } + + // MARK: - Public API - Message Board + + /// Get message board posts + /// + /// - Returns: Array of message strings + public func getMessageBoard() async throws -> [String] { + let transaction = HotlineTransaction(type: .getMessageBoard) + let reply = try await sendTransaction(transaction) + + guard let text = reply.getField(type: .data)?.getString() else { + return [] + } + + // Parse messages (separated by divider pattern) + // TODO: Implement proper divider parsing if needed + return [text] + } + + /// Post to the message board + /// + /// - Parameter text: Message text + public func postMessageBoard(_ text: String) async throws { + guard !text.isEmpty else { return } + + var transaction = HotlineTransaction(type: .oldPostNews) + transaction.setFieldString(type: .data, val: text, encoding: .macOSRoman) + + try await socket.send(transaction, endian: .big) + } + + // MARK: - Public API - File Operations + + /// Get detailed information about a file + /// + /// - Parameters: + /// - name: File name + /// - path: Directory path containing the file + /// - Returns: File details or nil if not found + public func getFileInfo(name: String, path: [String]) async throws -> FileDetails? { + var transaction = HotlineTransaction(type: .getFileInfo) + transaction.setFieldString(type: .fileName, val: name) + transaction.setFieldPath(type: .filePath, val: path) + + let reply = try await sendTransaction(transaction) + + guard + let fileName = reply.getField(type: .fileName)?.getString(), + let fileCreator = reply.getField(type: .fileCreatorString)?.getString(), + let fileType = reply.getField(type: .fileTypeString)?.getString(), + let fileCreateDate = reply.getField(type: .fileCreateDate)?.data.readDate(at: 0), + let fileModifyDate = reply.getField(type: .fileModifyDate)?.data.readDate(at: 0) + else { + return nil + } + + // Size field is not included in server reply for folders + let fileSize = reply.getField(type: .fileSize)?.getInteger() ?? 0 + let fileComment = reply.getField(type: .fileComment)?.getString() ?? "" + + return FileDetails( + name: fileName, + path: path, + size: fileSize, + comment: fileComment, + type: fileType, + creator: fileCreator, + created: fileCreateDate, + modified: fileModifyDate + ) + } + + /// Delete a file or folder + /// + /// - Parameters: + /// - name: File or folder name + /// - path: Directory path containing the item + /// - Returns: True if deletion succeeded + public func deleteFile(name: String, path: [String]) async throws -> Bool { + var transaction = HotlineTransaction(type: .deleteFile) + transaction.setFieldString(type: .fileName, val: name) + transaction.setFieldPath(type: .filePath, val: path) + + do { + _ = try await sendTransaction(transaction) + return true + } catch { + return false + } + } + + // MARK: - Public API - User Administration + + /// Get list of user accounts (requires admin access) + /// + /// - Returns: Array of user accounts sorted by login + public func getAccounts() async throws -> [HotlineAccount] { + let transaction = HotlineTransaction(type: .getAccounts) + let reply = try await sendTransaction(transaction) + + let accountFields = reply.getFieldList(type: .data) + var accounts: [HotlineAccount] = [] + + for data in accountFields { + accounts.append(data.getAcccount()) + } + + accounts.sort { $0.login < $1.login } + + return accounts + } + + /// Create a new user account (requires admin access) + /// + /// - Parameters: + /// - name: Display name for the user + /// - login: Login username + /// - password: Optional password (nil for no password) + /// - access: Access permissions bitmask + public func createUser(name: String, login: String, password: String?, access: UInt64) async throws { + var transaction = HotlineTransaction(type: .newUser) + + transaction.setFieldString(type: .userName, val: name) + transaction.setFieldEncodedString(type: .userLogin, val: login) + transaction.setFieldUInt64(type: .userAccess, val: access) + + if let password { + transaction.setFieldEncodedString(type: .userPassword, val: password) + } + + _ = try await sendTransaction(transaction) + } + + /// Update an existing user account (requires admin access) + /// + /// - Parameters: + /// - name: Display name for the user + /// - login: Current login username + /// - newLogin: New login username (nil to keep current) + /// - password: Password update - nil to keep current, "" to remove, or new password string + /// - access: Access permissions bitmask + public func setUser(name: String, login: String, newLogin: String?, password: String?, access: UInt64) async throws { + var transaction = HotlineTransaction(type: .setUser) + transaction.setFieldString(type: .userName, val: name) + transaction.setFieldUInt64(type: .userAccess, val: access) + + if let newLogin { + transaction.setFieldEncodedString(type: .data, val: login) + transaction.setFieldEncodedString(type: .userLogin, val: newLogin) + } else { + transaction.setFieldEncodedString(type: .userLogin, val: login) + } + + // Password field handling: + // - nil: Keep current password (send zero byte) + // - "": Remove password (omit field) + // - other: Set new password + if password == nil { + transaction.setFieldUInt8(type: .userPassword, val: 0) + } else if password != "" { + transaction.setFieldEncodedString(type: .userPassword, val: password!) + } + + _ = try await sendTransaction(transaction) + } + + /// Delete a user account (requires admin access) + /// + /// - Parameter login: Login username to delete + public func deleteUser(login: String) async throws { + var transaction = HotlineTransaction(type: .deleteUser) + transaction.setFieldEncodedString(type: .userLogin, val: login) + + _ = try await sendTransaction(transaction) + } + + // MARK: - Public API - Banner Download + + /// Request to download the server banner image + /// + /// - Returns: Tuple of (referenceNumber, transferSize) for the banner download + /// - Throws: HotlineClientError if not connected or server doesn't support banners + public func downloadBanner() async throws -> (referenceNumber: UInt32, transferSize: Int)? { + let transaction = HotlineTransaction(type: .downloadBanner) + let reply = try await sendTransaction(transaction) + + guard + let transferSizeField = reply.getField(type: .transferSize), + let transferSize = transferSizeField.getInteger(), + let transferReferenceField = reply.getField(type: .referenceNumber), + let referenceNumber = transferReferenceField.getUInt32() + else { + return nil + } + + return (referenceNumber, transferSize) + } + + // MARK: Files + + /// Request to download a file + /// + /// - Parameters: + /// - name: File name to download + /// - path: Directory path containing the file + /// - preview: If true, request preview mode (smaller transfer) + /// - Returns: Tuple of (referenceNumber, transferSize, fileSize, waitingCount) for the download + public func downloadFile(name: String, path: [String], preview: Bool = false) async throws -> (referenceNumber: UInt32, transferSize: Int, fileSize: Int, waitingCount: Int)? { + var transaction = HotlineTransaction(type: .downloadFile) + transaction.setFieldString(type: .fileName, val: name) + transaction.setFieldPath(type: .filePath, val: path) + + if preview { + transaction.setFieldUInt32(type: .fileTransferOptions, val: 2) + } + + let reply = try await sendTransaction(transaction) + + guard + let transferSizeField = reply.getField(type: .transferSize), + let transferSize = transferSizeField.getInteger(), + let transferReferenceField = reply.getField(type: .referenceNumber), + let referenceNumber = transferReferenceField.getUInt32() + else { + return nil + } + + let fileSize = reply.getField(type: .fileSize)?.getInteger() ?? transferSize + let waitingCount = reply.getField(type: .waitingCount)?.getInteger() ?? 0 + + return (referenceNumber, transferSize, fileSize, waitingCount) + } + + /// Request to download a folder + /// + /// - Parameters: + /// - name: Folder name to download + /// - path: Directory path containing the folder + /// - Returns: Tuple of (referenceNumber, transferSize, itemCount, waitingCount) for the download + public func downloadFolder(name: String, path: [String]) async throws -> (referenceNumber: UInt32, transferSize: Int, itemCount: Int, waitingCount: Int)? { + var transaction = HotlineTransaction(type: .downloadFolder) + transaction.setFieldString(type: .fileName, val: name) + transaction.setFieldPath(type: .filePath, val: path) + + let reply = try await sendTransaction(transaction) + + guard + let transferSizeField = reply.getField(type: .transferSize), + let transferSize = transferSizeField.getInteger(), + let transferReferenceField = reply.getField(type: .referenceNumber), + let referenceNumber = transferReferenceField.getUInt32() + else { + return nil + } + + let itemCount = reply.getField(type: .folderItemCount)?.getInteger() ?? 0 + let waitingCount = reply.getField(type: .waitingCount)?.getInteger() ?? 0 + + return (referenceNumber, transferSize, itemCount, waitingCount) + } + + /// Uploads a file to the server + /// - Parameters: + /// - name: File name to upload + /// - path: Directory path where the file should be uploaded + /// - Returns: Reference number for the upload transfer + public func uploadFile(name: String, path: [String]) async throws -> UInt32? { + var transaction = HotlineTransaction(type: .uploadFile) + transaction.setFieldString(type: .fileName, val: name) + transaction.setFieldPath(type: .filePath, val: path) + + let reply = try await sendTransaction(transaction) + + guard + let transferReferenceField = reply.getField(type: .referenceNumber), + let referenceNumber = transferReferenceField.getUInt32() + else { + return nil + } + + return referenceNumber + } + + /// Request to upload a folder + /// + /// - Parameters: + /// - name: Folder name to upload + /// - path: Directory path where the folder should be uploaded + /// - Returns: Reference number for the upload transfer + public func uploadFolder(name: String, path: [String], fileCount: UInt32, totalSize: UInt32) async throws -> UInt32? { + print("HotlineClientNew: uploadFolder request - name='\(name)', path=\(path), fileCount=\(fileCount), totalSize=\(totalSize)") + + var transaction = HotlineTransaction(type: .uploadFolder) + transaction.setFieldString(type: .fileName, val: name) + transaction.setFieldPath(type: .filePath, val: path) + transaction.setFieldUInt32(type: .transferSize, val: totalSize) + transaction.setFieldUInt16(type: .folderItemCount, val: UInt16(truncatingIfNeeded: fileCount)) + + let reply = try await sendTransaction(transaction) + + guard + let transferReferenceField = reply.getField(type: .referenceNumber), + let referenceNumber = transferReferenceField.getUInt32() + else { + return nil + } + + return referenceNumber + } +} diff --git a/Hotline/Hotline/HotlineExtensions.swift b/Hotline/Hotline/HotlineExtensions.swift index 81387bf..16e9583 100644 --- a/Hotline/Hotline/HotlineExtensions.swift +++ b/Hotline/Hotline/HotlineExtensions.swift @@ -11,6 +11,24 @@ extension URL { func urlForResourceFork() -> URL { self.appendingPathComponent("..namedfork/rsrc") } + + /// Append multiple path components to a URL by iterating through an array + /// - Parameter components: Array of path component strings + /// - Returns: New URL with all components appended + func appendingPathComponents(_ components: [String]) -> URL { + var path = self.path + for component in components { + path = (path as NSString).appendingPathComponent(component) + } + return URL(filePath: path) + } + +#if os(macOS) + func notifyDownloadFinished() { + print("DOWNLOAD FINISHED AT", self) + DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: self.path) + } +#endif } extension String { @@ -382,7 +400,7 @@ extension FileManager { // Get resource fork size. var resourceForkSize: UInt32 = 0 - let resourceFileURL: URL = fileURL.appendingPathComponent("..namedfork/rsrc") + let resourceFileURL: URL = fileURL.urlForResourceFork() let resourceFilePath: String = fileURL.path(percentEncoded: false) if self.fileExists(atPath: resourceFilePath) { let resourceFileAttributes: [FileAttributeKey: Any]? = try? self.attributesOfItem(atPath: resourceFileURL.path(percentEncoded: false)) @@ -394,6 +412,36 @@ extension FileManager { return (dataForkSize: dataForkSize, resourceForkSize: resourceForkSize) } + /// Create a file with metadata from a Hotline INFO fork + /// + /// - Parameters: + /// - url: Destination URL for the new file + /// - infoFork: Hotline file info containing metadata + /// - Returns: FileHandle open for writing + /// - Throws: Error if file creation fails + func createHotlineFile(at url: URL, infoFork: HotlineFileInfoFork) throws -> FileHandle { + var attributes: [FileAttributeKey: Any] = [:] + + if infoFork.creator != 0 { + attributes[.hfsCreatorCode] = infoFork.creator as NSNumber + } + if infoFork.type != 0 { + attributes[.hfsTypeCode] = infoFork.type as NSNumber + } + attributes[.creationDate] = infoFork.createdDate as NSDate + attributes[.modificationDate] = infoFork.modifiedDate as NSDate + + guard self.createFile(atPath: url.path, contents: nil, attributes: attributes) else { + throw HotlineFileClientError.failedToTransfer + } + + guard let handle = FileHandle(forWritingAtPath: url.path) else { + throw HotlineFileClientError.failedToTransfer + } + + return handle + } + func getFlattenedFileSize(_ fileURL: URL) -> UInt64? { var fileIsDirectory: ObjCBool = false let filePath: String = fileURL.path(percentEncoded: false) @@ -454,11 +502,52 @@ extension FileManager { // print("FOUND RESOURCE FORK: \(resourceForkSize)") // } // } -// +// // totalSize += resourceForkSize - + return totalSize } + + /// Get the total size of all files in a folder (recursively) + /// Returns the sum of flattened file sizes for all files in the folder, and the total count of all items (files + folders) + func getFolderSize(_ folderURL: URL) -> (size: UInt64, count: UInt32)? { + var isDirectory: ObjCBool = false + let folderPath = folderURL.path(percentEncoded: false) + + guard folderURL.isFileURL, + self.fileExists(atPath: folderPath, isDirectory: &isDirectory), + isDirectory.boolValue == true else { + return nil + } + + var totalSize: UInt64 = 0 + var itemCount: UInt32 = 0 + + guard let enumerator = self.enumerator(at: folderURL, includingPropertiesForKeys: [.isRegularFileKey, .isDirectoryKey], options: [.skipsHiddenFiles]) else { + return nil + } + + for case let fileURL as URL in enumerator { + guard let resourceValues = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .isDirectoryKey]) else { + continue + } + + let isRegularFile = resourceValues.isRegularFile ?? false + let isDirectory = resourceValues.isDirectory ?? false + + // Count all items (files and folders) + if isRegularFile || isDirectory { + itemCount += 1 + + // Only add size for files, not folders + if isRegularFile, let fileSize = self.getFlattenedFileSize(fileURL) { + totalSize += fileSize + } + } + } + + return (size: totalSize, count: itemCount) + } } extension Date { @@ -1016,3 +1105,51 @@ extension FourCharCode { return String(bytes: bytes, encoding: .ascii) ?? "" } } + + +extension NetSocketNew { + /// Read a pascal string (1-byte length prefix followed by string data) + /// + /// This method reads a single byte for the length, then reads that many bytes and attempts + /// to decode them as a string. It tries multiple encodings for compatibility with legacy + /// protocols like Hotline: UTF-8, Shift-JIS, Windows-1251, and falls back to MacRoman. + /// + /// - Returns: The decoded string, or nil if length is 0 + /// - Throws: `NetSocketError` if reading fails or no encoding succeeds + func readPascalString() async throws -> String? { + let length = try await read(UInt8.self) + guard length > 0 else { return nil } + + let data = try await read(Int(length)) + + // Try auto-detection with common encodings + let allowedEncodings = [ + String.Encoding.utf8.rawValue, + String.Encoding.shiftJIS.rawValue, + String.Encoding.unicode.rawValue, + String.Encoding.windowsCP1251.rawValue + ] + + var decodedString: NSString? + let detected = NSString.stringEncoding( + for: data, + encodingOptions: [.allowLossyKey: false], + convertedString: &decodedString, + usedLossyConversion: nil + ) + + if allowedEncodings.contains(detected), let str = decodedString as? String { + return str + } + + // Fallback to MacRoman for classic Mac compatibility + guard let str = String(data: data, encoding: .macOSRoman) else { + throw NetSocketError.decodeFailed(NSError( + domain: "NetSocketNew", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Failed to decode pascal string with any known encoding"] + )) + } + return str + } +} diff --git a/Hotline/Hotline/HotlineProtocol.swift b/Hotline/Hotline/HotlineProtocol.swift index 5f859fb..43f018e 100644 --- a/Hotline/Hotline/HotlineProtocol.swift +++ b/Hotline/Hotline/HotlineProtocol.swift @@ -5,18 +5,26 @@ struct HotlinePorts { static let DefaultTrackerPort: Int = 5498 } -struct HotlineUserOptions: OptionSet { - let rawValue: UInt16 - - static let none: HotlineUserOptions = [] - - static let refusePrivateMessages = HotlineUserOptions(rawValue: 1 << 0) - static let refusePrivateChat = HotlineUserOptions(rawValue: 1 << 1) - static let automaticResponse = HotlineUserOptions(rawValue: 1 << 2) +public struct HotlineUserOptions: OptionSet, Sendable { + public let rawValue: UInt16 + + public init(rawValue: UInt16) { + self.rawValue = rawValue + } + + public static let none: HotlineUserOptions = [] + + public static let refusePrivateMessages = HotlineUserOptions(rawValue: 1 << 0) + public static let refusePrivateChat = HotlineUserOptions(rawValue: 1 << 1) + public static let automaticResponse = HotlineUserOptions(rawValue: 1 << 2) } -struct HotlineUserAccessOptions: OptionSet { - let rawValue: UInt64 +public struct HotlineUserAccessOptions: OptionSet, Sendable { + public let rawValue: UInt64 + + public init(rawValue: UInt64) { + self.rawValue = rawValue + } static func accessIndexToBit(_ index: Int) -> Int { return 63 - index @@ -201,23 +209,23 @@ struct HotlineServer: Identifiable, Hashable, NetSocketDecodable { } } -struct HotlineNewsArticle: Identifiable { - let id: UInt32 - let parentID: UInt32 - let flags: UInt32 - let title: String - let username: String - let date: Date? - var flavors: [(String, UInt16)] = [] - var path: [String] = [] - - static func == (lhs: HotlineNewsArticle, rhs: HotlineNewsArticle) -> Bool { +public struct HotlineNewsArticle: Identifiable, Sendable { + public let id: UInt32 + public let parentID: UInt32 + public let flags: UInt32 + public let title: String + public let username: String + public let date: Date? + public var flavors: [(String, UInt16)] = [] + public var path: [String] = [] + + public static func == (lhs: HotlineNewsArticle, rhs: HotlineNewsArticle) -> Bool { return lhs.id == rhs.id } } -struct HotlineAccount: Identifiable { - let id: UUID = UUID() +public struct HotlineAccount: Identifiable { + public let id: UUID = UUID() var name: String = "" var login: String = "" var password: String? = nil @@ -279,11 +287,11 @@ struct HotlineAccount: Identifiable { } } } - - static func == (lhs: HotlineAccount, rhs: HotlineAccount) -> Bool { + + public static func == (lhs: HotlineAccount, rhs: HotlineAccount) -> Bool { return lhs.id == rhs.id } - + // Generate an initial random 21 character alphanumeric password, in the spirit of the original client static func randomPassword() -> String { return String((0..<20).map{_ in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".randomElement()!}) @@ -291,7 +299,7 @@ struct HotlineAccount: Identifiable { } extension HotlineAccount: Hashable { - func hash(into hasher: inout Hasher) { + public func hash(into hasher: inout Hasher) { hasher.combine(self.id) } } @@ -389,22 +397,22 @@ struct HotlineNewsList: Identifiable { } } -struct HotlineNewsCategory: Identifiable, Hashable { - let id = UUID() - let type: UInt16 - let count: UInt16 - let name: String - var path: [String] = [] - - static func == (lhs: HotlineNewsCategory, rhs: HotlineNewsCategory) -> Bool { +public struct HotlineNewsCategory: Identifiable, Hashable, Sendable { + public let id = UUID() + public let type: UInt16 + public let count: UInt16 + public let name: String + public var path: [String] = [] + + public static func == (lhs: HotlineNewsCategory, rhs: HotlineNewsCategory) -> Bool { return lhs.id == rhs.id } - - func hash(into hasher: inout Hasher) { + + public func hash(into hasher: inout Hasher) { hasher.combine(self.id) } - - init(type: UInt16, count: UInt16, name: String) { + + public init(type: UInt16, count: UInt16, name: String) { self.type = type self.count = count self.name = name @@ -438,32 +446,32 @@ struct HotlineNewsCategory: Identifiable, Hashable { @Observable -class HotlineFile: Identifiable, Hashable { - let id = UUID() - let type: String - let creator: String - let fileSize: UInt32 - let name: String - - var path: [String] = [] - var isExpanded: Bool = false - var files: [HotlineFile]? = nil - - let isFolder: Bool +public class HotlineFile: Identifiable, Hashable { + public let id = UUID() + public let type: String + public let creator: String + public let fileSize: UInt32 + public let name: String + + public var path: [String] = [] + public var isExpanded: Bool = false + public var files: [HotlineFile]? = nil + + public let isFolder: Bool - var isDropboxFolder: Bool { + public var isDropboxFolder: Bool { guard self.isFolder, (self.name.range(of: "upload", options: [.caseInsensitive]) != nil) || (self.name.range(of: "drop box", options: [.caseInsensitive]) != nil) else { return false } return true } - - static func == (lhs: HotlineFile, rhs: HotlineFile) -> Bool { + + public static func == (lhs: HotlineFile, rhs: HotlineFile) -> Bool { return lhs.id == rhs.id } - - func hash(into hasher: inout Hasher) { + + public func hash(into hasher: inout Hasher) { hasher.combine(self.id) } @@ -501,25 +509,25 @@ class HotlineFile: Identifiable, Hashable { } } -struct HotlineUser: Identifiable, Hashable { - let id: UInt16 - let iconID: UInt16 - let status: UInt16 - let name: String - - var isAdmin: Bool { +public struct HotlineUser: Identifiable, Hashable, Sendable { + public let id: UInt16 + public let iconID: UInt16 + public let status: UInt16 + public let name: String + + public var isAdmin: Bool { return ((self.status & 0x0002) != 0) } - - var isIdle: Bool { + + public var isIdle: Bool { return ((self.status & 0x0001) != 0) } - - static func == (lhs: HotlineUser, rhs: HotlineUser) -> Bool { + + public static func == (lhs: HotlineUser, rhs: HotlineUser) -> Bool { return lhs.id == rhs.id } - - init(id: UInt16, iconID: UInt16, status: UInt16, name: String) { + + public init(id: UInt16, iconID: UInt16, status: UInt16, name: String) { self.id = id self.iconID = iconID self.status = status @@ -535,10 +543,10 @@ struct HotlineUser: Identifiable, Hashable { self.name = data.readString(at: 8, length: userNameLength)! } - func hash(into hasher: inout Hasher) { + public func hash(into hasher: inout Hasher) { hasher.combine(self.id) } - + func encoded() -> [UInt8] { var data: [UInt8] = [] data.appendUInt16(self.id) @@ -819,6 +827,111 @@ struct HotlineTransaction { self.fields.append(HotlineTransactionField(type: type, pathComponents: val)) } + // MARK: - Subscript support for typed field access + // Replaces any existing field with the same type, or removes the field if value is set to nil + private mutating func replaceField(type: HotlineTransactionFieldType, with field: HotlineTransactionField?) { + // Remove existing fields of this type + self.fields.removeAll { $0.type == type } + // Append new field if provided + if let field = field { + self.fields.append(field) + } + } + + // UInt8 + subscript(_ type: HotlineTransactionFieldType) -> UInt8? { + get { self.getField(type: type)?.getUInt8() } + set { + if let v = newValue { + self.replaceField(type: type, with: HotlineTransactionField(type: type, val: v)) + } else { + self.replaceField(type: type, with: nil) + } + } + } + + // UInt16 + subscript(_ type: HotlineTransactionFieldType) -> UInt16? { + get { self.getField(type: type)?.getUInt16() } + set { + if let v = newValue { + self.replaceField(type: type, with: HotlineTransactionField(type: type, val: v)) + } else { + self.replaceField(type: type, with: nil) + } + } + } + + // UInt32 + subscript(_ type: HotlineTransactionFieldType) -> UInt32? { + get { self.getField(type: type)?.getUInt32() } + set { + if let v = newValue { + self.replaceField(type: type, with: HotlineTransactionField(type: type, val: v)) + } else { + self.replaceField(type: type, with: nil) + } + } + } + + // UInt64 + subscript(_ type: HotlineTransactionFieldType) -> UInt64? { + get { self.getField(type: type)?.getUInt64() } + set { + if let v = newValue { + self.replaceField(type: type, with: HotlineTransactionField(type: type, val: v)) + } else { + self.replaceField(type: type, with: nil) + } + } + } + + // String (plain, not obfuscated) + subscript(_ type: HotlineTransactionFieldType) -> String? { + get { self.getField(type: type)?.getString() } + set { + if let v = newValue { + self.replaceField(type: type, with: HotlineTransactionField(type: type, string: v, encoding: .utf8, encrypt: false)) + } else { + self.replaceField(type: type, with: nil) + } + } + } + + // [String] path components +// subscript(path type: HotlineTransactionFieldType) -> [String]? { +// get { +// self.getField(type: type)?.getString() +// } +// set { +// if let v = newValue { +// self.replaceField(type: type, with: HotlineTransactionField(type: type, pathComponents: v)) +// } else { +// self.replaceField(type: type, with: nil) +// } +// } +// } + + // Field list + subscript(_ type: HotlineTransactionFieldType) -> [HotlineTransactionField]? { + get { self.fields.filter { $0.type == type } } + set { + // Remove existing fields of this type + self.fields.removeAll { $0.type == type } + + if let v = newValue { + self.fields.append(contentsOf: v) + } + } + } + + // Field + subscript(_ type: HotlineTransactionFieldType) -> HotlineTransactionField? { + get { self.fields.first { $0.type == type } } + set { self.replaceField(type: type, with: newValue) } + } + + func getField(type: HotlineTransactionFieldType) -> HotlineTransactionField? { return self.fields.first { p in p.type == type @@ -838,7 +951,7 @@ struct HotlineTransaction { data.appendUInt16(self.isReply == 1 ? HotlineTransactionType.reply.rawValue : self.type.rawValue) data.appendUInt32(self.id) data.appendUInt32(self.errorCode) - + if self.fields.count > 0 { var fieldData: [UInt8] = [] fieldData.appendUInt16(UInt16(self.fields.count)) @@ -847,7 +960,7 @@ struct HotlineTransaction { fieldData.appendUInt16(f.dataSize) fieldData.appendData(f.data) } - + data.appendUInt32(UInt32(fieldData.count)) data.appendUInt32(UInt32(fieldData.count)) data.appendData(fieldData) @@ -861,6 +974,64 @@ struct HotlineTransaction { } } +// MARK: - NetSocket Protocol Conformance + +extension HotlineTransaction: NetSocketEncodable { + func encode(endian: Endian) throws -> Data { + return Data(self.encoded()) + } +} + +extension HotlineTransaction: NetSocketDecodable { + /// Decode a Hotline transaction directly from the socket stream + /// + /// Reads the 20-byte header, then reads and decodes the variable-length body with fields. + init(from socket: NetSocketNew, endian: Endian) async throws { + // Read 20-byte header + let flags = try await socket.read(UInt8.self) + let isReply = try await socket.read(UInt8.self) + let typeRaw = try await socket.read(UInt16.self, endian: endian) + let id = try await socket.read(UInt32.self, endian: endian) + let errorCode = try await socket.read(UInt32.self, endian: endian) + let totalSize = try await socket.read(UInt32.self, endian: endian) + let dataSize = try await socket.read(UInt32.self, endian: endian) + + // Initialize with header data + self.flags = flags + self.isReply = isReply + self.type = HotlineTransactionType(rawValue: typeRaw) ?? .unknown + self.id = id + self.errorCode = errorCode + self.totalSize = totalSize + self.dataSize = dataSize + self.fields = [] + + // Read body if present + guard dataSize > 0 else { return } + + let bodyData = try await socket.read(Int(dataSize)) + var fieldBytes = [UInt8](bodyData) + + // Decode fields + guard let fieldCount = fieldBytes.consumeUInt16(), fieldCount > 0 else { return } + + for _ in 0.. 0 { - self.fileResourceURL = fileURL.urlForResourceFork() - } - else { - self.fileResourceURL = nil - } - } - - deinit { - self.invalidate() - } - - func start() { - guard self.status == .unconnected else { - return - } - - let _ = self.fileURL.startAccessingSecurityScopedResource() - let _ = self.fileResourceURL?.stopAccessingSecurityScopedResource() - - self.bytesSent = 0 - self.connect() - } - - func cancel() { - self.delegate = nil - - if self.status == .unconnected { - return - } - - self.invalidate() - - print("HotlineFileUploadClient: Cancelled upload") - } - - private func connect() { - self.connection = NWConnection(host: self.serverAddress, port: self.serverPort, using: .tcp) - self.connection?.stateUpdateHandler = { [weak self] (newState: NWConnection.State) in - switch newState { - case .ready: - self?.status = .connected - self?.stage = .magic - self?.send() - case .waiting(let err): - print("HotlineFileClient: Waiting", err) - case .cancelled: - print("HotlineFileClient: Cancelled") - self?.invalidate() - case .failed(let err): - print("HotlineFileClient: Connection error \(err)") - switch self?.status { - case .connecting: - print("HotlineFileClient: Failed to connect to file transfer server.") - self?.invalidate() - self?.status = .failed(.failedToConnect) - case .connected, .progress(_): - print("HotlineFileClient: Failed to finish transfer.") - self?.invalidate() - self?.status = .failed(.failedToUpload) - case .completing: - print("HotlineFileClient: Completed.") - self?.invalidate() - self?.status = .completed - DispatchQueue.main.async { [weak self] in - if let s = self { - s.delegate?.hotlineFileUploadComplete(client: s, reference: s.referenceNumber) - } - } - case .completed: - self?.invalidate() - default: - break - } - default: - return - } - } - - self.status = .connecting - self.connection?.start(queue: .global()) - } - - private func invalidate() { - if let c = self.connection { - c.stateUpdateHandler = nil - c.cancel() - - self.connection = nil - } - - self.stage = .magic - - if let fh = self.fileHandle { - try? fh.close() - self.fileHandle = nil - } - - self.fileURL.stopAccessingSecurityScopedResource() - self.fileResourceURL?.stopAccessingSecurityScopedResource() - } - - private func sendComplete() { - guard let c = self.connection else { - self.invalidate() - print("HotlineFileUploadClient: invalid connection to send data.") - return - } - - self.status = .completing - - c.send(content: nil, contentContext: .finalMessage, completion: .contentProcessed({ error in - })) - } - - private func sendFileData(_ data: Data) { - guard let c = self.connection else { - self.invalidate() - - print("HotlineFileUploadClient: invalid connection to send data.") - return - } - - let dataSent: Int = data.count - c.send(content: data, completion: .contentProcessed({ [weak self] error in - guard let client = self, - error == nil else { - self?.status = .failed(.failedToConnect) - self?.invalidate() - return - } - - - client.bytesSent += dataSent - client.status = .progress(Double(client.bytesSent) / Double(client.payloadSize)) - - client.send() - })) - } - - private func send() { - guard let _ = self.connection else { - self.invalidate() - print("HotlineFileUploadClient: Invalid connection to send.") - return - } - - switch self.stage { - case .magic: - print("Upload: Starting upload for \(self.fileURL)") - print("Upload: Sending magic") - self.status = .progress(0.0) - - let magicData = Data(endian: .big) { - "HTXF".fourCharCode() - self.referenceNumber - self.payloadSize - UInt32.zero - } - // var magicData = Data() - // magicData.appendUInt32("HTXF".fourCharCode()) - // magicData.appendUInt32(self.referenceNumber) - // magicData.appendUInt32(self.payloadSize) - // magicData.appendUInt32(0) - self.stage = .fileHeader - self.sendFileData(magicData) - - case .fileHeader: - print("Upload: Sending file header") - if let header = HotlineFileHeader(file: self.fileURL) { - self.stage = .fileInfoForkHeader - self.sendFileData(header.data()) - } - - case .fileInfoForkHeader: - print("Upload: Sending info fork header") - let header = HotlineFileForkHeader(type: HotlineFileForkType.info.rawValue, dataSize: UInt32(self.infoForkData.count)) - self.stage = .fileInfoFork - self.sendFileData(header.data()) - - case .fileInfoFork: - print("Upload: Sending info fork") - self.stage = .fileDataForkHeader - self.sendFileData(self.infoForkData) - - case .fileDataForkHeader: - guard self.dataForkSize > 0 else { - print("Upload: Data fork empty, skipping to resource fork") - self.stage = .fileResourceForkHeader - fallthrough - } - - do { - let fh = try FileHandle(forReadingFrom: self.fileURL) - self.fileHandle = fh - self.stage = .fileDataFork - - let header = HotlineFileForkHeader(type: HotlineFileForkType.data.rawValue, dataSize: self.dataForkSize) - - print("Upload: Sending data fork header \(self.dataForkSize)") - self.sendFileData(header.data()) - } - catch { - print("Upload: Error opening data fork", error) - self.invalidate() - return - } - - case .fileDataFork: - guard self.dataForkSize > 0, - let fh = self.fileHandle else { - print("Upload: Data fork empty, skipping to resource fork") - self.stage = .fileResourceForkHeader - try? self.fileHandle?.close() - self.fileHandle = nil - fallthrough - } - - do { - let fileData = try fh.read(upToCount: 4 * 1024) - if fileData == nil || fileData?.isEmpty == true { - print("Upload: Finished data fork") - self.stage = .fileResourceForkHeader - try? fh.close() - self.fileHandle = nil - fallthrough - } - - print("Upload: Sending data Fork \(String(describing: fileData?.count))") - self.sendFileData(fileData!) - } - catch { - self.invalidate() - print("Upload: Error reading data fork", error) - return - } - - case .fileResourceForkHeader: - guard self.resourceForkSize > 0, - let resourceURL = self.fileResourceURL else { - print("Upload: Skipping resource fork header") - self.stage = .fileComplete - fallthrough - } - - print("Upload: Sending resource fork header") - guard let fh = try? FileHandle(forReadingFrom: resourceURL) else { - print("Upload: Error reading resource fork") - self.invalidate() - return - } - - let header = HotlineFileForkHeader(type: HotlineFileForkType.resource.rawValue, dataSize: self.resourceForkSize) - - self.fileHandle = fh - self.stage = .fileResourceFork - self.sendFileData(header.data()) - - case .fileResourceFork: - guard self.resourceForkSize > 0, - let fh = self.fileHandle else { - print("Upload: Resource fork empty, skipping to completion") - self.stage = .fileComplete - try? self.fileHandle?.close() - self.fileHandle = nil - fallthrough - } - - do { - let resourceData = try fh.read(upToCount: 4 * 1024) - if resourceData == nil || resourceData?.isEmpty == true { - print("Upload: Finished resource fork") - self.stage = .fileComplete - try? self.fileHandle?.close() - self.fileHandle = nil - fallthrough - } - - print("Upload: Sending resource fork \(String(describing: resourceData?.count))") - self.sendFileData(resourceData!) - } - catch { - self.invalidate() - print("Upload: Error reading resource fork", error) - return - } - break - - case .fileComplete: - print("Upload: Complete!") - self.sendComplete() - } - } -} - -// MARK: - - -class HotlineFilePreviewClient: HotlineTransferClient { - let serverAddress: NWEndpoint.Host - let serverPort: NWEndpoint.Port - let referenceNumber: UInt32 - let referenceDataSize: UInt32 - - weak var delegate: HotlineFilePreviewClientDelegate? = nil - - var status: HotlineTransferStatus = .unconnected { - didSet { - DispatchQueue.main.async { - self.delegate?.hotlineTransferStatusChanged(client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0) - } - } - } - - private var downloadTask: Task? - - init(address: String, port: UInt16, reference: UInt32, size: UInt32) { - self.serverAddress = NWEndpoint.Host(address) - self.serverPort = NWEndpoint.Port(rawValue: port + 1)! - self.referenceNumber = reference - self.referenceDataSize = size - } - - deinit { - self.downloadTask?.cancel() - } - - func start() { - guard status == .unconnected else { - return - } - - self.downloadTask = Task { - await self.download() - } - } - - func cancel() { - self.downloadTask?.cancel() - self.downloadTask = nil - self.delegate = nil - - print("HotlineFilePreviewClient: Cancelled preview transfer") - } - - private func download() async { - self.status = .connecting - - do { - // Connect to file transfer server (already includes +1 in serverPort from init) - let socket = try await NetSocketNew.connect( - host: self.serverAddress, - port: self.serverPort, - tls: .disabled - ) - defer { Task { await socket.close() } } - - self.status = .connected - - // Send magic header - let headerData = Data(endian: .big) { - "HTXF".fourCharCode() - self.referenceNumber - UInt32.zero - UInt32.zero - } - try await socket.write(headerData) - - self.status = .progress(0.0) - - // Download file data with progress updates - let fileData = try await socket.read(Int(self.referenceDataSize)) { current, total in - self.status = .progress(Double(current) / Double(total)) - } - - print("HotlineFilePreviewClient: Complete") - status = .completed - - // Notify delegate on main thread - let reference = self.referenceNumber - await MainActor.run { - self.delegate?.hotlineFilePreviewComplete(client: self, reference: reference, data: fileData) - } - - } catch is CancellationError { - // Already handled in cancel() - return - } catch { - print("HotlineFilePreviewClient: Download failed: \(error)") - - if self.status == .connecting { - self.status = .failed(.failedToConnect) - } else { - self.status = .failed(.failedToDownload) - } - } - } -} - -// MARK: - - -class HotlineFileDownloadClient: HotlineTransferClient { - let serverAddress: NWEndpoint.Host - let serverPort: NWEndpoint.Port - let referenceNumber: UInt32 - - private var connection: NWConnection? - private var transferStage: HotlineFileTransferStage = .fileHeader - - weak var delegate: HotlineFileDownloadClientDelegate? = nil - - var status: HotlineTransferStatus = .unconnected { - didSet { - DispatchQueue.main.async { - self.delegate?.hotlineTransferStatusChanged(client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0) - } - } - } - - private let referenceDataSize: UInt32 - private var fileBytes = Data() - private var fileResourceBytes = Data() - - private var fileHeader: HotlineFileHeader? = nil - private var fileCurrentForkHeader: HotlineFileForkHeader? = nil - private var fileCurrentForkBytesLeft: Int = 0 - private var fileInfoFork: HotlineFileInfoFork? = nil - private var fileHandle: FileHandle? = nil - private var filePath: String? = nil - private var fileBytesTransferred: Int = 0 - private var fileProgress: Progress - - init(address: String, port: UInt16, reference: UInt32, size: UInt32) { - self.serverAddress = NWEndpoint.Host(address) - self.serverPort = NWEndpoint.Port(rawValue: port + 1)! - self.referenceNumber = reference - self.referenceDataSize = size - self.transferStage = .fileHeader - self.fileProgress = Progress(totalUnitCount: Int64(size)) - } - - deinit { - self.invalidate() - } - - func start() { - guard self.status == .unconnected else { - return - } - - self.filePath = nil - self.connect() - } - - func start(to fileURL: URL) { - guard self.status == .unconnected else { - return - } - - self.filePath = fileURL.path - self.connect() - } - - func cancel() { - self.delegate = nil - - if self.status == .unconnected { - return - } - - // Close file before we try to potentionally delete it. - if let fh = self.fileHandle { - try? fh.close() - self.fileHandle = nil - } - - if let downloadPath = self.filePath { - print("HotlineFileClient: Deleting file fragment at", downloadPath) - if FileManager.default.isDeletableFile(atPath: downloadPath) { - try? FileManager.default.removeItem(atPath: downloadPath) - } - self.filePath = nil - } - - self.invalidate() - - print("HotlineFileClient: Cancelled transfer") - } - - private func connect() { - self.connection = NWConnection(host: self.serverAddress, port: self.serverPort, using: .tcp) - self.connection?.stateUpdateHandler = { [weak self] (newState: NWConnection.State) in - switch newState { - case .ready: - self?.status = .connected - self?.sendMagic() - case .waiting(let err): - print("HotlineFileClient: Waiting", err) - case .cancelled: - print("HotlineFileClient: Cancelled") - self?.invalidate() - case .failed(let err): - print("HotlineFileClient: Connection error \(err)") - switch self?.status { - case .connecting: - print("HotlineFileClient: Failed to connect to file transfer server.") - self?.invalidate() - self?.status = .failed(.failedToConnect) - case .connected, .progress(_): - print("HotlineFileClient: Failed to finish transfer.") - self?.invalidate() - self?.status = .failed(.failedToDownload) - default: - break - } - default: - return - } - } - - self.status = .connecting - self.connection?.start(queue: .global()) - } - - func invalidate() { - if let c = self.connection { - c.stateUpdateHandler = nil - c.cancel() - - self.connection = nil - } - - self.fileBytes = Data() - - if let fh = self.fileHandle { - try? fh.close() - self.fileHandle = nil - } - - self.fileProgress.unpublish() - } - - private func sendMagic() { - guard let c = connection, self.status == .connected else { - self.invalidate() - print("HotlineFileClient: invalid connection to send header.") - return - } - - let headerData = Data(endian: .big) { - "HTXF".fourCharCode() - self.referenceNumber - UInt32.zero - UInt32.zero - } - - c.send(content: headerData, completion: .contentProcessed { [weak self] (error) in - guard let self = self else { - return - } - - guard error == nil else { - self.status = .failed(.failedToConnect) - self.invalidate() - return - } - - self.status = .progress(0.0) - self.receiveFile() - }) - } - - private func receiveFile() { - guard let c = self.connection else { - return - } - - c.receive(minimumIncompleteLength: 1, maximumLength: Int(UInt16.max)) { [weak self] (data, context, isComplete, error) in - guard let self = self else { - return - } - - guard error == nil else { - self.status = .failed(.failedToDownload) - self.invalidate() - return - } - - if let newData = data, !newData.isEmpty { - self.fileBytesTransferred += newData.count - self.fileBytes.append(newData) - self.fileProgress.completedUnitCount = Int64(self.fileBytesTransferred) - self.status = .progress(Double(self.fileBytesTransferred) / Double(self.referenceDataSize)) - } - - // See if we need header data still. - var keepProcessing = false - repeat { - keepProcessing = false - - switch self.transferStage { - case .fileHeader: - if let header = HotlineFileHeader(from: self.fileBytes) { - self.fileBytes.removeSubrange(0..= infoForkDataSize { - let infoForkData = self.fileBytes.subdata(in: 0.. 0 { - if let f = self.fileHandle { - do { - var dataToWrite = self.fileBytes - - if dataToWrite.count >= self.fileCurrentForkBytesLeft { - dataToWrite = self.fileBytes.subdata(in: 0.. 0 { - var dataToWrite = self.fileBytes - - if dataToWrite.count >= self.fileCurrentForkBytesLeft { - dataToWrite = self.fileBytes.subdata(in: 0.. 0 { - var dataToWrite = self.fileBytes - - if dataToWrite.count >= self.fileCurrentForkBytesLeft { - dataToWrite = self.fileBytes.subdata(in: 0.. 0 { - let _ = self.writeResourceFork() - self.fileResourceBytes = Data() - } - - self.status = .completed - - if let downloadPath = self.filePath { - DispatchQueue.main.sync { - print("POSTING DOWNLOAD FILE FINISHED", downloadPath) - - var downloadURL = URL(filePath: downloadPath) - downloadURL.resolveSymlinksInPath() - print("FINAL PATH", downloadURL.path) - - self.delegate?.hotlineFileDownloadComplete(client: self, reference: self.referenceNumber, at: downloadURL) - -#if os(macOS) - // Bounce dock icon when download completes. Weird this is the only API to do so. - DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path) -#endif - } - } - } - } - } - - private func writeResourceFork() -> Bool { - guard let filePath = self.filePath else { - return false - } - - var resolvedFileURL = URL(filePath: filePath) - resolvedFileURL.resolveSymlinksInPath() - - let resourceFilePath = resolvedFileURL.appendingPathComponent("..namedfork/rsrc") - - do { - try self.fileResourceBytes.write(to: resourceFilePath) - } - catch { - return false - } - - return true - } - - private func prepareDownloadFile(name: String) -> Bool { - var filePath: String - - if self.filePath != nil { - filePath = self.filePath! - } - else { - let folderURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0] - filePath = folderURL.generateUniqueFilePath(filename: name) - } - - var fileAttributes: [FileAttributeKey: Any] = [:] - if let creatorCode = self.fileInfoFork?.creator { - fileAttributes[.hfsCreatorCode] = creatorCode as NSNumber - } - if let typeCode = self.fileInfoFork?.type { - fileAttributes[.hfsTypeCode] = typeCode as NSNumber - } - if let createdDate = self.fileInfoFork?.createdDate { - fileAttributes[.creationDate] = createdDate as NSDate - } - if let modifiedDate = self.fileInfoFork?.modifiedDate { - fileAttributes[.modificationDate] = modifiedDate as NSDate - } - if let comment = self.fileInfoFork?.comment { - if let commentPlistData = try? PropertyListSerialization.data(fromPropertyList: comment, format: .binary, options: 0) { - fileAttributes[FileAttributeKey(rawValue: "NSFileExtendedAttributes")] = [ - FileAttributeKey(rawValue: "com.apple.metadata:kMDItemFinderComment"): commentPlistData - ] - } - } - - if FileManager.default.createFile(atPath: filePath, contents: nil, attributes: fileAttributes) { - if let h = FileHandle(forWritingAtPath: filePath) { - self.filePath = filePath - self.fileHandle = h - self.fileProgress.fileURL = URL(filePath: filePath).resolvingSymlinksInPath() - self.fileProgress.fileOperationKind = .downloading - self.fileProgress.publish() - return true - } - } - - return false - } -} - -// MARK: - struct HotlineFileHeader { static let DataSize: Int = 4 + 2 + 16 + 2 @@ -1017,8 +75,8 @@ struct HotlineFileHeader { self.format = "FILP".fourCharCode() self.version = 1 - - let resourceURL = fileURL.appendingPathComponent("..namedfork/rsrc") + + let resourceURL = fileURL.urlForResourceFork() if FileManager.default.fileExists(atPath: resourceURL.path(percentEncoded: false)) { self.forkCount = 3 } @@ -1109,7 +167,7 @@ struct HotlineFileInfoFork { } self.platform = "AMAC".fourCharCode() - + if let hfsInfo = try? FileManager.default.getHFSTypeAndCreator(fileURL) { self.type = hfsInfo.hfsType self.creator = hfsInfo.hfsCreator @@ -1118,23 +176,23 @@ struct HotlineFileInfoFork { self.type = 0 self.creator = 0 } - + self.flags = 0 self.platformFlags = 0 - + let dateInfo = FileManager.default.getCreatedAndModifiedDates(fileURL) self.createdDate = dateInfo.createdDate self.modifiedDate = dateInfo.modifiedDate - + self.nameScript = 0 self.name = fileURL.lastPathComponent - + let fileComment = try? FileManager.default.getFinderComment(fileURL) self.comment = fileComment ?? "" - + self.headerSize = 0 } - + init?(from data: Data) { // Make sure we have at least enough data to read basic header data guard data.count >= HotlineFileInfoFork.BaseDataSize else { @@ -1227,828 +285,1836 @@ struct HotlineFileInfoFork { } } + +//protocol HotlineTransferDelegate: AnyObject { +// @MainActor func hotlineTransferStatusChanged(client: HotlineTransferClient, reference: UInt32, status: HotlineTransferStatus, timeRemaining: TimeInterval) +//} + +//protocol HotlineFileDownloadClientDelegate: HotlineTransferDelegate { +// @MainActor func hotlineFileDownloadReceivedInfo(client: HotlineFileDownloadClient, reference: UInt32, info: HotlineFileInfoFork) +// @MainActor func hotlineFileDownloadComplete(client: HotlineFileDownloadClient, reference: UInt32, at: URL) +//} + +//protocol HotlineFilePreviewClientDelegate: HotlineTransferDelegate { +// @MainActor func hotlineFilePreviewComplete(client: HotlineFilePreviewClient, reference: UInt32, data: Data) +//} + +//protocol HotlineFileUploadClientDelegate: HotlineTransferDelegate { +// @MainActor func hotlineFileUploadComplete(client: HotlineFileUploadClient, reference: UInt32) +//} + +//protocol HotlineFolderDownloadClientDelegate: HotlineTransferDelegate { +// @MainActor func hotlineFolderDownloadReceivedFileInfo(client: HotlineFolderDownloadClient, reference: UInt32, fileName: String, itemNumber: Int, totalItems: Int) +// @MainActor func hotlineFolderDownloadComplete(client: HotlineFolderDownloadClient, reference: UInt32, at: URL) +//} + +//enum HotlineFileTransferStage: Int { +// case fileHeader = 1 +// case fileForkHeader = 2 +// case fileInfoFork = 3 +// case fileDataFork = 4 +// case fileResourceFork = 5 +// case fileUnsupportedFork = 6 +//} + +//enum HotlineFileUploadStage: Int { +// case magic = 1 +// case fileHeader = 2 +// case fileInfoForkHeader = 3 +// case fileInfoFork = 4 +// case fileDataForkHeader = 5 +// case fileDataFork = 6 +// case fileResourceForkHeader = 7 +// case fileResourceFork = 8 +// case fileComplete = 9 +//} + +//enum HotlineFolderDownloadStage: Int { +// case itemHeader = 0 // Read 2-byte length + item header +// case waitingForFileSize = 1 // Read 4-byte file size before FILP +// case fileHeader = 2 +// case fileForkHeader = 3 +// case fileInfoFork = 4 +// case fileDataFork = 5 +// case fileResourceFork = 6 +// case fileUnsupportedFork = 7 +//} + + // MARK: - -class HotlineFolderDownloadClient: HotlineTransferClient { - let serverAddress: NWEndpoint.Host - let serverPort: NWEndpoint.Port - let referenceNumber: UInt32 - - private var connection: NWConnection? - private var transferStage: HotlineFolderDownloadStage = .fileHeader - - weak var delegate: HotlineFolderDownloadClientDelegate? = nil - - var status: HotlineTransferStatus = .unconnected { - didSet { - DispatchQueue.main.async { - self.delegate?.hotlineTransferStatusChanged(client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0) - } - } - } - - private let referenceDataSize: UInt32 - private let folderItemCount: Int - private var fileBytes = Data() - private var fileResourceBytes = Data() - - private var fileHeader: HotlineFileHeader? = nil - private var fileCurrentForkHeader: HotlineFileForkHeader? = nil - private var fileCurrentForkBytesLeft: Int = 0 - private var fileForksRemaining: Int = 0 - private var currentFileSize: UInt32 = 0 // Size of current file from server - private var currentFileBytesRead: Int = 0 // Bytes read for current file - private var fileInfoFork: HotlineFileInfoFork? = nil - private var fileHandle: FileHandle? = nil - private var currentFilePath: String? = nil - private var fileBytesTransferred: Int = 0 - private var fileProgress: Progress - - private var currentItemNumber: Int = 0 - private var completedItemCount: Int = 0 // Track actually completed files - private var folderPath: String? = nil - private var currentItemRelativePath: [String] = [] - private var currentFileName: String? = nil - - private let FILP_MAGIC: UInt32 = "FILP".fourCharCode() - - init(address: String, port: UInt16, reference: UInt32, size: UInt32, itemCount: Int) { - self.serverAddress = NWEndpoint.Host(address) - self.serverPort = NWEndpoint.Port(rawValue: port + 1)! - self.referenceNumber = reference - self.referenceDataSize = size - self.folderItemCount = itemCount - self.transferStage = .fileHeader - self.fileProgress = Progress(totalUnitCount: Int64(size)) - } - - deinit { - self.invalidate() - } - - func start() { - guard self.status == .unconnected else { - return - } - - self.folderPath = nil - self.connect() - } - - func start(to folderURL: URL) { - print("HotlineFolderDownloadClient: start(to:) called with path: \(folderURL.path)") - guard self.status == .unconnected else { - print("HotlineFolderDownloadClient: Already connected, status: \(self.status)") - return - } - - self.folderPath = folderURL.path - print("HotlineFolderDownloadClient: Calling connect()") - self.connect() - } - - func cancel() { - self.delegate = nil - - if self.status == .unconnected { - return - } - - // Close file before we try to potentially delete it. - if let fh = self.fileHandle { - try? fh.close() - self.fileHandle = nil - } - - if let downloadPath = self.folderPath { - print("HotlineFolderDownloadClient: Deleting folder fragment at", downloadPath) - if FileManager.default.isDeletableFile(atPath: downloadPath) { - try? FileManager.default.removeItem(atPath: downloadPath) - } - self.folderPath = nil - } - - self.invalidate() - - print("HotlineFolderDownloadClient: Cancelled transfer") - } - - private func connect() { - print("HotlineFolderDownloadClient: connect() called, connecting to \(self.serverAddress):\(self.serverPort)") - self.connection = NWConnection(host: self.serverAddress, port: self.serverPort, using: .tcp) - print("HotlineFolderDownloadClient: NWConnection created") - self.connection?.stateUpdateHandler = { [weak self] (newState: NWConnection.State) in - switch newState { - case .ready: - print("HotlineFolderDownloadClient: Connection ready!") - self?.status = .connected - self?.sendMagic() - case .waiting(let err): - print("HotlineFolderDownloadClient: Waiting", err) - case .cancelled: - print("HotlineFolderDownloadClient: Cancelled") - self?.invalidate() - case .failed(let err): - print("HotlineFolderDownloadClient: Connection error \(err)") - switch self?.status { - case .connecting: - print("HotlineFolderDownloadClient: Failed to connect to file transfer server.") - self?.invalidate() - self?.status = .failed(.failedToConnect) - case .connected, .progress(_): - print("HotlineFolderDownloadClient: Failed to finish transfer.") - self?.invalidate() - self?.status = .failed(.failedToDownload) - default: - break - } - default: - return - } - } - - self.status = .connecting - self.connection?.start(queue: .global()) - } - - func invalidate() { - if let c = self.connection { - c.stateUpdateHandler = nil - c.cancel() - - self.connection = nil - } - - self.fileBytes = Data() - - if let fh = self.fileHandle { - try? fh.close() - self.fileHandle = nil - } - - self.fileProgress.unpublish() - } - - private func parseItemHeaderPath(_ headerData: Data) -> (type: UInt16, components: [String])? { - // Need at least: type(2) + count(2) - guard headerData.count >= 4, - let type = headerData.readUInt16(at: 0), - let count = headerData.readUInt16(at: 2) else { return nil } - - var ofs = 4 - var comps: [String] = [] - for _ in 0..= ofs + 3 else { return nil } - // per Hotline path encoding: reserved(2) then nameLen(1) then name - ofs += 2 // reserved == 0 - let nameLen = Int(headerData.readUInt8(at: ofs)!) - ofs += 1 - guard headerData.count >= ofs + nameLen else { return nil } - let nameData = headerData.subdata(in: ofs..<(ofs + nameLen)) - ofs += nameLen - - let name = String(data: nameData, encoding: .macOSRoman) - ?? String(data: nameData, encoding: .utf8) - ?? "" - comps.append(name) - } - return (type, comps) - } - - /// Find and align the buffer to the start of a FILP header. - /// Returns the number of bytes dropped. - @discardableResult - private func alignBufferToFILP() -> Int { - // We need at least the 4-byte magic to try - guard self.fileBytes.count >= 4 else { return 0 } - let magicData = Data([0x46, 0x49, 0x4C, 0x50]) // "FILP" - if self.fileBytes.starts(with: magicData) { return 0 } - - if let r = self.fileBytes.firstRange(of: magicData) { - let toDrop = r.lowerBound - if toDrop > 0 { - print("HotlineFolderDownloadClient: Resync — dropping \(toDrop) stray bytes before FILP") - self.fileBytes.removeSubrange(0..= 2 else { break } - let headerLen = Int(self.fileBytes.readUInt16(at: 0)!) - guard self.fileBytes.count >= 2 + headerLen else { break } - - let headerData = self.fileBytes.subdata(in: 2..<(2 + headerLen)) - - guard let parsed = parseItemHeaderPath(headerData) else { - print("HotlineFolderDownloadClient: Invalid item header; waiting for more data") - break - } - - // Consume the header from the buffer - self.fileBytes.removeSubrange(0..<(2 + headerLen)) - - let itemType = parsed.type - let comps = parsed.components - let joinedPath = comps.joined(separator: "/") - print("HotlineFolderDownloadClient: item type=\(itemType) path=\(joinedPath)") - - guard !comps.isEmpty else { - print("HotlineFolderDownloadClient: Empty path components for item type \(itemType); requesting next item") - self.sendAction(.nextFile) - keepProcessing = !self.fileBytes.isEmpty - break - } - - if itemType == 1 { - // Folder entries: create the directory locally and continue. - self.currentItemRelativePath = comps - - if let base = self.folderPath { - var dir = base - for c in comps { dir = (dir as NSString).appendingPathComponent(c) } - do { - try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) - print("HotlineFolderDownloadClient: Created folder at \(dir)") - } catch { - print("HotlineFolderDownloadClient: Failed to create subfolder: \(error)") - } - } - - self.completedItemCount += 1 - - if self.completedItemCount >= self.folderItemCount { - self.handleAllItemsDownloaded() - return - } - - self.sendAction(.nextFile) - keepProcessing = !self.fileBytes.isEmpty - break - } - else if itemType == 0 { - // File entries include the full path; split parent components from filename. - self.currentItemRelativePath = Array(comps.dropLast()) - self.currentFileName = comps.last - - self.transferStage = .waitingForFileSize - print("HotlineFolderDownloadClient: Requesting file download for '\(self.currentFileName ?? "?")'") - self.sendAction(.sendFile) - - if self.fileBytes.count >= 4 { - keepProcessing = true - } - break - } - else { - print("HotlineFolderDownloadClient: Unknown item type \(itemType); skipping") - self.sendAction(.nextFile) - keepProcessing = !self.fileBytes.isEmpty - break - } - - case .waitingForFileSize: - // Read 4-byte file size that comes before FILP in folder mode - guard self.fileBytes.count >= 4 else { break } - let fileSize = self.fileBytes.readUInt32(at: 0)! - self.fileBytes.removeSubrange(0..<4) - - print("HotlineFolderDownloadClient: File size: \(fileSize) bytes") - - self.currentFileSize = fileSize - self.currentFileBytesRead = 0 - - // Align to FILP boundary before decoding the file header - let dropped = self.alignBufferToFILP() - if dropped > 0 { - // These bytes were in the stream for this file but not part of FILP. - // Keep byte-accounting consistent by shrinking the expected size. - if self.currentFileSize >= UInt32(dropped) { - self.currentFileSize -= UInt32(dropped) - } else { - // Defensive: if weird, treat as zero-size to avoid underflow - self.currentFileSize = 0 - } - } - - self.transferStage = .fileHeader - keepProcessing = true - - case .fileHeader: - // Make sure we're actually at a FILP header - if self.fileBytes.count >= HotlineFileHeader.DataSize { - // If the 4-byte magic doesn't match, try one more resync here - if self.fileBytes.readUInt32(at: 0)! != FILP_MAGIC { - let dropped = self.alignBufferToFILP() - if dropped > 0 { - if self.currentFileSize >= UInt32(dropped) { self.currentFileSize -= UInt32(dropped) } - } - // If still not aligned or not enough bytes, wait for more data - guard self.fileBytes.count >= HotlineFileHeader.DataSize, - self.fileBytes.readUInt32(at: 0)! == FILP_MAGIC else { break } - } - - if let header = HotlineFileHeader(from: self.fileBytes) { - // Sanity gate: version and fork count - if header.format != FILP_MAGIC || header.version == 0 || header.forkCount > 3 { - print("HotlineFolderDownloadClient: Invalid FILP header (fmt=\(String(format:"0x%08X", header.format)), ver=\(header.version), forks=\(header.forkCount)). Resyncing.") - // Try resync and wait for more data - let dropped = self.alignBufferToFILP() - if dropped > 0 { if self.currentFileSize >= UInt32(dropped) { self.currentFileSize -= UInt32(dropped) } } - break - } - - self.fileBytes.removeSubrange(0..= self.folderItemCount { - self.handleAllItemsDownloaded() - return - } - - // More files to download - // Check if server has pipelined all remaining data (we've received more than expected total) - print("HotlineFolderDownloadClient: Completed \(self.completedItemCount)/\(self.folderItemCount) items, requesting next entry") - self.transferStage = .itemHeader - self.sendAction(.nextFile) - keepProcessing = !self.fileBytes.isEmpty - } - // File not complete - try to read next fork header - else if let forkHeader = HotlineFileForkHeader(from: self.fileBytes) { - // Quick validation: first fork must be INFO, and sizes must be plausible - let remainingForThisFile = max(0, Int(self.currentFileSize) - self.currentFileBytesRead) - let plausible = forkHeader.dataSize <= UInt32(remainingForThisFile) - let knownType = forkHeader.isInfoFork || forkHeader.isDataFork || forkHeader.isResourceFork - - if !plausible || (!knownType && self.currentFileBytesRead == HotlineFileHeader.DataSize) { - print("HotlineFolderDownloadClient: Implausible fork header (type=\(String(format:"0x%08X", forkHeader.forkType)), size=\(forkHeader.dataSize), remaining=\(remainingForThisFile)). Resyncing.") - let dropped = self.alignBufferToFILP() - if dropped > 0 { if self.currentFileSize >= UInt32(dropped) { self.currentFileSize -= UInt32(dropped) } } - break - } - - self.fileBytes.removeSubrange(0..= infoForkDataSize { - let infoForkData = self.fileBytes.subdata(in: 0.. 0 { - if let f = self.fileHandle { - do { - var dataToWrite = self.fileBytes - - if dataToWrite.count >= self.fileCurrentForkBytesLeft { - dataToWrite = self.fileBytes.subdata(in: 0.. 0 { - var dataToWrite = self.fileBytes - - if dataToWrite.count >= self.fileCurrentForkBytesLeft { - dataToWrite = self.fileBytes.subdata(in: 0.. 0 { - var dataToWrite = self.fileBytes - - if dataToWrite.count >= self.fileCurrentForkBytesLeft { - dataToWrite = self.fileBytes.subdata(in: 0.. 0 { - let _ = self.writeResourceFork() - self.fileResourceBytes = Data() - } - - self.fileCurrentForkHeader = nil - self.fileCurrentForkBytesLeft = 0 - self.fileForksRemaining = 0 - self.currentFileBytesRead = 0 - self.currentFileSize = 0 - self.currentFilePath = nil - self.fileInfoFork = nil - self.fileHeader = nil - self.currentFileName = nil - } - - private func handleAllItemsDownloaded() { - guard self.status != .completed else { - return - } - - print("HotlineFolderDownloadClient: All \(self.folderItemCount) items downloaded") - - self.invalidate() - self.status = .completed - - if let downloadPath = self.folderPath { - DispatchQueue.main.sync { - print("HotlineFolderDownloadClient: Folder download complete", downloadPath) - - var downloadURL = URL(filePath: downloadPath) - downloadURL.resolveSymlinksInPath() - - self.delegate?.hotlineFolderDownloadComplete(client: self, reference: self.referenceNumber, at: downloadURL) - -#if os(macOS) - DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path) -#endif - } - } - } - - private func writeResourceFork() -> Bool { - guard let filePath = self.currentFilePath else { - return false - } - - var resolvedFileURL = URL(filePath: filePath) - resolvedFileURL.resolveSymlinksInPath() - - let resourceFilePath = resolvedFileURL.appendingPathComponent("..namedfork/rsrc") - - do { - try self.fileResourceBytes.write(to: resourceFilePath) - } - catch { - return false - } - - return true - } - - private func prepareDownloadFile(name: String) -> Bool { - var filePath: String - - if self.folderPath != nil { - // Build the full path including subfolders - var fullPath = self.folderPath! - for component in self.currentItemRelativePath { - fullPath = (fullPath as NSString).appendingPathComponent(component) - } - - let folderURL = URL(filePath: fullPath) - - // Create folder if it doesn't exist - if !FileManager.default.fileExists(atPath: folderURL.path) { - do { - try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) - } - catch { - print("HotlineFolderDownloadClient: Failed to create folder", error) - return false - } - } - - filePath = folderURL.appendingPathComponent(name).path - print("HotlineFolderDownloadClient: Creating file at \(filePath)") - } - else { - let downloadsURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0] - filePath = downloadsURL.generateUniqueFilePath(filename: name) - } - - var fileAttributes: [FileAttributeKey: Any] = [:] - if let creatorCode = self.fileInfoFork?.creator { - fileAttributes[.hfsCreatorCode] = creatorCode as NSNumber - } - if let typeCode = self.fileInfoFork?.type { - fileAttributes[.hfsTypeCode] = typeCode as NSNumber - } - if let createdDate = self.fileInfoFork?.createdDate { - fileAttributes[.creationDate] = createdDate as NSDate - } - if let modifiedDate = self.fileInfoFork?.modifiedDate { - fileAttributes[.modificationDate] = modifiedDate as NSDate - } - if let comment = self.fileInfoFork?.comment { - if let commentPlistData = try? PropertyListSerialization.data(fromPropertyList: comment, format: .binary, options: 0) { - fileAttributes[FileAttributeKey(rawValue: "NSFileExtendedAttributes")] = [ - FileAttributeKey(rawValue: "com.apple.metadata:kMDItemFinderComment"): commentPlistData - ] - } - } - - if FileManager.default.createFile(atPath: filePath, contents: nil, attributes: fileAttributes) { - if let h = FileHandle(forWritingAtPath: filePath) { - self.currentFilePath = filePath - self.fileHandle = h - - // Only set file progress on first file - if self.currentItemNumber == 1 && self.folderPath != nil { - self.fileProgress.fileURL = URL(filePath: self.folderPath!).resolvingSymlinksInPath() - self.fileProgress.fileOperationKind = .downloading - self.fileProgress.publish() - } - - return true - } - } - - return false - } -} +//class HotlineFileUploadClient: HotlineTransferClient { +// let serverAddress: NWEndpoint.Host +// let serverPort: NWEndpoint.Port +// let referenceNumber: UInt32 +// +// weak var delegate: HotlineFileUploadClientDelegate? = nil +// +// private var connection: NWConnection? +// private var stage: HotlineFileUploadStage = .magic +// private var payloadSize: UInt32 = 0 +// private let fileURL: URL +// private let fileResourceURL: URL? +// private var fileHandle: FileHandle? = nil +// private var bytesSent: Int = 0 +// private let infoForkData: Data +// private let dataForkSize: UInt32 +// private let resourceForkSize: UInt32 +// +// var status: HotlineTransferStatus = .unconnected { +// didSet { +// DispatchQueue.main.async { +// self.delegate?.hotlineTransferStatusChanged(client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0) +// } +// } +// } +// +// init?(upload fileURL: URL, address: String, port: UInt16, reference: UInt32) { +// guard let payloadSize = FileManager.default.getFlattenedFileSize(fileURL) else { +// return nil +// } +// +// guard let infoFork = HotlineFileInfoFork(file: fileURL) else { +// return nil +// } +// +// guard let forkSizes = try? FileManager.default.getFileForkSizes(fileURL) else { +// return nil +// } +// +// self.serverAddress = NWEndpoint.Host(address) +// self.serverPort = NWEndpoint.Port(rawValue: port + 1)! +// self.referenceNumber = reference +// self.stage = .magic +// self.payloadSize = UInt32(payloadSize) +// self.fileURL = fileURL +// self.infoForkData = infoFork.data() +// self.dataForkSize = forkSizes.dataForkSize +// self.resourceForkSize = forkSizes.resourceForkSize +// if forkSizes.resourceForkSize > 0 { +// self.fileResourceURL = fileURL.urlForResourceFork() +// } +// else { +// self.fileResourceURL = nil +// } +// } +// +// deinit { +// self.invalidate() +// } +// +// func start() { +// guard self.status == .unconnected else { +// return +// } +// +// let _ = self.fileURL.startAccessingSecurityScopedResource() +// let _ = self.fileResourceURL?.stopAccessingSecurityScopedResource() +// +// self.bytesSent = 0 +// self.connect() +// } +// +// func cancel() { +// self.delegate = nil +// +// if self.status == .unconnected { +// return +// } +// +// self.invalidate() +// +// print("HotlineFileUploadClient: Cancelled upload") +// } +// +// private func connect() { +// self.connection = NWConnection(host: self.serverAddress, port: self.serverPort, using: .tcp) +// self.connection?.stateUpdateHandler = { [weak self] (newState: NWConnection.State) in +// switch newState { +// case .ready: +// self?.status = .connected +// self?.stage = .magic +// self?.send() +// case .waiting(let err): +// print("HotlineFileClient: Waiting", err) +// case .cancelled: +// print("HotlineFileClient: Cancelled") +// self?.invalidate() +// case .failed(let err): +// print("HotlineFileClient: Connection error \(err)") +// switch self?.status { +// case .connecting: +// print("HotlineFileClient: Failed to connect to file transfer server.") +// self?.invalidate() +// self?.status = .failed(.failedToConnect) +// case .connected, .progress(_): +// print("HotlineFileClient: Failed to finish transfer.") +// self?.invalidate() +// self?.status = .failed(.failedToUpload) +// case .completing: +// print("HotlineFileClient: Completed.") +// self?.invalidate() +// self?.status = .completed +// DispatchQueue.main.async { [weak self] in +// if let s = self { +// s.delegate?.hotlineFileUploadComplete(client: s, reference: s.referenceNumber) +// } +// } +// case .completed: +// self?.invalidate() +// default: +// break +// } +// default: +// return +// } +// } +// +// self.status = .connecting +// self.connection?.start(queue: .global()) +// } +// +// private func invalidate() { +// if let c = self.connection { +// c.stateUpdateHandler = nil +// c.cancel() +// +// self.connection = nil +// } +// +// self.stage = .magic +// +// if let fh = self.fileHandle { +// try? fh.close() +// self.fileHandle = nil +// } +// +// self.fileURL.stopAccessingSecurityScopedResource() +// self.fileResourceURL?.stopAccessingSecurityScopedResource() +// } +// +// private func sendComplete() { +// guard let c = self.connection else { +// self.invalidate() +// print("HotlineFileUploadClient: invalid connection to send data.") +// return +// } +// +// self.status = .completing +// +// c.send(content: nil, contentContext: .finalMessage, completion: .contentProcessed({ error in +// })) +// } +// +// private func sendFileData(_ data: Data) { +// guard let c = self.connection else { +// self.invalidate() +// +// print("HotlineFileUploadClient: invalid connection to send data.") +// return +// } +// +// let dataSent: Int = data.count +// c.send(content: data, completion: .contentProcessed({ [weak self] error in +// guard let client = self, +// error == nil else { +// self?.status = .failed(.failedToConnect) +// self?.invalidate() +// return +// } +// +// +// client.bytesSent += dataSent +// client.status = .progress(Double(client.bytesSent) / Double(client.payloadSize)) +// +// client.send() +// })) +// } +// +// private func send() { +// guard let _ = self.connection else { +// self.invalidate() +// print("HotlineFileUploadClient: Invalid connection to send.") +// return +// } +// +// switch self.stage { +// case .magic: +// print("Upload: Starting upload for \(self.fileURL)") +// print("Upload: Sending magic") +// self.status = .progress(0.0) +// +// let magicData = Data(endian: .big) { +// "HTXF".fourCharCode() +// self.referenceNumber +// self.payloadSize +// UInt32.zero +// } +// // var magicData = Data() +// // magicData.appendUInt32("HTXF".fourCharCode()) +// // magicData.appendUInt32(self.referenceNumber) +// // magicData.appendUInt32(self.payloadSize) +// // magicData.appendUInt32(0) +// self.stage = .fileHeader +// self.sendFileData(magicData) +// +// case .fileHeader: +// print("Upload: Sending file header") +// if let header = HotlineFileHeader(file: self.fileURL) { +// self.stage = .fileInfoForkHeader +// self.sendFileData(header.data()) +// } +// +// case .fileInfoForkHeader: +// print("Upload: Sending info fork header") +// let header = HotlineFileForkHeader(type: HotlineFileForkType.info.rawValue, dataSize: UInt32(self.infoForkData.count)) +// self.stage = .fileInfoFork +// self.sendFileData(header.data()) +// +// case .fileInfoFork: +// print("Upload: Sending info fork") +// self.stage = .fileDataForkHeader +// self.sendFileData(self.infoForkData) +// +// case .fileDataForkHeader: +// guard self.dataForkSize > 0 else { +// print("Upload: Data fork empty, skipping to resource fork") +// self.stage = .fileResourceForkHeader +// fallthrough +// } +// +// do { +// let fh = try FileHandle(forReadingFrom: self.fileURL) +// self.fileHandle = fh +// self.stage = .fileDataFork +// +// let header = HotlineFileForkHeader(type: HotlineFileForkType.data.rawValue, dataSize: self.dataForkSize) +// +// print("Upload: Sending data fork header \(self.dataForkSize)") +// self.sendFileData(header.data()) +// } +// catch { +// print("Upload: Error opening data fork", error) +// self.invalidate() +// return +// } +// +// case .fileDataFork: +// guard self.dataForkSize > 0, +// let fh = self.fileHandle else { +// print("Upload: Data fork empty, skipping to resource fork") +// self.stage = .fileResourceForkHeader +// try? self.fileHandle?.close() +// self.fileHandle = nil +// fallthrough +// } +// +// do { +// let fileData = try fh.read(upToCount: 4 * 1024) +// if fileData == nil || fileData?.isEmpty == true { +// print("Upload: Finished data fork") +// self.stage = .fileResourceForkHeader +// try? fh.close() +// self.fileHandle = nil +// fallthrough +// } +// +// print("Upload: Sending data Fork \(String(describing: fileData?.count))") +// self.sendFileData(fileData!) +// } +// catch { +// self.invalidate() +// print("Upload: Error reading data fork", error) +// return +// } +// +// case .fileResourceForkHeader: +// guard self.resourceForkSize > 0, +// let resourceURL = self.fileResourceURL else { +// print("Upload: Skipping resource fork header") +// self.stage = .fileComplete +// fallthrough +// } +// +// print("Upload: Sending resource fork header") +// guard let fh = try? FileHandle(forReadingFrom: resourceURL) else { +// print("Upload: Error reading resource fork") +// self.invalidate() +// return +// } +// +// let header = HotlineFileForkHeader(type: HotlineFileForkType.resource.rawValue, dataSize: self.resourceForkSize) +// +// self.fileHandle = fh +// self.stage = .fileResourceFork +// self.sendFileData(header.data()) +// +// case .fileResourceFork: +// guard self.resourceForkSize > 0, +// let fh = self.fileHandle else { +// print("Upload: Resource fork empty, skipping to completion") +// self.stage = .fileComplete +// try? self.fileHandle?.close() +// self.fileHandle = nil +// fallthrough +// } +// +// do { +// let resourceData = try fh.read(upToCount: 4 * 1024) +// if resourceData == nil || resourceData?.isEmpty == true { +// print("Upload: Finished resource fork") +// self.stage = .fileComplete +// try? self.fileHandle?.close() +// self.fileHandle = nil +// fallthrough +// } +// +// print("Upload: Sending resource fork \(String(describing: resourceData?.count))") +// self.sendFileData(resourceData!) +// } +// catch { +// self.invalidate() +// print("Upload: Error reading resource fork", error) +// return +// } +// break +// +// case .fileComplete: +// print("Upload: Complete!") +// self.sendComplete() +// } +// } +//} + +// MARK: - +// +//class HotlineFilePreviewClient: HotlineTransferClient { +// let serverAddress: NWEndpoint.Host +// let serverPort: NWEndpoint.Port +// let referenceNumber: UInt32 +// let referenceDataSize: UInt32 +// +// weak var delegate: HotlineFilePreviewClientDelegate? = nil +// +// var status: HotlineTransferStatus = .unconnected { +// didSet { +// DispatchQueue.main.async { +// self.delegate?.hotlineTransferStatusChanged(client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0) +// } +// } +// } +// +// private var downloadTask: Task? +// +// init(address: String, port: UInt16, reference: UInt32, size: UInt32) { +// self.serverAddress = NWEndpoint.Host(address) +// self.serverPort = NWEndpoint.Port(rawValue: port + 1)! +// self.referenceNumber = reference +// self.referenceDataSize = size +// } +// +// deinit { +// self.downloadTask?.cancel() +// } +// +// func start() { +// guard status == .unconnected else { +// return +// } +// +// self.downloadTask = Task { +// await self.download() +// } +// } +// +// func cancel() { +// self.downloadTask?.cancel() +// self.downloadTask = nil +// self.delegate = nil +// +// print("HotlineFilePreviewClient: Cancelled preview transfer") +// } +// +// private func download() async { +// await MainActor.run { self.status = .connecting } +// +// do { +// // Connect to file transfer server (already includes +1 in serverPort from init) +// let socket = try await NetSocketNew.connect( +// host: self.serverAddress, +// port: self.serverPort, +// tls: .disabled +// ) +// defer { Task { await socket.close() } } +// +// await MainActor.run { self.status = .connected } +// +// // Send magic header +// let headerData = Data(endian: .big) { +// "HTXF".fourCharCode() +// self.referenceNumber +// UInt32.zero +// UInt32.zero +// } +// try await socket.write(headerData) +// +// await MainActor.run { self.status = .progress(0.0) } +// +// // Download file data with progress updates +// let fileData = try await socket.read(Int(self.referenceDataSize)) { current, total in +// Task { @MainActor [weak self] in +// guard let self else { return } +// self.status = .progress(Double(current) / Double(total)) +// } +// } +// +// print("HotlineFilePreviewClient: Complete") +// await MainActor.run { self.status = .completed } +// +// // Notify delegate +// let reference = self.referenceNumber +// await MainActor.run { +// self.delegate?.hotlineFilePreviewComplete(client: self, reference: reference, data: fileData) +// } +// +// } catch is CancellationError { +// // Already handled in cancel() +// return +// } catch { +// print("HotlineFilePreviewClient: Download failed: \(error)") +// +// let failureStatus: HotlineTransferStatus = await MainActor.run { self.status } +// +// if failureStatus == .connecting { +// await MainActor.run { self.status = .failed(.failedToConnect) } +// } else { +// await MainActor.run { self.status = .failed(.failedToDownload) } +// } +// } +// } +// +// // status mutations are centralized via MainActor.run calls above +//} + +// MARK: - Async Preview Client (New) + +//enum HotlineFilePreviewClientNew { +// typealias ProgressHandler = @Sendable (NetSocketNew.FileProgress) -> Void +// +// struct Configuration { +// /// Chunk size used when reading the preview data stream. +// var chunkSize: Int = 256 * 1024 +// } +// +// /// Download preview data directly from the transfer server. +// /// - Parameters: +// /// - address: Hostname/IP of the Hotline server (preview runs on port+1). +// /// - port: Hotline base port. +// /// - reference: Transfer reference number returned by the server. +// /// - size: Expected payload size in bytes. +// /// - configuration: Optional tuning knobs (currently chunk size). +// /// - progress: Optional callback invoked as bytes stream in. +// /// - Returns: Raw preview data. +// static func download( +// address: String, +// port: UInt16, +// reference: UInt32, +// size: UInt32, +// configuration: Configuration = .init(), +// progress: ProgressHandler? = nil +// ) async throws -> Data { +// let host = NWEndpoint.Host(address) +// guard let transferPort = NWEndpoint.Port(rawValue: port + 1) else { +// throw NetSocketError.invalidPort +// } +// +// let socket = try await NetSocketNew.connect(host: host, port: transferPort, tls: .disabled) +// defer { Task { await socket.close() } } +// +// let header = Data(endian: .big) { +// "HTXF".fourCharCode() +// reference +// UInt32.zero +// UInt32.zero +// } +// +// try await socket.write(header) +// +// guard size > 0 else { +// return Data() +// } +// +// return try await socket.read(Int(size), chunkSize: configuration.chunkSize) { current, total in +// guard let progress else { return } +// let info = NetSocketNew.FileProgress(sent: current, total: total) +// progress(info) +// } +// } +//} + +// MARK: - + +//class HotlineFileDownloadClient: HotlineTransferClient { +// let serverAddress: NWEndpoint.Host +// let serverPort: NWEndpoint.Port +// let referenceNumber: UInt32 +// +// private var connection: NWConnection? +// private var transferStage: HotlineFileTransferStage = .fileHeader +// +// weak var delegate: HotlineFileDownloadClientDelegate? = nil +// +// var status: HotlineTransferStatus = .unconnected { +// didSet { +// DispatchQueue.main.async { +// self.delegate?.hotlineTransferStatusChanged(client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0) +// } +// } +// } +// +// private let referenceDataSize: UInt32 +// private var fileBytes = Data() +// private var fileResourceBytes = Data() +// +// private var fileHeader: HotlineFileHeader? = nil +// private var fileCurrentForkHeader: HotlineFileForkHeader? = nil +// private var fileCurrentForkBytesLeft: Int = 0 +// private var fileInfoFork: HotlineFileInfoFork? = nil +// private var fileHandle: FileHandle? = nil +// private var filePath: String? = nil +// private var fileBytesTransferred: Int = 0 +// private var fileProgress: Progress +// +// init(address: String, port: UInt16, reference: UInt32, size: UInt32) { +// self.serverAddress = NWEndpoint.Host(address) +// self.serverPort = NWEndpoint.Port(rawValue: port + 1)! +// self.referenceNumber = reference +// self.referenceDataSize = size +// self.transferStage = .fileHeader +// self.fileProgress = Progress(totalUnitCount: Int64(size)) +// } +// +// deinit { +// self.invalidate() +// } +// +// func start() { +// guard self.status == .unconnected else { +// return +// } +// +// self.filePath = nil +// self.connect() +// } +// +// func start(to fileURL: URL) { +// guard self.status == .unconnected else { +// return +// } +// +// self.filePath = fileURL.path +// self.connect() +// } +// +// func cancel() { +// self.delegate = nil +// +// if self.status == .unconnected { +// return +// } +// +// // Close file before we try to potentionally delete it. +// if let fh = self.fileHandle { +// try? fh.close() +// self.fileHandle = nil +// } +// +// if let downloadPath = self.filePath { +// print("HotlineFileClient: Deleting file fragment at", downloadPath) +// if FileManager.default.isDeletableFile(atPath: downloadPath) { +// try? FileManager.default.removeItem(atPath: downloadPath) +// } +// self.filePath = nil +// } +// +// self.invalidate() +// +// print("HotlineFileClient: Cancelled transfer") +// } +// +// private func connect() { +// self.connection = NWConnection(host: self.serverAddress, port: self.serverPort, using: .tcp) +// self.connection?.stateUpdateHandler = { [weak self] (newState: NWConnection.State) in +// switch newState { +// case .ready: +// self?.status = .connected +// self?.sendMagic() +// case .waiting(let err): +// print("HotlineFileClient: Waiting", err) +// case .cancelled: +// print("HotlineFileClient: Cancelled") +// self?.invalidate() +// case .failed(let err): +// print("HotlineFileClient: Connection error \(err)") +// switch self?.status { +// case .connecting: +// print("HotlineFileClient: Failed to connect to file transfer server.") +// self?.invalidate() +// self?.status = .failed(.failedToConnect) +// case .connected, .progress(_): +// print("HotlineFileClient: Failed to finish transfer.") +// self?.invalidate() +// self?.status = .failed(.failedToDownload) +// default: +// break +// } +// default: +// return +// } +// } +// +// self.status = .connecting +// self.connection?.start(queue: .global()) +// } +// +// func invalidate() { +// if let c = self.connection { +// c.stateUpdateHandler = nil +// c.cancel() +// +// self.connection = nil +// } +// +// self.fileBytes = Data() +// +// if let fh = self.fileHandle { +// try? fh.close() +// self.fileHandle = nil +// } +// +// self.fileProgress.unpublish() +// } +// +// private func sendMagic() { +// guard let c = connection, self.status == .connected else { +// self.invalidate() +// print("HotlineFileClient: invalid connection to send header.") +// return +// } +// +// let headerData = Data(endian: .big) { +// "HTXF".fourCharCode() +// self.referenceNumber +// UInt32.zero +// UInt32.zero +// } +// +// c.send(content: headerData, completion: .contentProcessed { [weak self] (error) in +// guard let self = self else { +// return +// } +// +// guard error == nil else { +// self.status = .failed(.failedToConnect) +// self.invalidate() +// return +// } +// +// self.status = .progress(0.0) +// self.receiveFile() +// }) +// } +// +// private func receiveFile() { +// guard let c = self.connection else { +// return +// } +// +// c.receive(minimumIncompleteLength: 1, maximumLength: Int(UInt16.max)) { [weak self] (data, context, isComplete, error) in +// guard let self = self else { +// return +// } +// +// guard error == nil else { +// self.status = .failed(.failedToDownload) +// self.invalidate() +// return +// } +// +// if let newData = data, !newData.isEmpty { +// self.fileBytesTransferred += newData.count +// self.fileBytes.append(newData) +// self.fileProgress.completedUnitCount = Int64(self.fileBytesTransferred) +// self.status = .progress(Double(self.fileBytesTransferred) / Double(self.referenceDataSize)) +// } +// +// // See if we need header data still. +// var keepProcessing = false +// repeat { +// keepProcessing = false +// +// switch self.transferStage { +// case .fileHeader: +// if let header = HotlineFileHeader(from: self.fileBytes) { +// self.fileBytes.removeSubrange(0..= infoForkDataSize { +// let infoForkData = self.fileBytes.subdata(in: 0.. 0 { +// if let f = self.fileHandle { +// do { +// var dataToWrite = self.fileBytes +// +// if dataToWrite.count >= self.fileCurrentForkBytesLeft { +// dataToWrite = self.fileBytes.subdata(in: 0.. 0 { +// var dataToWrite = self.fileBytes +// +// if dataToWrite.count >= self.fileCurrentForkBytesLeft { +// dataToWrite = self.fileBytes.subdata(in: 0.. 0 { +// var dataToWrite = self.fileBytes +// +// if dataToWrite.count >= self.fileCurrentForkBytesLeft { +// dataToWrite = self.fileBytes.subdata(in: 0.. 0 { +// let _ = self.writeResourceFork() +// self.fileResourceBytes = Data() +// } +// +// self.status = .completed +// +// if let downloadPath = self.filePath { +// DispatchQueue.main.sync { +// print("POSTING DOWNLOAD FILE FINISHED", downloadPath) +// +// var downloadURL = URL(filePath: downloadPath) +// downloadURL.resolveSymlinksInPath() +// print("FINAL PATH", downloadURL.path) +// +// self.delegate?.hotlineFileDownloadComplete(client: self, reference: self.referenceNumber, at: downloadURL) +// +//#if os(macOS) +// // Bounce dock icon when download completes. Weird this is the only API to do so. +// DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path) +//#endif +// } +// } +// } +// } +// } +// +// private func writeResourceFork() -> Bool { +// guard let filePath = self.filePath else { +// return false +// } +// +// var resolvedFileURL = URL(filePath: filePath) +// resolvedFileURL.resolveSymlinksInPath() +// +// let resourceFilePath = resolvedFileURL.appendingPathComponent("..namedfork/rsrc") +// +// do { +// try self.fileResourceBytes.write(to: resourceFilePath) +// } +// catch { +// return false +// } +// +// return true +// } +// +// private func prepareDownloadFile(name: String) -> Bool { +// var filePath: String +// +// if self.filePath != nil { +// filePath = self.filePath! +// } +// else { +// let folderURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0] +// filePath = folderURL.generateUniqueFilePath(filename: name) +// } +// +// var fileAttributes: [FileAttributeKey: Any] = [:] +// if let creatorCode = self.fileInfoFork?.creator { +// fileAttributes[.hfsCreatorCode] = creatorCode as NSNumber +// } +// if let typeCode = self.fileInfoFork?.type { +// fileAttributes[.hfsTypeCode] = typeCode as NSNumber +// } +// if let createdDate = self.fileInfoFork?.createdDate { +// fileAttributes[.creationDate] = createdDate as NSDate +// } +// if let modifiedDate = self.fileInfoFork?.modifiedDate { +// fileAttributes[.modificationDate] = modifiedDate as NSDate +// } +// if let comment = self.fileInfoFork?.comment { +// if let commentPlistData = try? PropertyListSerialization.data(fromPropertyList: comment, format: .binary, options: 0) { +// fileAttributes[FileAttributeKey(rawValue: "NSFileExtendedAttributes")] = [ +// FileAttributeKey(rawValue: "com.apple.metadata:kMDItemFinderComment"): commentPlistData +// ] +// } +// } +// +// if FileManager.default.createFile(atPath: filePath, contents: nil, attributes: fileAttributes) { +// if let h = FileHandle(forWritingAtPath: filePath) { +// self.filePath = filePath +// self.fileHandle = h +// self.fileProgress.fileURL = URL(filePath: filePath).resolvingSymlinksInPath() +// self.fileProgress.fileOperationKind = .downloading +// self.fileProgress.publish() +// return true +// } +// } +// +// return false +// } +//} + +// MARK: - + + +// MARK: - + +//class HotlineFolderDownloadClient: HotlineTransferClient { +// let serverAddress: NWEndpoint.Host +// let serverPort: NWEndpoint.Port +// let referenceNumber: UInt32 +// +// private var connection: NWConnection? +// private var transferStage: HotlineFolderDownloadStage = .fileHeader +// +// weak var delegate: HotlineFolderDownloadClientDelegate? = nil +// +// var status: HotlineTransferStatus = .unconnected { +// didSet { +// DispatchQueue.main.async { +// self.delegate?.hotlineTransferStatusChanged(client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0) +// } +// } +// } +// +// private let referenceDataSize: UInt32 +// private let folderItemCount: Int +// private var fileBytes = Data() +// private var fileResourceBytes = Data() +// +// private var fileHeader: HotlineFileHeader? = nil +// private var fileCurrentForkHeader: HotlineFileForkHeader? = nil +// private var fileCurrentForkBytesLeft: Int = 0 +// private var fileForksRemaining: Int = 0 +// private var currentFileSize: UInt32 = 0 // Size of current file from server +// private var currentFileBytesRead: Int = 0 // Bytes read for current file +// private var fileInfoFork: HotlineFileInfoFork? = nil +// private var fileHandle: FileHandle? = nil +// private var currentFilePath: String? = nil +// private var fileBytesTransferred: Int = 0 +// private var fileProgress: Progress +// +// private var currentItemNumber: Int = 0 +// private var completedItemCount: Int = 0 // Track actually completed files +// private var folderPath: String? = nil +// private var currentItemRelativePath: [String] = [] +// private var currentFileName: String? = nil +// +// private let FILP_MAGIC: UInt32 = "FILP".fourCharCode() +// +// init(address: String, port: UInt16, reference: UInt32, size: UInt32, itemCount: Int) { +// self.serverAddress = NWEndpoint.Host(address) +// self.serverPort = NWEndpoint.Port(rawValue: port + 1)! +// self.referenceNumber = reference +// self.referenceDataSize = size +// self.folderItemCount = itemCount +// self.transferStage = .fileHeader +// self.fileProgress = Progress(totalUnitCount: Int64(size)) +// } +// +// deinit { +// self.invalidate() +// } +// +// func start() { +// guard self.status == .unconnected else { +// return +// } +// +// self.folderPath = nil +// self.connect() +// } +// +// func start(to folderURL: URL) { +// print("HotlineFolderDownloadClient: start(to:) called with path: \(folderURL.path)") +// guard self.status == .unconnected else { +// print("HotlineFolderDownloadClient: Already connected, status: \(self.status)") +// return +// } +// +// self.folderPath = folderURL.path +// print("HotlineFolderDownloadClient: Calling connect()") +// self.connect() +// } +// +// func cancel() { +// self.delegate = nil +// +// if self.status == .unconnected { +// return +// } +// +// // Close file before we try to potentially delete it. +// if let fh = self.fileHandle { +// try? fh.close() +// self.fileHandle = nil +// } +// +// if let downloadPath = self.folderPath { +// print("HotlineFolderDownloadClient: Deleting folder fragment at", downloadPath) +// if FileManager.default.isDeletableFile(atPath: downloadPath) { +// try? FileManager.default.removeItem(atPath: downloadPath) +// } +// self.folderPath = nil +// } +// +// self.invalidate() +// +// print("HotlineFolderDownloadClient: Cancelled transfer") +// } +// +// private func connect() { +// print("HotlineFolderDownloadClient: connect() called, connecting to \(self.serverAddress):\(self.serverPort)") +// self.connection = NWConnection(host: self.serverAddress, port: self.serverPort, using: .tcp) +// print("HotlineFolderDownloadClient: NWConnection created") +// self.connection?.stateUpdateHandler = { [weak self] (newState: NWConnection.State) in +// switch newState { +// case .ready: +// print("HotlineFolderDownloadClient: Connection ready!") +// self?.status = .connected +// self?.sendMagic() +// case .waiting(let err): +// print("HotlineFolderDownloadClient: Waiting", err) +// case .cancelled: +// print("HotlineFolderDownloadClient: Cancelled") +// self?.invalidate() +// case .failed(let err): +// print("HotlineFolderDownloadClient: Connection error \(err)") +// switch self?.status { +// case .connecting: +// print("HotlineFolderDownloadClient: Failed to connect to file transfer server.") +// self?.invalidate() +// self?.status = .failed(.failedToConnect) +// case .connected, .progress(_): +// print("HotlineFolderDownloadClient: Failed to finish transfer.") +// self?.invalidate() +// self?.status = .failed(.failedToDownload) +// default: +// break +// } +// default: +// return +// } +// } +// +// self.status = .connecting +// self.connection?.start(queue: .global()) +// } +// +// func invalidate() { +// if let c = self.connection { +// c.stateUpdateHandler = nil +// c.cancel() +// +// self.connection = nil +// } +// +// self.fileBytes = Data() +// +// if let fh = self.fileHandle { +// try? fh.close() +// self.fileHandle = nil +// } +// +// self.fileProgress.unpublish() +// } +// +// private func parseItemHeaderPath(_ headerData: Data) -> (type: UInt16, components: [String])? { +// // Need at least: type(2) + count(2) +// guard headerData.count >= 4, +// let type = headerData.readUInt16(at: 0), +// let count = headerData.readUInt16(at: 2) else { return nil } +// +// var ofs = 4 +// var comps: [String] = [] +// for _ in 0..= ofs + 3 else { return nil } +// // per Hotline path encoding: reserved(2) then nameLen(1) then name +// ofs += 2 // reserved == 0 +// let nameLen = Int(headerData.readUInt8(at: ofs)!) +// ofs += 1 +// guard headerData.count >= ofs + nameLen else { return nil } +// let nameData = headerData.subdata(in: ofs..<(ofs + nameLen)) +// ofs += nameLen +// +// let name = String(data: nameData, encoding: .macOSRoman) +// ?? String(data: nameData, encoding: .utf8) +// ?? "" +// comps.append(name) +// } +// return (type, comps) +// } +// +// /// Find and align the buffer to the start of a FILP header. +// /// Returns the number of bytes dropped. +// @discardableResult +// private func alignBufferToFILP() -> Int { +// // We need at least the 4-byte magic to try +// guard self.fileBytes.count >= 4 else { return 0 } +// let magicData = Data([0x46, 0x49, 0x4C, 0x50]) // "FILP" +// if self.fileBytes.starts(with: magicData) { return 0 } +// +// if let r = self.fileBytes.firstRange(of: magicData) { +// let toDrop = r.lowerBound +// if toDrop > 0 { +// print("HotlineFolderDownloadClient: Resync — dropping \(toDrop) stray bytes before FILP") +// self.fileBytes.removeSubrange(0..= 2 else { break } +// let headerLen = Int(self.fileBytes.readUInt16(at: 0)!) +// guard self.fileBytes.count >= 2 + headerLen else { break } +// +// let headerData = self.fileBytes.subdata(in: 2..<(2 + headerLen)) +// +// guard let parsed = parseItemHeaderPath(headerData) else { +// print("HotlineFolderDownloadClient: Invalid item header; waiting for more data") +// break +// } +// +// // Consume the header from the buffer +// self.fileBytes.removeSubrange(0..<(2 + headerLen)) +// +// let itemType = parsed.type +// let comps = parsed.components +// let joinedPath = comps.joined(separator: "/") +// print("HotlineFolderDownloadClient: item type=\(itemType) path=\(joinedPath)") +// +// guard !comps.isEmpty else { +// print("HotlineFolderDownloadClient: Empty path components for item type \(itemType); requesting next item") +// self.sendAction(.nextFile) +// keepProcessing = !self.fileBytes.isEmpty +// break +// } +// +// if itemType == 1 { +// // Folder entries: create the directory locally and continue. +// self.currentItemRelativePath = comps +// +// if let base = self.folderPath { +// var dir = base +// for c in comps { dir = (dir as NSString).appendingPathComponent(c) } +// do { +// try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) +// print("HotlineFolderDownloadClient: Created folder at \(dir)") +// } catch { +// print("HotlineFolderDownloadClient: Failed to create subfolder: \(error)") +// } +// } +// +// self.completedItemCount += 1 +// +// if self.completedItemCount >= self.folderItemCount { +// self.handleAllItemsDownloaded() +// return +// } +// +// self.sendAction(.nextFile) +// keepProcessing = !self.fileBytes.isEmpty +// break +// } +// else if itemType == 0 { +// // File entries include the full path; split parent components from filename. +// self.currentItemRelativePath = Array(comps.dropLast()) +// self.currentFileName = comps.last +// +// self.transferStage = .waitingForFileSize +// print("HotlineFolderDownloadClient: Requesting file download for '\(self.currentFileName ?? "?")'") +// self.sendAction(.sendFile) +// +// if self.fileBytes.count >= 4 { +// keepProcessing = true +// } +// break +// } +// else { +// print("HotlineFolderDownloadClient: Unknown item type \(itemType); skipping") +// self.sendAction(.nextFile) +// keepProcessing = !self.fileBytes.isEmpty +// break +// } +// +// case .waitingForFileSize: +// // Read 4-byte file size that comes before FILP in folder mode +// guard self.fileBytes.count >= 4 else { break } +// let fileSize = self.fileBytes.readUInt32(at: 0)! +// self.fileBytes.removeSubrange(0..<4) +// +// print("HotlineFolderDownloadClient: File size: \(fileSize) bytes") +// +// self.currentFileSize = fileSize +// self.currentFileBytesRead = 0 +// +// // Align to FILP boundary before decoding the file header +// let dropped = self.alignBufferToFILP() +// if dropped > 0 { +// // These bytes were in the stream for this file but not part of FILP. +// // Keep byte-accounting consistent by shrinking the expected size. +// if self.currentFileSize >= UInt32(dropped) { +// self.currentFileSize -= UInt32(dropped) +// } else { +// // Defensive: if weird, treat as zero-size to avoid underflow +// self.currentFileSize = 0 +// } +// } +// +// self.transferStage = .fileHeader +// keepProcessing = true +// +// case .fileHeader: +// // Make sure we're actually at a FILP header +// if self.fileBytes.count >= HotlineFileHeader.DataSize { +// // If the 4-byte magic doesn't match, try one more resync here +// if self.fileBytes.readUInt32(at: 0)! != FILP_MAGIC { +// let dropped = self.alignBufferToFILP() +// if dropped > 0 { +// if self.currentFileSize >= UInt32(dropped) { self.currentFileSize -= UInt32(dropped) } +// } +// // If still not aligned or not enough bytes, wait for more data +// guard self.fileBytes.count >= HotlineFileHeader.DataSize, +// self.fileBytes.readUInt32(at: 0)! == FILP_MAGIC else { break } +// } +// +// if let header = HotlineFileHeader(from: self.fileBytes) { +// // Sanity gate: version and fork count +// if header.format != FILP_MAGIC || header.version == 0 || header.forkCount > 3 { +// print("HotlineFolderDownloadClient: Invalid FILP header (fmt=\(String(format:"0x%08X", header.format)), ver=\(header.version), forks=\(header.forkCount)). Resyncing.") +// // Try resync and wait for more data +// let dropped = self.alignBufferToFILP() +// if dropped > 0 { if self.currentFileSize >= UInt32(dropped) { self.currentFileSize -= UInt32(dropped) } } +// break +// } +// +// self.fileBytes.removeSubrange(0..= self.folderItemCount { +// self.handleAllItemsDownloaded() +// return +// } +// +// // More files to download +// // Check if server has pipelined all remaining data (we've received more than expected total) +// print("HotlineFolderDownloadClient: Completed \(self.completedItemCount)/\(self.folderItemCount) items, requesting next entry") +// self.transferStage = .itemHeader +// self.sendAction(.nextFile) +// keepProcessing = !self.fileBytes.isEmpty +// } +// // File not complete - try to read next fork header +// else if let forkHeader = HotlineFileForkHeader(from: self.fileBytes) { +// // Quick validation: first fork must be INFO, and sizes must be plausible +// let remainingForThisFile = max(0, Int(self.currentFileSize) - self.currentFileBytesRead) +// let plausible = forkHeader.dataSize <= UInt32(remainingForThisFile) +// let knownType = forkHeader.isInfoFork || forkHeader.isDataFork || forkHeader.isResourceFork +// +// if !plausible || (!knownType && self.currentFileBytesRead == HotlineFileHeader.DataSize) { +// print("HotlineFolderDownloadClient: Implausible fork header (type=\(String(format:"0x%08X", forkHeader.forkType)), size=\(forkHeader.dataSize), remaining=\(remainingForThisFile)). Resyncing.") +// let dropped = self.alignBufferToFILP() +// if dropped > 0 { if self.currentFileSize >= UInt32(dropped) { self.currentFileSize -= UInt32(dropped) } } +// break +// } +// +// self.fileBytes.removeSubrange(0..= infoForkDataSize { +// let infoForkData = self.fileBytes.subdata(in: 0.. 0 { +// if let f = self.fileHandle { +// do { +// var dataToWrite = self.fileBytes +// +// if dataToWrite.count >= self.fileCurrentForkBytesLeft { +// dataToWrite = self.fileBytes.subdata(in: 0.. 0 { +// var dataToWrite = self.fileBytes +// +// if dataToWrite.count >= self.fileCurrentForkBytesLeft { +// dataToWrite = self.fileBytes.subdata(in: 0.. 0 { +// var dataToWrite = self.fileBytes +// +// if dataToWrite.count >= self.fileCurrentForkBytesLeft { +// dataToWrite = self.fileBytes.subdata(in: 0.. 0 { +// let _ = self.writeResourceFork() +// self.fileResourceBytes = Data() +// } +// +// self.fileCurrentForkHeader = nil +// self.fileCurrentForkBytesLeft = 0 +// self.fileForksRemaining = 0 +// self.currentFileBytesRead = 0 +// self.currentFileSize = 0 +// self.currentFilePath = nil +// self.fileInfoFork = nil +// self.fileHeader = nil +// self.currentFileName = nil +// } +// +// private func handleAllItemsDownloaded() { +// guard self.status != .completed else { +// return +// } +// +// print("HotlineFolderDownloadClient: All \(self.folderItemCount) items downloaded") +// +// self.invalidate() +// self.status = .completed +// +// if let downloadPath = self.folderPath { +// DispatchQueue.main.sync { +// print("HotlineFolderDownloadClient: Folder download complete", downloadPath) +// +// var downloadURL = URL(filePath: downloadPath) +// downloadURL.resolveSymlinksInPath() +// +// self.delegate?.hotlineFolderDownloadComplete(client: self, reference: self.referenceNumber, at: downloadURL) +// +//#if os(macOS) +// DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path) +//#endif +// } +// } +// } +// +// private func writeResourceFork() -> Bool { +// guard let filePath = self.currentFilePath else { +// return false +// } +// +// var resolvedFileURL = URL(filePath: filePath) +// resolvedFileURL.resolveSymlinksInPath() +// +// let resourceFilePath = resolvedFileURL.urlForResourceFork() +// +// do { +// try self.fileResourceBytes.write(to: resourceFilePath) +// } +// catch { +// return false +// } +// +// return true +// } +// +// private func prepareDownloadFile(name: String) -> Bool { +// var filePath: String +// +// if self.folderPath != nil { +// // Build the full path including subfolders +// var fullPath = self.folderPath! +// for component in self.currentItemRelativePath { +// fullPath = (fullPath as NSString).appendingPathComponent(component) +// } +// +// let folderURL = URL(filePath: fullPath) +// +// // Create folder if it doesn't exist +// if !FileManager.default.fileExists(atPath: folderURL.path) { +// do { +// try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) +// } +// catch { +// print("HotlineFolderDownloadClient: Failed to create folder", error) +// return false +// } +// } +// +// filePath = folderURL.appendingPathComponent(name).path +// print("HotlineFolderDownloadClient: Creating file at \(filePath)") +// } +// else { +// let downloadsURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0] +// filePath = downloadsURL.generateUniqueFilePath(filename: name) +// } +// +// var fileAttributes: [FileAttributeKey: Any] = [:] +// if let creatorCode = self.fileInfoFork?.creator { +// fileAttributes[.hfsCreatorCode] = creatorCode as NSNumber +// } +// if let typeCode = self.fileInfoFork?.type { +// fileAttributes[.hfsTypeCode] = typeCode as NSNumber +// } +// if let createdDate = self.fileInfoFork?.createdDate { +// fileAttributes[.creationDate] = createdDate as NSDate +// } +// if let modifiedDate = self.fileInfoFork?.modifiedDate { +// fileAttributes[.modificationDate] = modifiedDate as NSDate +// } +// if let comment = self.fileInfoFork?.comment { +// if let commentPlistData = try? PropertyListSerialization.data(fromPropertyList: comment, format: .binary, options: 0) { +// fileAttributes[FileAttributeKey(rawValue: "NSFileExtendedAttributes")] = [ +// FileAttributeKey(rawValue: "com.apple.metadata:kMDItemFinderComment"): commentPlistData +// ] +// } +// } +// +// if FileManager.default.createFile(atPath: filePath, contents: nil, attributes: fileAttributes) { +// if let h = FileHandle(forWritingAtPath: filePath) { +// self.currentFilePath = filePath +// self.fileHandle = h +// +// // Only set file progress on first file +// if self.currentItemNumber == 1 && self.folderPath != nil { +// self.fileProgress.fileURL = URL(filePath: self.folderPath!).resolvingSymlinksInPath() +// self.fileProgress.fileOperationKind = .downloading +// self.fileProgress.publish() +// } +// +// return true +// } +// } +// +// return false +// } +//} diff --git a/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift new file mode 100644 index 0000000..ced79a1 --- /dev/null +++ b/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift @@ -0,0 +1,291 @@ +import Foundation +import Network + +public enum HotlineDownloadLocation: Sendable { + case url(URL) + case downloads(String) // filename +} + + +public enum HotlineTransferProgress: Sendable { + case error(Error) // An error occurred + case unconnected // Initial state + case preparing // Preparing to begin + case connecting // Connecting to server + case connected // Connected to server + case transfer(name: String, size: Int, total: Int, progress: Double, speed: Double?, estimate: TimeInterval?) // size transferred, total size, progress (0.0-1.0), speed (in bytes/sec), time remaining + case completed(url: URL?) // Download or upload complete (local url valid for downloads) +} + +/// Modern async/await file download client for Hotline protocol +@MainActor +public class HotlineFileDownloadClientNew { + // MARK: - Configuration + + public struct Configuration: Sendable { + public var chunkSize: Int = 256 * 1024 + public init() {} + } + + // MARK: - Properties + + private let serverAddress: String + private let serverPort: UInt16 + private let referenceNumber: UInt32 + + private let config: Configuration + + private var transferSize: Int + private let transferTotal: Int + private var transferProgress: Progress + + private var socket: NetSocketNew? + private var downloadTask: Task? + + // MARK: - Initialization + + public init( + address: String, + port: UInt16, + reference: UInt32, + size: UInt32, + configuration: Configuration = .init() + ) { + self.serverAddress = address + self.serverPort = port + self.referenceNumber = reference + self.config = configuration + + self.transferTotal = Int(size) + self.transferSize = 0 + self.transferProgress = Progress(totalUnitCount: Int64(self.transferTotal)) + } + + // MARK: - Public API + + public func download( + to location: HotlineDownloadLocation, + progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil + ) async throws -> URL { + self.downloadTask?.cancel() + + let task = Task { + try await performDownload(to: location, progressHandler: progressHandler) + } + self.downloadTask = task + + do { + let url = try await task.value + self.downloadTask = nil + return url + } catch { + print("FAILED TO DOWNLOAD!", error) + self.downloadTask = nil + progressHandler?(.error(error)) + throw error + } + } + + /// Cancel the current download + public func cancel() { + downloadTask?.cancel() + downloadTask = nil + + if let socket = socket { + Task { + await socket.close() + } + } + } + + // MARK: - Private Implementation + + private func updateProgress(sent: Int) { + self.transferSize = sent + self.transferProgress.completedUnitCount = Int64(sent) + + // People can cancel a transfer from the file icon in the Finder. + // This code handles that. + if self.transferProgress.isCancelled { + self.cancel() + } + } + + private func performDownload( + to destination: HotlineDownloadLocation, + progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? + ) async throws -> URL { + + let fm = FileManager.default + var fileHandle: FileHandle? + var resourceForkData: Data? + + progressHandler?(.preparing) + + // Determine the download name + // Determine destination URL based on location + let destinationURL: URL + let destinationFilename: String + switch destination { + case .url(let url): + destinationURL = url.resolvingSymlinksInPath() + destinationFilename = destinationURL.lastPathComponent + case .downloads(let filename): + var downloadsURL = fm.urls(for: .downloadsDirectory, in: .userDomainMask)[0] + downloadsURL = downloadsURL.resolvingSymlinksInPath() + destinationURL = URL(filePath: downloadsURL.generateUniqueFilePath(filename: filename)) + destinationFilename = destinationURL.lastPathComponent + } + + progressHandler?(.connecting) + + // Connect to transfer server + let socket = try await connectToTransferServer() + self.socket = socket + defer {Task { await socket.close() } } + + // Send magic header + try await socket.write(Data(endian: .big) { + "HTXF".fourCharCode() + self.referenceNumber + UInt32.zero + UInt32.zero + }) + + // Read file header + let headerData = try await socket.read(HotlineFileHeader.DataSize) + guard let header = HotlineFileHeader(from: headerData) else { + throw HotlineFileClientError.failedToTransfer + } + + // Connected + progressHandler?(.connected) + + do { + // Process each fork + for _ in 0.. NetSocketNew { + guard let transferPort = NWEndpoint.Port(rawValue: serverPort + 1) else { + throw NetSocketError.invalidPort + } + + print("HotlineFileDownloadClientNew[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)") + + let socket = try await NetSocketNew.connect( + host: .name(serverAddress, nil), + port: transferPort, + tls: .disabled + ) + + print("HotlineFileDownloadClientNew[\(referenceNumber)]: Connected!") + return socket + } + + private func sendMagicHeader(socket: NetSocketNew) async throws { + let header = Data(endian: .big) { + "HTXF".fourCharCode() + referenceNumber + UInt32.zero + UInt32.zero + } + + try await socket.write(header) + } + + private func writeResourceFork(data: Data, to url: URL) throws { + var resolvedURL = url + resolvedURL.resolveSymlinksInPath() + + let resourceURL = resolvedURL.urlForResourceFork() + try data.write(to: resourceURL) + + print("HotlineFileDownloadClientNew[\(referenceNumber)]: Wrote resource fork (\(data.count) bytes)") + } +} diff --git a/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift b/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift new file mode 100644 index 0000000..9839997 --- /dev/null +++ b/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift @@ -0,0 +1,163 @@ +import Foundation +import Network + +@MainActor +public class HotlineFilePreviewClientNew { + // MARK: - Properties + + private let serverAddress: String + private let serverPort: UInt16 + private let referenceNumber: UInt32 + private let fileName: String + private let transferSize: UInt32 + + private var downloadClient: HotlineFileDownloadClientNew? + private var previewTask: Task? + private var temporaryFileURL: URL? + + // MARK: - Initialization + + public init( + fileName: String, + address: String, + port: UInt16, + reference: UInt32, + size: UInt32 + ) { + self.fileName = fileName + self.serverAddress = address + self.serverPort = port + self.referenceNumber = reference + self.transferSize = size + } + + deinit { + // Cleanup in deinit - must be synchronous + if let tempURL = temporaryFileURL { + try? FileManager.default.removeItem(at: tempURL) + } + } + + // MARK: - Public API + + /// Download file to temporary location for preview + /// - Parameter progressHandler: Optional progress callback + /// - Returns: URL to temporary file for preview + public func preview( + progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil + ) async throws -> URL { + self.previewTask?.cancel() + + let task = Task { + try await performPreview(progressHandler: progressHandler) + } + self.previewTask = task + + do { + let url = try await task.value + self.previewTask = nil + return url + } catch { + print("HotlineFilePreviewClientNew[\(referenceNumber)]: Failed to preview file: \(error)") + self.previewTask = nil + progressHandler?(.error(error)) + throw error + } + } + + /// Cancel the current preview download + public func cancel() { + self.previewTask?.cancel() + self.previewTask = nil + self.downloadClient?.cancel() + } + + /// Manually cleanup temporary file + /// Call this when preview is complete and you no longer need the file + public func cleanup() { + self.cleanupTempFile() + } + + // MARK: - Private Implementation + + private func performPreview( + progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? + ) async throws -> URL { + + // Create temporary file path directly in system temp directory + let tempDir = FileManager.default.temporaryDirectory + let uniqueFileName = "\(UUID().uuidString)_\(self.fileName)" + let tempFileURL = tempDir.appendingPathComponent(uniqueFileName) + self.temporaryFileURL = tempFileURL + + print("HotlineFilePreviewClientNew[\(referenceNumber)]: Downloading to temp: \(tempFileURL.path)") + + progressHandler?(.connecting) + + // Connect to transfer server + guard let transferPort = NWEndpoint.Port(rawValue: serverPort + 1) else { + throw NetSocketError.invalidPort + } + + print("HotlineFilePreviewClientNew[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)") + + let socket = try await NetSocketNew.connect( + host: .name(serverAddress, nil), + port: transferPort, + tls: .disabled + ) + defer { Task { await socket.close() } } + + print("HotlineFilePreviewClientNew[\(referenceNumber)]: Connected!") + + // Send magic header for raw data download + print("HotlineFilePreviewClientNew[\(referenceNumber)]: Sending magic header") + try await socket.write(Data(endian: .big) { + "HTXF".fourCharCode() + self.referenceNumber + UInt32.zero + UInt32.zero + }) + + progressHandler?(.connected) + + // Stream raw data directly to temp file with progress tracking + print("HotlineFilePreviewClientNew[\(referenceNumber)]: Streaming \(transferSize) bytes to temp file") + + let totalSize = Int(transferSize) + + // Create empty file + FileManager.default.createFile(atPath: tempFileURL.path(percentEncoded: false), contents: nil) + + let fileHandle = try FileHandle(forWritingTo: tempFileURL) + defer { try? fileHandle.close() } + + let updates = await socket.receiveFile(to: fileHandle, length: totalSize) + for try await p in updates { + progressHandler?(.transfer( + name: uniqueFileName, + size: p.sent, + total: totalSize, + progress: totalSize > 0 ? Double(p.sent) / Double(totalSize) : 0.0, + speed: p.bytesPerSecond, + estimate: p.estimatedTimeRemaining + )) + } + + progressHandler?(.completed(url: tempFileURL)) + + print("HotlineFilePreviewClientNew[\(referenceNumber)]: Preview file ready at \(tempFileURL.path)") + + return tempFileURL + } + + private func cleanupTempFile() { + guard let tempURL = temporaryFileURL else { return } + + // Delete the temp file + try? FileManager.default.removeItem(at: tempURL) + + temporaryFileURL = nil + print("HotlineFilePreviewClientNew[\(referenceNumber)]: Cleaned up temp file") + } +} diff --git a/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift new file mode 100644 index 0000000..f065233 --- /dev/null +++ b/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift @@ -0,0 +1,265 @@ +// +// HotlineFileUploadClientNew.swift +// Hotline +// +// Modern async/await file upload client using NetSocketNew +// + +import Foundation +import Network + +/// Modern async/await file upload client for Hotline protocol +@MainActor +public class HotlineFileUploadClientNew { + // MARK: - Configuration + + public struct Configuration: Sendable { + public var chunkSize: Int = 256 * 1024 + public var publishProgress: Bool = true + public init() {} + } + + // MARK: - Properties + + private let serverAddress: String + private let serverPort: UInt16 + private let referenceNumber: UInt32 + private let fileURL: URL + + private let config: Configuration + + private var transferSize: Int + private let transferTotal: Int + private var transferProgress: Progress + + private var socket: NetSocketNew? + private var uploadTask: Task? + + // MARK: - Initialization + + public init?( + fileURL: URL, + address: String, + port: UInt16, + reference: UInt32, + configuration: Configuration = .init() + ) { + // Validate file and get total size + guard let payloadSize = FileManager.default.getFlattenedFileSize(fileURL) else { + return nil + } + + guard FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false)) else { + return nil + } + + self.serverAddress = address + self.serverPort = port + self.referenceNumber = reference + self.fileURL = fileURL + self.config = configuration + + self.transferTotal = Int(payloadSize) + self.transferSize = 0 + self.transferProgress = Progress(totalUnitCount: Int64(self.transferTotal)) + + print("HotlineFileUploadClientNew[\(reference)]: Preparing to upload '\(fileURL.lastPathComponent)' (\(payloadSize) bytes)") + } + + // MARK: - Public API + + public func upload( + progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil + ) async throws { + self.uploadTask?.cancel() + + let task = Task { + try await performUpload(progressHandler: progressHandler) + } + self.uploadTask = task + + do { + try await task.value + self.uploadTask = nil + } catch { + print("HotlineFileUploadClientNew[\(referenceNumber)]: Failed to upload file: \(error)") + self.uploadTask = nil + progressHandler?(.error(error)) + throw error + } + } + + /// Cancel the current upload + public func cancel() { + uploadTask?.cancel() + uploadTask = nil + + if let socket = socket { + Task { + await socket.close() + } + } + } + + // MARK: - Private Implementation + + private func updateProgress(sent: Int, speed: Double? = nil, estimate: TimeInterval? = nil) { + self.transferSize = sent + self.transferProgress.completedUnitCount = Int64(sent) + + if self.transferProgress.isCancelled { + self.cancel() + } + } + + private func performUpload( + progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? + ) async throws { + let filename = self.fileURL.lastPathComponent + + progressHandler?(.connecting) + + // Start accessing security-scoped resource + let didStartAccess = fileURL.startAccessingSecurityScopedResource() + defer { + if didStartAccess { + fileURL.stopAccessingSecurityScopedResource() + } + } + + // Connect to transfer server + let socket = try await connectToTransferServer() + self.socket = socket + defer { Task { await socket.close() } } + + // Get file metadata + guard let infoFork = HotlineFileInfoFork(file: fileURL) else { + throw HotlineFileClientError.failedToTransfer + } + + guard let header = HotlineFileHeader(file: fileURL) else { + throw HotlineFileClientError.failedToTransfer + } + + guard let forkSizes = try? FileManager.default.getFileForkSizes(fileURL) else { + throw HotlineFileClientError.failedToTransfer + } + + let infoForkData = infoFork.data() + let dataForkSize = forkSizes.dataForkSize + let resourceForkSize = forkSizes.resourceForkSize + + print("HotlineFileUploadClientNew[\(referenceNumber)]: File has dataFork=\(dataForkSize) bytes, resourceFork=\(resourceForkSize) bytes") + + // Connected + progressHandler?(.connected) + + // Send magic header + print("HotlineFileUploadClientNew[\(referenceNumber)]: Sending magic header") + try await socket.write(Data(endian: .big) { + "HTXF".fourCharCode() + self.referenceNumber + UInt32(self.transferTotal) + UInt32.zero + }) + + var totalBytesSent = 0 + + // Send file header + print("HotlineFileUploadClientNew[\(referenceNumber)]: Sending file header") + let headerData = header.data() + try await socket.write(headerData) + totalBytesSent += headerData.count + + // Send INFO fork header + print("HotlineFileUploadClientNew[\(referenceNumber)]: Sending INFO fork header") + let infoForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.info.rawValue, dataSize: UInt32(infoForkData.count)) + try await socket.write(infoForkHeader.data()) + totalBytesSent += HotlineFileForkHeader.DataSize + + // Send INFO fork data + print("HotlineFileUploadClientNew[\(referenceNumber)]: Sending INFO fork (\(infoForkData.count) bytes)") + try await socket.write(infoForkData) + totalBytesSent += infoForkData.count + + self.updateProgress(sent: totalBytesSent) + progressHandler?(.transfer(name: filename, size: self.transferSize, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: nil, estimate: nil)) + + // Configure progress for Finder if enabled + if config.publishProgress { + self.transferProgress.fileURL = fileURL.resolvingSymlinksInPath() + self.transferProgress.fileOperationKind = .uploading + self.transferProgress.publish() + } + + // Send DATA fork if present + if dataForkSize > 0 { + print("HotlineFileUploadClientNew[\(referenceNumber)]: Sending DATA fork header") + let dataForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.data.rawValue, dataSize: dataForkSize) + try await socket.write(dataForkHeader.data()) + totalBytesSent += HotlineFileForkHeader.DataSize + + // Stream DATA fork + print("HotlineFileUploadClientNew[\(referenceNumber)]: Streaming DATA fork (\(dataForkSize) bytes)") + let fileHandle = try FileHandle(forReadingFrom: fileURL) + defer { try? fileHandle.close() } + + let updates = await socket.writeFile(from: fileHandle, length: Int(dataForkSize)) + for try await p in updates { + let bytesSentNow = totalBytesSent + p.sent + self.updateProgress(sent: bytesSentNow, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining) + progressHandler?(.transfer(name: filename, size: bytesSentNow, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining)) + } + + totalBytesSent += Int(dataForkSize) + } + + // Send RESOURCE fork if present + if resourceForkSize > 0 { + let resourceURL = fileURL.urlForResourceFork() + + print("HotlineFileUploadClientNew[\(referenceNumber)]: Sending RESOURCE fork header") + let resourceForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.resource.rawValue, dataSize: resourceForkSize) + try await socket.write(resourceForkHeader.data()) + totalBytesSent += HotlineFileForkHeader.DataSize + + // Stream RESOURCE fork + print("HotlineFileUploadClientNew[\(referenceNumber)]: Streaming RESOURCE fork (\(resourceForkSize) bytes)") + let resourceHandle = try FileHandle(forReadingFrom: resourceURL) + defer { try? resourceHandle.close() } + + let updates = await socket.writeFile(from: resourceHandle, length: Int(resourceForkSize)) + for try await p in updates { + let bytesSentNow = totalBytesSent + p.sent + self.updateProgress(sent: bytesSentNow, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining) + progressHandler?(.transfer(name: filename, size: bytesSentNow, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining)) + } + + totalBytesSent += Int(resourceForkSize) + } + + self.transferProgress.unpublish() + progressHandler?(.completed(url: nil)) + + print("HotlineFileUploadClientNew[\(referenceNumber)]: Upload complete!") + } + + // MARK: - Helper Methods + + private func connectToTransferServer() async throws -> NetSocketNew { + guard let transferPort = NWEndpoint.Port(rawValue: serverPort + 1) else { + throw NetSocketError.invalidPort + } + + print("HotlineFileUploadClientNew[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)") + + let socket = try await NetSocketNew.connect( + host: .name(serverAddress, nil), + port: transferPort, + tls: .disabled + ) + + print("HotlineFileUploadClientNew[\(referenceNumber)]: Connected!") + return socket + } +} diff --git a/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift new file mode 100644 index 0000000..9dcb7c9 --- /dev/null +++ b/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift @@ -0,0 +1,486 @@ +// +// HotlineFolderDownloadClientNew.swift +// Hotline +// +// Modern async/await folder download client using NetSocketNew +// + +import Foundation +import Network + +/// Item progress callback for folder downloads +public struct HotlineFolderItemProgress: Sendable { + public let fileName: String + public let itemNumber: Int + public let totalItems: Int +} + +/// Modern async/await folder download client for Hotline protocol +@MainActor +public class HotlineFolderDownloadClientNew { + // MARK: - Configuration + + public struct Configuration: Sendable { + public var publishProgress: Bool = true + public init() {} + } + + // MARK: - Properties + + private let serverAddress: String + private let serverPort: UInt16 + private let referenceNumber: UInt32 + + private let config: Configuration + + private let transferTotal: Int + private let folderItemCount: Int + private var transferSize: Int = 0 + + private var socket: NetSocketNew? + private var downloadTask: Task? + private var folderProgress: Progress? + + // MARK: - Initialization + + public init( + address: String, + port: UInt16, + reference: UInt32, + size: UInt32, + itemCount: Int, + configuration: Configuration = .init() + ) { + self.serverAddress = address + self.serverPort = port + self.referenceNumber = reference + self.config = configuration + self.transferTotal = Int(size) + self.folderItemCount = itemCount + + print("HotlineFolderDownloadClientNew[\(reference)]: Server reported transferSize=\(size) bytes, folderItemCount=\(itemCount) items") + } + + // MARK: - Public API + + public func download( + to location: HotlineDownloadLocation, + progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil, + itemProgress itemProgressHandler: (@Sendable (HotlineFolderItemProgress) -> Void)? = nil + ) async throws -> URL { + self.downloadTask?.cancel() + + let task = Task { + try await performDownload( + to: location, + progressHandler: progressHandler, + itemProgressHandler: itemProgressHandler + ) + } + self.downloadTask = task + + do { + let url = try await task.value + self.downloadTask = nil + return url + } catch { + print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Failed to download folder: \(error)") + self.downloadTask = nil + progressHandler?(.error(error)) + throw error + } + } + + /// Cancel the current download + public func cancel() { + downloadTask?.cancel() + downloadTask = nil + + if let socket = socket { + Task { + await socket.close() + } + } + } + + // MARK: - Private Implementation + + private func performDownload( + to destination: HotlineDownloadLocation, + progressHandler: (@Sendable (HotlineTransferProgress) -> Void)?, + itemProgressHandler: (@Sendable (HotlineFolderItemProgress) -> Void)? + ) async throws -> URL { + + var destinationFilename: String + + progressHandler?(.connecting) + + // Connect to transfer server + let socket = try await connectToTransferServer() + self.socket = socket + defer { Task { await socket.close() } } + + // Determine destination folder URL + let fm = FileManager.default + let destinationURL: URL + + switch destination { + case .url(let url): + destinationURL = url + destinationFilename = url.lastPathComponent + case .downloads(let filename): + let downloadsURL = fm.urls(for: .downloadsDirectory, in: .userDomainMask)[0] + destinationURL = URL(filePath: downloadsURL.generateUniqueFilePath(filename: filename)) + destinationFilename = destinationURL.lastPathComponent + } + + print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Downloading folder to \(destinationURL.path)") + + // Create destination folder + try? fm.removeItem(at: destinationURL) + try fm.createDirectory(at: destinationURL, withIntermediateDirectories: true) + + // Create and publish progress for the entire folder (shows in Finder) + if config.publishProgress { + let progress = Progress(totalUnitCount: Int64(self.transferTotal)) + progress.fileURL = destinationURL + progress.fileOperationKind = .downloading + progress.publish() + self.folderProgress = progress + } + defer { + // Unpublish progress when folder download completes + self.folderProgress?.unpublish() + self.folderProgress = nil + } + + // Send initial magic header + print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Sending HTXF magic") + try await socket.write(Data(endian: .big) { + "HTXF".fourCharCode() + self.referenceNumber + UInt32.zero // data size = 0 + UInt16(1) // type = 1 (folder transfer) + UInt16.zero // reserved = 0 + HotlineFolderAction.nextFile.rawValue // action = 3 (next file) + }) + + progressHandler?(.connected) + progressHandler?(.transfer(name: destinationFilename, size: 0, total: self.transferTotal, progress: 0.0, speed: nil, estimate: nil)) + + var completedItemCount = 0 + var totalBytesTransferred = 0 + + // Process each item in the folder + while completedItemCount < folderItemCount { + // Read item header + let headerLenData = try await socket.read(2) + let headerLen = Int(headerLenData.readUInt16(at: 0)!) + let headerData = try await socket.read(headerLen) + + totalBytesTransferred += 2 + headerLen + + guard let (itemType, pathComponents) = parseItemHeaderPath(headerData) else { + throw HotlineFileClientError.failedToTransfer + } + + let joinedPath = pathComponents.joined(separator: "/") + print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Item type=\(itemType) path=\(joinedPath)") + + if itemType == 1 { + // Folder entry - no progress shown for folder creation + if !pathComponents.isEmpty { + let folderURL = destinationURL.appendingPathComponents(pathComponents) + try fm.createDirectory(at: folderURL, withIntermediateDirectories: true) + print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Created folder at \(folderURL.path)") + } + + completedItemCount += 1 + + // Request next item if not done + if completedItemCount < folderItemCount { + try await sendAction(socket: socket, action: .nextFile) // nextFile + } + + } else if itemType == 0 { + // File entry + let parentComponents = pathComponents.dropLast() + let fileName = pathComponents.last ?? "untitled" + + // Request file download + try await sendAction(socket: socket, action: .sendFile) // sendFile + + // Read file size + let fileSizeData = try await socket.read(4) + let fileSize = fileSizeData.readUInt32(at: 0)! + totalBytesTransferred += 4 + + print("HotlineFolderDownloadClientNew[\(referenceNumber)]: File '\(fileName)' size: \(fileSize) bytes") + + // Notify item progress before download starts + completedItemCount += 1 + itemProgressHandler?(HotlineFolderItemProgress( + fileName: fileName, + itemNumber: completedItemCount, + totalItems: folderItemCount + )) + + // Download the file with overall folder progress tracking + let (fileURL, fileBytesRead) = try await downloadFile( + socket: socket, + fileName: fileName, + parentPath: Array(parentComponents), + destinationFolder: destinationURL, + fileSize: fileSize, + itemNumber: completedItemCount, + totalItems: folderItemCount, + totalBytesTransferredSoFar: totalBytesTransferred, + progressHandler: progressHandler + ) + + totalBytesTransferred += fileBytesRead + self.transferSize = totalBytesTransferred + + print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Downloaded file to \(fileURL.path)") + + // Request next item if not done + if completedItemCount < folderItemCount { + try await sendAction(socket: socket, action: .nextFile) // nextFile + } + + } else { + // Unknown item type + print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Unknown item type \(itemType), skipping") + completedItemCount += 1 + + if completedItemCount < folderItemCount { + try await sendAction(socket: socket, action: .nextFile) // nextFile + } + } + } + + print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Download complete!") + + // Ensure folder progress shows 100% complete + self.folderProgress?.completedUnitCount = Int64(self.transferTotal) + + progressHandler?(.completed(url: destinationURL)) + + return destinationURL + } + + // MARK: - Helper Methods + + private func connectToTransferServer() async throws -> NetSocketNew { + guard let transferPort = NWEndpoint.Port(rawValue: serverPort + 1) else { + throw NetSocketError.invalidPort + } + + print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)") + + let socket = try await NetSocketNew.connect( + host: .name(serverAddress, nil), + port: transferPort, + tls: .disabled + ) + + print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Connected!") + return socket + } + + private func sendAction(socket: NetSocketNew, action: HotlineFolderAction) async throws { + let actionData = Data(endian: .big) { + action.rawValue + } + try await socket.write(actionData) + print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Sent action: \(action)") + } + + private func parseItemHeaderPath(_ headerData: Data) -> (type: UInt16, components: [String])? { + // Need at least: type(2) + count(2) + guard headerData.count >= 4, + let type = headerData.readUInt16(at: 0), + let count = headerData.readUInt16(at: 2) else { return nil } + + var ofs = 4 + var comps: [String] = [] + for _ in 0..= ofs + 3 else { return nil } + // per Hotline path encoding: reserved(2) then nameLen(1) then name + ofs += 2 // reserved == 0 + let nameLen = Int(headerData.readUInt8(at: ofs)!) + ofs += 1 + guard headerData.count >= ofs + nameLen else { return nil } + let nameData = headerData.subdata(in: ofs..<(ofs + nameLen)) + ofs += nameLen + + let name = String(data: nameData, encoding: .macOSRoman) + ?? String(data: nameData, encoding: .utf8) + ?? "" + comps.append(name) + } + return (type, comps) + } + + private func downloadFile( + socket: NetSocketNew, + fileName: String, + parentPath: [String], + destinationFolder: URL, + fileSize: UInt32, + itemNumber: Int, + totalItems: Int, + totalBytesTransferredSoFar: Int, + progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? + ) async throws -> (url: URL, bytesRead: Int) { + let fm = FileManager.default + var bytesRead = 0 + + // Read file header + let headerData = try await socket.read(HotlineFileHeader.DataSize) + guard let header = HotlineFileHeader(from: headerData) else { + throw HotlineFileClientError.failedToTransfer + } + bytesRead += HotlineFileHeader.DataSize + + // Update folder progress for file header + let totalBytesNow = totalBytesTransferredSoFar + bytesRead + self.folderProgress?.completedUnitCount = Int64(totalBytesNow) + + print("HotlineFolderDownloadClientNew[\(referenceNumber)]: File has \(header.forkCount) forks") + + var resourceForkData: Data? + var fileHandle: FileHandle? + var filePath: URL? + var fileDataForkSize: Int = 0 + + defer { + try? fileHandle?.close() + } + + // Process each fork + for _ in 0.. 0 ? Double(totalBytesNow) / Double(self.transferTotal) : 0.0 + let overallProgress = min(rawProgress, 1.0) // Clamp to 1.0 to avoid exceeding 100% + + // Update folder-level Finder progress + self.folderProgress?.completedUnitCount = Int64(totalBytesNow) + + // Calculate overall folder time estimate based on current speed + let remainingBytes = max(0, self.transferTotal - totalBytesNow) + let estimate: TimeInterval? = if let speed = p.bytesPerSecond, speed > 0, remainingBytes > 0 { + TimeInterval(remainingBytes) / speed + } else { + nil + } + + // Report overall folder progress to UI + progressHandler?(.transfer( + name: fileName, + size: totalBytesNow, + total: self.transferTotal, + progress: overallProgress, + speed: p.bytesPerSecond, + estimate: estimate + )) + } + bytesRead += fileDataForkSize + + } else if forkHeader.isResourceFork { + // Read RESOURCE fork + resourceForkData = try await socket.read(Int(forkHeader.dataSize)) + bytesRead += Int(forkHeader.dataSize) + + // Update folder progress for RESOURCE fork + let totalBytesNow = totalBytesTransferredSoFar + bytesRead + self.folderProgress?.completedUnitCount = Int64(totalBytesNow) + + } else { + // Skip unsupported fork + try await socket.skip(Int(forkHeader.dataSize)) + bytesRead += Int(forkHeader.dataSize) + + // Update folder progress for skipped fork + let totalBytesNow = totalBytesTransferredSoFar + bytesRead + self.folderProgress?.completedUnitCount = Int64(totalBytesNow) + } + } + + // Close file handle + try? fileHandle?.close() + fileHandle = nil + + guard let finalPath = filePath else { + throw HotlineFileClientError.failedToTransfer + } + + // Write resource fork if present + if let rsrcData = resourceForkData, !rsrcData.isEmpty { + try writeResourceFork(data: rsrcData, to: finalPath) + } + + return (finalPath, bytesRead) + } + + private func writeResourceFork(data: Data, to url: URL) throws { + var resolvedURL = url + resolvedURL.resolveSymlinksInPath() + + let resourceURL = resolvedURL.urlForResourceFork() + try data.write(to: resourceURL) + } +} diff --git a/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift new file mode 100644 index 0000000..0b78f33 --- /dev/null +++ b/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift @@ -0,0 +1,538 @@ +import Foundation +import Network + +/// Item progress callback for folder uploads +public struct HotlineFolderItemUploadProgress: Sendable { + public let fileName: String + public let itemNumber: Int + public let totalItems: Int +} + +/// Represents a file or folder in the upload queue +private struct FolderItem { + let url: URL + let pathComponents: [String] // Path relative to upload root + let isFolder: Bool +} + +/// Modern async/await folder upload client for Hotline protocol +@MainActor +public class HotlineFolderUploadClientNew { + // MARK: - Configuration + + public struct Configuration: Sendable { + public var chunkSize: Int = 256 * 1024 + public var publishProgress: Bool = true + public init() {} + } + + // MARK: - Properties + + private let serverAddress: String + private let serverPort: UInt16 + private let referenceNumber: UInt32 + private let folderURL: URL + + private let config: Configuration + + private var transferTotal: Int = 0 + private var transferSize: Int = 0 + private var folderItems: [FolderItem] = [] + private var totalItems: Int = 0 + + private var socket: NetSocketNew? + private var uploadTask: Task? + + // MARK: - Initialization + + public init?( + folderURL: URL, + address: String, + port: UInt16, + reference: UInt32, + configuration: Configuration = .init() + ) { + guard FileManager.default.fileExists(atPath: folderURL.path(percentEncoded: false)) else { + return nil + } + + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: folderURL.path(percentEncoded: false), isDirectory: &isDirectory), + isDirectory.boolValue else { + return nil + } + + self.serverAddress = address + self.serverPort = port + self.referenceNumber = reference + self.folderURL = folderURL + self.config = configuration + + print("HotlineFolderUploadClientNew[\(reference)]: Preparing to upload folder '\(folderURL.lastPathComponent)'") + } + + // MARK: - API + + public func upload( + progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil, + itemProgress itemProgressHandler: (@Sendable (HotlineFolderItemUploadProgress) -> Void)? = nil + ) async throws { + self.uploadTask?.cancel() + + let task = Task { + try await performUpload( + progressHandler: progressHandler, + itemProgressHandler: itemProgressHandler + ) + } + self.uploadTask = task + + do { + try await task.value + self.uploadTask = nil + } catch { + print("HotlineFolderUploadClientNew[\(referenceNumber)]: Failed to upload folder: \(error)") + self.uploadTask = nil + progressHandler?(.error(error)) + throw error + } + } + + /// Cancel the current upload + public func cancel() { + uploadTask?.cancel() + uploadTask = nil + + if let socket = socket { + Task { + await socket.close() + } + } + } + + // MARK: - + + private enum UploadStage { + case waitingForNextFile // Waiting for server to send .nextFile action + case sendingItemHeader // Sending item header to server + case waitingForFileAction // Waiting for server action after file header (.sendFile, .nextFile, .resumeFile) + case uploadingFile // Uploading file data + case done // All items uploaded + } + + private func performUpload( + progressHandler: (@Sendable (HotlineTransferProgress) -> Void)?, + itemProgressHandler: (@Sendable (HotlineFolderItemUploadProgress) -> Void)? + ) async throws { + + // Note that we're preparing now. + progressHandler?(.preparing) + + // Start accessing security-scoped resource + let didStartAccess = folderURL.startAccessingSecurityScopedResource() + defer { + if didStartAccess { + self.folderURL.stopAccessingSecurityScopedResource() + } + } + + // Build folder hierarchy (excluding root folder itself) + try buildFolderHierarchy() + + // Fast path if this is an empty folder + if self.totalItems == 0 { + progressHandler?(.completed(url: nil)) + return + } + + // Note that we're connecting now. + progressHandler?(.connecting) + + // Connect to transfer server + let socket = try await self.connect(address: self.serverAddress, port: self.serverPort) + self.socket = socket + defer { Task { await socket.close() } } + + // Send magic header for folder upload + try await socket.write(Data(endian: .big) { + "HTXF".fourCharCode() + self.referenceNumber + UInt32.zero // data size = 0 + UInt16(1) // type = 1 (folder transfer) + UInt16.zero // reserved = 0 + }) + + progressHandler?(.connected) +// progressHandler?(.transfer(size: 0, total: self.transferTotal, progress: 0.0, speed: nil, estimate: nil)) + + var completedItemCount = 0 + var totalBytesTransferred = 0 + var itemIndex = 0 + var stage: UploadStage = .waitingForNextFile + var currentItem: FolderItem? + + // State machine loop - matches C++ client's goto-based state machine + while stage != .done { + switch stage { + + case .waitingForNextFile: + // Wait for server to send .nextFile action + let action = try await self.readAction(socket: socket) + guard action == .nextFile else { + throw HotlineFileClientError.failedToTransfer + } + + // Check if we have more items to send + if itemIndex < self.folderItems.count { + currentItem = self.folderItems[itemIndex] + itemIndex += 1 + stage = .sendingItemHeader + } else { + // No more items + stage = .done + } + + case .sendingItemHeader: + // Send item header to server + guard let item = currentItem else { + throw HotlineFileClientError.failedToTransfer + } + + // Encode and send item header + totalBytesTransferred += try await socket.write(self.encodeItemHeader(item: item)) + + // Next: wait for server's response + if item.isFolder { + // For folders, we're done with this item (just creating the directory) + completedItemCount += 1 + // Server should immediately respond with .nextFile + stage = .waitingForNextFile + } else { + // For files, server will tell us what to do + stage = .waitingForFileAction + } + + case .waitingForFileAction: + // Wait for server action after file header (.sendFile, .nextFile, .resumeFile) + guard currentItem != nil else { + throw HotlineFileClientError.failedToTransfer + } + + let action = try await self.readAction(socket: socket) + switch action { + case .nextFile: + // Server wants to skip this file + completedItemCount += 1 + // The .nextFile action means send next item, check if we have more + if itemIndex < self.folderItems.count { + currentItem = self.folderItems[itemIndex] + itemIndex += 1 + stage = .sendingItemHeader + } else { + stage = .done + } + + case .sendFile: + // Server wants the file + completedItemCount += 1 + stage = .uploadingFile + + case .resumeFile: + // Server wants to resume + let resumeSizeData = try await socket.read(2) + let resumeSize = Int(resumeSizeData.readUInt16(at: 0)!) + let _ = try await socket.read(resumeSize) + completedItemCount += 1 + stage = .uploadingFile + } + + case .uploadingFile: + // Upload file data + guard let item = currentItem else { + throw HotlineFileClientError.failedToTransfer + } + + // Notify item progress + itemProgressHandler?(HotlineFolderItemUploadProgress( + fileName: item.url.lastPathComponent, + itemNumber: completedItemCount, + totalItems: self.totalItems + )) + + // Upload the file + let bytesUploaded = try await self.uploadFile( + socket: socket, + fileURL: item.url, + itemNumber: completedItemCount, + totalItems: self.totalItems, + totalBytesTransferredSoFar: totalBytesTransferred, + progressHandler: progressHandler + ) + + totalBytesTransferred += bytesUploaded + self.transferSize = totalBytesTransferred + + // After uploading, wait for server to send .nextFile + stage = .waitingForNextFile + + case .done: + break + } + } + + // All items processed + progressHandler?(.completed(url: nil)) + } + + private func connect(address: String, port: UInt16) async throws -> NetSocketNew { + guard let transferPort = NWEndpoint.Port(rawValue: port + 1) else { + throw NetSocketError.invalidPort + } + + return try await NetSocketNew.connect( + host: .name(address, nil), + port: transferPort, + tls: .disabled + ) + } + + private func buildFolderHierarchy() throws { + let fm = FileManager.default + folderItems = [] + transferTotal = 0 + + let rootFolderName = folderURL.lastPathComponent + + // Recursively walk the folder + func walkFolder(at url: URL, relativePath: [String]) throws { + let contents = try fm.contentsOfDirectory(at: url, includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey], options: [.skipsHiddenFiles]) + + for itemURL in contents { + let resourceValues = try itemURL.resourceValues(forKeys: [.isDirectoryKey]) + let isDirectory = resourceValues.isDirectory ?? false + let itemName = itemURL.lastPathComponent + let itemPath = relativePath + [itemName] + + if isDirectory { + // Add folder to list + folderItems.append(FolderItem(url: itemURL, pathComponents: itemPath, isFolder: true)) + + // Recurse into subfolder + try walkFolder(at: itemURL, relativePath: itemPath) + + } else { + // Add file to list and calculate size + if let fileSize = FileManager.default.getFlattenedFileSize(itemURL) { + folderItems.append(FolderItem(url: itemURL, pathComponents: itemPath, isFolder: false)) + transferTotal += Int(fileSize) + } + } + } + } + + // Following C++ client behavior: Build hierarchy with root folder name prepended to all paths + // Start from root folder with root name as first path component + try walkFolder(at: folderURL, relativePath: [rootFolderName]) + totalItems = folderItems.count + + print("BUILD HEIRARCHY (\(folderItems.count) items):\n", folderItems) + } + + private func encodeItemHeader(item: FolderItem) -> Data { + // Following C++ client behavior: Skip the first path component (root folder name) + // The C++ client does: startPtr += 2; startPtr += *startPtr + 1; pathCount--; + let strippedPath = item.pathComponents.count > 1 ? Array(item.pathComponents.dropFirst()) : item.pathComponents + let strippedPathCount = strippedPath.count + + // Build path components (Hotline format: reserved(2) + nameLen(1) + name) + var pathData = Data() + for component in strippedPath { + let nameData = component.data(using: .macOSRoman) ?? component.data(using: .utf8) ?? Data() + let nameLen = min(nameData.count, 255) + + pathData.append(contentsOf: [0, 0]) // reserved + pathData.append(UInt8(nameLen)) + pathData.append(nameData.prefix(nameLen)) + } + + // Calculate header size (this is what goes in the DataSize field) + // DataSize = isFolder(2) + pathCount(2) + pathData + let headerSize = 2 + 2 + pathData.count + + return Data(endian: .big) { + UInt16(headerSize) + UInt16(item.isFolder ? 1 : 0) + UInt16(strippedPathCount) + pathData + } + } + + private func readAction(socket: NetSocketNew) async throws -> HotlineFolderAction { + let actionData = try await socket.read(2) + guard let rawAction = actionData.readUInt16(at: 0), + let action = HotlineFolderAction(rawValue: rawAction) else { + throw HotlineFileClientError.failedToTransfer + } + return action + } + + private func uploadFile( + socket: NetSocketNew, + fileURL: URL, + itemNumber: Int, + totalItems: Int, + totalBytesTransferredSoFar: Int, + progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? + ) async throws -> Int { + var bytesUploaded = 0 + let filename = fileURL.lastPathComponent + + // Get file metadata + guard let infoFork = HotlineFileInfoFork(file: fileURL) else { + throw HotlineFileClientError.failedToTransfer + } + + guard let header = HotlineFileHeader(file: fileURL) else { + throw HotlineFileClientError.failedToTransfer + } + + guard let forkSizes = try? FileManager.default.getFileForkSizes(fileURL) else { + throw HotlineFileClientError.failedToTransfer + } + + let infoForkData = infoFork.data() + let dataForkSize = forkSizes.dataForkSize + let resourceForkSize = forkSizes.resourceForkSize + + // Calculate total flattened file size + guard let flattenedSize = FileManager.default.getFlattenedFileSize(fileURL) else { + throw HotlineFileClientError.failedToTransfer + } + let totalFileSize = Int(flattenedSize) + + // Send file size + let fileSizeData = Data(endian: .big) { + UInt32(totalFileSize) + } + try await socket.write(fileSizeData) + bytesUploaded += 4 + + // Send file header + let headerData = header.data() + try await socket.write(headerData) + bytesUploaded += headerData.count + + // Send INFO fork header + let infoForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.info.rawValue, dataSize: UInt32(infoForkData.count)) + try await socket.write(infoForkHeader.data()) + bytesUploaded += HotlineFileForkHeader.DataSize + + // Send INFO fork data + print("HotlineFolderUploadClientNew[\(referenceNumber)]: Sending INFO fork (\(infoForkData.count) bytes)") + try await socket.write(infoForkData) + bytesUploaded += infoForkData.count + + // Create per-file progress for Finder + var fileProgress: Progress? + if config.publishProgress { + let progress = Progress(totalUnitCount: Int64(totalFileSize)) + progress.fileURL = fileURL.resolvingSymlinksInPath() + progress.fileOperationKind = Progress.FileOperationKind.uploading + progress.publish() + fileProgress = progress + } + + defer { + fileProgress?.unpublish() + } + + // Send DATA fork if present + let dataForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.data.rawValue, dataSize: dataForkSize) + try await socket.write(dataForkHeader.data()) + bytesUploaded += HotlineFileForkHeader.DataSize + + if dataForkSize > 0 { + // Stream DATA fork + let fileHandle = try FileHandle(forReadingFrom: fileURL) + defer { try? fileHandle.close() } + + let updates = await socket.writeFile(from: fileHandle, length: Int(dataForkSize)) + for try await p in updates { + // Update per-file Finder progress + fileProgress?.completedUnitCount = Int64(bytesUploaded + p.sent) + + // Calculate overall folder progress + let totalBytesNow = totalBytesTransferredSoFar + bytesUploaded + p.sent + let rawProgress = self.transferTotal > 0 ? Double(totalBytesNow) / Double(self.transferTotal) : 0.0 + let overallProgress = min(rawProgress, 1.0) + + // Calculate overall time estimate + let remainingBytes = max(0, self.transferTotal - totalBytesNow) + let estimate: TimeInterval? = if let speed = p.bytesPerSecond, speed > 0, remainingBytes > 0 { + TimeInterval(remainingBytes) / speed + } else { + nil + } + + // Report overall folder progress + progressHandler?(.transfer( + name: filename, + size: totalBytesNow, + total: self.transferTotal, + progress: overallProgress, + speed: p.bytesPerSecond, + estimate: estimate + )) + } + + bytesUploaded += Int(dataForkSize) + } + + // Send RESOURCE fork if present + if resourceForkSize > 0 { + let resourceURL = fileURL.urlForResourceFork() + + let resourceForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.resource.rawValue, dataSize: resourceForkSize) + try await socket.write(resourceForkHeader.data()) + bytesUploaded += HotlineFileForkHeader.DataSize + + // Stream RESOURCE fork + let resourceHandle = try FileHandle(forReadingFrom: resourceURL) + defer { try? resourceHandle.close() } + + let updates = await socket.writeFile(from: resourceHandle, length: Int(resourceForkSize)) + for try await p in updates { + // Update per-file Finder progress + fileProgress?.completedUnitCount = Int64(bytesUploaded + p.sent) + + // Calculate overall folder progress + let totalBytesNow = totalBytesTransferredSoFar + bytesUploaded + p.sent + let rawProgress = self.transferTotal > 0 ? Double(totalBytesNow) / Double(self.transferTotal) : 0.0 + let overallProgress = min(rawProgress, 1.0) + + // Calculate overall time estimate + let remainingBytes = max(0, self.transferTotal - totalBytesNow) + let estimate: TimeInterval? = if let speed = p.bytesPerSecond, speed > 0, remainingBytes > 0 { + TimeInterval(remainingBytes) / speed + } else { + nil + } + + // Report overall folder progress + progressHandler?(.transfer( + name: filename, + size: totalBytesNow, + total: self.transferTotal, + progress: overallProgress, + speed: p.bytesPerSecond, + estimate: estimate + )) + } + + bytesUploaded += Int(resourceForkSize) + } + + print("HotlineFolderUploadClientNew[\(referenceNumber)]: File upload complete, \(bytesUploaded) bytes sent") + return bytesUploaded + } +} diff --git a/Hotline/Info.plist b/Hotline/Info.plist index b43d991..82ee6ac 100644 --- a/Hotline/Info.plist +++ b/Hotline/Info.plist @@ -8,10 +8,10 @@ CFBundleTypeName Hotline Bookmark LSHandlerRank - None + Owner LSItemContentTypes - 'HTbm' + 'HTbm' @@ -46,7 +46,7 @@ UTTypeIconFiles UTTypeIdentifier - 'HTbm' + 'HTbm' UTTypeTagSpecification public.filename-extension diff --git a/Hotline/Library/HotlinePanel.swift b/Hotline/Library/HotlinePanel.swift index 191e89a..d7d8284 100644 --- a/Hotline/Library/HotlinePanel.swift +++ b/Hotline/Library/HotlinePanel.swift @@ -8,12 +8,12 @@ class HotlinePanel: NSPanel { super.init(contentRect: NSRect(x: 0, y: 0, width: HOTLINE_PANEL_SIZE.width, height: HOTLINE_PANEL_SIZE.height), styleMask: [.nonactivatingPanel, .titled, .closable, .utilityWindow, .fullSizeContentView], backing: .buffered, defer: false) // Make sure that the panel is in front of almost all other windows - self.isFloatingPanel = false + self.isFloatingPanel = true self.level = .floating self.hidesOnDeactivate = true self.animationBehavior = .utilityWindow - // Allow the panel to appear in a fullscreen space + // Allow the panelto appear in a fullscreen space // self.collectionBehavior.insert(.fullScreenAuxiliary) self.collectionBehavior.insert(.canJoinAllSpaces) self.collectionBehavior.insert(.ignoresCycle) @@ -23,7 +23,7 @@ class HotlinePanel: NSPanel { // Don't delete panel state when it's closed. self.isReleasedWhenClosed = false - self.standardWindowButton(.closeButton)?.isHidden = true + self.standardWindowButton(.closeButton)?.isHidden = false self.standardWindowButton(.zoomButton)?.isHidden = true self.standardWindowButton(.miniaturizeButton)?.isHidden = true diff --git a/Hotline/Library/NetSocket/FileProgress.swift b/Hotline/Library/NetSocket/FileProgress.swift new file mode 100644 index 0000000..c086af7 --- /dev/null +++ b/Hotline/Library/NetSocket/FileProgress.swift @@ -0,0 +1,58 @@ +// NetSocketProgress +// Dustin Mierau • @mierau +// MIT License + +import Foundation + +public extension NetSocketNew { + + /// Progress information for file uploads/downloads + struct FileProgress: Sendable { + /// Number of bytes sent/received so far + public let sent: Int + /// Total file size (may be nil if unknown) + public let total: Int? + /// Smoothed transfer rate in bytes per second (EMA), if enough samples collected + public let bytesPerSecond: Double? + /// Estimated time remaining (seconds) based on smoothed rate, if available + public let estimatedTimeRemaining: TimeInterval? + + public init(sent: Int, total: Int?, bytesPerSecond: Double? = nil, estimatedTimeRemaining: TimeInterval? = nil) { + self.sent = sent + self.total = total + self.bytesPerSecond = bytesPerSecond + self.estimatedTimeRemaining = estimatedTimeRemaining + } + + /// Format transfer speed in human-readable format + /// + /// Automatically selects appropriate unit (B/sec, KB/sec, MB/sec, GB/sec) + /// based on the magnitude of the speed. + /// + /// - Returns: Formatted string like "45KB/sec", "5B/sec", "12.5MB/sec", or nil if speed unavailable + /// + /// Example: + /// ```swift + /// if let speedString = progress.formattedSpeed { + /// print(speedString) // "2.5MB/sec" + /// } + /// ``` + public var formattedSpeed: String? { + guard let bytesPerSecond = bytesPerSecond, bytesPerSecond > 0 else { return nil } + + let kb = 1024.0 + let mb = kb * 1024.0 + let gb = mb * 1024.0 + + if bytesPerSecond >= gb { + return String(format: "%.1fGB/sec", bytesPerSecond / gb) + } else if bytesPerSecond >= mb { + return String(format: "%.1fMB/sec", bytesPerSecond / mb) + } else if bytesPerSecond >= kb { + return String(format: "%.0fKB/sec", bytesPerSecond / kb) + } else { + return String(format: "%.0fB/sec", bytesPerSecond) + } + } + } +} diff --git a/Hotline/Library/NetSocket/NetSocketNew.swift b/Hotline/Library/NetSocket/NetSocketNew.swift new file mode 100644 index 0000000..7b24b37 --- /dev/null +++ b/Hotline/Library/NetSocket/NetSocketNew.swift @@ -0,0 +1,1129 @@ +// NetSocketNew.swift +// Dustin Mierau • @mierau + +import Foundation +import Network + +/// Byte order for multi-byte integer values in binary protocols +public enum Endian { + /// Big-endian (network byte order, most significant byte first) + case big + /// Little-endian (least significant byte first) + case little +} + +/// Delimiter patterns for text-based protocols +public enum Delimiter { + /// Custom single byte delimiter + case byte(UInt8) + /// Null terminator (0x00) + case zeroByte + /// Line feed (\n, 0x0A) + case lineFeed + /// Carriage return + line feed (\r\n, 0x0D 0x0A) + case carriageReturnLineFeed + + /// Binary representation of this delimiter + var data: Data { + switch self { + case .byte(let b): return Data([b]) + case .zeroByte: return Data([0x00]) + case .lineFeed: return Data([0x0A]) + case .carriageReturnLineFeed: return Data([0x0D, 0x0A]) + } + } +} + +/// TLS/SSL encryption policy for socket connections +public struct TLSPolicy: Sendable { + /// Create a TLS-enabled policy with optional custom configuration + /// - Parameter configure: Optional closure to customize TLS options + public static func enabled(_ configure: (@Sendable (NWProtocolTLS.Options) -> Void)? = nil) -> TLSPolicy { + TLSPolicy(enabled: true, configure: configure) + } + + /// Create a policy with TLS disabled (plaintext connection) + public static var disabled: TLSPolicy { TLSPolicy(enabled: false, configure: nil) } + + /// Whether TLS is enabled + public let enabled: Bool + /// Optional TLS configuration closure + public let configure: (@Sendable (NWProtocolTLS.Options) -> Void)? +} + +// MARK: - Errors + +/// Errors that can occur during socket operations +public enum NetSocketError: Error, CustomStringConvertible, Sendable { + /// Socket is not yet in ready state + case notReady + /// Connection has been closed + case closed + /// Invalid port number provided + case invalidPort + /// Network operation failed with underlying error + case failed(underlying: Error) + /// Not enough data available to fulfill read request + case insufficientData(expected: Int, got: Int) + /// Frame size exceeds configured maximum + case framingExceeded(max: Int) + /// Failed to decode data + case decodeFailed(Error) + /// Failed to encode data + case encodeFailed(Error) + + public var description: String { + switch self { + case .notReady: return "Connection not ready." + case .closed: return "Connection closed." + case .invalidPort: return "Invalid port number." + case .failed(let e): return "Network failure: \(e.localizedDescription)" + case .insufficientData(let exp, let got): return "Insufficient data: need \(exp), have \(got)." + case .framingExceeded(let max): return "Frame length exceeded maximum \(max)." + case .decodeFailed(let e): return "Decoding failed: \(e)" + case .encodeFailed(let e): return "Encoding failed: \(e)" + } + } +} + +// MARK: - NetSocketNew + +/// An async/await TCP socket with automatic buffering and framing support +/// +/// NetSocketNew provides: +/// - Async connection management +/// - Automatic receive buffering with memory compaction +/// - Type-safe reading/writing of integers, strings, and custom types +/// - File upload/download with progress tracking +/// +/// Example usage: +/// ```swift +/// let socket = try await NetSocketNew.connect(host: "example.com", port: 80) +/// try await socket.write("Hello\n".data(using: .utf8)!) +/// let response = try await socket.readUntil(delimiter: .lineFeed) +/// ``` +public actor NetSocketNew { + /// Configuration options for the socket + public struct Config: Sendable { + /// Size of chunks to receive from network at once (default: 64 KB) + public var receiveChunk: Int = 64 * 1024 + /// Maximum bytes to buffer before disconnecting (default: 8 MB) + public var maxBufferBytes: Int = 8 * 1024 * 1024 + public init() {} + } + + // Connection + state + private let connection: NWConnection + private let queue = DispatchQueue(label: "NetSocket.NWConnection") + private var ready = false + private var isClosed = false + private let connectionID: String // For logging + + // Buffer with compaction + private var buffer = Data() + private var head = 0 // start of unread bytes + private let config: Config + + // Waiters for data/ready + private var dataWaiters: [CheckedContinuation] = [] + private var readyWaiters: [CheckedContinuation] = [] + + // MARK: Init + + private init(connection: NWConnection, config: Config) { + self.connection = connection + self.config = config + // Create a human-readable connection ID for logging + if case .hostPort(host: let h, port: let p) = connection.endpoint { + self.connectionID = "\(h):\(p)" + } else { + self.connectionID = "unknown" + } + } + + // MARK: Connect + + /// Connect to a remote host and return a ready socket + /// + /// This method establishes a TCP connection using Network framework types and waits until + /// the connection is in `.ready` state. + /// + /// - Parameters: + /// - host: Network framework host (e.g., `.name("example.com", nil)` or `.ipv4(...)`) + /// - port: Network framework port + /// - tls: TLS policy (default: enabled with default settings) + /// - config: Socket configuration (default: standard settings) + /// - Returns: A connected and ready `NetSocketNew` + /// - Throws: Network errors or connection failures + public static func connect(host: NWEndpoint.Host, port: NWEndpoint.Port, tls: TLSPolicy = .enabled(), config: Config = .init()) async throws -> NetSocketNew { + let parameters = NWParameters.tcp + if tls.enabled { + let tlsOptions = NWProtocolTLS.Options() + tls.configure?(tlsOptions) + parameters.defaultProtocolStack.applicationProtocols.insert(tlsOptions, at: 0) + } + + let conn = NWConnection(host: host, port: port, using: parameters) + let socket = NetSocketNew(connection: conn, config: config) + try await socket.start() + return socket + } + + /// Convenience wrapper to connect using string hostname and integer port + public static func connect(host: String, port: UInt16, tls: TLSPolicy = .enabled(), config: Config = .init()) async throws -> NetSocketNew { + guard let nwPort = NWEndpoint.Port(rawValue: port) else { + throw NetSocketError.invalidPort + } + return try await self.connect(host: .name(host, nil), port: nwPort, tls: tls, config: config) + } + + // MARK: Close + + /// Close the connection gracefully + /// + /// Performs a graceful shutdown of the underlying network connection (e.g., TCP FIN) + /// and wakes all pending read/write operations with a `NetSocketError.closed` error. + /// This method is idempotent - subsequent calls are ignored. + /// + /// Use `forceClose()` for immediate non-graceful termination (e.g., TCP RST). + public func close() { + guard !isClosed else { return } + isClosed = true + connection.cancel() + resumeDataWaiters() + resumeReadyWaiters(with: .failure(NetSocketError.closed)) + } + + /// Force close the connection immediately (non-graceful) + /// + /// Performs an immediate non-graceful shutdown of the underlying network connection + /// (e.g., TCP RST). Use this when you need to terminate the connection immediately + /// without waiting for graceful closure. For normal shutdown, use `close()` instead. + /// + /// This method is idempotent - subsequent calls are ignored. + public func forceClose() { + guard !isClosed else { return } + isClosed = true + connection.forceCancel() + resumeDataWaiters() + resumeReadyWaiters(with: .failure(NetSocketError.closed)) + } + + // MARK: Send Data + + /// Write raw data to the socket + /// + /// Sends data and waits for confirmation that it has been processed by the network stack. + /// + /// - Parameter data: Raw bytes to send + /// - Throws: `NetSocketError` if connection is not ready or send fails + @discardableResult + public func write(_ data: Data) async throws -> Int { + try await ensureReady() + return try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + connection.send(content: data, completion: .contentProcessed { error in + if let error { cont.resume(throwing: NetSocketError.failed(underlying: error)) } + else { cont.resume(returning: data.count) } + }) + } + } + + /// Write a fixed-width integer to the socket + /// + /// - Parameters: + /// - value: The integer value to write + /// - endian: Byte order (default: big-endian) + /// - Throws: `NetSocketError` if write fails + @discardableResult + public func write(_ value: T, endian: Endian = .big) async throws -> Int { + var v = value + switch endian { + case .big: v = T(bigEndian: value) + case .little: v = T(littleEndian: value) + } + var copy = v + let size = MemoryLayout.size + let bytes = withUnsafePointer(to: ©) { + Data(bytes: $0, count: size) + } + try await write(bytes) + return bytes.count + } + + /// Write a boolean as a single byte (0 or 1) + /// - Parameter value: Boolean value + @discardableResult + public func write(_ value: Bool) async throws -> Int { + return try await write(UInt8(value ? 0x01 : 0x00)) + } + + /// Write a Float as its IEEE 754 bit pattern + /// - Parameters: + /// - value: Float value + /// - endian: Byte order (default: big-endian) + @discardableResult + public func write(_ value: Float, endian: Endian = .big) async throws -> Int { + return try await write(value.bitPattern, endian: endian) + } + + /// Write a Double as its IEEE 754 bit pattern + /// - Parameters: + /// - value: Double value + /// - endian: Byte order (default: big-endian) + @discardableResult + public func write(_ value: Double, endian: Endian = .big) async throws -> Int { + return try await write(value.bitPattern, endian: endian) + } + + /// Write a string to the socket, optionally length-prefixed + /// + /// - Parameters: + /// - string: String to write + /// - encoding: Text encoding (default: UTF-8) + /// - allowLossyConversion: Allow lossy encoding if necessary (default: false) + /// - Throws: `NetSocketError` if encoding fails or write fails + @discardableResult + public func write(_ string: String, encoding: String.Encoding = .utf8, allowLossyConversion: Bool = false) async throws -> Int { + guard let data = string.data(using: encoding, allowLossyConversion: allowLossyConversion) else { + throw NetSocketError.encodeFailed(NSError(domain: "StringEncoding", code: -1)) + } + return try await write(data) + } + + // MARK: Receive Data + + /// Read data until a delimiter is found + /// + /// Searches the buffer for the delimiter pattern and returns all data up to (and optionally including) + /// the delimiter. The delimiter is always consumed from the stream. + /// + /// - Parameters: + /// - delimiter: Binary delimiter pattern to search for + /// - maxBytes: Maximum bytes to read before throwing (default: no limit) + /// - includeDelimiter: Whether to include delimiter in result (default: false) + /// - Returns: Data read from stream + /// - Throws: `NetSocketError.framingExceeded` if max bytes exceeded, or connection errors + public func read(past delimiter: Data, maxBytes: Int? = nil, includeDelimiter: Bool = false) async throws -> Data { + while true { + try Task.checkCancellation() + if let r = search(delimiter: delimiter) { + let consumeLen = r.upperBound - head + let data = try await read(consumeLen) + return includeDelimiter ? data : data.dropLast(delimiter.count) + } + if let maxBytes, availableBytes >= maxBytes { + throw NetSocketError.framingExceeded(max: maxBytes) + } + try await waitForData() + guard !isClosed || availableBytes > 0 else { throw NetSocketError.closed } + } + } + + /// Read exactly N bytes from the socket + /// + /// Waits for data to arrive if buffer doesn't contain enough bytes yet. The internal buffer + /// is automatically compacted after reading to prevent unbounded memory growth. + /// + /// - Parameter count: Number of bytes to read + /// - Returns: Exactly `count` bytes + /// - Throws: `NetSocketError.insufficientData` if connection closes before enough data arrives + public func read(_ count: Int) async throws -> Data { + try await self.ensureReadable(count) + let start = self.head + let end = self.head + count + let slice = self.buffer[start..(_ type: T.Type = T.self, endian: Endian = .big) async throws -> T { + let size = MemoryLayout.size + let data = try await self.read(size) + let value: T = data.withUnsafeBytes { raw in + raw.load(as: T.self) + } + switch endian { + case .big: return T(bigEndian: value) + case .little: return T(littleEndian: value) + } + } + + /// Read a fixed-length string + /// + /// - Parameters: + /// - length: Number of bytes to read + /// - encoding: Text encoding (default: UTF-8) + /// - Returns: Decoded string + /// - Throws: `NetSocketError` if decoding fails or insufficient data + public func read(_ length: Int, encoding: String.Encoding = .utf8) async throws -> String { + let data = try await self.read(length) + guard let s = String(data: data, encoding: encoding) else { + throw NetSocketError.decodeFailed(NSError()) + } + return s + } + + /// Read a string until a delimiter is found + /// + /// - Parameters: + /// - delimiter: Delimiter pattern to search for + /// - maxBytes: Maximum bytes to read before throwing (default: no limit) + /// - includeDelimiter: Whether to include delimiter in result (default: false) + /// - Returns: String read from stream (delimiter consumed but not included unless specified) + /// - Throws: `NetSocketError` if decoding fails, max bytes exceeded, or connection closed + public func read(until delimiter: Delimiter, maxBytes: Int? = nil, includeDelimiter: Bool = false) async throws -> String { + let bytes = try await read(past: delimiter.data, maxBytes: maxBytes, includeDelimiter: includeDelimiter) + guard let s = String(data: bytes, encoding: .utf8) else { throw NetSocketError.decodeFailed(NSError()) } + return s + } + + /// Read exactly N bytes with progress callbacks + /// + /// Like `read(_:)`, but reads in chunks and reports progress after each chunk. + /// Useful for downloading large amounts of data where you want to update UI progress. + /// + /// Example: + /// ```swift + /// let data = try await socket.read(1_000_000) { current, total in + /// print("Progress: \(current)/\(total)") + /// } + /// ``` + /// + /// - Parameters: + /// - count: Number of bytes to read + /// - chunkSize: Size of chunks to read at a time (default: 8192) + /// - progress: Optional callback with (bytesReceived, totalBytes) + /// - Returns: Exactly `count` bytes + /// - Throws: `NetSocketError` if connection closes before enough data arrives + public func read( + _ count: Int, + chunkSize: Int = 8192, + progress: (@Sendable (Int, Int) -> Void)? = nil + ) async throws -> Data { + var data = Data() + data.reserveCapacity(count) + var received = 0 + + while received < count { + try Task.checkCancellation() + let toRead = min(chunkSize, count - received) + let chunk = try await read(toRead) + data.append(chunk) + received += chunk.count + progress?(received, count) + } + + return data + } + + // MARK: Peek Data + + public var availableBytes: Int { self.buffer.count - self.head } + + public func peek(_ count: Int) -> Data? { + guard self.availableBytes >= count else { + return nil + } + + let slice = self.buffer[self.head..<(self.head + count)] + return Data(slice) // Don't advance head + } + + public func peek(upto count: Int) -> Data { + let amount = min(self.availableBytes, count) + guard amount > 0 else { + return Data() + } + + let slice = self.buffer[self.head..<(self.head + amount)] + return Data(slice) + } + + public func peek(awaiting count: Int) async throws -> Data { + try await self.ensureReadable(count) + let slice = self.buffer[self.head..<(self.head + count)] + return Data(slice) // Don't advance head + } + + // MARK: Skip Data + + /// Skip/discard exactly N bytes from the stream without allocating memory + public func skip(_ count: Int) async throws { + guard count > 0 else { return } + try await self.ensureReadable(count) + self.head += count + self.compactIfNeeded() + } + + /// Skip until delimiter is found (discards delimiter too) + public func skip(past delimiter: Data) async throws { + while true { + try Task.checkCancellation() + if let r = self.search(delimiter: delimiter) { + self.head = r.upperBound // Skip to end of delimiter + self.compactIfNeeded() + return + } + try await self.waitForData() + guard !self.isClosed else { + throw NetSocketError.closed + } + } + } + + // MARK: Files + + /// Upload a file from a URL, yielding progress as an AsyncSequence. + /// + /// Iterating this sequence drives the transfer. Each yielded value reports + /// the total bytes sent so far and the known total. Cancel the consuming + /// task to cancel the transfer. + /// + /// This method handles opening and closing the file handle automatically. + /// + /// - Parameters: + /// - url: File URL to upload. + /// - chunkSize: Size of each read chunk. + /// - Returns: An `AsyncThrowingStream` of `FileProgress` updates. + func writeFile(from url: URL, chunkSize: Int = 256 * 1024) -> AsyncThrowingStream { + // This stream wrapper manages the FileHandle's lifetime. + return AsyncThrowingStream(bufferingPolicy: .bufferingOldest(1)) { continuation in + // Capture self (the actor) to use in detached task + let actor = self + + // Open file on a background thread (file I/O is blocking) + let task = Task.detached { + let fh: FileHandle + let total: Int + + // 1. Open file and get length (blocking I/O, done off-actor) + do { + total = Int(try NetSocketNew.fileLength(at: url)) + fh = try FileHandle(forReadingFrom: url) + } catch { + continuation.finish(throwing: NetSocketError.failed(underlying: error)) + return + } + + // 2. Now switch to the actor context to call the actor-isolated method + let stream = await actor.writeFile( + from: fh, + length: total, + chunkSize: chunkSize + ) + + // 3. Forward all elements from the underlying stream to our stream + do { + for try await progress in stream { + try Task.checkCancellation() // Exit early if cancelled + continuation.yield(progress) + } + try? fh.close() + continuation.finish() + } catch is CancellationError { + try? fh.close() + continuation.finish() + } catch { + try? fh.close() + continuation.finish(throwing: error) + } + } + + // If the *consumer* cancels the stream, we cancel our managing task. + continuation.onTermination = { @Sendable _ in + task.cancel() + } + } + } + + /// Upload a file from an open FileHandle, yielding progress as an AsyncSequence. + /// + /// Iterating this sequence drives the transfer. Each yielded value reports + /// the total bytes sent so far and the known total. Cancel the consuming + /// task to cancel the transfer. + /// + /// **Note:** The caller is responsible for opening and closing the `fileHandle`. + /// + /// - Parameters: + /// - fileHandle: Open `FileHandle` for reading. + /// - length: Exact number of bytes to send (total file size). + /// - chunkSize: Size of each read chunk. + /// - Returns: An `AsyncThrowingStream` of `FileProgress` updates. + func writeFile(from fileHandle: FileHandle, length: Int, chunkSize: Int = 256 * 1024) -> AsyncThrowingStream { + precondition(length >= 0, "length must be >= 0") + + if length == 0 { + return AsyncThrowingStream { continuation in + continuation.yield(.init(sent: 0, total: 0, bytesPerSecond: 0, estimatedTimeRemaining: 0)) + continuation.finish() + } + } + + return AsyncThrowingStream(bufferingPolicy: .bufferingOldest(1)) { continuation in + let task = Task { [weak self] in + guard let self else { + continuation.finish() + return + } + + var estimator = TransferRateEstimator(total: Int(length)) + + do { + try await self.ensureReady() + + while estimator.transferred < length { + try Task.checkCancellation() + + let toRead = Int(min(chunkSize, length - estimator.transferred)) + + // Read from disk + guard let chunk = try fileHandle.read(upToCount: toRead), !chunk.isEmpty else { + if estimator.transferred < length { + throw NetSocketError.failed(underlying: NSError( + domain: "NetSocket", code: 9001, + userInfo: [NSLocalizedDescriptionKey: "File read ended prematurely. Expected \(length) bytes, got \(estimator.transferred)."] + )) + } + break + } + + // Write to network + try await self.write(chunk) + + // Update estimator and yield progress + let progress = estimator.update(bytes: chunk.count) + continuation.yield(progress) + } + + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { @Sendable _ in + task.cancel() + } + } + } + + /// Receive a file of known length and yield progress updates as an AsyncSequence. + /// + /// Iterating this sequence drives the transfer. Each yielded value reports + /// the total bytes written so far and the known total. Cancel the consuming + /// task to cancel the transfer. + /// + /// - Parameters: + /// - fileHandle: Open `FileHandle` for writing (caller must close). + /// - length: Exact number of bytes expected. + /// - chunkSize: Size of each read chunk. + /// - Returns: An `AsyncThrowingStream` of `FileProgress` updates. + func receiveFile(to fileHandle: FileHandle, length: Int, chunkSize: Int = 256 * 1024) -> AsyncThrowingStream { + precondition(length >= 0, "length must be >= 0") + + if length == 0 { + return AsyncThrowingStream { continuation in + continuation.yield(.init(sent: 0, total: 0, bytesPerSecond: 0, estimatedTimeRemaining: 0)) + continuation.finish() + } + } + + return AsyncThrowingStream(bufferingPolicy: .bufferingOldest(1)) { continuation in + let task = Task { [weak self] in + guard let self else { + continuation.finish() + return + } + + var estimator = TransferRateEstimator(total: length) + + do { + var remaining: Int = length + + while remaining > 0 { + try Task.checkCancellation() + let n = min(chunkSize, remaining) + + let chunk = try await self.read(n) + try fileHandle.write(contentsOf: chunk) + + let chunkSize = Int(chunk.count) + remaining -= chunkSize + let progress = estimator.update(bytes: chunkSize) + continuation.yield(progress) + } + + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { @Sendable _ in + task.cancel() + } + } + } + + /// Download a file of known length and write it to disk in chunks + /// + /// This method does **not** read a length prefix. The caller must provide the expected + /// file size (e.g., from protocol metadata). The file is streamed directly to disk to + /// avoid loading it entirely into memory. + /// + /// Supports atomic writes: when enabled, data is written to a temporary `.part` file and + /// renamed on success. If an error occurs, the temporary file is automatically cleaned up. + /// + /// - Parameters: + /// - url: Destination file URL + /// - length: Exact number of bytes to read (must match what's on the wire) + /// - chunkSize: Chunk size for reading/writing (default: 256 KB) + /// - overwrite: Whether to overwrite existing file (default: true) + /// - atomic: Write to temporary file and rename on success (default: true) + /// - progress: Optional progress callback + /// - Returns: Total bytes written (equals `length` on success) + /// - Throws: File I/O or network errors. On atomic writes, partial files are cleaned up. + /// + /// Example: + /// ```swift + /// // Hotline protocol: file size comes from transaction header + /// let transaction = try await socket.receive(HotlineTransaction.self) + /// try await socket.receiveFile( + /// to: destinationURL, + /// length: transaction.fileSize + /// ) + /// ``` + @discardableResult + func receiveFile( + to url: URL, + length: Int, + chunkSize: Int = 256 * 1024, + overwrite: Bool = true, + atomic: Bool = true, + progress: (@Sendable (FileProgress) -> Void)? = nil + ) async throws -> Int { + precondition(length >= 0, "length must be >= 0") + + // Fast path: nothing to do + if length == 0 { + if overwrite { try? FileManager.default.removeItem(at: url) } + FileManager.default.createFile(atPath: url.path, contents: Data(), attributes: nil) + return 0 + } + + // Prepare destination (optionally atomic) + let fm = FileManager.default + let dir = url.deletingLastPathComponent() + let tmp = atomic ? dir.appendingPathComponent(".\(url.lastPathComponent).part-\(UUID().uuidString)") : url + + if overwrite { try? fm.removeItem(at: tmp) } + if overwrite, !atomic { try? fm.removeItem(at: url) } + + // Create and open the file for writing + fm.createFile(atPath: tmp.path, contents: nil, attributes: nil) + let fh = try FileHandle(forWritingTo: tmp) + defer { try? fh.close() } + + var remaining: Int = length + var written: Int = 0 + + do { + while remaining > 0 { + try Task.checkCancellation() + let n = Int(min(chunkSize, remaining)) + let chunk = try await self.read(n) + try fh.write(contentsOf: chunk) + remaining -= n + written += Int(n) + progress?(.init(sent: written, total: length)) + } + } catch { + // Cleanup partial file on failure if we were writing atomically + if atomic { try? fm.removeItem(at: tmp) } + throw error + } + + // Atomically move into place if requested + if atomic { + if overwrite { try? fm.removeItem(at: url) } + try fm.moveItem(at: tmp, to: url) + } + + return written + } + + // MARK: Internals + + private func start() async throws { + self.connection.stateUpdateHandler = { state in + Task { [weak self] in + guard let self else { return } + switch state { + case .ready: + await self.setReady() + await self.resumeReadyWaiters(with: .success(())) + case .failed(let error): + await self.failAllWaiters(NetSocketError.failed(underlying: error)) + await self.setClosed() + case .waiting(let error): + // bubble as transient failure for awaiters; reconnect logic could live here + await self.resumeReadyWaiters(with: .failure(NetSocketError.failed(underlying: error))) + case .cancelled: + await self.failAllWaiters(NetSocketError.closed) + await self.setClosed() + default: + break + } + } + } + + // Kick off receive loop after .start + self.connection.start(queue: queue) + try await self.waitUntilReady() + self.startReceiveLoop() + } + + private func startReceiveLoop() { + @Sendable func loop(_ connection: NWConnection, chunk: Int, owner: NetSocketNew, connID: String) { + print("NetSocketNew[\(connID)]: Calling connection.receive(\(chunk)) to request more data...") + + connection.receive(minimumIncompleteLength: 1, maximumLength: chunk) { [weak owner] data, _, isComplete, error in + print("NetSocketNew[\(connID)]: Receive callback - data: \(data?.count ?? 0) bytes, isComplete: \(isComplete), error: \(String(describing: error))") + Task { + guard let o = owner else { + return + } + + if let error { + await o.handleReceiveError(error) + return + } + if let data, !data.isEmpty { + await o.append(data, connID: connID) + } + if isComplete { + print("NetSocketNew[\(connID)]: EOF from peer.") + await o.handleEOF() + return + } + loop(connection, chunk: chunk, owner: o, connID: connID) + } + } + } + loop(connection, chunk: self.config.receiveChunk, owner: self, connID: connectionID) + } + + private func handleReceiveError(_ error: Error) { + self.isClosed = true + self.failAllWaiters(NetSocketError.failed(underlying: error)) + } + + private func handleEOF() { + self.isClosed = true + let waiters = self.dataWaiters + self.dataWaiters.removeAll() + for w in waiters { + w.resume() + } // wake so readers can observe closure + } + + private func setReady() { + self.ready = true + } + + private func setClosed() { + self.isClosed = true + } + + private func ensureReady() async throws { + if self.isClosed { + throw NetSocketError.closed + } + if !self.ready { + try await self.waitUntilReady() + } + } + + private func ensureReadable(_ count: Int) async throws { + try await self.ensureReady() + while self.availableBytes < count { + try Task.checkCancellation() + if self.isClosed { + throw NetSocketError.insufficientData(expected: count, got: self.availableBytes) + } + try await self.waitForData() + } + } + + private func waitForData() async throws { + try Task.checkCancellation() + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + if self.isClosed { + cont.resume() + return + } + self.dataWaiters.append(cont) + } + } + + private func compactIfNeeded() { + // Avoid unbounded memory as head advances + if self.head > 64 * 1024 && self.head > self.buffer.count / 2 { + self.buffer.removeSubrange(0.. Range? { + guard !delimiter.isEmpty, availableBytes >= delimiter.count else { return nil } + let hay = buffer[head.. Int64 { + let values = try url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) + guard values.isRegularFile == true else { + throw NetSocketError.failed(underlying: NSError( + domain: "NetSocket", code: 1001, + userInfo: [NSLocalizedDescriptionKey: "Not a regular file: \(url.path)"] + )) + } + if let s = values.fileSize { return Int64(s) } + let attrs = try FileManager.default.attributesOfItem(atPath: url.path) + if let n = attrs[.size] as? NSNumber { + return n.int64Value + } + throw NetSocketError.failed(underlying: NSError( + domain: "NetSocket", code: 1002, + userInfo: [NSLocalizedDescriptionKey: "Unable to determine file size for \(url.lastPathComponent)"] + )) + } + + private func waitUntilReady() async throws { + guard !self.ready else { return } + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + self.readyWaiters.append(cont) + } + } + + private func resumeReadyWaiters(with result: Result) { + let waiters = self.readyWaiters + self.readyWaiters.removeAll() + for w in waiters { + switch result { + case .success: w.resume() + case .failure(let e): w.resume(throwing: e) + } + } + } + + private func failAllWaiters(_ error: Error) { + self.resumeReadyWaiters(with: .failure(error)) + let waiters = self.dataWaiters + self.dataWaiters.removeAll() + for w in waiters { + w.resume(throwing: error) + } + } + + private func append(_ data: Data, connID: String) { + print("NetSocketNew[\(connID)]: Received \(data.count) bytes from network, buffer now has \(buffer.count - head + data.count) available") + buffer.append(data) + if buffer.count - head > config.maxBufferBytes { + // Hard stop: drop connection rather than OOM'ing. + isClosed = true + connection.cancel() + failAllWaiters(NetSocketError.framingExceeded(max: config.maxBufferBytes)) + return + } + resumeDataWaiters() + } + + private func resumeDataWaiters() { + let waiters = dataWaiters + dataWaiters.removeAll() + for w in waiters { w.resume() } + } +} + +// MARK: - Utilities + +private extension Data { + mutating func appendInteger(_ value: T, endian: Endian) throws { + var v = value + switch endian { + case .big: v = T(bigEndian: value) + case .little: v = T(littleEndian: value) + } + var copy = v + withUnsafePointer(to: ©) { ptr in + self.append(contentsOf: UnsafeRawBufferPointer(start: ptr, count: MemoryLayout.size)) + } + } +} + +// MARK: - NetSocketEncodable + +/// Protocol for types that can encode themselves to binary data +/// +/// Types conforming to `NetSocketEncodable` produce binary data that can be sent over +/// a socket. Unlike writing field-by-field to the socket, encodable types build complete +/// binary messages that are sent in a single write operation for efficiency. +/// +/// Example: +/// ```swift +/// struct MyMessage: NetSocketEncodable { +/// let id: UInt32 +/// let name: String +/// +/// func encode(endian: Endian) throws -> Data { +/// var data = Data() +/// // Encode fields to data... +/// return data +/// } +/// } +/// +/// try await socket.send(message) +/// ``` +public protocol NetSocketEncodable: Sendable { + /// Encode this value to binary data + /// + /// Implementations should build a complete binary message and return it as Data. + /// The data will be sent to the socket in a single write operation. + /// + /// - Parameter endian: Byte order for multi-byte values + /// - Returns: Encoded binary data ready to send + /// - Throws: Encoding errors + func encode(endian: Endian) throws -> Data +} + +/// Protocol for types that can decode themselves directly from a socket stream +/// +/// Types conforming to `NetSocketDecodable` read field-by-field directly from the socket +/// using async reads. This enables true streaming without buffering entire messages. +/// +/// **Important**: If decoding throws after consuming some bytes (e.g., validation fails), +/// the socket will be left with those bytes consumed. In practice, this usually means the +/// connection should be closed. For most protocols this is acceptable since decode errors +/// indicate corrupt data or protocol violations. +/// +/// Example: +/// ```swift +/// struct MyMessage: NetSocketDecodable { +/// let id: UInt32 +/// let name: String +/// +/// init(from socket: NetSocketNew, endian: Endian) async throws { +/// self.id = try await socket.read(UInt32.self, endian: endian) +/// let nameLen = try await socket.read(UInt16.self, endian: endian) +/// let nameData = try await socket.readExactly(Int(nameLen)) +/// guard let name = String(data: nameData, encoding: .utf8) else { +/// throw NetSocketError.decodeFailed(NSError()) +/// } +/// self.name = name +/// } +/// } +/// +/// let message = try await socket.receive(MyMessage.self) +/// ``` +public protocol NetSocketDecodable: Sendable { + /// Decode a value by reading directly from the socket stream + /// + /// This initializer should read all necessary fields from the socket using + /// methods like `read(_:endian:)`, `readExactly(_:)`, `readString(length:)`, etc. + /// + /// The socket handles waiting for data to arrive, so you can read field by field + /// without worrying about buffering. + /// + /// - Parameters: + /// - socket: Socket to read from + /// - endian: Byte order for multi-byte values + /// - Throws: Network errors, insufficient data, or custom decoding errors + init(from socket: NetSocketNew, endian: Endian) async throws +} + +public extension NetSocketNew { + /// Send an encodable value to the socket + /// + /// The type encodes itself to binary data, which is then sent in a single write operation. + /// + /// Example: + /// ```swift + /// struct MyMessage: NetSocketEncodable { + /// let id: UInt32 + /// let name: String + /// + /// func encode(endian: Endian) throws -> Data { + /// var data = Data() + /// // Build binary message... + /// return data + /// } + /// } + /// + /// try await socket.send(message) + /// ``` + /// + /// - Parameters: + /// - value: Value conforming to NetSocketEncodable + /// - endian: Byte order (default: big-endian) + /// - Throws: Encoding or network errors + func send(_ value: T, endian: Endian = .big) async throws { + let data = try value.encode(endian: endian) + try await self.write(data) + } + + /// Receive and decode a value directly from the socket stream (no length prefix) + /// + /// The type reads field-by-field from the socket as needed, enabling true streaming + /// without buffering entire messages. Useful for protocols where message size isn't + /// known upfront or for progressive decoding. + /// + /// Example: + /// ```swift + /// struct ServerEntry: NetSocketDecodable { + /// let id: UInt32 + /// let name: String + /// + /// init(from socket: NetSocketNew, endian: Endian) async throws { + /// self.id = try await socket.read(UInt32.self, endian: endian) + /// // Read variable-length string... + /// } + /// } + /// + /// let entry = try await socket.receive(ServerEntry.self) + /// ``` + /// + /// - Parameters: + /// - type: Type conforming to NetSocketDecodable + /// - endian: Byte order (default: big-endian) + /// - Returns: Decoded value + /// - Throws: Decoding or network errors + func receive(_ type: T.Type, endian: Endian = .big) async throws -> T { + return try await T(from: self, endian: endian) + } +} diff --git a/Hotline/Library/NetSocket/TransferRateEstimator.swift b/Hotline/Library/NetSocket/TransferRateEstimator.swift new file mode 100644 index 0000000..7d16904 --- /dev/null +++ b/Hotline/Library/NetSocket/TransferRateEstimator.swift @@ -0,0 +1,135 @@ +// TransferRateEstimator +// Dustin Mierau • @mierau +// MIT License + +import Foundation + +/// Transfer rate estimator using exponential moving average (EMA) +/// +/// Tracks transfer speed and estimates time remaining. Designed to smooth out +/// network jitter and provide stable estimates after collecting enough samples. +/// +/// Example: +/// ```swift +/// var estimator = TransferRateEstimator(total: fileSize) +/// +/// while transferring { +/// let chunk = try await receiveData() +/// let progress = estimator.update(bytes: chunk.count) +/// print("Speed: \(progress.bytesPerSecond ?? 0) B/s, ETA: \(progress.estimatedTimeRemaining ?? 0)s") +/// } +/// ``` +public struct TransferRateEstimator { + /// Total bytes to transfer (nil if unknown) + public let total: Int? + + /// Exponential moving average of transfer rate (bytes/second) + private var emaBytesPerSecond: Double = 0 + + /// Smoothing factor for EMA (0 < alpha ≤ 1) + /// Higher = more responsive to recent changes, lower = more smoothing + private let alpha: Double + + /// Number of samples collected + private var sampleCount: Int = 0 + + /// Timestamp of first sample (for elapsed time calculation) + private var startTime: ContinuousClock.Instant? + + /// Timestamp of last update (for calculating sample duration) + private var lastUpdateTime: ContinuousClock.Instant? + + /// Minimum elapsed time before trusting estimates (seconds) + private let minElapsedTime: TimeInterval + + /// Minimum number of samples before trusting estimates + private let minSamples: Int + + /// Current number of bytes transferred + public private(set) var transferred: Int = 0 + + /// Create a new transfer rate estimator + /// + /// - Parameters: + /// - total: Total bytes to transfer (nil if unknown) + /// - alpha: EMA smoothing factor (default: 0.2). Range: 0.0-1.0 + /// - minElapsedTime: Minimum elapsed time before estimates are reliable (default: 2.0s) + /// - minSamples: Minimum samples before estimates are reliable (default: 4) + public init( + total: Int? = nil, + alpha: Double = 0.2, + minElapsedTime: TimeInterval = 2.0, + minSamples: Int = 8 + ) { + precondition(alpha > 0 && alpha <= 1, "alpha must be in range (0, 1]") + precondition(minSamples >= 0, "minSamples must be >= 0") + + self.total = total + self.alpha = alpha + self.minElapsedTime = minElapsedTime + self.minSamples = minSamples + } + + public mutating func update(total: Int) -> NetSocketNew.FileProgress { + return self.update(bytes: max(0, total - self.transferred)) + } + + /// Update the estimator with a new data sample + /// + /// Automatically calculates the duration since the last update. + /// + /// - Parameter bytes: Number of bytes transferred in this sample + /// - Returns: Current progress with speed and ETA estimates + public mutating func update(bytes: Int) -> NetSocketNew.FileProgress { + let clock = ContinuousClock() + let now = clock.now + + // Record start time on first sample + if self.startTime == nil { + self.startTime = now + } + + // Calculate duration since last update + let duration = self.lastUpdateTime.map { now - $0 } ?? .zero + self.lastUpdateTime = now + + // Update transferred count + self.transferred += bytes + + // Calculate instantaneous rate for this sample + let seconds: Double = duration / .seconds(1.0) + if seconds > 0 { + let instantRate = Double(bytes) / seconds + self.sampleCount += 1 + + // Update EMA + if self.emaBytesPerSecond == 0 { + self.emaBytesPerSecond = instantRate + } else { + self.emaBytesPerSecond += self.alpha * (instantRate - self.emaBytesPerSecond) + } + } + + // Determine if we have enough data to trust the estimate + let elapsed = self.startTime.map { now - $0 } ?? .zero + let elapsedSeconds: Double = elapsed / .seconds(1.0) + let haveEstimate = (elapsedSeconds >= self.minElapsedTime || self.sampleCount >= self.minSamples) && self.emaBytesPerSecond > 0 + + // Calculate ETA if we have both an estimate and a known total + let eta: TimeInterval? + if haveEstimate, let total = self.total { + let remaining = total - self.transferred + eta = remaining > 0 ? TimeInterval(Double(remaining) / self.emaBytesPerSecond) : 0 + } else { + eta = nil + } + + return NetSocketNew.FileProgress( + sent: self.transferred, + total: self.total, + bytesPerSecond: haveEstimate ? self.emaBytesPerSecond : nil, + estimatedTimeRemaining: eta + ) + } +} + diff --git a/Hotline/Library/NetSocketNew.swift b/Hotline/Library/NetSocketNew.swift deleted file mode 100644 index 8873ee6..0000000 --- a/Hotline/Library/NetSocketNew.swift +++ /dev/null @@ -1,1278 +0,0 @@ - -// NetSocketNew.swift -// Created by Dustin Mierau • @mierau - -import Foundation -import Network - -// MARK: - Endianness and Framing - -/// Byte order for multi-byte integer values in binary protocols -public enum Endian { - /// Big-endian (network byte order, most significant byte first) - case big - /// Little-endian (least significant byte first) - case little -} - -/// Length prefix types for framing variable-length data (strings, arrays, binary blobs) -/// -/// Used to encode the size of the following data as a fixed-width integer. -/// Each case can specify its own endianness. -public enum LengthPrefix { - /// 1-byte length prefix (0-255) - case u8 - /// 2-byte length prefix (0-65,535) - case u16(Endian = .big) - /// 4-byte length prefix (0-4,294,967,295) - case u32(Endian = .big) - /// 8-byte length prefix (0-2^64-1) - case u64(Endian = .big) - - /// Number of bytes used by this length prefix - var byteCount: Int { - switch self { - case .u8: return 1 - case .u16: return 2 - case .u32: return 4 - case .u64: return 8 - } - } -} - -/// Delimiter patterns for text-based protocols -public enum Delimiter { - /// Custom single byte delimiter - case byte(UInt8) - /// Null terminator (0x00) - case zeroByte - /// Line feed (\n, 0x0A) - case lineFeed - /// Carriage return + line feed (\r\n, 0x0D 0x0A) - case carriageReturnLineFeed - - /// Binary representation of this delimiter - var data: Data { - switch self { - case .byte(let b): return Data([b]) - case .zeroByte: return Data([0x00]) - case .lineFeed: return Data([0x0A]) - case .carriageReturnLineFeed: return Data([0x0D, 0x0A]) - } - } -} - -/// TLS/SSL encryption policy for socket connections -public struct TLSPolicy: Sendable { - /// Create a TLS-enabled policy with optional custom configuration - /// - Parameter configure: Optional closure to customize TLS options - public static func enabled(_ configure: (@Sendable (NWProtocolTLS.Options) -> Void)? = nil) -> TLSPolicy { - TLSPolicy(enabled: true, configure: configure) - } - - /// Create a policy with TLS disabled (plaintext connection) - public static var disabled: TLSPolicy { TLSPolicy(enabled: false, configure: nil) } - - /// Whether TLS is enabled - public let enabled: Bool - /// Optional TLS configuration closure - public let configure: (@Sendable (NWProtocolTLS.Options) -> Void)? -} - -// MARK: - Errors - -/// Errors that can occur during socket operations -public enum NetSocketError: Error, CustomStringConvertible, Sendable { - /// Socket is not yet in ready state - case notReady - /// Connection has been closed - case closed - /// Invalid port number provided - case invalidPort - /// Network operation failed with underlying error - case failed(underlying: Error) - /// Not enough data available to fulfill read request - case insufficientData(expected: Int, got: Int) - /// Frame size exceeds configured maximum - case framingExceeded(max: Int) - /// Failed to decode data - case decodeFailed(Error) - /// Failed to encode data - case encodeFailed(Error) - - public var description: String { - switch self { - case .notReady: return "Connection not ready." - case .closed: return "Connection closed." - case .invalidPort: return "Invalid port number." - case .failed(let e): return "Network failure: \(e.localizedDescription)" - case .insufficientData(let exp, let got): return "Insufficient data: need \(exp), have \(got)." - case .framingExceeded(let max): return "Frame length exceeded maximum \(max)." - case .decodeFailed(let e): return "Decoding failed: \(e)" - case .encodeFailed(let e): return "Encoding failed: \(e)" - } - } -} - -// MARK: - NetSocketNew - -/// An async/await TCP socket with automatic buffering and framing support -/// -/// NetSocketNew provides: -/// - Async connection management -/// - Automatic receive buffering with memory compaction -/// - Length-prefixed framing for messages -/// - Type-safe reading/writing of integers, strings, and custom types -/// - File upload/download with progress tracking -/// - Flexible encoder/decoder support (JSON, binary, etc.) -/// -/// Example usage: -/// ```swift -/// let socket = try await NetSocketNew.connect(host: "example.com", port: 80) -/// try await socket.write("Hello\n".data(using: .utf8)!) -/// let response = try await socket.readUntil(delimiter: .lineFeed) -/// ``` -public actor NetSocketNew { - /// Configuration options for the socket - public struct Config: Sendable { - /// Size of chunks to receive from network at once (default: 64 KB) - public var receiveChunk: Int = 64 * 1024 - /// Maximum bytes to buffer before disconnecting (default: 8 MB) - public var maxBufferBytes: Int = 8 * 1024 * 1024 - /// Maximum size for a single framed message (default: 4 MB) - public var maxFrameBytes: Int = 4 * 1024 * 1024 - public init() {} - } - - // Connection + state - private let connection: NWConnection - private let queue = DispatchQueue(label: "NetSocket.NWConnection") - private var ready = false - private var isClosed = false - - // Buffer with compaction - private var buffer = Data() - private var head = 0 // start of unread bytes - private let cfg: Config - - // Waiters for data/ready - private var dataWaiters: [CheckedContinuation] = [] - private var readyWaiters: [CheckedContinuation] = [] - - // Codable hooks - stored as closures for flexibility with any encoder/decoder - private var encodeValue: @Sendable (any Encodable) throws -> Data = { value in - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - return try encoder.encode(value) - } - - private var decodeValue: @Sendable (Data, any Decodable.Type) throws -> any Decodable = { data, type in - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - return try decoder.decode(type, from: data) - } - - // MARK: Init/Connect - - private init(connection: NWConnection, config: Config) { - self.connection = connection - self.cfg = config - } - - /// Connect to a remote host and return a ready socket - /// - /// This method establishes a TCP connection using Network framework types and waits until - /// the connection is in `.ready` state. - /// - /// - Parameters: - /// - host: Network framework host (e.g., `.name("example.com", nil)` or `.ipv4(...)`) - /// - port: Network framework port - /// - tls: TLS policy (default: enabled with default settings) - /// - config: Socket configuration (default: standard settings) - /// - Returns: A connected and ready `NetSocketNew` - /// - Throws: Network errors or connection failures - public static func connect(host: NWEndpoint.Host, port: NWEndpoint.Port, tls: TLSPolicy = .enabled(), config: Config = .init()) async throws -> NetSocketNew { - let parameters = NWParameters.tcp - if tls.enabled { - let tlsOptions = NWProtocolTLS.Options() - tls.configure?(tlsOptions) - parameters.defaultProtocolStack.applicationProtocols.insert(tlsOptions, at: 0) - } - - let conn = NWConnection(host: host, port: port, using: parameters) - let socket = NetSocketNew(connection: conn, config: config) - try await socket.start() - return socket - } - - /// Convenience wrapper to connect using string hostname and integer port - public static func connect(host: String, port: UInt16, tls: TLSPolicy = .enabled(), config: Config = .init()) async throws -> NetSocketNew { - guard let nwPort = NWEndpoint.Port(rawValue: port) else { - throw NetSocketError.invalidPort - } - return try await connect(host: .name(host, nil), port: nwPort, tls: tls, config: config) - } - - /// Inject custom encoding/decoding logic (supports any encoder/decoder: JSON, CBOR, MessagePack, etc.) - /// - /// Example with JSONEncoder: - /// ``` - /// let encoder = JSONEncoder() - /// socket.useCoders( - /// encode: { try encoder.encode($0) }, - /// decode: { data, type in try decoder.decode(type, from: data) } - /// ) - /// ``` - /// - /// Example with other encoders (pseudocode): - /// ``` - /// let cbor = CBOREncoder() - /// socket.useCoders( - /// encode: { try cbor.encode($0) }, - /// decode: { data, type in try CBORDecoder().decode(type, from: data) } - /// ) - /// ``` - public func useCoders( - encode: @escaping @Sendable (any Encodable) throws -> Data, - decode: @escaping @Sendable (Data, any Decodable.Type) throws -> any Decodable - ) { - self.encodeValue = encode - self.decodeValue = decode - } - - /// Convenience method to configure JSON encoding/decoding - /// - /// Sets up the socket to use the provided JSON encoder/decoder for `send()` and `receive()` calls. - /// - /// - Parameters: - /// - encoder: A configured `JSONEncoder` - /// - decoder: A configured `JSONDecoder` - public func useJSONCoders(encoder: JSONEncoder, decoder: JSONDecoder) { - self.encodeValue = { try encoder.encode($0) } - self.decodeValue = { data, type in try decoder.decode(type, from: data) } - } - - private func start() async throws { - self.connection.stateUpdateHandler = { state in - Task { [weak self] in - guard let self else { return } - switch state { - case .ready: - await self.setReady() - await self.resumeReadyWaiters(with: .success(())) - case .failed(let error): - await self.failAllWaiters(NetSocketError.failed(underlying: error)) - await self.setClosed() - case .waiting(let error): - // bubble as transient failure for awaiters; reconnect logic could live here - await self.resumeReadyWaiters(with: .failure(NetSocketError.failed(underlying: error))) - case .cancelled: - await self.failAllWaiters(NetSocketError.closed) - await self.setClosed() - default: - break - } - } - } - - // Kick off receive loop after .start - self.connection.start(queue: queue) - try await self.waitUntilReady() - self.startReceiveLoop() - } - - private func waitUntilReady() async throws { - guard !ready else { return } - try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in - readyWaiters.append(cont) - } - } - - private func resumeReadyWaiters(with result: Result) { - let waiters = readyWaiters - readyWaiters.removeAll() - for w in waiters { - switch result { - case .success: w.resume() - case .failure(let e): w.resume(throwing: e) - } - } - } - - private func failAllWaiters(_ error: Error) { - resumeReadyWaiters(with: .failure(error)) - let waiters = dataWaiters - dataWaiters.removeAll() - for w in waiters { w.resume(throwing: error) } - } - - private func setReady() { - ready = true - } - - private func setClosed() { - isClosed = true - } - - // MARK: Receive loop (runs on DispatchQueue, hops into actor) - - private nonisolated func startReceiveLoop() { - func loop(_ connection: NWConnection, chunk: Int, owner: NetSocketNew) { - print("NetSocketNew: Calling connection.receive() to request more data...") - connection.receive(minimumIncompleteLength: 1, maximumLength: chunk) { data, _, isComplete, error in - print("NetSocketNew: Receive callback - data: \(data?.count ?? 0) bytes, isComplete: \(isComplete), error: \(String(describing: error))") - if let error { - Task { await owner.handleReceiveError(error) } - return - } - if let data, !data.isEmpty { - Task { await owner.append(data) } - } - if isComplete { - Task { await owner.handleEOF() } - return - } - loop(connection, chunk: chunk, owner: owner) - } - } - loop(connection, chunk: cfg.receiveChunk, owner: self) - } - - private func handleReceiveError(_ error: Error) { - isClosed = true - failAllWaiters(NetSocketError.failed(underlying: error)) - } - - private func handleEOF() { - isClosed = true - let waiters = dataWaiters - dataWaiters.removeAll() - for w in waiters { w.resume() } // wake so readers can observe closure - } - - private func append(_ data: Data) { - print("NetSocketNew: Received \(data.count) bytes from network, buffer now has \(buffer.count - head + data.count) available") - buffer.append(data) - if buffer.count - head > cfg.maxBufferBytes { - // Hard stop: drop connection rather than OOM'ing. - isClosed = true - connection.cancel() - failAllWaiters(NetSocketError.framingExceeded(max: cfg.maxBufferBytes)) - return - } - resumeDataWaiters() - } - - private func resumeDataWaiters() { - let waiters = dataWaiters - dataWaiters.removeAll() - for w in waiters { w.resume() } - } - - // MARK: Close - - /// Close the connection gracefully - /// - /// Performs a graceful shutdown of the underlying network connection (e.g., TCP FIN) - /// and wakes all pending read/write operations with a `NetSocketError.closed` error. - /// This method is idempotent - subsequent calls are ignored. - /// - /// Use `forceClose()` for immediate non-graceful termination (e.g., TCP RST). - public func close() { - guard !isClosed else { return } - isClosed = true - connection.cancel() - resumeDataWaiters() - resumeReadyWaiters(with: .failure(NetSocketError.closed)) - } - - /// Force close the connection immediately (non-graceful) - /// - /// Performs an immediate non-graceful shutdown of the underlying network connection - /// (e.g., TCP RST). Use this when you need to terminate the connection immediately - /// without waiting for graceful closure. For normal shutdown, use `close()` instead. - /// - /// This method is idempotent - subsequent calls are ignored. - public func forceClose() { - guard !isClosed else { return } - isClosed = true - connection.forceCancel() - resumeDataWaiters() - resumeReadyWaiters(with: .failure(NetSocketError.closed)) - } - - // MARK: Send (async) - - /// Write raw data to the socket - /// - /// Sends data and waits for confirmation that it has been processed by the network stack. - /// - /// - Parameter data: Raw bytes to send - /// - Throws: `NetSocketError` if connection is not ready or send fails - public func write(_ data: Data) async throws { - try await ensureReady() - try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in - connection.send(content: data, completion: .contentProcessed { error in - if let error { cont.resume(throwing: NetSocketError.failed(underlying: error)) } - else { cont.resume() } - }) - } - } - - /// Write a fixed-width integer to the socket - /// - /// - Parameters: - /// - value: The integer value to write - /// - endian: Byte order (default: big-endian) - /// - Throws: `NetSocketError` if write fails - public func write(_ value: T, endian: Endian = .big) async throws { - var v = value - switch endian { - case .big: v = T(bigEndian: value) - case .little: v = T(littleEndian: value) - } - var copy = v - let size = MemoryLayout.size - let bytes = withUnsafePointer(to: ©) { - Data(bytes: $0, count: size) - } - try await write(bytes) - } - - /// Write a boolean as a single byte (0 or 1) - /// - Parameter value: Boolean value - public func write(_ value: Bool) async throws { - try await write(value ? UInt8(0x01) : UInt8(0x00)) - } - - /// Write a Float as its IEEE 754 bit pattern - /// - Parameters: - /// - value: Float value - /// - endian: Byte order (default: big-endian) - public func write(_ value: Float, endian: Endian = .big) async throws { - try await write(value.bitPattern, endian: endian) - } - - /// Write a Double as its IEEE 754 bit pattern - /// - Parameters: - /// - value: Double value - /// - endian: Byte order (default: big-endian) - public func write(_ value: Double, endian: Endian = .big) async throws { - try await write(value.bitPattern, endian: endian) - } - - /// Write a string to the socket, optionally length-prefixed - /// - /// - Parameters: - /// - string: String to write - /// - prefix: Optional length prefix (if provided, string is sent as a framed message) - /// - encoding: Text encoding (default: UTF-8) - /// - Throws: `NetSocketError` if encoding fails or write fails - public func write(_ string: String, prefix: LengthPrefix? = nil, encoding: String.Encoding = .utf8) async throws { - guard let data = string.data(using: encoding) else { - throw NetSocketError.encodeFailed(NSError(domain: "StringEncoding", code: -1)) - } - if let prefix { try await sendFrame(data, prefix: prefix) } - else { try await write(data) } - } - - // MARK: Frames & Codable - - /// Send a length-prefixed frame - /// - /// Writes the payload size as a fixed-width integer, followed by the payload bytes. - /// - /// - Parameters: - /// - payload: Data to send - /// - prefix: Length prefix type (default: u32 big-endian) - /// - Throws: `NetSocketError.framingExceeded` if payload is too large for prefix type - public func sendFrame(_ payload: Data, prefix: LengthPrefix = .u32()) async throws { - // Ensure frame payload does not exceed max frame length. - switch prefix { - case .u8 where payload.count > Int(UInt8.max): - throw NetSocketError.framingExceeded(max: Int(UInt8.max)) - case .u16 where payload.count > Int(UInt16.max): - throw NetSocketError.framingExceeded(max: Int(UInt16.max)) - case .u32 where payload.count > Int(UInt32.max): - throw NetSocketError.framingExceeded(max: Int(UInt32.max)) - default: - break - } - - if payload.count > cfg.maxFrameBytes { throw NetSocketError.framingExceeded(max: cfg.maxFrameBytes) } - var header = Data() - switch prefix { - case .u8: header.append(UInt8(payload.count)) - case .u16(let e): - try header.appendInteger(UInt16(payload.count), endian: e) - case .u32(let e): - try header.appendInteger(UInt32(payload.count), endian: e) - case .u64(let e): - try header.appendInteger(UInt64(payload.count), endian: e) - } - try await write(header + payload) - } - - /// Receive a length-prefixed frame - /// - /// Reads a length prefix, then reads exactly that many bytes. Waits for data to arrive if needed. - /// - /// - Parameter prefix: Length prefix type (default: u32 big-endian) - /// - Returns: The frame payload - /// - Throws: `NetSocketError.framingExceeded` if frame size exceeds maximum - public func receiveFrame(prefix: LengthPrefix = .u32()) async throws -> Data { - let length: Int - switch prefix { - case .u8: - let v: UInt8 = try await read(UInt8.self) - length = Int(v) - case .u16(let e): - let v: UInt16 = try await read(UInt16.self, endian: e) - length = Int(v) - case .u32(let e): - let v: UInt32 = try await read(UInt32.self, endian: e) - length = Int(v) - case .u64(let e): - let v: UInt64 = try await read(UInt64.self, endian: e) - if v > UInt64(cfg.maxFrameBytes) { throw NetSocketError.framingExceeded(max: cfg.maxFrameBytes) } - length = Int(v) - } - if length > cfg.maxFrameBytes { throw NetSocketError.framingExceeded(max: cfg.maxFrameBytes) } - return try await read(length) - } - - /// Send an encodable value as a length-prefixed frame - /// - /// Uses the configured encoder (default: JSON) to serialize the value. - /// - /// - Parameters: - /// - value: Value to encode and send - /// - prefix: Length prefix type (default: u32 big-endian) - /// - Throws: `NetSocketError.encodeFailed` if encoding fails - public func send(_ value: T, prefix: LengthPrefix = .u32()) async throws { - do { - let data = try encodeValue(value) - try await sendFrame(data, prefix: prefix) - } catch { - throw NetSocketError.encodeFailed(error) - } - } - - /// Receive and decode a length-prefixed value - /// - /// Uses the configured decoder (default: JSON) to deserialize the value. - /// - /// - Parameters: - /// - type: Type to decode - /// - prefix: Length prefix type (default: u32 big-endian) - /// - Returns: Decoded value - /// - Throws: `NetSocketError.decodeFailed` if decoding fails - public func receive(_ type: T.Type, prefix: LengthPrefix = .u32()) async throws -> T { - let data = try await receiveFrame(prefix: prefix) - do { - let decoded = try decodeValue(data, T.self) - guard let result = decoded as? T else { - throw NetSocketError.decodeFailed(NSError( - domain: "NetSocketNew", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "Type mismatch in decode"] - )) - } - return result - } catch { - throw NetSocketError.decodeFailed(error) - } - } - - // MARK: Read typed & utilities - - /// Read a fixed-width integer from the socket - /// - /// - Parameters: - /// - type: Integer type to read - /// - endian: Byte order (default: big-endian) - /// - Returns: The integer value - /// - Throws: `NetSocketError` if insufficient data or connection closed - public func read(_ type: T.Type = T.self, endian: Endian = .big) async throws -> T { - let size = MemoryLayout.size - let data = try await read(size) - let value: T = data.withUnsafeBytes { raw in - raw.load(as: T.self) - } - switch endian { - case .big: return T(bigEndian: value) - case .little: return T(littleEndian: value) - } - } - - /// Read a fixed-length string - /// - /// - Parameters: - /// - length: Number of bytes to read - /// - encoding: Text encoding (default: UTF-8) - /// - Returns: Decoded string - /// - Throws: `NetSocketError` if decoding fails or insufficient data - public func read(_ length: Int, encoding: String.Encoding = .utf8) async throws -> String { - let data = try await read(length) - guard let s = String(data: data, encoding: encoding) else { throw NetSocketError.decodeFailed(NSError()) } - return s - } - - /// Read a string until a delimiter is found - /// - /// - Parameters: - /// - delimiter: Delimiter pattern to search for - /// - maxBytes: Maximum bytes to read before throwing (default: no limit) - /// - includeDelimiter: Whether to include delimiter in result (default: false) - /// - Returns: String read from stream (delimiter consumed but not included unless specified) - /// - Throws: `NetSocketError` if decoding fails, max bytes exceeded, or connection closed - public func read(until delimiter: Delimiter, maxBytes: Int? = nil, includeDelimiter: Bool = false) async throws -> String { - let bytes = try await read(past: delimiter.data, maxBytes: maxBytes, includeDelimiter: includeDelimiter) - guard let s = String(data: bytes, encoding: .utf8) else { throw NetSocketError.decodeFailed(NSError()) } - return s - } - - /// Read a pascal string (1-byte length prefix followed by string data) - /// - /// This method reads a single byte for the length, then reads that many bytes and attempts - /// to decode them as a string. It tries multiple encodings for compatibility with legacy - /// protocols like Hotline: UTF-8, Shift-JIS, Windows-1251, and falls back to MacRoman. - /// - /// - Returns: The decoded string, or nil if length is 0 - /// - Throws: `NetSocketError` if reading fails or no encoding succeeds - public func readPascalString() async throws -> String? { - let length = try await read(UInt8.self) - guard length > 0 else { return nil } - - let data = try await read(Int(length)) - - // Try auto-detection with common encodings - let allowedEncodings = [ - String.Encoding.utf8.rawValue, - String.Encoding.shiftJIS.rawValue, - String.Encoding.unicode.rawValue, - String.Encoding.windowsCP1251.rawValue - ] - - var decodedString: NSString? - let detected = NSString.stringEncoding( - for: data, - encodingOptions: [.allowLossyKey: false], - convertedString: &decodedString, - usedLossyConversion: nil - ) - - if allowedEncodings.contains(detected), let str = decodedString as? String { - return str - } - - // Fallback to MacRoman for classic Mac compatibility - guard let str = String(data: data, encoding: .macOSRoman) else { - throw NetSocketError.decodeFailed(NSError( - domain: "NetSocketNew", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "Failed to decode pascal string with any known encoding"] - )) - } - return str - } - - /// Read data until a delimiter is found - /// - /// Searches the buffer for the delimiter pattern and returns all data up to (and optionally including) - /// the delimiter. The delimiter is always consumed from the stream. - /// - /// - Parameters: - /// - delimiter: Binary delimiter pattern to search for - /// - maxBytes: Maximum bytes to read before throwing (default: no limit) - /// - includeDelimiter: Whether to include delimiter in result (default: false) - /// - Returns: Data read from stream - /// - Throws: `NetSocketError.framingExceeded` if max bytes exceeded, or connection errors - public func read(past delimiter: Data, maxBytes: Int? = nil, includeDelimiter: Bool = false) async throws -> Data { - while true { - try Task.checkCancellation() - if let r = search(delimiter: delimiter) { - let consumeLen = r.upperBound - head - let data = try await read(consumeLen) - return includeDelimiter ? data : data.dropLast(delimiter.count) - } - if let maxBytes, availableBytes >= maxBytes { - throw NetSocketError.framingExceeded(max: maxBytes) - } - try await waitForData() - guard !isClosed || availableBytes > 0 else { throw NetSocketError.closed } - } - } - - /// Read exactly N bytes from the socket - /// - /// Waits for data to arrive if buffer doesn't contain enough bytes yet. The internal buffer - /// is automatically compacted after reading to prevent unbounded memory growth. - /// - /// - Parameter count: Number of bytes to read - /// - Returns: Exactly `count` bytes - /// - Throws: `NetSocketError.insufficientData` if connection closes before enough data arrives - public func read(_ count: Int) async throws -> Data { - try await ensureReadable(count) - let start = head - let end = head + count - let slice = buffer[start.. 0 else { return } - try await ensureReadable(count) - head += count - compactIfNeeded() - } - - /// Skip until delimiter is found (discards delimiter too) - public func skip(past delimiter: Data) async throws { - while true { - try Task.checkCancellation() - if let r = search(delimiter: delimiter) { - head = r.upperBound // Skip to end of delimiter - compactIfNeeded() - return - } - try await waitForData() - guard !isClosed else { throw NetSocketError.closed } - } - } - - /// Read exactly N bytes with progress callbacks - /// - /// Like `read(_:)`, but reads in chunks and reports progress after each chunk. - /// Useful for downloading large amounts of data where you want to update UI progress. - /// - /// Example: - /// ```swift - /// let data = try await socket.read(1_000_000) { current, total in - /// print("Progress: \(current)/\(total)") - /// } - /// ``` - /// - /// - Parameters: - /// - count: Number of bytes to read - /// - chunkSize: Size of chunks to read at a time (default: 8192) - /// - progress: Optional callback with (bytesReceived, totalBytes) - /// - Returns: Exactly `count` bytes - /// - Throws: `NetSocketError` if connection closes before enough data arrives - public func read( - _ count: Int, - chunkSize: Int = 8192, - progress: (@Sendable (Int, Int) -> Void)? = nil - ) async throws -> Data { - var data = Data() - data.reserveCapacity(count) - var received = 0 - - while received < count { - try Task.checkCancellation() - let toRead = min(chunkSize, count - received) - let chunk = try await read(toRead) - data.append(chunk) - received += chunk.count - progress?(received, count) - } - - return data - } - - func peek(_ count: Int) async throws -> Data { - try await ensureReadable(count) - let slice = buffer[head..<(head + count)] - return Data(slice) // Don't advance head - } - - // MARK: Internals - - private var availableBytes: Int { buffer.count - head } - - private func waitForData() async throws { - try Task.checkCancellation() - try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in - if isClosed { cont.resume(); return } - dataWaiters.append(cont) - } - } - - private func ensureReadable(_ count: Int) async throws { - try await ensureReady() - while availableBytes < count { - try Task.checkCancellation() - if isClosed { throw NetSocketError.insufficientData(expected: count, got: availableBytes) } - try await waitForData() - } - } - - private func ensureReady() async throws { - if isClosed { throw NetSocketError.closed } - if !ready { try await waitUntilReady() } - } - - private func compactIfNeeded() { - // Avoid unbounded memory as head advances - if head > 64 * 1024 && head > buffer.count / 2 { - buffer.removeSubrange(0.. Range? { - guard !delimiter.isEmpty, availableBytes >= delimiter.count else { return nil } - let hay = buffer[head..(_ value: T, endian: Endian) throws { - var v = value - switch endian { - case .big: v = T(bigEndian: value) - case .little: v = T(littleEndian: value) - } - var copy = v - withUnsafePointer(to: ©) { ptr in - self.append(contentsOf: UnsafeRawBufferPointer(start: ptr, count: MemoryLayout.size)) - } - } -} - -public extension NetSocketNew { - /// Progress information for file uploads/downloads - struct FileProgress: Sendable { - /// Number of bytes sent/received so far - public let sent: Int64 - /// Total file size (may be nil if unknown) - public let total: Int64? - } - - /// Upload a file to the socket without framing (raw byte stream) - /// - /// Reads and writes the file in chunks to limit memory usage. Each chunk waits for network - /// backpressure via `.contentProcessed` before reading the next chunk. - /// - /// - Parameters: - /// - url: File URL to upload - /// - chunkSize: Chunk size for reading/writing (default: 256 KB) - /// - progress: Optional progress callback - /// - Returns: Total bytes sent - /// - Throws: File I/O or network errors - @discardableResult - func writeFile( - from url: URL, - chunkSize: Int = 256 * 1024, - progress: (@Sendable (FileProgress) -> Void)? = nil - ) async throws -> Int64 { - try await ensureReady() - let total = try? self.fileLength(at: url) - - let fh = try FileHandle(forReadingFrom: url) - defer { try? fh.close() } - - var sent: Int64 = 0 - while true { - try Task.checkCancellation() - guard let chunk = try fh.read(upToCount: chunkSize), !chunk.isEmpty else { break } - try await write(chunk) // uses .contentProcessed completion inside - sent += Int64(chunk.count) - progress?(.init(sent: sent, total: total)) - } - return sent - } - - /// Upload a file as a length-prefixed frame without buffering the entire file in memory - /// - /// Sends the file size as a length prefix, then streams the file content in chunks. - /// Memory-efficient for large files. - /// - /// - Parameters: - /// - url: File URL to upload - /// - lengthPrefix: Length prefix type (default: u64 big-endian) - /// - chunkSize: Chunk size for reading/writing (default: 256 KB) - /// - progress: Optional progress callback - /// - Returns: Total bytes sent (not including length header) - /// - Throws: File I/O, framing, or network errors - @discardableResult - func sendFileFramed( - _ url: URL, - lengthPrefix: LengthPrefix = .u64(.big), - chunkSize: Int = 256 * 1024, - progress: (@Sendable (FileProgress) -> Void)? = nil - ) async throws -> Int64 { - let total = try fileLength(at: url) - try ensure(total, fitsIn: lengthPrefix) - - // 1) Send the length header - var header = Data() - switch lengthPrefix { - case .u8: - header.append(UInt8(truncatingIfNeeded: total)) - case .u16(let e): - try header.appendInteger(UInt16(truncatingIfNeeded: total), endian: e) - case .u32(let e): - try header.appendInteger(UInt32(truncatingIfNeeded: total), endian: e) - case .u64(let e): - try header.appendInteger(UInt64(total), endian: e) - } - try await write(header) - - // 2) Stream the file bytes (raw) right after the header - let sent = try await writeFile(from: url, chunkSize: chunkSize) { prog in - progress?(prog) - } - return sent - } - - /// Download a length-prefixed file and write it to disk in chunks (bounded memory) - /// - /// Reads the file size from a length prefix, then streams the content directly to disk - /// in chunks to avoid loading the entire file into memory. - /// - /// - Parameters: - /// - url: Destination file URL - /// - lengthPrefix: Length prefix type (default: u64 big-endian) - /// - chunkSize: Chunk size for reading/writing (default: 256 KB) - /// - overwrite: Whether to overwrite existing file (default: true) - /// - progress: Optional progress callback - /// - Returns: Total bytes written - /// - Throws: File I/O, framing, or network errors - @discardableResult - func receiveFile( - to url: URL, - lengthPrefix: LengthPrefix = .u64(.big), - chunkSize: Int = 256 * 1024, - overwrite: Bool = true, - progress: (@Sendable (FileProgress) -> Void)? = nil - ) async throws -> Int64 { - // 1) Read length header - let total64: Int64 = try await { - switch lengthPrefix { - case .u8: return Int64(try await read(UInt8.self)) - case .u16(let e): return Int64(try await read(UInt16.self, endian: e)) - case .u32(let e): return Int64(try await read(UInt32.self, endian: e)) - case .u64(let e): - let v: UInt64 = try await read(UInt64.self, endian: e) - guard v <= UInt64(Int64.max) else { - throw NetSocketError.framingExceeded(max: Int(Int64.max)) - } - return Int64(v) - } - }() - - // 2) Prepare destination file - if overwrite { try? FileManager.default.removeItem(at: url) } - FileManager.default.createFile(atPath: url.path, contents: nil, attributes: nil) - let fh = try FileHandle(forWritingTo: url) - defer { try? fh.close() } - - // 3) Stream chunks from the socket into the file - var remaining = total64 - var written: Int64 = 0 - - while remaining > 0 { - try Task.checkCancellation() - let n = Int(min(Int64(chunkSize), remaining)) - let chunk = try await read(n) // reuses your internal buffer, bounded by n - fh.write(chunk) - remaining -= Int64(n) - written += Int64(n) - progress?(.init(sent: written, total: total64)) - } - return written - } - - /// Download a file of known length and write it to disk in chunks - /// - /// Unlike `receiveFile()`, this method does **not** read a length prefix. The caller must - /// provide the expected file size (e.g., from protocol metadata). The file is streamed - /// directly to disk to avoid loading it entirely into memory. - /// - /// Supports atomic writes: when enabled, data is written to a temporary `.part` file and - /// renamed on success. If an error occurs, the temporary file is automatically cleaned up. - /// - /// - Parameters: - /// - url: Destination file URL - /// - length: Exact number of bytes to read (must match what's on the wire) - /// - chunkSize: Chunk size for reading/writing (default: 256 KB) - /// - overwrite: Whether to overwrite existing file (default: true) - /// - atomic: Write to temporary file and rename on success (default: true) - /// - progress: Optional progress callback - /// - Returns: Total bytes written (equals `length` on success) - /// - Throws: File I/O or network errors. On atomic writes, partial files are cleaned up. - /// - /// Example: - /// ```swift - /// // Hotline protocol: file size comes from transaction header - /// let transaction = try await socket.receive(HotlineTransaction.self) - /// try await socket.receiveFileKnownLength( - /// to: destinationURL, - /// length: transaction.fileSize - /// ) - /// ``` - @discardableResult - func receiveFileKnownLength( - to url: URL, - length: Int64, - chunkSize: Int = 256 * 1024, - overwrite: Bool = true, - atomic: Bool = true, - progress: (@Sendable (FileProgress) -> Void)? = nil - ) async throws -> Int64 { - precondition(length >= 0, "length must be >= 0") - - // Validate length doesn't exceed configured maximum - guard length <= cfg.maxFrameBytes else { - throw NetSocketError.framingExceeded(max: cfg.maxFrameBytes) - } - - // Fast path: nothing to do - if length == 0 { - if overwrite { try? FileManager.default.removeItem(at: url) } - FileManager.default.createFile(atPath: url.path, contents: Data(), attributes: nil) - return 0 - } - - // Prepare destination (optionally atomic) - let fm = FileManager.default - let dir = url.deletingLastPathComponent() - let tmp = atomic - ? dir.appendingPathComponent(".\(url.lastPathComponent).part-\(UUID().uuidString)") - : url - - if overwrite { try? fm.removeItem(at: tmp) } - if overwrite, !atomic { try? fm.removeItem(at: url) } - - // Create and open the file for writing - fm.createFile(atPath: tmp.path, contents: nil, attributes: nil) - let fh = try FileHandle(forWritingTo: tmp) - defer { try? fh.close() } - - var remaining = length - var written: Int64 = 0 - - do { - while remaining > 0 { - try Task.checkCancellation() - let n = Int(min(Int64(chunkSize), remaining)) - let chunk = try await read(n) - fh.write(chunk) - remaining -= Int64(n) - written += Int64(n) - progress?(.init(sent: written, total: length)) - } - } catch { - // Cleanup partial file on failure if we were writing atomically - if atomic { try? fm.removeItem(at: tmp) } - throw error - } - - // Atomically move into place if requested - if atomic { - if overwrite { try? fm.removeItem(at: url) } - try fm.moveItem(at: tmp, to: url) - } - - return written - } -} - -// MARK: - Small helpers (private) -fileprivate extension NetSocketNew { - func fileLength(at url: URL) throws -> Int64 { - let values = try url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) - guard values.isRegularFile == true else { - throw NetSocketError.failed(underlying: NSError( - domain: "NetSocket", code: 1001, - userInfo: [NSLocalizedDescriptionKey: "Not a regular file: \(url.path)"] - )) - } - if let s = values.fileSize { return Int64(s) } - let attrs = try FileManager.default.attributesOfItem(atPath: url.path) - if let n = attrs[.size] as? NSNumber { return n.int64Value } - throw NetSocketError.failed(underlying: NSError( - domain: "NetSocket", code: 1002, - userInfo: [NSLocalizedDescriptionKey: "Unable to determine file size for \(url.lastPathComponent)"] - )) - } - - func ensure(_ length: Int64, fitsIn prefix: LengthPrefix) throws { - let max: Int64 = { - switch prefix { - case .u8: return Int64(UInt8.max) - case .u16: return Int64(UInt16.max) - case .u32: return Int64(UInt32.max) - case .u64: return Int64.max - } - }() - if length > max { - throw NetSocketError.framingExceeded(max: Int(max)) - } - } -} - -// MARK: - Stream-based Encoding/Decoding - -/// Protocol for types that can encode themselves to binary data -/// -/// Types conforming to `NetSocketEncodable` produce binary data that can be sent over -/// a socket. Unlike writing field-by-field to the socket, encodable types build complete -/// binary messages that are sent in a single write operation for efficiency. -/// -/// Example: -/// ```swift -/// struct MyMessage: NetSocketEncodable { -/// let id: UInt32 -/// let name: String -/// -/// func encode(endian: Endian) throws -> Data { -/// var data = Data() -/// // Encode fields to data... -/// return data -/// } -/// } -/// -/// try await socket.send(message) -/// ``` -public protocol NetSocketEncodable: Sendable { - /// Encode this value to binary data - /// - /// Implementations should build a complete binary message and return it as Data. - /// The data will be sent to the socket in a single write operation. - /// - /// - Parameter endian: Byte order for multi-byte values - /// - Returns: Encoded binary data ready to send - /// - Throws: Encoding errors - func encode(endian: Endian) throws -> Data -} - -/// Protocol for types that can decode themselves directly from a socket stream -/// -/// Types conforming to `NetSocketDecodable` read field-by-field directly from the socket -/// using async reads. This enables true streaming without buffering entire messages. -/// -/// **Important**: If decoding throws after consuming some bytes (e.g., validation fails), -/// the socket will be left with those bytes consumed. In practice, this usually means the -/// connection should be closed. For most protocols this is acceptable since decode errors -/// indicate corrupt data or protocol violations. -/// -/// Example: -/// ```swift -/// struct MyMessage: NetSocketDecodable { -/// let id: UInt32 -/// let name: String -/// -/// init(from socket: NetSocketNew, endian: Endian) async throws { -/// self.id = try await socket.read(UInt32.self, endian: endian) -/// let nameLen = try await socket.read(UInt16.self, endian: endian) -/// let nameData = try await socket.readExactly(Int(nameLen)) -/// guard let name = String(data: nameData, encoding: .utf8) else { -/// throw NetSocketError.decodeFailed(NSError()) -/// } -/// self.name = name -/// } -/// } -/// -/// let message = try await socket.receive(MyMessage.self) -/// ``` -public protocol NetSocketDecodable: Sendable { - /// Decode a value by reading directly from the socket stream - /// - /// This initializer should read all necessary fields from the socket using - /// methods like `read(_:endian:)`, `readExactly(_:)`, `readString(length:)`, etc. - /// - /// The socket handles waiting for data to arrive, so you can read field by field - /// without worrying about buffering. - /// - /// - Parameters: - /// - socket: Socket to read from - /// - endian: Byte order for multi-byte values - /// - Throws: Network errors, insufficient data, or custom decoding errors - init(from socket: NetSocketNew, endian: Endian) async throws -} - -public extension NetSocketNew { - /// Send an encodable value to the socket - /// - /// The type encodes itself to binary data, which is then sent in a single write operation. - /// - /// Example: - /// ```swift - /// struct MyMessage: NetSocketEncodable { - /// let id: UInt32 - /// let name: String - /// - /// func encode(endian: Endian) throws -> Data { - /// var data = Data() - /// // Build binary message... - /// return data - /// } - /// } - /// - /// try await socket.send(message) - /// ``` - /// - /// - Parameters: - /// - value: Value conforming to NetSocketEncodable - /// - endian: Byte order (default: big-endian) - /// - Throws: Encoding or network errors - func send(_ value: T, endian: Endian = .big) async throws { - let data = try value.encode(endian: endian) - try await write(data) - } - - /// Receive and decode a value directly from the socket stream (no length prefix) - /// - /// The type reads field-by-field from the socket as needed, enabling true streaming - /// without buffering entire messages. Useful for protocols where message size isn't - /// known upfront or for progressive decoding. - /// - /// Example: - /// ```swift - /// struct ServerEntry: NetSocketDecodable { - /// let id: UInt32 - /// let name: String - /// - /// init(from socket: NetSocketNew, endian: Endian) async throws { - /// self.id = try await socket.read(UInt32.self, endian: endian) - /// // Read variable-length string... - /// } - /// } - /// - /// let entry = try await socket.receive(ServerEntry.self) - /// ``` - /// - /// - Parameters: - /// - type: Type conforming to NetSocketDecodable - /// - endian: Byte order (default: big-endian) - /// - Returns: Decoded value - /// - Throws: Decoding or network errors - func receive(_ type: T.Type, endian: Endian = .big) async throws -> T { - return try await T(from: self, endian: endian) - } -} diff --git a/Hotline/Library/QuickLookPreviewView.swift b/Hotline/Library/QuickLookPreviewView.swift new file mode 100644 index 0000000..6ba154e --- /dev/null +++ b/Hotline/Library/QuickLookPreviewView.swift @@ -0,0 +1,22 @@ +import SwiftUI +import Quartz + +/// Embeddable QuickLook preview view for macOS +/// +/// This view uses QLPreviewView to display file previews inline, without showing a modal. +/// Supports all file types that QuickLook supports (images, PDFs, videos, documents, etc.) +struct QuickLookPreviewView: NSViewRepresentable { + let fileURL: URL + + func makeNSView(context: Context) -> QLPreviewView { + let preview = QLPreviewView(frame: .zero, style: .normal)! + preview.autostarts = true + preview.shouldCloseWithWindow = true + preview.previewItem = fileURL as QLPreviewItem + return preview + } + + func updateNSView(_ nsView: QLPreviewView, context: Context) { + nsView.previewItem = fileURL as QLPreviewItem + } +} diff --git a/Hotline/Library/URLAdditions.swift b/Hotline/Library/URLAdditions.swift index 1f25541..0ba2100 100644 --- a/Hotline/Library/URLAdditions.swift +++ b/Hotline/Library/URLAdditions.swift @@ -1,4 +1,5 @@ import Foundation +import UniformTypeIdentifiers extension URL { func generateUniqueFilePath(filename base: String) -> String { @@ -24,3 +25,31 @@ extension URL { return filePath } } + +extension UTType { + var canBePreviewedByQuickLook: Bool { + // QuickLook supports most common document types + let supportedSupertypes: [UTType] = [ + .image, + .movie, + .audio, + .pdf, + .font, + .usdz, + .text, + .sourceCode, + .spreadsheet, + .presentation, + +// Microsoft Office + .init(filenameExtension: "doc")!, + .init(filenameExtension: "docx")!, + .init(filenameExtension: "xls")!, + .init(filenameExtension: "xlsx")!, + .init(filenameExtension: "ppt")!, + .init(filenameExtension: "pptx")!, + ] + + return supportedSupertypes.contains { self.conforms(to: $0) } + } +} diff --git a/Hotline/MacApp.swift b/Hotline/MacApp.swift index 44ca308..b1cc1af 100644 --- a/Hotline/MacApp.swift +++ b/Hotline/MacApp.swift @@ -2,6 +2,7 @@ import SwiftUI import SwiftData import CloudKit import UniformTypeIdentifiers +import Darwin @Observable final class AppLaunchState { @@ -65,7 +66,7 @@ struct Application: App { @State private var selection: TrackerSelection? = nil @Bindable private var update = AppUpdate.shared - @FocusedValue(\.activeHotlineModel) private var activeHotline: Hotline? + @FocusedValue(\.activeHotlineModel) private var activeHotline: HotlineState? @FocusedValue(\.activeServerState) private var activeServerState: ServerState? private var modelContainer: ModelContainer = { @@ -173,9 +174,7 @@ struct Application: App { .defaultSize(width: 690, height: 760) .defaultPosition(.center) .onChange(of: activeServerState) { - withAnimation { - AppState.shared.activeServerState = activeServerState - } + AppState.shared.activeServerState = activeServerState } .onChange(of: activeHotline) { AppState.shared.activeHotline = activeHotline @@ -187,7 +186,7 @@ struct Application: App { } .keyboardShortcut(.init("K"), modifiers: .command) } - CommandGroup(after: .singleWindowList) { + CommandGroup(before: .singleWindowList) { Button("Toolbar") { toggleBannerWindow() } @@ -222,7 +221,11 @@ struct Application: App { .disabled(selection == nil || selection?.server == nil) .keyboardShortcut(.downArrow, modifiers: .command) Button("Disconnect") { - activeHotline?.disconnect() + if let hotline = activeHotline { + Task { + await hotline.disconnect() + } + } } .disabled(activeHotline?.status == .disconnected) Divider() @@ -264,6 +267,15 @@ struct Application: App { Settings { SettingsView() } + + // MARK: Transfers Window + Window("Transfers", id: "transfers") { + TransfersView() + .frame(minWidth: 500, minHeight: 200) + } + .defaultSize(width: 600, height: 400) + .defaultPosition(.center) + .keyboardShortcut(.init("T"), modifiers: [.shift, .command]) // MARK: Image Preview Window WindowGroup(id: "preview-image", for: PreviewFileInfo.self) { $info in @@ -286,6 +298,18 @@ struct Application: App { .defaultSize(width: 450, height: 550) .defaultPosition(.center) .restorationBehavior(.disabled) + + // MARK: QuickLook Preview Window + WindowGroup(id: "preview-quicklook", for: PreviewFileInfo.self) { $info in + FilePreviewQuickLookView(info: $info) + } + .windowManagerRole(.associated) + .windowResizability(.automatic) + .windowStyle(.titleBar) + .windowToolbarStyle(.unifiedCompact(showsTitle: true)) + .defaultSize(width: 450, height: 550) + .defaultPosition(.center) + .restorationBehavior(.disabled) } func connect(to item: TrackerSelection) { @@ -320,3 +344,4 @@ struct Application: App { } } } + diff --git a/Hotline/Models/FileDetails.swift b/Hotline/Models/FileDetails.swift index 20b1ee2..6164151 100644 --- a/Hotline/Models/FileDetails.swift +++ b/Hotline/Models/FileDetails.swift @@ -1,7 +1,7 @@ import UniformTypeIdentifiers -struct FileDetails:Identifiable { - let id = UUID() +public struct FileDetails:Identifiable { + public let id = UUID() var name: String var path: [String] var size: Int diff --git a/Hotline/Models/FileInfo.swift b/Hotline/Models/FileInfo.swift index 62ec831..e3081c4 100644 --- a/Hotline/Models/FileInfo.swift +++ b/Hotline/Models/FileInfo.swift @@ -49,11 +49,25 @@ import UniformTypeIdentifiers var children: [FileInfo]? = nil var isPreviewable: Bool { - let fileExtension = (self.name as NSString).pathExtension + let fileExtension = (self.name as NSString).pathExtension.lowercased() if let fileType = UTType(filenameExtension: fileExtension) { + if fileType.canBePreviewedByQuickLook { + return true + } + + print("FILE TYPE?", fileType, fileExtension, fileType.isSubtype(of: .pdf), fileType.isSupertype(of: .pdf)) if fileType.isSubtype(of: .image) { return true } + else if fileType.isSubtype(of: .pdf) || fileExtension == "pdf" { + return true + } + else if fileType.isSubtype(of: .audio) { + return true + } + else if fileType.isSubtype(of: .video) { + return true + } else if fileType.isSubtype(of: .text) { return true } diff --git a/Hotline/Models/FilePreview.swift b/Hotline/Models/FilePreview.swift index e5f49a3..a142823 100644 --- a/Hotline/Models/FilePreview.swift +++ b/Hotline/Models/FilePreview.swift @@ -1,115 +1,115 @@ import SwiftUI import UniformTypeIdentifiers - -enum FilePreviewState: Equatable { - case unloaded - case loading - case loaded - case failed -} - -enum FilePreviewType: Equatable { - case unknown - case image - case text -} - -@Observable -final class FilePreview: HotlineFilePreviewClientDelegate { - @ObservationIgnored let info: PreviewFileInfo - @ObservationIgnored var client: HotlineFilePreviewClient? = nil - - var state: FilePreviewState = .unloaded - var progress: Double = 0.0 - - var data: Data? = nil - - #if os(iOS) - var image: UIImage? = nil - #elseif os(macOS) - var image: NSImage? = nil - #endif - - var text: String? = nil - var styledText: NSAttributedString? = nil - - var previewType: FilePreviewType { - let fileExtension = (info.name as NSString).pathExtension - if let fileType = UTType(filenameExtension: fileExtension) { - if fileType.isSubtype(of: .image) { - return .image - } - else if fileType.isSubtype(of: .text) { - return .text - } - } - return .unknown - } - - init(info: PreviewFileInfo) { - self.info = info - - self.client = HotlineFilePreviewClient(address: info.address, port: UInt16(info.port), reference: info.id, size: UInt32(info.size)) - self.client?.delegate = self - } - - func download() { - self.client?.start() - } - - func cancel() { - self.client?.cancel() - } - - func hotlineTransferStatusChanged(client: any HotlineTransferClient, reference: UInt32, status: HotlineTransferStatus, timeRemaining: TimeInterval) { - print("FilePreview: Download status changed:", status) - - switch status { - case .unconnected: - state = .unloaded - progress = 0.0 - case .connecting: - state = .loading - progress = 0.0 - case .connected: - state = .loading - progress = 0.0 - case .progress(let p): - state = .loading - progress = p - case .failed(_): - state = .failed - progress = 0.0 - case .completing: - state = .loading - progress = 1.0 - case .completed: - state = .loaded - progress = 1.0 - } - } - - func hotlineFilePreviewComplete(client: HotlineFilePreviewClient, reference: UInt32, data: Data) { - self.state = .loaded - self.data = data - - switch self.previewType { - case .image: - #if os(iOS) - self.image = UIImage(data: data) - #elseif os(macOS) - self.image = NSImage(data: data) - #endif - case .text: - let encoding: UInt = NSString.stringEncoding(for: data, convertedString: nil, usedLossyConversion: nil) - if encoding != 0 { - self.text = String(data: data, encoding: String.Encoding(rawValue: encoding)) - } - else { - self.text = String(data: data, encoding: .utf8) - } - case .unknown: - return - } - } -} +// +//enum FilePreviewState: Equatable { +// case unloaded +// case loading +// case loaded +// case failed +//} +// +//enum FilePreviewType: Equatable { +// case unknown +// case image +// case text +//} +// +//@Observable +//final class FilePreview: HotlineFilePreviewClientDelegate { +// @ObservationIgnored let info: PreviewFileInfo +// @ObservationIgnored var client: HotlineFilePreviewClient? = nil +// +// var state: FilePreviewState = .unloaded +// var progress: Double = 0.0 +// +// var data: Data? = nil +// +// #if os(iOS) +// var image: UIImage? = nil +// #elseif os(macOS) +// var image: NSImage? = nil +// #endif +// +// var text: String? = nil +// var styledText: NSAttributedString? = nil +// +// var previewType: FilePreviewType { +// let fileExtension = (info.name as NSString).pathExtension +// if let fileType = UTType(filenameExtension: fileExtension) { +// if fileType.isSubtype(of: .image) { +// return .image +// } +// else if fileType.isSubtype(of: .text) { +// return .text +// } +// } +// return .unknown +// } +// +// init(info: PreviewFileInfo) { +// self.info = info +// +// self.client = HotlineFilePreviewClient(address: info.address, port: UInt16(info.port), reference: info.id, size: UInt32(info.size)) +// self.client?.delegate = self +// } +// +// func download() { +// self.client?.start() +// } +// +// func cancel() { +// self.client?.cancel() +// } +// +// func hotlineTransferStatusChanged(client: any HotlineTransferClient, reference: UInt32, status: HotlineTransferStatus, timeRemaining: TimeInterval) { +// print("FilePreview: Download status changed:", status) +// +// switch status { +// case .unconnected: +// state = .unloaded +// progress = 0.0 +// case .connecting: +// state = .loading +// progress = 0.0 +// case .connected: +// state = .loading +// progress = 0.0 +// case .progress(let p): +// state = .loading +// progress = p +// case .failed(_): +// state = .failed +// progress = 0.0 +// case .completing: +// state = .loading +// progress = 1.0 +// case .completed: +// state = .loaded +// progress = 1.0 +// } +// } +// +// func hotlineFilePreviewComplete(client: HotlineFilePreviewClient, reference: UInt32, data: Data) { +// self.state = .loaded +// self.data = data +// +// switch self.previewType { +// case .image: +// #if os(iOS) +// self.image = UIImage(data: data) +// #elseif os(macOS) +// self.image = NSImage(data: data) +// #endif +// case .text: +// let encoding: UInt = NSString.stringEncoding(for: data, convertedString: nil, usedLossyConversion: nil) +// if encoding != 0 { +// self.text = String(data: data, encoding: String.Encoding(rawValue: encoding)) +// } +// else { +// self.text = String(data: data, encoding: .utf8) +// } +// case .unknown: +// return +// } +// } +//} diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift index 596df68..0246fbb 100644 --- a/Hotline/Models/Hotline.swift +++ b/Hotline/Models/Hotline.swift @@ -822,7 +822,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega fileClient.delegate = self self.downloads.append(fileClient) - let transfer = TransferInfo(id: referenceNumber, title: fileName, size: UInt(transferSize)) + let transfer = TransferInfo(reference: referenceNumber, title: fileName, size: UInt(transferSize)) transfer.downloadCallback = callback self.transfers.append(transfer) @@ -856,7 +856,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega fileClient.delegate = self self.downloads.append(fileClient) - let transfer = TransferInfo(id: referenceNumber, title: fileName, size: UInt(transferSize)) + let transfer = TransferInfo(reference: referenceNumber, title: fileName, size: UInt(transferSize)) transfer.downloadCallback = callback transfer.progressCallback = progressCallback self.transfers.append(transfer) @@ -894,7 +894,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega folderClient.delegate = self self.downloads.append(folderClient) - let transfer = TransferInfo(id: referenceNumber, title: folderName, size: UInt(transferSize)) + let transfer = TransferInfo(reference: referenceNumber, title: folderName, size: UInt(transferSize)) transfer.isFolder = true transfer.downloadCallback = callback self.transfers.append(transfer) @@ -972,7 +972,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega fileClient.delegate = self self.downloads.append(fileClient) - let transfer = TransferInfo(id: referenceNumber, title: fileName, size: fileSize) + let transfer = TransferInfo(reference: referenceNumber, title: fileName, size: fileSize) transfer.uploadCallback = callback self.transfers.append(transfer) diff --git a/Hotline/Models/HotlineState.swift b/Hotline/Models/HotlineState.swift new file mode 100644 index 0000000..d5ab438 --- /dev/null +++ b/Hotline/Models/HotlineState.swift @@ -0,0 +1,2468 @@ +import SwiftUI + +// MARK: - Connection Status + +enum HotlineConnectionStatus: Equatable { + case disconnected + case connecting + case connected + case loggedIn + case failed(String) + + var isConnected: Bool { + return self == .connected || self == .loggedIn + } +} + +struct FileSearchConfig: Equatable { + /// Number of folders we process before we start applying delay backoff. + var initialBurstCount: Int = 15 + /// Base delay applied between folder requests during the backoff phase. + var initialDelay: TimeInterval = 0.02 + /// Multiplier used to increase the delay after each processed folder in backoff. + var backoffMultiplier: Double = 1.1 + /// Maximum delay cap so searches don't stall out during long walks. + var maxDelay: TimeInterval = 1.0 + /// Maximum recursion depth allowed during file search. + var maxDepth: Int = 40 + /// Limit for repeated folder loops (guards against circular server listings). + var loopRepetitionLimit: Int = 4 + /// Number of child folders that get prioritized after a matching parent is found. + var hotBurstLimit: Int = 2 + /// Maximum age, in seconds, that a cached folder listing is treated as fresh. + var cacheTTL: TimeInterval = 60 * 15 + /// Upper bound on the number of folder listings retained in the cache. + var maxCachedFolders: Int = 1024 * 3 +} + +enum FileSearchStatus: Equatable { + case idle + case searching(processed: Int, pending: Int) + case completed(processed: Int) + case cancelled(processed: Int) + case failed(String) + + var isActive: Bool { + if case .searching = self { + return true + } + return false + } +} + +// MARK: - HotlineState + +@Observable @MainActor +class HotlineState: Equatable { + let id: UUID = UUID() + + nonisolated static func == (lhs: HotlineState, rhs: HotlineState) -> Bool { + return lhs.id == rhs.id + } + + // MARK: - Static Icon Data + + #if os(macOS) + static func getClassicIcon(_ index: Int) -> NSImage? { + return NSImage(named: "Classic/\(index)") + } + #elseif os(iOS) + static func getClassicIcon(_ index: Int) -> UIImage? { + return UIImage(named: "Classic/\(index)") + } + #endif + + static let classicIconSet: [Int] = [ + 141, 149, 150, 151, 172, 184, 204, + 2013, 2036, 2037, 2055, 2400, 2505, 2534, + 2578, 2592, 4004, 4015, 4022, 4104, 4131, + 4134, 4136, 4169, 4183, 4197, 4240, 4247, + 128, 129, 130, 131, 132, 133, 134, + 135, 136, 137, 138, 139, 140, 142, + 143, 144, 145, 146, 147, 148, 152, + 153, 154, 155, 156, 157, 158, 159, + 160, 161, 162, 163, 164, 165, 166, + 167, 168, 169, 170, 171, 173, 174, + 175, 176, 177, 178, 179, 180, 181, + 182, 183, 185, 186, 187, 188, 189, + 190, 191, 192, 193, 194, 195, 196, + 197, 198, 199, 200, 201, 202, 203, + 205, 206, 207, 208, 209, 212, 214, + 215, 220, 233, 236, 237, 243, 244, + 277, 410, 414, 500, 666, 1250, 1251, + 1968, 1969, 2000, 2001, 2002, 2003, 2004, + 2006, 2007, 2008, 2009, 2010, 2011, 2012, + 2014, 2015, 2016, 2017, 2018, 2019, 2020, + 2021, 2022, 2023, 2024, 2025, 2026, 2027, + 2028, 2029, 2030, 2031, 2032, 2033, 2034, + 2035, 2038, 2040, 2041, 2042, 2043, 2044, + 2045, 2046, 2047, 2048, 2049, 2050, 2051, + 2052, 2053, 2054, 2056, 2057, 2058, 2059, + 2060, 2061, 2062, 2063, 2064, 2065, 2066, + 2067, 2070, 2071, 2072, 2073, 2075, 2079, + 2098, 2100, 2101, 2102, 2103, 2104, 2105, + 2106, 2107, 2108, 2109, 2110, 2112, 2113, + 2115, 2116, 2117, 2118, 2119, 2120, 2121, + 2122, 2123, 2124, 2125, 2126, 4150, 2223, + 2401, 2402, 2403, 2404, 2500, 2501, 2502, + 2503, 2504, 2506, 2507, 2528, 2529, 2530, + 2531, 2532, 2533, 2535, 2536, 2537, 2538, + 2539, 2540, 2541, 2542, 2543, 2544, 2545, + 2546, 2547, 2548, 2549, 2550, 2551, 2552, + 2553, 2554, 2555, 2556, 2557, 2558, 2559, + 2560, 2561, 2562, 2563, 2564, 2565, 2566, + 2567, 2568, 2569, 2570, 2571, 2572, 2573, + 2574, 2575, 2576, 2577, 2579, 2580, 2581, + 2582, 2583, 2584, 2585, 2586, 2587, 2588, + 2589, 2590, 2591, 2593, 2594, 2595, 2596, + 2597, 2598, 2599, 2600, 4000, 4001, 4002, + 4003, 4005, 4006, 4007, 4008, 4009, 4010, + 4011, 4012, 4013, 4014, 4016, 4017, 4018, + 4019, 4020, 4021, 4023, 4024, 4025, 4026, + 4027, 4028, 4029, 4030, 4031, 4032, 4033, + 4034, 4035, 4036, 4037, 4038, 4039, 4040, + 4041, 4042, 4043, 4044, 4045, 4046, 4047, + 4048, 4049, 4050, 4051, 4052, 4053, 4054, + 4055, 4056, 4057, 4058, 4059, 4060, 4061, + 4062, 4063, 4064, 4065, 4066, 4067, 4068, + 4069, 4070, 4071, 4072, 4073, 4074, 4075, + 4076, 4077, 4078, 4079, 4080, 4081, 4082, + 4083, 4084, 4085, 4086, 4087, 4088, 4089, + 4090, 4091, 4092, 4093, 4094, 4095, 4096, + 4097, 4098, 4099, 4100, 4101, 4102, 4103, + 4105, 4106, 4107, 4108, 4109, 4110, 4111, + 4112, 4113, 4114, 4115, 4116, 4117, 4118, + 4119, 4120, 4121, 4122, 4123, 4124, 4125, + 4126, 4127, 4128, 4129, 4130, 4132, 4133, + 4135, 4137, 4138, 4139, 4140, 4141, 4142, + 4143, 4144, 4145, 4146, 4147, 4148, 4149, + 4151, 4152, 4153, 4154, 4155, 4156, 4157, + 4158, 4159, 4160, 4161, 4162, 4163, 4164, + 4165, 4166, 4167, 4168, 4170, 4171, 4172, + 4173, 4174, 4175, 4176, 4177, 4178, 4179, + 4180, 4181, 4182, 4184, 4185, 4186, 4187, + 4188, 4189, 4190, 4191, 4192, 4193, 4194, + 4195, 4196, 4198, 4199, 4200, 4201, 4202, + 4203, 4204, 4205, 4206, 4207, 4208, 4209, + 4210, 4211, 4212, 4213, 4214, 4215, 4216, + 4217, 4218, 4219, 4220, 4221, 4222, 4223, + 4224, 4225, 4226, 4227, 4228, 4229, 4230, + 4231, 4232, 4233, 4234, 4235, 4236, 4238, + 4241, 4242, 4243, 4244, 4245, 4246, 4248, + 4249, 4250, 4251, 4252, 4253, 4254, 31337, + 6001, 6002, 6003, 6004, 6005, 6008, 6009, + 6010, 6011, 6012, 6013, 6014, 6015, 6016, + 6017, 6018, 6023, 6025, 6026, 6027, 6028, + 6029, 6030, 6031, 6032, 6033, 6034, 6035 + ] + + // MARK: - Observable State + + var status: HotlineConnectionStatus = .disconnected + var server: Server? { + didSet { + self.updateServerTitle() + } + } + var serverVersion: UInt16 = 123 + var serverName: String? { + didSet { + self.updateServerTitle() + } + } + var serverTitle: String = "Server" + var username: String = "guest" + var iconID: Int = 414 + var access: HotlineUserAccessOptions? + var agreed: Bool = false + + // Users + var users: [User] = [] + + // Chat + var chat: [ChatMessage] = [] + var chatInput: String = "" + var unreadPublicChat: Bool = false + + // Instant Messages + var instantMessages: [UInt16:[InstantMessage]] = [:] + var unreadInstantMessages: [UInt16:UInt16] = [:] + + // Message Board + var messageBoard: [String] = [] + var messageBoardLoaded: Bool = false + + // News + var news: [NewsInfo] = [] + var newsLoaded: Bool = false + private var newsLookup: [String:NewsInfo] = [:] + + // Files + var files: [FileInfo] = [] + var filesLoaded: Bool = false + + // Accounts + var accounts: [HotlineAccount] = [] + var accountsLoaded: Bool = false + + // Banner + #if os(macOS) + var bannerImage: Image? = nil + var bannerColors: ColorArt? = nil + #elseif os(iOS) + var bannerImage: UIImage? = nil + #endif + + // Transfers (now stored globally in AppState) + /// Returns all transfers associated with this server + var transfers: [TransferInfo] { + AppState.shared.transfers.filter { $0.serverID == self.id } + } + + // Legacy transfer tracking (for old delegate-based downloads) +// @ObservationIgnored private var downloads: [HotlineTransferClient] = [] + @ObservationIgnored private var bannerDownloadTask: Task? = nil + + // File Search + var fileSearchResults: [FileInfo] = [] + var fileSearchStatus: FileSearchStatus = .idle + var fileSearchQuery: String = "" + var fileSearchConfig = FileSearchConfig() + var fileSearchScannedFolders: Int = 0 + var fileSearchCurrentPath: [String]? = nil + @ObservationIgnored private var fileSearchSession: HotlineStateFileSearchSession? = nil + @ObservationIgnored private var fileSearchResultKeys: Set = [] + + // File List Cache + private struct FileListCacheEntry { + let files: [FileInfo] + let timestamp: Date + } + @ObservationIgnored private var fileListCache: [String: FileListCacheEntry] = [:] + + // Error Display + var errorDisplayed: Bool = false + var errorMessage: String? = nil + + // MARK: - Private State + + @ObservationIgnored private var client: HotlineClientNew? + @ObservationIgnored private var eventTask: Task? + @ObservationIgnored private var chatSessionKey: ChatStore.SessionKey? + @ObservationIgnored private var restoredChatSessionKey: ChatStore.SessionKey? + @ObservationIgnored private var chatHistoryObserver: NSObjectProtocol? + @ObservationIgnored private var lastPersistedMessageType: ChatMessageType? + + // MARK: - Initialization + + init() { + self.chatHistoryObserver = NotificationCenter.default.addObserver( + forName: ChatStore.historyClearedNotification, + object: nil, + queue: .main + ) { @MainActor [weak self] _ in + self?.handleChatHistoryCleared() + } + } + + deinit { + if let observer = self.chatHistoryObserver { + NotificationCenter.default.removeObserver(observer) + } + } + + // MARK: - Connection + + @MainActor + func login(server: Server, username: String, iconID: Int) async throws { + print("HotlineState.login(): Starting login to \(server.address):\(server.port)") + self.server = server + self.username = username + self.iconID = iconID + self.status = .connecting + print("HotlineState.login(): Status set to connecting") + + // Set up chat session + let key = self.sessionKey(for: server) + self.chatSessionKey = key + self.restoredChatSessionKey = nil + self.lastPersistedMessageType = nil + self.chat = [] + self.restoreChatHistory(for: key) + print("HotlineState.login(): Chat session set up") + + do { + // Connect and login + let loginInfo = HotlineLoginInfo( + login: server.login, + password: server.password, + username: username, + iconID: UInt16(iconID) + ) + + print("HotlineState.login(): Calling HotlineClientNew.connect()...") + let client = try await HotlineClientNew.connect( + host: server.address, + port: UInt16(server.port), + login: loginInfo + ) + print("HotlineState.login(): HotlineClientNew.connect() returned") + + self.client = client + print("HotlineState.login(): Client stored") + + // Get server info + print("HotlineState.login(): Getting server info...") + if let serverInfo = await client.server { + self.serverVersion = serverInfo.version + if !serverInfo.name.isEmpty { + self.serverName = serverInfo.name + } + print("HotlineState.login(): Server info retrieved: \(serverInfo.name) v\(serverInfo.version)") + } + + self.status = .connected + print("HotlineState.login(): Status set to connected") + + // Request initial data before starting event loop + print("HotlineState.login(): Requesting user list...") + try await self.getUserList() + + self.status = .loggedIn + print("HotlineState.login(): Status set to loggedIn") + + if Prefs.shared.playSounds && Prefs.shared.playLoggedInSound { + SoundEffectPlayer.shared.playSoundEffect(.loggedIn) + } + + print("HotlineState.login(): Connected to \(self.serverTitle)") + print("HotlineState.login(): Scheduling post-login tasks...") + + // Defer event loop and post-login work to avoid layout recursion + // This allows login() to return and SwiftUI to complete its layout pass + // before we start receiving events that trigger state changes + Task { @MainActor in + print("HotlineState: Post-login: Starting event loop...") + self.startEventLoop() + + print("HotlineState: Post-login: Sending preferences...") + try? await self.sendUserPreferences() + + print("HotlineState: Post-login: Downloading banner...") + self.downloadBanner() + } + + } catch { + print("HotlineState.login(): Login failed with error: \(error)") + if let client = self.client { + await client.disconnect() + self.client = nil + } + self.status = .failed(error.localizedDescription) + self.errorDisplayed = true + self.errorMessage = error.localizedDescription + throw error + } + } + + /// Disconnect from the server (user-initiated) + @MainActor + func disconnect() async { + print("HotlineState.disconnect(): Called") + guard let client = self.client else { + print("HotlineState.disconnect(): No client, returning") + return + } + + // Stop event loop + print("HotlineState.disconnect(): Cancelling event task...") + self.eventTask?.cancel() + self.eventTask = nil + print("HotlineState.disconnect(): Event task cancelled") + + // Explicitly close the connection + print("HotlineState.disconnect(): Calling client.disconnect()...") + await client.disconnect() + print("HotlineState.disconnect(): client.disconnect() returned") + + // Clean up state + print("HotlineState.disconnect(): Calling handleConnectionClosed()...") + self.handleConnectionClosed() + print("HotlineState.disconnect(): disconnect() complete") + } + + /// Handle connection closure (server-initiated or after user disconnect) + @MainActor + private func handleConnectionClosed() { + print("HotlineState: handleConnectionClosed() entered") + guard self.client != nil else { + print("HotlineState: handleConnectionClosed() - client already nil, returning") + return + } + + print("HotlineState: Handling connection closure - recording chat...") + + // Record disconnect in chat history + if self.status == .loggedIn { + let message = ChatMessage(text: "Disconnected", type: .signOut, date: Date()) + self.recordChatMessage(message, persist: true, display: false) + } + + print("HotlineState: Cancelling banner and downloads...") + + self.bannerDownloadTask?.cancel() + self.bannerDownloadTask = nil + + // Cancel all downloads (both old delegate-based and new async downloads) +// self.downloads = [] + + // Cancel all transfers for this server +// self.cancelAllDownloads() + + // Cancel file search + self.fileSearchSession?.cancel() + self.fileSearchSession = nil + + // Clear client reference + self.client = nil + + print("HotlineState: Resetting state properties...") + + // Reset state immediately (constraint loop was caused by something else) + self.status = .disconnected + self.serverVersion = 123 + self.serverName = nil + self.access = nil + self.agreed = false + self.users = [] + self.chat = [] + self.instantMessages = [:] + self.unreadInstantMessages = [:] + self.unreadPublicChat = false + self.messageBoard = [] + self.messageBoardLoaded = false + self.news = [] + self.newsLoaded = false + self.newsLookup = [:] + self.files = [] + self.filesLoaded = false + self.accounts = [] + self.accountsLoaded = false + self.bannerImage = nil + self.bannerColors = nil + + print("HotlineState: Resetting file search...") + self.resetFileSearchState() + + self.chatSessionKey = nil + self.restoredChatSessionKey = nil + self.lastPersistedMessageType = nil + + print("HotlineState: Disconnected") + } + + @MainActor + func downloadBanner(force: Bool = false) { + guard self.serverVersion >= 150 else { + return + } + + if force { + self.bannerDownloadTask?.cancel() + self.bannerDownloadTask = nil + self.bannerImage = nil + self.bannerColors = nil + } else if self.bannerDownloadTask != nil || self.bannerImage != nil { + return + } + + let task = Task { @MainActor [weak self] in + defer { + self?.bannerDownloadTask = nil + } + + guard let self else { return } + guard let client = self.client, + let server = self.server, + let result = try? await client.downloadBanner(), + let address = server.address as String?, + let port = server.port as Int? + else { + return + } + + do { + print("HotlineState: Banner download info - reference: \(result.referenceNumber), transferSize: \(result.transferSize)") + + let previewClient = HotlineFilePreviewClientNew( + fileName: "banner", + address: address, + port: UInt16(port), + reference: result.referenceNumber, + size: UInt32(result.transferSize) + ) + + let fileURL = try await previewClient.preview() + defer { + previewClient.cleanup() + } + + guard self.client != nil else { return } + + let data = try Data(contentsOf: fileURL) + print("HotlineState: Banner download complete, data size: \(data.count) bytes") + +#if os(macOS) + guard let image = NSImage(data: data) else { + print("HotlineState: Failed to create NSImage from banner data") + return + } + let blah = Image(nsImage: image) +#elseif os(iOS) + guard let image = UIImage(data: data) else { + print("HotlineState: Failed to create UIImage from banner data") + return + } + self.bannerImage = Image(uiImage: image) +#endif + self.bannerImage = blah + self.bannerColors = ColorArt.analyze(image: image) + + } catch { + print("HotlineState: Banner download failed: \(error)") + } + } + + self.bannerDownloadTask = task + } + + // MARK: - Event Loop + + private func startEventLoop() { + print("HotlineState.startEventLoop(): Called") + guard let client = self.client else { + print("HotlineState.startEventLoop(): No client, returning") + return + } + + print("HotlineState.startEventLoop(): Creating event loop task") + self.eventTask = Task { @MainActor [weak self, client] in + guard let self else { + print("HotlineState.startEventLoop(): Self is nil in task, exiting") + return + } + + print("HotlineState.startEventLoop(): Event loop started, awaiting events...") + for await event in client.events { + print("HotlineState.startEventLoop(): Received event: \(event)") + self.handleEvent(event) + } + + // Event stream ended - server disconnected us + print("HotlineState.startEventLoop(): Event stream ended, calling handleConnectionClosed()...") + self.handleConnectionClosed() + print("HotlineState.startEventLoop(): handleConnectionClosed() returned, event loop task complete") + } + print("HotlineState.startEventLoop(): Event loop task created") + } + + @MainActor + private func handleEvent(_ event: HotlineEvent) { + switch event { + case .chatMessage(let text): + self.handleChatMessage(text) + + case .userChanged(let user): + self.handleUserChanged(user) + + case .userDisconnected(let userID): + self.handleUserDisconnected(userID) + + case .serverMessage(let message): + self.handleServerMessage(message) + + case .privateMessage(let userID, let message): + self.handlePrivateMessage(userID: userID, message: message) + + case .newsPost(let message): + self.handleNewsPost(message) + + case .agreementRequired(let text): + let message = ChatMessage(text: text, type: .agreement, date: Date()) + self.recordChatMessage(message, persist: false) + + case .userAccess(let options): + self.access = options + print("HotlineState: Got access options") + HotlineUserAccessOptions.printAccessOptions(options) + } + } + + // MARK: - Chat + + @MainActor + func sendChat(_ text: String, announce: Bool = false) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + try await client.sendChat(text, announce: announce) + } + + @MainActor + func sendInstantMessage(_ text: String, userID: UInt16) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + let message = InstantMessage( + direction: .outgoing, + text: text.convertingLinksToMarkdown(), + type: .message, + date: Date() + ) + + if self.instantMessages[userID] == nil { + self.instantMessages[userID] = [message] + } else { + self.instantMessages[userID]!.append(message) + } + + try await client.sendInstantMessage(text, to: userID) + + if Prefs.shared.playPrivateMessageSound { + SoundEffectPlayer.shared.playSoundEffect(.chatMessage) + } + } + + @MainActor + func sendAgree() async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + try await client.sendAgree() + self.agreed = true + } + + @MainActor + /// Send current user preferences from Prefs to the server + func sendUserPreferences() async throws { + var options: HotlineUserOptions = [] + + if Prefs.shared.refusePrivateMessages { + options.update(with: .refusePrivateMessages) + } + + if Prefs.shared.refusePrivateChat { + options.update(with: .refusePrivateChat) + } + + if Prefs.shared.enableAutomaticMessage { + options.update(with: .automaticResponse) + } + + print("HotlineState.sendUserPreferences(): Updating user info with server") + + try await self.sendUserInfo( + username: Prefs.shared.username, + iconID: Prefs.shared.userIconID, + options: options, + autoresponse: Prefs.shared.automaticMessage + ) + } + + func sendUserInfo(username: String, iconID: Int, options: HotlineUserOptions = [], autoresponse: String? = nil) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + self.username = username + self.iconID = iconID + + try await client.setClientUserInfo( + username: username, + iconID: UInt16(iconID), + options: options, + autoresponse: autoresponse + ) + } + + func markPublicChatAsRead() { + self.unreadPublicChat = false + } + + func hasUnreadInstantMessages(userID: UInt16) -> Bool { + return self.unreadInstantMessages[userID] != nil + } + + func markInstantMessagesAsRead(userID: UInt16) { + self.unreadInstantMessages.removeValue(forKey: userID) + } + + @MainActor + func searchChat(query: String) -> [ChatMessage] { + guard !query.isEmpty else { + return [] + } + + // Create a map of all messages by ID to deduplicate + var messageMap: [UUID: ChatMessage] = [:] + + // Add current in-memory messages + for message in self.chat { + messageMap[message.id] = message + } + + // Filter messages based on query + let filteredMessages = messageMap.values.filter { message in + // Never include agreement messages + if message.type == .agreement { + return false + } + + // Always include disconnect messages to show session boundaries + let isDisconnect = message.type == .signOut + + // Search in text and username + let matchesText = message.text.localizedCaseInsensitiveContains(query) + let matchesUsername = message.username?.localizedCaseInsensitiveContains(query) == true + let matchesQuery = matchesText || matchesUsername + + return isDisconnect || matchesQuery + } + + // Sort by date to maintain chronological order + let sortedMessages = filteredMessages.sorted { $0.date < $1.date } + + // Remove consecutive disconnect messages to avoid visual clutter + var deduplicated: [ChatMessage] = [] + var lastWasDisconnect = false + + for message in sortedMessages { + let isDisconnect = message.type == .signOut + + if isDisconnect && lastWasDisconnect { + continue + } + + deduplicated.append(message) + lastWasDisconnect = isDisconnect + } + + // Remove leading disconnect message + if deduplicated.first?.type == .signOut { + deduplicated.removeFirst() + } + + // Remove trailing disconnect message + if deduplicated.last?.type == .signOut { + deduplicated.removeLast() + } + + return deduplicated + } + + // MARK: - Users + + @MainActor + func getUserList() async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + let hotlineUsers = try await client.getUserList() + self.users = hotlineUsers.map { User(hotlineUser: $0) } + } + + // MARK: - Files (Basic) + + @MainActor + @discardableResult + func getFileList(path: [String] = [], suppressErrors: Bool = false, preferCache: Bool = false) async throws -> [FileInfo] { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + // Check cache first if preferred + if preferCache, let cached = self.cachedFileList(for: path, ttl: self.fileSearchConfig.cacheTTL, allowStale: false) { + return cached.items + } + + let hotlineFiles = try await client.getFileList(path: path) + let newFiles = hotlineFiles.map { FileInfo(hotlineFile: $0) } + + // Update UI state + if path.isEmpty { + self.filesLoaded = true + self.files = newFiles + } else { + // Update parent's children + let parentFile = self.findFile(in: self.files, at: path) + parentFile?.children = newFiles + } + + // Cache the result + self.storeFileListInCache(newFiles, for: path) + + return newFiles + } + + @MainActor + func getFileDetails(_ fileName: String, path: [String]) async throws -> FileDetails? { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + var fullPath: [String] = [] + if path.count > 1 { + fullPath = Array(path[0.. Bool { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + var fullPath: [String] = [] + if path.count > 1 { + fullPath = Array(path[0.. Void)? = nil, complete callback: ((TransferInfo, URL) -> Void)? = nil) { +// guard let client = self.client else { return } +// +// var fullPath: [String] = [] +// if path.count > 1 { +// fullPath = Array(path[0.. Void)? = nil, complete callback: ((TransferInfo) -> Void)? = nil) { + guard let client = self.client else { return } + + var fullPath: [String] = [] + if path.count > 1 { + fullPath = Array(path[0.. Void)? = nil) { +// guard let client = self.client else { return } +// +// var fullPath: [String] = [] +// if path.count > 1 { +// fullPath = Array(path[0.. Void)? = nil, + itemProgress itemProgressCallback: ((TransferInfo, String, Int, Int) -> Void)? = nil, + complete callback: ((TransferInfo) -> Void)? = nil + ) { + guard let client = self.client else { return } + + var fullPath: [String] = [] + if path.count > 1 { + fullPath = Array(path[0.. Void)? = nil, + itemProgress itemProgressCallback: ((TransferInfo, String, Int, Int) -> Void)? = nil, + complete callback: ((TransferInfo) -> Void)? = nil + ) { + guard let client = self.client else { return } + + let folderName = folderURL.lastPathComponent + + guard folderURL.isFileURL, !folderName.isEmpty else { + print("HotlineState: Not a valid folder URL") + return + } + + let folderPath = folderURL.path(percentEncoded: false) + + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: folderPath, isDirectory: &isDirectory), + isDirectory.boolValue == true else { + print("HotlineState: URL is not a folder") + return + } + + // Get the total size of the folder (all files) + guard let (folderSize, fileCount) = FileManager.default.getFolderSize(folderURL) else { + print("HotlineState: Could not determine folder size") + return + } + + print("HotlineState: Requesting upload for folder '\(folderName)' - \(fileCount) items, \(folderSize) bytes total") + + Task { @MainActor [weak self] in + guard let self else { return } + + // Request folder upload from server. + // The enumerator already omits the root folder, so report the full item count the server should expect. + let reportedItemCount = fileCount + print("HotlineState: Reporting \(reportedItemCount) items to server (enumerated count)") + guard let referenceNumber = try? await client.uploadFolder(name: folderName, path: path, fileCount: reportedItemCount, totalSize: UInt32(folderSize)), + let server = self.server, + let address = server.address as String?, + let port = server.port as Int? + else { + print("HotlineState: Failed to get upload reference from server") + return + } + + // Invalidate cache for the upload destination + self.invalidateFileListCache(for: path, includingAncestors: true) + + print("HotlineState: Got folder upload reference: \(referenceNumber)") + + // Create upload client + guard let uploadClient = HotlineFolderUploadClientNew( + folderURL: folderURL, + address: address, + port: UInt16(port), + reference: referenceNumber + ) else { + print("HotlineState: Failed to create folder upload client") + return + } + + // Create transfer info for tracking (stored globally in AppState) + let transfer = TransferInfo( + reference: referenceNumber, + title: folderName, + size: UInt(folderSize), + serverID: self.id, + serverName: self.serverName ?? self.serverTitle + ) + transfer.isFolder = true + transfer.uploadCallback = callback + transfer.progressCallback = progressCallback + AppState.shared.addTransfer(transfer) + + // Create and store the upload task + let uploadTask = Task { @MainActor [weak self] in + guard self != nil else { return } + + do { + // Upload folder with progress tracking + try await uploadClient.upload(progress: { progress in + switch progress { + case .preparing: + break + case .unconnected, .connected, .connecting: + break + case .transfer(name: _, size: _, total: _, progress: let progress, speed: let speed, estimate: let estimate): + transfer.timeRemaining = estimate + transfer.speed = speed + transfer.progress = progress + transfer.progressCallback?(transfer) + case .error(_): + transfer.failed = true + case .completed(url: _): + transfer.completed = true + } + }, itemProgress: { itemInfo in + // Update transfer title with current file being uploaded + transfer.title = "\(itemInfo.fileName) (\(itemInfo.itemNumber)/\(itemInfo.totalItems))" + itemProgressCallback?(transfer, itemInfo.fileName, itemInfo.itemNumber, itemInfo.totalItems) + }) + + // Mark as completed + transfer.progress = 1.0 + transfer.title = folderName // Reset title to folder name + + // Call completion callback + transfer.uploadCallback?(transfer) + + print("HotlineState: Folder upload complete - \(folderName)") + + } catch is CancellationError { + // Upload was cancelled + print("HotlineState: Folder upload cancelled") + } catch { + // Mark as failed + transfer.failed = true + print("HotlineState: Folder upload failed - \(error)") + } + + AppState.shared.unregisterTransferTask(for: transfer.id) + } + + // Store the task in AppState so it can be cancelled later + AppState.shared.registerTransferTask(uploadTask, transferID: transfer.id) + } + } + + func uploadFile(url fileURL: URL, path: [String], complete callback: ((TransferInfo) -> Void)? = nil) { + guard let client = self.client else { return } + + let fileName = fileURL.lastPathComponent + + guard fileURL.isFileURL, !fileName.isEmpty else { + print("HotlineState: Not a valid file URL") + return + } + + let filePath = fileURL.path(percentEncoded: false) + + var fileIsDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: filePath, isDirectory: &fileIsDirectory), + fileIsDirectory.boolValue == false else { + print("HotlineState: File is a directory") + return + } + + // Get the flattened file size (includes all forks and headers) + guard let payloadSize = FileManager.default.getFlattenedFileSize(fileURL) else { + print("HotlineState: Could not determine file size") + return + } + + Task { @MainActor [weak self] in + guard let self else { return } + + // Request upload from server + guard let referenceNumber = try? await client.uploadFile(name: fileName, path: path), + let server = self.server, + let address = server.address as String?, + let port = server.port as Int? + else { + print("HotlineState: Failed to get upload reference from server") + return + } + + // Invalidate cache for the upload destination + self.invalidateFileListCache(for: path, includingAncestors: true) + + print("HotlineState: Got upload reference: \(referenceNumber)") + + // Create upload client + guard let uploadClient = HotlineFileUploadClientNew( + fileURL: fileURL, + address: address, + port: UInt16(port), + reference: referenceNumber + ) else { + print("HotlineState: Failed to create upload client") + return + } + + // Create transfer info for tracking (stored globally in AppState) + let transfer = TransferInfo( + reference: referenceNumber, + title: fileName, + size: UInt(payloadSize), + serverID: self.id, + serverName: self.serverName ?? self.serverTitle + ) + transfer.uploadCallback = callback + AppState.shared.addTransfer(transfer) + + // Create and store the upload task + let uploadTask = Task { @MainActor [weak self] in + guard self != nil else { return } + + do { + // Upload file with progress tracking + try await uploadClient.upload { progress in + switch progress { + case .preparing: + break + case .unconnected, .connected, .connecting: + break + case .transfer(name: _, size: _, total: _, progress: let progress, speed: let speed, estimate: let estimate): + transfer.timeRemaining = estimate + transfer.speed = speed + transfer.progress = progress + case .error(_): + transfer.failed = true + case .completed(url: _): + transfer.completed = true + } + } + + // Mark as completed + transfer.progress = 1.0 + + // Call completion callback + transfer.uploadCallback?(transfer) + + print("HotlineState: Upload complete - \(fileName)") + + } catch is CancellationError { + // Upload was cancelled + print("HotlineState: Upload cancelled") + } catch { + // Mark as failed + transfer.failed = true + print("HotlineState: Upload failed - \(error)") + } + + AppState.shared.unregisterTransferTask(for: transfer.id) + } + + // Store the transfer + AppState.shared.registerTransferTask(uploadTask, transferID: transfer.id) + } + } + + func setFileInfo(fileName: String, path filePath: [String], fileNewName: String?, comment: String?, encoding: String.Encoding = .utf8) { + // TODO: Implement setFileInfo in HotlineClientNew + // This method updates file metadata (name and/or comment) + print("setFileInfo not yet implemented in HotlineState/HotlineClientNew") + } + + @MainActor + func previewFile(_ fileName: String, path: [String], complete callback: ((PreviewFileInfo?) -> Void)? = nil) { + guard let client = self.client else { + callback?(nil) + return + } + + var fullPath: [String] = [] + if path.count > 1 { + fullPath = Array(path[0.. [HotlineAccount] { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + self.accounts = try await client.getAccounts() + self.accountsLoaded = true + return self.accounts + } + + @MainActor + func createUser(name: String, login: String, password: String?, access: UInt64) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + try await client.createUser(name: name, login: login, password: password, access: access) + + // Refresh accounts list + self.accounts = try await client.getAccounts() + } + + @MainActor + func setUser(name: String, login: String, newLogin: String?, password: String?, access: UInt64) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + try await client.setUser(name: name, login: login, newLogin: newLogin, password: password, access: access) + + // Refresh accounts list + self.accounts = try await client.getAccounts() + } + + @MainActor + func deleteUser(login: String) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + try await client.deleteUser(login: login) + + // Refresh accounts list + self.accounts = try await client.getAccounts() + } + + // MARK: - Message Board + + @MainActor + func getMessageBoard() async throws -> [String] { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + self.messageBoard = try await client.getMessageBoard() + self.messageBoardLoaded = true + return self.messageBoard + } + + @MainActor + func postToMessageBoard(text: String) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + try await client.postMessageBoard(text) + } + + // MARK: - News + + @MainActor + func getNewsList(at path: [String] = []) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + let parentNewsGroup = self.findNews(in: self.news, at: path) + + // Send a categories request for bundle paths or root (empty path) + if path.isEmpty || parentNewsGroup?.type == .bundle { + print("HotlineState: Requesting categories at: /\(path.joined(separator: "/"))") + + let categories = try await client.getNewsCategories(path: path) + + // Create info for each category returned + var newCategoryInfos: [NewsInfo] = [] + + // Transform hotline categories into NewsInfo objects + for category in categories { + var newsCategoryInfo = NewsInfo(hotlineNewsCategory: category) + + if let lookupPath = newsCategoryInfo.lookupPath { + // Merge returned category info with existing category info + if let existingCategoryInfo = self.newsLookup[lookupPath] { + print("HotlineState: Merging category into existing category at \(lookupPath)") + + existingCategoryInfo.count = newsCategoryInfo.count + existingCategoryInfo.name = newsCategoryInfo.name + existingCategoryInfo.path = newsCategoryInfo.path + existingCategoryInfo.categoryID = newsCategoryInfo.categoryID + newsCategoryInfo = existingCategoryInfo + } else { + print("HotlineState: New category added at \(lookupPath)") + self.newsLookup[lookupPath] = newsCategoryInfo + } + } + + newCategoryInfos.append(newsCategoryInfo) + } + + if let parent = parentNewsGroup { + parent.children = newCategoryInfos + } else if path.isEmpty { + self.newsLoaded = true + self.news = newCategoryInfos + } + } else { + print("HotlineState: Requesting articles at: /\(path.joined(separator: "/"))") + + let articles = try await client.getNewsArticles(path: path) + + print("HotlineState: Organizing news at \(path.joined(separator: "/"))") + + // Create info for each article returned + var newArticleInfos: [NewsInfo] = [] + + for article in articles { + var newsArticleInfo = NewsInfo(hotlineNewsArticle: article) + + if let lookupPath = newsArticleInfo.lookupPath { + // Merge returned category info with existing category info + if let existingArticleInfo = self.newsLookup[lookupPath] { + print("HotlineState: Merging article into existing article at \(lookupPath)") + + existingArticleInfo.count = newsArticleInfo.count + existingArticleInfo.name = newsArticleInfo.name + existingArticleInfo.path = newsArticleInfo.path + existingArticleInfo.articleUsername = newsArticleInfo.articleUsername + existingArticleInfo.articleDate = newsArticleInfo.articleDate + existingArticleInfo.articleFlavors = newsArticleInfo.articleFlavors + existingArticleInfo.articleID = newsArticleInfo.articleID + newsArticleInfo = existingArticleInfo + } else { + print("HotlineState: New article added at \(lookupPath)") + self.newsLookup[lookupPath] = newsArticleInfo + } + } + + newArticleInfos.append(newsArticleInfo) + } + + let organizedNewsArticles: [NewsInfo] = self.organizeNewsArticles(newArticleInfos) + if let parent = parentNewsGroup { + parent.children = organizedNewsArticles + } + } + } + + @MainActor + func getNewsArticle(id articleID: UInt, at path: [String], flavor: String = "text/plain") async throws -> String? { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + return try await client.getNewsArticle(id: UInt32(articleID), path: path, flavor: flavor) + } + + @MainActor + func postNewsArticle(title: String, body: String, at path: [String], parentID: UInt32 = 0) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + try await client.postNewsArticle(title: title, text: body, path: path, parentID: parentID) + print("HotlineState: News article posted") + } + + // MARK: - File Search + + @MainActor + func startFileSearch(query: String) { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + self.cancelFileSearch() + return + } + + self.fileSearchSession?.cancel() + self.resetFileSearchState() + self.fileSearchQuery = trimmed + self.fileSearchStatus = .searching(processed: 0, pending: 0) + self.fileSearchScannedFolders = 0 + self.fileSearchCurrentPath = [] + + let session = HotlineStateFileSearchSession(hotlineState: self, query: trimmed, config: self.fileSearchConfig) + self.fileSearchSession = session + + Task { await session.start() } + } + + @MainActor + func cancelFileSearch(clearResults: Bool = true) { + guard let session = self.fileSearchSession else { + if clearResults { + self.resetFileSearchState() + } else if !self.fileSearchResults.isEmpty { + self.fileSearchStatus = .cancelled(processed: self.fileSearchScannedFolders) + self.fileSearchCurrentPath = nil + } + return + } + + session.cancel() + self.fileSearchSession = nil + self.fileSearchCurrentPath = nil + + if clearResults { + self.resetFileSearchState() + } else { + self.fileSearchStatus = .cancelled(processed: self.fileSearchScannedFolders) + } + } + + @MainActor + func clearFileListCache() { + guard !self.fileListCache.isEmpty else { + return + } + + self.fileListCache.removeAll(keepingCapacity: false) + } + + @MainActor + fileprivate func searchSession(_ session: HotlineStateFileSearchSession, didEmit matches: [FileInfo], processed: Int, pending: Int) { + guard self.fileSearchSession === session else { + return + } + + var appended: [FileInfo] = [] + for match in matches { + let key = self.searchPathKey(for: match.path) + if self.fileSearchResultKeys.insert(key).inserted { + appended.append(match) + } + } + + if !appended.isEmpty { + self.fileSearchResults.append(contentsOf: appended) + } + + self.fileSearchScannedFolders = processed + self.fileSearchStatus = .searching(processed: processed, pending: pending) + } + + @MainActor + fileprivate func searchSession(_ session: HotlineStateFileSearchSession, didFocusOn path: [String]) { + guard self.fileSearchSession === session else { + return + } + + self.fileSearchCurrentPath = path + } + + @MainActor + fileprivate func searchSessionDidFinish(_ session: HotlineStateFileSearchSession, processed: Int, pending: Int, completed: Bool) { + guard self.fileSearchSession === session else { + return + } + + self.fileSearchScannedFolders = processed + self.fileSearchSession = nil + self.fileSearchCurrentPath = nil + + if completed { + self.fileSearchStatus = .completed(processed: processed) + } else { + self.fileSearchStatus = .cancelled(processed: processed) + } + } + + fileprivate func cachedListingForSearch(path: [String], ttl: TimeInterval) -> (items: [FileInfo], isFresh: Bool)? { + self.cachedFileList(for: path, ttl: ttl, allowStale: true) + } + + // MARK: - Event Handlers + + private func handleChatMessage(_ text: String) { + if Prefs.shared.playSounds && Prefs.shared.playChatSound { + SoundEffectPlayer.shared.playSoundEffect(.chatMessage) + } + + let chatMessage = ChatMessage(text: text, type: .message, date: Date()) + self.recordChatMessage(chatMessage) + self.unreadPublicChat = true + } + + private func handleUserChanged(_ user: HotlineUser) { + self.addOrUpdateHotlineUser(user) + } + + private func handleUserDisconnected(_ userID: UInt16) { + if let existingUserIndex = self.users.firstIndex(where: { $0.id == UInt(userID) }) { + let user = self.users.remove(at: existingUserIndex) + + if Prefs.shared.showJoinLeaveMessages { + let chatMessage = ChatMessage(text: "\(user.name) left", type: .left, date: Date()) + self.recordChatMessage(chatMessage) + } + + if Prefs.shared.playSounds && Prefs.shared.playLeaveSound { + SoundEffectPlayer.shared.playSoundEffect(.userLogout) + } + } + } + + private func handleServerMessage(_ message: String) { + if Prefs.shared.playSounds && Prefs.shared.playChatSound { + SoundEffectPlayer.shared.playSoundEffect(.serverMessage) + } + + print("HotlineState: received server message:\n\(message)") + let chatMessage = ChatMessage(text: message, type: .server, date: Date()) + self.recordChatMessage(chatMessage) + } + + private func handlePrivateMessage(userID: UInt16, message: String) { + if let existingUserIndex = self.users.firstIndex(where: { $0.id == UInt(userID) }) { + let user = self.users[existingUserIndex] + print("HotlineState: received private message from \(user.name): \(message)") + + if Prefs.shared.playPrivateMessageSound { + if self.unreadInstantMessages[userID] == nil { + SoundEffectPlayer.shared.playSoundEffect(.serverMessage) + } else { + SoundEffectPlayer.shared.playSoundEffect(.chatMessage) + } + } + + let instantMessage = InstantMessage( + direction: .incoming, + text: message.convertingLinksToMarkdown(), + type: .message, + date: Date() + ) + + if self.instantMessages[userID] == nil { + self.instantMessages[userID] = [instantMessage] + } else { + self.instantMessages[userID]!.append(instantMessage) + } + + self.unreadInstantMessages[userID] = userID + } + } + + private func handleNewsPost(_ message: String) { + let messageBoardRegex = /([\s\r\n]*[_\-]+[\s\r\n]+)/ + let matches = message.matches(of: messageBoardRegex) + + if matches.count == 1 { + let range = matches[0].range + self.messageBoard.insert(String(message[message.startIndex.. ChatStore.SessionKey { + ChatStore.SessionKey(address: server.address.lowercased(), port: server.port) + } + + private func recordChatMessage(_ message: ChatMessage, persist: Bool = true, display: Bool = true) { + let shouldPersist = persist && message.type != .agreement + if shouldPersist, + message.type == .signOut, + self.lastPersistedMessageType == .signOut { + return + } + + if display { + self.chat.append(message) + } + + guard shouldPersist, let key = self.chatSessionKey else { return } + self.lastPersistedMessageType = message.type + + let entry = ChatStore.Entry( + id: message.id, + body: message.text, + username: message.username, + type: message.type.storageKey, + date: message.date + ) + let serverName = self.serverName ?? self.server?.name + + Task { + await ChatStore.shared.append(entry: entry, for: key, serverName: serverName) + } + } + + private func restoreChatHistory(for key: ChatStore.SessionKey) { + if self.restoredChatSessionKey == key { + return + } + + Task { [weak self] in + guard let self else { return } + let result = await ChatStore.shared.loadHistory(for: key) + + await MainActor.run { + guard self.chatSessionKey == key, self.restoredChatSessionKey != key else { return } + + let currentMessages = self.chat + let historyMessages = result.entries.compactMap { entry -> ChatMessage? in + guard let chatType = ChatMessageType(storageKey: entry.type) else { return nil } + + let renderedText: String + if chatType == .message, let username = entry.username, !username.isEmpty { + renderedText = "\(username): \(entry.body)" + } else { + renderedText = entry.body + } + + var message = ChatMessage(text: renderedText, type: chatType, date: entry.date) + message.metadata = entry.metadata + return message + } + + self.chat = historyMessages + currentMessages + self.lastPersistedMessageType = historyMessages.last?.type + self.unreadPublicChat = false + self.restoredChatSessionKey = key + } + } + } + + private func handleChatHistoryCleared() { + self.chat = [] + self.unreadPublicChat = false + self.restoredChatSessionKey = nil + self.lastPersistedMessageType = nil + } + + // MARK: - Utilities + + func updateServerTitle() { + self.serverTitle = self.serverName ?? self.server?.name ?? self.server?.address ?? "Server" + } + + // News helpers + func organizeNewsArticles(_ flatArticles: [NewsInfo]) -> [NewsInfo] { + // Place articles under their parent + var organized: [NewsInfo] = [] + for article in flatArticles { + if let parentLookupPath = article.parentArticleLookupPath, + let parentArticle = self.newsLookup[parentLookupPath] { + if parentArticle.children.firstIndex(of: article) == nil { + article.expanded = true + parentArticle.children.append(article) + } + } else { + organized.append(article) + } + } + + return organized + } + + private func findNews(in newsToSearch: [NewsInfo], at path: [String]) -> NewsInfo? { + guard !path.isEmpty, !newsToSearch.isEmpty, let currentName = path.first else { return nil } + + for news in newsToSearch { + if news.name == currentName { + if path.count == 1 { + return news + } else if !news.children.isEmpty { + let remainingPath = Array(path[1...]) + return self.findNews(in: news.children, at: remainingPath) + } + } + } + + return nil + } + + // File helpers + private func findFile(in filesToSearch: [FileInfo], at path: [String]) -> FileInfo? { + guard !path.isEmpty, !filesToSearch.isEmpty else { return nil } + + let currentName = path[0] + + for file in filesToSearch { + if file.name == currentName { + if path.count == 1 { + return file + } else if let subfiles = file.children { + let remainingPath = Array(path[1...]) + return self.findFile(in: subfiles, at: remainingPath) + } + } + } + + return nil + } + + // File search helpers + private func searchPathKey(for path: [String]) -> String { + path.joined(separator: "\u{001F}") + } + + private func resetFileSearchState() { + self.fileSearchResults = [] + self.fileSearchResultKeys.removeAll(keepingCapacity: true) + self.fileSearchStatus = .idle + self.fileSearchQuery = "" + self.fileSearchScannedFolders = 0 + self.fileSearchCurrentPath = nil + } + + // File cache helpers + private func shouldBypassFileCache(for path: [String]) -> Bool { + guard let folderName = path.last else { + return false + } + + let trimmed = folderName.trimmingCharacters(in: .whitespacesAndNewlines) + + if trimmed.range(of: "upload", options: [.caseInsensitive]) != nil { + return true + } + + if trimmed.range(of: "dropbox", options: [.caseInsensitive]) != nil { + return true + } + + if trimmed.range(of: "drop box", options: [.caseInsensitive]) != nil { + return true + } + + return false + } + + private func cachedFileList(for path: [String], ttl: TimeInterval, allowStale: Bool) -> (items: [FileInfo], isFresh: Bool)? { + guard ttl > 0 else { + return nil + } + + if self.shouldBypassFileCache(for: path) { + return nil + } + + let key = self.searchPathKey(for: path) + guard let entry = self.fileListCache[key] else { + return nil + } + + let age = Date().timeIntervalSince(entry.timestamp) + let isFresh = age <= ttl + if !allowStale && !isFresh { + return nil + } + + return (entry.files, isFresh) + } + + private func storeFileListInCache(_ files: [FileInfo], for path: [String]) { + guard self.fileSearchConfig.cacheTTL > 0 else { + return + } + + if self.shouldBypassFileCache(for: path) { + return + } + + let key = self.searchPathKey(for: path) + self.fileListCache[key] = FileListCacheEntry(files: files, timestamp: Date()) + self.pruneFileListCacheIfNeeded() + } + + private func pruneFileListCacheIfNeeded() { + let limit = self.fileSearchConfig.maxCachedFolders + guard limit > 0, self.fileListCache.count > limit else { + return + } + + let excess = self.fileListCache.count - limit + guard excess > 0 else { return } + + let sortedKeys = self.fileListCache.sorted { lhs, rhs in + lhs.value.timestamp < rhs.value.timestamp + } + + for index in 0.. = [] + private var loopHistogram: [String: Int] = [:] + + private var processedCount: Int = 0 + private var currentDelay: TimeInterval + private var isCancelled = false + + init(hotlineState: HotlineState, query: String, config: FileSearchConfig) { + self.hotlineState = hotlineState + self.queryTokens = query.lowercased().split(separator: " ").map(String.init) + self.config = config + self.currentDelay = config.initialDelay + } + + func start() async { + guard let hotlineState else { + return + } + + await Task.yield() + + if !hotlineState.filesLoaded { + hotlineState.searchSession(self, didFocusOn: []) + let rootFiles = try? await hotlineState.getFileList(path: [], suppressErrors: true, preferCache: true) + self.processedCount = max(self.processedCount, 1) + self.processListing(rootFiles ?? [], depth: 0, parentPath: [], parentIsHot: false) + } else { + hotlineState.searchSession(self, didFocusOn: []) + self.processedCount = max(self.processedCount, 1) + self.processListing(hotlineState.files, depth: 0, parentPath: [], parentIsHot: false) + } + + while !self.queue.isEmpty && !self.isCancelled { + await Task.yield() + + guard let task = self.dequeueNextTask() else { + continue + } + + if self.shouldSkip(path: task.path, depth: task.depth) { + hotlineState.searchSession(self, didEmit: [], processed: self.processedCount, pending: self.queue.count) + continue + } + + hotlineState.searchSession(self, didFocusOn: task.path) + self.visited.insert(self.pathKey(for: task.path)) + + if let cached = hotlineState.cachedListingForSearch(path: task.path, ttl: self.config.cacheTTL) { + if cached.isFresh { + self.processedCount += 1 + self.processListing(cached.items, depth: task.depth, parentPath: task.path, parentIsHot: task.isHot) + continue + } else { + self.processListing(cached.items, depth: task.depth, parentPath: task.path, parentIsHot: task.isHot) + } + } + + let children = try? await hotlineState.getFileList(path: task.path, suppressErrors: true) + self.processedCount += 1 + + if self.isCancelled { + break + } + + self.processListing(children ?? [], depth: task.depth, parentPath: task.path, parentIsHot: task.isHot) + + await self.applyBackoff() + } + + hotlineState.searchSessionDidFinish(self, processed: self.processedCount, pending: self.queue.count, completed: !self.isCancelled) + } + + func cancel() { + self.isCancelled = true + } + + private func processListing(_ items: [FileInfo], depth: Int, parentPath: [String], parentIsHot: Bool) { + guard let hotlineState else { + return + } + + var matches: [FileInfo] = [] + var folderEntries: [(file: FileInfo, isHot: Bool)] = [] + var hasFileMatch = false + + for file in items { + let matchesName = self.nameMatchesQuery(file.name) + + if matchesName { + matches.append(file) + if !file.isFolder { + hasFileMatch = true + } + } + + if file.isFolder && !file.isAppBundle { + folderEntries.append((file, matchesName)) + } + } + + var remainingBurst = 0 + if self.config.hotBurstLimit > 0 && (parentIsHot || hasFileMatch) { + remainingBurst = self.config.hotBurstLimit + } + + if remainingBurst > 0 { + var candidateIndices: [Int] = [] + for index in folderEntries.indices where !folderEntries[index].isHot { + candidateIndices.append(index) + } + + if !candidateIndices.isEmpty { + candidateIndices.shuffle() + for index in candidateIndices { + folderEntries[index].isHot = true + remainingBurst -= 1 + if remainingBurst == 0 { + break + } + } + } + } + + for entry in folderEntries { + self.enqueueFolder(entry.file, depth: depth + 1, markHot: entry.isHot) + } + + hotlineState.searchSession(self, didEmit: matches, processed: self.processedCount, pending: self.queue.count) + } + + private func enqueueFolder(_ folder: FileInfo, depth: Int, markHot: Bool) { + guard !self.isCancelled else { return } + guard depth <= self.config.maxDepth else { return } + + let path = folder.path + let key = self.pathKey(for: path) + guard !self.visited.contains(key) else { return } + + if self.exceedsLoopThreshold(for: path) { + return + } + + self.queue.append(FolderTask(path: path, depth: depth, isHot: markHot)) + } + + private func dequeueNextTask() -> FolderTask? { + guard !self.queue.isEmpty else { + return nil + } + + if self.queue.count == 1 { + return self.queue.removeFirst() + } + + let currentDepth = self.queue[0].depth + var lastSameDepthIndex = 0 + var hotIndices: [Int] = [] + + for index in 0.. Bool { + if self.isCancelled { + return true + } + + if depth > self.config.maxDepth { + return true + } + + let key = self.pathKey(for: path) + if self.visited.contains(key) { + return true + } + + return false + } + + private func nameMatchesQuery(_ name: String) -> Bool { + guard !self.queryTokens.isEmpty else { return false } + let lowercased = name.lowercased() + return self.queryTokens.allSatisfy { lowercased.contains($0) } + } + + private func exceedsLoopThreshold(for path: [String]) -> Bool { + guard self.config.loopRepetitionLimit > 0 else { return false } + guard let last = path.last else { return false } + let parent = path.dropLast() + + guard let previousIndex = parent.lastIndex(of: last) else { + return false + } + + let suffix = Array(path[previousIndex...]) + let key = suffix.joined(separator: "\u{001F}") + let count = (self.loopHistogram[key] ?? 0) + 1 + self.loopHistogram[key] = count + return count > self.config.loopRepetitionLimit + } + + private func pathKey(for path: [String]) -> String { + path.joined(separator: "\u{001F}") + } + + private func applyBackoff() async { + guard !self.isCancelled else { return } + + if self.processedCount > self.config.initialBurstCount { + self.currentDelay = min(self.config.maxDelay, max(self.config.initialDelay, self.currentDelay * self.config.backoffMultiplier)) + } + + guard self.currentDelay > 0 else { + return + } + + let nanoseconds = UInt64(self.currentDelay * 1_000_000_000) + try? await Task.sleep(nanoseconds: nanoseconds) + } +} diff --git a/Hotline/Models/PreviewFileInfo.swift b/Hotline/Models/PreviewFileInfo.swift index 7dd692f..dcf5314 100644 --- a/Hotline/Models/PreviewFileInfo.swift +++ b/Hotline/Models/PreviewFileInfo.swift @@ -1,11 +1,5 @@ import UniformTypeIdentifiers -enum PreviewFileType: Equatable { - case unknown - case image - case text -} - struct PreviewFileInfo: Identifiable, Codable { var id: UInt32 var address: String diff --git a/Hotline/Models/TransferInfo.swift b/Hotline/Models/TransferInfo.swift index ba25793..cb2b0fd 100644 --- a/Hotline/Models/TransferInfo.swift +++ b/Hotline/Models/TransferInfo.swift @@ -2,28 +2,36 @@ import SwiftUI @Observable class TransferInfo: Identifiable, Equatable, Hashable { - var id: UInt32 - + var id: UUID = UUID() + + var referenceNumber: UInt32 var title: String var size: UInt var progress: Double = 0.0 - var timeRemaining: TimeInterval = 0.0 + var speed: Double? = nil + var timeRemaining: TimeInterval? = nil var completed: Bool = false var failed: Bool = false var isFolder: Bool = false + // Server association - tracks which HotlineState this transfer belongs to + var serverID: UUID + var serverName: String? + // For file based transfers (i.e. not previews) var fileURL: URL? = nil - - var progressCallback: ((TransferInfo, Double) -> Void)? = nil - var downloadCallback: ((TransferInfo, URL) -> Void)? = nil + + var progressCallback: ((TransferInfo) -> Void)? = nil + var downloadCallback: ((TransferInfo) -> Void)? = nil var uploadCallback: ((TransferInfo) -> Void)? = nil var previewCallback: ((TransferInfo, Data) -> Void)? = nil - - init(id: UInt32, title: String, size: UInt) { - self.id = id + + init(reference: UInt32, title: String, size: UInt, serverID: UUID, serverName: String? = nil) { + self.referenceNumber = reference self.title = title self.size = size + self.serverID = serverID + self.serverName = serverName } static func == (lhs: TransferInfo, rhs: TransferInfo) -> Bool { diff --git a/Hotline/State/AppState.swift b/Hotline/State/AppState.swift index 3917ad0..558af2a 100644 --- a/Hotline/State/AppState.swift +++ b/Hotline/State/AppState.swift @@ -12,8 +12,64 @@ final class AppState { } - var activeHotline: Hotline? = nil + var activeHotline: HotlineState? = nil var activeServerState: ServerState? = nil var cloudKitReady: Bool = false + + // MARK: - Transfers + + /// All active transfers across all servers + /// Transfers persist even if you disconnect from the server + var transfers: [TransferInfo] = [] + + /// Track download tasks by reference number for cancellation + @ObservationIgnored private var transferTasks: [UUID: Task] = [:] + + /// Add a transfer to the transfer list + @MainActor + func addTransfer(_ transfer: TransferInfo) { + self.transfers.append(transfer) + } + + /// Cancel a transfer by transfer ID + @MainActor + func cancelTransfer(id: UUID) { + guard let transferIndex = self.transfers.firstIndex(where: { $0.id == id }) else { + return + } + + // Cancel the task if it exists + if let task = self.transferTasks[id] { + task.cancel() + self.transferTasks.removeValue(forKey: id) + } + + // Remove from transfers list + self.transfers.remove(at: transferIndex) + } + + /// Cancel all active transfers + @MainActor + func cancelAllTransfers() { + for (_, task) in self.transferTasks { + task.cancel() + } + self.transferTasks.removeAll() + + // Clear transfers + self.transfers.removeAll() + } + + /// Register a transfer task + @MainActor + func registerTransferTask(_ task: Task, transferID: UUID) { + self.transferTasks[transferID] = task + } + + /// Unregister a download task (called on completion/failure) + @MainActor + func unregisterTransferTask(for transferID: UUID) { + self.transferTasks.removeValue(forKey: transferID) + } } diff --git a/Hotline/State/FilePreviewState.swift b/Hotline/State/FilePreviewState.swift new file mode 100644 index 0000000..97bd907 --- /dev/null +++ b/Hotline/State/FilePreviewState.swift @@ -0,0 +1,207 @@ +// +// FilePreviewState.swift +// Hotline +// +// Modern file preview state using HotlineFilePreviewClientNew +// + +import SwiftUI +import UniformTypeIdentifiers + +// MARK: - Preview Type + +enum FilePreviewType: Equatable { + case unknown + case image + case text +} + +/// State for a file preview download +@MainActor +@Observable +final class FilePreviewState { + // MARK: - Properties + + let info: PreviewFileInfo + + private var previewClient: HotlineFilePreviewClientNew? + private var previewTask: Task? + + var state: LoadState = .unloaded + var progress: Double = 0.0 + + var fileURL: URL? = nil + + #if os(iOS) + var image: UIImage? = nil + #elseif os(macOS) + var image: NSImage? = nil + #endif + + var text: String? = nil + var styledText: NSAttributedString? = nil + + // MARK: - Computed Properties + + var previewType: FilePreviewType { + info.previewType + } + + // MARK: - Initialization + + init(info: PreviewFileInfo) { + self.info = info + } + + nonisolated deinit { + // Note: Can't access @MainActor properties from deinit + // Cleanup will happen when previewClient is deallocated + } + + // MARK: - Public API + + func download() { + // Cancel any existing download + previewTask?.cancel() + previewClient?.cleanup() + + let task = Task { @MainActor in + do { + let client = HotlineFilePreviewClientNew( + fileName: info.name, + address: info.address, + port: UInt16(info.port), + reference: info.id, + size: UInt32(info.size) + ) + self.previewClient = client + + self.state = .loading + self.progress = 0.0 + + let url = try await client.preview { [weak self] progress in + guard let self else { return } + + Task { @MainActor in + switch progress { + case .preparing: + self.state = .loading + self.progress = 0.0 + + case .connecting: + self.state = .loading + self.progress = 0.0 + + case .connected: + self.state = .loading + self.progress = 0.0 + + case .transfer(name: _, size: _, total: _, progress: let p, speed: _, estimate: _): + self.state = .loading + self.progress = p + + case .completed(url: let url): + self.state = .loaded + self.progress = 1.0 + self.fileURL = url +// self.loadPreview(from: url) + + case .error(let error): + self.state = .failed + self.progress = 0.0 + print("FilePreviewState: Download failed: \(error)") + + case .unconnected: + break + } + } + } + + // Final load if not already loaded + if self.state != .loaded { + self.state = .loaded + self.progress = 1.0 + self.fileURL = url +// self.loadPreview(from: url) + } + + } catch is CancellationError { + // Cancelled, do nothing + return + } catch { + self.state = .failed + self.progress = 0.0 + print("FilePreviewState: Download error: \(error)") + } + } + + self.previewTask = task + } + + func cancel() { + previewTask?.cancel() + previewTask = nil + previewClient?.cancel() + } + + func cleanup() { + previewClient?.cleanup() + previewClient = nil + fileURL = nil + image = nil + text = nil + styledText = nil + } + + // MARK: - Private Implementation + + private func loadPreview(from url: URL) { + guard let data = try? Data(contentsOf: url) else { + self.state = .failed + print("FilePreviewState: Failed to read preview data from \(url.path)") + return + } + + switch self.previewType { + case .image: + #if os(iOS) + self.image = UIImage(data: data) + #elseif os(macOS) + self.image = NSImage(data: data) + #endif + + if self.image == nil { + self.state = .failed + print("FilePreviewState: Failed to create image from data") + } + + case .text: + let encoding: UInt = NSString.stringEncoding(for: data, convertedString: nil, usedLossyConversion: nil) + if encoding != 0 { + self.text = String(data: data, encoding: String.Encoding(rawValue: encoding)) + } else { + self.text = String(data: data, encoding: .utf8) + } + + if self.text == nil { + self.state = .failed + print("FilePreviewState: Failed to decode text data") + } + + case .unknown: + print("FilePreviewState: Unknown preview type for \(info.name)") + break + } + } +} + +// MARK: - Load State + +extension FilePreviewState { + enum LoadState: Equatable { + case unloaded + case loading + case loaded + case failed + } +} diff --git a/Hotline/State/ServerState.swift b/Hotline/State/ServerState.swift index e2fa40f..5913bf5 100644 --- a/Hotline/State/ServerState.swift +++ b/Hotline/State/ServerState.swift @@ -5,8 +5,8 @@ class ServerState: Equatable { var id: UUID = UUID() var selection: ServerNavigationType var serverName: String? = nil - var serverBanner: NSImage? = nil - var bannerColors: ColorArt? = nil +// var serverBanner: NSImage? = nil +// var bannerBackgroundColor: Color? = nil init(selection: ServerNavigationType) { self.selection = selection diff --git a/Hotline/Utility/ColorArt.swift b/Hotline/Utility/ColorArt.swift index 1f99f0f..93889a3 100644 --- a/Hotline/Utility/ColorArt.swift +++ b/Hotline/Utility/ColorArt.swift @@ -1,7 +1,11 @@ +// Swift translation and modernization +// by Dustin Mierau +// +// of: +// // ColorArt.swift // SLColorArt by Panic Inc. -// Swift translation by Dustin Mierau // // Copyright (C) 2012 Panic Inc. Code by Wade Cosgrove. All rights reserved. // @@ -32,12 +36,14 @@ import SwiftUI fileprivate let kColorThresholdMinimumPercentage: CGFloat = 0.001 +// ColorArt.analyze(image: img) -> ColorArt? + struct ColorArt: Equatable { let backgroundColor: NSColor let primaryColor: NSColor let secondaryColor: NSColor let detailColor: NSColor - let scaledImage: NSImage +// let scaledImage: NSImage static func == (lhs: ColorArt, rhs: ColorArt) -> Bool { return lhs.backgroundColor == rhs.backgroundColor && @@ -45,57 +51,80 @@ struct ColorArt: Equatable { lhs.secondaryColor == rhs.secondaryColor && lhs.detailColor == rhs.detailColor } + + static func analyze(image: NSImage) -> ColorArt? { + print("ColorArt.analyze: Starting, image size: \(image.size)") + // Scale image to a reasonable size for analysis + // This is important because: + // 1. Makes analysis faster (fewer pixels) + // 2. Normalizes weird image dimensions + // 3. Ensures CGImage conversion succeeds + print("ColorArt.analyze: Calling scaleImage...") + let finalImage = Self.scaleImage(image, size: NSSize(width: 100, height: 100)) + print("ColorArt.analyze: scaleImage returned, scaled size: \(finalImage.size)") - init?(image: NSImage, scaledSize: NSSize = .zero) { - let finalImage = Self.scaleImage(image, size: scaledSize) - self.scaledImage = finalImage - guard let colors = Self.analyzeImage(finalImage) else { + print("ColorArt.analyze: failed with no colors") return nil } - self.backgroundColor = colors.background - self.primaryColor = colors.primary - self.secondaryColor = colors.secondary - self.detailColor = colors.detail + print("ColorArt.analyze: returning colors", colors) + + return ColorArt(backgroundColor: colors.background, + primaryColor: colors.primary, + secondaryColor: colors.secondary, + detailColor: colors.detail) } // MARK: - Image Scaling private static func scaleImage(_ image: NSImage, size scaledSize: NSSize) -> NSImage { - let imageSize = image.size - let squareImage = NSImage(size: NSSize(width: imageSize.width, height: imageSize.width)) - var drawRect: NSRect - - // Make the image square - if imageSize.height > imageSize.width { - drawRect = NSRect(x: 0, y: imageSize.height - imageSize.width, width: imageSize.width, height: imageSize.width) - } else { - drawRect = NSRect(x: 0, y: 0, width: imageSize.height, height: imageSize.height) + print("ColorArt.scaleImage: Entered, input: \(image.size), target: \(scaledSize)") + // Get CGImage directly without using lockFocus + print("ColorArt.scaleImage: Getting CGImage...") + guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + print("ColorArt.scaleImage: Failed to get CGImage, returning original") + return image } - + print("ColorArt.scaleImage: Got CGImage") + + let imageSize = image.size + let squareSize = min(imageSize.width, imageSize.height) + // Use native square size if passed zero size - let finalScaledSize = scaledSize == .zero ? drawRect.size : scaledSize - let scaledImage = NSImage(size: finalScaledSize) - - squareImage.lockFocus() - image.draw(in: NSRect(x: 0, y: 0, width: imageSize.width, height: imageSize.width), from: drawRect, operation: .sourceOver, fraction: 1.0) - squareImage.unlockFocus() - - // Scale the image to the desired size - scaledImage.lockFocus() - squareImage.draw(in: NSRect(x: 0, y: 0, width: finalScaledSize.width, height: finalScaledSize.height), from: .zero, operation: .sourceOver, fraction: 1.0) - scaledImage.unlockFocus() - - // Convert back to readable bitmap data - guard let cgImage = scaledImage.cgImage(forProposedRect: nil, context: nil, hints: nil) else { - return scaledImage + let finalScaledSize = scaledSize == .zero ? NSSize(width: squareSize, height: squareSize) : scaledSize + + // Create bitmap context for drawing + let width = Int(finalScaledSize.width) + let height = Int(finalScaledSize.height) + let colorSpace = CGColorSpaceCreateDeviceRGB() + let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue) + + guard let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width * 4, + space: colorSpace, + bitmapInfo: bitmapInfo.rawValue + ) else { + return image } - - let bitmapRep = NSBitmapImageRep(cgImage: cgImage) - let finalImage = NSImage(size: scaledImage.size) + + // Draw the image scaled + context.interpolationQuality = .high + context.draw(cgImage, in: CGRect(x: 0, y: 0, width: finalScaledSize.width, height: finalScaledSize.height)) + + // Create NSImage from context + guard let scaledCGImage = context.makeImage() else { + return image + } + + let bitmapRep = NSBitmapImageRep(cgImage: scaledCGImage) + let finalImage = NSImage(size: finalScaledSize) finalImage.addRepresentation(bitmapRep) - + return finalImage } @@ -120,33 +149,45 @@ struct ColorArt: Equatable { if primaryColor == nil { primaryColor = darkBackground ? .white : .black } - + if secondaryColor == nil { secondaryColor = darkBackground ? .white : .black } - + if detailColor == nil { detailColor = darkBackground ? .white : .black } - - return (backgroundColor, primaryColor!, secondaryColor!, detailColor!) + + // Convert all colors to calibrated RGB color space for consistency + // This ensures all colors are in the same color space and prevents + // any color space conversion issues when used in SwiftUI + let rgbColorSpace = NSColorSpace.genericRGB + let finalBackground = backgroundColor.usingColorSpace(rgbColorSpace) ?? backgroundColor + let finalPrimary = primaryColor!.usingColorSpace(rgbColorSpace) ?? primaryColor! + let finalSecondary = secondaryColor!.usingColorSpace(rgbColorSpace) ?? secondaryColor! + let finalDetail = detailColor!.usingColorSpace(rgbColorSpace) ?? detailColor! + + return (finalBackground, finalPrimary, finalSecondary, finalDetail) } // MARK: - Edge Color Detection private static func findEdgeColor(_ image: NSImage, imageColors: inout NSCountedSet?) -> NSColor? { - guard var imageRep = image.representations.last else { - return nil - } - - if !(imageRep is NSBitmapImageRep) { - image.lockFocus() - imageRep = NSBitmapImageRep(focusedViewRect: NSRect(x: 0, y: 0, width: image.size.width, height: image.size.height))! - image.unlockFocus() + var bitmapRep: NSBitmapImageRep? + + // Try to get existing bitmap representation + if let existingRep = image.representations.last as? NSBitmapImageRep { + bitmapRep = existingRep + } else { + // Create bitmap rep from CGImage instead of using lockFocus + guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + return nil + } + bitmapRep = NSBitmapImageRep(cgImage: cgImage) } - + // Convert to RGB color space - guard let bitmapRep = (imageRep as? NSBitmapImageRep)?.converting(to: .genericRGB, renderingIntent: .default) else { + guard let bitmapRep = bitmapRep?.converting(to: .genericRGB, renderingIntent: .default) else { return nil } diff --git a/Hotline/Utility/SwiftUIExtensions.swift b/Hotline/Utility/SwiftUIExtensions.swift index 12217b2..d0dfff4 100644 --- a/Hotline/Utility/SwiftUIExtensions.swift +++ b/Hotline/Utility/SwiftUIExtensions.swift @@ -6,28 +6,3 @@ extension Color { self.init(red: Double((hex >> 16) & 0xFF) / 255.0, green: Double((hex >> 8) & 0xFF) / 255.0, blue: Double(hex & 0xFF) / 255.0, opacity: opacity) } } - -extension AttributedString { - func setHangingIndent(firstLineHeadIndent: CGFloat = 0, otherLinesHeadIndent: CGFloat) -> AttributedString { -// var blah = self - -// guard var paragraph = self.paragraphStyle else { -// return -// } - - var p = self.paragraphStyle?.mutableCopy() as? NSMutableParagraphStyle - p?.headIndent = otherLinesHeadIndent - p?.firstLineHeadIndent = firstLineHeadIndent - -// paragraph.headIndent = otherLinesHeadIndent // indent for lines 2+ -// paragraph.firstLineHeadIndent = firstLineHeadIndent // usually 0 - - var blah = self - - - blah.paragraphStyle = p - - return blah - } -} - diff --git a/Hotline/iOS/ChatView.swift b/Hotline/iOS/ChatView.swift index e320dbf..d4d9d03 100644 --- a/Hotline/iOS/ChatView.swift +++ b/Hotline/iOS/ChatView.swift @@ -27,7 +27,7 @@ struct ChatView: View { VStack(alignment: .center) { if let bannerImage = self.model.bannerImage { - Image(uiImage: bannerImage) + bannerImage .resizable() .scaledToFit() .frame(maxWidth: 468.0) diff --git a/Hotline/iOS/ServerView.swift b/Hotline/iOS/ServerView.swift index 1ce0a9c..8ae0fa7 100644 --- a/Hotline/iOS/ServerView.swift +++ b/Hotline/iOS/ServerView.swift @@ -1,7 +1,7 @@ import SwiftUI struct ServerView: View { - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState @Environment(\.colorScheme) var colorScheme enum Tab { diff --git a/Hotline/macOS/Accounts/AccountManagerView.swift b/Hotline/macOS/Accounts/AccountManagerView.swift index 57682cc..c45ba18 100644 --- a/Hotline/macOS/Accounts/AccountManagerView.swift +++ b/Hotline/macOS/Accounts/AccountManagerView.swift @@ -1,7 +1,7 @@ import SwiftUI struct AccountManagerView: View { - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState @State private var accounts: [HotlineAccount] = [] @State private var selection: HotlineAccount? @@ -43,7 +43,7 @@ struct AccountManagerView: View { .alternatingRowBackgrounds(.enabled) .task { if loading { - accounts = await model.getAccounts() + accounts = (try? await model.getAccounts()) ?? [] loading = false } } @@ -243,47 +243,49 @@ struct AccountManagerView: View { // .padding() Spacer() - - Button("Save"){ + + Button(action: { guard let selection else { return } - + // Update existing account if selection.persisted == true { if pendingPassword == placeholderPassword { Task { @MainActor in - model.client.sendSetUser(name: pendingName, login: pendingLogin, newLogin: nil, password: nil, access: pendingAccess.rawValue) + try? await model.setUser(name: pendingName, login: pendingLogin, newLogin: nil, password: nil, access: pendingAccess.rawValue) } } else { Task { @MainActor in - model.client.sendSetUser(name: pendingName, login: pendingLogin, newLogin: nil, password: pendingPassword, access: pendingAccess.rawValue) + try? await model.setUser(name: pendingName, login: pendingLogin, newLogin: nil, password: pendingPassword, access: pendingAccess.rawValue) } } } else { // Create new existing account Task { @MainActor in - model.client.sendCreateUser(name: pendingName, login: pendingLogin, password: pendingPassword, access: pendingAccess.rawValue) + try? await model.createUser(name: pendingName, login: pendingLogin, password: pendingPassword, access: pendingAccess.rawValue) } self.selection?.password = pendingPassword pendingPassword = placeholderPassword } - + var account = HotlineAccount(pendingName, pendingLogin, pendingAccess) account.persisted = true account.password = placeholderPassword - + accounts = accounts.filter { $0.persisted == true && $0.login != selection.login } - + // Add new account to list accounts.append(account) - + // Re-sort accounts accounts.sort { $0.login < $1.login } self.selection = account - } + }, label: { + Text("Save") + }) .controlSize(.large) .frame(minWidth: 75) .keyboardShortcut(.defaultAction) @@ -349,23 +351,25 @@ struct AccountManagerView: View { } ToolbarItem(placement: .primaryAction) { - Button("Delete") { + Button(action: { guard let userToDelete = toDelete else { return } - + self.toDelete = nil self.selection = nil - + if userToDelete.persisted { Task { @MainActor in - model.client.sendDeleteUser(login: userToDelete.login) + try? await model.deleteUser(login: userToDelete.login) } } - + accounts = accounts.filter { $0.login != userToDelete.login } - - } + + }, label: { + Text("Delete") + }) } } } diff --git a/Hotline/macOS/Board/MessageBoardEditorView.swift b/Hotline/macOS/Board/MessageBoardEditorView.swift index 474384e..ea1754c 100644 --- a/Hotline/macOS/Board/MessageBoardEditorView.swift +++ b/Hotline/macOS/Board/MessageBoardEditorView.swift @@ -8,7 +8,7 @@ struct MessageBoardEditorView: View { @Environment(\.controlActiveState) private var controlActiveState @Environment(\.colorScheme) private var colorScheme @Environment(\.dismiss) private var dismiss - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState @State private var text: String = "" @State private var sending: Bool = false @@ -17,11 +17,11 @@ struct MessageBoardEditorView: View { func sendPost() async { sending = true - + let cleanedText = text.trimmingCharacters(in: .whitespacesAndNewlines) - - model.postToMessageBoard(text: cleanedText) - let _ = await model.getMessageBoard() + + try? await model.postToMessageBoard(text: cleanedText) + let _ = try? await model.getMessageBoard() // let success = await model.postNewsArticle(title: title, body: text, at: path, parentID: parentID) // if success { @@ -69,9 +69,9 @@ struct MessageBoardEditorView: View { else { Button { sending = true - model.postToMessageBoard(text: text) Task { - let _ = await model.getMessageBoard() + try? await model.postToMessageBoard(text: text) + let _ = try? await model.getMessageBoard() Task { @MainActor in sending = false dismiss() diff --git a/Hotline/macOS/Board/MessageBoardView.swift b/Hotline/macOS/Board/MessageBoardView.swift index f870b0c..8788d66 100644 --- a/Hotline/macOS/Board/MessageBoardView.swift +++ b/Hotline/macOS/Board/MessageBoardView.swift @@ -1,7 +1,7 @@ import SwiftUI struct MessageBoardView: View { - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState @State private var composerDisplayed: Bool = false @State private var composerText: String = "" @@ -25,7 +25,7 @@ struct MessageBoardView: View { } .task { if !model.messageBoardLoaded { - let _ = await model.getMessageBoard() + let _ = try? await model.getMessageBoard() } } .overlay { @@ -98,5 +98,5 @@ struct MessageBoardView: View { #Preview { MessageBoardView() - .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) + .environment(HotlineState()) } diff --git a/Hotline/macOS/Chat/ChatView.swift b/Hotline/macOS/Chat/ChatView.swift index 0795856..e1ec73a 100644 --- a/Hotline/macOS/Chat/ChatView.swift +++ b/Hotline/macOS/Chat/ChatView.swift @@ -88,7 +88,7 @@ struct ChatMessageView: View { } struct ChatView: View { - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState @Environment(\.colorScheme) var colorScheme @Environment(\.dismiss) var dismiss @@ -98,12 +98,14 @@ struct ChatView: View { @State private var searchQuery: String = "" @State private var searchResults: [ChatMessage] = [] @State private var isSearching: Bool = false + + @State private var stableBannerImage: Image? @FocusState private var focusedField: FocusedField? @Namespace var bottomID - private var bindableModel: Bindable { + private var bindableModel: Bindable { Bindable(model) } @@ -114,7 +116,36 @@ struct ChatView: View { var displayedMessages: [ChatMessage] { searchQuery.isEmpty ? model.chat : searchResults } - + +// private var blurredBannerImage: some View { +// self.stableBannerImage? +// .resizable() +// .scaledToFit() +// .frame(maxWidth: 468.0) +// .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) +// .offset(y: 1.5) +// .blur(radius: 4) +// .opacity(0.2) +// } + + private var bannerView: some View { + ZStack { + self.stableBannerImage? + .resizable() + .scaledToFit() + .frame(maxWidth: 468.0) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .offset(y: 1.5) + .blur(radius: 4) + .opacity(0.2) + self.stableBannerImage? + .resizable() + .scaledToFit() + .frame(maxWidth: 468.0) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + } + var body: some View { @Bindable var bindModel = model @@ -129,28 +160,10 @@ struct ChatView: View { ForEach(displayedMessages) { msg in if msg.type == .agreement { VStack(alignment: .center, spacing: 16) { - if let bannerImage = self.model.bannerImage { - HStack(spacing: 0) { - Spacer(minLength: 0) - ZStack { - Image(nsImage: bannerImage) - .resizable() - .scaledToFit() - .frame(maxWidth: 468.0) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - .offset(y: 1.5) - .blur(radius: 4) - .opacity(0.2) - - Image(nsImage: bannerImage) - .resizable() - .scaledToFit() - .frame(maxWidth: 468.0) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - } - - Spacer(minLength: 0) - } + HStack(spacing: 0) { + Spacer(minLength: 0) + self.bannerView + Spacer(minLength: 0) } ServerAgreementView(text: msg.text) @@ -218,7 +231,11 @@ struct ChatView: View { .multilineTextAlignment(.leading) .onSubmit { if !model.chatInput.isEmpty { - model.sendChat(model.chatInput, announce: NSEvent.modifierFlags.contains(.shift)) + let message = model.chatInput + let announce = NSEvent.modifierFlags.contains(.shift) + Task { + try? await model.sendChat(message, announce: announce) + } } model.chatInput = "" } @@ -252,6 +269,12 @@ struct ChatView: View { .onChange(of: searchQuery) { performSearch() } + .onChange(of: model.bannerImage) { oldValue, newValue in + stableBannerImage = newValue + } + .onAppear { + stableBannerImage = model.bannerImage + } // .toolbar { // ToolbarItem(placement: .primaryAction) { // Button { @@ -318,5 +341,5 @@ struct ChatView: View { #Preview { ChatView() - .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) + .environment(HotlineState()) } diff --git a/Hotline/macOS/Files/FileDetailsView.swift b/Hotline/macOS/Files/FileDetailsView.swift index 812f4e5..d001df2 100644 --- a/Hotline/macOS/Files/FileDetailsView.swift +++ b/Hotline/macOS/Files/FileDetailsView.swift @@ -2,7 +2,7 @@ import Foundation import SwiftUI struct FileDetailsView: View { - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState @Environment(\.presentationMode) var presentationMode var fd: FileDetails @@ -73,8 +73,8 @@ struct FileDetailsView: View { if comment != fd.comment { editedComment = comment } - - model.client.sendSetFileInfo(fileName: fd.name, path: fd.path, fileNewName: editedFilename, comment: editedComment) + + model.setFileInfo(fileName: fd.name, path: fd.path, fileNewName: editedFilename, comment: editedComment) presentationMode.wrappedValue.dismiss() // TODO: Update the file list if the filename was changed diff --git a/Hotline/macOS/Files/FileItemView.swift b/Hotline/macOS/Files/FileItemView.swift index 5da744f..31a8af7 100644 --- a/Hotline/macOS/Files/FileItemView.swift +++ b/Hotline/macOS/Files/FileItemView.swift @@ -1,7 +1,7 @@ import SwiftUI struct FileItemView: View { - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState var file: FileInfo let depth: Int diff --git a/Hotline/macOS/Files/FilePreviewImageView.swift b/Hotline/macOS/Files/FilePreviewImageView.swift index 9beeb80..c5899bd 100644 --- a/Hotline/macOS/Files/FilePreviewImageView.swift +++ b/Hotline/macOS/Files/FilePreviewImageView.swift @@ -12,7 +12,7 @@ struct FilePreviewImageView: View { @Binding var info: PreviewFileInfo? - @State var preview: FilePreview? = nil + @State var preview: FilePreviewState? = nil @FocusState private var focusField: FilePreviewFocus? var body: some View { @@ -78,13 +78,16 @@ struct FilePreviewImageView: View { if let info = info { ToolbarItem(placement: .primaryAction) { Button { - let _ = preview?.data?.saveAsFileToDownloads(filename: info.name) + if let fileURL = preview?.fileURL, + let data = try? Data(contentsOf: fileURL) { + let _ = data.saveAsFileToDownloads(filename: info.name) + } } label: { Label("Download Image...", systemImage: "arrow.down") } .help("Download Image") } - + ToolbarItem(placement: .primaryAction) { ShareLink(item: img, preview: SharePreview(info.name, image: img)) { Label("Share Image...", systemImage: "square.and.arrow.up") @@ -96,7 +99,7 @@ struct FilePreviewImageView: View { } .task { if let info = info { - preview = FilePreview(info: info) + preview = FilePreviewState(info: info) preview?.download() } } diff --git a/Hotline/macOS/Files/FilePreviewQuickLookView.swift b/Hotline/macOS/Files/FilePreviewQuickLookView.swift new file mode 100644 index 0000000..0323fdd --- /dev/null +++ b/Hotline/macOS/Files/FilePreviewQuickLookView.swift @@ -0,0 +1,131 @@ +// +// FilePreviewQuickLookView.swift +// Hotline +// +// QuickLook-based file preview window for all supported file types +// + +import SwiftUI +import UniformTypeIdentifiers + +struct FilePreviewQuickLookView: View { + enum FilePreviewFocus: Hashable { + case window + } + + @Environment(\.controlActiveState) private var controlActiveState + @Environment(\.colorScheme) private var colorScheme + @Environment(\.dismiss) var dismiss + + @Binding var info: PreviewFileInfo? + @State var preview: FilePreviewState? = nil + @FocusState private var focusField: FilePreviewFocus? + + var body: some View { + Group { + if preview?.state != .loaded { + VStack(alignment: .center, spacing: 0) { + Spacer() + ProgressView(value: max(0.0, min(1.0, preview?.progress ?? 0.0))) + .focusable(false) + .progressViewStyle(.circular) + .controlSize(.extraLarge) + .frame(maxWidth: 300, alignment: .center) + .padding(.bottom, 48) + Spacer() + } + .background(Color(nsColor: .textBackgroundColor)) + .frame(minWidth: 350, maxWidth: .infinity, minHeight: 150, maxHeight: .infinity) + .padding() + } + else { + if let fileURL = preview?.fileURL { + QuickLookPreviewView(fileURL: fileURL) + .frame(minWidth: 400, maxWidth: .infinity, minHeight: 400, maxHeight: .infinity) + } + else { + VStack(alignment: .center, spacing: 0) { + Spacer() + + Image(systemName: "eye.trianglebadge.exclamationmark") + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + .frame(height: 48) + .padding(.bottom) + Group { + Text("This file type is not previewable") + .bold() + Text("Try downloading and opening this file in another application.") + .foregroundStyle(Color.secondary) + } + .font(.system(size: 14.0)) + .frame(maxWidth: 300) + .multilineTextAlignment(.center) + + Spacer() + Spacer() + } + .frame(minWidth: 350, maxWidth: .infinity, minHeight: 150, maxHeight: .infinity) + .padding() + } + } + } + .focusable() + .focusEffectDisabled() + .background(Color(nsColor: .textBackgroundColor)) + .focused($focusField, equals: .window) + .navigationTitle(info?.name ?? "File Preview") + .background( + WindowConfigurator { window in + if let fileURL = preview?.fileURL { + window.representedURL = fileURL + window.standardWindowButton(.documentIconButton)?.isHidden = false + } + } + ) + .toolbar { + if let _ = preview?.fileURL { + if let info = info { + ToolbarItem(placement: .primaryAction) { + Button { + if let fileURL = preview?.fileURL, + let data = try? Data(contentsOf: fileURL) { + let _ = data.saveAsFileToDownloads(filename: info.name) + } + } label: { + Label("Download File...", systemImage: "arrow.down") + } + .help("Download File") + } + } + } + } + .task { + if let info = info { + preview = FilePreviewState(info: info) + preview?.download() + } + } + .onAppear { + if info == nil { + Task { + dismiss() + } + return + } + + focusField = .window + } + .onDisappear { + preview?.cancel() + dismiss() + } + .onChange(of: preview?.state) { + if preview?.state == .failed { + dismiss() + } + } + .preferredColorScheme(.dark) + } +} diff --git a/Hotline/macOS/Files/FilePreviewTextView.swift b/Hotline/macOS/Files/FilePreviewTextView.swift index c286381..4e3a719 100644 --- a/Hotline/macOS/Files/FilePreviewTextView.swift +++ b/Hotline/macOS/Files/FilePreviewTextView.swift @@ -11,7 +11,7 @@ struct FilePreviewTextView: View { @Environment(\.dismiss) var dismiss @Binding var info: PreviewFileInfo? - @State var preview: FilePreview? = nil + @State var preview: FilePreviewState? = nil @FocusState private var focusField: FilePreviewFocus? var body: some View { @@ -89,7 +89,10 @@ struct FilePreviewTextView: View { if let info = info { ToolbarItem(placement: .primaryAction) { Button { - let _ = preview?.data?.saveAsFileToDownloads(filename: info.name) + if let fileURL = preview?.fileURL, + let data = try? Data(contentsOf: fileURL) { + let _ = data.saveAsFileToDownloads(filename: info.name) + } } label: { Label("Save Text File...", systemImage: "square.and.arrow.down") } @@ -100,7 +103,7 @@ struct FilePreviewTextView: View { } .task { if let info = info { - preview = FilePreview(info: info) + preview = FilePreviewState(info: info) preview?.download() } } diff --git a/Hotline/macOS/Files/FilesView.swift b/Hotline/macOS/Files/FilesView.swift index de699ff..b499fe6 100644 --- a/Hotline/macOS/Files/FilesView.swift +++ b/Hotline/macOS/Files/FilesView.swift @@ -7,7 +7,7 @@ import AppKit struct FilesView: View { - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState @Environment(\.openWindow) private var openWindow @State private var selection: FileInfo? @@ -15,6 +15,7 @@ struct FilesView: View { @State private var uploadFileSelectorDisplayed: Bool = false @State private var searchText: String = "" @State private var isSearching: Bool = false + @State private var dragOver: Bool = false private var isShowingSearchResults: Bool { switch model.fileSearchStatus { @@ -68,17 +69,18 @@ struct FilesView: View { private func openPreviewWindow(_ previewInfo: PreviewFileInfo) { switch previewInfo.previewType { case .image: - openWindow(id: "preview-image", value: previewInfo) + openWindow(id: "preview-quicklook", value: previewInfo) case .text: - openWindow(id: "preview-text", value: previewInfo) - default: + openWindow(id: "preview-quicklook", value: previewInfo) + case .unknown: + openWindow(id: "preview-quicklook", value: previewInfo) return } } @MainActor private func getFileInfo(_ file: FileInfo) { Task { - if let fileInfo = await model.getFileDetails(file.name, path: file.path) { + if let fileInfo = try? await model.getFileDetails(file.name, path: file.path) { Task { @MainActor in self.fileDetails = fileInfo } @@ -88,10 +90,10 @@ struct FilesView: View { @MainActor private func downloadFile(_ file: FileInfo) { if file.isFolder { - model.downloadFolder(file.name, path: file.path) + model.downloadFolderNew(file.name, path: file.path) } else { - model.downloadFile(file.name, path: file.path) + model.downloadFileNew(file.name, path: file.path) } } @@ -99,7 +101,31 @@ struct FilesView: View { model.uploadFile(url: fileURL, path: path) { info in Task { // Refresh file listing to display newly uploaded file. - let _ = await model.getFileList(path: path) + let _ = try? await model.getFileList(path: path) + } + } + } + + @MainActor private func upload(file fileURL: URL, to path: [String]) { + var fileIsDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false), isDirectory: &fileIsDirectory) else { + return + } + + if fileIsDirectory.boolValue { + self.model.uploadFolder(url: fileURL, path: path, complete: { info in + Task { + // Refresh file listing to display newly uploaded file. + try? await model.getFileList(path: path) + } + }) + } + else { + self.model.uploadFile(url: fileURL, path: path) { info in + Task { + // Refresh file listing to display newly uploaded file. + try? await model.getFileList(path: path) + } } } } @@ -121,15 +147,15 @@ struct FilesView: View { if file.path.count > 1 { parentPath = Array(file.path[0.. 0 else { + guard fileURLS.count > 0, + let fileURL = fileURLS.first + else { return } - let fileURL = fileURLS.first! - - print(fileURL) - var uploadPath: [String] = [] if let selection = selection { @@ -308,7 +356,8 @@ struct FilesView: View { } print("UPLOAD PATH: \(uploadPath)") - uploadFile(file: fileURL, to: uploadPath) + self.upload(file: fileURL, to: uploadPath) +// uploadFile(file: fileURL, to: uploadPath) case .failure(let error): print(error) @@ -414,5 +463,5 @@ struct FilesView: View { #Preview { FilesView() - .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) + .environment(HotlineState()) } diff --git a/Hotline/macOS/Files/FolderItemView.swift b/Hotline/macOS/Files/FolderItemView.swift index 4a08974..2b1b695 100644 --- a/Hotline/macOS/Files/FolderItemView.swift +++ b/Hotline/macOS/Files/FolderItemView.swift @@ -1,7 +1,7 @@ import SwiftUI struct FolderItemView: View { - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState @State var loading = false @State var dragOver = false @@ -20,7 +20,7 @@ struct FolderItemView: View { model.uploadFile(url: fileURL, path: filePath) { info in Task { // Refresh file listing to display newly uploaded file. - let _ = await model.getFileList(path: filePath) + let _ = try? await model.getFileList(path: filePath) } } } @@ -103,7 +103,7 @@ struct FolderItemView: View { if file.expanded && file.fileSize > 0 { Task { loading = true - let _ = await model.getFileList(path: file.path) + let _ = try? await model.getFileList(path: file.path) loading = false } } diff --git a/Hotline/macOS/HotlinePanelView.swift b/Hotline/macOS/HotlinePanelView.swift index ee4d198..7819c2f 100644 --- a/Hotline/macOS/HotlinePanelView.swift +++ b/Hotline/macOS/HotlinePanelView.swift @@ -3,10 +3,27 @@ import SwiftUI struct HotlinePanelView: View { @Environment(\.openWindow) var openWindow @Environment(\.colorScheme) var colorScheme + @Environment(\.appState) private var appState + + private var activeServerState: ServerState? { + self.appState.activeServerState + } + + private var activeHotline: HotlineState? { + self.appState.activeHotline + } + + private var bannerImage: Image { + self.activeHotline?.bannerImage ?? Image("Default Banner") + } + + private var backgroundColor: Color { + Color(nsColor: self.activeHotline?.bannerColors?.backgroundColor ?? NSColor.controlBackgroundColor) + } var body: some View { VStack(spacing: 0) { - Image(nsImage: AppState.shared.activeServerState?.serverBanner ?? NSImage(named: "Default Banner")!) + self.bannerImage .interpolation(.high) .resizable() .scaledToFill() @@ -14,8 +31,7 @@ struct HotlinePanelView: View { .frame(minWidth: 468, maxWidth: 468, minHeight: 60, maxHeight: 60) .clipped() .background(.black) -// .clipShape(RoundedRectangle(cornerRadius: 6.0)) -// .padding([.top, .leading, .trailing], 4) + .animation(.default, value: self.bannerImage) HStack(spacing: 12) { Button { @@ -36,7 +52,7 @@ struct HotlinePanelView: View { .help("Hotline Servers") Button { - AppState.shared.activeServerState?.selection = .chat + self.activeServerState?.selection = .chat } label: { Image("Section Chat") @@ -45,11 +61,11 @@ struct HotlinePanelView: View { } .buttonStyle(.plain) .frame(width: 20, height: 20) - .disabled(AppState.shared.activeServerState == nil) + .disabled(self.activeServerState == nil) .help("Public Chat") Button { - AppState.shared.activeServerState?.selection = .board + self.activeServerState?.selection = .board } label: { Image("Section Board") @@ -58,11 +74,11 @@ struct HotlinePanelView: View { } .buttonStyle(.plain) .frame(width: 20, height: 20) - .disabled(AppState.shared.activeServerState == nil) + .disabled(self.activeServerState == nil) .help("Message Board") Button { - AppState.shared.activeServerState?.selection = .news + self.activeServerState?.selection = .news } label: { Image("Section News") @@ -71,11 +87,11 @@ struct HotlinePanelView: View { } .buttonStyle(.plain) .frame(width: 20, height: 20) - .disabled(AppState.shared.activeServerState == nil || (AppState.shared.activeHotline?.serverVersion ?? 0) < 151) + .disabled(self.activeServerState == nil || (self.activeHotline?.serverVersion ?? 0) < 151) .help("News") Button { - AppState.shared.activeServerState?.selection = .files + self.activeServerState?.selection = .files } label: { Image("Section Files") @@ -84,14 +100,14 @@ struct HotlinePanelView: View { } .buttonStyle(.plain) .frame(width: 20, height: 20) - .disabled(AppState.shared.activeServerState == nil) + .disabled(self.activeServerState == nil) .help("Files") Spacer() - if AppState.shared.activeHotline?.access?.contains(.canOpenUsers) == true { + if self.activeHotline?.access?.contains(.canOpenUsers) == true { Button { - AppState.shared.activeServerState?.selection = .accounts + self.activeServerState?.selection = .accounts } label: { Image("Section Users") @@ -100,7 +116,7 @@ struct HotlinePanelView: View { } .buttonStyle(.plain) .frame(width: 20, height: 20) - .disabled(AppState.shared.activeServerState == nil) + .disabled(self.activeServerState == nil) .help("Accounts") } @@ -116,9 +132,9 @@ struct HotlinePanelView: View { .padding(.top, 12) .padding(.bottom, 12) .padding([.leading, .trailing], 12) - .background(AppState.shared.activeServerState?.bannerColors.map { Color(nsColor: $0.backgroundColor) } ?? Color(nsColor: .controlBackgroundColor)) - .foregroundStyle(AppState.shared.activeServerState?.bannerColors.map { Color(nsColor: $0.primaryColor) } ?? Color.primary) -// .background(Color.red.opacity(0.5).blendMode(.multiply)) + .background(self.backgroundColor) + .foregroundStyle(.primary) + .animation(.default, value: self.backgroundColor) // GroupBox { // HStack(spacing: 0) { @@ -146,5 +162,5 @@ struct HotlinePanelView: View { #Preview { HotlinePanelView() - .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) + .environment(HotlineState()) } diff --git a/Hotline/macOS/MessageView.swift b/Hotline/macOS/MessageView.swift index 0a8b071..b0c8baa 100644 --- a/Hotline/macOS/MessageView.swift +++ b/Hotline/macOS/MessageView.swift @@ -1,7 +1,7 @@ import SwiftUI struct MessageView: View { - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState @Environment(\.colorScheme) private var colorScheme @State private var input: String = "" @@ -75,7 +75,11 @@ struct MessageView: View { .multilineTextAlignment(.leading) .onSubmit { if !self.input.isEmpty { - model.sendInstantMessage(self.input, userID: self.userID) + let message = self.input + let uid = self.userID + Task { + try? await model.sendInstantMessage(message, userID: uid) + } } self.input = "" } @@ -107,5 +111,5 @@ struct MessageView: View { #Preview { ChatView() - .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) + .environment(HotlineState()) } diff --git a/Hotline/macOS/News/NewsEditorView.swift b/Hotline/macOS/News/NewsEditorView.swift index f69c846..cc3e7d7 100644 --- a/Hotline/macOS/News/NewsEditorView.swift +++ b/Hotline/macOS/News/NewsEditorView.swift @@ -9,7 +9,7 @@ struct NewsEditorView: View { @Environment(\.controlActiveState) private var controlActiveState @Environment(\.colorScheme) private var colorScheme @Environment(\.dismiss) private var dismiss - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState let editorTitle: String let isReply: Bool @@ -24,15 +24,16 @@ struct NewsEditorView: View { func sendArticle() async -> Bool { sending = true - - let success = await model.postNewsArticle(title: title, body: text, at: path, parentID: parentID) - if success { - await model.getNewsList(at: path) + + do { + try await model.postNewsArticle(title: title, body: text, at: path, parentID: parentID) + try? await model.getNewsList(at: path) + sending = false + return true + } catch { + sending = false + return false } - - sending = false - - return success } var body: some View { diff --git a/Hotline/macOS/News/NewsItemView.swift b/Hotline/macOS/News/NewsItemView.swift index fc20e61..29c0e7d 100644 --- a/Hotline/macOS/News/NewsItemView.swift +++ b/Hotline/macOS/News/NewsItemView.swift @@ -1,7 +1,7 @@ import SwiftUI struct NewsItemView: View { - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState var news: NewsInfo let depth: Int @@ -132,9 +132,9 @@ struct NewsItemView: View { guard news.expanded, news.type == .bundle || news.type == .category else { return } - + Task { - await model.getNewsList(at: news.path) + try? await model.getNewsList(at: news.path) } } @@ -148,5 +148,5 @@ struct NewsItemView: View { #Preview { NewsItemView(news: NewsInfo(hotlineNewsArticle: HotlineNewsArticle(id: 0, parentID: 0, flags: 0, title: "Title", username: "username", date: Date.now, flavors: [("", 1)], path: ["Guest"])), depth: 0) - .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) + .environment(HotlineState()) } diff --git a/Hotline/macOS/News/NewsView.swift b/Hotline/macOS/News/NewsView.swift index 91bf2fe..dfc5ab2 100644 --- a/Hotline/macOS/News/NewsView.swift +++ b/Hotline/macOS/News/NewsView.swift @@ -3,7 +3,7 @@ import MarkdownUI import SplitView struct NewsView: View { - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState @Environment(\.openWindow) private var openWindow @Environment(\.colorScheme) private var colorScheme @@ -64,7 +64,7 @@ struct NewsView: View { .task { if !model.newsLoaded { loading = true - await model.getNewsList() + try? await model.getNewsList() loading = false } } @@ -123,13 +123,13 @@ struct NewsView: View { loading = true if let selectionPath = selection?.path { Task { - await model.getNewsList(at: selectionPath) + try? await model.getNewsList(at: selectionPath) loading = false } } else { Task { - await model.getNewsList() + try? await model.getNewsList() loading = false } } @@ -179,7 +179,7 @@ struct NewsView: View { if let articleFlavor = article.articleFlavors?.first, let articleID = article.articleID { Task { - if let articleText = await self.model.getNewsArticle(id: articleID, at: article.path, flavor: articleFlavor) { + if let articleText = try? await self.model.getNewsArticle(id: articleID, at: article.path, flavor: articleFlavor) { self.articleText = articleText } } @@ -293,5 +293,5 @@ struct NewsView: View { #Preview { NewsView() - .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) + .environment(HotlineState()) } diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index e18d630..df3e54b 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -69,7 +69,7 @@ struct ListItemView: View { } extension FocusedValues { - @Entry var activeHotlineModel: Hotline? + @Entry var activeHotlineModel: HotlineState? @Entry var activeServerState: ServerState? } @@ -80,7 +80,7 @@ struct ServerView: View { @Environment(\.scenePhase) private var scenePhase @Environment(\.modelContext) private var modelContext - @State private var model: Hotline = Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient()) + @State private var model: HotlineState = HotlineState() @State private var state: ServerState = ServerState(selection: .chat) @State private var agreementShown: Bool = false @State private var connectAddress: String = "" @@ -119,6 +119,27 @@ struct ServerView: View { connectForm .navigationTitle("Connect to Server") } + else if case .failed(let error) = model.status { + VStack { + Image("Hotline") + .resizable() + .renderingMode(.template) + .scaledToFit() + .foregroundColor(Color(hex: 0xE10000)) + .frame(width: 18) + .opacity(controlActiveState == .inactive ? 0.5 : 1.0) + .padding(.trailing, 4) + + Text("Connection Failed") + .font(.headline) + Text(error) + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: 300) + .padding() + .navigationTitle("Connection Failed") + } else if model.status != .loggedIn { HStack { Image("Hotline") @@ -129,7 +150,7 @@ struct ServerView: View { .frame(width: 18) .opacity(controlActiveState == .inactive ? 0.5 : 1.0) .padding(.trailing, 4) - + ProgressView(value: connectionStatusToProgress(status: model.status)) { Text(connectionStatusToLabel(status: model.status)) } @@ -142,12 +163,24 @@ struct ServerView: View { else { serverView .environment(model) - .onChange(of: Prefs.shared.userIconID) { sendPreferences() } - .onChange(of: Prefs.shared.username) { sendPreferences() } - .onChange(of: Prefs.shared.refusePrivateMessages) { sendPreferences() } - .onChange(of: Prefs.shared.refusePrivateChat) { sendPreferences() } - .onChange(of: Prefs.shared.enableAutomaticMessage) { sendPreferences() } - .onChange(of: Prefs.shared.automaticMessage) { sendPreferences() } + .onChange(of: Prefs.shared.userIconID) { + Task { try? await model.sendUserPreferences() } + } + .onChange(of: Prefs.shared.username) { + Task { try? await model.sendUserPreferences() } + } + .onChange(of: Prefs.shared.refusePrivateMessages) { + Task { try? await model.sendUserPreferences() } + } + .onChange(of: Prefs.shared.refusePrivateChat) { + Task { try? await model.sendUserPreferences() } + } + .onChange(of: Prefs.shared.enableAutomaticMessage) { + Task { try? await model.sendUserPreferences() } + } + .onChange(of: Prefs.shared.automaticMessage) { + Task { try? await model.sendUserPreferences() } + } .toolbar { if #available(macOS 26.0, *) { ToolbarItem(placement: .navigation) { @@ -172,21 +205,23 @@ struct ServerView: View { } } .onDisappear { - model.disconnect() - } - .onChange(of: model.serverTitle) { oldTitle, newTitle in - state.serverName = newTitle - } - .onChange(of: model.bannerImage) { oldBanner, newBanner in - withAnimation { - state.serverBanner = newBanner - if let banner = newBanner { - state.bannerColors = ColorArt(image: banner, scaledSize: NSSize(width: 100, height: 100)) - } else { - state.bannerColors = nil - } + Task { + await model.disconnect() } } + .onChange(of: model.serverTitle) { + state.serverName = model.serverTitle + } +// .onChange(of: model.bannerImage) { +// state.serverBanner = model.bannerImage +// } +// .onChange(of: model.bannerColors) { +// guard let backgroundColor = model.bannerColors?.backgroundColor else { +// state.bannerBackgroundColor = nil +// return +// } +// state.bannerBackgroundColor = Color(nsColor: backgroundColor) +// } .alert(model.errorMessage ?? "Server Error", isPresented: $model.errorDisplayed) { Button("OK") {} } @@ -310,16 +345,18 @@ struct ServerView: View { if !name.isEmpty { connectNameSheetPresented = false connectName = "" - Task.detached { - let (host, port) = Server.parseServerAddressAndPort(connectAddress) - let login: String? = connectLogin.isEmpty ? nil : connectLogin - let password: String? = connectPassword.isEmpty ? nil : connectPassword - - if !host.isEmpty { - let newBookmark = Bookmark(type: .server, name: name, address: host, port: port, login: login, password: password) - Bookmark.add(newBookmark, context: modelContext) - } +// Task.detached { + + let (host, port) = Server.parseServerAddressAndPort(connectAddress) + let login: String? = connectLogin.isEmpty ? nil : connectLogin + let password: String? = connectPassword.isEmpty ? nil : connectPassword + + if !host.isEmpty { + let newBookmark = Bookmark(type: .server, name: name, address: host, port: port, login: login, password: password) + Bookmark.add(newBookmark, context: modelContext) } + +// } } } } @@ -390,7 +427,7 @@ struct ServerView: View { // Section("\(model.users.count) Online") { ForEach(model.users) { user in HStack(spacing: 5) { - if let iconImage = Hotline.getClassicIcon(Int(user.iconID)) { + if let iconImage = HotlineState.getClassicIcon(Int(user.iconID)) { Image(nsImage: iconImage) .frame(width: 16, height: 16) .padding(.leading, 2) @@ -476,35 +513,37 @@ struct ServerView: View { guard !server.address.isEmpty else { return } - model.login(server: server, username: Prefs.shared.username, iconID: Prefs.shared.userIconID) { success in - if !success { - print("FAILED LOGIN??") - model.disconnect() - } - else { - sendPreferences() - model.getUserList() - model.downloadBanner() + + Task { @MainActor in + do { + // login() handles everything: connect, getUserList, sendPreferences, downloadBanner + try await model.login( + server: server, + username: Prefs.shared.username, + iconID: Prefs.shared.userIconID + ) + } catch { + print("ServerView: Login failed: \(error)") } } } - private func connectionStatusToProgress(status: HotlineClientStatus) -> Double { + private func connectionStatusToProgress(status: HotlineConnectionStatus) -> Double { switch status { case .disconnected: return 0.0 case .connecting: return 0.4 case .connected: - return 0.75 - case .loggingIn: return 0.9 case .loggedIn: return 1.0 + case .failed: + return 0.0 } } - - private func connectionStatusToLabel(status: HotlineClientStatus) -> String { + + private func connectionStatusToLabel(status: HotlineConnectionStatus) -> String { let n = server.name ?? server.address switch status { case .disconnected: @@ -512,42 +551,21 @@ struct ServerView: View { case .connecting: return "Connecting to \(n)..." case .connected: - return "Connected to \(n)" - case .loggingIn: return "Logging in to \(n)..." case .loggedIn: return "Logged in to \(n)" + case .failed(let error): + return "Failed: \(error)" } } - @MainActor func sendPreferences() { - if self.model.status == .loggedIn { - var options: HotlineUserOptions = HotlineUserOptions() - - if Prefs.shared.refusePrivateMessages { - options.update(with: .refusePrivateMessages) - } - - if Prefs.shared.refusePrivateChat { - options.update(with: .refusePrivateChat) - } - - if Prefs.shared.enableAutomaticMessage { - options.update(with: .automaticResponse) - } - - print("Updating preferences with server") - - self.model.sendUserInfo(username: Prefs.shared.username, iconID: Prefs.shared.userIconID, options: options, autoresponse: Prefs.shared.automaticMessage) - } - } } struct TransferItemView: View { let transfer: TransferInfo @Environment(\.controlActiveState) private var controlActiveState - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState @State private var hovered: Bool = false @State private var buttonHovered: Bool = false @@ -559,8 +577,8 @@ struct TransferItemView: View { return "File transfer failed" } else if self.transfer.progress > 0.0 { - if self.transfer.timeRemaining > 0.0 { - return "\(round(self.transfer.progress * 100.0))% – \(self.transfer.timeRemaining) seconds left" + if let estimate = self.transfer.timeRemaining, estimate > 0.0 { + return "\(round(self.transfer.progress * 100.0))% – \(estimate) seconds left" } else { return "\(round(self.transfer.progress * 100.0))% complete" @@ -597,7 +615,7 @@ struct TransferItemView: View { if self.hovered { Button { - model.deleteTransfer(id: transfer.id) + AppState.shared.cancelTransfer(id: transfer.id) } label: { Image(systemName: self.buttonHovered ? "xmark.circle.fill" : "xmark.circle") .resizable() @@ -657,4 +675,3 @@ struct TransferItemView: View { .help(formattedProgressHelp()) } } - diff --git a/Hotline/macOS/Settings/IconSettingsView.swift b/Hotline/macOS/Settings/IconSettingsView.swift index 98cbb09..2bb92c4 100644 --- a/Hotline/macOS/Settings/IconSettingsView.swift +++ b/Hotline/macOS/Settings/IconSettingsView.swift @@ -18,7 +18,7 @@ struct IconSettingsView: View { GridItem(.fixed(4+32+4)), GridItem(.fixed(4+32+4)) ], spacing: 0) { - ForEach(Hotline.classicIconSet, id: \.self) { iconID in + ForEach(HotlineState.classicIconSet, id: \.self) { iconID in HStack { Image("Classic/\(iconID)") .resizable() diff --git a/Hotline/macOS/TransfersView.swift b/Hotline/macOS/TransfersView.swift new file mode 100644 index 0000000..9932c0a --- /dev/null +++ b/Hotline/macOS/TransfersView.swift @@ -0,0 +1,169 @@ +import SwiftUI + +struct TransfersView: View { + @Environment(\.appState) private var appState + + var body: some View { + VStack(spacing: 0) { + if appState.transfers.isEmpty { + emptyState + } else { + transfersList + } + } + .frame(minWidth: 500, minHeight: 200) + .navigationTitle("Transfers") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + appState.cancelAllTransfers() + } label: { + Label("Cancel All", systemImage: "xmark.circle") + } + .disabled(appState.transfers.isEmpty) + } + } + } + + // MARK: - Empty State + + private var emptyState: some View { + ContentUnavailableView { + Label("No Transfers", systemImage: "arrow.up.arrow.down") + } description: { + Text("Your Hotline file transfers will appear here") + } + } + + // MARK: - Transfers List + + private var transfersList: some View { + List { + ForEach(appState.transfers) { transfer in + TransferRow(transfer: transfer) + } + } + .listStyle(.inset(alternatesRowBackgrounds: true)) + } +} + +// MARK: - Transfer Row + +struct TransferRow: View { + @Bindable var transfer: TransferInfo + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + // File name and server + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(transfer.title) + .font(.system(.body, design: .default, weight: .medium)) + + if let serverName = transfer.serverName { + Text(serverName) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer() + + // Cancel button + Button { + AppState.shared.cancelTransfer(id: transfer.id) + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("Cancel download") + } + + // Progress bar and status + VStack(alignment: .leading, spacing: 4) { + if transfer.failed { + Label("Failed", systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(.red) + } else if transfer.completed { + Label("Complete", systemImage: "checkmark.circle.fill") + .font(.caption) + .foregroundStyle(.green) + } else { + // Progress bar + ProgressView(value: transfer.progress, total: 1.0) + .progressViewStyle(.linear) + + // Progress info + HStack(spacing: 8) { + // Progress percentage + Text("\(Int(transfer.progress * 100))%") + .font(.caption) + .foregroundStyle(.secondary) + .monospacedDigit() + + // File size + Text(formatSize(transfer.size)) + .font(.caption) + .foregroundStyle(.secondary) + + // Speed + if let speed = transfer.speed { + Text(formatSpeed(speed)) + .font(.caption) + .foregroundStyle(.secondary) + .monospacedDigit() + } + + // Time remaining + if let timeRemaining = transfer.timeRemaining { + Text(formatTimeRemaining(timeRemaining)) + .font(.caption) + .foregroundStyle(.secondary) + .monospacedDigit() + } + } + } + } + } + .padding(.vertical, 4) + } + + // MARK: - Formatting + + private func formatSize(_ bytes: UInt) -> String { + let formatter = ByteCountFormatter() + formatter.countStyle = .file + formatter.allowedUnits = [.useKB, .useMB, .useGB] + return formatter.string(fromByteCount: Int64(bytes)) + } + + private func formatSpeed(_ bytesPerSecond: Double) -> String { + let formatter = ByteCountFormatter() + formatter.countStyle = .file + formatter.allowedUnits = [.useKB, .useMB, .useGB] + return "\(formatter.string(fromByteCount: Int64(bytesPerSecond)))/s" + } + + private func formatTimeRemaining(_ seconds: TimeInterval) -> String { + if seconds < 60 { + return "\(Int(seconds))s" + } else if seconds < 3600 { + let minutes = Int(seconds / 60) + let secs = Int(seconds.truncatingRemainder(dividingBy: 60)) + return "\(minutes)m \(secs)s" + } else { + let hours = Int(seconds / 3600) + let minutes = Int((seconds.truncatingRemainder(dividingBy: 3600)) / 60) + return "\(hours)h \(minutes)m" + } + } +} + +// MARK: - Preview + +#Preview { + TransfersView() + .environment(AppState.shared) +} -- cgit From 39f51fd902bb34272c78ffdfb872c22095382f70 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 7 Nov 2025 10:39:48 -0800 Subject: Further cleanup of previous refactor. Removing old view model and NetSocket. Added some empty states for newsgroups and message board. --- Hotline.xcodeproj/project.pbxproj | 8 +- Hotline/Hotline/HotlineClient.swift | 878 ------- Hotline/Hotline/HotlineTransferClient.swift | 1834 --------------- .../Transfers/HotlineFileUploadClientNew.swift | 18 +- .../Transfers/HotlineFolderDownloadClientNew.swift | 38 +- .../Transfers/HotlineFolderUploadClientNew.swift | 28 +- Hotline/Library/NetSocket.swift | 274 --- Hotline/Models/Hotline.swift | 1876 --------------- Hotline/Models/HotlineState.swift | 2468 -------------------- Hotline/State/HotlineState.swift | 2468 ++++++++++++++++++++ Hotline/macOS/Board/MessageBoardView.swift | 121 +- Hotline/macOS/Files/FilesView.swift | 272 +-- Hotline/macOS/News/NewsView.swift | 42 +- 13 files changed, 2700 insertions(+), 7625 deletions(-) delete mode 100644 Hotline/Hotline/HotlineClient.swift delete mode 100644 Hotline/Library/NetSocket.swift delete mode 100644 Hotline/Models/Hotline.swift delete mode 100644 Hotline/Models/HotlineState.swift create mode 100644 Hotline/State/HotlineState.swift (limited to 'Hotline/macOS/Files/FilesView.swift') diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index a234fbc..cb621a0 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -137,7 +137,6 @@ DA3429B62EBAB1750010784E /* QuickLookPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickLookPreviewView.swift; sourceTree = ""; }; DA3429B82EBAB2130010784E /* FilePreviewQuickLookView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePreviewQuickLookView.swift; sourceTree = ""; }; DA43205D2B1D615600FC8843 /* ServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerView.swift; sourceTree = ""; }; - DA4930BC2B4F8DD700822D0B /* NetSocket.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetSocket.swift; sourceTree = ""; }; DA4B8F392EA6FB3C00CBFD53 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; }; DA4F2BF72B16A17200D8ADDC /* HotlineProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineProtocol.swift; sourceTree = ""; }; DA4F2C002B1A558E00D8ADDC /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = ""; }; @@ -161,7 +160,6 @@ DA55AC782BE6A1AD00034857 /* RegularExpressions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegularExpressions.swift; sourceTree = ""; }; DA5753672B33E88A00FAC277 /* HotlineTransferClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineTransferClient.swift; sourceTree = ""; }; DA57536B2B36BA1D00FAC277 /* TextDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextDocument.swift; sourceTree = ""; }; - DA6300962B24036B0034CBFD /* HotlineClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineClient.swift; sourceTree = ""; }; DA6549992BEC280E00EDB697 /* ServerMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerMessageView.swift; sourceTree = ""; }; DA65499B2BEC3FBD00EDB697 /* ServerAgreementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerAgreementView.swift; sourceTree = ""; }; DA65499D2BEC438A00EDB697 /* NSWindowBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSWindowBridge.swift; sourceTree = ""; }; @@ -209,7 +207,6 @@ DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdateView.swift; sourceTree = ""; }; DADDB28A2B22B31F0024040D /* Tracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tracker.swift; sourceTree = ""; }; DADDB28C2B22B5920024040D /* Server.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Server.swift; sourceTree = ""; }; - DADDB28E2B238D850024040D /* Hotline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Hotline.swift; sourceTree = ""; }; DAE136B92B9D1147007D8307 /* HotlinePanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlinePanelView.swift; sourceTree = ""; }; DAE734F82B2E4185000C56F6 /* ServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerView.swift; sourceTree = ""; }; DAE734FA2B2E41F9000C56F6 /* TrackerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackerView.swift; sourceTree = ""; }; @@ -255,6 +252,7 @@ DA2863D92B37BF6E00A7D050 /* Preferences.swift */, DACCE5E02EABE4B4008CDD92 /* AppUpdate.swift */, DA3429B42EBA8A450010784E /* FilePreviewState.swift */, + DA5268B02EB2708E00DCB941 /* HotlineState.swift */, ); path = State; sourceTree = ""; @@ -409,7 +407,6 @@ DAB4D8832B4CABEF0048A05C /* DataAdditions.swift */, DAB4D87F2B4C8E9A0048A05C /* URLAdditions.swift */, DA3429B62EBAB1750010784E /* QuickLookPreviewView.swift */, - DA4930BC2B4F8DD700822D0B /* NetSocket.swift */, DA872B122BDDBF78008B1012 /* HotlinePanel.swift */, DA872B142BDDEE1A008B1012 /* VisualEffectView.swift */, DA55AC722BE42AF000034857 /* AsyncLinkPreview.swift */, @@ -436,7 +433,6 @@ DABFCC262B1530AE009F40D2 /* Hotline */ = { isa = PBXGroup; children = ( - DA6300962B24036B0034CBFD /* HotlineClient.swift */, DA5268AE2EB2682B00DCB941 /* HotlineClientNew.swift */, DA9CAFCA2B126E3300CDA197 /* HotlineTrackerClient.swift */, DA5753672B33E88A00FAC277 /* HotlineTransferClient.swift */, @@ -476,8 +472,6 @@ DADDB2892B22B2C60024040D /* Models */ = { isa = PBXGroup; children = ( - DADDB28E2B238D850024040D /* Hotline.swift */, - DA5268B02EB2708E00DCB941 /* HotlineState.swift */, DADDB28A2B22B31F0024040D /* Tracker.swift */, DADDB28C2B22B5920024040D /* Server.swift */, DA32CD482B2931640053B98B /* User.swift */, diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift deleted file mode 100644 index 0e54872..0000000 --- a/Hotline/Hotline/HotlineClient.swift +++ /dev/null @@ -1,878 +0,0 @@ -import Foundation -import Network -import RegexBuilder - -enum HotlineClientStatus: Int { - case disconnected - case connecting - case connected - case loggingIn - case loggedIn -} - -enum HotlineTransactionError: Error { - case networkFailure - case timeout - case error(UInt32, String?) - case invalidMessage(UInt32, String?) -} - -struct HotlineTransactionInfo { - let type: HotlineTransactionType - let callback: ((HotlineTransaction) -> Void)? - let reply: ((HotlineTransaction) -> Void)? -} - -private struct HotlineLogin { - let login: String? - let password: String? - let username: String - let iconID: UInt16 - let callback: ((HotlineTransactionError?, String?, UInt16?) -> Void)? -} - -protocol HotlineClientDelegate: AnyObject { - func hotlineGetUserInfo() -> (String, UInt16) - func hotlineStatusChanged(status: HotlineClientStatus) - func hotlineReceivedAgreement(text: String) - func hotlineReceivedErrorMessage(code: UInt32, message: String?) - func hotlineReceivedChatMessage(message: String) - func hotlineReceivedUserList(users: [HotlineUser]) - func hotlineReceivedServerMessage(message: String) - func hotlineReceivedPrivateMessage(userID: UInt16, message: String) - func hotlineReceivedUserAccess(options: HotlineUserAccessOptions) - func hotlineUserChanged(user: HotlineUser) - func hotlineUserDisconnected(userID: UInt16) - func hotlineReceivedNewsPost(message: String) -} - -extension HotlineClientDelegate { - func hotlineStatusChanged(status: HotlineClientStatus) {} - func hotlineReceivedAgreement(text: String) {} - func hotlineReceivedErrorMessage(code: UInt32, message: String?) {} - func hotlineReceivedChatMessage(message: String) {} - func hotlineReceivedUserList(users: [HotlineUser]) {} - func hotlineReceivedServerMessage(message: String) {} - func hotlineReceivedPrivateMessage(userID: UInt16, message: String) {} - func hotlineReceivedUserAccess(options: HotlineUserAccessOptions) {} - func hotlineUserChanged(user: HotlineUser) {} - func hotlineUserDisconnected(userID: UInt16) {} - func hotlineReceivedNewsPost(message: String) {} -} - -enum HotlineClientStage { - case handshake - case packetHeader - case packetBody -} - -class HotlineClient: NetSocketDelegate { - static let handshakePacket = Data([ - 0x54, 0x52, 0x54, 0x50, // 'TRTP' protocol ID - 0x48, 0x4F, 0x54, 0x4C, // Sub-protocol ID - 0x00, 0x01, // Version - 0x00, 0x02, // Sub-version - ]) - - static let HandshakePacket: [UInt8] = [ - 0x54, 0x52, 0x54, 0x50, // 'TRTP' protocol ID - 0x48, 0x4F, 0x54, 0x4C, // Sub-protocol ID - 0x00, 0x01, // Version - 0x00, 0x02, // Sub-version - ] - - weak var delegate: HotlineClientDelegate? - - var connectionStatus: HotlineClientStatus = .disconnected - var connectCallback: ((Bool) -> Void)? - - private var serverAddress: String? = nil - private var serverPort: UInt16? = nil - - private struct TransactionContext { - let type: HotlineTransactionType - let callback: ((HotlineTransaction, HotlineTransactionError?) -> Void)? - let suppressErrors: Bool - } - - private var transactionLog: [UInt32: TransactionContext] = [:] - - private var socket: NetSocket? - private var stage: HotlineClientStage = .handshake - private var packet: HotlineTransaction? = nil - private var serverVersion: UInt16? = nil - private var loginDetails: HotlineLogin? = nil - private var keepAliveTimer: Timer? = nil - - init() {} - - // MARK: - NetSocket Delegate - - @MainActor func netsocketConnected(socket: NetSocket) { - self.updateConnectionStatus(.loggingIn) - self.stage = .handshake - } - - @MainActor func netsocketDisconnected(socket: NetSocket, error: Error?) { - self.reset() - self.updateConnectionStatus(.disconnected) - self.stage = .handshake - } - - @MainActor func netsocketReceived(socket: NetSocket, bytes: [UInt8]) { - switch self.stage { - case .handshake: - self.receiveHandshake() - case .packetHeader: - self.receivePacket() - case .packetBody: - self.receivePacket() - } - } - - // MARK: - Connect - - @MainActor func login(address: String, port: Int, login: String?, password: String?, username: String, iconID: UInt16, callback: ((HotlineTransactionError?, String?, UInt16?) -> Void)?) { - if self.socket != nil { - self.socket?.delegate = nil - self.socket?.close() - self.socket = nil - } - self.packet = nil - - self.loginDetails = HotlineLogin(login: login, password: password, username: username, iconID: iconID, callback: callback) - - self.socket = NetSocket() - self.socket?.delegate = self - - self.updateConnectionStatus(.connecting) - self.socket?.connect(host: address, port: port) - self.socket?.write(HotlineClient.HandshakePacket) - } - - @MainActor private func startKeepAliveTimer() { - self.keepAliveTimer = Timer.scheduledTimer(withTimeInterval: 60 * 3, repeats: true) { [weak self] _ in - DispatchQueue.main.async { [weak self] in - self?.sendKeepAlive() - } - } - } - - @MainActor func receiveHandshake() { - guard let socket = self.socket, - self.stage == .handshake, - socket.available >= 8 else { - return - } - - var handshake: [UInt8] = socket.read(count: 8) - - // Verify handshake data - guard let protocolID = handshake.consumeUInt32(), - protocolID == "TRTP".fourCharCode() else { - // TODO: Close with appropriate error - socket.close() - return - } - - // Check for error code - guard let errorCode = handshake.consumeUInt32(), - errorCode == 0 else { - // TODO: Close with wrapped error - socket.close() - return - } - - self.stage = .packetHeader - - let session = self.loginDetails! - self.loginDetails = nil - self.sendLogin(login: session.login ?? "", password: session.password ?? "", username: session.username, iconID: session.iconID) { [weak self] err, serverName, serverVersion in - self?.serverVersion = serverVersion - self?.startKeepAliveTimer() - session.callback?(err, serverName, serverVersion) - } - - self.receivePacket() - } - - @MainActor private func reset() { - self.transactionLog = [:] - self.packet = nil - - self.keepAliveTimer?.invalidate() - self.keepAliveTimer = nil - - self.socket?.close() - self.socket?.delegate = nil - self.socket = nil - } - - @MainActor func disconnect() { - let wasConnected = self.connectionStatus != .disconnected - self.reset() - if wasConnected { - self.updateConnectionStatus(.disconnected) - } - } - - // MARK: - Packets - - @MainActor private func sendPacket(_ t: HotlineTransaction, suppressErrors: Bool = false, callback: ((HotlineTransaction, HotlineTransactionError?) -> Void)? = nil) { - guard let socket = self.socket else { - return - } - - print("HotlineClient => \(t.id) \(t.type)") - - if callback != nil || suppressErrors { - self.transactionLog[t.id] = TransactionContext(type: t.type, callback: callback, suppressErrors: suppressErrors) - } - - socket.write(t.encoded()) - } - - @MainActor private func receivePacket() { - guard let socket = self.socket else { - return - } - - var done: Bool = false - repeat { - switch self.stage { - case .packetHeader: - guard socket.has(HotlineTransaction.headerSize) else { - done = true - break - } - - let headerData: [UInt8] = socket.read(count: HotlineTransaction.headerSize) - guard let packet = HotlineTransaction(from: headerData) else { - done = true - break - } - - self.packet = packet - if packet.dataSize == 0 { - self.stage = .packetHeader - self.processPacket() - } - else { - self.stage = .packetBody - } - - case .packetBody: - guard let packet = self.packet, socket.has(Int(packet.dataSize)) else { - done = true - break - } - - let bodyData: [UInt8] = socket.read(count: Int(packet.dataSize)) - self.packet?.decodeFields(from: bodyData) - self.stage = .packetHeader - self.processPacket() - - default: - done = true - break - } - } while !done - } - - @MainActor private func processPacket() { - guard let packet = self.packet else { - return - } - - if packet.type == .reply || packet.isReply == 1 { - print("HotlineClient <= \(packet.type) to \(packet.id):") - } - else { - print("HotlineClient <= \(packet.type) \(packet.id)") - } - - if packet.isReply == 1 || packet.type == .reply { - self.processReplyPacket() - return - } - - // Mark packet is processed - self.packet = nil - - switch(packet.type) { - case .chatMessage: - if - let chatTextParam = packet.getField(type: .data), - let chatText = chatTextParam.getString() - { - print("HotlineClient: \(chatText)") - self.delegate?.hotlineReceivedChatMessage(message: chatText) - } - - case .notifyOfUserChange: - if let usernameField = packet.getField(type: .userName), - let username = usernameField.getString(), - let userIDField = packet.getField(type: .userID), - let userID = userIDField.getUInt16(), - let userIconIDField = packet.getField(type: .userIconID), - let userIconID = userIconIDField.getUInt16(), - let userFlagsField = packet.getField(type: .userFlags), - let userFlags = userFlagsField.getUInt16() { - print("HotlineClient: User changed \(userID) \(username) icon: \(userIconID)") - - let user = HotlineUser(id: userID, iconID: userIconID, status: userFlags, name: username) - self.delegate?.hotlineUserChanged(user: user) - } - - case .notifyOfUserDelete: - if let userIDField = packet.getField(type: .userID), - let userID = userIDField.getUInt16() { - self.delegate?.hotlineUserDisconnected(userID: userID) - } - - case .disconnectMessage: - // Server disconnected us. - print("HotlineClient ❌") - self.disconnect() - - case .serverMessage: - if let messageField = packet.getField(type: .data), - let message = messageField.getString() { - - if let userIDField = packet.getField(type: .userID), - let userID = userIDField.getUInt16() { - self.delegate?.hotlineReceivedPrivateMessage(userID: userID, message: message) - } - else { - self.delegate?.hotlineReceivedServerMessage(message: message) - } - } - - case .showAgreement: - if let _ = packet.getField(type: .noServerAgreement) { - // Server told us there is no agreement to show. - return - } - if let agreementParam = packet.getField(type: .data) { - if let agreementText = agreementParam.getString() { - self.delegate?.hotlineReceivedAgreement(text: agreementText) - } - } - - case .userAccess: - print("HotlineClient: user access info \(packet.getField(type: .userAccess).debugDescription)") - if let accessParam = packet.getField(type: .userAccess) { - if let accessValue = accessParam.getUInt64() { - let accessOptions = HotlineUserAccessOptions(rawValue: accessValue) - self.delegate?.hotlineReceivedUserAccess(options: accessOptions) - } - } - - case .newMessage: - if let messageField = packet.getField(type: .data), - let message = messageField.getString() { - self.delegate?.hotlineReceivedNewsPost(message: message) - } - - default: - print("HotlineClient: UNKNOWN transaction \(packet.type) with \(packet.fields.count) parameters") - print(packet.fields) - } - } - - @MainActor private func processReplyPacket() { - guard let packet = self.packet else { - return - } - - let context = self.transactionLog[packet.id] - self.transactionLog[packet.id] = nil - - if packet.errorCode != 0 { - let errorField: HotlineTransactionField? = packet.getField(type: .errorText) - print("HotlineClient 😵 \(packet.errorCode): \(errorField?.getString() ?? "")") - if context?.suppressErrors != true { - self.delegate?.hotlineReceivedErrorMessage(code: packet.errorCode, message: errorField?.getString()) - } - } - - if let context { - print("HotlineClient reply in response to \(context.type)") - } - - let replyCallback = context?.callback - - guard packet.errorCode == 0 else { - let errorField: HotlineTransactionField? = packet.getField(type: .errorText) - replyCallback?(packet, .error(packet.errorCode, errorField?.getString())) - return - } - - replyCallback?(packet, nil) - } - - // MARK: - Messages - - @MainActor func sendLogin(login: String, password: String, username: String, iconID: UInt16, callback: ((HotlineTransactionError?, String?, UInt16?) -> Void)?) { - var t = HotlineTransaction(type: .login) - t.setFieldEncodedString(type: .userLogin, val: login) - t.setFieldEncodedString(type: .userPassword, val: password) - t.setFieldUInt16(type: .userIconID, val: iconID) - t.setFieldString(type: .userName, val: username) - t.setFieldUInt32(type: .versionNumber, val: 123) - - self.sendPacket(t) { [weak self] reply, err in - self?.updateConnectionStatus(.loggedIn) - - var serverVersion: UInt16? - var serverName: String? - - if - let serverVersionField = reply.getField(type: .versionNumber), - let serverVersionValue = serverVersionField.getUInt16() { - serverVersion = serverVersionValue - print("SERVER VERSION: \(serverVersionValue)") - } - - if - let serverNameField = reply.getField(type: .serverName), - let serverNameValue = serverNameField.getString() { - serverName = serverNameValue - print("SERVER NAME: \(serverNameValue)") - } - - callback?(err, serverName, serverVersion) - } - } - - @MainActor func sendSetClientUserInfo(username: String, iconID: UInt16, options: HotlineUserOptions = [], autoresponse: String? = nil) { - var t = HotlineTransaction(type: .setClientUserInfo) - t.setFieldString(type: .userName, val: username) - t.setFieldUInt16(type: .userIconID, val: iconID) - t.setFieldUInt16(type: .options, val: options.rawValue) - if let text = autoresponse { - t.setFieldString(type: .automaticResponse, val: text) - } - - self.sendPacket(t) - } - - @MainActor func sendAgree(username: String, iconID: UInt16, options: HotlineUserOptions) { - let t = HotlineTransaction(type: .agreed) -// t.setFieldString(type: .userName, val: username) -// t.setFieldUInt16(type: .userIconID, val: iconID) -// t.setFieldUInt8(type: .options, val: options.rawValue) - self.sendPacket(t) - } - - @MainActor func sendChat(message: String, encoding: String.Encoding = .utf8, announce: Bool = false) { - var t = HotlineTransaction(type: .sendChat) - t.setFieldString(type: .data, val: message, encoding: encoding) - t.setFieldUInt16(type: .chatOptions, val: announce ? 1 : 0) - self.sendPacket(t) - } - - @MainActor func sendInstantMessage(message: String, userID: UInt16, encoding: String.Encoding = .utf8) { - var t = HotlineTransaction(type: .sendInstantMessage) - t.setFieldUInt16(type: .userID, val: userID) - t.setFieldUInt32(type: .options, val: 1) - t.setFieldString(type: .data, val: message, encoding: encoding) - self.sendPacket(t) - } - - @MainActor func sendGetUserList() { - let t = HotlineTransaction(type: .getUserNameList) - self.sendPacket(t) { [weak self] reply, err in - var newUsers: [UInt16:HotlineUser] = [:] - var newUserList: [HotlineUser] = [] - for u in reply.getFieldList(type: .userNameWithInfo) { - let user = u.getUser() - newUsers[user.id] = user - newUserList.append(user) - } - self?.delegate?.hotlineReceivedUserList(users: newUserList) - } - } - - @MainActor func sendGetMessageBoard(callback: ((HotlineTransactionError?, [String]) -> Void)?) { - let t = HotlineTransaction(type: .getMessageBoard) - self.sendPacket(t) { reply, err in - guard err == nil, - let textField = reply.getField(type: .data), - let text = textField.getString() else { - callback?(err, []) - return - } - - var messages: [String] = [] - let matches = text.matches(of: RegularExpressions.messageBoardDivider) - var start = text.startIndex - - if matches.count > 0 { - for match in matches { - let range = match.range - let messageText = String(text[start.. 0 else { - return - } - - var t = HotlineTransaction(type: .oldPostNews) - t.setFieldString(type: .data, val: text.convertingLineEndings(to: .cr), encoding: .macOSRoman) - self.sendPacket(t) - } - - @MainActor func sendGetNewsCategories(path: [String] = [], callback: (([HotlineNewsCategory]) -> Void)?) { - var t = HotlineTransaction(type: .getNewsCategoryNameList) - if !path.isEmpty { - t.setFieldPath(type: .newsPath, val: path) - } - - self.sendPacket(t) { reply, err in - var categories: [HotlineNewsCategory] = [] - for categoryListItem in reply.getFieldList(type: .newsCategoryListData15) { - var c = categoryListItem.getNewsCategory() - c.path = path + [c.name] - categories.append(c) - } - callback?(categories) - } - } - - @MainActor func sendGetNewsArticle(id articleID: UInt32, path: [String], flavor: String, callback: ((String?) -> Void)? = nil) { - var t = HotlineTransaction(type: .getNewsArticleData) - t.setFieldPath(type: .newsPath, val: path) - t.setFieldUInt32(type: .newsArticleID, val: articleID) - t.setFieldString(type: .newsArticleDataFlavor, val: flavor, encoding: .ascii) - - self.sendPacket(t) { reply, err in - guard err == nil, - let articleData = reply.getField(type: .newsArticleData), - let articleString = articleData.getString() else { - callback?(nil) - return - } - - callback?(articleString) - } - } - - @MainActor func postNewsArticle(title: String, text: String, path: [String] = [], parentID: UInt32 = 0, callback: ((Bool) -> Void)? = nil) { - guard !path.isEmpty else { - callback?(false) - return - } - - var t = HotlineTransaction(type: .postNewsArticle) - t.setFieldPath(type: .newsPath, val: path) - t.setFieldUInt32(type: .newsArticleID, val: parentID) - t.setFieldString(type: .newsArticleTitle, val: title) - t.setFieldString(type: .newsArticleDataFlavor, val: "text/plain") - t.setFieldUInt32(type: .newsArticleFlags, val: 0) - t.setFieldString(type: .newsArticleData, val: text.convertingLineEndings(to: .cr)) - - print("HotlineClient postings \(title) under \(parentID)") - - self.sendPacket(t) { reply, err in - guard err == nil else { - callback?(false) - return - } - callback?(true) - } - } - - @MainActor func sendGetAccounts(callback: (([HotlineAccount]) -> Void)? = nil) { - let t = HotlineTransaction(type: .getAccounts) - - self.sendPacket(t) { reply, err in - guard err == nil else { - callback?([]) - return - } - - let accountFields = reply.getFieldList(type: .data) - - var accounts: [HotlineAccount] = [] - for data in accountFields { - accounts.append(data.getAcccount()) - } - - accounts.sort { $0.login < $1.login } - - callback?(accounts) - } - } - - @MainActor func sendGetNewsArticles(path: [String] = [], callback: (([HotlineNewsArticle]) -> Void)? = nil) { - var t = HotlineTransaction(type: .getNewsArticleNameList) - if !path.isEmpty { - t.setFieldPath(type: .newsPath, val: path) - } - self.sendPacket(t) { reply, err in - guard err == nil, - let articleData = reply.getField(type: .newsArticleListData) else { - callback?([]) - return - } - - var articles: [HotlineNewsArticle] = [] - let newsList = articleData.getNewsList() - for art in newsList.articles { - var blah = art - blah.path = path - articles.append(blah) - } - - callback?(articles) - } - } - - @MainActor func sendGetFileList(path: [String] = [], suppressErrors: Bool = false, callback: (([HotlineFile]) -> Void)? = nil) { - var t = HotlineTransaction(type: .getFileNameList) - if !path.isEmpty { - t.setFieldPath(type: .filePath, val: path) - } - - self.sendPacket(t, suppressErrors: suppressErrors) { reply, err in - guard err == nil else { - callback?([]) - return - } - - var files: [HotlineFile] = [] - for fi in reply.getFieldList(type: .fileNameWithInfo) { - let file = fi.getFile() - file.path = path + [file.name] - files.append(file) - } - - callback?(files) - } - } - - @MainActor func sendDeleteFile(name fileName: String, path filePath: [String], callback: ((Bool) -> Void)? = nil) { - var t = HotlineTransaction(type: .deleteFile) - t.setFieldString(type: .fileName, val: fileName) - t.setFieldPath(type: .filePath, val: filePath) - self.sendPacket(t) { reply, err in - callback?(err == nil) - } - } - - @MainActor func sendGetFileInfo(name fileName: String, path filePath: [String], callback: ((FileDetails?) -> Void)? = nil) { - var t = HotlineTransaction(type: .getFileInfo) - t.setFieldString(type: .fileName, val: fileName) - t.setFieldPath(type: .filePath, val: filePath) - - self.sendPacket(t) { reply, err in - guard err == nil, - let fileName = reply.getField(type: .fileName)?.getString(), - let fileCreator = reply.getField(type: .fileCreatorString)?.getString(), - let fileType = reply.getField(type: .fileTypeString)?.getString(), - let _ = reply.getField(type: .fileTypeString)?.getString(), - let fileCreateDate = reply.getField(type: .fileCreateDate)?.data.readDate(at: 0), - let fileModifyDate = reply.getField(type: .fileModifyDate)?.data.readDate(at: 0) - else { - callback?(nil) - return - } - - - // Size field is not included in server reply for folders - let fileSize = reply.getField(type: .fileSize)?.getInteger() ?? 0 - - // Comment field is not included for if no comment present - let fileComment = reply.getField(type: .fileComment)?.getString() ?? "" - - callback?(FileDetails(name: fileName, path: filePath, size: fileSize, comment: fileComment, type: fileType, creator: fileCreator, - created: fileCreateDate, modified: fileModifyDate)) - } - } - - - @MainActor func sendCreateUser(name: String, login: String, password: String?, access: uint64) { - var t = HotlineTransaction(type: .newUser) - - t.setFieldString(type: .userName, val: name) - t.setFieldEncodedString(type: .userLogin, val: login) - t.setFieldUInt64(type: .userAccess, val: access) - - if let password { - t.setFieldEncodedString(type: .userPassword, val: password) - } - - self.sendPacket(t) - // TODO: handle errors - } - - @MainActor func sendSetUser(name: String, login: String, newLogin: String?, password: String?, access: uint64) { - var t = HotlineTransaction(type: .setUser) - t.setFieldString(type: .userName, val: name) - t.setFieldUInt64(type: .userAccess, val: access) - - if let newLogin { - t.setFieldEncodedString(type: .data, val: login) - t.setFieldEncodedString(type: .userLogin, val: newLogin) - } else { - t.setFieldEncodedString(type: .userLogin, val: login) - } - - // In the setUser transaction, there are 3 possibilities for the password field: - // 1. If the password was not modified, the password field is sent with a zero byte. - if password == nil { - t.setFieldUInt8(type: .userPassword, val: 0) - } - - // 2. If the transaction should update the password, the password field is sent with the new password. - if let password, password != "" { - t.setFieldEncodedString(type: .userPassword, val: password) - } - - // 3) If the transaction should remove the password, the password field is omitted from the transaction. - self.sendPacket(t) - // TODO: handle errors - } - - @MainActor func sendDeleteUser(login: String) { - var t = HotlineTransaction(type: .deleteUser) - t.setFieldEncodedString(type: .userLogin, val: login) - - self.sendPacket(t) - // TODO: handle errors - } - - @MainActor func sendSetFileInfo(fileName: String, path filePath: [String], fileNewName: String?, comment: String?, encoding: String.Encoding = .utf8) { - var t = HotlineTransaction(type: .setFileInfo) - t.setFieldString(type: .fileName, val: fileName, encoding: encoding) - t.setFieldPath(type: .filePath, val: filePath) - - if fileNewName != nil { - t.setFieldString(type: .fileNewName, val: fileNewName!, encoding: encoding) - } - - if comment != nil { - t.setFieldString(type: .fileComment, val: comment!, encoding: encoding) - } - - self.sendPacket(t) - } - - @MainActor func sendDownloadFile(name fileName: String, path filePath: [String], preview: Bool = false, callback: ((Bool, UInt32?, Int?, Int?, Int?) -> Void)? = nil) { - var t = HotlineTransaction(type: .downloadFile) - t.setFieldString(type: .fileName, val: fileName) - t.setFieldPath(type: .filePath, val: filePath) - if preview { - t.setFieldUInt32(type: .fileTransferOptions, val: 2) - } - - self.sendPacket(t) { reply, err in - guard err == nil, - let transferSizeField = reply.getField(type: .transferSize), - let transferSize = transferSizeField.getInteger(), - let transferReferenceField = reply.getField(type: .referenceNumber), - let referenceNumber = transferReferenceField.getUInt32() else { - callback?(false, nil, nil, nil, nil) - return - } - - let transferFileSizeField = reply.getField(type: .fileSize) - let transferFileSize = transferFileSizeField?.getInteger() - let transferWaitingCountField = reply.getField(type: .waitingCount) - let transferWaitingCount = transferWaitingCountField?.getInteger() - - callback?(true, referenceNumber, transferSize, transferFileSize ?? transferSize, transferWaitingCount) - } - } - - @MainActor func sendUploadFile(name fileName: String, path filePath: [String], callback: ((Bool, UInt32?) -> Void)? = nil) { - var t = HotlineTransaction(type: .uploadFile) - t.setFieldString(type: .fileName, val: fileName) - t.setFieldPath(type: .filePath, val: filePath) - - self.sendPacket(t) { reply, err in - guard err == nil, - let transferReferenceField = reply.getField(type: .referenceNumber), - let referenceNumber = transferReferenceField.getUInt32() else { - callback?(false, nil) - return - } - - callback?(true, referenceNumber) - } - } - - @MainActor func sendDownloadFolder(name folderName: String, path folderPath: [String], callback: ((Bool, UInt32?, Int?, Int?, Int?) -> Void)? = nil) { - var t = HotlineTransaction(type: .downloadFolder) - t.setFieldString(type: .fileName, val: folderName) - t.setFieldPath(type: .filePath, val: folderPath) - - self.sendPacket(t) { reply, err in - guard err == nil, - let transferSizeField = reply.getField(type: .transferSize), - let transferSize = transferSizeField.getInteger(), - let transferReferenceField = reply.getField(type: .referenceNumber), - let referenceNumber = transferReferenceField.getUInt32() else { - callback?(false, nil, nil, nil, nil) - return - } - - let folderItemCountField = reply.getField(type: .folderItemCount) - let folderItemCount = folderItemCountField?.getInteger() - let transferWaitingCountField = reply.getField(type: .waitingCount) - let transferWaitingCount = transferWaitingCountField?.getInteger() - - callback?(true, referenceNumber, transferSize, folderItemCount, transferWaitingCount) - } - } - - @MainActor func sendDownloadBanner(callback: ((Bool, UInt32?, Int?) -> Void)? = nil) { - let t = HotlineTransaction(type: .downloadBanner) - - self.sendPacket(t) { reply, err in - guard err == nil, - let transferSizeField = reply.getField(type: .transferSize), - let transferSize = transferSizeField.getInteger(), - let transferReferenceField = reply.getField(type: .referenceNumber), - let referenceNumber = transferReferenceField.getUInt32() else { - callback?(false, nil, nil) - return - } - - callback?(true, referenceNumber, transferSize) - } - } - - @MainActor private func sendKeepAlive() { - print("HotlineClient: Sending keep alive") - if let v = self.serverVersion, v >= 185 { - let t = HotlineTransaction(type: .connectionKeepAlive) - self.sendPacket(t) - } - else { - let t = HotlineTransaction(type: .getUserNameList) - self.sendPacket(t) - } - } - - - // MARK: - Utility - - @MainActor private func updateConnectionStatus(_ status: HotlineClientStatus) { - self.connectionStatus = status - self.delegate?.hotlineStatusChanged(status: status) - } - -} diff --git a/Hotline/Hotline/HotlineTransferClient.swift b/Hotline/Hotline/HotlineTransferClient.swift index 30fe694..156b2b0 100644 --- a/Hotline/Hotline/HotlineTransferClient.swift +++ b/Hotline/Hotline/HotlineTransferClient.swift @@ -284,1837 +284,3 @@ struct HotlineFileInfoFork { return data } } - - -//protocol HotlineTransferDelegate: AnyObject { -// @MainActor func hotlineTransferStatusChanged(client: HotlineTransferClient, reference: UInt32, status: HotlineTransferStatus, timeRemaining: TimeInterval) -//} - -//protocol HotlineFileDownloadClientDelegate: HotlineTransferDelegate { -// @MainActor func hotlineFileDownloadReceivedInfo(client: HotlineFileDownloadClient, reference: UInt32, info: HotlineFileInfoFork) -// @MainActor func hotlineFileDownloadComplete(client: HotlineFileDownloadClient, reference: UInt32, at: URL) -//} - -//protocol HotlineFilePreviewClientDelegate: HotlineTransferDelegate { -// @MainActor func hotlineFilePreviewComplete(client: HotlineFilePreviewClient, reference: UInt32, data: Data) -//} - -//protocol HotlineFileUploadClientDelegate: HotlineTransferDelegate { -// @MainActor func hotlineFileUploadComplete(client: HotlineFileUploadClient, reference: UInt32) -//} - -//protocol HotlineFolderDownloadClientDelegate: HotlineTransferDelegate { -// @MainActor func hotlineFolderDownloadReceivedFileInfo(client: HotlineFolderDownloadClient, reference: UInt32, fileName: String, itemNumber: Int, totalItems: Int) -// @MainActor func hotlineFolderDownloadComplete(client: HotlineFolderDownloadClient, reference: UInt32, at: URL) -//} - -//enum HotlineFileTransferStage: Int { -// case fileHeader = 1 -// case fileForkHeader = 2 -// case fileInfoFork = 3 -// case fileDataFork = 4 -// case fileResourceFork = 5 -// case fileUnsupportedFork = 6 -//} - -//enum HotlineFileUploadStage: Int { -// case magic = 1 -// case fileHeader = 2 -// case fileInfoForkHeader = 3 -// case fileInfoFork = 4 -// case fileDataForkHeader = 5 -// case fileDataFork = 6 -// case fileResourceForkHeader = 7 -// case fileResourceFork = 8 -// case fileComplete = 9 -//} - -//enum HotlineFolderDownloadStage: Int { -// case itemHeader = 0 // Read 2-byte length + item header -// case waitingForFileSize = 1 // Read 4-byte file size before FILP -// case fileHeader = 2 -// case fileForkHeader = 3 -// case fileInfoFork = 4 -// case fileDataFork = 5 -// case fileResourceFork = 6 -// case fileUnsupportedFork = 7 -//} - - -// MARK: - - -//class HotlineFileUploadClient: HotlineTransferClient { -// let serverAddress: NWEndpoint.Host -// let serverPort: NWEndpoint.Port -// let referenceNumber: UInt32 -// -// weak var delegate: HotlineFileUploadClientDelegate? = nil -// -// private var connection: NWConnection? -// private var stage: HotlineFileUploadStage = .magic -// private var payloadSize: UInt32 = 0 -// private let fileURL: URL -// private let fileResourceURL: URL? -// private var fileHandle: FileHandle? = nil -// private var bytesSent: Int = 0 -// private let infoForkData: Data -// private let dataForkSize: UInt32 -// private let resourceForkSize: UInt32 -// -// var status: HotlineTransferStatus = .unconnected { -// didSet { -// DispatchQueue.main.async { -// self.delegate?.hotlineTransferStatusChanged(client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0) -// } -// } -// } -// -// init?(upload fileURL: URL, address: String, port: UInt16, reference: UInt32) { -// guard let payloadSize = FileManager.default.getFlattenedFileSize(fileURL) else { -// return nil -// } -// -// guard let infoFork = HotlineFileInfoFork(file: fileURL) else { -// return nil -// } -// -// guard let forkSizes = try? FileManager.default.getFileForkSizes(fileURL) else { -// return nil -// } -// -// self.serverAddress = NWEndpoint.Host(address) -// self.serverPort = NWEndpoint.Port(rawValue: port + 1)! -// self.referenceNumber = reference -// self.stage = .magic -// self.payloadSize = UInt32(payloadSize) -// self.fileURL = fileURL -// self.infoForkData = infoFork.data() -// self.dataForkSize = forkSizes.dataForkSize -// self.resourceForkSize = forkSizes.resourceForkSize -// if forkSizes.resourceForkSize > 0 { -// self.fileResourceURL = fileURL.urlForResourceFork() -// } -// else { -// self.fileResourceURL = nil -// } -// } -// -// deinit { -// self.invalidate() -// } -// -// func start() { -// guard self.status == .unconnected else { -// return -// } -// -// let _ = self.fileURL.startAccessingSecurityScopedResource() -// let _ = self.fileResourceURL?.stopAccessingSecurityScopedResource() -// -// self.bytesSent = 0 -// self.connect() -// } -// -// func cancel() { -// self.delegate = nil -// -// if self.status == .unconnected { -// return -// } -// -// self.invalidate() -// -// print("HotlineFileUploadClient: Cancelled upload") -// } -// -// private func connect() { -// self.connection = NWConnection(host: self.serverAddress, port: self.serverPort, using: .tcp) -// self.connection?.stateUpdateHandler = { [weak self] (newState: NWConnection.State) in -// switch newState { -// case .ready: -// self?.status = .connected -// self?.stage = .magic -// self?.send() -// case .waiting(let err): -// print("HotlineFileClient: Waiting", err) -// case .cancelled: -// print("HotlineFileClient: Cancelled") -// self?.invalidate() -// case .failed(let err): -// print("HotlineFileClient: Connection error \(err)") -// switch self?.status { -// case .connecting: -// print("HotlineFileClient: Failed to connect to file transfer server.") -// self?.invalidate() -// self?.status = .failed(.failedToConnect) -// case .connected, .progress(_): -// print("HotlineFileClient: Failed to finish transfer.") -// self?.invalidate() -// self?.status = .failed(.failedToUpload) -// case .completing: -// print("HotlineFileClient: Completed.") -// self?.invalidate() -// self?.status = .completed -// DispatchQueue.main.async { [weak self] in -// if let s = self { -// s.delegate?.hotlineFileUploadComplete(client: s, reference: s.referenceNumber) -// } -// } -// case .completed: -// self?.invalidate() -// default: -// break -// } -// default: -// return -// } -// } -// -// self.status = .connecting -// self.connection?.start(queue: .global()) -// } -// -// private func invalidate() { -// if let c = self.connection { -// c.stateUpdateHandler = nil -// c.cancel() -// -// self.connection = nil -// } -// -// self.stage = .magic -// -// if let fh = self.fileHandle { -// try? fh.close() -// self.fileHandle = nil -// } -// -// self.fileURL.stopAccessingSecurityScopedResource() -// self.fileResourceURL?.stopAccessingSecurityScopedResource() -// } -// -// private func sendComplete() { -// guard let c = self.connection else { -// self.invalidate() -// print("HotlineFileUploadClient: invalid connection to send data.") -// return -// } -// -// self.status = .completing -// -// c.send(content: nil, contentContext: .finalMessage, completion: .contentProcessed({ error in -// })) -// } -// -// private func sendFileData(_ data: Data) { -// guard let c = self.connection else { -// self.invalidate() -// -// print("HotlineFileUploadClient: invalid connection to send data.") -// return -// } -// -// let dataSent: Int = data.count -// c.send(content: data, completion: .contentProcessed({ [weak self] error in -// guard let client = self, -// error == nil else { -// self?.status = .failed(.failedToConnect) -// self?.invalidate() -// return -// } -// -// -// client.bytesSent += dataSent -// client.status = .progress(Double(client.bytesSent) / Double(client.payloadSize)) -// -// client.send() -// })) -// } -// -// private func send() { -// guard let _ = self.connection else { -// self.invalidate() -// print("HotlineFileUploadClient: Invalid connection to send.") -// return -// } -// -// switch self.stage { -// case .magic: -// print("Upload: Starting upload for \(self.fileURL)") -// print("Upload: Sending magic") -// self.status = .progress(0.0) -// -// let magicData = Data(endian: .big) { -// "HTXF".fourCharCode() -// self.referenceNumber -// self.payloadSize -// UInt32.zero -// } -// // var magicData = Data() -// // magicData.appendUInt32("HTXF".fourCharCode()) -// // magicData.appendUInt32(self.referenceNumber) -// // magicData.appendUInt32(self.payloadSize) -// // magicData.appendUInt32(0) -// self.stage = .fileHeader -// self.sendFileData(magicData) -// -// case .fileHeader: -// print("Upload: Sending file header") -// if let header = HotlineFileHeader(file: self.fileURL) { -// self.stage = .fileInfoForkHeader -// self.sendFileData(header.data()) -// } -// -// case .fileInfoForkHeader: -// print("Upload: Sending info fork header") -// let header = HotlineFileForkHeader(type: HotlineFileForkType.info.rawValue, dataSize: UInt32(self.infoForkData.count)) -// self.stage = .fileInfoFork -// self.sendFileData(header.data()) -// -// case .fileInfoFork: -// print("Upload: Sending info fork") -// self.stage = .fileDataForkHeader -// self.sendFileData(self.infoForkData) -// -// case .fileDataForkHeader: -// guard self.dataForkSize > 0 else { -// print("Upload: Data fork empty, skipping to resource fork") -// self.stage = .fileResourceForkHeader -// fallthrough -// } -// -// do { -// let fh = try FileHandle(forReadingFrom: self.fileURL) -// self.fileHandle = fh -// self.stage = .fileDataFork -// -// let header = HotlineFileForkHeader(type: HotlineFileForkType.data.rawValue, dataSize: self.dataForkSize) -// -// print("Upload: Sending data fork header \(self.dataForkSize)") -// self.sendFileData(header.data()) -// } -// catch { -// print("Upload: Error opening data fork", error) -// self.invalidate() -// return -// } -// -// case .fileDataFork: -// guard self.dataForkSize > 0, -// let fh = self.fileHandle else { -// print("Upload: Data fork empty, skipping to resource fork") -// self.stage = .fileResourceForkHeader -// try? self.fileHandle?.close() -// self.fileHandle = nil -// fallthrough -// } -// -// do { -// let fileData = try fh.read(upToCount: 4 * 1024) -// if fileData == nil || fileData?.isEmpty == true { -// print("Upload: Finished data fork") -// self.stage = .fileResourceForkHeader -// try? fh.close() -// self.fileHandle = nil -// fallthrough -// } -// -// print("Upload: Sending data Fork \(String(describing: fileData?.count))") -// self.sendFileData(fileData!) -// } -// catch { -// self.invalidate() -// print("Upload: Error reading data fork", error) -// return -// } -// -// case .fileResourceForkHeader: -// guard self.resourceForkSize > 0, -// let resourceURL = self.fileResourceURL else { -// print("Upload: Skipping resource fork header") -// self.stage = .fileComplete -// fallthrough -// } -// -// print("Upload: Sending resource fork header") -// guard let fh = try? FileHandle(forReadingFrom: resourceURL) else { -// print("Upload: Error reading resource fork") -// self.invalidate() -// return -// } -// -// let header = HotlineFileForkHeader(type: HotlineFileForkType.resource.rawValue, dataSize: self.resourceForkSize) -// -// self.fileHandle = fh -// self.stage = .fileResourceFork -// self.sendFileData(header.data()) -// -// case .fileResourceFork: -// guard self.resourceForkSize > 0, -// let fh = self.fileHandle else { -// print("Upload: Resource fork empty, skipping to completion") -// self.stage = .fileComplete -// try? self.fileHandle?.close() -// self.fileHandle = nil -// fallthrough -// } -// -// do { -// let resourceData = try fh.read(upToCount: 4 * 1024) -// if resourceData == nil || resourceData?.isEmpty == true { -// print("Upload: Finished resource fork") -// self.stage = .fileComplete -// try? self.fileHandle?.close() -// self.fileHandle = nil -// fallthrough -// } -// -// print("Upload: Sending resource fork \(String(describing: resourceData?.count))") -// self.sendFileData(resourceData!) -// } -// catch { -// self.invalidate() -// print("Upload: Error reading resource fork", error) -// return -// } -// break -// -// case .fileComplete: -// print("Upload: Complete!") -// self.sendComplete() -// } -// } -//} - -// MARK: - -// -//class HotlineFilePreviewClient: HotlineTransferClient { -// let serverAddress: NWEndpoint.Host -// let serverPort: NWEndpoint.Port -// let referenceNumber: UInt32 -// let referenceDataSize: UInt32 -// -// weak var delegate: HotlineFilePreviewClientDelegate? = nil -// -// var status: HotlineTransferStatus = .unconnected { -// didSet { -// DispatchQueue.main.async { -// self.delegate?.hotlineTransferStatusChanged(client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0) -// } -// } -// } -// -// private var downloadTask: Task? -// -// init(address: String, port: UInt16, reference: UInt32, size: UInt32) { -// self.serverAddress = NWEndpoint.Host(address) -// self.serverPort = NWEndpoint.Port(rawValue: port + 1)! -// self.referenceNumber = reference -// self.referenceDataSize = size -// } -// -// deinit { -// self.downloadTask?.cancel() -// } -// -// func start() { -// guard status == .unconnected else { -// return -// } -// -// self.downloadTask = Task { -// await self.download() -// } -// } -// -// func cancel() { -// self.downloadTask?.cancel() -// self.downloadTask = nil -// self.delegate = nil -// -// print("HotlineFilePreviewClient: Cancelled preview transfer") -// } -// -// private func download() async { -// await MainActor.run { self.status = .connecting } -// -// do { -// // Connect to file transfer server (already includes +1 in serverPort from init) -// let socket = try await NetSocketNew.connect( -// host: self.serverAddress, -// port: self.serverPort, -// tls: .disabled -// ) -// defer { Task { await socket.close() } } -// -// await MainActor.run { self.status = .connected } -// -// // Send magic header -// let headerData = Data(endian: .big) { -// "HTXF".fourCharCode() -// self.referenceNumber -// UInt32.zero -// UInt32.zero -// } -// try await socket.write(headerData) -// -// await MainActor.run { self.status = .progress(0.0) } -// -// // Download file data with progress updates -// let fileData = try await socket.read(Int(self.referenceDataSize)) { current, total in -// Task { @MainActor [weak self] in -// guard let self else { return } -// self.status = .progress(Double(current) / Double(total)) -// } -// } -// -// print("HotlineFilePreviewClient: Complete") -// await MainActor.run { self.status = .completed } -// -// // Notify delegate -// let reference = self.referenceNumber -// await MainActor.run { -// self.delegate?.hotlineFilePreviewComplete(client: self, reference: reference, data: fileData) -// } -// -// } catch is CancellationError { -// // Already handled in cancel() -// return -// } catch { -// print("HotlineFilePreviewClient: Download failed: \(error)") -// -// let failureStatus: HotlineTransferStatus = await MainActor.run { self.status } -// -// if failureStatus == .connecting { -// await MainActor.run { self.status = .failed(.failedToConnect) } -// } else { -// await MainActor.run { self.status = .failed(.failedToDownload) } -// } -// } -// } -// -// // status mutations are centralized via MainActor.run calls above -//} - -// MARK: - Async Preview Client (New) - -//enum HotlineFilePreviewClientNew { -// typealias ProgressHandler = @Sendable (NetSocketNew.FileProgress) -> Void -// -// struct Configuration { -// /// Chunk size used when reading the preview data stream. -// var chunkSize: Int = 256 * 1024 -// } -// -// /// Download preview data directly from the transfer server. -// /// - Parameters: -// /// - address: Hostname/IP of the Hotline server (preview runs on port+1). -// /// - port: Hotline base port. -// /// - reference: Transfer reference number returned by the server. -// /// - size: Expected payload size in bytes. -// /// - configuration: Optional tuning knobs (currently chunk size). -// /// - progress: Optional callback invoked as bytes stream in. -// /// - Returns: Raw preview data. -// static func download( -// address: String, -// port: UInt16, -// reference: UInt32, -// size: UInt32, -// configuration: Configuration = .init(), -// progress: ProgressHandler? = nil -// ) async throws -> Data { -// let host = NWEndpoint.Host(address) -// guard let transferPort = NWEndpoint.Port(rawValue: port + 1) else { -// throw NetSocketError.invalidPort -// } -// -// let socket = try await NetSocketNew.connect(host: host, port: transferPort, tls: .disabled) -// defer { Task { await socket.close() } } -// -// let header = Data(endian: .big) { -// "HTXF".fourCharCode() -// reference -// UInt32.zero -// UInt32.zero -// } -// -// try await socket.write(header) -// -// guard size > 0 else { -// return Data() -// } -// -// return try await socket.read(Int(size), chunkSize: configuration.chunkSize) { current, total in -// guard let progress else { return } -// let info = NetSocketNew.FileProgress(sent: current, total: total) -// progress(info) -// } -// } -//} - -// MARK: - - -//class HotlineFileDownloadClient: HotlineTransferClient { -// let serverAddress: NWEndpoint.Host -// let serverPort: NWEndpoint.Port -// let referenceNumber: UInt32 -// -// private var connection: NWConnection? -// private var transferStage: HotlineFileTransferStage = .fileHeader -// -// weak var delegate: HotlineFileDownloadClientDelegate? = nil -// -// var status: HotlineTransferStatus = .unconnected { -// didSet { -// DispatchQueue.main.async { -// self.delegate?.hotlineTransferStatusChanged(client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0) -// } -// } -// } -// -// private let referenceDataSize: UInt32 -// private var fileBytes = Data() -// private var fileResourceBytes = Data() -// -// private var fileHeader: HotlineFileHeader? = nil -// private var fileCurrentForkHeader: HotlineFileForkHeader? = nil -// private var fileCurrentForkBytesLeft: Int = 0 -// private var fileInfoFork: HotlineFileInfoFork? = nil -// private var fileHandle: FileHandle? = nil -// private var filePath: String? = nil -// private var fileBytesTransferred: Int = 0 -// private var fileProgress: Progress -// -// init(address: String, port: UInt16, reference: UInt32, size: UInt32) { -// self.serverAddress = NWEndpoint.Host(address) -// self.serverPort = NWEndpoint.Port(rawValue: port + 1)! -// self.referenceNumber = reference -// self.referenceDataSize = size -// self.transferStage = .fileHeader -// self.fileProgress = Progress(totalUnitCount: Int64(size)) -// } -// -// deinit { -// self.invalidate() -// } -// -// func start() { -// guard self.status == .unconnected else { -// return -// } -// -// self.filePath = nil -// self.connect() -// } -// -// func start(to fileURL: URL) { -// guard self.status == .unconnected else { -// return -// } -// -// self.filePath = fileURL.path -// self.connect() -// } -// -// func cancel() { -// self.delegate = nil -// -// if self.status == .unconnected { -// return -// } -// -// // Close file before we try to potentionally delete it. -// if let fh = self.fileHandle { -// try? fh.close() -// self.fileHandle = nil -// } -// -// if let downloadPath = self.filePath { -// print("HotlineFileClient: Deleting file fragment at", downloadPath) -// if FileManager.default.isDeletableFile(atPath: downloadPath) { -// try? FileManager.default.removeItem(atPath: downloadPath) -// } -// self.filePath = nil -// } -// -// self.invalidate() -// -// print("HotlineFileClient: Cancelled transfer") -// } -// -// private func connect() { -// self.connection = NWConnection(host: self.serverAddress, port: self.serverPort, using: .tcp) -// self.connection?.stateUpdateHandler = { [weak self] (newState: NWConnection.State) in -// switch newState { -// case .ready: -// self?.status = .connected -// self?.sendMagic() -// case .waiting(let err): -// print("HotlineFileClient: Waiting", err) -// case .cancelled: -// print("HotlineFileClient: Cancelled") -// self?.invalidate() -// case .failed(let err): -// print("HotlineFileClient: Connection error \(err)") -// switch self?.status { -// case .connecting: -// print("HotlineFileClient: Failed to connect to file transfer server.") -// self?.invalidate() -// self?.status = .failed(.failedToConnect) -// case .connected, .progress(_): -// print("HotlineFileClient: Failed to finish transfer.") -// self?.invalidate() -// self?.status = .failed(.failedToDownload) -// default: -// break -// } -// default: -// return -// } -// } -// -// self.status = .connecting -// self.connection?.start(queue: .global()) -// } -// -// func invalidate() { -// if let c = self.connection { -// c.stateUpdateHandler = nil -// c.cancel() -// -// self.connection = nil -// } -// -// self.fileBytes = Data() -// -// if let fh = self.fileHandle { -// try? fh.close() -// self.fileHandle = nil -// } -// -// self.fileProgress.unpublish() -// } -// -// private func sendMagic() { -// guard let c = connection, self.status == .connected else { -// self.invalidate() -// print("HotlineFileClient: invalid connection to send header.") -// return -// } -// -// let headerData = Data(endian: .big) { -// "HTXF".fourCharCode() -// self.referenceNumber -// UInt32.zero -// UInt32.zero -// } -// -// c.send(content: headerData, completion: .contentProcessed { [weak self] (error) in -// guard let self = self else { -// return -// } -// -// guard error == nil else { -// self.status = .failed(.failedToConnect) -// self.invalidate() -// return -// } -// -// self.status = .progress(0.0) -// self.receiveFile() -// }) -// } -// -// private func receiveFile() { -// guard let c = self.connection else { -// return -// } -// -// c.receive(minimumIncompleteLength: 1, maximumLength: Int(UInt16.max)) { [weak self] (data, context, isComplete, error) in -// guard let self = self else { -// return -// } -// -// guard error == nil else { -// self.status = .failed(.failedToDownload) -// self.invalidate() -// return -// } -// -// if let newData = data, !newData.isEmpty { -// self.fileBytesTransferred += newData.count -// self.fileBytes.append(newData) -// self.fileProgress.completedUnitCount = Int64(self.fileBytesTransferred) -// self.status = .progress(Double(self.fileBytesTransferred) / Double(self.referenceDataSize)) -// } -// -// // See if we need header data still. -// var keepProcessing = false -// repeat { -// keepProcessing = false -// -// switch self.transferStage { -// case .fileHeader: -// if let header = HotlineFileHeader(from: self.fileBytes) { -// self.fileBytes.removeSubrange(0..= infoForkDataSize { -// let infoForkData = self.fileBytes.subdata(in: 0.. 0 { -// if let f = self.fileHandle { -// do { -// var dataToWrite = self.fileBytes -// -// if dataToWrite.count >= self.fileCurrentForkBytesLeft { -// dataToWrite = self.fileBytes.subdata(in: 0.. 0 { -// var dataToWrite = self.fileBytes -// -// if dataToWrite.count >= self.fileCurrentForkBytesLeft { -// dataToWrite = self.fileBytes.subdata(in: 0.. 0 { -// var dataToWrite = self.fileBytes -// -// if dataToWrite.count >= self.fileCurrentForkBytesLeft { -// dataToWrite = self.fileBytes.subdata(in: 0.. 0 { -// let _ = self.writeResourceFork() -// self.fileResourceBytes = Data() -// } -// -// self.status = .completed -// -// if let downloadPath = self.filePath { -// DispatchQueue.main.sync { -// print("POSTING DOWNLOAD FILE FINISHED", downloadPath) -// -// var downloadURL = URL(filePath: downloadPath) -// downloadURL.resolveSymlinksInPath() -// print("FINAL PATH", downloadURL.path) -// -// self.delegate?.hotlineFileDownloadComplete(client: self, reference: self.referenceNumber, at: downloadURL) -// -//#if os(macOS) -// // Bounce dock icon when download completes. Weird this is the only API to do so. -// DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path) -//#endif -// } -// } -// } -// } -// } -// -// private func writeResourceFork() -> Bool { -// guard let filePath = self.filePath else { -// return false -// } -// -// var resolvedFileURL = URL(filePath: filePath) -// resolvedFileURL.resolveSymlinksInPath() -// -// let resourceFilePath = resolvedFileURL.appendingPathComponent("..namedfork/rsrc") -// -// do { -// try self.fileResourceBytes.write(to: resourceFilePath) -// } -// catch { -// return false -// } -// -// return true -// } -// -// private func prepareDownloadFile(name: String) -> Bool { -// var filePath: String -// -// if self.filePath != nil { -// filePath = self.filePath! -// } -// else { -// let folderURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0] -// filePath = folderURL.generateUniqueFilePath(filename: name) -// } -// -// var fileAttributes: [FileAttributeKey: Any] = [:] -// if let creatorCode = self.fileInfoFork?.creator { -// fileAttributes[.hfsCreatorCode] = creatorCode as NSNumber -// } -// if let typeCode = self.fileInfoFork?.type { -// fileAttributes[.hfsTypeCode] = typeCode as NSNumber -// } -// if let createdDate = self.fileInfoFork?.createdDate { -// fileAttributes[.creationDate] = createdDate as NSDate -// } -// if let modifiedDate = self.fileInfoFork?.modifiedDate { -// fileAttributes[.modificationDate] = modifiedDate as NSDate -// } -// if let comment = self.fileInfoFork?.comment { -// if let commentPlistData = try? PropertyListSerialization.data(fromPropertyList: comment, format: .binary, options: 0) { -// fileAttributes[FileAttributeKey(rawValue: "NSFileExtendedAttributes")] = [ -// FileAttributeKey(rawValue: "com.apple.metadata:kMDItemFinderComment"): commentPlistData -// ] -// } -// } -// -// if FileManager.default.createFile(atPath: filePath, contents: nil, attributes: fileAttributes) { -// if let h = FileHandle(forWritingAtPath: filePath) { -// self.filePath = filePath -// self.fileHandle = h -// self.fileProgress.fileURL = URL(filePath: filePath).resolvingSymlinksInPath() -// self.fileProgress.fileOperationKind = .downloading -// self.fileProgress.publish() -// return true -// } -// } -// -// return false -// } -//} - -// MARK: - - - -// MARK: - - -//class HotlineFolderDownloadClient: HotlineTransferClient { -// let serverAddress: NWEndpoint.Host -// let serverPort: NWEndpoint.Port -// let referenceNumber: UInt32 -// -// private var connection: NWConnection? -// private var transferStage: HotlineFolderDownloadStage = .fileHeader -// -// weak var delegate: HotlineFolderDownloadClientDelegate? = nil -// -// var status: HotlineTransferStatus = .unconnected { -// didSet { -// DispatchQueue.main.async { -// self.delegate?.hotlineTransferStatusChanged(client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0) -// } -// } -// } -// -// private let referenceDataSize: UInt32 -// private let folderItemCount: Int -// private var fileBytes = Data() -// private var fileResourceBytes = Data() -// -// private var fileHeader: HotlineFileHeader? = nil -// private var fileCurrentForkHeader: HotlineFileForkHeader? = nil -// private var fileCurrentForkBytesLeft: Int = 0 -// private var fileForksRemaining: Int = 0 -// private var currentFileSize: UInt32 = 0 // Size of current file from server -// private var currentFileBytesRead: Int = 0 // Bytes read for current file -// private var fileInfoFork: HotlineFileInfoFork? = nil -// private var fileHandle: FileHandle? = nil -// private var currentFilePath: String? = nil -// private var fileBytesTransferred: Int = 0 -// private var fileProgress: Progress -// -// private var currentItemNumber: Int = 0 -// private var completedItemCount: Int = 0 // Track actually completed files -// private var folderPath: String? = nil -// private var currentItemRelativePath: [String] = [] -// private var currentFileName: String? = nil -// -// private let FILP_MAGIC: UInt32 = "FILP".fourCharCode() -// -// init(address: String, port: UInt16, reference: UInt32, size: UInt32, itemCount: Int) { -// self.serverAddress = NWEndpoint.Host(address) -// self.serverPort = NWEndpoint.Port(rawValue: port + 1)! -// self.referenceNumber = reference -// self.referenceDataSize = size -// self.folderItemCount = itemCount -// self.transferStage = .fileHeader -// self.fileProgress = Progress(totalUnitCount: Int64(size)) -// } -// -// deinit { -// self.invalidate() -// } -// -// func start() { -// guard self.status == .unconnected else { -// return -// } -// -// self.folderPath = nil -// self.connect() -// } -// -// func start(to folderURL: URL) { -// print("HotlineFolderDownloadClient: start(to:) called with path: \(folderURL.path)") -// guard self.status == .unconnected else { -// print("HotlineFolderDownloadClient: Already connected, status: \(self.status)") -// return -// } -// -// self.folderPath = folderURL.path -// print("HotlineFolderDownloadClient: Calling connect()") -// self.connect() -// } -// -// func cancel() { -// self.delegate = nil -// -// if self.status == .unconnected { -// return -// } -// -// // Close file before we try to potentially delete it. -// if let fh = self.fileHandle { -// try? fh.close() -// self.fileHandle = nil -// } -// -// if let downloadPath = self.folderPath { -// print("HotlineFolderDownloadClient: Deleting folder fragment at", downloadPath) -// if FileManager.default.isDeletableFile(atPath: downloadPath) { -// try? FileManager.default.removeItem(atPath: downloadPath) -// } -// self.folderPath = nil -// } -// -// self.invalidate() -// -// print("HotlineFolderDownloadClient: Cancelled transfer") -// } -// -// private func connect() { -// print("HotlineFolderDownloadClient: connect() called, connecting to \(self.serverAddress):\(self.serverPort)") -// self.connection = NWConnection(host: self.serverAddress, port: self.serverPort, using: .tcp) -// print("HotlineFolderDownloadClient: NWConnection created") -// self.connection?.stateUpdateHandler = { [weak self] (newState: NWConnection.State) in -// switch newState { -// case .ready: -// print("HotlineFolderDownloadClient: Connection ready!") -// self?.status = .connected -// self?.sendMagic() -// case .waiting(let err): -// print("HotlineFolderDownloadClient: Waiting", err) -// case .cancelled: -// print("HotlineFolderDownloadClient: Cancelled") -// self?.invalidate() -// case .failed(let err): -// print("HotlineFolderDownloadClient: Connection error \(err)") -// switch self?.status { -// case .connecting: -// print("HotlineFolderDownloadClient: Failed to connect to file transfer server.") -// self?.invalidate() -// self?.status = .failed(.failedToConnect) -// case .connected, .progress(_): -// print("HotlineFolderDownloadClient: Failed to finish transfer.") -// self?.invalidate() -// self?.status = .failed(.failedToDownload) -// default: -// break -// } -// default: -// return -// } -// } -// -// self.status = .connecting -// self.connection?.start(queue: .global()) -// } -// -// func invalidate() { -// if let c = self.connection { -// c.stateUpdateHandler = nil -// c.cancel() -// -// self.connection = nil -// } -// -// self.fileBytes = Data() -// -// if let fh = self.fileHandle { -// try? fh.close() -// self.fileHandle = nil -// } -// -// self.fileProgress.unpublish() -// } -// -// private func parseItemHeaderPath(_ headerData: Data) -> (type: UInt16, components: [String])? { -// // Need at least: type(2) + count(2) -// guard headerData.count >= 4, -// let type = headerData.readUInt16(at: 0), -// let count = headerData.readUInt16(at: 2) else { return nil } -// -// var ofs = 4 -// var comps: [String] = [] -// for _ in 0..= ofs + 3 else { return nil } -// // per Hotline path encoding: reserved(2) then nameLen(1) then name -// ofs += 2 // reserved == 0 -// let nameLen = Int(headerData.readUInt8(at: ofs)!) -// ofs += 1 -// guard headerData.count >= ofs + nameLen else { return nil } -// let nameData = headerData.subdata(in: ofs..<(ofs + nameLen)) -// ofs += nameLen -// -// let name = String(data: nameData, encoding: .macOSRoman) -// ?? String(data: nameData, encoding: .utf8) -// ?? "" -// comps.append(name) -// } -// return (type, comps) -// } -// -// /// Find and align the buffer to the start of a FILP header. -// /// Returns the number of bytes dropped. -// @discardableResult -// private func alignBufferToFILP() -> Int { -// // We need at least the 4-byte magic to try -// guard self.fileBytes.count >= 4 else { return 0 } -// let magicData = Data([0x46, 0x49, 0x4C, 0x50]) // "FILP" -// if self.fileBytes.starts(with: magicData) { return 0 } -// -// if let r = self.fileBytes.firstRange(of: magicData) { -// let toDrop = r.lowerBound -// if toDrop > 0 { -// print("HotlineFolderDownloadClient: Resync — dropping \(toDrop) stray bytes before FILP") -// self.fileBytes.removeSubrange(0..= 2 else { break } -// let headerLen = Int(self.fileBytes.readUInt16(at: 0)!) -// guard self.fileBytes.count >= 2 + headerLen else { break } -// -// let headerData = self.fileBytes.subdata(in: 2..<(2 + headerLen)) -// -// guard let parsed = parseItemHeaderPath(headerData) else { -// print("HotlineFolderDownloadClient: Invalid item header; waiting for more data") -// break -// } -// -// // Consume the header from the buffer -// self.fileBytes.removeSubrange(0..<(2 + headerLen)) -// -// let itemType = parsed.type -// let comps = parsed.components -// let joinedPath = comps.joined(separator: "/") -// print("HotlineFolderDownloadClient: item type=\(itemType) path=\(joinedPath)") -// -// guard !comps.isEmpty else { -// print("HotlineFolderDownloadClient: Empty path components for item type \(itemType); requesting next item") -// self.sendAction(.nextFile) -// keepProcessing = !self.fileBytes.isEmpty -// break -// } -// -// if itemType == 1 { -// // Folder entries: create the directory locally and continue. -// self.currentItemRelativePath = comps -// -// if let base = self.folderPath { -// var dir = base -// for c in comps { dir = (dir as NSString).appendingPathComponent(c) } -// do { -// try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) -// print("HotlineFolderDownloadClient: Created folder at \(dir)") -// } catch { -// print("HotlineFolderDownloadClient: Failed to create subfolder: \(error)") -// } -// } -// -// self.completedItemCount += 1 -// -// if self.completedItemCount >= self.folderItemCount { -// self.handleAllItemsDownloaded() -// return -// } -// -// self.sendAction(.nextFile) -// keepProcessing = !self.fileBytes.isEmpty -// break -// } -// else if itemType == 0 { -// // File entries include the full path; split parent components from filename. -// self.currentItemRelativePath = Array(comps.dropLast()) -// self.currentFileName = comps.last -// -// self.transferStage = .waitingForFileSize -// print("HotlineFolderDownloadClient: Requesting file download for '\(self.currentFileName ?? "?")'") -// self.sendAction(.sendFile) -// -// if self.fileBytes.count >= 4 { -// keepProcessing = true -// } -// break -// } -// else { -// print("HotlineFolderDownloadClient: Unknown item type \(itemType); skipping") -// self.sendAction(.nextFile) -// keepProcessing = !self.fileBytes.isEmpty -// break -// } -// -// case .waitingForFileSize: -// // Read 4-byte file size that comes before FILP in folder mode -// guard self.fileBytes.count >= 4 else { break } -// let fileSize = self.fileBytes.readUInt32(at: 0)! -// self.fileBytes.removeSubrange(0..<4) -// -// print("HotlineFolderDownloadClient: File size: \(fileSize) bytes") -// -// self.currentFileSize = fileSize -// self.currentFileBytesRead = 0 -// -// // Align to FILP boundary before decoding the file header -// let dropped = self.alignBufferToFILP() -// if dropped > 0 { -// // These bytes were in the stream for this file but not part of FILP. -// // Keep byte-accounting consistent by shrinking the expected size. -// if self.currentFileSize >= UInt32(dropped) { -// self.currentFileSize -= UInt32(dropped) -// } else { -// // Defensive: if weird, treat as zero-size to avoid underflow -// self.currentFileSize = 0 -// } -// } -// -// self.transferStage = .fileHeader -// keepProcessing = true -// -// case .fileHeader: -// // Make sure we're actually at a FILP header -// if self.fileBytes.count >= HotlineFileHeader.DataSize { -// // If the 4-byte magic doesn't match, try one more resync here -// if self.fileBytes.readUInt32(at: 0)! != FILP_MAGIC { -// let dropped = self.alignBufferToFILP() -// if dropped > 0 { -// if self.currentFileSize >= UInt32(dropped) { self.currentFileSize -= UInt32(dropped) } -// } -// // If still not aligned or not enough bytes, wait for more data -// guard self.fileBytes.count >= HotlineFileHeader.DataSize, -// self.fileBytes.readUInt32(at: 0)! == FILP_MAGIC else { break } -// } -// -// if let header = HotlineFileHeader(from: self.fileBytes) { -// // Sanity gate: version and fork count -// if header.format != FILP_MAGIC || header.version == 0 || header.forkCount > 3 { -// print("HotlineFolderDownloadClient: Invalid FILP header (fmt=\(String(format:"0x%08X", header.format)), ver=\(header.version), forks=\(header.forkCount)). Resyncing.") -// // Try resync and wait for more data -// let dropped = self.alignBufferToFILP() -// if dropped > 0 { if self.currentFileSize >= UInt32(dropped) { self.currentFileSize -= UInt32(dropped) } } -// break -// } -// -// self.fileBytes.removeSubrange(0..= self.folderItemCount { -// self.handleAllItemsDownloaded() -// return -// } -// -// // More files to download -// // Check if server has pipelined all remaining data (we've received more than expected total) -// print("HotlineFolderDownloadClient: Completed \(self.completedItemCount)/\(self.folderItemCount) items, requesting next entry") -// self.transferStage = .itemHeader -// self.sendAction(.nextFile) -// keepProcessing = !self.fileBytes.isEmpty -// } -// // File not complete - try to read next fork header -// else if let forkHeader = HotlineFileForkHeader(from: self.fileBytes) { -// // Quick validation: first fork must be INFO, and sizes must be plausible -// let remainingForThisFile = max(0, Int(self.currentFileSize) - self.currentFileBytesRead) -// let plausible = forkHeader.dataSize <= UInt32(remainingForThisFile) -// let knownType = forkHeader.isInfoFork || forkHeader.isDataFork || forkHeader.isResourceFork -// -// if !plausible || (!knownType && self.currentFileBytesRead == HotlineFileHeader.DataSize) { -// print("HotlineFolderDownloadClient: Implausible fork header (type=\(String(format:"0x%08X", forkHeader.forkType)), size=\(forkHeader.dataSize), remaining=\(remainingForThisFile)). Resyncing.") -// let dropped = self.alignBufferToFILP() -// if dropped > 0 { if self.currentFileSize >= UInt32(dropped) { self.currentFileSize -= UInt32(dropped) } } -// break -// } -// -// self.fileBytes.removeSubrange(0..= infoForkDataSize { -// let infoForkData = self.fileBytes.subdata(in: 0.. 0 { -// if let f = self.fileHandle { -// do { -// var dataToWrite = self.fileBytes -// -// if dataToWrite.count >= self.fileCurrentForkBytesLeft { -// dataToWrite = self.fileBytes.subdata(in: 0.. 0 { -// var dataToWrite = self.fileBytes -// -// if dataToWrite.count >= self.fileCurrentForkBytesLeft { -// dataToWrite = self.fileBytes.subdata(in: 0.. 0 { -// var dataToWrite = self.fileBytes -// -// if dataToWrite.count >= self.fileCurrentForkBytesLeft { -// dataToWrite = self.fileBytes.subdata(in: 0.. 0 { -// let _ = self.writeResourceFork() -// self.fileResourceBytes = Data() -// } -// -// self.fileCurrentForkHeader = nil -// self.fileCurrentForkBytesLeft = 0 -// self.fileForksRemaining = 0 -// self.currentFileBytesRead = 0 -// self.currentFileSize = 0 -// self.currentFilePath = nil -// self.fileInfoFork = nil -// self.fileHeader = nil -// self.currentFileName = nil -// } -// -// private func handleAllItemsDownloaded() { -// guard self.status != .completed else { -// return -// } -// -// print("HotlineFolderDownloadClient: All \(self.folderItemCount) items downloaded") -// -// self.invalidate() -// self.status = .completed -// -// if let downloadPath = self.folderPath { -// DispatchQueue.main.sync { -// print("HotlineFolderDownloadClient: Folder download complete", downloadPath) -// -// var downloadURL = URL(filePath: downloadPath) -// downloadURL.resolveSymlinksInPath() -// -// self.delegate?.hotlineFolderDownloadComplete(client: self, reference: self.referenceNumber, at: downloadURL) -// -//#if os(macOS) -// DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path) -//#endif -// } -// } -// } -// -// private func writeResourceFork() -> Bool { -// guard let filePath = self.currentFilePath else { -// return false -// } -// -// var resolvedFileURL = URL(filePath: filePath) -// resolvedFileURL.resolveSymlinksInPath() -// -// let resourceFilePath = resolvedFileURL.urlForResourceFork() -// -// do { -// try self.fileResourceBytes.write(to: resourceFilePath) -// } -// catch { -// return false -// } -// -// return true -// } -// -// private func prepareDownloadFile(name: String) -> Bool { -// var filePath: String -// -// if self.folderPath != nil { -// // Build the full path including subfolders -// var fullPath = self.folderPath! -// for component in self.currentItemRelativePath { -// fullPath = (fullPath as NSString).appendingPathComponent(component) -// } -// -// let folderURL = URL(filePath: fullPath) -// -// // Create folder if it doesn't exist -// if !FileManager.default.fileExists(atPath: folderURL.path) { -// do { -// try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) -// } -// catch { -// print("HotlineFolderDownloadClient: Failed to create folder", error) -// return false -// } -// } -// -// filePath = folderURL.appendingPathComponent(name).path -// print("HotlineFolderDownloadClient: Creating file at \(filePath)") -// } -// else { -// let downloadsURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0] -// filePath = downloadsURL.generateUniqueFilePath(filename: name) -// } -// -// var fileAttributes: [FileAttributeKey: Any] = [:] -// if let creatorCode = self.fileInfoFork?.creator { -// fileAttributes[.hfsCreatorCode] = creatorCode as NSNumber -// } -// if let typeCode = self.fileInfoFork?.type { -// fileAttributes[.hfsTypeCode] = typeCode as NSNumber -// } -// if let createdDate = self.fileInfoFork?.createdDate { -// fileAttributes[.creationDate] = createdDate as NSDate -// } -// if let modifiedDate = self.fileInfoFork?.modifiedDate { -// fileAttributes[.modificationDate] = modifiedDate as NSDate -// } -// if let comment = self.fileInfoFork?.comment { -// if let commentPlistData = try? PropertyListSerialization.data(fromPropertyList: comment, format: .binary, options: 0) { -// fileAttributes[FileAttributeKey(rawValue: "NSFileExtendedAttributes")] = [ -// FileAttributeKey(rawValue: "com.apple.metadata:kMDItemFinderComment"): commentPlistData -// ] -// } -// } -// -// if FileManager.default.createFile(atPath: filePath, contents: nil, attributes: fileAttributes) { -// if let h = FileHandle(forWritingAtPath: filePath) { -// self.currentFilePath = filePath -// self.fileHandle = h -// -// // Only set file progress on first file -// if self.currentItemNumber == 1 && self.folderPath != nil { -// self.fileProgress.fileURL = URL(filePath: self.folderPath!).resolvingSymlinksInPath() -// self.fileProgress.fileOperationKind = .downloading -// self.fileProgress.publish() -// } -// -// return true -// } -// } -// -// return false -// } -//} diff --git a/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift index f065233..10934e7 100644 --- a/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift @@ -1,21 +1,13 @@ -// -// HotlineFileUploadClientNew.swift -// Hotline -// -// Modern async/await file upload client using NetSocketNew -// - import Foundation import Network -/// Modern async/await file upload client for Hotline protocol @MainActor public class HotlineFileUploadClientNew { // MARK: - Configuration public struct Configuration: Sendable { public var chunkSize: Int = 256 * 1024 - public var publishProgress: Bool = true + public init() {} } @@ -186,11 +178,9 @@ public class HotlineFileUploadClientNew { progressHandler?(.transfer(name: filename, size: self.transferSize, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: nil, estimate: nil)) // Configure progress for Finder if enabled - if config.publishProgress { - self.transferProgress.fileURL = fileURL.resolvingSymlinksInPath() - self.transferProgress.fileOperationKind = .uploading - self.transferProgress.publish() - } + self.transferProgress.fileURL = fileURL.resolvingSymlinksInPath() + self.transferProgress.fileOperationKind = .uploading + self.transferProgress.publish() // Send DATA fork if present if dataForkSize > 0 { diff --git a/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift index 9dcb7c9..4ad35e7 100644 --- a/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift @@ -1,10 +1,3 @@ -// -// HotlineFolderDownloadClientNew.swift -// Hotline -// -// Modern async/await folder download client using NetSocketNew -// - import Foundation import Network @@ -15,24 +8,14 @@ public struct HotlineFolderItemProgress: Sendable { public let totalItems: Int } -/// Modern async/await folder download client for Hotline protocol @MainActor public class HotlineFolderDownloadClientNew { - // MARK: - Configuration - - public struct Configuration: Sendable { - public var publishProgress: Bool = true - public init() {} - } - // MARK: - Properties private let serverAddress: String private let serverPort: UInt16 private let referenceNumber: UInt32 - private let config: Configuration - private let transferTotal: Int private let folderItemCount: Int private var transferSize: Int = 0 @@ -48,13 +31,11 @@ public class HotlineFolderDownloadClientNew { port: UInt16, reference: UInt32, size: UInt32, - itemCount: Int, - configuration: Configuration = .init() + itemCount: Int ) { self.serverAddress = address self.serverPort = port self.referenceNumber = reference - self.config = configuration self.transferTotal = Int(size) self.folderItemCount = itemCount @@ -141,18 +122,11 @@ public class HotlineFolderDownloadClientNew { try fm.createDirectory(at: destinationURL, withIntermediateDirectories: true) // Create and publish progress for the entire folder (shows in Finder) - if config.publishProgress { - let progress = Progress(totalUnitCount: Int64(self.transferTotal)) - progress.fileURL = destinationURL - progress.fileOperationKind = .downloading - progress.publish() - self.folderProgress = progress - } - defer { - // Unpublish progress when folder download completes - self.folderProgress?.unpublish() - self.folderProgress = nil - } + let progress = Progress(totalUnitCount: Int64(self.transferTotal)) + progress.fileURL = destinationURL + progress.fileOperationKind = .downloading + progress.publish() + self.folderProgress = progress // Send initial magic header print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Sending HTXF magic") diff --git a/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift index 0b78f33..2efced2 100644 --- a/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift @@ -15,14 +15,12 @@ private struct FolderItem { let isFolder: Bool } -/// Modern async/await folder upload client for Hotline protocol @MainActor public class HotlineFolderUploadClientNew { // MARK: - Configuration public struct Configuration: Sendable { public var chunkSize: Int = 256 * 1024 - public var publishProgress: Bool = true public init() {} } @@ -163,7 +161,6 @@ public class HotlineFolderUploadClientNew { }) progressHandler?(.connected) -// progressHandler?(.transfer(size: 0, total: self.transferTotal, progress: 0.0, speed: nil, estimate: nil)) var completedItemCount = 0 var totalBytesTransferred = 0 @@ -171,7 +168,7 @@ public class HotlineFolderUploadClientNew { var stage: UploadStage = .waitingForNextFile var currentItem: FolderItem? - // State machine loop - matches C++ client's goto-based state machine + // State machine loop while stage != .done { switch stage { @@ -330,7 +327,6 @@ public class HotlineFolderUploadClientNew { } } - // Following C++ client behavior: Build hierarchy with root folder name prepended to all paths // Start from root folder with root name as first path component try walkFolder(at: folderURL, relativePath: [rootFolderName]) totalItems = folderItems.count @@ -339,8 +335,6 @@ public class HotlineFolderUploadClientNew { } private func encodeItemHeader(item: FolderItem) -> Data { - // Following C++ client behavior: Skip the first path component (root folder name) - // The C++ client does: startPtr += 2; startPtr += *startPtr + 1; pathCount--; let strippedPath = item.pathComponents.count > 1 ? Array(item.pathComponents.dropFirst()) : item.pathComponents let strippedPathCount = strippedPath.count @@ -428,22 +422,17 @@ public class HotlineFolderUploadClientNew { bytesUploaded += HotlineFileForkHeader.DataSize // Send INFO fork data - print("HotlineFolderUploadClientNew[\(referenceNumber)]: Sending INFO fork (\(infoForkData.count) bytes)") try await socket.write(infoForkData) bytesUploaded += infoForkData.count // Create per-file progress for Finder - var fileProgress: Progress? - if config.publishProgress { - let progress = Progress(totalUnitCount: Int64(totalFileSize)) - progress.fileURL = fileURL.resolvingSymlinksInPath() - progress.fileOperationKind = Progress.FileOperationKind.uploading - progress.publish() - fileProgress = progress - } + let fileProgress = Progress(totalUnitCount: Int64(totalFileSize)) + fileProgress.fileURL = fileURL.resolvingSymlinksInPath() + fileProgress.fileOperationKind = Progress.FileOperationKind.uploading + fileProgress.publish() defer { - fileProgress?.unpublish() + fileProgress.unpublish() } // Send DATA fork if present @@ -459,7 +448,7 @@ public class HotlineFolderUploadClientNew { let updates = await socket.writeFile(from: fileHandle, length: Int(dataForkSize)) for try await p in updates { // Update per-file Finder progress - fileProgress?.completedUnitCount = Int64(bytesUploaded + p.sent) + fileProgress.completedUnitCount = Int64(bytesUploaded + p.sent) // Calculate overall folder progress let totalBytesNow = totalBytesTransferredSoFar + bytesUploaded + p.sent @@ -503,7 +492,7 @@ public class HotlineFolderUploadClientNew { let updates = await socket.writeFile(from: resourceHandle, length: Int(resourceForkSize)) for try await p in updates { // Update per-file Finder progress - fileProgress?.completedUnitCount = Int64(bytesUploaded + p.sent) + fileProgress.completedUnitCount = Int64(bytesUploaded + p.sent) // Calculate overall folder progress let totalBytesNow = totalBytesTransferredSoFar + bytesUploaded + p.sent @@ -532,7 +521,6 @@ public class HotlineFolderUploadClientNew { bytesUploaded += Int(resourceForkSize) } - print("HotlineFolderUploadClientNew[\(referenceNumber)]: File upload complete, \(bytesUploaded) bytes sent") return bytesUploaded } } diff --git a/Hotline/Library/NetSocket.swift b/Hotline/Library/NetSocket.swift deleted file mode 100644 index b1f1742..0000000 --- a/Hotline/Library/NetSocket.swift +++ /dev/null @@ -1,274 +0,0 @@ - -// NetSocket.swift -// A simple delegate based buffered read/write TCP socket. -// Created by Dustin Mierau - -import Foundation - -protocol NetSocketDelegate: AnyObject { - func netsocketConnected(socket: NetSocket) - func netsocketDisconnected(socket: NetSocket, error: Error?) - func netsocketReceived(socket: NetSocket, bytes: [UInt8]) - func netsocketSent(socket: NetSocket, count: Int) -} - -extension NetSocketDelegate { - func netsocketConnected(socket: NetSocket) {} - func netsocketDisconnected(socket: NetSocket, error: Error?) {} - func netsocketReceived(socket: NetSocket, bytes: [UInt8]) {} - func netsocketSent(socket: NetSocket, count: Int) {} -} - -enum NetSocketStatus { - case disconnected - case connecting - case connected -} - -final class NetSocket: NSObject, StreamDelegate { - weak var delegate: NetSocketDelegate? = nil - - private var output: OutputStream? = nil - private var input: InputStream? = nil - - private var outputBuffer: [UInt8] = [] - private var inputBuffer: [UInt8] = [] - - private var readBuffer: [UInt8] = Array(repeating: 0, count: 4 * 1024) - - public func peek() -> [UInt8] { self.inputBuffer } - public var available: Int { self.inputBuffer.count } - - private var status: NetSocketStatus = .disconnected - - @MainActor public func has(_ length: Int) -> Bool { - return (self.available >= length) - } - - override init() {} - - @MainActor public func connect(host: String, port: Int) { - self.close() - - var outputStream: OutputStream? = nil - var inputStream: InputStream? = nil - - self.status = .connecting - - Stream.getStreamsToHost(withName: host, port: port, inputStream: &inputStream, outputStream: &outputStream) - - self.input = inputStream - self.output = outputStream - - inputStream?.delegate = self - outputStream?.delegate = self - - inputStream?.schedule(in: .current, forMode: .default) - outputStream?.schedule(in: .current, forMode: .default) - - inputStream?.open() - outputStream?.open() - } - - @MainActor public func close(_ err: Error? = nil) { - print("NetSocket: Closed") - - let disconnected = (self.status != .disconnected) - - self.status = .disconnected - - self.input?.delegate = nil - self.output?.delegate = nil - self.input?.close() - self.output?.close() - self.input?.remove(from: .current, forMode: .default) - self.output?.remove(from: .current, forMode: .default) - self.input = nil - self.output = nil - self.inputBuffer = [] - self.outputBuffer = [] - - if disconnected { - self.delegate?.netsocketDisconnected(socket: self, error: err) - } - } - - @MainActor public func write(_ data: Data) { - guard let output = self.output else { - return - } - - self.outputBuffer.append(contentsOf: data) - - if output.hasSpaceAvailable { - self.writeBufferToStream() - } - } - - @MainActor public func write(_ data: [UInt8]) { - guard let output = self.output else { - return - } - - self.outputBuffer.append(contentsOf: data) - - if output.hasSpaceAvailable { - self.writeBufferToStream() - } - } - - @MainActor public func read(count: Int) -> [UInt8] { - guard self.inputBuffer.count > 0, count > 0 else { - return [] - } - - let amountToRead = min(count, self.inputBuffer.count) - let dataRead: [UInt8] = Array(self.inputBuffer[0.. Data { - guard self.inputBuffer.count > 0, count > 0 else { - return Data() - } - - let amountToRead = min(count, self.inputBuffer.count) - - let dataRead: Data = Data(self.inputBuffer[0.. [UInt8] { - guard self.inputBuffer.count > 0 else { - return [] - } - - let dataRead: [UInt8] = Array(self.inputBuffer) - self.inputBuffer = [] - - return dataRead - } - - @MainActor public func readAll() -> Data { - guard self.inputBuffer.count > 0 else { - return Data() - } - - let dataRead: Data = Data(self.inputBuffer) - self.inputBuffer = [] - - return dataRead - } - - @MainActor private func writeBufferToStream() { - guard let output = self.output, self.outputBuffer.count > 0 else { - return - } - - let bytesWritten = output.write(self.outputBuffer, maxLength: self.outputBuffer.count) - print("NetSocket => \(bytesWritten) bytes") - if bytesWritten > 0 { - self.outputBuffer.removeFirst(bytesWritten) - self.delegate?.netsocketSent(socket: self, count: bytesWritten) - } - else if bytesWritten == -1 { - self.close(output.streamError) - } - } - - @MainActor private func readStreamToBuffer() { - guard let input = self.input else { - return - } - - let bytesRead = input.read(&self.readBuffer, maxLength: 4 * 1024) - print("NetSocket <= \(bytesRead) bytes") - if bytesRead > 0 { - self.inputBuffer.append(contentsOf: self.readBuffer[0.. Bool { - return lhs.id == rhs.id - } - - #if os(macOS) - static func getClassicIcon(_ index: Int) -> NSImage? { - return NSImage(named: "Classic/\(index)") - } - #elseif os(iOS) - static func getClassicIcon(_ index: Int) -> UIImage? { - return UIImage(named: "Classic/\(index)") - } - #endif - - // The icon ordering here was painsakenly pulled manually - // from the original Hotline client to display the classic icons - // in the same order as the original client. - static let classicIconSet: [Int] = [ - 141, 149, 150, 151, 172, 184, 204, - 2013, 2036, 2037, 2055, 2400, 2505, 2534, - 2578, 2592, 4004, 4015, 4022, 4104, 4131, - 4134, 4136, 4169, 4183, 4197, 4240, 4247, - 128, 129, 130, 131, 132, 133, 134, - 135, 136, 137, 138, 139, 140, 142, - 143, 144, 145, 146, 147, 148, 152, - 153, 154, 155, 156, 157, 158, 159, - 160, 161, 162, 163, 164, 165, 166, - 167, 168, 169, 170, 171, 173, 174, - 175, 176, 177, 178, 179, 180, 181, - 182, 183, 185, 186, 187, 188, 189, - 190, 191, 192, 193, 194, 195, 196, - 197, 198, 199, 200, 201, 202, 203, - 205, 206, 207, 208, 209, 212, 214, - 215, 220, 233, 236, 237, 243, 244, - 277, 410, 414, 500, 666, 1250, 1251, - 1968, 1969, 2000, 2001, 2002, 2003, 2004, - 2006, 2007, 2008, 2009, 2010, 2011, 2012, - 2014, 2015, 2016, 2017, 2018, 2019, 2020, - 2021, 2022, 2023, 2024, 2025, 2026, 2027, - 2028, 2029, 2030, 2031, 2032, 2033, 2034, - 2035, 2038, 2040, 2041, 2042, 2043, 2044, - 2045, 2046, 2047, 2048, 2049, 2050, 2051, - 2052, 2053, 2054, 2056, 2057, 2058, 2059, - 2060, 2061, 2062, 2063, 2064, 2065, 2066, - 2067, 2070, 2071, 2072, 2073, 2075, 2079, - 2098, 2100, 2101, 2102, 2103, 2104, 2105, - 2106, 2107, 2108, 2109, 2110, 2112, 2113, - 2115, 2116, 2117, 2118, 2119, 2120, 2121, - 2122, 2123, 2124, 2125, 2126, 4150, 2223, - 2401, 2402, 2403, 2404, 2500, 2501, 2502, - 2503, 2504, 2506, 2507, 2528, 2529, 2530, - 2531, 2532, 2533, 2535, 2536, 2537, 2538, - 2539, 2540, 2541, 2542, 2543, 2544, 2545, - 2546, 2547, 2548, 2549, 2550, 2551, 2552, - 2553, 2554, 2555, 2556, 2557, 2558, 2559, - 2560, 2561, 2562, 2563, 2564, 2565, 2566, - 2567, 2568, 2569, 2570, 2571, 2572, 2573, - 2574, 2575, 2576, 2577, 2579, 2580, 2581, - 2582, 2583, 2584, 2585, 2586, 2587, 2588, - 2589, 2590, 2591, 2593, 2594, 2595, 2596, - 2597, 2598, 2599, 2600, 4000, 4001, 4002, - 4003, 4005, 4006, 4007, 4008, 4009, 4010, - 4011, 4012, 4013, 4014, 4016, 4017, 4018, - 4019, 4020, 4021, 4023, 4024, 4025, 4026, - 4027, 4028, 4029, 4030, 4031, 4032, 4033, - 4034, 4035, 4036, 4037, 4038, 4039, 4040, - 4041, 4042, 4043, 4044, 4045, 4046, 4047, - 4048, 4049, 4050, 4051, 4052, 4053, 4054, - 4055, 4056, 4057, 4058, 4059, 4060, 4061, - 4062, 4063, 4064, 4065, 4066, 4067, 4068, - 4069, 4070, 4071, 4072, 4073, 4074, 4075, - 4076, 4077, 4078, 4079, 4080, 4081, 4082, - 4083, 4084, 4085, 4086, 4087, 4088, 4089, - 4090, 4091, 4092, 4093, 4094, 4095, 4096, - 4097, 4098, 4099, 4100, 4101, 4102, 4103, - 4105, 4106, 4107, 4108, 4109, 4110, 4111, - 4112, 4113, 4114, 4115, 4116, 4117, 4118, - 4119, 4120, 4121, 4122, 4123, 4124, 4125, - 4126, 4127, 4128, 4129, 4130, 4132, 4133, - 4135, 4137, 4138, 4139, 4140, 4141, 4142, - 4143, 4144, 4145, 4146, 4147, 4148, 4149, - 4151, 4152, 4153, 4154, 4155, 4156, 4157, - 4158, 4159, 4160, 4161, 4162, 4163, 4164, - 4165, 4166, 4167, 4168, 4170, 4171, 4172, - 4173, 4174, 4175, 4176, 4177, 4178, 4179, - 4180, 4181, 4182, 4184, 4185, 4186, 4187, - 4188, 4189, 4190, 4191, 4192, 4193, 4194, - 4195, 4196, 4198, 4199, 4200, 4201, 4202, - 4203, 4204, 4205, 4206, 4207, 4208, 4209, - 4210, 4211, 4212, 4213, 4214, 4215, 4216, - 4217, 4218, 4219, 4220, 4221, 4222, 4223, - 4224, 4225, 4226, 4227, 4228, 4229, 4230, - 4231, 4232, 4233, 4234, 4235, 4236, 4238, - 4241, 4242, 4243, 4244, 4245, 4246, 4248, - 4249, 4250, 4251, 4252, 4253, 4254, 31337, - 6001, 6002, 6003, 6004, 6005, 6008, 6009, - 6010, 6011, 6012, 6013, 6014, 6015, 6016, - 6017, 6018, 6023, 6025, 6026, 6027, 6028, - 6029, 6030, 6031, 6032, 6033, 6034, 6035 - ] - - var status: HotlineClientStatus = .disconnected - var server: Server? { - didSet { - self.updateServerTitle() - } - } - var serverVersion: UInt16 = 123 - var serverName: String? { - didSet { - self.updateServerTitle() - } - } - var serverTitle: String = "Server" - var username: String = "guest" - var iconID: Int = 414 - var access: HotlineUserAccessOptions? - var agreed: Bool = false - var users: [User] = [] - var accounts: [HotlineAccount] = [] - var chat: [ChatMessage] = [] - var chatInput: String = "" - var messageBoard: [String] = [] - var messageBoardLoaded: Bool = false - var files: [FileInfo] = [] - var filesLoaded: Bool = false - var fileSearchResults: [FileInfo] = [] - var fileSearchStatus: FileSearchStatus = .idle - var fileSearchQuery: String = "" - var fileSearchConfig = FileSearchConfig() - var fileSearchScannedFolders: Int = 0 - var fileSearchCurrentPath: [String]? = nil - @ObservationIgnored private var fileSearchSession: FileSearchSession? = nil - @ObservationIgnored private var fileSearchResultKeys: Set = [] - private struct FileListCacheEntry { - let files: [FileInfo] - let timestamp: Date - } - @ObservationIgnored private var fileListCache: [String: FileListCacheEntry] = [:] - var news: [NewsInfo] = [] - private var newsLookup: [String:NewsInfo] = [:] - var newsLoaded: Bool = false - var accountsLoaded: Bool = false - var instantMessages: [UInt16:[InstantMessage]] = [:] - var transfers: [TransferInfo] = [] - var downloads: [HotlineTransferClient] = [] - var unreadInstantMessages: [UInt16:UInt16] = [:] - var unreadPublicChat: Bool = false - var errorDisplayed: Bool = false - var errorMessage: String? = nil - - @ObservationIgnored var bannerClient: HotlineFilePreviewClient? - @ObservationIgnored private var chatSessionKey: ChatStore.SessionKey? - @ObservationIgnored private var restoredChatSessionKey: ChatStore.SessionKey? - @ObservationIgnored private var chatHistoryObserver: NSObjectProtocol? - @ObservationIgnored private var lastPersistedMessageType: ChatMessageType? - #if os(macOS) - var bannerImage: NSImage? = nil - #elseif os(iOS) - var bannerImage: UIImage? = nil - #endif - - - // MARK: - - - init(trackerClient: HotlineTrackerClient, client: HotlineClient) { - self.trackerClient = trackerClient - self.client = client - self.client.delegate = self - - self.chatHistoryObserver = NotificationCenter.default.addObserver(forName: ChatStore.historyClearedNotification, object: nil, queue: .main) { [weak self] _ in - self?.handleChatHistoryCleared() - } - } - - // MARK: - - - @MainActor func getServerList(tracker: String, port: Int = HotlinePorts.DefaultTrackerPort) async -> [Server] { - var servers: [Server] = [] - print("Hotline.getServerList: Starting fetch from \(tracker):\(port)") - - do { - for try await hotlineServer in self.trackerClient.fetchServers(address: tracker, port: port) { - if let serverName = hotlineServer.name { - servers.append(Server( - name: serverName, - description: hotlineServer.description, - address: hotlineServer.address, - port: Int(hotlineServer.port), - users: Int(hotlineServer.users) - )) - if servers.count % 10 == 0 { - print("Hotline.getServerList: Collected \(servers.count) servers so far...") - } - } - } - } catch { - print("Hotline.getServerList: Error - \(error)") - } - - print("Hotline.getServerList: Returning \(servers.count) servers") - return servers - } - - @MainActor func disconnectTracker() { - // No-op: HotlineTrackerClient now uses async/await and manages - // connections internally. Each fetchServers() call opens and closes - // its own connection automatically. - } - - @MainActor func login(server: Server, username: String, iconID: Int, callback: ((Bool) -> Void)? = nil) { - self.server = server - self.serverName = server.name - self.username = username - self.iconID = iconID - - let key = sessionKey(for: server) - self.chatSessionKey = key - self.restoredChatSessionKey = nil - self.lastPersistedMessageType = nil - self.chat = [] - self.restoreChatHistory(for: key) - - self.client.login(address: server.address, port: server.port, login: server.login, password: server.password, username: username, iconID: UInt16(iconID)) { [weak self] err, serverName, serverVersion in - self?.serverVersion = serverVersion ?? 123 - if serverName != nil { - self?.serverName = serverName - } - - callback?(err == nil) - } - } - - @MainActor func sendUserInfo(username: String, iconID: Int, options: HotlineUserOptions = [], autoresponse: String? = nil) { - self.username = username - self.iconID = iconID - - self.client.sendSetClientUserInfo(username: username, iconID: UInt16(iconID), options: options, autoresponse: autoresponse) - } - - @MainActor func getUserList() { - self.client.sendGetUserList() - } - - @MainActor func disconnect() { - self.client.disconnect() - self.bannerClient?.cancel() - self.fileSearchSession?.cancel() - self.fileSearchSession = nil - self.resetFileSearchState() - } - - deinit { - if let observer = chatHistoryObserver { - NotificationCenter.default.removeObserver(observer) - } - } - - @MainActor func sendAgree() { - self.client.sendAgree(username: self.username, iconID: UInt16(self.iconID), options: .none) - } - - @MainActor func sendInstantMessage(_ text: String, userID: UInt16) { - let message = InstantMessage(direction: .outgoing, text: text.convertingLinksToMarkdown(), type: .message, date: Date()) - - if self.instantMessages[userID] == nil { - self.instantMessages[userID] = [message] - } - else { - self.instantMessages[userID]!.append(message) - } - - self.client.sendInstantMessage(message: text, userID: userID) - - if Prefs.shared.playPrivateMessageSound && Prefs.shared.playPrivateMessageSound { - SoundEffectPlayer.shared.playSoundEffect(.chatMessage) - } - } - - func markPublicChatAsRead() { - self.unreadPublicChat = false - } - - func hasUnreadInstantMessages(userID: UInt16) -> Bool { - return self.unreadInstantMessages[userID] != nil - } - - func markInstantMessagesAsRead(userID: UInt16) { - self.unreadInstantMessages.removeValue(forKey: userID) - } - - @MainActor func sendChat(_ text: String, announce: Bool = false) { - self.client.sendChat(message: text, announce: announce) - } - - @MainActor func getMessageBoard() async -> [String] { - self.messageBoard = await withCheckedContinuation { [weak self] continuation in - self?.client.sendGetMessageBoard() { err, messages in - continuation.resume(returning: (err != nil ? [] : messages)) - } - } - - self.messageBoardLoaded = true - - return self.messageBoard - } - - @MainActor func postToMessageBoard(text: String) { - self.client.sendPostMessageBoard(text: text) - } - - @MainActor func getFileList(path: [String] = [], suppressErrors: Bool = false, preferCache: Bool = false) async -> [FileInfo] { - if preferCache, let cached = cachedFileList(for: path, ttl: fileSearchConfig.cacheTTL, allowStale: false) { - return cached.items - } - - return await withCheckedContinuation { [weak self] continuation in - self?.client.sendGetFileList(path: path, suppressErrors: suppressErrors) { [weak self] files in - let parentFile = self?.findFile(in: self?.files ?? [], at: path) - - var newFiles: [FileInfo] = [] - for f in files { - newFiles.append(FileInfo(hotlineFile: f)) - } - - DispatchQueue.main.async { - if let parent = parentFile { - parent.children = newFiles - } - else if path.isEmpty { - self?.filesLoaded = true - self?.files = newFiles - } - - self?.storeFileListInCache(newFiles, for: path) - continuation.resume(returning: newFiles) - } - } - } - } - - @MainActor func startFileSearch(query: String) { - let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - cancelFileSearch() - return - } - - fileSearchSession?.cancel() - resetFileSearchState() - fileSearchQuery = trimmed - fileSearchStatus = .searching(processed: 0, pending: 0) - fileSearchScannedFolders = 0 - fileSearchCurrentPath = [] - - let session = FileSearchSession(hotline: self, query: trimmed, config: fileSearchConfig) - fileSearchSession = session - - Task { await session.start() } - } - - @MainActor func cancelFileSearch(clearResults: Bool = true) { - guard let session = fileSearchSession else { - if clearResults { - resetFileSearchState() - } else if !fileSearchResults.isEmpty { - fileSearchStatus = .cancelled(processed: fileSearchScannedFolders) - fileSearchCurrentPath = nil - } - return - } - - session.cancel() - fileSearchSession = nil - fileSearchCurrentPath = nil - if clearResults { - resetFileSearchState() - } - else { - fileSearchStatus = .cancelled(processed: fileSearchScannedFolders) - } - } - - @MainActor fileprivate func searchSession(_ session: FileSearchSession, didEmit matches: [FileInfo], processed: Int, pending: Int) { - guard fileSearchSession === session else { - return - } - - var appended: [FileInfo] = [] - for match in matches { - let key = searchPathKey(for: match.path) - if fileSearchResultKeys.insert(key).inserted { - appended.append(match) - } - } - - if !appended.isEmpty { - fileSearchResults.append(contentsOf: appended) - } - - fileSearchScannedFolders = processed - fileSearchStatus = .searching(processed: processed, pending: pending) - } - - @MainActor fileprivate func searchSession(_ session: FileSearchSession, didFocusOn path: [String]) { - guard fileSearchSession === session else { - return - } - - fileSearchCurrentPath = path - } - - @MainActor fileprivate func searchSessionDidFinish(_ session: FileSearchSession, processed: Int, pending: Int, completed: Bool) { - guard fileSearchSession === session else { - return - } - - fileSearchScannedFolders = processed - fileSearchSession = nil - fileSearchCurrentPath = nil - if completed { - fileSearchStatus = .completed(processed: processed) - } else { - fileSearchStatus = .cancelled(processed: processed) - } - } - - private func resetFileSearchState() { - fileSearchResults = [] - fileSearchResultKeys.removeAll(keepingCapacity: true) - fileSearchStatus = .idle - fileSearchQuery = "" - fileSearchScannedFolders = 0 - fileSearchCurrentPath = nil - } - - private func searchPathKey(for path: [String]) -> String { - path.joined(separator: "\u{001F}") - } - - private func shouldBypassFileCache(for path: [String]) -> Bool { - guard let folderName = path.last else { - return false - } - - let trimmed = folderName.trimmingCharacters(in: .whitespacesAndNewlines) - - if trimmed.range(of: "upload", options: [.caseInsensitive]) != nil { - return true - } - - if trimmed.range(of: "dropbox", options: [.caseInsensitive]) != nil { - return true - } - - if trimmed.range(of: "drop box", options: [.caseInsensitive]) != nil { - return true - } - - return false - } - - private func cachedFileList(for path: [String], ttl: TimeInterval, allowStale: Bool) -> (items: [FileInfo], isFresh: Bool)? { - guard ttl > 0 else { - return nil - } - - if shouldBypassFileCache(for: path) { - return nil - } - - let key = searchPathKey(for: path) - guard let entry = fileListCache[key] else { - return nil - } - - let age = Date().timeIntervalSince(entry.timestamp) - let isFresh = age <= ttl - if !allowStale && !isFresh { - return nil - } - - return (entry.files, isFresh) - } - - private func storeFileListInCache(_ files: [FileInfo], for path: [String]) { - guard fileSearchConfig.cacheTTL > 0 else { - return - } - - if shouldBypassFileCache(for: path) { - return - } - - let key = searchPathKey(for: path) - fileListCache[key] = FileListCacheEntry(files: files, timestamp: Date()) - pruneFileListCacheIfNeeded() - } - - private func pruneFileListCacheIfNeeded() { - let limit = fileSearchConfig.maxCachedFolders - guard limit > 0, fileListCache.count > limit else { - return - } - - let excess = fileListCache.count - limit - guard excess > 0 else { return } - - let sortedKeys = fileListCache.sorted { lhs, rhs in - lhs.value.timestamp < rhs.value.timestamp - } - - for index in 0.. (items: [FileInfo], isFresh: Bool)? { - cachedFileList(for: path, ttl: ttl, allowStale: true) - } - - @MainActor func clearFileListCache() { - guard !fileListCache.isEmpty else { - return - } - - fileListCache.removeAll(keepingCapacity: false) - } - - @MainActor func getNewsArticle(id articleID: UInt, at path: [String], flavor: String) async -> String? { - return await withCheckedContinuation { [weak self] continuation in - self?.client.sendGetNewsArticle(id: UInt32(articleID), path: path, flavor: flavor) { articleText in -// let parentNews = self?.findNews(in: self?.news ?? [], at: path) - -// var newCategories: [NewsInfo] = [] -// for category in categories { -// newCategories.append(NewsInfo(hotlineNewsCategory: category)) -// } -// -// if let parent = existingNewsItem { -// parent.children = newCategories -// } -// else if path.isEmpty { -// self?.news = newCategories -// } - - continuation.resume(returning: articleText) - } - } - - } - - @MainActor func getAccounts() async -> [HotlineAccount] { - return await withCheckedContinuation { [weak self] continuation in - self?.client.sendGetAccounts() { articles in - continuation.resume(returning: articles) - } - } - } - - @MainActor func getNewsList(at path: [String] = []) async { - return await withCheckedContinuation { [weak self] continuation in - let parentNewsGroup = self?.findNews(in: self?.news ?? [], at: path) - - // Send a categories request for bundle paths or root (empty path) - if path.isEmpty || parentNewsGroup?.type == .bundle { - print("Hotline: Requesting categories at: /\(path.joined(separator: "/"))") - - self?.client.sendGetNewsCategories(path: path) { @MainActor [weak self] categories in - // Create info for each category returned. - var newCategoryInfos: [NewsInfo] = [] - - // Transform hotline categories into NewsInfo objects. - for category in categories { - var newsCategoryInfo = NewsInfo(hotlineNewsCategory: category) - - if let lookupPath = newsCategoryInfo.lookupPath { - // Merge returned category info with existing category info. - if let existingCategoryInfo = self?.newsLookup[lookupPath] { - print("Hotline: Merging category into existing category at \(lookupPath)") - - existingCategoryInfo.count = newsCategoryInfo.count - existingCategoryInfo.name = newsCategoryInfo.name - existingCategoryInfo.path = newsCategoryInfo.path - existingCategoryInfo.categoryID = newsCategoryInfo.categoryID - newsCategoryInfo = existingCategoryInfo - } - else { - print("Hotline: New category added at \(lookupPath)") - self?.newsLookup[lookupPath] = newsCategoryInfo - } - } - - newCategoryInfos.append(newsCategoryInfo) - } - - if let parent = parentNewsGroup { - parent.children = newCategoryInfos - } - else if path.isEmpty { - self?.newsLoaded = true - self?.news = newCategoryInfos - } - - continuation.resume() - } - } - else { - print("Hotline: Requesting articles at: /\(path.joined(separator: "/"))") - - self?.client.sendGetNewsArticles(path: path) { @MainActor [weak self] articles in - print("Hotline: Organizing news at \(path.joined(separator: "/"))") - - // Create info for each article returned. - var newArticleInfos: [NewsInfo] = [] - - for article in articles { - var newsArticleInfo = NewsInfo(hotlineNewsArticle: article) - - if let lookupPath = newsArticleInfo.lookupPath { - // Merge returned category info with existing category info. - if let existingArticleInfo = self?.newsLookup[lookupPath] { - print("Hotline: Merging article into existing article at \(lookupPath)") - - existingArticleInfo.count = newsArticleInfo.count - existingArticleInfo.name = newsArticleInfo.name - existingArticleInfo.path = newsArticleInfo.path - existingArticleInfo.articleUsername = newsArticleInfo.articleUsername - existingArticleInfo.articleDate = newsArticleInfo.articleDate - existingArticleInfo.articleFlavors = newsArticleInfo.articleFlavors - existingArticleInfo.articleID = newsArticleInfo.articleID - newsArticleInfo = existingArticleInfo - } - else { - print("Hotline: New article added at \(lookupPath)") - self?.newsLookup[lookupPath] = newsArticleInfo - } - } - - newArticleInfos.append(newsArticleInfo) - } - - let organizedNewsArticles: [NewsInfo] = self?.organizeNewsArticles(newArticleInfos) ?? [] - if let parent = parentNewsGroup { - parent.children = organizedNewsArticles - } - - continuation.resume() - } - } - } - } - - func organizeNewsArticles(_ flatArticles: [NewsInfo]) -> [NewsInfo] { - // Place articles under their parent. - var organized: [NewsInfo] = [] - for article in flatArticles { - if let parentLookupPath = article.parentArticleLookupPath, - let parentArticle = self.newsLookup[parentLookupPath] { -// article.expanded = true - if parentArticle.children.firstIndex(of: article) == nil { - article.expanded = true - parentArticle.children.append(article) - } - } - else { - organized.append(article) - } - } - - return organized - } - - @MainActor func postNewsArticle(title: String, body: String, at path: [String], parentID: UInt32 = 0) async -> Bool { - - - return await withCheckedContinuation { [weak self] continuation in - guard let client = self?.client else { - continuation.resume(returning: false) - return - } - - client.postNewsArticle(title: title, text: body, path: path, parentID: parentID, callback: { success in - print("Hotline: News article posted? \(success)") - continuation.resume(returning: success) - }) - } - } - -// @MainActor func getNewsCategories(at path: [String] = []) async -> [NewsInfo] { -// return await withCheckedContinuation { [weak self] continuation in -// guard let client = self?.client else { -// continuation.resume(returning: []) -// return -// } -// -// client.sendGetNewsCategories(path: path) { [weak self] categories in -// let parentNews = self?.findNews(in: self?.news ?? [], at: path) -// -// var newCategories: [NewsInfo] = [] -// for category in categories { -// let categoryInfo: NewsInfo = NewsInfo(hotlineNewsCategory: category) -// newCategories.append(categoryInfo) -// self?.newsLookup[categoryInfo.path.joined(separator: "/")] = categoryInfo -// } -// -// DispatchQueue.main.async { -// if let parent = parentNews { -// parent.children = newCategories -// } -// else if path.isEmpty { -// self?.news = newCategories -// } -// -// continuation.resume(returning: newCategories) -// } -// } -// } -// } - - @MainActor func getArticles(at path: [String]) async -> [NewsInfo] { - return await withCheckedContinuation { [weak self] continuation in - self?.client.sendGetNewsArticles(path: path) { articles in - continuation.resume(returning: []) - } - } - } - - @MainActor func downloadFile(_ fileName: String, path: [String], complete callback: ((TransferInfo, URL) -> Void)? = nil) { - var fullPath: [String] = [] - if path.count > 1 { - fullPath = Array(path[0.. Void)? = nil, complete callback: ((TransferInfo, URL) -> Void)? = nil) { - var fullPath: [String] = [] - if path.count > 1 { - fullPath = Array(path[0.. Void)? = nil) { - var fullPath: [String] = [] - if path.count > 1 { - fullPath = Array(path[0.. Void)? = nil) { - let fileName = fileURL.lastPathComponent - - guard fileURL.isFileURL, !fileName.isEmpty else { - print("NOT A FILE URL?") - return - } - - let filePath = fileURL.path(percentEncoded: false) - - var fileIsDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: filePath, isDirectory: &fileIsDirectory), - fileIsDirectory.boolValue == false else { - print("FILE IS A DIRECTORY?") - return - } - - var fileSize: UInt = 0 - - // Data size - let fileAttributes = try? FileManager.default.attributesOfItem(atPath: filePath) - if let sizeAttribute = fileAttributes?[.size] as? NSNumber { - print("DATA SIZE \(sizeAttribute.uintValue)") - fileSize += sizeAttribute.uintValue - } - - // Resource size - let resourceURL = fileURL.appendingPathComponent("..namedfork/rsrc") - - - print("RESOURCE PATH \(resourceURL)") - let resourceAttributes = try? FileManager.default.attributesOfItem(atPath: resourceURL.path(percentEncoded: false)) - if let sizeAttribute = resourceAttributes?[.size] as? NSNumber { - print("RESOURCE SIZE \(sizeAttribute.uintValue)") - fileSize += sizeAttribute.uintValue - } - - print("FILE SIZE? \(fileSize)") - - guard fileSize > 0 else { - print("FILE IS EMPTY??") - return - } - - print("FILE SIZE: \(fileSize) NAME: \(fileName) PATH: \(path)") - - invalidateFileListCache(for: path, includingAncestors: true) - - self.client.sendUploadFile(name: fileName, path: path) { [weak self] success, uploadReferenceNumber in - print("UPLOAD REFERENCE: \(String(describing: uploadReferenceNumber))") - - if let self = self, - let address = self.server?.address, - let port = self.server?.port, - let referenceNumber = uploadReferenceNumber, - let fileClient = HotlineFileUploadClient(upload: fileURL, address: address, port: UInt16(port), reference: referenceNumber) { - - print("GOING TO UPLOAD") - - fileClient.delegate = self - self.downloads.append(fileClient) - - let transfer = TransferInfo(reference: referenceNumber, title: fileName, size: fileSize) - transfer.uploadCallback = callback - self.transfers.append(transfer) - - fileClient.start() - } - } - - } - - @MainActor func getFileDetails(_ fileName: String, path: [String]) async -> FileDetails? { - var fullPath: [String] = [] - if path.count > 1 { - fullPath = Array(path[0.. Bool { - var fullPath: [String] = [] - if path.count > 1 { - fullPath = Array(path[0.. Void)? = nil) { - var fullPath: [String] = [] - if path.count > 1 { - fullPath = Array(path[0..= 150 else { - return - } - - if self.bannerClient != nil || force { - self.bannerClient?.delegate = nil - self.bannerClient?.cancel() - self.bannerClient = nil - - if force { - self.bannerImage = nil - } - } - - if self.bannerImage != nil { - return - } - - self.client.sendDownloadBanner { [weak self] success, downloadReferenceNumber, downloadTransferSize in - if !success { - return - } - - if - let self = self, - let address = self.server?.address, - let port = self.server?.port, - let referenceNumber = downloadReferenceNumber, - let transferSize = downloadTransferSize { - self.bannerClient = HotlineFilePreviewClient(address: address, port: UInt16(port), reference: referenceNumber, size: UInt32(transferSize)) - self.bannerClient?.delegate = self - self.bannerClient?.start() - } - } - } - - // MARK: - Hotline Delegate - - @MainActor func hotlineStatusChanged(status: HotlineClientStatus) { - print("Hotline: Connection status changed to: \(status)") - let previousStatus = self.status - let previousTitle = self.serverTitle - - if status == .disconnected { - if previousStatus == .loggedIn { - let message = ChatMessage(text: "Disconnected", type: .signOut, date: Date()) - self.recordChatMessage(message, persist: true, display: false) - } - - self.serverVersion = 123 - self.serverName = nil - self.access = nil - - self.fileSearchSession?.cancel() - self.fileSearchSession = nil - self.resetFileSearchState() - - self.users = [] - self.chat = [] - self.messageBoard = [] - self.messageBoardLoaded = false - self.files = [] - self.filesLoaded = false - self.news = [] - self.newsLoaded = false - - self.bannerImage = nil - if let b = self.bannerClient { - b.cancel() - self.bannerClient = nil - } - - self.deleteAllTransfers() - - self.chatSessionKey = nil - self.restoredChatSessionKey = nil - self.lastPersistedMessageType = nil - } - else if status == .loggedIn { - if Prefs.shared.playSounds && Prefs.shared.playLoggedInSound { - SoundEffectPlayer.shared.playSoundEffect(.loggedIn) - } - } - - self.status = status - } - - func hotlineGetUserInfo() -> (String, UInt16) { - return (self.username, UInt16(self.iconID)) - } - - func hotlineReceivedAgreement(text: String) { - let message = ChatMessage(text: text, type: .agreement, date: Date()) - self.recordChatMessage(message, persist: false) - } - - func hotlineReceivedNewsPost(message: String) { - let messageBoardRegex = /([\s\r\n]*[_\-]+[\s\r\n]+)/ - let matches = message.matches(of: messageBoardRegex) - - if matches.count == 1 { - let range = matches[0].range - self.messageBoard.insert(String(message[message.startIndex.. ChatStore.SessionKey { - ChatStore.SessionKey(address: server.address.lowercased(), port: server.port) - } - - private func recordChatMessage(_ message: ChatMessage, persist: Bool = true, display: Bool = true) { - let shouldPersist = persist && message.type != .agreement - if shouldPersist, - message.type == .signOut, - lastPersistedMessageType == .signOut { - return - } - - if display { - self.chat.append(message) - } - - guard shouldPersist, let key = chatSessionKey else { return } - self.lastPersistedMessageType = message.type - let entry = ChatStore.Entry( - id: message.id, - body: message.text, - username: message.username, - type: message.type.storageKey, - date: message.date - ) - let serverName = self.serverName ?? self.server?.name - - Task { - await ChatStore.shared.append(entry: entry, for: key, serverName: serverName) - } - } - - private func restoreChatHistory(for key: ChatStore.SessionKey) { - if restoredChatSessionKey == key { - return - } - - Task { [weak self] in - guard let self else { return } - let result = await ChatStore.shared.loadHistory(for: key) - await MainActor.run { - guard self.chatSessionKey == key, self.restoredChatSessionKey != key else { return } - let currentMessages = self.chat - let historyMessages = result.entries.compactMap { entry -> ChatMessage? in - guard let chatType = ChatMessageType(storageKey: entry.type) else { return nil } - let renderedText: String - if chatType == .message, let username = entry.username, !username.isEmpty { - renderedText = "\(username): \(entry.body)" - } - else { - renderedText = entry.body - } - var message = ChatMessage(text: renderedText, type: chatType, date: entry.date) - message.metadata = entry.metadata - return message - } - self.chat = historyMessages + currentMessages - self.lastPersistedMessageType = historyMessages.last?.type - self.unreadPublicChat = false - self.restoredChatSessionKey = key - } - } - } - - private func handleChatHistoryCleared() { - self.chat = [] - self.unreadPublicChat = false - self.restoredChatSessionKey = nil - self.lastPersistedMessageType = nil - } - - @MainActor func searchChat(query: String) -> [ChatMessage] { - guard !query.isEmpty else { - return [] - } - - // Create a map of all messages by ID to deduplicate (current chat includes restored history) - var messageMap: [UUID: ChatMessage] = [:] - - // Add current in-memory messages (includes both restored history and new messages) - for message in self.chat { - messageMap[message.id] = message - } - - // Filter messages based on query - let filteredMessages = messageMap.values.filter { message in - // Never include agreement messages - if message.type == .agreement { - return false - } - - // Always include disconnect messages to show session boundaries - let isDisconnect = message.type == .signOut - - // Search in text and username - let matchesText = message.text.localizedCaseInsensitiveContains(query) - let matchesUsername = message.username?.localizedCaseInsensitiveContains(query) == true - let matchesQuery = matchesText || matchesUsername - - return isDisconnect || matchesQuery - } - - // Sort by date to maintain chronological order - let sortedMessages = filteredMessages.sorted { $0.date < $1.date } - - // Remove consecutive disconnect messages to avoid visual clutter - var deduplicated: [ChatMessage] = [] - var lastWasDisconnect = false - - for message in sortedMessages { - let isDisconnect = message.type == .signOut - - if isDisconnect && lastWasDisconnect { - // Skip consecutive disconnect messages - continue - } - - deduplicated.append(message) - lastWasDisconnect = isDisconnect - } - - // Remove leading disconnect message - if deduplicated.first?.type == .signOut { - deduplicated.removeFirst() - } - - // Remove trailing disconnect message - if deduplicated.last?.type == .signOut { - deduplicated.removeLast() - } - - return deduplicated - } - - func updateServerTitle() { - self.serverTitle = self.serverName ?? self.server?.name ?? server?.address ?? "Server" - } - - private func addOrUpdateHotlineUser(_ user: HotlineUser) { - print("Hotline: users: \n\(self.users)") - if let i = self.users.firstIndex(where: { $0.id == user.id }) { - print("Hotline: updating user \(self.users[i].name)") - self.users[i] = User(hotlineUser: user) - } - else { - if !self.users.isEmpty { - if Prefs.shared.playSounds && Prefs.shared.playJoinSound { - SoundEffectPlayer.shared.playSoundEffect(.userLogin) - } - } - - print("Hotline: added user: \(user.name)") - self.users.append(User(hotlineUser: user)) - if Prefs.shared.showJoinLeaveMessages { - let chatMessage = ChatMessage(text: "\(user.name) joined", type: .joined, date: Date()) - self.recordChatMessage(chatMessage) - } - } - } - - private func findFile(in filesToSearch: [FileInfo], at path: [String]) -> FileInfo? { - guard !path.isEmpty, !filesToSearch.isEmpty else { return nil } - - let currentName = path[0] - - for file in filesToSearch { - if file.name == currentName { - if path.count == 1 { - return file - } - else if let subfiles = file.children { - let remainingPath = Array(path[1...]) - return self.findFile(in: subfiles, at: remainingPath) - } - } - } - - return nil - } - - private func findNews(in newsToSearch: [NewsInfo], at path: [String]) -> NewsInfo? { - guard !path.isEmpty, !newsToSearch.isEmpty, let currentName = path.first else { return nil } - - for news in newsToSearch { - if news.name == currentName { - if path.count == 1 { - return news - } - else if !news.children.isEmpty { - let remainingPath = Array(path[1...]) - return self.findNews(in: news.children, at: remainingPath) - } - } - } - - return nil - } - - private func findNewsArticle(id articleID: UInt32, at path: [String]) -> NewsInfo? { - guard let parent = self.findNews(in: self.news, at: path), !parent.children.isEmpty else { - return nil - } - - return parent.children.first { child in - guard let childArticleID = child.articleID else { - return false - } - - return child.type == .article && child.articleID == childArticleID - } - } -} - -@MainActor -final class FileSearchSession { - private struct FolderTask { - let path: [String] - let depth: Int - let isHot: Bool - } - - private weak var hotline: Hotline? - private let queryTokens: [String] - private let config: FileSearchConfig - - private var queue: [FolderTask] = [] - private var visited: Set = [] - private var loopHistogram: [String: Int] = [:] - - private var processedCount: Int = 0 - private var currentDelay: TimeInterval - private var isCancelled = false - - init(hotline: Hotline, query: String, config: FileSearchConfig) { - self.hotline = hotline - self.queryTokens = query.lowercased().split(separator: " ").map(String.init) - self.config = config - self.currentDelay = config.initialDelay - } - - func start() async { - guard let hotline else { - return - } - - await Task.yield() - - if !hotline.filesLoaded { - hotline.searchSession(self, didFocusOn: []) - let rootFiles = await hotline.getFileList(path: [], suppressErrors: true, preferCache: true) - processedCount = max(processedCount, 1) - processListing(rootFiles, depth: 0, parentPath: [], parentIsHot: false) - } - else { - hotline.searchSession(self, didFocusOn: []) - processedCount = max(processedCount, 1) - processListing(hotline.files, depth: 0, parentPath: [], parentIsHot: false) - } - - while !queue.isEmpty && !isCancelled { - await Task.yield() - - guard let task = dequeueNextTask() else { - continue - } - if shouldSkip(path: task.path, depth: task.depth) { - hotline.searchSession(self, didEmit: [], processed: processedCount, pending: queue.count) - continue - } - - hotline.searchSession(self, didFocusOn: task.path) - visited.insert(pathKey(for: task.path)) - - if let cached = hotline.cachedListingForSearch(path: task.path, ttl: config.cacheTTL) { - if cached.isFresh { - processedCount += 1 - processListing(cached.items, depth: task.depth, parentPath: task.path, parentIsHot: task.isHot) - continue - } else { - processListing(cached.items, depth: task.depth, parentPath: task.path, parentIsHot: task.isHot) - } - } - - let children = await hotline.getFileList(path: task.path, suppressErrors: true) - processedCount += 1 - - if isCancelled { - break - } - - processListing(children, depth: task.depth, parentPath: task.path, parentIsHot: task.isHot) - - await applyBackoff() - } - - hotline.searchSessionDidFinish(self, processed: processedCount, pending: queue.count, completed: !isCancelled) - } - - func cancel() { - isCancelled = true - } - - private func processListing(_ items: [FileInfo], depth: Int, parentPath: [String], parentIsHot: Bool) { - guard let hotline else { - return - } - - var matches: [FileInfo] = [] - var folderEntries: [(file: FileInfo, isHot: Bool)] = [] - var hasFileMatch = false - - for file in items { - let matchesName = nameMatchesQuery(file.name) - - if matchesName { - matches.append(file) - if !file.isFolder { - hasFileMatch = true - } - } - - if file.isFolder && !file.isAppBundle { - folderEntries.append((file, matchesName)) - } - } - - var remainingBurst = 0 - if config.hotBurstLimit > 0 && (parentIsHot || hasFileMatch) { - remainingBurst = config.hotBurstLimit - } - - if remainingBurst > 0 { - var candidateIndices: [Int] = [] - for index in folderEntries.indices where !folderEntries[index].isHot { - candidateIndices.append(index) - } - - if !candidateIndices.isEmpty { - candidateIndices.shuffle() - for index in candidateIndices { - folderEntries[index].isHot = true - remainingBurst -= 1 - if remainingBurst == 0 { - break - } - } - } - } - - for entry in folderEntries { - enqueueFolder(entry.file, depth: depth + 1, markHot: entry.isHot) - } - - hotline.searchSession(self, didEmit: matches, processed: processedCount, pending: queue.count) - } - - private func enqueueFolder(_ folder: FileInfo, depth: Int, markHot: Bool) { - guard !isCancelled else { return } - guard depth <= config.maxDepth else { return } - - let path = folder.path - let key = pathKey(for: path) - guard !visited.contains(key) else { return } - - if exceedsLoopThreshold(for: path) { - return - } - - queue.append(FolderTask(path: path, depth: depth, isHot: markHot)) - } - - private func dequeueNextTask() -> FolderTask? { - guard !queue.isEmpty else { - return nil - } - - if queue.count == 1 { - return queue.removeFirst() - } - - let currentDepth = queue[0].depth - var lastSameDepthIndex = 0 - var hotIndices: [Int] = [] - - for index in 0.. Bool { - if isCancelled { - return true - } - - if depth > config.maxDepth { - return true - } - - let key = pathKey(for: path) - if visited.contains(key) { - return true - } - - return false - } - - private func nameMatchesQuery(_ name: String) -> Bool { - guard !queryTokens.isEmpty else { return false } - let lowercased = name.lowercased() - return queryTokens.allSatisfy { lowercased.contains($0) } - } - - private func exceedsLoopThreshold(for path: [String]) -> Bool { - guard config.loopRepetitionLimit > 0 else { return false } - guard let last = path.last else { return false } - let parent = path.dropLast() - - guard let previousIndex = parent.lastIndex(of: last) else { - return false - } - - let suffix = Array(path[previousIndex...]) - let key = suffix.joined(separator: "\u{001F}") - let count = (loopHistogram[key] ?? 0) + 1 - loopHistogram[key] = count - return count > config.loopRepetitionLimit - } - - private func pathKey(for path: [String]) -> String { - path.joined(separator: "\u{001F}") - } - - private func applyBackoff() async { - guard !isCancelled else { return } - - if processedCount > config.initialBurstCount { - currentDelay = min(config.maxDelay, max(config.initialDelay, currentDelay * config.backoffMultiplier)) - } - - guard currentDelay > 0 else { - return - } - - let nanoseconds = UInt64(currentDelay * 1_000_000_000) - try? await Task.sleep(nanoseconds: nanoseconds) - } -} diff --git a/Hotline/Models/HotlineState.swift b/Hotline/Models/HotlineState.swift deleted file mode 100644 index d5ab438..0000000 --- a/Hotline/Models/HotlineState.swift +++ /dev/null @@ -1,2468 +0,0 @@ -import SwiftUI - -// MARK: - Connection Status - -enum HotlineConnectionStatus: Equatable { - case disconnected - case connecting - case connected - case loggedIn - case failed(String) - - var isConnected: Bool { - return self == .connected || self == .loggedIn - } -} - -struct FileSearchConfig: Equatable { - /// Number of folders we process before we start applying delay backoff. - var initialBurstCount: Int = 15 - /// Base delay applied between folder requests during the backoff phase. - var initialDelay: TimeInterval = 0.02 - /// Multiplier used to increase the delay after each processed folder in backoff. - var backoffMultiplier: Double = 1.1 - /// Maximum delay cap so searches don't stall out during long walks. - var maxDelay: TimeInterval = 1.0 - /// Maximum recursion depth allowed during file search. - var maxDepth: Int = 40 - /// Limit for repeated folder loops (guards against circular server listings). - var loopRepetitionLimit: Int = 4 - /// Number of child folders that get prioritized after a matching parent is found. - var hotBurstLimit: Int = 2 - /// Maximum age, in seconds, that a cached folder listing is treated as fresh. - var cacheTTL: TimeInterval = 60 * 15 - /// Upper bound on the number of folder listings retained in the cache. - var maxCachedFolders: Int = 1024 * 3 -} - -enum FileSearchStatus: Equatable { - case idle - case searching(processed: Int, pending: Int) - case completed(processed: Int) - case cancelled(processed: Int) - case failed(String) - - var isActive: Bool { - if case .searching = self { - return true - } - return false - } -} - -// MARK: - HotlineState - -@Observable @MainActor -class HotlineState: Equatable { - let id: UUID = UUID() - - nonisolated static func == (lhs: HotlineState, rhs: HotlineState) -> Bool { - return lhs.id == rhs.id - } - - // MARK: - Static Icon Data - - #if os(macOS) - static func getClassicIcon(_ index: Int) -> NSImage? { - return NSImage(named: "Classic/\(index)") - } - #elseif os(iOS) - static func getClassicIcon(_ index: Int) -> UIImage? { - return UIImage(named: "Classic/\(index)") - } - #endif - - static let classicIconSet: [Int] = [ - 141, 149, 150, 151, 172, 184, 204, - 2013, 2036, 2037, 2055, 2400, 2505, 2534, - 2578, 2592, 4004, 4015, 4022, 4104, 4131, - 4134, 4136, 4169, 4183, 4197, 4240, 4247, - 128, 129, 130, 131, 132, 133, 134, - 135, 136, 137, 138, 139, 140, 142, - 143, 144, 145, 146, 147, 148, 152, - 153, 154, 155, 156, 157, 158, 159, - 160, 161, 162, 163, 164, 165, 166, - 167, 168, 169, 170, 171, 173, 174, - 175, 176, 177, 178, 179, 180, 181, - 182, 183, 185, 186, 187, 188, 189, - 190, 191, 192, 193, 194, 195, 196, - 197, 198, 199, 200, 201, 202, 203, - 205, 206, 207, 208, 209, 212, 214, - 215, 220, 233, 236, 237, 243, 244, - 277, 410, 414, 500, 666, 1250, 1251, - 1968, 1969, 2000, 2001, 2002, 2003, 2004, - 2006, 2007, 2008, 2009, 2010, 2011, 2012, - 2014, 2015, 2016, 2017, 2018, 2019, 2020, - 2021, 2022, 2023, 2024, 2025, 2026, 2027, - 2028, 2029, 2030, 2031, 2032, 2033, 2034, - 2035, 2038, 2040, 2041, 2042, 2043, 2044, - 2045, 2046, 2047, 2048, 2049, 2050, 2051, - 2052, 2053, 2054, 2056, 2057, 2058, 2059, - 2060, 2061, 2062, 2063, 2064, 2065, 2066, - 2067, 2070, 2071, 2072, 2073, 2075, 2079, - 2098, 2100, 2101, 2102, 2103, 2104, 2105, - 2106, 2107, 2108, 2109, 2110, 2112, 2113, - 2115, 2116, 2117, 2118, 2119, 2120, 2121, - 2122, 2123, 2124, 2125, 2126, 4150, 2223, - 2401, 2402, 2403, 2404, 2500, 2501, 2502, - 2503, 2504, 2506, 2507, 2528, 2529, 2530, - 2531, 2532, 2533, 2535, 2536, 2537, 2538, - 2539, 2540, 2541, 2542, 2543, 2544, 2545, - 2546, 2547, 2548, 2549, 2550, 2551, 2552, - 2553, 2554, 2555, 2556, 2557, 2558, 2559, - 2560, 2561, 2562, 2563, 2564, 2565, 2566, - 2567, 2568, 2569, 2570, 2571, 2572, 2573, - 2574, 2575, 2576, 2577, 2579, 2580, 2581, - 2582, 2583, 2584, 2585, 2586, 2587, 2588, - 2589, 2590, 2591, 2593, 2594, 2595, 2596, - 2597, 2598, 2599, 2600, 4000, 4001, 4002, - 4003, 4005, 4006, 4007, 4008, 4009, 4010, - 4011, 4012, 4013, 4014, 4016, 4017, 4018, - 4019, 4020, 4021, 4023, 4024, 4025, 4026, - 4027, 4028, 4029, 4030, 4031, 4032, 4033, - 4034, 4035, 4036, 4037, 4038, 4039, 4040, - 4041, 4042, 4043, 4044, 4045, 4046, 4047, - 4048, 4049, 4050, 4051, 4052, 4053, 4054, - 4055, 4056, 4057, 4058, 4059, 4060, 4061, - 4062, 4063, 4064, 4065, 4066, 4067, 4068, - 4069, 4070, 4071, 4072, 4073, 4074, 4075, - 4076, 4077, 4078, 4079, 4080, 4081, 4082, - 4083, 4084, 4085, 4086, 4087, 4088, 4089, - 4090, 4091, 4092, 4093, 4094, 4095, 4096, - 4097, 4098, 4099, 4100, 4101, 4102, 4103, - 4105, 4106, 4107, 4108, 4109, 4110, 4111, - 4112, 4113, 4114, 4115, 4116, 4117, 4118, - 4119, 4120, 4121, 4122, 4123, 4124, 4125, - 4126, 4127, 4128, 4129, 4130, 4132, 4133, - 4135, 4137, 4138, 4139, 4140, 4141, 4142, - 4143, 4144, 4145, 4146, 4147, 4148, 4149, - 4151, 4152, 4153, 4154, 4155, 4156, 4157, - 4158, 4159, 4160, 4161, 4162, 4163, 4164, - 4165, 4166, 4167, 4168, 4170, 4171, 4172, - 4173, 4174, 4175, 4176, 4177, 4178, 4179, - 4180, 4181, 4182, 4184, 4185, 4186, 4187, - 4188, 4189, 4190, 4191, 4192, 4193, 4194, - 4195, 4196, 4198, 4199, 4200, 4201, 4202, - 4203, 4204, 4205, 4206, 4207, 4208, 4209, - 4210, 4211, 4212, 4213, 4214, 4215, 4216, - 4217, 4218, 4219, 4220, 4221, 4222, 4223, - 4224, 4225, 4226, 4227, 4228, 4229, 4230, - 4231, 4232, 4233, 4234, 4235, 4236, 4238, - 4241, 4242, 4243, 4244, 4245, 4246, 4248, - 4249, 4250, 4251, 4252, 4253, 4254, 31337, - 6001, 6002, 6003, 6004, 6005, 6008, 6009, - 6010, 6011, 6012, 6013, 6014, 6015, 6016, - 6017, 6018, 6023, 6025, 6026, 6027, 6028, - 6029, 6030, 6031, 6032, 6033, 6034, 6035 - ] - - // MARK: - Observable State - - var status: HotlineConnectionStatus = .disconnected - var server: Server? { - didSet { - self.updateServerTitle() - } - } - var serverVersion: UInt16 = 123 - var serverName: String? { - didSet { - self.updateServerTitle() - } - } - var serverTitle: String = "Server" - var username: String = "guest" - var iconID: Int = 414 - var access: HotlineUserAccessOptions? - var agreed: Bool = false - - // Users - var users: [User] = [] - - // Chat - var chat: [ChatMessage] = [] - var chatInput: String = "" - var unreadPublicChat: Bool = false - - // Instant Messages - var instantMessages: [UInt16:[InstantMessage]] = [:] - var unreadInstantMessages: [UInt16:UInt16] = [:] - - // Message Board - var messageBoard: [String] = [] - var messageBoardLoaded: Bool = false - - // News - var news: [NewsInfo] = [] - var newsLoaded: Bool = false - private var newsLookup: [String:NewsInfo] = [:] - - // Files - var files: [FileInfo] = [] - var filesLoaded: Bool = false - - // Accounts - var accounts: [HotlineAccount] = [] - var accountsLoaded: Bool = false - - // Banner - #if os(macOS) - var bannerImage: Image? = nil - var bannerColors: ColorArt? = nil - #elseif os(iOS) - var bannerImage: UIImage? = nil - #endif - - // Transfers (now stored globally in AppState) - /// Returns all transfers associated with this server - var transfers: [TransferInfo] { - AppState.shared.transfers.filter { $0.serverID == self.id } - } - - // Legacy transfer tracking (for old delegate-based downloads) -// @ObservationIgnored private var downloads: [HotlineTransferClient] = [] - @ObservationIgnored private var bannerDownloadTask: Task? = nil - - // File Search - var fileSearchResults: [FileInfo] = [] - var fileSearchStatus: FileSearchStatus = .idle - var fileSearchQuery: String = "" - var fileSearchConfig = FileSearchConfig() - var fileSearchScannedFolders: Int = 0 - var fileSearchCurrentPath: [String]? = nil - @ObservationIgnored private var fileSearchSession: HotlineStateFileSearchSession? = nil - @ObservationIgnored private var fileSearchResultKeys: Set = [] - - // File List Cache - private struct FileListCacheEntry { - let files: [FileInfo] - let timestamp: Date - } - @ObservationIgnored private var fileListCache: [String: FileListCacheEntry] = [:] - - // Error Display - var errorDisplayed: Bool = false - var errorMessage: String? = nil - - // MARK: - Private State - - @ObservationIgnored private var client: HotlineClientNew? - @ObservationIgnored private var eventTask: Task? - @ObservationIgnored private var chatSessionKey: ChatStore.SessionKey? - @ObservationIgnored private var restoredChatSessionKey: ChatStore.SessionKey? - @ObservationIgnored private var chatHistoryObserver: NSObjectProtocol? - @ObservationIgnored private var lastPersistedMessageType: ChatMessageType? - - // MARK: - Initialization - - init() { - self.chatHistoryObserver = NotificationCenter.default.addObserver( - forName: ChatStore.historyClearedNotification, - object: nil, - queue: .main - ) { @MainActor [weak self] _ in - self?.handleChatHistoryCleared() - } - } - - deinit { - if let observer = self.chatHistoryObserver { - NotificationCenter.default.removeObserver(observer) - } - } - - // MARK: - Connection - - @MainActor - func login(server: Server, username: String, iconID: Int) async throws { - print("HotlineState.login(): Starting login to \(server.address):\(server.port)") - self.server = server - self.username = username - self.iconID = iconID - self.status = .connecting - print("HotlineState.login(): Status set to connecting") - - // Set up chat session - let key = self.sessionKey(for: server) - self.chatSessionKey = key - self.restoredChatSessionKey = nil - self.lastPersistedMessageType = nil - self.chat = [] - self.restoreChatHistory(for: key) - print("HotlineState.login(): Chat session set up") - - do { - // Connect and login - let loginInfo = HotlineLoginInfo( - login: server.login, - password: server.password, - username: username, - iconID: UInt16(iconID) - ) - - print("HotlineState.login(): Calling HotlineClientNew.connect()...") - let client = try await HotlineClientNew.connect( - host: server.address, - port: UInt16(server.port), - login: loginInfo - ) - print("HotlineState.login(): HotlineClientNew.connect() returned") - - self.client = client - print("HotlineState.login(): Client stored") - - // Get server info - print("HotlineState.login(): Getting server info...") - if let serverInfo = await client.server { - self.serverVersion = serverInfo.version - if !serverInfo.name.isEmpty { - self.serverName = serverInfo.name - } - print("HotlineState.login(): Server info retrieved: \(serverInfo.name) v\(serverInfo.version)") - } - - self.status = .connected - print("HotlineState.login(): Status set to connected") - - // Request initial data before starting event loop - print("HotlineState.login(): Requesting user list...") - try await self.getUserList() - - self.status = .loggedIn - print("HotlineState.login(): Status set to loggedIn") - - if Prefs.shared.playSounds && Prefs.shared.playLoggedInSound { - SoundEffectPlayer.shared.playSoundEffect(.loggedIn) - } - - print("HotlineState.login(): Connected to \(self.serverTitle)") - print("HotlineState.login(): Scheduling post-login tasks...") - - // Defer event loop and post-login work to avoid layout recursion - // This allows login() to return and SwiftUI to complete its layout pass - // before we start receiving events that trigger state changes - Task { @MainActor in - print("HotlineState: Post-login: Starting event loop...") - self.startEventLoop() - - print("HotlineState: Post-login: Sending preferences...") - try? await self.sendUserPreferences() - - print("HotlineState: Post-login: Downloading banner...") - self.downloadBanner() - } - - } catch { - print("HotlineState.login(): Login failed with error: \(error)") - if let client = self.client { - await client.disconnect() - self.client = nil - } - self.status = .failed(error.localizedDescription) - self.errorDisplayed = true - self.errorMessage = error.localizedDescription - throw error - } - } - - /// Disconnect from the server (user-initiated) - @MainActor - func disconnect() async { - print("HotlineState.disconnect(): Called") - guard let client = self.client else { - print("HotlineState.disconnect(): No client, returning") - return - } - - // Stop event loop - print("HotlineState.disconnect(): Cancelling event task...") - self.eventTask?.cancel() - self.eventTask = nil - print("HotlineState.disconnect(): Event task cancelled") - - // Explicitly close the connection - print("HotlineState.disconnect(): Calling client.disconnect()...") - await client.disconnect() - print("HotlineState.disconnect(): client.disconnect() returned") - - // Clean up state - print("HotlineState.disconnect(): Calling handleConnectionClosed()...") - self.handleConnectionClosed() - print("HotlineState.disconnect(): disconnect() complete") - } - - /// Handle connection closure (server-initiated or after user disconnect) - @MainActor - private func handleConnectionClosed() { - print("HotlineState: handleConnectionClosed() entered") - guard self.client != nil else { - print("HotlineState: handleConnectionClosed() - client already nil, returning") - return - } - - print("HotlineState: Handling connection closure - recording chat...") - - // Record disconnect in chat history - if self.status == .loggedIn { - let message = ChatMessage(text: "Disconnected", type: .signOut, date: Date()) - self.recordChatMessage(message, persist: true, display: false) - } - - print("HotlineState: Cancelling banner and downloads...") - - self.bannerDownloadTask?.cancel() - self.bannerDownloadTask = nil - - // Cancel all downloads (both old delegate-based and new async downloads) -// self.downloads = [] - - // Cancel all transfers for this server -// self.cancelAllDownloads() - - // Cancel file search - self.fileSearchSession?.cancel() - self.fileSearchSession = nil - - // Clear client reference - self.client = nil - - print("HotlineState: Resetting state properties...") - - // Reset state immediately (constraint loop was caused by something else) - self.status = .disconnected - self.serverVersion = 123 - self.serverName = nil - self.access = nil - self.agreed = false - self.users = [] - self.chat = [] - self.instantMessages = [:] - self.unreadInstantMessages = [:] - self.unreadPublicChat = false - self.messageBoard = [] - self.messageBoardLoaded = false - self.news = [] - self.newsLoaded = false - self.newsLookup = [:] - self.files = [] - self.filesLoaded = false - self.accounts = [] - self.accountsLoaded = false - self.bannerImage = nil - self.bannerColors = nil - - print("HotlineState: Resetting file search...") - self.resetFileSearchState() - - self.chatSessionKey = nil - self.restoredChatSessionKey = nil - self.lastPersistedMessageType = nil - - print("HotlineState: Disconnected") - } - - @MainActor - func downloadBanner(force: Bool = false) { - guard self.serverVersion >= 150 else { - return - } - - if force { - self.bannerDownloadTask?.cancel() - self.bannerDownloadTask = nil - self.bannerImage = nil - self.bannerColors = nil - } else if self.bannerDownloadTask != nil || self.bannerImage != nil { - return - } - - let task = Task { @MainActor [weak self] in - defer { - self?.bannerDownloadTask = nil - } - - guard let self else { return } - guard let client = self.client, - let server = self.server, - let result = try? await client.downloadBanner(), - let address = server.address as String?, - let port = server.port as Int? - else { - return - } - - do { - print("HotlineState: Banner download info - reference: \(result.referenceNumber), transferSize: \(result.transferSize)") - - let previewClient = HotlineFilePreviewClientNew( - fileName: "banner", - address: address, - port: UInt16(port), - reference: result.referenceNumber, - size: UInt32(result.transferSize) - ) - - let fileURL = try await previewClient.preview() - defer { - previewClient.cleanup() - } - - guard self.client != nil else { return } - - let data = try Data(contentsOf: fileURL) - print("HotlineState: Banner download complete, data size: \(data.count) bytes") - -#if os(macOS) - guard let image = NSImage(data: data) else { - print("HotlineState: Failed to create NSImage from banner data") - return - } - let blah = Image(nsImage: image) -#elseif os(iOS) - guard let image = UIImage(data: data) else { - print("HotlineState: Failed to create UIImage from banner data") - return - } - self.bannerImage = Image(uiImage: image) -#endif - self.bannerImage = blah - self.bannerColors = ColorArt.analyze(image: image) - - } catch { - print("HotlineState: Banner download failed: \(error)") - } - } - - self.bannerDownloadTask = task - } - - // MARK: - Event Loop - - private func startEventLoop() { - print("HotlineState.startEventLoop(): Called") - guard let client = self.client else { - print("HotlineState.startEventLoop(): No client, returning") - return - } - - print("HotlineState.startEventLoop(): Creating event loop task") - self.eventTask = Task { @MainActor [weak self, client] in - guard let self else { - print("HotlineState.startEventLoop(): Self is nil in task, exiting") - return - } - - print("HotlineState.startEventLoop(): Event loop started, awaiting events...") - for await event in client.events { - print("HotlineState.startEventLoop(): Received event: \(event)") - self.handleEvent(event) - } - - // Event stream ended - server disconnected us - print("HotlineState.startEventLoop(): Event stream ended, calling handleConnectionClosed()...") - self.handleConnectionClosed() - print("HotlineState.startEventLoop(): handleConnectionClosed() returned, event loop task complete") - } - print("HotlineState.startEventLoop(): Event loop task created") - } - - @MainActor - private func handleEvent(_ event: HotlineEvent) { - switch event { - case .chatMessage(let text): - self.handleChatMessage(text) - - case .userChanged(let user): - self.handleUserChanged(user) - - case .userDisconnected(let userID): - self.handleUserDisconnected(userID) - - case .serverMessage(let message): - self.handleServerMessage(message) - - case .privateMessage(let userID, let message): - self.handlePrivateMessage(userID: userID, message: message) - - case .newsPost(let message): - self.handleNewsPost(message) - - case .agreementRequired(let text): - let message = ChatMessage(text: text, type: .agreement, date: Date()) - self.recordChatMessage(message, persist: false) - - case .userAccess(let options): - self.access = options - print("HotlineState: Got access options") - HotlineUserAccessOptions.printAccessOptions(options) - } - } - - // MARK: - Chat - - @MainActor - func sendChat(_ text: String, announce: Bool = false) async throws { - guard let client = self.client else { - throw HotlineClientError.notConnected - } - - try await client.sendChat(text, announce: announce) - } - - @MainActor - func sendInstantMessage(_ text: String, userID: UInt16) async throws { - guard let client = self.client else { - throw HotlineClientError.notConnected - } - - let message = InstantMessage( - direction: .outgoing, - text: text.convertingLinksToMarkdown(), - type: .message, - date: Date() - ) - - if self.instantMessages[userID] == nil { - self.instantMessages[userID] = [message] - } else { - self.instantMessages[userID]!.append(message) - } - - try await client.sendInstantMessage(text, to: userID) - - if Prefs.shared.playPrivateMessageSound { - SoundEffectPlayer.shared.playSoundEffect(.chatMessage) - } - } - - @MainActor - func sendAgree() async throws { - guard let client = self.client else { - throw HotlineClientError.notConnected - } - - try await client.sendAgree() - self.agreed = true - } - - @MainActor - /// Send current user preferences from Prefs to the server - func sendUserPreferences() async throws { - var options: HotlineUserOptions = [] - - if Prefs.shared.refusePrivateMessages { - options.update(with: .refusePrivateMessages) - } - - if Prefs.shared.refusePrivateChat { - options.update(with: .refusePrivateChat) - } - - if Prefs.shared.enableAutomaticMessage { - options.update(with: .automaticResponse) - } - - print("HotlineState.sendUserPreferences(): Updating user info with server") - - try await self.sendUserInfo( - username: Prefs.shared.username, - iconID: Prefs.shared.userIconID, - options: options, - autoresponse: Prefs.shared.automaticMessage - ) - } - - func sendUserInfo(username: String, iconID: Int, options: HotlineUserOptions = [], autoresponse: String? = nil) async throws { - guard let client = self.client else { - throw HotlineClientError.notConnected - } - - self.username = username - self.iconID = iconID - - try await client.setClientUserInfo( - username: username, - iconID: UInt16(iconID), - options: options, - autoresponse: autoresponse - ) - } - - func markPublicChatAsRead() { - self.unreadPublicChat = false - } - - func hasUnreadInstantMessages(userID: UInt16) -> Bool { - return self.unreadInstantMessages[userID] != nil - } - - func markInstantMessagesAsRead(userID: UInt16) { - self.unreadInstantMessages.removeValue(forKey: userID) - } - - @MainActor - func searchChat(query: String) -> [ChatMessage] { - guard !query.isEmpty else { - return [] - } - - // Create a map of all messages by ID to deduplicate - var messageMap: [UUID: ChatMessage] = [:] - - // Add current in-memory messages - for message in self.chat { - messageMap[message.id] = message - } - - // Filter messages based on query - let filteredMessages = messageMap.values.filter { message in - // Never include agreement messages - if message.type == .agreement { - return false - } - - // Always include disconnect messages to show session boundaries - let isDisconnect = message.type == .signOut - - // Search in text and username - let matchesText = message.text.localizedCaseInsensitiveContains(query) - let matchesUsername = message.username?.localizedCaseInsensitiveContains(query) == true - let matchesQuery = matchesText || matchesUsername - - return isDisconnect || matchesQuery - } - - // Sort by date to maintain chronological order - let sortedMessages = filteredMessages.sorted { $0.date < $1.date } - - // Remove consecutive disconnect messages to avoid visual clutter - var deduplicated: [ChatMessage] = [] - var lastWasDisconnect = false - - for message in sortedMessages { - let isDisconnect = message.type == .signOut - - if isDisconnect && lastWasDisconnect { - continue - } - - deduplicated.append(message) - lastWasDisconnect = isDisconnect - } - - // Remove leading disconnect message - if deduplicated.first?.type == .signOut { - deduplicated.removeFirst() - } - - // Remove trailing disconnect message - if deduplicated.last?.type == .signOut { - deduplicated.removeLast() - } - - return deduplicated - } - - // MARK: - Users - - @MainActor - func getUserList() async throws { - guard let client = self.client else { - throw HotlineClientError.notConnected - } - - let hotlineUsers = try await client.getUserList() - self.users = hotlineUsers.map { User(hotlineUser: $0) } - } - - // MARK: - Files (Basic) - - @MainActor - @discardableResult - func getFileList(path: [String] = [], suppressErrors: Bool = false, preferCache: Bool = false) async throws -> [FileInfo] { - guard let client = self.client else { - throw HotlineClientError.notConnected - } - - // Check cache first if preferred - if preferCache, let cached = self.cachedFileList(for: path, ttl: self.fileSearchConfig.cacheTTL, allowStale: false) { - return cached.items - } - - let hotlineFiles = try await client.getFileList(path: path) - let newFiles = hotlineFiles.map { FileInfo(hotlineFile: $0) } - - // Update UI state - if path.isEmpty { - self.filesLoaded = true - self.files = newFiles - } else { - // Update parent's children - let parentFile = self.findFile(in: self.files, at: path) - parentFile?.children = newFiles - } - - // Cache the result - self.storeFileListInCache(newFiles, for: path) - - return newFiles - } - - @MainActor - func getFileDetails(_ fileName: String, path: [String]) async throws -> FileDetails? { - guard let client = self.client else { - throw HotlineClientError.notConnected - } - - var fullPath: [String] = [] - if path.count > 1 { - fullPath = Array(path[0.. Bool { - guard let client = self.client else { - throw HotlineClientError.notConnected - } - - var fullPath: [String] = [] - if path.count > 1 { - fullPath = Array(path[0.. Void)? = nil, complete callback: ((TransferInfo, URL) -> Void)? = nil) { -// guard let client = self.client else { return } -// -// var fullPath: [String] = [] -// if path.count > 1 { -// fullPath = Array(path[0.. Void)? = nil, complete callback: ((TransferInfo) -> Void)? = nil) { - guard let client = self.client else { return } - - var fullPath: [String] = [] - if path.count > 1 { - fullPath = Array(path[0.. Void)? = nil) { -// guard let client = self.client else { return } -// -// var fullPath: [String] = [] -// if path.count > 1 { -// fullPath = Array(path[0.. Void)? = nil, - itemProgress itemProgressCallback: ((TransferInfo, String, Int, Int) -> Void)? = nil, - complete callback: ((TransferInfo) -> Void)? = nil - ) { - guard let client = self.client else { return } - - var fullPath: [String] = [] - if path.count > 1 { - fullPath = Array(path[0.. Void)? = nil, - itemProgress itemProgressCallback: ((TransferInfo, String, Int, Int) -> Void)? = nil, - complete callback: ((TransferInfo) -> Void)? = nil - ) { - guard let client = self.client else { return } - - let folderName = folderURL.lastPathComponent - - guard folderURL.isFileURL, !folderName.isEmpty else { - print("HotlineState: Not a valid folder URL") - return - } - - let folderPath = folderURL.path(percentEncoded: false) - - var isDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: folderPath, isDirectory: &isDirectory), - isDirectory.boolValue == true else { - print("HotlineState: URL is not a folder") - return - } - - // Get the total size of the folder (all files) - guard let (folderSize, fileCount) = FileManager.default.getFolderSize(folderURL) else { - print("HotlineState: Could not determine folder size") - return - } - - print("HotlineState: Requesting upload for folder '\(folderName)' - \(fileCount) items, \(folderSize) bytes total") - - Task { @MainActor [weak self] in - guard let self else { return } - - // Request folder upload from server. - // The enumerator already omits the root folder, so report the full item count the server should expect. - let reportedItemCount = fileCount - print("HotlineState: Reporting \(reportedItemCount) items to server (enumerated count)") - guard let referenceNumber = try? await client.uploadFolder(name: folderName, path: path, fileCount: reportedItemCount, totalSize: UInt32(folderSize)), - let server = self.server, - let address = server.address as String?, - let port = server.port as Int? - else { - print("HotlineState: Failed to get upload reference from server") - return - } - - // Invalidate cache for the upload destination - self.invalidateFileListCache(for: path, includingAncestors: true) - - print("HotlineState: Got folder upload reference: \(referenceNumber)") - - // Create upload client - guard let uploadClient = HotlineFolderUploadClientNew( - folderURL: folderURL, - address: address, - port: UInt16(port), - reference: referenceNumber - ) else { - print("HotlineState: Failed to create folder upload client") - return - } - - // Create transfer info for tracking (stored globally in AppState) - let transfer = TransferInfo( - reference: referenceNumber, - title: folderName, - size: UInt(folderSize), - serverID: self.id, - serverName: self.serverName ?? self.serverTitle - ) - transfer.isFolder = true - transfer.uploadCallback = callback - transfer.progressCallback = progressCallback - AppState.shared.addTransfer(transfer) - - // Create and store the upload task - let uploadTask = Task { @MainActor [weak self] in - guard self != nil else { return } - - do { - // Upload folder with progress tracking - try await uploadClient.upload(progress: { progress in - switch progress { - case .preparing: - break - case .unconnected, .connected, .connecting: - break - case .transfer(name: _, size: _, total: _, progress: let progress, speed: let speed, estimate: let estimate): - transfer.timeRemaining = estimate - transfer.speed = speed - transfer.progress = progress - transfer.progressCallback?(transfer) - case .error(_): - transfer.failed = true - case .completed(url: _): - transfer.completed = true - } - }, itemProgress: { itemInfo in - // Update transfer title with current file being uploaded - transfer.title = "\(itemInfo.fileName) (\(itemInfo.itemNumber)/\(itemInfo.totalItems))" - itemProgressCallback?(transfer, itemInfo.fileName, itemInfo.itemNumber, itemInfo.totalItems) - }) - - // Mark as completed - transfer.progress = 1.0 - transfer.title = folderName // Reset title to folder name - - // Call completion callback - transfer.uploadCallback?(transfer) - - print("HotlineState: Folder upload complete - \(folderName)") - - } catch is CancellationError { - // Upload was cancelled - print("HotlineState: Folder upload cancelled") - } catch { - // Mark as failed - transfer.failed = true - print("HotlineState: Folder upload failed - \(error)") - } - - AppState.shared.unregisterTransferTask(for: transfer.id) - } - - // Store the task in AppState so it can be cancelled later - AppState.shared.registerTransferTask(uploadTask, transferID: transfer.id) - } - } - - func uploadFile(url fileURL: URL, path: [String], complete callback: ((TransferInfo) -> Void)? = nil) { - guard let client = self.client else { return } - - let fileName = fileURL.lastPathComponent - - guard fileURL.isFileURL, !fileName.isEmpty else { - print("HotlineState: Not a valid file URL") - return - } - - let filePath = fileURL.path(percentEncoded: false) - - var fileIsDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: filePath, isDirectory: &fileIsDirectory), - fileIsDirectory.boolValue == false else { - print("HotlineState: File is a directory") - return - } - - // Get the flattened file size (includes all forks and headers) - guard let payloadSize = FileManager.default.getFlattenedFileSize(fileURL) else { - print("HotlineState: Could not determine file size") - return - } - - Task { @MainActor [weak self] in - guard let self else { return } - - // Request upload from server - guard let referenceNumber = try? await client.uploadFile(name: fileName, path: path), - let server = self.server, - let address = server.address as String?, - let port = server.port as Int? - else { - print("HotlineState: Failed to get upload reference from server") - return - } - - // Invalidate cache for the upload destination - self.invalidateFileListCache(for: path, includingAncestors: true) - - print("HotlineState: Got upload reference: \(referenceNumber)") - - // Create upload client - guard let uploadClient = HotlineFileUploadClientNew( - fileURL: fileURL, - address: address, - port: UInt16(port), - reference: referenceNumber - ) else { - print("HotlineState: Failed to create upload client") - return - } - - // Create transfer info for tracking (stored globally in AppState) - let transfer = TransferInfo( - reference: referenceNumber, - title: fileName, - size: UInt(payloadSize), - serverID: self.id, - serverName: self.serverName ?? self.serverTitle - ) - transfer.uploadCallback = callback - AppState.shared.addTransfer(transfer) - - // Create and store the upload task - let uploadTask = Task { @MainActor [weak self] in - guard self != nil else { return } - - do { - // Upload file with progress tracking - try await uploadClient.upload { progress in - switch progress { - case .preparing: - break - case .unconnected, .connected, .connecting: - break - case .transfer(name: _, size: _, total: _, progress: let progress, speed: let speed, estimate: let estimate): - transfer.timeRemaining = estimate - transfer.speed = speed - transfer.progress = progress - case .error(_): - transfer.failed = true - case .completed(url: _): - transfer.completed = true - } - } - - // Mark as completed - transfer.progress = 1.0 - - // Call completion callback - transfer.uploadCallback?(transfer) - - print("HotlineState: Upload complete - \(fileName)") - - } catch is CancellationError { - // Upload was cancelled - print("HotlineState: Upload cancelled") - } catch { - // Mark as failed - transfer.failed = true - print("HotlineState: Upload failed - \(error)") - } - - AppState.shared.unregisterTransferTask(for: transfer.id) - } - - // Store the transfer - AppState.shared.registerTransferTask(uploadTask, transferID: transfer.id) - } - } - - func setFileInfo(fileName: String, path filePath: [String], fileNewName: String?, comment: String?, encoding: String.Encoding = .utf8) { - // TODO: Implement setFileInfo in HotlineClientNew - // This method updates file metadata (name and/or comment) - print("setFileInfo not yet implemented in HotlineState/HotlineClientNew") - } - - @MainActor - func previewFile(_ fileName: String, path: [String], complete callback: ((PreviewFileInfo?) -> Void)? = nil) { - guard let client = self.client else { - callback?(nil) - return - } - - var fullPath: [String] = [] - if path.count > 1 { - fullPath = Array(path[0.. [HotlineAccount] { - guard let client = self.client else { - throw HotlineClientError.notConnected - } - - self.accounts = try await client.getAccounts() - self.accountsLoaded = true - return self.accounts - } - - @MainActor - func createUser(name: String, login: String, password: String?, access: UInt64) async throws { - guard let client = self.client else { - throw HotlineClientError.notConnected - } - - try await client.createUser(name: name, login: login, password: password, access: access) - - // Refresh accounts list - self.accounts = try await client.getAccounts() - } - - @MainActor - func setUser(name: String, login: String, newLogin: String?, password: String?, access: UInt64) async throws { - guard let client = self.client else { - throw HotlineClientError.notConnected - } - - try await client.setUser(name: name, login: login, newLogin: newLogin, password: password, access: access) - - // Refresh accounts list - self.accounts = try await client.getAccounts() - } - - @MainActor - func deleteUser(login: String) async throws { - guard let client = self.client else { - throw HotlineClientError.notConnected - } - - try await client.deleteUser(login: login) - - // Refresh accounts list - self.accounts = try await client.getAccounts() - } - - // MARK: - Message Board - - @MainActor - func getMessageBoard() async throws -> [String] { - guard let client = self.client else { - throw HotlineClientError.notConnected - } - - self.messageBoard = try await client.getMessageBoard() - self.messageBoardLoaded = true - return self.messageBoard - } - - @MainActor - func postToMessageBoard(text: String) async throws { - guard let client = self.client else { - throw HotlineClientError.notConnected - } - - try await client.postMessageBoard(text) - } - - // MARK: - News - - @MainActor - func getNewsList(at path: [String] = []) async throws { - guard let client = self.client else { - throw HotlineClientError.notConnected - } - - let parentNewsGroup = self.findNews(in: self.news, at: path) - - // Send a categories request for bundle paths or root (empty path) - if path.isEmpty || parentNewsGroup?.type == .bundle { - print("HotlineState: Requesting categories at: /\(path.joined(separator: "/"))") - - let categories = try await client.getNewsCategories(path: path) - - // Create info for each category returned - var newCategoryInfos: [NewsInfo] = [] - - // Transform hotline categories into NewsInfo objects - for category in categories { - var newsCategoryInfo = NewsInfo(hotlineNewsCategory: category) - - if let lookupPath = newsCategoryInfo.lookupPath { - // Merge returned category info with existing category info - if let existingCategoryInfo = self.newsLookup[lookupPath] { - print("HotlineState: Merging category into existing category at \(lookupPath)") - - existingCategoryInfo.count = newsCategoryInfo.count - existingCategoryInfo.name = newsCategoryInfo.name - existingCategoryInfo.path = newsCategoryInfo.path - existingCategoryInfo.categoryID = newsCategoryInfo.categoryID - newsCategoryInfo = existingCategoryInfo - } else { - print("HotlineState: New category added at \(lookupPath)") - self.newsLookup[lookupPath] = newsCategoryInfo - } - } - - newCategoryInfos.append(newsCategoryInfo) - } - - if let parent = parentNewsGroup { - parent.children = newCategoryInfos - } else if path.isEmpty { - self.newsLoaded = true - self.news = newCategoryInfos - } - } else { - print("HotlineState: Requesting articles at: /\(path.joined(separator: "/"))") - - let articles = try await client.getNewsArticles(path: path) - - print("HotlineState: Organizing news at \(path.joined(separator: "/"))") - - // Create info for each article returned - var newArticleInfos: [NewsInfo] = [] - - for article in articles { - var newsArticleInfo = NewsInfo(hotlineNewsArticle: article) - - if let lookupPath = newsArticleInfo.lookupPath { - // Merge returned category info with existing category info - if let existingArticleInfo = self.newsLookup[lookupPath] { - print("HotlineState: Merging article into existing article at \(lookupPath)") - - existingArticleInfo.count = newsArticleInfo.count - existingArticleInfo.name = newsArticleInfo.name - existingArticleInfo.path = newsArticleInfo.path - existingArticleInfo.articleUsername = newsArticleInfo.articleUsername - existingArticleInfo.articleDate = newsArticleInfo.articleDate - existingArticleInfo.articleFlavors = newsArticleInfo.articleFlavors - existingArticleInfo.articleID = newsArticleInfo.articleID - newsArticleInfo = existingArticleInfo - } else { - print("HotlineState: New article added at \(lookupPath)") - self.newsLookup[lookupPath] = newsArticleInfo - } - } - - newArticleInfos.append(newsArticleInfo) - } - - let organizedNewsArticles: [NewsInfo] = self.organizeNewsArticles(newArticleInfos) - if let parent = parentNewsGroup { - parent.children = organizedNewsArticles - } - } - } - - @MainActor - func getNewsArticle(id articleID: UInt, at path: [String], flavor: String = "text/plain") async throws -> String? { - guard let client = self.client else { - throw HotlineClientError.notConnected - } - - return try await client.getNewsArticle(id: UInt32(articleID), path: path, flavor: flavor) - } - - @MainActor - func postNewsArticle(title: String, body: String, at path: [String], parentID: UInt32 = 0) async throws { - guard let client = self.client else { - throw HotlineClientError.notConnected - } - - try await client.postNewsArticle(title: title, text: body, path: path, parentID: parentID) - print("HotlineState: News article posted") - } - - // MARK: - File Search - - @MainActor - func startFileSearch(query: String) { - let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - self.cancelFileSearch() - return - } - - self.fileSearchSession?.cancel() - self.resetFileSearchState() - self.fileSearchQuery = trimmed - self.fileSearchStatus = .searching(processed: 0, pending: 0) - self.fileSearchScannedFolders = 0 - self.fileSearchCurrentPath = [] - - let session = HotlineStateFileSearchSession(hotlineState: self, query: trimmed, config: self.fileSearchConfig) - self.fileSearchSession = session - - Task { await session.start() } - } - - @MainActor - func cancelFileSearch(clearResults: Bool = true) { - guard let session = self.fileSearchSession else { - if clearResults { - self.resetFileSearchState() - } else if !self.fileSearchResults.isEmpty { - self.fileSearchStatus = .cancelled(processed: self.fileSearchScannedFolders) - self.fileSearchCurrentPath = nil - } - return - } - - session.cancel() - self.fileSearchSession = nil - self.fileSearchCurrentPath = nil - - if clearResults { - self.resetFileSearchState() - } else { - self.fileSearchStatus = .cancelled(processed: self.fileSearchScannedFolders) - } - } - - @MainActor - func clearFileListCache() { - guard !self.fileListCache.isEmpty else { - return - } - - self.fileListCache.removeAll(keepingCapacity: false) - } - - @MainActor - fileprivate func searchSession(_ session: HotlineStateFileSearchSession, didEmit matches: [FileInfo], processed: Int, pending: Int) { - guard self.fileSearchSession === session else { - return - } - - var appended: [FileInfo] = [] - for match in matches { - let key = self.searchPathKey(for: match.path) - if self.fileSearchResultKeys.insert(key).inserted { - appended.append(match) - } - } - - if !appended.isEmpty { - self.fileSearchResults.append(contentsOf: appended) - } - - self.fileSearchScannedFolders = processed - self.fileSearchStatus = .searching(processed: processed, pending: pending) - } - - @MainActor - fileprivate func searchSession(_ session: HotlineStateFileSearchSession, didFocusOn path: [String]) { - guard self.fileSearchSession === session else { - return - } - - self.fileSearchCurrentPath = path - } - - @MainActor - fileprivate func searchSessionDidFinish(_ session: HotlineStateFileSearchSession, processed: Int, pending: Int, completed: Bool) { - guard self.fileSearchSession === session else { - return - } - - self.fileSearchScannedFolders = processed - self.fileSearchSession = nil - self.fileSearchCurrentPath = nil - - if completed { - self.fileSearchStatus = .completed(processed: processed) - } else { - self.fileSearchStatus = .cancelled(processed: processed) - } - } - - fileprivate func cachedListingForSearch(path: [String], ttl: TimeInterval) -> (items: [FileInfo], isFresh: Bool)? { - self.cachedFileList(for: path, ttl: ttl, allowStale: true) - } - - // MARK: - Event Handlers - - private func handleChatMessage(_ text: String) { - if Prefs.shared.playSounds && Prefs.shared.playChatSound { - SoundEffectPlayer.shared.playSoundEffect(.chatMessage) - } - - let chatMessage = ChatMessage(text: text, type: .message, date: Date()) - self.recordChatMessage(chatMessage) - self.unreadPublicChat = true - } - - private func handleUserChanged(_ user: HotlineUser) { - self.addOrUpdateHotlineUser(user) - } - - private func handleUserDisconnected(_ userID: UInt16) { - if let existingUserIndex = self.users.firstIndex(where: { $0.id == UInt(userID) }) { - let user = self.users.remove(at: existingUserIndex) - - if Prefs.shared.showJoinLeaveMessages { - let chatMessage = ChatMessage(text: "\(user.name) left", type: .left, date: Date()) - self.recordChatMessage(chatMessage) - } - - if Prefs.shared.playSounds && Prefs.shared.playLeaveSound { - SoundEffectPlayer.shared.playSoundEffect(.userLogout) - } - } - } - - private func handleServerMessage(_ message: String) { - if Prefs.shared.playSounds && Prefs.shared.playChatSound { - SoundEffectPlayer.shared.playSoundEffect(.serverMessage) - } - - print("HotlineState: received server message:\n\(message)") - let chatMessage = ChatMessage(text: message, type: .server, date: Date()) - self.recordChatMessage(chatMessage) - } - - private func handlePrivateMessage(userID: UInt16, message: String) { - if let existingUserIndex = self.users.firstIndex(where: { $0.id == UInt(userID) }) { - let user = self.users[existingUserIndex] - print("HotlineState: received private message from \(user.name): \(message)") - - if Prefs.shared.playPrivateMessageSound { - if self.unreadInstantMessages[userID] == nil { - SoundEffectPlayer.shared.playSoundEffect(.serverMessage) - } else { - SoundEffectPlayer.shared.playSoundEffect(.chatMessage) - } - } - - let instantMessage = InstantMessage( - direction: .incoming, - text: message.convertingLinksToMarkdown(), - type: .message, - date: Date() - ) - - if self.instantMessages[userID] == nil { - self.instantMessages[userID] = [instantMessage] - } else { - self.instantMessages[userID]!.append(instantMessage) - } - - self.unreadInstantMessages[userID] = userID - } - } - - private func handleNewsPost(_ message: String) { - let messageBoardRegex = /([\s\r\n]*[_\-]+[\s\r\n]+)/ - let matches = message.matches(of: messageBoardRegex) - - if matches.count == 1 { - let range = matches[0].range - self.messageBoard.insert(String(message[message.startIndex.. ChatStore.SessionKey { - ChatStore.SessionKey(address: server.address.lowercased(), port: server.port) - } - - private func recordChatMessage(_ message: ChatMessage, persist: Bool = true, display: Bool = true) { - let shouldPersist = persist && message.type != .agreement - if shouldPersist, - message.type == .signOut, - self.lastPersistedMessageType == .signOut { - return - } - - if display { - self.chat.append(message) - } - - guard shouldPersist, let key = self.chatSessionKey else { return } - self.lastPersistedMessageType = message.type - - let entry = ChatStore.Entry( - id: message.id, - body: message.text, - username: message.username, - type: message.type.storageKey, - date: message.date - ) - let serverName = self.serverName ?? self.server?.name - - Task { - await ChatStore.shared.append(entry: entry, for: key, serverName: serverName) - } - } - - private func restoreChatHistory(for key: ChatStore.SessionKey) { - if self.restoredChatSessionKey == key { - return - } - - Task { [weak self] in - guard let self else { return } - let result = await ChatStore.shared.loadHistory(for: key) - - await MainActor.run { - guard self.chatSessionKey == key, self.restoredChatSessionKey != key else { return } - - let currentMessages = self.chat - let historyMessages = result.entries.compactMap { entry -> ChatMessage? in - guard let chatType = ChatMessageType(storageKey: entry.type) else { return nil } - - let renderedText: String - if chatType == .message, let username = entry.username, !username.isEmpty { - renderedText = "\(username): \(entry.body)" - } else { - renderedText = entry.body - } - - var message = ChatMessage(text: renderedText, type: chatType, date: entry.date) - message.metadata = entry.metadata - return message - } - - self.chat = historyMessages + currentMessages - self.lastPersistedMessageType = historyMessages.last?.type - self.unreadPublicChat = false - self.restoredChatSessionKey = key - } - } - } - - private func handleChatHistoryCleared() { - self.chat = [] - self.unreadPublicChat = false - self.restoredChatSessionKey = nil - self.lastPersistedMessageType = nil - } - - // MARK: - Utilities - - func updateServerTitle() { - self.serverTitle = self.serverName ?? self.server?.name ?? self.server?.address ?? "Server" - } - - // News helpers - func organizeNewsArticles(_ flatArticles: [NewsInfo]) -> [NewsInfo] { - // Place articles under their parent - var organized: [NewsInfo] = [] - for article in flatArticles { - if let parentLookupPath = article.parentArticleLookupPath, - let parentArticle = self.newsLookup[parentLookupPath] { - if parentArticle.children.firstIndex(of: article) == nil { - article.expanded = true - parentArticle.children.append(article) - } - } else { - organized.append(article) - } - } - - return organized - } - - private func findNews(in newsToSearch: [NewsInfo], at path: [String]) -> NewsInfo? { - guard !path.isEmpty, !newsToSearch.isEmpty, let currentName = path.first else { return nil } - - for news in newsToSearch { - if news.name == currentName { - if path.count == 1 { - return news - } else if !news.children.isEmpty { - let remainingPath = Array(path[1...]) - return self.findNews(in: news.children, at: remainingPath) - } - } - } - - return nil - } - - // File helpers - private func findFile(in filesToSearch: [FileInfo], at path: [String]) -> FileInfo? { - guard !path.isEmpty, !filesToSearch.isEmpty else { return nil } - - let currentName = path[0] - - for file in filesToSearch { - if file.name == currentName { - if path.count == 1 { - return file - } else if let subfiles = file.children { - let remainingPath = Array(path[1...]) - return self.findFile(in: subfiles, at: remainingPath) - } - } - } - - return nil - } - - // File search helpers - private func searchPathKey(for path: [String]) -> String { - path.joined(separator: "\u{001F}") - } - - private func resetFileSearchState() { - self.fileSearchResults = [] - self.fileSearchResultKeys.removeAll(keepingCapacity: true) - self.fileSearchStatus = .idle - self.fileSearchQuery = "" - self.fileSearchScannedFolders = 0 - self.fileSearchCurrentPath = nil - } - - // File cache helpers - private func shouldBypassFileCache(for path: [String]) -> Bool { - guard let folderName = path.last else { - return false - } - - let trimmed = folderName.trimmingCharacters(in: .whitespacesAndNewlines) - - if trimmed.range(of: "upload", options: [.caseInsensitive]) != nil { - return true - } - - if trimmed.range(of: "dropbox", options: [.caseInsensitive]) != nil { - return true - } - - if trimmed.range(of: "drop box", options: [.caseInsensitive]) != nil { - return true - } - - return false - } - - private func cachedFileList(for path: [String], ttl: TimeInterval, allowStale: Bool) -> (items: [FileInfo], isFresh: Bool)? { - guard ttl > 0 else { - return nil - } - - if self.shouldBypassFileCache(for: path) { - return nil - } - - let key = self.searchPathKey(for: path) - guard let entry = self.fileListCache[key] else { - return nil - } - - let age = Date().timeIntervalSince(entry.timestamp) - let isFresh = age <= ttl - if !allowStale && !isFresh { - return nil - } - - return (entry.files, isFresh) - } - - private func storeFileListInCache(_ files: [FileInfo], for path: [String]) { - guard self.fileSearchConfig.cacheTTL > 0 else { - return - } - - if self.shouldBypassFileCache(for: path) { - return - } - - let key = self.searchPathKey(for: path) - self.fileListCache[key] = FileListCacheEntry(files: files, timestamp: Date()) - self.pruneFileListCacheIfNeeded() - } - - private func pruneFileListCacheIfNeeded() { - let limit = self.fileSearchConfig.maxCachedFolders - guard limit > 0, self.fileListCache.count > limit else { - return - } - - let excess = self.fileListCache.count - limit - guard excess > 0 else { return } - - let sortedKeys = self.fileListCache.sorted { lhs, rhs in - lhs.value.timestamp < rhs.value.timestamp - } - - for index in 0.. = [] - private var loopHistogram: [String: Int] = [:] - - private var processedCount: Int = 0 - private var currentDelay: TimeInterval - private var isCancelled = false - - init(hotlineState: HotlineState, query: String, config: FileSearchConfig) { - self.hotlineState = hotlineState - self.queryTokens = query.lowercased().split(separator: " ").map(String.init) - self.config = config - self.currentDelay = config.initialDelay - } - - func start() async { - guard let hotlineState else { - return - } - - await Task.yield() - - if !hotlineState.filesLoaded { - hotlineState.searchSession(self, didFocusOn: []) - let rootFiles = try? await hotlineState.getFileList(path: [], suppressErrors: true, preferCache: true) - self.processedCount = max(self.processedCount, 1) - self.processListing(rootFiles ?? [], depth: 0, parentPath: [], parentIsHot: false) - } else { - hotlineState.searchSession(self, didFocusOn: []) - self.processedCount = max(self.processedCount, 1) - self.processListing(hotlineState.files, depth: 0, parentPath: [], parentIsHot: false) - } - - while !self.queue.isEmpty && !self.isCancelled { - await Task.yield() - - guard let task = self.dequeueNextTask() else { - continue - } - - if self.shouldSkip(path: task.path, depth: task.depth) { - hotlineState.searchSession(self, didEmit: [], processed: self.processedCount, pending: self.queue.count) - continue - } - - hotlineState.searchSession(self, didFocusOn: task.path) - self.visited.insert(self.pathKey(for: task.path)) - - if let cached = hotlineState.cachedListingForSearch(path: task.path, ttl: self.config.cacheTTL) { - if cached.isFresh { - self.processedCount += 1 - self.processListing(cached.items, depth: task.depth, parentPath: task.path, parentIsHot: task.isHot) - continue - } else { - self.processListing(cached.items, depth: task.depth, parentPath: task.path, parentIsHot: task.isHot) - } - } - - let children = try? await hotlineState.getFileList(path: task.path, suppressErrors: true) - self.processedCount += 1 - - if self.isCancelled { - break - } - - self.processListing(children ?? [], depth: task.depth, parentPath: task.path, parentIsHot: task.isHot) - - await self.applyBackoff() - } - - hotlineState.searchSessionDidFinish(self, processed: self.processedCount, pending: self.queue.count, completed: !self.isCancelled) - } - - func cancel() { - self.isCancelled = true - } - - private func processListing(_ items: [FileInfo], depth: Int, parentPath: [String], parentIsHot: Bool) { - guard let hotlineState else { - return - } - - var matches: [FileInfo] = [] - var folderEntries: [(file: FileInfo, isHot: Bool)] = [] - var hasFileMatch = false - - for file in items { - let matchesName = self.nameMatchesQuery(file.name) - - if matchesName { - matches.append(file) - if !file.isFolder { - hasFileMatch = true - } - } - - if file.isFolder && !file.isAppBundle { - folderEntries.append((file, matchesName)) - } - } - - var remainingBurst = 0 - if self.config.hotBurstLimit > 0 && (parentIsHot || hasFileMatch) { - remainingBurst = self.config.hotBurstLimit - } - - if remainingBurst > 0 { - var candidateIndices: [Int] = [] - for index in folderEntries.indices where !folderEntries[index].isHot { - candidateIndices.append(index) - } - - if !candidateIndices.isEmpty { - candidateIndices.shuffle() - for index in candidateIndices { - folderEntries[index].isHot = true - remainingBurst -= 1 - if remainingBurst == 0 { - break - } - } - } - } - - for entry in folderEntries { - self.enqueueFolder(entry.file, depth: depth + 1, markHot: entry.isHot) - } - - hotlineState.searchSession(self, didEmit: matches, processed: self.processedCount, pending: self.queue.count) - } - - private func enqueueFolder(_ folder: FileInfo, depth: Int, markHot: Bool) { - guard !self.isCancelled else { return } - guard depth <= self.config.maxDepth else { return } - - let path = folder.path - let key = self.pathKey(for: path) - guard !self.visited.contains(key) else { return } - - if self.exceedsLoopThreshold(for: path) { - return - } - - self.queue.append(FolderTask(path: path, depth: depth, isHot: markHot)) - } - - private func dequeueNextTask() -> FolderTask? { - guard !self.queue.isEmpty else { - return nil - } - - if self.queue.count == 1 { - return self.queue.removeFirst() - } - - let currentDepth = self.queue[0].depth - var lastSameDepthIndex = 0 - var hotIndices: [Int] = [] - - for index in 0.. Bool { - if self.isCancelled { - return true - } - - if depth > self.config.maxDepth { - return true - } - - let key = self.pathKey(for: path) - if self.visited.contains(key) { - return true - } - - return false - } - - private func nameMatchesQuery(_ name: String) -> Bool { - guard !self.queryTokens.isEmpty else { return false } - let lowercased = name.lowercased() - return self.queryTokens.allSatisfy { lowercased.contains($0) } - } - - private func exceedsLoopThreshold(for path: [String]) -> Bool { - guard self.config.loopRepetitionLimit > 0 else { return false } - guard let last = path.last else { return false } - let parent = path.dropLast() - - guard let previousIndex = parent.lastIndex(of: last) else { - return false - } - - let suffix = Array(path[previousIndex...]) - let key = suffix.joined(separator: "\u{001F}") - let count = (self.loopHistogram[key] ?? 0) + 1 - self.loopHistogram[key] = count - return count > self.config.loopRepetitionLimit - } - - private func pathKey(for path: [String]) -> String { - path.joined(separator: "\u{001F}") - } - - private func applyBackoff() async { - guard !self.isCancelled else { return } - - if self.processedCount > self.config.initialBurstCount { - self.currentDelay = min(self.config.maxDelay, max(self.config.initialDelay, self.currentDelay * self.config.backoffMultiplier)) - } - - guard self.currentDelay > 0 else { - return - } - - let nanoseconds = UInt64(self.currentDelay * 1_000_000_000) - try? await Task.sleep(nanoseconds: nanoseconds) - } -} diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift new file mode 100644 index 0000000..d5ab438 --- /dev/null +++ b/Hotline/State/HotlineState.swift @@ -0,0 +1,2468 @@ +import SwiftUI + +// MARK: - Connection Status + +enum HotlineConnectionStatus: Equatable { + case disconnected + case connecting + case connected + case loggedIn + case failed(String) + + var isConnected: Bool { + return self == .connected || self == .loggedIn + } +} + +struct FileSearchConfig: Equatable { + /// Number of folders we process before we start applying delay backoff. + var initialBurstCount: Int = 15 + /// Base delay applied between folder requests during the backoff phase. + var initialDelay: TimeInterval = 0.02 + /// Multiplier used to increase the delay after each processed folder in backoff. + var backoffMultiplier: Double = 1.1 + /// Maximum delay cap so searches don't stall out during long walks. + var maxDelay: TimeInterval = 1.0 + /// Maximum recursion depth allowed during file search. + var maxDepth: Int = 40 + /// Limit for repeated folder loops (guards against circular server listings). + var loopRepetitionLimit: Int = 4 + /// Number of child folders that get prioritized after a matching parent is found. + var hotBurstLimit: Int = 2 + /// Maximum age, in seconds, that a cached folder listing is treated as fresh. + var cacheTTL: TimeInterval = 60 * 15 + /// Upper bound on the number of folder listings retained in the cache. + var maxCachedFolders: Int = 1024 * 3 +} + +enum FileSearchStatus: Equatable { + case idle + case searching(processed: Int, pending: Int) + case completed(processed: Int) + case cancelled(processed: Int) + case failed(String) + + var isActive: Bool { + if case .searching = self { + return true + } + return false + } +} + +// MARK: - HotlineState + +@Observable @MainActor +class HotlineState: Equatable { + let id: UUID = UUID() + + nonisolated static func == (lhs: HotlineState, rhs: HotlineState) -> Bool { + return lhs.id == rhs.id + } + + // MARK: - Static Icon Data + + #if os(macOS) + static func getClassicIcon(_ index: Int) -> NSImage? { + return NSImage(named: "Classic/\(index)") + } + #elseif os(iOS) + static func getClassicIcon(_ index: Int) -> UIImage? { + return UIImage(named: "Classic/\(index)") + } + #endif + + static let classicIconSet: [Int] = [ + 141, 149, 150, 151, 172, 184, 204, + 2013, 2036, 2037, 2055, 2400, 2505, 2534, + 2578, 2592, 4004, 4015, 4022, 4104, 4131, + 4134, 4136, 4169, 4183, 4197, 4240, 4247, + 128, 129, 130, 131, 132, 133, 134, + 135, 136, 137, 138, 139, 140, 142, + 143, 144, 145, 146, 147, 148, 152, + 153, 154, 155, 156, 157, 158, 159, + 160, 161, 162, 163, 164, 165, 166, + 167, 168, 169, 170, 171, 173, 174, + 175, 176, 177, 178, 179, 180, 181, + 182, 183, 185, 186, 187, 188, 189, + 190, 191, 192, 193, 194, 195, 196, + 197, 198, 199, 200, 201, 202, 203, + 205, 206, 207, 208, 209, 212, 214, + 215, 220, 233, 236, 237, 243, 244, + 277, 410, 414, 500, 666, 1250, 1251, + 1968, 1969, 2000, 2001, 2002, 2003, 2004, + 2006, 2007, 2008, 2009, 2010, 2011, 2012, + 2014, 2015, 2016, 2017, 2018, 2019, 2020, + 2021, 2022, 2023, 2024, 2025, 2026, 2027, + 2028, 2029, 2030, 2031, 2032, 2033, 2034, + 2035, 2038, 2040, 2041, 2042, 2043, 2044, + 2045, 2046, 2047, 2048, 2049, 2050, 2051, + 2052, 2053, 2054, 2056, 2057, 2058, 2059, + 2060, 2061, 2062, 2063, 2064, 2065, 2066, + 2067, 2070, 2071, 2072, 2073, 2075, 2079, + 2098, 2100, 2101, 2102, 2103, 2104, 2105, + 2106, 2107, 2108, 2109, 2110, 2112, 2113, + 2115, 2116, 2117, 2118, 2119, 2120, 2121, + 2122, 2123, 2124, 2125, 2126, 4150, 2223, + 2401, 2402, 2403, 2404, 2500, 2501, 2502, + 2503, 2504, 2506, 2507, 2528, 2529, 2530, + 2531, 2532, 2533, 2535, 2536, 2537, 2538, + 2539, 2540, 2541, 2542, 2543, 2544, 2545, + 2546, 2547, 2548, 2549, 2550, 2551, 2552, + 2553, 2554, 2555, 2556, 2557, 2558, 2559, + 2560, 2561, 2562, 2563, 2564, 2565, 2566, + 2567, 2568, 2569, 2570, 2571, 2572, 2573, + 2574, 2575, 2576, 2577, 2579, 2580, 2581, + 2582, 2583, 2584, 2585, 2586, 2587, 2588, + 2589, 2590, 2591, 2593, 2594, 2595, 2596, + 2597, 2598, 2599, 2600, 4000, 4001, 4002, + 4003, 4005, 4006, 4007, 4008, 4009, 4010, + 4011, 4012, 4013, 4014, 4016, 4017, 4018, + 4019, 4020, 4021, 4023, 4024, 4025, 4026, + 4027, 4028, 4029, 4030, 4031, 4032, 4033, + 4034, 4035, 4036, 4037, 4038, 4039, 4040, + 4041, 4042, 4043, 4044, 4045, 4046, 4047, + 4048, 4049, 4050, 4051, 4052, 4053, 4054, + 4055, 4056, 4057, 4058, 4059, 4060, 4061, + 4062, 4063, 4064, 4065, 4066, 4067, 4068, + 4069, 4070, 4071, 4072, 4073, 4074, 4075, + 4076, 4077, 4078, 4079, 4080, 4081, 4082, + 4083, 4084, 4085, 4086, 4087, 4088, 4089, + 4090, 4091, 4092, 4093, 4094, 4095, 4096, + 4097, 4098, 4099, 4100, 4101, 4102, 4103, + 4105, 4106, 4107, 4108, 4109, 4110, 4111, + 4112, 4113, 4114, 4115, 4116, 4117, 4118, + 4119, 4120, 4121, 4122, 4123, 4124, 4125, + 4126, 4127, 4128, 4129, 4130, 4132, 4133, + 4135, 4137, 4138, 4139, 4140, 4141, 4142, + 4143, 4144, 4145, 4146, 4147, 4148, 4149, + 4151, 4152, 4153, 4154, 4155, 4156, 4157, + 4158, 4159, 4160, 4161, 4162, 4163, 4164, + 4165, 4166, 4167, 4168, 4170, 4171, 4172, + 4173, 4174, 4175, 4176, 4177, 4178, 4179, + 4180, 4181, 4182, 4184, 4185, 4186, 4187, + 4188, 4189, 4190, 4191, 4192, 4193, 4194, + 4195, 4196, 4198, 4199, 4200, 4201, 4202, + 4203, 4204, 4205, 4206, 4207, 4208, 4209, + 4210, 4211, 4212, 4213, 4214, 4215, 4216, + 4217, 4218, 4219, 4220, 4221, 4222, 4223, + 4224, 4225, 4226, 4227, 4228, 4229, 4230, + 4231, 4232, 4233, 4234, 4235, 4236, 4238, + 4241, 4242, 4243, 4244, 4245, 4246, 4248, + 4249, 4250, 4251, 4252, 4253, 4254, 31337, + 6001, 6002, 6003, 6004, 6005, 6008, 6009, + 6010, 6011, 6012, 6013, 6014, 6015, 6016, + 6017, 6018, 6023, 6025, 6026, 6027, 6028, + 6029, 6030, 6031, 6032, 6033, 6034, 6035 + ] + + // MARK: - Observable State + + var status: HotlineConnectionStatus = .disconnected + var server: Server? { + didSet { + self.updateServerTitle() + } + } + var serverVersion: UInt16 = 123 + var serverName: String? { + didSet { + self.updateServerTitle() + } + } + var serverTitle: String = "Server" + var username: String = "guest" + var iconID: Int = 414 + var access: HotlineUserAccessOptions? + var agreed: Bool = false + + // Users + var users: [User] = [] + + // Chat + var chat: [ChatMessage] = [] + var chatInput: String = "" + var unreadPublicChat: Bool = false + + // Instant Messages + var instantMessages: [UInt16:[InstantMessage]] = [:] + var unreadInstantMessages: [UInt16:UInt16] = [:] + + // Message Board + var messageBoard: [String] = [] + var messageBoardLoaded: Bool = false + + // News + var news: [NewsInfo] = [] + var newsLoaded: Bool = false + private var newsLookup: [String:NewsInfo] = [:] + + // Files + var files: [FileInfo] = [] + var filesLoaded: Bool = false + + // Accounts + var accounts: [HotlineAccount] = [] + var accountsLoaded: Bool = false + + // Banner + #if os(macOS) + var bannerImage: Image? = nil + var bannerColors: ColorArt? = nil + #elseif os(iOS) + var bannerImage: UIImage? = nil + #endif + + // Transfers (now stored globally in AppState) + /// Returns all transfers associated with this server + var transfers: [TransferInfo] { + AppState.shared.transfers.filter { $0.serverID == self.id } + } + + // Legacy transfer tracking (for old delegate-based downloads) +// @ObservationIgnored private var downloads: [HotlineTransferClient] = [] + @ObservationIgnored private var bannerDownloadTask: Task? = nil + + // File Search + var fileSearchResults: [FileInfo] = [] + var fileSearchStatus: FileSearchStatus = .idle + var fileSearchQuery: String = "" + var fileSearchConfig = FileSearchConfig() + var fileSearchScannedFolders: Int = 0 + var fileSearchCurrentPath: [String]? = nil + @ObservationIgnored private var fileSearchSession: HotlineStateFileSearchSession? = nil + @ObservationIgnored private var fileSearchResultKeys: Set = [] + + // File List Cache + private struct FileListCacheEntry { + let files: [FileInfo] + let timestamp: Date + } + @ObservationIgnored private var fileListCache: [String: FileListCacheEntry] = [:] + + // Error Display + var errorDisplayed: Bool = false + var errorMessage: String? = nil + + // MARK: - Private State + + @ObservationIgnored private var client: HotlineClientNew? + @ObservationIgnored private var eventTask: Task? + @ObservationIgnored private var chatSessionKey: ChatStore.SessionKey? + @ObservationIgnored private var restoredChatSessionKey: ChatStore.SessionKey? + @ObservationIgnored private var chatHistoryObserver: NSObjectProtocol? + @ObservationIgnored private var lastPersistedMessageType: ChatMessageType? + + // MARK: - Initialization + + init() { + self.chatHistoryObserver = NotificationCenter.default.addObserver( + forName: ChatStore.historyClearedNotification, + object: nil, + queue: .main + ) { @MainActor [weak self] _ in + self?.handleChatHistoryCleared() + } + } + + deinit { + if let observer = self.chatHistoryObserver { + NotificationCenter.default.removeObserver(observer) + } + } + + // MARK: - Connection + + @MainActor + func login(server: Server, username: String, iconID: Int) async throws { + print("HotlineState.login(): Starting login to \(server.address):\(server.port)") + self.server = server + self.username = username + self.iconID = iconID + self.status = .connecting + print("HotlineState.login(): Status set to connecting") + + // Set up chat session + let key = self.sessionKey(for: server) + self.chatSessionKey = key + self.restoredChatSessionKey = nil + self.lastPersistedMessageType = nil + self.chat = [] + self.restoreChatHistory(for: key) + print("HotlineState.login(): Chat session set up") + + do { + // Connect and login + let loginInfo = HotlineLoginInfo( + login: server.login, + password: server.password, + username: username, + iconID: UInt16(iconID) + ) + + print("HotlineState.login(): Calling HotlineClientNew.connect()...") + let client = try await HotlineClientNew.connect( + host: server.address, + port: UInt16(server.port), + login: loginInfo + ) + print("HotlineState.login(): HotlineClientNew.connect() returned") + + self.client = client + print("HotlineState.login(): Client stored") + + // Get server info + print("HotlineState.login(): Getting server info...") + if let serverInfo = await client.server { + self.serverVersion = serverInfo.version + if !serverInfo.name.isEmpty { + self.serverName = serverInfo.name + } + print("HotlineState.login(): Server info retrieved: \(serverInfo.name) v\(serverInfo.version)") + } + + self.status = .connected + print("HotlineState.login(): Status set to connected") + + // Request initial data before starting event loop + print("HotlineState.login(): Requesting user list...") + try await self.getUserList() + + self.status = .loggedIn + print("HotlineState.login(): Status set to loggedIn") + + if Prefs.shared.playSounds && Prefs.shared.playLoggedInSound { + SoundEffectPlayer.shared.playSoundEffect(.loggedIn) + } + + print("HotlineState.login(): Connected to \(self.serverTitle)") + print("HotlineState.login(): Scheduling post-login tasks...") + + // Defer event loop and post-login work to avoid layout recursion + // This allows login() to return and SwiftUI to complete its layout pass + // before we start receiving events that trigger state changes + Task { @MainActor in + print("HotlineState: Post-login: Starting event loop...") + self.startEventLoop() + + print("HotlineState: Post-login: Sending preferences...") + try? await self.sendUserPreferences() + + print("HotlineState: Post-login: Downloading banner...") + self.downloadBanner() + } + + } catch { + print("HotlineState.login(): Login failed with error: \(error)") + if let client = self.client { + await client.disconnect() + self.client = nil + } + self.status = .failed(error.localizedDescription) + self.errorDisplayed = true + self.errorMessage = error.localizedDescription + throw error + } + } + + /// Disconnect from the server (user-initiated) + @MainActor + func disconnect() async { + print("HotlineState.disconnect(): Called") + guard let client = self.client else { + print("HotlineState.disconnect(): No client, returning") + return + } + + // Stop event loop + print("HotlineState.disconnect(): Cancelling event task...") + self.eventTask?.cancel() + self.eventTask = nil + print("HotlineState.disconnect(): Event task cancelled") + + // Explicitly close the connection + print("HotlineState.disconnect(): Calling client.disconnect()...") + await client.disconnect() + print("HotlineState.disconnect(): client.disconnect() returned") + + // Clean up state + print("HotlineState.disconnect(): Calling handleConnectionClosed()...") + self.handleConnectionClosed() + print("HotlineState.disconnect(): disconnect() complete") + } + + /// Handle connection closure (server-initiated or after user disconnect) + @MainActor + private func handleConnectionClosed() { + print("HotlineState: handleConnectionClosed() entered") + guard self.client != nil else { + print("HotlineState: handleConnectionClosed() - client already nil, returning") + return + } + + print("HotlineState: Handling connection closure - recording chat...") + + // Record disconnect in chat history + if self.status == .loggedIn { + let message = ChatMessage(text: "Disconnected", type: .signOut, date: Date()) + self.recordChatMessage(message, persist: true, display: false) + } + + print("HotlineState: Cancelling banner and downloads...") + + self.bannerDownloadTask?.cancel() + self.bannerDownloadTask = nil + + // Cancel all downloads (both old delegate-based and new async downloads) +// self.downloads = [] + + // Cancel all transfers for this server +// self.cancelAllDownloads() + + // Cancel file search + self.fileSearchSession?.cancel() + self.fileSearchSession = nil + + // Clear client reference + self.client = nil + + print("HotlineState: Resetting state properties...") + + // Reset state immediately (constraint loop was caused by something else) + self.status = .disconnected + self.serverVersion = 123 + self.serverName = nil + self.access = nil + self.agreed = false + self.users = [] + self.chat = [] + self.instantMessages = [:] + self.unreadInstantMessages = [:] + self.unreadPublicChat = false + self.messageBoard = [] + self.messageBoardLoaded = false + self.news = [] + self.newsLoaded = false + self.newsLookup = [:] + self.files = [] + self.filesLoaded = false + self.accounts = [] + self.accountsLoaded = false + self.bannerImage = nil + self.bannerColors = nil + + print("HotlineState: Resetting file search...") + self.resetFileSearchState() + + self.chatSessionKey = nil + self.restoredChatSessionKey = nil + self.lastPersistedMessageType = nil + + print("HotlineState: Disconnected") + } + + @MainActor + func downloadBanner(force: Bool = false) { + guard self.serverVersion >= 150 else { + return + } + + if force { + self.bannerDownloadTask?.cancel() + self.bannerDownloadTask = nil + self.bannerImage = nil + self.bannerColors = nil + } else if self.bannerDownloadTask != nil || self.bannerImage != nil { + return + } + + let task = Task { @MainActor [weak self] in + defer { + self?.bannerDownloadTask = nil + } + + guard let self else { return } + guard let client = self.client, + let server = self.server, + let result = try? await client.downloadBanner(), + let address = server.address as String?, + let port = server.port as Int? + else { + return + } + + do { + print("HotlineState: Banner download info - reference: \(result.referenceNumber), transferSize: \(result.transferSize)") + + let previewClient = HotlineFilePreviewClientNew( + fileName: "banner", + address: address, + port: UInt16(port), + reference: result.referenceNumber, + size: UInt32(result.transferSize) + ) + + let fileURL = try await previewClient.preview() + defer { + previewClient.cleanup() + } + + guard self.client != nil else { return } + + let data = try Data(contentsOf: fileURL) + print("HotlineState: Banner download complete, data size: \(data.count) bytes") + +#if os(macOS) + guard let image = NSImage(data: data) else { + print("HotlineState: Failed to create NSImage from banner data") + return + } + let blah = Image(nsImage: image) +#elseif os(iOS) + guard let image = UIImage(data: data) else { + print("HotlineState: Failed to create UIImage from banner data") + return + } + self.bannerImage = Image(uiImage: image) +#endif + self.bannerImage = blah + self.bannerColors = ColorArt.analyze(image: image) + + } catch { + print("HotlineState: Banner download failed: \(error)") + } + } + + self.bannerDownloadTask = task + } + + // MARK: - Event Loop + + private func startEventLoop() { + print("HotlineState.startEventLoop(): Called") + guard let client = self.client else { + print("HotlineState.startEventLoop(): No client, returning") + return + } + + print("HotlineState.startEventLoop(): Creating event loop task") + self.eventTask = Task { @MainActor [weak self, client] in + guard let self else { + print("HotlineState.startEventLoop(): Self is nil in task, exiting") + return + } + + print("HotlineState.startEventLoop(): Event loop started, awaiting events...") + for await event in client.events { + print("HotlineState.startEventLoop(): Received event: \(event)") + self.handleEvent(event) + } + + // Event stream ended - server disconnected us + print("HotlineState.startEventLoop(): Event stream ended, calling handleConnectionClosed()...") + self.handleConnectionClosed() + print("HotlineState.startEventLoop(): handleConnectionClosed() returned, event loop task complete") + } + print("HotlineState.startEventLoop(): Event loop task created") + } + + @MainActor + private func handleEvent(_ event: HotlineEvent) { + switch event { + case .chatMessage(let text): + self.handleChatMessage(text) + + case .userChanged(let user): + self.handleUserChanged(user) + + case .userDisconnected(let userID): + self.handleUserDisconnected(userID) + + case .serverMessage(let message): + self.handleServerMessage(message) + + case .privateMessage(let userID, let message): + self.handlePrivateMessage(userID: userID, message: message) + + case .newsPost(let message): + self.handleNewsPost(message) + + case .agreementRequired(let text): + let message = ChatMessage(text: text, type: .agreement, date: Date()) + self.recordChatMessage(message, persist: false) + + case .userAccess(let options): + self.access = options + print("HotlineState: Got access options") + HotlineUserAccessOptions.printAccessOptions(options) + } + } + + // MARK: - Chat + + @MainActor + func sendChat(_ text: String, announce: Bool = false) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + try await client.sendChat(text, announce: announce) + } + + @MainActor + func sendInstantMessage(_ text: String, userID: UInt16) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + let message = InstantMessage( + direction: .outgoing, + text: text.convertingLinksToMarkdown(), + type: .message, + date: Date() + ) + + if self.instantMessages[userID] == nil { + self.instantMessages[userID] = [message] + } else { + self.instantMessages[userID]!.append(message) + } + + try await client.sendInstantMessage(text, to: userID) + + if Prefs.shared.playPrivateMessageSound { + SoundEffectPlayer.shared.playSoundEffect(.chatMessage) + } + } + + @MainActor + func sendAgree() async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + try await client.sendAgree() + self.agreed = true + } + + @MainActor + /// Send current user preferences from Prefs to the server + func sendUserPreferences() async throws { + var options: HotlineUserOptions = [] + + if Prefs.shared.refusePrivateMessages { + options.update(with: .refusePrivateMessages) + } + + if Prefs.shared.refusePrivateChat { + options.update(with: .refusePrivateChat) + } + + if Prefs.shared.enableAutomaticMessage { + options.update(with: .automaticResponse) + } + + print("HotlineState.sendUserPreferences(): Updating user info with server") + + try await self.sendUserInfo( + username: Prefs.shared.username, + iconID: Prefs.shared.userIconID, + options: options, + autoresponse: Prefs.shared.automaticMessage + ) + } + + func sendUserInfo(username: String, iconID: Int, options: HotlineUserOptions = [], autoresponse: String? = nil) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + self.username = username + self.iconID = iconID + + try await client.setClientUserInfo( + username: username, + iconID: UInt16(iconID), + options: options, + autoresponse: autoresponse + ) + } + + func markPublicChatAsRead() { + self.unreadPublicChat = false + } + + func hasUnreadInstantMessages(userID: UInt16) -> Bool { + return self.unreadInstantMessages[userID] != nil + } + + func markInstantMessagesAsRead(userID: UInt16) { + self.unreadInstantMessages.removeValue(forKey: userID) + } + + @MainActor + func searchChat(query: String) -> [ChatMessage] { + guard !query.isEmpty else { + return [] + } + + // Create a map of all messages by ID to deduplicate + var messageMap: [UUID: ChatMessage] = [:] + + // Add current in-memory messages + for message in self.chat { + messageMap[message.id] = message + } + + // Filter messages based on query + let filteredMessages = messageMap.values.filter { message in + // Never include agreement messages + if message.type == .agreement { + return false + } + + // Always include disconnect messages to show session boundaries + let isDisconnect = message.type == .signOut + + // Search in text and username + let matchesText = message.text.localizedCaseInsensitiveContains(query) + let matchesUsername = message.username?.localizedCaseInsensitiveContains(query) == true + let matchesQuery = matchesText || matchesUsername + + return isDisconnect || matchesQuery + } + + // Sort by date to maintain chronological order + let sortedMessages = filteredMessages.sorted { $0.date < $1.date } + + // Remove consecutive disconnect messages to avoid visual clutter + var deduplicated: [ChatMessage] = [] + var lastWasDisconnect = false + + for message in sortedMessages { + let isDisconnect = message.type == .signOut + + if isDisconnect && lastWasDisconnect { + continue + } + + deduplicated.append(message) + lastWasDisconnect = isDisconnect + } + + // Remove leading disconnect message + if deduplicated.first?.type == .signOut { + deduplicated.removeFirst() + } + + // Remove trailing disconnect message + if deduplicated.last?.type == .signOut { + deduplicated.removeLast() + } + + return deduplicated + } + + // MARK: - Users + + @MainActor + func getUserList() async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + let hotlineUsers = try await client.getUserList() + self.users = hotlineUsers.map { User(hotlineUser: $0) } + } + + // MARK: - Files (Basic) + + @MainActor + @discardableResult + func getFileList(path: [String] = [], suppressErrors: Bool = false, preferCache: Bool = false) async throws -> [FileInfo] { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + // Check cache first if preferred + if preferCache, let cached = self.cachedFileList(for: path, ttl: self.fileSearchConfig.cacheTTL, allowStale: false) { + return cached.items + } + + let hotlineFiles = try await client.getFileList(path: path) + let newFiles = hotlineFiles.map { FileInfo(hotlineFile: $0) } + + // Update UI state + if path.isEmpty { + self.filesLoaded = true + self.files = newFiles + } else { + // Update parent's children + let parentFile = self.findFile(in: self.files, at: path) + parentFile?.children = newFiles + } + + // Cache the result + self.storeFileListInCache(newFiles, for: path) + + return newFiles + } + + @MainActor + func getFileDetails(_ fileName: String, path: [String]) async throws -> FileDetails? { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + var fullPath: [String] = [] + if path.count > 1 { + fullPath = Array(path[0.. Bool { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + var fullPath: [String] = [] + if path.count > 1 { + fullPath = Array(path[0.. Void)? = nil, complete callback: ((TransferInfo, URL) -> Void)? = nil) { +// guard let client = self.client else { return } +// +// var fullPath: [String] = [] +// if path.count > 1 { +// fullPath = Array(path[0.. Void)? = nil, complete callback: ((TransferInfo) -> Void)? = nil) { + guard let client = self.client else { return } + + var fullPath: [String] = [] + if path.count > 1 { + fullPath = Array(path[0.. Void)? = nil) { +// guard let client = self.client else { return } +// +// var fullPath: [String] = [] +// if path.count > 1 { +// fullPath = Array(path[0.. Void)? = nil, + itemProgress itemProgressCallback: ((TransferInfo, String, Int, Int) -> Void)? = nil, + complete callback: ((TransferInfo) -> Void)? = nil + ) { + guard let client = self.client else { return } + + var fullPath: [String] = [] + if path.count > 1 { + fullPath = Array(path[0.. Void)? = nil, + itemProgress itemProgressCallback: ((TransferInfo, String, Int, Int) -> Void)? = nil, + complete callback: ((TransferInfo) -> Void)? = nil + ) { + guard let client = self.client else { return } + + let folderName = folderURL.lastPathComponent + + guard folderURL.isFileURL, !folderName.isEmpty else { + print("HotlineState: Not a valid folder URL") + return + } + + let folderPath = folderURL.path(percentEncoded: false) + + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: folderPath, isDirectory: &isDirectory), + isDirectory.boolValue == true else { + print("HotlineState: URL is not a folder") + return + } + + // Get the total size of the folder (all files) + guard let (folderSize, fileCount) = FileManager.default.getFolderSize(folderURL) else { + print("HotlineState: Could not determine folder size") + return + } + + print("HotlineState: Requesting upload for folder '\(folderName)' - \(fileCount) items, \(folderSize) bytes total") + + Task { @MainActor [weak self] in + guard let self else { return } + + // Request folder upload from server. + // The enumerator already omits the root folder, so report the full item count the server should expect. + let reportedItemCount = fileCount + print("HotlineState: Reporting \(reportedItemCount) items to server (enumerated count)") + guard let referenceNumber = try? await client.uploadFolder(name: folderName, path: path, fileCount: reportedItemCount, totalSize: UInt32(folderSize)), + let server = self.server, + let address = server.address as String?, + let port = server.port as Int? + else { + print("HotlineState: Failed to get upload reference from server") + return + } + + // Invalidate cache for the upload destination + self.invalidateFileListCache(for: path, includingAncestors: true) + + print("HotlineState: Got folder upload reference: \(referenceNumber)") + + // Create upload client + guard let uploadClient = HotlineFolderUploadClientNew( + folderURL: folderURL, + address: address, + port: UInt16(port), + reference: referenceNumber + ) else { + print("HotlineState: Failed to create folder upload client") + return + } + + // Create transfer info for tracking (stored globally in AppState) + let transfer = TransferInfo( + reference: referenceNumber, + title: folderName, + size: UInt(folderSize), + serverID: self.id, + serverName: self.serverName ?? self.serverTitle + ) + transfer.isFolder = true + transfer.uploadCallback = callback + transfer.progressCallback = progressCallback + AppState.shared.addTransfer(transfer) + + // Create and store the upload task + let uploadTask = Task { @MainActor [weak self] in + guard self != nil else { return } + + do { + // Upload folder with progress tracking + try await uploadClient.upload(progress: { progress in + switch progress { + case .preparing: + break + case .unconnected, .connected, .connecting: + break + case .transfer(name: _, size: _, total: _, progress: let progress, speed: let speed, estimate: let estimate): + transfer.timeRemaining = estimate + transfer.speed = speed + transfer.progress = progress + transfer.progressCallback?(transfer) + case .error(_): + transfer.failed = true + case .completed(url: _): + transfer.completed = true + } + }, itemProgress: { itemInfo in + // Update transfer title with current file being uploaded + transfer.title = "\(itemInfo.fileName) (\(itemInfo.itemNumber)/\(itemInfo.totalItems))" + itemProgressCallback?(transfer, itemInfo.fileName, itemInfo.itemNumber, itemInfo.totalItems) + }) + + // Mark as completed + transfer.progress = 1.0 + transfer.title = folderName // Reset title to folder name + + // Call completion callback + transfer.uploadCallback?(transfer) + + print("HotlineState: Folder upload complete - \(folderName)") + + } catch is CancellationError { + // Upload was cancelled + print("HotlineState: Folder upload cancelled") + } catch { + // Mark as failed + transfer.failed = true + print("HotlineState: Folder upload failed - \(error)") + } + + AppState.shared.unregisterTransferTask(for: transfer.id) + } + + // Store the task in AppState so it can be cancelled later + AppState.shared.registerTransferTask(uploadTask, transferID: transfer.id) + } + } + + func uploadFile(url fileURL: URL, path: [String], complete callback: ((TransferInfo) -> Void)? = nil) { + guard let client = self.client else { return } + + let fileName = fileURL.lastPathComponent + + guard fileURL.isFileURL, !fileName.isEmpty else { + print("HotlineState: Not a valid file URL") + return + } + + let filePath = fileURL.path(percentEncoded: false) + + var fileIsDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: filePath, isDirectory: &fileIsDirectory), + fileIsDirectory.boolValue == false else { + print("HotlineState: File is a directory") + return + } + + // Get the flattened file size (includes all forks and headers) + guard let payloadSize = FileManager.default.getFlattenedFileSize(fileURL) else { + print("HotlineState: Could not determine file size") + return + } + + Task { @MainActor [weak self] in + guard let self else { return } + + // Request upload from server + guard let referenceNumber = try? await client.uploadFile(name: fileName, path: path), + let server = self.server, + let address = server.address as String?, + let port = server.port as Int? + else { + print("HotlineState: Failed to get upload reference from server") + return + } + + // Invalidate cache for the upload destination + self.invalidateFileListCache(for: path, includingAncestors: true) + + print("HotlineState: Got upload reference: \(referenceNumber)") + + // Create upload client + guard let uploadClient = HotlineFileUploadClientNew( + fileURL: fileURL, + address: address, + port: UInt16(port), + reference: referenceNumber + ) else { + print("HotlineState: Failed to create upload client") + return + } + + // Create transfer info for tracking (stored globally in AppState) + let transfer = TransferInfo( + reference: referenceNumber, + title: fileName, + size: UInt(payloadSize), + serverID: self.id, + serverName: self.serverName ?? self.serverTitle + ) + transfer.uploadCallback = callback + AppState.shared.addTransfer(transfer) + + // Create and store the upload task + let uploadTask = Task { @MainActor [weak self] in + guard self != nil else { return } + + do { + // Upload file with progress tracking + try await uploadClient.upload { progress in + switch progress { + case .preparing: + break + case .unconnected, .connected, .connecting: + break + case .transfer(name: _, size: _, total: _, progress: let progress, speed: let speed, estimate: let estimate): + transfer.timeRemaining = estimate + transfer.speed = speed + transfer.progress = progress + case .error(_): + transfer.failed = true + case .completed(url: _): + transfer.completed = true + } + } + + // Mark as completed + transfer.progress = 1.0 + + // Call completion callback + transfer.uploadCallback?(transfer) + + print("HotlineState: Upload complete - \(fileName)") + + } catch is CancellationError { + // Upload was cancelled + print("HotlineState: Upload cancelled") + } catch { + // Mark as failed + transfer.failed = true + print("HotlineState: Upload failed - \(error)") + } + + AppState.shared.unregisterTransferTask(for: transfer.id) + } + + // Store the transfer + AppState.shared.registerTransferTask(uploadTask, transferID: transfer.id) + } + } + + func setFileInfo(fileName: String, path filePath: [String], fileNewName: String?, comment: String?, encoding: String.Encoding = .utf8) { + // TODO: Implement setFileInfo in HotlineClientNew + // This method updates file metadata (name and/or comment) + print("setFileInfo not yet implemented in HotlineState/HotlineClientNew") + } + + @MainActor + func previewFile(_ fileName: String, path: [String], complete callback: ((PreviewFileInfo?) -> Void)? = nil) { + guard let client = self.client else { + callback?(nil) + return + } + + var fullPath: [String] = [] + if path.count > 1 { + fullPath = Array(path[0.. [HotlineAccount] { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + self.accounts = try await client.getAccounts() + self.accountsLoaded = true + return self.accounts + } + + @MainActor + func createUser(name: String, login: String, password: String?, access: UInt64) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + try await client.createUser(name: name, login: login, password: password, access: access) + + // Refresh accounts list + self.accounts = try await client.getAccounts() + } + + @MainActor + func setUser(name: String, login: String, newLogin: String?, password: String?, access: UInt64) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + try await client.setUser(name: name, login: login, newLogin: newLogin, password: password, access: access) + + // Refresh accounts list + self.accounts = try await client.getAccounts() + } + + @MainActor + func deleteUser(login: String) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + try await client.deleteUser(login: login) + + // Refresh accounts list + self.accounts = try await client.getAccounts() + } + + // MARK: - Message Board + + @MainActor + func getMessageBoard() async throws -> [String] { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + self.messageBoard = try await client.getMessageBoard() + self.messageBoardLoaded = true + return self.messageBoard + } + + @MainActor + func postToMessageBoard(text: String) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + try await client.postMessageBoard(text) + } + + // MARK: - News + + @MainActor + func getNewsList(at path: [String] = []) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + let parentNewsGroup = self.findNews(in: self.news, at: path) + + // Send a categories request for bundle paths or root (empty path) + if path.isEmpty || parentNewsGroup?.type == .bundle { + print("HotlineState: Requesting categories at: /\(path.joined(separator: "/"))") + + let categories = try await client.getNewsCategories(path: path) + + // Create info for each category returned + var newCategoryInfos: [NewsInfo] = [] + + // Transform hotline categories into NewsInfo objects + for category in categories { + var newsCategoryInfo = NewsInfo(hotlineNewsCategory: category) + + if let lookupPath = newsCategoryInfo.lookupPath { + // Merge returned category info with existing category info + if let existingCategoryInfo = self.newsLookup[lookupPath] { + print("HotlineState: Merging category into existing category at \(lookupPath)") + + existingCategoryInfo.count = newsCategoryInfo.count + existingCategoryInfo.name = newsCategoryInfo.name + existingCategoryInfo.path = newsCategoryInfo.path + existingCategoryInfo.categoryID = newsCategoryInfo.categoryID + newsCategoryInfo = existingCategoryInfo + } else { + print("HotlineState: New category added at \(lookupPath)") + self.newsLookup[lookupPath] = newsCategoryInfo + } + } + + newCategoryInfos.append(newsCategoryInfo) + } + + if let parent = parentNewsGroup { + parent.children = newCategoryInfos + } else if path.isEmpty { + self.newsLoaded = true + self.news = newCategoryInfos + } + } else { + print("HotlineState: Requesting articles at: /\(path.joined(separator: "/"))") + + let articles = try await client.getNewsArticles(path: path) + + print("HotlineState: Organizing news at \(path.joined(separator: "/"))") + + // Create info for each article returned + var newArticleInfos: [NewsInfo] = [] + + for article in articles { + var newsArticleInfo = NewsInfo(hotlineNewsArticle: article) + + if let lookupPath = newsArticleInfo.lookupPath { + // Merge returned category info with existing category info + if let existingArticleInfo = self.newsLookup[lookupPath] { + print("HotlineState: Merging article into existing article at \(lookupPath)") + + existingArticleInfo.count = newsArticleInfo.count + existingArticleInfo.name = newsArticleInfo.name + existingArticleInfo.path = newsArticleInfo.path + existingArticleInfo.articleUsername = newsArticleInfo.articleUsername + existingArticleInfo.articleDate = newsArticleInfo.articleDate + existingArticleInfo.articleFlavors = newsArticleInfo.articleFlavors + existingArticleInfo.articleID = newsArticleInfo.articleID + newsArticleInfo = existingArticleInfo + } else { + print("HotlineState: New article added at \(lookupPath)") + self.newsLookup[lookupPath] = newsArticleInfo + } + } + + newArticleInfos.append(newsArticleInfo) + } + + let organizedNewsArticles: [NewsInfo] = self.organizeNewsArticles(newArticleInfos) + if let parent = parentNewsGroup { + parent.children = organizedNewsArticles + } + } + } + + @MainActor + func getNewsArticle(id articleID: UInt, at path: [String], flavor: String = "text/plain") async throws -> String? { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + return try await client.getNewsArticle(id: UInt32(articleID), path: path, flavor: flavor) + } + + @MainActor + func postNewsArticle(title: String, body: String, at path: [String], parentID: UInt32 = 0) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + try await client.postNewsArticle(title: title, text: body, path: path, parentID: parentID) + print("HotlineState: News article posted") + } + + // MARK: - File Search + + @MainActor + func startFileSearch(query: String) { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + self.cancelFileSearch() + return + } + + self.fileSearchSession?.cancel() + self.resetFileSearchState() + self.fileSearchQuery = trimmed + self.fileSearchStatus = .searching(processed: 0, pending: 0) + self.fileSearchScannedFolders = 0 + self.fileSearchCurrentPath = [] + + let session = HotlineStateFileSearchSession(hotlineState: self, query: trimmed, config: self.fileSearchConfig) + self.fileSearchSession = session + + Task { await session.start() } + } + + @MainActor + func cancelFileSearch(clearResults: Bool = true) { + guard let session = self.fileSearchSession else { + if clearResults { + self.resetFileSearchState() + } else if !self.fileSearchResults.isEmpty { + self.fileSearchStatus = .cancelled(processed: self.fileSearchScannedFolders) + self.fileSearchCurrentPath = nil + } + return + } + + session.cancel() + self.fileSearchSession = nil + self.fileSearchCurrentPath = nil + + if clearResults { + self.resetFileSearchState() + } else { + self.fileSearchStatus = .cancelled(processed: self.fileSearchScannedFolders) + } + } + + @MainActor + func clearFileListCache() { + guard !self.fileListCache.isEmpty else { + return + } + + self.fileListCache.removeAll(keepingCapacity: false) + } + + @MainActor + fileprivate func searchSession(_ session: HotlineStateFileSearchSession, didEmit matches: [FileInfo], processed: Int, pending: Int) { + guard self.fileSearchSession === session else { + return + } + + var appended: [FileInfo] = [] + for match in matches { + let key = self.searchPathKey(for: match.path) + if self.fileSearchResultKeys.insert(key).inserted { + appended.append(match) + } + } + + if !appended.isEmpty { + self.fileSearchResults.append(contentsOf: appended) + } + + self.fileSearchScannedFolders = processed + self.fileSearchStatus = .searching(processed: processed, pending: pending) + } + + @MainActor + fileprivate func searchSession(_ session: HotlineStateFileSearchSession, didFocusOn path: [String]) { + guard self.fileSearchSession === session else { + return + } + + self.fileSearchCurrentPath = path + } + + @MainActor + fileprivate func searchSessionDidFinish(_ session: HotlineStateFileSearchSession, processed: Int, pending: Int, completed: Bool) { + guard self.fileSearchSession === session else { + return + } + + self.fileSearchScannedFolders = processed + self.fileSearchSession = nil + self.fileSearchCurrentPath = nil + + if completed { + self.fileSearchStatus = .completed(processed: processed) + } else { + self.fileSearchStatus = .cancelled(processed: processed) + } + } + + fileprivate func cachedListingForSearch(path: [String], ttl: TimeInterval) -> (items: [FileInfo], isFresh: Bool)? { + self.cachedFileList(for: path, ttl: ttl, allowStale: true) + } + + // MARK: - Event Handlers + + private func handleChatMessage(_ text: String) { + if Prefs.shared.playSounds && Prefs.shared.playChatSound { + SoundEffectPlayer.shared.playSoundEffect(.chatMessage) + } + + let chatMessage = ChatMessage(text: text, type: .message, date: Date()) + self.recordChatMessage(chatMessage) + self.unreadPublicChat = true + } + + private func handleUserChanged(_ user: HotlineUser) { + self.addOrUpdateHotlineUser(user) + } + + private func handleUserDisconnected(_ userID: UInt16) { + if let existingUserIndex = self.users.firstIndex(where: { $0.id == UInt(userID) }) { + let user = self.users.remove(at: existingUserIndex) + + if Prefs.shared.showJoinLeaveMessages { + let chatMessage = ChatMessage(text: "\(user.name) left", type: .left, date: Date()) + self.recordChatMessage(chatMessage) + } + + if Prefs.shared.playSounds && Prefs.shared.playLeaveSound { + SoundEffectPlayer.shared.playSoundEffect(.userLogout) + } + } + } + + private func handleServerMessage(_ message: String) { + if Prefs.shared.playSounds && Prefs.shared.playChatSound { + SoundEffectPlayer.shared.playSoundEffect(.serverMessage) + } + + print("HotlineState: received server message:\n\(message)") + let chatMessage = ChatMessage(text: message, type: .server, date: Date()) + self.recordChatMessage(chatMessage) + } + + private func handlePrivateMessage(userID: UInt16, message: String) { + if let existingUserIndex = self.users.firstIndex(where: { $0.id == UInt(userID) }) { + let user = self.users[existingUserIndex] + print("HotlineState: received private message from \(user.name): \(message)") + + if Prefs.shared.playPrivateMessageSound { + if self.unreadInstantMessages[userID] == nil { + SoundEffectPlayer.shared.playSoundEffect(.serverMessage) + } else { + SoundEffectPlayer.shared.playSoundEffect(.chatMessage) + } + } + + let instantMessage = InstantMessage( + direction: .incoming, + text: message.convertingLinksToMarkdown(), + type: .message, + date: Date() + ) + + if self.instantMessages[userID] == nil { + self.instantMessages[userID] = [instantMessage] + } else { + self.instantMessages[userID]!.append(instantMessage) + } + + self.unreadInstantMessages[userID] = userID + } + } + + private func handleNewsPost(_ message: String) { + let messageBoardRegex = /([\s\r\n]*[_\-]+[\s\r\n]+)/ + let matches = message.matches(of: messageBoardRegex) + + if matches.count == 1 { + let range = matches[0].range + self.messageBoard.insert(String(message[message.startIndex.. ChatStore.SessionKey { + ChatStore.SessionKey(address: server.address.lowercased(), port: server.port) + } + + private func recordChatMessage(_ message: ChatMessage, persist: Bool = true, display: Bool = true) { + let shouldPersist = persist && message.type != .agreement + if shouldPersist, + message.type == .signOut, + self.lastPersistedMessageType == .signOut { + return + } + + if display { + self.chat.append(message) + } + + guard shouldPersist, let key = self.chatSessionKey else { return } + self.lastPersistedMessageType = message.type + + let entry = ChatStore.Entry( + id: message.id, + body: message.text, + username: message.username, + type: message.type.storageKey, + date: message.date + ) + let serverName = self.serverName ?? self.server?.name + + Task { + await ChatStore.shared.append(entry: entry, for: key, serverName: serverName) + } + } + + private func restoreChatHistory(for key: ChatStore.SessionKey) { + if self.restoredChatSessionKey == key { + return + } + + Task { [weak self] in + guard let self else { return } + let result = await ChatStore.shared.loadHistory(for: key) + + await MainActor.run { + guard self.chatSessionKey == key, self.restoredChatSessionKey != key else { return } + + let currentMessages = self.chat + let historyMessages = result.entries.compactMap { entry -> ChatMessage? in + guard let chatType = ChatMessageType(storageKey: entry.type) else { return nil } + + let renderedText: String + if chatType == .message, let username = entry.username, !username.isEmpty { + renderedText = "\(username): \(entry.body)" + } else { + renderedText = entry.body + } + + var message = ChatMessage(text: renderedText, type: chatType, date: entry.date) + message.metadata = entry.metadata + return message + } + + self.chat = historyMessages + currentMessages + self.lastPersistedMessageType = historyMessages.last?.type + self.unreadPublicChat = false + self.restoredChatSessionKey = key + } + } + } + + private func handleChatHistoryCleared() { + self.chat = [] + self.unreadPublicChat = false + self.restoredChatSessionKey = nil + self.lastPersistedMessageType = nil + } + + // MARK: - Utilities + + func updateServerTitle() { + self.serverTitle = self.serverName ?? self.server?.name ?? self.server?.address ?? "Server" + } + + // News helpers + func organizeNewsArticles(_ flatArticles: [NewsInfo]) -> [NewsInfo] { + // Place articles under their parent + var organized: [NewsInfo] = [] + for article in flatArticles { + if let parentLookupPath = article.parentArticleLookupPath, + let parentArticle = self.newsLookup[parentLookupPath] { + if parentArticle.children.firstIndex(of: article) == nil { + article.expanded = true + parentArticle.children.append(article) + } + } else { + organized.append(article) + } + } + + return organized + } + + private func findNews(in newsToSearch: [NewsInfo], at path: [String]) -> NewsInfo? { + guard !path.isEmpty, !newsToSearch.isEmpty, let currentName = path.first else { return nil } + + for news in newsToSearch { + if news.name == currentName { + if path.count == 1 { + return news + } else if !news.children.isEmpty { + let remainingPath = Array(path[1...]) + return self.findNews(in: news.children, at: remainingPath) + } + } + } + + return nil + } + + // File helpers + private func findFile(in filesToSearch: [FileInfo], at path: [String]) -> FileInfo? { + guard !path.isEmpty, !filesToSearch.isEmpty else { return nil } + + let currentName = path[0] + + for file in filesToSearch { + if file.name == currentName { + if path.count == 1 { + return file + } else if let subfiles = file.children { + let remainingPath = Array(path[1...]) + return self.findFile(in: subfiles, at: remainingPath) + } + } + } + + return nil + } + + // File search helpers + private func searchPathKey(for path: [String]) -> String { + path.joined(separator: "\u{001F}") + } + + private func resetFileSearchState() { + self.fileSearchResults = [] + self.fileSearchResultKeys.removeAll(keepingCapacity: true) + self.fileSearchStatus = .idle + self.fileSearchQuery = "" + self.fileSearchScannedFolders = 0 + self.fileSearchCurrentPath = nil + } + + // File cache helpers + private func shouldBypassFileCache(for path: [String]) -> Bool { + guard let folderName = path.last else { + return false + } + + let trimmed = folderName.trimmingCharacters(in: .whitespacesAndNewlines) + + if trimmed.range(of: "upload", options: [.caseInsensitive]) != nil { + return true + } + + if trimmed.range(of: "dropbox", options: [.caseInsensitive]) != nil { + return true + } + + if trimmed.range(of: "drop box", options: [.caseInsensitive]) != nil { + return true + } + + return false + } + + private func cachedFileList(for path: [String], ttl: TimeInterval, allowStale: Bool) -> (items: [FileInfo], isFresh: Bool)? { + guard ttl > 0 else { + return nil + } + + if self.shouldBypassFileCache(for: path) { + return nil + } + + let key = self.searchPathKey(for: path) + guard let entry = self.fileListCache[key] else { + return nil + } + + let age = Date().timeIntervalSince(entry.timestamp) + let isFresh = age <= ttl + if !allowStale && !isFresh { + return nil + } + + return (entry.files, isFresh) + } + + private func storeFileListInCache(_ files: [FileInfo], for path: [String]) { + guard self.fileSearchConfig.cacheTTL > 0 else { + return + } + + if self.shouldBypassFileCache(for: path) { + return + } + + let key = self.searchPathKey(for: path) + self.fileListCache[key] = FileListCacheEntry(files: files, timestamp: Date()) + self.pruneFileListCacheIfNeeded() + } + + private func pruneFileListCacheIfNeeded() { + let limit = self.fileSearchConfig.maxCachedFolders + guard limit > 0, self.fileListCache.count > limit else { + return + } + + let excess = self.fileListCache.count - limit + guard excess > 0 else { return } + + let sortedKeys = self.fileListCache.sorted { lhs, rhs in + lhs.value.timestamp < rhs.value.timestamp + } + + for index in 0.. = [] + private var loopHistogram: [String: Int] = [:] + + private var processedCount: Int = 0 + private var currentDelay: TimeInterval + private var isCancelled = false + + init(hotlineState: HotlineState, query: String, config: FileSearchConfig) { + self.hotlineState = hotlineState + self.queryTokens = query.lowercased().split(separator: " ").map(String.init) + self.config = config + self.currentDelay = config.initialDelay + } + + func start() async { + guard let hotlineState else { + return + } + + await Task.yield() + + if !hotlineState.filesLoaded { + hotlineState.searchSession(self, didFocusOn: []) + let rootFiles = try? await hotlineState.getFileList(path: [], suppressErrors: true, preferCache: true) + self.processedCount = max(self.processedCount, 1) + self.processListing(rootFiles ?? [], depth: 0, parentPath: [], parentIsHot: false) + } else { + hotlineState.searchSession(self, didFocusOn: []) + self.processedCount = max(self.processedCount, 1) + self.processListing(hotlineState.files, depth: 0, parentPath: [], parentIsHot: false) + } + + while !self.queue.isEmpty && !self.isCancelled { + await Task.yield() + + guard let task = self.dequeueNextTask() else { + continue + } + + if self.shouldSkip(path: task.path, depth: task.depth) { + hotlineState.searchSession(self, didEmit: [], processed: self.processedCount, pending: self.queue.count) + continue + } + + hotlineState.searchSession(self, didFocusOn: task.path) + self.visited.insert(self.pathKey(for: task.path)) + + if let cached = hotlineState.cachedListingForSearch(path: task.path, ttl: self.config.cacheTTL) { + if cached.isFresh { + self.processedCount += 1 + self.processListing(cached.items, depth: task.depth, parentPath: task.path, parentIsHot: task.isHot) + continue + } else { + self.processListing(cached.items, depth: task.depth, parentPath: task.path, parentIsHot: task.isHot) + } + } + + let children = try? await hotlineState.getFileList(path: task.path, suppressErrors: true) + self.processedCount += 1 + + if self.isCancelled { + break + } + + self.processListing(children ?? [], depth: task.depth, parentPath: task.path, parentIsHot: task.isHot) + + await self.applyBackoff() + } + + hotlineState.searchSessionDidFinish(self, processed: self.processedCount, pending: self.queue.count, completed: !self.isCancelled) + } + + func cancel() { + self.isCancelled = true + } + + private func processListing(_ items: [FileInfo], depth: Int, parentPath: [String], parentIsHot: Bool) { + guard let hotlineState else { + return + } + + var matches: [FileInfo] = [] + var folderEntries: [(file: FileInfo, isHot: Bool)] = [] + var hasFileMatch = false + + for file in items { + let matchesName = self.nameMatchesQuery(file.name) + + if matchesName { + matches.append(file) + if !file.isFolder { + hasFileMatch = true + } + } + + if file.isFolder && !file.isAppBundle { + folderEntries.append((file, matchesName)) + } + } + + var remainingBurst = 0 + if self.config.hotBurstLimit > 0 && (parentIsHot || hasFileMatch) { + remainingBurst = self.config.hotBurstLimit + } + + if remainingBurst > 0 { + var candidateIndices: [Int] = [] + for index in folderEntries.indices where !folderEntries[index].isHot { + candidateIndices.append(index) + } + + if !candidateIndices.isEmpty { + candidateIndices.shuffle() + for index in candidateIndices { + folderEntries[index].isHot = true + remainingBurst -= 1 + if remainingBurst == 0 { + break + } + } + } + } + + for entry in folderEntries { + self.enqueueFolder(entry.file, depth: depth + 1, markHot: entry.isHot) + } + + hotlineState.searchSession(self, didEmit: matches, processed: self.processedCount, pending: self.queue.count) + } + + private func enqueueFolder(_ folder: FileInfo, depth: Int, markHot: Bool) { + guard !self.isCancelled else { return } + guard depth <= self.config.maxDepth else { return } + + let path = folder.path + let key = self.pathKey(for: path) + guard !self.visited.contains(key) else { return } + + if self.exceedsLoopThreshold(for: path) { + return + } + + self.queue.append(FolderTask(path: path, depth: depth, isHot: markHot)) + } + + private func dequeueNextTask() -> FolderTask? { + guard !self.queue.isEmpty else { + return nil + } + + if self.queue.count == 1 { + return self.queue.removeFirst() + } + + let currentDepth = self.queue[0].depth + var lastSameDepthIndex = 0 + var hotIndices: [Int] = [] + + for index in 0.. Bool { + if self.isCancelled { + return true + } + + if depth > self.config.maxDepth { + return true + } + + let key = self.pathKey(for: path) + if self.visited.contains(key) { + return true + } + + return false + } + + private func nameMatchesQuery(_ name: String) -> Bool { + guard !self.queryTokens.isEmpty else { return false } + let lowercased = name.lowercased() + return self.queryTokens.allSatisfy { lowercased.contains($0) } + } + + private func exceedsLoopThreshold(for path: [String]) -> Bool { + guard self.config.loopRepetitionLimit > 0 else { return false } + guard let last = path.last else { return false } + let parent = path.dropLast() + + guard let previousIndex = parent.lastIndex(of: last) else { + return false + } + + let suffix = Array(path[previousIndex...]) + let key = suffix.joined(separator: "\u{001F}") + let count = (self.loopHistogram[key] ?? 0) + 1 + self.loopHistogram[key] = count + return count > self.config.loopRepetitionLimit + } + + private func pathKey(for path: [String]) -> String { + path.joined(separator: "\u{001F}") + } + + private func applyBackoff() async { + guard !self.isCancelled else { return } + + if self.processedCount > self.config.initialBurstCount { + self.currentDelay = min(self.config.maxDelay, max(self.config.initialDelay, self.currentDelay * self.config.backoffMultiplier)) + } + + guard self.currentDelay > 0 else { + return + } + + let nanoseconds = UInt64(self.currentDelay * 1_000_000_000) + try? await Task.sleep(nanoseconds: nanoseconds) + } +} diff --git a/Hotline/macOS/Board/MessageBoardView.swift b/Hotline/macOS/Board/MessageBoardView.swift index 8788d66..710ff20 100644 --- a/Hotline/macOS/Board/MessageBoardView.swift +++ b/Hotline/macOS/Board/MessageBoardView.swift @@ -8,91 +8,82 @@ struct MessageBoardView: View { var body: some View { NavigationStack { - if model.access?.contains(.canReadMessageBoard) != false { - ScrollView { - LazyVStack(alignment: .leading) { - ForEach(model.messageBoard, id: \.self) { msg in - Text(LocalizedStringKey(msg)) - .tint(Color("Link Color")) - .lineLimit(100) - .lineSpacing(4) - .padding() - .textSelection(.enabled) - Divider() - } - } - Spacer() + if self.model.access?.contains(.canReadMessageBoard) != false { + if self.model.messageBoardLoaded && self.model.messageBoard.isEmpty { + self.emptyBoardView } - .task { - if !model.messageBoardLoaded { - let _ = try? await model.getMessageBoard() - } + else { + self.messageBoardView } - .overlay { - if !model.messageBoardLoaded { - VStack { - ProgressView() - .controlSize(.large) - } - .frame(maxWidth: .infinity) - } - } - .background(Color(nsColor: .textBackgroundColor)) } else { - ZStack(alignment: .center) { - Text("No Message Board") - .font(.title) - .multilineTextAlignment(.center) - .foregroundStyle(.secondary) - .padding() - } - .frame(maxWidth: .infinity) + self.disabledBoardView } } .sheet(isPresented: $composerDisplayed) { MessageBoardEditorView() .frame(maxWidth: .infinity, maxHeight: .infinity) .frame(idealWidth: 450, idealHeight: 350) -// RichTextEditor(text: $composerText) -// .richEditorFont(NSFont.systemFont(ofSize: 16.0)) -// .richEditorAutomaticDashSubstitution(false) -// .richEditorAutomaticQuoteSubstitution(false) -// .richEditorAutomaticSpellingCorrection(false) -// .background(Color(nsColor: .textBackgroundColor)) -// .frame(maxWidth: .infinity, maxHeight: .infinity) -// .frame(idealWidth: 450, idealHeight: 350) -// .toolbar { -// ToolbarItem(placement: .cancellationAction) { -// Button("Cancel") { -// composerDisplayed.toggle() -// } -// } -// -// ToolbarItem(placement: .primaryAction) { -// Button("Post") { -// composerDisplayed.toggle() -// let text = composerText -// composerText = "" -// model.postToMessageBoard(text: text) -// Task { -// await model.getMessageBoard() -// } -// } -// } -// } } .toolbar { ToolbarItem(placement:.primaryAction) { Button { - composerDisplayed.toggle() + self.composerDisplayed.toggle() } label: { Image(systemName: "square.and.pencil") } - .disabled(model.access?.contains(.canPostMessageBoard) == false) + .disabled(self.model.access?.contains(.canPostMessageBoard) == false) .help("Post to Message Board") } } + .task { + if !self.model.messageBoardLoaded { + let _ = try? await self.model.getMessageBoard() + } + } + } + + private var disabledBoardView: some View { + ContentUnavailableView { + Label("Message Board Disabled", systemImage: "quote.bubble") + } description: { + Text("This server has turned off the message board") + } + } + + private var emptyBoardView: some View { + ContentUnavailableView { + Label("No Posts", systemImage: "quote.bubble") + } description: { + Text("Message board posts will appear here") + } + } + + private var messageBoardView: some View { + ScrollView { + LazyVStack(alignment: .leading) { + ForEach(self.model.messageBoard, id: \.self) { msg in + Text(LocalizedStringKey(msg)) + .tint(Color("Link Color")) + .lineLimit(100) + .lineSpacing(4) + .padding() + .textSelection(.enabled) + Divider() + } + } + Spacer() + } + .overlay { + if !self.model.messageBoardLoaded { + VStack { + ProgressView() + .controlSize(.large) + } + .frame(maxWidth: .infinity) + } + } + .background(Color(nsColor: .textBackgroundColor)) } } diff --git a/Hotline/macOS/Files/FilesView.swift b/Hotline/macOS/Files/FilesView.swift index b499fe6..d4ba5c8 100644 --- a/Hotline/macOS/Files/FilesView.swift +++ b/Hotline/macOS/Files/FilesView.swift @@ -16,142 +16,6 @@ struct FilesView: View { @State private var searchText: String = "" @State private var isSearching: Bool = false @State private var dragOver: Bool = false - - private var isShowingSearchResults: Bool { - switch model.fileSearchStatus { - case .idle: - return !model.fileSearchResults.isEmpty - case .cancelled(_): - return !model.fileSearchResults.isEmpty - default: - return true - } - } - - private var displayedFiles: [FileInfo] { - isShowingSearchResults ? model.fileSearchResults : model.files - } - - private var searchStatusMessage: String? { - switch model.fileSearchStatus { - case .searching(let processed, _): - let scanned = processed == 1 ? "folder" : "folders" - return "Searched \(processed) \(scanned)..." - case .completed(let processed): - let count = model.fileSearchResults.count - let folderWord = processed == 1 ? "folder" : "folders" - if count == 0 { - return "No files found in \(processed) \(folderWord)" - } - return "\(count) file\(count == 1 ? "" : "s") found in \(processed) \(folderWord)" - case .cancelled(_): - if model.fileSearchResults.isEmpty { - return nil - } - return "Search cancelled" - case .failed(let message): - return "Search failed: \(message)" - case .idle: - return nil - } - } - - private var searchStatusPath: String? { - guard let path = model.fileSearchCurrentPath else { - return nil - } - if path.isEmpty { - return "/" - } - return path.joined(separator: "/") - } - - private func openPreviewWindow(_ previewInfo: PreviewFileInfo) { - switch previewInfo.previewType { - case .image: - openWindow(id: "preview-quicklook", value: previewInfo) - case .text: - openWindow(id: "preview-quicklook", value: previewInfo) - case .unknown: - openWindow(id: "preview-quicklook", value: previewInfo) - return - } - } - - @MainActor private func getFileInfo(_ file: FileInfo) { - Task { - if let fileInfo = try? await model.getFileDetails(file.name, path: file.path) { - Task { @MainActor in - self.fileDetails = fileInfo - } - } - } - } - - @MainActor private func downloadFile(_ file: FileInfo) { - if file.isFolder { - model.downloadFolderNew(file.name, path: file.path) - } - else { - model.downloadFileNew(file.name, path: file.path) - } - } - - @MainActor private func uploadFile(file fileURL: URL, to path: [String]) { - model.uploadFile(url: fileURL, path: path) { info in - Task { - // Refresh file listing to display newly uploaded file. - let _ = try? await model.getFileList(path: path) - } - } - } - - @MainActor private func upload(file fileURL: URL, to path: [String]) { - var fileIsDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false), isDirectory: &fileIsDirectory) else { - return - } - - if fileIsDirectory.boolValue { - self.model.uploadFolder(url: fileURL, path: path, complete: { info in - Task { - // Refresh file listing to display newly uploaded file. - try? await model.getFileList(path: path) - } - }) - } - else { - self.model.uploadFile(url: fileURL, path: path) { info in - Task { - // Refresh file listing to display newly uploaded file. - try? await model.getFileList(path: path) - } - } - } - } - - @MainActor private func previewFile(_ file: FileInfo) { - guard file.isPreviewable else { - return - } - - model.previewFile(file.name, path: file.path) { info in - if let info = info { - openPreviewWindow(info) - } - } - } - - private func deleteFile(_ file: FileInfo) async { - var parentPath: [String] = [] - if file.path.count > 1 { - parentPath = Array(file.path[0.. 1 { + parentPath = Array(file.path[0.. Date: Mon, 10 Nov 2025 21:00:43 -0800 Subject: Remove "New" suffix from our transfer clients. Further cleanup. Allow previewing certain files with HFS types (but no extensions). Cleanup preview files when window closes. --- Hotline.xcodeproj/project.pbxproj | 40 +- Hotline/Hotline/HotlineClientNew.swift | 108 ++--- .../Transfers/HotlineFileDownloadClient.swift | 269 +++++++++++ .../Transfers/HotlineFileDownloadClientNew.swift | 269 ----------- .../Transfers/HotlineFilePreviewClient.swift | 162 +++++++ .../Transfers/HotlineFilePreviewClientNew.swift | 146 ------ .../Transfers/HotlineFileUploadClient.swift | 225 +++++++++ .../Transfers/HotlineFileUploadClientNew.swift | 225 --------- .../Transfers/HotlineFolderDownloadClient.swift | 450 ++++++++++++++++++ .../Transfers/HotlineFolderDownloadClientNew.swift | 453 ------------------ .../Transfers/HotlineFolderUploadClient.swift | 512 ++++++++++++++++++++ .../Transfers/HotlineFolderUploadClientNew.swift | 518 --------------------- Hotline/Library/Extensions.swift | 90 +++- Hotline/Models/FileInfo.swift | 10 +- Hotline/Models/PreviewFileInfo.swift | 3 + Hotline/State/AppUpdate.swift | 9 +- Hotline/State/FilePreviewState.swift | 155 +++--- Hotline/State/HotlineState.swift | 22 +- Hotline/State/ServerState.swift | 2 +- Hotline/macOS/Files/FilePreviewQuickLookView.swift | 53 +-- Hotline/macOS/Files/FilesView.swift | 17 +- Hotline/macOS/Files/FolderItemView.swift | 2 +- Hotline/macOS/ServerView.swift | 72 ++- Hotline/macOS/TransfersView.swift | 4 +- 24 files changed, 1931 insertions(+), 1885 deletions(-) create mode 100644 Hotline/Hotline/Transfers/HotlineFileDownloadClient.swift delete mode 100644 Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift create mode 100644 Hotline/Hotline/Transfers/HotlineFilePreviewClient.swift delete mode 100644 Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift create mode 100644 Hotline/Hotline/Transfers/HotlineFileUploadClient.swift delete mode 100644 Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift create mode 100644 Hotline/Hotline/Transfers/HotlineFolderDownloadClient.swift delete mode 100644 Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift create mode 100644 Hotline/Hotline/Transfers/HotlineFolderUploadClient.swift delete mode 100644 Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift (limited to 'Hotline/macOS/Files/FilesView.swift') diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index aa10d51..57e7032 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -22,9 +22,9 @@ DA32CD4B2B29318E0053B98B /* FileInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32CD4A2B29318E0053B98B /* FileInfo.swift */; }; DA32CD4D2B2931B50053B98B /* ChatMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32CD4C2B2931B50053B98B /* ChatMessage.swift */; }; DA32CD4F2B2931CC0053B98B /* NewsInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32CD4E2B2931CC0053B98B /* NewsInfo.swift */; }; - DA3429AE2EB9C0280010784E /* HotlineFileUploadClientNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429AD2EB9C0220010784E /* HotlineFileUploadClientNew.swift */; }; - DA3429B02EBA70790010784E /* HotlineFolderUploadClientNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429AF2EBA70790010784E /* HotlineFolderUploadClientNew.swift */; }; - DA3429B32EBA7ADF0010784E /* HotlineFilePreviewClientNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429B22EBA7ADE0010784E /* HotlineFilePreviewClientNew.swift */; }; + DA3429AE2EB9C0280010784E /* HotlineFileUploadClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429AD2EB9C0220010784E /* HotlineFileUploadClient.swift */; }; + DA3429B02EBA70790010784E /* HotlineFolderUploadClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429AF2EBA70790010784E /* HotlineFolderUploadClient.swift */; }; + DA3429B32EBA7ADF0010784E /* HotlineFilePreviewClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429B22EBA7ADE0010784E /* HotlineFilePreviewClient.swift */; }; DA3429B52EBA8A450010784E /* FilePreviewState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429B42EBA8A450010784E /* FilePreviewState.swift */; }; DA3429B72EBAB1750010784E /* QuickLookPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429B62EBAB1750010784E /* QuickLookPreviewView.swift */; platformFilters = (macos, ); }; DA3429B92EBAB2130010784E /* FilePreviewQuickLookView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429B82EBAB2130010784E /* FilePreviewQuickLookView.swift */; }; @@ -49,11 +49,11 @@ DA5268AD2EB12FE200DCB941 /* ServerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268AC2EB12FE200DCB941 /* ServerState.swift */; }; DA5268AF2EB2682B00DCB941 /* HotlineClientNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268AE2EB2682B00DCB941 /* HotlineClientNew.swift */; }; DA5268B12EB2708E00DCB941 /* HotlineState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268B02EB2708E00DCB941 /* HotlineState.swift */; }; - DA5268B32EB6806E00DCB941 /* HotlineFileDownloadClientNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268B22EB6806E00DCB941 /* HotlineFileDownloadClientNew.swift */; }; + DA5268B32EB6806E00DCB941 /* HotlineFileDownloadClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268B22EB6806E00DCB941 /* HotlineFileDownloadClient.swift */; }; DA5268B52EB6840A00DCB941 /* TransfersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268B42EB6840A00DCB941 /* TransfersView.swift */; }; DA5268B82EB916AF00DCB941 /* TransferRateEstimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268B72EB916AF00DCB941 /* TransferRateEstimator.swift */; }; DA5268BA2EB91B5E00DCB941 /* FileProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268B92EB91B5E00DCB941 /* FileProgress.swift */; }; - DA5268BC2EB95A7700DCB941 /* HotlineFolderDownloadClientNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268BB2EB95A7700DCB941 /* HotlineFolderDownloadClientNew.swift */; }; + DA5268BC2EB95A7700DCB941 /* HotlineFolderDownloadClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268BB2EB95A7700DCB941 /* HotlineFolderDownloadClient.swift */; }; DA55AC732BE42AF000034857 /* AsyncLinkPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA55AC722BE42AF000034857 /* AsyncLinkPreview.swift */; platformFilters = (macos, ); }; DA55AC752BE4888300034857 /* InstantMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA55AC742BE4888300034857 /* InstantMessage.swift */; }; DA55AC772BE589F700034857 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA55AC762BE589F700034857 /* AboutView.swift */; platformFilters = (macos, ); }; @@ -136,9 +136,9 @@ DA32CD4A2B29318E0053B98B /* FileInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileInfo.swift; sourceTree = ""; }; DA32CD4C2B2931B50053B98B /* ChatMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessage.swift; sourceTree = ""; }; DA32CD4E2B2931CC0053B98B /* NewsInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsInfo.swift; sourceTree = ""; }; - DA3429AD2EB9C0220010784E /* HotlineFileUploadClientNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFileUploadClientNew.swift; sourceTree = ""; }; - DA3429AF2EBA70790010784E /* HotlineFolderUploadClientNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFolderUploadClientNew.swift; sourceTree = ""; }; - DA3429B22EBA7ADE0010784E /* HotlineFilePreviewClientNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFilePreviewClientNew.swift; sourceTree = ""; }; + DA3429AD2EB9C0220010784E /* HotlineFileUploadClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFileUploadClient.swift; sourceTree = ""; }; + DA3429AF2EBA70790010784E /* HotlineFolderUploadClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFolderUploadClient.swift; sourceTree = ""; }; + DA3429B22EBA7ADE0010784E /* HotlineFilePreviewClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFilePreviewClient.swift; sourceTree = ""; }; DA3429B42EBA8A450010784E /* FilePreviewState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePreviewState.swift; sourceTree = ""; }; DA3429B62EBAB1750010784E /* QuickLookPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickLookPreviewView.swift; sourceTree = ""; }; DA3429B82EBAB2130010784E /* FilePreviewQuickLookView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePreviewQuickLookView.swift; sourceTree = ""; }; @@ -162,11 +162,11 @@ DA5268AC2EB12FE200DCB941 /* ServerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerState.swift; sourceTree = ""; }; DA5268AE2EB2682B00DCB941 /* HotlineClientNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineClientNew.swift; sourceTree = ""; }; DA5268B02EB2708E00DCB941 /* HotlineState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineState.swift; sourceTree = ""; }; - DA5268B22EB6806E00DCB941 /* HotlineFileDownloadClientNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFileDownloadClientNew.swift; sourceTree = ""; }; + DA5268B22EB6806E00DCB941 /* HotlineFileDownloadClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFileDownloadClient.swift; sourceTree = ""; }; DA5268B42EB6840A00DCB941 /* TransfersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransfersView.swift; sourceTree = ""; }; DA5268B72EB916AF00DCB941 /* TransferRateEstimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransferRateEstimator.swift; sourceTree = ""; }; DA5268B92EB91B5E00DCB941 /* FileProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProgress.swift; sourceTree = ""; }; - DA5268BB2EB95A7700DCB941 /* HotlineFolderDownloadClientNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFolderDownloadClientNew.swift; sourceTree = ""; }; + DA5268BB2EB95A7700DCB941 /* HotlineFolderDownloadClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFolderDownloadClient.swift; sourceTree = ""; }; DA55AC722BE42AF000034857 /* AsyncLinkPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncLinkPreview.swift; sourceTree = ""; }; DA55AC742BE4888300034857 /* InstantMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstantMessage.swift; sourceTree = ""; }; DA55AC762BE589F700034857 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; @@ -248,11 +248,11 @@ isa = PBXGroup; children = ( DA5753672B33E88A00FAC277 /* HotlineTransferClient.swift */, - DA3429B22EBA7ADE0010784E /* HotlineFilePreviewClientNew.swift */, - DA5268B22EB6806E00DCB941 /* HotlineFileDownloadClientNew.swift */, - DA3429AD2EB9C0220010784E /* HotlineFileUploadClientNew.swift */, - DA5268BB2EB95A7700DCB941 /* HotlineFolderDownloadClientNew.swift */, - DA3429AF2EBA70790010784E /* HotlineFolderUploadClientNew.swift */, + DA3429B22EBA7ADE0010784E /* HotlineFilePreviewClient.swift */, + DA5268B22EB6806E00DCB941 /* HotlineFileDownloadClient.swift */, + DA3429AD2EB9C0220010784E /* HotlineFileUploadClient.swift */, + DA5268BB2EB95A7700DCB941 /* HotlineFolderDownloadClient.swift */, + DA3429AF2EBA70790010784E /* HotlineFolderUploadClient.swift */, ); path = Transfers; sourceTree = ""; @@ -639,9 +639,9 @@ DA52689E2EB073A400DCB941 /* IconSettingsView.swift in Sources */, DAC3D9832BC33FD000A727C9 /* AppState.swift in Sources */, DA65499C2BEC3FBD00EDB697 /* ServerAgreementView.swift in Sources */, - DA5268BC2EB95A7700DCB941 /* HotlineFolderDownloadClientNew.swift in Sources */, + DA5268BC2EB95A7700DCB941 /* HotlineFolderDownloadClient.swift in Sources */, DA2863DD2B3E8B7000A7D050 /* FilePreview.swift in Sources */, - DA3429B02EBA70790010784E /* HotlineFolderUploadClientNew.swift in Sources */, + DA3429B02EBA70790010784E /* HotlineFolderUploadClient.swift in Sources */, DA5268AB2EB11EA300DCB941 /* ColorArt.swift in Sources */, DA2863D82B37AD1C00A7D050 /* SettingsView.swift in Sources */, DA5268BA2EB91B5E00DCB941 /* FileProgress.swift in Sources */, @@ -672,7 +672,7 @@ DA32CD4B2B29318E0053B98B /* FileInfo.swift in Sources */, DAF5BC6C2EC2727700551E4D /* ConnectView.swift in Sources */, DA20BBE12BF5237600B94E7C /* Bookmark.swift in Sources */, - DA3429B32EBA7ADF0010784E /* HotlineFilePreviewClientNew.swift in Sources */, + DA3429B32EBA7ADF0010784E /* HotlineFilePreviewClient.swift in Sources */, DA9CAFCB2B126E3300CDA197 /* HotlineTrackerClient.swift in Sources */, DA2863DA2B37BF6E00A7D050 /* Preferences.swift in Sources */, DA32CD4F2B2931CC0053B98B /* NewsInfo.swift in Sources */, @@ -706,7 +706,7 @@ DA501BF02EBED848001714F8 /* BonjourState.swift in Sources */, DAAEE66F2B47625600A5BA07 /* FilePreviewImageView.swift in Sources */, DA872B152BDDEE1A008B1012 /* VisualEffectView.swift in Sources */, - DA3429AE2EB9C0280010784E /* HotlineFileUploadClientNew.swift in Sources */, + DA3429AE2EB9C0280010784E /* HotlineFileUploadClient.swift in Sources */, DAAEE66D2B475F1400A5BA07 /* PreviewFileInfo.swift in Sources */, DAB4D8842B4CABEF0048A05C /* Extensions.swift in Sources */, DA0D698D2B1E7CF700C71DF5 /* UsersView.swift in Sources */, @@ -723,7 +723,7 @@ DAB4D87E2B4C8BCA0048A05C /* FilePreviewTextView.swift in Sources */, DA3429B72EBAB1750010784E /* QuickLookPreviewView.swift in Sources */, DA4F2C012B1A558E00D8ADDC /* ChatView.swift in Sources */, - DA5268B32EB6806E00DCB941 /* HotlineFileDownloadClientNew.swift in Sources */, + DA5268B32EB6806E00DCB941 /* HotlineFileDownloadClient.swift in Sources */, DACCE5E32EABE86A008CDD92 /* AppUpdateView.swift in Sources */, DAC6B2E02EAC6236004E2CBA /* ChatStore.swift in Sources */, DA32CD492B2931640053B98B /* User.swift in Sources */, diff --git a/Hotline/Hotline/HotlineClientNew.swift b/Hotline/Hotline/HotlineClientNew.swift index 854c7ff..cd34d9e 100644 --- a/Hotline/Hotline/HotlineClientNew.swift +++ b/Hotline/Hotline/HotlineClientNew.swift @@ -93,11 +93,11 @@ public struct HotlineServerInfo: Sendable { // MARK: - Hotline Client -/// Modern async/await-based Hotline protocol client +/// A client for connecting to and interacting with Hotline servers. /// /// Example usage: /// ```swift -/// let client = try await HotlineClientNew.connect( +/// let client = try await HotlineClient.connect( /// host: "server.example.com", /// port: 5500, /// login: HotlineLogin(login: "guest", password: "", username: "John", iconID: 414) @@ -123,7 +123,7 @@ public struct HotlineServerInfo: Sendable { /// // Get user list /// let users = try await client.getUserList() /// ``` -public actor HotlineClientNew { +public actor HotlineClient { // MARK: - Properties private let socket: NetSocket @@ -189,23 +189,23 @@ public actor HotlineClientNew { host: String, port: UInt16 = 5500, login: HotlineLogin - ) async throws -> HotlineClientNew { - print("HotlineClientNew.connect(): Starting connection to \(host):\(port) as '\(login.username)'") + ) async throws -> HotlineClient { + print("HotlineClient.connect(): Starting connection to \(host):\(port) as '\(login.username)'") // Connect socket - print("HotlineClientNew.connect(): Connecting socket...") + print("HotlineClient.connect(): Connecting socket...") let socket = try await NetSocket.connect(host: host, port: port) - print("HotlineClientNew.connect(): Socket connected") + print("HotlineClient.connect(): Socket connected") // Perform handshake - print("HotlineClientNew.connect(): Sending handshake...") + print("HotlineClient.connect(): Sending handshake...") try await socket.write(handshakeData) let handshakeResponse = try await socket.read(8) - print("HotlineClientNew.connect(): Handshake response received") + print("HotlineClient.connect(): Handshake response received") // Verify handshake guard handshakeResponse.prefix(4) == Data([0x54, 0x52, 0x54, 0x50]) else { - print("HotlineClientNew.connect(): Invalid handshake response") + print("HotlineClient.connect(): Invalid handshake response") throw HotlineClientError.connectionFailed( NSError(domain: "HotlineClient", code: -1, userInfo: [ NSLocalizedDescriptionKey: "Invalid handshake response" @@ -215,7 +215,7 @@ public actor HotlineClientNew { let errorCode = handshakeResponse.withUnsafeBytes { $0.load(fromByteOffset: 4, as: UInt32.self) } guard errorCode.bigEndian == 0 else { - print("HotlineClientNew.connect(): Handshake failed with error code \(errorCode)") + print("HotlineClient.connect(): Handshake failed with error code \(errorCode)") throw HotlineClientError.connectionFailed( NSError(domain: "HotlineClient", code: Int(errorCode), userInfo: [ NSLocalizedDescriptionKey: "Handshake failed with error code \(errorCode)" @@ -224,24 +224,24 @@ public actor HotlineClientNew { } // Create client - print("HotlineClientNew.connect(): Creating client instance") - let client = HotlineClientNew(socket: socket) + print("HotlineClient.connect(): Creating client instance") + let client = HotlineClient(socket: socket) // Start receive loop - print("HotlineClientNew.connect(): Starting receive loop") + print("HotlineClient.connect(): Starting receive loop") await client.startReceiveLoop() // Perform login - print("HotlineClientNew.connect(): Performing login") + print("HotlineClient.connect(): Performing login") let serverInfo = try await client.performLogin(login) await client.setServerInfo(serverInfo) - print("HotlineClientNew.connect(): Login successful") + print("HotlineClient.connect(): Login successful") // Start keep-alive - print("HotlineClientNew.connect(): Starting keep-alive") + print("HotlineClient.connect(): Starting keep-alive") await client.startKeepAlive() - print("HotlineClientNew.connect(): Connected to \(serverInfo.name) (v\(serverInfo.version))") + print("HotlineClient.connect(): Connected to \(serverInfo.name) (v\(serverInfo.version))") return client } @@ -296,19 +296,19 @@ public actor HotlineClientNew { isConnected = false - print("HotlineClientNew.disconnect(): Starting disconnect") + print("HotlineClient.disconnect(): Starting disconnect") self.receiveTask?.cancel() self.keepAliveTask?.cancel() await self.socket.close() self.failAllPendingTransactions(HotlineClientError.notConnected) self.eventContinuation.finish() - print("HotlineClientNew.disconnect(): Disconnect complete") + print("HotlineClient.disconnect(): Disconnect complete") } // MARK: - Receive Loop private func startReceiveLoop() { - print("HotlineClientNew.startReceiveLoop(): Creating receive task") + print("HotlineClient.startReceiveLoop(): Creating receive task") self.receiveTask = Task { [weak self] in guard let self else { return @@ -320,21 +320,21 @@ public actor HotlineClientNew { let transaction = try await self.socket.receive(HotlineTransaction.self, endian: .big) await self.handleTransaction(transaction) } - print("HotlineClientNew.startReceiveLoop(): Task cancelled, exiting loop") + print("HotlineClient.startReceiveLoop(): Task cancelled, exiting loop") } catch { if Task.isCancelled || error is CancellationError { - print("HotlineClientNew.startReceiveLoop(): Receive loop cancelled") + print("HotlineClient.startReceiveLoop(): Receive loop cancelled") } else { - print("HotlineClientNew.startReceiveLoop(): Receive loop error: \(error)") + print("HotlineClient.startReceiveLoop(): Receive loop error: \(error)") await self.disconnect() } } - print("HotlineClientNew.startReceiveLoop(): Receive loop ended") + print("HotlineClient.startReceiveLoop(): Receive loop ended") } } private func handleTransaction(_ transaction: HotlineTransaction) { - print("HotlineClientNew: <= \(transaction.type) [\(transaction.id)]") + print("HotlineClient: <= \(transaction.type) [\(transaction.id)]") // Check if this is a reply to a pending transaction if transaction.isReply == 1 || transaction.type == .reply { @@ -348,7 +348,7 @@ public actor HotlineClientNew { private func handleReply(_ transaction: HotlineTransaction) { guard let continuation = pendingTransactions.removeValue(forKey: transaction.id) else { - print("HotlineClientNew: Received reply for unknown transaction \(transaction.id)") + print("HotlineClient: Received reply for unknown transaction \(transaction.id)") return } @@ -359,7 +359,6 @@ public actor HotlineClientNew { message: errorText )) } else { - print("HELLO") continuation.resume(returning: transaction) } } @@ -417,14 +416,15 @@ public actor HotlineClientNew { } default: - print("HotlineClientNew: Unhandled event type \(transaction.type)") + print("HotlineClient: Unhandled event type \(transaction.type)") } } // MARK: - Transaction Sending + @discardableResult private func sendTransaction(_ transaction: HotlineTransaction, timeout: TimeInterval = 30.0) async throws -> HotlineTransaction { - print("HotlineClientNew: => \(transaction.type) [\(transaction.id)]") + print("HotlineClient: => \(transaction.type) [\(transaction.id)]") let transactionID = transaction.id @@ -518,7 +518,7 @@ public actor HotlineClientNew { let _ = try? await self.getUserList() } } catch { - print("HotlineClientNew: Keep-alive failed: \(error)") + print("HotlineClient: Keep-alive failed: \(error)") } } @@ -605,7 +605,7 @@ public actor HotlineClientNew { try await socket.send(transaction, endian: .big) } - // MARK: - Public API - Files + // MARK: - Files /// Get the file list for a directory /// @@ -617,7 +617,7 @@ public actor HotlineClientNew { transaction.setFieldPath(type: .filePath, val: path) } - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) var files: [HotlineFile] = [] for field in reply.getFieldList(type: .fileNameWithInfo) { @@ -649,7 +649,7 @@ public actor HotlineClientNew { transaction.setFieldUInt32(type: .fileTransferOptions, val: 2) } - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) guard let transferSize = reply.getField(type: .transferSize)?.getInteger(), @@ -664,7 +664,7 @@ public actor HotlineClientNew { return (referenceNumber, transferSize, fileSize, waitingCount) } - // MARK: - Public API - News + // MARK: - News /// Get news categories at a path /// @@ -676,7 +676,7 @@ public actor HotlineClientNew { transaction.setFieldPath(type: .newsPath, val: path) } - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) var categories: [HotlineNewsCategory] = [] for field in reply.getFieldList(type: .newsCategoryListData15) { @@ -698,7 +698,7 @@ public actor HotlineClientNew { transaction.setFieldPath(type: .newsPath, val: path) } - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) guard let articleData = reply.getField(type: .newsArticleListData) else { return [] @@ -725,7 +725,7 @@ public actor HotlineClientNew { transaction.setFieldUInt32(type: .newsArticleID, val: id) transaction.setFieldString(type: .newsArticleDataFlavor, val: flavor, encoding: .ascii) - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) return reply.getField(type: .newsArticleData)?.getString() } @@ -754,17 +754,17 @@ public actor HotlineClientNew { transaction.setFieldUInt32(type: .newsArticleFlags, val: 0) transaction.setFieldString(type: .newsArticleData, val: text) - _ = try await sendTransaction(transaction) + try await self.sendTransaction(transaction) } - // MARK: - Public API - Message Board + // MARK: - Message Board /// Get message board posts /// /// - Returns: Array of message strings public func getMessageBoard() async throws -> [String] { let transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getMessageBoard) - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) guard let text = reply.getField(type: .data)?.getString() else { return [] @@ -784,10 +784,10 @@ public actor HotlineClientNew { var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .oldPostNews) transaction.setFieldString(type: .data, val: text, encoding: .macOSRoman) - try await socket.send(transaction, endian: .big) + try await self.socket.send(transaction, endian: .big) } - // MARK: - Public API - File Operations + // MARK: - File Operations /// Get detailed information about a file /// @@ -840,7 +840,7 @@ public actor HotlineClientNew { transaction.setFieldPath(type: .filePath, val: path) do { - _ = try await sendTransaction(transaction) + try await self.sendTransaction(transaction) return true } catch { return false @@ -854,7 +854,7 @@ public actor HotlineClientNew { /// - Returns: Array of user accounts sorted by login public func getAccounts() async throws -> [HotlineAccount] { let transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getAccounts) - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) let accountFields = reply.getFieldList(type: .data) var accounts: [HotlineAccount] = [] @@ -886,7 +886,7 @@ public actor HotlineClientNew { transaction.setFieldEncodedString(type: .userPassword, val: password) } - _ = try await sendTransaction(transaction) + try await self.sendTransaction(transaction) } /// Update an existing user account (requires admin access) @@ -919,7 +919,7 @@ public actor HotlineClientNew { transaction.setFieldEncodedString(type: .userPassword, val: password!) } - _ = try await sendTransaction(transaction) + try await self.sendTransaction(transaction) } /// Delete a user account (requires admin access) @@ -929,7 +929,7 @@ public actor HotlineClientNew { var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .deleteUser) transaction.setFieldEncodedString(type: .userLogin, val: login) - _ = try await sendTransaction(transaction) + try await self.sendTransaction(transaction) } // MARK: - Banners @@ -940,7 +940,7 @@ public actor HotlineClientNew { /// - Throws: HotlineClientError if not connected or server doesn't support banners public func downloadBanner() async throws -> (referenceNumber: UInt32, transferSize: Int)? { let transaction = HotlineTransaction(id: self.generateTransactionID(), type: .downloadBanner) - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) guard let transferSizeField = reply.getField(type: .transferSize), @@ -972,7 +972,7 @@ public actor HotlineClientNew { transaction.setFieldUInt32(type: .fileTransferOptions, val: 2) } - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) guard let transferSizeField = reply.getField(type: .transferSize), @@ -1000,7 +1000,7 @@ public actor HotlineClientNew { transaction.setFieldString(type: .fileName, val: name) transaction.setFieldPath(type: .filePath, val: path) - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) guard let transferSizeField = reply.getField(type: .transferSize), @@ -1027,7 +1027,7 @@ public actor HotlineClientNew { transaction.setFieldString(type: .fileName, val: name) transaction.setFieldPath(type: .filePath, val: path) - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) guard let transferReferenceField = reply.getField(type: .referenceNumber), @@ -1046,7 +1046,7 @@ public actor HotlineClientNew { /// - path: Directory path where the folder should be uploaded /// - Returns: Reference number for the upload transfer public func uploadFolder(name: String, path: [String], fileCount: UInt32, totalSize: UInt32) async throws -> UInt32? { - print("HotlineClientNew: uploadFolder request - name='\(name)', path=\(path), fileCount=\(fileCount), totalSize=\(totalSize)") + print("HotlineClient: uploadFolder request - name='\(name)', path=\(path), fileCount=\(fileCount), totalSize=\(totalSize)") var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .uploadFolder) transaction.setFieldString(type: .fileName, val: name) @@ -1054,7 +1054,7 @@ public actor HotlineClientNew { transaction.setFieldUInt32(type: .transferSize, val: totalSize) transaction.setFieldUInt16(type: .folderItemCount, val: UInt16(truncatingIfNeeded: fileCount)) - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) guard let transferReferenceField = reply.getField(type: .referenceNumber), diff --git a/Hotline/Hotline/Transfers/HotlineFileDownloadClient.swift b/Hotline/Hotline/Transfers/HotlineFileDownloadClient.swift new file mode 100644 index 0000000..82f61d4 --- /dev/null +++ b/Hotline/Hotline/Transfers/HotlineFileDownloadClient.swift @@ -0,0 +1,269 @@ +import Foundation +import Network + +public enum HotlineDownloadLocation: Sendable { + case url(URL) + case downloads(String) // filename +} + +public enum HotlineTransferProgress: Sendable { + case error(Error) // An error occurred + case unconnected // Initial state + case preparing // Preparing to begin + case connecting // Connecting to server + case connected // Connected to server + case transfer(name: String, size: Int, total: Int, progress: Double, speed: Double?, estimate: TimeInterval?) // size transferred, total size, progress (0.0-1.0), speed (in bytes/sec), time remaining + case completed(url: URL?) // Download or upload complete (local url valid for downloads) +} + + +@MainActor +public class HotlineFileDownloadClient: @MainActor HotlineTransferClient { + public struct Configuration: Sendable { + public var chunkSize: Int = 256 * 1024 + public init() {} + } + + private let serverAddress: String + private let serverPort: UInt16 + private let referenceNumber: UInt32 + + private let config: Configuration + + private var transferSize: Int + private let transferTotal: Int + private var transferProgress: Progress + + private var socket: NetSocket? + private var downloadTask: Task? + + public init( + address: String, + port: UInt16, + reference: UInt32, + size: UInt32, + configuration: Configuration = .init() + ) { + self.serverAddress = address + self.serverPort = port + self.referenceNumber = reference + self.config = configuration + + self.transferTotal = Int(size) + self.transferSize = 0 + self.transferProgress = Progress(totalUnitCount: Int64(self.transferTotal)) + } + + // MARK: - API + + public func download( + to location: HotlineDownloadLocation, + progress progressHandler: (@Sendable (HotlineTransferProgress) throws -> Void)? = nil + ) async throws -> URL { + self.downloadTask?.cancel() + + let task = Task { + try await performDownload(to: location, progressHandler: progressHandler) + } + self.downloadTask = task + + do { + let url = try await task.value + self.downloadTask = nil + return url + } + catch { + self.downloadTask = nil + try? progressHandler?(.error(error)) + throw error + } + } + + /// Cancel the current download + public func cancel() { + self.downloadTask?.cancel() + self.downloadTask = nil + } + + // MARK: - Implementation + + private func updateProgress(sent: Int) throws { + self.transferSize = sent + self.transferProgress.completedUnitCount = Int64(sent) + try self.checkCancelled() + } + + private func checkCancelled() throws { + if Task.isCancelled { + throw CancellationError() + } + + // People can cancel a transfer from the file icon in the Finder. + // This code handles that. + if self.transferProgress.isCancelled { + throw CancellationError() + } + } + + private func performDownload( + to destination: HotlineDownloadLocation, + progressHandler: (@Sendable (HotlineTransferProgress) throws -> Void)? + ) async throws -> URL { + + let fm = FileManager.default + var fileHandle: FileHandle? + var resourceForkData: Data? + + try progressHandler?(.preparing) + + // Determine the download name + // Determine destination URL based on location + let destinationURL: URL + let destinationFilename: String + switch destination { + case .url(let url): + destinationURL = url.resolvingSymlinksInPath() + destinationFilename = destinationURL.lastPathComponent + case .downloads(let filename): + var downloadsURL = fm.urls(for: .downloadsDirectory, in: .userDomainMask)[0] + downloadsURL = downloadsURL.resolvingSymlinksInPath() + destinationURL = URL(filePath: downloadsURL.generateUniqueFilePath(filename: filename)) + destinationFilename = destinationURL.lastPathComponent + } + + try self.checkCancelled() + try progressHandler?(.connecting) + + // Connect to transfer server + let socket = try await NetSocket.connect( + host: self.serverAddress, + port: self.serverPort + 1 + ) + defer { Task { await socket.close() } } + self.socket = socket + + // See if we've been cancelled + try self.checkCancelled() + + // Send magic header + try await socket.write(Data(endian: .big) { + "HTXF".fourCharCode() + self.referenceNumber + UInt32.zero + UInt32.zero + }) + + // Read file header + let headerData = try await socket.read(HotlineFileHeader.DataSize) + guard let header = HotlineFileHeader(from: headerData) else { + throw HotlineTransferClientError.failedToTransfer + } + + // Connected + try progressHandler?(.connected) + + do { + // Process each fork + for _ in 0..? - - public init( - address: String, - port: UInt16, - reference: UInt32, - size: UInt32, - configuration: Configuration = .init() - ) { - self.serverAddress = address - self.serverPort = port - self.referenceNumber = reference - self.config = configuration - - self.transferTotal = Int(size) - self.transferSize = 0 - self.transferProgress = Progress(totalUnitCount: Int64(self.transferTotal)) - } - - // MARK: - API - - public func download( - to location: HotlineDownloadLocation, - progress progressHandler: (@Sendable (HotlineTransferProgress) throws -> Void)? = nil - ) async throws -> URL { - self.downloadTask?.cancel() - - let task = Task { - try await performDownload(to: location, progressHandler: progressHandler) - } - self.downloadTask = task - - do { - let url = try await task.value - self.downloadTask = nil - return url - } - catch { - self.downloadTask = nil - try? progressHandler?(.error(error)) - throw error - } - } - - /// Cancel the current download - public func cancel() { - self.downloadTask?.cancel() - self.downloadTask = nil - } - - // MARK: - Implementation - - private func updateProgress(sent: Int) throws { - self.transferSize = sent - self.transferProgress.completedUnitCount = Int64(sent) - try self.checkCancelled() - } - - private func checkCancelled() throws { - if Task.isCancelled { - throw CancellationError() - } - - // People can cancel a transfer from the file icon in the Finder. - // This code handles that. - if self.transferProgress.isCancelled { - throw CancellationError() - } - } - - private func performDownload( - to destination: HotlineDownloadLocation, - progressHandler: (@Sendable (HotlineTransferProgress) throws -> Void)? - ) async throws -> URL { - - let fm = FileManager.default - var fileHandle: FileHandle? - var resourceForkData: Data? - - try progressHandler?(.preparing) - - // Determine the download name - // Determine destination URL based on location - let destinationURL: URL - let destinationFilename: String - switch destination { - case .url(let url): - destinationURL = url.resolvingSymlinksInPath() - destinationFilename = destinationURL.lastPathComponent - case .downloads(let filename): - var downloadsURL = fm.urls(for: .downloadsDirectory, in: .userDomainMask)[0] - downloadsURL = downloadsURL.resolvingSymlinksInPath() - destinationURL = URL(filePath: downloadsURL.generateUniqueFilePath(filename: filename)) - destinationFilename = destinationURL.lastPathComponent - } - - try self.checkCancelled() - try progressHandler?(.connecting) - - // Connect to transfer server - let socket = try await NetSocket.connect( - host: self.serverAddress, - port: self.serverPort + 1 - ) - defer { Task { await socket.close() } } - self.socket = socket - - // See if we've been cancelled - try self.checkCancelled() - - // Send magic header - try await socket.write(Data(endian: .big) { - "HTXF".fourCharCode() - self.referenceNumber - UInt32.zero - UInt32.zero - }) - - // Read file header - let headerData = try await socket.read(HotlineFileHeader.DataSize) - guard let header = HotlineFileHeader(from: headerData) else { - throw HotlineTransferClientError.failedToTransfer - } - - // Connected - try progressHandler?(.connected) - - do { - // Process each fork - for _ in 0..? + private var temporaryFileURL: URL? + + public init( + fileName: String, + address: String, + port: UInt16, + reference: UInt32, + size: UInt32, + fileType: String? = nil, + fileCreator: String? = nil + ) { + self.fileName = fileName + self.serverAddress = address + self.serverPort = port + self.referenceNumber = reference + self.transferSize = size + self.fileType = fileType + self.fileCreator = fileCreator + } + + // MARK: - API + + /// Download file to temporary location for preview + /// - Parameter progressHandler: Optional progress callback + /// - Returns: URL to temporary file for preview + public func preview( + progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil + ) async throws -> URL { + self.previewTask?.cancel() + + let task = Task { + try await performPreview(progressHandler: progressHandler) + } + self.previewTask = task + + do { + let url = try await task.value + self.previewTask = nil + return url + } catch { + print("HotlineFilePreviewClient[\(referenceNumber)]: Failed to preview file: \(error)") + self.previewTask = nil + progressHandler?(.error(error)) + throw error + } + } + + /// Cancel the current preview download + public func cancel() { + self.previewTask?.cancel() + self.previewTask = nil + self.downloadClient?.cancel() + } + + /// Manually cleanup temporary file + /// Call this when preview is complete and you no longer need the file + public func cleanup() { + self.cleanupTempFile() + } + + // MARK: - Implementation + + private func performPreview( + progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? + ) async throws -> URL { + + // Create temporary file path directly in system temp directory + let tempDir = FileManager.default.temporaryDirectory + let uniqueFileName = "\(UUID().uuidString)_\(self.fileName)" + let tempFileURL = tempDir.appendingPathComponent(uniqueFileName) + self.temporaryFileURL = tempFileURL + + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Downloading to temp: \(tempFileURL.path)") + + progressHandler?(.connecting) + + // Connect to transfer server + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Connecting to \(self.serverAddress):\(self.serverPort + 1)") + let socket = try await NetSocket.connect( + host: self.serverAddress, + port: self.serverPort + 1 + ) + defer { Task { await socket.close() } } + + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Connected!") + + // Send magic header for raw data download + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Sending magic header") + try await socket.write(Data(endian: .big) { + "HTXF".fourCharCode() + self.referenceNumber + UInt32.zero + UInt32.zero + }) + + progressHandler?(.connected) + + // Stream raw data directly to temp file with progress tracking + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Streaming \(self.transferSize) bytes to temp file") + + let totalSize = Int(self.transferSize) + + // Create empty file (with HFS attributes if available) + var attributes: [FileAttributeKey: Any] = [:] + if let creator = self.fileCreator, !creator.isBlank { + attributes[.hfsCreatorCode] = creator.fourCharCode() as NSNumber + } + if let type = self.fileType, !type.isBlank { + attributes[.hfsTypeCode] = type.fourCharCode() as NSNumber + } + + guard FileManager.default.createFile(atPath: tempFileURL.path, contents: nil, attributes: attributes) else { + throw HotlineTransferClientError.failedToTransfer + } + + let fileHandle = try FileHandle(forWritingTo: tempFileURL) + defer { try? fileHandle.close() } + + let updates = await socket.receiveFile(to: fileHandle, length: totalSize) + for try await p in updates { + progressHandler?(.transfer( + name: uniqueFileName, + size: p.sent, + total: totalSize, + progress: totalSize > 0 ? Double(p.sent) / Double(totalSize) : 0.0, + speed: p.bytesPerSecond, + estimate: p.estimatedTimeRemaining + )) + } + + progressHandler?(.completed(url: tempFileURL)) + + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Preview file ready at \(tempFileURL.path)") + + return tempFileURL + } + + private func cleanupTempFile() { + guard let tempURL = self.temporaryFileURL else { return } + self.temporaryFileURL = nil + + // Delete the temp file + try? FileManager.default.removeItem(at: tempURL) + + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Cleaned up temp file") + } +} diff --git a/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift b/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift deleted file mode 100644 index 5cf5628..0000000 --- a/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift +++ /dev/null @@ -1,146 +0,0 @@ -import Foundation -import Network - -@MainActor -public class HotlineFilePreviewClient { - private let serverAddress: String - private let serverPort: UInt16 - private let referenceNumber: UInt32 - private let fileName: String - private let transferSize: UInt32 - - private var downloadClient: HotlineFileDownloadClient? - private var previewTask: Task? - private var temporaryFileURL: URL? - - public init( - fileName: String, - address: String, - port: UInt16, - reference: UInt32, - size: UInt32 - ) { - self.fileName = fileName - self.serverAddress = address - self.serverPort = port - self.referenceNumber = reference - self.transferSize = size - } - - // MARK: - API - - /// Download file to temporary location for preview - /// - Parameter progressHandler: Optional progress callback - /// - Returns: URL to temporary file for preview - public func preview( - progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil - ) async throws -> URL { - self.previewTask?.cancel() - - let task = Task { - try await performPreview(progressHandler: progressHandler) - } - self.previewTask = task - - do { - let url = try await task.value - self.previewTask = nil - return url - } catch { - print("HotlineFilePreviewClient[\(referenceNumber)]: Failed to preview file: \(error)") - self.previewTask = nil - progressHandler?(.error(error)) - throw error - } - } - - /// Cancel the current preview download - public func cancel() { - self.previewTask?.cancel() - self.previewTask = nil - self.downloadClient?.cancel() - } - - /// Manually cleanup temporary file - /// Call this when preview is complete and you no longer need the file - public func cleanup() { - self.cleanupTempFile() - } - - // MARK: - Implementation - - private func performPreview( - progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? - ) async throws -> URL { - - // Create temporary file path directly in system temp directory - let tempDir = FileManager.default.temporaryDirectory - let uniqueFileName = "\(UUID().uuidString)_\(self.fileName)" - let tempFileURL = tempDir.appendingPathComponent(uniqueFileName) - self.temporaryFileURL = tempFileURL - - print("HotlineFilePreviewClient[\(self.referenceNumber)]: Downloading to temp: \(tempFileURL.path)") - - progressHandler?(.connecting) - - // Connect to transfer server - print("HotlineFilePreviewClient[\(self.referenceNumber)]: Connecting to \(self.serverAddress):\(self.serverPort + 1)") - let socket = try await NetSocket.connect( - host: self.serverAddress, - port: self.serverPort + 1 - ) - defer { Task { await socket.close() } } - - print("HotlineFilePreviewClient[\(self.referenceNumber)]: Connected!") - - // Send magic header for raw data download - print("HotlineFilePreviewClient[\(self.referenceNumber)]: Sending magic header") - try await socket.write(Data(endian: .big) { - "HTXF".fourCharCode() - self.referenceNumber - UInt32.zero - UInt32.zero - }) - - progressHandler?(.connected) - - // Stream raw data directly to temp file with progress tracking - print("HotlineFilePreviewClient[\(self.referenceNumber)]: Streaming \(transferSize) bytes to temp file") - - let totalSize = Int(transferSize) - - // Create empty file - FileManager.default.createFile(atPath: tempFileURL.path(percentEncoded: false), contents: nil) - - let fileHandle = try FileHandle(forWritingTo: tempFileURL) - defer { try? fileHandle.close() } - - let updates = await socket.receiveFile(to: fileHandle, length: totalSize) - for try await p in updates { - progressHandler?(.transfer( - name: uniqueFileName, - size: p.sent, - total: totalSize, - progress: totalSize > 0 ? Double(p.sent) / Double(totalSize) : 0.0, - speed: p.bytesPerSecond, - estimate: p.estimatedTimeRemaining - )) - } - - progressHandler?(.completed(url: tempFileURL)) - - print("HotlineFilePreviewClient[\(self.referenceNumber)]: Preview file ready at \(tempFileURL.path)") - - return tempFileURL - } - - private func cleanupTempFile() { - guard let tempURL = self.temporaryFileURL else { return } - self.temporaryFileURL = nil - - // Delete the temp file - try? FileManager.default.removeItem(at: tempURL) - - print("HotlineFilePreviewClient[\(self.referenceNumber)]: Cleaned up temp file") - } -} diff --git a/Hotline/Hotline/Transfers/HotlineFileUploadClient.swift b/Hotline/Hotline/Transfers/HotlineFileUploadClient.swift new file mode 100644 index 0000000..384e9bd --- /dev/null +++ b/Hotline/Hotline/Transfers/HotlineFileUploadClient.swift @@ -0,0 +1,225 @@ +import Foundation +import Network + +@MainActor +public class HotlineFileUploadClient: @MainActor HotlineTransferClient { + public struct Configuration: Sendable { + public var chunkSize: Int = 256 * 1024 + public init() {} + } + + private let serverAddress: String + private let serverPort: UInt16 + private let referenceNumber: UInt32 + private let fileURL: URL + + private let config: Configuration + + private var transferSize: Int + private let transferTotal: Int + private var transferProgress: Progress + + private var socket: NetSocket? + private var uploadTask: Task? + + public init?( + fileURL: URL, + address: String, + port: UInt16, + reference: UInt32, + configuration: Configuration = .init() + ) { + // Validate file and get total size + guard let payloadSize = FileManager.default.getFlattenedFileSize(fileURL) else { + return nil + } + + guard FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false)) else { + return nil + } + + self.serverAddress = address + self.serverPort = port + self.referenceNumber = reference + self.fileURL = fileURL + self.config = configuration + + self.transferTotal = Int(payloadSize) + self.transferSize = 0 + self.transferProgress = Progress(totalUnitCount: Int64(self.transferTotal)) + } + + // MARK: - Public API + + public func upload( + progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil + ) async throws { + self.uploadTask?.cancel() + + let task = Task { + try await performUpload(progressHandler: progressHandler) + } + self.uploadTask = task + + do { + try await task.value + self.uploadTask = nil + } catch { + print("HotlineFileUploadClient[\(self.referenceNumber)]: Failed to upload file: \(error)") + self.uploadTask = nil + progressHandler?(.error(error)) + throw error + } + } + + /// Cancel the current upload + public func cancel() { + self.uploadTask?.cancel() + self.uploadTask = nil + + if let socket = self.socket { + Task { + await socket.close() + } + } + } + + // MARK: - Implementation + + private func updateProgress(sent: Int, speed: Double? = nil, estimate: TimeInterval? = nil) { + self.transferSize = sent + self.transferProgress.completedUnitCount = Int64(sent) + + if self.transferProgress.isCancelled { + self.cancel() + } + } + + private func performUpload( + progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? + ) async throws { + let filename = self.fileURL.lastPathComponent + + progressHandler?(.connecting) + + // Start accessing security-scoped resource + let didStartAccess = fileURL.startAccessingSecurityScopedResource() + defer { + if didStartAccess { + fileURL.stopAccessingSecurityScopedResource() + } + } + + // Connect to transfer server + let socket = try await NetSocket.connect( + host: self.serverAddress, + port: self.serverPort + 1 + ) + defer { Task { await socket.close() } } + self.socket = socket + + // Get file metadata + guard let infoFork = HotlineFileInfoFork(file: self.fileURL) else { + throw HotlineTransferClientError.failedToTransfer + } + + guard let header = HotlineFileHeader(file: self.fileURL) else { + throw HotlineTransferClientError.failedToTransfer + } + + guard let forkSizes = try? FileManager.default.getFileForkSizes(self.fileURL) else { + throw HotlineTransferClientError.failedToTransfer + } + + let infoForkData = infoFork.data() + let dataForkSize = forkSizes.dataForkSize + let resourceForkSize = forkSizes.resourceForkSize + + // Configure progress for Finder if enabled + self.transferProgress.fileURL = self.fileURL.resolvingSymlinksInPath() + self.transferProgress.fileOperationKind = .uploading + self.transferProgress.publish() + + // Connected + progressHandler?(.connected) + + // Send magic header + try await socket.write(Data(endian: .big) { + "HTXF".fourCharCode() + self.referenceNumber + UInt32(self.transferTotal) + UInt32.zero + }) + + var totalBytesSent = 0 + + // MARK: - Info Fork + // Send file header + let headerData = header.data() + try await socket.write(headerData) + totalBytesSent += headerData.count + + // Send info fork header + let infoForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.info.rawValue, dataSize: UInt32(infoForkData.count)) + try await socket.write(infoForkHeader.data()) + totalBytesSent += HotlineFileForkHeader.DataSize + + // Send info fork + try await socket.write(infoForkData) + totalBytesSent += infoForkData.count + + self.updateProgress(sent: totalBytesSent) + progressHandler?(.transfer(name: filename, size: self.transferSize, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: nil, estimate: nil)) + + // MARK: - Data Fork + // Send data fork (if present) + if dataForkSize > 0 { + // Data fork header + let dataForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.data.rawValue, dataSize: dataForkSize) + try await socket.write(dataForkHeader.data()) + totalBytesSent += HotlineFileForkHeader.DataSize + + // Stream data fork from disk + let fileHandle = try FileHandle(forReadingFrom: self.fileURL) + defer { try? fileHandle.close() } + + let updates = await socket.writeFile(from: fileHandle, length: Int(dataForkSize)) + for try await p in updates { + let bytesSentNow = totalBytesSent + p.sent + self.updateProgress(sent: bytesSentNow, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining) + progressHandler?(.transfer(name: filename, size: bytesSentNow, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining)) + } + + totalBytesSent += Int(dataForkSize) + } + + // MARK: - Resource Fork + // Send resource fork (if present) + if resourceForkSize > 0 { + let resourceURL = self.fileURL.urlForResourceFork() + + // Resource fork header + let resourceForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.resource.rawValue, dataSize: resourceForkSize) + try await socket.write(resourceForkHeader.data()) + totalBytesSent += HotlineFileForkHeader.DataSize + + // Stream resource fork from disk + let resourceHandle = try FileHandle(forReadingFrom: resourceURL) + defer { try? resourceHandle.close() } + + let updates = await socket.writeFile(from: resourceHandle, length: Int(resourceForkSize)) + for try await p in updates { + let bytesSentNow = totalBytesSent + p.sent + self.updateProgress(sent: bytesSentNow, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining) + progressHandler?(.transfer(name: filename, size: bytesSentNow, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining)) + } + + totalBytesSent += Int(resourceForkSize) + } + + self.transferProgress.unpublish() + progressHandler?(.completed(url: nil)) + + print("HotlineFileUploadClient[\(self.referenceNumber)]: Complete!") + } +} diff --git a/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift deleted file mode 100644 index 384e9bd..0000000 --- a/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift +++ /dev/null @@ -1,225 +0,0 @@ -import Foundation -import Network - -@MainActor -public class HotlineFileUploadClient: @MainActor HotlineTransferClient { - public struct Configuration: Sendable { - public var chunkSize: Int = 256 * 1024 - public init() {} - } - - private let serverAddress: String - private let serverPort: UInt16 - private let referenceNumber: UInt32 - private let fileURL: URL - - private let config: Configuration - - private var transferSize: Int - private let transferTotal: Int - private var transferProgress: Progress - - private var socket: NetSocket? - private var uploadTask: Task? - - public init?( - fileURL: URL, - address: String, - port: UInt16, - reference: UInt32, - configuration: Configuration = .init() - ) { - // Validate file and get total size - guard let payloadSize = FileManager.default.getFlattenedFileSize(fileURL) else { - return nil - } - - guard FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false)) else { - return nil - } - - self.serverAddress = address - self.serverPort = port - self.referenceNumber = reference - self.fileURL = fileURL - self.config = configuration - - self.transferTotal = Int(payloadSize) - self.transferSize = 0 - self.transferProgress = Progress(totalUnitCount: Int64(self.transferTotal)) - } - - // MARK: - Public API - - public func upload( - progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil - ) async throws { - self.uploadTask?.cancel() - - let task = Task { - try await performUpload(progressHandler: progressHandler) - } - self.uploadTask = task - - do { - try await task.value - self.uploadTask = nil - } catch { - print("HotlineFileUploadClient[\(self.referenceNumber)]: Failed to upload file: \(error)") - self.uploadTask = nil - progressHandler?(.error(error)) - throw error - } - } - - /// Cancel the current upload - public func cancel() { - self.uploadTask?.cancel() - self.uploadTask = nil - - if let socket = self.socket { - Task { - await socket.close() - } - } - } - - // MARK: - Implementation - - private func updateProgress(sent: Int, speed: Double? = nil, estimate: TimeInterval? = nil) { - self.transferSize = sent - self.transferProgress.completedUnitCount = Int64(sent) - - if self.transferProgress.isCancelled { - self.cancel() - } - } - - private func performUpload( - progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? - ) async throws { - let filename = self.fileURL.lastPathComponent - - progressHandler?(.connecting) - - // Start accessing security-scoped resource - let didStartAccess = fileURL.startAccessingSecurityScopedResource() - defer { - if didStartAccess { - fileURL.stopAccessingSecurityScopedResource() - } - } - - // Connect to transfer server - let socket = try await NetSocket.connect( - host: self.serverAddress, - port: self.serverPort + 1 - ) - defer { Task { await socket.close() } } - self.socket = socket - - // Get file metadata - guard let infoFork = HotlineFileInfoFork(file: self.fileURL) else { - throw HotlineTransferClientError.failedToTransfer - } - - guard let header = HotlineFileHeader(file: self.fileURL) else { - throw HotlineTransferClientError.failedToTransfer - } - - guard let forkSizes = try? FileManager.default.getFileForkSizes(self.fileURL) else { - throw HotlineTransferClientError.failedToTransfer - } - - let infoForkData = infoFork.data() - let dataForkSize = forkSizes.dataForkSize - let resourceForkSize = forkSizes.resourceForkSize - - // Configure progress for Finder if enabled - self.transferProgress.fileURL = self.fileURL.resolvingSymlinksInPath() - self.transferProgress.fileOperationKind = .uploading - self.transferProgress.publish() - - // Connected - progressHandler?(.connected) - - // Send magic header - try await socket.write(Data(endian: .big) { - "HTXF".fourCharCode() - self.referenceNumber - UInt32(self.transferTotal) - UInt32.zero - }) - - var totalBytesSent = 0 - - // MARK: - Info Fork - // Send file header - let headerData = header.data() - try await socket.write(headerData) - totalBytesSent += headerData.count - - // Send info fork header - let infoForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.info.rawValue, dataSize: UInt32(infoForkData.count)) - try await socket.write(infoForkHeader.data()) - totalBytesSent += HotlineFileForkHeader.DataSize - - // Send info fork - try await socket.write(infoForkData) - totalBytesSent += infoForkData.count - - self.updateProgress(sent: totalBytesSent) - progressHandler?(.transfer(name: filename, size: self.transferSize, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: nil, estimate: nil)) - - // MARK: - Data Fork - // Send data fork (if present) - if dataForkSize > 0 { - // Data fork header - let dataForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.data.rawValue, dataSize: dataForkSize) - try await socket.write(dataForkHeader.data()) - totalBytesSent += HotlineFileForkHeader.DataSize - - // Stream data fork from disk - let fileHandle = try FileHandle(forReadingFrom: self.fileURL) - defer { try? fileHandle.close() } - - let updates = await socket.writeFile(from: fileHandle, length: Int(dataForkSize)) - for try await p in updates { - let bytesSentNow = totalBytesSent + p.sent - self.updateProgress(sent: bytesSentNow, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining) - progressHandler?(.transfer(name: filename, size: bytesSentNow, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining)) - } - - totalBytesSent += Int(dataForkSize) - } - - // MARK: - Resource Fork - // Send resource fork (if present) - if resourceForkSize > 0 { - let resourceURL = self.fileURL.urlForResourceFork() - - // Resource fork header - let resourceForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.resource.rawValue, dataSize: resourceForkSize) - try await socket.write(resourceForkHeader.data()) - totalBytesSent += HotlineFileForkHeader.DataSize - - // Stream resource fork from disk - let resourceHandle = try FileHandle(forReadingFrom: resourceURL) - defer { try? resourceHandle.close() } - - let updates = await socket.writeFile(from: resourceHandle, length: Int(resourceForkSize)) - for try await p in updates { - let bytesSentNow = totalBytesSent + p.sent - self.updateProgress(sent: bytesSentNow, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining) - progressHandler?(.transfer(name: filename, size: bytesSentNow, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining)) - } - - totalBytesSent += Int(resourceForkSize) - } - - self.transferProgress.unpublish() - progressHandler?(.completed(url: nil)) - - print("HotlineFileUploadClient[\(self.referenceNumber)]: Complete!") - } -} diff --git a/Hotline/Hotline/Transfers/HotlineFolderDownloadClient.swift b/Hotline/Hotline/Transfers/HotlineFolderDownloadClient.swift new file mode 100644 index 0000000..36193bd --- /dev/null +++ b/Hotline/Hotline/Transfers/HotlineFolderDownloadClient.swift @@ -0,0 +1,450 @@ +import Foundation +import Network + +/// Item progress callback for folder downloads +public struct HotlineFolderItemProgress: Sendable { + public let fileName: String + public let itemNumber: Int + public let totalItems: Int +} + +@MainActor +public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { + private let serverAddress: String + private let serverPort: UInt16 + private let referenceNumber: UInt32 + + private let transferTotal: Int + private let folderItemCount: Int + private var transferSize: Int = 0 + + private var socket: NetSocket? + private var downloadTask: Task? + private var folderProgress: Progress? + + public init( + address: String, + port: UInt16, + reference: UInt32, + size: UInt32, + itemCount: Int + ) { + self.serverAddress = address + self.serverPort = port + self.referenceNumber = reference + self.transferTotal = Int(size) + self.folderItemCount = itemCount + } + + // MARK: - API + + public func download( + to location: HotlineDownloadLocation, + progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil, + itemProgress itemProgressHandler: (@Sendable (HotlineFolderItemProgress) -> Void)? = nil + ) async throws -> URL { + self.downloadTask?.cancel() + + let task = Task { + try await performDownload( + to: location, + progressHandler: progressHandler, + itemProgressHandler: itemProgressHandler + ) + } + self.downloadTask = task + + do { + let url = try await task.value + self.downloadTask = nil + return url + } catch { + print("HotlineFolderDownloadClient[\(referenceNumber)]: Failed to download folder: \(error)") + self.downloadTask = nil + progressHandler?(.error(error)) + throw error + } + } + + /// Cancel the current download + public func cancel() { + downloadTask?.cancel() + downloadTask = nil + + if let socket = socket { + Task { + await socket.close() + } + } + } + + // MARK: - Implementation + + private func performDownload( + to destination: HotlineDownloadLocation, + progressHandler: (@Sendable (HotlineTransferProgress) -> Void)?, + itemProgressHandler: (@Sendable (HotlineFolderItemProgress) -> Void)? + ) async throws -> URL { + + var destinationFilename: String + + progressHandler?(.connecting) + + // Connect to transfer server + let socket = try await connectToTransferServer() + self.socket = socket + defer { Task { await socket.close() } } + + // Determine destination folder URL + let fm = FileManager.default + let destinationURL: URL + + switch destination { + case .url(let url): + destinationURL = url + destinationFilename = url.lastPathComponent + case .downloads(let filename): + let downloadsURL = fm.urls(for: .downloadsDirectory, in: .userDomainMask)[0] + destinationURL = URL(filePath: downloadsURL.generateUniqueFilePath(filename: filename)) + destinationFilename = destinationURL.lastPathComponent + } + + print("HotlineFolderDownloadClient[\(referenceNumber)]: Downloading folder to \(destinationURL.path)") + + // Create destination folder + try? fm.removeItem(at: destinationURL) + try fm.createDirectory(at: destinationURL, withIntermediateDirectories: true) + + // Create and publish progress for the entire folder (shows in Finder) + let progress = Progress(totalUnitCount: Int64(self.transferTotal)) + progress.fileURL = destinationURL + progress.fileOperationKind = .downloading + progress.publish() + self.folderProgress = progress + + // Send initial magic header + print("HotlineFolderDownloadClient[\(self.referenceNumber)]: Sending HTXF magic") + try await socket.write(Data(endian: .big) { + "HTXF".fourCharCode() + self.referenceNumber + UInt32.zero // data size = 0 + UInt16(1) // type = 1 (folder transfer) + UInt16.zero // reserved = 0 + HotlineFolderAction.nextFile.rawValue // action = 3 (next file) + }) + + progressHandler?(.connected) + progressHandler?(.transfer(name: destinationFilename, size: 0, total: self.transferTotal, progress: 0.0, speed: nil, estimate: nil)) + + var completedItemCount = 0 + var totalBytesTransferred = 0 + + // Process each item in the folder + while completedItemCount < self.folderItemCount { + // Read item header + let headerLenData = try await socket.read(2) + let headerLen = Int(headerLenData.readUInt16(at: 0)!) + let headerData = try await socket.read(headerLen) + + totalBytesTransferred += 2 + headerLen + + guard let (itemType, pathComponents) = self.parseItemHeaderPath(headerData) else { + throw HotlineTransferClientError.failedToTransfer + } + + let joinedPath = pathComponents.joined(separator: "/") + print("HotlineFolderDownloadClient[\(referenceNumber)]: Item type=\(itemType) path=\(joinedPath)") + + if itemType == 1 { + // Folder entry - no progress shown for folder creation + if !pathComponents.isEmpty { + let folderURL = destinationURL.appendingPathComponents(pathComponents) + try fm.createDirectory(at: folderURL, withIntermediateDirectories: true) + print("HotlineFolderDownloadClient[\(self.referenceNumber)]: Created folder at \(folderURL.path)") + } + + completedItemCount += 1 + + // Request next item if not done + if completedItemCount < folderItemCount { + try await sendAction(socket: socket, action: .nextFile) // nextFile + } + + } else if itemType == 0 { + // File entry + let parentComponents = pathComponents.dropLast() + let fileName = pathComponents.last ?? "untitled" + + // Request file download + try await sendAction(socket: socket, action: .sendFile) // sendFile + + // Read file size + let fileSizeData = try await socket.read(4) + let fileSize = fileSizeData.readUInt32(at: 0)! + totalBytesTransferred += 4 + + print("HotlineFolderDownloadClient[\(referenceNumber)]: File '\(fileName)' size: \(fileSize) bytes") + + // Notify item progress before download starts + completedItemCount += 1 + itemProgressHandler?(HotlineFolderItemProgress( + fileName: fileName, + itemNumber: completedItemCount, + totalItems: folderItemCount + )) + + // Download the file with overall folder progress tracking + let (fileURL, fileBytesRead) = try await downloadFile( + socket: socket, + fileName: fileName, + parentPath: Array(parentComponents), + destinationFolder: destinationURL, + fileSize: fileSize, + itemNumber: completedItemCount, + totalItems: folderItemCount, + totalBytesTransferredSoFar: totalBytesTransferred, + progressHandler: progressHandler + ) + + totalBytesTransferred += fileBytesRead + self.transferSize = totalBytesTransferred + + print("HotlineFolderDownloadClient[\(referenceNumber)]: Downloaded file to \(fileURL.path)") + + // Request next item if not done + if completedItemCount < folderItemCount { + try await sendAction(socket: socket, action: .nextFile) // nextFile + } + + } else { + // Unknown item type + print("HotlineFolderDownloadClient[\(referenceNumber)]: Unknown item type \(itemType), skipping") + completedItemCount += 1 + + if completedItemCount < folderItemCount { + try await sendAction(socket: socket, action: .nextFile) // nextFile + } + } + } + + print("HotlineFolderDownloadClient[\(referenceNumber)]: Download complete!") + + // Ensure folder progress shows 100% complete + self.folderProgress?.completedUnitCount = Int64(self.transferTotal) + + progressHandler?(.completed(url: destinationURL)) + + return destinationURL + } + + // MARK: - Helper Methods + + private func connectToTransferServer() async throws -> NetSocket { + print("HotlineFolderDownloadClient[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)") + + let socket = try await NetSocket.connect( + host: self.serverAddress, + port: self.serverPort + 1 + ) + + print("HotlineFolderDownloadClient[\(referenceNumber)]: Connected!") + return socket + } + + private func sendAction(socket: NetSocket, action: HotlineFolderAction) async throws { + let actionData = Data(endian: .big) { + action.rawValue + } + try await socket.write(actionData) + print("HotlineFolderDownloadClient[\(referenceNumber)]: Sent action: \(action)") + } + + private func parseItemHeaderPath(_ headerData: Data) -> (type: UInt16, components: [String])? { + // Need at least: type(2) + count(2) + guard headerData.count >= 4, + let type = headerData.readUInt16(at: 0), + let count = headerData.readUInt16(at: 2) else { return nil } + + var ofs = 4 + var comps: [String] = [] + for _ in 0..= ofs + 3 else { return nil } + // per Hotline path encoding: reserved(2) then nameLen(1) then name + ofs += 2 // reserved == 0 + let nameLen = Int(headerData.readUInt8(at: ofs)!) + ofs += 1 + guard headerData.count >= ofs + nameLen else { return nil } + let nameData = headerData.subdata(in: ofs..<(ofs + nameLen)) + ofs += nameLen + + let name = String(data: nameData, encoding: .macOSRoman) + ?? String(data: nameData, encoding: .utf8) + ?? "" + comps.append(name) + } + return (type, comps) + } + + private func downloadFile( + socket: NetSocket, + fileName: String, + parentPath: [String], + destinationFolder: URL, + fileSize: UInt32, + itemNumber: Int, + totalItems: Int, + totalBytesTransferredSoFar: Int, + progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? + ) async throws -> (url: URL, bytesRead: Int) { + let fm = FileManager.default + var bytesRead = 0 + + // Read file header + let headerData = try await socket.read(HotlineFileHeader.DataSize) + guard let header = HotlineFileHeader(from: headerData) else { + throw HotlineTransferClientError.failedToTransfer + } + bytesRead += HotlineFileHeader.DataSize + + // Update folder progress for file header + let totalBytesNow = totalBytesTransferredSoFar + bytesRead + self.folderProgress?.completedUnitCount = Int64(totalBytesNow) + + print("HotlineFolderDownloadClient[\(referenceNumber)]: File has \(header.forkCount) forks") + + var resourceForkData: Data? + var fileHandle: FileHandle? + var filePath: URL? + var fileDataForkSize: Int = 0 + + defer { + try? fileHandle?.close() + } + + // Process each fork + for _ in 0.. 0 ? Double(totalBytesNow) / Double(self.transferTotal) : 0.0 + let overallProgress = min(rawProgress, 1.0) // Clamp to 1.0 to avoid exceeding 100% + + // Update folder-level Finder progress + self.folderProgress?.completedUnitCount = Int64(totalBytesNow) + + // Calculate overall folder time estimate based on current speed + let remainingBytes = max(0, self.transferTotal - totalBytesNow) + let estimate: TimeInterval? = if let speed = p.bytesPerSecond, speed > 0, remainingBytes > 0 { + TimeInterval(remainingBytes) / speed + } else { + nil + } + + // Report overall folder progress to UI + progressHandler?(.transfer( + name: fileName, + size: totalBytesNow, + total: self.transferTotal, + progress: overallProgress, + speed: p.bytesPerSecond, + estimate: estimate + )) + } + bytesRead += fileDataForkSize + + } else if forkHeader.isResourceFork { + // Read RESOURCE fork + resourceForkData = try await socket.read(Int(forkHeader.dataSize)) + bytesRead += Int(forkHeader.dataSize) + + // Update folder progress for RESOURCE fork + let totalBytesNow = totalBytesTransferredSoFar + bytesRead + self.folderProgress?.completedUnitCount = Int64(totalBytesNow) + + } else { + // Skip unsupported fork + try await socket.skip(Int(forkHeader.dataSize)) + bytesRead += Int(forkHeader.dataSize) + + // Update folder progress for skipped fork + let totalBytesNow = totalBytesTransferredSoFar + bytesRead + self.folderProgress?.completedUnitCount = Int64(totalBytesNow) + } + } + + // Close file handle + try? fileHandle?.close() + fileHandle = nil + + guard let finalPath = filePath else { + throw HotlineTransferClientError.failedToTransfer + } + + // Write resource fork if present + if let rsrcData = resourceForkData, !rsrcData.isEmpty { + try writeResourceFork(data: rsrcData, to: finalPath) + } + + return (finalPath, bytesRead) + } + + private func writeResourceFork(data: Data, to url: URL) throws { + var resolvedURL = url + resolvedURL.resolveSymlinksInPath() + + let resourceURL = resolvedURL.urlForResourceFork() + try data.write(to: resourceURL) + } +} diff --git a/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift deleted file mode 100644 index 600b9f2..0000000 --- a/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift +++ /dev/null @@ -1,453 +0,0 @@ -import Foundation -import Network - -/// Item progress callback for folder downloads -public struct HotlineFolderItemProgress: Sendable { - public let fileName: String - public let itemNumber: Int - public let totalItems: Int -} - -@MainActor -public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient { - private let serverAddress: String - private let serverPort: UInt16 - private let referenceNumber: UInt32 - - private let transferTotal: Int - private let folderItemCount: Int - private var transferSize: Int = 0 - - private var socket: NetSocket? - private var downloadTask: Task? - private var folderProgress: Progress? - - // MARK: - Initialization - - public init( - address: String, - port: UInt16, - reference: UInt32, - size: UInt32, - itemCount: Int - ) { - self.serverAddress = address - self.serverPort = port - self.referenceNumber = reference - self.transferTotal = Int(size) - self.folderItemCount = itemCount - - print("HotlineFolderDownloadClientNew[\(reference)]: Server reported transferSize=\(size) bytes, folderItemCount=\(itemCount) items") - } - - // MARK: - Public API - - public func download( - to location: HotlineDownloadLocation, - progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil, - itemProgress itemProgressHandler: (@Sendable (HotlineFolderItemProgress) -> Void)? = nil - ) async throws -> URL { - self.downloadTask?.cancel() - - let task = Task { - try await performDownload( - to: location, - progressHandler: progressHandler, - itemProgressHandler: itemProgressHandler - ) - } - self.downloadTask = task - - do { - let url = try await task.value - self.downloadTask = nil - return url - } catch { - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Failed to download folder: \(error)") - self.downloadTask = nil - progressHandler?(.error(error)) - throw error - } - } - - /// Cancel the current download - public func cancel() { - downloadTask?.cancel() - downloadTask = nil - - if let socket = socket { - Task { - await socket.close() - } - } - } - - // MARK: - Private Implementation - - private func performDownload( - to destination: HotlineDownloadLocation, - progressHandler: (@Sendable (HotlineTransferProgress) -> Void)?, - itemProgressHandler: (@Sendable (HotlineFolderItemProgress) -> Void)? - ) async throws -> URL { - - var destinationFilename: String - - progressHandler?(.connecting) - - // Connect to transfer server - let socket = try await connectToTransferServer() - self.socket = socket - defer { Task { await socket.close() } } - - // Determine destination folder URL - let fm = FileManager.default - let destinationURL: URL - - switch destination { - case .url(let url): - destinationURL = url - destinationFilename = url.lastPathComponent - case .downloads(let filename): - let downloadsURL = fm.urls(for: .downloadsDirectory, in: .userDomainMask)[0] - destinationURL = URL(filePath: downloadsURL.generateUniqueFilePath(filename: filename)) - destinationFilename = destinationURL.lastPathComponent - } - - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Downloading folder to \(destinationURL.path)") - - // Create destination folder - try? fm.removeItem(at: destinationURL) - try fm.createDirectory(at: destinationURL, withIntermediateDirectories: true) - - // Create and publish progress for the entire folder (shows in Finder) - let progress = Progress(totalUnitCount: Int64(self.transferTotal)) - progress.fileURL = destinationURL - progress.fileOperationKind = .downloading - progress.publish() - self.folderProgress = progress - - // Send initial magic header - print("HotlineFolderDownloadClientNew[\(self.referenceNumber)]: Sending HTXF magic") - try await socket.write(Data(endian: .big) { - "HTXF".fourCharCode() - self.referenceNumber - UInt32.zero // data size = 0 - UInt16(1) // type = 1 (folder transfer) - UInt16.zero // reserved = 0 - HotlineFolderAction.nextFile.rawValue // action = 3 (next file) - }) - - progressHandler?(.connected) - progressHandler?(.transfer(name: destinationFilename, size: 0, total: self.transferTotal, progress: 0.0, speed: nil, estimate: nil)) - - var completedItemCount = 0 - var totalBytesTransferred = 0 - - // Process each item in the folder - while completedItemCount < self.folderItemCount { - // Read item header - let headerLenData = try await socket.read(2) - let headerLen = Int(headerLenData.readUInt16(at: 0)!) - let headerData = try await socket.read(headerLen) - - totalBytesTransferred += 2 + headerLen - - guard let (itemType, pathComponents) = self.parseItemHeaderPath(headerData) else { - throw HotlineTransferClientError.failedToTransfer - } - - let joinedPath = pathComponents.joined(separator: "/") - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Item type=\(itemType) path=\(joinedPath)") - - if itemType == 1 { - // Folder entry - no progress shown for folder creation - if !pathComponents.isEmpty { - let folderURL = destinationURL.appendingPathComponents(pathComponents) - try fm.createDirectory(at: folderURL, withIntermediateDirectories: true) - print("HotlineFolderDownloadClientNew[\(self.referenceNumber)]: Created folder at \(folderURL.path)") - } - - completedItemCount += 1 - - // Request next item if not done - if completedItemCount < folderItemCount { - try await sendAction(socket: socket, action: .nextFile) // nextFile - } - - } else if itemType == 0 { - // File entry - let parentComponents = pathComponents.dropLast() - let fileName = pathComponents.last ?? "untitled" - - // Request file download - try await sendAction(socket: socket, action: .sendFile) // sendFile - - // Read file size - let fileSizeData = try await socket.read(4) - let fileSize = fileSizeData.readUInt32(at: 0)! - totalBytesTransferred += 4 - - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: File '\(fileName)' size: \(fileSize) bytes") - - // Notify item progress before download starts - completedItemCount += 1 - itemProgressHandler?(HotlineFolderItemProgress( - fileName: fileName, - itemNumber: completedItemCount, - totalItems: folderItemCount - )) - - // Download the file with overall folder progress tracking - let (fileURL, fileBytesRead) = try await downloadFile( - socket: socket, - fileName: fileName, - parentPath: Array(parentComponents), - destinationFolder: destinationURL, - fileSize: fileSize, - itemNumber: completedItemCount, - totalItems: folderItemCount, - totalBytesTransferredSoFar: totalBytesTransferred, - progressHandler: progressHandler - ) - - totalBytesTransferred += fileBytesRead - self.transferSize = totalBytesTransferred - - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Downloaded file to \(fileURL.path)") - - // Request next item if not done - if completedItemCount < folderItemCount { - try await sendAction(socket: socket, action: .nextFile) // nextFile - } - - } else { - // Unknown item type - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Unknown item type \(itemType), skipping") - completedItemCount += 1 - - if completedItemCount < folderItemCount { - try await sendAction(socket: socket, action: .nextFile) // nextFile - } - } - } - - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Download complete!") - - // Ensure folder progress shows 100% complete - self.folderProgress?.completedUnitCount = Int64(self.transferTotal) - - progressHandler?(.completed(url: destinationURL)) - - return destinationURL - } - - // MARK: - Helper Methods - - private func connectToTransferServer() async throws -> NetSocket { - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)") - - let socket = try await NetSocket.connect( - host: self.serverAddress, - port: self.serverPort + 1 - ) - - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Connected!") - return socket - } - - private func sendAction(socket: NetSocket, action: HotlineFolderAction) async throws { - let actionData = Data(endian: .big) { - action.rawValue - } - try await socket.write(actionData) - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Sent action: \(action)") - } - - private func parseItemHeaderPath(_ headerData: Data) -> (type: UInt16, components: [String])? { - // Need at least: type(2) + count(2) - guard headerData.count >= 4, - let type = headerData.readUInt16(at: 0), - let count = headerData.readUInt16(at: 2) else { return nil } - - var ofs = 4 - var comps: [String] = [] - for _ in 0..= ofs + 3 else { return nil } - // per Hotline path encoding: reserved(2) then nameLen(1) then name - ofs += 2 // reserved == 0 - let nameLen = Int(headerData.readUInt8(at: ofs)!) - ofs += 1 - guard headerData.count >= ofs + nameLen else { return nil } - let nameData = headerData.subdata(in: ofs..<(ofs + nameLen)) - ofs += nameLen - - let name = String(data: nameData, encoding: .macOSRoman) - ?? String(data: nameData, encoding: .utf8) - ?? "" - comps.append(name) - } - return (type, comps) - } - - private func downloadFile( - socket: NetSocket, - fileName: String, - parentPath: [String], - destinationFolder: URL, - fileSize: UInt32, - itemNumber: Int, - totalItems: Int, - totalBytesTransferredSoFar: Int, - progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? - ) async throws -> (url: URL, bytesRead: Int) { - let fm = FileManager.default - var bytesRead = 0 - - // Read file header - let headerData = try await socket.read(HotlineFileHeader.DataSize) - guard let header = HotlineFileHeader(from: headerData) else { - throw HotlineTransferClientError.failedToTransfer - } - bytesRead += HotlineFileHeader.DataSize - - // Update folder progress for file header - let totalBytesNow = totalBytesTransferredSoFar + bytesRead - self.folderProgress?.completedUnitCount = Int64(totalBytesNow) - - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: File has \(header.forkCount) forks") - - var resourceForkData: Data? - var fileHandle: FileHandle? - var filePath: URL? - var fileDataForkSize: Int = 0 - - defer { - try? fileHandle?.close() - } - - // Process each fork - for _ in 0.. 0 ? Double(totalBytesNow) / Double(self.transferTotal) : 0.0 - let overallProgress = min(rawProgress, 1.0) // Clamp to 1.0 to avoid exceeding 100% - - // Update folder-level Finder progress - self.folderProgress?.completedUnitCount = Int64(totalBytesNow) - - // Calculate overall folder time estimate based on current speed - let remainingBytes = max(0, self.transferTotal - totalBytesNow) - let estimate: TimeInterval? = if let speed = p.bytesPerSecond, speed > 0, remainingBytes > 0 { - TimeInterval(remainingBytes) / speed - } else { - nil - } - - // Report overall folder progress to UI - progressHandler?(.transfer( - name: fileName, - size: totalBytesNow, - total: self.transferTotal, - progress: overallProgress, - speed: p.bytesPerSecond, - estimate: estimate - )) - } - bytesRead += fileDataForkSize - - } else if forkHeader.isResourceFork { - // Read RESOURCE fork - resourceForkData = try await socket.read(Int(forkHeader.dataSize)) - bytesRead += Int(forkHeader.dataSize) - - // Update folder progress for RESOURCE fork - let totalBytesNow = totalBytesTransferredSoFar + bytesRead - self.folderProgress?.completedUnitCount = Int64(totalBytesNow) - - } else { - // Skip unsupported fork - try await socket.skip(Int(forkHeader.dataSize)) - bytesRead += Int(forkHeader.dataSize) - - // Update folder progress for skipped fork - let totalBytesNow = totalBytesTransferredSoFar + bytesRead - self.folderProgress?.completedUnitCount = Int64(totalBytesNow) - } - } - - // Close file handle - try? fileHandle?.close() - fileHandle = nil - - guard let finalPath = filePath else { - throw HotlineTransferClientError.failedToTransfer - } - - // Write resource fork if present - if let rsrcData = resourceForkData, !rsrcData.isEmpty { - try writeResourceFork(data: rsrcData, to: finalPath) - } - - return (finalPath, bytesRead) - } - - private func writeResourceFork(data: Data, to url: URL) throws { - var resolvedURL = url - resolvedURL.resolveSymlinksInPath() - - let resourceURL = resolvedURL.urlForResourceFork() - try data.write(to: resourceURL) - } -} diff --git a/Hotline/Hotline/Transfers/HotlineFolderUploadClient.swift b/Hotline/Hotline/Transfers/HotlineFolderUploadClient.swift new file mode 100644 index 0000000..1947de6 --- /dev/null +++ b/Hotline/Hotline/Transfers/HotlineFolderUploadClient.swift @@ -0,0 +1,512 @@ +import Foundation +import Network + +/// Item progress callback for folder uploads +public struct HotlineFolderItemUploadProgress: Sendable { + public let fileName: String + public let itemNumber: Int + public let totalItems: Int +} + +/// Represents a file or folder in the upload queue +private struct FolderItem { + let url: URL + let pathComponents: [String] // Path relative to upload root + let isFolder: Bool +} + +@MainActor +public class HotlineFolderUploadClient: @MainActor HotlineTransferClient { + public struct Configuration: Sendable { + public var chunkSize: Int = 256 * 1024 + public init() {} + } + + private let serverAddress: String + private let serverPort: UInt16 + private let referenceNumber: UInt32 + private let folderURL: URL + + private let config: Configuration + + private var transferTotal: Int = 0 + private var transferSize: Int = 0 + private var folderItems: [FolderItem] = [] + private var totalItems: Int = 0 + + private var socket: NetSocket? + private var uploadTask: Task? + + public init?( + folderURL: URL, + address: String, + port: UInt16, + reference: UInt32, + configuration: Configuration = .init() + ) { + guard FileManager.default.fileExists(atPath: folderURL.path(percentEncoded: false)) else { + return nil + } + + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: folderURL.path(percentEncoded: false), isDirectory: &isDirectory), + isDirectory.boolValue else { + return nil + } + + self.serverAddress = address + self.serverPort = port + self.referenceNumber = reference + self.folderURL = folderURL + self.config = configuration + + print("HotlineFolderUploadClientNew[\(reference)]: Preparing to upload folder '\(folderURL.lastPathComponent)'") + } + + // MARK: - API + + public func upload( + progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil, + itemProgress itemProgressHandler: (@Sendable (HotlineFolderItemUploadProgress) -> Void)? = nil + ) async throws { + self.uploadTask?.cancel() + + let task = Task { + try await performUpload( + progressHandler: progressHandler, + itemProgressHandler: itemProgressHandler + ) + } + self.uploadTask = task + + do { + try await task.value + self.uploadTask = nil + } catch { + print("HotlineFolderUploadClientNew[\(referenceNumber)]: Failed to upload folder: \(error)") + self.uploadTask = nil + progressHandler?(.error(error)) + throw error + } + } + + /// Cancel the current upload + public func cancel() { + uploadTask?.cancel() + uploadTask = nil + + if let socket = socket { + Task { + await socket.close() + } + } + } + + // MARK: - Implementation + + private enum UploadStage { + case waitingForNextFile // Waiting for server to send .nextFile action + case sendingItemHeader // Sending item header to server + case waitingForFileAction // Waiting for server action after file header (.sendFile, .nextFile, .resumeFile) + case uploadingFile // Uploading file data + case done // All items uploaded + } + + private func performUpload( + progressHandler: (@Sendable (HotlineTransferProgress) -> Void)?, + itemProgressHandler: (@Sendable (HotlineFolderItemUploadProgress) -> Void)? + ) async throws { + + // Note that we're preparing now. + progressHandler?(.preparing) + + // Start accessing security-scoped resource + let didStartAccess = folderURL.startAccessingSecurityScopedResource() + defer { + if didStartAccess { + self.folderURL.stopAccessingSecurityScopedResource() + } + } + + // Build folder hierarchy (excluding root folder itself) + try buildFolderHierarchy() + + // Fast path if this is an empty folder + if self.totalItems == 0 { + progressHandler?(.completed(url: nil)) + return + } + + // Note that we're connecting now. + progressHandler?(.connecting) + + // Connect to transfer server + let socket = try await NetSocket.connect( + host: self.serverAddress, + port: self.serverPort + 1 + ) + + self.socket = socket + defer { Task { await socket.close() } } + + // Send magic header for folder upload + try await socket.write(Data(endian: .big) { + "HTXF".fourCharCode() + self.referenceNumber + UInt32.zero // data size = 0 + UInt16(1) // type = 1 (folder transfer) + UInt16.zero // reserved = 0 + }) + + progressHandler?(.connected) + + var completedItemCount = 0 + var totalBytesTransferred = 0 + var itemIndex = 0 + var stage: UploadStage = .waitingForNextFile + var currentItem: FolderItem? + + // State machine loop + while stage != .done { + switch stage { + + case .waitingForNextFile: + // Wait for server to send .nextFile action + let action = try await self.readAction(socket: socket) + guard action == .nextFile else { + throw HotlineTransferClientError.failedToTransfer + } + + // Check if we have more items to send + if itemIndex < self.folderItems.count { + currentItem = self.folderItems[itemIndex] + itemIndex += 1 + stage = .sendingItemHeader + } else { + // No more items + stage = .done + } + + case .sendingItemHeader: + // Send item header to server + guard let item = currentItem else { + throw HotlineTransferClientError.failedToTransfer + } + + // Encode and send item header + totalBytesTransferred += try await socket.write(self.encodeItemHeader(item: item)) + + // Next: wait for server's response + if item.isFolder { + // For folders, we're done with this item (just creating the directory) + completedItemCount += 1 + // Server should immediately respond with .nextFile + stage = .waitingForNextFile + } else { + // For files, server will tell us what to do + stage = .waitingForFileAction + } + + case .waitingForFileAction: + // Wait for server action after file header (.sendFile, .nextFile, .resumeFile) + guard currentItem != nil else { + throw HotlineTransferClientError.failedToTransfer + } + + let action = try await self.readAction(socket: socket) + switch action { + case .nextFile: + // Server wants to skip this file + completedItemCount += 1 + // The .nextFile action means send next item, check if we have more + if itemIndex < self.folderItems.count { + currentItem = self.folderItems[itemIndex] + itemIndex += 1 + stage = .sendingItemHeader + } else { + stage = .done + } + + case .sendFile: + // Server wants the file + completedItemCount += 1 + stage = .uploadingFile + + case .resumeFile: + // Server wants to resume + let resumeSizeData = try await socket.read(2) + let resumeSize = Int(resumeSizeData.readUInt16(at: 0)!) + let _ = try await socket.read(resumeSize) + completedItemCount += 1 + stage = .uploadingFile + } + + case .uploadingFile: + // Upload file data + guard let item = currentItem else { + throw HotlineTransferClientError.failedToTransfer + } + + // Notify item progress + itemProgressHandler?(HotlineFolderItemUploadProgress( + fileName: item.url.lastPathComponent, + itemNumber: completedItemCount, + totalItems: self.totalItems + )) + + // Upload the file + let bytesUploaded = try await self.uploadFile( + socket: socket, + fileURL: item.url, + itemNumber: completedItemCount, + totalItems: self.totalItems, + totalBytesTransferredSoFar: totalBytesTransferred, + progressHandler: progressHandler + ) + + totalBytesTransferred += bytesUploaded + self.transferSize = totalBytesTransferred + + // After uploading, wait for server to send .nextFile + stage = .waitingForNextFile + + case .done: + break + } + } + + // All items processed + progressHandler?(.completed(url: nil)) + } + + private func buildFolderHierarchy() throws { + let fm = FileManager.default + folderItems = [] + transferTotal = 0 + + let rootFolderName = folderURL.lastPathComponent + + // Recursively walk the folder + func walkFolder(at url: URL, relativePath: [String]) throws { + let contents = try fm.contentsOfDirectory(at: url, includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey], options: [.skipsHiddenFiles]) + + for itemURL in contents { + let resourceValues = try itemURL.resourceValues(forKeys: [.isDirectoryKey]) + let isDirectory = resourceValues.isDirectory ?? false + let itemName = itemURL.lastPathComponent + let itemPath = relativePath + [itemName] + + if isDirectory { + // Add folder to list + folderItems.append(FolderItem(url: itemURL, pathComponents: itemPath, isFolder: true)) + + // Recurse into subfolder + try walkFolder(at: itemURL, relativePath: itemPath) + + } else { + // Add file to list and calculate size + if let fileSize = FileManager.default.getFlattenedFileSize(itemURL) { + folderItems.append(FolderItem(url: itemURL, pathComponents: itemPath, isFolder: false)) + transferTotal += Int(fileSize) + } + } + } + } + + // Start from root folder with root name as first path component + try walkFolder(at: folderURL, relativePath: [rootFolderName]) + totalItems = folderItems.count + + print("BUILD HEIRARCHY (\(folderItems.count) items):\n", folderItems) + } + + private func encodeItemHeader(item: FolderItem) -> Data { + let strippedPath = item.pathComponents.count > 1 ? Array(item.pathComponents.dropFirst()) : item.pathComponents + let strippedPathCount = strippedPath.count + + // Build path components (Hotline format: reserved(2) + nameLen(1) + name) + var pathData = Data() + for component in strippedPath { + let nameData = component.data(using: .macOSRoman) ?? component.data(using: .utf8) ?? Data() + let nameLen = min(nameData.count, 255) + + pathData.append(contentsOf: [0, 0]) // reserved + pathData.append(UInt8(nameLen)) + pathData.append(nameData.prefix(nameLen)) + } + + // Calculate header size (this is what goes in the DataSize field) + // DataSize = isFolder(2) + pathCount(2) + pathData + let headerSize = 2 + 2 + pathData.count + + return Data(endian: .big) { + UInt16(headerSize) + UInt16(item.isFolder ? 1 : 0) + UInt16(strippedPathCount) + pathData + } + } + + private func readAction(socket: NetSocket) async throws -> HotlineFolderAction { + let actionData = try await socket.read(2) + guard let rawAction = actionData.readUInt16(at: 0), + let action = HotlineFolderAction(rawValue: rawAction) else { + throw HotlineTransferClientError.failedToTransfer + } + return action + } + + private func uploadFile( + socket: NetSocket, + fileURL: URL, + itemNumber: Int, + totalItems: Int, + totalBytesTransferredSoFar: Int, + progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? + ) async throws -> Int { + var bytesUploaded = 0 + let filename = fileURL.lastPathComponent + + // Get file metadata + guard let infoFork = HotlineFileInfoFork(file: fileURL) else { + throw HotlineTransferClientError.failedToTransfer + } + + guard let header = HotlineFileHeader(file: fileURL) else { + throw HotlineTransferClientError.failedToTransfer + } + + guard let forkSizes = try? FileManager.default.getFileForkSizes(fileURL) else { + throw HotlineTransferClientError.failedToTransfer + } + + let infoForkData = infoFork.data() + let dataForkSize = forkSizes.dataForkSize + let resourceForkSize = forkSizes.resourceForkSize + + // Calculate total flattened file size + guard let flattenedSize = FileManager.default.getFlattenedFileSize(fileURL) else { + throw HotlineTransferClientError.failedToTransfer + } + let totalFileSize = Int(flattenedSize) + + // Send file size + let fileSizeData = Data(endian: .big) { + UInt32(totalFileSize) + } + try await socket.write(fileSizeData) + bytesUploaded += 4 + + // Send file header + let headerData = header.data() + try await socket.write(headerData) + bytesUploaded += headerData.count + + // Send INFO fork header + let infoForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.info.rawValue, dataSize: UInt32(infoForkData.count)) + try await socket.write(infoForkHeader.data()) + bytesUploaded += HotlineFileForkHeader.DataSize + + // Send INFO fork data + try await socket.write(infoForkData) + bytesUploaded += infoForkData.count + + // Create per-file progress for Finder + let fileProgress = Progress(totalUnitCount: Int64(totalFileSize)) + fileProgress.fileURL = fileURL.resolvingSymlinksInPath() + fileProgress.fileOperationKind = Progress.FileOperationKind.uploading + fileProgress.publish() + + defer { + fileProgress.unpublish() + } + + // Send DATA fork if present + let dataForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.data.rawValue, dataSize: dataForkSize) + try await socket.write(dataForkHeader.data()) + bytesUploaded += HotlineFileForkHeader.DataSize + + if dataForkSize > 0 { + // Stream DATA fork + let fileHandle = try FileHandle(forReadingFrom: fileURL) + defer { try? fileHandle.close() } + + let updates = await socket.writeFile(from: fileHandle, length: Int(dataForkSize)) + for try await p in updates { + // Update per-file Finder progress + fileProgress.completedUnitCount = Int64(bytesUploaded + p.sent) + + // Calculate overall folder progress + let totalBytesNow = totalBytesTransferredSoFar + bytesUploaded + p.sent + let rawProgress = self.transferTotal > 0 ? Double(totalBytesNow) / Double(self.transferTotal) : 0.0 + let overallProgress = min(rawProgress, 1.0) + + // Calculate overall time estimate + let remainingBytes = max(0, self.transferTotal - totalBytesNow) + let estimate: TimeInterval? = if let speed = p.bytesPerSecond, speed > 0, remainingBytes > 0 { + TimeInterval(remainingBytes) / speed + } else { + nil + } + + // Report overall folder progress + progressHandler?(.transfer( + name: filename, + size: totalBytesNow, + total: self.transferTotal, + progress: overallProgress, + speed: p.bytesPerSecond, + estimate: estimate + )) + } + + bytesUploaded += Int(dataForkSize) + } + + // Send RESOURCE fork if present + if resourceForkSize > 0 { + let resourceURL = fileURL.urlForResourceFork() + + let resourceForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.resource.rawValue, dataSize: resourceForkSize) + try await socket.write(resourceForkHeader.data()) + bytesUploaded += HotlineFileForkHeader.DataSize + + // Stream RESOURCE fork + let resourceHandle = try FileHandle(forReadingFrom: resourceURL) + defer { try? resourceHandle.close() } + + let updates = await socket.writeFile(from: resourceHandle, length: Int(resourceForkSize)) + for try await p in updates { + // Update per-file Finder progress + fileProgress.completedUnitCount = Int64(bytesUploaded + p.sent) + + // Calculate overall folder progress + let totalBytesNow = totalBytesTransferredSoFar + bytesUploaded + p.sent + let rawProgress = self.transferTotal > 0 ? Double(totalBytesNow) / Double(self.transferTotal) : 0.0 + let overallProgress = min(rawProgress, 1.0) + + // Calculate overall time estimate + let remainingBytes = max(0, self.transferTotal - totalBytesNow) + let estimate: TimeInterval? = if let speed = p.bytesPerSecond, speed > 0, remainingBytes > 0 { + TimeInterval(remainingBytes) / speed + } else { + nil + } + + // Report overall folder progress + progressHandler?(.transfer( + name: filename, + size: totalBytesNow, + total: self.transferTotal, + progress: overallProgress, + speed: p.bytesPerSecond, + estimate: estimate + )) + } + + bytesUploaded += Int(resourceForkSize) + } + + return bytesUploaded + } +} diff --git a/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift deleted file mode 100644 index 15636f3..0000000 --- a/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift +++ /dev/null @@ -1,518 +0,0 @@ -import Foundation -import Network - -/// Item progress callback for folder uploads -public struct HotlineFolderItemUploadProgress: Sendable { - public let fileName: String - public let itemNumber: Int - public let totalItems: Int -} - -/// Represents a file or folder in the upload queue -private struct FolderItem { - let url: URL - let pathComponents: [String] // Path relative to upload root - let isFolder: Bool -} - -@MainActor -public class HotlineFolderUploadClientNew: @MainActor HotlineTransferClient { - // MARK: - Configuration - - public struct Configuration: Sendable { - public var chunkSize: Int = 256 * 1024 - public init() {} - } - - // MARK: - Properties - - private let serverAddress: String - private let serverPort: UInt16 - private let referenceNumber: UInt32 - private let folderURL: URL - - private let config: Configuration - - private var transferTotal: Int = 0 - private var transferSize: Int = 0 - private var folderItems: [FolderItem] = [] - private var totalItems: Int = 0 - - private var socket: NetSocket? - private var uploadTask: Task? - - // MARK: - Initialization - - public init?( - folderURL: URL, - address: String, - port: UInt16, - reference: UInt32, - configuration: Configuration = .init() - ) { - guard FileManager.default.fileExists(atPath: folderURL.path(percentEncoded: false)) else { - return nil - } - - var isDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: folderURL.path(percentEncoded: false), isDirectory: &isDirectory), - isDirectory.boolValue else { - return nil - } - - self.serverAddress = address - self.serverPort = port - self.referenceNumber = reference - self.folderURL = folderURL - self.config = configuration - - print("HotlineFolderUploadClientNew[\(reference)]: Preparing to upload folder '\(folderURL.lastPathComponent)'") - } - - // MARK: - API - - public func upload( - progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil, - itemProgress itemProgressHandler: (@Sendable (HotlineFolderItemUploadProgress) -> Void)? = nil - ) async throws { - self.uploadTask?.cancel() - - let task = Task { - try await performUpload( - progressHandler: progressHandler, - itemProgressHandler: itemProgressHandler - ) - } - self.uploadTask = task - - do { - try await task.value - self.uploadTask = nil - } catch { - print("HotlineFolderUploadClientNew[\(referenceNumber)]: Failed to upload folder: \(error)") - self.uploadTask = nil - progressHandler?(.error(error)) - throw error - } - } - - /// Cancel the current upload - public func cancel() { - uploadTask?.cancel() - uploadTask = nil - - if let socket = socket { - Task { - await socket.close() - } - } - } - - // MARK: - - - private enum UploadStage { - case waitingForNextFile // Waiting for server to send .nextFile action - case sendingItemHeader // Sending item header to server - case waitingForFileAction // Waiting for server action after file header (.sendFile, .nextFile, .resumeFile) - case uploadingFile // Uploading file data - case done // All items uploaded - } - - private func performUpload( - progressHandler: (@Sendable (HotlineTransferProgress) -> Void)?, - itemProgressHandler: (@Sendable (HotlineFolderItemUploadProgress) -> Void)? - ) async throws { - - // Note that we're preparing now. - progressHandler?(.preparing) - - // Start accessing security-scoped resource - let didStartAccess = folderURL.startAccessingSecurityScopedResource() - defer { - if didStartAccess { - self.folderURL.stopAccessingSecurityScopedResource() - } - } - - // Build folder hierarchy (excluding root folder itself) - try buildFolderHierarchy() - - // Fast path if this is an empty folder - if self.totalItems == 0 { - progressHandler?(.completed(url: nil)) - return - } - - // Note that we're connecting now. - progressHandler?(.connecting) - - // Connect to transfer server - let socket = try await NetSocket.connect( - host: self.serverAddress, - port: self.serverPort + 1 - ) - - self.socket = socket - defer { Task { await socket.close() } } - - // Send magic header for folder upload - try await socket.write(Data(endian: .big) { - "HTXF".fourCharCode() - self.referenceNumber - UInt32.zero // data size = 0 - UInt16(1) // type = 1 (folder transfer) - UInt16.zero // reserved = 0 - }) - - progressHandler?(.connected) - - var completedItemCount = 0 - var totalBytesTransferred = 0 - var itemIndex = 0 - var stage: UploadStage = .waitingForNextFile - var currentItem: FolderItem? - - // State machine loop - while stage != .done { - switch stage { - - case .waitingForNextFile: - // Wait for server to send .nextFile action - let action = try await self.readAction(socket: socket) - guard action == .nextFile else { - throw HotlineTransferClientError.failedToTransfer - } - - // Check if we have more items to send - if itemIndex < self.folderItems.count { - currentItem = self.folderItems[itemIndex] - itemIndex += 1 - stage = .sendingItemHeader - } else { - // No more items - stage = .done - } - - case .sendingItemHeader: - // Send item header to server - guard let item = currentItem else { - throw HotlineTransferClientError.failedToTransfer - } - - // Encode and send item header - totalBytesTransferred += try await socket.write(self.encodeItemHeader(item: item)) - - // Next: wait for server's response - if item.isFolder { - // For folders, we're done with this item (just creating the directory) - completedItemCount += 1 - // Server should immediately respond with .nextFile - stage = .waitingForNextFile - } else { - // For files, server will tell us what to do - stage = .waitingForFileAction - } - - case .waitingForFileAction: - // Wait for server action after file header (.sendFile, .nextFile, .resumeFile) - guard currentItem != nil else { - throw HotlineTransferClientError.failedToTransfer - } - - let action = try await self.readAction(socket: socket) - switch action { - case .nextFile: - // Server wants to skip this file - completedItemCount += 1 - // The .nextFile action means send next item, check if we have more - if itemIndex < self.folderItems.count { - currentItem = self.folderItems[itemIndex] - itemIndex += 1 - stage = .sendingItemHeader - } else { - stage = .done - } - - case .sendFile: - // Server wants the file - completedItemCount += 1 - stage = .uploadingFile - - case .resumeFile: - // Server wants to resume - let resumeSizeData = try await socket.read(2) - let resumeSize = Int(resumeSizeData.readUInt16(at: 0)!) - let _ = try await socket.read(resumeSize) - completedItemCount += 1 - stage = .uploadingFile - } - - case .uploadingFile: - // Upload file data - guard let item = currentItem else { - throw HotlineTransferClientError.failedToTransfer - } - - // Notify item progress - itemProgressHandler?(HotlineFolderItemUploadProgress( - fileName: item.url.lastPathComponent, - itemNumber: completedItemCount, - totalItems: self.totalItems - )) - - // Upload the file - let bytesUploaded = try await self.uploadFile( - socket: socket, - fileURL: item.url, - itemNumber: completedItemCount, - totalItems: self.totalItems, - totalBytesTransferredSoFar: totalBytesTransferred, - progressHandler: progressHandler - ) - - totalBytesTransferred += bytesUploaded - self.transferSize = totalBytesTransferred - - // After uploading, wait for server to send .nextFile - stage = .waitingForNextFile - - case .done: - break - } - } - - // All items processed - progressHandler?(.completed(url: nil)) - } - - private func buildFolderHierarchy() throws { - let fm = FileManager.default - folderItems = [] - transferTotal = 0 - - let rootFolderName = folderURL.lastPathComponent - - // Recursively walk the folder - func walkFolder(at url: URL, relativePath: [String]) throws { - let contents = try fm.contentsOfDirectory(at: url, includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey], options: [.skipsHiddenFiles]) - - for itemURL in contents { - let resourceValues = try itemURL.resourceValues(forKeys: [.isDirectoryKey]) - let isDirectory = resourceValues.isDirectory ?? false - let itemName = itemURL.lastPathComponent - let itemPath = relativePath + [itemName] - - if isDirectory { - // Add folder to list - folderItems.append(FolderItem(url: itemURL, pathComponents: itemPath, isFolder: true)) - - // Recurse into subfolder - try walkFolder(at: itemURL, relativePath: itemPath) - - } else { - // Add file to list and calculate size - if let fileSize = FileManager.default.getFlattenedFileSize(itemURL) { - folderItems.append(FolderItem(url: itemURL, pathComponents: itemPath, isFolder: false)) - transferTotal += Int(fileSize) - } - } - } - } - - // Start from root folder with root name as first path component - try walkFolder(at: folderURL, relativePath: [rootFolderName]) - totalItems = folderItems.count - - print("BUILD HEIRARCHY (\(folderItems.count) items):\n", folderItems) - } - - private func encodeItemHeader(item: FolderItem) -> Data { - let strippedPath = item.pathComponents.count > 1 ? Array(item.pathComponents.dropFirst()) : item.pathComponents - let strippedPathCount = strippedPath.count - - // Build path components (Hotline format: reserved(2) + nameLen(1) + name) - var pathData = Data() - for component in strippedPath { - let nameData = component.data(using: .macOSRoman) ?? component.data(using: .utf8) ?? Data() - let nameLen = min(nameData.count, 255) - - pathData.append(contentsOf: [0, 0]) // reserved - pathData.append(UInt8(nameLen)) - pathData.append(nameData.prefix(nameLen)) - } - - // Calculate header size (this is what goes in the DataSize field) - // DataSize = isFolder(2) + pathCount(2) + pathData - let headerSize = 2 + 2 + pathData.count - - return Data(endian: .big) { - UInt16(headerSize) - UInt16(item.isFolder ? 1 : 0) - UInt16(strippedPathCount) - pathData - } - } - - private func readAction(socket: NetSocket) async throws -> HotlineFolderAction { - let actionData = try await socket.read(2) - guard let rawAction = actionData.readUInt16(at: 0), - let action = HotlineFolderAction(rawValue: rawAction) else { - throw HotlineTransferClientError.failedToTransfer - } - return action - } - - private func uploadFile( - socket: NetSocket, - fileURL: URL, - itemNumber: Int, - totalItems: Int, - totalBytesTransferredSoFar: Int, - progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? - ) async throws -> Int { - var bytesUploaded = 0 - let filename = fileURL.lastPathComponent - - // Get file metadata - guard let infoFork = HotlineFileInfoFork(file: fileURL) else { - throw HotlineTransferClientError.failedToTransfer - } - - guard let header = HotlineFileHeader(file: fileURL) else { - throw HotlineTransferClientError.failedToTransfer - } - - guard let forkSizes = try? FileManager.default.getFileForkSizes(fileURL) else { - throw HotlineTransferClientError.failedToTransfer - } - - let infoForkData = infoFork.data() - let dataForkSize = forkSizes.dataForkSize - let resourceForkSize = forkSizes.resourceForkSize - - // Calculate total flattened file size - guard let flattenedSize = FileManager.default.getFlattenedFileSize(fileURL) else { - throw HotlineTransferClientError.failedToTransfer - } - let totalFileSize = Int(flattenedSize) - - // Send file size - let fileSizeData = Data(endian: .big) { - UInt32(totalFileSize) - } - try await socket.write(fileSizeData) - bytesUploaded += 4 - - // Send file header - let headerData = header.data() - try await socket.write(headerData) - bytesUploaded += headerData.count - - // Send INFO fork header - let infoForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.info.rawValue, dataSize: UInt32(infoForkData.count)) - try await socket.write(infoForkHeader.data()) - bytesUploaded += HotlineFileForkHeader.DataSize - - // Send INFO fork data - try await socket.write(infoForkData) - bytesUploaded += infoForkData.count - - // Create per-file progress for Finder - let fileProgress = Progress(totalUnitCount: Int64(totalFileSize)) - fileProgress.fileURL = fileURL.resolvingSymlinksInPath() - fileProgress.fileOperationKind = Progress.FileOperationKind.uploading - fileProgress.publish() - - defer { - fileProgress.unpublish() - } - - // Send DATA fork if present - let dataForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.data.rawValue, dataSize: dataForkSize) - try await socket.write(dataForkHeader.data()) - bytesUploaded += HotlineFileForkHeader.DataSize - - if dataForkSize > 0 { - // Stream DATA fork - let fileHandle = try FileHandle(forReadingFrom: fileURL) - defer { try? fileHandle.close() } - - let updates = await socket.writeFile(from: fileHandle, length: Int(dataForkSize)) - for try await p in updates { - // Update per-file Finder progress - fileProgress.completedUnitCount = Int64(bytesUploaded + p.sent) - - // Calculate overall folder progress - let totalBytesNow = totalBytesTransferredSoFar + bytesUploaded + p.sent - let rawProgress = self.transferTotal > 0 ? Double(totalBytesNow) / Double(self.transferTotal) : 0.0 - let overallProgress = min(rawProgress, 1.0) - - // Calculate overall time estimate - let remainingBytes = max(0, self.transferTotal - totalBytesNow) - let estimate: TimeInterval? = if let speed = p.bytesPerSecond, speed > 0, remainingBytes > 0 { - TimeInterval(remainingBytes) / speed - } else { - nil - } - - // Report overall folder progress - progressHandler?(.transfer( - name: filename, - size: totalBytesNow, - total: self.transferTotal, - progress: overallProgress, - speed: p.bytesPerSecond, - estimate: estimate - )) - } - - bytesUploaded += Int(dataForkSize) - } - - // Send RESOURCE fork if present - if resourceForkSize > 0 { - let resourceURL = fileURL.urlForResourceFork() - - let resourceForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.resource.rawValue, dataSize: resourceForkSize) - try await socket.write(resourceForkHeader.data()) - bytesUploaded += HotlineFileForkHeader.DataSize - - // Stream RESOURCE fork - let resourceHandle = try FileHandle(forReadingFrom: resourceURL) - defer { try? resourceHandle.close() } - - let updates = await socket.writeFile(from: resourceHandle, length: Int(resourceForkSize)) - for try await p in updates { - // Update per-file Finder progress - fileProgress.completedUnitCount = Int64(bytesUploaded + p.sent) - - // Calculate overall folder progress - let totalBytesNow = totalBytesTransferredSoFar + bytesUploaded + p.sent - let rawProgress = self.transferTotal > 0 ? Double(totalBytesNow) / Double(self.transferTotal) : 0.0 - let overallProgress = min(rawProgress, 1.0) - - // Calculate overall time estimate - let remainingBytes = max(0, self.transferTotal - totalBytesNow) - let estimate: TimeInterval? = if let speed = p.bytesPerSecond, speed > 0, remainingBytes > 0 { - TimeInterval(remainingBytes) / speed - } else { - nil - } - - // Report overall folder progress - progressHandler?(.transfer( - name: filename, - size: totalBytesNow, - total: self.transferTotal, - progress: overallProgress, - speed: p.bytesPerSecond, - estimate: estimate - )) - } - - bytesUploaded += Int(resourceForkSize) - } - - return bytesUploaded - } -} diff --git a/Hotline/Library/Extensions.swift b/Hotline/Library/Extensions.swift index bb28370..cf9f8ff 100644 --- a/Hotline/Library/Extensions.swift +++ b/Hotline/Library/Extensions.swift @@ -2,23 +2,85 @@ import Foundation import SwiftUI import UniformTypeIdentifiers +extension FileManager { + @discardableResult + func moveToDownloads(from sourceURL: URL, using filename: String, bounceDock: Bool = false) -> Bool { + let filePath = URL.downloadsDirectory.generateUniqueFilePath(filename: filename) + let destinationURL = URL(filePath: filePath).resolvingSymlinksInPath() + + do { + try FileManager.default.moveItem(at: sourceURL.resolvingSymlinksInPath(), to: destinationURL) + } + catch { + return false + } + + if bounceDock { + #if os(macOS) + DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: destinationURL.path) + #endif + } + + return true + } + + @discardableResult + func copyToDownloads(from sourceURL: URL, using filename: String, bounceDock: Bool = false) -> Bool { + let filePath = URL.downloadsDirectory.generateUniqueFilePath(filename: filename) + let destinationURL = URL(filePath: filePath).resolvingSymlinksInPath() + + do { + try FileManager.default.copyItem(at: sourceURL.resolvingSymlinksInPath(), to: destinationURL) + } + catch { + return false + } + + if bounceDock { + #if os(macOS) + DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: destinationURL.path) + #endif + } + + return true + } +} + +// MARK: - + +extension View { + @ViewBuilder + func applyNavigationDocumentIfPresent(_ url: URL?) -> some View { + if let url { + self.navigationDocument(url) + } else { + self + } + } +} + +// MARK: - + extension Data { func saveAsFileToDownloads(filename: String, bounceDock: Bool = true) -> Bool { - let folderURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0] - let filePath = folderURL.generateUniqueFilePath(filename: filename) - if FileManager.default.createFile(atPath: filePath, contents: nil) { - if let h = FileHandle(forWritingAtPath: filePath) { - try? h.write(contentsOf: self) - try? h.close() - if bounceDock { - #if os(macOS) - var downloadURL = URL(filePath: filePath) - downloadURL.resolveSymlinksInPath() - DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path) - #endif - } - return true + let filePath = URL.downloadsDirectory.generateUniqueFilePath(filename: filename) + + if FileManager.default.createFile(atPath: filePath, contents: self) { + if bounceDock { + #if os(macOS) + var downloadURL = URL(filePath: filePath) + downloadURL.resolveSymlinksInPath() + DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path) + #endif } + return true + +// if FileManager.default.createFile(atPath: filePath, contents: nil) { +// if let h = FileHandle(forWritingAtPath: filePath) { +// try? h.write(contentsOf: self) +// try? h.close() +// +// } } return false } diff --git a/Hotline/Models/FileInfo.swift b/Hotline/Models/FileInfo.swift index e3081c4..a2f0ea5 100644 --- a/Hotline/Models/FileInfo.swift +++ b/Hotline/Models/FileInfo.swift @@ -49,13 +49,19 @@ import UniformTypeIdentifiers var children: [FileInfo]? = nil var isPreviewable: Bool { - let fileExtension = (self.name as NSString).pathExtension.lowercased() + var fileExtension = (self.name as NSString).pathExtension.lowercased() + if fileExtension.isEmpty && !self.type.isEmpty { + let type = self.type.lowercased() + if let ext = FileManager.HFSTypeToExtension[type] { + fileExtension = ext + } + } + if let fileType = UTType(filenameExtension: fileExtension) { if fileType.canBePreviewedByQuickLook { return true } - print("FILE TYPE?", fileType, fileExtension, fileType.isSubtype(of: .pdf), fileType.isSupertype(of: .pdf)) if fileType.isSubtype(of: .image) { return true } diff --git a/Hotline/Models/PreviewFileInfo.swift b/Hotline/Models/PreviewFileInfo.swift index dcf5314..7e05a97 100644 --- a/Hotline/Models/PreviewFileInfo.swift +++ b/Hotline/Models/PreviewFileInfo.swift @@ -7,6 +7,9 @@ struct PreviewFileInfo: Identifiable, Codable { var size: Int var name: String + var type: String? = nil + var creator: String? = nil + var previewType: FilePreviewType { let fileExtension = (self.name as NSString).pathExtension if let fileType = UTType(filenameExtension: fileExtension) { diff --git a/Hotline/State/AppUpdate.swift b/Hotline/State/AppUpdate.swift index 601859e..558a296 100644 --- a/Hotline/State/AppUpdate.swift +++ b/Hotline/State/AppUpdate.swift @@ -35,8 +35,6 @@ final class AppUpdate { case manual } - // MARK: - Public State - var isChecking = false var isDownloading = false var showWindow = false @@ -55,7 +53,7 @@ final class AppUpdate { private let remindDateKey = "update.remind.date" private let lastPromptedVersionKey = "update.last.prompt.version" - // MARK: - Public API + // MARK: - API func checkForUpdatesOnLaunch() async { await checkForUpdates(trigger: .automatic) @@ -100,7 +98,7 @@ final class AppUpdate { resetAndCloseWindow() } - // MARK: - Internal Logic + // MARK: - Implementation private func checkForUpdates(trigger: CheckTrigger) async { await MainActor.run { @@ -259,8 +257,7 @@ final class AppUpdate { private func downloadRelease(_ release: UpdateReleaseInfo) async { do { let (temporaryURL, _) = try await URLSession.shared.download(from: release.downloadURL) - let downloadsDirectory = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first! - let destinationURL = downloadsDirectory.appendingPathComponent(release.assetName) + let destinationURL = URL.downloadsDirectory.appendingPathComponent(release.assetName) if FileManager.default.fileExists(atPath: destinationURL.path) { try? FileManager.default.removeItem(at: destinationURL) diff --git a/Hotline/State/FilePreviewState.swift b/Hotline/State/FilePreviewState.swift index 27f8199..ec79f66 100644 --- a/Hotline/State/FilePreviewState.swift +++ b/Hotline/State/FilePreviewState.swift @@ -1,8 +1,6 @@ import SwiftUI import UniformTypeIdentifiers -// MARK: - Preview Type - enum FilePreviewType: Equatable { case unknown case image @@ -13,13 +11,15 @@ enum FilePreviewType: Equatable { @MainActor @Observable final class FilePreviewState { - // MARK: - Properties - + enum LoadState: Equatable { + case unloaded + case loading + case loaded + case failed + } + let info: PreviewFileInfo - private var previewClient: HotlineFilePreviewClient? - private var previewTask: Task? - var state: LoadState = .unloaded var progress: Double = 0.0 @@ -33,39 +33,35 @@ final class FilePreviewState { var text: String? = nil var styledText: NSAttributedString? = nil - - // MARK: - Computed Properties + + @ObservationIgnored private var previewClient: HotlineFilePreviewClient? + @ObservationIgnored private var previewTask: Task? var previewType: FilePreviewType { - info.previewType + self.info.previewType } - // MARK: - Initialization - init(info: PreviewFileInfo) { self.info = info } - nonisolated deinit { - // Note: Can't access @MainActor properties from deinit - // Cleanup will happen when previewClient is deallocated - } - - // MARK: - Public API + // MARK: - API func download() { // Cancel any existing download - previewTask?.cancel() - previewClient?.cleanup() + self.previewTask?.cancel() + self.previewClient?.cleanup() let task = Task { @MainActor in do { let client = HotlineFilePreviewClient( - fileName: info.name, - address: info.address, - port: UInt16(info.port), - reference: info.id, - size: UInt32(info.size) + fileName: self.info.name, + address: self.info.address, + port: UInt16(self.info.port), + reference: self.info.id, + size: UInt32(self.info.size), + fileType: self.info.type, + fileCreator: self.info.creator ) self.previewClient = client @@ -132,69 +128,58 @@ final class FilePreviewState { } func cancel() { - previewTask?.cancel() - previewTask = nil - previewClient?.cancel() + self.previewTask?.cancel() + self.previewTask = nil + self.previewClient?.cancel() } func cleanup() { - previewClient?.cleanup() - previewClient = nil - fileURL = nil - image = nil - text = nil - styledText = nil - } - - // MARK: - Private Implementation - - private func loadPreview(from url: URL) { - guard let data = try? Data(contentsOf: url) else { - self.state = .failed - print("FilePreviewState: Failed to read preview data from \(url.path)") - return - } - - switch self.previewType { - case .image: - #if os(iOS) - self.image = UIImage(data: data) - #elseif os(macOS) - self.image = NSImage(data: data) - #endif - - if self.image == nil { - self.state = .failed - print("FilePreviewState: Failed to create image from data") - } - - case .text: - let encoding: UInt = NSString.stringEncoding(for: data, convertedString: nil, usedLossyConversion: nil) - if encoding != 0 { - self.text = String(data: data, encoding: String.Encoding(rawValue: encoding)) - } else { - self.text = String(data: data, encoding: .utf8) - } - - if self.text == nil { - self.state = .failed - print("FilePreviewState: Failed to decode text data") - } - - case .unknown: - print("FilePreviewState: Unknown preview type for \(info.name)") - break - } + self.previewClient?.cleanup() + self.previewClient = nil + self.fileURL = nil + self.image = nil + self.text = nil + self.styledText = nil } -} - -// MARK: - Load State -extension FilePreviewState { - enum LoadState: Equatable { - case unloaded - case loading - case loaded - case failed - } + // MARK: - Utility + +// private func loadPreview(from url: URL) { +// guard let data = try? Data(contentsOf: url) else { +// self.state = .failed +// print("FilePreviewState: Failed to read preview data from \(url.path)") +// return +// } +// +// switch self.previewType { +// case .image: +// #if os(iOS) +// self.image = UIImage(data: data) +// #elseif os(macOS) +// self.image = NSImage(data: data) +// #endif +// +// if self.image == nil { +// self.state = .failed +// print("FilePreviewState: Failed to create image from data") +// } +// +// case .text: +// let encoding: UInt = NSString.stringEncoding(for: data, convertedString: nil, usedLossyConversion: nil) +// if encoding != 0 { +// self.text = String(data: data, encoding: String.Encoding(rawValue: encoding)) +// } else { +// self.text = String(data: data, encoding: .utf8) +// } +// +// if self.text == nil { +// self.state = .failed +// print("FilePreviewState: Failed to decode text data") +// } +// +// case .unknown: +// print("FilePreviewState: Unknown preview type for \(info.name)") +// break +// } +// } } diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index e80571a..f814818 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -252,7 +252,7 @@ class HotlineState: Equatable { // MARK: - Private State - @ObservationIgnored private var client: HotlineClientNew? + @ObservationIgnored private var client: HotlineClient? @ObservationIgnored private var eventTask: Task? @ObservationIgnored private var chatSessionKey: ChatStore.SessionKey? @ObservationIgnored private var restoredChatSessionKey: ChatStore.SessionKey? @@ -308,13 +308,13 @@ class HotlineState: Equatable { iconID: UInt16(iconID) ) - print("HotlineState.login(): Calling HotlineClientNew.connect()...") - let client = try await HotlineClientNew.connect( + print("HotlineState.login(): Calling HotlineClient.connect()...") + let client = try await HotlineClient.connect( host: server.address, port: UInt16(server.port), login: loginInfo ) - print("HotlineState.login(): HotlineClientNew.connect() returned") + print("HotlineState.login(): HotlineClient.connect() returned") self.client = client print("HotlineState.login(): Client stored") @@ -865,7 +865,7 @@ class HotlineState: Equatable { /// - progressCallback: Optional callback for progress updates (receives TransferInfo and progress 0.0-1.0) /// - callback: Optional completion callback (receives TransferInfo and final file URL) @MainActor - func downloadFileNew(_ fileName: String, path: [String], to destination: URL? = nil, progress progressCallback: ((TransferInfo) -> Void)? = nil, complete callback: ((TransferInfo) -> Void)? = nil) { + func downloadFile(_ fileName: String, path: [String], to destination: URL? = nil, progress progressCallback: ((TransferInfo) -> Void)? = nil, complete callback: ((TransferInfo) -> Void)? = nil) { guard let client = self.client else { return } var fullPath: [String] = [] @@ -975,7 +975,7 @@ class HotlineState: Equatable { /// - itemProgressCallback: Optional callback for per-item updates (receives TransferInfo with current file info) /// - callback: Optional completion callback (receives TransferInfo and final folder URL) @MainActor - func downloadFolderNew( + func downloadFolder( _ folderName: String, path: [String], to destination: URL? = nil, @@ -1018,7 +1018,7 @@ class HotlineState: Equatable { AppState.shared.addTransfer(transfer) // Create download client - let downloadClient = HotlineFolderDownloadClientNew( + let downloadClient = HotlineFolderDownloadClient( address: address, port: UInt16(port), reference: referenceNumber, @@ -1091,7 +1091,7 @@ class HotlineState: Equatable { } } - /// Modern async/await folder upload using HotlineFolderUploadClientNew + /// Upload a folder to the server. /// /// - Parameters: /// - folderURL: URL to the folder on disk to upload @@ -1155,7 +1155,7 @@ class HotlineState: Equatable { print("HotlineState: Got folder upload reference: \(referenceNumber)") // Create upload client - guard let uploadClient = HotlineFolderUploadClientNew( + guard let uploadClient = HotlineFolderUploadClient( folderURL: folderURL, address: address, port: UInt16(port), @@ -1346,9 +1346,9 @@ class HotlineState: Equatable { } func setFileInfo(fileName: String, path filePath: [String], fileNewName: String?, comment: String?, encoding: String.Encoding = .utf8) { - // TODO: Implement setFileInfo in HotlineClientNew + // TODO: Implement setFileInfo in HotlineClient // This method updates file metadata (name and/or comment) - print("setFileInfo not yet implemented in HotlineState/HotlineClientNew") + print("setFileInfo not yet implemented in HotlineState/HotlineClient") } @MainActor diff --git a/Hotline/State/ServerState.swift b/Hotline/State/ServerState.swift index 805672d..5913bf5 100644 --- a/Hotline/State/ServerState.swift +++ b/Hotline/State/ServerState.swift @@ -29,7 +29,7 @@ enum ServerNavigationType: Identifiable, Hashable, Equatable { case .files: return "Files" case .accounts: - return "Admin" + return "Accounts" case .user(let userID): return String(userID) } diff --git a/Hotline/macOS/Files/FilePreviewQuickLookView.swift b/Hotline/macOS/Files/FilePreviewQuickLookView.swift index 26bd286..a504a7a 100644 --- a/Hotline/macOS/Files/FilePreviewQuickLookView.swift +++ b/Hotline/macOS/Files/FilePreviewQuickLookView.swift @@ -11,16 +11,16 @@ struct FilePreviewQuickLookView: View { @Environment(\.dismiss) private var dismiss @Binding var info: PreviewFileInfo? - @State var preview: FilePreviewState? = nil + @State private var preview: FilePreviewState? = nil @FocusState private var focusField: FilePreviewFocus? var body: some View { Group { - if preview?.state != .loaded { + if self.preview?.state != .loaded { VStack(alignment: .center, spacing: 0) { Spacer() - ProgressView(value: max(0.0, min(1.0, preview?.progress ?? 0.0))) + ProgressView(value: max(0.0, min(1.0, self.preview?.progress ?? 0.0))) .focusable(false) .progressViewStyle(.circular) .controlSize(.extraLarge) @@ -33,7 +33,7 @@ struct FilePreviewQuickLookView: View { .padding() } else { - if let fileURL = preview?.fileURL { + if let fileURL = self.preview?.fileURL { QuickLookPreviewView(fileURL: fileURL) .frame(minWidth: 400, maxWidth: .infinity, minHeight: 400, maxHeight: .infinity) } @@ -68,25 +68,15 @@ struct FilePreviewQuickLookView: View { .focusable() .focusEffectDisabled() .background(Color(nsColor: .textBackgroundColor)) - .focused($focusField, equals: .window) - .navigationTitle(info?.name ?? "File Preview") - .background { - if let fileURL = self.preview?.fileURL { - WindowConfigurator { window in - window.representedURL = fileURL - window.standardWindowButton(.documentIconButton)?.isHidden = false - } - } - } + .focused(self.$focusField, equals: .window) + .navigationTitle(self.info?.name ?? "File Preview") + .applyNavigationDocumentIfPresent(self.preview?.fileURL) .toolbar { - if let _ = preview?.fileURL { + if let fileURL = self.preview?.fileURL { if let info = info { ToolbarItem(placement: .primaryAction) { Button { - if let fileURL = preview?.fileURL, - let data = try? Data(contentsOf: fileURL) { - let _ = data.saveAsFileToDownloads(filename: info.name) - } + FileManager.default.copyToDownloads(from: fileURL, using: info.name, bounceDock: true) } label: { Label("Download File...", systemImage: "arrow.down") } @@ -96,28 +86,27 @@ struct FilePreviewQuickLookView: View { } } .task { - if let info = info { - preview = FilePreviewState(info: info) - preview?.download() + if let info = self.info { + self.preview = FilePreviewState(info: info) + self.preview?.download() } } .onAppear { - if info == nil { - Task { - dismiss() - } + guard self.info != nil else { + self.dismiss() return } - focusField = .window + self.focusField = .window } .onDisappear { - preview?.cancel() - dismiss() + self.preview?.cancel() + self.preview?.cleanup() + self.dismiss() } - .onChange(of: preview?.state) { - if preview?.state == .failed { - dismiss() + .onChange(of: self.preview?.state) { + if self.preview?.state == .failed { + self.dismiss() } } .preferredColorScheme(.dark) diff --git a/Hotline/macOS/Files/FilesView.swift b/Hotline/macOS/Files/FilesView.swift index d4ba5c8..984b120 100644 --- a/Hotline/macOS/Files/FilesView.swift +++ b/Hotline/macOS/Files/FilesView.swift @@ -376,11 +376,11 @@ struct FilesView: View { private func openPreviewWindow(_ previewInfo: PreviewFileInfo) { switch previewInfo.previewType { case .image: - openWindow(id: "preview-quicklook", value: previewInfo) + self.openWindow(id: "preview-quicklook", value: previewInfo) case .text: - openWindow(id: "preview-quicklook", value: previewInfo) + self.openWindow(id: "preview-quicklook", value: previewInfo) case .unknown: - openWindow(id: "preview-quicklook", value: previewInfo) + self.openWindow(id: "preview-quicklook", value: previewInfo) return } } @@ -397,10 +397,10 @@ struct FilesView: View { @MainActor private func downloadFile(_ file: FileInfo) { if file.isFolder { - model.downloadFolderNew(file.name, path: file.path) + model.downloadFolder(file.name, path: file.path) } else { - model.downloadFileNew(file.name, path: file.path) + model.downloadFile(file.name, path: file.path) } } @@ -442,9 +442,12 @@ struct FilesView: View { return } - model.previewFile(file.name, path: file.path) { info in + self.model.previewFile(file.name, path: file.path) { info in if let info = info { - openPreviewWindow(info) + var extendedInfo = info + extendedInfo.creator = file.creator + extendedInfo.type = file.type + self.openPreviewWindow(extendedInfo) } } } diff --git a/Hotline/macOS/Files/FolderItemView.swift b/Hotline/macOS/Files/FolderItemView.swift index 2b1b695..9b13cc0 100644 --- a/Hotline/macOS/Files/FolderItemView.swift +++ b/Hotline/macOS/Files/FolderItemView.swift @@ -81,7 +81,7 @@ struct FolderItemView: View { .opacity(file.isUnavailable ? 0.5 : 1.0) if loading { - ProgressView().controlSize(.small).padding([.leading, .trailing], 5) + ProgressView().controlSize(.mini).padding([.leading, .trailing], 5) } Spacer() if !file.isUnavailable { diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index 757dbd8..d4b3407 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -93,7 +93,7 @@ struct ServerView: View { ServerMenuItem(type: .board, name: "Board", image: "Section Board"), ServerMenuItem(type: .news, name: "News", image: "Section News"), ServerMenuItem(type: .files, name: "Files", image: "Section Files"), - ServerMenuItem(type: .accounts, name: "Admin", image: "Section Users"), + ServerMenuItem(type: .accounts, name: "Accounts", image: "Section Users"), ] static var classicMenuItems: [ServerMenuItem] = [ @@ -280,47 +280,43 @@ struct ServerView: View { } var transfersSection: some View { -// Section("Transfers") { - ForEach(model.transfers) { transfer in - TransferItemView(transfer: transfer) - } -// } + ForEach(model.transfers) { transfer in + TransferItemView(transfer: transfer) + } } var usersSection: some View { -// Section("\(model.users.count) Online") { - ForEach(model.users) { user in - HStack(spacing: 5) { - if let iconImage = HotlineState.getClassicIcon(Int(user.iconID)) { - Image(nsImage: iconImage) - .frame(width: 16, height: 16) - .padding(.leading, 2) - .padding(.trailing, 2) - } - else { - Image("User") - .frame(width: 16, height: 16) - .padding(.leading, 2) - .padding(.trailing, 2) - } - - Text(user.name) - .foregroundStyle(user.isAdmin ? Color.hotlineRed : .primary) - - Spacer() - - if model.hasUnreadInstantMessages(userID: user.id) { - Circle() - .frame(width: 6, height: 6) - .foregroundStyle(user.isAdmin ? Color.hotlineRed : .primary.opacity(0.5)) - .padding(EdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 2)) - } + ForEach(model.users) { user in + HStack(spacing: 5) { + if let iconImage = HotlineState.getClassicIcon(Int(user.iconID)) { + Image(nsImage: iconImage) + .frame(width: 16, height: 16) + .padding(.leading, 2) + .padding(.trailing, 2) + } + else { + Image("User") + .frame(width: 16, height: 16) + .padding(.leading, 2) + .padding(.trailing, 2) + } + + Text(user.name) + .foregroundStyle(user.isAdmin ? Color.hotlineRed : .primary) + + Spacer() + + if model.hasUnreadInstantMessages(userID: user.id) { + Circle() + .frame(width: 6, height: 6) + .foregroundStyle(user.isAdmin ? Color.hotlineRed : .primary.opacity(0.5)) + .padding(EdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 2)) } - .opacity(user.isIdle ? 0.5 : 1.0) - .opacity(controlActiveState == .inactive ? 0.5 : 1.0) - .tag(ServerNavigationType.user(userID: user.id)) } -// } + .opacity(user.isIdle ? 0.5 : 1.0) + .opacity(controlActiveState == .inactive ? 0.5 : 1.0) + .tag(ServerNavigationType.user(userID: user.id)) + } } var serverView: some View { @@ -353,7 +349,7 @@ struct ServerView: View { case .accounts: AccountManagerView() .navigationTitle(model.serverTitle) - .navigationSubtitle("Administration") + .navigationSubtitle("Accounts") .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .user(let userID): let user = model.users.first(where: { $0.id == userID }) diff --git a/Hotline/macOS/TransfersView.swift b/Hotline/macOS/TransfersView.swift index 489a50b..bf8c8bd 100644 --- a/Hotline/macOS/TransfersView.swift +++ b/Hotline/macOS/TransfersView.swift @@ -19,9 +19,7 @@ struct TransfersView: View { ToolbarItem(placement: .primaryAction) { Button { if self.selectedTransfers.isEmpty { - if let downloadsURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first { - NSWorkspace.shared.open(downloadsURL) - } + NSWorkspace.shared.open(URL.downloadsDirectory) } else { let fileURLs = self.selectedTransfers.compactMap(\.fileURL) -- cgit From bcc7775dcace8593870e3afc12de21c871114d54 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Tue, 11 Nov 2025 17:14:13 -0800 Subject: Add confirmation for file/folder delete. Also add New Folder button to files. --- Hotline/Hotline/HotlineClient.swift | 160 ++++++++++++++++-------------------- Hotline/State/HotlineState.swift | 9 ++ Hotline/macOS/Files/FilesView.swift | 124 +++++++++++++++++++++++----- 3 files changed, 182 insertions(+), 111 deletions(-) (limited to 'Hotline/macOS/Files/FilesView.swift') diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index cd34d9e..1faaf55 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -152,15 +152,6 @@ public actor HotlineClient { // Keep-alive timer private var keepAliveTask: Task? - // MARK: - Static Handshake - - private static let handshakeData = Data(endian: .big, { - "TRTP".fourCharCode() // 'TRTP' protocol ID - "HOTL".fourCharCode() // 'HOTL' sub-protocol ID - UInt16(0x0001) // Version - UInt16(0x0002) // Sub-version - }) - // Transaction IDs private var nextTransactionID: UInt32 = 1 private func generateTransactionID() -> UInt32 { @@ -199,7 +190,12 @@ public actor HotlineClient { // Perform handshake print("HotlineClient.connect(): Sending handshake...") - try await socket.write(handshakeData) + try await socket.write(Data(endian: .big, { + "TRTP".fourCharCode() // 'TRTP' protocol ID + "HOTL".fourCharCode() // 'HOTL' sub-protocol ID + UInt16(0x0001) // Version + UInt16(0x0002) // Sub-version + })) let handshakeResponse = try await socket.read(8) print("HotlineClient.connect(): Handshake response received") @@ -628,40 +624,82 @@ public actor HotlineClient { return files } - - /// Request to download a file + + /// Get detailed information about a file /// /// - Parameters: /// - name: File name /// - path: Directory path containing the file - /// - preview: Request preview/thumbnail instead of full file - /// - Returns: Transfer info (reference number, size, waiting count) - public func downloadFile( - name: String, - path: [String], - preview: Bool = false - ) async throws -> (referenceNumber: UInt32, size: Int, fileSize: Int?, waitingCount: Int?) { - var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .downloadFile) + /// - Returns: File details or nil if not found + public func getFileInfo(name: String, path: [String]) async throws -> FileDetails? { + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getFileInfo) transaction.setFieldString(type: .fileName, val: name) transaction.setFieldPath(type: .filePath, val: path) - if preview { - transaction.setFieldUInt32(type: .fileTransferOptions, val: 2) - } - - let reply = try await self.sendTransaction(transaction) + let reply = try await sendTransaction(transaction) guard - let transferSize = reply.getField(type: .transferSize)?.getInteger(), - let referenceNumber = reply.getField(type: .referenceNumber)?.getUInt32() + let fileName = reply.getField(type: .fileName)?.getString(), + let fileCreator = reply.getField(type: .fileCreatorString)?.getString(), + let fileType = reply.getField(type: .fileTypeString)?.getString(), + let fileCreateDate = reply.getField(type: .fileCreateDate)?.data.readDate(at: 0), + let fileModifyDate = reply.getField(type: .fileModifyDate)?.data.readDate(at: 0) else { - throw HotlineClientError.invalidResponse + return nil } - let fileSize = reply.getField(type: .fileSize)?.getInteger() - let waitingCount = reply.getField(type: .waitingCount)?.getInteger() + // Size field is not included in server reply for folders + let fileSize = reply.getField(type: .fileSize)?.getInteger() ?? 0 + let fileComment = reply.getField(type: .fileComment)?.getString() ?? "" - return (referenceNumber, transferSize, fileSize, waitingCount) + return FileDetails( + name: fileName, + path: path, + size: fileSize, + comment: fileComment, + type: fileType, + creator: fileCreator, + created: fileCreateDate, + modified: fileModifyDate + ) + } + + /// Delete a file or folder + /// + /// - Parameters: + /// - name: File or folder name + /// - path: Directory path containing the item + /// - Returns: True if deletion succeeded + public func deleteFile(name: String, path: [String]) async throws -> Bool { + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .deleteFile) + transaction.setFieldString(type: .fileName, val: name) + transaction.setFieldPath(type: .filePath, val: path) + + do { + try await self.sendTransaction(transaction) + return true + } catch { + return false + } + } + + /// Create a folder + /// + /// - Parameters: + /// - name: New folder name + /// - path: Directory path for the new folder + /// - Returns: True if creation succeeded + public func newFolder(name: String, path: [String]) async throws -> Bool { + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .newFolder) + transaction.setFieldString(type: .fileName, val: name) + transaction.setFieldPath(type: .filePath, val: path) + + do { + try await self.sendTransaction(transaction) + return true + } catch { + return false + } } // MARK: - News @@ -787,66 +825,6 @@ public actor HotlineClient { try await self.socket.send(transaction, endian: .big) } - // MARK: - File Operations - - /// Get detailed information about a file - /// - /// - Parameters: - /// - name: File name - /// - path: Directory path containing the file - /// - Returns: File details or nil if not found - public func getFileInfo(name: String, path: [String]) async throws -> FileDetails? { - var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getFileInfo) - transaction.setFieldString(type: .fileName, val: name) - transaction.setFieldPath(type: .filePath, val: path) - - let reply = try await sendTransaction(transaction) - - guard - let fileName = reply.getField(type: .fileName)?.getString(), - let fileCreator = reply.getField(type: .fileCreatorString)?.getString(), - let fileType = reply.getField(type: .fileTypeString)?.getString(), - let fileCreateDate = reply.getField(type: .fileCreateDate)?.data.readDate(at: 0), - let fileModifyDate = reply.getField(type: .fileModifyDate)?.data.readDate(at: 0) - else { - return nil - } - - // Size field is not included in server reply for folders - let fileSize = reply.getField(type: .fileSize)?.getInteger() ?? 0 - let fileComment = reply.getField(type: .fileComment)?.getString() ?? "" - - return FileDetails( - name: fileName, - path: path, - size: fileSize, - comment: fileComment, - type: fileType, - creator: fileCreator, - created: fileCreateDate, - modified: fileModifyDate - ) - } - - /// Delete a file or folder - /// - /// - Parameters: - /// - name: File or folder name - /// - path: Directory path containing the item - /// - Returns: True if deletion succeeded - public func deleteFile(name: String, path: [String]) async throws -> Bool { - var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .deleteFile) - transaction.setFieldString(type: .fileName, val: name) - transaction.setFieldPath(type: .filePath, val: path) - - do { - try await self.sendTransaction(transaction) - return true - } catch { - return false - } - } - // MARK: - Administration /// Get list of user accounts (requires admin access) diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index 7335f37..5d6471c 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -835,6 +835,15 @@ class HotlineState: Equatable { return try await client.getFileInfo(name: fileName, path: fullPath) } + + @MainActor + func newFolder(name: String, parentPath: [String]) async throws -> Bool { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + return try await client.newFolder(name: name, path: parentPath) + } @MainActor func deleteFile(_ fileName: String, path: [String]) async throws -> Bool { diff --git a/Hotline/macOS/Files/FilesView.swift b/Hotline/macOS/Files/FilesView.swift index 984b120..3732d9a 100644 --- a/Hotline/macOS/Files/FilesView.swift +++ b/Hotline/macOS/Files/FilesView.swift @@ -2,9 +2,36 @@ import SwiftUI import UniformTypeIdentifiers import AppKit - - - +struct NewFolderSheet: View { + @Environment(\.dismiss) private var dismiss + + let action: ((String) -> Void)? + + @State private var folderName: String = "Untitled" + + var body: some View { + Form { + TextField(text: self.$folderName) { + Text("Folder Name") + } + } + .formStyle(.grouped) + .fixedSize(horizontal: false, vertical: true) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("New Folder") { + self.dismiss() + self.action?(self.folderName) + } + } + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + self.dismiss() + } + } + } + } +} struct FilesView: View { @Environment(HotlineState.self) private var model: HotlineState @@ -16,6 +43,8 @@ struct FilesView: View { @State private var searchText: String = "" @State private var isSearching: Bool = false @State private var dragOver: Bool = false + @State private var deleteConfirmationDisplayed: Bool = false + @State private var newFolderSheetDisplayed: Bool = false var body: some View { NavigationStack { @@ -98,13 +127,9 @@ struct FilesView: View { Divider() Button { - if let s = selectedFile { - Task { - await deleteFile(s) - } - } + self.deleteConfirmationDisplayed = true } label: { - Label("Delete", systemImage: "trash") + Label("Delete...", systemImage: "trash") } .disabled(selectedFile == nil) } @@ -154,38 +179,44 @@ struct FilesView: View { .searchable(text: $searchText, isPresented: $isSearching, placement: .automatic, prompt: "Search") .background(Button("", action: { isSearching = true }).keyboardShortcut("f").hidden()) .toolbar { - ToolbarItemGroup(placement: .automatic) { + ToolbarItem { Button { if let selectedFile = selection, selectedFile.isPreviewable { - previewFile(selectedFile) + self.previewFile(selectedFile) } } label: { Label("Preview", systemImage: "eye") } .help("Preview") .disabled(selection == nil || selection?.isPreviewable != true) - + } + + ToolbarItem { Button { if let selectedFile = selection { - getFileInfo(selectedFile) + self.getFileInfo(selectedFile) } } label: { Label("Get Info", systemImage: "info.circle") } .help("Get Info") .disabled(selection == nil) - + } + + ToolbarItem { Button { - uploadFileSelectorDisplayed = true + self.uploadFileSelectorDisplayed = true } label: { Label("Upload", systemImage: "arrow.up") } .help("Upload") .disabled(model.access?.contains(.canUploadFiles) != true) - + } + + ToolbarItem { Button { if let selectedFile = selection { - downloadFile(selectedFile) + self.downloadFile(selectedFile) } } label: { Label("Download", systemImage: "arrow.down") @@ -193,9 +224,48 @@ struct FilesView: View { .help("Download") .disabled(selection == nil || model.access?.contains(.canDownloadFiles) != true) } + + if #available(macOS 26.0, *) { + ToolbarSpacer() + } + + ToolbarItem { + Button { + self.newFolderSheetDisplayed = true + } label: { + Label("New Folder", systemImage: "folder.badge.plus") + } + .help("New Folder") + } + + ToolbarItem { + Button { + self.deleteConfirmationDisplayed = true + } label: { + Label("Delete", systemImage: "trash") + } + .disabled(self.selection == nil) + .help("Delete") + } + } + } + .alert("Are you sure you want to permanently delete \"\(self.selection?.name ?? "this file")\"?", isPresented: self.$deleteConfirmationDisplayed, actions: { + Button("Delete", role: .destructive) { + if let s = self.selection { + Task { + await self.deleteFile(s) + } + } + } + }, message: { + Text("You cannot undo this action.") + }) + .sheet(isPresented: self.$newFolderSheetDisplayed) { + NewFolderSheet { folderName in + self.newFolder(name: folderName, parent: self.selection) } } - .sheet(item: $fileDetails ) { item in + .sheet(item: $fileDetails) { item in FileDetailsView(fd: item) } .fileImporter(isPresented: $uploadFileSelectorDisplayed, allowedContentTypes: [.data, .folder], allowsMultipleSelection: false, onCompletion: { results in @@ -385,6 +455,20 @@ struct FilesView: View { } } + @MainActor private func newFolder(name: String, parent: FileInfo?) { + Task { + var parentFolder: FileInfo? = nil + if parent?.isFolder == true { + parentFolder = parent + } + + let path: [String] = parentFolder?.path ?? [] + if try await self.model.newFolder(name: name, parentPath: path) == true { + try await self.model.getFileList(path: path) + } + } + } + @MainActor private func getFileInfo(_ file: FileInfo) { Task { if let fileInfo = try? await model.getFileDetails(file.name, path: file.path) { @@ -405,10 +489,10 @@ struct FilesView: View { } @MainActor private func uploadFile(file fileURL: URL, to path: [String]) { - model.uploadFile(url: fileURL, path: path) { info in + self.model.uploadFile(url: fileURL, path: path) { info in Task { // Refresh file listing to display newly uploaded file. - let _ = try? await model.getFileList(path: path) + try? await self.model.getFileList(path: path) } } } -- cgit From b58129025413367d8237b3147a9fa96bd4e6924f Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Tue, 11 Nov 2025 17:46:31 -0800 Subject: Fix up File Info sheet. --- Hotline.xcodeproj/project.pbxproj | 12 ++- Hotline/macOS/Files/FileDetailsSheet.swift | 141 +++++++++++++++++++++++++++ Hotline/macOS/Files/FileDetailsView.swift | 147 ----------------------------- Hotline/macOS/Files/FilesView.swift | 35 +------ Hotline/macOS/Files/NewFolderSheet.swift | 32 +++++++ 5 files changed, 183 insertions(+), 184 deletions(-) create mode 100644 Hotline/macOS/Files/FileDetailsSheet.swift delete mode 100644 Hotline/macOS/Files/FileDetailsView.swift create mode 100644 Hotline/macOS/Files/NewFolderSheet.swift (limited to 'Hotline/macOS/Files/FilesView.swift') diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index 2579dbe..1f9bdef 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -8,7 +8,7 @@ /* Begin PBXBuildFile section */ 11A726082BE0672A000C1DA7 /* FileDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11A726072BE0672A000C1DA7 /* FileDetails.swift */; }; - 11A7260A2BE0675A000C1DA7 /* FileDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11A726092BE06759000C1DA7 /* FileDetailsView.swift */; platformFilters = (macos, ); }; + 11A7260A2BE0675A000C1DA7 /* FileDetailsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11A726092BE06759000C1DA7 /* FileDetailsSheet.swift */; platformFilters = (macos, ); }; 11F8288B2BF9428100216BA0 /* AccountManagerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11F8288A2BF9428100216BA0 /* AccountManagerView.swift */; }; DA0D698D2B1E7CF700C71DF5 /* UsersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0D698C2B1E7CF700C71DF5 /* UsersView.swift */; platformFilter = ios; }; DA0D698F2B1E841600C71DF5 /* MessageBoardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0D698E2B1E841600C71DF5 /* MessageBoardView.swift */; platformFilter = ios; }; @@ -22,6 +22,7 @@ DA32CD4B2B29318E0053B98B /* FileInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32CD4A2B29318E0053B98B /* FileInfo.swift */; }; DA32CD4D2B2931B50053B98B /* ChatMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32CD4C2B2931B50053B98B /* ChatMessage.swift */; }; DA32CD4F2B2931CC0053B98B /* NewsInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32CD4E2B2931CC0053B98B /* NewsInfo.swift */; }; + DA32F1CC2EC4175F00B243BC /* NewFolderSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32F1CB2EC4175F00B243BC /* NewFolderSheet.swift */; }; DA3429AE2EB9C0280010784E /* HotlineFileUploadClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429AD2EB9C0220010784E /* HotlineFileUploadClient.swift */; }; DA3429B02EBA70790010784E /* HotlineFolderUploadClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429AF2EBA70790010784E /* HotlineFolderUploadClient.swift */; }; DA3429B32EBA7ADF0010784E /* HotlineFilePreviewClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429B22EBA7ADE0010784E /* HotlineFilePreviewClient.swift */; }; @@ -120,7 +121,7 @@ /* Begin PBXFileReference section */ 11A726072BE0672A000C1DA7 /* FileDetails.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileDetails.swift; sourceTree = ""; }; - 11A726092BE06759000C1DA7 /* FileDetailsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileDetailsView.swift; sourceTree = ""; }; + 11A726092BE06759000C1DA7 /* FileDetailsSheet.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileDetailsSheet.swift; sourceTree = ""; }; 11F8288A2BF9428100216BA0 /* AccountManagerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountManagerView.swift; sourceTree = ""; }; DA0D698C2B1E7CF700C71DF5 /* UsersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsersView.swift; sourceTree = ""; }; DA0D698E2B1E841600C71DF5 /* MessageBoardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageBoardView.swift; sourceTree = ""; }; @@ -136,6 +137,7 @@ DA32CD4A2B29318E0053B98B /* FileInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileInfo.swift; sourceTree = ""; }; DA32CD4C2B2931B50053B98B /* ChatMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessage.swift; sourceTree = ""; }; DA32CD4E2B2931CC0053B98B /* NewsInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsInfo.swift; sourceTree = ""; }; + DA32F1CB2EC4175F00B243BC /* NewFolderSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewFolderSheet.swift; sourceTree = ""; }; DA3429AD2EB9C0220010784E /* HotlineFileUploadClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFileUploadClient.swift; sourceTree = ""; }; DA3429AF2EBA70790010784E /* HotlineFolderUploadClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFolderUploadClient.swift; sourceTree = ""; }; DA3429B22EBA7ADE0010784E /* HotlineFilePreviewClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFilePreviewClient.swift; sourceTree = ""; }; @@ -339,7 +341,8 @@ DAE735002B2E71F2000C56F6 /* FilesView.swift */, DA5268A42EB0743000DCB941 /* FileItemView.swift */, DA5268A22EB0741B00DCB941 /* FolderItemView.swift */, - 11A726092BE06759000C1DA7 /* FileDetailsView.swift */, + DA32F1CB2EC4175F00B243BC /* NewFolderSheet.swift */, + 11A726092BE06759000C1DA7 /* FileDetailsSheet.swift */, DAAEE66E2B47625600A5BA07 /* FilePreviewImageView.swift */, DAB4D87D2B4C8BCA0048A05C /* FilePreviewTextView.swift */, DA3429B82EBAB2130010784E /* FilePreviewQuickLookView.swift */, @@ -678,6 +681,7 @@ DA32CD4F2B2931CC0053B98B /* NewsInfo.swift in Sources */, DA9CAFBB2B126D5700CDA197 /* MacApp.swift in Sources */, DAAEE66B2B3FBC2100A5BA07 /* TransferInfo.swift in Sources */, + DA32F1CC2EC4175F00B243BC /* NewFolderSheet.swift in Sources */, DA0D698F2B1E841600C71DF5 /* MessageBoardView.swift in Sources */, DAE735072B3251B3000C56F6 /* SoundEffects.swift in Sources */, DA55AC752BE4888300034857 /* InstantMessage.swift in Sources */, @@ -686,7 +690,7 @@ DA5268B12EB2708E00DCB941 /* HotlineState.swift in Sources */, DA55AC792BE6A1AD00034857 /* RegularExpressions.swift in Sources */, DA5268A02EB073BC00DCB941 /* SoundSettingsView.swift in Sources */, - 11A7260A2BE0675A000C1DA7 /* FileDetailsView.swift in Sources */, + 11A7260A2BE0675A000C1DA7 /* FileDetailsSheet.swift in Sources */, DA72A0DD2B4CD0BF00A0F48A /* NewsEditorView.swift in Sources */, DA52689C2EB0738B00DCB941 /* GeneralSettingsView.swift in Sources */, DA501BE92EBE9589001714F8 /* TrackerItemView.swift in Sources */, diff --git a/Hotline/macOS/Files/FileDetailsSheet.swift b/Hotline/macOS/Files/FileDetailsSheet.swift new file mode 100644 index 0000000..d798fd4 --- /dev/null +++ b/Hotline/macOS/Files/FileDetailsSheet.swift @@ -0,0 +1,141 @@ +import Foundation +import SwiftUI + +struct FileDetailsSheet: View { + @Environment(HotlineState.self) private var model: HotlineState + @Environment(\.presentationMode) var presentationMode + + var fd: FileDetails + + @State private var comment: String = "" + @State private var filename: String = "" + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .center, spacing: 16){ + FileIconView(filename: fd.name, fileType: nil) + .frame(width: 32, height: 32) + TextField("", text: $filename) + .disabled(!self.canRename()) + } + + let rows: [(String, String)] = [ + ("Type", fd.type), + ("Creator", fd.creator), + ("Size", self.formattedSize(byteCount: fd.size)), + ("Created", Self.dateFormatter.string(from: fd.created)), + ("Modified", Self.dateFormatter.string(from: fd.modified)) + ] + + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { + ForEach(rows, id: \.0) { label, value in + GridRow { + Text(label) + .font(.body.bold()) + .gridColumnAlignment(.trailing) // right-align label column + Text(value) + .font(.body) + .gridColumnAlignment(.leading) // left-align value column + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 32 + 16) + + TextField(text: self.$comment, prompt: Text("Comments"), axis: .vertical) { + EmptyView() + } + .font(.body) + .lineLimit(10, reservesSpace: true) + .padding(.leading, 32 + 16) + .disabled(!self.canSetComment()) + } + .padding(.vertical, 24) + .padding(.horizontal, 24) + .frame(width: 400) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + presentationMode.wrappedValue.dismiss() + } + } + + ToolbarItem(placement: .primaryAction) { + Button{ + var editedFilename: String? + if filename != fd.name { + editedFilename = filename + } + + var editedComment: String? + if comment != fd.comment { + editedComment = comment + } + + model.setFileInfo(fileName: fd.name, path: fd.path, fileNewName: editedFilename, comment: editedComment) + presentationMode.wrappedValue.dismiss() + + // TODO: Update the file list if the filename was changed + } label: { + Text("Save") + }.disabled(!isEdited()) + } + } + .onAppear { + self.filename = fd.name + self.comment = fd.comment + } + } + + + static var dateFormatter: DateFormatter = { + var dateFormatter = DateFormatter() + dateFormatter.dateStyle = .long + dateFormatter.timeStyle = .short + + // Original format: Fri, Aug 20, 2021, 5:14:07 PM + return dateFormatter + }() + + static var byteCountSizeFormatter: NumberFormatter = { + let numberFormatter = NumberFormatter() + numberFormatter.numberStyle = .decimal + return numberFormatter + }() + + static let byteFormatter = ByteCountFormatter() + + private func formattedFileSize(_ fileSize: UInt) -> String { + FileItemView.byteFormatter.allowedUnits = [.useAll] + FileItemView.byteFormatter.countStyle = .file + return FileItemView.byteFormatter.string(fromByteCount: Int64(fileSize)) + } + + // Format byte count Int into string like: 23.4M (24,601,664 bytes) + private func formattedSize(byteCount: Int) -> String { + let formattedByteCount = Self.byteCountSizeFormatter.string(from: NSNumber(value:byteCount)) ?? "0" + return "\(FileItemView.byteFormatter.string(fromByteCount: Int64(byteCount))) (\(formattedByteCount) bytes)" + } + + private func isEdited() -> Bool { + return self.filename != fd.name || self.comment != fd.comment + } + + private func canRename() -> Bool { + if self.fd.type == "fldr" { + return model.access?.contains(.canRenameFolders) == true + } + return model.access?.contains(.canRenameFiles) == true + } + + private func canSetComment() -> Bool { + if self.fd.type == "fldr" { + return model.access?.contains(.canSetFolderComment) == true + } + return model.access?.contains(.canSetFileComment) == true + } +} + +//#Preview { +// FileDetailsView(fd: FileDetails(name: "AppleWorks 6.sit", path: [""], size: 24601664, comment: "test comment", type: "SITD", creator: "SIT!", created: Date.now, modified: Date.now )) +//} diff --git a/Hotline/macOS/Files/FileDetailsView.swift b/Hotline/macOS/Files/FileDetailsView.swift deleted file mode 100644 index d001df2..0000000 --- a/Hotline/macOS/Files/FileDetailsView.swift +++ /dev/null @@ -1,147 +0,0 @@ -import Foundation -import SwiftUI - -struct FileDetailsView: View { - @Environment(HotlineState.self) private var model: HotlineState - @Environment(\.presentationMode) var presentationMode - - var fd: FileDetails - - @State private var comment: String = "" - @State private var filename: String = "" - - var body: some View { - VStack (alignment: .leading){ - Form { - HStack(alignment: .center){ - FileIconView(filename: fd.name, fileType: nil) - .frame(width: 32, height: 32) - TextField("", text: $filename) - .disabled(!self.canRename()) - } - HStack(alignment: .center){ - Text("Type:").bold().padding(.leading, 43) - Text(fd.type) - } - HStack(alignment: .center){ - Text("Creator:").bold().padding(.leading, 26) - Text(fd.creator) - } - HStack(alignment: .center){ - Text("Size:").bold().bold().padding(.leading, 48) - Text(self.formattedSize(byteCount: fd.size)) - } - HStack(alignment: .center){ - Text("Created:").bold().padding(.leading, 24) - Text("\(FileDetailsView.dateFormatter.string(from: fd.created))") - } - HStack(alignment: .center){ - Text("Modified:").bold().padding(.leading, 19) - Text("\(FileDetailsView.dateFormatter.string(from: fd.modified))") - } - HStack(alignment: .center){ - Text("Comments:").bold().padding(.top, 8) - .padding(.leading, 5) - } - - VStack(alignment: .trailing){ - TextEditor(text: $comment) - .padding(.leading, 2) - .padding(.top, 1) - .font(.system(size: 13)) - .background(Color(nsColor: .textBackgroundColor)) - .border(Color.secondary, width: 1) - .clipShape(RoundedRectangle(cornerRadius: 2, style: .continuous)) - .disabled(!self.canSetComment()) - } - } - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - presentationMode.wrappedValue.dismiss() - } - } - - ToolbarItem(placement: .primaryAction) { - Button{ - var editedFilename: String? - if filename != fd.name { - editedFilename = filename - } - - var editedComment: String? - if comment != fd.comment { - editedComment = comment - } - - model.setFileInfo(fileName: fd.name, path: fd.path, fileNewName: editedFilename, comment: editedComment) - presentationMode.wrappedValue.dismiss() - - // TODO: Update the file list if the filename was changed - } label: { - Text("Save") - }.disabled(!isEdited()) - } - } - - .onAppear { - self.filename = fd.name - self.comment = fd.comment - } - .padding(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24)) - } - .frame(minWidth: 400, minHeight: 400) - } - - - static var dateFormatter: DateFormatter = { - var dateFormatter = DateFormatter() - dateFormatter.dateStyle = .long - dateFormatter.timeStyle = .short - - // Original format: Fri, Aug 20, 2021, 5:14:07 PM - return dateFormatter - }() - - static var byteCountSizeFormatter: NumberFormatter = { - let numberFormatter = NumberFormatter() - numberFormatter.numberStyle = .decimal - return numberFormatter - }() - - static let byteFormatter = ByteCountFormatter() - - private func formattedFileSize(_ fileSize: UInt) -> String { - FileItemView.byteFormatter.allowedUnits = [.useAll] - FileItemView.byteFormatter.countStyle = .file - return FileItemView.byteFormatter.string(fromByteCount: Int64(fileSize)) - } - - // Format byte count Int into string like: 23.4M (24,601,664 bytes) - private func formattedSize(byteCount: Int) -> String { - let formattedByteCount = FileDetailsView.byteCountSizeFormatter.string(from: NSNumber(value:byteCount)) ?? "0" - return "\(FileItemView.byteFormatter.string(fromByteCount: Int64(byteCount))) (\(formattedByteCount) bytes)" - } - - private func isEdited() -> Bool { - return self.filename != fd.name || self.comment != fd.comment - } - - private func canRename() -> Bool { - if self.fd.type == "fldr" { - return model.access?.contains(.canRenameFolders) == true - } - return model.access?.contains(.canRenameFiles) == true - } - - private func canSetComment() -> Bool { - if self.fd.type == "fldr" { - return model.access?.contains(.canSetFolderComment) == true - } - return model.access?.contains(.canSetFileComment) == true - } -} - -//#Preview { -// FileDetailsView(fd: FileDetails(name: "AppleWorks 6.sit", path: [""], size: 24601664, comment: "test comment", type: "SITD", creator: "SIT!", created: Date.now, modified: Date.now )) -//} diff --git a/Hotline/macOS/Files/FilesView.swift b/Hotline/macOS/Files/FilesView.swift index 3732d9a..a9b0b83 100644 --- a/Hotline/macOS/Files/FilesView.swift +++ b/Hotline/macOS/Files/FilesView.swift @@ -2,37 +2,6 @@ import SwiftUI import UniformTypeIdentifiers import AppKit -struct NewFolderSheet: View { - @Environment(\.dismiss) private var dismiss - - let action: ((String) -> Void)? - - @State private var folderName: String = "Untitled" - - var body: some View { - Form { - TextField(text: self.$folderName) { - Text("Folder Name") - } - } - .formStyle(.grouped) - .fixedSize(horizontal: false, vertical: true) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("New Folder") { - self.dismiss() - self.action?(self.folderName) - } - } - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - self.dismiss() - } - } - } - } -} - struct FilesView: View { @Environment(HotlineState.self) private var model: HotlineState @Environment(\.openWindow) private var openWindow @@ -265,8 +234,8 @@ struct FilesView: View { self.newFolder(name: folderName, parent: self.selection) } } - .sheet(item: $fileDetails) { item in - FileDetailsView(fd: item) + .sheet(item: self.$fileDetails) { item in + FileDetailsSheet(fd: item) } .fileImporter(isPresented: $uploadFileSelectorDisplayed, allowedContentTypes: [.data, .folder], allowsMultipleSelection: false, onCompletion: { results in switch results { diff --git a/Hotline/macOS/Files/NewFolderSheet.swift b/Hotline/macOS/Files/NewFolderSheet.swift new file mode 100644 index 0000000..a899f36 --- /dev/null +++ b/Hotline/macOS/Files/NewFolderSheet.swift @@ -0,0 +1,32 @@ +import SwiftUI + +struct NewFolderSheet: View { + @Environment(\.dismiss) private var dismiss + + let action: ((String) -> Void)? + + @State private var folderName: String = "Untitled" + + var body: some View { + Form { + TextField(text: self.$folderName) { + Text("Folder Name") + } + } + .formStyle(.grouped) + .fixedSize(horizontal: false, vertical: true) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("New Folder") { + self.dismiss() + self.action?(self.folderName) + } + } + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + self.dismiss() + } + } + } + } +} -- cgit From 5eff79cc4891076d85223dcfd96c7cb8894c80f2 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Wed, 12 Nov 2025 18:55:25 -0800 Subject: Get Accounts out of the sidebar, redesign Account management. --- Hotline.xcodeproj/project.pbxproj | 8 +- Hotline/Hotline/HotlineClient.swift | 42 +- Hotline/Hotline/HotlineExtensions.swift | 44 +- Hotline/Hotline/HotlineProtocol.swift | 6 +- Hotline/Hotline/HotlineTrackerClient.swift | 21 +- Hotline/MacApp.swift | 6 +- Hotline/Models/FileDetails.swift | 4 +- Hotline/State/AppUpdate.swift | 20 +- Hotline/State/ServerState.swift | 7 +- Hotline/macOS/Accounts/AccountManagerView.swift | 686 ++++++++++++------------ Hotline/macOS/Files/FileDetailsSheet.swift | 10 +- Hotline/macOS/Files/FilesView.swift | 26 +- Hotline/macOS/Files/NewFolderPopover.swift | 51 ++ Hotline/macOS/Files/NewFolderSheet.swift | 32 -- Hotline/macOS/HotlinePanelView.swift | 5 +- Hotline/macOS/ServerView.swift | 59 +- Hotline/macOS/TransfersView.swift | 6 +- 17 files changed, 548 insertions(+), 485 deletions(-) create mode 100644 Hotline/macOS/Files/NewFolderPopover.swift delete mode 100644 Hotline/macOS/Files/NewFolderSheet.swift (limited to 'Hotline/macOS/Files/FilesView.swift') diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index 1f9bdef..7032420 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -22,7 +22,7 @@ DA32CD4B2B29318E0053B98B /* FileInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32CD4A2B29318E0053B98B /* FileInfo.swift */; }; DA32CD4D2B2931B50053B98B /* ChatMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32CD4C2B2931B50053B98B /* ChatMessage.swift */; }; DA32CD4F2B2931CC0053B98B /* NewsInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32CD4E2B2931CC0053B98B /* NewsInfo.swift */; }; - DA32F1CC2EC4175F00B243BC /* NewFolderSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32F1CB2EC4175F00B243BC /* NewFolderSheet.swift */; }; + DA32F1CC2EC4175F00B243BC /* NewFolderPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32F1CB2EC4175F00B243BC /* NewFolderPopover.swift */; }; DA3429AE2EB9C0280010784E /* HotlineFileUploadClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429AD2EB9C0220010784E /* HotlineFileUploadClient.swift */; }; DA3429B02EBA70790010784E /* HotlineFolderUploadClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429AF2EBA70790010784E /* HotlineFolderUploadClient.swift */; }; DA3429B32EBA7ADF0010784E /* HotlineFilePreviewClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429B22EBA7ADE0010784E /* HotlineFilePreviewClient.swift */; }; @@ -137,7 +137,7 @@ DA32CD4A2B29318E0053B98B /* FileInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileInfo.swift; sourceTree = ""; }; DA32CD4C2B2931B50053B98B /* ChatMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessage.swift; sourceTree = ""; }; DA32CD4E2B2931CC0053B98B /* NewsInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsInfo.swift; sourceTree = ""; }; - DA32F1CB2EC4175F00B243BC /* NewFolderSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewFolderSheet.swift; sourceTree = ""; }; + DA32F1CB2EC4175F00B243BC /* NewFolderPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewFolderPopover.swift; sourceTree = ""; }; DA3429AD2EB9C0220010784E /* HotlineFileUploadClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFileUploadClient.swift; sourceTree = ""; }; DA3429AF2EBA70790010784E /* HotlineFolderUploadClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFolderUploadClient.swift; sourceTree = ""; }; DA3429B22EBA7ADE0010784E /* HotlineFilePreviewClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFilePreviewClient.swift; sourceTree = ""; }; @@ -341,7 +341,7 @@ DAE735002B2E71F2000C56F6 /* FilesView.swift */, DA5268A42EB0743000DCB941 /* FileItemView.swift */, DA5268A22EB0741B00DCB941 /* FolderItemView.swift */, - DA32F1CB2EC4175F00B243BC /* NewFolderSheet.swift */, + DA32F1CB2EC4175F00B243BC /* NewFolderPopover.swift */, 11A726092BE06759000C1DA7 /* FileDetailsSheet.swift */, DAAEE66E2B47625600A5BA07 /* FilePreviewImageView.swift */, DAB4D87D2B4C8BCA0048A05C /* FilePreviewTextView.swift */, @@ -681,7 +681,7 @@ DA32CD4F2B2931CC0053B98B /* NewsInfo.swift in Sources */, DA9CAFBB2B126D5700CDA197 /* MacApp.swift in Sources */, DAAEE66B2B3FBC2100A5BA07 /* TransferInfo.swift in Sources */, - DA32F1CC2EC4175F00B243BC /* NewFolderSheet.swift in Sources */, + DA32F1CC2EC4175F00B243BC /* NewFolderPopover.swift in Sources */, DA0D698F2B1E841600C71DF5 /* MessageBoardView.swift in Sources */, DAE735072B3251B3000C56F6 /* SoundEffects.swift in Sources */, DA55AC752BE4888300034857 /* InstantMessage.swift in Sources */, diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index 1faaf55..ee99ed7 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -142,10 +142,6 @@ public actor HotlineClient { // Transaction tracking for request/reply pattern private var pendingTransactions: [UInt32: CheckedContinuation] = [:] - private enum TransactionWaitError: Error { - case timeout - } - // Receive loop task private var receiveTask: Task? @@ -427,10 +423,10 @@ public actor HotlineClient { try await self.socket.send(transaction, endian: .big) do { - return try await withTimeout(seconds: timeout) { + return try await Task.withTimeout(seconds: timeout) { try await self.awaitReply(for: transactionID) } - } catch is TransactionWaitError { + } catch is TaskTimeoutError { throw HotlineClientError.timeout } catch let error as HotlineClientError { throw error @@ -467,32 +463,6 @@ public actor HotlineClient { } } - private func withTimeout(seconds: TimeInterval, operation: @escaping @Sendable () async throws -> T) async throws -> T { - if seconds <= 0 { - throw TransactionWaitError.timeout - } - - return try await withThrowingTaskGroup(of: T.self) { group in - group.addTask { - try await operation() - } - - group.addTask { - try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) - throw TransactionWaitError.timeout - } - - do { - let value = try await group.next()! - group.cancelAll() - return value - } catch { - group.cancelAll() - throw error - } - } - } - // MARK: - Keep-Alive private func startKeepAlive() { @@ -841,7 +811,7 @@ public actor HotlineClient { accounts.append(data.getAcccount()) } - accounts.sort { $0.login < $1.login } + accounts.sort { $0.name < $1.name } return accounts } @@ -893,7 +863,11 @@ public actor HotlineClient { // - other: Set new password if password == nil { transaction.setFieldUInt8(type: .userPassword, val: 0) - } else if password != "" { + } + else if password == "" { + // Don't add password to transaction (password will be removed) + } + else { transaction.setFieldEncodedString(type: .userPassword, val: password!) } diff --git a/Hotline/Hotline/HotlineExtensions.swift b/Hotline/Hotline/HotlineExtensions.swift index 6782a41..bcee812 100644 --- a/Hotline/Hotline/HotlineExtensions.swift +++ b/Hotline/Hotline/HotlineExtensions.swift @@ -7,6 +7,8 @@ enum LineEnding { case cr // Classic Mac-style (\r) } +// MARK: - + extension URL { func urlForResourceFork() -> URL { self.appendingPathComponent("..namedfork/rsrc") @@ -31,6 +33,40 @@ extension URL { #endif } +// MARK: - + +public struct TaskTimeoutError: Error {} + +extension Task where Success == Never, Failure == Never { + public static func withTimeout(seconds: TimeInterval, operation: @escaping @Sendable () async throws -> T) async throws -> T { + if seconds <= 0 { + throw TaskTimeoutError() + } + + return try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { + try await operation() + } + + group.addTask { + try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + throw TaskTimeoutError() + } + + do { + let value = try await group.next()! + group.cancelAll() + return value + } catch { + group.cancelAll() + throw error + } + } + } +} + +// MARK: - + extension String { func convertingLineEndings(to targetEnding: LineEnding) -> String { let lf = "\n" @@ -38,16 +74,16 @@ extension String { let cr = "\r" // Normalize all line endings to LF (\n) - let normalizedString = self.replacingOccurrences(of: cr, with: lf).replacingOccurrences(of: crlf, with: lf) + let normalizedString = self.replacing(cr, with: lf).replacing(crlf, with: lf) // Replace normalized LF (\n) line endings with the target line ending switch targetEnding { case .lf: return normalizedString case .crlf: - return normalizedString.replacingOccurrences(of: lf, with: crlf) + return normalizedString.replacing(lf, with: crlf) case .cr: - return normalizedString.replacingOccurrences(of: lf, with: cr) + return normalizedString.replacing(lf, with: cr) } } @@ -67,6 +103,8 @@ extension String { } } +// MARK: - + extension FileManager { static var extensionToHFSCreator: [String: UInt32] = [ // Documents diff --git a/Hotline/Hotline/HotlineProtocol.swift b/Hotline/Hotline/HotlineProtocol.swift index 5fd06dc..4857d87 100644 --- a/Hotline/Hotline/HotlineProtocol.swift +++ b/Hotline/Hotline/HotlineProtocol.swift @@ -233,18 +233,20 @@ public struct HotlineAccount: Identifiable { public let id: UUID = UUID() var name: String = "" var login: String = "" - var password: String? = nil - var persisted: Bool = true + var password: String = "" + var persisted: Bool = false var access: HotlineUserAccessOptions = HotlineUserAccessOptions() var fields: [HotlineTransactionField] = [] init(from data: [UInt8]) { self.decodeFields(from: data) + self.persisted = true } init(_ name: String, _ login: String, _ access: HotlineUserAccessOptions) { self.name = name self.login = login + self.password = HotlineAccount.randomPassword() self.access = access self.persisted = false } diff --git a/Hotline/Hotline/HotlineTrackerClient.swift b/Hotline/Hotline/HotlineTrackerClient.swift index 6a7a5b3..8580cd7 100644 --- a/Hotline/Hotline/HotlineTrackerClient.swift +++ b/Hotline/Hotline/HotlineTrackerClient.swift @@ -57,7 +57,7 @@ class HotlineTrackerClient { private func fetchServersInternal(address: String, port: Int, continuation: AsyncThrowingStream.Continuation) async { do { - try await withTimeout(seconds: 30) { + try await Task.withTimeout(seconds: 30) { try await self.doFetch(address: address, port: port, continuation: continuation) } } catch { @@ -160,23 +160,4 @@ class HotlineTrackerClient { print("HotlineTrackerClient: Completed - parsed \(totalEntriesParsed)/\(totalExpectedEntries) entries, yielded \(totalYielded) servers") continuation.finish() } - - private func withTimeout(seconds: TimeInterval, operation: @escaping () async throws -> T) async throws -> T { - try await withThrowingTaskGroup(of: T.self) { group in - group.addTask { - try await operation() - } - - group.addTask { - try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) - throw NSError(domain: "HotlineTracker", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "Tracker request timed out after \(seconds) seconds" - ]) - } - - let result = try await group.next()! - group.cancelAll() - return result - } - } } diff --git a/Hotline/MacApp.swift b/Hotline/MacApp.swift index 9ac54c1..5a84da8 100644 --- a/Hotline/MacApp.swift +++ b/Hotline/MacApp.swift @@ -277,11 +277,11 @@ struct Application: App { if activeHotline?.access?.contains(.canOpenUsers) == true { Divider() - Button("Accounts") { - activeServerState?.selection = .accounts + Button("Manage Server...") { + activeServerState?.accountsShown = true } .disabled(activeHotline?.status != .loggedIn || activeHotline?.access?.contains(.canOpenUsers) != true ) - .keyboardShortcut(.init("5"), modifiers: .command) +// .keyboardShortcut(.init("5"), modifiers: .command) } } } diff --git a/Hotline/Models/FileDetails.swift b/Hotline/Models/FileDetails.swift index 6164151..71ddb6d 100644 --- a/Hotline/Models/FileDetails.swift +++ b/Hotline/Models/FileDetails.swift @@ -1,7 +1,7 @@ import UniformTypeIdentifiers -public struct FileDetails:Identifiable { - public let id = UUID() +public struct FileDetails: Identifiable { + public let id: UUID = UUID() var name: String var path: [String] var size: Int diff --git a/Hotline/State/AppUpdate.swift b/Hotline/State/AppUpdate.swift index 558a296..83006a2 100644 --- a/Hotline/State/AppUpdate.swift +++ b/Hotline/State/AppUpdate.swift @@ -144,12 +144,13 @@ final class AppUpdate { self.releaseNotesCombined = nil self.isDownloading = false if trigger == .manual { - self.message = AppUpdateMessage( - title: "Hotline is up to date", - detail: "You're running the latest and greatest.", - kind: .success - ) - self.showWindow = true + self.showUpToDateAlert() +// self.message = AppUpdateMessage( +// title: "Hotline is up to date", +// detail: "You're running the latest and greatest.", +// kind: .success +// ) +// self.showWindow = true } else { self.message = nil self.showWindow = false @@ -175,6 +176,13 @@ final class AppUpdate { } } + private func showUpToDateAlert() { + let alert = NSAlert() + alert.messageText = "No Update Available" + alert.informativeText = "You are already using the latest version of Hotline!" + alert.runModal() + } + private func fetchNewerReleases() async throws -> [UpdateReleaseInfo] { let (data, _) = try await URLSession.shared.data(from: releasesURL) guard let jsonArray = try JSONSerialization.jsonObject(with: data, options: []) as? [[String: Any]] else { diff --git a/Hotline/State/ServerState.swift b/Hotline/State/ServerState.swift index 5913bf5..8f8d3bf 100644 --- a/Hotline/State/ServerState.swift +++ b/Hotline/State/ServerState.swift @@ -5,6 +5,7 @@ class ServerState: Equatable { var id: UUID = UUID() var selection: ServerNavigationType var serverName: String? = nil + var accountsShown: Bool = false // var serverBanner: NSImage? = nil // var bannerBackgroundColor: Color? = nil @@ -28,8 +29,8 @@ enum ServerNavigationType: Identifiable, Hashable, Equatable { return "Board" case .files: return "Files" - case .accounts: - return "Accounts" +// case .accounts: +// return "Accounts" case .user(let userID): return String(userID) } @@ -39,6 +40,6 @@ enum ServerNavigationType: Identifiable, Hashable, Equatable { case news case board case files - case accounts +// case accounts case user(userID: UInt16) } diff --git a/Hotline/macOS/Accounts/AccountManagerView.swift b/Hotline/macOS/Accounts/AccountManagerView.swift index c45ba18..bbb909b 100644 --- a/Hotline/macOS/Accounts/AccountManagerView.swift +++ b/Hotline/macOS/Accounts/AccountManagerView.swift @@ -1,402 +1,418 @@ import SwiftUI +fileprivate let DEFAULT_ACCOUNT_NAME = "Untitled Account" +fileprivate let PASSWORD_PLACEHOLDER = "xxxxxxxxxxxxxxxxxx" + struct AccountManagerView: View { @Environment(HotlineState.self) private var model: HotlineState + @Environment(\.dismiss) private var dismiss @State private var accounts: [HotlineAccount] = [] @State private var selection: HotlineAccount? @State private var loading: Bool = true - @State private var pendingName: String = "" - @State private var pendingLogin: String = "" - @State private var pendingPassword: String = "" - @State private var pendingAccess = HotlineUserAccessOptions.defaultAccess + @State private var creatorShown: Bool = false + @State private var deleteConfirm: Bool = false + @State private var accountToEdit: HotlineAccount? = nil + @State private var accountToDelete: HotlineAccount? = nil - @State private var toDelete: HotlineAccount? + private func newAccount() { + self.creatorShown = true + } - let placeholderPassword = "xxxxxxxxxxxxxxxxxx" + private func editAccount(_ account: HotlineAccount) { + // Always get the latest version from the array to avoid stale data + if let currentAccount = self.accounts.first(where: { $0.id == account.id }) { + self.accountToEdit = currentAccount + } + } + + private func deleteAccount(_ account: HotlineAccount) { + self.accountToDelete = account + self.deleteConfirm = true + } var body: some View { - HStack(spacing: 0) { - ZStack { - accountList - if loading { - ProgressView() - } - } - if selection != nil { - accountDetails - } - else { - ZStack(alignment: .center) { - Text("No Account Selected") - .font(.title) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding() + VStack(spacing: 8) { + HStack(alignment: .firstTextBaseline) { + Text("Accounts") + .font(.headline) + + Spacer() + + HStack { + Button { + self.newAccount() + } label: { + Image(systemName: "plus") + .padding(4) + } + .buttonBorderShape(.circle) + .help("New Account") + + Button { + if let account = self.selection { + self.editAccount(account) + } + } label: { + Image(systemName: "pencil") + .padding(4) + } + .buttonBorderShape(.circle) + .disabled(self.selection == nil) + .help("Edit Account") + + Button { + if let account = self.selection { + self.deleteAccount(account) + } + } label: { + Image(systemName: "trash") + .padding(4) + } + .tint(.hotlineRed) + .buttonBorderShape(.circle) + .disabled(self.selection == nil) + .help("Delete Account") } - .frame(maxWidth: .infinity) } + .padding(.horizontal, 16) + .padding(.top, 24) + + self.accountList +// .overlay { +// if self.loading { +// ProgressView() +// .progressViewStyle(.linear) +// .controlSize(.extraLarge) +// .frame(width: 100) +// } +// } } .environment(\.defaultMinListRowHeight, 34) .listStyle(.inset) .alternatingRowBackgrounds(.enabled) .task { - if loading { - accounts = (try? await model.getAccounts()) ?? [] - loading = false + if self.loading { + do { + self.accounts = try await self.model.getAccounts() + } + catch { + self.dismiss() + } + + self.loading = false } } .toolbar { - ToolbarItem(placement: .primaryAction) { - Button { - let newAccount = HotlineAccount("unnamed", "unnamed", HotlineUserAccessOptions.defaultAccess) - - pendingPassword = HotlineAccount.randomPassword() - accounts.append(newAccount) - selection = newAccount - } label: { - Label("New Account", systemImage: "plus") + if self.loading { + ToolbarItem { + ProgressView() + .controlSize(.small) } - .help("Create a new account") - .disabled(model.access?.contains(.canCreateUsers) != true) } - ToolbarItem(placement: .destructiveAction) { + ToolbarItem(placement: .confirmationAction) { Button { - toDelete = selection + self.dismiss() } label: { - Label("Delete Account", systemImage: "trash") + Text("OK") } - .help("Delete account") - .disabled(selection == nil || model.access?.contains(.canDeleteUsers) != true) } } } - var accountDetails: some View { - VStack(alignment: .center, spacing: 0) { - ScrollView(.vertical) { - Form { - Section { - TextField(text: $pendingName) { - Text("Name") - } - TextField("Login", text: $pendingLogin) - .disabled(selection?.persisted == true) - if selection?.persisted == true { - SecureField("Password", text: $pendingPassword) - } else { - TextField("Password", text: $pendingPassword) - } - } - .textFieldStyle(.roundedBorder) - .controlSize(.large) - - Section("File System Maintenance") { - Toggle("Can Download Files", isOn: $pendingAccess.bind(.canDownloadFiles)) - .disabled(model.access?.contains(.canDownloadFiles) == false) - Toggle("Can Download Folders", isOn: $pendingAccess.bind(.canDownloadFolders)) - .disabled(model.access?.contains(.canDownloadFolders) == false) - Toggle("Can Upload Files", isOn: $pendingAccess.bind(.canUploadFiles)) - .disabled(model.access?.contains(.canUploadFiles) == false) - Toggle("Can Upload Folders", isOn: $pendingAccess.bind(.canUploadFolders)) - .disabled(model.access?.contains(.canUploadFolders) == false) - Toggle("Can Upload Anywhere", isOn: $pendingAccess.bind(.canUploadAnywhere)) - .disabled(model.access?.contains(.canUploadAnywhere) == false) - Toggle("Can Delete Files", isOn: $pendingAccess.bind(.canDeleteFiles)) - .disabled(model.access?.contains(.canDeleteFiles) == false) - Toggle("Can Rename Files", isOn: $pendingAccess.bind(.canRenameFiles)) - .disabled(model.access?.contains(.canRenameFiles) == false) - Toggle("Can Move Files", isOn: $pendingAccess.bind(.canMoveFiles)) - .disabled(model.access?.contains(.canMoveFiles) == false) - Toggle("Can Comment Files", isOn: $pendingAccess.bind(.canSetFileComment)) - .disabled(model.access?.contains(.canSetFileComment) == false) - Toggle("Can Create Folders", isOn: $pendingAccess.bind(.canCreateFolders)) - .disabled(model.access?.contains(.canCreateFolders) == false) - Toggle("Can Delete Folders", isOn: $pendingAccess.bind(.canDeleteFolders)) - .disabled(model.access?.contains(.canDeleteFolders) == false) - Toggle("Can Rename Folders", isOn: $pendingAccess.bind(.canRenameFolders)) - .disabled(model.access?.contains(.canRenameFolders) == false) - Toggle("Can Move Folders", isOn: $pendingAccess.bind(.canMoveFolders)) - .disabled(model.access?.contains(.canMoveFolders) == false) - Toggle("Can Comment Folders", isOn: $pendingAccess.bind(.canSetFolderComment)) - .disabled(model.access?.contains(.canSetFolderComment) == false) - Toggle("Can View Drop Boxes", isOn: $pendingAccess.bind(.canViewDropBoxes)) - .disabled(model.access?.contains(.canViewDropBoxes) == false) - Toggle("Can Make Aliases", isOn: $pendingAccess.bind(.canMakeAliases)) - .disabled(model.access?.contains(.canMakeAliases) == false) - } - - Section("User Maintenance") { - Toggle("Can Create Accounts", isOn: $pendingAccess.bind(.canCreateUsers)) - .disabled(model.access?.contains(.canCreateUsers) == false) - Toggle("Can Delete Accounts", isOn: $pendingAccess.bind(.canDeleteUsers)) - .disabled(model.access?.contains(.canDeleteUsers) == false) - Toggle("Can Read Accounts", isOn: $pendingAccess.bind(.canOpenUsers)) - .disabled(model.access?.contains(.canOpenUsers) == false) - Toggle("Can Modify Accounts", isOn: $pendingAccess.bind(.canModifyUsers)) - .disabled(model.access?.contains(.canModifyUsers) == false) - Toggle("Can Get User Info", isOn: $pendingAccess.bind(.canGetClientInfo)) - .disabled(model.access?.contains(.canGetClientInfo) == false) - - Toggle("Can Disconnect Users", isOn: $pendingAccess.bind(.canDisconnectUsers)) - .disabled(model.access?.contains(.canDisconnectUsers) == false) - Toggle("Cannot be Disconnected", isOn: $pendingAccess.bind(.cantBeDisconnected)) - .disabled(model.access?.contains(.cantBeDisconnected) == false) - } - - Section("Messaging") { - Toggle("Can Send Messages", isOn: $pendingAccess.bind(.canSendMessages)) - .disabled(model.access?.contains(.canSendMessages) == false) - Toggle("Can Broadcast", isOn: $pendingAccess.bind(.canBroadcast)) - .disabled(model.access?.contains(.canBroadcast) == false) - } - - Section("News") { - Toggle("Can Read Articles", isOn: $pendingAccess.bind(.canReadMessageBoard)) - .disabled(model.access?.contains(.canReadMessageBoard) == false) - Toggle("Can Post Articles", isOn: $pendingAccess.bind(.canPostMessageBoard)) - .disabled(model.access?.contains(.canPostMessageBoard) == false) - Toggle("Can Delete Articles", isOn: $pendingAccess.bind(.canDeleteNewsArticles)) - .disabled(model.access?.contains(.canDeleteNewsArticles) == false) - Toggle("Can Create Categories", isOn: $pendingAccess.bind(.canCreateNewsCategories)) - .disabled(model.access?.contains(.canCreateNewsCategories) == false) - Toggle("Can Delete Categories", isOn: $pendingAccess.bind(.canDeleteNewsCategories)) - .disabled(model.access?.contains(.canDeleteNewsCategories) == false) - Toggle("Can Create News Bundles", isOn: $pendingAccess.bind(.canCreateNewsFolders)) - .disabled(model.access?.contains(.canCreateNewsFolders) == false) - Toggle("Can Delete News Bundles", isOn: $pendingAccess.bind(.canDeleteNewsFolders)) - .disabled(model.access?.contains(.canDeleteNewsFolders) == false) - } - - Section("Chat") { - Toggle("Can Initiate Private Chat", isOn: $pendingAccess.bind(.canCreateChat)) - .disabled(model.access?.contains(.canCreateChat) == false) - Toggle("Can Read Chat", isOn: $pendingAccess.bind(.canReadChat)) - .disabled(model.access?.contains(.canReadChat) == false) - Toggle("Can Send Chat", isOn: $pendingAccess.bind(.canSendChat)) - .disabled(model.access?.contains(.canSendChat) == false) - } - - Section("Miscellaneous") { - Toggle("Can Use Any Name", isOn: $pendingAccess.bind(.canUseAnyName)) - .disabled(model.access?.contains(.canUseAnyName) == false) - Toggle("Don't Show Agreement", isOn: $pendingAccess.bind(.canSkipAgreement)) - .disabled(model.access?.contains(.canSkipAgreement) == false) - } - } - .disabled(model.access?.contains(.canModifyUsers) == false) - .formStyle(.grouped) - .onChange(of: selection) { - if let selection { - pendingName = selection.name - pendingLogin = selection.login - pendingAccess = selection.access - - if selection.persisted { - if selection.password == nil { - pendingPassword = "" - } else { - pendingPassword = placeholderPassword - } - } - } - } - .onAppear() { - if let selection { - pendingName = selection.name - pendingLogin = selection.login - pendingAccess = selection.access - - if selection.persisted { - if selection.password == nil { - pendingPassword = "" - } else { - pendingPassword = placeholderPassword - } - } else { - pendingPassword = HotlineAccount.randomPassword() - } - } + private var accountList: some View { + List(self.accounts, id: \.self, selection: self.$selection) { account in + HStack(spacing: 5) { + Image(account.access.contains(.canDisconnectUsers) ? "User Admin" : "User") + .frame(width: 16, height: 16) + .opacity((account.access.rawValue == 0) ? 0.5 : 1.0) + Text(account.name) + .foregroundStyle(account.access.contains(.canDisconnectUsers) ? Color.hotlineRed : ((account.access.rawValue == 0) ? Color.secondary : Color.primary)) + + Spacer() + + Text(account.login) + .lineLimit(1) + .foregroundStyle(.secondary) + } + } + .contextMenu(forSelectionType: HotlineAccount.self) { items in + Button { + if let item = items.first { + self.editAccount(item) } + } label: { + Label("Edit Account...", systemImage: "pencil") } - .frame(maxWidth: .infinity) + .disabled(items.isEmpty) Divider() - HStack() { - Button("Revert") { - if let selection { - pendingAccess = selection.access - pendingName = selection.name - pendingLogin = selection.login - - if selection.password != nil { - pendingPassword = selection.password! - } - } + Button(role: .destructive) { + if let item = items.first { + self.deleteAccount(item) } - .controlSize(.large) - .frame(minWidth: 75) - .disabled(!self.isSaveable()) -// .padding() - - Spacer() - - Button(action: { - guard let selection else { - return - } - - // Update existing account - if selection.persisted == true { - - if pendingPassword == placeholderPassword { - Task { @MainActor in - try? await model.setUser(name: pendingName, login: pendingLogin, newLogin: nil, password: nil, access: pendingAccess.rawValue) - } - } else { - Task { @MainActor in - try? await model.setUser(name: pendingName, login: pendingLogin, newLogin: nil, password: pendingPassword, access: pendingAccess.rawValue) - } - } - - } else { - // Create new existing account - Task { @MainActor in - try? await model.createUser(name: pendingName, login: pendingLogin, password: pendingPassword, access: pendingAccess.rawValue) - } - self.selection?.password = pendingPassword - pendingPassword = placeholderPassword - } - - var account = HotlineAccount(pendingName, pendingLogin, pendingAccess) - account.persisted = true - account.password = placeholderPassword - - accounts = accounts.filter { $0.persisted == true && $0.login != selection.login } - - // Add new account to list - accounts.append(account) - - // Re-sort accounts - accounts.sort { $0.login < $1.login } - self.selection = account - }, label: { - Text("Save") - }) - .controlSize(.large) - .frame(minWidth: 75) - .keyboardShortcut(.defaultAction) - .disabled(!self.isSaveable()) + } label: { + Label("Delete Account...", systemImage: "trash") + } + .disabled(items.isEmpty) + } primaryAction: { items in + if let account = items.first { + self.editAccount(account) } - .padding() } - - } - - var accountList: some View { - List(accounts, id: \.self, selection: $selection) { account in - HStack(spacing: 5) { - if account.access.contains(.canDisconnectUsers) { - Image("User Admin") - .frame(width: 16, height: 16) - .opacity(account.persisted ? 1.0 : 0.25) - // .padding(.leading, 4) - Text(account.login) - .foregroundStyle(Color.hotlineRed) + .alert("Are you sure you want to delete the \"\(self.accountToDelete?.name ?? "unknown")\" account?", isPresented: self.$deleteConfirm, actions: { + Button("Delete", role: .destructive) { + guard let account = self.accountToDelete else { + return } - else if account.access.rawValue == 0 { - Image("User") - .frame(width: 16, height: 16) - // .padding(.leading, 4) - Text(account.login) - .foregroundStyle(.secondary) + + self.accountToDelete = nil + + Task { + self.selection = nil + + if account.persisted { + try await self.model.deleteUser(login: account.login) + } + + self.accounts = self.accounts.filter { $0.id != account.id } + self.deleteConfirm = false } - // else if account.persisted == false { - // HStack { - // Image("User") - // .frame(width: 16, height: 16) - //// .padding(.leading, 4) - // Text(account.login) - // .italic() - // } - // } - else { - Image("User") - .frame(width: 16, height: 16) - .opacity(account.persisted ? 1.0 : 0.5) - // .padding(.leading, 4) - Text(account.login) + } + }, message: { + Text("You cannot undo this action.") + }) + .sheet(item: self.$accountToEdit) { account in + AccountDetailsView(account: account) { editedAccount in + if let i = self.accounts.firstIndex(of: editedAccount) { + self.accounts.remove(at: i) + self.accounts.insert(editedAccount, at: i) } + self.accounts.sort { $0.name < $1.name } + self.selection = editedAccount + self.accountToEdit = nil } + .id(account.id) + .environment(self.model) + .frame(width: 480) + .frame(minHeight: 300, idealHeight: 400) + .presentationSizing(.fitted) } - .frame(width: 250) - .sheet(item: $toDelete) { item in - Form { - HStack{ - Image(systemName: "exclamationmark.triangle") - .font(.system(size: 30)) - Text("Delete account \"\(item.name)\" and all associated files?") - .lineSpacing(4) + .sheet(isPresented: self.$creatorShown) { + AccountDetailsView { newAccount in + self.accounts.append(newAccount) + self.accounts.sort { $0.name < $1.name } + self.selection = newAccount + } + .environment(self.model) + .frame(width: 480) + .frame(minHeight: 300, idealHeight: 400) + .presentationSizing(.fitted) + } + } +} + + +struct AccountDetailsView: View { + @Environment(HotlineState.self) private var model: HotlineState + @Environment(\.dismiss) private var dismiss + + @State var account: HotlineAccount = HotlineAccount("Untitled Account", "", HotlineUserAccessOptions.defaultAccess) + + let saved: ((HotlineAccount) -> Void)? + + @State private var password: String = "" + @State private var saving: Bool = false + + var body: some View { + self.accountDetails + .onAppear { + // Display a placeholder for accounts that have been saved to the server + // because we don't have the account password on hand to display. + if self.account.persisted { + self.password = PASSWORD_PLACEHOLDER } } - .frame(minWidth: 300, idealWidth: 450, maxWidth: .infinity, minHeight: 100, idealHeight: 100, maxHeight: .infinity) .toolbar { + if self.saving { + ToolbarItem { + ProgressView() + .controlSize(.small) + } + } + ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - toDelete = nil + Button { + self.dismiss() + } label: { + Text("Cancel") } } - ToolbarItem(placement: .primaryAction) { - Button(action: { - guard let userToDelete = toDelete else { - return - } - - self.toDelete = nil - self.selection = nil - - if userToDelete.persisted { - Task { @MainActor in - try? await model.deleteUser(login: userToDelete.login) + ToolbarItem(placement: .confirmationAction) { + Button { + Task { + do { + try await self.save() + self.dismiss() + } + catch { + print("ERROR SAVING ACCOUNT: \(error)") } } - - accounts = accounts.filter { $0.login != userToDelete.login } - - }, label: { - Text("Delete") - }) + } label: { + Text(self.account.persisted ? "Save" : "Create") + } + .disabled(self.saving) } } - } } - - private func isSaveable() -> Bool { - guard let selection else { - return false - } + private func save() async throws { + self.saving = true + defer { self.saving = false } - // Disable save if login field is cleared - if pendingLogin == "" { - return false + var accountName: String = self.account.name + if accountName.isBlank { + accountName = DEFAULT_ACCOUNT_NAME } - // If the account initial has a password and it was updated - if selection.password != nil && pendingPassword != placeholderPassword { - return true + // Update existing account + if self.account.persisted { + if self.password == PASSWORD_PLACEHOLDER { + try await self.model.setUser(name: accountName, login: self.account.login, newLogin: nil, password: nil, access: self.account.access.rawValue) + } else { + try await model.setUser(name: accountName, login: self.account.login, newLogin: nil, password: self.password, access: self.account.access.rawValue) + } + + } else { + // Create new existing account + try await model.createUser(name: accountName, login: self.account.login, password: self.password, access: self.account.access.rawValue) + +// self.password = PASSWORD_PLACEHOLDER + self.account.persisted = true } - // If the account initial has no password, but was updated to have one - if selection.password == nil && pendingPassword != "" { - return true + self.account.name = accountName + self.saved?(self.account) + } + + var accountDetails: some View { + Form { + Section { + TextField(text: self.$account.name, prompt: Text(DEFAULT_ACCOUNT_NAME)) { + Text("Account") + } + } + + Section { + TextField("Login", text: self.$account.login, prompt: Text("Required")) + .disabled(self.account.persisted) + + if self.account.persisted { + SecureField("Password", text: self.$password, prompt: Text("Optional")) + } else { + TextField("Password", text: self.$password, prompt: Text("Optional")) + } + } + + Section("Files") { + Toggle("Download Files", isOn: self.$account.access.bind(.canDownloadFiles)) + .disabled(self.model.access?.contains(.canDownloadFiles) == false) + Toggle("Download Folders", isOn: self.$account.access.bind(.canDownloadFolders)) + .disabled(model.access?.contains(.canDownloadFolders) == false) + Toggle("Upload Files", isOn: self.$account.access.bind(.canUploadFiles)) + .disabled(model.access?.contains(.canUploadFiles) == false) + Toggle("Upload Folders", isOn: self.$account.access.bind(.canUploadFolders)) + .disabled(model.access?.contains(.canUploadFolders) == false) + Toggle("Upload Anywhere", isOn: self.$account.access.bind(.canUploadAnywhere)) + .disabled(model.access?.contains(.canUploadAnywhere) == false) + Toggle("Delete Files", isOn: self.$account.access.bind(.canDeleteFiles)) + .disabled(model.access?.contains(.canDeleteFiles) == false) + Toggle("Rename Files", isOn: self.$account.access.bind(.canRenameFiles)) + .disabled(model.access?.contains(.canRenameFiles) == false) + Toggle("Move Files", isOn: self.$account.access.bind(.canMoveFiles)) + .disabled(model.access?.contains(.canMoveFiles) == false) + Toggle("Comment Files", isOn: self.$account.access.bind(.canSetFileComment)) + .disabled(model.access?.contains(.canSetFileComment) == false) + Toggle("Create Folders", isOn: self.$account.access.bind(.canCreateFolders)) + .disabled(model.access?.contains(.canCreateFolders) == false) + Toggle("Delete Folders", isOn: self.$account.access.bind(.canDeleteFolders)) + .disabled(model.access?.contains(.canDeleteFolders) == false) + Toggle("Rename Folders", isOn: self.$account.access.bind(.canRenameFolders)) + .disabled(model.access?.contains(.canRenameFolders) == false) + Toggle("Move Folders", isOn: self.$account.access.bind(.canMoveFolders)) + .disabled(model.access?.contains(.canMoveFolders) == false) + Toggle("Comment Folders", isOn: self.$account.access.bind(.canSetFolderComment)) + .disabled(model.access?.contains(.canSetFolderComment) == false) + Toggle("View Drop Boxes", isOn: self.$account.access.bind(.canViewDropBoxes)) + .disabled(model.access?.contains(.canViewDropBoxes) == false) + Toggle("Make Aliases", isOn: self.$account.access.bind(.canMakeAliases)) + .disabled(model.access?.contains(.canMakeAliases) == false) + } + + Section("User Maintenance") { + Toggle("Create Accounts", isOn: self.$account.access.bind(.canCreateUsers)) + .disabled(model.access?.contains(.canCreateUsers) == false) + Toggle("Delete Accounts", isOn: self.$account.access.bind(.canDeleteUsers)) + .disabled(model.access?.contains(.canDeleteUsers) == false) + Toggle("Read Accounts", isOn: self.$account.access.bind(.canOpenUsers)) + .disabled(model.access?.contains(.canOpenUsers) == false) + Toggle("Modify Accounts", isOn: self.$account.access.bind(.canModifyUsers)) + .disabled(model.access?.contains(.canModifyUsers) == false) + Toggle("Get User Info", isOn: self.$account.access.bind(.canGetClientInfo)) + .disabled(model.access?.contains(.canGetClientInfo) == false) + + Toggle("Disconnect Users", isOn: self.$account.access.bind(.canDisconnectUsers)) + .disabled(model.access?.contains(.canDisconnectUsers) == false) + Toggle("Cannot be Disconnected", isOn: self.$account.access.bind(.cantBeDisconnected)) + .disabled(model.access?.contains(.cantBeDisconnected) == false) + } + + Section("Messaging") { + Toggle("Send Messages", isOn: self.$account.access.bind(.canSendMessages)) + .disabled(model.access?.contains(.canSendMessages) == false) + Toggle("Broadcast", isOn: self.$account.access.bind(.canBroadcast)) + .disabled(model.access?.contains(.canBroadcast) == false) + } + + Section("News") { + Toggle("Read Articles", isOn: self.$account.access.bind(.canReadMessageBoard)) + .disabled(model.access?.contains(.canReadMessageBoard) == false) + Toggle("Post Articles", isOn: self.$account.access.bind(.canPostMessageBoard)) + .disabled(model.access?.contains(.canPostMessageBoard) == false) + Toggle("Delete Articles", isOn: self.$account.access.bind(.canDeleteNewsArticles)) + .disabled(model.access?.contains(.canDeleteNewsArticles) == false) + Toggle("Create Categories", isOn: self.$account.access.bind(.canCreateNewsCategories)) + .disabled(model.access?.contains(.canCreateNewsCategories) == false) + Toggle("Delete Categories", isOn: self.$account.access.bind(.canDeleteNewsCategories)) + .disabled(model.access?.contains(.canDeleteNewsCategories) == false) + Toggle("Create News Bundles", isOn: self.$account.access.bind(.canCreateNewsFolders)) + .disabled(model.access?.contains(.canCreateNewsFolders) == false) + Toggle("Delete News Bundles", isOn: self.$account.access.bind(.canDeleteNewsFolders)) + .disabled(model.access?.contains(.canDeleteNewsFolders) == false) + } + + Section("Chat") { + Toggle("Initiate Private Chat", isOn: self.$account.access.bind(.canCreateChat)) + .disabled(model.access?.contains(.canCreateChat) == false) + Toggle("Read Chat", isOn: self.$account.access.bind(.canReadChat)) + .disabled(model.access?.contains(.canReadChat) == false) + Toggle("Send Chat", isOn: self.$account.access.bind(.canSendChat)) + .disabled(model.access?.contains(.canSendChat) == false) + } + + Section("Miscellaneous") { + Toggle("Use Any Name", isOn: self.$account.access.bind(.canUseAnyName)) + .disabled(model.access?.contains(.canUseAnyName) == false) + Toggle("Don't Show Agreement", isOn: self.$account.access.bind(.canSkipAgreement)) + .disabled(model.access?.contains(.canSkipAgreement) == false) + } } - - // If the access bits or user name have been changed - return pendingAccess.rawValue != selection.access.rawValue || selection.name != pendingName + .disabled(self.model.access?.contains(.canModifyUsers) == false) + .formStyle(.grouped) } } diff --git a/Hotline/macOS/Files/FileDetailsSheet.swift b/Hotline/macOS/Files/FileDetailsSheet.swift index d798fd4..2118518 100644 --- a/Hotline/macOS/Files/FileDetailsSheet.swift +++ b/Hotline/macOS/Files/FileDetailsSheet.swift @@ -13,8 +13,14 @@ struct FileDetailsSheet: View { var body: some View { VStack(alignment: .leading, spacing: 16) { HStack(alignment: .center, spacing: 16){ - FileIconView(filename: fd.name, fileType: nil) - .frame(width: 32, height: 32) + if self.fd.type == "Folder" { + FolderIconView() + .frame(width: 32, height: 32) + } + else { + FileIconView(filename: fd.name, fileType: nil) + .frame(width: 32, height: 32) + } TextField("", text: $filename) .disabled(!self.canRename()) } diff --git a/Hotline/macOS/Files/FilesView.swift b/Hotline/macOS/Files/FilesView.swift index a9b0b83..2386d2a 100644 --- a/Hotline/macOS/Files/FilesView.swift +++ b/Hotline/macOS/Files/FilesView.swift @@ -12,8 +12,8 @@ struct FilesView: View { @State private var searchText: String = "" @State private var isSearching: Bool = false @State private var dragOver: Bool = false - @State private var deleteConfirmationDisplayed: Bool = false - @State private var newFolderSheetDisplayed: Bool = false + @State private var confirmDeleteShown: Bool = false + @State private var newFolderShown: Bool = false var body: some View { NavigationStack { @@ -96,7 +96,7 @@ struct FilesView: View { Divider() Button { - self.deleteConfirmationDisplayed = true + self.confirmDeleteShown = true } label: { Label("Delete...", systemImage: "trash") } @@ -200,16 +200,21 @@ struct FilesView: View { ToolbarItem { Button { - self.newFolderSheetDisplayed = true + self.newFolderShown = true } label: { Label("New Folder", systemImage: "folder.badge.plus") } .help("New Folder") + .popover(isPresented: self.$newFolderShown, arrowEdge: .bottom) { + NewFolderPopover { folderName in + self.newFolder(name: folderName, parent: self.selection) + } + } } ToolbarItem { Button { - self.deleteConfirmationDisplayed = true + self.confirmDeleteShown = true } label: { Label("Delete", systemImage: "trash") } @@ -218,7 +223,7 @@ struct FilesView: View { } } } - .alert("Are you sure you want to permanently delete \"\(self.selection?.name ?? "this file")\"?", isPresented: self.$deleteConfirmationDisplayed, actions: { + .alert("Are you sure you want to permanently delete \"\(self.selection?.name ?? "this file")\"?", isPresented: self.$confirmDeleteShown, actions: { Button("Delete", role: .destructive) { if let s = self.selection { Task { @@ -229,11 +234,6 @@ struct FilesView: View { }, message: { Text("You cannot undo this action.") }) - .sheet(isPresented: self.$newFolderSheetDisplayed) { - NewFolderSheet { folderName in - self.newFolder(name: folderName, parent: self.selection) - } - } .sheet(item: self.$fileDetails) { item in FileDetailsSheet(fd: item) } @@ -441,9 +441,7 @@ struct FilesView: View { @MainActor private func getFileInfo(_ file: FileInfo) { Task { if let fileInfo = try? await model.getFileDetails(file.name, path: file.path) { - Task { @MainActor in - self.fileDetails = fileInfo - } + self.fileDetails = fileInfo } } } diff --git a/Hotline/macOS/Files/NewFolderPopover.swift b/Hotline/macOS/Files/NewFolderPopover.swift new file mode 100644 index 0000000..4e282f5 --- /dev/null +++ b/Hotline/macOS/Files/NewFolderPopover.swift @@ -0,0 +1,51 @@ +import SwiftUI + +struct NewFolderPopover: View { + @Environment(\.dismiss) private var dismiss + + let action: ((String) -> Void)? + + @State private var folderName: String = "Untitled Folder" + + var body: some View { + VStack(spacing: 16) { + TextField("Folder Name", text: self.$folderName) + .onSubmit(of: .text) { + self.createFolder() + } + + HStack(spacing: 8) { + Spacer() + + Button("Cancel", role: .cancel) { + self.dismiss() + } + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + + if #available(macOS 26.0, *) { + Button("New Folder", role: .confirm) { + self.createFolder() + } + .buttonStyle(.borderedProminent) + .buttonBorderShape(.capsule) + } + else { + Button("OK") { + self.dismiss() + self.action?(self.folderName) + } + .buttonStyle(.borderedProminent) + .buttonBorderShape(.capsule) + } + } + } + .frame(width: 250) + .padding() + } + + private func createFolder() { + self.dismiss() + self.action?(self.folderName) + } +} diff --git a/Hotline/macOS/Files/NewFolderSheet.swift b/Hotline/macOS/Files/NewFolderSheet.swift deleted file mode 100644 index a899f36..0000000 --- a/Hotline/macOS/Files/NewFolderSheet.swift +++ /dev/null @@ -1,32 +0,0 @@ -import SwiftUI - -struct NewFolderSheet: View { - @Environment(\.dismiss) private var dismiss - - let action: ((String) -> Void)? - - @State private var folderName: String = "Untitled" - - var body: some View { - Form { - TextField(text: self.$folderName) { - Text("Folder Name") - } - } - .formStyle(.grouped) - .fixedSize(horizontal: false, vertical: true) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("New Folder") { - self.dismiss() - self.action?(self.folderName) - } - } - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - self.dismiss() - } - } - } - } -} diff --git a/Hotline/macOS/HotlinePanelView.swift b/Hotline/macOS/HotlinePanelView.swift index a56cd44..6443366 100644 --- a/Hotline/macOS/HotlinePanelView.swift +++ b/Hotline/macOS/HotlinePanelView.swift @@ -114,7 +114,8 @@ struct HotlinePanelView: View { if self.activeHotline?.access?.contains(.canOpenUsers) == true { Button { - self.activeServerState?.selection = .accounts +// self.activeServerState?.selection = .accounts + self.activeServerState?.accountsShown = true } label: { Image("Section Users") @@ -124,7 +125,7 @@ struct HotlinePanelView: View { .buttonStyle(.plain) .frame(width: 20, height: 20) .disabled(self.activeServerState == nil) - .help("Administration") + .help("Manage Server") } Button { diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index 399deba..2fddee6 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -87,13 +87,14 @@ struct ServerView: View { @State private var connectLogin: String = "" @State private var connectPassword: String = "" @State private var connectionDisplayed: Bool = false +// @State private var accountsShown: Bool = false static var menuItems: [ServerMenuItem] = [ ServerMenuItem(type: .chat, name: "Chat", image: "Section Chat"), ServerMenuItem(type: .board, name: "Board", image: "Section Board"), ServerMenuItem(type: .news, name: "News", image: "Section News"), ServerMenuItem(type: .files, name: "Files", image: "Section Files"), - ServerMenuItem(type: .accounts, name: "Accounts", image: "Section Users"), +// ServerMenuItem(type: .accounts, name: "Accounts", image: "Section Users"), ] static var classicMenuItems: [ServerMenuItem] = [ @@ -110,7 +111,7 @@ struct ServerView: View { self.connectForm Spacer() } - .navigationTitle("Connect to Server") + .navigationTitle("New Connection") } else if self.model.status.isLoggingIn { HStack { @@ -130,7 +131,7 @@ struct ServerView: View { } .frame(maxWidth: 300) .padding() - .navigationTitle("Connecting to Server") + .navigationTitle("New Connection") } else if self.model.status == .loggedIn { self.serverView @@ -153,6 +154,12 @@ struct ServerView: View { .onChange(of: Prefs.shared.automaticMessage) { Task { try? await self.model.sendUserPreferences() } } + .sheet(isPresented: self.$state.accountsShown) { + AccountManagerView() + .environment(self.model) + .frame(width: 400, height: 450) + .presentationSizing(.fitted) + } .toolbar { if #available(macOS 26.0, *) { ToolbarItem(placement: .navigation) { @@ -232,11 +239,11 @@ struct ServerView: View { if menuItem.type == .chat { ListItemView(icon: menuItem.image, title: menuItem.name, unread: model.unreadPublicChat).tag(menuItem.type) } - else if menuItem.type == .accounts { - if model.access?.contains(.canOpenUsers) == true { - ListItemView(icon: menuItem.image, title: menuItem.name, unread: false).tag(menuItem.type) - } - } +// else if menuItem.type == .accounts { +// if model.access?.contains(.canOpenUsers) == true { +// ListItemView(icon: menuItem.image, title: menuItem.name, unread: false).tag(menuItem.type) +// } +// } else if menuItem.type == .files { ListItemView(icon: menuItem.image, title: menuItem.name, unread: false).tag(menuItem.type) .overlay(alignment: .trailing) { @@ -320,39 +327,51 @@ struct ServerView: View { NavigationSplitView { self.navigationList .frame(maxWidth: .infinity) - .navigationSplitViewColumnWidth(min: 150, ideal: 200, max: 500) + .navigationSplitViewColumnWidth(min: 200, ideal: 250, max: 500) + .toolbar { + if self.model.access?.contains(.canOpenUsers) == true { + ToolbarItem { + Button { + self.state.accountsShown = true + } label: { + Label("Manage Server", systemImage: "gear") + } + .help("Manage Server") + } + } + } } detail: { switch state.selection { case .chat: ChatView() .navigationTitle(model.serverTitle) - .navigationSubtitle("Public Chat") +// .navigationSubtitle("Public Chat") .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .news: NewsView() .navigationTitle(model.serverTitle) - .navigationSubtitle("Newsgroups") +// .navigationSubtitle("Newsgroups") .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .board: MessageBoardView() .navigationTitle(model.serverTitle) - .navigationSubtitle("Message Board") +// .navigationSubtitle("Message Board") .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .files: FilesView() .navigationTitle(model.serverTitle) - .navigationSubtitle("Shared Files") +// .navigationSubtitle("Shared Files") .navigationSplitViewColumnWidth(min: 250, ideal: 500) - case .accounts: - AccountManagerView() - .navigationTitle(model.serverTitle) - .navigationSubtitle("Accounts") - .navigationSplitViewColumnWidth(min: 250, ideal: 500) +// case .accounts: +// AccountManagerView() +// .navigationTitle(model.serverTitle) +//// .navigationSubtitle("Accounts") +// .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .user(let userID): - let user = model.users.first(where: { $0.id == userID }) +// let user = model.users.first(where: { $0.id == userID }) MessageView(userID: userID) .navigationTitle(model.serverTitle) - .navigationSubtitle(user?.name ?? "Private Message") +// .navigationSubtitle(user?.name ?? "Private Message") .navigationSplitViewColumnWidth(min: 250, ideal: 500) .onAppear { model.markInstantMessagesAsRead(userID: userID) diff --git a/Hotline/macOS/TransfersView.swift b/Hotline/macOS/TransfersView.swift index 9bef02c..1fefe5a 100644 --- a/Hotline/macOS/TransfersView.swift +++ b/Hotline/macOS/TransfersView.swift @@ -163,7 +163,7 @@ struct TransfersView: View { let result: [(name: String, url: URL)] = intersection.compactMap { url in let appName = FileManager.default .displayName(atPath: url.path) - .replacingOccurrences(of: ".app", with: "") + .replacing(".app", with: "") return (name: appName, url: url) }.sorted { $0.name < $1.name } @@ -178,7 +178,7 @@ struct TransfersView: View { if fileURLs.count == 1, let url = NSWorkspace.shared.urlForApplication(toOpen: fileURLs[0]) { let name = FileManager.default .displayName(atPath: url.path) - .replacingOccurrences(of: ".app", with: "") + .replacing(".app", with: "") return (name, url) } @@ -213,7 +213,7 @@ struct TransfersView: View { defaultCounts[bestByMajority, default: 0] > 0 { let name = FileManager.default .displayName(atPath: bestByMajority.path) - .replacingOccurrences(of: ".app", with: "") + .replacing(".app", with: "") return (name, bestByMajority) } -- cgit From 467b53f143a0c3d7328fe85e9d1215eceb9b150a Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Thu, 13 Nov 2025 11:13:01 -0800 Subject: Start surfacing server errors properly to communicate permissions better. --- Hotline/Hotline/HotlineClient.swift | 10 +++------- Hotline/State/HotlineState.swift | 32 +++++++++++++++++++++++--------- Hotline/macOS/Files/FilesView.swift | 16 ++++++++++------ Hotline/macOS/Files/FolderItemView.swift | 4 ++-- Hotline/macOS/ServerView.swift | 7 ++++++- 5 files changed, 44 insertions(+), 25 deletions(-) (limited to 'Hotline/macOS/Files/FilesView.swift') diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index 8d0e870..7305a2f 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -429,6 +429,7 @@ public actor HotlineClient { } catch is TaskTimeoutError { throw HotlineClientError.timeout } catch let error as HotlineClientError { + print("Hotline Client Error: \(error)") throw error } catch { throw error @@ -651,17 +652,12 @@ public actor HotlineClient { /// - name: File or folder name /// - path: Directory path containing the item /// - Returns: True if deletion succeeded - public func deleteFile(name: String, path: [String]) async throws -> Bool { + public func deleteFile(name: String, path: [String]) async throws { var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .deleteFile) transaction.setFieldString(type: .fileName, val: name) transaction.setFieldPath(type: .filePath, val: path) - do { - try await self.sendTransaction(transaction) - return true - } catch { - return false - } + try await self.sendTransaction(transaction) } /// Create a folder diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index 627b6a0..1f8e2e5 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -799,7 +799,7 @@ class HotlineState: Equatable { self.users = hotlineUsers.map { User(hotlineUser: $0) } } - // MARK: - Files (Basic) + // MARK: - Files @MainActor @discardableResult @@ -856,7 +856,7 @@ class HotlineState: Equatable { } @MainActor - func deleteFile(_ fileName: String, path: [String]) async throws -> Bool { + func deleteFile(_ fileName: String, path: [String]) async throws { guard let client = self.client else { throw HotlineClientError.notConnected } @@ -865,14 +865,16 @@ class HotlineState: Equatable { if path.count > 1 { fullPath = Array(path[0.. 1 { parentPath = Array(file.path[0.. Date: Thu, 13 Nov 2025 11:33:43 -0800 Subject: Don't show accounts in sidebar to reduce clutter. Admins can use menu bar or toolbar. More error handling. --- Hotline/Hotline/HotlineClient.swift | 10 ++-------- Hotline/State/HotlineState.swift | 33 ++++++++++++++++++++++++++++----- Hotline/macOS/Files/FilesView.swift | 2 +- Hotline/macOS/ServerView.swift | 29 ++++++++++++++++------------- 4 files changed, 47 insertions(+), 27 deletions(-) (limited to 'Hotline/macOS/Files/FilesView.swift') diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index 7305a2f..6dbb460 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -666,17 +666,11 @@ public actor HotlineClient { /// - name: New folder name /// - path: Directory path for the new folder /// - Returns: True if creation succeeded - public func newFolder(name: String, path: [String]) async throws -> Bool { + public func newFolder(name: String, path: [String]) async throws { var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .newFolder) transaction.setFieldString(type: .fileName, val: name) transaction.setFieldPath(type: .filePath, val: path) - - do { - try await self.sendTransaction(transaction) - return true - } catch { - return false - } + try await self.sendTransaction(transaction) } // MARK: - News diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index 1f8e2e5..c9810ad 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -852,11 +852,22 @@ class HotlineState: Equatable { throw HotlineClientError.notConnected } - return try await client.newFolder(name: name, path: parentPath) + do { + try await client.newFolder(name: name, path: parentPath) + self.invalidateFileListCache(for: parentPath, includingAncestors: true) + return true + } + catch let error as HotlineClientError { + self.errorMessage = error.userMessage + self.errorDisplayed = true + } + + return false } + @discardableResult @MainActor - func deleteFile(_ fileName: String, path: [String]) async throws { + func deleteFile(_ fileName: String, path: [String]) async throws -> Bool { guard let client = self.client else { throw HotlineClientError.notConnected } @@ -869,12 +880,14 @@ class HotlineState: Equatable { do { try await client.deleteFile(name: fileName, path: fullPath) self.invalidateFileListCache(for: fullPath, includingAncestors: true) + return true } catch let error as HotlineClientError { self.errorMessage = error.userMessage self.errorDisplayed = true - return } + + return false } /// Download a file from the server. @@ -896,9 +909,19 @@ class HotlineState: Equatable { Task { @MainActor [weak self] in guard let self else { return } - + // Request download from server - guard let result = try? await client.downloadFile(name: fileName, path: fullPath), + let result: (referenceNumber: UInt32, transferSize: Int, fileSize: Int, waitingCount: Int)? + do { + result = try await client.downloadFile(name: fileName, path: fullPath) + } + catch let error as HotlineClientError { + self.errorMessage = error.userMessage + self.errorDisplayed = true + return + } + + guard let result, let server = self.server, let address = server.address as String?, let port = server.port as Int? diff --git a/Hotline/macOS/Files/FilesView.swift b/Hotline/macOS/Files/FilesView.swift index ab93cbc..325af8c 100644 --- a/Hotline/macOS/Files/FilesView.swift +++ b/Hotline/macOS/Files/FilesView.swift @@ -432,7 +432,7 @@ struct FilesView: View { } let path: [String] = parentFolder?.path ?? [] - if try await self.model.newFolder(name: name, parentPath: path) == true { + if try await self.model.newFolder(name: name, parentPath: path) { try await self.model.getFileList(path: path) } } diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index 1f85bd7..b8297fd 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -338,18 +338,19 @@ struct ServerView: View { self.navigationList .frame(maxWidth: .infinity) .navigationSplitViewColumnWidth(min: 200, ideal: 250, max: 500) - .toolbar { - if self.model.access?.contains(.canOpenUsers) == true { - ToolbarItem { - Button { - self.state.accountsShown = true - } label: { - Label("Manage Accounts", systemImage: "gear") - } - .help("Manage Accounts") - } - } - } + .toolbar(removing: .sidebarToggle) +// .toolbar { +// if self.model.access?.contains(.canOpenUsers) == true { +// ToolbarItem(placement: .primaryAction) { +// Button { +// self.state.accountsShown = true +// } label: { +// Label("Manage Accounts", systemImage: "gear") +// } +// .help("Manage Accounts") +// } +// } +// } } detail: { switch state.selection { case .chat: @@ -388,7 +389,9 @@ struct ServerView: View { } } } - .toolbar(removing: .sidebarToggle) + .background { + Color.red + } } // MARK: - -- cgit From 8d4cdce6ec00db6b329a3b9ae71cfc37cd642e9b Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Thu, 13 Nov 2025 20:15:46 -0800 Subject: You can now save file details (rename files/folders and comments) --- Hotline/Hotline/HotlineClient.swift | 23 ++++++++ Hotline/State/HotlineState.swift | 30 ++++++---- Hotline/macOS/Files/FileDetailsSheet.swift | 90 ++++++++++++++++++------------ Hotline/macOS/Files/FilesView.swift | 2 +- Hotline/macOS/ServerView.swift | 16 +++--- 5 files changed, 107 insertions(+), 54 deletions(-) (limited to 'Hotline/macOS/Files/FilesView.swift') diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index 6dbb460..98cf222 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -645,6 +645,29 @@ public actor HotlineClient { modified: fileModifyDate ) } + + /// Set a file's information (name/comment) + /// + /// - Parameters: + /// - name: File name + /// - path: Directory path containing the file + /// - newName: Name to set the file to + /// - comment: Comment to set on the file + public func setFileInfo(name: String, path: [String], newName: String? = nil, comment: String? = nil, encoding: String.Encoding = .utf8) async throws { + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .setFileInfo) + transaction.setFieldString(type: .fileName, val: name) + transaction.setFieldPath(type: .filePath, val: path) + + if let newName { + transaction.setFieldString(type: .fileNewName, val: newName) + } + + if let comment { + transaction.setFieldString(type: .fileComment, val: comment) + } + + try await sendTransaction(transaction) + } /// Delete a file or folder /// diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index c9810ad..c632f91 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -789,7 +789,6 @@ class HotlineState: Equatable { // MARK: - Users - @MainActor func getUserList() async throws { guard let client = self.client else { throw HotlineClientError.notConnected @@ -801,7 +800,6 @@ class HotlineState: Equatable { // MARK: - Files - @MainActor @discardableResult func getFileList(path: [String] = [], suppressErrors: Bool = false, preferCache: Bool = false) async throws -> [FileInfo] { guard let client = self.client else { @@ -832,7 +830,6 @@ class HotlineState: Equatable { return newFiles } - @MainActor func getFileDetails(_ fileName: String, path: [String]) async throws -> FileDetails? { guard let client = self.client else { throw HotlineClientError.notConnected @@ -846,7 +843,26 @@ class HotlineState: Equatable { return try await client.getFileInfo(name: fileName, path: fullPath) } - @MainActor + @discardableResult + func setFileInfo(fileName: String, path filePath: [String], fileNewName: String?, comment: String?) async throws -> Bool { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + do { + try await client.setFileInfo(name: fileName, path: filePath, newName: fileNewName, comment: comment) + self.invalidateFileListCache(for: filePath, includingAncestors: true) + return true + } + catch let error as HotlineClientError { + self.errorMessage = error.userMessage + self.errorDisplayed = true + } + + return false + } + + @discardableResult func newFolder(name: String, parentPath: [String]) async throws -> Bool { guard let client = self.client else { throw HotlineClientError.notConnected @@ -1404,12 +1420,6 @@ class HotlineState: Equatable { } } - func setFileInfo(fileName: String, path filePath: [String], fileNewName: String?, comment: String?, encoding: String.Encoding = .utf8) { - // TODO: Implement setFileInfo in HotlineClient - // This method updates file metadata (name and/or comment) - print("setFileInfo not yet implemented in HotlineState/HotlineClient") - } - @MainActor func previewFile(_ fileName: String, path: [String], complete callback: ((PreviewFileInfo?) -> Void)? = nil) { guard let client = self.client else { diff --git a/Hotline/macOS/Files/FileDetailsSheet.swift b/Hotline/macOS/Files/FileDetailsSheet.swift index 2118518..e86bf28 100644 --- a/Hotline/macOS/Files/FileDetailsSheet.swift +++ b/Hotline/macOS/Files/FileDetailsSheet.swift @@ -3,34 +3,35 @@ import SwiftUI struct FileDetailsSheet: View { @Environment(HotlineState.self) private var model: HotlineState - @Environment(\.presentationMode) var presentationMode + @Environment(\.dismiss) private var dismiss - var fd: FileDetails + var details: FileDetails + @State private var saving: Bool = false @State private var comment: String = "" @State private var filename: String = "" var body: some View { VStack(alignment: .leading, spacing: 16) { HStack(alignment: .center, spacing: 16){ - if self.fd.type == "Folder" { + if self.details.type == "Folder" { FolderIconView() .frame(width: 32, height: 32) } else { - FileIconView(filename: fd.name, fileType: nil) + FileIconView(filename: self.details.name, fileType: nil) .frame(width: 32, height: 32) } - TextField("", text: $filename) - .disabled(!self.canRename()) + TextField("File Name", text: $filename) + .disabled(!self.canRename) } let rows: [(String, String)] = [ - ("Type", fd.type), - ("Creator", fd.creator), - ("Size", self.formattedSize(byteCount: fd.size)), - ("Created", Self.dateFormatter.string(from: fd.created)), - ("Modified", Self.dateFormatter.string(from: fd.modified)) + ("Type", self.details.type), + ("Creator", self.details.creator), + ("Size", self.formattedSize(byteCount: self.details.size)), + ("Created", Self.dateFormatter.string(from: self.details.created)), + ("Modified", Self.dateFormatter.string(from: self.details.modified)) ] Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { @@ -54,46 +55,65 @@ struct FileDetailsSheet: View { .font(.body) .lineLimit(10, reservesSpace: true) .padding(.leading, 32 + 16) - .disabled(!self.canSetComment()) + .disabled(!self.canSetComment) } .padding(.vertical, 24) .padding(.horizontal, 24) .frame(width: 400) .toolbar { + if self.saving { + ToolbarItem { + ProgressView() + .controlSize(.small) + } + } + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { - presentationMode.wrappedValue.dismiss() + self.dismiss() } } ToolbarItem(placement: .primaryAction) { Button{ var editedFilename: String? - if filename != fd.name { - editedFilename = filename + if self.filename != self.details.name { + editedFilename = self.filename } var editedComment: String? - if comment != fd.comment { - editedComment = comment + if self.comment != self.details.comment { + editedComment = self.comment } - model.setFileInfo(fileName: fd.name, path: fd.path, fileNewName: editedFilename, comment: editedComment) - presentationMode.wrappedValue.dismiss() - - // TODO: Update the file list if the filename was changed + Task { + self.saving = true + defer { self.saving = false } + + if editedComment != nil || editedFilename != nil { + if try await self.model.setFileInfo(fileName: self.details.name, path: self.details.path, fileNewName: editedFilename, comment: editedComment) { + try await self.model.getFileList(path: self.details.path) + } + } + + // We dismiss even if there is an error for now + // This is not ideal as we may lose a user's written comment + // or new file name, but SwiftUI doesn't show the current + // alert above this sheet so we'll need a different way of + // handling errors to make this work. Until then... + self.dismiss() + } } label: { Text("Save") - }.disabled(!isEdited()) + } } } .onAppear { - self.filename = fd.name - self.comment = fd.comment + self.filename = self.details.name + self.comment = self.details.comment } } - static var dateFormatter: DateFormatter = { var dateFormatter = DateFormatter() dateFormatter.dateStyle = .long @@ -124,24 +144,24 @@ struct FileDetailsSheet: View { } private func isEdited() -> Bool { - return self.filename != fd.name || self.comment != fd.comment + return self.filename != self.details.name || self.comment != self.details.comment } - private func canRename() -> Bool { - if self.fd.type == "fldr" { - return model.access?.contains(.canRenameFolders) == true + private var canRename: Bool { + if self.details.type == "fldr" || self.details.type == "Folder" { + return self.model.access?.contains(.canRenameFolders) == true } - return model.access?.contains(.canRenameFiles) == true + return self.model.access?.contains(.canRenameFiles) == true } - private func canSetComment() -> Bool { - if self.fd.type == "fldr" { - return model.access?.contains(.canSetFolderComment) == true + private var canSetComment: Bool { + if self.details.type == "fldr" || self.details.type == "Folder" { + return self.model.access?.contains(.canSetFolderComment) == true } - return model.access?.contains(.canSetFileComment) == true + return self.model.access?.contains(.canSetFileComment) == true } } //#Preview { -// FileDetailsView(fd: FileDetails(name: "AppleWorks 6.sit", path: [""], size: 24601664, comment: "test comment", type: "SITD", creator: "SIT!", created: Date.now, modified: Date.now )) +// FileDetailsView(details: FileDetails(name: "AppleWorks 6.sit", path: [""], size: 24601664, comment: "test comment", type: "SITD", creator: "SIT!", created: Date.now, modified: Date.now )) //} diff --git a/Hotline/macOS/Files/FilesView.swift b/Hotline/macOS/Files/FilesView.swift index 325af8c..1ae99d9 100644 --- a/Hotline/macOS/Files/FilesView.swift +++ b/Hotline/macOS/Files/FilesView.swift @@ -235,7 +235,7 @@ struct FilesView: View { Text("You cannot undo this action.") }) .sheet(item: self.$fileDetails) { item in - FileDetailsSheet(fd: item) + FileDetailsSheet(details: item) } .fileImporter(isPresented: $uploadFileSelectorDisplayed, allowedContentTypes: [.data, .folder], allowsMultipleSelection: false, onCompletion: { results in switch results { diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index b8297fd..3794018 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -196,14 +196,6 @@ struct ServerView: View { .onChange(of: self.model.serverTitle) { self.state.serverName = self.model.serverTitle } - .alert("Something Went Wrong", isPresented: self.$model.errorDisplayed) { - Button("OK") {} - } message: { - if let message = self.model.errorMessage, - !message.isBlank { - Text(message) - } - } .onAppear { var address = self.server.address if self.server.port != HotlinePorts.DefaultServerPort { @@ -220,6 +212,14 @@ struct ServerView: View { self.connectionDisplayed = true } + .alert("Something Went Wrong", isPresented: self.$model.errorDisplayed) { + Button("OK") {} + } message: { + if let message = self.model.errorMessage, + !message.isBlank { + Text(message) + } + } .focusedSceneValue(\.activeHotlineModel, model) .focusedSceneValue(\.activeServerState, state) } -- cgit From da1d7001f5132115bfdbe19cd95e73b04ba76c95 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 14 Nov 2025 09:56:46 -0800 Subject: Add kick and improve client info display. Also improve error messages when can't connect (server down) or can't login (show server message). --- Hotline.xcodeproj/project.pbxproj | 16 ++++---- Hotline/Hotline/HotlineClient.swift | 27 +++++++++++- Hotline/Hotline/HotlineProtocol.swift | 5 +++ Hotline/MacApp.swift | 6 +-- Hotline/State/HotlineState.swift | 49 +++++++++++++++++++--- Hotline/macOS/BroadcastMessageSheet.swift | 66 ++++++++++++++++++++++++++++++ Hotline/macOS/BroadcastMessageView.swift | 66 ------------------------------ Hotline/macOS/Files/FileDetailsSheet.swift | 43 ++++++++++--------- Hotline/macOS/Files/FilesView.swift | 38 +++++++++-------- Hotline/macOS/MessageView.swift | 36 ++++++++++++++-- Hotline/macOS/ServerView.swift | 23 +++++------ Hotline/macOS/UserClientInfoSheet.swift | 25 +++++++++++ Hotline/macOS/UserInfo.swift | 25 ----------- 13 files changed, 264 insertions(+), 161 deletions(-) create mode 100644 Hotline/macOS/BroadcastMessageSheet.swift delete mode 100644 Hotline/macOS/BroadcastMessageView.swift create mode 100644 Hotline/macOS/UserClientInfoSheet.swift delete mode 100644 Hotline/macOS/UserInfo.swift (limited to 'Hotline/macOS/Files/FilesView.swift') diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index 2b3c8e9..6ff1f66 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -89,7 +89,7 @@ DAB4D8842B4CABEF0048A05C /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAB4D8832B4CABEF0048A05C /* Extensions.swift */; }; DAB4D8872B4CB3610048A05C /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = DAB4D8862B4CB3610048A05C /* MarkdownUI */; }; DABAFBEA2EC58C170015E889 /* AccountDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABAFBE92EC58C170015E889 /* AccountDetailsView.swift */; }; - DABAFBEC2EC599700015E889 /* BroadcastMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABAFBEB2EC5996B0015E889 /* BroadcastMessageView.swift */; }; + DABAFBEC2EC599700015E889 /* BroadcastMessageSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABAFBEB2EC5996B0015E889 /* BroadcastMessageSheet.swift */; }; DABE8BF42B55DC0A00884D28 /* transfer-complete.aiff in Resources */ = {isa = PBXBuildFile; fileRef = DABE8BF32B55DC0A00884D28 /* transfer-complete.aiff */; }; DABE8BF62B55DC2E00884D28 /* chat-message.aiff in Resources */ = {isa = PBXBuildFile; fileRef = DABE8BF52B55DC2E00884D28 /* chat-message.aiff */; }; DABE8BF82B55DC6100884D28 /* logged-in.aiff in Resources */ = {isa = PBXBuildFile; fileRef = DABE8BF72B55DC6100884D28 /* logged-in.aiff */; }; @@ -99,7 +99,7 @@ DABE8C002B55E69800884D28 /* server-message.aiff in Resources */ = {isa = PBXBuildFile; fileRef = DABE8BFF2B55E69800884D28 /* server-message.aiff */; }; DABE8C022B55E69D00884D28 /* new-news.aiff in Resources */ = {isa = PBXBuildFile; fileRef = DABE8C012B55E69D00884D28 /* new-news.aiff */; }; DABE8C042B57940B00884D28 /* DAKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABE8C032B57940A00884D28 /* DAKeychain.swift */; }; - DAC0E4232EC78E1100E999DB /* UserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC0E4222EC78E1100E999DB /* UserInfo.swift */; }; + DAC0E4232EC78E1100E999DB /* UserClientInfoSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC0E4222EC78E1100E999DB /* UserClientInfoSheet.swift */; }; DAC3D9832BC33FD000A727C9 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC3D9822BC33FD000A727C9 /* AppState.swift */; }; DAC6B2E02EAC6236004E2CBA /* ChatStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC6B2DE2EAC6236004E2CBA /* ChatStore.swift */; }; DAC6B2E22EAEE9FE004E2CBA /* NetSocket.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC6B2E12EAEE9EF004E2CBA /* NetSocket.swift */; }; @@ -205,7 +205,7 @@ DAB4D8812B4C8FED0048A05C /* FileIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileIconView.swift; sourceTree = ""; }; DAB4D8832B4CABEF0048A05C /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = ""; }; DABAFBE92EC58C170015E889 /* AccountDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDetailsView.swift; sourceTree = ""; }; - DABAFBEB2EC5996B0015E889 /* BroadcastMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BroadcastMessageView.swift; sourceTree = ""; }; + DABAFBEB2EC5996B0015E889 /* BroadcastMessageSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BroadcastMessageSheet.swift; sourceTree = ""; }; DABE8BF32B55DC0A00884D28 /* transfer-complete.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = "transfer-complete.aiff"; sourceTree = ""; }; DABE8BF52B55DC2E00884D28 /* chat-message.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = "chat-message.aiff"; sourceTree = ""; }; DABE8BF72B55DC6100884D28 /* logged-in.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = "logged-in.aiff"; sourceTree = ""; }; @@ -215,7 +215,7 @@ DABE8BFF2B55E69800884D28 /* server-message.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = "server-message.aiff"; sourceTree = ""; }; DABE8C012B55E69D00884D28 /* new-news.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = "new-news.aiff"; sourceTree = ""; }; DABE8C032B57940A00884D28 /* DAKeychain.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DAKeychain.swift; sourceTree = ""; }; - DAC0E4222EC78E1100E999DB /* UserInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserInfo.swift; sourceTree = ""; }; + DAC0E4222EC78E1100E999DB /* UserClientInfoSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserClientInfoSheet.swift; sourceTree = ""; }; DAC3D9822BC33FD000A727C9 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; DAC6B2DE2EAC6236004E2CBA /* ChatStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatStore.swift; sourceTree = ""; }; DAC6B2E12EAEE9EF004E2CBA /* NetSocket.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetSocket.swift; sourceTree = ""; }; @@ -534,15 +534,15 @@ DAE734F72B2E4126000C56F6 /* macOS */ = { isa = PBXGroup; children = ( - DAC0E4222EC78E1100E999DB /* UserInfo.swift */, DA55AC762BE589F700034857 /* AboutView.swift */, DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */, DAE136B92B9D1147007D8307 /* HotlinePanelView.swift */, DAE734F82B2E4185000C56F6 /* ServerView.swift */, DAF5BC6B2EC2727100551E4D /* ConnectView.swift */, DAE735022B30C0BB000C56F6 /* MessageView.swift */, + DAC0E4222EC78E1100E999DB /* UserClientInfoSheet.swift */, DA5268B42EB6840A00DCB941 /* TransfersView.swift */, - DABAFBEB2EC5996B0015E889 /* BroadcastMessageView.swift */, + DABAFBEB2EC5996B0015E889 /* BroadcastMessageSheet.swift */, DA501BE52EBE9520001714F8 /* Trackers */, DA5268A82EB081AF00DCB941 /* Chat */, DA5268A62EB0762300DCB941 /* Board */, @@ -736,12 +736,12 @@ DA501BE72EBE9542001714F8 /* TrackerBookmarkSheet.swift in Sources */, DAB4D87E2B4C8BCA0048A05C /* FilePreviewTextView.swift in Sources */, DA3429B72EBAB1750010784E /* QuickLookPreviewView.swift in Sources */, - DABAFBEC2EC599700015E889 /* BroadcastMessageView.swift in Sources */, + DABAFBEC2EC599700015E889 /* BroadcastMessageSheet.swift in Sources */, DA4F2C012B1A558E00D8ADDC /* ChatView.swift in Sources */, DA5268B32EB6806E00DCB941 /* HotlineFileDownloadClient.swift in Sources */, DACCE5E32EABE86A008CDD92 /* AppUpdateView.swift in Sources */, DAC6B2E02EAC6236004E2CBA /* ChatStore.swift in Sources */, - DAC0E4232EC78E1100E999DB /* UserInfo.swift in Sources */, + DAC0E4232EC78E1100E999DB /* UserClientInfoSheet.swift in Sources */, DA32CD492B2931640053B98B /* User.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index 1440b71..29b7827 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -181,7 +181,16 @@ public actor HotlineClient { // Connect socket print("HotlineClient.connect(): Connecting socket...") - let socket = try await NetSocket.connect(host: host, port: port) + let socket: NetSocket + do { + socket = try await NetSocket.connect(host: host, port: port) + } + catch let socketError as NetSocketError { + if case .failed(_) = socketError { + throw HotlineClientError.connectionFailed(socketError) + } + throw socketError + } print("HotlineClient.connect(): Socket connected") // Perform handshake @@ -590,6 +599,22 @@ public actor HotlineClient { try await socket.send(transaction, endian: .big) } + + /// Force a user to disconnect from the server + /// + /// - Parameters: + /// - userID: Target user ID + /// - options: If specified, temporarily or permanently ban the user + public func disconnectUser(userID: UInt16, options: HotlineUserDisconnectOptions? = nil) async throws { + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .disconnectUser) + + transaction.setFieldUInt16(type: .userID, val: userID) + if let options { + transaction.setFieldUInt16(type: .options, val: options.rawValue) + } + + try await self.sendTransaction(transaction) + } // MARK: - Agreement diff --git a/Hotline/Hotline/HotlineProtocol.swift b/Hotline/Hotline/HotlineProtocol.swift index 92fdfd2..a1a0f31 100644 --- a/Hotline/Hotline/HotlineProtocol.swift +++ b/Hotline/Hotline/HotlineProtocol.swift @@ -10,6 +10,11 @@ struct HotlinePorts { static let DefaultTrackerPort: Int = 5498 } +public enum HotlineUserDisconnectOptions: UInt16 { + case temporarilyBan = 1 + case permanentlyBan = 2 +} + public struct HotlineUserOptions: OptionSet, Sendable { public let rawValue: UInt16 diff --git a/Hotline/MacApp.swift b/Hotline/MacApp.swift index 7997a87..4f6c2af 100644 --- a/Hotline/MacApp.swift +++ b/Hotline/MacApp.swift @@ -185,13 +185,13 @@ struct Application: App { Server(name: nil, description: nil, address: "") } .modelContainer(self.modelContainer) - .defaultSize(width: 690, height: 760) + .defaultSize(width: 780, height: 640) .defaultPosition(.center) .onChange(of: activeServerState) { - AppState.shared.activeServerState = activeServerState + AppState.shared.activeServerState = self.activeServerState } .onChange(of: activeHotline) { - AppState.shared.activeHotline = activeHotline + AppState.shared.activeHotline = self.activeHotline } .commands { CommandGroup(replacing: .newItem) { diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index e78a780..7baa477 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -174,7 +174,7 @@ class HotlineState: Equatable { self.updateServerTitle() } } - var serverTitle: String = "Server" + var serverTitle: String = "" var username: String = "guest" var iconID: Int = 414 var access: HotlineUserAccessOptions? @@ -361,18 +361,41 @@ class HotlineState: Equatable { self.downloadBanner() } - } catch { + } + catch let clientError as HotlineClientError { + switch clientError { + case .connectionFailed(_): + self.displayError(clientError, message: "This server appears to be offline.") + case .loginFailed(let msg): + self.displayError(clientError, message: msg) + case .serverError(_, let msg): + self.displayError(clientError, message: msg) + default: + self.displayError(clientError) + } + if let client = self.client { + await client.disconnect() + self.client = nil + } + self.status = .disconnected + throw clientError + } + catch { print("HotlineState.login(): Login failed with error: \(error)") if let client = self.client { await client.disconnect() self.client = nil } - self.status = .disconnected // .failed(error.localizedDescription) - self.errorDisplayed = true - self.errorMessage = error.localizedDescription + self.status = .disconnected + self.displayError(error) throw error } } + + private func displayError(_ error: Error, message: String? = nil) { + self.errorDisplayed = true + self.errorMessage = message ?? error.localizedDescription + } /// Disconnect from the server (user-initiated) @MainActor @@ -813,6 +836,20 @@ class HotlineState: Equatable { return nil } + + func disconnectUser(id userID: UInt16, options: HotlineUserDisconnectOptions?) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + do { + try await client.disconnectUser(userID: userID, options: options) + } + catch let error as HotlineClientError { + self.errorMessage = error.userMessage + self.errorDisplayed = true + } + } // MARK: - Files @@ -1948,7 +1985,7 @@ class HotlineState: Equatable { // MARK: - Utilities func updateServerTitle() { - self.serverTitle = self.serverName ?? self.server?.name ?? self.server?.address ?? "Server" + self.serverTitle = self.serverName ?? self.server?.name ?? self.server?.address ?? "Hotline" } // News helpers diff --git a/Hotline/macOS/BroadcastMessageSheet.swift b/Hotline/macOS/BroadcastMessageSheet.swift new file mode 100644 index 0000000..b3bd7ea --- /dev/null +++ b/Hotline/macOS/BroadcastMessageSheet.swift @@ -0,0 +1,66 @@ +import SwiftUI + +fileprivate let CHARACTER_LIMIT: Int = 255 + +struct BroadcastMessageSheet: View { + @Environment(HotlineState.self) private var model: HotlineState + @Environment(\.dismiss) private var dismiss + + @State private var sending: Bool = false + + private var message: String { + self.model.broadcastMessage.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var body: some View { + @Bindable var model = self.model + + VStack { + TextField("Write a message...", text: $model.broadcastMessage, axis: .vertical) + .textFieldStyle(.plain) + .lineLimit(5, reservesSpace: true) + } + .padding(.leading, 32) + .padding(.top, 2) + .overlay(alignment: .topLeading) { + Image("Server Message") + } + .padding(16) + .frame(width: 400) + .toolbar { + if self.sending { + ToolbarItem { + ProgressView() + .controlSize(.small) + } + } + + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + self.dismiss() + } + } + + ToolbarItem(placement: .confirmationAction) { + Button("Broadcast") { + let message = self.message + model.broadcastMessage = "" + + guard !message.isBlank else { + return + } + + Task { + self.sending = true + defer { self.sending = false } + + try await model.sendBroadcast(message) + + self.dismiss() + } + } + .disabled(self.message.isEmpty) + } + } + } +} diff --git a/Hotline/macOS/BroadcastMessageView.swift b/Hotline/macOS/BroadcastMessageView.swift deleted file mode 100644 index a5f6fd2..0000000 --- a/Hotline/macOS/BroadcastMessageView.swift +++ /dev/null @@ -1,66 +0,0 @@ -import SwiftUI - -fileprivate let CHARACTER_LIMIT: Int = 255 - -struct BroadcastMessageView: View { - @Environment(HotlineState.self) private var model: HotlineState - @Environment(\.dismiss) private var dismiss - - @State private var sending: Bool = false - - private var message: String { - self.model.broadcastMessage.trimmingCharacters(in: .whitespacesAndNewlines) - } - - var body: some View { - @Bindable var model = self.model - - VStack { - TextField("Write a message...", text: $model.broadcastMessage, axis: .vertical) - .textFieldStyle(.plain) - .lineLimit(5, reservesSpace: true) - } - .padding(.leading, 32) - .padding(.top, 2) - .overlay(alignment: .topLeading) { - Image("Server Message") - } - .padding(16) - .frame(width: 400) - .toolbar { - if self.sending { - ToolbarItem { - ProgressView() - .controlSize(.small) - } - } - - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - self.dismiss() - } - } - - ToolbarItem(placement: .confirmationAction) { - Button("Broadcast") { - let message = self.message - model.broadcastMessage = "" - - guard !message.isBlank else { - return - } - - Task { - self.sending = true - defer { self.sending = false } - - try await model.sendBroadcast(message) - - self.dismiss() - } - } - .disabled(self.message.isEmpty) - } - } - } -} diff --git a/Hotline/macOS/Files/FileDetailsSheet.swift b/Hotline/macOS/Files/FileDetailsSheet.swift index e86bf28..1bfacb2 100644 --- a/Hotline/macOS/Files/FileDetailsSheet.swift +++ b/Hotline/macOS/Files/FileDetailsSheet.swift @@ -14,7 +14,7 @@ struct FileDetailsSheet: View { var body: some View { VStack(alignment: .leading, spacing: 16) { HStack(alignment: .center, spacing: 16){ - if self.details.type == "Folder" { + if self.isFolder { FolderIconView() .frame(width: 32, height: 32) } @@ -22,6 +22,7 @@ struct FileDetailsSheet: View { FileIconView(filename: self.details.name, fileType: nil) .frame(width: 32, height: 32) } + TextField("File Name", text: $filename) .disabled(!self.canRename) } @@ -114,6 +115,28 @@ struct FileDetailsSheet: View { } } + private var isFolder: Bool { + self.details.type == "Folder" || self.details.type == "fldr" + } + + private func isEdited() -> Bool { + return self.filename != self.details.name || self.comment != self.details.comment + } + + private var canRename: Bool { + if self.isFolder { + return self.model.access?.contains(.canRenameFolders) == true + } + return self.model.access?.contains(.canRenameFiles) == true + } + + private var canSetComment: Bool { + if self.isFolder { + return self.model.access?.contains(.canSetFolderComment) == true + } + return self.model.access?.contains(.canSetFileComment) == true + } + static var dateFormatter: DateFormatter = { var dateFormatter = DateFormatter() dateFormatter.dateStyle = .long @@ -142,24 +165,6 @@ struct FileDetailsSheet: View { let formattedByteCount = Self.byteCountSizeFormatter.string(from: NSNumber(value:byteCount)) ?? "0" return "\(FileItemView.byteFormatter.string(fromByteCount: Int64(byteCount))) (\(formattedByteCount) bytes)" } - - private func isEdited() -> Bool { - return self.filename != self.details.name || self.comment != self.details.comment - } - - private var canRename: Bool { - if self.details.type == "fldr" || self.details.type == "Folder" { - return self.model.access?.contains(.canRenameFolders) == true - } - return self.model.access?.contains(.canRenameFiles) == true - } - - private var canSetComment: Bool { - if self.details.type == "fldr" || self.details.type == "Folder" { - return self.model.access?.contains(.canSetFolderComment) == true - } - return self.model.access?.contains(.canSetFileComment) == true - } } //#Preview { diff --git a/Hotline/macOS/Files/FilesView.swift b/Hotline/macOS/Files/FilesView.swift index 1ae99d9..c9c4c24 100644 --- a/Hotline/macOS/Files/FilesView.swift +++ b/Hotline/macOS/Files/FilesView.swift @@ -198,28 +198,32 @@ struct FilesView: View { ToolbarSpacer() } - ToolbarItem { - Button { - self.newFolderShown = true - } label: { - Label("New Folder", systemImage: "folder.badge.plus") - } - .help("New Folder") - .popover(isPresented: self.$newFolderShown, arrowEdge: .bottom) { - NewFolderPopover { folderName in - self.newFolder(name: folderName, parent: self.selection) + if self.model.access?.contains(.canCreateFolders) == true { + ToolbarItem { + Button { + self.newFolderShown = true + } label: { + Label("New Folder", systemImage: "folder.badge.plus") + } + .help("New Folder") + .popover(isPresented: self.$newFolderShown, arrowEdge: .bottom) { + NewFolderPopover { folderName in + self.newFolder(name: folderName, parent: self.selection) + } } } } - ToolbarItem { - Button { - self.confirmDeleteShown = true - } label: { - Label("Delete", systemImage: "trash") + if self.model.access?.contains(.canDeleteFiles) == true || self.model.access?.contains(.canDeleteFolders) == true { + ToolbarItem { + Button { + self.confirmDeleteShown = true + } label: { + Label("Delete", systemImage: "trash") + } + .disabled(self.selection == nil) + .help("Delete") } - .disabled(self.selection == nil) - .help("Delete") } } } diff --git a/Hotline/macOS/MessageView.swift b/Hotline/macOS/MessageView.swift index 6690a88..6208d54 100644 --- a/Hotline/macOS/MessageView.swift +++ b/Hotline/macOS/MessageView.swift @@ -8,6 +8,8 @@ struct MessageView: View { @State private var scrollPos: Int? @State private var contentHeight: CGFloat = 0 @State private var userInfo: HotlineUserClientInfo? + @State private var disconnectConfirmShown: Bool = false + @State private var username: String? @Namespace private var bottomID @FocusState private var focusedField: FocusedField? @@ -27,6 +29,10 @@ struct MessageView: View { self.inputBar } .background(Color(nsColor: .textBackgroundColor)) + .onAppear { + let user = self.model.users.first(where: { $0.id == userID }) + self.username = user?.name + } .toolbar { if self.model.access?.contains(.canGetClientInfo) == true { ToolbarItem { @@ -35,11 +41,30 @@ struct MessageView: View { } label: { Image(systemName: "info.circle") } + .help("View \(self.username ?? "user")'s information") + } + } + + if self.model.access?.contains(.canDisconnectUsers) == true { + ToolbarItem { + Button { + self.disconnectConfirmShown = true + } label: { + Image(systemName: "nosign") + } + .help("Disconnect \(self.username ?? "this user")") } } } .sheet(item: self.$userInfo) { info in - UserInfoView(info: info) + UserClientInfoSheet(info: info) + } + .alert("Are you sure you want to disconnect \(self.username ?? "this user")?", isPresented: self.$disconnectConfirmShown) { + Button("Disconnect", role: .destructive) { + self.disconnectUser() + } + } message: { + Text("They will be disconnected from the server, but may reconnect.") } } @@ -51,10 +76,15 @@ struct MessageView: View { } } + private func disconnectUser() { + Task { + try await self.model.disconnectUser(id: self.userID, options: nil) + } + } + private var inputBar: some View { HStack(alignment: .lastTextBaseline, spacing: 0) { - let user = self.model.users.first(where: { $0.id == userID }) - TextField("Message \(user?.name ?? "")", text: $input, axis: .vertical) + TextField("Message \(self.username ?? "")", text: $input, axis: .vertical) .focused($focusedField, equals: .chatInput) .textFieldStyle(.plain) .lineLimit(1...5) diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index 3794018..ff92acc 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -111,7 +111,7 @@ struct ServerView: View { self.connectForm Spacer() } - .navigationTitle("New Connection") + .navigationTitle(self.model.serverTitle.isBlank ? "Hotline" : self.model.serverTitle) } else if self.model.status.isLoggingIn { HStack { @@ -131,7 +131,7 @@ struct ServerView: View { } .frame(maxWidth: 300) .padding() - .navigationTitle("New Connection") + .navigationTitle(self.model.serverTitle.isBlank ? "Hotline" : self.model.serverTitle) } else if self.model.status == .loggedIn { self.serverView @@ -155,7 +155,7 @@ struct ServerView: View { Task { try? await self.model.sendUserPreferences() } } .sheet(isPresented: self.$state.broadcastShown) { - BroadcastMessageView() + BroadcastMessageSheet() .environment(self.model) .presentationSizing(.fitted) } @@ -336,8 +336,8 @@ struct ServerView: View { var serverView: some View { NavigationSplitView { self.navigationList - .frame(maxWidth: .infinity) - .navigationSplitViewColumnWidth(min: 200, ideal: 250, max: 500) + .navigationSplitViewColumnWidth(200) +// .navigationSplitViewColumnWidth(min: 150, ideal: 200, max: 400) .toolbar(removing: .sidebarToggle) // .toolbar { // if self.model.access?.contains(.canOpenUsers) == true { @@ -357,22 +357,22 @@ struct ServerView: View { ChatView() .navigationTitle(model.serverTitle) // .navigationSubtitle("Public Chat") - .navigationSplitViewColumnWidth(min: 250, ideal: 500) +// .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .news: NewsView() .navigationTitle(model.serverTitle) // .navigationSubtitle("Newsgroups") - .navigationSplitViewColumnWidth(min: 250, ideal: 500) +// .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .board: MessageBoardView() .navigationTitle(model.serverTitle) // .navigationSubtitle("Message Board") - .navigationSplitViewColumnWidth(min: 250, ideal: 500) +// .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .files: FilesView() .navigationTitle(model.serverTitle) // .navigationSubtitle("Shared Files") - .navigationSplitViewColumnWidth(min: 250, ideal: 500) +// .navigationSplitViewColumnWidth(min: 250, ideal: 500) // case .accounts: // AccountManagerView() // .navigationTitle(model.serverTitle) @@ -383,15 +383,12 @@ struct ServerView: View { MessageView(userID: userID) .navigationTitle(model.serverTitle) // .navigationSubtitle(user?.name ?? "Private Message") - .navigationSplitViewColumnWidth(min: 250, ideal: 500) +// .navigationSplitViewColumnWidth(min: 250, ideal: 500) .onAppear { model.markInstantMessagesAsRead(userID: userID) } } } - .background { - Color.red - } } // MARK: - diff --git a/Hotline/macOS/UserClientInfoSheet.swift b/Hotline/macOS/UserClientInfoSheet.swift new file mode 100644 index 0000000..8fa4db8 --- /dev/null +++ b/Hotline/macOS/UserClientInfoSheet.swift @@ -0,0 +1,25 @@ +import SwiftUI + +struct UserClientInfoSheet: View { + @Environment(\.dismiss) private var dismiss + + let info: HotlineUserClientInfo + + var body: some View { + ScrollView(.vertical) { + Text(self.info.details) + .fontDesign(.monospaced) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding() + } + .frame(width: 350, height: 400) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("OK") { + self.dismiss() + } + } + } + } +} diff --git a/Hotline/macOS/UserInfo.swift b/Hotline/macOS/UserInfo.swift deleted file mode 100644 index 328fc02..0000000 --- a/Hotline/macOS/UserInfo.swift +++ /dev/null @@ -1,25 +0,0 @@ -import SwiftUI - -struct UserInfoView: View { - @Environment(\.dismiss) private var dismiss - - let info: HotlineUserClientInfo - - var body: some View { - ScrollView(.vertical) { - Text(self.info.details) - .fontDesign(.monospaced) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .topLeading) - .padding() - } - .frame(width: 350, height: 400) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("OK") { - self.dismiss() - } - } - } - } -} -- cgit