aboutsummaryrefslogtreecommitdiff
path: root/Hotline
diff options
context:
space:
mode:
authorDustin Mierau <dustin@mierau.me>2024-01-05 19:46:13 -0800
committerDustin Mierau <dustin@mierau.me>2024-01-05 19:46:37 -0800
commit2f8f71d0410f59d5d303a4c221a628630168bfe2 (patch)
tree6a18ae2e4dbd72a688667bd9e819ecee72d76b52 /Hotline
parent57c33eb798f6802fe5af8fcd3d1992a4b63a448f (diff)
Add image preview to Files so you don't have to download images to view them, works with animated GIFs too. Added cmd-R shortcut for Servers window. More work on login view.
Diffstat (limited to 'Hotline')
-rw-r--r--Hotline/Application.swift16
-rw-r--r--Hotline/Hotline/HotlineClient.swift6
-rw-r--r--Hotline/Hotline/HotlineFileClient.swift5
-rw-r--r--Hotline/Models/FileInfo.swift11
-rw-r--r--Hotline/Models/FilePreview.swift110
-rw-r--r--Hotline/Models/Hotline.swift98
-rw-r--r--Hotline/Models/PreviewFileInfo.swift20
-rw-r--r--Hotline/Models/Server.swift33
-rw-r--r--Hotline/iOS/FilesView.swift5
-rw-r--r--Hotline/iOS/TrackerView.swift6
-rw-r--r--Hotline/macOS/FileImageView.swift49
-rw-r--r--Hotline/macOS/FilePreviewView.swift333
-rw-r--r--Hotline/macOS/FilesView.swift31
-rw-r--r--Hotline/macOS/ServerView.swift156
-rw-r--r--Hotline/macOS/TrackerView.swift34
15 files changed, 712 insertions, 201 deletions
diff --git a/Hotline/Application.swift b/Hotline/Application.swift
index 91f8dac..e08695a 100644
--- a/Hotline/Application.swift
+++ b/Hotline/Application.swift
@@ -20,17 +20,22 @@ struct Application: App {
.environment(model)
}
#elseif os(macOS)
+ // MARK: Tracker Window
Window("Servers", id: "servers") {
TrackerView()
.frame(minWidth: 250, minHeight: 250)
}
+ .keyboardShortcut(.init(.init("R"), modifiers: .command))
.defaultSize(width: 700, height: 550)
.defaultPosition(.center)
-
+
+ // MARK: Server Window
WindowGroup(id: "server", for: Server.self) { $server in
ServerView(server: $server)
.frame(minWidth: 400, minHeight: 300)
.environment(preferences)
+ } defaultValue: {
+ Server(name: nil, description: nil, address: "")
}
.defaultSize(width: 750, height: 700)
.defaultPosition(.center)
@@ -43,6 +48,15 @@ struct Application: App {
}
}
+// WindowGroup(id: "preview", for: PreviewFileInfo.self) { info in
+// FilePreviewView(info: info)
+// .frame(minWidth: 400, minHeight: 300)
+// }
+// .defaultSize(width: 750, height: 700)
+// .windowStyle(.hiddenTitleBar)
+// .aspectRatio(nil, contentMode: .fit)
+
+ // MARK: Settings Window
Settings {
SettingsView()
.environment(preferences)
diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift
index 78597e6..3e45321 100644
--- a/Hotline/Hotline/HotlineClient.swift
+++ b/Hotline/Hotline/HotlineClient.swift
@@ -71,7 +71,7 @@ class HotlineClient {
// MARK: -
- func login(_ address: String, port: UInt16, login: String, password: String, username: String, iconID: UInt16, callback: ((HotlineTransactionError?, String?, UInt16?) -> Void)?) {
+ func login(_ address: String, port: UInt16, login: String?, password: String?, username: String, iconID: UInt16, callback: ((HotlineTransactionError?, String?, UInt16?) -> Void)?) {
print("AWAITING CONNECT")
self.connect(address: address, port: port) { [weak self] success in
guard success else {
@@ -90,8 +90,8 @@ class HotlineClient {
return
}
- print("AWAITING LOGIN")
- self?.sendLogin(login: login, password: password, username: username, iconID: iconID) { err, serverName, serverVersion in
+ print("AWAITING LOGIN \(login ?? "empty login") \(password ?? "empty pass")")
+ self?.sendLogin(login: login ?? "", password: password ?? "", username: username, iconID: iconID) { err, serverName, serverVersion in
// guard err == nil else {
// DispatchQueue.main.async {
// callback?(err, nil, nil)
diff --git a/Hotline/Hotline/HotlineFileClient.swift b/Hotline/Hotline/HotlineFileClient.swift
index d2b063b..13bd29c 100644
--- a/Hotline/Hotline/HotlineFileClient.swift
+++ b/Hotline/Hotline/HotlineFileClient.swift
@@ -533,12 +533,9 @@ class HotlineFileClient {
if let newData = data, !newData.isEmpty {
self.fileBytesDownloaded += newData.count
self.fileBytes.append(newData)
- self.fileProgress?.completedUnitCount = Int64(self.fileBytesDownloaded)
self.status = .progress(Double(self.fileBytesDownloaded) / Double(self.referenceDataSize))
-
-
- print("DOWNLOAD PROGRESS", self.fileProgress.debugDescription)
+ print("DOWNLOAD PROGRESS", self.fileBytesDownloaded, self.referenceDataSize, isComplete)
}
if self.fileBytesDownloaded < Int(self.referenceDataSize) {
diff --git a/Hotline/Models/FileInfo.swift b/Hotline/Models/FileInfo.swift
index 9caa976..0cd9b2b 100644
--- a/Hotline/Models/FileInfo.swift
+++ b/Hotline/Models/FileInfo.swift
@@ -1,4 +1,5 @@
import SwiftUI
+import UniformTypeIdentifiers
@Observable class FileInfo: Identifiable, Hashable {
let id: UUID
@@ -15,6 +16,16 @@ import SwiftUI
var expanded: Bool = false
var children: [FileInfo]? = nil
+ var isImage: Bool {
+ let fileExtension = (self.name as NSString).pathExtension
+ if let fileType = UTType(filenameExtension: fileExtension) {
+ if fileType.isSubtype(of: .image) {
+ return true
+ }
+ }
+ return false
+ }
+
init(hotlineFile: HotlineFile) {
self.id = UUID()
self.path = hotlineFile.path
diff --git a/Hotline/Models/FilePreview.swift b/Hotline/Models/FilePreview.swift
index 552ccff..a7b183f 100644
--- a/Hotline/Models/FilePreview.swift
+++ b/Hotline/Models/FilePreview.swift
@@ -1,41 +1,73 @@
import SwiftUI
+import UniformTypeIdentifiers
-//struct FilePreview: Identifiable, Hashable {
-// let id: UUID
-//
-// let path: [String]
-// let name: String
-//
-// let type: String
-// let creator: String
-// let fileSize: UInt
-//
-// let isFolder: Bool
-// let isUnavailable: Bool
-// var expanded: Bool = false
-// var children: [FileInfo]? = nil
-//
-// init(hotlineFile: HotlineFile) {
-// self.id = UUID()
-// self.path = hotlineFile.path
-// self.name = hotlineFile.name
-// self.type = hotlineFile.type
-// self.creator = hotlineFile.creator
-// self.fileSize = UInt(hotlineFile.fileSize)
-// self.isFolder = hotlineFile.isFolder
-// self.isUnavailable = (!self.isFolder && (self.fileSize == 0))
-//
-// print(self.name, self.type, self.creator, self.isUnavailable)
-// if self.isFolder {
-// self.children = []
-// }
-// }
-//
-// static func == (lhs: FileInfo, rhs: FileInfo) -> Bool {
-// return lhs.id == rhs.id
-// }
-//
-// func hash(into hasher: inout Hasher) {
-// hasher.combine(self.id)
-// }
-//}
+enum FilePreviewState: Equatable {
+ case unloaded
+ case loading
+ case loaded
+ case failed
+}
+
+@Observable
+final class FilePreview: HotlineFileClientDelegate {
+ @ObservationIgnored let info: PreviewFileInfo
+ @ObservationIgnored var client: HotlineFileClient? = nil
+
+ var state: FilePreviewState = .unloaded
+ var progress: Double = 0.0
+
+ #if os(macOS)
+ var image: NSImage?
+ #endif
+
+ init(info: PreviewFileInfo) {
+ self.info = info
+
+ self.client = HotlineFileClient(address: info.address, port: UInt16(info.port), reference: info.id, size: UInt32(info.size), type: .preview)
+ self.client?.delegate = self
+ }
+
+ func download() {
+ self.client?.downloadToMemory()
+ }
+
+ func cancel() {
+ self.client?.cancel(deleteIncompleteFile: true)
+ }
+
+ func hotlineFileStatusChanged(client: HotlineFileClient, reference: UInt32, status: HotlineFileClientStatus, timeRemaining: TimeInterval) {
+ print("FILE STATUS CHANGED", status)
+
+ switch status {
+ case .unconnected:
+ state = .unloaded
+ progress = 0.0
+ case .connecting:
+ state = .loading
+ progress = 0.0
+ case .connected:
+ state = .loading
+ progress = 0.0
+ case .progress(let p):
+ state = .loading
+ progress = p
+ case .failed(_):
+ state = .failed
+ progress = 0.0
+ case .completed:
+ state = .loaded
+ progress = 1.0
+ }
+ }
+
+ func hotlineFileDownloadedData(client: HotlineFileClient, reference: UInt32, data: Data) {
+ self.state = .loaded
+
+ let fileExtension = (info.name as NSString).pathExtension
+ if let fileType = UTType(filenameExtension: fileExtension) {
+ if fileType.isSubtype(of: .image) {
+ self.image = NSImage(data: data)
+ }
+ }
+ }
+}
diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift
index 3e3bf26..e88c267 100644
--- a/Hotline/Models/Hotline.swift
+++ b/Hotline/Models/Hotline.swift
@@ -1,6 +1,7 @@
import SwiftUI
-@Observable final class Hotline: HotlineClientDelegate, HotlineFileClientDelegate {
+@Observable
+final class Hotline: HotlineClientDelegate, HotlineFileClientDelegate {
let trackerClient: HotlineTrackerClient
let client: HotlineClient
@@ -239,13 +240,15 @@ import SwiftUI
self.trackerClient.disconnect()
}
- @MainActor func login(server: Server, login: String, password: String, username: String, iconID: Int, callback: ((Bool) -> Void)? = nil) {
+ @MainActor func login(server: Server, username: String, iconID: Int, callback: ((Bool) -> Void)? = nil) {
self.server = server
self.serverName = server.name
self.username = username
self.iconID = iconID
- self.client.login(server.address, port: UInt16(server.port), login: login, password: password, username: username, iconID: UInt16(iconID)) { [weak self] err, serverName, serverVersion in
+ print("CLIENT LOGIN: '\(server.login)' '\(server.password == nil)'")
+
+ self.client.login(server.address, port: UInt16(server.port), login: server.login, password: server.password, username: username, iconID: UInt16(iconID)) { [weak self] err, serverName, serverVersion in
self?.serverVersion = serverVersion
if serverName != nil {
self?.serverName = serverName
@@ -519,16 +522,21 @@ import SwiftUI
})
}
- @MainActor func previewFile(_ fileName: String, path: [String], addTransfer: Bool = false, complete callback: ((TransferInfo, Data) -> Void)? = nil) {
+ @MainActor func previewFile(_ fileName: String, path: [String], complete callback: ((PreviewFileInfo?) -> Void)? = nil) {
var fullPath: [String] = []
if path.count > 1 {
fullPath = Array(path[0..<path.count-1])
}
- self.client.sendDownloadFile(name: fileName, path: fullPath, preview: true, sent: { _ in
+ self.client.sendDownloadFile(name: fileName, path: fullPath, preview: true, sent: { success in
+ if !success {
+ callback?(nil)
+ return
+ }
}, reply: { [weak self] success, downloadReferenceNumber, downloadTransferSize, downloadFileSize, downloadWaitingCount in
guard success else {
+ callback?(nil)
return
}
@@ -539,24 +547,27 @@ import SwiftUI
print("REFERENCE NUM: \(downloadReferenceNumber.debugDescription)")
print("WAITING COUNT: \(downloadWaitingCount.debugDescription)")
+ var info: PreviewFileInfo? = nil
+
if
- let self = self,
- let address = self.server?.address,
- let port = self.server?.port,
+ let address = self?.server?.address,
+ let port = self?.server?.port,
let referenceNumber = downloadReferenceNumber,
let transferSize = downloadTransferSize {
- let fileClient = HotlineFileClient(address: address, port: UInt16(port), reference: referenceNumber, size: UInt32(transferSize), type: .preview)
- fileClient.delegate = self
- self.downloads.append(fileClient)
+ info = PreviewFileInfo(id: referenceNumber, address: address, port: port, size: transferSize, name: fileName)
- if addTransfer {
- let transfer = TransferInfo(id: referenceNumber, title: fileName, size: UInt(transferSize))
- transfer.previewCallback = callback
- self.transfers.append(transfer)
- }
+// let fileClient = HotlineFileClient(address: address, port: UInt16(port), reference: referenceNumber, size: UInt32(transferSize), type: .preview)
+// fileClient.delegate = self
+// self.downloads.append(fileClient)
- fileClient.downloadToMemory()
+// if addTransfer {
+// let transfer = TransferInfo(id: referenceNumber, title: fileName, size: UInt(transferSize))
+// transfer.previewCallback = callback
+// self.transfers.append(transfer)
+// }
+
+// fileClient.downloadToMemory()
print("DOWNLOADING TO MEMORY")
// fileClient.downloadToMemory({ [weak self] fileData in
@@ -567,9 +578,62 @@ import SwiftUI
// self.downloads.append(fileClient)
}
+
+ callback?(info)
})
}
+// @MainActor func previewFile(_ fileName: String, path: [String], addTransfer: Bool = false, complete callback: ((TransferInfo, Data) -> Void)? = nil) {
+// var fullPath: [String] = []
+// if path.count > 1 {
+// fullPath = Array(path[0..<path.count-1])
+// }
+//
+// self.client.sendDownloadFile(name: fileName, path: fullPath, preview: true, sent: { _ in
+//
+// }, reply: { [weak self] success, downloadReferenceNumber, downloadTransferSize, downloadFileSize, downloadWaitingCount in
+// guard success else {
+// return
+// }
+//
+// print("GOT DOWNLOAD REPLY:")
+// print("SUCCESS?", success)
+// print("TRANSFER SIZE: \(downloadTransferSize.debugDescription)")
+// print("FILE SIZE: \(downloadFileSize.debugDescription)")
+// print("REFERENCE NUM: \(downloadReferenceNumber.debugDescription)")
+// print("WAITING COUNT: \(downloadWaitingCount.debugDescription)")
+//
+// if
+// let self = self,
+// let address = self.server?.address,
+// let port = self.server?.port,
+// let referenceNumber = downloadReferenceNumber,
+// let transferSize = downloadTransferSize {
+//
+// let fileClient = HotlineFileClient(address: address, port: UInt16(port), reference: referenceNumber, size: UInt32(transferSize), type: .preview)
+// fileClient.delegate = self
+// self.downloads.append(fileClient)
+//
+// if addTransfer {
+// let transfer = TransferInfo(id: referenceNumber, title: fileName, size: UInt(transferSize))
+// transfer.previewCallback = callback
+// self.transfers.append(transfer)
+// }
+//
+// fileClient.downloadToMemory()
+//
+// print("DOWNLOADING TO MEMORY")
+//// fileClient.downloadToMemory({ [weak self] fileData in
+//// print("DOWNLOADED PREVIEW DATA", fileData?.count)
+//// self?.downloads.removeAll { $0.referenceNumber == referenceNumber }
+//// callback?(fileData != nil, fileData)
+//// })
+//
+//// self.downloads.append(fileClient)
+// }
+// })
+// }
+
@MainActor func deleteTransfer(id: UInt32) {
if let b = self.bannerClient, b.referenceNumber == id {
b.cancel()
diff --git a/Hotline/Models/PreviewFileInfo.swift b/Hotline/Models/PreviewFileInfo.swift
new file mode 100644
index 0000000..9a4c980
--- /dev/null
+++ b/Hotline/Models/PreviewFileInfo.swift
@@ -0,0 +1,20 @@
+
+struct PreviewFileInfo: Identifiable, Codable {
+ var id: UInt32
+ var address: String
+ var port: Int
+ var size: Int
+ var name: String
+}
+
+extension PreviewFileInfo: Equatable {
+ static func == (lhs: PreviewFileInfo, rhs: PreviewFileInfo) -> Bool {
+ return lhs.id == rhs.id
+ }
+}
+
+extension PreviewFileInfo: Hashable {
+ func hash(into hasher: inout Hasher) {
+ hasher.combine(self.id)
+ }
+}
diff --git a/Hotline/Models/Server.swift b/Hotline/Models/Server.swift
index 62c6867..27d45c6 100644
--- a/Hotline/Models/Server.swift
+++ b/Hotline/Models/Server.swift
@@ -4,22 +4,47 @@ struct Server: Codable {
var name: String?
var description: String?
var users: Int
+
var address: String
var port: Int
+ var login: String
+ var password: String
- init(name: String?, description: String?, address: String, port: Int = HotlinePorts.DefaultServerPort, users: Int = 0) {
+ init(name: String?, description: String?, address: String, port: Int = HotlinePorts.DefaultServerPort, users: Int = 0, login: String = "", password: String = "") {
self.name = name
self.description = description
self.address = address.lowercased()
self.port = port
self.users = users
+ self.login = login
+ self.password = password
+ }
+
+ init?(url: URL) {
+ guard url.scheme?.lowercased() == "hotline" else {
+ return nil
+ }
+
+ guard let host = url.host(percentEncoded: false) else {
+ return nil
+ }
+
+ self.name = nil
+ self.description = nil
+ self.users = 0
+
+ self.address = host.lowercased()
+ self.port = url.port ?? HotlinePorts.DefaultServerPort
+
+ self.login = url.user(percentEncoded: false) ?? ""
+ self.password = url.password(percentEncoded: false) ?? ""
}
static func parseServerAddressAndPort(_ address: String) -> (String, Int) {
let url = URL(string: "hotline://\(address)")
let port = url?.port ?? HotlinePorts.DefaultServerPort
- let host = url?.host(percentEncoded: false) ?? address
- return (host, port)
+ let host = url?.host(percentEncoded: false) ?? ""
+ return (host.lowercased().trimmingCharacters(in: .whitespacesAndNewlines), port)
}
}
@@ -29,7 +54,7 @@ extension Server: Identifiable {
extension Server: Equatable {
static func == (lhs: Server, rhs: Server) -> Bool {
- return (lhs.address == rhs.address) && (lhs.port == rhs.port)
+ return (lhs.address == rhs.address) && (lhs.port == rhs.port) && (lhs.login == rhs.login) && (lhs.password == rhs.password)
}
}
diff --git a/Hotline/iOS/FilesView.swift b/Hotline/iOS/FilesView.swift
index 5815ca2..017a469 100644
--- a/Hotline/iOS/FilesView.swift
+++ b/Hotline/iOS/FilesView.swift
@@ -31,6 +31,11 @@ struct FileView: View {
return
}
+ // Some servers don't reply when asking for the contents of an empty folder.
+ if file.isFolder && file.fileSize == 0 {
+ return
+ }
+
Task {
await model.getFileList(path: file.path)
}
diff --git a/Hotline/iOS/TrackerView.swift b/Hotline/iOS/TrackerView.swift
index 2c0fac4..8359468 100644
--- a/Hotline/iOS/TrackerView.swift
+++ b/Hotline/iOS/TrackerView.swift
@@ -86,9 +86,9 @@ struct TrackerConnectView: View {
)
.cornerRadius(10.0)
Button {
- let s = Server(name: nil, description: nil, address: address, port: HotlinePorts.DefaultServerPort, users: 0)
+ let s = Server(name: nil, description: nil, address: address, port: HotlinePorts.DefaultServerPort, users: 0, login: login, password: password)
server = s
- self.model.login(server: s, login: login, password: password, username: "bolt", iconID: 128) { success in
+ self.model.login(server: s, username: "bolt", iconID: 128) { success in
if !success {
print("FAILED LOGIN??")
}
@@ -392,7 +392,7 @@ struct TrackerView: View {
self.model.disconnect()
let tempServer = Server(name: nil, description: nil, address: address, port: port, users: 0)
- self.model.login(server: tempServer, login: login, password: password, username: "bolt", iconID: 128) { success in
+ self.model.login(server: tempServer, username: "bolt", iconID: 128) { success in
if !success {
print("FAILED LOGIN??")
}
diff --git a/Hotline/macOS/FileImageView.swift b/Hotline/macOS/FileImageView.swift
deleted file mode 100644
index 99fd7b5..0000000
--- a/Hotline/macOS/FileImageView.swift
+++ /dev/null
@@ -1,49 +0,0 @@
-import SwiftUI
-
-enum FileImageViewStatus {
- case notloaded
- case loading
- case loaded
-}
-
-enum FileImageType {
- case banner
- case file
-}
-
-struct FileImageView: View {
- @Environment(Hotline.self) private var model: Hotline
- @State var status: FileImageViewStatus = .notloaded
-
- @MainActor func startLoading() {
- if self.status != .notloaded {
- return
- }
-
- self.status = .loading
- self.model.downloadBanner()
- }
-
- var body: some View {
- HStack(spacing: 0) {
- if let img = self.model.bannerImage {
- #if os(macOS)
- Image(nsImage: img)
- .resizable()
- .scaledToFit()
- #elseif os(iOS)
- Image(uiImage: img)
- .resizable()
- .scaledToFit()
- #endif
- }
- }
- .task {
- self.startLoading()
- }
- }
-}
-
-#Preview {
- FileImageView()
-}
diff --git a/Hotline/macOS/FilePreviewView.swift b/Hotline/macOS/FilePreviewView.swift
new file mode 100644
index 0000000..d170dd7
--- /dev/null
+++ b/Hotline/macOS/FilePreviewView.swift
@@ -0,0 +1,333 @@
+import SwiftUI
+
+class FilePreviewWindowController: NSWindowController, NSWindowDelegate {
+ init(info: PreviewFileInfo) {
+ let window = PreviewWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 480, height: 300),
+ styleMask: [.titled, .unifiedTitleAndToolbar, .closable, .miniaturizable, .resizable, .fullSizeContentView],
+ backing: .buffered, defer: false)
+
+ super.init(window: window)
+
+ window.toolbarStyle = .unified
+ window.delegate = self
+ window.title = info.name
+ window.acceptsMouseMovedEvents = true
+ window.appearance = NSAppearance(named: .darkAqua)
+ window.collectionBehavior = .fullScreenAuxiliary
+
+ window.standardWindowButton(.closeButton)?.isHidden = false
+ window.standardWindowButton(.miniaturizeButton)?.isHidden = false
+ window.standardWindowButton(.zoomButton)?.isHidden = false
+
+ window.center()
+
+ let rootView = PreviewWindowView(frame: NSRect(x: 0, y: 0, width: 480, height: 300))
+ rootView.autoresizingMask = [.width, .height]
+
+ let hostingView = NSHostingView(rootView: FilePreviewView(info: info))
+ hostingView.frame = rootView.bounds
+ hostingView.autoresizingMask = [.width, .height]
+ rootView.addSubview(hostingView)
+
+ window.contentView = rootView
+
+// let toolbar = NSToolbar()
+// toolbar.allowsUserCustomization = false
+// toolbar.displayMode = .iconOnly
+//
+// window.toolbar = toolbar
+
+ self.showWindow(nil)
+ window.makeFirstResponder(nil)
+ }
+
+ deinit {
+ print("FilePreviewWindowController: dealloc")
+ }
+
+ required init?(coder: NSCoder) {
+ return nil
+ }
+}
+
+struct FilePreviewToolbar: View {
+ var body: some View {
+// ToolbarItem(placement: .primaryAction) {
+ Button {
+
+ } label: {
+ Image(systemName: "plus")
+ }
+ .buttonStyle(.accessoryBar)
+// }
+ }
+}
+
+struct FilePreviewView: View {
+ enum FilePreviewFocus: Hashable {
+ case window
+ }
+
+ @Environment(\.controlActiveState) private var controlActiveState
+ @Environment(\.colorScheme) private var colorScheme
+
+ let info: PreviewFileInfo
+
+ @State var preview: FilePreview? = nil
+ @Namespace var mainNamespace
+ @FocusState private var focusField: FilePreviewFocus?
+
+ var body: some View {
+ Group {
+ if preview?.state != .loaded {
+ ProgressView(value: max(0.0, min(1.0, preview?.progress ?? 0.0)))
+ .focusable(false)
+ .padding()
+ .accentColor(colorScheme == .dark ? .white : .black)
+ .frame(maxWidth: 250)
+ .focusEffectDisabled()
+ }
+ else {
+ if let img = preview?.image {
+ AnimatedImageView(image: img)
+ .focusable(false)
+ .scaledToFit()
+ .focusEffectDisabled()
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ else {
+ Text("UNSUPPORTED FORMAT:")
+ }
+ }
+ }
+ .focusable()
+ .focusEffectDisabled()
+ .focused($focusField, equals: .window)
+// .prefersDefaultFocus(in: mainNamespace)
+// .focusScope(mainNamespace)
+// .focusSection()
+
+ .ignoresSafeArea()
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(.black)
+ .overlay(DraggableWindowView())
+ .task {
+ preview = FilePreview(info: info)
+ preview?.download()
+ }
+ .onAppear {
+ focusField = .window
+ }
+ .onDisappear {
+ preview?.cancel()
+ }
+ }
+}
+
+fileprivate struct AnimatedImageView: NSViewRepresentable {
+ var image: NSImage?
+
+ 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
+ }
+
+ 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) {
+ if let window = imageView.window {
+ window.aspectRatio = img.size
+
+ var windowRect = window.frame
+ let centerPoint = CGPoint(x: windowRect.midX, y: windowRect.midY)
+
+ windowRect.size = img.size
+
+ if let screen = window.screen {
+ var paddedScreenSize = screen.frame.size
+ paddedScreenSize.width *= 0.5
+ paddedScreenSize.height *= 0.5
+
+ window.minSize = aspectFit(source: windowRect.size, bounds: CGSize(width: 400, height: 400))
+ windowRect.size = aspectFit(source: windowRect.size, bounds: paddedScreenSize, minimum: window.minSize)
+ }
+
+ 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)
+ }
+ }
+ }
+
+ return imageView
+ }
+
+ func updateNSView(_ nsView: NSImageView, context: Context) {
+ nsView.image = self.image
+ }
+}
+
+fileprivate struct DraggableWindowView: NSViewRepresentable {
+ func makeNSView(context: Context) -> NSView {
+ return DraggableWindowNSView()
+ }
+
+ func updateNSView(_ nsView: NSView, context: Context) { }
+}
+
+fileprivate class DraggableWindowNSView: NSView {
+ override public func mouseDown(with event: NSEvent) {
+ window?.performDrag(with: event)
+ }
+}
+
+fileprivate class PreviewWindow: NSWindow {
+ override init(contentRect: NSRect, styleMask style: NSWindow.StyleMask, backing backingStoreType: NSWindow.BackingStoreType, defer flag: Bool) {
+ super.init(contentRect: contentRect, styleMask: style, backing: backingStoreType, defer: flag)
+ self.minSize = NSSize(width: 200, height: 200)
+ self.isReleasedWhenClosed = true
+ self.titlebarSeparatorStyle = .line
+ self.acceptsMouseMovedEvents = true
+ }
+
+ deinit {
+ print("FilePreviewWindow: dealloc")
+ }
+
+ override var canBecomeMain: Bool { return true }
+ override var canBecomeKey: Bool { return true }
+}
+
+fileprivate class PreviewWindowView: NSView {
+ private var trackingArea: NSTrackingArea? = nil
+ private var windowIsFullscreen: Bool = false
+
+ override init(frame frameRect: NSRect) {
+ super.init(frame: frameRect)
+
+ self.updateTrackingAreas()
+ }
+
+ required init?(coder: NSCoder) {
+ return nil
+ }
+
+ deinit {
+ print("FilePreviewWindowView: dealloc")
+ }
+
+ override func updateTrackingAreas() {
+ if let t = self.trackingArea {
+ self.removeTrackingArea(t)
+ self.trackingArea = nil
+ }
+
+ let options: NSTrackingArea.Options = [.mouseEnteredAndExited, .activeAlways]
+ self.trackingArea = NSTrackingArea(rect: self.bounds, options: options, owner: self, userInfo: nil)
+ self.addTrackingArea(self.trackingArea!)
+ }
+
+// override func viewDidMoveToWindow() {
+// print("VIEW MOVED TO WINDOW?")
+// if let w = self.window {
+//// w.titlebarAppearsTransparent = false
+// let toolbarButtons = NSHostingView(rootView: FilePreviewToolbar())
+// toolbarButtons.frame.size = toolbarButtons.fittingSize
+//
+// let titlebarAccessory = NSTitlebarAccessoryViewController()
+// titlebarAccessory.automaticallyAdjustsSize = true
+// titlebarAccessory.view = toolbarButtons
+// titlebarAccessory.layoutAttribute = .trailing
+// w.addTitlebarAccessoryViewController(titlebarAccessory)
+// }
+// }
+
+ override func mouseEntered(with event: NSEvent) {
+ if self.windowIsFullscreen {
+ return
+ }
+
+ if let w = self.window {
+ w.titlebarAppearsTransparent = false
+ w.titleVisibility = .visible
+ w.standardWindowButton(.closeButton)?.isHidden = false
+ w.standardWindowButton(.miniaturizeButton)?.isHidden = false
+ w.standardWindowButton(.zoomButton)?.isHidden = false
+ w.toolbar?.isVisible = true
+ }
+ }
+
+ override func mouseExited(with event: NSEvent) {
+ if self.windowIsFullscreen {
+ return
+ }
+
+ if let w = self.window {
+ w.titlebarAppearsTransparent = true
+ w.titleVisibility = .hidden
+ w.standardWindowButton(.closeButton)?.isHidden = true
+ w.standardWindowButton(.miniaturizeButton)?.isHidden = true
+ w.standardWindowButton(.zoomButton)?.isHidden = true
+ w.toolbar?.isVisible = false
+ }
+ }
+
+ func setWindowIsFullscreen(_ isFullscreen: Bool) {
+ self.windowIsFullscreen = isFullscreen
+
+ if let w = self.window, isFullscreen {
+ w.titlebarAppearsTransparent = false
+ w.titleVisibility = .visible
+ w.standardWindowButton(.closeButton)?.isHidden = false
+ w.standardWindowButton(.miniaturizeButton)?.isHidden = false
+ w.standardWindowButton(.zoomButton)?.isHidden = false
+ }
+ }
+
+ override var acceptsFirstResponder: Bool { return true }
+
+ override func keyDown(with event: NSEvent) {
+ switch event.charactersIgnoringModifiers?.first {
+ case " ":
+ self.window?.performClose(nil)
+ default:
+ break
+ }
+ }
+}
diff --git a/Hotline/macOS/FilesView.swift b/Hotline/macOS/FilesView.swift
index 09354ed..5443270 100644
--- a/Hotline/macOS/FilesView.swift
+++ b/Hotline/macOS/FilesView.swift
@@ -70,8 +70,7 @@ struct FileView: View {
.padding(.leading, CGFloat(depth * (12 + 10)))
.onChange(of: file.expanded) {
loading = false
- if file.isFolder {
- print("EXPANDED \(file.name)? \(expanded)")
+ if file.isFolder && file.fileSize > 0 {
if file.expanded {
Task {
loading = true
@@ -108,9 +107,14 @@ struct FileView: View {
struct FilesView: View {
@Environment(Hotline.self) private var model: Hotline
+ @Environment(\.openWindow) private var openWindow
@State private var selection: FileInfo?
+ private func openPreviewWindow(_ previewInfo: PreviewFileInfo) {
+ let _ = FilePreviewWindowController(info: previewInfo)
+ }
+
var body: some View {
NavigationStack {
List(model.files, id: \.self, selection: $selection) { file in
@@ -129,7 +133,6 @@ struct FilesView: View {
.contextMenu(forSelectionType: FileInfo.self) { items in
// ...
} primaryAction: { items in
- print("ITEMS?", items)
guard let clickedFile = items.first else {
return
}
@@ -139,7 +142,6 @@ struct FilesView: View {
clickedFile.expanded.toggle()
}
else {
- print("DOWNLOAD FILE", clickedFile.name, clickedFile.path)
model.downloadFile(clickedFile.name, path: clickedFile.path)
}
}
@@ -157,6 +159,17 @@ struct FilesView: View {
}
return .ignored
}
+ .onKeyPress(.space) {
+ if let s = selection, s.isImage {
+ model.previewFile(s.name, path: s.path) { info in
+ if let info = info {
+ openPreviewWindow(info)
+ }
+ }
+ return .handled
+ }
+ return .ignored
+ }
.overlay {
if !model.filesLoaded {
VStack {
@@ -167,8 +180,6 @@ struct FilesView: View {
}
}
.toolbar {
-
-
ToolbarItem(placement: .primaryAction) {
Button {
} label: {
@@ -180,15 +191,17 @@ struct FilesView: View {
ToolbarItem(placement: .primaryAction) {
Button {
if let s = selection, !s.isFolder {
- print("DOWNLOAD FILE", s.name, s.path)
- model.previewFile(s.name, path: s.path, addTransfer: true) { transfer, fileData in
- print("FILE PREVIEWED?", fileData.count)
+ model.previewFile(s.name, path: s.path) { info in
+ if let info = info {
+ openPreviewWindow(info)
+ }
}
}
} label: {
Label("Preview File", systemImage: "eye")
}
.help("Preview File")
+ .disabled(selection?.isImage == false)
}
ToolbarItem(placement: .primaryAction) {
diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift
index a7dc90d..ea0a090 100644
--- a/Hotline/macOS/ServerView.swift
+++ b/Hotline/macOS/ServerView.swift
@@ -2,8 +2,6 @@ import SwiftUI
import UniformTypeIdentifiers
enum MenuItemType {
- case banner
- case progress
case chat
case news
case messageBoard
@@ -171,8 +169,8 @@ struct ServerView: View {
@State private var connectLogin: String = ""
@State private var connectPassword: String = ""
- @Binding var server: Server?
-
+ @Binding var server: Server
+
static var menuItems = [
MenuItem(name: "Chat", image: "bubble", type: .chat),
MenuItem(name: "News", image: "newspaper", type: .news, serverVersion: 150),
@@ -196,6 +194,20 @@ struct ServerView: View {
}
.textFieldStyle(.roundedBorder)
.controlSize(.regular)
+ .onChange(of: connectAddress) {
+ let (a, p) = Server.parseServerAddressAndPort(connectAddress)
+ server.address = a
+ server.port = p
+ print("ADDRESS CHANGED: '\(connectAddress)' \(a) \(p)")
+ }
+ .onChange(of: connectLogin) {
+ server.login = connectLogin.trimmingCharacters(in: .whitespacesAndNewlines)
+ print("LOGIN CHANGED: '\(connectLogin)'" + $server.wrappedValue.login)
+ }
+ .onChange(of: connectPassword) {
+ server.password = connectPassword
+ print("PASS CHANGED: '\(connectPassword)'" + server.password)
+ }
HStack {
Button {
@@ -219,8 +231,21 @@ struct ServerView: View {
.keyboardShortcut(.cancelAction)
Button {
- let (a, p) = Server.parseServerAddressAndPort(connectAddress)
- server = Server(name: nil, description: nil, address: a, port: p, users: 0)
+
+// if var s = server {
+// print("CHANGING EXISTING SERVER")
+// s.name = newServer.name
+// s.description = newServer.description
+// s.users = newServer.users
+// s.address = newServer.address
+// s.port = newServer.port
+// s.login = newServer.login
+// s.password = newServer.password
+// }
+// else {
+// server = newServer
+// }
+
Task {
await connectToServer()
}
@@ -242,39 +267,26 @@ struct ServerView: View {
var navigationList: some View {
List(selection: $selection) {
-
- if model.status != .loggedIn {
- HStack {
- ProgressView(value: connectionStatusToProgress(status: model.status))
- .padding()
- }
- .tag(MenuItem(name: "progress", image: "", type: .progress))
- .frame(maxWidth: .infinity, minHeight: 60)
- .selectionDisabled()
- }
-
- if model.status == .loggedIn {
- ForEach(ServerView.menuItems) { menuItem in
- if let minServerVersion = menuItem.serverVersion {
- if let v = model.serverVersion, v >= minServerVersion {
- ListItemView(icon: menuItem.image, title: menuItem.name)
- .tag(menuItem)
- }
- }
- else {
+ ForEach(ServerView.menuItems) { menuItem in
+ if let minServerVersion = menuItem.serverVersion {
+ if let v = model.serverVersion, v >= minServerVersion {
ListItemView(icon: menuItem.image, title: menuItem.name)
.tag(menuItem)
}
}
-
- if model.transfers.count > 0 {
- self.transfersSection
- }
-
- if model.users.count > 0 {
- self.usersSection
+ else {
+ ListItemView(icon: menuItem.image, title: menuItem.name)
+ .tag(menuItem)
}
}
+
+ if model.transfers.count > 0 {
+ self.transfersSection
+ }
+
+ if model.users.count > 0 {
+ self.usersSection
+ }
}
}
@@ -319,10 +331,6 @@ struct ServerView: View {
} detail: {
if let selection = selection {
switch selection.type {
- case .banner:
- EmptyView()
- case .progress:
- EmptyView()
case .chat:
ChatView()
.navigationTitle(model.serverTitle)
@@ -404,18 +412,28 @@ struct ServerView: View {
model.disconnect()
}
.task {
- if server != nil {
- connectToServer()
+ var address = server.address
+ if server.port != HotlinePorts.DefaultServerPort {
+ address += ":\(server.port)"
}
+ connectAddress = server.address
+ connectLogin = server.login
+ connectPassword = server.password
+ connectToServer()
}
}
// MARK: -
- @MainActor func connectToServer(login: String = "", password: String = "") {
- model.login(server: server!, login: login, password: password, username: preferences.username, iconID: preferences.userIconID) { success in
+ @MainActor func connectToServer() {
+ guard !server.address.isEmpty else {
+ return
+ }
+
+ model.login(server: server, username: preferences.username, iconID: preferences.userIconID) { success in
if !success {
print("FAILED LOGIN??")
+ model.disconnect()
}
else {
print("GETTING USER LIST????!")
@@ -442,35 +460,35 @@ struct ServerView: View {
}
private func connectionStatusToLabel(status: HotlineClientStatus) -> String {
- if let s = self.server {
- let n = s.name ?? s.address
- switch status {
- case .disconnected:
- return "Disconnected"
- case .connecting:
- return "Connecting to \(n)..."
- case .connected:
- return "Connected to \(n)"
- case .loggingIn:
- return "Logging in to \(n)..."
- case .loggedIn:
- return "Logged in to \(n)"
- }
- }
- else {
- switch status {
- case .disconnected:
- return "Disconnected"
- case .connecting:
- return "Connecting..."
- case .connected:
- return "Connected"
- case .loggingIn:
- return "Logging in..."
- case .loggedIn:
- return "Logged in"
- }
+// if let s = self.server {
+ let n = server.name ?? server.address
+ switch status {
+ case .disconnected:
+ return "Disconnected"
+ case .connecting:
+ return "Connecting to \(n)..."
+ case .connected:
+ return "Connected to \(n)"
+ case .loggingIn:
+ return "Logging in to \(n)..."
+ case .loggedIn:
+ return "Logged in to \(n)"
}
+// }
+// else {
+// switch status {
+// case .disconnected:
+// return "Disconnected"
+// case .connecting:
+// return "Connecting..."
+// case .connected:
+// return "Connected"
+// case .loggingIn:
+// return "Logging in..."
+// case .loggedIn:
+// return "Logged in"
+// }
+// }
}
@MainActor func sendPreferences() {
diff --git a/Hotline/macOS/TrackerView.swift b/Hotline/macOS/TrackerView.swift
index 2c9c839..268cd3e 100644
--- a/Hotline/macOS/TrackerView.swift
+++ b/Hotline/macOS/TrackerView.swift
@@ -9,6 +9,7 @@ struct TrackerBookmark {
let type: TrackerBookmarkType
let name: String
let address: String
+ let port: Int = HotlinePorts.DefaultServerPort
}
@Observable
@@ -22,6 +23,27 @@ class TrackerItem: Identifiable, Hashable {
var expanded: Bool = false
var loading: Bool = false
+ var displayAddress: String? {
+ if let s = server {
+ if s.port == HotlinePorts.DefaultServerPort {
+ return s.address
+ }
+ else {
+ return "\(s.address):\(s.port)"
+ }
+ }
+ else if let b = bookmark {
+ if b.port == HotlinePorts.DefaultServerPort {
+ return b.address
+ }
+ else {
+ return "\(b.address):\(b.port)"
+ }
+ }
+
+ return nil
+ }
+
init(bookmark: TrackerBookmark) {
self.bookmark = bookmark
self.server = nil
@@ -46,6 +68,7 @@ class TrackerItem: Identifiable, Hashable {
hasher.combine(self.id)
}
+
@MainActor
func loadServers() async {
guard
@@ -283,10 +306,10 @@ struct TrackerView: View {
.alternatingRowBackgrounds(.enabled)
.contextMenu(forSelectionType: TrackerItem.self) { items in
if let item = items.first {
- if let server = item.server {
+ if let address = item.displayAddress {
Button {
NSPasteboard.general.clearContents()
- NSPasteboard.general.setString(server.address, forType: .string)
+ NSPasteboard.general.setString(address, forType: .string)
} label: {
Label("Copy Server Address", systemImage: "doc.on.doc")
}
@@ -371,7 +394,7 @@ struct TrackerView: View {
.help("Connect to Server...")
}
}
- .onAppear {
+ .task {
// Add initial items to tracker list.
var items: [TrackerItem] = []
for bookmark in self.bookmarks {
@@ -379,6 +402,11 @@ struct TrackerView: View {
}
self.servers = items
}
+ .onOpenURL(perform: { url in
+ if let s = Server(url: url) {
+ openWindow(id: "server", value: s)
+ }
+ })
}
}