aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Hotline.xcodeproj/project.pbxproj18
-rw-r--r--Hotline/Application.swift8
-rw-r--r--Hotline/Hotline/HotlineClient.swift54
-rw-r--r--Hotline/Hotline/HotlineFileClient.swift600
-rw-r--r--Hotline/Hotline/HotlineProtocol.swift33
-rw-r--r--Hotline/Models/FilePreview.swift41
-rw-r--r--Hotline/Models/Hotline.swift259
-rw-r--r--Hotline/Models/Server.swift32
-rw-r--r--Hotline/Models/TransferInfo.swift33
-rw-r--r--Hotline/Models/User.swift5
-rw-r--r--Hotline/Utility/FoundationExtensions.swift50
-rw-r--r--Hotline/iOS/ChatView.swift1
-rw-r--r--Hotline/macOS/ChatView.swift1
-rw-r--r--Hotline/macOS/FilesView.swift14
-rw-r--r--Hotline/macOS/MessageView.swift1
-rw-r--r--Hotline/macOS/ServerView.swift301
-rw-r--r--Hotline/macOS/TrackerView.swift4
17 files changed, 1188 insertions, 267 deletions
diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj
index 5d0220e..0f85b66 100644
--- a/Hotline.xcodeproj/project.pbxproj
+++ b/Hotline.xcodeproj/project.pbxproj
@@ -12,6 +12,7 @@
DA0D69912B1E894800C71DF5 /* FilesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0D69902B1E894800C71DF5 /* FilesView.swift */; platformFilter = ios; };
DA2863D82B37AD1C00A7D050 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2863D72B37AD1C00A7D050 /* SettingsView.swift */; };
DA2863DA2B37BF6E00A7D050 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2863D92B37BF6E00A7D050 /* Preferences.swift */; };
+ DA2863DD2B3E8B7000A7D050 /* FilePreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2863DC2B3E8B7000A7D050 /* FilePreview.swift */; };
DA32CD492B2931640053B98B /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32CD482B2931640053B98B /* User.swift */; };
DA32CD4B2B29318E0053B98B /* FileInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32CD4A2B29318E0053B98B /* FileInfo.swift */; };
DA32CD4D2B2931B50053B98B /* ChatMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32CD4C2B2931B50053B98B /* ChatMessage.swift */; };
@@ -30,6 +31,7 @@
DA9CAFC12B126D5800CDA197 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DA9CAFC02B126D5800CDA197 /* Assets.xcassets */; };
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 */; };
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 */; };
@@ -53,6 +55,7 @@
DA2863D72B37AD1C00A7D050 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
DA2863D92B37BF6E00A7D050 /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = "<group>"; };
DA2863DB2B37DBE000A7D050 /* Hotline.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Hotline.entitlements; sourceTree = "<group>"; };
+ DA2863DC2B3E8B7000A7D050 /* FilePreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePreview.swift; sourceTree = "<group>"; };
DA32CD472B28EE640053B98B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
DA32CD482B2931640053B98B /* User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = "<group>"; };
DA32CD4A2B29318E0053B98B /* FileInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileInfo.swift; sourceTree = "<group>"; };
@@ -73,6 +76,7 @@
DA9CAFC02B126D5800CDA197 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
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>"; };
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>"; };
@@ -188,6 +192,8 @@
DADDB28C2B22B5920024040D /* Server.swift */,
DA32CD482B2931640053B98B /* User.swift */,
DA32CD4A2B29318E0053B98B /* FileInfo.swift */,
+ DAAEE66A2B3FBC2100A5BA07 /* TransferInfo.swift */,
+ DA2863DC2B3E8B7000A7D050 /* FilePreview.swift */,
DA32CD4C2B2931B50053B98B /* ChatMessage.swift */,
DA32CD4E2B2931CC0053B98B /* NewsInfo.swift */,
);
@@ -285,6 +291,7 @@
DA43205E2B1D615600FC8843 /* ServerView.swift in Sources */,
DA0D69912B1E894800C71DF5 /* FilesView.swift in Sources */,
DAE735092B329810000C56F6 /* VisualEffectView.swift in Sources */,
+ DA2863DD2B3E8B7000A7D050 /* FilePreview.swift in Sources */,
DA2863D82B37AD1C00A7D050 /* SettingsView.swift in Sources */,
DA7725412B21435B006C5ABB /* ObservableScrollView.swift in Sources */,
DAE734FF2B2E6750000C56F6 /* ChatView.swift in Sources */,
@@ -300,6 +307,7 @@
DA2863DA2B37BF6E00A7D050 /* Preferences.swift in Sources */,
DA32CD4F2B2931CC0053B98B /* NewsInfo.swift in Sources */,
DA9CAFBB2B126D5700CDA197 /* Application.swift in Sources */,
+ DAAEE66B2B3FBC2100A5BA07 /* TransferInfo.swift in Sources */,
DA0D698F2B1E841600C71DF5 /* MessageBoardView.swift in Sources */,
DAE735072B3251B3000C56F6 /* Utilities.swift in Sources */,
DAE735012B2E71F2000C56F6 /* FilesView.swift in Sources */,
@@ -448,18 +456,21 @@
CODE_SIGN_ENTITLEMENTS = Hotline/Hotline.entitlements;
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 2;
+ CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_ASSET_PATHS = "\"Hotline/Preview Content\"";
DEVELOPMENT_TEAM = 5AEEV7QB2U;
"ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Hotline/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = Hotline;
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ IPHONEOS_DEPLOYMENT_TARGET = 17.2;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -484,18 +495,21 @@
CODE_SIGN_ENTITLEMENTS = Hotline/Hotline.entitlements;
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 2;
+ CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_ASSET_PATHS = "\"Hotline/Preview Content\"";
DEVELOPMENT_TEAM = 5AEEV7QB2U;
"ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Hotline/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = Hotline;
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ IPHONEOS_DEPLOYMENT_TARGET = 17.2;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
diff --git a/Hotline/Application.swift b/Hotline/Application.swift
index 83751c3..3c5a5a9 100644
--- a/Hotline/Application.swift
+++ b/Hotline/Application.swift
@@ -55,6 +55,14 @@ struct Application: App {
}
.defaultSize(width: 700, height: 800)
.defaultPosition(.center)
+ .commandsRemoved()
+ .commands {
+ CommandGroup(before: CommandGroupPlacement.newItem) {
+ Button("before item") {
+ print("before item")
+ }
+ }
+ }
#if os(macOS)
Settings {
diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift
index 6eb6f42..6193025 100644
--- a/Hotline/Hotline/HotlineClient.swift
+++ b/Hotline/Hotline/HotlineClient.swift
@@ -388,7 +388,7 @@ class HotlineClient {
}
let protocolID = data.readUInt32(at: 0)!
- if protocolID != 0x54525450 { // 'TRTP'
+ if protocolID != "TRTP".fourCharCode() {
print("HotlineClient: invalid handshake protocol ID \(protocolID)")
self.disconnect()
callback?(false)
@@ -645,12 +645,54 @@ class HotlineClient {
})
}
- func sendDownloadBanner(sent: ((Bool) -> Void)? = nil, reply: ((UInt32?, Int?) -> Void)? = nil) {
+ func sendDownloadFile(name fileName: String, path filePath: [String], preview: Bool = false, sent: ((Bool) -> Void)? = nil, reply: ((Bool, UInt32?, Int?, Int?, Int?) -> Void)? = nil) {
+ var t = HotlineTransaction(type: .downloadFile)
+ t.setFieldString(type: .fileName, val: fileName)
+ t.setFieldPath(type: .filePath, val: filePath)
+ if preview {
+ t.setFieldUInt32(type: .fileTransferOptions, val: 2)
+ }
+
+ print("DOWNLOAD \(fileName) AT PATH \(filePath)")
+
+ self.sendTransaction(t, sent: sent, reply: { r, err in
+ if err != nil {
+ DispatchQueue.main.async {
+ reply?(false, nil, nil, nil, nil)
+ }
+ return
+ }
+
+ if let transferSizeField = r.getField(type: .transferSize),
+ let transferSize = transferSizeField.getInteger(),
+ let transferReferenceField = r.getField(type: .referenceNumber),
+ let referenceNumber = transferReferenceField.getUInt32(),
+ let transferFileSizeField = r.getField(type: .fileSize),
+ let transferFileSize = transferFileSizeField.getInteger() {
+
+ let transferWaitingCountField = r.getField(type: .waitingCount)
+ let transferWaitingCount = transferWaitingCountField?.getInteger()
+
+ DispatchQueue.main.async {
+ reply?(true, referenceNumber, transferSize, transferFileSize, transferWaitingCount)
+ }
+ }
+ else {
+ DispatchQueue.main.async {
+ reply?(false, nil, nil, nil, nil)
+ }
+ }
+ })
+ }
+
+ func sendDownloadBanner(sent: ((Bool) -> Void)? = nil, reply: ((Bool, UInt32?, Int?) -> Void)? = nil) {
let t = HotlineTransaction(type: .downloadBanner)
self.sendTransaction(t, sent: sent, reply: { r, err in
if err != nil {
- reply?(nil, nil)
+ DispatchQueue.main.async {
+ reply?(false, nil, nil)
+ }
return
}
@@ -659,15 +701,13 @@ class HotlineClient {
let transferReferenceField = r.getField(type: .referenceNumber),
let referenceNumber = transferReferenceField.getUInt32() {
- print("TRANSFER SIZE", transferSize)
-
DispatchQueue.main.async {
- reply?(referenceNumber, transferSize)
+ reply?(true, referenceNumber, transferSize)
}
}
else {
DispatchQueue.main.async {
- reply?(nil, nil)
+ reply?(false, nil, nil)
}
}
})
diff --git a/Hotline/Hotline/HotlineFileClient.swift b/Hotline/Hotline/HotlineFileClient.swift
index 25323f3..f573809 100644
--- a/Hotline/Hotline/HotlineFileClient.swift
+++ b/Hotline/Hotline/HotlineFileClient.swift
@@ -1,62 +1,262 @@
import Foundation
import Network
-enum HotlineFileTransferType {
- case banner
- case file
+enum HotlineFileClientError: Error {
+ case failedToConnect
+ case failedToDownload
}
-enum HotlineFileStatus: Int {
- case disconnected
+enum HotlineFileClientStatus: Equatable {
+ case unconnected
case connecting
case connected
- case downloading
- case complete
+ case progress(Double)
+ case completed
+ case failed(HotlineFileClientError)
}
-@Observable
-class HotlineFileClient {
- var status: HotlineFileStatus = .disconnected
- var progress: Double = -1.0
+protocol HotlineFileClientDelegate: AnyObject {
+ func hotlineFileStatusChanged(client: HotlineFileClient, reference: UInt32, status: HotlineFileClientStatus, timeRemaining: TimeInterval)
+ func hotlineFileReceivedInfo(client: HotlineFileClient, reference: UInt32, info: HotlineFileInfoFork)
+ func hotlineFileDownloadedData(client: HotlineFileClient, reference: UInt32, data: Data)
+ func hotlineFileDownloadedFile(client: HotlineFileClient, reference: UInt32, at: URL)
+}
+
+extension HotlineFileClientDelegate {
+ func hotlineFileStatusChanged(client: HotlineFileClient, reference: UInt32, status: HotlineFileClientStatus, timeRemaining: TimeInterval) {}
+ func hotlineFileReceivedInfo(client: HotlineFileClient, reference: UInt32, info: HotlineFileInfoFork) {}
+ func hotlineFileDownloadedData(client: HotlineFileClient, reference: UInt32, data: Data) {}
+ func hotlineFileDownloadedFile(client: HotlineFileClient, reference: UInt32, at: URL) {}
+}
+
+enum HotlineFileTransferType {
+ case file
+ case preview
+}
+
+enum HotlineFileTransferStage: Int {
+ case fileHeader = 1
+ case fileForkHeader = 2
+ case fileInfoFork = 3
+ case fileDataFork = 4
+ case fileUnsupportedFork = 5
+}
+
+struct HotlineFileHeader {
+ static let DataSize: Int = 4 + 2 + 16 + 2
+
+ let format: UInt32
+ let version: UInt16
+ let forkCount: UInt16
+
+ init?(from data: Data) {
+ guard data.count >= HotlineFileHeader.DataSize else {
+ return nil
+ }
+
+ self.format = data.readUInt32(at: 0)!
+ self.version = data.readUInt16(at: 4)!
+ // 16 bytes of reserved data sits here. Skip it.
+ // self.reserved = data.readData(at: 4 + 2, length: 16)
+ self.forkCount = data.readUInt16(at: 4 + 2 + 16)!
+ }
+}
+
+struct HotlineFileForkHeader {
+ static let DataSize: Int = 4 + 4 + 4 + 4
+
+ let forkType: UInt32
+ let compressionType: UInt32
+ let dataSize: UInt32
+
+ init?(from data: Data) {
+ guard data.count >= HotlineFileForkHeader.DataSize else {
+ return nil
+ }
+
+ self.forkType = data.readUInt32(at: 0)!
+ self.compressionType = data.readUInt32(at: 4)!
+ // 4 bytes of reserved data sits here. Skip it.
+ // self.reserved = data.readUInt32(at: 4 + 4)!
+ self.dataSize = data.readUInt32(at: 4 + 4 + 4)!
+ }
+}
+
+struct HotlineFileInfoFork {
+ static let BaseDataSize: Int = 4 + 4 + 4 + 4 + 4 + 32 + 8 + 8 + 2 + 2
+ let platform: UInt32
+ let type: UInt32
+ let creator: UInt32
+ let flags: UInt32
+ let platformFlags: UInt32
+ let createdDate: Date
+ let modifiedDate: Date
+ let nameScript: UInt16
+ let name: String
+ let comment: String?
+ var headerSize: Int
+
+ init?(from data: Data) {
+ // Make sure we have at least enough data to read basic header data
+ guard data.count >= HotlineFileInfoFork.BaseDataSize else {
+ return nil
+ }
+
+ if
+ let platform = data.readUInt32(at: 0),
+ let type = data.readUInt32(at: 4),
+ let creator = data.readUInt32(at: 4 + 4),
+ let flags = data.readUInt32(at: 4 + 4 + 4),
+ let platformFlags = data.readUInt32(at: 4 + 4 + 4 + 4),
+ // 32 bytes of reserved data sits here. Skip it.
+ let nameScript = data.readUInt16(at: 4 + 4 + 4 + 4 + 4 + 32 + 8 + 8) {
+
+ let createdDate = data.readDate(at: 4 + 4 + 4 + 4 + 4 + 32) ?? Date.now
+ let modifiedDate = data.readDate(at: 4 + 4 + 4 + 4 + 4 + 32 + 8) ?? Date.now
+
+ let (n, nl) = data.readLongPString(at: 4 + 4 + 4 + 4 + 4 + 32 + 8 + 8 + 2)
+ if let name = n {
+ self.platform = platform
+ self.type = type
+ self.creator = creator
+ self.flags = flags
+ self.platformFlags = platformFlags
+ self.createdDate = createdDate
+ self.modifiedDate = modifiedDate
+ self.nameScript = nameScript
+ self.name = name
+
+ var calculatedHeaderSize: Int = HotlineFileInfoFork.BaseDataSize + nl
+ var commentRead: String? = nil
+ if data.count >= HotlineFileInfoFork.BaseDataSize + nl + 2 {
+ let commentLength = data.readUInt16(at: HotlineFileInfoFork.BaseDataSize + nl)!
+ var commentCorrupted = false
+
+ // Some servers have incorrect data length for the INFO fork
+ // the length they send is what it should be but don't include
+ // the comment length in the actual data, so we end up with mismatched
+ // lengths. So here we test if the length we read is actually 'DA'
+ // or the first part of the "DATA" fork header.
+ // Needless to say, stuff like this makes for sad code but this ain't so bad.
+ if commentLength == 0x4441 {
+ commentCorrupted = true
+ }
+
+ if !commentCorrupted {
+ let (c, cl) = data.readLongPString(at: HotlineFileInfoFork.BaseDataSize + nl)
+ calculatedHeaderSize += 2
+ if cl > 0 {
+ calculatedHeaderSize += Int(cl)
+ if let ct = c, cl > 0 {
+ commentRead = ct
+ }
+ }
+ }
+ }
+
+ self.comment = commentRead
+ self.headerSize = calculatedHeaderSize
+ return
+ }
+ }
+
+ return nil
+ }
+}
+
+class HotlineFileClient {
let serverAddress: NWEndpoint.Host
let serverPort: NWEndpoint.Port
let referenceNumber: UInt32
let referenceDataSize: UInt32
let transferType: HotlineFileTransferType
+ weak var delegate: HotlineFileClientDelegate? = nil
+
+ var status: HotlineFileClientStatus = .unconnected {
+ didSet {
+ DispatchQueue.main.async {
+ self.delegate?.hotlineFileStatusChanged(client: self, reference: self.referenceNumber, status: self.status, timeRemaining: self.fileProgress?.estimatedTimeRemaining ?? 0.0)
+ }
+ }
+ }
+
private var connection: NWConnection?
+ private var transferStage: HotlineFileTransferStage = .fileHeader
+
private var fileBytes = Data()
+ private var fileHeader: HotlineFileHeader? = nil
+ private var fileCurrentForkHeader: HotlineFileForkHeader? = nil
+ private var fileCurrentForkBytesLeft: Int = 0
+ private var fileInfoFork: HotlineFileInfoFork? = nil
+ private var fileHandle: FileHandle? = nil
+ private var filePath: String? = nil
+ private var fileBytesDownloaded: Int = 0
+ private var fileProgress: Progress? = nil
+
+// private var previewCallback: ((Data?) -> Void)? = nil
+// private var fileCallback: ((URL?) -> Void)? = nil
+
init(address: String, port: UInt16, reference: UInt32, size: UInt32, type: HotlineFileTransferType = .file) {
self.serverAddress = NWEndpoint.Host(address)
self.serverPort = NWEndpoint.Port(rawValue: port + 1)!
self.referenceNumber = reference
self.referenceDataSize = size
- self.status = .disconnected
self.transferType = type
-
- if size == 0 {
- self.progress = -1.0
- }
- else {
- self.progress = 0.0
+ self.transferStage = .fileHeader
+ }
+
+ deinit {
+ self.invalidate()
+ }
+
+ func downloadToMemory() {
+ guard self.status == .unconnected else {
+ return
}
+
+ self.connect()
}
- func downloadToMemory(_ callback: ((Data?) -> Void)?) {
- self.reset()
- self.connect { data in
- callback?(data)
+ func downloadToFile() {
+ guard self.status == .unconnected else {
+ return
}
+
+ self.connect()
}
- private func reset() {
- self.progress = 0.0
- self.fileBytes = Data()
+ func cancel(deleteIncompleteFile: Bool = true) {
+ self.delegate = nil
+
+ if self.status == .unconnected {
+ return
+ }
+
+ // Close file before we try to potentionally delete it.
+ if let fh = self.fileHandle {
+ try? fh.close()
+ self.fileHandle = nil
+ }
+
+ if deleteIncompleteFile, let downloadPath = self.filePath {
+ print("HotlineFileClient: Deleting file fragment at", downloadPath)
+ if FileManager.default.isDeletableFile(atPath: downloadPath) {
+ try? FileManager.default.removeItem(atPath: downloadPath)
+ }
+ self.filePath = nil
+ }
+
+ self.invalidate()
+
+ print("HotlineFileClient: Cancelled transfer")
}
- private func connect(_ callback: ((Data?) -> Void)? = nil) {
+ // MARK: -
+
+ private func connect() {
self.connection = NWConnection(host: self.serverAddress, port: self.serverPort, using: .tcp)
self.connection?.stateUpdateHandler = { [weak self] (newState: NWConnection.State) in
switch newState {
@@ -64,19 +264,20 @@ class HotlineFileClient {
self?.status = .connected
self?.sendMagic()
case .cancelled:
- self?.status = .disconnected
- DispatchQueue.main.async {
- callback?(self?.fileBytes)
- }
+ self?.invalidate()
case .failed(let err):
print("HotlineTrackerClient: Connection error \(err)")
- if self?.status == .complete {
- DispatchQueue.main.async {
- callback?(self?.fileBytes)
- }
- }
- else {
- self?.status = .disconnected
+ switch self?.status {
+ case .connecting:
+ print("FAILED TO CONNECT")
+ self?.invalidate()
+ self?.status = .failed(.failedToConnect)
+ case .connected, .progress(_):
+ print("FAILED TO DOWNLLOAD")
+ self?.invalidate()
+ self?.status = .failed(.failedToDownload)
+ default:
+ break
}
default:
return
@@ -87,89 +288,322 @@ class HotlineFileClient {
self.connection?.start(queue: .global())
}
- func disconnect() {
- self.status = .disconnected
- self.connection?.cancel()
- self.connection = nil
+ func invalidate() {
+ if let c = self.connection {
+ c.stateUpdateHandler = nil
+ c.cancel()
+
+ self.connection = nil
+ }
+
+ self.fileBytes = Data()
+
+ if let fh = self.fileHandle {
+ try? fh.close()
+ self.fileHandle = nil
+ }
}
private func sendMagic() {
- guard let c = connection else {
+ guard let c = connection, self.status == .connected else {
+ self.invalidate()
print("HotlineFileClient: invalid connection to send header.")
return
}
var headerData = Data()
- headerData.appendUInt32(0x48545846) // 'HTXF'
+ headerData.appendUInt32("HTXF".fourCharCode())
headerData.appendUInt32(self.referenceNumber)
- headerData.appendUInt32(self.referenceDataSize)
- headerData.appendUInt16(2) // Type
- headerData.appendUInt16(0) // Reserved?
+ headerData.appendUInt32(0)
+ headerData.appendUInt32(0)
c.send(content: headerData, completion: .contentProcessed { [weak self] (error) in
- guard let transferType = self?.transferType else {
- self?.disconnect()
+ guard let self = self else {
return
}
- if let err = error {
- print("HotlineFileClient: sending header failed \(err)")
- self?.disconnect()
+ guard error == nil else {
+ self.status = .failed(.failedToConnect)
+ self.invalidate()
return
}
- switch transferType {
- case .banner:
- self?.receiveBannerFile()
+ self.status = .progress(0.0)
+
+ switch self.transferType {
case .file:
- break
+ self.receiveFile()
+ case .preview:
+ self.receivePreviewData()
}
})
}
- private func receiveBannerFile() {
- guard let c = connection else {
+ private func receiveFile() {
+ guard let c = self.connection else {
return
}
- self.status = .downloading
-
- let finalSize = Double(self.referenceDataSize)
- var maxLength: Int = 65536
-
- if self.referenceDataSize != 0 {
- maxLength = max(Int(self.referenceDataSize) - self.fileBytes.count, 0)
+ c.receive(minimumIncompleteLength: 1024, maximumLength: Int(UInt16.max)) { [weak self] (data, context, isComplete, error) in
+ guard let self = self else {
+ return
+ }
+
+ guard error == nil else {
+ self.status = .failed(.failedToDownload)
+ self.invalidate()
+ return
+ }
+
+ 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))
+ }
+
+ // See if we need header data still.
+ var keepProcessing = false
+ repeat {
+ keepProcessing = false
+
+ switch self.transferStage {
+ case .fileHeader:
+ if let header = HotlineFileHeader(from: self.fileBytes) {
+ self.fileBytes.removeSubrange(0..<HotlineFileHeader.DataSize)
+
+ self.fileHeader = header
+ print("FILE HEADER", self.fileHeader)
+
+ self.transferStage = .fileForkHeader
+ keepProcessing = true
+ }
+ case .fileForkHeader:
+ if let forkHeader = HotlineFileForkHeader(from: self.fileBytes) {
+// let fileForkHeader = HotlineFileForkHeader(from: self.fileBytes)
+ self.fileBytes.removeSubrange(0..<HotlineFileForkHeader.DataSize)
+ self.fileCurrentForkHeader = forkHeader
+ self.fileCurrentForkBytesLeft = Int(forkHeader.dataSize)
+
+ print("FILE FORK HEADER", forkHeader, forkHeader.forkType.fourCharCode())
+ if forkHeader.forkType == "INFO".fourCharCode() {
+ print("INFO FORK!")
+ self.transferStage = .fileInfoFork
+ }
+ else if forkHeader.forkType == "DATA".fourCharCode() {
+ print("DATA FORK!")
+ self.transferStage = .fileDataFork
+ }
+ else if forkHeader.forkType == "MACR".fourCharCode() {
+ print("RESOURCE FORK!")
+ self.transferStage = .fileUnsupportedFork
+ }
+ else {
+ print("UNKNOWN FORK??")
+ self.transferStage = .fileUnsupportedFork
+ }
+
+ keepProcessing = true
+ }
+ case .fileInfoFork:
+ if let infoForkDataSize = self.fileCurrentForkHeader?.dataSize, self.fileBytes.count >= infoForkDataSize {
+ let infoForkData = self.fileBytes.subdata(in: 0..<Int(infoForkDataSize))
+ if let infoFork = HotlineFileInfoFork(from: infoForkData) {
+ self.fileInfoFork = infoFork
+
+ self.fileBytes.removeSubrange(0..<infoFork.headerSize)
+
+ self.transferStage = .fileForkHeader
+ self.fileCurrentForkBytesLeft -= Int(infoForkDataSize)
+ self.fileCurrentForkHeader = nil
+
+ print("INFO FORK STUFF:", infoFork)
+
+ if !self.prepareDownloadFile(name: infoFork.name) {
+ print("FAILED TO CREATE FILE ON DISK")
+ }
+
+ let reference = self.referenceNumber
+ DispatchQueue.main.async {
+ self.delegate?.hotlineFileReceivedInfo(client: self, reference: reference, info: infoFork)
+ }
+ }
+
+ keepProcessing = true
+ }
+ case .fileDataFork:
+ if self.fileBytes.count > 0 {
+ print("DOWNLOADING DATA FORK")
+ if let f = self.fileHandle {
+ do {
+ var dataToWrite = self.fileBytes
+
+ if dataToWrite.count >= self.fileCurrentForkBytesLeft {
+ dataToWrite = self.fileBytes.subdata(in: 0..<self.fileCurrentForkBytesLeft)
+ self.fileBytes.removeSubrange(0..<self.fileCurrentForkBytesLeft)
+
+ self.transferStage = .fileForkHeader
+ self.fileCurrentForkBytesLeft = 0
+ self.fileCurrentForkHeader = nil
+
+ keepProcessing = true
+ }
+ else {
+ self.fileCurrentForkBytesLeft -= dataToWrite.count
+ self.fileBytes = Data()
+ }
+
+ print("WRITING \(dataToWrite.count) BYTES TO DISK")
+
+ try f.write(contentsOf: dataToWrite)
+ }
+ catch {
+ print("DOWNLOAD WRITE ERROR", error)
+ }
+ }
+ }
+ case .fileUnsupportedFork:
+ if self.fileBytes.count > 0 {
+ print("SKIPPING UNSUPPORTED FORK DATA")
+ var dataToWrite = self.fileBytes
+
+ if dataToWrite.count >= self.fileCurrentForkBytesLeft {
+ dataToWrite = self.fileBytes.subdata(in: 0..<self.fileCurrentForkBytesLeft)
+ self.fileBytes.removeSubrange(0..<self.fileCurrentForkBytesLeft)
+
+ self.transferStage = .fileForkHeader
+ self.fileCurrentForkBytesLeft = 0
+ self.fileCurrentForkHeader = nil
+
+ keepProcessing = true
+ }
+ else {
+ self.fileCurrentForkBytesLeft -= dataToWrite.count
+ self.fileBytes = Data()
+ }
+ }
+ }
+ } while keepProcessing
+
+ if self.fileBytesDownloaded < Int(self.referenceDataSize) {
+ // If we still have more to download, then receive more data.
+ self.receiveFile()
+ return
+ }
+ else {
+ print("FILE COMPLETE")
+ if let h = self.fileHandle {
+ try? h.close()
+ self.fileHandle = nil
+ }
+ self.fileBytes = Data()
+
+ self.status = .completed
+ self.invalidate()
+
+ if let downloadPath = self.filePath {
+ DispatchQueue.main.sync {
+ print("POSTING DOWNLOAD FILE FINISHED", downloadPath)
+
+ var downloadURL = URL(filePath: downloadPath)
+ downloadURL.resolveSymlinksInPath()
+ print("FINAL PATH", downloadURL.path)
+
+ let reference = self.referenceNumber
+ self.delegate?.hotlineFileDownloadedFile(client: self, reference: reference, at: downloadURL)
+
+ #if os(macOS)
+ // Bounce dock icon when download completes. Weird this is the only API to do so.
+ DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path)
+ #endif
+ }
+ }
+ }
}
-
- if maxLength == 0 {
- self.status = .complete
- self.progress = 1.0
- self.disconnect()
+ }
+
+ private func receivePreviewData() {
+ guard let c = self.connection else {
return
}
-
- c.receive(minimumIncompleteLength: maxLength, maximumLength: maxLength) { [weak self] (data, context, isComplete, error) in
+
+ c.receive(minimumIncompleteLength: 1024, maximumLength: Int(UInt16.max)) { [weak self] (data, context, isComplete, error) in
guard let self = self else {
return
}
- if let fileData = data {
- if !fileData.isEmpty {
- self.fileBytes.append(fileData)
- if finalSize > 0 {
- self.progress = min(min(Double(self.fileBytes.count) / finalSize, 1.0), 0.0)
- }
- }
+ guard error == nil else {
+ self.status = .failed(.failedToDownload)
+ self.invalidate()
+ return
}
+
+ 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))
- if isComplete {
- self.status = .complete
- self.progress = 1.0
- self.disconnect()
+
+
+ print("DOWNLOAD PROGRESS", self.fileProgress.debugDescription)
+ }
+
+ if self.fileBytesDownloaded < Int(self.referenceDataSize) {
+ self.receivePreviewData()
}
else {
- self.receiveBannerFile()
+ let reference = self.referenceNumber
+ let data = self.fileBytes
+
+ self.status = .completed
+ self.invalidate()
+
+ DispatchQueue.main.sync {
+ self.delegate?.hotlineFileDownloadedData(client: self, reference: reference, data: data)
+ }
}
}
}
+
+ // MARK: - Utility
+
+ private func findUniqueFilePath(base: String, at folderURL: URL) -> 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 = folderURL.appending(component: finalName).path(percentEncoded: false)
+ while fileManager.fileExists(atPath: filePath) {
+ finalName = makeFileName()
+ filePath = folderURL.appending(component: finalName).path(percentEncoded: false)
+ counter += 1
+ }
+
+ return filePath
+ }
+
+ private func prepareDownloadFile(name: String) -> Bool {
+ let folderURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0]
+ let filePath = findUniqueFilePath(base: name, at: folderURL)
+
+ if FileManager.default.createFile(atPath: filePath, contents: nil) {
+ if let h = FileHandle(forWritingAtPath: filePath) {
+ self.filePath = filePath
+ self.fileHandle = h
+ self.fileProgress?.fileURL = URL(filePath: filePath).resolvingSymlinksInPath()
+ return true
+ }
+ }
+
+ return false
+ }
}
diff --git a/Hotline/Hotline/HotlineProtocol.swift b/Hotline/Hotline/HotlineProtocol.swift
index 8cbbeb5..4277bce 100644
--- a/Hotline/Hotline/HotlineProtocol.swift
+++ b/Hotline/Hotline/HotlineProtocol.swift
@@ -114,7 +114,8 @@ struct HotlineNewsList: Identifiable {
let articleID = data.readUInt32(at: baseIndex)!
baseIndex += 4
- let timestampData = data.readData(at: baseIndex, length: 8)!
+ let date = data.readDate(at: baseIndex)!
+// let timestampData = data.readData(at: baseIndex, length: 8)!
baseIndex += 8
let parentID = data.readUInt32(at: baseIndex)!
@@ -139,11 +140,11 @@ struct HotlineNewsList: Identifiable {
baseIndex += Int(posterLength)
// Parse date info
- let yearData = timestampData.readUInt16(at: 0)!
- let millisecondData = timestampData.readUInt16(at: 2)!
- let secondsData = timestampData.readUInt32(at: 4)!
- let articleData = convertHotlineDate(year: yearData, seconds: secondsData, milliseconds: millisecondData)
- var newArticle = HotlineNewsArticle(id: articleID, parentID: parentID, flags: flags, title: title, username: poster, date: articleData)
+// let yearData = timestampData.readUInt16(at: 0)!
+// let millisecondData = timestampData.readUInt16(at: 2)!
+// let secondsData = timestampData.readUInt32(at: 4)!
+// let articleData = convertHotlineDate(year: yearData, seconds: secondsData, milliseconds: millisecondData)
+ var newArticle = HotlineNewsArticle(id: articleID, parentID: parentID, flags: flags, title: title, username: poster, date: date)
print("ARTICLE ID: \(articleID)")
print("PARENT ID: \(parentID)")
@@ -269,8 +270,8 @@ class HotlineFile: Identifiable, Hashable {
let typeRaw = data.readUInt32(at: 0)!
let creatorRaw = data.readUInt32(at: 4)!
- self.type = typeRaw.toStringLiteral()
- self.creator = creatorRaw.toStringLiteral()
+ self.type = typeRaw.fourCharCode()
+ self.creator = creatorRaw.fourCharCode()
self.fileSize = data.readUInt32(at: 8)!
self.isFolder = (self.type == "fldr")
@@ -612,7 +613,10 @@ enum HotlineTransactionFieldType: UInt16 {
case communityBannerID = 161 // Integer
case serverName = 162 // String
case fileNameWithInfo = 200 // Data { type: 4, creator: 4, file size: 4, reserved: 4, name script: 2, name size: 2, name data: size }
+ case fileName = 201 // String
case filePath = 202 // Path
+ case fileTransferOptions = 204 // Integer
+ case fileSize = 207 // Integer
// TODO: Add file field types
case quotingMessage = 214 // String?
case automaticResponse = 215 // String
@@ -703,15 +707,14 @@ enum HotlineTransactionType: UInt16 {
// MARK: - Utilities
-private func convertHotlineDate(year: UInt16, seconds: UInt32, milliseconds: UInt16) -> Date? {
- let days = round((Double(seconds) + (Double(milliseconds) * 1000.0)) / 86400.0)
-
+func convertHotlineDate(year:UInt16, seconds: UInt32, milliseconds: UInt16) -> Date? {
var components = DateComponents()
components.timeZone = .gmt
components.year = Int(year)
components.month = 1
- components.day = 1 + Int(days)
- components.second = Int(seconds) - Int(days * 86400.0)
-
- return Calendar.current.date(from: components)
+ components.day = 1
+ components.second = 0
+
+ let baseDate = Calendar.current.date(from: components)
+ return baseDate?.advanced(by: TimeInterval(seconds))
}
diff --git a/Hotline/Models/FilePreview.swift b/Hotline/Models/FilePreview.swift
new file mode 100644
index 0000000..552ccff
--- /dev/null
+++ b/Hotline/Models/FilePreview.swift
@@ -0,0 +1,41 @@
+import SwiftUI
+
+//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)
+// }
+//}
diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift
index 6aafdfe..fcf6c00 100644
--- a/Hotline/Models/Hotline.swift
+++ b/Hotline/Models/Hotline.swift
@@ -1,6 +1,6 @@
import SwiftUI
-@Observable final class Hotline: HotlineClientDelegate {
+@Observable final class Hotline: HotlineClientDelegate, HotlineFileClientDelegate {
let trackerClient: HotlineTrackerClient
let client: HotlineClient
@@ -71,12 +71,6 @@ import SwiftUI
2561: "πŸ‡ΈπŸ‡ͺ",
]
-// @AppStorage("username") private var username: String = "guest"
-// @AppStorage("refuse private messages") private var refusePrivateMessages = false
-// @AppStorage("refuse private chat") private var refusePrivateChat = false
-// @AppStorage("enable automatic response") private var enableAutomaticResponse = false
-// @AppStorage("automatic response") private var automaticResponse = ""
-
var status: HotlineClientStatus = .disconnected
var server: Server? {
@@ -109,6 +103,7 @@ import SwiftUI
var news: [NewsInfo] = []
var newsLoaded: Bool = false
+ var transfers: [TransferInfo] = []
var downloads: [HotlineFileClient] = []
@ObservationIgnored var bannerClient: HotlineFileClient?
@@ -180,7 +175,7 @@ import SwiftUI
@MainActor func disconnect() {
self.client.disconnect()
- self.bannerClient?.disconnect()
+ self.bannerClient?.cancel()
}
@MainActor func sendAgree() {
@@ -391,18 +386,130 @@ import SwiftUI
}
}
- func downloadFile(path: [String], callback: ((Bool) -> Void)?) {
+ @MainActor func downloadFile(_ fileName: String, path: [String], complete callback: ((TransferInfo, URL) -> Void)? = nil) {
+ var fullPath: [String] = []
+ if path.count > 1 {
+ fullPath = Array(path[0..<path.count-1])
+ }
+ self.client.sendDownloadFile(name: fileName, path: fullPath, sent: { _ in
+ }, reply: { [weak self] success, downloadReferenceNumber, downloadTransferSize, downloadFileSize, downloadWaitingCount in
+ print("GOT DOWNLOAD REPLY:")
+ print("\tSUCCESS?", success)
+ print("\tTRANSFER SIZE: \(downloadTransferSize.debugDescription)")
+ print("\tFILE SIZE: \(downloadFileSize.debugDescription)")
+ print("\tREFERENCE NUM: \(downloadReferenceNumber.debugDescription)")
+ print("\tWAITING COUNT: \(downloadWaitingCount.debugDescription)")
+
+ if
+ let self = self,
+ let address = self.server?.address,
+ let port = self.server?.port,
+ let referenceNumber = downloadReferenceNumber,
+ let transferSize = downloadTransferSize {
+
+ print("DOWNLOADING TO MEMORY")
+ let fileClient = HotlineFileClient(address: address, port: UInt16(port), reference: referenceNumber, size: UInt32(transferSize), type: .file)
+ fileClient.delegate = self
+ self.downloads.append(fileClient)
+
+ let transfer = TransferInfo(id: referenceNumber, title: fileName, size: UInt(transferSize))
+ transfer.downloadCallback = callback
+ self.transfers.append(transfer)
+
+ fileClient.downloadToFile()
+ }
+ })
}
- @MainActor func downloadBanner(force: Bool = false, callback: ((Bool) -> Void)?) {
- if let b = self.bannerClient {
- b.disconnect()
+ @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()
self.bannerClient = nil
+ return
}
- if force {
- self.bannerImage = nil
+ if let i = self.transfers.firstIndex(where: { $0.id == id }) {
+ self.transfers.remove(at: i)
+ }
+
+ if let i = self.downloads.firstIndex(where: { $0.referenceNumber == id }) {
+ let fileClient = self.downloads.remove(at: i)
+ fileClient.cancel(deleteIncompleteFile: true)
+ }
+ }
+
+ @MainActor func deleteAllTransfers() {
+ self.transfers = []
+
+ let downloads = self.downloads
+ self.downloads = []
+
+ for fileClient in downloads {
+ fileClient.cancel(deleteIncompleteFile: true)
+ }
+ }
+
+ @MainActor func downloadBanner(force: Bool = false, callback: ((Bool) -> Void)?) {
+ if self.bannerClient != nil || force {
+ self.bannerClient?.delegate = nil
+ self.bannerClient?.cancel()
+ self.bannerClient = nil
+
+ if force {
+ self.bannerImage = nil
+ }
}
if self.bannerImage != nil {
@@ -416,37 +523,44 @@ import SwiftUI
callback?(false)
return
}
- }, reply: { [weak self] downloadReferenceNumber, downloadTransferSize in
+ }, reply: { [weak self] success, downloadReferenceNumber, downloadTransferSize in
+ if !success {
+ callback?(false)
+ return
+ }
+
if
let self = self,
let address = self.server?.address,
let port = self.server?.port,
let referenceNumber = downloadReferenceNumber,
let transferSize = downloadTransferSize {
- self.bannerClient = HotlineFileClient(address: address, port: UInt16(port), reference: referenceNumber, size: UInt32(transferSize), type: .banner)
- self.bannerClient?.downloadToMemory({ [weak self] data in
- if let b = self?.bannerClient {
- b.disconnect()
- self?.bannerClient = nil
- }
-
- if data != nil {
- #if os(macOS)
- self?.bannerImage = NSImage(data: data!)
- #elseif os(iOS)
- self?.bannerImage = UIImage(data: data!)
- #endif
- }
-
- callback?(data != nil)
- })
+ self.bannerClient = HotlineFileClient(address: address, port: UInt16(port), reference: referenceNumber, size: UInt32(transferSize), type: .preview)
+ self.bannerClient?.downloadToMemory()
+
+// self.bannerClient?.downloadToMemory({ [weak self] data in
+// if let b = self?.bannerClient {
+// b.disconnect()
+// self?.bannerClient = nil
+// }
+//
+// if data != nil {
+// #if os(macOS)
+// self?.bannerImage = NSImage(data: data!)
+// #elseif os(iOS)
+// self?.bannerImage = UIImage(data: data!)
+// #endif
+// }
+//
+// callback?(data != nil)
+// })
}
})
}
// MARK: - Hotline Delegate
- func hotlineStatusChanged(status: HotlineClientStatus) {
+ @MainActor func hotlineStatusChanged(status: HotlineClientStatus) {
print("Hotline: Connection status changed to: \(status)")
if status == .disconnected {
@@ -464,11 +578,12 @@ import SwiftUI
self.newsLoaded = false
self.bannerImage = nil
-
if let b = self.bannerClient {
- b.disconnect()
+ b.cancel()
self.bannerClient = nil
}
+
+ self.deleteAllTransfers()
}
self.status = status
@@ -539,6 +654,80 @@ import SwiftUI
}
+ // MARK: - Hotline File Delegate
+
+ func hotlineFileStatusChanged(client: HotlineFileClient, reference: UInt32, status: HotlineFileClientStatus, timeRemaining: TimeInterval) {
+ switch status {
+ case .unconnected:
+ break
+ case .connecting:
+ break
+ case .connected:
+ break
+ case .progress(let progress):
+ if let transfer = self.transfers.first(where: { $0.id == reference }) {
+ transfer.progress = progress
+ transfer.timeRemaining = timeRemaining
+ }
+ case .failed(_):
+ if let i = self.downloads.firstIndex(where: { $0.referenceNumber == reference }) {
+ self.downloads.remove(at: i)
+ }
+ if let transfer = self.transfers.first(where: { $0.id == reference }) {
+ transfer.failed = true
+ transfer.timeRemaining = 0.0
+ }
+ if let b = self.bannerClient, reference == b.referenceNumber {
+ b.delegate = nil
+ self.bannerClient = nil
+ }
+ case .completed:
+ if let transfer = self.transfers.first(where: { $0.id == reference }) {
+ transfer.completed = true
+ transfer.timeRemaining = 0.0
+ }
+ break
+ }
+ }
+
+ func hotlineFileReceivedInfo(client: HotlineFileClient, reference: UInt32, info: HotlineFileInfoFork) {
+ if let transfer = self.transfers.first(where: { $0.id == reference }) {
+ transfer.title = info.name
+ }
+ }
+
+ func hotlineFileDownloadedData(client: HotlineFileClient, reference: UInt32, data: Data) {
+ if let b = self.bannerClient, reference == b.referenceNumber {
+ #if os(macOS)
+ self.bannerImage = NSImage(data: data)
+ #elseif os(iOS)
+ self.bannerImage = UIImage(data: data)
+ #endif
+ }
+ else
+ if let i = self.transfers.firstIndex(where: { $0.id == reference }) {
+ let transfer = self.transfers[i]
+ transfer.previewCallback?(transfer, data)
+ self.transfers.remove(at: i)
+ }
+
+ if let i = self.downloads.firstIndex(where: { $0.referenceNumber == reference }) {
+ self.downloads.remove(at: i)
+ }
+ }
+
+ func hotlineFileDownloadedFile(client: HotlineFileClient, reference: UInt32, at: URL) {
+ if let i = self.transfers.firstIndex(where: { $0.id == reference }) {
+ let transfer = self.transfers[i]
+ transfer.fileURL = at
+ transfer.downloadCallback?(transfer, at)
+ }
+
+ if let i = self.downloads.firstIndex(where: { $0.referenceNumber == reference }) {
+ self.downloads.remove(at: i)
+ }
+ }
+
// MARK: - Utilities
func updateServerTitle() {
diff --git a/Hotline/Models/Server.swift b/Hotline/Models/Server.swift
index b39f1c4..2275036 100644
--- a/Hotline/Models/Server.swift
+++ b/Hotline/Models/Server.swift
@@ -1,29 +1,35 @@
import SwiftUI
-struct Server: Identifiable, Equatable, Hashable, Codable {
+struct Server: Codable {
static let defaultPort: Int = 5500
- let id: UUID
- let name: String?
- let description: String?
- let users: Int
- let address: String
- let port: Int
+ var name: String?
+ var description: String?
+ var users: Int
+ var address: String
+ var port: Int
init(name: String?, description: String?, address: String, port: Int, users: Int = 0) {
- self.id = UUID()
self.name = name
self.description = description
self.address = address.lowercased()
self.port = port
self.users = users
}
-
- func hash(into hasher: inout Hasher) {
- hasher.combine(self.id)
- }
-
+}
+
+extension Server: Identifiable {
+ var id: String { "\(address):\(port)" }
+}
+
+extension Server: Equatable {
static func == (lhs: Server, rhs: Server) -> Bool {
return (lhs.address == rhs.address) && (lhs.port == rhs.port)
}
}
+
+extension Server: Hashable {
+ func hash(into hasher: inout Hasher) {
+ hasher.combine(self.id)
+ }
+}
diff --git a/Hotline/Models/TransferInfo.swift b/Hotline/Models/TransferInfo.swift
new file mode 100644
index 0000000..546efd3
--- /dev/null
+++ b/Hotline/Models/TransferInfo.swift
@@ -0,0 +1,33 @@
+import SwiftUI
+
+@Observable
+class TransferInfo: Identifiable, Equatable, Hashable {
+ var id: UInt32
+
+ var title: String
+ var size: UInt
+ var progress: Double = 0.0
+ var timeRemaining: TimeInterval = 0.0
+ var completed: Bool = false
+ var failed: Bool = false
+
+ // For file based transfers (i.e. not previews)
+ var fileURL: URL? = nil
+
+ var downloadCallback: ((TransferInfo, URL) -> Void)? = nil
+ var previewCallback: ((TransferInfo, Data) -> Void)? = nil
+
+ init(id: UInt32, title: String, size: UInt) {
+ self.id = id
+ self.title = title
+ self.size = size
+ }
+
+ static func == (lhs: TransferInfo, rhs: TransferInfo) -> Bool {
+ return lhs.id == rhs.id
+ }
+
+ func hash(into hasher: inout Hasher) {
+ hasher.combine(self.id)
+ }
+}
diff --git a/Hotline/Models/User.swift b/Hotline/Models/User.swift
index 1a921e2..7fbecb0 100644
--- a/Hotline/Models/User.swift
+++ b/Hotline/Models/User.swift
@@ -8,11 +8,14 @@ struct UserStatus: OptionSet {
}
struct User: Identifiable {
- let id: UInt
+ var id: UInt
var name: String
var iconID: UInt
var status: UserStatus
+ var isAdmin: Bool { self.status.contains(.admin) }
+ var isIdle: Bool { self.status.contains(.idle) }
+
init(hotlineUser: HotlineUser) {
var status: UserStatus = UserStatus()
if hotlineUser.isIdle { status.update(with: .idle) }
diff --git a/Hotline/Utility/FoundationExtensions.swift b/Hotline/Utility/FoundationExtensions.swift
index 3797dea..3183b39 100644
--- a/Hotline/Utility/FoundationExtensions.swift
+++ b/Hotline/Utility/FoundationExtensions.swift
@@ -50,12 +50,23 @@ extension Data {
}
return withUnsafeBytes { $0.load(as: UInt64.self ) }
+ }
+
+ func readDate(at offset: Int) -> Date? {
+ guard offset >= 0, offset + 2 + 2 + 4 <= self.count else {
+ return nil
+ }
-// return 0
+ if
+ let year = self.readUInt16(at: offset),
+ let ms = self.readUInt16(at: offset + 2),
+ let secs = self.readUInt32(at: offset + 2 + 2) {
+ return convertHotlineDate(year: year, seconds: secs, milliseconds: ms)
+ }
-// return (UInt64(self[offset]) << 56) + (UInt64(self[offset + 1]) << 48) + (UInt64(self[offset + 2]) << 40) + (UInt64(self[offset + 3]) << 32) + (UInt64(self[offset + 4]) << 24) + (UInt64(self[offset + 5]) << 16) + (UInt64(self[offset + 6]) << 8) + UInt64(self[offset + 7])
+ return nil
}
-
+
func readData(at offset: Int, length: Int) -> Data? {
guard offset >= 0, offset + length <= self.count else {
return nil
@@ -96,13 +107,28 @@ extension Data {
}
func readPString(at offset: Int) -> (String?, Int) {
+ guard offset >= 0, offset + 1 <= self.count else {
+ return (nil, 0)
+ }
let len = Int(self.readUInt8(at: offset)!)
+ guard offset + 1 + len <= self.count else {
+ return (nil, 0)
+ }
return (self.readString(at: offset+1, length: len), 1 + len)
}
func readLongPString(at offset: Int) -> (String?, Int) {
+ guard offset >= 0, offset + 2 <= self.count else {
+ return (nil, 0)
+ }
let len = Int(self.readUInt16(at: offset)!)
- return (self.readString(at: offset+2, length: len), 2 + len)
+ guard len > 0 else {
+ return ("", 0)
+ }
+ guard offset + 2 + len <= self.count else {
+ return (nil, 0)
+ }
+ return (self.readString(at: offset+2, length: len), len)
}
@@ -128,14 +154,24 @@ extension Data {
}
}
-extension UInt32 {
- func toStringLiteral() -> String {
+extension String {
+ func fourCharCode() -> FourCharCode {
+ guard self.count == 4 else {
+ return 0
+ }
+
+ return self.utf16.reduce(0, {$0 << 8 + FourCharCode($1)})
+ }
+}
+
+extension FourCharCode {
+ func fourCharCode() -> String {
let bytes = [
UInt8((self >> 24) & 0xFF),
UInt8((self >> 16) & 0xFF),
UInt8((self >> 8) & 0xFF),
UInt8(self & 0xFF)
]
- return String(bytes: bytes, encoding: .utf8) ?? ""
+ return String(bytes: bytes, encoding: .ascii) ?? ""
}
}
diff --git a/Hotline/iOS/ChatView.swift b/Hotline/iOS/ChatView.swift
index e56f3f1..02fd6dc 100644
--- a/Hotline/iOS/ChatView.swift
+++ b/Hotline/iOS/ChatView.swift
@@ -86,7 +86,6 @@ struct ChatView: View {
print("SCROLLED TO BOTTOM")
}
.onAppear {
- print("SCROLLED TO BOTTOM ON APPEAR")
reader.scrollTo(bottomID, anchor: .bottom)
}
.scrollDismissesKeyboard(.interactively)
diff --git a/Hotline/macOS/ChatView.swift b/Hotline/macOS/ChatView.swift
index 618fd12..4399de6 100644
--- a/Hotline/macOS/ChatView.swift
+++ b/Hotline/macOS/ChatView.swift
@@ -110,7 +110,6 @@ struct ChatView: View {
print("SCROLLED TO BOTTOM")
}
.onAppear {
- print("SCROLLED TO BOTTOM ON APPEAR")
reader.scrollTo(bottomID, anchor: .bottom)
// focusedField = .chatInput
}
diff --git a/Hotline/macOS/FilesView.swift b/Hotline/macOS/FilesView.swift
index 771836d..09354ed 100644
--- a/Hotline/macOS/FilesView.swift
+++ b/Hotline/macOS/FilesView.swift
@@ -138,6 +138,10 @@ struct FilesView: View {
if clickedFile.isFolder {
clickedFile.expanded.toggle()
}
+ else {
+ print("DOWNLOAD FILE", clickedFile.name, clickedFile.path)
+ model.downloadFile(clickedFile.name, path: clickedFile.path)
+ }
}
.onKeyPress(.rightArrow) {
if let s = selection, s.isFolder {
@@ -175,6 +179,12 @@ 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)
+ }
+ }
} label: {
Label("Preview File", systemImage: "eye")
}
@@ -191,6 +201,10 @@ struct FilesView: View {
ToolbarItem(placement: .primaryAction) {
Button {
+ if let s = selection, !s.isFolder {
+ print("DOWNLOAD FILE", s.name, s.path)
+ model.downloadFile(s.name, path: s.path)
+ }
} label: {
Label("Download File", systemImage: "square.and.arrow.down")
}
diff --git a/Hotline/macOS/MessageView.swift b/Hotline/macOS/MessageView.swift
index 19b48f2..e880435 100644
--- a/Hotline/macOS/MessageView.swift
+++ b/Hotline/macOS/MessageView.swift
@@ -88,7 +88,6 @@ struct MessageView: View {
print("SCROLLED TO BOTTOM")
}
.onAppear {
- print("SCROLLED TO BOTTOM ON APPEAR")
reader.scrollTo(bottomID, anchor: .bottom)
}
.scrollDismissesKeyboard(.interactively)
diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift
index 337882b..7d89481 100644
--- a/Hotline/macOS/ServerView.swift
+++ b/Hotline/macOS/ServerView.swift
@@ -1,4 +1,5 @@
import SwiftUI
+import UniformTypeIdentifiers
enum MenuItemType {
case banner
@@ -51,47 +52,108 @@ struct ListItemView: View {
}
}
-struct AgreementView: View {
- @Environment(\.dismiss) var dismiss
+struct TransferItemView: View {
+ let transfer: TransferInfo
- let text: String
- let disagree: (() -> Void)?
- let agree: (() -> Void)?
+ private func fileIcon(name: String) -> Image {
+ let fileExtension = (name as NSString).pathExtension
+ return Image(nsImage: NSWorkspace.shared.icon(for: UTType(filenameExtension: fileExtension) ?? UTType.content))
+ }
+
+ @Environment(Hotline.self) private var model: Hotline
+ @State private var hovered: Bool = false
+ @State private var buttonHovered: Bool = false
+
+ private func formattedProgressHelp() -> String {
+ if self.transfer.completed {
+ return "File transfer complete"
+ }
+ else if self.transfer.failed {
+ return "File transfer failed"
+ }
+ else if self.transfer.progress > 0.0 {
+ if self.transfer.timeRemaining > 0.0 {
+ return "\(round(self.transfer.progress * 100.0))% – \(self.transfer.timeRemaining) seconds left"
+ }
+ else {
+ return "\(round(self.transfer.progress * 100.0))% complete"
+ }
+ }
+ return ""
+ }
var body: some View {
- VStack(spacing: 0) {
- ScrollView(.vertical) {
- Text(text)
- .padding()
- .font(.system(size: 13))
- .fontDesign(.monospaced)
- .textSelection(.enabled)
- .lineSpacing(3)
- .frame(maxWidth: .infinity, alignment: .leading)
+ HStack(alignment: .center) {
+ HStack(spacing: 0) {
+ Spacer()
+ fileIcon(name: transfer.title)
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 16, height: 16)
+ Spacer()
}
- .frame(maxWidth: .infinity)
- Divider()
- HStack(spacing: 8) {
- Button("Disagree", action: {
- dismiss()
- disagree?()
- })
- .controlSize(.large)
- .frame(width: 80)
- .keyboardShortcut(.cancelAction)
-
- Button("Agree", action: {
- dismiss()
- agree?()
- })
- .controlSize(.large)
- .frame(width: 80)
- .keyboardShortcut(.defaultAction)
+ .frame(width: 18)
+
+ Text(transfer.title)
+ .lineLimit(1)
+ .truncationMode(.middle)
+
+ Spacer()
+
+ if self.hovered {
+ Button {
+ model.deleteTransfer(id: transfer.id)
+ } label: {
+ if transfer.completed {
+ Image(systemName: self.buttonHovered ? "xmark.circle.fill" : "xmark.circle")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 16, height: 16)
+ .opacity(self.buttonHovered ? 1.0 : 0.5)
+ }
+ else {
+ Image(systemName: self.buttonHovered ? "trash.circle.fill" : "trash.circle")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 16, height: 16)
+ .opacity(self.buttonHovered ? 1.0 : 0.5)
+ }
+ }
+ .buttonStyle(.plain)
+ .padding(0)
+ .frame(width: 16, height: 16)
+ .help(transfer.completed || transfer.failed ? "Remove" : "Cancel Transfer")
+ .onHover { hovered in
+ self.buttonHovered = hovered
+ }
+ }
+ else if transfer.failed {
+ Image(systemName: "exclamationmark.triangle")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 16, height: 16)
+ }
+ else if transfer.completed {
+ Image(systemName: "checkmark.circle.fill")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 16, height: 16)
+ }
+ else if transfer.progress == 0.0 {
+ ProgressView()
+ .progressViewStyle(.circular)
+ .controlSize(.small)
}
- .frame(maxWidth: .infinity)
- .padding(16)
+ else {
+ ProgressView(value: transfer.progress, total: 1.0)
+ .progressViewStyle(.circular)
+ .controlSize(.small)
+ }
+ }
+ .onHover { hovered in
+ self.hovered = hovered
}
- .frame(width: 500, height: 500)
+ .help(formattedProgressHelp())
}
}
@@ -126,7 +188,6 @@ struct ServerView: View {
MenuItem(name: "News", image: "newspaper", type: .news, serverVersion: 150),
MenuItem(name: "Board", image: "note.text", type: .messageBoard),
MenuItem(name: "Files", image: "folder", type: .files),
-// MenuItem(name: "Tasks", image: "arrow.up.circle", type: .tasks),
]
@MainActor func sendPreferences() {
@@ -151,78 +212,118 @@ struct ServerView: View {
}
}
- var body: some View {
- NavigationSplitView {
- List(selection: $selection) {
-
- if model.status != .loggedIn {
- HStack {
- ProgressView(value: connectionStatusToProgress(status: model.status))
- .padding()
+ 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 {
+ ListItemView(icon: menuItem.image, title: menuItem.name)
+ .tag(menuItem)
}
- .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)
- }
+ if model.transfers.count > 0 {
+ self.transfersSection
+ }
+
+ if model.users.count > 0 {
+ self.usersSection
+ }
+ }
+ }
+ }
+
+ var transfersSection: some View {
+ Section("Transfers") {
+ ForEach(model.transfers) { transfer in
+ TransferItemView(transfer: transfer)
+ }
+ }
+ }
+
+ var usersSection: some View {
+ Section("Users") {
+ ForEach(model.users) { user in
+ HStack {
+ if let iconString = Hotline.defaultIconSet[Int(user.iconID)] {
+ Text(iconString)
+ .font(.headline)
+ .frame(width: 18)
+ .opacity(controlActiveState == .inactive ? 0.5 : 1.0)
+ }
+ else {
+ Text("")
+ .frame(width: 18)
+ }
+
+ if user.isAdmin {
+ if user.isIdle {
+ Text(user.name)
+ .foregroundStyle(.red)
+ .opacity(controlActiveState == .inactive ? 0.3 : 0.5)
}
else {
- ListItemView(icon: menuItem.image, title: menuItem.name)
- .tag(menuItem)
+ Text(user.name)
+ .foregroundStyle(.red)
+ .opacity(controlActiveState == .inactive ? 0.5 : 1.0)
}
}
-
- if model.users.count > 0 {
- Section("Users") {
- ForEach(model.users) { user in
- HStack {
- if let iconString = Hotline.defaultIconSet[Int(user.iconID)] {
- Text(iconString)
- .font(.headline)
- .frame(width: 18)
- .opacity(controlActiveState == .inactive ? 0.5 : 1.0)
- }
- else {
- Text("")
- .frame(width: 18)
- }
-
- if user.status.contains(.admin) {
- if user.status.contains(.idle) {
- Text(user.name)
- .foregroundStyle(.red)
- .opacity(controlActiveState == .inactive ? 0.3 : 0.5)
- }
- else {
- Text(user.name)
- .foregroundStyle(.red)
- .opacity(controlActiveState == .inactive ? 0.5 : 1.0)
- }
- }
- else if user.status.contains(.idle) {
- Text(user.name)
- .opacity(controlActiveState == .inactive ? 0.3 : 0.5)
- }
- else {
- Text(user.name)
+ else if user.isIdle {
+ Text(user.name)
+ .opacity(controlActiveState == .inactive ? 0.3 : 0.5)
+ }
+ else {
+ Text(user.name)
// .opacity(controlActiveState == .inactive ? 0.5 : 1.0)
- }
- Spacer()
- }
- .tag(MenuItem(name: user.name, image: "", type: .user, userID: user.id))
- }
- }
}
+ Spacer()
}
+ .tag(MenuItem(name: user.name, image: "", type: .user, userID: user.id))
}
- .frame(minWidth: 200, idealWidth: 200)
+ }
+ }
+
+ var body: some View {
+ NavigationSplitView {
+
+ self.navigationList
+ .frame(maxWidth: .infinity)
+ .navigationSplitViewColumnWidth(min: 150, ideal: 200, max: 500)
+
+// VSplitView {
+//
+// self.navigationList
+//
+// ScrollView {
+// VStack(alignment: .leading) {
+// // ForEach(model.downloads) {
+// // }
+// }
+// .frame(maxWidth: .infinity)
+// }
+//
+// }
+// .frame(maxWidth: .infinity)
+// .navigationSplitViewColumnWidth(min: 150, ideal: 200, max: 500)
+
+
} detail: {
if let selection = self.selection {
switch selection.type {
@@ -234,18 +335,22 @@ struct ServerView: View {
ChatView()
.navigationTitle(self.model.serverTitle)
.navigationSubtitle(self.model.users.count > 0 ? "^[\(self.model.users.count) user](inflect: true) online" : "")
+ .navigationSplitViewColumnWidth(min: 250, ideal: 500)
case .news:
NewsView()
.navigationTitle(self.model.serverTitle)
.navigationSubtitle(self.model.users.count > 0 ? "^[\(self.model.users.count) user](inflect: true) online" : "")
+ .navigationSplitViewColumnWidth(min: 250, ideal: 500)
case .messageBoard:
MessageBoardView()
.navigationTitle(self.model.serverTitle)
.navigationSubtitle(self.model.users.count > 0 ? "^[\(self.model.users.count) user](inflect: true) online" : "")
+ .navigationSplitViewColumnWidth(min: 250, ideal: 500)
case .files:
FilesView()
.navigationTitle(self.model.serverTitle)
.navigationSubtitle(self.model.users.count > 0 ? "^[\(self.model.users.count) user](inflect: true) online" : "")
+ .navigationSplitViewColumnWidth(min: 250, ideal: 500)
case .tasks:
EmptyView()
case .user:
@@ -253,13 +358,13 @@ struct ServerView: View {
MessageView(userID: selectionUserID)
.navigationTitle(self.model.serverTitle)
.navigationSubtitle(self.model.users.count > 0 ? "^[\(self.model.users.count) user](inflect: true) online" : "")
+ .navigationSplitViewColumnWidth(min: 250, ideal: 500)
}
}
}
}
.navigationTitle("")
.onAppear {
- print(" YAYY")
self.model.login(server: self.server, login: "", password: "", username: preferences.username, iconID: preferences.userIconID) { success in
if !success {
print("FAILED LOGIN??")
diff --git a/Hotline/macOS/TrackerView.swift b/Hotline/macOS/TrackerView.swift
index 789b2cd..3870a38 100644
--- a/Hotline/macOS/TrackerView.swift
+++ b/Hotline/macOS/TrackerView.swift
@@ -13,7 +13,7 @@ struct TrackerBookmark {
@Observable
class TrackerItem: Identifiable, Hashable {
- @ObservationIgnored let id: UUID = UUID()
+ let id: UUID = UUID()
let bookmark: TrackerBookmark?
let server: Server?
@@ -86,8 +86,6 @@ class TrackerItem: Identifiable, Hashable {
}
struct TrackerItemView: View {
-// @Environment(Hotline.self) private var model: Hotline
-
@State var expanded = false
@State var loading = false