From 39f51fd902bb34272c78ffdfb872c22095382f70 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 7 Nov 2025 10:39:48 -0800 Subject: Further cleanup of previous refactor. Removing old view model and NetSocket. Added some empty states for newsgroups and message board. --- Hotline/State/HotlineState.swift | 2468 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 2468 insertions(+) create mode 100644 Hotline/State/HotlineState.swift (limited to 'Hotline/State/HotlineState.swift') diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift new file mode 100644 index 0000000..d5ab438 --- /dev/null +++ b/Hotline/State/HotlineState.swift @@ -0,0 +1,2468 @@ +import SwiftUI + +// MARK: - Connection Status + +enum HotlineConnectionStatus: Equatable { + case disconnected + case connecting + case connected + case loggedIn + case failed(String) + + var isConnected: Bool { + return self == .connected || self == .loggedIn + } +} + +struct FileSearchConfig: Equatable { + /// Number of folders we process before we start applying delay backoff. + var initialBurstCount: Int = 15 + /// Base delay applied between folder requests during the backoff phase. + var initialDelay: TimeInterval = 0.02 + /// Multiplier used to increase the delay after each processed folder in backoff. + var backoffMultiplier: Double = 1.1 + /// Maximum delay cap so searches don't stall out during long walks. + var maxDelay: TimeInterval = 1.0 + /// Maximum recursion depth allowed during file search. + var maxDepth: Int = 40 + /// Limit for repeated folder loops (guards against circular server listings). + var loopRepetitionLimit: Int = 4 + /// Number of child folders that get prioritized after a matching parent is found. + var hotBurstLimit: Int = 2 + /// Maximum age, in seconds, that a cached folder listing is treated as fresh. + var cacheTTL: TimeInterval = 60 * 15 + /// Upper bound on the number of folder listings retained in the cache. + var maxCachedFolders: Int = 1024 * 3 +} + +enum FileSearchStatus: Equatable { + case idle + case searching(processed: Int, pending: Int) + case completed(processed: Int) + case cancelled(processed: Int) + case failed(String) + + var isActive: Bool { + if case .searching = self { + return true + } + return false + } +} + +// MARK: - HotlineState + +@Observable @MainActor +class HotlineState: Equatable { + let id: UUID = UUID() + + nonisolated static func == (lhs: HotlineState, rhs: HotlineState) -> Bool { + return lhs.id == rhs.id + } + + // MARK: - Static Icon Data + + #if os(macOS) + 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)") + } + #endif + + static let classicIconSet: [Int] = [ + 141, 149, 150, 151, 172, 184, 204, + 2013, 2036, 2037, 2055, 2400, 2505, 2534, + 2578, 2592, 4004, 4015, 4022, 4104, 4131, + 4134, 4136, 4169, 4183, 4197, 4240, 4247, + 128, 129, 130, 131, 132, 133, 134, + 135, 136, 137, 138, 139, 140, 142, + 143, 144, 145, 146, 147, 148, 152, + 153, 154, 155, 156, 157, 158, 159, + 160, 161, 162, 163, 164, 165, 166, + 167, 168, 169, 170, 171, 173, 174, + 175, 176, 177, 178, 179, 180, 181, + 182, 183, 185, 186, 187, 188, 189, + 190, 191, 192, 193, 194, 195, 196, + 197, 198, 199, 200, 201, 202, 203, + 205, 206, 207, 208, 209, 212, 214, + 215, 220, 233, 236, 237, 243, 244, + 277, 410, 414, 500, 666, 1250, 1251, + 1968, 1969, 2000, 2001, 2002, 2003, 2004, + 2006, 2007, 2008, 2009, 2010, 2011, 2012, + 2014, 2015, 2016, 2017, 2018, 2019, 2020, + 2021, 2022, 2023, 2024, 2025, 2026, 2027, + 2028, 2029, 2030, 2031, 2032, 2033, 2034, + 2035, 2038, 2040, 2041, 2042, 2043, 2044, + 2045, 2046, 2047, 2048, 2049, 2050, 2051, + 2052, 2053, 2054, 2056, 2057, 2058, 2059, + 2060, 2061, 2062, 2063, 2064, 2065, 2066, + 2067, 2070, 2071, 2072, 2073, 2075, 2079, + 2098, 2100, 2101, 2102, 2103, 2104, 2105, + 2106, 2107, 2108, 2109, 2110, 2112, 2113, + 2115, 2116, 2117, 2118, 2119, 2120, 2121, + 2122, 2123, 2124, 2125, 2126, 4150, 2223, + 2401, 2402, 2403, 2404, 2500, 2501, 2502, + 2503, 2504, 2506, 2507, 2528, 2529, 2530, + 2531, 2532, 2533, 2535, 2536, 2537, 2538, + 2539, 2540, 2541, 2542, 2543, 2544, 2545, + 2546, 2547, 2548, 2549, 2550, 2551, 2552, + 2553, 2554, 2555, 2556, 2557, 2558, 2559, + 2560, 2561, 2562, 2563, 2564, 2565, 2566, + 2567, 2568, 2569, 2570, 2571, 2572, 2573, + 2574, 2575, 2576, 2577, 2579, 2580, 2581, + 2582, 2583, 2584, 2585, 2586, 2587, 2588, + 2589, 2590, 2591, 2593, 2594, 2595, 2596, + 2597, 2598, 2599, 2600, 4000, 4001, 4002, + 4003, 4005, 4006, 4007, 4008, 4009, 4010, + 4011, 4012, 4013, 4014, 4016, 4017, 4018, + 4019, 4020, 4021, 4023, 4024, 4025, 4026, + 4027, 4028, 4029, 4030, 4031, 4032, 4033, + 4034, 4035, 4036, 4037, 4038, 4039, 4040, + 4041, 4042, 4043, 4044, 4045, 4046, 4047, + 4048, 4049, 4050, 4051, 4052, 4053, 4054, + 4055, 4056, 4057, 4058, 4059, 4060, 4061, + 4062, 4063, 4064, 4065, 4066, 4067, 4068, + 4069, 4070, 4071, 4072, 4073, 4074, 4075, + 4076, 4077, 4078, 4079, 4080, 4081, 4082, + 4083, 4084, 4085, 4086, 4087, 4088, 4089, + 4090, 4091, 4092, 4093, 4094, 4095, 4096, + 4097, 4098, 4099, 4100, 4101, 4102, 4103, + 4105, 4106, 4107, 4108, 4109, 4110, 4111, + 4112, 4113, 4114, 4115, 4116, 4117, 4118, + 4119, 4120, 4121, 4122, 4123, 4124, 4125, + 4126, 4127, 4128, 4129, 4130, 4132, 4133, + 4135, 4137, 4138, 4139, 4140, 4141, 4142, + 4143, 4144, 4145, 4146, 4147, 4148, 4149, + 4151, 4152, 4153, 4154, 4155, 4156, 4157, + 4158, 4159, 4160, 4161, 4162, 4163, 4164, + 4165, 4166, 4167, 4168, 4170, 4171, 4172, + 4173, 4174, 4175, 4176, 4177, 4178, 4179, + 4180, 4181, 4182, 4184, 4185, 4186, 4187, + 4188, 4189, 4190, 4191, 4192, 4193, 4194, + 4195, 4196, 4198, 4199, 4200, 4201, 4202, + 4203, 4204, 4205, 4206, 4207, 4208, 4209, + 4210, 4211, 4212, 4213, 4214, 4215, 4216, + 4217, 4218, 4219, 4220, 4221, 4222, 4223, + 4224, 4225, 4226, 4227, 4228, 4229, 4230, + 4231, 4232, 4233, 4234, 4235, 4236, 4238, + 4241, 4242, 4243, 4244, 4245, 4246, 4248, + 4249, 4250, 4251, 4252, 4253, 4254, 31337, + 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 + ] + + // MARK: - Observable State + + var status: HotlineConnectionStatus = .disconnected + var server: Server? { + didSet { + self.updateServerTitle() + } + } + var serverVersion: UInt16 = 123 + var serverName: String? { + didSet { + self.updateServerTitle() + } + } + var serverTitle: String = "Server" + var username: String = "guest" + var iconID: Int = 414 + var access: HotlineUserAccessOptions? + var agreed: Bool = false + + // Users + var users: [User] = [] + + // Chat + var chat: [ChatMessage] = [] + var chatInput: String = "" + var unreadPublicChat: Bool = false + + // Instant Messages + var instantMessages: [UInt16:[InstantMessage]] = [:] + var unreadInstantMessages: [UInt16:UInt16] = [:] + + // Message Board + var messageBoard: [String] = [] + var messageBoardLoaded: Bool = false + + // News + var news: [NewsInfo] = [] + var newsLoaded: Bool = false + private var newsLookup: [String:NewsInfo] = [:] + + // Files + var files: [FileInfo] = [] + var filesLoaded: Bool = false + + // Accounts + var accounts: [HotlineAccount] = [] + var accountsLoaded: Bool = false + + // Banner + #if os(macOS) + var bannerImage: Image? = nil + var bannerColors: ColorArt? = nil + #elseif os(iOS) + var bannerImage: UIImage? = nil + #endif + + // Transfers (now stored globally in AppState) + /// Returns all transfers associated with this server + var transfers: [TransferInfo] { + AppState.shared.transfers.filter { $0.serverID == self.id } + } + + // Legacy transfer tracking (for old delegate-based downloads) +// @ObservationIgnored private var downloads: [HotlineTransferClient] = [] + @ObservationIgnored private var bannerDownloadTask: Task? = nil + + // File Search + var fileSearchResults: [FileInfo] = [] + var fileSearchStatus: FileSearchStatus = .idle + var fileSearchQuery: String = "" + var fileSearchConfig = FileSearchConfig() + var fileSearchScannedFolders: Int = 0 + var fileSearchCurrentPath: [String]? = nil + @ObservationIgnored private var fileSearchSession: HotlineStateFileSearchSession? = nil + @ObservationIgnored private var fileSearchResultKeys: Set = [] + + // File List Cache + private struct FileListCacheEntry { + let files: [FileInfo] + let timestamp: Date + } + @ObservationIgnored private var fileListCache: [String: FileListCacheEntry] = [:] + + // Error Display + var errorDisplayed: Bool = false + var errorMessage: String? = nil + + // MARK: - Private State + + @ObservationIgnored private var client: HotlineClientNew? + @ObservationIgnored private var eventTask: Task? + @ObservationIgnored private var chatSessionKey: ChatStore.SessionKey? + @ObservationIgnored private var restoredChatSessionKey: ChatStore.SessionKey? + @ObservationIgnored private var chatHistoryObserver: NSObjectProtocol? + @ObservationIgnored private var lastPersistedMessageType: ChatMessageType? + + // MARK: - Initialization + + init() { + self.chatHistoryObserver = NotificationCenter.default.addObserver( + forName: ChatStore.historyClearedNotification, + object: nil, + queue: .main + ) { @MainActor [weak self] _ in + self?.handleChatHistoryCleared() + } + } + + deinit { + if let observer = self.chatHistoryObserver { + NotificationCenter.default.removeObserver(observer) + } + } + + // MARK: - Connection + + @MainActor + func login(server: Server, username: String, iconID: Int) async throws { + print("HotlineState.login(): Starting login to \(server.address):\(server.port)") + self.server = server + self.username = username + self.iconID = iconID + self.status = .connecting + print("HotlineState.login(): Status set to connecting") + + // Set up chat session + let key = self.sessionKey(for: server) + self.chatSessionKey = key + self.restoredChatSessionKey = nil + self.lastPersistedMessageType = nil + self.chat = [] + self.restoreChatHistory(for: key) + print("HotlineState.login(): Chat session set up") + + do { + // Connect and login + let loginInfo = HotlineLoginInfo( + login: server.login, + password: server.password, + username: username, + iconID: UInt16(iconID) + ) + + print("HotlineState.login(): Calling HotlineClientNew.connect()...") + let client = try await HotlineClientNew.connect( + host: server.address, + port: UInt16(server.port), + login: loginInfo + ) + print("HotlineState.login(): HotlineClientNew.connect() returned") + + self.client = client + print("HotlineState.login(): Client stored") + + // Get server info + print("HotlineState.login(): Getting server info...") + if let serverInfo = await client.server { + self.serverVersion = serverInfo.version + if !serverInfo.name.isEmpty { + self.serverName = serverInfo.name + } + print("HotlineState.login(): Server info retrieved: \(serverInfo.name) v\(serverInfo.version)") + } + + self.status = .connected + print("HotlineState.login(): Status set to connected") + + // Request initial data before starting event loop + print("HotlineState.login(): Requesting user list...") + try await self.getUserList() + + self.status = .loggedIn + print("HotlineState.login(): Status set to loggedIn") + + if Prefs.shared.playSounds && Prefs.shared.playLoggedInSound { + SoundEffectPlayer.shared.playSoundEffect(.loggedIn) + } + + print("HotlineState.login(): Connected to \(self.serverTitle)") + print("HotlineState.login(): Scheduling post-login tasks...") + + // Defer event loop and post-login work to avoid layout recursion + // This allows login() to return and SwiftUI to complete its layout pass + // before we start receiving events that trigger state changes + Task { @MainActor in + print("HotlineState: Post-login: Starting event loop...") + self.startEventLoop() + + print("HotlineState: Post-login: Sending preferences...") + try? await self.sendUserPreferences() + + print("HotlineState: Post-login: Downloading banner...") + self.downloadBanner() + } + + } catch { + print("HotlineState.login(): Login failed with error: \(error)") + if let client = self.client { + await client.disconnect() + self.client = nil + } + self.status = .failed(error.localizedDescription) + self.errorDisplayed = true + self.errorMessage = error.localizedDescription + throw error + } + } + + /// Disconnect from the server (user-initiated) + @MainActor + func disconnect() async { + print("HotlineState.disconnect(): Called") + guard let client = self.client else { + print("HotlineState.disconnect(): No client, returning") + return + } + + // Stop event loop + print("HotlineState.disconnect(): Cancelling event task...") + self.eventTask?.cancel() + self.eventTask = nil + print("HotlineState.disconnect(): Event task cancelled") + + // Explicitly close the connection + print("HotlineState.disconnect(): Calling client.disconnect()...") + await client.disconnect() + print("HotlineState.disconnect(): client.disconnect() returned") + + // Clean up state + print("HotlineState.disconnect(): Calling handleConnectionClosed()...") + self.handleConnectionClosed() + print("HotlineState.disconnect(): disconnect() complete") + } + + /// Handle connection closure (server-initiated or after user disconnect) + @MainActor + private func handleConnectionClosed() { + print("HotlineState: handleConnectionClosed() entered") + guard self.client != nil else { + print("HotlineState: handleConnectionClosed() - client already nil, returning") + return + } + + print("HotlineState: Handling connection closure - recording chat...") + + // Record disconnect in chat history + if self.status == .loggedIn { + let message = ChatMessage(text: "Disconnected", type: .signOut, date: Date()) + self.recordChatMessage(message, persist: true, display: false) + } + + print("HotlineState: Cancelling banner and downloads...") + + self.bannerDownloadTask?.cancel() + self.bannerDownloadTask = nil + + // Cancel all downloads (both old delegate-based and new async downloads) +// self.downloads = [] + + // Cancel all transfers for this server +// self.cancelAllDownloads() + + // Cancel file search + self.fileSearchSession?.cancel() + self.fileSearchSession = nil + + // Clear client reference + self.client = nil + + print("HotlineState: Resetting state properties...") + + // Reset state immediately (constraint loop was caused by something else) + self.status = .disconnected + self.serverVersion = 123 + self.serverName = nil + self.access = nil + self.agreed = false + self.users = [] + self.chat = [] + self.instantMessages = [:] + self.unreadInstantMessages = [:] + self.unreadPublicChat = false + self.messageBoard = [] + self.messageBoardLoaded = false + self.news = [] + self.newsLoaded = false + self.newsLookup = [:] + self.files = [] + self.filesLoaded = false + self.accounts = [] + self.accountsLoaded = false + self.bannerImage = nil + self.bannerColors = nil + + print("HotlineState: Resetting file search...") + self.resetFileSearchState() + + self.chatSessionKey = nil + self.restoredChatSessionKey = nil + self.lastPersistedMessageType = nil + + print("HotlineState: Disconnected") + } + + @MainActor + func downloadBanner(force: Bool = false) { + guard self.serverVersion >= 150 else { + return + } + + if force { + self.bannerDownloadTask?.cancel() + self.bannerDownloadTask = nil + self.bannerImage = nil + self.bannerColors = nil + } else if self.bannerDownloadTask != nil || self.bannerImage != nil { + return + } + + let task = Task { @MainActor [weak self] in + defer { + self?.bannerDownloadTask = nil + } + + guard let self else { return } + guard let client = self.client, + let server = self.server, + let result = try? await client.downloadBanner(), + let address = server.address as String?, + let port = server.port as Int? + else { + return + } + + do { + print("HotlineState: Banner download info - reference: \(result.referenceNumber), transferSize: \(result.transferSize)") + + let previewClient = HotlineFilePreviewClientNew( + fileName: "banner", + address: address, + port: UInt16(port), + reference: result.referenceNumber, + size: UInt32(result.transferSize) + ) + + let fileURL = try await previewClient.preview() + defer { + previewClient.cleanup() + } + + guard self.client != nil else { return } + + let data = try Data(contentsOf: fileURL) + print("HotlineState: Banner download complete, data size: \(data.count) bytes") + +#if os(macOS) + guard let image = NSImage(data: data) else { + print("HotlineState: Failed to create NSImage from banner data") + return + } + let blah = Image(nsImage: image) +#elseif os(iOS) + guard let image = UIImage(data: data) else { + print("HotlineState: Failed to create UIImage from banner data") + return + } + self.bannerImage = Image(uiImage: image) +#endif + self.bannerImage = blah + self.bannerColors = ColorArt.analyze(image: image) + + } catch { + print("HotlineState: Banner download failed: \(error)") + } + } + + self.bannerDownloadTask = task + } + + // MARK: - Event Loop + + private func startEventLoop() { + print("HotlineState.startEventLoop(): Called") + guard let client = self.client else { + print("HotlineState.startEventLoop(): No client, returning") + return + } + + print("HotlineState.startEventLoop(): Creating event loop task") + self.eventTask = Task { @MainActor [weak self, client] in + guard let self else { + print("HotlineState.startEventLoop(): Self is nil in task, exiting") + return + } + + print("HotlineState.startEventLoop(): Event loop started, awaiting events...") + for await event in client.events { + print("HotlineState.startEventLoop(): Received event: \(event)") + self.handleEvent(event) + } + + // Event stream ended - server disconnected us + print("HotlineState.startEventLoop(): Event stream ended, calling handleConnectionClosed()...") + self.handleConnectionClosed() + print("HotlineState.startEventLoop(): handleConnectionClosed() returned, event loop task complete") + } + print("HotlineState.startEventLoop(): Event loop task created") + } + + @MainActor + private func handleEvent(_ event: HotlineEvent) { + switch event { + case .chatMessage(let text): + self.handleChatMessage(text) + + case .userChanged(let user): + self.handleUserChanged(user) + + case .userDisconnected(let userID): + self.handleUserDisconnected(userID) + + case .serverMessage(let message): + self.handleServerMessage(message) + + case .privateMessage(let userID, let message): + self.handlePrivateMessage(userID: userID, message: message) + + case .newsPost(let message): + self.handleNewsPost(message) + + case .agreementRequired(let text): + let message = ChatMessage(text: text, type: .agreement, date: Date()) + self.recordChatMessage(message, persist: false) + + case .userAccess(let options): + self.access = options + print("HotlineState: Got access options") + HotlineUserAccessOptions.printAccessOptions(options) + } + } + + // MARK: - Chat + + @MainActor + func sendChat(_ text: String, announce: Bool = false) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + try await client.sendChat(text, announce: announce) + } + + @MainActor + func sendInstantMessage(_ text: String, userID: UInt16) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + let message = InstantMessage( + direction: .outgoing, + text: text.convertingLinksToMarkdown(), + type: .message, + date: Date() + ) + + if self.instantMessages[userID] == nil { + self.instantMessages[userID] = [message] + } else { + self.instantMessages[userID]!.append(message) + } + + try await client.sendInstantMessage(text, to: userID) + + if Prefs.shared.playPrivateMessageSound { + SoundEffectPlayer.shared.playSoundEffect(.chatMessage) + } + } + + @MainActor + func sendAgree() async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + try await client.sendAgree() + self.agreed = true + } + + @MainActor + /// Send current user preferences from Prefs to the server + func sendUserPreferences() async throws { + var options: 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("HotlineState.sendUserPreferences(): Updating user info with server") + + try await self.sendUserInfo( + username: Prefs.shared.username, + iconID: Prefs.shared.userIconID, + options: options, + autoresponse: Prefs.shared.automaticMessage + ) + } + + func sendUserInfo(username: String, iconID: Int, options: HotlineUserOptions = [], autoresponse: String? = nil) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + self.username = username + self.iconID = iconID + + try await client.setClientUserInfo( + username: username, + iconID: UInt16(iconID), + options: options, + autoresponse: autoresponse + ) + } + + 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 searchChat(query: String) -> [ChatMessage] { + guard !query.isEmpty else { + return [] + } + + // Create a map of all messages by ID to deduplicate + var messageMap: [UUID: ChatMessage] = [:] + + // Add current in-memory messages + for message in self.chat { + messageMap[message.id] = message + } + + // Filter messages based on query + let filteredMessages = messageMap.values.filter { message in + // Never include agreement messages + if message.type == .agreement { + return false + } + + // Always include disconnect messages to show session boundaries + let isDisconnect = message.type == .signOut + + // Search in text and username + let matchesText = message.text.localizedCaseInsensitiveContains(query) + let matchesUsername = message.username?.localizedCaseInsensitiveContains(query) == true + let matchesQuery = matchesText || matchesUsername + + return isDisconnect || matchesQuery + } + + // Sort by date to maintain chronological order + let sortedMessages = filteredMessages.sorted { $0.date < $1.date } + + // Remove consecutive disconnect messages to avoid visual clutter + var deduplicated: [ChatMessage] = [] + var lastWasDisconnect = false + + for message in sortedMessages { + let isDisconnect = message.type == .signOut + + if isDisconnect && lastWasDisconnect { + continue + } + + deduplicated.append(message) + lastWasDisconnect = isDisconnect + } + + // Remove leading disconnect message + if deduplicated.first?.type == .signOut { + deduplicated.removeFirst() + } + + // Remove trailing disconnect message + if deduplicated.last?.type == .signOut { + deduplicated.removeLast() + } + + return deduplicated + } + + // MARK: - Users + + @MainActor + func getUserList() async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + let hotlineUsers = try await client.getUserList() + self.users = hotlineUsers.map { User(hotlineUser: $0) } + } + + // MARK: - Files (Basic) + + @MainActor + @discardableResult + func getFileList(path: [String] = [], suppressErrors: Bool = false, preferCache: Bool = false) async throws -> [FileInfo] { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + // Check cache first if preferred + if preferCache, let cached = self.cachedFileList(for: path, ttl: self.fileSearchConfig.cacheTTL, allowStale: false) { + return cached.items + } + + let hotlineFiles = try await client.getFileList(path: path) + let newFiles = hotlineFiles.map { FileInfo(hotlineFile: $0) } + + // Update UI state + if path.isEmpty { + self.filesLoaded = true + self.files = newFiles + } else { + // Update parent's children + let parentFile = self.findFile(in: self.files, at: path) + parentFile?.children = newFiles + } + + // Cache the result + self.storeFileListInCache(newFiles, for: path) + + return newFiles + } + + @MainActor + func getFileDetails(_ fileName: String, path: [String]) async throws -> FileDetails? { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + var fullPath: [String] = [] + if path.count > 1 { + fullPath = Array(path[0.. Bool { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + var fullPath: [String] = [] + if path.count > 1 { + fullPath = Array(path[0.. Void)? = nil, complete callback: ((TransferInfo, URL) -> Void)? = nil) { +// guard let client = self.client else { return } +// +// var fullPath: [String] = [] +// if path.count > 1 { +// fullPath = Array(path[0.. Void)? = nil, complete callback: ((TransferInfo) -> Void)? = nil) { + guard let client = self.client else { return } + + var fullPath: [String] = [] + if path.count > 1 { + fullPath = Array(path[0.. Void)? = nil) { +// guard let client = self.client else { return } +// +// var fullPath: [String] = [] +// if path.count > 1 { +// fullPath = Array(path[0.. Void)? = nil, + itemProgress itemProgressCallback: ((TransferInfo, String, Int, Int) -> Void)? = nil, + complete callback: ((TransferInfo) -> Void)? = nil + ) { + guard let client = self.client else { return } + + var fullPath: [String] = [] + if path.count > 1 { + fullPath = Array(path[0.. Void)? = nil, + itemProgress itemProgressCallback: ((TransferInfo, String, Int, Int) -> Void)? = nil, + complete callback: ((TransferInfo) -> Void)? = nil + ) { + guard let client = self.client else { return } + + let folderName = folderURL.lastPathComponent + + guard folderURL.isFileURL, !folderName.isEmpty else { + print("HotlineState: Not a valid folder URL") + return + } + + let folderPath = folderURL.path(percentEncoded: false) + + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: folderPath, isDirectory: &isDirectory), + isDirectory.boolValue == true else { + print("HotlineState: URL is not a folder") + return + } + + // Get the total size of the folder (all files) + guard let (folderSize, fileCount) = FileManager.default.getFolderSize(folderURL) else { + print("HotlineState: Could not determine folder size") + return + } + + print("HotlineState: Requesting upload for folder '\(folderName)' - \(fileCount) items, \(folderSize) bytes total") + + Task { @MainActor [weak self] in + guard let self else { return } + + // Request folder upload from server. + // The enumerator already omits the root folder, so report the full item count the server should expect. + let reportedItemCount = fileCount + print("HotlineState: Reporting \(reportedItemCount) items to server (enumerated count)") + guard let referenceNumber = try? await client.uploadFolder(name: folderName, path: path, fileCount: reportedItemCount, totalSize: UInt32(folderSize)), + let server = self.server, + let address = server.address as String?, + let port = server.port as Int? + else { + print("HotlineState: Failed to get upload reference from server") + return + } + + // Invalidate cache for the upload destination + self.invalidateFileListCache(for: path, includingAncestors: true) + + print("HotlineState: Got folder upload reference: \(referenceNumber)") + + // Create upload client + guard let uploadClient = HotlineFolderUploadClientNew( + folderURL: folderURL, + address: address, + port: UInt16(port), + reference: referenceNumber + ) else { + print("HotlineState: Failed to create folder upload client") + return + } + + // Create transfer info for tracking (stored globally in AppState) + let transfer = TransferInfo( + reference: referenceNumber, + title: folderName, + size: UInt(folderSize), + serverID: self.id, + serverName: self.serverName ?? self.serverTitle + ) + transfer.isFolder = true + transfer.uploadCallback = callback + transfer.progressCallback = progressCallback + AppState.shared.addTransfer(transfer) + + // Create and store the upload task + let uploadTask = Task { @MainActor [weak self] in + guard self != nil else { return } + + do { + // Upload folder with progress tracking + try await uploadClient.upload(progress: { progress in + switch progress { + case .preparing: + break + case .unconnected, .connected, .connecting: + break + case .transfer(name: _, size: _, total: _, progress: let progress, speed: let speed, estimate: let estimate): + transfer.timeRemaining = estimate + transfer.speed = speed + transfer.progress = progress + transfer.progressCallback?(transfer) + case .error(_): + transfer.failed = true + case .completed(url: _): + transfer.completed = true + } + }, itemProgress: { itemInfo in + // Update transfer title with current file being uploaded + transfer.title = "\(itemInfo.fileName) (\(itemInfo.itemNumber)/\(itemInfo.totalItems))" + itemProgressCallback?(transfer, itemInfo.fileName, itemInfo.itemNumber, itemInfo.totalItems) + }) + + // Mark as completed + transfer.progress = 1.0 + transfer.title = folderName // Reset title to folder name + + // Call completion callback + transfer.uploadCallback?(transfer) + + print("HotlineState: Folder upload complete - \(folderName)") + + } catch is CancellationError { + // Upload was cancelled + print("HotlineState: Folder upload cancelled") + } catch { + // Mark as failed + transfer.failed = true + print("HotlineState: Folder upload failed - \(error)") + } + + AppState.shared.unregisterTransferTask(for: transfer.id) + } + + // Store the task in AppState so it can be cancelled later + AppState.shared.registerTransferTask(uploadTask, transferID: transfer.id) + } + } + + func uploadFile(url fileURL: URL, path: [String], complete callback: ((TransferInfo) -> Void)? = nil) { + guard let client = self.client else { return } + + let fileName = fileURL.lastPathComponent + + guard fileURL.isFileURL, !fileName.isEmpty else { + print("HotlineState: Not a valid 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 { + print("HotlineState: File is a directory") + return + } + + // Get the flattened file size (includes all forks and headers) + guard let payloadSize = FileManager.default.getFlattenedFileSize(fileURL) else { + print("HotlineState: Could not determine file size") + return + } + + Task { @MainActor [weak self] in + guard let self else { return } + + // Request upload from server + guard let referenceNumber = try? await client.uploadFile(name: fileName, path: path), + let server = self.server, + let address = server.address as String?, + let port = server.port as Int? + else { + print("HotlineState: Failed to get upload reference from server") + return + } + + // Invalidate cache for the upload destination + self.invalidateFileListCache(for: path, includingAncestors: true) + + print("HotlineState: Got upload reference: \(referenceNumber)") + + // Create upload client + guard let uploadClient = HotlineFileUploadClientNew( + fileURL: fileURL, + address: address, + port: UInt16(port), + reference: referenceNumber + ) else { + print("HotlineState: Failed to create upload client") + return + } + + // Create transfer info for tracking (stored globally in AppState) + let transfer = TransferInfo( + reference: referenceNumber, + title: fileName, + size: UInt(payloadSize), + serverID: self.id, + serverName: self.serverName ?? self.serverTitle + ) + transfer.uploadCallback = callback + AppState.shared.addTransfer(transfer) + + // Create and store the upload task + let uploadTask = Task { @MainActor [weak self] in + guard self != nil else { return } + + do { + // Upload file with progress tracking + try await uploadClient.upload { progress in + switch progress { + case .preparing: + break + case .unconnected, .connected, .connecting: + break + case .transfer(name: _, size: _, total: _, progress: let progress, speed: let speed, estimate: let estimate): + transfer.timeRemaining = estimate + transfer.speed = speed + transfer.progress = progress + case .error(_): + transfer.failed = true + case .completed(url: _): + transfer.completed = true + } + } + + // Mark as completed + transfer.progress = 1.0 + + // Call completion callback + transfer.uploadCallback?(transfer) + + print("HotlineState: Upload complete - \(fileName)") + + } catch is CancellationError { + // Upload was cancelled + print("HotlineState: Upload cancelled") + } catch { + // Mark as failed + transfer.failed = true + print("HotlineState: Upload failed - \(error)") + } + + AppState.shared.unregisterTransferTask(for: transfer.id) + } + + // Store the transfer + AppState.shared.registerTransferTask(uploadTask, transferID: transfer.id) + } + } + + func setFileInfo(fileName: String, path filePath: [String], fileNewName: String?, comment: String?, encoding: String.Encoding = .utf8) { + // TODO: Implement setFileInfo in HotlineClientNew + // This method updates file metadata (name and/or comment) + print("setFileInfo not yet implemented in HotlineState/HotlineClientNew") + } + + @MainActor + func previewFile(_ fileName: String, path: [String], complete callback: ((PreviewFileInfo?) -> Void)? = nil) { + guard let client = self.client else { + callback?(nil) + return + } + + var fullPath: [String] = [] + if path.count > 1 { + fullPath = Array(path[0.. [HotlineAccount] { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + self.accounts = try await client.getAccounts() + self.accountsLoaded = true + return self.accounts + } + + @MainActor + func createUser(name: String, login: String, password: String?, access: UInt64) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + try await client.createUser(name: name, login: login, password: password, access: access) + + // Refresh accounts list + self.accounts = try await client.getAccounts() + } + + @MainActor + func setUser(name: String, login: String, newLogin: String?, password: String?, access: UInt64) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + try await client.setUser(name: name, login: login, newLogin: newLogin, password: password, access: access) + + // Refresh accounts list + self.accounts = try await client.getAccounts() + } + + @MainActor + func deleteUser(login: String) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + try await client.deleteUser(login: login) + + // Refresh accounts list + self.accounts = try await client.getAccounts() + } + + // MARK: - Message Board + + @MainActor + func getMessageBoard() async throws -> [String] { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + self.messageBoard = try await client.getMessageBoard() + self.messageBoardLoaded = true + return self.messageBoard + } + + @MainActor + func postToMessageBoard(text: String) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + try await client.postMessageBoard(text) + } + + // MARK: - News + + @MainActor + func getNewsList(at path: [String] = []) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + 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("HotlineState: Requesting categories at: /\(path.joined(separator: "/"))") + + let categories = try await client.getNewsCategories(path: path) + + // 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("HotlineState: 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 { + print("HotlineState: New category added at \(lookupPath)") + self.newsLookup[lookupPath] = newsCategoryInfo + } + } + + newCategoryInfos.append(newsCategoryInfo) + } + + if let parent = parentNewsGroup { + parent.children = newCategoryInfos + } else if path.isEmpty { + self.newsLoaded = true + self.news = newCategoryInfos + } + } else { + print("HotlineState: Requesting articles at: /\(path.joined(separator: "/"))") + + let articles = try await client.getNewsArticles(path: path) + + print("HotlineState: 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("HotlineState: Merging article into existing article at \(lookupPath)") + + existingArticleInfo.count = newsArticleInfo.count + existingArticleInfo.name = newsArticleInfo.name + existingArticleInfo.path = newsArticleInfo.path + existingArticleInfo.articleUsername = newsArticleInfo.articleUsername + existingArticleInfo.articleDate = newsArticleInfo.articleDate + existingArticleInfo.articleFlavors = newsArticleInfo.articleFlavors + existingArticleInfo.articleID = newsArticleInfo.articleID + newsArticleInfo = existingArticleInfo + } else { + print("HotlineState: 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 + } + } + } + + @MainActor + func getNewsArticle(id articleID: UInt, at path: [String], flavor: String = "text/plain") async throws -> String? { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + return try await client.getNewsArticle(id: UInt32(articleID), path: path, flavor: flavor) + } + + @MainActor + func postNewsArticle(title: String, body: String, at path: [String], parentID: UInt32 = 0) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + try await client.postNewsArticle(title: title, text: body, path: path, parentID: parentID) + print("HotlineState: News article posted") + } + + // MARK: - File Search + + @MainActor + func startFileSearch(query: String) { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + self.cancelFileSearch() + return + } + + self.fileSearchSession?.cancel() + self.resetFileSearchState() + self.fileSearchQuery = trimmed + self.fileSearchStatus = .searching(processed: 0, pending: 0) + self.fileSearchScannedFolders = 0 + self.fileSearchCurrentPath = [] + + let session = HotlineStateFileSearchSession(hotlineState: self, query: trimmed, config: self.fileSearchConfig) + self.fileSearchSession = session + + Task { await session.start() } + } + + @MainActor + func cancelFileSearch(clearResults: Bool = true) { + guard let session = self.fileSearchSession else { + if clearResults { + self.resetFileSearchState() + } else if !self.fileSearchResults.isEmpty { + self.fileSearchStatus = .cancelled(processed: self.fileSearchScannedFolders) + self.fileSearchCurrentPath = nil + } + return + } + + session.cancel() + self.fileSearchSession = nil + self.fileSearchCurrentPath = nil + + if clearResults { + self.resetFileSearchState() + } else { + self.fileSearchStatus = .cancelled(processed: self.fileSearchScannedFolders) + } + } + + @MainActor + func clearFileListCache() { + guard !self.fileListCache.isEmpty else { + return + } + + self.fileListCache.removeAll(keepingCapacity: false) + } + + @MainActor + fileprivate func searchSession(_ session: HotlineStateFileSearchSession, didEmit matches: [FileInfo], processed: Int, pending: Int) { + guard self.fileSearchSession === session else { + return + } + + var appended: [FileInfo] = [] + for match in matches { + let key = self.searchPathKey(for: match.path) + if self.fileSearchResultKeys.insert(key).inserted { + appended.append(match) + } + } + + if !appended.isEmpty { + self.fileSearchResults.append(contentsOf: appended) + } + + self.fileSearchScannedFolders = processed + self.fileSearchStatus = .searching(processed: processed, pending: pending) + } + + @MainActor + fileprivate func searchSession(_ session: HotlineStateFileSearchSession, didFocusOn path: [String]) { + guard self.fileSearchSession === session else { + return + } + + self.fileSearchCurrentPath = path + } + + @MainActor + fileprivate func searchSessionDidFinish(_ session: HotlineStateFileSearchSession, processed: Int, pending: Int, completed: Bool) { + guard self.fileSearchSession === session else { + return + } + + self.fileSearchScannedFolders = processed + self.fileSearchSession = nil + self.fileSearchCurrentPath = nil + + if completed { + self.fileSearchStatus = .completed(processed: processed) + } else { + self.fileSearchStatus = .cancelled(processed: processed) + } + } + + fileprivate func cachedListingForSearch(path: [String], ttl: TimeInterval) -> (items: [FileInfo], isFresh: Bool)? { + self.cachedFileList(for: path, ttl: ttl, allowStale: true) + } + + // MARK: - Event Handlers + + private func handleChatMessage(_ text: String) { + if Prefs.shared.playSounds && Prefs.shared.playChatSound { + SoundEffectPlayer.shared.playSoundEffect(.chatMessage) + } + + let chatMessage = ChatMessage(text: text, type: .message, date: Date()) + self.recordChatMessage(chatMessage) + self.unreadPublicChat = true + } + + private func handleUserChanged(_ user: HotlineUser) { + self.addOrUpdateHotlineUser(user) + } + + private func handleUserDisconnected(_ userID: UInt16) { + if let existingUserIndex = self.users.firstIndex(where: { $0.id == UInt(userID) }) { + let user = self.users.remove(at: existingUserIndex) + + if Prefs.shared.showJoinLeaveMessages { + let chatMessage = ChatMessage(text: "\(user.name) left", type: .left, date: Date()) + self.recordChatMessage(chatMessage) + } + + if Prefs.shared.playSounds && Prefs.shared.playLeaveSound { + SoundEffectPlayer.shared.playSoundEffect(.userLogout) + } + } + } + + private func handleServerMessage(_ message: String) { + if Prefs.shared.playSounds && Prefs.shared.playChatSound { + SoundEffectPlayer.shared.playSoundEffect(.serverMessage) + } + + print("HotlineState: received server message:\n\(message)") + let chatMessage = ChatMessage(text: message, type: .server, date: Date()) + self.recordChatMessage(chatMessage) + } + + private func handlePrivateMessage(userID: UInt16, message: String) { + if let existingUserIndex = self.users.firstIndex(where: { $0.id == UInt(userID) }) { + let user = self.users[existingUserIndex] + print("HotlineState: received private message from \(user.name): \(message)") + + if Prefs.shared.playPrivateMessageSound { + if self.unreadInstantMessages[userID] == nil { + SoundEffectPlayer.shared.playSoundEffect(.serverMessage) + } else { + SoundEffectPlayer.shared.playSoundEffect(.chatMessage) + } + } + + let instantMessage = InstantMessage( + direction: .incoming, + text: message.convertingLinksToMarkdown(), + type: .message, + date: Date() + ) + + if self.instantMessages[userID] == nil { + self.instantMessages[userID] = [instantMessage] + } else { + self.instantMessages[userID]!.append(instantMessage) + } + + self.unreadInstantMessages[userID] = userID + } + } + + private func handleNewsPost(_ message: String) { + let messageBoardRegex = /([\s\r\n]*[_\-]+[\s\r\n]+)/ + let matches = message.matches(of: messageBoardRegex) + + if matches.count == 1 { + let range = matches[0].range + self.messageBoard.insert(String(message[message.startIndex.. ChatStore.SessionKey { + ChatStore.SessionKey(address: server.address.lowercased(), port: server.port) + } + + private func recordChatMessage(_ message: ChatMessage, persist: Bool = true, display: Bool = true) { + let shouldPersist = persist && message.type != .agreement + if shouldPersist, + message.type == .signOut, + self.lastPersistedMessageType == .signOut { + return + } + + if display { + self.chat.append(message) + } + + guard shouldPersist, let key = self.chatSessionKey else { return } + self.lastPersistedMessageType = message.type + + let entry = ChatStore.Entry( + id: message.id, + body: message.text, + username: message.username, + type: message.type.storageKey, + date: message.date + ) + let serverName = self.serverName ?? self.server?.name + + Task { + await ChatStore.shared.append(entry: entry, for: key, serverName: serverName) + } + } + + private func restoreChatHistory(for key: ChatStore.SessionKey) { + if self.restoredChatSessionKey == key { + return + } + + Task { [weak self] in + guard let self else { return } + let result = await ChatStore.shared.loadHistory(for: key) + + await MainActor.run { + guard self.chatSessionKey == key, self.restoredChatSessionKey != key else { return } + + let currentMessages = self.chat + let historyMessages = result.entries.compactMap { entry -> ChatMessage? in + guard let chatType = ChatMessageType(storageKey: entry.type) else { return nil } + + let renderedText: String + if chatType == .message, let username = entry.username, !username.isEmpty { + renderedText = "\(username): \(entry.body)" + } else { + renderedText = entry.body + } + + var message = ChatMessage(text: renderedText, type: chatType, date: entry.date) + message.metadata = entry.metadata + return message + } + + self.chat = historyMessages + currentMessages + self.lastPersistedMessageType = historyMessages.last?.type + self.unreadPublicChat = false + self.restoredChatSessionKey = key + } + } + } + + private func handleChatHistoryCleared() { + self.chat = [] + self.unreadPublicChat = false + self.restoredChatSessionKey = nil + self.lastPersistedMessageType = nil + } + + // MARK: - Utilities + + func updateServerTitle() { + self.serverTitle = self.serverName ?? self.server?.name ?? self.server?.address ?? "Server" + } + + // News helpers + 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] { + if parentArticle.children.firstIndex(of: article) == nil { + article.expanded = true + parentArticle.children.append(article) + } + } else { + organized.append(article) + } + } + + return organized + } + + 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 { + let remainingPath = Array(path[1...]) + return self.findNews(in: news.children, at: remainingPath) + } + } + } + + return nil + } + + // File helpers + 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 { + let remainingPath = Array(path[1...]) + return self.findFile(in: subfiles, at: remainingPath) + } + } + } + + return nil + } + + // File search helpers + private func searchPathKey(for path: [String]) -> String { + path.joined(separator: "\u{001F}") + } + + private func resetFileSearchState() { + self.fileSearchResults = [] + self.fileSearchResultKeys.removeAll(keepingCapacity: true) + self.fileSearchStatus = .idle + self.fileSearchQuery = "" + self.fileSearchScannedFolders = 0 + self.fileSearchCurrentPath = nil + } + + // File cache helpers + private func shouldBypassFileCache(for path: [String]) -> Bool { + guard let folderName = path.last else { + return false + } + + let trimmed = folderName.trimmingCharacters(in: .whitespacesAndNewlines) + + if trimmed.range(of: "upload", options: [.caseInsensitive]) != nil { + return true + } + + if trimmed.range(of: "dropbox", options: [.caseInsensitive]) != nil { + return true + } + + if trimmed.range(of: "drop box", options: [.caseInsensitive]) != nil { + return true + } + + return false + } + + private func cachedFileList(for path: [String], ttl: TimeInterval, allowStale: Bool) -> (items: [FileInfo], isFresh: Bool)? { + guard ttl > 0 else { + return nil + } + + if self.shouldBypassFileCache(for: path) { + return nil + } + + let key = self.searchPathKey(for: path) + guard let entry = self.fileListCache[key] else { + return nil + } + + let age = Date().timeIntervalSince(entry.timestamp) + let isFresh = age <= ttl + if !allowStale && !isFresh { + return nil + } + + return (entry.files, isFresh) + } + + private func storeFileListInCache(_ files: [FileInfo], for path: [String]) { + guard self.fileSearchConfig.cacheTTL > 0 else { + return + } + + if self.shouldBypassFileCache(for: path) { + return + } + + let key = self.searchPathKey(for: path) + self.fileListCache[key] = FileListCacheEntry(files: files, timestamp: Date()) + self.pruneFileListCacheIfNeeded() + } + + private func pruneFileListCacheIfNeeded() { + let limit = self.fileSearchConfig.maxCachedFolders + guard limit > 0, self.fileListCache.count > limit else { + return + } + + let excess = self.fileListCache.count - limit + guard excess > 0 else { return } + + let sortedKeys = self.fileListCache.sorted { lhs, rhs in + lhs.value.timestamp < rhs.value.timestamp + } + + for index in 0.. = [] + private var loopHistogram: [String: Int] = [:] + + private var processedCount: Int = 0 + private var currentDelay: TimeInterval + private var isCancelled = false + + init(hotlineState: HotlineState, query: String, config: FileSearchConfig) { + self.hotlineState = hotlineState + self.queryTokens = query.lowercased().split(separator: " ").map(String.init) + self.config = config + self.currentDelay = config.initialDelay + } + + func start() async { + guard let hotlineState else { + return + } + + await Task.yield() + + if !hotlineState.filesLoaded { + hotlineState.searchSession(self, didFocusOn: []) + let rootFiles = try? await hotlineState.getFileList(path: [], suppressErrors: true, preferCache: true) + self.processedCount = max(self.processedCount, 1) + self.processListing(rootFiles ?? [], depth: 0, parentPath: [], parentIsHot: false) + } else { + hotlineState.searchSession(self, didFocusOn: []) + self.processedCount = max(self.processedCount, 1) + self.processListing(hotlineState.files, depth: 0, parentPath: [], parentIsHot: false) + } + + while !self.queue.isEmpty && !self.isCancelled { + await Task.yield() + + guard let task = self.dequeueNextTask() else { + continue + } + + if self.shouldSkip(path: task.path, depth: task.depth) { + hotlineState.searchSession(self, didEmit: [], processed: self.processedCount, pending: self.queue.count) + continue + } + + hotlineState.searchSession(self, didFocusOn: task.path) + self.visited.insert(self.pathKey(for: task.path)) + + if let cached = hotlineState.cachedListingForSearch(path: task.path, ttl: self.config.cacheTTL) { + if cached.isFresh { + self.processedCount += 1 + self.processListing(cached.items, depth: task.depth, parentPath: task.path, parentIsHot: task.isHot) + continue + } else { + self.processListing(cached.items, depth: task.depth, parentPath: task.path, parentIsHot: task.isHot) + } + } + + let children = try? await hotlineState.getFileList(path: task.path, suppressErrors: true) + self.processedCount += 1 + + if self.isCancelled { + break + } + + self.processListing(children ?? [], depth: task.depth, parentPath: task.path, parentIsHot: task.isHot) + + await self.applyBackoff() + } + + hotlineState.searchSessionDidFinish(self, processed: self.processedCount, pending: self.queue.count, completed: !self.isCancelled) + } + + func cancel() { + self.isCancelled = true + } + + private func processListing(_ items: [FileInfo], depth: Int, parentPath: [String], parentIsHot: Bool) { + guard let hotlineState else { + return + } + + var matches: [FileInfo] = [] + var folderEntries: [(file: FileInfo, isHot: Bool)] = [] + var hasFileMatch = false + + for file in items { + let matchesName = self.nameMatchesQuery(file.name) + + if matchesName { + matches.append(file) + if !file.isFolder { + hasFileMatch = true + } + } + + if file.isFolder && !file.isAppBundle { + folderEntries.append((file, matchesName)) + } + } + + var remainingBurst = 0 + if self.config.hotBurstLimit > 0 && (parentIsHot || hasFileMatch) { + remainingBurst = self.config.hotBurstLimit + } + + if remainingBurst > 0 { + var candidateIndices: [Int] = [] + for index in folderEntries.indices where !folderEntries[index].isHot { + candidateIndices.append(index) + } + + if !candidateIndices.isEmpty { + candidateIndices.shuffle() + for index in candidateIndices { + folderEntries[index].isHot = true + remainingBurst -= 1 + if remainingBurst == 0 { + break + } + } + } + } + + for entry in folderEntries { + self.enqueueFolder(entry.file, depth: depth + 1, markHot: entry.isHot) + } + + hotlineState.searchSession(self, didEmit: matches, processed: self.processedCount, pending: self.queue.count) + } + + private func enqueueFolder(_ folder: FileInfo, depth: Int, markHot: Bool) { + guard !self.isCancelled else { return } + guard depth <= self.config.maxDepth else { return } + + let path = folder.path + let key = self.pathKey(for: path) + guard !self.visited.contains(key) else { return } + + if self.exceedsLoopThreshold(for: path) { + return + } + + self.queue.append(FolderTask(path: path, depth: depth, isHot: markHot)) + } + + private func dequeueNextTask() -> FolderTask? { + guard !self.queue.isEmpty else { + return nil + } + + if self.queue.count == 1 { + return self.queue.removeFirst() + } + + let currentDepth = self.queue[0].depth + var lastSameDepthIndex = 0 + var hotIndices: [Int] = [] + + for index in 0.. Bool { + if self.isCancelled { + return true + } + + if depth > self.config.maxDepth { + return true + } + + let key = self.pathKey(for: path) + if self.visited.contains(key) { + return true + } + + return false + } + + private func nameMatchesQuery(_ name: String) -> Bool { + guard !self.queryTokens.isEmpty else { return false } + let lowercased = name.lowercased() + return self.queryTokens.allSatisfy { lowercased.contains($0) } + } + + private func exceedsLoopThreshold(for path: [String]) -> Bool { + guard self.config.loopRepetitionLimit > 0 else { return false } + guard let last = path.last else { return false } + let parent = path.dropLast() + + guard let previousIndex = parent.lastIndex(of: last) else { + return false + } + + let suffix = Array(path[previousIndex...]) + let key = suffix.joined(separator: "\u{001F}") + let count = (self.loopHistogram[key] ?? 0) + 1 + self.loopHistogram[key] = count + return count > self.config.loopRepetitionLimit + } + + private func pathKey(for path: [String]) -> String { + path.joined(separator: "\u{001F}") + } + + private func applyBackoff() async { + guard !self.isCancelled else { return } + + if self.processedCount > self.config.initialBurstCount { + self.currentDelay = min(self.config.maxDelay, max(self.config.initialDelay, self.currentDelay * self.config.backoffMultiplier)) + } + + guard self.currentDelay > 0 else { + return + } + + let nanoseconds = UInt64(self.currentDelay * 1_000_000_000) + try? await Task.sleep(nanoseconds: nanoseconds) + } +} -- cgit From 637cd9af54a58db0c5dc2062b6c20291afd31147 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 7 Nov 2025 17:40:59 -0800 Subject: Add Kingfisher dependency. Add support for animated GIF banners. Add fast image format detection to Data. --- Hotline.xcodeproj/project.pbxproj | 17 ++++++ .../xcshareddata/swiftpm/Package.resolved | 11 +++- .../Transfers/HotlineFilePreviewClientNew.swift | 9 +-- Hotline/Library/Extensions.swift | 36 ++++++++++++ Hotline/State/HotlineState.swift | 14 ++++- Hotline/macOS/HotlinePanelView.swift | 64 +++++++++++++++++++--- 6 files changed, 130 insertions(+), 21 deletions(-) (limited to 'Hotline/State/HotlineState.swift') diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index 2861aab..cac321c 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -37,6 +37,7 @@ DA501BE72EBE9542001714F8 /* TrackerBookmarkSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA501BE62EBE9542001714F8 /* TrackerBookmarkSheet.swift */; }; DA501BE92EBE9589001714F8 /* TrackerItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA501BE82EBE9589001714F8 /* TrackerItemView.swift */; }; DA501BEB2EBE95B4001714F8 /* TrackerBookmarkServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA501BEA2EBE95B4001714F8 /* TrackerBookmarkServerView.swift */; }; + DA501BEE2EBEC42E001714F8 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = DA501BED2EBEC42E001714F8 /* Kingfisher */; }; DA52689C2EB0738B00DCB941 /* GeneralSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA52689B2EB0738B00DCB941 /* GeneralSettingsView.swift */; platformFilters = (macos, ); }; DA52689E2EB073A400DCB941 /* IconSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA52689D2EB073A400DCB941 /* IconSettingsView.swift */; platformFilters = (macos, ); }; DA5268A02EB073BC00DCB941 /* SoundSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA52689F2EB073BC00DCB941 /* SoundSettingsView.swift */; platformFilters = (macos, ); }; @@ -227,6 +228,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + DA501BEE2EBEC42E001714F8 /* Kingfisher in Frameworks */, DACCE5EF2EAC19C9008CDD92 /* SwiftUIIntrospect in Frameworks */, DA72A0E02B4DA8CA00A0F48A /* SplitView in Frameworks */, DAB4D8872B4CB3610048A05C /* MarkdownUI in Frameworks */, @@ -551,6 +553,7 @@ DAB4D8862B4CB3610048A05C /* MarkdownUI */, DA72A0DF2B4DA8CA00A0F48A /* SplitView */, DACCE5EE2EAC19C9008CDD92 /* SwiftUIIntrospect */, + DA501BED2EBEC42E001714F8 /* Kingfisher */, ); productName = Hotline; productReference = DA9CAFB72B126D5700CDA197 /* Hotline.app */; @@ -584,6 +587,7 @@ DAB4D8852B4CB3610048A05C /* XCRemoteSwiftPackageReference "swift-markdown-ui" */, DA72A0DE2B4DA8CA00A0F48A /* XCRemoteSwiftPackageReference "SplitView" */, DACCE5ED2EAC19C9008CDD92 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */, + DA501BEC2EBEC42E001714F8 /* XCRemoteSwiftPackageReference "Kingfisher" */, ); productRefGroup = DA9CAFB82B126D5700CDA197 /* Products */; projectDirPath = ""; @@ -934,6 +938,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + DA501BEC2EBEC42E001714F8 /* XCRemoteSwiftPackageReference "Kingfisher" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/onevcat/Kingfisher"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 8.6.1; + }; + }; DA72A0DE2B4DA8CA00A0F48A /* XCRemoteSwiftPackageReference "SplitView" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/stevengharris/SplitView"; @@ -961,6 +973,11 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + DA501BED2EBEC42E001714F8 /* Kingfisher */ = { + isa = XCSwiftPackageProductDependency; + package = DA501BEC2EBEC42E001714F8 /* XCRemoteSwiftPackageReference "Kingfisher" */; + productName = Kingfisher; + }; DA72A0DF2B4DA8CA00A0F48A /* SplitView */ = { isa = XCSwiftPackageProductDependency; package = DA72A0DE2B4DA8CA00A0F48A /* XCRemoteSwiftPackageReference "SplitView" */; diff --git a/Hotline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Hotline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index f112b32..3084548 100644 --- a/Hotline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Hotline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "26db0d070cfc8e002044ea1380ec5e675c0718ebf212f6e8a57f12cc6c295723", + "originHash" : "590f8ee2c1318c670012f5d30a51419d3387a50648e0dd04f9654da2b1b7cef5", "pins" : [ + { + "identity" : "kingfisher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/onevcat/Kingfisher", + "state" : { + "revision" : "4d75de347da985a70c63af4d799ed482021f6733", + "version" : "8.6.1" + } + }, { "identity" : "networkimage", "kind" : "remoteSourceControl", diff --git a/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift b/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift index 63fe099..93a68fc 100644 --- a/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift @@ -31,14 +31,7 @@ public class HotlineFilePreviewClientNew { self.transferSize = size } - deinit { - // Cleanup in deinit - must be synchronous - if let tempURL = temporaryFileURL { - try? FileManager.default.removeItem(at: tempURL) - } - } - - // MARK: - Public API + // MARK: - /// Download file to temporary location for preview /// - Parameter progressHandler: Optional progress callback diff --git a/Hotline/Library/Extensions.swift b/Hotline/Library/Extensions.swift index 469676c..b6a4b68 100644 --- a/Hotline/Library/Extensions.swift +++ b/Hotline/Library/Extensions.swift @@ -22,6 +22,42 @@ extension Data { } return false } + + enum ImageFormat { + case gif + case jpeg + case png + case webp + case unknown + } + + var detectedImageFormat: ImageFormat { + guard self.count >= 12 else { return .unknown } + + let bytes = [UInt8](self.prefix(12)) + + // GIF: "GIF8" + if bytes[0] == 0x47 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x38 { + return .gif + } + + // JPEG: FF D8 FF + if bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF { + return .jpeg + } + + // PNG: 89 50 4E 47 0D 0A 1A 0A + if bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47 && bytes[4] == 0x0D && bytes[5] == 0x0A && bytes[6] == 0x1A && bytes[7] == 0x0A { + return .png + } + + // WebP: "RIFF" at 0-3 and "WEBP" at 8-11 + if bytes[0] == 0x52 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x46 && bytes[8] == 0x57 && bytes[9] == 0x45 && bytes[10] == 0x42 && bytes[11] == 0x50 { + return .webp + } + + return .unknown + } } // MARK: - diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index d5ab438..4b74df7 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -206,6 +206,8 @@ class HotlineState: Equatable { var accountsLoaded: Bool = false // Banner + var bannerFileURL: URL? = nil + var bannerImageFormat: Data.ImageFormat = .unknown #if os(macOS) var bannerImage: Image? = nil var bannerColors: ColorArt? = nil @@ -471,8 +473,10 @@ class HotlineState: Equatable { self.bannerDownloadTask?.cancel() self.bannerDownloadTask = nil self.bannerImage = nil + self.bannerImageFormat = .unknown + self.bannerFileURL = nil self.bannerColors = nil - } else if self.bannerDownloadTask != nil || self.bannerImage != nil { + } else if self.bannerDownloadTask != nil || self.bannerFileURL != nil { return } @@ -503,13 +507,16 @@ class HotlineState: Equatable { ) let fileURL = try await previewClient.preview() - defer { - previewClient.cleanup() + + if let oldFileURL = self.bannerFileURL { + try? FileManager.default.removeItem(at: oldFileURL) } guard self.client != nil else { return } let data = try Data(contentsOf: fileURL) + self.bannerImageFormat = data.detectedImageFormat + print("HotlineState: Banner download complete, data size: \(data.count) bytes") #if os(macOS) @@ -525,6 +532,7 @@ class HotlineState: Equatable { } self.bannerImage = Image(uiImage: image) #endif + self.bannerFileURL = fileURL self.bannerImage = blah self.bannerColors = ColorArt.analyze(image: image) diff --git a/Hotline/macOS/HotlinePanelView.swift b/Hotline/macOS/HotlinePanelView.swift index 81c24b7..220ea97 100644 --- a/Hotline/macOS/HotlinePanelView.swift +++ b/Hotline/macOS/HotlinePanelView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Kingfisher struct HotlinePanelView: View { @Environment(\.openWindow) var openWindow @@ -20,19 +21,25 @@ struct HotlinePanelView: View { private var backgroundColor: Color { Color(nsColor: self.activeHotline?.bannerColors?.backgroundColor ?? NSColor.controlBackgroundColor) } + + private var bannerFileURL: URL? { + self.activeHotline?.bannerFileURL + } + + private var bannerIsAnimated: Bool { + self.activeHotline?.bannerImageFormat == .gif + } var body: some View { VStack(spacing: 0) { - self.bannerImage - .interpolation(.high) - .resizable() - .scaledToFill() - .frame(width: 468, height: 60) - .frame(minWidth: 468, maxWidth: 468, minHeight: 60, maxHeight: 60) - .clipped() - .background(.black) - .animation(.default, value: self.bannerImage) + self.bannerView + .id("banner image view") + .animation(.default, value: self.bannerFileURL) + .background { + Color.black + } + HStack(spacing: 12) { Button { if NSEvent.modifierFlags.contains(.option) { @@ -170,6 +177,45 @@ struct HotlinePanelView: View { // .cornerRadius(10.0) // ) } + + private var bannerView: some View { + ZStack { + if self.bannerIsAnimated { + KFAnimatedImage + .url(self.bannerFileURL) + .placeholder { + Image("Default Banner") + } + .cacheMemoryOnly() + .cacheOriginalImage() + .scaledToFill() + .frame(width: 468, height: 60) + .frame(minWidth: 468, maxWidth: 468, minHeight: 60, maxHeight: 60) + .clipped() + .transition(.opacity) + .id("animated banner \(self.bannerFileURL?.absoluteString ?? "")") + } + else { + KFImage + .url(self.bannerFileURL) + .resizable() + .interpolation(.high) + .placeholder { + Image("Default Banner") + } + .cacheMemoryOnly() + .cacheOriginalImage() + .scaledToFill() + .frame(width: 468, height: 60) + .frame(minWidth: 468, maxWidth: 468, minHeight: 60, maxHeight: 60) + .clipped() + .transition(.opacity) + .id("static banner \(self.bannerFileURL?.absoluteString ?? "")") + } + } + .animation(.default, value: self.bannerIsAnimated) + .animation(.default, value: self.bannerFileURL) + } } #Preview { -- cgit From 206e4bbdaba85062a38b32c16a9937ab42cddb47 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Sat, 8 Nov 2025 17:21:54 -0800 Subject: Improve sound effect management and use NSSound on macOS. --- Hotline/Library/SoundEffects.swift | 51 ++++++++++++++++---------------------- Hotline/State/HotlineState.swift | 18 +++++++------- 2 files changed, 30 insertions(+), 39 deletions(-) (limited to 'Hotline/State/HotlineState.swift') diff --git a/Hotline/Library/SoundEffects.swift b/Hotline/Library/SoundEffects.swift index 93f8b9a..4964b94 100644 --- a/Hotline/Library/SoundEffects.swift +++ b/Hotline/Library/SoundEffects.swift @@ -1,7 +1,7 @@ import Foundation -import AVFAudio +import AppKit -enum SoundEffects: String { +enum SoundEffect: String { case loggedIn = "logged-in" case chatMessage = "chat-message" case transferComplete = "transfer-complete" @@ -10,41 +10,32 @@ enum SoundEffects: String { case newNews = "new-news" case serverMessage = "server-message" case error = "error" + + static var all: [SoundEffect] = [.loggedIn, .chatMessage, .transferComplete, .userLogin, .userLogout, .newNews, .serverMessage, .error] } @Observable -class SoundEffectPlayer: NSObject, AVAudioPlayerDelegate { - static let shared = SoundEffectPlayer() +class SoundEffects { + static let shared = SoundEffects() - private var activeSounds: [AVAudioPlayer] = [] + private var preloadedSounds: [SoundEffect: NSSound] = [:] - func playSoundEffect(_ name: SoundEffects) { - // Load a local sound file - 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) + private init() { + // Preload sound effects + for effect in SoundEffect.all { + if let soundFileURL = Bundle.main.url(forResource: effect.rawValue, withExtension: "aiff"), + let sound = NSSound(contentsOf: soundFileURL, byReference: true) { + sound.volume = 0.75 + self.preloadedSounds[effect] = sound } } } - - func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { - if let i = self.activeSounds.firstIndex(of: player) { - self.activeSounds.remove(at: i) - } + + static func play(_ name: SoundEffect) { + Self.shared.play(name) + } + + func play(_ name: SoundEffect) { + self.preloadedSounds[name]?.play() } } diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index 4b74df7..4e28af8 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -334,7 +334,7 @@ class HotlineState: Equatable { print("HotlineState.login(): Status set to loggedIn") if Prefs.shared.playSounds && Prefs.shared.playLoggedInSound { - SoundEffectPlayer.shared.playSoundEffect(.loggedIn) + SoundEffects.play(.loggedIn) } print("HotlineState.login(): Connected to \(self.serverTitle)") @@ -639,7 +639,7 @@ class HotlineState: Equatable { try await client.sendInstantMessage(text, to: userID) if Prefs.shared.playPrivateMessageSound { - SoundEffectPlayer.shared.playSoundEffect(.chatMessage) + SoundEffects.play(.chatMessage) } } @@ -1776,7 +1776,7 @@ class HotlineState: Equatable { private func handleChatMessage(_ text: String) { if Prefs.shared.playSounds && Prefs.shared.playChatSound { - SoundEffectPlayer.shared.playSoundEffect(.chatMessage) + SoundEffects.play(.chatMessage) } let chatMessage = ChatMessage(text: text, type: .message, date: Date()) @@ -1798,14 +1798,14 @@ class HotlineState: Equatable { } if Prefs.shared.playSounds && Prefs.shared.playLeaveSound { - SoundEffectPlayer.shared.playSoundEffect(.userLogout) + SoundEffects.play(.userLogout) } } } private func handleServerMessage(_ message: String) { if Prefs.shared.playSounds && Prefs.shared.playChatSound { - SoundEffectPlayer.shared.playSoundEffect(.serverMessage) + SoundEffects.play(.serverMessage) } print("HotlineState: received server message:\n\(message)") @@ -1820,9 +1820,9 @@ class HotlineState: Equatable { if Prefs.shared.playPrivateMessageSound { if self.unreadInstantMessages[userID] == nil { - SoundEffectPlayer.shared.playSoundEffect(.serverMessage) + SoundEffects.play(.serverMessage) } else { - SoundEffectPlayer.shared.playSoundEffect(.chatMessage) + SoundEffects.play(.chatMessage) } } @@ -1854,7 +1854,7 @@ class HotlineState: Equatable { self.messageBoard.insert(message, at: 0) } - SoundEffectPlayer.shared.playSoundEffect(.newNews) + SoundEffects.play(.newNews) } // MARK: - User Management @@ -1868,7 +1868,7 @@ class HotlineState: Equatable { } else { if !self.users.isEmpty { if Prefs.shared.playSounds && Prefs.shared.playJoinSound { - SoundEffectPlayer.shared.playSoundEffect(.userLogin) + SoundEffects.play(.userLogin) } } -- cgit From 6cfa1db09055a3b4ea64d3586a0b439d13498db6 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Sat, 8 Nov 2025 22:40:58 -0800 Subject: Fixing cancel transfer from transfer window. Transfer window ux improvements. --- Hotline/Hotline/HotlineExtensions.swift | 4 +- Hotline/Hotline/HotlineTransferClient.swift | 26 +-- .../Transfers/HotlineFileDownloadClientNew.swift | 79 ++++--- .../Transfers/HotlineFileUploadClientNew.swift | 8 +- .../Transfers/HotlineFolderDownloadClientNew.swift | 14 +- .../Transfers/HotlineFolderUploadClientNew.swift | 20 +- Hotline/Models/TransferInfo.swift | 5 + Hotline/State/AppState.swift | 29 +++ Hotline/State/HotlineState.swift | 124 +--------- Hotline/macOS/TransfersView.swift | 255 +++++++++++++++------ 10 files changed, 303 insertions(+), 261 deletions(-) (limited to 'Hotline/State/HotlineState.swift') diff --git a/Hotline/Hotline/HotlineExtensions.swift b/Hotline/Hotline/HotlineExtensions.swift index 049ebf9..6782a41 100644 --- a/Hotline/Hotline/HotlineExtensions.swift +++ b/Hotline/Hotline/HotlineExtensions.swift @@ -432,11 +432,11 @@ extension FileManager { attributes[.modificationDate] = infoFork.modifiedDate as NSDate guard self.createFile(atPath: url.path, contents: nil, attributes: attributes) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } guard let handle = FileHandle(forWritingAtPath: url.path) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } return handle diff --git a/Hotline/Hotline/HotlineTransferClient.swift b/Hotline/Hotline/HotlineTransferClient.swift index 156b2b0..596155b 100644 --- a/Hotline/Hotline/HotlineTransferClient.swift +++ b/Hotline/Hotline/HotlineTransferClient.swift @@ -2,9 +2,20 @@ import Foundation import Network import UniformTypeIdentifiers -enum HotlineFileClientError: Error { +protocol HotlineTransferClient { +// var serverAddress: NWEndpoint.Host { get } +// var serverPort: NWEndpoint.Port { get } +// var referenceNumber: UInt32 { get } +// var status: HotlineTransferStatus { get set } + +// func start() + func cancel() +} + +enum HotlineTransferClientError: Error { case failedToConnect case failedToTransfer + case cancelled } enum HotlineFileFork { @@ -22,7 +33,7 @@ enum HotlineTransferStatus: Equatable { case progress(Double) case completing case completed - case failed(HotlineFileClientError) + case failed(HotlineTransferClientError) } enum HotlineFileForkType: UInt32 { @@ -39,17 +50,6 @@ public enum HotlineFolderAction: UInt16 { case nextFile = 3 } -protocol HotlineTransferClient { - var serverAddress: NWEndpoint.Host { get } - var serverPort: NWEndpoint.Port { get } - var referenceNumber: UInt32 { get } - var status: HotlineTransferStatus { get set } - - func start() - func cancel() -} - - struct HotlineFileHeader { static let DataSize: Int = 4 + 2 + 16 + 2 diff --git a/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift index 77d2e38..981965a 100644 --- a/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift @@ -6,7 +6,6 @@ public enum HotlineDownloadLocation: Sendable { case downloads(String) // filename } - public enum HotlineTransferProgress: Sendable { case error(Error) // An error occurred case unconnected // Initial state @@ -19,7 +18,7 @@ public enum HotlineTransferProgress: Sendable { /// Modern async/await file download client for Hotline protocol @MainActor -public class HotlineFileDownloadClientNew { +public class HotlineFileDownloadClientNew: @MainActor HotlineTransferClient { // MARK: - Configuration public struct Configuration: Sendable { @@ -61,11 +60,11 @@ public class HotlineFileDownloadClientNew { self.transferProgress = Progress(totalUnitCount: Int64(self.transferTotal)) } - // MARK: - Public API + // MARK: - API public func download( to location: HotlineDownloadLocation, - progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil + progress progressHandler: (@Sendable (HotlineTransferProgress) throws -> Void)? = nil ) async throws -> URL { self.downloadTask?.cancel() @@ -78,49 +77,50 @@ public class HotlineFileDownloadClientNew { let url = try await task.value self.downloadTask = nil return url - } catch { - print("FAILED TO DOWNLOAD!", error) + } + catch { self.downloadTask = nil - progressHandler?(.error(error)) + try? progressHandler?(.error(error)) throw error } } /// Cancel the current download public func cancel() { - downloadTask?.cancel() - downloadTask = nil - - if let socket = socket { - Task { - await socket.close() - } - } + self.downloadTask?.cancel() + self.downloadTask = nil } // MARK: - Private Implementation - private func updateProgress(sent: Int) { + private func updateProgress(sent: Int) throws { self.transferSize = sent self.transferProgress.completedUnitCount = Int64(sent) + try self.checkCancelled() + } + + private func checkCancelled() throws { + if Task.isCancelled { + throw CancellationError() + } // People can cancel a transfer from the file icon in the Finder. // This code handles that. if self.transferProgress.isCancelled { - self.cancel() + throw CancellationError() } } private func performDownload( to destination: HotlineDownloadLocation, - progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? + progressHandler: (@Sendable (HotlineTransferProgress) throws -> Void)? ) async throws -> URL { let fm = FileManager.default var fileHandle: FileHandle? var resourceForkData: Data? - progressHandler?(.preparing) + try progressHandler?(.preparing) // Determine the download name // Determine destination URL based on location @@ -137,12 +137,16 @@ public class HotlineFileDownloadClientNew { destinationFilename = destinationURL.lastPathComponent } - progressHandler?(.connecting) + try self.checkCancelled() + try progressHandler?(.connecting) // Connect to transfer server let socket = try await connectToTransferServer() self.socket = socket - defer {Task { await socket.close() } } + defer { Task { await socket.close() } } + + // See if we've been cancelled + try self.checkCancelled() // Send magic header try await socket.write(Data(endian: .big) { @@ -155,11 +159,11 @@ public class HotlineFileDownloadClientNew { // Read file header let headerData = try await socket.read(HotlineFileHeader.DataSize) guard let header = HotlineFileHeader(from: headerData) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } // Connected - progressHandler?(.connected) + try progressHandler?(.connected) do { // Process each fork @@ -167,7 +171,7 @@ public class HotlineFileDownloadClientNew { // Read fork header let forkHeaderData = try await socket.read(HotlineFileForkHeader.DataSize) guard let forkHeader = HotlineFileForkHeader(from: forkHeaderData) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } // Handle whichever fork is being sent. @@ -175,7 +179,7 @@ public class HotlineFileDownloadClientNew { // Read info fork let infoData = try await socket.read(Int(forkHeader.dataSize)) guard let info = HotlineFileInfoFork(from: infoData) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } self.transferSize += infoData.count @@ -191,35 +195,35 @@ public class HotlineFileDownloadClientNew { self.transferProgress.publish() // Update progress - self.updateProgress(sent: infoData.count) + try self.updateProgress(sent: infoData.count) } else if forkHeader.isDataFork { guard let fh = fileHandle else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } // Stream data fork to disk let updates = await socket.receiveFile(to: fh, length: Int(forkHeader.dataSize)) for try await p in updates { - self.updateProgress(sent: p.sent) - progressHandler?(.transfer(name: destinationFilename, size: self.transferSize, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining)) + try self.updateProgress(sent: p.sent) + try progressHandler?(.transfer(name: destinationFilename, size: self.transferSize, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining)) } } else if forkHeader.isResourceFork { // Read resource fork into memory let rsrcData = try await socket.read(Int(forkHeader.dataSize)) resourceForkData = rsrcData - self.updateProgress(sent: Int(rsrcData.count)) + try self.updateProgress(sent: Int(rsrcData.count)) } else { // Skip unsupported fork let dataSize = Int(forkHeader.dataSize) try await socket.skip(dataSize) - self.updateProgress(sent: dataSize) + try self.updateProgress(sent: dataSize) } - progressHandler?(.transfer(name: destinationFilename, size: self.transferSize, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: nil, estimate: nil)) + try progressHandler?(.transfer(name: destinationFilename, size: self.transferSize, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: nil, estimate: nil)) } self.transferProgress.unpublish() @@ -232,18 +236,22 @@ public class HotlineFileDownloadClientNew { if let rsrcData = resourceForkData, !rsrcData.isEmpty { try writeResourceFork(data: rsrcData, to: destinationURL) } + + // See if we've been cancelled + try self.checkCancelled() - progressHandler?(.completed(url: destinationURL)) + try progressHandler?(.completed(url: destinationURL)) return destinationURL - } catch { + } + catch { // Cleanup on failure try? fileHandle?.close() try? fm.removeItem(at: destinationURL) self.transferProgress.unpublish() - progressHandler?(.error(error)) + try? progressHandler?(.error(error)) throw error } @@ -289,3 +297,4 @@ public class HotlineFileDownloadClientNew { print("HotlineFileDownloadClientNew[\(referenceNumber)]: Wrote resource fork (\(data.count) bytes)") } } + diff --git a/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift index 5acff2a..3bf3339 100644 --- a/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift @@ -2,7 +2,7 @@ import Foundation import Network @MainActor -public class HotlineFileUploadClientNew { +public class HotlineFileUploadClientNew: @MainActor HotlineTransferClient { // MARK: - Configuration public struct Configuration: Sendable { @@ -126,15 +126,15 @@ public class HotlineFileUploadClientNew { // Get file metadata guard let infoFork = HotlineFileInfoFork(file: fileURL) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } guard let header = HotlineFileHeader(file: fileURL) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } guard let forkSizes = try? FileManager.default.getFileForkSizes(fileURL) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } let infoForkData = infoFork.data() diff --git a/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift index 254a1c6..8303d11 100644 --- a/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift @@ -9,7 +9,7 @@ public struct HotlineFolderItemProgress: Sendable { } @MainActor -public class HotlineFolderDownloadClientNew { +public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient { // MARK: - Properties private let serverAddress: String @@ -155,7 +155,7 @@ public class HotlineFolderDownloadClientNew { totalBytesTransferred += 2 + headerLen guard let (itemType, pathComponents) = parseItemHeaderPath(headerData) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } let joinedPath = pathComponents.joined(separator: "/") @@ -313,7 +313,7 @@ public class HotlineFolderDownloadClientNew { // Read file header let headerData = try await socket.read(HotlineFileHeader.DataSize) guard let header = HotlineFileHeader(from: headerData) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } bytesRead += HotlineFileHeader.DataSize @@ -337,7 +337,7 @@ public class HotlineFolderDownloadClientNew { // Read fork header let forkHeaderData = try await socket.read(HotlineFileForkHeader.DataSize) guard let forkHeader = HotlineFileForkHeader(from: forkHeaderData) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } bytesRead += HotlineFileForkHeader.DataSize @@ -356,7 +356,7 @@ public class HotlineFolderDownloadClientNew { self.folderProgress?.completedUnitCount = Int64(totalBytesNow) guard let info = HotlineFileInfoFork(from: infoData) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } // Create parent folders @@ -378,7 +378,7 @@ public class HotlineFolderDownloadClientNew { print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Reading DATA fork (\(forkHeader.dataSize) bytes)") guard let fh = fileHandle else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } fileDataForkSize = Int(forkHeader.dataSize) @@ -439,7 +439,7 @@ public class HotlineFolderDownloadClientNew { fileHandle = nil guard let finalPath = filePath else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } // Write resource fork if present diff --git a/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift index 38532f1..4b98b54 100644 --- a/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift @@ -16,7 +16,7 @@ private struct FolderItem { } @MainActor -public class HotlineFolderUploadClientNew { +public class HotlineFolderUploadClientNew: @MainActor HotlineTransferClient { // MARK: - Configuration public struct Configuration: Sendable { @@ -176,7 +176,7 @@ public class HotlineFolderUploadClientNew { // Wait for server to send .nextFile action let action = try await self.readAction(socket: socket) guard action == .nextFile else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } // Check if we have more items to send @@ -192,7 +192,7 @@ public class HotlineFolderUploadClientNew { case .sendingItemHeader: // Send item header to server guard let item = currentItem else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } // Encode and send item header @@ -212,7 +212,7 @@ public class HotlineFolderUploadClientNew { case .waitingForFileAction: // Wait for server action after file header (.sendFile, .nextFile, .resumeFile) guard currentItem != nil else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } let action = try await self.readAction(socket: socket) @@ -246,7 +246,7 @@ public class HotlineFolderUploadClientNew { case .uploadingFile: // Upload file data guard let item = currentItem else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } // Notify item progress @@ -365,7 +365,7 @@ public class HotlineFolderUploadClientNew { let actionData = try await socket.read(2) guard let rawAction = actionData.readUInt16(at: 0), let action = HotlineFolderAction(rawValue: rawAction) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } return action } @@ -383,15 +383,15 @@ public class HotlineFolderUploadClientNew { // Get file metadata guard let infoFork = HotlineFileInfoFork(file: fileURL) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } guard let header = HotlineFileHeader(file: fileURL) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } guard let forkSizes = try? FileManager.default.getFileForkSizes(fileURL) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } let infoForkData = infoFork.data() @@ -400,7 +400,7 @@ public class HotlineFolderUploadClientNew { // Calculate total flattened file size guard let flattenedSize = FileManager.default.getFlattenedFileSize(fileURL) else { - throw HotlineFileClientError.failedToTransfer + throw HotlineTransferClientError.failedToTransfer } let totalFileSize = Int(flattenedSize) diff --git a/Hotline/Models/TransferInfo.swift b/Hotline/Models/TransferInfo.swift index cb2b0fd..10535bf 100644 --- a/Hotline/Models/TransferInfo.swift +++ b/Hotline/Models/TransferInfo.swift @@ -12,7 +12,12 @@ class TransferInfo: Identifiable, Equatable, Hashable { var timeRemaining: TimeInterval? = nil var completed: Bool = false var failed: Bool = false + var cancelled: Bool = false var isFolder: Bool = false + + var done: Bool { + self.completed || self.failed || self.cancelled + } // Server association - tracks which HotlineState this transfer belongs to var serverID: UUID diff --git a/Hotline/State/AppState.swift b/Hotline/State/AppState.swift index 3aadf08..2d7820b 100644 --- a/Hotline/State/AppState.swift +++ b/Hotline/State/AppState.swift @@ -24,6 +24,8 @@ final class AppState { /// All active transfers across all servers /// Transfers persist even if you disconnect from the server var transfers: [TransferInfo] = [] + + @ObservationIgnored private var transferClients: [UUID: HotlineTransferClient] = [:] /// Track download tasks by reference number for cancellation @ObservationIgnored private var transferTasks: [UUID: Task] = [:] @@ -46,6 +48,11 @@ final class AppState { task.cancel() self.transferTasks.removeValue(forKey: id) } + + if let client = self.transferClients[id] { + client.cancel() + self.transferClients.removeValue(forKey: id) + } // Remove from transfers list self.transfers.remove(at: transferIndex) @@ -58,10 +65,25 @@ final class AppState { task.cancel() } self.transferTasks.removeAll() + + for (_, client) in self.transferClients { + client.cancel() + } + self.transferClients.removeAll() // Clear transfers self.transfers.removeAll() } + + /// Remove all completed transfers + @MainActor + func sweepTransfers() { + for t in self.transfers { + if t.done { + self.cancelTransfer(id: t.id) + } + } + } /// Register a transfer task @MainActor @@ -69,9 +91,16 @@ final class AppState { self.transferTasks[transferID] = task } + @MainActor + func registerTransferTask(_ task: Task, transferID: UUID, client: HotlineTransferClient) { + self.transferTasks[transferID] = task + self.transferClients[transferID] = client + } + /// Unregister a download task (called on completion/failure) @MainActor func unregisterTransferTask(for transferID: UUID) { self.transferTasks.removeValue(forKey: transferID) + self.transferClients.removeValue(forKey: transferID) } } diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index 4e28af8..14fb9aa 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -850,56 +850,7 @@ class HotlineState: Equatable { return success } -// @MainActor -// func downloadFile(_ fileName: String, path: [String], to destination: URL? = nil, progress progressCallback: ((TransferInfo, Double) -> Void)? = nil, complete callback: ((TransferInfo, URL) -> Void)? = nil) { -// guard let client = self.client else { return } -// -// var fullPath: [String] = [] -// if path.count > 1 { -// fullPath = Array(path[0.. Void)? = nil) { -// guard let client = self.client else { return } -// -// var fullPath: [String] = [] -// if path.count > 1 { -// fullPath = Array(path[0..() + var body: some View { VStack(spacing: 0) { - if appState.transfers.isEmpty { - emptyState + if self.appState.transfers.isEmpty { + self.emptyState } else { - transfersList + self.transfersList } } .frame(minWidth: 500, minHeight: 200) .navigationTitle("Transfers") .toolbar { +// ToolbarItem(placement: .primaryAction) { +// Button { +// self.appState.sweepTransfers() +// self.selectedTransfers = [] +// } label: { +// Label("Remove Completed", systemImage: "checklist") +// } +// .disabled(self.appState.transfers.isEmpty) +// } + ToolbarItem(placement: .primaryAction) { Button { - appState.cancelAllTransfers() + for transfer in self.selectedTransfers { + self.appState.cancelTransfer(id: transfer.id) + } + self.selectedTransfers = [] } label: { - Label("Cancel All", systemImage: "xmark.circle") + Label("Cancel Transfer", systemImage: "xmark") } - .disabled(appState.transfers.isEmpty) + .disabled(self.selectedTransfers.isEmpty) } } } @@ -38,96 +53,186 @@ struct TransfersView: View { // MARK: - Transfers List private var transfersList: some View { - List { - ForEach(appState.transfers) { transfer in + List(selection: self.$selectedTransfers) { + ForEach(self.appState.transfers) { transfer in TransferRow(transfer: transfer) + .id(transfer) + } + } + .listStyle(.inset) + .environment(\.defaultMinListRowHeight, 56) + .contextMenu(forSelectionType: TransferInfo.self) { items in + if let item = items.first { + if item.completed, + let fileURL = item.fileURL { + Button("Remove Transfer") { + self.appState.cancelTransfer(id: item.id) + } + + Divider() + + Button("Show in Finder") { + NSWorkspace.shared.activateFileViewerSelecting([fileURL]) + } + + Button("Open") { + NSWorkspace.shared.open(fileURL) + } + + Divider() + + Button("Move to Trash") { + NSWorkspace.shared.recycle([fileURL]) + } + } + else if !item.done { + Button("Cancel Transfer") { + self.appState.cancelTransfer(id: item.id) + } + } + } + } primaryAction: { items in + let fileURLs: [URL] = items.compactMap { $0.fileURL } + if !fileURLs.isEmpty { + NSWorkspace.shared.activateFileViewerSelecting(fileURLs) } } - .listStyle(.inset(alternatesRowBackgrounds: true)) } } // MARK: - Transfer Row struct TransferRow: View { + @Environment(\.appState) private var appState + @Bindable var transfer: TransferInfo + + private var statsView: some View { + HStack(spacing: 8) { + // Progress percentage +// Text("\(Int(self.transfer.progress * 100))%") + + // Speed + if let speed = self.transfer.speed { + Text(self.formatSpeed(speed)) + } + + // Time remaining + if let timeRemaining = self.transfer.timeRemaining { + Text(self.formatTimeRemaining(timeRemaining)) + } + + // File size + Text(self.formatSize(self.transfer.size)) + } + .font(.subheadline) + .foregroundStyle(.secondary) + .monospacedDigit() + } + + private var fileIconView: some View { + FileIconView(filename: self.transfer.title, fileType: nil) + .frame(width: 32, height: 32) + .overlay(alignment: .bottomTrailing) { + if self.transfer.cancelled || self.transfer.failed { + Image(systemName: "exclamationmark.triangle.fill") + .resizable() + .symbolRenderingMode(.multicolor) + .scaledToFit() + .frame(width: 16, height: 16) + } + else if self.transfer.completed { + Image(systemName: "checkmark.circle.fill") + .resizable() + .symbolRenderingMode(.palette) + .foregroundStyle(.white, .fileComplete) + .scaledToFit() + .frame(width: 16, height: 16) + } + } + } var body: some View { - VStack(alignment: .leading, spacing: 8) { - // File name and server - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(transfer.title) - .font(.system(.body, design: .default, weight: .medium)) - - if let serverName = transfer.serverName { - Text(serverName) - .font(.caption) - .foregroundStyle(.secondary) + HStack(alignment: .center, spacing: 8) { + self.fileIconView + + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(self.transfer.title) + .font(.headline) + .lineLimit(1) + .truncationMode(.tail) + + Spacer() + + if !self.transfer.done { + self.statsView } } - - Spacer() - - // Cancel button + + // Progress bar and status + if self.transfer.cancelled { + Text("Cancelled") + .font(.subheadline) + .foregroundStyle(.secondary) + } + else if self.transfer.failed { + Text("Failed") + .font(.subheadline) + .foregroundStyle(.secondary) + } + else if self.transfer.completed { + Text("Complete") + .font(.subheadline) + .foregroundStyle(.fileComplete) + } + else { + ProgressView(value: self.transfer.progress, total: 1.0) + .progressViewStyle(.linear) + .controlSize(.large) + } + } + + if self.transfer.completed { Button { - AppState.shared.cancelTransfer(id: transfer.id) + guard let fileURL = self.transfer.fileURL else { + return + } + + NSWorkspace.shared.activateFileViewerSelecting([fileURL]) } label: { - Image(systemName: "xmark.circle.fill") + Image(systemName: "eye.circle.fill") + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) .foregroundStyle(.secondary) } + .buttonBorderShape(.circle) .buttonStyle(.plain) - .help("Cancel download") } + } + +// VStack(alignment: .leading, spacing: 2) { + - // Progress bar and status - VStack(alignment: .leading, spacing: 4) { - if transfer.failed { - Label("Failed", systemImage: "exclamationmark.triangle.fill") - .font(.caption) - .foregroundStyle(.red) - } else if transfer.completed { - Label("Complete", systemImage: "checkmark.circle.fill") - .font(.caption) - .foregroundStyle(.green) - } else { - // Progress bar - ProgressView(value: transfer.progress, total: 1.0) - .progressViewStyle(.linear) +// if let serverName = self.transfer.serverName { +// Text(serverName) +// .font(.caption) +// .foregroundStyle(.secondary) +// } +// } - // Progress info - HStack(spacing: 8) { - // Progress percentage - Text("\(Int(transfer.progress * 100))%") - .font(.caption) - .foregroundStyle(.secondary) - .monospacedDigit() - - // File size - Text(formatSize(transfer.size)) - .font(.caption) - .foregroundStyle(.secondary) - - // Speed - if let speed = transfer.speed { - Text(formatSpeed(speed)) - .font(.caption) - .foregroundStyle(.secondary) - .monospacedDigit() - } - - // Time remaining - if let timeRemaining = transfer.timeRemaining { - Text(formatTimeRemaining(timeRemaining)) - .font(.caption) - .foregroundStyle(.secondary) - .monospacedDigit() - } - } - } - } - } - .padding(.vertical, 4) +// // Cancel button +// Button { +// self.appState.cancelTransfer(id: transfer.id) +// } label: { +// Image(systemName: "xmark.circle.fill") +// .foregroundStyle(.secondary) +// } +// .buttonStyle(.plain) +// .help("Cancel download") +// } +// } } // MARK: - Formatting -- cgit From a4263aea6e2875fa77783685985e5c8f7991337f Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Mon, 10 Nov 2025 15:30:16 -0800 Subject: More cleanup and fix for connect view showing right before login UI displays. Move connect form to ConnectView. --- Hotline.xcodeproj/project.pbxproj | 6 +- Hotline/Hotline/HotlineClientNew.swift | 13 +- Hotline/Hotline/HotlineTrackerClient.swift | 3 +- Hotline/Hotline/HotlineTransferClient.swift | 286 --------------------- .../Transfers/HotlineFileDownloadClientNew.swift | 51 +--- .../Transfers/HotlineFilePreviewClientNew.swift | 46 ++-- .../Transfers/HotlineFileUploadClientNew.swift | 96 +++---- .../Transfers/HotlineFolderDownloadClientNew.swift | 21 +- .../Transfers/HotlineFolderUploadClientNew.swift | 18 +- .../Hotline/Transfers/HotlineTransferClient.swift | 286 +++++++++++++++++++++ Hotline/Library/Extensions.swift | 4 + Hotline/Library/NetSocket/NetSocket.swift | 35 +-- Hotline/MacApp.swift | 45 ++-- Hotline/State/FilePreviewState.swift | 11 +- Hotline/State/HotlineState.swift | 22 +- Hotline/State/ServerState.swift | 2 +- Hotline/iOS/TrackerView.swift | 216 ++++++++-------- Hotline/macOS/ConnectView.swift | 147 +++++++++++ Hotline/macOS/Files/FilePreviewQuickLookView.swift | 22 +- Hotline/macOS/HotlinePanelView.swift | 2 +- Hotline/macOS/ServerView.swift | 261 +++++-------------- 21 files changed, 748 insertions(+), 845 deletions(-) delete mode 100644 Hotline/Hotline/HotlineTransferClient.swift create mode 100644 Hotline/Hotline/Transfers/HotlineTransferClient.swift create mode 100644 Hotline/macOS/ConnectView.swift (limited to 'Hotline/State/HotlineState.swift') diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index f91bcf8..aa10d51 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -115,6 +115,7 @@ DAE735032B30C0BB000C56F6 /* MessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAE735022B30C0BB000C56F6 /* MessageView.swift */; platformFilters = (macos, ); }; DAE735052B3218D8000C56F6 /* NewsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAE735042B3218D8000C56F6 /* NewsView.swift */; platformFilters = (macos, ); }; DAE735072B3251B3000C56F6 /* SoundEffects.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAE735062B3251B3000C56F6 /* SoundEffects.swift */; }; + DAF5BC6C2EC2727700551E4D /* ConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF5BC6B2EC2727100551E4D /* ConnectView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -225,6 +226,7 @@ DAE735022B30C0BB000C56F6 /* MessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageView.swift; sourceTree = ""; }; DAE735042B3218D8000C56F6 /* NewsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsView.swift; sourceTree = ""; }; DAE735062B3251B3000C56F6 /* SoundEffects.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundEffects.swift; sourceTree = ""; }; + DAF5BC6B2EC2727100551E4D /* ConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -245,6 +247,7 @@ DA3429B12EBA73730010784E /* Transfers */ = { isa = PBXGroup; children = ( + DA5753672B33E88A00FAC277 /* HotlineTransferClient.swift */, DA3429B22EBA7ADE0010784E /* HotlineFilePreviewClientNew.swift */, DA5268B22EB6806E00DCB941 /* HotlineFileDownloadClientNew.swift */, DA3429AD2EB9C0220010784E /* HotlineFileUploadClientNew.swift */, @@ -470,7 +473,6 @@ children = ( DA5268AE2EB2682B00DCB941 /* HotlineClientNew.swift */, DA9CAFCA2B126E3300CDA197 /* HotlineTrackerClient.swift */, - DA5753672B33E88A00FAC277 /* HotlineTransferClient.swift */, DA3429B12EBA73730010784E /* Transfers */, DA4F2BF72B16A17200D8ADDC /* HotlineProtocol.swift */, DAC87F062C5010E80060FADF /* HotlineExtensions.swift */, @@ -526,6 +528,7 @@ DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */, DAE136B92B9D1147007D8307 /* HotlinePanelView.swift */, DAE734F82B2E4185000C56F6 /* ServerView.swift */, + DAF5BC6B2EC2727100551E4D /* ConnectView.swift */, DAE735022B30C0BB000C56F6 /* MessageView.swift */, DA5268B42EB6840A00DCB941 /* TransfersView.swift */, DA501BE52EBE9520001714F8 /* Trackers */, @@ -667,6 +670,7 @@ DA65499E2BEC438A00EDB697 /* NSWindowBridge.swift in Sources */, DAE735032B30C0BB000C56F6 /* MessageView.swift in Sources */, DA32CD4B2B29318E0053B98B /* FileInfo.swift in Sources */, + DAF5BC6C2EC2727700551E4D /* ConnectView.swift in Sources */, DA20BBE12BF5237600B94E7C /* Bookmark.swift in Sources */, DA3429B32EBA7ADF0010784E /* HotlineFilePreviewClientNew.swift in Sources */, DA9CAFCB2B126E3300CDA197 /* HotlineTrackerClient.swift in Sources */, diff --git a/Hotline/Hotline/HotlineClientNew.swift b/Hotline/Hotline/HotlineClientNew.swift index 303bd32..854c7ff 100644 --- a/Hotline/Hotline/HotlineClientNew.swift +++ b/Hotline/Hotline/HotlineClientNew.swift @@ -64,7 +64,7 @@ public enum HotlineClientError: Error { // MARK: - Login Info /// Information needed to log in to a Hotline server -public struct HotlineLoginInfo: Sendable { +public struct HotlineLogin: Sendable { let login: String let password: String let username: String @@ -100,7 +100,7 @@ public struct HotlineServerInfo: Sendable { /// let client = try await HotlineClientNew.connect( /// host: "server.example.com", /// port: 5500, -/// login: HotlineLoginInfo(login: "guest", password: "", username: "John", iconID: 414) +/// login: HotlineLogin(login: "guest", password: "", username: "John", iconID: 414) /// ) /// /// // Listen for events @@ -188,14 +188,13 @@ public actor HotlineClientNew { public static func connect( host: String, port: UInt16 = 5500, - login: HotlineLoginInfo, - tls: TLSPolicy = .disabled + login: HotlineLogin ) async throws -> HotlineClientNew { print("HotlineClientNew.connect(): Starting connection to \(host):\(port) as '\(login.username)'") // Connect socket print("HotlineClientNew.connect(): Connecting socket...") - let socket = try await NetSocket.connect(host: host, port: port, tls: tls) + let socket = try await NetSocket.connect(host: host, port: port) print("HotlineClientNew.connect(): Socket connected") // Perform handshake @@ -264,7 +263,7 @@ public actor HotlineClientNew { // MARK: - Login - private func performLogin(_ login: HotlineLoginInfo) async throws -> HotlineServerInfo { + private func performLogin(_ login: HotlineLogin) async throws -> HotlineServerInfo { var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .login) transaction.setFieldEncodedString(type: .userLogin, val: login.login) transaction.setFieldEncodedString(type: .userPassword, val: login.password) @@ -273,7 +272,7 @@ public actor HotlineClientNew { transaction.setFieldUInt32(type: .versionNumber, val: 123) let reply = try await sendTransaction(transaction) - + guard reply.errorCode == 0 else { let errorText = reply.getField(type: .errorText)?.getString() throw HotlineClientError.loginFailed(errorText) diff --git a/Hotline/Hotline/HotlineTrackerClient.swift b/Hotline/Hotline/HotlineTrackerClient.swift index f72735d..72ff233 100644 --- a/Hotline/Hotline/HotlineTrackerClient.swift +++ b/Hotline/Hotline/HotlineTrackerClient.swift @@ -74,8 +74,7 @@ class HotlineTrackerClient { // Connect to tracker (plaintext, no TLS) let socket = try await NetSocket.connect( host: address, - port: UInt16(port), - tls: .disabled + port: UInt16(port) ) defer { Task { await socket.close() } } diff --git a/Hotline/Hotline/HotlineTransferClient.swift b/Hotline/Hotline/HotlineTransferClient.swift deleted file mode 100644 index 596155b..0000000 --- a/Hotline/Hotline/HotlineTransferClient.swift +++ /dev/null @@ -1,286 +0,0 @@ -import Foundation -import Network -import UniformTypeIdentifiers - -protocol HotlineTransferClient { -// var serverAddress: NWEndpoint.Host { get } -// var serverPort: NWEndpoint.Port { get } -// var referenceNumber: UInt32 { get } -// var status: HotlineTransferStatus { get set } - -// func start() - func cancel() -} - -enum HotlineTransferClientError: Error { - case failedToConnect - case failedToTransfer - case cancelled -} - -enum HotlineFileFork { - case none - case info - case data - case resource - case unsupported -} - -enum HotlineTransferStatus: Equatable { - case unconnected - case connecting - case connected - case progress(Double) - case completing - case completed - case failed(HotlineTransferClientError) -} - -enum HotlineFileForkType: UInt32 { - case none = 0 - case unsupported = 1 - case info = 0x494E464F // 'INFO' - case data = 0x44415441 // 'DATA' - case resource = 1296122706 // 'MACR' -} - -public enum HotlineFolderAction: UInt16 { - case sendFile = 1 - case resumeFile = 2 - case nextFile = 3 -} - -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.urlForResourceFork() - if FileManager.default.fileExists(atPath: resourceURL.path(percentEncoded: false)) { - self.forkCount = 3 - } - else { - self.forkCount = 2 - } - } - - func data() -> Data { - Data(endian: .big) { - self.format - self.version - Data(repeating: 0, count: 16) - self.forkCount - } - } -} - -// MARK: - - -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 - self.compressionType - UInt32.zero - 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 - } -} - -// MARK: - - -struct HotlineFileInfoFork { - static let BaseDataSize: Int = 4 + 4 + 4 + 4 + 4 + 32 + 8 + 8 + 2 + 2 - - let platform: UInt32 - let type: UInt32 - let creator: UInt32 - let flags: UInt32 - let platformFlags: UInt32 - let createdDate: Date - let modifiedDate: Date - let nameScript: UInt16 - let name: String - let comment: String? - var headerSize: Int - - init?(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 { - 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), - let type = data.readUInt32(at: 4), - let creator = data.readUInt32(at: 4 + 4), - let flags = data.readUInt32(at: 4 + 4 + 4), - let platformFlags = data.readUInt32(at: 4 + 4 + 4 + 4), - // 32 bytes of reserved data sits here. Skip it. - let nameScript = data.readUInt16(at: 4 + 4 + 4 + 4 + 4 + 32 + 8 + 8) { - - let createdDate = data.readDate(at: 4 + 4 + 4 + 4 + 4 + 32) ?? Date.now - let modifiedDate = data.readDate(at: 4 + 4 + 4 + 4 + 4 + 32 + 8) ?? Date.now - - let (n, nl) = data.readLongPString(at: 4 + 4 + 4 + 4 + 4 + 32 + 8 + 8 + 2) - if let name = n { - self.platform = platform - self.type = type - self.creator = creator - self.flags = flags - self.platformFlags = platformFlags - self.createdDate = createdDate - self.modifiedDate = modifiedDate - self.nameScript = nameScript - self.name = name - - var calculatedHeaderSize: Int = HotlineFileInfoFork.BaseDataSize + nl - var commentRead: String? = nil - if data.count >= HotlineFileInfoFork.BaseDataSize + nl + 2 { - let commentLength = data.readUInt16(at: HotlineFileInfoFork.BaseDataSize + nl)! - var commentCorrupted = false - - // Some servers have incorrect data length for the INFO fork - // the length they send is what it should be but don't include - // the comment length in the actual data, so we end up with mismatched - // lengths. So here we test if the length we read is actually 'DA' - // or the first part of the "DATA" fork header. - // Needless to say, stuff like this makes for sad code but this ain't so bad. - if commentLength == 0x4441 { - commentCorrupted = true - } - - if !commentCorrupted { - let (c, cl) = data.readLongPString(at: HotlineFileInfoFork.BaseDataSize + nl) - calculatedHeaderSize += 2 - if cl > 0 { - calculatedHeaderSize += Int(cl) - if let ct = c, cl > 0 { - commentRead = ct - } - } - } - } - - self.comment = commentRead - self.headerSize = calculatedHeaderSize - return - } - } - - return nil - } - - func data() -> Data { - let fileName = self.name.data(using: .macOSRoman)! - - let data = Data(endian: .big) { - self.platform - self.type - self.creator - self.flags - self.platformFlags - Data(repeating: 0, count: 32) - self.createdDate - self.modifiedDate - self.nameScript - UInt16(fileName.count) - fileName - if let commentData = self.comment?.data(using: .macOSRoman) { - UInt16(commentData.count) - commentData - } - } - - return data - } -} diff --git a/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift index 981965a..82f61d4 100644 --- a/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift @@ -16,18 +16,14 @@ public enum HotlineTransferProgress: Sendable { case completed(url: URL?) // Download or upload complete (local url valid for downloads) } -/// Modern async/await file download client for Hotline protocol -@MainActor -public class HotlineFileDownloadClientNew: @MainActor HotlineTransferClient { - // MARK: - Configuration +@MainActor +public class HotlineFileDownloadClient: @MainActor HotlineTransferClient { public struct Configuration: Sendable { public var chunkSize: Int = 256 * 1024 public init() {} } - // MARK: - Properties - private let serverAddress: String private let serverPort: UInt16 private let referenceNumber: UInt32 @@ -41,8 +37,6 @@ public class HotlineFileDownloadClientNew: @MainActor HotlineTransferClient { private var socket: NetSocket? private var downloadTask: Task? - // MARK: - Initialization - public init( address: String, port: UInt16, @@ -91,7 +85,7 @@ public class HotlineFileDownloadClientNew: @MainActor HotlineTransferClient { self.downloadTask = nil } - // MARK: - Private Implementation + // MARK: - Implementation private func updateProgress(sent: Int) throws { self.transferSize = sent @@ -141,9 +135,12 @@ public class HotlineFileDownloadClientNew: @MainActor HotlineTransferClient { try progressHandler?(.connecting) // Connect to transfer server - let socket = try await connectToTransferServer() - self.socket = socket + let socket = try await NetSocket.connect( + host: self.serverAddress, + port: self.serverPort + 1 + ) defer { Task { await socket.close() } } + self.socket = socket // See if we've been cancelled try self.checkCancelled() @@ -257,35 +254,7 @@ public class HotlineFileDownloadClientNew: @MainActor HotlineTransferClient { } } - // MARK: - Helper Methods - - private func connectToTransferServer() async throws -> NetSocket { - guard let transferPort = NWEndpoint.Port(rawValue: serverPort + 1) else { - throw NetSocketError.invalidPort - } - - print("HotlineFileDownloadClientNew[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)") - - let socket = try await NetSocket.connect( - host: .name(serverAddress, nil), - port: transferPort, - tls: .disabled - ) - - print("HotlineFileDownloadClientNew[\(referenceNumber)]: Connected!") - return socket - } - - private func sendMagicHeader(socket: NetSocket) async throws { - let header = Data(endian: .big) { - "HTXF".fourCharCode() - referenceNumber - UInt32.zero - UInt32.zero - } - - try await socket.write(header) - } + // MARK: - Utility private func writeResourceFork(data: Data, to url: URL) throws { var resolvedURL = url @@ -294,7 +263,7 @@ public class HotlineFileDownloadClientNew: @MainActor HotlineTransferClient { let resourceURL = resolvedURL.urlForResourceFork() try data.write(to: resourceURL) - print("HotlineFileDownloadClientNew[\(referenceNumber)]: Wrote resource fork (\(data.count) bytes)") + print("HotlineFileDownloadClient[\(self.referenceNumber)]: Wrote resource fork (\(data.count) bytes)") } } diff --git a/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift b/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift index 93a68fc..5cf5628 100644 --- a/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift @@ -2,21 +2,17 @@ import Foundation import Network @MainActor -public class HotlineFilePreviewClientNew { - // MARK: - Properties - +public class HotlineFilePreviewClient { private let serverAddress: String private let serverPort: UInt16 private let referenceNumber: UInt32 private let fileName: String private let transferSize: UInt32 - private var downloadClient: HotlineFileDownloadClientNew? + private var downloadClient: HotlineFileDownloadClient? private var previewTask: Task? private var temporaryFileURL: URL? - // MARK: - Initialization - public init( fileName: String, address: String, @@ -31,7 +27,7 @@ public class HotlineFilePreviewClientNew { self.transferSize = size } - // MARK: - + // MARK: - API /// Download file to temporary location for preview /// - Parameter progressHandler: Optional progress callback @@ -51,7 +47,7 @@ public class HotlineFilePreviewClientNew { self.previewTask = nil return url } catch { - print("HotlineFilePreviewClientNew[\(referenceNumber)]: Failed to preview file: \(error)") + print("HotlineFilePreviewClient[\(referenceNumber)]: Failed to preview file: \(error)") self.previewTask = nil progressHandler?(.error(error)) throw error @@ -71,7 +67,7 @@ public class HotlineFilePreviewClientNew { self.cleanupTempFile() } - // MARK: - Private Implementation + // MARK: - Implementation private func performPreview( progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? @@ -83,28 +79,22 @@ public class HotlineFilePreviewClientNew { let tempFileURL = tempDir.appendingPathComponent(uniqueFileName) self.temporaryFileURL = tempFileURL - print("HotlineFilePreviewClientNew[\(referenceNumber)]: Downloading to temp: \(tempFileURL.path)") + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Downloading to temp: \(tempFileURL.path)") progressHandler?(.connecting) // Connect to transfer server - guard let transferPort = NWEndpoint.Port(rawValue: serverPort + 1) else { - throw NetSocketError.invalidPort - } - - print("HotlineFilePreviewClientNew[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)") - + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Connecting to \(self.serverAddress):\(self.serverPort + 1)") let socket = try await NetSocket.connect( - host: .name(serverAddress, nil), - port: transferPort, - tls: .disabled + host: self.serverAddress, + port: self.serverPort + 1 ) defer { Task { await socket.close() } } - print("HotlineFilePreviewClientNew[\(referenceNumber)]: Connected!") + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Connected!") // Send magic header for raw data download - print("HotlineFilePreviewClientNew[\(referenceNumber)]: Sending magic header") + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Sending magic header") try await socket.write(Data(endian: .big) { "HTXF".fourCharCode() self.referenceNumber @@ -115,7 +105,7 @@ public class HotlineFilePreviewClientNew { progressHandler?(.connected) // Stream raw data directly to temp file with progress tracking - print("HotlineFilePreviewClientNew[\(referenceNumber)]: Streaming \(transferSize) bytes to temp file") + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Streaming \(transferSize) bytes to temp file") let totalSize = Int(transferSize) @@ -139,18 +129,18 @@ public class HotlineFilePreviewClientNew { progressHandler?(.completed(url: tempFileURL)) - print("HotlineFilePreviewClientNew[\(referenceNumber)]: Preview file ready at \(tempFileURL.path)") + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Preview file ready at \(tempFileURL.path)") return tempFileURL } private func cleanupTempFile() { - guard let tempURL = temporaryFileURL else { return } - + guard let tempURL = self.temporaryFileURL else { return } + self.temporaryFileURL = nil + // Delete the temp file try? FileManager.default.removeItem(at: tempURL) - - temporaryFileURL = nil - print("HotlineFilePreviewClientNew[\(referenceNumber)]: Cleaned up temp file") + + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Cleaned up temp file") } } diff --git a/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift index 3bf3339..384e9bd 100644 --- a/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift @@ -2,17 +2,12 @@ import Foundation import Network @MainActor -public class HotlineFileUploadClientNew: @MainActor HotlineTransferClient { - // MARK: - Configuration - +public class HotlineFileUploadClient: @MainActor HotlineTransferClient { public struct Configuration: Sendable { public var chunkSize: Int = 256 * 1024 - public init() {} } - // MARK: - Properties - private let serverAddress: String private let serverPort: UInt16 private let referenceNumber: UInt32 @@ -27,8 +22,6 @@ public class HotlineFileUploadClientNew: @MainActor HotlineTransferClient { private var socket: NetSocket? private var uploadTask: Task? - // MARK: - Initialization - public init?( fileURL: URL, address: String, @@ -54,8 +47,6 @@ public class HotlineFileUploadClientNew: @MainActor HotlineTransferClient { self.transferTotal = Int(payloadSize) self.transferSize = 0 self.transferProgress = Progress(totalUnitCount: Int64(self.transferTotal)) - - print("HotlineFileUploadClientNew[\(reference)]: Preparing to upload '\(fileURL.lastPathComponent)' (\(payloadSize) bytes)") } // MARK: - Public API @@ -74,7 +65,7 @@ public class HotlineFileUploadClientNew: @MainActor HotlineTransferClient { try await task.value self.uploadTask = nil } catch { - print("HotlineFileUploadClientNew[\(referenceNumber)]: Failed to upload file: \(error)") + print("HotlineFileUploadClient[\(self.referenceNumber)]: Failed to upload file: \(error)") self.uploadTask = nil progressHandler?(.error(error)) throw error @@ -83,17 +74,17 @@ public class HotlineFileUploadClientNew: @MainActor HotlineTransferClient { /// Cancel the current upload public func cancel() { - uploadTask?.cancel() - uploadTask = nil + self.uploadTask?.cancel() + self.uploadTask = nil - if let socket = socket { + if let socket = self.socket { Task { await socket.close() } } } - // MARK: - Private Implementation + // MARK: - Implementation private func updateProgress(sent: Int, speed: Double? = nil, estimate: TimeInterval? = nil) { self.transferSize = sent @@ -120,34 +111,39 @@ public class HotlineFileUploadClientNew: @MainActor HotlineTransferClient { } // Connect to transfer server - let socket = try await connectToTransferServer() - self.socket = socket + let socket = try await NetSocket.connect( + host: self.serverAddress, + port: self.serverPort + 1 + ) defer { Task { await socket.close() } } + self.socket = socket // Get file metadata - guard let infoFork = HotlineFileInfoFork(file: fileURL) else { + guard let infoFork = HotlineFileInfoFork(file: self.fileURL) else { throw HotlineTransferClientError.failedToTransfer } - guard let header = HotlineFileHeader(file: fileURL) else { + guard let header = HotlineFileHeader(file: self.fileURL) else { throw HotlineTransferClientError.failedToTransfer } - guard let forkSizes = try? FileManager.default.getFileForkSizes(fileURL) else { + guard let forkSizes = try? FileManager.default.getFileForkSizes(self.fileURL) else { throw HotlineTransferClientError.failedToTransfer } let infoForkData = infoFork.data() let dataForkSize = forkSizes.dataForkSize let resourceForkSize = forkSizes.resourceForkSize - - print("HotlineFileUploadClientNew[\(referenceNumber)]: File has dataFork=\(dataForkSize) bytes, resourceFork=\(resourceForkSize) bytes") + + // Configure progress for Finder if enabled + self.transferProgress.fileURL = self.fileURL.resolvingSymlinksInPath() + self.transferProgress.fileOperationKind = .uploading + self.transferProgress.publish() // Connected progressHandler?(.connected) // Send magic header - print("HotlineFileUploadClientNew[\(referenceNumber)]: Sending magic header") try await socket.write(Data(endian: .big) { "HTXF".fourCharCode() self.referenceNumber @@ -157,41 +153,34 @@ public class HotlineFileUploadClientNew: @MainActor HotlineTransferClient { var totalBytesSent = 0 + // MARK: - Info Fork // Send file header - print("HotlineFileUploadClientNew[\(referenceNumber)]: Sending file header") let headerData = header.data() try await socket.write(headerData) totalBytesSent += headerData.count - // Send INFO fork header - print("HotlineFileUploadClientNew[\(referenceNumber)]: Sending INFO fork header") + // Send info fork header let infoForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.info.rawValue, dataSize: UInt32(infoForkData.count)) try await socket.write(infoForkHeader.data()) totalBytesSent += HotlineFileForkHeader.DataSize - // Send INFO fork data - print("HotlineFileUploadClientNew[\(referenceNumber)]: Sending INFO fork (\(infoForkData.count) bytes)") + // Send info fork try await socket.write(infoForkData) totalBytesSent += infoForkData.count self.updateProgress(sent: totalBytesSent) progressHandler?(.transfer(name: filename, size: self.transferSize, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: nil, estimate: nil)) - // Configure progress for Finder if enabled - self.transferProgress.fileURL = fileURL.resolvingSymlinksInPath() - self.transferProgress.fileOperationKind = .uploading - self.transferProgress.publish() - - // Send DATA fork if present + // MARK: - Data Fork + // Send data fork (if present) if dataForkSize > 0 { - print("HotlineFileUploadClientNew[\(referenceNumber)]: Sending DATA fork header") + // Data fork header let dataForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.data.rawValue, dataSize: dataForkSize) try await socket.write(dataForkHeader.data()) totalBytesSent += HotlineFileForkHeader.DataSize - // Stream DATA fork - print("HotlineFileUploadClientNew[\(referenceNumber)]: Streaming DATA fork (\(dataForkSize) bytes)") - let fileHandle = try FileHandle(forReadingFrom: fileURL) + // Stream data fork from disk + let fileHandle = try FileHandle(forReadingFrom: self.fileURL) defer { try? fileHandle.close() } let updates = await socket.writeFile(from: fileHandle, length: Int(dataForkSize)) @@ -204,17 +193,17 @@ public class HotlineFileUploadClientNew: @MainActor HotlineTransferClient { totalBytesSent += Int(dataForkSize) } - // Send RESOURCE fork if present + // MARK: - Resource Fork + // Send resource fork (if present) if resourceForkSize > 0 { - let resourceURL = fileURL.urlForResourceFork() + let resourceURL = self.fileURL.urlForResourceFork() - print("HotlineFileUploadClientNew[\(referenceNumber)]: Sending RESOURCE fork header") + // Resource fork header let resourceForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.resource.rawValue, dataSize: resourceForkSize) try await socket.write(resourceForkHeader.data()) totalBytesSent += HotlineFileForkHeader.DataSize - // Stream RESOURCE fork - print("HotlineFileUploadClientNew[\(referenceNumber)]: Streaming RESOURCE fork (\(resourceForkSize) bytes)") + // Stream resource fork from disk let resourceHandle = try FileHandle(forReadingFrom: resourceURL) defer { try? resourceHandle.close() } @@ -231,25 +220,6 @@ public class HotlineFileUploadClientNew: @MainActor HotlineTransferClient { self.transferProgress.unpublish() progressHandler?(.completed(url: nil)) - print("HotlineFileUploadClientNew[\(referenceNumber)]: Upload complete!") - } - - // MARK: - Helper Methods - - private func connectToTransferServer() async throws -> NetSocket { - guard let transferPort = NWEndpoint.Port(rawValue: serverPort + 1) else { - throw NetSocketError.invalidPort - } - - print("HotlineFileUploadClientNew[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)") - - let socket = try await NetSocket.connect( - host: .name(serverAddress, nil), - port: transferPort, - tls: .disabled - ) - - print("HotlineFileUploadClientNew[\(referenceNumber)]: Connected!") - return socket + print("HotlineFileUploadClient[\(self.referenceNumber)]: Complete!") } } diff --git a/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift index 8303d11..600b9f2 100644 --- a/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift @@ -10,8 +10,6 @@ public struct HotlineFolderItemProgress: Sendable { @MainActor public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient { - // MARK: - Properties - private let serverAddress: String private let serverPort: UInt16 private let referenceNumber: UInt32 @@ -129,14 +127,14 @@ public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient { self.folderProgress = progress // Send initial magic header - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Sending HTXF magic") + print("HotlineFolderDownloadClientNew[\(self.referenceNumber)]: Sending HTXF magic") try await socket.write(Data(endian: .big) { "HTXF".fourCharCode() self.referenceNumber UInt32.zero // data size = 0 UInt16(1) // type = 1 (folder transfer) UInt16.zero // reserved = 0 - HotlineFolderAction.nextFile.rawValue // action = 3 (next file) + HotlineFolderAction.nextFile.rawValue // action = 3 (next file) }) progressHandler?(.connected) @@ -146,7 +144,7 @@ public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient { var totalBytesTransferred = 0 // Process each item in the folder - while completedItemCount < folderItemCount { + while completedItemCount < self.folderItemCount { // Read item header let headerLenData = try await socket.read(2) let headerLen = Int(headerLenData.readUInt16(at: 0)!) @@ -154,7 +152,7 @@ public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient { totalBytesTransferred += 2 + headerLen - guard let (itemType, pathComponents) = parseItemHeaderPath(headerData) else { + guard let (itemType, pathComponents) = self.parseItemHeaderPath(headerData) else { throw HotlineTransferClientError.failedToTransfer } @@ -166,7 +164,7 @@ public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient { if !pathComponents.isEmpty { let folderURL = destinationURL.appendingPathComponents(pathComponents) try fm.createDirectory(at: folderURL, withIntermediateDirectories: true) - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Created folder at \(folderURL.path)") + print("HotlineFolderDownloadClientNew[\(self.referenceNumber)]: Created folder at \(folderURL.path)") } completedItemCount += 1 @@ -246,16 +244,11 @@ public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient { // MARK: - Helper Methods private func connectToTransferServer() async throws -> NetSocket { - guard let transferPort = NWEndpoint.Port(rawValue: serverPort + 1) else { - throw NetSocketError.invalidPort - } - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)") let socket = try await NetSocket.connect( - host: .name(serverAddress, nil), - port: transferPort, - tls: .disabled + host: self.serverAddress, + port: self.serverPort + 1 ) print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Connected!") diff --git a/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift index 4b98b54..15636f3 100644 --- a/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift @@ -147,7 +147,11 @@ public class HotlineFolderUploadClientNew: @MainActor HotlineTransferClient { progressHandler?(.connecting) // Connect to transfer server - let socket = try await self.connect(address: self.serverAddress, port: self.serverPort) + let socket = try await NetSocket.connect( + host: self.serverAddress, + port: self.serverPort + 1 + ) + self.socket = socket defer { Task { await socket.close() } } @@ -281,18 +285,6 @@ public class HotlineFolderUploadClientNew: @MainActor HotlineTransferClient { progressHandler?(.completed(url: nil)) } - private func connect(address: String, port: UInt16) async throws -> NetSocket { - guard let transferPort = NWEndpoint.Port(rawValue: port + 1) else { - throw NetSocketError.invalidPort - } - - return try await NetSocket.connect( - host: .name(address, nil), - port: transferPort, - tls: .disabled - ) - } - private func buildFolderHierarchy() throws { let fm = FileManager.default folderItems = [] diff --git a/Hotline/Hotline/Transfers/HotlineTransferClient.swift b/Hotline/Hotline/Transfers/HotlineTransferClient.swift new file mode 100644 index 0000000..596155b --- /dev/null +++ b/Hotline/Hotline/Transfers/HotlineTransferClient.swift @@ -0,0 +1,286 @@ +import Foundation +import Network +import UniformTypeIdentifiers + +protocol HotlineTransferClient { +// var serverAddress: NWEndpoint.Host { get } +// var serverPort: NWEndpoint.Port { get } +// var referenceNumber: UInt32 { get } +// var status: HotlineTransferStatus { get set } + +// func start() + func cancel() +} + +enum HotlineTransferClientError: Error { + case failedToConnect + case failedToTransfer + case cancelled +} + +enum HotlineFileFork { + case none + case info + case data + case resource + case unsupported +} + +enum HotlineTransferStatus: Equatable { + case unconnected + case connecting + case connected + case progress(Double) + case completing + case completed + case failed(HotlineTransferClientError) +} + +enum HotlineFileForkType: UInt32 { + case none = 0 + case unsupported = 1 + case info = 0x494E464F // 'INFO' + case data = 0x44415441 // 'DATA' + case resource = 1296122706 // 'MACR' +} + +public enum HotlineFolderAction: UInt16 { + case sendFile = 1 + case resumeFile = 2 + case nextFile = 3 +} + +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.urlForResourceFork() + if FileManager.default.fileExists(atPath: resourceURL.path(percentEncoded: false)) { + self.forkCount = 3 + } + else { + self.forkCount = 2 + } + } + + func data() -> Data { + Data(endian: .big) { + self.format + self.version + Data(repeating: 0, count: 16) + self.forkCount + } + } +} + +// MARK: - + +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 + self.compressionType + UInt32.zero + 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 + } +} + +// MARK: - + +struct HotlineFileInfoFork { + static let BaseDataSize: Int = 4 + 4 + 4 + 4 + 4 + 32 + 8 + 8 + 2 + 2 + + let platform: UInt32 + let type: UInt32 + let creator: UInt32 + let flags: UInt32 + let platformFlags: UInt32 + let createdDate: Date + let modifiedDate: Date + let nameScript: UInt16 + let name: String + let comment: String? + var headerSize: Int + + init?(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 { + 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), + let type = data.readUInt32(at: 4), + let creator = data.readUInt32(at: 4 + 4), + let flags = data.readUInt32(at: 4 + 4 + 4), + let platformFlags = data.readUInt32(at: 4 + 4 + 4 + 4), + // 32 bytes of reserved data sits here. Skip it. + let nameScript = data.readUInt16(at: 4 + 4 + 4 + 4 + 4 + 32 + 8 + 8) { + + let createdDate = data.readDate(at: 4 + 4 + 4 + 4 + 4 + 32) ?? Date.now + let modifiedDate = data.readDate(at: 4 + 4 + 4 + 4 + 4 + 32 + 8) ?? Date.now + + let (n, nl) = data.readLongPString(at: 4 + 4 + 4 + 4 + 4 + 32 + 8 + 8 + 2) + if let name = n { + self.platform = platform + self.type = type + self.creator = creator + self.flags = flags + self.platformFlags = platformFlags + self.createdDate = createdDate + self.modifiedDate = modifiedDate + self.nameScript = nameScript + self.name = name + + var calculatedHeaderSize: Int = HotlineFileInfoFork.BaseDataSize + nl + var commentRead: String? = nil + if data.count >= HotlineFileInfoFork.BaseDataSize + nl + 2 { + let commentLength = data.readUInt16(at: HotlineFileInfoFork.BaseDataSize + nl)! + var commentCorrupted = false + + // Some servers have incorrect data length for the INFO fork + // the length they send is what it should be but don't include + // the comment length in the actual data, so we end up with mismatched + // lengths. So here we test if the length we read is actually 'DA' + // or the first part of the "DATA" fork header. + // Needless to say, stuff like this makes for sad code but this ain't so bad. + if commentLength == 0x4441 { + commentCorrupted = true + } + + if !commentCorrupted { + let (c, cl) = data.readLongPString(at: HotlineFileInfoFork.BaseDataSize + nl) + calculatedHeaderSize += 2 + if cl > 0 { + calculatedHeaderSize += Int(cl) + if let ct = c, cl > 0 { + commentRead = ct + } + } + } + } + + self.comment = commentRead + self.headerSize = calculatedHeaderSize + return + } + } + + return nil + } + + func data() -> Data { + let fileName = self.name.data(using: .macOSRoman)! + + let data = Data(endian: .big) { + self.platform + self.type + self.creator + self.flags + self.platformFlags + Data(repeating: 0, count: 32) + self.createdDate + self.modifiedDate + self.nameScript + UInt16(fileName.count) + fileName + if let commentData = self.comment?.data(using: .macOSRoman) { + UInt16(commentData.count) + commentData + } + } + + return data + } +} diff --git a/Hotline/Library/Extensions.swift b/Hotline/Library/Extensions.swift index b6a4b68..bb28370 100644 --- a/Hotline/Library/Extensions.swift +++ b/Hotline/Library/Extensions.swift @@ -121,6 +121,10 @@ extension UTType { extension String { + var isBlank: Bool { + self.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + func markdownToAttributedString() -> AttributedString { let markdownText = self.convertingLinksToMarkdown() let attr = (try? AttributedString(markdown: markdownText, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(self) diff --git a/Hotline/Library/NetSocket/NetSocket.swift b/Hotline/Library/NetSocket/NetSocket.swift index e66ef3f..ffccf5c 100644 --- a/Hotline/Library/NetSocket/NetSocket.swift +++ b/Hotline/Library/NetSocket/NetSocket.swift @@ -87,7 +87,7 @@ public enum NetSocketError: Error, CustomStringConvertible, Sendable { } } -/// An async/await TCP socket with automatic buffering and framing support +/// An async TCP socket with automatic buffering /// /// NetSocket provides: /// - Async connection management @@ -99,7 +99,7 @@ public enum NetSocketError: Error, CustomStringConvertible, Sendable { /// ```swift /// let socket = try await NetSocket.connect(host: "example.com", port: 80) /// try await socket.write("Hello\n".data(using: .utf8)!) -/// let response = try await socket.readUntil(delimiter: .lineFeed) +/// let response = try await socket.read(until: .lineFeed) /// ``` public actor NetSocket { /// Configuration options for the socket @@ -150,18 +150,11 @@ public actor NetSocket { /// - Parameters: /// - host: Network framework host (e.g., `.name("example.com", nil)` or `.ipv4(...)`) /// - port: Network framework port - /// - tls: TLS policy (default: enabled with default settings) /// - config: Socket configuration (default: standard settings) + /// - parameters: NWParameters (default: .tcp) /// - Returns: A connected and ready `NetSocket` /// - Throws: Network errors or connection failures - public static func connect(host: NWEndpoint.Host, port: NWEndpoint.Port, tls: TLSPolicy = .enabled(), config: Config = .init()) async throws -> NetSocket { - let parameters = NWParameters.tcp - if tls.enabled { - let tlsOptions = NWProtocolTLS.Options() - tls.configure?(tlsOptions) - parameters.defaultProtocolStack.applicationProtocols.insert(tlsOptions, at: 0) - } - + public static func connect(host: NWEndpoint.Host, port: NWEndpoint.Port, config: Config = .init(), parameters: NWParameters = .tcp) async throws -> NetSocket { let conn = NWConnection(host: host, port: port, using: parameters) let socket = NetSocket(connection: conn, config: config) try await socket.start() @@ -169,28 +162,12 @@ public actor NetSocket { } /// Convenience wrapper to connect using string hostname and integer port - public static func connect(host: String, port: UInt16, tls: TLSPolicy = .enabled(), config: Config = .init()) async throws -> NetSocket { + public static func connect(host: String, port: UInt16, config: Config = .init()) async throws -> NetSocket { guard let nwPort = NWEndpoint.Port(rawValue: port) else { throw NetSocketError.invalidPort } - // Parse the host string to create the appropriate NWEndpoint.Host - let nwHost: NWEndpoint.Host - - // Try parsing as IPv6 without zone - if let ipv6Addr = IPv6Address(host) { - nwHost = .ipv6(ipv6Addr) - } - // Try parsing as IPv4 - else if let ipv4Addr = IPv4Address(host) { - nwHost = .ipv4(ipv4Addr) - } - // Fall back to treating as hostname - else { - nwHost = .name(host, nil) - } - - return try await self.connect(host: nwHost, port: nwPort, tls: tls, config: config) + return try await self.connect(host: NWEndpoint.Host(host), port: nwPort, config: config) } // MARK: Close diff --git a/Hotline/MacApp.swift b/Hotline/MacApp.swift index 9052c0b..9ac54c1 100644 --- a/Hotline/MacApp.swift +++ b/Hotline/MacApp.swift @@ -109,7 +109,7 @@ struct Application: App { .onChange(of: AppLaunchState.shared.launchState) { if AppLaunchState.shared.launchState == .launched { if Prefs.shared.showBannerToolbar { - showBannerWindow() + self.showBannerWindow() } } } @@ -196,13 +196,13 @@ struct Application: App { .commands { CommandGroup(replacing: .newItem) { Button("Connect to Server...") { - openWindow(id: "server") + self.openWindow(id: "server") } .keyboardShortcut(.init("K"), modifiers: .command) } CommandGroup(before: .singleWindowList) { Button("Toolbar") { - toggleBannerWindow() + self.toggleBannerWindow() } .keyboardShortcut(.init("\\"), modifiers: [.shift, .command]) } @@ -210,18 +210,18 @@ struct Application: App { Divider() Button("Request Feature...") { if let url = URL(string: "https://github.com/mierau/hotline/issues/new?labels=enhancement") { - openURL(url) + self.openURL(url) } } Button("Report Bug...") { if let url = URL(string: "https://github.com/mierau/hotline/issues/new?labels=bug") { - openURL(url) + self.openURL(url) } } Divider() Button("Open Latest Release Page...") { if let url = URL(string: "https://github.com/mierau/hotline/releases/latest") { - openURL(url) + self.openURL(url) } } } @@ -230,7 +230,7 @@ struct Application: App { guard let selection else { return } - connect(to: selection) + self.connect(to: selection) } .disabled(selection == nil || selection?.server == nil) .keyboardShortcut(.downArrow, modifiers: .command) @@ -242,38 +242,47 @@ struct Application: App { } } .disabled(activeHotline?.status == .disconnected) + Divider() + Button("Broadcast Message...") { // TODO: Implement broadcast message when user is allowed. } .disabled(true) .keyboardShortcut(.init("B"), modifiers: .command) + Divider() - Button("Show Chat") { + + Button("Chat") { activeServerState?.selection = .chat } .disabled(activeHotline?.status != .loggedIn) .keyboardShortcut(.init("1"), modifiers: .command) - Button("Show Message Board") { + Button("Board") { activeServerState?.selection = .board } .disabled(activeHotline?.status != .loggedIn) .keyboardShortcut(.init("2"), modifiers: .command) - Button("Show News") { + Button("News") { activeServerState?.selection = .news } .disabled(activeHotline?.status != .loggedIn || (activeHotline?.serverVersion ?? 0) < 151) .keyboardShortcut(.init("3"), modifiers: .command) - Button("Show Files") { + Button("Files") { activeServerState?.selection = .files } .disabled(activeHotline?.status != .loggedIn) .keyboardShortcut(.init("4"), modifiers: .command) - Button("Show Accounts") { - activeServerState?.selection = .accounts + + if activeHotline?.access?.contains(.canOpenUsers) == true { + Divider() + + Button("Accounts") { + activeServerState?.selection = .accounts + } + .disabled(activeHotline?.status != .loggedIn || activeHotline?.access?.contains(.canOpenUsers) != true ) + .keyboardShortcut(.init("5"), modifiers: .command) } - .disabled(activeHotline?.status != .loggedIn || activeHotline?.access?.contains(.canOpenUsers) != true ) - .keyboardShortcut(.init("5"), modifiers: .command) } } @@ -287,8 +296,8 @@ struct Application: App { TransfersView() .frame(minWidth: 500, minHeight: 200) } - .defaultSize(width: 600, height: 400) - .defaultPosition(.center) + .defaultSize(width: 500, height: 400) + .defaultPosition(.topTrailing) .keyboardShortcut(.init("T"), modifiers: [.shift, .command]) // MARK: Image Preview Window @@ -328,7 +337,7 @@ struct Application: App { func connect(to item: TrackerSelection) { if let server = item.server { - openWindow(id: "server", value: server) + self.openWindow(id: "server", value: server) } } diff --git a/Hotline/State/FilePreviewState.swift b/Hotline/State/FilePreviewState.swift index 97bd907..27f8199 100644 --- a/Hotline/State/FilePreviewState.swift +++ b/Hotline/State/FilePreviewState.swift @@ -1,10 +1,3 @@ -// -// FilePreviewState.swift -// Hotline -// -// Modern file preview state using HotlineFilePreviewClientNew -// - import SwiftUI import UniformTypeIdentifiers @@ -24,7 +17,7 @@ final class FilePreviewState { let info: PreviewFileInfo - private var previewClient: HotlineFilePreviewClientNew? + private var previewClient: HotlineFilePreviewClient? private var previewTask: Task? var state: LoadState = .unloaded @@ -67,7 +60,7 @@ final class FilePreviewState { let task = Task { @MainActor in do { - let client = HotlineFilePreviewClientNew( + let client = HotlineFilePreviewClient( fileName: info.name, address: info.address, port: UInt16(info.port), diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index 14fb9aa..e80571a 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -8,6 +8,10 @@ enum HotlineConnectionStatus: Equatable { case connected case loggedIn case failed(String) + + var isLoggingIn: Bool { + self == .connecting || self == .connected + } var isConnected: Bool { return self == .connected || self == .loggedIn @@ -254,7 +258,7 @@ class HotlineState: Equatable { @ObservationIgnored private var restoredChatSessionKey: ChatStore.SessionKey? @ObservationIgnored private var chatHistoryObserver: NSObjectProtocol? @ObservationIgnored private var lastPersistedMessageType: ChatMessageType? - + // MARK: - Initialization init() { @@ -262,8 +266,10 @@ class HotlineState: Equatable { forName: ChatStore.historyClearedNotification, object: nil, queue: .main - ) { @MainActor [weak self] _ in - self?.handleChatHistoryCleared() + ) { _ in + Task { @MainActor [weak self] in + self?.handleChatHistoryCleared() + } } } @@ -295,7 +301,7 @@ class HotlineState: Equatable { do { // Connect and login - let loginInfo = HotlineLoginInfo( + let loginInfo = HotlineLogin( login: server.login, password: server.password, username: username, @@ -360,7 +366,7 @@ class HotlineState: Equatable { await client.disconnect() self.client = nil } - self.status = .failed(error.localizedDescription) + self.status = .disconnected // .failed(error.localizedDescription) self.errorDisplayed = true self.errorMessage = error.localizedDescription throw error @@ -498,7 +504,7 @@ class HotlineState: Equatable { do { print("HotlineState: Banner download info - reference: \(result.referenceNumber), transferSize: \(result.transferSize)") - let previewClient = HotlineFilePreviewClientNew( + let previewClient = HotlineFilePreviewClient( fileName: "banner", address: address, port: UInt16(port), @@ -894,7 +900,7 @@ class HotlineState: Equatable { AppState.shared.addTransfer(transfer) // Create download client - let downloadClient = HotlineFileDownloadClientNew( + let downloadClient = HotlineFileDownloadClient( address: address, port: UInt16(port), reference: referenceNumber, @@ -1270,7 +1276,7 @@ class HotlineState: Equatable { print("HotlineState: Got upload reference: \(referenceNumber)") // Create upload client - guard let uploadClient = HotlineFileUploadClientNew( + guard let uploadClient = HotlineFileUploadClient( fileURL: fileURL, address: address, port: UInt16(port), diff --git a/Hotline/State/ServerState.swift b/Hotline/State/ServerState.swift index 5913bf5..805672d 100644 --- a/Hotline/State/ServerState.swift +++ b/Hotline/State/ServerState.swift @@ -29,7 +29,7 @@ enum ServerNavigationType: Identifiable, Hashable, Equatable { case .files: return "Files" case .accounts: - return "Accounts" + return "Admin" case .user(let userID): return String(userID) } diff --git a/Hotline/iOS/TrackerView.swift b/Hotline/iOS/TrackerView.swift index 2a3583b..8c846e5 100644 --- a/Hotline/iOS/TrackerView.swift +++ b/Hotline/iOS/TrackerView.swift @@ -9,7 +9,7 @@ struct TrackerConnectView: View { @State private var address = "" @State private var login = "" @State private var password = "" -// @State private var connecting = false + // @State private var connecting = false func connectionStatusToProgress(status: HotlineClientStatus) -> Double { switch status { @@ -27,116 +27,116 @@ struct TrackerConnectView: View { } var body: some View { - VStack(alignment: .leading) { - if self.model.status == .disconnected { - TextField("Server Address", text: $address) - .keyboardType(.URL) - .disableAutocorrection(true) - .frame(height: 48) - .textFieldStyle(.plain) - .padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) - .background { - Color.black.cornerRadius(8).blendMode(.overlay) - } - TextField("Login", text: $login) - .disableAutocorrection(true) - .frame(height: 48) - .textFieldStyle(.plain) - .padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) - .background { - Color.black.cornerRadius(8).blendMode(.overlay) - } - SecureField("Password", text: $password) - .disableAutocorrection(true) - .textFieldStyle(.plain) - .frame(height: 48) - .padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) - .background { - Color.black.cornerRadius(8).blendMode(.overlay) - } - } - else { - ProgressView(value: connectionStatusToProgress(status: model.status)) - .frame(minHeight: 10) - .accentColor(colorScheme == .dark ? .white : .black) - } - - Spacer() - - HStack { - Button { - dismiss() - server = nil - model.disconnect() - } label: { - Text("Cancel") + VStack(alignment: .leading) { + if self.model.status == .disconnected { + TextField("Server Address", text: $address) + .keyboardType(.URL) + .disableAutocorrection(true) + .frame(height: 48) + .textFieldStyle(.plain) + .padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) + .background { + Color.black.cornerRadius(8).blendMode(.overlay) } - .bold() - .padding(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24)) - .frame(maxWidth: .infinity) - .foregroundColor(colorScheme == .dark ? .white : .black) - .background( - colorScheme == .dark ? - LinearGradient(gradient: Gradient(colors: [Color(white: 0.4), Color(white: 0.3)]), startPoint: .top, endPoint: .bottom) - : - LinearGradient(gradient: Gradient(colors: [Color(white: 0.95), Color(white: 0.91)]), startPoint: .top, endPoint: .bottom) - ) - .overlay( - RoundedRectangle(cornerRadius: 10.0).stroke(.black, lineWidth: 3).opacity(colorScheme == .dark ? 0.0 : 0.2) - ) - .cornerRadius(10.0) - Button { - let s = Server(name: nil, description: nil, address: address, port: HotlinePorts.DefaultServerPort, users: 0, login: login, password: password) - server = s - self.model.login(server: s, username: "bolt", iconID: 128) { success in - if !success { - print("FAILED LOGIN??") - } - else { - self.model.sendUserInfo(username: "bolt", iconID: 128) - self.model.getUserList() - } + TextField("Login", text: $login) + .disableAutocorrection(true) + .frame(height: 48) + .textFieldStyle(.plain) + .padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) + .background { + Color.black.cornerRadius(8).blendMode(.overlay) + } + SecureField("Password", text: $password) + .disableAutocorrection(true) + .textFieldStyle(.plain) + .frame(height: 48) + .padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) + .background { + Color.black.cornerRadius(8).blendMode(.overlay) + } + } + else { + ProgressView(value: connectionStatusToProgress(status: model.status)) + .frame(minHeight: 10) + .accentColor(colorScheme == .dark ? .white : .black) + } + + Spacer() + + HStack { + Button { + dismiss() + server = nil + model.disconnect() + } label: { + Text("Cancel") + } + .bold() + .padding(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24)) + .frame(maxWidth: .infinity) + .foregroundColor(colorScheme == .dark ? .white : .black) + .background( + colorScheme == .dark ? + LinearGradient(gradient: Gradient(colors: [Color(white: 0.4), Color(white: 0.3)]), startPoint: .top, endPoint: .bottom) + : + LinearGradient(gradient: Gradient(colors: [Color(white: 0.95), Color(white: 0.91)]), startPoint: .top, endPoint: .bottom) + ) + .overlay( + RoundedRectangle(cornerRadius: 10.0).stroke(.black, lineWidth: 3).opacity(colorScheme == .dark ? 0.0 : 0.2) + ) + .cornerRadius(10.0) + Button { + let s = Server(name: nil, description: nil, address: address, port: HotlinePorts.DefaultServerPort, users: 0, login: login, password: password) + server = s + self.model.login(server: s, username: "bolt", iconID: 128) { success in + if !success { + print("FAILED LOGIN??") + } + else { + self.model.sendUserInfo(username: "bolt", iconID: 128) + self.model.getUserList() } - } label: { - Text("Connect") } - .disabled(self.model.status != .disconnected) - .bold() - .padding(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24)) - .frame(maxWidth: .infinity) - .foregroundColor(colorScheme == .dark ? .white : .black) - .background( - colorScheme == .dark ? - LinearGradient(gradient: Gradient(colors: [Color(white: 0.4), Color(white: 0.3)]), startPoint: .top, endPoint: .bottom) - : - LinearGradient(gradient: Gradient(colors: [Color(white: 0.95), Color(white: 0.91)]), startPoint: .top, endPoint: .bottom) - ) - .overlay( - RoundedRectangle(cornerRadius: 10.0).stroke(.black, lineWidth: 3).opacity(colorScheme == .dark ? 0.0 : 0.2) - ) - .cornerRadius(10.0) + } label: { + Text("Connect") } - .padding() + .disabled(self.model.status != .disconnected) + .bold() + .padding(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24)) + .frame(maxWidth: .infinity) + .foregroundColor(colorScheme == .dark ? .white : .black) + .background( + colorScheme == .dark ? + LinearGradient(gradient: Gradient(colors: [Color(white: 0.4), Color(white: 0.3)]), startPoint: .top, endPoint: .bottom) + : + LinearGradient(gradient: Gradient(colors: [Color(white: 0.95), Color(white: 0.91)]), startPoint: .top, endPoint: .bottom) + ) + .overlay( + RoundedRectangle(cornerRadius: 10.0).stroke(.black, lineWidth: 3).opacity(colorScheme == .dark ? 0.0 : 0.2) + ) + .cornerRadius(10.0) } .padding() - .onChange(of: model.status) { - print("MODEL STATUS CHANGED") - if model.server != nil && server != nil && model.server! == server! { - if model.status == .loggedIn { - dismiss() - } -// else { -// connecting = (model.status != .disconnected) -// } + } + .padding() + .onChange(of: self.model.status) { + print("MODEL STATUS CHANGED") + if self.model.server != nil && self.server != nil && self.model.server! == server! { + if self.model.status == .loggedIn { + self.dismiss() } + // else { + // connecting = (model.status != .disconnected) + // } } - .toolbar { - ToolbarItem(placement: .principal) { - Text("Connect to Server") - .font(.headline) - } + } + .toolbar { + ToolbarItem(placement: .principal) { + Text("Connect to Server") + .font(.headline) } -// .presentationBackground(.regularMaterial, in: Color(uiColor: .systemGroupedBackground)) + } + // .presentationBackground(.regularMaterial, in: Color(uiColor: .systemGroupedBackground)) .presentationBackground { Color.clear .background(Material.regular) @@ -190,12 +190,12 @@ struct TrackerView: View { } func updateServers() async { -// "hltracker.com" -// "tracker.preterhuman.net" -// "hotline.ubersoft.org" -// "tracked.nailbat.com" -// "hotline.duckdns.org" -// "tracked.agent79.org" + // "hltracker.com" + // "tracker.preterhuman.net" + // "hotline.ubersoft.org" + // "tracked.nailbat.com" + // "hotline.duckdns.org" + // "tracked.agent79.org" self.servers = await model.getServerList(tracker: "hltracker.com") } diff --git a/Hotline/macOS/ConnectView.swift b/Hotline/macOS/ConnectView.swift new file mode 100644 index 0000000..8419522 --- /dev/null +++ b/Hotline/macOS/ConnectView.swift @@ -0,0 +1,147 @@ +import SwiftUI + +struct ConnectView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + + @Binding var address: String + @Binding var login: String + @Binding var password: String + + var action: (() -> Void)? = nil + + @State private var bookmarkSheetPresented: Bool = false + @State private var bookmarkName: String = "" + + private enum FocusFields { + case address + case login + case password + } + + @FocusState private var focusedField: FocusFields? + + var body: some View { + VStack(alignment: .center, spacing: 0) { + Form { + HStack(alignment: .top, spacing: 10) { + Image("Server Large") + .resizable() + .scaledToFit() + .frame(width: 28, height: 28) + + VStack(alignment: .leading) { + Text("Connect to Server") + Text("Enter the address of a Hotline server to connect to.") + .foregroundStyle(.secondary) + .font(.subheadline) + } + } + + TextField(text: self.$address) { + Text("Address") + } + .focused($focusedField, equals: .address) + + TextField(text: self.$login, prompt: Text("Optional")) { + Text("Login") + } + .focused($focusedField, equals: .login) + + SecureField(text: self.$password, prompt: Text("Optional")) { + Text("Password") + } + .focused($focusedField, equals: .password) + } + .formStyle(.grouped) + .fixedSize(horizontal: false, vertical: true) + + HStack { + Button { + if !self.address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + self.bookmarkSheetPresented = true + } + } label: { + Image(systemName: "bookmark.fill") + } + .disabled(self.address.isEmpty) + .controlSize(.regular) + .buttonStyle(.automatic) + .help("Bookmark server") + + Spacer() + + Button("Cancel") { + self.dismiss() + } + .controlSize(.regular) + .buttonStyle(.automatic) + .keyboardShortcut(.cancelAction) + + Button("Connect") { + self.action?() +// self.connectToServer() + } + .controlSize(.regular) + .buttonStyle(.automatic) + .keyboardShortcut(.defaultAction) + } + .padding(.horizontal, 20) + } +// .onChange(of: self.address) { +// let (a, p) = Server.parseServerAddressAndPort(connectAddress) +// server.address = a +// server.port = p +// } +// .onChange(of: connectLogin) { +// server.login = connectLogin.trimmingCharacters(in: .whitespacesAndNewlines) +// } +// .onChange(of: connectPassword) { +// server.password = connectPassword +// } + .frame(maxWidth: 380) + .padding() + .onAppear { + self.focusedField = .address + } + .sheet(isPresented: self.$bookmarkSheetPresented) { + VStack(alignment: .leading) { + Text("Save Bookmark") + .foregroundStyle(.secondary) + .padding(.bottom, 4) + TextField("Bookmark Name", text: self.$bookmarkName) + .textFieldStyle(.roundedBorder) + .controlSize(.large) + } + .frame(width: 250) + .padding() + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + self.bookmarkSheetPresented = false + self.bookmarkName = "" + } + } + + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + let name = String(self.bookmarkName.trimmingCharacters(in: .whitespacesAndNewlines)) + if !name.isEmpty { + self.bookmarkSheetPresented = false + self.bookmarkName = "" + + let (host, port) = Server.parseServerAddressAndPort(self.address) + let login: String? = self.login.isBlank ? nil : self.login + let password: String? = self.password.isBlank ? nil : self.password + + if !host.isEmpty { + let newBookmark = Bookmark(type: .server, name: name, address: host, port: port, login: login, password: password) + Bookmark.add(newBookmark, context: modelContext) + } + } + } + } + } + } + } +} diff --git a/Hotline/macOS/Files/FilePreviewQuickLookView.swift b/Hotline/macOS/Files/FilePreviewQuickLookView.swift index 0323fdd..26bd286 100644 --- a/Hotline/macOS/Files/FilePreviewQuickLookView.swift +++ b/Hotline/macOS/Files/FilePreviewQuickLookView.swift @@ -1,10 +1,3 @@ -// -// FilePreviewQuickLookView.swift -// Hotline -// -// QuickLook-based file preview window for all supported file types -// - import SwiftUI import UniformTypeIdentifiers @@ -15,10 +8,11 @@ struct FilePreviewQuickLookView: View { @Environment(\.controlActiveState) private var controlActiveState @Environment(\.colorScheme) private var colorScheme - @Environment(\.dismiss) var dismiss + @Environment(\.dismiss) private var dismiss @Binding var info: PreviewFileInfo? @State var preview: FilePreviewState? = nil + @FocusState private var focusField: FilePreviewFocus? var body: some View { @@ -76,14 +70,14 @@ struct FilePreviewQuickLookView: View { .background(Color(nsColor: .textBackgroundColor)) .focused($focusField, equals: .window) .navigationTitle(info?.name ?? "File Preview") - .background( + .background { + if let fileURL = self.preview?.fileURL { WindowConfigurator { window in - if let fileURL = preview?.fileURL { - window.representedURL = fileURL - window.standardWindowButton(.documentIconButton)?.isHidden = false - } + window.representedURL = fileURL + window.standardWindowButton(.documentIconButton)?.isHidden = false } - ) + } + } .toolbar { if let _ = preview?.fileURL { if let info = info { diff --git a/Hotline/macOS/HotlinePanelView.swift b/Hotline/macOS/HotlinePanelView.swift index 220ea97..a56cd44 100644 --- a/Hotline/macOS/HotlinePanelView.swift +++ b/Hotline/macOS/HotlinePanelView.swift @@ -124,7 +124,7 @@ struct HotlinePanelView: View { .buttonStyle(.plain) .frame(width: 20, height: 20) .disabled(self.activeServerState == nil) - .help("Accounts") + .help("Administration") } Button { diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index 44274ea..757dbd8 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -47,10 +47,8 @@ struct ListItemView: View { if let i = icon { Image(i) .resizable() - // .renderingMode(.template) .scaledToFit() .frame(width: 20, height: 20) -// .padding(.leading, 2) .opacity(controlActiveState == .inactive ? 0.5 : 1.0) } @@ -74,29 +72,28 @@ extension FocusedValues { } struct ServerView: View { - @Environment(\.dismiss) var dismiss + @Environment(\.dismiss) private var dismiss @Environment(\.colorScheme) private var colorScheme @Environment(\.controlActiveState) private var controlActiveState @Environment(\.scenePhase) private var scenePhase @Environment(\.modelContext) private var modelContext + @Binding var server: Server + @State private var model: HotlineState = HotlineState() @State private var state: ServerState = ServerState(selection: .chat) @State private var agreementShown: Bool = false @State private var connectAddress: String = "" @State private var connectLogin: String = "" @State private var connectPassword: String = "" - @State private var connectNameSheetPresented: Bool = false - @State private var connectName: String = "" - - @Binding var server: Server + @State private var connectionDisplayed: Bool = false static var menuItems: [ServerMenuItem] = [ ServerMenuItem(type: .chat, name: "Chat", image: "Section Chat"), ServerMenuItem(type: .board, name: "Board", image: "Section Board"), ServerMenuItem(type: .news, name: "News", image: "Section News"), ServerMenuItem(type: .files, name: "Files", image: "Section Files"), - ServerMenuItem(type: .accounts, name: "Accounts", image: "Section Users"), + ServerMenuItem(type: .accounts, name: "Admin", image: "Section Users"), ] static var classicMenuItems: [ServerMenuItem] = [ @@ -105,50 +102,20 @@ struct ServerView: View { ServerMenuItem(type: .files, name: "Files", image: "Section Files"), ] - enum FocusFields { - case address - case login - case password - } - - @FocusState private var focusedField: FocusFields? - var body: some View { Group { - if model.status == .disconnected { + if self.model.status == .disconnected { VStack(alignment: .center) { Spacer() - self.connectForm +// if self.connectionDisplayed { + self.connectForm +// } Spacer() } .navigationTitle("Connect to Server") - .onAppear { - self.focusedField = .address - } - } - else if case .failed(let error) = model.status { - VStack { - Image("Hotline") - .resizable() - .renderingMode(.template) - .scaledToFit() - .foregroundColor(Color(hex: 0xE10000)) - .frame(width: 18) - .opacity(controlActiveState == .inactive ? 0.5 : 1.0) - .padding(.trailing, 4) - - Text("Connection Failed") - .font(.headline) - Text(error) - .font(.caption) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - .frame(maxWidth: 300) - .padding() - .navigationTitle("Connection Failed") +// .animation(.default.delay(0.25), value: self.connectionDisplayed) } - else if model.status != .loggedIn { + else if self.model.status.isLoggingIn { HStack { Image("Hotline") .resizable() @@ -156,38 +123,38 @@ struct ServerView: View { .scaledToFit() .foregroundColor(Color(hex: 0xE10000)) .frame(width: 18) - .opacity(controlActiveState == .inactive ? 0.5 : 1.0) + .opacity(self.controlActiveState == .inactive ? 0.5 : 1.0) .padding(.trailing, 4) - ProgressView(value: connectionStatusToProgress(status: model.status)) { - Text(connectionStatusToLabel(status: model.status)) + ProgressView(value: connectionStatusToProgress(status: self.model.status)) { + Text(connectionStatusToLabel(status: self.model.status)) } - .accentColor(colorScheme == .dark ? .white : .black) + .accentColor(self.colorScheme == .dark ? .white : .black) } .frame(maxWidth: 300) .padding() .navigationTitle("Connecting to Server") } - else { - serverView - .environment(model) + else if self.model.status == .loggedIn { + self.serverView + .environment(self.model) .onChange(of: Prefs.shared.userIconID) { - Task { try? await model.sendUserPreferences() } + Task { try? await self.model.sendUserPreferences() } } .onChange(of: Prefs.shared.username) { - Task { try? await model.sendUserPreferences() } + Task { try? await self.model.sendUserPreferences() } } .onChange(of: Prefs.shared.refusePrivateMessages) { - Task { try? await model.sendUserPreferences() } + Task { try? await self.model.sendUserPreferences() } } .onChange(of: Prefs.shared.refusePrivateChat) { - Task { try? await model.sendUserPreferences() } + Task { try? await self.model.sendUserPreferences() } } .onChange(of: Prefs.shared.enableAutomaticMessage) { - Task { try? await model.sendUserPreferences() } + Task { try? await self.model.sendUserPreferences() } } .onChange(of: Prefs.shared.automaticMessage) { - Task { try? await model.sendUserPreferences() } + Task { try? await self.model.sendUserPreferences() } } .toolbar { if #available(macOS 26.0, *) { @@ -196,7 +163,7 @@ struct ServerView: View { .resizable() .scaledToFit() .frame(width: 28) - .opacity(controlActiveState == .inactive ? 0.4 : 1.0) + .opacity(self.controlActiveState == .inactive ? 0.4 : 1.0) } .sharedBackgroundVisibility(.hidden) } @@ -206,7 +173,7 @@ struct ServerView: View { .resizable() .scaledToFit() .frame(width: 28) - .opacity(controlActiveState == .inactive ? 0.4 : 1.0) + .opacity(self.controlActiveState == .inactive ? 0.4 : 1.0) } } } @@ -214,164 +181,54 @@ struct ServerView: View { } .onDisappear { Task { - await model.disconnect() + await self.model.disconnect() } } - .onChange(of: model.serverTitle) { - state.serverName = model.serverTitle + .onChange(of: self.model.serverTitle) { + self.state.serverName = self.model.serverTitle } -// .onChange(of: model.bannerImage) { -// state.serverBanner = model.bannerImage -// } -// .onChange(of: model.bannerColors) { -// guard let backgroundColor = model.bannerColors?.backgroundColor else { -// state.bannerBackgroundColor = nil -// return -// } -// state.bannerBackgroundColor = Color(nsColor: backgroundColor) -// } - .alert(model.errorMessage ?? "Server Error", isPresented: $model.errorDisplayed) { + .alert(self.model.errorMessage ?? "Server Error", isPresented: self.$model.errorDisplayed) { Button("OK") {} } - .task { - var address = server.address - if server.port != HotlinePorts.DefaultServerPort { - address += ":\(server.port)" + .onAppear { + var address = self.server.address + if self.server.port != HotlinePorts.DefaultServerPort { + address += ":\(self.server.port)" } - connectAddress = server.address - connectLogin = server.login - connectPassword = server.password + self.connectAddress = self.server.address + self.connectLogin = self.server.login + self.connectPassword = self.server.password // Connect to server automatically unless the option key is held down. if !NSEvent.modifierFlags.contains(.option) { - connectToServer() + self.connectToServer() } + + self.connectionDisplayed = true } .focusedSceneValue(\.activeHotlineModel, model) .focusedSceneValue(\.activeServerState, state) } - var connectForm: some View { - VStack(alignment: .center, spacing: 0) { - Form { - HStack(alignment: .top, spacing: 10) { - Image("Server Large") - .resizable() - .scaledToFit() - .frame(width: 28, height: 28) - - VStack(alignment: .leading) { - Text("Connect to Server") - Text("Enter the address of a Hotline server to connect to.") - .foregroundStyle(.secondary) - .font(.subheadline) - } - } - - TextField(text: $connectAddress) { - Text("Address") - } - .focused($focusedField, equals: .address) - - TextField(text: $connectLogin, prompt: Text("Optional")) { - Text("Login") - } - .focused($focusedField, equals: .login) - - SecureField(text: $connectPassword, prompt: Text("Optional")) { - Text("Password") - } - .focused($focusedField, equals: .password) - } - .formStyle(.grouped) - .fixedSize(horizontal: false, vertical: true) - - HStack { - Button { - if !connectAddress.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - connectNameSheetPresented = true - } - } label: { - Image(systemName: "bookmark.fill") - } - .disabled(connectAddress.isEmpty) - .controlSize(.regular) - .buttonStyle(.automatic) - .help("Bookmark server") - - Spacer() - - Button("Cancel") { - dismiss() - } - .controlSize(.regular) - .buttonStyle(.automatic) - .keyboardShortcut(.cancelAction) - - Button("Connect") { - connectToServer() - } - .controlSize(.regular) - .buttonStyle(.automatic) - .keyboardShortcut(.defaultAction) - } - .padding(.horizontal, 20) + private var connectForm: some View { + ConnectView(address: self.$connectAddress, login: self.$connectLogin, password: self.$connectPassword) { + self.connectToServer() } - .onChange(of: connectAddress) { - let (a, p) = Server.parseServerAddressAndPort(connectAddress) - server.address = a - server.port = p + .focusSection() + .onChange(of: self.connectAddress) { + let (a, p) = Server.parseServerAddressAndPort(self.connectAddress) + self.server.address = a + self.server.port = p } - .onChange(of: connectLogin) { - server.login = connectLogin.trimmingCharacters(in: .whitespacesAndNewlines) + .onChange(of: self.connectLogin) { + self.server.login = self.connectLogin.trimmingCharacters(in: .whitespacesAndNewlines) } - .onChange(of: connectPassword) { - server.password = connectPassword - } - .frame(maxWidth: 380) - .padding() - .sheet(isPresented: $connectNameSheetPresented) { - VStack(alignment: .leading) { - Text("Save Bookmark") - .foregroundStyle(.secondary) - .padding(.bottom, 4) - TextField("Bookmark Name", text: $connectName) - .textFieldStyle(.roundedBorder) - .controlSize(.large) - } - .frame(width: 250) - .padding() - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - connectNameSheetPresented = false - connectName = "" - } - } - - ToolbarItem(placement: .confirmationAction) { - Button("Save") { - let name = String(connectName.trimmingCharacters(in: .whitespacesAndNewlines)) - if !name.isEmpty { - connectNameSheetPresented = false - connectName = "" - - let (host, port) = Server.parseServerAddressAndPort(connectAddress) - let login: String? = connectLogin.isEmpty ? nil : connectLogin - let password: String? = connectPassword.isEmpty ? nil : connectPassword - - if !host.isEmpty { - let newBookmark = Bookmark(type: .server, name: name, address: host, port: port, login: login, password: password) - Bookmark.add(newBookmark, context: modelContext) - } - } - } - } - } + .onChange(of: self.connectPassword) { + self.server.password = self.connectPassword } } - var navigationList: some View { + private var navigationList: some View { List(selection: $state.selection) { // Don't show news on older servers. ForEach(model.serverVersion < 151 ? ServerView.classicMenuItems : ServerView.menuItems) { menuItem in @@ -496,7 +353,7 @@ struct ServerView: View { case .accounts: AccountManagerView() .navigationTitle(model.serverTitle) - .navigationSubtitle("Accounts") + .navigationSubtitle("Administration") .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .user(let userID): let user = model.users.first(where: { $0.id == userID }) @@ -510,21 +367,21 @@ struct ServerView: View { } } .toolbar(removing: .sidebarToggle) - } - // MARK: - @MainActor func connectToServer() { - guard !server.address.isEmpty else { + guard !self.server.address.isEmpty else { return } + + // Set status here so it's immediate (not waiting to enter task). + self.model.status = .connecting Task { @MainActor in do { - // login() handles everything: connect, getUserList, sendPreferences, downloadBanner - try await model.login( + try await self.model.login( server: server, username: Prefs.shared.username, iconID: Prefs.shared.userIconID -- cgit From ddb9c69b24a67ac140af9ff20f5c36bdef6fb51b Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Mon, 10 Nov 2025 21:00:43 -0800 Subject: Remove "New" suffix from our transfer clients. Further cleanup. Allow previewing certain files with HFS types (but no extensions). Cleanup preview files when window closes. --- Hotline.xcodeproj/project.pbxproj | 40 +- Hotline/Hotline/HotlineClientNew.swift | 108 ++--- .../Transfers/HotlineFileDownloadClient.swift | 269 +++++++++++ .../Transfers/HotlineFileDownloadClientNew.swift | 269 ----------- .../Transfers/HotlineFilePreviewClient.swift | 162 +++++++ .../Transfers/HotlineFilePreviewClientNew.swift | 146 ------ .../Transfers/HotlineFileUploadClient.swift | 225 +++++++++ .../Transfers/HotlineFileUploadClientNew.swift | 225 --------- .../Transfers/HotlineFolderDownloadClient.swift | 450 ++++++++++++++++++ .../Transfers/HotlineFolderDownloadClientNew.swift | 453 ------------------ .../Transfers/HotlineFolderUploadClient.swift | 512 ++++++++++++++++++++ .../Transfers/HotlineFolderUploadClientNew.swift | 518 --------------------- Hotline/Library/Extensions.swift | 90 +++- Hotline/Models/FileInfo.swift | 10 +- Hotline/Models/PreviewFileInfo.swift | 3 + Hotline/State/AppUpdate.swift | 9 +- Hotline/State/FilePreviewState.swift | 155 +++--- Hotline/State/HotlineState.swift | 22 +- Hotline/State/ServerState.swift | 2 +- Hotline/macOS/Files/FilePreviewQuickLookView.swift | 53 +-- Hotline/macOS/Files/FilesView.swift | 17 +- Hotline/macOS/Files/FolderItemView.swift | 2 +- Hotline/macOS/ServerView.swift | 72 ++- Hotline/macOS/TransfersView.swift | 4 +- 24 files changed, 1931 insertions(+), 1885 deletions(-) create mode 100644 Hotline/Hotline/Transfers/HotlineFileDownloadClient.swift delete mode 100644 Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift create mode 100644 Hotline/Hotline/Transfers/HotlineFilePreviewClient.swift delete mode 100644 Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift create mode 100644 Hotline/Hotline/Transfers/HotlineFileUploadClient.swift delete mode 100644 Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift create mode 100644 Hotline/Hotline/Transfers/HotlineFolderDownloadClient.swift delete mode 100644 Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift create mode 100644 Hotline/Hotline/Transfers/HotlineFolderUploadClient.swift delete mode 100644 Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift (limited to 'Hotline/State/HotlineState.swift') diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index aa10d51..57e7032 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -22,9 +22,9 @@ DA32CD4B2B29318E0053B98B /* FileInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32CD4A2B29318E0053B98B /* FileInfo.swift */; }; DA32CD4D2B2931B50053B98B /* ChatMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32CD4C2B2931B50053B98B /* ChatMessage.swift */; }; DA32CD4F2B2931CC0053B98B /* NewsInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32CD4E2B2931CC0053B98B /* NewsInfo.swift */; }; - DA3429AE2EB9C0280010784E /* HotlineFileUploadClientNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429AD2EB9C0220010784E /* HotlineFileUploadClientNew.swift */; }; - DA3429B02EBA70790010784E /* HotlineFolderUploadClientNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429AF2EBA70790010784E /* HotlineFolderUploadClientNew.swift */; }; - DA3429B32EBA7ADF0010784E /* HotlineFilePreviewClientNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429B22EBA7ADE0010784E /* HotlineFilePreviewClientNew.swift */; }; + DA3429AE2EB9C0280010784E /* HotlineFileUploadClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429AD2EB9C0220010784E /* HotlineFileUploadClient.swift */; }; + DA3429B02EBA70790010784E /* HotlineFolderUploadClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429AF2EBA70790010784E /* HotlineFolderUploadClient.swift */; }; + DA3429B32EBA7ADF0010784E /* HotlineFilePreviewClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429B22EBA7ADE0010784E /* HotlineFilePreviewClient.swift */; }; DA3429B52EBA8A450010784E /* FilePreviewState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429B42EBA8A450010784E /* FilePreviewState.swift */; }; DA3429B72EBAB1750010784E /* QuickLookPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429B62EBAB1750010784E /* QuickLookPreviewView.swift */; platformFilters = (macos, ); }; DA3429B92EBAB2130010784E /* FilePreviewQuickLookView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429B82EBAB2130010784E /* FilePreviewQuickLookView.swift */; }; @@ -49,11 +49,11 @@ DA5268AD2EB12FE200DCB941 /* ServerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268AC2EB12FE200DCB941 /* ServerState.swift */; }; DA5268AF2EB2682B00DCB941 /* HotlineClientNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268AE2EB2682B00DCB941 /* HotlineClientNew.swift */; }; DA5268B12EB2708E00DCB941 /* HotlineState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268B02EB2708E00DCB941 /* HotlineState.swift */; }; - DA5268B32EB6806E00DCB941 /* HotlineFileDownloadClientNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268B22EB6806E00DCB941 /* HotlineFileDownloadClientNew.swift */; }; + DA5268B32EB6806E00DCB941 /* HotlineFileDownloadClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268B22EB6806E00DCB941 /* HotlineFileDownloadClient.swift */; }; DA5268B52EB6840A00DCB941 /* TransfersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268B42EB6840A00DCB941 /* TransfersView.swift */; }; DA5268B82EB916AF00DCB941 /* TransferRateEstimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268B72EB916AF00DCB941 /* TransferRateEstimator.swift */; }; DA5268BA2EB91B5E00DCB941 /* FileProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268B92EB91B5E00DCB941 /* FileProgress.swift */; }; - DA5268BC2EB95A7700DCB941 /* HotlineFolderDownloadClientNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268BB2EB95A7700DCB941 /* HotlineFolderDownloadClientNew.swift */; }; + DA5268BC2EB95A7700DCB941 /* HotlineFolderDownloadClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268BB2EB95A7700DCB941 /* HotlineFolderDownloadClient.swift */; }; DA55AC732BE42AF000034857 /* AsyncLinkPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA55AC722BE42AF000034857 /* AsyncLinkPreview.swift */; platformFilters = (macos, ); }; DA55AC752BE4888300034857 /* InstantMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA55AC742BE4888300034857 /* InstantMessage.swift */; }; DA55AC772BE589F700034857 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA55AC762BE589F700034857 /* AboutView.swift */; platformFilters = (macos, ); }; @@ -136,9 +136,9 @@ DA32CD4A2B29318E0053B98B /* FileInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileInfo.swift; sourceTree = ""; }; DA32CD4C2B2931B50053B98B /* ChatMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessage.swift; sourceTree = ""; }; DA32CD4E2B2931CC0053B98B /* NewsInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsInfo.swift; sourceTree = ""; }; - DA3429AD2EB9C0220010784E /* HotlineFileUploadClientNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFileUploadClientNew.swift; sourceTree = ""; }; - DA3429AF2EBA70790010784E /* HotlineFolderUploadClientNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFolderUploadClientNew.swift; sourceTree = ""; }; - DA3429B22EBA7ADE0010784E /* HotlineFilePreviewClientNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFilePreviewClientNew.swift; sourceTree = ""; }; + DA3429AD2EB9C0220010784E /* HotlineFileUploadClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFileUploadClient.swift; sourceTree = ""; }; + DA3429AF2EBA70790010784E /* HotlineFolderUploadClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFolderUploadClient.swift; sourceTree = ""; }; + DA3429B22EBA7ADE0010784E /* HotlineFilePreviewClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFilePreviewClient.swift; sourceTree = ""; }; DA3429B42EBA8A450010784E /* FilePreviewState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePreviewState.swift; sourceTree = ""; }; DA3429B62EBAB1750010784E /* QuickLookPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickLookPreviewView.swift; sourceTree = ""; }; DA3429B82EBAB2130010784E /* FilePreviewQuickLookView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePreviewQuickLookView.swift; sourceTree = ""; }; @@ -162,11 +162,11 @@ DA5268AC2EB12FE200DCB941 /* ServerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerState.swift; sourceTree = ""; }; DA5268AE2EB2682B00DCB941 /* HotlineClientNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineClientNew.swift; sourceTree = ""; }; DA5268B02EB2708E00DCB941 /* HotlineState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineState.swift; sourceTree = ""; }; - DA5268B22EB6806E00DCB941 /* HotlineFileDownloadClientNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFileDownloadClientNew.swift; sourceTree = ""; }; + DA5268B22EB6806E00DCB941 /* HotlineFileDownloadClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFileDownloadClient.swift; sourceTree = ""; }; DA5268B42EB6840A00DCB941 /* TransfersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransfersView.swift; sourceTree = ""; }; DA5268B72EB916AF00DCB941 /* TransferRateEstimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransferRateEstimator.swift; sourceTree = ""; }; DA5268B92EB91B5E00DCB941 /* FileProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProgress.swift; sourceTree = ""; }; - DA5268BB2EB95A7700DCB941 /* HotlineFolderDownloadClientNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFolderDownloadClientNew.swift; sourceTree = ""; }; + DA5268BB2EB95A7700DCB941 /* HotlineFolderDownloadClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFolderDownloadClient.swift; sourceTree = ""; }; DA55AC722BE42AF000034857 /* AsyncLinkPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncLinkPreview.swift; sourceTree = ""; }; DA55AC742BE4888300034857 /* InstantMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstantMessage.swift; sourceTree = ""; }; DA55AC762BE589F700034857 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; @@ -248,11 +248,11 @@ isa = PBXGroup; children = ( DA5753672B33E88A00FAC277 /* HotlineTransferClient.swift */, - DA3429B22EBA7ADE0010784E /* HotlineFilePreviewClientNew.swift */, - DA5268B22EB6806E00DCB941 /* HotlineFileDownloadClientNew.swift */, - DA3429AD2EB9C0220010784E /* HotlineFileUploadClientNew.swift */, - DA5268BB2EB95A7700DCB941 /* HotlineFolderDownloadClientNew.swift */, - DA3429AF2EBA70790010784E /* HotlineFolderUploadClientNew.swift */, + DA3429B22EBA7ADE0010784E /* HotlineFilePreviewClient.swift */, + DA5268B22EB6806E00DCB941 /* HotlineFileDownloadClient.swift */, + DA3429AD2EB9C0220010784E /* HotlineFileUploadClient.swift */, + DA5268BB2EB95A7700DCB941 /* HotlineFolderDownloadClient.swift */, + DA3429AF2EBA70790010784E /* HotlineFolderUploadClient.swift */, ); path = Transfers; sourceTree = ""; @@ -639,9 +639,9 @@ DA52689E2EB073A400DCB941 /* IconSettingsView.swift in Sources */, DAC3D9832BC33FD000A727C9 /* AppState.swift in Sources */, DA65499C2BEC3FBD00EDB697 /* ServerAgreementView.swift in Sources */, - DA5268BC2EB95A7700DCB941 /* HotlineFolderDownloadClientNew.swift in Sources */, + DA5268BC2EB95A7700DCB941 /* HotlineFolderDownloadClient.swift in Sources */, DA2863DD2B3E8B7000A7D050 /* FilePreview.swift in Sources */, - DA3429B02EBA70790010784E /* HotlineFolderUploadClientNew.swift in Sources */, + DA3429B02EBA70790010784E /* HotlineFolderUploadClient.swift in Sources */, DA5268AB2EB11EA300DCB941 /* ColorArt.swift in Sources */, DA2863D82B37AD1C00A7D050 /* SettingsView.swift in Sources */, DA5268BA2EB91B5E00DCB941 /* FileProgress.swift in Sources */, @@ -672,7 +672,7 @@ DA32CD4B2B29318E0053B98B /* FileInfo.swift in Sources */, DAF5BC6C2EC2727700551E4D /* ConnectView.swift in Sources */, DA20BBE12BF5237600B94E7C /* Bookmark.swift in Sources */, - DA3429B32EBA7ADF0010784E /* HotlineFilePreviewClientNew.swift in Sources */, + DA3429B32EBA7ADF0010784E /* HotlineFilePreviewClient.swift in Sources */, DA9CAFCB2B126E3300CDA197 /* HotlineTrackerClient.swift in Sources */, DA2863DA2B37BF6E00A7D050 /* Preferences.swift in Sources */, DA32CD4F2B2931CC0053B98B /* NewsInfo.swift in Sources */, @@ -706,7 +706,7 @@ DA501BF02EBED848001714F8 /* BonjourState.swift in Sources */, DAAEE66F2B47625600A5BA07 /* FilePreviewImageView.swift in Sources */, DA872B152BDDEE1A008B1012 /* VisualEffectView.swift in Sources */, - DA3429AE2EB9C0280010784E /* HotlineFileUploadClientNew.swift in Sources */, + DA3429AE2EB9C0280010784E /* HotlineFileUploadClient.swift in Sources */, DAAEE66D2B475F1400A5BA07 /* PreviewFileInfo.swift in Sources */, DAB4D8842B4CABEF0048A05C /* Extensions.swift in Sources */, DA0D698D2B1E7CF700C71DF5 /* UsersView.swift in Sources */, @@ -723,7 +723,7 @@ DAB4D87E2B4C8BCA0048A05C /* FilePreviewTextView.swift in Sources */, DA3429B72EBAB1750010784E /* QuickLookPreviewView.swift in Sources */, DA4F2C012B1A558E00D8ADDC /* ChatView.swift in Sources */, - DA5268B32EB6806E00DCB941 /* HotlineFileDownloadClientNew.swift in Sources */, + DA5268B32EB6806E00DCB941 /* HotlineFileDownloadClient.swift in Sources */, DACCE5E32EABE86A008CDD92 /* AppUpdateView.swift in Sources */, DAC6B2E02EAC6236004E2CBA /* ChatStore.swift in Sources */, DA32CD492B2931640053B98B /* User.swift in Sources */, diff --git a/Hotline/Hotline/HotlineClientNew.swift b/Hotline/Hotline/HotlineClientNew.swift index 854c7ff..cd34d9e 100644 --- a/Hotline/Hotline/HotlineClientNew.swift +++ b/Hotline/Hotline/HotlineClientNew.swift @@ -93,11 +93,11 @@ public struct HotlineServerInfo: Sendable { // MARK: - Hotline Client -/// Modern async/await-based Hotline protocol client +/// A client for connecting to and interacting with Hotline servers. /// /// Example usage: /// ```swift -/// let client = try await HotlineClientNew.connect( +/// let client = try await HotlineClient.connect( /// host: "server.example.com", /// port: 5500, /// login: HotlineLogin(login: "guest", password: "", username: "John", iconID: 414) @@ -123,7 +123,7 @@ public struct HotlineServerInfo: Sendable { /// // Get user list /// let users = try await client.getUserList() /// ``` -public actor HotlineClientNew { +public actor HotlineClient { // MARK: - Properties private let socket: NetSocket @@ -189,23 +189,23 @@ public actor HotlineClientNew { host: String, port: UInt16 = 5500, login: HotlineLogin - ) async throws -> HotlineClientNew { - print("HotlineClientNew.connect(): Starting connection to \(host):\(port) as '\(login.username)'") + ) async throws -> HotlineClient { + print("HotlineClient.connect(): Starting connection to \(host):\(port) as '\(login.username)'") // Connect socket - print("HotlineClientNew.connect(): Connecting socket...") + print("HotlineClient.connect(): Connecting socket...") let socket = try await NetSocket.connect(host: host, port: port) - print("HotlineClientNew.connect(): Socket connected") + print("HotlineClient.connect(): Socket connected") // Perform handshake - print("HotlineClientNew.connect(): Sending handshake...") + print("HotlineClient.connect(): Sending handshake...") try await socket.write(handshakeData) let handshakeResponse = try await socket.read(8) - print("HotlineClientNew.connect(): Handshake response received") + print("HotlineClient.connect(): Handshake response received") // Verify handshake guard handshakeResponse.prefix(4) == Data([0x54, 0x52, 0x54, 0x50]) else { - print("HotlineClientNew.connect(): Invalid handshake response") + print("HotlineClient.connect(): Invalid handshake response") throw HotlineClientError.connectionFailed( NSError(domain: "HotlineClient", code: -1, userInfo: [ NSLocalizedDescriptionKey: "Invalid handshake response" @@ -215,7 +215,7 @@ public actor HotlineClientNew { let errorCode = handshakeResponse.withUnsafeBytes { $0.load(fromByteOffset: 4, as: UInt32.self) } guard errorCode.bigEndian == 0 else { - print("HotlineClientNew.connect(): Handshake failed with error code \(errorCode)") + print("HotlineClient.connect(): Handshake failed with error code \(errorCode)") throw HotlineClientError.connectionFailed( NSError(domain: "HotlineClient", code: Int(errorCode), userInfo: [ NSLocalizedDescriptionKey: "Handshake failed with error code \(errorCode)" @@ -224,24 +224,24 @@ public actor HotlineClientNew { } // Create client - print("HotlineClientNew.connect(): Creating client instance") - let client = HotlineClientNew(socket: socket) + print("HotlineClient.connect(): Creating client instance") + let client = HotlineClient(socket: socket) // Start receive loop - print("HotlineClientNew.connect(): Starting receive loop") + print("HotlineClient.connect(): Starting receive loop") await client.startReceiveLoop() // Perform login - print("HotlineClientNew.connect(): Performing login") + print("HotlineClient.connect(): Performing login") let serverInfo = try await client.performLogin(login) await client.setServerInfo(serverInfo) - print("HotlineClientNew.connect(): Login successful") + print("HotlineClient.connect(): Login successful") // Start keep-alive - print("HotlineClientNew.connect(): Starting keep-alive") + print("HotlineClient.connect(): Starting keep-alive") await client.startKeepAlive() - print("HotlineClientNew.connect(): Connected to \(serverInfo.name) (v\(serverInfo.version))") + print("HotlineClient.connect(): Connected to \(serverInfo.name) (v\(serverInfo.version))") return client } @@ -296,19 +296,19 @@ public actor HotlineClientNew { isConnected = false - print("HotlineClientNew.disconnect(): Starting disconnect") + print("HotlineClient.disconnect(): Starting disconnect") self.receiveTask?.cancel() self.keepAliveTask?.cancel() await self.socket.close() self.failAllPendingTransactions(HotlineClientError.notConnected) self.eventContinuation.finish() - print("HotlineClientNew.disconnect(): Disconnect complete") + print("HotlineClient.disconnect(): Disconnect complete") } // MARK: - Receive Loop private func startReceiveLoop() { - print("HotlineClientNew.startReceiveLoop(): Creating receive task") + print("HotlineClient.startReceiveLoop(): Creating receive task") self.receiveTask = Task { [weak self] in guard let self else { return @@ -320,21 +320,21 @@ public actor HotlineClientNew { let transaction = try await self.socket.receive(HotlineTransaction.self, endian: .big) await self.handleTransaction(transaction) } - print("HotlineClientNew.startReceiveLoop(): Task cancelled, exiting loop") + print("HotlineClient.startReceiveLoop(): Task cancelled, exiting loop") } catch { if Task.isCancelled || error is CancellationError { - print("HotlineClientNew.startReceiveLoop(): Receive loop cancelled") + print("HotlineClient.startReceiveLoop(): Receive loop cancelled") } else { - print("HotlineClientNew.startReceiveLoop(): Receive loop error: \(error)") + print("HotlineClient.startReceiveLoop(): Receive loop error: \(error)") await self.disconnect() } } - print("HotlineClientNew.startReceiveLoop(): Receive loop ended") + print("HotlineClient.startReceiveLoop(): Receive loop ended") } } private func handleTransaction(_ transaction: HotlineTransaction) { - print("HotlineClientNew: <= \(transaction.type) [\(transaction.id)]") + print("HotlineClient: <= \(transaction.type) [\(transaction.id)]") // Check if this is a reply to a pending transaction if transaction.isReply == 1 || transaction.type == .reply { @@ -348,7 +348,7 @@ public actor HotlineClientNew { private func handleReply(_ transaction: HotlineTransaction) { guard let continuation = pendingTransactions.removeValue(forKey: transaction.id) else { - print("HotlineClientNew: Received reply for unknown transaction \(transaction.id)") + print("HotlineClient: Received reply for unknown transaction \(transaction.id)") return } @@ -359,7 +359,6 @@ public actor HotlineClientNew { message: errorText )) } else { - print("HELLO") continuation.resume(returning: transaction) } } @@ -417,14 +416,15 @@ public actor HotlineClientNew { } default: - print("HotlineClientNew: Unhandled event type \(transaction.type)") + print("HotlineClient: Unhandled event type \(transaction.type)") } } // MARK: - Transaction Sending + @discardableResult private func sendTransaction(_ transaction: HotlineTransaction, timeout: TimeInterval = 30.0) async throws -> HotlineTransaction { - print("HotlineClientNew: => \(transaction.type) [\(transaction.id)]") + print("HotlineClient: => \(transaction.type) [\(transaction.id)]") let transactionID = transaction.id @@ -518,7 +518,7 @@ public actor HotlineClientNew { let _ = try? await self.getUserList() } } catch { - print("HotlineClientNew: Keep-alive failed: \(error)") + print("HotlineClient: Keep-alive failed: \(error)") } } @@ -605,7 +605,7 @@ public actor HotlineClientNew { try await socket.send(transaction, endian: .big) } - // MARK: - Public API - Files + // MARK: - Files /// Get the file list for a directory /// @@ -617,7 +617,7 @@ public actor HotlineClientNew { transaction.setFieldPath(type: .filePath, val: path) } - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) var files: [HotlineFile] = [] for field in reply.getFieldList(type: .fileNameWithInfo) { @@ -649,7 +649,7 @@ public actor HotlineClientNew { transaction.setFieldUInt32(type: .fileTransferOptions, val: 2) } - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) guard let transferSize = reply.getField(type: .transferSize)?.getInteger(), @@ -664,7 +664,7 @@ public actor HotlineClientNew { return (referenceNumber, transferSize, fileSize, waitingCount) } - // MARK: - Public API - News + // MARK: - News /// Get news categories at a path /// @@ -676,7 +676,7 @@ public actor HotlineClientNew { transaction.setFieldPath(type: .newsPath, val: path) } - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) var categories: [HotlineNewsCategory] = [] for field in reply.getFieldList(type: .newsCategoryListData15) { @@ -698,7 +698,7 @@ public actor HotlineClientNew { transaction.setFieldPath(type: .newsPath, val: path) } - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) guard let articleData = reply.getField(type: .newsArticleListData) else { return [] @@ -725,7 +725,7 @@ public actor HotlineClientNew { transaction.setFieldUInt32(type: .newsArticleID, val: id) transaction.setFieldString(type: .newsArticleDataFlavor, val: flavor, encoding: .ascii) - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) return reply.getField(type: .newsArticleData)?.getString() } @@ -754,17 +754,17 @@ public actor HotlineClientNew { transaction.setFieldUInt32(type: .newsArticleFlags, val: 0) transaction.setFieldString(type: .newsArticleData, val: text) - _ = try await sendTransaction(transaction) + try await self.sendTransaction(transaction) } - // MARK: - Public API - Message Board + // MARK: - Message Board /// Get message board posts /// /// - Returns: Array of message strings public func getMessageBoard() async throws -> [String] { let transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getMessageBoard) - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) guard let text = reply.getField(type: .data)?.getString() else { return [] @@ -784,10 +784,10 @@ public actor HotlineClientNew { var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .oldPostNews) transaction.setFieldString(type: .data, val: text, encoding: .macOSRoman) - try await socket.send(transaction, endian: .big) + try await self.socket.send(transaction, endian: .big) } - // MARK: - Public API - File Operations + // MARK: - File Operations /// Get detailed information about a file /// @@ -840,7 +840,7 @@ public actor HotlineClientNew { transaction.setFieldPath(type: .filePath, val: path) do { - _ = try await sendTransaction(transaction) + try await self.sendTransaction(transaction) return true } catch { return false @@ -854,7 +854,7 @@ public actor HotlineClientNew { /// - Returns: Array of user accounts sorted by login public func getAccounts() async throws -> [HotlineAccount] { let transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getAccounts) - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) let accountFields = reply.getFieldList(type: .data) var accounts: [HotlineAccount] = [] @@ -886,7 +886,7 @@ public actor HotlineClientNew { transaction.setFieldEncodedString(type: .userPassword, val: password) } - _ = try await sendTransaction(transaction) + try await self.sendTransaction(transaction) } /// Update an existing user account (requires admin access) @@ -919,7 +919,7 @@ public actor HotlineClientNew { transaction.setFieldEncodedString(type: .userPassword, val: password!) } - _ = try await sendTransaction(transaction) + try await self.sendTransaction(transaction) } /// Delete a user account (requires admin access) @@ -929,7 +929,7 @@ public actor HotlineClientNew { var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .deleteUser) transaction.setFieldEncodedString(type: .userLogin, val: login) - _ = try await sendTransaction(transaction) + try await self.sendTransaction(transaction) } // MARK: - Banners @@ -940,7 +940,7 @@ public actor HotlineClientNew { /// - Throws: HotlineClientError if not connected or server doesn't support banners public func downloadBanner() async throws -> (referenceNumber: UInt32, transferSize: Int)? { let transaction = HotlineTransaction(id: self.generateTransactionID(), type: .downloadBanner) - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) guard let transferSizeField = reply.getField(type: .transferSize), @@ -972,7 +972,7 @@ public actor HotlineClientNew { transaction.setFieldUInt32(type: .fileTransferOptions, val: 2) } - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) guard let transferSizeField = reply.getField(type: .transferSize), @@ -1000,7 +1000,7 @@ public actor HotlineClientNew { transaction.setFieldString(type: .fileName, val: name) transaction.setFieldPath(type: .filePath, val: path) - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) guard let transferSizeField = reply.getField(type: .transferSize), @@ -1027,7 +1027,7 @@ public actor HotlineClientNew { transaction.setFieldString(type: .fileName, val: name) transaction.setFieldPath(type: .filePath, val: path) - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) guard let transferReferenceField = reply.getField(type: .referenceNumber), @@ -1046,7 +1046,7 @@ public actor HotlineClientNew { /// - path: Directory path where the folder should be uploaded /// - Returns: Reference number for the upload transfer public func uploadFolder(name: String, path: [String], fileCount: UInt32, totalSize: UInt32) async throws -> UInt32? { - print("HotlineClientNew: uploadFolder request - name='\(name)', path=\(path), fileCount=\(fileCount), totalSize=\(totalSize)") + print("HotlineClient: uploadFolder request - name='\(name)', path=\(path), fileCount=\(fileCount), totalSize=\(totalSize)") var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .uploadFolder) transaction.setFieldString(type: .fileName, val: name) @@ -1054,7 +1054,7 @@ public actor HotlineClientNew { transaction.setFieldUInt32(type: .transferSize, val: totalSize) transaction.setFieldUInt16(type: .folderItemCount, val: UInt16(truncatingIfNeeded: fileCount)) - let reply = try await sendTransaction(transaction) + let reply = try await self.sendTransaction(transaction) guard let transferReferenceField = reply.getField(type: .referenceNumber), diff --git a/Hotline/Hotline/Transfers/HotlineFileDownloadClient.swift b/Hotline/Hotline/Transfers/HotlineFileDownloadClient.swift new file mode 100644 index 0000000..82f61d4 --- /dev/null +++ b/Hotline/Hotline/Transfers/HotlineFileDownloadClient.swift @@ -0,0 +1,269 @@ +import Foundation +import Network + +public enum HotlineDownloadLocation: Sendable { + case url(URL) + case downloads(String) // filename +} + +public enum HotlineTransferProgress: Sendable { + case error(Error) // An error occurred + case unconnected // Initial state + case preparing // Preparing to begin + case connecting // Connecting to server + case connected // Connected to server + case transfer(name: String, size: Int, total: Int, progress: Double, speed: Double?, estimate: TimeInterval?) // size transferred, total size, progress (0.0-1.0), speed (in bytes/sec), time remaining + case completed(url: URL?) // Download or upload complete (local url valid for downloads) +} + + +@MainActor +public class HotlineFileDownloadClient: @MainActor HotlineTransferClient { + public struct Configuration: Sendable { + public var chunkSize: Int = 256 * 1024 + public init() {} + } + + private let serverAddress: String + private let serverPort: UInt16 + private let referenceNumber: UInt32 + + private let config: Configuration + + private var transferSize: Int + private let transferTotal: Int + private var transferProgress: Progress + + private var socket: NetSocket? + private var downloadTask: Task? + + public init( + address: String, + port: UInt16, + reference: UInt32, + size: UInt32, + configuration: Configuration = .init() + ) { + self.serverAddress = address + self.serverPort = port + self.referenceNumber = reference + self.config = configuration + + self.transferTotal = Int(size) + self.transferSize = 0 + self.transferProgress = Progress(totalUnitCount: Int64(self.transferTotal)) + } + + // MARK: - API + + public func download( + to location: HotlineDownloadLocation, + progress progressHandler: (@Sendable (HotlineTransferProgress) throws -> Void)? = nil + ) async throws -> URL { + self.downloadTask?.cancel() + + let task = Task { + try await performDownload(to: location, progressHandler: progressHandler) + } + self.downloadTask = task + + do { + let url = try await task.value + self.downloadTask = nil + return url + } + catch { + self.downloadTask = nil + try? progressHandler?(.error(error)) + throw error + } + } + + /// Cancel the current download + public func cancel() { + self.downloadTask?.cancel() + self.downloadTask = nil + } + + // MARK: - Implementation + + private func updateProgress(sent: Int) throws { + self.transferSize = sent + self.transferProgress.completedUnitCount = Int64(sent) + try self.checkCancelled() + } + + private func checkCancelled() throws { + if Task.isCancelled { + throw CancellationError() + } + + // People can cancel a transfer from the file icon in the Finder. + // This code handles that. + if self.transferProgress.isCancelled { + throw CancellationError() + } + } + + private func performDownload( + to destination: HotlineDownloadLocation, + progressHandler: (@Sendable (HotlineTransferProgress) throws -> Void)? + ) async throws -> URL { + + let fm = FileManager.default + var fileHandle: FileHandle? + var resourceForkData: Data? + + try progressHandler?(.preparing) + + // Determine the download name + // Determine destination URL based on location + let destinationURL: URL + let destinationFilename: String + switch destination { + case .url(let url): + destinationURL = url.resolvingSymlinksInPath() + destinationFilename = destinationURL.lastPathComponent + case .downloads(let filename): + var downloadsURL = fm.urls(for: .downloadsDirectory, in: .userDomainMask)[0] + downloadsURL = downloadsURL.resolvingSymlinksInPath() + destinationURL = URL(filePath: downloadsURL.generateUniqueFilePath(filename: filename)) + destinationFilename = destinationURL.lastPathComponent + } + + try self.checkCancelled() + try progressHandler?(.connecting) + + // Connect to transfer server + let socket = try await NetSocket.connect( + host: self.serverAddress, + port: self.serverPort + 1 + ) + defer { Task { await socket.close() } } + self.socket = socket + + // See if we've been cancelled + try self.checkCancelled() + + // Send magic header + try await socket.write(Data(endian: .big) { + "HTXF".fourCharCode() + self.referenceNumber + UInt32.zero + UInt32.zero + }) + + // Read file header + let headerData = try await socket.read(HotlineFileHeader.DataSize) + guard let header = HotlineFileHeader(from: headerData) else { + throw HotlineTransferClientError.failedToTransfer + } + + // Connected + try progressHandler?(.connected) + + do { + // Process each fork + for _ in 0..? - - public init( - address: String, - port: UInt16, - reference: UInt32, - size: UInt32, - configuration: Configuration = .init() - ) { - self.serverAddress = address - self.serverPort = port - self.referenceNumber = reference - self.config = configuration - - self.transferTotal = Int(size) - self.transferSize = 0 - self.transferProgress = Progress(totalUnitCount: Int64(self.transferTotal)) - } - - // MARK: - API - - public func download( - to location: HotlineDownloadLocation, - progress progressHandler: (@Sendable (HotlineTransferProgress) throws -> Void)? = nil - ) async throws -> URL { - self.downloadTask?.cancel() - - let task = Task { - try await performDownload(to: location, progressHandler: progressHandler) - } - self.downloadTask = task - - do { - let url = try await task.value - self.downloadTask = nil - return url - } - catch { - self.downloadTask = nil - try? progressHandler?(.error(error)) - throw error - } - } - - /// Cancel the current download - public func cancel() { - self.downloadTask?.cancel() - self.downloadTask = nil - } - - // MARK: - Implementation - - private func updateProgress(sent: Int) throws { - self.transferSize = sent - self.transferProgress.completedUnitCount = Int64(sent) - try self.checkCancelled() - } - - private func checkCancelled() throws { - if Task.isCancelled { - throw CancellationError() - } - - // People can cancel a transfer from the file icon in the Finder. - // This code handles that. - if self.transferProgress.isCancelled { - throw CancellationError() - } - } - - private func performDownload( - to destination: HotlineDownloadLocation, - progressHandler: (@Sendable (HotlineTransferProgress) throws -> Void)? - ) async throws -> URL { - - let fm = FileManager.default - var fileHandle: FileHandle? - var resourceForkData: Data? - - try progressHandler?(.preparing) - - // Determine the download name - // Determine destination URL based on location - let destinationURL: URL - let destinationFilename: String - switch destination { - case .url(let url): - destinationURL = url.resolvingSymlinksInPath() - destinationFilename = destinationURL.lastPathComponent - case .downloads(let filename): - var downloadsURL = fm.urls(for: .downloadsDirectory, in: .userDomainMask)[0] - downloadsURL = downloadsURL.resolvingSymlinksInPath() - destinationURL = URL(filePath: downloadsURL.generateUniqueFilePath(filename: filename)) - destinationFilename = destinationURL.lastPathComponent - } - - try self.checkCancelled() - try progressHandler?(.connecting) - - // Connect to transfer server - let socket = try await NetSocket.connect( - host: self.serverAddress, - port: self.serverPort + 1 - ) - defer { Task { await socket.close() } } - self.socket = socket - - // See if we've been cancelled - try self.checkCancelled() - - // Send magic header - try await socket.write(Data(endian: .big) { - "HTXF".fourCharCode() - self.referenceNumber - UInt32.zero - UInt32.zero - }) - - // Read file header - let headerData = try await socket.read(HotlineFileHeader.DataSize) - guard let header = HotlineFileHeader(from: headerData) else { - throw HotlineTransferClientError.failedToTransfer - } - - // Connected - try progressHandler?(.connected) - - do { - // Process each fork - for _ in 0..? + private var temporaryFileURL: URL? + + public init( + fileName: String, + address: String, + port: UInt16, + reference: UInt32, + size: UInt32, + fileType: String? = nil, + fileCreator: String? = nil + ) { + self.fileName = fileName + self.serverAddress = address + self.serverPort = port + self.referenceNumber = reference + self.transferSize = size + self.fileType = fileType + self.fileCreator = fileCreator + } + + // MARK: - API + + /// Download file to temporary location for preview + /// - Parameter progressHandler: Optional progress callback + /// - Returns: URL to temporary file for preview + public func preview( + progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil + ) async throws -> URL { + self.previewTask?.cancel() + + let task = Task { + try await performPreview(progressHandler: progressHandler) + } + self.previewTask = task + + do { + let url = try await task.value + self.previewTask = nil + return url + } catch { + print("HotlineFilePreviewClient[\(referenceNumber)]: Failed to preview file: \(error)") + self.previewTask = nil + progressHandler?(.error(error)) + throw error + } + } + + /// Cancel the current preview download + public func cancel() { + self.previewTask?.cancel() + self.previewTask = nil + self.downloadClient?.cancel() + } + + /// Manually cleanup temporary file + /// Call this when preview is complete and you no longer need the file + public func cleanup() { + self.cleanupTempFile() + } + + // MARK: - Implementation + + private func performPreview( + progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? + ) async throws -> URL { + + // Create temporary file path directly in system temp directory + let tempDir = FileManager.default.temporaryDirectory + let uniqueFileName = "\(UUID().uuidString)_\(self.fileName)" + let tempFileURL = tempDir.appendingPathComponent(uniqueFileName) + self.temporaryFileURL = tempFileURL + + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Downloading to temp: \(tempFileURL.path)") + + progressHandler?(.connecting) + + // Connect to transfer server + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Connecting to \(self.serverAddress):\(self.serverPort + 1)") + let socket = try await NetSocket.connect( + host: self.serverAddress, + port: self.serverPort + 1 + ) + defer { Task { await socket.close() } } + + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Connected!") + + // Send magic header for raw data download + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Sending magic header") + try await socket.write(Data(endian: .big) { + "HTXF".fourCharCode() + self.referenceNumber + UInt32.zero + UInt32.zero + }) + + progressHandler?(.connected) + + // Stream raw data directly to temp file with progress tracking + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Streaming \(self.transferSize) bytes to temp file") + + let totalSize = Int(self.transferSize) + + // Create empty file (with HFS attributes if available) + var attributes: [FileAttributeKey: Any] = [:] + if let creator = self.fileCreator, !creator.isBlank { + attributes[.hfsCreatorCode] = creator.fourCharCode() as NSNumber + } + if let type = self.fileType, !type.isBlank { + attributes[.hfsTypeCode] = type.fourCharCode() as NSNumber + } + + guard FileManager.default.createFile(atPath: tempFileURL.path, contents: nil, attributes: attributes) else { + throw HotlineTransferClientError.failedToTransfer + } + + let fileHandle = try FileHandle(forWritingTo: tempFileURL) + defer { try? fileHandle.close() } + + let updates = await socket.receiveFile(to: fileHandle, length: totalSize) + for try await p in updates { + progressHandler?(.transfer( + name: uniqueFileName, + size: p.sent, + total: totalSize, + progress: totalSize > 0 ? Double(p.sent) / Double(totalSize) : 0.0, + speed: p.bytesPerSecond, + estimate: p.estimatedTimeRemaining + )) + } + + progressHandler?(.completed(url: tempFileURL)) + + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Preview file ready at \(tempFileURL.path)") + + return tempFileURL + } + + private func cleanupTempFile() { + guard let tempURL = self.temporaryFileURL else { return } + self.temporaryFileURL = nil + + // Delete the temp file + try? FileManager.default.removeItem(at: tempURL) + + print("HotlineFilePreviewClient[\(self.referenceNumber)]: Cleaned up temp file") + } +} diff --git a/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift b/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift deleted file mode 100644 index 5cf5628..0000000 --- a/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift +++ /dev/null @@ -1,146 +0,0 @@ -import Foundation -import Network - -@MainActor -public class HotlineFilePreviewClient { - private let serverAddress: String - private let serverPort: UInt16 - private let referenceNumber: UInt32 - private let fileName: String - private let transferSize: UInt32 - - private var downloadClient: HotlineFileDownloadClient? - private var previewTask: Task? - private var temporaryFileURL: URL? - - public init( - fileName: String, - address: String, - port: UInt16, - reference: UInt32, - size: UInt32 - ) { - self.fileName = fileName - self.serverAddress = address - self.serverPort = port - self.referenceNumber = reference - self.transferSize = size - } - - // MARK: - API - - /// Download file to temporary location for preview - /// - Parameter progressHandler: Optional progress callback - /// - Returns: URL to temporary file for preview - public func preview( - progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil - ) async throws -> URL { - self.previewTask?.cancel() - - let task = Task { - try await performPreview(progressHandler: progressHandler) - } - self.previewTask = task - - do { - let url = try await task.value - self.previewTask = nil - return url - } catch { - print("HotlineFilePreviewClient[\(referenceNumber)]: Failed to preview file: \(error)") - self.previewTask = nil - progressHandler?(.error(error)) - throw error - } - } - - /// Cancel the current preview download - public func cancel() { - self.previewTask?.cancel() - self.previewTask = nil - self.downloadClient?.cancel() - } - - /// Manually cleanup temporary file - /// Call this when preview is complete and you no longer need the file - public func cleanup() { - self.cleanupTempFile() - } - - // MARK: - Implementation - - private func performPreview( - progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? - ) async throws -> URL { - - // Create temporary file path directly in system temp directory - let tempDir = FileManager.default.temporaryDirectory - let uniqueFileName = "\(UUID().uuidString)_\(self.fileName)" - let tempFileURL = tempDir.appendingPathComponent(uniqueFileName) - self.temporaryFileURL = tempFileURL - - print("HotlineFilePreviewClient[\(self.referenceNumber)]: Downloading to temp: \(tempFileURL.path)") - - progressHandler?(.connecting) - - // Connect to transfer server - print("HotlineFilePreviewClient[\(self.referenceNumber)]: Connecting to \(self.serverAddress):\(self.serverPort + 1)") - let socket = try await NetSocket.connect( - host: self.serverAddress, - port: self.serverPort + 1 - ) - defer { Task { await socket.close() } } - - print("HotlineFilePreviewClient[\(self.referenceNumber)]: Connected!") - - // Send magic header for raw data download - print("HotlineFilePreviewClient[\(self.referenceNumber)]: Sending magic header") - try await socket.write(Data(endian: .big) { - "HTXF".fourCharCode() - self.referenceNumber - UInt32.zero - UInt32.zero - }) - - progressHandler?(.connected) - - // Stream raw data directly to temp file with progress tracking - print("HotlineFilePreviewClient[\(self.referenceNumber)]: Streaming \(transferSize) bytes to temp file") - - let totalSize = Int(transferSize) - - // Create empty file - FileManager.default.createFile(atPath: tempFileURL.path(percentEncoded: false), contents: nil) - - let fileHandle = try FileHandle(forWritingTo: tempFileURL) - defer { try? fileHandle.close() } - - let updates = await socket.receiveFile(to: fileHandle, length: totalSize) - for try await p in updates { - progressHandler?(.transfer( - name: uniqueFileName, - size: p.sent, - total: totalSize, - progress: totalSize > 0 ? Double(p.sent) / Double(totalSize) : 0.0, - speed: p.bytesPerSecond, - estimate: p.estimatedTimeRemaining - )) - } - - progressHandler?(.completed(url: tempFileURL)) - - print("HotlineFilePreviewClient[\(self.referenceNumber)]: Preview file ready at \(tempFileURL.path)") - - return tempFileURL - } - - private func cleanupTempFile() { - guard let tempURL = self.temporaryFileURL else { return } - self.temporaryFileURL = nil - - // Delete the temp file - try? FileManager.default.removeItem(at: tempURL) - - print("HotlineFilePreviewClient[\(self.referenceNumber)]: Cleaned up temp file") - } -} diff --git a/Hotline/Hotline/Transfers/HotlineFileUploadClient.swift b/Hotline/Hotline/Transfers/HotlineFileUploadClient.swift new file mode 100644 index 0000000..384e9bd --- /dev/null +++ b/Hotline/Hotline/Transfers/HotlineFileUploadClient.swift @@ -0,0 +1,225 @@ +import Foundation +import Network + +@MainActor +public class HotlineFileUploadClient: @MainActor HotlineTransferClient { + public struct Configuration: Sendable { + public var chunkSize: Int = 256 * 1024 + public init() {} + } + + private let serverAddress: String + private let serverPort: UInt16 + private let referenceNumber: UInt32 + private let fileURL: URL + + private let config: Configuration + + private var transferSize: Int + private let transferTotal: Int + private var transferProgress: Progress + + private var socket: NetSocket? + private var uploadTask: Task? + + public init?( + fileURL: URL, + address: String, + port: UInt16, + reference: UInt32, + configuration: Configuration = .init() + ) { + // Validate file and get total size + guard let payloadSize = FileManager.default.getFlattenedFileSize(fileURL) else { + return nil + } + + guard FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false)) else { + return nil + } + + self.serverAddress = address + self.serverPort = port + self.referenceNumber = reference + self.fileURL = fileURL + self.config = configuration + + self.transferTotal = Int(payloadSize) + self.transferSize = 0 + self.transferProgress = Progress(totalUnitCount: Int64(self.transferTotal)) + } + + // MARK: - Public API + + public func upload( + progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil + ) async throws { + self.uploadTask?.cancel() + + let task = Task { + try await performUpload(progressHandler: progressHandler) + } + self.uploadTask = task + + do { + try await task.value + self.uploadTask = nil + } catch { + print("HotlineFileUploadClient[\(self.referenceNumber)]: Failed to upload file: \(error)") + self.uploadTask = nil + progressHandler?(.error(error)) + throw error + } + } + + /// Cancel the current upload + public func cancel() { + self.uploadTask?.cancel() + self.uploadTask = nil + + if let socket = self.socket { + Task { + await socket.close() + } + } + } + + // MARK: - Implementation + + private func updateProgress(sent: Int, speed: Double? = nil, estimate: TimeInterval? = nil) { + self.transferSize = sent + self.transferProgress.completedUnitCount = Int64(sent) + + if self.transferProgress.isCancelled { + self.cancel() + } + } + + private func performUpload( + progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? + ) async throws { + let filename = self.fileURL.lastPathComponent + + progressHandler?(.connecting) + + // Start accessing security-scoped resource + let didStartAccess = fileURL.startAccessingSecurityScopedResource() + defer { + if didStartAccess { + fileURL.stopAccessingSecurityScopedResource() + } + } + + // Connect to transfer server + let socket = try await NetSocket.connect( + host: self.serverAddress, + port: self.serverPort + 1 + ) + defer { Task { await socket.close() } } + self.socket = socket + + // Get file metadata + guard let infoFork = HotlineFileInfoFork(file: self.fileURL) else { + throw HotlineTransferClientError.failedToTransfer + } + + guard let header = HotlineFileHeader(file: self.fileURL) else { + throw HotlineTransferClientError.failedToTransfer + } + + guard let forkSizes = try? FileManager.default.getFileForkSizes(self.fileURL) else { + throw HotlineTransferClientError.failedToTransfer + } + + let infoForkData = infoFork.data() + let dataForkSize = forkSizes.dataForkSize + let resourceForkSize = forkSizes.resourceForkSize + + // Configure progress for Finder if enabled + self.transferProgress.fileURL = self.fileURL.resolvingSymlinksInPath() + self.transferProgress.fileOperationKind = .uploading + self.transferProgress.publish() + + // Connected + progressHandler?(.connected) + + // Send magic header + try await socket.write(Data(endian: .big) { + "HTXF".fourCharCode() + self.referenceNumber + UInt32(self.transferTotal) + UInt32.zero + }) + + var totalBytesSent = 0 + + // MARK: - Info Fork + // Send file header + let headerData = header.data() + try await socket.write(headerData) + totalBytesSent += headerData.count + + // Send info fork header + let infoForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.info.rawValue, dataSize: UInt32(infoForkData.count)) + try await socket.write(infoForkHeader.data()) + totalBytesSent += HotlineFileForkHeader.DataSize + + // Send info fork + try await socket.write(infoForkData) + totalBytesSent += infoForkData.count + + self.updateProgress(sent: totalBytesSent) + progressHandler?(.transfer(name: filename, size: self.transferSize, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: nil, estimate: nil)) + + // MARK: - Data Fork + // Send data fork (if present) + if dataForkSize > 0 { + // Data fork header + let dataForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.data.rawValue, dataSize: dataForkSize) + try await socket.write(dataForkHeader.data()) + totalBytesSent += HotlineFileForkHeader.DataSize + + // Stream data fork from disk + let fileHandle = try FileHandle(forReadingFrom: self.fileURL) + defer { try? fileHandle.close() } + + let updates = await socket.writeFile(from: fileHandle, length: Int(dataForkSize)) + for try await p in updates { + let bytesSentNow = totalBytesSent + p.sent + self.updateProgress(sent: bytesSentNow, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining) + progressHandler?(.transfer(name: filename, size: bytesSentNow, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining)) + } + + totalBytesSent += Int(dataForkSize) + } + + // MARK: - Resource Fork + // Send resource fork (if present) + if resourceForkSize > 0 { + let resourceURL = self.fileURL.urlForResourceFork() + + // Resource fork header + let resourceForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.resource.rawValue, dataSize: resourceForkSize) + try await socket.write(resourceForkHeader.data()) + totalBytesSent += HotlineFileForkHeader.DataSize + + // Stream resource fork from disk + let resourceHandle = try FileHandle(forReadingFrom: resourceURL) + defer { try? resourceHandle.close() } + + let updates = await socket.writeFile(from: resourceHandle, length: Int(resourceForkSize)) + for try await p in updates { + let bytesSentNow = totalBytesSent + p.sent + self.updateProgress(sent: bytesSentNow, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining) + progressHandler?(.transfer(name: filename, size: bytesSentNow, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining)) + } + + totalBytesSent += Int(resourceForkSize) + } + + self.transferProgress.unpublish() + progressHandler?(.completed(url: nil)) + + print("HotlineFileUploadClient[\(self.referenceNumber)]: Complete!") + } +} diff --git a/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift deleted file mode 100644 index 384e9bd..0000000 --- a/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift +++ /dev/null @@ -1,225 +0,0 @@ -import Foundation -import Network - -@MainActor -public class HotlineFileUploadClient: @MainActor HotlineTransferClient { - public struct Configuration: Sendable { - public var chunkSize: Int = 256 * 1024 - public init() {} - } - - private let serverAddress: String - private let serverPort: UInt16 - private let referenceNumber: UInt32 - private let fileURL: URL - - private let config: Configuration - - private var transferSize: Int - private let transferTotal: Int - private var transferProgress: Progress - - private var socket: NetSocket? - private var uploadTask: Task? - - public init?( - fileURL: URL, - address: String, - port: UInt16, - reference: UInt32, - configuration: Configuration = .init() - ) { - // Validate file and get total size - guard let payloadSize = FileManager.default.getFlattenedFileSize(fileURL) else { - return nil - } - - guard FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false)) else { - return nil - } - - self.serverAddress = address - self.serverPort = port - self.referenceNumber = reference - self.fileURL = fileURL - self.config = configuration - - self.transferTotal = Int(payloadSize) - self.transferSize = 0 - self.transferProgress = Progress(totalUnitCount: Int64(self.transferTotal)) - } - - // MARK: - Public API - - public func upload( - progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil - ) async throws { - self.uploadTask?.cancel() - - let task = Task { - try await performUpload(progressHandler: progressHandler) - } - self.uploadTask = task - - do { - try await task.value - self.uploadTask = nil - } catch { - print("HotlineFileUploadClient[\(self.referenceNumber)]: Failed to upload file: \(error)") - self.uploadTask = nil - progressHandler?(.error(error)) - throw error - } - } - - /// Cancel the current upload - public func cancel() { - self.uploadTask?.cancel() - self.uploadTask = nil - - if let socket = self.socket { - Task { - await socket.close() - } - } - } - - // MARK: - Implementation - - private func updateProgress(sent: Int, speed: Double? = nil, estimate: TimeInterval? = nil) { - self.transferSize = sent - self.transferProgress.completedUnitCount = Int64(sent) - - if self.transferProgress.isCancelled { - self.cancel() - } - } - - private func performUpload( - progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? - ) async throws { - let filename = self.fileURL.lastPathComponent - - progressHandler?(.connecting) - - // Start accessing security-scoped resource - let didStartAccess = fileURL.startAccessingSecurityScopedResource() - defer { - if didStartAccess { - fileURL.stopAccessingSecurityScopedResource() - } - } - - // Connect to transfer server - let socket = try await NetSocket.connect( - host: self.serverAddress, - port: self.serverPort + 1 - ) - defer { Task { await socket.close() } } - self.socket = socket - - // Get file metadata - guard let infoFork = HotlineFileInfoFork(file: self.fileURL) else { - throw HotlineTransferClientError.failedToTransfer - } - - guard let header = HotlineFileHeader(file: self.fileURL) else { - throw HotlineTransferClientError.failedToTransfer - } - - guard let forkSizes = try? FileManager.default.getFileForkSizes(self.fileURL) else { - throw HotlineTransferClientError.failedToTransfer - } - - let infoForkData = infoFork.data() - let dataForkSize = forkSizes.dataForkSize - let resourceForkSize = forkSizes.resourceForkSize - - // Configure progress for Finder if enabled - self.transferProgress.fileURL = self.fileURL.resolvingSymlinksInPath() - self.transferProgress.fileOperationKind = .uploading - self.transferProgress.publish() - - // Connected - progressHandler?(.connected) - - // Send magic header - try await socket.write(Data(endian: .big) { - "HTXF".fourCharCode() - self.referenceNumber - UInt32(self.transferTotal) - UInt32.zero - }) - - var totalBytesSent = 0 - - // MARK: - Info Fork - // Send file header - let headerData = header.data() - try await socket.write(headerData) - totalBytesSent += headerData.count - - // Send info fork header - let infoForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.info.rawValue, dataSize: UInt32(infoForkData.count)) - try await socket.write(infoForkHeader.data()) - totalBytesSent += HotlineFileForkHeader.DataSize - - // Send info fork - try await socket.write(infoForkData) - totalBytesSent += infoForkData.count - - self.updateProgress(sent: totalBytesSent) - progressHandler?(.transfer(name: filename, size: self.transferSize, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: nil, estimate: nil)) - - // MARK: - Data Fork - // Send data fork (if present) - if dataForkSize > 0 { - // Data fork header - let dataForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.data.rawValue, dataSize: dataForkSize) - try await socket.write(dataForkHeader.data()) - totalBytesSent += HotlineFileForkHeader.DataSize - - // Stream data fork from disk - let fileHandle = try FileHandle(forReadingFrom: self.fileURL) - defer { try? fileHandle.close() } - - let updates = await socket.writeFile(from: fileHandle, length: Int(dataForkSize)) - for try await p in updates { - let bytesSentNow = totalBytesSent + p.sent - self.updateProgress(sent: bytesSentNow, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining) - progressHandler?(.transfer(name: filename, size: bytesSentNow, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining)) - } - - totalBytesSent += Int(dataForkSize) - } - - // MARK: - Resource Fork - // Send resource fork (if present) - if resourceForkSize > 0 { - let resourceURL = self.fileURL.urlForResourceFork() - - // Resource fork header - let resourceForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.resource.rawValue, dataSize: resourceForkSize) - try await socket.write(resourceForkHeader.data()) - totalBytesSent += HotlineFileForkHeader.DataSize - - // Stream resource fork from disk - let resourceHandle = try FileHandle(forReadingFrom: resourceURL) - defer { try? resourceHandle.close() } - - let updates = await socket.writeFile(from: resourceHandle, length: Int(resourceForkSize)) - for try await p in updates { - let bytesSentNow = totalBytesSent + p.sent - self.updateProgress(sent: bytesSentNow, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining) - progressHandler?(.transfer(name: filename, size: bytesSentNow, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: p.bytesPerSecond, estimate: p.estimatedTimeRemaining)) - } - - totalBytesSent += Int(resourceForkSize) - } - - self.transferProgress.unpublish() - progressHandler?(.completed(url: nil)) - - print("HotlineFileUploadClient[\(self.referenceNumber)]: Complete!") - } -} diff --git a/Hotline/Hotline/Transfers/HotlineFolderDownloadClient.swift b/Hotline/Hotline/Transfers/HotlineFolderDownloadClient.swift new file mode 100644 index 0000000..36193bd --- /dev/null +++ b/Hotline/Hotline/Transfers/HotlineFolderDownloadClient.swift @@ -0,0 +1,450 @@ +import Foundation +import Network + +/// Item progress callback for folder downloads +public struct HotlineFolderItemProgress: Sendable { + public let fileName: String + public let itemNumber: Int + public let totalItems: Int +} + +@MainActor +public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { + private let serverAddress: String + private let serverPort: UInt16 + private let referenceNumber: UInt32 + + private let transferTotal: Int + private let folderItemCount: Int + private var transferSize: Int = 0 + + private var socket: NetSocket? + private var downloadTask: Task? + private var folderProgress: Progress? + + public init( + address: String, + port: UInt16, + reference: UInt32, + size: UInt32, + itemCount: Int + ) { + self.serverAddress = address + self.serverPort = port + self.referenceNumber = reference + self.transferTotal = Int(size) + self.folderItemCount = itemCount + } + + // MARK: - API + + public func download( + to location: HotlineDownloadLocation, + progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil, + itemProgress itemProgressHandler: (@Sendable (HotlineFolderItemProgress) -> Void)? = nil + ) async throws -> URL { + self.downloadTask?.cancel() + + let task = Task { + try await performDownload( + to: location, + progressHandler: progressHandler, + itemProgressHandler: itemProgressHandler + ) + } + self.downloadTask = task + + do { + let url = try await task.value + self.downloadTask = nil + return url + } catch { + print("HotlineFolderDownloadClient[\(referenceNumber)]: Failed to download folder: \(error)") + self.downloadTask = nil + progressHandler?(.error(error)) + throw error + } + } + + /// Cancel the current download + public func cancel() { + downloadTask?.cancel() + downloadTask = nil + + if let socket = socket { + Task { + await socket.close() + } + } + } + + // MARK: - Implementation + + private func performDownload( + to destination: HotlineDownloadLocation, + progressHandler: (@Sendable (HotlineTransferProgress) -> Void)?, + itemProgressHandler: (@Sendable (HotlineFolderItemProgress) -> Void)? + ) async throws -> URL { + + var destinationFilename: String + + progressHandler?(.connecting) + + // Connect to transfer server + let socket = try await connectToTransferServer() + self.socket = socket + defer { Task { await socket.close() } } + + // Determine destination folder URL + let fm = FileManager.default + let destinationURL: URL + + switch destination { + case .url(let url): + destinationURL = url + destinationFilename = url.lastPathComponent + case .downloads(let filename): + let downloadsURL = fm.urls(for: .downloadsDirectory, in: .userDomainMask)[0] + destinationURL = URL(filePath: downloadsURL.generateUniqueFilePath(filename: filename)) + destinationFilename = destinationURL.lastPathComponent + } + + print("HotlineFolderDownloadClient[\(referenceNumber)]: Downloading folder to \(destinationURL.path)") + + // Create destination folder + try? fm.removeItem(at: destinationURL) + try fm.createDirectory(at: destinationURL, withIntermediateDirectories: true) + + // Create and publish progress for the entire folder (shows in Finder) + let progress = Progress(totalUnitCount: Int64(self.transferTotal)) + progress.fileURL = destinationURL + progress.fileOperationKind = .downloading + progress.publish() + self.folderProgress = progress + + // Send initial magic header + print("HotlineFolderDownloadClient[\(self.referenceNumber)]: Sending HTXF magic") + try await socket.write(Data(endian: .big) { + "HTXF".fourCharCode() + self.referenceNumber + UInt32.zero // data size = 0 + UInt16(1) // type = 1 (folder transfer) + UInt16.zero // reserved = 0 + HotlineFolderAction.nextFile.rawValue // action = 3 (next file) + }) + + progressHandler?(.connected) + progressHandler?(.transfer(name: destinationFilename, size: 0, total: self.transferTotal, progress: 0.0, speed: nil, estimate: nil)) + + var completedItemCount = 0 + var totalBytesTransferred = 0 + + // Process each item in the folder + while completedItemCount < self.folderItemCount { + // Read item header + let headerLenData = try await socket.read(2) + let headerLen = Int(headerLenData.readUInt16(at: 0)!) + let headerData = try await socket.read(headerLen) + + totalBytesTransferred += 2 + headerLen + + guard let (itemType, pathComponents) = self.parseItemHeaderPath(headerData) else { + throw HotlineTransferClientError.failedToTransfer + } + + let joinedPath = pathComponents.joined(separator: "/") + print("HotlineFolderDownloadClient[\(referenceNumber)]: Item type=\(itemType) path=\(joinedPath)") + + if itemType == 1 { + // Folder entry - no progress shown for folder creation + if !pathComponents.isEmpty { + let folderURL = destinationURL.appendingPathComponents(pathComponents) + try fm.createDirectory(at: folderURL, withIntermediateDirectories: true) + print("HotlineFolderDownloadClient[\(self.referenceNumber)]: Created folder at \(folderURL.path)") + } + + completedItemCount += 1 + + // Request next item if not done + if completedItemCount < folderItemCount { + try await sendAction(socket: socket, action: .nextFile) // nextFile + } + + } else if itemType == 0 { + // File entry + let parentComponents = pathComponents.dropLast() + let fileName = pathComponents.last ?? "untitled" + + // Request file download + try await sendAction(socket: socket, action: .sendFile) // sendFile + + // Read file size + let fileSizeData = try await socket.read(4) + let fileSize = fileSizeData.readUInt32(at: 0)! + totalBytesTransferred += 4 + + print("HotlineFolderDownloadClient[\(referenceNumber)]: File '\(fileName)' size: \(fileSize) bytes") + + // Notify item progress before download starts + completedItemCount += 1 + itemProgressHandler?(HotlineFolderItemProgress( + fileName: fileName, + itemNumber: completedItemCount, + totalItems: folderItemCount + )) + + // Download the file with overall folder progress tracking + let (fileURL, fileBytesRead) = try await downloadFile( + socket: socket, + fileName: fileName, + parentPath: Array(parentComponents), + destinationFolder: destinationURL, + fileSize: fileSize, + itemNumber: completedItemCount, + totalItems: folderItemCount, + totalBytesTransferredSoFar: totalBytesTransferred, + progressHandler: progressHandler + ) + + totalBytesTransferred += fileBytesRead + self.transferSize = totalBytesTransferred + + print("HotlineFolderDownloadClient[\(referenceNumber)]: Downloaded file to \(fileURL.path)") + + // Request next item if not done + if completedItemCount < folderItemCount { + try await sendAction(socket: socket, action: .nextFile) // nextFile + } + + } else { + // Unknown item type + print("HotlineFolderDownloadClient[\(referenceNumber)]: Unknown item type \(itemType), skipping") + completedItemCount += 1 + + if completedItemCount < folderItemCount { + try await sendAction(socket: socket, action: .nextFile) // nextFile + } + } + } + + print("HotlineFolderDownloadClient[\(referenceNumber)]: Download complete!") + + // Ensure folder progress shows 100% complete + self.folderProgress?.completedUnitCount = Int64(self.transferTotal) + + progressHandler?(.completed(url: destinationURL)) + + return destinationURL + } + + // MARK: - Helper Methods + + private func connectToTransferServer() async throws -> NetSocket { + print("HotlineFolderDownloadClient[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)") + + let socket = try await NetSocket.connect( + host: self.serverAddress, + port: self.serverPort + 1 + ) + + print("HotlineFolderDownloadClient[\(referenceNumber)]: Connected!") + return socket + } + + private func sendAction(socket: NetSocket, action: HotlineFolderAction) async throws { + let actionData = Data(endian: .big) { + action.rawValue + } + try await socket.write(actionData) + print("HotlineFolderDownloadClient[\(referenceNumber)]: Sent action: \(action)") + } + + private func parseItemHeaderPath(_ headerData: Data) -> (type: UInt16, components: [String])? { + // Need at least: type(2) + count(2) + guard headerData.count >= 4, + let type = headerData.readUInt16(at: 0), + let count = headerData.readUInt16(at: 2) else { return nil } + + var ofs = 4 + var comps: [String] = [] + for _ in 0..= ofs + 3 else { return nil } + // per Hotline path encoding: reserved(2) then nameLen(1) then name + ofs += 2 // reserved == 0 + let nameLen = Int(headerData.readUInt8(at: ofs)!) + ofs += 1 + guard headerData.count >= ofs + nameLen else { return nil } + let nameData = headerData.subdata(in: ofs..<(ofs + nameLen)) + ofs += nameLen + + let name = String(data: nameData, encoding: .macOSRoman) + ?? String(data: nameData, encoding: .utf8) + ?? "" + comps.append(name) + } + return (type, comps) + } + + private func downloadFile( + socket: NetSocket, + fileName: String, + parentPath: [String], + destinationFolder: URL, + fileSize: UInt32, + itemNumber: Int, + totalItems: Int, + totalBytesTransferredSoFar: Int, + progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? + ) async throws -> (url: URL, bytesRead: Int) { + let fm = FileManager.default + var bytesRead = 0 + + // Read file header + let headerData = try await socket.read(HotlineFileHeader.DataSize) + guard let header = HotlineFileHeader(from: headerData) else { + throw HotlineTransferClientError.failedToTransfer + } + bytesRead += HotlineFileHeader.DataSize + + // Update folder progress for file header + let totalBytesNow = totalBytesTransferredSoFar + bytesRead + self.folderProgress?.completedUnitCount = Int64(totalBytesNow) + + print("HotlineFolderDownloadClient[\(referenceNumber)]: File has \(header.forkCount) forks") + + var resourceForkData: Data? + var fileHandle: FileHandle? + var filePath: URL? + var fileDataForkSize: Int = 0 + + defer { + try? fileHandle?.close() + } + + // Process each fork + for _ in 0.. 0 ? Double(totalBytesNow) / Double(self.transferTotal) : 0.0 + let overallProgress = min(rawProgress, 1.0) // Clamp to 1.0 to avoid exceeding 100% + + // Update folder-level Finder progress + self.folderProgress?.completedUnitCount = Int64(totalBytesNow) + + // Calculate overall folder time estimate based on current speed + let remainingBytes = max(0, self.transferTotal - totalBytesNow) + let estimate: TimeInterval? = if let speed = p.bytesPerSecond, speed > 0, remainingBytes > 0 { + TimeInterval(remainingBytes) / speed + } else { + nil + } + + // Report overall folder progress to UI + progressHandler?(.transfer( + name: fileName, + size: totalBytesNow, + total: self.transferTotal, + progress: overallProgress, + speed: p.bytesPerSecond, + estimate: estimate + )) + } + bytesRead += fileDataForkSize + + } else if forkHeader.isResourceFork { + // Read RESOURCE fork + resourceForkData = try await socket.read(Int(forkHeader.dataSize)) + bytesRead += Int(forkHeader.dataSize) + + // Update folder progress for RESOURCE fork + let totalBytesNow = totalBytesTransferredSoFar + bytesRead + self.folderProgress?.completedUnitCount = Int64(totalBytesNow) + + } else { + // Skip unsupported fork + try await socket.skip(Int(forkHeader.dataSize)) + bytesRead += Int(forkHeader.dataSize) + + // Update folder progress for skipped fork + let totalBytesNow = totalBytesTransferredSoFar + bytesRead + self.folderProgress?.completedUnitCount = Int64(totalBytesNow) + } + } + + // Close file handle + try? fileHandle?.close() + fileHandle = nil + + guard let finalPath = filePath else { + throw HotlineTransferClientError.failedToTransfer + } + + // Write resource fork if present + if let rsrcData = resourceForkData, !rsrcData.isEmpty { + try writeResourceFork(data: rsrcData, to: finalPath) + } + + return (finalPath, bytesRead) + } + + private func writeResourceFork(data: Data, to url: URL) throws { + var resolvedURL = url + resolvedURL.resolveSymlinksInPath() + + let resourceURL = resolvedURL.urlForResourceFork() + try data.write(to: resourceURL) + } +} diff --git a/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift deleted file mode 100644 index 600b9f2..0000000 --- a/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift +++ /dev/null @@ -1,453 +0,0 @@ -import Foundation -import Network - -/// Item progress callback for folder downloads -public struct HotlineFolderItemProgress: Sendable { - public let fileName: String - public let itemNumber: Int - public let totalItems: Int -} - -@MainActor -public class HotlineFolderDownloadClientNew: @MainActor HotlineTransferClient { - private let serverAddress: String - private let serverPort: UInt16 - private let referenceNumber: UInt32 - - private let transferTotal: Int - private let folderItemCount: Int - private var transferSize: Int = 0 - - private var socket: NetSocket? - private var downloadTask: Task? - private var folderProgress: Progress? - - // MARK: - Initialization - - public init( - address: String, - port: UInt16, - reference: UInt32, - size: UInt32, - itemCount: Int - ) { - self.serverAddress = address - self.serverPort = port - self.referenceNumber = reference - self.transferTotal = Int(size) - self.folderItemCount = itemCount - - print("HotlineFolderDownloadClientNew[\(reference)]: Server reported transferSize=\(size) bytes, folderItemCount=\(itemCount) items") - } - - // MARK: - Public API - - public func download( - to location: HotlineDownloadLocation, - progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil, - itemProgress itemProgressHandler: (@Sendable (HotlineFolderItemProgress) -> Void)? = nil - ) async throws -> URL { - self.downloadTask?.cancel() - - let task = Task { - try await performDownload( - to: location, - progressHandler: progressHandler, - itemProgressHandler: itemProgressHandler - ) - } - self.downloadTask = task - - do { - let url = try await task.value - self.downloadTask = nil - return url - } catch { - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Failed to download folder: \(error)") - self.downloadTask = nil - progressHandler?(.error(error)) - throw error - } - } - - /// Cancel the current download - public func cancel() { - downloadTask?.cancel() - downloadTask = nil - - if let socket = socket { - Task { - await socket.close() - } - } - } - - // MARK: - Private Implementation - - private func performDownload( - to destination: HotlineDownloadLocation, - progressHandler: (@Sendable (HotlineTransferProgress) -> Void)?, - itemProgressHandler: (@Sendable (HotlineFolderItemProgress) -> Void)? - ) async throws -> URL { - - var destinationFilename: String - - progressHandler?(.connecting) - - // Connect to transfer server - let socket = try await connectToTransferServer() - self.socket = socket - defer { Task { await socket.close() } } - - // Determine destination folder URL - let fm = FileManager.default - let destinationURL: URL - - switch destination { - case .url(let url): - destinationURL = url - destinationFilename = url.lastPathComponent - case .downloads(let filename): - let downloadsURL = fm.urls(for: .downloadsDirectory, in: .userDomainMask)[0] - destinationURL = URL(filePath: downloadsURL.generateUniqueFilePath(filename: filename)) - destinationFilename = destinationURL.lastPathComponent - } - - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Downloading folder to \(destinationURL.path)") - - // Create destination folder - try? fm.removeItem(at: destinationURL) - try fm.createDirectory(at: destinationURL, withIntermediateDirectories: true) - - // Create and publish progress for the entire folder (shows in Finder) - let progress = Progress(totalUnitCount: Int64(self.transferTotal)) - progress.fileURL = destinationURL - progress.fileOperationKind = .downloading - progress.publish() - self.folderProgress = progress - - // Send initial magic header - print("HotlineFolderDownloadClientNew[\(self.referenceNumber)]: Sending HTXF magic") - try await socket.write(Data(endian: .big) { - "HTXF".fourCharCode() - self.referenceNumber - UInt32.zero // data size = 0 - UInt16(1) // type = 1 (folder transfer) - UInt16.zero // reserved = 0 - HotlineFolderAction.nextFile.rawValue // action = 3 (next file) - }) - - progressHandler?(.connected) - progressHandler?(.transfer(name: destinationFilename, size: 0, total: self.transferTotal, progress: 0.0, speed: nil, estimate: nil)) - - var completedItemCount = 0 - var totalBytesTransferred = 0 - - // Process each item in the folder - while completedItemCount < self.folderItemCount { - // Read item header - let headerLenData = try await socket.read(2) - let headerLen = Int(headerLenData.readUInt16(at: 0)!) - let headerData = try await socket.read(headerLen) - - totalBytesTransferred += 2 + headerLen - - guard let (itemType, pathComponents) = self.parseItemHeaderPath(headerData) else { - throw HotlineTransferClientError.failedToTransfer - } - - let joinedPath = pathComponents.joined(separator: "/") - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Item type=\(itemType) path=\(joinedPath)") - - if itemType == 1 { - // Folder entry - no progress shown for folder creation - if !pathComponents.isEmpty { - let folderURL = destinationURL.appendingPathComponents(pathComponents) - try fm.createDirectory(at: folderURL, withIntermediateDirectories: true) - print("HotlineFolderDownloadClientNew[\(self.referenceNumber)]: Created folder at \(folderURL.path)") - } - - completedItemCount += 1 - - // Request next item if not done - if completedItemCount < folderItemCount { - try await sendAction(socket: socket, action: .nextFile) // nextFile - } - - } else if itemType == 0 { - // File entry - let parentComponents = pathComponents.dropLast() - let fileName = pathComponents.last ?? "untitled" - - // Request file download - try await sendAction(socket: socket, action: .sendFile) // sendFile - - // Read file size - let fileSizeData = try await socket.read(4) - let fileSize = fileSizeData.readUInt32(at: 0)! - totalBytesTransferred += 4 - - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: File '\(fileName)' size: \(fileSize) bytes") - - // Notify item progress before download starts - completedItemCount += 1 - itemProgressHandler?(HotlineFolderItemProgress( - fileName: fileName, - itemNumber: completedItemCount, - totalItems: folderItemCount - )) - - // Download the file with overall folder progress tracking - let (fileURL, fileBytesRead) = try await downloadFile( - socket: socket, - fileName: fileName, - parentPath: Array(parentComponents), - destinationFolder: destinationURL, - fileSize: fileSize, - itemNumber: completedItemCount, - totalItems: folderItemCount, - totalBytesTransferredSoFar: totalBytesTransferred, - progressHandler: progressHandler - ) - - totalBytesTransferred += fileBytesRead - self.transferSize = totalBytesTransferred - - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Downloaded file to \(fileURL.path)") - - // Request next item if not done - if completedItemCount < folderItemCount { - try await sendAction(socket: socket, action: .nextFile) // nextFile - } - - } else { - // Unknown item type - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Unknown item type \(itemType), skipping") - completedItemCount += 1 - - if completedItemCount < folderItemCount { - try await sendAction(socket: socket, action: .nextFile) // nextFile - } - } - } - - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Download complete!") - - // Ensure folder progress shows 100% complete - self.folderProgress?.completedUnitCount = Int64(self.transferTotal) - - progressHandler?(.completed(url: destinationURL)) - - return destinationURL - } - - // MARK: - Helper Methods - - private func connectToTransferServer() async throws -> NetSocket { - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)") - - let socket = try await NetSocket.connect( - host: self.serverAddress, - port: self.serverPort + 1 - ) - - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Connected!") - return socket - } - - private func sendAction(socket: NetSocket, action: HotlineFolderAction) async throws { - let actionData = Data(endian: .big) { - action.rawValue - } - try await socket.write(actionData) - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Sent action: \(action)") - } - - private func parseItemHeaderPath(_ headerData: Data) -> (type: UInt16, components: [String])? { - // Need at least: type(2) + count(2) - guard headerData.count >= 4, - let type = headerData.readUInt16(at: 0), - let count = headerData.readUInt16(at: 2) else { return nil } - - var ofs = 4 - var comps: [String] = [] - for _ in 0..= ofs + 3 else { return nil } - // per Hotline path encoding: reserved(2) then nameLen(1) then name - ofs += 2 // reserved == 0 - let nameLen = Int(headerData.readUInt8(at: ofs)!) - ofs += 1 - guard headerData.count >= ofs + nameLen else { return nil } - let nameData = headerData.subdata(in: ofs..<(ofs + nameLen)) - ofs += nameLen - - let name = String(data: nameData, encoding: .macOSRoman) - ?? String(data: nameData, encoding: .utf8) - ?? "" - comps.append(name) - } - return (type, comps) - } - - private func downloadFile( - socket: NetSocket, - fileName: String, - parentPath: [String], - destinationFolder: URL, - fileSize: UInt32, - itemNumber: Int, - totalItems: Int, - totalBytesTransferredSoFar: Int, - progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? - ) async throws -> (url: URL, bytesRead: Int) { - let fm = FileManager.default - var bytesRead = 0 - - // Read file header - let headerData = try await socket.read(HotlineFileHeader.DataSize) - guard let header = HotlineFileHeader(from: headerData) else { - throw HotlineTransferClientError.failedToTransfer - } - bytesRead += HotlineFileHeader.DataSize - - // Update folder progress for file header - let totalBytesNow = totalBytesTransferredSoFar + bytesRead - self.folderProgress?.completedUnitCount = Int64(totalBytesNow) - - print("HotlineFolderDownloadClientNew[\(referenceNumber)]: File has \(header.forkCount) forks") - - var resourceForkData: Data? - var fileHandle: FileHandle? - var filePath: URL? - var fileDataForkSize: Int = 0 - - defer { - try? fileHandle?.close() - } - - // Process each fork - for _ in 0.. 0 ? Double(totalBytesNow) / Double(self.transferTotal) : 0.0 - let overallProgress = min(rawProgress, 1.0) // Clamp to 1.0 to avoid exceeding 100% - - // Update folder-level Finder progress - self.folderProgress?.completedUnitCount = Int64(totalBytesNow) - - // Calculate overall folder time estimate based on current speed - let remainingBytes = max(0, self.transferTotal - totalBytesNow) - let estimate: TimeInterval? = if let speed = p.bytesPerSecond, speed > 0, remainingBytes > 0 { - TimeInterval(remainingBytes) / speed - } else { - nil - } - - // Report overall folder progress to UI - progressHandler?(.transfer( - name: fileName, - size: totalBytesNow, - total: self.transferTotal, - progress: overallProgress, - speed: p.bytesPerSecond, - estimate: estimate - )) - } - bytesRead += fileDataForkSize - - } else if forkHeader.isResourceFork { - // Read RESOURCE fork - resourceForkData = try await socket.read(Int(forkHeader.dataSize)) - bytesRead += Int(forkHeader.dataSize) - - // Update folder progress for RESOURCE fork - let totalBytesNow = totalBytesTransferredSoFar + bytesRead - self.folderProgress?.completedUnitCount = Int64(totalBytesNow) - - } else { - // Skip unsupported fork - try await socket.skip(Int(forkHeader.dataSize)) - bytesRead += Int(forkHeader.dataSize) - - // Update folder progress for skipped fork - let totalBytesNow = totalBytesTransferredSoFar + bytesRead - self.folderProgress?.completedUnitCount = Int64(totalBytesNow) - } - } - - // Close file handle - try? fileHandle?.close() - fileHandle = nil - - guard let finalPath = filePath else { - throw HotlineTransferClientError.failedToTransfer - } - - // Write resource fork if present - if let rsrcData = resourceForkData, !rsrcData.isEmpty { - try writeResourceFork(data: rsrcData, to: finalPath) - } - - return (finalPath, bytesRead) - } - - private func writeResourceFork(data: Data, to url: URL) throws { - var resolvedURL = url - resolvedURL.resolveSymlinksInPath() - - let resourceURL = resolvedURL.urlForResourceFork() - try data.write(to: resourceURL) - } -} diff --git a/Hotline/Hotline/Transfers/HotlineFolderUploadClient.swift b/Hotline/Hotline/Transfers/HotlineFolderUploadClient.swift new file mode 100644 index 0000000..1947de6 --- /dev/null +++ b/Hotline/Hotline/Transfers/HotlineFolderUploadClient.swift @@ -0,0 +1,512 @@ +import Foundation +import Network + +/// Item progress callback for folder uploads +public struct HotlineFolderItemUploadProgress: Sendable { + public let fileName: String + public let itemNumber: Int + public let totalItems: Int +} + +/// Represents a file or folder in the upload queue +private struct FolderItem { + let url: URL + let pathComponents: [String] // Path relative to upload root + let isFolder: Bool +} + +@MainActor +public class HotlineFolderUploadClient: @MainActor HotlineTransferClient { + public struct Configuration: Sendable { + public var chunkSize: Int = 256 * 1024 + public init() {} + } + + private let serverAddress: String + private let serverPort: UInt16 + private let referenceNumber: UInt32 + private let folderURL: URL + + private let config: Configuration + + private var transferTotal: Int = 0 + private var transferSize: Int = 0 + private var folderItems: [FolderItem] = [] + private var totalItems: Int = 0 + + private var socket: NetSocket? + private var uploadTask: Task? + + public init?( + folderURL: URL, + address: String, + port: UInt16, + reference: UInt32, + configuration: Configuration = .init() + ) { + guard FileManager.default.fileExists(atPath: folderURL.path(percentEncoded: false)) else { + return nil + } + + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: folderURL.path(percentEncoded: false), isDirectory: &isDirectory), + isDirectory.boolValue else { + return nil + } + + self.serverAddress = address + self.serverPort = port + self.referenceNumber = reference + self.folderURL = folderURL + self.config = configuration + + print("HotlineFolderUploadClientNew[\(reference)]: Preparing to upload folder '\(folderURL.lastPathComponent)'") + } + + // MARK: - API + + public func upload( + progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil, + itemProgress itemProgressHandler: (@Sendable (HotlineFolderItemUploadProgress) -> Void)? = nil + ) async throws { + self.uploadTask?.cancel() + + let task = Task { + try await performUpload( + progressHandler: progressHandler, + itemProgressHandler: itemProgressHandler + ) + } + self.uploadTask = task + + do { + try await task.value + self.uploadTask = nil + } catch { + print("HotlineFolderUploadClientNew[\(referenceNumber)]: Failed to upload folder: \(error)") + self.uploadTask = nil + progressHandler?(.error(error)) + throw error + } + } + + /// Cancel the current upload + public func cancel() { + uploadTask?.cancel() + uploadTask = nil + + if let socket = socket { + Task { + await socket.close() + } + } + } + + // MARK: - Implementation + + private enum UploadStage { + case waitingForNextFile // Waiting for server to send .nextFile action + case sendingItemHeader // Sending item header to server + case waitingForFileAction // Waiting for server action after file header (.sendFile, .nextFile, .resumeFile) + case uploadingFile // Uploading file data + case done // All items uploaded + } + + private func performUpload( + progressHandler: (@Sendable (HotlineTransferProgress) -> Void)?, + itemProgressHandler: (@Sendable (HotlineFolderItemUploadProgress) -> Void)? + ) async throws { + + // Note that we're preparing now. + progressHandler?(.preparing) + + // Start accessing security-scoped resource + let didStartAccess = folderURL.startAccessingSecurityScopedResource() + defer { + if didStartAccess { + self.folderURL.stopAccessingSecurityScopedResource() + } + } + + // Build folder hierarchy (excluding root folder itself) + try buildFolderHierarchy() + + // Fast path if this is an empty folder + if self.totalItems == 0 { + progressHandler?(.completed(url: nil)) + return + } + + // Note that we're connecting now. + progressHandler?(.connecting) + + // Connect to transfer server + let socket = try await NetSocket.connect( + host: self.serverAddress, + port: self.serverPort + 1 + ) + + self.socket = socket + defer { Task { await socket.close() } } + + // Send magic header for folder upload + try await socket.write(Data(endian: .big) { + "HTXF".fourCharCode() + self.referenceNumber + UInt32.zero // data size = 0 + UInt16(1) // type = 1 (folder transfer) + UInt16.zero // reserved = 0 + }) + + progressHandler?(.connected) + + var completedItemCount = 0 + var totalBytesTransferred = 0 + var itemIndex = 0 + var stage: UploadStage = .waitingForNextFile + var currentItem: FolderItem? + + // State machine loop + while stage != .done { + switch stage { + + case .waitingForNextFile: + // Wait for server to send .nextFile action + let action = try await self.readAction(socket: socket) + guard action == .nextFile else { + throw HotlineTransferClientError.failedToTransfer + } + + // Check if we have more items to send + if itemIndex < self.folderItems.count { + currentItem = self.folderItems[itemIndex] + itemIndex += 1 + stage = .sendingItemHeader + } else { + // No more items + stage = .done + } + + case .sendingItemHeader: + // Send item header to server + guard let item = currentItem else { + throw HotlineTransferClientError.failedToTransfer + } + + // Encode and send item header + totalBytesTransferred += try await socket.write(self.encodeItemHeader(item: item)) + + // Next: wait for server's response + if item.isFolder { + // For folders, we're done with this item (just creating the directory) + completedItemCount += 1 + // Server should immediately respond with .nextFile + stage = .waitingForNextFile + } else { + // For files, server will tell us what to do + stage = .waitingForFileAction + } + + case .waitingForFileAction: + // Wait for server action after file header (.sendFile, .nextFile, .resumeFile) + guard currentItem != nil else { + throw HotlineTransferClientError.failedToTransfer + } + + let action = try await self.readAction(socket: socket) + switch action { + case .nextFile: + // Server wants to skip this file + completedItemCount += 1 + // The .nextFile action means send next item, check if we have more + if itemIndex < self.folderItems.count { + currentItem = self.folderItems[itemIndex] + itemIndex += 1 + stage = .sendingItemHeader + } else { + stage = .done + } + + case .sendFile: + // Server wants the file + completedItemCount += 1 + stage = .uploadingFile + + case .resumeFile: + // Server wants to resume + let resumeSizeData = try await socket.read(2) + let resumeSize = Int(resumeSizeData.readUInt16(at: 0)!) + let _ = try await socket.read(resumeSize) + completedItemCount += 1 + stage = .uploadingFile + } + + case .uploadingFile: + // Upload file data + guard let item = currentItem else { + throw HotlineTransferClientError.failedToTransfer + } + + // Notify item progress + itemProgressHandler?(HotlineFolderItemUploadProgress( + fileName: item.url.lastPathComponent, + itemNumber: completedItemCount, + totalItems: self.totalItems + )) + + // Upload the file + let bytesUploaded = try await self.uploadFile( + socket: socket, + fileURL: item.url, + itemNumber: completedItemCount, + totalItems: self.totalItems, + totalBytesTransferredSoFar: totalBytesTransferred, + progressHandler: progressHandler + ) + + totalBytesTransferred += bytesUploaded + self.transferSize = totalBytesTransferred + + // After uploading, wait for server to send .nextFile + stage = .waitingForNextFile + + case .done: + break + } + } + + // All items processed + progressHandler?(.completed(url: nil)) + } + + private func buildFolderHierarchy() throws { + let fm = FileManager.default + folderItems = [] + transferTotal = 0 + + let rootFolderName = folderURL.lastPathComponent + + // Recursively walk the folder + func walkFolder(at url: URL, relativePath: [String]) throws { + let contents = try fm.contentsOfDirectory(at: url, includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey], options: [.skipsHiddenFiles]) + + for itemURL in contents { + let resourceValues = try itemURL.resourceValues(forKeys: [.isDirectoryKey]) + let isDirectory = resourceValues.isDirectory ?? false + let itemName = itemURL.lastPathComponent + let itemPath = relativePath + [itemName] + + if isDirectory { + // Add folder to list + folderItems.append(FolderItem(url: itemURL, pathComponents: itemPath, isFolder: true)) + + // Recurse into subfolder + try walkFolder(at: itemURL, relativePath: itemPath) + + } else { + // Add file to list and calculate size + if let fileSize = FileManager.default.getFlattenedFileSize(itemURL) { + folderItems.append(FolderItem(url: itemURL, pathComponents: itemPath, isFolder: false)) + transferTotal += Int(fileSize) + } + } + } + } + + // Start from root folder with root name as first path component + try walkFolder(at: folderURL, relativePath: [rootFolderName]) + totalItems = folderItems.count + + print("BUILD HEIRARCHY (\(folderItems.count) items):\n", folderItems) + } + + private func encodeItemHeader(item: FolderItem) -> Data { + let strippedPath = item.pathComponents.count > 1 ? Array(item.pathComponents.dropFirst()) : item.pathComponents + let strippedPathCount = strippedPath.count + + // Build path components (Hotline format: reserved(2) + nameLen(1) + name) + var pathData = Data() + for component in strippedPath { + let nameData = component.data(using: .macOSRoman) ?? component.data(using: .utf8) ?? Data() + let nameLen = min(nameData.count, 255) + + pathData.append(contentsOf: [0, 0]) // reserved + pathData.append(UInt8(nameLen)) + pathData.append(nameData.prefix(nameLen)) + } + + // Calculate header size (this is what goes in the DataSize field) + // DataSize = isFolder(2) + pathCount(2) + pathData + let headerSize = 2 + 2 + pathData.count + + return Data(endian: .big) { + UInt16(headerSize) + UInt16(item.isFolder ? 1 : 0) + UInt16(strippedPathCount) + pathData + } + } + + private func readAction(socket: NetSocket) async throws -> HotlineFolderAction { + let actionData = try await socket.read(2) + guard let rawAction = actionData.readUInt16(at: 0), + let action = HotlineFolderAction(rawValue: rawAction) else { + throw HotlineTransferClientError.failedToTransfer + } + return action + } + + private func uploadFile( + socket: NetSocket, + fileURL: URL, + itemNumber: Int, + totalItems: Int, + totalBytesTransferredSoFar: Int, + progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? + ) async throws -> Int { + var bytesUploaded = 0 + let filename = fileURL.lastPathComponent + + // Get file metadata + guard let infoFork = HotlineFileInfoFork(file: fileURL) else { + throw HotlineTransferClientError.failedToTransfer + } + + guard let header = HotlineFileHeader(file: fileURL) else { + throw HotlineTransferClientError.failedToTransfer + } + + guard let forkSizes = try? FileManager.default.getFileForkSizes(fileURL) else { + throw HotlineTransferClientError.failedToTransfer + } + + let infoForkData = infoFork.data() + let dataForkSize = forkSizes.dataForkSize + let resourceForkSize = forkSizes.resourceForkSize + + // Calculate total flattened file size + guard let flattenedSize = FileManager.default.getFlattenedFileSize(fileURL) else { + throw HotlineTransferClientError.failedToTransfer + } + let totalFileSize = Int(flattenedSize) + + // Send file size + let fileSizeData = Data(endian: .big) { + UInt32(totalFileSize) + } + try await socket.write(fileSizeData) + bytesUploaded += 4 + + // Send file header + let headerData = header.data() + try await socket.write(headerData) + bytesUploaded += headerData.count + + // Send INFO fork header + let infoForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.info.rawValue, dataSize: UInt32(infoForkData.count)) + try await socket.write(infoForkHeader.data()) + bytesUploaded += HotlineFileForkHeader.DataSize + + // Send INFO fork data + try await socket.write(infoForkData) + bytesUploaded += infoForkData.count + + // Create per-file progress for Finder + let fileProgress = Progress(totalUnitCount: Int64(totalFileSize)) + fileProgress.fileURL = fileURL.resolvingSymlinksInPath() + fileProgress.fileOperationKind = Progress.FileOperationKind.uploading + fileProgress.publish() + + defer { + fileProgress.unpublish() + } + + // Send DATA fork if present + let dataForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.data.rawValue, dataSize: dataForkSize) + try await socket.write(dataForkHeader.data()) + bytesUploaded += HotlineFileForkHeader.DataSize + + if dataForkSize > 0 { + // Stream DATA fork + let fileHandle = try FileHandle(forReadingFrom: fileURL) + defer { try? fileHandle.close() } + + let updates = await socket.writeFile(from: fileHandle, length: Int(dataForkSize)) + for try await p in updates { + // Update per-file Finder progress + fileProgress.completedUnitCount = Int64(bytesUploaded + p.sent) + + // Calculate overall folder progress + let totalBytesNow = totalBytesTransferredSoFar + bytesUploaded + p.sent + let rawProgress = self.transferTotal > 0 ? Double(totalBytesNow) / Double(self.transferTotal) : 0.0 + let overallProgress = min(rawProgress, 1.0) + + // Calculate overall time estimate + let remainingBytes = max(0, self.transferTotal - totalBytesNow) + let estimate: TimeInterval? = if let speed = p.bytesPerSecond, speed > 0, remainingBytes > 0 { + TimeInterval(remainingBytes) / speed + } else { + nil + } + + // Report overall folder progress + progressHandler?(.transfer( + name: filename, + size: totalBytesNow, + total: self.transferTotal, + progress: overallProgress, + speed: p.bytesPerSecond, + estimate: estimate + )) + } + + bytesUploaded += Int(dataForkSize) + } + + // Send RESOURCE fork if present + if resourceForkSize > 0 { + let resourceURL = fileURL.urlForResourceFork() + + let resourceForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.resource.rawValue, dataSize: resourceForkSize) + try await socket.write(resourceForkHeader.data()) + bytesUploaded += HotlineFileForkHeader.DataSize + + // Stream RESOURCE fork + let resourceHandle = try FileHandle(forReadingFrom: resourceURL) + defer { try? resourceHandle.close() } + + let updates = await socket.writeFile(from: resourceHandle, length: Int(resourceForkSize)) + for try await p in updates { + // Update per-file Finder progress + fileProgress.completedUnitCount = Int64(bytesUploaded + p.sent) + + // Calculate overall folder progress + let totalBytesNow = totalBytesTransferredSoFar + bytesUploaded + p.sent + let rawProgress = self.transferTotal > 0 ? Double(totalBytesNow) / Double(self.transferTotal) : 0.0 + let overallProgress = min(rawProgress, 1.0) + + // Calculate overall time estimate + let remainingBytes = max(0, self.transferTotal - totalBytesNow) + let estimate: TimeInterval? = if let speed = p.bytesPerSecond, speed > 0, remainingBytes > 0 { + TimeInterval(remainingBytes) / speed + } else { + nil + } + + // Report overall folder progress + progressHandler?(.transfer( + name: filename, + size: totalBytesNow, + total: self.transferTotal, + progress: overallProgress, + speed: p.bytesPerSecond, + estimate: estimate + )) + } + + bytesUploaded += Int(resourceForkSize) + } + + return bytesUploaded + } +} diff --git a/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift deleted file mode 100644 index 15636f3..0000000 --- a/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift +++ /dev/null @@ -1,518 +0,0 @@ -import Foundation -import Network - -/// Item progress callback for folder uploads -public struct HotlineFolderItemUploadProgress: Sendable { - public let fileName: String - public let itemNumber: Int - public let totalItems: Int -} - -/// Represents a file or folder in the upload queue -private struct FolderItem { - let url: URL - let pathComponents: [String] // Path relative to upload root - let isFolder: Bool -} - -@MainActor -public class HotlineFolderUploadClientNew: @MainActor HotlineTransferClient { - // MARK: - Configuration - - public struct Configuration: Sendable { - public var chunkSize: Int = 256 * 1024 - public init() {} - } - - // MARK: - Properties - - private let serverAddress: String - private let serverPort: UInt16 - private let referenceNumber: UInt32 - private let folderURL: URL - - private let config: Configuration - - private var transferTotal: Int = 0 - private var transferSize: Int = 0 - private var folderItems: [FolderItem] = [] - private var totalItems: Int = 0 - - private var socket: NetSocket? - private var uploadTask: Task? - - // MARK: - Initialization - - public init?( - folderURL: URL, - address: String, - port: UInt16, - reference: UInt32, - configuration: Configuration = .init() - ) { - guard FileManager.default.fileExists(atPath: folderURL.path(percentEncoded: false)) else { - return nil - } - - var isDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: folderURL.path(percentEncoded: false), isDirectory: &isDirectory), - isDirectory.boolValue else { - return nil - } - - self.serverAddress = address - self.serverPort = port - self.referenceNumber = reference - self.folderURL = folderURL - self.config = configuration - - print("HotlineFolderUploadClientNew[\(reference)]: Preparing to upload folder '\(folderURL.lastPathComponent)'") - } - - // MARK: - API - - public func upload( - progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil, - itemProgress itemProgressHandler: (@Sendable (HotlineFolderItemUploadProgress) -> Void)? = nil - ) async throws { - self.uploadTask?.cancel() - - let task = Task { - try await performUpload( - progressHandler: progressHandler, - itemProgressHandler: itemProgressHandler - ) - } - self.uploadTask = task - - do { - try await task.value - self.uploadTask = nil - } catch { - print("HotlineFolderUploadClientNew[\(referenceNumber)]: Failed to upload folder: \(error)") - self.uploadTask = nil - progressHandler?(.error(error)) - throw error - } - } - - /// Cancel the current upload - public func cancel() { - uploadTask?.cancel() - uploadTask = nil - - if let socket = socket { - Task { - await socket.close() - } - } - } - - // MARK: - - - private enum UploadStage { - case waitingForNextFile // Waiting for server to send .nextFile action - case sendingItemHeader // Sending item header to server - case waitingForFileAction // Waiting for server action after file header (.sendFile, .nextFile, .resumeFile) - case uploadingFile // Uploading file data - case done // All items uploaded - } - - private func performUpload( - progressHandler: (@Sendable (HotlineTransferProgress) -> Void)?, - itemProgressHandler: (@Sendable (HotlineFolderItemUploadProgress) -> Void)? - ) async throws { - - // Note that we're preparing now. - progressHandler?(.preparing) - - // Start accessing security-scoped resource - let didStartAccess = folderURL.startAccessingSecurityScopedResource() - defer { - if didStartAccess { - self.folderURL.stopAccessingSecurityScopedResource() - } - } - - // Build folder hierarchy (excluding root folder itself) - try buildFolderHierarchy() - - // Fast path if this is an empty folder - if self.totalItems == 0 { - progressHandler?(.completed(url: nil)) - return - } - - // Note that we're connecting now. - progressHandler?(.connecting) - - // Connect to transfer server - let socket = try await NetSocket.connect( - host: self.serverAddress, - port: self.serverPort + 1 - ) - - self.socket = socket - defer { Task { await socket.close() } } - - // Send magic header for folder upload - try await socket.write(Data(endian: .big) { - "HTXF".fourCharCode() - self.referenceNumber - UInt32.zero // data size = 0 - UInt16(1) // type = 1 (folder transfer) - UInt16.zero // reserved = 0 - }) - - progressHandler?(.connected) - - var completedItemCount = 0 - var totalBytesTransferred = 0 - var itemIndex = 0 - var stage: UploadStage = .waitingForNextFile - var currentItem: FolderItem? - - // State machine loop - while stage != .done { - switch stage { - - case .waitingForNextFile: - // Wait for server to send .nextFile action - let action = try await self.readAction(socket: socket) - guard action == .nextFile else { - throw HotlineTransferClientError.failedToTransfer - } - - // Check if we have more items to send - if itemIndex < self.folderItems.count { - currentItem = self.folderItems[itemIndex] - itemIndex += 1 - stage = .sendingItemHeader - } else { - // No more items - stage = .done - } - - case .sendingItemHeader: - // Send item header to server - guard let item = currentItem else { - throw HotlineTransferClientError.failedToTransfer - } - - // Encode and send item header - totalBytesTransferred += try await socket.write(self.encodeItemHeader(item: item)) - - // Next: wait for server's response - if item.isFolder { - // For folders, we're done with this item (just creating the directory) - completedItemCount += 1 - // Server should immediately respond with .nextFile - stage = .waitingForNextFile - } else { - // For files, server will tell us what to do - stage = .waitingForFileAction - } - - case .waitingForFileAction: - // Wait for server action after file header (.sendFile, .nextFile, .resumeFile) - guard currentItem != nil else { - throw HotlineTransferClientError.failedToTransfer - } - - let action = try await self.readAction(socket: socket) - switch action { - case .nextFile: - // Server wants to skip this file - completedItemCount += 1 - // The .nextFile action means send next item, check if we have more - if itemIndex < self.folderItems.count { - currentItem = self.folderItems[itemIndex] - itemIndex += 1 - stage = .sendingItemHeader - } else { - stage = .done - } - - case .sendFile: - // Server wants the file - completedItemCount += 1 - stage = .uploadingFile - - case .resumeFile: - // Server wants to resume - let resumeSizeData = try await socket.read(2) - let resumeSize = Int(resumeSizeData.readUInt16(at: 0)!) - let _ = try await socket.read(resumeSize) - completedItemCount += 1 - stage = .uploadingFile - } - - case .uploadingFile: - // Upload file data - guard let item = currentItem else { - throw HotlineTransferClientError.failedToTransfer - } - - // Notify item progress - itemProgressHandler?(HotlineFolderItemUploadProgress( - fileName: item.url.lastPathComponent, - itemNumber: completedItemCount, - totalItems: self.totalItems - )) - - // Upload the file - let bytesUploaded = try await self.uploadFile( - socket: socket, - fileURL: item.url, - itemNumber: completedItemCount, - totalItems: self.totalItems, - totalBytesTransferredSoFar: totalBytesTransferred, - progressHandler: progressHandler - ) - - totalBytesTransferred += bytesUploaded - self.transferSize = totalBytesTransferred - - // After uploading, wait for server to send .nextFile - stage = .waitingForNextFile - - case .done: - break - } - } - - // All items processed - progressHandler?(.completed(url: nil)) - } - - private func buildFolderHierarchy() throws { - let fm = FileManager.default - folderItems = [] - transferTotal = 0 - - let rootFolderName = folderURL.lastPathComponent - - // Recursively walk the folder - func walkFolder(at url: URL, relativePath: [String]) throws { - let contents = try fm.contentsOfDirectory(at: url, includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey], options: [.skipsHiddenFiles]) - - for itemURL in contents { - let resourceValues = try itemURL.resourceValues(forKeys: [.isDirectoryKey]) - let isDirectory = resourceValues.isDirectory ?? false - let itemName = itemURL.lastPathComponent - let itemPath = relativePath + [itemName] - - if isDirectory { - // Add folder to list - folderItems.append(FolderItem(url: itemURL, pathComponents: itemPath, isFolder: true)) - - // Recurse into subfolder - try walkFolder(at: itemURL, relativePath: itemPath) - - } else { - // Add file to list and calculate size - if let fileSize = FileManager.default.getFlattenedFileSize(itemURL) { - folderItems.append(FolderItem(url: itemURL, pathComponents: itemPath, isFolder: false)) - transferTotal += Int(fileSize) - } - } - } - } - - // Start from root folder with root name as first path component - try walkFolder(at: folderURL, relativePath: [rootFolderName]) - totalItems = folderItems.count - - print("BUILD HEIRARCHY (\(folderItems.count) items):\n", folderItems) - } - - private func encodeItemHeader(item: FolderItem) -> Data { - let strippedPath = item.pathComponents.count > 1 ? Array(item.pathComponents.dropFirst()) : item.pathComponents - let strippedPathCount = strippedPath.count - - // Build path components (Hotline format: reserved(2) + nameLen(1) + name) - var pathData = Data() - for component in strippedPath { - let nameData = component.data(using: .macOSRoman) ?? component.data(using: .utf8) ?? Data() - let nameLen = min(nameData.count, 255) - - pathData.append(contentsOf: [0, 0]) // reserved - pathData.append(UInt8(nameLen)) - pathData.append(nameData.prefix(nameLen)) - } - - // Calculate header size (this is what goes in the DataSize field) - // DataSize = isFolder(2) + pathCount(2) + pathData - let headerSize = 2 + 2 + pathData.count - - return Data(endian: .big) { - UInt16(headerSize) - UInt16(item.isFolder ? 1 : 0) - UInt16(strippedPathCount) - pathData - } - } - - private func readAction(socket: NetSocket) async throws -> HotlineFolderAction { - let actionData = try await socket.read(2) - guard let rawAction = actionData.readUInt16(at: 0), - let action = HotlineFolderAction(rawValue: rawAction) else { - throw HotlineTransferClientError.failedToTransfer - } - return action - } - - private func uploadFile( - socket: NetSocket, - fileURL: URL, - itemNumber: Int, - totalItems: Int, - totalBytesTransferredSoFar: Int, - progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? - ) async throws -> Int { - var bytesUploaded = 0 - let filename = fileURL.lastPathComponent - - // Get file metadata - guard let infoFork = HotlineFileInfoFork(file: fileURL) else { - throw HotlineTransferClientError.failedToTransfer - } - - guard let header = HotlineFileHeader(file: fileURL) else { - throw HotlineTransferClientError.failedToTransfer - } - - guard let forkSizes = try? FileManager.default.getFileForkSizes(fileURL) else { - throw HotlineTransferClientError.failedToTransfer - } - - let infoForkData = infoFork.data() - let dataForkSize = forkSizes.dataForkSize - let resourceForkSize = forkSizes.resourceForkSize - - // Calculate total flattened file size - guard let flattenedSize = FileManager.default.getFlattenedFileSize(fileURL) else { - throw HotlineTransferClientError.failedToTransfer - } - let totalFileSize = Int(flattenedSize) - - // Send file size - let fileSizeData = Data(endian: .big) { - UInt32(totalFileSize) - } - try await socket.write(fileSizeData) - bytesUploaded += 4 - - // Send file header - let headerData = header.data() - try await socket.write(headerData) - bytesUploaded += headerData.count - - // Send INFO fork header - let infoForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.info.rawValue, dataSize: UInt32(infoForkData.count)) - try await socket.write(infoForkHeader.data()) - bytesUploaded += HotlineFileForkHeader.DataSize - - // Send INFO fork data - try await socket.write(infoForkData) - bytesUploaded += infoForkData.count - - // Create per-file progress for Finder - let fileProgress = Progress(totalUnitCount: Int64(totalFileSize)) - fileProgress.fileURL = fileURL.resolvingSymlinksInPath() - fileProgress.fileOperationKind = Progress.FileOperationKind.uploading - fileProgress.publish() - - defer { - fileProgress.unpublish() - } - - // Send DATA fork if present - let dataForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.data.rawValue, dataSize: dataForkSize) - try await socket.write(dataForkHeader.data()) - bytesUploaded += HotlineFileForkHeader.DataSize - - if dataForkSize > 0 { - // Stream DATA fork - let fileHandle = try FileHandle(forReadingFrom: fileURL) - defer { try? fileHandle.close() } - - let updates = await socket.writeFile(from: fileHandle, length: Int(dataForkSize)) - for try await p in updates { - // Update per-file Finder progress - fileProgress.completedUnitCount = Int64(bytesUploaded + p.sent) - - // Calculate overall folder progress - let totalBytesNow = totalBytesTransferredSoFar + bytesUploaded + p.sent - let rawProgress = self.transferTotal > 0 ? Double(totalBytesNow) / Double(self.transferTotal) : 0.0 - let overallProgress = min(rawProgress, 1.0) - - // Calculate overall time estimate - let remainingBytes = max(0, self.transferTotal - totalBytesNow) - let estimate: TimeInterval? = if let speed = p.bytesPerSecond, speed > 0, remainingBytes > 0 { - TimeInterval(remainingBytes) / speed - } else { - nil - } - - // Report overall folder progress - progressHandler?(.transfer( - name: filename, - size: totalBytesNow, - total: self.transferTotal, - progress: overallProgress, - speed: p.bytesPerSecond, - estimate: estimate - )) - } - - bytesUploaded += Int(dataForkSize) - } - - // Send RESOURCE fork if present - if resourceForkSize > 0 { - let resourceURL = fileURL.urlForResourceFork() - - let resourceForkHeader = HotlineFileForkHeader(type: HotlineFileForkType.resource.rawValue, dataSize: resourceForkSize) - try await socket.write(resourceForkHeader.data()) - bytesUploaded += HotlineFileForkHeader.DataSize - - // Stream RESOURCE fork - let resourceHandle = try FileHandle(forReadingFrom: resourceURL) - defer { try? resourceHandle.close() } - - let updates = await socket.writeFile(from: resourceHandle, length: Int(resourceForkSize)) - for try await p in updates { - // Update per-file Finder progress - fileProgress.completedUnitCount = Int64(bytesUploaded + p.sent) - - // Calculate overall folder progress - let totalBytesNow = totalBytesTransferredSoFar + bytesUploaded + p.sent - let rawProgress = self.transferTotal > 0 ? Double(totalBytesNow) / Double(self.transferTotal) : 0.0 - let overallProgress = min(rawProgress, 1.0) - - // Calculate overall time estimate - let remainingBytes = max(0, self.transferTotal - totalBytesNow) - let estimate: TimeInterval? = if let speed = p.bytesPerSecond, speed > 0, remainingBytes > 0 { - TimeInterval(remainingBytes) / speed - } else { - nil - } - - // Report overall folder progress - progressHandler?(.transfer( - name: filename, - size: totalBytesNow, - total: self.transferTotal, - progress: overallProgress, - speed: p.bytesPerSecond, - estimate: estimate - )) - } - - bytesUploaded += Int(resourceForkSize) - } - - return bytesUploaded - } -} diff --git a/Hotline/Library/Extensions.swift b/Hotline/Library/Extensions.swift index bb28370..cf9f8ff 100644 --- a/Hotline/Library/Extensions.swift +++ b/Hotline/Library/Extensions.swift @@ -2,23 +2,85 @@ import Foundation import SwiftUI import UniformTypeIdentifiers +extension FileManager { + @discardableResult + func moveToDownloads(from sourceURL: URL, using filename: String, bounceDock: Bool = false) -> Bool { + let filePath = URL.downloadsDirectory.generateUniqueFilePath(filename: filename) + let destinationURL = URL(filePath: filePath).resolvingSymlinksInPath() + + do { + try FileManager.default.moveItem(at: sourceURL.resolvingSymlinksInPath(), to: destinationURL) + } + catch { + return false + } + + if bounceDock { + #if os(macOS) + DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: destinationURL.path) + #endif + } + + return true + } + + @discardableResult + func copyToDownloads(from sourceURL: URL, using filename: String, bounceDock: Bool = false) -> Bool { + let filePath = URL.downloadsDirectory.generateUniqueFilePath(filename: filename) + let destinationURL = URL(filePath: filePath).resolvingSymlinksInPath() + + do { + try FileManager.default.copyItem(at: sourceURL.resolvingSymlinksInPath(), to: destinationURL) + } + catch { + return false + } + + if bounceDock { + #if os(macOS) + DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: destinationURL.path) + #endif + } + + return true + } +} + +// MARK: - + +extension View { + @ViewBuilder + func applyNavigationDocumentIfPresent(_ url: URL?) -> some View { + if let url { + self.navigationDocument(url) + } else { + self + } + } +} + +// MARK: - + extension Data { func saveAsFileToDownloads(filename: String, bounceDock: Bool = true) -> Bool { - let folderURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0] - let filePath = folderURL.generateUniqueFilePath(filename: filename) - if FileManager.default.createFile(atPath: filePath, contents: nil) { - if let h = FileHandle(forWritingAtPath: filePath) { - try? h.write(contentsOf: self) - 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) - #endif - } - return true + let filePath = URL.downloadsDirectory.generateUniqueFilePath(filename: filename) + + if FileManager.default.createFile(atPath: filePath, contents: self) { + if bounceDock { + #if os(macOS) + var downloadURL = URL(filePath: filePath) + downloadURL.resolveSymlinksInPath() + DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path) + #endif } + return true + +// if FileManager.default.createFile(atPath: filePath, contents: nil) { +// if let h = FileHandle(forWritingAtPath: filePath) { +// try? h.write(contentsOf: self) +// try? h.close() +// +// } } return false } diff --git a/Hotline/Models/FileInfo.swift b/Hotline/Models/FileInfo.swift index e3081c4..a2f0ea5 100644 --- a/Hotline/Models/FileInfo.swift +++ b/Hotline/Models/FileInfo.swift @@ -49,13 +49,19 @@ import UniformTypeIdentifiers var children: [FileInfo]? = nil var isPreviewable: Bool { - let fileExtension = (self.name as NSString).pathExtension.lowercased() + var fileExtension = (self.name as NSString).pathExtension.lowercased() + if fileExtension.isEmpty && !self.type.isEmpty { + let type = self.type.lowercased() + if let ext = FileManager.HFSTypeToExtension[type] { + fileExtension = ext + } + } + if let fileType = UTType(filenameExtension: fileExtension) { if fileType.canBePreviewedByQuickLook { return true } - print("FILE TYPE?", fileType, fileExtension, fileType.isSubtype(of: .pdf), fileType.isSupertype(of: .pdf)) if fileType.isSubtype(of: .image) { return true } diff --git a/Hotline/Models/PreviewFileInfo.swift b/Hotline/Models/PreviewFileInfo.swift index dcf5314..7e05a97 100644 --- a/Hotline/Models/PreviewFileInfo.swift +++ b/Hotline/Models/PreviewFileInfo.swift @@ -7,6 +7,9 @@ struct PreviewFileInfo: Identifiable, Codable { var size: Int var name: String + var type: String? = nil + var creator: String? = nil + var previewType: FilePreviewType { let fileExtension = (self.name as NSString).pathExtension if let fileType = UTType(filenameExtension: fileExtension) { diff --git a/Hotline/State/AppUpdate.swift b/Hotline/State/AppUpdate.swift index 601859e..558a296 100644 --- a/Hotline/State/AppUpdate.swift +++ b/Hotline/State/AppUpdate.swift @@ -35,8 +35,6 @@ final class AppUpdate { case manual } - // MARK: - Public State - var isChecking = false var isDownloading = false var showWindow = false @@ -55,7 +53,7 @@ final class AppUpdate { private let remindDateKey = "update.remind.date" private let lastPromptedVersionKey = "update.last.prompt.version" - // MARK: - Public API + // MARK: - API func checkForUpdatesOnLaunch() async { await checkForUpdates(trigger: .automatic) @@ -100,7 +98,7 @@ final class AppUpdate { resetAndCloseWindow() } - // MARK: - Internal Logic + // MARK: - Implementation private func checkForUpdates(trigger: CheckTrigger) async { await MainActor.run { @@ -259,8 +257,7 @@ final class AppUpdate { private func downloadRelease(_ release: UpdateReleaseInfo) async { do { let (temporaryURL, _) = try await URLSession.shared.download(from: release.downloadURL) - let downloadsDirectory = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first! - let destinationURL = downloadsDirectory.appendingPathComponent(release.assetName) + let destinationURL = URL.downloadsDirectory.appendingPathComponent(release.assetName) if FileManager.default.fileExists(atPath: destinationURL.path) { try? FileManager.default.removeItem(at: destinationURL) diff --git a/Hotline/State/FilePreviewState.swift b/Hotline/State/FilePreviewState.swift index 27f8199..ec79f66 100644 --- a/Hotline/State/FilePreviewState.swift +++ b/Hotline/State/FilePreviewState.swift @@ -1,8 +1,6 @@ import SwiftUI import UniformTypeIdentifiers -// MARK: - Preview Type - enum FilePreviewType: Equatable { case unknown case image @@ -13,13 +11,15 @@ enum FilePreviewType: Equatable { @MainActor @Observable final class FilePreviewState { - // MARK: - Properties - + enum LoadState: Equatable { + case unloaded + case loading + case loaded + case failed + } + let info: PreviewFileInfo - private var previewClient: HotlineFilePreviewClient? - private var previewTask: Task? - var state: LoadState = .unloaded var progress: Double = 0.0 @@ -33,39 +33,35 @@ final class FilePreviewState { var text: String? = nil var styledText: NSAttributedString? = nil - - // MARK: - Computed Properties + + @ObservationIgnored private var previewClient: HotlineFilePreviewClient? + @ObservationIgnored private var previewTask: Task? var previewType: FilePreviewType { - info.previewType + self.info.previewType } - // MARK: - Initialization - init(info: PreviewFileInfo) { self.info = info } - nonisolated deinit { - // Note: Can't access @MainActor properties from deinit - // Cleanup will happen when previewClient is deallocated - } - - // MARK: - Public API + // MARK: - API func download() { // Cancel any existing download - previewTask?.cancel() - previewClient?.cleanup() + self.previewTask?.cancel() + self.previewClient?.cleanup() let task = Task { @MainActor in do { let client = HotlineFilePreviewClient( - fileName: info.name, - address: info.address, - port: UInt16(info.port), - reference: info.id, - size: UInt32(info.size) + fileName: self.info.name, + address: self.info.address, + port: UInt16(self.info.port), + reference: self.info.id, + size: UInt32(self.info.size), + fileType: self.info.type, + fileCreator: self.info.creator ) self.previewClient = client @@ -132,69 +128,58 @@ final class FilePreviewState { } func cancel() { - previewTask?.cancel() - previewTask = nil - previewClient?.cancel() + self.previewTask?.cancel() + self.previewTask = nil + self.previewClient?.cancel() } func cleanup() { - previewClient?.cleanup() - previewClient = nil - fileURL = nil - image = nil - text = nil - styledText = nil - } - - // MARK: - Private Implementation - - private func loadPreview(from url: URL) { - guard let data = try? Data(contentsOf: url) else { - self.state = .failed - print("FilePreviewState: Failed to read preview data from \(url.path)") - return - } - - switch self.previewType { - case .image: - #if os(iOS) - self.image = UIImage(data: data) - #elseif os(macOS) - self.image = NSImage(data: data) - #endif - - if self.image == nil { - self.state = .failed - print("FilePreviewState: Failed to create image from data") - } - - case .text: - 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 { - self.text = String(data: data, encoding: .utf8) - } - - if self.text == nil { - self.state = .failed - print("FilePreviewState: Failed to decode text data") - } - - case .unknown: - print("FilePreviewState: Unknown preview type for \(info.name)") - break - } + self.previewClient?.cleanup() + self.previewClient = nil + self.fileURL = nil + self.image = nil + self.text = nil + self.styledText = nil } -} - -// MARK: - Load State -extension FilePreviewState { - enum LoadState: Equatable { - case unloaded - case loading - case loaded - case failed - } + // MARK: - Utility + +// private func loadPreview(from url: URL) { +// guard let data = try? Data(contentsOf: url) else { +// self.state = .failed +// print("FilePreviewState: Failed to read preview data from \(url.path)") +// return +// } +// +// switch self.previewType { +// case .image: +// #if os(iOS) +// self.image = UIImage(data: data) +// #elseif os(macOS) +// self.image = NSImage(data: data) +// #endif +// +// if self.image == nil { +// self.state = .failed +// print("FilePreviewState: Failed to create image from data") +// } +// +// case .text: +// 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 { +// self.text = String(data: data, encoding: .utf8) +// } +// +// if self.text == nil { +// self.state = .failed +// print("FilePreviewState: Failed to decode text data") +// } +// +// case .unknown: +// print("FilePreviewState: Unknown preview type for \(info.name)") +// break +// } +// } } diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index e80571a..f814818 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -252,7 +252,7 @@ class HotlineState: Equatable { // MARK: - Private State - @ObservationIgnored private var client: HotlineClientNew? + @ObservationIgnored private var client: HotlineClient? @ObservationIgnored private var eventTask: Task? @ObservationIgnored private var chatSessionKey: ChatStore.SessionKey? @ObservationIgnored private var restoredChatSessionKey: ChatStore.SessionKey? @@ -308,13 +308,13 @@ class HotlineState: Equatable { iconID: UInt16(iconID) ) - print("HotlineState.login(): Calling HotlineClientNew.connect()...") - let client = try await HotlineClientNew.connect( + print("HotlineState.login(): Calling HotlineClient.connect()...") + let client = try await HotlineClient.connect( host: server.address, port: UInt16(server.port), login: loginInfo ) - print("HotlineState.login(): HotlineClientNew.connect() returned") + print("HotlineState.login(): HotlineClient.connect() returned") self.client = client print("HotlineState.login(): Client stored") @@ -865,7 +865,7 @@ class HotlineState: Equatable { /// - progressCallback: Optional callback for progress updates (receives TransferInfo and progress 0.0-1.0) /// - callback: Optional completion callback (receives TransferInfo and final file URL) @MainActor - func downloadFileNew(_ fileName: String, path: [String], to destination: URL? = nil, progress progressCallback: ((TransferInfo) -> Void)? = nil, complete callback: ((TransferInfo) -> Void)? = nil) { + func downloadFile(_ fileName: String, path: [String], to destination: URL? = nil, progress progressCallback: ((TransferInfo) -> Void)? = nil, complete callback: ((TransferInfo) -> Void)? = nil) { guard let client = self.client else { return } var fullPath: [String] = [] @@ -975,7 +975,7 @@ class HotlineState: Equatable { /// - itemProgressCallback: Optional callback for per-item updates (receives TransferInfo with current file info) /// - callback: Optional completion callback (receives TransferInfo and final folder URL) @MainActor - func downloadFolderNew( + func downloadFolder( _ folderName: String, path: [String], to destination: URL? = nil, @@ -1018,7 +1018,7 @@ class HotlineState: Equatable { AppState.shared.addTransfer(transfer) // Create download client - let downloadClient = HotlineFolderDownloadClientNew( + let downloadClient = HotlineFolderDownloadClient( address: address, port: UInt16(port), reference: referenceNumber, @@ -1091,7 +1091,7 @@ class HotlineState: Equatable { } } - /// Modern async/await folder upload using HotlineFolderUploadClientNew + /// Upload a folder to the server. /// /// - Parameters: /// - folderURL: URL to the folder on disk to upload @@ -1155,7 +1155,7 @@ class HotlineState: Equatable { print("HotlineState: Got folder upload reference: \(referenceNumber)") // Create upload client - guard let uploadClient = HotlineFolderUploadClientNew( + guard let uploadClient = HotlineFolderUploadClient( folderURL: folderURL, address: address, port: UInt16(port), @@ -1346,9 +1346,9 @@ class HotlineState: Equatable { } func setFileInfo(fileName: String, path filePath: [String], fileNewName: String?, comment: String?, encoding: String.Encoding = .utf8) { - // TODO: Implement setFileInfo in HotlineClientNew + // TODO: Implement setFileInfo in HotlineClient // This method updates file metadata (name and/or comment) - print("setFileInfo not yet implemented in HotlineState/HotlineClientNew") + print("setFileInfo not yet implemented in HotlineState/HotlineClient") } @MainActor diff --git a/Hotline/State/ServerState.swift b/Hotline/State/ServerState.swift index 805672d..5913bf5 100644 --- a/Hotline/State/ServerState.swift +++ b/Hotline/State/ServerState.swift @@ -29,7 +29,7 @@ enum ServerNavigationType: Identifiable, Hashable, Equatable { case .files: return "Files" case .accounts: - return "Admin" + return "Accounts" case .user(let userID): return String(userID) } diff --git a/Hotline/macOS/Files/FilePreviewQuickLookView.swift b/Hotline/macOS/Files/FilePreviewQuickLookView.swift index 26bd286..a504a7a 100644 --- a/Hotline/macOS/Files/FilePreviewQuickLookView.swift +++ b/Hotline/macOS/Files/FilePreviewQuickLookView.swift @@ -11,16 +11,16 @@ struct FilePreviewQuickLookView: View { @Environment(\.dismiss) private var dismiss @Binding var info: PreviewFileInfo? - @State var preview: FilePreviewState? = nil + @State private var preview: FilePreviewState? = nil @FocusState private var focusField: FilePreviewFocus? var body: some View { Group { - if preview?.state != .loaded { + if self.preview?.state != .loaded { VStack(alignment: .center, spacing: 0) { Spacer() - ProgressView(value: max(0.0, min(1.0, preview?.progress ?? 0.0))) + ProgressView(value: max(0.0, min(1.0, self.preview?.progress ?? 0.0))) .focusable(false) .progressViewStyle(.circular) .controlSize(.extraLarge) @@ -33,7 +33,7 @@ struct FilePreviewQuickLookView: View { .padding() } else { - if let fileURL = preview?.fileURL { + if let fileURL = self.preview?.fileURL { QuickLookPreviewView(fileURL: fileURL) .frame(minWidth: 400, maxWidth: .infinity, minHeight: 400, maxHeight: .infinity) } @@ -68,25 +68,15 @@ struct FilePreviewQuickLookView: View { .focusable() .focusEffectDisabled() .background(Color(nsColor: .textBackgroundColor)) - .focused($focusField, equals: .window) - .navigationTitle(info?.name ?? "File Preview") - .background { - if let fileURL = self.preview?.fileURL { - WindowConfigurator { window in - window.representedURL = fileURL - window.standardWindowButton(.documentIconButton)?.isHidden = false - } - } - } + .focused(self.$focusField, equals: .window) + .navigationTitle(self.info?.name ?? "File Preview") + .applyNavigationDocumentIfPresent(self.preview?.fileURL) .toolbar { - if let _ = preview?.fileURL { + if let fileURL = self.preview?.fileURL { if let info = info { ToolbarItem(placement: .primaryAction) { Button { - if let fileURL = preview?.fileURL, - let data = try? Data(contentsOf: fileURL) { - let _ = data.saveAsFileToDownloads(filename: info.name) - } + FileManager.default.copyToDownloads(from: fileURL, using: info.name, bounceDock: true) } label: { Label("Download File...", systemImage: "arrow.down") } @@ -96,28 +86,27 @@ struct FilePreviewQuickLookView: View { } } .task { - if let info = info { - preview = FilePreviewState(info: info) - preview?.download() + if let info = self.info { + self.preview = FilePreviewState(info: info) + self.preview?.download() } } .onAppear { - if info == nil { - Task { - dismiss() - } + guard self.info != nil else { + self.dismiss() return } - focusField = .window + self.focusField = .window } .onDisappear { - preview?.cancel() - dismiss() + self.preview?.cancel() + self.preview?.cleanup() + self.dismiss() } - .onChange(of: preview?.state) { - if preview?.state == .failed { - dismiss() + .onChange(of: self.preview?.state) { + if self.preview?.state == .failed { + self.dismiss() } } .preferredColorScheme(.dark) diff --git a/Hotline/macOS/Files/FilesView.swift b/Hotline/macOS/Files/FilesView.swift index d4ba5c8..984b120 100644 --- a/Hotline/macOS/Files/FilesView.swift +++ b/Hotline/macOS/Files/FilesView.swift @@ -376,11 +376,11 @@ struct FilesView: View { private func openPreviewWindow(_ previewInfo: PreviewFileInfo) { switch previewInfo.previewType { case .image: - openWindow(id: "preview-quicklook", value: previewInfo) + self.openWindow(id: "preview-quicklook", value: previewInfo) case .text: - openWindow(id: "preview-quicklook", value: previewInfo) + self.openWindow(id: "preview-quicklook", value: previewInfo) case .unknown: - openWindow(id: "preview-quicklook", value: previewInfo) + self.openWindow(id: "preview-quicklook", value: previewInfo) return } } @@ -397,10 +397,10 @@ struct FilesView: View { @MainActor private func downloadFile(_ file: FileInfo) { if file.isFolder { - model.downloadFolderNew(file.name, path: file.path) + model.downloadFolder(file.name, path: file.path) } else { - model.downloadFileNew(file.name, path: file.path) + model.downloadFile(file.name, path: file.path) } } @@ -442,9 +442,12 @@ struct FilesView: View { return } - model.previewFile(file.name, path: file.path) { info in + self.model.previewFile(file.name, path: file.path) { info in if let info = info { - openPreviewWindow(info) + var extendedInfo = info + extendedInfo.creator = file.creator + extendedInfo.type = file.type + self.openPreviewWindow(extendedInfo) } } } diff --git a/Hotline/macOS/Files/FolderItemView.swift b/Hotline/macOS/Files/FolderItemView.swift index 2b1b695..9b13cc0 100644 --- a/Hotline/macOS/Files/FolderItemView.swift +++ b/Hotline/macOS/Files/FolderItemView.swift @@ -81,7 +81,7 @@ struct FolderItemView: View { .opacity(file.isUnavailable ? 0.5 : 1.0) if loading { - ProgressView().controlSize(.small).padding([.leading, .trailing], 5) + ProgressView().controlSize(.mini).padding([.leading, .trailing], 5) } Spacer() if !file.isUnavailable { diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index 757dbd8..d4b3407 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -93,7 +93,7 @@ struct ServerView: View { ServerMenuItem(type: .board, name: "Board", image: "Section Board"), ServerMenuItem(type: .news, name: "News", image: "Section News"), ServerMenuItem(type: .files, name: "Files", image: "Section Files"), - ServerMenuItem(type: .accounts, name: "Admin", image: "Section Users"), + ServerMenuItem(type: .accounts, name: "Accounts", image: "Section Users"), ] static var classicMenuItems: [ServerMenuItem] = [ @@ -280,47 +280,43 @@ struct ServerView: View { } var transfersSection: some View { -// Section("Transfers") { - ForEach(model.transfers) { transfer in - TransferItemView(transfer: transfer) - } -// } + 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 = HotlineState.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)) - } + ForEach(model.users) { user in + HStack(spacing: 5) { + if let iconImage = HotlineState.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 { @@ -353,7 +349,7 @@ struct ServerView: View { case .accounts: AccountManagerView() .navigationTitle(model.serverTitle) - .navigationSubtitle("Administration") + .navigationSubtitle("Accounts") .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .user(let userID): let user = model.users.first(where: { $0.id == userID }) diff --git a/Hotline/macOS/TransfersView.swift b/Hotline/macOS/TransfersView.swift index 489a50b..bf8c8bd 100644 --- a/Hotline/macOS/TransfersView.swift +++ b/Hotline/macOS/TransfersView.swift @@ -19,9 +19,7 @@ struct TransfersView: View { ToolbarItem(placement: .primaryAction) { Button { if self.selectedTransfers.isEmpty { - if let downloadsURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first { - NSWorkspace.shared.open(downloadsURL) - } + NSWorkspace.shared.open(URL.downloadsDirectory) } else { let fileURLs = self.selectedTransfers.compactMap(\.fileURL) -- cgit From f87ff05d17cedf21845abbb8d750759ba725a263 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Tue, 11 Nov 2025 15:39:02 -0800 Subject: Folder download logic cleanup. Display folder icon on folder download transfers with small icon for the current file. Fix progress for folder downloads. --- .../Transfers/HotlineFolderDownloadClient.swift | 202 ++++++++----------- Hotline/Library/Extensions.swift | 5 + Hotline/Library/NetSocket/FileProgress.swift | 15 +- .../Library/NetSocket/TransferRateEstimator.swift | 3 + Hotline/Library/Views/FileIconView.swift | 12 +- Hotline/Models/TransferInfo.swift | 24 ++- Hotline/State/HotlineState.swift | 19 +- Hotline/macOS/TransfersView.swift | 224 ++++++++++++--------- 8 files changed, 270 insertions(+), 234 deletions(-) (limited to 'Hotline/State/HotlineState.swift') diff --git a/Hotline/Hotline/Transfers/HotlineFolderDownloadClient.swift b/Hotline/Hotline/Transfers/HotlineFolderDownloadClient.swift index 36193bd..a915043 100644 --- a/Hotline/Hotline/Transfers/HotlineFolderDownloadClient.swift +++ b/Hotline/Hotline/Transfers/HotlineFolderDownloadClient.swift @@ -14,13 +14,14 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { private let serverPort: UInt16 private let referenceNumber: UInt32 - private let transferTotal: Int - private let folderItemCount: Int - private var transferSize: Int = 0 + private let transferTotal: Int // Total byte size of all files in folder. + private let folderItemCount: Int // Total numbner of items in the folder hierarchy. private var socket: NetSocket? private var downloadTask: Task? private var folderProgress: Progress? + + private var estimator: TransferRateEstimator public init( address: String, @@ -34,6 +35,8 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { self.referenceNumber = reference self.transferTotal = Int(size) self.folderItemCount = itemCount + + self.estimator = TransferRateEstimator(total: self.transferTotal) } // MARK: - API @@ -41,8 +44,10 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { public func download( to location: HotlineDownloadLocation, progress progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? = nil, - itemProgress itemProgressHandler: (@Sendable (HotlineFolderItemProgress) -> Void)? = nil + items itemProgressHandler: (@Sendable (HotlineFolderItemProgress) -> Void)? = nil ) async throws -> URL { + progressHandler?(.preparing) + self.downloadTask?.cancel() let task = Task { @@ -59,7 +64,7 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { self.downloadTask = nil return url } catch { - print("HotlineFolderDownloadClient[\(referenceNumber)]: Failed to download folder: \(error)") + print("HotlineFolderDownloadClient[\(self.referenceNumber)]: Failed to download folder: \(error)") self.downloadTask = nil progressHandler?(.error(error)) throw error @@ -68,10 +73,10 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { /// Cancel the current download public func cancel() { - downloadTask?.cancel() - downloadTask = nil + self.downloadTask?.cancel() + self.downloadTask = nil - if let socket = socket { + if let socket = self.socket { Task { await socket.close() } @@ -91,7 +96,10 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { progressHandler?(.connecting) // Connect to transfer server - let socket = try await connectToTransferServer() + let socket = try await NetSocket.connect( + host: self.serverAddress, + port: self.serverPort + 1 + ) self.socket = socket defer { Task { await socket.close() } } @@ -104,12 +112,11 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { destinationURL = url destinationFilename = url.lastPathComponent case .downloads(let filename): - let downloadsURL = fm.urls(for: .downloadsDirectory, in: .userDomainMask)[0] - destinationURL = URL(filePath: downloadsURL.generateUniqueFilePath(filename: filename)) + destinationURL = URL.downloadsDirectory.generateUniqueFileURL(filename: filename) destinationFilename = destinationURL.lastPathComponent } - print("HotlineFolderDownloadClient[\(referenceNumber)]: Downloading folder to \(destinationURL.path)") + print("HotlineFolderDownloadClient[\(self.referenceNumber)]: Downloading folder to \(destinationURL.path)") // Create destination folder try? fm.removeItem(at: destinationURL) @@ -120,10 +127,10 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { progress.fileURL = destinationURL progress.fileOperationKind = .downloading progress.publish() + defer { progress.unpublish() } self.folderProgress = progress // Send initial magic header - print("HotlineFolderDownloadClient[\(self.referenceNumber)]: Sending HTXF magic") try await socket.write(Data(endian: .big) { "HTXF".fourCharCode() self.referenceNumber @@ -137,7 +144,6 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { progressHandler?(.transfer(name: destinationFilename, size: 0, total: self.transferTotal, progress: 0.0, speed: nil, estimate: nil)) var completedItemCount = 0 - var totalBytesTransferred = 0 // Process each item in the folder while completedItemCount < self.folderItemCount { @@ -146,15 +152,12 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { let headerLen = Int(headerLenData.readUInt16(at: 0)!) let headerData = try await socket.read(headerLen) - totalBytesTransferred += 2 + headerLen + self.updateProgress(2 + headerLen) guard let (itemType, pathComponents) = self.parseItemHeaderPath(headerData) else { throw HotlineTransferClientError.failedToTransfer } - let joinedPath = pathComponents.joined(separator: "/") - print("HotlineFolderDownloadClient[\(referenceNumber)]: Item type=\(itemType) path=\(joinedPath)") - if itemType == 1 { // Folder entry - no progress shown for folder creation if !pathComponents.isEmpty { @@ -166,14 +169,14 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { completedItemCount += 1 // Request next item if not done - if completedItemCount < folderItemCount { + if completedItemCount < self.folderItemCount { try await sendAction(socket: socket, action: .nextFile) // nextFile } } else if itemType == 0 { // File entry let parentComponents = pathComponents.dropLast() - let fileName = pathComponents.last ?? "untitled" + let fileName = pathComponents.last ?? "Untitled" // Request file download try await sendAction(socket: socket, action: .sendFile) // sendFile @@ -181,47 +184,38 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { // Read file size let fileSizeData = try await socket.read(4) let fileSize = fileSizeData.readUInt32(at: 0)! - totalBytesTransferred += 4 - print("HotlineFolderDownloadClient[\(referenceNumber)]: File '\(fileName)' size: \(fileSize) bytes") + self.updateProgress(4) // Notify item progress before download starts completedItemCount += 1 itemProgressHandler?(HotlineFolderItemProgress( fileName: fileName, itemNumber: completedItemCount, - totalItems: folderItemCount + totalItems: self.folderItemCount )) // Download the file with overall folder progress tracking - let (fileURL, fileBytesRead) = try await downloadFile( + try await self.downloadFile( socket: socket, fileName: fileName, parentPath: Array(parentComponents), destinationFolder: destinationURL, fileSize: fileSize, - itemNumber: completedItemCount, - totalItems: folderItemCount, - totalBytesTransferredSoFar: totalBytesTransferred, progressHandler: progressHandler ) - totalBytesTransferred += fileBytesRead - self.transferSize = totalBytesTransferred - - print("HotlineFolderDownloadClient[\(referenceNumber)]: Downloaded file to \(fileURL.path)") - // Request next item if not done - if completedItemCount < folderItemCount { + if completedItemCount < self.folderItemCount { try await sendAction(socket: socket, action: .nextFile) // nextFile } } else { // Unknown item type - print("HotlineFolderDownloadClient[\(referenceNumber)]: Unknown item type \(itemType), skipping") + print("HotlineFolderDownloadClient[\(self.referenceNumber)]: Unknown item type \(itemType), skipping") completedItemCount += 1 - if completedItemCount < folderItemCount { + if completedItemCount < self.folderItemCount { try await sendAction(socket: socket, action: .nextFile) // nextFile } } @@ -231,25 +225,12 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { // Ensure folder progress shows 100% complete self.folderProgress?.completedUnitCount = Int64(self.transferTotal) - progressHandler?(.completed(url: destinationURL)) return destinationURL } - // MARK: - Helper Methods - - private func connectToTransferServer() async throws -> NetSocket { - print("HotlineFolderDownloadClient[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)") - - let socket = try await NetSocket.connect( - host: self.serverAddress, - port: self.serverPort + 1 - ) - - print("HotlineFolderDownloadClient[\(referenceNumber)]: Connected!") - return socket - } + // MARK: - private func sendAction(socket: NetSocket, action: HotlineFolderAction) async throws { let actionData = Data(endian: .big) { @@ -284,33 +265,34 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { } return (type, comps) } - + + @discardableResult + private func updateProgress(_ sent: Int) -> NetSocket.FileProgress { + let progress = self.estimator.update(bytes: sent) + self.folderProgress?.completedUnitCount = Int64(progress.sent) + return progress + } + + @discardableResult private func downloadFile( socket: NetSocket, fileName: String, parentPath: [String], destinationFolder: URL, fileSize: UInt32, - itemNumber: Int, - totalItems: Int, - totalBytesTransferredSoFar: Int, progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? - ) async throws -> (url: URL, bytesRead: Int) { + ) async throws -> URL { let fm = FileManager.default - var bytesRead = 0 +// var bytesRead = 0 // Read file header let headerData = try await socket.read(HotlineFileHeader.DataSize) guard let header = HotlineFileHeader(from: headerData) else { throw HotlineTransferClientError.failedToTransfer } - bytesRead += HotlineFileHeader.DataSize + self.updateProgress(HotlineFileHeader.DataSize) - // Update folder progress for file header - let totalBytesNow = totalBytesTransferredSoFar + bytesRead - self.folderProgress?.completedUnitCount = Int64(totalBytesNow) - - print("HotlineFolderDownloadClient[\(referenceNumber)]: File has \(header.forkCount) forks") + print("HotlineFolderDownloadClient[\(self.referenceNumber)]: File has \(header.forkCount) forks") var resourceForkData: Data? var fileHandle: FileHandle? @@ -328,27 +310,18 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { guard let forkHeader = HotlineFileForkHeader(from: forkHeaderData) else { throw HotlineTransferClientError.failedToTransfer } - bytesRead += HotlineFileForkHeader.DataSize - - // Update folder progress for fork header - let totalBytesNow = totalBytesTransferredSoFar + bytesRead - self.folderProgress?.completedUnitCount = Int64(totalBytesNow) + self.updateProgress(HotlineFileForkHeader.DataSize) if forkHeader.isInfoFork { - // Read INFO fork - print("HotlineFolderDownloadClient[\(referenceNumber)]: Reading INFO fork (\(forkHeader.dataSize) bytes)") + // Info fork let infoData = try await socket.read(Int(forkHeader.dataSize)) - bytesRead += infoData.count - - // Update folder progress for INFO fork - let totalBytesNow = totalBytesTransferredSoFar + bytesRead - self.folderProgress?.completedUnitCount = Int64(totalBytesNow) + self.updateProgress(infoData.count) guard let info = HotlineFileInfoFork(from: infoData) else { throw HotlineTransferClientError.failedToTransfer } - // Create parent folders + // Create parent folder let parentFolderURL = destinationFolder.appendingPathComponents(parentPath) if !fm.fileExists(atPath: parentFolderURL.path) { try fm.createDirectory(at: parentFolderURL, withIntermediateDirectories: true) @@ -364,63 +337,54 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { } else if forkHeader.isDataFork { - // Stream DATA fork to disk - print("HotlineFolderDownloadClient[\(referenceNumber)]: Reading DATA fork (\(forkHeader.dataSize) bytes)") - + // Data fork guard let fh = fileHandle else { throw HotlineTransferClientError.failedToTransfer } fileDataForkSize = Int(forkHeader.dataSize) - // Stream data fork using NetSocket's optimized file streaming + // Stream data fork to disk let updates = await socket.receiveFile(to: fh, length: fileDataForkSize) - for try await p in updates { - // Calculate overall folder progress - let totalBytesNow = totalBytesTransferredSoFar + bytesRead + p.sent - let rawProgress = self.transferTotal > 0 ? Double(totalBytesNow) / Double(self.transferTotal) : 0.0 - let overallProgress = min(rawProgress, 1.0) // Clamp to 1.0 to avoid exceeding 100% - - // Update folder-level Finder progress - self.folderProgress?.completedUnitCount = Int64(totalBytesNow) - - // Calculate overall folder time estimate based on current speed - let remainingBytes = max(0, self.transferTotal - totalBytesNow) - let estimate: TimeInterval? = if let speed = p.bytesPerSecond, speed > 0, remainingBytes > 0 { - TimeInterval(remainingBytes) / speed - } else { - nil - } - + for try await fileProgress in updates { + let progress = self.updateProgress(fileProgress.now) + // Report overall folder progress to UI progressHandler?(.transfer( name: fileName, - size: totalBytesNow, - total: self.transferTotal, - progress: overallProgress, - speed: p.bytesPerSecond, - estimate: estimate + size: progress.sent, + total: progress.total ?? 0, + progress: progress.progress, + speed: progress.bytesPerSecond, + estimate: progress.estimatedTimeRemaining )) } - bytesRead += fileDataForkSize } else if forkHeader.isResourceFork { - // Read RESOURCE fork + // Resource fork resourceForkData = try await socket.read(Int(forkHeader.dataSize)) - bytesRead += Int(forkHeader.dataSize) - - // Update folder progress for RESOURCE fork - let totalBytesNow = totalBytesTransferredSoFar + bytesRead - self.folderProgress?.completedUnitCount = Int64(totalBytesNow) + let progress = self.updateProgress(resourceForkData?.count ?? 0) + progressHandler?(.transfer( + name: fileName, + size: progress.sent, + total: progress.total ?? 0, + progress: progress.progress, + speed: progress.bytesPerSecond, + estimate: progress.estimatedTimeRemaining + )) } else { - // Skip unsupported fork + // Unsupported fork try await socket.skip(Int(forkHeader.dataSize)) - bytesRead += Int(forkHeader.dataSize) - - // Update folder progress for skipped fork - let totalBytesNow = totalBytesTransferredSoFar + bytesRead - self.folderProgress?.completedUnitCount = Int64(totalBytesNow) + let progress = self.updateProgress(Int(forkHeader.dataSize)) + progressHandler?(.transfer( + name: fileName, + size: progress.sent, + total: progress.total ?? 0, + progress: progress.progress, + speed: progress.bytesPerSecond, + estimate: progress.estimatedTimeRemaining + )) } } @@ -428,23 +392,19 @@ public class HotlineFolderDownloadClient: @MainActor HotlineTransferClient { try? fileHandle?.close() fileHandle = nil - guard let finalPath = filePath else { + guard let filePath else { throw HotlineTransferClientError.failedToTransfer } // Write resource fork if present if let rsrcData = resourceForkData, !rsrcData.isEmpty { - try writeResourceFork(data: rsrcData, to: finalPath) + try writeResourceFork(data: rsrcData, to: filePath) } - return (finalPath, bytesRead) + return filePath } private func writeResourceFork(data: Data, to url: URL) throws { - var resolvedURL = url - resolvedURL.resolveSymlinksInPath() - - let resourceURL = resolvedURL.urlForResourceFork() - try data.write(to: resourceURL) + try data.write(to: url.resolvingSymlinksInPath().urlForResourceFork()) } } diff --git a/Hotline/Library/Extensions.swift b/Hotline/Library/Extensions.swift index cf9f8ff..5ebc7db 100644 --- a/Hotline/Library/Extensions.swift +++ b/Hotline/Library/Extensions.swift @@ -147,6 +147,11 @@ extension URL { return filePath } + + func generateUniqueFileURL(filename base: String) -> URL { + let filePath = self.generateUniqueFilePath(filename: base) + return URL(filePath: filePath) + } } // MARK: - diff --git a/Hotline/Library/NetSocket/FileProgress.swift b/Hotline/Library/NetSocket/FileProgress.swift index c2306f3..2064c59 100644 --- a/Hotline/Library/NetSocket/FileProgress.swift +++ b/Hotline/Library/NetSocket/FileProgress.swift @@ -12,14 +12,27 @@ public extension NetSocket { public let sent: Int /// Total file size (may be nil if unknown) public let total: Int? + /// Size of most recent packet + public let now: Int + /// Total progress so far (0.0 to 1.0) + public let progress: Double /// Smoothed transfer rate in bytes per second (EMA), if enough samples collected public let bytesPerSecond: Double? /// Estimated time remaining (seconds) based on smoothed rate, if available public let estimatedTimeRemaining: TimeInterval? - public init(sent: Int, total: Int?, bytesPerSecond: Double? = nil, estimatedTimeRemaining: TimeInterval? = nil) { + public init(sent: Int, total: Int?, now: Int = 0, bytesPerSecond: Double? = nil, estimatedTimeRemaining: TimeInterval? = nil) { self.sent = sent self.total = total + self.now = now + + if let t = total { + self.progress = max(0.0, min(1.0, Double(sent) / Double(t))) + } + else { + self.progress = 0.0 + } + self.bytesPerSecond = bytesPerSecond self.estimatedTimeRemaining = estimatedTimeRemaining } diff --git a/Hotline/Library/NetSocket/TransferRateEstimator.swift b/Hotline/Library/NetSocket/TransferRateEstimator.swift index badf87a..61383ff 100644 --- a/Hotline/Library/NetSocket/TransferRateEstimator.swift +++ b/Hotline/Library/NetSocket/TransferRateEstimator.swift @@ -70,6 +70,7 @@ public struct TransferRateEstimator { self.minSamples = minSamples } + @discardableResult public mutating func update(total: Int) -> NetSocket.FileProgress { return self.update(bytes: max(0, total - self.transferred)) } @@ -80,6 +81,7 @@ public struct TransferRateEstimator { /// /// - Parameter bytes: Number of bytes transferred in this sample /// - Returns: Current progress with speed and ETA estimates + @discardableResult public mutating func update(bytes: Int) -> NetSocket.FileProgress { let clock = ContinuousClock() let now = clock.now @@ -127,6 +129,7 @@ public struct TransferRateEstimator { return NetSocket.FileProgress( sent: self.transferred, total: self.total, + now: bytes, bytesPerSecond: haveEstimate ? self.emaBytesPerSecond : nil, estimatedTimeRemaining: eta ) diff --git a/Hotline/Library/Views/FileIconView.swift b/Hotline/Library/Views/FileIconView.swift index d0949fa..836c990 100644 --- a/Hotline/Library/Views/FileIconView.swift +++ b/Hotline/Library/Views/FileIconView.swift @@ -2,7 +2,7 @@ import SwiftUI import UniformTypeIdentifiers struct FolderIconView: View { - private func folderIcon() -> Image { + private var folderIcon: Image { #if os(iOS) return Image(systemName: "folder.fill") #elseif os(macOS) @@ -11,7 +11,7 @@ struct FolderIconView: View { } var body: some View { - folderIcon() + self.folderIcon .resizable() .scaledToFit() } @@ -22,7 +22,7 @@ struct FileIconView: View { let fileType: String? #if os(iOS) - private func fileIcon() -> Image { + private var fileIcon: Image { let fileExtension = (self.filename as NSString).pathExtension if let fileType = UTType(filenameExtension: fileExtension) { if fileType.isSubtype(of: .movie) { @@ -45,7 +45,7 @@ struct FileIconView: View { return Image(systemName: "doc") } #elseif os(macOS) - private func fileIcon() -> Image { + private var fileIcon: Image { let fileExtension = (self.filename as NSString).pathExtension if !fileExtension.isEmpty, @@ -60,14 +60,12 @@ struct FileIconView: View { else { return Image(nsImage: NSWorkspace.shared.icon(for: UTType.data)) } - -// Image(nsImage: NSWorkspace.shared.icon(for: UTType(filenameExtension: (filename as NSString).pathExtension) ?? UTType.content)) } #endif var body: some View { - fileIcon() + self.fileIcon .resizable() .scaledToFit() } diff --git a/Hotline/Models/TransferInfo.swift b/Hotline/Models/TransferInfo.swift index 10535bf..ebe9f64 100644 --- a/Hotline/Models/TransferInfo.swift +++ b/Hotline/Models/TransferInfo.swift @@ -7,23 +7,35 @@ class TransferInfo: Identifiable, Equatable, Hashable { var referenceNumber: UInt32 var title: String var size: UInt - var progress: Double = 0.0 - var speed: Double? = nil - var timeRemaining: TimeInterval? = nil + + // Status var completed: Bool = false var failed: Bool = false var cancelled: Bool = false - var isFolder: Bool = false - var done: Bool { self.completed || self.failed || self.cancelled } + + var progress: Double = 0.0 + var speed: Double? = nil + var timeRemaining: TimeInterval? = nil + + var isUpload: Bool = false + var isDownload: Bool { + get { !self.isUpload } + set { self.isUpload = !newValue } + } + + // Folder transfers + var isFolder: Bool = false + var folderName: String? = nil + var fileName: String? = nil // Server association - tracks which HotlineState this transfer belongs to var serverID: UUID var serverName: String? - // For file based transfers (i.e. not previews) + // For file based transfers var fileURL: URL? = nil var progressCallback: ((TransferInfo) -> Void)? = nil diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index f814818..7335f37 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -895,6 +895,7 @@ class HotlineState: Equatable { serverID: self.id, serverName: self.serverName ?? self.serverTitle ) + transfer.isUpload = false transfer.downloadCallback = callback transfer.progressCallback = progressCallback AppState.shared.addTransfer(transfer) @@ -980,7 +981,7 @@ class HotlineState: Equatable { path: [String], to destination: URL? = nil, progress progressCallback: ((TransferInfo) -> Void)? = nil, - itemProgress itemProgressCallback: ((TransferInfo, String, Int, Int) -> Void)? = nil, +// itemProgress itemProgressCallback: ((TransferInfo, String, Int, Int) -> Void)? = nil, complete callback: ((TransferInfo) -> Void)? = nil ) { guard let client = self.client else { return } @@ -1013,6 +1014,8 @@ class HotlineState: Equatable { serverName: self.serverName ?? self.serverTitle ) transfer.isFolder = true + transfer.folderName = folderName + transfer.isUpload = false transfer.downloadCallback = callback transfer.progressCallback = progressCallback AppState.shared.addTransfer(transfer) @@ -1031,8 +1034,6 @@ class HotlineState: Equatable { guard self != nil else { return } do { - let folderURL: URL - // Download folder with progress tracking let location: HotlineDownloadLocation = if let destination { .url(destination) @@ -1040,7 +1041,7 @@ class HotlineState: Equatable { .downloads(folderName) } - folderURL = try await downloadClient.download(to: location, progress: { progress in + let folderURL = try await downloadClient.download(to: location, progress: { progress in switch progress { case .preparing: break @@ -1057,14 +1058,14 @@ class HotlineState: Equatable { transfer.completed = true transfer.fileURL = url } - }, itemProgress: { itemInfo in - // Update transfer title with current file being downloaded - transfer.title = "\(itemInfo.fileName) (\(itemInfo.itemNumber)/\(itemInfo.totalItems))" - itemProgressCallback?(transfer, itemInfo.fileName, itemInfo.itemNumber, itemInfo.totalItems) + }, items: { item in + transfer.title = item.fileName + transfer.fileName = item.fileName }) // Mark as completed transfer.progress = 1.0 + transfer.fileName = nil transfer.title = folderName // Reset title to folder name // Call completion callback @@ -1174,6 +1175,7 @@ class HotlineState: Equatable { serverName: self.serverName ?? self.serverTitle ) transfer.isFolder = true + transfer.isUpload = true transfer.uploadCallback = callback transfer.progressCallback = progressCallback AppState.shared.addTransfer(transfer) @@ -1294,6 +1296,7 @@ class HotlineState: Equatable { serverID: self.id, serverName: self.serverName ?? self.serverTitle ) + transfer.isUpload = true transfer.uploadCallback = callback AppState.shared.addTransfer(transfer) diff --git a/Hotline/macOS/TransfersView.swift b/Hotline/macOS/TransfersView.swift index bf8c8bd..5f9357d 100644 --- a/Hotline/macOS/TransfersView.swift +++ b/Hotline/macOS/TransfersView.swift @@ -70,58 +70,70 @@ struct TransfersView: View { .listStyle(.inset) .environment(\.defaultMinListRowHeight, 56) .contextMenu(forSelectionType: TransferInfo.self) { items in - if items.allSatisfy(\.completed) { - let fileURLs: [URL] = items.compactMap(\.fileURL) - - Button("Remove Transfer\(items.count > 1 ? "s" : "")", systemImage: "xmark") { - self.appState.cancelTransfers(ids: items.map(\.id)) - self.selectedTransfers = [] - } - - Divider() - - Button("Open", systemImage: "arrow.up.right.square") { - for fileURL in fileURLs { - NSWorkspace.shared.open(fileURL) - } - } - - self.openWithMenu(for: fileURLs) - - Button("Show in Finder", systemImage: "finder") { - NSWorkspace.shared.activateFileViewerSelecting(fileURLs) + self.contextMenuForItems(items) + } primaryAction: { items in + self.performPrimaryAction(for: items) + } + } + + // MARK: - Double Click + + private func performPrimaryAction(for items: Set) { + if let fileURL = items.first?.fileURL { + NSWorkspace.shared.open(fileURL) + } + } + + // MARK: - Context Menu + + @ViewBuilder + private func contextMenuForItems(_ items: Set) -> some View { + if items.allSatisfy(\.completed) { + let fileURLs: [URL] = items.compactMap(\.fileURL) + + Button("Remove Transfer\(items.count > 1 ? "s" : "")", systemImage: "xmark") { + self.appState.cancelTransfers(ids: items.map(\.id)) + self.selectedTransfers = [] + } + + Divider() + + Button("Open", systemImage: "arrow.up.right.square") { + for fileURL in fileURLs { + NSWorkspace.shared.open(fileURL) } + } + + self.openWithMenu(for: fileURLs) + + Button("Show in Finder", systemImage: "finder") { + NSWorkspace.shared.activateFileViewerSelecting(fileURLs) + } + + Divider() + + Button("Move to Trash", systemImage: "trash") { + self.appState.cancelTransfers(ids: items.map(\.id)) + NSWorkspace.shared.recycle(fileURLs) + self.selectedTransfers = [] + } + } else { + Button("Remove Transfer\(items.count > 1 ? "s" : "")", systemImage: "xmark") { + self.appState.cancelTransfers(ids: items.map(\.id)) + self.selectedTransfers = [] + } + + Divider() + + Button("Move to Trash", systemImage: "trash") { + self.appState.cancelTransfers(ids: items.map(\.id)) - Divider() - - Button("Move to Trash", systemImage: "trash") { - self.appState.cancelTransfers(ids: items.map(\.id)) + let fileURLs: [URL] = items.compactMap(\.fileURL) + if !fileURLs.isEmpty { NSWorkspace.shared.recycle(fileURLs) - self.selectedTransfers = [] } - } - else { - Button("Remove Transfer\(items.count > 1 ? "s" : "")", systemImage: "xmark") { - self.appState.cancelTransfers(ids: items.map(\.id)) - self.selectedTransfers = [] - } - - Divider() - Button("Move to Trash", systemImage: "trash") { - self.appState.cancelTransfers(ids: items.map(\.id)) - - let fileURLs: [URL] = items.compactMap(\.fileURL) - if !fileURLs.isEmpty { - NSWorkspace.shared.recycle(fileURLs) - } - - self.selectedTransfers = [] - } - } - } primaryAction: { items in - if let fileURL = items.first?.fileURL { - NSWorkspace.shared.open(fileURL) + self.selectedTransfers = [] } } } @@ -250,6 +262,7 @@ struct TransfersView: View { } } } + } // MARK: - Transfer Row @@ -259,6 +272,56 @@ struct TransferRow: View { @Bindable var transfer: TransferInfo + var body: some View { + HStack(alignment: .center, spacing: 8) { + if self.transfer.isFolder { + self.folderIconView + } + else { + self.fileIconView + } + + VStack(alignment: .leading, spacing: 2) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(self.transfer.title) + .font(.headline) + .lineLimit(1) + .truncationMode(.tail) + + Spacer() + + if !self.transfer.done { + self.statsView + } + } + + // Progress bar and status + if self.transfer.cancelled { + Text("Cancelled") + .font(.subheadline) + .foregroundStyle(.secondary) + } + else if self.transfer.failed { + Text("Failed") + .font(.subheadline) + .foregroundStyle(.secondary) + } + else if self.transfer.completed { + Text("Downloaded") + .font(.subheadline) + .foregroundStyle(.fileComplete) + } + else { + ProgressView(value: self.transfer.progress, total: 1.0) + .progressViewStyle(.linear) + .controlSize(.large) + } + } + } + } + + // MARK: - + private var statsView: some View { HStack(spacing: 8) { // Progress percentage @@ -266,28 +329,24 @@ struct TransferRow: View { // Speed if let speed = self.transfer.speed { - // TODO: Use arrow.up for uploads. - Label(self.formatSpeed(speed), systemImage: "arrow.down") -// Text(self.formatSpeed(speed)) + Label(self.formatSpeed(speed), systemImage: self.transfer.isUpload ? "arrow.up" : "arrow.down") } // Time remaining if let timeRemaining = self.transfer.timeRemaining { Label(self.formatTimeRemaining(timeRemaining), systemImage: "clock") -// Text(self.formatTimeRemaining(timeRemaining)) } // File size Label(self.formatSize(self.transfer.size), systemImage: "document") -// Text(self.formatSize(self.transfer.size)) } .font(.subheadline) .foregroundStyle(.secondary) .monospacedDigit() } - private var fileIconView: some View { - FileIconView(filename: self.transfer.title, fileType: nil) + private var folderIconView: some View { + FolderIconView() .frame(width: 32, height: 32) .overlay(alignment: .bottomTrailing) { if self.transfer.cancelled || self.transfer.failed { @@ -305,50 +364,33 @@ struct TransferRow: View { .scaledToFit() .frame(width: 16, height: 16) } + else { + FileIconView(filename: self.transfer.title, fileType: nil) + .frame(width: 16, height: 16) + } } } - var body: some View { - HStack(alignment: .center, spacing: 8) { - self.fileIconView - - VStack(alignment: .leading, spacing: 2) { - HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(self.transfer.title) - .font(.headline) - .lineLimit(1) - .truncationMode(.tail) - - Spacer() - - if !self.transfer.done { - self.statsView - } - } - - // Progress bar and status - if self.transfer.cancelled { - Text("Cancelled") - .font(.subheadline) - .foregroundStyle(.secondary) - } - else if self.transfer.failed { - Text("Failed") - .font(.subheadline) - .foregroundStyle(.secondary) + private var fileIconView: some View { + FileIconView(filename: self.transfer.title, fileType: nil) + .frame(width: 32, height: 32) + .overlay(alignment: .bottomTrailing) { + if self.transfer.cancelled || self.transfer.failed { + Image(systemName: "exclamationmark.triangle.fill") + .resizable() + .symbolRenderingMode(.multicolor) + .scaledToFit() + .frame(width: 16, height: 16) } else if self.transfer.completed { - Text("Downloaded") - .font(.subheadline) - .foregroundStyle(.fileComplete) - } - else { - ProgressView(value: self.transfer.progress, total: 1.0) - .progressViewStyle(.linear) - .controlSize(.large) + Image(systemName: "checkmark.circle.fill") + .resizable() + .symbolRenderingMode(.palette) + .foregroundStyle(.white, .fileComplete) + .scaledToFit() + .frame(width: 16, height: 16) } } - } } // MARK: - Formatting -- cgit From bcc7775dcace8593870e3afc12de21c871114d54 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Tue, 11 Nov 2025 17:14:13 -0800 Subject: Add confirmation for file/folder delete. Also add New Folder button to files. --- Hotline/Hotline/HotlineClient.swift | 160 ++++++++++++++++-------------------- Hotline/State/HotlineState.swift | 9 ++ Hotline/macOS/Files/FilesView.swift | 124 +++++++++++++++++++++++----- 3 files changed, 182 insertions(+), 111 deletions(-) (limited to 'Hotline/State/HotlineState.swift') diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index cd34d9e..1faaf55 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -152,15 +152,6 @@ public actor HotlineClient { // Keep-alive timer private var keepAliveTask: Task? - // MARK: - Static Handshake - - private static let handshakeData = Data(endian: .big, { - "TRTP".fourCharCode() // 'TRTP' protocol ID - "HOTL".fourCharCode() // 'HOTL' sub-protocol ID - UInt16(0x0001) // Version - UInt16(0x0002) // Sub-version - }) - // Transaction IDs private var nextTransactionID: UInt32 = 1 private func generateTransactionID() -> UInt32 { @@ -199,7 +190,12 @@ public actor HotlineClient { // Perform handshake print("HotlineClient.connect(): Sending handshake...") - try await socket.write(handshakeData) + try await socket.write(Data(endian: .big, { + "TRTP".fourCharCode() // 'TRTP' protocol ID + "HOTL".fourCharCode() // 'HOTL' sub-protocol ID + UInt16(0x0001) // Version + UInt16(0x0002) // Sub-version + })) let handshakeResponse = try await socket.read(8) print("HotlineClient.connect(): Handshake response received") @@ -628,40 +624,82 @@ public actor HotlineClient { return files } - - /// Request to download a file + + /// Get detailed information about a file /// /// - Parameters: /// - name: File name /// - path: Directory path containing the file - /// - preview: Request preview/thumbnail instead of full file - /// - Returns: Transfer info (reference number, size, waiting count) - public func downloadFile( - name: String, - path: [String], - preview: Bool = false - ) async throws -> (referenceNumber: UInt32, size: Int, fileSize: Int?, waitingCount: Int?) { - var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .downloadFile) + /// - Returns: File details or nil if not found + public func getFileInfo(name: String, path: [String]) async throws -> FileDetails? { + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getFileInfo) transaction.setFieldString(type: .fileName, val: name) transaction.setFieldPath(type: .filePath, val: path) - if preview { - transaction.setFieldUInt32(type: .fileTransferOptions, val: 2) - } - - let reply = try await self.sendTransaction(transaction) + let reply = try await sendTransaction(transaction) guard - let transferSize = reply.getField(type: .transferSize)?.getInteger(), - let referenceNumber = reply.getField(type: .referenceNumber)?.getUInt32() + let fileName = reply.getField(type: .fileName)?.getString(), + let fileCreator = reply.getField(type: .fileCreatorString)?.getString(), + let fileType = 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 { - throw HotlineClientError.invalidResponse + return nil } - let fileSize = reply.getField(type: .fileSize)?.getInteger() - let waitingCount = reply.getField(type: .waitingCount)?.getInteger() + // Size field is not included in server reply for folders + let fileSize = reply.getField(type: .fileSize)?.getInteger() ?? 0 + let fileComment = reply.getField(type: .fileComment)?.getString() ?? "" - return (referenceNumber, transferSize, fileSize, waitingCount) + return FileDetails( + name: fileName, + path: path, + size: fileSize, + comment: fileComment, + type: fileType, + creator: fileCreator, + created: fileCreateDate, + modified: fileModifyDate + ) + } + + /// Delete a file or folder + /// + /// - Parameters: + /// - name: File or folder name + /// - path: Directory path containing the item + /// - Returns: True if deletion succeeded + public func deleteFile(name: String, path: [String]) async throws -> Bool { + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .deleteFile) + transaction.setFieldString(type: .fileName, val: name) + transaction.setFieldPath(type: .filePath, val: path) + + do { + try await self.sendTransaction(transaction) + return true + } catch { + return false + } + } + + /// Create a folder + /// + /// - Parameters: + /// - name: New folder name + /// - path: Directory path for the new folder + /// - Returns: True if creation succeeded + public func newFolder(name: String, path: [String]) async throws -> Bool { + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .newFolder) + transaction.setFieldString(type: .fileName, val: name) + transaction.setFieldPath(type: .filePath, val: path) + + do { + try await self.sendTransaction(transaction) + return true + } catch { + return false + } } // MARK: - News @@ -787,66 +825,6 @@ public actor HotlineClient { try await self.socket.send(transaction, endian: .big) } - // MARK: - File Operations - - /// Get detailed information about a file - /// - /// - Parameters: - /// - name: File name - /// - path: Directory path containing the file - /// - Returns: File details or nil if not found - public func getFileInfo(name: String, path: [String]) async throws -> FileDetails? { - var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getFileInfo) - transaction.setFieldString(type: .fileName, val: name) - transaction.setFieldPath(type: .filePath, val: path) - - let reply = try await sendTransaction(transaction) - - guard - let fileName = reply.getField(type: .fileName)?.getString(), - let fileCreator = reply.getField(type: .fileCreatorString)?.getString(), - let fileType = 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 { - return nil - } - - // Size field is not included in server reply for folders - let fileSize = reply.getField(type: .fileSize)?.getInteger() ?? 0 - let fileComment = reply.getField(type: .fileComment)?.getString() ?? "" - - return FileDetails( - name: fileName, - path: path, - size: fileSize, - comment: fileComment, - type: fileType, - creator: fileCreator, - created: fileCreateDate, - modified: fileModifyDate - ) - } - - /// Delete a file or folder - /// - /// - Parameters: - /// - name: File or folder name - /// - path: Directory path containing the item - /// - Returns: True if deletion succeeded - public func deleteFile(name: String, path: [String]) async throws -> Bool { - var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .deleteFile) - transaction.setFieldString(type: .fileName, val: name) - transaction.setFieldPath(type: .filePath, val: path) - - do { - try await self.sendTransaction(transaction) - return true - } catch { - return false - } - } - // MARK: - Administration /// Get list of user accounts (requires admin access) diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index 7335f37..5d6471c 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -835,6 +835,15 @@ class HotlineState: Equatable { return try await client.getFileInfo(name: fileName, path: fullPath) } + + @MainActor + func newFolder(name: String, parentPath: [String]) async throws -> Bool { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + return try await client.newFolder(name: name, path: parentPath) + } @MainActor func deleteFile(_ fileName: String, path: [String]) async throws -> Bool { diff --git a/Hotline/macOS/Files/FilesView.swift b/Hotline/macOS/Files/FilesView.swift index 984b120..3732d9a 100644 --- a/Hotline/macOS/Files/FilesView.swift +++ b/Hotline/macOS/Files/FilesView.swift @@ -2,9 +2,36 @@ import SwiftUI import UniformTypeIdentifiers import AppKit - - - +struct NewFolderSheet: View { + @Environment(\.dismiss) private var dismiss + + let action: ((String) -> Void)? + + @State private var folderName: String = "Untitled" + + var body: some View { + Form { + TextField(text: self.$folderName) { + Text("Folder Name") + } + } + .formStyle(.grouped) + .fixedSize(horizontal: false, vertical: true) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("New Folder") { + self.dismiss() + self.action?(self.folderName) + } + } + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + self.dismiss() + } + } + } + } +} struct FilesView: View { @Environment(HotlineState.self) private var model: HotlineState @@ -16,6 +43,8 @@ struct FilesView: View { @State private var searchText: String = "" @State private var isSearching: Bool = false @State private var dragOver: Bool = false + @State private var deleteConfirmationDisplayed: Bool = false + @State private var newFolderSheetDisplayed: Bool = false var body: some View { NavigationStack { @@ -98,13 +127,9 @@ struct FilesView: View { Divider() Button { - if let s = selectedFile { - Task { - await deleteFile(s) - } - } + self.deleteConfirmationDisplayed = true } label: { - Label("Delete", systemImage: "trash") + Label("Delete...", systemImage: "trash") } .disabled(selectedFile == nil) } @@ -154,38 +179,44 @@ struct FilesView: View { .searchable(text: $searchText, isPresented: $isSearching, placement: .automatic, prompt: "Search") .background(Button("", action: { isSearching = true }).keyboardShortcut("f").hidden()) .toolbar { - ToolbarItemGroup(placement: .automatic) { + ToolbarItem { Button { if let selectedFile = selection, selectedFile.isPreviewable { - previewFile(selectedFile) + self.previewFile(selectedFile) } } label: { Label("Preview", systemImage: "eye") } .help("Preview") .disabled(selection == nil || selection?.isPreviewable != true) - + } + + ToolbarItem { Button { if let selectedFile = selection { - getFileInfo(selectedFile) + self.getFileInfo(selectedFile) } } label: { Label("Get Info", systemImage: "info.circle") } .help("Get Info") .disabled(selection == nil) - + } + + ToolbarItem { Button { - uploadFileSelectorDisplayed = true + self.uploadFileSelectorDisplayed = true } label: { Label("Upload", systemImage: "arrow.up") } .help("Upload") .disabled(model.access?.contains(.canUploadFiles) != true) - + } + + ToolbarItem { Button { if let selectedFile = selection { - downloadFile(selectedFile) + self.downloadFile(selectedFile) } } label: { Label("Download", systemImage: "arrow.down") @@ -193,9 +224,48 @@ struct FilesView: View { .help("Download") .disabled(selection == nil || model.access?.contains(.canDownloadFiles) != true) } + + if #available(macOS 26.0, *) { + ToolbarSpacer() + } + + ToolbarItem { + Button { + self.newFolderSheetDisplayed = true + } label: { + Label("New Folder", systemImage: "folder.badge.plus") + } + .help("New Folder") + } + + ToolbarItem { + Button { + self.deleteConfirmationDisplayed = true + } label: { + Label("Delete", systemImage: "trash") + } + .disabled(self.selection == nil) + .help("Delete") + } + } + } + .alert("Are you sure you want to permanently delete \"\(self.selection?.name ?? "this file")\"?", isPresented: self.$deleteConfirmationDisplayed, actions: { + Button("Delete", role: .destructive) { + if let s = self.selection { + Task { + await self.deleteFile(s) + } + } + } + }, message: { + Text("You cannot undo this action.") + }) + .sheet(isPresented: self.$newFolderSheetDisplayed) { + NewFolderSheet { folderName in + self.newFolder(name: folderName, parent: self.selection) } } - .sheet(item: $fileDetails ) { item in + .sheet(item: $fileDetails) { item in FileDetailsView(fd: item) } .fileImporter(isPresented: $uploadFileSelectorDisplayed, allowedContentTypes: [.data, .folder], allowsMultipleSelection: false, onCompletion: { results in @@ -385,6 +455,20 @@ struct FilesView: View { } } + @MainActor private func newFolder(name: String, parent: FileInfo?) { + Task { + var parentFolder: FileInfo? = nil + if parent?.isFolder == true { + parentFolder = parent + } + + let path: [String] = parentFolder?.path ?? [] + if try await self.model.newFolder(name: name, parentPath: path) == true { + try await self.model.getFileList(path: path) + } + } + } + @MainActor private func getFileInfo(_ file: FileInfo) { Task { if let fileInfo = try? await model.getFileDetails(file.name, path: file.path) { @@ -405,10 +489,10 @@ struct FilesView: View { } @MainActor private func uploadFile(file fileURL: URL, to path: [String]) { - model.uploadFile(url: fileURL, path: path) { info in + self.model.uploadFile(url: fileURL, path: path) { info in Task { // Refresh file listing to display newly uploaded file. - let _ = try? await model.getFileList(path: path) + try? await self.model.getFileList(path: path) } } } -- cgit From 3795039436f741ec23f65e7cd0639023bac1f1ca Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Wed, 12 Nov 2025 21:45:13 -0800 Subject: More work on account management. Add Broadcast UI. --- Hotline.xcodeproj/project.pbxproj | 4 + Hotline/Hotline/HotlineClient.swift | 11 ++ Hotline/MacApp.swift | 17 +-- Hotline/State/HotlineState.swift | 10 ++ Hotline/State/ServerState.swift | 1 + Hotline/macOS/Accounts/AccountDetailsView.swift | 181 +++++++++++++++++------- Hotline/macOS/BroadcastMessageView.swift | 66 +++++++++ Hotline/macOS/HotlinePanelView.swift | 2 +- Hotline/macOS/News/NewsView.swift | 2 +- Hotline/macOS/ServerView.swift | 9 +- 10 files changed, 234 insertions(+), 69 deletions(-) create mode 100644 Hotline/macOS/BroadcastMessageView.swift (limited to 'Hotline/State/HotlineState.swift') diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index 28b30f8..e741f7f 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -89,6 +89,7 @@ DAB4D8842B4CABEF0048A05C /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAB4D8832B4CABEF0048A05C /* Extensions.swift */; }; DAB4D8872B4CB3610048A05C /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = DAB4D8862B4CB3610048A05C /* MarkdownUI */; }; DABAFBEA2EC58C170015E889 /* AccountDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABAFBE92EC58C170015E889 /* AccountDetailsView.swift */; }; + DABAFBEC2EC599700015E889 /* BroadcastMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABAFBEB2EC5996B0015E889 /* BroadcastMessageView.swift */; }; DABE8BF42B55DC0A00884D28 /* transfer-complete.aiff in Resources */ = {isa = PBXBuildFile; fileRef = DABE8BF32B55DC0A00884D28 /* transfer-complete.aiff */; }; DABE8BF62B55DC2E00884D28 /* chat-message.aiff in Resources */ = {isa = PBXBuildFile; fileRef = DABE8BF52B55DC2E00884D28 /* chat-message.aiff */; }; DABE8BF82B55DC6100884D28 /* logged-in.aiff in Resources */ = {isa = PBXBuildFile; fileRef = DABE8BF72B55DC6100884D28 /* logged-in.aiff */; }; @@ -203,6 +204,7 @@ DAB4D8812B4C8FED0048A05C /* FileIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileIconView.swift; sourceTree = ""; }; DAB4D8832B4CABEF0048A05C /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = ""; }; DABAFBE92EC58C170015E889 /* AccountDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDetailsView.swift; sourceTree = ""; }; + DABAFBEB2EC5996B0015E889 /* BroadcastMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BroadcastMessageView.swift; sourceTree = ""; }; DABE8BF32B55DC0A00884D28 /* transfer-complete.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = "transfer-complete.aiff"; sourceTree = ""; }; DABE8BF52B55DC2E00884D28 /* chat-message.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = "chat-message.aiff"; sourceTree = ""; }; DABE8BF72B55DC6100884D28 /* logged-in.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = "logged-in.aiff"; sourceTree = ""; }; @@ -530,6 +532,7 @@ DAE734F72B2E4126000C56F6 /* macOS */ = { isa = PBXGroup; children = ( + DABAFBEB2EC5996B0015E889 /* BroadcastMessageView.swift */, DA55AC762BE589F700034857 /* AboutView.swift */, DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */, DAE136B92B9D1147007D8307 /* HotlinePanelView.swift */, @@ -730,6 +733,7 @@ DA501BE72EBE9542001714F8 /* TrackerBookmarkSheet.swift in Sources */, DAB4D87E2B4C8BCA0048A05C /* FilePreviewTextView.swift in Sources */, DA3429B72EBAB1750010784E /* QuickLookPreviewView.swift in Sources */, + DABAFBEC2EC599700015E889 /* BroadcastMessageView.swift in Sources */, DA4F2C012B1A558E00D8ADDC /* ChatView.swift in Sources */, DA5268B32EB6806E00DCB941 /* HotlineFileDownloadClient.swift in Sources */, DACCE5E32EABE86A008CDD92 /* AppUpdateView.swift in Sources */, diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index ee99ed7..8d0e870 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -489,6 +489,17 @@ public actor HotlineClient { } // MARK: - Chat + + /// Broadcast a message to the server + /// + /// - Parameters: + /// - message: Text to send + /// - encoding: Text encoding (default: UTF-8) + public func sendBroadcast(_ message: String, encoding: String.Encoding = .utf8, announce: Bool = false) async throws { + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .userBroadcast) + transaction.setFieldString(type: .data, val: message, encoding: encoding) + try await socket.send(transaction, endian: .big) + } /// Send a chat message to the server /// diff --git a/Hotline/MacApp.swift b/Hotline/MacApp.swift index 5a84da8..7997a87 100644 --- a/Hotline/MacApp.swift +++ b/Hotline/MacApp.swift @@ -246,9 +246,9 @@ struct Application: App { Divider() Button("Broadcast Message...") { - // TODO: Implement broadcast message when user is allowed. + activeServerState?.broadcastShown = true } - .disabled(true) + .disabled(activeHotline?.access?.contains(.canBroadcast) != true) .keyboardShortcut(.init("B"), modifiers: .command) Divider() @@ -274,15 +274,12 @@ struct Application: App { .disabled(activeHotline?.status != .loggedIn) .keyboardShortcut(.init("4"), modifiers: .command) - if activeHotline?.access?.contains(.canOpenUsers) == true { - Divider() - - Button("Manage Server...") { - activeServerState?.accountsShown = true - } - .disabled(activeHotline?.status != .loggedIn || activeHotline?.access?.contains(.canOpenUsers) != true ) -// .keyboardShortcut(.init("5"), modifiers: .command) + Divider() + + Button("Manage Accounts...") { + activeServerState?.accountsShown = true } + .disabled(activeHotline?.status != .loggedIn || activeHotline?.access?.contains(.canOpenUsers) != true) } } diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index 5d6471c..627b6a0 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -184,6 +184,7 @@ class HotlineState: Equatable { var users: [User] = [] // Chat + var broadcastMessage: String = "" var chat: [ChatMessage] = [] var chatInput: String = "" var unreadPublicChat: Bool = false @@ -613,6 +614,15 @@ class HotlineState: Equatable { } // MARK: - Chat + + @MainActor + func sendBroadcast(_ message: String) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + try await client.sendBroadcast(message) + } @MainActor func sendChat(_ text: String, announce: Bool = false) async throws { diff --git a/Hotline/State/ServerState.swift b/Hotline/State/ServerState.swift index 8f8d3bf..3fe10f9 100644 --- a/Hotline/State/ServerState.swift +++ b/Hotline/State/ServerState.swift @@ -6,6 +6,7 @@ class ServerState: Equatable { var selection: ServerNavigationType var serverName: String? = nil var accountsShown: Bool = false + var broadcastShown: Bool = false // var serverBanner: NSImage? = nil // var bannerBackgroundColor: Color? = nil diff --git a/Hotline/macOS/Accounts/AccountDetailsView.swift b/Hotline/macOS/Accounts/AccountDetailsView.swift index d4eab4e..8514873 100644 --- a/Hotline/macOS/Accounts/AccountDetailsView.swift +++ b/Hotline/macOS/Accounts/AccountDetailsView.swift @@ -2,6 +2,25 @@ import SwiftUI fileprivate let PASSWORD_PLACEHOLDER = "xxxxxxxxxxxxxxxxxx" +enum AccountDetailsError: Error { + case noLogin + case failedToSave + + var alertTitle: String { + switch self { + case .noLogin: "A login is required" + case .failedToSave: "Failed to save account" + } + } + + var alertMessage: String { + switch self { + case .noLogin: "Users with accounts are required to have a login. Please add one and try again." + case .failedToSave: "An error occurred while saving this account. Please try again." + } + } +} + struct AccountDetailsView: View { @Environment(HotlineState.self) private var model: HotlineState @Environment(\.dismiss) private var dismiss @@ -12,9 +31,27 @@ struct AccountDetailsView: View { @State private var password: String = "" @State private var saving: Bool = false + @State private var alertTitle: String = "" + @State private var alertMessage: String = "" + @State private var alertShown: Bool = false var body: some View { - self.accountDetails + self.detailsView + .alert(self.alertTitle, isPresented: self.$alertShown, actions: { + if #available(macOS 26.0, *) { + Button("OK", role: .confirm) { + self.alertShown = false + } + } + else { + Button("OK") { + self.alertShown = false + } + } + + }, message: { + Text(self.alertMessage) + }) .onAppear { // Display a placeholder for accounts that have been saved to the server // because we don't have the account password on hand to display. @@ -43,10 +80,11 @@ struct AccountDetailsView: View { Task { do { try await self.save() - self.dismiss() } - catch { - print("ERROR SAVING ACCOUNT: \(error)") + catch let error as AccountDetailsError { + self.alertTitle = error.alertTitle + self.alertMessage = error.alertMessage + self.alertShown = true } } } label: { @@ -58,41 +96,58 @@ struct AccountDetailsView: View { } private func save() async throws { + guard !self.account.login.isBlank else { + throw AccountDetailsError.noLogin + } + + self.account.name = self.account.name.trimmingCharacters(in: .whitespacesAndNewlines) + self.account.login = self.account.login.trimmingCharacters(in: .whitespacesAndNewlines) + self.saving = true defer { self.saving = false } + // We create a name var here so we don't see the default account name + // flash in the UI while saving. var accountName: String = self.account.name if accountName.isBlank { accountName = DEFAULT_ACCOUNT_NAME } - // Update existing account - if self.account.persisted { - if self.password == PASSWORD_PLACEHOLDER { - try await self.model.setUser(name: accountName, login: self.account.login, newLogin: nil, password: nil, access: self.account.access.rawValue) + do { + // Update existing account + if self.account.persisted { + if self.password == PASSWORD_PLACEHOLDER { + try await self.model.setUser(name: accountName, login: self.account.login, newLogin: nil, password: nil, access: self.account.access.rawValue) + } else { + try await model.setUser(name: accountName, login: self.account.login, newLogin: nil, password: self.password, access: self.account.access.rawValue) + } + } else { - try await model.setUser(name: accountName, login: self.account.login, newLogin: nil, password: self.password, access: self.account.access.rawValue) + // Create new existing account + try await model.createUser(name: accountName, login: self.account.login, password: self.password, access: self.account.access.rawValue) } - - } else { - // Create new existing account - try await model.createUser(name: accountName, login: self.account.login, password: self.password, access: self.account.access.rawValue) - -// self.password = PASSWORD_PLACEHOLDER - self.account.persisted = true + } + catch { + throw AccountDetailsError.failedToSave } + self.account.persisted = true self.account.name = accountName self.saved?(self.account) } - var accountDetails: some View { + private var isEditable: Bool { + self.model.access?.contains(.canModifyUsers) == true + } + + private var detailsView: some View { Form { Section { TextField(text: self.$account.name, prompt: Text(DEFAULT_ACCOUNT_NAME)) { Text("Account") } } + .disabled(!self.isEditable) Section { TextField("Login", text: self.$account.login, prompt: Text("Required")) @@ -104,101 +159,117 @@ struct AccountDetailsView: View { TextField("Password", text: self.$password, prompt: Text("Optional")) } } + .sectionActions { + HStack { + Spacer() + Text("The following permissions define what users of this account can do on this server. Accounts that can disconnect other users are shown in red.") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + Spacer() + } + } + .disabled(!self.isEditable) Section("Files") { Toggle("Download Files", isOn: self.$account.access.bind(.canDownloadFiles)) - .disabled(self.model.access?.contains(.canDownloadFiles) == false) +// .disabled(self.model.access?.contains(.canDownloadFiles) == false) Toggle("Download Folders", isOn: self.$account.access.bind(.canDownloadFolders)) - .disabled(model.access?.contains(.canDownloadFolders) == false) +// .disabled(model.access?.contains(.canDownloadFolders) == false) Toggle("Upload Files", isOn: self.$account.access.bind(.canUploadFiles)) - .disabled(model.access?.contains(.canUploadFiles) == false) +// .disabled(model.access?.contains(.canUploadFiles) == false) Toggle("Upload Folders", isOn: self.$account.access.bind(.canUploadFolders)) - .disabled(model.access?.contains(.canUploadFolders) == false) +// .disabled(model.access?.contains(.canUploadFolders) == false) Toggle("Upload Anywhere", isOn: self.$account.access.bind(.canUploadAnywhere)) - .disabled(model.access?.contains(.canUploadAnywhere) == false) +// .disabled(model.access?.contains(.canUploadAnywhere) == false) Toggle("Delete Files", isOn: self.$account.access.bind(.canDeleteFiles)) - .disabled(model.access?.contains(.canDeleteFiles) == false) +// .disabled(model.access?.contains(.canDeleteFiles) == false) Toggle("Rename Files", isOn: self.$account.access.bind(.canRenameFiles)) - .disabled(model.access?.contains(.canRenameFiles) == false) +// .disabled(model.access?.contains(.canRenameFiles) == false) Toggle("Move Files", isOn: self.$account.access.bind(.canMoveFiles)) - .disabled(model.access?.contains(.canMoveFiles) == false) +// .disabled(model.access?.contains(.canMoveFiles) == false) Toggle("Comment Files", isOn: self.$account.access.bind(.canSetFileComment)) - .disabled(model.access?.contains(.canSetFileComment) == false) +// .disabled(model.access?.contains(.canSetFileComment) == false) Toggle("Create Folders", isOn: self.$account.access.bind(.canCreateFolders)) - .disabled(model.access?.contains(.canCreateFolders) == false) +// .disabled(model.access?.contains(.canCreateFolders) == false) Toggle("Delete Folders", isOn: self.$account.access.bind(.canDeleteFolders)) - .disabled(model.access?.contains(.canDeleteFolders) == false) +// .disabled(model.access?.contains(.canDeleteFolders) == false) Toggle("Rename Folders", isOn: self.$account.access.bind(.canRenameFolders)) - .disabled(model.access?.contains(.canRenameFolders) == false) +// .disabled(model.access?.contains(.canRenameFolders) == false) Toggle("Move Folders", isOn: self.$account.access.bind(.canMoveFolders)) - .disabled(model.access?.contains(.canMoveFolders) == false) +// .disabled(model.access?.contains(.canMoveFolders) == false) Toggle("Comment Folders", isOn: self.$account.access.bind(.canSetFolderComment)) - .disabled(model.access?.contains(.canSetFolderComment) == false) +// .disabled(model.access?.contains(.canSetFolderComment) == false) Toggle("View Drop Boxes", isOn: self.$account.access.bind(.canViewDropBoxes)) - .disabled(model.access?.contains(.canViewDropBoxes) == false) +// .disabled(model.access?.contains(.canViewDropBoxes) == false) Toggle("Make Aliases", isOn: self.$account.access.bind(.canMakeAliases)) - .disabled(model.access?.contains(.canMakeAliases) == false) +// .disabled(model.access?.contains(.canMakeAliases) == false) } + .disabled(!self.isEditable) Section("User Maintenance") { Toggle("Create Accounts", isOn: self.$account.access.bind(.canCreateUsers)) - .disabled(model.access?.contains(.canCreateUsers) == false) +// .disabled(model.access?.contains(.canCreateUsers) == false) Toggle("Delete Accounts", isOn: self.$account.access.bind(.canDeleteUsers)) - .disabled(model.access?.contains(.canDeleteUsers) == false) +// .disabled(model.access?.contains(.canDeleteUsers) == false) Toggle("Read Accounts", isOn: self.$account.access.bind(.canOpenUsers)) - .disabled(model.access?.contains(.canOpenUsers) == false) +// .disabled(model.access?.contains(.canOpenUsers) == false) Toggle("Modify Accounts", isOn: self.$account.access.bind(.canModifyUsers)) - .disabled(model.access?.contains(.canModifyUsers) == false) +// .disabled(model.access?.contains(.canModifyUsers) == false) Toggle("Get User Info", isOn: self.$account.access.bind(.canGetClientInfo)) - .disabled(model.access?.contains(.canGetClientInfo) == false) +// .disabled(model.access?.contains(.canGetClientInfo) == false) Toggle("Disconnect Users", isOn: self.$account.access.bind(.canDisconnectUsers)) - .disabled(model.access?.contains(.canDisconnectUsers) == false) +// .disabled(model.access?.contains(.canDisconnectUsers) == false) Toggle("Cannot be Disconnected", isOn: self.$account.access.bind(.cantBeDisconnected)) - .disabled(model.access?.contains(.cantBeDisconnected) == false) +// .disabled(model.access?.contains(.cantBeDisconnected) == false) } + .disabled(!self.isEditable) Section("Messaging") { Toggle("Send Messages", isOn: self.$account.access.bind(.canSendMessages)) - .disabled(model.access?.contains(.canSendMessages) == false) +// .disabled(model.access?.contains(.canSendMessages) == false) Toggle("Broadcast", isOn: self.$account.access.bind(.canBroadcast)) - .disabled(model.access?.contains(.canBroadcast) == false) +// .disabled(model.access?.contains(.canBroadcast) == false) } + .disabled(!self.isEditable) Section("News") { Toggle("Read Articles", isOn: self.$account.access.bind(.canReadMessageBoard)) - .disabled(model.access?.contains(.canReadMessageBoard) == false) +// .disabled(model.access?.contains(.canReadMessageBoard) == false) Toggle("Post Articles", isOn: self.$account.access.bind(.canPostMessageBoard)) - .disabled(model.access?.contains(.canPostMessageBoard) == false) +// .disabled(model.access?.contains(.canPostMessageBoard) == false) Toggle("Delete Articles", isOn: self.$account.access.bind(.canDeleteNewsArticles)) - .disabled(model.access?.contains(.canDeleteNewsArticles) == false) +// .disabled(model.access?.contains(.canDeleteNewsArticles) == false) Toggle("Create Categories", isOn: self.$account.access.bind(.canCreateNewsCategories)) - .disabled(model.access?.contains(.canCreateNewsCategories) == false) +// .disabled(model.access?.contains(.canCreateNewsCategories) == false) Toggle("Delete Categories", isOn: self.$account.access.bind(.canDeleteNewsCategories)) - .disabled(model.access?.contains(.canDeleteNewsCategories) == false) +// .disabled(model.access?.contains(.canDeleteNewsCategories) == false) Toggle("Create News Bundles", isOn: self.$account.access.bind(.canCreateNewsFolders)) - .disabled(model.access?.contains(.canCreateNewsFolders) == false) +// .disabled(model.access?.contains(.canCreateNewsFolders) == false) Toggle("Delete News Bundles", isOn: self.$account.access.bind(.canDeleteNewsFolders)) - .disabled(model.access?.contains(.canDeleteNewsFolders) == false) +// .disabled(model.access?.contains(.canDeleteNewsFolders) == false) } + .disabled(!self.isEditable) Section("Chat") { Toggle("Initiate Private Chat", isOn: self.$account.access.bind(.canCreateChat)) - .disabled(model.access?.contains(.canCreateChat) == false) +// .disabled(model.access?.contains(.canCreateChat) == false) Toggle("Read Chat", isOn: self.$account.access.bind(.canReadChat)) - .disabled(model.access?.contains(.canReadChat) == false) +// .disabled(model.access?.contains(.canReadChat) == false) Toggle("Send Chat", isOn: self.$account.access.bind(.canSendChat)) - .disabled(model.access?.contains(.canSendChat) == false) +// .disabled(model.access?.contains(.canSendChat) == false) } + .disabled(!self.isEditable) Section("Miscellaneous") { Toggle("Use Any Name", isOn: self.$account.access.bind(.canUseAnyName)) - .disabled(model.access?.contains(.canUseAnyName) == false) +// .disabled(model.access?.contains(.canUseAnyName) == false) Toggle("Don't Show Agreement", isOn: self.$account.access.bind(.canSkipAgreement)) - .disabled(model.access?.contains(.canSkipAgreement) == false) +// .disabled(model.access?.contains(.canSkipAgreement) == false) } + .disabled(!self.isEditable) } - .disabled(self.model.access?.contains(.canModifyUsers) == false) .formStyle(.grouped) } } diff --git a/Hotline/macOS/BroadcastMessageView.swift b/Hotline/macOS/BroadcastMessageView.swift new file mode 100644 index 0000000..a5f6fd2 --- /dev/null +++ b/Hotline/macOS/BroadcastMessageView.swift @@ -0,0 +1,66 @@ +import SwiftUI + +fileprivate let CHARACTER_LIMIT: Int = 255 + +struct BroadcastMessageView: View { + @Environment(HotlineState.self) private var model: HotlineState + @Environment(\.dismiss) private var dismiss + + @State private var sending: Bool = false + + private var message: String { + self.model.broadcastMessage.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var body: some View { + @Bindable var model = self.model + + VStack { + TextField("Write a message...", text: $model.broadcastMessage, axis: .vertical) + .textFieldStyle(.plain) + .lineLimit(5, reservesSpace: true) + } + .padding(.leading, 32) + .padding(.top, 2) + .overlay(alignment: .topLeading) { + Image("Server Message") + } + .padding(16) + .frame(width: 400) + .toolbar { + if self.sending { + ToolbarItem { + ProgressView() + .controlSize(.small) + } + } + + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + self.dismiss() + } + } + + ToolbarItem(placement: .confirmationAction) { + Button("Broadcast") { + let message = self.message + model.broadcastMessage = "" + + guard !message.isBlank else { + return + } + + Task { + self.sending = true + defer { self.sending = false } + + try await model.sendBroadcast(message) + + self.dismiss() + } + } + .disabled(self.message.isEmpty) + } + } + } +} diff --git a/Hotline/macOS/HotlinePanelView.swift b/Hotline/macOS/HotlinePanelView.swift index 6443366..aa97238 100644 --- a/Hotline/macOS/HotlinePanelView.swift +++ b/Hotline/macOS/HotlinePanelView.swift @@ -125,7 +125,7 @@ struct HotlinePanelView: View { .buttonStyle(.plain) .frame(width: 20, height: 20) .disabled(self.activeServerState == nil) - .help("Manage Server") + .help("Manage Accounts") } Button { diff --git a/Hotline/macOS/News/NewsView.swift b/Hotline/macOS/News/NewsView.swift index 976a985..a07a441 100644 --- a/Hotline/macOS/News/NewsView.swift +++ b/Hotline/macOS/News/NewsView.swift @@ -138,7 +138,7 @@ struct NewsView: View { ContentUnavailableView { Label("No News", systemImage: "newspaper") } description: { - Text("This server has not created any newsgroups yet") + Text("This server has no newsgroups") } } diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index 2fddee6..6184749 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -154,6 +154,11 @@ struct ServerView: View { .onChange(of: Prefs.shared.automaticMessage) { Task { try? await self.model.sendUserPreferences() } } + .sheet(isPresented: self.$state.broadcastShown) { + BroadcastMessageView() + .environment(self.model) + .presentationSizing(.fitted) + } .sheet(isPresented: self.$state.accountsShown) { AccountManagerView() .environment(self.model) @@ -334,9 +339,9 @@ struct ServerView: View { Button { self.state.accountsShown = true } label: { - Label("Manage Server", systemImage: "gear") + Label("Manage Accounts", systemImage: "gear") } - .help("Manage Server") + .help("Manage Accounts") } } } -- cgit From 467b53f143a0c3d7328fe85e9d1215eceb9b150a Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Thu, 13 Nov 2025 11:13:01 -0800 Subject: Start surfacing server errors properly to communicate permissions better. --- Hotline/Hotline/HotlineClient.swift | 10 +++------- Hotline/State/HotlineState.swift | 32 +++++++++++++++++++++++--------- Hotline/macOS/Files/FilesView.swift | 16 ++++++++++------ Hotline/macOS/Files/FolderItemView.swift | 4 ++-- Hotline/macOS/ServerView.swift | 7 ++++++- 5 files changed, 44 insertions(+), 25 deletions(-) (limited to 'Hotline/State/HotlineState.swift') diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index 8d0e870..7305a2f 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -429,6 +429,7 @@ public actor HotlineClient { } catch is TaskTimeoutError { throw HotlineClientError.timeout } catch let error as HotlineClientError { + print("Hotline Client Error: \(error)") throw error } catch { throw error @@ -651,17 +652,12 @@ public actor HotlineClient { /// - name: File or folder name /// - path: Directory path containing the item /// - Returns: True if deletion succeeded - public func deleteFile(name: String, path: [String]) async throws -> Bool { + public func deleteFile(name: String, path: [String]) async throws { var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .deleteFile) transaction.setFieldString(type: .fileName, val: name) transaction.setFieldPath(type: .filePath, val: path) - do { - try await self.sendTransaction(transaction) - return true - } catch { - return false - } + try await self.sendTransaction(transaction) } /// Create a folder diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index 627b6a0..1f8e2e5 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -799,7 +799,7 @@ class HotlineState: Equatable { self.users = hotlineUsers.map { User(hotlineUser: $0) } } - // MARK: - Files (Basic) + // MARK: - Files @MainActor @discardableResult @@ -856,7 +856,7 @@ class HotlineState: Equatable { } @MainActor - func deleteFile(_ fileName: String, path: [String]) async throws -> Bool { + func deleteFile(_ fileName: String, path: [String]) async throws { guard let client = self.client else { throw HotlineClientError.notConnected } @@ -865,14 +865,16 @@ class HotlineState: Equatable { if path.count > 1 { fullPath = Array(path[0.. 1 { parentPath = Array(file.path[0.. Date: Thu, 13 Nov 2025 11:33:43 -0800 Subject: Don't show accounts in sidebar to reduce clutter. Admins can use menu bar or toolbar. More error handling. --- Hotline/Hotline/HotlineClient.swift | 10 ++-------- Hotline/State/HotlineState.swift | 33 ++++++++++++++++++++++++++++----- Hotline/macOS/Files/FilesView.swift | 2 +- Hotline/macOS/ServerView.swift | 29 ++++++++++++++++------------- 4 files changed, 47 insertions(+), 27 deletions(-) (limited to 'Hotline/State/HotlineState.swift') diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index 7305a2f..6dbb460 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -666,17 +666,11 @@ public actor HotlineClient { /// - name: New folder name /// - path: Directory path for the new folder /// - Returns: True if creation succeeded - public func newFolder(name: String, path: [String]) async throws -> Bool { + public func newFolder(name: String, path: [String]) async throws { var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .newFolder) transaction.setFieldString(type: .fileName, val: name) transaction.setFieldPath(type: .filePath, val: path) - - do { - try await self.sendTransaction(transaction) - return true - } catch { - return false - } + try await self.sendTransaction(transaction) } // MARK: - News diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index 1f8e2e5..c9810ad 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -852,11 +852,22 @@ class HotlineState: Equatable { throw HotlineClientError.notConnected } - return try await client.newFolder(name: name, path: parentPath) + do { + try await client.newFolder(name: name, path: parentPath) + self.invalidateFileListCache(for: parentPath, includingAncestors: true) + return true + } + catch let error as HotlineClientError { + self.errorMessage = error.userMessage + self.errorDisplayed = true + } + + return false } + @discardableResult @MainActor - func deleteFile(_ fileName: String, path: [String]) async throws { + func deleteFile(_ fileName: String, path: [String]) async throws -> Bool { guard let client = self.client else { throw HotlineClientError.notConnected } @@ -869,12 +880,14 @@ class HotlineState: Equatable { do { try await client.deleteFile(name: fileName, path: fullPath) self.invalidateFileListCache(for: fullPath, includingAncestors: true) + return true } catch let error as HotlineClientError { self.errorMessage = error.userMessage self.errorDisplayed = true - return } + + return false } /// Download a file from the server. @@ -896,9 +909,19 @@ class HotlineState: Equatable { Task { @MainActor [weak self] in guard let self else { return } - + // Request download from server - guard let result = try? await client.downloadFile(name: fileName, path: fullPath), + let result: (referenceNumber: UInt32, transferSize: Int, fileSize: Int, waitingCount: Int)? + do { + result = try await client.downloadFile(name: fileName, path: fullPath) + } + catch let error as HotlineClientError { + self.errorMessage = error.userMessage + self.errorDisplayed = true + return + } + + guard let result, let server = self.server, let address = server.address as String?, let port = server.port as Int? diff --git a/Hotline/macOS/Files/FilesView.swift b/Hotline/macOS/Files/FilesView.swift index ab93cbc..325af8c 100644 --- a/Hotline/macOS/Files/FilesView.swift +++ b/Hotline/macOS/Files/FilesView.swift @@ -432,7 +432,7 @@ struct FilesView: View { } let path: [String] = parentFolder?.path ?? [] - if try await self.model.newFolder(name: name, parentPath: path) == true { + if try await self.model.newFolder(name: name, parentPath: path) { try await self.model.getFileList(path: path) } } diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index 1f85bd7..b8297fd 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -338,18 +338,19 @@ struct ServerView: View { self.navigationList .frame(maxWidth: .infinity) .navigationSplitViewColumnWidth(min: 200, ideal: 250, max: 500) - .toolbar { - if self.model.access?.contains(.canOpenUsers) == true { - ToolbarItem { - Button { - self.state.accountsShown = true - } label: { - Label("Manage Accounts", systemImage: "gear") - } - .help("Manage Accounts") - } - } - } + .toolbar(removing: .sidebarToggle) +// .toolbar { +// if self.model.access?.contains(.canOpenUsers) == true { +// ToolbarItem(placement: .primaryAction) { +// Button { +// self.state.accountsShown = true +// } label: { +// Label("Manage Accounts", systemImage: "gear") +// } +// .help("Manage Accounts") +// } +// } +// } } detail: { switch state.selection { case .chat: @@ -388,7 +389,9 @@ struct ServerView: View { } } } - .toolbar(removing: .sidebarToggle) + .background { + Color.red + } } // MARK: - -- cgit From 8d4cdce6ec00db6b329a3b9ae71cfc37cd642e9b Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Thu, 13 Nov 2025 20:15:46 -0800 Subject: You can now save file details (rename files/folders and comments) --- Hotline/Hotline/HotlineClient.swift | 23 ++++++++ Hotline/State/HotlineState.swift | 30 ++++++---- Hotline/macOS/Files/FileDetailsSheet.swift | 90 ++++++++++++++++++------------ Hotline/macOS/Files/FilesView.swift | 2 +- Hotline/macOS/ServerView.swift | 16 +++--- 5 files changed, 107 insertions(+), 54 deletions(-) (limited to 'Hotline/State/HotlineState.swift') diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index 6dbb460..98cf222 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -645,6 +645,29 @@ public actor HotlineClient { modified: fileModifyDate ) } + + /// Set a file's information (name/comment) + /// + /// - Parameters: + /// - name: File name + /// - path: Directory path containing the file + /// - newName: Name to set the file to + /// - comment: Comment to set on the file + public func setFileInfo(name: String, path: [String], newName: String? = nil, comment: String? = nil, encoding: String.Encoding = .utf8) async throws { + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .setFileInfo) + transaction.setFieldString(type: .fileName, val: name) + transaction.setFieldPath(type: .filePath, val: path) + + if let newName { + transaction.setFieldString(type: .fileNewName, val: newName) + } + + if let comment { + transaction.setFieldString(type: .fileComment, val: comment) + } + + try await sendTransaction(transaction) + } /// Delete a file or folder /// diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index c9810ad..c632f91 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -789,7 +789,6 @@ class HotlineState: Equatable { // MARK: - Users - @MainActor func getUserList() async throws { guard let client = self.client else { throw HotlineClientError.notConnected @@ -801,7 +800,6 @@ class HotlineState: Equatable { // MARK: - Files - @MainActor @discardableResult func getFileList(path: [String] = [], suppressErrors: Bool = false, preferCache: Bool = false) async throws -> [FileInfo] { guard let client = self.client else { @@ -832,7 +830,6 @@ class HotlineState: Equatable { return newFiles } - @MainActor func getFileDetails(_ fileName: String, path: [String]) async throws -> FileDetails? { guard let client = self.client else { throw HotlineClientError.notConnected @@ -846,7 +843,26 @@ class HotlineState: Equatable { return try await client.getFileInfo(name: fileName, path: fullPath) } - @MainActor + @discardableResult + func setFileInfo(fileName: String, path filePath: [String], fileNewName: String?, comment: String?) async throws -> Bool { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + do { + try await client.setFileInfo(name: fileName, path: filePath, newName: fileNewName, comment: comment) + self.invalidateFileListCache(for: filePath, includingAncestors: true) + return true + } + catch let error as HotlineClientError { + self.errorMessage = error.userMessage + self.errorDisplayed = true + } + + return false + } + + @discardableResult func newFolder(name: String, parentPath: [String]) async throws -> Bool { guard let client = self.client else { throw HotlineClientError.notConnected @@ -1404,12 +1420,6 @@ class HotlineState: Equatable { } } - func setFileInfo(fileName: String, path filePath: [String], fileNewName: String?, comment: String?, encoding: String.Encoding = .utf8) { - // TODO: Implement setFileInfo in HotlineClient - // This method updates file metadata (name and/or comment) - print("setFileInfo not yet implemented in HotlineState/HotlineClient") - } - @MainActor func previewFile(_ fileName: String, path: [String], complete callback: ((PreviewFileInfo?) -> Void)? = nil) { guard let client = self.client else { diff --git a/Hotline/macOS/Files/FileDetailsSheet.swift b/Hotline/macOS/Files/FileDetailsSheet.swift index 2118518..e86bf28 100644 --- a/Hotline/macOS/Files/FileDetailsSheet.swift +++ b/Hotline/macOS/Files/FileDetailsSheet.swift @@ -3,34 +3,35 @@ import SwiftUI struct FileDetailsSheet: View { @Environment(HotlineState.self) private var model: HotlineState - @Environment(\.presentationMode) var presentationMode + @Environment(\.dismiss) private var dismiss - var fd: FileDetails + var details: FileDetails + @State private var saving: Bool = false @State private var comment: String = "" @State private var filename: String = "" var body: some View { VStack(alignment: .leading, spacing: 16) { HStack(alignment: .center, spacing: 16){ - if self.fd.type == "Folder" { + if self.details.type == "Folder" { FolderIconView() .frame(width: 32, height: 32) } else { - FileIconView(filename: fd.name, fileType: nil) + FileIconView(filename: self.details.name, fileType: nil) .frame(width: 32, height: 32) } - TextField("", text: $filename) - .disabled(!self.canRename()) + TextField("File Name", text: $filename) + .disabled(!self.canRename) } let rows: [(String, String)] = [ - ("Type", fd.type), - ("Creator", fd.creator), - ("Size", self.formattedSize(byteCount: fd.size)), - ("Created", Self.dateFormatter.string(from: fd.created)), - ("Modified", Self.dateFormatter.string(from: fd.modified)) + ("Type", self.details.type), + ("Creator", self.details.creator), + ("Size", self.formattedSize(byteCount: self.details.size)), + ("Created", Self.dateFormatter.string(from: self.details.created)), + ("Modified", Self.dateFormatter.string(from: self.details.modified)) ] Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { @@ -54,46 +55,65 @@ struct FileDetailsSheet: View { .font(.body) .lineLimit(10, reservesSpace: true) .padding(.leading, 32 + 16) - .disabled(!self.canSetComment()) + .disabled(!self.canSetComment) } .padding(.vertical, 24) .padding(.horizontal, 24) .frame(width: 400) .toolbar { + if self.saving { + ToolbarItem { + ProgressView() + .controlSize(.small) + } + } + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { - presentationMode.wrappedValue.dismiss() + self.dismiss() } } ToolbarItem(placement: .primaryAction) { Button{ var editedFilename: String? - if filename != fd.name { - editedFilename = filename + if self.filename != self.details.name { + editedFilename = self.filename } var editedComment: String? - if comment != fd.comment { - editedComment = comment + if self.comment != self.details.comment { + editedComment = self.comment } - model.setFileInfo(fileName: fd.name, path: fd.path, fileNewName: editedFilename, comment: editedComment) - presentationMode.wrappedValue.dismiss() - - // TODO: Update the file list if the filename was changed + Task { + self.saving = true + defer { self.saving = false } + + if editedComment != nil || editedFilename != nil { + if try await self.model.setFileInfo(fileName: self.details.name, path: self.details.path, fileNewName: editedFilename, comment: editedComment) { + try await self.model.getFileList(path: self.details.path) + } + } + + // We dismiss even if there is an error for now + // This is not ideal as we may lose a user's written comment + // or new file name, but SwiftUI doesn't show the current + // alert above this sheet so we'll need a different way of + // handling errors to make this work. Until then... + self.dismiss() + } } label: { Text("Save") - }.disabled(!isEdited()) + } } } .onAppear { - self.filename = fd.name - self.comment = fd.comment + self.filename = self.details.name + self.comment = self.details.comment } } - static var dateFormatter: DateFormatter = { var dateFormatter = DateFormatter() dateFormatter.dateStyle = .long @@ -124,24 +144,24 @@ struct FileDetailsSheet: View { } private func isEdited() -> Bool { - return self.filename != fd.name || self.comment != fd.comment + return self.filename != self.details.name || self.comment != self.details.comment } - private func canRename() -> Bool { - if self.fd.type == "fldr" { - return model.access?.contains(.canRenameFolders) == true + private var canRename: Bool { + if self.details.type == "fldr" || self.details.type == "Folder" { + return self.model.access?.contains(.canRenameFolders) == true } - return model.access?.contains(.canRenameFiles) == true + return self.model.access?.contains(.canRenameFiles) == true } - private func canSetComment() -> Bool { - if self.fd.type == "fldr" { - return model.access?.contains(.canSetFolderComment) == true + private var canSetComment: Bool { + if self.details.type == "fldr" || self.details.type == "Folder" { + return self.model.access?.contains(.canSetFolderComment) == true } - return model.access?.contains(.canSetFileComment) == true + return self.model.access?.contains(.canSetFileComment) == true } } //#Preview { -// FileDetailsView(fd: FileDetails(name: "AppleWorks 6.sit", path: [""], size: 24601664, comment: "test comment", type: "SITD", creator: "SIT!", created: Date.now, modified: Date.now )) +// FileDetailsView(details: FileDetails(name: "AppleWorks 6.sit", path: [""], size: 24601664, comment: "test comment", type: "SITD", creator: "SIT!", created: Date.now, modified: Date.now )) //} diff --git a/Hotline/macOS/Files/FilesView.swift b/Hotline/macOS/Files/FilesView.swift index 325af8c..1ae99d9 100644 --- a/Hotline/macOS/Files/FilesView.swift +++ b/Hotline/macOS/Files/FilesView.swift @@ -235,7 +235,7 @@ struct FilesView: View { Text("You cannot undo this action.") }) .sheet(item: self.$fileDetails) { item in - FileDetailsSheet(fd: item) + FileDetailsSheet(details: item) } .fileImporter(isPresented: $uploadFileSelectorDisplayed, allowedContentTypes: [.data, .folder], allowsMultipleSelection: false, onCompletion: { results in switch results { diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index b8297fd..3794018 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -196,14 +196,6 @@ struct ServerView: View { .onChange(of: self.model.serverTitle) { self.state.serverName = self.model.serverTitle } - .alert("Something Went Wrong", isPresented: self.$model.errorDisplayed) { - Button("OK") {} - } message: { - if let message = self.model.errorMessage, - !message.isBlank { - Text(message) - } - } .onAppear { var address = self.server.address if self.server.port != HotlinePorts.DefaultServerPort { @@ -220,6 +212,14 @@ struct ServerView: View { self.connectionDisplayed = true } + .alert("Something Went Wrong", isPresented: self.$model.errorDisplayed) { + Button("OK") {} + } message: { + if let message = self.model.errorMessage, + !message.isBlank { + Text(message) + } + } .focusedSceneValue(\.activeHotlineModel, model) .focusedSceneValue(\.activeServerState, state) } -- cgit From 7ca2ece66a5d40964f5d640255d1b137433511a9 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Thu, 13 Nov 2025 20:53:39 -0800 Subject: UI to view user client info. --- Hotline/Hotline/HotlineClient.swift | 18 +++ Hotline/State/HotlineState.swift | 16 +++ Hotline/macOS/MessageView.swift | 224 +++++++++++++++++++++++------------- 3 files changed, 177 insertions(+), 81 deletions(-) (limited to 'Hotline/State/HotlineState.swift') diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index 98cf222..1394057 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -547,6 +547,24 @@ public actor HotlineClient { try await socket.send(transaction, endian: .big) } + + /// Get information text about a user + /// + /// - Parameters: + /// - userID: Target user ID + public func getClientInfoText(for userID: UInt16) async throws -> (username: String, info: String)? { + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getClientInfoText) + transaction.setFieldUInt16(type: .userID, val: userID) + + let reply = try await self.sendTransaction(transaction) + + if let username = reply.getField(type: .userName)?.getString(), + let info = reply.getField(type: .data)?.getString() { + return (username: username, info: info) + } + + return nil + } /// Update this client's user info (name, icon, options) /// diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index c632f91..318a468 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -797,6 +797,22 @@ class HotlineState: Equatable { let hotlineUsers = try await client.getUserList() self.users = hotlineUsers.map { User(hotlineUser: $0) } } + + func getClientInfoText(id userID: UInt16) async throws -> (username: String, info: String)? { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + do { + return try await client.getClientInfoText(for: userID) + } + catch let error as HotlineClientError { + self.errorMessage = error.userMessage + self.errorDisplayed = true + } + + return nil + } // MARK: - Files diff --git a/Hotline/macOS/MessageView.swift b/Hotline/macOS/MessageView.swift index b0c8baa..861a14a 100644 --- a/Hotline/macOS/MessageView.swift +++ b/Hotline/macOS/MessageView.swift @@ -1,5 +1,37 @@ import SwiftUI +struct UserInfo: Identifiable { + let username: String + let data: String + + var id: String { + self.username + } +} + +struct UserInfoView: View { + @Environment(\.dismiss) private var dismiss + + let info: UserInfo + + var body: some View { + ScrollView(.vertical) { + Text(self.info.data) + .fontDesign(.monospaced) + .textSelection(.enabled) + .padding() + } + .frame(width: 300, height: 400) + .toolbar { + if #available(macOS 26.0, *) { + Button(role: .close) { + self.dismiss() + } + } + } + } +} + struct MessageView: View { @Environment(HotlineState.self) private var model: HotlineState @Environment(\.colorScheme) private var colorScheme @@ -7,104 +39,134 @@ struct MessageView: View { @State private var input: String = "" @State private var scrollPos: Int? @State private var contentHeight: CGFloat = 0 + @State private var userInfo: UserInfo? + @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) { - LazyVStack(alignment: .leading) { - ForEach(model.instantMessages[userID] ?? [InstantMessage]()) { msg in - HStack(alignment: .firstTextBaseline) { - 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")) - .padding(EdgeInsets(top: 10, leading: 14, bottom: 10, trailing: 14)) - .background(msg.direction == .outgoing ? Color("Outgoing Message Background") : Color("Incoming Message Background")) - .clipShape(RoundedRectangle(cornerRadius: 16)) - - if msg.direction == .incoming { - Spacer() - } - } - .padding(EdgeInsets(top: 2, leading: 0, bottom: 2, trailing: 0)) - } + VStack(alignment: .leading, spacing: 0) { + + // MARK: Scroll View + self.messageList + + // MARK: Input Divider + Divider() + + // MARK: Input Bar + self.inputBar + } + .background(Color(nsColor: .textBackgroundColor)) + .toolbar { + ToolbarItem { + Button { + self.getUserInfo() + } label: { + Image(systemName: "info.circle") + } + } + } + .sheet(item: self.$userInfo) { info in + UserInfoView(info: info) + } + } + + private func getUserInfo() { + Task { + if let (username, info) = try await self.model.getClientInfoText(id: self.userID) { + self.userInfo = UserInfo(username: username, data: info) + } + } + } + + private var inputBar: some View { + HStack(alignment: .lastTextBaseline, spacing: 0) { + let user = self.model.users.first(where: { $0.id == userID }) + TextField("Message \(user?.name ?? "")", text: $input, axis: .vertical) + .focused($focusedField, equals: .chatInput) + .textFieldStyle(.plain) + .lineLimit(1...5) + .multilineTextAlignment(.leading) + .onSubmit { + if !self.input.isEmpty { + let message = self.input + let uid = self.userID + Task { + try? await self.model.sendInstantMessage(message, userID: uid) } - .padding() - - VStack(spacing: 0) {}.id(bottomID) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .defaultScrollAnchor(.bottom) - .onChange(of: model.instantMessages[userID]?.count) { - reader.scrollTo(bottomID, anchor: .bottom) - model.markInstantMessagesAsRead(userID: userID) - } - .onAppear { - reader.scrollTo(bottomID, anchor: .bottom) - model.markInstantMessagesAsRead(userID: userID) - } - .onChange(of: gm.size) { - reader.scrollTo(bottomID, anchor: .bottom) } + self.input = "" } - - // MARK: Input Divider - Divider() - - // MARK: Input Bar - HStack(alignment: .lastTextBaseline, spacing: 0) { - let user = model.users.first(where: { $0.id == userID }) - TextField("Message \(user?.name ?? "")", text: $input, axis: .vertical) - .focused($focusedField, equals: .chatInput) - .textFieldStyle(.plain) - .lineLimit(1...5) - .multilineTextAlignment(.leading) - .onSubmit { - if !self.input.isEmpty { - let message = self.input - let uid = self.userID - Task { - try? await model.sendInstantMessage(message, userID: uid) + .frame(maxWidth: .infinity) + .padding() + } + .frame(maxWidth: .infinity, minHeight: 28) + .padding(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) + .overlay(alignment: .leadingFirstTextBaseline) { + Image(systemName: "chevron.right").opacity(0.4).offset(x: 16) + } + .onContinuousHover { phase in + switch phase { + case .active(_): + NSCursor.iBeam.set() + case .ended: + NSCursor.arrow.set() + break + } + } + .onTapGesture { + focusedField = .chatInput + } + } + + private var messageList: some View { + ScrollViewReader { reader in + GeometryReader { gm in + ScrollView(.vertical) { + LazyVStack(alignment: .leading) { + ForEach(self.model.instantMessages[self.userID] ?? [InstantMessage]()) { msg in + HStack(alignment: .firstTextBaseline) { + 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")) + .padding(EdgeInsets(top: 10, leading: 14, bottom: 10, trailing: 14)) + .background(msg.direction == .outgoing ? Color("Outgoing Message Background") : Color("Incoming Message Background")) + .clipShape(RoundedRectangle(cornerRadius: 16)) + + if msg.direction == .incoming { + Spacer() } } - self.input = "" + .padding(EdgeInsets(top: 2, leading: 0, bottom: 2, trailing: 0)) } - .frame(maxWidth: .infinity) - .padding() + } + .padding() + + VStack(spacing: 0) {}.id(bottomID) } - .frame(maxWidth: .infinity, minHeight: 28) - .padding(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) - .overlay(alignment: .leadingFirstTextBaseline) { - Image(systemName: "chevron.right").opacity(0.4).offset(x: 16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .defaultScrollAnchor(.bottom) + .onChange(of: self.model.instantMessages[self.userID]?.count) { + reader.scrollTo(self.bottomID, anchor: .bottom) + self.model.markInstantMessagesAsRead(userID: self.userID) } - .onContinuousHover { phase in - switch phase { - case .active(_): - NSCursor.iBeam.set() - case .ended: - NSCursor.arrow.set() - break - } + .onAppear { + reader.scrollTo(self.bottomID, anchor: .bottom) + self.model.markInstantMessagesAsRead(userID: self.userID) } - .onTapGesture { - focusedField = .chatInput + .onChange(of: gm.size) { + reader.scrollTo(self.bottomID, anchor: .bottom) } } - .background(Color(nsColor: .textBackgroundColor)) } } } -- cgit From 46384d99b78cca150aa720eb915d161b5be8c08d Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 14 Nov 2025 08:38:32 -0800 Subject: Cleanup user client info sheet. Hide button if you don't have persmission to do so. --- Hotline.xcodeproj/project.pbxproj | 4 +++ Hotline/Hotline/HotlineClient.swift | 4 +-- Hotline/Hotline/HotlineProtocol.swift | 9 +++++++ Hotline/State/HotlineState.swift | 2 +- Hotline/macOS/MessageView.swift | 50 +++++++---------------------------- Hotline/macOS/UserInfo.swift | 25 ++++++++++++++++++ 6 files changed, 51 insertions(+), 43 deletions(-) create mode 100644 Hotline/macOS/UserInfo.swift (limited to 'Hotline/State/HotlineState.swift') diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index 15453da..2b3c8e9 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -99,6 +99,7 @@ DABE8C002B55E69800884D28 /* server-message.aiff in Resources */ = {isa = PBXBuildFile; fileRef = DABE8BFF2B55E69800884D28 /* server-message.aiff */; }; DABE8C022B55E69D00884D28 /* new-news.aiff in Resources */ = {isa = PBXBuildFile; fileRef = DABE8C012B55E69D00884D28 /* new-news.aiff */; }; DABE8C042B57940B00884D28 /* DAKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABE8C032B57940A00884D28 /* DAKeychain.swift */; }; + DAC0E4232EC78E1100E999DB /* UserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC0E4222EC78E1100E999DB /* UserInfo.swift */; }; DAC3D9832BC33FD000A727C9 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC3D9822BC33FD000A727C9 /* AppState.swift */; }; DAC6B2E02EAC6236004E2CBA /* ChatStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC6B2DE2EAC6236004E2CBA /* ChatStore.swift */; }; DAC6B2E22EAEE9FE004E2CBA /* NetSocket.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC6B2E12EAEE9EF004E2CBA /* NetSocket.swift */; }; @@ -214,6 +215,7 @@ DABE8BFF2B55E69800884D28 /* server-message.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = "server-message.aiff"; sourceTree = ""; }; DABE8C012B55E69D00884D28 /* new-news.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = "new-news.aiff"; sourceTree = ""; }; DABE8C032B57940A00884D28 /* DAKeychain.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DAKeychain.swift; sourceTree = ""; }; + DAC0E4222EC78E1100E999DB /* UserInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserInfo.swift; sourceTree = ""; }; DAC3D9822BC33FD000A727C9 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; DAC6B2DE2EAC6236004E2CBA /* ChatStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatStore.swift; sourceTree = ""; }; DAC6B2E12EAEE9EF004E2CBA /* NetSocket.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetSocket.swift; sourceTree = ""; }; @@ -532,6 +534,7 @@ DAE734F72B2E4126000C56F6 /* macOS */ = { isa = PBXGroup; children = ( + DAC0E4222EC78E1100E999DB /* UserInfo.swift */, DA55AC762BE589F700034857 /* AboutView.swift */, DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */, DAE136B92B9D1147007D8307 /* HotlinePanelView.swift */, @@ -738,6 +741,7 @@ DA5268B32EB6806E00DCB941 /* HotlineFileDownloadClient.swift in Sources */, DACCE5E32EABE86A008CDD92 /* AppUpdateView.swift in Sources */, DAC6B2E02EAC6236004E2CBA /* ChatStore.swift in Sources */, + DAC0E4232EC78E1100E999DB /* UserInfo.swift in Sources */, DA32CD492B2931640053B98B /* User.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index 1394057..1440b71 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -552,7 +552,7 @@ public actor HotlineClient { /// /// - Parameters: /// - userID: Target user ID - public func getClientInfoText(for userID: UInt16) async throws -> (username: String, info: String)? { + public func getClientInfoText(for userID: UInt16) async throws -> HotlineUserClientInfo? { var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getClientInfoText) transaction.setFieldUInt16(type: .userID, val: userID) @@ -560,7 +560,7 @@ public actor HotlineClient { if let username = reply.getField(type: .userName)?.getString(), let info = reply.getField(type: .data)?.getString() { - return (username: username, info: info) + return HotlineUserClientInfo(username: username, details: info) } return nil diff --git a/Hotline/Hotline/HotlineProtocol.swift b/Hotline/Hotline/HotlineProtocol.swift index 4857d87..92fdfd2 100644 --- a/Hotline/Hotline/HotlineProtocol.swift +++ b/Hotline/Hotline/HotlineProtocol.swift @@ -516,6 +516,15 @@ public class HotlineFile: Identifiable, Hashable { } } +public struct HotlineUserClientInfo: Identifiable { + public var username: String + public var details: String + + public var id: String { + self.username + } +} + public struct HotlineUser: Identifiable, Hashable, Sendable { public let id: UInt16 public let iconID: UInt16 diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index 318a468..e78a780 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -798,7 +798,7 @@ class HotlineState: Equatable { self.users = hotlineUsers.map { User(hotlineUser: $0) } } - func getClientInfoText(id userID: UInt16) async throws -> (username: String, info: String)? { + func getClientInfoText(id userID: UInt16) async throws -> HotlineUserClientInfo? { guard let client = self.client else { throw HotlineClientError.notConnected } diff --git a/Hotline/macOS/MessageView.swift b/Hotline/macOS/MessageView.swift index 861a14a..6690a88 100644 --- a/Hotline/macOS/MessageView.swift +++ b/Hotline/macOS/MessageView.swift @@ -1,37 +1,5 @@ import SwiftUI -struct UserInfo: Identifiable { - let username: String - let data: String - - var id: String { - self.username - } -} - -struct UserInfoView: View { - @Environment(\.dismiss) private var dismiss - - let info: UserInfo - - var body: some View { - ScrollView(.vertical) { - Text(self.info.data) - .fontDesign(.monospaced) - .textSelection(.enabled) - .padding() - } - .frame(width: 300, height: 400) - .toolbar { - if #available(macOS 26.0, *) { - Button(role: .close) { - self.dismiss() - } - } - } - } -} - struct MessageView: View { @Environment(HotlineState.self) private var model: HotlineState @Environment(\.colorScheme) private var colorScheme @@ -39,7 +7,7 @@ struct MessageView: View { @State private var input: String = "" @State private var scrollPos: Int? @State private var contentHeight: CGFloat = 0 - @State private var userInfo: UserInfo? + @State private var userInfo: HotlineUserClientInfo? @Namespace private var bottomID @FocusState private var focusedField: FocusedField? @@ -60,11 +28,13 @@ struct MessageView: View { } .background(Color(nsColor: .textBackgroundColor)) .toolbar { - ToolbarItem { - Button { - self.getUserInfo() - } label: { - Image(systemName: "info.circle") + if self.model.access?.contains(.canGetClientInfo) == true { + ToolbarItem { + Button { + self.getUserInfo() + } label: { + Image(systemName: "info.circle") + } } } } @@ -75,8 +45,8 @@ struct MessageView: View { private func getUserInfo() { Task { - if let (username, info) = try await self.model.getClientInfoText(id: self.userID) { - self.userInfo = UserInfo(username: username, data: info) + if let info = try await self.model.getClientInfoText(id: self.userID) { + self.userInfo = info } } } diff --git a/Hotline/macOS/UserInfo.swift b/Hotline/macOS/UserInfo.swift new file mode 100644 index 0000000..328fc02 --- /dev/null +++ b/Hotline/macOS/UserInfo.swift @@ -0,0 +1,25 @@ +import SwiftUI + +struct UserInfoView: View { + @Environment(\.dismiss) private var dismiss + + let info: HotlineUserClientInfo + + var body: some View { + ScrollView(.vertical) { + Text(self.info.details) + .fontDesign(.monospaced) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding() + } + .frame(width: 350, height: 400) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("OK") { + self.dismiss() + } + } + } + } +} -- cgit From da1d7001f5132115bfdbe19cd95e73b04ba76c95 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 14 Nov 2025 09:56:46 -0800 Subject: Add kick and improve client info display. Also improve error messages when can't connect (server down) or can't login (show server message). --- Hotline.xcodeproj/project.pbxproj | 16 ++++---- Hotline/Hotline/HotlineClient.swift | 27 +++++++++++- Hotline/Hotline/HotlineProtocol.swift | 5 +++ Hotline/MacApp.swift | 6 +-- Hotline/State/HotlineState.swift | 49 +++++++++++++++++++--- Hotline/macOS/BroadcastMessageSheet.swift | 66 ++++++++++++++++++++++++++++++ Hotline/macOS/BroadcastMessageView.swift | 66 ------------------------------ Hotline/macOS/Files/FileDetailsSheet.swift | 43 ++++++++++--------- Hotline/macOS/Files/FilesView.swift | 38 +++++++++-------- Hotline/macOS/MessageView.swift | 36 ++++++++++++++-- Hotline/macOS/ServerView.swift | 23 +++++------ Hotline/macOS/UserClientInfoSheet.swift | 25 +++++++++++ Hotline/macOS/UserInfo.swift | 25 ----------- 13 files changed, 264 insertions(+), 161 deletions(-) create mode 100644 Hotline/macOS/BroadcastMessageSheet.swift delete mode 100644 Hotline/macOS/BroadcastMessageView.swift create mode 100644 Hotline/macOS/UserClientInfoSheet.swift delete mode 100644 Hotline/macOS/UserInfo.swift (limited to 'Hotline/State/HotlineState.swift') diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index 2b3c8e9..6ff1f66 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -89,7 +89,7 @@ DAB4D8842B4CABEF0048A05C /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAB4D8832B4CABEF0048A05C /* Extensions.swift */; }; DAB4D8872B4CB3610048A05C /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = DAB4D8862B4CB3610048A05C /* MarkdownUI */; }; DABAFBEA2EC58C170015E889 /* AccountDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABAFBE92EC58C170015E889 /* AccountDetailsView.swift */; }; - DABAFBEC2EC599700015E889 /* BroadcastMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABAFBEB2EC5996B0015E889 /* BroadcastMessageView.swift */; }; + DABAFBEC2EC599700015E889 /* BroadcastMessageSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABAFBEB2EC5996B0015E889 /* BroadcastMessageSheet.swift */; }; DABE8BF42B55DC0A00884D28 /* transfer-complete.aiff in Resources */ = {isa = PBXBuildFile; fileRef = DABE8BF32B55DC0A00884D28 /* transfer-complete.aiff */; }; DABE8BF62B55DC2E00884D28 /* chat-message.aiff in Resources */ = {isa = PBXBuildFile; fileRef = DABE8BF52B55DC2E00884D28 /* chat-message.aiff */; }; DABE8BF82B55DC6100884D28 /* logged-in.aiff in Resources */ = {isa = PBXBuildFile; fileRef = DABE8BF72B55DC6100884D28 /* logged-in.aiff */; }; @@ -99,7 +99,7 @@ DABE8C002B55E69800884D28 /* server-message.aiff in Resources */ = {isa = PBXBuildFile; fileRef = DABE8BFF2B55E69800884D28 /* server-message.aiff */; }; DABE8C022B55E69D00884D28 /* new-news.aiff in Resources */ = {isa = PBXBuildFile; fileRef = DABE8C012B55E69D00884D28 /* new-news.aiff */; }; DABE8C042B57940B00884D28 /* DAKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABE8C032B57940A00884D28 /* DAKeychain.swift */; }; - DAC0E4232EC78E1100E999DB /* UserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC0E4222EC78E1100E999DB /* UserInfo.swift */; }; + DAC0E4232EC78E1100E999DB /* UserClientInfoSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC0E4222EC78E1100E999DB /* UserClientInfoSheet.swift */; }; DAC3D9832BC33FD000A727C9 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC3D9822BC33FD000A727C9 /* AppState.swift */; }; DAC6B2E02EAC6236004E2CBA /* ChatStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC6B2DE2EAC6236004E2CBA /* ChatStore.swift */; }; DAC6B2E22EAEE9FE004E2CBA /* NetSocket.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC6B2E12EAEE9EF004E2CBA /* NetSocket.swift */; }; @@ -205,7 +205,7 @@ DAB4D8812B4C8FED0048A05C /* FileIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileIconView.swift; sourceTree = ""; }; DAB4D8832B4CABEF0048A05C /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = ""; }; DABAFBE92EC58C170015E889 /* AccountDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDetailsView.swift; sourceTree = ""; }; - DABAFBEB2EC5996B0015E889 /* BroadcastMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BroadcastMessageView.swift; sourceTree = ""; }; + DABAFBEB2EC5996B0015E889 /* BroadcastMessageSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BroadcastMessageSheet.swift; sourceTree = ""; }; DABE8BF32B55DC0A00884D28 /* transfer-complete.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = "transfer-complete.aiff"; sourceTree = ""; }; DABE8BF52B55DC2E00884D28 /* chat-message.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = "chat-message.aiff"; sourceTree = ""; }; DABE8BF72B55DC6100884D28 /* logged-in.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = "logged-in.aiff"; sourceTree = ""; }; @@ -215,7 +215,7 @@ DABE8BFF2B55E69800884D28 /* server-message.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = "server-message.aiff"; sourceTree = ""; }; DABE8C012B55E69D00884D28 /* new-news.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = "new-news.aiff"; sourceTree = ""; }; DABE8C032B57940A00884D28 /* DAKeychain.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DAKeychain.swift; sourceTree = ""; }; - DAC0E4222EC78E1100E999DB /* UserInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserInfo.swift; sourceTree = ""; }; + DAC0E4222EC78E1100E999DB /* UserClientInfoSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserClientInfoSheet.swift; sourceTree = ""; }; DAC3D9822BC33FD000A727C9 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; DAC6B2DE2EAC6236004E2CBA /* ChatStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatStore.swift; sourceTree = ""; }; DAC6B2E12EAEE9EF004E2CBA /* NetSocket.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetSocket.swift; sourceTree = ""; }; @@ -534,15 +534,15 @@ DAE734F72B2E4126000C56F6 /* macOS */ = { isa = PBXGroup; children = ( - DAC0E4222EC78E1100E999DB /* UserInfo.swift */, DA55AC762BE589F700034857 /* AboutView.swift */, DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */, DAE136B92B9D1147007D8307 /* HotlinePanelView.swift */, DAE734F82B2E4185000C56F6 /* ServerView.swift */, DAF5BC6B2EC2727100551E4D /* ConnectView.swift */, DAE735022B30C0BB000C56F6 /* MessageView.swift */, + DAC0E4222EC78E1100E999DB /* UserClientInfoSheet.swift */, DA5268B42EB6840A00DCB941 /* TransfersView.swift */, - DABAFBEB2EC5996B0015E889 /* BroadcastMessageView.swift */, + DABAFBEB2EC5996B0015E889 /* BroadcastMessageSheet.swift */, DA501BE52EBE9520001714F8 /* Trackers */, DA5268A82EB081AF00DCB941 /* Chat */, DA5268A62EB0762300DCB941 /* Board */, @@ -736,12 +736,12 @@ DA501BE72EBE9542001714F8 /* TrackerBookmarkSheet.swift in Sources */, DAB4D87E2B4C8BCA0048A05C /* FilePreviewTextView.swift in Sources */, DA3429B72EBAB1750010784E /* QuickLookPreviewView.swift in Sources */, - DABAFBEC2EC599700015E889 /* BroadcastMessageView.swift in Sources */, + DABAFBEC2EC599700015E889 /* BroadcastMessageSheet.swift in Sources */, DA4F2C012B1A558E00D8ADDC /* ChatView.swift in Sources */, DA5268B32EB6806E00DCB941 /* HotlineFileDownloadClient.swift in Sources */, DACCE5E32EABE86A008CDD92 /* AppUpdateView.swift in Sources */, DAC6B2E02EAC6236004E2CBA /* ChatStore.swift in Sources */, - DAC0E4232EC78E1100E999DB /* UserInfo.swift in Sources */, + DAC0E4232EC78E1100E999DB /* UserClientInfoSheet.swift in Sources */, DA32CD492B2931640053B98B /* User.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index 1440b71..29b7827 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -181,7 +181,16 @@ public actor HotlineClient { // Connect socket print("HotlineClient.connect(): Connecting socket...") - let socket = try await NetSocket.connect(host: host, port: port) + let socket: NetSocket + do { + socket = try await NetSocket.connect(host: host, port: port) + } + catch let socketError as NetSocketError { + if case .failed(_) = socketError { + throw HotlineClientError.connectionFailed(socketError) + } + throw socketError + } print("HotlineClient.connect(): Socket connected") // Perform handshake @@ -590,6 +599,22 @@ public actor HotlineClient { try await socket.send(transaction, endian: .big) } + + /// Force a user to disconnect from the server + /// + /// - Parameters: + /// - userID: Target user ID + /// - options: If specified, temporarily or permanently ban the user + public func disconnectUser(userID: UInt16, options: HotlineUserDisconnectOptions? = nil) async throws { + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .disconnectUser) + + transaction.setFieldUInt16(type: .userID, val: userID) + if let options { + transaction.setFieldUInt16(type: .options, val: options.rawValue) + } + + try await self.sendTransaction(transaction) + } // MARK: - Agreement diff --git a/Hotline/Hotline/HotlineProtocol.swift b/Hotline/Hotline/HotlineProtocol.swift index 92fdfd2..a1a0f31 100644 --- a/Hotline/Hotline/HotlineProtocol.swift +++ b/Hotline/Hotline/HotlineProtocol.swift @@ -10,6 +10,11 @@ struct HotlinePorts { static let DefaultTrackerPort: Int = 5498 } +public enum HotlineUserDisconnectOptions: UInt16 { + case temporarilyBan = 1 + case permanentlyBan = 2 +} + public struct HotlineUserOptions: OptionSet, Sendable { public let rawValue: UInt16 diff --git a/Hotline/MacApp.swift b/Hotline/MacApp.swift index 7997a87..4f6c2af 100644 --- a/Hotline/MacApp.swift +++ b/Hotline/MacApp.swift @@ -185,13 +185,13 @@ struct Application: App { Server(name: nil, description: nil, address: "") } .modelContainer(self.modelContainer) - .defaultSize(width: 690, height: 760) + .defaultSize(width: 780, height: 640) .defaultPosition(.center) .onChange(of: activeServerState) { - AppState.shared.activeServerState = activeServerState + AppState.shared.activeServerState = self.activeServerState } .onChange(of: activeHotline) { - AppState.shared.activeHotline = activeHotline + AppState.shared.activeHotline = self.activeHotline } .commands { CommandGroup(replacing: .newItem) { diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index e78a780..7baa477 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -174,7 +174,7 @@ class HotlineState: Equatable { self.updateServerTitle() } } - var serverTitle: String = "Server" + var serverTitle: String = "" var username: String = "guest" var iconID: Int = 414 var access: HotlineUserAccessOptions? @@ -361,18 +361,41 @@ class HotlineState: Equatable { self.downloadBanner() } - } catch { + } + catch let clientError as HotlineClientError { + switch clientError { + case .connectionFailed(_): + self.displayError(clientError, message: "This server appears to be offline.") + case .loginFailed(let msg): + self.displayError(clientError, message: msg) + case .serverError(_, let msg): + self.displayError(clientError, message: msg) + default: + self.displayError(clientError) + } + if let client = self.client { + await client.disconnect() + self.client = nil + } + self.status = .disconnected + throw clientError + } + catch { print("HotlineState.login(): Login failed with error: \(error)") if let client = self.client { await client.disconnect() self.client = nil } - self.status = .disconnected // .failed(error.localizedDescription) - self.errorDisplayed = true - self.errorMessage = error.localizedDescription + self.status = .disconnected + self.displayError(error) throw error } } + + private func displayError(_ error: Error, message: String? = nil) { + self.errorDisplayed = true + self.errorMessage = message ?? error.localizedDescription + } /// Disconnect from the server (user-initiated) @MainActor @@ -813,6 +836,20 @@ class HotlineState: Equatable { return nil } + + func disconnectUser(id userID: UInt16, options: HotlineUserDisconnectOptions?) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + do { + try await client.disconnectUser(userID: userID, options: options) + } + catch let error as HotlineClientError { + self.errorMessage = error.userMessage + self.errorDisplayed = true + } + } // MARK: - Files @@ -1948,7 +1985,7 @@ class HotlineState: Equatable { // MARK: - Utilities func updateServerTitle() { - self.serverTitle = self.serverName ?? self.server?.name ?? self.server?.address ?? "Server" + self.serverTitle = self.serverName ?? self.server?.name ?? self.server?.address ?? "Hotline" } // News helpers diff --git a/Hotline/macOS/BroadcastMessageSheet.swift b/Hotline/macOS/BroadcastMessageSheet.swift new file mode 100644 index 0000000..b3bd7ea --- /dev/null +++ b/Hotline/macOS/BroadcastMessageSheet.swift @@ -0,0 +1,66 @@ +import SwiftUI + +fileprivate let CHARACTER_LIMIT: Int = 255 + +struct BroadcastMessageSheet: View { + @Environment(HotlineState.self) private var model: HotlineState + @Environment(\.dismiss) private var dismiss + + @State private var sending: Bool = false + + private var message: String { + self.model.broadcastMessage.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var body: some View { + @Bindable var model = self.model + + VStack { + TextField("Write a message...", text: $model.broadcastMessage, axis: .vertical) + .textFieldStyle(.plain) + .lineLimit(5, reservesSpace: true) + } + .padding(.leading, 32) + .padding(.top, 2) + .overlay(alignment: .topLeading) { + Image("Server Message") + } + .padding(16) + .frame(width: 400) + .toolbar { + if self.sending { + ToolbarItem { + ProgressView() + .controlSize(.small) + } + } + + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + self.dismiss() + } + } + + ToolbarItem(placement: .confirmationAction) { + Button("Broadcast") { + let message = self.message + model.broadcastMessage = "" + + guard !message.isBlank else { + return + } + + Task { + self.sending = true + defer { self.sending = false } + + try await model.sendBroadcast(message) + + self.dismiss() + } + } + .disabled(self.message.isEmpty) + } + } + } +} diff --git a/Hotline/macOS/BroadcastMessageView.swift b/Hotline/macOS/BroadcastMessageView.swift deleted file mode 100644 index a5f6fd2..0000000 --- a/Hotline/macOS/BroadcastMessageView.swift +++ /dev/null @@ -1,66 +0,0 @@ -import SwiftUI - -fileprivate let CHARACTER_LIMIT: Int = 255 - -struct BroadcastMessageView: View { - @Environment(HotlineState.self) private var model: HotlineState - @Environment(\.dismiss) private var dismiss - - @State private var sending: Bool = false - - private var message: String { - self.model.broadcastMessage.trimmingCharacters(in: .whitespacesAndNewlines) - } - - var body: some View { - @Bindable var model = self.model - - VStack { - TextField("Write a message...", text: $model.broadcastMessage, axis: .vertical) - .textFieldStyle(.plain) - .lineLimit(5, reservesSpace: true) - } - .padding(.leading, 32) - .padding(.top, 2) - .overlay(alignment: .topLeading) { - Image("Server Message") - } - .padding(16) - .frame(width: 400) - .toolbar { - if self.sending { - ToolbarItem { - ProgressView() - .controlSize(.small) - } - } - - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - self.dismiss() - } - } - - ToolbarItem(placement: .confirmationAction) { - Button("Broadcast") { - let message = self.message - model.broadcastMessage = "" - - guard !message.isBlank else { - return - } - - Task { - self.sending = true - defer { self.sending = false } - - try await model.sendBroadcast(message) - - self.dismiss() - } - } - .disabled(self.message.isEmpty) - } - } - } -} diff --git a/Hotline/macOS/Files/FileDetailsSheet.swift b/Hotline/macOS/Files/FileDetailsSheet.swift index e86bf28..1bfacb2 100644 --- a/Hotline/macOS/Files/FileDetailsSheet.swift +++ b/Hotline/macOS/Files/FileDetailsSheet.swift @@ -14,7 +14,7 @@ struct FileDetailsSheet: View { var body: some View { VStack(alignment: .leading, spacing: 16) { HStack(alignment: .center, spacing: 16){ - if self.details.type == "Folder" { + if self.isFolder { FolderIconView() .frame(width: 32, height: 32) } @@ -22,6 +22,7 @@ struct FileDetailsSheet: View { FileIconView(filename: self.details.name, fileType: nil) .frame(width: 32, height: 32) } + TextField("File Name", text: $filename) .disabled(!self.canRename) } @@ -114,6 +115,28 @@ struct FileDetailsSheet: View { } } + private var isFolder: Bool { + self.details.type == "Folder" || self.details.type == "fldr" + } + + private func isEdited() -> Bool { + return self.filename != self.details.name || self.comment != self.details.comment + } + + private var canRename: Bool { + if self.isFolder { + return self.model.access?.contains(.canRenameFolders) == true + } + return self.model.access?.contains(.canRenameFiles) == true + } + + private var canSetComment: Bool { + if self.isFolder { + return self.model.access?.contains(.canSetFolderComment) == true + } + return self.model.access?.contains(.canSetFileComment) == true + } + static var dateFormatter: DateFormatter = { var dateFormatter = DateFormatter() dateFormatter.dateStyle = .long @@ -142,24 +165,6 @@ struct FileDetailsSheet: View { let formattedByteCount = Self.byteCountSizeFormatter.string(from: NSNumber(value:byteCount)) ?? "0" return "\(FileItemView.byteFormatter.string(fromByteCount: Int64(byteCount))) (\(formattedByteCount) bytes)" } - - private func isEdited() -> Bool { - return self.filename != self.details.name || self.comment != self.details.comment - } - - private var canRename: Bool { - if self.details.type == "fldr" || self.details.type == "Folder" { - return self.model.access?.contains(.canRenameFolders) == true - } - return self.model.access?.contains(.canRenameFiles) == true - } - - private var canSetComment: Bool { - if self.details.type == "fldr" || self.details.type == "Folder" { - return self.model.access?.contains(.canSetFolderComment) == true - } - return self.model.access?.contains(.canSetFileComment) == true - } } //#Preview { diff --git a/Hotline/macOS/Files/FilesView.swift b/Hotline/macOS/Files/FilesView.swift index 1ae99d9..c9c4c24 100644 --- a/Hotline/macOS/Files/FilesView.swift +++ b/Hotline/macOS/Files/FilesView.swift @@ -198,28 +198,32 @@ struct FilesView: View { ToolbarSpacer() } - ToolbarItem { - Button { - self.newFolderShown = true - } label: { - Label("New Folder", systemImage: "folder.badge.plus") - } - .help("New Folder") - .popover(isPresented: self.$newFolderShown, arrowEdge: .bottom) { - NewFolderPopover { folderName in - self.newFolder(name: folderName, parent: self.selection) + if self.model.access?.contains(.canCreateFolders) == true { + ToolbarItem { + Button { + self.newFolderShown = true + } label: { + Label("New Folder", systemImage: "folder.badge.plus") + } + .help("New Folder") + .popover(isPresented: self.$newFolderShown, arrowEdge: .bottom) { + NewFolderPopover { folderName in + self.newFolder(name: folderName, parent: self.selection) + } } } } - ToolbarItem { - Button { - self.confirmDeleteShown = true - } label: { - Label("Delete", systemImage: "trash") + if self.model.access?.contains(.canDeleteFiles) == true || self.model.access?.contains(.canDeleteFolders) == true { + ToolbarItem { + Button { + self.confirmDeleteShown = true + } label: { + Label("Delete", systemImage: "trash") + } + .disabled(self.selection == nil) + .help("Delete") } - .disabled(self.selection == nil) - .help("Delete") } } } diff --git a/Hotline/macOS/MessageView.swift b/Hotline/macOS/MessageView.swift index 6690a88..6208d54 100644 --- a/Hotline/macOS/MessageView.swift +++ b/Hotline/macOS/MessageView.swift @@ -8,6 +8,8 @@ struct MessageView: View { @State private var scrollPos: Int? @State private var contentHeight: CGFloat = 0 @State private var userInfo: HotlineUserClientInfo? + @State private var disconnectConfirmShown: Bool = false + @State private var username: String? @Namespace private var bottomID @FocusState private var focusedField: FocusedField? @@ -27,6 +29,10 @@ struct MessageView: View { self.inputBar } .background(Color(nsColor: .textBackgroundColor)) + .onAppear { + let user = self.model.users.first(where: { $0.id == userID }) + self.username = user?.name + } .toolbar { if self.model.access?.contains(.canGetClientInfo) == true { ToolbarItem { @@ -35,11 +41,30 @@ struct MessageView: View { } label: { Image(systemName: "info.circle") } + .help("View \(self.username ?? "user")'s information") + } + } + + if self.model.access?.contains(.canDisconnectUsers) == true { + ToolbarItem { + Button { + self.disconnectConfirmShown = true + } label: { + Image(systemName: "nosign") + } + .help("Disconnect \(self.username ?? "this user")") } } } .sheet(item: self.$userInfo) { info in - UserInfoView(info: info) + UserClientInfoSheet(info: info) + } + .alert("Are you sure you want to disconnect \(self.username ?? "this user")?", isPresented: self.$disconnectConfirmShown) { + Button("Disconnect", role: .destructive) { + self.disconnectUser() + } + } message: { + Text("They will be disconnected from the server, but may reconnect.") } } @@ -51,10 +76,15 @@ struct MessageView: View { } } + private func disconnectUser() { + Task { + try await self.model.disconnectUser(id: self.userID, options: nil) + } + } + private var inputBar: some View { HStack(alignment: .lastTextBaseline, spacing: 0) { - let user = self.model.users.first(where: { $0.id == userID }) - TextField("Message \(user?.name ?? "")", text: $input, axis: .vertical) + TextField("Message \(self.username ?? "")", text: $input, axis: .vertical) .focused($focusedField, equals: .chatInput) .textFieldStyle(.plain) .lineLimit(1...5) diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index 3794018..ff92acc 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -111,7 +111,7 @@ struct ServerView: View { self.connectForm Spacer() } - .navigationTitle("New Connection") + .navigationTitle(self.model.serverTitle.isBlank ? "Hotline" : self.model.serverTitle) } else if self.model.status.isLoggingIn { HStack { @@ -131,7 +131,7 @@ struct ServerView: View { } .frame(maxWidth: 300) .padding() - .navigationTitle("New Connection") + .navigationTitle(self.model.serverTitle.isBlank ? "Hotline" : self.model.serverTitle) } else if self.model.status == .loggedIn { self.serverView @@ -155,7 +155,7 @@ struct ServerView: View { Task { try? await self.model.sendUserPreferences() } } .sheet(isPresented: self.$state.broadcastShown) { - BroadcastMessageView() + BroadcastMessageSheet() .environment(self.model) .presentationSizing(.fitted) } @@ -336,8 +336,8 @@ struct ServerView: View { var serverView: some View { NavigationSplitView { self.navigationList - .frame(maxWidth: .infinity) - .navigationSplitViewColumnWidth(min: 200, ideal: 250, max: 500) + .navigationSplitViewColumnWidth(200) +// .navigationSplitViewColumnWidth(min: 150, ideal: 200, max: 400) .toolbar(removing: .sidebarToggle) // .toolbar { // if self.model.access?.contains(.canOpenUsers) == true { @@ -357,22 +357,22 @@ struct ServerView: View { ChatView() .navigationTitle(model.serverTitle) // .navigationSubtitle("Public Chat") - .navigationSplitViewColumnWidth(min: 250, ideal: 500) +// .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .news: NewsView() .navigationTitle(model.serverTitle) // .navigationSubtitle("Newsgroups") - .navigationSplitViewColumnWidth(min: 250, ideal: 500) +// .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .board: MessageBoardView() .navigationTitle(model.serverTitle) // .navigationSubtitle("Message Board") - .navigationSplitViewColumnWidth(min: 250, ideal: 500) +// .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .files: FilesView() .navigationTitle(model.serverTitle) // .navigationSubtitle("Shared Files") - .navigationSplitViewColumnWidth(min: 250, ideal: 500) +// .navigationSplitViewColumnWidth(min: 250, ideal: 500) // case .accounts: // AccountManagerView() // .navigationTitle(model.serverTitle) @@ -383,15 +383,12 @@ struct ServerView: View { MessageView(userID: userID) .navigationTitle(model.serverTitle) // .navigationSubtitle(user?.name ?? "Private Message") - .navigationSplitViewColumnWidth(min: 250, ideal: 500) +// .navigationSplitViewColumnWidth(min: 250, ideal: 500) .onAppear { model.markInstantMessagesAsRead(userID: userID) } } } - .background { - Color.red - } } // MARK: - diff --git a/Hotline/macOS/UserClientInfoSheet.swift b/Hotline/macOS/UserClientInfoSheet.swift new file mode 100644 index 0000000..8fa4db8 --- /dev/null +++ b/Hotline/macOS/UserClientInfoSheet.swift @@ -0,0 +1,25 @@ +import SwiftUI + +struct UserClientInfoSheet: View { + @Environment(\.dismiss) private var dismiss + + let info: HotlineUserClientInfo + + var body: some View { + ScrollView(.vertical) { + Text(self.info.details) + .fontDesign(.monospaced) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding() + } + .frame(width: 350, height: 400) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("OK") { + self.dismiss() + } + } + } + } +} diff --git a/Hotline/macOS/UserInfo.swift b/Hotline/macOS/UserInfo.swift deleted file mode 100644 index 328fc02..0000000 --- a/Hotline/macOS/UserInfo.swift +++ /dev/null @@ -1,25 +0,0 @@ -import SwiftUI - -struct UserInfoView: View { - @Environment(\.dismiss) private var dismiss - - let info: HotlineUserClientInfo - - var body: some View { - ScrollView(.vertical) { - Text(self.info.details) - .fontDesign(.monospaced) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .topLeading) - .padding() - } - .frame(width: 350, height: 400) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("OK") { - self.dismiss() - } - } - } - } -} -- cgit From a1ec37248ce36e84cbdb896e226f241f39df8292 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 14 Nov 2025 16:02:56 -0800 Subject: Bit more work on error handling. --- Hotline/Hotline/HotlineClient.swift | 14 ++-- Hotline/Hotline/HotlineExtensions.swift | 94 ++++++++-------------- .../Transfers/HotlineFileUploadClient.swift | 4 +- .../Transfers/HotlineFolderUploadClient.swift | 5 +- .../Hotline/Transfers/HotlineTransferClient.swift | 6 +- Hotline/MacApp.swift | 4 +- Hotline/State/HotlineState.swift | 50 ++++++------ Hotline/macOS/Board/MessageBoardView.swift | 20 +++-- Hotline/macOS/Files/FolderItemView.swift | 2 +- Hotline/macOS/MessageView.swift | 8 +- Hotline/macOS/ServerView.swift | 33 ++------ 11 files changed, 109 insertions(+), 131 deletions(-) (limited to 'Hotline/State/HotlineState.swift') diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index 29b7827..2219330 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -82,10 +82,10 @@ public struct HotlineLogin: Sendable { /// Information about the connected server public struct HotlineServerInfo: Sendable { - let name: String + let name: String? let version: UInt16 - public init(name: String, version: UInt16) { + public init(name: String?, version: UInt16) { self.name = name self.version = version } @@ -242,7 +242,7 @@ public actor HotlineClient { print("HotlineClient.connect(): Starting keep-alive") await client.startKeepAlive() - print("HotlineClient.connect(): Connected to \(serverInfo.name) (v\(serverInfo.version))") + print("HotlineClient.connect(): Connected to \(serverInfo.name ?? "Server") (v\(serverInfo.version))") return client } @@ -279,8 +279,12 @@ public actor HotlineClient { throw HotlineClientError.loginFailed(errorText) } - let serverName = reply.getField(type: .serverName)?.getString() ?? "Unknown" - let serverVersion = reply.getField(type: .versionNumber)?.getUInt16() ?? 0 + // All servers send a server version. + let serverVersion = reply.getField(type: .versionNumber)?.getUInt16() ?? 123 + + // Later clients send a name and banner ID. + let serverName = reply.getField(type: .serverName)?.getString() +// let serverBannerID = reply.getField(type: .communityBannerID)?.getInteger() return HotlineServerInfo(name: serverName, version: serverVersion) } diff --git a/Hotline/Hotline/HotlineExtensions.swift b/Hotline/Hotline/HotlineExtensions.swift index bcee812..0d5d806 100644 --- a/Hotline/Hotline/HotlineExtensions.swift +++ b/Hotline/Hotline/HotlineExtensions.swift @@ -490,7 +490,7 @@ extension FileManager { return nil } - guard let fileName = fileURL.lastPathComponent.data(using: .macOSRoman) else { + guard let fileName = fileURL.lastPathComponent.data(using: .macOSRoman, allowLossyConversion: true) else { return nil } @@ -826,38 +826,11 @@ extension Array where Element == UInt8 { } func readString(at offset: Int, length: Int) -> String? { - guard let subdata: Data = self.readData(at: offset, length: length) else { + guard let data: Data = self.readData(at: offset, length: length) else { return nil } - if subdata.count == 0 { - return "" - } - - let allowedEncodings = [ - NSUTF8StringEncoding, - NSShiftJISStringEncoding, - NSUnicodeStringEncoding, - NSWindowsCP1251StringEncoding - ] - - var decodedNSString: NSString? - 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 { - print("ENCODING FOUND \(rawValue)") - } - - var macStr = String(data: subdata, encoding: .macOSRoman) - if macStr == nil { - macStr = String(data: subdata, encoding: .nonLossyASCII) - } - - return macStr + return data.readString(at: 0, length: length) } func readPString(at offset: Int) -> (String?, Int) { @@ -1158,36 +1131,39 @@ extension NetSocket { let length = try await read(UInt8.self) guard length > 0 else { return nil } - let data = try await read(Int(length)) + let dataLength = Int(length) + let data = try await read(dataLength) + + return data.readString(at: 0, length: dataLength) // Try auto-detection with common encodings - let allowedEncodings = [ - String.Encoding.utf8.rawValue, - String.Encoding.shiftJIS.rawValue, - String.Encoding.unicode.rawValue, - String.Encoding.windowsCP1251.rawValue - ] - - var decodedString: NSString? - let detected = NSString.stringEncoding( - for: data, - encodingOptions: [.allowLossyKey: false], - convertedString: &decodedString, - usedLossyConversion: nil - ) - - if allowedEncodings.contains(detected), let str = decodedString as? String { - return str - } - - // Fallback to MacRoman for classic Mac compatibility - guard let str = String(data: data, encoding: .macOSRoman) else { - throw NetSocketError.decodeFailed(NSError( - domain: "NetSocket", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "Failed to decode pascal string with any known encoding"] - )) - } - return str +// let allowedEncodings = [ +// String.Encoding.utf8.rawValue, +// String.Encoding.shiftJIS.rawValue, +// String.Encoding.unicode.rawValue, +// String.Encoding.windowsCP1251.rawValue +// ] +// +// var decodedString: NSString? +// let detected = NSString.stringEncoding( +// for: data, +// encodingOptions: [.allowLossyKey: false], +// convertedString: &decodedString, +// usedLossyConversion: nil +// ) +// +// if allowedEncodings.contains(detected), let str = decodedString as? String { +// return str +// } +// +// // Fallback to MacRoman for classic Mac compatibility +// guard let str = String(data: data, encoding: .macOSRoman) else { +// throw NetSocketError.decodeFailed(NSError( +// domain: "NetSocket", +// code: -1, +// userInfo: [NSLocalizedDescriptionKey: "Failed to decode pascal string with any known encoding"] +// )) +// } +// return str } } diff --git a/Hotline/Hotline/Transfers/HotlineFileUploadClient.swift b/Hotline/Hotline/Transfers/HotlineFileUploadClient.swift index 384e9bd..937db6a 100644 --- a/Hotline/Hotline/Transfers/HotlineFileUploadClient.swift +++ b/Hotline/Hotline/Transfers/HotlineFileUploadClient.swift @@ -131,7 +131,9 @@ public class HotlineFileUploadClient: @MainActor HotlineTransferClient { throw HotlineTransferClientError.failedToTransfer } - let infoForkData = infoFork.data() + guard let infoForkData = infoFork.data() else { + throw HotlineTransferClientError.failedToTransfer + } let dataForkSize = forkSizes.dataForkSize let resourceForkSize = forkSizes.resourceForkSize diff --git a/Hotline/Hotline/Transfers/HotlineFolderUploadClient.swift b/Hotline/Hotline/Transfers/HotlineFolderUploadClient.swift index 1947de6..f8dfe02 100644 --- a/Hotline/Hotline/Transfers/HotlineFolderUploadClient.swift +++ b/Hotline/Hotline/Transfers/HotlineFolderUploadClient.swift @@ -380,7 +380,10 @@ public class HotlineFolderUploadClient: @MainActor HotlineTransferClient { throw HotlineTransferClientError.failedToTransfer } - let infoForkData = infoFork.data() + guard let infoForkData = infoFork.data() else { + throw HotlineTransferClientError.failedToTransfer + } + let dataForkSize = forkSizes.dataForkSize let resourceForkSize = forkSizes.resourceForkSize diff --git a/Hotline/Hotline/Transfers/HotlineTransferClient.swift b/Hotline/Hotline/Transfers/HotlineTransferClient.swift index 596155b..5f786fc 100644 --- a/Hotline/Hotline/Transfers/HotlineTransferClient.swift +++ b/Hotline/Hotline/Transfers/HotlineTransferClient.swift @@ -260,8 +260,10 @@ struct HotlineFileInfoFork { return nil } - func data() -> Data { - let fileName = self.name.data(using: .macOSRoman)! + func data() -> Data? { + guard let fileName = self.name.data(using: .macOSRoman, allowLossyConversion: true) else { + return nil + } let data = Data(endian: .big) { self.platform diff --git a/Hotline/MacApp.swift b/Hotline/MacApp.swift index 4f6c2af..bf816d7 100644 --- a/Hotline/MacApp.swift +++ b/Hotline/MacApp.swift @@ -184,9 +184,11 @@ struct Application: App { } defaultValue: { Server(name: nil, description: nil, address: "") } - .modelContainer(self.modelContainer) + .windowToolbarStyle(.unified) +// .windowStyle(.hiddenTitleBar) .defaultSize(width: 780, height: 640) .defaultPosition(.center) + .modelContainer(self.modelContainer) .onChange(of: activeServerState) { AppState.shared.activeServerState = self.activeServerState } diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index 7baa477..df8434d 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -324,10 +324,10 @@ class HotlineState: Equatable { print("HotlineState.login(): Getting server info...") if let serverInfo = await client.server { self.serverVersion = serverInfo.version - if !serverInfo.name.isEmpty { - self.serverName = serverInfo.name + if let name = serverInfo.name { + self.serverName = name } - print("HotlineState.login(): Server info retrieved: \(serverInfo.name) v\(serverInfo.version)") + print("HotlineState.login(): Server info retrieved: \(self.serverTitle) v\(serverInfo.version)") } self.status = .connected @@ -336,7 +336,7 @@ class HotlineState: Equatable { // Request initial data before starting event loop print("HotlineState.login(): Requesting user list...") try await self.getUserList() - + self.status = .loggedIn print("HotlineState.login(): Status set to loggedIn") @@ -398,7 +398,6 @@ class HotlineState: Equatable { } /// Disconnect from the server (user-initiated) - @MainActor func disconnect() async { print("HotlineState.disconnect(): Called") guard let client = self.client else { @@ -424,7 +423,6 @@ class HotlineState: Equatable { } /// Handle connection closure (server-initiated or after user disconnect) - @MainActor private func handleConnectionClosed() { print("HotlineState: handleConnectionClosed() entered") guard self.client != nil else { @@ -830,8 +828,7 @@ class HotlineState: Equatable { return try await client.getClientInfoText(for: userID) } catch let error as HotlineClientError { - self.errorMessage = error.userMessage - self.errorDisplayed = true + self.displayError(error, message: error.userMessage) } return nil @@ -846,25 +843,33 @@ class HotlineState: Equatable { try await client.disconnectUser(userID: userID, options: options) } catch let error as HotlineClientError { - self.errorMessage = error.userMessage - self.errorDisplayed = true + self.displayError(error, message: error.userMessage) } } // MARK: - Files @discardableResult - func getFileList(path: [String] = [], suppressErrors: Bool = false, preferCache: Bool = false) async throws -> [FileInfo] { + func getFileList(path: [String] = [], suppressErrors: Bool = false, preferCache: Bool = false) async throws -> [FileInfo]? { guard let client = self.client else { throw HotlineClientError.notConnected } - + // Check cache first if preferred if preferCache, let cached = self.cachedFileList(for: path, ttl: self.fileSearchConfig.cacheTTL, allowStale: false) { return cached.items } - - let hotlineFiles = try await client.getFileList(path: path) + + let hotlineFiles: [HotlineFile] + do { + hotlineFiles = try await client.getFileList(path: path) + } + catch let error as HotlineClientError { + self.displayError(error, message: error.userMessage) + self.filesLoaded = true + return nil + } + let newFiles = hotlineFiles.map { FileInfo(hotlineFile: $0) } // Update UI state @@ -908,8 +913,7 @@ class HotlineState: Equatable { return true } catch let error as HotlineClientError { - self.errorMessage = error.userMessage - self.errorDisplayed = true + self.displayError(error, message: error.userMessage) } return false @@ -927,8 +931,7 @@ class HotlineState: Equatable { return true } catch let error as HotlineClientError { - self.errorMessage = error.userMessage - self.errorDisplayed = true + self.displayError(error, message: error.userMessage) } return false @@ -952,8 +955,7 @@ class HotlineState: Equatable { return true } catch let error as HotlineClientError { - self.errorMessage = error.userMessage - self.errorDisplayed = true + self.displayError(error, message: error.userMessage) } return false @@ -985,8 +987,7 @@ class HotlineState: Equatable { result = try await client.downloadFile(name: fileName, path: fullPath) } catch let error as HotlineClientError { - self.errorMessage = error.userMessage - self.errorDisplayed = true + self.displayError(error, message: error.userMessage) return } @@ -1351,6 +1352,8 @@ class HotlineState: Equatable { guard let client = self.client else { return } let fileName = fileURL.lastPathComponent + + print("UPLOAD FILE: \(fileName) \(fileURL)") guard fileURL.isFileURL, !fileName.isEmpty else { print("HotlineState: Not a valid file URL") @@ -1381,8 +1384,7 @@ class HotlineState: Equatable { referenceNumber = try await client.uploadFile(name: fileName, path: path) } catch let error as HotlineClientError { - self.errorMessage = error.userMessage - self.errorDisplayed = true + self.displayError(error, message: error.userMessage) return } diff --git a/Hotline/macOS/Board/MessageBoardView.swift b/Hotline/macOS/Board/MessageBoardView.swift index 710ff20..be039d8 100644 --- a/Hotline/macOS/Board/MessageBoardView.swift +++ b/Hotline/macOS/Board/MessageBoardView.swift @@ -8,16 +8,14 @@ struct MessageBoardView: View { var body: some View { NavigationStack { - if self.model.access?.contains(.canReadMessageBoard) != false { - if self.model.messageBoardLoaded && self.model.messageBoard.isEmpty { - self.emptyBoardView - } - else { - self.messageBoardView - } + if self.model.access?.contains(.canReadMessageBoard) != true { + self.disabledBoardView + } + else if self.model.messageBoardLoaded && self.model.messageBoard.isEmpty { + self.emptyBoardView } else { - self.disabledBoardView + self.messageBoardView } } .sheet(isPresented: $composerDisplayed) { @@ -32,7 +30,7 @@ struct MessageBoardView: View { } label: { Image(systemName: "square.and.pencil") } - .disabled(self.model.access?.contains(.canPostMessageBoard) == false) + .disabled((self.model.access?.contains(.canPostMessageBoard) != true) || (self.model.access?.contains(.canReadMessageBoard) != true)) .help("Post to Message Board") } } @@ -45,9 +43,9 @@ struct MessageBoardView: View { private var disabledBoardView: some View { ContentUnavailableView { - Label("Message Board Disabled", systemImage: "quote.bubble") + Label("No Message Board", systemImage: "quote.bubble") } description: { - Text("This server has turned off the message board") + Text("This server has turned off their message board") } } diff --git a/Hotline/macOS/Files/FolderItemView.swift b/Hotline/macOS/Files/FolderItemView.swift index 18522af..f82bb11 100644 --- a/Hotline/macOS/Files/FolderItemView.swift +++ b/Hotline/macOS/Files/FolderItemView.swift @@ -20,7 +20,7 @@ struct FolderItemView: View { self.model.uploadFile(url: fileURL, path: filePath) { info in Task { // Refresh file listing to display newly uploaded file. - let _ = try? await model.getFileList(path: filePath) + try? await model.getFileList(path: filePath) } } } diff --git a/Hotline/macOS/MessageView.swift b/Hotline/macOS/MessageView.swift index 6208d54..858aa37 100644 --- a/Hotline/macOS/MessageView.swift +++ b/Hotline/macOS/MessageView.swift @@ -30,9 +30,15 @@ struct MessageView: View { } .background(Color(nsColor: .textBackgroundColor)) .onAppear { - let user = self.model.users.first(where: { $0.id == userID }) + self.model.markInstantMessagesAsRead(userID: self.userID) + + let user = self.model.users.first(where: { $0.id == self.userID }) self.username = user?.name } + + .onAppear { + self.model.markInstantMessagesAsRead(userID: userID) + } .toolbar { if self.model.access?.contains(.canGetClientInfo) == true { ToolbarItem { diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index ff92acc..696e08d 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -111,6 +111,7 @@ struct ServerView: View { self.connectForm Spacer() } + .presentedWindowToolbarStyle(.unified(showsTitle: false)) .navigationTitle(self.model.serverTitle.isBlank ? "Hotline" : self.model.serverTitle) } else if self.model.status.isLoggingIn { @@ -249,6 +250,11 @@ struct ServerView: View { if menuItem.type == .chat { ListItemView(icon: menuItem.image, title: menuItem.name, unread: model.unreadPublicChat).tag(menuItem.type) } +// else if menuItem.type == .board { +// if self.model.access?.contains(.canReadMessageBoard) == true { +// ListItemView(icon: menuItem.image, title: menuItem.name, unread: false).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) @@ -355,40 +361,17 @@ struct ServerView: View { 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) - } } } + .navigationTitle(self.model.serverTitle) } // MARK: - @@ -538,7 +521,7 @@ struct ServerTransferRow: View { withAnimation(.snappy(duration: 0.25, extraBounce: 0.3)) { self.hovered = hovered } - self.detailsShown = hovered +// self.detailsShown = hovered } .onTapGesture(count: 2) { guard transfer.completed, let url = transfer.fileURL else { -- cgit