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/DataAdditions.swift23
-rw-r--r--Hotline/Library/Extensions.swift192
-rw-r--r--Hotline/Library/NetSocket/FileProgress.swift2
-rw-r--r--Hotline/Library/NetSocket/NetSocket.swift (renamed from Hotline/Library/NetSocket/NetSocketNew.swift)6
-rw-r--r--Hotline/Library/NetSocket/TransferRateEstimator.swift2
-rw-r--r--Hotline/Library/RegularExpressions.swift235
-rw-r--r--Hotline/Library/SoundEffects.swift50
-rw-r--r--Hotline/Library/URLAdditions.swift55
-rw-r--r--Hotline/Library/Utility/DAKeychain.swift81
-rw-r--r--Hotline/Library/Utility/NSWindowBridge.swift46
-rw-r--r--Hotline/Library/Utility/ObservableScrollView.swift38
-rw-r--r--Hotline/Library/Utility/TextDocument.swift31
-rw-r--r--Hotline/Library/Views/AsyncLinkPreview.swift (renamed from Hotline/Library/AsyncLinkPreview.swift)0
-rw-r--r--Hotline/Library/Views/BetterTextEditor.swift (renamed from Hotline/Library/TextView.swift)0
-rw-r--r--Hotline/Library/Views/FileIconView.swift (renamed from Hotline/Library/FileIconView.swift)0
-rw-r--r--Hotline/Library/Views/FileImageView.swift (renamed from Hotline/Library/FileImageView.swift)0
-rw-r--r--Hotline/Library/Views/GroupedIconView.swift23
-rw-r--r--Hotline/Library/Views/HotlinePanel.swift (renamed from Hotline/Library/HotlinePanel.swift)0
-rw-r--r--Hotline/Library/Views/QuickLookPreviewView.swift (renamed from Hotline/Library/QuickLookPreviewView.swift)0
-rw-r--r--Hotline/Library/Views/SpinningGlobeView.swift (renamed from Hotline/Library/SpinningGlobeView.swift)0
-rw-r--r--Hotline/Library/Views/VisualEffectView.swift (renamed from Hotline/Library/VisualEffectView.swift)0
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