diff options
| -rw-r--r-- | Hotline.xcodeproj/project.pbxproj | 18 | ||||
| -rw-r--r-- | Hotline/Application.swift | 8 | ||||
| -rw-r--r-- | Hotline/Hotline/HotlineClient.swift | 54 | ||||
| -rw-r--r-- | Hotline/Hotline/HotlineFileClient.swift | 600 | ||||
| -rw-r--r-- | Hotline/Hotline/HotlineProtocol.swift | 33 | ||||
| -rw-r--r-- | Hotline/Models/FilePreview.swift | 41 | ||||
| -rw-r--r-- | Hotline/Models/Hotline.swift | 259 | ||||
| -rw-r--r-- | Hotline/Models/Server.swift | 32 | ||||
| -rw-r--r-- | Hotline/Models/TransferInfo.swift | 33 | ||||
| -rw-r--r-- | Hotline/Models/User.swift | 5 | ||||
| -rw-r--r-- | Hotline/Utility/FoundationExtensions.swift | 50 | ||||
| -rw-r--r-- | Hotline/iOS/ChatView.swift | 1 | ||||
| -rw-r--r-- | Hotline/macOS/ChatView.swift | 1 | ||||
| -rw-r--r-- | Hotline/macOS/FilesView.swift | 14 | ||||
| -rw-r--r-- | Hotline/macOS/MessageView.swift | 1 | ||||
| -rw-r--r-- | Hotline/macOS/ServerView.swift | 301 | ||||
| -rw-r--r-- | Hotline/macOS/TrackerView.swift | 4 |
17 files changed, 1188 insertions, 267 deletions
diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index 5d0220e..0f85b66 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ DA0D69912B1E894800C71DF5 /* FilesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0D69902B1E894800C71DF5 /* FilesView.swift */; platformFilter = ios; }; DA2863D82B37AD1C00A7D050 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2863D72B37AD1C00A7D050 /* SettingsView.swift */; }; DA2863DA2B37BF6E00A7D050 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2863D92B37BF6E00A7D050 /* Preferences.swift */; }; + DA2863DD2B3E8B7000A7D050 /* FilePreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2863DC2B3E8B7000A7D050 /* FilePreview.swift */; }; DA32CD492B2931640053B98B /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32CD482B2931640053B98B /* User.swift */; }; DA32CD4B2B29318E0053B98B /* FileInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32CD4A2B29318E0053B98B /* FileInfo.swift */; }; DA32CD4D2B2931B50053B98B /* ChatMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32CD4C2B2931B50053B98B /* ChatMessage.swift */; }; @@ -30,6 +31,7 @@ DA9CAFC12B126D5800CDA197 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DA9CAFC02B126D5800CDA197 /* Assets.xcassets */; }; DA9CAFC42B126D5800CDA197 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DA9CAFC32B126D5800CDA197 /* Preview Assets.xcassets */; }; DA9CAFCB2B126E3300CDA197 /* HotlineTrackerClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA9CAFCA2B126E3300CDA197 /* HotlineTrackerClient.swift */; }; + DAAEE66B2B3FBC2100A5BA07 /* TransferInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAAEE66A2B3FBC2100A5BA07 /* TransferInfo.swift */; }; DABFCC292B1530DC009F40D2 /* FoundationExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABFCC282B1530DC009F40D2 /* FoundationExtensions.swift */; }; DAC002192B21630900A6C290 /* SwiftUIExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC002182B21630900A6C290 /* SwiftUIExtensions.swift */; }; DADDB28B2B22B31F0024040D /* Tracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADDB28A2B22B31F0024040D /* Tracker.swift */; }; @@ -53,6 +55,7 @@ DA2863D72B37AD1C00A7D050 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; }; DA2863D92B37BF6E00A7D050 /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = "<group>"; }; DA2863DB2B37DBE000A7D050 /* Hotline.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Hotline.entitlements; sourceTree = "<group>"; }; + DA2863DC2B3E8B7000A7D050 /* FilePreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePreview.swift; sourceTree = "<group>"; }; DA32CD472B28EE640053B98B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; DA32CD482B2931640053B98B /* User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = "<group>"; }; DA32CD4A2B29318E0053B98B /* FileInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileInfo.swift; sourceTree = "<group>"; }; @@ -73,6 +76,7 @@ DA9CAFC02B126D5800CDA197 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; DA9CAFC32B126D5800CDA197 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; }; DA9CAFCA2B126E3300CDA197 /* HotlineTrackerClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineTrackerClient.swift; sourceTree = "<group>"; }; + DAAEE66A2B3FBC2100A5BA07 /* TransferInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransferInfo.swift; sourceTree = "<group>"; }; DABFCC282B1530DC009F40D2 /* FoundationExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoundationExtensions.swift; sourceTree = "<group>"; }; DAC002182B21630900A6C290 /* SwiftUIExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIExtensions.swift; sourceTree = "<group>"; }; DADDB28A2B22B31F0024040D /* Tracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tracker.swift; sourceTree = "<group>"; }; @@ -188,6 +192,8 @@ DADDB28C2B22B5920024040D /* Server.swift */, DA32CD482B2931640053B98B /* User.swift */, DA32CD4A2B29318E0053B98B /* FileInfo.swift */, + DAAEE66A2B3FBC2100A5BA07 /* TransferInfo.swift */, + DA2863DC2B3E8B7000A7D050 /* FilePreview.swift */, DA32CD4C2B2931B50053B98B /* ChatMessage.swift */, DA32CD4E2B2931CC0053B98B /* NewsInfo.swift */, ); @@ -285,6 +291,7 @@ DA43205E2B1D615600FC8843 /* ServerView.swift in Sources */, DA0D69912B1E894800C71DF5 /* FilesView.swift in Sources */, DAE735092B329810000C56F6 /* VisualEffectView.swift in Sources */, + DA2863DD2B3E8B7000A7D050 /* FilePreview.swift in Sources */, DA2863D82B37AD1C00A7D050 /* SettingsView.swift in Sources */, DA7725412B21435B006C5ABB /* ObservableScrollView.swift in Sources */, DAE734FF2B2E6750000C56F6 /* ChatView.swift in Sources */, @@ -300,6 +307,7 @@ DA2863DA2B37BF6E00A7D050 /* Preferences.swift in Sources */, DA32CD4F2B2931CC0053B98B /* NewsInfo.swift in Sources */, DA9CAFBB2B126D5700CDA197 /* Application.swift in Sources */, + DAAEE66B2B3FBC2100A5BA07 /* TransferInfo.swift in Sources */, DA0D698F2B1E841600C71DF5 /* MessageBoardView.swift in Sources */, DAE735072B3251B3000C56F6 /* Utilities.swift in Sources */, DAE735012B2E71F2000C56F6 /* FilesView.swift in Sources */, @@ -448,18 +456,21 @@ CODE_SIGN_ENTITLEMENTS = Hotline/Hotline.entitlements; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_ASSET_PATHS = "\"Hotline/Preview Content\""; DEVELOPMENT_TEAM = 5AEEV7QB2U; "ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Hotline/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Hotline; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; 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", @@ -484,18 +495,21 @@ CODE_SIGN_ENTITLEMENTS = Hotline/Hotline.entitlements; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_ASSET_PATHS = "\"Hotline/Preview Content\""; DEVELOPMENT_TEAM = 5AEEV7QB2U; "ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Hotline/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Hotline; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; 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", diff --git a/Hotline/Application.swift b/Hotline/Application.swift index 83751c3..3c5a5a9 100644 --- a/Hotline/Application.swift +++ b/Hotline/Application.swift @@ -55,6 +55,14 @@ struct Application: App { } .defaultSize(width: 700, height: 800) .defaultPosition(.center) + .commandsRemoved() + .commands { + CommandGroup(before: CommandGroupPlacement.newItem) { + Button("before item") { + print("before item") + } + } + } #if os(macOS) Settings { diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index 6eb6f42..6193025 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -388,7 +388,7 @@ class HotlineClient { } let protocolID = data.readUInt32(at: 0)! - if protocolID != 0x54525450 { // 'TRTP' + if protocolID != "TRTP".fourCharCode() { print("HotlineClient: invalid handshake protocol ID \(protocolID)") self.disconnect() callback?(false) @@ -645,12 +645,54 @@ class HotlineClient { }) } - func sendDownloadBanner(sent: ((Bool) -> Void)? = nil, reply: ((UInt32?, Int?) -> Void)? = nil) { + func sendDownloadFile(name fileName: String, path filePath: [String], preview: Bool = false, sent: ((Bool) -> Void)? = nil, reply: ((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) + } + + print("DOWNLOAD \(fileName) AT PATH \(filePath)") + + self.sendTransaction(t, sent: sent, reply: { r, err in + if err != nil { + DispatchQueue.main.async { + reply?(false, nil, nil, nil, nil) + } + return + } + + if let transferSizeField = r.getField(type: .transferSize), + let transferSize = transferSizeField.getInteger(), + let transferReferenceField = r.getField(type: .referenceNumber), + let referenceNumber = transferReferenceField.getUInt32(), + let transferFileSizeField = r.getField(type: .fileSize), + let transferFileSize = transferFileSizeField.getInteger() { + + let transferWaitingCountField = r.getField(type: .waitingCount) + let transferWaitingCount = transferWaitingCountField?.getInteger() + + DispatchQueue.main.async { + reply?(true, referenceNumber, transferSize, transferFileSize, transferWaitingCount) + } + } + else { + DispatchQueue.main.async { + reply?(false, nil, nil, nil, nil) + } + } + }) + } + + func sendDownloadBanner(sent: ((Bool) -> Void)? = nil, reply: ((Bool, UInt32?, Int?) -> Void)? = nil) { let t = HotlineTransaction(type: .downloadBanner) self.sendTransaction(t, sent: sent, reply: { r, err in if err != nil { - reply?(nil, nil) + DispatchQueue.main.async { + reply?(false, nil, nil) + } return } @@ -659,15 +701,13 @@ class HotlineClient { let transferReferenceField = r.getField(type: .referenceNumber), let referenceNumber = transferReferenceField.getUInt32() { - print("TRANSFER SIZE", transferSize) - DispatchQueue.main.async { - reply?(referenceNumber, transferSize) + reply?(true, referenceNumber, transferSize) } } else { DispatchQueue.main.async { - reply?(nil, nil) + reply?(false, nil, nil) } } }) diff --git a/Hotline/Hotline/HotlineFileClient.swift b/Hotline/Hotline/HotlineFileClient.swift index 25323f3..f573809 100644 --- a/Hotline/Hotline/HotlineFileClient.swift +++ b/Hotline/Hotline/HotlineFileClient.swift @@ -1,62 +1,262 @@ import Foundation import Network -enum HotlineFileTransferType { - case banner - case file +enum HotlineFileClientError: Error { + case failedToConnect + case failedToDownload } -enum HotlineFileStatus: Int { - case disconnected +enum HotlineFileClientStatus: Equatable { + case unconnected case connecting case connected - case downloading - case complete + case progress(Double) + case completed + case failed(HotlineFileClientError) } -@Observable -class HotlineFileClient { - var status: HotlineFileStatus = .disconnected - var progress: Double = -1.0 +protocol HotlineFileClientDelegate: AnyObject { + func hotlineFileStatusChanged(client: HotlineFileClient, reference: UInt32, status: HotlineFileClientStatus, timeRemaining: TimeInterval) + func hotlineFileReceivedInfo(client: HotlineFileClient, reference: UInt32, info: HotlineFileInfoFork) + func hotlineFileDownloadedData(client: HotlineFileClient, reference: UInt32, data: Data) + func hotlineFileDownloadedFile(client: HotlineFileClient, reference: UInt32, at: URL) +} + +extension HotlineFileClientDelegate { + func hotlineFileStatusChanged(client: HotlineFileClient, reference: UInt32, status: HotlineFileClientStatus, timeRemaining: TimeInterval) {} + func hotlineFileReceivedInfo(client: HotlineFileClient, reference: UInt32, info: HotlineFileInfoFork) {} + func hotlineFileDownloadedData(client: HotlineFileClient, reference: UInt32, data: Data) {} + func hotlineFileDownloadedFile(client: HotlineFileClient, reference: UInt32, at: URL) {} +} + +enum HotlineFileTransferType { + case file + case preview +} + +enum HotlineFileTransferStage: Int { + case fileHeader = 1 + case fileForkHeader = 2 + case fileInfoFork = 3 + case fileDataFork = 4 + case fileUnsupportedFork = 5 +} + +struct HotlineFileHeader { + static let DataSize: Int = 4 + 2 + 16 + 2 + + let format: UInt32 + let version: UInt16 + let forkCount: UInt16 + + init?(from data: Data) { + guard data.count >= HotlineFileHeader.DataSize else { + return nil + } + + self.format = data.readUInt32(at: 0)! + self.version = data.readUInt16(at: 4)! + // 16 bytes of reserved data sits here. Skip it. + // self.reserved = data.readData(at: 4 + 2, length: 16) + self.forkCount = data.readUInt16(at: 4 + 2 + 16)! + } +} + +struct HotlineFileForkHeader { + static let DataSize: Int = 4 + 4 + 4 + 4 + + let forkType: UInt32 + let compressionType: UInt32 + let dataSize: UInt32 + + init?(from data: Data) { + guard data.count >= HotlineFileForkHeader.DataSize else { + return nil + } + + self.forkType = data.readUInt32(at: 0)! + self.compressionType = data.readUInt32(at: 4)! + // 4 bytes of reserved data sits here. Skip it. + // self.reserved = data.readUInt32(at: 4 + 4)! + self.dataSize = data.readUInt32(at: 4 + 4 + 4)! + } +} + +struct HotlineFileInfoFork { + static let BaseDataSize: Int = 4 + 4 + 4 + 4 + 4 + 32 + 8 + 8 + 2 + 2 + let platform: UInt32 + let type: UInt32 + let creator: UInt32 + let flags: UInt32 + let platformFlags: UInt32 + let createdDate: Date + let modifiedDate: Date + let nameScript: UInt16 + let name: String + let comment: String? + var headerSize: Int + + init?(from data: Data) { + // Make sure we have at least enough data to read basic header data + guard data.count >= HotlineFileInfoFork.BaseDataSize else { + return nil + } + + if + let platform = data.readUInt32(at: 0), + let type = data.readUInt32(at: 4), + let creator = data.readUInt32(at: 4 + 4), + let flags = data.readUInt32(at: 4 + 4 + 4), + let platformFlags = data.readUInt32(at: 4 + 4 + 4 + 4), + // 32 bytes of reserved data sits here. Skip it. + let nameScript = data.readUInt16(at: 4 + 4 + 4 + 4 + 4 + 32 + 8 + 8) { + + let createdDate = data.readDate(at: 4 + 4 + 4 + 4 + 4 + 32) ?? Date.now + let modifiedDate = data.readDate(at: 4 + 4 + 4 + 4 + 4 + 32 + 8) ?? Date.now + + let (n, nl) = data.readLongPString(at: 4 + 4 + 4 + 4 + 4 + 32 + 8 + 8 + 2) + if let name = n { + self.platform = platform + self.type = type + self.creator = creator + self.flags = flags + self.platformFlags = platformFlags + self.createdDate = createdDate + self.modifiedDate = modifiedDate + self.nameScript = nameScript + self.name = name + + var calculatedHeaderSize: Int = HotlineFileInfoFork.BaseDataSize + nl + var commentRead: String? = nil + if data.count >= HotlineFileInfoFork.BaseDataSize + nl + 2 { + let commentLength = data.readUInt16(at: HotlineFileInfoFork.BaseDataSize + nl)! + var commentCorrupted = false + + // Some servers have incorrect data length for the INFO fork + // the length they send is what it should be but don't include + // the comment length in the actual data, so we end up with mismatched + // lengths. So here we test if the length we read is actually 'DA' + // or the first part of the "DATA" fork header. + // Needless to say, stuff like this makes for sad code but this ain't so bad. + if commentLength == 0x4441 { + commentCorrupted = true + } + + if !commentCorrupted { + let (c, cl) = data.readLongPString(at: HotlineFileInfoFork.BaseDataSize + nl) + calculatedHeaderSize += 2 + if cl > 0 { + calculatedHeaderSize += Int(cl) + if let ct = c, cl > 0 { + commentRead = ct + } + } + } + } + + self.comment = commentRead + self.headerSize = calculatedHeaderSize + return + } + } + + return nil + } +} + +class HotlineFileClient { let serverAddress: NWEndpoint.Host let serverPort: NWEndpoint.Port let referenceNumber: UInt32 let referenceDataSize: UInt32 let transferType: HotlineFileTransferType + weak var delegate: HotlineFileClientDelegate? = nil + + var status: HotlineFileClientStatus = .unconnected { + didSet { + DispatchQueue.main.async { + self.delegate?.hotlineFileStatusChanged(client: self, reference: self.referenceNumber, status: self.status, timeRemaining: self.fileProgress?.estimatedTimeRemaining ?? 0.0) + } + } + } + private var connection: NWConnection? + private var transferStage: HotlineFileTransferStage = .fileHeader + private var fileBytes = 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 fileBytesDownloaded: Int = 0 + private var fileProgress: Progress? = nil + +// private var previewCallback: ((Data?) -> Void)? = nil +// private var fileCallback: ((URL?) -> Void)? = nil + init(address: String, port: UInt16, reference: UInt32, size: UInt32, type: HotlineFileTransferType = .file) { self.serverAddress = NWEndpoint.Host(address) self.serverPort = NWEndpoint.Port(rawValue: port + 1)! self.referenceNumber = reference self.referenceDataSize = size - self.status = .disconnected self.transferType = type - - if size == 0 { - self.progress = -1.0 - } - else { - self.progress = 0.0 + self.transferStage = .fileHeader + } + + deinit { + self.invalidate() + } + + func downloadToMemory() { + guard self.status == .unconnected else { + return } + + self.connect() } - func downloadToMemory(_ callback: ((Data?) -> Void)?) { - self.reset() - self.connect { data in - callback?(data) + func downloadToFile() { + guard self.status == .unconnected else { + return } + + self.connect() } - private func reset() { - self.progress = 0.0 - self.fileBytes = Data() + func cancel(deleteIncompleteFile: Bool = true) { + 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 deleteIncompleteFile, 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(_ callback: ((Data?) -> Void)? = nil) { + // MARK: - + + 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 { @@ -64,19 +264,20 @@ class HotlineFileClient { self?.status = .connected self?.sendMagic() case .cancelled: - self?.status = .disconnected - DispatchQueue.main.async { - callback?(self?.fileBytes) - } + self?.invalidate() case .failed(let err): print("HotlineTrackerClient: Connection error \(err)") - if self?.status == .complete { - DispatchQueue.main.async { - callback?(self?.fileBytes) - } - } - else { - self?.status = .disconnected + switch self?.status { + case .connecting: + print("FAILED TO CONNECT") + self?.invalidate() + self?.status = .failed(.failedToConnect) + case .connected, .progress(_): + print("FAILED TO DOWNLLOAD") + self?.invalidate() + self?.status = .failed(.failedToDownload) + default: + break } default: return @@ -87,89 +288,322 @@ class HotlineFileClient { self.connection?.start(queue: .global()) } - func disconnect() { - self.status = .disconnected - self.connection?.cancel() - self.connection = nil + 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 + } } private func sendMagic() { - guard let c = connection else { + guard let c = connection, self.status == .connected else { + self.invalidate() print("HotlineFileClient: invalid connection to send header.") return } var headerData = Data() - headerData.appendUInt32(0x48545846) // 'HTXF' + headerData.appendUInt32("HTXF".fourCharCode()) headerData.appendUInt32(self.referenceNumber) - headerData.appendUInt32(self.referenceDataSize) - headerData.appendUInt16(2) // Type - headerData.appendUInt16(0) // Reserved? + headerData.appendUInt32(0) + headerData.appendUInt32(0) c.send(content: headerData, completion: .contentProcessed { [weak self] (error) in - guard let transferType = self?.transferType else { - self?.disconnect() + guard let self = self else { return } - if let err = error { - print("HotlineFileClient: sending header failed \(err)") - self?.disconnect() + guard error == nil else { + self.status = .failed(.failedToConnect) + self.invalidate() return } - switch transferType { - case .banner: - self?.receiveBannerFile() + self.status = .progress(0.0) + + switch self.transferType { case .file: - break + self.receiveFile() + case .preview: + self.receivePreviewData() } }) } - private func receiveBannerFile() { - guard let c = connection else { + private func receiveFile() { + guard let c = self.connection else { return } - self.status = .downloading - - let finalSize = Double(self.referenceDataSize) - var maxLength: Int = 65536 - - if self.referenceDataSize != 0 { - maxLength = max(Int(self.referenceDataSize) - self.fileBytes.count, 0) + c.receive(minimumIncompleteLength: 1024, 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.fileBytesDownloaded += newData.count + self.fileBytes.append(newData) + self.fileProgress?.completedUnitCount = Int64(self.fileBytesDownloaded) + self.status = .progress(Double(self.fileBytesDownloaded) / Double(self.referenceDataSize)) + } + + // See if we need header data still. + var keepProcessing = false + repeat { + keepProcessing = false + + switch self.transferStage { + case .fileHeader: + if let header = HotlineFileHeader(from: self.fileBytes) { + self.fileBytes.removeSubrange(0..<HotlineFileHeader.DataSize) + + self.fileHeader = header + print("FILE HEADER", self.fileHeader) + + self.transferStage = .fileForkHeader + keepProcessing = true + } + case .fileForkHeader: + if let forkHeader = HotlineFileForkHeader(from: self.fileBytes) { +// let fileForkHeader = HotlineFileForkHeader(from: self.fileBytes) + self.fileBytes.removeSubrange(0..<HotlineFileForkHeader.DataSize) + self.fileCurrentForkHeader = forkHeader + self.fileCurrentForkBytesLeft = Int(forkHeader.dataSize) + + print("FILE FORK HEADER", forkHeader, forkHeader.forkType.fourCharCode()) + if forkHeader.forkType == "INFO".fourCharCode() { + print("INFO FORK!") + self.transferStage = .fileInfoFork + } + else if forkHeader.forkType == "DATA".fourCharCode() { + print("DATA FORK!") + self.transferStage = .fileDataFork + } + else if forkHeader.forkType == "MACR".fourCharCode() { + print("RESOURCE FORK!") + self.transferStage = .fileUnsupportedFork + } + else { + print("UNKNOWN FORK??") + self.transferStage = .fileUnsupportedFork + } + + keepProcessing = true + } + case .fileInfoFork: + if let infoForkDataSize = self.fileCurrentForkHeader?.dataSize, self.fileBytes.count >= infoForkDataSize { + let infoForkData = self.fileBytes.subdata(in: 0..<Int(infoForkDataSize)) + if let infoFork = HotlineFileInfoFork(from: infoForkData) { + self.fileInfoFork = infoFork + + self.fileBytes.removeSubrange(0..<infoFork.headerSize) + + self.transferStage = .fileForkHeader + self.fileCurrentForkBytesLeft -= Int(infoForkDataSize) + self.fileCurrentForkHeader = nil + + print("INFO FORK STUFF:", infoFork) + + if !self.prepareDownloadFile(name: infoFork.name) { + print("FAILED TO CREATE FILE ON DISK") + } + + let reference = self.referenceNumber + DispatchQueue.main.async { + self.delegate?.hotlineFileReceivedInfo(client: self, reference: reference, info: infoFork) + } + } + + keepProcessing = true + } + case .fileDataFork: + if self.fileBytes.count > 0 { + print("DOWNLOADING DATA FORK") + if let f = self.fileHandle { + do { + var dataToWrite = self.fileBytes + + if dataToWrite.count >= self.fileCurrentForkBytesLeft { + dataToWrite = self.fileBytes.subdata(in: 0..<self.fileCurrentForkBytesLeft) + self.fileBytes.removeSubrange(0..<self.fileCurrentForkBytesLeft) + + self.transferStage = .fileForkHeader + self.fileCurrentForkBytesLeft = 0 + self.fileCurrentForkHeader = nil + + keepProcessing = true + } + else { + self.fileCurrentForkBytesLeft -= dataToWrite.count + self.fileBytes = Data() + } + + print("WRITING \(dataToWrite.count) BYTES TO DISK") + + try f.write(contentsOf: dataToWrite) + } + catch { + print("DOWNLOAD WRITE ERROR", error) + } + } + } + case .fileUnsupportedFork: + if self.fileBytes.count > 0 { + print("SKIPPING UNSUPPORTED FORK DATA") + var dataToWrite = self.fileBytes + + if dataToWrite.count >= self.fileCurrentForkBytesLeft { + dataToWrite = self.fileBytes.subdata(in: 0..<self.fileCurrentForkBytesLeft) + self.fileBytes.removeSubrange(0..<self.fileCurrentForkBytesLeft) + + self.transferStage = .fileForkHeader + self.fileCurrentForkBytesLeft = 0 + self.fileCurrentForkHeader = nil + + keepProcessing = true + } + else { + self.fileCurrentForkBytesLeft -= dataToWrite.count + self.fileBytes = Data() + } + } + } + } while keepProcessing + + if self.fileBytesDownloaded < Int(self.referenceDataSize) { + // If we still have more to download, then receive more data. + self.receiveFile() + return + } + else { + print("FILE COMPLETE") + if let h = self.fileHandle { + try? h.close() + self.fileHandle = nil + } + self.fileBytes = Data() + + self.status = .completed + self.invalidate() + + 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) + + let reference = self.referenceNumber + self.delegate?.hotlineFileDownloadedFile(client: self, reference: reference, 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 + } + } + } } - - if maxLength == 0 { - self.status = .complete - self.progress = 1.0 - self.disconnect() + } + + private func receivePreviewData() { + guard let c = self.connection else { return } - - c.receive(minimumIncompleteLength: maxLength, maximumLength: maxLength) { [weak self] (data, context, isComplete, error) in + + c.receive(minimumIncompleteLength: 1024, maximumLength: Int(UInt16.max)) { [weak self] (data, context, isComplete, error) in guard let self = self else { return } - if let fileData = data { - if !fileData.isEmpty { - self.fileBytes.append(fileData) - if finalSize > 0 { - self.progress = min(min(Double(self.fileBytes.count) / finalSize, 1.0), 0.0) - } - } + guard error == nil else { + self.status = .failed(.failedToDownload) + self.invalidate() + return } + + if let newData = data, !newData.isEmpty { + self.fileBytesDownloaded += newData.count + self.fileBytes.append(newData) + self.fileProgress?.completedUnitCount = Int64(self.fileBytesDownloaded) + self.status = .progress(Double(self.fileBytesDownloaded) / Double(self.referenceDataSize)) - if isComplete { - self.status = .complete - self.progress = 1.0 - self.disconnect() + + + print("DOWNLOAD PROGRESS", self.fileProgress.debugDescription) + } + + if self.fileBytesDownloaded < Int(self.referenceDataSize) { + self.receivePreviewData() } else { - self.receiveBannerFile() + let reference = self.referenceNumber + let data = self.fileBytes + + self.status = .completed + self.invalidate() + + DispatchQueue.main.sync { + self.delegate?.hotlineFileDownloadedData(client: self, reference: reference, data: data) + } } } } + + // MARK: - Utility + + private func findUniqueFilePath(base: String, at folderURL: URL) -> String { + let fileManager = FileManager.default + var finalName = base + var counter = 2 + + // Helper function to generate a new filename with a counter + func makeFileName() -> String { + let baseName = (base as NSString).deletingPathExtension + let extensionName = (base as NSString).pathExtension + return extensionName.isEmpty ? "\(baseName) \(counter)" : "\(baseName) \(counter).\(extensionName)" + } + + // Check if file exists and append counter until a unique name is found + var filePath = folderURL.appending(component: finalName).path(percentEncoded: false) + while fileManager.fileExists(atPath: filePath) { + finalName = makeFileName() + filePath = folderURL.appending(component: finalName).path(percentEncoded: false) + counter += 1 + } + + return filePath + } + + private func prepareDownloadFile(name: String) -> Bool { + let folderURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0] + let filePath = findUniqueFilePath(base: name, at: folderURL) + + if FileManager.default.createFile(atPath: filePath, contents: nil) { + if let h = FileHandle(forWritingAtPath: filePath) { + self.filePath = filePath + self.fileHandle = h + self.fileProgress?.fileURL = URL(filePath: filePath).resolvingSymlinksInPath() + return true + } + } + + return false + } } diff --git a/Hotline/Hotline/HotlineProtocol.swift b/Hotline/Hotline/HotlineProtocol.swift index 8cbbeb5..4277bce 100644 --- a/Hotline/Hotline/HotlineProtocol.swift +++ b/Hotline/Hotline/HotlineProtocol.swift @@ -114,7 +114,8 @@ struct HotlineNewsList: Identifiable { let articleID = data.readUInt32(at: baseIndex)! baseIndex += 4 - let timestampData = data.readData(at: baseIndex, length: 8)! + let date = data.readDate(at: baseIndex)! +// let timestampData = data.readData(at: baseIndex, length: 8)! baseIndex += 8 let parentID = data.readUInt32(at: baseIndex)! @@ -139,11 +140,11 @@ struct HotlineNewsList: Identifiable { baseIndex += Int(posterLength) // Parse date info - let yearData = timestampData.readUInt16(at: 0)! - let millisecondData = timestampData.readUInt16(at: 2)! - let secondsData = timestampData.readUInt32(at: 4)! - let articleData = convertHotlineDate(year: yearData, seconds: secondsData, milliseconds: millisecondData) - var newArticle = HotlineNewsArticle(id: articleID, parentID: parentID, flags: flags, title: title, username: poster, date: articleData) +// let yearData = timestampData.readUInt16(at: 0)! +// let millisecondData = timestampData.readUInt16(at: 2)! +// let secondsData = timestampData.readUInt32(at: 4)! +// let articleData = convertHotlineDate(year: yearData, seconds: secondsData, milliseconds: millisecondData) + var newArticle = HotlineNewsArticle(id: articleID, parentID: parentID, flags: flags, title: title, username: poster, date: date) print("ARTICLE ID: \(articleID)") print("PARENT ID: \(parentID)") @@ -269,8 +270,8 @@ class HotlineFile: Identifiable, Hashable { let typeRaw = data.readUInt32(at: 0)! let creatorRaw = data.readUInt32(at: 4)! - self.type = typeRaw.toStringLiteral() - self.creator = creatorRaw.toStringLiteral() + self.type = typeRaw.fourCharCode() + self.creator = creatorRaw.fourCharCode() self.fileSize = data.readUInt32(at: 8)! self.isFolder = (self.type == "fldr") @@ -612,7 +613,10 @@ enum HotlineTransactionFieldType: UInt16 { case communityBannerID = 161 // Integer case serverName = 162 // String case fileNameWithInfo = 200 // Data { type: 4, creator: 4, file size: 4, reserved: 4, name script: 2, name size: 2, name data: size } + case fileName = 201 // String case filePath = 202 // Path + case fileTransferOptions = 204 // Integer + case fileSize = 207 // Integer // TODO: Add file field types case quotingMessage = 214 // String? case automaticResponse = 215 // String @@ -703,15 +707,14 @@ enum HotlineTransactionType: UInt16 { // MARK: - Utilities -private func convertHotlineDate(year: UInt16, seconds: UInt32, milliseconds: UInt16) -> Date? { - let days = round((Double(seconds) + (Double(milliseconds) * 1000.0)) / 86400.0) - +func convertHotlineDate(year:UInt16, seconds: UInt32, milliseconds: UInt16) -> Date? { var components = DateComponents() components.timeZone = .gmt components.year = Int(year) components.month = 1 - components.day = 1 + Int(days) - components.second = Int(seconds) - Int(days * 86400.0) - - return Calendar.current.date(from: components) + components.day = 1 + components.second = 0 + + let baseDate = Calendar.current.date(from: components) + return baseDate?.advanced(by: TimeInterval(seconds)) } diff --git a/Hotline/Models/FilePreview.swift b/Hotline/Models/FilePreview.swift new file mode 100644 index 0000000..552ccff --- /dev/null +++ b/Hotline/Models/FilePreview.swift @@ -0,0 +1,41 @@ +import SwiftUI + +//struct FilePreview: Identifiable, Hashable { +// let id: UUID +// +// let path: [String] +// let name: String +// +// let type: String +// let creator: String +// let fileSize: UInt +// +// let isFolder: Bool +// let isUnavailable: Bool +// var expanded: Bool = false +// var children: [FileInfo]? = nil +// +// init(hotlineFile: HotlineFile) { +// self.id = UUID() +// self.path = hotlineFile.path +// self.name = hotlineFile.name +// self.type = hotlineFile.type +// self.creator = hotlineFile.creator +// self.fileSize = UInt(hotlineFile.fileSize) +// self.isFolder = hotlineFile.isFolder +// self.isUnavailable = (!self.isFolder && (self.fileSize == 0)) +// +// print(self.name, self.type, self.creator, self.isUnavailable) +// if self.isFolder { +// self.children = [] +// } +// } +// +// static func == (lhs: FileInfo, rhs: FileInfo) -> Bool { +// return lhs.id == rhs.id +// } +// +// func hash(into hasher: inout Hasher) { +// hasher.combine(self.id) +// } +//} diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift index 6aafdfe..fcf6c00 100644 --- a/Hotline/Models/Hotline.swift +++ b/Hotline/Models/Hotline.swift @@ -1,6 +1,6 @@ import SwiftUI -@Observable final class Hotline: HotlineClientDelegate { +@Observable final class Hotline: HotlineClientDelegate, HotlineFileClientDelegate { let trackerClient: HotlineTrackerClient let client: HotlineClient @@ -71,12 +71,6 @@ import SwiftUI 2561: "πΈπͺ", ] -// @AppStorage("username") private var username: String = "guest" -// @AppStorage("refuse private messages") private var refusePrivateMessages = false -// @AppStorage("refuse private chat") private var refusePrivateChat = false -// @AppStorage("enable automatic response") private var enableAutomaticResponse = false -// @AppStorage("automatic response") private var automaticResponse = "" - var status: HotlineClientStatus = .disconnected var server: Server? { @@ -109,6 +103,7 @@ import SwiftUI var news: [NewsInfo] = [] var newsLoaded: Bool = false + var transfers: [TransferInfo] = [] var downloads: [HotlineFileClient] = [] @ObservationIgnored var bannerClient: HotlineFileClient? @@ -180,7 +175,7 @@ import SwiftUI @MainActor func disconnect() { self.client.disconnect() - self.bannerClient?.disconnect() + self.bannerClient?.cancel() } @MainActor func sendAgree() { @@ -391,18 +386,130 @@ import SwiftUI } } - func downloadFile(path: [String], callback: ((Bool) -> Void)?) { + @MainActor func downloadFile(_ fileName: String, path: [String], complete callback: ((TransferInfo, URL) -> Void)? = nil) { + var fullPath: [String] = [] + if path.count > 1 { + fullPath = Array(path[0..<path.count-1]) + } + self.client.sendDownloadFile(name: fileName, path: fullPath, sent: { _ in + }, reply: { [weak self] success, downloadReferenceNumber, downloadTransferSize, downloadFileSize, downloadWaitingCount in + print("GOT DOWNLOAD REPLY:") + print("\tSUCCESS?", success) + print("\tTRANSFER SIZE: \(downloadTransferSize.debugDescription)") + print("\tFILE SIZE: \(downloadFileSize.debugDescription)") + print("\tREFERENCE NUM: \(downloadReferenceNumber.debugDescription)") + print("\tWAITING COUNT: \(downloadWaitingCount.debugDescription)") + + if + let self = self, + let address = self.server?.address, + let port = self.server?.port, + let referenceNumber = downloadReferenceNumber, + let transferSize = downloadTransferSize { + + print("DOWNLOADING TO MEMORY") + let fileClient = HotlineFileClient(address: address, port: UInt16(port), reference: referenceNumber, size: UInt32(transferSize), type: .file) + fileClient.delegate = self + self.downloads.append(fileClient) + + let transfer = TransferInfo(id: referenceNumber, title: fileName, size: UInt(transferSize)) + transfer.downloadCallback = callback + self.transfers.append(transfer) + + fileClient.downloadToFile() + } + }) } - @MainActor func downloadBanner(force: Bool = false, callback: ((Bool) -> Void)?) { - if let b = self.bannerClient { - b.disconnect() + @MainActor func previewFile(_ fileName: String, path: [String], addTransfer: Bool = false, complete callback: ((TransferInfo, Data) -> Void)? = nil) { + var fullPath: [String] = [] + if path.count > 1 { + fullPath = Array(path[0..<path.count-1]) + } + + self.client.sendDownloadFile(name: fileName, path: fullPath, preview: true, sent: { _ in + + }, reply: { [weak self] success, downloadReferenceNumber, downloadTransferSize, downloadFileSize, downloadWaitingCount in + guard success else { + return + } + + print("GOT DOWNLOAD REPLY:") + print("SUCCESS?", success) + print("TRANSFER SIZE: \(downloadTransferSize.debugDescription)") + print("FILE SIZE: \(downloadFileSize.debugDescription)") + print("REFERENCE NUM: \(downloadReferenceNumber.debugDescription)") + print("WAITING COUNT: \(downloadWaitingCount.debugDescription)") + + if + let self = self, + let address = self.server?.address, + let port = self.server?.port, + let referenceNumber = downloadReferenceNumber, + let transferSize = downloadTransferSize { + + let fileClient = HotlineFileClient(address: address, port: UInt16(port), reference: referenceNumber, size: UInt32(transferSize), type: .preview) + fileClient.delegate = self + self.downloads.append(fileClient) + + if addTransfer { + let transfer = TransferInfo(id: referenceNumber, title: fileName, size: UInt(transferSize)) + transfer.previewCallback = callback + self.transfers.append(transfer) + } + + fileClient.downloadToMemory() + + print("DOWNLOADING TO MEMORY") +// fileClient.downloadToMemory({ [weak self] fileData in +// print("DOWNLOADED PREVIEW DATA", fileData?.count) +// self?.downloads.removeAll { $0.referenceNumber == referenceNumber } +// callback?(fileData != nil, fileData) +// }) + +// self.downloads.append(fileClient) + } + }) + } + + @MainActor func deleteTransfer(id: UInt32) { + if let b = self.bannerClient, b.referenceNumber == id { + b.cancel() self.bannerClient = nil + return } - if force { - self.bannerImage = nil + if let i = self.transfers.firstIndex(where: { $0.id == id }) { + self.transfers.remove(at: i) + } + + if let i = self.downloads.firstIndex(where: { $0.referenceNumber == id }) { + let fileClient = self.downloads.remove(at: i) + fileClient.cancel(deleteIncompleteFile: true) + } + } + + @MainActor func deleteAllTransfers() { + self.transfers = [] + + let downloads = self.downloads + self.downloads = [] + + for fileClient in downloads { + fileClient.cancel(deleteIncompleteFile: true) + } + } + + @MainActor func downloadBanner(force: Bool = false, callback: ((Bool) -> Void)?) { + if self.bannerClient != nil || force { + self.bannerClient?.delegate = nil + self.bannerClient?.cancel() + self.bannerClient = nil + + if force { + self.bannerImage = nil + } } if self.bannerImage != nil { @@ -416,37 +523,44 @@ import SwiftUI callback?(false) return } - }, reply: { [weak self] downloadReferenceNumber, downloadTransferSize in + }, reply: { [weak self] success, downloadReferenceNumber, downloadTransferSize in + if !success { + callback?(false) + return + } + if let self = self, let address = self.server?.address, let port = self.server?.port, let referenceNumber = downloadReferenceNumber, let transferSize = downloadTransferSize { - self.bannerClient = HotlineFileClient(address: address, port: UInt16(port), reference: referenceNumber, size: UInt32(transferSize), type: .banner) - self.bannerClient?.downloadToMemory({ [weak self] data in - if let b = self?.bannerClient { - b.disconnect() - self?.bannerClient = nil - } - - if data != nil { - #if os(macOS) - self?.bannerImage = NSImage(data: data!) - #elseif os(iOS) - self?.bannerImage = UIImage(data: data!) - #endif - } - - callback?(data != nil) - }) + self.bannerClient = HotlineFileClient(address: address, port: UInt16(port), reference: referenceNumber, size: UInt32(transferSize), type: .preview) + self.bannerClient?.downloadToMemory() + +// self.bannerClient?.downloadToMemory({ [weak self] data in +// if let b = self?.bannerClient { +// b.disconnect() +// self?.bannerClient = nil +// } +// +// if data != nil { +// #if os(macOS) +// self?.bannerImage = NSImage(data: data!) +// #elseif os(iOS) +// self?.bannerImage = UIImage(data: data!) +// #endif +// } +// +// callback?(data != nil) +// }) } }) } // MARK: - Hotline Delegate - func hotlineStatusChanged(status: HotlineClientStatus) { + @MainActor func hotlineStatusChanged(status: HotlineClientStatus) { print("Hotline: Connection status changed to: \(status)") if status == .disconnected { @@ -464,11 +578,12 @@ import SwiftUI self.newsLoaded = false self.bannerImage = nil - if let b = self.bannerClient { - b.disconnect() + b.cancel() self.bannerClient = nil } + + self.deleteAllTransfers() } self.status = status @@ -539,6 +654,80 @@ import SwiftUI } + // MARK: - Hotline File Delegate + + func hotlineFileStatusChanged(client: HotlineFileClient, reference: UInt32, status: HotlineFileClientStatus, timeRemaining: TimeInterval) { + switch status { + case .unconnected: + break + case .connecting: + break + case .connected: + break + case .progress(let progress): + if let transfer = self.transfers.first(where: { $0.id == reference }) { + transfer.progress = progress + transfer.timeRemaining = timeRemaining + } + case .failed(_): + if let i = self.downloads.firstIndex(where: { $0.referenceNumber == reference }) { + self.downloads.remove(at: i) + } + if let transfer = self.transfers.first(where: { $0.id == reference }) { + transfer.failed = true + transfer.timeRemaining = 0.0 + } + if let b = self.bannerClient, reference == b.referenceNumber { + b.delegate = nil + self.bannerClient = nil + } + case .completed: + if let transfer = self.transfers.first(where: { $0.id == reference }) { + transfer.completed = true + transfer.timeRemaining = 0.0 + } + break + } + } + + func hotlineFileReceivedInfo(client: HotlineFileClient, reference: UInt32, info: HotlineFileInfoFork) { + if let transfer = self.transfers.first(where: { $0.id == reference }) { + transfer.title = info.name + } + } + + func hotlineFileDownloadedData(client: HotlineFileClient, reference: UInt32, data: Data) { + if let b = self.bannerClient, reference == b.referenceNumber { + #if os(macOS) + self.bannerImage = NSImage(data: data) + #elseif os(iOS) + self.bannerImage = UIImage(data: data) + #endif + } + else + if let i = self.transfers.firstIndex(where: { $0.id == reference }) { + let transfer = self.transfers[i] + transfer.previewCallback?(transfer, data) + self.transfers.remove(at: i) + } + + if let i = self.downloads.firstIndex(where: { $0.referenceNumber == reference }) { + self.downloads.remove(at: i) + } + } + + func hotlineFileDownloadedFile(client: HotlineFileClient, reference: UInt32, at: URL) { + if let i = self.transfers.firstIndex(where: { $0.id == reference }) { + let transfer = self.transfers[i] + transfer.fileURL = at + transfer.downloadCallback?(transfer, at) + } + + if let i = self.downloads.firstIndex(where: { $0.referenceNumber == reference }) { + self.downloads.remove(at: i) + } + } + // MARK: - Utilities func updateServerTitle() { diff --git a/Hotline/Models/Server.swift b/Hotline/Models/Server.swift index b39f1c4..2275036 100644 --- a/Hotline/Models/Server.swift +++ b/Hotline/Models/Server.swift @@ -1,29 +1,35 @@ import SwiftUI -struct Server: Identifiable, Equatable, Hashable, Codable { +struct Server: Codable { static let defaultPort: Int = 5500 - let id: UUID - let name: String? - let description: String? - let users: Int - let address: String - let port: Int + var name: String? + var description: String? + var users: Int + var address: String + var port: Int init(name: String?, description: String?, address: String, port: Int, users: Int = 0) { - self.id = UUID() self.name = name self.description = description self.address = address.lowercased() self.port = port self.users = users } - - func hash(into hasher: inout Hasher) { - hasher.combine(self.id) - } - +} + +extension Server: Identifiable { + var id: String { "\(address):\(port)" } +} + +extension Server: Equatable { static func == (lhs: Server, rhs: Server) -> Bool { return (lhs.address == rhs.address) && (lhs.port == rhs.port) } } + +extension Server: Hashable { + func hash(into hasher: inout Hasher) { + hasher.combine(self.id) + } +} diff --git a/Hotline/Models/TransferInfo.swift b/Hotline/Models/TransferInfo.swift new file mode 100644 index 0000000..546efd3 --- /dev/null +++ b/Hotline/Models/TransferInfo.swift @@ -0,0 +1,33 @@ +import SwiftUI + +@Observable +class TransferInfo: Identifiable, Equatable, Hashable { + var id: UInt32 + + var title: String + var size: UInt + var progress: Double = 0.0 + var timeRemaining: TimeInterval = 0.0 + var completed: Bool = false + var failed: Bool = false + + // For file based transfers (i.e. not previews) + var fileURL: URL? = nil + + var downloadCallback: ((TransferInfo, URL) -> Void)? = nil + var previewCallback: ((TransferInfo, Data) -> Void)? = nil + + init(id: UInt32, title: String, size: UInt) { + self.id = id + self.title = title + self.size = size + } + + static func == (lhs: TransferInfo, rhs: TransferInfo) -> Bool { + return lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(self.id) + } +} diff --git a/Hotline/Models/User.swift b/Hotline/Models/User.swift index 1a921e2..7fbecb0 100644 --- a/Hotline/Models/User.swift +++ b/Hotline/Models/User.swift @@ -8,11 +8,14 @@ struct UserStatus: OptionSet { } struct User: Identifiable { - let id: UInt + var id: UInt var name: String var iconID: UInt var status: UserStatus + var isAdmin: Bool { self.status.contains(.admin) } + var isIdle: Bool { self.status.contains(.idle) } + init(hotlineUser: HotlineUser) { var status: UserStatus = UserStatus() if hotlineUser.isIdle { status.update(with: .idle) } diff --git a/Hotline/Utility/FoundationExtensions.swift b/Hotline/Utility/FoundationExtensions.swift index 3797dea..3183b39 100644 --- a/Hotline/Utility/FoundationExtensions.swift +++ b/Hotline/Utility/FoundationExtensions.swift @@ -50,12 +50,23 @@ extension Data { } return withUnsafeBytes { $0.load(as: UInt64.self ) } + } + + func readDate(at offset: Int) -> Date? { + guard offset >= 0, offset + 2 + 2 + 4 <= self.count else { + return nil + } -// return 0 + if + let year = self.readUInt16(at: offset), + let ms = self.readUInt16(at: offset + 2), + let secs = self.readUInt32(at: offset + 2 + 2) { + return convertHotlineDate(year: year, seconds: secs, milliseconds: ms) + } -// return (UInt64(self[offset]) << 56) + (UInt64(self[offset + 1]) << 48) + (UInt64(self[offset + 2]) << 40) + (UInt64(self[offset + 3]) << 32) + (UInt64(self[offset + 4]) << 24) + (UInt64(self[offset + 5]) << 16) + (UInt64(self[offset + 6]) << 8) + UInt64(self[offset + 7]) + return nil } - + func readData(at offset: Int, length: Int) -> Data? { guard offset >= 0, offset + length <= self.count else { return nil @@ -96,13 +107,28 @@ extension Data { } func readPString(at offset: Int) -> (String?, Int) { + guard offset >= 0, offset + 1 <= self.count else { + return (nil, 0) + } let len = Int(self.readUInt8(at: offset)!) + guard offset + 1 + len <= self.count else { + return (nil, 0) + } return (self.readString(at: offset+1, length: len), 1 + len) } func readLongPString(at offset: Int) -> (String?, Int) { + guard offset >= 0, offset + 2 <= self.count else { + return (nil, 0) + } let len = Int(self.readUInt16(at: offset)!) - return (self.readString(at: offset+2, length: len), 2 + len) + guard len > 0 else { + return ("", 0) + } + guard offset + 2 + len <= self.count else { + return (nil, 0) + } + return (self.readString(at: offset+2, length: len), len) } @@ -128,14 +154,24 @@ extension Data { } } -extension UInt32 { - func toStringLiteral() -> String { +extension String { + func fourCharCode() -> FourCharCode { + guard self.count == 4 else { + return 0 + } + + return self.utf16.reduce(0, {$0 << 8 + FourCharCode($1)}) + } +} + +extension FourCharCode { + func fourCharCode() -> String { let bytes = [ UInt8((self >> 24) & 0xFF), UInt8((self >> 16) & 0xFF), UInt8((self >> 8) & 0xFF), UInt8(self & 0xFF) ] - return String(bytes: bytes, encoding: .utf8) ?? "" + return String(bytes: bytes, encoding: .ascii) ?? "" } } diff --git a/Hotline/iOS/ChatView.swift b/Hotline/iOS/ChatView.swift index e56f3f1..02fd6dc 100644 --- a/Hotline/iOS/ChatView.swift +++ b/Hotline/iOS/ChatView.swift @@ -86,7 +86,6 @@ struct ChatView: View { print("SCROLLED TO BOTTOM") } .onAppear { - print("SCROLLED TO BOTTOM ON APPEAR") reader.scrollTo(bottomID, anchor: .bottom) } .scrollDismissesKeyboard(.interactively) diff --git a/Hotline/macOS/ChatView.swift b/Hotline/macOS/ChatView.swift index 618fd12..4399de6 100644 --- a/Hotline/macOS/ChatView.swift +++ b/Hotline/macOS/ChatView.swift @@ -110,7 +110,6 @@ struct ChatView: View { print("SCROLLED TO BOTTOM") } .onAppear { - print("SCROLLED TO BOTTOM ON APPEAR") reader.scrollTo(bottomID, anchor: .bottom) // focusedField = .chatInput } diff --git a/Hotline/macOS/FilesView.swift b/Hotline/macOS/FilesView.swift index 771836d..09354ed 100644 --- a/Hotline/macOS/FilesView.swift +++ b/Hotline/macOS/FilesView.swift @@ -138,6 +138,10 @@ struct FilesView: View { if clickedFile.isFolder { clickedFile.expanded.toggle() } + else { + print("DOWNLOAD FILE", clickedFile.name, clickedFile.path) + model.downloadFile(clickedFile.name, path: clickedFile.path) + } } .onKeyPress(.rightArrow) { if let s = selection, s.isFolder { @@ -175,6 +179,12 @@ struct FilesView: View { ToolbarItem(placement: .primaryAction) { Button { + if let s = selection, !s.isFolder { + print("DOWNLOAD FILE", s.name, s.path) + model.previewFile(s.name, path: s.path, addTransfer: true) { transfer, fileData in + print("FILE PREVIEWED?", fileData.count) + } + } } label: { Label("Preview File", systemImage: "eye") } @@ -191,6 +201,10 @@ struct FilesView: View { ToolbarItem(placement: .primaryAction) { Button { + if let s = selection, !s.isFolder { + print("DOWNLOAD FILE", s.name, s.path) + model.downloadFile(s.name, path: s.path) + } } label: { Label("Download File", systemImage: "square.and.arrow.down") } diff --git a/Hotline/macOS/MessageView.swift b/Hotline/macOS/MessageView.swift index 19b48f2..e880435 100644 --- a/Hotline/macOS/MessageView.swift +++ b/Hotline/macOS/MessageView.swift @@ -88,7 +88,6 @@ struct MessageView: View { print("SCROLLED TO BOTTOM") } .onAppear { - print("SCROLLED TO BOTTOM ON APPEAR") reader.scrollTo(bottomID, anchor: .bottom) } .scrollDismissesKeyboard(.interactively) diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index 337882b..7d89481 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -1,4 +1,5 @@ import SwiftUI +import UniformTypeIdentifiers enum MenuItemType { case banner @@ -51,47 +52,108 @@ struct ListItemView: View { } } -struct AgreementView: View { - @Environment(\.dismiss) var dismiss +struct TransferItemView: View { + let transfer: TransferInfo - let text: String - let disagree: (() -> Void)? - let agree: (() -> Void)? + private func fileIcon(name: String) -> Image { + let fileExtension = (name as NSString).pathExtension + return Image(nsImage: NSWorkspace.shared.icon(for: UTType(filenameExtension: fileExtension) ?? UTType.content)) + } + + @Environment(Hotline.self) private var model: Hotline + @State private var hovered: Bool = false + @State private var buttonHovered: Bool = false + + private func formattedProgressHelp() -> String { + if self.transfer.completed { + return "File transfer complete" + } + else if self.transfer.failed { + return "File transfer failed" + } + else if self.transfer.progress > 0.0 { + if self.transfer.timeRemaining > 0.0 { + return "\(round(self.transfer.progress * 100.0))% β \(self.transfer.timeRemaining) seconds left" + } + else { + return "\(round(self.transfer.progress * 100.0))% complete" + } + } + return "" + } var body: some View { - VStack(spacing: 0) { - ScrollView(.vertical) { - Text(text) - .padding() - .font(.system(size: 13)) - .fontDesign(.monospaced) - .textSelection(.enabled) - .lineSpacing(3) - .frame(maxWidth: .infinity, alignment: .leading) + HStack(alignment: .center) { + HStack(spacing: 0) { + Spacer() + fileIcon(name: transfer.title) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 16, height: 16) + Spacer() } - .frame(maxWidth: .infinity) - Divider() - HStack(spacing: 8) { - Button("Disagree", action: { - dismiss() - disagree?() - }) - .controlSize(.large) - .frame(width: 80) - .keyboardShortcut(.cancelAction) - - Button("Agree", action: { - dismiss() - agree?() - }) - .controlSize(.large) - .frame(width: 80) - .keyboardShortcut(.defaultAction) + .frame(width: 18) + + Text(transfer.title) + .lineLimit(1) + .truncationMode(.middle) + + Spacer() + + if self.hovered { + Button { + model.deleteTransfer(id: transfer.id) + } label: { + if transfer.completed { + Image(systemName: self.buttonHovered ? "xmark.circle.fill" : "xmark.circle") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 16, height: 16) + .opacity(self.buttonHovered ? 1.0 : 0.5) + } + else { + Image(systemName: self.buttonHovered ? "trash.circle.fill" : "trash.circle") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 16, height: 16) + .opacity(self.buttonHovered ? 1.0 : 0.5) + } + } + .buttonStyle(.plain) + .padding(0) + .frame(width: 16, height: 16) + .help(transfer.completed || transfer.failed ? "Remove" : "Cancel Transfer") + .onHover { hovered in + self.buttonHovered = hovered + } + } + else if transfer.failed { + Image(systemName: "exclamationmark.triangle") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 16, height: 16) + } + else if transfer.completed { + Image(systemName: "checkmark.circle.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 16, height: 16) + } + else if transfer.progress == 0.0 { + ProgressView() + .progressViewStyle(.circular) + .controlSize(.small) } - .frame(maxWidth: .infinity) - .padding(16) + else { + ProgressView(value: transfer.progress, total: 1.0) + .progressViewStyle(.circular) + .controlSize(.small) + } + } + .onHover { hovered in + self.hovered = hovered } - .frame(width: 500, height: 500) + .help(formattedProgressHelp()) } } @@ -126,7 +188,6 @@ struct ServerView: View { MenuItem(name: "News", image: "newspaper", type: .news, serverVersion: 150), MenuItem(name: "Board", image: "note.text", type: .messageBoard), MenuItem(name: "Files", image: "folder", type: .files), -// MenuItem(name: "Tasks", image: "arrow.up.circle", type: .tasks), ] @MainActor func sendPreferences() { @@ -151,78 +212,118 @@ struct ServerView: View { } } - var body: some View { - NavigationSplitView { - List(selection: $selection) { - - if model.status != .loggedIn { - HStack { - ProgressView(value: connectionStatusToProgress(status: model.status)) - .padding() + var navigationList: some View { + List(selection: $selection) { + + if model.status != .loggedIn { + HStack { + ProgressView(value: connectionStatusToProgress(status: model.status)) + .padding() + } + .tag(MenuItem(name: "progress", image: "", type: .progress)) + .frame(maxWidth: .infinity, minHeight: 60) + .selectionDisabled() + } + + if model.status == .loggedIn { + ForEach(ServerView.menuItems) { menuItem in + if let minServerVersion = menuItem.serverVersion { + if let v = model.serverVersion, v >= minServerVersion { + ListItemView(icon: menuItem.image, title: menuItem.name) + .tag(menuItem) + } + } + else { + ListItemView(icon: menuItem.image, title: menuItem.name) + .tag(menuItem) } - .tag(MenuItem(name: "progress", image: "", type: .progress)) - .frame(maxWidth: .infinity, minHeight: 60) - .selectionDisabled() } - if model.status == .loggedIn { - ForEach(ServerView.menuItems) { menuItem in - if let minServerVersion = menuItem.serverVersion { - if let v = model.serverVersion, v >= minServerVersion { - ListItemView(icon: menuItem.image, title: menuItem.name) - .tag(menuItem) - } + if model.transfers.count > 0 { + self.transfersSection + } + + if model.users.count > 0 { + self.usersSection + } + } + } + } + + var transfersSection: some View { + Section("Transfers") { + ForEach(model.transfers) { transfer in + TransferItemView(transfer: transfer) + } + } + } + + var usersSection: some View { + Section("Users") { + ForEach(model.users) { user in + HStack { + if let iconString = Hotline.defaultIconSet[Int(user.iconID)] { + Text(iconString) + .font(.headline) + .frame(width: 18) + .opacity(controlActiveState == .inactive ? 0.5 : 1.0) + } + else { + Text("") + .frame(width: 18) + } + + if user.isAdmin { + if user.isIdle { + Text(user.name) + .foregroundStyle(.red) + .opacity(controlActiveState == .inactive ? 0.3 : 0.5) } else { - ListItemView(icon: menuItem.image, title: menuItem.name) - .tag(menuItem) + Text(user.name) + .foregroundStyle(.red) + .opacity(controlActiveState == .inactive ? 0.5 : 1.0) } } - - if model.users.count > 0 { - Section("Users") { - ForEach(model.users) { user in - HStack { - if let iconString = Hotline.defaultIconSet[Int(user.iconID)] { - Text(iconString) - .font(.headline) - .frame(width: 18) - .opacity(controlActiveState == .inactive ? 0.5 : 1.0) - } - else { - Text("") - .frame(width: 18) - } - - if user.status.contains(.admin) { - if user.status.contains(.idle) { - Text(user.name) - .foregroundStyle(.red) - .opacity(controlActiveState == .inactive ? 0.3 : 0.5) - } - else { - Text(user.name) - .foregroundStyle(.red) - .opacity(controlActiveState == .inactive ? 0.5 : 1.0) - } - } - else if user.status.contains(.idle) { - Text(user.name) - .opacity(controlActiveState == .inactive ? 0.3 : 0.5) - } - else { - Text(user.name) + else if user.isIdle { + Text(user.name) + .opacity(controlActiveState == .inactive ? 0.3 : 0.5) + } + else { + Text(user.name) // .opacity(controlActiveState == .inactive ? 0.5 : 1.0) - } - Spacer() - } - .tag(MenuItem(name: user.name, image: "", type: .user, userID: user.id)) - } - } } + Spacer() } + .tag(MenuItem(name: user.name, image: "", type: .user, userID: user.id)) } - .frame(minWidth: 200, idealWidth: 200) + } + } + + var body: some View { + NavigationSplitView { + + self.navigationList + .frame(maxWidth: .infinity) + .navigationSplitViewColumnWidth(min: 150, ideal: 200, max: 500) + +// VSplitView { +// +// self.navigationList +// +// ScrollView { +// VStack(alignment: .leading) { +// // ForEach(model.downloads) { +// // } +// } +// .frame(maxWidth: .infinity) +// } +// +// } +// .frame(maxWidth: .infinity) +// .navigationSplitViewColumnWidth(min: 150, ideal: 200, max: 500) + + } detail: { if let selection = self.selection { switch selection.type { @@ -234,18 +335,22 @@ struct ServerView: View { ChatView() .navigationTitle(self.model.serverTitle) .navigationSubtitle(self.model.users.count > 0 ? "^[\(self.model.users.count) user](inflect: true) online" : "") + .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .news: NewsView() .navigationTitle(self.model.serverTitle) .navigationSubtitle(self.model.users.count > 0 ? "^[\(self.model.users.count) user](inflect: true) online" : "") + .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .messageBoard: MessageBoardView() .navigationTitle(self.model.serverTitle) .navigationSubtitle(self.model.users.count > 0 ? "^[\(self.model.users.count) user](inflect: true) online" : "") + .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .files: FilesView() .navigationTitle(self.model.serverTitle) .navigationSubtitle(self.model.users.count > 0 ? "^[\(self.model.users.count) user](inflect: true) online" : "") + .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .tasks: EmptyView() case .user: @@ -253,13 +358,13 @@ struct ServerView: View { MessageView(userID: selectionUserID) .navigationTitle(self.model.serverTitle) .navigationSubtitle(self.model.users.count > 0 ? "^[\(self.model.users.count) user](inflect: true) online" : "") + .navigationSplitViewColumnWidth(min: 250, ideal: 500) } } } } .navigationTitle("") .onAppear { - print(" YAYY") self.model.login(server: self.server, login: "", password: "", username: preferences.username, iconID: preferences.userIconID) { success in if !success { print("FAILED LOGIN??") diff --git a/Hotline/macOS/TrackerView.swift b/Hotline/macOS/TrackerView.swift index 789b2cd..3870a38 100644 --- a/Hotline/macOS/TrackerView.swift +++ b/Hotline/macOS/TrackerView.swift @@ -13,7 +13,7 @@ struct TrackerBookmark { @Observable class TrackerItem: Identifiable, Hashable { - @ObservationIgnored let id: UUID = UUID() + let id: UUID = UUID() let bookmark: TrackerBookmark? let server: Server? @@ -86,8 +86,6 @@ class TrackerItem: Identifiable, Hashable { } struct TrackerItemView: View { -// @Environment(Hotline.self) private var model: Hotline - @State var expanded = false @State var loading = false |