diff options
Diffstat (limited to 'Hotline/Library')
23 files changed, 1155 insertions, 85 deletions
diff --git a/Hotline/Library/BookmarkDocument.swift b/Hotline/Library/BookmarkDocument.swift new file mode 100644 index 0000000..47fa442 --- /dev/null +++ b/Hotline/Library/BookmarkDocument.swift @@ -0,0 +1,32 @@ +import SwiftUI +import Foundation +import UniformTypeIdentifiers + +struct BookmarkDocument: FileDocument { + static var readableContentTypes: [UTType] { [.data, UTType(filenameExtension: "hlbm")!] } + static var writableContentTypes: [UTType] { [.data, UTType(filenameExtension: "hlbm")!] } + + var bookmark: Bookmark + + init(bookmark: Bookmark) { + self.bookmark = bookmark + } + + init(configuration: ReadConfiguration) throws { + guard configuration.file.isRegularFile, + let data = configuration.file.regularFileContents, + let fileName = configuration.file.preferredFilename, + let bookmark = Bookmark(fileData: data, name: (fileName as NSString).deletingPathExtension) + else { + throw CocoaError(.fileReadCorruptFile) + } + self.bookmark = bookmark + } + + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + let wrapper = FileWrapper(regularFileWithContents: self.bookmark.bookmarkFileData()!) + wrapper.fileAttributes[FileAttributeKey.hfsCreatorCode.rawValue] = "HTLC".fourCharCode() + wrapper.fileAttributes[FileAttributeKey.hfsTypeCode.rawValue] = "HTbm".fourCharCode() + return wrapper + } +} diff --git a/Hotline/Library/ColorArt.swift b/Hotline/Library/ColorArt.swift new file mode 100644 index 0000000..93889a3 --- /dev/null +++ b/Hotline/Library/ColorArt.swift @@ -0,0 +1,424 @@ + +// Swift translation and modernization +// by Dustin Mierau +// +// of: +// +// ColorArt.swift +// SLColorArt by Panic Inc. +// +// Copyright (C) 2012 Panic Inc. Code by Wade Cosgrove. All rights reserved. +// +// Redistribution and use, with or without modification, are permitted +// provided that the following conditions are met: +// +// - Redistributions must reproduce the above copyright notice, this list of +// conditions and the following disclaimer in the documentation and/or other +// materials provided with the distribution. +// +// - Neither the name of Panic Inc nor the names of its contributors may be used +// to endorse or promote works derived from this software without specific prior +// written permission from Panic Inc. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +// ARE DISCLAIMED. IN NO EVENT SHALL PANIC INC BE LIABLE FOR ANY DIRECT, INDIRECT, +// INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +// PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +// POSSIBILITY OF SUCH DAMAGE. + +import AppKit +import SwiftUI + +fileprivate let kColorThresholdMinimumPercentage: CGFloat = 0.001 + +// ColorArt.analyze(image: img) -> ColorArt? + +struct ColorArt: Equatable { + let backgroundColor: NSColor + let primaryColor: NSColor + let secondaryColor: NSColor + let detailColor: NSColor +// let scaledImage: NSImage + + static func == (lhs: ColorArt, rhs: ColorArt) -> Bool { + return lhs.backgroundColor == rhs.backgroundColor && + lhs.primaryColor == rhs.primaryColor && + lhs.secondaryColor == rhs.secondaryColor && + lhs.detailColor == rhs.detailColor + } + + static func analyze(image: NSImage) -> ColorArt? { + print("ColorArt.analyze: Starting, image size: \(image.size)") + // Scale image to a reasonable size for analysis + // This is important because: + // 1. Makes analysis faster (fewer pixels) + // 2. Normalizes weird image dimensions + // 3. Ensures CGImage conversion succeeds + print("ColorArt.analyze: Calling scaleImage...") + let finalImage = Self.scaleImage(image, size: NSSize(width: 100, height: 100)) + print("ColorArt.analyze: scaleImage returned, scaled size: \(finalImage.size)") + + guard let colors = Self.analyzeImage(finalImage) else { + print("ColorArt.analyze: failed with no colors") + return nil + } + + print("ColorArt.analyze: returning colors", colors) + + return ColorArt(backgroundColor: colors.background, + primaryColor: colors.primary, + secondaryColor: colors.secondary, + detailColor: colors.detail) + } + + // MARK: - Image Scaling + + private static func scaleImage(_ image: NSImage, size scaledSize: NSSize) -> NSImage { + print("ColorArt.scaleImage: Entered, input: \(image.size), target: \(scaledSize)") + // Get CGImage directly without using lockFocus + print("ColorArt.scaleImage: Getting CGImage...") + guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + print("ColorArt.scaleImage: Failed to get CGImage, returning original") + return image + } + print("ColorArt.scaleImage: Got CGImage") + + let imageSize = image.size + let squareSize = min(imageSize.width, imageSize.height) + + // Use native square size if passed zero size + let finalScaledSize = scaledSize == .zero ? NSSize(width: squareSize, height: squareSize) : scaledSize + + // Create bitmap context for drawing + let width = Int(finalScaledSize.width) + let height = Int(finalScaledSize.height) + let colorSpace = CGColorSpaceCreateDeviceRGB() + let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue) + + guard let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width * 4, + space: colorSpace, + bitmapInfo: bitmapInfo.rawValue + ) else { + return image + } + + // Draw the image scaled + context.interpolationQuality = .high + context.draw(cgImage, in: CGRect(x: 0, y: 0, width: finalScaledSize.width, height: finalScaledSize.height)) + + // Create NSImage from context + guard let scaledCGImage = context.makeImage() else { + return image + } + + let bitmapRep = NSBitmapImageRep(cgImage: scaledCGImage) + let finalImage = NSImage(size: finalScaledSize) + finalImage.addRepresentation(bitmapRep) + + return finalImage + } + + // MARK: - Image Analysis + + private static func analyzeImage(_ image: NSImage) -> (background: NSColor, primary: NSColor, secondary: NSColor, detail: NSColor)? { + var imageColors: NSCountedSet? + guard let backgroundColor = self.findEdgeColor(image, imageColors: &imageColors), + let colors = imageColors + else { + return nil + } + + let darkBackground = backgroundColor.isDarkColor + var primaryColor: NSColor? + var secondaryColor: NSColor? + var detailColor: NSColor? + + self.findTextColors(colors, primaryColor: &primaryColor, secondaryColor: &secondaryColor, detailColor: &detailColor, backgroundColor: backgroundColor) + + // Fallback to black or white if colors not found + if primaryColor == nil { + primaryColor = darkBackground ? .white : .black + } + + if secondaryColor == nil { + secondaryColor = darkBackground ? .white : .black + } + + if detailColor == nil { + detailColor = darkBackground ? .white : .black + } + + // Convert all colors to calibrated RGB color space for consistency + // This ensures all colors are in the same color space and prevents + // any color space conversion issues when used in SwiftUI + let rgbColorSpace = NSColorSpace.genericRGB + let finalBackground = backgroundColor.usingColorSpace(rgbColorSpace) ?? backgroundColor + let finalPrimary = primaryColor!.usingColorSpace(rgbColorSpace) ?? primaryColor! + let finalSecondary = secondaryColor!.usingColorSpace(rgbColorSpace) ?? secondaryColor! + let finalDetail = detailColor!.usingColorSpace(rgbColorSpace) ?? detailColor! + + return (finalBackground, finalPrimary, finalSecondary, finalDetail) + } + + // MARK: - Edge Color Detection + + private static func findEdgeColor(_ image: NSImage, imageColors: inout NSCountedSet?) -> NSColor? { + var bitmapRep: NSBitmapImageRep? + + // Try to get existing bitmap representation + if let existingRep = image.representations.last as? NSBitmapImageRep { + bitmapRep = existingRep + } else { + // Create bitmap rep from CGImage instead of using lockFocus + guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + return nil + } + bitmapRep = NSBitmapImageRep(cgImage: cgImage) + } + + // Convert to RGB color space + guard let bitmapRep = bitmapRep?.converting(to: .genericRGB, renderingIntent: .default) else { + return nil + } + + let pixelsWide = bitmapRep.pixelsWide + let pixelsHigh = bitmapRep.pixelsHigh + + let colors = NSCountedSet(capacity: pixelsWide * pixelsHigh) + let leftEdgeColors = NSCountedSet(capacity: pixelsHigh) + var searchColumnX = 0 + + for x in 0..<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/DataAdditions.swift b/Hotline/Library/DataAdditions.swift deleted file mode 100644 index 0e9dd96..0000000 --- a/Hotline/Library/DataAdditions.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Foundation - -extension Data { - func saveAsFileToDownloads(filename: String, bounceDock: Bool = true) -> Bool { - let folderURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0] - let filePath = folderURL.generateUniqueFilePath(filename: filename) - if FileManager.default.createFile(atPath: filePath, contents: nil) { - if let h = FileHandle(forWritingAtPath: filePath) { - try? h.write(contentsOf: self) - try? h.close() - if bounceDock { - #if os(macOS) - var downloadURL = URL(filePath: filePath) - downloadURL.resolveSymlinksInPath() - DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path) - #endif - } - return true - } - } - return false - } -} diff --git a/Hotline/Library/Extensions.swift b/Hotline/Library/Extensions.swift new file mode 100644 index 0000000..469676c --- /dev/null +++ b/Hotline/Library/Extensions.swift @@ -0,0 +1,192 @@ +import Foundation +import SwiftUI +import UniformTypeIdentifiers + +extension Data { + func saveAsFileToDownloads(filename: String, bounceDock: Bool = true) -> Bool { + let folderURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0] + let filePath = folderURL.generateUniqueFilePath(filename: filename) + if FileManager.default.createFile(atPath: filePath, contents: nil) { + if let h = FileHandle(forWritingAtPath: filePath) { + try? h.write(contentsOf: self) + try? h.close() + if bounceDock { + #if os(macOS) + var downloadURL = URL(filePath: filePath) + downloadURL.resolveSymlinksInPath() + DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path) + #endif + } + return true + } + } + return false + } +} + +// MARK: - + +extension URL { + func generateUniqueFilePath(filename base: String) -> String { + let fileManager = FileManager.default + var finalName = base + var counter = 2 + + // Helper function to generate a new filename with a counter + func makeFileName() -> String { + let baseName = (base as NSString).deletingPathExtension + let extensionName = (base as NSString).pathExtension + return extensionName.isEmpty ? "\(baseName) \(counter)" : "\(baseName) \(counter).\(extensionName)" + } + + // Check if file exists and append counter until a unique name is found + var filePath = self.appending(component: finalName).path(percentEncoded: false) + while fileManager.fileExists(atPath: filePath) { + finalName = makeFileName() + filePath = self.appending(component: finalName).path(percentEncoded: false) + counter += 1 + } + + return filePath + } +} + +// MARK: - + +extension UTType { + var canBePreviewedByQuickLook: Bool { + // QuickLook supports most common document types + let supportedSupertypes: [UTType] = [ + .image, + .movie, + .audio, + .pdf, + .font, + .usdz, + .text, + .sourceCode, + .spreadsheet, + .presentation, + +// Microsoft Office + .init(filenameExtension: "doc")!, + .init(filenameExtension: "docx")!, + .init(filenameExtension: "xls")!, + .init(filenameExtension: "xlsx")!, + .init(filenameExtension: "ppt")!, + .init(filenameExtension: "pptx")!, + ] + + return supportedSupertypes.contains { self.conforms(to: $0) } + } +} + +// MARK: - + +extension String { + + func markdownToAttributedString() -> AttributedString { + let markdownText = self.convertingLinksToMarkdown() + let attr = (try? AttributedString(markdown: markdownText, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(self) + + return attr + } + + func convertToAttributedStringWithLinks() -> AttributedString { + let attributedString: NSMutableAttributedString = NSMutableAttributedString(string: self) + let matches = self.ranges(of: RegularExpressions.relaxedLink) + for match in matches { + let matchString = String(self[match]) + if matchString.isEmailAddress() { + attributedString.addAttribute(.link, value: "mailto:\(matchString)", range: NSRange(match, in: self)) + } + else { + attributedString.addAttribute(.link, value: matchString, range: NSRange(match, in: self)) + } +// attributedString.addAttribute(.underlineStyle, value: 1, range: NSRange(match, in: self)) + } + return AttributedString(attributedString) + } + + func isEmailAddress() -> Bool { + self.wholeMatch(of: RegularExpressions.emailAddress) != nil + } + + func isWebURL() -> Bool { + guard let url = URL(string: self) else { + return false + } + switch url.scheme?.lowercased() { + case "http", "https": + return true + default: + return false + } + } + + func isImageURL() -> Bool { + guard let url = URL(string: self) else { + return false + } + + switch url.pathExtension.lowercased() { + case "jpg", "jpeg", "png", "gif": + return true + default: + return false + } + } + + func convertingLinksToMarkdown() -> String { +// var cp = String(self) + + self.replacing(RegularExpressions.relaxedLink) { match in + let linkText = self[match.range] + + // Only add https:// if the link doesn't already have a scheme + let hasScheme = (try? RegularExpressions.supportedLinkScheme.prefixMatch(in: linkText)) != nil + let url = hasScheme ? String(linkText) : "https://\(linkText)" + + return "[\(linkText)](\(url))" + } + +// cp.replace(RegularExpressions.relaxedLink) { match -> String in +// let linkText = self[match.range] +// var injectedScheme = "https://" +// if let _ = try? RegularExpressions.supportedLinkScheme.prefixMatch(in: linkText) { +// injectedScheme = "" +// } +// +// return "[\(linkText)](\(injectedScheme)\(linkText))" +// } +// return cp + } +} + +// MARK: - + +extension Binding where Value: OptionSet, Value == Value.Element { + func bindedValue(_ options: Value) -> Bool { + return wrappedValue.contains(options) + } + + func bind(_ options: Value) -> Binding<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 index 1cc0228..c2306f3 100644 --- a/Hotline/Library/NetSocket/FileProgress.swift +++ b/Hotline/Library/NetSocket/FileProgress.swift @@ -1,4 +1,4 @@ -// NetSocketProgress +// NetSocket FileProgress // Dustin Mierau • @mierau // MIT License diff --git a/Hotline/Library/NetSocket/NetSocketNew.swift b/Hotline/Library/NetSocket/NetSocket.swift index 12f4271..5c7d185 100644 --- a/Hotline/Library/NetSocket/NetSocketNew.swift +++ b/Hotline/Library/NetSocket/NetSocket.swift @@ -1,5 +1,6 @@ // NetSocket.swift // Dustin Mierau • @mierau +// MIT License import Foundation import Network @@ -791,10 +792,7 @@ public actor NetSocket { private func startReceiveLoop() { @Sendable func loop(_ connection: NWConnection, chunk: Int, owner: NetSocket, connID: String) { - print("NetSocket[\(connID)]: Calling connection.receive(\(chunk)) to request more data...") - connection.receive(minimumIncompleteLength: 1, maximumLength: chunk) { [weak owner] data, _, isComplete, error in - print("NetSocket[\(connID)]: Receive callback - data: \(data?.count ?? 0) bytes, isComplete: \(isComplete), error: \(String(describing: error))") Task { guard let o = owner else { return @@ -808,7 +806,6 @@ public actor NetSocket { await o.append(data, connID: connID) } if isComplete { - print("NetSocket[\(connID)]: EOF from peer.") await o.handleEOF() return } @@ -950,7 +947,6 @@ public actor NetSocket { } private func append(_ data: Data, connID: String) { - print("NetSocket[\(connID)]: Received \(data.count) bytes from network, buffer now has \(buffer.count - head + data.count) available") buffer.append(data) if buffer.count - head > config.maxBufferBytes { // Hard stop: drop connection rather than OOM'ing. diff --git a/Hotline/Library/NetSocket/TransferRateEstimator.swift b/Hotline/Library/NetSocket/TransferRateEstimator.swift index 60647a7..badf87a 100644 --- a/Hotline/Library/NetSocket/TransferRateEstimator.swift +++ b/Hotline/Library/NetSocket/TransferRateEstimator.swift @@ -1,4 +1,4 @@ -// TransferRateEstimator +// NetSocket: TransferRateEstimator // Dustin Mierau • @mierau // MIT License diff --git a/Hotline/Library/RegularExpressions.swift b/Hotline/Library/RegularExpressions.swift new file mode 100644 index 0000000..c1f2a18 --- /dev/null +++ b/Hotline/Library/RegularExpressions.swift @@ -0,0 +1,235 @@ +import RegexBuilder + +struct RegularExpressions { + static let messageBoardDivider = Regex { + Capture { + OneOrMore { + CharacterClass(.newlineSequence) + } + ZeroOrMore { + CharacterClass(.whitespace, .newlineSequence) + } + Repeat(2...) { + CharacterClass(.anyOf("_-")) + } + ZeroOrMore { + CharacterClass(.whitespace) + } + OneOrMore { + CharacterClass(.newlineSequence) + } + } + } + + static let supportedLinkScheme = Regex { + Anchor.startOfLine + ChoiceOf { + "hotline" + "http" + "https" + } + "://" + }.ignoresCase().anchorsMatchLineEndings() + + static let relaxedLink = Regex { + ChoiceOf { + Anchor.startOfLine + Anchor.wordBoundary + } + Capture { + // scheme (optional) + Optionally { + ChoiceOf { + "hotline://" + "http://" + "https://" + } + } + // domain name + OneOrMore { + CharacterClass( + .anyOf(".-@"), + ("a"..."z"), + ("0"..."9") + ) + } + // top-level domain name + "." + ChoiceOf { + "com" + "net" + "org" + "edu" + "gov" + "mil" + "aero" + "asia" + "biz" + "cat" + "coop" + "info" + "int" + "jobs" + "mobi" + "museum" + "name" + "pizza" + "post" + "pro" + "red" + "tel" + "today" + "travel" + "garden" + "online" + "ai" + "be" + "by" + "ca" + "co" + "de" + "er" + "es" + "fr" + "gs" + "ie" + "im" + "in" + "io" + "is" + "it" + "jp" + "la" + "ly" + "ma" + "md" + "me" + "my" + "nl" + "ps" + "pt" + "ja" + "st" + "to" + "tv" + "uk" + "ws" + } + // Port + Optionally { + ":" + OneOrMore { + CharacterClass(.digit) + } + } + // path + ZeroOrMore { + CharacterClass( + .anyOf("#_-/.?=&%\\()[]"), + ("a"..."z"), + ("0"..."9") + ) + } + } + ChoiceOf { + Anchor.endOfLine + Anchor.wordBoundary + } + } + .anchorsMatchLineEndings() + .ignoresCase() + + static let emailAddress = Regex { + ChoiceOf { + Anchor.startOfLine + Anchor.wordBoundary + } + Capture { + // username + OneOrMore { + CharacterClass( + .anyOf(".-_"), + ("a"..."z"), + ("0"..."9") + ) + } + "@" + // domain name + OneOrMore { + CharacterClass( + .anyOf(".-"), + ("a"..."z"), + ("0"..."9") + ) + } + // top-level domain name + "." + ChoiceOf { + "com" + "net" + "org" + "edu" + "gov" + "mil" + "aero" + "asia" + "biz" + "cat" + "coop" + "info" + "int" + "jobs" + "mobi" + "museum" + "name" + "pizza" + "post" + "pro" + "red" + "tel" + "today" + "travel" + "garden" + "online" + "ai" + "be" + "by" + "ca" + "co" + "de" + "er" + "es" + "fr" + "gs" + "ie" + "im" + "in" + "io" + "is" + "it" + "jp" + "la" + "ly" + "ma" + "md" + "me" + "my" + "nl" + "ps" + "pt" + "ja" + "st" + "to" + "tv" + "uk" + "ws" + } + } + ChoiceOf { + Anchor.endOfLine + Anchor.wordBoundary + } + } + .anchorsMatchLineEndings() + .ignoresCase() +} diff --git a/Hotline/Library/SoundEffects.swift b/Hotline/Library/SoundEffects.swift new file mode 100644 index 0000000..93f8b9a --- /dev/null +++ b/Hotline/Library/SoundEffects.swift @@ -0,0 +1,50 @@ +import Foundation +import AVFAudio + +enum SoundEffects: String { + case loggedIn = "logged-in" + case chatMessage = "chat-message" + case transferComplete = "transfer-complete" + case userLogin = "user-login" + case userLogout = "user-logout" + case newNews = "new-news" + case serverMessage = "server-message" + case error = "error" +} + +@Observable +class SoundEffectPlayer: NSObject, AVAudioPlayerDelegate { + static let shared = SoundEffectPlayer() + + private var activeSounds: [AVAudioPlayer] = [] + + func playSoundEffect(_ name: SoundEffects) { + // Load a local sound file + guard let soundFileURL = Bundle.main.url( + forResource: name.rawValue, + withExtension: "aiff" + ) else { + return + } + + DispatchQueue.main.async { [weak self] in + guard let self = self else { + return + } + + if let soundEffect = try? AVAudioPlayer(contentsOf: soundFileURL) { + soundEffect.delegate = self + soundEffect.volume = 0.75 + soundEffect.play() + + self.activeSounds.append(soundEffect) + } + } + } + + func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { + if let i = self.activeSounds.firstIndex(of: player) { + self.activeSounds.remove(at: i) + } + } +} diff --git a/Hotline/Library/URLAdditions.swift b/Hotline/Library/URLAdditions.swift deleted file mode 100644 index 0ba2100..0000000 --- a/Hotline/Library/URLAdditions.swift +++ /dev/null @@ -1,55 +0,0 @@ -import Foundation -import UniformTypeIdentifiers - -extension URL { - func generateUniqueFilePath(filename base: String) -> String { - let fileManager = FileManager.default - var finalName = base - var counter = 2 - - // Helper function to generate a new filename with a counter - func makeFileName() -> String { - let baseName = (base as NSString).deletingPathExtension - let extensionName = (base as NSString).pathExtension - return extensionName.isEmpty ? "\(baseName) \(counter)" : "\(baseName) \(counter).\(extensionName)" - } - - // Check if file exists and append counter until a unique name is found - var filePath = self.appending(component: finalName).path(percentEncoded: false) - while fileManager.fileExists(atPath: filePath) { - finalName = makeFileName() - filePath = self.appending(component: finalName).path(percentEncoded: false) - counter += 1 - } - - return filePath - } -} - -extension UTType { - var canBePreviewedByQuickLook: Bool { - // QuickLook supports most common document types - let supportedSupertypes: [UTType] = [ - .image, - .movie, - .audio, - .pdf, - .font, - .usdz, - .text, - .sourceCode, - .spreadsheet, - .presentation, - -// Microsoft Office - .init(filenameExtension: "doc")!, - .init(filenameExtension: "docx")!, - .init(filenameExtension: "xls")!, - .init(filenameExtension: "xlsx")!, - .init(filenameExtension: "ppt")!, - .init(filenameExtension: "pptx")!, - ] - - return supportedSupertypes.contains { self.conforms(to: $0) } - } -} diff --git a/Hotline/Library/Utility/DAKeychain.swift b/Hotline/Library/Utility/DAKeychain.swift new file mode 100644 index 0000000..8031886 --- /dev/null +++ b/Hotline/Library/Utility/DAKeychain.swift @@ -0,0 +1,81 @@ +// +// DAKeychain.swift +// DAKeychain +// +// Created by Dejan on 28/02/2017. +// Copyright © 2017 Dejan. All rights reserved. +// + +import Foundation + +open class DAKeychain { + + open var loggingEnabled = false + + private init() {} + public static let shared = DAKeychain() + + open subscript(key: String) -> String? { + get { + return load(withKey: key) + } set { + DispatchQueue.global().sync(flags: .barrier) { + self.save(newValue, forKey: key) + } + } + } + + private func save(_ string: String?, forKey key: String) { + let query = keychainQuery(withKey: key) + let objectData: Data? = string?.data(using: .utf8, allowLossyConversion: false) + + if SecItemCopyMatching(query, nil) == noErr { + if let dictData = objectData { + let status = SecItemUpdate(query, NSDictionary(dictionary: [kSecValueData: dictData])) + logPrint("Update status: ", status) + } else { + let status = SecItemDelete(query) + logPrint("Delete status: ", status) + } + } else { + if let dictData = objectData { + query.setValue(dictData, forKey: kSecValueData as String) + let status = SecItemAdd(query, nil) + logPrint("Update status: ", status) + } + } + } + + private func load(withKey key: String) -> String? { + let query = keychainQuery(withKey: key) + query.setValue(kCFBooleanTrue, forKey: kSecReturnData as String) + query.setValue(kCFBooleanTrue, forKey: kSecReturnAttributes as String) + + var result: CFTypeRef? + let status = SecItemCopyMatching(query, &result) + + guard + let resultsDict = result as? NSDictionary, + let resultsData = resultsDict.value(forKey: kSecValueData as String) as? Data, + status == noErr + else { + logPrint("Load status: ", status) + return nil + } + return String(data: resultsData, encoding: .utf8) + } + + private func keychainQuery(withKey key: String) -> NSMutableDictionary { + let result = NSMutableDictionary() + result.setValue(kSecClassGenericPassword, forKey: kSecClass as String) + result.setValue(key, forKey: kSecAttrService as String) + result.setValue(kSecAttrAccessibleWhenUnlocked, forKey: kSecAttrAccessible as String) + return result + } + + private func logPrint(_ items: Any...) { + if loggingEnabled { + print(items) + } + } +} diff --git a/Hotline/Library/Utility/NSWindowBridge.swift b/Hotline/Library/Utility/NSWindowBridge.swift new file mode 100644 index 0000000..8f766d9 --- /dev/null +++ b/Hotline/Library/Utility/NSWindowBridge.swift @@ -0,0 +1,46 @@ +import SwiftUI + +fileprivate class NSWindowAccessorView: NSView { + let executeBlock: (_ window: NSWindow? ) -> () + + init(_ inConfigFunction: @escaping (_ window: NSWindow? ) -> () ) { + executeBlock = inConfigFunction + super.init( frame: NSRect() ) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + public override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + executeBlock( self.window ) // We pass it through even if it is nil. + } +} + +public struct NSWindowAccessor: NSViewRepresentable { + var configCode: (_ window: NSWindow? ) -> () + + public init(_ configCode: @escaping (_: NSWindow?) -> Void) { self.configCode = configCode } + public func makeNSView(context: Context) -> NSView { return NSWindowAccessorView( configCode ) } + public func updateNSView(_ nsView: NSView, context: Context) {} +} + + +//import SwiftUI + + /// A helper view you can embed once per window to run a closure +/// with the underlying NSWindow reference. +struct WindowConfigurator: NSViewRepresentable { + let configure: (NSWindow) -> Void + + func makeNSView(context: Context) -> NSView { + let view = NSView() + DispatchQueue.main.async { + if let window = view.window { + configure(window) + } + } + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { } +} diff --git a/Hotline/Library/Utility/ObservableScrollView.swift b/Hotline/Library/Utility/ObservableScrollView.swift new file mode 100644 index 0000000..a6f46cc --- /dev/null +++ b/Hotline/Library/Utility/ObservableScrollView.swift @@ -0,0 +1,38 @@ +import SwiftUI + +struct ScrollViewOffsetPreferenceKey: PreferenceKey { + typealias Value = CGFloat + static var defaultValue = CGFloat.zero + static func reduce(value: inout Value, nextValue: () -> Value) { + value += nextValue() + } +} + +struct ObservableScrollView<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..82727de --- /dev/null +++ b/Hotline/Library/Utility/TextDocument.swift @@ -0,0 +1,31 @@ +import Foundation +import UniformTypeIdentifiers +import SwiftUI + +struct TextFile: FileDocument { + // tell the system we support only plain text + static var readableContentTypes = [UTType.plainText, UTType.utf8PlainText] + + // by default our document is empty + var text = "" + + // a simple initializer that creates new, empty documents + init(initialText: String = "") { + text = initialText + } + + // this initializer loads data that has been saved previously + init(configuration: ReadConfiguration) throws { + + if let data = configuration.file.regularFileContents { + if let str = String(data: data, encoding: .utf8) { + self.text = str + } + } + } + + // this will be called when the system wants to write our data to disk + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + return FileWrapper(regularFileWithContents: self.text.data(using: .utf8)!) + } +} diff --git a/Hotline/Library/AsyncLinkPreview.swift b/Hotline/Library/Views/AsyncLinkPreview.swift index 89b186f..89b186f 100644 --- a/Hotline/Library/AsyncLinkPreview.swift +++ b/Hotline/Library/Views/AsyncLinkPreview.swift diff --git a/Hotline/Library/TextView.swift b/Hotline/Library/Views/BetterTextEditor.swift index 47746e2..47746e2 100644 --- a/Hotline/Library/TextView.swift +++ b/Hotline/Library/Views/BetterTextEditor.swift diff --git a/Hotline/Library/FileIconView.swift b/Hotline/Library/Views/FileIconView.swift index d0949fa..d0949fa 100644 --- a/Hotline/Library/FileIconView.swift +++ b/Hotline/Library/Views/FileIconView.swift diff --git a/Hotline/Library/FileImageView.swift b/Hotline/Library/Views/FileImageView.swift index 0cc50f1..0cc50f1 100644 --- a/Hotline/Library/FileImageView.swift +++ b/Hotline/Library/Views/FileImageView.swift 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/HotlinePanel.swift b/Hotline/Library/Views/HotlinePanel.swift index d7d8284..d7d8284 100644 --- a/Hotline/Library/HotlinePanel.swift +++ b/Hotline/Library/Views/HotlinePanel.swift diff --git a/Hotline/Library/QuickLookPreviewView.swift b/Hotline/Library/Views/QuickLookPreviewView.swift index 6ba154e..6ba154e 100644 --- a/Hotline/Library/QuickLookPreviewView.swift +++ b/Hotline/Library/Views/QuickLookPreviewView.swift diff --git a/Hotline/Library/SpinningGlobeView.swift b/Hotline/Library/Views/SpinningGlobeView.swift index bccb969..bccb969 100644 --- a/Hotline/Library/SpinningGlobeView.swift +++ b/Hotline/Library/Views/SpinningGlobeView.swift diff --git a/Hotline/Library/VisualEffectView.swift b/Hotline/Library/Views/VisualEffectView.swift index e595c55..e595c55 100644 --- a/Hotline/Library/VisualEffectView.swift +++ b/Hotline/Library/Views/VisualEffectView.swift |