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/Library/NetSocket/FileProgress.swift | 58 ++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 Hotline/Library/NetSocket/FileProgress.swift (limited to 'Hotline/Library/NetSocket/FileProgress.swift') 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) + } + } + } +} -- cgit From 786a387d58fc66ae20716a9dee46b74dff8e6894 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 7 Nov 2025 11:35:59 -0800 Subject: Rename NetSocketNew as NetSocket (old CFStream based NetSocket is gone!) --- Hotline/Hotline/HotlineClientNew.swift | 6 ++-- Hotline/Hotline/HotlineExtensions.swift | 4 +-- Hotline/Hotline/HotlineProtocol.swift | 6 ++-- Hotline/Hotline/HotlineTrackerClient.swift | 2 +- .../Transfers/HotlineFileDownloadClientNew.swift | 8 ++--- .../Transfers/HotlineFilePreviewClientNew.swift | 2 +- .../Transfers/HotlineFileUploadClientNew.swift | 6 ++-- .../Transfers/HotlineFolderDownloadClientNew.swift | 12 +++---- .../Transfers/HotlineFolderUploadClientNew.swift | 10 +++--- Hotline/Library/NetSocket/FileProgress.swift | 2 +- Hotline/Library/NetSocket/NetSocketNew.swift | 38 ++++++++++------------ .../Library/NetSocket/TransferRateEstimator.swift | 8 ++--- Hotline/macOS/ServerView.swift | 1 + 13 files changed, 52 insertions(+), 53 deletions(-) (limited to 'Hotline/Library/NetSocket/FileProgress.swift') diff --git a/Hotline/Hotline/HotlineClientNew.swift b/Hotline/Hotline/HotlineClientNew.swift index 9e60b5e..8642d42 100644 --- a/Hotline/Hotline/HotlineClientNew.swift +++ b/Hotline/Hotline/HotlineClientNew.swift @@ -126,7 +126,7 @@ public struct HotlineServerInfo: Sendable { public actor HotlineClientNew { // MARK: - Properties - private let socket: NetSocketNew + private let socket: NetSocket private var serverInfo: HotlineServerInfo? private var isConnected: Bool = true @@ -188,7 +188,7 @@ public actor HotlineClientNew { // Connect socket print("HotlineClientNew.connect(): Connecting socket...") - let socket = try await NetSocketNew.connect(host: host, port: port, tls: tls) + let socket = try await NetSocket.connect(host: host, port: port, tls: tls) print("HotlineClientNew.connect(): Socket connected") // Perform handshake @@ -240,7 +240,7 @@ public actor HotlineClientNew { return client } - private init(socket: NetSocketNew) { + private init(socket: NetSocket) { self.socket = socket // Set up event stream diff --git a/Hotline/Hotline/HotlineExtensions.swift b/Hotline/Hotline/HotlineExtensions.swift index 16e9583..049ebf9 100644 --- a/Hotline/Hotline/HotlineExtensions.swift +++ b/Hotline/Hotline/HotlineExtensions.swift @@ -1107,7 +1107,7 @@ extension FourCharCode { } -extension NetSocketNew { +extension NetSocket { /// Read a pascal string (1-byte length prefix followed by string data) /// /// This method reads a single byte for the length, then reads that many bytes and attempts @@ -1145,7 +1145,7 @@ extension NetSocketNew { // Fallback to MacRoman for classic Mac compatibility guard let str = String(data: data, encoding: .macOSRoman) else { throw NetSocketError.decodeFailed(NSError( - domain: "NetSocketNew", + domain: "NetSocket", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to decode pascal string with any known encoding"] )) diff --git a/Hotline/Hotline/HotlineProtocol.swift b/Hotline/Hotline/HotlineProtocol.swift index 43f018e..927cf69 100644 --- a/Hotline/Hotline/HotlineProtocol.swift +++ b/Hotline/Hotline/HotlineProtocol.swift @@ -188,7 +188,7 @@ struct HotlineServer: Identifiable, Hashable, NetSocketDecodable { hasher.combine(self.id) } - init(from socket: NetSocketNew, endian: Endian) async throws { + init(from socket: NetSocket, endian: Endian) async throws { // Read IP address (4 individual bytes) let ip1 = try await socket.read(UInt8.self) let ip2 = try await socket.read(UInt8.self) @@ -986,7 +986,7 @@ extension HotlineTransaction: NetSocketDecodable { /// Decode a Hotline transaction directly from the socket stream /// /// Reads the 20-byte header, then reads and decodes the variable-length body with fields. - init(from socket: NetSocketNew, endian: Endian) async throws { + init(from socket: NetSocket, endian: Endian) async throws { // Read 20-byte header let flags = try await socket.read(UInt8.self) let isReply = try await socket.read(UInt8.self) @@ -1173,7 +1173,7 @@ enum HotlineTransactionType: UInt16 { // /// - Unused: 2 bytes // /// - Server name: Pascal string (1-byte length + data) // /// - Server description: Pascal string (1-byte length + data) -// init(from socket: NetSocketNew, endian: Endian) async throws { +// init(from socket: NetSocket, endian: Endian) async throws { // // Read IP address (4 individual bytes) // let ip1 = try await socket.read(UInt8.self) // let ip2 = try await socket.read(UInt8.self) diff --git a/Hotline/Hotline/HotlineTrackerClient.swift b/Hotline/Hotline/HotlineTrackerClient.swift index 52d34c1..f72735d 100644 --- a/Hotline/Hotline/HotlineTrackerClient.swift +++ b/Hotline/Hotline/HotlineTrackerClient.swift @@ -72,7 +72,7 @@ class HotlineTrackerClient { private func doFetch(address: String, port: Int, continuation: AsyncThrowingStream.Continuation) async throws { // Connect to tracker (plaintext, no TLS) - let socket = try await NetSocketNew.connect( + let socket = try await NetSocket.connect( host: address, port: UInt16(port), tls: .disabled diff --git a/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift index ced79a1..77d2e38 100644 --- a/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift @@ -39,7 +39,7 @@ public class HotlineFileDownloadClientNew { private let transferTotal: Int private var transferProgress: Progress - private var socket: NetSocketNew? + private var socket: NetSocket? private var downloadTask: Task? // MARK: - Initialization @@ -251,14 +251,14 @@ public class HotlineFileDownloadClientNew { // MARK: - Helper Methods - private func connectToTransferServer() async throws -> NetSocketNew { + private func connectToTransferServer() async throws -> NetSocket { guard let transferPort = NWEndpoint.Port(rawValue: serverPort + 1) else { throw NetSocketError.invalidPort } print("HotlineFileDownloadClientNew[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)") - let socket = try await NetSocketNew.connect( + let socket = try await NetSocket.connect( host: .name(serverAddress, nil), port: transferPort, tls: .disabled @@ -268,7 +268,7 @@ public class HotlineFileDownloadClientNew { return socket } - private func sendMagicHeader(socket: NetSocketNew) async throws { + private func sendMagicHeader(socket: NetSocket) async throws { let header = Data(endian: .big) { "HTXF".fourCharCode() referenceNumber diff --git a/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift b/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift index 9839997..63fe099 100644 --- a/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift @@ -101,7 +101,7 @@ public class HotlineFilePreviewClientNew { print("HotlineFilePreviewClientNew[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)") - let socket = try await NetSocketNew.connect( + let socket = try await NetSocket.connect( host: .name(serverAddress, nil), port: transferPort, tls: .disabled diff --git a/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift index 10934e7..5acff2a 100644 --- a/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift @@ -24,7 +24,7 @@ public class HotlineFileUploadClientNew { private let transferTotal: Int private var transferProgress: Progress - private var socket: NetSocketNew? + private var socket: NetSocket? private var uploadTask: Task? // MARK: - Initialization @@ -236,14 +236,14 @@ public class HotlineFileUploadClientNew { // MARK: - Helper Methods - private func connectToTransferServer() async throws -> NetSocketNew { + private func connectToTransferServer() async throws -> NetSocket { guard let transferPort = NWEndpoint.Port(rawValue: serverPort + 1) else { throw NetSocketError.invalidPort } print("HotlineFileUploadClientNew[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)") - let socket = try await NetSocketNew.connect( + let socket = try await NetSocket.connect( host: .name(serverAddress, nil), port: transferPort, tls: .disabled diff --git a/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift index 4ad35e7..254a1c6 100644 --- a/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift @@ -20,7 +20,7 @@ public class HotlineFolderDownloadClientNew { private let folderItemCount: Int private var transferSize: Int = 0 - private var socket: NetSocketNew? + private var socket: NetSocket? private var downloadTask: Task? private var folderProgress: Progress? @@ -245,14 +245,14 @@ public class HotlineFolderDownloadClientNew { // MARK: - Helper Methods - private func connectToTransferServer() async throws -> NetSocketNew { + private func connectToTransferServer() async throws -> NetSocket { guard let transferPort = NWEndpoint.Port(rawValue: serverPort + 1) else { throw NetSocketError.invalidPort } print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)") - let socket = try await NetSocketNew.connect( + let socket = try await NetSocket.connect( host: .name(serverAddress, nil), port: transferPort, tls: .disabled @@ -262,7 +262,7 @@ public class HotlineFolderDownloadClientNew { return socket } - private func sendAction(socket: NetSocketNew, action: HotlineFolderAction) async throws { + private func sendAction(socket: NetSocket, action: HotlineFolderAction) async throws { let actionData = Data(endian: .big) { action.rawValue } @@ -297,7 +297,7 @@ public class HotlineFolderDownloadClientNew { } private func downloadFile( - socket: NetSocketNew, + socket: NetSocket, fileName: String, parentPath: [String], destinationFolder: URL, @@ -383,7 +383,7 @@ public class HotlineFolderDownloadClientNew { fileDataForkSize = Int(forkHeader.dataSize) - // Stream data fork using NetSocketNew's optimized file streaming + // Stream data fork using NetSocket's optimized file streaming let updates = await socket.receiveFile(to: fh, length: fileDataForkSize) for try await p in updates { // Calculate overall folder progress diff --git a/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift index 2efced2..38532f1 100644 --- a/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift @@ -38,7 +38,7 @@ public class HotlineFolderUploadClientNew { private var folderItems: [FolderItem] = [] private var totalItems: Int = 0 - private var socket: NetSocketNew? + private var socket: NetSocket? private var uploadTask: Task? // MARK: - Initialization @@ -281,12 +281,12 @@ public class HotlineFolderUploadClientNew { progressHandler?(.completed(url: nil)) } - private func connect(address: String, port: UInt16) async throws -> NetSocketNew { + private func connect(address: String, port: UInt16) async throws -> NetSocket { guard let transferPort = NWEndpoint.Port(rawValue: port + 1) else { throw NetSocketError.invalidPort } - return try await NetSocketNew.connect( + return try await NetSocket.connect( host: .name(address, nil), port: transferPort, tls: .disabled @@ -361,7 +361,7 @@ public class HotlineFolderUploadClientNew { } } - private func readAction(socket: NetSocketNew) async throws -> HotlineFolderAction { + private func readAction(socket: NetSocket) async throws -> HotlineFolderAction { let actionData = try await socket.read(2) guard let rawAction = actionData.readUInt16(at: 0), let action = HotlineFolderAction(rawValue: rawAction) else { @@ -371,7 +371,7 @@ public class HotlineFolderUploadClientNew { } private func uploadFile( - socket: NetSocketNew, + socket: NetSocket, fileURL: URL, itemNumber: Int, totalItems: Int, diff --git a/Hotline/Library/NetSocket/FileProgress.swift b/Hotline/Library/NetSocket/FileProgress.swift index c086af7..1cc0228 100644 --- a/Hotline/Library/NetSocket/FileProgress.swift +++ b/Hotline/Library/NetSocket/FileProgress.swift @@ -4,7 +4,7 @@ import Foundation -public extension NetSocketNew { +public extension NetSocket { /// Progress information for file uploads/downloads struct FileProgress: Sendable { diff --git a/Hotline/Library/NetSocket/NetSocketNew.swift b/Hotline/Library/NetSocket/NetSocketNew.swift index 7b24b37..12f4271 100644 --- a/Hotline/Library/NetSocket/NetSocketNew.swift +++ b/Hotline/Library/NetSocket/NetSocketNew.swift @@ -1,4 +1,4 @@ -// NetSocketNew.swift +// NetSocket.swift // Dustin Mierau • @mierau import Foundation @@ -86,11 +86,9 @@ public enum NetSocketError: Error, CustomStringConvertible, Sendable { } } -// MARK: - NetSocketNew - /// An async/await TCP socket with automatic buffering and framing support /// -/// NetSocketNew provides: +/// NetSocket provides: /// - Async connection management /// - Automatic receive buffering with memory compaction /// - Type-safe reading/writing of integers, strings, and custom types @@ -98,11 +96,11 @@ public enum NetSocketError: Error, CustomStringConvertible, Sendable { /// /// Example usage: /// ```swift -/// let socket = try await NetSocketNew.connect(host: "example.com", port: 80) +/// let socket = try await NetSocket.connect(host: "example.com", port: 80) /// try await socket.write("Hello\n".data(using: .utf8)!) /// let response = try await socket.readUntil(delimiter: .lineFeed) /// ``` -public actor NetSocketNew { +public actor NetSocket { /// Configuration options for the socket public struct Config: Sendable { /// Size of chunks to receive from network at once (default: 64 KB) @@ -153,9 +151,9 @@ public actor NetSocketNew { /// - port: Network framework port /// - tls: TLS policy (default: enabled with default settings) /// - config: Socket configuration (default: standard settings) - /// - Returns: A connected and ready `NetSocketNew` + /// - Returns: A connected and ready `NetSocket` /// - Throws: Network errors or connection failures - public static func connect(host: NWEndpoint.Host, port: NWEndpoint.Port, tls: TLSPolicy = .enabled(), config: Config = .init()) async throws -> NetSocketNew { + public static func connect(host: NWEndpoint.Host, port: NWEndpoint.Port, tls: TLSPolicy = .enabled(), config: Config = .init()) async throws -> NetSocket { let parameters = NWParameters.tcp if tls.enabled { let tlsOptions = NWProtocolTLS.Options() @@ -164,13 +162,13 @@ public actor NetSocketNew { } let conn = NWConnection(host: host, port: port, using: parameters) - let socket = NetSocketNew(connection: conn, config: config) + let socket = NetSocket(connection: conn, config: config) try await socket.start() return socket } /// Convenience wrapper to connect using string hostname and integer port - public static func connect(host: String, port: UInt16, tls: TLSPolicy = .enabled(), config: Config = .init()) async throws -> NetSocketNew { + public static func connect(host: String, port: UInt16, tls: TLSPolicy = .enabled(), config: Config = .init()) async throws -> NetSocket { guard let nwPort = NWEndpoint.Port(rawValue: port) else { throw NetSocketError.invalidPort } @@ -506,7 +504,7 @@ public actor NetSocketNew { // 1. Open file and get length (blocking I/O, done off-actor) do { - total = Int(try NetSocketNew.fileLength(at: url)) + total = Int(try NetSocket.fileLength(at: url)) fh = try FileHandle(forReadingFrom: url) } catch { continuation.finish(throwing: NetSocketError.failed(underlying: error)) @@ -792,11 +790,11 @@ public actor NetSocketNew { } private func startReceiveLoop() { - @Sendable func loop(_ connection: NWConnection, chunk: Int, owner: NetSocketNew, connID: String) { - print("NetSocketNew[\(connID)]: Calling connection.receive(\(chunk)) to request more data...") + @Sendable func loop(_ connection: NWConnection, chunk: Int, owner: NetSocket, connID: String) { + print("NetSocket[\(connID)]: Calling connection.receive(\(chunk)) to request more data...") connection.receive(minimumIncompleteLength: 1, maximumLength: chunk) { [weak owner] data, _, isComplete, error in - print("NetSocketNew[\(connID)]: Receive callback - data: \(data?.count ?? 0) bytes, isComplete: \(isComplete), error: \(String(describing: error))") + print("NetSocket[\(connID)]: Receive callback - data: \(data?.count ?? 0) bytes, isComplete: \(isComplete), error: \(String(describing: error))") Task { guard let o = owner else { return @@ -810,7 +808,7 @@ public actor NetSocketNew { await o.append(data, connID: connID) } if isComplete { - print("NetSocketNew[\(connID)]: EOF from peer.") + print("NetSocket[\(connID)]: EOF from peer.") await o.handleEOF() return } @@ -952,7 +950,7 @@ public actor NetSocketNew { } private func append(_ data: Data, connID: String) { - print("NetSocketNew[\(connID)]: Received \(data.count) bytes from network, buffer now has \(buffer.count - head + data.count) available") + print("NetSocket[\(connID)]: Received \(data.count) bytes from network, buffer now has \(buffer.count - head + data.count) available") buffer.append(data) if buffer.count - head > config.maxBufferBytes { // Hard stop: drop connection rather than OOM'ing. @@ -1038,7 +1036,7 @@ public protocol NetSocketEncodable: Sendable { /// let id: UInt32 /// let name: String /// -/// init(from socket: NetSocketNew, endian: Endian) async throws { +/// init(from socket: NetSocket, endian: Endian) async throws { /// self.id = try await socket.read(UInt32.self, endian: endian) /// let nameLen = try await socket.read(UInt16.self, endian: endian) /// let nameData = try await socket.readExactly(Int(nameLen)) @@ -1064,10 +1062,10 @@ public protocol NetSocketDecodable: Sendable { /// - socket: Socket to read from /// - endian: Byte order for multi-byte values /// - Throws: Network errors, insufficient data, or custom decoding errors - init(from socket: NetSocketNew, endian: Endian) async throws + init(from socket: NetSocket, endian: Endian) async throws } -public extension NetSocketNew { +public extension NetSocket { /// Send an encodable value to the socket /// /// The type encodes itself to binary data, which is then sent in a single write operation. @@ -1109,7 +1107,7 @@ public extension NetSocketNew { /// let id: UInt32 /// let name: String /// - /// init(from socket: NetSocketNew, endian: Endian) async throws { + /// init(from socket: NetSocket, endian: Endian) async throws { /// self.id = try await socket.read(UInt32.self, endian: endian) /// // Read variable-length string... /// } diff --git a/Hotline/Library/NetSocket/TransferRateEstimator.swift b/Hotline/Library/NetSocket/TransferRateEstimator.swift index 7d16904..60647a7 100644 --- a/Hotline/Library/NetSocket/TransferRateEstimator.swift +++ b/Hotline/Library/NetSocket/TransferRateEstimator.swift @@ -70,7 +70,7 @@ public struct TransferRateEstimator { self.minSamples = minSamples } - public mutating func update(total: Int) -> NetSocketNew.FileProgress { + public mutating func update(total: Int) -> NetSocket.FileProgress { return self.update(bytes: max(0, total - self.transferred)) } @@ -80,7 +80,7 @@ public struct TransferRateEstimator { /// /// - Parameter bytes: Number of bytes transferred in this sample /// - Returns: Current progress with speed and ETA estimates - public mutating func update(bytes: Int) -> NetSocketNew.FileProgress { + public mutating func update(bytes: Int) -> NetSocket.FileProgress { let clock = ContinuousClock() let now = clock.now @@ -102,7 +102,7 @@ public struct TransferRateEstimator { let instantRate = Double(bytes) / seconds self.sampleCount += 1 - // Update EMA + // Update EMANetSocket if self.emaBytesPerSecond == 0 { self.emaBytesPerSecond = instantRate } else { @@ -124,7 +124,7 @@ public struct TransferRateEstimator { eta = nil } - return NetSocketNew.FileProgress( + return NetSocket.FileProgress( sent: self.transferred, total: self.total, bytesPerSecond: haveEstimate ? self.emaBytesPerSecond : nil, diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index df3e54b..a66a071 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -135,6 +135,7 @@ struct ServerView: View { Text(error) .font(.caption) .foregroundStyle(.secondary) + .multilineTextAlignment(.center) } .frame(maxWidth: 300) .padding() -- cgit From a55318fa8d643160900bec3e6b14e7404c63497a Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 7 Nov 2025 12:44:55 -0800 Subject: Organize Library folder a bit. Update Tracker add/edit sheet design. --- Hotline.xcodeproj/project.pbxproj | 72 +- Hotline/Hotline/HotlineProtocol.swift | 5 + Hotline/Library/AsyncLinkPreview.swift | 57 - Hotline/Library/BookmarkDocument.swift | 32 + Hotline/Library/ColorArt.swift | 424 ++++++++ Hotline/Library/DataAdditions.swift | 23 - Hotline/Library/Extensions.swift | 192 ++++ Hotline/Library/FileIconView.swift | 74 -- Hotline/Library/FileImageView.swift | 98 -- Hotline/Library/HotlinePanel.swift | 64 -- Hotline/Library/NetSocket/FileProgress.swift | 2 +- Hotline/Library/NetSocket/NetSocket.swift | 1123 +++++++++++++++++++ Hotline/Library/NetSocket/NetSocketNew.swift | 1127 -------------------- .../Library/NetSocket/TransferRateEstimator.swift | 2 +- Hotline/Library/QuickLookPreviewView.swift | 22 - Hotline/Library/RegularExpressions.swift | 235 ++++ Hotline/Library/SoundEffects.swift | 50 + Hotline/Library/SpinningGlobeView.swift | 90 -- Hotline/Library/TextView.swift | 119 --- Hotline/Library/URLAdditions.swift | 55 - Hotline/Library/Utility/DAKeychain.swift | 81 ++ Hotline/Library/Utility/NSWindowBridge.swift | 46 + Hotline/Library/Utility/ObservableScrollView.swift | 38 + Hotline/Library/Utility/TextDocument.swift | 31 + Hotline/Library/Views/AsyncLinkPreview.swift | 57 + Hotline/Library/Views/BetterTextEditor.swift | 119 +++ Hotline/Library/Views/FileIconView.swift | 74 ++ Hotline/Library/Views/FileImageView.swift | 98 ++ Hotline/Library/Views/GroupedIconView.swift | 23 + Hotline/Library/Views/HotlinePanel.swift | 64 ++ Hotline/Library/Views/QuickLookPreviewView.swift | 22 + Hotline/Library/Views/SpinningGlobeView.swift | 90 ++ Hotline/Library/Views/VisualEffectView.swift | 22 + Hotline/Library/VisualEffectView.swift | 22 - Hotline/Utility/BookmarkDocument.swift | 32 - Hotline/Utility/ColorArt.swift | 424 -------- Hotline/Utility/DAKeychain.swift | 81 -- Hotline/Utility/FoundationExtensions.swift | 107 -- Hotline/Utility/NSWindowBridge.swift | 46 - Hotline/Utility/ObservableScrollView.swift | 38 - Hotline/Utility/RegularExpressions.swift | 235 ---- Hotline/Utility/SoundEffects.swift | 50 - Hotline/Utility/SwiftUIExtensions.swift | 8 - Hotline/Utility/TextDocument.swift | 31 - Hotline/macOS/TrackerView.swift | 49 +- 45 files changed, 2900 insertions(+), 2854 deletions(-) delete mode 100644 Hotline/Library/AsyncLinkPreview.swift create mode 100644 Hotline/Library/BookmarkDocument.swift create mode 100644 Hotline/Library/ColorArt.swift delete mode 100644 Hotline/Library/DataAdditions.swift create mode 100644 Hotline/Library/Extensions.swift delete mode 100644 Hotline/Library/FileIconView.swift delete mode 100644 Hotline/Library/FileImageView.swift delete mode 100644 Hotline/Library/HotlinePanel.swift create mode 100644 Hotline/Library/NetSocket/NetSocket.swift delete mode 100644 Hotline/Library/NetSocket/NetSocketNew.swift delete mode 100644 Hotline/Library/QuickLookPreviewView.swift create mode 100644 Hotline/Library/RegularExpressions.swift create mode 100644 Hotline/Library/SoundEffects.swift delete mode 100644 Hotline/Library/SpinningGlobeView.swift delete mode 100644 Hotline/Library/TextView.swift delete mode 100644 Hotline/Library/URLAdditions.swift create mode 100644 Hotline/Library/Utility/DAKeychain.swift create mode 100644 Hotline/Library/Utility/NSWindowBridge.swift create mode 100644 Hotline/Library/Utility/ObservableScrollView.swift create mode 100644 Hotline/Library/Utility/TextDocument.swift create mode 100644 Hotline/Library/Views/AsyncLinkPreview.swift create mode 100644 Hotline/Library/Views/BetterTextEditor.swift create mode 100644 Hotline/Library/Views/FileIconView.swift create mode 100644 Hotline/Library/Views/FileImageView.swift create mode 100644 Hotline/Library/Views/GroupedIconView.swift create mode 100644 Hotline/Library/Views/HotlinePanel.swift create mode 100644 Hotline/Library/Views/QuickLookPreviewView.swift create mode 100644 Hotline/Library/Views/SpinningGlobeView.swift create mode 100644 Hotline/Library/Views/VisualEffectView.swift delete mode 100644 Hotline/Library/VisualEffectView.swift delete mode 100644 Hotline/Utility/BookmarkDocument.swift delete mode 100644 Hotline/Utility/ColorArt.swift delete mode 100644 Hotline/Utility/DAKeychain.swift delete mode 100644 Hotline/Utility/FoundationExtensions.swift delete mode 100644 Hotline/Utility/NSWindowBridge.swift delete mode 100644 Hotline/Utility/ObservableScrollView.swift delete mode 100644 Hotline/Utility/RegularExpressions.swift delete mode 100644 Hotline/Utility/SoundEffects.swift delete mode 100644 Hotline/Utility/SwiftUIExtensions.swift delete mode 100644 Hotline/Utility/TextDocument.swift (limited to 'Hotline/Library/NetSocket/FileProgress.swift') diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index cb621a0..c63109a 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -32,6 +32,7 @@ DA4B8F3A2EA6FB3C00CBFD53 /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4B8F392EA6FB3C00CBFD53 /* iOSApp.swift */; platformFilter = ios; }; DA4F2BF82B16A17200D8ADDC /* HotlineProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4F2BF72B16A17200D8ADDC /* HotlineProtocol.swift */; }; DA4F2C012B1A558E00D8ADDC /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4F2C002B1A558E00D8ADDC /* ChatView.swift */; platformFilter = ios; }; + DA501BE22EBE9018001714F8 /* GroupedIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA501BE12EBE9018001714F8 /* GroupedIconView.swift */; }; DA52689C2EB0738B00DCB941 /* GeneralSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA52689B2EB0738B00DCB941 /* GeneralSettingsView.swift */; platformFilters = (macos, ); }; DA52689E2EB073A400DCB941 /* IconSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA52689D2EB073A400DCB941 /* IconSettingsView.swift */; platformFilters = (macos, ); }; DA5268A02EB073BC00DCB941 /* SoundSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA52689F2EB073BC00DCB941 /* SoundSettingsView.swift */; platformFilters = (macos, ); }; @@ -55,7 +56,7 @@ DA65499A2BEC280E00EDB697 /* ServerMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6549992BEC280E00EDB697 /* ServerMessageView.swift */; }; DA65499C2BEC3FBD00EDB697 /* ServerAgreementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA65499B2BEC3FBD00EDB697 /* ServerAgreementView.swift */; }; DA65499E2BEC438A00EDB697 /* NSWindowBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA65499D2BEC438A00EDB697 /* NSWindowBridge.swift */; platformFilters = (macos, ); }; - DA6980792BF80525003E434B /* TextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6980782BF80525003E434B /* TextView.swift */; }; + DA6980792BF80525003E434B /* BetterTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6980782BF80525003E434B /* BetterTextEditor.swift */; }; DA69807E2BFD449B003E434B /* MessageBoardEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA69807D2BFD449B003E434B /* MessageBoardEditorView.swift */; }; DA6980832BFFD06C003E434B /* BookmarkDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6980822BFFD06C003E434B /* BookmarkDocument.swift */; }; DA72A0DD2B4CD0BF00A0F48A /* NewsEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA72A0DC2B4CD0BF00A0F48A /* NewsEditorView.swift */; }; @@ -76,9 +77,8 @@ DAAEE66F2B47625600A5BA07 /* FilePreviewImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAAEE66E2B47625600A5BA07 /* FilePreviewImageView.swift */; platformFilters = (macos, ); }; DAB4D87B2B4B78310048A05C /* FileImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAB4D87A2B4B78310048A05C /* FileImageView.swift */; platformFilters = (macos, ); }; DAB4D87E2B4C8BCA0048A05C /* FilePreviewTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAB4D87D2B4C8BCA0048A05C /* FilePreviewTextView.swift */; platformFilters = (macos, ); }; - DAB4D8802B4C8E9A0048A05C /* URLAdditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAB4D87F2B4C8E9A0048A05C /* URLAdditions.swift */; }; DAB4D8822B4C8FED0048A05C /* FileIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAB4D8812B4C8FED0048A05C /* FileIconView.swift */; }; - DAB4D8842B4CABEF0048A05C /* DataAdditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAB4D8832B4CABEF0048A05C /* DataAdditions.swift */; }; + DAB4D8842B4CABEF0048A05C /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAB4D8832B4CABEF0048A05C /* Extensions.swift */; }; DAB4D8872B4CB3610048A05C /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = DAB4D8862B4CB3610048A05C /* MarkdownUI */; }; DABE8BF42B55DC0A00884D28 /* transfer-complete.aiff in Resources */ = {isa = PBXBuildFile; fileRef = DABE8BF32B55DC0A00884D28 /* transfer-complete.aiff */; }; DABE8BF62B55DC2E00884D28 /* chat-message.aiff in Resources */ = {isa = PBXBuildFile; fileRef = DABE8BF52B55DC2E00884D28 /* chat-message.aiff */; }; @@ -89,11 +89,9 @@ DABE8C002B55E69800884D28 /* server-message.aiff in Resources */ = {isa = PBXBuildFile; fileRef = DABE8BFF2B55E69800884D28 /* server-message.aiff */; }; DABE8C022B55E69D00884D28 /* new-news.aiff in Resources */ = {isa = PBXBuildFile; fileRef = DABE8C012B55E69D00884D28 /* new-news.aiff */; }; DABE8C042B57940B00884D28 /* DAKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABE8C032B57940A00884D28 /* DAKeychain.swift */; }; - DABFCC292B1530DC009F40D2 /* FoundationExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABFCC282B1530DC009F40D2 /* FoundationExtensions.swift */; }; - DAC002192B21630900A6C290 /* SwiftUIExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC002182B21630900A6C290 /* SwiftUIExtensions.swift */; }; DAC3D9832BC33FD000A727C9 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC3D9822BC33FD000A727C9 /* AppState.swift */; }; DAC6B2E02EAC6236004E2CBA /* ChatStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC6B2DE2EAC6236004E2CBA /* ChatStore.swift */; }; - DAC6B2E22EAEE9FE004E2CBA /* NetSocketNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC6B2E12EAEE9EF004E2CBA /* NetSocketNew.swift */; }; + DAC6B2E22EAEE9FE004E2CBA /* NetSocket.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC6B2E12EAEE9EF004E2CBA /* NetSocket.swift */; }; DAC6B2E42EAFE92F004E2CBA /* SpinningGlobeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC6B2E32EAFE92F004E2CBA /* SpinningGlobeView.swift */; }; DAC87F072C5010E80060FADF /* HotlineExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC87F062C5010E80060FADF /* HotlineExtensions.swift */; }; DACCE5E12EABE4B4008CDD92 /* AppUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DACCE5E02EABE4B4008CDD92 /* AppUpdate.swift */; platformFilters = (macos, ); }; @@ -140,6 +138,7 @@ DA4B8F392EA6FB3C00CBFD53 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; }; DA4F2BF72B16A17200D8ADDC /* HotlineProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineProtocol.swift; sourceTree = ""; }; DA4F2C002B1A558E00D8ADDC /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = ""; }; + DA501BE12EBE9018001714F8 /* GroupedIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupedIconView.swift; sourceTree = ""; }; DA52689B2EB0738B00DCB941 /* GeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsView.swift; sourceTree = ""; }; DA52689D2EB073A400DCB941 /* IconSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconSettingsView.swift; sourceTree = ""; }; DA52689F2EB073BC00DCB941 /* SoundSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundSettingsView.swift; sourceTree = ""; }; @@ -163,7 +162,7 @@ DA6549992BEC280E00EDB697 /* ServerMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerMessageView.swift; sourceTree = ""; }; DA65499B2BEC3FBD00EDB697 /* ServerAgreementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerAgreementView.swift; sourceTree = ""; }; DA65499D2BEC438A00EDB697 /* NSWindowBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSWindowBridge.swift; sourceTree = ""; }; - DA6980782BF80525003E434B /* TextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextView.swift; sourceTree = ""; }; + DA6980782BF80525003E434B /* BetterTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BetterTextEditor.swift; sourceTree = ""; }; DA69807D2BFD449B003E434B /* MessageBoardEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageBoardEditorView.swift; sourceTree = ""; }; DA6980822BFFD06C003E434B /* BookmarkDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkDocument.swift; sourceTree = ""; }; DA72A0DC2B4CD0BF00A0F48A /* NewsEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsEditorView.swift; sourceTree = ""; }; @@ -184,9 +183,8 @@ DAAEE66E2B47625600A5BA07 /* FilePreviewImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePreviewImageView.swift; sourceTree = ""; }; DAB4D87A2B4B78310048A05C /* FileImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileImageView.swift; sourceTree = ""; }; DAB4D87D2B4C8BCA0048A05C /* FilePreviewTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePreviewTextView.swift; sourceTree = ""; }; - DAB4D87F2B4C8E9A0048A05C /* URLAdditions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLAdditions.swift; sourceTree = ""; }; DAB4D8812B4C8FED0048A05C /* FileIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileIconView.swift; sourceTree = ""; }; - DAB4D8832B4CABEF0048A05C /* DataAdditions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataAdditions.swift; sourceTree = ""; }; + DAB4D8832B4CABEF0048A05C /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = ""; }; DABE8BF32B55DC0A00884D28 /* transfer-complete.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = "transfer-complete.aiff"; sourceTree = ""; }; DABE8BF52B55DC2E00884D28 /* chat-message.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = "chat-message.aiff"; sourceTree = ""; }; DABE8BF72B55DC6100884D28 /* logged-in.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = "logged-in.aiff"; sourceTree = ""; }; @@ -196,11 +194,9 @@ DABE8BFF2B55E69800884D28 /* server-message.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = "server-message.aiff"; sourceTree = ""; }; DABE8C012B55E69D00884D28 /* new-news.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = "new-news.aiff"; sourceTree = ""; }; DABE8C032B57940A00884D28 /* DAKeychain.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DAKeychain.swift; sourceTree = ""; }; - DABFCC282B1530DC009F40D2 /* FoundationExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoundationExtensions.swift; sourceTree = ""; }; - DAC002182B21630900A6C290 /* SwiftUIExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIExtensions.swift; sourceTree = ""; }; DAC3D9822BC33FD000A727C9 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; DAC6B2DE2EAC6236004E2CBA /* ChatStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatStore.swift; sourceTree = ""; }; - DAC6B2E12EAEE9EF004E2CBA /* NetSocketNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetSocketNew.swift; sourceTree = ""; }; + DAC6B2E12EAEE9EF004E2CBA /* NetSocket.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetSocket.swift; sourceTree = ""; }; DAC6B2E32EAFE92F004E2CBA /* SpinningGlobeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpinningGlobeView.swift; sourceTree = ""; }; DAC87F062C5010E80060FADF /* HotlineExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineExtensions.swift; sourceTree = ""; }; DACCE5E02EABE4B4008CDD92 /* AppUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdate.swift; sourceTree = ""; }; @@ -279,6 +275,22 @@ path = iOS; sourceTree = ""; }; + DA501BE02EBE844F001714F8 /* Views */ = { + isa = PBXGroup; + children = ( + DA501BE12EBE9018001714F8 /* GroupedIconView.swift */, + DA872B122BDDBF78008B1012 /* HotlinePanel.swift */, + DAB4D87A2B4B78310048A05C /* FileImageView.swift */, + DAB4D8812B4C8FED0048A05C /* FileIconView.swift */, + DA3429B62EBAB1750010784E /* QuickLookPreviewView.swift */, + DAC6B2E32EAFE92F004E2CBA /* SpinningGlobeView.swift */, + DA872B142BDDEE1A008B1012 /* VisualEffectView.swift */, + DA55AC722BE42AF000034857 /* AsyncLinkPreview.swift */, + DA6980782BF80525003E434B /* BetterTextEditor.swift */, + ); + path = Views; + sourceTree = ""; + }; DA52689A2EB0737400DCB941 /* Settings */ = { isa = PBXGroup; children = ( @@ -344,7 +356,7 @@ DA5268B62EB916AB00DCB941 /* NetSocket */ = { isa = PBXGroup; children = ( - DAC6B2E12EAEE9EF004E2CBA /* NetSocketNew.swift */, + DAC6B2E12EAEE9EF004E2CBA /* NetSocket.swift */, DA5268B92EB91B5E00DCB941 /* FileProgress.swift */, DA5268B72EB916AF00DCB941 /* TransferRateEstimator.swift */, ); @@ -379,7 +391,6 @@ DADDB2892B22B2C60024040D /* Models */, DABFCC262B1530AE009F40D2 /* Hotline */, DAB4D87C2B4B783A0048A05C /* Library */, - DABFCC272B1530BE009F40D2 /* Utility */, DA4B8F3C2EA6FDCE00CBFD53 /* Assets */, DA9CAFC02B126D5800CDA197 /* Assets.xcassets */, DA32CD472B28EE640053B98B /* Info.plist */, @@ -400,17 +411,14 @@ DAB4D87C2B4B783A0048A05C /* Library */ = { isa = PBXGroup; children = ( + DAB4D8832B4CABEF0048A05C /* Extensions.swift */, + DA5268AA2EB11EA300DCB941 /* ColorArt.swift */, + DAE735062B3251B3000C56F6 /* SoundEffects.swift */, + DA55AC782BE6A1AD00034857 /* RegularExpressions.swift */, + DA6980822BFFD06C003E434B /* BookmarkDocument.swift */, + DA501BE02EBE844F001714F8 /* Views */, DA5268B62EB916AB00DCB941 /* NetSocket */, - DAC6B2E32EAFE92F004E2CBA /* SpinningGlobeView.swift */, - DAB4D87A2B4B78310048A05C /* FileImageView.swift */, - DAB4D8812B4C8FED0048A05C /* FileIconView.swift */, - DAB4D8832B4CABEF0048A05C /* DataAdditions.swift */, - DAB4D87F2B4C8E9A0048A05C /* URLAdditions.swift */, - DA3429B62EBAB1750010784E /* QuickLookPreviewView.swift */, - DA872B122BDDBF78008B1012 /* HotlinePanel.swift */, - DA872B142BDDEE1A008B1012 /* VisualEffectView.swift */, - DA55AC722BE42AF000034857 /* AsyncLinkPreview.swift */, - DA6980782BF80525003E434B /* TextView.swift */, + DABFCC272B1530BE009F40D2 /* Utility */, ); path = Library; sourceTree = ""; @@ -447,16 +455,10 @@ DABFCC272B1530BE009F40D2 /* Utility */ = { isa = PBXGroup; children = ( - DABFCC282B1530DC009F40D2 /* FoundationExtensions.swift */, - DA5268AA2EB11EA300DCB941 /* ColorArt.swift */, - DAC002182B21630900A6C290 /* SwiftUIExtensions.swift */, DA7725402B21435B006C5ABB /* ObservableScrollView.swift */, - DAE735062B3251B3000C56F6 /* SoundEffects.swift */, DA57536B2B36BA1D00FAC277 /* TextDocument.swift */, DABE8C032B57940A00884D28 /* DAKeychain.swift */, - DA55AC782BE6A1AD00034857 /* RegularExpressions.swift */, DA65499D2BEC438A00EDB697 /* NSWindowBridge.swift */, - DA6980822BFFD06C003E434B /* BookmarkDocument.swift */, ); path = Utility; sourceTree = ""; @@ -605,7 +607,6 @@ DAC3D9832BC33FD000A727C9 /* AppState.swift in Sources */, DA65499C2BEC3FBD00EDB697 /* ServerAgreementView.swift in Sources */, DA5268BC2EB95A7700DCB941 /* HotlineFolderDownloadClientNew.swift in Sources */, - DAB4D8802B4C8E9A0048A05C /* URLAdditions.swift in Sources */, DA2863DD2B3E8B7000A7D050 /* FilePreview.swift in Sources */, DA3429B02EBA70790010784E /* HotlineFolderUploadClientNew.swift in Sources */, DA5268AB2EB11EA300DCB941 /* ColorArt.swift in Sources */, @@ -616,7 +617,6 @@ DA5268B52EB6840A00DCB941 /* TransfersView.swift in Sources */, 11F8288B2BF9428100216BA0 /* AccountManagerView.swift in Sources */, DAC6B2E42EAFE92F004E2CBA /* SpinningGlobeView.swift in Sources */, - DAC002192B21630900A6C290 /* SwiftUIExtensions.swift in Sources */, DADDB28D2B22B5920024040D /* Server.swift in Sources */, DA5268AF2EB2682B00DCB941 /* HotlineClientNew.swift in Sources */, DABE8C042B57940B00884D28 /* DAKeychain.swift in Sources */, @@ -625,12 +625,13 @@ DAE734FB2B2E41F9000C56F6 /* TrackerView.swift in Sources */, DA5268A32EB0741B00DCB941 /* FolderItemView.swift in Sources */, DAE136BA2B9D1147007D8307 /* HotlinePanelView.swift in Sources */, + DA501BE22EBE9018001714F8 /* GroupedIconView.swift in Sources */, 11A726082BE0672A000C1DA7 /* FileDetails.swift in Sources */, DA9CAFBD2B126D5700CDA197 /* TrackerView.swift in Sources */, DA5268B82EB916AF00DCB941 /* TransferRateEstimator.swift in Sources */, DAB4D87B2B4B78310048A05C /* FileImageView.swift in Sources */, DA3429B52EBA8A450010784E /* FilePreviewState.swift in Sources */, - DA6980792BF80525003E434B /* TextView.swift in Sources */, + DA6980792BF80525003E434B /* BetterTextEditor.swift in Sources */, DA55AC732BE42AF000034857 /* AsyncLinkPreview.swift in Sources */, DA65499E2BEC438A00EDB697 /* NSWindowBridge.swift in Sources */, DAE735032B30C0BB000C56F6 /* MessageView.swift in Sources */, @@ -653,12 +654,11 @@ 11A7260A2BE0675A000C1DA7 /* FileDetailsView.swift in Sources */, DA72A0DD2B4CD0BF00A0F48A /* NewsEditorView.swift in Sources */, DA52689C2EB0738B00DCB941 /* GeneralSettingsView.swift in Sources */, - DABFCC292B1530DC009F40D2 /* FoundationExtensions.swift in Sources */, DA57536C2B36BA1D00FAC277 /* TextDocument.swift in Sources */, DACCE5E12EABE4B4008CDD92 /* AppUpdate.swift in Sources */, DA5268AD2EB12FE200DCB941 /* ServerState.swift in Sources */, DA77253F2B21176D006C5ABB /* NewsView.swift in Sources */, - DAC6B2E22EAEE9FE004E2CBA /* NetSocketNew.swift in Sources */, + DAC6B2E22EAEE9FE004E2CBA /* NetSocket.swift in Sources */, DA99218E2C51AA5D0058FA6C /* HotlineDataBuilder.swift in Sources */, DA872B132BDDBF78008B1012 /* HotlinePanel.swift in Sources */, DA4B8F3A2EA6FB3C00CBFD53 /* iOSApp.swift in Sources */, @@ -670,7 +670,7 @@ DA872B152BDDEE1A008B1012 /* VisualEffectView.swift in Sources */, DA3429AE2EB9C0280010784E /* HotlineFileUploadClientNew.swift in Sources */, DAAEE66D2B475F1400A5BA07 /* PreviewFileInfo.swift in Sources */, - DAB4D8842B4CABEF0048A05C /* DataAdditions.swift in Sources */, + DAB4D8842B4CABEF0048A05C /* Extensions.swift in Sources */, DA0D698D2B1E7CF700C71DF5 /* UsersView.swift in Sources */, DAE735052B3218D8000C56F6 /* NewsView.swift in Sources */, DA5268A52EB0743000DCB941 /* FileItemView.swift in Sources */, diff --git a/Hotline/Hotline/HotlineProtocol.swift b/Hotline/Hotline/HotlineProtocol.swift index 927cf69..3870261 100644 --- a/Hotline/Hotline/HotlineProtocol.swift +++ b/Hotline/Hotline/HotlineProtocol.swift @@ -1,5 +1,10 @@ import Foundation +enum Endianness { + case big + case little +} + struct HotlinePorts { static let DefaultServerPort: Int = 5500 static let DefaultTrackerPort: Int = 5498 diff --git a/Hotline/Library/AsyncLinkPreview.swift b/Hotline/Library/AsyncLinkPreview.swift deleted file mode 100644 index 89b186f..0000000 --- a/Hotline/Library/AsyncLinkPreview.swift +++ /dev/null @@ -1,57 +0,0 @@ -import SwiftUI -import LinkPresentation - -fileprivate class CustomLinkView: LPLinkView { - override var intrinsicContentSize: CGSize { CGSize(width: 0, height: super.intrinsicContentSize.height) } -} - -struct AsyncLinkPreview: View { - @State private var metadata: LPLinkMetadata? - @State private var isLoading = true - let url: URL? - - func fetchMetadata() async { - guard let url else { - self.isLoading = false - return - } - do { - let provider = LPMetadataProvider() - let metadata = try await provider.startFetchingMetadata(for: url) - self.metadata = metadata - self.isLoading = false - } catch { - self.isLoading = false - } - } - - var body: some View { - if isLoading { - ProgressView() - .controlSize(.small) - .task { - await self.fetchMetadata() - } - } else if let metadata = metadata { - LinkView(metadata: metadata) - .frame(width: 200) - } else { - Text(LocalizedStringKey(url!.absoluteString)) - .multilineTextAlignment(.leading) - .tint(Color("Link Color")) - } - } -} - -struct LinkView: NSViewRepresentable { - var metadata: LPLinkMetadata - - func makeNSView(context: Context) -> LPLinkView { - let linkView = CustomLinkView(metadata: metadata) - return linkView - } - - func updateNSView(_ nsView: LPLinkView, context: Context) { - // Nothing required - } -} diff --git a/Hotline/Library/BookmarkDocument.swift b/Hotline/Library/BookmarkDocument.swift new file mode 100644 index 0000000..47fa442 --- /dev/null +++ b/Hotline/Library/BookmarkDocument.swift @@ -0,0 +1,32 @@ +import SwiftUI +import Foundation +import UniformTypeIdentifiers + +struct BookmarkDocument: FileDocument { + static var readableContentTypes: [UTType] { [.data, UTType(filenameExtension: "hlbm")!] } + static var writableContentTypes: [UTType] { [.data, UTType(filenameExtension: "hlbm")!] } + + var bookmark: Bookmark + + init(bookmark: Bookmark) { + self.bookmark = bookmark + } + + init(configuration: ReadConfiguration) throws { + guard configuration.file.isRegularFile, + let data = configuration.file.regularFileContents, + let fileName = configuration.file.preferredFilename, + let bookmark = Bookmark(fileData: data, name: (fileName as NSString).deletingPathExtension) + else { + throw CocoaError(.fileReadCorruptFile) + } + self.bookmark = bookmark + } + + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + let wrapper = FileWrapper(regularFileWithContents: self.bookmark.bookmarkFileData()!) + wrapper.fileAttributes[FileAttributeKey.hfsCreatorCode.rawValue] = "HTLC".fourCharCode() + wrapper.fileAttributes[FileAttributeKey.hfsTypeCode.rawValue] = "HTbm".fourCharCode() + return wrapper + } +} diff --git a/Hotline/Library/ColorArt.swift b/Hotline/Library/ColorArt.swift new file mode 100644 index 0000000..93889a3 --- /dev/null +++ b/Hotline/Library/ColorArt.swift @@ -0,0 +1,424 @@ + +// Swift translation and modernization +// by Dustin Mierau +// +// of: +// +// ColorArt.swift +// SLColorArt by Panic Inc. +// +// Copyright (C) 2012 Panic Inc. Code by Wade Cosgrove. All rights reserved. +// +// Redistribution and use, with or without modification, are permitted +// provided that the following conditions are met: +// +// - Redistributions must reproduce the above copyright notice, this list of +// conditions and the following disclaimer in the documentation and/or other +// materials provided with the distribution. +// +// - Neither the name of Panic Inc nor the names of its contributors may be used +// to endorse or promote works derived from this software without specific prior +// written permission from Panic Inc. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +// ARE DISCLAIMED. IN NO EVENT SHALL PANIC INC BE LIABLE FOR ANY DIRECT, INDIRECT, +// INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +// PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +// POSSIBILITY OF SUCH DAMAGE. + +import AppKit +import SwiftUI + +fileprivate let kColorThresholdMinimumPercentage: CGFloat = 0.001 + +// ColorArt.analyze(image: img) -> ColorArt? + +struct ColorArt: Equatable { + let backgroundColor: NSColor + let primaryColor: NSColor + let secondaryColor: NSColor + let detailColor: NSColor +// let scaledImage: NSImage + + static func == (lhs: ColorArt, rhs: ColorArt) -> Bool { + return lhs.backgroundColor == rhs.backgroundColor && + lhs.primaryColor == rhs.primaryColor && + lhs.secondaryColor == rhs.secondaryColor && + lhs.detailColor == rhs.detailColor + } + + static func analyze(image: NSImage) -> ColorArt? { + print("ColorArt.analyze: Starting, image size: \(image.size)") + // Scale image to a reasonable size for analysis + // This is important because: + // 1. Makes analysis faster (fewer pixels) + // 2. Normalizes weird image dimensions + // 3. Ensures CGImage conversion succeeds + print("ColorArt.analyze: Calling scaleImage...") + let finalImage = Self.scaleImage(image, size: NSSize(width: 100, height: 100)) + print("ColorArt.analyze: scaleImage returned, scaled size: \(finalImage.size)") + + guard let colors = Self.analyzeImage(finalImage) else { + print("ColorArt.analyze: failed with no colors") + return nil + } + + print("ColorArt.analyze: returning colors", colors) + + return ColorArt(backgroundColor: colors.background, + primaryColor: colors.primary, + secondaryColor: colors.secondary, + detailColor: colors.detail) + } + + // MARK: - Image Scaling + + private static func scaleImage(_ image: NSImage, size scaledSize: NSSize) -> NSImage { + print("ColorArt.scaleImage: Entered, input: \(image.size), target: \(scaledSize)") + // Get CGImage directly without using lockFocus + print("ColorArt.scaleImage: Getting CGImage...") + guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + print("ColorArt.scaleImage: Failed to get CGImage, returning original") + return image + } + print("ColorArt.scaleImage: Got CGImage") + + let imageSize = image.size + let squareSize = min(imageSize.width, imageSize.height) + + // Use native square size if passed zero size + let finalScaledSize = scaledSize == .zero ? NSSize(width: squareSize, height: squareSize) : scaledSize + + // Create bitmap context for drawing + let width = Int(finalScaledSize.width) + let height = Int(finalScaledSize.height) + let colorSpace = CGColorSpaceCreateDeviceRGB() + let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue) + + guard let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width * 4, + space: colorSpace, + bitmapInfo: bitmapInfo.rawValue + ) else { + return image + } + + // Draw the image scaled + context.interpolationQuality = .high + context.draw(cgImage, in: CGRect(x: 0, y: 0, width: finalScaledSize.width, height: finalScaledSize.height)) + + // Create NSImage from context + guard let scaledCGImage = context.makeImage() else { + return image + } + + let bitmapRep = NSBitmapImageRep(cgImage: scaledCGImage) + let finalImage = NSImage(size: finalScaledSize) + finalImage.addRepresentation(bitmapRep) + + return finalImage + } + + // MARK: - Image Analysis + + private static func analyzeImage(_ image: NSImage) -> (background: NSColor, primary: NSColor, secondary: NSColor, detail: NSColor)? { + var imageColors: NSCountedSet? + guard let backgroundColor = self.findEdgeColor(image, imageColors: &imageColors), + let colors = imageColors + else { + return nil + } + + let darkBackground = backgroundColor.isDarkColor + var primaryColor: NSColor? + var secondaryColor: NSColor? + var detailColor: NSColor? + + self.findTextColors(colors, primaryColor: &primaryColor, secondaryColor: &secondaryColor, detailColor: &detailColor, backgroundColor: backgroundColor) + + // Fallback to black or white if colors not found + if primaryColor == nil { + primaryColor = darkBackground ? .white : .black + } + + if secondaryColor == nil { + secondaryColor = darkBackground ? .white : .black + } + + if detailColor == nil { + detailColor = darkBackground ? .white : .black + } + + // Convert all colors to calibrated RGB color space for consistency + // This ensures all colors are in the same color space and prevents + // any color space conversion issues when used in SwiftUI + let rgbColorSpace = NSColorSpace.genericRGB + let finalBackground = backgroundColor.usingColorSpace(rgbColorSpace) ?? backgroundColor + let finalPrimary = primaryColor!.usingColorSpace(rgbColorSpace) ?? primaryColor! + let finalSecondary = secondaryColor!.usingColorSpace(rgbColorSpace) ?? secondaryColor! + let finalDetail = detailColor!.usingColorSpace(rgbColorSpace) ?? detailColor! + + return (finalBackground, finalPrimary, finalSecondary, finalDetail) + } + + // MARK: - Edge Color Detection + + private static func findEdgeColor(_ image: NSImage, imageColors: inout NSCountedSet?) -> NSColor? { + var bitmapRep: NSBitmapImageRep? + + // Try to get existing bitmap representation + if let existingRep = image.representations.last as? NSBitmapImageRep { + bitmapRep = existingRep + } else { + // Create bitmap rep from CGImage instead of using lockFocus + guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + return nil + } + bitmapRep = NSBitmapImageRep(cgImage: cgImage) + } + + // Convert to RGB color space + guard let bitmapRep = bitmapRep?.converting(to: .genericRGB, renderingIntent: .default) else { + return nil + } + + let pixelsWide = bitmapRep.pixelsWide + let pixelsHigh = bitmapRep.pixelsHigh + + let colors = NSCountedSet(capacity: pixelsWide * pixelsHigh) + let leftEdgeColors = NSCountedSet(capacity: pixelsHigh) + var searchColumnX = 0 + + for x in 0.. 0.5 { + leftEdgeColors.add(color) + } + } + + if color.alphaComponent > CGFloat.ulpOfOne { + colors.add(color) + } + } + + // Background is clear, keep looking in next column for background color + if leftEdgeColors.count == 0 { + searchColumnX += 1 + } + } + + imageColors = colors + + var sortedColors: [CountedColor] = [] + + for color in leftEdgeColors { + guard let nsColor = color as? NSColor else { continue } + let colorCount = leftEdgeColors.count(for: nsColor) + + let randomColorsThreshold = Int(CGFloat(pixelsHigh) * kColorThresholdMinimumPercentage) + + if colorCount <= randomColorsThreshold { + continue + } + + sortedColors.append(CountedColor(color: nsColor, count: colorCount)) + } + + sortedColors.sort { $0.count > $1.count } + + guard var proposedEdgeColor = sortedColors.first else { + return nil + } + + // Want to choose color over black/white so we keep looking + if proposedEdgeColor.color.isBlackOrWhite { + for i in 1.. 0.3 { + if !nextProposedColor.color.isBlackOrWhite { + proposedEdgeColor = nextProposedColor + break + } + } else { + // Reached color threshold less than 30% of the original proposed edge color + break + } + } + } + + return proposedEdgeColor.color + } + + // MARK: - Text Color Detection + + private static func findTextColors(_ colors: NSCountedSet, primaryColor: inout NSColor?, secondaryColor: inout NSColor?, detailColor: inout NSColor?, backgroundColor: NSColor) { + var sortedColors: [CountedColor] = [] + let findDarkTextColor = !backgroundColor.isDarkColor + + for color in colors { + guard let nsColor = color as? NSColor else { continue } + let adjustedColor = nsColor.withMinimumSaturation(0.15) + + if adjustedColor.isDarkColor == findDarkTextColor { + let colorCount = colors.count(for: nsColor) + sortedColors.append(CountedColor(color: adjustedColor, count: colorCount)) + } + } + + sortedColors.sort { $0.count > $1.count } + + for container in sortedColors { + let curColor = container.color + + if primaryColor == nil { + if curColor.isContrasting(to: backgroundColor) { + primaryColor = curColor + } + } else if secondaryColor == nil { + if let primary = primaryColor, + primary.isDistinct(from: curColor) && curColor.isContrasting(to: backgroundColor) { + secondaryColor = curColor + } + } else if detailColor == nil { + if let primary = primaryColor, + let secondary = secondaryColor, + secondary.isDistinct(from: curColor) && + primary.isDistinct(from: curColor) && + curColor.isContrasting(to: backgroundColor) { + detailColor = curColor + break + } + } + } + } +} + +// MARK: - Helper Classes + +fileprivate struct CountedColor { + let color: NSColor + let count: Int +} + +// MARK: - NSColor Extensions + +extension NSColor { + var isDarkColor: Bool { + guard let convertedColor = usingColorSpace(.genericRGB) else { + return false + } + + var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + convertedColor.getRed(&r, green: &g, blue: &b, alpha: &a) + + let lum = 0.2126 * r + 0.7152 * g + 0.0722 * b + + return lum < 0.5 + } + + func isDistinct(from compareColor: NSColor) -> Bool { + guard let convertedColor = usingColorSpace(.genericRGB), + let convertedCompareColor = compareColor.usingColorSpace(.genericRGB) + else { + return false + } + + var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + var r1: CGFloat = 0, g1: CGFloat = 0, b1: CGFloat = 0, a1: CGFloat = 0 + + convertedColor.getRed(&r, green: &g, blue: &b, alpha: &a) + convertedCompareColor.getRed(&r1, green: &g1, blue: &b1, alpha: &a1) + + let threshold: CGFloat = 0.25 + + if abs(r - r1) > threshold || abs(g - g1) > threshold || abs(b - b1) > threshold || abs(a - a1) > threshold { + // Check for grays, prevent multiple gray colors + if abs(r - g) < 0.03 && abs(r - b) < 0.03 { + if abs(r1 - g1) < 0.03 && abs(r1 - b1) < 0.03 { + return false + } + } + + return true + } + + return false + } + + func withMinimumSaturation(_ minSaturation: CGFloat) -> NSColor { + guard let tempColor = usingColorSpace(.genericRGB) else { + return self + } + + var hue: CGFloat = 0, saturation: CGFloat = 0, brightness: CGFloat = 0, alpha: CGFloat = 0 + tempColor.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) + + if saturation < minSaturation { + return NSColor(calibratedHue: hue, saturation: minSaturation, brightness: brightness, alpha: alpha) + } + + return self + } + + var isBlackOrWhite: Bool { + guard let tempColor = usingColorSpace(.genericRGB) else { + return false + } + + var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + tempColor.getRed(&r, green: &g, blue: &b, alpha: &a) + + // White + if r > 0.91 && g > 0.91 && b > 0.91 { + return true + } + + // Black + if r < 0.09 && g < 0.09 && b < 0.09 { + return true + } + + return false + } + + func isContrasting(to color: NSColor) -> Bool { + guard let backgroundColor = usingColorSpace(.genericRGB), + let foregroundColor = color.usingColorSpace(.genericRGB) + else { + return true + } + + var br: CGFloat = 0, bg: CGFloat = 0, bb: CGFloat = 0, ba: CGFloat = 0 + var fr: CGFloat = 0, fg: CGFloat = 0, fb: CGFloat = 0, fa: CGFloat = 0 + + backgroundColor.getRed(&br, green: &bg, blue: &bb, alpha: &ba) + foregroundColor.getRed(&fr, green: &fg, blue: &fb, alpha: &fa) + + let bLum = 0.2126 * br + 0.7152 * bg + 0.0722 * bb + let fLum = 0.2126 * fr + 0.7152 * fg + 0.0722 * fb + + let contrast: CGFloat + if bLum > fLum { + contrast = (bLum + 0.05) / (fLum + 0.05) + } else { + contrast = (fLum + 0.05) / (bLum + 0.05) + } + + return contrast > 1.6 + } +} diff --git a/Hotline/Library/DataAdditions.swift b/Hotline/Library/DataAdditions.swift deleted file mode 100644 index 0e9dd96..0000000 --- a/Hotline/Library/DataAdditions.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Foundation - -extension Data { - func saveAsFileToDownloads(filename: String, bounceDock: Bool = true) -> Bool { - let folderURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0] - let filePath = folderURL.generateUniqueFilePath(filename: filename) - if FileManager.default.createFile(atPath: filePath, contents: nil) { - if let h = FileHandle(forWritingAtPath: filePath) { - try? h.write(contentsOf: self) - try? h.close() - if bounceDock { - #if os(macOS) - var downloadURL = URL(filePath: filePath) - downloadURL.resolveSymlinksInPath() - DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path) - #endif - } - return true - } - } - return false - } -} diff --git a/Hotline/Library/Extensions.swift b/Hotline/Library/Extensions.swift new file mode 100644 index 0000000..469676c --- /dev/null +++ b/Hotline/Library/Extensions.swift @@ -0,0 +1,192 @@ +import Foundation +import SwiftUI +import UniformTypeIdentifiers + +extension Data { + func saveAsFileToDownloads(filename: String, bounceDock: Bool = true) -> Bool { + let folderURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0] + let filePath = folderURL.generateUniqueFilePath(filename: filename) + if FileManager.default.createFile(atPath: filePath, contents: nil) { + if let h = FileHandle(forWritingAtPath: filePath) { + try? h.write(contentsOf: self) + try? h.close() + if bounceDock { + #if os(macOS) + var downloadURL = URL(filePath: filePath) + downloadURL.resolveSymlinksInPath() + DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path) + #endif + } + return true + } + } + return false + } +} + +// MARK: - + +extension URL { + func generateUniqueFilePath(filename base: String) -> String { + let fileManager = FileManager.default + var finalName = base + var counter = 2 + + // Helper function to generate a new filename with a counter + func makeFileName() -> String { + let baseName = (base as NSString).deletingPathExtension + let extensionName = (base as NSString).pathExtension + return extensionName.isEmpty ? "\(baseName) \(counter)" : "\(baseName) \(counter).\(extensionName)" + } + + // Check if file exists and append counter until a unique name is found + var filePath = self.appending(component: finalName).path(percentEncoded: false) + while fileManager.fileExists(atPath: filePath) { + finalName = makeFileName() + filePath = self.appending(component: finalName).path(percentEncoded: false) + counter += 1 + } + + return filePath + } +} + +// MARK: - + +extension UTType { + var canBePreviewedByQuickLook: Bool { + // QuickLook supports most common document types + let supportedSupertypes: [UTType] = [ + .image, + .movie, + .audio, + .pdf, + .font, + .usdz, + .text, + .sourceCode, + .spreadsheet, + .presentation, + +// Microsoft Office + .init(filenameExtension: "doc")!, + .init(filenameExtension: "docx")!, + .init(filenameExtension: "xls")!, + .init(filenameExtension: "xlsx")!, + .init(filenameExtension: "ppt")!, + .init(filenameExtension: "pptx")!, + ] + + return supportedSupertypes.contains { self.conforms(to: $0) } + } +} + +// MARK: - + +extension String { + + func markdownToAttributedString() -> AttributedString { + let markdownText = self.convertingLinksToMarkdown() + let attr = (try? AttributedString(markdown: markdownText, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(self) + + return attr + } + + func convertToAttributedStringWithLinks() -> AttributedString { + let attributedString: NSMutableAttributedString = NSMutableAttributedString(string: self) + let matches = self.ranges(of: RegularExpressions.relaxedLink) + for match in matches { + let matchString = String(self[match]) + if matchString.isEmailAddress() { + attributedString.addAttribute(.link, value: "mailto:\(matchString)", range: NSRange(match, in: self)) + } + else { + attributedString.addAttribute(.link, value: matchString, range: NSRange(match, in: self)) + } +// attributedString.addAttribute(.underlineStyle, value: 1, range: NSRange(match, in: self)) + } + return AttributedString(attributedString) + } + + func isEmailAddress() -> Bool { + self.wholeMatch(of: RegularExpressions.emailAddress) != nil + } + + func isWebURL() -> Bool { + guard let url = URL(string: self) else { + return false + } + switch url.scheme?.lowercased() { + case "http", "https": + return true + default: + return false + } + } + + func isImageURL() -> Bool { + guard let url = URL(string: self) else { + return false + } + + switch url.pathExtension.lowercased() { + case "jpg", "jpeg", "png", "gif": + return true + default: + return false + } + } + + func convertingLinksToMarkdown() -> String { +// var cp = String(self) + + self.replacing(RegularExpressions.relaxedLink) { match in + let linkText = self[match.range] + + // Only add https:// if the link doesn't already have a scheme + let hasScheme = (try? RegularExpressions.supportedLinkScheme.prefixMatch(in: linkText)) != nil + let url = hasScheme ? String(linkText) : "https://\(linkText)" + + return "[\(linkText)](\(url))" + } + +// cp.replace(RegularExpressions.relaxedLink) { match -> String in +// let linkText = self[match.range] +// var injectedScheme = "https://" +// if let _ = try? RegularExpressions.supportedLinkScheme.prefixMatch(in: linkText) { +// injectedScheme = "" +// } +// +// return "[\(linkText)](\(injectedScheme)\(linkText))" +// } +// return cp + } +} + +// MARK: - + +extension Binding where Value: OptionSet, Value == Value.Element { + func bindedValue(_ options: Value) -> Bool { + return wrappedValue.contains(options) + } + + func bind(_ options: Value) -> Binding { + return .init { () -> Bool in + self.wrappedValue.contains(options) + } set: { newValue in + if newValue { + self.wrappedValue.insert(options) + } else { + self.wrappedValue.remove(options) + } + } + } +} + +// MARK: - + +extension Color { + init(hex: Int, opacity: Double = 1.0) { + self.init(red: Double((hex >> 16) & 0xFF) / 255.0, green: Double((hex >> 8) & 0xFF) / 255.0, blue: Double(hex & 0xFF) / 255.0, opacity: opacity) + } +} diff --git a/Hotline/Library/FileIconView.swift b/Hotline/Library/FileIconView.swift deleted file mode 100644 index d0949fa..0000000 --- a/Hotline/Library/FileIconView.swift +++ /dev/null @@ -1,74 +0,0 @@ -import SwiftUI -import UniformTypeIdentifiers - -struct FolderIconView: View { - private func folderIcon() -> Image { -#if os(iOS) - return Image(systemName: "folder.fill") -#elseif os(macOS) - return Image(nsImage: NSWorkspace.shared.icon(for: UTType.folder)) -#endif - } - - var body: some View { - folderIcon() - .resizable() - .scaledToFit() - } -} - -struct FileIconView: View { - let filename: String - let fileType: String? - - #if os(iOS) - private func fileIcon() -> Image { - let fileExtension = (self.filename as NSString).pathExtension - if let fileType = UTType(filenameExtension: fileExtension) { - if fileType.isSubtype(of: .movie) { - return Image(systemName: "play.rectangle") - } - else if fileType.isSubtype(of: .image) { - return Image(systemName: "photo") - } - else if fileType.isSubtype(of: .archive) { - return Image(systemName: "doc.zipper") - } - else if fileType.isSubtype(of: .text) { - return Image(systemName: "doc.text") - } - else { - return Image(systemName: "doc") - } - } - - return Image(systemName: "doc") - } - #elseif os(macOS) - private func fileIcon() -> Image { - let fileExtension = (self.filename as NSString).pathExtension - - if !fileExtension.isEmpty, - let uttype = UTType(filenameExtension: fileExtension) { - return Image(nsImage: NSWorkspace.shared.icon(for: uttype)) - } - else if let fileType = self.fileType, - let fileTypeExtension = FileManager.HFSTypeToExtension[fileType.lowercased()], - let uttype = UTType(filenameExtension: fileTypeExtension) { - return Image(nsImage: NSWorkspace.shared.icon(for: uttype)) - } - else { - return Image(nsImage: NSWorkspace.shared.icon(for: UTType.data)) - } - -// Image(nsImage: NSWorkspace.shared.icon(for: UTType(filenameExtension: (filename as NSString).pathExtension) ?? UTType.content)) - } - #endif - - - var body: some View { - fileIcon() - .resizable() - .scaledToFit() - } -} diff --git a/Hotline/Library/FileImageView.swift b/Hotline/Library/FileImageView.swift deleted file mode 100644 index 0cc50f1..0000000 --- a/Hotline/Library/FileImageView.swift +++ /dev/null @@ -1,98 +0,0 @@ -import SwiftUI - -struct FileImageView: NSViewRepresentable { - var image: NSImage? - - let minimumSize: CGSize = CGSize(width: 350, height: 350) - let presentationPaddingRatio: Double = 0.5 - - func makeNSView(context: Context) -> NSImageView { - let imageView = NSImageView() - imageView.imageScaling = .scaleProportionallyUpOrDown - imageView.animates = true - imageView.isEditable = false - imageView.allowsCutCopyPaste = true - imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) - imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - - if let img = self.image { - imageView.image = img - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - self.resizeWindowForImageView(imageView) - } - } - - return imageView - } - - func updateNSView(_ nsView: NSImageView, context: Context) { - nsView.image = self.image - } - - // MARK: - - - func resizeWindowForImageView(_ imageView: NSImageView) { - guard let window = imageView.window, let img = imageView.image else { - return - } - - var windowRect = window.contentLayoutRect - let windowChromeSize = CGSize(width: window.frame.width - windowRect.width, height: window.frame.height - windowRect.height) - - var windowMinSize: CGSize = windowRect.size - let centerPoint = CGPoint(x: window.frame.midX, y: window.frame.midY) - - windowRect.size = img.size - - if let screen = window.screen { - var paddedScreenSize = screen.frame.size - paddedScreenSize.width *= self.presentationPaddingRatio - paddedScreenSize.height *= self.presentationPaddingRatio - - windowMinSize = aspectFit(source: windowRect.size, bounds: self.minimumSize) - windowRect.size = aspectFit(source: windowRect.size, bounds: paddedScreenSize, minimum: windowMinSize) - } - - windowRect.size.width += windowChromeSize.width - windowRect.size.height += windowChromeSize.height - - windowRect.origin.x = centerPoint.x - windowRect.width / 2.0 - windowRect.origin.y = centerPoint.y - windowRect.height / 2.0 - - window.setFrame(windowRect, display: true, animate: true) - -// Do these APIs even work?? -// window.aspectRatio = windowRect.size -// window.contentAspectRatio = windowRect.size - } - - func aspectFit(source sourceSize: CGSize, bounds boundingSize: CGSize, minimum minSize: CGSize? = nil) -> CGSize { - let sourceAspectRatio = sourceSize.width / sourceSize.height - - var fitSize: CGSize = sourceSize - - if fitSize.width > boundingSize.width { - fitSize.width = boundingSize.width - fitSize.height = fitSize.width / sourceAspectRatio - } - - if fitSize.height > boundingSize.height { - fitSize.height = boundingSize.height - fitSize.width = fitSize.height * sourceAspectRatio - } - - if let m = minSize { - if fitSize.width < m.width { - fitSize.width = m.width - fitSize.height = fitSize.width / sourceAspectRatio - } - - if fitSize.height < m.height { - fitSize.height = m.height - fitSize.width = fitSize.height * sourceAspectRatio - } - } - - return fitSize - } -} diff --git a/Hotline/Library/HotlinePanel.swift b/Hotline/Library/HotlinePanel.swift deleted file mode 100644 index d7d8284..0000000 --- a/Hotline/Library/HotlinePanel.swift +++ /dev/null @@ -1,64 +0,0 @@ -import Cocoa -import SwiftUI - -fileprivate let HOTLINE_PANEL_SIZE: CGSize = CGSizeMake(468, 114 - 10) - -class HotlinePanel: NSPanel { - init(_ view: HotlinePanelView) { - super.init(contentRect: NSRect(x: 0, y: 0, width: HOTLINE_PANEL_SIZE.width, height: HOTLINE_PANEL_SIZE.height), styleMask: [.nonactivatingPanel, .titled, .closable, .utilityWindow, .fullSizeContentView], backing: .buffered, defer: false) - - // Make sure that the panel is in front of almost all other windows - self.isFloatingPanel = true - self.level = .floating - self.hidesOnDeactivate = true - self.animationBehavior = .utilityWindow - - // Allow the panelto appear in a fullscreen space -// self.collectionBehavior.insert(.fullScreenAuxiliary) - self.collectionBehavior.insert(.canJoinAllSpaces) - self.collectionBehavior.insert(.ignoresCycle) - -// self.appearance = NSAppearance(named: .vibrantDark) - - // Don't delete panel state when it's closed. - self.isReleasedWhenClosed = false - - self.standardWindowButton(.closeButton)?.isHidden = false - self.standardWindowButton(.zoomButton)?.isHidden = true - self.standardWindowButton(.miniaturizeButton)?.isHidden = true - - // Make it transparent, the view inside will have to set the background. - // This is necessary because otherwise, we will have some space for the titlebar on top of the height of the view itself which we don't want. - self.isOpaque = false - self.backgroundColor = .clear - - // Since we don't show a statusbar, this allows us to drag the window by its background instead of the titlebar. - self.isMovableByWindowBackground = true - self.titlebarAppearsTransparent = true - - let hostingView = NSHostingView(rootView: view.edgesIgnoringSafeArea(.top)) - hostingView.sizingOptions = [.preferredContentSize] - - let visualEffectView = NSVisualEffectView(frame: NSRect(x: 0, y: 0, width: HOTLINE_PANEL_SIZE.width, height: HOTLINE_PANEL_SIZE.height)) - visualEffectView.material = .sidebar - visualEffectView.blendingMode = .behindWindow - visualEffectView.state = NSVisualEffectView.State.active - visualEffectView.autoresizingMask = [.width, .height] - visualEffectView.autoresizesSubviews = true - visualEffectView.addSubview(hostingView) - - self.contentView = visualEffectView - - hostingView.frame = visualEffectView.bounds - - self.cascadeTopLeft(from: NSMakePoint(16, 16)) - } - - override var canBecomeKey: Bool { - return false - } - - override var canBecomeMain: Bool { - return false - } -} diff --git a/Hotline/Library/NetSocket/FileProgress.swift b/Hotline/Library/NetSocket/FileProgress.swift index 1cc0228..c2306f3 100644 --- a/Hotline/Library/NetSocket/FileProgress.swift +++ b/Hotline/Library/NetSocket/FileProgress.swift @@ -1,4 +1,4 @@ -// NetSocketProgress +// NetSocket FileProgress // Dustin Mierau • @mierau // MIT License diff --git a/Hotline/Library/NetSocket/NetSocket.swift b/Hotline/Library/NetSocket/NetSocket.swift new file mode 100644 index 0000000..5c7d185 --- /dev/null +++ b/Hotline/Library/NetSocket/NetSocket.swift @@ -0,0 +1,1123 @@ +// NetSocket.swift +// Dustin Mierau • @mierau +// MIT License + +import Foundation +import Network + +/// Byte order for multi-byte integer values in binary protocols +public enum Endian { + /// Big-endian (network byte order, most significant byte first) + case big + /// Little-endian (least significant byte first) + case little +} + +/// Delimiter patterns for text-based protocols +public enum Delimiter { + /// Custom single byte delimiter + case byte(UInt8) + /// Null terminator (0x00) + case zeroByte + /// Line feed (\n, 0x0A) + case lineFeed + /// Carriage return + line feed (\r\n, 0x0D 0x0A) + case carriageReturnLineFeed + + /// Binary representation of this delimiter + var data: Data { + switch self { + case .byte(let b): return Data([b]) + case .zeroByte: return Data([0x00]) + case .lineFeed: return Data([0x0A]) + case .carriageReturnLineFeed: return Data([0x0D, 0x0A]) + } + } +} + +/// TLS/SSL encryption policy for socket connections +public struct TLSPolicy: Sendable { + /// Create a TLS-enabled policy with optional custom configuration + /// - Parameter configure: Optional closure to customize TLS options + public static func enabled(_ configure: (@Sendable (NWProtocolTLS.Options) -> Void)? = nil) -> TLSPolicy { + TLSPolicy(enabled: true, configure: configure) + } + + /// Create a policy with TLS disabled (plaintext connection) + public static var disabled: TLSPolicy { TLSPolicy(enabled: false, configure: nil) } + + /// Whether TLS is enabled + public let enabled: Bool + /// Optional TLS configuration closure + public let configure: (@Sendable (NWProtocolTLS.Options) -> Void)? +} + +// MARK: - Errors + +/// Errors that can occur during socket operations +public enum NetSocketError: Error, CustomStringConvertible, Sendable { + /// Socket is not yet in ready state + case notReady + /// Connection has been closed + case closed + /// Invalid port number provided + case invalidPort + /// Network operation failed with underlying error + case failed(underlying: Error) + /// Not enough data available to fulfill read request + case insufficientData(expected: Int, got: Int) + /// Frame size exceeds configured maximum + case framingExceeded(max: Int) + /// Failed to decode data + case decodeFailed(Error) + /// Failed to encode data + case encodeFailed(Error) + + public var description: String { + switch self { + case .notReady: return "Connection not ready." + case .closed: return "Connection closed." + case .invalidPort: return "Invalid port number." + case .failed(let e): return "Network failure: \(e.localizedDescription)" + case .insufficientData(let exp, let got): return "Insufficient data: need \(exp), have \(got)." + case .framingExceeded(let max): return "Frame length exceeded maximum \(max)." + case .decodeFailed(let e): return "Decoding failed: \(e)" + case .encodeFailed(let e): return "Encoding failed: \(e)" + } + } +} + +/// An async/await TCP socket with automatic buffering and framing support +/// +/// NetSocket provides: +/// - Async connection management +/// - Automatic receive buffering with memory compaction +/// - Type-safe reading/writing of integers, strings, and custom types +/// - File upload/download with progress tracking +/// +/// Example usage: +/// ```swift +/// let socket = try await NetSocket.connect(host: "example.com", port: 80) +/// try await socket.write("Hello\n".data(using: .utf8)!) +/// let response = try await socket.readUntil(delimiter: .lineFeed) +/// ``` +public actor NetSocket { + /// Configuration options for the socket + public struct Config: Sendable { + /// Size of chunks to receive from network at once (default: 64 KB) + public var receiveChunk: Int = 64 * 1024 + /// Maximum bytes to buffer before disconnecting (default: 8 MB) + public var maxBufferBytes: Int = 8 * 1024 * 1024 + public init() {} + } + + // Connection + state + private let connection: NWConnection + private let queue = DispatchQueue(label: "NetSocket.NWConnection") + private var ready = false + private var isClosed = false + private let connectionID: String // For logging + + // Buffer with compaction + private var buffer = Data() + private var head = 0 // start of unread bytes + private let config: Config + + // Waiters for data/ready + private var dataWaiters: [CheckedContinuation] = [] + private var readyWaiters: [CheckedContinuation] = [] + + // MARK: Init + + private init(connection: NWConnection, config: Config) { + self.connection = connection + self.config = config + // Create a human-readable connection ID for logging + if case .hostPort(host: let h, port: let p) = connection.endpoint { + self.connectionID = "\(h):\(p)" + } else { + self.connectionID = "unknown" + } + } + + // MARK: Connect + + /// Connect to a remote host and return a ready socket + /// + /// This method establishes a TCP connection using Network framework types and waits until + /// the connection is in `.ready` state. + /// + /// - Parameters: + /// - host: Network framework host (e.g., `.name("example.com", nil)` or `.ipv4(...)`) + /// - port: Network framework port + /// - tls: TLS policy (default: enabled with default settings) + /// - config: Socket configuration (default: standard settings) + /// - Returns: A connected and ready `NetSocket` + /// - Throws: Network errors or connection failures + public static func connect(host: NWEndpoint.Host, port: NWEndpoint.Port, tls: TLSPolicy = .enabled(), config: Config = .init()) async throws -> NetSocket { + let parameters = NWParameters.tcp + if tls.enabled { + let tlsOptions = NWProtocolTLS.Options() + tls.configure?(tlsOptions) + parameters.defaultProtocolStack.applicationProtocols.insert(tlsOptions, at: 0) + } + + let conn = NWConnection(host: host, port: port, using: parameters) + let socket = NetSocket(connection: conn, config: config) + try await socket.start() + return socket + } + + /// Convenience wrapper to connect using string hostname and integer port + public static func connect(host: String, port: UInt16, tls: TLSPolicy = .enabled(), config: Config = .init()) async throws -> NetSocket { + guard let nwPort = NWEndpoint.Port(rawValue: port) else { + throw NetSocketError.invalidPort + } + return try await self.connect(host: .name(host, nil), port: nwPort, tls: tls, config: config) + } + + // MARK: Close + + /// Close the connection gracefully + /// + /// Performs a graceful shutdown of the underlying network connection (e.g., TCP FIN) + /// and wakes all pending read/write operations with a `NetSocketError.closed` error. + /// This method is idempotent - subsequent calls are ignored. + /// + /// Use `forceClose()` for immediate non-graceful termination (e.g., TCP RST). + public func close() { + guard !isClosed else { return } + isClosed = true + connection.cancel() + resumeDataWaiters() + resumeReadyWaiters(with: .failure(NetSocketError.closed)) + } + + /// Force close the connection immediately (non-graceful) + /// + /// Performs an immediate non-graceful shutdown of the underlying network connection + /// (e.g., TCP RST). Use this when you need to terminate the connection immediately + /// without waiting for graceful closure. For normal shutdown, use `close()` instead. + /// + /// This method is idempotent - subsequent calls are ignored. + public func forceClose() { + guard !isClosed else { return } + isClosed = true + connection.forceCancel() + resumeDataWaiters() + resumeReadyWaiters(with: .failure(NetSocketError.closed)) + } + + // MARK: Send Data + + /// Write raw data to the socket + /// + /// Sends data and waits for confirmation that it has been processed by the network stack. + /// + /// - Parameter data: Raw bytes to send + /// - Throws: `NetSocketError` if connection is not ready or send fails + @discardableResult + public func write(_ data: Data) async throws -> Int { + try await ensureReady() + return try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + connection.send(content: data, completion: .contentProcessed { error in + if let error { cont.resume(throwing: NetSocketError.failed(underlying: error)) } + else { cont.resume(returning: data.count) } + }) + } + } + + /// Write a fixed-width integer to the socket + /// + /// - Parameters: + /// - value: The integer value to write + /// - endian: Byte order (default: big-endian) + /// - Throws: `NetSocketError` if write fails + @discardableResult + public func write(_ value: T, endian: Endian = .big) async throws -> Int { + var v = value + switch endian { + case .big: v = T(bigEndian: value) + case .little: v = T(littleEndian: value) + } + var copy = v + let size = MemoryLayout.size + let bytes = withUnsafePointer(to: ©) { + Data(bytes: $0, count: size) + } + try await write(bytes) + return bytes.count + } + + /// Write a boolean as a single byte (0 or 1) + /// - Parameter value: Boolean value + @discardableResult + public func write(_ value: Bool) async throws -> Int { + return try await write(UInt8(value ? 0x01 : 0x00)) + } + + /// Write a Float as its IEEE 754 bit pattern + /// - Parameters: + /// - value: Float value + /// - endian: Byte order (default: big-endian) + @discardableResult + public func write(_ value: Float, endian: Endian = .big) async throws -> Int { + return try await write(value.bitPattern, endian: endian) + } + + /// Write a Double as its IEEE 754 bit pattern + /// - Parameters: + /// - value: Double value + /// - endian: Byte order (default: big-endian) + @discardableResult + public func write(_ value: Double, endian: Endian = .big) async throws -> Int { + return try await write(value.bitPattern, endian: endian) + } + + /// Write a string to the socket, optionally length-prefixed + /// + /// - Parameters: + /// - string: String to write + /// - encoding: Text encoding (default: UTF-8) + /// - allowLossyConversion: Allow lossy encoding if necessary (default: false) + /// - Throws: `NetSocketError` if encoding fails or write fails + @discardableResult + public func write(_ string: String, encoding: String.Encoding = .utf8, allowLossyConversion: Bool = false) async throws -> Int { + guard let data = string.data(using: encoding, allowLossyConversion: allowLossyConversion) else { + throw NetSocketError.encodeFailed(NSError(domain: "StringEncoding", code: -1)) + } + return try await write(data) + } + + // MARK: Receive Data + + /// Read data until a delimiter is found + /// + /// Searches the buffer for the delimiter pattern and returns all data up to (and optionally including) + /// the delimiter. The delimiter is always consumed from the stream. + /// + /// - Parameters: + /// - delimiter: Binary delimiter pattern to search for + /// - maxBytes: Maximum bytes to read before throwing (default: no limit) + /// - includeDelimiter: Whether to include delimiter in result (default: false) + /// - Returns: Data read from stream + /// - Throws: `NetSocketError.framingExceeded` if max bytes exceeded, or connection errors + public func read(past delimiter: Data, maxBytes: Int? = nil, includeDelimiter: Bool = false) async throws -> Data { + while true { + try Task.checkCancellation() + if let r = search(delimiter: delimiter) { + let consumeLen = r.upperBound - head + let data = try await read(consumeLen) + return includeDelimiter ? data : data.dropLast(delimiter.count) + } + if let maxBytes, availableBytes >= maxBytes { + throw NetSocketError.framingExceeded(max: maxBytes) + } + try await waitForData() + guard !isClosed || availableBytes > 0 else { throw NetSocketError.closed } + } + } + + /// Read exactly N bytes from the socket + /// + /// Waits for data to arrive if buffer doesn't contain enough bytes yet. The internal buffer + /// is automatically compacted after reading to prevent unbounded memory growth. + /// + /// - Parameter count: Number of bytes to read + /// - Returns: Exactly `count` bytes + /// - Throws: `NetSocketError.insufficientData` if connection closes before enough data arrives + public func read(_ count: Int) async throws -> Data { + try await self.ensureReadable(count) + let start = self.head + let end = self.head + count + let slice = self.buffer[start..(_ type: T.Type = T.self, endian: Endian = .big) async throws -> T { + let size = MemoryLayout.size + let data = try await self.read(size) + let value: T = data.withUnsafeBytes { raw in + raw.load(as: T.self) + } + switch endian { + case .big: return T(bigEndian: value) + case .little: return T(littleEndian: value) + } + } + + /// Read a fixed-length string + /// + /// - Parameters: + /// - length: Number of bytes to read + /// - encoding: Text encoding (default: UTF-8) + /// - Returns: Decoded string + /// - Throws: `NetSocketError` if decoding fails or insufficient data + public func read(_ length: Int, encoding: String.Encoding = .utf8) async throws -> String { + let data = try await self.read(length) + guard let s = String(data: data, encoding: encoding) else { + throw NetSocketError.decodeFailed(NSError()) + } + return s + } + + /// Read a string until a delimiter is found + /// + /// - Parameters: + /// - delimiter: Delimiter pattern to search for + /// - maxBytes: Maximum bytes to read before throwing (default: no limit) + /// - includeDelimiter: Whether to include delimiter in result (default: false) + /// - Returns: String read from stream (delimiter consumed but not included unless specified) + /// - Throws: `NetSocketError` if decoding fails, max bytes exceeded, or connection closed + public func read(until delimiter: Delimiter, maxBytes: Int? = nil, includeDelimiter: Bool = false) async throws -> String { + let bytes = try await read(past: delimiter.data, maxBytes: maxBytes, includeDelimiter: includeDelimiter) + guard let s = String(data: bytes, encoding: .utf8) else { throw NetSocketError.decodeFailed(NSError()) } + return s + } + + /// Read exactly N bytes with progress callbacks + /// + /// Like `read(_:)`, but reads in chunks and reports progress after each chunk. + /// Useful for downloading large amounts of data where you want to update UI progress. + /// + /// Example: + /// ```swift + /// let data = try await socket.read(1_000_000) { current, total in + /// print("Progress: \(current)/\(total)") + /// } + /// ``` + /// + /// - Parameters: + /// - count: Number of bytes to read + /// - chunkSize: Size of chunks to read at a time (default: 8192) + /// - progress: Optional callback with (bytesReceived, totalBytes) + /// - Returns: Exactly `count` bytes + /// - Throws: `NetSocketError` if connection closes before enough data arrives + public func read( + _ count: Int, + chunkSize: Int = 8192, + progress: (@Sendable (Int, Int) -> Void)? = nil + ) async throws -> Data { + var data = Data() + data.reserveCapacity(count) + var received = 0 + + while received < count { + try Task.checkCancellation() + let toRead = min(chunkSize, count - received) + let chunk = try await read(toRead) + data.append(chunk) + received += chunk.count + progress?(received, count) + } + + return data + } + + // MARK: Peek Data + + public var availableBytes: Int { self.buffer.count - self.head } + + public func peek(_ count: Int) -> Data? { + guard self.availableBytes >= count else { + return nil + } + + let slice = self.buffer[self.head..<(self.head + count)] + return Data(slice) // Don't advance head + } + + public func peek(upto count: Int) -> Data { + let amount = min(self.availableBytes, count) + guard amount > 0 else { + return Data() + } + + let slice = self.buffer[self.head..<(self.head + amount)] + return Data(slice) + } + + public func peek(awaiting count: Int) async throws -> Data { + try await self.ensureReadable(count) + let slice = self.buffer[self.head..<(self.head + count)] + return Data(slice) // Don't advance head + } + + // MARK: Skip Data + + /// Skip/discard exactly N bytes from the stream without allocating memory + public func skip(_ count: Int) async throws { + guard count > 0 else { return } + try await self.ensureReadable(count) + self.head += count + self.compactIfNeeded() + } + + /// Skip until delimiter is found (discards delimiter too) + public func skip(past delimiter: Data) async throws { + while true { + try Task.checkCancellation() + if let r = self.search(delimiter: delimiter) { + self.head = r.upperBound // Skip to end of delimiter + self.compactIfNeeded() + return + } + try await self.waitForData() + guard !self.isClosed else { + throw NetSocketError.closed + } + } + } + + // MARK: Files + + /// Upload a file from a URL, yielding progress as an AsyncSequence. + /// + /// Iterating this sequence drives the transfer. Each yielded value reports + /// the total bytes sent so far and the known total. Cancel the consuming + /// task to cancel the transfer. + /// + /// This method handles opening and closing the file handle automatically. + /// + /// - Parameters: + /// - url: File URL to upload. + /// - chunkSize: Size of each read chunk. + /// - Returns: An `AsyncThrowingStream` of `FileProgress` updates. + func writeFile(from url: URL, chunkSize: Int = 256 * 1024) -> AsyncThrowingStream { + // This stream wrapper manages the FileHandle's lifetime. + return AsyncThrowingStream(bufferingPolicy: .bufferingOldest(1)) { continuation in + // Capture self (the actor) to use in detached task + let actor = self + + // Open file on a background thread (file I/O is blocking) + let task = Task.detached { + let fh: FileHandle + let total: Int + + // 1. Open file and get length (blocking I/O, done off-actor) + do { + total = Int(try NetSocket.fileLength(at: url)) + fh = try FileHandle(forReadingFrom: url) + } catch { + continuation.finish(throwing: NetSocketError.failed(underlying: error)) + return + } + + // 2. Now switch to the actor context to call the actor-isolated method + let stream = await actor.writeFile( + from: fh, + length: total, + chunkSize: chunkSize + ) + + // 3. Forward all elements from the underlying stream to our stream + do { + for try await progress in stream { + try Task.checkCancellation() // Exit early if cancelled + continuation.yield(progress) + } + try? fh.close() + continuation.finish() + } catch is CancellationError { + try? fh.close() + continuation.finish() + } catch { + try? fh.close() + continuation.finish(throwing: error) + } + } + + // If the *consumer* cancels the stream, we cancel our managing task. + continuation.onTermination = { @Sendable _ in + task.cancel() + } + } + } + + /// Upload a file from an open FileHandle, yielding progress as an AsyncSequence. + /// + /// Iterating this sequence drives the transfer. Each yielded value reports + /// the total bytes sent so far and the known total. Cancel the consuming + /// task to cancel the transfer. + /// + /// **Note:** The caller is responsible for opening and closing the `fileHandle`. + /// + /// - Parameters: + /// - fileHandle: Open `FileHandle` for reading. + /// - length: Exact number of bytes to send (total file size). + /// - chunkSize: Size of each read chunk. + /// - Returns: An `AsyncThrowingStream` of `FileProgress` updates. + func writeFile(from fileHandle: FileHandle, length: Int, chunkSize: Int = 256 * 1024) -> AsyncThrowingStream { + precondition(length >= 0, "length must be >= 0") + + if length == 0 { + return AsyncThrowingStream { continuation in + continuation.yield(.init(sent: 0, total: 0, bytesPerSecond: 0, estimatedTimeRemaining: 0)) + continuation.finish() + } + } + + return AsyncThrowingStream(bufferingPolicy: .bufferingOldest(1)) { continuation in + let task = Task { [weak self] in + guard let self else { + continuation.finish() + return + } + + var estimator = TransferRateEstimator(total: Int(length)) + + do { + try await self.ensureReady() + + while estimator.transferred < length { + try Task.checkCancellation() + + let toRead = Int(min(chunkSize, length - estimator.transferred)) + + // Read from disk + guard let chunk = try fileHandle.read(upToCount: toRead), !chunk.isEmpty else { + if estimator.transferred < length { + throw NetSocketError.failed(underlying: NSError( + domain: "NetSocket", code: 9001, + userInfo: [NSLocalizedDescriptionKey: "File read ended prematurely. Expected \(length) bytes, got \(estimator.transferred)."] + )) + } + break + } + + // Write to network + try await self.write(chunk) + + // Update estimator and yield progress + let progress = estimator.update(bytes: chunk.count) + continuation.yield(progress) + } + + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { @Sendable _ in + task.cancel() + } + } + } + + /// Receive a file of known length and yield progress updates as an AsyncSequence. + /// + /// Iterating this sequence drives the transfer. Each yielded value reports + /// the total bytes written so far and the known total. Cancel the consuming + /// task to cancel the transfer. + /// + /// - Parameters: + /// - fileHandle: Open `FileHandle` for writing (caller must close). + /// - length: Exact number of bytes expected. + /// - chunkSize: Size of each read chunk. + /// - Returns: An `AsyncThrowingStream` of `FileProgress` updates. + func receiveFile(to fileHandle: FileHandle, length: Int, chunkSize: Int = 256 * 1024) -> AsyncThrowingStream { + precondition(length >= 0, "length must be >= 0") + + if length == 0 { + return AsyncThrowingStream { continuation in + continuation.yield(.init(sent: 0, total: 0, bytesPerSecond: 0, estimatedTimeRemaining: 0)) + continuation.finish() + } + } + + return AsyncThrowingStream(bufferingPolicy: .bufferingOldest(1)) { continuation in + let task = Task { [weak self] in + guard let self else { + continuation.finish() + return + } + + var estimator = TransferRateEstimator(total: length) + + do { + var remaining: Int = length + + while remaining > 0 { + try Task.checkCancellation() + let n = min(chunkSize, remaining) + + let chunk = try await self.read(n) + try fileHandle.write(contentsOf: chunk) + + let chunkSize = Int(chunk.count) + remaining -= chunkSize + let progress = estimator.update(bytes: chunkSize) + continuation.yield(progress) + } + + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { @Sendable _ in + task.cancel() + } + } + } + + /// Download a file of known length and write it to disk in chunks + /// + /// This method does **not** read a length prefix. The caller must provide the expected + /// file size (e.g., from protocol metadata). The file is streamed directly to disk to + /// avoid loading it entirely into memory. + /// + /// Supports atomic writes: when enabled, data is written to a temporary `.part` file and + /// renamed on success. If an error occurs, the temporary file is automatically cleaned up. + /// + /// - Parameters: + /// - url: Destination file URL + /// - length: Exact number of bytes to read (must match what's on the wire) + /// - chunkSize: Chunk size for reading/writing (default: 256 KB) + /// - overwrite: Whether to overwrite existing file (default: true) + /// - atomic: Write to temporary file and rename on success (default: true) + /// - progress: Optional progress callback + /// - Returns: Total bytes written (equals `length` on success) + /// - Throws: File I/O or network errors. On atomic writes, partial files are cleaned up. + /// + /// Example: + /// ```swift + /// // Hotline protocol: file size comes from transaction header + /// let transaction = try await socket.receive(HotlineTransaction.self) + /// try await socket.receiveFile( + /// to: destinationURL, + /// length: transaction.fileSize + /// ) + /// ``` + @discardableResult + func receiveFile( + to url: URL, + length: Int, + chunkSize: Int = 256 * 1024, + overwrite: Bool = true, + atomic: Bool = true, + progress: (@Sendable (FileProgress) -> Void)? = nil + ) async throws -> Int { + precondition(length >= 0, "length must be >= 0") + + // Fast path: nothing to do + if length == 0 { + if overwrite { try? FileManager.default.removeItem(at: url) } + FileManager.default.createFile(atPath: url.path, contents: Data(), attributes: nil) + return 0 + } + + // Prepare destination (optionally atomic) + let fm = FileManager.default + let dir = url.deletingLastPathComponent() + let tmp = atomic ? dir.appendingPathComponent(".\(url.lastPathComponent).part-\(UUID().uuidString)") : url + + if overwrite { try? fm.removeItem(at: tmp) } + if overwrite, !atomic { try? fm.removeItem(at: url) } + + // Create and open the file for writing + fm.createFile(atPath: tmp.path, contents: nil, attributes: nil) + let fh = try FileHandle(forWritingTo: tmp) + defer { try? fh.close() } + + var remaining: Int = length + var written: Int = 0 + + do { + while remaining > 0 { + try Task.checkCancellation() + let n = Int(min(chunkSize, remaining)) + let chunk = try await self.read(n) + try fh.write(contentsOf: chunk) + remaining -= n + written += Int(n) + progress?(.init(sent: written, total: length)) + } + } catch { + // Cleanup partial file on failure if we were writing atomically + if atomic { try? fm.removeItem(at: tmp) } + throw error + } + + // Atomically move into place if requested + if atomic { + if overwrite { try? fm.removeItem(at: url) } + try fm.moveItem(at: tmp, to: url) + } + + return written + } + + // MARK: Internals + + private func start() async throws { + self.connection.stateUpdateHandler = { state in + Task { [weak self] in + guard let self else { return } + switch state { + case .ready: + await self.setReady() + await self.resumeReadyWaiters(with: .success(())) + case .failed(let error): + await self.failAllWaiters(NetSocketError.failed(underlying: error)) + await self.setClosed() + case .waiting(let error): + // bubble as transient failure for awaiters; reconnect logic could live here + await self.resumeReadyWaiters(with: .failure(NetSocketError.failed(underlying: error))) + case .cancelled: + await self.failAllWaiters(NetSocketError.closed) + await self.setClosed() + default: + break + } + } + } + + // Kick off receive loop after .start + self.connection.start(queue: queue) + try await self.waitUntilReady() + self.startReceiveLoop() + } + + private func startReceiveLoop() { + @Sendable func loop(_ connection: NWConnection, chunk: Int, owner: NetSocket, connID: String) { + connection.receive(minimumIncompleteLength: 1, maximumLength: chunk) { [weak owner] data, _, isComplete, error in + Task { + guard let o = owner else { + return + } + + if let error { + await o.handleReceiveError(error) + return + } + if let data, !data.isEmpty { + await o.append(data, connID: connID) + } + if isComplete { + await o.handleEOF() + return + } + loop(connection, chunk: chunk, owner: o, connID: connID) + } + } + } + loop(connection, chunk: self.config.receiveChunk, owner: self, connID: connectionID) + } + + private func handleReceiveError(_ error: Error) { + self.isClosed = true + self.failAllWaiters(NetSocketError.failed(underlying: error)) + } + + private func handleEOF() { + self.isClosed = true + let waiters = self.dataWaiters + self.dataWaiters.removeAll() + for w in waiters { + w.resume() + } // wake so readers can observe closure + } + + private func setReady() { + self.ready = true + } + + private func setClosed() { + self.isClosed = true + } + + private func ensureReady() async throws { + if self.isClosed { + throw NetSocketError.closed + } + if !self.ready { + try await self.waitUntilReady() + } + } + + private func ensureReadable(_ count: Int) async throws { + try await self.ensureReady() + while self.availableBytes < count { + try Task.checkCancellation() + if self.isClosed { + throw NetSocketError.insufficientData(expected: count, got: self.availableBytes) + } + try await self.waitForData() + } + } + + private func waitForData() async throws { + try Task.checkCancellation() + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + if self.isClosed { + cont.resume() + return + } + self.dataWaiters.append(cont) + } + } + + private func compactIfNeeded() { + // Avoid unbounded memory as head advances + if self.head > 64 * 1024 && self.head > self.buffer.count / 2 { + self.buffer.removeSubrange(0.. Range? { + guard !delimiter.isEmpty, availableBytes >= delimiter.count else { return nil } + let hay = buffer[head.. Int64 { + let values = try url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) + guard values.isRegularFile == true else { + throw NetSocketError.failed(underlying: NSError( + domain: "NetSocket", code: 1001, + userInfo: [NSLocalizedDescriptionKey: "Not a regular file: \(url.path)"] + )) + } + if let s = values.fileSize { return Int64(s) } + let attrs = try FileManager.default.attributesOfItem(atPath: url.path) + if let n = attrs[.size] as? NSNumber { + return n.int64Value + } + throw NetSocketError.failed(underlying: NSError( + domain: "NetSocket", code: 1002, + userInfo: [NSLocalizedDescriptionKey: "Unable to determine file size for \(url.lastPathComponent)"] + )) + } + + private func waitUntilReady() async throws { + guard !self.ready else { return } + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + self.readyWaiters.append(cont) + } + } + + private func resumeReadyWaiters(with result: Result) { + let waiters = self.readyWaiters + self.readyWaiters.removeAll() + for w in waiters { + switch result { + case .success: w.resume() + case .failure(let e): w.resume(throwing: e) + } + } + } + + private func failAllWaiters(_ error: Error) { + self.resumeReadyWaiters(with: .failure(error)) + let waiters = self.dataWaiters + self.dataWaiters.removeAll() + for w in waiters { + w.resume(throwing: error) + } + } + + private func append(_ data: Data, connID: String) { + buffer.append(data) + if buffer.count - head > config.maxBufferBytes { + // Hard stop: drop connection rather than OOM'ing. + isClosed = true + connection.cancel() + failAllWaiters(NetSocketError.framingExceeded(max: config.maxBufferBytes)) + return + } + resumeDataWaiters() + } + + private func resumeDataWaiters() { + let waiters = dataWaiters + dataWaiters.removeAll() + for w in waiters { w.resume() } + } +} + +// MARK: - Utilities + +private extension Data { + mutating func appendInteger(_ value: T, endian: Endian) throws { + var v = value + switch endian { + case .big: v = T(bigEndian: value) + case .little: v = T(littleEndian: value) + } + var copy = v + withUnsafePointer(to: ©) { ptr in + self.append(contentsOf: UnsafeRawBufferPointer(start: ptr, count: MemoryLayout.size)) + } + } +} + +// MARK: - NetSocketEncodable + +/// Protocol for types that can encode themselves to binary data +/// +/// Types conforming to `NetSocketEncodable` produce binary data that can be sent over +/// a socket. Unlike writing field-by-field to the socket, encodable types build complete +/// binary messages that are sent in a single write operation for efficiency. +/// +/// Example: +/// ```swift +/// struct MyMessage: NetSocketEncodable { +/// let id: UInt32 +/// let name: String +/// +/// func encode(endian: Endian) throws -> Data { +/// var data = Data() +/// // Encode fields to data... +/// return data +/// } +/// } +/// +/// try await socket.send(message) +/// ``` +public protocol NetSocketEncodable: Sendable { + /// Encode this value to binary data + /// + /// Implementations should build a complete binary message and return it as Data. + /// The data will be sent to the socket in a single write operation. + /// + /// - Parameter endian: Byte order for multi-byte values + /// - Returns: Encoded binary data ready to send + /// - Throws: Encoding errors + func encode(endian: Endian) throws -> Data +} + +/// Protocol for types that can decode themselves directly from a socket stream +/// +/// Types conforming to `NetSocketDecodable` read field-by-field directly from the socket +/// using async reads. This enables true streaming without buffering entire messages. +/// +/// **Important**: If decoding throws after consuming some bytes (e.g., validation fails), +/// the socket will be left with those bytes consumed. In practice, this usually means the +/// connection should be closed. For most protocols this is acceptable since decode errors +/// indicate corrupt data or protocol violations. +/// +/// Example: +/// ```swift +/// struct MyMessage: NetSocketDecodable { +/// let id: UInt32 +/// let name: String +/// +/// init(from socket: NetSocket, endian: Endian) async throws { +/// self.id = try await socket.read(UInt32.self, endian: endian) +/// let nameLen = try await socket.read(UInt16.self, endian: endian) +/// let nameData = try await socket.readExactly(Int(nameLen)) +/// guard let name = String(data: nameData, encoding: .utf8) else { +/// throw NetSocketError.decodeFailed(NSError()) +/// } +/// self.name = name +/// } +/// } +/// +/// let message = try await socket.receive(MyMessage.self) +/// ``` +public protocol NetSocketDecodable: Sendable { + /// Decode a value by reading directly from the socket stream + /// + /// This initializer should read all necessary fields from the socket using + /// methods like `read(_:endian:)`, `readExactly(_:)`, `readString(length:)`, etc. + /// + /// The socket handles waiting for data to arrive, so you can read field by field + /// without worrying about buffering. + /// + /// - Parameters: + /// - socket: Socket to read from + /// - endian: Byte order for multi-byte values + /// - Throws: Network errors, insufficient data, or custom decoding errors + init(from socket: NetSocket, endian: Endian) async throws +} + +public extension NetSocket { + /// Send an encodable value to the socket + /// + /// The type encodes itself to binary data, which is then sent in a single write operation. + /// + /// Example: + /// ```swift + /// struct MyMessage: NetSocketEncodable { + /// let id: UInt32 + /// let name: String + /// + /// func encode(endian: Endian) throws -> Data { + /// var data = Data() + /// // Build binary message... + /// return data + /// } + /// } + /// + /// try await socket.send(message) + /// ``` + /// + /// - Parameters: + /// - value: Value conforming to NetSocketEncodable + /// - endian: Byte order (default: big-endian) + /// - Throws: Encoding or network errors + func send(_ value: T, endian: Endian = .big) async throws { + let data = try value.encode(endian: endian) + try await self.write(data) + } + + /// Receive and decode a value directly from the socket stream (no length prefix) + /// + /// The type reads field-by-field from the socket as needed, enabling true streaming + /// without buffering entire messages. Useful for protocols where message size isn't + /// known upfront or for progressive decoding. + /// + /// Example: + /// ```swift + /// struct ServerEntry: NetSocketDecodable { + /// let id: UInt32 + /// let name: String + /// + /// init(from socket: NetSocket, endian: Endian) async throws { + /// self.id = try await socket.read(UInt32.self, endian: endian) + /// // Read variable-length string... + /// } + /// } + /// + /// let entry = try await socket.receive(ServerEntry.self) + /// ``` + /// + /// - Parameters: + /// - type: Type conforming to NetSocketDecodable + /// - endian: Byte order (default: big-endian) + /// - Returns: Decoded value + /// - Throws: Decoding or network errors + func receive(_ type: T.Type, endian: Endian = .big) async throws -> T { + return try await T(from: self, endian: endian) + } +} diff --git a/Hotline/Library/NetSocket/NetSocketNew.swift b/Hotline/Library/NetSocket/NetSocketNew.swift deleted file mode 100644 index 12f4271..0000000 --- a/Hotline/Library/NetSocket/NetSocketNew.swift +++ /dev/null @@ -1,1127 +0,0 @@ -// NetSocket.swift -// Dustin Mierau • @mierau - -import Foundation -import Network - -/// Byte order for multi-byte integer values in binary protocols -public enum Endian { - /// Big-endian (network byte order, most significant byte first) - case big - /// Little-endian (least significant byte first) - case little -} - -/// Delimiter patterns for text-based protocols -public enum Delimiter { - /// Custom single byte delimiter - case byte(UInt8) - /// Null terminator (0x00) - case zeroByte - /// Line feed (\n, 0x0A) - case lineFeed - /// Carriage return + line feed (\r\n, 0x0D 0x0A) - case carriageReturnLineFeed - - /// Binary representation of this delimiter - var data: Data { - switch self { - case .byte(let b): return Data([b]) - case .zeroByte: return Data([0x00]) - case .lineFeed: return Data([0x0A]) - case .carriageReturnLineFeed: return Data([0x0D, 0x0A]) - } - } -} - -/// TLS/SSL encryption policy for socket connections -public struct TLSPolicy: Sendable { - /// Create a TLS-enabled policy with optional custom configuration - /// - Parameter configure: Optional closure to customize TLS options - public static func enabled(_ configure: (@Sendable (NWProtocolTLS.Options) -> Void)? = nil) -> TLSPolicy { - TLSPolicy(enabled: true, configure: configure) - } - - /// Create a policy with TLS disabled (plaintext connection) - public static var disabled: TLSPolicy { TLSPolicy(enabled: false, configure: nil) } - - /// Whether TLS is enabled - public let enabled: Bool - /// Optional TLS configuration closure - public let configure: (@Sendable (NWProtocolTLS.Options) -> Void)? -} - -// MARK: - Errors - -/// Errors that can occur during socket operations -public enum NetSocketError: Error, CustomStringConvertible, Sendable { - /// Socket is not yet in ready state - case notReady - /// Connection has been closed - case closed - /// Invalid port number provided - case invalidPort - /// Network operation failed with underlying error - case failed(underlying: Error) - /// Not enough data available to fulfill read request - case insufficientData(expected: Int, got: Int) - /// Frame size exceeds configured maximum - case framingExceeded(max: Int) - /// Failed to decode data - case decodeFailed(Error) - /// Failed to encode data - case encodeFailed(Error) - - public var description: String { - switch self { - case .notReady: return "Connection not ready." - case .closed: return "Connection closed." - case .invalidPort: return "Invalid port number." - case .failed(let e): return "Network failure: \(e.localizedDescription)" - case .insufficientData(let exp, let got): return "Insufficient data: need \(exp), have \(got)." - case .framingExceeded(let max): return "Frame length exceeded maximum \(max)." - case .decodeFailed(let e): return "Decoding failed: \(e)" - case .encodeFailed(let e): return "Encoding failed: \(e)" - } - } -} - -/// An async/await TCP socket with automatic buffering and framing support -/// -/// NetSocket provides: -/// - Async connection management -/// - Automatic receive buffering with memory compaction -/// - Type-safe reading/writing of integers, strings, and custom types -/// - File upload/download with progress tracking -/// -/// Example usage: -/// ```swift -/// let socket = try await NetSocket.connect(host: "example.com", port: 80) -/// try await socket.write("Hello\n".data(using: .utf8)!) -/// let response = try await socket.readUntil(delimiter: .lineFeed) -/// ``` -public actor NetSocket { - /// Configuration options for the socket - public struct Config: Sendable { - /// Size of chunks to receive from network at once (default: 64 KB) - public var receiveChunk: Int = 64 * 1024 - /// Maximum bytes to buffer before disconnecting (default: 8 MB) - public var maxBufferBytes: Int = 8 * 1024 * 1024 - public init() {} - } - - // Connection + state - private let connection: NWConnection - private let queue = DispatchQueue(label: "NetSocket.NWConnection") - private var ready = false - private var isClosed = false - private let connectionID: String // For logging - - // Buffer with compaction - private var buffer = Data() - private var head = 0 // start of unread bytes - private let config: Config - - // Waiters for data/ready - private var dataWaiters: [CheckedContinuation] = [] - private var readyWaiters: [CheckedContinuation] = [] - - // MARK: Init - - private init(connection: NWConnection, config: Config) { - self.connection = connection - self.config = config - // Create a human-readable connection ID for logging - if case .hostPort(host: let h, port: let p) = connection.endpoint { - self.connectionID = "\(h):\(p)" - } else { - self.connectionID = "unknown" - } - } - - // MARK: Connect - - /// Connect to a remote host and return a ready socket - /// - /// This method establishes a TCP connection using Network framework types and waits until - /// the connection is in `.ready` state. - /// - /// - Parameters: - /// - host: Network framework host (e.g., `.name("example.com", nil)` or `.ipv4(...)`) - /// - port: Network framework port - /// - tls: TLS policy (default: enabled with default settings) - /// - config: Socket configuration (default: standard settings) - /// - Returns: A connected and ready `NetSocket` - /// - Throws: Network errors or connection failures - public static func connect(host: NWEndpoint.Host, port: NWEndpoint.Port, tls: TLSPolicy = .enabled(), config: Config = .init()) async throws -> NetSocket { - let parameters = NWParameters.tcp - if tls.enabled { - let tlsOptions = NWProtocolTLS.Options() - tls.configure?(tlsOptions) - parameters.defaultProtocolStack.applicationProtocols.insert(tlsOptions, at: 0) - } - - let conn = NWConnection(host: host, port: port, using: parameters) - let socket = NetSocket(connection: conn, config: config) - try await socket.start() - return socket - } - - /// Convenience wrapper to connect using string hostname and integer port - public static func connect(host: String, port: UInt16, tls: TLSPolicy = .enabled(), config: Config = .init()) async throws -> NetSocket { - guard let nwPort = NWEndpoint.Port(rawValue: port) else { - throw NetSocketError.invalidPort - } - return try await self.connect(host: .name(host, nil), port: nwPort, tls: tls, config: config) - } - - // MARK: Close - - /// Close the connection gracefully - /// - /// Performs a graceful shutdown of the underlying network connection (e.g., TCP FIN) - /// and wakes all pending read/write operations with a `NetSocketError.closed` error. - /// This method is idempotent - subsequent calls are ignored. - /// - /// Use `forceClose()` for immediate non-graceful termination (e.g., TCP RST). - public func close() { - guard !isClosed else { return } - isClosed = true - connection.cancel() - resumeDataWaiters() - resumeReadyWaiters(with: .failure(NetSocketError.closed)) - } - - /// Force close the connection immediately (non-graceful) - /// - /// Performs an immediate non-graceful shutdown of the underlying network connection - /// (e.g., TCP RST). Use this when you need to terminate the connection immediately - /// without waiting for graceful closure. For normal shutdown, use `close()` instead. - /// - /// This method is idempotent - subsequent calls are ignored. - public func forceClose() { - guard !isClosed else { return } - isClosed = true - connection.forceCancel() - resumeDataWaiters() - resumeReadyWaiters(with: .failure(NetSocketError.closed)) - } - - // MARK: Send Data - - /// Write raw data to the socket - /// - /// Sends data and waits for confirmation that it has been processed by the network stack. - /// - /// - Parameter data: Raw bytes to send - /// - Throws: `NetSocketError` if connection is not ready or send fails - @discardableResult - public func write(_ data: Data) async throws -> Int { - try await ensureReady() - return try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in - connection.send(content: data, completion: .contentProcessed { error in - if let error { cont.resume(throwing: NetSocketError.failed(underlying: error)) } - else { cont.resume(returning: data.count) } - }) - } - } - - /// Write a fixed-width integer to the socket - /// - /// - Parameters: - /// - value: The integer value to write - /// - endian: Byte order (default: big-endian) - /// - Throws: `NetSocketError` if write fails - @discardableResult - public func write(_ value: T, endian: Endian = .big) async throws -> Int { - var v = value - switch endian { - case .big: v = T(bigEndian: value) - case .little: v = T(littleEndian: value) - } - var copy = v - let size = MemoryLayout.size - let bytes = withUnsafePointer(to: ©) { - Data(bytes: $0, count: size) - } - try await write(bytes) - return bytes.count - } - - /// Write a boolean as a single byte (0 or 1) - /// - Parameter value: Boolean value - @discardableResult - public func write(_ value: Bool) async throws -> Int { - return try await write(UInt8(value ? 0x01 : 0x00)) - } - - /// Write a Float as its IEEE 754 bit pattern - /// - Parameters: - /// - value: Float value - /// - endian: Byte order (default: big-endian) - @discardableResult - public func write(_ value: Float, endian: Endian = .big) async throws -> Int { - return try await write(value.bitPattern, endian: endian) - } - - /// Write a Double as its IEEE 754 bit pattern - /// - Parameters: - /// - value: Double value - /// - endian: Byte order (default: big-endian) - @discardableResult - public func write(_ value: Double, endian: Endian = .big) async throws -> Int { - return try await write(value.bitPattern, endian: endian) - } - - /// Write a string to the socket, optionally length-prefixed - /// - /// - Parameters: - /// - string: String to write - /// - encoding: Text encoding (default: UTF-8) - /// - allowLossyConversion: Allow lossy encoding if necessary (default: false) - /// - Throws: `NetSocketError` if encoding fails or write fails - @discardableResult - public func write(_ string: String, encoding: String.Encoding = .utf8, allowLossyConversion: Bool = false) async throws -> Int { - guard let data = string.data(using: encoding, allowLossyConversion: allowLossyConversion) else { - throw NetSocketError.encodeFailed(NSError(domain: "StringEncoding", code: -1)) - } - return try await write(data) - } - - // MARK: Receive Data - - /// Read data until a delimiter is found - /// - /// Searches the buffer for the delimiter pattern and returns all data up to (and optionally including) - /// the delimiter. The delimiter is always consumed from the stream. - /// - /// - Parameters: - /// - delimiter: Binary delimiter pattern to search for - /// - maxBytes: Maximum bytes to read before throwing (default: no limit) - /// - includeDelimiter: Whether to include delimiter in result (default: false) - /// - Returns: Data read from stream - /// - Throws: `NetSocketError.framingExceeded` if max bytes exceeded, or connection errors - public func read(past delimiter: Data, maxBytes: Int? = nil, includeDelimiter: Bool = false) async throws -> Data { - while true { - try Task.checkCancellation() - if let r = search(delimiter: delimiter) { - let consumeLen = r.upperBound - head - let data = try await read(consumeLen) - return includeDelimiter ? data : data.dropLast(delimiter.count) - } - if let maxBytes, availableBytes >= maxBytes { - throw NetSocketError.framingExceeded(max: maxBytes) - } - try await waitForData() - guard !isClosed || availableBytes > 0 else { throw NetSocketError.closed } - } - } - - /// Read exactly N bytes from the socket - /// - /// Waits for data to arrive if buffer doesn't contain enough bytes yet. The internal buffer - /// is automatically compacted after reading to prevent unbounded memory growth. - /// - /// - Parameter count: Number of bytes to read - /// - Returns: Exactly `count` bytes - /// - Throws: `NetSocketError.insufficientData` if connection closes before enough data arrives - public func read(_ count: Int) async throws -> Data { - try await self.ensureReadable(count) - let start = self.head - let end = self.head + count - let slice = self.buffer[start..(_ type: T.Type = T.self, endian: Endian = .big) async throws -> T { - let size = MemoryLayout.size - let data = try await self.read(size) - let value: T = data.withUnsafeBytes { raw in - raw.load(as: T.self) - } - switch endian { - case .big: return T(bigEndian: value) - case .little: return T(littleEndian: value) - } - } - - /// Read a fixed-length string - /// - /// - Parameters: - /// - length: Number of bytes to read - /// - encoding: Text encoding (default: UTF-8) - /// - Returns: Decoded string - /// - Throws: `NetSocketError` if decoding fails or insufficient data - public func read(_ length: Int, encoding: String.Encoding = .utf8) async throws -> String { - let data = try await self.read(length) - guard let s = String(data: data, encoding: encoding) else { - throw NetSocketError.decodeFailed(NSError()) - } - return s - } - - /// Read a string until a delimiter is found - /// - /// - Parameters: - /// - delimiter: Delimiter pattern to search for - /// - maxBytes: Maximum bytes to read before throwing (default: no limit) - /// - includeDelimiter: Whether to include delimiter in result (default: false) - /// - Returns: String read from stream (delimiter consumed but not included unless specified) - /// - Throws: `NetSocketError` if decoding fails, max bytes exceeded, or connection closed - public func read(until delimiter: Delimiter, maxBytes: Int? = nil, includeDelimiter: Bool = false) async throws -> String { - let bytes = try await read(past: delimiter.data, maxBytes: maxBytes, includeDelimiter: includeDelimiter) - guard let s = String(data: bytes, encoding: .utf8) else { throw NetSocketError.decodeFailed(NSError()) } - return s - } - - /// Read exactly N bytes with progress callbacks - /// - /// Like `read(_:)`, but reads in chunks and reports progress after each chunk. - /// Useful for downloading large amounts of data where you want to update UI progress. - /// - /// Example: - /// ```swift - /// let data = try await socket.read(1_000_000) { current, total in - /// print("Progress: \(current)/\(total)") - /// } - /// ``` - /// - /// - Parameters: - /// - count: Number of bytes to read - /// - chunkSize: Size of chunks to read at a time (default: 8192) - /// - progress: Optional callback with (bytesReceived, totalBytes) - /// - Returns: Exactly `count` bytes - /// - Throws: `NetSocketError` if connection closes before enough data arrives - public func read( - _ count: Int, - chunkSize: Int = 8192, - progress: (@Sendable (Int, Int) -> Void)? = nil - ) async throws -> Data { - var data = Data() - data.reserveCapacity(count) - var received = 0 - - while received < count { - try Task.checkCancellation() - let toRead = min(chunkSize, count - received) - let chunk = try await read(toRead) - data.append(chunk) - received += chunk.count - progress?(received, count) - } - - return data - } - - // MARK: Peek Data - - public var availableBytes: Int { self.buffer.count - self.head } - - public func peek(_ count: Int) -> Data? { - guard self.availableBytes >= count else { - return nil - } - - let slice = self.buffer[self.head..<(self.head + count)] - return Data(slice) // Don't advance head - } - - public func peek(upto count: Int) -> Data { - let amount = min(self.availableBytes, count) - guard amount > 0 else { - return Data() - } - - let slice = self.buffer[self.head..<(self.head + amount)] - return Data(slice) - } - - public func peek(awaiting count: Int) async throws -> Data { - try await self.ensureReadable(count) - let slice = self.buffer[self.head..<(self.head + count)] - return Data(slice) // Don't advance head - } - - // MARK: Skip Data - - /// Skip/discard exactly N bytes from the stream without allocating memory - public func skip(_ count: Int) async throws { - guard count > 0 else { return } - try await self.ensureReadable(count) - self.head += count - self.compactIfNeeded() - } - - /// Skip until delimiter is found (discards delimiter too) - public func skip(past delimiter: Data) async throws { - while true { - try Task.checkCancellation() - if let r = self.search(delimiter: delimiter) { - self.head = r.upperBound // Skip to end of delimiter - self.compactIfNeeded() - return - } - try await self.waitForData() - guard !self.isClosed else { - throw NetSocketError.closed - } - } - } - - // MARK: Files - - /// Upload a file from a URL, yielding progress as an AsyncSequence. - /// - /// Iterating this sequence drives the transfer. Each yielded value reports - /// the total bytes sent so far and the known total. Cancel the consuming - /// task to cancel the transfer. - /// - /// This method handles opening and closing the file handle automatically. - /// - /// - Parameters: - /// - url: File URL to upload. - /// - chunkSize: Size of each read chunk. - /// - Returns: An `AsyncThrowingStream` of `FileProgress` updates. - func writeFile(from url: URL, chunkSize: Int = 256 * 1024) -> AsyncThrowingStream { - // This stream wrapper manages the FileHandle's lifetime. - return AsyncThrowingStream(bufferingPolicy: .bufferingOldest(1)) { continuation in - // Capture self (the actor) to use in detached task - let actor = self - - // Open file on a background thread (file I/O is blocking) - let task = Task.detached { - let fh: FileHandle - let total: Int - - // 1. Open file and get length (blocking I/O, done off-actor) - do { - total = Int(try NetSocket.fileLength(at: url)) - fh = try FileHandle(forReadingFrom: url) - } catch { - continuation.finish(throwing: NetSocketError.failed(underlying: error)) - return - } - - // 2. Now switch to the actor context to call the actor-isolated method - let stream = await actor.writeFile( - from: fh, - length: total, - chunkSize: chunkSize - ) - - // 3. Forward all elements from the underlying stream to our stream - do { - for try await progress in stream { - try Task.checkCancellation() // Exit early if cancelled - continuation.yield(progress) - } - try? fh.close() - continuation.finish() - } catch is CancellationError { - try? fh.close() - continuation.finish() - } catch { - try? fh.close() - continuation.finish(throwing: error) - } - } - - // If the *consumer* cancels the stream, we cancel our managing task. - continuation.onTermination = { @Sendable _ in - task.cancel() - } - } - } - - /// Upload a file from an open FileHandle, yielding progress as an AsyncSequence. - /// - /// Iterating this sequence drives the transfer. Each yielded value reports - /// the total bytes sent so far and the known total. Cancel the consuming - /// task to cancel the transfer. - /// - /// **Note:** The caller is responsible for opening and closing the `fileHandle`. - /// - /// - Parameters: - /// - fileHandle: Open `FileHandle` for reading. - /// - length: Exact number of bytes to send (total file size). - /// - chunkSize: Size of each read chunk. - /// - Returns: An `AsyncThrowingStream` of `FileProgress` updates. - func writeFile(from fileHandle: FileHandle, length: Int, chunkSize: Int = 256 * 1024) -> AsyncThrowingStream { - precondition(length >= 0, "length must be >= 0") - - if length == 0 { - return AsyncThrowingStream { continuation in - continuation.yield(.init(sent: 0, total: 0, bytesPerSecond: 0, estimatedTimeRemaining: 0)) - continuation.finish() - } - } - - return AsyncThrowingStream(bufferingPolicy: .bufferingOldest(1)) { continuation in - let task = Task { [weak self] in - guard let self else { - continuation.finish() - return - } - - var estimator = TransferRateEstimator(total: Int(length)) - - do { - try await self.ensureReady() - - while estimator.transferred < length { - try Task.checkCancellation() - - let toRead = Int(min(chunkSize, length - estimator.transferred)) - - // Read from disk - guard let chunk = try fileHandle.read(upToCount: toRead), !chunk.isEmpty else { - if estimator.transferred < length { - throw NetSocketError.failed(underlying: NSError( - domain: "NetSocket", code: 9001, - userInfo: [NSLocalizedDescriptionKey: "File read ended prematurely. Expected \(length) bytes, got \(estimator.transferred)."] - )) - } - break - } - - // Write to network - try await self.write(chunk) - - // Update estimator and yield progress - let progress = estimator.update(bytes: chunk.count) - continuation.yield(progress) - } - - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - } - - continuation.onTermination = { @Sendable _ in - task.cancel() - } - } - } - - /// Receive a file of known length and yield progress updates as an AsyncSequence. - /// - /// Iterating this sequence drives the transfer. Each yielded value reports - /// the total bytes written so far and the known total. Cancel the consuming - /// task to cancel the transfer. - /// - /// - Parameters: - /// - fileHandle: Open `FileHandle` for writing (caller must close). - /// - length: Exact number of bytes expected. - /// - chunkSize: Size of each read chunk. - /// - Returns: An `AsyncThrowingStream` of `FileProgress` updates. - func receiveFile(to fileHandle: FileHandle, length: Int, chunkSize: Int = 256 * 1024) -> AsyncThrowingStream { - precondition(length >= 0, "length must be >= 0") - - if length == 0 { - return AsyncThrowingStream { continuation in - continuation.yield(.init(sent: 0, total: 0, bytesPerSecond: 0, estimatedTimeRemaining: 0)) - continuation.finish() - } - } - - return AsyncThrowingStream(bufferingPolicy: .bufferingOldest(1)) { continuation in - let task = Task { [weak self] in - guard let self else { - continuation.finish() - return - } - - var estimator = TransferRateEstimator(total: length) - - do { - var remaining: Int = length - - while remaining > 0 { - try Task.checkCancellation() - let n = min(chunkSize, remaining) - - let chunk = try await self.read(n) - try fileHandle.write(contentsOf: chunk) - - let chunkSize = Int(chunk.count) - remaining -= chunkSize - let progress = estimator.update(bytes: chunkSize) - continuation.yield(progress) - } - - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - } - - continuation.onTermination = { @Sendable _ in - task.cancel() - } - } - } - - /// Download a file of known length and write it to disk in chunks - /// - /// This method does **not** read a length prefix. The caller must provide the expected - /// file size (e.g., from protocol metadata). The file is streamed directly to disk to - /// avoid loading it entirely into memory. - /// - /// Supports atomic writes: when enabled, data is written to a temporary `.part` file and - /// renamed on success. If an error occurs, the temporary file is automatically cleaned up. - /// - /// - Parameters: - /// - url: Destination file URL - /// - length: Exact number of bytes to read (must match what's on the wire) - /// - chunkSize: Chunk size for reading/writing (default: 256 KB) - /// - overwrite: Whether to overwrite existing file (default: true) - /// - atomic: Write to temporary file and rename on success (default: true) - /// - progress: Optional progress callback - /// - Returns: Total bytes written (equals `length` on success) - /// - Throws: File I/O or network errors. On atomic writes, partial files are cleaned up. - /// - /// Example: - /// ```swift - /// // Hotline protocol: file size comes from transaction header - /// let transaction = try await socket.receive(HotlineTransaction.self) - /// try await socket.receiveFile( - /// to: destinationURL, - /// length: transaction.fileSize - /// ) - /// ``` - @discardableResult - func receiveFile( - to url: URL, - length: Int, - chunkSize: Int = 256 * 1024, - overwrite: Bool = true, - atomic: Bool = true, - progress: (@Sendable (FileProgress) -> Void)? = nil - ) async throws -> Int { - precondition(length >= 0, "length must be >= 0") - - // Fast path: nothing to do - if length == 0 { - if overwrite { try? FileManager.default.removeItem(at: url) } - FileManager.default.createFile(atPath: url.path, contents: Data(), attributes: nil) - return 0 - } - - // Prepare destination (optionally atomic) - let fm = FileManager.default - let dir = url.deletingLastPathComponent() - let tmp = atomic ? dir.appendingPathComponent(".\(url.lastPathComponent).part-\(UUID().uuidString)") : url - - if overwrite { try? fm.removeItem(at: tmp) } - if overwrite, !atomic { try? fm.removeItem(at: url) } - - // Create and open the file for writing - fm.createFile(atPath: tmp.path, contents: nil, attributes: nil) - let fh = try FileHandle(forWritingTo: tmp) - defer { try? fh.close() } - - var remaining: Int = length - var written: Int = 0 - - do { - while remaining > 0 { - try Task.checkCancellation() - let n = Int(min(chunkSize, remaining)) - let chunk = try await self.read(n) - try fh.write(contentsOf: chunk) - remaining -= n - written += Int(n) - progress?(.init(sent: written, total: length)) - } - } catch { - // Cleanup partial file on failure if we were writing atomically - if atomic { try? fm.removeItem(at: tmp) } - throw error - } - - // Atomically move into place if requested - if atomic { - if overwrite { try? fm.removeItem(at: url) } - try fm.moveItem(at: tmp, to: url) - } - - return written - } - - // MARK: Internals - - private func start() async throws { - self.connection.stateUpdateHandler = { state in - Task { [weak self] in - guard let self else { return } - switch state { - case .ready: - await self.setReady() - await self.resumeReadyWaiters(with: .success(())) - case .failed(let error): - await self.failAllWaiters(NetSocketError.failed(underlying: error)) - await self.setClosed() - case .waiting(let error): - // bubble as transient failure for awaiters; reconnect logic could live here - await self.resumeReadyWaiters(with: .failure(NetSocketError.failed(underlying: error))) - case .cancelled: - await self.failAllWaiters(NetSocketError.closed) - await self.setClosed() - default: - break - } - } - } - - // Kick off receive loop after .start - self.connection.start(queue: queue) - try await self.waitUntilReady() - self.startReceiveLoop() - } - - private func startReceiveLoop() { - @Sendable func loop(_ connection: NWConnection, chunk: Int, owner: NetSocket, connID: String) { - print("NetSocket[\(connID)]: Calling connection.receive(\(chunk)) to request more data...") - - connection.receive(minimumIncompleteLength: 1, maximumLength: chunk) { [weak owner] data, _, isComplete, error in - print("NetSocket[\(connID)]: Receive callback - data: \(data?.count ?? 0) bytes, isComplete: \(isComplete), error: \(String(describing: error))") - Task { - guard let o = owner else { - return - } - - if let error { - await o.handleReceiveError(error) - return - } - if let data, !data.isEmpty { - await o.append(data, connID: connID) - } - if isComplete { - print("NetSocket[\(connID)]: EOF from peer.") - await o.handleEOF() - return - } - loop(connection, chunk: chunk, owner: o, connID: connID) - } - } - } - loop(connection, chunk: self.config.receiveChunk, owner: self, connID: connectionID) - } - - private func handleReceiveError(_ error: Error) { - self.isClosed = true - self.failAllWaiters(NetSocketError.failed(underlying: error)) - } - - private func handleEOF() { - self.isClosed = true - let waiters = self.dataWaiters - self.dataWaiters.removeAll() - for w in waiters { - w.resume() - } // wake so readers can observe closure - } - - private func setReady() { - self.ready = true - } - - private func setClosed() { - self.isClosed = true - } - - private func ensureReady() async throws { - if self.isClosed { - throw NetSocketError.closed - } - if !self.ready { - try await self.waitUntilReady() - } - } - - private func ensureReadable(_ count: Int) async throws { - try await self.ensureReady() - while self.availableBytes < count { - try Task.checkCancellation() - if self.isClosed { - throw NetSocketError.insufficientData(expected: count, got: self.availableBytes) - } - try await self.waitForData() - } - } - - private func waitForData() async throws { - try Task.checkCancellation() - try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in - if self.isClosed { - cont.resume() - return - } - self.dataWaiters.append(cont) - } - } - - private func compactIfNeeded() { - // Avoid unbounded memory as head advances - if self.head > 64 * 1024 && self.head > self.buffer.count / 2 { - self.buffer.removeSubrange(0.. Range? { - guard !delimiter.isEmpty, availableBytes >= delimiter.count else { return nil } - let hay = buffer[head.. Int64 { - let values = try url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) - guard values.isRegularFile == true else { - throw NetSocketError.failed(underlying: NSError( - domain: "NetSocket", code: 1001, - userInfo: [NSLocalizedDescriptionKey: "Not a regular file: \(url.path)"] - )) - } - if let s = values.fileSize { return Int64(s) } - let attrs = try FileManager.default.attributesOfItem(atPath: url.path) - if let n = attrs[.size] as? NSNumber { - return n.int64Value - } - throw NetSocketError.failed(underlying: NSError( - domain: "NetSocket", code: 1002, - userInfo: [NSLocalizedDescriptionKey: "Unable to determine file size for \(url.lastPathComponent)"] - )) - } - - private func waitUntilReady() async throws { - guard !self.ready else { return } - try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in - self.readyWaiters.append(cont) - } - } - - private func resumeReadyWaiters(with result: Result) { - let waiters = self.readyWaiters - self.readyWaiters.removeAll() - for w in waiters { - switch result { - case .success: w.resume() - case .failure(let e): w.resume(throwing: e) - } - } - } - - private func failAllWaiters(_ error: Error) { - self.resumeReadyWaiters(with: .failure(error)) - let waiters = self.dataWaiters - self.dataWaiters.removeAll() - for w in waiters { - w.resume(throwing: error) - } - } - - private func append(_ data: Data, connID: String) { - print("NetSocket[\(connID)]: Received \(data.count) bytes from network, buffer now has \(buffer.count - head + data.count) available") - buffer.append(data) - if buffer.count - head > config.maxBufferBytes { - // Hard stop: drop connection rather than OOM'ing. - isClosed = true - connection.cancel() - failAllWaiters(NetSocketError.framingExceeded(max: config.maxBufferBytes)) - return - } - resumeDataWaiters() - } - - private func resumeDataWaiters() { - let waiters = dataWaiters - dataWaiters.removeAll() - for w in waiters { w.resume() } - } -} - -// MARK: - Utilities - -private extension Data { - mutating func appendInteger(_ value: T, endian: Endian) throws { - var v = value - switch endian { - case .big: v = T(bigEndian: value) - case .little: v = T(littleEndian: value) - } - var copy = v - withUnsafePointer(to: ©) { ptr in - self.append(contentsOf: UnsafeRawBufferPointer(start: ptr, count: MemoryLayout.size)) - } - } -} - -// MARK: - NetSocketEncodable - -/// Protocol for types that can encode themselves to binary data -/// -/// Types conforming to `NetSocketEncodable` produce binary data that can be sent over -/// a socket. Unlike writing field-by-field to the socket, encodable types build complete -/// binary messages that are sent in a single write operation for efficiency. -/// -/// Example: -/// ```swift -/// struct MyMessage: NetSocketEncodable { -/// let id: UInt32 -/// let name: String -/// -/// func encode(endian: Endian) throws -> Data { -/// var data = Data() -/// // Encode fields to data... -/// return data -/// } -/// } -/// -/// try await socket.send(message) -/// ``` -public protocol NetSocketEncodable: Sendable { - /// Encode this value to binary data - /// - /// Implementations should build a complete binary message and return it as Data. - /// The data will be sent to the socket in a single write operation. - /// - /// - Parameter endian: Byte order for multi-byte values - /// - Returns: Encoded binary data ready to send - /// - Throws: Encoding errors - func encode(endian: Endian) throws -> Data -} - -/// Protocol for types that can decode themselves directly from a socket stream -/// -/// Types conforming to `NetSocketDecodable` read field-by-field directly from the socket -/// using async reads. This enables true streaming without buffering entire messages. -/// -/// **Important**: If decoding throws after consuming some bytes (e.g., validation fails), -/// the socket will be left with those bytes consumed. In practice, this usually means the -/// connection should be closed. For most protocols this is acceptable since decode errors -/// indicate corrupt data or protocol violations. -/// -/// Example: -/// ```swift -/// struct MyMessage: NetSocketDecodable { -/// let id: UInt32 -/// let name: String -/// -/// init(from socket: NetSocket, endian: Endian) async throws { -/// self.id = try await socket.read(UInt32.self, endian: endian) -/// let nameLen = try await socket.read(UInt16.self, endian: endian) -/// let nameData = try await socket.readExactly(Int(nameLen)) -/// guard let name = String(data: nameData, encoding: .utf8) else { -/// throw NetSocketError.decodeFailed(NSError()) -/// } -/// self.name = name -/// } -/// } -/// -/// let message = try await socket.receive(MyMessage.self) -/// ``` -public protocol NetSocketDecodable: Sendable { - /// Decode a value by reading directly from the socket stream - /// - /// This initializer should read all necessary fields from the socket using - /// methods like `read(_:endian:)`, `readExactly(_:)`, `readString(length:)`, etc. - /// - /// The socket handles waiting for data to arrive, so you can read field by field - /// without worrying about buffering. - /// - /// - Parameters: - /// - socket: Socket to read from - /// - endian: Byte order for multi-byte values - /// - Throws: Network errors, insufficient data, or custom decoding errors - init(from socket: NetSocket, endian: Endian) async throws -} - -public extension NetSocket { - /// Send an encodable value to the socket - /// - /// The type encodes itself to binary data, which is then sent in a single write operation. - /// - /// Example: - /// ```swift - /// struct MyMessage: NetSocketEncodable { - /// let id: UInt32 - /// let name: String - /// - /// func encode(endian: Endian) throws -> Data { - /// var data = Data() - /// // Build binary message... - /// return data - /// } - /// } - /// - /// try await socket.send(message) - /// ``` - /// - /// - Parameters: - /// - value: Value conforming to NetSocketEncodable - /// - endian: Byte order (default: big-endian) - /// - Throws: Encoding or network errors - func send(_ value: T, endian: Endian = .big) async throws { - let data = try value.encode(endian: endian) - try await self.write(data) - } - - /// Receive and decode a value directly from the socket stream (no length prefix) - /// - /// The type reads field-by-field from the socket as needed, enabling true streaming - /// without buffering entire messages. Useful for protocols where message size isn't - /// known upfront or for progressive decoding. - /// - /// Example: - /// ```swift - /// struct ServerEntry: NetSocketDecodable { - /// let id: UInt32 - /// let name: String - /// - /// init(from socket: NetSocket, endian: Endian) async throws { - /// self.id = try await socket.read(UInt32.self, endian: endian) - /// // Read variable-length string... - /// } - /// } - /// - /// let entry = try await socket.receive(ServerEntry.self) - /// ``` - /// - /// - Parameters: - /// - type: Type conforming to NetSocketDecodable - /// - endian: Byte order (default: big-endian) - /// - Returns: Decoded value - /// - Throws: Decoding or network errors - func receive(_ type: T.Type, endian: Endian = .big) async throws -> T { - return try await T(from: self, endian: endian) - } -} diff --git a/Hotline/Library/NetSocket/TransferRateEstimator.swift b/Hotline/Library/NetSocket/TransferRateEstimator.swift index 60647a7..badf87a 100644 --- a/Hotline/Library/NetSocket/TransferRateEstimator.swift +++ b/Hotline/Library/NetSocket/TransferRateEstimator.swift @@ -1,4 +1,4 @@ -// TransferRateEstimator +// NetSocket: TransferRateEstimator // Dustin Mierau • @mierau // MIT License diff --git a/Hotline/Library/QuickLookPreviewView.swift b/Hotline/Library/QuickLookPreviewView.swift deleted file mode 100644 index 6ba154e..0000000 --- a/Hotline/Library/QuickLookPreviewView.swift +++ /dev/null @@ -1,22 +0,0 @@ -import SwiftUI -import Quartz - -/// Embeddable QuickLook preview view for macOS -/// -/// This view uses QLPreviewView to display file previews inline, without showing a modal. -/// Supports all file types that QuickLook supports (images, PDFs, videos, documents, etc.) -struct QuickLookPreviewView: NSViewRepresentable { - let fileURL: URL - - func makeNSView(context: Context) -> QLPreviewView { - let preview = QLPreviewView(frame: .zero, style: .normal)! - preview.autostarts = true - preview.shouldCloseWithWindow = true - preview.previewItem = fileURL as QLPreviewItem - return preview - } - - func updateNSView(_ nsView: QLPreviewView, context: Context) { - nsView.previewItem = fileURL as QLPreviewItem - } -} diff --git a/Hotline/Library/RegularExpressions.swift b/Hotline/Library/RegularExpressions.swift new file mode 100644 index 0000000..c1f2a18 --- /dev/null +++ b/Hotline/Library/RegularExpressions.swift @@ -0,0 +1,235 @@ +import RegexBuilder + +struct RegularExpressions { + static let messageBoardDivider = Regex { + Capture { + OneOrMore { + CharacterClass(.newlineSequence) + } + ZeroOrMore { + CharacterClass(.whitespace, .newlineSequence) + } + Repeat(2...) { + CharacterClass(.anyOf("_-")) + } + ZeroOrMore { + CharacterClass(.whitespace) + } + OneOrMore { + CharacterClass(.newlineSequence) + } + } + } + + static let supportedLinkScheme = Regex { + Anchor.startOfLine + ChoiceOf { + "hotline" + "http" + "https" + } + "://" + }.ignoresCase().anchorsMatchLineEndings() + + static let relaxedLink = Regex { + ChoiceOf { + Anchor.startOfLine + Anchor.wordBoundary + } + Capture { + // scheme (optional) + Optionally { + ChoiceOf { + "hotline://" + "http://" + "https://" + } + } + // domain name + OneOrMore { + CharacterClass( + .anyOf(".-@"), + ("a"..."z"), + ("0"..."9") + ) + } + // top-level domain name + "." + ChoiceOf { + "com" + "net" + "org" + "edu" + "gov" + "mil" + "aero" + "asia" + "biz" + "cat" + "coop" + "info" + "int" + "jobs" + "mobi" + "museum" + "name" + "pizza" + "post" + "pro" + "red" + "tel" + "today" + "travel" + "garden" + "online" + "ai" + "be" + "by" + "ca" + "co" + "de" + "er" + "es" + "fr" + "gs" + "ie" + "im" + "in" + "io" + "is" + "it" + "jp" + "la" + "ly" + "ma" + "md" + "me" + "my" + "nl" + "ps" + "pt" + "ja" + "st" + "to" + "tv" + "uk" + "ws" + } + // Port + Optionally { + ":" + OneOrMore { + CharacterClass(.digit) + } + } + // path + ZeroOrMore { + CharacterClass( + .anyOf("#_-/.?=&%\\()[]"), + ("a"..."z"), + ("0"..."9") + ) + } + } + ChoiceOf { + Anchor.endOfLine + Anchor.wordBoundary + } + } + .anchorsMatchLineEndings() + .ignoresCase() + + static let emailAddress = Regex { + ChoiceOf { + Anchor.startOfLine + Anchor.wordBoundary + } + Capture { + // username + OneOrMore { + CharacterClass( + .anyOf(".-_"), + ("a"..."z"), + ("0"..."9") + ) + } + "@" + // domain name + OneOrMore { + CharacterClass( + .anyOf(".-"), + ("a"..."z"), + ("0"..."9") + ) + } + // top-level domain name + "." + ChoiceOf { + "com" + "net" + "org" + "edu" + "gov" + "mil" + "aero" + "asia" + "biz" + "cat" + "coop" + "info" + "int" + "jobs" + "mobi" + "museum" + "name" + "pizza" + "post" + "pro" + "red" + "tel" + "today" + "travel" + "garden" + "online" + "ai" + "be" + "by" + "ca" + "co" + "de" + "er" + "es" + "fr" + "gs" + "ie" + "im" + "in" + "io" + "is" + "it" + "jp" + "la" + "ly" + "ma" + "md" + "me" + "my" + "nl" + "ps" + "pt" + "ja" + "st" + "to" + "tv" + "uk" + "ws" + } + } + ChoiceOf { + Anchor.endOfLine + Anchor.wordBoundary + } + } + .anchorsMatchLineEndings() + .ignoresCase() +} diff --git a/Hotline/Library/SoundEffects.swift b/Hotline/Library/SoundEffects.swift new file mode 100644 index 0000000..93f8b9a --- /dev/null +++ b/Hotline/Library/SoundEffects.swift @@ -0,0 +1,50 @@ +import Foundation +import AVFAudio + +enum SoundEffects: String { + case loggedIn = "logged-in" + case chatMessage = "chat-message" + case transferComplete = "transfer-complete" + case userLogin = "user-login" + case userLogout = "user-logout" + case newNews = "new-news" + case serverMessage = "server-message" + case error = "error" +} + +@Observable +class SoundEffectPlayer: NSObject, AVAudioPlayerDelegate { + static let shared = SoundEffectPlayer() + + private var activeSounds: [AVAudioPlayer] = [] + + func playSoundEffect(_ name: SoundEffects) { + // Load a local sound file + guard let soundFileURL = Bundle.main.url( + forResource: name.rawValue, + withExtension: "aiff" + ) else { + return + } + + DispatchQueue.main.async { [weak self] in + guard let self = self else { + return + } + + if let soundEffect = try? AVAudioPlayer(contentsOf: soundFileURL) { + soundEffect.delegate = self + soundEffect.volume = 0.75 + soundEffect.play() + + self.activeSounds.append(soundEffect) + } + } + } + + func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { + if let i = self.activeSounds.firstIndex(of: player) { + self.activeSounds.remove(at: i) + } + } +} diff --git a/Hotline/Library/SpinningGlobeView.swift b/Hotline/Library/SpinningGlobeView.swift deleted file mode 100644 index bccb969..0000000 --- a/Hotline/Library/SpinningGlobeView.swift +++ /dev/null @@ -1,90 +0,0 @@ -import SwiftUI - -/// An animated globe icon that cycles through different world regions -/// -/// Displays a spinning globe effect by cycling through SF Symbol images showing -/// different parts of the world. Useful for indicating network activity or global content. -/// -/// Example: -/// ```swift -/// SpinningGlobeView() -/// .frame(width: 16, height: 16) -/// -/// SpinningGlobeView(frameDelay: 0.5) -/// .frame(width: 24, height: 24) -/// ``` -struct SpinningGlobeView: View { - /// Delay between frames in seconds (default: 0.3) - let frameDelay: TimeInterval - - /// SF Symbol names for each frame of the globe animation - private let globeFrames = [ - "globe.americas.fill", - "globe.europe.africa.fill", - "globe.central.south.asia.fill", - "globe.asia.australia.fill" - ] - - @State private var currentFrameIndex = 0 - @State private var animationTask: Task? - - init(frameDelay: TimeInterval = 0.25) { - self.frameDelay = frameDelay - } - - var body: some View { - Image(systemName: globeFrames[currentFrameIndex]) - .onAppear { - startAnimation() - } - .onDisappear { - stopAnimation() - } - } - - private func startAnimation() { - animationTask = Task { - while !Task.isCancelled { - try? await Task.sleep(nanoseconds: UInt64(frameDelay * 1_000_000_000)) - - guard !Task.isCancelled else { break } - - currentFrameIndex = (currentFrameIndex + 1) % globeFrames.count - } - } - } - - private func stopAnimation() { - animationTask?.cancel() - animationTask = nil - } -} - -#Preview { - VStack(spacing: 20) { - HStack(spacing: 20) { - SpinningGlobeView() - .frame(width: 12, height: 12) - - SpinningGlobeView() - .frame(width: 16, height: 16) - - SpinningGlobeView() - .frame(width: 24, height: 24) - } - - Text("Different frame delays:") - - HStack(spacing: 20) { - SpinningGlobeView(frameDelay: 0.1) - .frame(width: 16, height: 16) - - SpinningGlobeView(frameDelay: 0.3) - .frame(width: 16, height: 16) - - SpinningGlobeView(frameDelay: 0.6) - .frame(width: 16, height: 16) - } - } - .padding() -} diff --git a/Hotline/Library/TextView.swift b/Hotline/Library/TextView.swift deleted file mode 100644 index 47746e2..0000000 --- a/Hotline/Library/TextView.swift +++ /dev/null @@ -1,119 +0,0 @@ -import SwiftUI - -struct BetterTextEditor: NSViewRepresentable { - - @Environment(\.lineSpacing) private var lineSpacing - - @Binding private var text: String - - private var customizations: [(NSTextView) -> Void] = [] - - init(text: Binding) { - self._text = text - } - - func makeCoordinator() -> Coordinator { - Coordinator(self) - } - - func makeNSView(context: Context) -> NSScrollView { - let scrollview = NSTextView.scrollablePlainDocumentContentTextView() - let textview = scrollview.documentView as! NSTextView - - textview.string = self.text - textview.delegate = context.coordinator - -// let p = NSMutableParagraphStyle() -// p.lineSpacing = self.lineSpacing -//// textview.defaultParagraphStyle = p -// textview.typingAttributes = [ -// .paragraphStyle: p -// ] - - textview.isEditable = true - textview.isRichText = false - textview.allowsUndo = true - textview.isFieldEditor = false - textview.usesAdaptiveColorMappingForDarkAppearance = true - textview.drawsBackground = false // true - textview.usesRuler = false - textview.usesFindBar = false - textview.isIncrementalSearchingEnabled = false - textview.isAutomaticQuoteSubstitutionEnabled = false - textview.isAutomaticDashSubstitutionEnabled = false - textview.isAutomaticSpellingCorrectionEnabled = true - textview.isAutomaticDataDetectionEnabled = false - textview.isAutomaticLinkDetectionEnabled = false - textview.usesInspectorBar = false - textview.usesFontPanel = false - textview.importsGraphics = false - textview.allowsImageEditing = false - textview.displaysLinkToolTips = true - textview.backgroundColor = NSColor.textBackgroundColor - textview.textContainerInset = NSSize(width: 16, height: 16) - textview.isContinuousSpellCheckingEnabled = true - textview.setSelectedRange(NSMakeRange(0, 0)) - self.customizations.forEach { $0(textview) } - - scrollview.scrollerStyle = .overlay - - return scrollview - } - - func updateNSView(_ nsView: NSScrollView, context: Context) { - let textview = nsView.documentView as! NSTextView - - if textview.string != text { - textview.string = text - } - - self.customizations.forEach { $0(textview) } - } - - func betterEditorFont(_ font: NSFont) -> Self { - self.customized { $0.font = font } - } - - func betterEditorParagraphStyle(_ paragraphStyle: NSParagraphStyle) -> Self { - self.customized { $0.defaultParagraphStyle = paragraphStyle } - } - - func betterEditorAutomaticDashSubstitution(_ enabled: Bool) -> Self { - self.customized { $0.isAutomaticDashSubstitutionEnabled = enabled } - } - - func betterEditorAutomaticQuoteSubstitution(_ enabled: Bool) -> Self { - self.customized { $0.isAutomaticQuoteSubstitutionEnabled = enabled } - } - - func betterEditorAutomaticSpellingCorrection(_ enabled: Bool) -> Self { - self.customized { $0.isAutomaticSpellingCorrectionEnabled = enabled } - } - - func betterEditorTextInset(_ size: NSSize) -> Self { - self.customized { $0.textContainerInset = size } - } - - class Coordinator: NSObject, NSTextViewDelegate { - var parent: BetterTextEditor - - init(_ parent: BetterTextEditor) { - self.parent = parent - } - - func textDidChange(_ notification: Notification) { - guard let textview = notification.object as? NSTextView else { - return - } - self.parent.text = textview.string - } - } -} - -private extension BetterTextEditor { - private func customized(_ customization: @escaping (NSTextView) -> Void) -> Self { - var copy = self - copy.customizations.append(customization) - return copy - } -} diff --git a/Hotline/Library/URLAdditions.swift b/Hotline/Library/URLAdditions.swift deleted file mode 100644 index 0ba2100..0000000 --- a/Hotline/Library/URLAdditions.swift +++ /dev/null @@ -1,55 +0,0 @@ -import Foundation -import UniformTypeIdentifiers - -extension URL { - func generateUniqueFilePath(filename base: String) -> String { - let fileManager = FileManager.default - var finalName = base - var counter = 2 - - // Helper function to generate a new filename with a counter - func makeFileName() -> String { - let baseName = (base as NSString).deletingPathExtension - let extensionName = (base as NSString).pathExtension - return extensionName.isEmpty ? "\(baseName) \(counter)" : "\(baseName) \(counter).\(extensionName)" - } - - // Check if file exists and append counter until a unique name is found - var filePath = self.appending(component: finalName).path(percentEncoded: false) - while fileManager.fileExists(atPath: filePath) { - finalName = makeFileName() - filePath = self.appending(component: finalName).path(percentEncoded: false) - counter += 1 - } - - return filePath - } -} - -extension UTType { - var canBePreviewedByQuickLook: Bool { - // QuickLook supports most common document types - let supportedSupertypes: [UTType] = [ - .image, - .movie, - .audio, - .pdf, - .font, - .usdz, - .text, - .sourceCode, - .spreadsheet, - .presentation, - -// Microsoft Office - .init(filenameExtension: "doc")!, - .init(filenameExtension: "docx")!, - .init(filenameExtension: "xls")!, - .init(filenameExtension: "xlsx")!, - .init(filenameExtension: "ppt")!, - .init(filenameExtension: "pptx")!, - ] - - return supportedSupertypes.contains { self.conforms(to: $0) } - } -} diff --git a/Hotline/Library/Utility/DAKeychain.swift b/Hotline/Library/Utility/DAKeychain.swift new file mode 100644 index 0000000..8031886 --- /dev/null +++ b/Hotline/Library/Utility/DAKeychain.swift @@ -0,0 +1,81 @@ +// +// DAKeychain.swift +// DAKeychain +// +// Created by Dejan on 28/02/2017. +// Copyright © 2017 Dejan. All rights reserved. +// + +import Foundation + +open class DAKeychain { + + open var loggingEnabled = false + + private init() {} + public static let shared = DAKeychain() + + open subscript(key: String) -> String? { + get { + return load(withKey: key) + } set { + DispatchQueue.global().sync(flags: .barrier) { + self.save(newValue, forKey: key) + } + } + } + + private func save(_ string: String?, forKey key: String) { + let query = keychainQuery(withKey: key) + let objectData: Data? = string?.data(using: .utf8, allowLossyConversion: false) + + if SecItemCopyMatching(query, nil) == noErr { + if let dictData = objectData { + let status = SecItemUpdate(query, NSDictionary(dictionary: [kSecValueData: dictData])) + logPrint("Update status: ", status) + } else { + let status = SecItemDelete(query) + logPrint("Delete status: ", status) + } + } else { + if let dictData = objectData { + query.setValue(dictData, forKey: kSecValueData as String) + let status = SecItemAdd(query, nil) + logPrint("Update status: ", status) + } + } + } + + private func load(withKey key: String) -> String? { + let query = keychainQuery(withKey: key) + query.setValue(kCFBooleanTrue, forKey: kSecReturnData as String) + query.setValue(kCFBooleanTrue, forKey: kSecReturnAttributes as String) + + var result: CFTypeRef? + let status = SecItemCopyMatching(query, &result) + + guard + let resultsDict = result as? NSDictionary, + let resultsData = resultsDict.value(forKey: kSecValueData as String) as? Data, + status == noErr + else { + logPrint("Load status: ", status) + return nil + } + return String(data: resultsData, encoding: .utf8) + } + + private func keychainQuery(withKey key: String) -> NSMutableDictionary { + let result = NSMutableDictionary() + result.setValue(kSecClassGenericPassword, forKey: kSecClass as String) + result.setValue(key, forKey: kSecAttrService as String) + result.setValue(kSecAttrAccessibleWhenUnlocked, forKey: kSecAttrAccessible as String) + return result + } + + private func logPrint(_ items: Any...) { + if loggingEnabled { + print(items) + } + } +} diff --git a/Hotline/Library/Utility/NSWindowBridge.swift b/Hotline/Library/Utility/NSWindowBridge.swift new file mode 100644 index 0000000..8f766d9 --- /dev/null +++ b/Hotline/Library/Utility/NSWindowBridge.swift @@ -0,0 +1,46 @@ +import SwiftUI + +fileprivate class NSWindowAccessorView: NSView { + let executeBlock: (_ window: NSWindow? ) -> () + + init(_ inConfigFunction: @escaping (_ window: NSWindow? ) -> () ) { + executeBlock = inConfigFunction + super.init( frame: NSRect() ) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + public override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + executeBlock( self.window ) // We pass it through even if it is nil. + } +} + +public struct NSWindowAccessor: NSViewRepresentable { + var configCode: (_ window: NSWindow? ) -> () + + public init(_ configCode: @escaping (_: NSWindow?) -> Void) { self.configCode = configCode } + public func makeNSView(context: Context) -> NSView { return NSWindowAccessorView( configCode ) } + public func updateNSView(_ nsView: NSView, context: Context) {} +} + + +//import SwiftUI + + /// A helper view you can embed once per window to run a closure +/// with the underlying NSWindow reference. +struct WindowConfigurator: NSViewRepresentable { + let configure: (NSWindow) -> Void + + func makeNSView(context: Context) -> NSView { + let view = NSView() + DispatchQueue.main.async { + if let window = view.window { + configure(window) + } + } + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { } +} diff --git a/Hotline/Library/Utility/ObservableScrollView.swift b/Hotline/Library/Utility/ObservableScrollView.swift new file mode 100644 index 0000000..a6f46cc --- /dev/null +++ b/Hotline/Library/Utility/ObservableScrollView.swift @@ -0,0 +1,38 @@ +import SwiftUI + +struct ScrollViewOffsetPreferenceKey: PreferenceKey { + typealias Value = CGFloat + static var defaultValue = CGFloat.zero + static func reduce(value: inout Value, nextValue: () -> Value) { + value += nextValue() + } +} + +struct ObservableScrollView: View where Content : View { + @Namespace var scrollSpace + @Binding var scrollOffset: CGFloat + let content: () -> Content + + init(scrollOffset: Binding, + @ViewBuilder content: @escaping () -> Content) { + _scrollOffset = scrollOffset + self.content = content + } + + var body: some View { + ScrollView { + content() + .background(GeometryReader { geo in + let offset = -geo.frame(in: .named(scrollSpace)).minY + Color.clear + .preference(key: ScrollViewOffsetPreferenceKey.self, + value: offset) + }) + + } + .coordinateSpace(name: scrollSpace) + .onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { value in + scrollOffset = value + } + } +} diff --git a/Hotline/Library/Utility/TextDocument.swift b/Hotline/Library/Utility/TextDocument.swift new file mode 100644 index 0000000..82727de --- /dev/null +++ b/Hotline/Library/Utility/TextDocument.swift @@ -0,0 +1,31 @@ +import Foundation +import UniformTypeIdentifiers +import SwiftUI + +struct TextFile: FileDocument { + // tell the system we support only plain text + static var readableContentTypes = [UTType.plainText, UTType.utf8PlainText] + + // by default our document is empty + var text = "" + + // a simple initializer that creates new, empty documents + init(initialText: String = "") { + text = initialText + } + + // this initializer loads data that has been saved previously + init(configuration: ReadConfiguration) throws { + + if let data = configuration.file.regularFileContents { + if let str = String(data: data, encoding: .utf8) { + self.text = str + } + } + } + + // this will be called when the system wants to write our data to disk + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + return FileWrapper(regularFileWithContents: self.text.data(using: .utf8)!) + } +} diff --git a/Hotline/Library/Views/AsyncLinkPreview.swift b/Hotline/Library/Views/AsyncLinkPreview.swift new file mode 100644 index 0000000..89b186f --- /dev/null +++ b/Hotline/Library/Views/AsyncLinkPreview.swift @@ -0,0 +1,57 @@ +import SwiftUI +import LinkPresentation + +fileprivate class CustomLinkView: LPLinkView { + override var intrinsicContentSize: CGSize { CGSize(width: 0, height: super.intrinsicContentSize.height) } +} + +struct AsyncLinkPreview: View { + @State private var metadata: LPLinkMetadata? + @State private var isLoading = true + let url: URL? + + func fetchMetadata() async { + guard let url else { + self.isLoading = false + return + } + do { + let provider = LPMetadataProvider() + let metadata = try await provider.startFetchingMetadata(for: url) + self.metadata = metadata + self.isLoading = false + } catch { + self.isLoading = false + } + } + + var body: some View { + if isLoading { + ProgressView() + .controlSize(.small) + .task { + await self.fetchMetadata() + } + } else if let metadata = metadata { + LinkView(metadata: metadata) + .frame(width: 200) + } else { + Text(LocalizedStringKey(url!.absoluteString)) + .multilineTextAlignment(.leading) + .tint(Color("Link Color")) + } + } +} + +struct LinkView: NSViewRepresentable { + var metadata: LPLinkMetadata + + func makeNSView(context: Context) -> LPLinkView { + let linkView = CustomLinkView(metadata: metadata) + return linkView + } + + func updateNSView(_ nsView: LPLinkView, context: Context) { + // Nothing required + } +} diff --git a/Hotline/Library/Views/BetterTextEditor.swift b/Hotline/Library/Views/BetterTextEditor.swift new file mode 100644 index 0000000..47746e2 --- /dev/null +++ b/Hotline/Library/Views/BetterTextEditor.swift @@ -0,0 +1,119 @@ +import SwiftUI + +struct BetterTextEditor: NSViewRepresentable { + + @Environment(\.lineSpacing) private var lineSpacing + + @Binding private var text: String + + private var customizations: [(NSTextView) -> Void] = [] + + init(text: Binding) { + self._text = text + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + func makeNSView(context: Context) -> NSScrollView { + let scrollview = NSTextView.scrollablePlainDocumentContentTextView() + let textview = scrollview.documentView as! NSTextView + + textview.string = self.text + textview.delegate = context.coordinator + +// let p = NSMutableParagraphStyle() +// p.lineSpacing = self.lineSpacing +//// textview.defaultParagraphStyle = p +// textview.typingAttributes = [ +// .paragraphStyle: p +// ] + + textview.isEditable = true + textview.isRichText = false + textview.allowsUndo = true + textview.isFieldEditor = false + textview.usesAdaptiveColorMappingForDarkAppearance = true + textview.drawsBackground = false // true + textview.usesRuler = false + textview.usesFindBar = false + textview.isIncrementalSearchingEnabled = false + textview.isAutomaticQuoteSubstitutionEnabled = false + textview.isAutomaticDashSubstitutionEnabled = false + textview.isAutomaticSpellingCorrectionEnabled = true + textview.isAutomaticDataDetectionEnabled = false + textview.isAutomaticLinkDetectionEnabled = false + textview.usesInspectorBar = false + textview.usesFontPanel = false + textview.importsGraphics = false + textview.allowsImageEditing = false + textview.displaysLinkToolTips = true + textview.backgroundColor = NSColor.textBackgroundColor + textview.textContainerInset = NSSize(width: 16, height: 16) + textview.isContinuousSpellCheckingEnabled = true + textview.setSelectedRange(NSMakeRange(0, 0)) + self.customizations.forEach { $0(textview) } + + scrollview.scrollerStyle = .overlay + + return scrollview + } + + func updateNSView(_ nsView: NSScrollView, context: Context) { + let textview = nsView.documentView as! NSTextView + + if textview.string != text { + textview.string = text + } + + self.customizations.forEach { $0(textview) } + } + + func betterEditorFont(_ font: NSFont) -> Self { + self.customized { $0.font = font } + } + + func betterEditorParagraphStyle(_ paragraphStyle: NSParagraphStyle) -> Self { + self.customized { $0.defaultParagraphStyle = paragraphStyle } + } + + func betterEditorAutomaticDashSubstitution(_ enabled: Bool) -> Self { + self.customized { $0.isAutomaticDashSubstitutionEnabled = enabled } + } + + func betterEditorAutomaticQuoteSubstitution(_ enabled: Bool) -> Self { + self.customized { $0.isAutomaticQuoteSubstitutionEnabled = enabled } + } + + func betterEditorAutomaticSpellingCorrection(_ enabled: Bool) -> Self { + self.customized { $0.isAutomaticSpellingCorrectionEnabled = enabled } + } + + func betterEditorTextInset(_ size: NSSize) -> Self { + self.customized { $0.textContainerInset = size } + } + + class Coordinator: NSObject, NSTextViewDelegate { + var parent: BetterTextEditor + + init(_ parent: BetterTextEditor) { + self.parent = parent + } + + func textDidChange(_ notification: Notification) { + guard let textview = notification.object as? NSTextView else { + return + } + self.parent.text = textview.string + } + } +} + +private extension BetterTextEditor { + private func customized(_ customization: @escaping (NSTextView) -> Void) -> Self { + var copy = self + copy.customizations.append(customization) + return copy + } +} diff --git a/Hotline/Library/Views/FileIconView.swift b/Hotline/Library/Views/FileIconView.swift new file mode 100644 index 0000000..d0949fa --- /dev/null +++ b/Hotline/Library/Views/FileIconView.swift @@ -0,0 +1,74 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct FolderIconView: View { + private func folderIcon() -> Image { +#if os(iOS) + return Image(systemName: "folder.fill") +#elseif os(macOS) + return Image(nsImage: NSWorkspace.shared.icon(for: UTType.folder)) +#endif + } + + var body: some View { + folderIcon() + .resizable() + .scaledToFit() + } +} + +struct FileIconView: View { + let filename: String + let fileType: String? + + #if os(iOS) + private func fileIcon() -> Image { + let fileExtension = (self.filename as NSString).pathExtension + if let fileType = UTType(filenameExtension: fileExtension) { + if fileType.isSubtype(of: .movie) { + return Image(systemName: "play.rectangle") + } + else if fileType.isSubtype(of: .image) { + return Image(systemName: "photo") + } + else if fileType.isSubtype(of: .archive) { + return Image(systemName: "doc.zipper") + } + else if fileType.isSubtype(of: .text) { + return Image(systemName: "doc.text") + } + else { + return Image(systemName: "doc") + } + } + + return Image(systemName: "doc") + } + #elseif os(macOS) + private func fileIcon() -> Image { + let fileExtension = (self.filename as NSString).pathExtension + + if !fileExtension.isEmpty, + let uttype = UTType(filenameExtension: fileExtension) { + return Image(nsImage: NSWorkspace.shared.icon(for: uttype)) + } + else if let fileType = self.fileType, + let fileTypeExtension = FileManager.HFSTypeToExtension[fileType.lowercased()], + let uttype = UTType(filenameExtension: fileTypeExtension) { + return Image(nsImage: NSWorkspace.shared.icon(for: uttype)) + } + else { + return Image(nsImage: NSWorkspace.shared.icon(for: UTType.data)) + } + +// Image(nsImage: NSWorkspace.shared.icon(for: UTType(filenameExtension: (filename as NSString).pathExtension) ?? UTType.content)) + } + #endif + + + var body: some View { + fileIcon() + .resizable() + .scaledToFit() + } +} diff --git a/Hotline/Library/Views/FileImageView.swift b/Hotline/Library/Views/FileImageView.swift new file mode 100644 index 0000000..0cc50f1 --- /dev/null +++ b/Hotline/Library/Views/FileImageView.swift @@ -0,0 +1,98 @@ +import SwiftUI + +struct FileImageView: NSViewRepresentable { + var image: NSImage? + + let minimumSize: CGSize = CGSize(width: 350, height: 350) + let presentationPaddingRatio: Double = 0.5 + + func makeNSView(context: Context) -> NSImageView { + let imageView = NSImageView() + imageView.imageScaling = .scaleProportionallyUpOrDown + imageView.animates = true + imageView.isEditable = false + imageView.allowsCutCopyPaste = true + imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + if let img = self.image { + imageView.image = img + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.resizeWindowForImageView(imageView) + } + } + + return imageView + } + + func updateNSView(_ nsView: NSImageView, context: Context) { + nsView.image = self.image + } + + // MARK: - + + func resizeWindowForImageView(_ imageView: NSImageView) { + guard let window = imageView.window, let img = imageView.image else { + return + } + + var windowRect = window.contentLayoutRect + let windowChromeSize = CGSize(width: window.frame.width - windowRect.width, height: window.frame.height - windowRect.height) + + var windowMinSize: CGSize = windowRect.size + let centerPoint = CGPoint(x: window.frame.midX, y: window.frame.midY) + + windowRect.size = img.size + + if let screen = window.screen { + var paddedScreenSize = screen.frame.size + paddedScreenSize.width *= self.presentationPaddingRatio + paddedScreenSize.height *= self.presentationPaddingRatio + + windowMinSize = aspectFit(source: windowRect.size, bounds: self.minimumSize) + windowRect.size = aspectFit(source: windowRect.size, bounds: paddedScreenSize, minimum: windowMinSize) + } + + windowRect.size.width += windowChromeSize.width + windowRect.size.height += windowChromeSize.height + + windowRect.origin.x = centerPoint.x - windowRect.width / 2.0 + windowRect.origin.y = centerPoint.y - windowRect.height / 2.0 + + window.setFrame(windowRect, display: true, animate: true) + +// Do these APIs even work?? +// window.aspectRatio = windowRect.size +// window.contentAspectRatio = windowRect.size + } + + func aspectFit(source sourceSize: CGSize, bounds boundingSize: CGSize, minimum minSize: CGSize? = nil) -> CGSize { + let sourceAspectRatio = sourceSize.width / sourceSize.height + + var fitSize: CGSize = sourceSize + + if fitSize.width > boundingSize.width { + fitSize.width = boundingSize.width + fitSize.height = fitSize.width / sourceAspectRatio + } + + if fitSize.height > boundingSize.height { + fitSize.height = boundingSize.height + fitSize.width = fitSize.height * sourceAspectRatio + } + + if let m = minSize { + if fitSize.width < m.width { + fitSize.width = m.width + fitSize.height = fitSize.width / sourceAspectRatio + } + + if fitSize.height < m.height { + fitSize.height = m.height + fitSize.width = fitSize.height * sourceAspectRatio + } + } + + return fitSize + } +} diff --git a/Hotline/Library/Views/GroupedIconView.swift b/Hotline/Library/Views/GroupedIconView.swift new file mode 100644 index 0000000..313fac6 --- /dev/null +++ b/Hotline/Library/Views/GroupedIconView.swift @@ -0,0 +1,23 @@ +import SwiftUI + +struct GroupedIconView: View { + let color: Color + let systemName: String + var padding: CGFloat = 6.0 + var cornerRadius: CGFloat = 8.0 + + var body: some View { + RoundedRectangle(cornerRadius: self.cornerRadius, style: .continuous) + .fill(LinearGradient(colors: [self.color.mix(with: .white, by: 0.2), self.color], startPoint: .top, endPoint: .bottom)) + .shadow(color: self.color.mix(with: .black, by: 0.5).opacity(0.2), radius: 1, x: 0, y: 1) + .overlay { + Image(systemName: self.systemName) + .resizable() + .scaledToFit() + .symbolRenderingMode(.palette) + .foregroundStyle(.white, .white.opacity(0.4)) + .padding(self.padding) + .shadow(color: self.color.mix(with: .black, by: 0.5).opacity(0.2), radius: 0, x: 0, y: -1) + } + } +} diff --git a/Hotline/Library/Views/HotlinePanel.swift b/Hotline/Library/Views/HotlinePanel.swift new file mode 100644 index 0000000..d7d8284 --- /dev/null +++ b/Hotline/Library/Views/HotlinePanel.swift @@ -0,0 +1,64 @@ +import Cocoa +import SwiftUI + +fileprivate let HOTLINE_PANEL_SIZE: CGSize = CGSizeMake(468, 114 - 10) + +class HotlinePanel: NSPanel { + init(_ view: HotlinePanelView) { + super.init(contentRect: NSRect(x: 0, y: 0, width: HOTLINE_PANEL_SIZE.width, height: HOTLINE_PANEL_SIZE.height), styleMask: [.nonactivatingPanel, .titled, .closable, .utilityWindow, .fullSizeContentView], backing: .buffered, defer: false) + + // Make sure that the panel is in front of almost all other windows + self.isFloatingPanel = true + self.level = .floating + self.hidesOnDeactivate = true + self.animationBehavior = .utilityWindow + + // Allow the panelto appear in a fullscreen space +// self.collectionBehavior.insert(.fullScreenAuxiliary) + self.collectionBehavior.insert(.canJoinAllSpaces) + self.collectionBehavior.insert(.ignoresCycle) + +// self.appearance = NSAppearance(named: .vibrantDark) + + // Don't delete panel state when it's closed. + self.isReleasedWhenClosed = false + + self.standardWindowButton(.closeButton)?.isHidden = false + self.standardWindowButton(.zoomButton)?.isHidden = true + self.standardWindowButton(.miniaturizeButton)?.isHidden = true + + // Make it transparent, the view inside will have to set the background. + // This is necessary because otherwise, we will have some space for the titlebar on top of the height of the view itself which we don't want. + self.isOpaque = false + self.backgroundColor = .clear + + // Since we don't show a statusbar, this allows us to drag the window by its background instead of the titlebar. + self.isMovableByWindowBackground = true + self.titlebarAppearsTransparent = true + + let hostingView = NSHostingView(rootView: view.edgesIgnoringSafeArea(.top)) + hostingView.sizingOptions = [.preferredContentSize] + + let visualEffectView = NSVisualEffectView(frame: NSRect(x: 0, y: 0, width: HOTLINE_PANEL_SIZE.width, height: HOTLINE_PANEL_SIZE.height)) + visualEffectView.material = .sidebar + visualEffectView.blendingMode = .behindWindow + visualEffectView.state = NSVisualEffectView.State.active + visualEffectView.autoresizingMask = [.width, .height] + visualEffectView.autoresizesSubviews = true + visualEffectView.addSubview(hostingView) + + self.contentView = visualEffectView + + hostingView.frame = visualEffectView.bounds + + self.cascadeTopLeft(from: NSMakePoint(16, 16)) + } + + override var canBecomeKey: Bool { + return false + } + + override var canBecomeMain: Bool { + return false + } +} diff --git a/Hotline/Library/Views/QuickLookPreviewView.swift b/Hotline/Library/Views/QuickLookPreviewView.swift new file mode 100644 index 0000000..6ba154e --- /dev/null +++ b/Hotline/Library/Views/QuickLookPreviewView.swift @@ -0,0 +1,22 @@ +import SwiftUI +import Quartz + +/// Embeddable QuickLook preview view for macOS +/// +/// This view uses QLPreviewView to display file previews inline, without showing a modal. +/// Supports all file types that QuickLook supports (images, PDFs, videos, documents, etc.) +struct QuickLookPreviewView: NSViewRepresentable { + let fileURL: URL + + func makeNSView(context: Context) -> QLPreviewView { + let preview = QLPreviewView(frame: .zero, style: .normal)! + preview.autostarts = true + preview.shouldCloseWithWindow = true + preview.previewItem = fileURL as QLPreviewItem + return preview + } + + func updateNSView(_ nsView: QLPreviewView, context: Context) { + nsView.previewItem = fileURL as QLPreviewItem + } +} diff --git a/Hotline/Library/Views/SpinningGlobeView.swift b/Hotline/Library/Views/SpinningGlobeView.swift new file mode 100644 index 0000000..bccb969 --- /dev/null +++ b/Hotline/Library/Views/SpinningGlobeView.swift @@ -0,0 +1,90 @@ +import SwiftUI + +/// An animated globe icon that cycles through different world regions +/// +/// Displays a spinning globe effect by cycling through SF Symbol images showing +/// different parts of the world. Useful for indicating network activity or global content. +/// +/// Example: +/// ```swift +/// SpinningGlobeView() +/// .frame(width: 16, height: 16) +/// +/// SpinningGlobeView(frameDelay: 0.5) +/// .frame(width: 24, height: 24) +/// ``` +struct SpinningGlobeView: View { + /// Delay between frames in seconds (default: 0.3) + let frameDelay: TimeInterval + + /// SF Symbol names for each frame of the globe animation + private let globeFrames = [ + "globe.americas.fill", + "globe.europe.africa.fill", + "globe.central.south.asia.fill", + "globe.asia.australia.fill" + ] + + @State private var currentFrameIndex = 0 + @State private var animationTask: Task? + + init(frameDelay: TimeInterval = 0.25) { + self.frameDelay = frameDelay + } + + var body: some View { + Image(systemName: globeFrames[currentFrameIndex]) + .onAppear { + startAnimation() + } + .onDisappear { + stopAnimation() + } + } + + private func startAnimation() { + animationTask = Task { + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: UInt64(frameDelay * 1_000_000_000)) + + guard !Task.isCancelled else { break } + + currentFrameIndex = (currentFrameIndex + 1) % globeFrames.count + } + } + } + + private func stopAnimation() { + animationTask?.cancel() + animationTask = nil + } +} + +#Preview { + VStack(spacing: 20) { + HStack(spacing: 20) { + SpinningGlobeView() + .frame(width: 12, height: 12) + + SpinningGlobeView() + .frame(width: 16, height: 16) + + SpinningGlobeView() + .frame(width: 24, height: 24) + } + + Text("Different frame delays:") + + HStack(spacing: 20) { + SpinningGlobeView(frameDelay: 0.1) + .frame(width: 16, height: 16) + + SpinningGlobeView(frameDelay: 0.3) + .frame(width: 16, height: 16) + + SpinningGlobeView(frameDelay: 0.6) + .frame(width: 16, height: 16) + } + } + .padding() +} diff --git a/Hotline/Library/Views/VisualEffectView.swift b/Hotline/Library/Views/VisualEffectView.swift new file mode 100644 index 0000000..e595c55 --- /dev/null +++ b/Hotline/Library/Views/VisualEffectView.swift @@ -0,0 +1,22 @@ +import SwiftUI + +struct VisualEffectView: NSViewRepresentable +{ + let material: NSVisualEffectView.Material + let blendingMode: NSVisualEffectView.BlendingMode + + func makeNSView(context: Context) -> NSVisualEffectView + { + let visualEffectView = NSVisualEffectView() + visualEffectView.material = material + visualEffectView.blendingMode = blendingMode + visualEffectView.state = NSVisualEffectView.State.active + return visualEffectView + } + + func updateNSView(_ visualEffectView: NSVisualEffectView, context: Context) + { + visualEffectView.material = material + visualEffectView.blendingMode = blendingMode + } +} diff --git a/Hotline/Library/VisualEffectView.swift b/Hotline/Library/VisualEffectView.swift deleted file mode 100644 index e595c55..0000000 --- a/Hotline/Library/VisualEffectView.swift +++ /dev/null @@ -1,22 +0,0 @@ -import SwiftUI - -struct VisualEffectView: NSViewRepresentable -{ - let material: NSVisualEffectView.Material - let blendingMode: NSVisualEffectView.BlendingMode - - func makeNSView(context: Context) -> NSVisualEffectView - { - let visualEffectView = NSVisualEffectView() - visualEffectView.material = material - visualEffectView.blendingMode = blendingMode - visualEffectView.state = NSVisualEffectView.State.active - return visualEffectView - } - - func updateNSView(_ visualEffectView: NSVisualEffectView, context: Context) - { - visualEffectView.material = material - visualEffectView.blendingMode = blendingMode - } -} diff --git a/Hotline/Utility/BookmarkDocument.swift b/Hotline/Utility/BookmarkDocument.swift deleted file mode 100644 index 47fa442..0000000 --- a/Hotline/Utility/BookmarkDocument.swift +++ /dev/null @@ -1,32 +0,0 @@ -import SwiftUI -import Foundation -import UniformTypeIdentifiers - -struct BookmarkDocument: FileDocument { - static var readableContentTypes: [UTType] { [.data, UTType(filenameExtension: "hlbm")!] } - static var writableContentTypes: [UTType] { [.data, UTType(filenameExtension: "hlbm")!] } - - var bookmark: Bookmark - - init(bookmark: Bookmark) { - self.bookmark = bookmark - } - - init(configuration: ReadConfiguration) throws { - guard configuration.file.isRegularFile, - let data = configuration.file.regularFileContents, - let fileName = configuration.file.preferredFilename, - let bookmark = Bookmark(fileData: data, name: (fileName as NSString).deletingPathExtension) - else { - throw CocoaError(.fileReadCorruptFile) - } - self.bookmark = bookmark - } - - func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { - let wrapper = FileWrapper(regularFileWithContents: self.bookmark.bookmarkFileData()!) - wrapper.fileAttributes[FileAttributeKey.hfsCreatorCode.rawValue] = "HTLC".fourCharCode() - wrapper.fileAttributes[FileAttributeKey.hfsTypeCode.rawValue] = "HTbm".fourCharCode() - return wrapper - } -} diff --git a/Hotline/Utility/ColorArt.swift b/Hotline/Utility/ColorArt.swift deleted file mode 100644 index 93889a3..0000000 --- a/Hotline/Utility/ColorArt.swift +++ /dev/null @@ -1,424 +0,0 @@ - -// Swift translation and modernization -// by Dustin Mierau -// -// of: -// -// ColorArt.swift -// SLColorArt by Panic Inc. -// -// Copyright (C) 2012 Panic Inc. Code by Wade Cosgrove. All rights reserved. -// -// Redistribution and use, with or without modification, are permitted -// provided that the following conditions are met: -// -// - Redistributions must reproduce the above copyright notice, this list of -// conditions and the following disclaimer in the documentation and/or other -// materials provided with the distribution. -// -// - Neither the name of Panic Inc nor the names of its contributors may be used -// to endorse or promote works derived from this software without specific prior -// written permission from Panic Inc. -// -// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -// ARE DISCLAIMED. IN NO EVENT SHALL PANIC INC BE LIABLE FOR ANY DIRECT, INDIRECT, -// INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR -// PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, -// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE -// POSSIBILITY OF SUCH DAMAGE. - -import AppKit -import SwiftUI - -fileprivate let kColorThresholdMinimumPercentage: CGFloat = 0.001 - -// ColorArt.analyze(image: img) -> ColorArt? - -struct ColorArt: Equatable { - let backgroundColor: NSColor - let primaryColor: NSColor - let secondaryColor: NSColor - let detailColor: NSColor -// let scaledImage: NSImage - - static func == (lhs: ColorArt, rhs: ColorArt) -> Bool { - return lhs.backgroundColor == rhs.backgroundColor && - lhs.primaryColor == rhs.primaryColor && - lhs.secondaryColor == rhs.secondaryColor && - lhs.detailColor == rhs.detailColor - } - - static func analyze(image: NSImage) -> ColorArt? { - print("ColorArt.analyze: Starting, image size: \(image.size)") - // Scale image to a reasonable size for analysis - // This is important because: - // 1. Makes analysis faster (fewer pixels) - // 2. Normalizes weird image dimensions - // 3. Ensures CGImage conversion succeeds - print("ColorArt.analyze: Calling scaleImage...") - let finalImage = Self.scaleImage(image, size: NSSize(width: 100, height: 100)) - print("ColorArt.analyze: scaleImage returned, scaled size: \(finalImage.size)") - - guard let colors = Self.analyzeImage(finalImage) else { - print("ColorArt.analyze: failed with no colors") - return nil - } - - print("ColorArt.analyze: returning colors", colors) - - return ColorArt(backgroundColor: colors.background, - primaryColor: colors.primary, - secondaryColor: colors.secondary, - detailColor: colors.detail) - } - - // MARK: - Image Scaling - - private static func scaleImage(_ image: NSImage, size scaledSize: NSSize) -> NSImage { - print("ColorArt.scaleImage: Entered, input: \(image.size), target: \(scaledSize)") - // Get CGImage directly without using lockFocus - print("ColorArt.scaleImage: Getting CGImage...") - guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { - print("ColorArt.scaleImage: Failed to get CGImage, returning original") - return image - } - print("ColorArt.scaleImage: Got CGImage") - - let imageSize = image.size - let squareSize = min(imageSize.width, imageSize.height) - - // Use native square size if passed zero size - let finalScaledSize = scaledSize == .zero ? NSSize(width: squareSize, height: squareSize) : scaledSize - - // Create bitmap context for drawing - let width = Int(finalScaledSize.width) - let height = Int(finalScaledSize.height) - let colorSpace = CGColorSpaceCreateDeviceRGB() - let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue) - - guard let context = CGContext( - data: nil, - width: width, - height: height, - bitsPerComponent: 8, - bytesPerRow: width * 4, - space: colorSpace, - bitmapInfo: bitmapInfo.rawValue - ) else { - return image - } - - // Draw the image scaled - context.interpolationQuality = .high - context.draw(cgImage, in: CGRect(x: 0, y: 0, width: finalScaledSize.width, height: finalScaledSize.height)) - - // Create NSImage from context - guard let scaledCGImage = context.makeImage() else { - return image - } - - let bitmapRep = NSBitmapImageRep(cgImage: scaledCGImage) - let finalImage = NSImage(size: finalScaledSize) - finalImage.addRepresentation(bitmapRep) - - return finalImage - } - - // MARK: - Image Analysis - - private static func analyzeImage(_ image: NSImage) -> (background: NSColor, primary: NSColor, secondary: NSColor, detail: NSColor)? { - var imageColors: NSCountedSet? - guard let backgroundColor = self.findEdgeColor(image, imageColors: &imageColors), - let colors = imageColors - else { - return nil - } - - let darkBackground = backgroundColor.isDarkColor - var primaryColor: NSColor? - var secondaryColor: NSColor? - var detailColor: NSColor? - - self.findTextColors(colors, primaryColor: &primaryColor, secondaryColor: &secondaryColor, detailColor: &detailColor, backgroundColor: backgroundColor) - - // Fallback to black or white if colors not found - if primaryColor == nil { - primaryColor = darkBackground ? .white : .black - } - - if secondaryColor == nil { - secondaryColor = darkBackground ? .white : .black - } - - if detailColor == nil { - detailColor = darkBackground ? .white : .black - } - - // Convert all colors to calibrated RGB color space for consistency - // This ensures all colors are in the same color space and prevents - // any color space conversion issues when used in SwiftUI - let rgbColorSpace = NSColorSpace.genericRGB - let finalBackground = backgroundColor.usingColorSpace(rgbColorSpace) ?? backgroundColor - let finalPrimary = primaryColor!.usingColorSpace(rgbColorSpace) ?? primaryColor! - let finalSecondary = secondaryColor!.usingColorSpace(rgbColorSpace) ?? secondaryColor! - let finalDetail = detailColor!.usingColorSpace(rgbColorSpace) ?? detailColor! - - return (finalBackground, finalPrimary, finalSecondary, finalDetail) - } - - // MARK: - Edge Color Detection - - private static func findEdgeColor(_ image: NSImage, imageColors: inout NSCountedSet?) -> NSColor? { - var bitmapRep: NSBitmapImageRep? - - // Try to get existing bitmap representation - if let existingRep = image.representations.last as? NSBitmapImageRep { - bitmapRep = existingRep - } else { - // Create bitmap rep from CGImage instead of using lockFocus - guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { - return nil - } - bitmapRep = NSBitmapImageRep(cgImage: cgImage) - } - - // Convert to RGB color space - guard let bitmapRep = bitmapRep?.converting(to: .genericRGB, renderingIntent: .default) else { - return nil - } - - let pixelsWide = bitmapRep.pixelsWide - let pixelsHigh = bitmapRep.pixelsHigh - - let colors = NSCountedSet(capacity: pixelsWide * pixelsHigh) - let leftEdgeColors = NSCountedSet(capacity: pixelsHigh) - var searchColumnX = 0 - - for x in 0.. 0.5 { - leftEdgeColors.add(color) - } - } - - if color.alphaComponent > CGFloat.ulpOfOne { - colors.add(color) - } - } - - // Background is clear, keep looking in next column for background color - if leftEdgeColors.count == 0 { - searchColumnX += 1 - } - } - - imageColors = colors - - var sortedColors: [CountedColor] = [] - - for color in leftEdgeColors { - guard let nsColor = color as? NSColor else { continue } - let colorCount = leftEdgeColors.count(for: nsColor) - - let randomColorsThreshold = Int(CGFloat(pixelsHigh) * kColorThresholdMinimumPercentage) - - if colorCount <= randomColorsThreshold { - continue - } - - sortedColors.append(CountedColor(color: nsColor, count: colorCount)) - } - - sortedColors.sort { $0.count > $1.count } - - guard var proposedEdgeColor = sortedColors.first else { - return nil - } - - // Want to choose color over black/white so we keep looking - if proposedEdgeColor.color.isBlackOrWhite { - for i in 1.. 0.3 { - if !nextProposedColor.color.isBlackOrWhite { - proposedEdgeColor = nextProposedColor - break - } - } else { - // Reached color threshold less than 30% of the original proposed edge color - break - } - } - } - - return proposedEdgeColor.color - } - - // MARK: - Text Color Detection - - private static func findTextColors(_ colors: NSCountedSet, primaryColor: inout NSColor?, secondaryColor: inout NSColor?, detailColor: inout NSColor?, backgroundColor: NSColor) { - var sortedColors: [CountedColor] = [] - let findDarkTextColor = !backgroundColor.isDarkColor - - for color in colors { - guard let nsColor = color as? NSColor else { continue } - let adjustedColor = nsColor.withMinimumSaturation(0.15) - - if adjustedColor.isDarkColor == findDarkTextColor { - let colorCount = colors.count(for: nsColor) - sortedColors.append(CountedColor(color: adjustedColor, count: colorCount)) - } - } - - sortedColors.sort { $0.count > $1.count } - - for container in sortedColors { - let curColor = container.color - - if primaryColor == nil { - if curColor.isContrasting(to: backgroundColor) { - primaryColor = curColor - } - } else if secondaryColor == nil { - if let primary = primaryColor, - primary.isDistinct(from: curColor) && curColor.isContrasting(to: backgroundColor) { - secondaryColor = curColor - } - } else if detailColor == nil { - if let primary = primaryColor, - let secondary = secondaryColor, - secondary.isDistinct(from: curColor) && - primary.isDistinct(from: curColor) && - curColor.isContrasting(to: backgroundColor) { - detailColor = curColor - break - } - } - } - } -} - -// MARK: - Helper Classes - -fileprivate struct CountedColor { - let color: NSColor - let count: Int -} - -// MARK: - NSColor Extensions - -extension NSColor { - var isDarkColor: Bool { - guard let convertedColor = usingColorSpace(.genericRGB) else { - return false - } - - var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 - convertedColor.getRed(&r, green: &g, blue: &b, alpha: &a) - - let lum = 0.2126 * r + 0.7152 * g + 0.0722 * b - - return lum < 0.5 - } - - func isDistinct(from compareColor: NSColor) -> Bool { - guard let convertedColor = usingColorSpace(.genericRGB), - let convertedCompareColor = compareColor.usingColorSpace(.genericRGB) - else { - return false - } - - var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 - var r1: CGFloat = 0, g1: CGFloat = 0, b1: CGFloat = 0, a1: CGFloat = 0 - - convertedColor.getRed(&r, green: &g, blue: &b, alpha: &a) - convertedCompareColor.getRed(&r1, green: &g1, blue: &b1, alpha: &a1) - - let threshold: CGFloat = 0.25 - - if abs(r - r1) > threshold || abs(g - g1) > threshold || abs(b - b1) > threshold || abs(a - a1) > threshold { - // Check for grays, prevent multiple gray colors - if abs(r - g) < 0.03 && abs(r - b) < 0.03 { - if abs(r1 - g1) < 0.03 && abs(r1 - b1) < 0.03 { - return false - } - } - - return true - } - - return false - } - - func withMinimumSaturation(_ minSaturation: CGFloat) -> NSColor { - guard let tempColor = usingColorSpace(.genericRGB) else { - return self - } - - var hue: CGFloat = 0, saturation: CGFloat = 0, brightness: CGFloat = 0, alpha: CGFloat = 0 - tempColor.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) - - if saturation < minSaturation { - return NSColor(calibratedHue: hue, saturation: minSaturation, brightness: brightness, alpha: alpha) - } - - return self - } - - var isBlackOrWhite: Bool { - guard let tempColor = usingColorSpace(.genericRGB) else { - return false - } - - var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 - tempColor.getRed(&r, green: &g, blue: &b, alpha: &a) - - // White - if r > 0.91 && g > 0.91 && b > 0.91 { - return true - } - - // Black - if r < 0.09 && g < 0.09 && b < 0.09 { - return true - } - - return false - } - - func isContrasting(to color: NSColor) -> Bool { - guard let backgroundColor = usingColorSpace(.genericRGB), - let foregroundColor = color.usingColorSpace(.genericRGB) - else { - return true - } - - var br: CGFloat = 0, bg: CGFloat = 0, bb: CGFloat = 0, ba: CGFloat = 0 - var fr: CGFloat = 0, fg: CGFloat = 0, fb: CGFloat = 0, fa: CGFloat = 0 - - backgroundColor.getRed(&br, green: &bg, blue: &bb, alpha: &ba) - foregroundColor.getRed(&fr, green: &fg, blue: &fb, alpha: &fa) - - let bLum = 0.2126 * br + 0.7152 * bg + 0.0722 * bb - let fLum = 0.2126 * fr + 0.7152 * fg + 0.0722 * fb - - let contrast: CGFloat - if bLum > fLum { - contrast = (bLum + 0.05) / (fLum + 0.05) - } else { - contrast = (fLum + 0.05) / (bLum + 0.05) - } - - return contrast > 1.6 - } -} diff --git a/Hotline/Utility/DAKeychain.swift b/Hotline/Utility/DAKeychain.swift deleted file mode 100644 index 8031886..0000000 --- a/Hotline/Utility/DAKeychain.swift +++ /dev/null @@ -1,81 +0,0 @@ -// -// DAKeychain.swift -// DAKeychain -// -// Created by Dejan on 28/02/2017. -// Copyright © 2017 Dejan. All rights reserved. -// - -import Foundation - -open class DAKeychain { - - open var loggingEnabled = false - - private init() {} - public static let shared = DAKeychain() - - open subscript(key: String) -> String? { - get { - return load(withKey: key) - } set { - DispatchQueue.global().sync(flags: .barrier) { - self.save(newValue, forKey: key) - } - } - } - - private func save(_ string: String?, forKey key: String) { - let query = keychainQuery(withKey: key) - let objectData: Data? = string?.data(using: .utf8, allowLossyConversion: false) - - if SecItemCopyMatching(query, nil) == noErr { - if let dictData = objectData { - let status = SecItemUpdate(query, NSDictionary(dictionary: [kSecValueData: dictData])) - logPrint("Update status: ", status) - } else { - let status = SecItemDelete(query) - logPrint("Delete status: ", status) - } - } else { - if let dictData = objectData { - query.setValue(dictData, forKey: kSecValueData as String) - let status = SecItemAdd(query, nil) - logPrint("Update status: ", status) - } - } - } - - private func load(withKey key: String) -> String? { - let query = keychainQuery(withKey: key) - query.setValue(kCFBooleanTrue, forKey: kSecReturnData as String) - query.setValue(kCFBooleanTrue, forKey: kSecReturnAttributes as String) - - var result: CFTypeRef? - let status = SecItemCopyMatching(query, &result) - - guard - let resultsDict = result as? NSDictionary, - let resultsData = resultsDict.value(forKey: kSecValueData as String) as? Data, - status == noErr - else { - logPrint("Load status: ", status) - return nil - } - return String(data: resultsData, encoding: .utf8) - } - - private func keychainQuery(withKey key: String) -> NSMutableDictionary { - let result = NSMutableDictionary() - result.setValue(kSecClassGenericPassword, forKey: kSecClass as String) - result.setValue(key, forKey: kSecAttrService as String) - result.setValue(kSecAttrAccessibleWhenUnlocked, forKey: kSecAttrAccessible as String) - return result - } - - private func logPrint(_ items: Any...) { - if loggingEnabled { - print(items) - } - } -} diff --git a/Hotline/Utility/FoundationExtensions.swift b/Hotline/Utility/FoundationExtensions.swift deleted file mode 100644 index 90a3032..0000000 --- a/Hotline/Utility/FoundationExtensions.swift +++ /dev/null @@ -1,107 +0,0 @@ -import Foundation -import SwiftUI - -enum Endianness { - case big - case little -} - -extension String { - - func markdownToAttributedString() -> AttributedString { - let markdownText = self.convertingLinksToMarkdown() - let attr = (try? AttributedString(markdown: markdownText, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(self) - - return attr - } - - func convertToAttributedStringWithLinks() -> AttributedString { - let attributedString: NSMutableAttributedString = NSMutableAttributedString(string: self) - let matches = self.ranges(of: RegularExpressions.relaxedLink) - for match in matches { - let matchString = String(self[match]) - if matchString.isEmailAddress() { - attributedString.addAttribute(.link, value: "mailto:\(matchString)", range: NSRange(match, in: self)) - } - else { - attributedString.addAttribute(.link, value: matchString, range: NSRange(match, in: self)) - } -// attributedString.addAttribute(.underlineStyle, value: 1, range: NSRange(match, in: self)) - } - return AttributedString(attributedString) - } - - func isEmailAddress() -> Bool { - self.wholeMatch(of: RegularExpressions.emailAddress) != nil - } - - func isWebURL() -> Bool { - guard let url = URL(string: self) else { - return false - } - switch url.scheme?.lowercased() { - case "http", "https": - return true - default: - return false - } - } - - func isImageURL() -> Bool { - guard let url = URL(string: self) else { - return false - } - - switch url.pathExtension.lowercased() { - case "jpg", "jpeg", "png", "gif": - return true - default: - return false - } - } - - func convertingLinksToMarkdown() -> String { -// var cp = String(self) - - self.replacing(RegularExpressions.relaxedLink) { match in - let linkText = self[match.range] - - // Only add https:// if the link doesn't already have a scheme - let hasScheme = (try? RegularExpressions.supportedLinkScheme.prefixMatch(in: linkText)) != nil - let url = hasScheme ? String(linkText) : "https://\(linkText)" - - return "[\(linkText)](\(url))" - } - -// cp.replace(RegularExpressions.relaxedLink) { match -> String in -// let linkText = self[match.range] -// var injectedScheme = "https://" -// if let _ = try? RegularExpressions.supportedLinkScheme.prefixMatch(in: linkText) { -// injectedScheme = "" -// } -// -// return "[\(linkText)](\(injectedScheme)\(linkText))" -// } -// return cp - } -} - - - -extension Binding where Value: OptionSet, Value == Value.Element { - func bindedValue(_ options: Value) -> Bool { - return wrappedValue.contains(options) - } - - func bind(_ options: Value) -> Binding { - return .init { () -> Bool in - self.wrappedValue.contains(options) - } set: { newValue in - if newValue { - self.wrappedValue.insert(options) - } else { - self.wrappedValue.remove(options) - } - } - } -} diff --git a/Hotline/Utility/NSWindowBridge.swift b/Hotline/Utility/NSWindowBridge.swift deleted file mode 100644 index 8f766d9..0000000 --- a/Hotline/Utility/NSWindowBridge.swift +++ /dev/null @@ -1,46 +0,0 @@ -import SwiftUI - -fileprivate class NSWindowAccessorView: NSView { - let executeBlock: (_ window: NSWindow? ) -> () - - init(_ inConfigFunction: @escaping (_ window: NSWindow? ) -> () ) { - executeBlock = inConfigFunction - super.init( frame: NSRect() ) - } - - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - - public override func viewDidMoveToWindow() { - super.viewDidMoveToWindow() - executeBlock( self.window ) // We pass it through even if it is nil. - } -} - -public struct NSWindowAccessor: NSViewRepresentable { - var configCode: (_ window: NSWindow? ) -> () - - public init(_ configCode: @escaping (_: NSWindow?) -> Void) { self.configCode = configCode } - public func makeNSView(context: Context) -> NSView { return NSWindowAccessorView( configCode ) } - public func updateNSView(_ nsView: NSView, context: Context) {} -} - - -//import SwiftUI - - /// A helper view you can embed once per window to run a closure -/// with the underlying NSWindow reference. -struct WindowConfigurator: NSViewRepresentable { - let configure: (NSWindow) -> Void - - func makeNSView(context: Context) -> NSView { - let view = NSView() - DispatchQueue.main.async { - if let window = view.window { - configure(window) - } - } - return view - } - - func updateNSView(_ nsView: NSView, context: Context) { } -} diff --git a/Hotline/Utility/ObservableScrollView.swift b/Hotline/Utility/ObservableScrollView.swift deleted file mode 100644 index a6f46cc..0000000 --- a/Hotline/Utility/ObservableScrollView.swift +++ /dev/null @@ -1,38 +0,0 @@ -import SwiftUI - -struct ScrollViewOffsetPreferenceKey: PreferenceKey { - typealias Value = CGFloat - static var defaultValue = CGFloat.zero - static func reduce(value: inout Value, nextValue: () -> Value) { - value += nextValue() - } -} - -struct ObservableScrollView: View where Content : View { - @Namespace var scrollSpace - @Binding var scrollOffset: CGFloat - let content: () -> Content - - init(scrollOffset: Binding, - @ViewBuilder content: @escaping () -> Content) { - _scrollOffset = scrollOffset - self.content = content - } - - var body: some View { - ScrollView { - content() - .background(GeometryReader { geo in - let offset = -geo.frame(in: .named(scrollSpace)).minY - Color.clear - .preference(key: ScrollViewOffsetPreferenceKey.self, - value: offset) - }) - - } - .coordinateSpace(name: scrollSpace) - .onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { value in - scrollOffset = value - } - } -} diff --git a/Hotline/Utility/RegularExpressions.swift b/Hotline/Utility/RegularExpressions.swift deleted file mode 100644 index c1f2a18..0000000 --- a/Hotline/Utility/RegularExpressions.swift +++ /dev/null @@ -1,235 +0,0 @@ -import RegexBuilder - -struct RegularExpressions { - static let messageBoardDivider = Regex { - Capture { - OneOrMore { - CharacterClass(.newlineSequence) - } - ZeroOrMore { - CharacterClass(.whitespace, .newlineSequence) - } - Repeat(2...) { - CharacterClass(.anyOf("_-")) - } - ZeroOrMore { - CharacterClass(.whitespace) - } - OneOrMore { - CharacterClass(.newlineSequence) - } - } - } - - static let supportedLinkScheme = Regex { - Anchor.startOfLine - ChoiceOf { - "hotline" - "http" - "https" - } - "://" - }.ignoresCase().anchorsMatchLineEndings() - - static let relaxedLink = Regex { - ChoiceOf { - Anchor.startOfLine - Anchor.wordBoundary - } - Capture { - // scheme (optional) - Optionally { - ChoiceOf { - "hotline://" - "http://" - "https://" - } - } - // domain name - OneOrMore { - CharacterClass( - .anyOf(".-@"), - ("a"..."z"), - ("0"..."9") - ) - } - // top-level domain name - "." - ChoiceOf { - "com" - "net" - "org" - "edu" - "gov" - "mil" - "aero" - "asia" - "biz" - "cat" - "coop" - "info" - "int" - "jobs" - "mobi" - "museum" - "name" - "pizza" - "post" - "pro" - "red" - "tel" - "today" - "travel" - "garden" - "online" - "ai" - "be" - "by" - "ca" - "co" - "de" - "er" - "es" - "fr" - "gs" - "ie" - "im" - "in" - "io" - "is" - "it" - "jp" - "la" - "ly" - "ma" - "md" - "me" - "my" - "nl" - "ps" - "pt" - "ja" - "st" - "to" - "tv" - "uk" - "ws" - } - // Port - Optionally { - ":" - OneOrMore { - CharacterClass(.digit) - } - } - // path - ZeroOrMore { - CharacterClass( - .anyOf("#_-/.?=&%\\()[]"), - ("a"..."z"), - ("0"..."9") - ) - } - } - ChoiceOf { - Anchor.endOfLine - Anchor.wordBoundary - } - } - .anchorsMatchLineEndings() - .ignoresCase() - - static let emailAddress = Regex { - ChoiceOf { - Anchor.startOfLine - Anchor.wordBoundary - } - Capture { - // username - OneOrMore { - CharacterClass( - .anyOf(".-_"), - ("a"..."z"), - ("0"..."9") - ) - } - "@" - // domain name - OneOrMore { - CharacterClass( - .anyOf(".-"), - ("a"..."z"), - ("0"..."9") - ) - } - // top-level domain name - "." - ChoiceOf { - "com" - "net" - "org" - "edu" - "gov" - "mil" - "aero" - "asia" - "biz" - "cat" - "coop" - "info" - "int" - "jobs" - "mobi" - "museum" - "name" - "pizza" - "post" - "pro" - "red" - "tel" - "today" - "travel" - "garden" - "online" - "ai" - "be" - "by" - "ca" - "co" - "de" - "er" - "es" - "fr" - "gs" - "ie" - "im" - "in" - "io" - "is" - "it" - "jp" - "la" - "ly" - "ma" - "md" - "me" - "my" - "nl" - "ps" - "pt" - "ja" - "st" - "to" - "tv" - "uk" - "ws" - } - } - ChoiceOf { - Anchor.endOfLine - Anchor.wordBoundary - } - } - .anchorsMatchLineEndings() - .ignoresCase() -} diff --git a/Hotline/Utility/SoundEffects.swift b/Hotline/Utility/SoundEffects.swift deleted file mode 100644 index 93f8b9a..0000000 --- a/Hotline/Utility/SoundEffects.swift +++ /dev/null @@ -1,50 +0,0 @@ -import Foundation -import AVFAudio - -enum SoundEffects: String { - case loggedIn = "logged-in" - case chatMessage = "chat-message" - case transferComplete = "transfer-complete" - case userLogin = "user-login" - case userLogout = "user-logout" - case newNews = "new-news" - case serverMessage = "server-message" - case error = "error" -} - -@Observable -class SoundEffectPlayer: NSObject, AVAudioPlayerDelegate { - static let shared = SoundEffectPlayer() - - private var activeSounds: [AVAudioPlayer] = [] - - func playSoundEffect(_ name: SoundEffects) { - // Load a local sound file - guard let soundFileURL = Bundle.main.url( - forResource: name.rawValue, - withExtension: "aiff" - ) else { - return - } - - DispatchQueue.main.async { [weak self] in - guard let self = self else { - return - } - - if let soundEffect = try? AVAudioPlayer(contentsOf: soundFileURL) { - soundEffect.delegate = self - soundEffect.volume = 0.75 - soundEffect.play() - - self.activeSounds.append(soundEffect) - } - } - } - - func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { - if let i = self.activeSounds.firstIndex(of: player) { - self.activeSounds.remove(at: i) - } - } -} diff --git a/Hotline/Utility/SwiftUIExtensions.swift b/Hotline/Utility/SwiftUIExtensions.swift deleted file mode 100644 index d0dfff4..0000000 --- a/Hotline/Utility/SwiftUIExtensions.swift +++ /dev/null @@ -1,8 +0,0 @@ -import SwiftUI -import Foundation - -extension Color { - init(hex: Int, opacity: Double = 1.0) { - self.init(red: Double((hex >> 16) & 0xFF) / 255.0, green: Double((hex >> 8) & 0xFF) / 255.0, blue: Double(hex & 0xFF) / 255.0, opacity: opacity) - } -} diff --git a/Hotline/Utility/TextDocument.swift b/Hotline/Utility/TextDocument.swift deleted file mode 100644 index 82727de..0000000 --- a/Hotline/Utility/TextDocument.swift +++ /dev/null @@ -1,31 +0,0 @@ -import Foundation -import UniformTypeIdentifiers -import SwiftUI - -struct TextFile: FileDocument { - // tell the system we support only plain text - static var readableContentTypes = [UTType.plainText, UTType.utf8PlainText] - - // by default our document is empty - var text = "" - - // a simple initializer that creates new, empty documents - init(initialText: String = "") { - text = initialText - } - - // this initializer loads data that has been saved previously - init(configuration: ReadConfiguration) throws { - - if let data = configuration.file.regularFileContents { - if let str = String(data: data, encoding: .utf8) { - self.text = str - } - } - } - - // this will be called when the system wants to write our data to disk - func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { - return FileWrapper(regularFileWithContents: self.text.data(using: .utf8)!) - } -} diff --git a/Hotline/macOS/TrackerView.swift b/Hotline/macOS/TrackerView.swift index 34f7a57..6fab091 100644 --- a/Hotline/macOS/TrackerView.swift +++ b/Hotline/macOS/TrackerView.swift @@ -485,38 +485,61 @@ struct TrackerBookmarkSheet: View { var body: some View { VStack(alignment: .leading) { - if self.bookmark == nil { - Text("Type the address and name of a Hotline Tracker:") - .foregroundStyle(.secondary) - .padding(.bottom, 8) - } - Form { + if self.bookmark == nil { + HStack(alignment: .top, spacing: 10) { + GroupedIconView(color: .blue, systemName: "point.3.filled.connected.trianglepath.dotted", padding: 5.0) + .frame(width: 28, height: 28) + + VStack(alignment: .leading) { + Text("Add a Hotline Tracker") + + Text("Enter the address and name of a Hotline Tracker you want to add.") + .foregroundStyle(.secondary) + .font(.subheadline) + } + } + } + else { + HStack(alignment: .top, spacing: 10) { + GroupedIconView(color: .blue, systemName: "point.3.filled.connected.trianglepath.dotted", padding: 5.0) + .frame(width: 28, height: 28) + + VStack(alignment: .leading) { + Text("Edit Hotline Tracker") + + Text("Change the address and name of your Hotline Tracker.") + .foregroundStyle(.secondary) + .font(.subheadline) + } + } + } + Group { TextField(text: $trackerAddress) { - Text("Address:") + Text("Address") } TextField(text: $trackerName, prompt: Text("Optional")) { - Text("Name:") + Text("Name") } } - .textFieldStyle(.roundedBorder) +// .textFieldStyle(.roundedBorder) .controlSize(.large) } + .formStyle(.grouped) } - .frame(width: 300) + .frame(width: 350) .fixedSize(horizontal: true, vertical: true) - .padding() .toolbar { ToolbarItem(placement: .confirmationAction) { Button { self.saveTracker() } label: { if self.bookmark != nil { - Text("Save Tracker") + Text("Save") } else { - Text("Add Tracker") + Text("Add") } } } -- cgit From f87ff05d17cedf21845abbb8d750759ba725a263 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Tue, 11 Nov 2025 15:39:02 -0800 Subject: Folder download logic cleanup. Display folder icon on folder download transfers with small icon for the current file. Fix progress for folder downloads. --- .../Transfers/HotlineFolderDownloadClient.swift | 202 ++++++++----------- Hotline/Library/Extensions.swift | 5 + Hotline/Library/NetSocket/FileProgress.swift | 15 +- .../Library/NetSocket/TransferRateEstimator.swift | 3 + Hotline/Library/Views/FileIconView.swift | 12 +- Hotline/Models/TransferInfo.swift | 24 ++- Hotline/State/HotlineState.swift | 19 +- Hotline/macOS/TransfersView.swift | 224 ++++++++++++--------- 8 files changed, 270 insertions(+), 234 deletions(-) (limited to 'Hotline/Library/NetSocket/FileProgress.swift') diff --git a/Hotline/Hotline/Transfers/HotlineFolderDownloadClient.swift b/Hotline/Hotline/Transfers/HotlineFolderDownloadClient.swift index 36193bd..a915043 100644 --- a/Hotline/Hotline/Transfers/HotlineFolderDownloadClient.swift +++ b/Hotline/Hotline/Transfers/HotlineFolderDownloadClient.swift @@ -14,13 +14,14 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { private let serverPort: UInt16 private let referenceNumber: UInt32 - private let transferTotal: Int - private let folderItemCount: Int - private var transferSize: Int = 0 + private let transferTotal: Int // Total byte size of all files in folder. + private let folderItemCount: Int // Total numbner of items in the folder hierarchy. private var socket: NetSocket? private var downloadTask: Task? private var folderProgress: Progress? + + private var estimator: TransferRateEstimator public init( address: String, @@ -34,6 +35,8 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { self.referenceNumber = reference self.transferTotal = Int(size) self.folderItemCount = itemCount + + self.estimator = TransferRateEstimator(total: self.transferTotal) } // MARK: - API @@ -41,8 +44,10 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { public func download( to location: HotlineDownloadLocation, progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil, - itemProgress itemProgressHandler: (@Sendable (HotlineFolderItemProgress) -> Void)? = nil + items itemProgressHandler: (@Sendable (HotlineFolderItemProgress) -> Void)? = nil ) async throws -> URL { + progressHandler?(.preparing) + self.downloadTask?.cancel() let task = Task { @@ -59,7 +64,7 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { self.downloadTask = nil return url } catch { - print("HotlineFolderDownloadClient[\(referenceNumber)]: Failed to download folder: \(error)") + print("HotlineFolderDownloadClient[\(self.referenceNumber)]: Failed to download folder: \(error)") self.downloadTask = nil progressHandler?(.error(error)) throw error @@ -68,10 +73,10 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { /// Cancel the current download public func cancel() { - downloadTask?.cancel() - downloadTask = nil + self.downloadTask?.cancel() + self.downloadTask = nil - if let socket = socket { + if let socket = self.socket { Task { await socket.close() } @@ -91,7 +96,10 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { progressHandler?(.connecting) // Connect to transfer server - let socket = try await connectToTransferServer() + let socket = try await NetSocket.connect( + host: self.serverAddress, + port: self.serverPort + 1 + ) self.socket = socket defer { Task { await socket.close() } } @@ -104,12 +112,11 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { destinationURL = url destinationFilename = url.lastPathComponent case .downloads(let filename): - let downloadsURL = fm.urls(for: .downloadsDirectory, in: .userDomainMask)[0] - destinationURL = URL(filePath: downloadsURL.generateUniqueFilePath(filename: filename)) + destinationURL = URL.downloadsDirectory.generateUniqueFileURL(filename: filename) destinationFilename = destinationURL.lastPathComponent } - print("HotlineFolderDownloadClient[\(referenceNumber)]: Downloading folder to \(destinationURL.path)") + print("HotlineFolderDownloadClient[\(self.referenceNumber)]: Downloading folder to \(destinationURL.path)") // Create destination folder try? fm.removeItem(at: destinationURL) @@ -120,10 +127,10 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { progress.fileURL = destinationURL progress.fileOperationKind = .downloading progress.publish() + defer { progress.unpublish() } self.folderProgress = progress // Send initial magic header - print("HotlineFolderDownloadClient[\(self.referenceNumber)]: Sending HTXF magic") try await socket.write(Data(endian: .big) { "HTXF".fourCharCode() self.referenceNumber @@ -137,7 +144,6 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { progressHandler?(.transfer(name: destinationFilename, size: 0, total: self.transferTotal, progress: 0.0, speed: nil, estimate: nil)) var completedItemCount = 0 - var totalBytesTransferred = 0 // Process each item in the folder while completedItemCount < self.folderItemCount { @@ -146,15 +152,12 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { let headerLen = Int(headerLenData.readUInt16(at: 0)!) let headerData = try await socket.read(headerLen) - totalBytesTransferred += 2 + headerLen + self.updateProgress(2 + headerLen) guard let (itemType, pathComponents) = self.parseItemHeaderPath(headerData) else { throw HotlineTransferClientError.failedToTransfer } - let joinedPath = pathComponents.joined(separator: "/") - print("HotlineFolderDownloadClient[\(referenceNumber)]: Item type=\(itemType) path=\(joinedPath)") - if itemType == 1 { // Folder entry - no progress shown for folder creation if !pathComponents.isEmpty { @@ -166,14 +169,14 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { completedItemCount += 1 // Request next item if not done - if completedItemCount < folderItemCount { + if completedItemCount < self.folderItemCount { try await sendAction(socket: socket, action: .nextFile) // nextFile } } else if itemType == 0 { // File entry let parentComponents = pathComponents.dropLast() - let fileName = pathComponents.last ?? "untitled" + let fileName = pathComponents.last ?? "Untitled" // Request file download try await sendAction(socket: socket, action: .sendFile) // sendFile @@ -181,47 +184,38 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { // Read file size let fileSizeData = try await socket.read(4) let fileSize = fileSizeData.readUInt32(at: 0)! - totalBytesTransferred += 4 - print("HotlineFolderDownloadClient[\(referenceNumber)]: File '\(fileName)' size: \(fileSize) bytes") + self.updateProgress(4) // Notify item progress before download starts completedItemCount += 1 itemProgressHandler?(HotlineFolderItemProgress( fileName: fileName, itemNumber: completedItemCount, - totalItems: folderItemCount + totalItems: self.folderItemCount )) // Download the file with overall folder progress tracking - let (fileURL, fileBytesRead) = try await downloadFile( + try await self.downloadFile( socket: socket, fileName: fileName, parentPath: Array(parentComponents), destinationFolder: destinationURL, fileSize: fileSize, - itemNumber: completedItemCount, - totalItems: folderItemCount, - totalBytesTransferredSoFar: totalBytesTransferred, progressHandler: progressHandler ) - totalBytesTransferred += fileBytesRead - self.transferSize = totalBytesTransferred - - print("HotlineFolderDownloadClient[\(referenceNumber)]: Downloaded file to \(fileURL.path)") - // Request next item if not done - if completedItemCount < folderItemCount { + if completedItemCount < self.folderItemCount { try await sendAction(socket: socket, action: .nextFile) // nextFile } } else { // Unknown item type - print("HotlineFolderDownloadClient[\(referenceNumber)]: Unknown item type \(itemType), skipping") + print("HotlineFolderDownloadClient[\(self.referenceNumber)]: Unknown item type \(itemType), skipping") completedItemCount += 1 - if completedItemCount < folderItemCount { + if completedItemCount < self.folderItemCount { try await sendAction(socket: socket, action: .nextFile) // nextFile } } @@ -231,25 +225,12 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { // Ensure folder progress shows 100% complete self.folderProgress?.completedUnitCount = Int64(self.transferTotal) - progressHandler?(.completed(url: destinationURL)) return destinationURL } - // MARK: - Helper Methods - - private func connectToTransferServer() async throws -> NetSocket { - print("HotlineFolderDownloadClient[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)") - - let socket = try await NetSocket.connect( - host: self.serverAddress, - port: self.serverPort + 1 - ) - - print("HotlineFolderDownloadClient[\(referenceNumber)]: Connected!") - return socket - } + // MARK: - private func sendAction(socket: NetSocket, action: HotlineFolderAction) async throws { let actionData = Data(endian: .big) { @@ -284,33 +265,34 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { } return (type, comps) } - + + @discardableResult + private func updateProgress(_ sent: Int) -> NetSocket.FileProgress { + let progress = self.estimator.update(bytes: sent) + self.folderProgress?.completedUnitCount = Int64(progress.sent) + return progress + } + + @discardableResult private func downloadFile( socket: NetSocket, fileName: String, parentPath: [String], destinationFolder: URL, fileSize: UInt32, - itemNumber: Int, - totalItems: Int, - totalBytesTransferredSoFar: Int, progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? - ) async throws -> (url: URL, bytesRead: Int) { + ) async throws -> URL { let fm = FileManager.default - var bytesRead = 0 +// var bytesRead = 0 // Read file header let headerData = try await socket.read(HotlineFileHeader.DataSize) guard let header = HotlineFileHeader(from: headerData) else { throw HotlineTransferClientError.failedToTransfer } - bytesRead += HotlineFileHeader.DataSize + self.updateProgress(HotlineFileHeader.DataSize) - // Update folder progress for file header - let totalBytesNow = totalBytesTransferredSoFar + bytesRead - self.folderProgress?.completedUnitCount = Int64(totalBytesNow) - - print("HotlineFolderDownloadClient[\(referenceNumber)]: File has \(header.forkCount) forks") + print("HotlineFolderDownloadClient[\(self.referenceNumber)]: File has \(header.forkCount) forks") var resourceForkData: Data? var fileHandle: FileHandle? @@ -328,27 +310,18 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { guard let forkHeader = HotlineFileForkHeader(from: forkHeaderData) else { throw HotlineTransferClientError.failedToTransfer } - bytesRead += HotlineFileForkHeader.DataSize - - // Update folder progress for fork header - let totalBytesNow = totalBytesTransferredSoFar + bytesRead - self.folderProgress?.completedUnitCount = Int64(totalBytesNow) + self.updateProgress(HotlineFileForkHeader.DataSize) if forkHeader.isInfoFork { - // Read INFO fork - print("HotlineFolderDownloadClient[\(referenceNumber)]: Reading INFO fork (\(forkHeader.dataSize) bytes)") + // Info fork let infoData = try await socket.read(Int(forkHeader.dataSize)) - bytesRead += infoData.count - - // Update folder progress for INFO fork - let totalBytesNow = totalBytesTransferredSoFar + bytesRead - self.folderProgress?.completedUnitCount = Int64(totalBytesNow) + self.updateProgress(infoData.count) guard let info = HotlineFileInfoFork(from: infoData) else { throw HotlineTransferClientError.failedToTransfer } - // Create parent folders + // Create parent folder let parentFolderURL = destinationFolder.appendingPathComponents(parentPath) if !fm.fileExists(atPath: parentFolderURL.path) { try fm.createDirectory(at: parentFolderURL, withIntermediateDirectories: true) @@ -364,63 +337,54 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { } else if forkHeader.isDataFork { - // Stream DATA fork to disk - print("HotlineFolderDownloadClient[\(referenceNumber)]: Reading DATA fork (\(forkHeader.dataSize) bytes)") - + // Data fork guard let fh = fileHandle else { throw HotlineTransferClientError.failedToTransfer } fileDataForkSize = Int(forkHeader.dataSize) - // Stream data fork using NetSocket's optimized file streaming + // Stream data fork to disk let updates = await socket.receiveFile(to: fh, length: fileDataForkSize) - for try await p in updates { - // Calculate overall folder progress - let totalBytesNow = totalBytesTransferredSoFar + bytesRead + p.sent - let rawProgress = self.transferTotal > 0 ? Double(totalBytesNow) / Double(self.transferTotal) : 0.0 - let overallProgress = min(rawProgress, 1.0) // Clamp to 1.0 to avoid exceeding 100% - - // Update folder-level Finder progress - self.folderProgress?.completedUnitCount = Int64(totalBytesNow) - - // Calculate overall folder time estimate based on current speed - let remainingBytes = max(0, self.transferTotal - totalBytesNow) - let estimate: TimeInterval? = if let speed = p.bytesPerSecond, speed > 0, remainingBytes > 0 { - TimeInterval(remainingBytes) / speed - } else { - nil - } - + for try await fileProgress in updates { + let progress = self.updateProgress(fileProgress.now) + // Report overall folder progress to UI progressHandler?(.transfer( name: fileName, - size: totalBytesNow, - total: self.transferTotal, - progress: overallProgress, - speed: p.bytesPerSecond, - estimate: estimate + size: progress.sent, + total: progress.total ?? 0, + progress: progress.progress, + speed: progress.bytesPerSecond, + estimate: progress.estimatedTimeRemaining )) } - bytesRead += fileDataForkSize } else if forkHeader.isResourceFork { - // Read RESOURCE fork + // Resource fork resourceForkData = try await socket.read(Int(forkHeader.dataSize)) - bytesRead += Int(forkHeader.dataSize) - - // Update folder progress for RESOURCE fork - let totalBytesNow = totalBytesTransferredSoFar + bytesRead - self.folderProgress?.completedUnitCount = Int64(totalBytesNow) + let progress = self.updateProgress(resourceForkData?.count ?? 0) + progressHandler?(.transfer( + name: fileName, + size: progress.sent, + total: progress.total ?? 0, + progress: progress.progress, + speed: progress.bytesPerSecond, + estimate: progress.estimatedTimeRemaining + )) } else { - // Skip unsupported fork + // Unsupported fork try await socket.skip(Int(forkHeader.dataSize)) - bytesRead += Int(forkHeader.dataSize) - - // Update folder progress for skipped fork - let totalBytesNow = totalBytesTransferredSoFar + bytesRead - self.folderProgress?.completedUnitCount = Int64(totalBytesNow) + let progress = self.updateProgress(Int(forkHeader.dataSize)) + progressHandler?(.transfer( + name: fileName, + size: progress.sent, + total: progress.total ?? 0, + progress: progress.progress, + speed: progress.bytesPerSecond, + estimate: progress.estimatedTimeRemaining + )) } } @@ -428,23 +392,19 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { try? fileHandle?.close() fileHandle = nil - guard let finalPath = filePath else { + guard let filePath else { throw HotlineTransferClientError.failedToTransfer } // Write resource fork if present if let rsrcData = resourceForkData, !rsrcData.isEmpty { - try writeResourceFork(data: rsrcData, to: finalPath) + try writeResourceFork(data: rsrcData, to: filePath) } - return (finalPath, bytesRead) + return filePath } private func writeResourceFork(data: Data, to url: URL) throws { - var resolvedURL = url - resolvedURL.resolveSymlinksInPath() - - let resourceURL = resolvedURL.urlForResourceFork() - try data.write(to: resourceURL) + try data.write(to: url.resolvingSymlinksInPath().urlForResourceFork()) } } diff --git a/Hotline/Library/Extensions.swift b/Hotline/Library/Extensions.swift index cf9f8ff..5ebc7db 100644 --- a/Hotline/Library/Extensions.swift +++ b/Hotline/Library/Extensions.swift @@ -147,6 +147,11 @@ extension URL { return filePath } + + func generateUniqueFileURL(filename base: String) -> URL { + let filePath = self.generateUniqueFilePath(filename: base) + return URL(filePath: filePath) + } } // MARK: - diff --git a/Hotline/Library/NetSocket/FileProgress.swift b/Hotline/Library/NetSocket/FileProgress.swift index c2306f3..2064c59 100644 --- a/Hotline/Library/NetSocket/FileProgress.swift +++ b/Hotline/Library/NetSocket/FileProgress.swift @@ -12,14 +12,27 @@ public extension NetSocket { public let sent: Int /// Total file size (may be nil if unknown) public let total: Int? + /// Size of most recent packet + public let now: Int + /// Total progress so far (0.0 to 1.0) + public let progress: Double /// Smoothed transfer rate in bytes per second (EMA), if enough samples collected public let bytesPerSecond: Double? /// Estimated time remaining (seconds) based on smoothed rate, if available public let estimatedTimeRemaining: TimeInterval? - public init(sent: Int, total: Int?, bytesPerSecond: Double? = nil, estimatedTimeRemaining: TimeInterval? = nil) { + public init(sent: Int, total: Int?, now: Int = 0, bytesPerSecond: Double? = nil, estimatedTimeRemaining: TimeInterval? = nil) { self.sent = sent self.total = total + self.now = now + + if let t = total { + self.progress = max(0.0, min(1.0, Double(sent) / Double(t))) + } + else { + self.progress = 0.0 + } + self.bytesPerSecond = bytesPerSecond self.estimatedTimeRemaining = estimatedTimeRemaining } diff --git a/Hotline/Library/NetSocket/TransferRateEstimator.swift b/Hotline/Library/NetSocket/TransferRateEstimator.swift index badf87a..61383ff 100644 --- a/Hotline/Library/NetSocket/TransferRateEstimator.swift +++ b/Hotline/Library/NetSocket/TransferRateEstimator.swift @@ -70,6 +70,7 @@ public struct TransferRateEstimator { self.minSamples = minSamples } + @discardableResult public mutating func update(total: Int) -> NetSocket.FileProgress { return self.update(bytes: max(0, total - self.transferred)) } @@ -80,6 +81,7 @@ public struct TransferRateEstimator { /// /// - Parameter bytes: Number of bytes transferred in this sample /// - Returns: Current progress with speed and ETA estimates + @discardableResult public mutating func update(bytes: Int) -> NetSocket.FileProgress { let clock = ContinuousClock() let now = clock.now @@ -127,6 +129,7 @@ public struct TransferRateEstimator { return NetSocket.FileProgress( sent: self.transferred, total: self.total, + now: bytes, bytesPerSecond: haveEstimate ? self.emaBytesPerSecond : nil, estimatedTimeRemaining: eta ) diff --git a/Hotline/Library/Views/FileIconView.swift b/Hotline/Library/Views/FileIconView.swift index d0949fa..836c990 100644 --- a/Hotline/Library/Views/FileIconView.swift +++ b/Hotline/Library/Views/FileIconView.swift @@ -2,7 +2,7 @@ import SwiftUI import UniformTypeIdentifiers struct FolderIconView: View { - private func folderIcon() -> Image { + private var folderIcon: Image { #if os(iOS) return Image(systemName: "folder.fill") #elseif os(macOS) @@ -11,7 +11,7 @@ struct FolderIconView: View { } var body: some View { - folderIcon() + self.folderIcon .resizable() .scaledToFit() } @@ -22,7 +22,7 @@ struct FileIconView: View { let fileType: String? #if os(iOS) - private func fileIcon() -> Image { + private var fileIcon: Image { let fileExtension = (self.filename as NSString).pathExtension if let fileType = UTType(filenameExtension: fileExtension) { if fileType.isSubtype(of: .movie) { @@ -45,7 +45,7 @@ struct FileIconView: View { return Image(systemName: "doc") } #elseif os(macOS) - private func fileIcon() -> Image { + private var fileIcon: Image { let fileExtension = (self.filename as NSString).pathExtension if !fileExtension.isEmpty, @@ -60,14 +60,12 @@ struct FileIconView: View { else { return Image(nsImage: NSWorkspace.shared.icon(for: UTType.data)) } - -// Image(nsImage: NSWorkspace.shared.icon(for: UTType(filenameExtension: (filename as NSString).pathExtension) ?? UTType.content)) } #endif var body: some View { - fileIcon() + self.fileIcon .resizable() .scaledToFit() } diff --git a/Hotline/Models/TransferInfo.swift b/Hotline/Models/TransferInfo.swift index 10535bf..ebe9f64 100644 --- a/Hotline/Models/TransferInfo.swift +++ b/Hotline/Models/TransferInfo.swift @@ -7,23 +7,35 @@ class TransferInfo: Identifiable, Equatable, Hashable { var referenceNumber: UInt32 var title: String var size: UInt - var progress: Double = 0.0 - var speed: Double? = nil - var timeRemaining: TimeInterval? = nil + + // Status var completed: Bool = false var failed: Bool = false var cancelled: Bool = false - var isFolder: Bool = false - var done: Bool { self.completed || self.failed || self.cancelled } + + var progress: Double = 0.0 + var speed: Double? = nil + var timeRemaining: TimeInterval? = nil + + var isUpload: Bool = false + var isDownload: Bool { + get { !self.isUpload } + set { self.isUpload = !newValue } + } + + // Folder transfers + var isFolder: Bool = false + var folderName: String? = nil + var fileName: String? = nil // Server association - tracks which HotlineState this transfer belongs to var serverID: UUID var serverName: String? - // For file based transfers (i.e. not previews) + // For file based transfers var fileURL: URL? = nil var progressCallback: ((TransferInfo) -> Void)? = nil diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index f814818..7335f37 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -895,6 +895,7 @@ class HotlineState: Equatable { serverID: self.id, serverName: self.serverName ?? self.serverTitle ) + transfer.isUpload = false transfer.downloadCallback = callback transfer.progressCallback = progressCallback AppState.shared.addTransfer(transfer) @@ -980,7 +981,7 @@ class HotlineState: Equatable { path: [String], to destination: URL? = nil, progress progressCallback: ((TransferInfo) -> Void)? = nil, - itemProgress itemProgressCallback: ((TransferInfo, String, Int, Int) -> Void)? = nil, +// itemProgress itemProgressCallback: ((TransferInfo, String, Int, Int) -> Void)? = nil, complete callback: ((TransferInfo) -> Void)? = nil ) { guard let client = self.client else { return } @@ -1013,6 +1014,8 @@ class HotlineState: Equatable { serverName: self.serverName ?? self.serverTitle ) transfer.isFolder = true + transfer.folderName = folderName + transfer.isUpload = false transfer.downloadCallback = callback transfer.progressCallback = progressCallback AppState.shared.addTransfer(transfer) @@ -1031,8 +1034,6 @@ class HotlineState: Equatable { guard self != nil else { return } do { - let folderURL: URL - // Download folder with progress tracking let location: HotlineDownloadLocation = if let destination { .url(destination) @@ -1040,7 +1041,7 @@ class HotlineState: Equatable { .downloads(folderName) } - folderURL = try await downloadClient.download(to: location, progress: { progress in + let folderURL = try await downloadClient.download(to: location, progress: { progress in switch progress { case .preparing: break @@ -1057,14 +1058,14 @@ class HotlineState: Equatable { transfer.completed = true transfer.fileURL = url } - }, itemProgress: { itemInfo in - // Update transfer title with current file being downloaded - transfer.title = "\(itemInfo.fileName) (\(itemInfo.itemNumber)/\(itemInfo.totalItems))" - itemProgressCallback?(transfer, itemInfo.fileName, itemInfo.itemNumber, itemInfo.totalItems) + }, items: { item in + transfer.title = item.fileName + transfer.fileName = item.fileName }) // Mark as completed transfer.progress = 1.0 + transfer.fileName = nil transfer.title = folderName // Reset title to folder name // Call completion callback @@ -1174,6 +1175,7 @@ class HotlineState: Equatable { serverName: self.serverName ?? self.serverTitle ) transfer.isFolder = true + transfer.isUpload = true transfer.uploadCallback = callback transfer.progressCallback = progressCallback AppState.shared.addTransfer(transfer) @@ -1294,6 +1296,7 @@ class HotlineState: Equatable { serverID: self.id, serverName: self.serverName ?? self.serverTitle ) + transfer.isUpload = true transfer.uploadCallback = callback AppState.shared.addTransfer(transfer) diff --git a/Hotline/macOS/TransfersView.swift b/Hotline/macOS/TransfersView.swift index bf8c8bd..5f9357d 100644 --- a/Hotline/macOS/TransfersView.swift +++ b/Hotline/macOS/TransfersView.swift @@ -70,58 +70,70 @@ struct TransfersView: View { .listStyle(.inset) .environment(\.defaultMinListRowHeight, 56) .contextMenu(forSelectionType: TransferInfo.self) { items in - if items.allSatisfy(\.completed) { - let fileURLs: [URL] = items.compactMap(\.fileURL) - - Button("Remove Transfer\(items.count > 1 ? "s" : "")", systemImage: "xmark") { - self.appState.cancelTransfers(ids: items.map(\.id)) - self.selectedTransfers = [] - } - - Divider() - - Button("Open", systemImage: "arrow.up.right.square") { - for fileURL in fileURLs { - NSWorkspace.shared.open(fileURL) - } - } - - self.openWithMenu(for: fileURLs) - - Button("Show in Finder", systemImage: "finder") { - NSWorkspace.shared.activateFileViewerSelecting(fileURLs) + self.contextMenuForItems(items) + } primaryAction: { items in + self.performPrimaryAction(for: items) + } + } + + // MARK: - Double Click + + private func performPrimaryAction(for items: Set) { + if let fileURL = items.first?.fileURL { + NSWorkspace.shared.open(fileURL) + } + } + + // MARK: - Context Menu + + @ViewBuilder + private func contextMenuForItems(_ items: Set) -> some View { + if items.allSatisfy(\.completed) { + let fileURLs: [URL] = items.compactMap(\.fileURL) + + Button("Remove Transfer\(items.count > 1 ? "s" : "")", systemImage: "xmark") { + self.appState.cancelTransfers(ids: items.map(\.id)) + self.selectedTransfers = [] + } + + Divider() + + Button("Open", systemImage: "arrow.up.right.square") { + for fileURL in fileURLs { + NSWorkspace.shared.open(fileURL) } + } + + self.openWithMenu(for: fileURLs) + + Button("Show in Finder", systemImage: "finder") { + NSWorkspace.shared.activateFileViewerSelecting(fileURLs) + } + + Divider() + + Button("Move to Trash", systemImage: "trash") { + self.appState.cancelTransfers(ids: items.map(\.id)) + NSWorkspace.shared.recycle(fileURLs) + self.selectedTransfers = [] + } + } else { + Button("Remove Transfer\(items.count > 1 ? "s" : "")", systemImage: "xmark") { + self.appState.cancelTransfers(ids: items.map(\.id)) + self.selectedTransfers = [] + } + + Divider() + + Button("Move to Trash", systemImage: "trash") { + self.appState.cancelTransfers(ids: items.map(\.id)) - Divider() - - Button("Move to Trash", systemImage: "trash") { - self.appState.cancelTransfers(ids: items.map(\.id)) + let fileURLs: [URL] = items.compactMap(\.fileURL) + if !fileURLs.isEmpty { NSWorkspace.shared.recycle(fileURLs) - self.selectedTransfers = [] } - } - else { - Button("Remove Transfer\(items.count > 1 ? "s" : "")", systemImage: "xmark") { - self.appState.cancelTransfers(ids: items.map(\.id)) - self.selectedTransfers = [] - } - - Divider() - Button("Move to Trash", systemImage: "trash") { - self.appState.cancelTransfers(ids: items.map(\.id)) - - let fileURLs: [URL] = items.compactMap(\.fileURL) - if !fileURLs.isEmpty { - NSWorkspace.shared.recycle(fileURLs) - } - - self.selectedTransfers = [] - } - } - } primaryAction: { items in - if let fileURL = items.first?.fileURL { - NSWorkspace.shared.open(fileURL) + self.selectedTransfers = [] } } } @@ -250,6 +262,7 @@ struct TransfersView: View { } } } + } // MARK: - Transfer Row @@ -259,6 +272,56 @@ struct TransferRow: View { @Bindable var transfer: TransferInfo + var body: some View { + HStack(alignment: .center, spacing: 8) { + if self.transfer.isFolder { + self.folderIconView + } + else { + self.fileIconView + } + + VStack(alignment: .leading, spacing: 2) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(self.transfer.title) + .font(.headline) + .lineLimit(1) + .truncationMode(.tail) + + Spacer() + + if !self.transfer.done { + self.statsView + } + } + + // Progress bar and status + if self.transfer.cancelled { + Text("Cancelled") + .font(.subheadline) + .foregroundStyle(.secondary) + } + else if self.transfer.failed { + Text("Failed") + .font(.subheadline) + .foregroundStyle(.secondary) + } + else if self.transfer.completed { + Text("Downloaded") + .font(.subheadline) + .foregroundStyle(.fileComplete) + } + else { + ProgressView(value: self.transfer.progress, total: 1.0) + .progressViewStyle(.linear) + .controlSize(.large) + } + } + } + } + + // MARK: - + private var statsView: some View { HStack(spacing: 8) { // Progress percentage @@ -266,28 +329,24 @@ struct TransferRow: View { // Speed if let speed = self.transfer.speed { - // TODO: Use arrow.up for uploads. - Label(self.formatSpeed(speed), systemImage: "arrow.down") -// Text(self.formatSpeed(speed)) + Label(self.formatSpeed(speed), systemImage: self.transfer.isUpload ? "arrow.up" : "arrow.down") } // Time remaining if let timeRemaining = self.transfer.timeRemaining { Label(self.formatTimeRemaining(timeRemaining), systemImage: "clock") -// Text(self.formatTimeRemaining(timeRemaining)) } // File size Label(self.formatSize(self.transfer.size), systemImage: "document") -// Text(self.formatSize(self.transfer.size)) } .font(.subheadline) .foregroundStyle(.secondary) .monospacedDigit() } - private var fileIconView: some View { - FileIconView(filename: self.transfer.title, fileType: nil) + private var folderIconView: some View { + FolderIconView() .frame(width: 32, height: 32) .overlay(alignment: .bottomTrailing) { if self.transfer.cancelled || self.transfer.failed { @@ -305,50 +364,33 @@ struct TransferRow: View { .scaledToFit() .frame(width: 16, height: 16) } + else { + FileIconView(filename: self.transfer.title, fileType: nil) + .frame(width: 16, height: 16) + } } } - var body: some View { - HStack(alignment: .center, spacing: 8) { - self.fileIconView - - VStack(alignment: .leading, spacing: 2) { - HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(self.transfer.title) - .font(.headline) - .lineLimit(1) - .truncationMode(.tail) - - Spacer() - - if !self.transfer.done { - self.statsView - } - } - - // Progress bar and status - if self.transfer.cancelled { - Text("Cancelled") - .font(.subheadline) - .foregroundStyle(.secondary) - } - else if self.transfer.failed { - Text("Failed") - .font(.subheadline) - .foregroundStyle(.secondary) + private var fileIconView: some View { + FileIconView(filename: self.transfer.title, fileType: nil) + .frame(width: 32, height: 32) + .overlay(alignment: .bottomTrailing) { + if self.transfer.cancelled || self.transfer.failed { + Image(systemName: "exclamationmark.triangle.fill") + .resizable() + .symbolRenderingMode(.multicolor) + .scaledToFit() + .frame(width: 16, height: 16) } else if self.transfer.completed { - Text("Downloaded") - .font(.subheadline) - .foregroundStyle(.fileComplete) - } - else { - ProgressView(value: self.transfer.progress, total: 1.0) - .progressViewStyle(.linear) - .controlSize(.large) + Image(systemName: "checkmark.circle.fill") + .resizable() + .symbolRenderingMode(.palette) + .foregroundStyle(.white, .fileComplete) + .scaledToFit() + .frame(width: 16, height: 16) } } - } } // MARK: - Formatting -- cgit