diff options
| -rw-r--r-- | Hotline.xcodeproj/project.pbxproj | 12 | ||||
| -rw-r--r-- | Hotline/Application.swift | 16 | ||||
| -rw-r--r-- | Hotline/Hotline/HotlineClient.swift | 6 | ||||
| -rw-r--r-- | Hotline/Hotline/HotlineFileClient.swift | 5 | ||||
| -rw-r--r-- | Hotline/Models/FileInfo.swift | 11 | ||||
| -rw-r--r-- | Hotline/Models/FilePreview.swift | 110 | ||||
| -rw-r--r-- | Hotline/Models/Hotline.swift | 98 | ||||
| -rw-r--r-- | Hotline/Models/PreviewFileInfo.swift | 20 | ||||
| -rw-r--r-- | Hotline/Models/Server.swift | 33 | ||||
| -rw-r--r-- | Hotline/iOS/FilesView.swift | 5 | ||||
| -rw-r--r-- | Hotline/iOS/TrackerView.swift | 6 | ||||
| -rw-r--r-- | Hotline/macOS/FileImageView.swift | 49 | ||||
| -rw-r--r-- | Hotline/macOS/FilePreviewView.swift | 333 | ||||
| -rw-r--r-- | Hotline/macOS/FilesView.swift | 31 | ||||
| -rw-r--r-- | Hotline/macOS/ServerView.swift | 156 | ||||
| -rw-r--r-- | Hotline/macOS/TrackerView.swift | 34 |
16 files changed, 720 insertions, 205 deletions
diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index 099b3c8..ebcb270 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -21,7 +21,6 @@ DA4F2BF82B16A17200D8ADDC /* HotlineProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4F2BF72B16A17200D8ADDC /* HotlineProtocol.swift */; }; DA4F2C012B1A558E00D8ADDC /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4F2C002B1A558E00D8ADDC /* ChatView.swift */; platformFilter = ios; }; DA5753682B33E88A00FAC277 /* HotlineFileClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5753672B33E88A00FAC277 /* HotlineFileClient.swift */; }; - DA57536A2B34A60D00FAC277 /* FileImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5753692B34A60D00FAC277 /* FileImageView.swift */; }; DA57536C2B36BA1D00FAC277 /* TextDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA57536B2B36BA1D00FAC277 /* TextDocument.swift */; }; DA6300972B24036B0034CBFD /* HotlineClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6300962B24036B0034CBFD /* HotlineClient.swift */; }; DA77253F2B21176D006C5ABB /* NewsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA77253E2B21176D006C5ABB /* NewsView.swift */; platformFilter = ios; }; @@ -32,6 +31,8 @@ DA9CAFC42B126D5800CDA197 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DA9CAFC32B126D5800CDA197 /* Preview Assets.xcassets */; }; DA9CAFCB2B126E3300CDA197 /* HotlineTrackerClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA9CAFCA2B126E3300CDA197 /* HotlineTrackerClient.swift */; }; DAAEE66B2B3FBC2100A5BA07 /* TransferInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAAEE66A2B3FBC2100A5BA07 /* TransferInfo.swift */; }; + DAAEE66D2B475F1400A5BA07 /* PreviewFileInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAAEE66C2B475F1400A5BA07 /* PreviewFileInfo.swift */; }; + DAAEE66F2B47625600A5BA07 /* FilePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAAEE66E2B47625600A5BA07 /* FilePreviewView.swift */; platformFilters = (macos, ); }; DABFCC292B1530DC009F40D2 /* FoundationExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABFCC282B1530DC009F40D2 /* FoundationExtensions.swift */; }; DAC002192B21630900A6C290 /* SwiftUIExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC002182B21630900A6C290 /* SwiftUIExtensions.swift */; }; DADDB28B2B22B31F0024040D /* Tracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADDB28A2B22B31F0024040D /* Tracker.swift */; }; @@ -65,7 +66,6 @@ DA4F2BF72B16A17200D8ADDC /* HotlineProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineProtocol.swift; sourceTree = "<group>"; }; DA4F2C002B1A558E00D8ADDC /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = "<group>"; }; DA5753672B33E88A00FAC277 /* HotlineFileClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFileClient.swift; sourceTree = "<group>"; }; - DA5753692B34A60D00FAC277 /* FileImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileImageView.swift; sourceTree = "<group>"; }; DA57536B2B36BA1D00FAC277 /* TextDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextDocument.swift; sourceTree = "<group>"; }; DA6300962B24036B0034CBFD /* HotlineClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineClient.swift; sourceTree = "<group>"; }; DA77253E2B21176D006C5ABB /* NewsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsView.swift; sourceTree = "<group>"; }; @@ -77,6 +77,8 @@ DA9CAFC32B126D5800CDA197 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; }; DA9CAFCA2B126E3300CDA197 /* HotlineTrackerClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineTrackerClient.swift; sourceTree = "<group>"; }; DAAEE66A2B3FBC2100A5BA07 /* TransferInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransferInfo.swift; sourceTree = "<group>"; }; + DAAEE66C2B475F1400A5BA07 /* PreviewFileInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewFileInfo.swift; sourceTree = "<group>"; }; + DAAEE66E2B47625600A5BA07 /* FilePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePreviewView.swift; sourceTree = "<group>"; }; DABFCC282B1530DC009F40D2 /* FoundationExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoundationExtensions.swift; sourceTree = "<group>"; }; DAC002182B21630900A6C290 /* SwiftUIExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIExtensions.swift; sourceTree = "<group>"; }; DADDB28A2B22B31F0024040D /* Tracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tracker.swift; sourceTree = "<group>"; }; @@ -196,6 +198,7 @@ DA2863DC2B3E8B7000A7D050 /* FilePreview.swift */, DA32CD4C2B2931B50053B98B /* ChatMessage.swift */, DA32CD4E2B2931CC0053B98B /* NewsInfo.swift */, + DAAEE66C2B475F1400A5BA07 /* PreviewFileInfo.swift */, ); path = Models; sourceTree = "<group>"; @@ -210,7 +213,7 @@ DAE735022B30C0BB000C56F6 /* MessageView.swift */, DAE734FC2B2E65E9000C56F6 /* MessageBoardView.swift */, DAE735002B2E71F2000C56F6 /* FilesView.swift */, - DA5753692B34A60D00FAC277 /* FileImageView.swift */, + DAAEE66E2B47625600A5BA07 /* FilePreviewView.swift */, DA2863D72B37AD1C00A7D050 /* SettingsView.swift */, ); path = macOS; @@ -300,7 +303,6 @@ DADDB28D2B22B5920024040D /* Server.swift in Sources */, DAE734FB2B2E41F9000C56F6 /* TrackerView.swift in Sources */, DA9CAFBD2B126D5700CDA197 /* TrackerView.swift in Sources */, - DA57536A2B34A60D00FAC277 /* FileImageView.swift in Sources */, DAE735032B30C0BB000C56F6 /* MessageView.swift in Sources */, DA32CD4B2B29318E0053B98B /* FileInfo.swift in Sources */, DA9CAFCB2B126E3300CDA197 /* HotlineTrackerClient.swift in Sources */, @@ -316,6 +318,8 @@ DA77253F2B21176D006C5ABB /* NewsView.swift in Sources */, DA4F2BF82B16A17200D8ADDC /* HotlineProtocol.swift in Sources */, DA5753682B33E88A00FAC277 /* HotlineFileClient.swift in Sources */, + DAAEE66F2B47625600A5BA07 /* FilePreviewView.swift in Sources */, + DAAEE66D2B475F1400A5BA07 /* PreviewFileInfo.swift in Sources */, DA0D698D2B1E7CF700C71DF5 /* UsersView.swift in Sources */, DAE735052B3218D8000C56F6 /* NewsView.swift in Sources */, DAE734F92B2E4185000C56F6 /* ServerView.swift in Sources */, 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) + } + }) } } |