aboutsummaryrefslogtreecommitdiff
path: root/Hotline/Library
diff options
context:
space:
mode:
Diffstat (limited to 'Hotline/Library')
-rw-r--r--Hotline/Library/BookmarkDocument.swift32
-rw-r--r--Hotline/Library/ColorArt.swift424
-rw-r--r--Hotline/Library/Extensions.swift299
-rw-r--r--Hotline/Library/NetSocket/FileProgress.swift71
-rw-r--r--Hotline/Library/NetSocket/NetSocket.swift1117
-rw-r--r--Hotline/Library/NetSocket/TransferRateEstimator.swift138
-rw-r--r--Hotline/Library/RegularExpressions.swift235
-rw-r--r--Hotline/Library/Utility/DAKeychain.swift82
-rw-r--r--Hotline/Library/Utility/NSWindowBridge.swift46
-rw-r--r--Hotline/Library/Utility/ObservableScrollView.swift42
-rw-r--r--Hotline/Library/Utility/TextDocument.swift31
-rw-r--r--Hotline/Library/Views/AsyncLinkPreview.swift59
-rw-r--r--Hotline/Library/Views/BetterTextEditor.swift119
-rw-r--r--Hotline/Library/Views/FileIconView.swift70
-rw-r--r--Hotline/Library/Views/FileImageView.swift102
-rw-r--r--Hotline/Library/Views/GroupedIconView.swift23
-rw-r--r--Hotline/Library/Views/HotlinePanel.swift72
-rw-r--r--Hotline/Library/Views/QuickLookPreviewView.swift22
-rw-r--r--Hotline/Library/Views/SpinningGlobeView.swift90
-rw-r--r--Hotline/Library/Views/VisualEffectView.swift19
20 files changed, 3093 insertions, 0 deletions
diff --git a/Hotline/Library/BookmarkDocument.swift b/Hotline/Library/BookmarkDocument.swift
new file mode 100644
index 0000000..43f251c
--- /dev/null
+++ b/Hotline/Library/BookmarkDocument.swift
@@ -0,0 +1,32 @@
+import Foundation
+import SwiftUI
+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..<pixelsWide {
+ for y in 0..<pixelsHigh {
+ guard let color = bitmapRep.colorAt(x: x, y: y) else { continue }
+
+ if x == searchColumnX {
+ // Make sure it's a meaningful color
+ if color.alphaComponent > 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..<sortedColors.count {
+ let nextProposedColor = sortedColors[i]
+
+ // Make sure the second choice color is 30% as common as the first choice
+ if Double(nextProposedColor.count) / Double(proposedEdgeColor.count) > 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/Extensions.swift b/Hotline/Library/Extensions.swift
new file mode 100644
index 0000000..5ebc7db
--- /dev/null
+++ b/Hotline/Library/Extensions.swift
@@ -0,0 +1,299 @@
+import Foundation
+import SwiftUI
+import UniformTypeIdentifiers
+
+extension FileManager {
+ @discardableResult
+ func moveToDownloads(from sourceURL: URL, using filename: String, bounceDock: Bool = false) -> Bool {
+ let filePath = URL.downloadsDirectory.generateUniqueFilePath(filename: filename)
+ let destinationURL = URL(filePath: filePath).resolvingSymlinksInPath()
+
+ do {
+ try FileManager.default.moveItem(at: sourceURL.resolvingSymlinksInPath(), to: destinationURL)
+ }
+ catch {
+ return false
+ }
+
+ if bounceDock {
+ #if os(macOS)
+ DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: destinationURL.path)
+ #endif
+ }
+
+ return true
+ }
+
+ @discardableResult
+ func copyToDownloads(from sourceURL: URL, using filename: String, bounceDock: Bool = false) -> Bool {
+ let filePath = URL.downloadsDirectory.generateUniqueFilePath(filename: filename)
+ let destinationURL = URL(filePath: filePath).resolvingSymlinksInPath()
+
+ do {
+ try FileManager.default.copyItem(at: sourceURL.resolvingSymlinksInPath(), to: destinationURL)
+ }
+ catch {
+ return false
+ }
+
+ if bounceDock {
+ #if os(macOS)
+ DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: destinationURL.path)
+ #endif
+ }
+
+ return true
+ }
+}
+
+// MARK: -
+
+extension View {
+ @ViewBuilder
+ func applyNavigationDocumentIfPresent(_ url: URL?) -> some View {
+ if let url {
+ self.navigationDocument(url)
+ } else {
+ self
+ }
+ }
+}
+
+// MARK: -
+
+extension Data {
+ func saveAsFileToDownloads(filename: String, bounceDock: Bool = true) -> Bool {
+ let filePath = URL.downloadsDirectory.generateUniqueFilePath(filename: filename)
+
+ if FileManager.default.createFile(atPath: filePath, contents: self) {
+ if bounceDock {
+ #if os(macOS)
+ var downloadURL = URL(filePath: filePath)
+ downloadURL.resolveSymlinksInPath()
+ DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path)
+ #endif
+ }
+ return true
+
+// if FileManager.default.createFile(atPath: filePath, contents: nil) {
+// if let h = FileHandle(forWritingAtPath: filePath) {
+// try? h.write(contentsOf: self)
+// try? h.close()
+//
+// }
+ }
+ return false
+ }
+
+ enum ImageFormat {
+ case gif
+ case jpeg
+ case png
+ case webp
+ case unknown
+ }
+
+ var detectedImageFormat: ImageFormat {
+ guard self.count >= 12 else { return .unknown }
+
+ let bytes = [UInt8](self.prefix(12))
+
+ // GIF: "GIF8"
+ if bytes[0] == 0x47 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x38 {
+ return .gif
+ }
+
+ // JPEG: FF D8 FF
+ if bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF {
+ return .jpeg
+ }
+
+ // PNG: 89 50 4E 47 0D 0A 1A 0A
+ if bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47 && bytes[4] == 0x0D && bytes[5] == 0x0A && bytes[6] == 0x1A && bytes[7] == 0x0A {
+ return .png
+ }
+
+ // WebP: "RIFF" at 0-3 and "WEBP" at 8-11
+ if bytes[0] == 0x52 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x46 && bytes[8] == 0x57 && bytes[9] == 0x45 && bytes[10] == 0x42 && bytes[11] == 0x50 {
+ return .webp
+ }
+
+ return .unknown
+ }
+}
+
+// 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
+ }
+
+ func generateUniqueFileURL(filename base: String) -> URL {
+ let filePath = self.generateUniqueFilePath(filename: base)
+ return URL(filePath: 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 {
+
+ var isBlank: Bool {
+ self.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ }
+
+ 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<Bool> {
+ 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/NetSocket/FileProgress.swift b/Hotline/Library/NetSocket/FileProgress.swift
new file mode 100644
index 0000000..2064c59
--- /dev/null
+++ b/Hotline/Library/NetSocket/FileProgress.swift
@@ -0,0 +1,71 @@
+// NetSocket FileProgress
+// Dustin Mierau • @mierau
+// MIT License
+
+import Foundation
+
+public extension NetSocket {
+
+ /// 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?
+ /// 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?, 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
+ }
+
+ /// Format transfer speed in human-readable format
+ ///
+ /// Automatically selects appropriate unit (B/sec, KB/sec, MB/sec, GB/sec)
+ /// based on the magnitude of the speed.
+ ///
+ /// - Returns: Formatted string like "45KB/sec", "5B/sec", "12.5MB/sec", or nil if speed unavailable
+ ///
+ /// Example:
+ /// ```swift
+ /// if let speedString = progress.formattedSpeed {
+ /// print(speedString) // "2.5MB/sec"
+ /// }
+ /// ```
+ public var formattedSpeed: String? {
+ guard let bytesPerSecond = bytesPerSecond, bytesPerSecond > 0 else { return nil }
+
+ let kb = 1024.0
+ let mb = kb * 1024.0
+ let gb = mb * 1024.0
+
+ if bytesPerSecond >= gb {
+ return String(format: "%.1fGB/sec", bytesPerSecond / gb)
+ } else if bytesPerSecond >= mb {
+ return String(format: "%.1fMB/sec", bytesPerSecond / mb)
+ } else if bytesPerSecond >= kb {
+ return String(format: "%.0fKB/sec", bytesPerSecond / kb)
+ } else {
+ return String(format: "%.0fB/sec", bytesPerSecond)
+ }
+ }
+ }
+}
diff --git a/Hotline/Library/NetSocket/NetSocket.swift b/Hotline/Library/NetSocket/NetSocket.swift
new file mode 100644
index 0000000..ffccf5c
--- /dev/null
+++ b/Hotline/Library/NetSocket/NetSocket.swift
@@ -0,0 +1,1117 @@
+// 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 TCP socket with automatic buffering
+///
+/// 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.read(until: .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<Void, Error>] = []
+ private var readyWaiters: [CheckedContinuation<Void, Error>] = []
+
+ // 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
+ /// - config: Socket configuration (default: standard settings)
+ /// - parameters: NWParameters (default: .tcp)
+ /// - Returns: A connected and ready `NetSocket`
+ /// - Throws: Network errors or connection failures
+ public static func connect(host: NWEndpoint.Host, port: NWEndpoint.Port, config: Config = .init(), parameters: NWParameters = .tcp) async throws -> NetSocket {
+ 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, config: Config = .init()) async throws -> NetSocket {
+ guard let nwPort = NWEndpoint.Port(rawValue: port) else {
+ throw NetSocketError.invalidPort
+ }
+
+ return try await self.connect(host: NWEndpoint.Host(host), port: nwPort, 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<Int, Error>) 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<T: FixedWidthInteger>(_ 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<T>.size
+ let bytes = withUnsafePointer(to: &copy) {
+ 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..<end]
+ self.head = end
+ self.compactIfNeeded()
+ return Data(slice)
+ }
+
+ /// Read a fixed-width integer from the socket
+ ///
+ /// - Parameters:
+ /// - type: Integer type to read
+ /// - endian: Byte order (default: big-endian)
+ /// - Returns: The integer value
+ /// - Throws: `NetSocketError` if insufficient data or connection closed
+ public func read<T: FixedWidthInteger>(_ type: T.Type = T.self, endian: Endian = .big) async throws -> T {
+ let size = MemoryLayout<T>.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<FileProgress, Error> {
+ // This stream wrapper manages the FileHandle's lifetime.
+ return AsyncThrowingStream(bufferingPolicy: .bufferingOldest(1)) { continuation in
+ // Capture self (the actor) to use in detached task
+ let actor = self
+
+ // Open file on a background thread (file I/O is blocking)
+ let task = Task.detached {
+ let fh: FileHandle
+ let total: Int
+
+ // 1. Open file and get length (blocking I/O, done off-actor)
+ do {
+ total = Int(try 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<FileProgress, Error> {
+ 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<FileProgress, Error> {
+ 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<Void, Error>) in
+ if self.isClosed {
+ cont.resume()
+ return
+ }
+ self.dataWaiters.append(cont)
+ }
+ }
+
+ private func compactIfNeeded() {
+ // Avoid unbounded memory as head advances
+ if self.head > 64 * 1024 && self.head > self.buffer.count / 2 {
+ self.buffer.removeSubrange(0..<self.head)
+ self.head = 0
+ }
+ }
+
+ private func search(delimiter: Data) -> Range<Int>? {
+ guard !delimiter.isEmpty, availableBytes >= delimiter.count else { return nil }
+ let hay = buffer[head..<buffer.count]
+
+ // Fast path for single-byte delimiters
+ if delimiter.count == 1, let byte = delimiter.first {
+ if let idx = hay.firstIndex(of: byte) {
+ let pos = head + hay.distance(from: hay.startIndex, to: idx)
+ return pos..<(pos + 1)
+ }
+ return nil
+ }
+
+ // General case
+ if let r = hay.firstRange(of: delimiter) {
+ let lower = head + hay.distance(from: hay.startIndex, to: r.lowerBound)
+ let upper = head + hay.distance(from: hay.startIndex, to: r.upperBound)
+ return lower..<upper
+ }
+
+ return nil
+ }
+
+ private static func fileLength(at url: URL) throws -> Int64 {
+ let values = try url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey])
+ guard values.isRegularFile == true else {
+ throw NetSocketError.failed(underlying: NSError(
+ 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<Void, Error>) in
+ self.readyWaiters.append(cont)
+ }
+ }
+
+ private func resumeReadyWaiters(with result: Result<Void, Error>) {
+ let waiters = self.readyWaiters
+ self.readyWaiters.removeAll()
+ for w in waiters {
+ switch result {
+ case .success: w.resume()
+ case .failure(let e): w.resume(throwing: e)
+ }
+ }
+ }
+
+ 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<T: FixedWidthInteger>(_ value: T, endian: Endian) throws {
+ var v = value
+ switch endian {
+ case .big: v = T(bigEndian: value)
+ case .little: v = T(littleEndian: value)
+ }
+ var copy = v
+ withUnsafePointer(to: &copy) { ptr in
+ self.append(contentsOf: UnsafeRawBufferPointer(start: ptr, count: MemoryLayout<T>.size))
+ }
+ }
+}
+
+// MARK: - 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<T: NetSocketEncodable>(_ 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<T: NetSocketDecodable>(_ type: T.Type, endian: Endian = .big) async throws -> T {
+ return try await T(from: self, endian: endian)
+ }
+}
diff --git a/Hotline/Library/NetSocket/TransferRateEstimator.swift b/Hotline/Library/NetSocket/TransferRateEstimator.swift
new file mode 100644
index 0000000..61383ff
--- /dev/null
+++ b/Hotline/Library/NetSocket/TransferRateEstimator.swift
@@ -0,0 +1,138 @@
+// NetSocket: TransferRateEstimator
+// Dustin Mierau • @mierau
+// MIT License
+
+import Foundation
+
+/// Transfer rate estimator using exponential moving average (EMA)
+///
+/// Tracks transfer speed and estimates time remaining. Designed to smooth out
+/// network jitter and provide stable estimates after collecting enough samples.
+///
+/// Example:
+/// ```swift
+/// var estimator = TransferRateEstimator(total: fileSize)
+///
+/// while transferring {
+/// let chunk = try await receiveData()
+/// let progress = estimator.update(bytes: chunk.count)
+/// print("Speed: \(progress.bytesPerSecond ?? 0) B/s, ETA: \(progress.estimatedTimeRemaining ?? 0)s")
+/// }
+/// ```
+public struct TransferRateEstimator {
+ /// Total bytes to transfer (nil if unknown)
+ public let total: Int?
+
+ /// Exponential moving average of transfer rate (bytes/second)
+ private var emaBytesPerSecond: Double = 0
+
+ /// Smoothing factor for EMA (0 < alpha ≤ 1)
+ /// Higher = more responsive to recent changes, lower = more smoothing
+ private let alpha: Double
+
+ /// Number of samples collected
+ private var sampleCount: Int = 0
+
+ /// Timestamp of first sample (for elapsed time calculation)
+ private var startTime: ContinuousClock.Instant?
+
+ /// Timestamp of last update (for calculating sample duration)
+ private var lastUpdateTime: ContinuousClock.Instant?
+
+ /// Minimum elapsed time before trusting estimates (seconds)
+ private let minElapsedTime: TimeInterval
+
+ /// Minimum number of samples before trusting estimates
+ private let minSamples: Int
+
+ /// Current number of bytes transferred
+ public private(set) var transferred: Int = 0
+
+ /// Create a new transfer rate estimator
+ ///
+ /// - Parameters:
+ /// - total: Total bytes to transfer (nil if unknown)
+ /// - alpha: EMA smoothing factor (default: 0.2). Range: 0.0-1.0
+ /// - minElapsedTime: Minimum elapsed time before estimates are reliable (default: 2.0s)
+ /// - minSamples: Minimum samples before estimates are reliable (default: 4)
+ public init(
+ total: Int? = nil,
+ alpha: Double = 0.2,
+ minElapsedTime: TimeInterval = 2.0,
+ minSamples: Int = 8
+ ) {
+ precondition(alpha > 0 && alpha <= 1, "alpha must be in range (0, 1]")
+ precondition(minSamples >= 0, "minSamples must be >= 0")
+
+ self.total = total
+ self.alpha = alpha
+ self.minElapsedTime = minElapsedTime
+ self.minSamples = minSamples
+ }
+
+ @discardableResult
+ public mutating func update(total: Int) -> NetSocket.FileProgress {
+ return self.update(bytes: max(0, total - self.transferred))
+ }
+
+ /// Update the estimator with a new data sample
+ ///
+ /// Automatically calculates the duration since the last update.
+ ///
+ /// - Parameter bytes: Number of bytes transferred in this sample
+ /// - Returns: Current progress with speed and ETA estimates
+ @discardableResult
+ public mutating func update(bytes: Int) -> NetSocket.FileProgress {
+ let clock = ContinuousClock()
+ let now = clock.now
+
+ // Record start time on first sample
+ if self.startTime == nil {
+ self.startTime = now
+ }
+
+ // Calculate duration since last update
+ let duration = self.lastUpdateTime.map { now - $0 } ?? .zero
+ self.lastUpdateTime = now
+
+ // Update transferred count
+ self.transferred += bytes
+
+ // Calculate instantaneous rate for this sample
+ let seconds: Double = duration / .seconds(1.0)
+ if seconds > 0 {
+ let instantRate = Double(bytes) / seconds
+ self.sampleCount += 1
+
+ // Update EMANetSocket
+ if self.emaBytesPerSecond == 0 {
+ self.emaBytesPerSecond = instantRate
+ } else {
+ self.emaBytesPerSecond += self.alpha * (instantRate - self.emaBytesPerSecond)
+ }
+ }
+
+ // Determine if we have enough data to trust the estimate
+ let elapsed = self.startTime.map { now - $0 } ?? .zero
+ let elapsedSeconds: Double = elapsed / .seconds(1.0)
+ let haveEstimate = (elapsedSeconds >= self.minElapsedTime || self.sampleCount >= self.minSamples) && self.emaBytesPerSecond > 0
+
+ // Calculate ETA if we have both an estimate and a known total
+ let eta: TimeInterval?
+ if haveEstimate, let total = self.total {
+ let remaining = total - self.transferred
+ eta = remaining > 0 ? TimeInterval(Double(remaining) / self.emaBytesPerSecond) : 0
+ } else {
+ eta = nil
+ }
+
+ return NetSocket.FileProgress(
+ sent: self.transferred,
+ total: self.total,
+ now: bytes,
+ bytesPerSecond: haveEstimate ? self.emaBytesPerSecond : nil,
+ estimatedTimeRemaining: eta
+ )
+ }
+}
+
diff --git a/Hotline/Library/RegularExpressions.swift b/Hotline/Library/RegularExpressions.swift
new file mode 100644
index 0000000..db705ae
--- /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/Utility/DAKeychain.swift b/Hotline/Library/Utility/DAKeychain.swift
new file mode 100644
index 0000000..aa71508
--- /dev/null
+++ b/Hotline/Library/Utility/DAKeychain.swift
@@ -0,0 +1,82 @@
+//
+// 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..181a035
--- /dev/null
+++ b/Hotline/Library/Utility/NSWindowBridge.swift
@@ -0,0 +1,46 @@
+import SwiftUI
+
+private class NSWindowAccessorView: NSView {
+ let executeBlock: (_ window: NSWindow?) -> Void
+
+ init(_ inConfigFunction: @escaping (_ window: NSWindow?) -> Void) {
+ 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?) -> Void
+
+ 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..05688f1
--- /dev/null
+++ b/Hotline/Library/Utility/ObservableScrollView.swift
@@ -0,0 +1,42 @@
+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<Content>: View where Content: View {
+ @Namespace var scrollSpace
+ @Binding var scrollOffset: CGFloat
+ let content: () -> Content
+
+ init(
+ scrollOffset: Binding<CGFloat>,
+ @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..a3591fd
--- /dev/null
+++ b/Hotline/Library/Utility/TextDocument.swift
@@ -0,0 +1,31 @@
+import Foundation
+import SwiftUI
+import UniformTypeIdentifiers
+
+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..6f32abb
--- /dev/null
+++ b/Hotline/Library/Views/AsyncLinkPreview.swift
@@ -0,0 +1,59 @@
+import LinkPresentation
+import SwiftUI
+
+private 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..6361823
--- /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<String>) {
+ 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
+ }
+ }
+}
+
+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..10692e6
--- /dev/null
+++ b/Hotline/Library/Views/FileIconView.swift
@@ -0,0 +1,70 @@
+import SwiftUI
+import UniformTypeIdentifiers
+
+struct FolderIconView: View {
+ private var 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 {
+ self.folderIcon
+ .resizable()
+ .scaledToFit()
+ }
+}
+
+struct FileIconView: View {
+ let filename: String
+ let fileType: String?
+
+ #if os(iOS)
+ private var 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 var 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))
+ }
+ }
+ #endif
+
+ var body: some View {
+ self.fileIcon
+ .resizable()
+ .scaledToFit()
+ }
+}
diff --git a/Hotline/Library/Views/FileImageView.swift b/Hotline/Library/Views/FileImageView.swift
new file mode 100644
index 0000000..141a71b
--- /dev/null
+++ b/Hotline/Library/Views/FileImageView.swift
@@ -0,0 +1,102 @@
+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..137bcbb
--- /dev/null
+++ b/Hotline/Library/Views/HotlinePanel.swift
@@ -0,0 +1,72 @@
+import Cocoa
+import SwiftUI
+
+private 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
+
+ // Disable state restoration for this utility panel
+ self.isRestorable = 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<Void, Never>?
+
+ 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..ec64111
--- /dev/null
+++ b/Hotline/Library/Views/VisualEffectView.swift
@@ -0,0 +1,19 @@
+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
+ }
+}