aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Hotline.xcodeproj/project.pbxproj6
-rw-r--r--Hotline/Hotline/HotlineClientNew.swift13
-rw-r--r--Hotline/Hotline/HotlineTrackerClient.swift3
-rw-r--r--Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift51
-rw-r--r--Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift46
-rw-r--r--Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift96
-rw-r--r--Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift21
-rw-r--r--Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift18
-rw-r--r--Hotline/Hotline/Transfers/HotlineTransferClient.swift (renamed from Hotline/Hotline/HotlineTransferClient.swift)0
-rw-r--r--Hotline/Library/Extensions.swift4
-rw-r--r--Hotline/Library/NetSocket/NetSocket.swift35
-rw-r--r--Hotline/MacApp.swift45
-rw-r--r--Hotline/State/FilePreviewState.swift11
-rw-r--r--Hotline/State/HotlineState.swift22
-rw-r--r--Hotline/State/ServerState.swift2
-rw-r--r--Hotline/iOS/TrackerView.swift216
-rw-r--r--Hotline/macOS/ConnectView.swift147
-rw-r--r--Hotline/macOS/Files/FilePreviewQuickLookView.swift22
-rw-r--r--Hotline/macOS/HotlinePanelView.swift2
-rw-r--r--Hotline/macOS/ServerView.swift261
20 files changed, 462 insertions, 559 deletions
diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj
index f91bcf8..aa10d51 100644
--- a/Hotline.xcodeproj/project.pbxproj
+++ b/Hotline.xcodeproj/project.pbxproj
@@ -115,6 +115,7 @@
DAE735032B30C0BB000C56F6 /* MessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAE735022B30C0BB000C56F6 /* MessageView.swift */; platformFilters = (macos, ); };
DAE735052B3218D8000C56F6 /* NewsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAE735042B3218D8000C56F6 /* NewsView.swift */; platformFilters = (macos, ); };
DAE735072B3251B3000C56F6 /* SoundEffects.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAE735062B3251B3000C56F6 /* SoundEffects.swift */; };
+ DAF5BC6C2EC2727700551E4D /* ConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF5BC6B2EC2727100551E4D /* ConnectView.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@@ -225,6 +226,7 @@
DAE735022B30C0BB000C56F6 /* MessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageView.swift; sourceTree = "<group>"; };
DAE735042B3218D8000C56F6 /* NewsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsView.swift; sourceTree = "<group>"; };
DAE735062B3251B3000C56F6 /* SoundEffects.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundEffects.swift; sourceTree = "<group>"; };
+ DAF5BC6B2EC2727100551E4D /* ConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -245,6 +247,7 @@
DA3429B12EBA73730010784E /* Transfers */ = {
isa = PBXGroup;
children = (
+ DA5753672B33E88A00FAC277 /* HotlineTransferClient.swift */,
DA3429B22EBA7ADE0010784E /* HotlineFilePreviewClientNew.swift */,
DA5268B22EB6806E00DCB941 /* HotlineFileDownloadClientNew.swift */,
DA3429AD2EB9C0220010784E /* HotlineFileUploadClientNew.swift */,
@@ -470,7 +473,6 @@
children = (
DA5268AE2EB2682B00DCB941 /* HotlineClientNew.swift */,
DA9CAFCA2B126E3300CDA197 /* HotlineTrackerClient.swift */,
- DA5753672B33E88A00FAC277 /* HotlineTransferClient.swift */,
DA3429B12EBA73730010784E /* Transfers */,
DA4F2BF72B16A17200D8ADDC /* HotlineProtocol.swift */,
DAC87F062C5010E80060FADF /* HotlineExtensions.swift */,
@@ -526,6 +528,7 @@
DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */,
DAE136B92B9D1147007D8307 /* HotlinePanelView.swift */,
DAE734F82B2E4185000C56F6 /* ServerView.swift */,
+ DAF5BC6B2EC2727100551E4D /* ConnectView.swift */,
DAE735022B30C0BB000C56F6 /* MessageView.swift */,
DA5268B42EB6840A00DCB941 /* TransfersView.swift */,
DA501BE52EBE9520001714F8 /* Trackers */,
@@ -667,6 +670,7 @@
DA65499E2BEC438A00EDB697 /* NSWindowBridge.swift in Sources */,
DAE735032B30C0BB000C56F6 /* MessageView.swift in Sources */,
DA32CD4B2B29318E0053B98B /* FileInfo.swift in Sources */,
+ DAF5BC6C2EC2727700551E4D /* ConnectView.swift in Sources */,
DA20BBE12BF5237600B94E7C /* Bookmark.swift in Sources */,
DA3429B32EBA7ADF0010784E /* HotlineFilePreviewClientNew.swift in Sources */,
DA9CAFCB2B126E3300CDA197 /* HotlineTrackerClient.swift in Sources */,
diff --git a/Hotline/Hotline/HotlineClientNew.swift b/Hotline/Hotline/HotlineClientNew.swift
index 303bd32..854c7ff 100644
--- a/Hotline/Hotline/HotlineClientNew.swift
+++ b/Hotline/Hotline/HotlineClientNew.swift
@@ -64,7 +64,7 @@ public enum HotlineClientError: Error {
// MARK: - Login Info
/// Information needed to log in to a Hotline server
-public struct HotlineLoginInfo: Sendable {
+public struct HotlineLogin: Sendable {
let login: String
let password: String
let username: String
@@ -100,7 +100,7 @@ public struct HotlineServerInfo: Sendable {
/// let client = try await HotlineClientNew.connect(
/// host: "server.example.com",
/// port: 5500,
-/// login: HotlineLoginInfo(login: "guest", password: "", username: "John", iconID: 414)
+/// login: HotlineLogin(login: "guest", password: "", username: "John", iconID: 414)
/// )
///
/// // Listen for events
@@ -188,14 +188,13 @@ public actor HotlineClientNew {
public static func connect(
host: String,
port: UInt16 = 5500,
- login: HotlineLoginInfo,
- tls: TLSPolicy = .disabled
+ login: HotlineLogin
) async throws -> HotlineClientNew {
print("HotlineClientNew.connect(): Starting connection to \(host):\(port) as '\(login.username)'")
// Connect socket
print("HotlineClientNew.connect(): Connecting socket...")
- let socket = try await NetSocket.connect(host: host, port: port, tls: tls)
+ let socket = try await NetSocket.connect(host: host, port: port)
print("HotlineClientNew.connect(): Socket connected")
// Perform handshake
@@ -264,7 +263,7 @@ public actor HotlineClientNew {
// MARK: - Login
- private func performLogin(_ login: HotlineLoginInfo) async throws -> HotlineServerInfo {
+ private func performLogin(_ login: HotlineLogin) async throws -> HotlineServerInfo {
var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .login)
transaction.setFieldEncodedString(type: .userLogin, val: login.login)
transaction.setFieldEncodedString(type: .userPassword, val: login.password)
@@ -273,7 +272,7 @@ public actor HotlineClientNew {
transaction.setFieldUInt32(type: .versionNumber, val: 123)
let reply = try await sendTransaction(transaction)
-
+
guard reply.errorCode == 0 else {
let errorText = reply.getField(type: .errorText)?.getString()
throw HotlineClientError.loginFailed(errorText)
diff --git a/Hotline/Hotline/HotlineTrackerClient.swift b/Hotline/Hotline/HotlineTrackerClient.swift
index f72735d..72ff233 100644
--- a/Hotline/Hotline/HotlineTrackerClient.swift
+++ b/Hotline/Hotline/HotlineTrackerClient.swift
@@ -74,8 +74,7 @@ class HotlineTrackerClient {
// Connect to tracker (plaintext, no TLS)
let socket = try await NetSocket.connect(
host: address,
- port: UInt16(port),
- tls: .disabled
+ port: UInt16(port)
)
defer { Task { await socket.close() } }
diff --git a/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift
index 981965a..82f61d4 100644
--- a/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift
+++ b/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift
@@ -16,18 +16,14 @@ public enum HotlineTransferProgress: Sendable {
case completed(url: URL?) // Download or upload complete (local url valid for downloads)
}
-/// Modern async/await file download client for Hotline protocol
-@MainActor
-public class HotlineFileDownloadClientNew: @MainActor HotlineTransferClient {
- // MARK: - Configuration
+@MainActor
+public class HotlineFileDownloadClient: @MainActor HotlineTransferClient {
public struct Configuration: Sendable {
public var chunkSize: Int = 256 * 1024
public init() {}
}
- // MARK: - Properties
-
private let serverAddress: String
private let serverPort: UInt16
private let referenceNumber: UInt32
@@ -41,8 +37,6 @@ public class HotlineFileDownloadClientNew: @MainActor HotlineTransferClient {
private var socket: NetSocket?
private var downloadTask: Task<URL, Error>?
- // MARK: - Initialization
-
public init(
address: String,
port: UInt16,
@@ -91,7 +85,7 @@ public class HotlineFileDownloadClientNew: @MainActor HotlineTransferClient {
self.downloadTask = nil
}
- // MARK: - Private Implementation
+ // MARK: - Implementation
private func updateProgress(sent: Int) throws {
self.transferSize = sent
@@ -141,9 +135,12 @@ public class HotlineFileDownloadClientNew: @MainActor HotlineTransferClient {
try progressHandler?(.connecting)
// Connect to transfer server
- let socket = try await connectToTransferServer()
- self.socket = socket
+ let socket = try await NetSocket.connect(
+ host: self.serverAddress,
+ port: self.serverPort + 1
+ )
defer { Task { await socket.close() } }
+ self.socket = socket
// See if we've been cancelled
try self.checkCancelled()
@@ -257,35 +254,7 @@ public class HotlineFileDownloadClientNew: @MainActor HotlineTransferClient {
}
}
- // MARK: - Helper Methods
-
- private func connectToTransferServer() async throws -> NetSocket {
- guard let transferPort = NWEndpoint.Port(rawValue: serverPort + 1) else {
- throw NetSocketError.invalidPort
- }
-
- print("HotlineFileDownloadClientNew[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)")
-
- let socket = try await NetSocket.connect(
- host: .name(serverAddress, nil),
- port: transferPort,
- tls: .disabled
- )
-
- print("HotlineFileDownloadClientNew[\(referenceNumber)]: Connected!")
- return socket
- }
-
- private func sendMagicHeader(socket: NetSocket) async throws {
- let header = Data(endian: .big) {
- "HTXF".fourCharCode()
- referenceNumber
- UInt32.zero
- UInt32.zero
- }
-
- try await socket.write(header)
- }
+ // MARK: - Utility
private func writeResourceFork(data: Data, to url: URL) throws {
var resolvedURL = url
@@ -294,7 +263,7 @@ public class HotlineFileDownloadClientNew: @MainActor HotlineTransferClient {
let resourceURL = resolvedURL.urlForResourceFork()
try data.write(to: resourceURL)
- print("HotlineFileDownloadClientNew[\(referenceNumber)]: Wrote resource fork (\(data.count) bytes)")
+ print("HotlineFileDownloadClient[\(self.referenceNumber)]: Wrote resource fork (\(data.count) bytes)")
}
}
diff --git a/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift b/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift
index 93a68fc..5cf5628 100644
--- a/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift
+++ b/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift
@@ -2,21 +2,17 @@ import Foundation
import Network
@MainActor
-public class HotlineFilePreviewClientNew {
- // MARK: - Properties
-
+public class HotlineFilePreviewClient {
private let serverAddress: String
private let serverPort: UInt16
private let referenceNumber: UInt32
private let fileName: String
private let transferSize: UInt32
- private var downloadClient: HotlineFileDownloadClientNew?
+ private var downloadClient: HotlineFileDownloadClient?
private var previewTask: Task<URL, Error>?
private var temporaryFileURL: URL?
- // MARK: - Initialization
-
public init(
fileName: String,
address: String,
@@ -31,7 +27,7 @@ public class HotlineFilePreviewClientNew {
self.transferSize = size
}
- // MARK: -
+ // MARK: - API
/// Download file to temporary location for preview
/// - Parameter progressHandler: Optional progress callback
@@ -51,7 +47,7 @@ public class HotlineFilePreviewClientNew {
self.previewTask = nil
return url
} catch {
- print("HotlineFilePreviewClientNew[\(referenceNumber)]: Failed to preview file: \(error)")
+ print("HotlineFilePreviewClient[\(referenceNumber)]: Failed to preview file: \(error)")
self.previewTask = nil
progressHandler?(.error(error))
throw error
@@ -71,7 +67,7 @@ public class HotlineFilePreviewClientNew {
self.cleanupTempFile()
}
- // MARK: - Private Implementation
+ // MARK: - Implementation
private func performPreview(
progressHandler: (@Sendable (HotlineTransferProgress) -> Void)?
@@ -83,28 +79,22 @@ public class HotlineFilePreviewClientNew {
let tempFileURL = tempDir.appendingPathComponent(uniqueFileName)
self.temporaryFileURL = tempFileURL
- print("HotlineFilePreviewClientNew[\(referenceNumber)]: Downloading to temp: \(tempFileURL.path)")
+ print("HotlineFilePreviewClient[\(self.referenceNumber)]: Downloading to temp: \(tempFileURL.path)")
progressHandler?(.connecting)
// Connect to transfer server
- guard let transferPort = NWEndpoint.Port(rawValue: serverPort + 1) else {
- throw NetSocketError.invalidPort
- }
-
- print("HotlineFilePreviewClientNew[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)")
-
+ print("HotlineFilePreviewClient[\(self.referenceNumber)]: Connecting to \(self.serverAddress):\(self.serverPort + 1)")
let socket = try await NetSocket.connect(
- host: .name(serverAddress, nil),
- port: transferPort,
- tls: .disabled
+ host: self.serverAddress,
+ port: self.serverPort + 1
)
defer { Task { await socket.close() } }
- print("HotlineFilePreviewClientNew[\(referenceNumber)]: Connected!")
+ print("HotlineFilePreviewClient[\(self.referenceNumber)]: Connected!")
// Send magic header for raw data download
- print("HotlineFilePreviewClientNew[\(referenceNumber)]: Sending magic header")
+ print("HotlineFilePreviewClient[\(self.referenceNumber)]: Sending magic header")
try await socket.write(Data(endian: .big) {
"HTXF".fourCharCode()
self.referenceNumber
@@ -115,7 +105,7 @@ public class HotlineFilePreviewClientNew {
progressHandler?(.connected)
// Stream raw data directly to temp file with progress tracking
- print("HotlineFilePreviewClientNew[\(referenceNumber)]: Streaming \(transferSize) bytes to temp file")
+ print("HotlineFilePreviewClient[\(self.referenceNumber)]: Streaming \(transferSize) bytes to temp file")
let totalSize = Int(transferSize)
@@ -139,18 +129,18 @@ public class HotlineFilePreviewClientNew {
progressHandler?(.completed(url: tempFileURL))
- print("HotlineFilePreviewClientNew[\(referenceNumber)]: Preview file ready at \(tempFileURL.path)")
+ print("HotlineFilePreviewClient[\(self.referenceNumber)]: Preview file ready at \(tempFileURL.path)")
return tempFileURL
}
private func cleanupTempFile() {
- guard let tempURL = temporaryFileURL else { return }
-
+ guard let tempURL = self.temporaryFileURL else { return }
+ self.temporaryFileURL = nil
+
// Delete the temp file
try? FileManager.default.removeItem(at: tempURL)
-
- temporaryFileURL = nil
- print("HotlineFilePreviewClientNew[\(referenceNumber)]: Cleaned up temp file")
+
+ print("HotlineFilePreviewClient[\(self.referenceNumber)]: Cleaned up temp file")
}
}
diff --git a/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift
index 3bf3339..384e9bd 100644
--- a/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift
+++ b/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift
@@ -2,17 +2,12 @@ import Foundation
import Network
@MainActor
-public class HotlineFileUploadClientNew: @MainActor HotlineTransferClient {
- // MARK: - Configuration
-
+public class HotlineFileUploadClient: @MainActor HotlineTransferClient {
public struct Configuration: Sendable {
public var chunkSize: Int = 256 * 1024
-
public init() {}
}
- // MARK: - Properties
-
private let serverAddress: String
private let serverPort: UInt16
private let referenceNumber: UInt32
@@ -27,8 +22,6 @@ public class HotlineFileUploadClientNew: @MainActor HotlineTransferClient {
private var socket: NetSocket?
private var uploadTask: Task<Void, Error>?
- // MARK: - Initialization
-
public init?(
fileURL: URL,
address: String,
@@ -54,8 +47,6 @@ public class HotlineFileUploadClientNew: @MainActor HotlineTransferClient {
self.transferTotal = Int(payloadSize)
self.transferSize = 0
self.transferProgress = Progress(totalUnitCount: Int64(self.transferTotal))
-
- print("HotlineFileUploadClientNew[\(reference)]: Preparing to upload '\(fileURL.lastPathComponent)' (\(payloadSize) bytes)")
}
// MARK: - Public API
@@ -74,7 +65,7 @@ public class HotlineFileUploadClientNew: @MainActor HotlineTransferClient {
try await task.value
self.uploadTask = nil
} catch {
- print("HotlineFileUploadClientNew[\(referenceNumber)]: Failed to upload file: \(error)")
+ print("HotlineFileUploadClient[\(self.referenceNumber)]: Failed to upload file: \(error)")
self.uploadTask = nil
progressHandler?(.error(error))
throw error
@@ -83,17 +74,17 @@ public class HotlineFileUploadClientNew: @MainActor HotlineTransferClient {
/// Cancel the current upload
public func cancel() {
- uploadTask?.cancel()
- uploadTask = nil
+ self.uploadTask?.cancel()
+ self.uploadTask = nil
- if let socket = socket {
+ if let socket = self.socket {
Task {
await socket.close()
}
}
}
- // MARK: - Private Implementation
+ // MARK: - Implementation
private func updateProgress(sent: Int, speed: Double? = nil, estimate: TimeInterval? = nil) {
self.transferSize = sent
@@ -120,34 +111,39 @@ public class HotlineFileUploadClientNew: @MainActor HotlineTransferClient {
}
// Connect to transfer server
- let socket = try await connectToTransferServer()
- self.socket = socket
+ let socket = try await NetSocket.connect(
+ host: self.serverAddress,
+ port: self.serverPort + 1
+ )
defer { Task { await socket.close() } }
+ self.socket = socket
// Get file metadata
- guard let infoFork = HotlineFileInfoFork(file: fileURL) else {
+ guard let infoFork = HotlineFileInfoFork(file: self.fileURL) else {
throw HotlineTransferClientError.failedToTransfer
}
- guard let header = HotlineFileHeader(file: fileURL) else {
+ guard let header = HotlineFileHeader(file: self.fileURL) else {
throw HotlineTransferClientError.failedToTransfer
}
- guard let forkSizes = try? FileManager.default.getFileForkSizes(fileURL) else {
+ guard let forkSizes = try? FileManager.default.getFileForkSizes(self.fileURL) else {
throw HotlineTransferClientError.failedToTransfer
}
let infoForkData = infoFork.data()
let dataForkSize = forkSizes.dataForkSize
let resourceForkSize = forkSizes.resourceForkSize
-
- print("HotlineFileUploadClientNew[\(referenceNumber)]: File has dataFork=\(dataForkSize) bytes, resourceFork=\(resourceForkSize) bytes")
+
+ // Configure progress for Finder if enabled
+ self.transferProgress.fileURL = self.fileURL.resolvingSymlinksInPath()
+ self.transferProgress.fileOperationKind = .uploading
+ self.transferProgress.publish()
// Connected
progressHandler?(.connected)
// Send magic header
- print("HotlineFileUploadClientNew[\(referenceNumber)]: Sending magic header")
try await socket.write(Data(endian: .big) {
"HTXF".fourCharCode()
self.referenceNumber
@@ -157,41 +153,34 @@ public class HotlineFileUploadClientNew: @MainActor HotlineTransferClient {
var totalBytesSent = 0
+ // MARK: - Info Fork
// Send file header
- print("HotlineFileUploadClientNew[\(referenceNumber)]: Sending file header")
let headerData = header.data()
try await socket.write(headerData)
totalBytesSent += headerData.count
- // Send INFO fork header
- print("HotlineFileUploadClientNew[\(referenceNumber)]: Sending INFO fork header")
+ // Send info fork header
let infoForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.info.rawValue, dataSize: UInt32(infoForkData.count))
try await socket.write(infoForkHeader.data())
totalBytesSent += HotlineFileForkHeader.DataSize
- // Send INFO fork data
- print("HotlineFileUploadClientNew[\(referenceNumber)]: Sending INFO fork (\(infoForkData.count) bytes)")
+ // Send info fork
try await socket.write(infoForkData)
totalBytesSent += infoForkData.count
self.updateProgress(sent: totalBytesSent)
progressHandler?(.transfer(name: filename, size: self.transferSize, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: nil, estimate: nil))
- // Configure progress for Finder if enabled
- self.transferProgress.fileURL = fileURL.resolvingSymlinksInPath()
- self.transferProgress.fileOperationKind = .uploading
- self.transferProgress.publish()
-
- // Send DATA fork if present
+ // MARK: - Data Fork
+ // Send data fork (if present)
if dataForkSize > 0 {
- print("HotlineFileUploadClientNew[\(referenceNumber)]: Sending DATA fork header")
+ // Data fork header
let dataForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.data.rawValue, dataSize: dataForkSize)
try await socket.write(dataForkHeader.data())
totalBytesSent += HotlineFileForkHeader.DataSize
- // Stream DATA fork
- print("HotlineFileUploadClientNew[\(referenceNumber)]: Streaming DATA fork (\(dataForkSize) bytes)")
- let fileHandle = try FileHandle(forReadingFrom: fileURL)
+ // Stream data fork from disk
+ let fileHandle = try FileHandle(forReadingFrom: self.fileURL)
defer { try? fileHandle.close() }
let updates = await socket.writeFile(from: fileHandle, length: Int(dataForkSize))
@@ -204,17 +193,17 @@ public class HotlineFileUploadClientNew: @MainActor HotlineTransferClient {
totalBytesSent += Int(dataForkSize)
}
- // Send RESOURCE fork if present
+ // MARK: - Resource Fork
+ // Send resource fork (if present)
if resourceForkSize > 0 {
- let resourceURL = fileURL.urlForResourceFork()
+ let resourceURL = self.fileURL.urlForResourceFork()
- print("HotlineFileUploadClientNew[\(referenceNumber)]: Sending RESOURCE fork header")
+ // Resource fork header
let resourceForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.resource.rawValue, dataSize: resourceForkSize)
try await socket.write(resourceForkHeader.data())
totalBytesSent += HotlineFileForkHeader.DataSize
- // Stream RESOURCE fork
- print("HotlineFileUploadClientNew[\(referenceNumber)]: Streaming RESOURCE fork (\(resourceForkSize) bytes)")
+ // Stream resource fork from disk
let resourceHandle = try FileHandle(forReadingFrom: resourceURL)
defer { try? resourceHandle.close() }
@@ -231,25 +220,6 @@ public class HotlineFileUploadClientNew: @MainActor HotlineTransferClient {
self.transferProgress.unpublish()
progressHandler?(.completed(url: nil))
- print("HotlineFileUploadClientNew[\(referenceNumber)]: Upload complete!")
- }
-
- // MARK: - Helper Methods
-
- private func connectToTransferServer() async throws -> NetSocket {
- guard let transferPort = NWEndpoint.Port(rawValue: serverPort + 1) else {
- throw NetSocketError.invalidPort
- }
-
- print("HotlineFileUploadClientNew[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)")
-
- let socket = try await NetSocket.connect(
- host: .name(serverAddress, nil),
- port: transferPort,
- tls: .disabled
- )
-
- print("HotlineFileUploadClientNew[\(referenceNumber)]: Connected!")
- return socket
+ print("HotlineFileUploadClient[\(self.referenceNumber)]: Complete!")
}
}
diff --git a/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift
index 8303d11..600b9f2 100644
--- a/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift
+++ b/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift
@@ -10,8 +10,6 @@ public struct HotlineFolderItemProgress: Sendable {
@MainActor
public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient {
- // MARK: - Properties
-
private let serverAddress: String
private let serverPort: UInt16
private let referenceNumber: UInt32
@@ -129,14 +127,14 @@ public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient {
self.folderProgress = progress
// Send initial magic header
- print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Sending HTXF magic")
+ print("HotlineFolderDownloadClientNew[\(self.referenceNumber)]: Sending HTXF magic")
try await socket.write(Data(endian: .big) {
"HTXF".fourCharCode()
self.referenceNumber
UInt32.zero // data size = 0
UInt16(1) // type = 1 (folder transfer)
UInt16.zero // reserved = 0
- HotlineFolderAction.nextFile.rawValue // action = 3 (next file)
+ HotlineFolderAction.nextFile.rawValue // action = 3 (next file)
})
progressHandler?(.connected)
@@ -146,7 +144,7 @@ public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient {
var totalBytesTransferred = 0
// Process each item in the folder
- while completedItemCount < folderItemCount {
+ while completedItemCount < self.folderItemCount {
// Read item header
let headerLenData = try await socket.read(2)
let headerLen = Int(headerLenData.readUInt16(at: 0)!)
@@ -154,7 +152,7 @@ public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient {
totalBytesTransferred += 2 + headerLen
- guard let (itemType, pathComponents) = parseItemHeaderPath(headerData) else {
+ guard let (itemType, pathComponents) = self.parseItemHeaderPath(headerData) else {
throw HotlineTransferClientError.failedToTransfer
}
@@ -166,7 +164,7 @@ public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient {
if !pathComponents.isEmpty {
let folderURL = destinationURL.appendingPathComponents(pathComponents)
try fm.createDirectory(at: folderURL, withIntermediateDirectories: true)
- print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Created folder at \(folderURL.path)")
+ print("HotlineFolderDownloadClientNew[\(self.referenceNumber)]: Created folder at \(folderURL.path)")
}
completedItemCount += 1
@@ -246,16 +244,11 @@ public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient {
// MARK: - Helper Methods
private func connectToTransferServer() async throws -> NetSocket {
- guard let transferPort = NWEndpoint.Port(rawValue: serverPort + 1) else {
- throw NetSocketError.invalidPort
- }
-
print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)")
let socket = try await NetSocket.connect(
- host: .name(serverAddress, nil),
- port: transferPort,
- tls: .disabled
+ host: self.serverAddress,
+ port: self.serverPort + 1
)
print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Connected!")
diff --git a/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift
index 4b98b54..15636f3 100644
--- a/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift
+++ b/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift
@@ -147,7 +147,11 @@ public class HotlineFolderUploadClientNew: @MainActor HotlineTransferClient {
progressHandler?(.connecting)
// Connect to transfer server
- let socket = try await self.connect(address: self.serverAddress, port: self.serverPort)
+ let socket = try await NetSocket.connect(
+ host: self.serverAddress,
+ port: self.serverPort + 1
+ )
+
self.socket = socket
defer { Task { await socket.close() } }
@@ -281,18 +285,6 @@ public class HotlineFolderUploadClientNew: @MainActor HotlineTransferClient {
progressHandler?(.completed(url: nil))
}
- private func connect(address: String, port: UInt16) async throws -> NetSocket {
- guard let transferPort = NWEndpoint.Port(rawValue: port + 1) else {
- throw NetSocketError.invalidPort
- }
-
- return try await NetSocket.connect(
- host: .name(address, nil),
- port: transferPort,
- tls: .disabled
- )
- }
-
private func buildFolderHierarchy() throws {
let fm = FileManager.default
folderItems = []
diff --git a/Hotline/Hotline/HotlineTransferClient.swift b/Hotline/Hotline/Transfers/HotlineTransferClient.swift
index 596155b..596155b 100644
--- a/Hotline/Hotline/HotlineTransferClient.swift
+++ b/Hotline/Hotline/Transfers/HotlineTransferClient.swift
diff --git a/Hotline/Library/Extensions.swift b/Hotline/Library/Extensions.swift
index b6a4b68..bb28370 100644
--- a/Hotline/Library/Extensions.swift
+++ b/Hotline/Library/Extensions.swift
@@ -121,6 +121,10 @@ extension UTType {
extension String {
+ var isBlank: Bool {
+ self.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ }
+
func markdownToAttributedString() -> AttributedString {
let markdownText = self.convertingLinksToMarkdown()
let attr = (try? AttributedString(markdown: markdownText, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(self)
diff --git a/Hotline/Library/NetSocket/NetSocket.swift b/Hotline/Library/NetSocket/NetSocket.swift
index e66ef3f..ffccf5c 100644
--- a/Hotline/Library/NetSocket/NetSocket.swift
+++ b/Hotline/Library/NetSocket/NetSocket.swift
@@ -87,7 +87,7 @@ public enum NetSocketError: Error, CustomStringConvertible, Sendable {
}
}
-/// An async/await TCP socket with automatic buffering and framing support
+/// An async TCP socket with automatic buffering
///
/// NetSocket provides:
/// - Async connection management
@@ -99,7 +99,7 @@ public enum NetSocketError: Error, CustomStringConvertible, Sendable {
/// ```swift
/// let socket = try await NetSocket.connect(host: "example.com", port: 80)
/// try await socket.write("Hello\n".data(using: .utf8)!)
-/// let response = try await socket.readUntil(delimiter: .lineFeed)
+/// let response = try await socket.read(until: .lineFeed)
/// ```
public actor NetSocket {
/// Configuration options for the socket
@@ -150,18 +150,11 @@ public actor NetSocket {
/// - Parameters:
/// - host: Network framework host (e.g., `.name("example.com", nil)` or `.ipv4(...)`)
/// - port: Network framework port
- /// - tls: TLS policy (default: enabled with default settings)
/// - config: Socket configuration (default: standard settings)
+ /// - parameters: NWParameters (default: .tcp)
/// - Returns: A connected and ready `NetSocket`
/// - Throws: Network errors or connection failures
- public static func connect(host: NWEndpoint.Host, port: NWEndpoint.Port, tls: TLSPolicy = .enabled(), config: Config = .init()) async throws -> NetSocket {
- let parameters = NWParameters.tcp
- if tls.enabled {
- let tlsOptions = NWProtocolTLS.Options()
- tls.configure?(tlsOptions)
- parameters.defaultProtocolStack.applicationProtocols.insert(tlsOptions, at: 0)
- }
-
+ public static func connect(host: NWEndpoint.Host, port: NWEndpoint.Port, config: Config = .init(), parameters: NWParameters = .tcp) async throws -> NetSocket {
let conn = NWConnection(host: host, port: port, using: parameters)
let socket = NetSocket(connection: conn, config: config)
try await socket.start()
@@ -169,28 +162,12 @@ public actor NetSocket {
}
/// Convenience wrapper to connect using string hostname and integer port
- public static func connect(host: String, port: UInt16, tls: TLSPolicy = .enabled(), config: Config = .init()) async throws -> NetSocket {
+ public static func connect(host: String, port: UInt16, config: Config = .init()) async throws -> NetSocket {
guard let nwPort = NWEndpoint.Port(rawValue: port) else {
throw NetSocketError.invalidPort
}
- // Parse the host string to create the appropriate NWEndpoint.Host
- let nwHost: NWEndpoint.Host
-
- // Try parsing as IPv6 without zone
- if let ipv6Addr = IPv6Address(host) {
- nwHost = .ipv6(ipv6Addr)
- }
- // Try parsing as IPv4
- else if let ipv4Addr = IPv4Address(host) {
- nwHost = .ipv4(ipv4Addr)
- }
- // Fall back to treating as hostname
- else {
- nwHost = .name(host, nil)
- }
-
- return try await self.connect(host: nwHost, port: nwPort, tls: tls, config: config)
+ return try await self.connect(host: NWEndpoint.Host(host), port: nwPort, config: config)
}
// MARK: Close
diff --git a/Hotline/MacApp.swift b/Hotline/MacApp.swift
index 9052c0b..9ac54c1 100644
--- a/Hotline/MacApp.swift
+++ b/Hotline/MacApp.swift
@@ -109,7 +109,7 @@ struct Application: App {
.onChange(of: AppLaunchState.shared.launchState) {
if AppLaunchState.shared.launchState == .launched {
if Prefs.shared.showBannerToolbar {
- showBannerWindow()
+ self.showBannerWindow()
}
}
}
@@ -196,13 +196,13 @@ struct Application: App {
.commands {
CommandGroup(replacing: .newItem) {
Button("Connect to Server...") {
- openWindow(id: "server")
+ self.openWindow(id: "server")
}
.keyboardShortcut(.init("K"), modifiers: .command)
}
CommandGroup(before: .singleWindowList) {
Button("Toolbar") {
- toggleBannerWindow()
+ self.toggleBannerWindow()
}
.keyboardShortcut(.init("\\"), modifiers: [.shift, .command])
}
@@ -210,18 +210,18 @@ struct Application: App {
Divider()
Button("Request Feature...") {
if let url = URL(string: "https://github.com/mierau/hotline/issues/new?labels=enhancement") {
- openURL(url)
+ self.openURL(url)
}
}
Button("Report Bug...") {
if let url = URL(string: "https://github.com/mierau/hotline/issues/new?labels=bug") {
- openURL(url)
+ self.openURL(url)
}
}
Divider()
Button("Open Latest Release Page...") {
if let url = URL(string: "https://github.com/mierau/hotline/releases/latest") {
- openURL(url)
+ self.openURL(url)
}
}
}
@@ -230,7 +230,7 @@ struct Application: App {
guard let selection else {
return
}
- connect(to: selection)
+ self.connect(to: selection)
}
.disabled(selection == nil || selection?.server == nil)
.keyboardShortcut(.downArrow, modifiers: .command)
@@ -242,38 +242,47 @@ struct Application: App {
}
}
.disabled(activeHotline?.status == .disconnected)
+
Divider()
+
Button("Broadcast Message...") {
// TODO: Implement broadcast message when user is allowed.
}
.disabled(true)
.keyboardShortcut(.init("B"), modifiers: .command)
+
Divider()
- Button("Show Chat") {
+
+ Button("Chat") {
activeServerState?.selection = .chat
}
.disabled(activeHotline?.status != .loggedIn)
.keyboardShortcut(.init("1"), modifiers: .command)
- Button("Show Message Board") {
+ Button("Board") {
activeServerState?.selection = .board
}
.disabled(activeHotline?.status != .loggedIn)
.keyboardShortcut(.init("2"), modifiers: .command)
- Button("Show News") {
+ Button("News") {
activeServerState?.selection = .news
}
.disabled(activeHotline?.status != .loggedIn || (activeHotline?.serverVersion ?? 0) < 151)
.keyboardShortcut(.init("3"), modifiers: .command)
- Button("Show Files") {
+ Button("Files") {
activeServerState?.selection = .files
}
.disabled(activeHotline?.status != .loggedIn)
.keyboardShortcut(.init("4"), modifiers: .command)
- Button("Show Accounts") {
- activeServerState?.selection = .accounts
+
+ if activeHotline?.access?.contains(.canOpenUsers) == true {
+ Divider()
+
+ Button("Accounts") {
+ activeServerState?.selection = .accounts
+ }
+ .disabled(activeHotline?.status != .loggedIn || activeHotline?.access?.contains(.canOpenUsers) != true )
+ .keyboardShortcut(.init("5"), modifiers: .command)
}
- .disabled(activeHotline?.status != .loggedIn || activeHotline?.access?.contains(.canOpenUsers) != true )
- .keyboardShortcut(.init("5"), modifiers: .command)
}
}
@@ -287,8 +296,8 @@ struct Application: App {
TransfersView()
.frame(minWidth: 500, minHeight: 200)
}
- .defaultSize(width: 600, height: 400)
- .defaultPosition(.center)
+ .defaultSize(width: 500, height: 400)
+ .defaultPosition(.topTrailing)
.keyboardShortcut(.init("T"), modifiers: [.shift, .command])
// MARK: Image Preview Window
@@ -328,7 +337,7 @@ struct Application: App {
func connect(to item: TrackerSelection) {
if let server = item.server {
- openWindow(id: "server", value: server)
+ self.openWindow(id: "server", value: server)
}
}
diff --git a/Hotline/State/FilePreviewState.swift b/Hotline/State/FilePreviewState.swift
index 97bd907..27f8199 100644
--- a/Hotline/State/FilePreviewState.swift
+++ b/Hotline/State/FilePreviewState.swift
@@ -1,10 +1,3 @@
-//
-// FilePreviewState.swift
-// Hotline
-//
-// Modern file preview state using HotlineFilePreviewClientNew
-//
-
import SwiftUI
import UniformTypeIdentifiers
@@ -24,7 +17,7 @@ final class FilePreviewState {
let info: PreviewFileInfo
- private var previewClient: HotlineFilePreviewClientNew?
+ private var previewClient: HotlineFilePreviewClient?
private var previewTask: Task<Void, Never>?
var state: LoadState = .unloaded
@@ -67,7 +60,7 @@ final class FilePreviewState {
let task = Task { @MainActor in
do {
- let client = HotlineFilePreviewClientNew(
+ let client = HotlineFilePreviewClient(
fileName: info.name,
address: info.address,
port: UInt16(info.port),
diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift
index 14fb9aa..e80571a 100644
--- a/Hotline/State/HotlineState.swift
+++ b/Hotline/State/HotlineState.swift
@@ -8,6 +8,10 @@ enum HotlineConnectionStatus: Equatable {
case connected
case loggedIn
case failed(String)
+
+ var isLoggingIn: Bool {
+ self == .connecting || self == .connected
+ }
var isConnected: Bool {
return self == .connected || self == .loggedIn
@@ -254,7 +258,7 @@ class HotlineState: Equatable {
@ObservationIgnored private var restoredChatSessionKey: ChatStore.SessionKey?
@ObservationIgnored private var chatHistoryObserver: NSObjectProtocol?
@ObservationIgnored private var lastPersistedMessageType: ChatMessageType?
-
+
// MARK: - Initialization
init() {
@@ -262,8 +266,10 @@ class HotlineState: Equatable {
forName: ChatStore.historyClearedNotification,
object: nil,
queue: .main
- ) { @MainActor [weak self] _ in
- self?.handleChatHistoryCleared()
+ ) { _ in
+ Task { @MainActor [weak self] in
+ self?.handleChatHistoryCleared()
+ }
}
}
@@ -295,7 +301,7 @@ class HotlineState: Equatable {
do {
// Connect and login
- let loginInfo = HotlineLoginInfo(
+ let loginInfo = HotlineLogin(
login: server.login,
password: server.password,
username: username,
@@ -360,7 +366,7 @@ class HotlineState: Equatable {
await client.disconnect()
self.client = nil
}
- self.status = .failed(error.localizedDescription)
+ self.status = .disconnected // .failed(error.localizedDescription)
self.errorDisplayed = true
self.errorMessage = error.localizedDescription
throw error
@@ -498,7 +504,7 @@ class HotlineState: Equatable {
do {
print("HotlineState: Banner download info - reference: \(result.referenceNumber), transferSize: \(result.transferSize)")
- let previewClient = HotlineFilePreviewClientNew(
+ let previewClient = HotlineFilePreviewClient(
fileName: "banner",
address: address,
port: UInt16(port),
@@ -894,7 +900,7 @@ class HotlineState: Equatable {
AppState.shared.addTransfer(transfer)
// Create download client
- let downloadClient = HotlineFileDownloadClientNew(
+ let downloadClient = HotlineFileDownloadClient(
address: address,
port: UInt16(port),
reference: referenceNumber,
@@ -1270,7 +1276,7 @@ class HotlineState: Equatable {
print("HotlineState: Got upload reference: \(referenceNumber)")
// Create upload client
- guard let uploadClient = HotlineFileUploadClientNew(
+ guard let uploadClient = HotlineFileUploadClient(
fileURL: fileURL,
address: address,
port: UInt16(port),
diff --git a/Hotline/State/ServerState.swift b/Hotline/State/ServerState.swift
index 5913bf5..805672d 100644
--- a/Hotline/State/ServerState.swift
+++ b/Hotline/State/ServerState.swift
@@ -29,7 +29,7 @@ enum ServerNavigationType: Identifiable, Hashable, Equatable {
case .files:
return "Files"
case .accounts:
- return "Accounts"
+ return "Admin"
case .user(let userID):
return String(userID)
}
diff --git a/Hotline/iOS/TrackerView.swift b/Hotline/iOS/TrackerView.swift
index 2a3583b..8c846e5 100644
--- a/Hotline/iOS/TrackerView.swift
+++ b/Hotline/iOS/TrackerView.swift
@@ -9,7 +9,7 @@ struct TrackerConnectView: View {
@State private var address = ""
@State private var login = ""
@State private var password = ""
-// @State private var connecting = false
+ // @State private var connecting = false
func connectionStatusToProgress(status: HotlineClientStatus) -> Double {
switch status {
@@ -27,116 +27,116 @@ struct TrackerConnectView: View {
}
var body: some View {
- VStack(alignment: .leading) {
- if self.model.status == .disconnected {
- TextField("Server Address", text: $address)
- .keyboardType(.URL)
- .disableAutocorrection(true)
- .frame(height: 48)
- .textFieldStyle(.plain)
- .padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
- .background {
- Color.black.cornerRadius(8).blendMode(.overlay)
- }
- TextField("Login", text: $login)
- .disableAutocorrection(true)
- .frame(height: 48)
- .textFieldStyle(.plain)
- .padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
- .background {
- Color.black.cornerRadius(8).blendMode(.overlay)
- }
- SecureField("Password", text: $password)
- .disableAutocorrection(true)
- .textFieldStyle(.plain)
- .frame(height: 48)
- .padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
- .background {
- Color.black.cornerRadius(8).blendMode(.overlay)
- }
- }
- else {
- ProgressView(value: connectionStatusToProgress(status: model.status))
- .frame(minHeight: 10)
- .accentColor(colorScheme == .dark ? .white : .black)
- }
-
- Spacer()
-
- HStack {
- Button {
- dismiss()
- server = nil
- model.disconnect()
- } label: {
- Text("Cancel")
+ VStack(alignment: .leading) {
+ if self.model.status == .disconnected {
+ TextField("Server Address", text: $address)
+ .keyboardType(.URL)
+ .disableAutocorrection(true)
+ .frame(height: 48)
+ .textFieldStyle(.plain)
+ .padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
+ .background {
+ Color.black.cornerRadius(8).blendMode(.overlay)
}
- .bold()
- .padding(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24))
- .frame(maxWidth: .infinity)
- .foregroundColor(colorScheme == .dark ? .white : .black)
- .background(
- colorScheme == .dark ?
- LinearGradient(gradient: Gradient(colors: [Color(white: 0.4), Color(white: 0.3)]), startPoint: .top, endPoint: .bottom)
- :
- LinearGradient(gradient: Gradient(colors: [Color(white: 0.95), Color(white: 0.91)]), startPoint: .top, endPoint: .bottom)
- )
- .overlay(
- RoundedRectangle(cornerRadius: 10.0).stroke(.black, lineWidth: 3).opacity(colorScheme == .dark ? 0.0 : 0.2)
- )
- .cornerRadius(10.0)
- Button {
- 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, username: "bolt", iconID: 128) { success in
- if !success {
- print("FAILED LOGIN??")
- }
- else {
- self.model.sendUserInfo(username: "bolt", iconID: 128)
- self.model.getUserList()
- }
+ TextField("Login", text: $login)
+ .disableAutocorrection(true)
+ .frame(height: 48)
+ .textFieldStyle(.plain)
+ .padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
+ .background {
+ Color.black.cornerRadius(8).blendMode(.overlay)
+ }
+ SecureField("Password", text: $password)
+ .disableAutocorrection(true)
+ .textFieldStyle(.plain)
+ .frame(height: 48)
+ .padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
+ .background {
+ Color.black.cornerRadius(8).blendMode(.overlay)
+ }
+ }
+ else {
+ ProgressView(value: connectionStatusToProgress(status: model.status))
+ .frame(minHeight: 10)
+ .accentColor(colorScheme == .dark ? .white : .black)
+ }
+
+ Spacer()
+
+ HStack {
+ Button {
+ dismiss()
+ server = nil
+ model.disconnect()
+ } label: {
+ Text("Cancel")
+ }
+ .bold()
+ .padding(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24))
+ .frame(maxWidth: .infinity)
+ .foregroundColor(colorScheme == .dark ? .white : .black)
+ .background(
+ colorScheme == .dark ?
+ LinearGradient(gradient: Gradient(colors: [Color(white: 0.4), Color(white: 0.3)]), startPoint: .top, endPoint: .bottom)
+ :
+ LinearGradient(gradient: Gradient(colors: [Color(white: 0.95), Color(white: 0.91)]), startPoint: .top, endPoint: .bottom)
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 10.0).stroke(.black, lineWidth: 3).opacity(colorScheme == .dark ? 0.0 : 0.2)
+ )
+ .cornerRadius(10.0)
+ Button {
+ 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, username: "bolt", iconID: 128) { success in
+ if !success {
+ print("FAILED LOGIN??")
+ }
+ else {
+ self.model.sendUserInfo(username: "bolt", iconID: 128)
+ self.model.getUserList()
}
- } label: {
- Text("Connect")
}
- .disabled(self.model.status != .disconnected)
- .bold()
- .padding(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24))
- .frame(maxWidth: .infinity)
- .foregroundColor(colorScheme == .dark ? .white : .black)
- .background(
- colorScheme == .dark ?
- LinearGradient(gradient: Gradient(colors: [Color(white: 0.4), Color(white: 0.3)]), startPoint: .top, endPoint: .bottom)
- :
- LinearGradient(gradient: Gradient(colors: [Color(white: 0.95), Color(white: 0.91)]), startPoint: .top, endPoint: .bottom)
- )
- .overlay(
- RoundedRectangle(cornerRadius: 10.0).stroke(.black, lineWidth: 3).opacity(colorScheme == .dark ? 0.0 : 0.2)
- )
- .cornerRadius(10.0)
+ } label: {
+ Text("Connect")
}
- .padding()
+ .disabled(self.model.status != .disconnected)
+ .bold()
+ .padding(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24))
+ .frame(maxWidth: .infinity)
+ .foregroundColor(colorScheme == .dark ? .white : .black)
+ .background(
+ colorScheme == .dark ?
+ LinearGradient(gradient: Gradient(colors: [Color(white: 0.4), Color(white: 0.3)]), startPoint: .top, endPoint: .bottom)
+ :
+ LinearGradient(gradient: Gradient(colors: [Color(white: 0.95), Color(white: 0.91)]), startPoint: .top, endPoint: .bottom)
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 10.0).stroke(.black, lineWidth: 3).opacity(colorScheme == .dark ? 0.0 : 0.2)
+ )
+ .cornerRadius(10.0)
}
.padding()
- .onChange(of: model.status) {
- print("MODEL STATUS CHANGED")
- if model.server != nil && server != nil && model.server! == server! {
- if model.status == .loggedIn {
- dismiss()
- }
-// else {
-// connecting = (model.status != .disconnected)
-// }
+ }
+ .padding()
+ .onChange(of: self.model.status) {
+ print("MODEL STATUS CHANGED")
+ if self.model.server != nil && self.server != nil && self.model.server! == server! {
+ if self.model.status == .loggedIn {
+ self.dismiss()
}
+ // else {
+ // connecting = (model.status != .disconnected)
+ // }
}
- .toolbar {
- ToolbarItem(placement: .principal) {
- Text("Connect to Server")
- .font(.headline)
- }
+ }
+ .toolbar {
+ ToolbarItem(placement: .principal) {
+ Text("Connect to Server")
+ .font(.headline)
}
-// .presentationBackground(.regularMaterial, in: Color(uiColor: .systemGroupedBackground))
+ }
+ // .presentationBackground(.regularMaterial, in: Color(uiColor: .systemGroupedBackground))
.presentationBackground {
Color.clear
.background(Material.regular)
@@ -190,12 +190,12 @@ struct TrackerView: View {
}
func updateServers() async {
-// "hltracker.com"
-// "tracker.preterhuman.net"
-// "hotline.ubersoft.org"
-// "tracked.nailbat.com"
-// "hotline.duckdns.org"
-// "tracked.agent79.org"
+ // "hltracker.com"
+ // "tracker.preterhuman.net"
+ // "hotline.ubersoft.org"
+ // "tracked.nailbat.com"
+ // "hotline.duckdns.org"
+ // "tracked.agent79.org"
self.servers = await model.getServerList(tracker: "hltracker.com")
}
diff --git a/Hotline/macOS/ConnectView.swift b/Hotline/macOS/ConnectView.swift
new file mode 100644
index 0000000..8419522
--- /dev/null
+++ b/Hotline/macOS/ConnectView.swift
@@ -0,0 +1,147 @@
+import SwiftUI
+
+struct ConnectView: View {
+ @Environment(\.dismiss) private var dismiss
+ @Environment(\.modelContext) private var modelContext
+
+ @Binding var address: String
+ @Binding var login: String
+ @Binding var password: String
+
+ var action: (() -> Void)? = nil
+
+ @State private var bookmarkSheetPresented: Bool = false
+ @State private var bookmarkName: String = ""
+
+ private enum FocusFields {
+ case address
+ case login
+ case password
+ }
+
+ @FocusState private var focusedField: FocusFields?
+
+ var body: some View {
+ VStack(alignment: .center, spacing: 0) {
+ Form {
+ HStack(alignment: .top, spacing: 10) {
+ Image("Server Large")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 28, height: 28)
+
+ VStack(alignment: .leading) {
+ Text("Connect to Server")
+ Text("Enter the address of a Hotline server to connect to.")
+ .foregroundStyle(.secondary)
+ .font(.subheadline)
+ }
+ }
+
+ TextField(text: self.$address) {
+ Text("Address")
+ }
+ .focused($focusedField, equals: .address)
+
+ TextField(text: self.$login, prompt: Text("Optional")) {
+ Text("Login")
+ }
+ .focused($focusedField, equals: .login)
+
+ SecureField(text: self.$password, prompt: Text("Optional")) {
+ Text("Password")
+ }
+ .focused($focusedField, equals: .password)
+ }
+ .formStyle(.grouped)
+ .fixedSize(horizontal: false, vertical: true)
+
+ HStack {
+ Button {
+ if !self.address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ self.bookmarkSheetPresented = true
+ }
+ } label: {
+ Image(systemName: "bookmark.fill")
+ }
+ .disabled(self.address.isEmpty)
+ .controlSize(.regular)
+ .buttonStyle(.automatic)
+ .help("Bookmark server")
+
+ Spacer()
+
+ Button("Cancel") {
+ self.dismiss()
+ }
+ .controlSize(.regular)
+ .buttonStyle(.automatic)
+ .keyboardShortcut(.cancelAction)
+
+ Button("Connect") {
+ self.action?()
+// self.connectToServer()
+ }
+ .controlSize(.regular)
+ .buttonStyle(.automatic)
+ .keyboardShortcut(.defaultAction)
+ }
+ .padding(.horizontal, 20)
+ }
+// .onChange(of: self.address) {
+// let (a, p) = Server.parseServerAddressAndPort(connectAddress)
+// server.address = a
+// server.port = p
+// }
+// .onChange(of: connectLogin) {
+// server.login = connectLogin.trimmingCharacters(in: .whitespacesAndNewlines)
+// }
+// .onChange(of: connectPassword) {
+// server.password = connectPassword
+// }
+ .frame(maxWidth: 380)
+ .padding()
+ .onAppear {
+ self.focusedField = .address
+ }
+ .sheet(isPresented: self.$bookmarkSheetPresented) {
+ VStack(alignment: .leading) {
+ Text("Save Bookmark")
+ .foregroundStyle(.secondary)
+ .padding(.bottom, 4)
+ TextField("Bookmark Name", text: self.$bookmarkName)
+ .textFieldStyle(.roundedBorder)
+ .controlSize(.large)
+ }
+ .frame(width: 250)
+ .padding()
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ self.bookmarkSheetPresented = false
+ self.bookmarkName = ""
+ }
+ }
+
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Save") {
+ let name = String(self.bookmarkName.trimmingCharacters(in: .whitespacesAndNewlines))
+ if !name.isEmpty {
+ self.bookmarkSheetPresented = false
+ self.bookmarkName = ""
+
+ let (host, port) = Server.parseServerAddressAndPort(self.address)
+ let login: String? = self.login.isBlank ? nil : self.login
+ let password: String? = self.password.isBlank ? nil : self.password
+
+ if !host.isEmpty {
+ let newBookmark = Bookmark(type: .server, name: name, address: host, port: port, login: login, password: password)
+ Bookmark.add(newBookmark, context: modelContext)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/Hotline/macOS/Files/FilePreviewQuickLookView.swift b/Hotline/macOS/Files/FilePreviewQuickLookView.swift
index 0323fdd..26bd286 100644
--- a/Hotline/macOS/Files/FilePreviewQuickLookView.swift
+++ b/Hotline/macOS/Files/FilePreviewQuickLookView.swift
@@ -1,10 +1,3 @@
-//
-// FilePreviewQuickLookView.swift
-// Hotline
-//
-// QuickLook-based file preview window for all supported file types
-//
-
import SwiftUI
import UniformTypeIdentifiers
@@ -15,10 +8,11 @@ struct FilePreviewQuickLookView: View {
@Environment(\.controlActiveState) private var controlActiveState
@Environment(\.colorScheme) private var colorScheme
- @Environment(\.dismiss) var dismiss
+ @Environment(\.dismiss) private var dismiss
@Binding var info: PreviewFileInfo?
@State var preview: FilePreviewState? = nil
+
@FocusState private var focusField: FilePreviewFocus?
var body: some View {
@@ -76,14 +70,14 @@ struct FilePreviewQuickLookView: View {
.background(Color(nsColor: .textBackgroundColor))
.focused($focusField, equals: .window)
.navigationTitle(info?.name ?? "File Preview")
- .background(
+ .background {
+ if let fileURL = self.preview?.fileURL {
WindowConfigurator { window in
- if let fileURL = preview?.fileURL {
- window.representedURL = fileURL
- window.standardWindowButton(.documentIconButton)?.isHidden = false
- }
+ window.representedURL = fileURL
+ window.standardWindowButton(.documentIconButton)?.isHidden = false
}
- )
+ }
+ }
.toolbar {
if let _ = preview?.fileURL {
if let info = info {
diff --git a/Hotline/macOS/HotlinePanelView.swift b/Hotline/macOS/HotlinePanelView.swift
index 220ea97..a56cd44 100644
--- a/Hotline/macOS/HotlinePanelView.swift
+++ b/Hotline/macOS/HotlinePanelView.swift
@@ -124,7 +124,7 @@ struct HotlinePanelView: View {
.buttonStyle(.plain)
.frame(width: 20, height: 20)
.disabled(self.activeServerState == nil)
- .help("Accounts")
+ .help("Administration")
}
Button {
diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift
index 44274ea..757dbd8 100644
--- a/Hotline/macOS/ServerView.swift
+++ b/Hotline/macOS/ServerView.swift
@@ -47,10 +47,8 @@ struct ListItemView: View {
if let i = icon {
Image(i)
.resizable()
- // .renderingMode(.template)
.scaledToFit()
.frame(width: 20, height: 20)
-// .padding(.leading, 2)
.opacity(controlActiveState == .inactive ? 0.5 : 1.0)
}
@@ -74,29 +72,28 @@ extension FocusedValues {
}
struct ServerView: View {
- @Environment(\.dismiss) var dismiss
+ @Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme
@Environment(\.controlActiveState) private var controlActiveState
@Environment(\.scenePhase) private var scenePhase
@Environment(\.modelContext) private var modelContext
+ @Binding var server: Server
+
@State private var model: HotlineState = HotlineState()
@State private var state: ServerState = ServerState(selection: .chat)
@State private var agreementShown: Bool = false
@State private var connectAddress: String = ""
@State private var connectLogin: String = ""
@State private var connectPassword: String = ""
- @State private var connectNameSheetPresented: Bool = false
- @State private var connectName: String = ""
-
- @Binding var server: Server
+ @State private var connectionDisplayed: Bool = false
static var menuItems: [ServerMenuItem] = [
ServerMenuItem(type: .chat, name: "Chat", image: "Section Chat"),
ServerMenuItem(type: .board, name: "Board", image: "Section Board"),
ServerMenuItem(type: .news, name: "News", image: "Section News"),
ServerMenuItem(type: .files, name: "Files", image: "Section Files"),
- ServerMenuItem(type: .accounts, name: "Accounts", image: "Section Users"),
+ ServerMenuItem(type: .accounts, name: "Admin", image: "Section Users"),
]
static var classicMenuItems: [ServerMenuItem] = [
@@ -105,50 +102,20 @@ struct ServerView: View {
ServerMenuItem(type: .files, name: "Files", image: "Section Files"),
]
- enum FocusFields {
- case address
- case login
- case password
- }
-
- @FocusState private var focusedField: FocusFields?
-
var body: some View {
Group {
- if model.status == .disconnected {
+ if self.model.status == .disconnected {
VStack(alignment: .center) {
Spacer()
- self.connectForm
+// if self.connectionDisplayed {
+ self.connectForm
+// }
Spacer()
}
.navigationTitle("Connect to Server")
- .onAppear {
- self.focusedField = .address
- }
- }
- else if case .failed(let error) = model.status {
- VStack {
- Image("Hotline")
- .resizable()
- .renderingMode(.template)
- .scaledToFit()
- .foregroundColor(Color(hex: 0xE10000))
- .frame(width: 18)
- .opacity(controlActiveState == .inactive ? 0.5 : 1.0)
- .padding(.trailing, 4)
-
- Text("Connection Failed")
- .font(.headline)
- Text(error)
- .font(.caption)
- .foregroundStyle(.secondary)
- .multilineTextAlignment(.center)
- }
- .frame(maxWidth: 300)
- .padding()
- .navigationTitle("Connection Failed")
+// .animation(.default.delay(0.25), value: self.connectionDisplayed)
}
- else if model.status != .loggedIn {
+ else if self.model.status.isLoggingIn {
HStack {
Image("Hotline")
.resizable()
@@ -156,38 +123,38 @@ struct ServerView: View {
.scaledToFit()
.foregroundColor(Color(hex: 0xE10000))
.frame(width: 18)
- .opacity(controlActiveState == .inactive ? 0.5 : 1.0)
+ .opacity(self.controlActiveState == .inactive ? 0.5 : 1.0)
.padding(.trailing, 4)
- ProgressView(value: connectionStatusToProgress(status: model.status)) {
- Text(connectionStatusToLabel(status: model.status))
+ ProgressView(value: connectionStatusToProgress(status: self.model.status)) {
+ Text(connectionStatusToLabel(status: self.model.status))
}
- .accentColor(colorScheme == .dark ? .white : .black)
+ .accentColor(self.colorScheme == .dark ? .white : .black)
}
.frame(maxWidth: 300)
.padding()
.navigationTitle("Connecting to Server")
}
- else {
- serverView
- .environment(model)
+ else if self.model.status == .loggedIn {
+ self.serverView
+ .environment(self.model)
.onChange(of: Prefs.shared.userIconID) {
- Task { try? await model.sendUserPreferences() }
+ Task { try? await self.model.sendUserPreferences() }
}
.onChange(of: Prefs.shared.username) {
- Task { try? await model.sendUserPreferences() }
+ Task { try? await self.model.sendUserPreferences() }
}
.onChange(of: Prefs.shared.refusePrivateMessages) {
- Task { try? await model.sendUserPreferences() }
+ Task { try? await self.model.sendUserPreferences() }
}
.onChange(of: Prefs.shared.refusePrivateChat) {
- Task { try? await model.sendUserPreferences() }
+ Task { try? await self.model.sendUserPreferences() }
}
.onChange(of: Prefs.shared.enableAutomaticMessage) {
- Task { try? await model.sendUserPreferences() }
+ Task { try? await self.model.sendUserPreferences() }
}
.onChange(of: Prefs.shared.automaticMessage) {
- Task { try? await model.sendUserPreferences() }
+ Task { try? await self.model.sendUserPreferences() }
}
.toolbar {
if #available(macOS 26.0, *) {
@@ -196,7 +163,7 @@ struct ServerView: View {
.resizable()
.scaledToFit()
.frame(width: 28)
- .opacity(controlActiveState == .inactive ? 0.4 : 1.0)
+ .opacity(self.controlActiveState == .inactive ? 0.4 : 1.0)
}
.sharedBackgroundVisibility(.hidden)
}
@@ -206,7 +173,7 @@ struct ServerView: View {
.resizable()
.scaledToFit()
.frame(width: 28)
- .opacity(controlActiveState == .inactive ? 0.4 : 1.0)
+ .opacity(self.controlActiveState == .inactive ? 0.4 : 1.0)
}
}
}
@@ -214,164 +181,54 @@ struct ServerView: View {
}
.onDisappear {
Task {
- await model.disconnect()
+ await self.model.disconnect()
}
}
- .onChange(of: model.serverTitle) {
- state.serverName = model.serverTitle
+ .onChange(of: self.model.serverTitle) {
+ self.state.serverName = self.model.serverTitle
}
-// .onChange(of: model.bannerImage) {
-// state.serverBanner = model.bannerImage
-// }
-// .onChange(of: model.bannerColors) {
-// guard let backgroundColor = model.bannerColors?.backgroundColor else {
-// state.bannerBackgroundColor = nil
-// return
-// }
-// state.bannerBackgroundColor = Color(nsColor: backgroundColor)
-// }
- .alert(model.errorMessage ?? "Server Error", isPresented: $model.errorDisplayed) {
+ .alert(self.model.errorMessage ?? "Server Error", isPresented: self.$model.errorDisplayed) {
Button("OK") {}
}
- .task {
- var address = server.address
- if server.port != HotlinePorts.DefaultServerPort {
- address += ":\(server.port)"
+ .onAppear {
+ var address = self.server.address
+ if self.server.port != HotlinePorts.DefaultServerPort {
+ address += ":\(self.server.port)"
}
- connectAddress = server.address
- connectLogin = server.login
- connectPassword = server.password
+ self.connectAddress = self.server.address
+ self.connectLogin = self.server.login
+ self.connectPassword = self.server.password
// Connect to server automatically unless the option key is held down.
if !NSEvent.modifierFlags.contains(.option) {
- connectToServer()
+ self.connectToServer()
}
+
+ self.connectionDisplayed = true
}
.focusedSceneValue(\.activeHotlineModel, model)
.focusedSceneValue(\.activeServerState, state)
}
- var connectForm: some View {
- VStack(alignment: .center, spacing: 0) {
- Form {
- HStack(alignment: .top, spacing: 10) {
- Image("Server Large")
- .resizable()
- .scaledToFit()
- .frame(width: 28, height: 28)
-
- VStack(alignment: .leading) {
- Text("Connect to Server")
- Text("Enter the address of a Hotline server to connect to.")
- .foregroundStyle(.secondary)
- .font(.subheadline)
- }
- }
-
- TextField(text: $connectAddress) {
- Text("Address")
- }
- .focused($focusedField, equals: .address)
-
- TextField(text: $connectLogin, prompt: Text("Optional")) {
- Text("Login")
- }
- .focused($focusedField, equals: .login)
-
- SecureField(text: $connectPassword, prompt: Text("Optional")) {
- Text("Password")
- }
- .focused($focusedField, equals: .password)
- }
- .formStyle(.grouped)
- .fixedSize(horizontal: false, vertical: true)
-
- HStack {
- Button {
- if !connectAddress.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
- connectNameSheetPresented = true
- }
- } label: {
- Image(systemName: "bookmark.fill")
- }
- .disabled(connectAddress.isEmpty)
- .controlSize(.regular)
- .buttonStyle(.automatic)
- .help("Bookmark server")
-
- Spacer()
-
- Button("Cancel") {
- dismiss()
- }
- .controlSize(.regular)
- .buttonStyle(.automatic)
- .keyboardShortcut(.cancelAction)
-
- Button("Connect") {
- connectToServer()
- }
- .controlSize(.regular)
- .buttonStyle(.automatic)
- .keyboardShortcut(.defaultAction)
- }
- .padding(.horizontal, 20)
+ private var connectForm: some View {
+ ConnectView(address: self.$connectAddress, login: self.$connectLogin, password: self.$connectPassword) {
+ self.connectToServer()
}
- .onChange(of: connectAddress) {
- let (a, p) = Server.parseServerAddressAndPort(connectAddress)
- server.address = a
- server.port = p
+ .focusSection()
+ .onChange(of: self.connectAddress) {
+ let (a, p) = Server.parseServerAddressAndPort(self.connectAddress)
+ self.server.address = a
+ self.server.port = p
}
- .onChange(of: connectLogin) {
- server.login = connectLogin.trimmingCharacters(in: .whitespacesAndNewlines)
+ .onChange(of: self.connectLogin) {
+ self.server.login = self.connectLogin.trimmingCharacters(in: .whitespacesAndNewlines)
}
- .onChange(of: connectPassword) {
- server.password = connectPassword
- }
- .frame(maxWidth: 380)
- .padding()
- .sheet(isPresented: $connectNameSheetPresented) {
- VStack(alignment: .leading) {
- Text("Save Bookmark")
- .foregroundStyle(.secondary)
- .padding(.bottom, 4)
- TextField("Bookmark Name", text: $connectName)
- .textFieldStyle(.roundedBorder)
- .controlSize(.large)
- }
- .frame(width: 250)
- .padding()
- .toolbar {
- ToolbarItem(placement: .cancellationAction) {
- Button("Cancel") {
- connectNameSheetPresented = false
- connectName = ""
- }
- }
-
- ToolbarItem(placement: .confirmationAction) {
- Button("Save") {
- let name = String(connectName.trimmingCharacters(in: .whitespacesAndNewlines))
- if !name.isEmpty {
- connectNameSheetPresented = false
- connectName = ""
-
- let (host, port) = Server.parseServerAddressAndPort(connectAddress)
- let login: String? = connectLogin.isEmpty ? nil : connectLogin
- let password: String? = connectPassword.isEmpty ? nil : connectPassword
-
- if !host.isEmpty {
- let newBookmark = Bookmark(type: .server, name: name, address: host, port: port, login: login, password: password)
- Bookmark.add(newBookmark, context: modelContext)
- }
- }
- }
- }
- }
+ .onChange(of: self.connectPassword) {
+ self.server.password = self.connectPassword
}
}
- var navigationList: some View {
+ private var navigationList: some View {
List(selection: $state.selection) {
// Don't show news on older servers.
ForEach(model.serverVersion < 151 ? ServerView.classicMenuItems : ServerView.menuItems) { menuItem in
@@ -496,7 +353,7 @@ struct ServerView: View {
case .accounts:
AccountManagerView()
.navigationTitle(model.serverTitle)
- .navigationSubtitle("Accounts")
+ .navigationSubtitle("Administration")
.navigationSplitViewColumnWidth(min: 250, ideal: 500)
case .user(let userID):
let user = model.users.first(where: { $0.id == userID })
@@ -510,21 +367,21 @@ struct ServerView: View {
}
}
.toolbar(removing: .sidebarToggle)
-
}
-
// MARK: -
@MainActor func connectToServer() {
- guard !server.address.isEmpty else {
+ guard !self.server.address.isEmpty else {
return
}
+
+ // Set status here so it's immediate (not waiting to enter task).
+ self.model.status = .connecting
Task { @MainActor in
do {
- // login() handles everything: connect, getUserList, sendPreferences, downloadBanner
- try await model.login(
+ try await self.model.login(
server: server,
username: Prefs.shared.username,
iconID: Prefs.shared.userIconID