From 45829daa856b376b1ab04d415917110b71b1dec5 Mon Sep 17 00:00:00 2001 From: Ruben Beltran del Rio Date: Wed, 5 Feb 2025 22:27:17 +0100 Subject: Apply formatting --- Hotline/Application-macOS.swift | 73 +-- Hotline/Hotline/HotlineClient.swift | 513 +++++++++++--------- Hotline/Hotline/HotlineDataBuilder.swift | 38 +- Hotline/Hotline/HotlineExtensions.swift | 546 +++++++++++---------- Hotline/Hotline/HotlineProtocol.swift | 712 +++++++++++++++------------- Hotline/Hotline/HotlineTrackerClient.swift | 100 ++-- Hotline/Hotline/HotlineTransferClient.swift | 594 ++++++++++++----------- Hotline/Models/ApplicationState.swift | 6 +- Hotline/Models/Bookmark.swift | 284 +++++------ Hotline/Models/ChatMessage.swift | 17 +- Hotline/Models/FileDetails.swift | 2 +- Hotline/Models/FileInfo.swift | 30 +- Hotline/Models/FilePreview.swift | 51 +- Hotline/Models/Hotline.swift | 699 ++++++++++++++------------- Hotline/Models/NewsInfo.swift | 47 +- Hotline/Models/Preferences.swift | 134 ++++-- Hotline/Models/PreviewFileInfo.swift | 5 +- Hotline/Models/Server.swift | 25 +- Hotline/Models/Tracker.swift | 4 +- Hotline/Models/TransferInfo.swift | 12 +- Hotline/Models/User.swift | 8 +- Hotline/Shared/AsyncLinkPreview.swift | 16 +- Hotline/Shared/DataAdditions.swift | 7 +- Hotline/Shared/FileIconView.swift | 85 ++-- Hotline/Shared/FileImageView.swift | 62 +-- Hotline/Shared/HotlinePanel.swift | 39 +- Hotline/Shared/NetSocket.swift | 155 +++--- Hotline/Shared/TextView.swift | 62 +-- Hotline/Shared/URLAdditions.swift | 9 +- Hotline/Shared/VisualEffectView.swift | 13 +- Hotline/Sounds/Application-iOS.swift | 6 +- Hotline/Utility/BookmarkDocument.swift | 14 +- Hotline/Utility/DAKeychain.swift | 23 +- Hotline/Utility/FoundationExtensions.swift | 28 +- Hotline/Utility/NSWindowBridge.swift | 22 +- Hotline/Utility/ObservableScrollView.swift | 28 +- Hotline/Utility/RegularExpressions.swift | 6 +- Hotline/Utility/SoundEffects.swift | 22 +- Hotline/Utility/SwiftUIExtensions.swift | 4 +- Hotline/Utility/TextDocument.swift | 12 +- Hotline/iOS/ChatView.swift | 28 +- Hotline/iOS/FilesView.swift | 53 +-- Hotline/iOS/MessageBoardView.swift | 8 +- Hotline/iOS/NewsView.swift | 74 +-- Hotline/iOS/ServerView.swift | 12 +- Hotline/iOS/TrackerView.swift | 302 ++++++------ Hotline/iOS/UsersView.swift | 2 +- Hotline/macOS/AboutView.swift | 114 ++--- Hotline/macOS/AccountManagerView.swift | 117 ++--- Hotline/macOS/ChatView.swift | 161 +++---- Hotline/macOS/FileDetailsView.swift | 62 +-- Hotline/macOS/FilePreviewImageView.swift | 24 +- Hotline/macOS/FilePreviewTextView.swift | 22 +- Hotline/macOS/FilesView.swift | 182 ++++--- Hotline/macOS/HotlinePanelView.swift | 110 +++-- Hotline/macOS/MessageBoardEditorView.swift | 60 +-- Hotline/macOS/MessageBoardView.swift | 63 ++- Hotline/macOS/MessageView.swift | 34 +- Hotline/macOS/NewsEditorView.swift | 52 +- Hotline/macOS/NewsItemView.swift | 97 ++-- Hotline/macOS/NewsView.swift | 96 ++-- Hotline/macOS/ServerAgreementView.swift | 60 +-- Hotline/macOS/ServerMessageView.swift | 12 +- Hotline/macOS/ServerView.swift | 346 +++++++------- Hotline/macOS/SettingsView.swift | 47 +- Hotline/macOS/TrackerView.swift | 181 +++---- 66 files changed, 3596 insertions(+), 3236 deletions(-) (limited to 'Hotline') 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(_ expression: (T, DataEndianness)) -> Data { let value = expression.1 == .little ? expression.0.littleEndian : expression.0.bigEndian return withUnsafeBytes(of: value) { Data($0) } } - + static func buildExpression(_ 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.. [UInt8] { var cpy = [UInt8](self) - - - + for i in (0.. 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.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.size) + // append(&val, count: MemoryLayout.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.size) + // append(&val, count: MemoryLayout.size) } - + mutating func appendZeros(count: Int) { - append(contentsOf: Array(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.. 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.. 1 { print("MORE THAN ONE FLAVOR!!") } - + for _ in 0.. 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 - } - -// 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) -// } + + // 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) + // } } 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.. 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..= infoForkDataSize { + if let infoForkDataSize = self.fileCurrentForkHeader?.dataSize, + self.fileBytes.count >= infoForkDataSize + { let infoForkData = self.fileBytes.subdata(in: 0..= self.fileCurrentForkBytesLeft { dataToWrite = self.fileBytes.subdata(in: 0.. 0 { var dataToWrite = self.fileBytes - + if dataToWrite.count >= self.fileCurrentForkBytesLeft { dataToWrite = self.fileBytes.subdata(in: 0.. 0 { var dataToWrite = self.fileBytes - + if dataToWrite.count >= self.fileCurrentForkBytesLeft { dataToWrite = self.fileBytes.subdata(in: 0.. 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.. 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(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() 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.. 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.. 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.. Bool { var fullPath: [String] = [] if path.count > 1 { - fullPath = Array(path[0.. 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..= 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.. 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.. 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.. [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.. Void] = [] - + init(text: Binding) { 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 { 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: View where Content : View { +struct ObservableScrollView: View where Content: View { @Namespace var scrollSpace @Binding var scrollOffset: CGFloat let content: () -> Content - - init(scrollOffset: Binding, - @ViewBuilder content: @escaping () -> Content) { + + init( + scrollOffset: Binding, + @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.. 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(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? @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 -- cgit