aboutsummaryrefslogtreecommitdiff
path: root/Hotline
diff options
context:
space:
mode:
authorRuben Beltran del Rio <git@r.bdr.sh>2025-02-05 22:27:17 +0100
committerRuben Beltran del Rio <git@r.bdr.sh>2025-02-05 22:27:17 +0100
commit45829daa856b376b1ab04d415917110b71b1dec5 (patch)
tree8e52daed6897b6f489d455736fe256cb9bd90fef /Hotline
parent5c3ea897d062a47bc8cd6255fb8c36bad2f0733f (diff)
Apply formatting
Diffstat (limited to 'Hotline')
-rw-r--r--Hotline/Application-macOS.swift73
-rw-r--r--Hotline/Hotline/HotlineClient.swift513
-rw-r--r--Hotline/Hotline/HotlineDataBuilder.swift38
-rw-r--r--Hotline/Hotline/HotlineExtensions.swift546
-rw-r--r--Hotline/Hotline/HotlineProtocol.swift710
-rw-r--r--Hotline/Hotline/HotlineTrackerClient.swift100
-rw-r--r--Hotline/Hotline/HotlineTransferClient.swift594
-rw-r--r--Hotline/Models/ApplicationState.swift6
-rw-r--r--Hotline/Models/Bookmark.swift284
-rw-r--r--Hotline/Models/ChatMessage.swift17
-rw-r--r--Hotline/Models/FileDetails.swift2
-rw-r--r--Hotline/Models/FileInfo.swift30
-rw-r--r--Hotline/Models/FilePreview.swift51
-rw-r--r--Hotline/Models/Hotline.swift699
-rw-r--r--Hotline/Models/NewsInfo.swift47
-rw-r--r--Hotline/Models/Preferences.swift134
-rw-r--r--Hotline/Models/PreviewFileInfo.swift5
-rw-r--r--Hotline/Models/Server.swift25
-rw-r--r--Hotline/Models/Tracker.swift4
-rw-r--r--Hotline/Models/TransferInfo.swift12
-rw-r--r--Hotline/Models/User.swift8
-rw-r--r--Hotline/Shared/AsyncLinkPreview.swift16
-rw-r--r--Hotline/Shared/DataAdditions.swift7
-rw-r--r--Hotline/Shared/FileIconView.swift85
-rw-r--r--Hotline/Shared/FileImageView.swift62
-rw-r--r--Hotline/Shared/HotlinePanel.swift39
-rw-r--r--Hotline/Shared/NetSocket.swift155
-rw-r--r--Hotline/Shared/TextView.swift62
-rw-r--r--Hotline/Shared/URLAdditions.swift9
-rw-r--r--Hotline/Shared/VisualEffectView.swift13
-rw-r--r--Hotline/Sounds/Application-iOS.swift6
-rw-r--r--Hotline/Utility/BookmarkDocument.swift14
-rw-r--r--Hotline/Utility/DAKeychain.swift23
-rw-r--r--Hotline/Utility/FoundationExtensions.swift28
-rw-r--r--Hotline/Utility/NSWindowBridge.swift22
-rw-r--r--Hotline/Utility/ObservableScrollView.swift28
-rw-r--r--Hotline/Utility/RegularExpressions.swift6
-rw-r--r--Hotline/Utility/SoundEffects.swift22
-rw-r--r--Hotline/Utility/SwiftUIExtensions.swift4
-rw-r--r--Hotline/Utility/TextDocument.swift12
-rw-r--r--Hotline/iOS/ChatView.swift28
-rw-r--r--Hotline/iOS/FilesView.swift53
-rw-r--r--Hotline/iOS/MessageBoardView.swift8
-rw-r--r--Hotline/iOS/NewsView.swift74
-rw-r--r--Hotline/iOS/ServerView.swift12
-rw-r--r--Hotline/iOS/TrackerView.swift302
-rw-r--r--Hotline/iOS/UsersView.swift2
-rw-r--r--Hotline/macOS/AboutView.swift114
-rw-r--r--Hotline/macOS/AccountManagerView.swift117
-rw-r--r--Hotline/macOS/ChatView.swift161
-rw-r--r--Hotline/macOS/FileDetailsView.swift62
-rw-r--r--Hotline/macOS/FilePreviewImageView.swift24
-rw-r--r--Hotline/macOS/FilePreviewTextView.swift22
-rw-r--r--Hotline/macOS/FilesView.swift182
-rw-r--r--Hotline/macOS/HotlinePanelView.swift110
-rw-r--r--Hotline/macOS/MessageBoardEditorView.swift60
-rw-r--r--Hotline/macOS/MessageBoardView.swift63
-rw-r--r--Hotline/macOS/MessageView.swift34
-rw-r--r--Hotline/macOS/NewsEditorView.swift52
-rw-r--r--Hotline/macOS/NewsItemView.swift97
-rw-r--r--Hotline/macOS/NewsView.swift96
-rw-r--r--Hotline/macOS/ServerAgreementView.swift60
-rw-r--r--Hotline/macOS/ServerMessageView.swift12
-rw-r--r--Hotline/macOS/ServerView.swift346
-rw-r--r--Hotline/macOS/SettingsView.swift47
-rw-r--r--Hotline/macOS/TrackerView.swift181
66 files changed, 3595 insertions, 3235 deletions
diff --git a/Hotline/Application-macOS.swift b/Hotline/Application-macOS.swift
index 36f9aa8..fe9d848 100644
--- a/Hotline/Application-macOS.swift
+++ b/Hotline/Application-macOS.swift
@@ -1,49 +1,52 @@
-import SwiftUI
-import SwiftData
import CloudKit
+import SwiftData
+import SwiftUI
import UniformTypeIdentifiers
@Observable
final class AppLaunchState {
static let shared = AppLaunchState()
-
+
enum LaunchState {
case loading
case launched
case terminated
}
-
+
var launchState = LaunchState.loading
}
class AppDelegate: NSObject, NSApplicationDelegate {
private var cloudKitObserverToken: Any? = nil
-
+
func applicationDidFinishLaunching(_ notification: Notification) {
AppLaunchState.shared.launchState = .launched
-
+
CKContainer.default().accountStatus { status, error in
switch status {
case .noAccount:
print("iCloud Unavailable")
-
+
// We mark CloudKit has available now since we're not waiting on
// a server sync or anything.
ApplicationState.shared.cloudKitReady = true
default:
print("iCloud Available")
-
- self.cloudKitObserverToken = NotificationCenter.default.addObserver(forName: NSPersistentCloudKitContainer.eventChangedNotification, object: nil, queue: OperationQueue.main) { [weak self] note in
+
+ self.cloudKitObserverToken = NotificationCenter.default.addObserver(
+ forName: NSPersistentCloudKitContainer.eventChangedNotification, object: nil,
+ queue: OperationQueue.main
+ ) { [weak self] note in
print("iCloud Changed!")
ApplicationState.shared.cloudKitReady = true
-
+
guard let token = self?.cloudKitObserverToken else { return }
NotificationCenter.default.removeObserver(token)
}
}
}
}
-
+
func applicationWillTerminate(_ notification: Notification) {
AppLaunchState.shared.launchState = .terminated
}
@@ -54,28 +57,30 @@ struct Application: App {
@Environment(\.scenePhase) private var scenePhase
@Environment(\.openWindow) private var openWindow
@Environment(\.openURL) private var openURL
-
+
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
-
+
@State private var hotlinePanel: HotlinePanel? = nil
@State private var selection: Bookmark? = nil
@FocusedValue(\.activeHotlineModel) private var activeHotline: Hotline?
@FocusedValue(\.activeServerState) private var activeServerState: ServerState?
-
+
private var modelContainer: ModelContainer = {
let schema = Schema([
Bookmark.self
])
- let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false, cloudKitDatabase: .private("iCloud.pizza.unlimited.hotline"))
+ let config = ModelConfiguration(
+ schema: schema, isStoredInMemoryOnly: false,
+ cloudKitDatabase: .private("iCloud.pizza.unlimited.hotline"))
let modelContainer = try! ModelContainer(for: schema, configurations: [config])
-
+
// Print local SwiftData sqlite file.
-// print(modelContainer.configurations.first?.url.path(percentEncoded: false))
-
+ // print(modelContainer.configurations.first?.url.path(percentEncoded: false))
+
return modelContainer
}()
-
+
var body: some Scene {
// MARK: Tracker Window
Window("Servers", id: "servers") {
@@ -93,7 +98,7 @@ struct Application: App {
}
}
}
-
+
// MARK: About Box
Window("About", id: "about") {
AboutView()
@@ -103,7 +108,7 @@ struct Application: App {
.windowResizability(.contentSize)
.windowStyle(.hiddenTitleBar)
.defaultPosition(.center)
- .commandsRemoved() // Remove About that was automatically added to Window menu.
+ .commandsRemoved() // Remove About that was automatically added to Window menu.
.commands {
CommandGroup(replacing: CommandGroupPlacement.appInfo) {
Button("About Hotline") {
@@ -111,7 +116,7 @@ struct Application: App {
}
}
}
-
+
// MARK: Server Window
WindowGroup(id: "server", for: Server.self) { server in
ServerView(server: server)
@@ -160,7 +165,9 @@ struct Application: App {
CommandGroup(after: .help) {
Divider()
Button("Request Feature...") {
- if let url = URL(string: "https://github.com/mierau/hotline/issues/new?labels=enhancement") {
+ if let url = URL(
+ string: "https://github.com/mierau/hotline/issues/new?labels=enhancement")
+ {
openURL(url)
}
}
@@ -219,16 +226,19 @@ struct Application: App {
Button("Show Accounts") {
activeServerState?.selection = .accounts
}
- .disabled(activeHotline?.status != .loggedIn || activeHotline?.access?.contains(.canOpenUsers) != true )
+ .disabled(
+ activeHotline?.status != .loggedIn
+ || activeHotline?.access?.contains(.canOpenUsers) != true
+ )
.keyboardShortcut(.init("5"), modifiers: .command)
}
}
-
+
// MARK: Settings Window
Settings {
SettingsView()
}
-
+
// MARK: Image Preview Window
WindowGroup(id: "preview-image", for: PreviewFileInfo.self) { $info in
FilePreviewImageView(info: $info)
@@ -238,7 +248,7 @@ struct Application: App {
.windowToolbarStyle(.unifiedCompact(showsTitle: true))
.defaultSize(width: 350, height: 150)
.defaultPosition(.center)
-
+
// MARK: Text Preview Window
WindowGroup(id: "preview-text", for: PreviewFileInfo.self) { $info in
FilePreviewTextView(info: $info)
@@ -260,23 +270,22 @@ struct Application: App {
if hotlinePanel == nil {
hotlinePanel = HotlinePanel(HotlinePanelView())
}
-
+
if hotlinePanel?.isVisible == false {
hotlinePanel?.orderFront(nil)
Prefs.shared.showBannerToolbar = true
}
}
-
+
func toggleBannerWindow() {
if hotlinePanel == nil {
hotlinePanel = HotlinePanel(HotlinePanelView())
}
-
+
if hotlinePanel?.isVisible == true {
hotlinePanel?.orderOut(nil)
Prefs.shared.showBannerToolbar = false
- }
- else {
+ } else {
hotlinePanel?.orderFront(nil)
Prefs.shared.showBannerToolbar = true
}
diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift
index 063419d..c93f223 100644
--- a/Hotline/Hotline/HotlineClient.swift
+++ b/Hotline/Hotline/HotlineClient.swift
@@ -68,51 +68,53 @@ enum HotlineClientStage {
class HotlineClient: NetSocketDelegate {
static let handshakePacket = Data([
- 0x54, 0x52, 0x54, 0x50, // 'TRTP' protocol ID
- 0x48, 0x4F, 0x54, 0x4C, // Sub-protocol ID
- 0x00, 0x01, // Version
- 0x00, 0x02, // Sub-version
+ 0x54, 0x52, 0x54, 0x50, // 'TRTP' protocol ID
+ 0x48, 0x4F, 0x54, 0x4C, // Sub-protocol ID
+ 0x00, 0x01, // Version
+ 0x00, 0x02, // Sub-version
])
-
+
static let HandshakePacket: [UInt8] = [
- 0x54, 0x52, 0x54, 0x50, // 'TRTP' protocol ID
- 0x48, 0x4F, 0x54, 0x4C, // Sub-protocol ID
- 0x00, 0x01, // Version
- 0x00, 0x02, // Sub-version
+ 0x54, 0x52, 0x54, 0x50, // 'TRTP' protocol ID
+ 0x48, 0x4F, 0x54, 0x4C, // Sub-protocol ID
+ 0x00, 0x01, // Version
+ 0x00, 0x02, // Sub-version
]
-
+
weak var delegate: HotlineClientDelegate?
-
+
var connectionStatus: HotlineClientStatus = .disconnected
var connectCallback: ((Bool) -> Void)?
-
+
private var serverAddress: String? = nil
private var serverPort: UInt16? = nil
-
- private var transactionLog: [UInt32:(HotlineTransactionType, ((HotlineTransaction, HotlineTransactionError?) -> Void)?)] = [:]
-
+
+ private var transactionLog:
+ [UInt32: (HotlineTransactionType, ((HotlineTransaction, HotlineTransactionError?) -> Void)?)] =
+ [:]
+
private var socket: NetSocket?
private var stage: HotlineClientStage = .handshake
private var packet: HotlineTransaction? = nil
private var serverVersion: UInt16? = nil
private var loginDetails: HotlineLogin? = nil
private var keepAliveTimer: Timer? = nil
-
+
init() {}
-
+
// MARK: - NetSocket Delegate
-
+
@MainActor func netsocketConnected(socket: NetSocket) {
self.updateConnectionStatus(.loggingIn)
self.stage = .handshake
}
-
+
@MainActor func netsocketDisconnected(socket: NetSocket, error: Error?) {
self.reset()
self.updateConnectionStatus(.disconnected)
self.stage = .handshake
}
-
+
@MainActor func netsocketReceived(socket: NetSocket, bytes: [UInt8]) {
switch self.stage {
case .handshake:
@@ -123,110 +125,124 @@ class HotlineClient: NetSocketDelegate {
self.receivePacket()
}
}
-
+
// MARK: - Connect
-
- @MainActor func login(address: String, port: Int, login: String?, password: String?, username: String, iconID: UInt16, callback: ((HotlineTransactionError?, String?, UInt16?) -> Void)?) {
+
+ @MainActor func login(
+ address: String, port: Int, login: String?, password: String?, username: String, iconID: UInt16,
+ callback: ((HotlineTransactionError?, String?, UInt16?) -> Void)?
+ ) {
if self.socket != nil {
self.socket?.delegate = nil
self.socket?.close()
self.socket = nil
}
self.packet = nil
-
- self.loginDetails = HotlineLogin(login: login, password: password, username: username, iconID: iconID, callback: callback)
-
+
+ self.loginDetails = HotlineLogin(
+ login: login, password: password, username: username, iconID: iconID, callback: callback)
+
self.socket = NetSocket()
self.socket?.delegate = self
-
+
self.updateConnectionStatus(.connecting)
self.socket?.connect(host: address, port: port)
self.socket?.write(HotlineClient.HandshakePacket)
}
-
+
@MainActor private func startKeepAliveTimer() {
- self.keepAliveTimer = Timer.scheduledTimer(withTimeInterval: 60 * 3, repeats: true) { [weak self] _ in
+ self.keepAliveTimer = Timer.scheduledTimer(withTimeInterval: 60 * 3, repeats: true) {
+ [weak self] _ in
DispatchQueue.main.async { [weak self] in
self?.sendKeepAlive()
}
}
}
-
+
@MainActor func receiveHandshake() {
guard let socket = self.socket,
- self.stage == .handshake,
- socket.available >= 8 else {
+ self.stage == .handshake,
+ socket.available >= 8
+ else {
return
}
-
+
var handshake: [UInt8] = socket.read(count: 8)
-
+
// Verify handshake data
guard let protocolID = handshake.consumeUInt32(),
- protocolID == "TRTP".fourCharCode() else {
+ protocolID == "TRTP".fourCharCode()
+ else {
// TODO: Close with appropriate error
socket.close()
return
}
-
+
// Check for error code
guard let errorCode = handshake.consumeUInt32(),
- errorCode == 0 else {
+ errorCode == 0
+ else {
// TODO: Close with wrapped error
socket.close()
return
}
-
+
self.stage = .packetHeader
-
+
let session = self.loginDetails!
self.loginDetails = nil
- self.sendLogin(login: session.login ?? "", password: session.password ?? "", username: session.username, iconID: session.iconID) { [weak self] err, serverName, serverVersion in
+ self.sendLogin(
+ login: session.login ?? "", password: session.password ?? "", username: session.username,
+ iconID: session.iconID
+ ) { [weak self] err, serverName, serverVersion in
self?.serverVersion = serverVersion
self?.startKeepAliveTimer()
session.callback?(err, serverName, serverVersion)
}
-
+
self.receivePacket()
}
-
+
@MainActor private func reset() {
self.transactionLog = [:]
self.packet = nil
-
+
self.keepAliveTimer?.invalidate()
self.keepAliveTimer = nil
-
+
self.socket?.close()
self.socket?.delegate = nil
self.socket = nil
}
-
+
@MainActor func disconnect() {
self.reset()
}
-
+
// MARK: - Packets
-
- @MainActor private func sendPacket(_ t: HotlineTransaction, callback: ((HotlineTransaction, HotlineTransactionError?) -> Void)? = nil) {
+
+ @MainActor private func sendPacket(
+ _ t: HotlineTransaction,
+ callback: ((HotlineTransaction, HotlineTransactionError?) -> Void)? = nil
+ ) {
guard let socket = self.socket else {
return
}
-
+
print("HotlineClient => \(t.id) \(t.type)")
-
+
if callback != nil {
self.transactionLog[t.id] = (t.type, callback)
}
-
+
socket.write(t.encoded())
}
-
+
@MainActor private func receivePacket() {
guard let socket = self.socket else {
return
}
-
+
var done: Bool = false
repeat {
switch self.stage {
@@ -235,111 +251,111 @@ class HotlineClient: NetSocketDelegate {
done = true
break
}
-
+
let headerData: [UInt8] = socket.read(count: HotlineTransaction.headerSize)
guard let packet = HotlineTransaction(from: headerData) else {
done = true
break
}
-
+
self.packet = packet
if packet.dataSize == 0 {
self.stage = .packetHeader
self.processPacket()
- }
- else {
+ } else {
self.stage = .packetBody
}
-
+
case .packetBody:
guard let packet = self.packet, socket.has(Int(packet.dataSize)) else {
done = true
break
}
-
+
let bodyData: [UInt8] = socket.read(count: Int(packet.dataSize))
self.packet?.decodeFields(from: bodyData)
self.stage = .packetHeader
self.processPacket()
-
+
default:
done = true
break
}
} while !done
}
-
+
@MainActor private func processPacket() {
guard let packet = self.packet else {
return
}
-
+
if packet.type == .reply || packet.isReply == 1 {
print("HotlineClient <= \(packet.type) to \(packet.id):")
- }
- else {
+ } else {
print("HotlineClient <= \(packet.type) \(packet.id)")
}
-
+
if packet.isReply == 1 || packet.type == .reply {
self.processReplyPacket()
return
}
-
+
// Mark packet is processed
self.packet = nil
-
- switch(packet.type) {
+
+ switch packet.type {
case .chatMessage:
- if
- let chatTextParam = packet.getField(type: .data),
+ if let chatTextParam = packet.getField(type: .data),
let chatText = chatTextParam.getString()
{
print("HotlineClient: \(chatText)")
self.delegate?.hotlineReceivedChatMessage(message: chatText)
}
-
+
case .notifyOfUserChange:
if let usernameField = packet.getField(type: .userName),
- let username = usernameField.getString(),
- let userIDField = packet.getField(type: .userID),
- let userID = userIDField.getUInt16(),
- let userIconIDField = packet.getField(type: .userIconID),
- let userIconID = userIconIDField.getUInt16(),
- let userFlagsField = packet.getField(type: .userFlags),
- let userFlags = userFlagsField.getUInt16() {
+ let username = usernameField.getString(),
+ let userIDField = packet.getField(type: .userID),
+ let userID = userIDField.getUInt16(),
+ let userIconIDField = packet.getField(type: .userIconID),
+ let userIconID = userIconIDField.getUInt16(),
+ let userFlagsField = packet.getField(type: .userFlags),
+ let userFlags = userFlagsField.getUInt16()
+ {
print("HotlineClient: User changed \(userID) \(username) icon: \(userIconID)")
-
+
let user = HotlineUser(id: userID, iconID: userIconID, status: userFlags, name: username)
self.delegate?.hotlineUserChanged(user: user)
}
-
+
case .notifyOfUserDelete:
if let userIDField = packet.getField(type: .userID),
- let userID = userIDField.getUInt16() {
+ let userID = userIDField.getUInt16()
+ {
self.delegate?.hotlineUserDisconnected(userID: userID)
}
-
+
case .disconnectMessage:
// Server disconnected us.
print("HotlineClient ❌")
self.disconnect()
-
+
case .serverMessage:
if let messageField = packet.getField(type: .data),
- let message = messageField.getString() {
-
+ let message = messageField.getString()
+ {
+
if let userIDField = packet.getField(type: .userID),
- let userID = userIDField.getUInt16() {
+ let userID = userIDField.getUInt16()
+ {
self.delegate?.hotlineReceivedPrivateMessage(userID: userID, message: message)
- }
- else {
+ } else {
self.delegate?.hotlineReceivedServerMessage(message: message)
}
}
-
+
case .showAgreement:
- if let _ = packet.getField(type: .noServerAgreement) {
+ if packet.getField(type: .noServerAgreement) != nil {
// Server told us there is no agreement to show.
return
}
@@ -348,94 +364,103 @@ class HotlineClient: NetSocketDelegate {
self.delegate?.hotlineReceivedAgreement(text: agreementText)
}
}
-
+
case .userAccess:
- print("HotlineClient: user access info \(packet.getField(type: .userAccess).debugDescription)")
+ print(
+ "HotlineClient: user access info \(packet.getField(type: .userAccess).debugDescription)")
if let accessParam = packet.getField(type: .userAccess) {
if let accessValue = accessParam.getUInt64() {
let accessOptions = HotlineUserAccessOptions(rawValue: accessValue)
self.delegate?.hotlineReceivedUserAccess(options: accessOptions)
}
}
-
+
case .newMessage:
- if let messageField = packet.getField(type: .data),
- let message = messageField.getString() {
- self.delegate?.hotlineReceivedNewsPost(message: message)
- }
-
+ if let messageField = packet.getField(type: .data),
+ let message = messageField.getString()
+ {
+ self.delegate?.hotlineReceivedNewsPost(message: message)
+ }
+
default:
- print("HotlineClient: UNKNOWN transaction \(packet.type) with \(packet.fields.count) parameters")
+ print(
+ "HotlineClient: UNKNOWN transaction \(packet.type) with \(packet.fields.count) parameters")
print(packet.fields)
}
}
-
+
@MainActor private func processReplyPacket() {
guard let packet = self.packet else {
return
}
-
+
if packet.errorCode != 0 {
let errorField: HotlineTransactionField? = packet.getField(type: .errorText)
print("HotlineClient 😵 \(packet.errorCode): \(errorField?.getString() ?? "")")
- self.delegate?.hotlineReceivedErrorMessage(code: packet.errorCode, message: errorField?.getString())
+ self.delegate?.hotlineReceivedErrorMessage(
+ code: packet.errorCode, message: errorField?.getString())
}
-
+
guard let replyCallbackInfo = self.transactionLog[packet.id] else {
print("Hmm, no reply waiting though")
return
}
-
+
self.transactionLog[packet.id] = nil
-
+
print("HotlineClient reply in response to \(replyCallbackInfo.0)")
-
+
let replyCallback = replyCallbackInfo.1
-
+
guard packet.errorCode == 0 else {
let errorField: HotlineTransactionField? = packet.getField(type: .errorText)
replyCallback?(packet, .error(packet.errorCode, errorField?.getString()))
return
}
-
+
replyCallback?(packet, nil)
}
-
+
// MARK: - Messages
-
- @MainActor func sendLogin(login: String, password: String, username: String, iconID: UInt16, callback: ((HotlineTransactionError?, String?, UInt16?) -> Void)?) {
+
+ @MainActor func sendLogin(
+ login: String, password: String, username: String, iconID: UInt16,
+ callback: ((HotlineTransactionError?, String?, UInt16?) -> Void)?
+ ) {
var t = HotlineTransaction(type: .login)
t.setFieldEncodedString(type: .userLogin, val: login)
t.setFieldEncodedString(type: .userPassword, val: password)
t.setFieldUInt16(type: .userIconID, val: iconID)
t.setFieldString(type: .userName, val: username)
t.setFieldUInt32(type: .versionNumber, val: 123)
-
+
self.sendPacket(t) { [weak self] reply, err in
self?.updateConnectionStatus(.loggedIn)
-
+
var serverVersion: UInt16?
var serverName: String?
-
- if
- let serverVersionField = reply.getField(type: .versionNumber),
- let serverVersionValue = serverVersionField.getUInt16() {
+
+ if let serverVersionField = reply.getField(type: .versionNumber),
+ let serverVersionValue = serverVersionField.getUInt16()
+ {
serverVersion = serverVersionValue
print("SERVER VERSION: \(serverVersionValue)")
}
-
- if
- let serverNameField = reply.getField(type: .serverName),
- let serverNameValue = serverNameField.getString() {
+
+ if let serverNameField = reply.getField(type: .serverName),
+ let serverNameValue = serverNameField.getString()
+ {
serverName = serverNameValue
print("SERVER NAME: \(serverNameValue)")
}
-
+
callback?(err, serverName, serverVersion)
}
}
-
- @MainActor func sendSetClientUserInfo(username: String, iconID: UInt16, options: HotlineUserOptions = [], autoresponse: String? = nil) {
+
+ @MainActor func sendSetClientUserInfo(
+ username: String, iconID: UInt16, options: HotlineUserOptions = [], autoresponse: String? = nil
+ ) {
var t = HotlineTransaction(type: .setClientUserInfo)
t.setFieldString(type: .userName, val: username)
t.setFieldUInt16(type: .userIconID, val: iconID)
@@ -443,37 +468,41 @@ class HotlineClient: NetSocketDelegate {
if let text = autoresponse {
t.setFieldString(type: .automaticResponse, val: text)
}
-
+
self.sendPacket(t)
}
-
+
@MainActor func sendAgree(username: String, iconID: UInt16, options: HotlineUserOptions) {
let t = HotlineTransaction(type: .agreed)
-// t.setFieldString(type: .userName, val: username)
-// t.setFieldUInt16(type: .userIconID, val: iconID)
-// t.setFieldUInt8(type: .options, val: options.rawValue)
+ // t.setFieldString(type: .userName, val: username)
+ // t.setFieldUInt16(type: .userIconID, val: iconID)
+ // t.setFieldUInt8(type: .options, val: options.rawValue)
self.sendPacket(t)
}
-
- @MainActor func sendChat(message: String, encoding: String.Encoding = .utf8, announce: Bool = false) {
+
+ @MainActor func sendChat(
+ message: String, encoding: String.Encoding = .utf8, announce: Bool = false
+ ) {
var t = HotlineTransaction(type: .sendChat)
t.setFieldString(type: .data, val: message, encoding: encoding)
t.setFieldUInt16(type: .chatOptions, val: announce ? 1 : 0)
self.sendPacket(t)
}
-
- @MainActor func sendInstantMessage(message: String, userID: UInt16, encoding: String.Encoding = .utf8) {
+
+ @MainActor func sendInstantMessage(
+ message: String, userID: UInt16, encoding: String.Encoding = .utf8
+ ) {
var t = HotlineTransaction(type: .sendInstantMessage)
t.setFieldUInt16(type: .userID, val: userID)
t.setFieldUInt32(type: .options, val: 1)
t.setFieldString(type: .data, val: message, encoding: encoding)
self.sendPacket(t)
}
-
+
@MainActor func sendGetUserList() {
let t = HotlineTransaction(type: .getUserNameList)
self.sendPacket(t) { [weak self] reply, err in
- var newUsers: [UInt16:HotlineUser] = [:]
+ var newUsers: [UInt16: HotlineUser] = [:]
var newUserList: [HotlineUser] = []
for u in reply.getFieldList(type: .userNameWithInfo) {
let user = u.getUser()
@@ -483,21 +512,22 @@ class HotlineClient: NetSocketDelegate {
self?.delegate?.hotlineReceivedUserList(users: newUserList)
}
}
-
+
@MainActor func sendGetMessageBoard(callback: ((HotlineTransactionError?, [String]) -> Void)?) {
let t = HotlineTransaction(type: .getMessageBoard)
self.sendPacket(t) { reply, err in
guard err == nil,
- let textField = reply.getField(type: .data),
- let text = textField.getString() else {
+ let textField = reply.getField(type: .data),
+ let text = textField.getString()
+ else {
callback?(err, [])
return
}
-
+
var messages: [String] = []
let matches = text.matches(of: RegularExpressions.messageBoardDivider)
var start = text.startIndex
-
+
if matches.count > 0 {
for match in matches {
let range = match.range
@@ -505,31 +535,32 @@ class HotlineClient: NetSocketDelegate {
messages.append(messageText.convertingLinksToMarkdown())
start = range.upperBound
}
- }
- else {
+ } else {
messages.append(text)
}
-
+
callback?(err, messages)
}
}
-
+
@MainActor func sendPostMessageBoard(text: String) {
guard text.count > 0 else {
return
}
-
+
var t = HotlineTransaction(type: .oldPostNews)
t.setFieldString(type: .data, val: text.convertingLineEndings(to: .cr), encoding: .macOSRoman)
self.sendPacket(t)
}
-
- @MainActor func sendGetNewsCategories(path: [String] = [], callback: (([HotlineNewsCategory]) -> Void)?) {
+
+ @MainActor func sendGetNewsCategories(
+ path: [String] = [], callback: (([HotlineNewsCategory]) -> Void)?
+ ) {
var t = HotlineTransaction(type: .getNewsCategoryNameList)
if !path.isEmpty {
t.setFieldPath(type: .newsPath, val: path)
}
-
+
self.sendPacket(t) { reply, err in
var categories: [HotlineNewsCategory] = []
for categoryListItem in reply.getFieldList(type: .newsCategoryListData15) {
@@ -540,31 +571,37 @@ class HotlineClient: NetSocketDelegate {
callback?(categories)
}
}
-
- @MainActor func sendGetNewsArticle(id articleID: UInt32, path: [String], flavor: String, callback: ((String?) -> Void)? = nil) {
+
+ @MainActor func sendGetNewsArticle(
+ id articleID: UInt32, path: [String], flavor: String, callback: ((String?) -> Void)? = nil
+ ) {
var t = HotlineTransaction(type: .getNewsArticleData)
t.setFieldPath(type: .newsPath, val: path)
t.setFieldUInt32(type: .newsArticleID, val: articleID)
t.setFieldString(type: .newsArticleDataFlavor, val: flavor, encoding: .ascii)
-
+
self.sendPacket(t) { reply, err in
guard err == nil,
- let articleData = reply.getField(type: .newsArticleData),
- let articleString = articleData.getString() else {
+ let articleData = reply.getField(type: .newsArticleData),
+ let articleString = articleData.getString()
+ else {
callback?(nil)
return
}
-
+
callback?(articleString)
}
}
-
- @MainActor func postNewsArticle(title: String, text: String, path: [String] = [], parentID: UInt32 = 0, callback: ((Bool) -> Void)? = nil) {
+
+ @MainActor func postNewsArticle(
+ title: String, text: String, path: [String] = [], parentID: UInt32 = 0,
+ callback: ((Bool) -> Void)? = nil
+ ) {
guard !path.isEmpty else {
callback?(false)
return
}
-
+
var t = HotlineTransaction(type: .postNewsArticle)
t.setFieldPath(type: .newsPath, val: path)
t.setFieldUInt32(type: .newsArticleID, val: parentID)
@@ -572,9 +609,9 @@ class HotlineClient: NetSocketDelegate {
t.setFieldString(type: .newsArticleDataFlavor, val: "text/plain")
t.setFieldUInt32(type: .newsArticleFlags, val: 0)
t.setFieldString(type: .newsArticleData, val: text.convertingLineEndings(to: .cr))
-
+
print("HotlineClient postings \(title) under \(parentID)")
-
+
self.sendPacket(t) { reply, err in
guard err == nil else {
callback?(false)
@@ -583,10 +620,10 @@ class HotlineClient: NetSocketDelegate {
callback?(true)
}
}
-
+
@MainActor func sendGetAccounts(callback: (([HotlineAccount]) -> Void)? = nil) {
let t = HotlineTransaction(type: .getAccounts)
-
+
self.sendPacket(t) { reply, err in
guard err == nil else {
callback?([])
@@ -594,30 +631,33 @@ class HotlineClient: NetSocketDelegate {
}
let accountFields = reply.getFieldList(type: .data)
-
+
var accounts: [HotlineAccount] = []
for data in accountFields {
accounts.append(data.getAcccount())
}
-
+
accounts.sort { $0.login < $1.login }
-
+
callback?(accounts)
}
}
-
- @MainActor func sendGetNewsArticles(path: [String] = [], callback: (([HotlineNewsArticle]) -> Void)? = nil) {
+
+ @MainActor func sendGetNewsArticles(
+ path: [String] = [], callback: (([HotlineNewsArticle]) -> Void)? = nil
+ ) {
var t = HotlineTransaction(type: .getNewsArticleNameList)
if !path.isEmpty {
t.setFieldPath(type: .newsPath, val: path)
}
self.sendPacket(t) { reply, err in
guard err == nil,
- let articleData = reply.getField(type: .newsArticleListData) else {
+ let articleData = reply.getField(type: .newsArticleListData)
+ else {
callback?([])
return
}
-
+
var articles: [HotlineNewsArticle] = []
let newsList = articleData.getNewsList()
for art in newsList.articles {
@@ -629,31 +669,33 @@ class HotlineClient: NetSocketDelegate {
callback?(articles)
}
}
-
+
@MainActor func sendGetFileList(path: [String] = [], callback: (([HotlineFile]) -> Void)? = nil) {
var t = HotlineTransaction(type: .getFileNameList)
if !path.isEmpty {
t.setFieldPath(type: .filePath, val: path)
}
-
+
self.sendPacket(t) { reply, err in
guard err == nil else {
callback?([])
return
}
-
+
var files: [HotlineFile] = []
for fi in reply.getFieldList(type: .fileNameWithInfo) {
let file = fi.getFile()
file.path = path + [file.name]
files.append(file)
}
-
+
callback?(files)
}
}
-
- @MainActor func sendDeleteFile(name fileName: String, path filePath: [String], callback: ((Bool) -> Void)? = nil) {
+
+ @MainActor func sendDeleteFile(
+ name fileName: String, path filePath: [String], callback: ((Bool) -> Void)? = nil
+ ) {
var t = HotlineTransaction(type: .deleteFile)
t.setFieldString(type: .fileName, val: fileName)
t.setFieldPath(type: .filePath, val: filePath)
@@ -661,25 +703,26 @@ class HotlineClient: NetSocketDelegate {
callback?(err == nil)
}
}
-
- @MainActor func sendGetFileInfo(name fileName: String, path filePath: [String], callback: ((FileDetails?) -> Void)? = nil) {
+
+ @MainActor func sendGetFileInfo(
+ name fileName: String, path filePath: [String], callback: ((FileDetails?) -> Void)? = nil
+ ) {
var t = HotlineTransaction(type: .getFileInfo)
t.setFieldString(type: .fileName, val: fileName)
t.setFieldPath(type: .filePath, val: filePath)
-
+
self.sendPacket(t) { reply, err in
guard err == nil,
- let fileName = reply.getField(type: .fileName)?.getString(),
- let fileCreator = reply.getField(type: .fileCreatorString)?.getString(),
- let fileType = reply.getField(type: .fileTypeString)?.getString(),
- let _ = reply.getField(type: .fileTypeString)?.getString(),
- let fileCreateDate = reply.getField(type: .fileCreateDate)?.data.readDate(at: 0),
- let fileModifyDate = reply.getField(type: .fileModifyDate)?.data.readDate(at: 0)
- else {
+ let fileName = reply.getField(type: .fileName)?.getString(),
+ let fileCreator = reply.getField(type: .fileCreatorString)?.getString(),
+ let fileType = reply.getField(type: .fileTypeString)?.getString(),
+ reply.getField(type: .fileTypeString)?.getString() != nil,
+ let fileCreateDate = reply.getField(type: .fileCreateDate)?.data.readDate(at: 0),
+ let fileModifyDate = reply.getField(type: .fileModifyDate)?.data.readDate(at: 0)
+ else {
callback?(nil)
return
}
-
// Size field is not included in server reply for folders
let fileSize = reply.getField(type: .fileSize)?.getInteger() ?? 0
@@ -687,45 +730,49 @@ class HotlineClient: NetSocketDelegate {
// Comment field is not included for if no comment present
let fileComment = reply.getField(type: .fileComment)?.getString() ?? ""
- callback?(FileDetails(name: fileName, path: filePath, size: fileSize, comment: fileComment, type: fileType, creator: fileCreator,
- created: fileCreateDate, modified: fileModifyDate))
+ callback?(
+ FileDetails(
+ name: fileName, path: filePath, size: fileSize, comment: fileComment, type: fileType,
+ creator: fileCreator,
+ created: fileCreateDate, modified: fileModifyDate))
}
}
-
- @MainActor func sendCreateUser(name: String, login: String, password: String?, access: uint64) {
+ @MainActor func sendCreateUser(name: String, login: String, password: String?, access: uint64) {
var t = HotlineTransaction(type: .newUser)
-
+
t.setFieldString(type: .userName, val: name)
t.setFieldEncodedString(type: .userLogin, val: login)
t.setFieldUInt64(type: .userAccess, val: access)
-
+
if let password {
t.setFieldEncodedString(type: .userPassword, val: password)
}
-
+
self.sendPacket(t)
// TODO: handle errors
}
- @MainActor func sendSetUser(name: String, login: String, newLogin: String?, password: String?, access: uint64) {
+ @MainActor func sendSetUser(
+ name: String, login: String, newLogin: String?, password: String?, access: uint64
+ ) {
var t = HotlineTransaction(type: .setUser)
t.setFieldString(type: .userName, val: name)
t.setFieldUInt64(type: .userAccess, val: access)
-
+
if let newLogin {
t.setFieldEncodedString(type: .data, val: login)
t.setFieldEncodedString(type: .userLogin, val: newLogin)
} else {
t.setFieldEncodedString(type: .userLogin, val: login)
}
-
+
// In the setUser transaction, there are 3 possibilities for the password field:
// 1. If the password was not modified, the password field is sent with a zero byte.
if password == nil {
t.setFieldUInt8(type: .userPassword, val: 0)
}
-
+
// 2. If the transaction should update the password, the password field is sent with the new password.
if let password, password != "" {
t.setFieldEncodedString(type: .userPassword, val: password)
@@ -735,16 +782,19 @@ class HotlineClient: NetSocketDelegate {
self.sendPacket(t)
// TODO: handle errors
}
-
+
@MainActor func sendDeleteUser(login: String) {
var t = HotlineTransaction(type: .deleteUser)
t.setFieldEncodedString(type: .userLogin, val: login)
-
+
self.sendPacket(t)
// TODO: handle errors
}
-
- @MainActor func sendSetFileInfo(fileName: String, path filePath: [String], fileNewName: String?, comment: String?, encoding: String.Encoding = .utf8) {
+
+ @MainActor func sendSetFileInfo(
+ fileName: String, path filePath: [String], fileNewName: String?, comment: String?,
+ encoding: String.Encoding = .utf8
+ ) {
var t = HotlineTransaction(type: .setFileInfo)
t.setFieldString(type: .fileName, val: fileName, encoding: encoding)
t.setFieldPath(type: .filePath, val: filePath)
@@ -752,67 +802,76 @@ class HotlineClient: NetSocketDelegate {
if fileNewName != nil {
t.setFieldString(type: .fileNewName, val: fileNewName!, encoding: encoding)
}
-
+
if comment != nil {
t.setFieldString(type: .fileComment, val: comment!, encoding: encoding)
}
self.sendPacket(t)
}
-
- @MainActor func sendDownloadFile(name fileName: String, path filePath: [String], preview: Bool = false, callback: ((Bool, UInt32?, Int?, Int?, Int?) -> Void)? = nil) {
+
+ @MainActor func sendDownloadFile(
+ name fileName: String, path filePath: [String], preview: Bool = false,
+ callback: ((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)
}
-
+
self.sendPacket(t) { reply, err in
guard err == nil,
- let transferSizeField = reply.getField(type: .transferSize),
- let transferSize = transferSizeField.getInteger(),
- let transferReferenceField = reply.getField(type: .referenceNumber),
- let referenceNumber = transferReferenceField.getUInt32() else {
+ let transferSizeField = reply.getField(type: .transferSize),
+ let transferSize = transferSizeField.getInteger(),
+ let transferReferenceField = reply.getField(type: .referenceNumber),
+ let referenceNumber = transferReferenceField.getUInt32()
+ else {
callback?(false, nil, nil, nil, nil)
return
}
-
+
let transferFileSizeField = reply.getField(type: .fileSize)
let transferFileSize = transferFileSizeField?.getInteger()
let transferWaitingCountField = reply.getField(type: .waitingCount)
let transferWaitingCount = transferWaitingCountField?.getInteger()
-
- callback?(true, referenceNumber, transferSize, transferFileSize ?? transferSize, transferWaitingCount)
+
+ callback?(
+ true, referenceNumber, transferSize, transferFileSize ?? transferSize, transferWaitingCount)
}
}
-
- @MainActor func sendUploadFile(name fileName: String, path filePath: [String], callback: ((Bool, UInt32?) -> Void)? = nil) {
+
+ @MainActor func sendUploadFile(
+ name fileName: String, path filePath: [String], callback: ((Bool, UInt32?) -> Void)? = nil
+ ) {
var t = HotlineTransaction(type: .uploadFile)
t.setFieldString(type: .fileName, val: fileName)
t.setFieldPath(type: .filePath, val: filePath)
-
+
self.sendPacket(t) { reply, err in
guard err == nil,
- let transferReferenceField = reply.getField(type: .referenceNumber),
- let referenceNumber = transferReferenceField.getUInt32() else {
+ let transferReferenceField = reply.getField(type: .referenceNumber),
+ let referenceNumber = transferReferenceField.getUInt32()
+ else {
callback?(false, nil)
return
}
-
+
callback?(true, referenceNumber)
}
}
-
+
@MainActor func sendDownloadBanner(callback: ((Bool, UInt32?, Int?) -> Void)? = nil) {
let t = HotlineTransaction(type: .downloadBanner)
-
+
self.sendPacket(t) { reply, err in
guard err == nil,
- let transferSizeField = reply.getField(type: .transferSize),
- let transferSize = transferSizeField.getInteger(),
- let transferReferenceField = reply.getField(type: .referenceNumber),
- let referenceNumber = transferReferenceField.getUInt32() else {
+ let transferSizeField = reply.getField(type: .transferSize),
+ let transferSize = transferSizeField.getInteger(),
+ let transferReferenceField = reply.getField(type: .referenceNumber),
+ let referenceNumber = transferReferenceField.getUInt32()
+ else {
callback?(false, nil, nil)
return
}
@@ -820,20 +879,18 @@ class HotlineClient: NetSocketDelegate {
callback?(true, referenceNumber, transferSize)
}
}
-
+
@MainActor private func sendKeepAlive() {
print("HotlineClient: Sending keep alive")
if let v = self.serverVersion, v >= 185 {
let t = HotlineTransaction(type: .connectionKeepAlive)
self.sendPacket(t)
- }
- else {
+ } else {
let t = HotlineTransaction(type: .getUserNameList)
self.sendPacket(t)
}
}
-
-
+
// MARK: - Utility
@MainActor private func updateConnectionStatus(_ status: HotlineClientStatus) {
diff --git a/Hotline/Hotline/HotlineDataBuilder.swift b/Hotline/Hotline/HotlineDataBuilder.swift
index 440518a..7ec4ef7 100644
--- a/Hotline/Hotline/HotlineDataBuilder.swift
+++ b/Hotline/Hotline/HotlineDataBuilder.swift
@@ -3,9 +3,9 @@ import Foundation
enum DataEndianness {
case big
case little
-
+
static let system: DataEndianness = {
- let number: UInt32 = 0x12345678
+ let number: UInt32 = 0x1234_5678
return number == number.littleEndian ? .little : .big
}()
}
@@ -13,74 +13,74 @@ enum DataEndianness {
@resultBuilder
struct DataBuilder {
static var defaultEndian: DataEndianness = .system
-
+
static func buildBlock(_ components: Data...) -> Data {
components.reduce(Data(), +)
}
-
+
static func buildExpression(_ expression: Data) -> Data {
expression
}
-
+
static func buildExpression(_ expression: String) -> Data {
Data(expression.utf8)
}
-
+
static func buildExpression(_ expression: (String, String.Encoding)) -> Data {
expression.0.data(using: expression.1) ?? Data()
}
-
+
static func buildExpression(_ expression: [UInt8]) -> Data {
Data(expression)
}
-
+
static func buildExpression(_ expression: Date) -> Data {
var dateData = Data()
-
+
var year: UInt16 = 0
var msecs: UInt16 = 0
var secs: UInt32 = 0
-
+
if let components = expression.hotlineDateComponents() {
year = components.year
secs = components.seconds
msecs = components.milliseconds
}
-
+
year = DataBuilder.defaultEndian == .little ? year.littleEndian : year.bigEndian
secs = DataBuilder.defaultEndian == .little ? secs.littleEndian : secs.bigEndian
msecs = DataBuilder.defaultEndian == .little ? msecs.littleEndian : msecs.bigEndian
-
+
dateData.append(withUnsafeBytes(of: year) { Data($0) })
dateData.append(withUnsafeBytes(of: msecs) { Data($0) })
dateData.append(withUnsafeBytes(of: secs) { Data($0) })
-
+
return dateData
}
-
+
static func buildExpression<T: FixedWidthInteger>(_ expression: (T, DataEndianness)) -> Data {
let value = expression.1 == .little ? expression.0.littleEndian : expression.0.bigEndian
return withUnsafeBytes(of: value) { Data($0) }
}
-
+
static func buildExpression<T: FixedWidthInteger>(_ expression: T) -> Data {
buildExpression((expression, DataBuilder.defaultEndian))
}
-
+
// Support for if statements
static func buildEither(first component: Data) -> Data {
component
}
-
+
static func buildEither(second component: Data) -> Data {
component
}
-
+
// Support for optionals
static func buildOptional(_ component: Data?) -> Data {
component ?? Data()
}
-
+
static func buildFinalResult(_ component: Data) -> Data {
var data = Data()
data.append(component)
diff --git a/Hotline/Hotline/HotlineExtensions.swift b/Hotline/Hotline/HotlineExtensions.swift
index 81387bf..44a3c38 100644
--- a/Hotline/Hotline/HotlineExtensions.swift
+++ b/Hotline/Hotline/HotlineExtensions.swift
@@ -1,10 +1,10 @@
-import Foundation
import CoreServices
+import Foundation
enum LineEnding {
- case lf // Unix-style (\n)
- case crlf // Windows-style (\r\n)
- case cr // Classic Mac-style (\r)
+ case lf // Unix-style (\n)
+ case crlf // Windows-style (\r\n)
+ case cr // Classic Mac-style (\r)
}
extension URL {
@@ -18,10 +18,11 @@ extension String {
let lf = "\n"
let crlf = "\r\n"
let cr = "\r"
-
+
// Normalize all line endings to LF (\n)
- let normalizedString = self.replacingOccurrences(of: cr, with: lf).replacingOccurrences(of: crlf, with: lf)
-
+ let normalizedString = self.replacingOccurrences(of: cr, with: lf).replacingOccurrences(
+ of: crlf, with: lf)
+
// Replace normalized LF (\n) line endings with the target line ending
switch targetEnding {
case .lf:
@@ -32,20 +33,20 @@ extension String {
return normalizedString.replacingOccurrences(of: lf, with: cr)
}
}
-
+
func replyToString() -> String {
if self.range(of: "^Re:", options: [.regularExpression, .caseInsensitive]) != nil {
return String(self)
}
return "Re: \(self)"
}
-
+
func fourCharCode() -> FourCharCode {
guard self.count == 4 else {
return 0
}
-
- return self.utf16.reduce(0, {$0 << 8 + FourCharCode($1)})
+
+ return self.utf16.reduce(0, { $0 << 8 + FourCharCode($1) })
}
}
@@ -58,17 +59,17 @@ extension FileManager {
"qxd": "XPR3".fourCharCode(),
"indd": "InDn".fourCharCode(),
"idd": "InDn".fourCharCode(),
-
+
// Spreadsheets
"csv": "ttxt".fourCharCode(),
"xls": "XCEL".fourCharCode(),
"xlsx": "XCEL".fourCharCode(),
"numbers": "NMBR".fourCharCode(),
-
+
// Presentations
"ppt": "PPT3".fourCharCode(),
"key": "KEYN".fourCharCode(),
-
+
// Images
"ai": "ART5".fourCharCode(),
"jpg": "8BIM".fourCharCode(),
@@ -83,13 +84,13 @@ extension FileManager {
"psd": "8BIM".fourCharCode(),
"pict": "8BIM".fourCharCode(),
"tga": "8BIM".fourCharCode(),
-
+
// Archives
"zip": "SITx".fourCharCode(),
"sit": "SITx".fourCharCode(),
"dmg": "ddsk".fourCharCode(),
"sea": "SITx".fourCharCode(),
-
+
// Programming
"swift": "R*ch".fourCharCode(),
"java": "R*ch".fourCharCode(),
@@ -107,7 +108,7 @@ extension FileManager {
"log": "R*ch".fourCharCode(),
"xml": "R*ch".fourCharCode(),
]
-
+
static var HFSTypeToExtension: [String: String] = [
// Documents
"text": "txt",
@@ -119,16 +120,16 @@ extension FileManager {
"pdf ": "pdf",
"xdoc": "qxd",
"indd": "ind",
-
+
// Spreadsheets
"xls ": "xls",
"xlsx": "xlsx",
"nmbr": "numbers",
-
+
// Presentations
"ppt3": "ppt",
"keyn": "key",
-
+
// Images
"jpeg": "jpg",
"pngf": "png",
@@ -141,7 +142,7 @@ extension FileManager {
"pict": "pict",
"tpic": "tga",
"swfl": "swf",
-
+
// Audio
"mp3 ": "mp3",
"m4a ": "m4a",
@@ -150,7 +151,7 @@ extension FileManager {
"aiff": "aiff",
"midi": "midi",
"snd ": "snd",
-
+
// Video
"m4v ": "mp4",
"mpg ": "mpeg",
@@ -158,7 +159,7 @@ extension FileManager {
"moov": "mov",
"vfw ": "avi",
"wmv ": "wmv",
-
+
// Archives
"zip ": "zip",
"rar ": "rar",
@@ -170,11 +171,11 @@ extension FileManager {
"sit5": "sit",
"udif": "dmg",
"cdrw": "cdr",
-
+
// Fonts
"tfil": "ttf",
]
-
+
static var extensionToHFSType: [String: UInt32] = [
// Documents
"txt": "TEXT".fourCharCode(),
@@ -187,17 +188,17 @@ extension FileManager {
"qxd": "XDOC".fourCharCode(),
"indd": "inDd".fourCharCode(),
"idd": "inDd".fourCharCode(),
-
+
// Spreadsheets
"csv": "TEXT".fourCharCode(),
"xls": "XLS ".fourCharCode(),
"xlsx": "XLSX".fourCharCode(),
"numbers": "NMBR".fourCharCode(),
-
+
// Presentations
"ppt": "PPT3".fourCharCode(),
"key": "KEYN".fourCharCode(),
-
+
// Images
"jpg": "JPEG".fourCharCode(),
"jpeg": "JPEG".fourCharCode(),
@@ -212,7 +213,7 @@ extension FileManager {
"pict": "PICT".fourCharCode(),
"tga": "TPIC".fourCharCode(),
"swf": "SWFL".fourCharCode(),
-
+
// Audio
"mp3": "Mp3 ".fourCharCode(),
"m4a": "M4A ".fourCharCode(),
@@ -221,7 +222,7 @@ extension FileManager {
"aiff": "AIFF".fourCharCode(),
"midi": "Midi".fourCharCode(),
"snd": "snd ".fourCharCode(),
-
+
// Video
"mp4": "M4V ".fourCharCode(),
"mpeg": "MPG ".fourCharCode(),
@@ -229,7 +230,7 @@ extension FileManager {
"mov": "MooV".fourCharCode(),
"avi": "VfW ".fourCharCode(),
"wmv": "WMV ".fourCharCode(),
-
+
// Archives
"zip": "ZIP ".fourCharCode(),
"rar": "RAR ".fourCharCode(),
@@ -240,7 +241,7 @@ extension FileManager {
"dmg": "udif".fourCharCode(),
"sea": "SITx".fourCharCode(),
"cdr": "CDRW".fourCharCode(),
-
+
// Programming
"swift": "TEXT".fourCharCode(),
"java": "TEXT".fourCharCode(),
@@ -257,19 +258,20 @@ extension FileManager {
"md": "TEXT".fourCharCode(),
"log": "TEXT".fourCharCode(),
"xml": "TEXT".fourCharCode(),
-
+
// Fonts
"ttf": "tfil".fourCharCode(),
]
-
+
func getHFSTypeAndCreator(_ fileURL: URL) throws -> (hfsCreator: UInt32, hfsType: UInt32) {
let filePath = fileURL.path(percentEncoded: false)
let fileAttributes: [FileAttributeKey: Any] = try self.attributesOfItem(atPath: filePath)
let fileExtension = fileURL.pathExtension.lowercased()
-
+
var creator: UInt32 = "????".fourCharCode()
if let creatorCode: NSNumber = fileAttributes[.hfsCreatorCode] as? NSNumber,
- creatorCode.uint32Value != 0 {
+ creatorCode.uint32Value != 0
+ {
creator = creatorCode.uint32Value
}
if creator == "????".fourCharCode() {
@@ -277,10 +279,11 @@ extension FileManager {
creator = possibleCreator
}
}
-
+
var type: UInt32 = "????".fourCharCode()
if let typeCode: NSNumber = fileAttributes[.hfsTypeCode] as? NSNumber,
- typeCode.uint32Value != 0 {
+ typeCode.uint32Value != 0
+ {
type = typeCode.uint32Value
}
if type == "????".fourCharCode() {
@@ -288,175 +291,202 @@ extension FileManager {
type = possibleType
}
}
-
+
return (hfsCreator: creator, hfsType: type)
}
-
+
func getFinderComment(_ fileURL: URL) throws -> String {
let filePath = fileURL.path(percentEncoded: false)
let fileAttributes: [FileAttributeKey: Any] = try self.attributesOfItem(atPath: filePath)
-
- if let extendedAttributesData = fileAttributes[FileAttributeKey(rawValue: "NSFileExtendedAttributes")],
- let extendedAttributes = extendedAttributesData as? [FileAttributeKey: Any] {
+
+ if let extendedAttributesData = fileAttributes[
+ FileAttributeKey(rawValue: "NSFileExtendedAttributes")],
+ let extendedAttributes = extendedAttributesData as? [FileAttributeKey: Any]
+ {
print(extendedAttributes)
-
- if let commentPlistDataAttribute = extendedAttributes[FileAttributeKey(rawValue: "com.apple.metadata:kMDItemFinderComment")],
- let commentPlistData = commentPlistDataAttribute as? Data {
- if let plist = try? PropertyListSerialization.propertyList(from: commentPlistData, options: [], format: nil),
- let plistString = plist as? String {
+
+ if let commentPlistDataAttribute = extendedAttributes[
+ FileAttributeKey(rawValue: "com.apple.metadata:kMDItemFinderComment")],
+ let commentPlistData = commentPlistDataAttribute as? Data
+ {
+ if let plist = try? PropertyListSerialization.propertyList(
+ from: commentPlistData, options: [], format: nil),
+ let plistString = plist as? String
+ {
return plistString
}
}
}
-
+
return ""
}
-
+
func getCreatedAndModifiedDates(_ fileURL: URL) -> (createdDate: Date, modifiedDate: Date) {
let filePath = fileURL.path(percentEncoded: false)
-
- guard let fileAttributes: [FileAttributeKey: Any] = try? self.attributesOfItem(atPath: filePath),
- let createdNSDate: NSDate = fileAttributes[.creationDate] as? NSDate,
- let modifiedNSDate: NSDate = fileAttributes[.modificationDate] as? NSDate else {
+
+ guard
+ let fileAttributes: [FileAttributeKey: Any] = try? self.attributesOfItem(atPath: filePath),
+ let createdNSDate: NSDate = fileAttributes[.creationDate] as? NSDate,
+ let modifiedNSDate: NSDate = fileAttributes[.modificationDate] as? NSDate
+ else {
return (createdDate: Date(), modifiedDate: Date())
}
-
+
print("GOT CREATED DATE: ", createdNSDate)
-
+
return (createdDate: createdNSDate as Date, modifiedDate: modifiedNSDate as Date)
}
-
+
func setExtendedFileAttribute(_ fileURL: URL, name: String, value: Data) throws {
try fileURL.withUnsafeFileSystemRepresentation { fileSystemPath in
let result = value.withUnsafeBytes { [count = value.count] in
setxattr(fileSystemPath, name, $0.baseAddress, count, 0, 0)
}
-
- guard result >= 0 else { throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno), userInfo: [NSLocalizedDescriptionKey: String(cString: strerror(errno))]) }
+
+ guard result >= 0 else {
+ throw NSError(
+ domain: NSPOSIXErrorDomain, code: Int(errno),
+ userInfo: [NSLocalizedDescriptionKey: String(cString: strerror(errno))])
+ }
}
}
-
+
func removeExtendedFileAttribute(_ fileURL: URL, name: String) throws {
try fileURL.withUnsafeFileSystemRepresentation { fileSystemPath in
let result = removexattr(fileSystemPath, name, 0)
- guard result >= 0 else { throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno), userInfo: [NSLocalizedDescriptionKey: String(cString: strerror(errno))]) }
+ guard result >= 0 else {
+ throw NSError(
+ domain: NSPOSIXErrorDomain, code: Int(errno),
+ userInfo: [NSLocalizedDescriptionKey: String(cString: strerror(errno))])
+ }
}
}
-
+
func getExtendedFileAttribute(_ fileURL: URL, name: String) throws -> Data {
let data = try fileURL.withUnsafeFileSystemRepresentation { fileSystemPath in
let length = getxattr(fileSystemPath, name, nil, 0, 0, 0)
- guard length >= 0 else { throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno), userInfo: [NSLocalizedDescriptionKey: String(cString: strerror(errno))]) }
-
+ guard length >= 0 else {
+ throw NSError(
+ domain: NSPOSIXErrorDomain, code: Int(errno),
+ userInfo: [NSLocalizedDescriptionKey: String(cString: strerror(errno))])
+ }
+
var data = Data(count: length)
if length == 0 {
return data
}
-
+
let result = data.withUnsafeMutableBytes { [count = data.count] in
getxattr(fileSystemPath, name, $0.baseAddress, count, 0, 0)
}
- guard result >= 0 else { throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno), userInfo: [NSLocalizedDescriptionKey: String(cString: strerror(errno))]) }
-
+ guard result >= 0 else {
+ throw NSError(
+ domain: NSPOSIXErrorDomain, code: Int(errno),
+ userInfo: [NSLocalizedDescriptionKey: String(cString: strerror(errno))])
+ }
+
return data
}
-
+
return data
}
-
+
func getFileForkSizes(_ fileURL: URL) throws -> (dataForkSize: UInt32, resourceForkSize: UInt32) {
let filePath = fileURL.path(percentEncoded: false)
guard self.fileExists(atPath: filePath) else {
throw CocoaError(.fileNoSuchFile)
}
-
+
// Get data fork size.
var dataForkSize: UInt32 = 0
- let dataFileAttributes: [FileAttributeKey: Any]? = try? self.attributesOfItem(atPath: fileURL.path(percentEncoded: false))
+ let dataFileAttributes: [FileAttributeKey: Any]? = try? self.attributesOfItem(
+ atPath: fileURL.path(percentEncoded: false))
if let dataNSSizeAttribute: NSNumber = dataFileAttributes?[FileAttributeKey.size] as? NSNumber {
dataForkSize = UInt32(dataNSSizeAttribute.int64Value)
- }
- else {
+ } else {
throw CocoaError(.fileReadCorruptFile)
}
-
+
// Get resource fork size.
var resourceForkSize: UInt32 = 0
let resourceFileURL: URL = fileURL.appendingPathComponent("..namedfork/rsrc")
let resourceFilePath: String = fileURL.path(percentEncoded: false)
if self.fileExists(atPath: resourceFilePath) {
- let resourceFileAttributes: [FileAttributeKey: Any]? = try? self.attributesOfItem(atPath: resourceFileURL.path(percentEncoded: false))
- if let resourceNSSizeAttribute: NSNumber = resourceFileAttributes?[FileAttributeKey.size] as? NSNumber {
+ let resourceFileAttributes: [FileAttributeKey: Any]? = try? self.attributesOfItem(
+ atPath: resourceFileURL.path(percentEncoded: false))
+ if let resourceNSSizeAttribute: NSNumber = resourceFileAttributes?[FileAttributeKey.size]
+ as? NSNumber
+ {
resourceForkSize = UInt32(resourceNSSizeAttribute.int64Value)
}
}
-
+
return (dataForkSize: dataForkSize, resourceForkSize: resourceForkSize)
}
-
+
func getFlattenedFileSize(_ fileURL: URL) -> UInt64? {
var fileIsDirectory: ObjCBool = false
let filePath: String = fileURL.path(percentEncoded: false)
-
+
guard fileURL.isFileURL,
- self.fileExists(atPath: filePath, isDirectory: &fileIsDirectory),
- fileIsDirectory.boolValue == false else {
+ self.fileExists(atPath: filePath, isDirectory: &fileIsDirectory),
+ fileIsDirectory.boolValue == false
+ else {
return nil
}
-
+
guard let fileName = fileURL.lastPathComponent.data(using: .macOSRoman) else {
return nil
}
-
+
var totalSize: UInt64 = 0
-
+
// Add flat file header size.
totalSize += UInt64(HotlineFileHeader.DataSize)
-
+
// Add information fork header size.
totalSize += UInt64(HotlineFileForkHeader.DataSize)
-
+
// Add information fork size.
totalSize += UInt64(HotlineFileInfoFork.BaseDataSize)
totalSize += UInt64(fileName.count)
-
+
// Add file fork sizes.
if let forkSizes = try? self.getFileForkSizes(fileURL) {
// Add data fork size.
totalSize += UInt64(HotlineFileForkHeader.DataSize)
totalSize += UInt64(forkSizes.dataForkSize)
-
+
// Add resource fork size.
if forkSizes.resourceForkSize > 0 {
totalSize += UInt64(HotlineFileForkHeader.DataSize)
totalSize += UInt64(forkSizes.resourceForkSize)
}
}
-
-
+
// Add data fork size.
-// var dataSize: UInt64 = 0
-// let dataFileAttributes: [FileAttributeKey: Any]? = try? self.attributesOfItem(atPath: fileURL.path(percentEncoded: false))
-// if let dataSizeAttribute: UInt64 = dataFileAttributes?[FileAttributeKey.size] as? UInt64 {
-// dataSize = UInt64(dataSizeAttribute)
-// }
-//
-// totalSize += dataSize
-//
-// // Add resource fork size.
-// var resourceForkSize: UInt64 = 0
-// let resourceFileURL: URL = fileURL.appendingPathComponent("..namedfork/rsrc")
-// let resourceFilePath: String = fileURL.path(percentEncoded: false)
-// if self.fileExists(atPath: resourceFilePath) {
-// let resourceFileAttributes: [FileAttributeKey: Any]? = try? self.attributesOfItem(atPath: resourceFileURL.path(percentEncoded: false))
-// if let resourceSizeAttribute: UInt64 = resourceFileAttributes?[FileAttributeKey.size] as? UInt64 {
-// resourceForkSize = UInt64(resourceSizeAttribute)
-// print("FOUND RESOURCE FORK: \(resourceForkSize)")
-// }
-// }
-//
-// totalSize += resourceForkSize
-
+ // var dataSize: UInt64 = 0
+ // let dataFileAttributes: [FileAttributeKey: Any]? = try? self.attributesOfItem(atPath: fileURL.path(percentEncoded: false))
+ // if let dataSizeAttribute: UInt64 = dataFileAttributes?[FileAttributeKey.size] as? UInt64 {
+ // dataSize = UInt64(dataSizeAttribute)
+ // }
+ //
+ // totalSize += dataSize
+ //
+ // // Add resource fork size.
+ // var resourceForkSize: UInt64 = 0
+ // let resourceFileURL: URL = fileURL.appendingPathComponent("..namedfork/rsrc")
+ // let resourceFilePath: String = fileURL.path(percentEncoded: false)
+ // if self.fileExists(atPath: resourceFilePath) {
+ // let resourceFileAttributes: [FileAttributeKey: Any]? = try? self.attributesOfItem(atPath: resourceFileURL.path(percentEncoded: false))
+ // if let resourceSizeAttribute: UInt64 = resourceFileAttributes?[FileAttributeKey.size] as? UInt64 {
+ // resourceForkSize = UInt64(resourceSizeAttribute)
+ // print("FOUND RESOURCE FORK: \(resourceForkSize)")
+ // }
+ // }
+ //
+ // totalSize += resourceForkSize
+
return totalSize
}
}
@@ -469,33 +499,38 @@ extension Date {
components.month = 1
components.day = 1
components.second = 0
-
+
guard let baseDate = Calendar.current.date(from: components) else {
return nil
}
-
+
self = baseDate.advanced(by: TimeInterval(seconds))
}
-
+
func hotlineDateComponents() -> (year: UInt16, seconds: UInt32, milliseconds: UInt16)? {
let epochTime = self.timeIntervalSince1970
let gmtDate = Date(timeIntervalSince1970: epochTime)
-
+
let calendar = Calendar.current
- var components = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second, .nanosecond], from: gmtDate)
+ var components = calendar.dateComponents(
+ [.year, .month, .day, .hour, .minute, .second, .nanosecond], from: gmtDate)
components.month = 1
components.day = 1
components.hour = 0
components.minute = 0
components.second = 0
components.nanosecond = 0
-
+
guard let startOfYear = calendar.date(from: components),
- let year = components.year else {
+ let year = components.year
+ else {
return nil
}
-
- return (year: UInt16(year), seconds: UInt32(gmtDate.timeIntervalSince(startOfYear)), milliseconds: UInt16(0))
+
+ return (
+ year: UInt16(year), seconds: UInt32(gmtDate.timeIntervalSince(startOfYear)),
+ milliseconds: UInt16(0)
+ )
}
}
@@ -504,94 +539,94 @@ extension Array where Element == UInt8 {
self.init()
self.appendUInt8(val)
}
-
+
init(_ val: UInt16) {
self.init()
self.appendUInt16(val)
}
-
+
init(_ val: UInt32) {
self.init()
self.appendUInt32(val)
}
-
+
init(_ val: UInt64) {
self.init()
self.appendUInt64(val)
}
-
+
mutating func consumeUInt8() -> UInt8? {
guard let val = self.readUInt8(at: 0) else {
return nil
}
-
+
self.removeFirst(1)
return val
}
-
+
mutating func consumeUInt16() -> UInt16? {
guard let val = self.readUInt16(at: 0) else {
return nil
}
-
+
self.removeFirst(2)
return val
}
-
+
mutating func consumeUInt32() -> UInt32? {
guard let val = self.readUInt32(at: 0) else {
return nil
}
-
+
self.removeFirst(4)
return val
}
-
+
mutating func consumeUInt64() -> UInt64? {
guard let val = self.readUInt64(at: 0) else {
return nil
}
-
+
self.removeFirst(8)
return val
}
-
+
mutating func consume(_ length: Int) -> Bool {
guard length <= self.count else {
return false
}
-
+
self.removeFirst(length)
return true
}
-
+
mutating func consumeBytes(_ length: Int) -> Data? {
guard let val: Data = self.readData(at: 0, length: length) else {
return nil
}
-
+
self.removeFirst(length)
return val
}
-
+
mutating func consumeBytes(_ length: Int) -> [UInt8]? {
guard let val: [UInt8] = self.readData(at: 0, length: length) else {
return nil
}
-
+
self.removeFirst(length)
return val
}
-
+
mutating func consumeDate() -> Date? {
guard let date = self.readDate(at: 0) else {
return nil
}
-
+
self.removeFirst(2 + 2 + 4)
return date
}
-
+
mutating func consumePString() -> String? {
let (str, len) = self.readPString(at: 0)
guard let str = str else {
@@ -600,11 +635,11 @@ extension Array where Element == UInt8 {
if len == 0 {
return ""
}
-
+
self.removeFirst(len)
return str
}
-
+
mutating func consumeLongPString() -> String? {
let (str, len) = self.readLongPString(at: 0)
guard let str = str else {
@@ -613,126 +648,125 @@ extension Array where Element == UInt8 {
if len == 0 {
return ""
}
-
+
self.removeFirst(len)
return str
}
-
+
mutating func consumeString(_ length: Int) -> String? {
guard let val = self.readString(at: 0, length: length) else {
return nil
}
-
+
self.removeFirst(length)
return val
}
-
+
func readUInt8(at offset: Int) -> UInt8? {
guard offset >= 0, offset + 1 <= self.count else {
return nil
}
return self[offset]
}
-
+
func readUInt16(at offset: Int) -> UInt16? {
guard offset >= 0, offset + 2 <= self.count else {
return nil
}
-
+
return (UInt16(self[offset]) << 8) + UInt16(self[offset + 1])
}
-
+
func readUInt32(at offset: Int) -> UInt32? {
guard offset >= 0, offset + 4 <= self.count else {
return nil
}
-
- return (UInt32(self[offset]) << 24) + (UInt32(self[offset + 1]) << 16) + (UInt32(self[offset + 2]) << 8) + UInt32(self[offset + 3])
+
+ return (UInt32(self[offset]) << 24) + (UInt32(self[offset + 1]) << 16)
+ + (UInt32(self[offset + 2]) << 8) + UInt32(self[offset + 3])
}
-
+
func readUInt64(at offset: Int) -> UInt64? {
guard offset >= 0, offset + 8 <= self.count else {
return nil
}
-
- let a: UInt64 = (UInt64(self[offset]) << 56) +
- (UInt64(self[offset + 1]) << 48) +
- (UInt64(self[offset + 2]) << 40) +
- (UInt64(self[offset + 3]) << 32)
-
- let b: UInt64 = (UInt64(self[offset + 4]) << 24) +
- (UInt64(self[offset + 5]) << 16) +
- (UInt64(self[offset + 6]) << 8) +
- UInt64(self[offset + 7])
-
+
+ let a: UInt64 =
+ (UInt64(self[offset]) << 56) + (UInt64(self[offset + 1]) << 48)
+ + (UInt64(self[offset + 2]) << 40) + (UInt64(self[offset + 3]) << 32)
+
+ let b: UInt64 =
+ (UInt64(self[offset + 4]) << 24) + (UInt64(self[offset + 5]) << 16)
+ + (UInt64(self[offset + 6]) << 8) + UInt64(self[offset + 7])
+
return a + b
}
-
+
func readDate(at offset: Int) -> Date? {
guard offset >= 0, offset + 2 + 2 + 4 <= self.count else {
return nil
}
-
- if
- let year = self.readUInt16(at: offset),
+
+ if let year = self.readUInt16(at: offset),
let ms = self.readUInt16(at: offset + 2),
- let secs = self.readUInt32(at: offset + 2 + 2) {
+ let secs = self.readUInt32(at: offset + 2 + 2)
+ {
return Date(year: year, seconds: secs, milliseconds: ms)
-// return convertHotlineDate(year: year, seconds: secs, milliseconds: ms)
+ // return convertHotlineDate(year: year, seconds: secs, milliseconds: ms)
}
-
+
return nil
}
-
+
func readData(at offset: Int, length: Int) -> Data? {
guard offset >= 0, offset + length <= self.count else {
return nil
}
return Data(self[offset..<(offset + length)])
}
-
+
func readData(at offset: Int, length: Int) -> [UInt8]? {
guard offset >= 0, offset + length <= self.count else {
return nil
}
return Array(self[offset..<(offset + length)])
}
-
+
func readString(at offset: Int, length: Int) -> String? {
guard let subdata: Data = self.readData(at: offset, length: length) else {
return nil
}
-
+
if subdata.count == 0 {
return ""
}
-
+
let allowedEncodings = [
NSUTF8StringEncoding,
NSShiftJISStringEncoding,
NSUnicodeStringEncoding,
- NSWindowsCP1251StringEncoding
+ NSWindowsCP1251StringEncoding,
]
var decodedNSString: NSString?
- let rawValue = NSString.stringEncoding(for: subdata, encodingOptions: [.allowLossyKey: false], convertedString: &decodedNSString, usedLossyConversion: nil)
-
+ let rawValue = NSString.stringEncoding(
+ for: subdata, encodingOptions: [.allowLossyKey: false], convertedString: &decodedNSString,
+ usedLossyConversion: nil)
+
if allowedEncodings.contains(rawValue) {
return decodedNSString as? String
- }
-
- else if rawValue > 1 {
+ } else if rawValue > 1 {
print("ENCODING FOUND \(rawValue)")
}
-
+
var macStr = String(data: subdata, encoding: .macOSRoman)
if macStr == nil {
macStr = String(data: subdata, encoding: .nonLossyASCII)
}
-
+
return macStr
}
-
+
func readPString(at offset: Int) -> (String?, Int) {
guard offset >= 0, offset + 1 <= self.count else {
return (nil, 0)
@@ -741,9 +775,9 @@ extension Array where Element == UInt8 {
guard offset + 1 + len <= self.count else {
return (nil, 0)
}
- return (self.readString(at: offset+1, length: len), 1 + len)
+ 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)
@@ -755,14 +789,14 @@ extension Array where Element == UInt8 {
guard offset + 2 + len <= self.count else {
return (nil, 0)
}
- return (self.readString(at: offset+2, length: len), len)
+ return (self.readString(at: offset + 2, length: len), len)
}
-
+
mutating func appendUInt8(_ value: UInt8, endianness: Endianness = .big) {
let val = endianness == .big ? value.bigEndian : value.littleEndian
self.append(val)
}
-
+
mutating func appendUInt16(_ value: UInt16, endianness: Endianness = .big) {
let val = endianness == .big ? value.bigEndian : value.littleEndian
let bytes: [UInt8] = [
@@ -771,52 +805,50 @@ extension Array where Element == UInt8 {
]
self.append(contentsOf: bytes)
}
-
+
mutating func appendUInt32(_ value: UInt32, endianness: Endianness = .big) {
let val = endianness == .big ? value.bigEndian : value.littleEndian
let bytes: [UInt8] = [
- UInt8(val & 0x000000FF),
- UInt8((val >> 8) & 0x000000FF),
- UInt8((val >> 16) & 0x000000FF),
- UInt8((val >> 24) & 0x000000FF),
+ UInt8(val & 0x0000_00FF),
+ UInt8((val >> 8) & 0x0000_00FF),
+ UInt8((val >> 16) & 0x0000_00FF),
+ UInt8((val >> 24) & 0x0000_00FF),
]
self.append(contentsOf: bytes)
}
-
+
mutating func appendUInt64(_ value: UInt64, endianness: Endianness = .big) {
let val: UInt64 = endianness == .big ? value.bigEndian : value.littleEndian
let bytes: [UInt8] = [
- UInt8(val & 0x00000000000000FF),
- UInt8((val >> 8) & 0x00000000000000FF),
- UInt8((val >> 16) & 0x00000000000000FF),
- UInt8((val >> 24) & 0x00000000000000FF),
- UInt8((val >> 32) & 0x00000000000000FF),
- UInt8((val >> 40) & 0x00000000000000FF),
- UInt8((val >> 48) & 0x00000000000000FF),
- UInt8((val >> 56) & 0x00000000000000FF),
+ UInt8(val & 0x0000_0000_0000_00FF),
+ UInt8((val >> 8) & 0x0000_0000_0000_00FF),
+ UInt8((val >> 16) & 0x0000_0000_0000_00FF),
+ UInt8((val >> 24) & 0x0000_0000_0000_00FF),
+ UInt8((val >> 32) & 0x0000_0000_0000_00FF),
+ UInt8((val >> 40) & 0x0000_0000_0000_00FF),
+ UInt8((val >> 48) & 0x0000_0000_0000_00FF),
+ UInt8((val >> 56) & 0x0000_0000_0000_00FF),
]
self.append(contentsOf: bytes)
}
-
+
mutating func appendData(_ data: Data) {
self.append(contentsOf: data)
}
-
+
mutating func appendData(_ data: [UInt8]) {
self.append(contentsOf: data)
}
-
+
mutating func hotlineEncrypt() {
for i in (0..<self.count).reversed() {
self[i] = 0xFF - self[i]
}
}
-
+
func hotlineEncrypted() -> [UInt8] {
var cpy = [UInt8](self)
-
-
-
+
for i in (0..<cpy.count).reversed() {
cpy[i] = 0xFF - cpy[i]
}
@@ -828,84 +860,85 @@ extension Data {
func hexDump() -> String {
return self.map { String(format: "%02x", $0) }.joined(separator: " ")
}
-
+
init(_ val: UInt8) {
self.init()
self.appendUInt8(val)
}
-
+
init(_ val: UInt16) {
self.init()
self.appendUInt16(val)
}
-
+
init(_ val: UInt32) {
self.init()
self.appendUInt32(val)
}
-
+
func readUInt8(at offset: Int) -> UInt8? {
guard offset >= 0, offset + 1 <= self.count else {
return nil
}
return self[offset]
}
-
+
func readUInt16(at offset: Int) -> UInt16? {
guard offset >= 0, offset + 2 <= self.count else {
return nil
}
-
+
return (UInt16(self[offset]) << 8) + UInt16(self[offset + 1])
}
-
+
func readUInt32(at offset: Int) -> UInt32? {
guard offset >= 0, offset + 4 <= self.count else {
return nil
}
-
- return (UInt32(self[offset]) << 24) + (UInt32(self[offset + 1]) << 16) + (UInt32(self[offset + 2]) << 8) + UInt32(self[offset + 3])
+
+ return (UInt32(self[offset]) << 24) + (UInt32(self[offset + 1]) << 16)
+ + (UInt32(self[offset + 2]) << 8) + UInt32(self[offset + 3])
}
-
+
func readUInt64(at offset: Int) -> UInt64? {
guard offset >= 0, offset + 8 <= self.count else {
return nil
}
-
- return withUnsafeBytes { $0.load(as: UInt64.self ) }
+
+ 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
}
-
- if
- let year = self.readUInt16(at: offset),
+
+ if let year = self.readUInt16(at: offset),
let ms = self.readUInt16(at: offset + 2),
- let secs = self.readUInt32(at: offset + 2 + 2) {
+ let secs = self.readUInt32(at: offset + 2 + 2)
+ {
return Date(year: year, seconds: secs, milliseconds: ms)
}
-
+
return nil
}
-
+
mutating func appendDate(_ date: Date) {
var year: UInt16 = 0
var msecs: UInt16 = 0
var secs: UInt32 = 0
-
+
if let components = date.hotlineDateComponents() {
year = components.year
secs = components.seconds
msecs = components.milliseconds
}
-
+
self.appendUInt16(year)
self.appendUInt16(msecs)
self.appendUInt32(secs)
}
-
+
func readData(at offset: Int, length: Int) -> Data? {
guard offset >= 0, offset + length <= self.count else {
return nil
@@ -918,33 +951,33 @@ extension Data {
if subdata.count == 0 {
return ""
}
-
+
let allowedEncodings = [
NSUTF8StringEncoding,
NSShiftJISStringEncoding,
NSUnicodeStringEncoding,
- NSWindowsCP1251StringEncoding
+ NSWindowsCP1251StringEncoding,
]
var decodedNSString: NSString?
- let rawValue = NSString.stringEncoding(for: subdata, encodingOptions: [.allowLossyKey: false], convertedString: &decodedNSString, usedLossyConversion: nil)
-
+ let rawValue = NSString.stringEncoding(
+ for: subdata, encodingOptions: [.allowLossyKey: false], convertedString: &decodedNSString,
+ usedLossyConversion: nil)
+
if allowedEncodings.contains(rawValue) {
return decodedNSString as? String
- }
-
- else if rawValue > 1 {
+ } else if rawValue > 1 {
print("ENCODING FOUND \(rawValue)")
}
-
+
var macStr = String(data: subdata, encoding: .macOSRoman)
if macStr == nil {
macStr = String(data: subdata, encoding: .nonLossyASCII)
}
-
+
return macStr
}
-
+
func readPString(at offset: Int) -> (String?, Int) {
guard offset >= 0, offset + 1 <= self.count else {
return (nil, 0)
@@ -953,9 +986,9 @@ extension Data {
guard offset + 1 + len <= self.count else {
return (nil, 0)
}
- return (self.readString(at: offset+1, length: len), 1 + len)
+ 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)
@@ -967,40 +1000,39 @@ extension Data {
guard offset + 2 + len <= self.count else {
return (nil, 0)
}
- return (self.readString(at: offset+2, length: len), len)
+ return (self.readString(at: offset + 2, length: len), len)
}
-
-
+
mutating func appendUInt8(_ value: UInt8, endianness: Endianness = .big) {
var val = endianness == .big ? value.bigEndian : value.littleEndian
append(&val, count: MemoryLayout<UInt8>.size)
}
-
+
mutating func appendUInt16(_ value: UInt16, endianness: Endianness = .big) {
var val = endianness == .big ? value.bigEndian : value.littleEndian
Swift.withUnsafeBytes(of: &val) { buffer in
append(buffer.bindMemory(to: UInt8.self))
}
-// append(&val, count: MemoryLayout<UInt16>.size)
+ // append(&val, count: MemoryLayout<UInt16>.size)
}
-
+
mutating func appendUInt32(_ value: UInt32, endianness: Endianness = .big) {
var val = endianness == .big ? value.bigEndian : value.littleEndian
Swift.withUnsafeBytes(of: &val) { buffer in
append(buffer.bindMemory(to: UInt8.self))
}
-// append(&val, count: MemoryLayout<UInt32>.size)
+ // append(&val, count: MemoryLayout<UInt32>.size)
}
-
+
mutating func appendZeros(count: Int) {
- append(contentsOf: Array<UInt8>(repeating: 0, count: count))
+ append(contentsOf: [UInt8](repeating: 0, count: count))
}
-
+
mutating func appendString(_ value: String, encoding: String.Encoding) {
guard let encodedString = value.data(using: encoding) else {
return
}
-
+
append(encodedString)
}
}
@@ -1011,7 +1043,7 @@ extension FourCharCode {
UInt8((self >> 24) & 0xFF),
UInt8((self >> 16) & 0xFF),
UInt8((self >> 8) & 0xFF),
- UInt8(self & 0xFF)
+ UInt8(self & 0xFF),
]
return String(bytes: bytes, encoding: .ascii) ?? ""
}
diff --git a/Hotline/Hotline/HotlineProtocol.swift b/Hotline/Hotline/HotlineProtocol.swift
index 70636a6..adcf7e5 100644
--- a/Hotline/Hotline/HotlineProtocol.swift
+++ b/Hotline/Hotline/HotlineProtocol.swift
@@ -7,9 +7,9 @@ struct HotlinePorts {
struct HotlineUserOptions: OptionSet {
let rawValue: UInt16
-
+
static let none: HotlineUserOptions = []
-
+
static let refusePrivateMessages = HotlineUserOptions(rawValue: 1 << 0)
static let refusePrivateChat = HotlineUserOptions(rawValue: 1 << 1)
static let automaticResponse = HotlineUserOptions(rawValue: 1 << 2)
@@ -17,11 +17,11 @@ struct HotlineUserOptions: OptionSet {
struct HotlineUserAccessOptions: OptionSet {
let rawValue: UInt64
-
+
static func accessIndexToBit(_ index: Int) -> Int {
return 63 - index
}
-
+
// Hotline 1.9.2 default user permissions
static let defaultAccess = Self([
.canDownloadFiles,
@@ -34,19 +34,19 @@ struct HotlineUserAccessOptions: OptionSet {
.canCreateChat,
.canReadChat,
.canSendChat,
- .canUseAnyName
+ .canUseAnyName,
])
static func printAccessOptions(_ val: HotlineUserAccessOptions) {
func formatBinaryString(_ binaryString: String) -> String {
- var formattedString = ""
- for (index, char) in binaryString.reversed().enumerated() {
- if index % 8 == 0 && index != 0 {
- formattedString.append("_")
- }
- formattedString.append(char)
+ var formattedString = ""
+ for (index, char) in binaryString.reversed().enumerated() {
+ if index % 8 == 0 && index != 0 {
+ formattedString.append("_")
}
- return String(formattedString.reversed())
+ formattedString.append(char)
+ }
+ return String(formattedString.reversed())
}
var formattedBits = String(val.rawValue, radix: 2)
@@ -54,7 +54,7 @@ struct HotlineUserAccessOptions: OptionSet {
formattedBits = String(repeating: "0", count: 64 - formattedBits.count) + formattedBits
}
formattedBits = formatBinaryString(formattedBits)
-
+
print("Access Options for \(formattedBits):")
print("")
print("File System Maintenance")
@@ -74,7 +74,7 @@ struct HotlineUserAccessOptions: OptionSet {
print("Can comment folders: \(val.contains(.canSetFolderComment))")
print("Can view dropboxes: \(val.contains(.canViewDropBoxes))")
print("Can make aliases: \(val.contains(.canMakeAliases))")
-
+
print("")
print("User Maintenance")
print("Can create accounts: \(val.contains(.canCreateUsers))")
@@ -84,12 +84,12 @@ struct HotlineUserAccessOptions: OptionSet {
print("Can get user info: \(val.contains(.canGetClientInfo))")
print("Can disconnect users: \(val.contains(.canDisconnectUsers))")
print("Cannot be disconnected: \(val.contains(.cantBeDisconnected))")
-
+
print("")
print("Messaging")
print("Can send private messages: \(val.contains(.canSendPrivateMessages))")
print("Can broadcast: \(val.contains(.canBroadcast))")
-
+
print("")
print("News")
print("Can read message board: \(val.contains(.canReadMessageBoard))")
@@ -99,69 +99,110 @@ struct HotlineUserAccessOptions: OptionSet {
print("Can delete news categories: \(val.contains(.canDeleteNewsCategories))")
print("Can create news bundles: \(val.contains(.canCreateNewsFolders))")
print("Can delete news bundles: \(val.contains(.canDeleteNewsFolders))")
-
+
print("")
print("Chat")
print("Can initiate private chat: \(val.contains(.canCreateChat))")
print("Can read chat: \(val.contains(.canReadChat))")
print("Can send chat: \(val.contains(.canSendChat))")
-
+
print("")
print("Miscellaneous")
print("Can use any name: \(val.contains(.canUseAnyName))")
print("Don't show agreement: \(val.contains(.canSkipAgreement))")
print("Can change own password: \(val.contains(.canChangeOwnPassword))")
-
+
print("Can close chat: \(val.contains(.canCloseChat))")
print("Can shown in list: \(val.contains(.canShownInList))")
print("")
}
-
- static let canDeleteFiles = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(0))
- static let canUploadFiles = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(1))
- static let canDownloadFiles = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(2))
- static let canRenameFiles = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(3))
- static let canMoveFiles = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(4))
- static let canCreateFolders = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(5))
- static let canDeleteFolders = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(6))
- static let canRenameFolders = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(7))
-
- static let canMoveFolders = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(8))
- static let canReadChat = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(9))
- static let canSendChat = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(10))
- static let canCreateChat = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(11))
- static let canCloseChat = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(12))
- static let canShownInList = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(13))
- static let canCreateUsers = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(14))
- static let canDeleteUsers = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(15))
-
- static let canOpenUsers = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(16))
- static let canModifyUsers = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(17))
- static let canChangeOwnPassword = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(18))
- static let canSendPrivateMessages = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(19))
- static let canReadMessageBoard = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(20))
- static let canPostMessageBoard = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(21))
- static let canDisconnectUsers = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(22))
- static let cantBeDisconnected = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(23))
-
- static let canGetClientInfo = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(24))
- static let canUploadAnywhere = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(25))
- static let canUseAnyName = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(26))
- static let canSkipAgreement = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(27))
- static let canSetFileComment = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(28))
- static let canSetFolderComment = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(29))
- static let canViewDropBoxes = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(30))
- static let canMakeAliases = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(31))
-
- static let canBroadcast = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(32))
- static let canDeleteNewsArticles = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(33))
- static let canCreateNewsCategories = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(34))
- static let canDeleteNewsCategories = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(35))
- static let canCreateNewsFolders = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(36))
- static let canDeleteNewsFolders = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(37))
- static let canUploadFolders = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(38))
- static let canDownloadFolders = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(39))
- static let canSendMessages = HotlineUserAccessOptions(rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(40))
+
+ static let canDeleteFiles = HotlineUserAccessOptions(
+ rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(0))
+ static let canUploadFiles = HotlineUserAccessOptions(
+ rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(1))
+ static let canDownloadFiles = HotlineUserAccessOptions(
+ rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(2))
+ static let canRenameFiles = HotlineUserAccessOptions(
+ rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(3))
+ static let canMoveFiles = HotlineUserAccessOptions(
+ rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(4))
+ static let canCreateFolders = HotlineUserAccessOptions(
+ rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(5))
+ static let canDeleteFolders = HotlineUserAccessOptions(
+ rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(6))
+ static let canRenameFolders = HotlineUserAccessOptions(
+ rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(7))
+
+ static let canMoveFolders = HotlineUserAccessOptions(
+ rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(8))
+ static let canReadChat = HotlineUserAccessOptions(
+ rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(9))
+ static let canSendChat = HotlineUserAccessOptions(
+ rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(10))
+ static let canCreateChat = HotlineUserAccessOptions(
+ rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(11))
+ static let canCloseChat = HotlineUserAccessOptions(
+ rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(12))
+ static let canShownInList = HotlineUserAccessOptions(
+ rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(13))
+ static let canCreateUsers = HotlineUserAccessOptions(
+ rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(14))
+ static let canDeleteUsers = HotlineUserAccessOptions(
+ rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(15))
+
+ static let canOpenUsers = HotlineUserAccessOptions(
+ rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(16))
+ static let canModifyUsers = HotlineUserAccessOptions(
+ rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(17))
+ static let canChangeOwnPassword = HotlineUserAccessOptions(
+ rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(18))
+ static let canSendPrivateMessages = HotlineUserAccessOptions(
+ rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(19))
+ static let canReadMessageBoard = HotlineUserAccessOptions(
+ rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(20))
+ static let canPostMessageBoard = HotlineUserAccessOptions(
+ rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(21))
+ static let canDisconnectUsers = HotlineUserAccessOptions(
+ rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(22))
+ static let cantBeDisconnected = HotlineUserAccessOptions(
+ rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(23))
+
+ static let canGetClientInfo = HotlineUserAccessOptions(
+ rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(24))
+ static let canUploadAnywhere = HotlineUserAccessOptions(
+ rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(25))
+ static let canUseAnyName = HotlineUserAccessOptions(
+ rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(26))
+ static let canSkipAgreement = HotlineUserAccessOptions(
+ rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(27))
+ static let canSetFileComment = HotlineUserAccessOptions(
+ rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(28))
+ static let canSetFolderComment = HotlineUserAccessOptions(
+ rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(29))
+ static let canViewDropBoxes = HotlineUserAccessOptions(
+ rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(30))
+ static let canMakeAliases = HotlineUserAccessOptions(
+ rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(31))
+
+ static let canBroadcast = HotlineUserAccessOptions(
+ rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(32))
+ static let canDeleteNewsArticles = HotlineUserAccessOptions(
+ rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(33))
+ static let canCreateNewsCategories = HotlineUserAccessOptions(
+ rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(34))
+ static let canDeleteNewsCategories = HotlineUserAccessOptions(
+ rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(35))
+ static let canCreateNewsFolders = HotlineUserAccessOptions(
+ rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(36))
+ static let canDeleteNewsFolders = HotlineUserAccessOptions(
+ rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(37))
+ static let canUploadFolders = HotlineUserAccessOptions(
+ rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(38))
+ static let canDownloadFolders = HotlineUserAccessOptions(
+ rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(39))
+ static let canSendMessages = HotlineUserAccessOptions(
+ rawValue: 1 << HotlineUserAccessOptions.accessIndexToBit(40))
}
struct HotlineServer: Identifiable, Hashable {
@@ -171,11 +212,11 @@ struct HotlineServer: Identifiable, Hashable {
let users: UInt16
let name: String?
let description: String?
-
+
static func == (lhs: HotlineServer, rhs: HotlineServer) -> Bool {
return lhs.id == rhs.id
}
-
+
func hash(into hasher: inout Hasher) {
hasher.combine(self.id)
}
@@ -190,7 +231,7 @@ struct HotlineNewsArticle: Identifiable {
let date: Date?
var flavors: [(String, UInt16)] = []
var path: [String] = []
-
+
static func == (lhs: HotlineNewsArticle, rhs: HotlineNewsArticle) -> Bool {
return lhs.id == rhs.id
}
@@ -208,7 +249,7 @@ struct HotlineAccount: Identifiable {
init(from data: [UInt8]) {
self.decodeFields(from: data)
}
-
+
init(_ name: String, _ login: String, _ access: HotlineUserAccessOptions) {
self.name = name
self.login = login
@@ -216,58 +257,62 @@ struct HotlineAccount: Identifiable {
self.persisted = false
}
-
mutating func decodeFields(from data: [UInt8]) {
var fieldData = data
-
+
guard fieldData.count > 0,
- let fieldCount = fieldData.consumeUInt16(),
- fieldCount > 0 else {
+ let fieldCount = fieldData.consumeUInt16(),
+ fieldCount > 0
+ else {
return
}
-
+
for _ in 0..<fieldCount {
- if
- let fieldID = fieldData.consumeUInt16(),
+ if let fieldID = fieldData.consumeUInt16(),
let fieldSize = fieldData.consumeUInt16(),
- let fieldRemainingData: [UInt8] = fieldData.consumeBytes(Int(fieldSize)) {
-
+ let fieldRemainingData: [UInt8] = fieldData.consumeBytes(Int(fieldSize))
+ {
+
if let fieldType = HotlineTransactionFieldType(rawValue: fieldID) {
-
- let field = HotlineTransactionField(type: fieldType, dataSize: fieldSize, data: fieldRemainingData)
-
+
+ let field = HotlineTransactionField(
+ type: fieldType, dataSize: fieldSize, data: fieldRemainingData)
+
if fieldType == .userName {
self.name = field.getString()!
}
-
+
if fieldType == .userLogin {
self.login = field.getObfuscatedString()!
}
-
+
if fieldType == .userPassword {
self.password = field.getObfuscatedString() ?? ""
}
-
- if fieldType == .userAccess, let opts = field.getUInt64(){
+
+ if fieldType == .userAccess, let opts = field.getUInt64() {
self.access = HotlineUserAccessOptions(rawValue: opts)
-
+
HotlineUserAccessOptions.printAccessOptions(self.access)
}
-
-
- self.fields.append(HotlineTransactionField(type: fieldType, dataSize: fieldSize, data: fieldRemainingData))
+
+ self.fields.append(
+ HotlineTransactionField(type: fieldType, dataSize: fieldSize, data: fieldRemainingData))
}
}
}
}
-
+
static func == (lhs: HotlineAccount, rhs: HotlineAccount) -> Bool {
return lhs.id == rhs.id
}
-
+
// Generate an initial random 21 character alphanumeric password, in the spirit of the original client
static func randomPassword() -> String {
- return String((0..<20).map{_ in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".randomElement()!})
+ return String(
+ (0..<20).map { _ in
+ "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".randomElement()!
+ })
}
}
@@ -283,88 +328,89 @@ struct HotlineNewsList: Identifiable {
let description: String
let count: UInt32
var path: [String] = []
-
+
var articles: [HotlineNewsArticle] = []
-
+
init(from data: [UInt8]) {
self.id = data.readUInt32(at: 0)!
-
+
self.count = data.readUInt32(at: 4)!
-
+
let (n, nl) = data.readPString(at: 8)
self.name = n!
-
+
let (d, dl) = data.readPString(at: 8 + nl)
self.description = d!
-
+
var baseIndex = Int(8 + nl + dl)
-
+
for _ in 0..<Int(self.count) {
let articleID = data.readUInt32(at: baseIndex)!
baseIndex += 4
-
+
let date = data.readDate(at: baseIndex)!
-// let timestampData = data.readData(at: baseIndex, length: 8)!
+ // let timestampData = data.readData(at: baseIndex, length: 8)!
baseIndex += 8
-
+
let parentID = data.readUInt32(at: baseIndex)!
baseIndex += 4
-
+
let flags = data.readUInt32(at: baseIndex)!
baseIndex += 4
-
+
let flavorCount = data.readUInt16(at: baseIndex)!
baseIndex += 2
-
+
let titleLength = data.readUInt8(at: baseIndex)!
baseIndex += 1
-
+
let title = data.readString(at: baseIndex, length: Int(titleLength))!
baseIndex += Int(titleLength)
-
+
let posterLength = data.readUInt8(at: baseIndex)!
baseIndex += 1
-
+
let poster = data.readString(at: baseIndex, length: Int(posterLength))!
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: date)
-
+ // 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)")
print("FLAGS: \(flags)")
print("FLAVOR COUNT: \(flavorCount)")
print("TITLE: \(title)")
print("POSTER: \(poster)")
-
+
if flavorCount > 1 {
print("MORE THAN ONE FLAVOR!!")
}
-
+
for _ in 0..<Int(flavorCount) {
let flavorLength = data.readUInt8(at: baseIndex)!
baseIndex += 1
-
+
let flavorText = data.readString(at: baseIndex, length: Int(flavorLength))!
baseIndex += Int(flavorLength)
-
+
let articleSize = data.readUInt16(at: baseIndex)!
baseIndex += 2
-
+
newArticle.flavors.append((flavorText, articleSize))
-
-// let articleString = data.readString(at: baseIndex + i + 4 + 8 + 4 + 4 + 2 + 1 + Int(titleLength) + 1 + Int(posterLength) + 1 + Int(flavorLength) + 2, length: Int(articleSize))!
-
+
+ // let articleString = data.readString(at: baseIndex + i + 4 + 8 + 4 + 4 + 2 + 1 + Int(titleLength) + 1 + Int(posterLength) + 1 + Int(flavorLength) + 2, length: Int(articleSize))!
+
print("FLAVOR: \(flavorText)")
print("ARTICLE SIZE: \(articleSize)")
-// print("ARTICLE: \(articleString)")
+ // print("ARTICLE: \(articleString)")
}
-
+
self.articles.append(newArticle)
}
}
@@ -376,48 +422,45 @@ struct HotlineNewsCategory: Identifiable, Hashable {
let count: UInt16
let name: String
var path: [String] = []
-
+
static func == (lhs: HotlineNewsCategory, rhs: HotlineNewsCategory) -> Bool {
return lhs.id == rhs.id
}
-
+
func hash(into hasher: inout Hasher) {
hasher.combine(self.id)
}
-
+
init(type: UInt16, count: UInt16, name: String) {
self.type = type
self.count = count
self.name = name
}
-
+
init(from data: [UInt8]) {
self.type = data.readUInt16(at: 0)!
-
+
if self.type == 2 {
// Read bundle properties
self.count = data.readUInt16(at: 2)!
let (n, _) = data.readPString(at: 4)
self.name = n!
- }
- else if self.type == 3 {
+ } else if self.type == 3 {
// Read category properties
self.count = data.readUInt16(at: 2)!
-// let guid = data.readUInt32(at: 4)!
-// print("CATEGORY GUID: \(guid)")
-// let addSN = data.readUInt32(at: 20)!
-// let removeSN = data.readUInt32(at: 24)!
+ // let guid = data.readUInt32(at: 4)!
+ // print("CATEGORY GUID: \(guid)")
+ // let addSN = data.readUInt32(at: 20)!
+ // let removeSN = data.readUInt32(at: 24)!
let (n, _) = data.readPString(at: 28)
self.name = n!
- }
- else {
+ } else {
self.count = 0
self.name = ""
}
}
}
-
@Observable
class HotlineFile: Identifiable, Hashable {
let id = UUID()
@@ -425,58 +468,60 @@ class HotlineFile: Identifiable, Hashable {
let creator: String
let fileSize: UInt32
let name: String
-
+
var path: [String] = []
var isExpanded: Bool = false
var files: [HotlineFile]? = nil
-
+
let isFolder: Bool
-
+
var isDropboxFolder: Bool {
guard self.isFolder,
- (self.name.range(of: "upload", options: [.caseInsensitive]) != nil) || (self.name.range(of: "drop box", options: [.caseInsensitive]) != nil) else {
+ (self.name.range(of: "upload", options: [.caseInsensitive]) != nil)
+ || (self.name.range(of: "drop box", options: [.caseInsensitive]) != nil)
+ else {
return false
}
return true
}
-
+
static func == (lhs: HotlineFile, rhs: HotlineFile) -> Bool {
return lhs.id == rhs.id
}
-
+
func hash(into hasher: inout Hasher) {
hasher.combine(self.id)
}
-
+
init(type: String, creator: String, fileSize: UInt32, fileName: String) {
self.type = type
self.creator = creator
self.fileSize = fileSize
self.name = fileName
-
+
self.isFolder = (self.type == "fldr")
if self.isFolder {
self.files = []
}
}
-
+
init(from data: [UInt8]) {
let typeRaw = data.readUInt32(at: 0)!
let creatorRaw = data.readUInt32(at: 4)!
-
+
self.type = typeRaw.fourCharCode()
self.creator = creatorRaw.fourCharCode()
self.fileSize = data.readUInt32(at: 8)!
-
+
self.isFolder = (self.type == "fldr")
if self.isFolder {
self.files = []
}
-
-// data.readUInt32(at: 12)! // reserved
-// let nameScript = data.readUInt16(at: 16)! // name script
-// print("NAME SCRIPT: \(nameScript)")
-
+
+ // data.readUInt32(at: 12)! // reserved
+ // let nameScript = data.readUInt16(at: 16)! // name script
+ // print("NAME SCRIPT: \(nameScript)")
+
let nameLength = data.readUInt16(at: 18)!
self.name = data.readString(at: 20, length: Int(nameLength))!
}
@@ -487,160 +532,163 @@ struct HotlineUser: Identifiable, Hashable {
let iconID: UInt16
let status: UInt16
let name: String
-
+
var isAdmin: Bool {
return ((self.status & 0x0002) != 0)
}
-
+
var isIdle: Bool {
return ((self.status & 0x0001) != 0)
}
-
+
static func == (lhs: HotlineUser, rhs: HotlineUser) -> Bool {
return lhs.id == rhs.id
}
-
+
init(id: UInt16, iconID: UInt16, status: UInt16, name: String) {
self.id = id
self.iconID = iconID
self.status = status
self.name = name
}
-
+
init(from data: [UInt8]) {
self.id = data.readUInt16(at: 0)!
self.iconID = data.readUInt16(at: 2)!
self.status = data.readUInt16(at: 4)!
-
+
let userNameLength = Int(data.readUInt16(at: 6)!)
self.name = data.readString(at: 8, length: userNameLength)!
}
-
+
func hash(into hasher: inout Hasher) {
hasher.combine(self.id)
}
-
+
func encoded() -> [UInt8] {
var data: [UInt8] = []
data.appendUInt16(self.id)
data.appendUInt16(self.iconID)
data.appendUInt16(self.status)
-
+
let userNameData = name.data(using: .ascii, allowLossyConversion: true)!
-
+
data.appendUInt16(UInt16(userNameData.count))
data.appendData(userNameData)
-
+
return data
-
-// var data = Data()
-// self.encode(to: &data)
-// return data
+
+ // var data = Data()
+ // self.encode(to: &data)
+ // return data
}
-
-// func encode(to data: inout Data) {
-// data.appendUInt16(self.id)
-// data.appendUInt16(self.iconID)
-// data.appendUInt16(self.status)
-//
-// let userNameData = name.data(using: .ascii, allowLossyConversion: true)!
-//
-// data.appendUInt16(UInt16(userNameData.count))
-// data.append(userNameData)
-// }
+
+ // func encode(to data: inout Data) {
+ // data.appendUInt16(self.id)
+ // data.appendUInt16(self.iconID)
+ // data.appendUInt16(self.status)
+ //
+ // let userNameData = name.data(using: .ascii, allowLossyConversion: true)!
+ //
+ // data.appendUInt16(UInt16(userNameData.count))
+ // data.append(userNameData)
+ // }
}
struct HotlineTransactionField {
let type: HotlineTransactionFieldType
let dataSize: UInt16
let data: [UInt8]
-
+
init(type: HotlineTransactionFieldType, dataSize: UInt16, data: [UInt8]) {
self.type = type
self.dataSize = dataSize
self.data = data
}
-
+
init(type: HotlineTransactionFieldType, val: UInt8) {
self.init(type: type, dataSize: UInt16(1), data: [val])
}
-
+
init(type: HotlineTransactionFieldType, val: UInt16) {
self.init(type: type, dataSize: UInt16(2), data: [UInt8](val))
}
-
+
init(type: HotlineTransactionFieldType, val: UInt32) {
self.init(type: type, dataSize: UInt16(4), data: [UInt8](val))
}
-
+
init(type: HotlineTransactionFieldType, val: UInt64) {
self.init(type: type, dataSize: UInt16(8), data: [UInt8](val))
}
-
- init(type: HotlineTransactionFieldType, string: String, encoding: String.Encoding = .ascii, encrypt: Bool = false) {
+
+ init(
+ type: HotlineTransactionFieldType, string: String, encoding: String.Encoding = .ascii,
+ encrypt: Bool = false
+ ) {
var bytes = [UInt8](string.utf8)
if encrypt {
- bytes = string.utf8.map { char in
- return 0xFF - char
- }
+ bytes = string.utf8.map { char in
+ return 0xFF - char
+ }
}
self.init(type: type, dataSize: UInt16(bytes.count), data: [UInt8](bytes))
}
-
+
init(type: HotlineTransactionFieldType, string: String, encrypt: Bool) {
self.init(type: type, string: string, encoding: .ascii, encrypt: encrypt)
}
-
+
init(type: HotlineTransactionFieldType, path: String) {
var components: [String] = []
-
+
for component in path.components(separatedBy: "/") {
if !component.isEmpty {
components.append(component)
}
}
-
+
self.init(type: type, pathComponents: components)
}
-
+
init(type: HotlineTransactionFieldType, pathComponents: [String]) {
var pathData: [UInt8] = []
-
+
pathData.appendUInt16(UInt16(pathComponents.count))
for name in pathComponents {
pathData.appendUInt16(0)
-
+
var nameData = name.data(using: .macOSRoman, allowLossyConversion: true)
if nameData == nil {
nameData = Data()
}
-
+
pathData.appendUInt8(UInt8(nameData!.count))
pathData.appendData(nameData!)
}
-
+
self.init(type: type, dataSize: UInt16(pathData.count), data: pathData)
}
func getUInt8() -> UInt8? {
return self.data.readUInt8(at: 0)
}
-
+
func getUInt16() -> UInt16? {
return self.data.readUInt16(at: 0)
}
-
+
func getUInt32() -> UInt32? {
return self.data.readUInt32(at: 0)
}
-
+
func getUInt64() -> UInt64? {
return self.data.readUInt64(at: 0)
}
-
+
func getInteger() -> Int? {
- switch(self.dataSize) {
+ switch self.dataSize {
case 1:
if let val = self.getUInt8() {
return Int(val)
@@ -660,38 +708,38 @@ struct HotlineTransactionField {
default:
break
}
-
+
return nil
}
-
+
func getString() -> String? {
return self.data.readString(at: 0, length: self.data.count)
}
-
+
func getObfuscatedString() -> String? {
return String(bytes: self.data.map { 255 - $0 }, encoding: .ascii)
}
-
+
func getDecodedString() -> String? {
return self.data.readString(at: 0, length: self.data.count)
}
-
+
func getUser() -> HotlineUser {
return HotlineUser(from: self.data)
}
-
+
func getFile() -> HotlineFile {
return HotlineFile(from: self.data)
}
-
+
func getNewsCategory() -> HotlineNewsCategory {
return HotlineNewsCategory(from: self.data)
}
-
+
func getNewsList() -> HotlineNewsList {
return HotlineNewsList(from: self.data)
}
-
+
func getAcccount() -> HotlineAccount {
return HotlineAccount(from: self.data)
}
@@ -700,12 +748,12 @@ struct HotlineTransactionField {
struct HotlineTransaction {
static let headerSize = 20
static var sequenceID: UInt32 = 1
-
+
static func nextID() -> UInt32 {
HotlineTransaction.sequenceID += 1
return HotlineTransaction.sequenceID
}
-
+
var flags: UInt8 = 0
var isReply: UInt8 = 0
var type: HotlineTransactionType
@@ -713,14 +761,14 @@ struct HotlineTransaction {
var errorCode: UInt32 = 0
var totalSize: UInt32 = UInt32(HotlineTransaction.headerSize)
var dataSize: UInt32 = 0
-
+
var fields: [HotlineTransactionField] = []
-
+
init(type: HotlineTransactionType) {
self.type = type
self.id = HotlineTransaction.nextID()
}
-
+
init?(from data: [UInt8]) {
guard
let flags = data.readUInt8(at: 0),
@@ -729,40 +777,47 @@ struct HotlineTransaction {
let id = data.readUInt32(at: 4),
let errorCode = data.readUInt32(at: 8),
let totalSize = data.readUInt32(at: 12),
- let dataSize = data.readUInt32(at: 16) else {
-
+ let dataSize = data.readUInt32(at: 16)
+ else {
+
return nil
}
-
- self.init(type: HotlineTransactionType(rawValue: type) ?? .unknown, flags: flags, isReply: isReply, id: id, errorCode: errorCode, totalSize: totalSize, dataSize: dataSize)
+
+ self.init(
+ type: HotlineTransactionType(rawValue: type) ?? .unknown, flags: flags, isReply: isReply,
+ id: id, errorCode: errorCode, totalSize: totalSize, dataSize: dataSize)
}
-
+
mutating func decodeFields(from data: [UInt8]) {
var fieldData = data
-
+
guard fieldData.count > 0,
- let fieldCount = fieldData.consumeUInt16(),
- fieldCount > 0 else {
+ let fieldCount = fieldData.consumeUInt16(),
+ fieldCount > 0
+ else {
return
}
-
+
for _ in 0..<fieldCount {
- if
- let fieldID = fieldData.consumeUInt16(),
+ if let fieldID = fieldData.consumeUInt16(),
let fieldSize = fieldData.consumeUInt16(),
- let fieldRemainingData: [UInt8] = fieldData.consumeBytes(Int(fieldSize)) {
-
+ let fieldRemainingData: [UInt8] = fieldData.consumeBytes(Int(fieldSize))
+ {
+
if let fieldType = HotlineTransactionFieldType(rawValue: fieldID) {
- self.fields.append(HotlineTransactionField(type: fieldType, dataSize: fieldSize, data: fieldRemainingData))
- }
- else {
+ self.fields.append(
+ HotlineTransactionField(type: fieldType, dataSize: fieldSize, data: fieldRemainingData))
+ } else {
print("HotlineClient: UNKNOWN FIELD TYPE!", fieldID, fieldSize)
}
}
}
}
-
- init(type: HotlineTransactionType, flags: UInt8, isReply: UInt8, id: UInt32, errorCode: UInt32, totalSize: UInt32, dataSize: UInt32) {
+
+ init(
+ type: HotlineTransactionType, flags: UInt8, isReply: UInt8, id: UInt32, errorCode: UInt32,
+ totalSize: UInt32, dataSize: UInt32
+ ) {
self.type = type
self.flags = flags
self.isReply = isReply
@@ -771,55 +826,59 @@ struct HotlineTransaction {
self.totalSize = totalSize
self.dataSize = dataSize
}
-
+
mutating func setFieldUInt8(type: HotlineTransactionFieldType, val: UInt8) {
self.fields.append(HotlineTransactionField(type: type, val: val))
}
-
+
mutating func setFieldUInt16(type: HotlineTransactionFieldType, val: UInt16) {
self.fields.append(HotlineTransactionField(type: type, val: val))
}
-
+
mutating func setFieldUInt32(type: HotlineTransactionFieldType, val: UInt32) {
self.fields.append(HotlineTransactionField(type: type, val: val))
}
-
+
mutating func setFieldUInt64(type: HotlineTransactionFieldType, val: UInt64) {
self.fields.append(HotlineTransactionField(type: type, val: val))
}
-
+
mutating func setFieldEncodedString(type: HotlineTransactionFieldType, val: String) {
self.fields.append(HotlineTransactionField(type: type, string: val, encrypt: true))
}
-
- mutating func setFieldString(type: HotlineTransactionFieldType, val: String, encoding: String.Encoding = .utf8) {
- self.fields.append(HotlineTransactionField(type: type, string: val, encoding: encoding, encrypt: false))
+
+ mutating func setFieldString(
+ type: HotlineTransactionFieldType, val: String, encoding: String.Encoding = .utf8
+ ) {
+ self.fields.append(
+ HotlineTransactionField(type: type, string: val, encoding: encoding, encrypt: false))
}
-
+
mutating func setFieldPath(type: HotlineTransactionFieldType, val: [String]) {
self.fields.append(HotlineTransactionField(type: type, pathComponents: val))
}
-
+
func getField(type: HotlineTransactionFieldType) -> HotlineTransactionField? {
return self.fields.first { p in
p.type == type
}
}
-
+
func getFieldList(type: HotlineTransactionFieldType) -> [HotlineTransactionField] {
return self.fields.filter { p in
p.type == type
}
}
-
+
func encoded() -> [UInt8] {
var data: [UInt8] = []
data.appendUInt8(0)
data.appendUInt8(self.isReply)
- data.appendUInt16(self.isReply == 1 ? HotlineTransactionType.reply.rawValue : self.type.rawValue)
+ data.appendUInt16(
+ self.isReply == 1 ? HotlineTransactionType.reply.rawValue : self.type.rawValue)
data.appendUInt32(self.id)
data.appendUInt32(self.errorCode)
-
+
if self.fields.count > 0 {
var fieldData: [UInt8] = []
fieldData.appendUInt16(UInt16(self.fields.count))
@@ -828,12 +887,11 @@ struct HotlineTransaction {
fieldData.appendUInt16(f.dataSize)
fieldData.appendData(f.data)
}
-
+
data.appendUInt32(UInt32(fieldData.count))
data.appendUInt32(UInt32(fieldData.count))
data.appendData(fieldData)
- }
- else {
+ } else {
data.appendUInt32(2)
data.appendUInt32(2)
data.appendUInt16(0)
@@ -843,65 +901,65 @@ struct HotlineTransaction {
}
enum HotlineTransactionFieldType: UInt16 {
- case errorText = 100 // String
- case data = 101 // String
- case userName = 102 // String
- case userID = 103 // Integer
- case userIconID = 104 // Integer
- case userLogin = 105 // Encoded string
- case userPassword = 106 // Encoded string
- case referenceNumber = 107 // Integer
- case transferSize = 108 // Integer
- case chatOptions = 109 // Integer
- case userAccess = 110 // 64-bit integer?
- case userAlias = 111 // ???
- case userFlags = 112 // Integer
- case options = 113 // 32-bit integer?
- case chatID = 114 // Integer
- case chatSubject = 115 // String
- case waitingCount = 116 // Integer
- case serverAgreement = 150 // ???
- case serverBanner = 151 // Data?
- case serverBannerType = 152 // Integer
- case serverBannerURL = 153 // String
- case noServerAgreement = 154 // Integer
- case versionNumber = 160 // Integer
- 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 fileTypeString = 205 // String
- case fileCreatorString = 206 // String
- case fileSize = 207 // Integer
+ case errorText = 100 // String
+ case data = 101 // String
+ case userName = 102 // String
+ case userID = 103 // Integer
+ case userIconID = 104 // Integer
+ case userLogin = 105 // Encoded string
+ case userPassword = 106 // Encoded string
+ case referenceNumber = 107 // Integer
+ case transferSize = 108 // Integer
+ case chatOptions = 109 // Integer
+ case userAccess = 110 // 64-bit integer?
+ case userAlias = 111 // ???
+ case userFlags = 112 // Integer
+ case options = 113 // 32-bit integer?
+ case chatID = 114 // Integer
+ case chatSubject = 115 // String
+ case waitingCount = 116 // Integer
+ case serverAgreement = 150 // ???
+ case serverBanner = 151 // Data?
+ case serverBannerType = 152 // Integer
+ case serverBannerURL = 153 // String
+ case noServerAgreement = 154 // Integer
+ case versionNumber = 160 // Integer
+ 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 fileTypeString = 205 // String
+ case fileCreatorString = 206 // String
+ case fileSize = 207 // Integer
case fileCreateDate = 208
case fileModifyDate = 209
- case fileComment = 210 // Integer
- case fileNewName = 211 // String
- case fileType = 213 // Integer
- case quotingMessage = 214 // String?
- case automaticResponse = 215 // String
- case folderItemCount = 220 // Integer
- case userNameWithInfo = 300 // Data { user id: 2, icon id: 2, user flags: 2, user name size: 2, user name: size }
- case newsCategoryGUID = 319 // Data?
- case newsCategoryListData = 320 // Data { type: 1 (1 = folder, 10 = category, 255 = other), category name: rest }
- case newsArticleListData = 321 // Data
- case newsCategoryName = 322 // String
- case newsCategoryListData15 = 323 // Data
- case newsPath = 325 // Data
- case newsArticleID = 326 // Integer
- case newsArticleDataFlavor = 327 // String
- case newsArticleTitle = 328 // String
- case newsArticlePoster = 329 // String
- case newsArticleDate = 330 // Data { year: 2, ms: 2, secs: 4 }
- case newsArticlePrevious = 331 // Integer
- case newsArticleNext = 332 // Integer
- case newsArticleData = 333 // Data
- case newsArticleFlags = 334 // Integer
- case newsArticleParentArticle = 335 // Integer
- case newsArticleFirstChildArticle = 336 // Integer
- case newsArticleRecursiveDelete = 337 // Integer
+ case fileComment = 210 // Integer
+ case fileNewName = 211 // String
+ case fileType = 213 // Integer
+ case quotingMessage = 214 // String?
+ case automaticResponse = 215 // String
+ case folderItemCount = 220 // Integer
+ case userNameWithInfo = 300 // Data { user id: 2, icon id: 2, user flags: 2, user name size: 2, user name: size }
+ case newsCategoryGUID = 319 // Data?
+ case newsCategoryListData = 320 // Data { type: 1 (1 = folder, 10 = category, 255 = other), category name: rest }
+ case newsArticleListData = 321 // Data
+ case newsCategoryName = 322 // String
+ case newsCategoryListData15 = 323 // Data
+ case newsPath = 325 // Data
+ case newsArticleID = 326 // Integer
+ case newsArticleDataFlavor = 327 // String
+ case newsArticleTitle = 328 // String
+ case newsArticlePoster = 329 // String
+ case newsArticleDate = 330 // Data { year: 2, ms: 2, secs: 4 }
+ case newsArticlePrevious = 331 // Integer
+ case newsArticleNext = 332 // Integer
+ case newsArticleData = 333 // Data
+ case newsArticleFlags = 334 // Integer
+ case newsArticleParentArticle = 335 // Integer
+ case newsArticleFirstChildArticle = 336 // Integer
+ case newsArticleRecursiveDelete = 337 // Integer
}
enum HotlineTransactionType: UInt16 {
@@ -910,25 +968,25 @@ enum HotlineTransactionType: UInt16 {
case getMessageBoard = 101
case newMessage = 102 // Server
case oldPostNews = 103
- case serverMessage = 104 // Server
+ case serverMessage = 104 // Server
case sendChat = 105
- case chatMessage = 106 // Server
+ case chatMessage = 106 // Server
case login = 107
case sendInstantMessage = 108
case showAgreement = 109 // Server
case disconnectUser = 110
- case disconnectMessage = 111 // Server
+ case disconnectMessage = 111 // Server
case inviteToNewChat = 112
- case inviteToChat = 113 // Server
+ case inviteToChat = 113 // Server
case rejectChatInvite = 114
case joinChat = 115
case leaveChat = 116
- case notifyChatOfUserChange = 117 // Server
- case notifyChatOfUserDelete = 118 // Server
- case notifyChatSubject = 119 // Server
+ case notifyChatOfUserChange = 117 // Server
+ case notifyChatOfUserDelete = 118 // Server
+ case notifyChatSubject = 119 // Server
case setChatSubject = 120
case agreed = 121
- case serverBanner = 122 // Server
+ case serverBanner = 122 // Server
case getFileNameList = 200
case downloadFile = 202
case uploadFile = 203
@@ -939,15 +997,15 @@ enum HotlineTransactionType: UInt16 {
case moveFile = 208
case makeFileAlias = 209
case downloadFolder = 210
- case downloadInfo = 211 // Server
+ case downloadInfo = 211 // Server
case downloadBanner = 212
case uploadFolder = 213
case getNewsFile = 294
case postNews = 295
- case receiveNewsFile = 296 // Server
+ case receiveNewsFile = 296 // Server
case getUserNameList = 300
- case notifyOfUserChange = 301 // Server
- case notifyOfUserDelete = 302 // Server
+ case notifyOfUserChange = 301 // Server
+ case notifyOfUserDelete = 302 // Server
case getClientInfoText = 303
case setClientUserInfo = 304
case getAccounts = 348
@@ -956,8 +1014,8 @@ enum HotlineTransactionType: UInt16 {
case deleteUser = 351
case getUser = 352
case setUser = 353
- case userAccess = 354 // Server
- case userBroadcast = 355 // Client & Server
+ case userAccess = 354 // Server
+ case userBroadcast = 355 // Client & Server
case getNewsCategoryNameList = 370
case getNewsArticleNameList = 371
case deleteNewsItem = 380
@@ -967,6 +1025,6 @@ enum HotlineTransactionType: UInt16 {
case postNewsArticle = 410
case deleteNewsArticle = 411
case connectionKeepAlive = 500
-
+
case unknown = 15000
}
diff --git a/Hotline/Hotline/HotlineTrackerClient.swift b/Hotline/Hotline/HotlineTrackerClient.swift
index 3c1ad85..c3c1670 100644
--- a/Hotline/Hotline/HotlineTrackerClient.swift
+++ b/Hotline/Hotline/HotlineTrackerClient.swift
@@ -5,12 +5,12 @@ struct HotlineTracker: Identifiable, Equatable {
let id: UUID = UUID()
var address: String
var port: UInt16
-
+
init(_ address: String, port: UInt16 = 5498) {
self.address = address
self.port = port
}
-
+
static func == (lhs: HotlineTracker, rhs: HotlineTracker) -> Bool {
return lhs.address == rhs.address && lhs.port == rhs.port
}
@@ -31,24 +31,24 @@ private enum HotlineTrackerStage {
class HotlineTrackerClient {
static let MagicPacket: [UInt8] = [
- 0x48, 0x54, 0x52, 0x4B, // 'HTRK'
- 0x00, 0x01 // Version
+ 0x48, 0x54, 0x52, 0x4B, // 'HTRK'
+ 0x00, 0x01, // Version
]
-
+
private var tracker: HotlineTracker
private var connectionStatus: HotlineTrackerStatus = .disconnected
private var servers: [HotlineServer] = []
private var socket: NetSocket = NetSocket()
private var stage: HotlineTrackerStage = .magic
-
+
private var serverAddress: String
private var serverPort: Int
private var expectedDataLength: Int = 0
private var serverCount: Int = 0
-
+
private var fetchContinuation: CheckedContinuation<[HotlineServer], any Error>?
-
+
init() {
let t = HotlineTracker("hltracker.com")
self.tracker = t
@@ -56,95 +56,99 @@ class HotlineTrackerClient {
self.serverPort = Int(t.port)
self.socket.delegate = self
}
-
+
init(tracker: HotlineTracker) {
self.tracker = tracker
self.serverAddress = tracker.address
self.serverPort = Int(tracker.port)
self.socket.delegate = self
}
-
+
@MainActor func fetchServers(address: String, port: Int) async throws -> [HotlineServer] {
self.serverAddress = address
self.serverPort = Int(port)
-
+
self.reset()
-
+
return try await withCheckedThrowingContinuation { [weak self] continuation in
self?.fetchContinuation = continuation
self?.connect()
}
}
-
+
@MainActor func close() {
self.socket.close()
}
-
+
// MARK: -
-
+
@MainActor private func reset() {
self.expectedDataLength = 0
self.serverCount = 0
self.servers = []
}
-
+
@MainActor private func connect() {
self.socket.close()
-
+
self.connectionStatus = .connecting
self.socket.connect(host: self.serverAddress, port: self.serverPort)
self.socket.write(HotlineTrackerClient.MagicPacket)
}
-
+
@MainActor private func receiveMagic() {
- guard self.stage == .magic, self.socket.available >= HotlineTrackerClient.MagicPacket.count else {
+ guard self.stage == .magic, self.socket.available >= HotlineTrackerClient.MagicPacket.count
+ else {
return
}
-
+
let magic: [UInt8] = self.socket.read(count: HotlineTrackerClient.MagicPacket.count)
-
+
if magic != HotlineTrackerClient.MagicPacket {
self.socket.close()
return
}
-
+
self.stage = .header
self.receiveHeader()
}
-
+
@MainActor private func receiveHeader() {
guard self.stage == .header, self.socket.available >= 8 else {
return
}
-
+
var header: [UInt8] = self.socket.read(count: 8)
guard let messageType = header.consumeUInt16(),
- let dataLength = header.consumeUInt16(),
- let numberOfServers = header.consumeUInt16(),
- let numberOfServers2 = header.consumeUInt16() else {
+ let dataLength = header.consumeUInt16(),
+ let numberOfServers = header.consumeUInt16(),
+ let numberOfServers2 = header.consumeUInt16()
+ else {
self.socket.close()
return
}
-
- print("HotlineTrackerClient: Received response header ", messageType, dataLength, numberOfServers, numberOfServers2)
-
+
+ print(
+ "HotlineTrackerClient: Received response header ", messageType, dataLength, numberOfServers,
+ numberOfServers2)
+
self.expectedDataLength = Int(dataLength)
- self.expectedDataLength -= 4 // Remove the size of the two server count fields
+ self.expectedDataLength -= 4 // Remove the size of the two server count fields
self.serverCount = Int(numberOfServers)
-
+
self.stage = .listing
self.receiveListing()
}
-
+
@MainActor private func receiveListing() {
guard self.stage == .listing, self.socket.available >= self.expectedDataLength else {
return
}
-
+
self.parseListing(self.socket.read(count: self.expectedDataLength))
}
-
+
@MainActor private func parseListing(_ listingBytes: [UInt8]) {
// IP address (4 bytes)
// Port number (2 bytes)
@@ -154,11 +158,11 @@ class HotlineTrackerClient {
// Name (name size)
// Description size (1 byte)
// Description (description size)
-
+
var bytes: [UInt8] = listingBytes
let trackerSeparatorRegex = /^[-]+$/
var foundServers: [HotlineServer] = []
-
+
for _ in 1...self.serverCount {
guard
let ip_1 = bytes.consumeUInt8(),
@@ -169,19 +173,22 @@ class HotlineTrackerClient {
let userCount = bytes.consumeUInt16(),
bytes.consume(2),
let serverName = bytes.consumePString(),
- let serverDescription = bytes.consumePString() else {
+ let serverDescription = bytes.consumePString()
+ else {
print("HotlineTrackerClient: Data isn't long enough for next server")
break
}
-
+
// Ignore servers that are just used as dividers in the tracker listing.
let validName = try? trackerSeparatorRegex.prefixMatch(in: serverName)
if validName == nil {
- let server = HotlineServer(address: "\(ip_1).\(ip_2).\(ip_3).\(ip_4)", port: port, users: userCount, name: serverName, description: serverDescription)
+ let server = HotlineServer(
+ address: "\(ip_1).\(ip_2).\(ip_3).\(ip_4)", port: port, users: userCount,
+ name: serverName, description: serverDescription)
foundServers.append(server)
}
}
-
+
self.servers = foundServers
self.stage = .done
self.socket.close()
@@ -194,25 +201,24 @@ extension HotlineTrackerClient: NetSocketDelegate {
@MainActor func netsocketConnected(socket: NetSocket) {
self.connectionStatus = .connected
}
-
+
@MainActor func netsocketDisconnected(socket: NetSocket, error: Error?) {
self.stage = .magic
self.connectionStatus = .disconnected
-
+
let servers = self.servers
self.reset()
-
+
if let continuation = self.fetchContinuation {
self.fetchContinuation = nil
if let err = error {
continuation.resume(throwing: err)
- }
- else {
+ } else {
continuation.resume(returning: servers)
}
}
}
-
+
@MainActor func netsocketReceived(socket: NetSocket, bytes: [UInt8]) {
switch self.stage {
case .magic:
diff --git a/Hotline/Hotline/HotlineTransferClient.swift b/Hotline/Hotline/HotlineTransferClient.swift
index 4322c70..7f62518 100644
--- a/Hotline/Hotline/HotlineTransferClient.swift
+++ b/Hotline/Hotline/HotlineTransferClient.swift
@@ -29,17 +29,20 @@ enum HotlineTransferStatus: Equatable {
enum HotlineFileForkType: UInt32 {
case none = 0
case unsupported = 1
- case info = 0x494E464F // 'INFO'
- case data = 0x44415441 // 'DATA'
- case resource = 1296122706 // 'MACR'
+ case info = 0x494E_464F // 'INFO'
+ case data = 0x4441_5441 // 'DATA'
+ case resource = 1_296_122_706 // 'MACR'
}
protocol HotlineTransferDelegate: AnyObject {
- func hotlineTransferStatusChanged(client: HotlineTransferClient, reference: UInt32, status: HotlineTransferStatus, timeRemaining: TimeInterval)
+ func hotlineTransferStatusChanged(
+ client: HotlineTransferClient, reference: UInt32, status: HotlineTransferStatus,
+ timeRemaining: TimeInterval)
}
protocol HotlineFileDownloadClientDelegate: HotlineTransferDelegate {
- func hotlineFileDownloadReceivedInfo(client: HotlineFileDownloadClient, reference: UInt32, info: HotlineFileInfoFork)
+ func hotlineFileDownloadReceivedInfo(
+ client: HotlineFileDownloadClient, reference: UInt32, info: HotlineFileInfoFork)
func hotlineFileDownloadComplete(client: HotlineFileDownloadClient, reference: UInt32, at: URL)
}
@@ -77,7 +80,7 @@ protocol HotlineTransferClient {
var serverPort: NWEndpoint.Port { get }
var referenceNumber: UInt32 { get }
var status: HotlineTransferStatus { get set }
-
+
func start()
func cancel()
}
@@ -88,9 +91,9 @@ class HotlineFileUploadClient: HotlineTransferClient {
let serverAddress: NWEndpoint.Host
let serverPort: NWEndpoint.Port
let referenceNumber: UInt32
-
+
weak var delegate: HotlineFileUploadClientDelegate? = nil
-
+
private var connection: NWConnection?
private var stage: HotlineFileUploadStage = .magic
private var payloadSize: UInt32 = 0
@@ -101,28 +104,29 @@ class HotlineFileUploadClient: HotlineTransferClient {
private let infoForkData: Data
private let dataForkSize: UInt32
private let resourceForkSize: UInt32
-
+
var status: HotlineTransferStatus = .unconnected {
didSet {
DispatchQueue.main.async {
- self.delegate?.hotlineTransferStatusChanged(client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0)
+ self.delegate?.hotlineTransferStatusChanged(
+ client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0)
}
}
}
-
+
init?(upload fileURL: URL, address: String, port: UInt16, reference: UInt32) {
guard let payloadSize = FileManager.default.getFlattenedFileSize(fileURL) else {
return nil
}
-
+
guard let infoFork = HotlineFileInfoFork(file: fileURL) else {
return nil
}
-
+
guard let forkSizes = try? FileManager.default.getFileForkSizes(fileURL) else {
return nil
}
-
+
self.serverAddress = NWEndpoint.Host(address)
self.serverPort = NWEndpoint.Port(rawValue: port + 1)!
self.referenceNumber = reference
@@ -134,40 +138,39 @@ class HotlineFileUploadClient: HotlineTransferClient {
self.resourceForkSize = forkSizes.resourceForkSize
if forkSizes.resourceForkSize > 0 {
self.fileResourceURL = fileURL.urlForResourceFork()
- }
- else {
+ } else {
self.fileResourceURL = nil
}
}
-
+
deinit {
self.invalidate()
}
-
+
func start() {
guard self.status == .unconnected else {
return
}
-
+
let _ = self.fileURL.startAccessingSecurityScopedResource()
let _ = self.fileResourceURL?.stopAccessingSecurityScopedResource()
-
+
self.bytesSent = 0
self.connect()
}
-
+
func cancel() {
self.delegate = nil
-
+
if self.status == .unconnected {
return
}
-
+
self.invalidate()
-
+
print("HotlineFileUploadClient: Cancelled upload")
}
-
+
private func connect() {
self.connection = NWConnection(host: self.serverAddress, port: self.serverPort, using: .tcp)
self.connection?.stateUpdateHandler = { [weak self] (newState: NWConnection.State) in
@@ -210,146 +213,152 @@ class HotlineFileUploadClient: HotlineTransferClient {
return
}
}
-
+
self.status = .connecting
self.connection?.start(queue: .global())
}
-
+
private func invalidate() {
if let c = self.connection {
c.stateUpdateHandler = nil
c.cancel()
-
+
self.connection = nil
}
-
+
self.stage = .magic
-
+
if let fh = self.fileHandle {
try? fh.close()
self.fileHandle = nil
}
-
+
self.fileURL.stopAccessingSecurityScopedResource()
self.fileResourceURL?.stopAccessingSecurityScopedResource()
}
-
+
private func sendComplete() {
guard let c = self.connection else {
self.invalidate()
print("HotlineFileUploadClient: invalid connection to send data.")
return
}
-
+
self.status = .completing
- c.send(content: nil, contentContext: .finalMessage, completion: .contentProcessed({ error in
- }))
+ c.send(
+ content: nil, contentContext: .finalMessage,
+ completion: .contentProcessed({ error in
+ }))
}
-
+
private func sendFileData(_ data: Data) {
guard let c = self.connection else {
self.invalidate()
-
+
print("HotlineFileUploadClient: invalid connection to send data.")
return
}
-
+
let dataSent: Int = data.count
- c.send(content: data, completion: .contentProcessed({ [weak self] error in
- guard let client = self,
- error == nil else {
- self?.status = .failed(.failedToConnect)
- self?.invalidate()
- return
- }
-
-
- client.bytesSent += dataSent
- client.status = .progress(Double(client.bytesSent) / Double(client.payloadSize))
-
- client.send()
- }))
+ c.send(
+ content: data,
+ completion: .contentProcessed({ [weak self] error in
+ guard let client = self,
+ error == nil
+ else {
+ self?.status = .failed(.failedToConnect)
+ self?.invalidate()
+ return
+ }
+
+ client.bytesSent += dataSent
+ client.status = .progress(Double(client.bytesSent) / Double(client.payloadSize))
+
+ client.send()
+ }))
}
-
+
private func send() {
- guard let _ = self.connection else {
+ guard self.connection != nil else {
self.invalidate()
print("HotlineFileUploadClient: Invalid connection to send.")
return
}
-
+
switch self.stage {
case .magic:
print("Upload: Starting upload for \(self.fileURL)")
print("Upload: Sending magic")
self.status = .progress(0.0)
-
+
let magicData = Data(endian: .big) {
"HTXF".fourCharCode()
self.referenceNumber
self.payloadSize
UInt32.zero
}
-// var magicData = Data()
-// magicData.appendUInt32("HTXF".fourCharCode())
-// magicData.appendUInt32(self.referenceNumber)
-// magicData.appendUInt32(self.payloadSize)
-// magicData.appendUInt32(0)
+ // var magicData = Data()
+ // magicData.appendUInt32("HTXF".fourCharCode())
+ // magicData.appendUInt32(self.referenceNumber)
+ // magicData.appendUInt32(self.payloadSize)
+ // magicData.appendUInt32(0)
self.stage = .fileHeader
self.sendFileData(magicData)
-
+
case .fileHeader:
print("Upload: Sending file header")
if let header = HotlineFileHeader(file: self.fileURL) {
self.stage = .fileInfoForkHeader
self.sendFileData(header.data())
}
-
+
case .fileInfoForkHeader:
print("Upload: Sending info fork header")
- let header = HotlineFileForkHeader(type: HotlineFileForkType.info.rawValue, dataSize: UInt32(self.infoForkData.count))
+ let header = HotlineFileForkHeader(
+ type: HotlineFileForkType.info.rawValue, dataSize: UInt32(self.infoForkData.count))
self.stage = .fileInfoFork
self.sendFileData(header.data())
-
+
case .fileInfoFork:
print("Upload: Sending info fork")
self.stage = .fileDataForkHeader
self.sendFileData(self.infoForkData)
-
+
case .fileDataForkHeader:
guard self.dataForkSize > 0 else {
print("Upload: Data fork empty, skipping to resource fork")
self.stage = .fileResourceForkHeader
fallthrough
}
-
+
do {
let fh = try FileHandle(forReadingFrom: self.fileURL)
self.fileHandle = fh
self.stage = .fileDataFork
-
- let header = HotlineFileForkHeader(type: HotlineFileForkType.data.rawValue, dataSize: self.dataForkSize)
-
+
+ let header = HotlineFileForkHeader(
+ type: HotlineFileForkType.data.rawValue, dataSize: self.dataForkSize)
+
print("Upload: Sending data fork header \(self.dataForkSize)")
self.sendFileData(header.data())
- }
- catch {
+ } catch {
print("Upload: Error opening data fork", error)
self.invalidate()
return
}
-
+
case .fileDataFork:
guard self.dataForkSize > 0,
- let fh = self.fileHandle else {
+ let fh = self.fileHandle
+ else {
print("Upload: Data fork empty, skipping to resource fork")
self.stage = .fileResourceForkHeader
try? self.fileHandle?.close()
self.fileHandle = nil
fallthrough
}
-
+
do {
let fileData = try fh.read(upToCount: 4 * 1024)
if fileData == nil || fileData?.isEmpty == true {
@@ -359,47 +368,49 @@ class HotlineFileUploadClient: HotlineTransferClient {
self.fileHandle = nil
fallthrough
}
-
+
print("Upload: Sending data Fork \(String(describing: fileData?.count))")
self.sendFileData(fileData!)
- }
- catch {
+ } catch {
self.invalidate()
print("Upload: Error reading data fork", error)
return
}
-
+
case .fileResourceForkHeader:
guard self.resourceForkSize > 0,
- let resourceURL = self.fileResourceURL else {
+ let resourceURL = self.fileResourceURL
+ else {
print("Upload: Skipping resource fork header")
self.stage = .fileComplete
fallthrough
}
-
+
print("Upload: Sending resource fork header")
guard let fh = try? FileHandle(forReadingFrom: resourceURL) else {
print("Upload: Error reading resource fork")
self.invalidate()
return
}
-
- let header = HotlineFileForkHeader(type: HotlineFileForkType.resource.rawValue, dataSize: self.resourceForkSize)
-
+
+ let header = HotlineFileForkHeader(
+ type: HotlineFileForkType.resource.rawValue, dataSize: self.resourceForkSize)
+
self.fileHandle = fh
self.stage = .fileResourceFork
self.sendFileData(header.data())
-
+
case .fileResourceFork:
guard self.resourceForkSize > 0,
- let fh = self.fileHandle else {
+ let fh = self.fileHandle
+ else {
print("Upload: Resource fork empty, skipping to completion")
self.stage = .fileComplete
try? self.fileHandle?.close()
self.fileHandle = nil
fallthrough
}
-
+
do {
let resourceData = try fh.read(upToCount: 4 * 1024)
if resourceData == nil || resourceData?.isEmpty == true {
@@ -409,17 +420,16 @@ class HotlineFileUploadClient: HotlineTransferClient {
self.fileHandle = nil
fallthrough
}
-
+
print("Upload: Sending resource fork \(String(describing: resourceData?.count))")
self.sendFileData(resourceData!)
- }
- catch {
+ } catch {
self.invalidate()
print("Upload: Error reading resource fork", error)
return
}
break
-
+
case .fileComplete:
print("Upload: Complete!")
self.sendComplete()
@@ -434,64 +444,65 @@ class HotlineFilePreviewClient: HotlineTransferClient {
let serverPort: NWEndpoint.Port
let referenceNumber: UInt32
let referenceDataSize: UInt32
-
+
weak var delegate: HotlineFilePreviewClientDelegate? = nil
-
+
var status: HotlineTransferStatus = .unconnected {
didSet {
DispatchQueue.main.async {
- self.delegate?.hotlineTransferStatusChanged(client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0)
+ self.delegate?.hotlineTransferStatusChanged(
+ client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0)
}
}
}
-
+
private var connection: NWConnection?
private var transferStage: HotlineFileTransferStage = .fileHeader
private var fileBytes = Data()
private var fileBytesTransferred: Int = 0
-
+
init(address: String, port: UInt16, reference: UInt32, size: UInt32) {
self.serverAddress = NWEndpoint.Host(address)
self.serverPort = NWEndpoint.Port(rawValue: port + 1)!
self.referenceNumber = reference
self.referenceDataSize = size
}
-
+
deinit {
self.invalidate()
}
-
+
func start() {
guard self.status == .unconnected else {
return
}
-
+
self.connect()
}
-
+
func cancel() {
self.delegate = nil
-
+
if self.status == .unconnected {
return
}
-
+
self.invalidate()
-
+
print("HotlineFilePreviewClient: Cancelled preview transfer")
}
-
+
private func invalidate() {
if let c = self.connection {
c.stateUpdateHandler = nil
c.cancel()
-
+
self.connection = nil
}
-
+
self.fileBytes = Data()
}
-
+
private func connect() {
self.connection = NWConnection(host: self.serverAddress, port: self.serverPort, using: .tcp)
self.connection?.stateUpdateHandler = { [weak self] (newState: NWConnection.State) in
@@ -522,74 +533,78 @@ class HotlineFilePreviewClient: HotlineTransferClient {
return
}
}
-
+
self.status = .connecting
self.connection?.start(queue: .global())
}
-
+
private func sendMagic() {
guard let c = connection, self.status == .connected else {
self.invalidate()
print("HotlineFileClient: invalid connection to send header.")
return
}
-
+
let headerData = Data(endian: .big) {
"HTXF".fourCharCode()
self.referenceNumber
UInt32.zero
UInt32.zero
}
-
- c.send(content: headerData, completion: .contentProcessed { [weak self] (error) in
- guard let self = self else {
- return
- }
-
- guard error == nil else {
- self.status = .failed(.failedToConnect)
- self.invalidate()
- return
- }
-
- self.status = .progress(0.0)
- self.receive()
- })
+
+ c.send(
+ content: headerData,
+ completion: .contentProcessed { [weak self] (error) in
+ guard let self = self else {
+ return
+ }
+
+ guard error == nil else {
+ self.status = .failed(.failedToConnect)
+ self.invalidate()
+ return
+ }
+
+ self.status = .progress(0.0)
+ self.receive()
+ })
}
-
+
private func receive() {
guard let c = self.connection else {
return
}
-
- c.receive(minimumIncompleteLength: 1, maximumLength: Int(UInt16.max)) { [weak self] (data, context, isComplete, error) in
+
+ c.receive(minimumIncompleteLength: 1, 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.fileBytesTransferred += newData.count
self.fileBytes.append(newData)
self.status = .progress(Double(self.fileBytesTransferred) / Double(self.referenceDataSize))
- print("HotlineFilePreviewClient: Download progress", self.fileBytesTransferred, self.referenceDataSize, isComplete)
+ print(
+ "HotlineFilePreviewClient: Download progress", self.fileBytesTransferred,
+ self.referenceDataSize, isComplete)
}
-
+
if self.fileBytesTransferred < Int(self.referenceDataSize) {
self.receive()
- }
- else {
+ } else {
print("HotlineFilePreviewClient: Complete")
let data = self.fileBytes
-
+
self.status = .completed
self.invalidate()
-
+
let reference = self.referenceNumber
DispatchQueue.main.sync {
self.delegate?.hotlineFilePreviewComplete(client: self, reference: reference, data: data)
@@ -605,24 +620,25 @@ class HotlineFileDownloadClient: HotlineTransferClient {
let serverAddress: NWEndpoint.Host
let serverPort: NWEndpoint.Port
let referenceNumber: UInt32
-
+
private var connection: NWConnection?
private var transferStage: HotlineFileTransferStage = .fileHeader
-
+
weak var delegate: HotlineFileDownloadClientDelegate? = nil
-
+
var status: HotlineTransferStatus = .unconnected {
didSet {
DispatchQueue.main.async {
- self.delegate?.hotlineTransferStatusChanged(client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0)
+ self.delegate?.hotlineTransferStatusChanged(
+ client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0)
}
}
}
-
+
private let referenceDataSize: UInt32
private var fileBytes = Data()
private var fileResourceBytes = Data()
-
+
private var fileHeader: HotlineFileHeader? = nil
private var fileCurrentForkHeader: HotlineFileForkHeader? = nil
private var fileCurrentForkBytesLeft: Int = 0
@@ -631,7 +647,7 @@ class HotlineFileDownloadClient: HotlineTransferClient {
private var filePath: String? = nil
private var fileBytesTransferred: Int = 0
private var fileProgress: Progress
-
+
init(address: String, port: UInt16, reference: UInt32, size: UInt32) {
self.serverAddress = NWEndpoint.Host(address)
self.serverPort = NWEndpoint.Port(rawValue: port + 1)!
@@ -640,11 +656,11 @@ class HotlineFileDownloadClient: HotlineTransferClient {
self.transferStage = .fileHeader
self.fileProgress = Progress(totalUnitCount: Int64(size))
}
-
+
deinit {
self.invalidate()
}
-
+
func start() {
guard self.status == .unconnected else {
return
@@ -653,29 +669,29 @@ class HotlineFileDownloadClient: HotlineTransferClient {
self.filePath = nil
self.connect()
}
-
+
func start(to fileURL: URL) {
guard self.status == .unconnected else {
return
}
-
+
self.filePath = fileURL.path
self.connect()
}
-
+
func cancel() {
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 let downloadPath = self.filePath {
print("HotlineFileClient: Deleting file fragment at", downloadPath)
if FileManager.default.isDeletableFile(atPath: downloadPath) {
@@ -683,12 +699,12 @@ class HotlineFileDownloadClient: HotlineTransferClient {
}
self.filePath = nil
}
-
+
self.invalidate()
-
+
print("HotlineFileClient: Cancelled transfer")
}
-
+
private func connect() {
self.connection = NWConnection(host: self.serverAddress, port: self.serverPort, using: .tcp)
self.connection?.stateUpdateHandler = { [weak self] (newState: NWConnection.State) in
@@ -719,87 +735,90 @@ class HotlineFileDownloadClient: HotlineTransferClient {
return
}
}
-
+
self.status = .connecting
self.connection?.start(queue: .global())
}
-
+
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
}
-
+
self.fileProgress.unpublish()
}
-
+
private func sendMagic() {
guard let c = connection, self.status == .connected else {
self.invalidate()
print("HotlineFileClient: invalid connection to send header.")
return
}
-
+
let headerData = Data(endian: .big) {
"HTXF".fourCharCode()
self.referenceNumber
UInt32.zero
UInt32.zero
}
-
- c.send(content: headerData, completion: .contentProcessed { [weak self] (error) in
- guard let self = self else {
- return
- }
-
- guard error == nil else {
- self.status = .failed(.failedToConnect)
- self.invalidate()
- return
- }
-
- self.status = .progress(0.0)
- self.receiveFile()
- })
+
+ c.send(
+ content: headerData,
+ completion: .contentProcessed { [weak self] (error) in
+ guard let self = self else {
+ return
+ }
+
+ guard error == nil else {
+ self.status = .failed(.failedToConnect)
+ self.invalidate()
+ return
+ }
+
+ self.status = .progress(0.0)
+ self.receiveFile()
+ })
}
-
+
private func receiveFile() {
guard let c = self.connection else {
return
}
-
- c.receive(minimumIncompleteLength: 1, maximumLength: Int(UInt16.max)) { [weak self] (data, context, isComplete, error) in
+
+ c.receive(minimumIncompleteLength: 1, 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.fileBytesTransferred += newData.count
self.fileBytes.append(newData)
self.fileProgress.completedUnitCount = Int64(self.fileBytesTransferred)
self.status = .progress(Double(self.fileBytesTransferred) / 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) {
@@ -810,56 +829,56 @@ class HotlineFileDownloadClient: HotlineTransferClient {
}
case .fileForkHeader:
if let forkHeader = HotlineFileForkHeader(from: self.fileBytes) {
-// let fileForkHeader = HotlineFileForkHeader(from: self.fileBytes)
+ // let fileForkHeader = HotlineFileForkHeader(from: self.fileBytes)
self.fileBytes.removeSubrange(0..<HotlineFileForkHeader.DataSize)
self.fileCurrentForkHeader = forkHeader
self.fileCurrentForkBytesLeft = Int(forkHeader.dataSize)
-
+
if forkHeader.isInfoFork {
print("HotlineFileClient: Downloading info fork")
self.transferStage = .fileInfoFork
- }
- else if forkHeader.isDataFork {
+ } else if forkHeader.isDataFork {
print("HotlineFileClient: Downloading data fork")
self.transferStage = .fileDataFork
- }
- else if forkHeader.isResourceFork {
+ } else if forkHeader.isResourceFork {
print("HotlineFileClient: Downloading resource fork")
self.fileResourceBytes = Data()
self.transferStage = .fileResourceFork
- }
- else {
+ } else {
print("HotlineFileClient: Downloading unsupported fork")
self.transferStage = .fileUnsupportedFork
}
-
+
keepProcessing = true
}
case .fileInfoFork:
- if let infoForkDataSize = self.fileCurrentForkHeader?.dataSize, self.fileBytes.count >= infoForkDataSize {
+ 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)
-
+
// Create file in Downloads folder if we don't have a destination already.
if !self.prepareDownloadFile(name: infoFork.name) {
print("FAILED TO CREATE FILE ON DISK")
}
-
+
let reference = self.referenceNumber
DispatchQueue.main.async {
- self.delegate?.hotlineFileDownloadReceivedInfo(client: self, reference: reference, info: infoFork)
+ self.delegate?.hotlineFileDownloadReceivedInfo(
+ client: self, reference: reference, info: infoFork)
}
}
-
+
keepProcessing = true
}
case .fileDataFork:
@@ -867,25 +886,23 @@ class HotlineFileDownloadClient: HotlineTransferClient {
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 {
+ } else {
self.fileCurrentForkBytesLeft -= dataToWrite.count
self.fileBytes = Data()
}
-
+
try f.write(contentsOf: dataToWrite)
- }
- catch {
+ } catch {
print("DOWNLOAD WRITE ERROR", error)
}
}
@@ -893,114 +910,111 @@ class HotlineFileDownloadClient: HotlineTransferClient {
case .fileResourceFork:
if self.fileBytes.count > 0 {
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 {
+ } else {
self.fileCurrentForkBytesLeft -= dataToWrite.count
self.fileBytes = Data()
}
-
+
print("WRITING TO RESOURCE FORK", dataToWrite.count)
-
+
self.fileResourceBytes.append(dataToWrite)
}
case .fileUnsupportedFork:
if self.fileBytes.count > 0 {
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 {
+ } else {
self.fileCurrentForkBytesLeft -= dataToWrite.count
self.fileBytes = Data()
}
}
}
} while keepProcessing
-
+
if self.fileBytesTransferred < Int(self.referenceDataSize) {
// If we still have more to download, then receive more data.
self.receiveFile()
return
- }
- else {
+ } else {
self.invalidate()
-
+
if self.fileResourceBytes.count > 0 {
let _ = self.writeResourceFork()
self.fileResourceBytes = Data()
}
-
+
self.status = .completed
-
+
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)
-
- self.delegate?.hotlineFileDownloadComplete(client: self, reference: self.referenceNumber, at: downloadURL)
-
+
+ self.delegate?.hotlineFileDownloadComplete(
+ client: self, reference: self.referenceNumber, 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)
+ // 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
}
}
}
}
}
-
+
private func writeResourceFork() -> Bool {
guard let filePath = self.filePath else {
return false
}
-
+
var resolvedFileURL = URL(filePath: filePath)
resolvedFileURL.resolveSymlinksInPath()
-
+
let resourceFilePath = resolvedFileURL.appendingPathComponent("..namedfork/rsrc")
-
+
do {
try self.fileResourceBytes.write(to: resourceFilePath)
- }
- catch {
+ } catch {
return false
}
-
+
return true
}
-
+
private func prepareDownloadFile(name: String) -> Bool {
var filePath: String
-
+
if self.filePath != nil {
filePath = self.filePath!
- }
- else {
+ } else {
let folderURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0]
filePath = folderURL.generateUniqueFilePath(filename: name)
}
-
+
var fileAttributes: [FileAttributeKey: Any] = [:]
if let creatorCode = self.fileInfoFork?.creator {
fileAttributes[.hfsCreatorCode] = creatorCode as NSNumber
@@ -1015,13 +1029,15 @@ class HotlineFileDownloadClient: HotlineTransferClient {
fileAttributes[.modificationDate] = modifiedDate as NSDate
}
if let comment = self.fileInfoFork?.comment {
- if let commentPlistData = try? PropertyListSerialization.data(fromPropertyList: comment, format: .binary, options: 0) {
+ if let commentPlistData = try? PropertyListSerialization.data(
+ fromPropertyList: comment, format: .binary, options: 0)
+ {
fileAttributes[FileAttributeKey(rawValue: "NSFileExtendedAttributes")] = [
FileAttributeKey(rawValue: "com.apple.metadata:kMDItemFinderComment"): commentPlistData
]
}
}
-
+
if FileManager.default.createFile(atPath: filePath, contents: nil, attributes: fileAttributes) {
if let h = FileHandle(forWritingAtPath: filePath) {
self.filePath = filePath
@@ -1041,39 +1057,38 @@ class HotlineFileDownloadClient: HotlineTransferClient {
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.forkCount = data.readUInt16(at: 4 + 2 + 16)!
}
-
+
init?(file fileURL: URL) {
guard FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false)) else {
return nil
}
-
+
self.format = "FILP".fourCharCode()
self.version = 1
-
+
let resourceURL = fileURL.appendingPathComponent("..namedfork/rsrc")
if FileManager.default.fileExists(atPath: resourceURL.path(percentEncoded: false)) {
self.forkCount = 3
- }
- else {
+ } else {
self.forkCount = 2
}
}
-
+
func data() -> Data {
Data(endian: .big) {
self.format
@@ -1088,29 +1103,29 @@ struct HotlineFileHeader {
struct HotlineFileForkHeader {
static let DataSize: Int = 4 + 4 + 4 + 4
-
+
let forkType: UInt32
let compressionType: UInt32
let dataSize: UInt32
-
+
init(type: UInt32, dataSize: UInt32) {
self.forkType = type
self.compressionType = 0
self.dataSize = dataSize
}
-
+
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)!
}
-
+
func data() -> Data {
Data(endian: .big) {
self.forkType
@@ -1119,15 +1134,15 @@ struct HotlineFileForkHeader {
self.dataSize
}
}
-
+
var isInfoFork: Bool {
return self.forkType == HotlineFileForkType.info.rawValue
}
-
+
var isDataFork: Bool {
return self.forkType == HotlineFileForkType.data.rawValue
}
-
+
var isResourceFork: Bool {
return self.forkType == HotlineFileForkType.resource.rawValue
}
@@ -1137,7 +1152,7 @@ struct HotlineFileForkHeader {
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
@@ -1149,57 +1164,56 @@ struct HotlineFileInfoFork {
let name: String
let comment: String?
var headerSize: Int
-
+
init?(file fileURL: URL) {
guard FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false)) else {
return nil
}
-
+
self.platform = "AMAC".fourCharCode()
-
+
if let hfsInfo = try? FileManager.default.getHFSTypeAndCreator(fileURL) {
self.type = hfsInfo.hfsType
self.creator = hfsInfo.hfsCreator
- }
- else {
+ } else {
self.type = 0
self.creator = 0
}
-
+
self.flags = 0
self.platformFlags = 0
-
+
let dateInfo = FileManager.default.getCreatedAndModifiedDates(fileURL)
self.createdDate = dateInfo.createdDate
self.modifiedDate = dateInfo.modifiedDate
-
+
self.nameScript = 0
self.name = fileURL.lastPathComponent
-
+
let fileComment = try? FileManager.default.getFinderComment(fileURL)
self.comment = fileComment ?? ""
-
+
self.headerSize = 0
}
-
+
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),
+
+ 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 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
@@ -1211,13 +1225,13 @@ struct HotlineFileInfoFork {
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
@@ -1227,7 +1241,7 @@ struct HotlineFileInfoFork {
if commentLength == 0x4441 {
commentCorrupted = true
}
-
+
if !commentCorrupted {
let (c, cl) = data.readLongPString(at: HotlineFileInfoFork.BaseDataSize + nl)
calculatedHeaderSize += 2
@@ -1239,19 +1253,19 @@ struct HotlineFileInfoFork {
}
}
}
-
+
self.comment = commentRead
self.headerSize = calculatedHeaderSize
return
}
}
-
+
return nil
}
-
+
func data() -> Data {
let fileName = self.name.data(using: .macOSRoman)!
-
+
let data = Data(endian: .big) {
self.platform
self.type
@@ -1269,7 +1283,7 @@ struct HotlineFileInfoFork {
commentData
}
}
-
+
return data
}
}
diff --git a/Hotline/Models/ApplicationState.swift b/Hotline/Models/ApplicationState.swift
index 6e077c5..3e90ed9 100644
--- a/Hotline/Models/ApplicationState.swift
+++ b/Hotline/Models/ApplicationState.swift
@@ -3,14 +3,14 @@ import SwiftUI
@Observable
final class ApplicationState {
static let shared = ApplicationState()
-
+
var activeHotline: Hotline? = nil
var activeServerState: ServerState? = nil
-
+
// Frontmost server window information
var activeServerID: UUID? = nil
var activeServerBanner: NSImage? = nil
var activeServerName: String? = nil
-
+
var cloudKitReady: Bool = false
}
diff --git a/Hotline/Models/Bookmark.swift b/Hotline/Models/Bookmark.swift
index b410f75..5afc2c0 100644
--- a/Hotline/Models/Bookmark.swift
+++ b/Hotline/Models/Bookmark.swift
@@ -11,131 +11,140 @@ enum BookmarkType: String, Codable {
final class Bookmark {
var type: BookmarkType = BookmarkType.server
var order: Int = 0
-
+
var name: String = ""
var address: String = ""
var port: Int = HotlinePorts.DefaultServerPort
-
+
@Attribute(.allowsCloudEncryption)
var login: String?
-
+
@Attribute(.allowsCloudEncryption)
var password: String?
-
+
@Attribute
var autoconnect: Bool = false
-
+
@Attribute(.ephemeral)
var expanded: Bool = false
-
+
@Attribute(.ephemeral)
var loading: Bool = false
-
+
@Attribute(.ephemeral)
var serverDescription: String? = nil
-
+
@Attribute(.ephemeral)
var serverUserCount: Int? = nil
-
+
@Transient
var servers: [Bookmark] = []
-
+
func hash(into hasher: inout Hasher) {
-
+
}
-
+
@Transient
var displayAddress: String {
switch self.type {
case .tracker:
if self.port == HotlinePorts.DefaultTrackerPort {
return self.address
- }
- else {
+ } else {
return "\(self.address):\(String(self.port))"
}
-
+
case .server, .temporary:
if self.port == HotlinePorts.DefaultServerPort {
return self.address
- }
- else {
+ } else {
return "\(self.address):\(String(self.port))"
}
}
}
-
+
@Transient
var server: Server? {
switch self.type {
case .tracker:
return nil
-
+
case .server, .temporary:
- return Server(name: self.name, description: self.serverDescription, address: self.address, port: self.port, login: self.login, password: self.password, autoconnect: self.autoconnect)
+ return Server(
+ name: self.name, description: self.serverDescription, address: self.address,
+ port: self.port, login: self.login, password: self.password, autoconnect: self.autoconnect)
}
}
-
+
static let DefaultBookmarks: [Bookmark] = [
- Bookmark(type: .server, name: "The Mobius Strip", address: "67.174.208.111", port: HotlinePorts.DefaultServerPort),
- Bookmark(type: .server, name: "System 7 Today", address: "hotline.system7today.com", port: HotlinePorts.DefaultServerPort),
- Bookmark(type: .tracker, name: "Featured Servers", address: "hltracker.com", port: HotlinePorts.DefaultTrackerPort)
+ Bookmark(
+ type: .server, name: "The Mobius Strip", address: "67.174.208.111",
+ port: HotlinePorts.DefaultServerPort),
+ Bookmark(
+ type: .server, name: "System 7 Today", address: "hotline.system7today.com",
+ port: HotlinePorts.DefaultServerPort),
+ Bookmark(
+ type: .tracker, name: "Featured Servers", address: "hltracker.com",
+ port: HotlinePorts.DefaultTrackerPort),
]
-
- init(type: BookmarkType, name: String, address: String, port: Int, login: String? = nil, password: String? = nil, autoconnect: Bool = false) {
+
+ init(
+ type: BookmarkType, name: String, address: String, port: Int, login: String? = nil,
+ password: String? = nil, autoconnect: Bool = false
+ ) {
self.type = type
self.name = name
self.address = address
self.port = port
-
+
self.login = login
self.password = password
-
+
self.autoconnect = autoconnect
}
-
+
init(temporaryServer server: Server) {
self.type = .temporary
-
+
self.name = server.name ?? server.address
self.address = server.address
self.port = server.port
-
+
self.serverDescription = server.description
self.serverUserCount = server.users
}
-
+
init?(fileData: Data, name: String? = nil) {
guard fileData.count <= 2000 else {
return nil
}
-
+
var fileDataArray: [UInt8] = [UInt8](fileData)
-
+
guard let headerValue = fileDataArray.consumeUInt32(),
- headerValue.fourCharCode() == "HTsc",
- let versionNumber = fileDataArray.consumeUInt16(),
- versionNumber == 1,
- fileDataArray.consume(128), // Skip 128 reserved bytes.
- let loginLength = fileDataArray.consumeUInt16(),
- let loginData: Data = fileDataArray.consumeBytes(32),
- let passwordLength = fileDataArray.consumeUInt16(),
- let passwordData: Data = fileDataArray.consumeBytes(32),
- let addressLength = fileDataArray.consumeUInt16(),
- let addressData: Data = fileDataArray.consumeBytes(256),
- let addressString = String(data: addressData[0..<Int(addressLength)], encoding: .ascii),
- let loginString = String(data: loginData[0..<Int(loginLength)], encoding: .ascii),
- let passwordString = String(data: passwordData[0..<Int(passwordLength)], encoding: .ascii) else {
+ headerValue.fourCharCode() == "HTsc",
+ let versionNumber = fileDataArray.consumeUInt16(),
+ versionNumber == 1,
+ fileDataArray.consume(128), // Skip 128 reserved bytes.
+ let loginLength = fileDataArray.consumeUInt16(),
+ let loginData: Data = fileDataArray.consumeBytes(32),
+ let passwordLength = fileDataArray.consumeUInt16(),
+ let passwordData: Data = fileDataArray.consumeBytes(32),
+ let addressLength = fileDataArray.consumeUInt16(),
+ let addressData: Data = fileDataArray.consumeBytes(256),
+ let addressString = String(data: addressData[0..<Int(addressLength)], encoding: .ascii),
+ let loginString = String(data: loginData[0..<Int(loginLength)], encoding: .ascii),
+ let passwordString = String(data: passwordData[0..<Int(passwordLength)], encoding: .ascii)
+ else {
return nil
}
-
+
let (addressHost, addressPort) = Server.parseServerAddressAndPort(addressString)
-
+
self.type = .server
if let name = name, !name.isEmpty {
self.name = name
- }
- else {
+ } else {
self.name = addressHost
}
self.address = addressHost
@@ -143,156 +152,158 @@ final class Bookmark {
self.login = loginString.isEmpty ? nil : loginString
self.password = passwordString.isEmpty ? nil : passwordString
}
-
+
convenience init?(fileURL bookmarkFileURL: URL) {
guard bookmarkFileURL.isFileURL,
- let fileAttributes = try? FileManager.default.attributesOfItem(atPath: bookmarkFileURL.path(percentEncoded: false)),
- let fileSize = fileAttributes[FileAttributeKey.size] as? UInt64,
- fileSize <= 2000,
- let fileData = try? Data(contentsOf: bookmarkFileURL) else {
+ let fileAttributes = try? FileManager.default.attributesOfItem(
+ atPath: bookmarkFileURL.path(percentEncoded: false)),
+ let fileSize = fileAttributes[FileAttributeKey.size] as? UInt64,
+ fileSize <= 2000,
+ let fileData = try? Data(contentsOf: bookmarkFileURL)
+ else {
return nil
}
-
+
print("Bookmark: Parsing Hotline bookmark file...")
-
- let fileName = bookmarkFileURL.deletingPathExtension().lastPathComponent.trimmingCharacters(in: .whitespacesAndNewlines)
-
+
+ let fileName = bookmarkFileURL.deletingPathExtension().lastPathComponent.trimmingCharacters(
+ in: .whitespacesAndNewlines)
+
self.init(fileData: fileData, name: fileName)
-
-// var fileDataArray: [UInt8] = [UInt8](fileData)
-//
-// guard let headerValue = fileDataArray.consumeUInt32(),
-// headerValue.fourCharCode() == "HTsc",
-// let versionNumber = fileDataArray.consumeUInt16(),
-// versionNumber == 1,
-// fileDataArray.consume(128), // Skip 128 reserved bytes.
-// let loginLength = fileDataArray.consumeUInt16(),
-// let loginData: Data = fileDataArray.consumeBytes(32),
-// let passwordLength = fileDataArray.consumeUInt16(),
-// let passwordData: Data = fileDataArray.consumeBytes(32),
-// let addressLength = fileDataArray.consumeUInt16(),
-// let addressData: Data = fileDataArray.consumeBytes(256),
-// let addressString = String(data: addressData[0..<Int(addressLength)], encoding: .ascii),
-// let loginString = String(data: loginData[0..<Int(loginLength)], encoding: .ascii),
-// let passwordString = String(data: passwordData[0..<Int(passwordLength)], encoding: .ascii) else {
-// return nil
-// }
-//
-// let (addressHost, addressPort) = Server.parseServerAddressAndPort(addressString)
-//
-// self.type = .server
-// self.name = fileName.isEmpty ? addressHost : fileName
-// self.address = addressHost
-// self.port = addressPort
-// self.login = loginString.isEmpty ? nil : loginString
-// self.password = passwordString.isEmpty ? nil : passwordString
+
+ // var fileDataArray: [UInt8] = [UInt8](fileData)
+ //
+ // guard let headerValue = fileDataArray.consumeUInt32(),
+ // headerValue.fourCharCode() == "HTsc",
+ // let versionNumber = fileDataArray.consumeUInt16(),
+ // versionNumber == 1,
+ // fileDataArray.consume(128), // Skip 128 reserved bytes.
+ // let loginLength = fileDataArray.consumeUInt16(),
+ // let loginData: Data = fileDataArray.consumeBytes(32),
+ // let passwordLength = fileDataArray.consumeUInt16(),
+ // let passwordData: Data = fileDataArray.consumeBytes(32),
+ // let addressLength = fileDataArray.consumeUInt16(),
+ // let addressData: Data = fileDataArray.consumeBytes(256),
+ // let addressString = String(data: addressData[0..<Int(addressLength)], encoding: .ascii),
+ // let loginString = String(data: loginData[0..<Int(loginLength)], encoding: .ascii),
+ // let passwordString = String(data: passwordData[0..<Int(passwordLength)], encoding: .ascii) else {
+ // return nil
+ // }
+ //
+ // let (addressHost, addressPort) = Server.parseServerAddressAndPort(addressString)
+ //
+ // self.type = .server
+ // self.name = fileName.isEmpty ? addressHost : fileName
+ // self.address = addressHost
+ // self.port = addressPort
+ // self.login = loginString.isEmpty ? nil : loginString
+ // self.password = passwordString.isEmpty ? nil : passwordString
}
-
+
func bookmarkFileData() -> Data? {
guard let addressData = self.displayAddress.data(using: .ascii) else {
return nil
}
-
+
let loginData: Data = self.login?.data(using: .ascii) ?? Data()
let passwordData: Data = self.password?.data(using: .ascii) ?? Data()
-
+
var fileData: Data = Data()
-
- fileData.appendUInt32("HTsc".fourCharCode()) // magic
- fileData.appendUInt16(0x0001) // version
- fileData.append(Data(repeating: 0x00, count: 128)) // reserved
-
+
+ fileData.appendUInt32("HTsc".fourCharCode()) // magic
+ fileData.appendUInt16(0x0001) // version
+ fileData.append(Data(repeating: 0x00, count: 128)) // reserved
+
fileData.appendUInt16(UInt16(loginData.count))
fileData.append(loginData)
if loginData.count < 32 {
// Pad login data to 32 bytes
fileData.append(Data(repeating: 0x00, count: 32 - loginData.count))
}
-
+
fileData.appendUInt16(UInt16(passwordData.count))
fileData.append(passwordData)
if passwordData.count < 32 {
// Pad password data to 32 bytes
fileData.append(Data(repeating: 0x00, count: 32 - passwordData.count))
}
-
+
fileData.appendUInt16(UInt16(addressData.count))
fileData.append(addressData)
if passwordData.count < 256 {
// Pad address data to 256 bytes
fileData.append(Data(repeating: 0x00, count: 256 - addressData.count))
}
-
+
return fileData
}
-
+
static func populateDefaults(force: Bool = false, context: ModelContext) {
if force || Bookmark.fetchCount(context: context) == 0 {
Bookmark.add(Bookmark.DefaultBookmarks, context: context)
}
}
-
+
static func fetchAll(context: ModelContext) -> [Bookmark] {
let fetchDescriptor = FetchDescriptor<Bookmark>(sortBy: [.init(\.order)])
do {
let bookmarks: [Bookmark] = try context.fetch(fetchDescriptor)
return bookmarks
- }
- catch {
+ } catch {
return []
}
}
-
+
static func fetchCount(context: ModelContext) -> Int {
let descriptor = FetchDescriptor<Bookmark>()
return (try? context.fetchCount(descriptor)) ?? 0
}
-
+
static func deleteAll(context: ModelContext) {
try? context.delete(model: Bookmark.self)
}
-
+
static func add(_ bookmark: Bookmark, context: ModelContext) {
guard bookmark.type != .temporary else {
print("Bookmark: Attempting to add temporary bookmark to store. Aborting.")
return
}
-
+
let existingBookmarks = Bookmark.fetchAll(context: context)
-
+
// Reindex bookmarks before insert.
for existingBookmark in existingBookmarks {
existingBookmark.order += 1
}
-
+
// Insert new bookmark at start.
bookmark.order = 0
context.insert(bookmark)
}
-
+
static func add(_ bookmarks: [Bookmark], context: ModelContext) {
let existingBookmarks = Bookmark.fetchAll(context: context)
-
+
// Reindex bookmarks before insert.
for existingBookmark in existingBookmarks {
existingBookmark.order += bookmarks.count
}
-
+
// Insert new bookmarks at start.
var bookmarkIndex = 0
for newBookmark in bookmarks {
newBookmark.order = bookmarkIndex
context.insert(newBookmark)
bookmarkIndex += 1
-
+
print("Bookmark: added \(newBookmark.name)")
}
}
-
+
static func delete(_ bookmark: Bookmark, context: ModelContext) {
// Delete bookmark
context.delete(bookmark)
-
+
// Reindex bookmarks
let existingBookmarks = Bookmark.fetchAll(context: context)
var index = 0
@@ -301,17 +312,17 @@ final class Bookmark {
index += 1
}
}
-
+
static func delete(at indexes: IndexSet, context: ModelContext) {
var existingBookmarks = Bookmark.fetchAll(context: context)
-// existingBookmarks.remove(atOffsets: indexes)
+ // existingBookmarks.remove(atOffsets: indexes)
let bookmarksToDelete = indexes.map { existingBookmarks[$0] }
-
+
// Delete bookmark
for bookmark in bookmarksToDelete {
context.delete(bookmark)
}
-
+
// Reindex bookmarks
var index = 0
existingBookmarks.remove(atOffsets: indexes)
@@ -319,62 +330,65 @@ final class Bookmark {
existingBookmark.order = index
index += 1
}
-
+
do {
try context.save()
- }
- catch {
+ } catch {
print("Bookmark: Failed to save bookmark deletions")
}
}
-
+
static func move(_ indexes: IndexSet, to newIndex: Int, context: ModelContext) {
guard Bookmark.fetchCount(context: context) >= indexes.count else {
print("Bookmark: Not enough bookmarks to move requested set")
return
}
-
+
// Perform move
var existingBookmarks = Bookmark.fetchAll(context: context)
existingBookmarks.move(fromOffsets: indexes, toOffset: newIndex)
-
+
// Reindex bookmarks
var index = 0
for existingBookmark in existingBookmarks {
existingBookmark.order = index
index += 1
}
-
+
do {
try context.save()
- }
- catch {
+ } catch {
print("Bookmark: Failed to save bookmark reordering")
}
}
-
+
func fetchServers() async {
guard self.type == .tracker else {
// self.loading = false
return
}
-
+
DispatchQueue.main.sync {
self.loading = true
}
-
+
var fetchedBookmarks: [Bookmark] = []
-
+
let client = HotlineTrackerClient()
- if let fetchedServers: [HotlineServer] = try? await client.fetchServers(address: self.address, port: self.port) {
+ if let fetchedServers: [HotlineServer] = try? await client.fetchServers(
+ address: self.address, port: self.port)
+ {
for fetchedServer in fetchedServers {
if let serverName = fetchedServer.name {
- let server = Server(name: serverName, description: fetchedServer.description, address: fetchedServer.address, port: Int(fetchedServer.port), users: Int(fetchedServer.users))
+ let server = Server(
+ name: serverName, description: fetchedServer.description,
+ address: fetchedServer.address, port: Int(fetchedServer.port),
+ users: Int(fetchedServer.users))
fetchedBookmarks.append(Bookmark(temporaryServer: server))
}
}
}
-
+
let newServers = fetchedBookmarks
DispatchQueue.main.sync {
self.servers = newServers
diff --git a/Hotline/Models/ChatMessage.swift b/Hotline/Models/ChatMessage.swift
index e585cdf..36648d1 100644
--- a/Hotline/Models/ChatMessage.swift
+++ b/Hotline/Models/ChatMessage.swift
@@ -9,25 +9,24 @@ enum ChatMessageType {
struct ChatMessage: Identifiable {
let id = UUID()
-
+
let text: String
let type: ChatMessageType
let date: Date
let username: String?
-
+
static let parser = /^\s*([^\:]+):\s*([\s\S]+)$/
-
+
init(text: String, type: ChatMessageType, date: Date) {
self.type = type
self.date = date
-
- if
- type == .message,
- let match = text.firstMatch(of: ChatMessage.parser) {
+
+ if type == .message,
+ let match = text.firstMatch(of: ChatMessage.parser)
+ {
self.username = String(match.1)
self.text = String(match.2)
- }
- else {
+ } else {
self.username = nil
self.text = text
}
diff --git a/Hotline/Models/FileDetails.swift b/Hotline/Models/FileDetails.swift
index 20b1ee2..dc302e8 100644
--- a/Hotline/Models/FileDetails.swift
+++ b/Hotline/Models/FileDetails.swift
@@ -1,6 +1,6 @@
import UniformTypeIdentifiers
-struct FileDetails:Identifiable {
+struct FileDetails: Identifiable {
let id = UUID()
var name: String
var path: [String]
diff --git a/Hotline/Models/FileInfo.swift b/Hotline/Models/FileInfo.swift
index 2da7378..0eadd10 100644
--- a/Hotline/Models/FileInfo.swift
+++ b/Hotline/Models/FileInfo.swift
@@ -3,46 +3,46 @@ import UniformTypeIdentifiers
@Observable class FileInfo: 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 isDropboxFolder: Bool {
guard self.isFolder,
- (self.name.range(of: "upload", options: [.caseInsensitive]) != nil) || (self.name.range(of: "drop box", options: [.caseInsensitive]) != nil)
+ (self.name.range(of: "upload", options: [.caseInsensitive]) != nil)
+ || (self.name.range(of: "drop box", options: [.caseInsensitive]) != nil)
else {
return false
}
return true
}
-
+
var isAdminDropboxFolder: Bool {
self.isDropboxFolder && (self.name.range(of: "admin", options: [.caseInsensitive]) != nil)
}
-
+
var expanded: Bool = false
var children: [FileInfo]? = nil
-
+
var isPreviewable: Bool {
let fileExtension = (self.name as NSString).pathExtension
if let fileType = UTType(filenameExtension: fileExtension) {
if fileType.isSubtype(of: .image) {
return true
- }
- else if fileType.isSubtype(of: .text) {
+ } else if fileType.isSubtype(of: .text) {
return true
}
}
return false
}
-
+
var isImage: Bool {
let fileExtension = (self.name as NSString).pathExtension
if let fileType = UTType(filenameExtension: fileExtension) {
@@ -52,7 +52,7 @@ import UniformTypeIdentifiers
}
return false
}
-
+
init(hotlineFile: HotlineFile) {
self.id = UUID()
self.path = hotlineFile.path
@@ -62,17 +62,17 @@ import UniformTypeIdentifiers
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/FilePreview.swift b/Hotline/Models/FilePreview.swift
index e5f49a3..bba29fa 100644
--- a/Hotline/Models/FilePreview.swift
+++ b/Hotline/Models/FilePreview.swift
@@ -18,52 +18,55 @@ enum FilePreviewType: Equatable {
final class FilePreview: HotlineFilePreviewClientDelegate {
@ObservationIgnored let info: PreviewFileInfo
@ObservationIgnored var client: HotlineFilePreviewClient? = nil
-
+
var state: FilePreviewState = .unloaded
var progress: Double = 0.0
-
+
var data: Data? = nil
-
+
#if os(iOS)
- var image: UIImage? = nil
+ var image: UIImage? = nil
#elseif os(macOS)
- var image: NSImage? = nil
+ var image: NSImage? = nil
#endif
-
+
var text: String? = nil
var styledText: NSAttributedString? = nil
-
+
var previewType: FilePreviewType {
let fileExtension = (info.name as NSString).pathExtension
if let fileType = UTType(filenameExtension: fileExtension) {
if fileType.isSubtype(of: .image) {
return .image
- }
- else if fileType.isSubtype(of: .text) {
+ } else if fileType.isSubtype(of: .text) {
return .text
}
}
return .unknown
}
-
+
init(info: PreviewFileInfo) {
self.info = info
-
- self.client = HotlineFilePreviewClient(address: info.address, port: UInt16(info.port), reference: info.id, size: UInt32(info.size))
+
+ self.client = HotlineFilePreviewClient(
+ address: info.address, port: UInt16(info.port), reference: info.id, size: UInt32(info.size))
self.client?.delegate = self
}
-
+
func download() {
self.client?.start()
}
-
+
func cancel() {
self.client?.cancel()
}
-
- func hotlineTransferStatusChanged(client: any HotlineTransferClient, reference: UInt32, status: HotlineTransferStatus, timeRemaining: TimeInterval) {
+
+ func hotlineTransferStatusChanged(
+ client: any HotlineTransferClient, reference: UInt32, status: HotlineTransferStatus,
+ timeRemaining: TimeInterval
+ ) {
print("FilePreview: Download status changed:", status)
-
+
switch status {
case .unconnected:
state = .unloaded
@@ -88,24 +91,24 @@ final class FilePreview: HotlineFilePreviewClientDelegate {
progress = 1.0
}
}
-
+
func hotlineFilePreviewComplete(client: HotlineFilePreviewClient, reference: UInt32, data: Data) {
self.state = .loaded
self.data = data
-
+
switch self.previewType {
case .image:
#if os(iOS)
- self.image = UIImage(data: data)
+ self.image = UIImage(data: data)
#elseif os(macOS)
- self.image = NSImage(data: data)
+ self.image = NSImage(data: data)
#endif
case .text:
- let encoding: UInt = NSString.stringEncoding(for: data, convertedString: nil, usedLossyConversion: nil)
+ let encoding: UInt = NSString.stringEncoding(
+ for: data, convertedString: nil, usedLossyConversion: nil)
if encoding != 0 {
self.text = String(data: data, encoding: String.Encoding(rawValue: encoding))
- }
- else {
+ } else {
self.text = String(data: data, encoding: .utf8)
}
case .unknown:
diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift
index 9ea29c5..1989d46 100644
--- a/Hotline/Models/Hotline.swift
+++ b/Hotline/Models/Hotline.swift
@@ -1,25 +1,27 @@
import SwiftUI
@Observable
-class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelegate, HotlineFilePreviewClientDelegate, HotlineFileUploadClientDelegate {
+class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelegate,
+ HotlineFilePreviewClientDelegate, HotlineFileUploadClientDelegate
+{
let id: UUID = UUID()
let trackerClient: HotlineTrackerClient
let client: HotlineClient
-
+
static func == (lhs: Hotline, rhs: Hotline) -> Bool {
return lhs.id == rhs.id
}
-
+
#if os(macOS)
- static func getClassicIcon(_ index: Int) -> NSImage? {
- return NSImage(named: "Classic/\(index)")
- }
+ static func getClassicIcon(_ index: Int) -> NSImage? {
+ return NSImage(named: "Classic/\(index)")
+ }
#elseif os(iOS)
- static func getClassicIcon(_ index: Int) -> UIImage? {
- return UIImage(named: "Classic/\(index)")
- }
+ static func getClassicIcon(_ index: Int) -> UIImage? {
+ return UIImage(named: "Classic/\(index)")
+ }
#endif
-
+
// The icon ordering here was painsakenly pulled manually
// from the original Hotline client to display the classic icons
// in the same order as the original client.
@@ -104,11 +106,11 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega
6001, 6002, 6003, 6004, 6005, 6008, 6009,
6010, 6011, 6012, 6013, 6014, 6015, 6016,
6017, 6018, 6023, 6025, 6026, 6027, 6028,
- 6029, 6030, 6031, 6032, 6033, 6034, 6035
+ 6029, 6030, 6031, 6032, 6033, 6034, 6035,
]
-
+
var status: HotlineClientStatus = .disconnected
- var server: Server? {
+ var server: Server? {
didSet {
self.updateServerTitle()
}
@@ -132,258 +134,271 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega
var files: [FileInfo] = []
var filesLoaded: Bool = false
var news: [NewsInfo] = []
- private var newsLookup: [String:NewsInfo] = [:]
+ private var newsLookup: [String: NewsInfo] = [:]
var newsLoaded: Bool = false
var accountsLoaded: Bool = false
- var instantMessages: [UInt16:[InstantMessage]] = [:]
+ var instantMessages: [UInt16: [InstantMessage]] = [:]
var transfers: [TransferInfo] = []
var downloads: [HotlineTransferClient] = []
- var unreadInstantMessages: [UInt16:UInt16] = [:]
+ var unreadInstantMessages: [UInt16: UInt16] = [:]
var unreadPublicChat: Bool = false
var errorDisplayed: Bool = false
var errorMessage: String? = nil
-
+
@ObservationIgnored var bannerClient: HotlineFilePreviewClient?
#if os(macOS)
- var bannerImage: NSImage? = nil
+ var bannerImage: NSImage? = nil
#elseif os(iOS)
- var bannerImage: UIImage? = nil
+ var bannerImage: UIImage? = nil
#endif
-
-
+
// MARK: -
-
+
init(trackerClient: HotlineTrackerClient, client: HotlineClient) {
self.trackerClient = trackerClient
self.client = client
self.client.delegate = self
}
-
+
// MARK: -
-
- @MainActor func getServerList(tracker: String, port: Int = HotlinePorts.DefaultTrackerPort) async -> [Server] {
+
+ @MainActor func getServerList(tracker: String, port: Int = HotlinePorts.DefaultTrackerPort) async
+ -> [Server]
+ {
var servers: [Server] = []
-
- if let fetchedServers: [HotlineServer] = try? await self.trackerClient.fetchServers(address: tracker, port: port) {
+
+ if let fetchedServers: [HotlineServer] = try? await self.trackerClient.fetchServers(
+ address: tracker, port: port)
+ {
for s in fetchedServers {
if let serverName = s.name {
- servers.append(Server(name: serverName, description: s.description, address: s.address, port: Int(s.port), users: Int(s.users)))
+ servers.append(
+ Server(
+ name: serverName, description: s.description, address: s.address, port: Int(s.port),
+ users: Int(s.users)))
}
}
}
-
+
return servers
}
-
+
@MainActor func disconnectTracker() {
self.trackerClient.close()
}
-
- @MainActor func login(server: Server, username: String, iconID: Int, callback: ((Bool) -> Void)? = nil) {
+
+ @MainActor func login(
+ server: Server, username: String, iconID: Int, callback: ((Bool) -> Void)? = nil
+ ) {
self.server = server
self.serverName = server.name
self.username = username
self.iconID = iconID
-
- self.client.login(address: server.address, port: server.port, login: server.login, password: server.password, username: username, iconID: UInt16(iconID)) { [weak self] err, serverName, serverVersion in
+
+ self.client.login(
+ address: server.address, port: server.port, login: server.login, password: server.password,
+ username: username, iconID: UInt16(iconID)
+ ) { [weak self] err, serverName, serverVersion in
self?.serverVersion = serverVersion ?? 123
if serverName != nil {
self?.serverName = serverName
}
-
+
callback?(err == nil)
}
}
-
- @MainActor func sendUserInfo(username: String, iconID: Int, options: HotlineUserOptions = [], autoresponse: String? = nil) {
+
+ @MainActor func sendUserInfo(
+ username: String, iconID: Int, options: HotlineUserOptions = [], autoresponse: String? = nil
+ ) {
self.username = username
self.iconID = iconID
-
- self.client.sendSetClientUserInfo(username: username, iconID: UInt16(iconID), options: options, autoresponse: autoresponse)
+
+ self.client.sendSetClientUserInfo(
+ username: username, iconID: UInt16(iconID), options: options, autoresponse: autoresponse)
}
-
+
@MainActor func getUserList() {
self.client.sendGetUserList()
}
-
+
@MainActor func disconnect() {
self.client.disconnect()
self.bannerClient?.cancel()
}
-
+
@MainActor func sendAgree() {
self.client.sendAgree(username: self.username, iconID: UInt16(self.iconID), options: .none)
}
-
+
@MainActor func sendInstantMessage(_ text: String, userID: UInt16) {
- let message = InstantMessage(direction: .outgoing, text: text.convertingLinksToMarkdown(), type: .message, date: Date())
-
+ let message = InstantMessage(
+ direction: .outgoing, text: text.convertingLinksToMarkdown(), type: .message, date: Date())
+
if self.instantMessages[userID] == nil {
self.instantMessages[userID] = [message]
- }
- else {
+ } else {
self.instantMessages[userID]!.append(message)
}
-
+
self.client.sendInstantMessage(message: text, userID: userID)
-
+
if Prefs.shared.playPrivateMessageSound && Prefs.shared.playPrivateMessageSound {
SoundEffectPlayer.shared.playSoundEffect(.chatMessage)
}
}
-
+
func markPublicChatAsRead() {
self.unreadPublicChat = false
}
-
+
func hasUnreadInstantMessages(userID: UInt16) -> Bool {
return self.unreadInstantMessages[userID] != nil
}
-
+
func markInstantMessagesAsRead(userID: UInt16) {
self.unreadInstantMessages.removeValue(forKey: userID)
}
-
+
@MainActor func sendChat(_ text: String, announce: Bool = false) {
self.client.sendChat(message: text, announce: announce)
}
-
+
@MainActor func getMessageBoard() async -> [String] {
self.messageBoard = await withCheckedContinuation { [weak self] continuation in
- self?.client.sendGetMessageBoard() { err, messages in
+ self?.client.sendGetMessageBoard { err, messages in
continuation.resume(returning: (err != nil ? [] : messages))
}
}
-
+
self.messageBoardLoaded = true
-
+
return self.messageBoard
}
-
+
@MainActor func postToMessageBoard(text: String) {
self.client.sendPostMessageBoard(text: text)
}
-
+
@MainActor func getFileList(path: [String] = []) async -> [FileInfo] {
return await withCheckedContinuation { [weak self] continuation in
self?.client.sendGetFileList(path: path) { [weak self] files in
let parentFile = self?.findFile(in: self?.files ?? [], at: path)
-
+
var newFiles: [FileInfo] = []
for f in files {
newFiles.append(FileInfo(hotlineFile: f))
}
-
+
DispatchQueue.main.async {
if let parent = parentFile {
parent.children = newFiles
- }
- else if path.isEmpty {
+ } else if path.isEmpty {
self?.filesLoaded = true
self?.files = newFiles
}
-
+
continuation.resume(returning: newFiles)
}
}
}
}
-
- @MainActor func getNewsArticle(id articleID: UInt, at path: [String], flavor: String) async -> String? {
+
+ @MainActor func getNewsArticle(id articleID: UInt, at path: [String], flavor: String) async
+ -> String?
+ {
return await withCheckedContinuation { [weak self] continuation in
- self?.client.sendGetNewsArticle(id: UInt32(articleID), path: path, flavor: flavor) { articleText in
-// let parentNews = self?.findNews(in: self?.news ?? [], at: path)
-
-// var newCategories: [NewsInfo] = []
-// for category in categories {
-// newCategories.append(NewsInfo(hotlineNewsCategory: category))
-// }
-//
-// if let parent = existingNewsItem {
-// parent.children = newCategories
-// }
-// else if path.isEmpty {
-// self?.news = newCategories
-// }
-
+ self?.client.sendGetNewsArticle(id: UInt32(articleID), path: path, flavor: flavor) {
+ articleText in
+ // let parentNews = self?.findNews(in: self?.news ?? [], at: path)
+
+ // var newCategories: [NewsInfo] = []
+ // for category in categories {
+ // newCategories.append(NewsInfo(hotlineNewsCategory: category))
+ // }
+ //
+ // if let parent = existingNewsItem {
+ // parent.children = newCategories
+ // }
+ // else if path.isEmpty {
+ // self?.news = newCategories
+ // }
+
continuation.resume(returning: articleText)
}
}
-
+
}
-
+
@MainActor func getAccounts() async -> [HotlineAccount] {
return await withCheckedContinuation { [weak self] continuation in
- self?.client.sendGetAccounts() { articles in
+ self?.client.sendGetAccounts { articles in
continuation.resume(returning: articles)
}
}
}
-
+
@MainActor func getNewsList(at path: [String] = []) async {
return await withCheckedContinuation { [weak self] continuation in
let parentNewsGroup = self?.findNews(in: self?.news ?? [], at: path)
-
+
// Send a categories request for bundle paths or root (empty path)
if path.isEmpty || parentNewsGroup?.type == .bundle {
print("Hotline: Requesting categories at: /\(path.joined(separator: "/"))")
-
+
self?.client.sendGetNewsCategories(path: path) { @MainActor [weak self] categories in
// Create info for each category returned.
var newCategoryInfos: [NewsInfo] = []
-
+
// Transform hotline categories into NewsInfo objects.
for category in categories {
var newsCategoryInfo = NewsInfo(hotlineNewsCategory: category)
-
+
if let lookupPath = newsCategoryInfo.lookupPath {
// Merge returned category info with existing category info.
if let existingCategoryInfo = self?.newsLookup[lookupPath] {
print("Hotline: Merging category into existing category at \(lookupPath)")
-
+
existingCategoryInfo.count = newsCategoryInfo.count
existingCategoryInfo.name = newsCategoryInfo.name
existingCategoryInfo.path = newsCategoryInfo.path
existingCategoryInfo.categoryID = newsCategoryInfo.categoryID
newsCategoryInfo = existingCategoryInfo
- }
- else {
+ } else {
print("Hotline: New category added at \(lookupPath)")
self?.newsLookup[lookupPath] = newsCategoryInfo
}
}
-
+
newCategoryInfos.append(newsCategoryInfo)
}
-
+
if let parent = parentNewsGroup {
parent.children = newCategoryInfos
- }
- else if path.isEmpty {
+ } else if path.isEmpty {
self?.newsLoaded = true
self?.news = newCategoryInfos
}
-
+
continuation.resume()
}
- }
- else {
+ } else {
print("Hotline: Requesting articles at: /\(path.joined(separator: "/"))")
-
+
self?.client.sendGetNewsArticles(path: path) { @MainActor [weak self] articles in
print("Hotline: Organizing news at \(path.joined(separator: "/"))")
// Create info for each article returned.
var newArticleInfos: [NewsInfo] = []
-
+
for article in articles {
var newsArticleInfo = NewsInfo(hotlineNewsArticle: article)
-
+
if let lookupPath = newsArticleInfo.lookupPath {
// Merge returned category info with existing category info.
if let existingArticleInfo = self?.newsLookup[lookupPath] {
print("Hotline: Merging article into existing article at \(lookupPath)")
-
+
existingArticleInfo.count = newsArticleInfo.count
existingArticleInfo.name = newsArticleInfo.name
existingArticleInfo.path = newsArticleInfo.path
@@ -392,94 +407,96 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega
existingArticleInfo.articleFlavors = newsArticleInfo.articleFlavors
existingArticleInfo.articleID = newsArticleInfo.articleID
newsArticleInfo = existingArticleInfo
- }
- else {
+ } else {
print("Hotline: New article added at \(lookupPath)")
self?.newsLookup[lookupPath] = newsArticleInfo
}
}
-
+
newArticleInfos.append(newsArticleInfo)
}
-
+
let organizedNewsArticles: [NewsInfo] = self?.organizeNewsArticles(newArticleInfos) ?? []
if let parent = parentNewsGroup {
parent.children = organizedNewsArticles
}
-
+
continuation.resume()
}
}
}
}
-
+
func organizeNewsArticles(_ flatArticles: [NewsInfo]) -> [NewsInfo] {
// Place articles under their parent.
var organized: [NewsInfo] = []
for article in flatArticles {
if let parentLookupPath = article.parentArticleLookupPath,
- let parentArticle = self.newsLookup[parentLookupPath] {
-// article.expanded = true
+ let parentArticle = self.newsLookup[parentLookupPath]
+ {
+ // article.expanded = true
if parentArticle.children.firstIndex(of: article) == nil {
article.expanded = true
parentArticle.children.append(article)
}
- }
- else {
+ } else {
organized.append(article)
}
}
-
+
return organized
}
-
- @MainActor func postNewsArticle(title: String, body: String, at path: [String], parentID: UInt32 = 0) async -> Bool {
-
-
+
+ @MainActor func postNewsArticle(
+ title: String, body: String, at path: [String], parentID: UInt32 = 0
+ ) async -> Bool {
+
return await withCheckedContinuation { [weak self] continuation in
guard let client = self?.client else {
continuation.resume(returning: false)
return
}
-
- client.postNewsArticle(title: title, text: body, path: path, parentID: parentID, callback: { success in
- print("Hotline: News article posted? \(success)")
- continuation.resume(returning: success)
- })
+
+ client.postNewsArticle(
+ title: title, text: body, path: path, parentID: parentID,
+ callback: { success in
+ print("Hotline: News article posted? \(success)")
+ continuation.resume(returning: success)
+ })
}
}
-
-// @MainActor func getNewsCategories(at path: [String] = []) async -> [NewsInfo] {
-// return await withCheckedContinuation { [weak self] continuation in
-// guard let client = self?.client else {
-// continuation.resume(returning: [])
-// return
-// }
-//
-// client.sendGetNewsCategories(path: path) { [weak self] categories in
-// let parentNews = self?.findNews(in: self?.news ?? [], at: path)
-//
-// var newCategories: [NewsInfo] = []
-// for category in categories {
-// let categoryInfo: NewsInfo = NewsInfo(hotlineNewsCategory: category)
-// newCategories.append(categoryInfo)
-// self?.newsLookup[categoryInfo.path.joined(separator: "/")] = categoryInfo
-// }
-//
-// DispatchQueue.main.async {
-// if let parent = parentNews {
-// parent.children = newCategories
-// }
-// else if path.isEmpty {
-// self?.news = newCategories
-// }
-//
-// continuation.resume(returning: newCategories)
-// }
-// }
-// }
-// }
-
+
+ // @MainActor func getNewsCategories(at path: [String] = []) async -> [NewsInfo] {
+ // return await withCheckedContinuation { [weak self] continuation in
+ // guard let client = self?.client else {
+ // continuation.resume(returning: [])
+ // return
+ // }
+ //
+ // client.sendGetNewsCategories(path: path) { [weak self] categories in
+ // let parentNews = self?.findNews(in: self?.news ?? [], at: path)
+ //
+ // var newCategories: [NewsInfo] = []
+ // for category in categories {
+ // let categoryInfo: NewsInfo = NewsInfo(hotlineNewsCategory: category)
+ // newCategories.append(categoryInfo)
+ // self?.newsLookup[categoryInfo.path.joined(separator: "/")] = categoryInfo
+ // }
+ //
+ // DispatchQueue.main.async {
+ // if let parent = parentNews {
+ // parent.children = newCategories
+ // }
+ // else if path.isEmpty {
+ // self?.news = newCategories
+ // }
+ //
+ // continuation.resume(returning: newCategories)
+ // }
+ // }
+ // }
+ // }
+
@MainActor func getArticles(at path: [String]) async -> [NewsInfo] {
return await withCheckedContinuation { [weak self] continuation in
self?.client.sendGetNewsArticles(path: path) { articles in
@@ -487,284 +504,315 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega
}
}
}
-
- @MainActor func downloadFile(_ fileName: String, path: [String], complete callback: ((TransferInfo, URL) -> Void)? = nil) {
+
+ @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])
+ fullPath = Array(path[0..<path.count - 1])
}
-
- self.client.sendDownloadFile(name: fileName, path: fullPath) { [weak self] success, downloadReferenceNumber, downloadTransferSize, downloadFileSize, downloadWaitingCount in
+
+ self.client.sendDownloadFile(name: fileName, path: fullPath) {
+ [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 server = self.server,
+
+ if let self = self,
+ // let server = self.server,
let address = self.server?.address,
let port = self.server?.port,
let referenceNumber = downloadReferenceNumber,
- let transferSize = downloadTransferSize {
-
- let fileClient = HotlineFileDownloadClient(address: address, port: UInt16(port), reference: referenceNumber, size: UInt32(transferSize))
-// let previewClient = HotlineFilePreviewClient(server: self.server, reference: referenceNumber, size: UInt32(transferSize), type: .fileDownload)
-// let fileClient = HotlineFileClient(address: address, port: UInt16(port), reference: referenceNumber, size: UInt32(transferSize), type: .fileDownload)
+ let transferSize = downloadTransferSize
+ {
+
+ let fileClient = HotlineFileDownloadClient(
+ address: address, port: UInt16(port), reference: referenceNumber,
+ size: UInt32(transferSize))
+ // let previewClient = HotlineFilePreviewClient(server: self.server, reference: referenceNumber, size: UInt32(transferSize), type: .fileDownload)
+ // let fileClient = HotlineFileClient(address: address, port: UInt16(port), reference: referenceNumber, size: UInt32(transferSize), type: .fileDownload)
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.start()
}
}
}
-
- @MainActor func downloadFileTo(url fileURL: URL, fileName: String, path: [String], progress progressCallback: ((TransferInfo, Double) -> Void)? = nil, complete callback: ((TransferInfo, URL) -> Void)? = nil) {
+
+ @MainActor func downloadFileTo(
+ url fileURL: URL, fileName: String, path: [String],
+ progress progressCallback: ((TransferInfo, Double) -> Void)? = nil,
+ complete callback: ((TransferInfo, URL) -> Void)? = nil
+ ) {
var fullPath: [String] = []
if path.count > 1 {
- fullPath = Array(path[0..<path.count-1])
+ fullPath = Array(path[0..<path.count - 1])
}
-
- self.client.sendDownloadFile(name: fileName, path: fullPath) { [weak self] success, downloadReferenceNumber, downloadTransferSize, downloadFileSize, downloadWaitingCount in
+
+ self.client.sendDownloadFile(name: fileName, path: fullPath) {
+ [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,
+
+ if let self = self,
let address = self.server?.address,
let port = self.server?.port,
let referenceNumber = downloadReferenceNumber,
- let transferSize = downloadTransferSize {
-
- let fileClient = HotlineFileDownloadClient(address: address, port: UInt16(port), reference: referenceNumber, size: UInt32(transferSize))
+ let transferSize = downloadTransferSize
+ {
+
+ let fileClient = HotlineFileDownloadClient(
+ address: address, port: UInt16(port), reference: referenceNumber,
+ size: UInt32(transferSize))
fileClient.delegate = self
self.downloads.append(fileClient)
-
+
let transfer = TransferInfo(id: referenceNumber, title: fileName, size: UInt(transferSize))
transfer.downloadCallback = callback
transfer.progressCallback = progressCallback
self.transfers.append(transfer)
-
+
fileClient.start(to: fileURL)
}
}
}
-
- @MainActor func uploadFile(url fileURL: URL, path: [String], complete callback: ((TransferInfo) -> Void)? = nil) {
+
+ @MainActor func uploadFile(
+ url fileURL: URL, path: [String], complete callback: ((TransferInfo) -> Void)? = nil
+ ) {
let fileName = fileURL.lastPathComponent
-
+
guard fileURL.isFileURL, !fileName.isEmpty else {
print("NOT A FILE URL?")
return
}
-
+
let filePath = fileURL.path(percentEncoded: false)
-
+
var fileIsDirectory: ObjCBool = false
guard FileManager.default.fileExists(atPath: filePath, isDirectory: &fileIsDirectory),
- fileIsDirectory.boolValue == false else {
+ fileIsDirectory.boolValue == false
+ else {
print("FILE IS A DIRECTORY?")
return
}
-
+
var fileSize: UInt = 0
-
+
// Data size
let fileAttributes = try? FileManager.default.attributesOfItem(atPath: filePath)
if let sizeAttribute = fileAttributes?[.size] as? NSNumber {
print("DATA SIZE \(sizeAttribute.uintValue)")
fileSize += sizeAttribute.uintValue
}
-
+
// Resource size
let resourceURL = fileURL.appendingPathComponent("..namedfork/rsrc")
-
-
+
print("RESOURCE PATH \(resourceURL)")
- let resourceAttributes = try? FileManager.default.attributesOfItem(atPath: resourceURL.path(percentEncoded: false))
+ let resourceAttributes = try? FileManager.default.attributesOfItem(
+ atPath: resourceURL.path(percentEncoded: false))
if let sizeAttribute = resourceAttributes?[.size] as? NSNumber {
print("RESOURCE SIZE \(sizeAttribute.uintValue)")
fileSize += sizeAttribute.uintValue
}
-
+
print("FILE SIZE? \(fileSize)")
-
+
guard fileSize > 0 else {
print("FILE IS EMPTY??")
return
}
-
+
print("FILE SIZE: \(fileSize) NAME: \(fileName) PATH: \(path)")
-
- self.client.sendUploadFile(name: fileName, path: path) { [weak self] success, uploadReferenceNumber in
+
+ self.client.sendUploadFile(name: fileName, path: path) {
+ [weak self] success, uploadReferenceNumber in
print("UPLOAD REFERENCE: \(String(describing: uploadReferenceNumber))")
-
+
if let self = self,
- let address = self.server?.address,
- let port = self.server?.port,
- let referenceNumber = uploadReferenceNumber,
- let fileClient = HotlineFileUploadClient(upload: fileURL, address: address, port: UInt16(port), reference: referenceNumber) {
-
+ let address = self.server?.address,
+ let port = self.server?.port,
+ let referenceNumber = uploadReferenceNumber,
+ let fileClient = HotlineFileUploadClient(
+ upload: fileURL, address: address, port: UInt16(port), reference: referenceNumber)
+ {
+
print("GOING TO UPLOAD")
-
+
fileClient.delegate = self
self.downloads.append(fileClient)
-
+
let transfer = TransferInfo(id: referenceNumber, title: fileName, size: fileSize)
transfer.uploadCallback = callback
self.transfers.append(transfer)
-
+
fileClient.start()
}
}
-
+
}
-
+
@MainActor func getFileDetails(_ fileName: String, path: [String]) async -> FileDetails? {
var fullPath: [String] = []
if path.count > 1 {
- fullPath = Array(path[0..<path.count-1])
+ fullPath = Array(path[0..<path.count - 1])
}
-
+
return await withCheckedContinuation { [weak self] continuation in
self?.client.sendGetFileInfo(name: fileName, path: fullPath) { info in
continuation.resume(returning: info)
}
}
}
-
+
@MainActor func deleteFile(_ fileName: String, path: [String]) async -> Bool {
var fullPath: [String] = []
if path.count > 1 {
- fullPath = Array(path[0..<path.count-1])
+ fullPath = Array(path[0..<path.count - 1])
}
-
+
return await withCheckedContinuation { [weak self] continuation in
self?.client.sendDeleteFile(name: fileName, path: fullPath) { success in
continuation.resume(returning: success)
}
}
}
-
- @MainActor func previewFile(_ fileName: String, path: [String], complete callback: ((PreviewFileInfo?) -> Void)? = nil) {
+
+ @MainActor func previewFile(
+ _ fileName: String, path: [String], complete callback: ((PreviewFileInfo?) -> Void)? = nil
+ ) {
var fullPath: [String] = []
if path.count > 1 {
- fullPath = Array(path[0..<path.count-1])
+ fullPath = Array(path[0..<path.count - 1])
}
-
- self.client.sendDownloadFile(name: fileName, path: fullPath, preview: true) { [weak self] success, downloadReferenceNumber, downloadTransferSize, downloadFileSize, downloadWaitingCount in
+
+ self.client.sendDownloadFile(name: fileName, path: fullPath, preview: true) {
+ [weak self]
+ success, downloadReferenceNumber, downloadTransferSize, downloadFileSize, downloadWaitingCount
+ in
guard success else {
callback?(nil)
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)")
-
+
var info: PreviewFileInfo? = nil
-
- if
- let address = self?.server?.address,
+
+ if let address = self?.server?.address,
let port = self?.server?.port,
let referenceNumber = downloadReferenceNumber,
- let transferSize = downloadTransferSize {
-
- info = PreviewFileInfo(id: referenceNumber, address: address, port: port, size: transferSize, name: fileName)
+ let transferSize = downloadTransferSize
+ {
+
+ info = PreviewFileInfo(
+ id: referenceNumber, address: address, port: port, size: transferSize, name: fileName)
}
-
+
callback?(info)
}
}
-
+
@MainActor func deleteTransfer(id: UInt32) {
if let b = self.bannerClient, b.referenceNumber == id {
b.cancel()
self.bannerClient = nil
return
}
-
+
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()
}
}
-
+
@MainActor func deleteAllTransfers() {
self.transfers = []
-
+
let downloads = self.downloads
self.downloads = []
-
+
for fileClient in downloads {
fileClient.cancel()
}
}
-
+
@MainActor func downloadBanner(force: Bool = false) {
guard self.serverVersion >= 150 else {
return
}
-
+
if self.bannerClient != nil || force {
self.bannerClient?.delegate = nil
self.bannerClient?.cancel()
self.bannerClient = nil
-
+
if force {
self.bannerImage = nil
}
}
-
+
if self.bannerImage != nil {
return
}
-
- self.client.sendDownloadBanner { [weak self] success, downloadReferenceNumber, downloadTransferSize in
+
+ self.client.sendDownloadBanner {
+ [weak self] success, downloadReferenceNumber, downloadTransferSize in
if !success {
return
}
-
- if
- let self = self,
+
+ if let self = self,
let address = self.server?.address,
let port = self.server?.port,
let referenceNumber = downloadReferenceNumber,
- let transferSize = downloadTransferSize {
- self.bannerClient = HotlineFilePreviewClient(address: address, port: UInt16(port), reference: referenceNumber, size: UInt32(transferSize))
+ let transferSize = downloadTransferSize
+ {
+ self.bannerClient = HotlineFilePreviewClient(
+ address: address, port: UInt16(port), reference: referenceNumber,
+ size: UInt32(transferSize))
self.bannerClient?.delegate = self
self.bannerClient?.start()
}
}
}
-
+
// MARK: - Hotline Delegate
-
+
@MainActor func hotlineStatusChanged(status: HotlineClientStatus) {
print("Hotline: Connection status changed to: \(status)")
-
+
if status == .disconnected {
self.serverVersion = 123
self.serverName = nil
self.access = nil
-
+
self.users = []
self.chat = []
self.messageBoard = []
@@ -773,32 +821,31 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega
self.filesLoaded = false
self.news = []
self.newsLoaded = false
-
+
self.bannerImage = nil
if let b = self.bannerClient {
b.cancel()
self.bannerClient = nil
}
-
+
self.deleteAllTransfers()
- }
- else if status == .loggedIn {
+ } else if status == .loggedIn {
if Prefs.shared.playSounds && Prefs.shared.playLoggedInSound {
SoundEffectPlayer.shared.playSoundEffect(.loggedIn)
}
}
-
+
self.status = status
}
-
+
func hotlineGetUserInfo() -> (String, UInt16) {
return (self.username, UInt16(self.iconID))
}
-
+
func hotlineReceivedAgreement(text: String) {
self.chat.append(ChatMessage(text: text, type: .agreement, date: Date()))
}
-
+
func hotlineReceivedNewsPost(message: String) {
let messageBoardRegex = /([\s\r\n]*[_\-]+[\s\r\n]+)/
let matches = message.matches(of: messageBoardRegex)
@@ -809,44 +856,44 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega
} else {
self.messageBoard.insert(message, at: 0)
}
-
+
SoundEffectPlayer.shared.playSoundEffect(.newNews)
}
-
+
func hotlineReceivedServerMessage(message: String) {
if Prefs.shared.playChatSound && Prefs.shared.playChatSound {
SoundEffectPlayer.shared.playSoundEffect(.serverMessage)
}
-
+
print("Hotline: received server message:\n\(message)")
self.chat.append(ChatMessage(text: message, type: .server, date: Date()))
}
-
+
func hotlineReceivedPrivateMessage(userID: UInt16, message: String) {
if let existingUserIndex = self.users.firstIndex(where: { $0.id == UInt(userID) }) {
let user = self.users[existingUserIndex]
print("Hotline: received private message from \(user.name): \(message)")
-
+
if Prefs.shared.playPrivateMessageSound && Prefs.shared.playPrivateMessageSound {
if self.unreadInstantMessages[userID] == nil {
SoundEffectPlayer.shared.playSoundEffect(.serverMessage)
- }
- else {
+ } else {
SoundEffectPlayer.shared.playSoundEffect(.chatMessage)
}
}
-
- let instantMessage = InstantMessage(direction: .incoming, text: message.convertingLinksToMarkdown(), type: .message, date: Date())
+
+ let instantMessage = InstantMessage(
+ direction: .incoming, text: message.convertingLinksToMarkdown(), type: .message,
+ date: Date())
if self.instantMessages[userID] == nil {
self.instantMessages[userID] = [instantMessage]
- }
- else {
+ } else {
self.instantMessages[userID]!.append(instantMessage)
}
self.unreadInstantMessages[userID] = userID
}
}
-
+
func hotlineReceivedChatMessage(message: String) {
if Prefs.shared.playSounds && Prefs.shared.playChatSound {
SoundEffectPlayer.shared.playSoundEffect(.chatMessage)
@@ -854,11 +901,11 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega
self.chat.append(ChatMessage(text: message, type: .message, date: Date()))
self.unreadPublicChat = true
}
-
+
func hotlineReceivedUserList(users: [HotlineUser]) {
var existingUserIDs: [UInt16] = []
var userList: [User] = []
-
+
for u in users {
if let i = self.users.firstIndex(where: { $0.id == u.id }) {
// If a user is already in the user list we have to assume
@@ -866,59 +913,62 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega
// which means let's keep their existing info.
existingUserIDs.append(u.id)
userList.append(self.users[i])
- }
- else {
+ } else {
userList.append(User(hotlineUser: u))
}
}
-
+
if !existingUserIDs.isEmpty {
self.users = self.users.filter { !existingUserIDs.contains($0.id) }
}
-
+
self.users = userList + self.users
}
-
+
func hotlineUserChanged(user: HotlineUser) {
self.addOrUpdateHotlineUser(user)
}
-
+
func hotlineUserDisconnected(userID: UInt16) {
if let existingUserIndex = self.users.firstIndex(where: { $0.id == UInt(userID) }) {
let user = self.users.remove(at: existingUserIndex)
-
+
if Prefs.shared.showJoinLeaveMessages {
self.chat.append(ChatMessage(text: "\(user.name) left", type: .status, date: Date()))
}
-
+
if Prefs.shared.playSounds && Prefs.shared.playLeaveSound {
SoundEffectPlayer.shared.playSoundEffect(.userLogout)
}
}
}
-
+
func hotlineReceivedUserAccess(options: HotlineUserAccessOptions) {
print("Hotline: got access options")
HotlineUserAccessOptions.printAccessOptions(options)
-
+
self.access = options
}
-
+
func hotlineReceivedErrorMessage(code: UInt32, message: String?) {
print("Hotline: received error message \(code)", message.debugDescription)
-
- self.errorDisplayed = (message != nil) // Show error if there is a message to display.
+
+ self.errorDisplayed = (message != nil) // Show error if there is a message to display.
self.errorMessage = message
-
+
if self.errorDisplayed,
- Prefs.shared.playSounds && Prefs.shared.playErrorSound {
+ Prefs.shared.playSounds && Prefs.shared.playErrorSound
+ {
SoundEffectPlayer.shared.playSoundEffect(.error)
}
}
-
+
// MARK: - Hotline Transfer Delegate
-
- func hotlineTransferStatusChanged(client: HotlineTransferClient, reference: UInt32, status: HotlineTransferStatus, timeRemaining: TimeInterval) {
+
+ func hotlineTransferStatusChanged(
+ client: HotlineTransferClient, reference: UInt32, status: HotlineTransferStatus,
+ timeRemaining: TimeInterval
+ ) {
switch status {
case .unconnected:
break
@@ -953,34 +1003,34 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega
}
}
}
-
- func hotlineFileDownloadReceivedInfo(client: HotlineFileDownloadClient, reference: UInt32, info: HotlineFileInfoFork) {
+
+ func hotlineFileDownloadReceivedInfo(
+ client: HotlineFileDownloadClient, reference: UInt32, info: HotlineFileInfoFork
+ ) {
if let transfer = self.transfers.first(where: { $0.id == reference }) {
transfer.title = info.name
}
}
-
+
func hotlineFilePreviewComplete(client: HotlineFilePreviewClient, reference: UInt32, data: Data) {
if let b = self.bannerClient, b.referenceNumber == reference {
#if os(macOS)
- self.bannerImage = NSImage(data: data)
+ self.bannerImage = NSImage(data: data)
#elseif os(iOS)
- self.bannerImage = UIImage(data: data)
+ self.bannerImage = UIImage(data: data)
#endif
- }
- else
- if let i = self.transfers.firstIndex(where: { $0.id == reference }) {
+ } 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 hotlineFileDownloadComplete(client: HotlineFileDownloadClient, reference: UInt32, at: URL) {
+
+ func hotlineFileDownloadComplete(client: HotlineFileDownloadClient, reference: UInt32, at: URL) {
if let i = self.transfers.firstIndex(where: { $0.id == reference }) {
let transfer = self.transfers[i]
transfer.fileURL = at
@@ -989,12 +1039,12 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega
SoundEffectPlayer.shared.playSoundEffect(.transferComplete)
}
}
-
+
if let i = self.downloads.firstIndex(where: { $0.referenceNumber == reference }) {
self.downloads.remove(at: i)
}
}
-
+
func hotlineFileUploadComplete(client: HotlineFileUploadClient, reference: UInt32) {
if let i = self.transfers.firstIndex(where: { $0.id == reference }) {
let transfer = self.transfers[i]
@@ -1003,31 +1053,30 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega
SoundEffectPlayer.shared.playSoundEffect(.transferComplete)
}
}
-
+
if let i = self.downloads.firstIndex(where: { $0.referenceNumber == reference }) {
self.downloads.remove(at: i)
}
}
-
+
// MARK: - Utilities
-
+
func updateServerTitle() {
self.serverTitle = self.serverName ?? self.server?.name ?? server?.address ?? "Server"
}
-
+
private func addOrUpdateHotlineUser(_ user: HotlineUser) {
print("Hotline: users: \n\(self.users)")
if let i = self.users.firstIndex(where: { $0.id == user.id }) {
print("Hotline: updating user \(self.users[i].name)")
self.users[i] = User(hotlineUser: user)
- }
- else {
+ } else {
if !self.users.isEmpty {
if Prefs.shared.playSounds && Prefs.shared.playJoinSound {
SoundEffectPlayer.shared.playSoundEffect(.userLogin)
}
}
-
+
print("Hotline: added user: \(user.name)")
self.users.append(User(hotlineUser: user))
if Prefs.shared.showJoinLeaveMessages {
@@ -1035,55 +1084,53 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega
}
}
}
-
+
private func findFile(in filesToSearch: [FileInfo], at path: [String]) -> FileInfo? {
guard !path.isEmpty, !filesToSearch.isEmpty else { return nil }
-
+
let currentName = path[0]
-
+
for file in filesToSearch {
if file.name == currentName {
if path.count == 1 {
return file
- }
- else if let subfiles = file.children {
+ } else if let subfiles = file.children {
let remainingPath = Array(path[1...])
return self.findFile(in: subfiles, at: remainingPath)
}
}
}
-
+
return nil
}
-
+
private func findNews(in newsToSearch: [NewsInfo], at path: [String]) -> NewsInfo? {
guard !path.isEmpty, !newsToSearch.isEmpty, let currentName = path.first else { return nil }
-
+
for news in newsToSearch {
if news.name == currentName {
if path.count == 1 {
return news
- }
- else if !news.children.isEmpty {
+ } else if !news.children.isEmpty {
let remainingPath = Array(path[1...])
return self.findNews(in: news.children, at: remainingPath)
}
}
}
-
+
return nil
}
-
+
private func findNewsArticle(id articleID: UInt32, at path: [String]) -> NewsInfo? {
guard let parent = self.findNews(in: self.news, at: path), !parent.children.isEmpty else {
return nil
}
-
+
return parent.children.first { child in
guard let childArticleID = child.articleID else {
return false
}
-
+
return child.type == .article && child.articleID == childArticleID
}
}
diff --git a/Hotline/Models/NewsInfo.swift b/Hotline/Models/NewsInfo.swift
index 6ee28b8..caf532f 100644
--- a/Hotline/Models/NewsInfo.swift
+++ b/Hotline/Models/NewsInfo.swift
@@ -8,28 +8,28 @@ enum NewsInfoType {
@Observable class NewsInfo: Identifiable, Hashable {
let id: UUID = UUID()
-
+
var name: String
var count: UInt
let type: NewsInfoType
-
+
var categoryID: UUID?
var articleID: UInt?
var parentID: UInt?
-
+
var path: [String]
var expanded: Bool = false
var children: [NewsInfo] = []
-
+
var articleFlavors: [String]?
var articleUsername: String?
var articleDate: Date?
var read: Bool = false
-
+
var expandable: Bool {
self.type == .bundle || self.type == .category || self.children.count > 0
}
-
+
var lookupPath: String? {
switch self.type {
case .bundle, .category:
@@ -38,21 +38,21 @@ enum NewsInfoType {
guard let aid = self.articleID else {
return nil
}
-// if let pid = self.parentID, pid != 0 {
-// return "/\(self.path.joined(separator: "/"))/\(pid)/\(aid)"
-// }
+ // if let pid = self.parentID, pid != 0 {
+ // return "/\(self.path.joined(separator: "/"))/\(pid)/\(aid)"
+ // }
return "/\(self.path.joined(separator: "/"))/\(aid)"
}
}
-
+
var parentArticleLookupPath: String? {
switch self.type {
case .bundle, .category:
-// if self.path.count <= 1 {
-// return "/"
-// }
-// let parentPath = self.path[0..<self.path.count-1]
-// return "/\(parentPath.joined(separator: "/"))"
+ // if self.path.count <= 1 {
+ // return "/"
+ // }
+ // let parentPath = self.path[0..<self.path.count-1]
+ // return "/\(parentPath.joined(separator: "/"))"
return nil
case .article:
guard let pid = self.parentID, pid != 0 else {
@@ -61,7 +61,7 @@ enum NewsInfoType {
return "/\(self.path.joined(separator: "/"))/\(pid)"
}
}
-
+
init(hotlineNewsCategory: HotlineNewsCategory) {
self.categoryID = hotlineNewsCategory.id
self.articleID = nil
@@ -69,15 +69,14 @@ enum NewsInfoType {
self.name = hotlineNewsCategory.name
self.count = UInt(hotlineNewsCategory.count)
self.path = hotlineNewsCategory.path
-
+
if hotlineNewsCategory.type == 2 {
self.type = .bundle
- }
- else {
+ } else {
self.type = .category
}
}
-
+
init(hotlineNewsArticle: HotlineNewsArticle) {
self.articleID = UInt(hotlineNewsArticle.id)
self.parentID = hotlineNewsArticle.parentID == 0 ? nil : UInt(hotlineNewsArticle.parentID)
@@ -85,19 +84,19 @@ enum NewsInfoType {
self.categoryID = nil
self.name = hotlineNewsArticle.title
self.count = 0
-// self.count = UInt(hotlineNewsArticle.count)
+ // self.count = UInt(hotlineNewsArticle.count)
self.path = hotlineNewsArticle.path
self.type = .article
-
+
self.articleFlavors = hotlineNewsArticle.flavors.map { $0.0 }
self.articleUsername = hotlineNewsArticle.username
self.articleDate = hotlineNewsArticle.date
}
-
+
func hash(into hasher: inout Hasher) {
hasher.combine(self.id)
}
-
+
static func == (lhs: NewsInfo, rhs: NewsInfo) -> Bool {
return lhs.id == rhs.id
}
diff --git a/Hotline/Models/Preferences.swift b/Hotline/Models/Preferences.swift
index 207ce91..bcceec5 100644
--- a/Hotline/Models/Preferences.swift
+++ b/Hotline/Models/Preferences.swift
@@ -23,7 +23,7 @@ enum PrefsKeys: String {
@Observable
class Prefs {
init() {
- UserDefaults.standard.register(defaults:[
+ UserDefaults.standard.register(defaults: [
PrefsKeys.username.rawValue: "guest",
PrefsKeys.userIconID.rawValue: 191,
PrefsKeys.refusePrivateMessages.rawValue: false,
@@ -42,93 +42,141 @@ class Prefs {
PrefsKeys.showBannerToolbar.rawValue: true,
PrefsKeys.showJoinLeaveMessages.rawValue: true,
])
-
+
self.username = UserDefaults.standard.string(forKey: PrefsKeys.username.rawValue)!
self.userIconID = UserDefaults.standard.integer(forKey: PrefsKeys.userIconID.rawValue)
- self.refusePrivateMessages = UserDefaults.standard.bool(forKey: PrefsKeys.refusePrivateMessages.rawValue)
- self.refusePrivateChat = UserDefaults.standard.bool(forKey: PrefsKeys.refusePrivateChat.rawValue)
- self.enableAutomaticMessage = UserDefaults.standard.bool(forKey: PrefsKeys.enableAutomaticMessage.rawValue)
- self.automaticMessage = UserDefaults.standard.string(forKey: PrefsKeys.automaticMessage.rawValue)!
+ self.refusePrivateMessages = UserDefaults.standard.bool(
+ forKey: PrefsKeys.refusePrivateMessages.rawValue)
+ self.refusePrivateChat = UserDefaults.standard.bool(
+ forKey: PrefsKeys.refusePrivateChat.rawValue)
+ self.enableAutomaticMessage = UserDefaults.standard.bool(
+ forKey: PrefsKeys.enableAutomaticMessage.rawValue)
+ self.automaticMessage = UserDefaults.standard.string(
+ forKey: PrefsKeys.automaticMessage.rawValue)!
self.playSounds = UserDefaults.standard.bool(forKey: PrefsKeys.playSounds.rawValue)
self.playChatSound = UserDefaults.standard.bool(forKey: PrefsKeys.playChatSound.rawValue)
- self.playFileTransferCompleteSound = UserDefaults.standard.bool(forKey: PrefsKeys.playFileTransferCompleteSound.rawValue)
- self.playPrivateMessageSound = UserDefaults.standard.bool(forKey: PrefsKeys.playPrivateMessageSound.rawValue)
+ self.playFileTransferCompleteSound = UserDefaults.standard.bool(
+ forKey: PrefsKeys.playFileTransferCompleteSound.rawValue)
+ self.playPrivateMessageSound = UserDefaults.standard.bool(
+ forKey: PrefsKeys.playPrivateMessageSound.rawValue)
self.playJoinSound = UserDefaults.standard.bool(forKey: PrefsKeys.playJoinSound.rawValue)
self.playLeaveSound = UserDefaults.standard.bool(forKey: PrefsKeys.playLeaveSound.rawValue)
- self.playLoggedInSound = UserDefaults.standard.bool(forKey: PrefsKeys.playLoggedInSound.rawValue)
+ self.playLoggedInSound = UserDefaults.standard.bool(
+ forKey: PrefsKeys.playLoggedInSound.rawValue)
self.playErrorSound = UserDefaults.standard.bool(forKey: PrefsKeys.playErrorSound.rawValue)
- self.playChatInvitationSound = UserDefaults.standard.bool(forKey: PrefsKeys.playChatInvitationSound.rawValue)
- self.showBannerToolbar = UserDefaults.standard.bool(forKey: PrefsKeys.showBannerToolbar.rawValue)
- self.showJoinLeaveMessages = UserDefaults.standard.bool(forKey: PrefsKeys.showJoinLeaveMessages.rawValue)
+ self.playChatInvitationSound = UserDefaults.standard.bool(
+ forKey: PrefsKeys.playChatInvitationSound.rawValue)
+ self.showBannerToolbar = UserDefaults.standard.bool(
+ forKey: PrefsKeys.showBannerToolbar.rawValue)
+ self.showJoinLeaveMessages = UserDefaults.standard.bool(
+ forKey: PrefsKeys.showJoinLeaveMessages.rawValue)
}
-
+
public static let shared = Prefs()
var username: String {
didSet { UserDefaults.standard.set(self.username, forKey: PrefsKeys.username.rawValue) }
}
-
+
var userIconID: Int {
didSet { UserDefaults.standard.set(self.userIconID, forKey: PrefsKeys.userIconID.rawValue) }
}
-
+
var refusePrivateMessages: Bool {
- didSet { UserDefaults.standard.set(self.refusePrivateMessages, forKey: PrefsKeys.refusePrivateMessages.rawValue) }
+ didSet {
+ UserDefaults.standard.set(
+ self.refusePrivateMessages, forKey: PrefsKeys.refusePrivateMessages.rawValue)
+ }
}
-
+
var playSounds: Bool {
didSet { UserDefaults.standard.set(self.playSounds, forKey: PrefsKeys.playSounds.rawValue) }
}
-
+
var playChatSound: Bool {
- didSet { UserDefaults.standard.set(self.playChatSound, forKey: PrefsKeys.playChatSound.rawValue) }
+ didSet {
+ UserDefaults.standard.set(self.playChatSound, forKey: PrefsKeys.playChatSound.rawValue)
+ }
}
-
+
var playFileTransferCompleteSound: Bool {
- didSet { UserDefaults.standard.set(self.playFileTransferCompleteSound, forKey: PrefsKeys.playFileTransferCompleteSound.rawValue) }
+ didSet {
+ UserDefaults.standard.set(
+ self.playFileTransferCompleteSound, forKey: PrefsKeys.playFileTransferCompleteSound.rawValue
+ )
+ }
}
-
+
var playPrivateMessageSound: Bool {
- didSet { UserDefaults.standard.set(self.playPrivateMessageSound, forKey: PrefsKeys.playPrivateMessageSound.rawValue) }
+ didSet {
+ UserDefaults.standard.set(
+ self.playPrivateMessageSound, forKey: PrefsKeys.playPrivateMessageSound.rawValue)
+ }
}
-
+
var playJoinSound: Bool {
- didSet { UserDefaults.standard.set(self.playJoinSound, forKey: PrefsKeys.playJoinSound.rawValue) }
+ didSet {
+ UserDefaults.standard.set(self.playJoinSound, forKey: PrefsKeys.playJoinSound.rawValue)
+ }
}
-
+
var playLeaveSound: Bool {
- didSet { UserDefaults.standard.set(self.playLeaveSound, forKey: PrefsKeys.playLeaveSound.rawValue) }
+ didSet {
+ UserDefaults.standard.set(self.playLeaveSound, forKey: PrefsKeys.playLeaveSound.rawValue)
+ }
}
-
+
var playLoggedInSound: Bool {
- didSet { UserDefaults.standard.set(self.playLoggedInSound, forKey: PrefsKeys.playLoggedInSound.rawValue) }
+ didSet {
+ UserDefaults.standard.set(
+ self.playLoggedInSound, forKey: PrefsKeys.playLoggedInSound.rawValue)
+ }
}
-
+
var playErrorSound: Bool {
- didSet { UserDefaults.standard.set(self.playErrorSound, forKey: PrefsKeys.playErrorSound.rawValue) }
+ didSet {
+ UserDefaults.standard.set(self.playErrorSound, forKey: PrefsKeys.playErrorSound.rawValue)
+ }
}
-
+
var playChatInvitationSound: Bool {
- didSet { UserDefaults.standard.set(self.playChatInvitationSound, forKey: PrefsKeys.playChatInvitationSound.rawValue) }
+ didSet {
+ UserDefaults.standard.set(
+ self.playChatInvitationSound, forKey: PrefsKeys.playChatInvitationSound.rawValue)
+ }
}
-
+
var refusePrivateChat: Bool {
- didSet { UserDefaults.standard.set(self.refusePrivateChat, forKey: PrefsKeys.refusePrivateChat.rawValue) }
+ didSet {
+ UserDefaults.standard.set(
+ self.refusePrivateChat, forKey: PrefsKeys.refusePrivateChat.rawValue)
+ }
}
-
+
var enableAutomaticMessage: Bool {
- didSet { UserDefaults.standard.set(self.enableAutomaticMessage, forKey: PrefsKeys.enableAutomaticMessage.rawValue) }
+ didSet {
+ UserDefaults.standard.set(
+ self.enableAutomaticMessage, forKey: PrefsKeys.enableAutomaticMessage.rawValue)
+ }
}
-
+
var automaticMessage: String {
- didSet { UserDefaults.standard.set(self.automaticMessage, forKey: PrefsKeys.automaticMessage.rawValue) }
+ didSet {
+ UserDefaults.standard.set(self.automaticMessage, forKey: PrefsKeys.automaticMessage.rawValue)
+ }
}
-
+
var showBannerToolbar: Bool {
- didSet { UserDefaults.standard.set(self.showBannerToolbar, forKey: PrefsKeys.showBannerToolbar.rawValue) }
+ didSet {
+ UserDefaults.standard.set(
+ self.showBannerToolbar, forKey: PrefsKeys.showBannerToolbar.rawValue)
+ }
}
-
+
var showJoinLeaveMessages: Bool {
- didSet { UserDefaults.standard.set(self.showJoinLeaveMessages, forKey: PrefsKeys.showJoinLeaveMessages.rawValue) }
+ didSet {
+ UserDefaults.standard.set(
+ self.showJoinLeaveMessages, forKey: PrefsKeys.showJoinLeaveMessages.rawValue)
+ }
}
}
diff --git a/Hotline/Models/PreviewFileInfo.swift b/Hotline/Models/PreviewFileInfo.swift
index 7dd692f..06a2966 100644
--- a/Hotline/Models/PreviewFileInfo.swift
+++ b/Hotline/Models/PreviewFileInfo.swift
@@ -12,14 +12,13 @@ struct PreviewFileInfo: Identifiable, Codable {
var port: Int
var size: Int
var name: String
-
+
var previewType: FilePreviewType {
let fileExtension = (self.name as NSString).pathExtension
if let fileType = UTType(filenameExtension: fileExtension) {
if fileType.isSubtype(of: .image) {
return .image
- }
- else if fileType.isSubtype(of: .text) {
+ } else if fileType.isSubtype(of: .text) {
return .text
}
}
diff --git a/Hotline/Models/Server.swift b/Hotline/Models/Server.swift
index 3f4c9e8..a3e3dde 100644
--- a/Hotline/Models/Server.swift
+++ b/Hotline/Models/Server.swift
@@ -4,14 +4,18 @@ struct Server: Codable {
var name: String?
var description: String?
var users: Int
-
+
var address: String
var port: Int
var login: String
var password: String
var autoconnect: Bool = false
-
- init(name: String?, description: String?, address: String, port: Int = HotlinePorts.DefaultServerPort, users: Int = 0, login: String? = nil, password: String? = nil, autoconnect: Bool? = false) {
+
+ init(
+ name: String?, description: String?, address: String,
+ port: Int = HotlinePorts.DefaultServerPort, users: Int = 0, login: String? = nil,
+ password: String? = nil, autoconnect: Bool? = false
+ ) {
self.name = name
self.description = description
self.address = address.lowercased()
@@ -21,27 +25,27 @@ struct Server: Codable {
self.password = password ?? ""
self.autoconnect = autoconnect ?? false
}
-
+
init?(url: URL) {
guard url.scheme?.lowercased() == "hotline" else {
return nil
}
-
+
guard let host = url.host(percentEncoded: false) else {
return nil
}
-
+
self.name = nil
self.description = nil
self.users = 0
-
+
self.address = host.lowercased()
self.port = url.port ?? HotlinePorts.DefaultServerPort
-
+
self.login = url.user(percentEncoded: false) ?? ""
self.password = url.password(percentEncoded: false) ?? ""
}
-
+
static func parseServerAddressAndPort(_ address: String) -> (String, Int) {
let url = URL(string: "hotline://\(address)")
let port = url?.port ?? HotlinePorts.DefaultServerPort
@@ -56,7 +60,8 @@ extension Server: Identifiable {
extension Server: Equatable {
static func == (lhs: Server, rhs: Server) -> Bool {
- return (lhs.address == rhs.address) && (lhs.port == rhs.port) && (lhs.login == rhs.login) && (lhs.password == rhs.password)
+ return (lhs.address == rhs.address) && (lhs.port == rhs.port) && (lhs.login == rhs.login)
+ && (lhs.password == rhs.password)
}
}
diff --git a/Hotline/Models/Tracker.swift b/Hotline/Models/Tracker.swift
index 171cb35..0ebcf2f 100644
--- a/Hotline/Models/Tracker.swift
+++ b/Hotline/Models/Tracker.swift
@@ -3,12 +3,12 @@ import SwiftUI
@Observable final class Tracker {
let address: String
let port: Int
-
+
init(address: String, port: Int = HotlinePorts.DefaultTrackerPort) {
self.address = address
self.port = port
}
-
+
static func parseTrackerAddressAndPort(_ address: String) -> (String, Int) {
let url = URL(string: "hotlinetracker://\(address)")
let port = url?.port ?? HotlinePorts.DefaultTrackerPort
diff --git a/Hotline/Models/TransferInfo.swift b/Hotline/Models/TransferInfo.swift
index 50b23f5..8aacf1d 100644
--- a/Hotline/Models/TransferInfo.swift
+++ b/Hotline/Models/TransferInfo.swift
@@ -3,32 +3,32 @@ 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 progressCallback: ((TransferInfo, Double) -> Void)? = nil
var downloadCallback: ((TransferInfo, URL) -> Void)? = nil
var uploadCallback: ((TransferInfo) -> 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 21a8fff..90b0801 100644
--- a/Hotline/Models/User.swift
+++ b/Hotline/Models/User.swift
@@ -12,21 +12,21 @@ struct User: Identifiable {
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) }
if hotlineUser.isAdmin { status.update(with: .admin) }
-
+
self.id = hotlineUser.id
self.name = hotlineUser.name
self.iconID = UInt(hotlineUser.iconID)
self.status = status
}
-
+
init(id: UInt16, name: String, iconID: UInt, status: UserStatus) {
self.id = id
self.name = name
diff --git a/Hotline/Shared/AsyncLinkPreview.swift b/Hotline/Shared/AsyncLinkPreview.swift
index 89b186f..6f32abb 100644
--- a/Hotline/Shared/AsyncLinkPreview.swift
+++ b/Hotline/Shared/AsyncLinkPreview.swift
@@ -1,15 +1,17 @@
-import SwiftUI
import LinkPresentation
+import SwiftUI
-fileprivate class CustomLinkView: LPLinkView {
- override var intrinsicContentSize: CGSize { CGSize(width: 0, height: super.intrinsicContentSize.height) }
+private class CustomLinkView: LPLinkView {
+ override var intrinsicContentSize: CGSize {
+ CGSize(width: 0, height: super.intrinsicContentSize.height)
+ }
}
struct AsyncLinkPreview: View {
@State private var metadata: LPLinkMetadata?
@State private var isLoading = true
let url: URL?
-
+
func fetchMetadata() async {
guard let url else {
self.isLoading = false
@@ -24,7 +26,7 @@ struct AsyncLinkPreview: View {
self.isLoading = false
}
}
-
+
var body: some View {
if isLoading {
ProgressView()
@@ -45,12 +47,12 @@ struct AsyncLinkPreview: View {
struct LinkView: NSViewRepresentable {
var metadata: LPLinkMetadata
-
+
func makeNSView(context: Context) -> LPLinkView {
let linkView = CustomLinkView(metadata: metadata)
return linkView
}
-
+
func updateNSView(_ nsView: LPLinkView, context: Context) {
// Nothing required
}
diff --git a/Hotline/Shared/DataAdditions.swift b/Hotline/Shared/DataAdditions.swift
index 0e9dd96..1eb7f6f 100644
--- a/Hotline/Shared/DataAdditions.swift
+++ b/Hotline/Shared/DataAdditions.swift
@@ -10,9 +10,10 @@ extension Data {
try? h.close()
if bounceDock {
#if os(macOS)
- var downloadURL = URL(filePath: filePath)
- downloadURL.resolveSymlinksInPath()
- DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path)
+ var downloadURL = URL(filePath: filePath)
+ downloadURL.resolveSymlinksInPath()
+ DistributedNotificationCenter.default().post(
+ name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path)
#endif
}
return true
diff --git a/Hotline/Shared/FileIconView.swift b/Hotline/Shared/FileIconView.swift
index d0949fa..d39ff1e 100644
--- a/Hotline/Shared/FileIconView.swift
+++ b/Hotline/Shared/FileIconView.swift
@@ -3,13 +3,13 @@ import UniformTypeIdentifiers
struct FolderIconView: View {
private func folderIcon() -> Image {
-#if os(iOS)
- return Image(systemName: "folder.fill")
-#elseif os(macOS)
- return Image(nsImage: NSWorkspace.shared.icon(for: UTType.folder))
-#endif
+ #if os(iOS)
+ return Image(systemName: "folder.fill")
+ #elseif os(macOS)
+ return Image(nsImage: NSWorkspace.shared.icon(for: UTType.folder))
+ #endif
}
-
+
var body: some View {
folderIcon()
.resizable()
@@ -20,52 +20,47 @@ struct FolderIconView: View {
struct FileIconView: View {
let filename: String
let fileType: String?
-
+
#if os(iOS)
- private func fileIcon() -> Image {
- let fileExtension = (self.filename as NSString).pathExtension
- if let fileType = UTType(filenameExtension: fileExtension) {
- if fileType.isSubtype(of: .movie) {
- return Image(systemName: "play.rectangle")
- }
- else if fileType.isSubtype(of: .image) {
- return Image(systemName: "photo")
- }
- else if fileType.isSubtype(of: .archive) {
- return Image(systemName: "doc.zipper")
- }
- else if fileType.isSubtype(of: .text) {
- return Image(systemName: "doc.text")
- }
- else {
- return Image(systemName: "doc")
+ private func fileIcon() -> Image {
+ let fileExtension = (self.filename as NSString).pathExtension
+ if let fileType = UTType(filenameExtension: fileExtension) {
+ if fileType.isSubtype(of: .movie) {
+ return Image(systemName: "play.rectangle")
+ } else if fileType.isSubtype(of: .image) {
+ return Image(systemName: "photo")
+ } else if fileType.isSubtype(of: .archive) {
+ return Image(systemName: "doc.zipper")
+ } else if fileType.isSubtype(of: .text) {
+ return Image(systemName: "doc.text")
+ } else {
+ return Image(systemName: "doc")
+ }
}
+
+ return Image(systemName: "doc")
}
-
- return Image(systemName: "doc")
- }
#elseif os(macOS)
- private func fileIcon() -> Image {
- let fileExtension = (self.filename as NSString).pathExtension
-
- if !fileExtension.isEmpty,
- let uttype = UTType(filenameExtension: fileExtension) {
- return Image(nsImage: NSWorkspace.shared.icon(for: uttype))
- }
- else if let fileType = self.fileType,
- let fileTypeExtension = FileManager.HFSTypeToExtension[fileType.lowercased()],
- let uttype = UTType(filenameExtension: fileTypeExtension) {
- return Image(nsImage: NSWorkspace.shared.icon(for: uttype))
- }
- else {
- return Image(nsImage: NSWorkspace.shared.icon(for: UTType.data))
+ private func fileIcon() -> Image {
+ let fileExtension = (self.filename as NSString).pathExtension
+
+ if !fileExtension.isEmpty,
+ let uttype = UTType(filenameExtension: fileExtension)
+ {
+ return Image(nsImage: NSWorkspace.shared.icon(for: uttype))
+ } else if let fileType = self.fileType,
+ let fileTypeExtension = FileManager.HFSTypeToExtension[fileType.lowercased()],
+ let uttype = UTType(filenameExtension: fileTypeExtension)
+ {
+ return Image(nsImage: NSWorkspace.shared.icon(for: uttype))
+ } else {
+ return Image(nsImage: NSWorkspace.shared.icon(for: UTType.data))
+ }
+
+ // Image(nsImage: NSWorkspace.shared.icon(for: UTType(filenameExtension: (filename as NSString).pathExtension) ?? UTType.content))
}
-
-// Image(nsImage: NSWorkspace.shared.icon(for: UTType(filenameExtension: (filename as NSString).pathExtension) ?? UTType.content))
- }
#endif
-
var body: some View {
fileIcon()
.resizable()
diff --git a/Hotline/Shared/FileImageView.swift b/Hotline/Shared/FileImageView.swift
index 0cc50f1..141a71b 100644
--- a/Hotline/Shared/FileImageView.swift
+++ b/Hotline/Shared/FileImageView.swift
@@ -2,10 +2,10 @@ import SwiftUI
struct FileImageView: NSViewRepresentable {
var image: NSImage?
-
+
let minimumSize: CGSize = CGSize(width: 350, height: 350)
let presentationPaddingRatio: Double = 0.5
-
+
func makeNSView(context: Context) -> NSImageView {
let imageView = NSImageView()
imageView.imageScaling = .scaleProportionallyUpOrDown
@@ -14,85 +14,89 @@ struct FileImageView: NSViewRepresentable {
imageView.allowsCutCopyPaste = true
imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
-
+
if let img = self.image {
imageView.image = img
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
self.resizeWindowForImageView(imageView)
}
}
-
+
return imageView
}
-
+
func updateNSView(_ nsView: NSImageView, context: Context) {
nsView.image = self.image
}
-
+
// MARK: -
-
+
func resizeWindowForImageView(_ imageView: NSImageView) {
guard let window = imageView.window, let img = imageView.image else {
return
}
-
+
var windowRect = window.contentLayoutRect
- let windowChromeSize = CGSize(width: window.frame.width - windowRect.width, height: window.frame.height - windowRect.height)
-
+ let windowChromeSize = CGSize(
+ width: window.frame.width - windowRect.width, height: window.frame.height - windowRect.height)
+
var windowMinSize: CGSize = windowRect.size
let centerPoint = CGPoint(x: window.frame.midX, y: window.frame.midY)
-
+
windowRect.size = img.size
-
+
if let screen = window.screen {
var paddedScreenSize = screen.frame.size
paddedScreenSize.width *= self.presentationPaddingRatio
paddedScreenSize.height *= self.presentationPaddingRatio
-
+
windowMinSize = aspectFit(source: windowRect.size, bounds: self.minimumSize)
- windowRect.size = aspectFit(source: windowRect.size, bounds: paddedScreenSize, minimum: windowMinSize)
+ windowRect.size = aspectFit(
+ source: windowRect.size, bounds: paddedScreenSize, minimum: windowMinSize)
}
-
+
windowRect.size.width += windowChromeSize.width
windowRect.size.height += windowChromeSize.height
-
+
windowRect.origin.x = centerPoint.x - windowRect.width / 2.0
windowRect.origin.y = centerPoint.y - windowRect.height / 2.0
-
+
window.setFrame(windowRect, display: true, animate: true)
-
-// Do these APIs even work??
-// window.aspectRatio = windowRect.size
-// window.contentAspectRatio = windowRect.size
+
+ // Do these APIs even work??
+ // window.aspectRatio = windowRect.size
+ // window.contentAspectRatio = windowRect.size
}
-
- func aspectFit(source sourceSize: CGSize, bounds boundingSize: CGSize, minimum minSize: CGSize? = nil) -> CGSize {
+
+ func aspectFit(
+ source sourceSize: CGSize, bounds boundingSize: CGSize, minimum minSize: CGSize? = nil
+ ) -> CGSize {
let sourceAspectRatio = sourceSize.width / sourceSize.height
-
+
var fitSize: CGSize = sourceSize
-
+
if fitSize.width > boundingSize.width {
fitSize.width = boundingSize.width
fitSize.height = fitSize.width / sourceAspectRatio
}
-
+
if fitSize.height > boundingSize.height {
fitSize.height = boundingSize.height
fitSize.width = fitSize.height * sourceAspectRatio
}
-
+
if let m = minSize {
if fitSize.width < m.width {
fitSize.width = m.width
fitSize.height = fitSize.width / sourceAspectRatio
}
-
+
if fitSize.height < m.height {
fitSize.height = m.height
fitSize.width = fitSize.height * sourceAspectRatio
}
}
-
+
return fitSize
}
}
diff --git a/Hotline/Shared/HotlinePanel.swift b/Hotline/Shared/HotlinePanel.swift
index 191e89a..6376a07 100644
--- a/Hotline/Shared/HotlinePanel.swift
+++ b/Hotline/Shared/HotlinePanel.swift
@@ -1,63 +1,68 @@
import Cocoa
import SwiftUI
-fileprivate let HOTLINE_PANEL_SIZE: CGSize = CGSizeMake(468, 114 - 10)
+private let HOTLINE_PANEL_SIZE: CGSize = CGSizeMake(468, 114 - 10)
class HotlinePanel: NSPanel {
init(_ view: HotlinePanelView) {
- super.init(contentRect: NSRect(x: 0, y: 0, width: HOTLINE_PANEL_SIZE.width, height: HOTLINE_PANEL_SIZE.height), styleMask: [.nonactivatingPanel, .titled, .closable, .utilityWindow, .fullSizeContentView], backing: .buffered, defer: false)
-
+ super.init(
+ contentRect: NSRect(
+ x: 0, y: 0, width: HOTLINE_PANEL_SIZE.width, height: HOTLINE_PANEL_SIZE.height),
+ styleMask: [.nonactivatingPanel, .titled, .closable, .utilityWindow, .fullSizeContentView],
+ backing: .buffered, defer: false)
+
// Make sure that the panel is in front of almost all other windows
self.isFloatingPanel = false
self.level = .floating
self.hidesOnDeactivate = true
self.animationBehavior = .utilityWindow
-
+
// Allow the panel to appear in a fullscreen space
-// self.collectionBehavior.insert(.fullScreenAuxiliary)
+ // self.collectionBehavior.insert(.fullScreenAuxiliary)
self.collectionBehavior.insert(.canJoinAllSpaces)
self.collectionBehavior.insert(.ignoresCycle)
-
-// self.appearance = NSAppearance(named: .vibrantDark)
+
+ // self.appearance = NSAppearance(named: .vibrantDark)
// Don't delete panel state when it's closed.
self.isReleasedWhenClosed = false
-
+
self.standardWindowButton(.closeButton)?.isHidden = true
self.standardWindowButton(.zoomButton)?.isHidden = true
self.standardWindowButton(.miniaturizeButton)?.isHidden = true
-
+
// Make it transparent, the view inside will have to set the background.
// This is necessary because otherwise, we will have some space for the titlebar on top of the height of the view itself which we don't want.
self.isOpaque = false
self.backgroundColor = .clear
-
+
// Since we don't show a statusbar, this allows us to drag the window by its background instead of the titlebar.
self.isMovableByWindowBackground = true
self.titlebarAppearsTransparent = true
-
+
let hostingView = NSHostingView(rootView: view.edgesIgnoringSafeArea(.top))
hostingView.sizingOptions = [.preferredContentSize]
- let visualEffectView = NSVisualEffectView(frame: NSRect(x: 0, y: 0, width: HOTLINE_PANEL_SIZE.width, height: HOTLINE_PANEL_SIZE.height))
+ let visualEffectView = NSVisualEffectView(
+ frame: NSRect(x: 0, y: 0, width: HOTLINE_PANEL_SIZE.width, height: HOTLINE_PANEL_SIZE.height))
visualEffectView.material = .sidebar
visualEffectView.blendingMode = .behindWindow
visualEffectView.state = NSVisualEffectView.State.active
visualEffectView.autoresizingMask = [.width, .height]
visualEffectView.autoresizesSubviews = true
visualEffectView.addSubview(hostingView)
-
+
self.contentView = visualEffectView
-
+
hostingView.frame = visualEffectView.bounds
-
+
self.cascadeTopLeft(from: NSMakePoint(16, 16))
}
-
+
override var canBecomeKey: Bool {
return false
}
-
+
override var canBecomeMain: Bool {
return false
}
diff --git a/Hotline/Shared/NetSocket.swift b/Hotline/Shared/NetSocket.swift
index 263487b..59c26af 100644
--- a/Hotline/Shared/NetSocket.swift
+++ b/Hotline/Shared/NetSocket.swift
@@ -1,4 +1,3 @@
-
// NetSocket.swift
// A simple delegate based buffered read/write TCP socket.
// Created by Dustin Mierau
@@ -27,56 +26,57 @@ enum NetSocketStatus {
final class NetSocket: NSObject, StreamDelegate {
weak var delegate: NetSocketDelegate? = nil
-
+
private var output: OutputStream? = nil
private var input: InputStream? = nil
-
+
private var outputBuffer: [UInt8] = []
private var inputBuffer: [UInt8] = []
-
+
private var readBuffer: [UInt8] = Array(repeating: 0, count: 4 * 1024)
-
+
public func peek() -> [UInt8] { self.inputBuffer }
public var available: Int { self.inputBuffer.count }
-
+
private var status: NetSocketStatus = .disconnected
-
+
@MainActor public func has(_ length: Int) -> Bool {
return (self.available >= length)
}
-
+
override init() {}
-
+
@MainActor public func connect(host: String, port: Int) {
self.close()
-
+
var outputStream: OutputStream? = nil
var inputStream: InputStream? = nil
-
+
self.status = .connecting
-
- Stream.getStreamsToHost(withName: host, port: port, inputStream: &inputStream, outputStream: &outputStream)
-
+
+ Stream.getStreamsToHost(
+ withName: host, port: port, inputStream: &inputStream, outputStream: &outputStream)
+
self.input = inputStream
self.output = outputStream
-
+
inputStream?.delegate = self
outputStream?.delegate = self
-
+
inputStream?.schedule(in: .current, forMode: .default)
outputStream?.schedule(in: .current, forMode: .default)
-
+
inputStream?.open()
outputStream?.open()
}
-
+
@MainActor public func close(_ err: Error? = nil) {
print("NetSocket: Closed")
-
+
let disconnected = (self.status != .disconnected)
-
+
self.status = .disconnected
-
+
self.input?.delegate = nil
self.output?.delegate = nil
self.input?.close()
@@ -87,122 +87,120 @@ final class NetSocket: NSObject, StreamDelegate {
self.output = nil
self.inputBuffer = []
self.outputBuffer = []
-
+
if disconnected {
self.delegate?.netsocketDisconnected(socket: self, error: err)
}
}
-
+
@MainActor public func write(_ data: Data) {
guard let output = self.output else {
return
}
-
+
self.outputBuffer.append(contentsOf: data)
-
+
if output.hasSpaceAvailable {
self.writeBufferToStream()
}
}
-
+
@MainActor public func write(_ data: [UInt8]) {
guard let output = self.output else {
return
}
-
+
self.outputBuffer.append(contentsOf: data)
-
+
if output.hasSpaceAvailable {
self.writeBufferToStream()
}
}
-
+
@MainActor public func read(count: Int) -> [UInt8] {
guard self.inputBuffer.count > 0, count > 0 else {
return []
}
-
+
let amountToRead = min(count, self.inputBuffer.count)
let dataRead: [UInt8] = Array(self.inputBuffer[0..<amountToRead])
self.inputBuffer.removeFirst(amountToRead)
-
+
return dataRead
}
-
+
@MainActor public func read(count: Int) -> Data {
guard self.inputBuffer.count > 0, count > 0 else {
return Data()
}
-
+
let amountToRead = min(count, self.inputBuffer.count)
-
+
let dataRead: Data = Data(self.inputBuffer[0..<amountToRead])
self.inputBuffer.removeFirst(amountToRead)
-
+
return dataRead
}
-
+
@MainActor public func readAll() -> [UInt8] {
guard self.inputBuffer.count > 0 else {
return []
}
-
+
let dataRead: [UInt8] = Array(self.inputBuffer)
self.inputBuffer = []
-
+
return dataRead
}
-
+
@MainActor public func readAll() -> Data {
guard self.inputBuffer.count > 0 else {
return Data()
}
-
+
let dataRead: Data = Data(self.inputBuffer)
self.inputBuffer = []
-
+
return dataRead
}
-
+
@MainActor private func writeBufferToStream() {
guard let output = self.output, self.outputBuffer.count > 0 else {
return
}
-
+
let bytesWritten = output.write(self.outputBuffer, maxLength: self.outputBuffer.count)
print("NetSocket => \(bytesWritten) bytes")
if bytesWritten > 0 {
self.outputBuffer.removeFirst(bytesWritten)
self.delegate?.netsocketSent(socket: self, count: bytesWritten)
- }
- else if bytesWritten == -1 {
+ } else if bytesWritten == -1 {
self.close(output.streamError)
}
}
-
+
@MainActor private func readStreamToBuffer() {
guard let input = self.input else {
return
}
-
+
let bytesRead = input.read(&self.readBuffer, maxLength: 4 * 1024)
print("NetSocket <= \(bytesRead) bytes")
if bytesRead > 0 {
self.inputBuffer.append(contentsOf: self.readBuffer[0..<bytesRead])
self.delegate?.netsocketReceived(socket: self, bytes: self.inputBuffer)
- }
- else if bytesRead == -1 {
+ } else if bytesRead == -1 {
self.close(input.streamError)
}
}
-
+
// MARK: -
-
+
@MainActor func stream(_ aStream: Stream, handle eventCode: Stream.Event) {
guard let input = self.input, let output = self.output else {
return
}
-
+
switch eventCode {
case .openCompleted:
if aStream == input {
@@ -226,44 +224,49 @@ final class NetSocket: NSObject, StreamDelegate {
print("NetSocket: End encounterd")
self.close()
case .errorOccurred:
- print("NetSocket: Error encounterd", input.streamError.debugDescription, output.streamError.debugDescription)
+ print(
+ "NetSocket: Error encounterd", input.streamError.debugDescription,
+ output.streamError.debugDescription)
let err = input.streamError ?? output.streamError
self.close(err)
default:
break
}
}
-
+
// MARK: -
-
+
private func setupStreamOptions() {
if let input = self.input {
- let socketData: Data = CFReadStreamCopyProperty(input as CFReadStream, CFStreamPropertyKey.socketNativeHandle) as! Data;
- var socketHandle: CFSocketNativeHandle = 0;
- (socketData as NSData).getBytes(&socketHandle, length: MemoryLayout.size(ofValue: socketHandle));
-
- var value: Int = 0;
- let size = UInt32(MemoryLayout.size(ofValue: value));
-
- value = 1;
+ let socketData: Data =
+ CFReadStreamCopyProperty(input as CFReadStream, CFStreamPropertyKey.socketNativeHandle)
+ as! Data
+ var socketHandle: CFSocketNativeHandle = 0
+ (socketData as NSData).getBytes(
+ &socketHandle, length: MemoryLayout.size(ofValue: socketHandle))
+
+ var value: Int = 0
+ let size = UInt32(MemoryLayout.size(ofValue: value))
+
+ value = 1
if setsockopt(socketHandle, IPPROTO_TCP, TCP_NODELAY, &value, size) != 0 {
- print("NetSocket: failed to set TCP_NODELAY");
+ print("NetSocket: failed to set TCP_NODELAY")
}
// Enable keepalive
- value = 1;
+ value = 1
if setsockopt(socketHandle, SOL_SOCKET, SO_KEEPALIVE, &value, size) != 0 {
- print("NetSocket: failed to set SO_KEEPALIVE");
+ print("NetSocket: failed to set SO_KEEPALIVE")
}
-// // Number of keepalives before close (including first keepalive packet)
-// value = 5
-// if setsockopt(socketHandle, IPPROTO_TCP, TCP_KEEPCNT, &value, size) != 0 {
-// print("NetSocket: failed to set TCP_KEEPCNT");
-// }
-// // Idle time used when SO_KEEPALIVE is enabled. Sets how long connection must be idle before keepalive is sent.
-// value = 60
-// if setsockopt(socketHandle, IPPROTO_TCP, TCP_KEEPALIVE, &value, size) != 0 {
-// print("NetSocket: failed to set TCP_KEEPALIVE")
-// }
+ // // Number of keepalives before close (including first keepalive packet)
+ // value = 5
+ // if setsockopt(socketHandle, IPPROTO_TCP, TCP_KEEPCNT, &value, size) != 0 {
+ // print("NetSocket: failed to set TCP_KEEPCNT");
+ // }
+ // // Idle time used when SO_KEEPALIVE is enabled. Sets how long connection must be idle before keepalive is sent.
+ // value = 60
+ // if setsockopt(socketHandle, IPPROTO_TCP, TCP_KEEPALIVE, &value, size) != 0 {
+ // print("NetSocket: failed to set TCP_KEEPALIVE")
+ // }
}
}
}
diff --git a/Hotline/Shared/TextView.swift b/Hotline/Shared/TextView.swift
index 47746e2..6361823 100644
--- a/Hotline/Shared/TextView.swift
+++ b/Hotline/Shared/TextView.swift
@@ -1,41 +1,41 @@
import SwiftUI
struct BetterTextEditor: NSViewRepresentable {
-
+
@Environment(\.lineSpacing) private var lineSpacing
-
+
@Binding private var text: String
-
+
private var customizations: [(NSTextView) -> Void] = []
-
+
init(text: Binding<String>) {
self._text = text
}
-
+
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
-
+
func makeNSView(context: Context) -> NSScrollView {
let scrollview = NSTextView.scrollablePlainDocumentContentTextView()
let textview = scrollview.documentView as! NSTextView
-
+
textview.string = self.text
textview.delegate = context.coordinator
-
-// let p = NSMutableParagraphStyle()
-// p.lineSpacing = self.lineSpacing
-//// textview.defaultParagraphStyle = p
-// textview.typingAttributes = [
-// .paragraphStyle: p
-// ]
-
+
+ // let p = NSMutableParagraphStyle()
+ // p.lineSpacing = self.lineSpacing
+ //// textview.defaultParagraphStyle = p
+ // textview.typingAttributes = [
+ // .paragraphStyle: p
+ // ]
+
textview.isEditable = true
textview.isRichText = false
textview.allowsUndo = true
textview.isFieldEditor = false
textview.usesAdaptiveColorMappingForDarkAppearance = true
- textview.drawsBackground = false // true
+ textview.drawsBackground = false // true
textview.usesRuler = false
textview.usesFindBar = false
textview.isIncrementalSearchingEnabled = false
@@ -54,53 +54,53 @@ struct BetterTextEditor: NSViewRepresentable {
textview.isContinuousSpellCheckingEnabled = true
textview.setSelectedRange(NSMakeRange(0, 0))
self.customizations.forEach { $0(textview) }
-
+
scrollview.scrollerStyle = .overlay
-
+
return scrollview
}
-
+
func updateNSView(_ nsView: NSScrollView, context: Context) {
let textview = nsView.documentView as! NSTextView
-
+
if textview.string != text {
textview.string = text
}
-
+
self.customizations.forEach { $0(textview) }
}
-
+
func betterEditorFont(_ font: NSFont) -> Self {
self.customized { $0.font = font }
}
-
+
func betterEditorParagraphStyle(_ paragraphStyle: NSParagraphStyle) -> Self {
self.customized { $0.defaultParagraphStyle = paragraphStyle }
}
-
+
func betterEditorAutomaticDashSubstitution(_ enabled: Bool) -> Self {
self.customized { $0.isAutomaticDashSubstitutionEnabled = enabled }
}
-
+
func betterEditorAutomaticQuoteSubstitution(_ enabled: Bool) -> Self {
self.customized { $0.isAutomaticQuoteSubstitutionEnabled = enabled }
}
-
+
func betterEditorAutomaticSpellingCorrection(_ enabled: Bool) -> Self {
self.customized { $0.isAutomaticSpellingCorrectionEnabled = enabled }
}
-
+
func betterEditorTextInset(_ size: NSSize) -> Self {
self.customized { $0.textContainerInset = size }
}
-
+
class Coordinator: NSObject, NSTextViewDelegate {
var parent: BetterTextEditor
-
+
init(_ parent: BetterTextEditor) {
self.parent = parent
}
-
+
func textDidChange(_ notification: Notification) {
guard let textview = notification.object as? NSTextView else {
return
@@ -110,7 +110,7 @@ struct BetterTextEditor: NSViewRepresentable {
}
}
-private extension BetterTextEditor {
+extension BetterTextEditor {
private func customized(_ customization: @escaping (NSTextView) -> Void) -> Self {
var copy = self
copy.customizations.append(customization)
diff --git a/Hotline/Shared/URLAdditions.swift b/Hotline/Shared/URLAdditions.swift
index 1f25541..5e6a450 100644
--- a/Hotline/Shared/URLAdditions.swift
+++ b/Hotline/Shared/URLAdditions.swift
@@ -5,14 +5,15 @@ extension URL {
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)"
+ return extensionName.isEmpty
+ ? "\(baseName) \(counter)" : "\(baseName) \(counter).\(extensionName)"
}
-
+
// Check if file exists and append counter until a unique name is found
var filePath = self.appending(component: finalName).path(percentEncoded: false)
while fileManager.fileExists(atPath: filePath) {
@@ -20,7 +21,7 @@ extension URL {
filePath = self.appending(component: finalName).path(percentEncoded: false)
counter += 1
}
-
+
return filePath
}
}
diff --git a/Hotline/Shared/VisualEffectView.swift b/Hotline/Shared/VisualEffectView.swift
index e595c55..ec64111 100644
--- a/Hotline/Shared/VisualEffectView.swift
+++ b/Hotline/Shared/VisualEffectView.swift
@@ -1,21 +1,18 @@
import SwiftUI
-struct VisualEffectView: NSViewRepresentable
-{
+struct VisualEffectView: NSViewRepresentable {
let material: NSVisualEffectView.Material
let blendingMode: NSVisualEffectView.BlendingMode
-
- func makeNSView(context: Context) -> NSVisualEffectView
- {
+
+ func makeNSView(context: Context) -> NSVisualEffectView {
let visualEffectView = NSVisualEffectView()
visualEffectView.material = material
visualEffectView.blendingMode = blendingMode
visualEffectView.state = NSVisualEffectView.State.active
return visualEffectView
}
-
- func updateNSView(_ visualEffectView: NSVisualEffectView, context: Context)
- {
+
+ func updateNSView(_ visualEffectView: NSVisualEffectView, context: Context) {
visualEffectView.material = material
visualEffectView.blendingMode = blendingMode
}
diff --git a/Hotline/Sounds/Application-iOS.swift b/Hotline/Sounds/Application-iOS.swift
index 2fb6ad2..55c5aa1 100644
--- a/Hotline/Sounds/Application-iOS.swift
+++ b/Hotline/Sounds/Application-iOS.swift
@@ -1,14 +1,14 @@
-import SwiftUI
import SwiftData
+import SwiftUI
import UniformTypeIdentifiers
@main
struct Application: App {
private var model = Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())
-
+
@FocusedValue(\.activeHotlineModel) private var activeHotline: Hotline?
@FocusedValue(\.activeServerState) private var activeServerState: ServerState?
-
+
var body: some Scene {
WindowGroup {
TrackerView()
diff --git a/Hotline/Utility/BookmarkDocument.swift b/Hotline/Utility/BookmarkDocument.swift
index 47fa442..43f251c 100644
--- a/Hotline/Utility/BookmarkDocument.swift
+++ b/Hotline/Utility/BookmarkDocument.swift
@@ -1,5 +1,5 @@
-import SwiftUI
import Foundation
+import SwiftUI
import UniformTypeIdentifiers
struct BookmarkDocument: FileDocument {
@@ -7,22 +7,22 @@ struct BookmarkDocument: FileDocument {
static var writableContentTypes: [UTType] { [.data, UTType(filenameExtension: "hlbm")!] }
var bookmark: Bookmark
-
+
init(bookmark: Bookmark) {
self.bookmark = bookmark
}
-
+
init(configuration: ReadConfiguration) throws {
guard configuration.file.isRegularFile,
- let data = configuration.file.regularFileContents,
- let fileName = configuration.file.preferredFilename,
- let bookmark = Bookmark(fileData: data, name: (fileName as NSString).deletingPathExtension)
+ let data = configuration.file.regularFileContents,
+ let fileName = configuration.file.preferredFilename,
+ let bookmark = Bookmark(fileData: data, name: (fileName as NSString).deletingPathExtension)
else {
throw CocoaError(.fileReadCorruptFile)
}
self.bookmark = bookmark
}
-
+
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
let wrapper = FileWrapper(regularFileWithContents: self.bookmark.bookmarkFileData()!)
wrapper.fileAttributes[FileAttributeKey.hfsCreatorCode.rawValue] = "HTLC".fourCharCode()
diff --git a/Hotline/Utility/DAKeychain.swift b/Hotline/Utility/DAKeychain.swift
index 8031886..aa71508 100644
--- a/Hotline/Utility/DAKeychain.swift
+++ b/Hotline/Utility/DAKeychain.swift
@@ -9,26 +9,27 @@
import Foundation
open class DAKeychain {
-
+
open var loggingEnabled = false
-
+
private init() {}
public static let shared = DAKeychain()
-
+
open subscript(key: String) -> String? {
get {
return load(withKey: key)
- } set {
+ }
+ set {
DispatchQueue.global().sync(flags: .barrier) {
self.save(newValue, forKey: key)
}
}
}
-
+
private func save(_ string: String?, forKey key: String) {
let query = keychainQuery(withKey: key)
let objectData: Data? = string?.data(using: .utf8, allowLossyConversion: false)
-
+
if SecItemCopyMatching(query, nil) == noErr {
if let dictData = objectData {
let status = SecItemUpdate(query, NSDictionary(dictionary: [kSecValueData: dictData]))
@@ -45,15 +46,15 @@ open class DAKeychain {
}
}
}
-
+
private func load(withKey key: String) -> String? {
let query = keychainQuery(withKey: key)
query.setValue(kCFBooleanTrue, forKey: kSecReturnData as String)
query.setValue(kCFBooleanTrue, forKey: kSecReturnAttributes as String)
-
+
var result: CFTypeRef?
let status = SecItemCopyMatching(query, &result)
-
+
guard
let resultsDict = result as? NSDictionary,
let resultsData = resultsDict.value(forKey: kSecValueData as String) as? Data,
@@ -64,7 +65,7 @@ open class DAKeychain {
}
return String(data: resultsData, encoding: .utf8)
}
-
+
private func keychainQuery(withKey key: String) -> NSMutableDictionary {
let result = NSMutableDictionary()
result.setValue(kSecClassGenericPassword, forKey: kSecClass as String)
@@ -72,7 +73,7 @@ open class DAKeychain {
result.setValue(kSecAttrAccessibleWhenUnlocked, forKey: kSecAttrAccessible as String)
return result
}
-
+
private func logPrint(_ items: Any...) {
if loggingEnabled {
print(items)
diff --git a/Hotline/Utility/FoundationExtensions.swift b/Hotline/Utility/FoundationExtensions.swift
index 9fc9ae7..b1cd1b9 100644
--- a/Hotline/Utility/FoundationExtensions.swift
+++ b/Hotline/Utility/FoundationExtensions.swift
@@ -7,27 +7,27 @@ enum Endianness {
}
extension String {
-
+
func convertToAttributedStringWithLinks() -> AttributedString {
let attributedString: NSMutableAttributedString = NSMutableAttributedString(string: self)
let matches = self.ranges(of: RegularExpressions.relaxedLink)
for match in matches {
let matchString = String(self[match])
if matchString.isEmailAddress() {
- attributedString.addAttribute(.link, value: "mailto:\(matchString)", range: NSRange(match, in: self))
- }
- else {
+ attributedString.addAttribute(
+ .link, value: "mailto:\(matchString)", range: NSRange(match, in: self))
+ } else {
attributedString.addAttribute(.link, value: matchString, range: NSRange(match, in: self))
}
-// attributedString.addAttribute(.underlineStyle, value: 1, range: NSRange(match, in: self))
+ // attributedString.addAttribute(.underlineStyle, value: 1, range: NSRange(match, in: self))
}
return AttributedString(attributedString)
}
-
+
func isEmailAddress() -> Bool {
self.wholeMatch(of: RegularExpressions.emailAddress) != nil
}
-
+
func isWebURL() -> Bool {
guard let url = URL(string: self) else {
return false
@@ -39,12 +39,12 @@ extension String {
return false
}
}
-
+
func isImageURL() -> Bool {
guard let url = URL(string: self) else {
return false
}
-
+
switch url.pathExtension.lowercased() {
case "jpg", "jpeg", "png", "gif":
return true
@@ -52,29 +52,27 @@ extension String {
return false
}
}
-
+
func convertingLinksToMarkdown() -> String {
var cp = String(self)
cp.replace(RegularExpressions.relaxedLink) { match -> String in
let linkText = self[match.range]
var injectedScheme = "https://"
- if let _ = try? RegularExpressions.supportedLinkScheme.prefixMatch(in: linkText) {
+ if (try? RegularExpressions.supportedLinkScheme.prefixMatch(in: linkText)) != nil {
injectedScheme = ""
}
-
+
return "[\(linkText)](\(injectedScheme)\(linkText))"
}
return cp
}
}
-
-
extension Binding where Value: OptionSet, Value == Value.Element {
func bindedValue(_ options: Value) -> Bool {
return wrappedValue.contains(options)
}
-
+
func bind(_ options: Value) -> Binding<Bool> {
return .init { () -> Bool in
self.wrappedValue.contains(options)
diff --git a/Hotline/Utility/NSWindowBridge.swift b/Hotline/Utility/NSWindowBridge.swift
index 5cd7197..ad56105 100644
--- a/Hotline/Utility/NSWindowBridge.swift
+++ b/Hotline/Utility/NSWindowBridge.swift
@@ -1,25 +1,25 @@
import SwiftUI
-fileprivate class NSWindowAccessorView: NSView {
- let executeBlock: (_ window: NSWindow? ) -> ()
-
- init(_ inConfigFunction: @escaping (_ window: NSWindow? ) -> () ) {
+private class NSWindowAccessorView: NSView {
+ let executeBlock: (_ window: NSWindow?) -> Void
+
+ init(_ inConfigFunction: @escaping (_ window: NSWindow?) -> Void) {
executeBlock = inConfigFunction
- super.init( frame: NSRect() )
+ super.init(frame: NSRect())
}
-
+
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
-
+
public override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
- executeBlock( self.window ) // We pass it through even if it is nil.
+ executeBlock(self.window) // We pass it through even if it is nil.
}
}
public struct NSWindowAccessor: NSViewRepresentable {
- var configCode: (_ window: NSWindow? ) -> ()
-
+ var configCode: (_ window: NSWindow?) -> Void
+
public init(_ configCode: @escaping (_: NSWindow?) -> Void) { self.configCode = configCode }
- public func makeNSView(context: Context) -> NSView { return NSWindowAccessorView( configCode ) }
+ public func makeNSView(context: Context) -> NSView { return NSWindowAccessorView(configCode) }
public func updateNSView(_ nsView: NSView, context: Context) {}
}
diff --git a/Hotline/Utility/ObservableScrollView.swift b/Hotline/Utility/ObservableScrollView.swift
index a6f46cc..05688f1 100644
--- a/Hotline/Utility/ObservableScrollView.swift
+++ b/Hotline/Utility/ObservableScrollView.swift
@@ -8,27 +8,31 @@ struct ScrollViewOffsetPreferenceKey: PreferenceKey {
}
}
-struct ObservableScrollView<Content>: View where Content : View {
+struct ObservableScrollView<Content>: View where Content: View {
@Namespace var scrollSpace
@Binding var scrollOffset: CGFloat
let content: () -> Content
-
- init(scrollOffset: Binding<CGFloat>,
- @ViewBuilder content: @escaping () -> Content) {
+
+ init(
+ scrollOffset: Binding<CGFloat>,
+ @ViewBuilder content: @escaping () -> Content
+ ) {
_scrollOffset = scrollOffset
self.content = content
}
-
+
var body: some View {
ScrollView {
content()
- .background(GeometryReader { geo in
- let offset = -geo.frame(in: .named(scrollSpace)).minY
- Color.clear
- .preference(key: ScrollViewOffsetPreferenceKey.self,
- value: offset)
- })
-
+ .background(
+ GeometryReader { geo in
+ let offset = -geo.frame(in: .named(scrollSpace)).minY
+ Color.clear
+ .preference(
+ key: ScrollViewOffsetPreferenceKey.self,
+ value: offset)
+ })
+
}
.coordinateSpace(name: scrollSpace)
.onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { value in
diff --git a/Hotline/Utility/RegularExpressions.swift b/Hotline/Utility/RegularExpressions.swift
index c1f2a18..db705ae 100644
--- a/Hotline/Utility/RegularExpressions.swift
+++ b/Hotline/Utility/RegularExpressions.swift
@@ -20,7 +20,7 @@ struct RegularExpressions {
}
}
}
-
+
static let supportedLinkScheme = Regex {
Anchor.startOfLine
ChoiceOf {
@@ -30,7 +30,7 @@ struct RegularExpressions {
}
"://"
}.ignoresCase().anchorsMatchLineEndings()
-
+
static let relaxedLink = Regex {
ChoiceOf {
Anchor.startOfLine
@@ -138,7 +138,7 @@ struct RegularExpressions {
}
.anchorsMatchLineEndings()
.ignoresCase()
-
+
static let emailAddress = Regex {
ChoiceOf {
Anchor.startOfLine
diff --git a/Hotline/Utility/SoundEffects.swift b/Hotline/Utility/SoundEffects.swift
index 93f8b9a..74314e4 100644
--- a/Hotline/Utility/SoundEffects.swift
+++ b/Hotline/Utility/SoundEffects.swift
@@ -1,5 +1,5 @@
-import Foundation
import AVFAudio
+import Foundation
enum SoundEffects: String {
case loggedIn = "logged-in"
@@ -15,28 +15,30 @@ enum SoundEffects: String {
@Observable
class SoundEffectPlayer: NSObject, AVAudioPlayerDelegate {
static let shared = SoundEffectPlayer()
-
+
private var activeSounds: [AVAudioPlayer] = []
-
+
func playSoundEffect(_ name: SoundEffects) {
// Load a local sound file
- guard let soundFileURL = Bundle.main.url(
- forResource: name.rawValue,
- withExtension: "aiff"
- ) else {
+ guard
+ let soundFileURL = Bundle.main.url(
+ forResource: name.rawValue,
+ withExtension: "aiff"
+ )
+ else {
return
}
-
+
DispatchQueue.main.async { [weak self] in
guard let self = self else {
return
}
-
+
if let soundEffect = try? AVAudioPlayer(contentsOf: soundFileURL) {
soundEffect.delegate = self
soundEffect.volume = 0.75
soundEffect.play()
-
+
self.activeSounds.append(soundEffect)
}
}
diff --git a/Hotline/Utility/SwiftUIExtensions.swift b/Hotline/Utility/SwiftUIExtensions.swift
index 3b850e0..27697f8 100644
--- a/Hotline/Utility/SwiftUIExtensions.swift
+++ b/Hotline/Utility/SwiftUIExtensions.swift
@@ -2,6 +2,8 @@ import SwiftUI
extension Color {
init(hex: Int, opacity: Double = 1.0) {
- self.init(red: Double((hex >> 16) & 0xFF) / 255.0, green: Double((hex >> 8) & 0xFF) / 255.0, blue: Double(hex & 0xFF) / 255.0, opacity: opacity)
+ self.init(
+ red: Double((hex >> 16) & 0xFF) / 255.0, green: Double((hex >> 8) & 0xFF) / 255.0,
+ blue: Double(hex & 0xFF) / 255.0, opacity: opacity)
}
}
diff --git a/Hotline/Utility/TextDocument.swift b/Hotline/Utility/TextDocument.swift
index 82727de..a3591fd 100644
--- a/Hotline/Utility/TextDocument.swift
+++ b/Hotline/Utility/TextDocument.swift
@@ -1,29 +1,29 @@
import Foundation
-import UniformTypeIdentifiers
import SwiftUI
+import UniformTypeIdentifiers
struct TextFile: FileDocument {
// tell the system we support only plain text
static var readableContentTypes = [UTType.plainText, UTType.utf8PlainText]
-
+
// by default our document is empty
var text = ""
-
+
// a simple initializer that creates new, empty documents
init(initialText: String = "") {
text = initialText
}
-
+
// this initializer loads data that has been saved previously
init(configuration: ReadConfiguration) throws {
-
+
if let data = configuration.file.regularFileContents {
if let str = String(data: data, encoding: .utf8) {
self.text = str
}
}
}
-
+
// this will be called when the system wants to write our data to disk
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
return FileWrapper(regularFileWithContents: self.text.data(using: .utf8)!)
diff --git a/Hotline/iOS/ChatView.swift b/Hotline/iOS/ChatView.swift
index dadd5e6..e5105d0 100644
--- a/Hotline/iOS/ChatView.swift
+++ b/Hotline/iOS/ChatView.swift
@@ -2,20 +2,21 @@ import SwiftUI
extension View {
func endEditing() {
- UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
+ UIApplication.shared.sendAction(
+ #selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
}
struct ChatView: View {
@Environment(Hotline.self) private var model: Hotline
@Environment(\.colorScheme) var colorScheme
-
+
@State var input: String = ""
@State private var scrollPos: Int?
@State private var contentHeight: CGFloat = 0
-
+
@Namespace var bottomID
-
+
var body: some View {
NavigationStack {
VStack(spacing: 0) {
@@ -24,7 +25,7 @@ struct ChatView: View {
LazyVStack(alignment: .leading) {
ForEach(model.chat) { msg in
if msg.type == .agreement {
-
+
VStack(alignment: .center) {
if let bannerImage = self.model.bannerImage {
Image(uiImage: bannerImage)
@@ -33,7 +34,7 @@ struct ChatView: View {
.frame(maxWidth: 468.0)
.clipShape(RoundedRectangle(cornerRadius: 5))
}
-
+
VStack(alignment: .leading) {
HStack {
Text(msg.text)
@@ -51,8 +52,7 @@ struct ChatView: View {
}
.frame(maxWidth: .infinity)
.padding()
- }
- else if msg.type == .status {
+ } else if msg.type == .status {
HStack {
Spacer()
Text(msg.text)
@@ -62,13 +62,11 @@ struct ChatView: View {
Spacer()
}
.padding()
- }
- else {
+ } else {
HStack(alignment: .firstTextBaseline) {
if let username = msg.username {
Text("**\(username):** \(msg.text)")
- }
- else {
+ } else {
Text(msg.text)
.textSelection(.enabled)
}
@@ -96,9 +94,9 @@ struct ChatView: View {
self.endEditing()
}
}
-
+
Divider()
-
+
HStack(alignment: .top) {
Image(systemName: "chevron.right").opacity(0.4)
TextField("", text: $input, axis: .vertical)
@@ -144,7 +142,7 @@ struct ChatView: View {
}
}
}
-
+
}
}
}
diff --git a/Hotline/iOS/FilesView.swift b/Hotline/iOS/FilesView.swift
index 017a469..70b07c2 100644
--- a/Hotline/iOS/FilesView.swift
+++ b/Hotline/iOS/FilesView.swift
@@ -3,11 +3,11 @@ import UniformTypeIdentifiers
struct FileView: View {
@Environment(Hotline.self) private var model: Hotline
-
+
@State var expanded = false
-
+
var file: FileInfo
-
+
var body: some View {
if file.isFolder {
DisclosureGroup(isExpanded: $expanded) {
@@ -17,7 +17,7 @@ struct FileView: View {
}
} label: {
HStack {
- HStack(alignment: /*@START_MENU_TOKEN@*/.center/*@END_MENU_TOKEN@*/) {
+ HStack(alignment: /*@START_MENU_TOKEN@*/ .center /*@END_MENU_TOKEN@*/) {
Image(systemName: "folder.fill")
}
.frame(minWidth: 25)
@@ -30,20 +30,19 @@ struct FileView: View {
if !expanded {
return
}
-
+
// Some servers don't reply when asking for the contents of an empty folder.
if file.isFolder && file.fileSize == 0 {
return
}
-
+
Task {
await model.getFileList(path: file.path)
}
}
- }
- else {
+ } else {
HStack {
- HStack(alignment: /*@START_MENU_TOKEN@*/.center/*@END_MENU_TOKEN@*/) {
+ HStack(alignment: /*@START_MENU_TOKEN@*/ .center /*@END_MENU_TOKEN@*/) {
fileIcon(name: file.name)
.renderingMode(.template)
}
@@ -54,53 +53,49 @@ struct FileView: View {
}
}
}
-
+
static let byteFormatter = ByteCountFormatter()
-
+
private func formattedFileSize(_ fileSize: UInt) -> String {
// let bcf = ByteCountFormatter()
FileView.byteFormatter.allowedUnits = [.useAll]
FileView.byteFormatter.countStyle = .file
return FileView.byteFormatter.string(fromByteCount: Int64(fileSize))
}
-
+
private func fileIcon(name: String) -> Image {
// func utTypeForFilename(_ filename: String) -> UTType? {
let fileExtension = (name as NSString).pathExtension
if let fileType = UTType(filenameExtension: fileExtension) {
print("\(name) \(fileExtension) = \(fileType)")
-
+
if fileType.isSubtype(of: .movie) {
return Image(systemName: "play.rectangle")
-// return UIImage(systemName: "play.rectangle")!
- }
- else if fileType.isSubtype(of: .image) {
+ // return UIImage(systemName: "play.rectangle")!
+ } else if fileType.isSubtype(of: .image) {
return Image(systemName: "photo")
-// return UIImage(systemName: "photo")!
- }
- else if fileType.isSubtype(of: .archive) {
+ // return UIImage(systemName: "photo")!
+ } else if fileType.isSubtype(of: .archive) {
return Image(systemName: "doc.zipper")
-// return UIImage(systemName: "doc.zipper")!
- }
- else if fileType.isSubtype(of: .text) {
+ // return UIImage(systemName: "doc.zipper")!
+ } else if fileType.isSubtype(of: .text) {
return Image(systemName: "doc.text")
-// return UIImage(systemName: "doc.text")!
- }
- else {
+ // return UIImage(systemName: "doc.text")!
+ } else {
return Image(systemName: "doc")
-// return UIImage(systemName: "doc")!
+ // return UIImage(systemName: "doc")!
}
}
-
+
return Image(systemName: "doc")
}
}
struct FilesView: View {
@Environment(Hotline.self) private var model: Hotline
-
+
@State var initialLoadComplete = false
-
+
var body: some View {
NavigationStack {
List(model.files) { file in
diff --git a/Hotline/iOS/MessageBoardView.swift b/Hotline/iOS/MessageBoardView.swift
index 0d3968f..56fd4cc 100644
--- a/Hotline/iOS/MessageBoardView.swift
+++ b/Hotline/iOS/MessageBoardView.swift
@@ -2,10 +2,10 @@ import SwiftUI
struct MessageBoardView: View {
@Environment(Hotline.self) private var model: Hotline
-
+
@State private var initialLoadComplete = false
@State private var fetched = false
-
+
var body: some View {
NavigationStack {
ScrollView {
@@ -57,14 +57,14 @@ struct MessageBoardView: View {
}
ToolbarItem(placement: .navigationBarTrailing) {
Button {
-
+
} label: {
Image(systemName: "square.and.pencil")
}
}
}
}
-
+
}
}
diff --git a/Hotline/iOS/NewsView.swift b/Hotline/iOS/NewsView.swift
index 460dcd3..72dd034 100644
--- a/Hotline/iOS/NewsView.swift
+++ b/Hotline/iOS/NewsView.swift
@@ -4,16 +4,16 @@ import UniformTypeIdentifiers
struct NewsItemView: View {
@Environment(Hotline.self) private var model: Hotline
@Environment(NewsItemSelection.self) private var selectedArticle: NewsItemSelection
-
+
let news: NewsInfo
-
+
static var dateFormatter: DateFormatter = {
var formatter = DateFormatter()
formatter.dateFormat = "MM/dd/yy, h:mm a"
formatter.timeZone = .gmt
return formatter
}()
-
+
static var relativeDateFormatter: RelativeDateTimeFormatter = {
var formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .abbreviated
@@ -21,9 +21,9 @@ struct NewsItemView: View {
formatter.formattingContext = .listItem
return formatter
}()
-
+
@State var expanded = false
-
+
var body: some View {
if news.count > 0 {
DisclosureGroup(isExpanded: $expanded) {
@@ -49,13 +49,12 @@ struct NewsItemView: View {
if !expanded {
return
}
-
+
Task {
await model.getNewsList(at: news.path)
}
}
- }
- else {
+ } else {
HStack(alignment: .firstTextBaseline) {
Text(Image(systemName: "quote.opening"))
Text(news.name)
@@ -66,13 +65,12 @@ struct NewsItemView: View {
Text("\(news.count)")
.lineLimit(1)
.foregroundStyle(.secondary)
- }
- else if let postAuthor = news.articleUsername {
-// Text("\(NewsItemView.relativeDateFormatter.localizedString(for: postDate, relativeTo: Date.now))")
+ } else if let postAuthor = news.articleUsername {
+ // Text("\(NewsItemView.relativeDateFormatter.localizedString(for: postDate, relativeTo: Date.now))")
Text(postAuthor)
.foregroundStyle(.secondary)
.truncationMode(.tail)
-// .frame(maxWidth: 50)
+ // .frame(maxWidth: 50)
.font(.caption)
.lineLimit(1)
}
@@ -90,7 +88,7 @@ struct NewsItemView: View {
@Observable
class NewsItemSelection: Equatable {
var selectedArticle: NewsInfo? = nil
-
+
static func == (lhs: NewsItemSelection, rhs: NewsItemSelection) -> Bool {
return lhs.selectedArticle == rhs.selectedArticle
}
@@ -99,25 +97,24 @@ class NewsItemSelection: Equatable {
struct NewsView: View {
@Environment(Hotline.self) private var model: Hotline
@Environment(\.colorScheme) var colorScheme
-
+
@State private var fetched = false
@State private var selectedCategory: NewsInfo? = nil
@State private var topListHeight: CGFloat = 280
@State private var dividerHeight: CGFloat = 30
-
+
@State private var articleSelection = NewsItemSelection()
@State private var articleText = ""
-
-// @State private var selectedArticleID: UInt?
-
+
+ // @State private var selectedArticleID: UInt?
+
var articleList: some View {
VStack(spacing: 0) {
if model.news.count == 0 {
Text("No News Available")
.font(.headline)
.opacity(0.3)
- }
- else {
+ } else {
List(model.news) { category in
NewsItemView(news: category)
.frame(height: 38)
@@ -128,10 +125,10 @@ struct NewsView: View {
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(uiColor: .systemGroupedBackground))
-
+
// .listStyle(.plain)
}
-
+
var body: some View {
NavigationStack {
VStack(spacing: 0) {
@@ -140,19 +137,21 @@ struct NewsView: View {
.frame(minHeight: topListHeight)
.onChange(of: self.articleSelection.selectedArticle) {
self.articleText = ""
- if
- let article = self.articleSelection.selectedArticle,
+ if let article = self.articleSelection.selectedArticle,
let articleFlavor = article.articleFlavors?.first,
- let articleID = article.articleID {
+ let articleID = article.articleID
+ {
Task {
- if let articleText = await self.model.getNewsArticle(id: articleID, at: article.path, flavor: articleFlavor) {
+ if let articleText = await self.model.getNewsArticle(
+ id: articleID, at: article.path, flavor: articleFlavor)
+ {
self.articleText = articleText
}
}
}
-// print("SELECTED ARTICLE", articleSelection.selectedArticle?.name)
+ // print("SELECTED ARTICLE", articleSelection.selectedArticle?.name)
}
-
+
// Movable Divider
VStack(alignment: .center) {
Divider()
@@ -165,7 +164,9 @@ struct NewsView: View {
}
Spacer()
}
- .background(colorScheme == .dark ? Color(white: 0.1) : Color(uiColor: UIColor.systemBackground))
+ .background(
+ colorScheme == .dark ? Color(white: 0.1) : Color(uiColor: UIColor.systemBackground)
+ )
.frame(maxWidth: .infinity)
.frame(height: dividerHeight)
.gesture(
@@ -175,11 +176,11 @@ struct NewsView: View {
topListHeight = max(min(topListHeight + delta, 500), 50)
}
)
-
+
// Reader View
ScrollView(.vertical) {
VStack(alignment: .leading) {
-
+
if let news = self.articleSelection.selectedArticle {
if let poster = news.articleUsername, let postDate = news.articleDate {
HStack(alignment: .firstTextBaseline) {
@@ -189,7 +190,7 @@ struct NewsView: View {
.lineLimit(1)
.truncationMode(.tail)
.textSelection(.enabled)
-// .padding()
+ // .padding()
Spacer()
Text("\(NewsItemView.dateFormatter.string(from: postDate))")
.foregroundStyle(.secondary)
@@ -198,16 +199,16 @@ struct NewsView: View {
.textSelection(.enabled)
}
.padding(.bottom, 8)
-
+
Divider()
.padding(.bottom, 16)
}
-
+
Text(news.name).font(.title3).fontWeight(.medium)
.textSelection(.enabled)
.padding(.bottom, 8)
}
-
+
Text(self.articleText)
.multilineTextAlignment(.leading)
.textSelection(.enabled)
@@ -216,7 +217,8 @@ struct NewsView: View {
.padding()
}
.scrollBounceBehavior(.basedOnSize)
- .background(colorScheme == .dark ? Color(white: 0.1) : Color(uiColor: UIColor.systemBackground))
+ .background(
+ colorScheme == .dark ? Color(white: 0.1) : Color(uiColor: UIColor.systemBackground))
}
.task {
if !fetched {
diff --git a/Hotline/iOS/ServerView.swift b/Hotline/iOS/ServerView.swift
index 1ce0a9c..11f57aa 100644
--- a/Hotline/iOS/ServerView.swift
+++ b/Hotline/iOS/ServerView.swift
@@ -3,11 +3,11 @@ import SwiftUI
struct ServerView: View {
@Environment(Hotline.self) private var model: Hotline
@Environment(\.colorScheme) var colorScheme
-
+
enum Tab {
case chat, users, news, messageBoard, files
}
-
+
var body: some View {
TabView {
ChatView()
@@ -15,13 +15,13 @@ struct ServerView: View {
Image(systemName: "bubble")
}
.tag(Tab.chat)
-
+
UsersView()
.tabItem {
Image(systemName: "person.2")
}
.tag(Tab.users)
-
+
if let v = model.serverVersion, v >= 150 {
NewsView()
.tabItem {
@@ -29,13 +29,13 @@ struct ServerView: View {
}
.tag(Tab.news)
}
-
+
MessageBoardView()
.tabItem {
Image(systemName: "note.text")
}
.tag(Tab.messageBoard)
-
+
FilesView()
.tabItem {
Image(systemName: "folder").tint(.black)
diff --git a/Hotline/iOS/TrackerView.swift b/Hotline/iOS/TrackerView.swift
index 2a3583b..43b1916 100644
--- a/Hotline/iOS/TrackerView.swift
+++ b/Hotline/iOS/TrackerView.swift
@@ -4,13 +4,13 @@ struct TrackerConnectView: View {
@Environment(Hotline.self) private var model: Hotline
@Environment(\.dismiss) var dismiss
@Environment(\.colorScheme) var colorScheme
-
+
@State private var server: Server?
@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 {
case .disconnected:
@@ -25,118 +25,126 @@ struct TrackerConnectView: View {
return 1.0
}
}
-
+
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: 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)
+ // }
}
- .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)
@@ -151,7 +159,7 @@ struct TrackerConnectView: View {
struct TrackerView: View {
@Environment(Hotline.self) private var model: Hotline
@Environment(\.colorScheme) var colorScheme
-
+
@State private var servers: [Server] = []
@State private var selectedServer: Server?
@State private var scrollOffset: CGFloat = CGFloat.zero
@@ -161,15 +169,15 @@ struct TrackerView: View {
@State private var connectVisible = false
@State private var connectDismissed = true
@State private var serverVisible = false
-
+
func shouldDisplayDescription(server: Server) -> Bool {
guard let desc = server.description else {
return false
}
-
+
return desc.count > 0 && desc != server.name
}
-
+
func connectionStatusToProgress(status: HotlineClientStatus) -> Double {
switch status {
case .disconnected:
@@ -184,21 +192,21 @@ struct TrackerView: View {
return 1.0
}
}
-
+
func inverseLerp(lower: Double, upper: Double, v: Double) -> Double {
return (v - lower) / (upper - lower)
}
-
+
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")
}
-
+
var body: some View {
ZStack(alignment: .center) {
VStack(alignment: .center) {
@@ -249,7 +257,7 @@ struct TrackerView: View {
.frame(height: 40.0)
}
.padding()
-
+
Spacer()
}
.opacity(inverseLerp(lower: -50, upper: 0, v: scrollOffset))
@@ -269,7 +277,8 @@ struct TrackerView: View {
Text(server.description!).opacity(0.5).font(.system(size: 16))
}
Spacer()
- Text("\(server.address):" + String(format: "%i", server.port)).opacity(0.3).font(.system(size: 13))
+ Text("\(server.address):" + String(format: "%i", server.port)).opacity(0.3).font(
+ .system(size: 13))
}
Spacer()
if server.users > 0 {
@@ -278,19 +287,17 @@ struct TrackerView: View {
}
if server == selectedServer {
Spacer(minLength: 16)
-
+
if model.status != .disconnected && model.server != nil && model.server! == server {
ProgressView(value: connectionStatusToProgress(status: model.status))
.frame(minHeight: 10)
.accentColor(colorScheme == .dark ? .white : .black)
- }
- else {
+ } else {
Button("Connect") {
self.model.login(server: server, username: "bolt", iconID: 128) { success in
if !success {
print("FAILED LOGIN??")
- }
- else {
+ } else {
self.model.sendUserInfo(username: "bolt", iconID: 128)
self.model.getUserList()
self.model.downloadBanner()
@@ -302,13 +309,17 @@ struct TrackerView: View {
.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)
+ 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)
+ RoundedRectangle(cornerRadius: 10.0).stroke(.black, lineWidth: 3).opacity(
+ colorScheme == .dark ? 0.0 : 0.2)
)
.cornerRadius(10.0)
}
@@ -346,22 +357,24 @@ struct TrackerView: View {
}
initialLoadComplete = true
}
-
+
model.disconnectTracker()
await updateServers()
-
+
DispatchQueue.main.async {
withAnimation(.easeOut(duration: 1.0).delay(0.75)) {
topBarOpacity = 1.0
}
}
}
-
-
+
}
- .fullScreenCover(isPresented: Binding(get: { return (connectDismissed && serverVisible) }, set: { _ in }), onDismiss: {
- model.disconnect()
- }) {
+ .fullScreenCover(
+ isPresented: Binding(get: { return (connectDismissed && serverVisible) }, set: { _ in }),
+ onDismiss: {
+ model.disconnect()
+ }
+ ) {
ServerView()
}
.onChange(of: model.status) {
@@ -377,26 +390,27 @@ struct TrackerView: View {
guard url.scheme == "hotline" else {
return
}
-
+
if let address = url.host() {
let login = url.user(percentEncoded: false) ?? ""
let password = url.password(percentEncoded: false) ?? ""
let port = url.port ?? HotlinePorts.DefaultServerPort
-
+
self.model.disconnect()
-
- let tempServer = Server(name: nil, description: nil, address: address, port: port, users: 0, login: login, password: password)
+
+ let tempServer = Server(
+ name: nil, description: nil, address: address, port: port, users: 0, login: login,
+ password: password)
self.model.login(server: tempServer, username: "bolt", iconID: 128) { success in
if !success {
print("FAILED LOGIN??")
- }
- else {
+ } else {
self.model.sendUserInfo(username: "bolt", iconID: 128)
self.model.getUserList()
self.model.downloadBanner()
}
}
-
+
// TODO: Find a better way to show login status when trying to connect outside of the Tracker server list. Perhaps this opens the connect sheet prefilled.
}
})
diff --git a/Hotline/iOS/UsersView.swift b/Hotline/iOS/UsersView.swift
index 5d7a198..e1297c8 100644
--- a/Hotline/iOS/UsersView.swift
+++ b/Hotline/iOS/UsersView.swift
@@ -2,7 +2,7 @@ import SwiftUI
struct UsersView: View {
@Environment(Hotline.self) private var model: Hotline
-
+
var body: some View {
NavigationStack {
List(model.users) { u in
diff --git a/Hotline/macOS/AboutView.swift b/Hotline/macOS/AboutView.swift
index baafcf8..a2771b9 100644
--- a/Hotline/macOS/AboutView.swift
+++ b/Hotline/macOS/AboutView.swift
@@ -16,31 +16,31 @@ struct AboutContributor: Identifiable {
struct AboutView: View {
@Environment(\.openURL) private var openURL
-
+
@State private var versionCheck: VersionCheckState = .needToCheck
@State private var downloadURL: String = "https://github.com/mierau/hotline/releases/latest"
@State private var contributors: [AboutContributor] = []
-
+
var body: some View {
HStack(alignment: .center, spacing: 0) {
VStack(alignment: .center, spacing: 0) {
Spacer()
-
+
Image("About Hotline")
.padding(.top, 44)
-
+
Text("Hotline")
.font(.system(size: 28))
.fontWeight(.bold)
.padding(.top, 12)
.kerning(-1.0)
.foregroundColor(.white)
-
+
let appDetails = getAppVersionAndBuild()
Text("Version \(String(format: "%.1f", appDetails.version))b\(appDetails.build)")
.foregroundColor(.white)
.opacity(0.4)
-
+
HStack(alignment: .center) {
switch versionCheck {
case .needToCheck:
@@ -81,12 +81,12 @@ struct AboutView: View {
Spacer()
}
.frame(width: 250)
-
+
Spacer()
-
+
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 16) {
-
+
VStack(alignment: .leading, spacing: 4) {
Link(destination: URL(string: "https://github.com/mierau/hotline")!) {
HStack(alignment: .center, spacing: 4) {
@@ -107,7 +107,7 @@ struct AboutView: View {
}
}
.padding(.top, 24)
-
+
Text("Hotline is an open source project made possible by its contributors.")
.font(.system(size: 11))
.foregroundStyle(.black)
@@ -115,7 +115,7 @@ struct AboutView: View {
.padding(.trailing, 32)
}
.padding(.bottom, 8)
-
+
ForEach(contributors) { contributor in
Link(destination: contributor.webURL) {
HStack {
@@ -139,27 +139,27 @@ struct AboutView: View {
}
.frame(width: 32, height: 32)
.clipShape(Circle())
-
-// AsyncImage(url: pictureURL) { img in
-// img
-// .interpolation(.high)
-// .resizable()
-// .scaledToFit()
-// .background(.white)
-// } placeholder: {
-// Color.white.opacity(0.2)
-// .frame(width: 32, height: 32)
-// }
-// .frame(width: 32, height: 32)
-// .clipShape(Circle())
+
+ // AsyncImage(url: pictureURL) { img in
+ // img
+ // .interpolation(.high)
+ // .resizable()
+ // .scaledToFit()
+ // .background(.white)
+ // } placeholder: {
+ // Color.white.opacity(0.2)
+ // .frame(width: 32, height: 32)
+ // }
+ // .frame(width: 32, height: 32)
+ // .clipShape(Circle())
}
-
+
VStack(alignment: .leading, spacing: 2) {
Text(contributor.username)
.fontWeight(.semibold)
.foregroundStyle(.white)
.lineLimit(1)
-
+
Text(contributor.webURL.absoluteString)
.lineLimit(1)
.truncationMode(.middle)
@@ -189,68 +189,72 @@ struct AboutView: View {
await loadContributors()
}
}
-
+
func loadContributors() async {
var newContributors: [AboutContributor] = []
-
+
if let url = URL(string: "https://api.github.com/repos/mierau/hotline/contributors"),
- let (data, _) = try? await URLSession.shared.data(from: url) {
- if let jsonContributors = try? JSONSerialization.jsonObject(with: data, options: []) as? [[String: Any]] {
+ let (data, _) = try? await URLSession.shared.data(from: url)
+ {
+ if let jsonContributors = try? JSONSerialization.jsonObject(with: data, options: [])
+ as? [[String: Any]]
+ {
for contributor in jsonContributors {
if let username = contributor["login"] as? String,
- let webURLString = contributor["html_url"] as? String,
- let webURL = URL(string: webURLString) {
+ let webURLString = contributor["html_url"] as? String,
+ let webURL = URL(string: webURLString)
+ {
var pictureURL: URL? = nil
if let pictureURLString = contributor["avatar_url"] as? String {
pictureURL = URL(string: pictureURLString)
}
- newContributors.append(AboutContributor(username: username, webURL: webURL, pictureURL: pictureURL))
+ newContributors.append(
+ AboutContributor(username: username, webURL: webURL, pictureURL: pictureURL))
}
}
}
}
-
+
withAnimation {
contributors = newContributors
}
}
-
+
func checkForUpdate() async {
let appDetails = getAppVersionAndBuild()
-
+
self.versionCheck = .checking
-
+
do {
let url = URL(string: "https://api.github.com/repos/mierau/hotline/releases/latest")!
let (data, _) = try await URLSession.shared.data(from: url)
let versionExpression = /^([0-9\.]+)beta([0-9]+)/
-
+
if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
- let tagName = json["tag_name"] as? String,
- let assets = json["assets"] as? [[String: Any]],
- let firstAsset = assets.first,
- let assetDownloadURL = firstAsset["browser_download_url"] as? String,
- let versionMatches = try? versionExpression.wholeMatch(in: tagName) {
+ let tagName = json["tag_name"] as? String,
+ let assets = json["assets"] as? [[String: Any]],
+ let firstAsset = assets.first,
+ let assetDownloadURL = firstAsset["browser_download_url"] as? String,
+ let versionMatches = try? versionExpression.wholeMatch(in: tagName)
+ {
if let versionNumber = Double(versionMatches.1),
- let buildNumber = Int(versionMatches.2),
- versionNumber > appDetails.version || buildNumber > appDetails.build {
- let versionString = "\(versionMatches.1)b\(versionMatches.2)"
- self.versionCheck = .updateAvailable(version: versionString)
- downloadURL = assetDownloadURL
- }
- else {
+ let buildNumber = Int(versionMatches.2),
+ versionNumber > appDetails.version || buildNumber > appDetails.build
+ {
+ let versionString = "\(versionMatches.1)b\(versionMatches.2)"
+ self.versionCheck = .updateAvailable(version: versionString)
+ downloadURL = assetDownloadURL
+ } else {
self.versionCheck = .upToDate
}
- }
- else {
+ } else {
self.versionCheck = .needToCheck
}
- }
- catch {
+ } catch {
self.versionCheck = .needToCheck
}
}
-
+
func getAppVersionAndBuild() -> (version: Double, build: Int) {
let infoDictionary = Bundle.main.infoDictionary!
let version = Double(infoDictionary["CFBundleShortVersionString"]! as! String)!
diff --git a/Hotline/macOS/AccountManagerView.swift b/Hotline/macOS/AccountManagerView.swift
index 57682cc..84ae6dc 100644
--- a/Hotline/macOS/AccountManagerView.swift
+++ b/Hotline/macOS/AccountManagerView.swift
@@ -2,20 +2,20 @@ import SwiftUI
struct AccountManagerView: View {
@Environment(Hotline.self) private var model: Hotline
-
+
@State private var accounts: [HotlineAccount] = []
@State private var selection: HotlineAccount?
@State private var loading: Bool = true
-
+
@State private var pendingName: String = ""
@State private var pendingLogin: String = ""
@State private var pendingPassword: String = ""
@State private var pendingAccess = HotlineUserAccessOptions.defaultAccess
-
+
@State private var toDelete: HotlineAccount?
-
+
let placeholderPassword = "xxxxxxxxxxxxxxxxxx"
-
+
var body: some View {
HStack(spacing: 0) {
ZStack {
@@ -26,8 +26,7 @@ struct AccountManagerView: View {
}
if selection != nil {
accountDetails
- }
- else {
+ } else {
ZStack(alignment: .center) {
Text("No Account Selected")
.font(.title)
@@ -50,8 +49,9 @@ struct AccountManagerView: View {
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
- let newAccount = HotlineAccount("unnamed", "unnamed", HotlineUserAccessOptions.defaultAccess)
-
+ let newAccount = HotlineAccount(
+ "unnamed", "unnamed", HotlineUserAccessOptions.defaultAccess)
+
pendingPassword = HotlineAccount.randomPassword()
accounts.append(newAccount)
selection = newAccount
@@ -61,7 +61,7 @@ struct AccountManagerView: View {
.help("Create a new account")
.disabled(model.access?.contains(.canCreateUsers) != true)
}
-
+
ToolbarItem(placement: .destructiveAction) {
Button {
toDelete = selection
@@ -73,7 +73,7 @@ struct AccountManagerView: View {
}
}
}
-
+
var accountDetails: some View {
VStack(alignment: .center, spacing: 0) {
ScrollView(.vertical) {
@@ -92,7 +92,7 @@ struct AccountManagerView: View {
}
.textFieldStyle(.roundedBorder)
.controlSize(.large)
-
+
Section("File System Maintenance") {
Toggle("Can Download Files", isOn: $pendingAccess.bind(.canDownloadFiles))
.disabled(model.access?.contains(.canDownloadFiles) == false)
@@ -127,7 +127,7 @@ struct AccountManagerView: View {
Toggle("Can Make Aliases", isOn: $pendingAccess.bind(.canMakeAliases))
.disabled(model.access?.contains(.canMakeAliases) == false)
}
-
+
Section("User Maintenance") {
Toggle("Can Create Accounts", isOn: $pendingAccess.bind(.canCreateUsers))
.disabled(model.access?.contains(.canCreateUsers) == false)
@@ -139,20 +139,20 @@ struct AccountManagerView: View {
.disabled(model.access?.contains(.canModifyUsers) == false)
Toggle("Can Get User Info", isOn: $pendingAccess.bind(.canGetClientInfo))
.disabled(model.access?.contains(.canGetClientInfo) == false)
-
+
Toggle("Can Disconnect Users", isOn: $pendingAccess.bind(.canDisconnectUsers))
.disabled(model.access?.contains(.canDisconnectUsers) == false)
Toggle("Cannot be Disconnected", isOn: $pendingAccess.bind(.cantBeDisconnected))
.disabled(model.access?.contains(.cantBeDisconnected) == false)
}
-
+
Section("Messaging") {
Toggle("Can Send Messages", isOn: $pendingAccess.bind(.canSendMessages))
.disabled(model.access?.contains(.canSendMessages) == false)
Toggle("Can Broadcast", isOn: $pendingAccess.bind(.canBroadcast))
.disabled(model.access?.contains(.canBroadcast) == false)
}
-
+
Section("News") {
Toggle("Can Read Articles", isOn: $pendingAccess.bind(.canReadMessageBoard))
.disabled(model.access?.contains(.canReadMessageBoard) == false)
@@ -169,7 +169,7 @@ struct AccountManagerView: View {
Toggle("Can Delete News Bundles", isOn: $pendingAccess.bind(.canDeleteNewsFolders))
.disabled(model.access?.contains(.canDeleteNewsFolders) == false)
}
-
+
Section("Chat") {
Toggle("Can Initiate Private Chat", isOn: $pendingAccess.bind(.canCreateChat))
.disabled(model.access?.contains(.canCreateChat) == false)
@@ -178,7 +178,7 @@ struct AccountManagerView: View {
Toggle("Can Send Chat", isOn: $pendingAccess.bind(.canSendChat))
.disabled(model.access?.contains(.canSendChat) == false)
}
-
+
Section("Miscellaneous") {
Toggle("Can Use Any Name", isOn: $pendingAccess.bind(.canUseAnyName))
.disabled(model.access?.contains(.canUseAnyName) == false)
@@ -193,7 +193,7 @@ struct AccountManagerView: View {
pendingName = selection.name
pendingLogin = selection.login
pendingAccess = selection.access
-
+
if selection.persisted {
if selection.password == nil {
pendingPassword = ""
@@ -203,12 +203,12 @@ struct AccountManagerView: View {
}
}
}
- .onAppear() {
+ .onAppear {
if let selection {
pendingName = selection.name
pendingLogin = selection.login
pendingAccess = selection.access
-
+
if selection.persisted {
if selection.password == nil {
pendingPassword = ""
@@ -216,22 +216,22 @@ struct AccountManagerView: View {
pendingPassword = placeholderPassword
}
} else {
- pendingPassword = HotlineAccount.randomPassword()
+ pendingPassword = HotlineAccount.randomPassword()
}
}
}
}
.frame(maxWidth: .infinity)
-
+
Divider()
-
- HStack() {
+
+ HStack {
Button("Revert") {
if let selection {
pendingAccess = selection.access
pendingName = selection.name
pendingLogin = selection.login
-
+
if selection.password != nil {
pendingPassword = selection.password!
}
@@ -240,46 +240,52 @@ struct AccountManagerView: View {
.controlSize(.large)
.frame(minWidth: 75)
.disabled(!self.isSaveable())
-// .padding()
-
+ // .padding()
+
Spacer()
-
- Button("Save"){
+
+ Button("Save") {
guard let selection else {
return
}
-
+
// Update existing account
if selection.persisted == true {
if pendingPassword == placeholderPassword {
Task { @MainActor in
- model.client.sendSetUser(name: pendingName, login: pendingLogin, newLogin: nil, password: nil, access: pendingAccess.rawValue)
+ model.client.sendSetUser(
+ name: pendingName, login: pendingLogin, newLogin: nil, password: nil,
+ access: pendingAccess.rawValue)
}
} else {
Task { @MainActor in
- model.client.sendSetUser(name: pendingName, login: pendingLogin, newLogin: nil, password: pendingPassword, access: pendingAccess.rawValue)
+ model.client.sendSetUser(
+ name: pendingName, login: pendingLogin, newLogin: nil, password: pendingPassword,
+ access: pendingAccess.rawValue)
}
}
} else {
// Create new existing account
Task { @MainActor in
- model.client.sendCreateUser(name: pendingName, login: pendingLogin, password: pendingPassword, access: pendingAccess.rawValue)
+ model.client.sendCreateUser(
+ name: pendingName, login: pendingLogin, password: pendingPassword,
+ access: pendingAccess.rawValue)
}
self.selection?.password = pendingPassword
pendingPassword = placeholderPassword
}
-
+
var account = HotlineAccount(pendingName, pendingLogin, pendingAccess)
account.persisted = true
account.password = placeholderPassword
-
+
accounts = accounts.filter { $0.persisted == true && $0.login != selection.login }
-
+
// Add new account to list
accounts.append(account)
-
+
// Re-sort accounts
accounts.sort { $0.login < $1.login }
self.selection = account
@@ -291,9 +297,9 @@ struct AccountManagerView: View {
}
.padding()
}
-
+
}
-
+
var accountList: some View {
List(accounts, id: \.self, selection: $selection) { account in
HStack(spacing: 5) {
@@ -304,8 +310,7 @@ struct AccountManagerView: View {
// .padding(.leading, 4)
Text(account.login)
.foregroundStyle(Color.hotlineRed)
- }
- else if account.access.rawValue == 0 {
+ } else if account.access.rawValue == 0 {
Image("User")
.frame(width: 16, height: 16)
// .padding(.leading, 4)
@@ -333,65 +338,67 @@ struct AccountManagerView: View {
.frame(width: 250)
.sheet(item: $toDelete) { item in
Form {
- HStack{
+ HStack {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 30))
Text("Delete account \"\(item.name)\" and all associated files?")
.lineSpacing(4)
}
}
- .frame(minWidth: 300, idealWidth: 450, maxWidth: .infinity, minHeight: 100, idealHeight: 100, maxHeight: .infinity)
+ .frame(
+ minWidth: 300, idealWidth: 450, maxWidth: .infinity, minHeight: 100, idealHeight: 100,
+ maxHeight: .infinity
+ )
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
toDelete = nil
}
}
-
+
ToolbarItem(placement: .primaryAction) {
Button("Delete") {
guard let userToDelete = toDelete else {
return
}
-
+
self.toDelete = nil
self.selection = nil
-
+
if userToDelete.persisted {
Task { @MainActor in
model.client.sendDeleteUser(login: userToDelete.login)
}
}
-
+
accounts = accounts.filter { $0.login != userToDelete.login }
-
+
}
}
}
}
}
-
-
+
private func isSaveable() -> Bool {
guard let selection else {
return false
}
-
+
// Disable save if login field is cleared
if pendingLogin == "" {
return false
}
-
+
// If the account initial has a password and it was updated
if selection.password != nil && pendingPassword != placeholderPassword {
return true
}
-
+
// If the account initial has no password, but was updated to have one
if selection.password == nil && pendingPassword != "" {
return true
}
-
+
// If the access bits or user name have been changed
return pendingAccess.rawValue != selection.access.rawValue || selection.name != pendingName
}
diff --git a/Hotline/macOS/ChatView.swift b/Hotline/macOS/ChatView.swift
index cd84369..33a43b1 100644
--- a/Hotline/macOS/ChatView.swift
+++ b/Hotline/macOS/ChatView.swift
@@ -8,34 +8,34 @@ struct ChatView: View {
@Environment(Hotline.self) private var model: Hotline
@Environment(\.colorScheme) var colorScheme
@Environment(\.dismiss) var dismiss
-
+
@State var input: String = ""
@State private var scrollPos: Int?
@State private var contentHeight: CGFloat = 0
-
+
@FocusState private var focusedField: FocusedField?
-
+
@Namespace var bottomID
-
+
@State private var showingExporter: Bool = false
-
+
@State private var chatDocument: TextFile = TextFile()
-
+
var body: some View {
NavigationStack {
ScrollViewReader { reader in
VStack(alignment: .leading, spacing: 0) {
-
+
// MARK: Scroll View
GeometryReader { gm in
ScrollView(.vertical) {
LazyVStack(alignment: .leading) {
-
+
ForEach(model.chat) { msg in
-
+
// MARK: Agreement
if msg.type == .agreement {
-#if os(iOS)
+ #if os(iOS)
if let bannerImage = self.model.bannerImage {
Image(uiImage: bannerImage)
.resizable()
@@ -43,9 +43,9 @@ struct ChatView: View {
.frame(maxWidth: 468.0)
.clipShape(RoundedRectangle(cornerRadius: 3))
}
-#endif
+ #endif
ServerAgreementView(text: msg.text)
- .padding(.bottom, 16)
+ .padding(.bottom, 16)
}
// MARK: Server Message
else if msg.type == .server {
@@ -64,50 +64,51 @@ struct ChatView: View {
Spacer()
}
.padding(EdgeInsets(top: 2, leading: 0, bottom: 2, trailing: 0))
- }
- else {
+ } else {
HStack(alignment: .firstTextBaseline) {
if let username = msg.username {
-// if msg.text.isImageURL() {
-// HStack(alignment: .bottom) {
-// Text("**\(username):** ")
-//
-// let imageURL = URL(string: msg.text)!
-// AsyncImage(url: imageURL) { phase in
-// switch phase {
-// case .failure:
-// Text(LocalizedStringKey(msg.text.convertLinksToMarkdown()))
-// .lineSpacing(4)
-// .multilineTextAlignment(.leading)
-// .textSelection(.enabled)
-// .tint(Color("Link Color"))
-// case .success(let img):
-// Link(destination: imageURL) {
-// img
-// .resizable()
-// .scaledToFit()
-// .frame(maxWidth: 250, maxHeight: 150, alignment: .leading)
-// .onAppear {
-// reader.scrollTo(bottomID, anchor: .bottom)
-// }
-// }
-// default:
-// ProgressView().controlSize(.small)
-// }
-// }
-//
-// Spacer()
-// }
-// }
-// else {
- Text(LocalizedStringKey("**\(username):** \(msg.text)".convertingLinksToMarkdown()))
- .lineSpacing(4)
- .multilineTextAlignment(.leading)
- .textSelection(.enabled)
- .tint(Color("Link Color"))
-// }
- }
- else {
+ // if msg.text.isImageURL() {
+ // HStack(alignment: .bottom) {
+ // Text("**\(username):** ")
+ //
+ // let imageURL = URL(string: msg.text)!
+ // AsyncImage(url: imageURL) { phase in
+ // switch phase {
+ // case .failure:
+ // Text(LocalizedStringKey(msg.text.convertLinksToMarkdown()))
+ // .lineSpacing(4)
+ // .multilineTextAlignment(.leading)
+ // .textSelection(.enabled)
+ // .tint(Color("Link Color"))
+ // case .success(let img):
+ // Link(destination: imageURL) {
+ // img
+ // .resizable()
+ // .scaledToFit()
+ // .frame(maxWidth: 250, maxHeight: 150, alignment: .leading)
+ // .onAppear {
+ // reader.scrollTo(bottomID, anchor: .bottom)
+ // }
+ // }
+ // default:
+ // ProgressView().controlSize(.small)
+ // }
+ // }
+ //
+ // Spacer()
+ // }
+ // }
+ // else {
+ Text(
+ LocalizedStringKey(
+ "**\(username):** \(msg.text)".convertingLinksToMarkdown())
+ )
+ .lineSpacing(4)
+ .multilineTextAlignment(.leading)
+ .textSelection(.enabled)
+ .tint(Color("Link Color"))
+ // }
+ } else {
Text(msg.text)
.lineSpacing(4)
.multilineTextAlignment(.leading)
@@ -137,10 +138,10 @@ struct ChatView: View {
reader.scrollTo(bottomID, anchor: .bottom)
}
}
-
+
// MARK: Input Divider
Divider()
-
+
// MARK: Input Bar
HStack(alignment: .lastTextBaseline, spacing: 0) {
TextField("", text: $input, axis: .vertical)
@@ -178,59 +179,59 @@ struct ChatView: View {
}
}
.background(Color(nsColor: .textBackgroundColor))
-// .toolbar {
-// ToolbarItem(placement: .primaryAction) {
-// Button {
-// if prepareChatDocument() {
-// showingExporter = true
-// }
-// } label: {
-// Image(systemName: "square.and.arrow.up")
-// }.help("Save Chat...")
-// }
-// }
- .fileExporter(isPresented: $showingExporter, document: self.chatDocument, contentType: .utf8PlainText, defaultFilename: "\(self.model.serverTitle) Chat.txt") { result in
+ // .toolbar {
+ // ToolbarItem(placement: .primaryAction) {
+ // Button {
+ // if prepareChatDocument() {
+ // showingExporter = true
+ // }
+ // } label: {
+ // Image(systemName: "square.and.arrow.up")
+ // }.help("Save Chat...")
+ // }
+ // }
+ .fileExporter(
+ isPresented: $showingExporter, document: self.chatDocument, contentType: .utf8PlainText,
+ defaultFilename: "\(self.model.serverTitle) Chat.txt"
+ ) { result in
switch result {
case .success(let url):
print("Saved to \(url)")
-
+
case .failure(let error):
print(error.localizedDescription)
}
self.chatDocument.text = ""
}
}
-
+
private func prepareChatDocument() -> Bool {
var text: String = String()
-
+
self.chatDocument.text = ""
for msg in model.chat {
if msg.type == .agreement {
text.append(msg.text)
text.append("\n\n")
- }
- else if msg.type == .message {
+ } else if msg.type == .message {
if let username = msg.username {
text.append("\(username): \(msg.text)")
- }
- else {
+ } else {
text.append(msg.text)
}
text.append("\n")
- }
- else if msg.type == .status {
+ } else if msg.type == .status {
text.append(msg.text)
text.append("\n")
}
}
-
+
if text.isEmpty {
return false
}
-
+
self.chatDocument.text = text
-
+
return true
}
}
diff --git a/Hotline/macOS/FileDetailsView.swift b/Hotline/macOS/FileDetailsView.swift
index 34e083b..16b7a60 100644
--- a/Hotline/macOS/FileDetailsView.swift
+++ b/Hotline/macOS/FileDetailsView.swift
@@ -6,45 +6,45 @@ struct FileDetailsView: View {
@Environment(\.presentationMode) var presentationMode
var fd: FileDetails
-
+
@State private var comment: String = ""
@State private var filename: String = ""
-
+
var body: some View {
- VStack (alignment: .leading){
+ VStack(alignment: .leading) {
Form {
- HStack(alignment: .center){
+ HStack(alignment: .center) {
FileIconView(filename: fd.name, fileType: nil)
.frame(width: 32, height: 32)
TextField("", text: $filename)
.disabled(!self.canRename())
}
- HStack(alignment: .center){
+ HStack(alignment: .center) {
Text("Type:").bold().padding(.leading, 43)
Text(fd.type)
}
- HStack(alignment: .center){
+ HStack(alignment: .center) {
Text("Creator:").bold().padding(.leading, 26)
Text(fd.creator)
}
- HStack(alignment: .center){
+ HStack(alignment: .center) {
Text("Size:").bold().bold().padding(.leading, 48)
Text(self.formattedSize(byteCount: fd.size))
}
- HStack(alignment: .center){
+ HStack(alignment: .center) {
Text("Created:").bold().padding(.leading, 24)
Text("\(FileDetailsView.dateFormatter.string(from: fd.created))")
}
- HStack(alignment: .center){
+ HStack(alignment: .center) {
Text("Modified:").bold().padding(.leading, 19)
Text("\(FileDetailsView.dateFormatter.string(from: fd.modified))")
}
- HStack(alignment: .center){
+ HStack(alignment: .center) {
Text("Comments:").bold().padding(.top, 8)
.padding(.leading, 5)
}
-
- VStack(alignment: .trailing){
+
+ VStack(alignment: .trailing) {
TextEditor(text: $comment)
.padding(.leading, 2)
.padding(.top, 1)
@@ -61,22 +61,23 @@ struct FileDetailsView: View {
presentationMode.wrappedValue.dismiss()
}
}
-
+
ToolbarItem(placement: .primaryAction) {
- Button{
+ Button {
var editedFilename: String?
if filename != fd.name {
editedFilename = filename
}
-
+
var editedComment: String?
if comment != fd.comment {
editedComment = comment
}
-
- model.client.sendSetFileInfo(fileName: fd.name, path: fd.path, fileNewName: editedFilename, comment: editedComment)
+
+ model.client.sendSetFileInfo(
+ fileName: fd.name, path: fd.path, fileNewName: editedFilename, comment: editedComment)
presentationMode.wrappedValue.dismiss()
-
+
// TODO: Update the file list if the filename was changed
} label: {
Text("Save")
@@ -92,48 +93,49 @@ struct FileDetailsView: View {
}
.frame(minWidth: 400, minHeight: 400)
}
-
-
+
static var dateFormatter: DateFormatter = {
var dateFormatter = DateFormatter()
dateFormatter.dateStyle = .long
dateFormatter.timeStyle = .short
-
+
// Original format: Fri, Aug 20, 2021, 5:14:07 PM
return dateFormatter
}()
-
+
static var byteCountSizeFormatter: NumberFormatter = {
let numberFormatter = NumberFormatter()
numberFormatter.numberStyle = .decimal
return numberFormatter
}()
-
+
static let byteFormatter = ByteCountFormatter()
-
+
private func formattedFileSize(_ fileSize: UInt) -> String {
FileView.byteFormatter.allowedUnits = [.useAll]
FileView.byteFormatter.countStyle = .file
return FileView.byteFormatter.string(fromByteCount: Int64(fileSize))
}
-
+
// Format byte count Int into string like: 23.4M (24,601,664 bytes)
private func formattedSize(byteCount: Int) -> String {
- let formattedByteCount = FileDetailsView.byteCountSizeFormatter.string(from: NSNumber(value:byteCount)) ?? "0"
- return "\(FileView.byteFormatter.string(fromByteCount: Int64(byteCount))) (\(formattedByteCount) bytes)"
+ let formattedByteCount =
+ FileDetailsView.byteCountSizeFormatter.string(from: NSNumber(value: byteCount)) ?? "0"
+ return
+ "\(FileView.byteFormatter.string(fromByteCount: Int64(byteCount))) (\(formattedByteCount) bytes)"
}
-
+
private func isEdited() -> Bool {
return self.filename != fd.name || self.comment != fd.comment
}
-
+
private func canRename() -> Bool {
if self.fd.type == "fldr" {
return model.access?.contains(.canRenameFolders) == true
}
return model.access?.contains(.canRenameFiles) == true
}
-
+
private func canSetComment() -> Bool {
if self.fd.type == "fldr" {
return model.access?.contains(.canSetFolderComment) == true
diff --git a/Hotline/macOS/FilePreviewImageView.swift b/Hotline/macOS/FilePreviewImageView.swift
index 9beeb80..3aa37d3 100644
--- a/Hotline/macOS/FilePreviewImageView.swift
+++ b/Hotline/macOS/FilePreviewImageView.swift
@@ -5,16 +5,16 @@ struct FilePreviewImageView: View {
enum FilePreviewFocus: Hashable {
case window
}
-
+
@Environment(\.controlActiveState) private var controlActiveState
@Environment(\.colorScheme) private var colorScheme
@Environment(\.dismiss) var dismiss
-
+
@Binding var info: PreviewFileInfo?
-
+
@State var preview: FilePreview? = nil
@FocusState private var focusField: FilePreviewFocus?
-
+
var body: some View {
Group {
if preview?.state != .loaded {
@@ -28,16 +28,14 @@ struct FilePreviewImageView: View {
}
.frame(minWidth: 350, maxWidth: 350, minHeight: 150, maxHeight: 150)
.padding()
- }
- else {
+ } else {
if let image = preview?.image {
FileImageView(image: image)
.frame(minWidth: 200, maxWidth: .infinity, minHeight: 200, maxHeight: .infinity)
- }
- else {
+ } else {
VStack(alignment: .center, spacing: 0) {
Spacer()
-
+
Image(systemName: "eye.trianglebadge.exclamationmark")
.resizable()
.scaledToFit()
@@ -53,7 +51,7 @@ struct FilePreviewImageView: View {
.font(.system(size: 14.0))
.frame(maxWidth: 300)
.multilineTextAlignment(.center)
-
+
Spacer()
}
.frame(minWidth: 350, maxWidth: 350, minHeight: 150, maxHeight: 150)
@@ -73,7 +71,7 @@ struct FilePreviewImageView: View {
.frame(width: 16, height: 16)
.opacity(controlActiveState == .inactive ? 0.5 : 1.0)
}
-
+
if let img = preview?.image {
if let info = info {
ToolbarItem(placement: .primaryAction) {
@@ -84,7 +82,7 @@ struct FilePreviewImageView: View {
}
.help("Download Image")
}
-
+
ToolbarItem(placement: .primaryAction) {
ShareLink(item: img, preview: SharePreview(info.name, image: img)) {
Label("Share Image...", systemImage: "square.and.arrow.up")
@@ -107,7 +105,7 @@ struct FilePreviewImageView: View {
}
return
}
-
+
focusField = .window
}
.onDisappear {
diff --git a/Hotline/macOS/FilePreviewTextView.swift b/Hotline/macOS/FilePreviewTextView.swift
index c286381..6dc1529 100644
--- a/Hotline/macOS/FilePreviewTextView.swift
+++ b/Hotline/macOS/FilePreviewTextView.swift
@@ -5,15 +5,15 @@ struct FilePreviewTextView: View {
enum FilePreviewFocus: Hashable {
case window
}
-
+
@Environment(\.controlActiveState) private var controlActiveState
@Environment(\.colorScheme) private var colorScheme
@Environment(\.dismiss) var dismiss
-
+
@Binding var info: PreviewFileInfo?
@State var preview: FilePreview? = nil
@FocusState private var focusField: FilePreviewFocus?
-
+
var body: some View {
Group {
if preview?.state != .loaded {
@@ -31,8 +31,7 @@ struct FilePreviewTextView: View {
.background(Color(nsColor: .textBackgroundColor))
.frame(minWidth: 350, maxWidth: .infinity, minHeight: 150, maxHeight: .infinity)
.padding()
- }
- else {
+ } else {
if let text = preview?.text {
TextEditor(text: .constant(text))
.textEditorStyle(.plain)
@@ -44,11 +43,10 @@ struct FilePreviewTextView: View {
.contentMargins(.trailing, -16.0, for: .scrollIndicators)
.scrollClipDisabled()
.frame(maxWidth: .infinity, maxHeight: .infinity)
- }
- else {
+ } else {
VStack(alignment: .center, spacing: 0) {
Spacer()
-
+
Image(systemName: "eye.trianglebadge.exclamationmark")
.resizable()
.scaledToFit()
@@ -64,7 +62,7 @@ struct FilePreviewTextView: View {
.font(.system(size: 14.0))
.frame(maxWidth: 300)
.multilineTextAlignment(.center)
-
+
Spacer()
Spacer()
}
@@ -84,8 +82,8 @@ struct FilePreviewTextView: View {
.frame(width: 16, height: 16)
.opacity(controlActiveState == .inactive ? 0.5 : 1.0)
}
-
- if let _ = preview?.text {
+
+ if preview?.text != nil {
if let info = info {
ToolbarItem(placement: .primaryAction) {
Button {
@@ -111,7 +109,7 @@ struct FilePreviewTextView: View {
}
return
}
-
+
focusField = .window
}
.onDisappear {
diff --git a/Hotline/macOS/FilesView.swift b/Hotline/macOS/FilesView.swift
index 57a9aeb..fd5826f 100644
--- a/Hotline/macOS/FilesView.swift
+++ b/Hotline/macOS/FilesView.swift
@@ -3,21 +3,21 @@ import UniformTypeIdentifiers
struct FolderView: View {
@Environment(Hotline.self) private var model: Hotline
-
+
@State var loading = false
@State var dragOver = false
-
+
var file: FileInfo
let depth: Int
-
+
@MainActor private func uploadFile(file fileURL: URL) {
var filePath: [String] = [String](self.file.path)
if !self.file.isFolder {
filePath.removeLast()
}
-
+
print("UPLOADING TO PATH: ", filePath)
-
+
model.uploadFile(url: fileURL, path: filePath) { info in
Task {
// Refresh file listing to display newly uploaded file.
@@ -25,12 +25,12 @@ struct FolderView: View {
}
}
}
-
+
var body: some View {
HStack(alignment: .center, spacing: 0) {
Spacer()
.frame(width: CGFloat(depth * (12 + 2)))
-
+
Button {
if file.isFolder {
file.expanded.toggle()
@@ -46,26 +46,23 @@ struct FolderView: View {
.frame(width: 10)
.padding(.leading, 4)
.padding(.trailing, 8)
-
+
HStack(alignment: .center) {
if file.isUnavailable {
Image(systemName: "questionmark.app.fill")
.frame(width: 16, height: 16)
.opacity(0.5)
- }
- else if file.isAdminDropboxFolder {
+ } else if file.isAdminDropboxFolder {
Image("Admin Drop Box")
.resizable()
.scaledToFit()
.frame(width: 16, height: 16)
- }
- else if file.isDropboxFolder {
+ } else if file.isDropboxFolder {
Image("Drop Box")
.resizable()
.scaledToFit()
.frame(width: 16, height: 16)
- }
- else {
+ } else {
Image("Folder")
.resizable()
.scaledToFit()
@@ -74,13 +71,13 @@ struct FolderView: View {
}
.frame(width: 16)
.padding(.trailing, 6)
-
+
Text(file.name)
.lineLimit(1)
.truncationMode(.tail)
.foregroundStyle(dragOver ? Color.white : Color.primary)
.opacity(file.isUnavailable ? 0.5 : 1.0)
-
+
if loading {
ProgressView().controlSize(.small).padding([.leading, .trailing], 5)
}
@@ -111,28 +108,29 @@ struct FolderView: View {
}
.onDrop(of: [.fileURL], isTargeted: $dragOver) { items in
guard let item = items.first,
- let identifier = item.registeredTypeIdentifiers.first else {
+ let identifier = item.registeredTypeIdentifiers.first
+ else {
return false
}
-
+
item.loadItem(forTypeIdentifier: identifier, options: nil) { (urlData, error) in
DispatchQueue.main.async {
if let urlData = urlData as? Data,
- let fileURL = URL(dataRepresentation: urlData, relativeTo: nil, isAbsolute: true) {
+ let fileURL = URL(dataRepresentation: urlData, relativeTo: nil, isAbsolute: true)
+ {
uploadFile(file: fileURL)
}
}
}
-
+
return true
}
-
+
if file.expanded {
ForEach(file.children!, id: \.self) { childFile in
if childFile.isFolder {
FolderView(file: childFile, depth: self.depth + 1).tag(file.id)
- }
- else {
+ } else {
FileView(file: childFile, depth: self.depth + 1).tag(file.id)
}
}
@@ -142,34 +140,33 @@ struct FolderView: View {
struct FileView: View {
@Environment(Hotline.self) private var model: Hotline
-
+
var file: FileInfo
let depth: Int
-
+
var body: some View {
HStack(alignment: .center, spacing: 0) {
Spacer()
.frame(width: CGFloat(depth * (12 + 2)))
-
+
Spacer()
.frame(width: 10)
.padding(.leading, 4)
.padding(.trailing, 8)
-
+
HStack(alignment: .center) {
if file.isUnavailable {
Image(systemName: "questionmark.app.fill")
.frame(width: 16, height: 16)
.opacity(0.5)
- }
- else {
+ } else {
FileIconView(filename: file.name, fileType: file.type)
.frame(width: 16, height: 16)
}
}
.frame(width: 16)
.padding(.trailing, 6)
-
+
Text(file.name)
.lineLimit(1)
.truncationMode(.tail)
@@ -189,16 +186,15 @@ struct FileView: View {
ForEach(file.children!, id: \.self) { childFile in
if childFile.isFolder {
FolderView(file: childFile, depth: self.depth + 1).tag(file.id)
- }
- else {
+ } else {
FileView(file: childFile, depth: self.depth + 1).tag(file.id)
}
}
}
}
-
+
static let byteFormatter = ByteCountFormatter()
-
+
private func formattedFileSize(_ fileSize: UInt) -> String {
FileView.byteFormatter.allowedUnits = [.useAll]
FileView.byteFormatter.countStyle = .file
@@ -209,11 +205,11 @@ struct FileView: View {
struct FilesView: View {
@Environment(Hotline.self) private var model: Hotline
@Environment(\.openWindow) private var openWindow
-
+
@State private var selection: FileInfo?
@State private var fileDetails: FileDetails?
@State private var uploadFileSelectorDisplayed: Bool = false
-
+
private func openPreviewWindow(_ previewInfo: PreviewFileInfo) {
switch previewInfo.previewType {
case .image:
@@ -224,7 +220,7 @@ struct FilesView: View {
return
}
}
-
+
@MainActor private func getFileInfo(_ file: FileInfo) {
Task {
if let fileInfo = await model.getFileDetails(file.name, path: file.path) {
@@ -234,15 +230,15 @@ struct FilesView: View {
}
}
}
-
+
@MainActor private func downloadFile(_ file: FileInfo) {
guard !file.isFolder else {
return
}
-
+
model.downloadFile(file.name, path: file.path)
}
-
+
@MainActor private func uploadFile(file fileURL: URL, to path: [String]) {
model.uploadFile(url: fileURL, path: path) { info in
Task {
@@ -251,30 +247,30 @@ struct FilesView: View {
}
}
}
-
+
@MainActor private func previewFile(_ file: FileInfo) {
guard file.isPreviewable else {
return
}
-
+
model.previewFile(file.name, path: file.path) { info in
if let info = info {
openPreviewWindow(info)
}
}
}
-
+
private func deleteFile(_ file: FileInfo) async {
var parentPath: [String] = []
if file.path.count > 1 {
- parentPath = Array(file.path[0..<file.path.count-1])
+ parentPath = Array(file.path[0..<file.path.count - 1])
}
-
+
if await model.deleteFile(file.name, path: file.path) {
let _ = await model.getFileList(path: parentPath)
}
}
-
+
// Friendship Quest Remix
private func isInHomeFolder(_ file: FileInfo?) -> Bool {
if let file {
@@ -282,14 +278,13 @@ struct FilesView: View {
}
return false
}
-
+
var body: some View {
NavigationStack {
List(model.files, id: \.self, selection: $selection) { file in
if file.isFolder {
FolderView(file: file, depth: 0).tag(file.id)
- }
- else {
+ } else {
FileView(file: file, depth: 0).tag(file.id)
}
}
@@ -303,7 +298,7 @@ struct FilesView: View {
}
.contextMenu(forSelectionType: FileInfo.self) { items in
let selectedFile = items.first
-
+
Button {
if let s = selectedFile, !s.isFolder {
downloadFile(s)
@@ -312,9 +307,9 @@ struct FilesView: View {
Label("Download", systemImage: "arrow.down")
}
.disabled(selectedFile == nil || (selectedFile != nil && selectedFile!.isFolder))
-
+
Divider()
-
+
Button {
if let s = selectedFile {
getFileInfo(s)
@@ -323,7 +318,7 @@ struct FilesView: View {
Label("Get Info", systemImage: "info.circle")
}
.disabled(selectedFile == nil)
-
+
Button {
if let s = selectedFile {
previewFile(s)
@@ -332,13 +327,13 @@ struct FilesView: View {
Label("Preview", systemImage: "eye")
}
.disabled(selectedFile == nil || (selectedFile != nil && !selectedFile!.isPreviewable))
-
+
if model.access?.contains(.canDeleteFiles) == true
- // Friendship Quest Remix
- || isInHomeFolder(selectedFile)
+ // Friendship Quest Remix
+ || isInHomeFolder(selectedFile)
{
Divider()
-
+
Button {
if let s = selectedFile {
Task {
@@ -354,12 +349,11 @@ struct FilesView: View {
guard let clickedFile = items.first else {
return
}
-
+
self.selection = clickedFile
if clickedFile.isFolder {
clickedFile.expanded.toggle()
- }
- else {
+ } else {
downloadFile(clickedFile)
}
}
@@ -405,7 +399,7 @@ struct FilesView: View {
.help("Preview")
.disabled(selection == nil || selection?.isPreviewable == false)
}
-
+
ToolbarItem(placement: .primaryAction) {
Button {
if let selectedFile = selection {
@@ -417,7 +411,7 @@ struct FilesView: View {
.help("Get Info")
.disabled(selection == nil)
}
-
+
ToolbarItem(placement: .primaryAction) {
Button {
uploadFileSelectorDisplayed = true
@@ -427,7 +421,7 @@ struct FilesView: View {
.help("Upload")
.disabled(model.access?.contains(.canUploadFiles) != true)
}
-
+
ToolbarItem(placement: .primaryAction) {
Button {
if let selectedFile = selection, !selectedFile.isFolder {
@@ -437,43 +431,47 @@ struct FilesView: View {
Label("Download", systemImage: "arrow.down")
}
.help("Download")
- .disabled(selection == nil || selection?.isFolder == true || model.access?.contains(.canDownloadFiles) != true)
+ .disabled(
+ selection == nil || selection?.isFolder == true
+ || model.access?.contains(.canDownloadFiles) != true)
}
}
}
- .sheet(item: $fileDetails ) { item in
+ .sheet(item: $fileDetails) { item in
FileDetailsView(fd: item)
}
- .fileImporter(isPresented: $uploadFileSelectorDisplayed, allowedContentTypes: [.data], allowsMultipleSelection: false, onCompletion: { results in
- switch results {
- case .success(let fileURLS):
- guard fileURLS.count > 0 else {
- return
- }
-
- let fileURL = fileURLS.first!
-
- print(fileURL)
-
- var uploadPath: [String] = []
-
- if let selection = selection {
- if selection.isFolder {
- uploadPath = selection.path
+ .fileImporter(
+ isPresented: $uploadFileSelectorDisplayed, allowedContentTypes: [.data],
+ allowsMultipleSelection: false,
+ onCompletion: { results in
+ switch results {
+ case .success(let fileURLS):
+ guard fileURLS.count > 0 else {
+ return
}
- else {
- uploadPath = Array<String>(selection.path)
- uploadPath.removeLast()
+
+ let fileURL = fileURLS.first!
+
+ print(fileURL)
+
+ var uploadPath: [String] = []
+
+ if let selection = selection {
+ if selection.isFolder {
+ uploadPath = selection.path
+ } else {
+ uploadPath = [String](selection.path)
+ uploadPath.removeLast()
+ }
}
+
+ print("UPLOAD PATH: \(uploadPath)")
+ uploadFile(file: fileURL, to: uploadPath)
+
+ case .failure(let error):
+ print(error)
}
-
- print("UPLOAD PATH: \(uploadPath)")
- uploadFile(file: fileURL, to: uploadPath)
-
- case .failure(let error):
- print(error)
- }
- })
+ })
}
}
diff --git a/Hotline/macOS/HotlinePanelView.swift b/Hotline/macOS/HotlinePanelView.swift
index dc58698..2617bc8 100644
--- a/Hotline/macOS/HotlinePanelView.swift
+++ b/Hotline/macOS/HotlinePanelView.swift
@@ -3,30 +3,30 @@ import SwiftUI
struct HotlinePanelView: View {
@Environment(\.openWindow) var openWindow
@Environment(\.colorScheme) var colorScheme
-
+
var body: some View {
VStack(spacing: 0) {
- Image(nsImage: ApplicationState.shared.activeServerBanner ?? NSImage(named: "Default Banner")!)
- .interpolation(.high)
- .resizable()
- .scaledToFill()
- .frame(width: 468, height: 60)
- .frame(minWidth: 468, maxWidth: 468, minHeight: 60, maxHeight: 60)
- .clipped()
- .background(.black)
-// .clipShape(RoundedRectangle(cornerRadius: 6.0))
-// .padding([.top, .leading, .trailing], 4)
-
+ Image(
+ nsImage: ApplicationState.shared.activeServerBanner ?? NSImage(named: "Default Banner")!
+ )
+ .interpolation(.high)
+ .resizable()
+ .scaledToFill()
+ .frame(width: 468, height: 60)
+ .frame(minWidth: 468, maxWidth: 468, minHeight: 60, maxHeight: 60)
+ .clipped()
+ .background(.black)
+ // .clipShape(RoundedRectangle(cornerRadius: 6.0))
+ // .padding([.top, .leading, .trailing], 4)
+
HStack(spacing: 12) {
Button {
if NSEvent.modifierFlags.contains(.option) {
openWindow(id: "server")
- }
- else {
+ } else {
openWindow(id: "servers")
}
- }
- label: {
+ } label: {
Image("Section Servers")
.resizable()
.scaledToFit()
@@ -34,11 +34,10 @@ struct HotlinePanelView: View {
.buttonStyle(.plain)
.frame(width: 20, height: 20)
.help("Hotline Servers")
-
+
Button {
ApplicationState.shared.activeServerState?.selection = .chat
- }
- label: {
+ } label: {
Image("Section Chat")
.resizable()
.scaledToFit()
@@ -47,11 +46,10 @@ struct HotlinePanelView: View {
.frame(width: 20, height: 20)
.disabled(ApplicationState.shared.activeServerState == nil)
.help("Public Chat")
-
+
Button {
ApplicationState.shared.activeServerState?.selection = .board
- }
- label: {
+ } label: {
Image("Section Board")
.resizable()
.scaledToFit()
@@ -60,24 +58,25 @@ struct HotlinePanelView: View {
.frame(width: 20, height: 20)
.disabled(ApplicationState.shared.activeServerState == nil)
.help("Message Board")
-
+
Button {
ApplicationState.shared.activeServerState?.selection = .news
- }
- label: {
+ } label: {
Image("Section News")
.resizable()
.scaledToFit()
}
.buttonStyle(.plain)
.frame(width: 20, height: 20)
- .disabled(ApplicationState.shared.activeServerState == nil || (ApplicationState.shared.activeHotline?.serverVersion ?? 0) < 151)
+ .disabled(
+ ApplicationState.shared.activeServerState == nil
+ || (ApplicationState.shared.activeHotline?.serverVersion ?? 0) < 151
+ )
.help("News")
-
+
Button {
ApplicationState.shared.activeServerState?.selection = .files
- }
- label: {
+ } label: {
Image("Section Files")
.resizable()
.scaledToFit()
@@ -86,14 +85,13 @@ struct HotlinePanelView: View {
.frame(width: 20, height: 20)
.disabled(ApplicationState.shared.activeServerState == nil)
.help("Files")
-
+
Spacer()
-
+
if ApplicationState.shared.activeHotline?.access?.contains(.canOpenUsers) == true {
Button {
ApplicationState.shared.activeServerState?.selection = .accounts
- }
- label: {
+ } label: {
Image("Section Users")
.resizable()
.scaledToFit()
@@ -103,7 +101,7 @@ struct HotlinePanelView: View {
.disabled(ApplicationState.shared.activeServerState == nil)
.help("Accounts")
}
-
+
SettingsLink(label: {
Image("Section Settings")
.resizable()
@@ -116,29 +114,29 @@ struct HotlinePanelView: View {
.padding(.top, 12)
.padding(.bottom, 12)
.padding([.leading, .trailing], 12)
-// .background(Color.red.opacity(0.5).blendMode(.multiply))
-
-// GroupBox {
-// HStack(spacing: 0) {
-// Text("Not Connected")
-// .font(.system(size: 10.0))
-// .lineLimit(1)
-// .truncationMode(.tail)
-// .opacity(0.5)
-// .padding(.vertical, 0.0)
-// .padding(.horizontal, 4.0)
-//
-// Spacer()
-// }
-// }
-// .padding([.leading, .bottom, .trailing], 4.0)
+ // .background(Color.red.opacity(0.5).blendMode(.multiply))
+
+ // GroupBox {
+ // HStack(spacing: 0) {
+ // Text("Not Connected")
+ // .font(.system(size: 10.0))
+ // .lineLimit(1)
+ // .truncationMode(.tail)
+ // .opacity(0.5)
+ // .padding(.vertical, 0.0)
+ // .padding(.horizontal, 4.0)
+ //
+ // Spacer()
+ // }
+ // }
+ // .padding([.leading, .bottom, .trailing], 4.0)
}
-// .frame(width: 468)
-// .background(colorScheme == .dark ? .black : .white)
-// .background(
-// VisualEffectView(material: .headerView, blendingMode: .behindWindow)
-// .cornerRadius(10.0)
-// )
+ // .frame(width: 468)
+ // .background(colorScheme == .dark ? .black : .white)
+ // .background(
+ // VisualEffectView(material: .headerView, blendingMode: .behindWindow)
+ // .cornerRadius(10.0)
+ // )
}
}
diff --git a/Hotline/macOS/MessageBoardEditorView.swift b/Hotline/macOS/MessageBoardEditorView.swift
index 474384e..9026c6f 100644
--- a/Hotline/macOS/MessageBoardEditorView.swift
+++ b/Hotline/macOS/MessageBoardEditorView.swift
@@ -9,28 +9,28 @@ struct MessageBoardEditorView: View {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.dismiss) private var dismiss
@Environment(Hotline.self) private var model: Hotline
-
+
@State private var text: String = ""
@State private var sending: Bool = false
-
+
@FocusState private var focusedField: FocusFields?
-
+
func sendPost() async {
sending = true
-
+
let cleanedText = text.trimmingCharacters(in: .whitespacesAndNewlines)
-
+
model.postToMessageBoard(text: cleanedText)
let _ = await model.getMessageBoard()
-
-// let success = await model.postNewsArticle(title: title, body: text, at: path, parentID: parentID)
-// if success {
-// await model.getNewsList(at: path)
-// }
-
+
+ // let success = await model.postNewsArticle(title: title, body: text, at: path, parentID: parentID)
+ // if success {
+ // await model.getNewsList(at: path)
+ // }
+
sending = false
}
-
+
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .center, spacing: 0) {
@@ -45,28 +45,27 @@ struct MessageBoardEditorView: View {
}
.buttonStyle(.plain)
.frame(width: 16, height: 16)
-
+
Spacer()
-
-// Image("Message Board Post")
-// .resizable()
-// .scaledToFit()
-// .frame(width: 16, height: 16)
-// .padding(.trailing, 6)
-
+
+ // Image("Message Board Post")
+ // .resizable()
+ // .scaledToFit()
+ // .frame(width: 16, height: 16)
+ // .padding(.trailing, 6)
+
Text("New Post")
.fontWeight(.semibold)
.lineLimit(1)
.truncationMode(.middle)
-
+
Spacer()
-
+
if sending {
ProgressView()
.controlSize(.small)
.frame(width: 22, height: 22)
- }
- else {
+ } else {
Button {
sending = true
model.postToMessageBoard(text: text)
@@ -82,7 +81,9 @@ struct MessageBoardEditorView: View {
.resizable()
.renderingMode(.template)
.scaledToFit()
- .foregroundColor(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? .secondary : .accentColor)
+ .foregroundColor(
+ text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ ? .secondary : .accentColor)
}
.buttonStyle(.plain)
.frame(width: 22, height: 22)
@@ -92,9 +93,9 @@ struct MessageBoardEditorView: View {
}
.frame(maxWidth: .infinity)
.padding()
-
+
Divider()
-
+
BetterTextEditor(text: $text)
.betterEditorFont(NSFont.systemFont(ofSize: 14.0))
.betterEditorAutomaticSpellingCorrection(true)
@@ -104,7 +105,10 @@ struct MessageBoardEditorView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
.focused($focusedField, equals: .body)
}
- .frame(minWidth: 300, idealWidth: 450, maxWidth: .infinity, minHeight: 300, idealHeight: 500, maxHeight: .infinity)
+ .frame(
+ minWidth: 300, idealWidth: 450, maxWidth: .infinity, minHeight: 300, idealHeight: 500,
+ maxHeight: .infinity
+ )
.background(Color(nsColor: .textBackgroundColor))
.presentationCompactAdaptation(.sheet)
.onAppear {
diff --git a/Hotline/macOS/MessageBoardView.swift b/Hotline/macOS/MessageBoardView.swift
index f870b0c..115e4e3 100644
--- a/Hotline/macOS/MessageBoardView.swift
+++ b/Hotline/macOS/MessageBoardView.swift
@@ -2,10 +2,10 @@ import SwiftUI
struct MessageBoardView: View {
@Environment(Hotline.self) private var model: Hotline
-
+
@State private var composerDisplayed: Bool = false
@State private var composerText: String = ""
-
+
var body: some View {
NavigationStack {
if model.access?.contains(.canReadMessageBoard) != false {
@@ -38,8 +38,7 @@ struct MessageBoardView: View {
}
}
.background(Color(nsColor: .textBackgroundColor))
- }
- else {
+ } else {
ZStack(alignment: .center) {
Text("No Message Board")
.font(.title)
@@ -54,36 +53,36 @@ struct MessageBoardView: View {
MessageBoardEditorView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.frame(idealWidth: 450, idealHeight: 350)
-// RichTextEditor(text: $composerText)
-// .richEditorFont(NSFont.systemFont(ofSize: 16.0))
-// .richEditorAutomaticDashSubstitution(false)
-// .richEditorAutomaticQuoteSubstitution(false)
-// .richEditorAutomaticSpellingCorrection(false)
-// .background(Color(nsColor: .textBackgroundColor))
-// .frame(maxWidth: .infinity, maxHeight: .infinity)
-// .frame(idealWidth: 450, idealHeight: 350)
-// .toolbar {
-// ToolbarItem(placement: .cancellationAction) {
-// Button("Cancel") {
-// composerDisplayed.toggle()
-// }
-// }
-//
-// ToolbarItem(placement: .primaryAction) {
-// Button("Post") {
-// composerDisplayed.toggle()
-// let text = composerText
-// composerText = ""
-// model.postToMessageBoard(text: text)
-// Task {
-// await model.getMessageBoard()
-// }
-// }
-// }
-// }
+ // RichTextEditor(text: $composerText)
+ // .richEditorFont(NSFont.systemFont(ofSize: 16.0))
+ // .richEditorAutomaticDashSubstitution(false)
+ // .richEditorAutomaticQuoteSubstitution(false)
+ // .richEditorAutomaticSpellingCorrection(false)
+ // .background(Color(nsColor: .textBackgroundColor))
+ // .frame(maxWidth: .infinity, maxHeight: .infinity)
+ // .frame(idealWidth: 450, idealHeight: 350)
+ // .toolbar {
+ // ToolbarItem(placement: .cancellationAction) {
+ // Button("Cancel") {
+ // composerDisplayed.toggle()
+ // }
+ // }
+ //
+ // ToolbarItem(placement: .primaryAction) {
+ // Button("Post") {
+ // composerDisplayed.toggle()
+ // let text = composerText
+ // composerText = ""
+ // model.postToMessageBoard(text: text)
+ // Task {
+ // await model.getMessageBoard()
+ // }
+ // }
+ // }
+ // }
}
.toolbar {
- ToolbarItem(placement:.primaryAction) {
+ ToolbarItem(placement: .primaryAction) {
Button {
composerDisplayed.toggle()
} label: {
diff --git a/Hotline/macOS/MessageView.swift b/Hotline/macOS/MessageView.swift
index 0a8b071..f62f922 100644
--- a/Hotline/macOS/MessageView.swift
+++ b/Hotline/macOS/MessageView.swift
@@ -3,19 +3,19 @@ import SwiftUI
struct MessageView: View {
@Environment(Hotline.self) private var model: Hotline
@Environment(\.colorScheme) private var colorScheme
-
+
@State private var input: String = ""
@State private var scrollPos: Int?
@State private var contentHeight: CGFloat = 0
@Namespace private var bottomID
@FocusState private var focusedField: FocusedField?
-
+
var userID: UInt16
-
+
var body: some View {
ScrollViewReader { reader in
VStack(alignment: .leading, spacing: 0) {
-
+
// MARK: Scroll View
GeometryReader { gm in
ScrollView(.vertical) {
@@ -25,17 +25,27 @@ struct MessageView: View {
if msg.direction == .outgoing {
Spacer()
}
-
+
Text(LocalizedStringKey(msg.text))
.lineSpacing(4)
.multilineTextAlignment(.leading)
.textSelection(.enabled)
- .tint(msg.direction == .outgoing ? Color("Outgoing Message Link") : Color("Link Color"))
- .foregroundStyle(msg.direction == .outgoing ? Color("Outgoing Message Text") : Color("Incoming Message Text"))
+ .tint(
+ msg.direction == .outgoing
+ ? Color("Outgoing Message Link") : Color("Link Color")
+ )
+ .foregroundStyle(
+ msg.direction == .outgoing
+ ? Color("Outgoing Message Text") : Color("Incoming Message Text")
+ )
.padding(EdgeInsets(top: 10, leading: 14, bottom: 10, trailing: 14))
- .background(msg.direction == .outgoing ? Color("Outgoing Message Background") : Color("Incoming Message Background"))
+ .background(
+ msg.direction == .outgoing
+ ? Color("Outgoing Message Background")
+ : Color("Incoming Message Background")
+ )
.clipShape(RoundedRectangle(cornerRadius: 16))
-
+
if msg.direction == .incoming {
Spacer()
}
@@ -44,7 +54,7 @@ struct MessageView: View {
}
}
.padding()
-
+
VStack(spacing: 0) {}.id(bottomID)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -61,10 +71,10 @@ struct MessageView: View {
reader.scrollTo(bottomID, anchor: .bottom)
}
}
-
+
// MARK: Input Divider
Divider()
-
+
// MARK: Input Bar
HStack(alignment: .lastTextBaseline, spacing: 0) {
let user = model.users.first(where: { $0.id == userID })
diff --git a/Hotline/macOS/NewsEditorView.swift b/Hotline/macOS/NewsEditorView.swift
index f69c846..9a7242e 100644
--- a/Hotline/macOS/NewsEditorView.swift
+++ b/Hotline/macOS/NewsEditorView.swift
@@ -10,31 +10,32 @@ struct NewsEditorView: View {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.dismiss) private var dismiss
@Environment(Hotline.self) private var model: Hotline
-
+
let editorTitle: String
let isReply: Bool
let path: [String]
let parentID: UInt32
-
+
@State var title: String = ""
@State private var text: String = ""
@State private var sending: Bool = false
-
+
@FocusState private var focusedField: FocusFields?
-
+
func sendArticle() async -> Bool {
sending = true
-
- let success = await model.postNewsArticle(title: title, body: text, at: path, parentID: parentID)
+
+ let success = await model.postNewsArticle(
+ title: title, body: text, at: path, parentID: parentID)
if success {
await model.getNewsList(at: path)
}
-
+
sending = false
-
+
return success
}
-
+
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .center, spacing: 0) {
@@ -49,9 +50,9 @@ struct NewsEditorView: View {
}
.buttonStyle(.plain)
.frame(width: 16, height: 16)
-
+
Spacer()
-
+
if !isReply {
Image("News Category")
.resizable()
@@ -59,20 +60,19 @@ struct NewsEditorView: View {
.frame(width: 16, height: 16)
.padding(.trailing, 6)
}
-
+
Text(editorTitle)
.fontWeight(.semibold)
.lineLimit(1)
.truncationMode(.middle)
-
+
Spacer()
-
+
if sending {
ProgressView()
.controlSize(.small)
.frame(width: 22, height: 22)
- }
- else {
+ } else {
Button {
Task {
if await sendArticle() {
@@ -94,7 +94,7 @@ struct NewsEditorView: View {
}
.frame(maxWidth: .infinity)
.padding([.leading, .top, .trailing])
-
+
TextField("Title", text: $title, axis: .vertical)
.textFieldStyle(.plain)
.lineLimit(3)
@@ -107,9 +107,9 @@ struct NewsEditorView: View {
.clipShape(RoundedRectangle(cornerRadius: 8))
.padding()
.focused($focusedField, equals: .title)
-
+
Divider()
-
+
BetterTextEditor(text: $text)
.betterEditorFont(NSFont.monospacedSystemFont(ofSize: 14.0, weight: .regular))
.betterEditorAutomaticSpellingCorrection(true)
@@ -117,10 +117,10 @@ struct NewsEditorView: View {
.background(Color(nsColor: .textBackgroundColor))
.frame(maxWidth: .infinity, maxHeight: .infinity)
.focused($focusedField, equals: .body)
-
+
HStack(alignment: .center) {
Spacer()
-
+
Text(String("**bold** _italics_ [link name](url) ![image name](url)"))
.foregroundStyle(.secondary)
.font(.caption)
@@ -128,21 +128,23 @@ struct NewsEditorView: View {
.lineLimit(1)
.truncationMode(.middle)
.padding()
-
+
Spacer()
}
.frame(maxWidth: .infinity)
.background(.tertiary.opacity(0.15))
}
- .frame(minWidth: 300, idealWidth: 450, maxWidth: .infinity, minHeight: 300, idealHeight: 500, maxHeight: .infinity)
+ .frame(
+ minWidth: 300, idealWidth: 450, maxWidth: .infinity, minHeight: 300, idealHeight: 500,
+ maxHeight: .infinity
+ )
.background(Color(nsColor: .textBackgroundColor))
.presentationCompactAdaptation(.sheet)
.toolbarTitleDisplayMode(.inlineLarge)
.onAppear {
if !title.isEmpty {
focusedField = .body
- }
- else {
+ } else {
focusedField = .title
}
}
diff --git a/Hotline/macOS/NewsItemView.swift b/Hotline/macOS/NewsItemView.swift
index fc20e61..ff7495b 100644
--- a/Hotline/macOS/NewsItemView.swift
+++ b/Hotline/macOS/NewsItemView.swift
@@ -2,10 +2,10 @@ import SwiftUI
struct NewsItemView: View {
@Environment(Hotline.self) private var model: Hotline
-
+
var news: NewsInfo
let depth: Int
-
+
static var dateFormatter: DateFormatter = {
var dateFormatter = DateFormatter()
dateFormatter.dateStyle = .long
@@ -13,7 +13,7 @@ struct NewsItemView: View {
dateFormatter.timeZone = .gmt
return dateFormatter
}()
-
+
static var relativeDateFormatter: RelativeDateTimeFormatter = {
var formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .full
@@ -21,7 +21,7 @@ struct NewsItemView: View {
formatter.formattingContext = .listItem
return formatter
}()
-
+
var body: some View {
HStack(alignment: .center, spacing: 0) {
if news.expandable {
@@ -38,18 +38,17 @@ struct NewsItemView: View {
.frame(width: 10)
.padding(.leading, 4)
.padding(.trailing, 8)
- }
- else {
+ } else {
Spacer()
.frame(width: 10)
.padding(.leading, 4)
.padding(.trailing, 8)
}
-
+
// Tree indent
Spacer()
.frame(width: (CGFloat(depth) * 22))
-
+
switch news.type {
case .category:
Image("News Category")
@@ -64,60 +63,61 @@ struct NewsItemView: View {
case .article:
EmptyView()
}
-
+
Text(news.name)
- .fontWeight((news.type == .bundle || news.type == .category || !news.read) ? .semibold : .regular)
+ .fontWeight(
+ (news.type == .bundle || news.type == .category || !news.read) ? .semibold : .regular
+ )
.lineLimit(1)
.truncationMode(.tail)
-
+
if news.type == .article && news.articleUsername != nil {
Text(news.articleUsername!)
.foregroundStyle(.secondary)
.lineLimit(1)
.padding(.leading, 8)
}
-
+
Spacer()
-
+
if news.type == .category && news.count > 0 {
Text("^[\(news.count) Post](inflect: true)")
.lineLimit(1)
.foregroundStyle(.secondary)
.padding(.trailing, 8)
- }
- else if news.type == .bundle && news.count > 0 {
+ } else if news.type == .bundle && news.count > 0 {
Text("^[\(news.count) Category](inflect: true)")
.lineLimit(1)
.foregroundStyle(.secondary)
.padding(.trailing, 8)
}
-// if news.type == .bundle {
-// Text("\(news.count)")
-// .lineLimit(1)
-// .foregroundStyle(.tertiary)
-// .padding(.trailing, 8)
-
-// ZStack {
-// Text("^[\(news.count) \(news.type == .bundle ? "Category" : "Post")](inflect: true)")
-// Text("\(news.count)")
-// .foregroundStyle(.clear)
-//// .font(.caption)
-// .lineLimit(1)
-// .padding([.leading, .trailing], 8)
-// .padding([.top, .bottom], 2)
-// .background(.secondary)
-// .clipShape(Capsule())
-//
-// Text("\(news.count)")
-// .foregroundStyle(.white)
-//// .font(.caption)
-// .lineLimit(1)
-// .padding([.leading, .trailing], 8)
-// .padding([.top, .bottom], 2)
-// .blendMode(.destinationOut)
-// }
-// .drawingGroup(opaque: false)
-// }
+ // if news.type == .bundle {
+ // Text("\(news.count)")
+ // .lineLimit(1)
+ // .foregroundStyle(.tertiary)
+ // .padding(.trailing, 8)
+
+ // ZStack {
+ // Text("^[\(news.count) \(news.type == .bundle ? "Category" : "Post")](inflect: true)")
+ // Text("\(news.count)")
+ // .foregroundStyle(.clear)
+ //// .font(.caption)
+ // .lineLimit(1)
+ // .padding([.leading, .trailing], 8)
+ // .padding([.top, .bottom], 2)
+ // .background(.secondary)
+ // .clipShape(Capsule())
+ //
+ // Text("\(news.count)")
+ // .foregroundStyle(.white)
+ //// .font(.caption)
+ // .lineLimit(1)
+ // .padding([.leading, .trailing], 8)
+ // .padding([.top, .bottom], 2)
+ // .blendMode(.destinationOut)
+ // }
+ // .drawingGroup(opaque: false)
+ // }
else if news.type == .article && news.articleUsername != nil {
if let d = news.articleDate {
Text(NewsItemView.relativeDateFormatter.localizedString(for: d, relativeTo: Date.now))
@@ -132,12 +132,12 @@ struct NewsItemView: View {
guard news.expanded, news.type == .bundle || news.type == .category else {
return
}
-
+
Task {
await model.getNewsList(at: news.path)
}
}
-
+
if news.expanded {
ForEach(news.children.reversed(), id: \.self) { childNews in
NewsItemView(news: childNews, depth: self.depth + 1).tag(childNews.id)
@@ -147,6 +147,11 @@ struct NewsItemView: View {
}
#Preview {
- NewsItemView(news: NewsInfo(hotlineNewsArticle: HotlineNewsArticle(id: 0, parentID: 0, flags: 0, title: "Title", username: "username", date: Date.now, flavors: [("", 1)], path: ["Guest"])), depth: 0)
- .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient()))
+ NewsItemView(
+ news: NewsInfo(
+ hotlineNewsArticle: HotlineNewsArticle(
+ id: 0, parentID: 0, flags: 0, title: "Title", username: "username", date: Date.now,
+ flavors: [("", 1)], path: ["Guest"])), depth: 0
+ )
+ .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient()))
}
diff --git a/Hotline/macOS/NewsView.swift b/Hotline/macOS/NewsView.swift
index 91bf2fe..d710c62 100644
--- a/Hotline/macOS/NewsView.swift
+++ b/Hotline/macOS/NewsView.swift
@@ -1,20 +1,21 @@
-import SwiftUI
import MarkdownUI
import SplitView
+import SwiftUI
struct NewsView: View {
@Environment(Hotline.self) private var model: Hotline
@Environment(\.openWindow) private var openWindow
@Environment(\.colorScheme) private var colorScheme
-
+
@State private var selection: NewsInfo?
@State private var articleText: String?
@State private var splitHidden = SideHolder(.bottom)
- @State private var splitFraction = FractionHolder.usingUserDefaults(0.25, key: "News Split Fraction")
+ @State private var splitFraction = FractionHolder.usingUserDefaults(
+ 0.25, key: "News Split Fraction")
@State private var editorOpen: Bool = false
@State private var replyOpen: Bool = false
@State private var loading: Bool = false
-
+
var body: some View {
Group {
if model.serverVersion < 151 {
@@ -28,15 +29,13 @@ struct NewsView: View {
.font(.system(size: 13))
}
.padding()
- }
- else {
+ } else {
NavigationStack {
VSplit(
top: {
if !model.newsLoaded {
loadingIndicator
- }
- else if model.news.isEmpty {
+ } else if model.news.isEmpty {
ZStack(alignment: .center) {
Text("No News")
.font(.title)
@@ -45,8 +44,7 @@ struct NewsView: View {
.padding()
}
.frame(maxWidth: .infinity)
- }
- else {
+ } else {
newsBrowser
}
},
@@ -57,7 +55,10 @@ struct NewsView: View {
.fraction(splitFraction)
.constraints(minPFraction: 0.1, minSFraction: 0.3)
.hide(splitHidden)
- .styling(color: colorScheme == .dark ? .black : Splitter.defaultColor, inset: 0, visibleThickness: 0.5, invisibleThickness: 5, hideSplitter: true)
+ .styling(
+ color: colorScheme == .dark ? .black : Splitter.defaultColor, inset: 0,
+ visibleThickness: 0.5, invisibleThickness: 5, hideSplitter: true
+ )
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(nsColor: .textBackgroundColor))
}
@@ -75,21 +76,24 @@ struct NewsView: View {
if let selection = selection {
switch selection.type {
case .article, .category:
- NewsEditorView(editorTitle: selection.path.last ?? "New Post", isReply: false, path: selection.path, parentID: 0)
+ NewsEditorView(
+ editorTitle: selection.path.last ?? "New Post", isReply: false, path: selection.path,
+ parentID: 0)
default:
EmptyView()
}
- }
- else {
+ } else {
EmptyView()
}
}
.sheet(isPresented: $replyOpen) {
} content: {
if let selection = selection, selection.type == .article {
- NewsEditorView(editorTitle: "Reply to \(selection.articleUsername ?? "Post")", isReply: true, path: selection.path, parentID: UInt32(selection.articleID!), title: selection.name.replyToString())
- }
- else {
+ NewsEditorView(
+ editorTitle: "Reply to \(selection.articleUsername ?? "Post")", isReply: true,
+ path: selection.path, parentID: UInt32(selection.articleID!),
+ title: selection.name.replyToString())
+ } else {
EmptyView()
}
}
@@ -105,7 +109,7 @@ struct NewsView: View {
.help("New Post")
.disabled(selection?.type != .category && selection?.type != .article)
}
-
+
ToolbarItem(placement: .primaryAction) {
Button {
if selection?.type == .article {
@@ -117,7 +121,7 @@ struct NewsView: View {
.help("Reply to Post")
.disabled(selection?.type != .article)
}
-
+
ToolbarItem(placement: .primaryAction) {
Button {
loading = true
@@ -126,8 +130,7 @@ struct NewsView: View {
await model.getNewsList(at: selectionPath)
loading = false
}
- }
- else {
+ } else {
Task {
await model.getNewsList()
loading = false
@@ -141,7 +144,7 @@ struct NewsView: View {
}
}
}
-
+
var newsBrowser: some View {
List(model.news, id: \.self, selection: $selection) { newsItem in
NewsItemView(news: newsItem, depth: 0).tag(newsItem.id)
@@ -152,23 +155,27 @@ struct NewsView: View {
.alternatingRowBackgrounds(.enabled)
.contextMenu(forSelectionType: NewsInfo.self) { items in
let selectedItem = items.first
-
+
Button {
if selectedItem?.type == .article {
replyOpen = true
}
} label: {
- Label("Reply to \(selectedItem?.articleUsername ?? "Post")", systemImage: "arrowshape.turn.up.left")
+ Label(
+ "Reply to \(selectedItem?.articleUsername ?? "Post")",
+ systemImage: "arrowshape.turn.up.left")
}
.disabled(selectedItem == nil || selectedItem?.type != .article)
-
+
} primaryAction: { items in
guard let clickedNews = items.first else {
return
}
-
+
self.selection = clickedNews
- if clickedNews.type == .bundle || clickedNews.type == .category || clickedNews.children.count > 0 {
+ if clickedNews.type == .bundle || clickedNews.type == .category
+ || clickedNews.children.count > 0
+ {
clickedNews.expanded.toggle()
}
}
@@ -177,9 +184,12 @@ struct NewsView: View {
if let article = selection, article.type == .article {
article.read = true
if let articleFlavor = article.articleFlavors?.first,
- let articleID = article.articleID {
+ let articleID = article.articleID
+ {
Task {
- if let articleText = await self.model.getNewsArticle(id: articleID, at: article.path, flavor: articleFlavor) {
+ if let articleText = await self.model.getNewsArticle(
+ id: articleID, at: article.path, flavor: articleFlavor)
+ {
self.articleText = articleText
}
}
@@ -188,10 +198,9 @@ struct NewsView: View {
self.splitHidden.side = nil
}
}
-
+
}
- }
- else {
+ } else {
if self.splitHidden.side != .bottom {
withAnimation(.easeOut(duration: 0.25)) {
self.splitHidden.side = .bottom
@@ -214,7 +223,7 @@ struct NewsView: View {
return .ignored
}
}
-
+
var loadingIndicator: some View {
VStack {
HStack {
@@ -226,7 +235,7 @@ struct NewsView: View {
}
.frame(maxWidth: .infinity)
}
-
+
var articleViewer: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
@@ -248,14 +257,14 @@ struct NewsView: View {
.padding(.bottom, 16)
}
}
-
+
Divider()
-
+
Text(selection.name).font(.title)
.textSelection(.enabled)
.padding(.bottom, 8)
.padding(.top, 16)
-
+
if let newsText = self.articleText {
Markdown(newsText)
.markdownTheme(.basic)
@@ -263,16 +272,15 @@ struct NewsView: View {
.lineSpacing(6)
.padding(.top, 16)
}
- }
- else {
+ } else {
HStack(alignment: .center) {
Spacer()
HStack(alignment: .center, spacing: 8) {
-// Image(systemName: "doc.append")
-// .resizable()
-// .scaledToFit()
-// .foregroundStyle(.tertiary)
-// .frame(width: 16, height: 16)
+ // Image(systemName: "doc.append")
+ // .resizable()
+ // .scaledToFit()
+ // .foregroundStyle(.tertiary)
+ // .frame(width: 16, height: 16)
Text("Select a news post to read")
.foregroundStyle(.tertiary)
.font(.system(size: 13))
diff --git a/Hotline/macOS/ServerAgreementView.swift b/Hotline/macOS/ServerAgreementView.swift
index a1f96d9..77e9e15 100644
--- a/Hotline/macOS/ServerAgreementView.swift
+++ b/Hotline/macOS/ServerAgreementView.swift
@@ -1,13 +1,13 @@
import SwiftUI
-fileprivate let MAX_AGREEMENT_HEIGHT: CGFloat = 280
+private let MAX_AGREEMENT_HEIGHT: CGFloat = 280
struct ServerAgreementView: View {
let text: String
-
+
@State private var expandable: Bool = false
@State private var expanded: Bool = false
-
+
var body: some View {
ScrollView(.vertical) {
HStack(alignment: .top) {
@@ -24,8 +24,7 @@ struct ServerAgreementView: View {
Color.clear.onAppear {
if geometry.size.height > MAX_AGREEMENT_HEIGHT {
expandable = true
- }
- else {
+ } else {
expandable = false
}
}
@@ -37,41 +36,42 @@ struct ServerAgreementView: View {
.scrollIndicators(.never)
.frame(maxWidth: .infinity, maxHeight: (expandable && expanded) ? nil : MAX_AGREEMENT_HEIGHT)
.scrollBounceBehavior(.basedOnSize)
-#if os(iOS)
- .background(Color("Agreement Background"))
-#elseif os(macOS)
- .background(VisualEffectView(material: .titlebar, blendingMode: .withinWindow))
-#endif
+ #if os(iOS)
+ .background(Color("Agreement Background"))
+ #elseif os(macOS)
+ .background(VisualEffectView(material: .titlebar, blendingMode: .withinWindow))
+ #endif
.overlay(
ZStack(alignment: .bottomTrailing) {
Group {
if !expandable || expanded {
EmptyView()
- }
- else {
- Button(action: {
- withAnimation(.easeOut(duration: 0.15)) {
- expanded = true
+ } else {
+ Button(
+ action: {
+ withAnimation(.easeOut(duration: 0.15)) {
+ expanded = true
+ }
+ },
+ label: {
+ Color.black
+ .opacity(0.00001)
+ .frame(width: 32, height: 32)
+ .overlay(
+ Image(systemName: "arrow.up.left.and.arrow.down.right")
+ .resizable()
+ .scaledToFit()
+ .fontWeight(.semibold)
+ .frame(width: 12, height: 12)
+ .foregroundColor(.primary.opacity(0.8)), alignment: .center)
}
- }, label: {
- Color.black
- .opacity(0.00001)
- .frame(width: 32, height: 32)
- .overlay(
- Image(systemName: "arrow.up.left.and.arrow.down.right")
- .resizable()
- .scaledToFit()
- .fontWeight(.semibold)
- .frame(width: 12, height: 12)
- .foregroundColor(.primary.opacity(0.8))
- , alignment: .center)
- })
+ )
.buttonStyle(.plain)
.help("Expand Server Agreement")
}
}
- }
- , alignment: .bottomTrailing)
+ }, alignment: .bottomTrailing
+ )
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
diff --git a/Hotline/macOS/ServerMessageView.swift b/Hotline/macOS/ServerMessageView.swift
index 8413a87..98f4043 100644
--- a/Hotline/macOS/ServerMessageView.swift
+++ b/Hotline/macOS/ServerMessageView.swift
@@ -2,7 +2,7 @@ import SwiftUI
struct ServerMessageView: View {
let message: String
-
+
var body: some View {
HStack(alignment: .center, spacing: 8) {
Image("Server Message")
@@ -19,11 +19,11 @@ struct ServerMessageView: View {
}
.padding()
.frame(maxWidth: .infinity)
-#if os(iOS)
- .background(Color("Agreement Background"))
-#elseif os(macOS)
- .background(VisualEffectView(material: .titlebar, blendingMode: .withinWindow))
-#endif
+ #if os(iOS)
+ .background(Color("Agreement Background"))
+ #elseif os(macOS)
+ .background(VisualEffectView(material: .titlebar, blendingMode: .withinWindow))
+ #endif
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift
index 1e53e49..3a25925 100644
--- a/Hotline/macOS/ServerView.swift
+++ b/Hotline/macOS/ServerView.swift
@@ -5,11 +5,11 @@ import UniformTypeIdentifiers
class ServerState: Equatable {
var id: UUID = UUID()
var selection: ServerNavigationType
-
+
init(selection: ServerNavigationType) {
self.selection = selection
}
-
+
static func == (lhs: ServerState, rhs: ServerState) -> Bool {
return lhs.id == rhs.id
}
@@ -29,18 +29,18 @@ struct ServerMenuItem: Identifiable, Hashable {
let type: ServerNavigationType
let name: String
let image: String
-
+
init(type: ServerNavigationType, name: String, image: String) {
self.id = UUID()
self.type = type
self.name = name
self.image = image
}
-
+
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
-
+
static func == (lhs: ServerMenuItem, rhs: ServerMenuItem) -> Bool {
switch lhs.type {
case .user(let lhsUID):
@@ -59,23 +59,23 @@ struct ServerMenuItem: Identifiable, Hashable {
struct ListItemView: View {
@Environment(\.controlActiveState) private var controlActiveState
-
+
let icon: String?
let title: String
let unread: Bool
-
+
var body: some View {
HStack(spacing: 5) {
if let i = icon {
Image(i)
.resizable()
- // .renderingMode(.template)
+ // .renderingMode(.template)
.scaledToFit()
.frame(width: 20, height: 20)
-// .padding(.leading, 2)
+ // .padding(.leading, 2)
.opacity(controlActiveState == .inactive ? 0.5 : 1.0)
}
-
+
Text(title)
.lineLimit(1)
.truncationMode(.tail)
@@ -103,7 +103,7 @@ extension FocusedValues {
get { self[ActiveHotlineModelFocusedValueKey.self] }
set { self[ActiveHotlineModelFocusedValueKey.self] = newValue }
}
-
+
var activeServerState: ServerState? {
get { self[ActiveServerStateFocusedValueKey.self] }
set { self[ActiveServerStateFocusedValueKey.self] = newValue }
@@ -127,7 +127,7 @@ enum ServerNavigationType: Identifiable, Hashable, Equatable {
return String(userID)
}
}
-
+
case chat
case news
case board
@@ -142,10 +142,11 @@ struct ServerView: View {
@Environment(\.controlActiveState) private var controlActiveState
@Environment(\.scenePhase) private var scenePhase
@Environment(\.modelContext) private var modelContext
-
+
@State private var reconnectTimer: Task<Void, Never>?
@State private var shouldReconnect: Bool = false
- @State private var model: Hotline = Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())
+ @State private var model: Hotline = Hotline(
+ trackerClient: HotlineTrackerClient(), client: HotlineClient())
@State private var state: ServerState = ServerState(selection: .chat)
@State private var agreementShown: Bool = false
@State private var connectAddress: String = ""
@@ -156,7 +157,7 @@ struct ServerView: View {
@State private var autoconnect: Bool = false
@Binding var server: Server
-
+
static var menuItems: [ServerMenuItem] = [
ServerMenuItem(type: .chat, name: "Chat", image: "Section Chat"),
ServerMenuItem(type: .board, name: "Board", image: "Section Board"),
@@ -164,28 +165,27 @@ struct ServerView: View {
ServerMenuItem(type: .files, name: "Files", image: "Section Files"),
ServerMenuItem(type: .accounts, name: "Accounts", image: "Section Users"),
]
-
+
static var classicMenuItems: [ServerMenuItem] = [
ServerMenuItem(type: .chat, name: "Chat", image: "Section Chat"),
ServerMenuItem(type: .board, name: "Board", image: "Section Board"),
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 {
connectForm
.navigationTitle("Connect to Server")
- }
- else if model.status != .loggedIn {
+ } else if model.status != .loggedIn {
HStack {
Image("Hotline")
.resizable()
@@ -195,7 +195,7 @@ struct ServerView: View {
.frame(width: 18)
.opacity(controlActiveState == .inactive ? 0.5 : 1.0)
.padding(.trailing, 4)
-
+
ProgressView(value: connectionStatusToProgress(status: model.status)) {
Text(connectionStatusToLabel(status: model.status))
}
@@ -204,8 +204,7 @@ struct ServerView: View {
.frame(maxWidth: 300)
.padding()
.navigationTitle("Connecting to Server")
- }
- else {
+ } else {
serverView
.environment(model)
.onChange(of: Prefs.shared.userIconID) { sendPreferences() }
@@ -217,8 +216,8 @@ struct ServerView: View {
.toolbar {
ToolbarItem(placement: .navigation) {
Image("Server Large")
-// .renderingMode(.template)
-
+ // .renderingMode(.template)
+
.resizable()
.scaledToFit()
.frame(width: 28)
@@ -251,7 +250,7 @@ struct ServerView: View {
connectAddress = server.address
connectLogin = server.login
connectPassword = server.password
-
+
// Connect to server automatically unless the option key is held down.
if !NSEvent.modifierFlags.contains(.option) {
connectToServer()
@@ -260,16 +259,16 @@ struct ServerView: View {
.focusedSceneValue(\.activeHotlineModel, model)
.focusedSceneValue(\.activeServerState, state)
}
-
+
private func startReconnectTimer() {
- reconnectTimer = Task {
- while !Task.isCancelled {
- connectToServer()
- try? await Task.sleep(for: .seconds(5))
- }
+ reconnectTimer = Task {
+ while !Task.isCancelled {
+ connectToServer()
+ try? await Task.sleep(for: .seconds(5))
}
}
-
+ }
+
var connectForm: some View {
VStack(alignment: .center) {
GroupBox {
@@ -279,12 +278,14 @@ struct ServerView: View {
Text("Address:")
}
.focused($focusedField, equals: .address)
-
- Text("Type the address of the Hotline server you would like to connect to. If you have an account on that server, type your login and password too.")
- .font(.caption)
- .foregroundStyle(.secondary)
- .padding(.bottom, 4)
-
+
+ Text(
+ "Type the address of the Hotline server you would like to connect to. If you have an account on that server, type your login and password too."
+ )
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .padding(.bottom, 4)
+
TextField(text: $connectLogin, prompt: Text("Optional")) {
Text("Login:")
}
@@ -296,7 +297,7 @@ struct ServerView: View {
}
.textFieldStyle(.roundedBorder)
.controlSize(.large)
-
+
HStack {
Button("Save...") {
if !connectAddress.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
@@ -307,28 +308,28 @@ struct ServerView: View {
.controlSize(.regular)
.buttonStyle(.automatic)
.help("Bookmark server")
-
+
Spacer()
-
+
Button("Cancel") {
dismiss()
}
.controlSize(.regular)
.buttonStyle(.automatic)
.keyboardShortcut(.cancelAction)
-
+
Button("Connect") {
Task {
connectToServer()
}
}
-
+
.controlSize(.regular)
.buttonStyle(.automatic)
.keyboardShortcut(.defaultAction)
}
.padding(.top, 8)
-
+
}
.padding()
.onChange(of: connectAddress) {
@@ -368,7 +369,7 @@ struct ServerView: View {
connectName = ""
}
}
-
+
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
let name = String(connectName.trimmingCharacters(in: .whitespacesAndNewlines))
@@ -379,9 +380,11 @@ struct ServerView: View {
let (host, port) = await Server.parseServerAddressAndPort(connectAddress)
let login: String? = await connectLogin.isEmpty ? nil : connectLogin
let password: String? = await connectPassword.isEmpty ? nil : connectPassword
-
+
if !host.isEmpty {
- let newBookmark = await Bookmark(type: .server, name: name, address: host, port: port, login: login, password: password, autoconnect: autoconnect)
+ let newBookmark = await Bookmark(
+ type: .server, name: name, address: host, port: port, login: login,
+ password: password, autoconnect: autoconnect)
await MainActor.run {
Bookmark.add(newBookmark, context: modelContext)
}
@@ -396,38 +399,39 @@ struct ServerView: View {
}
}
}
-
+
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
+ ForEach(model.serverVersion < 151 ? ServerView.classicMenuItems : ServerView.menuItems) {
+ menuItem in
if menuItem.type == .chat {
- ListItemView(icon: menuItem.image, title: menuItem.name, unread: model.unreadPublicChat).tag(menuItem.type)
- }
- else if menuItem.type == .accounts {
+ ListItemView(icon: menuItem.image, title: menuItem.name, unread: model.unreadPublicChat)
+ .tag(menuItem.type)
+ } else if menuItem.type == .accounts {
if model.access?.contains(.canOpenUsers) == true {
- ListItemView(icon: menuItem.image, title: menuItem.name, unread: false).tag(menuItem.type)
+ ListItemView(icon: menuItem.image, title: menuItem.name, unread: false).tag(
+ menuItem.type)
}
- }
- else {
+ } else {
ListItemView(icon: menuItem.image, title: menuItem.name, unread: false).tag(menuItem.type)
}
}
-
+
if model.transfers.count > 0 {
Divider()
-
+
self.transfersSection
}
-
+
if model.users.count > 0 {
Divider()
-
+
self.usersSection
}
}
.onChange(of: state.selection) {
- switch(state.selection) {
+ switch state.selection {
case .chat:
model.markPublicChatAsRead()
case .user(let userID):
@@ -437,118 +441,116 @@ struct ServerView: View {
}
}
}
-
+
var transfersSection: some View {
-// Section("Transfers") {
- ForEach(model.transfers) { transfer in
- TransferItemView(transfer: transfer)
- }
-// }
+ // Section("Transfers") {
+ ForEach(model.transfers) { transfer in
+ TransferItemView(transfer: transfer)
+ }
+ // }
}
-
+
var usersSection: some View {
-// Section("\(model.users.count) Online") {
- ForEach(model.users) { user in
- HStack(spacing: 5) {
- if let iconImage = Hotline.getClassicIcon(Int(user.iconID)) {
- Image(nsImage: iconImage)
- .frame(width: 16, height: 16)
- .padding(.leading, 2)
- .padding(.trailing, 2)
- }
- else {
- Image("User")
- .frame(width: 16, height: 16)
- .padding(.leading, 2)
- .padding(.trailing, 2)
- }
-
- Text(user.name)
- .foregroundStyle(user.isAdmin ? Color.hotlineRed : .primary)
-
- Spacer()
-
- if model.hasUnreadInstantMessages(userID: user.id) {
- Circle()
- .frame(width: 6, height: 6)
- .foregroundStyle(user.isAdmin ? Color.hotlineRed : .primary.opacity(0.5))
- .padding(EdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 2))
- }
+ // Section("\(model.users.count) Online") {
+ ForEach(model.users) { user in
+ HStack(spacing: 5) {
+ if let iconImage = Hotline.getClassicIcon(Int(user.iconID)) {
+ Image(nsImage: iconImage)
+ .frame(width: 16, height: 16)
+ .padding(.leading, 2)
+ .padding(.trailing, 2)
+ } else {
+ Image("User")
+ .frame(width: 16, height: 16)
+ .padding(.leading, 2)
+ .padding(.trailing, 2)
+ }
+
+ Text(user.name)
+ .foregroundStyle(user.isAdmin ? Color.hotlineRed : .primary)
+
+ Spacer()
+
+ if model.hasUnreadInstantMessages(userID: user.id) {
+ Circle()
+ .frame(width: 6, height: 6)
+ .foregroundStyle(user.isAdmin ? Color.hotlineRed : .primary.opacity(0.5))
+ .padding(EdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 2))
}
- .opacity(user.isIdle ? 0.5 : 1.0)
- .opacity(controlActiveState == .inactive ? 0.5 : 1.0)
- .tag(ServerNavigationType.user(userID: user.id))
}
-// }
+ .opacity(user.isIdle ? 0.5 : 1.0)
+ .opacity(controlActiveState == .inactive ? 0.5 : 1.0)
+ .tag(ServerNavigationType.user(userID: user.id))
+ }
+ // }
}
-
+
var serverView: some View {
NavigationSplitView {
self.navigationList
.frame(maxWidth: .infinity)
.navigationSplitViewColumnWidth(min: 150, ideal: 200, max: 500)
} detail: {
- switch state.selection {
- case .chat:
- ChatView()
- .navigationTitle(model.serverTitle)
- .navigationSubtitle("Public Chat")
- .navigationSplitViewColumnWidth(min: 250, ideal: 500)
- case .news:
- NewsView()
- .navigationTitle(model.serverTitle)
- .navigationSubtitle("Newsgroups")
- .navigationSplitViewColumnWidth(min: 250, ideal: 500)
- case .board:
- MessageBoardView()
- .navigationTitle(model.serverTitle)
- .navigationSubtitle("Message Board")
- .navigationSplitViewColumnWidth(min: 250, ideal: 500)
- case .files:
- FilesView()
- .navigationTitle(model.serverTitle)
- .navigationSubtitle("Shared Files")
- .navigationSplitViewColumnWidth(min: 250, ideal: 500)
- case .accounts:
- AccountManagerView()
- .navigationTitle(model.serverTitle)
- .navigationSubtitle("Accounts")
- .navigationSplitViewColumnWidth(min: 250, ideal: 500)
- case .user(let userID):
- let user = model.users.first(where: { $0.id == userID })
- MessageView(userID: userID)
- .navigationTitle(model.serverTitle)
- .navigationSubtitle(user?.name ?? "Private Message")
- .navigationSplitViewColumnWidth(min: 250, ideal: 500)
- .onAppear {
- model.markInstantMessagesAsRead(userID: userID)
- }
- }
+ switch state.selection {
+ case .chat:
+ ChatView()
+ .navigationTitle(model.serverTitle)
+ .navigationSubtitle("Public Chat")
+ .navigationSplitViewColumnWidth(min: 250, ideal: 500)
+ case .news:
+ NewsView()
+ .navigationTitle(model.serverTitle)
+ .navigationSubtitle("Newsgroups")
+ .navigationSplitViewColumnWidth(min: 250, ideal: 500)
+ case .board:
+ MessageBoardView()
+ .navigationTitle(model.serverTitle)
+ .navigationSubtitle("Message Board")
+ .navigationSplitViewColumnWidth(min: 250, ideal: 500)
+ case .files:
+ FilesView()
+ .navigationTitle(model.serverTitle)
+ .navigationSubtitle("Shared Files")
+ .navigationSplitViewColumnWidth(min: 250, ideal: 500)
+ case .accounts:
+ AccountManagerView()
+ .navigationTitle(model.serverTitle)
+ .navigationSubtitle("Accounts")
+ .navigationSplitViewColumnWidth(min: 250, ideal: 500)
+ case .user(let userID):
+ let user = model.users.first(where: { $0.id == userID })
+ MessageView(userID: userID)
+ .navigationTitle(model.serverTitle)
+ .navigationSubtitle(user?.name ?? "Private Message")
+ .navigationSplitViewColumnWidth(min: 250, ideal: 500)
+ .onAppear {
+ model.markInstantMessagesAsRead(userID: userID)
+ }
+ }
}
.toolbar(removing: .sidebarToggle)
}
-
-
+
// MARK: -
-
+
@MainActor func connectToServer() {
guard !server.address.isEmpty else {
return
}
- model.login(server: server, username: Prefs.shared.username, iconID: Prefs.shared.userIconID) { success in
+ model.login(server: server, username: Prefs.shared.username, iconID: Prefs.shared.userIconID) {
+ success in
if !success {
print("FAILED LOGIN??")
model.disconnect()
- }
- else {
+ } else {
sendPreferences()
model.getUserList()
model.downloadBanner()
}
}
}
-
+
private func connectionStatusToProgress(status: HotlineClientStatus) -> Double {
switch status {
case .disconnected:
@@ -563,7 +565,7 @@ struct ServerView: View {
return 1.0
}
}
-
+
private func connectionStatusToLabel(status: HotlineClientStatus) -> String {
let n = server.name ?? server.address
switch status {
@@ -579,26 +581,28 @@ struct ServerView: View {
return "Logged in to \(n)"
}
}
-
+
@MainActor func sendPreferences() {
if self.model.status == .loggedIn {
var options: HotlineUserOptions = HotlineUserOptions()
-
+
if Prefs.shared.refusePrivateMessages {
options.update(with: .refusePrivateMessages)
}
-
+
if Prefs.shared.refusePrivateChat {
options.update(with: .refusePrivateChat)
}
-
+
if Prefs.shared.enableAutomaticMessage {
options.update(with: .automaticResponse)
}
-
+
print("Updating preferences with server")
-
- self.model.sendUserInfo(username: Prefs.shared.username, iconID: Prefs.shared.userIconID, options: options, autoresponse: Prefs.shared.automaticMessage)
+
+ self.model.sendUserInfo(
+ username: Prefs.shared.username, iconID: Prefs.shared.userIconID, options: options,
+ autoresponse: Prefs.shared.automaticMessage)
}
}
}
@@ -609,30 +613,28 @@ struct ServerView: View {
struct TransferItemView: View {
let transfer: TransferInfo
-
+
@Environment(\.controlActiveState) private var controlActiveState
@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 {
+ } else if self.transfer.failed {
return "File transfer failed"
- }
- else if self.transfer.progress > 0.0 {
+ } 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))% – \(self.transfer.timeRemaining) seconds left"
+ } else {
return "\(round(self.transfer.progress * 100.0))% complete"
}
}
return ""
}
-
+
var body: some View {
HStack(alignment: .center, spacing: 5) {
HStack(spacing: 0) {
@@ -640,17 +642,17 @@ struct TransferItemView: View {
FileIconView(filename: transfer.title, fileType: nil)
.frame(width: 16, height: 16)
.opacity(controlActiveState == .inactive ? 0.5 : 1.0)
-// .padding(.leading, 2)
+ // .padding(.leading, 2)
Spacer()
}
.frame(width: 20)
-
+
Text(transfer.title)
.lineLimit(1)
.truncationMode(.middle)
-
+
Spacer()
-
+
if self.hovered {
Button {
model.deleteTransfer(id: transfer.id)
@@ -669,16 +671,14 @@ struct TransferItemView: View {
.onHover { hovered in
self.buttonHovered = hovered
}
- }
- else if transfer.failed {
+ } else if transfer.failed {
Image(systemName: "exclamationmark.triangle.fill")
.resizable()
.symbolRenderingMode(.multicolor)
.aspectRatio(contentMode: .fit)
.frame(width: 16, height: 16)
.opacity(controlActiveState == .inactive ? 0.5 : 1.0)
- }
- else if transfer.completed {
+ } else if transfer.completed {
Image(systemName: "checkmark.circle.fill")
.resizable()
.symbolRenderingMode(.palette)
@@ -686,13 +686,11 @@ struct TransferItemView: View {
.aspectRatio(contentMode: .fit)
.frame(width: 16, height: 16)
.opacity(controlActiveState == .inactive ? 0.5 : 1.0)
- }
- else if transfer.progress == 0.0 {
+ } else if transfer.progress == 0.0 {
ProgressView()
.progressViewStyle(.circular)
.controlSize(.small)
- }
- else {
+ } else {
ProgressView(value: transfer.progress, total: 1.0)
.progressViewStyle(.circular)
.controlSize(.small)
diff --git a/Hotline/macOS/SettingsView.swift b/Hotline/macOS/SettingsView.swift
index 81a2f8d..c67ba85 100644
--- a/Hotline/macOS/SettingsView.swift
+++ b/Hotline/macOS/SettingsView.swift
@@ -3,12 +3,12 @@ import SwiftUI
struct GeneralSettingsView: View {
@State private var username: String = ""
@State private var usernameChanged: Bool = false
-
+
let saveTimer = Timer.publish(every: 5, on: .main, in: .common).autoconnect()
-
+
var body: some View {
@Bindable var preferences = Prefs.shared
-
+
Form {
TextField("Your Name", text: $username, prompt: Text("guest"))
Toggle("Show Join/Leave in Chat", isOn: $preferences.showJoinLeaveMessages)
@@ -49,22 +49,24 @@ struct GeneralSettingsView: View {
struct IconSettingsView: View {
@State private var hoveredUserIconID: Int = -1
-
+
var body: some View {
@Bindable var preferences = Prefs.shared
-
+
Form {
ScrollViewReader { scrollProxy in
ScrollView {
- LazyVGrid(columns: [
- GridItem(.fixed(4+32+4)),
- GridItem(.fixed(4+32+4)),
- GridItem(.fixed(4+32+4)),
- GridItem(.fixed(4+32+4)),
- GridItem(.fixed(4+32+4)),
- GridItem(.fixed(4+32+4)),
- GridItem(.fixed(4+32+4))
- ], spacing: 0) {
+ LazyVGrid(
+ columns: [
+ GridItem(.fixed(4 + 32 + 4)),
+ GridItem(.fixed(4 + 32 + 4)),
+ GridItem(.fixed(4 + 32 + 4)),
+ GridItem(.fixed(4 + 32 + 4)),
+ GridItem(.fixed(4 + 32 + 4)),
+ GridItem(.fixed(4 + 32 + 4)),
+ GridItem(.fixed(4 + 32 + 4)),
+ ], spacing: 0
+ ) {
ForEach(Hotline.classicIconSet, id: \.self) { iconID in
HStack {
Image("Classic/\(iconID)")
@@ -77,7 +79,12 @@ struct IconSettingsView: View {
.tag(iconID)
.frame(width: 32, height: 32)
.padding(4)
- .background(iconID == preferences.userIconID ? Color.accentColor : (iconID == hoveredUserIconID ? Color.accentColor.opacity(0.1) : Color(nsColor: .textBackgroundColor)))
+ .background(
+ iconID == preferences.userIconID
+ ? Color.accentColor
+ : (iconID == hoveredUserIconID
+ ? Color.accentColor.opacity(0.1) : Color(nsColor: .textBackgroundColor))
+ )
.clipShape(RoundedRectangle(cornerRadius: 5))
.onTapGesture {
preferences.userIconID = iconID
@@ -93,7 +100,9 @@ struct IconSettingsView: View {
}
.background(Color(nsColor: .textBackgroundColor))
.clipShape(RoundedRectangle(cornerRadius: 6))
- .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color(nsColor: .separatorColor), lineWidth: 1))
+ .overlay(
+ RoundedRectangle(cornerRadius: 6).stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ )
.onAppear {
scrollProxy.scrollTo(preferences.userIconID, anchor: .center)
}
@@ -107,11 +116,11 @@ struct IconSettingsView: View {
struct SoundSettingsView: View {
var body: some View {
@Bindable var preferences = Prefs.shared
-
+
Form {
Toggle("Enable Sounds", isOn: $preferences.playSounds)
.controlSize(.large)
-
+
Section("Sounds") {
Toggle("Chat", isOn: $preferences.playChatSound)
.disabled(!preferences.playSounds)
@@ -147,7 +156,7 @@ struct SettingsView: View {
private enum Tabs: Hashable {
case general, icon
}
-
+
var body: some View {
TabView {
GeneralSettingsView()
diff --git a/Hotline/macOS/TrackerView.swift b/Hotline/macOS/TrackerView.swift
index 1382200..0943d63 100644
--- a/Hotline/macOS/TrackerView.swift
+++ b/Hotline/macOS/TrackerView.swift
@@ -1,6 +1,6 @@
-import SwiftUI
-import SwiftData
import Foundation
+import SwiftData
+import SwiftUI
import UniformTypeIdentifiers
struct TrackerView: View {
@@ -8,7 +8,7 @@ struct TrackerView: View {
@Environment(\.openWindow) private var openWindow
@Environment(\.controlActiveState) private var controlActiveState
@Environment(\.modelContext) private var modelContext
-
+
@State private var refreshing = false
@State private var trackerSheetPresented: Bool = false
@State private var trackerSheetBookmark: Bookmark? = nil
@@ -16,7 +16,7 @@ struct TrackerView: View {
@State private var fileDropActive = false
@State private var bookmarkExportActive = false
@State private var bookmarkExport: BookmarkDocument? = nil
-
+
@Query(sort: \Bookmark.order) private var bookmarks: [Bookmark]
@Binding var selection: Bookmark?
@@ -32,7 +32,7 @@ struct TrackerView: View {
}
}
.tag(bookmark)
-
+
if bookmark.type == .tracker && bookmark.expanded {
ForEach(bookmark.servers, id: \.self) { trackedServer in
TrackerItemView(bookmark: trackedServer)
@@ -51,7 +51,8 @@ struct TrackerView: View {
}
.onDeleteCommand {
if let bookmark = selection,
- bookmark.type != .temporary {
+ bookmark.type != .temporary
+ {
Bookmark.delete(bookmark, context: modelContext)
}
}
@@ -63,40 +64,42 @@ struct TrackerView: View {
print("Tracker: Already attempted to prepopulate bookmarks")
return
}
-
+
print("Tracker: Prepopulating bookmarks")
-
+
attemptedPrepopulate = true
-
+
// Make sure default bookmarks are there when empty.
Bookmark.populateDefaults(context: modelContext)
}
.onAppear {
-// Bookmark.deleteAll(context: modelContext)
+ // Bookmark.deleteAll(context: modelContext)
}
.contextMenu(forSelectionType: Bookmark.self) { items in
if let item = items.first {
if item.type == .temporary {
Button {
- let newBookmark = Bookmark(type: .server, name: item.name, address: item.address, port: item.port, login: item.login, password: item.password)
+ let newBookmark = Bookmark(
+ type: .server, name: item.name, address: item.address, port: item.port,
+ login: item.login, password: item.password)
Bookmark.add(newBookmark, context: modelContext)
} label: {
Label("Bookmark", systemImage: "bookmark")
}
-
+
Divider()
}
-
+
Button {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(item.displayAddress, forType: .string)
} label: {
Label("Copy Address", systemImage: "doc.on.doc")
}
-
+
if item.type == .tracker || item.type == .server {
Divider()
-
+
if item.type == .tracker {
Button {
trackerSheetBookmark = item
@@ -104,7 +107,7 @@ struct TrackerView: View {
Label("Edit Tracker...", systemImage: "pencil")
}
}
-
+
if item.type == .server {
Button {
bookmarkExport = BookmarkDocument(bookmark: item)
@@ -113,13 +116,14 @@ struct TrackerView: View {
Label("Export Bookmark...", systemImage: "bookmark.square")
}
}
-
+
Divider()
-
+
Button {
Bookmark.delete(item, context: modelContext)
} label: {
- Label(item.type == .tracker ? "Delete Tracker" : "Delete Bookmark", systemImage: "trash")
+ Label(
+ item.type == .tracker ? "Delete Tracker" : "Delete Bookmark", systemImage: "trash")
}
}
}
@@ -127,43 +131,45 @@ struct TrackerView: View {
guard let clickedItem = items.first else {
return
}
-
+
if clickedItem.type == .tracker {
if NSEvent.modifierFlags.contains(.option) {
trackerSheetBookmark = clickedItem
- }
- else {
+ } else {
clickedItem.expanded.toggle()
}
- }
- else if let server = clickedItem.server {
+ } else if let server = clickedItem.server {
openWindow(id: "server", value: server)
}
}
- .fileExporter(isPresented: $bookmarkExportActive, document: bookmarkExport, contentTypes: [.data], defaultFilename: "\(bookmarkExport?.bookmark.name ?? "Hotline Bookmark").hlbm", onCompletion: { result in
- switch result {
- case .success(let fileURL):
- print("Hotline Bookmark: Successfully exported:", fileURL)
- case .failure(let err):
- print("Hotline Bookmark: Failed to export:", err)
- }
-
- bookmarkExport = nil
- bookmarkExportActive = false
- }, onCancellation: {})
+ .fileExporter(
+ isPresented: $bookmarkExportActive, document: bookmarkExport, contentTypes: [.data],
+ defaultFilename: "\(bookmarkExport?.bookmark.name ?? "Hotline Bookmark").hlbm",
+ onCompletion: { result in
+ switch result {
+ case .success(let fileURL):
+ print("Hotline Bookmark: Successfully exported:", fileURL)
+ case .failure(let err):
+ print("Hotline Bookmark: Failed to export:", err)
+ }
+
+ bookmarkExport = nil
+ bookmarkExportActive = false
+ }, onCancellation: {}
+ )
.onKeyPress(.rightArrow) {
- if
- let bookmark = selection,
- bookmark.type == .tracker {
+ if let bookmark = selection,
+ bookmark.type == .tracker
+ {
bookmark.expanded = true
return .handled
}
return .ignored
}
.onKeyPress(.leftArrow) {
- if
- let bookmark = selection,
- bookmark.type == .tracker {
+ if let bookmark = selection,
+ bookmark.type == .tracker
+ {
bookmark.expanded = false
return .handled
}
@@ -174,26 +180,26 @@ struct TrackerView: View {
let _ = provider.loadDataRepresentation(for: UTType.fileURL) { dataRepresentation, err in
// HOTLINE CREATOR CODE: 1213484099
// HOTLINE BOOKMARK TYPE CODE: 1213489773
-
+
if let filePathData = dataRepresentation,
- let filePath = String(data: filePathData, encoding: .utf8),
- let fileURL = URL(string: filePath) {
-
+ let filePath = String(data: filePathData, encoding: .utf8),
+ let fileURL = URL(string: filePath)
+ {
+
print("Hotline Bookmark: Dropped from ", fileURL.path(percentEncoded: false))
-
+
DispatchQueue.main.async {
if let newBookmark = Bookmark(fileURL: fileURL) {
print("Hotline Bookmark: Added bookmark.")
Bookmark.add(newBookmark, context: modelContext)
- }
- else {
+ } else {
print("Hotline Bookmark: Failed to parse.")
}
}
}
}
}
-
+
return true
}
.sheet(item: $trackerSheetBookmark) { item in
@@ -213,7 +219,7 @@ struct TrackerView: View {
.frame(width: 9)
.opacity(controlActiveState == .inactive ? 0.5 : 1.0)
}
-
+
ToolbarItem(placement: .primaryAction) {
Button {
refreshing = true
@@ -225,7 +231,7 @@ struct TrackerView: View {
.disabled(refreshing)
.help("Refresh Trackers")
}
-
+
ToolbarItem(placement: .primaryAction) {
Button {
trackerSheetPresented = true
@@ -234,7 +240,7 @@ struct TrackerView: View {
}
.help("Add Tracker")
}
-
+
ToolbarItem(placement: .primaryAction) {
Button {
openWindow(id: "server")
@@ -250,30 +256,28 @@ struct TrackerView: View {
}
})
}
-
+
func refresh() {
// When a tracker is selected, refresh only that tracker.
- if
- let selectedBookmark = selection,
- selectedBookmark.type == .tracker {
+ if let selectedBookmark = selection,
+ selectedBookmark.type == .tracker
+ {
if !selectedBookmark.expanded {
selectedBookmark.expanded = true
- }
- else {
+ } else {
Task {
await selectedBookmark.fetchServers()
}
}
return
}
-
+
// Otherwise refresh/expand all trackers.
for bookmark in self.bookmarks {
if bookmark.type == .tracker {
if !bookmark.expanded {
bookmark.expanded = true
- }
- else {
+ } else {
Task {
await bookmark.fetchServers()
}
@@ -286,21 +290,21 @@ struct TrackerView: View {
struct TrackerBookmarkSheet: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext
-
+
@State private var bookmark: Bookmark? = nil
@State private var trackerAddress: String = ""
@State private var trackerName: String = ""
-
+
init() {
-
+
}
-
+
init(_ editingBookmark: Bookmark) {
_bookmark = .init(initialValue: editingBookmark)
_trackerAddress = .init(initialValue: editingBookmark.displayAddress)
_trackerName = .init(initialValue: editingBookmark.name)
}
-
+
var body: some View {
VStack(alignment: .leading) {
Text("Type the address and name of a Hotline Tracker:")
@@ -327,11 +331,11 @@ struct TrackerBookmarkSheet: View {
Button(self.bookmark != nil ? "Save Tracker" : "Add Tracker") {
var displayName = trackerName.trimmingCharacters(in: .whitespacesAndNewlines)
let (host, port) = Tracker.parseTrackerAddressAndPort(trackerAddress)
-
+
if displayName.isEmpty {
displayName = host
}
-
+
if !displayName.isEmpty && !host.isEmpty {
if !host.isEmpty {
if self.bookmark != nil {
@@ -339,16 +343,16 @@ struct TrackerBookmarkSheet: View {
self.bookmark?.name = displayName
self.bookmark?.address = host
self.bookmark?.port = port
- }
- else {
+ } else {
// We're creating a new bookmark.
- let newBookmark = Bookmark(type: .tracker, name: displayName, address: host, port: port)
+ let newBookmark = Bookmark(
+ type: .tracker, name: displayName, address: host, port: port)
Bookmark.add(newBookmark, context: modelContext)
}
-
+
self.trackerName = ""
self.trackerAddress = ""
-
+
dismiss()
}
}
@@ -358,7 +362,7 @@ struct TrackerBookmarkSheet: View {
Button("Cancel") {
self.trackerName = ""
self.trackerAddress = ""
-
+
dismiss()
}
}
@@ -368,9 +372,9 @@ struct TrackerBookmarkSheet: View {
struct TrackerItemView: View {
let bookmark: Bookmark
-
+
@State private var onlineAnimationMaxState: Bool = true
-
+
var body: some View {
HStack(alignment: .center, spacing: 6) {
if bookmark.type == .tracker {
@@ -388,7 +392,7 @@ struct TrackerItemView: View {
.padding(.leading, 4)
.padding(.trailing, 2)
}
-
+
switch bookmark.type {
case .tracker:
Image("Tracker")
@@ -445,11 +449,12 @@ struct TrackerItemView: View {
}
Spacer(minLength: 0)
if let serverUserCount = bookmark.serverUserCount,
- serverUserCount > 0 {
+ serverUserCount > 0
+ {
Text(String(serverUserCount))
.foregroundStyle(.secondary)
.lineLimit(1)
-
+
Circle()
.fill(.fileComplete)
.frame(width: 7, height: 7)
@@ -468,7 +473,7 @@ struct TrackerItemView: View {
guard bookmark.type == .tracker else {
return
}
-
+
if bookmark.expanded {
Task {
await bookmark.fetchServers()
@@ -479,15 +484,15 @@ struct TrackerItemView: View {
}
#if DEBUG
-private struct TrackerViewPreview: View {
- @State var selection: Bookmark? = nil
+ private struct TrackerViewPreview: View {
+ @State var selection: Bookmark? = nil
- var body: some View {
- TrackerView(selection: $selection)
+ var body: some View {
+ TrackerView(selection: $selection)
+ }
}
-}
-#Preview {
- TrackerViewPreview()
-}
+ #Preview {
+ TrackerViewPreview()
+ }
#endif