From ede41868962ffed386b0da694d14cdfe6cfdb34f Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Mon, 20 Oct 2025 22:15:48 -0700 Subject: Cleanup and a bunch of work to fix issues syncing, loading, and general bookmark states in TrackerView. Added search field (substring conjunctive filter on server/bookmark names and descriptions). --- Hotline/macOS/HotlinePanelView.swift | 24 +- Hotline/macOS/TrackerView.swift | 503 ++++++++++++++++++++++++----------- 2 files changed, 361 insertions(+), 166 deletions(-) (limited to 'Hotline/macOS') diff --git a/Hotline/macOS/HotlinePanelView.swift b/Hotline/macOS/HotlinePanelView.swift index dc58698..fd43c15 100644 --- a/Hotline/macOS/HotlinePanelView.swift +++ b/Hotline/macOS/HotlinePanelView.swift @@ -6,7 +6,7 @@ struct HotlinePanelView: View { var body: some View { VStack(spacing: 0) { - Image(nsImage: ApplicationState.shared.activeServerBanner ?? NSImage(named: "Default Banner")!) + Image(nsImage: AppState.shared.activeServerBanner ?? NSImage(named: "Default Banner")!) .interpolation(.high) .resizable() .scaledToFill() @@ -36,7 +36,7 @@ struct HotlinePanelView: View { .help("Hotline Servers") Button { - ApplicationState.shared.activeServerState?.selection = .chat + AppState.shared.activeServerState?.selection = .chat } label: { Image("Section Chat") @@ -45,11 +45,11 @@ struct HotlinePanelView: View { } .buttonStyle(.plain) .frame(width: 20, height: 20) - .disabled(ApplicationState.shared.activeServerState == nil) + .disabled(AppState.shared.activeServerState == nil) .help("Public Chat") Button { - ApplicationState.shared.activeServerState?.selection = .board + AppState.shared.activeServerState?.selection = .board } label: { Image("Section Board") @@ -58,11 +58,11 @@ struct HotlinePanelView: View { } .buttonStyle(.plain) .frame(width: 20, height: 20) - .disabled(ApplicationState.shared.activeServerState == nil) + .disabled(AppState.shared.activeServerState == nil) .help("Message Board") Button { - ApplicationState.shared.activeServerState?.selection = .news + AppState.shared.activeServerState?.selection = .news } label: { Image("Section News") @@ -71,11 +71,11 @@ struct HotlinePanelView: View { } .buttonStyle(.plain) .frame(width: 20, height: 20) - .disabled(ApplicationState.shared.activeServerState == nil || (ApplicationState.shared.activeHotline?.serverVersion ?? 0) < 151) + .disabled(AppState.shared.activeServerState == nil || (AppState.shared.activeHotline?.serverVersion ?? 0) < 151) .help("News") Button { - ApplicationState.shared.activeServerState?.selection = .files + AppState.shared.activeServerState?.selection = .files } label: { Image("Section Files") @@ -84,14 +84,14 @@ struct HotlinePanelView: View { } .buttonStyle(.plain) .frame(width: 20, height: 20) - .disabled(ApplicationState.shared.activeServerState == nil) + .disabled(AppState.shared.activeServerState == nil) .help("Files") Spacer() - if ApplicationState.shared.activeHotline?.access?.contains(.canOpenUsers) == true { + if AppState.shared.activeHotline?.access?.contains(.canOpenUsers) == true { Button { - ApplicationState.shared.activeServerState?.selection = .accounts + AppState.shared.activeServerState?.selection = .accounts } label: { Image("Section Users") @@ -100,7 +100,7 @@ struct HotlinePanelView: View { } .buttonStyle(.plain) .frame(width: 20, height: 20) - .disabled(ApplicationState.shared.activeServerState == nil) + .disabled(AppState.shared.activeServerState == nil) .help("Accounts") } diff --git a/Hotline/macOS/TrackerView.swift b/Hotline/macOS/TrackerView.swift index 0fac8b0..2fbce85 100644 --- a/Hotline/macOS/TrackerView.swift +++ b/Hotline/macOS/TrackerView.swift @@ -3,6 +3,18 @@ import SwiftData import Foundation import UniformTypeIdentifiers +enum TrackerSelection: Hashable { + case bookmark(Bookmark) + case bookmarkServer(BookmarkServer) + + var server: Server? { + switch self { + case .bookmark(let b): return b.server + case .bookmarkServer(let t): return t.server + } + } +} + struct TrackerView: View { @Environment(\.colorScheme) private var colorScheme @Environment(\.openWindow) private var openWindow @@ -16,22 +28,78 @@ struct TrackerView: View { @State private var fileDropActive = false @State private var bookmarkExportActive = false @State private var bookmarkExport: BookmarkDocument? = nil - + @State private var expandedTrackers: Set = [] + @State private var trackerServers: [Bookmark: [BookmarkServer]] = [:] + @State private var loadingTrackers: Set = [] + @State private var searchText: String = "" + @Query(sort: \Bookmark.order) private var bookmarks: [Bookmark] - @Binding var selection: Bookmark? + @Binding var selection: TrackerSelection? + + private var filteredBookmarks: [Bookmark] { + guard !self.searchText.isEmpty else { + return self.bookmarks + } + + let searchWords = self.searchText.lowercased().split(separator: " ").map(String.init) + + return self.bookmarks.filter { bookmark in + // Always show tracker bookmarks (filter only their servers) + if bookmark.type == .tracker { + return true + } + + // Filter server bookmarks by search text + return self.bookmarkMatchesSearch(bookmark, searchWords: searchWords) + } + } + + private func bookmarkMatchesSearch(_ bookmark: Bookmark, searchWords: [String]) -> Bool { + let searchableText = "\(bookmark.name) \(bookmark.address)".lowercased() + + // All search words must match + return searchWords.allSatisfy { word in + searchableText.contains(word) + } + } + + private func filteredServers(for bookmark: Bookmark) -> [BookmarkServer] { + let servers = self.trackerServers[bookmark] ?? [] + + guard !self.searchText.isEmpty else { + return servers + } + + let searchWords = self.searchText.lowercased().split(separator: " ").map(String.init) + + return servers.filter { server in + let searchableText = "\(server.name ?? "") \(server.address) \(server.description ?? "")".lowercased() + + // All search words must match + return searchWords.allSatisfy { word in + searchableText.contains(word) + } + } + } var body: some View { List(selection: $selection) { - ForEach(bookmarks, id: \.self) { bookmark in - TrackerItemView(bookmark: bookmark) - .tag(bookmark) - - if bookmark.type == .tracker && bookmark.expanded { - ForEach(bookmark.servers, id: \.self) { trackedServer in - TrackerItemView(bookmark: trackedServer) + ForEach(filteredBookmarks, id: \.self) { bookmark in + TrackerItemView( + bookmark: bookmark, + isExpanded: self.expandedTrackers.contains(bookmark), + isLoading: self.loadingTrackers.contains(bookmark) + ) { + self.toggleExpanded(for: bookmark) + } + .tag(TrackerSelection.bookmark(bookmark)) + + if bookmark.type == .tracker && self.expandedTrackers.contains(bookmark) { + ForEach(self.filteredServers(for: bookmark), id: \.self) { trackedServer in + TrackerBookmarkServerView(server: trackedServer) .moveDisabled(true) .deleteDisabled(true) - .tag(trackedServer) + .tag(TrackerSelection.bookmarkServer(trackedServer)) } } } @@ -43,15 +111,22 @@ struct TrackerView: View { } } .onDeleteCommand { - if let bookmark = selection, - bookmark.type != .temporary { + switch self.selection { + case .bookmark(let bookmark): Bookmark.delete(bookmark, context: modelContext) + default: + break } + +// if let bookmark = selection, +// bookmark.type != .temporary { +// Bookmark.delete(bookmark, context: modelContext) +// } } .environment(\.defaultMinListRowHeight, 34) .listStyle(.inset) .alternatingRowBackgrounds(.enabled) - .onChange(of: ApplicationState.shared.cloudKitReady) { + .onChange(of: AppState.shared.cloudKitReady) { if attemptedPrepopulate { print("Tracker: Already attempted to prepopulate bookmarks") return @@ -67,53 +142,13 @@ struct TrackerView: View { .onAppear { // Bookmark.deleteAll(context: modelContext) } - .contextMenu(forSelectionType: Bookmark.self) { items in + .contextMenu(forSelectionType: TrackerSelection.self) { items in if let item = items.first { - if item.type == .temporary { - Button { - let newBookmark = Bookmark(type: .server, name: item.name, address: item.address, port: item.port, login: item.login, password: item.password) - Bookmark.add(newBookmark, context: modelContext) - } label: { - Label("Bookmark", systemImage: "bookmark") - } - - Divider() - } - - Button { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(item.displayAddress, forType: .string) - } label: { - Label("Copy Address", systemImage: "doc.on.doc") - } - - if item.type == .tracker || item.type == .server { - Divider() - - if item.type == .tracker { - Button { - trackerSheetBookmark = item - } label: { - Label("Edit Tracker...", systemImage: "pencil") - } - } - - if item.type == .server { - Button { - bookmarkExport = BookmarkDocument(bookmark: item) - bookmarkExportActive = true - } label: { - Label("Export Bookmark...", systemImage: "bookmark.square") - } - } - - Divider() - - Button { - Bookmark.delete(item, context: modelContext) - } label: { - Label(item.type == .tracker ? "Delete Tracker" : "Delete Bookmark", systemImage: "trash") - } + switch item { + case .bookmark(let bookmark): + self.bookmarkContextMenu(bookmark) + case .bookmarkServer(let server): + self.bookmarkServerContextMenu(server) } } } primaryAction: { items in @@ -121,17 +156,37 @@ struct TrackerView: View { return } - if clickedItem.type == .tracker { - if NSEvent.modifierFlags.contains(.option) { - trackerSheetBookmark = clickedItem + switch clickedItem { + case .bookmark(let bookmark): + if bookmark.type == .server { + if let s = bookmark.server { + openWindow(id: "server", value: s) + } } - else { - clickedItem.expanded.toggle() + else if bookmark.type == .tracker { + if NSEvent.modifierFlags.contains(.option) { + trackerSheetBookmark = bookmark + } + else { + self.toggleExpanded(for: bookmark) + } } + + case .bookmarkServer(let bookmarkServer): + openWindow(id: "server", value: bookmarkServer.server) } - else if let server = clickedItem.server { - openWindow(id: "server", value: server) - } + +// if clickedItem.type == .tracker { +// if NSEvent.modifierFlags.contains(.option) { +// trackerSheetBookmark = clickedItem +// } +// else { +// clickedItem.expanded.toggle() +// } +// } +// else if let server = clickedItem.server { +// openWindow(id: "server", value: server) +// } } .fileExporter(isPresented: $bookmarkExportActive, document: bookmarkExport, contentTypes: [.data], defaultFilename: "\(bookmarkExport?.bookmark.name ?? "Hotline Bookmark").hlbm", onCompletion: { result in switch result { @@ -145,21 +200,41 @@ struct TrackerView: View { bookmarkExportActive = false }, onCancellation: {}) .onKeyPress(.rightArrow) { - if - let bookmark = selection, - bookmark.type == .tracker { - bookmark.expanded = true - return .handled + switch self.selection { + case .bookmark(let bookmark): + if bookmark.type == .tracker { + self.expandedTrackers.insert(bookmark) + return .handled + } + default: + break } + +// if +// let bookmark = selection, +// bookmark.type == .tracker { +// bookmark.expanded = true +// return .handled +// } return .ignored } .onKeyPress(.leftArrow) { - if - let bookmark = selection, - bookmark.type == .tracker { - bookmark.expanded = false - return .handled + switch self.selection { + case .bookmark(let bookmark): + if bookmark.type == .tracker { + self.expandedTrackers.remove(bookmark) + return .handled + } + default: + break } + +// if +// let bookmark = selection, +// bookmark.type == .tracker { +// bookmark.expanded = false +// return .handled +// } return .ignored } .onDrop(of: [UTType.fileURL], isTargeted: $fileDropActive) { providers, dropPoint in @@ -198,20 +273,26 @@ struct TrackerView: View { .navigationTitle("Servers") .toolbar { ToolbarItem(placement: .navigation) { - Image("Hotline") - .resizable() - .renderingMode(.template) - .scaledToFit() - .foregroundColor(Color(hex: 0xE10000)) - .frame(width: 9) - .opacity(controlActiveState == .inactive ? 0.5 : 1.0) + let image = Image("Hotline") + .resizable() + .renderingMode(.template) + .scaledToFit() + .foregroundColor(Color(hex: 0xE10000)) + .frame(width: 9) + .opacity(controlActiveState == .inactive ? 0.5 : 1.0) + +// if #available(macOS 26, *) { +// image.sharedBackgroundVisibility(.hidden) +// } else { + image +// } } ToolbarItem(placement: .primaryAction) { Button { - refreshing = true - refresh() - refreshing = false + self.refreshing = true + self.refresh() + self.refreshing = false } label: { Label("Refresh", systemImage: "arrow.clockwise") } @@ -242,38 +323,142 @@ struct TrackerView: View { openWindow(id: "server", value: s) } }) + .searchable(text: $searchText, placement: .automatic, prompt: "Search") } + @ViewBuilder + func bookmarkServerContextMenu(_ server: BookmarkServer) -> some View { + Button { + let newBookmark = Bookmark(type: .server, name: server.name ?? server.address, address: server.address, port: server.port, login: nil, password: nil) + Bookmark.add(newBookmark, context: modelContext) + } label: { + Label("Bookmark", systemImage: "bookmark") + } + + Divider() + + Button { + NSPasteboard.general.clearContents() + let displayAddress = server.port == HotlinePorts.DefaultServerPort ? + server.address : "\(server.address):\(server.port)" + NSPasteboard.general.setString(displayAddress, forType: .string) + } label: { + Label("Copy Address", systemImage: "doc.on.doc") + } + } + + @ViewBuilder + func bookmarkContextMenu(_ bookmark: Bookmark) -> some View { + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(bookmark.displayAddress, forType: .string) + } label: { + Label("Copy Address", systemImage: "doc.on.doc") + } + + Divider() + + if bookmark.type == .tracker { + Button { + trackerSheetBookmark = bookmark + } label: { + Label("Edit Tracker...", systemImage: "pencil") + } + } + + if bookmark.type == .server { + Button { + bookmarkExport = BookmarkDocument(bookmark: bookmark) + bookmarkExportActive = true + } label: { + Label("Export Bookmark...", systemImage: "bookmark.square") + } + } + + Divider() + + Button { + Bookmark.delete(bookmark, context: modelContext) + } label: { + Label(bookmark.type == .tracker ? "Delete Tracker" : "Delete Bookmark", systemImage: "trash") + } + } + + func refresh() { // When a tracker is selected, refresh only that tracker. - if - let selectedBookmark = selection, - selectedBookmark.type == .tracker { - if !selectedBookmark.expanded { - selectedBookmark.expanded = true - } - else { - Task { - await selectedBookmark.fetchServers() + if let trackerSelection = self.selection { + switch trackerSelection { + case .bookmark(let bookmark): + if bookmark.type == .tracker { + if self.expandedTrackers.contains(bookmark) { + // Already expanded, just refresh the servers + Task { + await self.fetchServers(for: bookmark) + } + } else { + // Not expanded, expand it (which also fetches) + self.setExpanded(true, for: bookmark) + } } + return + default: + break } - return } - + // Otherwise refresh/expand all trackers. for bookmark in self.bookmarks { if bookmark.type == .tracker { - if !bookmark.expanded { - bookmark.expanded = true - } - else { + if self.expandedTrackers.contains(bookmark) { + // Already expanded, just refresh the servers Task { - await bookmark.fetchServers() + await self.fetchServers(for: bookmark) } + } else { + // Not expanded, expand it (which also fetches) + self.setExpanded(true, for: bookmark) } } } } + + func toggleExpanded(for bookmark: Bookmark) { + guard bookmark.type == .tracker else { return } + + if self.expandedTrackers.contains(bookmark) { + self.expandedTrackers.remove(bookmark) + self.trackerServers[bookmark] = nil + } else { + self.expandedTrackers.insert(bookmark) + Task { + await self.fetchServers(for: bookmark) + } + } + } + + func setExpanded(_ expanded: Bool, for bookmark: Bookmark) { + guard bookmark.type == .tracker else { return } + + if expanded && !self.expandedTrackers.contains(bookmark) { + self.expandedTrackers.insert(bookmark) + Task { + await self.fetchServers(for: bookmark) + } + } else if !expanded && self.expandedTrackers.contains(bookmark) { + self.expandedTrackers.remove(bookmark) + self.trackerServers[bookmark] = nil + } + } + + private func fetchServers(for bookmark: Bookmark) async { + self.loadingTrackers.insert(bookmark) + let servers = await bookmark.fetchServers() + await MainActor.run { + self.trackerServers[bookmark] = servers + self.loadingTrackers.remove(bookmark) + } + } } struct TrackerBookmarkSheet: View { @@ -359,18 +544,60 @@ struct TrackerBookmarkSheet: View { } } +struct TrackerBookmarkServerView: View { + let server: BookmarkServer + + var body: some View { + HStack(alignment: .center, spacing: 6) { + Spacer() + .frame(width: 14 + 8 + 16) + Image("Server") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16, alignment: .center) + Text(self.server.name ?? "Server").lineLimit(1).truncationMode(.tail) + if let serverDescription = self.server.description { + Text(serverDescription) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + Spacer(minLength: 0) + if self.server.users > 0 { + Text(String(self.server.users)) + .foregroundStyle(.secondary) + .lineLimit(1) + + Circle() + .fill(.fileComplete) + .frame(width: 7, height: 7) + .keyframeAnimator(initialValue: 1.0, repeating: true) { content, opacity in + content.opacity(opacity) + } keyframes: { _ in + CubicKeyframe(1.0, duration: 2.0) // Stay visible for 1 second + CubicKeyframe(0.75, duration: 0.5) // Fade out quickly + CubicKeyframe(1.0, duration: 0.5) // Fade in quickly + } + .padding(.trailing, 6) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + struct TrackerItemView: View { let bookmark: Bookmark - - @State private var onlineAnimationMaxState: Bool = true - + let isExpanded: Bool + let isLoading: Bool + let onToggleExpanded: () -> Void + var body: some View { HStack(alignment: .center, spacing: 6) { if bookmark.type == .tracker { Button { - bookmark.expanded.toggle() + self.onToggleExpanded() } label: { - Text(Image(systemName: bookmark.expanded ? "chevron.down" : "chevron.right")) + Text(Image(systemName: self.isExpanded ? "chevron.down" : "chevron.right")) .bold() .font(.system(size: 10)) .opacity(0.5) @@ -381,7 +608,7 @@ struct TrackerItemView: View { .padding(.leading, 4) .padding(.trailing, 2) } - + switch bookmark.type { case .tracker: Image("Tracker") @@ -389,7 +616,7 @@ struct TrackerItemView: View { .scaledToFit() .frame(width: 16, height: 16, alignment: .center) Text(bookmark.name).bold().lineLimit(1).truncationMode(.tail) - if bookmark.loading { + if isLoading { ProgressView() .padding([.leading, .trailing], 2) .controlSize(.small) @@ -410,58 +637,26 @@ struct TrackerItemView: View { .frame(width: 16, height: 16, alignment: .center) Text(bookmark.name).lineLimit(1).truncationMode(.tail) Spacer(minLength: 0) - case .temporary: - Spacer() - .frame(width: 14 + 8 + 16) - Image("Server") - .resizable() - .scaledToFit() - .frame(width: 16, height: 16, alignment: .center) - Text(bookmark.name).lineLimit(1).truncationMode(.tail) - if let serverDescription = bookmark.serverDescription { - Text(serverDescription) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) - } - Spacer(minLength: 0) - if let serverUserCount = bookmark.serverUserCount, - serverUserCount > 0 { - Text(String(serverUserCount)) - .foregroundStyle(.secondary) - .lineLimit(1) - - Circle() - .fill(.fileComplete) - .frame(width: 7, height: 7) - .opacity(onlineAnimationMaxState ? 1.0 : 0.2) - .onAppear { - withAnimation(.easeInOut(duration: 1.0).repeatForever()) { - onlineAnimationMaxState.toggle() - } - } - .padding(.trailing, 6) - } } } .frame(maxWidth: .infinity, maxHeight: .infinity) - .onChange(of: bookmark.expanded) { - guard bookmark.type == .tracker else { - return - } - - if bookmark.expanded { - Task { - await bookmark.fetchServers() - } - } - } +// .onChange(of: self.isExpanded) { +// guard bookmark.type == .tracker else { +// return +// } +// +// if self.isExpanded { +// Task { +// await bookmark.fetchServers() +// } +// } +// } } } #if DEBUG private struct TrackerViewPreview: View { - @State var selection: Bookmark? = nil + @State var selection: TrackerSelection? = nil var body: some View { TrackerView(selection: $selection) -- cgit From 926c92be7a32e374c8855b2eb8b5a49485dec072 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Tue, 21 Oct 2025 10:17:23 -0700 Subject: Add bookmark editing to TrackerView (you can add login/pass to bookmarks now). Fix macOS 26 border around images in toolbar. --- Hotline/Models/Bookmark.swift | 2 +- Hotline/macOS/ServerView.swift | 26 +++-- Hotline/macOS/TrackerView.swift | 205 +++++++++++++++++++++++++++++----------- 3 files changed, 167 insertions(+), 66 deletions(-) (limited to 'Hotline/macOS') diff --git a/Hotline/Models/Bookmark.swift b/Hotline/Models/Bookmark.swift index 394f9f6..80963f9 100644 --- a/Hotline/Models/Bookmark.swift +++ b/Hotline/Models/Bookmark.swift @@ -75,7 +75,7 @@ final class Bookmark { } static let DefaultBookmarks: [Bookmark] = [ - Bookmark(type: .server, name: "The Mobius Strip", address: "67.174.208.111", port: HotlinePorts.DefaultServerPort), + Bookmark(type: .server, name: "The Mobius Strip", address: "24.6.82.54", port: HotlinePorts.DefaultServerPort), Bookmark(type: .server, name: "System 7 Today", address: "hotline.system7today.com", port: HotlinePorts.DefaultServerPort), Bookmark(type: .tracker, name: "Featured Servers", address: "hltracker.com", port: HotlinePorts.DefaultTrackerPort) ] diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index e9f4a96..154bd7c 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -212,14 +212,24 @@ struct ServerView: View { .onChange(of: Prefs.shared.enableAutomaticMessage) { sendPreferences() } .onChange(of: Prefs.shared.automaticMessage) { sendPreferences() } .toolbar { - ToolbarItem(placement: .navigation) { - Image("Server Large") -// .renderingMode(.template) - - .resizable() - .scaledToFit() - .frame(width: 28) - .opacity(controlActiveState == .inactive ? 0.4 : 1.0) + if #available(macOS 26.0, *) { + ToolbarItem(placement: .navigation) { + Image("Server Large") + .resizable() + .scaledToFit() + .frame(width: 28) + .opacity(controlActiveState == .inactive ? 0.4 : 1.0) + } + .sharedBackgroundVisibility(.hidden) + } + else { + ToolbarItem(placement: .navigation) { + Image("Server Large") + .resizable() + .scaledToFit() + .frame(width: 28) + .opacity(controlActiveState == .inactive ? 0.4 : 1.0) + } } } } diff --git a/Hotline/macOS/TrackerView.swift b/Hotline/macOS/TrackerView.swift index 2fbce85..83a56b8 100644 --- a/Hotline/macOS/TrackerView.swift +++ b/Hotline/macOS/TrackerView.swift @@ -24,6 +24,7 @@ struct TrackerView: View { @State private var refreshing = false @State private var trackerSheetPresented: Bool = false @State private var trackerSheetBookmark: Bookmark? = nil + @State private var serverSheetBookmark: Bookmark? = nil @State private var attemptedPrepopulate: Bool = false @State private var fileDropActive = false @State private var bookmarkExportActive = false @@ -270,22 +271,21 @@ struct TrackerView: View { .sheet(isPresented: $trackerSheetPresented) { TrackerBookmarkSheet() } + .sheet(item: $serverSheetBookmark) { item in + ServerBookmarkSheet(item) + } .navigationTitle("Servers") .toolbar { - ToolbarItem(placement: .navigation) { - let image = Image("Hotline") - .resizable() - .renderingMode(.template) - .scaledToFit() - .foregroundColor(Color(hex: 0xE10000)) - .frame(width: 9) - .opacity(controlActiveState == .inactive ? 0.5 : 1.0) - -// if #available(macOS 26, *) { -// image.sharedBackgroundVisibility(.hidden) -// } else { - image -// } + if #available(macOS 26.0, *) { + ToolbarItem(placement: .navigation) { + self.hotlineLogoImage + } + .sharedBackgroundVisibility(.hidden) + } + else { + ToolbarItem(placement: .navigation) { + self.hotlineLogoImage + } } ToolbarItem(placement: .primaryAction) { @@ -326,6 +326,16 @@ struct TrackerView: View { .searchable(text: $searchText, placement: .automatic, prompt: "Search") } + private var hotlineLogoImage: some View { + Image("Hotline") + .resizable() + .renderingMode(.template) + .scaledToFit() + .foregroundColor(Color(hex: 0xE10000)) + .frame(width: 9) + .opacity(controlActiveState == .inactive ? 0.5 : 1.0) + } + @ViewBuilder func bookmarkServerContextMenu(_ server: BookmarkServer) -> some View { Button { @@ -367,11 +377,17 @@ struct TrackerView: View { } if bookmark.type == .server { + Button { + serverSheetBookmark = bookmark + } label: { + Label("Edit Bookmark...", systemImage: "pencil") + } + Button { bookmarkExport = BookmarkDocument(bookmark: bookmark) bookmarkExportActive = true } label: { - Label("Export Bookmark...", systemImage: "bookmark.square") + Label("Export Bookmark...", systemImage: "square.and.arrow.down") } } @@ -481,9 +497,12 @@ struct TrackerBookmarkSheet: View { var body: some View { VStack(alignment: .leading) { - Text("Type the address and name of a Hotline Tracker:") - .foregroundStyle(.secondary) - .padding(.bottom, 8) + if self.bookmark == nil { + Text("Type the address and name of a Hotline Tracker:") + .foregroundStyle(.secondary) + .padding(.bottom, 8) + } + Form { Group { TextField(text: $trackerAddress) { @@ -502,33 +521,14 @@ struct TrackerBookmarkSheet: View { .padding() .toolbar { ToolbarItem(placement: .confirmationAction) { - Button(self.bookmark != nil ? "Save Tracker" : "Add Tracker") { - var displayName = trackerName.trimmingCharacters(in: .whitespacesAndNewlines) - let (host, port) = Tracker.parseTrackerAddressAndPort(trackerAddress) - - if displayName.isEmpty { - displayName = host + Button { + self.saveTracker() + } label: { + if self.bookmark != nil { + Text("Save Tracker") } - - if !displayName.isEmpty && !host.isEmpty { - if !host.isEmpty { - if self.bookmark != nil { - // We're editing an existing bookmark. - self.bookmark?.name = displayName - self.bookmark?.address = host - self.bookmark?.port = port - } - else { - // We're creating a new bookmark. - let newBookmark = Bookmark(type: .tracker, name: displayName, address: host, port: port) - Bookmark.add(newBookmark, context: modelContext) - } - - self.trackerName = "" - self.trackerAddress = "" - - dismiss() - } + else { + Text("Add Tracker") } } } @@ -537,7 +537,109 @@ struct TrackerBookmarkSheet: View { self.trackerName = "" self.trackerAddress = "" - dismiss() + self.dismiss() + } + } + } + } + + private func saveTracker() { + var displayName = trackerName.trimmingCharacters(in: .whitespacesAndNewlines) + let (host, port) = Tracker.parseTrackerAddressAndPort(trackerAddress) + + if displayName.isEmpty { + displayName = host + } + + if !displayName.isEmpty && !host.isEmpty { + if !host.isEmpty { + if self.bookmark != nil { + // We're editing an existing bookmark. + self.bookmark?.name = displayName + self.bookmark?.address = host + self.bookmark?.port = port + } + else { + // We're creating a new bookmark. + let newBookmark = Bookmark(type: .tracker, name: displayName, address: host, port: port) + Bookmark.add(newBookmark, context: modelContext) + } + + self.trackerName = "" + self.trackerAddress = "" + + self.dismiss() + } + } + } +} + +struct ServerBookmarkSheet: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + + @State private var bookmark: Bookmark + @State private var serverName: String = "" + @State private var serverAddress: String = "" + @State private var serverLogin: String = "" + @State private var serverPassword: String = "" + + init(_ editingBookmark: Bookmark) { + _bookmark = .init(initialValue: editingBookmark) + _serverName = .init(initialValue: editingBookmark.name) + _serverAddress = .init(initialValue: editingBookmark.displayAddress) + _serverLogin = .init(initialValue: editingBookmark.login ?? "") + _serverPassword = .init(initialValue: editingBookmark.password ?? "") + } + + var body: some View { + VStack(alignment: .leading) { + Form { + Group { + TextField(text: $serverName) { + Text("Name:") + } + .padding(.bottom) + + TextField(text: $serverAddress) { + Text("Address:") + } + TextField(text: $serverLogin, prompt: Text("Optional")) { + Text("Login:") + } + SecureField(text: $serverPassword, prompt: Text("Optional")) { + Text("Password:") + } + } + .textFieldStyle(.roundedBorder) + .controlSize(.large) + } + } + .frame(width: 300) + .fixedSize(horizontal: true, vertical: true) + .padding() + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Save Bookmark") { + let displayName = self.serverName.trimmingCharacters(in: .whitespacesAndNewlines) + let (host, port) = Server.parseServerAddressAndPort(self.serverAddress) + let login = self.serverLogin.trimmingCharacters(in: .whitespacesAndNewlines) + let password = self.serverPassword + + if !displayName.isEmpty && !host.isEmpty { + self.bookmark.name = displayName + self.bookmark.address = host + self.bookmark.port = port + self.bookmark.login = login.isEmpty ? nil : login + self.bookmark.password = password.isEmpty ? nil : password + + self.dismiss() + } + } + } + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + self.dismiss() } } } @@ -575,7 +677,7 @@ struct TrackerBookmarkServerView: View { content.opacity(opacity) } keyframes: { _ in CubicKeyframe(1.0, duration: 2.0) // Stay visible for 1 second - CubicKeyframe(0.75, duration: 0.5) // Fade out quickly + CubicKeyframe(0.6, duration: 0.5) // Fade out quickly CubicKeyframe(1.0, duration: 0.5) // Fade in quickly } .padding(.trailing, 6) @@ -640,17 +742,6 @@ struct TrackerItemView: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity) -// .onChange(of: self.isExpanded) { -// guard bookmark.type == .tracker else { -// return -// } -// -// if self.isExpanded { -// Task { -// await bookmark.fetchServers() -// } -// } -// } } } -- cgit From 54b85a88efcd455d5c12b7ca65d425d095a675f1 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Tue, 21 Oct 2025 23:09:53 -0700 Subject: Add initial support for folder downloading. --- Hotline/Hotline/HotlineClient.swift | 26 +- Hotline/Hotline/HotlineTransferClient.swift | 880 +++++++++++++++++++++++++++- Hotline/Models/Hotline.swift | 76 ++- Hotline/Models/TransferInfo.swift | 5 +- Hotline/macOS/FilesView.swift | 17 +- Hotline/macOS/ServerView.swift | 20 +- Research/HLProtocol.pdf | Bin 0 -> 1117544 bytes 7 files changed, 985 insertions(+), 39 deletions(-) create mode 100644 Research/HLProtocol.pdf (limited to 'Hotline/macOS') diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index 434285f..99aa167 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -804,9 +804,33 @@ class HotlineClient: NetSocketDelegate { } } + @MainActor func sendDownloadFolder(name folderName: String, path folderPath: [String], callback: ((Bool, UInt32?, Int?, Int?, Int?) -> Void)? = nil) { + var t = HotlineTransaction(type: .downloadFolder) + t.setFieldString(type: .fileName, val: folderName) + t.setFieldPath(type: .filePath, val: folderPath) + + self.sendPacket(t) { reply, err in + guard err == nil, + let transferSizeField = reply.getField(type: .transferSize), + let transferSize = transferSizeField.getInteger(), + let transferReferenceField = reply.getField(type: .referenceNumber), + let referenceNumber = transferReferenceField.getUInt32() else { + callback?(false, nil, nil, nil, nil) + return + } + + let folderItemCountField = reply.getField(type: .folderItemCount) + let folderItemCount = folderItemCountField?.getInteger() + let transferWaitingCountField = reply.getField(type: .waitingCount) + let transferWaitingCount = transferWaitingCountField?.getInteger() + + callback?(true, referenceNumber, transferSize, folderItemCount, transferWaitingCount) + } + } + @MainActor func sendDownloadBanner(callback: ((Bool, UInt32?, Int?) -> Void)? = nil) { let t = HotlineTransaction(type: .downloadBanner) - + self.sendPacket(t) { reply, err in guard err == nil, let transferSizeField = reply.getField(type: .transferSize), diff --git a/Hotline/Hotline/HotlineTransferClient.swift b/Hotline/Hotline/HotlineTransferClient.swift index 4322c70..40dd598 100644 --- a/Hotline/Hotline/HotlineTransferClient.swift +++ b/Hotline/Hotline/HotlineTransferClient.swift @@ -51,6 +51,11 @@ protocol HotlineFileUploadClientDelegate: HotlineTransferDelegate { func hotlineFileUploadComplete(client: HotlineFileUploadClient, reference: UInt32) } +protocol HotlineFolderDownloadClientDelegate: HotlineTransferDelegate { + func hotlineFolderDownloadReceivedFileInfo(client: HotlineFolderDownloadClient, reference: UInt32, fileName: String, itemNumber: Int, totalItems: Int) + func hotlineFolderDownloadComplete(client: HotlineFolderDownloadClient, reference: UInt32, at: URL) +} + enum HotlineFileTransferStage: Int { case fileHeader = 1 case fileForkHeader = 2 @@ -72,6 +77,23 @@ enum HotlineFileUploadStage: Int { case fileComplete = 9 } +enum HotlineFolderDownloadStage: Int { + case itemHeader = 0 // Read 2-byte length + item header + case waitingForFileSize = 1 // Read 4-byte file size before FILP + case fileHeader = 2 + case fileForkHeader = 3 + case fileInfoFork = 4 + case fileDataFork = 5 + case fileResourceFork = 6 + case fileUnsupportedFork = 7 +} + +private enum HotlineFolderAction: UInt16 { + case sendFile = 1 + case resumeFile = 2 + case nextFile = 3 +} + protocol HotlineTransferClient { var serverAddress: NWEndpoint.Host { get } var serverPort: NWEndpoint.Port { get } @@ -242,7 +264,7 @@ class HotlineFileUploadClient: HotlineTransferClient { } self.status = .completing - + c.send(content: nil, contentContext: .finalMessage, completion: .contentProcessed({ error in })) } @@ -291,11 +313,11 @@ class HotlineFileUploadClient: HotlineTransferClient { self.payloadSize UInt32.zero } -// var magicData = Data() -// magicData.appendUInt32("HTXF".fourCharCode()) -// magicData.appendUInt32(self.referenceNumber) -// magicData.appendUInt32(self.payloadSize) -// magicData.appendUInt32(0) + // var magicData = Data() + // magicData.appendUInt32("HTXF".fourCharCode()) + // magicData.appendUInt32(self.referenceNumber) + // magicData.appendUInt32(self.payloadSize) + // magicData.appendUInt32(0) self.stage = .fileHeader self.sendFileData(magicData) @@ -359,7 +381,7 @@ class HotlineFileUploadClient: HotlineTransferClient { self.fileHandle = nil fallthrough } - + print("Upload: Sending data Fork \(String(describing: fileData?.count))") self.sendFileData(fileData!) } @@ -561,7 +583,7 @@ class HotlineFilePreviewClient: HotlineTransferClient { guard let c = self.connection else { return } - + c.receive(minimumIncompleteLength: 1, maximumLength: Int(UInt16.max)) { [weak self] (data, context, isComplete, error) in guard let self = self else { return @@ -644,12 +666,12 @@ class HotlineFileDownloadClient: HotlineTransferClient { deinit { self.invalidate() } - + func start() { guard self.status == .unconnected else { return } - + self.filePath = nil self.connect() } @@ -810,7 +832,7 @@ class HotlineFileDownloadClient: HotlineTransferClient { } case .fileForkHeader: if let forkHeader = HotlineFileForkHeader(from: self.fileBytes) { -// let fileForkHeader = HotlineFileForkHeader(from: self.fileBytes) + // let fileForkHeader = HotlineFileForkHeader(from: self.fileBytes) self.fileBytes.removeSubrange(0..= HotlineFileInfoFork.BaseDataSize else { @@ -1273,3 +1295,829 @@ struct HotlineFileInfoFork { return data } } + +// MARK: - + +class HotlineFolderDownloadClient: HotlineTransferClient { + let serverAddress: NWEndpoint.Host + let serverPort: NWEndpoint.Port + let referenceNumber: UInt32 + + private var connection: NWConnection? + private var transferStage: HotlineFolderDownloadStage = .fileHeader + + weak var delegate: HotlineFolderDownloadClientDelegate? = nil + + var status: HotlineTransferStatus = .unconnected { + didSet { + DispatchQueue.main.async { + self.delegate?.hotlineTransferStatusChanged(client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0) + } + } + } + + private let referenceDataSize: UInt32 + private let folderItemCount: Int + private var fileBytes = Data() + private var fileResourceBytes = Data() + + private var fileHeader: HotlineFileHeader? = nil + private var fileCurrentForkHeader: HotlineFileForkHeader? = nil + private var fileCurrentForkBytesLeft: Int = 0 + private var fileForksRemaining: Int = 0 + private var currentFileSize: UInt32 = 0 // Size of current file from server + private var currentFileBytesRead: Int = 0 // Bytes read for current file + private var fileInfoFork: HotlineFileInfoFork? = nil + private var fileHandle: FileHandle? = nil + private var currentFilePath: String? = nil + private var fileBytesTransferred: Int = 0 + private var fileProgress: Progress + + private var currentItemNumber: Int = 0 + private var completedItemCount: Int = 0 // Track actually completed files + private var folderPath: String? = nil + private var currentItemRelativePath: [String] = [] + private var currentFileName: String? = nil + + private let FILP_MAGIC: UInt32 = "FILP".fourCharCode() + + init(address: String, port: UInt16, reference: UInt32, size: UInt32, itemCount: Int) { + self.serverAddress = NWEndpoint.Host(address) + self.serverPort = NWEndpoint.Port(rawValue: port + 1)! + self.referenceNumber = reference + self.referenceDataSize = size + self.folderItemCount = itemCount + self.transferStage = .fileHeader + self.fileProgress = Progress(totalUnitCount: Int64(size)) + } + + deinit { + self.invalidate() + } + + func start() { + guard self.status == .unconnected else { + return + } + + self.folderPath = nil + self.connect() + } + + func start(to folderURL: URL) { + print("HotlineFolderDownloadClient: start(to:) called with path: \(folderURL.path)") + guard self.status == .unconnected else { + print("HotlineFolderDownloadClient: Already connected, status: \(self.status)") + return + } + + self.folderPath = folderURL.path + print("HotlineFolderDownloadClient: Calling connect()") + self.connect() + } + + func cancel() { + self.delegate = nil + + if self.status == .unconnected { + return + } + + // Close file before we try to potentially delete it. + if let fh = self.fileHandle { + try? fh.close() + self.fileHandle = nil + } + + if let downloadPath = self.folderPath { + print("HotlineFolderDownloadClient: Deleting folder fragment at", downloadPath) + if FileManager.default.isDeletableFile(atPath: downloadPath) { + try? FileManager.default.removeItem(atPath: downloadPath) + } + self.folderPath = nil + } + + self.invalidate() + + print("HotlineFolderDownloadClient: Cancelled transfer") + } + + private func connect() { + print("HotlineFolderDownloadClient: connect() called, connecting to \(self.serverAddress):\(self.serverPort)") + self.connection = NWConnection(host: self.serverAddress, port: self.serverPort, using: .tcp) + print("HotlineFolderDownloadClient: NWConnection created") + self.connection?.stateUpdateHandler = { [weak self] (newState: NWConnection.State) in + switch newState { + case .ready: + print("HotlineFolderDownloadClient: Connection ready!") + self?.status = .connected + self?.sendMagic() + case .waiting(let err): + print("HotlineFolderDownloadClient: Waiting", err) + case .cancelled: + print("HotlineFolderDownloadClient: Cancelled") + self?.invalidate() + case .failed(let err): + print("HotlineFolderDownloadClient: Connection error \(err)") + switch self?.status { + case .connecting: + print("HotlineFolderDownloadClient: Failed to connect to file transfer server.") + self?.invalidate() + self?.status = .failed(.failedToConnect) + case .connected, .progress(_): + print("HotlineFolderDownloadClient: Failed to finish transfer.") + self?.invalidate() + self?.status = .failed(.failedToDownload) + default: + break + } + default: + return + } + } + + self.status = .connecting + self.connection?.start(queue: .global()) + } + + func invalidate() { + if let c = self.connection { + c.stateUpdateHandler = nil + c.cancel() + + self.connection = nil + } + + self.fileBytes = Data() + + if let fh = self.fileHandle { + try? fh.close() + self.fileHandle = nil + } + + self.fileProgress.unpublish() + } + + private func 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) + } + + /// Find and align the buffer to the start of a FILP header. + /// Returns the number of bytes dropped. + @discardableResult + private func alignBufferToFILP() -> Int { + // We need at least the 4-byte magic to try + guard self.fileBytes.count >= 4 else { return 0 } + let magicData = Data([0x46, 0x49, 0x4C, 0x50]) // "FILP" + if self.fileBytes.starts(with: magicData) { return 0 } + + if let r = self.fileBytes.firstRange(of: magicData) { + let toDrop = r.lowerBound + if toDrop > 0 { + print("HotlineFolderDownloadClient: Resync — dropping \(toDrop) stray bytes before FILP") + self.fileBytes.removeSubrange(0..= 2 else { break } + let headerLen = Int(self.fileBytes.readUInt16(at: 0)!) + guard self.fileBytes.count >= 2 + headerLen else { break } + + let headerData = self.fileBytes.subdata(in: 2..<(2 + headerLen)) + + guard let parsed = parseItemHeaderPath(headerData) else { + print("HotlineFolderDownloadClient: Invalid item header; waiting for more data") + break + } + + // Consume the header from the buffer + self.fileBytes.removeSubrange(0..<(2 + headerLen)) + + let itemType = parsed.type + let comps = parsed.components + let joinedPath = comps.joined(separator: "/") + print("HotlineFolderDownloadClient: item type=\(itemType) path=\(joinedPath)") + + guard !comps.isEmpty else { + print("HotlineFolderDownloadClient: Empty path components for item type \(itemType); requesting next item") + self.sendAction(.nextFile) + keepProcessing = !self.fileBytes.isEmpty + break + } + + if itemType == 1 { + // Folder entries: create the directory locally and continue. + self.currentItemRelativePath = comps + + if let base = self.folderPath { + var dir = base + for c in comps { dir = (dir as NSString).appendingPathComponent(c) } + do { + try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + print("HotlineFolderDownloadClient: Created folder at \(dir)") + } catch { + print("HotlineFolderDownloadClient: Failed to create subfolder: \(error)") + } + } + + self.completedItemCount += 1 + + if self.completedItemCount >= self.folderItemCount { + self.handleAllItemsDownloaded() + return + } + + self.sendAction(.nextFile) + keepProcessing = !self.fileBytes.isEmpty + break + } + else if itemType == 0 { + // File entries include the full path; split parent components from filename. + self.currentItemRelativePath = Array(comps.dropLast()) + self.currentFileName = comps.last + + self.transferStage = .waitingForFileSize + print("HotlineFolderDownloadClient: Requesting file download for '\(self.currentFileName ?? "?")'") + self.sendAction(.sendFile) + + if self.fileBytes.count >= 4 { + keepProcessing = true + } + break + } + else { + print("HotlineFolderDownloadClient: Unknown item type \(itemType); skipping") + self.sendAction(.nextFile) + keepProcessing = !self.fileBytes.isEmpty + break + } + + case .waitingForFileSize: + // Read 4-byte file size that comes before FILP in folder mode + guard self.fileBytes.count >= 4 else { break } + let fileSize = self.fileBytes.readUInt32(at: 0)! + self.fileBytes.removeSubrange(0..<4) + + print("HotlineFolderDownloadClient: File size: \(fileSize) bytes") + + self.currentFileSize = fileSize + self.currentFileBytesRead = 0 + + // Align to FILP boundary before decoding the file header + let dropped = self.alignBufferToFILP() + if dropped > 0 { + // These bytes were in the stream for this file but not part of FILP. + // Keep byte-accounting consistent by shrinking the expected size. + if self.currentFileSize >= UInt32(dropped) { + self.currentFileSize -= UInt32(dropped) + } else { + // Defensive: if weird, treat as zero-size to avoid underflow + self.currentFileSize = 0 + } + } + + self.transferStage = .fileHeader + keepProcessing = true + + case .fileHeader: + // Make sure we're actually at a FILP header + if self.fileBytes.count >= HotlineFileHeader.DataSize { + // If the 4-byte magic doesn't match, try one more resync here + if self.fileBytes.readUInt32(at: 0)! != FILP_MAGIC { + let dropped = self.alignBufferToFILP() + if dropped > 0 { + if self.currentFileSize >= UInt32(dropped) { self.currentFileSize -= UInt32(dropped) } + } + // If still not aligned or not enough bytes, wait for more data + guard self.fileBytes.count >= HotlineFileHeader.DataSize, + self.fileBytes.readUInt32(at: 0)! == FILP_MAGIC else { break } + } + + if let header = HotlineFileHeader(from: self.fileBytes) { + // Sanity gate: version and fork count + if header.format != FILP_MAGIC || header.version == 0 || header.forkCount > 3 { + print("HotlineFolderDownloadClient: Invalid FILP header (fmt=\(String(format:"0x%08X", header.format)), ver=\(header.version), forks=\(header.forkCount)). Resyncing.") + // Try resync and wait for more data + let dropped = self.alignBufferToFILP() + if dropped > 0 { if self.currentFileSize >= UInt32(dropped) { self.currentFileSize -= UInt32(dropped) } } + break + } + + self.fileBytes.removeSubrange(0..= self.folderItemCount { + self.handleAllItemsDownloaded() + return + } + + // More files to download + // Check if server has pipelined all remaining data (we've received more than expected total) + print("HotlineFolderDownloadClient: Completed \(self.completedItemCount)/\(self.folderItemCount) items, requesting next entry") + self.transferStage = .itemHeader + self.sendAction(.nextFile) + keepProcessing = !self.fileBytes.isEmpty + } + // File not complete - try to read next fork header + else if let forkHeader = HotlineFileForkHeader(from: self.fileBytes) { + // Quick validation: first fork must be INFO, and sizes must be plausible + let remainingForThisFile = max(0, Int(self.currentFileSize) - self.currentFileBytesRead) + let plausible = forkHeader.dataSize <= UInt32(remainingForThisFile) + let knownType = forkHeader.isInfoFork || forkHeader.isDataFork || forkHeader.isResourceFork + + if !plausible || (!knownType && self.currentFileBytesRead == HotlineFileHeader.DataSize) { + print("HotlineFolderDownloadClient: Implausible fork header (type=\(String(format:"0x%08X", forkHeader.forkType)), size=\(forkHeader.dataSize), remaining=\(remainingForThisFile)). Resyncing.") + let dropped = self.alignBufferToFILP() + if dropped > 0 { if self.currentFileSize >= UInt32(dropped) { self.currentFileSize -= UInt32(dropped) } } + break + } + + self.fileBytes.removeSubrange(0..= infoForkDataSize { + let infoForkData = self.fileBytes.subdata(in: 0.. 0 { + if let f = self.fileHandle { + do { + var dataToWrite = self.fileBytes + + if dataToWrite.count >= self.fileCurrentForkBytesLeft { + dataToWrite = self.fileBytes.subdata(in: 0.. 0 { + var dataToWrite = self.fileBytes + + if dataToWrite.count >= self.fileCurrentForkBytesLeft { + dataToWrite = self.fileBytes.subdata(in: 0.. 0 { + var dataToWrite = self.fileBytes + + if dataToWrite.count >= self.fileCurrentForkBytesLeft { + dataToWrite = self.fileBytes.subdata(in: 0.. 0 { + let _ = self.writeResourceFork() + self.fileResourceBytes = Data() + } + + self.fileCurrentForkHeader = nil + self.fileCurrentForkBytesLeft = 0 + self.fileForksRemaining = 0 + self.currentFileBytesRead = 0 + self.currentFileSize = 0 + self.currentFilePath = nil + self.fileInfoFork = nil + self.fileHeader = nil + self.currentFileName = nil + } + + private func handleAllItemsDownloaded() { + guard self.status != .completed else { + return + } + + print("HotlineFolderDownloadClient: All \(self.folderItemCount) items downloaded") + + self.invalidate() + self.status = .completed + + if let downloadPath = self.folderPath { + DispatchQueue.main.sync { + print("HotlineFolderDownloadClient: Folder download complete", downloadPath) + + var downloadURL = URL(filePath: downloadPath) + downloadURL.resolveSymlinksInPath() + + self.delegate?.hotlineFolderDownloadComplete(client: self, reference: self.referenceNumber, at: downloadURL) + +#if os(macOS) + DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path) +#endif + } + } + } + + private func writeResourceFork() -> Bool { + guard let filePath = self.currentFilePath else { + return false + } + + var resolvedFileURL = URL(filePath: filePath) + resolvedFileURL.resolveSymlinksInPath() + + let resourceFilePath = resolvedFileURL.appendingPathComponent("..namedfork/rsrc") + + do { + try self.fileResourceBytes.write(to: resourceFilePath) + } + catch { + return false + } + + return true + } + + private func prepareDownloadFile(name: String) -> Bool { + var filePath: String + + if self.folderPath != nil { + // Build the full path including subfolders + var fullPath = self.folderPath! + for component in self.currentItemRelativePath { + fullPath = (fullPath as NSString).appendingPathComponent(component) + } + + let folderURL = URL(filePath: fullPath) + + // Create folder if it doesn't exist + if !FileManager.default.fileExists(atPath: folderURL.path) { + do { + try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) + } + catch { + print("HotlineFolderDownloadClient: Failed to create folder", error) + return false + } + } + + filePath = folderURL.appendingPathComponent(name).path + print("HotlineFolderDownloadClient: Creating file at \(filePath)") + } + else { + let downloadsURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0] + filePath = downloadsURL.generateUniqueFilePath(filename: name) + } + + var fileAttributes: [FileAttributeKey: Any] = [:] + if let creatorCode = self.fileInfoFork?.creator { + fileAttributes[.hfsCreatorCode] = creatorCode as NSNumber + } + if let typeCode = self.fileInfoFork?.type { + fileAttributes[.hfsTypeCode] = typeCode as NSNumber + } + if let createdDate = self.fileInfoFork?.createdDate { + fileAttributes[.creationDate] = createdDate as NSDate + } + if let modifiedDate = self.fileInfoFork?.modifiedDate { + fileAttributes[.modificationDate] = modifiedDate as NSDate + } + if let comment = self.fileInfoFork?.comment { + if let commentPlistData = try? PropertyListSerialization.data(fromPropertyList: comment, format: .binary, options: 0) { + fileAttributes[FileAttributeKey(rawValue: "NSFileExtendedAttributes")] = [ + FileAttributeKey(rawValue: "com.apple.metadata:kMDItemFinderComment"): commentPlistData + ] + } + } + + if FileManager.default.createFile(atPath: filePath, contents: nil, attributes: fileAttributes) { + if let h = FileHandle(forWritingAtPath: filePath) { + self.currentFilePath = filePath + self.fileHandle = h + + // Only set file progress on first file + if self.currentItemNumber == 1 && self.folderPath != nil { + self.fileProgress.fileURL = URL(filePath: self.folderPath!).resolvingSymlinksInPath() + self.fileProgress.fileOperationKind = .downloading + self.fileProgress.publish() + } + + return true + } + } + + return false + } +} diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift index 9ea29c5..48cae54 100644 --- a/Hotline/Models/Hotline.swift +++ b/Hotline/Models/Hotline.swift @@ -1,7 +1,7 @@ import SwiftUI @Observable -class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelegate, HotlineFilePreviewClientDelegate, HotlineFileUploadClientDelegate { +class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelegate, HotlineFilePreviewClientDelegate, HotlineFileUploadClientDelegate, HotlineFolderDownloadClientDelegate { let id: UUID = UUID() let trackerClient: HotlineTrackerClient let client: HotlineClient @@ -559,7 +559,51 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega } } } - + + @MainActor func downloadFolder(_ folderName: String, path: [String], complete callback: ((TransferInfo, URL) -> Void)? = nil) { + var fullPath: [String] = [] + if path.count > 1 { + fullPath = Array(path[0.. Void)? = nil) { let fileName = fileURL.lastPathComponent @@ -1003,12 +1047,36 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega SoundEffectPlayer.shared.playSoundEffect(.transferComplete) } } - + if let i = self.downloads.firstIndex(where: { $0.referenceNumber == reference }) { self.downloads.remove(at: i) } } - + + func hotlineFolderDownloadReceivedFileInfo(client: HotlineFolderDownloadClient, reference: UInt32, fileName: String, itemNumber: Int, totalItems: Int) { + // Optional: Update transfer title to show current file being downloaded + if let i = self.transfers.firstIndex(where: { $0.id == reference }) { + let transfer = self.transfers[i] + // You could update the title here to show progress: "FolderName (file 3 of 10)" + // transfer.title = "\(originalFolderName) (\(itemNumber) of \(totalItems))" + } + } + + func hotlineFolderDownloadComplete(client: HotlineFolderDownloadClient, reference: UInt32, at: URL) { + if let i = self.transfers.firstIndex(where: { $0.id == reference }) { + let transfer = self.transfers[i] + transfer.fileURL = at + transfer.downloadCallback?(transfer, at) + if Prefs.shared.playSounds && Prefs.shared.playFileTransferCompleteSound { + SoundEffectPlayer.shared.playSoundEffect(.transferComplete) + } + } + + if let i = self.downloads.firstIndex(where: { $0.referenceNumber == reference }) { + self.downloads.remove(at: i) + } + } + // MARK: - Utilities func updateServerTitle() { diff --git a/Hotline/Models/TransferInfo.swift b/Hotline/Models/TransferInfo.swift index 50b23f5..ba25793 100644 --- a/Hotline/Models/TransferInfo.swift +++ b/Hotline/Models/TransferInfo.swift @@ -3,14 +3,15 @@ import SwiftUI @Observable class TransferInfo: Identifiable, Equatable, Hashable { var id: UInt32 - + var title: String var size: UInt var progress: Double = 0.0 var timeRemaining: TimeInterval = 0.0 var completed: Bool = false var failed: Bool = false - + var isFolder: Bool = false + // For file based transfers (i.e. not previews) var fileURL: URL? = nil diff --git a/Hotline/macOS/FilesView.swift b/Hotline/macOS/FilesView.swift index 2befa18..f399372 100644 --- a/Hotline/macOS/FilesView.swift +++ b/Hotline/macOS/FilesView.swift @@ -236,11 +236,12 @@ struct FilesView: View { } @MainActor private func downloadFile(_ file: FileInfo) { - guard !file.isFolder else { - return + if file.isFolder { + model.downloadFolder(file.name, path: file.path) + } + else { + model.downloadFile(file.name, path: file.path) } - - model.downloadFile(file.name, path: file.path) } @MainActor private func uploadFile(file fileURL: URL, to path: [String]) { @@ -297,13 +298,13 @@ struct FilesView: View { let selectedFile = items.first Button { - if let s = selectedFile, !s.isFolder { + if let s = selectedFile { downloadFile(s) } } label: { Label("Download", systemImage: "arrow.down") } - .disabled(selectedFile == nil || (selectedFile != nil && selectedFile!.isFolder)) + .disabled(selectedFile == nil) Divider() @@ -419,14 +420,14 @@ struct FilesView: View { ToolbarItem(placement: .primaryAction) { Button { - if let selectedFile = selection, !selectedFile.isFolder { + if let selectedFile = selection { downloadFile(selectedFile) } } label: { Label("Download", systemImage: "arrow.down") } .help("Download") - .disabled(selection == nil || selection?.isFolder == true || model.access?.contains(.canDownloadFiles) != true) + .disabled(selection == nil || model.access?.contains(.canDownloadFiles) != true) } } } diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index 154bd7c..115709d 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -585,10 +585,6 @@ struct ServerView: View { } } -//#Preview { -// ServerView(server: Server(name: "", description: "", address: "", port: 0)) -//} - struct TransferItemView: View { let transfer: TransferInfo @@ -619,10 +615,18 @@ struct TransferItemView: View { HStack(alignment: .center, spacing: 5) { HStack(spacing: 0) { Spacer() - FileIconView(filename: transfer.title, fileType: nil) - .frame(width: 16, height: 16) - .opacity(controlActiveState == .inactive ? 0.5 : 1.0) -// .padding(.leading, 2) + if transfer.isFolder { + Image("Folder") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + .opacity(controlActiveState == .inactive ? 0.5 : 1.0) + } + else { + FileIconView(filename: transfer.title, fileType: nil) + .frame(width: 16, height: 16) + .opacity(controlActiveState == .inactive ? 0.5 : 1.0) + } Spacer() } .frame(width: 20) diff --git a/Research/HLProtocol.pdf b/Research/HLProtocol.pdf new file mode 100644 index 0000000..aa2a197 Binary files /dev/null and b/Research/HLProtocol.pdf differ -- cgit From e2389dee5255661313b2b3c4fc23f4b5b6dd9d22 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Tue, 21 Oct 2025 23:14:19 -0700 Subject: Double click finished transfers to reveal in finder. --- Hotline/macOS/ServerView.swift | 8 ++++++++ 1 file changed, 8 insertions(+) (limited to 'Hotline/macOS') diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index 115709d..81c993a 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -1,5 +1,6 @@ import SwiftUI import UniformTypeIdentifiers +import AppKit @Observable class ServerState: Equatable { @@ -689,6 +690,13 @@ struct TransferItemView: View { self.hovered = hovered } } + .onTapGesture(count: 2) { + guard transfer.completed, let url = transfer.fileURL else { + return + } + + NSWorkspace.shared.activateFileViewerSelecting([url]) + } .help(formattedProgressHelp()) } } -- cgit From 46213b6e09b8b23bfa039634ff5af4919c758473 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Wed, 22 Oct 2025 17:32:59 -0700 Subject: First pass at file search in files view. --- Hotline/Hotline/HotlineClient.swift | 38 +++-- Hotline/Models/Hotline.swift | 295 +++++++++++++++++++++++++++++++++++- Hotline/macOS/FilesView.swift | 139 +++++++++++++++-- Hotline/macOS/ServerView.swift | 11 ++ 4 files changed, 454 insertions(+), 29 deletions(-) (limited to 'Hotline/macOS') diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index 99aa167..3c6a643 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -89,7 +89,13 @@ class HotlineClient: NetSocketDelegate { private var serverAddress: String? = nil private var serverPort: UInt16? = nil - private var transactionLog: [UInt32:(HotlineTransactionType, ((HotlineTransaction, HotlineTransactionError?) -> Void)?)] = [:] + private struct TransactionContext { + let type: HotlineTransactionType + let callback: ((HotlineTransaction, HotlineTransactionError?) -> Void)? + let suppressErrors: Bool + } + + private var transactionLog: [UInt32: TransactionContext] = [:] private var socket: NetSocket? private var stage: HotlineClientStage = .handshake @@ -208,15 +214,15 @@ class HotlineClient: NetSocketDelegate { // MARK: - Packets - @MainActor private func sendPacket(_ t: HotlineTransaction, callback: ((HotlineTransaction, HotlineTransactionError?) -> Void)? = nil) { + @MainActor private func sendPacket(_ t: HotlineTransaction, suppressErrors: Bool = false, callback: ((HotlineTransaction, HotlineTransactionError?) -> Void)? = nil) { guard let socket = self.socket else { return } print("HotlineClient => \(t.id) \(t.type)") - if callback != nil { - self.transactionLog[t.id] = (t.type, callback) + if callback != nil || suppressErrors { + self.transactionLog[t.id] = TransactionContext(type: t.type, callback: callback, suppressErrors: suppressErrors) } socket.write(t.encoded()) @@ -375,22 +381,22 @@ class HotlineClient: NetSocketDelegate { return } + let context = self.transactionLog[packet.id] + self.transactionLog[packet.id] = nil + if packet.errorCode != 0 { let errorField: HotlineTransactionField? = packet.getField(type: .errorText) print("HotlineClient 😵 \(packet.errorCode): \(errorField?.getString() ?? "")") - self.delegate?.hotlineReceivedErrorMessage(code: packet.errorCode, message: errorField?.getString()) + if context?.suppressErrors != true { + self.delegate?.hotlineReceivedErrorMessage(code: packet.errorCode, message: errorField?.getString()) + } } - - guard let replyCallbackInfo = self.transactionLog[packet.id] else { - print("Hmm, no reply waiting though") - return + + if let context { + print("HotlineClient reply in response to \(context.type)") } - self.transactionLog[packet.id] = nil - - print("HotlineClient reply in response to \(replyCallbackInfo.0)") - - let replyCallback = replyCallbackInfo.1 + let replyCallback = context?.callback guard packet.errorCode == 0 else { let errorField: HotlineTransactionField? = packet.getField(type: .errorText) @@ -630,13 +636,13 @@ class HotlineClient: NetSocketDelegate { } } - @MainActor func sendGetFileList(path: [String] = [], callback: (([HotlineFile]) -> Void)? = nil) { + @MainActor func sendGetFileList(path: [String] = [], suppressErrors: Bool = false, callback: (([HotlineFile]) -> Void)? = nil) { var t = HotlineTransaction(type: .getFileNameList) if !path.isEmpty { t.setFieldPath(type: .filePath, val: path) } - self.sendPacket(t) { reply, err in + self.sendPacket(t, suppressErrors: suppressErrors) { reply, err in guard err == nil else { callback?([]) return diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift index 48cae54..b93fb55 100644 --- a/Hotline/Models/Hotline.swift +++ b/Hotline/Models/Hotline.swift @@ -1,5 +1,29 @@ import SwiftUI +struct FileSearchConfig: Equatable { + var initialBurstCount: Int = 8 + var initialDelay: TimeInterval = 0.05 + var backoffMultiplier: Double = 1.6 + var maxDelay: TimeInterval = 1.5 + var maxDepth: Int = 40 + var loopRepetitionLimit: Int = 4 +} + +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 + } +} + @Observable class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelegate, HotlineFilePreviewClientDelegate, HotlineFileUploadClientDelegate, HotlineFolderDownloadClientDelegate { let id: UUID = UUID() @@ -131,6 +155,13 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega var messageBoardLoaded: Bool = false var files: [FileInfo] = [] var filesLoaded: Bool = false + var fileSearchResults: [FileInfo] = [] + var fileSearchStatus: FileSearchStatus = .idle + var fileSearchQuery: String = "" + var fileSearchConfig = FileSearchConfig() + var fileSearchScannedFolders: Int = 0 + @ObservationIgnored private var fileSearchSession: FileSearchSession? = nil + @ObservationIgnored private var fileSearchResultKeys: Set = [] var news: [NewsInfo] = [] private var newsLookup: [String:NewsInfo] = [:] var newsLoaded: Bool = false @@ -209,6 +240,9 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega @MainActor func disconnect() { self.client.disconnect() self.bannerClient?.cancel() + self.fileSearchSession?.cancel() + self.fileSearchSession = nil + self.resetFileSearchState() } @MainActor func sendAgree() { @@ -264,9 +298,9 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega self.client.sendPostMessageBoard(text: text) } - @MainActor func getFileList(path: [String] = []) async -> [FileInfo] { + @MainActor func getFileList(path: [String] = [], suppressErrors: Bool = false) async -> [FileInfo] { return await withCheckedContinuation { [weak self] continuation in - self?.client.sendGetFileList(path: path) { [weak self] files in + self?.client.sendGetFileList(path: path, suppressErrors: suppressErrors) { [weak self] files in let parentFile = self?.findFile(in: self?.files ?? [], at: path) var newFiles: [FileInfo] = [] @@ -289,6 +323,92 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega } } + @MainActor func startFileSearch(query: String) { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + cancelFileSearch() + return + } + + fileSearchSession?.cancel() + resetFileSearchState() + fileSearchQuery = trimmed + fileSearchStatus = .searching(processed: 0, pending: 0) + fileSearchScannedFolders = 0 + + let session = FileSearchSession(hotline: self, query: trimmed, config: fileSearchConfig) + fileSearchSession = session + + Task { await session.start() } + } + + @MainActor func cancelFileSearch(clearResults: Bool = true) { + guard let session = fileSearchSession else { + if clearResults { + resetFileSearchState() + } else if !fileSearchResults.isEmpty { + fileSearchStatus = .cancelled(processed: fileSearchScannedFolders) + } + return + } + + session.cancel() + fileSearchSession = nil + if clearResults { + resetFileSearchState() + } + else { + fileSearchStatus = .cancelled(processed: fileSearchScannedFolders) + } + } + + @MainActor fileprivate func searchSession(_ session: FileSearchSession, didEmit matches: [FileInfo], processed: Int, pending: Int) { + guard fileSearchSession === session else { + return + } + + var appended: [FileInfo] = [] + for match in matches { + let key = searchPathKey(for: match.path) + if fileSearchResultKeys.insert(key).inserted { + appended.append(match) + } + } + + if !appended.isEmpty { + fileSearchResults.append(contentsOf: appended) + } + + fileSearchScannedFolders = processed + fileSearchStatus = .searching(processed: processed, pending: pending) + } + + @MainActor fileprivate func searchSessionDidFinish(_ session: FileSearchSession, processed: Int, pending: Int, completed: Bool) { + guard fileSearchSession === session else { + return + } + + fileSearchScannedFolders = processed + fileSearchSession = nil + if completed { + fileSearchStatus = .completed(processed: processed) + } else { + fileSearchStatus = .cancelled(processed: processed) + } + } + + private func resetFileSearchState() { + fileSearchResults = [] + fileSearchResultKeys.removeAll(keepingCapacity: true) + fileSearchStatus = .idle + fileSearchQuery = "" + fileSearchScannedFolders = 0 + } + + private func searchPathKey(for path: [String]) -> String { + path.joined(separator: "\u{001F}") + } + @MainActor func getNewsArticle(id articleID: UInt, at path: [String], flavor: String) async -> String? { return await withCheckedContinuation { [weak self] continuation in self?.client.sendGetNewsArticle(id: UInt32(articleID), path: path, flavor: flavor) { articleText in @@ -809,6 +929,10 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega self.serverName = nil self.access = nil + self.fileSearchSession?.cancel() + self.fileSearchSession = nil + self.resetFileSearchState() + self.users = [] self.chat = [] self.messageBoard = [] @@ -1156,3 +1280,170 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega } } } + +@MainActor +final class FileSearchSession { + private struct FolderTask { + let path: [String] + let depth: Int + } + + private weak var hotline: Hotline? + private let queryTokens: [String] + private let config: FileSearchConfig + + private var queue: [FolderTask] = [] + private var visited: Set = [] + private var loopHistogram: [String: Int] = [:] + + private var processedCount: Int = 0 + private var currentDelay: TimeInterval + private var isCancelled = false + + init(hotline: Hotline, query: String, config: FileSearchConfig) { + self.hotline = hotline + self.queryTokens = query.lowercased().split(separator: " ").map(String.init) + self.config = config + self.currentDelay = config.initialDelay + } + + func start() async { + guard let hotline else { + return + } + + await Task.yield() + + if !hotline.filesLoaded { + let rootFiles = await hotline.getFileList(path: [], suppressErrors: true) + processedCount = max(processedCount, 1) + processListing(rootFiles, depth: 0) + } + else { + processedCount = max(processedCount, 1) + processListing(hotline.files, depth: 0) + } + + while !queue.isEmpty && !isCancelled { + await Task.yield() + + let task = queue.removeFirst() + if shouldSkip(path: task.path, depth: task.depth) { + hotline.searchSession(self, didEmit: [], processed: processedCount, pending: queue.count) + continue + } + + visited.insert(pathKey(for: task.path)) + + let children = await hotline.getFileList(path: task.path, suppressErrors: true) + processedCount += 1 + + if isCancelled { + break + } + + processListing(children, depth: task.depth) + + await applyBackoff() + } + + hotline.searchSessionDidFinish(self, processed: processedCount, pending: queue.count, completed: !isCancelled) + } + + func cancel() { + isCancelled = true + } + + private func processListing(_ items: [FileInfo], depth: Int) { + guard let hotline else { + return + } + + var matches: [FileInfo] = [] + + for file in items { + if nameMatchesQuery(file.name) { + matches.append(file) + } + + if file.isFolder { + enqueueFolder(file, depth: depth + 1) + } + } + + hotline.searchSession(self, didEmit: matches, processed: processedCount, pending: queue.count) + } + + private func enqueueFolder(_ folder: FileInfo, depth: Int) { + guard !isCancelled else { return } + guard depth <= config.maxDepth else { return } + + let path = folder.path + let key = pathKey(for: path) + guard !visited.contains(key) else { return } + + if exceedsLoopThreshold(for: path) { + return + } + + queue.append(FolderTask(path: path, depth: depth)) + } + + private func shouldSkip(path: [String], depth: Int) -> Bool { + if isCancelled { + return true + } + + if depth > config.maxDepth { + return true + } + + let key = pathKey(for: path) + if visited.contains(key) { + return true + } + + return false + } + + private func nameMatchesQuery(_ name: String) -> Bool { + guard !queryTokens.isEmpty else { return false } + let lowercased = name.lowercased() + return queryTokens.allSatisfy { lowercased.contains($0) } + } + + private func exceedsLoopThreshold(for path: [String]) -> Bool { + guard 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 = (loopHistogram[key] ?? 0) + 1 + loopHistogram[key] = count + return count > config.loopRepetitionLimit + } + + private func pathKey(for path: [String]) -> String { + path.joined(separator: "\u{001F}") + } + + private func applyBackoff() async { + guard !isCancelled else { return } + + if processedCount > config.initialBurstCount { + currentDelay = min(config.maxDelay, max(config.initialDelay, currentDelay * config.backoffMultiplier)) + } + + guard currentDelay > 0 else { + return + } + + let nanoseconds = UInt64(currentDelay * 1_000_000_000) + try? await Task.sleep(nanoseconds: nanoseconds) + } +} diff --git a/Hotline/macOS/FilesView.swift b/Hotline/macOS/FilesView.swift index f399372..206e0a4 100644 --- a/Hotline/macOS/FilesView.swift +++ b/Hotline/macOS/FilesView.swift @@ -213,6 +213,48 @@ struct FilesView: View { @State private var selection: FileInfo? @State private var fileDetails: FileDetails? @State private var uploadFileSelectorDisplayed: Bool = false + @State private var searchText: String = "" + + private var isShowingSearchResults: Bool { + switch model.fileSearchStatus { + case .idle: + return !model.fileSearchResults.isEmpty + case .cancelled(_): + return !model.fileSearchResults.isEmpty + default: + return true + } + } + + private var displayedFiles: [FileInfo] { + isShowingSearchResults ? model.fileSearchResults : model.files + } + + private var searchStatusMessage: String? { + switch model.fileSearchStatus { + case .searching(let processed, let pending): + let scanned = processed == 1 ? "folder" : "folders" + return "Searched \(processed) \(scanned)..." + case .completed(let processed): + let count = model.fileSearchResults.count + let folderWord = processed == 1 ? "folder" : "folders" + if count == 0 { + return "No files found in \(processed) \(folderWord)" + } + return "\(count) file\(count == 1 ? "" : "s") found in \(processed) \(folderWord)" + case .cancelled(let processed): + if model.fileSearchResults.isEmpty { + return nil + } + let count = model.fileSearchResults.count + let folderWord = processed == 1 ? "folder" : "folders" + return "Search cancelled" + case .failed(let message): + return "Search failed: \(message)" + case .idle: + return nil + } + } private func openPreviewWindow(_ previewInfo: PreviewFileInfo) { switch previewInfo.previewType { @@ -278,7 +320,7 @@ struct FilesView: View { var body: some View { NavigationStack { - List(model.files, id: \.self, selection: $selection) { file in + List(displayedFiles, id: \.self, selection: $selection) { file in if file.isFolder { FolderView(file: file, depth: 0).tag(file.id) } @@ -383,8 +425,9 @@ struct FilesView: View { .frame(maxWidth: .infinity) } } + .searchable(text: $searchText, placement: .automatic, prompt: "Search") .toolbar { - ToolbarItem(placement: .primaryAction) { + ToolbarItemGroup(placement: .automatic) { Button { if let selectedFile = selection, selectedFile.isPreviewable { previewFile(selectedFile) @@ -394,9 +437,7 @@ struct FilesView: View { } .help("Preview") .disabled(selection == nil || selection?.isPreviewable == false) - } - - ToolbarItem(placement: .primaryAction) { + Button { if let selectedFile = selection { getFileInfo(selectedFile) @@ -406,9 +447,7 @@ struct FilesView: View { } .help("Get Info") .disabled(selection == nil) - } - - ToolbarItem(placement: .primaryAction) { + Button { uploadFileSelectorDisplayed = true } label: { @@ -416,9 +455,7 @@ struct FilesView: View { } .help("Upload") .disabled(model.access?.contains(.canUploadFiles) != true) - } - - ToolbarItem(placement: .primaryAction) { + Button { if let selectedFile = selection { downloadFile(selectedFile) @@ -464,6 +501,86 @@ struct FilesView: View { print(error) } }) + .onSubmit(of: .search) { + let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + model.cancelFileSearch() + return + } + searchText = trimmed + model.startFileSearch(query: trimmed) + } + .onChange(of: searchText) { _, newValue in + if newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + if isShowingSearchResults { + model.cancelFileSearch() + } + } + } + .onChange(of: model.fileSearchQuery) { _, newValue in + if newValue != searchText { + searchText = newValue + } + } + .onAppear { + if searchText != model.fileSearchQuery { + searchText = model.fileSearchQuery + } + } + .safeAreaInset(edge: .top) { + if isShowingSearchResults, let message = searchStatusMessage { + HStack { + Spacer() + + if case .searching(_, _) = model.fileSearchStatus { + ProgressView() + .controlSize(.small) + .tint(.red) + } + else if case .completed = model.fileSearchStatus { + Image(systemName: "checkmark.circle.fill") + .resizable() + .symbolRenderingMode(.palette) + .foregroundStyle(.white, .fileComplete) + .aspectRatio(contentMode: .fit) + .frame(width: 16, height: 16) + } + else if case .failed = model.fileSearchStatus { + Image(systemName: "exclamationmark.triangle.fill") + .resizable() + .symbolRenderingMode(.multicolor) + .aspectRatio(contentMode: .fit) + .frame(width: 16, height: 16) + } + + Text(message) + .font(.body) + .foregroundStyle(.white) + + Spacer() + } +// .overlay(alignment: .leading) { +// Button { +// model.cancelFileSearch(clearResults: true) +// } label: { +// Image(systemName: "xmark.circle.fill") +// .resizable() +// .scaledToFit() +// .foregroundStyle(.white) +// .opacity(0.8) +// } +// .buttonStyle(.plain) +// } + .padding(.horizontal, 8) + .padding(.leading, 4) + .padding(.vertical, 8) + .background { + Color(nsColor: .controlAccentColor) + .clipShape(.capsule(style: .continuous)) + } + .padding(.horizontal, 8) + } + } } } diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index 81c993a..49413fe 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -392,6 +392,16 @@ struct ServerView: View { ListItemView(icon: menuItem.image, title: menuItem.name, unread: false).tag(menuItem.type) } } + else if menuItem.type == .files { + ListItemView(icon: menuItem.image, title: menuItem.name, unread: false).tag(menuItem.type) + .overlay(alignment: .trailing) { + if case .searching(_, _) = model.fileSearchStatus { + ProgressView() + .controlSize(.mini) + .padding(.trailing, 4) + } + } + } else { ListItemView(icon: menuItem.image, title: menuItem.name, unread: false).tag(menuItem.type) } @@ -700,3 +710,4 @@ struct TransferItemView: View { .help(formattedProgressHelp()) } } + -- cgit From 485e4d981b6cd19a1e00ebf244ed1dd538fba284 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Wed, 22 Oct 2025 21:15:04 -0700 Subject: Improvements to file search UI and config. --- Hotline/Models/Hotline.swift | 25 +++++++++++++--- Hotline/macOS/FilesView.swift | 64 ++++++++++++++++++++++++++--------------- Hotline/macOS/TrackerView.swift | 3 +- 3 files changed, 64 insertions(+), 28 deletions(-) (limited to 'Hotline/macOS') diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift index b93fb55..786fb0c 100644 --- a/Hotline/Models/Hotline.swift +++ b/Hotline/Models/Hotline.swift @@ -1,10 +1,10 @@ import SwiftUI struct FileSearchConfig: Equatable { - var initialBurstCount: Int = 8 - var initialDelay: TimeInterval = 0.05 - var backoffMultiplier: Double = 1.6 - var maxDelay: TimeInterval = 1.5 + var initialBurstCount: Int = 15 + var initialDelay: TimeInterval = 0.02 + var backoffMultiplier: Double = 1.1 + var maxDelay: TimeInterval = 1.3 var maxDepth: Int = 40 var loopRepetitionLimit: Int = 4 } @@ -160,6 +160,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega var fileSearchQuery: String = "" var fileSearchConfig = FileSearchConfig() var fileSearchScannedFolders: Int = 0 + var fileSearchCurrentPath: [String]? = nil @ObservationIgnored private var fileSearchSession: FileSearchSession? = nil @ObservationIgnored private var fileSearchResultKeys: Set = [] var news: [NewsInfo] = [] @@ -335,6 +336,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega fileSearchQuery = trimmed fileSearchStatus = .searching(processed: 0, pending: 0) fileSearchScannedFolders = 0 + fileSearchCurrentPath = [] let session = FileSearchSession(hotline: self, query: trimmed, config: fileSearchConfig) fileSearchSession = session @@ -348,12 +350,14 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega resetFileSearchState() } else if !fileSearchResults.isEmpty { fileSearchStatus = .cancelled(processed: fileSearchScannedFolders) + fileSearchCurrentPath = nil } return } session.cancel() fileSearchSession = nil + fileSearchCurrentPath = nil if clearResults { resetFileSearchState() } @@ -383,6 +387,14 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega fileSearchStatus = .searching(processed: processed, pending: pending) } + @MainActor fileprivate func searchSession(_ session: FileSearchSession, didFocusOn path: [String]) { + guard fileSearchSession === session else { + return + } + + fileSearchCurrentPath = path + } + @MainActor fileprivate func searchSessionDidFinish(_ session: FileSearchSession, processed: Int, pending: Int, completed: Bool) { guard fileSearchSession === session else { return @@ -390,6 +402,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega fileSearchScannedFolders = processed fileSearchSession = nil + fileSearchCurrentPath = nil if completed { fileSearchStatus = .completed(processed: processed) } else { @@ -403,6 +416,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega fileSearchStatus = .idle fileSearchQuery = "" fileSearchScannedFolders = 0 + fileSearchCurrentPath = nil } private func searchPathKey(for path: [String]) -> String { @@ -1315,11 +1329,13 @@ final class FileSearchSession { await Task.yield() if !hotline.filesLoaded { + hotline.searchSession(self, didFocusOn: []) let rootFiles = await hotline.getFileList(path: [], suppressErrors: true) processedCount = max(processedCount, 1) processListing(rootFiles, depth: 0) } else { + hotline.searchSession(self, didFocusOn: []) processedCount = max(processedCount, 1) processListing(hotline.files, depth: 0) } @@ -1333,6 +1349,7 @@ final class FileSearchSession { continue } + hotline.searchSession(self, didFocusOn: task.path) visited.insert(pathKey(for: task.path)) let children = await hotline.getFileList(path: task.path, suppressErrors: true) diff --git a/Hotline/macOS/FilesView.swift b/Hotline/macOS/FilesView.swift index 206e0a4..4498dc9 100644 --- a/Hotline/macOS/FilesView.swift +++ b/Hotline/macOS/FilesView.swift @@ -255,6 +255,16 @@ struct FilesView: View { return nil } } + + private var searchStatusPath: String? { + guard let path = model.fileSearchCurrentPath else { + return nil + } + if path.isEmpty { + return "/" + } + return "/" + path.joined(separator: "/") + "/" + } private func openPreviewWindow(_ previewInfo: PreviewFileInfo) { switch previewInfo.previewType { @@ -529,56 +539,64 @@ struct FilesView: View { } .safeAreaInset(edge: .top) { if isShowingSearchResults, let message = searchStatusMessage { - HStack { - Spacer() - + HStack(alignment: .center, spacing: 6) { if case .searching(_, _) = model.fileSearchStatus { ProgressView() .controlSize(.small) - .tint(.red) + .accentColor(.white) + .tint(.white) } else if case .completed = model.fileSearchStatus { Image(systemName: "checkmark.circle.fill") .resizable() - .symbolRenderingMode(.palette) - .foregroundStyle(.white, .fileComplete) + .symbolRenderingMode(.monochrome) + .foregroundStyle(.white) .aspectRatio(contentMode: .fit) .frame(width: 16, height: 16) } else if case .failed = model.fileSearchStatus { Image(systemName: "exclamationmark.triangle.fill") .resizable() - .symbolRenderingMode(.multicolor) + .symbolRenderingMode(.monochrome) + .foregroundStyle(.white) .aspectRatio(contentMode: .fit) .frame(width: 16, height: 16) } Text(message) + .lineLimit(1) .font(.body) .foregroundStyle(.white) Spacer() + + if let pathMessage = searchStatusPath { + Text(pathMessage) + .lineLimit(1) + .truncationMode(.tail) + .font(.footnote) +// .fontWeight(.semibold) + .foregroundStyle(.white) + .opacity(0.5) + .padding(.top, 2) + } } -// .overlay(alignment: .leading) { -// Button { -// model.cancelFileSearch(clearResults: true) -// } label: { -// Image(systemName: "xmark.circle.fill") -// .resizable() -// .scaledToFit() -// .foregroundStyle(.white) -// .opacity(0.8) -// } -// .buttonStyle(.plain) -// } - .padding(.horizontal, 8) - .padding(.leading, 4) + .padding(.trailing, 14) + .padding(.leading, 8) .padding(.vertical, 8) .background { - Color(nsColor: .controlAccentColor) - .clipShape(.capsule(style: .continuous)) + Group { + if case .completed = model.fileSearchStatus { + Color.fileComplete + } + else { + Color(nsColor: .controlAccentColor) + } + } + .clipShape(.capsule(style: .continuous)) } .padding(.horizontal, 8) + .padding(.top, 8) } } } diff --git a/Hotline/macOS/TrackerView.swift b/Hotline/macOS/TrackerView.swift index 83a56b8..c14f982 100644 --- a/Hotline/macOS/TrackerView.swift +++ b/Hotline/macOS/TrackerView.swift @@ -416,8 +416,9 @@ struct TrackerView: View { // Not expanded, expand it (which also fetches) self.setExpanded(true, for: bookmark) } + return } - return + break default: break } -- cgit From 5b4d615792a951979ff79506657e8407d2a48016 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Thu, 23 Oct 2025 11:24:06 -0700 Subject: Improve file search to move down the tree once current depth has been searched with exception now for hot spots where we burst into folders that contain matches. --- Hotline.xcodeproj/project.pbxproj | 4 +- Hotline/Models/Hotline.swift | 101 ++++++++++++++++++++++++++++++++++---- Hotline/macOS/FilesView.swift | 7 ++- 3 files changed, 98 insertions(+), 14 deletions(-) (limited to 'Hotline/macOS') diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index 54adc37..d35440f 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -656,7 +656,7 @@ CODE_SIGN_ENTITLEMENTS = Hotline/Hotline.entitlements; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 22; + CURRENT_PROJECT_VERSION = 23; DEVELOPMENT_ASSET_PATHS = "\"Hotline/Preview Content\""; DEVELOPMENT_TEAM = 5AEEV7QB2U; "ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = YES; @@ -696,7 +696,7 @@ CODE_SIGN_ENTITLEMENTS = Hotline/Hotline.entitlements; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 22; + CURRENT_PROJECT_VERSION = 23; DEVELOPMENT_ASSET_PATHS = "\"Hotline/Preview Content\""; DEVELOPMENT_TEAM = 5AEEV7QB2U; "ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = YES; diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift index 786fb0c..1e2c6c5 100644 --- a/Hotline/Models/Hotline.swift +++ b/Hotline/Models/Hotline.swift @@ -1,12 +1,20 @@ import SwiftUI 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 - var maxDelay: TimeInterval = 1.3 + /// 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 } enum FileSearchStatus: Equatable { @@ -1300,6 +1308,7 @@ final class FileSearchSession { private struct FolderTask { let path: [String] let depth: Int + let isHot: Bool } private weak var hotline: Hotline? @@ -1332,18 +1341,20 @@ final class FileSearchSession { hotline.searchSession(self, didFocusOn: []) let rootFiles = await hotline.getFileList(path: [], suppressErrors: true) processedCount = max(processedCount, 1) - processListing(rootFiles, depth: 0) + processListing(rootFiles, depth: 0, parentPath: [], parentIsHot: false) } else { hotline.searchSession(self, didFocusOn: []) processedCount = max(processedCount, 1) - processListing(hotline.files, depth: 0) + processListing(hotline.files, depth: 0, parentPath: [], parentIsHot: false) } while !queue.isEmpty && !isCancelled { await Task.yield() - let task = queue.removeFirst() + guard let task = dequeueNextTask() else { + continue + } if shouldSkip(path: task.path, depth: task.depth) { hotline.searchSession(self, didEmit: [], processed: processedCount, pending: queue.count) continue @@ -1359,7 +1370,7 @@ final class FileSearchSession { break } - processListing(children, depth: task.depth) + processListing(children, depth: task.depth, parentPath: task.path, parentIsHot: task.isHot) await applyBackoff() } @@ -1371,27 +1382,61 @@ final class FileSearchSession { isCancelled = true } - private func processListing(_ items: [FileInfo], depth: Int) { + private func processListing(_ items: [FileInfo], depth: Int, parentPath: [String], parentIsHot: Bool) { guard let hotline else { return } var matches: [FileInfo] = [] + var folderEntries: [(file: FileInfo, isHot: Bool)] = [] + var hasFileMatch = false for file in items { - if nameMatchesQuery(file.name) { + let matchesName = nameMatchesQuery(file.name) + + if matchesName { matches.append(file) + if !file.isFolder { + hasFileMatch = true + } } if file.isFolder { - enqueueFolder(file, depth: depth + 1) + folderEntries.append((file, matchesName)) + } + } + + var remainingBurst = 0 + if config.hotBurstLimit > 0 && (parentIsHot || hasFileMatch) { + remainingBurst = 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 { + enqueueFolder(entry.file, depth: depth + 1, markHot: entry.isHot) + } + hotline.searchSession(self, didEmit: matches, processed: processedCount, pending: queue.count) } - private func enqueueFolder(_ folder: FileInfo, depth: Int) { + private func enqueueFolder(_ folder: FileInfo, depth: Int, markHot: Bool) { guard !isCancelled else { return } guard depth <= config.maxDepth else { return } @@ -1403,7 +1448,43 @@ final class FileSearchSession { return } - queue.append(FolderTask(path: path, depth: depth)) + queue.append(FolderTask(path: path, depth: depth, isHot: markHot)) + } + + private func dequeueNextTask() -> FolderTask? { + guard !queue.isEmpty else { + return nil + } + + if queue.count == 1 { + return queue.removeFirst() + } + + let currentDepth = queue[0].depth + var lastSameDepthIndex = 0 + var hotIndices: [Int] = [] + + for index in 0.. Bool { diff --git a/Hotline/macOS/FilesView.swift b/Hotline/macOS/FilesView.swift index 4498dc9..5d06b02 100644 --- a/Hotline/macOS/FilesView.swift +++ b/Hotline/macOS/FilesView.swift @@ -263,7 +263,7 @@ struct FilesView: View { if path.isEmpty { return "/" } - return "/" + path.joined(separator: "/") + "/" + return path.joined(separator: "/") } private func openPreviewWindow(_ previewInfo: PreviewFileInfo) { @@ -464,7 +464,10 @@ struct FilesView: View { Label("Upload", systemImage: "arrow.up") } .help("Upload") - .disabled(model.access?.contains(.canUploadFiles) != true) + .disabled( + model.access?.contains(.canUploadFiles) != true || + (model.fileSearchStatus.isActive && !(selection?.isFolder ?? false)) + ) Button { if let selectedFile = selection { -- cgit From 2df678753e2e6fe309de6b546925540f89e3d680 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Thu, 23 Oct 2025 12:11:22 -0700 Subject: Add a basic file cache that gets searched over first. TTL on entries. Force clear cache but holding shift+enter to search. Don't cache upload/dropbox folders. Don't search in .app folders. --- Hotline/Models/FileInfo.swift | 26 +++++-- Hotline/Models/Hotline.swift | 162 +++++++++++++++++++++++++++++++++++++++--- Hotline/macOS/FilesView.swift | 8 +++ 3 files changed, 182 insertions(+), 14 deletions(-) (limited to 'Hotline/macOS') diff --git a/Hotline/Models/FileInfo.swift b/Hotline/Models/FileInfo.swift index 2da7378..62ec831 100644 --- a/Hotline/Models/FileInfo.swift +++ b/Hotline/Models/FileInfo.swift @@ -15,18 +15,36 @@ import UniformTypeIdentifiers let isUnavailable: Bool var isDropboxFolder: Bool { - guard self.isFolder, - (self.name.range(of: "upload", options: [.caseInsensitive]) != nil) || (self.name.range(of: "drop box", options: [.caseInsensitive]) != nil) - else { + guard self.isFolder else { return false } - return true + + if self.name.range(of: "upload", options: [.caseInsensitive]) != nil { + return true + } + + if self.name.range(of: "dropbox", options: [.caseInsensitive]) != nil { + return true + } + + if self.name.range(of: "drop box", options: [.caseInsensitive]) != nil { + return true + } + + return false } var isAdminDropboxFolder: Bool { self.isDropboxFolder && (self.name.range(of: "admin", options: [.caseInsensitive]) != nil) } + var isAppBundle: Bool { + guard self.isFolder else { + return false + } + return self.name.lowercased().hasSuffix(".app") + } + var expanded: Bool = false var children: [FileInfo]? = nil diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift index 1e2c6c5..054a1bc 100644 --- a/Hotline/Models/Hotline.swift +++ b/Hotline/Models/Hotline.swift @@ -15,6 +15,10 @@ struct FileSearchConfig: Equatable { 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 { @@ -171,6 +175,11 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega var fileSearchCurrentPath: [String]? = nil @ObservationIgnored private var fileSearchSession: FileSearchSession? = nil @ObservationIgnored private var fileSearchResultKeys: Set = [] + private struct FileListCacheEntry { + let files: [FileInfo] + let timestamp: Date + } + @ObservationIgnored private var fileListCache: [String: FileListCacheEntry] = [:] var news: [NewsInfo] = [] private var newsLookup: [String:NewsInfo] = [:] var newsLoaded: Bool = false @@ -307,7 +316,11 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega self.client.sendPostMessageBoard(text: text) } - @MainActor func getFileList(path: [String] = [], suppressErrors: Bool = false) async -> [FileInfo] { + @MainActor func getFileList(path: [String] = [], suppressErrors: Bool = false, preferCache: Bool = false) async -> [FileInfo] { + if preferCache, let cached = cachedFileList(for: path, ttl: fileSearchConfig.cacheTTL, allowStale: false) { + return cached.items + } + return await withCheckedContinuation { [weak self] continuation in self?.client.sendGetFileList(path: path, suppressErrors: suppressErrors) { [weak self] files in let parentFile = self?.findFile(in: self?.files ?? [], at: path) @@ -326,6 +339,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega self?.files = newFiles } + self?.storeFileListInCache(newFiles, for: path) continuation.resume(returning: newFiles) } } @@ -430,6 +444,119 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega private func searchPathKey(for path: [String]) -> String { path.joined(separator: "\u{001F}") } + + 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 shouldBypassFileCache(for: path) { + return nil + } + + let key = searchPathKey(for: path) + guard let entry = 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 fileSearchConfig.cacheTTL > 0 else { + return + } + + if shouldBypassFileCache(for: path) { + return + } + + let key = searchPathKey(for: path) + fileListCache[key] = FileListCacheEntry(files: files, timestamp: Date()) + pruneFileListCacheIfNeeded() + } + + private func pruneFileListCacheIfNeeded() { + let limit = fileSearchConfig.maxCachedFolders + guard limit > 0, fileListCache.count > limit else { + return + } + + let excess = fileListCache.count - limit + guard excess > 0 else { return } + + let sortedKeys = fileListCache.sorted { lhs, rhs in + lhs.value.timestamp < rhs.value.timestamp + } + + for index in 0.. (items: [FileInfo], isFresh: Bool)? { + cachedFileList(for: path, ttl: ttl, allowStale: true) + } + + @MainActor func clearFileListCache() { + guard !fileListCache.isEmpty else { + return + } + + fileListCache.removeAll(keepingCapacity: false) + } @MainActor func getNewsArticle(id articleID: UInt, at path: [String], flavor: String) async -> String? { return await withCheckedContinuation { [weak self] continuation in @@ -748,7 +875,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega @MainActor func uploadFile(url fileURL: URL, path: [String], complete callback: ((TransferInfo) -> Void)? = nil) { let fileName = fileURL.lastPathComponent - + guard fileURL.isFileURL, !fileName.isEmpty else { print("NOT A FILE URL?") return @@ -789,9 +916,11 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega print("FILE IS EMPTY??") return } - + print("FILE SIZE: \(fileSize) NAME: \(fileName) PATH: \(path)") - + + invalidateFileListCache(for: path, includingAncestors: true) + self.client.sendUploadFile(name: fileName, path: path) { [weak self] success, uploadReferenceNumber in print("UPLOAD REFERENCE: \(String(describing: uploadReferenceNumber))") @@ -809,11 +938,11 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega let transfer = TransferInfo(id: referenceNumber, title: fileName, size: fileSize) transfer.uploadCallback = callback self.transfers.append(transfer) - + fileClient.start() } } - + } @MainActor func getFileDetails(_ fileName: String, path: [String]) async -> FileDetails? { @@ -834,9 +963,12 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega if path.count > 1 { fullPath = Array(path[0.. Date: Fri, 24 Oct 2025 14:16:30 -0700 Subject: Further along work towards auto updating. Show release notes for the builds that are more recent than yours. --- Hotline.xcodeproj/project.pbxproj | 35 ++- .../xcshareddata/swiftpm/Package.resolved | 11 +- Hotline/MacApp.swift | 56 +++- Hotline/State/AppUpdate.swift | 341 +++++++++++++++++++++ Hotline/Utility/NSWindowBridge.swift | 21 ++ Hotline/macOS/AboutView.swift | 339 +++++++++----------- Hotline/macOS/AppUpdateView.swift | 187 +++++++++++ 7 files changed, 788 insertions(+), 202 deletions(-) create mode 100644 Hotline/State/AppUpdate.swift create mode 100644 Hotline/macOS/AppUpdateView.swift (limited to 'Hotline/macOS') diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index d35440f..642a329 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -36,7 +36,7 @@ DA6300972B24036B0034CBFD /* HotlineClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6300962B24036B0034CBFD /* HotlineClient.swift */; }; DA65499A2BEC280E00EDB697 /* ServerMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6549992BEC280E00EDB697 /* ServerMessageView.swift */; }; DA65499C2BEC3FBD00EDB697 /* ServerAgreementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA65499B2BEC3FBD00EDB697 /* ServerAgreementView.swift */; }; - DA65499E2BEC438A00EDB697 /* NSWindowBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA65499D2BEC438A00EDB697 /* NSWindowBridge.swift */; }; + DA65499E2BEC438A00EDB697 /* NSWindowBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA65499D2BEC438A00EDB697 /* NSWindowBridge.swift */; platformFilters = (macos, ); }; DA6980792BF80525003E434B /* TextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6980782BF80525003E434B /* TextView.swift */; }; DA69807E2BFD449B003E434B /* MessageBoardEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA69807D2BFD449B003E434B /* MessageBoardEditorView.swift */; }; DA6980832BFFD06C003E434B /* BookmarkDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6980822BFFD06C003E434B /* BookmarkDocument.swift */; }; @@ -75,6 +75,9 @@ DAC002192B21630900A6C290 /* SwiftUIExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC002182B21630900A6C290 /* SwiftUIExtensions.swift */; }; DAC3D9832BC33FD000A727C9 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC3D9822BC33FD000A727C9 /* AppState.swift */; }; DAC87F072C5010E80060FADF /* HotlineExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC87F062C5010E80060FADF /* HotlineExtensions.swift */; }; + DACCE5E12EABE4B4008CDD92 /* AppUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DACCE5E02EABE4B4008CDD92 /* AppUpdate.swift */; platformFilters = (macos, ); }; + DACCE5E32EABE86A008CDD92 /* AppUpdateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */; platformFilters = (macos, ); }; + DACCE5EF2EAC19C9008CDD92 /* SwiftUIIntrospect in Frameworks */ = {isa = PBXBuildFile; productRef = DACCE5EE2EAC19C9008CDD92 /* SwiftUIIntrospect */; }; DADDB28B2B22B31F0024040D /* Tracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADDB28A2B22B31F0024040D /* Tracker.swift */; }; DADDB28D2B22B5920024040D /* Server.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADDB28C2B22B5920024040D /* Server.swift */; }; DADDB28F2B238D850024040D /* Hotline.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADDB28E2B238D850024040D /* Hotline.swift */; }; @@ -159,6 +162,8 @@ DAC002182B21630900A6C290 /* SwiftUIExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIExtensions.swift; sourceTree = ""; }; DAC3D9822BC33FD000A727C9 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; DAC87F062C5010E80060FADF /* HotlineExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineExtensions.swift; sourceTree = ""; }; + DACCE5E02EABE4B4008CDD92 /* AppUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdate.swift; sourceTree = ""; }; + DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdateView.swift; sourceTree = ""; }; DADDB28A2B22B31F0024040D /* Tracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tracker.swift; sourceTree = ""; }; DADDB28C2B22B5920024040D /* Server.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Server.swift; sourceTree = ""; }; DADDB28E2B238D850024040D /* Hotline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Hotline.swift; sourceTree = ""; }; @@ -178,6 +183,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + DACCE5EF2EAC19C9008CDD92 /* SwiftUIIntrospect in Frameworks */, DA72A0E02B4DA8CA00A0F48A /* SplitView in Frameworks */, DAB4D8872B4CB3610048A05C /* MarkdownUI in Frameworks */, ); @@ -191,6 +197,7 @@ children = ( DAC3D9822BC33FD000A727C9 /* AppState.swift */, DA2863D92B37BF6E00A7D050 /* Preferences.swift */, + DACCE5E02EABE4B4008CDD92 /* AppUpdate.swift */, ); path = State; sourceTree = ""; @@ -362,6 +369,7 @@ DA2863D72B37AD1C00A7D050 /* SettingsView.swift */, DAE136B92B9D1147007D8307 /* HotlinePanelView.swift */, DA55AC762BE589F700034857 /* AboutView.swift */, + DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */, DA65499B2BEC3FBD00EDB697 /* ServerAgreementView.swift */, DA6549992BEC280E00EDB697 /* ServerMessageView.swift */, 11F8288A2BF9428100216BA0 /* AccountManagerView.swift */, @@ -388,6 +396,7 @@ packageProductDependencies = ( DAB4D8862B4CB3610048A05C /* MarkdownUI */, DA72A0DF2B4DA8CA00A0F48A /* SplitView */, + DACCE5EE2EAC19C9008CDD92 /* SwiftUIIntrospect */, ); productName = Hotline; productReference = DA9CAFB72B126D5700CDA197 /* Hotline.app */; @@ -420,6 +429,7 @@ packageReferences = ( DAB4D8852B4CB3610048A05C /* XCRemoteSwiftPackageReference "swift-markdown-ui" */, DA72A0DE2B4DA8CA00A0F48A /* XCRemoteSwiftPackageReference "SplitView" */, + DACCE5ED2EAC19C9008CDD92 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */, ); productRefGroup = DA9CAFB82B126D5700CDA197 /* Products */; projectDirPath = ""; @@ -500,6 +510,7 @@ DA72A0DD2B4CD0BF00A0F48A /* NewsEditorView.swift in Sources */, DABFCC292B1530DC009F40D2 /* FoundationExtensions.swift in Sources */, DA57536C2B36BA1D00FAC277 /* TextDocument.swift in Sources */, + DACCE5E12EABE4B4008CDD92 /* AppUpdate.swift in Sources */, DA77253F2B21176D006C5ABB /* NewsView.swift in Sources */, DA99218E2C51AA5D0058FA6C /* HotlineDataBuilder.swift in Sources */, DA872B132BDDBF78008B1012 /* HotlinePanel.swift in Sources */, @@ -522,6 +533,7 @@ DA6980832BFFD06C003E434B /* BookmarkDocument.swift in Sources */, DAB4D87E2B4C8BCA0048A05C /* FilePreviewTextView.swift in Sources */, DA4F2C012B1A558E00D8ADDC /* ChatView.swift in Sources */, + DACCE5E32EABE86A008CDD92 /* AppUpdateView.swift in Sources */, DA32CD492B2931640053B98B /* User.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -656,7 +668,7 @@ CODE_SIGN_ENTITLEMENTS = Hotline/Hotline.entitlements; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 23; + CURRENT_PROJECT_VERSION = 24; DEVELOPMENT_ASSET_PATHS = "\"Hotline/Preview Content\""; DEVELOPMENT_TEAM = 5AEEV7QB2U; "ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = YES; @@ -675,7 +687,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 14.3; + MACOSX_DEPLOYMENT_TARGET = 15.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = co.goodmake.hotline; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -696,7 +708,7 @@ CODE_SIGN_ENTITLEMENTS = Hotline/Hotline.entitlements; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 23; + CURRENT_PROJECT_VERSION = 24; DEVELOPMENT_ASSET_PATHS = "\"Hotline/Preview Content\""; DEVELOPMENT_TEAM = 5AEEV7QB2U; "ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = YES; @@ -715,7 +727,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 14.3; + MACOSX_DEPLOYMENT_TARGET = 15.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = co.goodmake.hotline; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -768,6 +780,14 @@ minimumVersion = 2.3.0; }; }; + DACCE5ED2EAC19C9008CDD92 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/siteline/SwiftUI-Introspect"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 26.0.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -781,6 +801,11 @@ package = DAB4D8852B4CB3610048A05C /* XCRemoteSwiftPackageReference "swift-markdown-ui" */; productName = MarkdownUI; }; + DACCE5EE2EAC19C9008CDD92 /* SwiftUIIntrospect */ = { + isa = XCSwiftPackageProductDependency; + package = DACCE5ED2EAC19C9008CDD92 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */; + productName = SwiftUIIntrospect; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = DA9CAFAF2B126D5700CDA197 /* Project object */; diff --git a/Hotline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Hotline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 28a7003..f112b32 100644 --- a/Hotline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Hotline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "e60904df125ffbce75adea70452a77ae65a068371a6b6c4cfe0e6c155c4bdcee", + "originHash" : "26db0d070cfc8e002044ea1380ec5e675c0718ebf212f6e8a57f12cc6c295723", "pins" : [ { "identity" : "networkimage", @@ -36,6 +36,15 @@ "revision" : "5f613358148239d0292c0cef674a3c2314737f9e", "version" : "2.4.1" } + }, + { + "identity" : "swiftui-introspect", + "kind" : "remoteSourceControl", + "location" : "https://github.com/siteline/SwiftUI-Introspect", + "state" : { + "revision" : "a08b87f96b41055577721a6e397562b21ad52454", + "version" : "26.0.0" + } } ], "version" : 3 diff --git a/Hotline/MacApp.swift b/Hotline/MacApp.swift index a4ff3cb..a91df52 100644 --- a/Hotline/MacApp.swift +++ b/Hotline/MacApp.swift @@ -61,6 +61,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { // NotificationCenter.default.removeObserver(token) // } // } + + Task { + await AppUpdate.shared.checkForUpdatesOnLaunch() + } } func applicationWillTerminate(_ notification: Notification) { @@ -78,6 +82,7 @@ struct Application: App { @State private var hotlinePanel: HotlinePanel? = nil @State private var selection: TrackerSelection? = nil + @Bindable private var update = AppUpdate.shared @FocusedValue(\.activeHotlineModel) private var activeHotline: Hotline? @FocusedValue(\.activeServerState) private var activeServerState: ServerState? @@ -112,15 +117,43 @@ struct Application: App { } } } + .onChange(of: self.update.showWindow) { + if self.update.showWindow { + self.openWindow(id: "update") + } + } // MARK: About Box Window("About", id: "about") { AboutView() - .ignoresSafeArea() - .background(Color.hotlineRed) + .background(Color.hotlineRed, ignoresSafeAreaEdges: .all) + .windowFullScreenBehavior(.disabled) + .toolbar(removing: .title) + .gesture(WindowDragGesture()) + .background( + WindowConfigurator { window in + window.titlebarAppearsTransparent = true + window.titlebarSeparatorStyle = .none + window.isMovableByWindowBackground = true + + if let closeButton = window.standardWindowButton(.closeButton) { + closeButton.isHidden = false // make sure it’s visible + closeButton.isEnabled = true + } + + if let btn = window.standardWindowButton(.zoomButton) { + btn.isHidden = true + } + + if let btn = window.standardWindowButton(.miniaturizeButton) { + btn.isHidden = true + } + } + ) } .windowResizability(.contentSize) .windowStyle(.hiddenTitleBar) + .restorationBehavior(.disabled) .defaultPosition(.center) .commandsRemoved() // Remove About that was automatically added to Window menu. .commands { @@ -128,9 +161,26 @@ struct Application: App { Button("About Hotline") { openWindow(id: "about") } + + Button("Check for Updates...") { + Task { + await AppUpdate.shared.checkForUpdatesManually() + } + } } } + // MARK: Update Window + Window("New Update", id: "update") { + AppUpdateView() + .windowFullScreenBehavior(.disabled) + } + .windowResizability(.contentSize) + .windowStyle(.hiddenTitleBar) + .restorationBehavior(.disabled) + .defaultPosition(.center) + .commandsRemoved() + // MARK: Server Window WindowGroup(id: "server", for: Server.self) { server in ServerView(server: server) @@ -189,7 +239,7 @@ struct Application: App { } } Divider() - Button("Download Latest...") { + Button("Open Latest Release Page...") { if let url = URL(string: "https://github.com/mierau/hotline/releases/latest") { openURL(url) } diff --git a/Hotline/State/AppUpdate.swift b/Hotline/State/AppUpdate.swift new file mode 100644 index 0000000..601859e --- /dev/null +++ b/Hotline/State/AppUpdate.swift @@ -0,0 +1,341 @@ +import Foundation +import Observation +import AppKit + +struct UpdateReleaseInfo: Equatable { + let tagName: String + let displayVersion: String + let versionNumber: Double + let buildNumber: Int + let notes: String + let downloadURL: URL + let assetName: String +} + +struct AppUpdateMessage: Equatable { + enum Kind { + case info + case success + case error + } + + let title: String + let detail: String + let kind: Kind +} + +@Observable +final class AppUpdate { + static let shared = AppUpdate() + + private init() {} + + private enum CheckTrigger { + case automatic + case manual + } + + // MARK: - Public State + + var isChecking = false + var isDownloading = false + var showWindow = false + var release: UpdateReleaseInfo? + var releases: [UpdateReleaseInfo] = [] + var message: AppUpdateMessage? + var userInitiated = false + var releaseNotesCombined: String? + + // MARK: - Configuration + + private let releasesURL = URL(string: "https://api.github.com/repos/mierau/hotline/releases?per_page=100")! + private let remindInterval: TimeInterval = 60 * 60 * 24 * 14 + + private let defaults = UserDefaults.standard + private let remindDateKey = "update.remind.date" + private let lastPromptedVersionKey = "update.last.prompt.version" + + // MARK: - Public API + + func checkForUpdatesOnLaunch() async { + await checkForUpdates(trigger: .automatic) + } + + func checkForUpdatesManually() async { + await checkForUpdates(trigger: .manual) + } + + @MainActor + func startDownload() { + guard let release, isDownloading == false else { return } + + isDownloading = true + message = nil + + Task(priority: .userInitiated) { + await self.downloadRelease(release) + } + } + + @MainActor + func remindLater() { + guard let release else { + resetAndCloseWindow() + return + } + + recordPrompt(for: release, remindLater: true) + resetAndCloseWindow() + } + + @MainActor + func acknowledgeMessage() { + resetAndCloseWindow() + } + + @MainActor + func handleWindowDismissed() { + guard showWindow else { return } + + resetAndCloseWindow() + } + + // MARK: - Internal Logic + + private func checkForUpdates(trigger: CheckTrigger) async { + await MainActor.run { + self.isChecking = true + self.userInitiated = (trigger == .manual) + self.message = nil + self.releases = [] + self.release = nil + self.releaseNotesCombined = nil + self.isDownloading = false + if trigger == .manual { + self.showWindow = false + } + } + + do { + let newerReleases = try await fetchNewerReleases() + let latestRelease = newerReleases.first + let shouldShow: Bool + switch trigger { + case .manual: + shouldShow = latestRelease != nil + case .automatic: + if let latestRelease { + shouldShow = shouldPrompt(for: latestRelease) + } else { + shouldShow = false + } + } + + await MainActor.run { + self.isChecking = false + if shouldShow, let latestRelease { + self.release = latestRelease + self.releases = newerReleases + self.releaseNotesCombined = combinedReleaseNotes(from: newerReleases) + self.isDownloading = false + self.message = nil + self.showWindow = true + } else { + self.release = nil + self.releases = [] + self.releaseNotesCombined = nil + self.isDownloading = false + if trigger == .manual { + self.message = AppUpdateMessage( + title: "Hotline is up to date", + detail: "You're running the latest and greatest.", + kind: .success + ) + self.showWindow = true + } else { + self.message = nil + self.showWindow = false + } + } + } + } catch { + await MainActor.run { + self.isChecking = false + self.release = nil + self.releases = [] + self.releaseNotesCombined = nil + self.isDownloading = false + if trigger == .manual { + self.message = AppUpdateMessage( + title: "Unable to Check for Updates", + detail: error.localizedDescription, + kind: .error + ) + self.showWindow = true + } + } + } + } + + private func fetchNewerReleases() async throws -> [UpdateReleaseInfo] { + let (data, _) = try await URLSession.shared.data(from: releasesURL) + guard let jsonArray = try JSONSerialization.jsonObject(with: data, options: []) as? [[String: Any]] else { + throw NSError(domain: "AppUpdate", code: -10, userInfo: [NSLocalizedDescriptionKey: "Malformed GitHub releases response."]) + } + + let parsed = jsonArray.compactMap(parseRelease) + let newer = parsed.filter(isReleaseNewer) + return newer + } + + private func parseRelease(_ json: [String: Any]) -> UpdateReleaseInfo? { + guard let tagName = json["tag_name"] as? String else { + return nil + } + + let notes = (json["body"] as? String) ?? "" + guard + let assets = json["assets"] as? [[String: Any]], + let asset = assets.first, + let downloadString = asset["browser_download_url"] as? String, + let downloadURL = URL(string: downloadString) + else { + return nil + } + + let assetName = (asset["name"] as? String) ?? downloadURL.lastPathComponent + + let versionPattern = #"^([0-9\.]+)beta([0-9]+)"# + guard let regex = try? NSRegularExpression(pattern: versionPattern, options: []) else { + return nil + } + + let range = NSRange(location: 0, length: tagName.utf16.count) + guard let match = regex.firstMatch(in: tagName, options: [], range: range), + match.numberOfRanges >= 3, + let versionRange = Range(match.range(at: 1), in: tagName), + let buildRange = Range(match.range(at: 2), in: tagName), + let versionNumber = Double(String(tagName[versionRange])), + let buildNumber = Int(String(tagName[buildRange])) + else { + return nil + } + + let versionComponent = String(tagName[versionRange]) + let buildComponent = String(tagName[buildRange]) + let displayVersion = "\(versionComponent)b\(buildComponent)" + + return UpdateReleaseInfo( + tagName: tagName, + displayVersion: displayVersion, + versionNumber: versionNumber, + buildNumber: buildNumber, + notes: notes, + downloadURL: downloadURL, + assetName: assetName + ) + } + + private func combinedReleaseNotes(from releases: [UpdateReleaseInfo]) -> String? { + guard let firstRelease = releases.first else { return nil } + + let firstNotes = firstRelease.notes.trimmingCharacters(in: .whitespacesAndNewlines) + let firstBody = firstNotes.isEmpty ? "_No release notes provided._" : firstNotes + + guard releases.count > 1 else { + return firstBody + } + + let olderSections = releases.dropFirst().map { release -> String in + let trimmed = release.notes.trimmingCharacters(in: .whitespacesAndNewlines) + let body = trimmed.isEmpty ? "_No release notes provided._" : trimmed + return "## Hotline \(release.displayVersion)\n\n\(body)" + } + + let olderCombined = olderSections.joined(separator: "\n\n---\n\n") + return "\(firstBody)\n\n---\n\n\(olderCombined)" + } + + 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) + + if FileManager.default.fileExists(atPath: destinationURL.path) { + try? FileManager.default.removeItem(at: destinationURL) + } + + try FileManager.default.moveItem(at: temporaryURL, to: destinationURL) + + await MainActor.run { + self.isDownloading = false + NSWorkspace.shared.activateFileViewerSelecting([destinationURL]) + self.resetAndCloseWindow() + } + } catch { + await MainActor.run { + self.isDownloading = false + self.message = AppUpdateMessage( + title: "Download Failed", + detail: error.localizedDescription, + kind: .error + ) + self.showWindow = true + } + } + } + + private func currentApplicationVersion() -> (version: Double, build: Int) { + let info = Bundle.main.infoDictionary ?? [:] + let versionString = info["CFBundleShortVersionString"] as? String ?? "0" + let buildString = info["CFBundleVersion"] as? String ?? "0" + let version = Double(versionString) ?? 0 + let build = Int(buildString) ?? 0 + return (version, build) + } + + private func isReleaseNewer(_ release: UpdateReleaseInfo) -> Bool { + let current = currentApplicationVersion() + if release.versionNumber > current.version { + return true + } + if release.versionNumber == current.version { + return release.buildNumber > current.build + } + return false + } + + private func shouldPrompt(for release: UpdateReleaseInfo) -> Bool { + if defaults.string(forKey: lastPromptedVersionKey) != release.tagName { + return true + } + guard let remindDate = defaults.object(forKey: remindDateKey) as? Date else { + return true + } + return remindDate <= Date() + } + + @MainActor + private func recordPrompt(for release: UpdateReleaseInfo, remindLater: Bool) { + defaults.set(release.tagName, forKey: lastPromptedVersionKey) + if remindLater { + let nextReminder = Date().addingTimeInterval(remindInterval) + defaults.set(nextReminder, forKey: remindDateKey) + } else { + defaults.removeObject(forKey: remindDateKey) + } + } + + @MainActor + private func resetAndCloseWindow() { + isChecking = false + isDownloading = false + release = nil + releases = [] + releaseNotesCombined = nil + message = nil + userInitiated = false + showWindow = false + } +} diff --git a/Hotline/Utility/NSWindowBridge.swift b/Hotline/Utility/NSWindowBridge.swift index 5cd7197..8f766d9 100644 --- a/Hotline/Utility/NSWindowBridge.swift +++ b/Hotline/Utility/NSWindowBridge.swift @@ -23,3 +23,24 @@ public struct NSWindowAccessor: NSViewRepresentable { public func makeNSView(context: Context) -> NSView { return NSWindowAccessorView( configCode ) } public func updateNSView(_ nsView: NSView, context: Context) {} } + + +//import SwiftUI + + /// A helper view you can embed once per window to run a closure +/// with the underlying NSWindow reference. +struct WindowConfigurator: NSViewRepresentable { + let configure: (NSWindow) -> Void + + func makeNSView(context: Context) -> NSView { + let view = NSView() + DispatchQueue.main.async { + if let window = view.window { + configure(window) + } + } + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { } +} diff --git a/Hotline/macOS/AboutView.swift b/Hotline/macOS/AboutView.swift index baafcf8..57f855e 100644 --- a/Hotline/macOS/AboutView.swift +++ b/Hotline/macOS/AboutView.swift @@ -1,11 +1,5 @@ import SwiftUI - -enum VersionCheckState { - case needToCheck - case checking - case upToDate - case updateAvailable(version: String) -} +import SwiftUIIntrospect struct AboutContributor: Identifiable { let id: UUID = UUID() @@ -14,164 +8,66 @@ struct AboutContributor: Identifiable { let pictureURL: URL? } -struct AboutView: View { +struct AboutContributorView: View { @Environment(\.openURL) private var openURL - @State private var versionCheck: VersionCheckState = .needToCheck - @State private var downloadURL: String = "https://github.com/mierau/hotline/releases/latest" - @State private var contributors: [AboutContributor] = [] + let contributor: AboutContributor var body: some View { - HStack(alignment: .center, spacing: 0) { - VStack(alignment: .center, spacing: 0) { - Spacer() - - Image("About Hotline") - .padding(.top, 44) - - Text("Hotline") - .font(.system(size: 28)) - .fontWeight(.bold) - .padding(.top, 12) - .kerning(-1.0) - .foregroundColor(.white) - - let appDetails = getAppVersionAndBuild() - Text("Version \(String(format: "%.1f", appDetails.version))b\(appDetails.build)") - .foregroundColor(.white) - .opacity(0.4) - - HStack(alignment: .center) { - switch versionCheck { - case .needToCheck: - Button("Check for Updates") { - Task { - await checkForUpdate() - } - } - .controlSize(.small) - case .checking: - HStack(spacing: 8) { - ProgressView() - .controlSize(.small) - Text("Checking for updates...") - .fontWeight(.semibold) - } - .foregroundStyle(.white) - .tint(.white) - case .upToDate: - Label("Hotline is up to date.", systemImage: "checkmark.circle.fill") - .foregroundStyle(.white) - .fontWeight(.semibold) - .tint(.white) - .onTapGesture { - versionCheck = .needToCheck - } - case .updateAvailable(let version): - Button("Download Latest \(version)") { - if let url = URL(string: downloadURL) { - openURL(url) - } - } - .controlSize(.small) + HStack { + if let pictureURL = contributor.pictureURL { + AsyncImage(url: pictureURL) { phase in + if let image = phase.image { + image + .interpolation(.high) + .resizable() + .scaledToFit() + .background(.white) + .frame(width: 32, height: 32) + } else if phase.error != nil { + Color.clear + .frame(width: 32, height: 32) + } else { + Color.white + .opacity(0.2) + .frame(width: 32, height: 32) } } - .frame(height: 40) - - Spacer() + .frame(width: 32, height: 32) + .clipShape(Circle()) } - .frame(width: 250) - Spacer() - - ScrollView(.vertical) { - VStack(alignment: .leading, spacing: 16) { - - VStack(alignment: .leading, spacing: 4) { - Link(destination: URL(string: "https://github.com/mierau/hotline")!) { - HStack(alignment: .center, spacing: 4) { - Text("Contributors") - .lineLimit(1) - .font(.system(size: 16)) - .fontWeight(.semibold) - .foregroundStyle(.black) - .opacity(0.75) + VStack(alignment: .leading, spacing: 2) { + Text(contributor.username) + .fontWeight(.semibold) + .foregroundStyle(.white) + .lineLimit(1) + + Text(contributor.webURL.absoluteString) + .lineLimit(1) + .truncationMode(.middle) + .font(.system(size: 11)) + .foregroundStyle(.white.opacity(0.4)) + } + } +// } +// .accessibilityAddTraits(.isLink) +// .pointerStyle(.link) + } +} - Image(systemName: "arrow.forward.circle.fill") - .resizable() - .fontWeight(.bold) - .scaledToFit() - .frame(width: 12, height: 12) - .foregroundStyle(.black) - .opacity(0.75) - } - } - .padding(.top, 24) - - Text("Hotline is an open source project made possible by its contributors.") - .font(.system(size: 11)) - .foregroundStyle(.black) - .blendMode(.overlay) - .padding(.trailing, 32) - } - .padding(.bottom, 8) - - ForEach(contributors) { contributor in - Link(destination: contributor.webURL) { - HStack { - if let pictureURL = contributor.pictureURL { - AsyncImage(url: pictureURL) { phase in - if let image = phase.image { - image - .interpolation(.high) - .resizable() - .scaledToFit() - .background(.white) - .frame(width: 32, height: 32) - } else if phase.error != nil { - Color.clear - .frame(width: 32, height: 32) - } else { - Color.white - .opacity(0.2) - .frame(width: 32, height: 32) - } - } - .frame(width: 32, height: 32) - .clipShape(Circle()) - -// AsyncImage(url: pictureURL) { img in -// img -// .interpolation(.high) -// .resizable() -// .scaledToFit() -// .background(.white) -// } placeholder: { -// Color.white.opacity(0.2) -// .frame(width: 32, height: 32) -// } -// .frame(width: 32, height: 32) -// .clipShape(Circle()) - } - - VStack(alignment: .leading, spacing: 2) { - Text(contributor.username) - .fontWeight(.semibold) - .foregroundStyle(.white) - .lineLimit(1) - - Text(contributor.webURL.absoluteString) - .lineLimit(1) - .truncationMode(.middle) - .font(.system(size: 11)) - .foregroundStyle(.white.opacity(0.4)) - } - } - } - } +struct AboutView: View { + @Environment(\.openURL) private var openURL + + @State private var contributors: [AboutContributor] = [] + + var body: some View { + HStack(alignment: .center, spacing: 0) { + self.brandView + self.contributorsList + .background { + Color.black.blendMode(.softLight).opacity(0.3).ignoresSafeArea() } - } - .scrollClipDisabled() } .frame(width: 570, height: 330) .background( @@ -184,12 +80,105 @@ struct AboutView: View { .offset(x: 250) } ) - .background(Color.hotlineRed) .task { await loadContributors() } } + private var brandView: some View { + VStack(alignment: .center, spacing: 4) { + Spacer() + + Image("About Hotline") + + Text("Hotline") + .font(.system(size: 28)) + .fontWeight(.bold) + .padding(.top, 12) + .kerning(-1.0) + .foregroundColor(.white) + + let appDetails = getAppVersionAndBuild() + Button { + self.openURL(URL(string: "https://github.com/mierau/hotline/releases/tag/\(appDetails.version)beta\(appDetails.build)")!) + } label: { + + Text("Version \(String(format: "%.1f", appDetails.version))b\(appDetails.build)") + .foregroundColor(.white.opacity(0.756)) + .padding(.vertical, 4) + .padding(.horizontal, 12) + .background { + Capsule() + .fill(.white.opacity(0.5)) + .blendMode(.softLight) + } + } + .buttonStyle(.plain) + .buttonBorderShape(.capsule) + .padding(.bottom, 16) + + Spacer() + } + .frame(width: 250) + } + + private var contributorsList: some View { + ScrollView(.vertical) { + VStack(alignment: .leading, spacing: 16) { + self.contributorHeaderView + ForEach(self.contributors) { contributor in + Button { + self.openURL(contributor.webURL) + } label: { + AboutContributorView(contributor: contributor) + } + .buttonStyle(.plain) + .accessibilityAddTraits(.isLink) + .pointerStyle(.link) + } + } + .frame(maxWidth: .infinity) + .padding() + } + .scrollClipDisabled() + .scrollContentBackground(.hidden) +// .introspect(.scrollView, on: .macOS(.v10_15, .v11, .v12, .v13, .v14, .v15, .v26)) { v in +// v.automaticallyAdjustsContentInsets = false +// } + } + + private var contributorHeaderView: some View { + VStack(alignment: .leading, spacing: 4) { + Link(destination: URL(string: "https://github.com/mierau/hotline")!) { + HStack(alignment: .center, spacing: 4) { + Text("Contributors") + .lineLimit(1) + .font(.system(size: 16)) + .fontWeight(.semibold) + .foregroundStyle(.black) + .opacity(0.8) + + Image(systemName: "arrow.forward.circle.fill") + .resizable() + .fontWeight(.bold) + .scaledToFit() + .frame(width: 12, height: 12) + .foregroundStyle(.black) + .opacity(0.4) + } + } + .accessibilityAddTraits(.isLink) + .pointerStyle(.link) + + Text("Hotline is an open source project made possible by its contributors.") + .font(.system(size: 11)) + .foregroundStyle(.black) + .opacity(0.5) + .padding(.trailing, 32) + } + .padding(.bottom, 8) + } + func loadContributors() async { var newContributors: [AboutContributor] = [] @@ -214,43 +203,7 @@ struct AboutView: View { contributors = newContributors } } - - func checkForUpdate() async { - let appDetails = getAppVersionAndBuild() - - self.versionCheck = .checking - - do { - let url = URL(string: "https://api.github.com/repos/mierau/hotline/releases/latest")! - let (data, _) = try await URLSession.shared.data(from: url) - let versionExpression = /^([0-9\.]+)beta([0-9]+)/ - - if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], - let tagName = json["tag_name"] as? String, - let assets = json["assets"] as? [[String: Any]], - let firstAsset = assets.first, - let assetDownloadURL = firstAsset["browser_download_url"] as? String, - let versionMatches = try? versionExpression.wholeMatch(in: tagName) { - if let versionNumber = Double(versionMatches.1), - let buildNumber = Int(versionMatches.2), - versionNumber > appDetails.version || buildNumber > appDetails.build { - let versionString = "\(versionMatches.1)b\(versionMatches.2)" - self.versionCheck = .updateAvailable(version: versionString) - downloadURL = assetDownloadURL - } - else { - self.versionCheck = .upToDate - } - } - else { - self.versionCheck = .needToCheck - } - } - catch { - self.versionCheck = .needToCheck - } - } - + func getAppVersionAndBuild() -> (version: Double, build: Int) { let infoDictionary = Bundle.main.infoDictionary! let version = Double(infoDictionary["CFBundleShortVersionString"]! as! String)! diff --git a/Hotline/macOS/AppUpdateView.swift b/Hotline/macOS/AppUpdateView.swift new file mode 100644 index 0000000..89312fc --- /dev/null +++ b/Hotline/macOS/AppUpdateView.swift @@ -0,0 +1,187 @@ +import SwiftUI +import MarkdownUI +import AppKit +import Observation + +struct AppUpdateView: View { + @Environment(\.dismiss) private var dismiss + @Bindable private var update = AppUpdate.shared + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + if let message = update.message { + messageOnlyView(message) + } + else if update.release != nil { + headerSection + releaseNotesSection + actionRow + } + else { + defaultPlaceholder + } + } + .padding(.horizontal, 24) + .padding(.bottom, 24) + .padding(.top, 8) + .frame(width: update.message != nil ? 380 : 520) + .frame(idealHeight: 360) + .onChange(of: update.showWindow) { _, show in + if !show { + dismiss() + } + } + .onDisappear { + update.handleWindowDismissed() + } + } + + private var headerSection: some View { + HStack(alignment: .center, spacing: 8) { + Image(nsImage: NSApplication.shared.applicationIconImage) + .resizable() + .scaledToFit() + .frame(width: 56, height: 56) + .shadow(color: Color.black.mix(with: .red, by: 0.4).opacity(0.15), radius: 3, y: 1.5) + + VStack(alignment: .leading, spacing: 2) { + if let release = update.release { + Text("Hotline \(release.displayVersion)") + .font(.title2) + .fontWeight(.semibold) + } + Text("A new version of Hotline is available. 🎉") + .foregroundStyle(.secondary) + } + } + } + + private var releaseNotesSection: some View { + ScrollView(.vertical) { + Markdown(releaseNotesMarkdown()) + .textSelection(.enabled) + .markdownTheme(.gitHub.text(text: { + FontSize(.em(0.85)) + })) + .font(.system(size: 14)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 12) + .padding(.horizontal, 12) + } + .frame(minHeight: 220, maxHeight: 260) + .background( + RoundedRectangle(cornerRadius: 12) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + } + + private var actionRow: some View { + HStack { + Button("Not Now") { + update.remindLater() + } + .buttonBorderShape(.capsule) + .keyboardShortcut(.escape, modifiers: []) + .controlSize(.large) + .disabled(update.isDownloading) + + Spacer() + + if update.isDownloading { + ProgressView() + .controlSize(.small) + .padding(.trailing, 12) + } + + Button("Download") { + update.startDownload() + } + .buttonBorderShape(.capsule) + .keyboardShortcut(.defaultAction) + .controlSize(.large) + .disabled(update.isDownloading) + } + } + + @ViewBuilder + private func messageOnlyView(_ message: AppUpdateMessage) -> some View { + let iconName = { + switch message.kind { + case .info: + return "info.circle" + case .success: + return "checkmark.circle.fill" + case .error: + return "exclamationmark.triangle.fill" + } + }() + + HStack(alignment: .center, spacing: 12) { + + if message.kind == .success { + Text("👍") + .font(.system(size: 42)) + .shadow(color: .yellow.mix(with: .black, by: 0.3).opacity(0.2), radius: 4, y: 1.5) + } + else { + Image(systemName: iconName) + .resizable() + .scaledToFit() + .symbolRenderingMode(.multicolor) + .frame(width: 48, height: 48) + } + + VStack(alignment: .leading, spacing: 2) { + Text(message.title) + .font(.title2) + .fontWeight(.semibold) + Text(message.detail) + .foregroundStyle(.secondary) + } + + Spacer() + } + } + + private var defaultPlaceholder: some View { + VStack(alignment: .center, spacing: 12) { + Text("No update information available.") + .font(.headline) + Button("Close") { + update.acknowledgeMessage() + } + .keyboardShortcut(.defaultAction) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func releaseNotesMarkdown() -> String { + if let combined = update.releaseNotesCombined? + .trimmingCharacters(in: .whitespacesAndNewlines), + combined.isEmpty == false { + return combined + } + + let fallback = update.release?.notes.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return fallback.isEmpty ? "_No release notes provided._" : fallback + } +} + +#Preview { + AppUpdate.shared.release = UpdateReleaseInfo( + tagName: "1.0beta1", + displayVersion: "1.0b1", + versionNumber: 1.0, + buildNumber: 1, + notes: """ + - Added support for release notes in Markdown. + - Improved the update workflow for macOS users. + """, + downloadURL: URL(string: "https://example.com")!, + assetName: "Hotline.zip" + ) + AppUpdate.shared.releases = [AppUpdate.shared.release!] + AppUpdate.shared.releaseNotesCombined = nil + AppUpdate.shared.showWindow = true + return AppUpdateView() +} -- cgit From 1118b224e4a6561e55e757b8b826780be1444f07 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 24 Oct 2025 14:20:11 -0700 Subject: Add keyboard shortcut to Tracker window to activate search (cmd-f) --- Hotline/macOS/TrackerView.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) (limited to 'Hotline/macOS') diff --git a/Hotline/macOS/TrackerView.swift b/Hotline/macOS/TrackerView.swift index c14f982..5e848de 100644 --- a/Hotline/macOS/TrackerView.swift +++ b/Hotline/macOS/TrackerView.swift @@ -33,6 +33,7 @@ struct TrackerView: View { @State private var trackerServers: [Bookmark: [BookmarkServer]] = [:] @State private var loadingTrackers: Set = [] @State private var searchText: String = "" + @State private var isSearching = false @Query(sort: \Bookmark.order) private var bookmarks: [Bookmark] @Binding var selection: TrackerSelection? @@ -323,7 +324,8 @@ struct TrackerView: View { openWindow(id: "server", value: s) } }) - .searchable(text: $searchText, placement: .automatic, prompt: "Search") + .searchable(text: $searchText, isPresented: $isSearching, placement: .automatic, prompt: "Search") + .background(Button("", action: { isSearching = true }).keyboardShortcut("f").hidden()) } private var hotlineLogoImage: some View { -- cgit From 9491be2c0e5e4d52258b691ffe232be5bd83268b Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 24 Oct 2025 14:21:34 -0700 Subject: Add cmd-f to activate search in Files. --- Hotline/macOS/FilesView.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) (limited to 'Hotline/macOS') diff --git a/Hotline/macOS/FilesView.swift b/Hotline/macOS/FilesView.swift index f9fb36f..3447b28 100644 --- a/Hotline/macOS/FilesView.swift +++ b/Hotline/macOS/FilesView.swift @@ -215,6 +215,7 @@ struct FilesView: View { @State private var fileDetails: FileDetails? @State private var uploadFileSelectorDisplayed: Bool = false @State private var searchText: String = "" + @State private var isSearching: Bool = false private var isShowingSearchResults: Bool { switch model.fileSearchStatus { @@ -436,7 +437,8 @@ struct FilesView: View { .frame(maxWidth: .infinity) } } - .searchable(text: $searchText, placement: .automatic, prompt: "Search") + .searchable(text: $searchText, isPresented: $isSearching, placement: .automatic, prompt: "Search") + .background(Button("", action: { isSearching = true }).keyboardShortcut("f").hidden()) .toolbar { ToolbarItemGroup(placement: .automatic) { Button { -- cgit From cf113ea053334175b93770b025c1f7d22eda6eab Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 24 Oct 2025 22:47:44 -0700 Subject: A first pass at chat persistence. Also some chat UI cleanup. --- Hotline.xcodeproj/project.pbxproj | 12 ++ Hotline/Hotline/HotlineClient.swift | 4 + Hotline/Managers/ChatStore.swift | 246 ++++++++++++++++++++++++ Hotline/Models/ChatMessage.swift | 45 +++++ Hotline/Models/Hotline.swift | 132 +++++++++++-- Hotline/iOS/ChatView.swift | 16 ++ Hotline/macOS/ChatView.swift | 288 ++++++++++++++++++++--------- Hotline/macOS/FilesView.swift | 3 +- Hotline/macOS/MessageBoardEditorView.swift | 2 +- Hotline/macOS/ServerAgreementView.swift | 27 ++- Hotline/macOS/ServerMessageView.swift | 2 +- Hotline/macOS/ServerView.swift | 4 +- Hotline/macOS/SettingsView.swift | 19 ++ 13 files changed, 671 insertions(+), 129 deletions(-) create mode 100644 Hotline/Managers/ChatStore.swift (limited to 'Hotline/macOS') diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index 642a329..a31f398 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -74,6 +74,7 @@ DABFCC292B1530DC009F40D2 /* FoundationExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABFCC282B1530DC009F40D2 /* FoundationExtensions.swift */; }; DAC002192B21630900A6C290 /* SwiftUIExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC002182B21630900A6C290 /* SwiftUIExtensions.swift */; }; DAC3D9832BC33FD000A727C9 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC3D9822BC33FD000A727C9 /* AppState.swift */; }; + DAC6B2E02EAC6236004E2CBA /* ChatStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC6B2DE2EAC6236004E2CBA /* ChatStore.swift */; }; DAC87F072C5010E80060FADF /* HotlineExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC87F062C5010E80060FADF /* HotlineExtensions.swift */; }; DACCE5E12EABE4B4008CDD92 /* AppUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DACCE5E02EABE4B4008CDD92 /* AppUpdate.swift */; platformFilters = (macos, ); }; DACCE5E32EABE86A008CDD92 /* AppUpdateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */; platformFilters = (macos, ); }; @@ -161,6 +162,7 @@ DABFCC282B1530DC009F40D2 /* FoundationExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoundationExtensions.swift; sourceTree = ""; }; DAC002182B21630900A6C290 /* SwiftUIExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIExtensions.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 = ""; }; DAC87F062C5010E80060FADF /* HotlineExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineExtensions.swift; sourceTree = ""; }; DACCE5E02EABE4B4008CDD92 /* AppUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdate.swift; sourceTree = ""; }; DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdateView.swift; sourceTree = ""; }; @@ -247,6 +249,7 @@ DA4B8F392EA6FB3C00CBFD53 /* iOSApp.swift */, DA4F2BF92B1A516F00D8ADDC /* iOS */, DAE734F72B2E4126000C56F6 /* macOS */, + DAC6B2DF2EAC6236004E2CBA /* Managers */, DA4B8F3B2EA6FB5F00CBFD53 /* State */, DADDB2892B22B2C60024040D /* Models */, DABFCC262B1530AE009F40D2 /* Hotline */, @@ -329,6 +332,14 @@ path = Utility; sourceTree = ""; }; + DAC6B2DF2EAC6236004E2CBA /* Managers */ = { + isa = PBXGroup; + children = ( + DAC6B2DE2EAC6236004E2CBA /* ChatStore.swift */, + ); + path = Managers; + sourceTree = ""; + }; DADDB2892B22B2C60024040D /* Models */ = { isa = PBXGroup; children = ( @@ -534,6 +545,7 @@ DAB4D87E2B4C8BCA0048A05C /* FilePreviewTextView.swift in Sources */, DA4F2C012B1A558E00D8ADDC /* ChatView.swift in Sources */, DACCE5E32EABE86A008CDD92 /* AppUpdateView.swift in Sources */, + DAC6B2E02EAC6236004E2CBA /* ChatStore.swift in Sources */, DA32CD492B2931640053B98B /* User.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index 3c6a643..0e54872 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -209,7 +209,11 @@ class HotlineClient: NetSocketDelegate { } @MainActor func disconnect() { + let wasConnected = self.connectionStatus != .disconnected self.reset() + if wasConnected { + self.updateConnectionStatus(.disconnected) + } } // MARK: - Packets diff --git a/Hotline/Managers/ChatStore.swift b/Hotline/Managers/ChatStore.swift new file mode 100644 index 0000000..daf8ca0 --- /dev/null +++ b/Hotline/Managers/ChatStore.swift @@ -0,0 +1,246 @@ +import Foundation +import CryptoKit +import Security + +actor ChatStore { + static let shared = ChatStore() + static let historyClearedNotification = Notification.Name("ChatStoreHistoryCleared") + + struct SessionKey: Hashable { + let address: String + let port: Int + + var identifier: String { "\(address):\(port)" } + } + + struct Metadata: Codable { + let address: String + let port: Int + var serverName: String? + var createdAt: Date + var updatedAt: Date + + mutating func update(serverName: String?, timestamp: Date) { + if let serverName, !serverName.isEmpty { + self.serverName = serverName + } + self.updatedAt = timestamp + } + } + + struct Entry: Codable { + let id: UUID + let body: String + let username: String? + let type: String + let date: Date + } + + struct LoadResult { + let entries: [Entry] + let metadata: Metadata? + } + + private struct LogFile: Codable { + var metadata: Metadata + var entries: [Entry] + } + + private enum StoreError: Error { + case encryptionFailed + case invalidCombinedCiphertext + case keyGenerationFailed + } + + private let keychainKey = "chatlog-encryption-key" + private let applicationFolderName = "Hotline" + private let logsFolderName = "ChatLogs" + private let fileExtension = "hlchat" + private let maxEntries = 2000 + + private var cache: [SessionKey: LogFile] = [:] + private var cachedDirectory: URL? + private var cachedKey: SymmetricKey? + + func append(entry: Entry, for key: SessionKey, serverName: String?) async { + do { + var logFile = try loadLogFile(for: key) ?? newLogFile(for: key, serverName: serverName) + + logFile.entries.append(entry) + if logFile.entries.count > maxEntries { + logFile.entries = Array(logFile.entries.suffix(maxEntries)) + } + + logFile.metadata.update(serverName: serverName, timestamp: entry.date) + cache[key] = logFile + + try persist(logFile, for: key) + } + catch { + print("ChatStore: failed to append entry —", error) + } + } + + func loadHistory(for key: SessionKey, limit: Int? = nil) async -> LoadResult { + do { + let logFile = try loadLogFile(for: key) + guard let logFile else { + return LoadResult(entries: [], metadata: nil) + } + + let entries: [Entry] + if let limit, limit < logFile.entries.count { + entries = Array(logFile.entries.suffix(limit)) + } + else { + entries = logFile.entries + } + + return LoadResult(entries: entries, metadata: logFile.metadata) + } + catch { + print("ChatStore: failed to load history —", error) + return LoadResult(entries: [], metadata: nil) + } + } + + func clearAll() async { + let fm = FileManager.default + if let dir = try? directoryURL(), fm.fileExists(atPath: dir.path) { + do { + try fm.removeItem(at: dir) + } + catch { + print("ChatStore: failed to clear chat logs —", error) + } + } + + cache.removeAll() + cachedDirectory = nil + + await MainActor.run { + NotificationCenter.default.post(name: Self.historyClearedNotification, object: nil) + } + } + + static func digest(for string: String) -> String { + let hash = SHA256.hash(data: Data(string.utf8)) + return hash.compactMap { String(format: "%02x", $0) }.joined() + } + + private func newLogFile(for key: SessionKey, serverName: String?) -> LogFile { + let now = Date() + var metadata = Metadata(address: key.address, port: key.port, serverName: nil, createdAt: now, updatedAt: now) + metadata.update(serverName: serverName, timestamp: now) + let logFile = LogFile(metadata: metadata, entries: []) + cache[key] = logFile + return logFile + } + + private func loadLogFile(for key: SessionKey) throws -> LogFile? { + if let cached = cache[key] { + return cached + } + + let url = try fileURL(for: key) + let fm = FileManager.default + guard fm.fileExists(atPath: url.path) else { + return nil + } + + let encryptedData = try Data(contentsOf: url) + let decryptedData = try decrypt(encryptedData) + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let logFile = try decoder.decode(LogFile.self, from: decryptedData) + cache[key] = logFile + return logFile + } + + private func persist(_ logFile: LogFile, for key: SessionKey) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(logFile) + let encrypted = try encrypt(data) + let url = try fileURL(for: key) + let fm = FileManager.default + let directory = url.deletingLastPathComponent() + if !fm.fileExists(atPath: directory.path) { + try fm.createDirectory(at: directory, withIntermediateDirectories: true) + } + try encrypted.write(to: url, options: .atomic) + } + + private func directoryURL() throws -> URL { + if let cachedDirectory { + return cachedDirectory + } + + let fm = FileManager.default + guard let base = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { + throw StoreError.keyGenerationFailed + } + + let appDirectory = base.appendingPathComponent(applicationFolderName, isDirectory: true) + let logsDirectory = appDirectory.appendingPathComponent(logsFolderName, isDirectory: true) + + if !fm.fileExists(atPath: appDirectory.path) { + try fm.createDirectory(at: appDirectory, withIntermediateDirectories: true) + } + if !fm.fileExists(atPath: logsDirectory.path) { + try fm.createDirectory(at: logsDirectory, withIntermediateDirectories: true) + } + + cachedDirectory = logsDirectory + return logsDirectory + } + + private func fileURL(for key: SessionKey) throws -> URL { + let directory = try directoryURL() + let digest = Self.digest(for: key.identifier) + return directory.appendingPathComponent(digest).appendingPathExtension(fileExtension) + } + + private func encrypt(_ data: Data) throws -> Data { + let key = try symmetricKey() + let sealedBox = try AES.GCM.seal(data, using: key) + guard let combined = sealedBox.combined else { + throw StoreError.encryptionFailed + } + return combined + } + + private func decrypt(_ data: Data) throws -> Data { + let key = try symmetricKey() + let sealedBox = try AES.GCM.SealedBox(combined: data) + return try AES.GCM.open(sealedBox, using: key) + } + + private func symmetricKey() throws -> SymmetricKey { + if let cachedKey { + return cachedKey + } + + if let stored = DAKeychain.shared[keychainKey], + let storedData = Data(base64Encoded: stored), + storedData.count == 32 { + let key = SymmetricKey(data: storedData) + cachedKey = key + return key + } + + var bytes = [UInt8](repeating: 0, count: 32) + let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + guard status == errSecSuccess else { + throw StoreError.keyGenerationFailed + } + + let data = Data(bytes) + let key = SymmetricKey(data: data) + DAKeychain.shared[keychainKey] = data.base64EncodedString() + cachedKey = key + return key + } +} diff --git a/Hotline/Models/ChatMessage.swift b/Hotline/Models/ChatMessage.swift index e585cdf..365f91f 100644 --- a/Hotline/Models/ChatMessage.swift +++ b/Hotline/Models/ChatMessage.swift @@ -3,8 +3,53 @@ import SwiftUI enum ChatMessageType { case agreement case status + case joined + case left case message case server + case signOut +} + +extension ChatMessageType { + var storageKey: String { + switch self { + case .agreement: + return "agreement" + case .status: + return "status" + case .joined: + return "joined" + case .left: + return "left" + case .message: + return "message" + case .server: + return "server" + case .signOut: + return "signOut" + } + } + + init?(storageKey: String) { + switch storageKey { + case "agreement": + self = .agreement + case "status": + self = .status + case "joined": + self = .joined + case "left": + self = .left + case "message": + self = .message + case "server": + self = .server + case "signOut": + self = .signOut + default: + return nil + } + } } struct ChatMessage: Identifiable { diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift index 054a1bc..8cdeccd 100644 --- a/Hotline/Models/Hotline.swift +++ b/Hotline/Models/Hotline.swift @@ -191,8 +191,11 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega var unreadPublicChat: Bool = false var errorDisplayed: Bool = false var errorMessage: String? = nil - + @ObservationIgnored var bannerClient: HotlineFilePreviewClient? + @ObservationIgnored private var chatSessionKey: ChatStore.SessionKey? + @ObservationIgnored private var restoredChatSessionKey: ChatStore.SessionKey? + @ObservationIgnored private var chatHistoryObserver: NSObjectProtocol? #if os(macOS) var bannerImage: NSImage? = nil #elseif os(iOS) @@ -206,6 +209,10 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega self.trackerClient = trackerClient self.client = client self.client.delegate = self + + self.chatHistoryObserver = NotificationCenter.default.addObserver(forName: ChatStore.historyClearedNotification, object: nil, queue: .main) { [weak self] _ in + self?.handleChatHistoryCleared() + } } // MARK: - @@ -233,7 +240,13 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega self.serverName = server.name self.username = username self.iconID = iconID - + + let key = sessionKey(for: server) + self.chatSessionKey = key + self.restoredChatSessionKey = nil + self.chat = [] + self.restoreChatHistory(for: key) + self.client.login(address: server.address, port: server.port, login: server.login, password: server.password, username: username, iconID: UInt16(iconID)) { [weak self] err, serverName, serverVersion in self?.serverVersion = serverVersion ?? 123 if serverName != nil { @@ -258,10 +271,16 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega @MainActor func disconnect() { self.client.disconnect() self.bannerClient?.cancel() - self.fileSearchSession?.cancel() + self.fileSearchSession?.cancel() self.fileSearchSession = nil self.resetFileSearchState() } + + deinit { + if let observer = chatHistoryObserver { + NotificationCenter.default.removeObserver(observer) + } + } @MainActor func sendAgree() { self.client.sendAgree(username: self.username, iconID: UInt16(self.iconID), options: .none) @@ -1077,12 +1096,19 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega @MainActor func hotlineStatusChanged(status: HotlineClientStatus) { print("Hotline: Connection status changed to: \(status)") - + let previousStatus = self.status + let previousTitle = self.serverTitle + if status == .disconnected { + if previousStatus == .loggedIn { + let message = ChatMessage(text: "Disconnected", type: .signOut, date: Date()) + self.recordChatMessage(message, persist: true, display: false) + } + self.serverVersion = 123 self.serverName = nil self.access = nil - + self.fileSearchSession?.cancel() self.fileSearchSession = nil self.resetFileSearchState() @@ -1095,30 +1121,34 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega self.filesLoaded = false self.news = [] self.newsLoaded = false - + self.bannerImage = nil if let b = self.bannerClient { b.cancel() self.bannerClient = nil } - + self.deleteAllTransfers() + + self.chatSessionKey = nil + self.restoredChatSessionKey = nil } else if status == .loggedIn { if Prefs.shared.playSounds && Prefs.shared.playLoggedInSound { SoundEffectPlayer.shared.playSoundEffect(.loggedIn) } } - + self.status = status } func hotlineGetUserInfo() -> (String, UInt16) { return (self.username, UInt16(self.iconID)) } - + func hotlineReceivedAgreement(text: String) { - self.chat.append(ChatMessage(text: text, type: .agreement, date: Date())) + let message = ChatMessage(text: text, type: .agreement, date: Date()) + self.recordChatMessage(message, persist: false) } func hotlineReceivedNewsPost(message: String) { @@ -1139,9 +1169,10 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega if Prefs.shared.playChatSound && Prefs.shared.playChatSound { SoundEffectPlayer.shared.playSoundEffect(.serverMessage) } - + print("Hotline: received server message:\n\(message)") - self.chat.append(ChatMessage(text: message, type: .server, date: Date())) + let chatMessage = ChatMessage(text: message, type: .server, date: Date()) + self.recordChatMessage(chatMessage) } func hotlineReceivedPrivateMessage(userID: UInt16, message: String) { @@ -1173,7 +1204,8 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega if Prefs.shared.playSounds && Prefs.shared.playChatSound { SoundEffectPlayer.shared.playSoundEffect(.chatMessage) } - self.chat.append(ChatMessage(text: message, type: .message, date: Date())) + let chatMessage = ChatMessage(text: message, type: .message, date: Date()) + self.recordChatMessage(chatMessage) self.unreadPublicChat = true } @@ -1208,11 +1240,12 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega func hotlineUserDisconnected(userID: UInt16) { if let existingUserIndex = self.users.firstIndex(where: { $0.id == UInt(userID) }) { let user = self.users.remove(at: existingUserIndex) - + if Prefs.shared.showJoinLeaveMessages { - self.chat.append(ChatMessage(text: "\(user.name) left", type: .status, date: Date())) + 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) } @@ -1356,11 +1389,71 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega } // MARK: - Utilities - + + private func sessionKey(for server: Server) -> ChatStore.SessionKey { + ChatStore.SessionKey(address: server.address.lowercased(), port: server.port) + } + + private func recordChatMessage(_ message: ChatMessage, persist: Bool = true, display: Bool = true) { + if display { + self.chat.append(message) + } + + let shouldPersist = persist && message.type != .agreement + guard shouldPersist, let key = chatSessionKey else { return } + 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 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 + } + return ChatMessage(text: renderedText, type: chatType, date: entry.date) + } + self.chat = historyMessages + currentMessages + self.unreadPublicChat = false + self.restoredChatSessionKey = key + } + } + } + + private func handleChatHistoryCleared() { + self.chat = [] + self.unreadPublicChat = false + self.restoredChatSessionKey = nil + } + func updateServerTitle() { self.serverTitle = self.serverName ?? self.server?.name ?? server?.address ?? "Server" } - + private func addOrUpdateHotlineUser(_ user: HotlineUser) { print("Hotline: users: \n\(self.users)") if let i = self.users.firstIndex(where: { $0.id == user.id }) { @@ -1377,7 +1470,8 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega print("Hotline: added user: \(user.name)") self.users.append(User(hotlineUser: user)) if Prefs.shared.showJoinLeaveMessages { - self.chat.append(ChatMessage(text: "\(user.name) joined", type: .status, date: Date())) + let chatMessage = ChatMessage(text: "\(user.name) joined", type: .joined, date: Date()) + self.recordChatMessage(chatMessage) } } } diff --git a/Hotline/iOS/ChatView.swift b/Hotline/iOS/ChatView.swift index dadd5e6..0ad41e0 100644 --- a/Hotline/iOS/ChatView.swift +++ b/Hotline/iOS/ChatView.swift @@ -63,6 +63,22 @@ struct ChatView: View { } .padding() } + else if msg.type == .signOut { + HStack { + Spacer() + Label { + Text(msg.text) + .font(.footnote) + } icon: { + Image(systemName: "arrow.up.circle.fill") + } + .labelStyle(.titleAndIcon) + .foregroundStyle(Color.red) + .opacity(0.8) + Spacer() + } + .padding(.vertical, 6) + } else { HStack(alignment: .firstTextBaseline) { if let username = msg.username { diff --git a/Hotline/macOS/ChatView.swift b/Hotline/macOS/ChatView.swift index cd84369..12e6c94 100644 --- a/Hotline/macOS/ChatView.swift +++ b/Hotline/macOS/ChatView.swift @@ -4,6 +4,150 @@ enum FocusedField: Int, Hashable { case chatInput } +struct ChatStatusMessageView: View { + let message: ChatMessage + + var body: some View { + HStack(alignment: .center, spacing: 8) { + Image(systemName: "arrow.right") + .resizable() + .scaledToFit() + .fontWeight(.semibold) + .foregroundStyle(.primary) + .frame(width: 14, height: 14) + + Text(message.text) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.disabled) + + Spacer() + } + .opacity(0.3) + } +} + + +struct ChatJoinedMessageView: View { + let message: ChatMessage + + var body: some View { + HStack(alignment: .center, spacing: 8) { + Image(systemName: "arrow.right") + .resizable() + .scaledToFit() + .fontWeight(.semibold) + .foregroundStyle(.primary) + .frame(width: 12, height: 12) + + Text(message.text) + .lineLimit(1) + .truncationMode(.middle) + .fontWeight(.semibold) + .textSelection(.disabled) + + Spacer() + } + .opacity(0.3) + } +} + +struct ChatLeftMessageView: View { + let message: ChatMessage + + var body: some View { + HStack(alignment: .center, spacing: 8) { + Image(systemName: "arrow.left") + .resizable() + .scaledToFit() + .fontWeight(.semibold) + .foregroundStyle(.primary) + .frame(width: 12, height: 12) + + Text(message.text) + .lineLimit(1) + .truncationMode(.middle) + .fontWeight(.semibold) + .textSelection(.disabled) + + Spacer() + } + .opacity(0.3) + } +} + + +struct ChatDisconnectedMessageView: View { + let message: ChatMessage + + var body: some View { + HStack { + Spacer() + } + .frame(height: 5) + .background(Color.primary) + .clipShape(.capsule) + .opacity(0.1) + } +} + +struct ChatMessageView: View { + let message: ChatMessage + + var body: some View { + HStack(alignment: .firstTextBaseline) { + if let username = message.username { + // if msg.text.isImageURL() { + // HStack(alignment: .bottom) { + // Text("**\(username):** ") + // + // let imageURL = URL(string: msg.text)! + // AsyncImage(url: imageURL) { phase in + // switch phase { + // case .failure: + // Text(LocalizedStringKey(msg.text.convertLinksToMarkdown())) + // .lineSpacing(4) + // .multilineTextAlignment(.leading) + // .textSelection(.enabled) + // .tint(Color("Link Color")) + // case .success(let img): + // Link(destination: imageURL) { + // img + // .resizable() + // .scaledToFit() + // .frame(maxWidth: 250, maxHeight: 150, alignment: .leading) + // .onAppear { + // reader.scrollTo(bottomID, anchor: .bottom) + // } + // } + // default: + // ProgressView().controlSize(.small) + // } + // } + // + // Spacer() + // } + // } + // else { + Text(LocalizedStringKey("**\(username):** \(message.text)".convertingLinksToMarkdown())) + .lineSpacing(4) + .multilineTextAlignment(.leading) + .textSelection(.enabled) + .tint(Color("Link Color")) + // } + } + else { + Text(message.text) + .lineSpacing(4) + .multilineTextAlignment(.leading) + .textSelection(.enabled) + .tint(Color("Link Color")) + } + Spacer() + } + } +} + struct ChatView: View { @Environment(Hotline.self) private var model: Hotline @Environment(\.colorScheme) var colorScheme @@ -20,7 +164,7 @@ struct ChatView: View { @State private var showingExporter: Bool = false @State private var chatDocument: TextFile = TextFile() - + var body: some View { NavigationStack { ScrollViewReader { reader in @@ -29,99 +173,64 @@ struct ChatView: View { // MARK: Scroll View GeometryReader { gm in ScrollView(.vertical) { - LazyVStack(alignment: .leading) { + LazyVStack(alignment: .leading, spacing: 8) { ForEach(model.chat) { msg in - - // MARK: Agreement if msg.type == .agreement { -#if os(iOS) + VStack(alignment: .center, spacing: 16) { if let bannerImage = self.model.bannerImage { - Image(uiImage: bannerImage) - .resizable() - .scaledToFit() - .frame(maxWidth: 468.0) - .clipShape(RoundedRectangle(cornerRadius: 3)) + HStack(spacing: 0) { + Spacer(minLength: 0) + ZStack { + Image(nsImage: bannerImage) + .resizable() + .scaledToFit() + .frame(maxWidth: 468.0) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .offset(y: 1.5) + .blur(radius: 4) + .opacity(0.2) + + Image(nsImage: bannerImage) + .resizable() + .scaledToFit() + .frame(maxWidth: 468.0) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + + Spacer(minLength: 0) + } } -#endif - ServerAgreementView(text: msg.text) - .padding(.bottom, 16) + + ServerAgreementView(text: msg.text) + } + .padding(.vertical, 24) } // MARK: Server Message else if msg.type == .server { ServerMessageView(message: msg.text) - .padding(EdgeInsets(top: 8, leading: 0, bottom: 8, trailing: 0)) } // MARK: Status else if msg.type == .status { - HStack { - Spacer() - Text(msg.text) - .lineLimit(1) - .truncationMode(.middle) - .textSelection(.disabled) - .opacity(0.3) - Spacer() - } - .padding(EdgeInsets(top: 2, leading: 0, bottom: 2, trailing: 0)) + ChatStatusMessageView(message: msg) + } + else if msg.type == .joined { + ChatJoinedMessageView(message: msg) + } + else if msg.type == .left { + ChatLeftMessageView(message: msg) + } + else if msg.type == .signOut { + ChatDisconnectedMessageView(message: msg) + .padding(.vertical, 24) } else { - HStack(alignment: .firstTextBaseline) { - if let username = msg.username { -// if msg.text.isImageURL() { -// HStack(alignment: .bottom) { -// Text("**\(username):** ") -// -// let imageURL = URL(string: msg.text)! -// AsyncImage(url: imageURL) { phase in -// switch phase { -// case .failure: -// Text(LocalizedStringKey(msg.text.convertLinksToMarkdown())) -// .lineSpacing(4) -// .multilineTextAlignment(.leading) -// .textSelection(.enabled) -// .tint(Color("Link Color")) -// case .success(let img): -// Link(destination: imageURL) { -// img -// .resizable() -// .scaledToFit() -// .frame(maxWidth: 250, maxHeight: 150, alignment: .leading) -// .onAppear { -// reader.scrollTo(bottomID, anchor: .bottom) -// } -// } -// default: -// ProgressView().controlSize(.small) -// } -// } -// -// Spacer() -// } -// } -// else { - Text(LocalizedStringKey("**\(username):** \(msg.text)".convertingLinksToMarkdown())) - .lineSpacing(4) - .multilineTextAlignment(.leading) - .textSelection(.enabled) - .tint(Color("Link Color")) -// } - } - else { - Text(msg.text) - .lineSpacing(4) - .multilineTextAlignment(.leading) - .textSelection(.enabled) - .tint(Color("Link Color")) - } - Spacer() - } - .padding(EdgeInsets(top: 2, leading: 0, bottom: 2, trailing: 0)) + ChatMessageView(message: msg) } } } .padding() - + VStack(spacing: 0) {}.id(bottomID) } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -136,6 +245,9 @@ struct ChatView: View { .onChange(of: gm.size) { reader.scrollTo(bottomID, anchor: .bottom) } + .onChange(of: self.model.bannerImage) { + reader.scrollTo(bottomID, anchor: .bottom) + } } // MARK: Input Divider @@ -160,7 +272,7 @@ struct ChatView: View { .frame(maxWidth: .infinity, minHeight: 28) .padding(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) .overlay(alignment: .leadingLastTextBaseline) { - Image(systemName: "chevron.right").opacity(0.4).offset(x: 16) + Image(systemName: "chevron.right").fontWeight(.semibold).opacity(0.4).offset(x: 16) } .onContinuousHover { phase in switch phase { @@ -178,17 +290,17 @@ struct ChatView: View { } } .background(Color(nsColor: .textBackgroundColor)) -// .toolbar { -// ToolbarItem(placement: .primaryAction) { -// Button { -// if prepareChatDocument() { -// showingExporter = true -// } -// } label: { -// Image(systemName: "square.and.arrow.up") -// }.help("Save Chat...") -// } -// } + // .toolbar { + // ToolbarItem(placement: .primaryAction) { + // Button { + // if prepareChatDocument() { + // showingExporter = true + // } + // } label: { + // Image(systemName: "square.and.arrow.up") + // }.help("Save Chat...") + // } + // } .fileExporter(isPresented: $showingExporter, document: self.chatDocument, contentType: .utf8PlainText, defaultFilename: "\(self.model.serverTitle) Chat.txt") { result in switch result { case .success(let url): diff --git a/Hotline/macOS/FilesView.swift b/Hotline/macOS/FilesView.swift index 3447b28..00a4e1e 100644 --- a/Hotline/macOS/FilesView.swift +++ b/Hotline/macOS/FilesView.swift @@ -234,7 +234,7 @@ struct FilesView: View { private var searchStatusMessage: String? { switch model.fileSearchStatus { - case .searching(let processed, let pending): + case .searching(let processed, _): let scanned = processed == 1 ? "folder" : "folders" return "Searched \(processed) \(scanned)..." case .completed(let processed): @@ -248,7 +248,6 @@ struct FilesView: View { if model.fileSearchResults.isEmpty { return nil } - let count = model.fileSearchResults.count let folderWord = processed == 1 ? "folder" : "folders" return "Search cancelled" case .failed(let message): diff --git a/Hotline/macOS/MessageBoardEditorView.swift b/Hotline/macOS/MessageBoardEditorView.swift index 029616f..474384e 100644 --- a/Hotline/macOS/MessageBoardEditorView.swift +++ b/Hotline/macOS/MessageBoardEditorView.swift @@ -20,7 +20,7 @@ struct MessageBoardEditorView: View { let cleanedText = text.trimmingCharacters(in: .whitespacesAndNewlines) - await model.postToMessageBoard(text: cleanedText) + model.postToMessageBoard(text: cleanedText) let _ = await model.getMessageBoard() // let success = await model.postNewsArticle(title: title, body: text, at: path, parentID: parentID) diff --git a/Hotline/macOS/ServerAgreementView.swift b/Hotline/macOS/ServerAgreementView.swift index a1f96d9..e72de7d 100644 --- a/Hotline/macOS/ServerAgreementView.swift +++ b/Hotline/macOS/ServerAgreementView.swift @@ -1,6 +1,6 @@ import SwiftUI -fileprivate let MAX_AGREEMENT_HEIGHT: CGFloat = 280 +fileprivate let MAX_AGREEMENT_HEIGHT: CGFloat = 340 struct ServerAgreementView: View { let text: String @@ -42,7 +42,7 @@ struct ServerAgreementView: View { #elseif os(macOS) .background(VisualEffectView(material: .titlebar, blendingMode: .withinWindow)) #endif - .overlay( + .overlay(alignment: .bottomTrailing) { ZStack(alignment: .bottomTrailing) { Group { if !expandable || expanded { @@ -54,25 +54,22 @@ struct ServerAgreementView: View { expanded = true } }, label: { - Color.black - .opacity(0.00001) - .frame(width: 32, height: 32) - .overlay( - Image(systemName: "arrow.up.left.and.arrow.down.right") - .resizable() - .scaledToFit() - .fontWeight(.semibold) - .frame(width: 12, height: 12) - .foregroundColor(.primary.opacity(0.8)) - , alignment: .center) + Image(systemName: "arrow.up.and.down.circle.fill") + .resizable() + .scaledToFit() + .fontWeight(.semibold) + .frame(width: 16, height: 16) + .foregroundColor(.primary.opacity(0.5)) }) .buttonStyle(.plain) + .buttonBorderShape(.circle) .help("Expand Server Agreement") + .padding([.trailing, .bottom], 16) } } } - , alignment: .bottomTrailing) - .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) } } diff --git a/Hotline/macOS/ServerMessageView.swift b/Hotline/macOS/ServerMessageView.swift index 8413a87..6c3c60e 100644 --- a/Hotline/macOS/ServerMessageView.swift +++ b/Hotline/macOS/ServerMessageView.swift @@ -24,7 +24,7 @@ struct ServerMessageView: View { #elseif os(macOS) .background(VisualEffectView(material: .titlebar, blendingMode: .withinWindow)) #endif - .clipShape(RoundedRectangle(cornerRadius: 8)) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) } } diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index 49413fe..77b6dc8 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -307,9 +307,7 @@ struct ServerView: View { .keyboardShortcut(.cancelAction) Button("Connect") { - Task { - await connectToServer() - } + connectToServer() } .controlSize(.regular) diff --git a/Hotline/macOS/SettingsView.swift b/Hotline/macOS/SettingsView.swift index 81a2f8d..d301abd 100644 --- a/Hotline/macOS/SettingsView.swift +++ b/Hotline/macOS/SettingsView.swift @@ -3,6 +3,7 @@ import SwiftUI struct GeneralSettingsView: View { @State private var username: String = "" @State private var usernameChanged: Bool = false + @State private var showClearHistoryConfirmation: Bool = false let saveTimer = Timer.publish(every: 5, on: .main, in: .common).autoconnect() @@ -24,9 +25,27 @@ struct GeneralSettingsView: View { preferences.username = self.username } } + + Divider() + + Button(role: .destructive) { + showClearHistoryConfirmation = true + } label: { + Text("Clear Chat History…") + } } .padding() .frame(width: 392) + .confirmationDialog("Clear chat history?", isPresented: $showClearHistoryConfirmation, titleVisibility: .visible) { + Button("Clear Chat History", role: .destructive) { + Task { + await ChatStore.shared.clearAll() + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This removes all saved chat logs across servers. Active chats will repopulate only with new messages.") + } .onAppear { self.username = preferences.username self.usernameChanged = false -- cgit From fefede829c12d9f69db72d70231291d973b0c1a2 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 24 Oct 2025 23:02:12 -0700 Subject: Further polish on chat. Try not to persist back to back disconnect messages. --- Hotline/Models/ChatMessage.swift | 5 --- Hotline/Models/Hotline.swift | 14 +++++- Hotline/iOS/ChatView.swift | 11 ----- Hotline/macOS/ChatView.swift | 97 ++++++++++++++-------------------------- 4 files changed, 47 insertions(+), 80 deletions(-) (limited to 'Hotline/macOS') diff --git a/Hotline/Models/ChatMessage.swift b/Hotline/Models/ChatMessage.swift index 365f91f..744a5d2 100644 --- a/Hotline/Models/ChatMessage.swift +++ b/Hotline/Models/ChatMessage.swift @@ -2,7 +2,6 @@ import SwiftUI enum ChatMessageType { case agreement - case status case joined case left case message @@ -15,8 +14,6 @@ extension ChatMessageType { switch self { case .agreement: return "agreement" - case .status: - return "status" case .joined: return "joined" case .left: @@ -34,8 +31,6 @@ extension ChatMessageType { switch storageKey { case "agreement": self = .agreement - case "status": - self = .status case "joined": self = .joined case "left": diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift index 8cdeccd..08b79a1 100644 --- a/Hotline/Models/Hotline.swift +++ b/Hotline/Models/Hotline.swift @@ -196,6 +196,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega @ObservationIgnored private var chatSessionKey: ChatStore.SessionKey? @ObservationIgnored private var restoredChatSessionKey: ChatStore.SessionKey? @ObservationIgnored private var chatHistoryObserver: NSObjectProtocol? + @ObservationIgnored private var lastPersistedMessageType: ChatMessageType? #if os(macOS) var bannerImage: NSImage? = nil #elseif os(iOS) @@ -244,6 +245,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega let key = sessionKey(for: server) self.chatSessionKey = key self.restoredChatSessionKey = nil + self.lastPersistedMessageType = nil self.chat = [] self.restoreChatHistory(for: key) @@ -1132,6 +1134,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega self.chatSessionKey = nil self.restoredChatSessionKey = nil + self.lastPersistedMessageType = nil } else if status == .loggedIn { if Prefs.shared.playSounds && Prefs.shared.playLoggedInSound { @@ -1395,12 +1398,19 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega } private func recordChatMessage(_ message: ChatMessage, persist: Bool = true, display: Bool = true) { + let shouldPersist = persist && message.type != .agreement + if shouldPersist, + message.type == .signOut, + lastPersistedMessageType == .signOut { + return + } + if display { self.chat.append(message) } - let shouldPersist = persist && message.type != .agreement guard shouldPersist, let key = chatSessionKey else { return } + self.lastPersistedMessageType = message.type let entry = ChatStore.Entry( id: message.id, body: message.text, @@ -1438,6 +1448,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega return ChatMessage(text: renderedText, type: chatType, date: entry.date) } self.chat = historyMessages + currentMessages + self.lastPersistedMessageType = historyMessages.last?.type self.unreadPublicChat = false self.restoredChatSessionKey = key } @@ -1448,6 +1459,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega self.chat = [] self.unreadPublicChat = false self.restoredChatSessionKey = nil + self.lastPersistedMessageType = nil } func updateServerTitle() { diff --git a/Hotline/iOS/ChatView.swift b/Hotline/iOS/ChatView.swift index 0ad41e0..e320dbf 100644 --- a/Hotline/iOS/ChatView.swift +++ b/Hotline/iOS/ChatView.swift @@ -52,17 +52,6 @@ struct ChatView: View { .frame(maxWidth: .infinity) .padding() } - else if msg.type == .status { - HStack { - Spacer() - Text(msg.text) - .lineLimit(1) - .truncationMode(.middle) - .opacity(0.3) - Spacer() - } - .padding() - } else if msg.type == .signOut { HStack { Spacer() diff --git a/Hotline/macOS/ChatView.swift b/Hotline/macOS/ChatView.swift index 12e6c94..a45e7ed 100644 --- a/Hotline/macOS/ChatView.swift +++ b/Hotline/macOS/ChatView.swift @@ -4,35 +4,11 @@ enum FocusedField: Int, Hashable { case chatInput } -struct ChatStatusMessageView: View { - let message: ChatMessage - - var body: some View { - HStack(alignment: .center, spacing: 8) { - Image(systemName: "arrow.right") - .resizable() - .scaledToFit() - .fontWeight(.semibold) - .foregroundStyle(.primary) - .frame(width: 14, height: 14) - - Text(message.text) - .lineLimit(1) - .truncationMode(.middle) - .textSelection(.disabled) - - Spacer() - } - .opacity(0.3) - } -} - - struct ChatJoinedMessageView: View { let message: ChatMessage var body: some View { - HStack(alignment: .center, spacing: 8) { + HStack(alignment: .center, spacing: 4) { Image(systemName: "arrow.right") .resizable() .scaledToFit() @@ -56,7 +32,7 @@ struct ChatLeftMessageView: View { let message: ChatMessage var body: some View { - HStack(alignment: .center, spacing: 8) { + HStack(alignment: .center, spacing: 4) { Image(systemName: "arrow.left") .resizable() .scaledToFit() @@ -206,14 +182,9 @@ struct ChatView: View { } .padding(.vertical, 24) } - // MARK: Server Message else if msg.type == .server { ServerMessageView(message: msg.text) } - // MARK: Status - else if msg.type == .status { - ChatStatusMessageView(message: msg) - } else if msg.type == .joined { ChatJoinedMessageView(message: msg) } @@ -313,38 +284,38 @@ struct ChatView: View { } } - private func prepareChatDocument() -> Bool { - var text: String = String() - - self.chatDocument.text = "" - for msg in model.chat { - if msg.type == .agreement { - text.append(msg.text) - text.append("\n\n") - } - else if msg.type == .message { - if let username = msg.username { - text.append("\(username): \(msg.text)") - } - else { - text.append(msg.text) - } - text.append("\n") - } - else if msg.type == .status { - text.append(msg.text) - text.append("\n") - } - } - - if text.isEmpty { - return false - } - - self.chatDocument.text = text - - return true - } +// private func prepareChatDocument() -> Bool { +// var text: String = String() +// +// self.chatDocument.text = "" +// for msg in model.chat { +// if msg.type == .agreement { +// text.append(msg.text) +// text.append("\n\n") +// } +// else if msg.type == .message { +// if let username = msg.username { +// text.append("\(username): \(msg.text)") +// } +// else { +// text.append(msg.text) +// } +// text.append("\n") +// } +// else if msg.type == .status { +// text.append(msg.text) +// text.append("\n") +// } +// } +// +// if text.isEmpty { +// return false +// } +// +// self.chatDocument.text = text +// +// return true +// } } #Preview { -- cgit From 53686e30592fee566585e391738adee8b2ef2137 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Sat, 25 Oct 2025 15:26:05 -0700 Subject: Fixed some warnings. Add basic markdown formatting support to chat messages. Add some metadata support for chat log (though we're not using this yet). --- Hotline/Hotline/HotlineProtocol.swift | 4 +-- Hotline/Managers/ChatStore.swift | 26 ++++++++++++++++ Hotline/Models/ChatMessage.swift | 9 ++++-- Hotline/Models/Hotline.swift | 8 +++-- Hotline/Utility/FoundationExtensions.swift | 34 ++++++++++++++++----- Hotline/Utility/SwiftUIExtensions.swift | 26 ++++++++++++++++ Hotline/macOS/ChatView.swift | 49 ++++-------------------------- Hotline/macOS/FilesView.swift | 3 +- 8 files changed, 98 insertions(+), 61 deletions(-) (limited to 'Hotline/macOS') diff --git a/Hotline/Hotline/HotlineProtocol.swift b/Hotline/Hotline/HotlineProtocol.swift index ff91672..9b3a812 100644 --- a/Hotline/Hotline/HotlineProtocol.swift +++ b/Hotline/Hotline/HotlineProtocol.swift @@ -241,11 +241,11 @@ struct HotlineAccount: Identifiable { } if fieldType == .userLogin { - self.login = field.getObfuscatedString()! + self.login = field.getObfuscatedString() ?? "" } if fieldType == .userPassword { - self.password = field.getObfuscatedString()! + self.password = field.getObfuscatedString() ?? "" } if fieldType == .userAccess, let opts = field.getUInt64(){ diff --git a/Hotline/Managers/ChatStore.swift b/Hotline/Managers/ChatStore.swift index daf8ca0..59c9484 100644 --- a/Hotline/Managers/ChatStore.swift +++ b/Hotline/Managers/ChatStore.swift @@ -28,12 +28,23 @@ actor ChatStore { } } + struct EntryMetadata: Codable { + var images: [ImageMetadata]? + + struct ImageMetadata: Codable { + let url: String + let width: CGFloat? + let height: CGFloat? + } + } + struct Entry: Codable { let id: UUID let body: String let username: String? let type: String let date: Date + var metadata: EntryMetadata? } struct LoadResult { @@ -81,6 +92,21 @@ actor ChatStore { } } + func updateMetadata(_ metadata: EntryMetadata, for entryID: UUID, key: SessionKey) async { + do { + guard var logFile = try loadLogFile(for: key) else { return } + + if let index = logFile.entries.firstIndex(where: { $0.id == entryID }) { + logFile.entries[index].metadata = metadata + cache[key] = logFile + try persist(logFile, for: key) + } + } + catch { + print("ChatStore: failed to update metadata —", error) + } + } + func loadHistory(for key: SessionKey, limit: Int? = nil) async -> LoadResult { do { let logFile = try loadLogFile(for: key) diff --git a/Hotline/Models/ChatMessage.swift b/Hotline/Models/ChatMessage.swift index 744a5d2..1c210ce 100644 --- a/Hotline/Models/ChatMessage.swift +++ b/Hotline/Models/ChatMessage.swift @@ -48,19 +48,22 @@ extension ChatMessageType { } struct ChatMessage: Identifiable { - let id = UUID() - + let id: UUID + let text: String let type: ChatMessageType let date: Date let username: String? + var metadata: ChatStore.EntryMetadata? static let parser = /^\s*([^\:]+):\s*([\s\S]+)$/ init(text: String, type: ChatMessageType, date: Date) { + self.id = UUID() self.type = type self.date = date - + self.metadata = nil + if type == .message, let match = text.firstMatch(of: ChatMessage.parser) { diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift index 08b79a1..3783ba0 100644 --- a/Hotline/Models/Hotline.swift +++ b/Hotline/Models/Hotline.swift @@ -1445,7 +1445,9 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega else { renderedText = entry.body } - return ChatMessage(text: renderedText, type: chatType, date: entry.date) + 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 @@ -1530,12 +1532,12 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega guard let parent = self.findNews(in: self.news, at: path), !parent.children.isEmpty else { return nil } - + return parent.children.first { child in guard let childArticleID = child.articleID else { return false } - + return child.type == .article && child.articleID == childArticleID } } diff --git a/Hotline/Utility/FoundationExtensions.swift b/Hotline/Utility/FoundationExtensions.swift index 9fc9ae7..90a3032 100644 --- a/Hotline/Utility/FoundationExtensions.swift +++ b/Hotline/Utility/FoundationExtensions.swift @@ -8,6 +8,13 @@ enum Endianness { extension String { + func markdownToAttributedString() -> AttributedString { + let markdownText = self.convertingLinksToMarkdown() + let attr = (try? AttributedString(markdown: markdownText, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(self) + + return attr + } + func convertToAttributedStringWithLinks() -> AttributedString { let attributedString: NSMutableAttributedString = NSMutableAttributedString(string: self) let matches = self.ranges(of: RegularExpressions.relaxedLink) @@ -54,17 +61,28 @@ extension String { } func convertingLinksToMarkdown() -> String { - var cp = String(self) - cp.replace(RegularExpressions.relaxedLink) { match -> String in +// var cp = String(self) + + self.replacing(RegularExpressions.relaxedLink) { match in let linkText = self[match.range] - var injectedScheme = "https://" - if let _ = try? RegularExpressions.supportedLinkScheme.prefixMatch(in: linkText) { - injectedScheme = "" - } - return "[\(linkText)](\(injectedScheme)\(linkText))" + // Only add https:// if the link doesn't already have a scheme + let hasScheme = (try? RegularExpressions.supportedLinkScheme.prefixMatch(in: linkText)) != nil + let url = hasScheme ? String(linkText) : "https://\(linkText)" + + return "[\(linkText)](\(url))" } - return cp + +// cp.replace(RegularExpressions.relaxedLink) { match -> String in +// let linkText = self[match.range] +// var injectedScheme = "https://" +// if let _ = try? RegularExpressions.supportedLinkScheme.prefixMatch(in: linkText) { +// injectedScheme = "" +// } +// +// return "[\(linkText)](\(injectedScheme)\(linkText))" +// } +// return cp } } diff --git a/Hotline/Utility/SwiftUIExtensions.swift b/Hotline/Utility/SwiftUIExtensions.swift index 3b850e0..12217b2 100644 --- a/Hotline/Utility/SwiftUIExtensions.swift +++ b/Hotline/Utility/SwiftUIExtensions.swift @@ -1,7 +1,33 @@ import SwiftUI +import Foundation extension Color { init(hex: Int, opacity: Double = 1.0) { self.init(red: Double((hex >> 16) & 0xFF) / 255.0, green: Double((hex >> 8) & 0xFF) / 255.0, blue: Double(hex & 0xFF) / 255.0, opacity: opacity) } } + +extension AttributedString { + func setHangingIndent(firstLineHeadIndent: CGFloat = 0, otherLinesHeadIndent: CGFloat) -> AttributedString { +// var blah = self + +// guard var paragraph = self.paragraphStyle else { +// return +// } + + var p = self.paragraphStyle?.mutableCopy() as? NSMutableParagraphStyle + p?.headIndent = otherLinesHeadIndent + p?.firstLineHeadIndent = firstLineHeadIndent + +// paragraph.headIndent = otherLinesHeadIndent // indent for lines 2+ +// paragraph.firstLineHeadIndent = firstLineHeadIndent // usually 0 + + var blah = self + + + blah.paragraphStyle = p + + return blah + } +} + diff --git a/Hotline/macOS/ChatView.swift b/Hotline/macOS/ChatView.swift index a45e7ed..2ad9816 100644 --- a/Hotline/macOS/ChatView.swift +++ b/Hotline/macOS/ChatView.swift @@ -69,58 +69,21 @@ struct ChatDisconnectedMessageView: View { struct ChatMessageView: View { let message: ChatMessage - + var body: some View { HStack(alignment: .firstTextBaseline) { if let username = message.username { - // if msg.text.isImageURL() { - // HStack(alignment: .bottom) { - // Text("**\(username):** ") - // - // let imageURL = URL(string: msg.text)! - // AsyncImage(url: imageURL) { phase in - // switch phase { - // case .failure: - // Text(LocalizedStringKey(msg.text.convertLinksToMarkdown())) - // .lineSpacing(4) - // .multilineTextAlignment(.leading) - // .textSelection(.enabled) - // .tint(Color("Link Color")) - // case .success(let img): - // Link(destination: imageURL) { - // img - // .resizable() - // .scaledToFit() - // .frame(maxWidth: 250, maxHeight: 150, alignment: .leading) - // .onAppear { - // reader.scrollTo(bottomID, anchor: .bottom) - // } - // } - // default: - // ProgressView().controlSize(.small) - // } - // } - // - // Spacer() - // } - // } - // else { - Text(LocalizedStringKey("**\(username):** \(message.text)".convertingLinksToMarkdown())) - .lineSpacing(4) - .multilineTextAlignment(.leading) - .textSelection(.enabled) - .tint(Color("Link Color")) - // } + Text("\(username): ").fontWeight(.semibold) + Text(message.text.markdownToAttributedString()) } else { Text(message.text) - .lineSpacing(4) - .multilineTextAlignment(.leading) - .textSelection(.enabled) - .tint(Color("Link Color")) } Spacer() } + .lineSpacing(4) + .multilineTextAlignment(.leading) + .textSelection(.enabled) + .tint(Color("Link Color")) } } diff --git a/Hotline/macOS/FilesView.swift b/Hotline/macOS/FilesView.swift index 00a4e1e..42232af 100644 --- a/Hotline/macOS/FilesView.swift +++ b/Hotline/macOS/FilesView.swift @@ -244,11 +244,10 @@ struct FilesView: View { return "No files found in \(processed) \(folderWord)" } return "\(count) file\(count == 1 ? "" : "s") found in \(processed) \(folderWord)" - case .cancelled(let processed): + case .cancelled(_): if model.fileSearchResults.isEmpty { return nil } - let folderWord = processed == 1 ? "folder" : "folders" return "Search cancelled" case .failed(let message): return "Search failed: \(message)" -- cgit From 3b3b965842c47939ca54d0d1cbdf469346847f14 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Sat, 25 Oct 2025 16:46:52 -0700 Subject: Move chat input string to view model so its not lost when switching between tabs. Add chat search/filtering with suppport for live filtering. --- Hotline/MacApp.swift | 10 ++-- Hotline/Models/Hotline.swift | 64 +++++++++++++++++++++++ Hotline/macOS/ChatView.swift | 120 +++++++++++++++++++++++++++---------------- 3 files changed, 146 insertions(+), 48 deletions(-) (limited to 'Hotline/macOS') diff --git a/Hotline/MacApp.swift b/Hotline/MacApp.swift index a91df52..0682c1d 100644 --- a/Hotline/MacApp.swift +++ b/Hotline/MacApp.swift @@ -270,15 +270,15 @@ struct Application: App { } .disabled(activeHotline?.status != .loggedIn) .keyboardShortcut(.init("1"), modifiers: .command) - Button("Show News") { - activeServerState?.selection = .news - } - .disabled(activeHotline?.status != .loggedIn || (activeHotline?.serverVersion ?? 0) < 151) - .keyboardShortcut(.init("2"), modifiers: .command) Button("Show Message Board") { activeServerState?.selection = .board } .disabled(activeHotline?.status != .loggedIn) + .keyboardShortcut(.init("2"), modifiers: .command) + Button("Show News") { + activeServerState?.selection = .news + } + .disabled(activeHotline?.status != .loggedIn || (activeHotline?.serverVersion ?? 0) < 151) .keyboardShortcut(.init("3"), modifiers: .command) Button("Show Files") { activeServerState?.selection = .files diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift index 3783ba0..3d1f309 100644 --- a/Hotline/Models/Hotline.swift +++ b/Hotline/Models/Hotline.swift @@ -163,6 +163,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega var users: [User] = [] var accounts: [HotlineAccount] = [] var chat: [ChatMessage] = [] + var chatInput: String = "" var messageBoard: [String] = [] var messageBoardLoaded: Bool = false var files: [FileInfo] = [] @@ -1464,6 +1465,69 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega self.lastPersistedMessageType = nil } + @MainActor func searchChat(query: String) -> [ChatMessage] { + guard !query.isEmpty else { + return [] + } + + // Create a map of all messages by ID to deduplicate (current chat includes restored history) + var messageMap: [UUID: ChatMessage] = [:] + + // Add current in-memory messages (includes both restored history and new 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 { + // Skip consecutive disconnect messages + 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 + } + func updateServerTitle() { self.serverTitle = self.serverName ?? self.server?.name ?? server?.address ?? "Server" } diff --git a/Hotline/macOS/ChatView.swift b/Hotline/macOS/ChatView.swift index 2ad9816..65087c6 100644 --- a/Hotline/macOS/ChatView.swift +++ b/Hotline/macOS/ChatView.swift @@ -91,30 +91,42 @@ struct ChatView: View { @Environment(Hotline.self) private var model: Hotline @Environment(\.colorScheme) var colorScheme @Environment(\.dismiss) var dismiss - - @State var input: String = "" + @State private var scrollPos: Int? @State private var contentHeight: CGFloat = 0 - + + @State private var searchQuery: String = "" + @State private var searchResults: [ChatMessage] = [] + @State private var isSearching: Bool = false + @FocusState private var focusedField: FocusedField? - + @Namespace var bottomID - - @State private var showingExporter: Bool = false - - @State private var chatDocument: TextFile = TextFile() - + + private var bindableModel: Bindable { + Bindable(model) + } + +// @State private var showingExporter: Bool = false +// +// @State private var chatDocument: TextFile = TextFile() + + var displayedMessages: [ChatMessage] { + searchQuery.isEmpty ? model.chat : searchResults + } + var body: some View { + @Bindable var bindModel = model + NavigationStack { ScrollViewReader { reader in VStack(alignment: .leading, spacing: 0) { - + // MARK: Scroll View - GeometryReader { gm in ScrollView(.vertical) { LazyVStack(alignment: .leading, spacing: 8) { - - ForEach(model.chat) { msg in + + ForEach(displayedMessages) { msg in if msg.type == .agreement { VStack(alignment: .center, spacing: 16) { if let bannerImage = self.model.bannerImage { @@ -168,37 +180,46 @@ struct ChatView: View { VStack(spacing: 0) {}.id(bottomID) } .frame(maxWidth: .infinity, maxHeight: .infinity) - .defaultScrollAnchor(.bottom) +// .defaultScrollAnchor(.bottom, for: .initialOffset) + .defaultScrollAnchor(.bottom, for: .alignment) + .defaultScrollAnchor(.bottom, for: .sizeChanges) .onChange(of: model.chat.count) { + // Re-run search when new messages arrive to keep filter active + if !searchQuery.isEmpty { + performSearch() + } reader.scrollTo(bottomID, anchor: .bottom) model.markPublicChatAsRead() } .onAppear { reader.scrollTo(bottomID, anchor: .bottom) + self.focusedField = .chatInput } - .onChange(of: gm.size) { + .onChange(of: self.model.bannerImage) { reader.scrollTo(bottomID, anchor: .bottom) } - .onChange(of: self.model.bannerImage) { + .onChange(of: searchQuery) { + reader.scrollTo(bottomID, anchor: .bottom) + } + .onChange(of: isSearching) { reader.scrollTo(bottomID, anchor: .bottom) } - } // MARK: Input Divider Divider() // MARK: Input Bar HStack(alignment: .lastTextBaseline, spacing: 0) { - TextField("", text: $input, axis: .vertical) + TextField("", text: $bindModel.chatInput, axis: .vertical) .focused($focusedField, equals: .chatInput) .textFieldStyle(.plain) .lineLimit(1...5) .multilineTextAlignment(.leading) .onSubmit { - if !self.input.isEmpty { - model.sendChat(self.input, announce: NSEvent.modifierFlags.contains(.shift)) + if !model.chatInput.isEmpty { + model.sendChat(model.chatInput, announce: NSEvent.modifierFlags.contains(.shift)) } - self.input = "" + model.chatInput = "" } .frame(maxWidth: .infinity) .padding() @@ -217,36 +238,49 @@ struct ChatView: View { break } } - .onTapGesture { + .onTapGesture(count: 1) { focusedField = .chatInput } } } + .searchable(text: $searchQuery, isPresented: $isSearching, placement: .toolbar, prompt: "Search") + .background(Button("", action: { isSearching = true }).keyboardShortcut("f").hidden()) } .background(Color(nsColor: .textBackgroundColor)) - // .toolbar { - // ToolbarItem(placement: .primaryAction) { - // Button { - // if prepareChatDocument() { - // showingExporter = true - // } - // } label: { - // Image(systemName: "square.and.arrow.up") - // }.help("Save Chat...") - // } - // } - .fileExporter(isPresented: $showingExporter, document: self.chatDocument, contentType: .utf8PlainText, defaultFilename: "\(self.model.serverTitle) Chat.txt") { result in - switch result { - case .success(let url): - print("Saved to \(url)") - - case .failure(let error): - print(error.localizedDescription) - } - self.chatDocument.text = "" +// .navigationTitle(model.serverTitle) + .onChange(of: searchQuery) { + performSearch() + } +// .toolbar { +// ToolbarItem(placement: .primaryAction) { +// Button { +// showingExporter = true +// } label: { +// Image(systemName: "square.and.arrow.up") +// }.help("Save Chat...") +// } +// } +// .fileExporter(isPresented: $showingExporter, document: self.chatDocument, contentType: .utf8PlainText, defaultFilename: "\(self.model.serverTitle) Chat.txt") { result in +// switch result { +// case .success(let url): +// print("Saved to \(url)") +// +// case .failure(let error): +// print(error.localizedDescription) +// } +// self.chatDocument.text = "" +// } + } + + private func performSearch() { + guard !searchQuery.isEmpty else { + searchResults = [] + return } + + searchResults = model.searchChat(query: searchQuery) } - + // private func prepareChatDocument() -> Bool { // var text: String = String() // -- cgit From b7e909cccfaa8611522f488134f9f28f643b83eb Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Sun, 26 Oct 2025 23:06:59 -0700 Subject: Rewrite HotlineTrackerClient using new async NetSocket as proof of concept. --- Hotline/Hotline/HotlineTrackerClient.swift | 393 ++++++++++++++++------------- Hotline/Library/NetSocketNew.swift | 37 ++- Hotline/Models/Bookmark.swift | 7 +- Hotline/Models/Hotline.swift | 29 ++- Hotline/macOS/TrackerView.swift | 56 +++- 5 files changed, 321 insertions(+), 201 deletions(-) (limited to 'Hotline/macOS') diff --git a/Hotline/Hotline/HotlineTrackerClient.swift b/Hotline/Hotline/HotlineTrackerClient.swift index 3c1ad85..110ca21 100644 --- a/Hotline/Hotline/HotlineTrackerClient.swift +++ b/Hotline/Hotline/HotlineTrackerClient.swift @@ -5,224 +5,259 @@ struct HotlineTracker: Identifiable, Equatable { let id: UUID = UUID() var address: String var port: UInt16 - + init(_ address: String, port: UInt16 = 5498) { self.address = address self.port = port } - + static func == (lhs: HotlineTracker, rhs: HotlineTracker) -> Bool { return lhs.address == rhs.address && lhs.port == rhs.port } } -enum HotlineTrackerStatus: Int { - case disconnected - case connecting - case connected -} - -private enum HotlineTrackerStage { - case magic - case header - case listing - case done -} +/// Client for Hotline trackers +/// +/// The tracker protocol: +/// 1. Client sends magic: "HTRK" + version (0x0001) +/// 2. Server echoes magic back +/// 3. Server sends header: message type, data length, server count +/// 4. Server sends listing: array of server records class HotlineTrackerClient { static let MagicPacket: [UInt8] = [ 0x48, 0x54, 0x52, 0x4B, // 'HTRK' 0x00, 0x01 // Version ] - + private var tracker: HotlineTracker - private var connectionStatus: HotlineTrackerStatus = .disconnected - private var servers: [HotlineServer] = [] - - private var socket: NetSocket = NetSocket() - private var stage: HotlineTrackerStage = .magic - - private var serverAddress: String - private var serverPort: Int - private var expectedDataLength: Int = 0 - private var serverCount: Int = 0 - - private var fetchContinuation: CheckedContinuation<[HotlineServer], any Error>? - + init() { - let t = HotlineTracker("hltracker.com") - self.tracker = t - self.serverAddress = t.address - self.serverPort = Int(t.port) - self.socket.delegate = self + self.tracker = HotlineTracker("hltracker.com") } - + init(tracker: HotlineTracker) { self.tracker = tracker - self.serverAddress = tracker.address - self.serverPort = Int(tracker.port) - self.socket.delegate = self } - - @MainActor func fetchServers(address: String, port: Int) async throws -> [HotlineServer] { - self.serverAddress = address - self.serverPort = Int(port) - - self.reset() - - return try await withCheckedThrowingContinuation { [weak self] continuation in - self?.fetchContinuation = continuation - self?.connect() + + /// Fetch server list from the tracker + /// - Parameters: + /// - address: Tracker hostname or IP + /// - port: Tracker port (default: 5498) + /// - Returns: AsyncThrowingStream of servers as they arrive from the tracker + /// - Throws: Network or protocol errors + func fetchServers(address: String, port: Int) -> AsyncThrowingStream { + return AsyncThrowingStream { continuation in + let task = Task { + await self.fetchServersInternal(address: address, port: port, continuation: continuation) + } + + continuation.onTermination = { @Sendable _ in + task.cancel() + } } } - - @MainActor func close() { - self.socket.close() - } - - // MARK: - - - @MainActor private func reset() { - self.expectedDataLength = 0 - self.serverCount = 0 - self.servers = [] - } - - @MainActor private func connect() { - self.socket.close() - - self.connectionStatus = .connecting - self.socket.connect(host: self.serverAddress, port: self.serverPort) - self.socket.write(HotlineTrackerClient.MagicPacket) - } - - @MainActor private func receiveMagic() { - guard self.stage == .magic, self.socket.available >= HotlineTrackerClient.MagicPacket.count else { - return - } - - let magic: [UInt8] = self.socket.read(count: HotlineTrackerClient.MagicPacket.count) - - if magic != HotlineTrackerClient.MagicPacket { - self.socket.close() - return + + private func fetchServersInternal(address: String, port: Int, continuation: AsyncThrowingStream.Continuation) async { + do { + // Add timeout wrapper + try await withTimeout(seconds: 30) { + try await self.doFetch(address: address, port: port, continuation: continuation) + } + } catch { + print("HotlineTrackerClient: Error in fetchServersInternal: \(error)") + continuation.finish(throwing: error) } - - self.stage = .header - self.receiveHeader() } - - @MainActor private func receiveHeader() { - guard self.stage == .header, self.socket.available >= 8 else { - return - } - - var header: [UInt8] = self.socket.read(count: 8) - - guard let messageType = header.consumeUInt16(), - let dataLength = header.consumeUInt16(), - let numberOfServers = header.consumeUInt16(), - let numberOfServers2 = header.consumeUInt16() else { - self.socket.close() - return + + private func doFetch(address: String, port: Int, continuation: AsyncThrowingStream.Continuation) async throws { + // Connect to tracker (plaintext, no TLS) + let socket = try await NetSocketNew.connect( + host: address, + port: UInt16(port), + tls: .disabled + ) + defer { Task { await socket.close() } } + + // Send magic packet + try await socket.write(Data(HotlineTrackerClient.MagicPacket)) + + // Receive magic response (6 bytes: 'HTRK' + version) + let magicResponse = try await socket.readExactly(6) + let magic = magicResponse[0..<4] + let version = UInt16(magicResponse[4]) << 8 | UInt16(magicResponse[5]) + + // Validate magic ('HTRK') + guard magic == Data([0x48, 0x54, 0x52, 0x4B]) else { + throw NetSocketError.decodeFailed( + NSError(domain: "HotlineTracker", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Invalid magic response from tracker (expected 'HTRK')" + ]) + ) } - - print("HotlineTrackerClient: Received response header ", messageType, dataLength, numberOfServers, numberOfServers2) - - self.expectedDataLength = Int(dataLength) - self.expectedDataLength -= 4 // Remove the size of the two server count fields - self.serverCount = Int(numberOfServers) - - self.stage = .listing - self.receiveListing() + + print("HotlineTrackerClient: Connected to tracker (version \(version))") + + // Read server listings (may span multiple batches) + var totalYielded = 0 + var totalEntriesParsed = 0 // Includes separators + var totalExpectedEntries: Int = 0 + var batchCount = 0 + + repeat { + batchCount += 1 + + // Receive server information header (8 bytes) + // Format: [message type: u16][data length: u16][server count: u16][server count 2: u16] + let messageType = try await socket.read(UInt16.self, endian: .big) + let dataLength = try await socket.read(UInt16.self, endian: .big) + let serverCount = try await socket.read(UInt16.self, endian: .big) + let serverCount2 = try await socket.read(UInt16.self, endian: .big) + + // First header tells us the total expected entries (includes separators) + if totalExpectedEntries == 0 { + totalExpectedEntries = Int(serverCount) + } + + print("HotlineTrackerClient: Batch #\(batchCount) - type: \(messageType), dataLen: \(dataLength), count1: \(serverCount), count2: \(serverCount2)") + + // Calculate actual listing data length (excludes the two server count fields) + let listingLength = Int(dataLength) - 4 + print("HotlineTrackerClient: About to read \(listingLength) bytes of listing data...") + + // Receive listing data + let listingData = try await socket.readExactly(listingLength) + print("HotlineTrackerClient: Successfully read \(listingData.count) bytes") + + // Parse and yield servers one at a time for true progressive streaming + var bytes = Array(listingData) + let trackerSeparatorRegex = /^[-]+$/ + var batchEntriesParsed = 0 + var batchServersYielded = 0 + + for _ in 0..= 100 { + print("HotlineTrackerClient: WARNING - Stopped after 100 batches") + break + } + + } while totalEntriesParsed < totalExpectedEntries + + print("HotlineTrackerClient: Completed - parsed \(totalEntriesParsed)/\(totalExpectedEntries) entries, yielded \(totalYielded) servers") + continuation.finish() } - - @MainActor private func receiveListing() { - guard self.stage == .listing, self.socket.available >= self.expectedDataLength else { - return + + private func withTimeout(seconds: TimeInterval, operation: @escaping () async throws -> T) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { + try await operation() + } + + group.addTask { + try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + throw NSError(domain: "HotlineTracker", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "Tracker request timed out after \(seconds) seconds" + ]) + } + + let result = try await group.next()! + group.cancelAll() + return result } - - self.parseListing(self.socket.read(count: self.expectedDataLength)) } - - @MainActor private func parseListing(_ listingBytes: [UInt8]) { - // IP address (4 bytes) - // Port number (2 bytes) - // Number of users (2 bytes) - // Unused (2 bytes) - // Name size (1 byte) - // Name (name size) - // Description size (1 byte) - // Description (description size) - - var bytes: [UInt8] = listingBytes + + /// Parse tracker listing data into array of servers + /// - Parameters: + /// - data: Raw listing data + /// - serverCount: Expected number of entries (including separators) + /// - Returns: Tuple of (servers array, total entries parsed including separators) + /// - Throws: Parsing errors + private func parseListing(_ data: Data, serverCount: Int) throws -> (servers: [HotlineServer], entriesParsed: Int) { + // Server record format: + // - IP address (4 bytes) + // - Port number (2 bytes) + // - Number of users (2 bytes) + // - Unused (2 bytes) + // - Name size (1 byte) + // - Name (name size) + // - Description size (1 byte) + // - Description (description size) + + var bytes = Array(data) let trackerSeparatorRegex = /^[-]+$/ - var foundServers: [HotlineServer] = [] - - for _ in 1...self.serverCount { + var servers: [HotlineServer] = [] + var entriesParsed = 0 + + for _ in 0.. cfg.maxBufferBytes { - // Hard stop: drop connection rather than OOM’ing. + // Hard stop: drop connection rather than OOM'ing. isClosed = true connection.cancel() failAllWaiters(NetSocketError.framingExceeded(max: cfg.maxBufferBytes)) @@ -363,10 +366,13 @@ public actor NetSocketNew { // MARK: Close - /// Close the connection + /// Close the connection gracefully /// - /// Cancels the underlying network connection and wakes all pending read/write operations - /// with a `NetSocketError.closed` error. This method is idempotent. + /// Performs a graceful shutdown of the underlying network connection (e.g., TCP FIN) + /// and wakes all pending read/write operations with a `NetSocketError.closed` error. + /// This method is idempotent - subsequent calls are ignored. + /// + /// Use `forceClose()` for immediate non-graceful termination (e.g., TCP RST). public func close() { guard !isClosed else { return } isClosed = true @@ -375,6 +381,21 @@ public actor NetSocketNew { resumeReadyWaiters(with: .failure(NetSocketError.closed)) } + /// Force close the connection immediately (non-graceful) + /// + /// Performs an immediate non-graceful shutdown of the underlying network connection + /// (e.g., TCP RST). Use this when you need to terminate the connection immediately + /// without waiting for graceful closure. For normal shutdown, use `close()` instead. + /// + /// This method is idempotent - subsequent calls are ignored. + public func forceClose() { + guard !isClosed else { return } + isClosed = true + connection.forceCancel() + resumeDataWaiters() + resumeReadyWaiters(with: .failure(NetSocketError.closed)) + } + // MARK: Send (async) /// Write raw data to the socket @@ -597,6 +618,7 @@ public actor NetSocketNew { /// - Throws: `NetSocketError.framingExceeded` if max bytes exceeded, or connection errors public func readUntil(delimiter: Data, maxBytes: Int? = nil, includeDelimiter: Bool = false) async throws -> Data { while true { + try Task.checkCancellation() if let r = search(delimiter: delimiter) { let consumeLen = r.upperBound - head let data = try await readExactly(consumeLen) @@ -639,6 +661,7 @@ public actor NetSocketNew { /// Skip until delimiter is found (discards delimiter too) public func skipUntil(delimiter: Data) async throws { while true { + try Task.checkCancellation() if let r = search(delimiter: delimiter) { head = r.upperBound // Skip to end of delimiter compactIfNeeded() @@ -660,8 +683,9 @@ public actor NetSocketNew { private var availableBytes: Int { buffer.count - head } private func waitForData() async throws { + try Task.checkCancellation() try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in - if availableBytes > 0 || isClosed { cont.resume(); return } + if isClosed { cont.resume(); return } dataWaiters.append(cont) } } @@ -669,6 +693,7 @@ public actor NetSocketNew { private func ensureReadable(_ count: Int) async throws { try await ensureReady() while availableBytes < count { + try Task.checkCancellation() if isClosed { throw NetSocketError.insufficientData(expected: count, got: availableBytes) } try await waitForData() } @@ -945,7 +970,7 @@ public extension NetSocketNew { try Task.checkCancellation() let n = Int(min(Int64(chunkSize), remaining)) let chunk = try await readExactly(n) - try fh.write(chunk) + fh.write(chunk) remaining -= Int64(n) written += Int64(n) progress?(.init(sent: written, total: length)) diff --git a/Hotline/Models/Bookmark.swift b/Hotline/Models/Bookmark.swift index 80963f9..6063f48 100644 --- a/Hotline/Models/Bookmark.swift +++ b/Hotline/Models/Bookmark.swift @@ -341,13 +341,16 @@ final class Bookmark { var fetchedBookmarks: [BookmarkServer] = [] let client = HotlineTrackerClient() - if let fetchedServers: [HotlineServer] = try? await client.fetchServers(address: self.address, port: self.port) { - for fetchedServer in fetchedServers { + do { + for try await fetchedServer in client.fetchServers(address: self.address, port: self.port) { + print("FETCHED SERVER", fetchedServer) if let serverName = fetchedServer.name { let server = Server(name: serverName, description: fetchedServer.description, address: fetchedServer.address, port: Int(fetchedServer.port), users: Int(fetchedServer.users)) fetchedBookmarks.append(BookmarkServer(server: server)) } } + } catch { + print("Failed to fetch servers from tracker: \(error)") } return fetchedBookmarks diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift index 3d1f309..596df68 100644 --- a/Hotline/Models/Hotline.swift +++ b/Hotline/Models/Hotline.swift @@ -221,20 +221,35 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega @MainActor func getServerList(tracker: String, port: Int = HotlinePorts.DefaultTrackerPort) async -> [Server] { var servers: [Server] = [] - - if let fetchedServers: [HotlineServer] = try? await self.trackerClient.fetchServers(address: tracker, port: port) { - for s in fetchedServers { - if let serverName = s.name { - servers.append(Server(name: serverName, description: s.description, address: s.address, port: Int(s.port), users: Int(s.users))) + print("Hotline.getServerList: Starting fetch from \(tracker):\(port)") + + do { + for try await hotlineServer in self.trackerClient.fetchServers(address: tracker, port: port) { + if let serverName = hotlineServer.name { + servers.append(Server( + name: serverName, + description: hotlineServer.description, + address: hotlineServer.address, + port: Int(hotlineServer.port), + users: Int(hotlineServer.users) + )) + if servers.count % 10 == 0 { + print("Hotline.getServerList: Collected \(servers.count) servers so far...") + } } } + } catch { + print("Hotline.getServerList: Error - \(error)") } - + + print("Hotline.getServerList: Returning \(servers.count) servers") return servers } @MainActor func disconnectTracker() { - self.trackerClient.close() + // No-op: HotlineTrackerClient now uses async/await and manages + // connections internally. Each fetchServers() call opens and closes + // its own connection automatically. } @MainActor func login(server: Server, username: String, iconID: Int, callback: ((Bool) -> Void)? = nil) { diff --git a/Hotline/macOS/TrackerView.swift b/Hotline/macOS/TrackerView.swift index 5e848de..ae653b8 100644 --- a/Hotline/macOS/TrackerView.swift +++ b/Hotline/macOS/TrackerView.swift @@ -32,6 +32,7 @@ struct TrackerView: View { @State private var expandedTrackers: Set = [] @State private var trackerServers: [Bookmark: [BookmarkServer]] = [:] @State private var loadingTrackers: Set = [] + @State private var fetchTasks: [Bookmark: Task] = [:] @State private var searchText: String = "" @State private var isSearching = false @@ -67,6 +68,7 @@ struct TrackerView: View { private func filteredServers(for bookmark: Bookmark) -> [BookmarkServer] { let servers = self.trackerServers[bookmark] ?? [] + print("TrackerView.filteredServers: Looking up servers for \(bookmark.name), found \(servers.count) servers") guard !self.searchText.isEmpty else { return servers @@ -90,7 +92,8 @@ struct TrackerView: View { TrackerItemView( bookmark: bookmark, isExpanded: self.expandedTrackers.contains(bookmark), - isLoading: self.loadingTrackers.contains(bookmark) + isLoading: self.loadingTrackers.contains(bookmark), + count: self.trackerServers[bookmark]?.count ?? 0 ) { self.toggleExpanded(for: bookmark) } @@ -410,10 +413,12 @@ struct TrackerView: View { case .bookmark(let bookmark): if bookmark.type == .tracker { if self.expandedTrackers.contains(bookmark) { - // Already expanded, just refresh the servers - Task { + // Already expanded, cancel old fetch and start new one + self.fetchTasks[bookmark]?.cancel() + let task = Task { await self.fetchServers(for: bookmark) } + self.fetchTasks[bookmark] = task } else { // Not expanded, expand it (which also fetches) self.setExpanded(true, for: bookmark) @@ -430,10 +435,12 @@ struct TrackerView: View { for bookmark in self.bookmarks { if bookmark.type == .tracker { if self.expandedTrackers.contains(bookmark) { - // Already expanded, just refresh the servers - Task { + // Already expanded, cancel old fetch and start new one + self.fetchTasks[bookmark]?.cancel() + let task = Task { await self.fetchServers(for: bookmark) } + self.fetchTasks[bookmark] = task } else { // Not expanded, expand it (which also fetches) self.setExpanded(true, for: bookmark) @@ -446,13 +453,19 @@ struct TrackerView: View { guard bookmark.type == .tracker else { return } if self.expandedTrackers.contains(bookmark) { + // Collapse: cancel ongoing fetch and clear data + self.fetchTasks[bookmark]?.cancel() + self.fetchTasks[bookmark] = nil self.expandedTrackers.remove(bookmark) self.trackerServers[bookmark] = nil + self.loadingTrackers.remove(bookmark) } else { + // Expand: start fetch task self.expandedTrackers.insert(bookmark) - Task { + let task = Task { await self.fetchServers(for: bookmark) } + self.fetchTasks[bookmark] = task } } @@ -461,21 +474,32 @@ struct TrackerView: View { if expanded && !self.expandedTrackers.contains(bookmark) { self.expandedTrackers.insert(bookmark) - Task { + let task = Task { await self.fetchServers(for: bookmark) } + self.fetchTasks[bookmark] = task } else if !expanded && self.expandedTrackers.contains(bookmark) { + // Cancel ongoing fetch and clear data + self.fetchTasks[bookmark]?.cancel() + self.fetchTasks[bookmark] = nil self.expandedTrackers.remove(bookmark) self.trackerServers[bookmark] = nil + self.loadingTrackers.remove(bookmark) } } private func fetchServers(for bookmark: Bookmark) async { + print("TrackerView.fetchServers: Starting fetch for bookmark: \(bookmark.name)") self.loadingTrackers.insert(bookmark) let servers = await bookmark.fetchServers() + print("TrackerView.fetchServers: Got \(servers.count) servers from bookmark.fetchServers()") await MainActor.run { + print("TrackerView.fetchServers: Assigning \(servers.count) servers to trackerServers[\(bookmark.name)]") self.trackerServers[bookmark] = servers self.loadingTrackers.remove(bookmark) + self.fetchTasks[bookmark] = nil // Clean up completed task + print("TrackerView.fetchServers: trackerServers now has \(self.trackerServers.count) entries") + print("TrackerView.fetchServers: Verification - trackerServers[\(bookmark.name)] now has \(self.trackerServers[bookmark]?.count ?? -1) servers") } } } @@ -694,6 +718,7 @@ struct TrackerItemView: View { let bookmark: Bookmark let isExpanded: Bool let isLoading: Bool + let count: Int let onToggleExpanded: () -> Void var body: some View { @@ -727,6 +752,23 @@ struct TrackerItemView: View { .controlSize(.small) } Spacer(minLength: 0) + if isExpanded && count > 0 { + HStack(spacing: 4) { + Text(String(count)) + + Image(systemName: "globe.americas.fill") + .resizable() + .scaledToFit() + .frame(width: 12, height: 12) + .opacity(0.5) + } + + .padding(.horizontal, 8) + .padding(.vertical, 2) + .foregroundStyle(.secondary) + .background(.quinary) + .clipShape(.capsule) + } case .server: Image(systemName: "bookmark.fill") .resizable() -- cgit From 5924204ab2956d3947ca19c07585b81173226a4f Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Mon, 27 Oct 2025 11:03:49 -0700 Subject: Add spinning globe to TrackerView. Continue cleanup of NetSocketNew. --- Hotline.xcodeproj/project.pbxproj | 4 ++ Hotline/Library/NetSocketNew.swift | 4 +- Hotline/Library/SpinningGlobeView.swift | 90 +++++++++++++++++++++++++++++++++ Hotline/macOS/TrackerView.swift | 21 ++++---- 4 files changed, 106 insertions(+), 13 deletions(-) create mode 100644 Hotline/Library/SpinningGlobeView.swift (limited to 'Hotline/macOS') diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index 2aafcfe..6368fe0 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -76,6 +76,7 @@ DAC3D9832BC33FD000A727C9 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC3D9822BC33FD000A727C9 /* AppState.swift */; }; DAC6B2E02EAC6236004E2CBA /* ChatStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC6B2DE2EAC6236004E2CBA /* ChatStore.swift */; }; DAC6B2E22EAEE9FE004E2CBA /* NetSocketNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC6B2E12EAEE9EF004E2CBA /* NetSocketNew.swift */; }; + DAC6B2E42EAFE92F004E2CBA /* SpinningGlobeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC6B2E32EAFE92F004E2CBA /* SpinningGlobeView.swift */; }; DAC87F072C5010E80060FADF /* HotlineExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC87F062C5010E80060FADF /* HotlineExtensions.swift */; }; DACCE5E12EABE4B4008CDD92 /* AppUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DACCE5E02EABE4B4008CDD92 /* AppUpdate.swift */; platformFilters = (macos, ); }; DACCE5E32EABE86A008CDD92 /* AppUpdateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */; platformFilters = (macos, ); }; @@ -165,6 +166,7 @@ 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 /* NetSocketNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetSocketNew.swift; sourceTree = ""; }; + DAC6B2E32EAFE92F004E2CBA /* SpinningGlobeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpinningGlobeView.swift; sourceTree = ""; }; DAC87F062C5010E80060FADF /* HotlineExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineExtensions.swift; sourceTree = ""; }; DACCE5E02EABE4B4008CDD92 /* AppUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdate.swift; sourceTree = ""; }; DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdateView.swift; sourceTree = ""; }; @@ -278,6 +280,7 @@ isa = PBXGroup; children = ( DAC6B2E12EAEE9EF004E2CBA /* NetSocketNew.swift */, + DAC6B2E32EAFE92F004E2CBA /* SpinningGlobeView.swift */, DAB4D87A2B4B78310048A05C /* FileImageView.swift */, DAB4D8812B4C8FED0048A05C /* FileIconView.swift */, DAB4D8832B4CABEF0048A05C /* DataAdditions.swift */, @@ -491,6 +494,7 @@ DA7725412B21435B006C5ABB /* ObservableScrollView.swift in Sources */, DAE734FF2B2E6750000C56F6 /* ChatView.swift in Sources */, 11F8288B2BF9428100216BA0 /* AccountManagerView.swift in Sources */, + DAC6B2E42EAFE92F004E2CBA /* SpinningGlobeView.swift in Sources */, DA4930BD2B4F8DD700822D0B /* NetSocket.swift in Sources */, DAC002192B21630900A6C290 /* SwiftUIExtensions.swift in Sources */, DADDB28F2B238D850024040D /* Hotline.swift in Sources */, diff --git a/Hotline/Library/NetSocketNew.swift b/Hotline/Library/NetSocketNew.swift index d4507b8..8873ee6 100644 --- a/Hotline/Library/NetSocketNew.swift +++ b/Hotline/Library/NetSocketNew.swift @@ -1108,7 +1108,7 @@ fileprivate extension NetSocketNew { let values = try url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) guard values.isRegularFile == true else { throw NetSocketError.failed(underlying: NSError( - domain: "CleanSockets", code: 1001, + domain: "NetSocket", code: 1001, userInfo: [NSLocalizedDescriptionKey: "Not a regular file: \(url.path)"] )) } @@ -1116,7 +1116,7 @@ fileprivate extension NetSocketNew { let attrs = try FileManager.default.attributesOfItem(atPath: url.path) if let n = attrs[.size] as? NSNumber { return n.int64Value } throw NetSocketError.failed(underlying: NSError( - domain: "CleanSockets", code: 1002, + domain: "NetSocket", code: 1002, userInfo: [NSLocalizedDescriptionKey: "Unable to determine file size for \(url.lastPathComponent)"] )) } diff --git a/Hotline/Library/SpinningGlobeView.swift b/Hotline/Library/SpinningGlobeView.swift new file mode 100644 index 0000000..bccb969 --- /dev/null +++ b/Hotline/Library/SpinningGlobeView.swift @@ -0,0 +1,90 @@ +import SwiftUI + +/// An animated globe icon that cycles through different world regions +/// +/// Displays a spinning globe effect by cycling through SF Symbol images showing +/// different parts of the world. Useful for indicating network activity or global content. +/// +/// Example: +/// ```swift +/// SpinningGlobeView() +/// .frame(width: 16, height: 16) +/// +/// SpinningGlobeView(frameDelay: 0.5) +/// .frame(width: 24, height: 24) +/// ``` +struct SpinningGlobeView: View { + /// Delay between frames in seconds (default: 0.3) + let frameDelay: TimeInterval + + /// SF Symbol names for each frame of the globe animation + private let globeFrames = [ + "globe.americas.fill", + "globe.europe.africa.fill", + "globe.central.south.asia.fill", + "globe.asia.australia.fill" + ] + + @State private var currentFrameIndex = 0 + @State private var animationTask: Task? + + init(frameDelay: TimeInterval = 0.25) { + self.frameDelay = frameDelay + } + + var body: some View { + Image(systemName: globeFrames[currentFrameIndex]) + .onAppear { + startAnimation() + } + .onDisappear { + stopAnimation() + } + } + + private func startAnimation() { + animationTask = Task { + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: UInt64(frameDelay * 1_000_000_000)) + + guard !Task.isCancelled else { break } + + currentFrameIndex = (currentFrameIndex + 1) % globeFrames.count + } + } + } + + private func stopAnimation() { + animationTask?.cancel() + animationTask = nil + } +} + +#Preview { + VStack(spacing: 20) { + HStack(spacing: 20) { + SpinningGlobeView() + .frame(width: 12, height: 12) + + SpinningGlobeView() + .frame(width: 16, height: 16) + + SpinningGlobeView() + .frame(width: 24, height: 24) + } + + Text("Different frame delays:") + + HStack(spacing: 20) { + SpinningGlobeView(frameDelay: 0.1) + .frame(width: 16, height: 16) + + SpinningGlobeView(frameDelay: 0.3) + .frame(width: 16, height: 16) + + SpinningGlobeView(frameDelay: 0.6) + .frame(width: 16, height: 16) + } + } + .padding() +} diff --git a/Hotline/macOS/TrackerView.swift b/Hotline/macOS/TrackerView.swift index ae653b8..251248f 100644 --- a/Hotline/macOS/TrackerView.swift +++ b/Hotline/macOS/TrackerView.swift @@ -755,19 +755,18 @@ struct TrackerItemView: View { if isExpanded && count > 0 { HStack(spacing: 4) { Text(String(count)) - - Image(systemName: "globe.americas.fill") - .resizable() - .scaledToFit() + + SpinningGlobeView() + .fontWeight(.semibold) .frame(width: 12, height: 12) - .opacity(0.5) +// .opacity(0.5) } - - .padding(.horizontal, 8) - .padding(.vertical, 2) - .foregroundStyle(.secondary) - .background(.quinary) - .clipShape(.capsule) + + .padding(.horizontal, 8) + .padding(.vertical, 2) + .foregroundStyle(.secondary) + .background(.quinary) + .clipShape(.capsule) } case .server: Image(systemName: "bookmark.fill") -- cgit From 4bb0ffba596e41a8309ba50d222248a0ba3eb42e Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Mon, 27 Oct 2025 21:48:20 -0700 Subject: Random project housekeeping/organizing source files. --- Hotline.xcodeproj/project.pbxproj | 104 +++- Hotline/macOS/AccountManagerView.swift | 398 --------------- Hotline/macOS/Accounts/AccountManagerView.swift | 398 +++++++++++++++ Hotline/macOS/Board/MessageBoardEditorView.swift | 117 +++++ Hotline/macOS/Board/MessageBoardView.swift | 102 ++++ Hotline/macOS/Chat/ChatView.swift | 321 ++++++++++++ Hotline/macOS/Chat/ServerAgreementView.swift | 78 +++ Hotline/macOS/Chat/ServerMessageView.swift | 33 ++ Hotline/macOS/ChatView.swift | 321 ------------ Hotline/macOS/FileDetailsView.swift | 147 ------ Hotline/macOS/FilePreviewImageView.swift | 123 ----- Hotline/macOS/FilePreviewTextView.swift | 127 ----- Hotline/macOS/Files/FileDetailsView.swift | 147 ++++++ Hotline/macOS/Files/FileItemView.swift | 67 +++ Hotline/macOS/Files/FilePreviewImageView.swift | 123 +++++ Hotline/macOS/Files/FilePreviewTextView.swift | 127 +++++ Hotline/macOS/Files/FilesView.swift | 418 +++++++++++++++ Hotline/macOS/Files/FolderItemView.swift | 140 +++++ Hotline/macOS/FilesView.swift | 619 ----------------------- Hotline/macOS/MessageBoardEditorView.swift | 117 ----- Hotline/macOS/MessageBoardView.swift | 102 ---- Hotline/macOS/News/NewsEditorView.swift | 153 ++++++ Hotline/macOS/News/NewsItemView.swift | 152 ++++++ Hotline/macOS/News/NewsView.swift | 297 +++++++++++ Hotline/macOS/NewsEditorView.swift | 153 ------ Hotline/macOS/NewsItemView.swift | 152 ------ Hotline/macOS/NewsView.swift | 297 ----------- Hotline/macOS/ServerAgreementView.swift | 78 --- Hotline/macOS/ServerMessageView.swift | 33 -- Hotline/macOS/Settings/GeneralSettingsView.swift | 67 +++ Hotline/macOS/Settings/IconSettingsView.swift | 58 +++ Hotline/macOS/Settings/SettingsView.swift | 31 ++ Hotline/macOS/Settings/SoundSettingsView.swift | 40 ++ Hotline/macOS/SettingsView.swift | 193 ------- Hotline/macOS/TrackerView.swift | 64 +-- 35 files changed, 2966 insertions(+), 2931 deletions(-) delete mode 100644 Hotline/macOS/AccountManagerView.swift create mode 100644 Hotline/macOS/Accounts/AccountManagerView.swift create mode 100644 Hotline/macOS/Board/MessageBoardEditorView.swift create mode 100644 Hotline/macOS/Board/MessageBoardView.swift create mode 100644 Hotline/macOS/Chat/ChatView.swift create mode 100644 Hotline/macOS/Chat/ServerAgreementView.swift create mode 100644 Hotline/macOS/Chat/ServerMessageView.swift delete mode 100644 Hotline/macOS/ChatView.swift delete mode 100644 Hotline/macOS/FileDetailsView.swift delete mode 100644 Hotline/macOS/FilePreviewImageView.swift delete mode 100644 Hotline/macOS/FilePreviewTextView.swift create mode 100644 Hotline/macOS/Files/FileDetailsView.swift create mode 100644 Hotline/macOS/Files/FileItemView.swift create mode 100644 Hotline/macOS/Files/FilePreviewImageView.swift create mode 100644 Hotline/macOS/Files/FilePreviewTextView.swift create mode 100644 Hotline/macOS/Files/FilesView.swift create mode 100644 Hotline/macOS/Files/FolderItemView.swift delete mode 100644 Hotline/macOS/FilesView.swift delete mode 100644 Hotline/macOS/MessageBoardEditorView.swift delete mode 100644 Hotline/macOS/MessageBoardView.swift create mode 100644 Hotline/macOS/News/NewsEditorView.swift create mode 100644 Hotline/macOS/News/NewsItemView.swift create mode 100644 Hotline/macOS/News/NewsView.swift delete mode 100644 Hotline/macOS/NewsEditorView.swift delete mode 100644 Hotline/macOS/NewsItemView.swift delete mode 100644 Hotline/macOS/NewsView.swift delete mode 100644 Hotline/macOS/ServerAgreementView.swift delete mode 100644 Hotline/macOS/ServerMessageView.swift create mode 100644 Hotline/macOS/Settings/GeneralSettingsView.swift create mode 100644 Hotline/macOS/Settings/IconSettingsView.swift create mode 100644 Hotline/macOS/Settings/SettingsView.swift create mode 100644 Hotline/macOS/Settings/SoundSettingsView.swift delete mode 100644 Hotline/macOS/SettingsView.swift (limited to 'Hotline/macOS') diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index 6368fe0..a173d55 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -8,7 +8,7 @@ /* Begin PBXBuildFile section */ 11A726082BE0672A000C1DA7 /* FileDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11A726072BE0672A000C1DA7 /* FileDetails.swift */; }; - 11A7260A2BE0675A000C1DA7 /* FileDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11A726092BE06759000C1DA7 /* FileDetailsView.swift */; }; + 11A7260A2BE0675A000C1DA7 /* FileDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11A726092BE06759000C1DA7 /* FileDetailsView.swift */; platformFilters = (macos, ); }; 11F8288B2BF9428100216BA0 /* AccountManagerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11F8288A2BF9428100216BA0 /* AccountManagerView.swift */; }; DA0D698D2B1E7CF700C71DF5 /* UsersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0D698C2B1E7CF700C71DF5 /* UsersView.swift */; platformFilter = ios; }; DA0D698F2B1E841600C71DF5 /* MessageBoardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0D698E2B1E841600C71DF5 /* MessageBoardView.swift */; platformFilter = ios; }; @@ -27,6 +27,11 @@ DA4B8F3A2EA6FB3C00CBFD53 /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4B8F392EA6FB3C00CBFD53 /* iOSApp.swift */; platformFilter = ios; }; DA4F2BF82B16A17200D8ADDC /* HotlineProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4F2BF72B16A17200D8ADDC /* HotlineProtocol.swift */; }; DA4F2C012B1A558E00D8ADDC /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4F2C002B1A558E00D8ADDC /* ChatView.swift */; platformFilter = ios; }; + 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, ); }; + DA5268A32EB0741B00DCB941 /* FolderItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268A22EB0741B00DCB941 /* FolderItemView.swift */; platformFilters = (macos, ); }; + DA5268A52EB0743000DCB941 /* FileItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268A42EB0743000DCB941 /* FileItemView.swift */; platformFilters = (macos, ); }; 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, ); }; @@ -118,6 +123,11 @@ DA4B8F392EA6FB3C00CBFD53 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; }; DA4F2BF72B16A17200D8ADDC /* HotlineProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineProtocol.swift; sourceTree = ""; }; DA4F2C002B1A558E00D8ADDC /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = ""; }; + DA52689B2EB0738B00DCB941 /* GeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsView.swift; sourceTree = ""; }; + DA52689D2EB073A400DCB941 /* IconSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconSettingsView.swift; sourceTree = ""; }; + DA52689F2EB073BC00DCB941 /* SoundSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundSettingsView.swift; sourceTree = ""; }; + DA5268A22EB0741B00DCB941 /* FolderItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderItemView.swift; sourceTree = ""; }; + DA5268A42EB0743000DCB941 /* FileItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileItemView.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 = ""; }; @@ -230,6 +240,67 @@ path = iOS; sourceTree = ""; }; + DA52689A2EB0737400DCB941 /* Settings */ = { + isa = PBXGroup; + children = ( + DA2863D72B37AD1C00A7D050 /* SettingsView.swift */, + DA52689B2EB0738B00DCB941 /* GeneralSettingsView.swift */, + DA52689D2EB073A400DCB941 /* IconSettingsView.swift */, + DA52689F2EB073BC00DCB941 /* SoundSettingsView.swift */, + ); + path = Settings; + sourceTree = ""; + }; + DA5268A12EB073E600DCB941 /* Files */ = { + isa = PBXGroup; + children = ( + DAE735002B2E71F2000C56F6 /* FilesView.swift */, + DA5268A42EB0743000DCB941 /* FileItemView.swift */, + DA5268A22EB0741B00DCB941 /* FolderItemView.swift */, + 11A726092BE06759000C1DA7 /* FileDetailsView.swift */, + DAAEE66E2B47625600A5BA07 /* FilePreviewImageView.swift */, + DAB4D87D2B4C8BCA0048A05C /* FilePreviewTextView.swift */, + ); + path = Files; + sourceTree = ""; + }; + DA5268A62EB0762300DCB941 /* Board */ = { + isa = PBXGroup; + children = ( + DAE734FC2B2E65E9000C56F6 /* MessageBoardView.swift */, + DA69807D2BFD449B003E434B /* MessageBoardEditorView.swift */, + ); + path = Board; + sourceTree = ""; + }; + DA5268A72EB0812E00DCB941 /* Accounts */ = { + isa = PBXGroup; + children = ( + 11F8288A2BF9428100216BA0 /* AccountManagerView.swift */, + ); + path = Accounts; + sourceTree = ""; + }; + DA5268A82EB081AF00DCB941 /* Chat */ = { + isa = PBXGroup; + children = ( + DAE734FE2B2E6750000C56F6 /* ChatView.swift */, + DA6549992BEC280E00EDB697 /* ServerMessageView.swift */, + DA65499B2BEC3FBD00EDB697 /* ServerAgreementView.swift */, + ); + path = Chat; + sourceTree = ""; + }; + DA5268A92EB081DE00DCB941 /* News */ = { + isa = PBXGroup; + children = ( + DAE735042B3218D8000C56F6 /* NewsView.swift */, + DA2D98112BF29D5F0027E4BD /* NewsItemView.swift */, + DA72A0DC2B4CD0BF00A0F48A /* NewsEditorView.swift */, + ); + path = News; + sourceTree = ""; + }; DA9CAFAE2B126D5700CDA197 = { isa = PBXGroup; children = ( @@ -370,26 +441,18 @@ DAE734F72B2E4126000C56F6 /* macOS */ = { isa = PBXGroup; children = ( + DA55AC762BE589F700034857 /* AboutView.swift */, + DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */, + DAE136B92B9D1147007D8307 /* HotlinePanelView.swift */, DAE734FA2B2E41F9000C56F6 /* TrackerView.swift */, DAE734F82B2E4185000C56F6 /* ServerView.swift */, - DAE734FE2B2E6750000C56F6 /* ChatView.swift */, - DAE735042B3218D8000C56F6 /* NewsView.swift */, - DA2D98112BF29D5F0027E4BD /* NewsItemView.swift */, - DA72A0DC2B4CD0BF00A0F48A /* NewsEditorView.swift */, DAE735022B30C0BB000C56F6 /* MessageView.swift */, - DAE734FC2B2E65E9000C56F6 /* MessageBoardView.swift */, - DA69807D2BFD449B003E434B /* MessageBoardEditorView.swift */, - DAE735002B2E71F2000C56F6 /* FilesView.swift */, - DAAEE66E2B47625600A5BA07 /* FilePreviewImageView.swift */, - DAB4D87D2B4C8BCA0048A05C /* FilePreviewTextView.swift */, - 11A726092BE06759000C1DA7 /* FileDetailsView.swift */, - DA2863D72B37AD1C00A7D050 /* SettingsView.swift */, - DAE136B92B9D1147007D8307 /* HotlinePanelView.swift */, - DA55AC762BE589F700034857 /* AboutView.swift */, - DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */, - DA65499B2BEC3FBD00EDB697 /* ServerAgreementView.swift */, - DA6549992BEC280E00EDB697 /* ServerMessageView.swift */, - 11F8288A2BF9428100216BA0 /* AccountManagerView.swift */, + DA5268A82EB081AF00DCB941 /* Chat */, + DA5268A62EB0762300DCB941 /* Board */, + DA5268A92EB081DE00DCB941 /* News */, + DA5268A12EB073E600DCB941 /* Files */, + DA5268A72EB0812E00DCB941 /* Accounts */, + DA52689A2EB0737400DCB941 /* Settings */, ); path = macOS; sourceTree = ""; @@ -486,6 +549,7 @@ DA6300972B24036B0034CBFD /* HotlineClient.swift in Sources */, DA43205E2B1D615600FC8843 /* ServerView.swift in Sources */, DA0D69912B1E894800C71DF5 /* FilesView.swift in Sources */, + DA52689E2EB073A400DCB941 /* IconSettingsView.swift in Sources */, DAC3D9832BC33FD000A727C9 /* AppState.swift in Sources */, DA65499C2BEC3FBD00EDB697 /* ServerAgreementView.swift in Sources */, DAB4D8802B4C8E9A0048A05C /* URLAdditions.swift in Sources */, @@ -503,6 +567,7 @@ DA55AC772BE589F700034857 /* AboutView.swift in Sources */, DA72A0E22B4DAA4000A0F48A /* NewsArticle.swift in Sources */, DAE734FB2B2E41F9000C56F6 /* TrackerView.swift in Sources */, + DA5268A32EB0741B00DCB941 /* FolderItemView.swift in Sources */, DAE136BA2B9D1147007D8307 /* HotlinePanelView.swift in Sources */, 11A726082BE0672A000C1DA7 /* FileDetails.swift in Sources */, DA9CAFBD2B126D5700CDA197 /* TrackerView.swift in Sources */, @@ -524,8 +589,10 @@ DA69807E2BFD449B003E434B /* MessageBoardEditorView.swift in Sources */, DAE735012B2E71F2000C56F6 /* FilesView.swift in Sources */, DA55AC792BE6A1AD00034857 /* RegularExpressions.swift in Sources */, + DA5268A02EB073BC00DCB941 /* SoundSettingsView.swift in Sources */, 11A7260A2BE0675A000C1DA7 /* FileDetailsView.swift in Sources */, DA72A0DD2B4CD0BF00A0F48A /* NewsEditorView.swift in Sources */, + DA52689C2EB0738B00DCB941 /* GeneralSettingsView.swift in Sources */, DABFCC292B1530DC009F40D2 /* FoundationExtensions.swift in Sources */, DA57536C2B36BA1D00FAC277 /* TextDocument.swift in Sources */, DACCE5E12EABE4B4008CDD92 /* AppUpdate.swift in Sources */, @@ -544,6 +611,7 @@ DAB4D8842B4CABEF0048A05C /* DataAdditions.swift in Sources */, DA0D698D2B1E7CF700C71DF5 /* UsersView.swift in Sources */, DAE735052B3218D8000C56F6 /* NewsView.swift in Sources */, + DA5268A52EB0743000DCB941 /* FileItemView.swift in Sources */, DAE734F92B2E4185000C56F6 /* ServerView.swift in Sources */, DA2D98122BF29D5F0027E4BD /* NewsItemView.swift in Sources */, DADDB28B2B22B31F0024040D /* Tracker.swift in Sources */, diff --git a/Hotline/macOS/AccountManagerView.swift b/Hotline/macOS/AccountManagerView.swift deleted file mode 100644 index 57682cc..0000000 --- a/Hotline/macOS/AccountManagerView.swift +++ /dev/null @@ -1,398 +0,0 @@ -import SwiftUI - -struct AccountManagerView: View { - @Environment(Hotline.self) private var model: Hotline - - @State private var accounts: [HotlineAccount] = [] - @State private var selection: HotlineAccount? - @State private var loading: Bool = true - - @State private var pendingName: String = "" - @State private var pendingLogin: String = "" - @State private var pendingPassword: String = "" - @State private var pendingAccess = HotlineUserAccessOptions.defaultAccess - - @State private var toDelete: HotlineAccount? - - let placeholderPassword = "xxxxxxxxxxxxxxxxxx" - - var body: some View { - HStack(spacing: 0) { - ZStack { - accountList - if loading { - ProgressView() - } - } - if selection != nil { - accountDetails - } - else { - ZStack(alignment: .center) { - Text("No Account Selected") - .font(.title) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding() - } - .frame(maxWidth: .infinity) - } - } - .environment(\.defaultMinListRowHeight, 34) - .listStyle(.inset) - .alternatingRowBackgrounds(.enabled) - .task { - if loading { - accounts = await model.getAccounts() - loading = false - } - } - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button { - let newAccount = HotlineAccount("unnamed", "unnamed", HotlineUserAccessOptions.defaultAccess) - - pendingPassword = HotlineAccount.randomPassword() - accounts.append(newAccount) - selection = newAccount - } label: { - Label("New Account", systemImage: "plus") - } - .help("Create a new account") - .disabled(model.access?.contains(.canCreateUsers) != true) - } - - ToolbarItem(placement: .destructiveAction) { - Button { - toDelete = selection - } label: { - Label("Delete Account", systemImage: "trash") - } - .help("Delete account") - .disabled(selection == nil || model.access?.contains(.canDeleteUsers) != true) - } - } - } - - var accountDetails: some View { - VStack(alignment: .center, spacing: 0) { - ScrollView(.vertical) { - Form { - Section { - TextField(text: $pendingName) { - Text("Name") - } - TextField("Login", text: $pendingLogin) - .disabled(selection?.persisted == true) - if selection?.persisted == true { - SecureField("Password", text: $pendingPassword) - } else { - TextField("Password", text: $pendingPassword) - } - } - .textFieldStyle(.roundedBorder) - .controlSize(.large) - - Section("File System Maintenance") { - Toggle("Can Download Files", isOn: $pendingAccess.bind(.canDownloadFiles)) - .disabled(model.access?.contains(.canDownloadFiles) == false) - Toggle("Can Download Folders", isOn: $pendingAccess.bind(.canDownloadFolders)) - .disabled(model.access?.contains(.canDownloadFolders) == false) - Toggle("Can Upload Files", isOn: $pendingAccess.bind(.canUploadFiles)) - .disabled(model.access?.contains(.canUploadFiles) == false) - Toggle("Can Upload Folders", isOn: $pendingAccess.bind(.canUploadFolders)) - .disabled(model.access?.contains(.canUploadFolders) == false) - Toggle("Can Upload Anywhere", isOn: $pendingAccess.bind(.canUploadAnywhere)) - .disabled(model.access?.contains(.canUploadAnywhere) == false) - Toggle("Can Delete Files", isOn: $pendingAccess.bind(.canDeleteFiles)) - .disabled(model.access?.contains(.canDeleteFiles) == false) - Toggle("Can Rename Files", isOn: $pendingAccess.bind(.canRenameFiles)) - .disabled(model.access?.contains(.canRenameFiles) == false) - Toggle("Can Move Files", isOn: $pendingAccess.bind(.canMoveFiles)) - .disabled(model.access?.contains(.canMoveFiles) == false) - Toggle("Can Comment Files", isOn: $pendingAccess.bind(.canSetFileComment)) - .disabled(model.access?.contains(.canSetFileComment) == false) - Toggle("Can Create Folders", isOn: $pendingAccess.bind(.canCreateFolders)) - .disabled(model.access?.contains(.canCreateFolders) == false) - Toggle("Can Delete Folders", isOn: $pendingAccess.bind(.canDeleteFolders)) - .disabled(model.access?.contains(.canDeleteFolders) == false) - Toggle("Can Rename Folders", isOn: $pendingAccess.bind(.canRenameFolders)) - .disabled(model.access?.contains(.canRenameFolders) == false) - Toggle("Can Move Folders", isOn: $pendingAccess.bind(.canMoveFolders)) - .disabled(model.access?.contains(.canMoveFolders) == false) - Toggle("Can Comment Folders", isOn: $pendingAccess.bind(.canSetFolderComment)) - .disabled(model.access?.contains(.canSetFolderComment) == false) - Toggle("Can View Drop Boxes", isOn: $pendingAccess.bind(.canViewDropBoxes)) - .disabled(model.access?.contains(.canViewDropBoxes) == false) - Toggle("Can Make Aliases", isOn: $pendingAccess.bind(.canMakeAliases)) - .disabled(model.access?.contains(.canMakeAliases) == false) - } - - Section("User Maintenance") { - Toggle("Can Create Accounts", isOn: $pendingAccess.bind(.canCreateUsers)) - .disabled(model.access?.contains(.canCreateUsers) == false) - Toggle("Can Delete Accounts", isOn: $pendingAccess.bind(.canDeleteUsers)) - .disabled(model.access?.contains(.canDeleteUsers) == false) - Toggle("Can Read Accounts", isOn: $pendingAccess.bind(.canOpenUsers)) - .disabled(model.access?.contains(.canOpenUsers) == false) - Toggle("Can Modify Accounts", isOn: $pendingAccess.bind(.canModifyUsers)) - .disabled(model.access?.contains(.canModifyUsers) == false) - Toggle("Can Get User Info", isOn: $pendingAccess.bind(.canGetClientInfo)) - .disabled(model.access?.contains(.canGetClientInfo) == false) - - Toggle("Can Disconnect Users", isOn: $pendingAccess.bind(.canDisconnectUsers)) - .disabled(model.access?.contains(.canDisconnectUsers) == false) - Toggle("Cannot be Disconnected", isOn: $pendingAccess.bind(.cantBeDisconnected)) - .disabled(model.access?.contains(.cantBeDisconnected) == false) - } - - Section("Messaging") { - Toggle("Can Send Messages", isOn: $pendingAccess.bind(.canSendMessages)) - .disabled(model.access?.contains(.canSendMessages) == false) - Toggle("Can Broadcast", isOn: $pendingAccess.bind(.canBroadcast)) - .disabled(model.access?.contains(.canBroadcast) == false) - } - - Section("News") { - Toggle("Can Read Articles", isOn: $pendingAccess.bind(.canReadMessageBoard)) - .disabled(model.access?.contains(.canReadMessageBoard) == false) - Toggle("Can Post Articles", isOn: $pendingAccess.bind(.canPostMessageBoard)) - .disabled(model.access?.contains(.canPostMessageBoard) == false) - Toggle("Can Delete Articles", isOn: $pendingAccess.bind(.canDeleteNewsArticles)) - .disabled(model.access?.contains(.canDeleteNewsArticles) == false) - Toggle("Can Create Categories", isOn: $pendingAccess.bind(.canCreateNewsCategories)) - .disabled(model.access?.contains(.canCreateNewsCategories) == false) - Toggle("Can Delete Categories", isOn: $pendingAccess.bind(.canDeleteNewsCategories)) - .disabled(model.access?.contains(.canDeleteNewsCategories) == false) - Toggle("Can Create News Bundles", isOn: $pendingAccess.bind(.canCreateNewsFolders)) - .disabled(model.access?.contains(.canCreateNewsFolders) == false) - Toggle("Can Delete News Bundles", isOn: $pendingAccess.bind(.canDeleteNewsFolders)) - .disabled(model.access?.contains(.canDeleteNewsFolders) == false) - } - - Section("Chat") { - Toggle("Can Initiate Private Chat", isOn: $pendingAccess.bind(.canCreateChat)) - .disabled(model.access?.contains(.canCreateChat) == false) - Toggle("Can Read Chat", isOn: $pendingAccess.bind(.canReadChat)) - .disabled(model.access?.contains(.canReadChat) == false) - Toggle("Can Send Chat", isOn: $pendingAccess.bind(.canSendChat)) - .disabled(model.access?.contains(.canSendChat) == false) - } - - Section("Miscellaneous") { - Toggle("Can Use Any Name", isOn: $pendingAccess.bind(.canUseAnyName)) - .disabled(model.access?.contains(.canUseAnyName) == false) - Toggle("Don't Show Agreement", isOn: $pendingAccess.bind(.canSkipAgreement)) - .disabled(model.access?.contains(.canSkipAgreement) == false) - } - } - .disabled(model.access?.contains(.canModifyUsers) == false) - .formStyle(.grouped) - .onChange(of: selection) { - if let selection { - pendingName = selection.name - pendingLogin = selection.login - pendingAccess = selection.access - - if selection.persisted { - if selection.password == nil { - pendingPassword = "" - } else { - pendingPassword = placeholderPassword - } - } - } - } - .onAppear() { - if let selection { - pendingName = selection.name - pendingLogin = selection.login - pendingAccess = selection.access - - if selection.persisted { - if selection.password == nil { - pendingPassword = "" - } else { - pendingPassword = placeholderPassword - } - } else { - pendingPassword = HotlineAccount.randomPassword() - } - } - } - } - .frame(maxWidth: .infinity) - - Divider() - - HStack() { - Button("Revert") { - if let selection { - pendingAccess = selection.access - pendingName = selection.name - pendingLogin = selection.login - - if selection.password != nil { - pendingPassword = selection.password! - } - } - } - .controlSize(.large) - .frame(minWidth: 75) - .disabled(!self.isSaveable()) -// .padding() - - Spacer() - - Button("Save"){ - guard let selection else { - return - } - - // Update existing account - if selection.persisted == true { - - if pendingPassword == placeholderPassword { - Task { @MainActor in - model.client.sendSetUser(name: pendingName, login: pendingLogin, newLogin: nil, password: nil, access: pendingAccess.rawValue) - } - } else { - Task { @MainActor in - model.client.sendSetUser(name: pendingName, login: pendingLogin, newLogin: nil, password: pendingPassword, access: pendingAccess.rawValue) - } - } - - } else { - // Create new existing account - Task { @MainActor in - model.client.sendCreateUser(name: pendingName, login: pendingLogin, password: pendingPassword, access: pendingAccess.rawValue) - } - self.selection?.password = pendingPassword - pendingPassword = placeholderPassword - } - - var account = HotlineAccount(pendingName, pendingLogin, pendingAccess) - account.persisted = true - account.password = placeholderPassword - - accounts = accounts.filter { $0.persisted == true && $0.login != selection.login } - - // Add new account to list - accounts.append(account) - - // Re-sort accounts - accounts.sort { $0.login < $1.login } - self.selection = account - } - .controlSize(.large) - .frame(minWidth: 75) - .keyboardShortcut(.defaultAction) - .disabled(!self.isSaveable()) - } - .padding() - } - - } - - var accountList: some View { - List(accounts, id: \.self, selection: $selection) { account in - HStack(spacing: 5) { - if account.access.contains(.canDisconnectUsers) { - Image("User Admin") - .frame(width: 16, height: 16) - .opacity(account.persisted ? 1.0 : 0.25) - // .padding(.leading, 4) - Text(account.login) - .foregroundStyle(Color.hotlineRed) - } - else if account.access.rawValue == 0 { - Image("User") - .frame(width: 16, height: 16) - // .padding(.leading, 4) - Text(account.login) - .foregroundStyle(.secondary) - } - // else if account.persisted == false { - // HStack { - // Image("User") - // .frame(width: 16, height: 16) - //// .padding(.leading, 4) - // Text(account.login) - // .italic() - // } - // } - else { - Image("User") - .frame(width: 16, height: 16) - .opacity(account.persisted ? 1.0 : 0.5) - // .padding(.leading, 4) - Text(account.login) - } - } - } - .frame(width: 250) - .sheet(item: $toDelete) { item in - Form { - HStack{ - Image(systemName: "exclamationmark.triangle") - .font(.system(size: 30)) - Text("Delete account \"\(item.name)\" and all associated files?") - .lineSpacing(4) - } - } - .frame(minWidth: 300, idealWidth: 450, maxWidth: .infinity, minHeight: 100, idealHeight: 100, maxHeight: .infinity) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - toDelete = nil - } - } - - ToolbarItem(placement: .primaryAction) { - Button("Delete") { - guard let userToDelete = toDelete else { - return - } - - self.toDelete = nil - self.selection = nil - - if userToDelete.persisted { - Task { @MainActor in - model.client.sendDeleteUser(login: userToDelete.login) - } - } - - accounts = accounts.filter { $0.login != userToDelete.login } - - } - } - } - } - } - - - private func isSaveable() -> Bool { - guard let selection else { - return false - } - - // Disable save if login field is cleared - if pendingLogin == "" { - return false - } - - // If the account initial has a password and it was updated - if selection.password != nil && pendingPassword != placeholderPassword { - return true - } - - // If the account initial has no password, but was updated to have one - if selection.password == nil && pendingPassword != "" { - return true - } - - // If the access bits or user name have been changed - return pendingAccess.rawValue != selection.access.rawValue || selection.name != pendingName - } -} diff --git a/Hotline/macOS/Accounts/AccountManagerView.swift b/Hotline/macOS/Accounts/AccountManagerView.swift new file mode 100644 index 0000000..57682cc --- /dev/null +++ b/Hotline/macOS/Accounts/AccountManagerView.swift @@ -0,0 +1,398 @@ +import SwiftUI + +struct AccountManagerView: View { + @Environment(Hotline.self) private var model: Hotline + + @State private var accounts: [HotlineAccount] = [] + @State private var selection: HotlineAccount? + @State private var loading: Bool = true + + @State private var pendingName: String = "" + @State private var pendingLogin: String = "" + @State private var pendingPassword: String = "" + @State private var pendingAccess = HotlineUserAccessOptions.defaultAccess + + @State private var toDelete: HotlineAccount? + + let placeholderPassword = "xxxxxxxxxxxxxxxxxx" + + var body: some View { + HStack(spacing: 0) { + ZStack { + accountList + if loading { + ProgressView() + } + } + if selection != nil { + accountDetails + } + else { + ZStack(alignment: .center) { + Text("No Account Selected") + .font(.title) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding() + } + .frame(maxWidth: .infinity) + } + } + .environment(\.defaultMinListRowHeight, 34) + .listStyle(.inset) + .alternatingRowBackgrounds(.enabled) + .task { + if loading { + accounts = await model.getAccounts() + loading = false + } + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + let newAccount = HotlineAccount("unnamed", "unnamed", HotlineUserAccessOptions.defaultAccess) + + pendingPassword = HotlineAccount.randomPassword() + accounts.append(newAccount) + selection = newAccount + } label: { + Label("New Account", systemImage: "plus") + } + .help("Create a new account") + .disabled(model.access?.contains(.canCreateUsers) != true) + } + + ToolbarItem(placement: .destructiveAction) { + Button { + toDelete = selection + } label: { + Label("Delete Account", systemImage: "trash") + } + .help("Delete account") + .disabled(selection == nil || model.access?.contains(.canDeleteUsers) != true) + } + } + } + + var accountDetails: some View { + VStack(alignment: .center, spacing: 0) { + ScrollView(.vertical) { + Form { + Section { + TextField(text: $pendingName) { + Text("Name") + } + TextField("Login", text: $pendingLogin) + .disabled(selection?.persisted == true) + if selection?.persisted == true { + SecureField("Password", text: $pendingPassword) + } else { + TextField("Password", text: $pendingPassword) + } + } + .textFieldStyle(.roundedBorder) + .controlSize(.large) + + Section("File System Maintenance") { + Toggle("Can Download Files", isOn: $pendingAccess.bind(.canDownloadFiles)) + .disabled(model.access?.contains(.canDownloadFiles) == false) + Toggle("Can Download Folders", isOn: $pendingAccess.bind(.canDownloadFolders)) + .disabled(model.access?.contains(.canDownloadFolders) == false) + Toggle("Can Upload Files", isOn: $pendingAccess.bind(.canUploadFiles)) + .disabled(model.access?.contains(.canUploadFiles) == false) + Toggle("Can Upload Folders", isOn: $pendingAccess.bind(.canUploadFolders)) + .disabled(model.access?.contains(.canUploadFolders) == false) + Toggle("Can Upload Anywhere", isOn: $pendingAccess.bind(.canUploadAnywhere)) + .disabled(model.access?.contains(.canUploadAnywhere) == false) + Toggle("Can Delete Files", isOn: $pendingAccess.bind(.canDeleteFiles)) + .disabled(model.access?.contains(.canDeleteFiles) == false) + Toggle("Can Rename Files", isOn: $pendingAccess.bind(.canRenameFiles)) + .disabled(model.access?.contains(.canRenameFiles) == false) + Toggle("Can Move Files", isOn: $pendingAccess.bind(.canMoveFiles)) + .disabled(model.access?.contains(.canMoveFiles) == false) + Toggle("Can Comment Files", isOn: $pendingAccess.bind(.canSetFileComment)) + .disabled(model.access?.contains(.canSetFileComment) == false) + Toggle("Can Create Folders", isOn: $pendingAccess.bind(.canCreateFolders)) + .disabled(model.access?.contains(.canCreateFolders) == false) + Toggle("Can Delete Folders", isOn: $pendingAccess.bind(.canDeleteFolders)) + .disabled(model.access?.contains(.canDeleteFolders) == false) + Toggle("Can Rename Folders", isOn: $pendingAccess.bind(.canRenameFolders)) + .disabled(model.access?.contains(.canRenameFolders) == false) + Toggle("Can Move Folders", isOn: $pendingAccess.bind(.canMoveFolders)) + .disabled(model.access?.contains(.canMoveFolders) == false) + Toggle("Can Comment Folders", isOn: $pendingAccess.bind(.canSetFolderComment)) + .disabled(model.access?.contains(.canSetFolderComment) == false) + Toggle("Can View Drop Boxes", isOn: $pendingAccess.bind(.canViewDropBoxes)) + .disabled(model.access?.contains(.canViewDropBoxes) == false) + Toggle("Can Make Aliases", isOn: $pendingAccess.bind(.canMakeAliases)) + .disabled(model.access?.contains(.canMakeAliases) == false) + } + + Section("User Maintenance") { + Toggle("Can Create Accounts", isOn: $pendingAccess.bind(.canCreateUsers)) + .disabled(model.access?.contains(.canCreateUsers) == false) + Toggle("Can Delete Accounts", isOn: $pendingAccess.bind(.canDeleteUsers)) + .disabled(model.access?.contains(.canDeleteUsers) == false) + Toggle("Can Read Accounts", isOn: $pendingAccess.bind(.canOpenUsers)) + .disabled(model.access?.contains(.canOpenUsers) == false) + Toggle("Can Modify Accounts", isOn: $pendingAccess.bind(.canModifyUsers)) + .disabled(model.access?.contains(.canModifyUsers) == false) + Toggle("Can Get User Info", isOn: $pendingAccess.bind(.canGetClientInfo)) + .disabled(model.access?.contains(.canGetClientInfo) == false) + + Toggle("Can Disconnect Users", isOn: $pendingAccess.bind(.canDisconnectUsers)) + .disabled(model.access?.contains(.canDisconnectUsers) == false) + Toggle("Cannot be Disconnected", isOn: $pendingAccess.bind(.cantBeDisconnected)) + .disabled(model.access?.contains(.cantBeDisconnected) == false) + } + + Section("Messaging") { + Toggle("Can Send Messages", isOn: $pendingAccess.bind(.canSendMessages)) + .disabled(model.access?.contains(.canSendMessages) == false) + Toggle("Can Broadcast", isOn: $pendingAccess.bind(.canBroadcast)) + .disabled(model.access?.contains(.canBroadcast) == false) + } + + Section("News") { + Toggle("Can Read Articles", isOn: $pendingAccess.bind(.canReadMessageBoard)) + .disabled(model.access?.contains(.canReadMessageBoard) == false) + Toggle("Can Post Articles", isOn: $pendingAccess.bind(.canPostMessageBoard)) + .disabled(model.access?.contains(.canPostMessageBoard) == false) + Toggle("Can Delete Articles", isOn: $pendingAccess.bind(.canDeleteNewsArticles)) + .disabled(model.access?.contains(.canDeleteNewsArticles) == false) + Toggle("Can Create Categories", isOn: $pendingAccess.bind(.canCreateNewsCategories)) + .disabled(model.access?.contains(.canCreateNewsCategories) == false) + Toggle("Can Delete Categories", isOn: $pendingAccess.bind(.canDeleteNewsCategories)) + .disabled(model.access?.contains(.canDeleteNewsCategories) == false) + Toggle("Can Create News Bundles", isOn: $pendingAccess.bind(.canCreateNewsFolders)) + .disabled(model.access?.contains(.canCreateNewsFolders) == false) + Toggle("Can Delete News Bundles", isOn: $pendingAccess.bind(.canDeleteNewsFolders)) + .disabled(model.access?.contains(.canDeleteNewsFolders) == false) + } + + Section("Chat") { + Toggle("Can Initiate Private Chat", isOn: $pendingAccess.bind(.canCreateChat)) + .disabled(model.access?.contains(.canCreateChat) == false) + Toggle("Can Read Chat", isOn: $pendingAccess.bind(.canReadChat)) + .disabled(model.access?.contains(.canReadChat) == false) + Toggle("Can Send Chat", isOn: $pendingAccess.bind(.canSendChat)) + .disabled(model.access?.contains(.canSendChat) == false) + } + + Section("Miscellaneous") { + Toggle("Can Use Any Name", isOn: $pendingAccess.bind(.canUseAnyName)) + .disabled(model.access?.contains(.canUseAnyName) == false) + Toggle("Don't Show Agreement", isOn: $pendingAccess.bind(.canSkipAgreement)) + .disabled(model.access?.contains(.canSkipAgreement) == false) + } + } + .disabled(model.access?.contains(.canModifyUsers) == false) + .formStyle(.grouped) + .onChange(of: selection) { + if let selection { + pendingName = selection.name + pendingLogin = selection.login + pendingAccess = selection.access + + if selection.persisted { + if selection.password == nil { + pendingPassword = "" + } else { + pendingPassword = placeholderPassword + } + } + } + } + .onAppear() { + if let selection { + pendingName = selection.name + pendingLogin = selection.login + pendingAccess = selection.access + + if selection.persisted { + if selection.password == nil { + pendingPassword = "" + } else { + pendingPassword = placeholderPassword + } + } else { + pendingPassword = HotlineAccount.randomPassword() + } + } + } + } + .frame(maxWidth: .infinity) + + Divider() + + HStack() { + Button("Revert") { + if let selection { + pendingAccess = selection.access + pendingName = selection.name + pendingLogin = selection.login + + if selection.password != nil { + pendingPassword = selection.password! + } + } + } + .controlSize(.large) + .frame(minWidth: 75) + .disabled(!self.isSaveable()) +// .padding() + + Spacer() + + Button("Save"){ + guard let selection else { + return + } + + // Update existing account + if selection.persisted == true { + + if pendingPassword == placeholderPassword { + Task { @MainActor in + model.client.sendSetUser(name: pendingName, login: pendingLogin, newLogin: nil, password: nil, access: pendingAccess.rawValue) + } + } else { + Task { @MainActor in + model.client.sendSetUser(name: pendingName, login: pendingLogin, newLogin: nil, password: pendingPassword, access: pendingAccess.rawValue) + } + } + + } else { + // Create new existing account + Task { @MainActor in + model.client.sendCreateUser(name: pendingName, login: pendingLogin, password: pendingPassword, access: pendingAccess.rawValue) + } + self.selection?.password = pendingPassword + pendingPassword = placeholderPassword + } + + var account = HotlineAccount(pendingName, pendingLogin, pendingAccess) + account.persisted = true + account.password = placeholderPassword + + accounts = accounts.filter { $0.persisted == true && $0.login != selection.login } + + // Add new account to list + accounts.append(account) + + // Re-sort accounts + accounts.sort { $0.login < $1.login } + self.selection = account + } + .controlSize(.large) + .frame(minWidth: 75) + .keyboardShortcut(.defaultAction) + .disabled(!self.isSaveable()) + } + .padding() + } + + } + + var accountList: some View { + List(accounts, id: \.self, selection: $selection) { account in + HStack(spacing: 5) { + if account.access.contains(.canDisconnectUsers) { + Image("User Admin") + .frame(width: 16, height: 16) + .opacity(account.persisted ? 1.0 : 0.25) + // .padding(.leading, 4) + Text(account.login) + .foregroundStyle(Color.hotlineRed) + } + else if account.access.rawValue == 0 { + Image("User") + .frame(width: 16, height: 16) + // .padding(.leading, 4) + Text(account.login) + .foregroundStyle(.secondary) + } + // else if account.persisted == false { + // HStack { + // Image("User") + // .frame(width: 16, height: 16) + //// .padding(.leading, 4) + // Text(account.login) + // .italic() + // } + // } + else { + Image("User") + .frame(width: 16, height: 16) + .opacity(account.persisted ? 1.0 : 0.5) + // .padding(.leading, 4) + Text(account.login) + } + } + } + .frame(width: 250) + .sheet(item: $toDelete) { item in + Form { + HStack{ + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 30)) + Text("Delete account \"\(item.name)\" and all associated files?") + .lineSpacing(4) + } + } + .frame(minWidth: 300, idealWidth: 450, maxWidth: .infinity, minHeight: 100, idealHeight: 100, maxHeight: .infinity) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + toDelete = nil + } + } + + ToolbarItem(placement: .primaryAction) { + Button("Delete") { + guard let userToDelete = toDelete else { + return + } + + self.toDelete = nil + self.selection = nil + + if userToDelete.persisted { + Task { @MainActor in + model.client.sendDeleteUser(login: userToDelete.login) + } + } + + accounts = accounts.filter { $0.login != userToDelete.login } + + } + } + } + } + } + + + private func isSaveable() -> Bool { + guard let selection else { + return false + } + + // Disable save if login field is cleared + if pendingLogin == "" { + return false + } + + // If the account initial has a password and it was updated + if selection.password != nil && pendingPassword != placeholderPassword { + return true + } + + // If the account initial has no password, but was updated to have one + if selection.password == nil && pendingPassword != "" { + return true + } + + // If the access bits or user name have been changed + return pendingAccess.rawValue != selection.access.rawValue || selection.name != pendingName + } +} diff --git a/Hotline/macOS/Board/MessageBoardEditorView.swift b/Hotline/macOS/Board/MessageBoardEditorView.swift new file mode 100644 index 0000000..474384e --- /dev/null +++ b/Hotline/macOS/Board/MessageBoardEditorView.swift @@ -0,0 +1,117 @@ +import SwiftUI + +private enum FocusFields { + case body +} + +struct MessageBoardEditorView: View { + @Environment(\.controlActiveState) private var controlActiveState + @Environment(\.colorScheme) private var colorScheme + @Environment(\.dismiss) private var dismiss + @Environment(Hotline.self) private var model: Hotline + + @State private var text: String = "" + @State private var sending: Bool = false + + @FocusState private var focusedField: FocusFields? + + func sendPost() async { + sending = true + + let cleanedText = text.trimmingCharacters(in: .whitespacesAndNewlines) + + model.postToMessageBoard(text: cleanedText) + let _ = await model.getMessageBoard() + +// let success = await model.postNewsArticle(title: title, body: text, at: path, parentID: parentID) +// if success { +// await model.getNewsList(at: path) +// } + + sending = false + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .center, spacing: 0) { + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .resizable() + .scaledToFit() + .frame(width: 14, height: 14) + .opacity(0.5) + } + .buttonStyle(.plain) + .frame(width: 16, height: 16) + + Spacer() + +// Image("Message Board Post") +// .resizable() +// .scaledToFit() +// .frame(width: 16, height: 16) +// .padding(.trailing, 6) + + Text("New Post") + .fontWeight(.semibold) + .lineLimit(1) + .truncationMode(.middle) + + Spacer() + + if sending { + ProgressView() + .controlSize(.small) + .frame(width: 22, height: 22) + } + else { + Button { + sending = true + model.postToMessageBoard(text: text) + Task { + let _ = await model.getMessageBoard() + Task { @MainActor in + sending = false + dismiss() + } + } + } label: { + Image(systemName: "arrow.up.circle.fill") + .resizable() + .renderingMode(.template) + .scaledToFit() + .foregroundColor(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? .secondary : .accentColor) + } + .buttonStyle(.plain) + .frame(width: 22, height: 22) + .help("Post to Message Board") + .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + .frame(maxWidth: .infinity) + .padding() + + Divider() + + BetterTextEditor(text: $text) + .betterEditorFont(NSFont.systemFont(ofSize: 14.0)) + .betterEditorAutomaticSpellingCorrection(true) + .betterEditorTextInset(.init(width: 16, height: 18)) + .lineSpacing(20) + .background(Color(nsColor: .textBackgroundColor)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .focused($focusedField, equals: .body) + } + .frame(minWidth: 300, idealWidth: 450, maxWidth: .infinity, minHeight: 300, idealHeight: 500, maxHeight: .infinity) + .background(Color(nsColor: .textBackgroundColor)) + .presentationCompactAdaptation(.sheet) + .onAppear { + focusedField = .body + } + .onDisappear { + dismiss() + } + } +} diff --git a/Hotline/macOS/Board/MessageBoardView.swift b/Hotline/macOS/Board/MessageBoardView.swift new file mode 100644 index 0000000..f870b0c --- /dev/null +++ b/Hotline/macOS/Board/MessageBoardView.swift @@ -0,0 +1,102 @@ +import SwiftUI + +struct MessageBoardView: View { + @Environment(Hotline.self) private var model: Hotline + + @State private var composerDisplayed: Bool = false + @State private var composerText: String = "" + + var body: some View { + NavigationStack { + if model.access?.contains(.canReadMessageBoard) != false { + ScrollView { + LazyVStack(alignment: .leading) { + ForEach(model.messageBoard, id: \.self) { msg in + Text(LocalizedStringKey(msg)) + .tint(Color("Link Color")) + .lineLimit(100) + .lineSpacing(4) + .padding() + .textSelection(.enabled) + Divider() + } + } + Spacer() + } + .task { + if !model.messageBoardLoaded { + let _ = await model.getMessageBoard() + } + } + .overlay { + if !model.messageBoardLoaded { + VStack { + ProgressView() + .controlSize(.large) + } + .frame(maxWidth: .infinity) + } + } + .background(Color(nsColor: .textBackgroundColor)) + } + else { + ZStack(alignment: .center) { + Text("No Message Board") + .font(.title) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + .padding() + } + .frame(maxWidth: .infinity) + } + } + .sheet(isPresented: $composerDisplayed) { + MessageBoardEditorView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .frame(idealWidth: 450, idealHeight: 350) +// RichTextEditor(text: $composerText) +// .richEditorFont(NSFont.systemFont(ofSize: 16.0)) +// .richEditorAutomaticDashSubstitution(false) +// .richEditorAutomaticQuoteSubstitution(false) +// .richEditorAutomaticSpellingCorrection(false) +// .background(Color(nsColor: .textBackgroundColor)) +// .frame(maxWidth: .infinity, maxHeight: .infinity) +// .frame(idealWidth: 450, idealHeight: 350) +// .toolbar { +// ToolbarItem(placement: .cancellationAction) { +// Button("Cancel") { +// composerDisplayed.toggle() +// } +// } +// +// ToolbarItem(placement: .primaryAction) { +// Button("Post") { +// composerDisplayed.toggle() +// let text = composerText +// composerText = "" +// model.postToMessageBoard(text: text) +// Task { +// await model.getMessageBoard() +// } +// } +// } +// } + } + .toolbar { + ToolbarItem(placement:.primaryAction) { + Button { + composerDisplayed.toggle() + } label: { + Image(systemName: "square.and.pencil") + } + .disabled(model.access?.contains(.canPostMessageBoard) == false) + .help("Post to Message Board") + } + } + } +} + +#Preview { + MessageBoardView() + .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) +} diff --git a/Hotline/macOS/Chat/ChatView.swift b/Hotline/macOS/Chat/ChatView.swift new file mode 100644 index 0000000..65087c6 --- /dev/null +++ b/Hotline/macOS/Chat/ChatView.swift @@ -0,0 +1,321 @@ +import SwiftUI + +enum FocusedField: Int, Hashable { + case chatInput +} + +struct ChatJoinedMessageView: View { + let message: ChatMessage + + var body: some View { + HStack(alignment: .center, spacing: 4) { + Image(systemName: "arrow.right") + .resizable() + .scaledToFit() + .fontWeight(.semibold) + .foregroundStyle(.primary) + .frame(width: 12, height: 12) + + Text(message.text) + .lineLimit(1) + .truncationMode(.middle) + .fontWeight(.semibold) + .textSelection(.disabled) + + Spacer() + } + .opacity(0.3) + } +} + +struct ChatLeftMessageView: View { + let message: ChatMessage + + var body: some View { + HStack(alignment: .center, spacing: 4) { + Image(systemName: "arrow.left") + .resizable() + .scaledToFit() + .fontWeight(.semibold) + .foregroundStyle(.primary) + .frame(width: 12, height: 12) + + Text(message.text) + .lineLimit(1) + .truncationMode(.middle) + .fontWeight(.semibold) + .textSelection(.disabled) + + Spacer() + } + .opacity(0.3) + } +} + + +struct ChatDisconnectedMessageView: View { + let message: ChatMessage + + var body: some View { + HStack { + Spacer() + } + .frame(height: 5) + .background(Color.primary) + .clipShape(.capsule) + .opacity(0.1) + } +} + +struct ChatMessageView: View { + let message: ChatMessage + + var body: some View { + HStack(alignment: .firstTextBaseline) { + if let username = message.username { + Text("\(username): ").fontWeight(.semibold) + Text(message.text.markdownToAttributedString()) + } + else { + Text(message.text) + } + Spacer() + } + .lineSpacing(4) + .multilineTextAlignment(.leading) + .textSelection(.enabled) + .tint(Color("Link Color")) + } +} + +struct ChatView: View { + @Environment(Hotline.self) private var model: Hotline + @Environment(\.colorScheme) var colorScheme + @Environment(\.dismiss) var dismiss + + @State private var scrollPos: Int? + @State private var contentHeight: CGFloat = 0 + + @State private var searchQuery: String = "" + @State private var searchResults: [ChatMessage] = [] + @State private var isSearching: Bool = false + + @FocusState private var focusedField: FocusedField? + + @Namespace var bottomID + + private var bindableModel: Bindable { + Bindable(model) + } + +// @State private var showingExporter: Bool = false +// +// @State private var chatDocument: TextFile = TextFile() + + var displayedMessages: [ChatMessage] { + searchQuery.isEmpty ? model.chat : searchResults + } + + var body: some View { + @Bindable var bindModel = model + + NavigationStack { + ScrollViewReader { reader in + VStack(alignment: .leading, spacing: 0) { + + // MARK: Scroll View + ScrollView(.vertical) { + LazyVStack(alignment: .leading, spacing: 8) { + + ForEach(displayedMessages) { msg in + if msg.type == .agreement { + VStack(alignment: .center, spacing: 16) { + if let bannerImage = self.model.bannerImage { + HStack(spacing: 0) { + Spacer(minLength: 0) + ZStack { + Image(nsImage: bannerImage) + .resizable() + .scaledToFit() + .frame(maxWidth: 468.0) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .offset(y: 1.5) + .blur(radius: 4) + .opacity(0.2) + + Image(nsImage: bannerImage) + .resizable() + .scaledToFit() + .frame(maxWidth: 468.0) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + + Spacer(minLength: 0) + } + } + + ServerAgreementView(text: msg.text) + } + .padding(.vertical, 24) + } + else if msg.type == .server { + ServerMessageView(message: msg.text) + } + else if msg.type == .joined { + ChatJoinedMessageView(message: msg) + } + else if msg.type == .left { + ChatLeftMessageView(message: msg) + } + else if msg.type == .signOut { + ChatDisconnectedMessageView(message: msg) + .padding(.vertical, 24) + } + else { + ChatMessageView(message: msg) + } + } + } + .padding() + + VStack(spacing: 0) {}.id(bottomID) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) +// .defaultScrollAnchor(.bottom, for: .initialOffset) + .defaultScrollAnchor(.bottom, for: .alignment) + .defaultScrollAnchor(.bottom, for: .sizeChanges) + .onChange(of: model.chat.count) { + // Re-run search when new messages arrive to keep filter active + if !searchQuery.isEmpty { + performSearch() + } + reader.scrollTo(bottomID, anchor: .bottom) + model.markPublicChatAsRead() + } + .onAppear { + reader.scrollTo(bottomID, anchor: .bottom) + self.focusedField = .chatInput + } + .onChange(of: self.model.bannerImage) { + reader.scrollTo(bottomID, anchor: .bottom) + } + .onChange(of: searchQuery) { + reader.scrollTo(bottomID, anchor: .bottom) + } + .onChange(of: isSearching) { + reader.scrollTo(bottomID, anchor: .bottom) + } + + // MARK: Input Divider + Divider() + + // MARK: Input Bar + HStack(alignment: .lastTextBaseline, spacing: 0) { + TextField("", text: $bindModel.chatInput, axis: .vertical) + .focused($focusedField, equals: .chatInput) + .textFieldStyle(.plain) + .lineLimit(1...5) + .multilineTextAlignment(.leading) + .onSubmit { + if !model.chatInput.isEmpty { + model.sendChat(model.chatInput, announce: NSEvent.modifierFlags.contains(.shift)) + } + model.chatInput = "" + } + .frame(maxWidth: .infinity) + .padding() + } + .frame(maxWidth: .infinity, minHeight: 28) + .padding(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) + .overlay(alignment: .leadingLastTextBaseline) { + Image(systemName: "chevron.right").fontWeight(.semibold).opacity(0.4).offset(x: 16) + } + .onContinuousHover { phase in + switch phase { + case .active(_): + NSCursor.iBeam.set() + case .ended: + NSCursor.arrow.set() + break + } + } + .onTapGesture(count: 1) { + focusedField = .chatInput + } + } + } + .searchable(text: $searchQuery, isPresented: $isSearching, placement: .toolbar, prompt: "Search") + .background(Button("", action: { isSearching = true }).keyboardShortcut("f").hidden()) + } + .background(Color(nsColor: .textBackgroundColor)) +// .navigationTitle(model.serverTitle) + .onChange(of: searchQuery) { + performSearch() + } +// .toolbar { +// ToolbarItem(placement: .primaryAction) { +// Button { +// showingExporter = true +// } label: { +// Image(systemName: "square.and.arrow.up") +// }.help("Save Chat...") +// } +// } +// .fileExporter(isPresented: $showingExporter, document: self.chatDocument, contentType: .utf8PlainText, defaultFilename: "\(self.model.serverTitle) Chat.txt") { result in +// switch result { +// case .success(let url): +// print("Saved to \(url)") +// +// case .failure(let error): +// print(error.localizedDescription) +// } +// self.chatDocument.text = "" +// } + } + + private func performSearch() { + guard !searchQuery.isEmpty else { + searchResults = [] + return + } + + searchResults = model.searchChat(query: searchQuery) + } + +// private func prepareChatDocument() -> Bool { +// var text: String = String() +// +// self.chatDocument.text = "" +// for msg in model.chat { +// if msg.type == .agreement { +// text.append(msg.text) +// text.append("\n\n") +// } +// else if msg.type == .message { +// if let username = msg.username { +// text.append("\(username): \(msg.text)") +// } +// else { +// text.append(msg.text) +// } +// text.append("\n") +// } +// else if msg.type == .status { +// text.append(msg.text) +// text.append("\n") +// } +// } +// +// if text.isEmpty { +// return false +// } +// +// self.chatDocument.text = text +// +// return true +// } +} + +#Preview { + ChatView() + .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) +} diff --git a/Hotline/macOS/Chat/ServerAgreementView.swift b/Hotline/macOS/Chat/ServerAgreementView.swift new file mode 100644 index 0000000..e72de7d --- /dev/null +++ b/Hotline/macOS/Chat/ServerAgreementView.swift @@ -0,0 +1,78 @@ +import SwiftUI + +fileprivate let MAX_AGREEMENT_HEIGHT: CGFloat = 340 + +struct ServerAgreementView: View { + let text: String + + @State private var expandable: Bool = false + @State private var expanded: Bool = false + + var body: some View { + ScrollView(.vertical) { + HStack(alignment: .top) { + Spacer() + Text(text.convertToAttributedStringWithLinks()) + .font(.system(size: 12)) + .fontDesign(.monospaced) + .textSelection(.enabled) + .tint(Color("Link Color")) + .frame(maxWidth: 400) + .padding(16) + .background( + GeometryReader { geometry in + Color.clear.onAppear { + if geometry.size.height > MAX_AGREEMENT_HEIGHT { + expandable = true + } + else { + expandable = false + } + } + } + ) + Spacer() + } + } + .scrollIndicators(.never) + .frame(maxWidth: .infinity, maxHeight: (expandable && expanded) ? nil : MAX_AGREEMENT_HEIGHT) + .scrollBounceBehavior(.basedOnSize) +#if os(iOS) + .background(Color("Agreement Background")) +#elseif os(macOS) + .background(VisualEffectView(material: .titlebar, blendingMode: .withinWindow)) +#endif + .overlay(alignment: .bottomTrailing) { + ZStack(alignment: .bottomTrailing) { + Group { + if !expandable || expanded { + EmptyView() + } + else { + Button(action: { + withAnimation(.easeOut(duration: 0.15)) { + expanded = true + } + }, label: { + Image(systemName: "arrow.up.and.down.circle.fill") + .resizable() + .scaledToFit() + .fontWeight(.semibold) + .frame(width: 16, height: 16) + .foregroundColor(.primary.opacity(0.5)) + }) + .buttonStyle(.plain) + .buttonBorderShape(.circle) + .help("Expand Server Agreement") + .padding([.trailing, .bottom], 16) + } + } + } + } + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + } +} + +#Preview { + ServerAgreementView(text: "Hello there and welcome to this server.") +} diff --git a/Hotline/macOS/Chat/ServerMessageView.swift b/Hotline/macOS/Chat/ServerMessageView.swift new file mode 100644 index 0000000..6c3c60e --- /dev/null +++ b/Hotline/macOS/Chat/ServerMessageView.swift @@ -0,0 +1,33 @@ +import SwiftUI + +struct ServerMessageView: View { + let message: String + + var body: some View { + HStack(alignment: .center, spacing: 8) { + Image("Server Message") + .symbolRenderingMode(.multicolor) + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + Text(message) + .fontWeight(.semibold) + .lineSpacing(4) + .multilineTextAlignment(.leading) + .textSelection(.enabled) + Spacer() + } + .padding() + .frame(maxWidth: .infinity) +#if os(iOS) + .background(Color("Agreement Background")) +#elseif os(macOS) + .background(VisualEffectView(material: .titlebar, blendingMode: .withinWindow)) +#endif + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + } +} + +#Preview { + ServerMessageView(message: "This server has something important to say.") +} diff --git a/Hotline/macOS/ChatView.swift b/Hotline/macOS/ChatView.swift deleted file mode 100644 index 65087c6..0000000 --- a/Hotline/macOS/ChatView.swift +++ /dev/null @@ -1,321 +0,0 @@ -import SwiftUI - -enum FocusedField: Int, Hashable { - case chatInput -} - -struct ChatJoinedMessageView: View { - let message: ChatMessage - - var body: some View { - HStack(alignment: .center, spacing: 4) { - Image(systemName: "arrow.right") - .resizable() - .scaledToFit() - .fontWeight(.semibold) - .foregroundStyle(.primary) - .frame(width: 12, height: 12) - - Text(message.text) - .lineLimit(1) - .truncationMode(.middle) - .fontWeight(.semibold) - .textSelection(.disabled) - - Spacer() - } - .opacity(0.3) - } -} - -struct ChatLeftMessageView: View { - let message: ChatMessage - - var body: some View { - HStack(alignment: .center, spacing: 4) { - Image(systemName: "arrow.left") - .resizable() - .scaledToFit() - .fontWeight(.semibold) - .foregroundStyle(.primary) - .frame(width: 12, height: 12) - - Text(message.text) - .lineLimit(1) - .truncationMode(.middle) - .fontWeight(.semibold) - .textSelection(.disabled) - - Spacer() - } - .opacity(0.3) - } -} - - -struct ChatDisconnectedMessageView: View { - let message: ChatMessage - - var body: some View { - HStack { - Spacer() - } - .frame(height: 5) - .background(Color.primary) - .clipShape(.capsule) - .opacity(0.1) - } -} - -struct ChatMessageView: View { - let message: ChatMessage - - var body: some View { - HStack(alignment: .firstTextBaseline) { - if let username = message.username { - Text("\(username): ").fontWeight(.semibold) + Text(message.text.markdownToAttributedString()) - } - else { - Text(message.text) - } - Spacer() - } - .lineSpacing(4) - .multilineTextAlignment(.leading) - .textSelection(.enabled) - .tint(Color("Link Color")) - } -} - -struct ChatView: View { - @Environment(Hotline.self) private var model: Hotline - @Environment(\.colorScheme) var colorScheme - @Environment(\.dismiss) var dismiss - - @State private var scrollPos: Int? - @State private var contentHeight: CGFloat = 0 - - @State private var searchQuery: String = "" - @State private var searchResults: [ChatMessage] = [] - @State private var isSearching: Bool = false - - @FocusState private var focusedField: FocusedField? - - @Namespace var bottomID - - private var bindableModel: Bindable { - Bindable(model) - } - -// @State private var showingExporter: Bool = false -// -// @State private var chatDocument: TextFile = TextFile() - - var displayedMessages: [ChatMessage] { - searchQuery.isEmpty ? model.chat : searchResults - } - - var body: some View { - @Bindable var bindModel = model - - NavigationStack { - ScrollViewReader { reader in - VStack(alignment: .leading, spacing: 0) { - - // MARK: Scroll View - ScrollView(.vertical) { - LazyVStack(alignment: .leading, spacing: 8) { - - ForEach(displayedMessages) { msg in - if msg.type == .agreement { - VStack(alignment: .center, spacing: 16) { - if let bannerImage = self.model.bannerImage { - HStack(spacing: 0) { - Spacer(minLength: 0) - ZStack { - Image(nsImage: bannerImage) - .resizable() - .scaledToFit() - .frame(maxWidth: 468.0) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - .offset(y: 1.5) - .blur(radius: 4) - .opacity(0.2) - - Image(nsImage: bannerImage) - .resizable() - .scaledToFit() - .frame(maxWidth: 468.0) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - } - - Spacer(minLength: 0) - } - } - - ServerAgreementView(text: msg.text) - } - .padding(.vertical, 24) - } - else if msg.type == .server { - ServerMessageView(message: msg.text) - } - else if msg.type == .joined { - ChatJoinedMessageView(message: msg) - } - else if msg.type == .left { - ChatLeftMessageView(message: msg) - } - else if msg.type == .signOut { - ChatDisconnectedMessageView(message: msg) - .padding(.vertical, 24) - } - else { - ChatMessageView(message: msg) - } - } - } - .padding() - - VStack(spacing: 0) {}.id(bottomID) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) -// .defaultScrollAnchor(.bottom, for: .initialOffset) - .defaultScrollAnchor(.bottom, for: .alignment) - .defaultScrollAnchor(.bottom, for: .sizeChanges) - .onChange(of: model.chat.count) { - // Re-run search when new messages arrive to keep filter active - if !searchQuery.isEmpty { - performSearch() - } - reader.scrollTo(bottomID, anchor: .bottom) - model.markPublicChatAsRead() - } - .onAppear { - reader.scrollTo(bottomID, anchor: .bottom) - self.focusedField = .chatInput - } - .onChange(of: self.model.bannerImage) { - reader.scrollTo(bottomID, anchor: .bottom) - } - .onChange(of: searchQuery) { - reader.scrollTo(bottomID, anchor: .bottom) - } - .onChange(of: isSearching) { - reader.scrollTo(bottomID, anchor: .bottom) - } - - // MARK: Input Divider - Divider() - - // MARK: Input Bar - HStack(alignment: .lastTextBaseline, spacing: 0) { - TextField("", text: $bindModel.chatInput, axis: .vertical) - .focused($focusedField, equals: .chatInput) - .textFieldStyle(.plain) - .lineLimit(1...5) - .multilineTextAlignment(.leading) - .onSubmit { - if !model.chatInput.isEmpty { - model.sendChat(model.chatInput, announce: NSEvent.modifierFlags.contains(.shift)) - } - model.chatInput = "" - } - .frame(maxWidth: .infinity) - .padding() - } - .frame(maxWidth: .infinity, minHeight: 28) - .padding(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) - .overlay(alignment: .leadingLastTextBaseline) { - Image(systemName: "chevron.right").fontWeight(.semibold).opacity(0.4).offset(x: 16) - } - .onContinuousHover { phase in - switch phase { - case .active(_): - NSCursor.iBeam.set() - case .ended: - NSCursor.arrow.set() - break - } - } - .onTapGesture(count: 1) { - focusedField = .chatInput - } - } - } - .searchable(text: $searchQuery, isPresented: $isSearching, placement: .toolbar, prompt: "Search") - .background(Button("", action: { isSearching = true }).keyboardShortcut("f").hidden()) - } - .background(Color(nsColor: .textBackgroundColor)) -// .navigationTitle(model.serverTitle) - .onChange(of: searchQuery) { - performSearch() - } -// .toolbar { -// ToolbarItem(placement: .primaryAction) { -// Button { -// showingExporter = true -// } label: { -// Image(systemName: "square.and.arrow.up") -// }.help("Save Chat...") -// } -// } -// .fileExporter(isPresented: $showingExporter, document: self.chatDocument, contentType: .utf8PlainText, defaultFilename: "\(self.model.serverTitle) Chat.txt") { result in -// switch result { -// case .success(let url): -// print("Saved to \(url)") -// -// case .failure(let error): -// print(error.localizedDescription) -// } -// self.chatDocument.text = "" -// } - } - - private func performSearch() { - guard !searchQuery.isEmpty else { - searchResults = [] - return - } - - searchResults = model.searchChat(query: searchQuery) - } - -// private func prepareChatDocument() -> Bool { -// var text: String = String() -// -// self.chatDocument.text = "" -// for msg in model.chat { -// if msg.type == .agreement { -// text.append(msg.text) -// text.append("\n\n") -// } -// else if msg.type == .message { -// if let username = msg.username { -// text.append("\(username): \(msg.text)") -// } -// else { -// text.append(msg.text) -// } -// text.append("\n") -// } -// else if msg.type == .status { -// text.append(msg.text) -// text.append("\n") -// } -// } -// -// if text.isEmpty { -// return false -// } -// -// self.chatDocument.text = text -// -// return true -// } -} - -#Preview { - ChatView() - .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) -} diff --git a/Hotline/macOS/FileDetailsView.swift b/Hotline/macOS/FileDetailsView.swift deleted file mode 100644 index 34e083b..0000000 --- a/Hotline/macOS/FileDetailsView.swift +++ /dev/null @@ -1,147 +0,0 @@ -import Foundation -import SwiftUI - -struct FileDetailsView: View { - @Environment(Hotline.self) private var model: Hotline - @Environment(\.presentationMode) var presentationMode - - var fd: FileDetails - - @State private var comment: String = "" - @State private var filename: String = "" - - var body: some View { - VStack (alignment: .leading){ - Form { - HStack(alignment: .center){ - FileIconView(filename: fd.name, fileType: nil) - .frame(width: 32, height: 32) - TextField("", text: $filename) - .disabled(!self.canRename()) - } - HStack(alignment: .center){ - Text("Type:").bold().padding(.leading, 43) - Text(fd.type) - } - HStack(alignment: .center){ - Text("Creator:").bold().padding(.leading, 26) - Text(fd.creator) - } - HStack(alignment: .center){ - Text("Size:").bold().bold().padding(.leading, 48) - Text(self.formattedSize(byteCount: fd.size)) - } - HStack(alignment: .center){ - Text("Created:").bold().padding(.leading, 24) - Text("\(FileDetailsView.dateFormatter.string(from: fd.created))") - } - HStack(alignment: .center){ - Text("Modified:").bold().padding(.leading, 19) - Text("\(FileDetailsView.dateFormatter.string(from: fd.modified))") - } - HStack(alignment: .center){ - Text("Comments:").bold().padding(.top, 8) - .padding(.leading, 5) - } - - VStack(alignment: .trailing){ - TextEditor(text: $comment) - .padding(.leading, 2) - .padding(.top, 1) - .font(.system(size: 13)) - .background(Color(nsColor: .textBackgroundColor)) - .border(Color.secondary, width: 1) - .clipShape(RoundedRectangle(cornerRadius: 2, style: .continuous)) - .disabled(!self.canSetComment()) - } - } - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - presentationMode.wrappedValue.dismiss() - } - } - - ToolbarItem(placement: .primaryAction) { - Button{ - var editedFilename: String? - if filename != fd.name { - editedFilename = filename - } - - var editedComment: String? - if comment != fd.comment { - editedComment = comment - } - - model.client.sendSetFileInfo(fileName: fd.name, path: fd.path, fileNewName: editedFilename, comment: editedComment) - presentationMode.wrappedValue.dismiss() - - // TODO: Update the file list if the filename was changed - } label: { - Text("Save") - }.disabled(!isEdited()) - } - } - - .onAppear { - self.filename = fd.name - self.comment = fd.comment - } - .padding(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24)) - } - .frame(minWidth: 400, minHeight: 400) - } - - - static var dateFormatter: DateFormatter = { - var dateFormatter = DateFormatter() - dateFormatter.dateStyle = .long - dateFormatter.timeStyle = .short - - // Original format: Fri, Aug 20, 2021, 5:14:07 PM - return dateFormatter - }() - - static var byteCountSizeFormatter: NumberFormatter = { - let numberFormatter = NumberFormatter() - numberFormatter.numberStyle = .decimal - return numberFormatter - }() - - static let byteFormatter = ByteCountFormatter() - - private func formattedFileSize(_ fileSize: UInt) -> String { - FileView.byteFormatter.allowedUnits = [.useAll] - FileView.byteFormatter.countStyle = .file - return FileView.byteFormatter.string(fromByteCount: Int64(fileSize)) - } - - // Format byte count Int into string like: 23.4M (24,601,664 bytes) - private func formattedSize(byteCount: Int) -> String { - let formattedByteCount = FileDetailsView.byteCountSizeFormatter.string(from: NSNumber(value:byteCount)) ?? "0" - return "\(FileView.byteFormatter.string(fromByteCount: Int64(byteCount))) (\(formattedByteCount) bytes)" - } - - private func isEdited() -> Bool { - return self.filename != fd.name || self.comment != fd.comment - } - - private func canRename() -> Bool { - if self.fd.type == "fldr" { - return model.access?.contains(.canRenameFolders) == true - } - return model.access?.contains(.canRenameFiles) == true - } - - private func canSetComment() -> Bool { - if self.fd.type == "fldr" { - return model.access?.contains(.canSetFolderComment) == true - } - return 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 )) -//} diff --git a/Hotline/macOS/FilePreviewImageView.swift b/Hotline/macOS/FilePreviewImageView.swift deleted file mode 100644 index 9beeb80..0000000 --- a/Hotline/macOS/FilePreviewImageView.swift +++ /dev/null @@ -1,123 +0,0 @@ -import SwiftUI -import UniformTypeIdentifiers - -struct FilePreviewImageView: View { - enum FilePreviewFocus: Hashable { - case window - } - - @Environment(\.controlActiveState) private var controlActiveState - @Environment(\.colorScheme) private var colorScheme - @Environment(\.dismiss) var dismiss - - @Binding var info: PreviewFileInfo? - - @State var preview: FilePreview? = nil - @FocusState private var focusField: FilePreviewFocus? - - var body: some View { - Group { - if preview?.state != .loaded { - HStack(alignment: .center, spacing: 0) { - ProgressView(value: max(0.0, min(1.0, preview?.progress ?? 0.0))) - .focusable(false) - .progressViewStyle(.circular) - .controlSize(.extraLarge) - .tint(.white) - .frame(maxWidth: 300, alignment: .center) - } - .frame(minWidth: 350, maxWidth: 350, minHeight: 150, maxHeight: 150) - .padding() - } - else { - if let image = preview?.image { - FileImageView(image: image) - .frame(minWidth: 200, maxWidth: .infinity, minHeight: 200, maxHeight: .infinity) - } - else { - VStack(alignment: .center, spacing: 0) { - Spacer() - - Image(systemName: "eye.trianglebadge.exclamationmark") - .resizable() - .scaledToFit() - .frame(maxWidth: .infinity) - .frame(height: 48) - .padding(.bottom) - Group { - Text("This file type is not previewable") - .bold() - Text("Try downloading and opening this file in another application.") - .foregroundStyle(Color.secondary) - } - .font(.system(size: 14.0)) - .frame(maxWidth: 300) - .multilineTextAlignment(.center) - - Spacer() - } - .frame(minWidth: 350, maxWidth: 350, minHeight: 150, maxHeight: 150) - .padding() - } - } - } - .focusable() - .focusEffectDisabled() - .focused($focusField, equals: .window) - .preferredColorScheme(.dark) - .navigationTitle(info?.name ?? "Preview") - .background(.black) - .toolbar { - ToolbarItem(placement: .navigation) { - FileIconView(filename: info?.name ?? "", fileType: nil) - .frame(width: 16, height: 16) - .opacity(controlActiveState == .inactive ? 0.5 : 1.0) - } - - if let img = preview?.image { - if let info = info { - ToolbarItem(placement: .primaryAction) { - Button { - let _ = preview?.data?.saveAsFileToDownloads(filename: info.name) - } label: { - Label("Download Image...", systemImage: "arrow.down") - } - .help("Download Image") - } - - ToolbarItem(placement: .primaryAction) { - ShareLink(item: img, preview: SharePreview(info.name, image: img)) { - Label("Share Image...", systemImage: "square.and.arrow.up") - } - .help("Share Image") - } - } - } - } - .task { - if let info = info { - preview = FilePreview(info: info) - preview?.download() - } - } - .onAppear { - if info == nil { - Task { - dismiss() - } - return - } - - focusField = .window - } - .onDisappear { - preview?.cancel() - dismiss() - } - .onChange(of: preview?.state) { - if preview?.state == .failed { - dismiss() - } - } - } -} diff --git a/Hotline/macOS/FilePreviewTextView.swift b/Hotline/macOS/FilePreviewTextView.swift deleted file mode 100644 index c286381..0000000 --- a/Hotline/macOS/FilePreviewTextView.swift +++ /dev/null @@ -1,127 +0,0 @@ -import SwiftUI -import UniformTypeIdentifiers - -struct FilePreviewTextView: View { - enum FilePreviewFocus: Hashable { - case window - } - - @Environment(\.controlActiveState) private var controlActiveState - @Environment(\.colorScheme) private var colorScheme - @Environment(\.dismiss) var dismiss - - @Binding var info: PreviewFileInfo? - @State var preview: FilePreview? = nil - @FocusState private var focusField: FilePreviewFocus? - - var body: some View { - Group { - if preview?.state != .loaded { - VStack(alignment: .center, spacing: 0) { - Spacer() - ProgressView(value: max(0.0, min(1.0, preview?.progress ?? 0.0))) - .focusable(false) - .progressViewStyle(.circular) - .controlSize(.extraLarge) - .tint(.white) - .frame(maxWidth: 300, alignment: .center) - Spacer() - Spacer() - } - .background(Color(nsColor: .textBackgroundColor)) - .frame(minWidth: 350, maxWidth: .infinity, minHeight: 150, maxHeight: .infinity) - .padding() - } - else { - if let text = preview?.text { - TextEditor(text: .constant(text)) - .textEditorStyle(.plain) - .font(.system(size: 14)) - .lineSpacing(3) - .padding(16) - .contentMargins(.top, -16.0, for: .scrollIndicators) - .contentMargins(.bottom, -16.0, for: .scrollIndicators) - .contentMargins(.trailing, -16.0, for: .scrollIndicators) - .scrollClipDisabled() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - else { - VStack(alignment: .center, spacing: 0) { - Spacer() - - Image(systemName: "eye.trianglebadge.exclamationmark") - .resizable() - .scaledToFit() - .frame(maxWidth: .infinity) - .frame(height: 48) - .padding(.bottom) - Group { - Text("This file type is not previewable") - .bold() - Text("Try downloading and opening this file in another application.") - .foregroundStyle(Color.secondary) - } - .font(.system(size: 14.0)) - .frame(maxWidth: 300) - .multilineTextAlignment(.center) - - Spacer() - Spacer() - } - .frame(minWidth: 350, maxWidth: .infinity, minHeight: 150, maxHeight: .infinity) - .padding() - } - } - } - .focusable() - .focusEffectDisabled() - .background(Color(nsColor: .textBackgroundColor)) - .focused($focusField, equals: .window) - .navigationTitle(info?.name ?? "File Preview") - .toolbar { - ToolbarItem(placement: .navigation) { - FileIconView(filename: info?.name ?? "", fileType: nil) - .frame(width: 16, height: 16) - .opacity(controlActiveState == .inactive ? 0.5 : 1.0) - } - - if let _ = preview?.text { - if let info = info { - ToolbarItem(placement: .primaryAction) { - Button { - let _ = preview?.data?.saveAsFileToDownloads(filename: info.name) - } label: { - Label("Save Text File...", systemImage: "square.and.arrow.down") - } - .help("Save Text File") - } - } - } - } - .task { - if let info = info { - preview = FilePreview(info: info) - preview?.download() - } - } - .onAppear { - if info == nil { - Task { - dismiss() - } - return - } - - focusField = .window - } - .onDisappear { - preview?.cancel() - dismiss() - } - .onChange(of: preview?.state) { - if preview?.state == .failed { - dismiss() - } - } - } -} diff --git a/Hotline/macOS/Files/FileDetailsView.swift b/Hotline/macOS/Files/FileDetailsView.swift new file mode 100644 index 0000000..812f4e5 --- /dev/null +++ b/Hotline/macOS/Files/FileDetailsView.swift @@ -0,0 +1,147 @@ +import Foundation +import SwiftUI + +struct FileDetailsView: View { + @Environment(Hotline.self) private var model: Hotline + @Environment(\.presentationMode) var presentationMode + + var fd: FileDetails + + @State private var comment: String = "" + @State private var filename: String = "" + + var body: some View { + VStack (alignment: .leading){ + Form { + HStack(alignment: .center){ + FileIconView(filename: fd.name, fileType: nil) + .frame(width: 32, height: 32) + TextField("", text: $filename) + .disabled(!self.canRename()) + } + HStack(alignment: .center){ + Text("Type:").bold().padding(.leading, 43) + Text(fd.type) + } + HStack(alignment: .center){ + Text("Creator:").bold().padding(.leading, 26) + Text(fd.creator) + } + HStack(alignment: .center){ + Text("Size:").bold().bold().padding(.leading, 48) + Text(self.formattedSize(byteCount: fd.size)) + } + HStack(alignment: .center){ + Text("Created:").bold().padding(.leading, 24) + Text("\(FileDetailsView.dateFormatter.string(from: fd.created))") + } + HStack(alignment: .center){ + Text("Modified:").bold().padding(.leading, 19) + Text("\(FileDetailsView.dateFormatter.string(from: fd.modified))") + } + HStack(alignment: .center){ + Text("Comments:").bold().padding(.top, 8) + .padding(.leading, 5) + } + + VStack(alignment: .trailing){ + TextEditor(text: $comment) + .padding(.leading, 2) + .padding(.top, 1) + .font(.system(size: 13)) + .background(Color(nsColor: .textBackgroundColor)) + .border(Color.secondary, width: 1) + .clipShape(RoundedRectangle(cornerRadius: 2, style: .continuous)) + .disabled(!self.canSetComment()) + } + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + presentationMode.wrappedValue.dismiss() + } + } + + ToolbarItem(placement: .primaryAction) { + Button{ + var editedFilename: String? + if filename != fd.name { + editedFilename = filename + } + + var editedComment: String? + if comment != fd.comment { + editedComment = comment + } + + model.client.sendSetFileInfo(fileName: fd.name, path: fd.path, fileNewName: editedFilename, comment: editedComment) + presentationMode.wrappedValue.dismiss() + + // TODO: Update the file list if the filename was changed + } label: { + Text("Save") + }.disabled(!isEdited()) + } + } + + .onAppear { + self.filename = fd.name + self.comment = fd.comment + } + .padding(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24)) + } + .frame(minWidth: 400, minHeight: 400) + } + + + static var dateFormatter: DateFormatter = { + var dateFormatter = DateFormatter() + dateFormatter.dateStyle = .long + dateFormatter.timeStyle = .short + + // Original format: Fri, Aug 20, 2021, 5:14:07 PM + return dateFormatter + }() + + static var byteCountSizeFormatter: NumberFormatter = { + let numberFormatter = NumberFormatter() + numberFormatter.numberStyle = .decimal + return numberFormatter + }() + + static let byteFormatter = ByteCountFormatter() + + private func formattedFileSize(_ fileSize: UInt) -> String { + FileItemView.byteFormatter.allowedUnits = [.useAll] + FileItemView.byteFormatter.countStyle = .file + return FileItemView.byteFormatter.string(fromByteCount: Int64(fileSize)) + } + + // Format byte count Int into string like: 23.4M (24,601,664 bytes) + private func formattedSize(byteCount: Int) -> String { + let formattedByteCount = FileDetailsView.byteCountSizeFormatter.string(from: NSNumber(value:byteCount)) ?? "0" + return "\(FileItemView.byteFormatter.string(fromByteCount: Int64(byteCount))) (\(formattedByteCount) bytes)" + } + + private func isEdited() -> Bool { + return self.filename != fd.name || self.comment != fd.comment + } + + private func canRename() -> Bool { + if self.fd.type == "fldr" { + return model.access?.contains(.canRenameFolders) == true + } + return model.access?.contains(.canRenameFiles) == true + } + + private func canSetComment() -> Bool { + if self.fd.type == "fldr" { + return model.access?.contains(.canSetFolderComment) == true + } + return 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 )) +//} diff --git a/Hotline/macOS/Files/FileItemView.swift b/Hotline/macOS/Files/FileItemView.swift new file mode 100644 index 0000000..5da744f --- /dev/null +++ b/Hotline/macOS/Files/FileItemView.swift @@ -0,0 +1,67 @@ +import SwiftUI + +struct FileItemView: View { + @Environment(Hotline.self) private var model: Hotline + + var file: FileInfo + let depth: Int + + var body: some View { + HStack(alignment: .center, spacing: 0) { + Spacer() + .frame(width: CGFloat(depth * (12 + 2))) + + Spacer() + .frame(width: 10) + .padding(.leading, 4) + .padding(.trailing, 8) + + HStack(alignment: .center) { + if file.isUnavailable { + Image(systemName: "questionmark.app.fill") + .frame(width: 16, height: 16) + .opacity(0.5) + } + else { + FileIconView(filename: file.name, fileType: file.type) + .frame(width: 16, height: 16) + } + } + .frame(width: 16) + .padding(.trailing, 6) + + Text(file.name) + .lineLimit(1) + .truncationMode(.tail) + .opacity(file.isUnavailable ? 0.5 : 1.0) + + Spacer() + if !file.isUnavailable { + Text(formattedFileSize(file.fileSize)) + .foregroundStyle(.secondary) + .lineLimit(1) + .padding(.trailing, 6) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + if file.expanded { + ForEach(file.children!, id: \.self) { childFile in + if childFile.isFolder { + FolderItemView(file: childFile, depth: self.depth + 1).tag(file.id) + } + else { + FileItemView(file: childFile, depth: self.depth + 1).tag(file.id) + } + } + } + } + + static let byteFormatter = ByteCountFormatter() + + private func formattedFileSize(_ fileSize: UInt) -> String { + FileItemView.byteFormatter.allowedUnits = [.useAll] + FileItemView.byteFormatter.countStyle = .file + return FileItemView.byteFormatter.string(fromByteCount: Int64(fileSize)) + } +} diff --git a/Hotline/macOS/Files/FilePreviewImageView.swift b/Hotline/macOS/Files/FilePreviewImageView.swift new file mode 100644 index 0000000..9beeb80 --- /dev/null +++ b/Hotline/macOS/Files/FilePreviewImageView.swift @@ -0,0 +1,123 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct FilePreviewImageView: View { + enum FilePreviewFocus: Hashable { + case window + } + + @Environment(\.controlActiveState) private var controlActiveState + @Environment(\.colorScheme) private var colorScheme + @Environment(\.dismiss) var dismiss + + @Binding var info: PreviewFileInfo? + + @State var preview: FilePreview? = nil + @FocusState private var focusField: FilePreviewFocus? + + var body: some View { + Group { + if preview?.state != .loaded { + HStack(alignment: .center, spacing: 0) { + ProgressView(value: max(0.0, min(1.0, preview?.progress ?? 0.0))) + .focusable(false) + .progressViewStyle(.circular) + .controlSize(.extraLarge) + .tint(.white) + .frame(maxWidth: 300, alignment: .center) + } + .frame(minWidth: 350, maxWidth: 350, minHeight: 150, maxHeight: 150) + .padding() + } + else { + if let image = preview?.image { + FileImageView(image: image) + .frame(minWidth: 200, maxWidth: .infinity, minHeight: 200, maxHeight: .infinity) + } + else { + VStack(alignment: .center, spacing: 0) { + Spacer() + + Image(systemName: "eye.trianglebadge.exclamationmark") + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + .frame(height: 48) + .padding(.bottom) + Group { + Text("This file type is not previewable") + .bold() + Text("Try downloading and opening this file in another application.") + .foregroundStyle(Color.secondary) + } + .font(.system(size: 14.0)) + .frame(maxWidth: 300) + .multilineTextAlignment(.center) + + Spacer() + } + .frame(minWidth: 350, maxWidth: 350, minHeight: 150, maxHeight: 150) + .padding() + } + } + } + .focusable() + .focusEffectDisabled() + .focused($focusField, equals: .window) + .preferredColorScheme(.dark) + .navigationTitle(info?.name ?? "Preview") + .background(.black) + .toolbar { + ToolbarItem(placement: .navigation) { + FileIconView(filename: info?.name ?? "", fileType: nil) + .frame(width: 16, height: 16) + .opacity(controlActiveState == .inactive ? 0.5 : 1.0) + } + + if let img = preview?.image { + if let info = info { + ToolbarItem(placement: .primaryAction) { + Button { + let _ = preview?.data?.saveAsFileToDownloads(filename: info.name) + } label: { + Label("Download Image...", systemImage: "arrow.down") + } + .help("Download Image") + } + + ToolbarItem(placement: .primaryAction) { + ShareLink(item: img, preview: SharePreview(info.name, image: img)) { + Label("Share Image...", systemImage: "square.and.arrow.up") + } + .help("Share Image") + } + } + } + } + .task { + if let info = info { + preview = FilePreview(info: info) + preview?.download() + } + } + .onAppear { + if info == nil { + Task { + dismiss() + } + return + } + + focusField = .window + } + .onDisappear { + preview?.cancel() + dismiss() + } + .onChange(of: preview?.state) { + if preview?.state == .failed { + dismiss() + } + } + } +} diff --git a/Hotline/macOS/Files/FilePreviewTextView.swift b/Hotline/macOS/Files/FilePreviewTextView.swift new file mode 100644 index 0000000..c286381 --- /dev/null +++ b/Hotline/macOS/Files/FilePreviewTextView.swift @@ -0,0 +1,127 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct FilePreviewTextView: View { + enum FilePreviewFocus: Hashable { + case window + } + + @Environment(\.controlActiveState) private var controlActiveState + @Environment(\.colorScheme) private var colorScheme + @Environment(\.dismiss) var dismiss + + @Binding var info: PreviewFileInfo? + @State var preview: FilePreview? = nil + @FocusState private var focusField: FilePreviewFocus? + + var body: some View { + Group { + if preview?.state != .loaded { + VStack(alignment: .center, spacing: 0) { + Spacer() + ProgressView(value: max(0.0, min(1.0, preview?.progress ?? 0.0))) + .focusable(false) + .progressViewStyle(.circular) + .controlSize(.extraLarge) + .tint(.white) + .frame(maxWidth: 300, alignment: .center) + Spacer() + Spacer() + } + .background(Color(nsColor: .textBackgroundColor)) + .frame(minWidth: 350, maxWidth: .infinity, minHeight: 150, maxHeight: .infinity) + .padding() + } + else { + if let text = preview?.text { + TextEditor(text: .constant(text)) + .textEditorStyle(.plain) + .font(.system(size: 14)) + .lineSpacing(3) + .padding(16) + .contentMargins(.top, -16.0, for: .scrollIndicators) + .contentMargins(.bottom, -16.0, for: .scrollIndicators) + .contentMargins(.trailing, -16.0, for: .scrollIndicators) + .scrollClipDisabled() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + else { + VStack(alignment: .center, spacing: 0) { + Spacer() + + Image(systemName: "eye.trianglebadge.exclamationmark") + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + .frame(height: 48) + .padding(.bottom) + Group { + Text("This file type is not previewable") + .bold() + Text("Try downloading and opening this file in another application.") + .foregroundStyle(Color.secondary) + } + .font(.system(size: 14.0)) + .frame(maxWidth: 300) + .multilineTextAlignment(.center) + + Spacer() + Spacer() + } + .frame(minWidth: 350, maxWidth: .infinity, minHeight: 150, maxHeight: .infinity) + .padding() + } + } + } + .focusable() + .focusEffectDisabled() + .background(Color(nsColor: .textBackgroundColor)) + .focused($focusField, equals: .window) + .navigationTitle(info?.name ?? "File Preview") + .toolbar { + ToolbarItem(placement: .navigation) { + FileIconView(filename: info?.name ?? "", fileType: nil) + .frame(width: 16, height: 16) + .opacity(controlActiveState == .inactive ? 0.5 : 1.0) + } + + if let _ = preview?.text { + if let info = info { + ToolbarItem(placement: .primaryAction) { + Button { + let _ = preview?.data?.saveAsFileToDownloads(filename: info.name) + } label: { + Label("Save Text File...", systemImage: "square.and.arrow.down") + } + .help("Save Text File") + } + } + } + } + .task { + if let info = info { + preview = FilePreview(info: info) + preview?.download() + } + } + .onAppear { + if info == nil { + Task { + dismiss() + } + return + } + + focusField = .window + } + .onDisappear { + preview?.cancel() + dismiss() + } + .onChange(of: preview?.state) { + if preview?.state == .failed { + dismiss() + } + } + } +} diff --git a/Hotline/macOS/Files/FilesView.swift b/Hotline/macOS/Files/FilesView.swift new file mode 100644 index 0000000..de699ff --- /dev/null +++ b/Hotline/macOS/Files/FilesView.swift @@ -0,0 +1,418 @@ +import SwiftUI +import UniformTypeIdentifiers +import AppKit + + + + + +struct FilesView: View { + @Environment(Hotline.self) private var model: Hotline + @Environment(\.openWindow) private var openWindow + + @State private var selection: FileInfo? + @State private var fileDetails: FileDetails? + @State private var uploadFileSelectorDisplayed: Bool = false + @State private var searchText: String = "" + @State private var isSearching: Bool = false + + private var isShowingSearchResults: Bool { + switch model.fileSearchStatus { + case .idle: + return !model.fileSearchResults.isEmpty + case .cancelled(_): + return !model.fileSearchResults.isEmpty + default: + return true + } + } + + private var displayedFiles: [FileInfo] { + isShowingSearchResults ? model.fileSearchResults : model.files + } + + private var searchStatusMessage: String? { + switch model.fileSearchStatus { + case .searching(let processed, _): + let scanned = processed == 1 ? "folder" : "folders" + return "Searched \(processed) \(scanned)..." + case .completed(let processed): + let count = model.fileSearchResults.count + let folderWord = processed == 1 ? "folder" : "folders" + if count == 0 { + return "No files found in \(processed) \(folderWord)" + } + return "\(count) file\(count == 1 ? "" : "s") found in \(processed) \(folderWord)" + case .cancelled(_): + if model.fileSearchResults.isEmpty { + return nil + } + return "Search cancelled" + case .failed(let message): + return "Search failed: \(message)" + case .idle: + return nil + } + } + + private var searchStatusPath: String? { + guard let path = model.fileSearchCurrentPath else { + return nil + } + if path.isEmpty { + return "/" + } + return path.joined(separator: "/") + } + + private func openPreviewWindow(_ previewInfo: PreviewFileInfo) { + switch previewInfo.previewType { + case .image: + openWindow(id: "preview-image", value: previewInfo) + case .text: + openWindow(id: "preview-text", value: previewInfo) + default: + return + } + } + + @MainActor private func getFileInfo(_ file: FileInfo) { + Task { + if let fileInfo = await model.getFileDetails(file.name, path: file.path) { + Task { @MainActor in + self.fileDetails = fileInfo + } + } + } + } + + @MainActor private func downloadFile(_ file: FileInfo) { + if file.isFolder { + model.downloadFolder(file.name, path: file.path) + } + else { + model.downloadFile(file.name, path: file.path) + } + } + + @MainActor private func uploadFile(file fileURL: URL, to path: [String]) { + model.uploadFile(url: fileURL, path: path) { info in + Task { + // Refresh file listing to display newly uploaded file. + let _ = await model.getFileList(path: path) + } + } + } + + @MainActor private func previewFile(_ file: FileInfo) { + guard file.isPreviewable else { + return + } + + model.previewFile(file.name, path: file.path) { info in + if let info = info { + openPreviewWindow(info) + } + } + } + + private func deleteFile(_ file: FileInfo) async { + var parentPath: [String] = [] + if file.path.count > 1 { + parentPath = Array(file.path[0.. 0 else { + return + } + + let fileURL = fileURLS.first! + + print(fileURL) + + var uploadPath: [String] = [] + + if let selection = selection { + if selection.isFolder { + uploadPath = selection.path + } + else { + uploadPath = Array(selection.path) + uploadPath.removeLast() + } + } + + print("UPLOAD PATH: \(uploadPath)") + uploadFile(file: fileURL, to: uploadPath) + + case .failure(let error): + print(error) + } + }) + .onSubmit(of: .search) { + #if os(macOS) + let shiftPressed = NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false + if shiftPressed { + model.clearFileListCache() + } + #endif + + let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + model.cancelFileSearch() + return + } + searchText = trimmed + model.startFileSearch(query: trimmed) + } + .onChange(of: searchText) { _, newValue in + if newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + if isShowingSearchResults { + model.cancelFileSearch() + } + } + } + .onChange(of: model.fileSearchQuery) { _, newValue in + if newValue != searchText { + searchText = newValue + } + } + .onAppear { + if searchText != model.fileSearchQuery { + searchText = model.fileSearchQuery + } + } + .safeAreaInset(edge: .top) { + if isShowingSearchResults, let message = searchStatusMessage { + HStack(alignment: .center, spacing: 6) { + if case .searching(_, _) = model.fileSearchStatus { + ProgressView() + .controlSize(.small) + .accentColor(.white) + .tint(.white) + } + else if case .completed = model.fileSearchStatus { + Image(systemName: "checkmark.circle.fill") + .resizable() + .symbolRenderingMode(.monochrome) + .foregroundStyle(.white) + .aspectRatio(contentMode: .fit) + .frame(width: 16, height: 16) + } + else if case .failed = model.fileSearchStatus { + Image(systemName: "exclamationmark.triangle.fill") + .resizable() + .symbolRenderingMode(.monochrome) + .foregroundStyle(.white) + .aspectRatio(contentMode: .fit) + .frame(width: 16, height: 16) + } + + Text(message) + .lineLimit(1) + .font(.body) + .foregroundStyle(.white) + + Spacer() + + if let pathMessage = searchStatusPath { + Text(pathMessage) + .lineLimit(1) + .truncationMode(.tail) + .font(.footnote) +// .fontWeight(.semibold) + .foregroundStyle(.white) + .opacity(0.5) + .padding(.top, 2) + } + } + .padding(.trailing, 14) + .padding(.leading, 8) + .padding(.vertical, 8) + .background { + Group { + if case .completed = model.fileSearchStatus { + Color.fileComplete + } + else { + Color(nsColor: .controlAccentColor) + } + } + .clipShape(.capsule(style: .continuous)) + } + .padding(.horizontal, 8) + .padding(.top, 8) + } + } + } +} + +#Preview { + FilesView() + .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) +} diff --git a/Hotline/macOS/Files/FolderItemView.swift b/Hotline/macOS/Files/FolderItemView.swift new file mode 100644 index 0000000..4a08974 --- /dev/null +++ b/Hotline/macOS/Files/FolderItemView.swift @@ -0,0 +1,140 @@ +import SwiftUI + +struct FolderItemView: View { + @Environment(Hotline.self) private var model: Hotline + + @State var loading = false + @State var dragOver = false + + var file: FileInfo + let depth: Int + + @MainActor private func uploadFile(file fileURL: URL) { + var filePath: [String] = [String](self.file.path) + if !self.file.isFolder { + filePath.removeLast() + } + + print("UPLOADING TO PATH: ", filePath) + + model.uploadFile(url: fileURL, path: filePath) { info in + Task { + // Refresh file listing to display newly uploaded file. + let _ = await model.getFileList(path: filePath) + } + } + } + + var body: some View { + HStack(alignment: .center, spacing: 0) { + Spacer() + .frame(width: CGFloat(depth * (12 + 2))) + + Button { + if file.isFolder { + file.expanded.toggle() + } + } label: { + Text(Image(systemName: file.expanded ? "chevron.down" : "chevron.right")) + .bold() + .font(.system(size: 10)) + .foregroundStyle(dragOver ? Color.white : Color.primary) + .opacity(0.5) + } + .buttonStyle(.plain) + .frame(width: 10) + .padding(.leading, 4) + .padding(.trailing, 8) + + HStack(alignment: .center) { + if file.isUnavailable { + Image(systemName: "questionmark.app.fill") + .frame(width: 16, height: 16) + .opacity(0.5) + } + else if file.isAdminDropboxFolder { + Image("Admin Drop Box") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + } + else if file.isDropboxFolder { + Image("Drop Box") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + } + else { + Image("Folder") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + } + } + .frame(width: 16) + .padding(.trailing, 6) + + Text(file.name) + .lineLimit(1) + .truncationMode(.tail) + .foregroundStyle(dragOver ? Color.white : Color.primary) + .opacity(file.isUnavailable ? 0.5 : 1.0) + + if loading { + ProgressView().controlSize(.small).padding([.leading, .trailing], 5) + } + Spacer() + if !file.isUnavailable { + Text(file.fileSize == 0 ? "Empty" : "^[\(file.fileSize) \("file")](inflect: true)") + .foregroundStyle(dragOver ? Color.white.opacity(0.75) : Color.secondary) + .lineLimit(1) + .padding(.trailing, 6) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background( + RoundedRectangle(cornerRadius: 4.0) + .fill(dragOver ? Color(nsColor: NSColor.selectedContentBackgroundColor) : Color.clear) + .padding(.horizontal, -6) + .padding(.vertical, -4) + ) + .onChange(of: file.expanded) { + loading = false + if file.expanded && file.fileSize > 0 { + Task { + loading = true + let _ = await model.getFileList(path: file.path) + loading = false + } + } + } + .onDrop(of: [.fileURL], isTargeted: $dragOver) { items in + guard let item = items.first, + let identifier = item.registeredTypeIdentifiers.first else { + return false + } + + item.loadItem(forTypeIdentifier: identifier, options: nil) { (urlData, error) in + DispatchQueue.main.async { + if let urlData = urlData as? Data, + let fileURL = URL(dataRepresentation: urlData, relativeTo: nil, isAbsolute: true) { + uploadFile(file: fileURL) + } + } + } + + return true + } + + if file.expanded { + ForEach(file.children!, id: \.self) { childFile in + if childFile.isFolder { + FolderItemView(file: childFile, depth: self.depth + 1).tag(file.id) + } + else { + FileItemView(file: childFile, depth: self.depth + 1).tag(file.id) + } + } + } + } +} diff --git a/Hotline/macOS/FilesView.swift b/Hotline/macOS/FilesView.swift deleted file mode 100644 index 42232af..0000000 --- a/Hotline/macOS/FilesView.swift +++ /dev/null @@ -1,619 +0,0 @@ -import SwiftUI -import UniformTypeIdentifiers -import AppKit - -struct FolderView: View { - @Environment(Hotline.self) private var model: Hotline - - @State var loading = false - @State var dragOver = false - - var file: FileInfo - let depth: Int - - @MainActor private func uploadFile(file fileURL: URL) { - var filePath: [String] = [String](self.file.path) - if !self.file.isFolder { - filePath.removeLast() - } - - print("UPLOADING TO PATH: ", filePath) - - model.uploadFile(url: fileURL, path: filePath) { info in - Task { - // Refresh file listing to display newly uploaded file. - let _ = await model.getFileList(path: filePath) - } - } - } - - var body: some View { - HStack(alignment: .center, spacing: 0) { - Spacer() - .frame(width: CGFloat(depth * (12 + 2))) - - Button { - if file.isFolder { - file.expanded.toggle() - } - } label: { - Text(Image(systemName: file.expanded ? "chevron.down" : "chevron.right")) - .bold() - .font(.system(size: 10)) - .foregroundStyle(dragOver ? Color.white : Color.primary) - .opacity(0.5) - } - .buttonStyle(.plain) - .frame(width: 10) - .padding(.leading, 4) - .padding(.trailing, 8) - - HStack(alignment: .center) { - if file.isUnavailable { - Image(systemName: "questionmark.app.fill") - .frame(width: 16, height: 16) - .opacity(0.5) - } - else if file.isAdminDropboxFolder { - Image("Admin Drop Box") - .resizable() - .scaledToFit() - .frame(width: 16, height: 16) - } - else if file.isDropboxFolder { - Image("Drop Box") - .resizable() - .scaledToFit() - .frame(width: 16, height: 16) - } - else { - Image("Folder") - .resizable() - .scaledToFit() - .frame(width: 16, height: 16) - } - } - .frame(width: 16) - .padding(.trailing, 6) - - Text(file.name) - .lineLimit(1) - .truncationMode(.tail) - .foregroundStyle(dragOver ? Color.white : Color.primary) - .opacity(file.isUnavailable ? 0.5 : 1.0) - - if loading { - ProgressView().controlSize(.small).padding([.leading, .trailing], 5) - } - Spacer() - if !file.isUnavailable { - Text(file.fileSize == 0 ? "Empty" : "^[\(file.fileSize) \("file")](inflect: true)") - .foregroundStyle(dragOver ? Color.white.opacity(0.75) : Color.secondary) - .lineLimit(1) - .padding(.trailing, 6) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background( - RoundedRectangle(cornerRadius: 4.0) - .fill(dragOver ? Color(nsColor: NSColor.selectedContentBackgroundColor) : Color.clear) - .padding(.horizontal, -6) - .padding(.vertical, -4) - ) - .onChange(of: file.expanded) { - loading = false - if file.expanded && file.fileSize > 0 { - Task { - loading = true - let _ = await model.getFileList(path: file.path) - loading = false - } - } - } - .onDrop(of: [.fileURL], isTargeted: $dragOver) { items in - guard let item = items.first, - let identifier = item.registeredTypeIdentifiers.first else { - return false - } - - item.loadItem(forTypeIdentifier: identifier, options: nil) { (urlData, error) in - DispatchQueue.main.async { - if let urlData = urlData as? Data, - let fileURL = URL(dataRepresentation: urlData, relativeTo: nil, isAbsolute: true) { - uploadFile(file: fileURL) - } - } - } - - return true - } - - if file.expanded { - ForEach(file.children!, id: \.self) { childFile in - if childFile.isFolder { - FolderView(file: childFile, depth: self.depth + 1).tag(file.id) - } - else { - FileView(file: childFile, depth: self.depth + 1).tag(file.id) - } - } - } - } -} - -struct FileView: View { - @Environment(Hotline.self) private var model: Hotline - - var file: FileInfo - let depth: Int - - var body: some View { - HStack(alignment: .center, spacing: 0) { - Spacer() - .frame(width: CGFloat(depth * (12 + 2))) - - Spacer() - .frame(width: 10) - .padding(.leading, 4) - .padding(.trailing, 8) - - HStack(alignment: .center) { - if file.isUnavailable { - Image(systemName: "questionmark.app.fill") - .frame(width: 16, height: 16) - .opacity(0.5) - } - else { - FileIconView(filename: file.name, fileType: file.type) - .frame(width: 16, height: 16) - } - } - .frame(width: 16) - .padding(.trailing, 6) - - Text(file.name) - .lineLimit(1) - .truncationMode(.tail) - .opacity(file.isUnavailable ? 0.5 : 1.0) - - Spacer() - if !file.isUnavailable { - Text(formattedFileSize(file.fileSize)) - .foregroundStyle(.secondary) - .lineLimit(1) - .padding(.trailing, 6) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - - if file.expanded { - ForEach(file.children!, id: \.self) { childFile in - if childFile.isFolder { - FolderView(file: childFile, depth: self.depth + 1).tag(file.id) - } - else { - FileView(file: childFile, depth: self.depth + 1).tag(file.id) - } - } - } - } - - static let byteFormatter = ByteCountFormatter() - - private func formattedFileSize(_ fileSize: UInt) -> String { - FileView.byteFormatter.allowedUnits = [.useAll] - FileView.byteFormatter.countStyle = .file - return FileView.byteFormatter.string(fromByteCount: Int64(fileSize)) - } -} - -struct FilesView: View { - @Environment(Hotline.self) private var model: Hotline - @Environment(\.openWindow) private var openWindow - - @State private var selection: FileInfo? - @State private var fileDetails: FileDetails? - @State private var uploadFileSelectorDisplayed: Bool = false - @State private var searchText: String = "" - @State private var isSearching: Bool = false - - private var isShowingSearchResults: Bool { - switch model.fileSearchStatus { - case .idle: - return !model.fileSearchResults.isEmpty - case .cancelled(_): - return !model.fileSearchResults.isEmpty - default: - return true - } - } - - private var displayedFiles: [FileInfo] { - isShowingSearchResults ? model.fileSearchResults : model.files - } - - private var searchStatusMessage: String? { - switch model.fileSearchStatus { - case .searching(let processed, _): - let scanned = processed == 1 ? "folder" : "folders" - return "Searched \(processed) \(scanned)..." - case .completed(let processed): - let count = model.fileSearchResults.count - let folderWord = processed == 1 ? "folder" : "folders" - if count == 0 { - return "No files found in \(processed) \(folderWord)" - } - return "\(count) file\(count == 1 ? "" : "s") found in \(processed) \(folderWord)" - case .cancelled(_): - if model.fileSearchResults.isEmpty { - return nil - } - return "Search cancelled" - case .failed(let message): - return "Search failed: \(message)" - case .idle: - return nil - } - } - - private var searchStatusPath: String? { - guard let path = model.fileSearchCurrentPath else { - return nil - } - if path.isEmpty { - return "/" - } - return path.joined(separator: "/") - } - - private func openPreviewWindow(_ previewInfo: PreviewFileInfo) { - switch previewInfo.previewType { - case .image: - openWindow(id: "preview-image", value: previewInfo) - case .text: - openWindow(id: "preview-text", value: previewInfo) - default: - return - } - } - - @MainActor private func getFileInfo(_ file: FileInfo) { - Task { - if let fileInfo = await model.getFileDetails(file.name, path: file.path) { - Task { @MainActor in - self.fileDetails = fileInfo - } - } - } - } - - @MainActor private func downloadFile(_ file: FileInfo) { - if file.isFolder { - model.downloadFolder(file.name, path: file.path) - } - else { - model.downloadFile(file.name, path: file.path) - } - } - - @MainActor private func uploadFile(file fileURL: URL, to path: [String]) { - model.uploadFile(url: fileURL, path: path) { info in - Task { - // Refresh file listing to display newly uploaded file. - let _ = await model.getFileList(path: path) - } - } - } - - @MainActor private func previewFile(_ file: FileInfo) { - guard file.isPreviewable else { - return - } - - model.previewFile(file.name, path: file.path) { info in - if let info = info { - openPreviewWindow(info) - } - } - } - - private func deleteFile(_ file: FileInfo) async { - var parentPath: [String] = [] - if file.path.count > 1 { - parentPath = Array(file.path[0.. 0 else { - return - } - - let fileURL = fileURLS.first! - - print(fileURL) - - var uploadPath: [String] = [] - - if let selection = selection { - if selection.isFolder { - uploadPath = selection.path - } - else { - uploadPath = Array(selection.path) - uploadPath.removeLast() - } - } - - print("UPLOAD PATH: \(uploadPath)") - uploadFile(file: fileURL, to: uploadPath) - - case .failure(let error): - print(error) - } - }) - .onSubmit(of: .search) { - #if os(macOS) - let shiftPressed = NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false - if shiftPressed { - model.clearFileListCache() - } - #endif - - let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - model.cancelFileSearch() - return - } - searchText = trimmed - model.startFileSearch(query: trimmed) - } - .onChange(of: searchText) { _, newValue in - if newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - if isShowingSearchResults { - model.cancelFileSearch() - } - } - } - .onChange(of: model.fileSearchQuery) { _, newValue in - if newValue != searchText { - searchText = newValue - } - } - .onAppear { - if searchText != model.fileSearchQuery { - searchText = model.fileSearchQuery - } - } - .safeAreaInset(edge: .top) { - if isShowingSearchResults, let message = searchStatusMessage { - HStack(alignment: .center, spacing: 6) { - if case .searching(_, _) = model.fileSearchStatus { - ProgressView() - .controlSize(.small) - .accentColor(.white) - .tint(.white) - } - else if case .completed = model.fileSearchStatus { - Image(systemName: "checkmark.circle.fill") - .resizable() - .symbolRenderingMode(.monochrome) - .foregroundStyle(.white) - .aspectRatio(contentMode: .fit) - .frame(width: 16, height: 16) - } - else if case .failed = model.fileSearchStatus { - Image(systemName: "exclamationmark.triangle.fill") - .resizable() - .symbolRenderingMode(.monochrome) - .foregroundStyle(.white) - .aspectRatio(contentMode: .fit) - .frame(width: 16, height: 16) - } - - Text(message) - .lineLimit(1) - .font(.body) - .foregroundStyle(.white) - - Spacer() - - if let pathMessage = searchStatusPath { - Text(pathMessage) - .lineLimit(1) - .truncationMode(.tail) - .font(.footnote) -// .fontWeight(.semibold) - .foregroundStyle(.white) - .opacity(0.5) - .padding(.top, 2) - } - } - .padding(.trailing, 14) - .padding(.leading, 8) - .padding(.vertical, 8) - .background { - Group { - if case .completed = model.fileSearchStatus { - Color.fileComplete - } - else { - Color(nsColor: .controlAccentColor) - } - } - .clipShape(.capsule(style: .continuous)) - } - .padding(.horizontal, 8) - .padding(.top, 8) - } - } - } -} - -#Preview { - FilesView() - .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) -} diff --git a/Hotline/macOS/MessageBoardEditorView.swift b/Hotline/macOS/MessageBoardEditorView.swift deleted file mode 100644 index 474384e..0000000 --- a/Hotline/macOS/MessageBoardEditorView.swift +++ /dev/null @@ -1,117 +0,0 @@ -import SwiftUI - -private enum FocusFields { - case body -} - -struct MessageBoardEditorView: View { - @Environment(\.controlActiveState) private var controlActiveState - @Environment(\.colorScheme) private var colorScheme - @Environment(\.dismiss) private var dismiss - @Environment(Hotline.self) private var model: Hotline - - @State private var text: String = "" - @State private var sending: Bool = false - - @FocusState private var focusedField: FocusFields? - - func sendPost() async { - sending = true - - let cleanedText = text.trimmingCharacters(in: .whitespacesAndNewlines) - - model.postToMessageBoard(text: cleanedText) - let _ = await model.getMessageBoard() - -// let success = await model.postNewsArticle(title: title, body: text, at: path, parentID: parentID) -// if success { -// await model.getNewsList(at: path) -// } - - sending = false - } - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - HStack(alignment: .center, spacing: 0) { - Button { - dismiss() - } label: { - Image(systemName: "xmark") - .resizable() - .scaledToFit() - .frame(width: 14, height: 14) - .opacity(0.5) - } - .buttonStyle(.plain) - .frame(width: 16, height: 16) - - Spacer() - -// Image("Message Board Post") -// .resizable() -// .scaledToFit() -// .frame(width: 16, height: 16) -// .padding(.trailing, 6) - - Text("New Post") - .fontWeight(.semibold) - .lineLimit(1) - .truncationMode(.middle) - - Spacer() - - if sending { - ProgressView() - .controlSize(.small) - .frame(width: 22, height: 22) - } - else { - Button { - sending = true - model.postToMessageBoard(text: text) - Task { - let _ = await model.getMessageBoard() - Task { @MainActor in - sending = false - dismiss() - } - } - } label: { - Image(systemName: "arrow.up.circle.fill") - .resizable() - .renderingMode(.template) - .scaledToFit() - .foregroundColor(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? .secondary : .accentColor) - } - .buttonStyle(.plain) - .frame(width: 22, height: 22) - .help("Post to Message Board") - .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - } - .frame(maxWidth: .infinity) - .padding() - - Divider() - - BetterTextEditor(text: $text) - .betterEditorFont(NSFont.systemFont(ofSize: 14.0)) - .betterEditorAutomaticSpellingCorrection(true) - .betterEditorTextInset(.init(width: 16, height: 18)) - .lineSpacing(20) - .background(Color(nsColor: .textBackgroundColor)) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .focused($focusedField, equals: .body) - } - .frame(minWidth: 300, idealWidth: 450, maxWidth: .infinity, minHeight: 300, idealHeight: 500, maxHeight: .infinity) - .background(Color(nsColor: .textBackgroundColor)) - .presentationCompactAdaptation(.sheet) - .onAppear { - focusedField = .body - } - .onDisappear { - dismiss() - } - } -} diff --git a/Hotline/macOS/MessageBoardView.swift b/Hotline/macOS/MessageBoardView.swift deleted file mode 100644 index f870b0c..0000000 --- a/Hotline/macOS/MessageBoardView.swift +++ /dev/null @@ -1,102 +0,0 @@ -import SwiftUI - -struct MessageBoardView: View { - @Environment(Hotline.self) private var model: Hotline - - @State private var composerDisplayed: Bool = false - @State private var composerText: String = "" - - var body: some View { - NavigationStack { - if model.access?.contains(.canReadMessageBoard) != false { - ScrollView { - LazyVStack(alignment: .leading) { - ForEach(model.messageBoard, id: \.self) { msg in - Text(LocalizedStringKey(msg)) - .tint(Color("Link Color")) - .lineLimit(100) - .lineSpacing(4) - .padding() - .textSelection(.enabled) - Divider() - } - } - Spacer() - } - .task { - if !model.messageBoardLoaded { - let _ = await model.getMessageBoard() - } - } - .overlay { - if !model.messageBoardLoaded { - VStack { - ProgressView() - .controlSize(.large) - } - .frame(maxWidth: .infinity) - } - } - .background(Color(nsColor: .textBackgroundColor)) - } - else { - ZStack(alignment: .center) { - Text("No Message Board") - .font(.title) - .multilineTextAlignment(.center) - .foregroundStyle(.secondary) - .padding() - } - .frame(maxWidth: .infinity) - } - } - .sheet(isPresented: $composerDisplayed) { - MessageBoardEditorView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - .frame(idealWidth: 450, idealHeight: 350) -// RichTextEditor(text: $composerText) -// .richEditorFont(NSFont.systemFont(ofSize: 16.0)) -// .richEditorAutomaticDashSubstitution(false) -// .richEditorAutomaticQuoteSubstitution(false) -// .richEditorAutomaticSpellingCorrection(false) -// .background(Color(nsColor: .textBackgroundColor)) -// .frame(maxWidth: .infinity, maxHeight: .infinity) -// .frame(idealWidth: 450, idealHeight: 350) -// .toolbar { -// ToolbarItem(placement: .cancellationAction) { -// Button("Cancel") { -// composerDisplayed.toggle() -// } -// } -// -// ToolbarItem(placement: .primaryAction) { -// Button("Post") { -// composerDisplayed.toggle() -// let text = composerText -// composerText = "" -// model.postToMessageBoard(text: text) -// Task { -// await model.getMessageBoard() -// } -// } -// } -// } - } - .toolbar { - ToolbarItem(placement:.primaryAction) { - Button { - composerDisplayed.toggle() - } label: { - Image(systemName: "square.and.pencil") - } - .disabled(model.access?.contains(.canPostMessageBoard) == false) - .help("Post to Message Board") - } - } - } -} - -#Preview { - MessageBoardView() - .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) -} diff --git a/Hotline/macOS/News/NewsEditorView.swift b/Hotline/macOS/News/NewsEditorView.swift new file mode 100644 index 0000000..f69c846 --- /dev/null +++ b/Hotline/macOS/News/NewsEditorView.swift @@ -0,0 +1,153 @@ +import SwiftUI + +private enum FocusFields { + case title + case body +} + +struct NewsEditorView: View { + @Environment(\.controlActiveState) private var controlActiveState + @Environment(\.colorScheme) private var colorScheme + @Environment(\.dismiss) private var dismiss + @Environment(Hotline.self) private var model: Hotline + + let editorTitle: String + let isReply: Bool + let path: [String] + let parentID: UInt32 + + @State var title: String = "" + @State private var text: String = "" + @State private var sending: Bool = false + + @FocusState private var focusedField: FocusFields? + + func sendArticle() async -> Bool { + sending = true + + let success = await model.postNewsArticle(title: title, body: text, at: path, parentID: parentID) + if success { + await model.getNewsList(at: path) + } + + sending = false + + return success + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .center, spacing: 0) { + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .resizable() + .scaledToFit() + .frame(width: 14, height: 14) + .opacity(0.5) + } + .buttonStyle(.plain) + .frame(width: 16, height: 16) + + Spacer() + + if !isReply { + Image("News Category") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + .padding(.trailing, 6) + } + + Text(editorTitle) + .fontWeight(.semibold) + .lineLimit(1) + .truncationMode(.middle) + + Spacer() + + if sending { + ProgressView() + .controlSize(.small) + .frame(width: 22, height: 22) + } + else { + Button { + Task { + if await sendArticle() { + dismiss() + } + } + } label: { + Image(systemName: "arrow.up.circle.fill") + .resizable() + .renderingMode(.template) + .scaledToFit() + .foregroundColor((title.isEmpty || text.isEmpty) ? .secondary : .accentColor) + } + .buttonStyle(.plain) + .frame(width: 22, height: 22) + .help("Post News") + .disabled(title.isEmpty || text.isEmpty) + } + } + .frame(maxWidth: .infinity) + .padding([.leading, .top, .trailing]) + + TextField("Title", text: $title, axis: .vertical) + .textFieldStyle(.plain) + .lineLimit(3) + .padding() + .focusEffectDisabled() + .fontWeight(.semibold) + .frame(maxWidth: .infinity) + .border(Color.pink, width: 0) + .background(.tertiary.opacity(0.2)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .padding() + .focused($focusedField, equals: .title) + + Divider() + + BetterTextEditor(text: $text) + .betterEditorFont(NSFont.monospacedSystemFont(ofSize: 14.0, weight: .regular)) + .betterEditorAutomaticSpellingCorrection(true) + .betterEditorTextInset(.init(width: 16, height: 18)) + .background(Color(nsColor: .textBackgroundColor)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .focused($focusedField, equals: .body) + + HStack(alignment: .center) { + Spacer() + + Text(String("**bold** _italics_ [link name](url) ![image name](url)")) + .foregroundStyle(.secondary) + .font(.caption) + .fontDesign(.monospaced) + .lineLimit(1) + .truncationMode(.middle) + .padding() + + Spacer() + } + .frame(maxWidth: .infinity) + .background(.tertiary.opacity(0.15)) + } + .frame(minWidth: 300, idealWidth: 450, maxWidth: .infinity, minHeight: 300, idealHeight: 500, maxHeight: .infinity) + .background(Color(nsColor: .textBackgroundColor)) + .presentationCompactAdaptation(.sheet) + .toolbarTitleDisplayMode(.inlineLarge) + .onAppear { + if !title.isEmpty { + focusedField = .body + } + else { + focusedField = .title + } + } + .onDisappear { + dismiss() + } + } +} diff --git a/Hotline/macOS/News/NewsItemView.swift b/Hotline/macOS/News/NewsItemView.swift new file mode 100644 index 0000000..fc20e61 --- /dev/null +++ b/Hotline/macOS/News/NewsItemView.swift @@ -0,0 +1,152 @@ +import SwiftUI + +struct NewsItemView: View { + @Environment(Hotline.self) private var model: Hotline + + var news: NewsInfo + let depth: Int + + static var dateFormatter: DateFormatter = { + var dateFormatter = DateFormatter() + dateFormatter.dateStyle = .long + dateFormatter.timeStyle = .short + dateFormatter.timeZone = .gmt + return dateFormatter + }() + + static var relativeDateFormatter: RelativeDateTimeFormatter = { + var formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .full + formatter.dateTimeStyle = .named + formatter.formattingContext = .listItem + return formatter + }() + + var body: some View { + HStack(alignment: .center, spacing: 0) { + if news.expandable { + Button { + news.expanded.toggle() + } label: { + Text(Image(systemName: news.expanded ? "chevron.down" : "chevron.right")) + .bold() + .font(.system(size: 10)) + .opacity(0.5) + .frame(alignment: .center) + } + .buttonStyle(.plain) + .frame(width: 10) + .padding(.leading, 4) + .padding(.trailing, 8) + } + else { + Spacer() + .frame(width: 10) + .padding(.leading, 4) + .padding(.trailing, 8) + } + + // Tree indent + Spacer() + .frame(width: (CGFloat(depth) * 22)) + + switch news.type { + case .category: + Image("News Category") + .resizable() + .frame(width: 16, height: 16, alignment: .center) + .padding(.trailing, 6) + case .bundle: + Image("News Bundle") + .resizable() + .frame(width: 16, height: 16, alignment: .center) + .padding(.trailing, 6) + case .article: + EmptyView() + } + + Text(news.name) + .fontWeight((news.type == .bundle || news.type == .category || !news.read) ? .semibold : .regular) + .lineLimit(1) + .truncationMode(.tail) + + if news.type == .article && news.articleUsername != nil { + Text(news.articleUsername!) + .foregroundStyle(.secondary) + .lineLimit(1) + .padding(.leading, 8) + } + + Spacer() + + if news.type == .category && news.count > 0 { + Text("^[\(news.count) Post](inflect: true)") + .lineLimit(1) + .foregroundStyle(.secondary) + .padding(.trailing, 8) + } + else if news.type == .bundle && news.count > 0 { + Text("^[\(news.count) Category](inflect: true)") + .lineLimit(1) + .foregroundStyle(.secondary) + .padding(.trailing, 8) + } +// if news.type == .bundle { +// Text("\(news.count)") +// .lineLimit(1) +// .foregroundStyle(.tertiary) +// .padding(.trailing, 8) + +// ZStack { +// Text("^[\(news.count) \(news.type == .bundle ? "Category" : "Post")](inflect: true)") +// Text("\(news.count)") +// .foregroundStyle(.clear) +//// .font(.caption) +// .lineLimit(1) +// .padding([.leading, .trailing], 8) +// .padding([.top, .bottom], 2) +// .background(.secondary) +// .clipShape(Capsule()) +// +// Text("\(news.count)") +// .foregroundStyle(.white) +//// .font(.caption) +// .lineLimit(1) +// .padding([.leading, .trailing], 8) +// .padding([.top, .bottom], 2) +// .blendMode(.destinationOut) +// } +// .drawingGroup(opaque: false) +// } + else if news.type == .article && news.articleUsername != nil { + if let d = news.articleDate { + Text(NewsItemView.relativeDateFormatter.localizedString(for: d, relativeTo: Date.now)) + .lineLimit(1) + .foregroundStyle(.secondary) + .padding(.trailing, 8) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onChange(of: news.expanded) { + guard news.expanded, news.type == .bundle || news.type == .category else { + return + } + + Task { + await model.getNewsList(at: news.path) + } + } + + if news.expanded { + ForEach(news.children.reversed(), id: \.self) { childNews in + NewsItemView(news: childNews, depth: self.depth + 1).tag(childNews.id) + } + } + } +} + +#Preview { + NewsItemView(news: NewsInfo(hotlineNewsArticle: HotlineNewsArticle(id: 0, parentID: 0, flags: 0, title: "Title", username: "username", date: Date.now, flavors: [("", 1)], path: ["Guest"])), depth: 0) + .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) +} diff --git a/Hotline/macOS/News/NewsView.swift b/Hotline/macOS/News/NewsView.swift new file mode 100644 index 0000000..91bf2fe --- /dev/null +++ b/Hotline/macOS/News/NewsView.swift @@ -0,0 +1,297 @@ +import SwiftUI +import MarkdownUI +import SplitView + +struct NewsView: View { + @Environment(Hotline.self) private var model: Hotline + @Environment(\.openWindow) private var openWindow + @Environment(\.colorScheme) private var colorScheme + + @State private var selection: NewsInfo? + @State private var articleText: String? + @State private var splitHidden = SideHolder(.bottom) + @State private var splitFraction = FractionHolder.usingUserDefaults(0.25, key: "News Split Fraction") + @State private var editorOpen: Bool = false + @State private var replyOpen: Bool = false + @State private var loading: Bool = false + + var body: some View { + Group { + if model.serverVersion < 151 { + VStack { + Text("No News") + .bold() + .foregroundStyle(.secondary) + .font(.title3) + Text("This server has news turned off.") + .foregroundStyle(.tertiary) + .font(.system(size: 13)) + } + .padding() + } + else { + NavigationStack { + VSplit( + top: { + if !model.newsLoaded { + loadingIndicator + } + else if model.news.isEmpty { + ZStack(alignment: .center) { + Text("No News") + .font(.title) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + .padding() + } + .frame(maxWidth: .infinity) + } + else { + newsBrowser + } + }, + bottom: { + articleViewer + } + ) + .fraction(splitFraction) + .constraints(minPFraction: 0.1, minSFraction: 0.3) + .hide(splitHidden) + .styling(color: colorScheme == .dark ? .black : Splitter.defaultColor, inset: 0, visibleThickness: 0.5, invisibleThickness: 5, hideSplitter: true) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(nsColor: .textBackgroundColor)) + } + .task { + if !model.newsLoaded { + loading = true + await model.getNewsList() + loading = false + } + } + } + } + .sheet(isPresented: $editorOpen) { + } content: { + if let selection = selection { + switch selection.type { + case .article, .category: + NewsEditorView(editorTitle: selection.path.last ?? "New Post", isReply: false, path: selection.path, parentID: 0) + default: + EmptyView() + } + } + else { + EmptyView() + } + } + .sheet(isPresented: $replyOpen) { + } content: { + if let selection = selection, selection.type == .article { + NewsEditorView(editorTitle: "Reply to \(selection.articleUsername ?? "Post")", isReply: true, path: selection.path, parentID: UInt32(selection.articleID!), title: selection.name.replyToString()) + } + else { + EmptyView() + } + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + if selection?.type == .category || selection?.type == .article { + editorOpen = true + } + } label: { + Image(systemName: "square.and.pencil") + } + .help("New Post") + .disabled(selection?.type != .category && selection?.type != .article) + } + + ToolbarItem(placement: .primaryAction) { + Button { + if selection?.type == .article { + replyOpen = true + } + } label: { + Image(systemName: "arrowshape.turn.up.left") + } + .help("Reply to Post") + .disabled(selection?.type != .article) + } + + ToolbarItem(placement: .primaryAction) { + Button { + loading = true + if let selectionPath = selection?.path { + Task { + await model.getNewsList(at: selectionPath) + loading = false + } + } + else { + Task { + await model.getNewsList() + loading = false + } + } + } label: { + Image(systemName: "arrow.clockwise") + } + .help("Reload News") + .disabled(loading) + } + } + } + + var newsBrowser: some View { + List(model.news, id: \.self, selection: $selection) { newsItem in + NewsItemView(news: newsItem, depth: 0).tag(newsItem.id) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .environment(\.defaultMinListRowHeight, 28) + .listStyle(.inset) + .alternatingRowBackgrounds(.enabled) + .contextMenu(forSelectionType: NewsInfo.self) { items in + let selectedItem = items.first + + Button { + if selectedItem?.type == .article { + replyOpen = true + } + } label: { + Label("Reply to \(selectedItem?.articleUsername ?? "Post")", systemImage: "arrowshape.turn.up.left") + } + .disabled(selectedItem == nil || selectedItem?.type != .article) + + } primaryAction: { items in + guard let clickedNews = items.first else { + return + } + + self.selection = clickedNews + if clickedNews.type == .bundle || clickedNews.type == .category || clickedNews.children.count > 0 { + clickedNews.expanded.toggle() + } + } + .onChange(of: selection) { + self.articleText = nil + if let article = selection, article.type == .article { + article.read = true + if let articleFlavor = article.articleFlavors?.first, + let articleID = article.articleID { + Task { + if let articleText = await self.model.getNewsArticle(id: articleID, at: article.path, flavor: articleFlavor) { + self.articleText = articleText + } + } + if self.splitHidden.side != nil { + withAnimation(.easeOut(duration: 0.15)) { + self.splitHidden.side = nil + } + } + + } + } + else { + if self.splitHidden.side != .bottom { + withAnimation(.easeOut(duration: 0.25)) { + self.splitHidden.side = .bottom + } + } + } + } + .onKeyPress(.rightArrow) { + if let s = selection, s.expandable { + s.expanded = true + return .handled + } + return .ignored + } + .onKeyPress(.leftArrow) { + if let s = selection, s.expandable { + s.expanded = false + return .handled + } + return .ignored + } + } + + var loadingIndicator: some View { + VStack { + HStack { + ProgressView { + Text("Loading Newsgroups") + } + .controlSize(.regular) + } + } + .frame(maxWidth: .infinity) + } + + var articleViewer: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + if let selection = selection, selection.type == .article { + if let poster = selection.articleUsername, let postDate = selection.articleDate { + HStack(alignment: .firstTextBaseline) { + Text(poster) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .textSelection(.enabled) + .padding(.bottom, 16) + Spacer() + Text("\(NewsItemView.dateFormatter.string(from: postDate))") + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .textSelection(.enabled) + .padding(.bottom, 16) + } + } + + Divider() + + Text(selection.name).font(.title) + .textSelection(.enabled) + .padding(.bottom, 8) + .padding(.top, 16) + + if let newsText = self.articleText { + Markdown(newsText) + .markdownTheme(.basic) + .textSelection(.enabled) + .lineSpacing(6) + .padding(.top, 16) + } + } + else { + HStack(alignment: .center) { + Spacer() + HStack(alignment: .center, spacing: 8) { +// Image(systemName: "doc.append") +// .resizable() +// .scaledToFit() +// .foregroundStyle(.tertiary) +// .frame(width: 16, height: 16) + Text("Select a news post to read") + .foregroundStyle(.tertiary) + .font(.system(size: 13)) + } + Spacer() + } + .padding() + .padding(.top, 48) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .padding() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .transition(.move(edge: .bottom)) + } +} + +#Preview { + NewsView() + .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) +} diff --git a/Hotline/macOS/NewsEditorView.swift b/Hotline/macOS/NewsEditorView.swift deleted file mode 100644 index f69c846..0000000 --- a/Hotline/macOS/NewsEditorView.swift +++ /dev/null @@ -1,153 +0,0 @@ -import SwiftUI - -private enum FocusFields { - case title - case body -} - -struct NewsEditorView: View { - @Environment(\.controlActiveState) private var controlActiveState - @Environment(\.colorScheme) private var colorScheme - @Environment(\.dismiss) private var dismiss - @Environment(Hotline.self) private var model: Hotline - - let editorTitle: String - let isReply: Bool - let path: [String] - let parentID: UInt32 - - @State var title: String = "" - @State private var text: String = "" - @State private var sending: Bool = false - - @FocusState private var focusedField: FocusFields? - - func sendArticle() async -> Bool { - sending = true - - let success = await model.postNewsArticle(title: title, body: text, at: path, parentID: parentID) - if success { - await model.getNewsList(at: path) - } - - sending = false - - return success - } - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - HStack(alignment: .center, spacing: 0) { - Button { - dismiss() - } label: { - Image(systemName: "xmark") - .resizable() - .scaledToFit() - .frame(width: 14, height: 14) - .opacity(0.5) - } - .buttonStyle(.plain) - .frame(width: 16, height: 16) - - Spacer() - - if !isReply { - Image("News Category") - .resizable() - .scaledToFit() - .frame(width: 16, height: 16) - .padding(.trailing, 6) - } - - Text(editorTitle) - .fontWeight(.semibold) - .lineLimit(1) - .truncationMode(.middle) - - Spacer() - - if sending { - ProgressView() - .controlSize(.small) - .frame(width: 22, height: 22) - } - else { - Button { - Task { - if await sendArticle() { - dismiss() - } - } - } label: { - Image(systemName: "arrow.up.circle.fill") - .resizable() - .renderingMode(.template) - .scaledToFit() - .foregroundColor((title.isEmpty || text.isEmpty) ? .secondary : .accentColor) - } - .buttonStyle(.plain) - .frame(width: 22, height: 22) - .help("Post News") - .disabled(title.isEmpty || text.isEmpty) - } - } - .frame(maxWidth: .infinity) - .padding([.leading, .top, .trailing]) - - TextField("Title", text: $title, axis: .vertical) - .textFieldStyle(.plain) - .lineLimit(3) - .padding() - .focusEffectDisabled() - .fontWeight(.semibold) - .frame(maxWidth: .infinity) - .border(Color.pink, width: 0) - .background(.tertiary.opacity(0.2)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .padding() - .focused($focusedField, equals: .title) - - Divider() - - BetterTextEditor(text: $text) - .betterEditorFont(NSFont.monospacedSystemFont(ofSize: 14.0, weight: .regular)) - .betterEditorAutomaticSpellingCorrection(true) - .betterEditorTextInset(.init(width: 16, height: 18)) - .background(Color(nsColor: .textBackgroundColor)) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .focused($focusedField, equals: .body) - - HStack(alignment: .center) { - Spacer() - - Text(String("**bold** _italics_ [link name](url) ![image name](url)")) - .foregroundStyle(.secondary) - .font(.caption) - .fontDesign(.monospaced) - .lineLimit(1) - .truncationMode(.middle) - .padding() - - Spacer() - } - .frame(maxWidth: .infinity) - .background(.tertiary.opacity(0.15)) - } - .frame(minWidth: 300, idealWidth: 450, maxWidth: .infinity, minHeight: 300, idealHeight: 500, maxHeight: .infinity) - .background(Color(nsColor: .textBackgroundColor)) - .presentationCompactAdaptation(.sheet) - .toolbarTitleDisplayMode(.inlineLarge) - .onAppear { - if !title.isEmpty { - focusedField = .body - } - else { - focusedField = .title - } - } - .onDisappear { - dismiss() - } - } -} diff --git a/Hotline/macOS/NewsItemView.swift b/Hotline/macOS/NewsItemView.swift deleted file mode 100644 index fc20e61..0000000 --- a/Hotline/macOS/NewsItemView.swift +++ /dev/null @@ -1,152 +0,0 @@ -import SwiftUI - -struct NewsItemView: View { - @Environment(Hotline.self) private var model: Hotline - - var news: NewsInfo - let depth: Int - - static var dateFormatter: DateFormatter = { - var dateFormatter = DateFormatter() - dateFormatter.dateStyle = .long - dateFormatter.timeStyle = .short - dateFormatter.timeZone = .gmt - return dateFormatter - }() - - static var relativeDateFormatter: RelativeDateTimeFormatter = { - var formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .full - formatter.dateTimeStyle = .named - formatter.formattingContext = .listItem - return formatter - }() - - var body: some View { - HStack(alignment: .center, spacing: 0) { - if news.expandable { - Button { - news.expanded.toggle() - } label: { - Text(Image(systemName: news.expanded ? "chevron.down" : "chevron.right")) - .bold() - .font(.system(size: 10)) - .opacity(0.5) - .frame(alignment: .center) - } - .buttonStyle(.plain) - .frame(width: 10) - .padding(.leading, 4) - .padding(.trailing, 8) - } - else { - Spacer() - .frame(width: 10) - .padding(.leading, 4) - .padding(.trailing, 8) - } - - // Tree indent - Spacer() - .frame(width: (CGFloat(depth) * 22)) - - switch news.type { - case .category: - Image("News Category") - .resizable() - .frame(width: 16, height: 16, alignment: .center) - .padding(.trailing, 6) - case .bundle: - Image("News Bundle") - .resizable() - .frame(width: 16, height: 16, alignment: .center) - .padding(.trailing, 6) - case .article: - EmptyView() - } - - Text(news.name) - .fontWeight((news.type == .bundle || news.type == .category || !news.read) ? .semibold : .regular) - .lineLimit(1) - .truncationMode(.tail) - - if news.type == .article && news.articleUsername != nil { - Text(news.articleUsername!) - .foregroundStyle(.secondary) - .lineLimit(1) - .padding(.leading, 8) - } - - Spacer() - - if news.type == .category && news.count > 0 { - Text("^[\(news.count) Post](inflect: true)") - .lineLimit(1) - .foregroundStyle(.secondary) - .padding(.trailing, 8) - } - else if news.type == .bundle && news.count > 0 { - Text("^[\(news.count) Category](inflect: true)") - .lineLimit(1) - .foregroundStyle(.secondary) - .padding(.trailing, 8) - } -// if news.type == .bundle { -// Text("\(news.count)") -// .lineLimit(1) -// .foregroundStyle(.tertiary) -// .padding(.trailing, 8) - -// ZStack { -// Text("^[\(news.count) \(news.type == .bundle ? "Category" : "Post")](inflect: true)") -// Text("\(news.count)") -// .foregroundStyle(.clear) -//// .font(.caption) -// .lineLimit(1) -// .padding([.leading, .trailing], 8) -// .padding([.top, .bottom], 2) -// .background(.secondary) -// .clipShape(Capsule()) -// -// Text("\(news.count)") -// .foregroundStyle(.white) -//// .font(.caption) -// .lineLimit(1) -// .padding([.leading, .trailing], 8) -// .padding([.top, .bottom], 2) -// .blendMode(.destinationOut) -// } -// .drawingGroup(opaque: false) -// } - else if news.type == .article && news.articleUsername != nil { - if let d = news.articleDate { - Text(NewsItemView.relativeDateFormatter.localizedString(for: d, relativeTo: Date.now)) - .lineLimit(1) - .foregroundStyle(.secondary) - .padding(.trailing, 8) - } - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .onChange(of: news.expanded) { - guard news.expanded, news.type == .bundle || news.type == .category else { - return - } - - Task { - await model.getNewsList(at: news.path) - } - } - - if news.expanded { - ForEach(news.children.reversed(), id: \.self) { childNews in - NewsItemView(news: childNews, depth: self.depth + 1).tag(childNews.id) - } - } - } -} - -#Preview { - NewsItemView(news: NewsInfo(hotlineNewsArticle: HotlineNewsArticle(id: 0, parentID: 0, flags: 0, title: "Title", username: "username", date: Date.now, flavors: [("", 1)], path: ["Guest"])), depth: 0) - .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) -} diff --git a/Hotline/macOS/NewsView.swift b/Hotline/macOS/NewsView.swift deleted file mode 100644 index 91bf2fe..0000000 --- a/Hotline/macOS/NewsView.swift +++ /dev/null @@ -1,297 +0,0 @@ -import SwiftUI -import MarkdownUI -import SplitView - -struct NewsView: View { - @Environment(Hotline.self) private var model: Hotline - @Environment(\.openWindow) private var openWindow - @Environment(\.colorScheme) private var colorScheme - - @State private var selection: NewsInfo? - @State private var articleText: String? - @State private var splitHidden = SideHolder(.bottom) - @State private var splitFraction = FractionHolder.usingUserDefaults(0.25, key: "News Split Fraction") - @State private var editorOpen: Bool = false - @State private var replyOpen: Bool = false - @State private var loading: Bool = false - - var body: some View { - Group { - if model.serverVersion < 151 { - VStack { - Text("No News") - .bold() - .foregroundStyle(.secondary) - .font(.title3) - Text("This server has news turned off.") - .foregroundStyle(.tertiary) - .font(.system(size: 13)) - } - .padding() - } - else { - NavigationStack { - VSplit( - top: { - if !model.newsLoaded { - loadingIndicator - } - else if model.news.isEmpty { - ZStack(alignment: .center) { - Text("No News") - .font(.title) - .multilineTextAlignment(.center) - .foregroundStyle(.secondary) - .padding() - } - .frame(maxWidth: .infinity) - } - else { - newsBrowser - } - }, - bottom: { - articleViewer - } - ) - .fraction(splitFraction) - .constraints(minPFraction: 0.1, minSFraction: 0.3) - .hide(splitHidden) - .styling(color: colorScheme == .dark ? .black : Splitter.defaultColor, inset: 0, visibleThickness: 0.5, invisibleThickness: 5, hideSplitter: true) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(nsColor: .textBackgroundColor)) - } - .task { - if !model.newsLoaded { - loading = true - await model.getNewsList() - loading = false - } - } - } - } - .sheet(isPresented: $editorOpen) { - } content: { - if let selection = selection { - switch selection.type { - case .article, .category: - NewsEditorView(editorTitle: selection.path.last ?? "New Post", isReply: false, path: selection.path, parentID: 0) - default: - EmptyView() - } - } - else { - EmptyView() - } - } - .sheet(isPresented: $replyOpen) { - } content: { - if let selection = selection, selection.type == .article { - NewsEditorView(editorTitle: "Reply to \(selection.articleUsername ?? "Post")", isReply: true, path: selection.path, parentID: UInt32(selection.articleID!), title: selection.name.replyToString()) - } - else { - EmptyView() - } - } - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button { - if selection?.type == .category || selection?.type == .article { - editorOpen = true - } - } label: { - Image(systemName: "square.and.pencil") - } - .help("New Post") - .disabled(selection?.type != .category && selection?.type != .article) - } - - ToolbarItem(placement: .primaryAction) { - Button { - if selection?.type == .article { - replyOpen = true - } - } label: { - Image(systemName: "arrowshape.turn.up.left") - } - .help("Reply to Post") - .disabled(selection?.type != .article) - } - - ToolbarItem(placement: .primaryAction) { - Button { - loading = true - if let selectionPath = selection?.path { - Task { - await model.getNewsList(at: selectionPath) - loading = false - } - } - else { - Task { - await model.getNewsList() - loading = false - } - } - } label: { - Image(systemName: "arrow.clockwise") - } - .help("Reload News") - .disabled(loading) - } - } - } - - var newsBrowser: some View { - List(model.news, id: \.self, selection: $selection) { newsItem in - NewsItemView(news: newsItem, depth: 0).tag(newsItem.id) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .environment(\.defaultMinListRowHeight, 28) - .listStyle(.inset) - .alternatingRowBackgrounds(.enabled) - .contextMenu(forSelectionType: NewsInfo.self) { items in - let selectedItem = items.first - - Button { - if selectedItem?.type == .article { - replyOpen = true - } - } label: { - Label("Reply to \(selectedItem?.articleUsername ?? "Post")", systemImage: "arrowshape.turn.up.left") - } - .disabled(selectedItem == nil || selectedItem?.type != .article) - - } primaryAction: { items in - guard let clickedNews = items.first else { - return - } - - self.selection = clickedNews - if clickedNews.type == .bundle || clickedNews.type == .category || clickedNews.children.count > 0 { - clickedNews.expanded.toggle() - } - } - .onChange(of: selection) { - self.articleText = nil - if let article = selection, article.type == .article { - article.read = true - if let articleFlavor = article.articleFlavors?.first, - let articleID = article.articleID { - Task { - if let articleText = await self.model.getNewsArticle(id: articleID, at: article.path, flavor: articleFlavor) { - self.articleText = articleText - } - } - if self.splitHidden.side != nil { - withAnimation(.easeOut(duration: 0.15)) { - self.splitHidden.side = nil - } - } - - } - } - else { - if self.splitHidden.side != .bottom { - withAnimation(.easeOut(duration: 0.25)) { - self.splitHidden.side = .bottom - } - } - } - } - .onKeyPress(.rightArrow) { - if let s = selection, s.expandable { - s.expanded = true - return .handled - } - return .ignored - } - .onKeyPress(.leftArrow) { - if let s = selection, s.expandable { - s.expanded = false - return .handled - } - return .ignored - } - } - - var loadingIndicator: some View { - VStack { - HStack { - ProgressView { - Text("Loading Newsgroups") - } - .controlSize(.regular) - } - } - .frame(maxWidth: .infinity) - } - - var articleViewer: some View { - ScrollView { - VStack(alignment: .leading, spacing: 0) { - if let selection = selection, selection.type == .article { - if let poster = selection.articleUsername, let postDate = selection.articleDate { - HStack(alignment: .firstTextBaseline) { - Text(poster) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) - .textSelection(.enabled) - .padding(.bottom, 16) - Spacer() - Text("\(NewsItemView.dateFormatter.string(from: postDate))") - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) - .textSelection(.enabled) - .padding(.bottom, 16) - } - } - - Divider() - - Text(selection.name).font(.title) - .textSelection(.enabled) - .padding(.bottom, 8) - .padding(.top, 16) - - if let newsText = self.articleText { - Markdown(newsText) - .markdownTheme(.basic) - .textSelection(.enabled) - .lineSpacing(6) - .padding(.top, 16) - } - } - else { - HStack(alignment: .center) { - Spacer() - HStack(alignment: .center, spacing: 8) { -// Image(systemName: "doc.append") -// .resizable() -// .scaledToFit() -// .foregroundStyle(.tertiary) -// .frame(width: 16, height: 16) - Text("Select a news post to read") - .foregroundStyle(.tertiary) - .font(.system(size: 13)) - } - Spacer() - } - .padding() - .padding(.top, 48) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) - .padding() - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .transition(.move(edge: .bottom)) - } -} - -#Preview { - NewsView() - .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) -} diff --git a/Hotline/macOS/ServerAgreementView.swift b/Hotline/macOS/ServerAgreementView.swift deleted file mode 100644 index e72de7d..0000000 --- a/Hotline/macOS/ServerAgreementView.swift +++ /dev/null @@ -1,78 +0,0 @@ -import SwiftUI - -fileprivate let MAX_AGREEMENT_HEIGHT: CGFloat = 340 - -struct ServerAgreementView: View { - let text: String - - @State private var expandable: Bool = false - @State private var expanded: Bool = false - - var body: some View { - ScrollView(.vertical) { - HStack(alignment: .top) { - Spacer() - Text(text.convertToAttributedStringWithLinks()) - .font(.system(size: 12)) - .fontDesign(.monospaced) - .textSelection(.enabled) - .tint(Color("Link Color")) - .frame(maxWidth: 400) - .padding(16) - .background( - GeometryReader { geometry in - Color.clear.onAppear { - if geometry.size.height > MAX_AGREEMENT_HEIGHT { - expandable = true - } - else { - expandable = false - } - } - } - ) - Spacer() - } - } - .scrollIndicators(.never) - .frame(maxWidth: .infinity, maxHeight: (expandable && expanded) ? nil : MAX_AGREEMENT_HEIGHT) - .scrollBounceBehavior(.basedOnSize) -#if os(iOS) - .background(Color("Agreement Background")) -#elseif os(macOS) - .background(VisualEffectView(material: .titlebar, blendingMode: .withinWindow)) -#endif - .overlay(alignment: .bottomTrailing) { - ZStack(alignment: .bottomTrailing) { - Group { - if !expandable || expanded { - EmptyView() - } - else { - Button(action: { - withAnimation(.easeOut(duration: 0.15)) { - expanded = true - } - }, label: { - Image(systemName: "arrow.up.and.down.circle.fill") - .resizable() - .scaledToFit() - .fontWeight(.semibold) - .frame(width: 16, height: 16) - .foregroundColor(.primary.opacity(0.5)) - }) - .buttonStyle(.plain) - .buttonBorderShape(.circle) - .help("Expand Server Agreement") - .padding([.trailing, .bottom], 16) - } - } - } - } - .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) - } -} - -#Preview { - ServerAgreementView(text: "Hello there and welcome to this server.") -} diff --git a/Hotline/macOS/ServerMessageView.swift b/Hotline/macOS/ServerMessageView.swift deleted file mode 100644 index 6c3c60e..0000000 --- a/Hotline/macOS/ServerMessageView.swift +++ /dev/null @@ -1,33 +0,0 @@ -import SwiftUI - -struct ServerMessageView: View { - let message: String - - var body: some View { - HStack(alignment: .center, spacing: 8) { - Image("Server Message") - .symbolRenderingMode(.multicolor) - .resizable() - .scaledToFit() - .frame(width: 20, height: 20) - Text(message) - .fontWeight(.semibold) - .lineSpacing(4) - .multilineTextAlignment(.leading) - .textSelection(.enabled) - Spacer() - } - .padding() - .frame(maxWidth: .infinity) -#if os(iOS) - .background(Color("Agreement Background")) -#elseif os(macOS) - .background(VisualEffectView(material: .titlebar, blendingMode: .withinWindow)) -#endif - .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) - } -} - -#Preview { - ServerMessageView(message: "This server has something important to say.") -} diff --git a/Hotline/macOS/Settings/GeneralSettingsView.swift b/Hotline/macOS/Settings/GeneralSettingsView.swift new file mode 100644 index 0000000..6be73aa --- /dev/null +++ b/Hotline/macOS/Settings/GeneralSettingsView.swift @@ -0,0 +1,67 @@ +import SwiftUI + +struct GeneralSettingsView: View { + @State private var username: String = "" + @State private var usernameChanged: Bool = false + @State private var showClearHistoryConfirmation: Bool = false + + let saveTimer = Timer.publish(every: 5, on: .main, in: .common).autoconnect() + + var body: some View { + @Bindable var preferences = Prefs.shared + + Form { + TextField("Your Name", text: $username, prompt: Text("guest")) + Toggle("Show Join/Leave in Chat", isOn: $preferences.showJoinLeaveMessages) + Toggle("Refuse private messages", isOn: $preferences.refusePrivateMessages) + Toggle("Refuse private chat", isOn: $preferences.refusePrivateChat) + Toggle("Automatic Response", isOn: $preferences.enableAutomaticMessage) + if preferences.enableAutomaticMessage { + TextField("", text: $preferences.automaticMessage, prompt: Text("Write a response message")) + .lineLimit(2) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity) + .onSubmit(of: .text) { + preferences.username = self.username + } + } + + Divider() + + Button(role: .destructive) { + showClearHistoryConfirmation = true + } label: { + Text("Clear Chat History…") + } + } + .padding() + .frame(width: 392) + .confirmationDialog("Clear chat history?", isPresented: $showClearHistoryConfirmation, titleVisibility: .visible) { + Button("Clear Chat History", role: .destructive) { + Task { + await ChatStore.shared.clearAll() + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This removes all saved chat logs across servers. Active chats will repopulate only with new messages.") + } + .onAppear { + self.username = preferences.username + self.usernameChanged = false + } + .onDisappear { + preferences.username = self.username + self.usernameChanged = false + } + .onChange(of: username) { oldValue, newValue in + self.usernameChanged = true + } + .onReceive(saveTimer) { _ in + if self.usernameChanged { + self.usernameChanged = false + preferences.username = self.username + } + } + } +} diff --git a/Hotline/macOS/Settings/IconSettingsView.swift b/Hotline/macOS/Settings/IconSettingsView.swift new file mode 100644 index 0000000..98cbb09 --- /dev/null +++ b/Hotline/macOS/Settings/IconSettingsView.swift @@ -0,0 +1,58 @@ +import SwiftUI + +struct IconSettingsView: View { + @State private var hoveredUserIconID: Int = -1 + + var body: some View { + @Bindable var preferences = Prefs.shared + + Form { + ScrollViewReader { scrollProxy in + ScrollView { + LazyVGrid(columns: [ + GridItem(.fixed(4+32+4)), + GridItem(.fixed(4+32+4)), + GridItem(.fixed(4+32+4)), + GridItem(.fixed(4+32+4)), + GridItem(.fixed(4+32+4)), + GridItem(.fixed(4+32+4)), + GridItem(.fixed(4+32+4)) + ], spacing: 0) { + ForEach(Hotline.classicIconSet, id: \.self) { iconID in + HStack { + Image("Classic/\(iconID)") + .resizable() + .interpolation(.none) + .scaledToFit() + .frame(width: 32, height: 16) + .help("Icon \(String(iconID))") + } + .tag(iconID) + .frame(width: 32, height: 32) + .padding(4) + .background(iconID == preferences.userIconID ? Color.accentColor : (iconID == hoveredUserIconID ? Color.accentColor.opacity(0.1) : Color(nsColor: .textBackgroundColor))) + .clipShape(RoundedRectangle(cornerRadius: 5)) + .onTapGesture { + preferences.userIconID = iconID + } + .onHover { hovered in + if hovered { + self.hoveredUserIconID = iconID + } + } + } + } + .padding() + } + .background(Color(nsColor: .textBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color(nsColor: .separatorColor), lineWidth: 1)) + .onAppear { + scrollProxy.scrollTo(preferences.userIconID, anchor: .center) + } + .frame(height: 355) + } + } + .padding() + } +} diff --git a/Hotline/macOS/Settings/SettingsView.swift b/Hotline/macOS/Settings/SettingsView.swift new file mode 100644 index 0000000..b2a2948 --- /dev/null +++ b/Hotline/macOS/Settings/SettingsView.swift @@ -0,0 +1,31 @@ +import SwiftUI + +struct SettingsView: View { + private enum Tabs: Hashable { + case general, icon + } + + var body: some View { + TabView { + GeneralSettingsView() + .tabItem { + Label("General", systemImage: "person.text.rectangle") + } + .tag(Tabs.general) + IconSettingsView() + .tabItem { + Label("Icon", systemImage: "person") + } + .tag(Tabs.icon) + SoundSettingsView() + .tabItem { + Label("Sound", systemImage: "speaker.wave.3") + } + .tag(Tabs.icon) + } + } +} + +#Preview { + SettingsView() +} diff --git a/Hotline/macOS/Settings/SoundSettingsView.swift b/Hotline/macOS/Settings/SoundSettingsView.swift new file mode 100644 index 0000000..b99db77 --- /dev/null +++ b/Hotline/macOS/Settings/SoundSettingsView.swift @@ -0,0 +1,40 @@ +import SwiftUI + +struct SoundSettingsView: View { + var body: some View { + @Bindable var preferences = Prefs.shared + + Form { + Toggle("Enable Sounds", isOn: $preferences.playSounds) + .controlSize(.large) + + Section("Sounds") { + Toggle("Chat", isOn: $preferences.playChatSound) + .disabled(!preferences.playSounds) + + Toggle("File Transfers", isOn: $preferences.playFileTransferCompleteSound) + .disabled(!preferences.playSounds) + + Toggle("Private Message", isOn: $preferences.playPrivateMessageSound) + .disabled(!preferences.playSounds) + + Toggle("Join", isOn: $preferences.playJoinSound) + .disabled(!preferences.playSounds) + + Toggle("Leave", isOn: $preferences.playLeaveSound) + .disabled(!preferences.playSounds) + + Toggle("Logged in", isOn: $preferences.playLoggedInSound) + .disabled(!preferences.playSounds) + + Toggle("Error", isOn: $preferences.playErrorSound) + .disabled(!preferences.playSounds) + + Toggle("Chat Invitation", isOn: $preferences.playChatInvitationSound) + .disabled(!preferences.playSounds) + } + } + .formStyle(.grouped) + .frame(width: 392, height: 433) + } +} diff --git a/Hotline/macOS/SettingsView.swift b/Hotline/macOS/SettingsView.swift deleted file mode 100644 index d301abd..0000000 --- a/Hotline/macOS/SettingsView.swift +++ /dev/null @@ -1,193 +0,0 @@ -import SwiftUI - -struct GeneralSettingsView: View { - @State private var username: String = "" - @State private var usernameChanged: Bool = false - @State private var showClearHistoryConfirmation: Bool = false - - let saveTimer = Timer.publish(every: 5, on: .main, in: .common).autoconnect() - - var body: some View { - @Bindable var preferences = Prefs.shared - - Form { - TextField("Your Name", text: $username, prompt: Text("guest")) - Toggle("Show Join/Leave in Chat", isOn: $preferences.showJoinLeaveMessages) - Toggle("Refuse private messages", isOn: $preferences.refusePrivateMessages) - Toggle("Refuse private chat", isOn: $preferences.refusePrivateChat) - Toggle("Automatic Response", isOn: $preferences.enableAutomaticMessage) - if preferences.enableAutomaticMessage { - TextField("", text: $preferences.automaticMessage, prompt: Text("Write a response message")) - .lineLimit(2) - .multilineTextAlignment(.leading) - .frame(maxWidth: .infinity) - .onSubmit(of: .text) { - preferences.username = self.username - } - } - - Divider() - - Button(role: .destructive) { - showClearHistoryConfirmation = true - } label: { - Text("Clear Chat History…") - } - } - .padding() - .frame(width: 392) - .confirmationDialog("Clear chat history?", isPresented: $showClearHistoryConfirmation, titleVisibility: .visible) { - Button("Clear Chat History", role: .destructive) { - Task { - await ChatStore.shared.clearAll() - } - } - Button("Cancel", role: .cancel) {} - } message: { - Text("This removes all saved chat logs across servers. Active chats will repopulate only with new messages.") - } - .onAppear { - self.username = preferences.username - self.usernameChanged = false - } - .onDisappear { - preferences.username = self.username - self.usernameChanged = false - } - .onChange(of: username) { oldValue, newValue in - self.usernameChanged = true - } - .onReceive(saveTimer) { _ in - if self.usernameChanged { - self.usernameChanged = false - preferences.username = self.username - } - } - } -} - -struct IconSettingsView: View { - @State private var hoveredUserIconID: Int = -1 - - var body: some View { - @Bindable var preferences = Prefs.shared - - Form { - ScrollViewReader { scrollProxy in - ScrollView { - LazyVGrid(columns: [ - GridItem(.fixed(4+32+4)), - GridItem(.fixed(4+32+4)), - GridItem(.fixed(4+32+4)), - GridItem(.fixed(4+32+4)), - GridItem(.fixed(4+32+4)), - GridItem(.fixed(4+32+4)), - GridItem(.fixed(4+32+4)) - ], spacing: 0) { - ForEach(Hotline.classicIconSet, id: \.self) { iconID in - HStack { - Image("Classic/\(iconID)") - .resizable() - .interpolation(.none) - .scaledToFit() - .frame(width: 32, height: 16) - .help("Icon \(String(iconID))") - } - .tag(iconID) - .frame(width: 32, height: 32) - .padding(4) - .background(iconID == preferences.userIconID ? Color.accentColor : (iconID == hoveredUserIconID ? Color.accentColor.opacity(0.1) : Color(nsColor: .textBackgroundColor))) - .clipShape(RoundedRectangle(cornerRadius: 5)) - .onTapGesture { - preferences.userIconID = iconID - } - .onHover { hovered in - if hovered { - self.hoveredUserIconID = iconID - } - } - } - } - .padding() - } - .background(Color(nsColor: .textBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 6)) - .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color(nsColor: .separatorColor), lineWidth: 1)) - .onAppear { - scrollProxy.scrollTo(preferences.userIconID, anchor: .center) - } - .frame(height: 355) - } - } - .padding() - } -} - -struct SoundSettingsView: View { - var body: some View { - @Bindable var preferences = Prefs.shared - - Form { - Toggle("Enable Sounds", isOn: $preferences.playSounds) - .controlSize(.large) - - Section("Sounds") { - Toggle("Chat", isOn: $preferences.playChatSound) - .disabled(!preferences.playSounds) - - Toggle("File Transfers", isOn: $preferences.playFileTransferCompleteSound) - .disabled(!preferences.playSounds) - - Toggle("Private Message", isOn: $preferences.playPrivateMessageSound) - .disabled(!preferences.playSounds) - - Toggle("Join", isOn: $preferences.playJoinSound) - .disabled(!preferences.playSounds) - - Toggle("Leave", isOn: $preferences.playLeaveSound) - .disabled(!preferences.playSounds) - - Toggle("Logged in", isOn: $preferences.playLoggedInSound) - .disabled(!preferences.playSounds) - - Toggle("Error", isOn: $preferences.playErrorSound) - .disabled(!preferences.playSounds) - - Toggle("Chat Invitation", isOn: $preferences.playChatInvitationSound) - .disabled(!preferences.playSounds) - } - } - .formStyle(.grouped) - .frame(width: 392, height: 433) - } -} - -struct SettingsView: View { - private enum Tabs: Hashable { - case general, icon - } - - var body: some View { - TabView { - GeneralSettingsView() - .tabItem { - Label("General", systemImage: "person.text.rectangle") - } - .tag(Tabs.general) - IconSettingsView() - .tabItem { - Label("Icon", systemImage: "person") - } - .tag(Tabs.icon) - SoundSettingsView() - .tabItem { - Label("Sound", systemImage: "speaker.wave.3") - } - .tag(Tabs.icon) - } - } -} - -#Preview { - SettingsView() -} diff --git a/Hotline/macOS/TrackerView.swift b/Hotline/macOS/TrackerView.swift index 251248f..34f7a57 100644 --- a/Hotline/macOS/TrackerView.swift +++ b/Hotline/macOS/TrackerView.swift @@ -105,6 +105,7 @@ struct TrackerView: View { .moveDisabled(true) .deleteDisabled(true) .tag(TrackerSelection.bookmarkServer(trackedServer)) + .padding(.leading, 16 + 8 + 10) } } } @@ -180,18 +181,6 @@ struct TrackerView: View { case .bookmarkServer(let bookmarkServer): openWindow(id: "server", value: bookmarkServer.server) } - -// if clickedItem.type == .tracker { -// if NSEvent.modifierFlags.contains(.option) { -// trackerSheetBookmark = clickedItem -// } -// else { -// clickedItem.expanded.toggle() -// } -// } -// else if let server = clickedItem.server { -// openWindow(id: "server", value: server) -// } } .fileExporter(isPresented: $bookmarkExportActive, document: bookmarkExport, contentTypes: [.data], defaultFilename: "\(bookmarkExport?.bookmark.name ?? "Hotline Bookmark").hlbm", onCompletion: { result in switch result { @@ -208,38 +197,26 @@ struct TrackerView: View { switch self.selection { case .bookmark(let bookmark): if bookmark.type == .tracker { - self.expandedTrackers.insert(bookmark) + self.setExpanded(true, for: bookmark) return .handled } default: break } -// if -// let bookmark = selection, -// bookmark.type == .tracker { -// bookmark.expanded = true -// return .handled -// } return .ignored } .onKeyPress(.leftArrow) { switch self.selection { case .bookmark(let bookmark): if bookmark.type == .tracker { - self.expandedTrackers.remove(bookmark) + self.setExpanded(false, for: bookmark) return .handled } default: break } -// if -// let bookmark = selection, -// bookmark.type == .tracker { -// bookmark.expanded = false -// return .handled -// } return .ignored } .onDrop(of: [UTType.fileURL], isTargeted: $fileDropActive) { providers, dropPoint in @@ -354,8 +331,7 @@ struct TrackerView: View { Button { NSPasteboard.general.clearContents() - let displayAddress = server.port == HotlinePorts.DefaultServerPort ? - server.address : "\(server.address):\(server.port)" + let displayAddress = (server.port == HotlinePorts.DefaultServerPort) ? server.address : "\(server.address):\(server.port)" NSPasteboard.general.setString(displayAddress, forType: .string) } label: { Label("Copy Address", systemImage: "doc.on.doc") @@ -451,22 +427,7 @@ struct TrackerView: View { func toggleExpanded(for bookmark: Bookmark) { guard bookmark.type == .tracker else { return } - - if self.expandedTrackers.contains(bookmark) { - // Collapse: cancel ongoing fetch and clear data - self.fetchTasks[bookmark]?.cancel() - self.fetchTasks[bookmark] = nil - self.expandedTrackers.remove(bookmark) - self.trackerServers[bookmark] = nil - self.loadingTrackers.remove(bookmark) - } else { - // Expand: start fetch task - self.expandedTrackers.insert(bookmark) - let task = Task { - await self.fetchServers(for: bookmark) - } - self.fetchTasks[bookmark] = task - } + self.setExpanded(!self.expandedTrackers.contains(bookmark), for: bookmark) } func setExpanded(_ expanded: Bool, for bookmark: Bookmark) { @@ -678,8 +639,6 @@ struct TrackerBookmarkServerView: View { var body: some View { HStack(alignment: .center, spacing: 6) { - Spacer() - .frame(width: 14 + 8 + 16) Image("Server") .resizable() .scaledToFit() @@ -720,6 +679,7 @@ struct TrackerItemView: View { let isLoading: Bool let count: Int let onToggleExpanded: () -> Void + @Environment(\.appearsActive) private var appearsActive var body: some View { HStack(alignment: .center, spacing: 6) { @@ -759,22 +719,20 @@ struct TrackerItemView: View { SpinningGlobeView() .fontWeight(.semibold) .frame(width: 12, height: 12) -// .opacity(0.5) } - - .padding(.horizontal, 8) + .padding(.horizontal, 6) .padding(.vertical, 2) .foregroundStyle(.secondary) - .background(.quinary) +// .background(.quinary) .clipShape(.capsule) } case .server: Image(systemName: "bookmark.fill") .resizable() - .renderingMode(.template) - .aspectRatio(contentMode: .fit) + .scaledToFit() + .foregroundStyle(Color.secondary) .frame(width: 11, height: 11, alignment: .center) - .opacity(0.5) + .opacity(0.75) .padding(.leading, 3) .padding(.trailing, 2) Image("Server") -- cgit From d23d0ad9405c1622569415e6ce16d3768af2936a Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Tue, 28 Oct 2025 09:33:19 -0700 Subject: New default banner artwork. Theme toolbar to server's banner (if any). --- Hotline.xcodeproj/project.pbxproj | 4 + .../Default Banner.imageset/Contents.json | 6 +- .../Default Banner.imageset/Default Banner.png | Bin 0 -> 54979 bytes .../Default Banner.imageset/Default Banner@2x.png | Bin 0 -> 218553 bytes .../Default Banner.imageset/Default Banner@3x.png | Bin 0 -> 474621 bytes .../Default Banner.imageset/Frame 2.png | Bin 59733 -> 0 bytes .../Default Banner.imageset/Frame 2@2x.png | Bin 207508 -> 0 bytes .../Default Banner.imageset/Frame 2@3x.png | Bin 386312 -> 0 bytes Hotline/Utility/ColorArt.swift | 376 +++++++++++++++++++++ Hotline/macOS/Chat/ChatView.swift | 3 +- Hotline/macOS/HotlinePanelView.swift | 25 +- 11 files changed, 402 insertions(+), 12 deletions(-) create mode 100644 Hotline/Assets.xcassets/Default Banner.imageset/Default Banner.png create mode 100644 Hotline/Assets.xcassets/Default Banner.imageset/Default Banner@2x.png create mode 100644 Hotline/Assets.xcassets/Default Banner.imageset/Default Banner@3x.png delete mode 100644 Hotline/Assets.xcassets/Default Banner.imageset/Frame 2.png delete mode 100644 Hotline/Assets.xcassets/Default Banner.imageset/Frame 2@2x.png delete mode 100644 Hotline/Assets.xcassets/Default Banner.imageset/Frame 2@3x.png create mode 100644 Hotline/Utility/ColorArt.swift (limited to 'Hotline/macOS') diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index a173d55..5c0b943 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -32,6 +32,7 @@ DA5268A02EB073BC00DCB941 /* SoundSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA52689F2EB073BC00DCB941 /* SoundSettingsView.swift */; platformFilters = (macos, ); }; DA5268A32EB0741B00DCB941 /* FolderItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268A22EB0741B00DCB941 /* FolderItemView.swift */; platformFilters = (macos, ); }; DA5268A52EB0743000DCB941 /* FileItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268A42EB0743000DCB941 /* FileItemView.swift */; platformFilters = (macos, ); }; + DA5268AB2EB11EA300DCB941 /* ColorArt.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268AA2EB11EA300DCB941 /* ColorArt.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, ); }; @@ -128,6 +129,7 @@ DA52689F2EB073BC00DCB941 /* SoundSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundSettingsView.swift; sourceTree = ""; }; DA5268A22EB0741B00DCB941 /* FolderItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderItemView.swift; sourceTree = ""; }; DA5268A42EB0743000DCB941 /* FileItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileItemView.swift; sourceTree = ""; }; + DA5268AA2EB11EA300DCB941 /* ColorArt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorArt.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 = ""; }; @@ -397,6 +399,7 @@ isa = PBXGroup; children = ( DABFCC282B1530DC009F40D2 /* FoundationExtensions.swift */, + DA5268AA2EB11EA300DCB941 /* ColorArt.swift */, DAC002182B21630900A6C290 /* SwiftUIExtensions.swift */, DA7725402B21435B006C5ABB /* ObservableScrollView.swift */, DAE735062B3251B3000C56F6 /* SoundEffects.swift */, @@ -554,6 +557,7 @@ DA65499C2BEC3FBD00EDB697 /* ServerAgreementView.swift in Sources */, DAB4D8802B4C8E9A0048A05C /* URLAdditions.swift in Sources */, DA2863DD2B3E8B7000A7D050 /* FilePreview.swift in Sources */, + DA5268AB2EB11EA300DCB941 /* ColorArt.swift in Sources */, DA2863D82B37AD1C00A7D050 /* SettingsView.swift in Sources */, DA7725412B21435B006C5ABB /* ObservableScrollView.swift in Sources */, DAE734FF2B2E6750000C56F6 /* ChatView.swift in Sources */, diff --git a/Hotline/Assets.xcassets/Default Banner.imageset/Contents.json b/Hotline/Assets.xcassets/Default Banner.imageset/Contents.json index 1abbabf..ba0af63 100644 --- a/Hotline/Assets.xcassets/Default Banner.imageset/Contents.json +++ b/Hotline/Assets.xcassets/Default Banner.imageset/Contents.json @@ -1,17 +1,17 @@ { "images" : [ { - "filename" : "Frame 2.png", + "filename" : "Default Banner.png", "idiom" : "universal", "scale" : "1x" }, { - "filename" : "Frame 2@2x.png", + "filename" : "Default Banner@2x.png", "idiom" : "universal", "scale" : "2x" }, { - "filename" : "Frame 2@3x.png", + "filename" : "Default Banner@3x.png", "idiom" : "universal", "scale" : "3x" } diff --git a/Hotline/Assets.xcassets/Default Banner.imageset/Default Banner.png b/Hotline/Assets.xcassets/Default Banner.imageset/Default Banner.png new file mode 100644 index 0000000..d960610 Binary files /dev/null and b/Hotline/Assets.xcassets/Default Banner.imageset/Default Banner.png differ diff --git a/Hotline/Assets.xcassets/Default Banner.imageset/Default Banner@2x.png b/Hotline/Assets.xcassets/Default Banner.imageset/Default Banner@2x.png new file mode 100644 index 0000000..4ce4eab Binary files /dev/null and b/Hotline/Assets.xcassets/Default Banner.imageset/Default Banner@2x.png differ diff --git a/Hotline/Assets.xcassets/Default Banner.imageset/Default Banner@3x.png b/Hotline/Assets.xcassets/Default Banner.imageset/Default Banner@3x.png new file mode 100644 index 0000000..13c2236 Binary files /dev/null and b/Hotline/Assets.xcassets/Default Banner.imageset/Default Banner@3x.png differ diff --git a/Hotline/Assets.xcassets/Default Banner.imageset/Frame 2.png b/Hotline/Assets.xcassets/Default Banner.imageset/Frame 2.png deleted file mode 100644 index fae0905..0000000 Binary files a/Hotline/Assets.xcassets/Default Banner.imageset/Frame 2.png and /dev/null differ diff --git a/Hotline/Assets.xcassets/Default Banner.imageset/Frame 2@2x.png b/Hotline/Assets.xcassets/Default Banner.imageset/Frame 2@2x.png deleted file mode 100644 index 163e634..0000000 Binary files a/Hotline/Assets.xcassets/Default Banner.imageset/Frame 2@2x.png and /dev/null differ diff --git a/Hotline/Assets.xcassets/Default Banner.imageset/Frame 2@3x.png b/Hotline/Assets.xcassets/Default Banner.imageset/Frame 2@3x.png deleted file mode 100644 index 3f2ef8f..0000000 Binary files a/Hotline/Assets.xcassets/Default Banner.imageset/Frame 2@3x.png and /dev/null differ diff --git a/Hotline/Utility/ColorArt.swift b/Hotline/Utility/ColorArt.swift new file mode 100644 index 0000000..abbe04b --- /dev/null +++ b/Hotline/Utility/ColorArt.swift @@ -0,0 +1,376 @@ + +// ColorArt.swift +// SLColorArt by Panic Inc. +// Swift translation by Dustin Mierau +// +// Copyright (C) 2012 Panic Inc. Code by Wade Cosgrove. All rights reserved. +// +// Redistribution and use, with or without modification, are permitted +// provided that the following conditions are met: +// +// - Redistributions must reproduce the above copyright notice, this list of +// conditions and the following disclaimer in the documentation and/or other +// materials provided with the distribution. +// +// - Neither the name of Panic Inc nor the names of its contributors may be used +// to endorse or promote works derived from this software without specific prior +// written permission from Panic Inc. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +// ARE DISCLAIMED. IN NO EVENT SHALL PANIC INC BE LIABLE FOR ANY DIRECT, INDIRECT, +// INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +// PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +// POSSIBILITY OF SUCH DAMAGE. + +import AppKit +import SwiftUI + +fileprivate let kColorThresholdMinimumPercentage: CGFloat = 0.001 + +struct ColorArt { + let backgroundColor: NSColor + let primaryColor: NSColor + let secondaryColor: NSColor + let detailColor: NSColor + let scaledImage: NSImage + + init?(image: NSImage, scaledSize: NSSize = .zero) { + let finalImage = Self.scaleImage(image, size: scaledSize) + self.scaledImage = finalImage + + guard let colors = Self.analyzeImage(finalImage) else { + return nil + } + + self.backgroundColor = colors.background + self.primaryColor = colors.primary + self.secondaryColor = colors.secondary + self.detailColor = colors.detail + } + + // MARK: - Image Scaling + + private static func scaleImage(_ image: NSImage, size scaledSize: NSSize) -> NSImage { + let imageSize = image.size + let squareImage = NSImage(size: NSSize(width: imageSize.width, height: imageSize.width)) + var drawRect: NSRect + + // Make the image square + if imageSize.height > imageSize.width { + drawRect = NSRect(x: 0, y: imageSize.height - imageSize.width, width: imageSize.width, height: imageSize.width) + } else { + drawRect = NSRect(x: 0, y: 0, width: imageSize.height, height: imageSize.height) + } + + // Use native square size if passed zero size + let finalScaledSize = scaledSize == .zero ? drawRect.size : scaledSize + let scaledImage = NSImage(size: finalScaledSize) + + squareImage.lockFocus() + image.draw(in: NSRect(x: 0, y: 0, width: imageSize.width, height: imageSize.width), from: drawRect, operation: .sourceOver, fraction: 1.0) + squareImage.unlockFocus() + + // Scale the image to the desired size + scaledImage.lockFocus() + squareImage.draw(in: NSRect(x: 0, y: 0, width: finalScaledSize.width, height: finalScaledSize.height), from: .zero, operation: .sourceOver, fraction: 1.0) + scaledImage.unlockFocus() + + // Convert back to readable bitmap data + guard let cgImage = scaledImage.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + return scaledImage + } + + let bitmapRep = NSBitmapImageRep(cgImage: cgImage) + let finalImage = NSImage(size: scaledImage.size) + finalImage.addRepresentation(bitmapRep) + + return finalImage + } + + // MARK: - Image Analysis + + private static func analyzeImage(_ image: NSImage) -> (background: NSColor, primary: NSColor, secondary: NSColor, detail: NSColor)? { + var imageColors: NSCountedSet? + guard let backgroundColor = self.findEdgeColor(image, imageColors: &imageColors), + let colors = imageColors + else { + return nil + } + + let darkBackground = backgroundColor.isDarkColor + var primaryColor: NSColor? + var secondaryColor: NSColor? + var detailColor: NSColor? + + self.findTextColors(colors, primaryColor: &primaryColor, secondaryColor: &secondaryColor, detailColor: &detailColor, backgroundColor: backgroundColor) + + // Fallback to black or white if colors not found + if primaryColor == nil { + primaryColor = darkBackground ? .white : .black + } + + if secondaryColor == nil { + secondaryColor = darkBackground ? .white : .black + } + + if detailColor == nil { + detailColor = darkBackground ? .white : .black + } + + return (backgroundColor, primaryColor!, secondaryColor!, detailColor!) + } + + // MARK: - Edge Color Detection + + private static func findEdgeColor(_ image: NSImage, imageColors: inout NSCountedSet?) -> NSColor? { + guard var imageRep = image.representations.last else { + return nil + } + + if !(imageRep is NSBitmapImageRep) { + image.lockFocus() + imageRep = NSBitmapImageRep(focusedViewRect: NSRect(x: 0, y: 0, width: image.size.width, height: image.size.height))! + image.unlockFocus() + } + + // Convert to RGB color space + guard let bitmapRep = (imageRep as? NSBitmapImageRep)?.converting(to: .genericRGB, renderingIntent: .default) else { + return nil + } + + let pixelsWide = bitmapRep.pixelsWide + let pixelsHigh = bitmapRep.pixelsHigh + + let colors = NSCountedSet(capacity: pixelsWide * pixelsHigh) + let leftEdgeColors = NSCountedSet(capacity: pixelsHigh) + var searchColumnX = 0 + + for x in 0.. 0.5 { + leftEdgeColors.add(color) + } + } + + if color.alphaComponent > CGFloat.ulpOfOne { + colors.add(color) + } + } + + // Background is clear, keep looking in next column for background color + if leftEdgeColors.count == 0 { + searchColumnX += 1 + } + } + + imageColors = colors + + var sortedColors: [CountedColor] = [] + + for color in leftEdgeColors { + guard let nsColor = color as? NSColor else { continue } + let colorCount = leftEdgeColors.count(for: nsColor) + + let randomColorsThreshold = Int(CGFloat(pixelsHigh) * kColorThresholdMinimumPercentage) + + if colorCount <= randomColorsThreshold { + continue + } + + sortedColors.append(CountedColor(color: nsColor, count: colorCount)) + } + + sortedColors.sort { $0.count > $1.count } + + guard var proposedEdgeColor = sortedColors.first else { + return nil + } + + // Want to choose color over black/white so we keep looking + if proposedEdgeColor.color.isBlackOrWhite { + for i in 1.. 0.3 { + if !nextProposedColor.color.isBlackOrWhite { + proposedEdgeColor = nextProposedColor + break + } + } else { + // Reached color threshold less than 30% of the original proposed edge color + break + } + } + } + + return proposedEdgeColor.color + } + + // MARK: - Text Color Detection + + private static func findTextColors(_ colors: NSCountedSet, primaryColor: inout NSColor?, secondaryColor: inout NSColor?, detailColor: inout NSColor?, backgroundColor: NSColor) { + var sortedColors: [CountedColor] = [] + let findDarkTextColor = !backgroundColor.isDarkColor + + for color in colors { + guard let nsColor = color as? NSColor else { continue } + let adjustedColor = nsColor.withMinimumSaturation(0.15) + + if adjustedColor.isDarkColor == findDarkTextColor { + let colorCount = colors.count(for: nsColor) + sortedColors.append(CountedColor(color: adjustedColor, count: colorCount)) + } + } + + sortedColors.sort { $0.count > $1.count } + + for container in sortedColors { + let curColor = container.color + + if primaryColor == nil { + if curColor.isContrasting(to: backgroundColor) { + primaryColor = curColor + } + } else if secondaryColor == nil { + if let primary = primaryColor, + primary.isDistinct(from: curColor) && curColor.isContrasting(to: backgroundColor) { + secondaryColor = curColor + } + } else if detailColor == nil { + if let primary = primaryColor, + let secondary = secondaryColor, + secondary.isDistinct(from: curColor) && + primary.isDistinct(from: curColor) && + curColor.isContrasting(to: backgroundColor) { + detailColor = curColor + break + } + } + } + } +} + +// MARK: - Helper Classes + +fileprivate struct CountedColor { + let color: NSColor + let count: Int +} + +// MARK: - NSColor Extensions + +extension NSColor { + var isDarkColor: Bool { + guard let convertedColor = usingColorSpace(.genericRGB) else { + return false + } + + var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + convertedColor.getRed(&r, green: &g, blue: &b, alpha: &a) + + let lum = 0.2126 * r + 0.7152 * g + 0.0722 * b + + return lum < 0.5 + } + + func isDistinct(from compareColor: NSColor) -> Bool { + guard let convertedColor = usingColorSpace(.genericRGB), + let convertedCompareColor = compareColor.usingColorSpace(.genericRGB) + else { + return false + } + + var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + var r1: CGFloat = 0, g1: CGFloat = 0, b1: CGFloat = 0, a1: CGFloat = 0 + + convertedColor.getRed(&r, green: &g, blue: &b, alpha: &a) + convertedCompareColor.getRed(&r1, green: &g1, blue: &b1, alpha: &a1) + + let threshold: CGFloat = 0.25 + + if abs(r - r1) > threshold || abs(g - g1) > threshold || abs(b - b1) > threshold || abs(a - a1) > threshold { + // Check for grays, prevent multiple gray colors + if abs(r - g) < 0.03 && abs(r - b) < 0.03 { + if abs(r1 - g1) < 0.03 && abs(r1 - b1) < 0.03 { + return false + } + } + + return true + } + + return false + } + + func withMinimumSaturation(_ minSaturation: CGFloat) -> NSColor { + guard let tempColor = usingColorSpace(.genericRGB) else { + return self + } + + var hue: CGFloat = 0, saturation: CGFloat = 0, brightness: CGFloat = 0, alpha: CGFloat = 0 + tempColor.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) + + if saturation < minSaturation { + return NSColor(calibratedHue: hue, saturation: minSaturation, brightness: brightness, alpha: alpha) + } + + return self + } + + var isBlackOrWhite: Bool { + guard let tempColor = usingColorSpace(.genericRGB) else { + return false + } + + var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + tempColor.getRed(&r, green: &g, blue: &b, alpha: &a) + + // White + if r > 0.91 && g > 0.91 && b > 0.91 { + return true + } + + // Black + if r < 0.09 && g < 0.09 && b < 0.09 { + return true + } + + return false + } + + func isContrasting(to color: NSColor) -> Bool { + guard let backgroundColor = usingColorSpace(.genericRGB), + let foregroundColor = color.usingColorSpace(.genericRGB) + else { + return true + } + + var br: CGFloat = 0, bg: CGFloat = 0, bb: CGFloat = 0, ba: CGFloat = 0 + var fr: CGFloat = 0, fg: CGFloat = 0, fb: CGFloat = 0, fa: CGFloat = 0 + + backgroundColor.getRed(&br, green: &bg, blue: &bb, alpha: &ba) + foregroundColor.getRed(&fr, green: &fg, blue: &fb, alpha: &fa) + + let bLum = 0.2126 * br + 0.7152 * bg + 0.0722 * bb + let fLum = 0.2126 * fr + 0.7152 * fg + 0.0722 * fb + + let contrast: CGFloat + if bLum > fLum { + contrast = (bLum + 0.05) / (fLum + 0.05) + } else { + contrast = (fLum + 0.05) / (bLum + 0.05) + } + + return contrast > 1.6 + } +} diff --git a/Hotline/macOS/Chat/ChatView.swift b/Hotline/macOS/Chat/ChatView.swift index 65087c6..0795856 100644 --- a/Hotline/macOS/Chat/ChatView.swift +++ b/Hotline/macOS/Chat/ChatView.swift @@ -192,8 +192,9 @@ struct ChatView: View { model.markPublicChatAsRead() } .onAppear { - reader.scrollTo(bottomID, anchor: .bottom) self.focusedField = .chatInput + model.markPublicChatAsRead() + reader.scrollTo(bottomID, anchor: .bottom) } .onChange(of: self.model.bannerImage) { reader.scrollTo(bottomID, anchor: .bottom) diff --git a/Hotline/macOS/HotlinePanelView.swift b/Hotline/macOS/HotlinePanelView.swift index fd43c15..4ae4924 100644 --- a/Hotline/macOS/HotlinePanelView.swift +++ b/Hotline/macOS/HotlinePanelView.swift @@ -3,7 +3,14 @@ import SwiftUI struct HotlinePanelView: View { @Environment(\.openWindow) var openWindow @Environment(\.colorScheme) var colorScheme - + + private var colorArt: ColorArt? { + if let banner = AppState.shared.activeServerBanner { + return ColorArt(image: banner, scaledSize: NSSize(width: 100, height: 100)) + } + return nil + } + var body: some View { VStack(spacing: 0) { Image(nsImage: AppState.shared.activeServerBanner ?? NSImage(named: "Default Banner")!) @@ -34,7 +41,7 @@ struct HotlinePanelView: View { .buttonStyle(.plain) .frame(width: 20, height: 20) .help("Hotline Servers") - + Button { AppState.shared.activeServerState?.selection = .chat } @@ -47,7 +54,7 @@ struct HotlinePanelView: View { .frame(width: 20, height: 20) .disabled(AppState.shared.activeServerState == nil) .help("Public Chat") - + Button { AppState.shared.activeServerState?.selection = .board } @@ -60,7 +67,7 @@ struct HotlinePanelView: View { .frame(width: 20, height: 20) .disabled(AppState.shared.activeServerState == nil) .help("Message Board") - + Button { AppState.shared.activeServerState?.selection = .news } @@ -73,7 +80,7 @@ struct HotlinePanelView: View { .frame(width: 20, height: 20) .disabled(AppState.shared.activeServerState == nil || (AppState.shared.activeHotline?.serverVersion ?? 0) < 151) .help("News") - + Button { AppState.shared.activeServerState?.selection = .files } @@ -86,9 +93,9 @@ struct HotlinePanelView: View { .frame(width: 20, height: 20) .disabled(AppState.shared.activeServerState == nil) .help("Files") - + Spacer() - + if AppState.shared.activeHotline?.access?.contains(.canOpenUsers) == true { Button { AppState.shared.activeServerState?.selection = .accounts @@ -103,7 +110,7 @@ struct HotlinePanelView: View { .disabled(AppState.shared.activeServerState == nil) .help("Accounts") } - + SettingsLink(label: { Image("Section Settings") .resizable() @@ -116,6 +123,8 @@ struct HotlinePanelView: View { .padding(.top, 12) .padding(.bottom, 12) .padding([.leading, .trailing], 12) + .background(colorArt.map { Color(nsColor: $0.backgroundColor) } ?? Color(nsColor: .controlBackgroundColor)) + .foregroundStyle(colorArt.map { Color(nsColor: $0.primaryColor) } ?? Color.primary) // .background(Color.red.opacity(0.5).blendMode(.multiply)) // GroupBox { -- cgit From c5165d348c1efbcc553b3c31dbeaa23353914fb6 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Tue, 28 Oct 2025 10:06:33 -0700 Subject: Some state cleanup and banner color caching in ServerState. --- Hotline/MacApp.swift | 16 +--------------- Hotline/State/AppState.swift | 13 ++++--------- Hotline/Utility/ColorArt.swift | 11 +++++++++-- Hotline/macOS/HotlinePanelView.swift | 13 +++---------- Hotline/macOS/ServerView.swift | 22 +++++++++++++++++++--- 5 files changed, 36 insertions(+), 39 deletions(-) (limited to 'Hotline/macOS') diff --git a/Hotline/MacApp.swift b/Hotline/MacApp.swift index dd7e190..89db36e 100644 --- a/Hotline/MacApp.swift +++ b/Hotline/MacApp.swift @@ -192,26 +192,12 @@ struct Application: App { .defaultSize(width: 690, height: 760) .defaultPosition(.center) .onChange(of: activeServerState) { - AppState.shared.activeServerState = activeServerState - } - .onChange(of: activeHotline) { - AppState.shared.activeHotline = activeHotline - } - .onChange(of: activeHotline?.serverTitle) { - if let hotline = activeHotline { - AppState.shared.activeServerName = hotline.serverTitle - } - } - .onChange(of: activeHotline?.bannerImage) { withAnimation { - AppState.shared.activeServerBanner = activeHotline?.bannerImage + AppState.shared.activeServerState = activeServerState } } .onChange(of: activeHotline) { AppState.shared.activeHotline = activeHotline - if let hotline = activeHotline { - AppState.shared.activeServerName = hotline.serverTitle - } } .commands { CommandGroup(replacing: .newItem) { diff --git a/Hotline/State/AppState.swift b/Hotline/State/AppState.swift index 11bae74..3917ad0 100644 --- a/Hotline/State/AppState.swift +++ b/Hotline/State/AppState.swift @@ -7,18 +7,13 @@ extension EnvironmentValues { @Observable final class AppState { static let shared = AppState() - + private init() { - + } - + var activeHotline: Hotline? = nil var activeServerState: ServerState? = nil - - // Frontmost server window information - var activeServerID: UUID? = nil - var activeServerBanner: NSImage? = nil - var activeServerName: String? = nil - + var cloudKitReady: Bool = false } diff --git a/Hotline/Utility/ColorArt.swift b/Hotline/Utility/ColorArt.swift index abbe04b..1f99f0f 100644 --- a/Hotline/Utility/ColorArt.swift +++ b/Hotline/Utility/ColorArt.swift @@ -32,13 +32,20 @@ import SwiftUI fileprivate let kColorThresholdMinimumPercentage: CGFloat = 0.001 -struct ColorArt { +struct ColorArt: Equatable { let backgroundColor: NSColor let primaryColor: NSColor let secondaryColor: NSColor let detailColor: NSColor let scaledImage: NSImage - + + static func == (lhs: ColorArt, rhs: ColorArt) -> Bool { + return lhs.backgroundColor == rhs.backgroundColor && + lhs.primaryColor == rhs.primaryColor && + lhs.secondaryColor == rhs.secondaryColor && + lhs.detailColor == rhs.detailColor + } + init?(image: NSImage, scaledSize: NSSize = .zero) { let finalImage = Self.scaleImage(image, size: scaledSize) self.scaledImage = finalImage diff --git a/Hotline/macOS/HotlinePanelView.swift b/Hotline/macOS/HotlinePanelView.swift index 4ae4924..ee4d198 100644 --- a/Hotline/macOS/HotlinePanelView.swift +++ b/Hotline/macOS/HotlinePanelView.swift @@ -4,16 +4,9 @@ struct HotlinePanelView: View { @Environment(\.openWindow) var openWindow @Environment(\.colorScheme) var colorScheme - private var colorArt: ColorArt? { - if let banner = AppState.shared.activeServerBanner { - return ColorArt(image: banner, scaledSize: NSSize(width: 100, height: 100)) - } - return nil - } - var body: some View { VStack(spacing: 0) { - Image(nsImage: AppState.shared.activeServerBanner ?? NSImage(named: "Default Banner")!) + Image(nsImage: AppState.shared.activeServerState?.serverBanner ?? NSImage(named: "Default Banner")!) .interpolation(.high) .resizable() .scaledToFill() @@ -123,8 +116,8 @@ struct HotlinePanelView: View { .padding(.top, 12) .padding(.bottom, 12) .padding([.leading, .trailing], 12) - .background(colorArt.map { Color(nsColor: $0.backgroundColor) } ?? Color(nsColor: .controlBackgroundColor)) - .foregroundStyle(colorArt.map { Color(nsColor: $0.primaryColor) } ?? Color.primary) + .background(AppState.shared.activeServerState?.bannerColors.map { Color(nsColor: $0.backgroundColor) } ?? Color(nsColor: .controlBackgroundColor)) + .foregroundStyle(AppState.shared.activeServerState?.bannerColors.map { Color(nsColor: $0.primaryColor) } ?? Color.primary) // .background(Color.red.opacity(0.5).blendMode(.multiply)) // GroupBox { diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index 77b6dc8..6d5743c 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -6,11 +6,14 @@ import AppKit class ServerState: Equatable { var id: UUID = UUID() var selection: ServerNavigationType - + var serverName: String? = nil + var serverBanner: NSImage? = nil + var bannerColors: ColorArt? = nil + init(selection: ServerNavigationType) { self.selection = selection } - + static func == (lhs: ServerState, rhs: ServerState) -> Bool { return lhs.id == rhs.id } @@ -104,7 +107,7 @@ extension FocusedValues { get { self[ActiveHotlineModelFocusedValueKey.self] } set { self[ActiveHotlineModelFocusedValueKey.self] = newValue } } - + var activeServerState: ServerState? { get { self[ActiveServerStateFocusedValueKey.self] } set { self[ActiveServerStateFocusedValueKey.self] = newValue } @@ -238,6 +241,19 @@ struct ServerView: View { .onDisappear { model.disconnect() } + .onChange(of: model.serverTitle) { oldTitle, newTitle in + state.serverName = newTitle + } + .onChange(of: model.bannerImage) { oldBanner, newBanner in + withAnimation { + state.serverBanner = newBanner + if let banner = newBanner { + state.bannerColors = ColorArt(image: banner, scaledSize: NSSize(width: 100, height: 100)) + } else { + state.bannerColors = nil + } + } + } .alert(model.errorMessage ?? "Server Error", isPresented: $model.errorDisplayed) { Button("OK") {} } -- cgit From 8fdad8745921ea0bc43a9efd2beafc1a1ce3142b Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Tue, 28 Oct 2025 10:12:49 -0700 Subject: Pop ServerState out into its own file. --- Hotline.xcodeproj/project.pbxproj | 4 +++ Hotline/State/ServerState.swift | 44 ++++++++++++++++++++++++ Hotline/macOS/ServerView.swift | 71 ++------------------------------------- 3 files changed, 50 insertions(+), 69 deletions(-) create mode 100644 Hotline/State/ServerState.swift (limited to 'Hotline/macOS') diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index 5c0b943..03f170c 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -33,6 +33,7 @@ DA5268A32EB0741B00DCB941 /* FolderItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268A22EB0741B00DCB941 /* FolderItemView.swift */; platformFilters = (macos, ); }; DA5268A52EB0743000DCB941 /* FileItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268A42EB0743000DCB941 /* FileItemView.swift */; platformFilters = (macos, ); }; DA5268AB2EB11EA300DCB941 /* ColorArt.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268AA2EB11EA300DCB941 /* ColorArt.swift */; }; + DA5268AD2EB12FE200DCB941 /* ServerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268AC2EB12FE200DCB941 /* ServerState.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, ); }; @@ -130,6 +131,7 @@ DA5268A22EB0741B00DCB941 /* FolderItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderItemView.swift; sourceTree = ""; }; DA5268A42EB0743000DCB941 /* FileItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileItemView.swift; sourceTree = ""; }; DA5268AA2EB11EA300DCB941 /* ColorArt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorArt.swift; sourceTree = ""; }; + DA5268AC2EB12FE200DCB941 /* ServerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerState.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 = ""; }; @@ -214,6 +216,7 @@ isa = PBXGroup; children = ( DAC3D9822BC33FD000A727C9 /* AppState.swift */, + DA5268AC2EB12FE200DCB941 /* ServerState.swift */, DA2863D92B37BF6E00A7D050 /* Preferences.swift */, DACCE5E02EABE4B4008CDD92 /* AppUpdate.swift */, ); @@ -600,6 +603,7 @@ DABFCC292B1530DC009F40D2 /* FoundationExtensions.swift in Sources */, DA57536C2B36BA1D00FAC277 /* TextDocument.swift in Sources */, DACCE5E12EABE4B4008CDD92 /* AppUpdate.swift in Sources */, + DA5268AD2EB12FE200DCB941 /* ServerState.swift in Sources */, DA77253F2B21176D006C5ABB /* NewsView.swift in Sources */, DAC6B2E22EAEE9FE004E2CBA /* NetSocketNew.swift in Sources */, DA99218E2C51AA5D0058FA6C /* HotlineDataBuilder.swift in Sources */, diff --git a/Hotline/State/ServerState.swift b/Hotline/State/ServerState.swift new file mode 100644 index 0000000..e2fa40f --- /dev/null +++ b/Hotline/State/ServerState.swift @@ -0,0 +1,44 @@ +import SwiftUI + +@Observable +class ServerState: Equatable { + var id: UUID = UUID() + var selection: ServerNavigationType + var serverName: String? = nil + var serverBanner: NSImage? = nil + var bannerColors: ColorArt? = nil + + init(selection: ServerNavigationType) { + self.selection = selection + } + + static func == (lhs: ServerState, rhs: ServerState) -> Bool { + return lhs.id == rhs.id + } +} + +enum ServerNavigationType: Identifiable, Hashable, Equatable { + var id: String { + switch self { + case .chat: + return "Chat" + case .news: + return "News" + case .board: + return "Board" + case .files: + return "Files" + case .accounts: + return "Accounts" + case .user(let userID): + return String(userID) + } + } + + case chat + case news + case board + case files + case accounts + case user(userID: UInt16) +} diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index 6d5743c..e18d630 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -2,32 +2,6 @@ import SwiftUI import UniformTypeIdentifiers import AppKit -@Observable -class ServerState: Equatable { - var id: UUID = UUID() - var selection: ServerNavigationType - var serverName: String? = nil - var serverBanner: NSImage? = nil - var bannerColors: ColorArt? = nil - - init(selection: ServerNavigationType) { - self.selection = selection - } - - static func == (lhs: ServerState, rhs: ServerState) -> Bool { - return lhs.id == rhs.id - } -} - -enum MenuItemType { - case chat - case news - case messageBoard - case files - case tasks - case user -} - struct ServerMenuItem: Identifiable, Hashable { let id: UUID let type: ServerNavigationType @@ -94,50 +68,9 @@ struct ListItemView: View { } } -struct ActiveHotlineModelFocusedValueKey: FocusedValueKey { - typealias Value = Hotline -} - -struct ActiveServerStateFocusedValueKey: FocusedValueKey { - typealias Value = ServerState -} - extension FocusedValues { - var activeHotlineModel: Hotline? { - get { self[ActiveHotlineModelFocusedValueKey.self] } - set { self[ActiveHotlineModelFocusedValueKey.self] = newValue } - } - - var activeServerState: ServerState? { - get { self[ActiveServerStateFocusedValueKey.self] } - set { self[ActiveServerStateFocusedValueKey.self] = newValue } - } -} - -enum ServerNavigationType: Identifiable, Hashable, Equatable { - var id: String { - switch self { - case .chat: - return "Chat" - case .news: - return "News" - case .board: - return "Board" - case .files: - return "Files" - case .accounts: - return "Accounts" - case .user(let userID): - return String(userID) - } - } - - case chat - case news - case board - case files - case accounts - case user(userID: UInt16) + @Entry var activeHotlineModel: Hotline? + @Entry var activeServerState: ServerState? } struct ServerView: View { -- cgit From 87f08cf60a5d7c1cf618463916cbac4dab88e0f8 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 7 Nov 2025 10:19:42 -0800 Subject: Massive refactor of transfer cients (new folder upload implementation), brand new async version of NetSocket, and a rewritten Hotline view model. New cross-server transfers UI. --- Hotline.xcodeproj/project.pbxproj | 86 +- .../xcshareddata/xcschemes/Hotline.xcscheme | 1 + Hotline/Hotline/HotlineClientNew.swift | 1061 ++++++ Hotline/Hotline/HotlineExtensions.swift | 143 +- Hotline/Hotline/HotlineProtocol.swift | 317 +- Hotline/Hotline/HotlineTransferClient.swift | 3618 ++++++++++---------- .../Transfers/HotlineFileDownloadClientNew.swift | 291 ++ .../Transfers/HotlineFilePreviewClientNew.swift | 163 + .../Transfers/HotlineFileUploadClientNew.swift | 265 ++ .../Transfers/HotlineFolderDownloadClientNew.swift | 486 +++ .../Transfers/HotlineFolderUploadClientNew.swift | 538 +++ Hotline/Info.plist | 6 +- Hotline/Library/HotlinePanel.swift | 6 +- Hotline/Library/NetSocket/FileProgress.swift | 58 + Hotline/Library/NetSocket/NetSocketNew.swift | 1129 ++++++ .../Library/NetSocket/TransferRateEstimator.swift | 135 + Hotline/Library/NetSocketNew.swift | 1278 ------- Hotline/Library/QuickLookPreviewView.swift | 22 + Hotline/Library/URLAdditions.swift | 29 + Hotline/MacApp.swift | 37 +- Hotline/Models/FileDetails.swift | 4 +- Hotline/Models/FileInfo.swift | 16 +- Hotline/Models/FilePreview.swift | 226 +- Hotline/Models/Hotline.swift | 8 +- Hotline/Models/HotlineState.swift | 2468 +++++++++++++ Hotline/Models/PreviewFileInfo.swift | 6 - Hotline/Models/TransferInfo.swift | 26 +- Hotline/State/AppState.swift | 58 +- Hotline/State/FilePreviewState.swift | 207 ++ Hotline/State/ServerState.swift | 4 +- Hotline/Utility/ColorArt.swift | 147 +- Hotline/Utility/SwiftUIExtensions.swift | 25 - Hotline/iOS/ChatView.swift | 2 +- Hotline/iOS/ServerView.swift | 2 +- Hotline/macOS/Accounts/AccountManagerView.swift | 44 +- Hotline/macOS/Board/MessageBoardEditorView.swift | 14 +- Hotline/macOS/Board/MessageBoardView.swift | 6 +- Hotline/macOS/Chat/ChatView.swift | 77 +- Hotline/macOS/Files/FileDetailsView.swift | 6 +- Hotline/macOS/Files/FileItemView.swift | 2 +- Hotline/macOS/Files/FilePreviewImageView.swift | 11 +- Hotline/macOS/Files/FilePreviewQuickLookView.swift | 131 + Hotline/macOS/Files/FilePreviewTextView.swift | 9 +- Hotline/macOS/Files/FilesView.swift | 103 +- Hotline/macOS/Files/FolderItemView.swift | 6 +- Hotline/macOS/HotlinePanelView.swift | 52 +- Hotline/macOS/MessageView.swift | 10 +- Hotline/macOS/News/NewsEditorView.swift | 19 +- Hotline/macOS/News/NewsItemView.swift | 8 +- Hotline/macOS/News/NewsView.swift | 12 +- Hotline/macOS/ServerView.swift | 165 +- Hotline/macOS/Settings/IconSettingsView.swift | 2 +- Hotline/macOS/TransfersView.swift | 169 + 53 files changed, 10126 insertions(+), 3588 deletions(-) create mode 100644 Hotline/Hotline/HotlineClientNew.swift create mode 100644 Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift create mode 100644 Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift create mode 100644 Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift create mode 100644 Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift create mode 100644 Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift create mode 100644 Hotline/Library/NetSocket/FileProgress.swift create mode 100644 Hotline/Library/NetSocket/NetSocketNew.swift create mode 100644 Hotline/Library/NetSocket/TransferRateEstimator.swift delete mode 100644 Hotline/Library/NetSocketNew.swift create mode 100644 Hotline/Library/QuickLookPreviewView.swift create mode 100644 Hotline/Models/HotlineState.swift create mode 100644 Hotline/State/FilePreviewState.swift create mode 100644 Hotline/macOS/Files/FilePreviewQuickLookView.swift create mode 100644 Hotline/macOS/TransfersView.swift (limited to 'Hotline/macOS') diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index 03f170c..a234fbc 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -22,8 +22,13 @@ 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 */; }; + 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 */; }; DA43205E2B1D615600FC8843 /* ServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA43205D2B1D615600FC8843 /* ServerView.swift */; platformFilter = ios; }; - DA4930BD2B4F8DD700822D0B /* NetSocket.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4930BC2B4F8DD700822D0B /* NetSocket.swift */; }; DA4B8F3A2EA6FB3C00CBFD53 /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4B8F392EA6FB3C00CBFD53 /* iOSApp.swift */; platformFilter = ios; }; DA4F2BF82B16A17200D8ADDC /* HotlineProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4F2BF72B16A17200D8ADDC /* HotlineProtocol.swift */; }; DA4F2C012B1A558E00D8ADDC /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4F2C002B1A558E00D8ADDC /* ChatView.swift */; platformFilter = ios; }; @@ -34,13 +39,19 @@ DA5268A52EB0743000DCB941 /* FileItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268A42EB0743000DCB941 /* FileItemView.swift */; platformFilters = (macos, ); }; DA5268AB2EB11EA300DCB941 /* ColorArt.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5268AA2EB11EA300DCB941 /* ColorArt.swift */; }; 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 */; }; + 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 */; }; 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, ); }; DA55AC792BE6A1AD00034857 /* RegularExpressions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA55AC782BE6A1AD00034857 /* RegularExpressions.swift */; }; DA5753682B33E88A00FAC277 /* HotlineTransferClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5753672B33E88A00FAC277 /* HotlineTransferClient.swift */; }; DA57536C2B36BA1D00FAC277 /* TextDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA57536B2B36BA1D00FAC277 /* TextDocument.swift */; }; - DA6300972B24036B0034CBFD /* HotlineClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6300962B24036B0034CBFD /* HotlineClient.swift */; }; DA65499A2BEC280E00EDB697 /* ServerMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6549992BEC280E00EDB697 /* ServerMessageView.swift */; }; DA65499C2BEC3FBD00EDB697 /* ServerAgreementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA65499B2BEC3FBD00EDB697 /* ServerAgreementView.swift */; }; DA65499E2BEC438A00EDB697 /* NSWindowBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA65499D2BEC438A00EDB697 /* NSWindowBridge.swift */; platformFilters = (macos, ); }; @@ -90,7 +101,6 @@ DACCE5EF2EAC19C9008CDD92 /* SwiftUIIntrospect in Frameworks */ = {isa = PBXBuildFile; productRef = DACCE5EE2EAC19C9008CDD92 /* SwiftUIIntrospect */; }; DADDB28B2B22B31F0024040D /* Tracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADDB28A2B22B31F0024040D /* Tracker.swift */; }; DADDB28D2B22B5920024040D /* Server.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADDB28C2B22B5920024040D /* Server.swift */; }; - DADDB28F2B238D850024040D /* Hotline.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADDB28E2B238D850024040D /* Hotline.swift */; }; DAE136BA2B9D1147007D8307 /* HotlinePanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAE136B92B9D1147007D8307 /* HotlinePanelView.swift */; platformFilters = (macos, ); }; DAE734F92B2E4185000C56F6 /* ServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAE734F82B2E4185000C56F6 /* ServerView.swift */; platformFilters = (macos, ); }; DAE734FB2B2E41F9000C56F6 /* TrackerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAE734FA2B2E41F9000C56F6 /* TrackerView.swift */; platformFilters = (macos, ); }; @@ -120,6 +130,12 @@ 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 = ""; }; + 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 = ""; }; DA43205D2B1D615600FC8843 /* ServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerView.swift; sourceTree = ""; }; DA4930BC2B4F8DD700822D0B /* NetSocket.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetSocket.swift; sourceTree = ""; }; DA4B8F392EA6FB3C00CBFD53 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; }; @@ -132,6 +148,13 @@ DA5268A42EB0743000DCB941 /* FileItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileItemView.swift; sourceTree = ""; }; DA5268AA2EB11EA300DCB941 /* ColorArt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorArt.swift; sourceTree = ""; }; 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 = ""; }; + 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 = ""; }; 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 = ""; }; @@ -212,6 +235,18 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + DA3429B12EBA73730010784E /* Transfers */ = { + isa = PBXGroup; + children = ( + DA3429B22EBA7ADE0010784E /* HotlineFilePreviewClientNew.swift */, + DA5268B22EB6806E00DCB941 /* HotlineFileDownloadClientNew.swift */, + DA3429AD2EB9C0220010784E /* HotlineFileUploadClientNew.swift */, + DA5268BB2EB95A7700DCB941 /* HotlineFolderDownloadClientNew.swift */, + DA3429AF2EBA70790010784E /* HotlineFolderUploadClientNew.swift */, + ); + path = Transfers; + sourceTree = ""; + }; DA4B8F3B2EA6FB5F00CBFD53 /* State */ = { isa = PBXGroup; children = ( @@ -219,6 +254,7 @@ DA5268AC2EB12FE200DCB941 /* ServerState.swift */, DA2863D92B37BF6E00A7D050 /* Preferences.swift */, DACCE5E02EABE4B4008CDD92 /* AppUpdate.swift */, + DA3429B42EBA8A450010784E /* FilePreviewState.swift */, ); path = State; sourceTree = ""; @@ -265,6 +301,7 @@ 11A726092BE06759000C1DA7 /* FileDetailsView.swift */, DAAEE66E2B47625600A5BA07 /* FilePreviewImageView.swift */, DAB4D87D2B4C8BCA0048A05C /* FilePreviewTextView.swift */, + DA3429B82EBAB2130010784E /* FilePreviewQuickLookView.swift */, ); path = Files; sourceTree = ""; @@ -306,6 +343,16 @@ path = News; sourceTree = ""; }; + DA5268B62EB916AB00DCB941 /* NetSocket */ = { + isa = PBXGroup; + children = ( + DAC6B2E12EAEE9EF004E2CBA /* NetSocketNew.swift */, + DA5268B92EB91B5E00DCB941 /* FileProgress.swift */, + DA5268B72EB916AF00DCB941 /* TransferRateEstimator.swift */, + ); + path = NetSocket; + sourceTree = ""; + }; DA9CAFAE2B126D5700CDA197 = { isa = PBXGroup; children = ( @@ -355,12 +402,13 @@ DAB4D87C2B4B783A0048A05C /* Library */ = { isa = PBXGroup; children = ( - DAC6B2E12EAEE9EF004E2CBA /* NetSocketNew.swift */, + DA5268B62EB916AB00DCB941 /* NetSocket */, DAC6B2E32EAFE92F004E2CBA /* SpinningGlobeView.swift */, DAB4D87A2B4B78310048A05C /* FileImageView.swift */, DAB4D8812B4C8FED0048A05C /* FileIconView.swift */, DAB4D8832B4CABEF0048A05C /* DataAdditions.swift */, DAB4D87F2B4C8E9A0048A05C /* URLAdditions.swift */, + DA3429B62EBAB1750010784E /* QuickLookPreviewView.swift */, DA4930BC2B4F8DD700822D0B /* NetSocket.swift */, DA872B122BDDBF78008B1012 /* HotlinePanel.swift */, DA872B142BDDEE1A008B1012 /* VisualEffectView.swift */, @@ -389,8 +437,10 @@ isa = PBXGroup; children = ( DA6300962B24036B0034CBFD /* HotlineClient.swift */, + DA5268AE2EB2682B00DCB941 /* HotlineClientNew.swift */, DA9CAFCA2B126E3300CDA197 /* HotlineTrackerClient.swift */, DA5753672B33E88A00FAC277 /* HotlineTransferClient.swift */, + DA3429B12EBA73730010784E /* Transfers */, DA4F2BF72B16A17200D8ADDC /* HotlineProtocol.swift */, DAC87F062C5010E80060FADF /* HotlineExtensions.swift */, DA99218D2C51AA5D0058FA6C /* HotlineDataBuilder.swift */, @@ -427,6 +477,7 @@ isa = PBXGroup; children = ( DADDB28E2B238D850024040D /* Hotline.swift */, + DA5268B02EB2708E00DCB941 /* HotlineState.swift */, DADDB28A2B22B31F0024040D /* Tracker.swift */, DADDB28C2B22B5920024040D /* Server.swift */, DA32CD482B2931640053B98B /* User.swift */, @@ -453,6 +504,7 @@ DAE734FA2B2E41F9000C56F6 /* TrackerView.swift */, DAE734F82B2E4185000C56F6 /* ServerView.swift */, DAE735022B30C0BB000C56F6 /* MessageView.swift */, + DA5268B42EB6840A00DCB941 /* TransfersView.swift */, DA5268A82EB081AF00DCB941 /* Chat */, DA5268A62EB0762300DCB941 /* Board */, DA5268A92EB081DE00DCB941 /* News */, @@ -552,24 +604,27 @@ buildActionMask = 2147483647; files = ( DA32CD4D2B2931B50053B98B /* ChatMessage.swift in Sources */, - DA6300972B24036B0034CBFD /* HotlineClient.swift in Sources */, + DA3429B92EBAB2130010784E /* FilePreviewQuickLookView.swift in Sources */, DA43205E2B1D615600FC8843 /* ServerView.swift in Sources */, DA0D69912B1E894800C71DF5 /* FilesView.swift in Sources */, DA52689E2EB073A400DCB941 /* IconSettingsView.swift in Sources */, DAC3D9832BC33FD000A727C9 /* AppState.swift in Sources */, DA65499C2BEC3FBD00EDB697 /* ServerAgreementView.swift in Sources */, + DA5268BC2EB95A7700DCB941 /* HotlineFolderDownloadClientNew.swift in Sources */, DAB4D8802B4C8E9A0048A05C /* URLAdditions.swift in Sources */, DA2863DD2B3E8B7000A7D050 /* FilePreview.swift in Sources */, + DA3429B02EBA70790010784E /* HotlineFolderUploadClientNew.swift in Sources */, DA5268AB2EB11EA300DCB941 /* ColorArt.swift in Sources */, DA2863D82B37AD1C00A7D050 /* SettingsView.swift in Sources */, + DA5268BA2EB91B5E00DCB941 /* FileProgress.swift in Sources */, DA7725412B21435B006C5ABB /* ObservableScrollView.swift in Sources */, DAE734FF2B2E6750000C56F6 /* ChatView.swift in Sources */, + DA5268B52EB6840A00DCB941 /* TransfersView.swift in Sources */, 11F8288B2BF9428100216BA0 /* AccountManagerView.swift in Sources */, DAC6B2E42EAFE92F004E2CBA /* SpinningGlobeView.swift in Sources */, - DA4930BD2B4F8DD700822D0B /* NetSocket.swift in Sources */, DAC002192B21630900A6C290 /* SwiftUIExtensions.swift in Sources */, - DADDB28F2B238D850024040D /* Hotline.swift in Sources */, DADDB28D2B22B5920024040D /* Server.swift in Sources */, + DA5268AF2EB2682B00DCB941 /* HotlineClientNew.swift in Sources */, DABE8C042B57940B00884D28 /* DAKeychain.swift in Sources */, DA55AC772BE589F700034857 /* AboutView.swift in Sources */, DA72A0E22B4DAA4000A0F48A /* NewsArticle.swift in Sources */, @@ -578,13 +633,16 @@ DAE136BA2B9D1147007D8307 /* HotlinePanelView.swift in Sources */, 11A726082BE0672A000C1DA7 /* FileDetails.swift in Sources */, DA9CAFBD2B126D5700CDA197 /* TrackerView.swift in Sources */, + DA5268B82EB916AF00DCB941 /* TransferRateEstimator.swift in Sources */, DAB4D87B2B4B78310048A05C /* FileImageView.swift in Sources */, + DA3429B52EBA8A450010784E /* FilePreviewState.swift in Sources */, DA6980792BF80525003E434B /* TextView.swift in Sources */, DA55AC732BE42AF000034857 /* AsyncLinkPreview.swift in Sources */, DA65499E2BEC438A00EDB697 /* NSWindowBridge.swift in Sources */, DAE735032B30C0BB000C56F6 /* MessageView.swift in Sources */, DA32CD4B2B29318E0053B98B /* FileInfo.swift in Sources */, DA20BBE12BF5237600B94E7C /* Bookmark.swift in Sources */, + DA3429B32EBA7ADF0010784E /* HotlineFilePreviewClientNew.swift in Sources */, DA9CAFCB2B126E3300CDA197 /* HotlineTrackerClient.swift in Sources */, DA2863DA2B37BF6E00A7D050 /* Preferences.swift in Sources */, DA32CD4F2B2931CC0053B98B /* NewsInfo.swift in Sources */, @@ -595,6 +653,7 @@ DA55AC752BE4888300034857 /* InstantMessage.swift in Sources */, DA69807E2BFD449B003E434B /* MessageBoardEditorView.swift in Sources */, DAE735012B2E71F2000C56F6 /* FilesView.swift in Sources */, + DA5268B12EB2708E00DCB941 /* HotlineState.swift in Sources */, DA55AC792BE6A1AD00034857 /* RegularExpressions.swift in Sources */, DA5268A02EB073BC00DCB941 /* SoundSettingsView.swift in Sources */, 11A7260A2BE0675A000C1DA7 /* FileDetailsView.swift in Sources */, @@ -615,6 +674,7 @@ DA65499A2BEC280E00EDB697 /* ServerMessageView.swift in Sources */, DAAEE66F2B47625600A5BA07 /* FilePreviewImageView.swift in Sources */, DA872B152BDDEE1A008B1012 /* VisualEffectView.swift in Sources */, + DA3429AE2EB9C0280010784E /* HotlineFileUploadClientNew.swift in Sources */, DAAEE66D2B475F1400A5BA07 /* PreviewFileInfo.swift in Sources */, DAB4D8842B4CABEF0048A05C /* DataAdditions.swift in Sources */, DA0D698D2B1E7CF700C71DF5 /* UsersView.swift in Sources */, @@ -627,7 +687,9 @@ DAE734FD2B2E65E9000C56F6 /* MessageBoardView.swift in Sources */, DA6980832BFFD06C003E434B /* BookmarkDocument.swift in Sources */, DAB4D87E2B4C8BCA0048A05C /* FilePreviewTextView.swift in Sources */, + DA3429B72EBAB1750010784E /* QuickLookPreviewView.swift in Sources */, DA4F2C012B1A558E00D8ADDC /* ChatView.swift in Sources */, + DA5268B32EB6806E00DCB941 /* HotlineFileDownloadClientNew.swift in Sources */, DACCE5E32EABE86A008CDD92 /* AppUpdateView.swift in Sources */, DAC6B2E02EAC6236004E2CBA /* ChatStore.swift in Sources */, DA32CD492B2931640053B98B /* User.swift in Sources */, @@ -779,10 +841,7 @@ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; IPHONEOS_DEPLOYMENT_TARGET = 17.2; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); + LD_RUNPATH_SEARCH_PATHS = "$(inherited)"; MACOSX_DEPLOYMENT_TARGET = 15.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = co.goodmake.hotline; @@ -819,10 +878,7 @@ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; IPHONEOS_DEPLOYMENT_TARGET = 17.2; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); + LD_RUNPATH_SEARCH_PATHS = "$(inherited)"; MACOSX_DEPLOYMENT_TARGET = 15.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = co.goodmake.hotline; diff --git a/Hotline.xcodeproj/xcshareddata/xcschemes/Hotline.xcscheme b/Hotline.xcodeproj/xcshareddata/xcschemes/Hotline.xcscheme index 2ba63f6..91934ce 100644 --- a/Hotline.xcodeproj/xcshareddata/xcschemes/Hotline.xcscheme +++ b/Hotline.xcodeproj/xcshareddata/xcschemes/Hotline.xcscheme @@ -34,6 +34,7 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + enableThreadSanitizer = "YES" launchStyle = "0" useCustomWorkingDirectory = "NO" ignoresPersistentStateOnLaunch = "NO" diff --git a/Hotline/Hotline/HotlineClientNew.swift b/Hotline/Hotline/HotlineClientNew.swift new file mode 100644 index 0000000..9e60b5e --- /dev/null +++ b/Hotline/Hotline/HotlineClientNew.swift @@ -0,0 +1,1061 @@ +import Foundation +import Network + +// MARK: - Events + +/// Events that can be received from a Hotline server +/// +/// These are unsolicited messages sent by the server (not replies to requests). +/// Subscribe to the `events` stream to receive them. +public enum HotlineEvent: Sendable { + /// Server sent a chat message + case chatMessage(String) + /// A user's information changed (name, icon, status) + case userChanged(HotlineUser) + /// A user disconnected from the server + case userDisconnected(UInt16) + /// Server sent a broadcast message + case serverMessage(String) + /// Received a private message from a user + case privateMessage(userID: UInt16, message: String) + /// Server sent a news post notification + case newsPost(String) + /// Server is requesting agreement acceptance + case agreementRequired(String) + /// Server sent user access permissions + case userAccess(HotlineUserAccessOptions) +} + +// MARK: - Errors + +/// Errors that can occur during Hotline operations +public enum HotlineClientError: Error { + /// Connection failed + case connectionFailed(Error) + /// Server responded with an error code + case serverError(code: UInt32, message: String?) + /// Transaction timed out waiting for reply + case timeout + /// Client is not connected + case notConnected + /// Invalid response from server + case invalidResponse + /// Login failed + case loginFailed(String?) + + var userMessage: String { + switch self { + case .connectionFailed: + "Failed to connect to server" + case .serverError(let code, let message): + message ?? "Server error: \(code)" + case .timeout: + "Request could not be completed" + case .notConnected: + "Not connected" + case .invalidResponse: + "Server returned an invalid response" + case .loginFailed(let message): + message ?? "Login failed" + } + } +} + +// MARK: - Login Info + +/// Information needed to log in to a Hotline server +public struct HotlineLoginInfo: Sendable { + let login: String + let password: String + let username: String + let iconID: UInt16 + + public init(login: String, password: String, username: String, iconID: UInt16) { + self.login = login + self.password = password + self.username = username + self.iconID = iconID + } +} + +// MARK: - Server Info + +/// Information about the connected server +public struct HotlineServerInfo: Sendable { + let name: String + let version: UInt16 + + public init(name: String, version: UInt16) { + self.name = name + self.version = version + } +} + +// MARK: - Hotline Client + +/// Modern async/await-based Hotline protocol client +/// +/// Example usage: +/// ```swift +/// let client = try await HotlineClientNew.connect( +/// host: "server.example.com", +/// port: 5500, +/// login: HotlineLoginInfo(login: "guest", password: "", username: "John", iconID: 414) +/// ) +/// +/// // Listen for events +/// Task { +/// for await event in client.events { +/// switch event { +/// case .chatMessage(let text): +/// print("Chat: \(text)") +/// case .userChanged(let user): +/// print("User changed: \(user.name)") +/// default: +/// break +/// } +/// } +/// } +/// +/// // Send chat message +/// try await client.sendChat("Hello world!") +/// +/// // Get user list +/// let users = try await client.getUserList() +/// ``` +public actor HotlineClientNew { + // MARK: - Properties + + private let socket: NetSocketNew + private var serverInfo: HotlineServerInfo? + private var isConnected: Bool = true + + /// Information about the connected server (name and version) + public var server: HotlineServerInfo? { + return serverInfo + } + + // Event streaming + private let eventContinuation: AsyncStream.Continuation + public let events: AsyncStream + + // Transaction tracking for request/reply pattern + private var pendingTransactions: [UInt32: CheckedContinuation] = [:] + + private enum TransactionWaitError: Error { + case timeout + } + + // Receive loop task + private var receiveTask: Task? + + // 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 + }) + + // MARK: - Connection + + /// Connect to a Hotline server and log in + /// + /// This method: + /// 1. Establishes TCP connection + /// 2. Performs handshake + /// 3. Logs in with provided credentials + /// 4. Starts event streaming and keep-alive + /// + /// - Parameters: + /// - host: Server hostname or IP address + /// - port: Server port (default: 5500) + /// - login: Login credentials and user info + /// - tls: TLS policy (default: disabled for Hotline) + /// - Returns: Connected and logged-in client + /// - Throws: `HotlineClientError` if connection or login fails + public static func connect( + host: String, + port: UInt16 = 5500, + login: HotlineLoginInfo, + tls: TLSPolicy = .disabled + ) 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 NetSocketNew.connect(host: host, port: port, tls: tls) + print("HotlineClientNew.connect(): Socket connected") + + // Perform handshake + print("HotlineClientNew.connect(): Sending handshake...") + try await socket.write(handshakeData) + let handshakeResponse = try await socket.read(8) + print("HotlineClientNew.connect(): Handshake response received") + + // Verify handshake + guard handshakeResponse.prefix(4) == Data([0x54, 0x52, 0x54, 0x50]) else { + print("HotlineClientNew.connect(): Invalid handshake response") + throw HotlineClientError.connectionFailed( + NSError(domain: "HotlineClient", code: -1, userInfo: [ + NSLocalizedDescriptionKey: "Invalid handshake response" + ]) + ) + } + + 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)") + throw HotlineClientError.connectionFailed( + NSError(domain: "HotlineClient", code: Int(errorCode), userInfo: [ + NSLocalizedDescriptionKey: "Handshake failed with error code \(errorCode)" + ]) + ) + } + + // Create client + print("HotlineClientNew.connect(): Creating client instance") + let client = HotlineClientNew(socket: socket) + + // Start receive loop + print("HotlineClientNew.connect(): Starting receive loop") + await client.startReceiveLoop() + + // Perform login + print("HotlineClientNew.connect(): Performing login") + let serverInfo = try await client.performLogin(login) + await client.setServerInfo(serverInfo) + print("HotlineClientNew.connect(): Login successful") + + // Start keep-alive + print("HotlineClientNew.connect(): Starting keep-alive") + await client.startKeepAlive() + + print("HotlineClientNew.connect(): Connected to \(serverInfo.name) (v\(serverInfo.version))") + + return client + } + + private init(socket: NetSocketNew) { + self.socket = socket + + // Set up event stream + var continuation: AsyncStream.Continuation! + self.events = AsyncStream { cont in + continuation = cont + } + self.eventContinuation = continuation + } + + private func setServerInfo(_ info: HotlineServerInfo) { + self.serverInfo = info + } + + // MARK: - Login + + private func performLogin(_ login: HotlineLoginInfo) async throws -> HotlineServerInfo { + var transaction = HotlineTransaction(type: .login) + transaction.setFieldEncodedString(type: .userLogin, val: login.login) + transaction.setFieldEncodedString(type: .userPassword, val: login.password) + transaction.setFieldUInt16(type: .userIconID, val: login.iconID) + transaction.setFieldString(type: .userName, val: login.username) + 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) + } + + let serverName = reply.getField(type: .serverName)?.getString() ?? "Unknown" + let serverVersion = reply.getField(type: .versionNumber)?.getUInt16() ?? 0 + + return HotlineServerInfo(name: serverName, version: serverVersion) + } + + // MARK: - Disconnect + + /// Disconnect from the server + /// + /// Closes the socket and stops all background tasks. + public func disconnect() async { + guard isConnected else { + return + } + + isConnected = false + + print("HotlineClientNew.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") + } + + // MARK: - Receive Loop + + private func startReceiveLoop() { + print("HotlineClientNew.startReceiveLoop(): Creating receive task") + self.receiveTask = Task { [weak self] in + guard let self else { + return + } + + do { + while !Task.isCancelled { + // Read transaction from socket + let transaction = try await self.socket.receive(HotlineTransaction.self, endian: .big) + await self.handleTransaction(transaction) + } + print("HotlineClientNew.startReceiveLoop(): Task cancelled, exiting loop") + } catch { + if Task.isCancelled || error is CancellationError { + print("HotlineClientNew.startReceiveLoop(): Receive loop cancelled") + } else { + print("HotlineClientNew.startReceiveLoop(): Receive loop error: \(error)") + await self.disconnect() + } + } + print("HotlineClientNew.startReceiveLoop(): Receive loop ended") + } + } + + private func handleTransaction(_ transaction: HotlineTransaction) { + print("HotlineClientNew: <= \(transaction.type) [\(transaction.id)]") + + // Check if this is a reply to a pending transaction + if transaction.isReply == 1 || transaction.type == .reply { + handleReply(transaction) + return + } + + // Handle unsolicited server messages (events) + handleEvent(transaction) + } + + private func handleReply(_ transaction: HotlineTransaction) { + guard let continuation = pendingTransactions.removeValue(forKey: transaction.id) else { + print("HotlineClientNew: Received reply for unknown transaction \(transaction.id)") + return + } + + if transaction.errorCode != 0 { + let errorText = transaction.getField(type: .errorText)?.getString() + continuation.resume(throwing: HotlineClientError.serverError( + code: transaction.errorCode, + message: errorText + )) + } else { + print("HELLO") + continuation.resume(returning: transaction) + } + } + + private func handleEvent(_ transaction: HotlineTransaction) { + switch transaction.type { + case .chatMessage: + if let text = transaction.getField(type: .data)?.getString() { + eventContinuation.yield(.chatMessage(text)) + } + + case .notifyOfUserChange: + if let usernameField = transaction.getField(type: .userName), + let username = usernameField.getString(), + let userID = transaction.getField(type: .userID)?.getUInt16(), + let iconID = transaction.getField(type: .userIconID)?.getUInt16(), + let flags = transaction.getField(type: .userFlags)?.getUInt16() { + let user = HotlineUser(id: userID, iconID: iconID, status: flags, name: username) + eventContinuation.yield(.userChanged(user)) + } + + case .notifyOfUserDelete: + if let userID = transaction.getField(type: .userID)?.getUInt16() { + eventContinuation.yield(.userDisconnected(userID)) + } + + case .serverMessage: + if let message = transaction.getField(type: .data)?.getString() { + if let userID = transaction.getField(type: .userID)?.getUInt16() { + eventContinuation.yield(.privateMessage(userID: userID, message: message)) + } else { + eventContinuation.yield(.serverMessage(message)) + } + } + + case .showAgreement: + if transaction.getField(type: .noServerAgreement) == nil, + let agreementText = transaction.getField(type: .data)?.getString() { + eventContinuation.yield(.agreementRequired(agreementText)) + } + + case .userAccess: + if let accessValue = transaction.getField(type: .userAccess)?.getUInt64() { + eventContinuation.yield(.userAccess(HotlineUserAccessOptions(rawValue: accessValue))) + } + + case .newMessage: + if let message = transaction.getField(type: .data)?.getString() { + eventContinuation.yield(.newsPost(message)) + } + + case .disconnectMessage: + Task { + await self.disconnect() + } + + default: + print("HotlineClientNew: Unhandled event type \(transaction.type)") + } + } + + // MARK: - Transaction Sending + + private func sendTransaction(_ transaction: HotlineTransaction, timeout: TimeInterval = 30.0) async throws -> HotlineTransaction { + print("HotlineClientNew: => \(transaction.type) [\(transaction.id)]") + + let transactionID = transaction.id + + try await self.socket.send(transaction, endian: .big) + + do { + return try await withTimeout(seconds: timeout) { + try await self.awaitReply(for: transactionID) + } + } catch is TransactionWaitError { + throw HotlineClientError.timeout + } catch let error as HotlineClientError { + throw error + } catch { + throw error + } + } + + private func storePendingTransaction(id: UInt32, continuation: CheckedContinuation) { + self.pendingTransactions[id] = continuation + } + + private func awaitReply(for transactionID: UInt32) async throws -> HotlineTransaction { + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + self.storePendingTransaction(id: transactionID, continuation: continuation) + } + } onCancel: { [weak self] in + Task { await self?.failPendingTransaction(id: transactionID, error: HotlineClientError.timeout) } + } + } + + private func failPendingTransaction(id: UInt32, error: Error) { + guard let continuation = self.pendingTransactions.removeValue(forKey: id) else { return } + continuation.resume(throwing: error) + } + + private func failAllPendingTransactions(_ error: Error) { + guard !self.pendingTransactions.isEmpty else { return } + let continuations = self.pendingTransactions + self.pendingTransactions.removeAll() + for (_, continuation) in continuations { + continuation.resume(throwing: error) + } + } + + private func withTimeout(seconds: TimeInterval, operation: @escaping @Sendable () async throws -> T) async throws -> T { + if seconds <= 0 { + throw TransactionWaitError.timeout + } + + return try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { + try await operation() + } + + group.addTask { + try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + throw TransactionWaitError.timeout + } + + do { + let value = try await group.next()! + group.cancelAll() + return value + } catch { + group.cancelAll() + throw error + } + } + } + + // MARK: - Keep-Alive + + private func startKeepAlive() { + keepAliveTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 180_000_000_000) // 3 minutes + + guard let self else { return } + + do { + if let version = await self.serverInfo?.version, version >= 185 { + let transaction = HotlineTransaction(type: .connectionKeepAlive) + try await self.socket.send(transaction, endian: .big) + } else { + // Older servers: send getUserNameList as keep-alive + _ = try? await self.getUserList() + } + } catch { + print("HotlineClientNew: Keep-alive failed: \(error)") + } + } + } + } + + // MARK: - Public API - Chat + + /// Send a chat message to the server + /// + /// - Parameters: + /// - message: Text to send + /// - encoding: Text encoding (default: UTF-8) + /// - announce: Whether this is an announcement (admin only, default: false) + public func sendChat(_ message: String, encoding: String.Encoding = .utf8, announce: Bool = false) async throws { + var transaction = HotlineTransaction(type: .sendChat) + transaction.setFieldString(type: .data, val: message, encoding: encoding) + transaction.setFieldUInt16(type: .chatOptions, val: announce ? 1 : 0) + + try await socket.send(transaction, endian: .big) + } + + // MARK: - Public API - Users + + /// Get the list of users currently connected to the server + /// + /// - Returns: Array of connected users + public func getUserList() async throws -> [HotlineUser] { + let transaction = HotlineTransaction(type: .getUserNameList) + let reply = try await sendTransaction(transaction) + + var users: [HotlineUser] = [] + for field in reply.getFieldList(type: .userNameWithInfo) { + users.append(field.getUser()) + } + + return users + } + + /// Send a private instant message to a user + /// + /// - Parameters: + /// - message: Text to send + /// - userID: Target user ID + /// - encoding: Text encoding (default: UTF-8) + public func sendInstantMessage(_ message: String, to userID: UInt16, encoding: String.Encoding = .utf8) async throws { + var transaction = HotlineTransaction(type: .sendInstantMessage) + transaction.setFieldUInt16(type: .userID, val: userID) + transaction.setFieldUInt32(type: .options, val: 1) + transaction.setFieldString(type: .data, val: message, encoding: encoding) + + try await socket.send(transaction, endian: .big) + } + + /// Update this client's user info (name, icon, options) + /// + /// - Parameters: + /// - username: Display name + /// - iconID: Icon ID + /// - options: User options flags + /// - autoresponse: Optional auto-response text + public func setClientUserInfo( + username: String, + iconID: UInt16, + options: HotlineUserOptions = [], + autoresponse: String? = nil + ) async throws { + var transaction = HotlineTransaction(type: .setClientUserInfo) + transaction.setFieldString(type: .userName, val: username) + transaction.setFieldUInt16(type: .userIconID, val: iconID) + transaction.setFieldUInt16(type: .options, val: options.rawValue) + + if let autoresponse { + transaction.setFieldString(type: .automaticResponse, val: autoresponse) + } + + try await socket.send(transaction, endian: .big) + } + + // MARK: - Public API - Agreement + + /// Send agreement acceptance to the server + /// + /// Call this after receiving `.agreementRequired` event. + public func sendAgree() async throws { + let transaction = HotlineTransaction(type: .agreed) + try await socket.send(transaction, endian: .big) + } + + // MARK: - Public API - Files + + /// Get the file list for a directory + /// + /// - Parameter path: Directory path (empty for root) + /// - Returns: Array of files and folders + public func getFileList(path: [String] = []) async throws -> [HotlineFile] { + var transaction = HotlineTransaction(type: .getFileNameList) + if !path.isEmpty { + transaction.setFieldPath(type: .filePath, val: path) + } + + let reply = try await sendTransaction(transaction) + + var files: [HotlineFile] = [] + for field in reply.getFieldList(type: .fileNameWithInfo) { + let file = field.getFile() + file.path = path + [file.name] + files.append(file) + } + + return files + } + + /// Request to download 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(type: .downloadFile) + transaction.setFieldString(type: .fileName, val: name) + transaction.setFieldPath(type: .filePath, val: path) + + if preview { + transaction.setFieldUInt32(type: .fileTransferOptions, val: 2) + } + + let reply = try await sendTransaction(transaction) + + guard + let transferSize = reply.getField(type: .transferSize)?.getInteger(), + let referenceNumber = reply.getField(type: .referenceNumber)?.getUInt32() + else { + throw HotlineClientError.invalidResponse + } + + let fileSize = reply.getField(type: .fileSize)?.getInteger() + let waitingCount = reply.getField(type: .waitingCount)?.getInteger() + + return (referenceNumber, transferSize, fileSize, waitingCount) + } + + // MARK: - Public API - News + + /// Get news categories at a path + /// + /// - Parameter path: Category path (empty for root) + /// - Returns: Array of news categories + public func getNewsCategories(path: [String] = []) async throws -> [HotlineNewsCategory] { + var transaction = HotlineTransaction(type: .getNewsCategoryNameList) + if !path.isEmpty { + transaction.setFieldPath(type: .newsPath, val: path) + } + + let reply = try await sendTransaction(transaction) + + var categories: [HotlineNewsCategory] = [] + for field in reply.getFieldList(type: .newsCategoryListData15) { + var category = field.getNewsCategory() + category.path = path + [category.name] + categories.append(category) + } + + return categories + } + + /// Get news articles in a category + /// + /// - Parameter path: Category path + /// - Returns: Array of news articles + public func getNewsArticles(path: [String] = []) async throws -> [HotlineNewsArticle] { + var transaction = HotlineTransaction(type: .getNewsArticleNameList) + if !path.isEmpty { + transaction.setFieldPath(type: .newsPath, val: path) + } + + let reply = try await sendTransaction(transaction) + + guard let articleData = reply.getField(type: .newsArticleListData) else { + return [] + } + + let newsList = articleData.getNewsList() + return newsList.articles.map { article in + var a = article + a.path = path + return a + } + } + + /// Get the content of a news article + /// + /// - Parameters: + /// - id: Article ID + /// - path: Category path + /// - flavor: Content flavor (default: "text/plain") + /// - Returns: Article content as string + public func getNewsArticle(id: UInt32, path: [String], flavor: String = "text/plain") async throws -> String? { + var transaction = HotlineTransaction(type: .getNewsArticleData) + transaction.setFieldPath(type: .newsPath, val: path) + transaction.setFieldUInt32(type: .newsArticleID, val: id) + transaction.setFieldString(type: .newsArticleDataFlavor, val: flavor, encoding: .ascii) + + let reply = try await sendTransaction(transaction) + return reply.getField(type: .newsArticleData)?.getString() + } + + /// Post a news article + /// + /// - Parameters: + /// - title: Article title + /// - text: Article body + /// - path: Category path + /// - parentID: Parent article ID (for replies, default: 0) + public func postNewsArticle( + title: String, + text: String, + path: [String], + parentID: UInt32 = 0 + ) async throws { + guard !path.isEmpty else { + throw HotlineClientError.invalidResponse + } + + var transaction = HotlineTransaction(type: .postNewsArticle) + transaction.setFieldPath(type: .newsPath, val: path) + transaction.setFieldUInt32(type: .newsArticleID, val: parentID) + transaction.setFieldString(type: .newsArticleTitle, val: title) + transaction.setFieldString(type: .newsArticleDataFlavor, val: "text/plain") + transaction.setFieldUInt32(type: .newsArticleFlags, val: 0) + transaction.setFieldString(type: .newsArticleData, val: text) + + _ = try await sendTransaction(transaction) + } + + // MARK: - Public API - Message Board + + /// Get message board posts + /// + /// - Returns: Array of message strings + public func getMessageBoard() async throws -> [String] { + let transaction = HotlineTransaction(type: .getMessageBoard) + let reply = try await sendTransaction(transaction) + + guard let text = reply.getField(type: .data)?.getString() else { + return [] + } + + // Parse messages (separated by divider pattern) + // TODO: Implement proper divider parsing if needed + return [text] + } + + /// Post to the message board + /// + /// - Parameter text: Message text + public func postMessageBoard(_ text: String) async throws { + guard !text.isEmpty else { return } + + var transaction = HotlineTransaction(type: .oldPostNews) + transaction.setFieldString(type: .data, val: text, encoding: .macOSRoman) + + try await socket.send(transaction, endian: .big) + } + + // MARK: - Public API - 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(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(type: .deleteFile) + transaction.setFieldString(type: .fileName, val: name) + transaction.setFieldPath(type: .filePath, val: path) + + do { + _ = try await sendTransaction(transaction) + return true + } catch { + return false + } + } + + // MARK: - Public API - User Administration + + /// Get list of user accounts (requires admin access) + /// + /// - Returns: Array of user accounts sorted by login + public func getAccounts() async throws -> [HotlineAccount] { + let transaction = HotlineTransaction(type: .getAccounts) + let reply = try await sendTransaction(transaction) + + let accountFields = reply.getFieldList(type: .data) + var accounts: [HotlineAccount] = [] + + for data in accountFields { + accounts.append(data.getAcccount()) + } + + accounts.sort { $0.login < $1.login } + + return accounts + } + + /// Create a new user account (requires admin access) + /// + /// - Parameters: + /// - name: Display name for the user + /// - login: Login username + /// - password: Optional password (nil for no password) + /// - access: Access permissions bitmask + public func createUser(name: String, login: String, password: String?, access: UInt64) async throws { + var transaction = HotlineTransaction(type: .newUser) + + transaction.setFieldString(type: .userName, val: name) + transaction.setFieldEncodedString(type: .userLogin, val: login) + transaction.setFieldUInt64(type: .userAccess, val: access) + + if let password { + transaction.setFieldEncodedString(type: .userPassword, val: password) + } + + _ = try await sendTransaction(transaction) + } + + /// Update an existing user account (requires admin access) + /// + /// - Parameters: + /// - name: Display name for the user + /// - login: Current login username + /// - newLogin: New login username (nil to keep current) + /// - password: Password update - nil to keep current, "" to remove, or new password string + /// - access: Access permissions bitmask + public func setUser(name: String, login: String, newLogin: String?, password: String?, access: UInt64) async throws { + var transaction = HotlineTransaction(type: .setUser) + transaction.setFieldString(type: .userName, val: name) + transaction.setFieldUInt64(type: .userAccess, val: access) + + if let newLogin { + transaction.setFieldEncodedString(type: .data, val: login) + transaction.setFieldEncodedString(type: .userLogin, val: newLogin) + } else { + transaction.setFieldEncodedString(type: .userLogin, val: login) + } + + // Password field handling: + // - nil: Keep current password (send zero byte) + // - "": Remove password (omit field) + // - other: Set new password + if password == nil { + transaction.setFieldUInt8(type: .userPassword, val: 0) + } else if password != "" { + transaction.setFieldEncodedString(type: .userPassword, val: password!) + } + + _ = try await sendTransaction(transaction) + } + + /// Delete a user account (requires admin access) + /// + /// - Parameter login: Login username to delete + public func deleteUser(login: String) async throws { + var transaction = HotlineTransaction(type: .deleteUser) + transaction.setFieldEncodedString(type: .userLogin, val: login) + + _ = try await sendTransaction(transaction) + } + + // MARK: - Public API - Banner Download + + /// Request to download the server banner image + /// + /// - Returns: Tuple of (referenceNumber, transferSize) for the banner download + /// - Throws: HotlineClientError if not connected or server doesn't support banners + public func downloadBanner() async throws -> (referenceNumber: UInt32, transferSize: Int)? { + let transaction = HotlineTransaction(type: .downloadBanner) + let reply = try await sendTransaction(transaction) + + guard + let transferSizeField = reply.getField(type: .transferSize), + let transferSize = transferSizeField.getInteger(), + let transferReferenceField = reply.getField(type: .referenceNumber), + let referenceNumber = transferReferenceField.getUInt32() + else { + return nil + } + + return (referenceNumber, transferSize) + } + + // MARK: Files + + /// Request to download a file + /// + /// - Parameters: + /// - name: File name to download + /// - path: Directory path containing the file + /// - preview: If true, request preview mode (smaller transfer) + /// - Returns: Tuple of (referenceNumber, transferSize, fileSize, waitingCount) for the download + public func downloadFile(name: String, path: [String], preview: Bool = false) async throws -> (referenceNumber: UInt32, transferSize: Int, fileSize: Int, waitingCount: Int)? { + var transaction = HotlineTransaction(type: .downloadFile) + transaction.setFieldString(type: .fileName, val: name) + transaction.setFieldPath(type: .filePath, val: path) + + if preview { + transaction.setFieldUInt32(type: .fileTransferOptions, val: 2) + } + + let reply = try await sendTransaction(transaction) + + guard + let transferSizeField = reply.getField(type: .transferSize), + let transferSize = transferSizeField.getInteger(), + let transferReferenceField = reply.getField(type: .referenceNumber), + let referenceNumber = transferReferenceField.getUInt32() + else { + return nil + } + + let fileSize = reply.getField(type: .fileSize)?.getInteger() ?? transferSize + let waitingCount = reply.getField(type: .waitingCount)?.getInteger() ?? 0 + + return (referenceNumber, transferSize, fileSize, waitingCount) + } + + /// Request to download a folder + /// + /// - Parameters: + /// - name: Folder name to download + /// - path: Directory path containing the folder + /// - Returns: Tuple of (referenceNumber, transferSize, itemCount, waitingCount) for the download + public func downloadFolder(name: String, path: [String]) async throws -> (referenceNumber: UInt32, transferSize: Int, itemCount: Int, waitingCount: Int)? { + var transaction = HotlineTransaction(type: .downloadFolder) + transaction.setFieldString(type: .fileName, val: name) + transaction.setFieldPath(type: .filePath, val: path) + + let reply = try await sendTransaction(transaction) + + guard + let transferSizeField = reply.getField(type: .transferSize), + let transferSize = transferSizeField.getInteger(), + let transferReferenceField = reply.getField(type: .referenceNumber), + let referenceNumber = transferReferenceField.getUInt32() + else { + return nil + } + + let itemCount = reply.getField(type: .folderItemCount)?.getInteger() ?? 0 + let waitingCount = reply.getField(type: .waitingCount)?.getInteger() ?? 0 + + return (referenceNumber, transferSize, itemCount, waitingCount) + } + + /// Uploads a file to the server + /// - Parameters: + /// - name: File name to upload + /// - path: Directory path where the file should be uploaded + /// - Returns: Reference number for the upload transfer + public func uploadFile(name: String, path: [String]) async throws -> UInt32? { + var transaction = HotlineTransaction(type: .uploadFile) + transaction.setFieldString(type: .fileName, val: name) + transaction.setFieldPath(type: .filePath, val: path) + + let reply = try await sendTransaction(transaction) + + guard + let transferReferenceField = reply.getField(type: .referenceNumber), + let referenceNumber = transferReferenceField.getUInt32() + else { + return nil + } + + return referenceNumber + } + + /// Request to upload a folder + /// + /// - Parameters: + /// - name: Folder name to upload + /// - 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)") + + var transaction = HotlineTransaction(type: .uploadFolder) + transaction.setFieldString(type: .fileName, val: name) + transaction.setFieldPath(type: .filePath, val: path) + transaction.setFieldUInt32(type: .transferSize, val: totalSize) + transaction.setFieldUInt16(type: .folderItemCount, val: UInt16(truncatingIfNeeded: fileCount)) + + let reply = try await sendTransaction(transaction) + + guard + let transferReferenceField = reply.getField(type: .referenceNumber), + let referenceNumber = transferReferenceField.getUInt32() + else { + return nil + } + + return referenceNumber + } +} diff --git a/Hotline/Hotline/HotlineExtensions.swift b/Hotline/Hotline/HotlineExtensions.swift index 81387bf..16e9583 100644 --- a/Hotline/Hotline/HotlineExtensions.swift +++ b/Hotline/Hotline/HotlineExtensions.swift @@ -11,6 +11,24 @@ extension URL { func urlForResourceFork() -> URL { self.appendingPathComponent("..namedfork/rsrc") } + + /// Append multiple path components to a URL by iterating through an array + /// - Parameter components: Array of path component strings + /// - Returns: New URL with all components appended + func appendingPathComponents(_ components: [String]) -> URL { + var path = self.path + for component in components { + path = (path as NSString).appendingPathComponent(component) + } + return URL(filePath: path) + } + +#if os(macOS) + func notifyDownloadFinished() { + print("DOWNLOAD FINISHED AT", self) + DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: self.path) + } +#endif } extension String { @@ -382,7 +400,7 @@ extension FileManager { // Get resource fork size. var resourceForkSize: UInt32 = 0 - let resourceFileURL: URL = fileURL.appendingPathComponent("..namedfork/rsrc") + let resourceFileURL: URL = fileURL.urlForResourceFork() let resourceFilePath: String = fileURL.path(percentEncoded: false) if self.fileExists(atPath: resourceFilePath) { let resourceFileAttributes: [FileAttributeKey: Any]? = try? self.attributesOfItem(atPath: resourceFileURL.path(percentEncoded: false)) @@ -394,6 +412,36 @@ extension FileManager { return (dataForkSize: dataForkSize, resourceForkSize: resourceForkSize) } + /// Create a file with metadata from a Hotline INFO fork + /// + /// - Parameters: + /// - url: Destination URL for the new file + /// - infoFork: Hotline file info containing metadata + /// - Returns: FileHandle open for writing + /// - Throws: Error if file creation fails + func createHotlineFile(at url: URL, infoFork: HotlineFileInfoFork) throws -> FileHandle { + var attributes: [FileAttributeKey: Any] = [:] + + if infoFork.creator != 0 { + attributes[.hfsCreatorCode] = infoFork.creator as NSNumber + } + if infoFork.type != 0 { + attributes[.hfsTypeCode] = infoFork.type as NSNumber + } + attributes[.creationDate] = infoFork.createdDate as NSDate + attributes[.modificationDate] = infoFork.modifiedDate as NSDate + + guard self.createFile(atPath: url.path, contents: nil, attributes: attributes) else { + throw HotlineFileClientError.failedToTransfer + } + + guard let handle = FileHandle(forWritingAtPath: url.path) else { + throw HotlineFileClientError.failedToTransfer + } + + return handle + } + func getFlattenedFileSize(_ fileURL: URL) -> UInt64? { var fileIsDirectory: ObjCBool = false let filePath: String = fileURL.path(percentEncoded: false) @@ -454,11 +502,52 @@ extension FileManager { // print("FOUND RESOURCE FORK: \(resourceForkSize)") // } // } -// +// // totalSize += resourceForkSize - + return totalSize } + + /// Get the total size of all files in a folder (recursively) + /// Returns the sum of flattened file sizes for all files in the folder, and the total count of all items (files + folders) + func getFolderSize(_ folderURL: URL) -> (size: UInt64, count: UInt32)? { + var isDirectory: ObjCBool = false + let folderPath = folderURL.path(percentEncoded: false) + + guard folderURL.isFileURL, + self.fileExists(atPath: folderPath, isDirectory: &isDirectory), + isDirectory.boolValue == true else { + return nil + } + + var totalSize: UInt64 = 0 + var itemCount: UInt32 = 0 + + guard let enumerator = self.enumerator(at: folderURL, includingPropertiesForKeys: [.isRegularFileKey, .isDirectoryKey], options: [.skipsHiddenFiles]) else { + return nil + } + + for case let fileURL as URL in enumerator { + guard let resourceValues = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .isDirectoryKey]) else { + continue + } + + let isRegularFile = resourceValues.isRegularFile ?? false + let isDirectory = resourceValues.isDirectory ?? false + + // Count all items (files and folders) + if isRegularFile || isDirectory { + itemCount += 1 + + // Only add size for files, not folders + if isRegularFile, let fileSize = self.getFlattenedFileSize(fileURL) { + totalSize += fileSize + } + } + } + + return (size: totalSize, count: itemCount) + } } extension Date { @@ -1016,3 +1105,51 @@ extension FourCharCode { return String(bytes: bytes, encoding: .ascii) ?? "" } } + + +extension NetSocketNew { + /// Read a pascal string (1-byte length prefix followed by string data) + /// + /// This method reads a single byte for the length, then reads that many bytes and attempts + /// to decode them as a string. It tries multiple encodings for compatibility with legacy + /// protocols like Hotline: UTF-8, Shift-JIS, Windows-1251, and falls back to MacRoman. + /// + /// - Returns: The decoded string, or nil if length is 0 + /// - Throws: `NetSocketError` if reading fails or no encoding succeeds + func readPascalString() async throws -> String? { + let length = try await read(UInt8.self) + guard length > 0 else { return nil } + + let data = try await read(Int(length)) + + // 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: "NetSocketNew", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Failed to decode pascal string with any known encoding"] + )) + } + return str + } +} diff --git a/Hotline/Hotline/HotlineProtocol.swift b/Hotline/Hotline/HotlineProtocol.swift index 5f859fb..43f018e 100644 --- a/Hotline/Hotline/HotlineProtocol.swift +++ b/Hotline/Hotline/HotlineProtocol.swift @@ -5,18 +5,26 @@ struct HotlinePorts { static let DefaultTrackerPort: Int = 5498 } -struct HotlineUserOptions: OptionSet { - let rawValue: UInt16 - - static let none: HotlineUserOptions = [] - - static let refusePrivateMessages = HotlineUserOptions(rawValue: 1 << 0) - static let refusePrivateChat = HotlineUserOptions(rawValue: 1 << 1) - static let automaticResponse = HotlineUserOptions(rawValue: 1 << 2) +public struct HotlineUserOptions: OptionSet, Sendable { + public let rawValue: UInt16 + + public init(rawValue: UInt16) { + self.rawValue = rawValue + } + + public static let none: HotlineUserOptions = [] + + public static let refusePrivateMessages = HotlineUserOptions(rawValue: 1 << 0) + public static let refusePrivateChat = HotlineUserOptions(rawValue: 1 << 1) + public static let automaticResponse = HotlineUserOptions(rawValue: 1 << 2) } -struct HotlineUserAccessOptions: OptionSet { - let rawValue: UInt64 +public struct HotlineUserAccessOptions: OptionSet, Sendable { + public let rawValue: UInt64 + + public init(rawValue: UInt64) { + self.rawValue = rawValue + } static func accessIndexToBit(_ index: Int) -> Int { return 63 - index @@ -201,23 +209,23 @@ struct HotlineServer: Identifiable, Hashable, NetSocketDecodable { } } -struct HotlineNewsArticle: Identifiable { - let id: UInt32 - let parentID: UInt32 - let flags: UInt32 - let title: String - let username: String - let date: Date? - var flavors: [(String, UInt16)] = [] - var path: [String] = [] - - static func == (lhs: HotlineNewsArticle, rhs: HotlineNewsArticle) -> Bool { +public struct HotlineNewsArticle: Identifiable, Sendable { + public let id: UInt32 + public let parentID: UInt32 + public let flags: UInt32 + public let title: String + public let username: String + public let date: Date? + public var flavors: [(String, UInt16)] = [] + public var path: [String] = [] + + public static func == (lhs: HotlineNewsArticle, rhs: HotlineNewsArticle) -> Bool { return lhs.id == rhs.id } } -struct HotlineAccount: Identifiable { - let id: UUID = UUID() +public struct HotlineAccount: Identifiable { + public let id: UUID = UUID() var name: String = "" var login: String = "" var password: String? = nil @@ -279,11 +287,11 @@ struct HotlineAccount: Identifiable { } } } - - static func == (lhs: HotlineAccount, rhs: HotlineAccount) -> Bool { + + public static func == (lhs: HotlineAccount, rhs: HotlineAccount) -> Bool { return lhs.id == rhs.id } - + // Generate an initial random 21 character alphanumeric password, in the spirit of the original client static func randomPassword() -> String { return String((0..<20).map{_ in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".randomElement()!}) @@ -291,7 +299,7 @@ struct HotlineAccount: Identifiable { } extension HotlineAccount: Hashable { - func hash(into hasher: inout Hasher) { + public func hash(into hasher: inout Hasher) { hasher.combine(self.id) } } @@ -389,22 +397,22 @@ struct HotlineNewsList: Identifiable { } } -struct HotlineNewsCategory: Identifiable, Hashable { - let id = UUID() - let type: UInt16 - let count: UInt16 - let name: String - var path: [String] = [] - - static func == (lhs: HotlineNewsCategory, rhs: HotlineNewsCategory) -> Bool { +public struct HotlineNewsCategory: Identifiable, Hashable, Sendable { + public let id = UUID() + public let type: UInt16 + public let count: UInt16 + public let name: String + public var path: [String] = [] + + public static func == (lhs: HotlineNewsCategory, rhs: HotlineNewsCategory) -> Bool { return lhs.id == rhs.id } - - func hash(into hasher: inout Hasher) { + + public func hash(into hasher: inout Hasher) { hasher.combine(self.id) } - - init(type: UInt16, count: UInt16, name: String) { + + public init(type: UInt16, count: UInt16, name: String) { self.type = type self.count = count self.name = name @@ -438,32 +446,32 @@ struct HotlineNewsCategory: Identifiable, Hashable { @Observable -class HotlineFile: Identifiable, Hashable { - let id = UUID() - let type: String - let creator: String - let fileSize: UInt32 - let name: String - - var path: [String] = [] - var isExpanded: Bool = false - var files: [HotlineFile]? = nil - - let isFolder: Bool +public class HotlineFile: Identifiable, Hashable { + public let id = UUID() + public let type: String + public let creator: String + public let fileSize: UInt32 + public let name: String + + public var path: [String] = [] + public var isExpanded: Bool = false + public var files: [HotlineFile]? = nil + + public let isFolder: Bool - var isDropboxFolder: Bool { + public var isDropboxFolder: Bool { guard self.isFolder, (self.name.range(of: "upload", options: [.caseInsensitive]) != nil) || (self.name.range(of: "drop box", options: [.caseInsensitive]) != nil) else { return false } return true } - - static func == (lhs: HotlineFile, rhs: HotlineFile) -> Bool { + + public static func == (lhs: HotlineFile, rhs: HotlineFile) -> Bool { return lhs.id == rhs.id } - - func hash(into hasher: inout Hasher) { + + public func hash(into hasher: inout Hasher) { hasher.combine(self.id) } @@ -501,25 +509,25 @@ class HotlineFile: Identifiable, Hashable { } } -struct HotlineUser: Identifiable, Hashable { - let id: UInt16 - let iconID: UInt16 - let status: UInt16 - let name: String - - var isAdmin: Bool { +public struct HotlineUser: Identifiable, Hashable, Sendable { + public let id: UInt16 + public let iconID: UInt16 + public let status: UInt16 + public let name: String + + public var isAdmin: Bool { return ((self.status & 0x0002) != 0) } - - var isIdle: Bool { + + public var isIdle: Bool { return ((self.status & 0x0001) != 0) } - - static func == (lhs: HotlineUser, rhs: HotlineUser) -> Bool { + + public static func == (lhs: HotlineUser, rhs: HotlineUser) -> Bool { return lhs.id == rhs.id } - - init(id: UInt16, iconID: UInt16, status: UInt16, name: String) { + + public init(id: UInt16, iconID: UInt16, status: UInt16, name: String) { self.id = id self.iconID = iconID self.status = status @@ -535,10 +543,10 @@ struct HotlineUser: Identifiable, Hashable { self.name = data.readString(at: 8, length: userNameLength)! } - func hash(into hasher: inout Hasher) { + public func hash(into hasher: inout Hasher) { hasher.combine(self.id) } - + func encoded() -> [UInt8] { var data: [UInt8] = [] data.appendUInt16(self.id) @@ -819,6 +827,111 @@ struct HotlineTransaction { self.fields.append(HotlineTransactionField(type: type, pathComponents: val)) } + // MARK: - Subscript support for typed field access + // Replaces any existing field with the same type, or removes the field if value is set to nil + private mutating func replaceField(type: HotlineTransactionFieldType, with field: HotlineTransactionField?) { + // Remove existing fields of this type + self.fields.removeAll { $0.type == type } + // Append new field if provided + if let field = field { + self.fields.append(field) + } + } + + // UInt8 + subscript(_ type: HotlineTransactionFieldType) -> UInt8? { + get { self.getField(type: type)?.getUInt8() } + set { + if let v = newValue { + self.replaceField(type: type, with: HotlineTransactionField(type: type, val: v)) + } else { + self.replaceField(type: type, with: nil) + } + } + } + + // UInt16 + subscript(_ type: HotlineTransactionFieldType) -> UInt16? { + get { self.getField(type: type)?.getUInt16() } + set { + if let v = newValue { + self.replaceField(type: type, with: HotlineTransactionField(type: type, val: v)) + } else { + self.replaceField(type: type, with: nil) + } + } + } + + // UInt32 + subscript(_ type: HotlineTransactionFieldType) -> UInt32? { + get { self.getField(type: type)?.getUInt32() } + set { + if let v = newValue { + self.replaceField(type: type, with: HotlineTransactionField(type: type, val: v)) + } else { + self.replaceField(type: type, with: nil) + } + } + } + + // UInt64 + subscript(_ type: HotlineTransactionFieldType) -> UInt64? { + get { self.getField(type: type)?.getUInt64() } + set { + if let v = newValue { + self.replaceField(type: type, with: HotlineTransactionField(type: type, val: v)) + } else { + self.replaceField(type: type, with: nil) + } + } + } + + // String (plain, not obfuscated) + subscript(_ type: HotlineTransactionFieldType) -> String? { + get { self.getField(type: type)?.getString() } + set { + if let v = newValue { + self.replaceField(type: type, with: HotlineTransactionField(type: type, string: v, encoding: .utf8, encrypt: false)) + } else { + self.replaceField(type: type, with: nil) + } + } + } + + // [String] path components +// subscript(path type: HotlineTransactionFieldType) -> [String]? { +// get { +// self.getField(type: type)?.getString() +// } +// set { +// if let v = newValue { +// self.replaceField(type: type, with: HotlineTransactionField(type: type, pathComponents: v)) +// } else { +// self.replaceField(type: type, with: nil) +// } +// } +// } + + // Field list + subscript(_ type: HotlineTransactionFieldType) -> [HotlineTransactionField]? { + get { self.fields.filter { $0.type == type } } + set { + // Remove existing fields of this type + self.fields.removeAll { $0.type == type } + + if let v = newValue { + self.fields.append(contentsOf: v) + } + } + } + + // Field + subscript(_ type: HotlineTransactionFieldType) -> HotlineTransactionField? { + get { self.fields.first { $0.type == type } } + set { self.replaceField(type: type, with: newValue) } + } + + func getField(type: HotlineTransactionFieldType) -> HotlineTransactionField? { return self.fields.first { p in p.type == type @@ -838,7 +951,7 @@ struct HotlineTransaction { data.appendUInt16(self.isReply == 1 ? HotlineTransactionType.reply.rawValue : self.type.rawValue) data.appendUInt32(self.id) data.appendUInt32(self.errorCode) - + if self.fields.count > 0 { var fieldData: [UInt8] = [] fieldData.appendUInt16(UInt16(self.fields.count)) @@ -847,7 +960,7 @@ struct HotlineTransaction { fieldData.appendUInt16(f.dataSize) fieldData.appendData(f.data) } - + data.appendUInt32(UInt32(fieldData.count)) data.appendUInt32(UInt32(fieldData.count)) data.appendData(fieldData) @@ -861,6 +974,64 @@ struct HotlineTransaction { } } +// MARK: - NetSocket Protocol Conformance + +extension HotlineTransaction: NetSocketEncodable { + func encode(endian: Endian) throws -> Data { + return Data(self.encoded()) + } +} + +extension HotlineTransaction: NetSocketDecodable { + /// Decode a Hotline transaction directly from the socket stream + /// + /// Reads the 20-byte header, then reads and decodes the variable-length body with fields. + init(from socket: NetSocketNew, endian: Endian) async throws { + // Read 20-byte header + let flags = try await socket.read(UInt8.self) + let isReply = try await socket.read(UInt8.self) + let typeRaw = try await socket.read(UInt16.self, endian: endian) + let id = try await socket.read(UInt32.self, endian: endian) + let errorCode = try await socket.read(UInt32.self, endian: endian) + let totalSize = try await socket.read(UInt32.self, endian: endian) + let dataSize = try await socket.read(UInt32.self, endian: endian) + + // Initialize with header data + self.flags = flags + self.isReply = isReply + self.type = HotlineTransactionType(rawValue: typeRaw) ?? .unknown + self.id = id + self.errorCode = errorCode + self.totalSize = totalSize + self.dataSize = dataSize + self.fields = [] + + // Read body if present + guard dataSize > 0 else { return } + + let bodyData = try await socket.read(Int(dataSize)) + var fieldBytes = [UInt8](bodyData) + + // Decode fields + guard let fieldCount = fieldBytes.consumeUInt16(), fieldCount > 0 else { return } + + for _ in 0.. 0 { - self.fileResourceURL = fileURL.urlForResourceFork() - } - else { - self.fileResourceURL = nil - } - } - - deinit { - self.invalidate() - } - - func start() { - guard self.status == .unconnected else { - return - } - - let _ = self.fileURL.startAccessingSecurityScopedResource() - let _ = self.fileResourceURL?.stopAccessingSecurityScopedResource() - - self.bytesSent = 0 - self.connect() - } - - func cancel() { - self.delegate = nil - - if self.status == .unconnected { - return - } - - self.invalidate() - - print("HotlineFileUploadClient: Cancelled upload") - } - - private func connect() { - self.connection = NWConnection(host: self.serverAddress, port: self.serverPort, using: .tcp) - self.connection?.stateUpdateHandler = { [weak self] (newState: NWConnection.State) in - switch newState { - case .ready: - self?.status = .connected - self?.stage = .magic - self?.send() - case .waiting(let err): - print("HotlineFileClient: Waiting", err) - case .cancelled: - print("HotlineFileClient: Cancelled") - self?.invalidate() - case .failed(let err): - print("HotlineFileClient: Connection error \(err)") - switch self?.status { - case .connecting: - print("HotlineFileClient: Failed to connect to file transfer server.") - self?.invalidate() - self?.status = .failed(.failedToConnect) - case .connected, .progress(_): - print("HotlineFileClient: Failed to finish transfer.") - self?.invalidate() - self?.status = .failed(.failedToUpload) - case .completing: - print("HotlineFileClient: Completed.") - self?.invalidate() - self?.status = .completed - DispatchQueue.main.async { [weak self] in - if let s = self { - s.delegate?.hotlineFileUploadComplete(client: s, reference: s.referenceNumber) - } - } - case .completed: - self?.invalidate() - default: - break - } - default: - return - } - } - - self.status = .connecting - self.connection?.start(queue: .global()) - } - - private func invalidate() { - if let c = self.connection { - c.stateUpdateHandler = nil - c.cancel() - - self.connection = nil - } - - self.stage = .magic - - if let fh = self.fileHandle { - try? fh.close() - self.fileHandle = nil - } - - self.fileURL.stopAccessingSecurityScopedResource() - self.fileResourceURL?.stopAccessingSecurityScopedResource() - } - - private func sendComplete() { - guard let c = self.connection else { - self.invalidate() - print("HotlineFileUploadClient: invalid connection to send data.") - return - } - - self.status = .completing - - c.send(content: nil, contentContext: .finalMessage, completion: .contentProcessed({ error in - })) - } - - private func sendFileData(_ data: Data) { - guard let c = self.connection else { - self.invalidate() - - print("HotlineFileUploadClient: invalid connection to send data.") - return - } - - let dataSent: Int = data.count - c.send(content: data, completion: .contentProcessed({ [weak self] error in - guard let client = self, - error == nil else { - self?.status = .failed(.failedToConnect) - self?.invalidate() - return - } - - - client.bytesSent += dataSent - client.status = .progress(Double(client.bytesSent) / Double(client.payloadSize)) - - client.send() - })) - } - - private func send() { - guard let _ = self.connection else { - self.invalidate() - print("HotlineFileUploadClient: Invalid connection to send.") - return - } - - switch self.stage { - case .magic: - print("Upload: Starting upload for \(self.fileURL)") - print("Upload: Sending magic") - self.status = .progress(0.0) - - let magicData = Data(endian: .big) { - "HTXF".fourCharCode() - self.referenceNumber - self.payloadSize - UInt32.zero - } - // var magicData = Data() - // magicData.appendUInt32("HTXF".fourCharCode()) - // magicData.appendUInt32(self.referenceNumber) - // magicData.appendUInt32(self.payloadSize) - // magicData.appendUInt32(0) - self.stage = .fileHeader - self.sendFileData(magicData) - - case .fileHeader: - print("Upload: Sending file header") - if let header = HotlineFileHeader(file: self.fileURL) { - self.stage = .fileInfoForkHeader - self.sendFileData(header.data()) - } - - case .fileInfoForkHeader: - print("Upload: Sending info fork header") - let header = HotlineFileForkHeader(type: HotlineFileForkType.info.rawValue, dataSize: UInt32(self.infoForkData.count)) - self.stage = .fileInfoFork - self.sendFileData(header.data()) - - case .fileInfoFork: - print("Upload: Sending info fork") - self.stage = .fileDataForkHeader - self.sendFileData(self.infoForkData) - - case .fileDataForkHeader: - guard self.dataForkSize > 0 else { - print("Upload: Data fork empty, skipping to resource fork") - self.stage = .fileResourceForkHeader - fallthrough - } - - do { - let fh = try FileHandle(forReadingFrom: self.fileURL) - self.fileHandle = fh - self.stage = .fileDataFork - - let header = HotlineFileForkHeader(type: HotlineFileForkType.data.rawValue, dataSize: self.dataForkSize) - - print("Upload: Sending data fork header \(self.dataForkSize)") - self.sendFileData(header.data()) - } - catch { - print("Upload: Error opening data fork", error) - self.invalidate() - return - } - - case .fileDataFork: - guard self.dataForkSize > 0, - let fh = self.fileHandle else { - print("Upload: Data fork empty, skipping to resource fork") - self.stage = .fileResourceForkHeader - try? self.fileHandle?.close() - self.fileHandle = nil - fallthrough - } - - do { - let fileData = try fh.read(upToCount: 4 * 1024) - if fileData == nil || fileData?.isEmpty == true { - print("Upload: Finished data fork") - self.stage = .fileResourceForkHeader - try? fh.close() - self.fileHandle = nil - fallthrough - } - - print("Upload: Sending data Fork \(String(describing: fileData?.count))") - self.sendFileData(fileData!) - } - catch { - self.invalidate() - print("Upload: Error reading data fork", error) - return - } - - case .fileResourceForkHeader: - guard self.resourceForkSize > 0, - let resourceURL = self.fileResourceURL else { - print("Upload: Skipping resource fork header") - self.stage = .fileComplete - fallthrough - } - - print("Upload: Sending resource fork header") - guard let fh = try? FileHandle(forReadingFrom: resourceURL) else { - print("Upload: Error reading resource fork") - self.invalidate() - return - } - - let header = HotlineFileForkHeader(type: HotlineFileForkType.resource.rawValue, dataSize: self.resourceForkSize) - - self.fileHandle = fh - self.stage = .fileResourceFork - self.sendFileData(header.data()) - - case .fileResourceFork: - guard self.resourceForkSize > 0, - let fh = self.fileHandle else { - print("Upload: Resource fork empty, skipping to completion") - self.stage = .fileComplete - try? self.fileHandle?.close() - self.fileHandle = nil - fallthrough - } - - do { - let resourceData = try fh.read(upToCount: 4 * 1024) - if resourceData == nil || resourceData?.isEmpty == true { - print("Upload: Finished resource fork") - self.stage = .fileComplete - try? self.fileHandle?.close() - self.fileHandle = nil - fallthrough - } - - print("Upload: Sending resource fork \(String(describing: resourceData?.count))") - self.sendFileData(resourceData!) - } - catch { - self.invalidate() - print("Upload: Error reading resource fork", error) - return - } - break - - case .fileComplete: - print("Upload: Complete!") - self.sendComplete() - } - } -} - -// MARK: - - -class HotlineFilePreviewClient: HotlineTransferClient { - let serverAddress: NWEndpoint.Host - let serverPort: NWEndpoint.Port - let referenceNumber: UInt32 - let referenceDataSize: UInt32 - - weak var delegate: HotlineFilePreviewClientDelegate? = nil - - var status: HotlineTransferStatus = .unconnected { - didSet { - DispatchQueue.main.async { - self.delegate?.hotlineTransferStatusChanged(client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0) - } - } - } - - private var downloadTask: Task? - - init(address: String, port: UInt16, reference: UInt32, size: UInt32) { - self.serverAddress = NWEndpoint.Host(address) - self.serverPort = NWEndpoint.Port(rawValue: port + 1)! - self.referenceNumber = reference - self.referenceDataSize = size - } - - deinit { - self.downloadTask?.cancel() - } - - func start() { - guard status == .unconnected else { - return - } - - self.downloadTask = Task { - await self.download() - } - } - - func cancel() { - self.downloadTask?.cancel() - self.downloadTask = nil - self.delegate = nil - - print("HotlineFilePreviewClient: Cancelled preview transfer") - } - - private func download() async { - self.status = .connecting - - do { - // Connect to file transfer server (already includes +1 in serverPort from init) - let socket = try await NetSocketNew.connect( - host: self.serverAddress, - port: self.serverPort, - tls: .disabled - ) - defer { Task { await socket.close() } } - - self.status = .connected - - // Send magic header - let headerData = Data(endian: .big) { - "HTXF".fourCharCode() - self.referenceNumber - UInt32.zero - UInt32.zero - } - try await socket.write(headerData) - - self.status = .progress(0.0) - - // Download file data with progress updates - let fileData = try await socket.read(Int(self.referenceDataSize)) { current, total in - self.status = .progress(Double(current) / Double(total)) - } - - print("HotlineFilePreviewClient: Complete") - status = .completed - - // Notify delegate on main thread - let reference = self.referenceNumber - await MainActor.run { - self.delegate?.hotlineFilePreviewComplete(client: self, reference: reference, data: fileData) - } - - } catch is CancellationError { - // Already handled in cancel() - return - } catch { - print("HotlineFilePreviewClient: Download failed: \(error)") - - if self.status == .connecting { - self.status = .failed(.failedToConnect) - } else { - self.status = .failed(.failedToDownload) - } - } - } -} - -// MARK: - - -class HotlineFileDownloadClient: HotlineTransferClient { - let serverAddress: NWEndpoint.Host - let serverPort: NWEndpoint.Port - let referenceNumber: UInt32 - - private var connection: NWConnection? - private var transferStage: HotlineFileTransferStage = .fileHeader - - weak var delegate: HotlineFileDownloadClientDelegate? = nil - - var status: HotlineTransferStatus = .unconnected { - didSet { - DispatchQueue.main.async { - self.delegate?.hotlineTransferStatusChanged(client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0) - } - } - } - - private let referenceDataSize: UInt32 - private var fileBytes = Data() - private var fileResourceBytes = Data() - - private var fileHeader: HotlineFileHeader? = nil - private var fileCurrentForkHeader: HotlineFileForkHeader? = nil - private var fileCurrentForkBytesLeft: Int = 0 - private var fileInfoFork: HotlineFileInfoFork? = nil - private var fileHandle: FileHandle? = nil - private var filePath: String? = nil - private var fileBytesTransferred: Int = 0 - private var fileProgress: Progress - - init(address: String, port: UInt16, reference: UInt32, size: UInt32) { - self.serverAddress = NWEndpoint.Host(address) - self.serverPort = NWEndpoint.Port(rawValue: port + 1)! - self.referenceNumber = reference - self.referenceDataSize = size - self.transferStage = .fileHeader - self.fileProgress = Progress(totalUnitCount: Int64(size)) - } - - deinit { - self.invalidate() - } - - func start() { - guard self.status == .unconnected else { - return - } - - self.filePath = nil - self.connect() - } - - func start(to fileURL: URL) { - guard self.status == .unconnected else { - return - } - - self.filePath = fileURL.path - self.connect() - } - - func cancel() { - self.delegate = nil - - if self.status == .unconnected { - return - } - - // Close file before we try to potentionally delete it. - if let fh = self.fileHandle { - try? fh.close() - self.fileHandle = nil - } - - if let downloadPath = self.filePath { - print("HotlineFileClient: Deleting file fragment at", downloadPath) - if FileManager.default.isDeletableFile(atPath: downloadPath) { - try? FileManager.default.removeItem(atPath: downloadPath) - } - self.filePath = nil - } - - self.invalidate() - - print("HotlineFileClient: Cancelled transfer") - } - - private func connect() { - self.connection = NWConnection(host: self.serverAddress, port: self.serverPort, using: .tcp) - self.connection?.stateUpdateHandler = { [weak self] (newState: NWConnection.State) in - switch newState { - case .ready: - self?.status = .connected - self?.sendMagic() - case .waiting(let err): - print("HotlineFileClient: Waiting", err) - case .cancelled: - print("HotlineFileClient: Cancelled") - self?.invalidate() - case .failed(let err): - print("HotlineFileClient: Connection error \(err)") - switch self?.status { - case .connecting: - print("HotlineFileClient: Failed to connect to file transfer server.") - self?.invalidate() - self?.status = .failed(.failedToConnect) - case .connected, .progress(_): - print("HotlineFileClient: Failed to finish transfer.") - self?.invalidate() - self?.status = .failed(.failedToDownload) - default: - break - } - default: - return - } - } - - self.status = .connecting - self.connection?.start(queue: .global()) - } - - func invalidate() { - if let c = self.connection { - c.stateUpdateHandler = nil - c.cancel() - - self.connection = nil - } - - self.fileBytes = Data() - - if let fh = self.fileHandle { - try? fh.close() - self.fileHandle = nil - } - - self.fileProgress.unpublish() - } - - private func sendMagic() { - guard let c = connection, self.status == .connected else { - self.invalidate() - print("HotlineFileClient: invalid connection to send header.") - return - } - - let headerData = Data(endian: .big) { - "HTXF".fourCharCode() - self.referenceNumber - UInt32.zero - UInt32.zero - } - - c.send(content: headerData, completion: .contentProcessed { [weak self] (error) in - guard let self = self else { - return - } - - guard error == nil else { - self.status = .failed(.failedToConnect) - self.invalidate() - return - } - - self.status = .progress(0.0) - self.receiveFile() - }) - } - - private func receiveFile() { - guard let c = self.connection else { - return - } - - c.receive(minimumIncompleteLength: 1, maximumLength: Int(UInt16.max)) { [weak self] (data, context, isComplete, error) in - guard let self = self else { - return - } - - guard error == nil else { - self.status = .failed(.failedToDownload) - self.invalidate() - return - } - - if let newData = data, !newData.isEmpty { - self.fileBytesTransferred += newData.count - self.fileBytes.append(newData) - self.fileProgress.completedUnitCount = Int64(self.fileBytesTransferred) - self.status = .progress(Double(self.fileBytesTransferred) / Double(self.referenceDataSize)) - } - - // See if we need header data still. - var keepProcessing = false - repeat { - keepProcessing = false - - switch self.transferStage { - case .fileHeader: - if let header = HotlineFileHeader(from: self.fileBytes) { - self.fileBytes.removeSubrange(0..= infoForkDataSize { - let infoForkData = self.fileBytes.subdata(in: 0.. 0 { - if let f = self.fileHandle { - do { - var dataToWrite = self.fileBytes - - if dataToWrite.count >= self.fileCurrentForkBytesLeft { - dataToWrite = self.fileBytes.subdata(in: 0.. 0 { - var dataToWrite = self.fileBytes - - if dataToWrite.count >= self.fileCurrentForkBytesLeft { - dataToWrite = self.fileBytes.subdata(in: 0.. 0 { - var dataToWrite = self.fileBytes - - if dataToWrite.count >= self.fileCurrentForkBytesLeft { - dataToWrite = self.fileBytes.subdata(in: 0.. 0 { - let _ = self.writeResourceFork() - self.fileResourceBytes = Data() - } - - self.status = .completed - - if let downloadPath = self.filePath { - DispatchQueue.main.sync { - print("POSTING DOWNLOAD FILE FINISHED", downloadPath) - - var downloadURL = URL(filePath: downloadPath) - downloadURL.resolveSymlinksInPath() - print("FINAL PATH", downloadURL.path) - - self.delegate?.hotlineFileDownloadComplete(client: self, reference: self.referenceNumber, at: downloadURL) - -#if os(macOS) - // Bounce dock icon when download completes. Weird this is the only API to do so. - DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path) -#endif - } - } - } - } - } - - private func writeResourceFork() -> Bool { - guard let filePath = self.filePath else { - return false - } - - var resolvedFileURL = URL(filePath: filePath) - resolvedFileURL.resolveSymlinksInPath() - - let resourceFilePath = resolvedFileURL.appendingPathComponent("..namedfork/rsrc") - - do { - try self.fileResourceBytes.write(to: resourceFilePath) - } - catch { - return false - } - - return true - } - - private func prepareDownloadFile(name: String) -> Bool { - var filePath: String - - if self.filePath != nil { - filePath = self.filePath! - } - else { - let folderURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0] - filePath = folderURL.generateUniqueFilePath(filename: name) - } - - var fileAttributes: [FileAttributeKey: Any] = [:] - if let creatorCode = self.fileInfoFork?.creator { - fileAttributes[.hfsCreatorCode] = creatorCode as NSNumber - } - if let typeCode = self.fileInfoFork?.type { - fileAttributes[.hfsTypeCode] = typeCode as NSNumber - } - if let createdDate = self.fileInfoFork?.createdDate { - fileAttributes[.creationDate] = createdDate as NSDate - } - if let modifiedDate = self.fileInfoFork?.modifiedDate { - fileAttributes[.modificationDate] = modifiedDate as NSDate - } - if let comment = self.fileInfoFork?.comment { - if let commentPlistData = try? PropertyListSerialization.data(fromPropertyList: comment, format: .binary, options: 0) { - fileAttributes[FileAttributeKey(rawValue: "NSFileExtendedAttributes")] = [ - FileAttributeKey(rawValue: "com.apple.metadata:kMDItemFinderComment"): commentPlistData - ] - } - } - - if FileManager.default.createFile(atPath: filePath, contents: nil, attributes: fileAttributes) { - if let h = FileHandle(forWritingAtPath: filePath) { - self.filePath = filePath - self.fileHandle = h - self.fileProgress.fileURL = URL(filePath: filePath).resolvingSymlinksInPath() - self.fileProgress.fileOperationKind = .downloading - self.fileProgress.publish() - return true - } - } - - return false - } -} - -// MARK: - struct HotlineFileHeader { static let DataSize: Int = 4 + 2 + 16 + 2 @@ -1017,8 +75,8 @@ struct HotlineFileHeader { self.format = "FILP".fourCharCode() self.version = 1 - - let resourceURL = fileURL.appendingPathComponent("..namedfork/rsrc") + + let resourceURL = fileURL.urlForResourceFork() if FileManager.default.fileExists(atPath: resourceURL.path(percentEncoded: false)) { self.forkCount = 3 } @@ -1109,7 +167,7 @@ struct HotlineFileInfoFork { } self.platform = "AMAC".fourCharCode() - + if let hfsInfo = try? FileManager.default.getHFSTypeAndCreator(fileURL) { self.type = hfsInfo.hfsType self.creator = hfsInfo.hfsCreator @@ -1118,23 +176,23 @@ struct HotlineFileInfoFork { 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 { @@ -1227,828 +285,1836 @@ struct HotlineFileInfoFork { } } + +//protocol HotlineTransferDelegate: AnyObject { +// @MainActor func hotlineTransferStatusChanged(client: HotlineTransferClient, reference: UInt32, status: HotlineTransferStatus, timeRemaining: TimeInterval) +//} + +//protocol HotlineFileDownloadClientDelegate: HotlineTransferDelegate { +// @MainActor func hotlineFileDownloadReceivedInfo(client: HotlineFileDownloadClient, reference: UInt32, info: HotlineFileInfoFork) +// @MainActor func hotlineFileDownloadComplete(client: HotlineFileDownloadClient, reference: UInt32, at: URL) +//} + +//protocol HotlineFilePreviewClientDelegate: HotlineTransferDelegate { +// @MainActor func hotlineFilePreviewComplete(client: HotlineFilePreviewClient, reference: UInt32, data: Data) +//} + +//protocol HotlineFileUploadClientDelegate: HotlineTransferDelegate { +// @MainActor func hotlineFileUploadComplete(client: HotlineFileUploadClient, reference: UInt32) +//} + +//protocol HotlineFolderDownloadClientDelegate: HotlineTransferDelegate { +// @MainActor func hotlineFolderDownloadReceivedFileInfo(client: HotlineFolderDownloadClient, reference: UInt32, fileName: String, itemNumber: Int, totalItems: Int) +// @MainActor func hotlineFolderDownloadComplete(client: HotlineFolderDownloadClient, reference: UInt32, at: URL) +//} + +//enum HotlineFileTransferStage: Int { +// case fileHeader = 1 +// case fileForkHeader = 2 +// case fileInfoFork = 3 +// case fileDataFork = 4 +// case fileResourceFork = 5 +// case fileUnsupportedFork = 6 +//} + +//enum HotlineFileUploadStage: Int { +// case magic = 1 +// case fileHeader = 2 +// case fileInfoForkHeader = 3 +// case fileInfoFork = 4 +// case fileDataForkHeader = 5 +// case fileDataFork = 6 +// case fileResourceForkHeader = 7 +// case fileResourceFork = 8 +// case fileComplete = 9 +//} + +//enum HotlineFolderDownloadStage: Int { +// case itemHeader = 0 // Read 2-byte length + item header +// case waitingForFileSize = 1 // Read 4-byte file size before FILP +// case fileHeader = 2 +// case fileForkHeader = 3 +// case fileInfoFork = 4 +// case fileDataFork = 5 +// case fileResourceFork = 6 +// case fileUnsupportedFork = 7 +//} + + // MARK: - -class HotlineFolderDownloadClient: HotlineTransferClient { - let serverAddress: NWEndpoint.Host - let serverPort: NWEndpoint.Port - let referenceNumber: UInt32 - - private var connection: NWConnection? - private var transferStage: HotlineFolderDownloadStage = .fileHeader - - weak var delegate: HotlineFolderDownloadClientDelegate? = nil - - var status: HotlineTransferStatus = .unconnected { - didSet { - DispatchQueue.main.async { - self.delegate?.hotlineTransferStatusChanged(client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0) - } - } - } - - private let referenceDataSize: UInt32 - private let folderItemCount: Int - private var fileBytes = Data() - private var fileResourceBytes = Data() - - private var fileHeader: HotlineFileHeader? = nil - private var fileCurrentForkHeader: HotlineFileForkHeader? = nil - private var fileCurrentForkBytesLeft: Int = 0 - private var fileForksRemaining: Int = 0 - private var currentFileSize: UInt32 = 0 // Size of current file from server - private var currentFileBytesRead: Int = 0 // Bytes read for current file - private var fileInfoFork: HotlineFileInfoFork? = nil - private var fileHandle: FileHandle? = nil - private var currentFilePath: String? = nil - private var fileBytesTransferred: Int = 0 - private var fileProgress: Progress - - private var currentItemNumber: Int = 0 - private var completedItemCount: Int = 0 // Track actually completed files - private var folderPath: String? = nil - private var currentItemRelativePath: [String] = [] - private var currentFileName: String? = nil - - private let FILP_MAGIC: UInt32 = "FILP".fourCharCode() - - init(address: String, port: UInt16, reference: UInt32, size: UInt32, itemCount: Int) { - self.serverAddress = NWEndpoint.Host(address) - self.serverPort = NWEndpoint.Port(rawValue: port + 1)! - self.referenceNumber = reference - self.referenceDataSize = size - self.folderItemCount = itemCount - self.transferStage = .fileHeader - self.fileProgress = Progress(totalUnitCount: Int64(size)) - } - - deinit { - self.invalidate() - } - - func start() { - guard self.status == .unconnected else { - return - } - - self.folderPath = nil - self.connect() - } - - func start(to folderURL: URL) { - print("HotlineFolderDownloadClient: start(to:) called with path: \(folderURL.path)") - guard self.status == .unconnected else { - print("HotlineFolderDownloadClient: Already connected, status: \(self.status)") - return - } - - self.folderPath = folderURL.path - print("HotlineFolderDownloadClient: Calling connect()") - self.connect() - } - - func cancel() { - self.delegate = nil - - if self.status == .unconnected { - return - } - - // Close file before we try to potentially delete it. - if let fh = self.fileHandle { - try? fh.close() - self.fileHandle = nil - } - - if let downloadPath = self.folderPath { - print("HotlineFolderDownloadClient: Deleting folder fragment at", downloadPath) - if FileManager.default.isDeletableFile(atPath: downloadPath) { - try? FileManager.default.removeItem(atPath: downloadPath) - } - self.folderPath = nil - } - - self.invalidate() - - print("HotlineFolderDownloadClient: Cancelled transfer") - } - - private func connect() { - print("HotlineFolderDownloadClient: connect() called, connecting to \(self.serverAddress):\(self.serverPort)") - self.connection = NWConnection(host: self.serverAddress, port: self.serverPort, using: .tcp) - print("HotlineFolderDownloadClient: NWConnection created") - self.connection?.stateUpdateHandler = { [weak self] (newState: NWConnection.State) in - switch newState { - case .ready: - print("HotlineFolderDownloadClient: Connection ready!") - self?.status = .connected - self?.sendMagic() - case .waiting(let err): - print("HotlineFolderDownloadClient: Waiting", err) - case .cancelled: - print("HotlineFolderDownloadClient: Cancelled") - self?.invalidate() - case .failed(let err): - print("HotlineFolderDownloadClient: Connection error \(err)") - switch self?.status { - case .connecting: - print("HotlineFolderDownloadClient: Failed to connect to file transfer server.") - self?.invalidate() - self?.status = .failed(.failedToConnect) - case .connected, .progress(_): - print("HotlineFolderDownloadClient: Failed to finish transfer.") - self?.invalidate() - self?.status = .failed(.failedToDownload) - default: - break - } - default: - return - } - } - - self.status = .connecting - self.connection?.start(queue: .global()) - } - - func invalidate() { - if let c = self.connection { - c.stateUpdateHandler = nil - c.cancel() - - self.connection = nil - } - - self.fileBytes = Data() - - if let fh = self.fileHandle { - try? fh.close() - self.fileHandle = nil - } - - self.fileProgress.unpublish() - } - - private func 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) - } - - /// Find and align the buffer to the start of a FILP header. - /// Returns the number of bytes dropped. - @discardableResult - private func alignBufferToFILP() -> Int { - // We need at least the 4-byte magic to try - guard self.fileBytes.count >= 4 else { return 0 } - let magicData = Data([0x46, 0x49, 0x4C, 0x50]) // "FILP" - if self.fileBytes.starts(with: magicData) { return 0 } - - if let r = self.fileBytes.firstRange(of: magicData) { - let toDrop = r.lowerBound - if toDrop > 0 { - print("HotlineFolderDownloadClient: Resync — dropping \(toDrop) stray bytes before FILP") - self.fileBytes.removeSubrange(0..= 2 else { break } - let headerLen = Int(self.fileBytes.readUInt16(at: 0)!) - guard self.fileBytes.count >= 2 + headerLen else { break } - - let headerData = self.fileBytes.subdata(in: 2..<(2 + headerLen)) - - guard let parsed = parseItemHeaderPath(headerData) else { - print("HotlineFolderDownloadClient: Invalid item header; waiting for more data") - break - } - - // Consume the header from the buffer - self.fileBytes.removeSubrange(0..<(2 + headerLen)) - - let itemType = parsed.type - let comps = parsed.components - let joinedPath = comps.joined(separator: "/") - print("HotlineFolderDownloadClient: item type=\(itemType) path=\(joinedPath)") - - guard !comps.isEmpty else { - print("HotlineFolderDownloadClient: Empty path components for item type \(itemType); requesting next item") - self.sendAction(.nextFile) - keepProcessing = !self.fileBytes.isEmpty - break - } - - if itemType == 1 { - // Folder entries: create the directory locally and continue. - self.currentItemRelativePath = comps - - if let base = self.folderPath { - var dir = base - for c in comps { dir = (dir as NSString).appendingPathComponent(c) } - do { - try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) - print("HotlineFolderDownloadClient: Created folder at \(dir)") - } catch { - print("HotlineFolderDownloadClient: Failed to create subfolder: \(error)") - } - } - - self.completedItemCount += 1 - - if self.completedItemCount >= self.folderItemCount { - self.handleAllItemsDownloaded() - return - } - - self.sendAction(.nextFile) - keepProcessing = !self.fileBytes.isEmpty - break - } - else if itemType == 0 { - // File entries include the full path; split parent components from filename. - self.currentItemRelativePath = Array(comps.dropLast()) - self.currentFileName = comps.last - - self.transferStage = .waitingForFileSize - print("HotlineFolderDownloadClient: Requesting file download for '\(self.currentFileName ?? "?")'") - self.sendAction(.sendFile) - - if self.fileBytes.count >= 4 { - keepProcessing = true - } - break - } - else { - print("HotlineFolderDownloadClient: Unknown item type \(itemType); skipping") - self.sendAction(.nextFile) - keepProcessing = !self.fileBytes.isEmpty - break - } - - case .waitingForFileSize: - // Read 4-byte file size that comes before FILP in folder mode - guard self.fileBytes.count >= 4 else { break } - let fileSize = self.fileBytes.readUInt32(at: 0)! - self.fileBytes.removeSubrange(0..<4) - - print("HotlineFolderDownloadClient: File size: \(fileSize) bytes") - - self.currentFileSize = fileSize - self.currentFileBytesRead = 0 - - // Align to FILP boundary before decoding the file header - let dropped = self.alignBufferToFILP() - if dropped > 0 { - // These bytes were in the stream for this file but not part of FILP. - // Keep byte-accounting consistent by shrinking the expected size. - if self.currentFileSize >= UInt32(dropped) { - self.currentFileSize -= UInt32(dropped) - } else { - // Defensive: if weird, treat as zero-size to avoid underflow - self.currentFileSize = 0 - } - } - - self.transferStage = .fileHeader - keepProcessing = true - - case .fileHeader: - // Make sure we're actually at a FILP header - if self.fileBytes.count >= HotlineFileHeader.DataSize { - // If the 4-byte magic doesn't match, try one more resync here - if self.fileBytes.readUInt32(at: 0)! != FILP_MAGIC { - let dropped = self.alignBufferToFILP() - if dropped > 0 { - if self.currentFileSize >= UInt32(dropped) { self.currentFileSize -= UInt32(dropped) } - } - // If still not aligned or not enough bytes, wait for more data - guard self.fileBytes.count >= HotlineFileHeader.DataSize, - self.fileBytes.readUInt32(at: 0)! == FILP_MAGIC else { break } - } - - if let header = HotlineFileHeader(from: self.fileBytes) { - // Sanity gate: version and fork count - if header.format != FILP_MAGIC || header.version == 0 || header.forkCount > 3 { - print("HotlineFolderDownloadClient: Invalid FILP header (fmt=\(String(format:"0x%08X", header.format)), ver=\(header.version), forks=\(header.forkCount)). Resyncing.") - // Try resync and wait for more data - let dropped = self.alignBufferToFILP() - if dropped > 0 { if self.currentFileSize >= UInt32(dropped) { self.currentFileSize -= UInt32(dropped) } } - break - } - - self.fileBytes.removeSubrange(0..= self.folderItemCount { - self.handleAllItemsDownloaded() - return - } - - // More files to download - // Check if server has pipelined all remaining data (we've received more than expected total) - print("HotlineFolderDownloadClient: Completed \(self.completedItemCount)/\(self.folderItemCount) items, requesting next entry") - self.transferStage = .itemHeader - self.sendAction(.nextFile) - keepProcessing = !self.fileBytes.isEmpty - } - // File not complete - try to read next fork header - else if let forkHeader = HotlineFileForkHeader(from: self.fileBytes) { - // Quick validation: first fork must be INFO, and sizes must be plausible - let remainingForThisFile = max(0, Int(self.currentFileSize) - self.currentFileBytesRead) - let plausible = forkHeader.dataSize <= UInt32(remainingForThisFile) - let knownType = forkHeader.isInfoFork || forkHeader.isDataFork || forkHeader.isResourceFork - - if !plausible || (!knownType && self.currentFileBytesRead == HotlineFileHeader.DataSize) { - print("HotlineFolderDownloadClient: Implausible fork header (type=\(String(format:"0x%08X", forkHeader.forkType)), size=\(forkHeader.dataSize), remaining=\(remainingForThisFile)). Resyncing.") - let dropped = self.alignBufferToFILP() - if dropped > 0 { if self.currentFileSize >= UInt32(dropped) { self.currentFileSize -= UInt32(dropped) } } - break - } - - self.fileBytes.removeSubrange(0..= infoForkDataSize { - let infoForkData = self.fileBytes.subdata(in: 0.. 0 { - if let f = self.fileHandle { - do { - var dataToWrite = self.fileBytes - - if dataToWrite.count >= self.fileCurrentForkBytesLeft { - dataToWrite = self.fileBytes.subdata(in: 0.. 0 { - var dataToWrite = self.fileBytes - - if dataToWrite.count >= self.fileCurrentForkBytesLeft { - dataToWrite = self.fileBytes.subdata(in: 0.. 0 { - var dataToWrite = self.fileBytes - - if dataToWrite.count >= self.fileCurrentForkBytesLeft { - dataToWrite = self.fileBytes.subdata(in: 0.. 0 { - let _ = self.writeResourceFork() - self.fileResourceBytes = Data() - } - - self.fileCurrentForkHeader = nil - self.fileCurrentForkBytesLeft = 0 - self.fileForksRemaining = 0 - self.currentFileBytesRead = 0 - self.currentFileSize = 0 - self.currentFilePath = nil - self.fileInfoFork = nil - self.fileHeader = nil - self.currentFileName = nil - } - - private func handleAllItemsDownloaded() { - guard self.status != .completed else { - return - } - - print("HotlineFolderDownloadClient: All \(self.folderItemCount) items downloaded") - - self.invalidate() - self.status = .completed - - if let downloadPath = self.folderPath { - DispatchQueue.main.sync { - print("HotlineFolderDownloadClient: Folder download complete", downloadPath) - - var downloadURL = URL(filePath: downloadPath) - downloadURL.resolveSymlinksInPath() - - self.delegate?.hotlineFolderDownloadComplete(client: self, reference: self.referenceNumber, at: downloadURL) - -#if os(macOS) - DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path) -#endif - } - } - } - - private func writeResourceFork() -> Bool { - guard let filePath = self.currentFilePath else { - return false - } - - var resolvedFileURL = URL(filePath: filePath) - resolvedFileURL.resolveSymlinksInPath() - - let resourceFilePath = resolvedFileURL.appendingPathComponent("..namedfork/rsrc") - - do { - try self.fileResourceBytes.write(to: resourceFilePath) - } - catch { - return false - } - - return true - } - - private func prepareDownloadFile(name: String) -> Bool { - var filePath: String - - if self.folderPath != nil { - // Build the full path including subfolders - var fullPath = self.folderPath! - for component in self.currentItemRelativePath { - fullPath = (fullPath as NSString).appendingPathComponent(component) - } - - let folderURL = URL(filePath: fullPath) - - // Create folder if it doesn't exist - if !FileManager.default.fileExists(atPath: folderURL.path) { - do { - try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) - } - catch { - print("HotlineFolderDownloadClient: Failed to create folder", error) - return false - } - } - - filePath = folderURL.appendingPathComponent(name).path - print("HotlineFolderDownloadClient: Creating file at \(filePath)") - } - else { - let downloadsURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0] - filePath = downloadsURL.generateUniqueFilePath(filename: name) - } - - var fileAttributes: [FileAttributeKey: Any] = [:] - if let creatorCode = self.fileInfoFork?.creator { - fileAttributes[.hfsCreatorCode] = creatorCode as NSNumber - } - if let typeCode = self.fileInfoFork?.type { - fileAttributes[.hfsTypeCode] = typeCode as NSNumber - } - if let createdDate = self.fileInfoFork?.createdDate { - fileAttributes[.creationDate] = createdDate as NSDate - } - if let modifiedDate = self.fileInfoFork?.modifiedDate { - fileAttributes[.modificationDate] = modifiedDate as NSDate - } - if let comment = self.fileInfoFork?.comment { - if let commentPlistData = try? PropertyListSerialization.data(fromPropertyList: comment, format: .binary, options: 0) { - fileAttributes[FileAttributeKey(rawValue: "NSFileExtendedAttributes")] = [ - FileAttributeKey(rawValue: "com.apple.metadata:kMDItemFinderComment"): commentPlistData - ] - } - } - - if FileManager.default.createFile(atPath: filePath, contents: nil, attributes: fileAttributes) { - if let h = FileHandle(forWritingAtPath: filePath) { - self.currentFilePath = filePath - self.fileHandle = h - - // Only set file progress on first file - if self.currentItemNumber == 1 && self.folderPath != nil { - self.fileProgress.fileURL = URL(filePath: self.folderPath!).resolvingSymlinksInPath() - self.fileProgress.fileOperationKind = .downloading - self.fileProgress.publish() - } - - return true - } - } - - return false - } -} +//class HotlineFileUploadClient: HotlineTransferClient { +// let serverAddress: NWEndpoint.Host +// let serverPort: NWEndpoint.Port +// let referenceNumber: UInt32 +// +// weak var delegate: HotlineFileUploadClientDelegate? = nil +// +// private var connection: NWConnection? +// private var stage: HotlineFileUploadStage = .magic +// private var payloadSize: UInt32 = 0 +// private let fileURL: URL +// private let fileResourceURL: URL? +// private var fileHandle: FileHandle? = nil +// private var bytesSent: Int = 0 +// private let infoForkData: Data +// private let dataForkSize: UInt32 +// private let resourceForkSize: UInt32 +// +// var status: HotlineTransferStatus = .unconnected { +// didSet { +// DispatchQueue.main.async { +// self.delegate?.hotlineTransferStatusChanged(client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0) +// } +// } +// } +// +// init?(upload fileURL: URL, address: String, port: UInt16, reference: UInt32) { +// guard let payloadSize = FileManager.default.getFlattenedFileSize(fileURL) else { +// return nil +// } +// +// guard let infoFork = HotlineFileInfoFork(file: fileURL) else { +// return nil +// } +// +// guard let forkSizes = try? FileManager.default.getFileForkSizes(fileURL) else { +// return nil +// } +// +// self.serverAddress = NWEndpoint.Host(address) +// self.serverPort = NWEndpoint.Port(rawValue: port + 1)! +// self.referenceNumber = reference +// self.stage = .magic +// self.payloadSize = UInt32(payloadSize) +// self.fileURL = fileURL +// self.infoForkData = infoFork.data() +// self.dataForkSize = forkSizes.dataForkSize +// self.resourceForkSize = forkSizes.resourceForkSize +// if forkSizes.resourceForkSize > 0 { +// self.fileResourceURL = fileURL.urlForResourceFork() +// } +// else { +// self.fileResourceURL = nil +// } +// } +// +// deinit { +// self.invalidate() +// } +// +// func start() { +// guard self.status == .unconnected else { +// return +// } +// +// let _ = self.fileURL.startAccessingSecurityScopedResource() +// let _ = self.fileResourceURL?.stopAccessingSecurityScopedResource() +// +// self.bytesSent = 0 +// self.connect() +// } +// +// func cancel() { +// self.delegate = nil +// +// if self.status == .unconnected { +// return +// } +// +// self.invalidate() +// +// print("HotlineFileUploadClient: Cancelled upload") +// } +// +// private func connect() { +// self.connection = NWConnection(host: self.serverAddress, port: self.serverPort, using: .tcp) +// self.connection?.stateUpdateHandler = { [weak self] (newState: NWConnection.State) in +// switch newState { +// case .ready: +// self?.status = .connected +// self?.stage = .magic +// self?.send() +// case .waiting(let err): +// print("HotlineFileClient: Waiting", err) +// case .cancelled: +// print("HotlineFileClient: Cancelled") +// self?.invalidate() +// case .failed(let err): +// print("HotlineFileClient: Connection error \(err)") +// switch self?.status { +// case .connecting: +// print("HotlineFileClient: Failed to connect to file transfer server.") +// self?.invalidate() +// self?.status = .failed(.failedToConnect) +// case .connected, .progress(_): +// print("HotlineFileClient: Failed to finish transfer.") +// self?.invalidate() +// self?.status = .failed(.failedToUpload) +// case .completing: +// print("HotlineFileClient: Completed.") +// self?.invalidate() +// self?.status = .completed +// DispatchQueue.main.async { [weak self] in +// if let s = self { +// s.delegate?.hotlineFileUploadComplete(client: s, reference: s.referenceNumber) +// } +// } +// case .completed: +// self?.invalidate() +// default: +// break +// } +// default: +// return +// } +// } +// +// self.status = .connecting +// self.connection?.start(queue: .global()) +// } +// +// private func invalidate() { +// if let c = self.connection { +// c.stateUpdateHandler = nil +// c.cancel() +// +// self.connection = nil +// } +// +// self.stage = .magic +// +// if let fh = self.fileHandle { +// try? fh.close() +// self.fileHandle = nil +// } +// +// self.fileURL.stopAccessingSecurityScopedResource() +// self.fileResourceURL?.stopAccessingSecurityScopedResource() +// } +// +// private func sendComplete() { +// guard let c = self.connection else { +// self.invalidate() +// print("HotlineFileUploadClient: invalid connection to send data.") +// return +// } +// +// self.status = .completing +// +// c.send(content: nil, contentContext: .finalMessage, completion: .contentProcessed({ error in +// })) +// } +// +// private func sendFileData(_ data: Data) { +// guard let c = self.connection else { +// self.invalidate() +// +// print("HotlineFileUploadClient: invalid connection to send data.") +// return +// } +// +// let dataSent: Int = data.count +// c.send(content: data, completion: .contentProcessed({ [weak self] error in +// guard let client = self, +// error == nil else { +// self?.status = .failed(.failedToConnect) +// self?.invalidate() +// return +// } +// +// +// client.bytesSent += dataSent +// client.status = .progress(Double(client.bytesSent) / Double(client.payloadSize)) +// +// client.send() +// })) +// } +// +// private func send() { +// guard let _ = self.connection else { +// self.invalidate() +// print("HotlineFileUploadClient: Invalid connection to send.") +// return +// } +// +// switch self.stage { +// case .magic: +// print("Upload: Starting upload for \(self.fileURL)") +// print("Upload: Sending magic") +// self.status = .progress(0.0) +// +// let magicData = Data(endian: .big) { +// "HTXF".fourCharCode() +// self.referenceNumber +// self.payloadSize +// UInt32.zero +// } +// // var magicData = Data() +// // magicData.appendUInt32("HTXF".fourCharCode()) +// // magicData.appendUInt32(self.referenceNumber) +// // magicData.appendUInt32(self.payloadSize) +// // magicData.appendUInt32(0) +// self.stage = .fileHeader +// self.sendFileData(magicData) +// +// case .fileHeader: +// print("Upload: Sending file header") +// if let header = HotlineFileHeader(file: self.fileURL) { +// self.stage = .fileInfoForkHeader +// self.sendFileData(header.data()) +// } +// +// case .fileInfoForkHeader: +// print("Upload: Sending info fork header") +// let header = HotlineFileForkHeader(type: HotlineFileForkType.info.rawValue, dataSize: UInt32(self.infoForkData.count)) +// self.stage = .fileInfoFork +// self.sendFileData(header.data()) +// +// case .fileInfoFork: +// print("Upload: Sending info fork") +// self.stage = .fileDataForkHeader +// self.sendFileData(self.infoForkData) +// +// case .fileDataForkHeader: +// guard self.dataForkSize > 0 else { +// print("Upload: Data fork empty, skipping to resource fork") +// self.stage = .fileResourceForkHeader +// fallthrough +// } +// +// do { +// let fh = try FileHandle(forReadingFrom: self.fileURL) +// self.fileHandle = fh +// self.stage = .fileDataFork +// +// let header = HotlineFileForkHeader(type: HotlineFileForkType.data.rawValue, dataSize: self.dataForkSize) +// +// print("Upload: Sending data fork header \(self.dataForkSize)") +// self.sendFileData(header.data()) +// } +// catch { +// print("Upload: Error opening data fork", error) +// self.invalidate() +// return +// } +// +// case .fileDataFork: +// guard self.dataForkSize > 0, +// let fh = self.fileHandle else { +// print("Upload: Data fork empty, skipping to resource fork") +// self.stage = .fileResourceForkHeader +// try? self.fileHandle?.close() +// self.fileHandle = nil +// fallthrough +// } +// +// do { +// let fileData = try fh.read(upToCount: 4 * 1024) +// if fileData == nil || fileData?.isEmpty == true { +// print("Upload: Finished data fork") +// self.stage = .fileResourceForkHeader +// try? fh.close() +// self.fileHandle = nil +// fallthrough +// } +// +// print("Upload: Sending data Fork \(String(describing: fileData?.count))") +// self.sendFileData(fileData!) +// } +// catch { +// self.invalidate() +// print("Upload: Error reading data fork", error) +// return +// } +// +// case .fileResourceForkHeader: +// guard self.resourceForkSize > 0, +// let resourceURL = self.fileResourceURL else { +// print("Upload: Skipping resource fork header") +// self.stage = .fileComplete +// fallthrough +// } +// +// print("Upload: Sending resource fork header") +// guard let fh = try? FileHandle(forReadingFrom: resourceURL) else { +// print("Upload: Error reading resource fork") +// self.invalidate() +// return +// } +// +// let header = HotlineFileForkHeader(type: HotlineFileForkType.resource.rawValue, dataSize: self.resourceForkSize) +// +// self.fileHandle = fh +// self.stage = .fileResourceFork +// self.sendFileData(header.data()) +// +// case .fileResourceFork: +// guard self.resourceForkSize > 0, +// let fh = self.fileHandle else { +// print("Upload: Resource fork empty, skipping to completion") +// self.stage = .fileComplete +// try? self.fileHandle?.close() +// self.fileHandle = nil +// fallthrough +// } +// +// do { +// let resourceData = try fh.read(upToCount: 4 * 1024) +// if resourceData == nil || resourceData?.isEmpty == true { +// print("Upload: Finished resource fork") +// self.stage = .fileComplete +// try? self.fileHandle?.close() +// self.fileHandle = nil +// fallthrough +// } +// +// print("Upload: Sending resource fork \(String(describing: resourceData?.count))") +// self.sendFileData(resourceData!) +// } +// catch { +// self.invalidate() +// print("Upload: Error reading resource fork", error) +// return +// } +// break +// +// case .fileComplete: +// print("Upload: Complete!") +// self.sendComplete() +// } +// } +//} + +// MARK: - +// +//class HotlineFilePreviewClient: HotlineTransferClient { +// let serverAddress: NWEndpoint.Host +// let serverPort: NWEndpoint.Port +// let referenceNumber: UInt32 +// let referenceDataSize: UInt32 +// +// weak var delegate: HotlineFilePreviewClientDelegate? = nil +// +// var status: HotlineTransferStatus = .unconnected { +// didSet { +// DispatchQueue.main.async { +// self.delegate?.hotlineTransferStatusChanged(client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0) +// } +// } +// } +// +// private var downloadTask: Task? +// +// init(address: String, port: UInt16, reference: UInt32, size: UInt32) { +// self.serverAddress = NWEndpoint.Host(address) +// self.serverPort = NWEndpoint.Port(rawValue: port + 1)! +// self.referenceNumber = reference +// self.referenceDataSize = size +// } +// +// deinit { +// self.downloadTask?.cancel() +// } +// +// func start() { +// guard status == .unconnected else { +// return +// } +// +// self.downloadTask = Task { +// await self.download() +// } +// } +// +// func cancel() { +// self.downloadTask?.cancel() +// self.downloadTask = nil +// self.delegate = nil +// +// print("HotlineFilePreviewClient: Cancelled preview transfer") +// } +// +// private func download() async { +// await MainActor.run { self.status = .connecting } +// +// do { +// // Connect to file transfer server (already includes +1 in serverPort from init) +// let socket = try await NetSocketNew.connect( +// host: self.serverAddress, +// port: self.serverPort, +// tls: .disabled +// ) +// defer { Task { await socket.close() } } +// +// await MainActor.run { self.status = .connected } +// +// // Send magic header +// let headerData = Data(endian: .big) { +// "HTXF".fourCharCode() +// self.referenceNumber +// UInt32.zero +// UInt32.zero +// } +// try await socket.write(headerData) +// +// await MainActor.run { self.status = .progress(0.0) } +// +// // Download file data with progress updates +// let fileData = try await socket.read(Int(self.referenceDataSize)) { current, total in +// Task { @MainActor [weak self] in +// guard let self else { return } +// self.status = .progress(Double(current) / Double(total)) +// } +// } +// +// print("HotlineFilePreviewClient: Complete") +// await MainActor.run { self.status = .completed } +// +// // Notify delegate +// let reference = self.referenceNumber +// await MainActor.run { +// self.delegate?.hotlineFilePreviewComplete(client: self, reference: reference, data: fileData) +// } +// +// } catch is CancellationError { +// // Already handled in cancel() +// return +// } catch { +// print("HotlineFilePreviewClient: Download failed: \(error)") +// +// let failureStatus: HotlineTransferStatus = await MainActor.run { self.status } +// +// if failureStatus == .connecting { +// await MainActor.run { self.status = .failed(.failedToConnect) } +// } else { +// await MainActor.run { self.status = .failed(.failedToDownload) } +// } +// } +// } +// +// // status mutations are centralized via MainActor.run calls above +//} + +// MARK: - Async Preview Client (New) + +//enum HotlineFilePreviewClientNew { +// typealias ProgressHandler = @Sendable (NetSocketNew.FileProgress) -> Void +// +// struct Configuration { +// /// Chunk size used when reading the preview data stream. +// var chunkSize: Int = 256 * 1024 +// } +// +// /// Download preview data directly from the transfer server. +// /// - Parameters: +// /// - address: Hostname/IP of the Hotline server (preview runs on port+1). +// /// - port: Hotline base port. +// /// - reference: Transfer reference number returned by the server. +// /// - size: Expected payload size in bytes. +// /// - configuration: Optional tuning knobs (currently chunk size). +// /// - progress: Optional callback invoked as bytes stream in. +// /// - Returns: Raw preview data. +// static func download( +// address: String, +// port: UInt16, +// reference: UInt32, +// size: UInt32, +// configuration: Configuration = .init(), +// progress: ProgressHandler? = nil +// ) async throws -> Data { +// let host = NWEndpoint.Host(address) +// guard let transferPort = NWEndpoint.Port(rawValue: port + 1) else { +// throw NetSocketError.invalidPort +// } +// +// let socket = try await NetSocketNew.connect(host: host, port: transferPort, tls: .disabled) +// defer { Task { await socket.close() } } +// +// let header = Data(endian: .big) { +// "HTXF".fourCharCode() +// reference +// UInt32.zero +// UInt32.zero +// } +// +// try await socket.write(header) +// +// guard size > 0 else { +// return Data() +// } +// +// return try await socket.read(Int(size), chunkSize: configuration.chunkSize) { current, total in +// guard let progress else { return } +// let info = NetSocketNew.FileProgress(sent: current, total: total) +// progress(info) +// } +// } +//} + +// MARK: - + +//class HotlineFileDownloadClient: HotlineTransferClient { +// let serverAddress: NWEndpoint.Host +// let serverPort: NWEndpoint.Port +// let referenceNumber: UInt32 +// +// private var connection: NWConnection? +// private var transferStage: HotlineFileTransferStage = .fileHeader +// +// weak var delegate: HotlineFileDownloadClientDelegate? = nil +// +// var status: HotlineTransferStatus = .unconnected { +// didSet { +// DispatchQueue.main.async { +// self.delegate?.hotlineTransferStatusChanged(client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0) +// } +// } +// } +// +// private let referenceDataSize: UInt32 +// private var fileBytes = Data() +// private var fileResourceBytes = Data() +// +// private var fileHeader: HotlineFileHeader? = nil +// private var fileCurrentForkHeader: HotlineFileForkHeader? = nil +// private var fileCurrentForkBytesLeft: Int = 0 +// private var fileInfoFork: HotlineFileInfoFork? = nil +// private var fileHandle: FileHandle? = nil +// private var filePath: String? = nil +// private var fileBytesTransferred: Int = 0 +// private var fileProgress: Progress +// +// init(address: String, port: UInt16, reference: UInt32, size: UInt32) { +// self.serverAddress = NWEndpoint.Host(address) +// self.serverPort = NWEndpoint.Port(rawValue: port + 1)! +// self.referenceNumber = reference +// self.referenceDataSize = size +// self.transferStage = .fileHeader +// self.fileProgress = Progress(totalUnitCount: Int64(size)) +// } +// +// deinit { +// self.invalidate() +// } +// +// func start() { +// guard self.status == .unconnected else { +// return +// } +// +// self.filePath = nil +// self.connect() +// } +// +// func start(to fileURL: URL) { +// guard self.status == .unconnected else { +// return +// } +// +// self.filePath = fileURL.path +// self.connect() +// } +// +// func cancel() { +// self.delegate = nil +// +// if self.status == .unconnected { +// return +// } +// +// // Close file before we try to potentionally delete it. +// if let fh = self.fileHandle { +// try? fh.close() +// self.fileHandle = nil +// } +// +// if let downloadPath = self.filePath { +// print("HotlineFileClient: Deleting file fragment at", downloadPath) +// if FileManager.default.isDeletableFile(atPath: downloadPath) { +// try? FileManager.default.removeItem(atPath: downloadPath) +// } +// self.filePath = nil +// } +// +// self.invalidate() +// +// print("HotlineFileClient: Cancelled transfer") +// } +// +// private func connect() { +// self.connection = NWConnection(host: self.serverAddress, port: self.serverPort, using: .tcp) +// self.connection?.stateUpdateHandler = { [weak self] (newState: NWConnection.State) in +// switch newState { +// case .ready: +// self?.status = .connected +// self?.sendMagic() +// case .waiting(let err): +// print("HotlineFileClient: Waiting", err) +// case .cancelled: +// print("HotlineFileClient: Cancelled") +// self?.invalidate() +// case .failed(let err): +// print("HotlineFileClient: Connection error \(err)") +// switch self?.status { +// case .connecting: +// print("HotlineFileClient: Failed to connect to file transfer server.") +// self?.invalidate() +// self?.status = .failed(.failedToConnect) +// case .connected, .progress(_): +// print("HotlineFileClient: Failed to finish transfer.") +// self?.invalidate() +// self?.status = .failed(.failedToDownload) +// default: +// break +// } +// default: +// return +// } +// } +// +// self.status = .connecting +// self.connection?.start(queue: .global()) +// } +// +// func invalidate() { +// if let c = self.connection { +// c.stateUpdateHandler = nil +// c.cancel() +// +// self.connection = nil +// } +// +// self.fileBytes = Data() +// +// if let fh = self.fileHandle { +// try? fh.close() +// self.fileHandle = nil +// } +// +// self.fileProgress.unpublish() +// } +// +// private func sendMagic() { +// guard let c = connection, self.status == .connected else { +// self.invalidate() +// print("HotlineFileClient: invalid connection to send header.") +// return +// } +// +// let headerData = Data(endian: .big) { +// "HTXF".fourCharCode() +// self.referenceNumber +// UInt32.zero +// UInt32.zero +// } +// +// c.send(content: headerData, completion: .contentProcessed { [weak self] (error) in +// guard let self = self else { +// return +// } +// +// guard error == nil else { +// self.status = .failed(.failedToConnect) +// self.invalidate() +// return +// } +// +// self.status = .progress(0.0) +// self.receiveFile() +// }) +// } +// +// private func receiveFile() { +// guard let c = self.connection else { +// return +// } +// +// c.receive(minimumIncompleteLength: 1, maximumLength: Int(UInt16.max)) { [weak self] (data, context, isComplete, error) in +// guard let self = self else { +// return +// } +// +// guard error == nil else { +// self.status = .failed(.failedToDownload) +// self.invalidate() +// return +// } +// +// if let newData = data, !newData.isEmpty { +// self.fileBytesTransferred += newData.count +// self.fileBytes.append(newData) +// self.fileProgress.completedUnitCount = Int64(self.fileBytesTransferred) +// self.status = .progress(Double(self.fileBytesTransferred) / Double(self.referenceDataSize)) +// } +// +// // See if we need header data still. +// var keepProcessing = false +// repeat { +// keepProcessing = false +// +// switch self.transferStage { +// case .fileHeader: +// if let header = HotlineFileHeader(from: self.fileBytes) { +// self.fileBytes.removeSubrange(0..= infoForkDataSize { +// let infoForkData = self.fileBytes.subdata(in: 0.. 0 { +// if let f = self.fileHandle { +// do { +// var dataToWrite = self.fileBytes +// +// if dataToWrite.count >= self.fileCurrentForkBytesLeft { +// dataToWrite = self.fileBytes.subdata(in: 0.. 0 { +// var dataToWrite = self.fileBytes +// +// if dataToWrite.count >= self.fileCurrentForkBytesLeft { +// dataToWrite = self.fileBytes.subdata(in: 0.. 0 { +// var dataToWrite = self.fileBytes +// +// if dataToWrite.count >= self.fileCurrentForkBytesLeft { +// dataToWrite = self.fileBytes.subdata(in: 0.. 0 { +// let _ = self.writeResourceFork() +// self.fileResourceBytes = Data() +// } +// +// self.status = .completed +// +// if let downloadPath = self.filePath { +// DispatchQueue.main.sync { +// print("POSTING DOWNLOAD FILE FINISHED", downloadPath) +// +// var downloadURL = URL(filePath: downloadPath) +// downloadURL.resolveSymlinksInPath() +// print("FINAL PATH", downloadURL.path) +// +// self.delegate?.hotlineFileDownloadComplete(client: self, reference: self.referenceNumber, at: downloadURL) +// +//#if os(macOS) +// // Bounce dock icon when download completes. Weird this is the only API to do so. +// DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path) +//#endif +// } +// } +// } +// } +// } +// +// private func writeResourceFork() -> Bool { +// guard let filePath = self.filePath else { +// return false +// } +// +// var resolvedFileURL = URL(filePath: filePath) +// resolvedFileURL.resolveSymlinksInPath() +// +// let resourceFilePath = resolvedFileURL.appendingPathComponent("..namedfork/rsrc") +// +// do { +// try self.fileResourceBytes.write(to: resourceFilePath) +// } +// catch { +// return false +// } +// +// return true +// } +// +// private func prepareDownloadFile(name: String) -> Bool { +// var filePath: String +// +// if self.filePath != nil { +// filePath = self.filePath! +// } +// else { +// let folderURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0] +// filePath = folderURL.generateUniqueFilePath(filename: name) +// } +// +// var fileAttributes: [FileAttributeKey: Any] = [:] +// if let creatorCode = self.fileInfoFork?.creator { +// fileAttributes[.hfsCreatorCode] = creatorCode as NSNumber +// } +// if let typeCode = self.fileInfoFork?.type { +// fileAttributes[.hfsTypeCode] = typeCode as NSNumber +// } +// if let createdDate = self.fileInfoFork?.createdDate { +// fileAttributes[.creationDate] = createdDate as NSDate +// } +// if let modifiedDate = self.fileInfoFork?.modifiedDate { +// fileAttributes[.modificationDate] = modifiedDate as NSDate +// } +// if let comment = self.fileInfoFork?.comment { +// if let commentPlistData = try? PropertyListSerialization.data(fromPropertyList: comment, format: .binary, options: 0) { +// fileAttributes[FileAttributeKey(rawValue: "NSFileExtendedAttributes")] = [ +// FileAttributeKey(rawValue: "com.apple.metadata:kMDItemFinderComment"): commentPlistData +// ] +// } +// } +// +// if FileManager.default.createFile(atPath: filePath, contents: nil, attributes: fileAttributes) { +// if let h = FileHandle(forWritingAtPath: filePath) { +// self.filePath = filePath +// self.fileHandle = h +// self.fileProgress.fileURL = URL(filePath: filePath).resolvingSymlinksInPath() +// self.fileProgress.fileOperationKind = .downloading +// self.fileProgress.publish() +// return true +// } +// } +// +// return false +// } +//} + +// MARK: - + + +// MARK: - + +//class HotlineFolderDownloadClient: HotlineTransferClient { +// let serverAddress: NWEndpoint.Host +// let serverPort: NWEndpoint.Port +// let referenceNumber: UInt32 +// +// private var connection: NWConnection? +// private var transferStage: HotlineFolderDownloadStage = .fileHeader +// +// weak var delegate: HotlineFolderDownloadClientDelegate? = nil +// +// var status: HotlineTransferStatus = .unconnected { +// didSet { +// DispatchQueue.main.async { +// self.delegate?.hotlineTransferStatusChanged(client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0) +// } +// } +// } +// +// private let referenceDataSize: UInt32 +// private let folderItemCount: Int +// private var fileBytes = Data() +// private var fileResourceBytes = Data() +// +// private var fileHeader: HotlineFileHeader? = nil +// private var fileCurrentForkHeader: HotlineFileForkHeader? = nil +// private var fileCurrentForkBytesLeft: Int = 0 +// private var fileForksRemaining: Int = 0 +// private var currentFileSize: UInt32 = 0 // Size of current file from server +// private var currentFileBytesRead: Int = 0 // Bytes read for current file +// private var fileInfoFork: HotlineFileInfoFork? = nil +// private var fileHandle: FileHandle? = nil +// private var currentFilePath: String? = nil +// private var fileBytesTransferred: Int = 0 +// private var fileProgress: Progress +// +// private var currentItemNumber: Int = 0 +// private var completedItemCount: Int = 0 // Track actually completed files +// private var folderPath: String? = nil +// private var currentItemRelativePath: [String] = [] +// private var currentFileName: String? = nil +// +// private let FILP_MAGIC: UInt32 = "FILP".fourCharCode() +// +// init(address: String, port: UInt16, reference: UInt32, size: UInt32, itemCount: Int) { +// self.serverAddress = NWEndpoint.Host(address) +// self.serverPort = NWEndpoint.Port(rawValue: port + 1)! +// self.referenceNumber = reference +// self.referenceDataSize = size +// self.folderItemCount = itemCount +// self.transferStage = .fileHeader +// self.fileProgress = Progress(totalUnitCount: Int64(size)) +// } +// +// deinit { +// self.invalidate() +// } +// +// func start() { +// guard self.status == .unconnected else { +// return +// } +// +// self.folderPath = nil +// self.connect() +// } +// +// func start(to folderURL: URL) { +// print("HotlineFolderDownloadClient: start(to:) called with path: \(folderURL.path)") +// guard self.status == .unconnected else { +// print("HotlineFolderDownloadClient: Already connected, status: \(self.status)") +// return +// } +// +// self.folderPath = folderURL.path +// print("HotlineFolderDownloadClient: Calling connect()") +// self.connect() +// } +// +// func cancel() { +// self.delegate = nil +// +// if self.status == .unconnected { +// return +// } +// +// // Close file before we try to potentially delete it. +// if let fh = self.fileHandle { +// try? fh.close() +// self.fileHandle = nil +// } +// +// if let downloadPath = self.folderPath { +// print("HotlineFolderDownloadClient: Deleting folder fragment at", downloadPath) +// if FileManager.default.isDeletableFile(atPath: downloadPath) { +// try? FileManager.default.removeItem(atPath: downloadPath) +// } +// self.folderPath = nil +// } +// +// self.invalidate() +// +// print("HotlineFolderDownloadClient: Cancelled transfer") +// } +// +// private func connect() { +// print("HotlineFolderDownloadClient: connect() called, connecting to \(self.serverAddress):\(self.serverPort)") +// self.connection = NWConnection(host: self.serverAddress, port: self.serverPort, using: .tcp) +// print("HotlineFolderDownloadClient: NWConnection created") +// self.connection?.stateUpdateHandler = { [weak self] (newState: NWConnection.State) in +// switch newState { +// case .ready: +// print("HotlineFolderDownloadClient: Connection ready!") +// self?.status = .connected +// self?.sendMagic() +// case .waiting(let err): +// print("HotlineFolderDownloadClient: Waiting", err) +// case .cancelled: +// print("HotlineFolderDownloadClient: Cancelled") +// self?.invalidate() +// case .failed(let err): +// print("HotlineFolderDownloadClient: Connection error \(err)") +// switch self?.status { +// case .connecting: +// print("HotlineFolderDownloadClient: Failed to connect to file transfer server.") +// self?.invalidate() +// self?.status = .failed(.failedToConnect) +// case .connected, .progress(_): +// print("HotlineFolderDownloadClient: Failed to finish transfer.") +// self?.invalidate() +// self?.status = .failed(.failedToDownload) +// default: +// break +// } +// default: +// return +// } +// } +// +// self.status = .connecting +// self.connection?.start(queue: .global()) +// } +// +// func invalidate() { +// if let c = self.connection { +// c.stateUpdateHandler = nil +// c.cancel() +// +// self.connection = nil +// } +// +// self.fileBytes = Data() +// +// if let fh = self.fileHandle { +// try? fh.close() +// self.fileHandle = nil +// } +// +// self.fileProgress.unpublish() +// } +// +// private func 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) +// } +// +// /// Find and align the buffer to the start of a FILP header. +// /// Returns the number of bytes dropped. +// @discardableResult +// private func alignBufferToFILP() -> Int { +// // We need at least the 4-byte magic to try +// guard self.fileBytes.count >= 4 else { return 0 } +// let magicData = Data([0x46, 0x49, 0x4C, 0x50]) // "FILP" +// if self.fileBytes.starts(with: magicData) { return 0 } +// +// if let r = self.fileBytes.firstRange(of: magicData) { +// let toDrop = r.lowerBound +// if toDrop > 0 { +// print("HotlineFolderDownloadClient: Resync — dropping \(toDrop) stray bytes before FILP") +// self.fileBytes.removeSubrange(0..= 2 else { break } +// let headerLen = Int(self.fileBytes.readUInt16(at: 0)!) +// guard self.fileBytes.count >= 2 + headerLen else { break } +// +// let headerData = self.fileBytes.subdata(in: 2..<(2 + headerLen)) +// +// guard let parsed = parseItemHeaderPath(headerData) else { +// print("HotlineFolderDownloadClient: Invalid item header; waiting for more data") +// break +// } +// +// // Consume the header from the buffer +// self.fileBytes.removeSubrange(0..<(2 + headerLen)) +// +// let itemType = parsed.type +// let comps = parsed.components +// let joinedPath = comps.joined(separator: "/") +// print("HotlineFolderDownloadClient: item type=\(itemType) path=\(joinedPath)") +// +// guard !comps.isEmpty else { +// print("HotlineFolderDownloadClient: Empty path components for item type \(itemType); requesting next item") +// self.sendAction(.nextFile) +// keepProcessing = !self.fileBytes.isEmpty +// break +// } +// +// if itemType == 1 { +// // Folder entries: create the directory locally and continue. +// self.currentItemRelativePath = comps +// +// if let base = self.folderPath { +// var dir = base +// for c in comps { dir = (dir as NSString).appendingPathComponent(c) } +// do { +// try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) +// print("HotlineFolderDownloadClient: Created folder at \(dir)") +// } catch { +// print("HotlineFolderDownloadClient: Failed to create subfolder: \(error)") +// } +// } +// +// self.completedItemCount += 1 +// +// if self.completedItemCount >= self.folderItemCount { +// self.handleAllItemsDownloaded() +// return +// } +// +// self.sendAction(.nextFile) +// keepProcessing = !self.fileBytes.isEmpty +// break +// } +// else if itemType == 0 { +// // File entries include the full path; split parent components from filename. +// self.currentItemRelativePath = Array(comps.dropLast()) +// self.currentFileName = comps.last +// +// self.transferStage = .waitingForFileSize +// print("HotlineFolderDownloadClient: Requesting file download for '\(self.currentFileName ?? "?")'") +// self.sendAction(.sendFile) +// +// if self.fileBytes.count >= 4 { +// keepProcessing = true +// } +// break +// } +// else { +// print("HotlineFolderDownloadClient: Unknown item type \(itemType); skipping") +// self.sendAction(.nextFile) +// keepProcessing = !self.fileBytes.isEmpty +// break +// } +// +// case .waitingForFileSize: +// // Read 4-byte file size that comes before FILP in folder mode +// guard self.fileBytes.count >= 4 else { break } +// let fileSize = self.fileBytes.readUInt32(at: 0)! +// self.fileBytes.removeSubrange(0..<4) +// +// print("HotlineFolderDownloadClient: File size: \(fileSize) bytes") +// +// self.currentFileSize = fileSize +// self.currentFileBytesRead = 0 +// +// // Align to FILP boundary before decoding the file header +// let dropped = self.alignBufferToFILP() +// if dropped > 0 { +// // These bytes were in the stream for this file but not part of FILP. +// // Keep byte-accounting consistent by shrinking the expected size. +// if self.currentFileSize >= UInt32(dropped) { +// self.currentFileSize -= UInt32(dropped) +// } else { +// // Defensive: if weird, treat as zero-size to avoid underflow +// self.currentFileSize = 0 +// } +// } +// +// self.transferStage = .fileHeader +// keepProcessing = true +// +// case .fileHeader: +// // Make sure we're actually at a FILP header +// if self.fileBytes.count >= HotlineFileHeader.DataSize { +// // If the 4-byte magic doesn't match, try one more resync here +// if self.fileBytes.readUInt32(at: 0)! != FILP_MAGIC { +// let dropped = self.alignBufferToFILP() +// if dropped > 0 { +// if self.currentFileSize >= UInt32(dropped) { self.currentFileSize -= UInt32(dropped) } +// } +// // If still not aligned or not enough bytes, wait for more data +// guard self.fileBytes.count >= HotlineFileHeader.DataSize, +// self.fileBytes.readUInt32(at: 0)! == FILP_MAGIC else { break } +// } +// +// if let header = HotlineFileHeader(from: self.fileBytes) { +// // Sanity gate: version and fork count +// if header.format != FILP_MAGIC || header.version == 0 || header.forkCount > 3 { +// print("HotlineFolderDownloadClient: Invalid FILP header (fmt=\(String(format:"0x%08X", header.format)), ver=\(header.version), forks=\(header.forkCount)). Resyncing.") +// // Try resync and wait for more data +// let dropped = self.alignBufferToFILP() +// if dropped > 0 { if self.currentFileSize >= UInt32(dropped) { self.currentFileSize -= UInt32(dropped) } } +// break +// } +// +// self.fileBytes.removeSubrange(0..= self.folderItemCount { +// self.handleAllItemsDownloaded() +// return +// } +// +// // More files to download +// // Check if server has pipelined all remaining data (we've received more than expected total) +// print("HotlineFolderDownloadClient: Completed \(self.completedItemCount)/\(self.folderItemCount) items, requesting next entry") +// self.transferStage = .itemHeader +// self.sendAction(.nextFile) +// keepProcessing = !self.fileBytes.isEmpty +// } +// // File not complete - try to read next fork header +// else if let forkHeader = HotlineFileForkHeader(from: self.fileBytes) { +// // Quick validation: first fork must be INFO, and sizes must be plausible +// let remainingForThisFile = max(0, Int(self.currentFileSize) - self.currentFileBytesRead) +// let plausible = forkHeader.dataSize <= UInt32(remainingForThisFile) +// let knownType = forkHeader.isInfoFork || forkHeader.isDataFork || forkHeader.isResourceFork +// +// if !plausible || (!knownType && self.currentFileBytesRead == HotlineFileHeader.DataSize) { +// print("HotlineFolderDownloadClient: Implausible fork header (type=\(String(format:"0x%08X", forkHeader.forkType)), size=\(forkHeader.dataSize), remaining=\(remainingForThisFile)). Resyncing.") +// let dropped = self.alignBufferToFILP() +// if dropped > 0 { if self.currentFileSize >= UInt32(dropped) { self.currentFileSize -= UInt32(dropped) } } +// break +// } +// +// self.fileBytes.removeSubrange(0..= infoForkDataSize { +// let infoForkData = self.fileBytes.subdata(in: 0.. 0 { +// if let f = self.fileHandle { +// do { +// var dataToWrite = self.fileBytes +// +// if dataToWrite.count >= self.fileCurrentForkBytesLeft { +// dataToWrite = self.fileBytes.subdata(in: 0.. 0 { +// var dataToWrite = self.fileBytes +// +// if dataToWrite.count >= self.fileCurrentForkBytesLeft { +// dataToWrite = self.fileBytes.subdata(in: 0.. 0 { +// var dataToWrite = self.fileBytes +// +// if dataToWrite.count >= self.fileCurrentForkBytesLeft { +// dataToWrite = self.fileBytes.subdata(in: 0.. 0 { +// let _ = self.writeResourceFork() +// self.fileResourceBytes = Data() +// } +// +// self.fileCurrentForkHeader = nil +// self.fileCurrentForkBytesLeft = 0 +// self.fileForksRemaining = 0 +// self.currentFileBytesRead = 0 +// self.currentFileSize = 0 +// self.currentFilePath = nil +// self.fileInfoFork = nil +// self.fileHeader = nil +// self.currentFileName = nil +// } +// +// private func handleAllItemsDownloaded() { +// guard self.status != .completed else { +// return +// } +// +// print("HotlineFolderDownloadClient: All \(self.folderItemCount) items downloaded") +// +// self.invalidate() +// self.status = .completed +// +// if let downloadPath = self.folderPath { +// DispatchQueue.main.sync { +// print("HotlineFolderDownloadClient: Folder download complete", downloadPath) +// +// var downloadURL = URL(filePath: downloadPath) +// downloadURL.resolveSymlinksInPath() +// +// self.delegate?.hotlineFolderDownloadComplete(client: self, reference: self.referenceNumber, at: downloadURL) +// +//#if os(macOS) +// DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path) +//#endif +// } +// } +// } +// +// private func writeResourceFork() -> Bool { +// guard let filePath = self.currentFilePath else { +// return false +// } +// +// var resolvedFileURL = URL(filePath: filePath) +// resolvedFileURL.resolveSymlinksInPath() +// +// let resourceFilePath = resolvedFileURL.urlForResourceFork() +// +// do { +// try self.fileResourceBytes.write(to: resourceFilePath) +// } +// catch { +// return false +// } +// +// return true +// } +// +// private func prepareDownloadFile(name: String) -> Bool { +// var filePath: String +// +// if self.folderPath != nil { +// // Build the full path including subfolders +// var fullPath = self.folderPath! +// for component in self.currentItemRelativePath { +// fullPath = (fullPath as NSString).appendingPathComponent(component) +// } +// +// let folderURL = URL(filePath: fullPath) +// +// // Create folder if it doesn't exist +// if !FileManager.default.fileExists(atPath: folderURL.path) { +// do { +// try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) +// } +// catch { +// print("HotlineFolderDownloadClient: Failed to create folder", error) +// return false +// } +// } +// +// filePath = folderURL.appendingPathComponent(name).path +// print("HotlineFolderDownloadClient: Creating file at \(filePath)") +// } +// else { +// let downloadsURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0] +// filePath = downloadsURL.generateUniqueFilePath(filename: name) +// } +// +// var fileAttributes: [FileAttributeKey: Any] = [:] +// if let creatorCode = self.fileInfoFork?.creator { +// fileAttributes[.hfsCreatorCode] = creatorCode as NSNumber +// } +// if let typeCode = self.fileInfoFork?.type { +// fileAttributes[.hfsTypeCode] = typeCode as NSNumber +// } +// if let createdDate = self.fileInfoFork?.createdDate { +// fileAttributes[.creationDate] = createdDate as NSDate +// } +// if let modifiedDate = self.fileInfoFork?.modifiedDate { +// fileAttributes[.modificationDate] = modifiedDate as NSDate +// } +// if let comment = self.fileInfoFork?.comment { +// if let commentPlistData = try? PropertyListSerialization.data(fromPropertyList: comment, format: .binary, options: 0) { +// fileAttributes[FileAttributeKey(rawValue: "NSFileExtendedAttributes")] = [ +// FileAttributeKey(rawValue: "com.apple.metadata:kMDItemFinderComment"): commentPlistData +// ] +// } +// } +// +// if FileManager.default.createFile(atPath: filePath, contents: nil, attributes: fileAttributes) { +// if let h = FileHandle(forWritingAtPath: filePath) { +// self.currentFilePath = filePath +// self.fileHandle = h +// +// // Only set file progress on first file +// if self.currentItemNumber == 1 && self.folderPath != nil { +// self.fileProgress.fileURL = URL(filePath: self.folderPath!).resolvingSymlinksInPath() +// self.fileProgress.fileOperationKind = .downloading +// self.fileProgress.publish() +// } +// +// return true +// } +// } +// +// return false +// } +//} diff --git a/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift new file mode 100644 index 0000000..ced79a1 --- /dev/null +++ b/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift @@ -0,0 +1,291 @@ +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) +} + +/// Modern async/await file download client for Hotline protocol +@MainActor +public class HotlineFileDownloadClientNew { + // 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 config: Configuration + + private var transferSize: Int + private let transferTotal: Int + private var transferProgress: Progress + + private var socket: NetSocketNew? + private var downloadTask: Task? + + // MARK: - Initialization + + 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: - Public API + + public func download( + to location: HotlineDownloadLocation, + progress progressHandler: (@Sendable (HotlineTransferProgress) -> 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 { + print("FAILED TO DOWNLOAD!", 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 updateProgress(sent: Int) { + self.transferSize = sent + self.transferProgress.completedUnitCount = Int64(sent) + + // People can cancel a transfer from the file icon in the Finder. + // This code handles that. + if self.transferProgress.isCancelled { + self.cancel() + } + } + + private func performDownload( + to destination: HotlineDownloadLocation, + progressHandler: (@Sendable (HotlineTransferProgress) -> Void)? + ) async throws -> URL { + + let fm = FileManager.default + var fileHandle: FileHandle? + var resourceForkData: Data? + + 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 + } + + progressHandler?(.connecting) + + // Connect to transfer server + let socket = try await connectToTransferServer() + self.socket = socket + defer {Task { await socket.close() } } + + // 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 HotlineFileClientError.failedToTransfer + } + + // Connected + progressHandler?(.connected) + + do { + // Process each fork + for _ in 0.. NetSocketNew { + guard let transferPort = NWEndpoint.Port(rawValue: serverPort + 1) else { + throw NetSocketError.invalidPort + } + + print("HotlineFileDownloadClientNew[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)") + + let socket = try await NetSocketNew.connect( + host: .name(serverAddress, nil), + port: transferPort, + tls: .disabled + ) + + print("HotlineFileDownloadClientNew[\(referenceNumber)]: Connected!") + return socket + } + + private func sendMagicHeader(socket: NetSocketNew) async throws { + let header = Data(endian: .big) { + "HTXF".fourCharCode() + referenceNumber + UInt32.zero + UInt32.zero + } + + try await socket.write(header) + } + + private func writeResourceFork(data: Data, to url: URL) throws { + var resolvedURL = url + resolvedURL.resolveSymlinksInPath() + + let resourceURL = resolvedURL.urlForResourceFork() + try data.write(to: resourceURL) + + print("HotlineFileDownloadClientNew[\(referenceNumber)]: Wrote resource fork (\(data.count) bytes)") + } +} diff --git a/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift b/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift new file mode 100644 index 0000000..9839997 --- /dev/null +++ b/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift @@ -0,0 +1,163 @@ +import Foundation +import Network + +@MainActor +public class HotlineFilePreviewClientNew { + // MARK: - Properties + + 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 previewTask: Task? + private var temporaryFileURL: URL? + + // MARK: - Initialization + + 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 + } + + deinit { + // Cleanup in deinit - must be synchronous + if let tempURL = temporaryFileURL { + try? FileManager.default.removeItem(at: tempURL) + } + } + + // MARK: - Public 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("HotlineFilePreviewClientNew[\(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: - Private 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("HotlineFilePreviewClientNew[\(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)") + + let socket = try await NetSocketNew.connect( + host: .name(serverAddress, nil), + port: transferPort, + tls: .disabled + ) + defer { Task { await socket.close() } } + + print("HotlineFilePreviewClientNew[\(referenceNumber)]: Connected!") + + // Send magic header for raw data download + print("HotlineFilePreviewClientNew[\(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("HotlineFilePreviewClientNew[\(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("HotlineFilePreviewClientNew[\(referenceNumber)]: Preview file ready at \(tempFileURL.path)") + + return tempFileURL + } + + private func cleanupTempFile() { + guard let tempURL = temporaryFileURL else { return } + + // Delete the temp file + try? FileManager.default.removeItem(at: tempURL) + + temporaryFileURL = nil + print("HotlineFilePreviewClientNew[\(referenceNumber)]: Cleaned up temp file") + } +} diff --git a/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift new file mode 100644 index 0000000..f065233 --- /dev/null +++ b/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift @@ -0,0 +1,265 @@ +// +// HotlineFileUploadClientNew.swift +// Hotline +// +// Modern async/await file upload client using NetSocketNew +// + +import Foundation +import Network + +/// Modern async/await file upload client for Hotline protocol +@MainActor +public class HotlineFileUploadClientNew { + // MARK: - Configuration + + public struct Configuration: Sendable { + public var chunkSize: Int = 256 * 1024 + public var publishProgress: Bool = true + public init() {} + } + + // MARK: - Properties + + 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: NetSocketNew? + private var uploadTask: Task? + + // MARK: - Initialization + + 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)) + + print("HotlineFileUploadClientNew[\(reference)]: Preparing to upload '\(fileURL.lastPathComponent)' (\(payloadSize) bytes)") + } + + // 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("HotlineFileUploadClientNew[\(referenceNumber)]: Failed to upload file: \(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 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 connectToTransferServer() + self.socket = socket + defer { Task { await socket.close() } } + + // Get file metadata + guard let infoFork = HotlineFileInfoFork(file: fileURL) else { + throw HotlineFileClientError.failedToTransfer + } + + guard let header = HotlineFileHeader(file: fileURL) else { + throw HotlineFileClientError.failedToTransfer + } + + guard let forkSizes = try? FileManager.default.getFileForkSizes(fileURL) else { + throw HotlineFileClientError.failedToTransfer + } + + let infoForkData = infoFork.data() + let dataForkSize = forkSizes.dataForkSize + let resourceForkSize = forkSizes.resourceForkSize + + print("HotlineFileUploadClientNew[\(referenceNumber)]: File has dataFork=\(dataForkSize) bytes, resourceFork=\(resourceForkSize) bytes") + + // Connected + progressHandler?(.connected) + + // Send magic header + print("HotlineFileUploadClientNew[\(referenceNumber)]: Sending magic header") + try await socket.write(Data(endian: .big) { + "HTXF".fourCharCode() + self.referenceNumber + UInt32(self.transferTotal) + UInt32.zero + }) + + var totalBytesSent = 0 + + // 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") + 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)") + 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 + if config.publishProgress { + self.transferProgress.fileURL = fileURL.resolvingSymlinksInPath() + self.transferProgress.fileOperationKind = .uploading + self.transferProgress.publish() + } + + // Send DATA fork if present + if dataForkSize > 0 { + print("HotlineFileUploadClientNew[\(referenceNumber)]: Sending 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) + 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) + } + + // Send RESOURCE fork if present + if resourceForkSize > 0 { + let resourceURL = fileURL.urlForResourceFork() + + print("HotlineFileUploadClientNew[\(referenceNumber)]: Sending 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)") + 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("HotlineFileUploadClientNew[\(referenceNumber)]: Upload complete!") + } + + // MARK: - Helper Methods + + private func connectToTransferServer() async throws -> NetSocketNew { + guard let transferPort = NWEndpoint.Port(rawValue: serverPort + 1) else { + throw NetSocketError.invalidPort + } + + print("HotlineFileUploadClientNew[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)") + + let socket = try await NetSocketNew.connect( + host: .name(serverAddress, nil), + port: transferPort, + tls: .disabled + ) + + print("HotlineFileUploadClientNew[\(referenceNumber)]: Connected!") + return socket + } +} diff --git a/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift new file mode 100644 index 0000000..9dcb7c9 --- /dev/null +++ b/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift @@ -0,0 +1,486 @@ +// +// HotlineFolderDownloadClientNew.swift +// Hotline +// +// Modern async/await folder download client using NetSocketNew +// + +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 +} + +/// Modern async/await folder download client for Hotline protocol +@MainActor +public class HotlineFolderDownloadClientNew { + // MARK: - Configuration + + public struct Configuration: Sendable { + public var publishProgress: Bool = true + public init() {} + } + + // MARK: - Properties + + private let serverAddress: String + private let serverPort: UInt16 + private let referenceNumber: UInt32 + + private let config: Configuration + + private let transferTotal: Int + private let folderItemCount: Int + private var transferSize: Int = 0 + + private var socket: NetSocketNew? + private var downloadTask: Task? + private var folderProgress: Progress? + + // MARK: - Initialization + + public init( + address: String, + port: UInt16, + reference: UInt32, + size: UInt32, + itemCount: Int, + configuration: Configuration = .init() + ) { + self.serverAddress = address + self.serverPort = port + self.referenceNumber = reference + self.config = configuration + 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) + if config.publishProgress { + let progress = Progress(totalUnitCount: Int64(self.transferTotal)) + progress.fileURL = destinationURL + progress.fileOperationKind = .downloading + progress.publish() + self.folderProgress = progress + } + defer { + // Unpublish progress when folder download completes + self.folderProgress?.unpublish() + self.folderProgress = nil + } + + // Send initial magic header + print("HotlineFolderDownloadClientNew[\(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 < 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) = parseItemHeaderPath(headerData) else { + throw HotlineFileClientError.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[\(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 -> NetSocketNew { + guard let transferPort = NWEndpoint.Port(rawValue: serverPort + 1) else { + throw NetSocketError.invalidPort + } + + print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)") + + let socket = try await NetSocketNew.connect( + host: .name(serverAddress, nil), + port: transferPort, + tls: .disabled + ) + + print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Connected!") + return socket + } + + private func sendAction(socket: NetSocketNew, 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: NetSocketNew, + 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 HotlineFileClientError.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 HotlineFileClientError.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/HotlineFolderUploadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift new file mode 100644 index 0000000..0b78f33 --- /dev/null +++ b/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift @@ -0,0 +1,538 @@ +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 +} + +/// Modern async/await folder upload client for Hotline protocol +@MainActor +public class HotlineFolderUploadClientNew { + // MARK: - Configuration + + public struct Configuration: Sendable { + public var chunkSize: Int = 256 * 1024 + public var publishProgress: Bool = true + 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: NetSocketNew? + 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 self.connect(address: self.serverAddress, port: self.serverPort) + 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) +// progressHandler?(.transfer(size: 0, total: self.transferTotal, progress: 0.0, speed: nil, estimate: nil)) + + var completedItemCount = 0 + var totalBytesTransferred = 0 + var itemIndex = 0 + var stage: UploadStage = .waitingForNextFile + var currentItem: FolderItem? + + // State machine loop - matches C++ client's goto-based state machine + 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 HotlineFileClientError.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 HotlineFileClientError.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 HotlineFileClientError.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 HotlineFileClientError.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 connect(address: String, port: UInt16) async throws -> NetSocketNew { + guard let transferPort = NWEndpoint.Port(rawValue: port + 1) else { + throw NetSocketError.invalidPort + } + + return try await NetSocketNew.connect( + host: .name(address, nil), + port: transferPort, + tls: .disabled + ) + } + + 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) + } + } + } + } + + // Following C++ client behavior: Build hierarchy with root folder name prepended to all paths + // 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 { + // Following C++ client behavior: Skip the first path component (root folder name) + // The C++ client does: startPtr += 2; startPtr += *startPtr + 1; pathCount--; + 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: NetSocketNew) async throws -> HotlineFolderAction { + let actionData = try await socket.read(2) + guard let rawAction = actionData.readUInt16(at: 0), + let action = HotlineFolderAction(rawValue: rawAction) else { + throw HotlineFileClientError.failedToTransfer + } + return action + } + + private func uploadFile( + socket: NetSocketNew, + 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 HotlineFileClientError.failedToTransfer + } + + guard let header = HotlineFileHeader(file: fileURL) else { + throw HotlineFileClientError.failedToTransfer + } + + guard let forkSizes = try? FileManager.default.getFileForkSizes(fileURL) else { + throw HotlineFileClientError.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 HotlineFileClientError.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 + print("HotlineFolderUploadClientNew[\(referenceNumber)]: Sending INFO fork (\(infoForkData.count) bytes)") + try await socket.write(infoForkData) + bytesUploaded += infoForkData.count + + // Create per-file progress for Finder + var fileProgress: Progress? + if config.publishProgress { + let progress = Progress(totalUnitCount: Int64(totalFileSize)) + progress.fileURL = fileURL.resolvingSymlinksInPath() + progress.fileOperationKind = Progress.FileOperationKind.uploading + progress.publish() + fileProgress = progress + } + + 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) + } + + print("HotlineFolderUploadClientNew[\(referenceNumber)]: File upload complete, \(bytesUploaded) bytes sent") + return bytesUploaded + } +} diff --git a/Hotline/Info.plist b/Hotline/Info.plist index b43d991..82ee6ac 100644 --- a/Hotline/Info.plist +++ b/Hotline/Info.plist @@ -8,10 +8,10 @@ CFBundleTypeName Hotline Bookmark LSHandlerRank - None + Owner LSItemContentTypes - 'HTbm' + 'HTbm' @@ -46,7 +46,7 @@ UTTypeIconFiles UTTypeIdentifier - 'HTbm' + 'HTbm' UTTypeTagSpecification public.filename-extension diff --git a/Hotline/Library/HotlinePanel.swift b/Hotline/Library/HotlinePanel.swift index 191e89a..d7d8284 100644 --- a/Hotline/Library/HotlinePanel.swift +++ b/Hotline/Library/HotlinePanel.swift @@ -8,12 +8,12 @@ class HotlinePanel: NSPanel { super.init(contentRect: NSRect(x: 0, y: 0, width: HOTLINE_PANEL_SIZE.width, height: HOTLINE_PANEL_SIZE.height), styleMask: [.nonactivatingPanel, .titled, .closable, .utilityWindow, .fullSizeContentView], backing: .buffered, defer: false) // Make sure that the panel is in front of almost all other windows - self.isFloatingPanel = false + self.isFloatingPanel = true self.level = .floating self.hidesOnDeactivate = true self.animationBehavior = .utilityWindow - // Allow the panel to appear in a fullscreen space + // Allow the panelto appear in a fullscreen space // self.collectionBehavior.insert(.fullScreenAuxiliary) self.collectionBehavior.insert(.canJoinAllSpaces) self.collectionBehavior.insert(.ignoresCycle) @@ -23,7 +23,7 @@ class HotlinePanel: NSPanel { // Don't delete panel state when it's closed. self.isReleasedWhenClosed = false - self.standardWindowButton(.closeButton)?.isHidden = true + self.standardWindowButton(.closeButton)?.isHidden = false self.standardWindowButton(.zoomButton)?.isHidden = true self.standardWindowButton(.miniaturizeButton)?.isHidden = true diff --git a/Hotline/Library/NetSocket/FileProgress.swift b/Hotline/Library/NetSocket/FileProgress.swift new file mode 100644 index 0000000..c086af7 --- /dev/null +++ b/Hotline/Library/NetSocket/FileProgress.swift @@ -0,0 +1,58 @@ +// NetSocketProgress +// Dustin Mierau • @mierau +// MIT License + +import Foundation + +public extension NetSocketNew { + + /// Progress information for file uploads/downloads + struct FileProgress: Sendable { + /// Number of bytes sent/received so far + public let sent: Int + /// Total file size (may be nil if unknown) + public let total: Int? + /// 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) { + self.sent = sent + self.total = total + self.bytesPerSecond = bytesPerSecond + self.estimatedTimeRemaining = estimatedTimeRemaining + } + + /// Format transfer speed in human-readable format + /// + /// Automatically selects appropriate unit (B/sec, KB/sec, MB/sec, GB/sec) + /// based on the magnitude of the speed. + /// + /// - Returns: Formatted string like "45KB/sec", "5B/sec", "12.5MB/sec", or nil if speed unavailable + /// + /// Example: + /// ```swift + /// if let speedString = progress.formattedSpeed { + /// print(speedString) // "2.5MB/sec" + /// } + /// ``` + public var formattedSpeed: String? { + guard let bytesPerSecond = bytesPerSecond, bytesPerSecond > 0 else { return nil } + + let kb = 1024.0 + let mb = kb * 1024.0 + let gb = mb * 1024.0 + + if bytesPerSecond >= gb { + return String(format: "%.1fGB/sec", bytesPerSecond / gb) + } else if bytesPerSecond >= mb { + return String(format: "%.1fMB/sec", bytesPerSecond / mb) + } else if bytesPerSecond >= kb { + return String(format: "%.0fKB/sec", bytesPerSecond / kb) + } else { + return String(format: "%.0fB/sec", bytesPerSecond) + } + } + } +} diff --git a/Hotline/Library/NetSocket/NetSocketNew.swift b/Hotline/Library/NetSocket/NetSocketNew.swift new file mode 100644 index 0000000..7b24b37 --- /dev/null +++ b/Hotline/Library/NetSocket/NetSocketNew.swift @@ -0,0 +1,1129 @@ +// NetSocketNew.swift +// Dustin Mierau • @mierau + +import Foundation +import Network + +/// Byte order for multi-byte integer values in binary protocols +public enum Endian { + /// Big-endian (network byte order, most significant byte first) + case big + /// Little-endian (least significant byte first) + case little +} + +/// Delimiter patterns for text-based protocols +public enum Delimiter { + /// Custom single byte delimiter + case byte(UInt8) + /// Null terminator (0x00) + case zeroByte + /// Line feed (\n, 0x0A) + case lineFeed + /// Carriage return + line feed (\r\n, 0x0D 0x0A) + case carriageReturnLineFeed + + /// Binary representation of this delimiter + var data: Data { + switch self { + case .byte(let b): return Data([b]) + case .zeroByte: return Data([0x00]) + case .lineFeed: return Data([0x0A]) + case .carriageReturnLineFeed: return Data([0x0D, 0x0A]) + } + } +} + +/// TLS/SSL encryption policy for socket connections +public struct TLSPolicy: Sendable { + /// Create a TLS-enabled policy with optional custom configuration + /// - Parameter configure: Optional closure to customize TLS options + public static func enabled(_ configure: (@Sendable (NWProtocolTLS.Options) -> Void)? = nil) -> TLSPolicy { + TLSPolicy(enabled: true, configure: configure) + } + + /// Create a policy with TLS disabled (plaintext connection) + public static var disabled: TLSPolicy { TLSPolicy(enabled: false, configure: nil) } + + /// Whether TLS is enabled + public let enabled: Bool + /// Optional TLS configuration closure + public let configure: (@Sendable (NWProtocolTLS.Options) -> Void)? +} + +// MARK: - Errors + +/// Errors that can occur during socket operations +public enum NetSocketError: Error, CustomStringConvertible, Sendable { + /// Socket is not yet in ready state + case notReady + /// Connection has been closed + case closed + /// Invalid port number provided + case invalidPort + /// Network operation failed with underlying error + case failed(underlying: Error) + /// Not enough data available to fulfill read request + case insufficientData(expected: Int, got: Int) + /// Frame size exceeds configured maximum + case framingExceeded(max: Int) + /// Failed to decode data + case decodeFailed(Error) + /// Failed to encode data + case encodeFailed(Error) + + public var description: String { + switch self { + case .notReady: return "Connection not ready." + case .closed: return "Connection closed." + case .invalidPort: return "Invalid port number." + case .failed(let e): return "Network failure: \(e.localizedDescription)" + case .insufficientData(let exp, let got): return "Insufficient data: need \(exp), have \(got)." + case .framingExceeded(let max): return "Frame length exceeded maximum \(max)." + case .decodeFailed(let e): return "Decoding failed: \(e)" + case .encodeFailed(let e): return "Encoding failed: \(e)" + } + } +} + +// MARK: - NetSocketNew + +/// An async/await TCP socket with automatic buffering and framing support +/// +/// NetSocketNew provides: +/// - Async connection management +/// - Automatic receive buffering with memory compaction +/// - Type-safe reading/writing of integers, strings, and custom types +/// - File upload/download with progress tracking +/// +/// Example usage: +/// ```swift +/// let socket = try await NetSocketNew.connect(host: "example.com", port: 80) +/// try await socket.write("Hello\n".data(using: .utf8)!) +/// let response = try await socket.readUntil(delimiter: .lineFeed) +/// ``` +public actor NetSocketNew { + /// Configuration options for the socket + public struct Config: Sendable { + /// Size of chunks to receive from network at once (default: 64 KB) + public var receiveChunk: Int = 64 * 1024 + /// Maximum bytes to buffer before disconnecting (default: 8 MB) + public var maxBufferBytes: Int = 8 * 1024 * 1024 + public init() {} + } + + // Connection + state + private let connection: NWConnection + private let queue = DispatchQueue(label: "NetSocket.NWConnection") + private var ready = false + private var isClosed = false + private let connectionID: String // For logging + + // Buffer with compaction + private var buffer = Data() + private var head = 0 // start of unread bytes + private let config: Config + + // Waiters for data/ready + private var dataWaiters: [CheckedContinuation] = [] + private var readyWaiters: [CheckedContinuation] = [] + + // MARK: Init + + private init(connection: NWConnection, config: Config) { + self.connection = connection + self.config = config + // Create a human-readable connection ID for logging + if case .hostPort(host: let h, port: let p) = connection.endpoint { + self.connectionID = "\(h):\(p)" + } else { + self.connectionID = "unknown" + } + } + + // MARK: Connect + + /// Connect to a remote host and return a ready socket + /// + /// This method establishes a TCP connection using Network framework types and waits until + /// the connection is in `.ready` state. + /// + /// - 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) + /// - Returns: A connected and ready `NetSocketNew` + /// - Throws: Network errors or connection failures + public static func connect(host: NWEndpoint.Host, port: NWEndpoint.Port, tls: TLSPolicy = .enabled(), config: Config = .init()) async throws -> NetSocketNew { + let parameters = NWParameters.tcp + if tls.enabled { + let tlsOptions = NWProtocolTLS.Options() + tls.configure?(tlsOptions) + parameters.defaultProtocolStack.applicationProtocols.insert(tlsOptions, at: 0) + } + + let conn = NWConnection(host: host, port: port, using: parameters) + let socket = NetSocketNew(connection: conn, config: config) + try await socket.start() + return socket + } + + /// 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 -> NetSocketNew { + guard let nwPort = NWEndpoint.Port(rawValue: port) else { + throw NetSocketError.invalidPort + } + return try await self.connect(host: .name(host, nil), port: nwPort, tls: tls, config: config) + } + + // MARK: Close + + /// Close the connection gracefully + /// + /// Performs a graceful shutdown of the underlying network connection (e.g., TCP FIN) + /// and wakes all pending read/write operations with a `NetSocketError.closed` error. + /// This method is idempotent - subsequent calls are ignored. + /// + /// Use `forceClose()` for immediate non-graceful termination (e.g., TCP RST). + public func close() { + guard !isClosed else { return } + isClosed = true + connection.cancel() + resumeDataWaiters() + resumeReadyWaiters(with: .failure(NetSocketError.closed)) + } + + /// Force close the connection immediately (non-graceful) + /// + /// Performs an immediate non-graceful shutdown of the underlying network connection + /// (e.g., TCP RST). Use this when you need to terminate the connection immediately + /// without waiting for graceful closure. For normal shutdown, use `close()` instead. + /// + /// This method is idempotent - subsequent calls are ignored. + public func forceClose() { + guard !isClosed else { return } + isClosed = true + connection.forceCancel() + resumeDataWaiters() + resumeReadyWaiters(with: .failure(NetSocketError.closed)) + } + + // MARK: Send Data + + /// Write raw data to the socket + /// + /// Sends data and waits for confirmation that it has been processed by the network stack. + /// + /// - Parameter data: Raw bytes to send + /// - Throws: `NetSocketError` if connection is not ready or send fails + @discardableResult + public func write(_ data: Data) async throws -> Int { + try await ensureReady() + return try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + connection.send(content: data, completion: .contentProcessed { error in + if let error { cont.resume(throwing: NetSocketError.failed(underlying: error)) } + else { cont.resume(returning: data.count) } + }) + } + } + + /// Write a fixed-width integer to the socket + /// + /// - Parameters: + /// - value: The integer value to write + /// - endian: Byte order (default: big-endian) + /// - Throws: `NetSocketError` if write fails + @discardableResult + public func write(_ value: T, endian: Endian = .big) async throws -> Int { + var v = value + switch endian { + case .big: v = T(bigEndian: value) + case .little: v = T(littleEndian: value) + } + var copy = v + let size = MemoryLayout.size + let bytes = withUnsafePointer(to: ©) { + Data(bytes: $0, count: size) + } + try await write(bytes) + return bytes.count + } + + /// Write a boolean as a single byte (0 or 1) + /// - Parameter value: Boolean value + @discardableResult + public func write(_ value: Bool) async throws -> Int { + return try await write(UInt8(value ? 0x01 : 0x00)) + } + + /// Write a Float as its IEEE 754 bit pattern + /// - Parameters: + /// - value: Float value + /// - endian: Byte order (default: big-endian) + @discardableResult + public func write(_ value: Float, endian: Endian = .big) async throws -> Int { + return try await write(value.bitPattern, endian: endian) + } + + /// Write a Double as its IEEE 754 bit pattern + /// - Parameters: + /// - value: Double value + /// - endian: Byte order (default: big-endian) + @discardableResult + public func write(_ value: Double, endian: Endian = .big) async throws -> Int { + return try await write(value.bitPattern, endian: endian) + } + + /// Write a string to the socket, optionally length-prefixed + /// + /// - Parameters: + /// - string: String to write + /// - encoding: Text encoding (default: UTF-8) + /// - allowLossyConversion: Allow lossy encoding if necessary (default: false) + /// - Throws: `NetSocketError` if encoding fails or write fails + @discardableResult + public func write(_ string: String, encoding: String.Encoding = .utf8, allowLossyConversion: Bool = false) async throws -> Int { + guard let data = string.data(using: encoding, allowLossyConversion: allowLossyConversion) else { + throw NetSocketError.encodeFailed(NSError(domain: "StringEncoding", code: -1)) + } + return try await write(data) + } + + // MARK: Receive Data + + /// Read data until a delimiter is found + /// + /// Searches the buffer for the delimiter pattern and returns all data up to (and optionally including) + /// the delimiter. The delimiter is always consumed from the stream. + /// + /// - Parameters: + /// - delimiter: Binary delimiter pattern to search for + /// - maxBytes: Maximum bytes to read before throwing (default: no limit) + /// - includeDelimiter: Whether to include delimiter in result (default: false) + /// - Returns: Data read from stream + /// - Throws: `NetSocketError.framingExceeded` if max bytes exceeded, or connection errors + public func read(past delimiter: Data, maxBytes: Int? = nil, includeDelimiter: Bool = false) async throws -> Data { + while true { + try Task.checkCancellation() + if let r = search(delimiter: delimiter) { + let consumeLen = r.upperBound - head + let data = try await read(consumeLen) + return includeDelimiter ? data : data.dropLast(delimiter.count) + } + if let maxBytes, availableBytes >= maxBytes { + throw NetSocketError.framingExceeded(max: maxBytes) + } + try await waitForData() + guard !isClosed || availableBytes > 0 else { throw NetSocketError.closed } + } + } + + /// Read exactly N bytes from the socket + /// + /// Waits for data to arrive if buffer doesn't contain enough bytes yet. The internal buffer + /// is automatically compacted after reading to prevent unbounded memory growth. + /// + /// - Parameter count: Number of bytes to read + /// - Returns: Exactly `count` bytes + /// - Throws: `NetSocketError.insufficientData` if connection closes before enough data arrives + public func read(_ count: Int) async throws -> Data { + try await self.ensureReadable(count) + let start = self.head + let end = self.head + count + let slice = self.buffer[start..(_ type: T.Type = T.self, endian: Endian = .big) async throws -> T { + let size = MemoryLayout.size + let data = try await self.read(size) + let value: T = data.withUnsafeBytes { raw in + raw.load(as: T.self) + } + switch endian { + case .big: return T(bigEndian: value) + case .little: return T(littleEndian: value) + } + } + + /// Read a fixed-length string + /// + /// - Parameters: + /// - length: Number of bytes to read + /// - encoding: Text encoding (default: UTF-8) + /// - Returns: Decoded string + /// - Throws: `NetSocketError` if decoding fails or insufficient data + public func read(_ length: Int, encoding: String.Encoding = .utf8) async throws -> String { + let data = try await self.read(length) + guard let s = String(data: data, encoding: encoding) else { + throw NetSocketError.decodeFailed(NSError()) + } + return s + } + + /// Read a string until a delimiter is found + /// + /// - Parameters: + /// - delimiter: Delimiter pattern to search for + /// - maxBytes: Maximum bytes to read before throwing (default: no limit) + /// - includeDelimiter: Whether to include delimiter in result (default: false) + /// - Returns: String read from stream (delimiter consumed but not included unless specified) + /// - Throws: `NetSocketError` if decoding fails, max bytes exceeded, or connection closed + public func read(until delimiter: Delimiter, maxBytes: Int? = nil, includeDelimiter: Bool = false) async throws -> String { + let bytes = try await read(past: delimiter.data, maxBytes: maxBytes, includeDelimiter: includeDelimiter) + guard let s = String(data: bytes, encoding: .utf8) else { throw NetSocketError.decodeFailed(NSError()) } + return s + } + + /// Read exactly N bytes with progress callbacks + /// + /// Like `read(_:)`, but reads in chunks and reports progress after each chunk. + /// Useful for downloading large amounts of data where you want to update UI progress. + /// + /// Example: + /// ```swift + /// let data = try await socket.read(1_000_000) { current, total in + /// print("Progress: \(current)/\(total)") + /// } + /// ``` + /// + /// - Parameters: + /// - count: Number of bytes to read + /// - chunkSize: Size of chunks to read at a time (default: 8192) + /// - progress: Optional callback with (bytesReceived, totalBytes) + /// - Returns: Exactly `count` bytes + /// - Throws: `NetSocketError` if connection closes before enough data arrives + public func read( + _ count: Int, + chunkSize: Int = 8192, + progress: (@Sendable (Int, Int) -> Void)? = nil + ) async throws -> Data { + var data = Data() + data.reserveCapacity(count) + var received = 0 + + while received < count { + try Task.checkCancellation() + let toRead = min(chunkSize, count - received) + let chunk = try await read(toRead) + data.append(chunk) + received += chunk.count + progress?(received, count) + } + + return data + } + + // MARK: Peek Data + + public var availableBytes: Int { self.buffer.count - self.head } + + public func peek(_ count: Int) -> Data? { + guard self.availableBytes >= count else { + return nil + } + + let slice = self.buffer[self.head..<(self.head + count)] + return Data(slice) // Don't advance head + } + + public func peek(upto count: Int) -> Data { + let amount = min(self.availableBytes, count) + guard amount > 0 else { + return Data() + } + + let slice = self.buffer[self.head..<(self.head + amount)] + return Data(slice) + } + + public func peek(awaiting count: Int) async throws -> Data { + try await self.ensureReadable(count) + let slice = self.buffer[self.head..<(self.head + count)] + return Data(slice) // Don't advance head + } + + // MARK: Skip Data + + /// Skip/discard exactly N bytes from the stream without allocating memory + public func skip(_ count: Int) async throws { + guard count > 0 else { return } + try await self.ensureReadable(count) + self.head += count + self.compactIfNeeded() + } + + /// Skip until delimiter is found (discards delimiter too) + public func skip(past delimiter: Data) async throws { + while true { + try Task.checkCancellation() + if let r = self.search(delimiter: delimiter) { + self.head = r.upperBound // Skip to end of delimiter + self.compactIfNeeded() + return + } + try await self.waitForData() + guard !self.isClosed else { + throw NetSocketError.closed + } + } + } + + // MARK: Files + + /// Upload a file from a URL, yielding progress as an AsyncSequence. + /// + /// Iterating this sequence drives the transfer. Each yielded value reports + /// the total bytes sent so far and the known total. Cancel the consuming + /// task to cancel the transfer. + /// + /// This method handles opening and closing the file handle automatically. + /// + /// - Parameters: + /// - url: File URL to upload. + /// - chunkSize: Size of each read chunk. + /// - Returns: An `AsyncThrowingStream` of `FileProgress` updates. + func writeFile(from url: URL, chunkSize: Int = 256 * 1024) -> AsyncThrowingStream { + // This stream wrapper manages the FileHandle's lifetime. + return AsyncThrowingStream(bufferingPolicy: .bufferingOldest(1)) { continuation in + // Capture self (the actor) to use in detached task + let actor = self + + // Open file on a background thread (file I/O is blocking) + let task = Task.detached { + let fh: FileHandle + let total: Int + + // 1. Open file and get length (blocking I/O, done off-actor) + do { + total = Int(try NetSocketNew.fileLength(at: url)) + fh = try FileHandle(forReadingFrom: url) + } catch { + continuation.finish(throwing: NetSocketError.failed(underlying: error)) + return + } + + // 2. Now switch to the actor context to call the actor-isolated method + let stream = await actor.writeFile( + from: fh, + length: total, + chunkSize: chunkSize + ) + + // 3. Forward all elements from the underlying stream to our stream + do { + for try await progress in stream { + try Task.checkCancellation() // Exit early if cancelled + continuation.yield(progress) + } + try? fh.close() + continuation.finish() + } catch is CancellationError { + try? fh.close() + continuation.finish() + } catch { + try? fh.close() + continuation.finish(throwing: error) + } + } + + // If the *consumer* cancels the stream, we cancel our managing task. + continuation.onTermination = { @Sendable _ in + task.cancel() + } + } + } + + /// Upload a file from an open FileHandle, yielding progress as an AsyncSequence. + /// + /// Iterating this sequence drives the transfer. Each yielded value reports + /// the total bytes sent so far and the known total. Cancel the consuming + /// task to cancel the transfer. + /// + /// **Note:** The caller is responsible for opening and closing the `fileHandle`. + /// + /// - Parameters: + /// - fileHandle: Open `FileHandle` for reading. + /// - length: Exact number of bytes to send (total file size). + /// - chunkSize: Size of each read chunk. + /// - Returns: An `AsyncThrowingStream` of `FileProgress` updates. + func writeFile(from fileHandle: FileHandle, length: Int, chunkSize: Int = 256 * 1024) -> AsyncThrowingStream { + precondition(length >= 0, "length must be >= 0") + + if length == 0 { + return AsyncThrowingStream { continuation in + continuation.yield(.init(sent: 0, total: 0, bytesPerSecond: 0, estimatedTimeRemaining: 0)) + continuation.finish() + } + } + + return AsyncThrowingStream(bufferingPolicy: .bufferingOldest(1)) { continuation in + let task = Task { [weak self] in + guard let self else { + continuation.finish() + return + } + + var estimator = TransferRateEstimator(total: Int(length)) + + do { + try await self.ensureReady() + + while estimator.transferred < length { + try Task.checkCancellation() + + let toRead = Int(min(chunkSize, length - estimator.transferred)) + + // Read from disk + guard let chunk = try fileHandle.read(upToCount: toRead), !chunk.isEmpty else { + if estimator.transferred < length { + throw NetSocketError.failed(underlying: NSError( + domain: "NetSocket", code: 9001, + userInfo: [NSLocalizedDescriptionKey: "File read ended prematurely. Expected \(length) bytes, got \(estimator.transferred)."] + )) + } + break + } + + // Write to network + try await self.write(chunk) + + // Update estimator and yield progress + let progress = estimator.update(bytes: chunk.count) + continuation.yield(progress) + } + + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { @Sendable _ in + task.cancel() + } + } + } + + /// Receive a file of known length and yield progress updates as an AsyncSequence. + /// + /// Iterating this sequence drives the transfer. Each yielded value reports + /// the total bytes written so far and the known total. Cancel the consuming + /// task to cancel the transfer. + /// + /// - Parameters: + /// - fileHandle: Open `FileHandle` for writing (caller must close). + /// - length: Exact number of bytes expected. + /// - chunkSize: Size of each read chunk. + /// - Returns: An `AsyncThrowingStream` of `FileProgress` updates. + func receiveFile(to fileHandle: FileHandle, length: Int, chunkSize: Int = 256 * 1024) -> AsyncThrowingStream { + precondition(length >= 0, "length must be >= 0") + + if length == 0 { + return AsyncThrowingStream { continuation in + continuation.yield(.init(sent: 0, total: 0, bytesPerSecond: 0, estimatedTimeRemaining: 0)) + continuation.finish() + } + } + + return AsyncThrowingStream(bufferingPolicy: .bufferingOldest(1)) { continuation in + let task = Task { [weak self] in + guard let self else { + continuation.finish() + return + } + + var estimator = TransferRateEstimator(total: length) + + do { + var remaining: Int = length + + while remaining > 0 { + try Task.checkCancellation() + let n = min(chunkSize, remaining) + + let chunk = try await self.read(n) + try fileHandle.write(contentsOf: chunk) + + let chunkSize = Int(chunk.count) + remaining -= chunkSize + let progress = estimator.update(bytes: chunkSize) + continuation.yield(progress) + } + + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { @Sendable _ in + task.cancel() + } + } + } + + /// Download a file of known length and write it to disk in chunks + /// + /// This method does **not** read a length prefix. The caller must provide the expected + /// file size (e.g., from protocol metadata). The file is streamed directly to disk to + /// avoid loading it entirely into memory. + /// + /// Supports atomic writes: when enabled, data is written to a temporary `.part` file and + /// renamed on success. If an error occurs, the temporary file is automatically cleaned up. + /// + /// - Parameters: + /// - url: Destination file URL + /// - length: Exact number of bytes to read (must match what's on the wire) + /// - chunkSize: Chunk size for reading/writing (default: 256 KB) + /// - overwrite: Whether to overwrite existing file (default: true) + /// - atomic: Write to temporary file and rename on success (default: true) + /// - progress: Optional progress callback + /// - Returns: Total bytes written (equals `length` on success) + /// - Throws: File I/O or network errors. On atomic writes, partial files are cleaned up. + /// + /// Example: + /// ```swift + /// // Hotline protocol: file size comes from transaction header + /// let transaction = try await socket.receive(HotlineTransaction.self) + /// try await socket.receiveFile( + /// to: destinationURL, + /// length: transaction.fileSize + /// ) + /// ``` + @discardableResult + func receiveFile( + to url: URL, + length: Int, + chunkSize: Int = 256 * 1024, + overwrite: Bool = true, + atomic: Bool = true, + progress: (@Sendable (FileProgress) -> Void)? = nil + ) async throws -> Int { + precondition(length >= 0, "length must be >= 0") + + // Fast path: nothing to do + if length == 0 { + if overwrite { try? FileManager.default.removeItem(at: url) } + FileManager.default.createFile(atPath: url.path, contents: Data(), attributes: nil) + return 0 + } + + // Prepare destination (optionally atomic) + let fm = FileManager.default + let dir = url.deletingLastPathComponent() + let tmp = atomic ? dir.appendingPathComponent(".\(url.lastPathComponent).part-\(UUID().uuidString)") : url + + if overwrite { try? fm.removeItem(at: tmp) } + if overwrite, !atomic { try? fm.removeItem(at: url) } + + // Create and open the file for writing + fm.createFile(atPath: tmp.path, contents: nil, attributes: nil) + let fh = try FileHandle(forWritingTo: tmp) + defer { try? fh.close() } + + var remaining: Int = length + var written: Int = 0 + + do { + while remaining > 0 { + try Task.checkCancellation() + let n = Int(min(chunkSize, remaining)) + let chunk = try await self.read(n) + try fh.write(contentsOf: chunk) + remaining -= n + written += Int(n) + progress?(.init(sent: written, total: length)) + } + } catch { + // Cleanup partial file on failure if we were writing atomically + if atomic { try? fm.removeItem(at: tmp) } + throw error + } + + // Atomically move into place if requested + if atomic { + if overwrite { try? fm.removeItem(at: url) } + try fm.moveItem(at: tmp, to: url) + } + + return written + } + + // MARK: Internals + + private func start() async throws { + self.connection.stateUpdateHandler = { state in + Task { [weak self] in + guard let self else { return } + switch state { + case .ready: + await self.setReady() + await self.resumeReadyWaiters(with: .success(())) + case .failed(let error): + await self.failAllWaiters(NetSocketError.failed(underlying: error)) + await self.setClosed() + case .waiting(let error): + // bubble as transient failure for awaiters; reconnect logic could live here + await self.resumeReadyWaiters(with: .failure(NetSocketError.failed(underlying: error))) + case .cancelled: + await self.failAllWaiters(NetSocketError.closed) + await self.setClosed() + default: + break + } + } + } + + // Kick off receive loop after .start + self.connection.start(queue: queue) + try await self.waitUntilReady() + self.startReceiveLoop() + } + + private func startReceiveLoop() { + @Sendable func loop(_ connection: NWConnection, chunk: Int, owner: NetSocketNew, connID: String) { + print("NetSocketNew[\(connID)]: Calling connection.receive(\(chunk)) to request more data...") + + connection.receive(minimumIncompleteLength: 1, maximumLength: chunk) { [weak owner] data, _, isComplete, error in + print("NetSocketNew[\(connID)]: Receive callback - data: \(data?.count ?? 0) bytes, isComplete: \(isComplete), error: \(String(describing: error))") + Task { + guard let o = owner else { + return + } + + if let error { + await o.handleReceiveError(error) + return + } + if let data, !data.isEmpty { + await o.append(data, connID: connID) + } + if isComplete { + print("NetSocketNew[\(connID)]: EOF from peer.") + await o.handleEOF() + return + } + loop(connection, chunk: chunk, owner: o, connID: connID) + } + } + } + loop(connection, chunk: self.config.receiveChunk, owner: self, connID: connectionID) + } + + private func handleReceiveError(_ error: Error) { + self.isClosed = true + self.failAllWaiters(NetSocketError.failed(underlying: error)) + } + + private func handleEOF() { + self.isClosed = true + let waiters = self.dataWaiters + self.dataWaiters.removeAll() + for w in waiters { + w.resume() + } // wake so readers can observe closure + } + + private func setReady() { + self.ready = true + } + + private func setClosed() { + self.isClosed = true + } + + private func ensureReady() async throws { + if self.isClosed { + throw NetSocketError.closed + } + if !self.ready { + try await self.waitUntilReady() + } + } + + private func ensureReadable(_ count: Int) async throws { + try await self.ensureReady() + while self.availableBytes < count { + try Task.checkCancellation() + if self.isClosed { + throw NetSocketError.insufficientData(expected: count, got: self.availableBytes) + } + try await self.waitForData() + } + } + + private func waitForData() async throws { + try Task.checkCancellation() + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + if self.isClosed { + cont.resume() + return + } + self.dataWaiters.append(cont) + } + } + + private func compactIfNeeded() { + // Avoid unbounded memory as head advances + if self.head > 64 * 1024 && self.head > self.buffer.count / 2 { + self.buffer.removeSubrange(0.. Range? { + guard !delimiter.isEmpty, availableBytes >= delimiter.count else { return nil } + let hay = buffer[head.. Int64 { + let values = try url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) + guard values.isRegularFile == true else { + throw NetSocketError.failed(underlying: NSError( + domain: "NetSocket", code: 1001, + userInfo: [NSLocalizedDescriptionKey: "Not a regular file: \(url.path)"] + )) + } + if let s = values.fileSize { return Int64(s) } + let attrs = try FileManager.default.attributesOfItem(atPath: url.path) + if let n = attrs[.size] as? NSNumber { + return n.int64Value + } + throw NetSocketError.failed(underlying: NSError( + domain: "NetSocket", code: 1002, + userInfo: [NSLocalizedDescriptionKey: "Unable to determine file size for \(url.lastPathComponent)"] + )) + } + + private func waitUntilReady() async throws { + guard !self.ready else { return } + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + self.readyWaiters.append(cont) + } + } + + private func resumeReadyWaiters(with result: Result) { + let waiters = self.readyWaiters + self.readyWaiters.removeAll() + for w in waiters { + switch result { + case .success: w.resume() + case .failure(let e): w.resume(throwing: e) + } + } + } + + private func failAllWaiters(_ error: Error) { + self.resumeReadyWaiters(with: .failure(error)) + let waiters = self.dataWaiters + self.dataWaiters.removeAll() + for w in waiters { + w.resume(throwing: error) + } + } + + private func append(_ data: Data, connID: String) { + print("NetSocketNew[\(connID)]: Received \(data.count) bytes from network, buffer now has \(buffer.count - head + data.count) available") + buffer.append(data) + if buffer.count - head > config.maxBufferBytes { + // Hard stop: drop connection rather than OOM'ing. + isClosed = true + connection.cancel() + failAllWaiters(NetSocketError.framingExceeded(max: config.maxBufferBytes)) + return + } + resumeDataWaiters() + } + + private func resumeDataWaiters() { + let waiters = dataWaiters + dataWaiters.removeAll() + for w in waiters { w.resume() } + } +} + +// MARK: - Utilities + +private extension Data { + mutating func appendInteger(_ value: T, endian: Endian) throws { + var v = value + switch endian { + case .big: v = T(bigEndian: value) + case .little: v = T(littleEndian: value) + } + var copy = v + withUnsafePointer(to: ©) { ptr in + self.append(contentsOf: UnsafeRawBufferPointer(start: ptr, count: MemoryLayout.size)) + } + } +} + +// MARK: - NetSocketEncodable + +/// Protocol for types that can encode themselves to binary data +/// +/// Types conforming to `NetSocketEncodable` produce binary data that can be sent over +/// a socket. Unlike writing field-by-field to the socket, encodable types build complete +/// binary messages that are sent in a single write operation for efficiency. +/// +/// Example: +/// ```swift +/// struct MyMessage: NetSocketEncodable { +/// let id: UInt32 +/// let name: String +/// +/// func encode(endian: Endian) throws -> Data { +/// var data = Data() +/// // Encode fields to data... +/// return data +/// } +/// } +/// +/// try await socket.send(message) +/// ``` +public protocol NetSocketEncodable: Sendable { + /// Encode this value to binary data + /// + /// Implementations should build a complete binary message and return it as Data. + /// The data will be sent to the socket in a single write operation. + /// + /// - Parameter endian: Byte order for multi-byte values + /// - Returns: Encoded binary data ready to send + /// - Throws: Encoding errors + func encode(endian: Endian) throws -> Data +} + +/// Protocol for types that can decode themselves directly from a socket stream +/// +/// Types conforming to `NetSocketDecodable` read field-by-field directly from the socket +/// using async reads. This enables true streaming without buffering entire messages. +/// +/// **Important**: If decoding throws after consuming some bytes (e.g., validation fails), +/// the socket will be left with those bytes consumed. In practice, this usually means the +/// connection should be closed. For most protocols this is acceptable since decode errors +/// indicate corrupt data or protocol violations. +/// +/// Example: +/// ```swift +/// struct MyMessage: NetSocketDecodable { +/// let id: UInt32 +/// let name: String +/// +/// init(from socket: NetSocketNew, endian: Endian) async throws { +/// self.id = try await socket.read(UInt32.self, endian: endian) +/// let nameLen = try await socket.read(UInt16.self, endian: endian) +/// let nameData = try await socket.readExactly(Int(nameLen)) +/// guard let name = String(data: nameData, encoding: .utf8) else { +/// throw NetSocketError.decodeFailed(NSError()) +/// } +/// self.name = name +/// } +/// } +/// +/// let message = try await socket.receive(MyMessage.self) +/// ``` +public protocol NetSocketDecodable: Sendable { + /// Decode a value by reading directly from the socket stream + /// + /// This initializer should read all necessary fields from the socket using + /// methods like `read(_:endian:)`, `readExactly(_:)`, `readString(length:)`, etc. + /// + /// The socket handles waiting for data to arrive, so you can read field by field + /// without worrying about buffering. + /// + /// - Parameters: + /// - socket: Socket to read from + /// - endian: Byte order for multi-byte values + /// - Throws: Network errors, insufficient data, or custom decoding errors + init(from socket: NetSocketNew, endian: Endian) async throws +} + +public extension NetSocketNew { + /// Send an encodable value to the socket + /// + /// The type encodes itself to binary data, which is then sent in a single write operation. + /// + /// Example: + /// ```swift + /// struct MyMessage: NetSocketEncodable { + /// let id: UInt32 + /// let name: String + /// + /// func encode(endian: Endian) throws -> Data { + /// var data = Data() + /// // Build binary message... + /// return data + /// } + /// } + /// + /// try await socket.send(message) + /// ``` + /// + /// - Parameters: + /// - value: Value conforming to NetSocketEncodable + /// - endian: Byte order (default: big-endian) + /// - Throws: Encoding or network errors + func send(_ value: T, endian: Endian = .big) async throws { + let data = try value.encode(endian: endian) + try await self.write(data) + } + + /// Receive and decode a value directly from the socket stream (no length prefix) + /// + /// The type reads field-by-field from the socket as needed, enabling true streaming + /// without buffering entire messages. Useful for protocols where message size isn't + /// known upfront or for progressive decoding. + /// + /// Example: + /// ```swift + /// struct ServerEntry: NetSocketDecodable { + /// let id: UInt32 + /// let name: String + /// + /// init(from socket: NetSocketNew, endian: Endian) async throws { + /// self.id = try await socket.read(UInt32.self, endian: endian) + /// // Read variable-length string... + /// } + /// } + /// + /// let entry = try await socket.receive(ServerEntry.self) + /// ``` + /// + /// - Parameters: + /// - type: Type conforming to NetSocketDecodable + /// - endian: Byte order (default: big-endian) + /// - Returns: Decoded value + /// - Throws: Decoding or network errors + func receive(_ type: T.Type, endian: Endian = .big) async throws -> T { + return try await T(from: self, endian: endian) + } +} diff --git a/Hotline/Library/NetSocket/TransferRateEstimator.swift b/Hotline/Library/NetSocket/TransferRateEstimator.swift new file mode 100644 index 0000000..7d16904 --- /dev/null +++ b/Hotline/Library/NetSocket/TransferRateEstimator.swift @@ -0,0 +1,135 @@ +// TransferRateEstimator +// Dustin Mierau • @mierau +// MIT License + +import Foundation + +/// Transfer rate estimator using exponential moving average (EMA) +/// +/// Tracks transfer speed and estimates time remaining. Designed to smooth out +/// network jitter and provide stable estimates after collecting enough samples. +/// +/// Example: +/// ```swift +/// var estimator = TransferRateEstimator(total: fileSize) +/// +/// while transferring { +/// let chunk = try await receiveData() +/// let progress = estimator.update(bytes: chunk.count) +/// print("Speed: \(progress.bytesPerSecond ?? 0) B/s, ETA: \(progress.estimatedTimeRemaining ?? 0)s") +/// } +/// ``` +public struct TransferRateEstimator { + /// Total bytes to transfer (nil if unknown) + public let total: Int? + + /// Exponential moving average of transfer rate (bytes/second) + private var emaBytesPerSecond: Double = 0 + + /// Smoothing factor for EMA (0 < alpha ≤ 1) + /// Higher = more responsive to recent changes, lower = more smoothing + private let alpha: Double + + /// Number of samples collected + private var sampleCount: Int = 0 + + /// Timestamp of first sample (for elapsed time calculation) + private var startTime: ContinuousClock.Instant? + + /// Timestamp of last update (for calculating sample duration) + private var lastUpdateTime: ContinuousClock.Instant? + + /// Minimum elapsed time before trusting estimates (seconds) + private let minElapsedTime: TimeInterval + + /// Minimum number of samples before trusting estimates + private let minSamples: Int + + /// Current number of bytes transferred + public private(set) var transferred: Int = 0 + + /// Create a new transfer rate estimator + /// + /// - Parameters: + /// - total: Total bytes to transfer (nil if unknown) + /// - alpha: EMA smoothing factor (default: 0.2). Range: 0.0-1.0 + /// - minElapsedTime: Minimum elapsed time before estimates are reliable (default: 2.0s) + /// - minSamples: Minimum samples before estimates are reliable (default: 4) + public init( + total: Int? = nil, + alpha: Double = 0.2, + minElapsedTime: TimeInterval = 2.0, + minSamples: Int = 8 + ) { + precondition(alpha > 0 && alpha <= 1, "alpha must be in range (0, 1]") + precondition(minSamples >= 0, "minSamples must be >= 0") + + self.total = total + self.alpha = alpha + self.minElapsedTime = minElapsedTime + self.minSamples = minSamples + } + + public mutating func update(total: Int) -> NetSocketNew.FileProgress { + return self.update(bytes: max(0, total - self.transferred)) + } + + /// Update the estimator with a new data sample + /// + /// Automatically calculates the duration since the last update. + /// + /// - Parameter bytes: Number of bytes transferred in this sample + /// - Returns: Current progress with speed and ETA estimates + public mutating func update(bytes: Int) -> NetSocketNew.FileProgress { + let clock = ContinuousClock() + let now = clock.now + + // Record start time on first sample + if self.startTime == nil { + self.startTime = now + } + + // Calculate duration since last update + let duration = self.lastUpdateTime.map { now - $0 } ?? .zero + self.lastUpdateTime = now + + // Update transferred count + self.transferred += bytes + + // Calculate instantaneous rate for this sample + let seconds: Double = duration / .seconds(1.0) + if seconds > 0 { + let instantRate = Double(bytes) / seconds + self.sampleCount += 1 + + // Update EMA + if self.emaBytesPerSecond == 0 { + self.emaBytesPerSecond = instantRate + } else { + self.emaBytesPerSecond += self.alpha * (instantRate - self.emaBytesPerSecond) + } + } + + // Determine if we have enough data to trust the estimate + let elapsed = self.startTime.map { now - $0 } ?? .zero + let elapsedSeconds: Double = elapsed / .seconds(1.0) + let haveEstimate = (elapsedSeconds >= self.minElapsedTime || self.sampleCount >= self.minSamples) && self.emaBytesPerSecond > 0 + + // Calculate ETA if we have both an estimate and a known total + let eta: TimeInterval? + if haveEstimate, let total = self.total { + let remaining = total - self.transferred + eta = remaining > 0 ? TimeInterval(Double(remaining) / self.emaBytesPerSecond) : 0 + } else { + eta = nil + } + + return NetSocketNew.FileProgress( + sent: self.transferred, + total: self.total, + bytesPerSecond: haveEstimate ? self.emaBytesPerSecond : nil, + estimatedTimeRemaining: eta + ) + } +} + diff --git a/Hotline/Library/NetSocketNew.swift b/Hotline/Library/NetSocketNew.swift deleted file mode 100644 index 8873ee6..0000000 --- a/Hotline/Library/NetSocketNew.swift +++ /dev/null @@ -1,1278 +0,0 @@ - -// NetSocketNew.swift -// Created by Dustin Mierau • @mierau - -import Foundation -import Network - -// MARK: - Endianness and Framing - -/// Byte order for multi-byte integer values in binary protocols -public enum Endian { - /// Big-endian (network byte order, most significant byte first) - case big - /// Little-endian (least significant byte first) - case little -} - -/// Length prefix types for framing variable-length data (strings, arrays, binary blobs) -/// -/// Used to encode the size of the following data as a fixed-width integer. -/// Each case can specify its own endianness. -public enum LengthPrefix { - /// 1-byte length prefix (0-255) - case u8 - /// 2-byte length prefix (0-65,535) - case u16(Endian = .big) - /// 4-byte length prefix (0-4,294,967,295) - case u32(Endian = .big) - /// 8-byte length prefix (0-2^64-1) - case u64(Endian = .big) - - /// Number of bytes used by this length prefix - var byteCount: Int { - switch self { - case .u8: return 1 - case .u16: return 2 - case .u32: return 4 - case .u64: return 8 - } - } -} - -/// Delimiter patterns for text-based protocols -public enum Delimiter { - /// Custom single byte delimiter - case byte(UInt8) - /// Null terminator (0x00) - case zeroByte - /// Line feed (\n, 0x0A) - case lineFeed - /// Carriage return + line feed (\r\n, 0x0D 0x0A) - case carriageReturnLineFeed - - /// Binary representation of this delimiter - var data: Data { - switch self { - case .byte(let b): return Data([b]) - case .zeroByte: return Data([0x00]) - case .lineFeed: return Data([0x0A]) - case .carriageReturnLineFeed: return Data([0x0D, 0x0A]) - } - } -} - -/// TLS/SSL encryption policy for socket connections -public struct TLSPolicy: Sendable { - /// Create a TLS-enabled policy with optional custom configuration - /// - Parameter configure: Optional closure to customize TLS options - public static func enabled(_ configure: (@Sendable (NWProtocolTLS.Options) -> Void)? = nil) -> TLSPolicy { - TLSPolicy(enabled: true, configure: configure) - } - - /// Create a policy with TLS disabled (plaintext connection) - public static var disabled: TLSPolicy { TLSPolicy(enabled: false, configure: nil) } - - /// Whether TLS is enabled - public let enabled: Bool - /// Optional TLS configuration closure - public let configure: (@Sendable (NWProtocolTLS.Options) -> Void)? -} - -// MARK: - Errors - -/// Errors that can occur during socket operations -public enum NetSocketError: Error, CustomStringConvertible, Sendable { - /// Socket is not yet in ready state - case notReady - /// Connection has been closed - case closed - /// Invalid port number provided - case invalidPort - /// Network operation failed with underlying error - case failed(underlying: Error) - /// Not enough data available to fulfill read request - case insufficientData(expected: Int, got: Int) - /// Frame size exceeds configured maximum - case framingExceeded(max: Int) - /// Failed to decode data - case decodeFailed(Error) - /// Failed to encode data - case encodeFailed(Error) - - public var description: String { - switch self { - case .notReady: return "Connection not ready." - case .closed: return "Connection closed." - case .invalidPort: return "Invalid port number." - case .failed(let e): return "Network failure: \(e.localizedDescription)" - case .insufficientData(let exp, let got): return "Insufficient data: need \(exp), have \(got)." - case .framingExceeded(let max): return "Frame length exceeded maximum \(max)." - case .decodeFailed(let e): return "Decoding failed: \(e)" - case .encodeFailed(let e): return "Encoding failed: \(e)" - } - } -} - -// MARK: - NetSocketNew - -/// An async/await TCP socket with automatic buffering and framing support -/// -/// NetSocketNew provides: -/// - Async connection management -/// - Automatic receive buffering with memory compaction -/// - Length-prefixed framing for messages -/// - Type-safe reading/writing of integers, strings, and custom types -/// - File upload/download with progress tracking -/// - Flexible encoder/decoder support (JSON, binary, etc.) -/// -/// Example usage: -/// ```swift -/// let socket = try await NetSocketNew.connect(host: "example.com", port: 80) -/// try await socket.write("Hello\n".data(using: .utf8)!) -/// let response = try await socket.readUntil(delimiter: .lineFeed) -/// ``` -public actor NetSocketNew { - /// Configuration options for the socket - public struct Config: Sendable { - /// Size of chunks to receive from network at once (default: 64 KB) - public var receiveChunk: Int = 64 * 1024 - /// Maximum bytes to buffer before disconnecting (default: 8 MB) - public var maxBufferBytes: Int = 8 * 1024 * 1024 - /// Maximum size for a single framed message (default: 4 MB) - public var maxFrameBytes: Int = 4 * 1024 * 1024 - public init() {} - } - - // Connection + state - private let connection: NWConnection - private let queue = DispatchQueue(label: "NetSocket.NWConnection") - private var ready = false - private var isClosed = false - - // Buffer with compaction - private var buffer = Data() - private var head = 0 // start of unread bytes - private let cfg: Config - - // Waiters for data/ready - private var dataWaiters: [CheckedContinuation] = [] - private var readyWaiters: [CheckedContinuation] = [] - - // Codable hooks - stored as closures for flexibility with any encoder/decoder - private var encodeValue: @Sendable (any Encodable) throws -> Data = { value in - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - return try encoder.encode(value) - } - - private var decodeValue: @Sendable (Data, any Decodable.Type) throws -> any Decodable = { data, type in - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - return try decoder.decode(type, from: data) - } - - // MARK: Init/Connect - - private init(connection: NWConnection, config: Config) { - self.connection = connection - self.cfg = config - } - - /// Connect to a remote host and return a ready socket - /// - /// This method establishes a TCP connection using Network framework types and waits until - /// the connection is in `.ready` state. - /// - /// - 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) - /// - Returns: A connected and ready `NetSocketNew` - /// - Throws: Network errors or connection failures - public static func connect(host: NWEndpoint.Host, port: NWEndpoint.Port, tls: TLSPolicy = .enabled(), config: Config = .init()) async throws -> NetSocketNew { - let parameters = NWParameters.tcp - if tls.enabled { - let tlsOptions = NWProtocolTLS.Options() - tls.configure?(tlsOptions) - parameters.defaultProtocolStack.applicationProtocols.insert(tlsOptions, at: 0) - } - - let conn = NWConnection(host: host, port: port, using: parameters) - let socket = NetSocketNew(connection: conn, config: config) - try await socket.start() - return socket - } - - /// 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 -> NetSocketNew { - guard let nwPort = NWEndpoint.Port(rawValue: port) else { - throw NetSocketError.invalidPort - } - return try await connect(host: .name(host, nil), port: nwPort, tls: tls, config: config) - } - - /// Inject custom encoding/decoding logic (supports any encoder/decoder: JSON, CBOR, MessagePack, etc.) - /// - /// Example with JSONEncoder: - /// ``` - /// let encoder = JSONEncoder() - /// socket.useCoders( - /// encode: { try encoder.encode($0) }, - /// decode: { data, type in try decoder.decode(type, from: data) } - /// ) - /// ``` - /// - /// Example with other encoders (pseudocode): - /// ``` - /// let cbor = CBOREncoder() - /// socket.useCoders( - /// encode: { try cbor.encode($0) }, - /// decode: { data, type in try CBORDecoder().decode(type, from: data) } - /// ) - /// ``` - public func useCoders( - encode: @escaping @Sendable (any Encodable) throws -> Data, - decode: @escaping @Sendable (Data, any Decodable.Type) throws -> any Decodable - ) { - self.encodeValue = encode - self.decodeValue = decode - } - - /// Convenience method to configure JSON encoding/decoding - /// - /// Sets up the socket to use the provided JSON encoder/decoder for `send()` and `receive()` calls. - /// - /// - Parameters: - /// - encoder: A configured `JSONEncoder` - /// - decoder: A configured `JSONDecoder` - public func useJSONCoders(encoder: JSONEncoder, decoder: JSONDecoder) { - self.encodeValue = { try encoder.encode($0) } - self.decodeValue = { data, type in try decoder.decode(type, from: data) } - } - - private func start() async throws { - self.connection.stateUpdateHandler = { state in - Task { [weak self] in - guard let self else { return } - switch state { - case .ready: - await self.setReady() - await self.resumeReadyWaiters(with: .success(())) - case .failed(let error): - await self.failAllWaiters(NetSocketError.failed(underlying: error)) - await self.setClosed() - case .waiting(let error): - // bubble as transient failure for awaiters; reconnect logic could live here - await self.resumeReadyWaiters(with: .failure(NetSocketError.failed(underlying: error))) - case .cancelled: - await self.failAllWaiters(NetSocketError.closed) - await self.setClosed() - default: - break - } - } - } - - // Kick off receive loop after .start - self.connection.start(queue: queue) - try await self.waitUntilReady() - self.startReceiveLoop() - } - - private func waitUntilReady() async throws { - guard !ready else { return } - try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in - readyWaiters.append(cont) - } - } - - private func resumeReadyWaiters(with result: Result) { - let waiters = readyWaiters - readyWaiters.removeAll() - for w in waiters { - switch result { - case .success: w.resume() - case .failure(let e): w.resume(throwing: e) - } - } - } - - private func failAllWaiters(_ error: Error) { - resumeReadyWaiters(with: .failure(error)) - let waiters = dataWaiters - dataWaiters.removeAll() - for w in waiters { w.resume(throwing: error) } - } - - private func setReady() { - ready = true - } - - private func setClosed() { - isClosed = true - } - - // MARK: Receive loop (runs on DispatchQueue, hops into actor) - - private nonisolated func startReceiveLoop() { - func loop(_ connection: NWConnection, chunk: Int, owner: NetSocketNew) { - print("NetSocketNew: Calling connection.receive() to request more data...") - connection.receive(minimumIncompleteLength: 1, maximumLength: chunk) { data, _, isComplete, error in - print("NetSocketNew: Receive callback - data: \(data?.count ?? 0) bytes, isComplete: \(isComplete), error: \(String(describing: error))") - if let error { - Task { await owner.handleReceiveError(error) } - return - } - if let data, !data.isEmpty { - Task { await owner.append(data) } - } - if isComplete { - Task { await owner.handleEOF() } - return - } - loop(connection, chunk: chunk, owner: owner) - } - } - loop(connection, chunk: cfg.receiveChunk, owner: self) - } - - private func handleReceiveError(_ error: Error) { - isClosed = true - failAllWaiters(NetSocketError.failed(underlying: error)) - } - - private func handleEOF() { - isClosed = true - let waiters = dataWaiters - dataWaiters.removeAll() - for w in waiters { w.resume() } // wake so readers can observe closure - } - - private func append(_ data: Data) { - print("NetSocketNew: Received \(data.count) bytes from network, buffer now has \(buffer.count - head + data.count) available") - buffer.append(data) - if buffer.count - head > cfg.maxBufferBytes { - // Hard stop: drop connection rather than OOM'ing. - isClosed = true - connection.cancel() - failAllWaiters(NetSocketError.framingExceeded(max: cfg.maxBufferBytes)) - return - } - resumeDataWaiters() - } - - private func resumeDataWaiters() { - let waiters = dataWaiters - dataWaiters.removeAll() - for w in waiters { w.resume() } - } - - // MARK: Close - - /// Close the connection gracefully - /// - /// Performs a graceful shutdown of the underlying network connection (e.g., TCP FIN) - /// and wakes all pending read/write operations with a `NetSocketError.closed` error. - /// This method is idempotent - subsequent calls are ignored. - /// - /// Use `forceClose()` for immediate non-graceful termination (e.g., TCP RST). - public func close() { - guard !isClosed else { return } - isClosed = true - connection.cancel() - resumeDataWaiters() - resumeReadyWaiters(with: .failure(NetSocketError.closed)) - } - - /// Force close the connection immediately (non-graceful) - /// - /// Performs an immediate non-graceful shutdown of the underlying network connection - /// (e.g., TCP RST). Use this when you need to terminate the connection immediately - /// without waiting for graceful closure. For normal shutdown, use `close()` instead. - /// - /// This method is idempotent - subsequent calls are ignored. - public func forceClose() { - guard !isClosed else { return } - isClosed = true - connection.forceCancel() - resumeDataWaiters() - resumeReadyWaiters(with: .failure(NetSocketError.closed)) - } - - // MARK: Send (async) - - /// Write raw data to the socket - /// - /// Sends data and waits for confirmation that it has been processed by the network stack. - /// - /// - Parameter data: Raw bytes to send - /// - Throws: `NetSocketError` if connection is not ready or send fails - public func write(_ data: Data) async throws { - try await ensureReady() - try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in - connection.send(content: data, completion: .contentProcessed { error in - if let error { cont.resume(throwing: NetSocketError.failed(underlying: error)) } - else { cont.resume() } - }) - } - } - - /// Write a fixed-width integer to the socket - /// - /// - Parameters: - /// - value: The integer value to write - /// - endian: Byte order (default: big-endian) - /// - Throws: `NetSocketError` if write fails - public func write(_ value: T, endian: Endian = .big) async throws { - var v = value - switch endian { - case .big: v = T(bigEndian: value) - case .little: v = T(littleEndian: value) - } - var copy = v - let size = MemoryLayout.size - let bytes = withUnsafePointer(to: ©) { - Data(bytes: $0, count: size) - } - try await write(bytes) - } - - /// Write a boolean as a single byte (0 or 1) - /// - Parameter value: Boolean value - public func write(_ value: Bool) async throws { - try await write(value ? UInt8(0x01) : UInt8(0x00)) - } - - /// Write a Float as its IEEE 754 bit pattern - /// - Parameters: - /// - value: Float value - /// - endian: Byte order (default: big-endian) - public func write(_ value: Float, endian: Endian = .big) async throws { - try await write(value.bitPattern, endian: endian) - } - - /// Write a Double as its IEEE 754 bit pattern - /// - Parameters: - /// - value: Double value - /// - endian: Byte order (default: big-endian) - public func write(_ value: Double, endian: Endian = .big) async throws { - try await write(value.bitPattern, endian: endian) - } - - /// Write a string to the socket, optionally length-prefixed - /// - /// - Parameters: - /// - string: String to write - /// - prefix: Optional length prefix (if provided, string is sent as a framed message) - /// - encoding: Text encoding (default: UTF-8) - /// - Throws: `NetSocketError` if encoding fails or write fails - public func write(_ string: String, prefix: LengthPrefix? = nil, encoding: String.Encoding = .utf8) async throws { - guard let data = string.data(using: encoding) else { - throw NetSocketError.encodeFailed(NSError(domain: "StringEncoding", code: -1)) - } - if let prefix { try await sendFrame(data, prefix: prefix) } - else { try await write(data) } - } - - // MARK: Frames & Codable - - /// Send a length-prefixed frame - /// - /// Writes the payload size as a fixed-width integer, followed by the payload bytes. - /// - /// - Parameters: - /// - payload: Data to send - /// - prefix: Length prefix type (default: u32 big-endian) - /// - Throws: `NetSocketError.framingExceeded` if payload is too large for prefix type - public func sendFrame(_ payload: Data, prefix: LengthPrefix = .u32()) async throws { - // Ensure frame payload does not exceed max frame length. - switch prefix { - case .u8 where payload.count > Int(UInt8.max): - throw NetSocketError.framingExceeded(max: Int(UInt8.max)) - case .u16 where payload.count > Int(UInt16.max): - throw NetSocketError.framingExceeded(max: Int(UInt16.max)) - case .u32 where payload.count > Int(UInt32.max): - throw NetSocketError.framingExceeded(max: Int(UInt32.max)) - default: - break - } - - if payload.count > cfg.maxFrameBytes { throw NetSocketError.framingExceeded(max: cfg.maxFrameBytes) } - var header = Data() - switch prefix { - case .u8: header.append(UInt8(payload.count)) - case .u16(let e): - try header.appendInteger(UInt16(payload.count), endian: e) - case .u32(let e): - try header.appendInteger(UInt32(payload.count), endian: e) - case .u64(let e): - try header.appendInteger(UInt64(payload.count), endian: e) - } - try await write(header + payload) - } - - /// Receive a length-prefixed frame - /// - /// Reads a length prefix, then reads exactly that many bytes. Waits for data to arrive if needed. - /// - /// - Parameter prefix: Length prefix type (default: u32 big-endian) - /// - Returns: The frame payload - /// - Throws: `NetSocketError.framingExceeded` if frame size exceeds maximum - public func receiveFrame(prefix: LengthPrefix = .u32()) async throws -> Data { - let length: Int - switch prefix { - case .u8: - let v: UInt8 = try await read(UInt8.self) - length = Int(v) - case .u16(let e): - let v: UInt16 = try await read(UInt16.self, endian: e) - length = Int(v) - case .u32(let e): - let v: UInt32 = try await read(UInt32.self, endian: e) - length = Int(v) - case .u64(let e): - let v: UInt64 = try await read(UInt64.self, endian: e) - if v > UInt64(cfg.maxFrameBytes) { throw NetSocketError.framingExceeded(max: cfg.maxFrameBytes) } - length = Int(v) - } - if length > cfg.maxFrameBytes { throw NetSocketError.framingExceeded(max: cfg.maxFrameBytes) } - return try await read(length) - } - - /// Send an encodable value as a length-prefixed frame - /// - /// Uses the configured encoder (default: JSON) to serialize the value. - /// - /// - Parameters: - /// - value: Value to encode and send - /// - prefix: Length prefix type (default: u32 big-endian) - /// - Throws: `NetSocketError.encodeFailed` if encoding fails - public func send(_ value: T, prefix: LengthPrefix = .u32()) async throws { - do { - let data = try encodeValue(value) - try await sendFrame(data, prefix: prefix) - } catch { - throw NetSocketError.encodeFailed(error) - } - } - - /// Receive and decode a length-prefixed value - /// - /// Uses the configured decoder (default: JSON) to deserialize the value. - /// - /// - Parameters: - /// - type: Type to decode - /// - prefix: Length prefix type (default: u32 big-endian) - /// - Returns: Decoded value - /// - Throws: `NetSocketError.decodeFailed` if decoding fails - public func receive(_ type: T.Type, prefix: LengthPrefix = .u32()) async throws -> T { - let data = try await receiveFrame(prefix: prefix) - do { - let decoded = try decodeValue(data, T.self) - guard let result = decoded as? T else { - throw NetSocketError.decodeFailed(NSError( - domain: "NetSocketNew", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "Type mismatch in decode"] - )) - } - return result - } catch { - throw NetSocketError.decodeFailed(error) - } - } - - // MARK: Read typed & utilities - - /// Read a fixed-width integer from the socket - /// - /// - Parameters: - /// - type: Integer type to read - /// - endian: Byte order (default: big-endian) - /// - Returns: The integer value - /// - Throws: `NetSocketError` if insufficient data or connection closed - public func read(_ type: T.Type = T.self, endian: Endian = .big) async throws -> T { - let size = MemoryLayout.size - let data = try await read(size) - let value: T = data.withUnsafeBytes { raw in - raw.load(as: T.self) - } - switch endian { - case .big: return T(bigEndian: value) - case .little: return T(littleEndian: value) - } - } - - /// Read a fixed-length string - /// - /// - Parameters: - /// - length: Number of bytes to read - /// - encoding: Text encoding (default: UTF-8) - /// - Returns: Decoded string - /// - Throws: `NetSocketError` if decoding fails or insufficient data - public func read(_ length: Int, encoding: String.Encoding = .utf8) async throws -> String { - let data = try await read(length) - guard let s = String(data: data, encoding: encoding) else { throw NetSocketError.decodeFailed(NSError()) } - return s - } - - /// Read a string until a delimiter is found - /// - /// - Parameters: - /// - delimiter: Delimiter pattern to search for - /// - maxBytes: Maximum bytes to read before throwing (default: no limit) - /// - includeDelimiter: Whether to include delimiter in result (default: false) - /// - Returns: String read from stream (delimiter consumed but not included unless specified) - /// - Throws: `NetSocketError` if decoding fails, max bytes exceeded, or connection closed - public func read(until delimiter: Delimiter, maxBytes: Int? = nil, includeDelimiter: Bool = false) async throws -> String { - let bytes = try await read(past: delimiter.data, maxBytes: maxBytes, includeDelimiter: includeDelimiter) - guard let s = String(data: bytes, encoding: .utf8) else { throw NetSocketError.decodeFailed(NSError()) } - return s - } - - /// Read a pascal string (1-byte length prefix followed by string data) - /// - /// This method reads a single byte for the length, then reads that many bytes and attempts - /// to decode them as a string. It tries multiple encodings for compatibility with legacy - /// protocols like Hotline: UTF-8, Shift-JIS, Windows-1251, and falls back to MacRoman. - /// - /// - Returns: The decoded string, or nil if length is 0 - /// - Throws: `NetSocketError` if reading fails or no encoding succeeds - public func readPascalString() async throws -> String? { - let length = try await read(UInt8.self) - guard length > 0 else { return nil } - - let data = try await read(Int(length)) - - // 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: "NetSocketNew", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "Failed to decode pascal string with any known encoding"] - )) - } - return str - } - - /// Read data until a delimiter is found - /// - /// Searches the buffer for the delimiter pattern and returns all data up to (and optionally including) - /// the delimiter. The delimiter is always consumed from the stream. - /// - /// - Parameters: - /// - delimiter: Binary delimiter pattern to search for - /// - maxBytes: Maximum bytes to read before throwing (default: no limit) - /// - includeDelimiter: Whether to include delimiter in result (default: false) - /// - Returns: Data read from stream - /// - Throws: `NetSocketError.framingExceeded` if max bytes exceeded, or connection errors - public func read(past delimiter: Data, maxBytes: Int? = nil, includeDelimiter: Bool = false) async throws -> Data { - while true { - try Task.checkCancellation() - if let r = search(delimiter: delimiter) { - let consumeLen = r.upperBound - head - let data = try await read(consumeLen) - return includeDelimiter ? data : data.dropLast(delimiter.count) - } - if let maxBytes, availableBytes >= maxBytes { - throw NetSocketError.framingExceeded(max: maxBytes) - } - try await waitForData() - guard !isClosed || availableBytes > 0 else { throw NetSocketError.closed } - } - } - - /// Read exactly N bytes from the socket - /// - /// Waits for data to arrive if buffer doesn't contain enough bytes yet. The internal buffer - /// is automatically compacted after reading to prevent unbounded memory growth. - /// - /// - Parameter count: Number of bytes to read - /// - Returns: Exactly `count` bytes - /// - Throws: `NetSocketError.insufficientData` if connection closes before enough data arrives - public func read(_ count: Int) async throws -> Data { - try await ensureReadable(count) - let start = head - let end = head + count - let slice = buffer[start.. 0 else { return } - try await ensureReadable(count) - head += count - compactIfNeeded() - } - - /// Skip until delimiter is found (discards delimiter too) - public func skip(past delimiter: Data) async throws { - while true { - try Task.checkCancellation() - if let r = search(delimiter: delimiter) { - head = r.upperBound // Skip to end of delimiter - compactIfNeeded() - return - } - try await waitForData() - guard !isClosed else { throw NetSocketError.closed } - } - } - - /// Read exactly N bytes with progress callbacks - /// - /// Like `read(_:)`, but reads in chunks and reports progress after each chunk. - /// Useful for downloading large amounts of data where you want to update UI progress. - /// - /// Example: - /// ```swift - /// let data = try await socket.read(1_000_000) { current, total in - /// print("Progress: \(current)/\(total)") - /// } - /// ``` - /// - /// - Parameters: - /// - count: Number of bytes to read - /// - chunkSize: Size of chunks to read at a time (default: 8192) - /// - progress: Optional callback with (bytesReceived, totalBytes) - /// - Returns: Exactly `count` bytes - /// - Throws: `NetSocketError` if connection closes before enough data arrives - public func read( - _ count: Int, - chunkSize: Int = 8192, - progress: (@Sendable (Int, Int) -> Void)? = nil - ) async throws -> Data { - var data = Data() - data.reserveCapacity(count) - var received = 0 - - while received < count { - try Task.checkCancellation() - let toRead = min(chunkSize, count - received) - let chunk = try await read(toRead) - data.append(chunk) - received += chunk.count - progress?(received, count) - } - - return data - } - - func peek(_ count: Int) async throws -> Data { - try await ensureReadable(count) - let slice = buffer[head..<(head + count)] - return Data(slice) // Don't advance head - } - - // MARK: Internals - - private var availableBytes: Int { buffer.count - head } - - private func waitForData() async throws { - try Task.checkCancellation() - try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in - if isClosed { cont.resume(); return } - dataWaiters.append(cont) - } - } - - private func ensureReadable(_ count: Int) async throws { - try await ensureReady() - while availableBytes < count { - try Task.checkCancellation() - if isClosed { throw NetSocketError.insufficientData(expected: count, got: availableBytes) } - try await waitForData() - } - } - - private func ensureReady() async throws { - if isClosed { throw NetSocketError.closed } - if !ready { try await waitUntilReady() } - } - - private func compactIfNeeded() { - // Avoid unbounded memory as head advances - if head > 64 * 1024 && head > buffer.count / 2 { - buffer.removeSubrange(0.. Range? { - guard !delimiter.isEmpty, availableBytes >= delimiter.count else { return nil } - let hay = buffer[head..(_ value: T, endian: Endian) throws { - var v = value - switch endian { - case .big: v = T(bigEndian: value) - case .little: v = T(littleEndian: value) - } - var copy = v - withUnsafePointer(to: ©) { ptr in - self.append(contentsOf: UnsafeRawBufferPointer(start: ptr, count: MemoryLayout.size)) - } - } -} - -public extension NetSocketNew { - /// Progress information for file uploads/downloads - struct FileProgress: Sendable { - /// Number of bytes sent/received so far - public let sent: Int64 - /// Total file size (may be nil if unknown) - public let total: Int64? - } - - /// Upload a file to the socket without framing (raw byte stream) - /// - /// Reads and writes the file in chunks to limit memory usage. Each chunk waits for network - /// backpressure via `.contentProcessed` before reading the next chunk. - /// - /// - Parameters: - /// - url: File URL to upload - /// - chunkSize: Chunk size for reading/writing (default: 256 KB) - /// - progress: Optional progress callback - /// - Returns: Total bytes sent - /// - Throws: File I/O or network errors - @discardableResult - func writeFile( - from url: URL, - chunkSize: Int = 256 * 1024, - progress: (@Sendable (FileProgress) -> Void)? = nil - ) async throws -> Int64 { - try await ensureReady() - let total = try? self.fileLength(at: url) - - let fh = try FileHandle(forReadingFrom: url) - defer { try? fh.close() } - - var sent: Int64 = 0 - while true { - try Task.checkCancellation() - guard let chunk = try fh.read(upToCount: chunkSize), !chunk.isEmpty else { break } - try await write(chunk) // uses .contentProcessed completion inside - sent += Int64(chunk.count) - progress?(.init(sent: sent, total: total)) - } - return sent - } - - /// Upload a file as a length-prefixed frame without buffering the entire file in memory - /// - /// Sends the file size as a length prefix, then streams the file content in chunks. - /// Memory-efficient for large files. - /// - /// - Parameters: - /// - url: File URL to upload - /// - lengthPrefix: Length prefix type (default: u64 big-endian) - /// - chunkSize: Chunk size for reading/writing (default: 256 KB) - /// - progress: Optional progress callback - /// - Returns: Total bytes sent (not including length header) - /// - Throws: File I/O, framing, or network errors - @discardableResult - func sendFileFramed( - _ url: URL, - lengthPrefix: LengthPrefix = .u64(.big), - chunkSize: Int = 256 * 1024, - progress: (@Sendable (FileProgress) -> Void)? = nil - ) async throws -> Int64 { - let total = try fileLength(at: url) - try ensure(total, fitsIn: lengthPrefix) - - // 1) Send the length header - var header = Data() - switch lengthPrefix { - case .u8: - header.append(UInt8(truncatingIfNeeded: total)) - case .u16(let e): - try header.appendInteger(UInt16(truncatingIfNeeded: total), endian: e) - case .u32(let e): - try header.appendInteger(UInt32(truncatingIfNeeded: total), endian: e) - case .u64(let e): - try header.appendInteger(UInt64(total), endian: e) - } - try await write(header) - - // 2) Stream the file bytes (raw) right after the header - let sent = try await writeFile(from: url, chunkSize: chunkSize) { prog in - progress?(prog) - } - return sent - } - - /// Download a length-prefixed file and write it to disk in chunks (bounded memory) - /// - /// Reads the file size from a length prefix, then streams the content directly to disk - /// in chunks to avoid loading the entire file into memory. - /// - /// - Parameters: - /// - url: Destination file URL - /// - lengthPrefix: Length prefix type (default: u64 big-endian) - /// - chunkSize: Chunk size for reading/writing (default: 256 KB) - /// - overwrite: Whether to overwrite existing file (default: true) - /// - progress: Optional progress callback - /// - Returns: Total bytes written - /// - Throws: File I/O, framing, or network errors - @discardableResult - func receiveFile( - to url: URL, - lengthPrefix: LengthPrefix = .u64(.big), - chunkSize: Int = 256 * 1024, - overwrite: Bool = true, - progress: (@Sendable (FileProgress) -> Void)? = nil - ) async throws -> Int64 { - // 1) Read length header - let total64: Int64 = try await { - switch lengthPrefix { - case .u8: return Int64(try await read(UInt8.self)) - case .u16(let e): return Int64(try await read(UInt16.self, endian: e)) - case .u32(let e): return Int64(try await read(UInt32.self, endian: e)) - case .u64(let e): - let v: UInt64 = try await read(UInt64.self, endian: e) - guard v <= UInt64(Int64.max) else { - throw NetSocketError.framingExceeded(max: Int(Int64.max)) - } - return Int64(v) - } - }() - - // 2) Prepare destination file - if overwrite { try? FileManager.default.removeItem(at: url) } - FileManager.default.createFile(atPath: url.path, contents: nil, attributes: nil) - let fh = try FileHandle(forWritingTo: url) - defer { try? fh.close() } - - // 3) Stream chunks from the socket into the file - var remaining = total64 - var written: Int64 = 0 - - while remaining > 0 { - try Task.checkCancellation() - let n = Int(min(Int64(chunkSize), remaining)) - let chunk = try await read(n) // reuses your internal buffer, bounded by n - fh.write(chunk) - remaining -= Int64(n) - written += Int64(n) - progress?(.init(sent: written, total: total64)) - } - return written - } - - /// Download a file of known length and write it to disk in chunks - /// - /// Unlike `receiveFile()`, this method does **not** read a length prefix. The caller must - /// provide the expected file size (e.g., from protocol metadata). The file is streamed - /// directly to disk to avoid loading it entirely into memory. - /// - /// Supports atomic writes: when enabled, data is written to a temporary `.part` file and - /// renamed on success. If an error occurs, the temporary file is automatically cleaned up. - /// - /// - Parameters: - /// - url: Destination file URL - /// - length: Exact number of bytes to read (must match what's on the wire) - /// - chunkSize: Chunk size for reading/writing (default: 256 KB) - /// - overwrite: Whether to overwrite existing file (default: true) - /// - atomic: Write to temporary file and rename on success (default: true) - /// - progress: Optional progress callback - /// - Returns: Total bytes written (equals `length` on success) - /// - Throws: File I/O or network errors. On atomic writes, partial files are cleaned up. - /// - /// Example: - /// ```swift - /// // Hotline protocol: file size comes from transaction header - /// let transaction = try await socket.receive(HotlineTransaction.self) - /// try await socket.receiveFileKnownLength( - /// to: destinationURL, - /// length: transaction.fileSize - /// ) - /// ``` - @discardableResult - func receiveFileKnownLength( - to url: URL, - length: Int64, - chunkSize: Int = 256 * 1024, - overwrite: Bool = true, - atomic: Bool = true, - progress: (@Sendable (FileProgress) -> Void)? = nil - ) async throws -> Int64 { - precondition(length >= 0, "length must be >= 0") - - // Validate length doesn't exceed configured maximum - guard length <= cfg.maxFrameBytes else { - throw NetSocketError.framingExceeded(max: cfg.maxFrameBytes) - } - - // Fast path: nothing to do - if length == 0 { - if overwrite { try? FileManager.default.removeItem(at: url) } - FileManager.default.createFile(atPath: url.path, contents: Data(), attributes: nil) - return 0 - } - - // Prepare destination (optionally atomic) - let fm = FileManager.default - let dir = url.deletingLastPathComponent() - let tmp = atomic - ? dir.appendingPathComponent(".\(url.lastPathComponent).part-\(UUID().uuidString)") - : url - - if overwrite { try? fm.removeItem(at: tmp) } - if overwrite, !atomic { try? fm.removeItem(at: url) } - - // Create and open the file for writing - fm.createFile(atPath: tmp.path, contents: nil, attributes: nil) - let fh = try FileHandle(forWritingTo: tmp) - defer { try? fh.close() } - - var remaining = length - var written: Int64 = 0 - - do { - while remaining > 0 { - try Task.checkCancellation() - let n = Int(min(Int64(chunkSize), remaining)) - let chunk = try await read(n) - fh.write(chunk) - remaining -= Int64(n) - written += Int64(n) - progress?(.init(sent: written, total: length)) - } - } catch { - // Cleanup partial file on failure if we were writing atomically - if atomic { try? fm.removeItem(at: tmp) } - throw error - } - - // Atomically move into place if requested - if atomic { - if overwrite { try? fm.removeItem(at: url) } - try fm.moveItem(at: tmp, to: url) - } - - return written - } -} - -// MARK: - Small helpers (private) -fileprivate extension NetSocketNew { - func fileLength(at url: URL) throws -> Int64 { - let values = try url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) - guard values.isRegularFile == true else { - throw NetSocketError.failed(underlying: NSError( - domain: "NetSocket", code: 1001, - userInfo: [NSLocalizedDescriptionKey: "Not a regular file: \(url.path)"] - )) - } - if let s = values.fileSize { return Int64(s) } - let attrs = try FileManager.default.attributesOfItem(atPath: url.path) - if let n = attrs[.size] as? NSNumber { return n.int64Value } - throw NetSocketError.failed(underlying: NSError( - domain: "NetSocket", code: 1002, - userInfo: [NSLocalizedDescriptionKey: "Unable to determine file size for \(url.lastPathComponent)"] - )) - } - - func ensure(_ length: Int64, fitsIn prefix: LengthPrefix) throws { - let max: Int64 = { - switch prefix { - case .u8: return Int64(UInt8.max) - case .u16: return Int64(UInt16.max) - case .u32: return Int64(UInt32.max) - case .u64: return Int64.max - } - }() - if length > max { - throw NetSocketError.framingExceeded(max: Int(max)) - } - } -} - -// MARK: - Stream-based Encoding/Decoding - -/// Protocol for types that can encode themselves to binary data -/// -/// Types conforming to `NetSocketEncodable` produce binary data that can be sent over -/// a socket. Unlike writing field-by-field to the socket, encodable types build complete -/// binary messages that are sent in a single write operation for efficiency. -/// -/// Example: -/// ```swift -/// struct MyMessage: NetSocketEncodable { -/// let id: UInt32 -/// let name: String -/// -/// func encode(endian: Endian) throws -> Data { -/// var data = Data() -/// // Encode fields to data... -/// return data -/// } -/// } -/// -/// try await socket.send(message) -/// ``` -public protocol NetSocketEncodable: Sendable { - /// Encode this value to binary data - /// - /// Implementations should build a complete binary message and return it as Data. - /// The data will be sent to the socket in a single write operation. - /// - /// - Parameter endian: Byte order for multi-byte values - /// - Returns: Encoded binary data ready to send - /// - Throws: Encoding errors - func encode(endian: Endian) throws -> Data -} - -/// Protocol for types that can decode themselves directly from a socket stream -/// -/// Types conforming to `NetSocketDecodable` read field-by-field directly from the socket -/// using async reads. This enables true streaming without buffering entire messages. -/// -/// **Important**: If decoding throws after consuming some bytes (e.g., validation fails), -/// the socket will be left with those bytes consumed. In practice, this usually means the -/// connection should be closed. For most protocols this is acceptable since decode errors -/// indicate corrupt data or protocol violations. -/// -/// Example: -/// ```swift -/// struct MyMessage: NetSocketDecodable { -/// let id: UInt32 -/// let name: String -/// -/// init(from socket: NetSocketNew, endian: Endian) async throws { -/// self.id = try await socket.read(UInt32.self, endian: endian) -/// let nameLen = try await socket.read(UInt16.self, endian: endian) -/// let nameData = try await socket.readExactly(Int(nameLen)) -/// guard let name = String(data: nameData, encoding: .utf8) else { -/// throw NetSocketError.decodeFailed(NSError()) -/// } -/// self.name = name -/// } -/// } -/// -/// let message = try await socket.receive(MyMessage.self) -/// ``` -public protocol NetSocketDecodable: Sendable { - /// Decode a value by reading directly from the socket stream - /// - /// This initializer should read all necessary fields from the socket using - /// methods like `read(_:endian:)`, `readExactly(_:)`, `readString(length:)`, etc. - /// - /// The socket handles waiting for data to arrive, so you can read field by field - /// without worrying about buffering. - /// - /// - Parameters: - /// - socket: Socket to read from - /// - endian: Byte order for multi-byte values - /// - Throws: Network errors, insufficient data, or custom decoding errors - init(from socket: NetSocketNew, endian: Endian) async throws -} - -public extension NetSocketNew { - /// Send an encodable value to the socket - /// - /// The type encodes itself to binary data, which is then sent in a single write operation. - /// - /// Example: - /// ```swift - /// struct MyMessage: NetSocketEncodable { - /// let id: UInt32 - /// let name: String - /// - /// func encode(endian: Endian) throws -> Data { - /// var data = Data() - /// // Build binary message... - /// return data - /// } - /// } - /// - /// try await socket.send(message) - /// ``` - /// - /// - Parameters: - /// - value: Value conforming to NetSocketEncodable - /// - endian: Byte order (default: big-endian) - /// - Throws: Encoding or network errors - func send(_ value: T, endian: Endian = .big) async throws { - let data = try value.encode(endian: endian) - try await write(data) - } - - /// Receive and decode a value directly from the socket stream (no length prefix) - /// - /// The type reads field-by-field from the socket as needed, enabling true streaming - /// without buffering entire messages. Useful for protocols where message size isn't - /// known upfront or for progressive decoding. - /// - /// Example: - /// ```swift - /// struct ServerEntry: NetSocketDecodable { - /// let id: UInt32 - /// let name: String - /// - /// init(from socket: NetSocketNew, endian: Endian) async throws { - /// self.id = try await socket.read(UInt32.self, endian: endian) - /// // Read variable-length string... - /// } - /// } - /// - /// let entry = try await socket.receive(ServerEntry.self) - /// ``` - /// - /// - Parameters: - /// - type: Type conforming to NetSocketDecodable - /// - endian: Byte order (default: big-endian) - /// - Returns: Decoded value - /// - Throws: Decoding or network errors - func receive(_ type: T.Type, endian: Endian = .big) async throws -> T { - return try await T(from: self, endian: endian) - } -} diff --git a/Hotline/Library/QuickLookPreviewView.swift b/Hotline/Library/QuickLookPreviewView.swift new file mode 100644 index 0000000..6ba154e --- /dev/null +++ b/Hotline/Library/QuickLookPreviewView.swift @@ -0,0 +1,22 @@ +import SwiftUI +import Quartz + +/// Embeddable QuickLook preview view for macOS +/// +/// This view uses QLPreviewView to display file previews inline, without showing a modal. +/// Supports all file types that QuickLook supports (images, PDFs, videos, documents, etc.) +struct QuickLookPreviewView: NSViewRepresentable { + let fileURL: URL + + func makeNSView(context: Context) -> QLPreviewView { + let preview = QLPreviewView(frame: .zero, style: .normal)! + preview.autostarts = true + preview.shouldCloseWithWindow = true + preview.previewItem = fileURL as QLPreviewItem + return preview + } + + func updateNSView(_ nsView: QLPreviewView, context: Context) { + nsView.previewItem = fileURL as QLPreviewItem + } +} diff --git a/Hotline/Library/URLAdditions.swift b/Hotline/Library/URLAdditions.swift index 1f25541..0ba2100 100644 --- a/Hotline/Library/URLAdditions.swift +++ b/Hotline/Library/URLAdditions.swift @@ -1,4 +1,5 @@ import Foundation +import UniformTypeIdentifiers extension URL { func generateUniqueFilePath(filename base: String) -> String { @@ -24,3 +25,31 @@ extension URL { return filePath } } + +extension UTType { + var canBePreviewedByQuickLook: Bool { + // QuickLook supports most common document types + let supportedSupertypes: [UTType] = [ + .image, + .movie, + .audio, + .pdf, + .font, + .usdz, + .text, + .sourceCode, + .spreadsheet, + .presentation, + +// Microsoft Office + .init(filenameExtension: "doc")!, + .init(filenameExtension: "docx")!, + .init(filenameExtension: "xls")!, + .init(filenameExtension: "xlsx")!, + .init(filenameExtension: "ppt")!, + .init(filenameExtension: "pptx")!, + ] + + return supportedSupertypes.contains { self.conforms(to: $0) } + } +} diff --git a/Hotline/MacApp.swift b/Hotline/MacApp.swift index 44ca308..b1cc1af 100644 --- a/Hotline/MacApp.swift +++ b/Hotline/MacApp.swift @@ -2,6 +2,7 @@ import SwiftUI import SwiftData import CloudKit import UniformTypeIdentifiers +import Darwin @Observable final class AppLaunchState { @@ -65,7 +66,7 @@ struct Application: App { @State private var selection: TrackerSelection? = nil @Bindable private var update = AppUpdate.shared - @FocusedValue(\.activeHotlineModel) private var activeHotline: Hotline? + @FocusedValue(\.activeHotlineModel) private var activeHotline: HotlineState? @FocusedValue(\.activeServerState) private var activeServerState: ServerState? private var modelContainer: ModelContainer = { @@ -173,9 +174,7 @@ struct Application: App { .defaultSize(width: 690, height: 760) .defaultPosition(.center) .onChange(of: activeServerState) { - withAnimation { - AppState.shared.activeServerState = activeServerState - } + AppState.shared.activeServerState = activeServerState } .onChange(of: activeHotline) { AppState.shared.activeHotline = activeHotline @@ -187,7 +186,7 @@ struct Application: App { } .keyboardShortcut(.init("K"), modifiers: .command) } - CommandGroup(after: .singleWindowList) { + CommandGroup(before: .singleWindowList) { Button("Toolbar") { toggleBannerWindow() } @@ -222,7 +221,11 @@ struct Application: App { .disabled(selection == nil || selection?.server == nil) .keyboardShortcut(.downArrow, modifiers: .command) Button("Disconnect") { - activeHotline?.disconnect() + if let hotline = activeHotline { + Task { + await hotline.disconnect() + } + } } .disabled(activeHotline?.status == .disconnected) Divider() @@ -264,6 +267,15 @@ struct Application: App { Settings { SettingsView() } + + // MARK: Transfers Window + Window("Transfers", id: "transfers") { + TransfersView() + .frame(minWidth: 500, minHeight: 200) + } + .defaultSize(width: 600, height: 400) + .defaultPosition(.center) + .keyboardShortcut(.init("T"), modifiers: [.shift, .command]) // MARK: Image Preview Window WindowGroup(id: "preview-image", for: PreviewFileInfo.self) { $info in @@ -286,6 +298,18 @@ struct Application: App { .defaultSize(width: 450, height: 550) .defaultPosition(.center) .restorationBehavior(.disabled) + + // MARK: QuickLook Preview Window + WindowGroup(id: "preview-quicklook", for: PreviewFileInfo.self) { $info in + FilePreviewQuickLookView(info: $info) + } + .windowManagerRole(.associated) + .windowResizability(.automatic) + .windowStyle(.titleBar) + .windowToolbarStyle(.unifiedCompact(showsTitle: true)) + .defaultSize(width: 450, height: 550) + .defaultPosition(.center) + .restorationBehavior(.disabled) } func connect(to item: TrackerSelection) { @@ -320,3 +344,4 @@ struct Application: App { } } } + diff --git a/Hotline/Models/FileDetails.swift b/Hotline/Models/FileDetails.swift index 20b1ee2..6164151 100644 --- a/Hotline/Models/FileDetails.swift +++ b/Hotline/Models/FileDetails.swift @@ -1,7 +1,7 @@ import UniformTypeIdentifiers -struct FileDetails:Identifiable { - let id = UUID() +public struct FileDetails:Identifiable { + public let id = UUID() var name: String var path: [String] var size: Int diff --git a/Hotline/Models/FileInfo.swift b/Hotline/Models/FileInfo.swift index 62ec831..e3081c4 100644 --- a/Hotline/Models/FileInfo.swift +++ b/Hotline/Models/FileInfo.swift @@ -49,11 +49,25 @@ import UniformTypeIdentifiers var children: [FileInfo]? = nil var isPreviewable: Bool { - let fileExtension = (self.name as NSString).pathExtension + let fileExtension = (self.name as NSString).pathExtension.lowercased() 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 } + else if fileType.isSubtype(of: .pdf) || fileExtension == "pdf" { + return true + } + else if fileType.isSubtype(of: .audio) { + return true + } + else if fileType.isSubtype(of: .video) { + return true + } else if fileType.isSubtype(of: .text) { return true } diff --git a/Hotline/Models/FilePreview.swift b/Hotline/Models/FilePreview.swift index e5f49a3..a142823 100644 --- a/Hotline/Models/FilePreview.swift +++ b/Hotline/Models/FilePreview.swift @@ -1,115 +1,115 @@ import SwiftUI import UniformTypeIdentifiers - -enum FilePreviewState: Equatable { - case unloaded - case loading - case loaded - case failed -} - -enum FilePreviewType: Equatable { - case unknown - case image - case text -} - -@Observable -final class FilePreview: HotlineFilePreviewClientDelegate { - @ObservationIgnored let info: PreviewFileInfo - @ObservationIgnored var client: HotlineFilePreviewClient? = nil - - var state: FilePreviewState = .unloaded - var progress: Double = 0.0 - - var data: Data? = nil - - #if os(iOS) - var image: UIImage? = nil - #elseif os(macOS) - var image: NSImage? = nil - #endif - - var text: String? = nil - var styledText: NSAttributedString? = nil - - var previewType: FilePreviewType { - let fileExtension = (info.name as NSString).pathExtension - if let fileType = UTType(filenameExtension: fileExtension) { - if fileType.isSubtype(of: .image) { - return .image - } - else if fileType.isSubtype(of: .text) { - return .text - } - } - return .unknown - } - - init(info: PreviewFileInfo) { - self.info = info - - self.client = HotlineFilePreviewClient(address: info.address, port: UInt16(info.port), reference: info.id, size: UInt32(info.size)) - self.client?.delegate = self - } - - func download() { - self.client?.start() - } - - func cancel() { - self.client?.cancel() - } - - func hotlineTransferStatusChanged(client: any HotlineTransferClient, reference: UInt32, status: HotlineTransferStatus, timeRemaining: TimeInterval) { - print("FilePreview: Download status changed:", status) - - switch status { - case .unconnected: - state = .unloaded - progress = 0.0 - case .connecting: - state = .loading - progress = 0.0 - case .connected: - state = .loading - progress = 0.0 - case .progress(let p): - state = .loading - progress = p - case .failed(_): - state = .failed - progress = 0.0 - case .completing: - state = .loading - progress = 1.0 - case .completed: - state = .loaded - progress = 1.0 - } - } - - func hotlineFilePreviewComplete(client: HotlineFilePreviewClient, reference: UInt32, data: Data) { - self.state = .loaded - self.data = data - - switch self.previewType { - case .image: - #if os(iOS) - self.image = UIImage(data: data) - #elseif os(macOS) - self.image = NSImage(data: data) - #endif - 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) - } - case .unknown: - return - } - } -} +// +//enum FilePreviewState: Equatable { +// case unloaded +// case loading +// case loaded +// case failed +//} +// +//enum FilePreviewType: Equatable { +// case unknown +// case image +// case text +//} +// +//@Observable +//final class FilePreview: HotlineFilePreviewClientDelegate { +// @ObservationIgnored let info: PreviewFileInfo +// @ObservationIgnored var client: HotlineFilePreviewClient? = nil +// +// var state: FilePreviewState = .unloaded +// var progress: Double = 0.0 +// +// var data: Data? = nil +// +// #if os(iOS) +// var image: UIImage? = nil +// #elseif os(macOS) +// var image: NSImage? = nil +// #endif +// +// var text: String? = nil +// var styledText: NSAttributedString? = nil +// +// var previewType: FilePreviewType { +// let fileExtension = (info.name as NSString).pathExtension +// if let fileType = UTType(filenameExtension: fileExtension) { +// if fileType.isSubtype(of: .image) { +// return .image +// } +// else if fileType.isSubtype(of: .text) { +// return .text +// } +// } +// return .unknown +// } +// +// init(info: PreviewFileInfo) { +// self.info = info +// +// self.client = HotlineFilePreviewClient(address: info.address, port: UInt16(info.port), reference: info.id, size: UInt32(info.size)) +// self.client?.delegate = self +// } +// +// func download() { +// self.client?.start() +// } +// +// func cancel() { +// self.client?.cancel() +// } +// +// func hotlineTransferStatusChanged(client: any HotlineTransferClient, reference: UInt32, status: HotlineTransferStatus, timeRemaining: TimeInterval) { +// print("FilePreview: Download status changed:", status) +// +// switch status { +// case .unconnected: +// state = .unloaded +// progress = 0.0 +// case .connecting: +// state = .loading +// progress = 0.0 +// case .connected: +// state = .loading +// progress = 0.0 +// case .progress(let p): +// state = .loading +// progress = p +// case .failed(_): +// state = .failed +// progress = 0.0 +// case .completing: +// state = .loading +// progress = 1.0 +// case .completed: +// state = .loaded +// progress = 1.0 +// } +// } +// +// func hotlineFilePreviewComplete(client: HotlineFilePreviewClient, reference: UInt32, data: Data) { +// self.state = .loaded +// self.data = data +// +// switch self.previewType { +// case .image: +// #if os(iOS) +// self.image = UIImage(data: data) +// #elseif os(macOS) +// self.image = NSImage(data: data) +// #endif +// 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) +// } +// case .unknown: +// return +// } +// } +//} diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift index 596df68..0246fbb 100644 --- a/Hotline/Models/Hotline.swift +++ b/Hotline/Models/Hotline.swift @@ -822,7 +822,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega fileClient.delegate = self self.downloads.append(fileClient) - let transfer = TransferInfo(id: referenceNumber, title: fileName, size: UInt(transferSize)) + let transfer = TransferInfo(reference: referenceNumber, title: fileName, size: UInt(transferSize)) transfer.downloadCallback = callback self.transfers.append(transfer) @@ -856,7 +856,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega fileClient.delegate = self self.downloads.append(fileClient) - let transfer = TransferInfo(id: referenceNumber, title: fileName, size: UInt(transferSize)) + let transfer = TransferInfo(reference: referenceNumber, title: fileName, size: UInt(transferSize)) transfer.downloadCallback = callback transfer.progressCallback = progressCallback self.transfers.append(transfer) @@ -894,7 +894,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega folderClient.delegate = self self.downloads.append(folderClient) - let transfer = TransferInfo(id: referenceNumber, title: folderName, size: UInt(transferSize)) + let transfer = TransferInfo(reference: referenceNumber, title: folderName, size: UInt(transferSize)) transfer.isFolder = true transfer.downloadCallback = callback self.transfers.append(transfer) @@ -972,7 +972,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileDownloadClientDelega fileClient.delegate = self self.downloads.append(fileClient) - let transfer = TransferInfo(id: referenceNumber, title: fileName, size: fileSize) + let transfer = TransferInfo(reference: referenceNumber, title: fileName, size: fileSize) transfer.uploadCallback = callback self.transfers.append(transfer) diff --git a/Hotline/Models/HotlineState.swift b/Hotline/Models/HotlineState.swift new file mode 100644 index 0000000..d5ab438 --- /dev/null +++ b/Hotline/Models/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) + } +} diff --git a/Hotline/Models/PreviewFileInfo.swift b/Hotline/Models/PreviewFileInfo.swift index 7dd692f..dcf5314 100644 --- a/Hotline/Models/PreviewFileInfo.swift +++ b/Hotline/Models/PreviewFileInfo.swift @@ -1,11 +1,5 @@ import UniformTypeIdentifiers -enum PreviewFileType: Equatable { - case unknown - case image - case text -} - struct PreviewFileInfo: Identifiable, Codable { var id: UInt32 var address: String diff --git a/Hotline/Models/TransferInfo.swift b/Hotline/Models/TransferInfo.swift index ba25793..cb2b0fd 100644 --- a/Hotline/Models/TransferInfo.swift +++ b/Hotline/Models/TransferInfo.swift @@ -2,28 +2,36 @@ import SwiftUI @Observable class TransferInfo: Identifiable, Equatable, Hashable { - var id: UInt32 - + var id: UUID = UUID() + + var referenceNumber: UInt32 var title: String var size: UInt var progress: Double = 0.0 - var timeRemaining: TimeInterval = 0.0 + var speed: Double? = nil + var timeRemaining: TimeInterval? = nil var completed: Bool = false var failed: Bool = false var isFolder: Bool = false + // Server association - tracks which HotlineState this transfer belongs to + var serverID: UUID + var serverName: String? + // For file based transfers (i.e. not previews) var fileURL: URL? = nil - - var progressCallback: ((TransferInfo, Double) -> Void)? = nil - var downloadCallback: ((TransferInfo, URL) -> Void)? = nil + + var progressCallback: ((TransferInfo) -> Void)? = nil + var downloadCallback: ((TransferInfo) -> Void)? = nil var uploadCallback: ((TransferInfo) -> Void)? = nil var previewCallback: ((TransferInfo, Data) -> Void)? = nil - - init(id: UInt32, title: String, size: UInt) { - self.id = id + + init(reference: UInt32, title: String, size: UInt, serverID: UUID, serverName: String? = nil) { + self.referenceNumber = reference self.title = title self.size = size + self.serverID = serverID + self.serverName = serverName } static func == (lhs: TransferInfo, rhs: TransferInfo) -> Bool { diff --git a/Hotline/State/AppState.swift b/Hotline/State/AppState.swift index 3917ad0..558af2a 100644 --- a/Hotline/State/AppState.swift +++ b/Hotline/State/AppState.swift @@ -12,8 +12,64 @@ final class AppState { } - var activeHotline: Hotline? = nil + var activeHotline: HotlineState? = nil var activeServerState: ServerState? = nil var cloudKitReady: Bool = false + + // MARK: - Transfers + + /// All active transfers across all servers + /// Transfers persist even if you disconnect from the server + var transfers: [TransferInfo] = [] + + /// Track download tasks by reference number for cancellation + @ObservationIgnored private var transferTasks: [UUID: Task] = [:] + + /// Add a transfer to the transfer list + @MainActor + func addTransfer(_ transfer: TransferInfo) { + self.transfers.append(transfer) + } + + /// Cancel a transfer by transfer ID + @MainActor + func cancelTransfer(id: UUID) { + guard let transferIndex = self.transfers.firstIndex(where: { $0.id == id }) else { + return + } + + // Cancel the task if it exists + if let task = self.transferTasks[id] { + task.cancel() + self.transferTasks.removeValue(forKey: id) + } + + // Remove from transfers list + self.transfers.remove(at: transferIndex) + } + + /// Cancel all active transfers + @MainActor + func cancelAllTransfers() { + for (_, task) in self.transferTasks { + task.cancel() + } + self.transferTasks.removeAll() + + // Clear transfers + self.transfers.removeAll() + } + + /// Register a transfer task + @MainActor + func registerTransferTask(_ task: Task, transferID: UUID) { + self.transferTasks[transferID] = task + } + + /// Unregister a download task (called on completion/failure) + @MainActor + func unregisterTransferTask(for transferID: UUID) { + self.transferTasks.removeValue(forKey: transferID) + } } diff --git a/Hotline/State/FilePreviewState.swift b/Hotline/State/FilePreviewState.swift new file mode 100644 index 0000000..97bd907 --- /dev/null +++ b/Hotline/State/FilePreviewState.swift @@ -0,0 +1,207 @@ +// +// FilePreviewState.swift +// Hotline +// +// Modern file preview state using HotlineFilePreviewClientNew +// + +import SwiftUI +import UniformTypeIdentifiers + +// MARK: - Preview Type + +enum FilePreviewType: Equatable { + case unknown + case image + case text +} + +/// State for a file preview download +@MainActor +@Observable +final class FilePreviewState { + // MARK: - Properties + + let info: PreviewFileInfo + + private var previewClient: HotlineFilePreviewClientNew? + private var previewTask: Task? + + var state: LoadState = .unloaded + var progress: Double = 0.0 + + var fileURL: URL? = nil + + #if os(iOS) + var image: UIImage? = nil + #elseif os(macOS) + var image: NSImage? = nil + #endif + + var text: String? = nil + var styledText: NSAttributedString? = nil + + // MARK: - Computed Properties + + var previewType: FilePreviewType { + 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 + + func download() { + // Cancel any existing download + previewTask?.cancel() + previewClient?.cleanup() + + let task = Task { @MainActor in + do { + let client = HotlineFilePreviewClientNew( + fileName: info.name, + address: info.address, + port: UInt16(info.port), + reference: info.id, + size: UInt32(info.size) + ) + self.previewClient = client + + self.state = .loading + self.progress = 0.0 + + let url = try await client.preview { [weak self] progress in + guard let self else { return } + + Task { @MainActor in + switch progress { + case .preparing: + self.state = .loading + self.progress = 0.0 + + case .connecting: + self.state = .loading + self.progress = 0.0 + + case .connected: + self.state = .loading + self.progress = 0.0 + + case .transfer(name: _, size: _, total: _, progress: let p, speed: _, estimate: _): + self.state = .loading + self.progress = p + + case .completed(url: let url): + self.state = .loaded + self.progress = 1.0 + self.fileURL = url +// self.loadPreview(from: url) + + case .error(let error): + self.state = .failed + self.progress = 0.0 + print("FilePreviewState: Download failed: \(error)") + + case .unconnected: + break + } + } + } + + // Final load if not already loaded + if self.state != .loaded { + self.state = .loaded + self.progress = 1.0 + self.fileURL = url +// self.loadPreview(from: url) + } + + } catch is CancellationError { + // Cancelled, do nothing + return + } catch { + self.state = .failed + self.progress = 0.0 + print("FilePreviewState: Download error: \(error)") + } + } + + self.previewTask = task + } + + func cancel() { + previewTask?.cancel() + previewTask = nil + 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 + } + } +} + +// MARK: - Load State + +extension FilePreviewState { + enum LoadState: Equatable { + case unloaded + case loading + case loaded + case failed + } +} diff --git a/Hotline/State/ServerState.swift b/Hotline/State/ServerState.swift index e2fa40f..5913bf5 100644 --- a/Hotline/State/ServerState.swift +++ b/Hotline/State/ServerState.swift @@ -5,8 +5,8 @@ class ServerState: Equatable { var id: UUID = UUID() var selection: ServerNavigationType var serverName: String? = nil - var serverBanner: NSImage? = nil - var bannerColors: ColorArt? = nil +// var serverBanner: NSImage? = nil +// var bannerBackgroundColor: Color? = nil init(selection: ServerNavigationType) { self.selection = selection diff --git a/Hotline/Utility/ColorArt.swift b/Hotline/Utility/ColorArt.swift index 1f99f0f..93889a3 100644 --- a/Hotline/Utility/ColorArt.swift +++ b/Hotline/Utility/ColorArt.swift @@ -1,7 +1,11 @@ +// Swift translation and modernization +// by Dustin Mierau +// +// of: +// // ColorArt.swift // SLColorArt by Panic Inc. -// Swift translation by Dustin Mierau // // Copyright (C) 2012 Panic Inc. Code by Wade Cosgrove. All rights reserved. // @@ -32,12 +36,14 @@ import SwiftUI fileprivate let kColorThresholdMinimumPercentage: CGFloat = 0.001 +// ColorArt.analyze(image: img) -> ColorArt? + struct ColorArt: Equatable { let backgroundColor: NSColor let primaryColor: NSColor let secondaryColor: NSColor let detailColor: NSColor - let scaledImage: NSImage +// let scaledImage: NSImage static func == (lhs: ColorArt, rhs: ColorArt) -> Bool { return lhs.backgroundColor == rhs.backgroundColor && @@ -45,57 +51,80 @@ struct ColorArt: Equatable { lhs.secondaryColor == rhs.secondaryColor && lhs.detailColor == rhs.detailColor } + + static func analyze(image: NSImage) -> ColorArt? { + print("ColorArt.analyze: Starting, image size: \(image.size)") + // Scale image to a reasonable size for analysis + // This is important because: + // 1. Makes analysis faster (fewer pixels) + // 2. Normalizes weird image dimensions + // 3. Ensures CGImage conversion succeeds + print("ColorArt.analyze: Calling scaleImage...") + let finalImage = Self.scaleImage(image, size: NSSize(width: 100, height: 100)) + print("ColorArt.analyze: scaleImage returned, scaled size: \(finalImage.size)") - init?(image: NSImage, scaledSize: NSSize = .zero) { - let finalImage = Self.scaleImage(image, size: scaledSize) - self.scaledImage = finalImage - guard let colors = Self.analyzeImage(finalImage) else { + print("ColorArt.analyze: failed with no colors") return nil } - self.backgroundColor = colors.background - self.primaryColor = colors.primary - self.secondaryColor = colors.secondary - self.detailColor = colors.detail + print("ColorArt.analyze: returning colors", colors) + + return ColorArt(backgroundColor: colors.background, + primaryColor: colors.primary, + secondaryColor: colors.secondary, + detailColor: colors.detail) } // MARK: - Image Scaling private static func scaleImage(_ image: NSImage, size scaledSize: NSSize) -> NSImage { - let imageSize = image.size - let squareImage = NSImage(size: NSSize(width: imageSize.width, height: imageSize.width)) - var drawRect: NSRect - - // Make the image square - if imageSize.height > imageSize.width { - drawRect = NSRect(x: 0, y: imageSize.height - imageSize.width, width: imageSize.width, height: imageSize.width) - } else { - drawRect = NSRect(x: 0, y: 0, width: imageSize.height, height: imageSize.height) + print("ColorArt.scaleImage: Entered, input: \(image.size), target: \(scaledSize)") + // Get CGImage directly without using lockFocus + print("ColorArt.scaleImage: Getting CGImage...") + guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + print("ColorArt.scaleImage: Failed to get CGImage, returning original") + return image } - + print("ColorArt.scaleImage: Got CGImage") + + let imageSize = image.size + let squareSize = min(imageSize.width, imageSize.height) + // Use native square size if passed zero size - let finalScaledSize = scaledSize == .zero ? drawRect.size : scaledSize - let scaledImage = NSImage(size: finalScaledSize) - - squareImage.lockFocus() - image.draw(in: NSRect(x: 0, y: 0, width: imageSize.width, height: imageSize.width), from: drawRect, operation: .sourceOver, fraction: 1.0) - squareImage.unlockFocus() - - // Scale the image to the desired size - scaledImage.lockFocus() - squareImage.draw(in: NSRect(x: 0, y: 0, width: finalScaledSize.width, height: finalScaledSize.height), from: .zero, operation: .sourceOver, fraction: 1.0) - scaledImage.unlockFocus() - - // Convert back to readable bitmap data - guard let cgImage = scaledImage.cgImage(forProposedRect: nil, context: nil, hints: nil) else { - return scaledImage + let finalScaledSize = scaledSize == .zero ? NSSize(width: squareSize, height: squareSize) : scaledSize + + // Create bitmap context for drawing + let width = Int(finalScaledSize.width) + let height = Int(finalScaledSize.height) + let colorSpace = CGColorSpaceCreateDeviceRGB() + let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue) + + guard let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width * 4, + space: colorSpace, + bitmapInfo: bitmapInfo.rawValue + ) else { + return image } - - let bitmapRep = NSBitmapImageRep(cgImage: cgImage) - let finalImage = NSImage(size: scaledImage.size) + + // Draw the image scaled + context.interpolationQuality = .high + context.draw(cgImage, in: CGRect(x: 0, y: 0, width: finalScaledSize.width, height: finalScaledSize.height)) + + // Create NSImage from context + guard let scaledCGImage = context.makeImage() else { + return image + } + + let bitmapRep = NSBitmapImageRep(cgImage: scaledCGImage) + let finalImage = NSImage(size: finalScaledSize) finalImage.addRepresentation(bitmapRep) - + return finalImage } @@ -120,33 +149,45 @@ struct ColorArt: Equatable { if primaryColor == nil { primaryColor = darkBackground ? .white : .black } - + if secondaryColor == nil { secondaryColor = darkBackground ? .white : .black } - + if detailColor == nil { detailColor = darkBackground ? .white : .black } - - return (backgroundColor, primaryColor!, secondaryColor!, detailColor!) + + // Convert all colors to calibrated RGB color space for consistency + // This ensures all colors are in the same color space and prevents + // any color space conversion issues when used in SwiftUI + let rgbColorSpace = NSColorSpace.genericRGB + let finalBackground = backgroundColor.usingColorSpace(rgbColorSpace) ?? backgroundColor + let finalPrimary = primaryColor!.usingColorSpace(rgbColorSpace) ?? primaryColor! + let finalSecondary = secondaryColor!.usingColorSpace(rgbColorSpace) ?? secondaryColor! + let finalDetail = detailColor!.usingColorSpace(rgbColorSpace) ?? detailColor! + + return (finalBackground, finalPrimary, finalSecondary, finalDetail) } // MARK: - Edge Color Detection private static func findEdgeColor(_ image: NSImage, imageColors: inout NSCountedSet?) -> NSColor? { - guard var imageRep = image.representations.last else { - return nil - } - - if !(imageRep is NSBitmapImageRep) { - image.lockFocus() - imageRep = NSBitmapImageRep(focusedViewRect: NSRect(x: 0, y: 0, width: image.size.width, height: image.size.height))! - image.unlockFocus() + var bitmapRep: NSBitmapImageRep? + + // Try to get existing bitmap representation + if let existingRep = image.representations.last as? NSBitmapImageRep { + bitmapRep = existingRep + } else { + // Create bitmap rep from CGImage instead of using lockFocus + guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + return nil + } + bitmapRep = NSBitmapImageRep(cgImage: cgImage) } - + // Convert to RGB color space - guard let bitmapRep = (imageRep as? NSBitmapImageRep)?.converting(to: .genericRGB, renderingIntent: .default) else { + guard let bitmapRep = bitmapRep?.converting(to: .genericRGB, renderingIntent: .default) else { return nil } diff --git a/Hotline/Utility/SwiftUIExtensions.swift b/Hotline/Utility/SwiftUIExtensions.swift index 12217b2..d0dfff4 100644 --- a/Hotline/Utility/SwiftUIExtensions.swift +++ b/Hotline/Utility/SwiftUIExtensions.swift @@ -6,28 +6,3 @@ extension Color { self.init(red: Double((hex >> 16) & 0xFF) / 255.0, green: Double((hex >> 8) & 0xFF) / 255.0, blue: Double(hex & 0xFF) / 255.0, opacity: opacity) } } - -extension AttributedString { - func setHangingIndent(firstLineHeadIndent: CGFloat = 0, otherLinesHeadIndent: CGFloat) -> AttributedString { -// var blah = self - -// guard var paragraph = self.paragraphStyle else { -// return -// } - - var p = self.paragraphStyle?.mutableCopy() as? NSMutableParagraphStyle - p?.headIndent = otherLinesHeadIndent - p?.firstLineHeadIndent = firstLineHeadIndent - -// paragraph.headIndent = otherLinesHeadIndent // indent for lines 2+ -// paragraph.firstLineHeadIndent = firstLineHeadIndent // usually 0 - - var blah = self - - - blah.paragraphStyle = p - - return blah - } -} - diff --git a/Hotline/iOS/ChatView.swift b/Hotline/iOS/ChatView.swift index e320dbf..d4d9d03 100644 --- a/Hotline/iOS/ChatView.swift +++ b/Hotline/iOS/ChatView.swift @@ -27,7 +27,7 @@ struct ChatView: View { VStack(alignment: .center) { if let bannerImage = self.model.bannerImage { - Image(uiImage: bannerImage) + bannerImage .resizable() .scaledToFit() .frame(maxWidth: 468.0) diff --git a/Hotline/iOS/ServerView.swift b/Hotline/iOS/ServerView.swift index 1ce0a9c..8ae0fa7 100644 --- a/Hotline/iOS/ServerView.swift +++ b/Hotline/iOS/ServerView.swift @@ -1,7 +1,7 @@ import SwiftUI struct ServerView: View { - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState @Environment(\.colorScheme) var colorScheme enum Tab { diff --git a/Hotline/macOS/Accounts/AccountManagerView.swift b/Hotline/macOS/Accounts/AccountManagerView.swift index 57682cc..c45ba18 100644 --- a/Hotline/macOS/Accounts/AccountManagerView.swift +++ b/Hotline/macOS/Accounts/AccountManagerView.swift @@ -1,7 +1,7 @@ import SwiftUI struct AccountManagerView: View { - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState @State private var accounts: [HotlineAccount] = [] @State private var selection: HotlineAccount? @@ -43,7 +43,7 @@ struct AccountManagerView: View { .alternatingRowBackgrounds(.enabled) .task { if loading { - accounts = await model.getAccounts() + accounts = (try? await model.getAccounts()) ?? [] loading = false } } @@ -243,47 +243,49 @@ struct AccountManagerView: View { // .padding() Spacer() - - Button("Save"){ + + Button(action: { guard let selection else { return } - + // Update existing account if selection.persisted == true { if pendingPassword == placeholderPassword { Task { @MainActor in - model.client.sendSetUser(name: pendingName, login: pendingLogin, newLogin: nil, password: nil, access: pendingAccess.rawValue) + try? await model.setUser(name: pendingName, login: pendingLogin, newLogin: nil, password: nil, access: pendingAccess.rawValue) } } else { Task { @MainActor in - model.client.sendSetUser(name: pendingName, login: pendingLogin, newLogin: nil, password: pendingPassword, access: pendingAccess.rawValue) + try? await model.setUser(name: pendingName, login: pendingLogin, newLogin: nil, password: pendingPassword, access: pendingAccess.rawValue) } } } else { // Create new existing account Task { @MainActor in - model.client.sendCreateUser(name: pendingName, login: pendingLogin, password: pendingPassword, access: pendingAccess.rawValue) + try? await model.createUser(name: pendingName, login: pendingLogin, password: pendingPassword, access: pendingAccess.rawValue) } self.selection?.password = pendingPassword pendingPassword = placeholderPassword } - + var account = HotlineAccount(pendingName, pendingLogin, pendingAccess) account.persisted = true account.password = placeholderPassword - + accounts = accounts.filter { $0.persisted == true && $0.login != selection.login } - + // Add new account to list accounts.append(account) - + // Re-sort accounts accounts.sort { $0.login < $1.login } self.selection = account - } + }, label: { + Text("Save") + }) .controlSize(.large) .frame(minWidth: 75) .keyboardShortcut(.defaultAction) @@ -349,23 +351,25 @@ struct AccountManagerView: View { } ToolbarItem(placement: .primaryAction) { - Button("Delete") { + Button(action: { guard let userToDelete = toDelete else { return } - + self.toDelete = nil self.selection = nil - + if userToDelete.persisted { Task { @MainActor in - model.client.sendDeleteUser(login: userToDelete.login) + try? await model.deleteUser(login: userToDelete.login) } } - + accounts = accounts.filter { $0.login != userToDelete.login } - - } + + }, label: { + Text("Delete") + }) } } } diff --git a/Hotline/macOS/Board/MessageBoardEditorView.swift b/Hotline/macOS/Board/MessageBoardEditorView.swift index 474384e..ea1754c 100644 --- a/Hotline/macOS/Board/MessageBoardEditorView.swift +++ b/Hotline/macOS/Board/MessageBoardEditorView.swift @@ -8,7 +8,7 @@ struct MessageBoardEditorView: View { @Environment(\.controlActiveState) private var controlActiveState @Environment(\.colorScheme) private var colorScheme @Environment(\.dismiss) private var dismiss - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState @State private var text: String = "" @State private var sending: Bool = false @@ -17,11 +17,11 @@ struct MessageBoardEditorView: View { func sendPost() async { sending = true - + let cleanedText = text.trimmingCharacters(in: .whitespacesAndNewlines) - - model.postToMessageBoard(text: cleanedText) - let _ = await model.getMessageBoard() + + try? await model.postToMessageBoard(text: cleanedText) + let _ = try? await model.getMessageBoard() // let success = await model.postNewsArticle(title: title, body: text, at: path, parentID: parentID) // if success { @@ -69,9 +69,9 @@ struct MessageBoardEditorView: View { else { Button { sending = true - model.postToMessageBoard(text: text) Task { - let _ = await model.getMessageBoard() + try? await model.postToMessageBoard(text: text) + let _ = try? await model.getMessageBoard() Task { @MainActor in sending = false dismiss() diff --git a/Hotline/macOS/Board/MessageBoardView.swift b/Hotline/macOS/Board/MessageBoardView.swift index f870b0c..8788d66 100644 --- a/Hotline/macOS/Board/MessageBoardView.swift +++ b/Hotline/macOS/Board/MessageBoardView.swift @@ -1,7 +1,7 @@ import SwiftUI struct MessageBoardView: View { - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState @State private var composerDisplayed: Bool = false @State private var composerText: String = "" @@ -25,7 +25,7 @@ struct MessageBoardView: View { } .task { if !model.messageBoardLoaded { - let _ = await model.getMessageBoard() + let _ = try? await model.getMessageBoard() } } .overlay { @@ -98,5 +98,5 @@ struct MessageBoardView: View { #Preview { MessageBoardView() - .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) + .environment(HotlineState()) } diff --git a/Hotline/macOS/Chat/ChatView.swift b/Hotline/macOS/Chat/ChatView.swift index 0795856..e1ec73a 100644 --- a/Hotline/macOS/Chat/ChatView.swift +++ b/Hotline/macOS/Chat/ChatView.swift @@ -88,7 +88,7 @@ struct ChatMessageView: View { } struct ChatView: View { - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState @Environment(\.colorScheme) var colorScheme @Environment(\.dismiss) var dismiss @@ -98,12 +98,14 @@ struct ChatView: View { @State private var searchQuery: String = "" @State private var searchResults: [ChatMessage] = [] @State private var isSearching: Bool = false + + @State private var stableBannerImage: Image? @FocusState private var focusedField: FocusedField? @Namespace var bottomID - private var bindableModel: Bindable { + private var bindableModel: Bindable { Bindable(model) } @@ -114,7 +116,36 @@ struct ChatView: View { var displayedMessages: [ChatMessage] { searchQuery.isEmpty ? model.chat : searchResults } - + +// private var blurredBannerImage: some View { +// self.stableBannerImage? +// .resizable() +// .scaledToFit() +// .frame(maxWidth: 468.0) +// .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) +// .offset(y: 1.5) +// .blur(radius: 4) +// .opacity(0.2) +// } + + private var bannerView: some View { + ZStack { + self.stableBannerImage? + .resizable() + .scaledToFit() + .frame(maxWidth: 468.0) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .offset(y: 1.5) + .blur(radius: 4) + .opacity(0.2) + self.stableBannerImage? + .resizable() + .scaledToFit() + .frame(maxWidth: 468.0) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + } + var body: some View { @Bindable var bindModel = model @@ -129,28 +160,10 @@ struct ChatView: View { ForEach(displayedMessages) { msg in if msg.type == .agreement { VStack(alignment: .center, spacing: 16) { - if let bannerImage = self.model.bannerImage { - HStack(spacing: 0) { - Spacer(minLength: 0) - ZStack { - Image(nsImage: bannerImage) - .resizable() - .scaledToFit() - .frame(maxWidth: 468.0) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - .offset(y: 1.5) - .blur(radius: 4) - .opacity(0.2) - - Image(nsImage: bannerImage) - .resizable() - .scaledToFit() - .frame(maxWidth: 468.0) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - } - - Spacer(minLength: 0) - } + HStack(spacing: 0) { + Spacer(minLength: 0) + self.bannerView + Spacer(minLength: 0) } ServerAgreementView(text: msg.text) @@ -218,7 +231,11 @@ struct ChatView: View { .multilineTextAlignment(.leading) .onSubmit { if !model.chatInput.isEmpty { - model.sendChat(model.chatInput, announce: NSEvent.modifierFlags.contains(.shift)) + let message = model.chatInput + let announce = NSEvent.modifierFlags.contains(.shift) + Task { + try? await model.sendChat(message, announce: announce) + } } model.chatInput = "" } @@ -252,6 +269,12 @@ struct ChatView: View { .onChange(of: searchQuery) { performSearch() } + .onChange(of: model.bannerImage) { oldValue, newValue in + stableBannerImage = newValue + } + .onAppear { + stableBannerImage = model.bannerImage + } // .toolbar { // ToolbarItem(placement: .primaryAction) { // Button { @@ -318,5 +341,5 @@ struct ChatView: View { #Preview { ChatView() - .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) + .environment(HotlineState()) } diff --git a/Hotline/macOS/Files/FileDetailsView.swift b/Hotline/macOS/Files/FileDetailsView.swift index 812f4e5..d001df2 100644 --- a/Hotline/macOS/Files/FileDetailsView.swift +++ b/Hotline/macOS/Files/FileDetailsView.swift @@ -2,7 +2,7 @@ import Foundation import SwiftUI struct FileDetailsView: View { - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState @Environment(\.presentationMode) var presentationMode var fd: FileDetails @@ -73,8 +73,8 @@ struct FileDetailsView: View { if comment != fd.comment { editedComment = comment } - - model.client.sendSetFileInfo(fileName: fd.name, path: fd.path, fileNewName: editedFilename, comment: editedComment) + + 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 diff --git a/Hotline/macOS/Files/FileItemView.swift b/Hotline/macOS/Files/FileItemView.swift index 5da744f..31a8af7 100644 --- a/Hotline/macOS/Files/FileItemView.swift +++ b/Hotline/macOS/Files/FileItemView.swift @@ -1,7 +1,7 @@ import SwiftUI struct FileItemView: View { - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState var file: FileInfo let depth: Int diff --git a/Hotline/macOS/Files/FilePreviewImageView.swift b/Hotline/macOS/Files/FilePreviewImageView.swift index 9beeb80..c5899bd 100644 --- a/Hotline/macOS/Files/FilePreviewImageView.swift +++ b/Hotline/macOS/Files/FilePreviewImageView.swift @@ -12,7 +12,7 @@ struct FilePreviewImageView: View { @Binding var info: PreviewFileInfo? - @State var preview: FilePreview? = nil + @State var preview: FilePreviewState? = nil @FocusState private var focusField: FilePreviewFocus? var body: some View { @@ -78,13 +78,16 @@ struct FilePreviewImageView: View { if let info = info { ToolbarItem(placement: .primaryAction) { Button { - let _ = preview?.data?.saveAsFileToDownloads(filename: info.name) + if let fileURL = preview?.fileURL, + let data = try? Data(contentsOf: fileURL) { + let _ = data.saveAsFileToDownloads(filename: info.name) + } } label: { Label("Download Image...", systemImage: "arrow.down") } .help("Download Image") } - + ToolbarItem(placement: .primaryAction) { ShareLink(item: img, preview: SharePreview(info.name, image: img)) { Label("Share Image...", systemImage: "square.and.arrow.up") @@ -96,7 +99,7 @@ struct FilePreviewImageView: View { } .task { if let info = info { - preview = FilePreview(info: info) + preview = FilePreviewState(info: info) preview?.download() } } diff --git a/Hotline/macOS/Files/FilePreviewQuickLookView.swift b/Hotline/macOS/Files/FilePreviewQuickLookView.swift new file mode 100644 index 0000000..0323fdd --- /dev/null +++ b/Hotline/macOS/Files/FilePreviewQuickLookView.swift @@ -0,0 +1,131 @@ +// +// FilePreviewQuickLookView.swift +// Hotline +// +// QuickLook-based file preview window for all supported file types +// + +import SwiftUI +import UniformTypeIdentifiers + +struct FilePreviewQuickLookView: View { + enum FilePreviewFocus: Hashable { + case window + } + + @Environment(\.controlActiveState) private var controlActiveState + @Environment(\.colorScheme) private var colorScheme + @Environment(\.dismiss) var dismiss + + @Binding var info: PreviewFileInfo? + @State var preview: FilePreviewState? = nil + @FocusState private var focusField: FilePreviewFocus? + + var body: some View { + Group { + if preview?.state != .loaded { + VStack(alignment: .center, spacing: 0) { + Spacer() + ProgressView(value: max(0.0, min(1.0, preview?.progress ?? 0.0))) + .focusable(false) + .progressViewStyle(.circular) + .controlSize(.extraLarge) + .frame(maxWidth: 300, alignment: .center) + .padding(.bottom, 48) + Spacer() + } + .background(Color(nsColor: .textBackgroundColor)) + .frame(minWidth: 350, maxWidth: .infinity, minHeight: 150, maxHeight: .infinity) + .padding() + } + else { + if let fileURL = preview?.fileURL { + QuickLookPreviewView(fileURL: fileURL) + .frame(minWidth: 400, maxWidth: .infinity, minHeight: 400, maxHeight: .infinity) + } + else { + VStack(alignment: .center, spacing: 0) { + Spacer() + + Image(systemName: "eye.trianglebadge.exclamationmark") + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + .frame(height: 48) + .padding(.bottom) + Group { + Text("This file type is not previewable") + .bold() + Text("Try downloading and opening this file in another application.") + .foregroundStyle(Color.secondary) + } + .font(.system(size: 14.0)) + .frame(maxWidth: 300) + .multilineTextAlignment(.center) + + Spacer() + Spacer() + } + .frame(minWidth: 350, maxWidth: .infinity, minHeight: 150, maxHeight: .infinity) + .padding() + } + } + } + .focusable() + .focusEffectDisabled() + .background(Color(nsColor: .textBackgroundColor)) + .focused($focusField, equals: .window) + .navigationTitle(info?.name ?? "File Preview") + .background( + WindowConfigurator { window in + if let fileURL = preview?.fileURL { + window.representedURL = fileURL + window.standardWindowButton(.documentIconButton)?.isHidden = false + } + } + ) + .toolbar { + if let _ = 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) + } + } label: { + Label("Download File...", systemImage: "arrow.down") + } + .help("Download File") + } + } + } + } + .task { + if let info = info { + preview = FilePreviewState(info: info) + preview?.download() + } + } + .onAppear { + if info == nil { + Task { + dismiss() + } + return + } + + focusField = .window + } + .onDisappear { + preview?.cancel() + dismiss() + } + .onChange(of: preview?.state) { + if preview?.state == .failed { + dismiss() + } + } + .preferredColorScheme(.dark) + } +} diff --git a/Hotline/macOS/Files/FilePreviewTextView.swift b/Hotline/macOS/Files/FilePreviewTextView.swift index c286381..4e3a719 100644 --- a/Hotline/macOS/Files/FilePreviewTextView.swift +++ b/Hotline/macOS/Files/FilePreviewTextView.swift @@ -11,7 +11,7 @@ struct FilePreviewTextView: View { @Environment(\.dismiss) var dismiss @Binding var info: PreviewFileInfo? - @State var preview: FilePreview? = nil + @State var preview: FilePreviewState? = nil @FocusState private var focusField: FilePreviewFocus? var body: some View { @@ -89,7 +89,10 @@ struct FilePreviewTextView: View { if let info = info { ToolbarItem(placement: .primaryAction) { Button { - let _ = preview?.data?.saveAsFileToDownloads(filename: info.name) + if let fileURL = preview?.fileURL, + let data = try? Data(contentsOf: fileURL) { + let _ = data.saveAsFileToDownloads(filename: info.name) + } } label: { Label("Save Text File...", systemImage: "square.and.arrow.down") } @@ -100,7 +103,7 @@ struct FilePreviewTextView: View { } .task { if let info = info { - preview = FilePreview(info: info) + preview = FilePreviewState(info: info) preview?.download() } } diff --git a/Hotline/macOS/Files/FilesView.swift b/Hotline/macOS/Files/FilesView.swift index de699ff..b499fe6 100644 --- a/Hotline/macOS/Files/FilesView.swift +++ b/Hotline/macOS/Files/FilesView.swift @@ -7,7 +7,7 @@ import AppKit struct FilesView: View { - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState @Environment(\.openWindow) private var openWindow @State private var selection: FileInfo? @@ -15,6 +15,7 @@ struct FilesView: View { @State private var uploadFileSelectorDisplayed: Bool = false @State private var searchText: String = "" @State private var isSearching: Bool = false + @State private var dragOver: Bool = false private var isShowingSearchResults: Bool { switch model.fileSearchStatus { @@ -68,17 +69,18 @@ struct FilesView: View { private func openPreviewWindow(_ previewInfo: PreviewFileInfo) { switch previewInfo.previewType { case .image: - openWindow(id: "preview-image", value: previewInfo) + openWindow(id: "preview-quicklook", value: previewInfo) case .text: - openWindow(id: "preview-text", value: previewInfo) - default: + openWindow(id: "preview-quicklook", value: previewInfo) + case .unknown: + openWindow(id: "preview-quicklook", value: previewInfo) return } } @MainActor private func getFileInfo(_ file: FileInfo) { Task { - if let fileInfo = await model.getFileDetails(file.name, path: file.path) { + if let fileInfo = try? await model.getFileDetails(file.name, path: file.path) { Task { @MainActor in self.fileDetails = fileInfo } @@ -88,10 +90,10 @@ struct FilesView: View { @MainActor private func downloadFile(_ file: FileInfo) { if file.isFolder { - model.downloadFolder(file.name, path: file.path) + model.downloadFolderNew(file.name, path: file.path) } else { - model.downloadFile(file.name, path: file.path) + model.downloadFileNew(file.name, path: file.path) } } @@ -99,7 +101,31 @@ struct FilesView: View { model.uploadFile(url: fileURL, path: path) { info in Task { // Refresh file listing to display newly uploaded file. - let _ = await model.getFileList(path: path) + let _ = try? await model.getFileList(path: path) + } + } + } + + @MainActor private func upload(file fileURL: URL, to path: [String]) { + var fileIsDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false), isDirectory: &fileIsDirectory) else { + return + } + + if fileIsDirectory.boolValue { + self.model.uploadFolder(url: fileURL, path: path, complete: { info in + Task { + // Refresh file listing to display newly uploaded file. + try? await model.getFileList(path: path) + } + }) + } + else { + self.model.uploadFile(url: fileURL, path: path) { info in + Task { + // Refresh file listing to display newly uploaded file. + try? await model.getFileList(path: path) + } } } } @@ -121,15 +147,15 @@ struct FilesView: View { if file.path.count > 1 { parentPath = Array(file.path[0.. 0 else { + guard fileURLS.count > 0, + let fileURL = fileURLS.first + else { return } - let fileURL = fileURLS.first! - - print(fileURL) - var uploadPath: [String] = [] if let selection = selection { @@ -308,7 +356,8 @@ struct FilesView: View { } print("UPLOAD PATH: \(uploadPath)") - uploadFile(file: fileURL, to: uploadPath) + self.upload(file: fileURL, to: uploadPath) +// uploadFile(file: fileURL, to: uploadPath) case .failure(let error): print(error) @@ -414,5 +463,5 @@ struct FilesView: View { #Preview { FilesView() - .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) + .environment(HotlineState()) } diff --git a/Hotline/macOS/Files/FolderItemView.swift b/Hotline/macOS/Files/FolderItemView.swift index 4a08974..2b1b695 100644 --- a/Hotline/macOS/Files/FolderItemView.swift +++ b/Hotline/macOS/Files/FolderItemView.swift @@ -1,7 +1,7 @@ import SwiftUI struct FolderItemView: View { - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState @State var loading = false @State var dragOver = false @@ -20,7 +20,7 @@ struct FolderItemView: View { model.uploadFile(url: fileURL, path: filePath) { info in Task { // Refresh file listing to display newly uploaded file. - let _ = await model.getFileList(path: filePath) + let _ = try? await model.getFileList(path: filePath) } } } @@ -103,7 +103,7 @@ struct FolderItemView: View { if file.expanded && file.fileSize > 0 { Task { loading = true - let _ = await model.getFileList(path: file.path) + let _ = try? await model.getFileList(path: file.path) loading = false } } diff --git a/Hotline/macOS/HotlinePanelView.swift b/Hotline/macOS/HotlinePanelView.swift index ee4d198..7819c2f 100644 --- a/Hotline/macOS/HotlinePanelView.swift +++ b/Hotline/macOS/HotlinePanelView.swift @@ -3,10 +3,27 @@ import SwiftUI struct HotlinePanelView: View { @Environment(\.openWindow) var openWindow @Environment(\.colorScheme) var colorScheme + @Environment(\.appState) private var appState + + private var activeServerState: ServerState? { + self.appState.activeServerState + } + + private var activeHotline: HotlineState? { + self.appState.activeHotline + } + + private var bannerImage: Image { + self.activeHotline?.bannerImage ?? Image("Default Banner") + } + + private var backgroundColor: Color { + Color(nsColor: self.activeHotline?.bannerColors?.backgroundColor ?? NSColor.controlBackgroundColor) + } var body: some View { VStack(spacing: 0) { - Image(nsImage: AppState.shared.activeServerState?.serverBanner ?? NSImage(named: "Default Banner")!) + self.bannerImage .interpolation(.high) .resizable() .scaledToFill() @@ -14,8 +31,7 @@ struct HotlinePanelView: View { .frame(minWidth: 468, maxWidth: 468, minHeight: 60, maxHeight: 60) .clipped() .background(.black) -// .clipShape(RoundedRectangle(cornerRadius: 6.0)) -// .padding([.top, .leading, .trailing], 4) + .animation(.default, value: self.bannerImage) HStack(spacing: 12) { Button { @@ -36,7 +52,7 @@ struct HotlinePanelView: View { .help("Hotline Servers") Button { - AppState.shared.activeServerState?.selection = .chat + self.activeServerState?.selection = .chat } label: { Image("Section Chat") @@ -45,11 +61,11 @@ struct HotlinePanelView: View { } .buttonStyle(.plain) .frame(width: 20, height: 20) - .disabled(AppState.shared.activeServerState == nil) + .disabled(self.activeServerState == nil) .help("Public Chat") Button { - AppState.shared.activeServerState?.selection = .board + self.activeServerState?.selection = .board } label: { Image("Section Board") @@ -58,11 +74,11 @@ struct HotlinePanelView: View { } .buttonStyle(.plain) .frame(width: 20, height: 20) - .disabled(AppState.shared.activeServerState == nil) + .disabled(self.activeServerState == nil) .help("Message Board") Button { - AppState.shared.activeServerState?.selection = .news + self.activeServerState?.selection = .news } label: { Image("Section News") @@ -71,11 +87,11 @@ struct HotlinePanelView: View { } .buttonStyle(.plain) .frame(width: 20, height: 20) - .disabled(AppState.shared.activeServerState == nil || (AppState.shared.activeHotline?.serverVersion ?? 0) < 151) + .disabled(self.activeServerState == nil || (self.activeHotline?.serverVersion ?? 0) < 151) .help("News") Button { - AppState.shared.activeServerState?.selection = .files + self.activeServerState?.selection = .files } label: { Image("Section Files") @@ -84,14 +100,14 @@ struct HotlinePanelView: View { } .buttonStyle(.plain) .frame(width: 20, height: 20) - .disabled(AppState.shared.activeServerState == nil) + .disabled(self.activeServerState == nil) .help("Files") Spacer() - if AppState.shared.activeHotline?.access?.contains(.canOpenUsers) == true { + if self.activeHotline?.access?.contains(.canOpenUsers) == true { Button { - AppState.shared.activeServerState?.selection = .accounts + self.activeServerState?.selection = .accounts } label: { Image("Section Users") @@ -100,7 +116,7 @@ struct HotlinePanelView: View { } .buttonStyle(.plain) .frame(width: 20, height: 20) - .disabled(AppState.shared.activeServerState == nil) + .disabled(self.activeServerState == nil) .help("Accounts") } @@ -116,9 +132,9 @@ struct HotlinePanelView: View { .padding(.top, 12) .padding(.bottom, 12) .padding([.leading, .trailing], 12) - .background(AppState.shared.activeServerState?.bannerColors.map { Color(nsColor: $0.backgroundColor) } ?? Color(nsColor: .controlBackgroundColor)) - .foregroundStyle(AppState.shared.activeServerState?.bannerColors.map { Color(nsColor: $0.primaryColor) } ?? Color.primary) -// .background(Color.red.opacity(0.5).blendMode(.multiply)) + .background(self.backgroundColor) + .foregroundStyle(.primary) + .animation(.default, value: self.backgroundColor) // GroupBox { // HStack(spacing: 0) { @@ -146,5 +162,5 @@ struct HotlinePanelView: View { #Preview { HotlinePanelView() - .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) + .environment(HotlineState()) } diff --git a/Hotline/macOS/MessageView.swift b/Hotline/macOS/MessageView.swift index 0a8b071..b0c8baa 100644 --- a/Hotline/macOS/MessageView.swift +++ b/Hotline/macOS/MessageView.swift @@ -1,7 +1,7 @@ import SwiftUI struct MessageView: View { - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState @Environment(\.colorScheme) private var colorScheme @State private var input: String = "" @@ -75,7 +75,11 @@ struct MessageView: View { .multilineTextAlignment(.leading) .onSubmit { if !self.input.isEmpty { - model.sendInstantMessage(self.input, userID: self.userID) + let message = self.input + let uid = self.userID + Task { + try? await model.sendInstantMessage(message, userID: uid) + } } self.input = "" } @@ -107,5 +111,5 @@ struct MessageView: View { #Preview { ChatView() - .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) + .environment(HotlineState()) } diff --git a/Hotline/macOS/News/NewsEditorView.swift b/Hotline/macOS/News/NewsEditorView.swift index f69c846..cc3e7d7 100644 --- a/Hotline/macOS/News/NewsEditorView.swift +++ b/Hotline/macOS/News/NewsEditorView.swift @@ -9,7 +9,7 @@ struct NewsEditorView: View { @Environment(\.controlActiveState) private var controlActiveState @Environment(\.colorScheme) private var colorScheme @Environment(\.dismiss) private var dismiss - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState let editorTitle: String let isReply: Bool @@ -24,15 +24,16 @@ struct NewsEditorView: View { func sendArticle() async -> Bool { sending = true - - let success = await model.postNewsArticle(title: title, body: text, at: path, parentID: parentID) - if success { - await model.getNewsList(at: path) + + do { + try await model.postNewsArticle(title: title, body: text, at: path, parentID: parentID) + try? await model.getNewsList(at: path) + sending = false + return true + } catch { + sending = false + return false } - - sending = false - - return success } var body: some View { diff --git a/Hotline/macOS/News/NewsItemView.swift b/Hotline/macOS/News/NewsItemView.swift index fc20e61..29c0e7d 100644 --- a/Hotline/macOS/News/NewsItemView.swift +++ b/Hotline/macOS/News/NewsItemView.swift @@ -1,7 +1,7 @@ import SwiftUI struct NewsItemView: View { - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState var news: NewsInfo let depth: Int @@ -132,9 +132,9 @@ struct NewsItemView: View { guard news.expanded, news.type == .bundle || news.type == .category else { return } - + Task { - await model.getNewsList(at: news.path) + try? await model.getNewsList(at: news.path) } } @@ -148,5 +148,5 @@ struct NewsItemView: View { #Preview { NewsItemView(news: NewsInfo(hotlineNewsArticle: HotlineNewsArticle(id: 0, parentID: 0, flags: 0, title: "Title", username: "username", date: Date.now, flavors: [("", 1)], path: ["Guest"])), depth: 0) - .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) + .environment(HotlineState()) } diff --git a/Hotline/macOS/News/NewsView.swift b/Hotline/macOS/News/NewsView.swift index 91bf2fe..dfc5ab2 100644 --- a/Hotline/macOS/News/NewsView.swift +++ b/Hotline/macOS/News/NewsView.swift @@ -3,7 +3,7 @@ import MarkdownUI import SplitView struct NewsView: View { - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState @Environment(\.openWindow) private var openWindow @Environment(\.colorScheme) private var colorScheme @@ -64,7 +64,7 @@ struct NewsView: View { .task { if !model.newsLoaded { loading = true - await model.getNewsList() + try? await model.getNewsList() loading = false } } @@ -123,13 +123,13 @@ struct NewsView: View { loading = true if let selectionPath = selection?.path { Task { - await model.getNewsList(at: selectionPath) + try? await model.getNewsList(at: selectionPath) loading = false } } else { Task { - await model.getNewsList() + try? await model.getNewsList() loading = false } } @@ -179,7 +179,7 @@ struct NewsView: View { if let articleFlavor = article.articleFlavors?.first, let articleID = article.articleID { Task { - if let articleText = await self.model.getNewsArticle(id: articleID, at: article.path, flavor: articleFlavor) { + if let articleText = try? await self.model.getNewsArticle(id: articleID, at: article.path, flavor: articleFlavor) { self.articleText = articleText } } @@ -293,5 +293,5 @@ struct NewsView: View { #Preview { NewsView() - .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) + .environment(HotlineState()) } diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index e18d630..df3e54b 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -69,7 +69,7 @@ struct ListItemView: View { } extension FocusedValues { - @Entry var activeHotlineModel: Hotline? + @Entry var activeHotlineModel: HotlineState? @Entry var activeServerState: ServerState? } @@ -80,7 +80,7 @@ struct ServerView: View { @Environment(\.scenePhase) private var scenePhase @Environment(\.modelContext) private var modelContext - @State private var model: Hotline = Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient()) + @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 = "" @@ -119,6 +119,27 @@ struct ServerView: View { connectForm .navigationTitle("Connect to Server") } + 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) + } + .frame(maxWidth: 300) + .padding() + .navigationTitle("Connection Failed") + } else if model.status != .loggedIn { HStack { Image("Hotline") @@ -129,7 +150,7 @@ struct ServerView: View { .frame(width: 18) .opacity(controlActiveState == .inactive ? 0.5 : 1.0) .padding(.trailing, 4) - + ProgressView(value: connectionStatusToProgress(status: model.status)) { Text(connectionStatusToLabel(status: model.status)) } @@ -142,12 +163,24 @@ struct ServerView: View { else { serverView .environment(model) - .onChange(of: Prefs.shared.userIconID) { sendPreferences() } - .onChange(of: Prefs.shared.username) { sendPreferences() } - .onChange(of: Prefs.shared.refusePrivateMessages) { sendPreferences() } - .onChange(of: Prefs.shared.refusePrivateChat) { sendPreferences() } - .onChange(of: Prefs.shared.enableAutomaticMessage) { sendPreferences() } - .onChange(of: Prefs.shared.automaticMessage) { sendPreferences() } + .onChange(of: Prefs.shared.userIconID) { + Task { try? await model.sendUserPreferences() } + } + .onChange(of: Prefs.shared.username) { + Task { try? await model.sendUserPreferences() } + } + .onChange(of: Prefs.shared.refusePrivateMessages) { + Task { try? await model.sendUserPreferences() } + } + .onChange(of: Prefs.shared.refusePrivateChat) { + Task { try? await model.sendUserPreferences() } + } + .onChange(of: Prefs.shared.enableAutomaticMessage) { + Task { try? await model.sendUserPreferences() } + } + .onChange(of: Prefs.shared.automaticMessage) { + Task { try? await model.sendUserPreferences() } + } .toolbar { if #available(macOS 26.0, *) { ToolbarItem(placement: .navigation) { @@ -172,21 +205,23 @@ struct ServerView: View { } } .onDisappear { - model.disconnect() - } - .onChange(of: model.serverTitle) { oldTitle, newTitle in - state.serverName = newTitle - } - .onChange(of: model.bannerImage) { oldBanner, newBanner in - withAnimation { - state.serverBanner = newBanner - if let banner = newBanner { - state.bannerColors = ColorArt(image: banner, scaledSize: NSSize(width: 100, height: 100)) - } else { - state.bannerColors = nil - } + Task { + await model.disconnect() } } + .onChange(of: model.serverTitle) { + state.serverName = 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) { Button("OK") {} } @@ -310,16 +345,18 @@ struct ServerView: View { if !name.isEmpty { connectNameSheetPresented = false connectName = "" - Task.detached { - 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) - } +// Task.detached { + + 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) } + +// } } } } @@ -390,7 +427,7 @@ struct ServerView: View { // Section("\(model.users.count) Online") { ForEach(model.users) { user in HStack(spacing: 5) { - if let iconImage = Hotline.getClassicIcon(Int(user.iconID)) { + if let iconImage = HotlineState.getClassicIcon(Int(user.iconID)) { Image(nsImage: iconImage) .frame(width: 16, height: 16) .padding(.leading, 2) @@ -476,35 +513,37 @@ struct ServerView: View { guard !server.address.isEmpty else { return } - model.login(server: server, username: Prefs.shared.username, iconID: Prefs.shared.userIconID) { success in - if !success { - print("FAILED LOGIN??") - model.disconnect() - } - else { - sendPreferences() - model.getUserList() - model.downloadBanner() + + Task { @MainActor in + do { + // login() handles everything: connect, getUserList, sendPreferences, downloadBanner + try await model.login( + server: server, + username: Prefs.shared.username, + iconID: Prefs.shared.userIconID + ) + } catch { + print("ServerView: Login failed: \(error)") } } } - private func connectionStatusToProgress(status: HotlineClientStatus) -> Double { + private func connectionStatusToProgress(status: HotlineConnectionStatus) -> Double { switch status { case .disconnected: return 0.0 case .connecting: return 0.4 case .connected: - return 0.75 - case .loggingIn: return 0.9 case .loggedIn: return 1.0 + case .failed: + return 0.0 } } - - private func connectionStatusToLabel(status: HotlineClientStatus) -> String { + + private func connectionStatusToLabel(status: HotlineConnectionStatus) -> String { let n = server.name ?? server.address switch status { case .disconnected: @@ -512,42 +551,21 @@ struct ServerView: View { case .connecting: return "Connecting to \(n)..." case .connected: - return "Connected to \(n)" - case .loggingIn: return "Logging in to \(n)..." case .loggedIn: return "Logged in to \(n)" + case .failed(let error): + return "Failed: \(error)" } } - @MainActor func sendPreferences() { - if self.model.status == .loggedIn { - var options: HotlineUserOptions = HotlineUserOptions() - - if Prefs.shared.refusePrivateMessages { - options.update(with: .refusePrivateMessages) - } - - if Prefs.shared.refusePrivateChat { - options.update(with: .refusePrivateChat) - } - - if Prefs.shared.enableAutomaticMessage { - options.update(with: .automaticResponse) - } - - print("Updating preferences with server") - - self.model.sendUserInfo(username: Prefs.shared.username, iconID: Prefs.shared.userIconID, options: options, autoresponse: Prefs.shared.automaticMessage) - } - } } struct TransferItemView: View { let transfer: TransferInfo @Environment(\.controlActiveState) private var controlActiveState - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState @State private var hovered: Bool = false @State private var buttonHovered: Bool = false @@ -559,8 +577,8 @@ struct TransferItemView: View { return "File transfer failed" } else if self.transfer.progress > 0.0 { - if self.transfer.timeRemaining > 0.0 { - return "\(round(self.transfer.progress * 100.0))% – \(self.transfer.timeRemaining) seconds left" + if let estimate = self.transfer.timeRemaining, estimate > 0.0 { + return "\(round(self.transfer.progress * 100.0))% – \(estimate) seconds left" } else { return "\(round(self.transfer.progress * 100.0))% complete" @@ -597,7 +615,7 @@ struct TransferItemView: View { if self.hovered { Button { - model.deleteTransfer(id: transfer.id) + AppState.shared.cancelTransfer(id: transfer.id) } label: { Image(systemName: self.buttonHovered ? "xmark.circle.fill" : "xmark.circle") .resizable() @@ -657,4 +675,3 @@ struct TransferItemView: View { .help(formattedProgressHelp()) } } - diff --git a/Hotline/macOS/Settings/IconSettingsView.swift b/Hotline/macOS/Settings/IconSettingsView.swift index 98cbb09..2bb92c4 100644 --- a/Hotline/macOS/Settings/IconSettingsView.swift +++ b/Hotline/macOS/Settings/IconSettingsView.swift @@ -18,7 +18,7 @@ struct IconSettingsView: View { GridItem(.fixed(4+32+4)), GridItem(.fixed(4+32+4)) ], spacing: 0) { - ForEach(Hotline.classicIconSet, id: \.self) { iconID in + ForEach(HotlineState.classicIconSet, id: \.self) { iconID in HStack { Image("Classic/\(iconID)") .resizable() diff --git a/Hotline/macOS/TransfersView.swift b/Hotline/macOS/TransfersView.swift new file mode 100644 index 0000000..9932c0a --- /dev/null +++ b/Hotline/macOS/TransfersView.swift @@ -0,0 +1,169 @@ +import SwiftUI + +struct TransfersView: View { + @Environment(\.appState) private var appState + + var body: some View { + VStack(spacing: 0) { + if appState.transfers.isEmpty { + emptyState + } else { + transfersList + } + } + .frame(minWidth: 500, minHeight: 200) + .navigationTitle("Transfers") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + appState.cancelAllTransfers() + } label: { + Label("Cancel All", systemImage: "xmark.circle") + } + .disabled(appState.transfers.isEmpty) + } + } + } + + // MARK: - Empty State + + private var emptyState: some View { + ContentUnavailableView { + Label("No Transfers", systemImage: "arrow.up.arrow.down") + } description: { + Text("Your Hotline file transfers will appear here") + } + } + + // MARK: - Transfers List + + private var transfersList: some View { + List { + ForEach(appState.transfers) { transfer in + TransferRow(transfer: transfer) + } + } + .listStyle(.inset(alternatesRowBackgrounds: true)) + } +} + +// MARK: - Transfer Row + +struct TransferRow: View { + @Bindable var transfer: TransferInfo + + 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) + } + } + + Spacer() + + // Cancel button + Button { + AppState.shared.cancelTransfer(id: transfer.id) + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("Cancel download") + } + + // 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) + + // 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) + } + + // MARK: - Formatting + + private func formatSize(_ bytes: UInt) -> String { + let formatter = ByteCountFormatter() + formatter.countStyle = .file + formatter.allowedUnits = [.useKB, .useMB, .useGB] + return formatter.string(fromByteCount: Int64(bytes)) + } + + private func formatSpeed(_ bytesPerSecond: Double) -> String { + let formatter = ByteCountFormatter() + formatter.countStyle = .file + formatter.allowedUnits = [.useKB, .useMB, .useGB] + return "\(formatter.string(fromByteCount: Int64(bytesPerSecond)))/s" + } + + private func formatTimeRemaining(_ seconds: TimeInterval) -> String { + if seconds < 60 { + return "\(Int(seconds))s" + } else if seconds < 3600 { + let minutes = Int(seconds / 60) + let secs = Int(seconds.truncatingRemainder(dividingBy: 60)) + return "\(minutes)m \(secs)s" + } else { + let hours = Int(seconds / 3600) + let minutes = Int((seconds.truncatingRemainder(dividingBy: 3600)) / 60) + return "\(hours)h \(minutes)m" + } + } +} + +// MARK: - Preview + +#Preview { + TransfersView() + .environment(AppState.shared) +} -- cgit 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.xcodeproj/project.pbxproj | 8 +- Hotline/Hotline/HotlineClient.swift | 878 ------- Hotline/Hotline/HotlineTransferClient.swift | 1834 --------------- .../Transfers/HotlineFileUploadClientNew.swift | 18 +- .../Transfers/HotlineFolderDownloadClientNew.swift | 38 +- .../Transfers/HotlineFolderUploadClientNew.swift | 28 +- Hotline/Library/NetSocket.swift | 274 --- Hotline/Models/Hotline.swift | 1876 --------------- Hotline/Models/HotlineState.swift | 2468 -------------------- Hotline/State/HotlineState.swift | 2468 ++++++++++++++++++++ Hotline/macOS/Board/MessageBoardView.swift | 121 +- Hotline/macOS/Files/FilesView.swift | 272 +-- Hotline/macOS/News/NewsView.swift | 42 +- 13 files changed, 2700 insertions(+), 7625 deletions(-) delete mode 100644 Hotline/Hotline/HotlineClient.swift delete mode 100644 Hotline/Library/NetSocket.swift delete mode 100644 Hotline/Models/Hotline.swift delete mode 100644 Hotline/Models/HotlineState.swift create mode 100644 Hotline/State/HotlineState.swift (limited to 'Hotline/macOS') diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index a234fbc..cb621a0 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -137,7 +137,6 @@ DA3429B62EBAB1750010784E /* QuickLookPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickLookPreviewView.swift; sourceTree = ""; }; DA3429B82EBAB2130010784E /* FilePreviewQuickLookView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePreviewQuickLookView.swift; sourceTree = ""; }; DA43205D2B1D615600FC8843 /* ServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerView.swift; sourceTree = ""; }; - DA4930BC2B4F8DD700822D0B /* NetSocket.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetSocket.swift; sourceTree = ""; }; DA4B8F392EA6FB3C00CBFD53 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; }; DA4F2BF72B16A17200D8ADDC /* HotlineProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineProtocol.swift; sourceTree = ""; }; DA4F2C002B1A558E00D8ADDC /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = ""; }; @@ -161,7 +160,6 @@ DA55AC782BE6A1AD00034857 /* RegularExpressions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegularExpressions.swift; sourceTree = ""; }; DA5753672B33E88A00FAC277 /* HotlineTransferClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineTransferClient.swift; sourceTree = ""; }; DA57536B2B36BA1D00FAC277 /* TextDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextDocument.swift; sourceTree = ""; }; - DA6300962B24036B0034CBFD /* HotlineClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineClient.swift; sourceTree = ""; }; DA6549992BEC280E00EDB697 /* ServerMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerMessageView.swift; sourceTree = ""; }; DA65499B2BEC3FBD00EDB697 /* ServerAgreementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerAgreementView.swift; sourceTree = ""; }; DA65499D2BEC438A00EDB697 /* NSWindowBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSWindowBridge.swift; sourceTree = ""; }; @@ -209,7 +207,6 @@ DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdateView.swift; sourceTree = ""; }; DADDB28A2B22B31F0024040D /* Tracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tracker.swift; sourceTree = ""; }; DADDB28C2B22B5920024040D /* Server.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Server.swift; sourceTree = ""; }; - DADDB28E2B238D850024040D /* Hotline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Hotline.swift; sourceTree = ""; }; DAE136B92B9D1147007D8307 /* HotlinePanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlinePanelView.swift; sourceTree = ""; }; DAE734F82B2E4185000C56F6 /* ServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerView.swift; sourceTree = ""; }; DAE734FA2B2E41F9000C56F6 /* TrackerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackerView.swift; sourceTree = ""; }; @@ -255,6 +252,7 @@ DA2863D92B37BF6E00A7D050 /* Preferences.swift */, DACCE5E02EABE4B4008CDD92 /* AppUpdate.swift */, DA3429B42EBA8A450010784E /* FilePreviewState.swift */, + DA5268B02EB2708E00DCB941 /* HotlineState.swift */, ); path = State; sourceTree = ""; @@ -409,7 +407,6 @@ DAB4D8832B4CABEF0048A05C /* DataAdditions.swift */, DAB4D87F2B4C8E9A0048A05C /* URLAdditions.swift */, DA3429B62EBAB1750010784E /* QuickLookPreviewView.swift */, - DA4930BC2B4F8DD700822D0B /* NetSocket.swift */, DA872B122BDDBF78008B1012 /* HotlinePanel.swift */, DA872B142BDDEE1A008B1012 /* VisualEffectView.swift */, DA55AC722BE42AF000034857 /* AsyncLinkPreview.swift */, @@ -436,7 +433,6 @@ DABFCC262B1530AE009F40D2 /* Hotline */ = { isa = PBXGroup; children = ( - DA6300962B24036B0034CBFD /* HotlineClient.swift */, DA5268AE2EB2682B00DCB941 /* HotlineClientNew.swift */, DA9CAFCA2B126E3300CDA197 /* HotlineTrackerClient.swift */, DA5753672B33E88A00FAC277 /* HotlineTransferClient.swift */, @@ -476,8 +472,6 @@ DADDB2892B22B2C60024040D /* Models */ = { isa = PBXGroup; children = ( - DADDB28E2B238D850024040D /* Hotline.swift */, - DA5268B02EB2708E00DCB941 /* HotlineState.swift */, DADDB28A2B22B31F0024040D /* Tracker.swift */, DADDB28C2B22B5920024040D /* Server.swift */, DA32CD482B2931640053B98B /* User.swift */, diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift deleted file mode 100644 index 0e54872..0000000 --- a/Hotline/Hotline/HotlineClient.swift +++ /dev/null @@ -1,878 +0,0 @@ -import Foundation -import Network -import RegexBuilder - -enum HotlineClientStatus: Int { - case disconnected - case connecting - case connected - case loggingIn - case loggedIn -} - -enum HotlineTransactionError: Error { - case networkFailure - case timeout - case error(UInt32, String?) - case invalidMessage(UInt32, String?) -} - -struct HotlineTransactionInfo { - let type: HotlineTransactionType - let callback: ((HotlineTransaction) -> Void)? - let reply: ((HotlineTransaction) -> Void)? -} - -private struct HotlineLogin { - let login: String? - let password: String? - let username: String - let iconID: UInt16 - let callback: ((HotlineTransactionError?, String?, UInt16?) -> Void)? -} - -protocol HotlineClientDelegate: AnyObject { - func hotlineGetUserInfo() -> (String, UInt16) - func hotlineStatusChanged(status: HotlineClientStatus) - func hotlineReceivedAgreement(text: String) - func hotlineReceivedErrorMessage(code: UInt32, message: String?) - func hotlineReceivedChatMessage(message: String) - func hotlineReceivedUserList(users: [HotlineUser]) - func hotlineReceivedServerMessage(message: String) - func hotlineReceivedPrivateMessage(userID: UInt16, message: String) - func hotlineReceivedUserAccess(options: HotlineUserAccessOptions) - func hotlineUserChanged(user: HotlineUser) - func hotlineUserDisconnected(userID: UInt16) - func hotlineReceivedNewsPost(message: String) -} - -extension HotlineClientDelegate { - func hotlineStatusChanged(status: HotlineClientStatus) {} - func hotlineReceivedAgreement(text: String) {} - func hotlineReceivedErrorMessage(code: UInt32, message: String?) {} - func hotlineReceivedChatMessage(message: String) {} - func hotlineReceivedUserList(users: [HotlineUser]) {} - func hotlineReceivedServerMessage(message: String) {} - func hotlineReceivedPrivateMessage(userID: UInt16, message: String) {} - func hotlineReceivedUserAccess(options: HotlineUserAccessOptions) {} - func hotlineUserChanged(user: HotlineUser) {} - func hotlineUserDisconnected(userID: UInt16) {} - func hotlineReceivedNewsPost(message: String) {} -} - -enum HotlineClientStage { - case handshake - case packetHeader - case packetBody -} - -class HotlineClient: NetSocketDelegate { - static let handshakePacket = Data([ - 0x54, 0x52, 0x54, 0x50, // 'TRTP' protocol ID - 0x48, 0x4F, 0x54, 0x4C, // Sub-protocol ID - 0x00, 0x01, // Version - 0x00, 0x02, // Sub-version - ]) - - static let HandshakePacket: [UInt8] = [ - 0x54, 0x52, 0x54, 0x50, // 'TRTP' protocol ID - 0x48, 0x4F, 0x54, 0x4C, // Sub-protocol ID - 0x00, 0x01, // Version - 0x00, 0x02, // Sub-version - ] - - weak var delegate: HotlineClientDelegate? - - var connectionStatus: HotlineClientStatus = .disconnected - var connectCallback: ((Bool) -> Void)? - - private var serverAddress: String? = nil - private var serverPort: UInt16? = nil - - private struct TransactionContext { - let type: HotlineTransactionType - let callback: ((HotlineTransaction, HotlineTransactionError?) -> Void)? - let suppressErrors: Bool - } - - private var transactionLog: [UInt32: TransactionContext] = [:] - - private var socket: NetSocket? - private var stage: HotlineClientStage = .handshake - private var packet: HotlineTransaction? = nil - private var serverVersion: UInt16? = nil - private var loginDetails: HotlineLogin? = nil - private var keepAliveTimer: Timer? = nil - - init() {} - - // MARK: - NetSocket Delegate - - @MainActor func netsocketConnected(socket: NetSocket) { - self.updateConnectionStatus(.loggingIn) - self.stage = .handshake - } - - @MainActor func netsocketDisconnected(socket: NetSocket, error: Error?) { - self.reset() - self.updateConnectionStatus(.disconnected) - self.stage = .handshake - } - - @MainActor func netsocketReceived(socket: NetSocket, bytes: [UInt8]) { - switch self.stage { - case .handshake: - self.receiveHandshake() - case .packetHeader: - self.receivePacket() - case .packetBody: - self.receivePacket() - } - } - - // MARK: - Connect - - @MainActor func login(address: String, port: Int, login: String?, password: String?, username: String, iconID: UInt16, callback: ((HotlineTransactionError?, String?, UInt16?) -> Void)?) { - if self.socket != nil { - self.socket?.delegate = nil - self.socket?.close() - self.socket = nil - } - self.packet = nil - - self.loginDetails = HotlineLogin(login: login, password: password, username: username, iconID: iconID, callback: callback) - - self.socket = NetSocket() - self.socket?.delegate = self - - self.updateConnectionStatus(.connecting) - self.socket?.connect(host: address, port: port) - self.socket?.write(HotlineClient.HandshakePacket) - } - - @MainActor private func startKeepAliveTimer() { - self.keepAliveTimer = Timer.scheduledTimer(withTimeInterval: 60 * 3, repeats: true) { [weak self] _ in - DispatchQueue.main.async { [weak self] in - self?.sendKeepAlive() - } - } - } - - @MainActor func receiveHandshake() { - guard let socket = self.socket, - self.stage == .handshake, - socket.available >= 8 else { - return - } - - var handshake: [UInt8] = socket.read(count: 8) - - // Verify handshake data - guard let protocolID = handshake.consumeUInt32(), - protocolID == "TRTP".fourCharCode() else { - // TODO: Close with appropriate error - socket.close() - return - } - - // Check for error code - guard let errorCode = handshake.consumeUInt32(), - errorCode == 0 else { - // TODO: Close with wrapped error - socket.close() - return - } - - self.stage = .packetHeader - - let session = self.loginDetails! - self.loginDetails = nil - self.sendLogin(login: session.login ?? "", password: session.password ?? "", username: session.username, iconID: session.iconID) { [weak self] err, serverName, serverVersion in - self?.serverVersion = serverVersion - self?.startKeepAliveTimer() - session.callback?(err, serverName, serverVersion) - } - - self.receivePacket() - } - - @MainActor private func reset() { - self.transactionLog = [:] - self.packet = nil - - self.keepAliveTimer?.invalidate() - self.keepAliveTimer = nil - - self.socket?.close() - self.socket?.delegate = nil - self.socket = nil - } - - @MainActor func disconnect() { - let wasConnected = self.connectionStatus != .disconnected - self.reset() - if wasConnected { - self.updateConnectionStatus(.disconnected) - } - } - - // MARK: - Packets - - @MainActor private func sendPacket(_ t: HotlineTransaction, suppressErrors: Bool = false, callback: ((HotlineTransaction, HotlineTransactionError?) -> Void)? = nil) { - guard let socket = self.socket else { - return - } - - print("HotlineClient => \(t.id) \(t.type)") - - if callback != nil || suppressErrors { - self.transactionLog[t.id] = TransactionContext(type: t.type, callback: callback, suppressErrors: suppressErrors) - } - - socket.write(t.encoded()) - } - - @MainActor private func receivePacket() { - guard let socket = self.socket else { - return - } - - var done: Bool = false - repeat { - switch self.stage { - case .packetHeader: - guard socket.has(HotlineTransaction.headerSize) else { - done = true - break - } - - let headerData: [UInt8] = socket.read(count: HotlineTransaction.headerSize) - guard let packet = HotlineTransaction(from: headerData) else { - done = true - break - } - - self.packet = packet - if packet.dataSize == 0 { - self.stage = .packetHeader - self.processPacket() - } - else { - self.stage = .packetBody - } - - case .packetBody: - guard let packet = self.packet, socket.has(Int(packet.dataSize)) else { - done = true - break - } - - let bodyData: [UInt8] = socket.read(count: Int(packet.dataSize)) - self.packet?.decodeFields(from: bodyData) - self.stage = .packetHeader - self.processPacket() - - default: - done = true - break - } - } while !done - } - - @MainActor private func processPacket() { - guard let packet = self.packet else { - return - } - - if packet.type == .reply || packet.isReply == 1 { - print("HotlineClient <= \(packet.type) to \(packet.id):") - } - else { - print("HotlineClient <= \(packet.type) \(packet.id)") - } - - if packet.isReply == 1 || packet.type == .reply { - self.processReplyPacket() - return - } - - // Mark packet is processed - self.packet = nil - - switch(packet.type) { - case .chatMessage: - if - let chatTextParam = packet.getField(type: .data), - let chatText = chatTextParam.getString() - { - print("HotlineClient: \(chatText)") - self.delegate?.hotlineReceivedChatMessage(message: chatText) - } - - case .notifyOfUserChange: - if let usernameField = packet.getField(type: .userName), - let username = usernameField.getString(), - let userIDField = packet.getField(type: .userID), - let userID = userIDField.getUInt16(), - let userIconIDField = packet.getField(type: .userIconID), - let userIconID = userIconIDField.getUInt16(), - let userFlagsField = packet.getField(type: .userFlags), - let userFlags = userFlagsField.getUInt16() { - print("HotlineClient: User changed \(userID) \(username) icon: \(userIconID)") - - let user = HotlineUser(id: userID, iconID: userIconID, status: userFlags, name: username) - self.delegate?.hotlineUserChanged(user: user) - } - - case .notifyOfUserDelete: - if let userIDField = packet.getField(type: .userID), - let userID = userIDField.getUInt16() { - self.delegate?.hotlineUserDisconnected(userID: userID) - } - - case .disconnectMessage: - // Server disconnected us. - print("HotlineClient ❌") - self.disconnect() - - case .serverMessage: - if let messageField = packet.getField(type: .data), - let message = messageField.getString() { - - if let userIDField = packet.getField(type: .userID), - let userID = userIDField.getUInt16() { - self.delegate?.hotlineReceivedPrivateMessage(userID: userID, message: message) - } - else { - self.delegate?.hotlineReceivedServerMessage(message: message) - } - } - - case .showAgreement: - if let _ = packet.getField(type: .noServerAgreement) { - // Server told us there is no agreement to show. - return - } - if let agreementParam = packet.getField(type: .data) { - if let agreementText = agreementParam.getString() { - self.delegate?.hotlineReceivedAgreement(text: agreementText) - } - } - - case .userAccess: - print("HotlineClient: user access info \(packet.getField(type: .userAccess).debugDescription)") - if let accessParam = packet.getField(type: .userAccess) { - if let accessValue = accessParam.getUInt64() { - let accessOptions = HotlineUserAccessOptions(rawValue: accessValue) - self.delegate?.hotlineReceivedUserAccess(options: accessOptions) - } - } - - case .newMessage: - if let messageField = packet.getField(type: .data), - let message = messageField.getString() { - self.delegate?.hotlineReceivedNewsPost(message: message) - } - - default: - print("HotlineClient: UNKNOWN transaction \(packet.type) with \(packet.fields.count) parameters") - print(packet.fields) - } - } - - @MainActor private func processReplyPacket() { - guard let packet = self.packet else { - return - } - - let context = self.transactionLog[packet.id] - self.transactionLog[packet.id] = nil - - if packet.errorCode != 0 { - let errorField: HotlineTransactionField? = packet.getField(type: .errorText) - print("HotlineClient 😵 \(packet.errorCode): \(errorField?.getString() ?? "")") - if context?.suppressErrors != true { - self.delegate?.hotlineReceivedErrorMessage(code: packet.errorCode, message: errorField?.getString()) - } - } - - if let context { - print("HotlineClient reply in response to \(context.type)") - } - - let replyCallback = context?.callback - - guard packet.errorCode == 0 else { - let errorField: HotlineTransactionField? = packet.getField(type: .errorText) - replyCallback?(packet, .error(packet.errorCode, errorField?.getString())) - return - } - - replyCallback?(packet, nil) - } - - // MARK: - Messages - - @MainActor func sendLogin(login: String, password: String, username: String, iconID: UInt16, callback: ((HotlineTransactionError?, String?, UInt16?) -> Void)?) { - var t = HotlineTransaction(type: .login) - t.setFieldEncodedString(type: .userLogin, val: login) - t.setFieldEncodedString(type: .userPassword, val: password) - t.setFieldUInt16(type: .userIconID, val: iconID) - t.setFieldString(type: .userName, val: username) - t.setFieldUInt32(type: .versionNumber, val: 123) - - self.sendPacket(t) { [weak self] reply, err in - self?.updateConnectionStatus(.loggedIn) - - var serverVersion: UInt16? - var serverName: String? - - if - let serverVersionField = reply.getField(type: .versionNumber), - let serverVersionValue = serverVersionField.getUInt16() { - serverVersion = serverVersionValue - print("SERVER VERSION: \(serverVersionValue)") - } - - if - let serverNameField = reply.getField(type: .serverName), - let serverNameValue = serverNameField.getString() { - serverName = serverNameValue - print("SERVER NAME: \(serverNameValue)") - } - - callback?(err, serverName, serverVersion) - } - } - - @MainActor func sendSetClientUserInfo(username: String, iconID: UInt16, options: HotlineUserOptions = [], autoresponse: String? = nil) { - var t = HotlineTransaction(type: .setClientUserInfo) - t.setFieldString(type: .userName, val: username) - t.setFieldUInt16(type: .userIconID, val: iconID) - t.setFieldUInt16(type: .options, val: options.rawValue) - if let text = autoresponse { - t.setFieldString(type: .automaticResponse, val: text) - } - - self.sendPacket(t) - } - - @MainActor func sendAgree(username: String, iconID: UInt16, options: HotlineUserOptions) { - let t = HotlineTransaction(type: .agreed) -// t.setFieldString(type: .userName, val: username) -// t.setFieldUInt16(type: .userIconID, val: iconID) -// t.setFieldUInt8(type: .options, val: options.rawValue) - self.sendPacket(t) - } - - @MainActor func sendChat(message: String, encoding: String.Encoding = .utf8, announce: Bool = false) { - var t = HotlineTransaction(type: .sendChat) - t.setFieldString(type: .data, val: message, encoding: encoding) - t.setFieldUInt16(type: .chatOptions, val: announce ? 1 : 0) - self.sendPacket(t) - } - - @MainActor func sendInstantMessage(message: String, userID: UInt16, encoding: String.Encoding = .utf8) { - var t = HotlineTransaction(type: .sendInstantMessage) - t.setFieldUInt16(type: .userID, val: userID) - t.setFieldUInt32(type: .options, val: 1) - t.setFieldString(type: .data, val: message, encoding: encoding) - self.sendPacket(t) - } - - @MainActor func sendGetUserList() { - let t = HotlineTransaction(type: .getUserNameList) - self.sendPacket(t) { [weak self] reply, err in - var newUsers: [UInt16:HotlineUser] = [:] - var newUserList: [HotlineUser] = [] - for u in reply.getFieldList(type: .userNameWithInfo) { - let user = u.getUser() - newUsers[user.id] = user - newUserList.append(user) - } - self?.delegate?.hotlineReceivedUserList(users: newUserList) - } - } - - @MainActor func sendGetMessageBoard(callback: ((HotlineTransactionError?, [String]) -> Void)?) { - let t = HotlineTransaction(type: .getMessageBoard) - self.sendPacket(t) { reply, err in - guard err == nil, - let textField = reply.getField(type: .data), - let text = textField.getString() else { - callback?(err, []) - return - } - - var messages: [String] = [] - let matches = text.matches(of: RegularExpressions.messageBoardDivider) - var start = text.startIndex - - if matches.count > 0 { - for match in matches { - let range = match.range - let messageText = String(text[start.. 0 else { - return - } - - var t = HotlineTransaction(type: .oldPostNews) - t.setFieldString(type: .data, val: text.convertingLineEndings(to: .cr), encoding: .macOSRoman) - self.sendPacket(t) - } - - @MainActor func sendGetNewsCategories(path: [String] = [], callback: (([HotlineNewsCategory]) -> Void)?) { - var t = HotlineTransaction(type: .getNewsCategoryNameList) - if !path.isEmpty { - t.setFieldPath(type: .newsPath, val: path) - } - - self.sendPacket(t) { reply, err in - var categories: [HotlineNewsCategory] = [] - for categoryListItem in reply.getFieldList(type: .newsCategoryListData15) { - var c = categoryListItem.getNewsCategory() - c.path = path + [c.name] - categories.append(c) - } - callback?(categories) - } - } - - @MainActor func sendGetNewsArticle(id articleID: UInt32, path: [String], flavor: String, callback: ((String?) -> Void)? = nil) { - var t = HotlineTransaction(type: .getNewsArticleData) - t.setFieldPath(type: .newsPath, val: path) - t.setFieldUInt32(type: .newsArticleID, val: articleID) - t.setFieldString(type: .newsArticleDataFlavor, val: flavor, encoding: .ascii) - - self.sendPacket(t) { reply, err in - guard err == nil, - let articleData = reply.getField(type: .newsArticleData), - let articleString = articleData.getString() else { - callback?(nil) - return - } - - callback?(articleString) - } - } - - @MainActor func postNewsArticle(title: String, text: String, path: [String] = [], parentID: UInt32 = 0, callback: ((Bool) -> Void)? = nil) { - guard !path.isEmpty else { - callback?(false) - return - } - - var t = HotlineTransaction(type: .postNewsArticle) - t.setFieldPath(type: .newsPath, val: path) - t.setFieldUInt32(type: .newsArticleID, val: parentID) - t.setFieldString(type: .newsArticleTitle, val: title) - t.setFieldString(type: .newsArticleDataFlavor, val: "text/plain") - t.setFieldUInt32(type: .newsArticleFlags, val: 0) - t.setFieldString(type: .newsArticleData, val: text.convertingLineEndings(to: .cr)) - - print("HotlineClient postings \(title) under \(parentID)") - - self.sendPacket(t) { reply, err in - guard err == nil else { - callback?(false) - return - } - callback?(true) - } - } - - @MainActor func sendGetAccounts(callback: (([HotlineAccount]) -> Void)? = nil) { - let t = HotlineTransaction(type: .getAccounts) - - self.sendPacket(t) { reply, err in - guard err == nil else { - callback?([]) - return - } - - let accountFields = reply.getFieldList(type: .data) - - var accounts: [HotlineAccount] = [] - for data in accountFields { - accounts.append(data.getAcccount()) - } - - accounts.sort { $0.login < $1.login } - - callback?(accounts) - } - } - - @MainActor func sendGetNewsArticles(path: [String] = [], callback: (([HotlineNewsArticle]) -> Void)? = nil) { - var t = HotlineTransaction(type: .getNewsArticleNameList) - if !path.isEmpty { - t.setFieldPath(type: .newsPath, val: path) - } - self.sendPacket(t) { reply, err in - guard err == nil, - let articleData = reply.getField(type: .newsArticleListData) else { - callback?([]) - return - } - - var articles: [HotlineNewsArticle] = [] - let newsList = articleData.getNewsList() - for art in newsList.articles { - var blah = art - blah.path = path - articles.append(blah) - } - - callback?(articles) - } - } - - @MainActor func sendGetFileList(path: [String] = [], suppressErrors: Bool = false, callback: (([HotlineFile]) -> Void)? = nil) { - var t = HotlineTransaction(type: .getFileNameList) - if !path.isEmpty { - t.setFieldPath(type: .filePath, val: path) - } - - self.sendPacket(t, suppressErrors: suppressErrors) { reply, err in - guard err == nil else { - callback?([]) - return - } - - var files: [HotlineFile] = [] - for fi in reply.getFieldList(type: .fileNameWithInfo) { - let file = fi.getFile() - file.path = path + [file.name] - files.append(file) - } - - callback?(files) - } - } - - @MainActor func sendDeleteFile(name fileName: String, path filePath: [String], callback: ((Bool) -> Void)? = nil) { - var t = HotlineTransaction(type: .deleteFile) - t.setFieldString(type: .fileName, val: fileName) - t.setFieldPath(type: .filePath, val: filePath) - self.sendPacket(t) { reply, err in - callback?(err == nil) - } - } - - @MainActor func sendGetFileInfo(name fileName: String, path filePath: [String], callback: ((FileDetails?) -> Void)? = nil) { - var t = HotlineTransaction(type: .getFileInfo) - t.setFieldString(type: .fileName, val: fileName) - t.setFieldPath(type: .filePath, val: filePath) - - self.sendPacket(t) { reply, err in - guard err == nil, - let fileName = reply.getField(type: .fileName)?.getString(), - let fileCreator = reply.getField(type: .fileCreatorString)?.getString(), - let fileType = reply.getField(type: .fileTypeString)?.getString(), - let _ = reply.getField(type: .fileTypeString)?.getString(), - let fileCreateDate = reply.getField(type: .fileCreateDate)?.data.readDate(at: 0), - let fileModifyDate = reply.getField(type: .fileModifyDate)?.data.readDate(at: 0) - else { - callback?(nil) - return - } - - - // Size field is not included in server reply for folders - let fileSize = reply.getField(type: .fileSize)?.getInteger() ?? 0 - - // Comment field is not included for if no comment present - let fileComment = reply.getField(type: .fileComment)?.getString() ?? "" - - callback?(FileDetails(name: fileName, path: filePath, size: fileSize, comment: fileComment, type: fileType, creator: fileCreator, - created: fileCreateDate, modified: fileModifyDate)) - } - } - - - @MainActor func sendCreateUser(name: String, login: String, password: String?, access: uint64) { - var t = HotlineTransaction(type: .newUser) - - t.setFieldString(type: .userName, val: name) - t.setFieldEncodedString(type: .userLogin, val: login) - t.setFieldUInt64(type: .userAccess, val: access) - - if let password { - t.setFieldEncodedString(type: .userPassword, val: password) - } - - self.sendPacket(t) - // TODO: handle errors - } - - @MainActor func sendSetUser(name: String, login: String, newLogin: String?, password: String?, access: uint64) { - var t = HotlineTransaction(type: .setUser) - t.setFieldString(type: .userName, val: name) - t.setFieldUInt64(type: .userAccess, val: access) - - if let newLogin { - t.setFieldEncodedString(type: .data, val: login) - t.setFieldEncodedString(type: .userLogin, val: newLogin) - } else { - t.setFieldEncodedString(type: .userLogin, val: login) - } - - // In the setUser transaction, there are 3 possibilities for the password field: - // 1. If the password was not modified, the password field is sent with a zero byte. - if password == nil { - t.setFieldUInt8(type: .userPassword, val: 0) - } - - // 2. If the transaction should update the password, the password field is sent with the new password. - if let password, password != "" { - t.setFieldEncodedString(type: .userPassword, val: password) - } - - // 3) If the transaction should remove the password, the password field is omitted from the transaction. - self.sendPacket(t) - // TODO: handle errors - } - - @MainActor func sendDeleteUser(login: String) { - var t = HotlineTransaction(type: .deleteUser) - t.setFieldEncodedString(type: .userLogin, val: login) - - self.sendPacket(t) - // TODO: handle errors - } - - @MainActor func sendSetFileInfo(fileName: String, path filePath: [String], fileNewName: String?, comment: String?, encoding: String.Encoding = .utf8) { - var t = HotlineTransaction(type: .setFileInfo) - t.setFieldString(type: .fileName, val: fileName, encoding: encoding) - t.setFieldPath(type: .filePath, val: filePath) - - if fileNewName != nil { - t.setFieldString(type: .fileNewName, val: fileNewName!, encoding: encoding) - } - - if comment != nil { - t.setFieldString(type: .fileComment, val: comment!, encoding: encoding) - } - - self.sendPacket(t) - } - - @MainActor func sendDownloadFile(name fileName: String, path filePath: [String], preview: Bool = false, callback: ((Bool, UInt32?, Int?, Int?, Int?) -> Void)? = nil) { - var t = HotlineTransaction(type: .downloadFile) - t.setFieldString(type: .fileName, val: fileName) - t.setFieldPath(type: .filePath, val: filePath) - if preview { - t.setFieldUInt32(type: .fileTransferOptions, val: 2) - } - - self.sendPacket(t) { reply, err in - guard err == nil, - let transferSizeField = reply.getField(type: .transferSize), - let transferSize = transferSizeField.getInteger(), - let transferReferenceField = reply.getField(type: .referenceNumber), - let referenceNumber = transferReferenceField.getUInt32() else { - callback?(false, nil, nil, nil, nil) - return - } - - let transferFileSizeField = reply.getField(type: .fileSize) - let transferFileSize = transferFileSizeField?.getInteger() - let transferWaitingCountField = reply.getField(type: .waitingCount) - let transferWaitingCount = transferWaitingCountField?.getInteger() - - callback?(true, referenceNumber, transferSize, transferFileSize ?? transferSize, transferWaitingCount) - } - } - - @MainActor func sendUploadFile(name fileName: String, path filePath: [String], callback: ((Bool, UInt32?) -> Void)? = nil) { - var t = HotlineTransaction(type: .uploadFile) - t.setFieldString(type: .fileName, val: fileName) - t.setFieldPath(type: .filePath, val: filePath) - - self.sendPacket(t) { reply, err in - guard err == nil, - let transferReferenceField = reply.getField(type: .referenceNumber), - let referenceNumber = transferReferenceField.getUInt32() else { - callback?(false, nil) - return - } - - callback?(true, referenceNumber) - } - } - - @MainActor func sendDownloadFolder(name folderName: String, path folderPath: [String], callback: ((Bool, UInt32?, Int?, Int?, Int?) -> Void)? = nil) { - var t = HotlineTransaction(type: .downloadFolder) - t.setFieldString(type: .fileName, val: folderName) - t.setFieldPath(type: .filePath, val: folderPath) - - self.sendPacket(t) { reply, err in - guard err == nil, - let transferSizeField = reply.getField(type: .transferSize), - let transferSize = transferSizeField.getInteger(), - let transferReferenceField = reply.getField(type: .referenceNumber), - let referenceNumber = transferReferenceField.getUInt32() else { - callback?(false, nil, nil, nil, nil) - return - } - - let folderItemCountField = reply.getField(type: .folderItemCount) - let folderItemCount = folderItemCountField?.getInteger() - let transferWaitingCountField = reply.getField(type: .waitingCount) - let transferWaitingCount = transferWaitingCountField?.getInteger() - - callback?(true, referenceNumber, transferSize, folderItemCount, transferWaitingCount) - } - } - - @MainActor func sendDownloadBanner(callback: ((Bool, UInt32?, Int?) -> Void)? = nil) { - let t = HotlineTransaction(type: .downloadBanner) - - self.sendPacket(t) { reply, err in - guard err == nil, - let transferSizeField = reply.getField(type: .transferSize), - let transferSize = transferSizeField.getInteger(), - let transferReferenceField = reply.getField(type: .referenceNumber), - let referenceNumber = transferReferenceField.getUInt32() else { - callback?(false, nil, nil) - return - } - - callback?(true, referenceNumber, transferSize) - } - } - - @MainActor private func sendKeepAlive() { - print("HotlineClient: Sending keep alive") - if let v = self.serverVersion, v >= 185 { - let t = HotlineTransaction(type: .connectionKeepAlive) - self.sendPacket(t) - } - else { - let t = HotlineTransaction(type: .getUserNameList) - self.sendPacket(t) - } - } - - - // MARK: - Utility - - @MainActor private func updateConnectionStatus(_ status: HotlineClientStatus) { - self.connectionStatus = status - self.delegate?.hotlineStatusChanged(status: status) - } - -} diff --git a/Hotline/Hotline/HotlineTransferClient.swift b/Hotline/Hotline/HotlineTransferClient.swift index 30fe694..156b2b0 100644 --- a/Hotline/Hotline/HotlineTransferClient.swift +++ b/Hotline/Hotline/HotlineTransferClient.swift @@ -284,1837 +284,3 @@ struct HotlineFileInfoFork { return data } } - - -//protocol HotlineTransferDelegate: AnyObject { -// @MainActor func hotlineTransferStatusChanged(client: HotlineTransferClient, reference: UInt32, status: HotlineTransferStatus, timeRemaining: TimeInterval) -//} - -//protocol HotlineFileDownloadClientDelegate: HotlineTransferDelegate { -// @MainActor func hotlineFileDownloadReceivedInfo(client: HotlineFileDownloadClient, reference: UInt32, info: HotlineFileInfoFork) -// @MainActor func hotlineFileDownloadComplete(client: HotlineFileDownloadClient, reference: UInt32, at: URL) -//} - -//protocol HotlineFilePreviewClientDelegate: HotlineTransferDelegate { -// @MainActor func hotlineFilePreviewComplete(client: HotlineFilePreviewClient, reference: UInt32, data: Data) -//} - -//protocol HotlineFileUploadClientDelegate: HotlineTransferDelegate { -// @MainActor func hotlineFileUploadComplete(client: HotlineFileUploadClient, reference: UInt32) -//} - -//protocol HotlineFolderDownloadClientDelegate: HotlineTransferDelegate { -// @MainActor func hotlineFolderDownloadReceivedFileInfo(client: HotlineFolderDownloadClient, reference: UInt32, fileName: String, itemNumber: Int, totalItems: Int) -// @MainActor func hotlineFolderDownloadComplete(client: HotlineFolderDownloadClient, reference: UInt32, at: URL) -//} - -//enum HotlineFileTransferStage: Int { -// case fileHeader = 1 -// case fileForkHeader = 2 -// case fileInfoFork = 3 -// case fileDataFork = 4 -// case fileResourceFork = 5 -// case fileUnsupportedFork = 6 -//} - -//enum HotlineFileUploadStage: Int { -// case magic = 1 -// case fileHeader = 2 -// case fileInfoForkHeader = 3 -// case fileInfoFork = 4 -// case fileDataForkHeader = 5 -// case fileDataFork = 6 -// case fileResourceForkHeader = 7 -// case fileResourceFork = 8 -// case fileComplete = 9 -//} - -//enum HotlineFolderDownloadStage: Int { -// case itemHeader = 0 // Read 2-byte length + item header -// case waitingForFileSize = 1 // Read 4-byte file size before FILP -// case fileHeader = 2 -// case fileForkHeader = 3 -// case fileInfoFork = 4 -// case fileDataFork = 5 -// case fileResourceFork = 6 -// case fileUnsupportedFork = 7 -//} - - -// MARK: - - -//class HotlineFileUploadClient: HotlineTransferClient { -// let serverAddress: NWEndpoint.Host -// let serverPort: NWEndpoint.Port -// let referenceNumber: UInt32 -// -// weak var delegate: HotlineFileUploadClientDelegate? = nil -// -// private var connection: NWConnection? -// private var stage: HotlineFileUploadStage = .magic -// private var payloadSize: UInt32 = 0 -// private let fileURL: URL -// private let fileResourceURL: URL? -// private var fileHandle: FileHandle? = nil -// private var bytesSent: Int = 0 -// private let infoForkData: Data -// private let dataForkSize: UInt32 -// private let resourceForkSize: UInt32 -// -// var status: HotlineTransferStatus = .unconnected { -// didSet { -// DispatchQueue.main.async { -// self.delegate?.hotlineTransferStatusChanged(client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0) -// } -// } -// } -// -// init?(upload fileURL: URL, address: String, port: UInt16, reference: UInt32) { -// guard let payloadSize = FileManager.default.getFlattenedFileSize(fileURL) else { -// return nil -// } -// -// guard let infoFork = HotlineFileInfoFork(file: fileURL) else { -// return nil -// } -// -// guard let forkSizes = try? FileManager.default.getFileForkSizes(fileURL) else { -// return nil -// } -// -// self.serverAddress = NWEndpoint.Host(address) -// self.serverPort = NWEndpoint.Port(rawValue: port + 1)! -// self.referenceNumber = reference -// self.stage = .magic -// self.payloadSize = UInt32(payloadSize) -// self.fileURL = fileURL -// self.infoForkData = infoFork.data() -// self.dataForkSize = forkSizes.dataForkSize -// self.resourceForkSize = forkSizes.resourceForkSize -// if forkSizes.resourceForkSize > 0 { -// self.fileResourceURL = fileURL.urlForResourceFork() -// } -// else { -// self.fileResourceURL = nil -// } -// } -// -// deinit { -// self.invalidate() -// } -// -// func start() { -// guard self.status == .unconnected else { -// return -// } -// -// let _ = self.fileURL.startAccessingSecurityScopedResource() -// let _ = self.fileResourceURL?.stopAccessingSecurityScopedResource() -// -// self.bytesSent = 0 -// self.connect() -// } -// -// func cancel() { -// self.delegate = nil -// -// if self.status == .unconnected { -// return -// } -// -// self.invalidate() -// -// print("HotlineFileUploadClient: Cancelled upload") -// } -// -// private func connect() { -// self.connection = NWConnection(host: self.serverAddress, port: self.serverPort, using: .tcp) -// self.connection?.stateUpdateHandler = { [weak self] (newState: NWConnection.State) in -// switch newState { -// case .ready: -// self?.status = .connected -// self?.stage = .magic -// self?.send() -// case .waiting(let err): -// print("HotlineFileClient: Waiting", err) -// case .cancelled: -// print("HotlineFileClient: Cancelled") -// self?.invalidate() -// case .failed(let err): -// print("HotlineFileClient: Connection error \(err)") -// switch self?.status { -// case .connecting: -// print("HotlineFileClient: Failed to connect to file transfer server.") -// self?.invalidate() -// self?.status = .failed(.failedToConnect) -// case .connected, .progress(_): -// print("HotlineFileClient: Failed to finish transfer.") -// self?.invalidate() -// self?.status = .failed(.failedToUpload) -// case .completing: -// print("HotlineFileClient: Completed.") -// self?.invalidate() -// self?.status = .completed -// DispatchQueue.main.async { [weak self] in -// if let s = self { -// s.delegate?.hotlineFileUploadComplete(client: s, reference: s.referenceNumber) -// } -// } -// case .completed: -// self?.invalidate() -// default: -// break -// } -// default: -// return -// } -// } -// -// self.status = .connecting -// self.connection?.start(queue: .global()) -// } -// -// private func invalidate() { -// if let c = self.connection { -// c.stateUpdateHandler = nil -// c.cancel() -// -// self.connection = nil -// } -// -// self.stage = .magic -// -// if let fh = self.fileHandle { -// try? fh.close() -// self.fileHandle = nil -// } -// -// self.fileURL.stopAccessingSecurityScopedResource() -// self.fileResourceURL?.stopAccessingSecurityScopedResource() -// } -// -// private func sendComplete() { -// guard let c = self.connection else { -// self.invalidate() -// print("HotlineFileUploadClient: invalid connection to send data.") -// return -// } -// -// self.status = .completing -// -// c.send(content: nil, contentContext: .finalMessage, completion: .contentProcessed({ error in -// })) -// } -// -// private func sendFileData(_ data: Data) { -// guard let c = self.connection else { -// self.invalidate() -// -// print("HotlineFileUploadClient: invalid connection to send data.") -// return -// } -// -// let dataSent: Int = data.count -// c.send(content: data, completion: .contentProcessed({ [weak self] error in -// guard let client = self, -// error == nil else { -// self?.status = .failed(.failedToConnect) -// self?.invalidate() -// return -// } -// -// -// client.bytesSent += dataSent -// client.status = .progress(Double(client.bytesSent) / Double(client.payloadSize)) -// -// client.send() -// })) -// } -// -// private func send() { -// guard let _ = self.connection else { -// self.invalidate() -// print("HotlineFileUploadClient: Invalid connection to send.") -// return -// } -// -// switch self.stage { -// case .magic: -// print("Upload: Starting upload for \(self.fileURL)") -// print("Upload: Sending magic") -// self.status = .progress(0.0) -// -// let magicData = Data(endian: .big) { -// "HTXF".fourCharCode() -// self.referenceNumber -// self.payloadSize -// UInt32.zero -// } -// // var magicData = Data() -// // magicData.appendUInt32("HTXF".fourCharCode()) -// // magicData.appendUInt32(self.referenceNumber) -// // magicData.appendUInt32(self.payloadSize) -// // magicData.appendUInt32(0) -// self.stage = .fileHeader -// self.sendFileData(magicData) -// -// case .fileHeader: -// print("Upload: Sending file header") -// if let header = HotlineFileHeader(file: self.fileURL) { -// self.stage = .fileInfoForkHeader -// self.sendFileData(header.data()) -// } -// -// case .fileInfoForkHeader: -// print("Upload: Sending info fork header") -// let header = HotlineFileForkHeader(type: HotlineFileForkType.info.rawValue, dataSize: UInt32(self.infoForkData.count)) -// self.stage = .fileInfoFork -// self.sendFileData(header.data()) -// -// case .fileInfoFork: -// print("Upload: Sending info fork") -// self.stage = .fileDataForkHeader -// self.sendFileData(self.infoForkData) -// -// case .fileDataForkHeader: -// guard self.dataForkSize > 0 else { -// print("Upload: Data fork empty, skipping to resource fork") -// self.stage = .fileResourceForkHeader -// fallthrough -// } -// -// do { -// let fh = try FileHandle(forReadingFrom: self.fileURL) -// self.fileHandle = fh -// self.stage = .fileDataFork -// -// let header = HotlineFileForkHeader(type: HotlineFileForkType.data.rawValue, dataSize: self.dataForkSize) -// -// print("Upload: Sending data fork header \(self.dataForkSize)") -// self.sendFileData(header.data()) -// } -// catch { -// print("Upload: Error opening data fork", error) -// self.invalidate() -// return -// } -// -// case .fileDataFork: -// guard self.dataForkSize > 0, -// let fh = self.fileHandle else { -// print("Upload: Data fork empty, skipping to resource fork") -// self.stage = .fileResourceForkHeader -// try? self.fileHandle?.close() -// self.fileHandle = nil -// fallthrough -// } -// -// do { -// let fileData = try fh.read(upToCount: 4 * 1024) -// if fileData == nil || fileData?.isEmpty == true { -// print("Upload: Finished data fork") -// self.stage = .fileResourceForkHeader -// try? fh.close() -// self.fileHandle = nil -// fallthrough -// } -// -// print("Upload: Sending data Fork \(String(describing: fileData?.count))") -// self.sendFileData(fileData!) -// } -// catch { -// self.invalidate() -// print("Upload: Error reading data fork", error) -// return -// } -// -// case .fileResourceForkHeader: -// guard self.resourceForkSize > 0, -// let resourceURL = self.fileResourceURL else { -// print("Upload: Skipping resource fork header") -// self.stage = .fileComplete -// fallthrough -// } -// -// print("Upload: Sending resource fork header") -// guard let fh = try? FileHandle(forReadingFrom: resourceURL) else { -// print("Upload: Error reading resource fork") -// self.invalidate() -// return -// } -// -// let header = HotlineFileForkHeader(type: HotlineFileForkType.resource.rawValue, dataSize: self.resourceForkSize) -// -// self.fileHandle = fh -// self.stage = .fileResourceFork -// self.sendFileData(header.data()) -// -// case .fileResourceFork: -// guard self.resourceForkSize > 0, -// let fh = self.fileHandle else { -// print("Upload: Resource fork empty, skipping to completion") -// self.stage = .fileComplete -// try? self.fileHandle?.close() -// self.fileHandle = nil -// fallthrough -// } -// -// do { -// let resourceData = try fh.read(upToCount: 4 * 1024) -// if resourceData == nil || resourceData?.isEmpty == true { -// print("Upload: Finished resource fork") -// self.stage = .fileComplete -// try? self.fileHandle?.close() -// self.fileHandle = nil -// fallthrough -// } -// -// print("Upload: Sending resource fork \(String(describing: resourceData?.count))") -// self.sendFileData(resourceData!) -// } -// catch { -// self.invalidate() -// print("Upload: Error reading resource fork", error) -// return -// } -// break -// -// case .fileComplete: -// print("Upload: Complete!") -// self.sendComplete() -// } -// } -//} - -// MARK: - -// -//class HotlineFilePreviewClient: HotlineTransferClient { -// let serverAddress: NWEndpoint.Host -// let serverPort: NWEndpoint.Port -// let referenceNumber: UInt32 -// let referenceDataSize: UInt32 -// -// weak var delegate: HotlineFilePreviewClientDelegate? = nil -// -// var status: HotlineTransferStatus = .unconnected { -// didSet { -// DispatchQueue.main.async { -// self.delegate?.hotlineTransferStatusChanged(client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0) -// } -// } -// } -// -// private var downloadTask: Task? -// -// init(address: String, port: UInt16, reference: UInt32, size: UInt32) { -// self.serverAddress = NWEndpoint.Host(address) -// self.serverPort = NWEndpoint.Port(rawValue: port + 1)! -// self.referenceNumber = reference -// self.referenceDataSize = size -// } -// -// deinit { -// self.downloadTask?.cancel() -// } -// -// func start() { -// guard status == .unconnected else { -// return -// } -// -// self.downloadTask = Task { -// await self.download() -// } -// } -// -// func cancel() { -// self.downloadTask?.cancel() -// self.downloadTask = nil -// self.delegate = nil -// -// print("HotlineFilePreviewClient: Cancelled preview transfer") -// } -// -// private func download() async { -// await MainActor.run { self.status = .connecting } -// -// do { -// // Connect to file transfer server (already includes +1 in serverPort from init) -// let socket = try await NetSocketNew.connect( -// host: self.serverAddress, -// port: self.serverPort, -// tls: .disabled -// ) -// defer { Task { await socket.close() } } -// -// await MainActor.run { self.status = .connected } -// -// // Send magic header -// let headerData = Data(endian: .big) { -// "HTXF".fourCharCode() -// self.referenceNumber -// UInt32.zero -// UInt32.zero -// } -// try await socket.write(headerData) -// -// await MainActor.run { self.status = .progress(0.0) } -// -// // Download file data with progress updates -// let fileData = try await socket.read(Int(self.referenceDataSize)) { current, total in -// Task { @MainActor [weak self] in -// guard let self else { return } -// self.status = .progress(Double(current) / Double(total)) -// } -// } -// -// print("HotlineFilePreviewClient: Complete") -// await MainActor.run { self.status = .completed } -// -// // Notify delegate -// let reference = self.referenceNumber -// await MainActor.run { -// self.delegate?.hotlineFilePreviewComplete(client: self, reference: reference, data: fileData) -// } -// -// } catch is CancellationError { -// // Already handled in cancel() -// return -// } catch { -// print("HotlineFilePreviewClient: Download failed: \(error)") -// -// let failureStatus: HotlineTransferStatus = await MainActor.run { self.status } -// -// if failureStatus == .connecting { -// await MainActor.run { self.status = .failed(.failedToConnect) } -// } else { -// await MainActor.run { self.status = .failed(.failedToDownload) } -// } -// } -// } -// -// // status mutations are centralized via MainActor.run calls above -//} - -// MARK: - Async Preview Client (New) - -//enum HotlineFilePreviewClientNew { -// typealias ProgressHandler = @Sendable (NetSocketNew.FileProgress) -> Void -// -// struct Configuration { -// /// Chunk size used when reading the preview data stream. -// var chunkSize: Int = 256 * 1024 -// } -// -// /// Download preview data directly from the transfer server. -// /// - Parameters: -// /// - address: Hostname/IP of the Hotline server (preview runs on port+1). -// /// - port: Hotline base port. -// /// - reference: Transfer reference number returned by the server. -// /// - size: Expected payload size in bytes. -// /// - configuration: Optional tuning knobs (currently chunk size). -// /// - progress: Optional callback invoked as bytes stream in. -// /// - Returns: Raw preview data. -// static func download( -// address: String, -// port: UInt16, -// reference: UInt32, -// size: UInt32, -// configuration: Configuration = .init(), -// progress: ProgressHandler? = nil -// ) async throws -> Data { -// let host = NWEndpoint.Host(address) -// guard let transferPort = NWEndpoint.Port(rawValue: port + 1) else { -// throw NetSocketError.invalidPort -// } -// -// let socket = try await NetSocketNew.connect(host: host, port: transferPort, tls: .disabled) -// defer { Task { await socket.close() } } -// -// let header = Data(endian: .big) { -// "HTXF".fourCharCode() -// reference -// UInt32.zero -// UInt32.zero -// } -// -// try await socket.write(header) -// -// guard size > 0 else { -// return Data() -// } -// -// return try await socket.read(Int(size), chunkSize: configuration.chunkSize) { current, total in -// guard let progress else { return } -// let info = NetSocketNew.FileProgress(sent: current, total: total) -// progress(info) -// } -// } -//} - -// MARK: - - -//class HotlineFileDownloadClient: HotlineTransferClient { -// let serverAddress: NWEndpoint.Host -// let serverPort: NWEndpoint.Port -// let referenceNumber: UInt32 -// -// private var connection: NWConnection? -// private var transferStage: HotlineFileTransferStage = .fileHeader -// -// weak var delegate: HotlineFileDownloadClientDelegate? = nil -// -// var status: HotlineTransferStatus = .unconnected { -// didSet { -// DispatchQueue.main.async { -// self.delegate?.hotlineTransferStatusChanged(client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0) -// } -// } -// } -// -// private let referenceDataSize: UInt32 -// private var fileBytes = Data() -// private var fileResourceBytes = Data() -// -// private var fileHeader: HotlineFileHeader? = nil -// private var fileCurrentForkHeader: HotlineFileForkHeader? = nil -// private var fileCurrentForkBytesLeft: Int = 0 -// private var fileInfoFork: HotlineFileInfoFork? = nil -// private var fileHandle: FileHandle? = nil -// private var filePath: String? = nil -// private var fileBytesTransferred: Int = 0 -// private var fileProgress: Progress -// -// init(address: String, port: UInt16, reference: UInt32, size: UInt32) { -// self.serverAddress = NWEndpoint.Host(address) -// self.serverPort = NWEndpoint.Port(rawValue: port + 1)! -// self.referenceNumber = reference -// self.referenceDataSize = size -// self.transferStage = .fileHeader -// self.fileProgress = Progress(totalUnitCount: Int64(size)) -// } -// -// deinit { -// self.invalidate() -// } -// -// func start() { -// guard self.status == .unconnected else { -// return -// } -// -// self.filePath = nil -// self.connect() -// } -// -// func start(to fileURL: URL) { -// guard self.status == .unconnected else { -// return -// } -// -// self.filePath = fileURL.path -// self.connect() -// } -// -// func cancel() { -// self.delegate = nil -// -// if self.status == .unconnected { -// return -// } -// -// // Close file before we try to potentionally delete it. -// if let fh = self.fileHandle { -// try? fh.close() -// self.fileHandle = nil -// } -// -// if let downloadPath = self.filePath { -// print("HotlineFileClient: Deleting file fragment at", downloadPath) -// if FileManager.default.isDeletableFile(atPath: downloadPath) { -// try? FileManager.default.removeItem(atPath: downloadPath) -// } -// self.filePath = nil -// } -// -// self.invalidate() -// -// print("HotlineFileClient: Cancelled transfer") -// } -// -// private func connect() { -// self.connection = NWConnection(host: self.serverAddress, port: self.serverPort, using: .tcp) -// self.connection?.stateUpdateHandler = { [weak self] (newState: NWConnection.State) in -// switch newState { -// case .ready: -// self?.status = .connected -// self?.sendMagic() -// case .waiting(let err): -// print("HotlineFileClient: Waiting", err) -// case .cancelled: -// print("HotlineFileClient: Cancelled") -// self?.invalidate() -// case .failed(let err): -// print("HotlineFileClient: Connection error \(err)") -// switch self?.status { -// case .connecting: -// print("HotlineFileClient: Failed to connect to file transfer server.") -// self?.invalidate() -// self?.status = .failed(.failedToConnect) -// case .connected, .progress(_): -// print("HotlineFileClient: Failed to finish transfer.") -// self?.invalidate() -// self?.status = .failed(.failedToDownload) -// default: -// break -// } -// default: -// return -// } -// } -// -// self.status = .connecting -// self.connection?.start(queue: .global()) -// } -// -// func invalidate() { -// if let c = self.connection { -// c.stateUpdateHandler = nil -// c.cancel() -// -// self.connection = nil -// } -// -// self.fileBytes = Data() -// -// if let fh = self.fileHandle { -// try? fh.close() -// self.fileHandle = nil -// } -// -// self.fileProgress.unpublish() -// } -// -// private func sendMagic() { -// guard let c = connection, self.status == .connected else { -// self.invalidate() -// print("HotlineFileClient: invalid connection to send header.") -// return -// } -// -// let headerData = Data(endian: .big) { -// "HTXF".fourCharCode() -// self.referenceNumber -// UInt32.zero -// UInt32.zero -// } -// -// c.send(content: headerData, completion: .contentProcessed { [weak self] (error) in -// guard let self = self else { -// return -// } -// -// guard error == nil else { -// self.status = .failed(.failedToConnect) -// self.invalidate() -// return -// } -// -// self.status = .progress(0.0) -// self.receiveFile() -// }) -// } -// -// private func receiveFile() { -// guard let c = self.connection else { -// return -// } -// -// c.receive(minimumIncompleteLength: 1, maximumLength: Int(UInt16.max)) { [weak self] (data, context, isComplete, error) in -// guard let self = self else { -// return -// } -// -// guard error == nil else { -// self.status = .failed(.failedToDownload) -// self.invalidate() -// return -// } -// -// if let newData = data, !newData.isEmpty { -// self.fileBytesTransferred += newData.count -// self.fileBytes.append(newData) -// self.fileProgress.completedUnitCount = Int64(self.fileBytesTransferred) -// self.status = .progress(Double(self.fileBytesTransferred) / Double(self.referenceDataSize)) -// } -// -// // See if we need header data still. -// var keepProcessing = false -// repeat { -// keepProcessing = false -// -// switch self.transferStage { -// case .fileHeader: -// if let header = HotlineFileHeader(from: self.fileBytes) { -// self.fileBytes.removeSubrange(0..= infoForkDataSize { -// let infoForkData = self.fileBytes.subdata(in: 0.. 0 { -// if let f = self.fileHandle { -// do { -// var dataToWrite = self.fileBytes -// -// if dataToWrite.count >= self.fileCurrentForkBytesLeft { -// dataToWrite = self.fileBytes.subdata(in: 0.. 0 { -// var dataToWrite = self.fileBytes -// -// if dataToWrite.count >= self.fileCurrentForkBytesLeft { -// dataToWrite = self.fileBytes.subdata(in: 0.. 0 { -// var dataToWrite = self.fileBytes -// -// if dataToWrite.count >= self.fileCurrentForkBytesLeft { -// dataToWrite = self.fileBytes.subdata(in: 0.. 0 { -// let _ = self.writeResourceFork() -// self.fileResourceBytes = Data() -// } -// -// self.status = .completed -// -// if let downloadPath = self.filePath { -// DispatchQueue.main.sync { -// print("POSTING DOWNLOAD FILE FINISHED", downloadPath) -// -// var downloadURL = URL(filePath: downloadPath) -// downloadURL.resolveSymlinksInPath() -// print("FINAL PATH", downloadURL.path) -// -// self.delegate?.hotlineFileDownloadComplete(client: self, reference: self.referenceNumber, at: downloadURL) -// -//#if os(macOS) -// // Bounce dock icon when download completes. Weird this is the only API to do so. -// DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path) -//#endif -// } -// } -// } -// } -// } -// -// private func writeResourceFork() -> Bool { -// guard let filePath = self.filePath else { -// return false -// } -// -// var resolvedFileURL = URL(filePath: filePath) -// resolvedFileURL.resolveSymlinksInPath() -// -// let resourceFilePath = resolvedFileURL.appendingPathComponent("..namedfork/rsrc") -// -// do { -// try self.fileResourceBytes.write(to: resourceFilePath) -// } -// catch { -// return false -// } -// -// return true -// } -// -// private func prepareDownloadFile(name: String) -> Bool { -// var filePath: String -// -// if self.filePath != nil { -// filePath = self.filePath! -// } -// else { -// let folderURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0] -// filePath = folderURL.generateUniqueFilePath(filename: name) -// } -// -// var fileAttributes: [FileAttributeKey: Any] = [:] -// if let creatorCode = self.fileInfoFork?.creator { -// fileAttributes[.hfsCreatorCode] = creatorCode as NSNumber -// } -// if let typeCode = self.fileInfoFork?.type { -// fileAttributes[.hfsTypeCode] = typeCode as NSNumber -// } -// if let createdDate = self.fileInfoFork?.createdDate { -// fileAttributes[.creationDate] = createdDate as NSDate -// } -// if let modifiedDate = self.fileInfoFork?.modifiedDate { -// fileAttributes[.modificationDate] = modifiedDate as NSDate -// } -// if let comment = self.fileInfoFork?.comment { -// if let commentPlistData = try? PropertyListSerialization.data(fromPropertyList: comment, format: .binary, options: 0) { -// fileAttributes[FileAttributeKey(rawValue: "NSFileExtendedAttributes")] = [ -// FileAttributeKey(rawValue: "com.apple.metadata:kMDItemFinderComment"): commentPlistData -// ] -// } -// } -// -// if FileManager.default.createFile(atPath: filePath, contents: nil, attributes: fileAttributes) { -// if let h = FileHandle(forWritingAtPath: filePath) { -// self.filePath = filePath -// self.fileHandle = h -// self.fileProgress.fileURL = URL(filePath: filePath).resolvingSymlinksInPath() -// self.fileProgress.fileOperationKind = .downloading -// self.fileProgress.publish() -// return true -// } -// } -// -// return false -// } -//} - -// MARK: - - - -// MARK: - - -//class HotlineFolderDownloadClient: HotlineTransferClient { -// let serverAddress: NWEndpoint.Host -// let serverPort: NWEndpoint.Port -// let referenceNumber: UInt32 -// -// private var connection: NWConnection? -// private var transferStage: HotlineFolderDownloadStage = .fileHeader -// -// weak var delegate: HotlineFolderDownloadClientDelegate? = nil -// -// var status: HotlineTransferStatus = .unconnected { -// didSet { -// DispatchQueue.main.async { -// self.delegate?.hotlineTransferStatusChanged(client: self, reference: self.referenceNumber, status: self.status, timeRemaining: 0.0) -// } -// } -// } -// -// private let referenceDataSize: UInt32 -// private let folderItemCount: Int -// private var fileBytes = Data() -// private var fileResourceBytes = Data() -// -// private var fileHeader: HotlineFileHeader? = nil -// private var fileCurrentForkHeader: HotlineFileForkHeader? = nil -// private var fileCurrentForkBytesLeft: Int = 0 -// private var fileForksRemaining: Int = 0 -// private var currentFileSize: UInt32 = 0 // Size of current file from server -// private var currentFileBytesRead: Int = 0 // Bytes read for current file -// private var fileInfoFork: HotlineFileInfoFork? = nil -// private var fileHandle: FileHandle? = nil -// private var currentFilePath: String? = nil -// private var fileBytesTransferred: Int = 0 -// private var fileProgress: Progress -// -// private var currentItemNumber: Int = 0 -// private var completedItemCount: Int = 0 // Track actually completed files -// private var folderPath: String? = nil -// private var currentItemRelativePath: [String] = [] -// private var currentFileName: String? = nil -// -// private let FILP_MAGIC: UInt32 = "FILP".fourCharCode() -// -// init(address: String, port: UInt16, reference: UInt32, size: UInt32, itemCount: Int) { -// self.serverAddress = NWEndpoint.Host(address) -// self.serverPort = NWEndpoint.Port(rawValue: port + 1)! -// self.referenceNumber = reference -// self.referenceDataSize = size -// self.folderItemCount = itemCount -// self.transferStage = .fileHeader -// self.fileProgress = Progress(totalUnitCount: Int64(size)) -// } -// -// deinit { -// self.invalidate() -// } -// -// func start() { -// guard self.status == .unconnected else { -// return -// } -// -// self.folderPath = nil -// self.connect() -// } -// -// func start(to folderURL: URL) { -// print("HotlineFolderDownloadClient: start(to:) called with path: \(folderURL.path)") -// guard self.status == .unconnected else { -// print("HotlineFolderDownloadClient: Already connected, status: \(self.status)") -// return -// } -// -// self.folderPath = folderURL.path -// print("HotlineFolderDownloadClient: Calling connect()") -// self.connect() -// } -// -// func cancel() { -// self.delegate = nil -// -// if self.status == .unconnected { -// return -// } -// -// // Close file before we try to potentially delete it. -// if let fh = self.fileHandle { -// try? fh.close() -// self.fileHandle = nil -// } -// -// if let downloadPath = self.folderPath { -// print("HotlineFolderDownloadClient: Deleting folder fragment at", downloadPath) -// if FileManager.default.isDeletableFile(atPath: downloadPath) { -// try? FileManager.default.removeItem(atPath: downloadPath) -// } -// self.folderPath = nil -// } -// -// self.invalidate() -// -// print("HotlineFolderDownloadClient: Cancelled transfer") -// } -// -// private func connect() { -// print("HotlineFolderDownloadClient: connect() called, connecting to \(self.serverAddress):\(self.serverPort)") -// self.connection = NWConnection(host: self.serverAddress, port: self.serverPort, using: .tcp) -// print("HotlineFolderDownloadClient: NWConnection created") -// self.connection?.stateUpdateHandler = { [weak self] (newState: NWConnection.State) in -// switch newState { -// case .ready: -// print("HotlineFolderDownloadClient: Connection ready!") -// self?.status = .connected -// self?.sendMagic() -// case .waiting(let err): -// print("HotlineFolderDownloadClient: Waiting", err) -// case .cancelled: -// print("HotlineFolderDownloadClient: Cancelled") -// self?.invalidate() -// case .failed(let err): -// print("HotlineFolderDownloadClient: Connection error \(err)") -// switch self?.status { -// case .connecting: -// print("HotlineFolderDownloadClient: Failed to connect to file transfer server.") -// self?.invalidate() -// self?.status = .failed(.failedToConnect) -// case .connected, .progress(_): -// print("HotlineFolderDownloadClient: Failed to finish transfer.") -// self?.invalidate() -// self?.status = .failed(.failedToDownload) -// default: -// break -// } -// default: -// return -// } -// } -// -// self.status = .connecting -// self.connection?.start(queue: .global()) -// } -// -// func invalidate() { -// if let c = self.connection { -// c.stateUpdateHandler = nil -// c.cancel() -// -// self.connection = nil -// } -// -// self.fileBytes = Data() -// -// if let fh = self.fileHandle { -// try? fh.close() -// self.fileHandle = nil -// } -// -// self.fileProgress.unpublish() -// } -// -// private func 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) -// } -// -// /// Find and align the buffer to the start of a FILP header. -// /// Returns the number of bytes dropped. -// @discardableResult -// private func alignBufferToFILP() -> Int { -// // We need at least the 4-byte magic to try -// guard self.fileBytes.count >= 4 else { return 0 } -// let magicData = Data([0x46, 0x49, 0x4C, 0x50]) // "FILP" -// if self.fileBytes.starts(with: magicData) { return 0 } -// -// if let r = self.fileBytes.firstRange(of: magicData) { -// let toDrop = r.lowerBound -// if toDrop > 0 { -// print("HotlineFolderDownloadClient: Resync — dropping \(toDrop) stray bytes before FILP") -// self.fileBytes.removeSubrange(0..= 2 else { break } -// let headerLen = Int(self.fileBytes.readUInt16(at: 0)!) -// guard self.fileBytes.count >= 2 + headerLen else { break } -// -// let headerData = self.fileBytes.subdata(in: 2..<(2 + headerLen)) -// -// guard let parsed = parseItemHeaderPath(headerData) else { -// print("HotlineFolderDownloadClient: Invalid item header; waiting for more data") -// break -// } -// -// // Consume the header from the buffer -// self.fileBytes.removeSubrange(0..<(2 + headerLen)) -// -// let itemType = parsed.type -// let comps = parsed.components -// let joinedPath = comps.joined(separator: "/") -// print("HotlineFolderDownloadClient: item type=\(itemType) path=\(joinedPath)") -// -// guard !comps.isEmpty else { -// print("HotlineFolderDownloadClient: Empty path components for item type \(itemType); requesting next item") -// self.sendAction(.nextFile) -// keepProcessing = !self.fileBytes.isEmpty -// break -// } -// -// if itemType == 1 { -// // Folder entries: create the directory locally and continue. -// self.currentItemRelativePath = comps -// -// if let base = self.folderPath { -// var dir = base -// for c in comps { dir = (dir as NSString).appendingPathComponent(c) } -// do { -// try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) -// print("HotlineFolderDownloadClient: Created folder at \(dir)") -// } catch { -// print("HotlineFolderDownloadClient: Failed to create subfolder: \(error)") -// } -// } -// -// self.completedItemCount += 1 -// -// if self.completedItemCount >= self.folderItemCount { -// self.handleAllItemsDownloaded() -// return -// } -// -// self.sendAction(.nextFile) -// keepProcessing = !self.fileBytes.isEmpty -// break -// } -// else if itemType == 0 { -// // File entries include the full path; split parent components from filename. -// self.currentItemRelativePath = Array(comps.dropLast()) -// self.currentFileName = comps.last -// -// self.transferStage = .waitingForFileSize -// print("HotlineFolderDownloadClient: Requesting file download for '\(self.currentFileName ?? "?")'") -// self.sendAction(.sendFile) -// -// if self.fileBytes.count >= 4 { -// keepProcessing = true -// } -// break -// } -// else { -// print("HotlineFolderDownloadClient: Unknown item type \(itemType); skipping") -// self.sendAction(.nextFile) -// keepProcessing = !self.fileBytes.isEmpty -// break -// } -// -// case .waitingForFileSize: -// // Read 4-byte file size that comes before FILP in folder mode -// guard self.fileBytes.count >= 4 else { break } -// let fileSize = self.fileBytes.readUInt32(at: 0)! -// self.fileBytes.removeSubrange(0..<4) -// -// print("HotlineFolderDownloadClient: File size: \(fileSize) bytes") -// -// self.currentFileSize = fileSize -// self.currentFileBytesRead = 0 -// -// // Align to FILP boundary before decoding the file header -// let dropped = self.alignBufferToFILP() -// if dropped > 0 { -// // These bytes were in the stream for this file but not part of FILP. -// // Keep byte-accounting consistent by shrinking the expected size. -// if self.currentFileSize >= UInt32(dropped) { -// self.currentFileSize -= UInt32(dropped) -// } else { -// // Defensive: if weird, treat as zero-size to avoid underflow -// self.currentFileSize = 0 -// } -// } -// -// self.transferStage = .fileHeader -// keepProcessing = true -// -// case .fileHeader: -// // Make sure we're actually at a FILP header -// if self.fileBytes.count >= HotlineFileHeader.DataSize { -// // If the 4-byte magic doesn't match, try one more resync here -// if self.fileBytes.readUInt32(at: 0)! != FILP_MAGIC { -// let dropped = self.alignBufferToFILP() -// if dropped > 0 { -// if self.currentFileSize >= UInt32(dropped) { self.currentFileSize -= UInt32(dropped) } -// } -// // If still not aligned or not enough bytes, wait for more data -// guard self.fileBytes.count >= HotlineFileHeader.DataSize, -// self.fileBytes.readUInt32(at: 0)! == FILP_MAGIC else { break } -// } -// -// if let header = HotlineFileHeader(from: self.fileBytes) { -// // Sanity gate: version and fork count -// if header.format != FILP_MAGIC || header.version == 0 || header.forkCount > 3 { -// print("HotlineFolderDownloadClient: Invalid FILP header (fmt=\(String(format:"0x%08X", header.format)), ver=\(header.version), forks=\(header.forkCount)). Resyncing.") -// // Try resync and wait for more data -// let dropped = self.alignBufferToFILP() -// if dropped > 0 { if self.currentFileSize >= UInt32(dropped) { self.currentFileSize -= UInt32(dropped) } } -// break -// } -// -// self.fileBytes.removeSubrange(0..= self.folderItemCount { -// self.handleAllItemsDownloaded() -// return -// } -// -// // More files to download -// // Check if server has pipelined all remaining data (we've received more than expected total) -// print("HotlineFolderDownloadClient: Completed \(self.completedItemCount)/\(self.folderItemCount) items, requesting next entry") -// self.transferStage = .itemHeader -// self.sendAction(.nextFile) -// keepProcessing = !self.fileBytes.isEmpty -// } -// // File not complete - try to read next fork header -// else if let forkHeader = HotlineFileForkHeader(from: self.fileBytes) { -// // Quick validation: first fork must be INFO, and sizes must be plausible -// let remainingForThisFile = max(0, Int(self.currentFileSize) - self.currentFileBytesRead) -// let plausible = forkHeader.dataSize <= UInt32(remainingForThisFile) -// let knownType = forkHeader.isInfoFork || forkHeader.isDataFork || forkHeader.isResourceFork -// -// if !plausible || (!knownType && self.currentFileBytesRead == HotlineFileHeader.DataSize) { -// print("HotlineFolderDownloadClient: Implausible fork header (type=\(String(format:"0x%08X", forkHeader.forkType)), size=\(forkHeader.dataSize), remaining=\(remainingForThisFile)). Resyncing.") -// let dropped = self.alignBufferToFILP() -// if dropped > 0 { if self.currentFileSize >= UInt32(dropped) { self.currentFileSize -= UInt32(dropped) } } -// break -// } -// -// self.fileBytes.removeSubrange(0..= infoForkDataSize { -// let infoForkData = self.fileBytes.subdata(in: 0.. 0 { -// if let f = self.fileHandle { -// do { -// var dataToWrite = self.fileBytes -// -// if dataToWrite.count >= self.fileCurrentForkBytesLeft { -// dataToWrite = self.fileBytes.subdata(in: 0.. 0 { -// var dataToWrite = self.fileBytes -// -// if dataToWrite.count >= self.fileCurrentForkBytesLeft { -// dataToWrite = self.fileBytes.subdata(in: 0.. 0 { -// var dataToWrite = self.fileBytes -// -// if dataToWrite.count >= self.fileCurrentForkBytesLeft { -// dataToWrite = self.fileBytes.subdata(in: 0.. 0 { -// let _ = self.writeResourceFork() -// self.fileResourceBytes = Data() -// } -// -// self.fileCurrentForkHeader = nil -// self.fileCurrentForkBytesLeft = 0 -// self.fileForksRemaining = 0 -// self.currentFileBytesRead = 0 -// self.currentFileSize = 0 -// self.currentFilePath = nil -// self.fileInfoFork = nil -// self.fileHeader = nil -// self.currentFileName = nil -// } -// -// private func handleAllItemsDownloaded() { -// guard self.status != .completed else { -// return -// } -// -// print("HotlineFolderDownloadClient: All \(self.folderItemCount) items downloaded") -// -// self.invalidate() -// self.status = .completed -// -// if let downloadPath = self.folderPath { -// DispatchQueue.main.sync { -// print("HotlineFolderDownloadClient: Folder download complete", downloadPath) -// -// var downloadURL = URL(filePath: downloadPath) -// downloadURL.resolveSymlinksInPath() -// -// self.delegate?.hotlineFolderDownloadComplete(client: self, reference: self.referenceNumber, at: downloadURL) -// -//#if os(macOS) -// DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path) -//#endif -// } -// } -// } -// -// private func writeResourceFork() -> Bool { -// guard let filePath = self.currentFilePath else { -// return false -// } -// -// var resolvedFileURL = URL(filePath: filePath) -// resolvedFileURL.resolveSymlinksInPath() -// -// let resourceFilePath = resolvedFileURL.urlForResourceFork() -// -// do { -// try self.fileResourceBytes.write(to: resourceFilePath) -// } -// catch { -// return false -// } -// -// return true -// } -// -// private func prepareDownloadFile(name: String) -> Bool { -// var filePath: String -// -// if self.folderPath != nil { -// // Build the full path including subfolders -// var fullPath = self.folderPath! -// for component in self.currentItemRelativePath { -// fullPath = (fullPath as NSString).appendingPathComponent(component) -// } -// -// let folderURL = URL(filePath: fullPath) -// -// // Create folder if it doesn't exist -// if !FileManager.default.fileExists(atPath: folderURL.path) { -// do { -// try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) -// } -// catch { -// print("HotlineFolderDownloadClient: Failed to create folder", error) -// return false -// } -// } -// -// filePath = folderURL.appendingPathComponent(name).path -// print("HotlineFolderDownloadClient: Creating file at \(filePath)") -// } -// else { -// let downloadsURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0] -// filePath = downloadsURL.generateUniqueFilePath(filename: name) -// } -// -// var fileAttributes: [FileAttributeKey: Any] = [:] -// if let creatorCode = self.fileInfoFork?.creator { -// fileAttributes[.hfsCreatorCode] = creatorCode as NSNumber -// } -// if let typeCode = self.fileInfoFork?.type { -// fileAttributes[.hfsTypeCode] = typeCode as NSNumber -// } -// if let createdDate = self.fileInfoFork?.createdDate { -// fileAttributes[.creationDate] = createdDate as NSDate -// } -// if let modifiedDate = self.fileInfoFork?.modifiedDate { -// fileAttributes[.modificationDate] = modifiedDate as NSDate -// } -// if let comment = self.fileInfoFork?.comment { -// if let commentPlistData = try? PropertyListSerialization.data(fromPropertyList: comment, format: .binary, options: 0) { -// fileAttributes[FileAttributeKey(rawValue: "NSFileExtendedAttributes")] = [ -// FileAttributeKey(rawValue: "com.apple.metadata:kMDItemFinderComment"): commentPlistData -// ] -// } -// } -// -// if FileManager.default.createFile(atPath: filePath, contents: nil, attributes: fileAttributes) { -// if let h = FileHandle(forWritingAtPath: filePath) { -// self.currentFilePath = filePath -// self.fileHandle = h -// -// // Only set file progress on first file -// if self.currentItemNumber == 1 && self.folderPath != nil { -// self.fileProgress.fileURL = URL(filePath: self.folderPath!).resolvingSymlinksInPath() -// self.fileProgress.fileOperationKind = .downloading -// self.fileProgress.publish() -// } -// -// return true -// } -// } -// -// return false -// } -//} diff --git a/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift index f065233..10934e7 100644 --- a/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift @@ -1,21 +1,13 @@ -// -// HotlineFileUploadClientNew.swift -// Hotline -// -// Modern async/await file upload client using NetSocketNew -// - import Foundation import Network -/// Modern async/await file upload client for Hotline protocol @MainActor public class HotlineFileUploadClientNew { // MARK: - Configuration public struct Configuration: Sendable { public var chunkSize: Int = 256 * 1024 - public var publishProgress: Bool = true + public init() {} } @@ -186,11 +178,9 @@ public class HotlineFileUploadClientNew { progressHandler?(.transfer(name: filename, size: self.transferSize, total: self.transferTotal, progress: self.transferProgress.fractionCompleted, speed: nil, estimate: nil)) // Configure progress for Finder if enabled - if config.publishProgress { - self.transferProgress.fileURL = fileURL.resolvingSymlinksInPath() - self.transferProgress.fileOperationKind = .uploading - self.transferProgress.publish() - } + self.transferProgress.fileURL = fileURL.resolvingSymlinksInPath() + self.transferProgress.fileOperationKind = .uploading + self.transferProgress.publish() // Send DATA fork if present if dataForkSize > 0 { diff --git a/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift index 9dcb7c9..4ad35e7 100644 --- a/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift @@ -1,10 +1,3 @@ -// -// HotlineFolderDownloadClientNew.swift -// Hotline -// -// Modern async/await folder download client using NetSocketNew -// - import Foundation import Network @@ -15,24 +8,14 @@ public struct HotlineFolderItemProgress: Sendable { public let totalItems: Int } -/// Modern async/await folder download client for Hotline protocol @MainActor public class HotlineFolderDownloadClientNew { - // MARK: - Configuration - - public struct Configuration: Sendable { - public var publishProgress: Bool = true - public init() {} - } - // MARK: - Properties private let serverAddress: String private let serverPort: UInt16 private let referenceNumber: UInt32 - private let config: Configuration - private let transferTotal: Int private let folderItemCount: Int private var transferSize: Int = 0 @@ -48,13 +31,11 @@ public class HotlineFolderDownloadClientNew { port: UInt16, reference: UInt32, size: UInt32, - itemCount: Int, - configuration: Configuration = .init() + itemCount: Int ) { self.serverAddress = address self.serverPort = port self.referenceNumber = reference - self.config = configuration self.transferTotal = Int(size) self.folderItemCount = itemCount @@ -141,18 +122,11 @@ public class HotlineFolderDownloadClientNew { try fm.createDirectory(at: destinationURL, withIntermediateDirectories: true) // Create and publish progress for the entire folder (shows in Finder) - if config.publishProgress { - let progress = Progress(totalUnitCount: Int64(self.transferTotal)) - progress.fileURL = destinationURL - progress.fileOperationKind = .downloading - progress.publish() - self.folderProgress = progress - } - defer { - // Unpublish progress when folder download completes - self.folderProgress?.unpublish() - self.folderProgress = nil - } + let progress = Progress(totalUnitCount: Int64(self.transferTotal)) + progress.fileURL = destinationURL + progress.fileOperationKind = .downloading + progress.publish() + self.folderProgress = progress // Send initial magic header print("HotlineFolderDownloadClientNew[\(referenceNumber)]: Sending HTXF magic") diff --git a/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift index 0b78f33..2efced2 100644 --- a/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift @@ -15,14 +15,12 @@ private struct FolderItem { let isFolder: Bool } -/// Modern async/await folder upload client for Hotline protocol @MainActor public class HotlineFolderUploadClientNew { // MARK: - Configuration public struct Configuration: Sendable { public var chunkSize: Int = 256 * 1024 - public var publishProgress: Bool = true public init() {} } @@ -163,7 +161,6 @@ public class HotlineFolderUploadClientNew { }) progressHandler?(.connected) -// progressHandler?(.transfer(size: 0, total: self.transferTotal, progress: 0.0, speed: nil, estimate: nil)) var completedItemCount = 0 var totalBytesTransferred = 0 @@ -171,7 +168,7 @@ public class HotlineFolderUploadClientNew { var stage: UploadStage = .waitingForNextFile var currentItem: FolderItem? - // State machine loop - matches C++ client's goto-based state machine + // State machine loop while stage != .done { switch stage { @@ -330,7 +327,6 @@ public class HotlineFolderUploadClientNew { } } - // Following C++ client behavior: Build hierarchy with root folder name prepended to all paths // Start from root folder with root name as first path component try walkFolder(at: folderURL, relativePath: [rootFolderName]) totalItems = folderItems.count @@ -339,8 +335,6 @@ public class HotlineFolderUploadClientNew { } private func encodeItemHeader(item: FolderItem) -> Data { - // Following C++ client behavior: Skip the first path component (root folder name) - // The C++ client does: startPtr += 2; startPtr += *startPtr + 1; pathCount--; let strippedPath = item.pathComponents.count > 1 ? Array(item.pathComponents.dropFirst()) : item.pathComponents let strippedPathCount = strippedPath.count @@ -428,22 +422,17 @@ public class HotlineFolderUploadClientNew { bytesUploaded += HotlineFileForkHeader.DataSize // Send INFO fork data - print("HotlineFolderUploadClientNew[\(referenceNumber)]: Sending INFO fork (\(infoForkData.count) bytes)") try await socket.write(infoForkData) bytesUploaded += infoForkData.count // Create per-file progress for Finder - var fileProgress: Progress? - if config.publishProgress { - let progress = Progress(totalUnitCount: Int64(totalFileSize)) - progress.fileURL = fileURL.resolvingSymlinksInPath() - progress.fileOperationKind = Progress.FileOperationKind.uploading - progress.publish() - fileProgress = progress - } + let fileProgress = Progress(totalUnitCount: Int64(totalFileSize)) + fileProgress.fileURL = fileURL.resolvingSymlinksInPath() + fileProgress.fileOperationKind = Progress.FileOperationKind.uploading + fileProgress.publish() defer { - fileProgress?.unpublish() + fileProgress.unpublish() } // Send DATA fork if present @@ -459,7 +448,7 @@ public class HotlineFolderUploadClientNew { 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) + fileProgress.completedUnitCount = Int64(bytesUploaded + p.sent) // Calculate overall folder progress let totalBytesNow = totalBytesTransferredSoFar + bytesUploaded + p.sent @@ -503,7 +492,7 @@ public class HotlineFolderUploadClientNew { 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) + fileProgress.completedUnitCount = Int64(bytesUploaded + p.sent) // Calculate overall folder progress let totalBytesNow = totalBytesTransferredSoFar + bytesUploaded + p.sent @@ -532,7 +521,6 @@ public class HotlineFolderUploadClientNew { bytesUploaded += Int(resourceForkSize) } - print("HotlineFolderUploadClientNew[\(referenceNumber)]: File upload complete, \(bytesUploaded) bytes sent") return bytesUploaded } } diff --git a/Hotline/Library/NetSocket.swift b/Hotline/Library/NetSocket.swift deleted file mode 100644 index b1f1742..0000000 --- a/Hotline/Library/NetSocket.swift +++ /dev/null @@ -1,274 +0,0 @@ - -// NetSocket.swift -// A simple delegate based buffered read/write TCP socket. -// Created by Dustin Mierau - -import Foundation - -protocol NetSocketDelegate: AnyObject { - func netsocketConnected(socket: NetSocket) - func netsocketDisconnected(socket: NetSocket, error: Error?) - func netsocketReceived(socket: NetSocket, bytes: [UInt8]) - func netsocketSent(socket: NetSocket, count: Int) -} - -extension NetSocketDelegate { - func netsocketConnected(socket: NetSocket) {} - func netsocketDisconnected(socket: NetSocket, error: Error?) {} - func netsocketReceived(socket: NetSocket, bytes: [UInt8]) {} - func netsocketSent(socket: NetSocket, count: Int) {} -} - -enum NetSocketStatus { - case disconnected - case connecting - case connected -} - -final class NetSocket: NSObject, StreamDelegate { - weak var delegate: NetSocketDelegate? = nil - - private var output: OutputStream? = nil - private var input: InputStream? = nil - - private var outputBuffer: [UInt8] = [] - private var inputBuffer: [UInt8] = [] - - private var readBuffer: [UInt8] = Array(repeating: 0, count: 4 * 1024) - - public func peek() -> [UInt8] { self.inputBuffer } - public var available: Int { self.inputBuffer.count } - - private var status: NetSocketStatus = .disconnected - - @MainActor public func has(_ length: Int) -> Bool { - return (self.available >= length) - } - - override init() {} - - @MainActor public func connect(host: String, port: Int) { - self.close() - - var outputStream: OutputStream? = nil - var inputStream: InputStream? = nil - - self.status = .connecting - - Stream.getStreamsToHost(withName: host, port: port, inputStream: &inputStream, outputStream: &outputStream) - - self.input = inputStream - self.output = outputStream - - inputStream?.delegate = self - outputStream?.delegate = self - - inputStream?.schedule(in: .current, forMode: .default) - outputStream?.schedule(in: .current, forMode: .default) - - inputStream?.open() - outputStream?.open() - } - - @MainActor public func close(_ err: Error? = nil) { - print("NetSocket: Closed") - - let disconnected = (self.status != .disconnected) - - self.status = .disconnected - - self.input?.delegate = nil - self.output?.delegate = nil - self.input?.close() - self.output?.close() - self.input?.remove(from: .current, forMode: .default) - self.output?.remove(from: .current, forMode: .default) - self.input = nil - self.output = nil - self.inputBuffer = [] - self.outputBuffer = [] - - if disconnected { - self.delegate?.netsocketDisconnected(socket: self, error: err) - } - } - - @MainActor public func write(_ data: Data) { - guard let output = self.output else { - return - } - - self.outputBuffer.append(contentsOf: data) - - if output.hasSpaceAvailable { - self.writeBufferToStream() - } - } - - @MainActor public func write(_ data: [UInt8]) { - guard let output = self.output else { - return - } - - self.outputBuffer.append(contentsOf: data) - - if output.hasSpaceAvailable { - self.writeBufferToStream() - } - } - - @MainActor public func read(count: Int) -> [UInt8] { - guard self.inputBuffer.count > 0, count > 0 else { - return [] - } - - let amountToRead = min(count, self.inputBuffer.count) - let dataRead: [UInt8] = Array(self.inputBuffer[0.. Data { - guard self.inputBuffer.count > 0, count > 0 else { - return Data() - } - - let amountToRead = min(count, self.inputBuffer.count) - - let dataRead: Data = Data(self.inputBuffer[0.. [UInt8] { - guard self.inputBuffer.count > 0 else { - return [] - } - - let dataRead: [UInt8] = Array(self.inputBuffer) - self.inputBuffer = [] - - return dataRead - } - - @MainActor public func readAll() -> Data { - guard self.inputBuffer.count > 0 else { - return Data() - } - - let dataRead: Data = Data(self.inputBuffer) - self.inputBuffer = [] - - return dataRead - } - - @MainActor private func writeBufferToStream() { - guard let output = self.output, self.outputBuffer.count > 0 else { - return - } - - let bytesWritten = output.write(self.outputBuffer, maxLength: self.outputBuffer.count) - print("NetSocket => \(bytesWritten) bytes") - if bytesWritten > 0 { - self.outputBuffer.removeFirst(bytesWritten) - self.delegate?.netsocketSent(socket: self, count: bytesWritten) - } - else if bytesWritten == -1 { - self.close(output.streamError) - } - } - - @MainActor private func readStreamToBuffer() { - guard let input = self.input else { - return - } - - let bytesRead = input.read(&self.readBuffer, maxLength: 4 * 1024) - print("NetSocket <= \(bytesRead) bytes") - if bytesRead > 0 { - self.inputBuffer.append(contentsOf: self.readBuffer[0.. Bool { - return lhs.id == rhs.id - } - - #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 - - // The icon ordering here was painsakenly pulled manually - // from the original Hotline client to display the classic icons - // in the same order as the original client. - 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 - ] - - var status: HotlineClientStatus = .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 - var users: [User] = [] - var accounts: [HotlineAccount] = [] - var chat: [ChatMessage] = [] - var chatInput: String = "" - var messageBoard: [String] = [] - var messageBoardLoaded: Bool = false - var files: [FileInfo] = [] - var filesLoaded: Bool = false - 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: FileSearchSession? = nil - @ObservationIgnored private var fileSearchResultKeys: Set = [] - private struct FileListCacheEntry { - let files: [FileInfo] - let timestamp: Date - } - @ObservationIgnored private var fileListCache: [String: FileListCacheEntry] = [:] - var news: [NewsInfo] = [] - private var newsLookup: [String:NewsInfo] = [:] - var newsLoaded: Bool = false - var accountsLoaded: Bool = false - var instantMessages: [UInt16:[InstantMessage]] = [:] - var transfers: [TransferInfo] = [] - var downloads: [HotlineTransferClient] = [] - var unreadInstantMessages: [UInt16:UInt16] = [:] - var unreadPublicChat: Bool = false - var errorDisplayed: Bool = false - var errorMessage: String? = nil - - @ObservationIgnored var bannerClient: HotlineFilePreviewClient? - @ObservationIgnored private var chatSessionKey: ChatStore.SessionKey? - @ObservationIgnored private var restoredChatSessionKey: ChatStore.SessionKey? - @ObservationIgnored private var chatHistoryObserver: NSObjectProtocol? - @ObservationIgnored private var lastPersistedMessageType: ChatMessageType? - #if os(macOS) - var bannerImage: NSImage? = nil - #elseif os(iOS) - var bannerImage: UIImage? = nil - #endif - - - // MARK: - - - init(trackerClient: HotlineTrackerClient, client: HotlineClient) { - self.trackerClient = trackerClient - self.client = client - self.client.delegate = self - - self.chatHistoryObserver = NotificationCenter.default.addObserver(forName: ChatStore.historyClearedNotification, object: nil, queue: .main) { [weak self] _ in - self?.handleChatHistoryCleared() - } - } - - // MARK: - - - @MainActor func getServerList(tracker: String, port: Int = HotlinePorts.DefaultTrackerPort) async -> [Server] { - var servers: [Server] = [] - print("Hotline.getServerList: Starting fetch from \(tracker):\(port)") - - do { - for try await hotlineServer in self.trackerClient.fetchServers(address: tracker, port: port) { - if let serverName = hotlineServer.name { - servers.append(Server( - name: serverName, - description: hotlineServer.description, - address: hotlineServer.address, - port: Int(hotlineServer.port), - users: Int(hotlineServer.users) - )) - if servers.count % 10 == 0 { - print("Hotline.getServerList: Collected \(servers.count) servers so far...") - } - } - } - } catch { - print("Hotline.getServerList: Error - \(error)") - } - - print("Hotline.getServerList: Returning \(servers.count) servers") - return servers - } - - @MainActor func disconnectTracker() { - // No-op: HotlineTrackerClient now uses async/await and manages - // connections internally. Each fetchServers() call opens and closes - // its own connection automatically. - } - - @MainActor func login(server: Server, username: String, iconID: Int, callback: ((Bool) -> Void)? = nil) { - self.server = server - self.serverName = server.name - self.username = username - self.iconID = iconID - - let key = sessionKey(for: server) - self.chatSessionKey = key - self.restoredChatSessionKey = nil - self.lastPersistedMessageType = nil - self.chat = [] - self.restoreChatHistory(for: key) - - self.client.login(address: server.address, port: server.port, login: server.login, password: server.password, username: username, iconID: UInt16(iconID)) { [weak self] err, serverName, serverVersion in - self?.serverVersion = serverVersion ?? 123 - if serverName != nil { - self?.serverName = serverName - } - - callback?(err == nil) - } - } - - @MainActor func sendUserInfo(username: String, iconID: Int, options: HotlineUserOptions = [], autoresponse: String? = nil) { - self.username = username - self.iconID = iconID - - self.client.sendSetClientUserInfo(username: username, iconID: UInt16(iconID), options: options, autoresponse: autoresponse) - } - - @MainActor func getUserList() { - self.client.sendGetUserList() - } - - @MainActor func disconnect() { - self.client.disconnect() - self.bannerClient?.cancel() - self.fileSearchSession?.cancel() - self.fileSearchSession = nil - self.resetFileSearchState() - } - - deinit { - if let observer = chatHistoryObserver { - NotificationCenter.default.removeObserver(observer) - } - } - - @MainActor func sendAgree() { - self.client.sendAgree(username: self.username, iconID: UInt16(self.iconID), options: .none) - } - - @MainActor func sendInstantMessage(_ text: String, userID: UInt16) { - let message = InstantMessage(direction: .outgoing, text: text.convertingLinksToMarkdown(), type: .message, date: Date()) - - if self.instantMessages[userID] == nil { - self.instantMessages[userID] = [message] - } - else { - self.instantMessages[userID]!.append(message) - } - - self.client.sendInstantMessage(message: text, userID: userID) - - if Prefs.shared.playPrivateMessageSound && Prefs.shared.playPrivateMessageSound { - SoundEffectPlayer.shared.playSoundEffect(.chatMessage) - } - } - - func markPublicChatAsRead() { - self.unreadPublicChat = false - } - - func hasUnreadInstantMessages(userID: UInt16) -> Bool { - return self.unreadInstantMessages[userID] != nil - } - - func markInstantMessagesAsRead(userID: UInt16) { - self.unreadInstantMessages.removeValue(forKey: userID) - } - - @MainActor func sendChat(_ text: String, announce: Bool = false) { - self.client.sendChat(message: text, announce: announce) - } - - @MainActor func getMessageBoard() async -> [String] { - self.messageBoard = await withCheckedContinuation { [weak self] continuation in - self?.client.sendGetMessageBoard() { err, messages in - continuation.resume(returning: (err != nil ? [] : messages)) - } - } - - self.messageBoardLoaded = true - - return self.messageBoard - } - - @MainActor func postToMessageBoard(text: String) { - self.client.sendPostMessageBoard(text: text) - } - - @MainActor func getFileList(path: [String] = [], suppressErrors: Bool = false, preferCache: Bool = false) async -> [FileInfo] { - if preferCache, let cached = cachedFileList(for: path, ttl: fileSearchConfig.cacheTTL, allowStale: false) { - return cached.items - } - - return await withCheckedContinuation { [weak self] continuation in - self?.client.sendGetFileList(path: path, suppressErrors: suppressErrors) { [weak self] files in - let parentFile = self?.findFile(in: self?.files ?? [], at: path) - - var newFiles: [FileInfo] = [] - for f in files { - newFiles.append(FileInfo(hotlineFile: f)) - } - - DispatchQueue.main.async { - if let parent = parentFile { - parent.children = newFiles - } - else if path.isEmpty { - self?.filesLoaded = true - self?.files = newFiles - } - - self?.storeFileListInCache(newFiles, for: path) - continuation.resume(returning: newFiles) - } - } - } - } - - @MainActor func startFileSearch(query: String) { - let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - cancelFileSearch() - return - } - - fileSearchSession?.cancel() - resetFileSearchState() - fileSearchQuery = trimmed - fileSearchStatus = .searching(processed: 0, pending: 0) - fileSearchScannedFolders = 0 - fileSearchCurrentPath = [] - - let session = FileSearchSession(hotline: self, query: trimmed, config: fileSearchConfig) - fileSearchSession = session - - Task { await session.start() } - } - - @MainActor func cancelFileSearch(clearResults: Bool = true) { - guard let session = fileSearchSession else { - if clearResults { - resetFileSearchState() - } else if !fileSearchResults.isEmpty { - fileSearchStatus = .cancelled(processed: fileSearchScannedFolders) - fileSearchCurrentPath = nil - } - return - } - - session.cancel() - fileSearchSession = nil - fileSearchCurrentPath = nil - if clearResults { - resetFileSearchState() - } - else { - fileSearchStatus = .cancelled(processed: fileSearchScannedFolders) - } - } - - @MainActor fileprivate func searchSession(_ session: FileSearchSession, didEmit matches: [FileInfo], processed: Int, pending: Int) { - guard fileSearchSession === session else { - return - } - - var appended: [FileInfo] = [] - for match in matches { - let key = searchPathKey(for: match.path) - if fileSearchResultKeys.insert(key).inserted { - appended.append(match) - } - } - - if !appended.isEmpty { - fileSearchResults.append(contentsOf: appended) - } - - fileSearchScannedFolders = processed - fileSearchStatus = .searching(processed: processed, pending: pending) - } - - @MainActor fileprivate func searchSession(_ session: FileSearchSession, didFocusOn path: [String]) { - guard fileSearchSession === session else { - return - } - - fileSearchCurrentPath = path - } - - @MainActor fileprivate func searchSessionDidFinish(_ session: FileSearchSession, processed: Int, pending: Int, completed: Bool) { - guard fileSearchSession === session else { - return - } - - fileSearchScannedFolders = processed - fileSearchSession = nil - fileSearchCurrentPath = nil - if completed { - fileSearchStatus = .completed(processed: processed) - } else { - fileSearchStatus = .cancelled(processed: processed) - } - } - - private func resetFileSearchState() { - fileSearchResults = [] - fileSearchResultKeys.removeAll(keepingCapacity: true) - fileSearchStatus = .idle - fileSearchQuery = "" - fileSearchScannedFolders = 0 - fileSearchCurrentPath = nil - } - - private func searchPathKey(for path: [String]) -> String { - path.joined(separator: "\u{001F}") - } - - 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 shouldBypassFileCache(for: path) { - return nil - } - - let key = searchPathKey(for: path) - guard let entry = 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 fileSearchConfig.cacheTTL > 0 else { - return - } - - if shouldBypassFileCache(for: path) { - return - } - - let key = searchPathKey(for: path) - fileListCache[key] = FileListCacheEntry(files: files, timestamp: Date()) - pruneFileListCacheIfNeeded() - } - - private func pruneFileListCacheIfNeeded() { - let limit = fileSearchConfig.maxCachedFolders - guard limit > 0, fileListCache.count > limit else { - return - } - - let excess = fileListCache.count - limit - guard excess > 0 else { return } - - let sortedKeys = fileListCache.sorted { lhs, rhs in - lhs.value.timestamp < rhs.value.timestamp - } - - for index in 0.. (items: [FileInfo], isFresh: Bool)? { - cachedFileList(for: path, ttl: ttl, allowStale: true) - } - - @MainActor func clearFileListCache() { - guard !fileListCache.isEmpty else { - return - } - - fileListCache.removeAll(keepingCapacity: false) - } - - @MainActor func getNewsArticle(id articleID: UInt, at path: [String], flavor: String) async -> String? { - return await withCheckedContinuation { [weak self] continuation in - self?.client.sendGetNewsArticle(id: UInt32(articleID), path: path, flavor: flavor) { articleText in -// let parentNews = self?.findNews(in: self?.news ?? [], at: path) - -// var newCategories: [NewsInfo] = [] -// for category in categories { -// newCategories.append(NewsInfo(hotlineNewsCategory: category)) -// } -// -// if let parent = existingNewsItem { -// parent.children = newCategories -// } -// else if path.isEmpty { -// self?.news = newCategories -// } - - continuation.resume(returning: articleText) - } - } - - } - - @MainActor func getAccounts() async -> [HotlineAccount] { - return await withCheckedContinuation { [weak self] continuation in - self?.client.sendGetAccounts() { articles in - continuation.resume(returning: articles) - } - } - } - - @MainActor func getNewsList(at path: [String] = []) async { - return await withCheckedContinuation { [weak self] continuation in - let parentNewsGroup = self?.findNews(in: self?.news ?? [], at: path) - - // Send a categories request for bundle paths or root (empty path) - if path.isEmpty || parentNewsGroup?.type == .bundle { - print("Hotline: Requesting categories at: /\(path.joined(separator: "/"))") - - self?.client.sendGetNewsCategories(path: path) { @MainActor [weak self] categories in - // Create info for each category returned. - var newCategoryInfos: [NewsInfo] = [] - - // Transform hotline categories into NewsInfo objects. - for category in categories { - var newsCategoryInfo = NewsInfo(hotlineNewsCategory: category) - - if let lookupPath = newsCategoryInfo.lookupPath { - // Merge returned category info with existing category info. - if let existingCategoryInfo = self?.newsLookup[lookupPath] { - print("Hotline: Merging category into existing category at \(lookupPath)") - - existingCategoryInfo.count = newsCategoryInfo.count - existingCategoryInfo.name = newsCategoryInfo.name - existingCategoryInfo.path = newsCategoryInfo.path - existingCategoryInfo.categoryID = newsCategoryInfo.categoryID - newsCategoryInfo = existingCategoryInfo - } - else { - print("Hotline: New category added at \(lookupPath)") - self?.newsLookup[lookupPath] = newsCategoryInfo - } - } - - newCategoryInfos.append(newsCategoryInfo) - } - - if let parent = parentNewsGroup { - parent.children = newCategoryInfos - } - else if path.isEmpty { - self?.newsLoaded = true - self?.news = newCategoryInfos - } - - continuation.resume() - } - } - else { - print("Hotline: Requesting articles at: /\(path.joined(separator: "/"))") - - self?.client.sendGetNewsArticles(path: path) { @MainActor [weak self] articles in - print("Hotline: Organizing news at \(path.joined(separator: "/"))") - - // Create info for each article returned. - var newArticleInfos: [NewsInfo] = [] - - for article in articles { - var newsArticleInfo = NewsInfo(hotlineNewsArticle: article) - - if let lookupPath = newsArticleInfo.lookupPath { - // Merge returned category info with existing category info. - if let existingArticleInfo = self?.newsLookup[lookupPath] { - print("Hotline: Merging article into existing article at \(lookupPath)") - - existingArticleInfo.count = newsArticleInfo.count - existingArticleInfo.name = newsArticleInfo.name - existingArticleInfo.path = newsArticleInfo.path - existingArticleInfo.articleUsername = newsArticleInfo.articleUsername - existingArticleInfo.articleDate = newsArticleInfo.articleDate - existingArticleInfo.articleFlavors = newsArticleInfo.articleFlavors - existingArticleInfo.articleID = newsArticleInfo.articleID - newsArticleInfo = existingArticleInfo - } - else { - print("Hotline: New article added at \(lookupPath)") - self?.newsLookup[lookupPath] = newsArticleInfo - } - } - - newArticleInfos.append(newsArticleInfo) - } - - let organizedNewsArticles: [NewsInfo] = self?.organizeNewsArticles(newArticleInfos) ?? [] - if let parent = parentNewsGroup { - parent.children = organizedNewsArticles - } - - continuation.resume() - } - } - } - } - - func organizeNewsArticles(_ flatArticles: [NewsInfo]) -> [NewsInfo] { - // Place articles under their parent. - var organized: [NewsInfo] = [] - for article in flatArticles { - if let parentLookupPath = article.parentArticleLookupPath, - let parentArticle = self.newsLookup[parentLookupPath] { -// article.expanded = true - if parentArticle.children.firstIndex(of: article) == nil { - article.expanded = true - parentArticle.children.append(article) - } - } - else { - organized.append(article) - } - } - - return organized - } - - @MainActor func postNewsArticle(title: String, body: String, at path: [String], parentID: UInt32 = 0) async -> Bool { - - - return await withCheckedContinuation { [weak self] continuation in - guard let client = self?.client else { - continuation.resume(returning: false) - return - } - - client.postNewsArticle(title: title, text: body, path: path, parentID: parentID, callback: { success in - print("Hotline: News article posted? \(success)") - continuation.resume(returning: success) - }) - } - } - -// @MainActor func getNewsCategories(at path: [String] = []) async -> [NewsInfo] { -// return await withCheckedContinuation { [weak self] continuation in -// guard let client = self?.client else { -// continuation.resume(returning: []) -// return -// } -// -// client.sendGetNewsCategories(path: path) { [weak self] categories in -// let parentNews = self?.findNews(in: self?.news ?? [], at: path) -// -// var newCategories: [NewsInfo] = [] -// for category in categories { -// let categoryInfo: NewsInfo = NewsInfo(hotlineNewsCategory: category) -// newCategories.append(categoryInfo) -// self?.newsLookup[categoryInfo.path.joined(separator: "/")] = categoryInfo -// } -// -// DispatchQueue.main.async { -// if let parent = parentNews { -// parent.children = newCategories -// } -// else if path.isEmpty { -// self?.news = newCategories -// } -// -// continuation.resume(returning: newCategories) -// } -// } -// } -// } - - @MainActor func getArticles(at path: [String]) async -> [NewsInfo] { - return await withCheckedContinuation { [weak self] continuation in - self?.client.sendGetNewsArticles(path: path) { articles in - continuation.resume(returning: []) - } - } - } - - @MainActor func downloadFile(_ fileName: String, path: [String], complete callback: ((TransferInfo, URL) -> Void)? = nil) { - var fullPath: [String] = [] - if path.count > 1 { - fullPath = Array(path[0.. Void)? = nil, complete callback: ((TransferInfo, URL) -> Void)? = nil) { - var fullPath: [String] = [] - if path.count > 1 { - fullPath = Array(path[0.. Void)? = nil) { - var fullPath: [String] = [] - if path.count > 1 { - fullPath = Array(path[0.. Void)? = nil) { - let fileName = fileURL.lastPathComponent - - guard fileURL.isFileURL, !fileName.isEmpty else { - print("NOT A FILE URL?") - return - } - - let filePath = fileURL.path(percentEncoded: false) - - var fileIsDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: filePath, isDirectory: &fileIsDirectory), - fileIsDirectory.boolValue == false else { - print("FILE IS A DIRECTORY?") - return - } - - var fileSize: UInt = 0 - - // Data size - let fileAttributes = try? FileManager.default.attributesOfItem(atPath: filePath) - if let sizeAttribute = fileAttributes?[.size] as? NSNumber { - print("DATA SIZE \(sizeAttribute.uintValue)") - fileSize += sizeAttribute.uintValue - } - - // Resource size - let resourceURL = fileURL.appendingPathComponent("..namedfork/rsrc") - - - print("RESOURCE PATH \(resourceURL)") - let resourceAttributes = try? FileManager.default.attributesOfItem(atPath: resourceURL.path(percentEncoded: false)) - if let sizeAttribute = resourceAttributes?[.size] as? NSNumber { - print("RESOURCE SIZE \(sizeAttribute.uintValue)") - fileSize += sizeAttribute.uintValue - } - - print("FILE SIZE? \(fileSize)") - - guard fileSize > 0 else { - print("FILE IS EMPTY??") - return - } - - print("FILE SIZE: \(fileSize) NAME: \(fileName) PATH: \(path)") - - invalidateFileListCache(for: path, includingAncestors: true) - - self.client.sendUploadFile(name: fileName, path: path) { [weak self] success, uploadReferenceNumber in - print("UPLOAD REFERENCE: \(String(describing: uploadReferenceNumber))") - - if let self = self, - let address = self.server?.address, - let port = self.server?.port, - let referenceNumber = uploadReferenceNumber, - let fileClient = HotlineFileUploadClient(upload: fileURL, address: address, port: UInt16(port), reference: referenceNumber) { - - print("GOING TO UPLOAD") - - fileClient.delegate = self - self.downloads.append(fileClient) - - let transfer = TransferInfo(reference: referenceNumber, title: fileName, size: fileSize) - transfer.uploadCallback = callback - self.transfers.append(transfer) - - fileClient.start() - } - } - - } - - @MainActor func getFileDetails(_ fileName: String, path: [String]) async -> FileDetails? { - var fullPath: [String] = [] - if path.count > 1 { - fullPath = Array(path[0.. Bool { - var fullPath: [String] = [] - if path.count > 1 { - fullPath = Array(path[0.. Void)? = nil) { - var fullPath: [String] = [] - if path.count > 1 { - fullPath = Array(path[0..= 150 else { - return - } - - if self.bannerClient != nil || force { - self.bannerClient?.delegate = nil - self.bannerClient?.cancel() - self.bannerClient = nil - - if force { - self.bannerImage = nil - } - } - - if self.bannerImage != nil { - return - } - - self.client.sendDownloadBanner { [weak self] success, downloadReferenceNumber, downloadTransferSize in - if !success { - return - } - - if - let self = self, - let address = self.server?.address, - let port = self.server?.port, - let referenceNumber = downloadReferenceNumber, - let transferSize = downloadTransferSize { - self.bannerClient = HotlineFilePreviewClient(address: address, port: UInt16(port), reference: referenceNumber, size: UInt32(transferSize)) - self.bannerClient?.delegate = self - self.bannerClient?.start() - } - } - } - - // MARK: - Hotline Delegate - - @MainActor func hotlineStatusChanged(status: HotlineClientStatus) { - print("Hotline: Connection status changed to: \(status)") - let previousStatus = self.status - let previousTitle = self.serverTitle - - if status == .disconnected { - if previousStatus == .loggedIn { - let message = ChatMessage(text: "Disconnected", type: .signOut, date: Date()) - self.recordChatMessage(message, persist: true, display: false) - } - - self.serverVersion = 123 - self.serverName = nil - self.access = nil - - self.fileSearchSession?.cancel() - self.fileSearchSession = nil - self.resetFileSearchState() - - self.users = [] - self.chat = [] - self.messageBoard = [] - self.messageBoardLoaded = false - self.files = [] - self.filesLoaded = false - self.news = [] - self.newsLoaded = false - - self.bannerImage = nil - if let b = self.bannerClient { - b.cancel() - self.bannerClient = nil - } - - self.deleteAllTransfers() - - self.chatSessionKey = nil - self.restoredChatSessionKey = nil - self.lastPersistedMessageType = nil - } - else if status == .loggedIn { - if Prefs.shared.playSounds && Prefs.shared.playLoggedInSound { - SoundEffectPlayer.shared.playSoundEffect(.loggedIn) - } - } - - self.status = status - } - - func hotlineGetUserInfo() -> (String, UInt16) { - return (self.username, UInt16(self.iconID)) - } - - func hotlineReceivedAgreement(text: String) { - let message = ChatMessage(text: text, type: .agreement, date: Date()) - self.recordChatMessage(message, persist: false) - } - - func hotlineReceivedNewsPost(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, - lastPersistedMessageType == .signOut { - return - } - - if display { - self.chat.append(message) - } - - guard shouldPersist, let key = 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 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 - } - - @MainActor func searchChat(query: String) -> [ChatMessage] { - guard !query.isEmpty else { - return [] - } - - // Create a map of all messages by ID to deduplicate (current chat includes restored history) - var messageMap: [UUID: ChatMessage] = [:] - - // Add current in-memory messages (includes both restored history and new 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 { - // Skip consecutive disconnect messages - 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 - } - - func updateServerTitle() { - self.serverTitle = self.serverName ?? self.server?.name ?? server?.address ?? "Server" - } - - private func addOrUpdateHotlineUser(_ user: HotlineUser) { - print("Hotline: users: \n\(self.users)") - if let i = self.users.firstIndex(where: { $0.id == user.id }) { - print("Hotline: updating user \(self.users[i].name)") - self.users[i] = User(hotlineUser: user) - } - else { - if !self.users.isEmpty { - if Prefs.shared.playSounds && Prefs.shared.playJoinSound { - SoundEffectPlayer.shared.playSoundEffect(.userLogin) - } - } - - print("Hotline: added user: \(user.name)") - self.users.append(User(hotlineUser: user)) - if Prefs.shared.showJoinLeaveMessages { - let chatMessage = ChatMessage(text: "\(user.name) joined", type: .joined, date: Date()) - self.recordChatMessage(chatMessage) - } - } - } - - 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 - } - - 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 - } - - private func findNewsArticle(id articleID: UInt32, at path: [String]) -> NewsInfo? { - guard let parent = self.findNews(in: self.news, at: path), !parent.children.isEmpty else { - return nil - } - - return parent.children.first { child in - guard let childArticleID = child.articleID else { - return false - } - - return child.type == .article && child.articleID == childArticleID - } - } -} - -@MainActor -final class FileSearchSession { - private struct FolderTask { - let path: [String] - let depth: Int - let isHot: Bool - } - - private weak var hotline: Hotline? - private let queryTokens: [String] - private let config: FileSearchConfig - - private var queue: [FolderTask] = [] - private var visited: Set = [] - private var loopHistogram: [String: Int] = [:] - - private var processedCount: Int = 0 - private var currentDelay: TimeInterval - private var isCancelled = false - - init(hotline: Hotline, query: String, config: FileSearchConfig) { - self.hotline = hotline - self.queryTokens = query.lowercased().split(separator: " ").map(String.init) - self.config = config - self.currentDelay = config.initialDelay - } - - func start() async { - guard let hotline else { - return - } - - await Task.yield() - - if !hotline.filesLoaded { - hotline.searchSession(self, didFocusOn: []) - let rootFiles = await hotline.getFileList(path: [], suppressErrors: true, preferCache: true) - processedCount = max(processedCount, 1) - processListing(rootFiles, depth: 0, parentPath: [], parentIsHot: false) - } - else { - hotline.searchSession(self, didFocusOn: []) - processedCount = max(processedCount, 1) - processListing(hotline.files, depth: 0, parentPath: [], parentIsHot: false) - } - - while !queue.isEmpty && !isCancelled { - await Task.yield() - - guard let task = dequeueNextTask() else { - continue - } - if shouldSkip(path: task.path, depth: task.depth) { - hotline.searchSession(self, didEmit: [], processed: processedCount, pending: queue.count) - continue - } - - hotline.searchSession(self, didFocusOn: task.path) - visited.insert(pathKey(for: task.path)) - - if let cached = hotline.cachedListingForSearch(path: task.path, ttl: config.cacheTTL) { - if cached.isFresh { - processedCount += 1 - processListing(cached.items, depth: task.depth, parentPath: task.path, parentIsHot: task.isHot) - continue - } else { - processListing(cached.items, depth: task.depth, parentPath: task.path, parentIsHot: task.isHot) - } - } - - let children = await hotline.getFileList(path: task.path, suppressErrors: true) - processedCount += 1 - - if isCancelled { - break - } - - processListing(children, depth: task.depth, parentPath: task.path, parentIsHot: task.isHot) - - await applyBackoff() - } - - hotline.searchSessionDidFinish(self, processed: processedCount, pending: queue.count, completed: !isCancelled) - } - - func cancel() { - isCancelled = true - } - - private func processListing(_ items: [FileInfo], depth: Int, parentPath: [String], parentIsHot: Bool) { - guard let hotline else { - return - } - - var matches: [FileInfo] = [] - var folderEntries: [(file: FileInfo, isHot: Bool)] = [] - var hasFileMatch = false - - for file in items { - let matchesName = 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 config.hotBurstLimit > 0 && (parentIsHot || hasFileMatch) { - remainingBurst = 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 { - enqueueFolder(entry.file, depth: depth + 1, markHot: entry.isHot) - } - - hotline.searchSession(self, didEmit: matches, processed: processedCount, pending: queue.count) - } - - private func enqueueFolder(_ folder: FileInfo, depth: Int, markHot: Bool) { - guard !isCancelled else { return } - guard depth <= config.maxDepth else { return } - - let path = folder.path - let key = pathKey(for: path) - guard !visited.contains(key) else { return } - - if exceedsLoopThreshold(for: path) { - return - } - - queue.append(FolderTask(path: path, depth: depth, isHot: markHot)) - } - - private func dequeueNextTask() -> FolderTask? { - guard !queue.isEmpty else { - return nil - } - - if queue.count == 1 { - return queue.removeFirst() - } - - let currentDepth = queue[0].depth - var lastSameDepthIndex = 0 - var hotIndices: [Int] = [] - - for index in 0.. Bool { - if isCancelled { - return true - } - - if depth > config.maxDepth { - return true - } - - let key = pathKey(for: path) - if visited.contains(key) { - return true - } - - return false - } - - private func nameMatchesQuery(_ name: String) -> Bool { - guard !queryTokens.isEmpty else { return false } - let lowercased = name.lowercased() - return queryTokens.allSatisfy { lowercased.contains($0) } - } - - private func exceedsLoopThreshold(for path: [String]) -> Bool { - guard 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 = (loopHistogram[key] ?? 0) + 1 - loopHistogram[key] = count - return count > config.loopRepetitionLimit - } - - private func pathKey(for path: [String]) -> String { - path.joined(separator: "\u{001F}") - } - - private func applyBackoff() async { - guard !isCancelled else { return } - - if processedCount > config.initialBurstCount { - currentDelay = min(config.maxDelay, max(config.initialDelay, currentDelay * config.backoffMultiplier)) - } - - guard currentDelay > 0 else { - return - } - - let nanoseconds = UInt64(currentDelay * 1_000_000_000) - try? await Task.sleep(nanoseconds: nanoseconds) - } -} diff --git a/Hotline/Models/HotlineState.swift b/Hotline/Models/HotlineState.swift deleted file mode 100644 index d5ab438..0000000 --- a/Hotline/Models/HotlineState.swift +++ /dev/null @@ -1,2468 +0,0 @@ -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) - } -} 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) + } +} diff --git a/Hotline/macOS/Board/MessageBoardView.swift b/Hotline/macOS/Board/MessageBoardView.swift index 8788d66..710ff20 100644 --- a/Hotline/macOS/Board/MessageBoardView.swift +++ b/Hotline/macOS/Board/MessageBoardView.swift @@ -8,91 +8,82 @@ struct MessageBoardView: View { var body: some View { NavigationStack { - if model.access?.contains(.canReadMessageBoard) != false { - ScrollView { - LazyVStack(alignment: .leading) { - ForEach(model.messageBoard, id: \.self) { msg in - Text(LocalizedStringKey(msg)) - .tint(Color("Link Color")) - .lineLimit(100) - .lineSpacing(4) - .padding() - .textSelection(.enabled) - Divider() - } - } - Spacer() + if self.model.access?.contains(.canReadMessageBoard) != false { + if self.model.messageBoardLoaded && self.model.messageBoard.isEmpty { + self.emptyBoardView } - .task { - if !model.messageBoardLoaded { - let _ = try? await model.getMessageBoard() - } + else { + self.messageBoardView } - .overlay { - if !model.messageBoardLoaded { - VStack { - ProgressView() - .controlSize(.large) - } - .frame(maxWidth: .infinity) - } - } - .background(Color(nsColor: .textBackgroundColor)) } else { - ZStack(alignment: .center) { - Text("No Message Board") - .font(.title) - .multilineTextAlignment(.center) - .foregroundStyle(.secondary) - .padding() - } - .frame(maxWidth: .infinity) + self.disabledBoardView } } .sheet(isPresented: $composerDisplayed) { MessageBoardEditorView() .frame(maxWidth: .infinity, maxHeight: .infinity) .frame(idealWidth: 450, idealHeight: 350) -// RichTextEditor(text: $composerText) -// .richEditorFont(NSFont.systemFont(ofSize: 16.0)) -// .richEditorAutomaticDashSubstitution(false) -// .richEditorAutomaticQuoteSubstitution(false) -// .richEditorAutomaticSpellingCorrection(false) -// .background(Color(nsColor: .textBackgroundColor)) -// .frame(maxWidth: .infinity, maxHeight: .infinity) -// .frame(idealWidth: 450, idealHeight: 350) -// .toolbar { -// ToolbarItem(placement: .cancellationAction) { -// Button("Cancel") { -// composerDisplayed.toggle() -// } -// } -// -// ToolbarItem(placement: .primaryAction) { -// Button("Post") { -// composerDisplayed.toggle() -// let text = composerText -// composerText = "" -// model.postToMessageBoard(text: text) -// Task { -// await model.getMessageBoard() -// } -// } -// } -// } } .toolbar { ToolbarItem(placement:.primaryAction) { Button { - composerDisplayed.toggle() + self.composerDisplayed.toggle() } label: { Image(systemName: "square.and.pencil") } - .disabled(model.access?.contains(.canPostMessageBoard) == false) + .disabled(self.model.access?.contains(.canPostMessageBoard) == false) .help("Post to Message Board") } } + .task { + if !self.model.messageBoardLoaded { + let _ = try? await self.model.getMessageBoard() + } + } + } + + private var disabledBoardView: some View { + ContentUnavailableView { + Label("Message Board Disabled", systemImage: "quote.bubble") + } description: { + Text("This server has turned off the message board") + } + } + + private var emptyBoardView: some View { + ContentUnavailableView { + Label("No Posts", systemImage: "quote.bubble") + } description: { + Text("Message board posts will appear here") + } + } + + private var messageBoardView: some View { + ScrollView { + LazyVStack(alignment: .leading) { + ForEach(self.model.messageBoard, id: \.self) { msg in + Text(LocalizedStringKey(msg)) + .tint(Color("Link Color")) + .lineLimit(100) + .lineSpacing(4) + .padding() + .textSelection(.enabled) + Divider() + } + } + Spacer() + } + .overlay { + if !self.model.messageBoardLoaded { + VStack { + ProgressView() + .controlSize(.large) + } + .frame(maxWidth: .infinity) + } + } + .background(Color(nsColor: .textBackgroundColor)) } } diff --git a/Hotline/macOS/Files/FilesView.swift b/Hotline/macOS/Files/FilesView.swift index b499fe6..d4ba5c8 100644 --- a/Hotline/macOS/Files/FilesView.swift +++ b/Hotline/macOS/Files/FilesView.swift @@ -16,142 +16,6 @@ struct FilesView: View { @State private var searchText: String = "" @State private var isSearching: Bool = false @State private var dragOver: Bool = false - - private var isShowingSearchResults: Bool { - switch model.fileSearchStatus { - case .idle: - return !model.fileSearchResults.isEmpty - case .cancelled(_): - return !model.fileSearchResults.isEmpty - default: - return true - } - } - - private var displayedFiles: [FileInfo] { - isShowingSearchResults ? model.fileSearchResults : model.files - } - - private var searchStatusMessage: String? { - switch model.fileSearchStatus { - case .searching(let processed, _): - let scanned = processed == 1 ? "folder" : "folders" - return "Searched \(processed) \(scanned)..." - case .completed(let processed): - let count = model.fileSearchResults.count - let folderWord = processed == 1 ? "folder" : "folders" - if count == 0 { - return "No files found in \(processed) \(folderWord)" - } - return "\(count) file\(count == 1 ? "" : "s") found in \(processed) \(folderWord)" - case .cancelled(_): - if model.fileSearchResults.isEmpty { - return nil - } - return "Search cancelled" - case .failed(let message): - return "Search failed: \(message)" - case .idle: - return nil - } - } - - private var searchStatusPath: String? { - guard let path = model.fileSearchCurrentPath else { - return nil - } - if path.isEmpty { - return "/" - } - return path.joined(separator: "/") - } - - private func openPreviewWindow(_ previewInfo: PreviewFileInfo) { - switch previewInfo.previewType { - case .image: - openWindow(id: "preview-quicklook", value: previewInfo) - case .text: - openWindow(id: "preview-quicklook", value: previewInfo) - case .unknown: - openWindow(id: "preview-quicklook", value: previewInfo) - return - } - } - - @MainActor private func getFileInfo(_ file: FileInfo) { - Task { - if let fileInfo = try? await model.getFileDetails(file.name, path: file.path) { - Task { @MainActor in - self.fileDetails = fileInfo - } - } - } - } - - @MainActor private func downloadFile(_ file: FileInfo) { - if file.isFolder { - model.downloadFolderNew(file.name, path: file.path) - } - else { - model.downloadFileNew(file.name, path: file.path) - } - } - - @MainActor private func uploadFile(file fileURL: URL, to path: [String]) { - model.uploadFile(url: fileURL, path: path) { info in - Task { - // Refresh file listing to display newly uploaded file. - let _ = try? await model.getFileList(path: path) - } - } - } - - @MainActor private func upload(file fileURL: URL, to path: [String]) { - var fileIsDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false), isDirectory: &fileIsDirectory) else { - return - } - - if fileIsDirectory.boolValue { - self.model.uploadFolder(url: fileURL, path: path, complete: { info in - Task { - // Refresh file listing to display newly uploaded file. - try? await model.getFileList(path: path) - } - }) - } - else { - self.model.uploadFile(url: fileURL, path: path) { info in - Task { - // Refresh file listing to display newly uploaded file. - try? await model.getFileList(path: path) - } - } - } - } - - @MainActor private func previewFile(_ file: FileInfo) { - guard file.isPreviewable else { - return - } - - model.previewFile(file.name, path: file.path) { info in - if let info = info { - openPreviewWindow(info) - } - } - } - - private func deleteFile(_ file: FileInfo) async { - var parentPath: [String] = [] - if file.path.count > 1 { - parentPath = Array(file.path[0.. 1 { + parentPath = Array(file.path[0.. Date: Fri, 7 Nov 2025 11:35:59 -0800 Subject: Rename NetSocketNew as NetSocket (old CFStream based NetSocket is gone!) --- Hotline/Hotline/HotlineClientNew.swift | 6 ++-- Hotline/Hotline/HotlineExtensions.swift | 4 +-- Hotline/Hotline/HotlineProtocol.swift | 6 ++-- Hotline/Hotline/HotlineTrackerClient.swift | 2 +- .../Transfers/HotlineFileDownloadClientNew.swift | 8 ++--- .../Transfers/HotlineFilePreviewClientNew.swift | 2 +- .../Transfers/HotlineFileUploadClientNew.swift | 6 ++-- .../Transfers/HotlineFolderDownloadClientNew.swift | 12 +++---- .../Transfers/HotlineFolderUploadClientNew.swift | 10 +++--- Hotline/Library/NetSocket/FileProgress.swift | 2 +- Hotline/Library/NetSocket/NetSocketNew.swift | 38 ++++++++++------------ .../Library/NetSocket/TransferRateEstimator.swift | 8 ++--- Hotline/macOS/ServerView.swift | 1 + 13 files changed, 52 insertions(+), 53 deletions(-) (limited to 'Hotline/macOS') diff --git a/Hotline/Hotline/HotlineClientNew.swift b/Hotline/Hotline/HotlineClientNew.swift index 9e60b5e..8642d42 100644 --- a/Hotline/Hotline/HotlineClientNew.swift +++ b/Hotline/Hotline/HotlineClientNew.swift @@ -126,7 +126,7 @@ public struct HotlineServerInfo: Sendable { public actor HotlineClientNew { // MARK: - Properties - private let socket: NetSocketNew + private let socket: NetSocket private var serverInfo: HotlineServerInfo? private var isConnected: Bool = true @@ -188,7 +188,7 @@ public actor HotlineClientNew { // Connect socket print("HotlineClientNew.connect(): Connecting socket...") - let socket = try await NetSocketNew.connect(host: host, port: port, tls: tls) + let socket = try await NetSocket.connect(host: host, port: port, tls: tls) print("HotlineClientNew.connect(): Socket connected") // Perform handshake @@ -240,7 +240,7 @@ public actor HotlineClientNew { return client } - private init(socket: NetSocketNew) { + private init(socket: NetSocket) { self.socket = socket // Set up event stream diff --git a/Hotline/Hotline/HotlineExtensions.swift b/Hotline/Hotline/HotlineExtensions.swift index 16e9583..049ebf9 100644 --- a/Hotline/Hotline/HotlineExtensions.swift +++ b/Hotline/Hotline/HotlineExtensions.swift @@ -1107,7 +1107,7 @@ extension FourCharCode { } -extension NetSocketNew { +extension NetSocket { /// Read a pascal string (1-byte length prefix followed by string data) /// /// This method reads a single byte for the length, then reads that many bytes and attempts @@ -1145,7 +1145,7 @@ extension NetSocketNew { // Fallback to MacRoman for classic Mac compatibility guard let str = String(data: data, encoding: .macOSRoman) else { throw NetSocketError.decodeFailed(NSError( - domain: "NetSocketNew", + domain: "NetSocket", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to decode pascal string with any known encoding"] )) diff --git a/Hotline/Hotline/HotlineProtocol.swift b/Hotline/Hotline/HotlineProtocol.swift index 43f018e..927cf69 100644 --- a/Hotline/Hotline/HotlineProtocol.swift +++ b/Hotline/Hotline/HotlineProtocol.swift @@ -188,7 +188,7 @@ struct HotlineServer: Identifiable, Hashable, NetSocketDecodable { hasher.combine(self.id) } - init(from socket: NetSocketNew, endian: Endian) async throws { + init(from socket: NetSocket, endian: Endian) async throws { // Read IP address (4 individual bytes) let ip1 = try await socket.read(UInt8.self) let ip2 = try await socket.read(UInt8.self) @@ -986,7 +986,7 @@ extension HotlineTransaction: NetSocketDecodable { /// Decode a Hotline transaction directly from the socket stream /// /// Reads the 20-byte header, then reads and decodes the variable-length body with fields. - init(from socket: NetSocketNew, endian: Endian) async throws { + init(from socket: NetSocket, endian: Endian) async throws { // Read 20-byte header let flags = try await socket.read(UInt8.self) let isReply = try await socket.read(UInt8.self) @@ -1173,7 +1173,7 @@ enum HotlineTransactionType: UInt16 { // /// - Unused: 2 bytes // /// - Server name: Pascal string (1-byte length + data) // /// - Server description: Pascal string (1-byte length + data) -// init(from socket: NetSocketNew, endian: Endian) async throws { +// init(from socket: NetSocket, endian: Endian) async throws { // // Read IP address (4 individual bytes) // let ip1 = try await socket.read(UInt8.self) // let ip2 = try await socket.read(UInt8.self) diff --git a/Hotline/Hotline/HotlineTrackerClient.swift b/Hotline/Hotline/HotlineTrackerClient.swift index 52d34c1..f72735d 100644 --- a/Hotline/Hotline/HotlineTrackerClient.swift +++ b/Hotline/Hotline/HotlineTrackerClient.swift @@ -72,7 +72,7 @@ class HotlineTrackerClient { private func doFetch(address: String, port: Int, continuation: AsyncThrowingStream.Continuation) async throws { // Connect to tracker (plaintext, no TLS) - let socket = try await NetSocketNew.connect( + let socket = try await NetSocket.connect( host: address, port: UInt16(port), tls: .disabled diff --git a/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift index ced79a1..77d2e38 100644 --- a/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFileDownloadClientNew.swift @@ -39,7 +39,7 @@ public class HotlineFileDownloadClientNew { private let transferTotal: Int private var transferProgress: Progress - private var socket: NetSocketNew? + private var socket: NetSocket? private var downloadTask: Task? // MARK: - Initialization @@ -251,14 +251,14 @@ public class HotlineFileDownloadClientNew { // MARK: - Helper Methods - private func connectToTransferServer() async throws -> NetSocketNew { + 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 NetSocketNew.connect( + let socket = try await NetSocket.connect( host: .name(serverAddress, nil), port: transferPort, tls: .disabled @@ -268,7 +268,7 @@ public class HotlineFileDownloadClientNew { return socket } - private func sendMagicHeader(socket: NetSocketNew) async throws { + private func sendMagicHeader(socket: NetSocket) async throws { let header = Data(endian: .big) { "HTXF".fourCharCode() referenceNumber diff --git a/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift b/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift index 9839997..63fe099 100644 --- a/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFilePreviewClientNew.swift @@ -101,7 +101,7 @@ public class HotlineFilePreviewClientNew { print("HotlineFilePreviewClientNew[\(referenceNumber)]: Connecting to \(serverAddress):\(serverPort + 1)") - let socket = try await NetSocketNew.connect( + let socket = try await NetSocket.connect( host: .name(serverAddress, nil), port: transferPort, tls: .disabled diff --git a/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift index 10934e7..5acff2a 100644 --- a/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFileUploadClientNew.swift @@ -24,7 +24,7 @@ public class HotlineFileUploadClientNew { private let transferTotal: Int private var transferProgress: Progress - private var socket: NetSocketNew? + private var socket: NetSocket? private var uploadTask: Task? // MARK: - Initialization @@ -236,14 +236,14 @@ public class HotlineFileUploadClientNew { // MARK: - Helper Methods - private func connectToTransferServer() async throws -> NetSocketNew { + 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 NetSocketNew.connect( + let socket = try await NetSocket.connect( host: .name(serverAddress, nil), port: transferPort, tls: .disabled diff --git a/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift index 4ad35e7..254a1c6 100644 --- a/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFolderDownloadClientNew.swift @@ -20,7 +20,7 @@ public class HotlineFolderDownloadClientNew { private let folderItemCount: Int private var transferSize: Int = 0 - private var socket: NetSocketNew? + private var socket: NetSocket? private var downloadTask: Task? private var folderProgress: Progress? @@ -245,14 +245,14 @@ public class HotlineFolderDownloadClientNew { // MARK: - Helper Methods - private func connectToTransferServer() async throws -> NetSocketNew { + 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 NetSocketNew.connect( + let socket = try await NetSocket.connect( host: .name(serverAddress, nil), port: transferPort, tls: .disabled @@ -262,7 +262,7 @@ public class HotlineFolderDownloadClientNew { return socket } - private func sendAction(socket: NetSocketNew, action: HotlineFolderAction) async throws { + private func sendAction(socket: NetSocket, action: HotlineFolderAction) async throws { let actionData = Data(endian: .big) { action.rawValue } @@ -297,7 +297,7 @@ public class HotlineFolderDownloadClientNew { } private func downloadFile( - socket: NetSocketNew, + socket: NetSocket, fileName: String, parentPath: [String], destinationFolder: URL, @@ -383,7 +383,7 @@ public class HotlineFolderDownloadClientNew { fileDataForkSize = Int(forkHeader.dataSize) - // Stream data fork using NetSocketNew's optimized file streaming + // Stream data fork using NetSocket's optimized file streaming let updates = await socket.receiveFile(to: fh, length: fileDataForkSize) for try await p in updates { // Calculate overall folder progress diff --git a/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift b/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift index 2efced2..38532f1 100644 --- a/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift +++ b/Hotline/Hotline/Transfers/HotlineFolderUploadClientNew.swift @@ -38,7 +38,7 @@ public class HotlineFolderUploadClientNew { private var folderItems: [FolderItem] = [] private var totalItems: Int = 0 - private var socket: NetSocketNew? + private var socket: NetSocket? private var uploadTask: Task? // MARK: - Initialization @@ -281,12 +281,12 @@ public class HotlineFolderUploadClientNew { progressHandler?(.completed(url: nil)) } - private func connect(address: String, port: UInt16) async throws -> NetSocketNew { + 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 NetSocketNew.connect( + return try await NetSocket.connect( host: .name(address, nil), port: transferPort, tls: .disabled @@ -361,7 +361,7 @@ public class HotlineFolderUploadClientNew { } } - private func readAction(socket: NetSocketNew) async throws -> HotlineFolderAction { + 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 { @@ -371,7 +371,7 @@ public class HotlineFolderUploadClientNew { } private func uploadFile( - socket: NetSocketNew, + socket: NetSocket, fileURL: URL, itemNumber: Int, totalItems: Int, diff --git a/Hotline/Library/NetSocket/FileProgress.swift b/Hotline/Library/NetSocket/FileProgress.swift index c086af7..1cc0228 100644 --- a/Hotline/Library/NetSocket/FileProgress.swift +++ b/Hotline/Library/NetSocket/FileProgress.swift @@ -4,7 +4,7 @@ import Foundation -public extension NetSocketNew { +public extension NetSocket { /// Progress information for file uploads/downloads struct FileProgress: Sendable { diff --git a/Hotline/Library/NetSocket/NetSocketNew.swift b/Hotline/Library/NetSocket/NetSocketNew.swift index 7b24b37..12f4271 100644 --- a/Hotline/Library/NetSocket/NetSocketNew.swift +++ b/Hotline/Library/NetSocket/NetSocketNew.swift @@ -1,4 +1,4 @@ -// NetSocketNew.swift +// NetSocket.swift // Dustin Mierau • @mierau import Foundation @@ -86,11 +86,9 @@ public enum NetSocketError: Error, CustomStringConvertible, Sendable { } } -// MARK: - NetSocketNew - /// An async/await TCP socket with automatic buffering and framing support /// -/// NetSocketNew provides: +/// NetSocket provides: /// - Async connection management /// - Automatic receive buffering with memory compaction /// - Type-safe reading/writing of integers, strings, and custom types @@ -98,11 +96,11 @@ public enum NetSocketError: Error, CustomStringConvertible, Sendable { /// /// Example usage: /// ```swift -/// let socket = try await NetSocketNew.connect(host: "example.com", port: 80) +/// 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) /// ``` -public actor NetSocketNew { +public actor NetSocket { /// Configuration options for the socket public struct Config: Sendable { /// Size of chunks to receive from network at once (default: 64 KB) @@ -153,9 +151,9 @@ public actor NetSocketNew { /// - port: Network framework port /// - tls: TLS policy (default: enabled with default settings) /// - config: Socket configuration (default: standard settings) - /// - Returns: A connected and ready `NetSocketNew` + /// - 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 -> NetSocketNew { + 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() @@ -164,13 +162,13 @@ public actor NetSocketNew { } let conn = NWConnection(host: host, port: port, using: parameters) - let socket = NetSocketNew(connection: conn, config: config) + let socket = NetSocket(connection: conn, config: config) try await socket.start() return socket } /// 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 -> NetSocketNew { + public static func connect(host: String, port: UInt16, tls: TLSPolicy = .enabled(), config: Config = .init()) async throws -> NetSocket { guard let nwPort = NWEndpoint.Port(rawValue: port) else { throw NetSocketError.invalidPort } @@ -506,7 +504,7 @@ public actor NetSocketNew { // 1. Open file and get length (blocking I/O, done off-actor) do { - total = Int(try NetSocketNew.fileLength(at: url)) + total = Int(try NetSocket.fileLength(at: url)) fh = try FileHandle(forReadingFrom: url) } catch { continuation.finish(throwing: NetSocketError.failed(underlying: error)) @@ -792,11 +790,11 @@ public actor NetSocketNew { } private func startReceiveLoop() { - @Sendable func loop(_ connection: NWConnection, chunk: Int, owner: NetSocketNew, connID: String) { - print("NetSocketNew[\(connID)]: Calling connection.receive(\(chunk)) to request more data...") + @Sendable func loop(_ connection: NWConnection, chunk: Int, owner: NetSocket, connID: String) { + print("NetSocket[\(connID)]: Calling connection.receive(\(chunk)) to request more data...") connection.receive(minimumIncompleteLength: 1, maximumLength: chunk) { [weak owner] data, _, isComplete, error in - print("NetSocketNew[\(connID)]: Receive callback - data: \(data?.count ?? 0) bytes, isComplete: \(isComplete), error: \(String(describing: error))") + print("NetSocket[\(connID)]: Receive callback - data: \(data?.count ?? 0) bytes, isComplete: \(isComplete), error: \(String(describing: error))") Task { guard let o = owner else { return @@ -810,7 +808,7 @@ public actor NetSocketNew { await o.append(data, connID: connID) } if isComplete { - print("NetSocketNew[\(connID)]: EOF from peer.") + print("NetSocket[\(connID)]: EOF from peer.") await o.handleEOF() return } @@ -952,7 +950,7 @@ public actor NetSocketNew { } private func append(_ data: Data, connID: String) { - print("NetSocketNew[\(connID)]: Received \(data.count) bytes from network, buffer now has \(buffer.count - head + data.count) available") + print("NetSocket[\(connID)]: Received \(data.count) bytes from network, buffer now has \(buffer.count - head + data.count) available") buffer.append(data) if buffer.count - head > config.maxBufferBytes { // Hard stop: drop connection rather than OOM'ing. @@ -1038,7 +1036,7 @@ public protocol NetSocketEncodable: Sendable { /// let id: UInt32 /// let name: String /// -/// init(from socket: NetSocketNew, endian: Endian) async throws { +/// init(from socket: NetSocket, endian: Endian) async throws { /// self.id = try await socket.read(UInt32.self, endian: endian) /// let nameLen = try await socket.read(UInt16.self, endian: endian) /// let nameData = try await socket.readExactly(Int(nameLen)) @@ -1064,10 +1062,10 @@ public protocol NetSocketDecodable: Sendable { /// - socket: Socket to read from /// - endian: Byte order for multi-byte values /// - Throws: Network errors, insufficient data, or custom decoding errors - init(from socket: NetSocketNew, endian: Endian) async throws + init(from socket: NetSocket, endian: Endian) async throws } -public extension NetSocketNew { +public extension NetSocket { /// Send an encodable value to the socket /// /// The type encodes itself to binary data, which is then sent in a single write operation. @@ -1109,7 +1107,7 @@ public extension NetSocketNew { /// let id: UInt32 /// let name: String /// - /// init(from socket: NetSocketNew, endian: Endian) async throws { + /// init(from socket: NetSocket, endian: Endian) async throws { /// self.id = try await socket.read(UInt32.self, endian: endian) /// // Read variable-length string... /// } diff --git a/Hotline/Library/NetSocket/TransferRateEstimator.swift b/Hotline/Library/NetSocket/TransferRateEstimator.swift index 7d16904..60647a7 100644 --- a/Hotline/Library/NetSocket/TransferRateEstimator.swift +++ b/Hotline/Library/NetSocket/TransferRateEstimator.swift @@ -70,7 +70,7 @@ public struct TransferRateEstimator { self.minSamples = minSamples } - public mutating func update(total: Int) -> NetSocketNew.FileProgress { + public mutating func update(total: Int) -> NetSocket.FileProgress { return self.update(bytes: max(0, total - self.transferred)) } @@ -80,7 +80,7 @@ public struct TransferRateEstimator { /// /// - Parameter bytes: Number of bytes transferred in this sample /// - Returns: Current progress with speed and ETA estimates - public mutating func update(bytes: Int) -> NetSocketNew.FileProgress { + public mutating func update(bytes: Int) -> NetSocket.FileProgress { let clock = ContinuousClock() let now = clock.now @@ -102,7 +102,7 @@ public struct TransferRateEstimator { let instantRate = Double(bytes) / seconds self.sampleCount += 1 - // Update EMA + // Update EMANetSocket if self.emaBytesPerSecond == 0 { self.emaBytesPerSecond = instantRate } else { @@ -124,7 +124,7 @@ public struct TransferRateEstimator { eta = nil } - return NetSocketNew.FileProgress( + return NetSocket.FileProgress( sent: self.transferred, total: self.total, bytesPerSecond: haveEstimate ? self.emaBytesPerSecond : nil, diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index df3e54b..a66a071 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -135,6 +135,7 @@ struct ServerView: View { Text(error) .font(.caption) .foregroundStyle(.secondary) + .multilineTextAlignment(.center) } .frame(maxWidth: 300) .padding() -- cgit From a55318fa8d643160900bec3e6b14e7404c63497a Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 7 Nov 2025 12:44:55 -0800 Subject: Organize Library folder a bit. Update Tracker add/edit sheet design. --- Hotline.xcodeproj/project.pbxproj | 72 +- Hotline/Hotline/HotlineProtocol.swift | 5 + Hotline/Library/AsyncLinkPreview.swift | 57 - Hotline/Library/BookmarkDocument.swift | 32 + Hotline/Library/ColorArt.swift | 424 ++++++++ Hotline/Library/DataAdditions.swift | 23 - Hotline/Library/Extensions.swift | 192 ++++ Hotline/Library/FileIconView.swift | 74 -- Hotline/Library/FileImageView.swift | 98 -- Hotline/Library/HotlinePanel.swift | 64 -- Hotline/Library/NetSocket/FileProgress.swift | 2 +- Hotline/Library/NetSocket/NetSocket.swift | 1123 +++++++++++++++++++ Hotline/Library/NetSocket/NetSocketNew.swift | 1127 -------------------- .../Library/NetSocket/TransferRateEstimator.swift | 2 +- Hotline/Library/QuickLookPreviewView.swift | 22 - Hotline/Library/RegularExpressions.swift | 235 ++++ Hotline/Library/SoundEffects.swift | 50 + Hotline/Library/SpinningGlobeView.swift | 90 -- Hotline/Library/TextView.swift | 119 --- Hotline/Library/URLAdditions.swift | 55 - Hotline/Library/Utility/DAKeychain.swift | 81 ++ Hotline/Library/Utility/NSWindowBridge.swift | 46 + Hotline/Library/Utility/ObservableScrollView.swift | 38 + Hotline/Library/Utility/TextDocument.swift | 31 + Hotline/Library/Views/AsyncLinkPreview.swift | 57 + Hotline/Library/Views/BetterTextEditor.swift | 119 +++ Hotline/Library/Views/FileIconView.swift | 74 ++ Hotline/Library/Views/FileImageView.swift | 98 ++ Hotline/Library/Views/GroupedIconView.swift | 23 + Hotline/Library/Views/HotlinePanel.swift | 64 ++ Hotline/Library/Views/QuickLookPreviewView.swift | 22 + Hotline/Library/Views/SpinningGlobeView.swift | 90 ++ Hotline/Library/Views/VisualEffectView.swift | 22 + Hotline/Library/VisualEffectView.swift | 22 - Hotline/Utility/BookmarkDocument.swift | 32 - Hotline/Utility/ColorArt.swift | 424 -------- Hotline/Utility/DAKeychain.swift | 81 -- Hotline/Utility/FoundationExtensions.swift | 107 -- Hotline/Utility/NSWindowBridge.swift | 46 - Hotline/Utility/ObservableScrollView.swift | 38 - Hotline/Utility/RegularExpressions.swift | 235 ---- Hotline/Utility/SoundEffects.swift | 50 - Hotline/Utility/SwiftUIExtensions.swift | 8 - Hotline/Utility/TextDocument.swift | 31 - Hotline/macOS/TrackerView.swift | 49 +- 45 files changed, 2900 insertions(+), 2854 deletions(-) delete mode 100644 Hotline/Library/AsyncLinkPreview.swift create mode 100644 Hotline/Library/BookmarkDocument.swift create mode 100644 Hotline/Library/ColorArt.swift delete mode 100644 Hotline/Library/DataAdditions.swift create mode 100644 Hotline/Library/Extensions.swift delete mode 100644 Hotline/Library/FileIconView.swift delete mode 100644 Hotline/Library/FileImageView.swift delete mode 100644 Hotline/Library/HotlinePanel.swift create mode 100644 Hotline/Library/NetSocket/NetSocket.swift delete mode 100644 Hotline/Library/NetSocket/NetSocketNew.swift delete mode 100644 Hotline/Library/QuickLookPreviewView.swift create mode 100644 Hotline/Library/RegularExpressions.swift create mode 100644 Hotline/Library/SoundEffects.swift delete mode 100644 Hotline/Library/SpinningGlobeView.swift delete mode 100644 Hotline/Library/TextView.swift delete mode 100644 Hotline/Library/URLAdditions.swift create mode 100644 Hotline/Library/Utility/DAKeychain.swift create mode 100644 Hotline/Library/Utility/NSWindowBridge.swift create mode 100644 Hotline/Library/Utility/ObservableScrollView.swift create mode 100644 Hotline/Library/Utility/TextDocument.swift create mode 100644 Hotline/Library/Views/AsyncLinkPreview.swift create mode 100644 Hotline/Library/Views/BetterTextEditor.swift create mode 100644 Hotline/Library/Views/FileIconView.swift create mode 100644 Hotline/Library/Views/FileImageView.swift create mode 100644 Hotline/Library/Views/GroupedIconView.swift create mode 100644 Hotline/Library/Views/HotlinePanel.swift create mode 100644 Hotline/Library/Views/QuickLookPreviewView.swift create mode 100644 Hotline/Library/Views/SpinningGlobeView.swift create mode 100644 Hotline/Library/Views/VisualEffectView.swift delete mode 100644 Hotline/Library/VisualEffectView.swift delete mode 100644 Hotline/Utility/BookmarkDocument.swift delete mode 100644 Hotline/Utility/ColorArt.swift delete mode 100644 Hotline/Utility/DAKeychain.swift delete mode 100644 Hotline/Utility/FoundationExtensions.swift delete mode 100644 Hotline/Utility/NSWindowBridge.swift delete mode 100644 Hotline/Utility/ObservableScrollView.swift delete mode 100644 Hotline/Utility/RegularExpressions.swift delete mode 100644 Hotline/Utility/SoundEffects.swift delete mode 100644 Hotline/Utility/SwiftUIExtensions.swift delete mode 100644 Hotline/Utility/TextDocument.swift (limited to 'Hotline/macOS') diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index cb621a0..c63109a 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -32,6 +32,7 @@ DA4B8F3A2EA6FB3C00CBFD53 /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4B8F392EA6FB3C00CBFD53 /* iOSApp.swift */; platformFilter = ios; }; DA4F2BF82B16A17200D8ADDC /* HotlineProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4F2BF72B16A17200D8ADDC /* HotlineProtocol.swift */; }; DA4F2C012B1A558E00D8ADDC /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4F2C002B1A558E00D8ADDC /* ChatView.swift */; platformFilter = ios; }; + DA501BE22EBE9018001714F8 /* GroupedIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA501BE12EBE9018001714F8 /* GroupedIconView.swift */; }; 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, ); }; @@ -55,7 +56,7 @@ DA65499A2BEC280E00EDB697 /* ServerMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6549992BEC280E00EDB697 /* ServerMessageView.swift */; }; DA65499C2BEC3FBD00EDB697 /* ServerAgreementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA65499B2BEC3FBD00EDB697 /* ServerAgreementView.swift */; }; DA65499E2BEC438A00EDB697 /* NSWindowBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA65499D2BEC438A00EDB697 /* NSWindowBridge.swift */; platformFilters = (macos, ); }; - DA6980792BF80525003E434B /* TextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6980782BF80525003E434B /* TextView.swift */; }; + DA6980792BF80525003E434B /* BetterTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6980782BF80525003E434B /* BetterTextEditor.swift */; }; DA69807E2BFD449B003E434B /* MessageBoardEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA69807D2BFD449B003E434B /* MessageBoardEditorView.swift */; }; DA6980832BFFD06C003E434B /* BookmarkDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6980822BFFD06C003E434B /* BookmarkDocument.swift */; }; DA72A0DD2B4CD0BF00A0F48A /* NewsEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA72A0DC2B4CD0BF00A0F48A /* NewsEditorView.swift */; }; @@ -76,9 +77,8 @@ DAAEE66F2B47625600A5BA07 /* FilePreviewImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAAEE66E2B47625600A5BA07 /* FilePreviewImageView.swift */; platformFilters = (macos, ); }; DAB4D87B2B4B78310048A05C /* FileImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAB4D87A2B4B78310048A05C /* FileImageView.swift */; platformFilters = (macos, ); }; DAB4D87E2B4C8BCA0048A05C /* FilePreviewTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAB4D87D2B4C8BCA0048A05C /* FilePreviewTextView.swift */; platformFilters = (macos, ); }; - DAB4D8802B4C8E9A0048A05C /* URLAdditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAB4D87F2B4C8E9A0048A05C /* URLAdditions.swift */; }; DAB4D8822B4C8FED0048A05C /* FileIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAB4D8812B4C8FED0048A05C /* FileIconView.swift */; }; - DAB4D8842B4CABEF0048A05C /* DataAdditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAB4D8832B4CABEF0048A05C /* DataAdditions.swift */; }; + DAB4D8842B4CABEF0048A05C /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAB4D8832B4CABEF0048A05C /* Extensions.swift */; }; DAB4D8872B4CB3610048A05C /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = DAB4D8862B4CB3610048A05C /* MarkdownUI */; }; 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 */; }; @@ -89,11 +89,9 @@ 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 */; }; - DABFCC292B1530DC009F40D2 /* FoundationExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABFCC282B1530DC009F40D2 /* FoundationExtensions.swift */; }; - DAC002192B21630900A6C290 /* SwiftUIExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC002182B21630900A6C290 /* SwiftUIExtensions.swift */; }; DAC3D9832BC33FD000A727C9 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC3D9822BC33FD000A727C9 /* AppState.swift */; }; DAC6B2E02EAC6236004E2CBA /* ChatStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC6B2DE2EAC6236004E2CBA /* ChatStore.swift */; }; - DAC6B2E22EAEE9FE004E2CBA /* NetSocketNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC6B2E12EAEE9EF004E2CBA /* NetSocketNew.swift */; }; + DAC6B2E22EAEE9FE004E2CBA /* NetSocket.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC6B2E12EAEE9EF004E2CBA /* NetSocket.swift */; }; DAC6B2E42EAFE92F004E2CBA /* SpinningGlobeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC6B2E32EAFE92F004E2CBA /* SpinningGlobeView.swift */; }; DAC87F072C5010E80060FADF /* HotlineExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC87F062C5010E80060FADF /* HotlineExtensions.swift */; }; DACCE5E12EABE4B4008CDD92 /* AppUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DACCE5E02EABE4B4008CDD92 /* AppUpdate.swift */; platformFilters = (macos, ); }; @@ -140,6 +138,7 @@ DA4B8F392EA6FB3C00CBFD53 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; }; DA4F2BF72B16A17200D8ADDC /* HotlineProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineProtocol.swift; sourceTree = ""; }; DA4F2C002B1A558E00D8ADDC /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = ""; }; + DA501BE12EBE9018001714F8 /* GroupedIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupedIconView.swift; sourceTree = ""; }; DA52689B2EB0738B00DCB941 /* GeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsView.swift; sourceTree = ""; }; DA52689D2EB073A400DCB941 /* IconSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconSettingsView.swift; sourceTree = ""; }; DA52689F2EB073BC00DCB941 /* SoundSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundSettingsView.swift; sourceTree = ""; }; @@ -163,7 +162,7 @@ DA6549992BEC280E00EDB697 /* ServerMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerMessageView.swift; sourceTree = ""; }; DA65499B2BEC3FBD00EDB697 /* ServerAgreementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerAgreementView.swift; sourceTree = ""; }; DA65499D2BEC438A00EDB697 /* NSWindowBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSWindowBridge.swift; sourceTree = ""; }; - DA6980782BF80525003E434B /* TextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextView.swift; sourceTree = ""; }; + DA6980782BF80525003E434B /* BetterTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BetterTextEditor.swift; sourceTree = ""; }; DA69807D2BFD449B003E434B /* MessageBoardEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageBoardEditorView.swift; sourceTree = ""; }; DA6980822BFFD06C003E434B /* BookmarkDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkDocument.swift; sourceTree = ""; }; DA72A0DC2B4CD0BF00A0F48A /* NewsEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsEditorView.swift; sourceTree = ""; }; @@ -184,9 +183,8 @@ DAAEE66E2B47625600A5BA07 /* FilePreviewImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePreviewImageView.swift; sourceTree = ""; }; DAB4D87A2B4B78310048A05C /* FileImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileImageView.swift; sourceTree = ""; }; DAB4D87D2B4C8BCA0048A05C /* FilePreviewTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePreviewTextView.swift; sourceTree = ""; }; - DAB4D87F2B4C8E9A0048A05C /* URLAdditions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLAdditions.swift; sourceTree = ""; }; DAB4D8812B4C8FED0048A05C /* FileIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileIconView.swift; sourceTree = ""; }; - DAB4D8832B4CABEF0048A05C /* DataAdditions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataAdditions.swift; sourceTree = ""; }; + DAB4D8832B4CABEF0048A05C /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.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 = ""; }; @@ -196,11 +194,9 @@ 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 = ""; }; - DABFCC282B1530DC009F40D2 /* FoundationExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoundationExtensions.swift; sourceTree = ""; }; - DAC002182B21630900A6C290 /* SwiftUIExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIExtensions.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 /* NetSocketNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetSocketNew.swift; sourceTree = ""; }; + DAC6B2E12EAEE9EF004E2CBA /* NetSocket.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetSocket.swift; sourceTree = ""; }; DAC6B2E32EAFE92F004E2CBA /* SpinningGlobeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpinningGlobeView.swift; sourceTree = ""; }; DAC87F062C5010E80060FADF /* HotlineExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineExtensions.swift; sourceTree = ""; }; DACCE5E02EABE4B4008CDD92 /* AppUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdate.swift; sourceTree = ""; }; @@ -279,6 +275,22 @@ path = iOS; sourceTree = ""; }; + DA501BE02EBE844F001714F8 /* Views */ = { + isa = PBXGroup; + children = ( + DA501BE12EBE9018001714F8 /* GroupedIconView.swift */, + DA872B122BDDBF78008B1012 /* HotlinePanel.swift */, + DAB4D87A2B4B78310048A05C /* FileImageView.swift */, + DAB4D8812B4C8FED0048A05C /* FileIconView.swift */, + DA3429B62EBAB1750010784E /* QuickLookPreviewView.swift */, + DAC6B2E32EAFE92F004E2CBA /* SpinningGlobeView.swift */, + DA872B142BDDEE1A008B1012 /* VisualEffectView.swift */, + DA55AC722BE42AF000034857 /* AsyncLinkPreview.swift */, + DA6980782BF80525003E434B /* BetterTextEditor.swift */, + ); + path = Views; + sourceTree = ""; + }; DA52689A2EB0737400DCB941 /* Settings */ = { isa = PBXGroup; children = ( @@ -344,7 +356,7 @@ DA5268B62EB916AB00DCB941 /* NetSocket */ = { isa = PBXGroup; children = ( - DAC6B2E12EAEE9EF004E2CBA /* NetSocketNew.swift */, + DAC6B2E12EAEE9EF004E2CBA /* NetSocket.swift */, DA5268B92EB91B5E00DCB941 /* FileProgress.swift */, DA5268B72EB916AF00DCB941 /* TransferRateEstimator.swift */, ); @@ -379,7 +391,6 @@ DADDB2892B22B2C60024040D /* Models */, DABFCC262B1530AE009F40D2 /* Hotline */, DAB4D87C2B4B783A0048A05C /* Library */, - DABFCC272B1530BE009F40D2 /* Utility */, DA4B8F3C2EA6FDCE00CBFD53 /* Assets */, DA9CAFC02B126D5800CDA197 /* Assets.xcassets */, DA32CD472B28EE640053B98B /* Info.plist */, @@ -400,17 +411,14 @@ DAB4D87C2B4B783A0048A05C /* Library */ = { isa = PBXGroup; children = ( + DAB4D8832B4CABEF0048A05C /* Extensions.swift */, + DA5268AA2EB11EA300DCB941 /* ColorArt.swift */, + DAE735062B3251B3000C56F6 /* SoundEffects.swift */, + DA55AC782BE6A1AD00034857 /* RegularExpressions.swift */, + DA6980822BFFD06C003E434B /* BookmarkDocument.swift */, + DA501BE02EBE844F001714F8 /* Views */, DA5268B62EB916AB00DCB941 /* NetSocket */, - DAC6B2E32EAFE92F004E2CBA /* SpinningGlobeView.swift */, - DAB4D87A2B4B78310048A05C /* FileImageView.swift */, - DAB4D8812B4C8FED0048A05C /* FileIconView.swift */, - DAB4D8832B4CABEF0048A05C /* DataAdditions.swift */, - DAB4D87F2B4C8E9A0048A05C /* URLAdditions.swift */, - DA3429B62EBAB1750010784E /* QuickLookPreviewView.swift */, - DA872B122BDDBF78008B1012 /* HotlinePanel.swift */, - DA872B142BDDEE1A008B1012 /* VisualEffectView.swift */, - DA55AC722BE42AF000034857 /* AsyncLinkPreview.swift */, - DA6980782BF80525003E434B /* TextView.swift */, + DABFCC272B1530BE009F40D2 /* Utility */, ); path = Library; sourceTree = ""; @@ -447,16 +455,10 @@ DABFCC272B1530BE009F40D2 /* Utility */ = { isa = PBXGroup; children = ( - DABFCC282B1530DC009F40D2 /* FoundationExtensions.swift */, - DA5268AA2EB11EA300DCB941 /* ColorArt.swift */, - DAC002182B21630900A6C290 /* SwiftUIExtensions.swift */, DA7725402B21435B006C5ABB /* ObservableScrollView.swift */, - DAE735062B3251B3000C56F6 /* SoundEffects.swift */, DA57536B2B36BA1D00FAC277 /* TextDocument.swift */, DABE8C032B57940A00884D28 /* DAKeychain.swift */, - DA55AC782BE6A1AD00034857 /* RegularExpressions.swift */, DA65499D2BEC438A00EDB697 /* NSWindowBridge.swift */, - DA6980822BFFD06C003E434B /* BookmarkDocument.swift */, ); path = Utility; sourceTree = ""; @@ -605,7 +607,6 @@ DAC3D9832BC33FD000A727C9 /* AppState.swift in Sources */, DA65499C2BEC3FBD00EDB697 /* ServerAgreementView.swift in Sources */, DA5268BC2EB95A7700DCB941 /* HotlineFolderDownloadClientNew.swift in Sources */, - DAB4D8802B4C8E9A0048A05C /* URLAdditions.swift in Sources */, DA2863DD2B3E8B7000A7D050 /* FilePreview.swift in Sources */, DA3429B02EBA70790010784E /* HotlineFolderUploadClientNew.swift in Sources */, DA5268AB2EB11EA300DCB941 /* ColorArt.swift in Sources */, @@ -616,7 +617,6 @@ DA5268B52EB6840A00DCB941 /* TransfersView.swift in Sources */, 11F8288B2BF9428100216BA0 /* AccountManagerView.swift in Sources */, DAC6B2E42EAFE92F004E2CBA /* SpinningGlobeView.swift in Sources */, - DAC002192B21630900A6C290 /* SwiftUIExtensions.swift in Sources */, DADDB28D2B22B5920024040D /* Server.swift in Sources */, DA5268AF2EB2682B00DCB941 /* HotlineClientNew.swift in Sources */, DABE8C042B57940B00884D28 /* DAKeychain.swift in Sources */, @@ -625,12 +625,13 @@ DAE734FB2B2E41F9000C56F6 /* TrackerView.swift in Sources */, DA5268A32EB0741B00DCB941 /* FolderItemView.swift in Sources */, DAE136BA2B9D1147007D8307 /* HotlinePanelView.swift in Sources */, + DA501BE22EBE9018001714F8 /* GroupedIconView.swift in Sources */, 11A726082BE0672A000C1DA7 /* FileDetails.swift in Sources */, DA9CAFBD2B126D5700CDA197 /* TrackerView.swift in Sources */, DA5268B82EB916AF00DCB941 /* TransferRateEstimator.swift in Sources */, DAB4D87B2B4B78310048A05C /* FileImageView.swift in Sources */, DA3429B52EBA8A450010784E /* FilePreviewState.swift in Sources */, - DA6980792BF80525003E434B /* TextView.swift in Sources */, + DA6980792BF80525003E434B /* BetterTextEditor.swift in Sources */, DA55AC732BE42AF000034857 /* AsyncLinkPreview.swift in Sources */, DA65499E2BEC438A00EDB697 /* NSWindowBridge.swift in Sources */, DAE735032B30C0BB000C56F6 /* MessageView.swift in Sources */, @@ -653,12 +654,11 @@ 11A7260A2BE0675A000C1DA7 /* FileDetailsView.swift in Sources */, DA72A0DD2B4CD0BF00A0F48A /* NewsEditorView.swift in Sources */, DA52689C2EB0738B00DCB941 /* GeneralSettingsView.swift in Sources */, - DABFCC292B1530DC009F40D2 /* FoundationExtensions.swift in Sources */, DA57536C2B36BA1D00FAC277 /* TextDocument.swift in Sources */, DACCE5E12EABE4B4008CDD92 /* AppUpdate.swift in Sources */, DA5268AD2EB12FE200DCB941 /* ServerState.swift in Sources */, DA77253F2B21176D006C5ABB /* NewsView.swift in Sources */, - DAC6B2E22EAEE9FE004E2CBA /* NetSocketNew.swift in Sources */, + DAC6B2E22EAEE9FE004E2CBA /* NetSocket.swift in Sources */, DA99218E2C51AA5D0058FA6C /* HotlineDataBuilder.swift in Sources */, DA872B132BDDBF78008B1012 /* HotlinePanel.swift in Sources */, DA4B8F3A2EA6FB3C00CBFD53 /* iOSApp.swift in Sources */, @@ -670,7 +670,7 @@ DA872B152BDDEE1A008B1012 /* VisualEffectView.swift in Sources */, DA3429AE2EB9C0280010784E /* HotlineFileUploadClientNew.swift in Sources */, DAAEE66D2B475F1400A5BA07 /* PreviewFileInfo.swift in Sources */, - DAB4D8842B4CABEF0048A05C /* DataAdditions.swift in Sources */, + DAB4D8842B4CABEF0048A05C /* Extensions.swift in Sources */, DA0D698D2B1E7CF700C71DF5 /* UsersView.swift in Sources */, DAE735052B3218D8000C56F6 /* NewsView.swift in Sources */, DA5268A52EB0743000DCB941 /* FileItemView.swift in Sources */, diff --git a/Hotline/Hotline/HotlineProtocol.swift b/Hotline/Hotline/HotlineProtocol.swift index 927cf69..3870261 100644 --- a/Hotline/Hotline/HotlineProtocol.swift +++ b/Hotline/Hotline/HotlineProtocol.swift @@ -1,5 +1,10 @@ import Foundation +enum Endianness { + case big + case little +} + struct HotlinePorts { static let DefaultServerPort: Int = 5500 static let DefaultTrackerPort: Int = 5498 diff --git a/Hotline/Library/AsyncLinkPreview.swift b/Hotline/Library/AsyncLinkPreview.swift deleted file mode 100644 index 89b186f..0000000 --- a/Hotline/Library/AsyncLinkPreview.swift +++ /dev/null @@ -1,57 +0,0 @@ -import SwiftUI -import LinkPresentation - -fileprivate class CustomLinkView: LPLinkView { - override var intrinsicContentSize: CGSize { CGSize(width: 0, height: super.intrinsicContentSize.height) } -} - -struct AsyncLinkPreview: View { - @State private var metadata: LPLinkMetadata? - @State private var isLoading = true - let url: URL? - - func fetchMetadata() async { - guard let url else { - self.isLoading = false - return - } - do { - let provider = LPMetadataProvider() - let metadata = try await provider.startFetchingMetadata(for: url) - self.metadata = metadata - self.isLoading = false - } catch { - self.isLoading = false - } - } - - var body: some View { - if isLoading { - ProgressView() - .controlSize(.small) - .task { - await self.fetchMetadata() - } - } else if let metadata = metadata { - LinkView(metadata: metadata) - .frame(width: 200) - } else { - Text(LocalizedStringKey(url!.absoluteString)) - .multilineTextAlignment(.leading) - .tint(Color("Link Color")) - } - } -} - -struct LinkView: NSViewRepresentable { - var metadata: LPLinkMetadata - - func makeNSView(context: Context) -> LPLinkView { - let linkView = CustomLinkView(metadata: metadata) - return linkView - } - - func updateNSView(_ nsView: LPLinkView, context: Context) { - // Nothing required - } -} diff --git a/Hotline/Library/BookmarkDocument.swift b/Hotline/Library/BookmarkDocument.swift new file mode 100644 index 0000000..47fa442 --- /dev/null +++ b/Hotline/Library/BookmarkDocument.swift @@ -0,0 +1,32 @@ +import SwiftUI +import Foundation +import UniformTypeIdentifiers + +struct BookmarkDocument: FileDocument { + static var readableContentTypes: [UTType] { [.data, UTType(filenameExtension: "hlbm")!] } + static var writableContentTypes: [UTType] { [.data, UTType(filenameExtension: "hlbm")!] } + + var bookmark: Bookmark + + init(bookmark: Bookmark) { + self.bookmark = bookmark + } + + init(configuration: ReadConfiguration) throws { + guard configuration.file.isRegularFile, + let data = configuration.file.regularFileContents, + let fileName = configuration.file.preferredFilename, + let bookmark = Bookmark(fileData: data, name: (fileName as NSString).deletingPathExtension) + else { + throw CocoaError(.fileReadCorruptFile) + } + self.bookmark = bookmark + } + + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + let wrapper = FileWrapper(regularFileWithContents: self.bookmark.bookmarkFileData()!) + wrapper.fileAttributes[FileAttributeKey.hfsCreatorCode.rawValue] = "HTLC".fourCharCode() + wrapper.fileAttributes[FileAttributeKey.hfsTypeCode.rawValue] = "HTbm".fourCharCode() + return wrapper + } +} diff --git a/Hotline/Library/ColorArt.swift b/Hotline/Library/ColorArt.swift new file mode 100644 index 0000000..93889a3 --- /dev/null +++ b/Hotline/Library/ColorArt.swift @@ -0,0 +1,424 @@ + +// Swift translation and modernization +// by Dustin Mierau +// +// of: +// +// ColorArt.swift +// SLColorArt by Panic Inc. +// +// Copyright (C) 2012 Panic Inc. Code by Wade Cosgrove. All rights reserved. +// +// Redistribution and use, with or without modification, are permitted +// provided that the following conditions are met: +// +// - Redistributions must reproduce the above copyright notice, this list of +// conditions and the following disclaimer in the documentation and/or other +// materials provided with the distribution. +// +// - Neither the name of Panic Inc nor the names of its contributors may be used +// to endorse or promote works derived from this software without specific prior +// written permission from Panic Inc. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +// ARE DISCLAIMED. IN NO EVENT SHALL PANIC INC BE LIABLE FOR ANY DIRECT, INDIRECT, +// INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +// PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +// POSSIBILITY OF SUCH DAMAGE. + +import AppKit +import SwiftUI + +fileprivate let kColorThresholdMinimumPercentage: CGFloat = 0.001 + +// ColorArt.analyze(image: img) -> ColorArt? + +struct ColorArt: Equatable { + let backgroundColor: NSColor + let primaryColor: NSColor + let secondaryColor: NSColor + let detailColor: NSColor +// let scaledImage: NSImage + + static func == (lhs: ColorArt, rhs: ColorArt) -> Bool { + return lhs.backgroundColor == rhs.backgroundColor && + lhs.primaryColor == rhs.primaryColor && + lhs.secondaryColor == rhs.secondaryColor && + lhs.detailColor == rhs.detailColor + } + + static func analyze(image: NSImage) -> ColorArt? { + print("ColorArt.analyze: Starting, image size: \(image.size)") + // Scale image to a reasonable size for analysis + // This is important because: + // 1. Makes analysis faster (fewer pixels) + // 2. Normalizes weird image dimensions + // 3. Ensures CGImage conversion succeeds + print("ColorArt.analyze: Calling scaleImage...") + let finalImage = Self.scaleImage(image, size: NSSize(width: 100, height: 100)) + print("ColorArt.analyze: scaleImage returned, scaled size: \(finalImage.size)") + + guard let colors = Self.analyzeImage(finalImage) else { + print("ColorArt.analyze: failed with no colors") + return nil + } + + print("ColorArt.analyze: returning colors", colors) + + return ColorArt(backgroundColor: colors.background, + primaryColor: colors.primary, + secondaryColor: colors.secondary, + detailColor: colors.detail) + } + + // MARK: - Image Scaling + + private static func scaleImage(_ image: NSImage, size scaledSize: NSSize) -> NSImage { + print("ColorArt.scaleImage: Entered, input: \(image.size), target: \(scaledSize)") + // Get CGImage directly without using lockFocus + print("ColorArt.scaleImage: Getting CGImage...") + guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + print("ColorArt.scaleImage: Failed to get CGImage, returning original") + return image + } + print("ColorArt.scaleImage: Got CGImage") + + let imageSize = image.size + let squareSize = min(imageSize.width, imageSize.height) + + // Use native square size if passed zero size + let finalScaledSize = scaledSize == .zero ? NSSize(width: squareSize, height: squareSize) : scaledSize + + // Create bitmap context for drawing + let width = Int(finalScaledSize.width) + let height = Int(finalScaledSize.height) + let colorSpace = CGColorSpaceCreateDeviceRGB() + let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue) + + guard let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width * 4, + space: colorSpace, + bitmapInfo: bitmapInfo.rawValue + ) else { + return image + } + + // Draw the image scaled + context.interpolationQuality = .high + context.draw(cgImage, in: CGRect(x: 0, y: 0, width: finalScaledSize.width, height: finalScaledSize.height)) + + // Create NSImage from context + guard let scaledCGImage = context.makeImage() else { + return image + } + + let bitmapRep = NSBitmapImageRep(cgImage: scaledCGImage) + let finalImage = NSImage(size: finalScaledSize) + finalImage.addRepresentation(bitmapRep) + + return finalImage + } + + // MARK: - Image Analysis + + private static func analyzeImage(_ image: NSImage) -> (background: NSColor, primary: NSColor, secondary: NSColor, detail: NSColor)? { + var imageColors: NSCountedSet? + guard let backgroundColor = self.findEdgeColor(image, imageColors: &imageColors), + let colors = imageColors + else { + return nil + } + + let darkBackground = backgroundColor.isDarkColor + var primaryColor: NSColor? + var secondaryColor: NSColor? + var detailColor: NSColor? + + self.findTextColors(colors, primaryColor: &primaryColor, secondaryColor: &secondaryColor, detailColor: &detailColor, backgroundColor: backgroundColor) + + // Fallback to black or white if colors not found + if primaryColor == nil { + primaryColor = darkBackground ? .white : .black + } + + if secondaryColor == nil { + secondaryColor = darkBackground ? .white : .black + } + + if detailColor == nil { + detailColor = darkBackground ? .white : .black + } + + // Convert all colors to calibrated RGB color space for consistency + // This ensures all colors are in the same color space and prevents + // any color space conversion issues when used in SwiftUI + let rgbColorSpace = NSColorSpace.genericRGB + let finalBackground = backgroundColor.usingColorSpace(rgbColorSpace) ?? backgroundColor + let finalPrimary = primaryColor!.usingColorSpace(rgbColorSpace) ?? primaryColor! + let finalSecondary = secondaryColor!.usingColorSpace(rgbColorSpace) ?? secondaryColor! + let finalDetail = detailColor!.usingColorSpace(rgbColorSpace) ?? detailColor! + + return (finalBackground, finalPrimary, finalSecondary, finalDetail) + } + + // MARK: - Edge Color Detection + + private static func findEdgeColor(_ image: NSImage, imageColors: inout NSCountedSet?) -> NSColor? { + var bitmapRep: NSBitmapImageRep? + + // Try to get existing bitmap representation + if let existingRep = image.representations.last as? NSBitmapImageRep { + bitmapRep = existingRep + } else { + // Create bitmap rep from CGImage instead of using lockFocus + guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + return nil + } + bitmapRep = NSBitmapImageRep(cgImage: cgImage) + } + + // Convert to RGB color space + guard let bitmapRep = bitmapRep?.converting(to: .genericRGB, renderingIntent: .default) else { + return nil + } + + let pixelsWide = bitmapRep.pixelsWide + let pixelsHigh = bitmapRep.pixelsHigh + + let colors = NSCountedSet(capacity: pixelsWide * pixelsHigh) + let leftEdgeColors = NSCountedSet(capacity: pixelsHigh) + var searchColumnX = 0 + + for x in 0.. 0.5 { + leftEdgeColors.add(color) + } + } + + if color.alphaComponent > CGFloat.ulpOfOne { + colors.add(color) + } + } + + // Background is clear, keep looking in next column for background color + if leftEdgeColors.count == 0 { + searchColumnX += 1 + } + } + + imageColors = colors + + var sortedColors: [CountedColor] = [] + + for color in leftEdgeColors { + guard let nsColor = color as? NSColor else { continue } + let colorCount = leftEdgeColors.count(for: nsColor) + + let randomColorsThreshold = Int(CGFloat(pixelsHigh) * kColorThresholdMinimumPercentage) + + if colorCount <= randomColorsThreshold { + continue + } + + sortedColors.append(CountedColor(color: nsColor, count: colorCount)) + } + + sortedColors.sort { $0.count > $1.count } + + guard var proposedEdgeColor = sortedColors.first else { + return nil + } + + // Want to choose color over black/white so we keep looking + if proposedEdgeColor.color.isBlackOrWhite { + for i in 1.. 0.3 { + if !nextProposedColor.color.isBlackOrWhite { + proposedEdgeColor = nextProposedColor + break + } + } else { + // Reached color threshold less than 30% of the original proposed edge color + break + } + } + } + + return proposedEdgeColor.color + } + + // MARK: - Text Color Detection + + private static func findTextColors(_ colors: NSCountedSet, primaryColor: inout NSColor?, secondaryColor: inout NSColor?, detailColor: inout NSColor?, backgroundColor: NSColor) { + var sortedColors: [CountedColor] = [] + let findDarkTextColor = !backgroundColor.isDarkColor + + for color in colors { + guard let nsColor = color as? NSColor else { continue } + let adjustedColor = nsColor.withMinimumSaturation(0.15) + + if adjustedColor.isDarkColor == findDarkTextColor { + let colorCount = colors.count(for: nsColor) + sortedColors.append(CountedColor(color: adjustedColor, count: colorCount)) + } + } + + sortedColors.sort { $0.count > $1.count } + + for container in sortedColors { + let curColor = container.color + + if primaryColor == nil { + if curColor.isContrasting(to: backgroundColor) { + primaryColor = curColor + } + } else if secondaryColor == nil { + if let primary = primaryColor, + primary.isDistinct(from: curColor) && curColor.isContrasting(to: backgroundColor) { + secondaryColor = curColor + } + } else if detailColor == nil { + if let primary = primaryColor, + let secondary = secondaryColor, + secondary.isDistinct(from: curColor) && + primary.isDistinct(from: curColor) && + curColor.isContrasting(to: backgroundColor) { + detailColor = curColor + break + } + } + } + } +} + +// MARK: - Helper Classes + +fileprivate struct CountedColor { + let color: NSColor + let count: Int +} + +// MARK: - NSColor Extensions + +extension NSColor { + var isDarkColor: Bool { + guard let convertedColor = usingColorSpace(.genericRGB) else { + return false + } + + var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + convertedColor.getRed(&r, green: &g, blue: &b, alpha: &a) + + let lum = 0.2126 * r + 0.7152 * g + 0.0722 * b + + return lum < 0.5 + } + + func isDistinct(from compareColor: NSColor) -> Bool { + guard let convertedColor = usingColorSpace(.genericRGB), + let convertedCompareColor = compareColor.usingColorSpace(.genericRGB) + else { + return false + } + + var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + var r1: CGFloat = 0, g1: CGFloat = 0, b1: CGFloat = 0, a1: CGFloat = 0 + + convertedColor.getRed(&r, green: &g, blue: &b, alpha: &a) + convertedCompareColor.getRed(&r1, green: &g1, blue: &b1, alpha: &a1) + + let threshold: CGFloat = 0.25 + + if abs(r - r1) > threshold || abs(g - g1) > threshold || abs(b - b1) > threshold || abs(a - a1) > threshold { + // Check for grays, prevent multiple gray colors + if abs(r - g) < 0.03 && abs(r - b) < 0.03 { + if abs(r1 - g1) < 0.03 && abs(r1 - b1) < 0.03 { + return false + } + } + + return true + } + + return false + } + + func withMinimumSaturation(_ minSaturation: CGFloat) -> NSColor { + guard let tempColor = usingColorSpace(.genericRGB) else { + return self + } + + var hue: CGFloat = 0, saturation: CGFloat = 0, brightness: CGFloat = 0, alpha: CGFloat = 0 + tempColor.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) + + if saturation < minSaturation { + return NSColor(calibratedHue: hue, saturation: minSaturation, brightness: brightness, alpha: alpha) + } + + return self + } + + var isBlackOrWhite: Bool { + guard let tempColor = usingColorSpace(.genericRGB) else { + return false + } + + var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + tempColor.getRed(&r, green: &g, blue: &b, alpha: &a) + + // White + if r > 0.91 && g > 0.91 && b > 0.91 { + return true + } + + // Black + if r < 0.09 && g < 0.09 && b < 0.09 { + return true + } + + return false + } + + func isContrasting(to color: NSColor) -> Bool { + guard let backgroundColor = usingColorSpace(.genericRGB), + let foregroundColor = color.usingColorSpace(.genericRGB) + else { + return true + } + + var br: CGFloat = 0, bg: CGFloat = 0, bb: CGFloat = 0, ba: CGFloat = 0 + var fr: CGFloat = 0, fg: CGFloat = 0, fb: CGFloat = 0, fa: CGFloat = 0 + + backgroundColor.getRed(&br, green: &bg, blue: &bb, alpha: &ba) + foregroundColor.getRed(&fr, green: &fg, blue: &fb, alpha: &fa) + + let bLum = 0.2126 * br + 0.7152 * bg + 0.0722 * bb + let fLum = 0.2126 * fr + 0.7152 * fg + 0.0722 * fb + + let contrast: CGFloat + if bLum > fLum { + contrast = (bLum + 0.05) / (fLum + 0.05) + } else { + contrast = (fLum + 0.05) / (bLum + 0.05) + } + + return contrast > 1.6 + } +} diff --git a/Hotline/Library/DataAdditions.swift b/Hotline/Library/DataAdditions.swift deleted file mode 100644 index 0e9dd96..0000000 --- a/Hotline/Library/DataAdditions.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Foundation - -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 - } - } - return false - } -} diff --git a/Hotline/Library/Extensions.swift b/Hotline/Library/Extensions.swift new file mode 100644 index 0000000..469676c --- /dev/null +++ b/Hotline/Library/Extensions.swift @@ -0,0 +1,192 @@ +import Foundation +import SwiftUI +import UniformTypeIdentifiers + +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 + } + } + return false + } +} + +// MARK: - + +extension URL { + func generateUniqueFilePath(filename base: String) -> String { + let fileManager = FileManager.default + var finalName = base + var counter = 2 + + // Helper function to generate a new filename with a counter + func makeFileName() -> String { + let baseName = (base as NSString).deletingPathExtension + let extensionName = (base as NSString).pathExtension + return extensionName.isEmpty ? "\(baseName) \(counter)" : "\(baseName) \(counter).\(extensionName)" + } + + // Check if file exists and append counter until a unique name is found + var filePath = self.appending(component: finalName).path(percentEncoded: false) + while fileManager.fileExists(atPath: filePath) { + finalName = makeFileName() + filePath = self.appending(component: finalName).path(percentEncoded: false) + counter += 1 + } + + return filePath + } +} + +// MARK: - + +extension UTType { + var canBePreviewedByQuickLook: Bool { + // QuickLook supports most common document types + let supportedSupertypes: [UTType] = [ + .image, + .movie, + .audio, + .pdf, + .font, + .usdz, + .text, + .sourceCode, + .spreadsheet, + .presentation, + +// Microsoft Office + .init(filenameExtension: "doc")!, + .init(filenameExtension: "docx")!, + .init(filenameExtension: "xls")!, + .init(filenameExtension: "xlsx")!, + .init(filenameExtension: "ppt")!, + .init(filenameExtension: "pptx")!, + ] + + return supportedSupertypes.contains { self.conforms(to: $0) } + } +} + +// MARK: - + +extension String { + + func markdownToAttributedString() -> AttributedString { + let markdownText = self.convertingLinksToMarkdown() + let attr = (try? AttributedString(markdown: markdownText, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(self) + + return attr + } + + func convertToAttributedStringWithLinks() -> AttributedString { + let attributedString: NSMutableAttributedString = NSMutableAttributedString(string: self) + let matches = self.ranges(of: RegularExpressions.relaxedLink) + for match in matches { + let matchString = String(self[match]) + if matchString.isEmailAddress() { + attributedString.addAttribute(.link, value: "mailto:\(matchString)", range: NSRange(match, in: self)) + } + else { + attributedString.addAttribute(.link, value: matchString, range: NSRange(match, in: self)) + } +// attributedString.addAttribute(.underlineStyle, value: 1, range: NSRange(match, in: self)) + } + return AttributedString(attributedString) + } + + func isEmailAddress() -> Bool { + self.wholeMatch(of: RegularExpressions.emailAddress) != nil + } + + func isWebURL() -> Bool { + guard let url = URL(string: self) else { + return false + } + switch url.scheme?.lowercased() { + case "http", "https": + return true + default: + return false + } + } + + func isImageURL() -> Bool { + guard let url = URL(string: self) else { + return false + } + + switch url.pathExtension.lowercased() { + case "jpg", "jpeg", "png", "gif": + return true + default: + return false + } + } + + func convertingLinksToMarkdown() -> String { +// var cp = String(self) + + self.replacing(RegularExpressions.relaxedLink) { match in + let linkText = self[match.range] + + // Only add https:// if the link doesn't already have a scheme + let hasScheme = (try? RegularExpressions.supportedLinkScheme.prefixMatch(in: linkText)) != nil + let url = hasScheme ? String(linkText) : "https://\(linkText)" + + return "[\(linkText)](\(url))" + } + +// cp.replace(RegularExpressions.relaxedLink) { match -> String in +// let linkText = self[match.range] +// var injectedScheme = "https://" +// if let _ = try? RegularExpressions.supportedLinkScheme.prefixMatch(in: linkText) { +// injectedScheme = "" +// } +// +// return "[\(linkText)](\(injectedScheme)\(linkText))" +// } +// return cp + } +} + +// MARK: - + +extension Binding where Value: OptionSet, Value == Value.Element { + func bindedValue(_ options: Value) -> Bool { + return wrappedValue.contains(options) + } + + func bind(_ options: Value) -> Binding { + return .init { () -> Bool in + self.wrappedValue.contains(options) + } set: { newValue in + if newValue { + self.wrappedValue.insert(options) + } else { + self.wrappedValue.remove(options) + } + } + } +} + +// MARK: - + +extension Color { + init(hex: Int, opacity: Double = 1.0) { + self.init(red: Double((hex >> 16) & 0xFF) / 255.0, green: Double((hex >> 8) & 0xFF) / 255.0, blue: Double(hex & 0xFF) / 255.0, opacity: opacity) + } +} diff --git a/Hotline/Library/FileIconView.swift b/Hotline/Library/FileIconView.swift deleted file mode 100644 index d0949fa..0000000 --- a/Hotline/Library/FileIconView.swift +++ /dev/null @@ -1,74 +0,0 @@ -import SwiftUI -import UniformTypeIdentifiers - -struct FolderIconView: View { - private func folderIcon() -> Image { -#if os(iOS) - return Image(systemName: "folder.fill") -#elseif os(macOS) - return Image(nsImage: NSWorkspace.shared.icon(for: UTType.folder)) -#endif - } - - var body: some View { - folderIcon() - .resizable() - .scaledToFit() - } -} - -struct FileIconView: View { - let filename: String - let fileType: String? - - #if os(iOS) - private func fileIcon() -> Image { - let fileExtension = (self.filename as NSString).pathExtension - if let fileType = UTType(filenameExtension: fileExtension) { - if fileType.isSubtype(of: .movie) { - return Image(systemName: "play.rectangle") - } - else if fileType.isSubtype(of: .image) { - return Image(systemName: "photo") - } - else if fileType.isSubtype(of: .archive) { - return Image(systemName: "doc.zipper") - } - else if fileType.isSubtype(of: .text) { - return Image(systemName: "doc.text") - } - else { - return Image(systemName: "doc") - } - } - - return Image(systemName: "doc") - } - #elseif os(macOS) - private func fileIcon() -> Image { - let fileExtension = (self.filename as NSString).pathExtension - - if !fileExtension.isEmpty, - let uttype = UTType(filenameExtension: fileExtension) { - return Image(nsImage: NSWorkspace.shared.icon(for: uttype)) - } - else if let fileType = self.fileType, - let fileTypeExtension = FileManager.HFSTypeToExtension[fileType.lowercased()], - let uttype = UTType(filenameExtension: fileTypeExtension) { - return Image(nsImage: NSWorkspace.shared.icon(for: uttype)) - } - else { - return Image(nsImage: NSWorkspace.shared.icon(for: UTType.data)) - } - -// Image(nsImage: NSWorkspace.shared.icon(for: UTType(filenameExtension: (filename as NSString).pathExtension) ?? UTType.content)) - } - #endif - - - var body: some View { - fileIcon() - .resizable() - .scaledToFit() - } -} diff --git a/Hotline/Library/FileImageView.swift b/Hotline/Library/FileImageView.swift deleted file mode 100644 index 0cc50f1..0000000 --- a/Hotline/Library/FileImageView.swift +++ /dev/null @@ -1,98 +0,0 @@ -import SwiftUI - -struct FileImageView: NSViewRepresentable { - var image: NSImage? - - let minimumSize: CGSize = CGSize(width: 350, height: 350) - let presentationPaddingRatio: Double = 0.5 - - func makeNSView(context: Context) -> NSImageView { - let imageView = NSImageView() - imageView.imageScaling = .scaleProportionallyUpOrDown - imageView.animates = true - imageView.isEditable = false - imageView.allowsCutCopyPaste = true - imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) - imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - - if let img = self.image { - imageView.image = img - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - self.resizeWindowForImageView(imageView) - } - } - - return imageView - } - - func updateNSView(_ nsView: NSImageView, context: Context) { - nsView.image = self.image - } - - // MARK: - - - func resizeWindowForImageView(_ imageView: NSImageView) { - guard let window = imageView.window, let img = imageView.image else { - return - } - - var windowRect = window.contentLayoutRect - let windowChromeSize = CGSize(width: window.frame.width - windowRect.width, height: window.frame.height - windowRect.height) - - var windowMinSize: CGSize = windowRect.size - let centerPoint = CGPoint(x: window.frame.midX, y: window.frame.midY) - - windowRect.size = img.size - - if let screen = window.screen { - var paddedScreenSize = screen.frame.size - paddedScreenSize.width *= self.presentationPaddingRatio - paddedScreenSize.height *= self.presentationPaddingRatio - - windowMinSize = aspectFit(source: windowRect.size, bounds: self.minimumSize) - windowRect.size = aspectFit(source: windowRect.size, bounds: paddedScreenSize, minimum: windowMinSize) - } - - windowRect.size.width += windowChromeSize.width - windowRect.size.height += windowChromeSize.height - - windowRect.origin.x = centerPoint.x - windowRect.width / 2.0 - windowRect.origin.y = centerPoint.y - windowRect.height / 2.0 - - window.setFrame(windowRect, display: true, animate: true) - -// Do these APIs even work?? -// window.aspectRatio = windowRect.size -// window.contentAspectRatio = windowRect.size - } - - func aspectFit(source sourceSize: CGSize, bounds boundingSize: CGSize, minimum minSize: CGSize? = nil) -> CGSize { - let sourceAspectRatio = sourceSize.width / sourceSize.height - - var fitSize: CGSize = sourceSize - - if fitSize.width > boundingSize.width { - fitSize.width = boundingSize.width - fitSize.height = fitSize.width / sourceAspectRatio - } - - if fitSize.height > boundingSize.height { - fitSize.height = boundingSize.height - fitSize.width = fitSize.height * sourceAspectRatio - } - - if let m = minSize { - if fitSize.width < m.width { - fitSize.width = m.width - fitSize.height = fitSize.width / sourceAspectRatio - } - - if fitSize.height < m.height { - fitSize.height = m.height - fitSize.width = fitSize.height * sourceAspectRatio - } - } - - return fitSize - } -} diff --git a/Hotline/Library/HotlinePanel.swift b/Hotline/Library/HotlinePanel.swift deleted file mode 100644 index d7d8284..0000000 --- a/Hotline/Library/HotlinePanel.swift +++ /dev/null @@ -1,64 +0,0 @@ -import Cocoa -import SwiftUI - -fileprivate let HOTLINE_PANEL_SIZE: CGSize = CGSizeMake(468, 114 - 10) - -class HotlinePanel: NSPanel { - init(_ view: HotlinePanelView) { - super.init(contentRect: NSRect(x: 0, y: 0, width: HOTLINE_PANEL_SIZE.width, height: HOTLINE_PANEL_SIZE.height), styleMask: [.nonactivatingPanel, .titled, .closable, .utilityWindow, .fullSizeContentView], backing: .buffered, defer: false) - - // Make sure that the panel is in front of almost all other windows - self.isFloatingPanel = true - self.level = .floating - self.hidesOnDeactivate = true - self.animationBehavior = .utilityWindow - - // Allow the panelto appear in a fullscreen space -// self.collectionBehavior.insert(.fullScreenAuxiliary) - self.collectionBehavior.insert(.canJoinAllSpaces) - self.collectionBehavior.insert(.ignoresCycle) - -// self.appearance = NSAppearance(named: .vibrantDark) - - // Don't delete panel state when it's closed. - self.isReleasedWhenClosed = false - - self.standardWindowButton(.closeButton)?.isHidden = false - self.standardWindowButton(.zoomButton)?.isHidden = true - self.standardWindowButton(.miniaturizeButton)?.isHidden = true - - // Make it transparent, the view inside will have to set the background. - // This is necessary because otherwise, we will have some space for the titlebar on top of the height of the view itself which we don't want. - self.isOpaque = false - self.backgroundColor = .clear - - // Since we don't show a statusbar, this allows us to drag the window by its background instead of the titlebar. - self.isMovableByWindowBackground = true - self.titlebarAppearsTransparent = true - - let hostingView = NSHostingView(rootView: view.edgesIgnoringSafeArea(.top)) - hostingView.sizingOptions = [.preferredContentSize] - - let visualEffectView = NSVisualEffectView(frame: NSRect(x: 0, y: 0, width: HOTLINE_PANEL_SIZE.width, height: HOTLINE_PANEL_SIZE.height)) - visualEffectView.material = .sidebar - visualEffectView.blendingMode = .behindWindow - visualEffectView.state = NSVisualEffectView.State.active - visualEffectView.autoresizingMask = [.width, .height] - visualEffectView.autoresizesSubviews = true - visualEffectView.addSubview(hostingView) - - self.contentView = visualEffectView - - hostingView.frame = visualEffectView.bounds - - self.cascadeTopLeft(from: NSMakePoint(16, 16)) - } - - override var canBecomeKey: Bool { - return false - } - - override var canBecomeMain: Bool { - return false - } -} diff --git a/Hotline/Library/NetSocket/FileProgress.swift b/Hotline/Library/NetSocket/FileProgress.swift index 1cc0228..c2306f3 100644 --- a/Hotline/Library/NetSocket/FileProgress.swift +++ b/Hotline/Library/NetSocket/FileProgress.swift @@ -1,4 +1,4 @@ -// NetSocketProgress +// NetSocket FileProgress // Dustin Mierau • @mierau // MIT License diff --git a/Hotline/Library/NetSocket/NetSocket.swift b/Hotline/Library/NetSocket/NetSocket.swift new file mode 100644 index 0000000..5c7d185 --- /dev/null +++ b/Hotline/Library/NetSocket/NetSocket.swift @@ -0,0 +1,1123 @@ +// NetSocket.swift +// Dustin Mierau • @mierau +// MIT License + +import Foundation +import Network + +/// Byte order for multi-byte integer values in binary protocols +public enum Endian { + /// Big-endian (network byte order, most significant byte first) + case big + /// Little-endian (least significant byte first) + case little +} + +/// Delimiter patterns for text-based protocols +public enum Delimiter { + /// Custom single byte delimiter + case byte(UInt8) + /// Null terminator (0x00) + case zeroByte + /// Line feed (\n, 0x0A) + case lineFeed + /// Carriage return + line feed (\r\n, 0x0D 0x0A) + case carriageReturnLineFeed + + /// Binary representation of this delimiter + var data: Data { + switch self { + case .byte(let b): return Data([b]) + case .zeroByte: return Data([0x00]) + case .lineFeed: return Data([0x0A]) + case .carriageReturnLineFeed: return Data([0x0D, 0x0A]) + } + } +} + +/// TLS/SSL encryption policy for socket connections +public struct TLSPolicy: Sendable { + /// Create a TLS-enabled policy with optional custom configuration + /// - Parameter configure: Optional closure to customize TLS options + public static func enabled(_ configure: (@Sendable (NWProtocolTLS.Options) -> Void)? = nil) -> TLSPolicy { + TLSPolicy(enabled: true, configure: configure) + } + + /// Create a policy with TLS disabled (plaintext connection) + public static var disabled: TLSPolicy { TLSPolicy(enabled: false, configure: nil) } + + /// Whether TLS is enabled + public let enabled: Bool + /// Optional TLS configuration closure + public let configure: (@Sendable (NWProtocolTLS.Options) -> Void)? +} + +// MARK: - Errors + +/// Errors that can occur during socket operations +public enum NetSocketError: Error, CustomStringConvertible, Sendable { + /// Socket is not yet in ready state + case notReady + /// Connection has been closed + case closed + /// Invalid port number provided + case invalidPort + /// Network operation failed with underlying error + case failed(underlying: Error) + /// Not enough data available to fulfill read request + case insufficientData(expected: Int, got: Int) + /// Frame size exceeds configured maximum + case framingExceeded(max: Int) + /// Failed to decode data + case decodeFailed(Error) + /// Failed to encode data + case encodeFailed(Error) + + public var description: String { + switch self { + case .notReady: return "Connection not ready." + case .closed: return "Connection closed." + case .invalidPort: return "Invalid port number." + case .failed(let e): return "Network failure: \(e.localizedDescription)" + case .insufficientData(let exp, let got): return "Insufficient data: need \(exp), have \(got)." + case .framingExceeded(let max): return "Frame length exceeded maximum \(max)." + case .decodeFailed(let e): return "Decoding failed: \(e)" + case .encodeFailed(let e): return "Encoding failed: \(e)" + } + } +} + +/// An async/await TCP socket with automatic buffering and framing support +/// +/// NetSocket provides: +/// - Async connection management +/// - Automatic receive buffering with memory compaction +/// - Type-safe reading/writing of integers, strings, and custom types +/// - File upload/download with progress tracking +/// +/// Example usage: +/// ```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) +/// ``` +public actor NetSocket { + /// Configuration options for the socket + public struct Config: Sendable { + /// Size of chunks to receive from network at once (default: 64 KB) + public var receiveChunk: Int = 64 * 1024 + /// Maximum bytes to buffer before disconnecting (default: 8 MB) + public var maxBufferBytes: Int = 8 * 1024 * 1024 + public init() {} + } + + // Connection + state + private let connection: NWConnection + private let queue = DispatchQueue(label: "NetSocket.NWConnection") + private var ready = false + private var isClosed = false + private let connectionID: String // For logging + + // Buffer with compaction + private var buffer = Data() + private var head = 0 // start of unread bytes + private let config: Config + + // Waiters for data/ready + private var dataWaiters: [CheckedContinuation] = [] + private var readyWaiters: [CheckedContinuation] = [] + + // MARK: Init + + private init(connection: NWConnection, config: Config) { + self.connection = connection + self.config = config + // Create a human-readable connection ID for logging + if case .hostPort(host: let h, port: let p) = connection.endpoint { + self.connectionID = "\(h):\(p)" + } else { + self.connectionID = "unknown" + } + } + + // MARK: Connect + + /// Connect to a remote host and return a ready socket + /// + /// This method establishes a TCP connection using Network framework types and waits until + /// the connection is in `.ready` state. + /// + /// - 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) + /// - 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) + } + + let conn = NWConnection(host: host, port: port, using: parameters) + let socket = NetSocket(connection: conn, config: config) + try await socket.start() + return socket + } + + /// 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 { + guard let nwPort = NWEndpoint.Port(rawValue: port) else { + throw NetSocketError.invalidPort + } + return try await self.connect(host: .name(host, nil), port: nwPort, tls: tls, config: config) + } + + // MARK: Close + + /// Close the connection gracefully + /// + /// Performs a graceful shutdown of the underlying network connection (e.g., TCP FIN) + /// and wakes all pending read/write operations with a `NetSocketError.closed` error. + /// This method is idempotent - subsequent calls are ignored. + /// + /// Use `forceClose()` for immediate non-graceful termination (e.g., TCP RST). + public func close() { + guard !isClosed else { return } + isClosed = true + connection.cancel() + resumeDataWaiters() + resumeReadyWaiters(with: .failure(NetSocketError.closed)) + } + + /// Force close the connection immediately (non-graceful) + /// + /// Performs an immediate non-graceful shutdown of the underlying network connection + /// (e.g., TCP RST). Use this when you need to terminate the connection immediately + /// without waiting for graceful closure. For normal shutdown, use `close()` instead. + /// + /// This method is idempotent - subsequent calls are ignored. + public func forceClose() { + guard !isClosed else { return } + isClosed = true + connection.forceCancel() + resumeDataWaiters() + resumeReadyWaiters(with: .failure(NetSocketError.closed)) + } + + // MARK: Send Data + + /// Write raw data to the socket + /// + /// Sends data and waits for confirmation that it has been processed by the network stack. + /// + /// - Parameter data: Raw bytes to send + /// - Throws: `NetSocketError` if connection is not ready or send fails + @discardableResult + public func write(_ data: Data) async throws -> Int { + try await ensureReady() + return try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + connection.send(content: data, completion: .contentProcessed { error in + if let error { cont.resume(throwing: NetSocketError.failed(underlying: error)) } + else { cont.resume(returning: data.count) } + }) + } + } + + /// Write a fixed-width integer to the socket + /// + /// - Parameters: + /// - value: The integer value to write + /// - endian: Byte order (default: big-endian) + /// - Throws: `NetSocketError` if write fails + @discardableResult + public func write(_ value: T, endian: Endian = .big) async throws -> Int { + var v = value + switch endian { + case .big: v = T(bigEndian: value) + case .little: v = T(littleEndian: value) + } + var copy = v + let size = MemoryLayout.size + let bytes = withUnsafePointer(to: ©) { + Data(bytes: $0, count: size) + } + try await write(bytes) + return bytes.count + } + + /// Write a boolean as a single byte (0 or 1) + /// - Parameter value: Boolean value + @discardableResult + public func write(_ value: Bool) async throws -> Int { + return try await write(UInt8(value ? 0x01 : 0x00)) + } + + /// Write a Float as its IEEE 754 bit pattern + /// - Parameters: + /// - value: Float value + /// - endian: Byte order (default: big-endian) + @discardableResult + public func write(_ value: Float, endian: Endian = .big) async throws -> Int { + return try await write(value.bitPattern, endian: endian) + } + + /// Write a Double as its IEEE 754 bit pattern + /// - Parameters: + /// - value: Double value + /// - endian: Byte order (default: big-endian) + @discardableResult + public func write(_ value: Double, endian: Endian = .big) async throws -> Int { + return try await write(value.bitPattern, endian: endian) + } + + /// Write a string to the socket, optionally length-prefixed + /// + /// - Parameters: + /// - string: String to write + /// - encoding: Text encoding (default: UTF-8) + /// - allowLossyConversion: Allow lossy encoding if necessary (default: false) + /// - Throws: `NetSocketError` if encoding fails or write fails + @discardableResult + public func write(_ string: String, encoding: String.Encoding = .utf8, allowLossyConversion: Bool = false) async throws -> Int { + guard let data = string.data(using: encoding, allowLossyConversion: allowLossyConversion) else { + throw NetSocketError.encodeFailed(NSError(domain: "StringEncoding", code: -1)) + } + return try await write(data) + } + + // MARK: Receive Data + + /// Read data until a delimiter is found + /// + /// Searches the buffer for the delimiter pattern and returns all data up to (and optionally including) + /// the delimiter. The delimiter is always consumed from the stream. + /// + /// - Parameters: + /// - delimiter: Binary delimiter pattern to search for + /// - maxBytes: Maximum bytes to read before throwing (default: no limit) + /// - includeDelimiter: Whether to include delimiter in result (default: false) + /// - Returns: Data read from stream + /// - Throws: `NetSocketError.framingExceeded` if max bytes exceeded, or connection errors + public func read(past delimiter: Data, maxBytes: Int? = nil, includeDelimiter: Bool = false) async throws -> Data { + while true { + try Task.checkCancellation() + if let r = search(delimiter: delimiter) { + let consumeLen = r.upperBound - head + let data = try await read(consumeLen) + return includeDelimiter ? data : data.dropLast(delimiter.count) + } + if let maxBytes, availableBytes >= maxBytes { + throw NetSocketError.framingExceeded(max: maxBytes) + } + try await waitForData() + guard !isClosed || availableBytes > 0 else { throw NetSocketError.closed } + } + } + + /// Read exactly N bytes from the socket + /// + /// Waits for data to arrive if buffer doesn't contain enough bytes yet. The internal buffer + /// is automatically compacted after reading to prevent unbounded memory growth. + /// + /// - Parameter count: Number of bytes to read + /// - Returns: Exactly `count` bytes + /// - Throws: `NetSocketError.insufficientData` if connection closes before enough data arrives + public func read(_ count: Int) async throws -> Data { + try await self.ensureReadable(count) + let start = self.head + let end = self.head + count + let slice = self.buffer[start..(_ type: T.Type = T.self, endian: Endian = .big) async throws -> T { + let size = MemoryLayout.size + let data = try await self.read(size) + let value: T = data.withUnsafeBytes { raw in + raw.load(as: T.self) + } + switch endian { + case .big: return T(bigEndian: value) + case .little: return T(littleEndian: value) + } + } + + /// Read a fixed-length string + /// + /// - Parameters: + /// - length: Number of bytes to read + /// - encoding: Text encoding (default: UTF-8) + /// - Returns: Decoded string + /// - Throws: `NetSocketError` if decoding fails or insufficient data + public func read(_ length: Int, encoding: String.Encoding = .utf8) async throws -> String { + let data = try await self.read(length) + guard let s = String(data: data, encoding: encoding) else { + throw NetSocketError.decodeFailed(NSError()) + } + return s + } + + /// Read a string until a delimiter is found + /// + /// - Parameters: + /// - delimiter: Delimiter pattern to search for + /// - maxBytes: Maximum bytes to read before throwing (default: no limit) + /// - includeDelimiter: Whether to include delimiter in result (default: false) + /// - Returns: String read from stream (delimiter consumed but not included unless specified) + /// - Throws: `NetSocketError` if decoding fails, max bytes exceeded, or connection closed + public func read(until delimiter: Delimiter, maxBytes: Int? = nil, includeDelimiter: Bool = false) async throws -> String { + let bytes = try await read(past: delimiter.data, maxBytes: maxBytes, includeDelimiter: includeDelimiter) + guard let s = String(data: bytes, encoding: .utf8) else { throw NetSocketError.decodeFailed(NSError()) } + return s + } + + /// Read exactly N bytes with progress callbacks + /// + /// Like `read(_:)`, but reads in chunks and reports progress after each chunk. + /// Useful for downloading large amounts of data where you want to update UI progress. + /// + /// Example: + /// ```swift + /// let data = try await socket.read(1_000_000) { current, total in + /// print("Progress: \(current)/\(total)") + /// } + /// ``` + /// + /// - Parameters: + /// - count: Number of bytes to read + /// - chunkSize: Size of chunks to read at a time (default: 8192) + /// - progress: Optional callback with (bytesReceived, totalBytes) + /// - Returns: Exactly `count` bytes + /// - Throws: `NetSocketError` if connection closes before enough data arrives + public func read( + _ count: Int, + chunkSize: Int = 8192, + progress: (@Sendable (Int, Int) -> Void)? = nil + ) async throws -> Data { + var data = Data() + data.reserveCapacity(count) + var received = 0 + + while received < count { + try Task.checkCancellation() + let toRead = min(chunkSize, count - received) + let chunk = try await read(toRead) + data.append(chunk) + received += chunk.count + progress?(received, count) + } + + return data + } + + // MARK: Peek Data + + public var availableBytes: Int { self.buffer.count - self.head } + + public func peek(_ count: Int) -> Data? { + guard self.availableBytes >= count else { + return nil + } + + let slice = self.buffer[self.head..<(self.head + count)] + return Data(slice) // Don't advance head + } + + public func peek(upto count: Int) -> Data { + let amount = min(self.availableBytes, count) + guard amount > 0 else { + return Data() + } + + let slice = self.buffer[self.head..<(self.head + amount)] + return Data(slice) + } + + public func peek(awaiting count: Int) async throws -> Data { + try await self.ensureReadable(count) + let slice = self.buffer[self.head..<(self.head + count)] + return Data(slice) // Don't advance head + } + + // MARK: Skip Data + + /// Skip/discard exactly N bytes from the stream without allocating memory + public func skip(_ count: Int) async throws { + guard count > 0 else { return } + try await self.ensureReadable(count) + self.head += count + self.compactIfNeeded() + } + + /// Skip until delimiter is found (discards delimiter too) + public func skip(past delimiter: Data) async throws { + while true { + try Task.checkCancellation() + if let r = self.search(delimiter: delimiter) { + self.head = r.upperBound // Skip to end of delimiter + self.compactIfNeeded() + return + } + try await self.waitForData() + guard !self.isClosed else { + throw NetSocketError.closed + } + } + } + + // MARK: Files + + /// Upload a file from a URL, yielding progress as an AsyncSequence. + /// + /// Iterating this sequence drives the transfer. Each yielded value reports + /// the total bytes sent so far and the known total. Cancel the consuming + /// task to cancel the transfer. + /// + /// This method handles opening and closing the file handle automatically. + /// + /// - Parameters: + /// - url: File URL to upload. + /// - chunkSize: Size of each read chunk. + /// - Returns: An `AsyncThrowingStream` of `FileProgress` updates. + func writeFile(from url: URL, chunkSize: Int = 256 * 1024) -> AsyncThrowingStream { + // This stream wrapper manages the FileHandle's lifetime. + return AsyncThrowingStream(bufferingPolicy: .bufferingOldest(1)) { continuation in + // Capture self (the actor) to use in detached task + let actor = self + + // Open file on a background thread (file I/O is blocking) + let task = Task.detached { + let fh: FileHandle + let total: Int + + // 1. Open file and get length (blocking I/O, done off-actor) + do { + total = Int(try NetSocket.fileLength(at: url)) + fh = try FileHandle(forReadingFrom: url) + } catch { + continuation.finish(throwing: NetSocketError.failed(underlying: error)) + return + } + + // 2. Now switch to the actor context to call the actor-isolated method + let stream = await actor.writeFile( + from: fh, + length: total, + chunkSize: chunkSize + ) + + // 3. Forward all elements from the underlying stream to our stream + do { + for try await progress in stream { + try Task.checkCancellation() // Exit early if cancelled + continuation.yield(progress) + } + try? fh.close() + continuation.finish() + } catch is CancellationError { + try? fh.close() + continuation.finish() + } catch { + try? fh.close() + continuation.finish(throwing: error) + } + } + + // If the *consumer* cancels the stream, we cancel our managing task. + continuation.onTermination = { @Sendable _ in + task.cancel() + } + } + } + + /// Upload a file from an open FileHandle, yielding progress as an AsyncSequence. + /// + /// Iterating this sequence drives the transfer. Each yielded value reports + /// the total bytes sent so far and the known total. Cancel the consuming + /// task to cancel the transfer. + /// + /// **Note:** The caller is responsible for opening and closing the `fileHandle`. + /// + /// - Parameters: + /// - fileHandle: Open `FileHandle` for reading. + /// - length: Exact number of bytes to send (total file size). + /// - chunkSize: Size of each read chunk. + /// - Returns: An `AsyncThrowingStream` of `FileProgress` updates. + func writeFile(from fileHandle: FileHandle, length: Int, chunkSize: Int = 256 * 1024) -> AsyncThrowingStream { + precondition(length >= 0, "length must be >= 0") + + if length == 0 { + return AsyncThrowingStream { continuation in + continuation.yield(.init(sent: 0, total: 0, bytesPerSecond: 0, estimatedTimeRemaining: 0)) + continuation.finish() + } + } + + return AsyncThrowingStream(bufferingPolicy: .bufferingOldest(1)) { continuation in + let task = Task { [weak self] in + guard let self else { + continuation.finish() + return + } + + var estimator = TransferRateEstimator(total: Int(length)) + + do { + try await self.ensureReady() + + while estimator.transferred < length { + try Task.checkCancellation() + + let toRead = Int(min(chunkSize, length - estimator.transferred)) + + // Read from disk + guard let chunk = try fileHandle.read(upToCount: toRead), !chunk.isEmpty else { + if estimator.transferred < length { + throw NetSocketError.failed(underlying: NSError( + domain: "NetSocket", code: 9001, + userInfo: [NSLocalizedDescriptionKey: "File read ended prematurely. Expected \(length) bytes, got \(estimator.transferred)."] + )) + } + break + } + + // Write to network + try await self.write(chunk) + + // Update estimator and yield progress + let progress = estimator.update(bytes: chunk.count) + continuation.yield(progress) + } + + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { @Sendable _ in + task.cancel() + } + } + } + + /// Receive a file of known length and yield progress updates as an AsyncSequence. + /// + /// Iterating this sequence drives the transfer. Each yielded value reports + /// the total bytes written so far and the known total. Cancel the consuming + /// task to cancel the transfer. + /// + /// - Parameters: + /// - fileHandle: Open `FileHandle` for writing (caller must close). + /// - length: Exact number of bytes expected. + /// - chunkSize: Size of each read chunk. + /// - Returns: An `AsyncThrowingStream` of `FileProgress` updates. + func receiveFile(to fileHandle: FileHandle, length: Int, chunkSize: Int = 256 * 1024) -> AsyncThrowingStream { + precondition(length >= 0, "length must be >= 0") + + if length == 0 { + return AsyncThrowingStream { continuation in + continuation.yield(.init(sent: 0, total: 0, bytesPerSecond: 0, estimatedTimeRemaining: 0)) + continuation.finish() + } + } + + return AsyncThrowingStream(bufferingPolicy: .bufferingOldest(1)) { continuation in + let task = Task { [weak self] in + guard let self else { + continuation.finish() + return + } + + var estimator = TransferRateEstimator(total: length) + + do { + var remaining: Int = length + + while remaining > 0 { + try Task.checkCancellation() + let n = min(chunkSize, remaining) + + let chunk = try await self.read(n) + try fileHandle.write(contentsOf: chunk) + + let chunkSize = Int(chunk.count) + remaining -= chunkSize + let progress = estimator.update(bytes: chunkSize) + continuation.yield(progress) + } + + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { @Sendable _ in + task.cancel() + } + } + } + + /// Download a file of known length and write it to disk in chunks + /// + /// This method does **not** read a length prefix. The caller must provide the expected + /// file size (e.g., from protocol metadata). The file is streamed directly to disk to + /// avoid loading it entirely into memory. + /// + /// Supports atomic writes: when enabled, data is written to a temporary `.part` file and + /// renamed on success. If an error occurs, the temporary file is automatically cleaned up. + /// + /// - Parameters: + /// - url: Destination file URL + /// - length: Exact number of bytes to read (must match what's on the wire) + /// - chunkSize: Chunk size for reading/writing (default: 256 KB) + /// - overwrite: Whether to overwrite existing file (default: true) + /// - atomic: Write to temporary file and rename on success (default: true) + /// - progress: Optional progress callback + /// - Returns: Total bytes written (equals `length` on success) + /// - Throws: File I/O or network errors. On atomic writes, partial files are cleaned up. + /// + /// Example: + /// ```swift + /// // Hotline protocol: file size comes from transaction header + /// let transaction = try await socket.receive(HotlineTransaction.self) + /// try await socket.receiveFile( + /// to: destinationURL, + /// length: transaction.fileSize + /// ) + /// ``` + @discardableResult + func receiveFile( + to url: URL, + length: Int, + chunkSize: Int = 256 * 1024, + overwrite: Bool = true, + atomic: Bool = true, + progress: (@Sendable (FileProgress) -> Void)? = nil + ) async throws -> Int { + precondition(length >= 0, "length must be >= 0") + + // Fast path: nothing to do + if length == 0 { + if overwrite { try? FileManager.default.removeItem(at: url) } + FileManager.default.createFile(atPath: url.path, contents: Data(), attributes: nil) + return 0 + } + + // Prepare destination (optionally atomic) + let fm = FileManager.default + let dir = url.deletingLastPathComponent() + let tmp = atomic ? dir.appendingPathComponent(".\(url.lastPathComponent).part-\(UUID().uuidString)") : url + + if overwrite { try? fm.removeItem(at: tmp) } + if overwrite, !atomic { try? fm.removeItem(at: url) } + + // Create and open the file for writing + fm.createFile(atPath: tmp.path, contents: nil, attributes: nil) + let fh = try FileHandle(forWritingTo: tmp) + defer { try? fh.close() } + + var remaining: Int = length + var written: Int = 0 + + do { + while remaining > 0 { + try Task.checkCancellation() + let n = Int(min(chunkSize, remaining)) + let chunk = try await self.read(n) + try fh.write(contentsOf: chunk) + remaining -= n + written += Int(n) + progress?(.init(sent: written, total: length)) + } + } catch { + // Cleanup partial file on failure if we were writing atomically + if atomic { try? fm.removeItem(at: tmp) } + throw error + } + + // Atomically move into place if requested + if atomic { + if overwrite { try? fm.removeItem(at: url) } + try fm.moveItem(at: tmp, to: url) + } + + return written + } + + // MARK: Internals + + private func start() async throws { + self.connection.stateUpdateHandler = { state in + Task { [weak self] in + guard let self else { return } + switch state { + case .ready: + await self.setReady() + await self.resumeReadyWaiters(with: .success(())) + case .failed(let error): + await self.failAllWaiters(NetSocketError.failed(underlying: error)) + await self.setClosed() + case .waiting(let error): + // bubble as transient failure for awaiters; reconnect logic could live here + await self.resumeReadyWaiters(with: .failure(NetSocketError.failed(underlying: error))) + case .cancelled: + await self.failAllWaiters(NetSocketError.closed) + await self.setClosed() + default: + break + } + } + } + + // Kick off receive loop after .start + self.connection.start(queue: queue) + try await self.waitUntilReady() + self.startReceiveLoop() + } + + private func startReceiveLoop() { + @Sendable func loop(_ connection: NWConnection, chunk: Int, owner: NetSocket, connID: String) { + connection.receive(minimumIncompleteLength: 1, maximumLength: chunk) { [weak owner] data, _, isComplete, error in + Task { + guard let o = owner else { + return + } + + if let error { + await o.handleReceiveError(error) + return + } + if let data, !data.isEmpty { + await o.append(data, connID: connID) + } + if isComplete { + await o.handleEOF() + return + } + loop(connection, chunk: chunk, owner: o, connID: connID) + } + } + } + loop(connection, chunk: self.config.receiveChunk, owner: self, connID: connectionID) + } + + private func handleReceiveError(_ error: Error) { + self.isClosed = true + self.failAllWaiters(NetSocketError.failed(underlying: error)) + } + + private func handleEOF() { + self.isClosed = true + let waiters = self.dataWaiters + self.dataWaiters.removeAll() + for w in waiters { + w.resume() + } // wake so readers can observe closure + } + + private func setReady() { + self.ready = true + } + + private func setClosed() { + self.isClosed = true + } + + private func ensureReady() async throws { + if self.isClosed { + throw NetSocketError.closed + } + if !self.ready { + try await self.waitUntilReady() + } + } + + private func ensureReadable(_ count: Int) async throws { + try await self.ensureReady() + while self.availableBytes < count { + try Task.checkCancellation() + if self.isClosed { + throw NetSocketError.insufficientData(expected: count, got: self.availableBytes) + } + try await self.waitForData() + } + } + + private func waitForData() async throws { + try Task.checkCancellation() + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + if self.isClosed { + cont.resume() + return + } + self.dataWaiters.append(cont) + } + } + + private func compactIfNeeded() { + // Avoid unbounded memory as head advances + if self.head > 64 * 1024 && self.head > self.buffer.count / 2 { + self.buffer.removeSubrange(0.. Range? { + guard !delimiter.isEmpty, availableBytes >= delimiter.count else { return nil } + let hay = buffer[head.. Int64 { + let values = try url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) + guard values.isRegularFile == true else { + throw NetSocketError.failed(underlying: NSError( + domain: "NetSocket", code: 1001, + userInfo: [NSLocalizedDescriptionKey: "Not a regular file: \(url.path)"] + )) + } + if let s = values.fileSize { return Int64(s) } + let attrs = try FileManager.default.attributesOfItem(atPath: url.path) + if let n = attrs[.size] as? NSNumber { + return n.int64Value + } + throw NetSocketError.failed(underlying: NSError( + domain: "NetSocket", code: 1002, + userInfo: [NSLocalizedDescriptionKey: "Unable to determine file size for \(url.lastPathComponent)"] + )) + } + + private func waitUntilReady() async throws { + guard !self.ready else { return } + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + self.readyWaiters.append(cont) + } + } + + private func resumeReadyWaiters(with result: Result) { + let waiters = self.readyWaiters + self.readyWaiters.removeAll() + for w in waiters { + switch result { + case .success: w.resume() + case .failure(let e): w.resume(throwing: e) + } + } + } + + private func failAllWaiters(_ error: Error) { + self.resumeReadyWaiters(with: .failure(error)) + let waiters = self.dataWaiters + self.dataWaiters.removeAll() + for w in waiters { + w.resume(throwing: error) + } + } + + private func append(_ data: Data, connID: String) { + buffer.append(data) + if buffer.count - head > config.maxBufferBytes { + // Hard stop: drop connection rather than OOM'ing. + isClosed = true + connection.cancel() + failAllWaiters(NetSocketError.framingExceeded(max: config.maxBufferBytes)) + return + } + resumeDataWaiters() + } + + private func resumeDataWaiters() { + let waiters = dataWaiters + dataWaiters.removeAll() + for w in waiters { w.resume() } + } +} + +// MARK: - Utilities + +private extension Data { + mutating func appendInteger(_ value: T, endian: Endian) throws { + var v = value + switch endian { + case .big: v = T(bigEndian: value) + case .little: v = T(littleEndian: value) + } + var copy = v + withUnsafePointer(to: ©) { ptr in + self.append(contentsOf: UnsafeRawBufferPointer(start: ptr, count: MemoryLayout.size)) + } + } +} + +// MARK: - NetSocketEncodable + +/// Protocol for types that can encode themselves to binary data +/// +/// Types conforming to `NetSocketEncodable` produce binary data that can be sent over +/// a socket. Unlike writing field-by-field to the socket, encodable types build complete +/// binary messages that are sent in a single write operation for efficiency. +/// +/// Example: +/// ```swift +/// struct MyMessage: NetSocketEncodable { +/// let id: UInt32 +/// let name: String +/// +/// func encode(endian: Endian) throws -> Data { +/// var data = Data() +/// // Encode fields to data... +/// return data +/// } +/// } +/// +/// try await socket.send(message) +/// ``` +public protocol NetSocketEncodable: Sendable { + /// Encode this value to binary data + /// + /// Implementations should build a complete binary message and return it as Data. + /// The data will be sent to the socket in a single write operation. + /// + /// - Parameter endian: Byte order for multi-byte values + /// - Returns: Encoded binary data ready to send + /// - Throws: Encoding errors + func encode(endian: Endian) throws -> Data +} + +/// Protocol for types that can decode themselves directly from a socket stream +/// +/// Types conforming to `NetSocketDecodable` read field-by-field directly from the socket +/// using async reads. This enables true streaming without buffering entire messages. +/// +/// **Important**: If decoding throws after consuming some bytes (e.g., validation fails), +/// the socket will be left with those bytes consumed. In practice, this usually means the +/// connection should be closed. For most protocols this is acceptable since decode errors +/// indicate corrupt data or protocol violations. +/// +/// Example: +/// ```swift +/// struct MyMessage: NetSocketDecodable { +/// let id: UInt32 +/// let name: String +/// +/// init(from socket: NetSocket, endian: Endian) async throws { +/// self.id = try await socket.read(UInt32.self, endian: endian) +/// let nameLen = try await socket.read(UInt16.self, endian: endian) +/// let nameData = try await socket.readExactly(Int(nameLen)) +/// guard let name = String(data: nameData, encoding: .utf8) else { +/// throw NetSocketError.decodeFailed(NSError()) +/// } +/// self.name = name +/// } +/// } +/// +/// let message = try await socket.receive(MyMessage.self) +/// ``` +public protocol NetSocketDecodable: Sendable { + /// Decode a value by reading directly from the socket stream + /// + /// This initializer should read all necessary fields from the socket using + /// methods like `read(_:endian:)`, `readExactly(_:)`, `readString(length:)`, etc. + /// + /// The socket handles waiting for data to arrive, so you can read field by field + /// without worrying about buffering. + /// + /// - Parameters: + /// - socket: Socket to read from + /// - endian: Byte order for multi-byte values + /// - Throws: Network errors, insufficient data, or custom decoding errors + init(from socket: NetSocket, endian: Endian) async throws +} + +public extension NetSocket { + /// Send an encodable value to the socket + /// + /// The type encodes itself to binary data, which is then sent in a single write operation. + /// + /// Example: + /// ```swift + /// struct MyMessage: NetSocketEncodable { + /// let id: UInt32 + /// let name: String + /// + /// func encode(endian: Endian) throws -> Data { + /// var data = Data() + /// // Build binary message... + /// return data + /// } + /// } + /// + /// try await socket.send(message) + /// ``` + /// + /// - Parameters: + /// - value: Value conforming to NetSocketEncodable + /// - endian: Byte order (default: big-endian) + /// - Throws: Encoding or network errors + func send(_ value: T, endian: Endian = .big) async throws { + let data = try value.encode(endian: endian) + try await self.write(data) + } + + /// Receive and decode a value directly from the socket stream (no length prefix) + /// + /// The type reads field-by-field from the socket as needed, enabling true streaming + /// without buffering entire messages. Useful for protocols where message size isn't + /// known upfront or for progressive decoding. + /// + /// Example: + /// ```swift + /// struct ServerEntry: NetSocketDecodable { + /// let id: UInt32 + /// let name: String + /// + /// init(from socket: NetSocket, endian: Endian) async throws { + /// self.id = try await socket.read(UInt32.self, endian: endian) + /// // Read variable-length string... + /// } + /// } + /// + /// let entry = try await socket.receive(ServerEntry.self) + /// ``` + /// + /// - Parameters: + /// - type: Type conforming to NetSocketDecodable + /// - endian: Byte order (default: big-endian) + /// - Returns: Decoded value + /// - Throws: Decoding or network errors + func receive(_ type: T.Type, endian: Endian = .big) async throws -> T { + return try await T(from: self, endian: endian) + } +} diff --git a/Hotline/Library/NetSocket/NetSocketNew.swift b/Hotline/Library/NetSocket/NetSocketNew.swift deleted file mode 100644 index 12f4271..0000000 --- a/Hotline/Library/NetSocket/NetSocketNew.swift +++ /dev/null @@ -1,1127 +0,0 @@ -// NetSocket.swift -// Dustin Mierau • @mierau - -import Foundation -import Network - -/// Byte order for multi-byte integer values in binary protocols -public enum Endian { - /// Big-endian (network byte order, most significant byte first) - case big - /// Little-endian (least significant byte first) - case little -} - -/// Delimiter patterns for text-based protocols -public enum Delimiter { - /// Custom single byte delimiter - case byte(UInt8) - /// Null terminator (0x00) - case zeroByte - /// Line feed (\n, 0x0A) - case lineFeed - /// Carriage return + line feed (\r\n, 0x0D 0x0A) - case carriageReturnLineFeed - - /// Binary representation of this delimiter - var data: Data { - switch self { - case .byte(let b): return Data([b]) - case .zeroByte: return Data([0x00]) - case .lineFeed: return Data([0x0A]) - case .carriageReturnLineFeed: return Data([0x0D, 0x0A]) - } - } -} - -/// TLS/SSL encryption policy for socket connections -public struct TLSPolicy: Sendable { - /// Create a TLS-enabled policy with optional custom configuration - /// - Parameter configure: Optional closure to customize TLS options - public static func enabled(_ configure: (@Sendable (NWProtocolTLS.Options) -> Void)? = nil) -> TLSPolicy { - TLSPolicy(enabled: true, configure: configure) - } - - /// Create a policy with TLS disabled (plaintext connection) - public static var disabled: TLSPolicy { TLSPolicy(enabled: false, configure: nil) } - - /// Whether TLS is enabled - public let enabled: Bool - /// Optional TLS configuration closure - public let configure: (@Sendable (NWProtocolTLS.Options) -> Void)? -} - -// MARK: - Errors - -/// Errors that can occur during socket operations -public enum NetSocketError: Error, CustomStringConvertible, Sendable { - /// Socket is not yet in ready state - case notReady - /// Connection has been closed - case closed - /// Invalid port number provided - case invalidPort - /// Network operation failed with underlying error - case failed(underlying: Error) - /// Not enough data available to fulfill read request - case insufficientData(expected: Int, got: Int) - /// Frame size exceeds configured maximum - case framingExceeded(max: Int) - /// Failed to decode data - case decodeFailed(Error) - /// Failed to encode data - case encodeFailed(Error) - - public var description: String { - switch self { - case .notReady: return "Connection not ready." - case .closed: return "Connection closed." - case .invalidPort: return "Invalid port number." - case .failed(let e): return "Network failure: \(e.localizedDescription)" - case .insufficientData(let exp, let got): return "Insufficient data: need \(exp), have \(got)." - case .framingExceeded(let max): return "Frame length exceeded maximum \(max)." - case .decodeFailed(let e): return "Decoding failed: \(e)" - case .encodeFailed(let e): return "Encoding failed: \(e)" - } - } -} - -/// An async/await TCP socket with automatic buffering and framing support -/// -/// NetSocket provides: -/// - Async connection management -/// - Automatic receive buffering with memory compaction -/// - Type-safe reading/writing of integers, strings, and custom types -/// - File upload/download with progress tracking -/// -/// Example usage: -/// ```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) -/// ``` -public actor NetSocket { - /// Configuration options for the socket - public struct Config: Sendable { - /// Size of chunks to receive from network at once (default: 64 KB) - public var receiveChunk: Int = 64 * 1024 - /// Maximum bytes to buffer before disconnecting (default: 8 MB) - public var maxBufferBytes: Int = 8 * 1024 * 1024 - public init() {} - } - - // Connection + state - private let connection: NWConnection - private let queue = DispatchQueue(label: "NetSocket.NWConnection") - private var ready = false - private var isClosed = false - private let connectionID: String // For logging - - // Buffer with compaction - private var buffer = Data() - private var head = 0 // start of unread bytes - private let config: Config - - // Waiters for data/ready - private var dataWaiters: [CheckedContinuation] = [] - private var readyWaiters: [CheckedContinuation] = [] - - // MARK: Init - - private init(connection: NWConnection, config: Config) { - self.connection = connection - self.config = config - // Create a human-readable connection ID for logging - if case .hostPort(host: let h, port: let p) = connection.endpoint { - self.connectionID = "\(h):\(p)" - } else { - self.connectionID = "unknown" - } - } - - // MARK: Connect - - /// Connect to a remote host and return a ready socket - /// - /// This method establishes a TCP connection using Network framework types and waits until - /// the connection is in `.ready` state. - /// - /// - 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) - /// - 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) - } - - let conn = NWConnection(host: host, port: port, using: parameters) - let socket = NetSocket(connection: conn, config: config) - try await socket.start() - return socket - } - - /// 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 { - guard let nwPort = NWEndpoint.Port(rawValue: port) else { - throw NetSocketError.invalidPort - } - return try await self.connect(host: .name(host, nil), port: nwPort, tls: tls, config: config) - } - - // MARK: Close - - /// Close the connection gracefully - /// - /// Performs a graceful shutdown of the underlying network connection (e.g., TCP FIN) - /// and wakes all pending read/write operations with a `NetSocketError.closed` error. - /// This method is idempotent - subsequent calls are ignored. - /// - /// Use `forceClose()` for immediate non-graceful termination (e.g., TCP RST). - public func close() { - guard !isClosed else { return } - isClosed = true - connection.cancel() - resumeDataWaiters() - resumeReadyWaiters(with: .failure(NetSocketError.closed)) - } - - /// Force close the connection immediately (non-graceful) - /// - /// Performs an immediate non-graceful shutdown of the underlying network connection - /// (e.g., TCP RST). Use this when you need to terminate the connection immediately - /// without waiting for graceful closure. For normal shutdown, use `close()` instead. - /// - /// This method is idempotent - subsequent calls are ignored. - public func forceClose() { - guard !isClosed else { return } - isClosed = true - connection.forceCancel() - resumeDataWaiters() - resumeReadyWaiters(with: .failure(NetSocketError.closed)) - } - - // MARK: Send Data - - /// Write raw data to the socket - /// - /// Sends data and waits for confirmation that it has been processed by the network stack. - /// - /// - Parameter data: Raw bytes to send - /// - Throws: `NetSocketError` if connection is not ready or send fails - @discardableResult - public func write(_ data: Data) async throws -> Int { - try await ensureReady() - return try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in - connection.send(content: data, completion: .contentProcessed { error in - if let error { cont.resume(throwing: NetSocketError.failed(underlying: error)) } - else { cont.resume(returning: data.count) } - }) - } - } - - /// Write a fixed-width integer to the socket - /// - /// - Parameters: - /// - value: The integer value to write - /// - endian: Byte order (default: big-endian) - /// - Throws: `NetSocketError` if write fails - @discardableResult - public func write(_ value: T, endian: Endian = .big) async throws -> Int { - var v = value - switch endian { - case .big: v = T(bigEndian: value) - case .little: v = T(littleEndian: value) - } - var copy = v - let size = MemoryLayout.size - let bytes = withUnsafePointer(to: ©) { - Data(bytes: $0, count: size) - } - try await write(bytes) - return bytes.count - } - - /// Write a boolean as a single byte (0 or 1) - /// - Parameter value: Boolean value - @discardableResult - public func write(_ value: Bool) async throws -> Int { - return try await write(UInt8(value ? 0x01 : 0x00)) - } - - /// Write a Float as its IEEE 754 bit pattern - /// - Parameters: - /// - value: Float value - /// - endian: Byte order (default: big-endian) - @discardableResult - public func write(_ value: Float, endian: Endian = .big) async throws -> Int { - return try await write(value.bitPattern, endian: endian) - } - - /// Write a Double as its IEEE 754 bit pattern - /// - Parameters: - /// - value: Double value - /// - endian: Byte order (default: big-endian) - @discardableResult - public func write(_ value: Double, endian: Endian = .big) async throws -> Int { - return try await write(value.bitPattern, endian: endian) - } - - /// Write a string to the socket, optionally length-prefixed - /// - /// - Parameters: - /// - string: String to write - /// - encoding: Text encoding (default: UTF-8) - /// - allowLossyConversion: Allow lossy encoding if necessary (default: false) - /// - Throws: `NetSocketError` if encoding fails or write fails - @discardableResult - public func write(_ string: String, encoding: String.Encoding = .utf8, allowLossyConversion: Bool = false) async throws -> Int { - guard let data = string.data(using: encoding, allowLossyConversion: allowLossyConversion) else { - throw NetSocketError.encodeFailed(NSError(domain: "StringEncoding", code: -1)) - } - return try await write(data) - } - - // MARK: Receive Data - - /// Read data until a delimiter is found - /// - /// Searches the buffer for the delimiter pattern and returns all data up to (and optionally including) - /// the delimiter. The delimiter is always consumed from the stream. - /// - /// - Parameters: - /// - delimiter: Binary delimiter pattern to search for - /// - maxBytes: Maximum bytes to read before throwing (default: no limit) - /// - includeDelimiter: Whether to include delimiter in result (default: false) - /// - Returns: Data read from stream - /// - Throws: `NetSocketError.framingExceeded` if max bytes exceeded, or connection errors - public func read(past delimiter: Data, maxBytes: Int? = nil, includeDelimiter: Bool = false) async throws -> Data { - while true { - try Task.checkCancellation() - if let r = search(delimiter: delimiter) { - let consumeLen = r.upperBound - head - let data = try await read(consumeLen) - return includeDelimiter ? data : data.dropLast(delimiter.count) - } - if let maxBytes, availableBytes >= maxBytes { - throw NetSocketError.framingExceeded(max: maxBytes) - } - try await waitForData() - guard !isClosed || availableBytes > 0 else { throw NetSocketError.closed } - } - } - - /// Read exactly N bytes from the socket - /// - /// Waits for data to arrive if buffer doesn't contain enough bytes yet. The internal buffer - /// is automatically compacted after reading to prevent unbounded memory growth. - /// - /// - Parameter count: Number of bytes to read - /// - Returns: Exactly `count` bytes - /// - Throws: `NetSocketError.insufficientData` if connection closes before enough data arrives - public func read(_ count: Int) async throws -> Data { - try await self.ensureReadable(count) - let start = self.head - let end = self.head + count - let slice = self.buffer[start..(_ type: T.Type = T.self, endian: Endian = .big) async throws -> T { - let size = MemoryLayout.size - let data = try await self.read(size) - let value: T = data.withUnsafeBytes { raw in - raw.load(as: T.self) - } - switch endian { - case .big: return T(bigEndian: value) - case .little: return T(littleEndian: value) - } - } - - /// Read a fixed-length string - /// - /// - Parameters: - /// - length: Number of bytes to read - /// - encoding: Text encoding (default: UTF-8) - /// - Returns: Decoded string - /// - Throws: `NetSocketError` if decoding fails or insufficient data - public func read(_ length: Int, encoding: String.Encoding = .utf8) async throws -> String { - let data = try await self.read(length) - guard let s = String(data: data, encoding: encoding) else { - throw NetSocketError.decodeFailed(NSError()) - } - return s - } - - /// Read a string until a delimiter is found - /// - /// - Parameters: - /// - delimiter: Delimiter pattern to search for - /// - maxBytes: Maximum bytes to read before throwing (default: no limit) - /// - includeDelimiter: Whether to include delimiter in result (default: false) - /// - Returns: String read from stream (delimiter consumed but not included unless specified) - /// - Throws: `NetSocketError` if decoding fails, max bytes exceeded, or connection closed - public func read(until delimiter: Delimiter, maxBytes: Int? = nil, includeDelimiter: Bool = false) async throws -> String { - let bytes = try await read(past: delimiter.data, maxBytes: maxBytes, includeDelimiter: includeDelimiter) - guard let s = String(data: bytes, encoding: .utf8) else { throw NetSocketError.decodeFailed(NSError()) } - return s - } - - /// Read exactly N bytes with progress callbacks - /// - /// Like `read(_:)`, but reads in chunks and reports progress after each chunk. - /// Useful for downloading large amounts of data where you want to update UI progress. - /// - /// Example: - /// ```swift - /// let data = try await socket.read(1_000_000) { current, total in - /// print("Progress: \(current)/\(total)") - /// } - /// ``` - /// - /// - Parameters: - /// - count: Number of bytes to read - /// - chunkSize: Size of chunks to read at a time (default: 8192) - /// - progress: Optional callback with (bytesReceived, totalBytes) - /// - Returns: Exactly `count` bytes - /// - Throws: `NetSocketError` if connection closes before enough data arrives - public func read( - _ count: Int, - chunkSize: Int = 8192, - progress: (@Sendable (Int, Int) -> Void)? = nil - ) async throws -> Data { - var data = Data() - data.reserveCapacity(count) - var received = 0 - - while received < count { - try Task.checkCancellation() - let toRead = min(chunkSize, count - received) - let chunk = try await read(toRead) - data.append(chunk) - received += chunk.count - progress?(received, count) - } - - return data - } - - // MARK: Peek Data - - public var availableBytes: Int { self.buffer.count - self.head } - - public func peek(_ count: Int) -> Data? { - guard self.availableBytes >= count else { - return nil - } - - let slice = self.buffer[self.head..<(self.head + count)] - return Data(slice) // Don't advance head - } - - public func peek(upto count: Int) -> Data { - let amount = min(self.availableBytes, count) - guard amount > 0 else { - return Data() - } - - let slice = self.buffer[self.head..<(self.head + amount)] - return Data(slice) - } - - public func peek(awaiting count: Int) async throws -> Data { - try await self.ensureReadable(count) - let slice = self.buffer[self.head..<(self.head + count)] - return Data(slice) // Don't advance head - } - - // MARK: Skip Data - - /// Skip/discard exactly N bytes from the stream without allocating memory - public func skip(_ count: Int) async throws { - guard count > 0 else { return } - try await self.ensureReadable(count) - self.head += count - self.compactIfNeeded() - } - - /// Skip until delimiter is found (discards delimiter too) - public func skip(past delimiter: Data) async throws { - while true { - try Task.checkCancellation() - if let r = self.search(delimiter: delimiter) { - self.head = r.upperBound // Skip to end of delimiter - self.compactIfNeeded() - return - } - try await self.waitForData() - guard !self.isClosed else { - throw NetSocketError.closed - } - } - } - - // MARK: Files - - /// Upload a file from a URL, yielding progress as an AsyncSequence. - /// - /// Iterating this sequence drives the transfer. Each yielded value reports - /// the total bytes sent so far and the known total. Cancel the consuming - /// task to cancel the transfer. - /// - /// This method handles opening and closing the file handle automatically. - /// - /// - Parameters: - /// - url: File URL to upload. - /// - chunkSize: Size of each read chunk. - /// - Returns: An `AsyncThrowingStream` of `FileProgress` updates. - func writeFile(from url: URL, chunkSize: Int = 256 * 1024) -> AsyncThrowingStream { - // This stream wrapper manages the FileHandle's lifetime. - return AsyncThrowingStream(bufferingPolicy: .bufferingOldest(1)) { continuation in - // Capture self (the actor) to use in detached task - let actor = self - - // Open file on a background thread (file I/O is blocking) - let task = Task.detached { - let fh: FileHandle - let total: Int - - // 1. Open file and get length (blocking I/O, done off-actor) - do { - total = Int(try NetSocket.fileLength(at: url)) - fh = try FileHandle(forReadingFrom: url) - } catch { - continuation.finish(throwing: NetSocketError.failed(underlying: error)) - return - } - - // 2. Now switch to the actor context to call the actor-isolated method - let stream = await actor.writeFile( - from: fh, - length: total, - chunkSize: chunkSize - ) - - // 3. Forward all elements from the underlying stream to our stream - do { - for try await progress in stream { - try Task.checkCancellation() // Exit early if cancelled - continuation.yield(progress) - } - try? fh.close() - continuation.finish() - } catch is CancellationError { - try? fh.close() - continuation.finish() - } catch { - try? fh.close() - continuation.finish(throwing: error) - } - } - - // If the *consumer* cancels the stream, we cancel our managing task. - continuation.onTermination = { @Sendable _ in - task.cancel() - } - } - } - - /// Upload a file from an open FileHandle, yielding progress as an AsyncSequence. - /// - /// Iterating this sequence drives the transfer. Each yielded value reports - /// the total bytes sent so far and the known total. Cancel the consuming - /// task to cancel the transfer. - /// - /// **Note:** The caller is responsible for opening and closing the `fileHandle`. - /// - /// - Parameters: - /// - fileHandle: Open `FileHandle` for reading. - /// - length: Exact number of bytes to send (total file size). - /// - chunkSize: Size of each read chunk. - /// - Returns: An `AsyncThrowingStream` of `FileProgress` updates. - func writeFile(from fileHandle: FileHandle, length: Int, chunkSize: Int = 256 * 1024) -> AsyncThrowingStream { - precondition(length >= 0, "length must be >= 0") - - if length == 0 { - return AsyncThrowingStream { continuation in - continuation.yield(.init(sent: 0, total: 0, bytesPerSecond: 0, estimatedTimeRemaining: 0)) - continuation.finish() - } - } - - return AsyncThrowingStream(bufferingPolicy: .bufferingOldest(1)) { continuation in - let task = Task { [weak self] in - guard let self else { - continuation.finish() - return - } - - var estimator = TransferRateEstimator(total: Int(length)) - - do { - try await self.ensureReady() - - while estimator.transferred < length { - try Task.checkCancellation() - - let toRead = Int(min(chunkSize, length - estimator.transferred)) - - // Read from disk - guard let chunk = try fileHandle.read(upToCount: toRead), !chunk.isEmpty else { - if estimator.transferred < length { - throw NetSocketError.failed(underlying: NSError( - domain: "NetSocket", code: 9001, - userInfo: [NSLocalizedDescriptionKey: "File read ended prematurely. Expected \(length) bytes, got \(estimator.transferred)."] - )) - } - break - } - - // Write to network - try await self.write(chunk) - - // Update estimator and yield progress - let progress = estimator.update(bytes: chunk.count) - continuation.yield(progress) - } - - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - } - - continuation.onTermination = { @Sendable _ in - task.cancel() - } - } - } - - /// Receive a file of known length and yield progress updates as an AsyncSequence. - /// - /// Iterating this sequence drives the transfer. Each yielded value reports - /// the total bytes written so far and the known total. Cancel the consuming - /// task to cancel the transfer. - /// - /// - Parameters: - /// - fileHandle: Open `FileHandle` for writing (caller must close). - /// - length: Exact number of bytes expected. - /// - chunkSize: Size of each read chunk. - /// - Returns: An `AsyncThrowingStream` of `FileProgress` updates. - func receiveFile(to fileHandle: FileHandle, length: Int, chunkSize: Int = 256 * 1024) -> AsyncThrowingStream { - precondition(length >= 0, "length must be >= 0") - - if length == 0 { - return AsyncThrowingStream { continuation in - continuation.yield(.init(sent: 0, total: 0, bytesPerSecond: 0, estimatedTimeRemaining: 0)) - continuation.finish() - } - } - - return AsyncThrowingStream(bufferingPolicy: .bufferingOldest(1)) { continuation in - let task = Task { [weak self] in - guard let self else { - continuation.finish() - return - } - - var estimator = TransferRateEstimator(total: length) - - do { - var remaining: Int = length - - while remaining > 0 { - try Task.checkCancellation() - let n = min(chunkSize, remaining) - - let chunk = try await self.read(n) - try fileHandle.write(contentsOf: chunk) - - let chunkSize = Int(chunk.count) - remaining -= chunkSize - let progress = estimator.update(bytes: chunkSize) - continuation.yield(progress) - } - - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - } - - continuation.onTermination = { @Sendable _ in - task.cancel() - } - } - } - - /// Download a file of known length and write it to disk in chunks - /// - /// This method does **not** read a length prefix. The caller must provide the expected - /// file size (e.g., from protocol metadata). The file is streamed directly to disk to - /// avoid loading it entirely into memory. - /// - /// Supports atomic writes: when enabled, data is written to a temporary `.part` file and - /// renamed on success. If an error occurs, the temporary file is automatically cleaned up. - /// - /// - Parameters: - /// - url: Destination file URL - /// - length: Exact number of bytes to read (must match what's on the wire) - /// - chunkSize: Chunk size for reading/writing (default: 256 KB) - /// - overwrite: Whether to overwrite existing file (default: true) - /// - atomic: Write to temporary file and rename on success (default: true) - /// - progress: Optional progress callback - /// - Returns: Total bytes written (equals `length` on success) - /// - Throws: File I/O or network errors. On atomic writes, partial files are cleaned up. - /// - /// Example: - /// ```swift - /// // Hotline protocol: file size comes from transaction header - /// let transaction = try await socket.receive(HotlineTransaction.self) - /// try await socket.receiveFile( - /// to: destinationURL, - /// length: transaction.fileSize - /// ) - /// ``` - @discardableResult - func receiveFile( - to url: URL, - length: Int, - chunkSize: Int = 256 * 1024, - overwrite: Bool = true, - atomic: Bool = true, - progress: (@Sendable (FileProgress) -> Void)? = nil - ) async throws -> Int { - precondition(length >= 0, "length must be >= 0") - - // Fast path: nothing to do - if length == 0 { - if overwrite { try? FileManager.default.removeItem(at: url) } - FileManager.default.createFile(atPath: url.path, contents: Data(), attributes: nil) - return 0 - } - - // Prepare destination (optionally atomic) - let fm = FileManager.default - let dir = url.deletingLastPathComponent() - let tmp = atomic ? dir.appendingPathComponent(".\(url.lastPathComponent).part-\(UUID().uuidString)") : url - - if overwrite { try? fm.removeItem(at: tmp) } - if overwrite, !atomic { try? fm.removeItem(at: url) } - - // Create and open the file for writing - fm.createFile(atPath: tmp.path, contents: nil, attributes: nil) - let fh = try FileHandle(forWritingTo: tmp) - defer { try? fh.close() } - - var remaining: Int = length - var written: Int = 0 - - do { - while remaining > 0 { - try Task.checkCancellation() - let n = Int(min(chunkSize, remaining)) - let chunk = try await self.read(n) - try fh.write(contentsOf: chunk) - remaining -= n - written += Int(n) - progress?(.init(sent: written, total: length)) - } - } catch { - // Cleanup partial file on failure if we were writing atomically - if atomic { try? fm.removeItem(at: tmp) } - throw error - } - - // Atomically move into place if requested - if atomic { - if overwrite { try? fm.removeItem(at: url) } - try fm.moveItem(at: tmp, to: url) - } - - return written - } - - // MARK: Internals - - private func start() async throws { - self.connection.stateUpdateHandler = { state in - Task { [weak self] in - guard let self else { return } - switch state { - case .ready: - await self.setReady() - await self.resumeReadyWaiters(with: .success(())) - case .failed(let error): - await self.failAllWaiters(NetSocketError.failed(underlying: error)) - await self.setClosed() - case .waiting(let error): - // bubble as transient failure for awaiters; reconnect logic could live here - await self.resumeReadyWaiters(with: .failure(NetSocketError.failed(underlying: error))) - case .cancelled: - await self.failAllWaiters(NetSocketError.closed) - await self.setClosed() - default: - break - } - } - } - - // Kick off receive loop after .start - self.connection.start(queue: queue) - try await self.waitUntilReady() - self.startReceiveLoop() - } - - private func startReceiveLoop() { - @Sendable func loop(_ connection: NWConnection, chunk: Int, owner: NetSocket, connID: String) { - print("NetSocket[\(connID)]: Calling connection.receive(\(chunk)) to request more data...") - - connection.receive(minimumIncompleteLength: 1, maximumLength: chunk) { [weak owner] data, _, isComplete, error in - print("NetSocket[\(connID)]: Receive callback - data: \(data?.count ?? 0) bytes, isComplete: \(isComplete), error: \(String(describing: error))") - Task { - guard let o = owner else { - return - } - - if let error { - await o.handleReceiveError(error) - return - } - if let data, !data.isEmpty { - await o.append(data, connID: connID) - } - if isComplete { - print("NetSocket[\(connID)]: EOF from peer.") - await o.handleEOF() - return - } - loop(connection, chunk: chunk, owner: o, connID: connID) - } - } - } - loop(connection, chunk: self.config.receiveChunk, owner: self, connID: connectionID) - } - - private func handleReceiveError(_ error: Error) { - self.isClosed = true - self.failAllWaiters(NetSocketError.failed(underlying: error)) - } - - private func handleEOF() { - self.isClosed = true - let waiters = self.dataWaiters - self.dataWaiters.removeAll() - for w in waiters { - w.resume() - } // wake so readers can observe closure - } - - private func setReady() { - self.ready = true - } - - private func setClosed() { - self.isClosed = true - } - - private func ensureReady() async throws { - if self.isClosed { - throw NetSocketError.closed - } - if !self.ready { - try await self.waitUntilReady() - } - } - - private func ensureReadable(_ count: Int) async throws { - try await self.ensureReady() - while self.availableBytes < count { - try Task.checkCancellation() - if self.isClosed { - throw NetSocketError.insufficientData(expected: count, got: self.availableBytes) - } - try await self.waitForData() - } - } - - private func waitForData() async throws { - try Task.checkCancellation() - try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in - if self.isClosed { - cont.resume() - return - } - self.dataWaiters.append(cont) - } - } - - private func compactIfNeeded() { - // Avoid unbounded memory as head advances - if self.head > 64 * 1024 && self.head > self.buffer.count / 2 { - self.buffer.removeSubrange(0.. Range? { - guard !delimiter.isEmpty, availableBytes >= delimiter.count else { return nil } - let hay = buffer[head.. Int64 { - let values = try url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) - guard values.isRegularFile == true else { - throw NetSocketError.failed(underlying: NSError( - domain: "NetSocket", code: 1001, - userInfo: [NSLocalizedDescriptionKey: "Not a regular file: \(url.path)"] - )) - } - if let s = values.fileSize { return Int64(s) } - let attrs = try FileManager.default.attributesOfItem(atPath: url.path) - if let n = attrs[.size] as? NSNumber { - return n.int64Value - } - throw NetSocketError.failed(underlying: NSError( - domain: "NetSocket", code: 1002, - userInfo: [NSLocalizedDescriptionKey: "Unable to determine file size for \(url.lastPathComponent)"] - )) - } - - private func waitUntilReady() async throws { - guard !self.ready else { return } - try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in - self.readyWaiters.append(cont) - } - } - - private func resumeReadyWaiters(with result: Result) { - let waiters = self.readyWaiters - self.readyWaiters.removeAll() - for w in waiters { - switch result { - case .success: w.resume() - case .failure(let e): w.resume(throwing: e) - } - } - } - - private func failAllWaiters(_ error: Error) { - self.resumeReadyWaiters(with: .failure(error)) - let waiters = self.dataWaiters - self.dataWaiters.removeAll() - for w in waiters { - w.resume(throwing: error) - } - } - - private func append(_ data: Data, connID: String) { - print("NetSocket[\(connID)]: Received \(data.count) bytes from network, buffer now has \(buffer.count - head + data.count) available") - buffer.append(data) - if buffer.count - head > config.maxBufferBytes { - // Hard stop: drop connection rather than OOM'ing. - isClosed = true - connection.cancel() - failAllWaiters(NetSocketError.framingExceeded(max: config.maxBufferBytes)) - return - } - resumeDataWaiters() - } - - private func resumeDataWaiters() { - let waiters = dataWaiters - dataWaiters.removeAll() - for w in waiters { w.resume() } - } -} - -// MARK: - Utilities - -private extension Data { - mutating func appendInteger(_ value: T, endian: Endian) throws { - var v = value - switch endian { - case .big: v = T(bigEndian: value) - case .little: v = T(littleEndian: value) - } - var copy = v - withUnsafePointer(to: ©) { ptr in - self.append(contentsOf: UnsafeRawBufferPointer(start: ptr, count: MemoryLayout.size)) - } - } -} - -// MARK: - NetSocketEncodable - -/// Protocol for types that can encode themselves to binary data -/// -/// Types conforming to `NetSocketEncodable` produce binary data that can be sent over -/// a socket. Unlike writing field-by-field to the socket, encodable types build complete -/// binary messages that are sent in a single write operation for efficiency. -/// -/// Example: -/// ```swift -/// struct MyMessage: NetSocketEncodable { -/// let id: UInt32 -/// let name: String -/// -/// func encode(endian: Endian) throws -> Data { -/// var data = Data() -/// // Encode fields to data... -/// return data -/// } -/// } -/// -/// try await socket.send(message) -/// ``` -public protocol NetSocketEncodable: Sendable { - /// Encode this value to binary data - /// - /// Implementations should build a complete binary message and return it as Data. - /// The data will be sent to the socket in a single write operation. - /// - /// - Parameter endian: Byte order for multi-byte values - /// - Returns: Encoded binary data ready to send - /// - Throws: Encoding errors - func encode(endian: Endian) throws -> Data -} - -/// Protocol for types that can decode themselves directly from a socket stream -/// -/// Types conforming to `NetSocketDecodable` read field-by-field directly from the socket -/// using async reads. This enables true streaming without buffering entire messages. -/// -/// **Important**: If decoding throws after consuming some bytes (e.g., validation fails), -/// the socket will be left with those bytes consumed. In practice, this usually means the -/// connection should be closed. For most protocols this is acceptable since decode errors -/// indicate corrupt data or protocol violations. -/// -/// Example: -/// ```swift -/// struct MyMessage: NetSocketDecodable { -/// let id: UInt32 -/// let name: String -/// -/// init(from socket: NetSocket, endian: Endian) async throws { -/// self.id = try await socket.read(UInt32.self, endian: endian) -/// let nameLen = try await socket.read(UInt16.self, endian: endian) -/// let nameData = try await socket.readExactly(Int(nameLen)) -/// guard let name = String(data: nameData, encoding: .utf8) else { -/// throw NetSocketError.decodeFailed(NSError()) -/// } -/// self.name = name -/// } -/// } -/// -/// let message = try await socket.receive(MyMessage.self) -/// ``` -public protocol NetSocketDecodable: Sendable { - /// Decode a value by reading directly from the socket stream - /// - /// This initializer should read all necessary fields from the socket using - /// methods like `read(_:endian:)`, `readExactly(_:)`, `readString(length:)`, etc. - /// - /// The socket handles waiting for data to arrive, so you can read field by field - /// without worrying about buffering. - /// - /// - Parameters: - /// - socket: Socket to read from - /// - endian: Byte order for multi-byte values - /// - Throws: Network errors, insufficient data, or custom decoding errors - init(from socket: NetSocket, endian: Endian) async throws -} - -public extension NetSocket { - /// Send an encodable value to the socket - /// - /// The type encodes itself to binary data, which is then sent in a single write operation. - /// - /// Example: - /// ```swift - /// struct MyMessage: NetSocketEncodable { - /// let id: UInt32 - /// let name: String - /// - /// func encode(endian: Endian) throws -> Data { - /// var data = Data() - /// // Build binary message... - /// return data - /// } - /// } - /// - /// try await socket.send(message) - /// ``` - /// - /// - Parameters: - /// - value: Value conforming to NetSocketEncodable - /// - endian: Byte order (default: big-endian) - /// - Throws: Encoding or network errors - func send(_ value: T, endian: Endian = .big) async throws { - let data = try value.encode(endian: endian) - try await self.write(data) - } - - /// Receive and decode a value directly from the socket stream (no length prefix) - /// - /// The type reads field-by-field from the socket as needed, enabling true streaming - /// without buffering entire messages. Useful for protocols where message size isn't - /// known upfront or for progressive decoding. - /// - /// Example: - /// ```swift - /// struct ServerEntry: NetSocketDecodable { - /// let id: UInt32 - /// let name: String - /// - /// init(from socket: NetSocket, endian: Endian) async throws { - /// self.id = try await socket.read(UInt32.self, endian: endian) - /// // Read variable-length string... - /// } - /// } - /// - /// let entry = try await socket.receive(ServerEntry.self) - /// ``` - /// - /// - Parameters: - /// - type: Type conforming to NetSocketDecodable - /// - endian: Byte order (default: big-endian) - /// - Returns: Decoded value - /// - Throws: Decoding or network errors - func receive(_ type: T.Type, endian: Endian = .big) async throws -> T { - return try await T(from: self, endian: endian) - } -} diff --git a/Hotline/Library/NetSocket/TransferRateEstimator.swift b/Hotline/Library/NetSocket/TransferRateEstimator.swift index 60647a7..badf87a 100644 --- a/Hotline/Library/NetSocket/TransferRateEstimator.swift +++ b/Hotline/Library/NetSocket/TransferRateEstimator.swift @@ -1,4 +1,4 @@ -// TransferRateEstimator +// NetSocket: TransferRateEstimator // Dustin Mierau • @mierau // MIT License diff --git a/Hotline/Library/QuickLookPreviewView.swift b/Hotline/Library/QuickLookPreviewView.swift deleted file mode 100644 index 6ba154e..0000000 --- a/Hotline/Library/QuickLookPreviewView.swift +++ /dev/null @@ -1,22 +0,0 @@ -import SwiftUI -import Quartz - -/// Embeddable QuickLook preview view for macOS -/// -/// This view uses QLPreviewView to display file previews inline, without showing a modal. -/// Supports all file types that QuickLook supports (images, PDFs, videos, documents, etc.) -struct QuickLookPreviewView: NSViewRepresentable { - let fileURL: URL - - func makeNSView(context: Context) -> QLPreviewView { - let preview = QLPreviewView(frame: .zero, style: .normal)! - preview.autostarts = true - preview.shouldCloseWithWindow = true - preview.previewItem = fileURL as QLPreviewItem - return preview - } - - func updateNSView(_ nsView: QLPreviewView, context: Context) { - nsView.previewItem = fileURL as QLPreviewItem - } -} diff --git a/Hotline/Library/RegularExpressions.swift b/Hotline/Library/RegularExpressions.swift new file mode 100644 index 0000000..c1f2a18 --- /dev/null +++ b/Hotline/Library/RegularExpressions.swift @@ -0,0 +1,235 @@ +import RegexBuilder + +struct RegularExpressions { + static let messageBoardDivider = Regex { + Capture { + OneOrMore { + CharacterClass(.newlineSequence) + } + ZeroOrMore { + CharacterClass(.whitespace, .newlineSequence) + } + Repeat(2...) { + CharacterClass(.anyOf("_-")) + } + ZeroOrMore { + CharacterClass(.whitespace) + } + OneOrMore { + CharacterClass(.newlineSequence) + } + } + } + + static let supportedLinkScheme = Regex { + Anchor.startOfLine + ChoiceOf { + "hotline" + "http" + "https" + } + "://" + }.ignoresCase().anchorsMatchLineEndings() + + static let relaxedLink = Regex { + ChoiceOf { + Anchor.startOfLine + Anchor.wordBoundary + } + Capture { + // scheme (optional) + Optionally { + ChoiceOf { + "hotline://" + "http://" + "https://" + } + } + // domain name + OneOrMore { + CharacterClass( + .anyOf(".-@"), + ("a"..."z"), + ("0"..."9") + ) + } + // top-level domain name + "." + ChoiceOf { + "com" + "net" + "org" + "edu" + "gov" + "mil" + "aero" + "asia" + "biz" + "cat" + "coop" + "info" + "int" + "jobs" + "mobi" + "museum" + "name" + "pizza" + "post" + "pro" + "red" + "tel" + "today" + "travel" + "garden" + "online" + "ai" + "be" + "by" + "ca" + "co" + "de" + "er" + "es" + "fr" + "gs" + "ie" + "im" + "in" + "io" + "is" + "it" + "jp" + "la" + "ly" + "ma" + "md" + "me" + "my" + "nl" + "ps" + "pt" + "ja" + "st" + "to" + "tv" + "uk" + "ws" + } + // Port + Optionally { + ":" + OneOrMore { + CharacterClass(.digit) + } + } + // path + ZeroOrMore { + CharacterClass( + .anyOf("#_-/.?=&%\\()[]"), + ("a"..."z"), + ("0"..."9") + ) + } + } + ChoiceOf { + Anchor.endOfLine + Anchor.wordBoundary + } + } + .anchorsMatchLineEndings() + .ignoresCase() + + static let emailAddress = Regex { + ChoiceOf { + Anchor.startOfLine + Anchor.wordBoundary + } + Capture { + // username + OneOrMore { + CharacterClass( + .anyOf(".-_"), + ("a"..."z"), + ("0"..."9") + ) + } + "@" + // domain name + OneOrMore { + CharacterClass( + .anyOf(".-"), + ("a"..."z"), + ("0"..."9") + ) + } + // top-level domain name + "." + ChoiceOf { + "com" + "net" + "org" + "edu" + "gov" + "mil" + "aero" + "asia" + "biz" + "cat" + "coop" + "info" + "int" + "jobs" + "mobi" + "museum" + "name" + "pizza" + "post" + "pro" + "red" + "tel" + "today" + "travel" + "garden" + "online" + "ai" + "be" + "by" + "ca" + "co" + "de" + "er" + "es" + "fr" + "gs" + "ie" + "im" + "in" + "io" + "is" + "it" + "jp" + "la" + "ly" + "ma" + "md" + "me" + "my" + "nl" + "ps" + "pt" + "ja" + "st" + "to" + "tv" + "uk" + "ws" + } + } + ChoiceOf { + Anchor.endOfLine + Anchor.wordBoundary + } + } + .anchorsMatchLineEndings() + .ignoresCase() +} diff --git a/Hotline/Library/SoundEffects.swift b/Hotline/Library/SoundEffects.swift new file mode 100644 index 0000000..93f8b9a --- /dev/null +++ b/Hotline/Library/SoundEffects.swift @@ -0,0 +1,50 @@ +import Foundation +import AVFAudio + +enum SoundEffects: String { + case loggedIn = "logged-in" + case chatMessage = "chat-message" + case transferComplete = "transfer-complete" + case userLogin = "user-login" + case userLogout = "user-logout" + case newNews = "new-news" + case serverMessage = "server-message" + case error = "error" +} + +@Observable +class SoundEffectPlayer: NSObject, AVAudioPlayerDelegate { + static let shared = SoundEffectPlayer() + + private var activeSounds: [AVAudioPlayer] = [] + + func playSoundEffect(_ name: SoundEffects) { + // Load a local sound file + guard let soundFileURL = Bundle.main.url( + forResource: name.rawValue, + withExtension: "aiff" + ) else { + 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) + } + } + } + + func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { + if let i = self.activeSounds.firstIndex(of: player) { + self.activeSounds.remove(at: i) + } + } +} diff --git a/Hotline/Library/SpinningGlobeView.swift b/Hotline/Library/SpinningGlobeView.swift deleted file mode 100644 index bccb969..0000000 --- a/Hotline/Library/SpinningGlobeView.swift +++ /dev/null @@ -1,90 +0,0 @@ -import SwiftUI - -/// An animated globe icon that cycles through different world regions -/// -/// Displays a spinning globe effect by cycling through SF Symbol images showing -/// different parts of the world. Useful for indicating network activity or global content. -/// -/// Example: -/// ```swift -/// SpinningGlobeView() -/// .frame(width: 16, height: 16) -/// -/// SpinningGlobeView(frameDelay: 0.5) -/// .frame(width: 24, height: 24) -/// ``` -struct SpinningGlobeView: View { - /// Delay between frames in seconds (default: 0.3) - let frameDelay: TimeInterval - - /// SF Symbol names for each frame of the globe animation - private let globeFrames = [ - "globe.americas.fill", - "globe.europe.africa.fill", - "globe.central.south.asia.fill", - "globe.asia.australia.fill" - ] - - @State private var currentFrameIndex = 0 - @State private var animationTask: Task? - - init(frameDelay: TimeInterval = 0.25) { - self.frameDelay = frameDelay - } - - var body: some View { - Image(systemName: globeFrames[currentFrameIndex]) - .onAppear { - startAnimation() - } - .onDisappear { - stopAnimation() - } - } - - private func startAnimation() { - animationTask = Task { - while !Task.isCancelled { - try? await Task.sleep(nanoseconds: UInt64(frameDelay * 1_000_000_000)) - - guard !Task.isCancelled else { break } - - currentFrameIndex = (currentFrameIndex + 1) % globeFrames.count - } - } - } - - private func stopAnimation() { - animationTask?.cancel() - animationTask = nil - } -} - -#Preview { - VStack(spacing: 20) { - HStack(spacing: 20) { - SpinningGlobeView() - .frame(width: 12, height: 12) - - SpinningGlobeView() - .frame(width: 16, height: 16) - - SpinningGlobeView() - .frame(width: 24, height: 24) - } - - Text("Different frame delays:") - - HStack(spacing: 20) { - SpinningGlobeView(frameDelay: 0.1) - .frame(width: 16, height: 16) - - SpinningGlobeView(frameDelay: 0.3) - .frame(width: 16, height: 16) - - SpinningGlobeView(frameDelay: 0.6) - .frame(width: 16, height: 16) - } - } - .padding() -} diff --git a/Hotline/Library/TextView.swift b/Hotline/Library/TextView.swift deleted file mode 100644 index 47746e2..0000000 --- a/Hotline/Library/TextView.swift +++ /dev/null @@ -1,119 +0,0 @@ -import SwiftUI - -struct BetterTextEditor: NSViewRepresentable { - - @Environment(\.lineSpacing) private var lineSpacing - - @Binding private var text: String - - private var customizations: [(NSTextView) -> Void] = [] - - init(text: Binding) { - self._text = text - } - - func makeCoordinator() -> Coordinator { - Coordinator(self) - } - - func makeNSView(context: Context) -> NSScrollView { - let scrollview = NSTextView.scrollablePlainDocumentContentTextView() - let textview = scrollview.documentView as! NSTextView - - textview.string = self.text - textview.delegate = context.coordinator - -// let p = NSMutableParagraphStyle() -// p.lineSpacing = self.lineSpacing -//// textview.defaultParagraphStyle = p -// textview.typingAttributes = [ -// .paragraphStyle: p -// ] - - textview.isEditable = true - textview.isRichText = false - textview.allowsUndo = true - textview.isFieldEditor = false - textview.usesAdaptiveColorMappingForDarkAppearance = true - textview.drawsBackground = false // true - textview.usesRuler = false - textview.usesFindBar = false - textview.isIncrementalSearchingEnabled = false - textview.isAutomaticQuoteSubstitutionEnabled = false - textview.isAutomaticDashSubstitutionEnabled = false - textview.isAutomaticSpellingCorrectionEnabled = true - textview.isAutomaticDataDetectionEnabled = false - textview.isAutomaticLinkDetectionEnabled = false - textview.usesInspectorBar = false - textview.usesFontPanel = false - textview.importsGraphics = false - textview.allowsImageEditing = false - textview.displaysLinkToolTips = true - textview.backgroundColor = NSColor.textBackgroundColor - textview.textContainerInset = NSSize(width: 16, height: 16) - textview.isContinuousSpellCheckingEnabled = true - textview.setSelectedRange(NSMakeRange(0, 0)) - self.customizations.forEach { $0(textview) } - - scrollview.scrollerStyle = .overlay - - return scrollview - } - - func updateNSView(_ nsView: NSScrollView, context: Context) { - let textview = nsView.documentView as! NSTextView - - if textview.string != text { - textview.string = text - } - - self.customizations.forEach { $0(textview) } - } - - func betterEditorFont(_ font: NSFont) -> Self { - self.customized { $0.font = font } - } - - func betterEditorParagraphStyle(_ paragraphStyle: NSParagraphStyle) -> Self { - self.customized { $0.defaultParagraphStyle = paragraphStyle } - } - - func betterEditorAutomaticDashSubstitution(_ enabled: Bool) -> Self { - self.customized { $0.isAutomaticDashSubstitutionEnabled = enabled } - } - - func betterEditorAutomaticQuoteSubstitution(_ enabled: Bool) -> Self { - self.customized { $0.isAutomaticQuoteSubstitutionEnabled = enabled } - } - - func betterEditorAutomaticSpellingCorrection(_ enabled: Bool) -> Self { - self.customized { $0.isAutomaticSpellingCorrectionEnabled = enabled } - } - - func betterEditorTextInset(_ size: NSSize) -> Self { - self.customized { $0.textContainerInset = size } - } - - class Coordinator: NSObject, NSTextViewDelegate { - var parent: BetterTextEditor - - init(_ parent: BetterTextEditor) { - self.parent = parent - } - - func textDidChange(_ notification: Notification) { - guard let textview = notification.object as? NSTextView else { - return - } - self.parent.text = textview.string - } - } -} - -private extension BetterTextEditor { - private func customized(_ customization: @escaping (NSTextView) -> Void) -> Self { - var copy = self - copy.customizations.append(customization) - return copy - } -} diff --git a/Hotline/Library/URLAdditions.swift b/Hotline/Library/URLAdditions.swift deleted file mode 100644 index 0ba2100..0000000 --- a/Hotline/Library/URLAdditions.swift +++ /dev/null @@ -1,55 +0,0 @@ -import Foundation -import UniformTypeIdentifiers - -extension URL { - func generateUniqueFilePath(filename base: String) -> String { - let fileManager = FileManager.default - var finalName = base - var counter = 2 - - // Helper function to generate a new filename with a counter - func makeFileName() -> String { - let baseName = (base as NSString).deletingPathExtension - let extensionName = (base as NSString).pathExtension - return extensionName.isEmpty ? "\(baseName) \(counter)" : "\(baseName) \(counter).\(extensionName)" - } - - // Check if file exists and append counter until a unique name is found - var filePath = self.appending(component: finalName).path(percentEncoded: false) - while fileManager.fileExists(atPath: filePath) { - finalName = makeFileName() - filePath = self.appending(component: finalName).path(percentEncoded: false) - counter += 1 - } - - return filePath - } -} - -extension UTType { - var canBePreviewedByQuickLook: Bool { - // QuickLook supports most common document types - let supportedSupertypes: [UTType] = [ - .image, - .movie, - .audio, - .pdf, - .font, - .usdz, - .text, - .sourceCode, - .spreadsheet, - .presentation, - -// Microsoft Office - .init(filenameExtension: "doc")!, - .init(filenameExtension: "docx")!, - .init(filenameExtension: "xls")!, - .init(filenameExtension: "xlsx")!, - .init(filenameExtension: "ppt")!, - .init(filenameExtension: "pptx")!, - ] - - return supportedSupertypes.contains { self.conforms(to: $0) } - } -} diff --git a/Hotline/Library/Utility/DAKeychain.swift b/Hotline/Library/Utility/DAKeychain.swift new file mode 100644 index 0000000..8031886 --- /dev/null +++ b/Hotline/Library/Utility/DAKeychain.swift @@ -0,0 +1,81 @@ +// +// DAKeychain.swift +// DAKeychain +// +// Created by Dejan on 28/02/2017. +// Copyright © 2017 Dejan. All rights reserved. +// + +import Foundation + +open class DAKeychain { + + open var loggingEnabled = false + + private init() {} + public static let shared = DAKeychain() + + open subscript(key: String) -> String? { + get { + return load(withKey: key) + } set { + DispatchQueue.global().sync(flags: .barrier) { + self.save(newValue, forKey: key) + } + } + } + + private func save(_ string: String?, forKey key: String) { + let query = keychainQuery(withKey: key) + let objectData: Data? = string?.data(using: .utf8, allowLossyConversion: false) + + if SecItemCopyMatching(query, nil) == noErr { + if let dictData = objectData { + let status = SecItemUpdate(query, NSDictionary(dictionary: [kSecValueData: dictData])) + logPrint("Update status: ", status) + } else { + let status = SecItemDelete(query) + logPrint("Delete status: ", status) + } + } else { + if let dictData = objectData { + query.setValue(dictData, forKey: kSecValueData as String) + let status = SecItemAdd(query, nil) + logPrint("Update status: ", status) + } + } + } + + private func load(withKey key: String) -> String? { + let query = keychainQuery(withKey: key) + query.setValue(kCFBooleanTrue, forKey: kSecReturnData as String) + query.setValue(kCFBooleanTrue, forKey: kSecReturnAttributes as String) + + var result: CFTypeRef? + let status = SecItemCopyMatching(query, &result) + + guard + let resultsDict = result as? NSDictionary, + let resultsData = resultsDict.value(forKey: kSecValueData as String) as? Data, + status == noErr + else { + logPrint("Load status: ", status) + return nil + } + return String(data: resultsData, encoding: .utf8) + } + + private func keychainQuery(withKey key: String) -> NSMutableDictionary { + let result = NSMutableDictionary() + result.setValue(kSecClassGenericPassword, forKey: kSecClass as String) + result.setValue(key, forKey: kSecAttrService as String) + result.setValue(kSecAttrAccessibleWhenUnlocked, forKey: kSecAttrAccessible as String) + return result + } + + private func logPrint(_ items: Any...) { + if loggingEnabled { + print(items) + } + } +} diff --git a/Hotline/Library/Utility/NSWindowBridge.swift b/Hotline/Library/Utility/NSWindowBridge.swift new file mode 100644 index 0000000..8f766d9 --- /dev/null +++ b/Hotline/Library/Utility/NSWindowBridge.swift @@ -0,0 +1,46 @@ +import SwiftUI + +fileprivate class NSWindowAccessorView: NSView { + let executeBlock: (_ window: NSWindow? ) -> () + + init(_ inConfigFunction: @escaping (_ window: NSWindow? ) -> () ) { + executeBlock = inConfigFunction + super.init( frame: NSRect() ) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + public override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + executeBlock( self.window ) // We pass it through even if it is nil. + } +} + +public struct NSWindowAccessor: NSViewRepresentable { + var configCode: (_ window: NSWindow? ) -> () + + public init(_ configCode: @escaping (_: NSWindow?) -> Void) { self.configCode = configCode } + public func makeNSView(context: Context) -> NSView { return NSWindowAccessorView( configCode ) } + public func updateNSView(_ nsView: NSView, context: Context) {} +} + + +//import SwiftUI + + /// A helper view you can embed once per window to run a closure +/// with the underlying NSWindow reference. +struct WindowConfigurator: NSViewRepresentable { + let configure: (NSWindow) -> Void + + func makeNSView(context: Context) -> NSView { + let view = NSView() + DispatchQueue.main.async { + if let window = view.window { + configure(window) + } + } + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { } +} diff --git a/Hotline/Library/Utility/ObservableScrollView.swift b/Hotline/Library/Utility/ObservableScrollView.swift new file mode 100644 index 0000000..a6f46cc --- /dev/null +++ b/Hotline/Library/Utility/ObservableScrollView.swift @@ -0,0 +1,38 @@ +import SwiftUI + +struct ScrollViewOffsetPreferenceKey: PreferenceKey { + typealias Value = CGFloat + static var defaultValue = CGFloat.zero + static func reduce(value: inout Value, nextValue: () -> Value) { + value += nextValue() + } +} + +struct ObservableScrollView: View where Content : View { + @Namespace var scrollSpace + @Binding var scrollOffset: CGFloat + let content: () -> Content + + init(scrollOffset: Binding, + @ViewBuilder content: @escaping () -> Content) { + _scrollOffset = scrollOffset + self.content = content + } + + var body: some View { + ScrollView { + content() + .background(GeometryReader { geo in + let offset = -geo.frame(in: .named(scrollSpace)).minY + Color.clear + .preference(key: ScrollViewOffsetPreferenceKey.self, + value: offset) + }) + + } + .coordinateSpace(name: scrollSpace) + .onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { value in + scrollOffset = value + } + } +} diff --git a/Hotline/Library/Utility/TextDocument.swift b/Hotline/Library/Utility/TextDocument.swift new file mode 100644 index 0000000..82727de --- /dev/null +++ b/Hotline/Library/Utility/TextDocument.swift @@ -0,0 +1,31 @@ +import Foundation +import UniformTypeIdentifiers +import SwiftUI + +struct TextFile: FileDocument { + // tell the system we support only plain text + static var readableContentTypes = [UTType.plainText, UTType.utf8PlainText] + + // by default our document is empty + var text = "" + + // a simple initializer that creates new, empty documents + init(initialText: String = "") { + text = initialText + } + + // this initializer loads data that has been saved previously + init(configuration: ReadConfiguration) throws { + + if let data = configuration.file.regularFileContents { + if let str = String(data: data, encoding: .utf8) { + self.text = str + } + } + } + + // this will be called when the system wants to write our data to disk + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + return FileWrapper(regularFileWithContents: self.text.data(using: .utf8)!) + } +} diff --git a/Hotline/Library/Views/AsyncLinkPreview.swift b/Hotline/Library/Views/AsyncLinkPreview.swift new file mode 100644 index 0000000..89b186f --- /dev/null +++ b/Hotline/Library/Views/AsyncLinkPreview.swift @@ -0,0 +1,57 @@ +import SwiftUI +import LinkPresentation + +fileprivate class CustomLinkView: LPLinkView { + override var intrinsicContentSize: CGSize { CGSize(width: 0, height: super.intrinsicContentSize.height) } +} + +struct AsyncLinkPreview: View { + @State private var metadata: LPLinkMetadata? + @State private var isLoading = true + let url: URL? + + func fetchMetadata() async { + guard let url else { + self.isLoading = false + return + } + do { + let provider = LPMetadataProvider() + let metadata = try await provider.startFetchingMetadata(for: url) + self.metadata = metadata + self.isLoading = false + } catch { + self.isLoading = false + } + } + + var body: some View { + if isLoading { + ProgressView() + .controlSize(.small) + .task { + await self.fetchMetadata() + } + } else if let metadata = metadata { + LinkView(metadata: metadata) + .frame(width: 200) + } else { + Text(LocalizedStringKey(url!.absoluteString)) + .multilineTextAlignment(.leading) + .tint(Color("Link Color")) + } + } +} + +struct LinkView: NSViewRepresentable { + var metadata: LPLinkMetadata + + func makeNSView(context: Context) -> LPLinkView { + let linkView = CustomLinkView(metadata: metadata) + return linkView + } + + func updateNSView(_ nsView: LPLinkView, context: Context) { + // Nothing required + } +} diff --git a/Hotline/Library/Views/BetterTextEditor.swift b/Hotline/Library/Views/BetterTextEditor.swift new file mode 100644 index 0000000..47746e2 --- /dev/null +++ b/Hotline/Library/Views/BetterTextEditor.swift @@ -0,0 +1,119 @@ +import SwiftUI + +struct BetterTextEditor: NSViewRepresentable { + + @Environment(\.lineSpacing) private var lineSpacing + + @Binding private var text: String + + private var customizations: [(NSTextView) -> Void] = [] + + init(text: Binding) { + self._text = text + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + func makeNSView(context: Context) -> NSScrollView { + let scrollview = NSTextView.scrollablePlainDocumentContentTextView() + let textview = scrollview.documentView as! NSTextView + + textview.string = self.text + textview.delegate = context.coordinator + +// let p = NSMutableParagraphStyle() +// p.lineSpacing = self.lineSpacing +//// textview.defaultParagraphStyle = p +// textview.typingAttributes = [ +// .paragraphStyle: p +// ] + + textview.isEditable = true + textview.isRichText = false + textview.allowsUndo = true + textview.isFieldEditor = false + textview.usesAdaptiveColorMappingForDarkAppearance = true + textview.drawsBackground = false // true + textview.usesRuler = false + textview.usesFindBar = false + textview.isIncrementalSearchingEnabled = false + textview.isAutomaticQuoteSubstitutionEnabled = false + textview.isAutomaticDashSubstitutionEnabled = false + textview.isAutomaticSpellingCorrectionEnabled = true + textview.isAutomaticDataDetectionEnabled = false + textview.isAutomaticLinkDetectionEnabled = false + textview.usesInspectorBar = false + textview.usesFontPanel = false + textview.importsGraphics = false + textview.allowsImageEditing = false + textview.displaysLinkToolTips = true + textview.backgroundColor = NSColor.textBackgroundColor + textview.textContainerInset = NSSize(width: 16, height: 16) + textview.isContinuousSpellCheckingEnabled = true + textview.setSelectedRange(NSMakeRange(0, 0)) + self.customizations.forEach { $0(textview) } + + scrollview.scrollerStyle = .overlay + + return scrollview + } + + func updateNSView(_ nsView: NSScrollView, context: Context) { + let textview = nsView.documentView as! NSTextView + + if textview.string != text { + textview.string = text + } + + self.customizations.forEach { $0(textview) } + } + + func betterEditorFont(_ font: NSFont) -> Self { + self.customized { $0.font = font } + } + + func betterEditorParagraphStyle(_ paragraphStyle: NSParagraphStyle) -> Self { + self.customized { $0.defaultParagraphStyle = paragraphStyle } + } + + func betterEditorAutomaticDashSubstitution(_ enabled: Bool) -> Self { + self.customized { $0.isAutomaticDashSubstitutionEnabled = enabled } + } + + func betterEditorAutomaticQuoteSubstitution(_ enabled: Bool) -> Self { + self.customized { $0.isAutomaticQuoteSubstitutionEnabled = enabled } + } + + func betterEditorAutomaticSpellingCorrection(_ enabled: Bool) -> Self { + self.customized { $0.isAutomaticSpellingCorrectionEnabled = enabled } + } + + func betterEditorTextInset(_ size: NSSize) -> Self { + self.customized { $0.textContainerInset = size } + } + + class Coordinator: NSObject, NSTextViewDelegate { + var parent: BetterTextEditor + + init(_ parent: BetterTextEditor) { + self.parent = parent + } + + func textDidChange(_ notification: Notification) { + guard let textview = notification.object as? NSTextView else { + return + } + self.parent.text = textview.string + } + } +} + +private extension BetterTextEditor { + private func customized(_ customization: @escaping (NSTextView) -> Void) -> Self { + var copy = self + copy.customizations.append(customization) + return copy + } +} diff --git a/Hotline/Library/Views/FileIconView.swift b/Hotline/Library/Views/FileIconView.swift new file mode 100644 index 0000000..d0949fa --- /dev/null +++ b/Hotline/Library/Views/FileIconView.swift @@ -0,0 +1,74 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct FolderIconView: View { + private func folderIcon() -> Image { +#if os(iOS) + return Image(systemName: "folder.fill") +#elseif os(macOS) + return Image(nsImage: NSWorkspace.shared.icon(for: UTType.folder)) +#endif + } + + var body: some View { + folderIcon() + .resizable() + .scaledToFit() + } +} + +struct FileIconView: View { + let filename: String + let fileType: String? + + #if os(iOS) + private func fileIcon() -> Image { + let fileExtension = (self.filename as NSString).pathExtension + if let fileType = UTType(filenameExtension: fileExtension) { + if fileType.isSubtype(of: .movie) { + return Image(systemName: "play.rectangle") + } + else if fileType.isSubtype(of: .image) { + return Image(systemName: "photo") + } + else if fileType.isSubtype(of: .archive) { + return Image(systemName: "doc.zipper") + } + else if fileType.isSubtype(of: .text) { + return Image(systemName: "doc.text") + } + else { + return Image(systemName: "doc") + } + } + + return Image(systemName: "doc") + } + #elseif os(macOS) + private func fileIcon() -> Image { + let fileExtension = (self.filename as NSString).pathExtension + + if !fileExtension.isEmpty, + let uttype = UTType(filenameExtension: fileExtension) { + return Image(nsImage: NSWorkspace.shared.icon(for: uttype)) + } + else if let fileType = self.fileType, + let fileTypeExtension = FileManager.HFSTypeToExtension[fileType.lowercased()], + let uttype = UTType(filenameExtension: fileTypeExtension) { + return Image(nsImage: NSWorkspace.shared.icon(for: uttype)) + } + else { + return Image(nsImage: NSWorkspace.shared.icon(for: UTType.data)) + } + +// Image(nsImage: NSWorkspace.shared.icon(for: UTType(filenameExtension: (filename as NSString).pathExtension) ?? UTType.content)) + } + #endif + + + var body: some View { + fileIcon() + .resizable() + .scaledToFit() + } +} diff --git a/Hotline/Library/Views/FileImageView.swift b/Hotline/Library/Views/FileImageView.swift new file mode 100644 index 0000000..0cc50f1 --- /dev/null +++ b/Hotline/Library/Views/FileImageView.swift @@ -0,0 +1,98 @@ +import SwiftUI + +struct FileImageView: NSViewRepresentable { + var image: NSImage? + + let minimumSize: CGSize = CGSize(width: 350, height: 350) + let presentationPaddingRatio: Double = 0.5 + + func makeNSView(context: Context) -> NSImageView { + let imageView = NSImageView() + imageView.imageScaling = .scaleProportionallyUpOrDown + imageView.animates = true + imageView.isEditable = false + imageView.allowsCutCopyPaste = true + imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + if let img = self.image { + imageView.image = img + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.resizeWindowForImageView(imageView) + } + } + + return imageView + } + + func updateNSView(_ nsView: NSImageView, context: Context) { + nsView.image = self.image + } + + // MARK: - + + func resizeWindowForImageView(_ imageView: NSImageView) { + guard let window = imageView.window, let img = imageView.image else { + return + } + + var windowRect = window.contentLayoutRect + let windowChromeSize = CGSize(width: window.frame.width - windowRect.width, height: window.frame.height - windowRect.height) + + var windowMinSize: CGSize = windowRect.size + let centerPoint = CGPoint(x: window.frame.midX, y: window.frame.midY) + + windowRect.size = img.size + + if let screen = window.screen { + var paddedScreenSize = screen.frame.size + paddedScreenSize.width *= self.presentationPaddingRatio + paddedScreenSize.height *= self.presentationPaddingRatio + + windowMinSize = aspectFit(source: windowRect.size, bounds: self.minimumSize) + windowRect.size = aspectFit(source: windowRect.size, bounds: paddedScreenSize, minimum: windowMinSize) + } + + windowRect.size.width += windowChromeSize.width + windowRect.size.height += windowChromeSize.height + + windowRect.origin.x = centerPoint.x - windowRect.width / 2.0 + windowRect.origin.y = centerPoint.y - windowRect.height / 2.0 + + window.setFrame(windowRect, display: true, animate: true) + +// Do these APIs even work?? +// window.aspectRatio = windowRect.size +// window.contentAspectRatio = windowRect.size + } + + func aspectFit(source sourceSize: CGSize, bounds boundingSize: CGSize, minimum minSize: CGSize? = nil) -> CGSize { + let sourceAspectRatio = sourceSize.width / sourceSize.height + + var fitSize: CGSize = sourceSize + + if fitSize.width > boundingSize.width { + fitSize.width = boundingSize.width + fitSize.height = fitSize.width / sourceAspectRatio + } + + if fitSize.height > boundingSize.height { + fitSize.height = boundingSize.height + fitSize.width = fitSize.height * sourceAspectRatio + } + + if let m = minSize { + if fitSize.width < m.width { + fitSize.width = m.width + fitSize.height = fitSize.width / sourceAspectRatio + } + + if fitSize.height < m.height { + fitSize.height = m.height + fitSize.width = fitSize.height * sourceAspectRatio + } + } + + return fitSize + } +} diff --git a/Hotline/Library/Views/GroupedIconView.swift b/Hotline/Library/Views/GroupedIconView.swift new file mode 100644 index 0000000..313fac6 --- /dev/null +++ b/Hotline/Library/Views/GroupedIconView.swift @@ -0,0 +1,23 @@ +import SwiftUI + +struct GroupedIconView: View { + let color: Color + let systemName: String + var padding: CGFloat = 6.0 + var cornerRadius: CGFloat = 8.0 + + var body: some View { + RoundedRectangle(cornerRadius: self.cornerRadius, style: .continuous) + .fill(LinearGradient(colors: [self.color.mix(with: .white, by: 0.2), self.color], startPoint: .top, endPoint: .bottom)) + .shadow(color: self.color.mix(with: .black, by: 0.5).opacity(0.2), radius: 1, x: 0, y: 1) + .overlay { + Image(systemName: self.systemName) + .resizable() + .scaledToFit() + .symbolRenderingMode(.palette) + .foregroundStyle(.white, .white.opacity(0.4)) + .padding(self.padding) + .shadow(color: self.color.mix(with: .black, by: 0.5).opacity(0.2), radius: 0, x: 0, y: -1) + } + } +} diff --git a/Hotline/Library/Views/HotlinePanel.swift b/Hotline/Library/Views/HotlinePanel.swift new file mode 100644 index 0000000..d7d8284 --- /dev/null +++ b/Hotline/Library/Views/HotlinePanel.swift @@ -0,0 +1,64 @@ +import Cocoa +import SwiftUI + +fileprivate let HOTLINE_PANEL_SIZE: CGSize = CGSizeMake(468, 114 - 10) + +class HotlinePanel: NSPanel { + init(_ view: HotlinePanelView) { + super.init(contentRect: NSRect(x: 0, y: 0, width: HOTLINE_PANEL_SIZE.width, height: HOTLINE_PANEL_SIZE.height), styleMask: [.nonactivatingPanel, .titled, .closable, .utilityWindow, .fullSizeContentView], backing: .buffered, defer: false) + + // Make sure that the panel is in front of almost all other windows + self.isFloatingPanel = true + self.level = .floating + self.hidesOnDeactivate = true + self.animationBehavior = .utilityWindow + + // Allow the panelto appear in a fullscreen space +// self.collectionBehavior.insert(.fullScreenAuxiliary) + self.collectionBehavior.insert(.canJoinAllSpaces) + self.collectionBehavior.insert(.ignoresCycle) + +// self.appearance = NSAppearance(named: .vibrantDark) + + // Don't delete panel state when it's closed. + self.isReleasedWhenClosed = false + + self.standardWindowButton(.closeButton)?.isHidden = false + self.standardWindowButton(.zoomButton)?.isHidden = true + self.standardWindowButton(.miniaturizeButton)?.isHidden = true + + // Make it transparent, the view inside will have to set the background. + // This is necessary because otherwise, we will have some space for the titlebar on top of the height of the view itself which we don't want. + self.isOpaque = false + self.backgroundColor = .clear + + // Since we don't show a statusbar, this allows us to drag the window by its background instead of the titlebar. + self.isMovableByWindowBackground = true + self.titlebarAppearsTransparent = true + + let hostingView = NSHostingView(rootView: view.edgesIgnoringSafeArea(.top)) + hostingView.sizingOptions = [.preferredContentSize] + + let visualEffectView = NSVisualEffectView(frame: NSRect(x: 0, y: 0, width: HOTLINE_PANEL_SIZE.width, height: HOTLINE_PANEL_SIZE.height)) + visualEffectView.material = .sidebar + visualEffectView.blendingMode = .behindWindow + visualEffectView.state = NSVisualEffectView.State.active + visualEffectView.autoresizingMask = [.width, .height] + visualEffectView.autoresizesSubviews = true + visualEffectView.addSubview(hostingView) + + self.contentView = visualEffectView + + hostingView.frame = visualEffectView.bounds + + self.cascadeTopLeft(from: NSMakePoint(16, 16)) + } + + override var canBecomeKey: Bool { + return false + } + + override var canBecomeMain: Bool { + return false + } +} diff --git a/Hotline/Library/Views/QuickLookPreviewView.swift b/Hotline/Library/Views/QuickLookPreviewView.swift new file mode 100644 index 0000000..6ba154e --- /dev/null +++ b/Hotline/Library/Views/QuickLookPreviewView.swift @@ -0,0 +1,22 @@ +import SwiftUI +import Quartz + +/// Embeddable QuickLook preview view for macOS +/// +/// This view uses QLPreviewView to display file previews inline, without showing a modal. +/// Supports all file types that QuickLook supports (images, PDFs, videos, documents, etc.) +struct QuickLookPreviewView: NSViewRepresentable { + let fileURL: URL + + func makeNSView(context: Context) -> QLPreviewView { + let preview = QLPreviewView(frame: .zero, style: .normal)! + preview.autostarts = true + preview.shouldCloseWithWindow = true + preview.previewItem = fileURL as QLPreviewItem + return preview + } + + func updateNSView(_ nsView: QLPreviewView, context: Context) { + nsView.previewItem = fileURL as QLPreviewItem + } +} diff --git a/Hotline/Library/Views/SpinningGlobeView.swift b/Hotline/Library/Views/SpinningGlobeView.swift new file mode 100644 index 0000000..bccb969 --- /dev/null +++ b/Hotline/Library/Views/SpinningGlobeView.swift @@ -0,0 +1,90 @@ +import SwiftUI + +/// An animated globe icon that cycles through different world regions +/// +/// Displays a spinning globe effect by cycling through SF Symbol images showing +/// different parts of the world. Useful for indicating network activity or global content. +/// +/// Example: +/// ```swift +/// SpinningGlobeView() +/// .frame(width: 16, height: 16) +/// +/// SpinningGlobeView(frameDelay: 0.5) +/// .frame(width: 24, height: 24) +/// ``` +struct SpinningGlobeView: View { + /// Delay between frames in seconds (default: 0.3) + let frameDelay: TimeInterval + + /// SF Symbol names for each frame of the globe animation + private let globeFrames = [ + "globe.americas.fill", + "globe.europe.africa.fill", + "globe.central.south.asia.fill", + "globe.asia.australia.fill" + ] + + @State private var currentFrameIndex = 0 + @State private var animationTask: Task? + + init(frameDelay: TimeInterval = 0.25) { + self.frameDelay = frameDelay + } + + var body: some View { + Image(systemName: globeFrames[currentFrameIndex]) + .onAppear { + startAnimation() + } + .onDisappear { + stopAnimation() + } + } + + private func startAnimation() { + animationTask = Task { + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: UInt64(frameDelay * 1_000_000_000)) + + guard !Task.isCancelled else { break } + + currentFrameIndex = (currentFrameIndex + 1) % globeFrames.count + } + } + } + + private func stopAnimation() { + animationTask?.cancel() + animationTask = nil + } +} + +#Preview { + VStack(spacing: 20) { + HStack(spacing: 20) { + SpinningGlobeView() + .frame(width: 12, height: 12) + + SpinningGlobeView() + .frame(width: 16, height: 16) + + SpinningGlobeView() + .frame(width: 24, height: 24) + } + + Text("Different frame delays:") + + HStack(spacing: 20) { + SpinningGlobeView(frameDelay: 0.1) + .frame(width: 16, height: 16) + + SpinningGlobeView(frameDelay: 0.3) + .frame(width: 16, height: 16) + + SpinningGlobeView(frameDelay: 0.6) + .frame(width: 16, height: 16) + } + } + .padding() +} diff --git a/Hotline/Library/Views/VisualEffectView.swift b/Hotline/Library/Views/VisualEffectView.swift new file mode 100644 index 0000000..e595c55 --- /dev/null +++ b/Hotline/Library/Views/VisualEffectView.swift @@ -0,0 +1,22 @@ +import SwiftUI + +struct VisualEffectView: NSViewRepresentable +{ + let material: NSVisualEffectView.Material + let blendingMode: NSVisualEffectView.BlendingMode + + func makeNSView(context: Context) -> NSVisualEffectView + { + let visualEffectView = NSVisualEffectView() + visualEffectView.material = material + visualEffectView.blendingMode = blendingMode + visualEffectView.state = NSVisualEffectView.State.active + return visualEffectView + } + + func updateNSView(_ visualEffectView: NSVisualEffectView, context: Context) + { + visualEffectView.material = material + visualEffectView.blendingMode = blendingMode + } +} diff --git a/Hotline/Library/VisualEffectView.swift b/Hotline/Library/VisualEffectView.swift deleted file mode 100644 index e595c55..0000000 --- a/Hotline/Library/VisualEffectView.swift +++ /dev/null @@ -1,22 +0,0 @@ -import SwiftUI - -struct VisualEffectView: NSViewRepresentable -{ - let material: NSVisualEffectView.Material - let blendingMode: NSVisualEffectView.BlendingMode - - func makeNSView(context: Context) -> NSVisualEffectView - { - let visualEffectView = NSVisualEffectView() - visualEffectView.material = material - visualEffectView.blendingMode = blendingMode - visualEffectView.state = NSVisualEffectView.State.active - return visualEffectView - } - - func updateNSView(_ visualEffectView: NSVisualEffectView, context: Context) - { - visualEffectView.material = material - visualEffectView.blendingMode = blendingMode - } -} diff --git a/Hotline/Utility/BookmarkDocument.swift b/Hotline/Utility/BookmarkDocument.swift deleted file mode 100644 index 47fa442..0000000 --- a/Hotline/Utility/BookmarkDocument.swift +++ /dev/null @@ -1,32 +0,0 @@ -import SwiftUI -import Foundation -import UniformTypeIdentifiers - -struct BookmarkDocument: FileDocument { - static var readableContentTypes: [UTType] { [.data, UTType(filenameExtension: "hlbm")!] } - static var writableContentTypes: [UTType] { [.data, UTType(filenameExtension: "hlbm")!] } - - var bookmark: Bookmark - - init(bookmark: Bookmark) { - self.bookmark = bookmark - } - - init(configuration: ReadConfiguration) throws { - guard configuration.file.isRegularFile, - let data = configuration.file.regularFileContents, - let fileName = configuration.file.preferredFilename, - let bookmark = Bookmark(fileData: data, name: (fileName as NSString).deletingPathExtension) - else { - throw CocoaError(.fileReadCorruptFile) - } - self.bookmark = bookmark - } - - func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { - let wrapper = FileWrapper(regularFileWithContents: self.bookmark.bookmarkFileData()!) - wrapper.fileAttributes[FileAttributeKey.hfsCreatorCode.rawValue] = "HTLC".fourCharCode() - wrapper.fileAttributes[FileAttributeKey.hfsTypeCode.rawValue] = "HTbm".fourCharCode() - return wrapper - } -} diff --git a/Hotline/Utility/ColorArt.swift b/Hotline/Utility/ColorArt.swift deleted file mode 100644 index 93889a3..0000000 --- a/Hotline/Utility/ColorArt.swift +++ /dev/null @@ -1,424 +0,0 @@ - -// Swift translation and modernization -// by Dustin Mierau -// -// of: -// -// ColorArt.swift -// SLColorArt by Panic Inc. -// -// Copyright (C) 2012 Panic Inc. Code by Wade Cosgrove. All rights reserved. -// -// Redistribution and use, with or without modification, are permitted -// provided that the following conditions are met: -// -// - Redistributions must reproduce the above copyright notice, this list of -// conditions and the following disclaimer in the documentation and/or other -// materials provided with the distribution. -// -// - Neither the name of Panic Inc nor the names of its contributors may be used -// to endorse or promote works derived from this software without specific prior -// written permission from Panic Inc. -// -// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -// ARE DISCLAIMED. IN NO EVENT SHALL PANIC INC BE LIABLE FOR ANY DIRECT, INDIRECT, -// INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR -// PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, -// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE -// POSSIBILITY OF SUCH DAMAGE. - -import AppKit -import SwiftUI - -fileprivate let kColorThresholdMinimumPercentage: CGFloat = 0.001 - -// ColorArt.analyze(image: img) -> ColorArt? - -struct ColorArt: Equatable { - let backgroundColor: NSColor - let primaryColor: NSColor - let secondaryColor: NSColor - let detailColor: NSColor -// let scaledImage: NSImage - - static func == (lhs: ColorArt, rhs: ColorArt) -> Bool { - return lhs.backgroundColor == rhs.backgroundColor && - lhs.primaryColor == rhs.primaryColor && - lhs.secondaryColor == rhs.secondaryColor && - lhs.detailColor == rhs.detailColor - } - - static func analyze(image: NSImage) -> ColorArt? { - print("ColorArt.analyze: Starting, image size: \(image.size)") - // Scale image to a reasonable size for analysis - // This is important because: - // 1. Makes analysis faster (fewer pixels) - // 2. Normalizes weird image dimensions - // 3. Ensures CGImage conversion succeeds - print("ColorArt.analyze: Calling scaleImage...") - let finalImage = Self.scaleImage(image, size: NSSize(width: 100, height: 100)) - print("ColorArt.analyze: scaleImage returned, scaled size: \(finalImage.size)") - - guard let colors = Self.analyzeImage(finalImage) else { - print("ColorArt.analyze: failed with no colors") - return nil - } - - print("ColorArt.analyze: returning colors", colors) - - return ColorArt(backgroundColor: colors.background, - primaryColor: colors.primary, - secondaryColor: colors.secondary, - detailColor: colors.detail) - } - - // MARK: - Image Scaling - - private static func scaleImage(_ image: NSImage, size scaledSize: NSSize) -> NSImage { - print("ColorArt.scaleImage: Entered, input: \(image.size), target: \(scaledSize)") - // Get CGImage directly without using lockFocus - print("ColorArt.scaleImage: Getting CGImage...") - guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { - print("ColorArt.scaleImage: Failed to get CGImage, returning original") - return image - } - print("ColorArt.scaleImage: Got CGImage") - - let imageSize = image.size - let squareSize = min(imageSize.width, imageSize.height) - - // Use native square size if passed zero size - let finalScaledSize = scaledSize == .zero ? NSSize(width: squareSize, height: squareSize) : scaledSize - - // Create bitmap context for drawing - let width = Int(finalScaledSize.width) - let height = Int(finalScaledSize.height) - let colorSpace = CGColorSpaceCreateDeviceRGB() - let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue) - - guard let context = CGContext( - data: nil, - width: width, - height: height, - bitsPerComponent: 8, - bytesPerRow: width * 4, - space: colorSpace, - bitmapInfo: bitmapInfo.rawValue - ) else { - return image - } - - // Draw the image scaled - context.interpolationQuality = .high - context.draw(cgImage, in: CGRect(x: 0, y: 0, width: finalScaledSize.width, height: finalScaledSize.height)) - - // Create NSImage from context - guard let scaledCGImage = context.makeImage() else { - return image - } - - let bitmapRep = NSBitmapImageRep(cgImage: scaledCGImage) - let finalImage = NSImage(size: finalScaledSize) - finalImage.addRepresentation(bitmapRep) - - return finalImage - } - - // MARK: - Image Analysis - - private static func analyzeImage(_ image: NSImage) -> (background: NSColor, primary: NSColor, secondary: NSColor, detail: NSColor)? { - var imageColors: NSCountedSet? - guard let backgroundColor = self.findEdgeColor(image, imageColors: &imageColors), - let colors = imageColors - else { - return nil - } - - let darkBackground = backgroundColor.isDarkColor - var primaryColor: NSColor? - var secondaryColor: NSColor? - var detailColor: NSColor? - - self.findTextColors(colors, primaryColor: &primaryColor, secondaryColor: &secondaryColor, detailColor: &detailColor, backgroundColor: backgroundColor) - - // Fallback to black or white if colors not found - if primaryColor == nil { - primaryColor = darkBackground ? .white : .black - } - - if secondaryColor == nil { - secondaryColor = darkBackground ? .white : .black - } - - if detailColor == nil { - detailColor = darkBackground ? .white : .black - } - - // Convert all colors to calibrated RGB color space for consistency - // This ensures all colors are in the same color space and prevents - // any color space conversion issues when used in SwiftUI - let rgbColorSpace = NSColorSpace.genericRGB - let finalBackground = backgroundColor.usingColorSpace(rgbColorSpace) ?? backgroundColor - let finalPrimary = primaryColor!.usingColorSpace(rgbColorSpace) ?? primaryColor! - let finalSecondary = secondaryColor!.usingColorSpace(rgbColorSpace) ?? secondaryColor! - let finalDetail = detailColor!.usingColorSpace(rgbColorSpace) ?? detailColor! - - return (finalBackground, finalPrimary, finalSecondary, finalDetail) - } - - // MARK: - Edge Color Detection - - private static func findEdgeColor(_ image: NSImage, imageColors: inout NSCountedSet?) -> NSColor? { - var bitmapRep: NSBitmapImageRep? - - // Try to get existing bitmap representation - if let existingRep = image.representations.last as? NSBitmapImageRep { - bitmapRep = existingRep - } else { - // Create bitmap rep from CGImage instead of using lockFocus - guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { - return nil - } - bitmapRep = NSBitmapImageRep(cgImage: cgImage) - } - - // Convert to RGB color space - guard let bitmapRep = bitmapRep?.converting(to: .genericRGB, renderingIntent: .default) else { - return nil - } - - let pixelsWide = bitmapRep.pixelsWide - let pixelsHigh = bitmapRep.pixelsHigh - - let colors = NSCountedSet(capacity: pixelsWide * pixelsHigh) - let leftEdgeColors = NSCountedSet(capacity: pixelsHigh) - var searchColumnX = 0 - - for x in 0.. 0.5 { - leftEdgeColors.add(color) - } - } - - if color.alphaComponent > CGFloat.ulpOfOne { - colors.add(color) - } - } - - // Background is clear, keep looking in next column for background color - if leftEdgeColors.count == 0 { - searchColumnX += 1 - } - } - - imageColors = colors - - var sortedColors: [CountedColor] = [] - - for color in leftEdgeColors { - guard let nsColor = color as? NSColor else { continue } - let colorCount = leftEdgeColors.count(for: nsColor) - - let randomColorsThreshold = Int(CGFloat(pixelsHigh) * kColorThresholdMinimumPercentage) - - if colorCount <= randomColorsThreshold { - continue - } - - sortedColors.append(CountedColor(color: nsColor, count: colorCount)) - } - - sortedColors.sort { $0.count > $1.count } - - guard var proposedEdgeColor = sortedColors.first else { - return nil - } - - // Want to choose color over black/white so we keep looking - if proposedEdgeColor.color.isBlackOrWhite { - for i in 1.. 0.3 { - if !nextProposedColor.color.isBlackOrWhite { - proposedEdgeColor = nextProposedColor - break - } - } else { - // Reached color threshold less than 30% of the original proposed edge color - break - } - } - } - - return proposedEdgeColor.color - } - - // MARK: - Text Color Detection - - private static func findTextColors(_ colors: NSCountedSet, primaryColor: inout NSColor?, secondaryColor: inout NSColor?, detailColor: inout NSColor?, backgroundColor: NSColor) { - var sortedColors: [CountedColor] = [] - let findDarkTextColor = !backgroundColor.isDarkColor - - for color in colors { - guard let nsColor = color as? NSColor else { continue } - let adjustedColor = nsColor.withMinimumSaturation(0.15) - - if adjustedColor.isDarkColor == findDarkTextColor { - let colorCount = colors.count(for: nsColor) - sortedColors.append(CountedColor(color: adjustedColor, count: colorCount)) - } - } - - sortedColors.sort { $0.count > $1.count } - - for container in sortedColors { - let curColor = container.color - - if primaryColor == nil { - if curColor.isContrasting(to: backgroundColor) { - primaryColor = curColor - } - } else if secondaryColor == nil { - if let primary = primaryColor, - primary.isDistinct(from: curColor) && curColor.isContrasting(to: backgroundColor) { - secondaryColor = curColor - } - } else if detailColor == nil { - if let primary = primaryColor, - let secondary = secondaryColor, - secondary.isDistinct(from: curColor) && - primary.isDistinct(from: curColor) && - curColor.isContrasting(to: backgroundColor) { - detailColor = curColor - break - } - } - } - } -} - -// MARK: - Helper Classes - -fileprivate struct CountedColor { - let color: NSColor - let count: Int -} - -// MARK: - NSColor Extensions - -extension NSColor { - var isDarkColor: Bool { - guard let convertedColor = usingColorSpace(.genericRGB) else { - return false - } - - var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 - convertedColor.getRed(&r, green: &g, blue: &b, alpha: &a) - - let lum = 0.2126 * r + 0.7152 * g + 0.0722 * b - - return lum < 0.5 - } - - func isDistinct(from compareColor: NSColor) -> Bool { - guard let convertedColor = usingColorSpace(.genericRGB), - let convertedCompareColor = compareColor.usingColorSpace(.genericRGB) - else { - return false - } - - var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 - var r1: CGFloat = 0, g1: CGFloat = 0, b1: CGFloat = 0, a1: CGFloat = 0 - - convertedColor.getRed(&r, green: &g, blue: &b, alpha: &a) - convertedCompareColor.getRed(&r1, green: &g1, blue: &b1, alpha: &a1) - - let threshold: CGFloat = 0.25 - - if abs(r - r1) > threshold || abs(g - g1) > threshold || abs(b - b1) > threshold || abs(a - a1) > threshold { - // Check for grays, prevent multiple gray colors - if abs(r - g) < 0.03 && abs(r - b) < 0.03 { - if abs(r1 - g1) < 0.03 && abs(r1 - b1) < 0.03 { - return false - } - } - - return true - } - - return false - } - - func withMinimumSaturation(_ minSaturation: CGFloat) -> NSColor { - guard let tempColor = usingColorSpace(.genericRGB) else { - return self - } - - var hue: CGFloat = 0, saturation: CGFloat = 0, brightness: CGFloat = 0, alpha: CGFloat = 0 - tempColor.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) - - if saturation < minSaturation { - return NSColor(calibratedHue: hue, saturation: minSaturation, brightness: brightness, alpha: alpha) - } - - return self - } - - var isBlackOrWhite: Bool { - guard let tempColor = usingColorSpace(.genericRGB) else { - return false - } - - var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 - tempColor.getRed(&r, green: &g, blue: &b, alpha: &a) - - // White - if r > 0.91 && g > 0.91 && b > 0.91 { - return true - } - - // Black - if r < 0.09 && g < 0.09 && b < 0.09 { - return true - } - - return false - } - - func isContrasting(to color: NSColor) -> Bool { - guard let backgroundColor = usingColorSpace(.genericRGB), - let foregroundColor = color.usingColorSpace(.genericRGB) - else { - return true - } - - var br: CGFloat = 0, bg: CGFloat = 0, bb: CGFloat = 0, ba: CGFloat = 0 - var fr: CGFloat = 0, fg: CGFloat = 0, fb: CGFloat = 0, fa: CGFloat = 0 - - backgroundColor.getRed(&br, green: &bg, blue: &bb, alpha: &ba) - foregroundColor.getRed(&fr, green: &fg, blue: &fb, alpha: &fa) - - let bLum = 0.2126 * br + 0.7152 * bg + 0.0722 * bb - let fLum = 0.2126 * fr + 0.7152 * fg + 0.0722 * fb - - let contrast: CGFloat - if bLum > fLum { - contrast = (bLum + 0.05) / (fLum + 0.05) - } else { - contrast = (fLum + 0.05) / (bLum + 0.05) - } - - return contrast > 1.6 - } -} diff --git a/Hotline/Utility/DAKeychain.swift b/Hotline/Utility/DAKeychain.swift deleted file mode 100644 index 8031886..0000000 --- a/Hotline/Utility/DAKeychain.swift +++ /dev/null @@ -1,81 +0,0 @@ -// -// DAKeychain.swift -// DAKeychain -// -// Created by Dejan on 28/02/2017. -// Copyright © 2017 Dejan. All rights reserved. -// - -import Foundation - -open class DAKeychain { - - open var loggingEnabled = false - - private init() {} - public static let shared = DAKeychain() - - open subscript(key: String) -> String? { - get { - return load(withKey: key) - } set { - DispatchQueue.global().sync(flags: .barrier) { - self.save(newValue, forKey: key) - } - } - } - - private func save(_ string: String?, forKey key: String) { - let query = keychainQuery(withKey: key) - let objectData: Data? = string?.data(using: .utf8, allowLossyConversion: false) - - if SecItemCopyMatching(query, nil) == noErr { - if let dictData = objectData { - let status = SecItemUpdate(query, NSDictionary(dictionary: [kSecValueData: dictData])) - logPrint("Update status: ", status) - } else { - let status = SecItemDelete(query) - logPrint("Delete status: ", status) - } - } else { - if let dictData = objectData { - query.setValue(dictData, forKey: kSecValueData as String) - let status = SecItemAdd(query, nil) - logPrint("Update status: ", status) - } - } - } - - private func load(withKey key: String) -> String? { - let query = keychainQuery(withKey: key) - query.setValue(kCFBooleanTrue, forKey: kSecReturnData as String) - query.setValue(kCFBooleanTrue, forKey: kSecReturnAttributes as String) - - var result: CFTypeRef? - let status = SecItemCopyMatching(query, &result) - - guard - let resultsDict = result as? NSDictionary, - let resultsData = resultsDict.value(forKey: kSecValueData as String) as? Data, - status == noErr - else { - logPrint("Load status: ", status) - return nil - } - return String(data: resultsData, encoding: .utf8) - } - - private func keychainQuery(withKey key: String) -> NSMutableDictionary { - let result = NSMutableDictionary() - result.setValue(kSecClassGenericPassword, forKey: kSecClass as String) - result.setValue(key, forKey: kSecAttrService as String) - result.setValue(kSecAttrAccessibleWhenUnlocked, forKey: kSecAttrAccessible as String) - return result - } - - private func logPrint(_ items: Any...) { - if loggingEnabled { - print(items) - } - } -} diff --git a/Hotline/Utility/FoundationExtensions.swift b/Hotline/Utility/FoundationExtensions.swift deleted file mode 100644 index 90a3032..0000000 --- a/Hotline/Utility/FoundationExtensions.swift +++ /dev/null @@ -1,107 +0,0 @@ -import Foundation -import SwiftUI - -enum Endianness { - case big - case little -} - -extension String { - - func markdownToAttributedString() -> AttributedString { - let markdownText = self.convertingLinksToMarkdown() - let attr = (try? AttributedString(markdown: markdownText, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(self) - - return attr - } - - func convertToAttributedStringWithLinks() -> AttributedString { - let attributedString: NSMutableAttributedString = NSMutableAttributedString(string: self) - let matches = self.ranges(of: RegularExpressions.relaxedLink) - for match in matches { - let matchString = String(self[match]) - if matchString.isEmailAddress() { - attributedString.addAttribute(.link, value: "mailto:\(matchString)", range: NSRange(match, in: self)) - } - else { - attributedString.addAttribute(.link, value: matchString, range: NSRange(match, in: self)) - } -// attributedString.addAttribute(.underlineStyle, value: 1, range: NSRange(match, in: self)) - } - return AttributedString(attributedString) - } - - func isEmailAddress() -> Bool { - self.wholeMatch(of: RegularExpressions.emailAddress) != nil - } - - func isWebURL() -> Bool { - guard let url = URL(string: self) else { - return false - } - switch url.scheme?.lowercased() { - case "http", "https": - return true - default: - return false - } - } - - func isImageURL() -> Bool { - guard let url = URL(string: self) else { - return false - } - - switch url.pathExtension.lowercased() { - case "jpg", "jpeg", "png", "gif": - return true - default: - return false - } - } - - func convertingLinksToMarkdown() -> String { -// var cp = String(self) - - self.replacing(RegularExpressions.relaxedLink) { match in - let linkText = self[match.range] - - // Only add https:// if the link doesn't already have a scheme - let hasScheme = (try? RegularExpressions.supportedLinkScheme.prefixMatch(in: linkText)) != nil - let url = hasScheme ? String(linkText) : "https://\(linkText)" - - return "[\(linkText)](\(url))" - } - -// cp.replace(RegularExpressions.relaxedLink) { match -> String in -// let linkText = self[match.range] -// var injectedScheme = "https://" -// if let _ = try? RegularExpressions.supportedLinkScheme.prefixMatch(in: linkText) { -// injectedScheme = "" -// } -// -// return "[\(linkText)](\(injectedScheme)\(linkText))" -// } -// return cp - } -} - - - -extension Binding where Value: OptionSet, Value == Value.Element { - func bindedValue(_ options: Value) -> Bool { - return wrappedValue.contains(options) - } - - func bind(_ options: Value) -> Binding { - return .init { () -> Bool in - self.wrappedValue.contains(options) - } set: { newValue in - if newValue { - self.wrappedValue.insert(options) - } else { - self.wrappedValue.remove(options) - } - } - } -} diff --git a/Hotline/Utility/NSWindowBridge.swift b/Hotline/Utility/NSWindowBridge.swift deleted file mode 100644 index 8f766d9..0000000 --- a/Hotline/Utility/NSWindowBridge.swift +++ /dev/null @@ -1,46 +0,0 @@ -import SwiftUI - -fileprivate class NSWindowAccessorView: NSView { - let executeBlock: (_ window: NSWindow? ) -> () - - init(_ inConfigFunction: @escaping (_ window: NSWindow? ) -> () ) { - executeBlock = inConfigFunction - super.init( frame: NSRect() ) - } - - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - - public override func viewDidMoveToWindow() { - super.viewDidMoveToWindow() - executeBlock( self.window ) // We pass it through even if it is nil. - } -} - -public struct NSWindowAccessor: NSViewRepresentable { - var configCode: (_ window: NSWindow? ) -> () - - public init(_ configCode: @escaping (_: NSWindow?) -> Void) { self.configCode = configCode } - public func makeNSView(context: Context) -> NSView { return NSWindowAccessorView( configCode ) } - public func updateNSView(_ nsView: NSView, context: Context) {} -} - - -//import SwiftUI - - /// A helper view you can embed once per window to run a closure -/// with the underlying NSWindow reference. -struct WindowConfigurator: NSViewRepresentable { - let configure: (NSWindow) -> Void - - func makeNSView(context: Context) -> NSView { - let view = NSView() - DispatchQueue.main.async { - if let window = view.window { - configure(window) - } - } - return view - } - - func updateNSView(_ nsView: NSView, context: Context) { } -} diff --git a/Hotline/Utility/ObservableScrollView.swift b/Hotline/Utility/ObservableScrollView.swift deleted file mode 100644 index a6f46cc..0000000 --- a/Hotline/Utility/ObservableScrollView.swift +++ /dev/null @@ -1,38 +0,0 @@ -import SwiftUI - -struct ScrollViewOffsetPreferenceKey: PreferenceKey { - typealias Value = CGFloat - static var defaultValue = CGFloat.zero - static func reduce(value: inout Value, nextValue: () -> Value) { - value += nextValue() - } -} - -struct ObservableScrollView: View where Content : View { - @Namespace var scrollSpace - @Binding var scrollOffset: CGFloat - let content: () -> Content - - init(scrollOffset: Binding, - @ViewBuilder content: @escaping () -> Content) { - _scrollOffset = scrollOffset - self.content = content - } - - var body: some View { - ScrollView { - content() - .background(GeometryReader { geo in - let offset = -geo.frame(in: .named(scrollSpace)).minY - Color.clear - .preference(key: ScrollViewOffsetPreferenceKey.self, - value: offset) - }) - - } - .coordinateSpace(name: scrollSpace) - .onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { value in - scrollOffset = value - } - } -} diff --git a/Hotline/Utility/RegularExpressions.swift b/Hotline/Utility/RegularExpressions.swift deleted file mode 100644 index c1f2a18..0000000 --- a/Hotline/Utility/RegularExpressions.swift +++ /dev/null @@ -1,235 +0,0 @@ -import RegexBuilder - -struct RegularExpressions { - static let messageBoardDivider = Regex { - Capture { - OneOrMore { - CharacterClass(.newlineSequence) - } - ZeroOrMore { - CharacterClass(.whitespace, .newlineSequence) - } - Repeat(2...) { - CharacterClass(.anyOf("_-")) - } - ZeroOrMore { - CharacterClass(.whitespace) - } - OneOrMore { - CharacterClass(.newlineSequence) - } - } - } - - static let supportedLinkScheme = Regex { - Anchor.startOfLine - ChoiceOf { - "hotline" - "http" - "https" - } - "://" - }.ignoresCase().anchorsMatchLineEndings() - - static let relaxedLink = Regex { - ChoiceOf { - Anchor.startOfLine - Anchor.wordBoundary - } - Capture { - // scheme (optional) - Optionally { - ChoiceOf { - "hotline://" - "http://" - "https://" - } - } - // domain name - OneOrMore { - CharacterClass( - .anyOf(".-@"), - ("a"..."z"), - ("0"..."9") - ) - } - // top-level domain name - "." - ChoiceOf { - "com" - "net" - "org" - "edu" - "gov" - "mil" - "aero" - "asia" - "biz" - "cat" - "coop" - "info" - "int" - "jobs" - "mobi" - "museum" - "name" - "pizza" - "post" - "pro" - "red" - "tel" - "today" - "travel" - "garden" - "online" - "ai" - "be" - "by" - "ca" - "co" - "de" - "er" - "es" - "fr" - "gs" - "ie" - "im" - "in" - "io" - "is" - "it" - "jp" - "la" - "ly" - "ma" - "md" - "me" - "my" - "nl" - "ps" - "pt" - "ja" - "st" - "to" - "tv" - "uk" - "ws" - } - // Port - Optionally { - ":" - OneOrMore { - CharacterClass(.digit) - } - } - // path - ZeroOrMore { - CharacterClass( - .anyOf("#_-/.?=&%\\()[]"), - ("a"..."z"), - ("0"..."9") - ) - } - } - ChoiceOf { - Anchor.endOfLine - Anchor.wordBoundary - } - } - .anchorsMatchLineEndings() - .ignoresCase() - - static let emailAddress = Regex { - ChoiceOf { - Anchor.startOfLine - Anchor.wordBoundary - } - Capture { - // username - OneOrMore { - CharacterClass( - .anyOf(".-_"), - ("a"..."z"), - ("0"..."9") - ) - } - "@" - // domain name - OneOrMore { - CharacterClass( - .anyOf(".-"), - ("a"..."z"), - ("0"..."9") - ) - } - // top-level domain name - "." - ChoiceOf { - "com" - "net" - "org" - "edu" - "gov" - "mil" - "aero" - "asia" - "biz" - "cat" - "coop" - "info" - "int" - "jobs" - "mobi" - "museum" - "name" - "pizza" - "post" - "pro" - "red" - "tel" - "today" - "travel" - "garden" - "online" - "ai" - "be" - "by" - "ca" - "co" - "de" - "er" - "es" - "fr" - "gs" - "ie" - "im" - "in" - "io" - "is" - "it" - "jp" - "la" - "ly" - "ma" - "md" - "me" - "my" - "nl" - "ps" - "pt" - "ja" - "st" - "to" - "tv" - "uk" - "ws" - } - } - ChoiceOf { - Anchor.endOfLine - Anchor.wordBoundary - } - } - .anchorsMatchLineEndings() - .ignoresCase() -} diff --git a/Hotline/Utility/SoundEffects.swift b/Hotline/Utility/SoundEffects.swift deleted file mode 100644 index 93f8b9a..0000000 --- a/Hotline/Utility/SoundEffects.swift +++ /dev/null @@ -1,50 +0,0 @@ -import Foundation -import AVFAudio - -enum SoundEffects: String { - case loggedIn = "logged-in" - case chatMessage = "chat-message" - case transferComplete = "transfer-complete" - case userLogin = "user-login" - case userLogout = "user-logout" - case newNews = "new-news" - case serverMessage = "server-message" - case error = "error" -} - -@Observable -class SoundEffectPlayer: NSObject, AVAudioPlayerDelegate { - static let shared = SoundEffectPlayer() - - private var activeSounds: [AVAudioPlayer] = [] - - func playSoundEffect(_ name: SoundEffects) { - // Load a local sound file - guard let soundFileURL = Bundle.main.url( - forResource: name.rawValue, - withExtension: "aiff" - ) else { - 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) - } - } - } - - func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { - if let i = self.activeSounds.firstIndex(of: player) { - self.activeSounds.remove(at: i) - } - } -} diff --git a/Hotline/Utility/SwiftUIExtensions.swift b/Hotline/Utility/SwiftUIExtensions.swift deleted file mode 100644 index d0dfff4..0000000 --- a/Hotline/Utility/SwiftUIExtensions.swift +++ /dev/null @@ -1,8 +0,0 @@ -import SwiftUI -import Foundation - -extension Color { - init(hex: Int, opacity: Double = 1.0) { - self.init(red: Double((hex >> 16) & 0xFF) / 255.0, green: Double((hex >> 8) & 0xFF) / 255.0, blue: Double(hex & 0xFF) / 255.0, opacity: opacity) - } -} diff --git a/Hotline/Utility/TextDocument.swift b/Hotline/Utility/TextDocument.swift deleted file mode 100644 index 82727de..0000000 --- a/Hotline/Utility/TextDocument.swift +++ /dev/null @@ -1,31 +0,0 @@ -import Foundation -import UniformTypeIdentifiers -import SwiftUI - -struct TextFile: FileDocument { - // tell the system we support only plain text - static var readableContentTypes = [UTType.plainText, UTType.utf8PlainText] - - // by default our document is empty - var text = "" - - // a simple initializer that creates new, empty documents - init(initialText: String = "") { - text = initialText - } - - // this initializer loads data that has been saved previously - init(configuration: ReadConfiguration) throws { - - if let data = configuration.file.regularFileContents { - if let str = String(data: data, encoding: .utf8) { - self.text = str - } - } - } - - // this will be called when the system wants to write our data to disk - func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { - return FileWrapper(regularFileWithContents: self.text.data(using: .utf8)!) - } -} diff --git a/Hotline/macOS/TrackerView.swift b/Hotline/macOS/TrackerView.swift index 34f7a57..6fab091 100644 --- a/Hotline/macOS/TrackerView.swift +++ b/Hotline/macOS/TrackerView.swift @@ -485,38 +485,61 @@ struct TrackerBookmarkSheet: View { var body: some View { VStack(alignment: .leading) { - if self.bookmark == nil { - Text("Type the address and name of a Hotline Tracker:") - .foregroundStyle(.secondary) - .padding(.bottom, 8) - } - Form { + if self.bookmark == nil { + HStack(alignment: .top, spacing: 10) { + GroupedIconView(color: .blue, systemName: "point.3.filled.connected.trianglepath.dotted", padding: 5.0) + .frame(width: 28, height: 28) + + VStack(alignment: .leading) { + Text("Add a Hotline Tracker") + + Text("Enter the address and name of a Hotline Tracker you want to add.") + .foregroundStyle(.secondary) + .font(.subheadline) + } + } + } + else { + HStack(alignment: .top, spacing: 10) { + GroupedIconView(color: .blue, systemName: "point.3.filled.connected.trianglepath.dotted", padding: 5.0) + .frame(width: 28, height: 28) + + VStack(alignment: .leading) { + Text("Edit Hotline Tracker") + + Text("Change the address and name of your Hotline Tracker.") + .foregroundStyle(.secondary) + .font(.subheadline) + } + } + } + Group { TextField(text: $trackerAddress) { - Text("Address:") + Text("Address") } TextField(text: $trackerName, prompt: Text("Optional")) { - Text("Name:") + Text("Name") } } - .textFieldStyle(.roundedBorder) +// .textFieldStyle(.roundedBorder) .controlSize(.large) } + .formStyle(.grouped) } - .frame(width: 300) + .frame(width: 350) .fixedSize(horizontal: true, vertical: true) - .padding() .toolbar { ToolbarItem(placement: .confirmationAction) { Button { self.saveTracker() } label: { if self.bookmark != nil { - Text("Save Tracker") + Text("Save") } else { - Text("Add Tracker") + Text("Add") } } } -- cgit From e1566598c96601ebcf3229aab22fc7a593ee1904 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 7 Nov 2025 16:16:00 -0800 Subject: Further UI tweraks. Transfers button in toolbar. Better empty states in places. Fix race condition with transactions ids (yikes)! --- .../Section Transfers.imageset/Contents.json | 23 + .../Section Transfers.png | Bin 0 -> 883 bytes .../Section Transfers@2x.png | Bin 0 -> 2039 bytes .../Section Transfers@3x.png | Bin 0 -> 3584 bytes Hotline/macOS/TrackerView.swift | 785 --------------------- .../macOS/Trackers/TrackerBookmarkServerView.swift | 38 + Hotline/macOS/Trackers/TrackerBookmarkSheet.swift | 119 ++++ Hotline/macOS/Trackers/TrackerItemView.swift | 73 ++ 8 files changed, 253 insertions(+), 785 deletions(-) create mode 100644 Hotline/Assets.xcassets/Section Transfers.imageset/Contents.json create mode 100644 Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers.png create mode 100644 Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers@2x.png create mode 100644 Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers@3x.png delete mode 100644 Hotline/macOS/TrackerView.swift create mode 100644 Hotline/macOS/Trackers/TrackerBookmarkServerView.swift create mode 100644 Hotline/macOS/Trackers/TrackerBookmarkSheet.swift create mode 100644 Hotline/macOS/Trackers/TrackerItemView.swift (limited to 'Hotline/macOS') diff --git a/Hotline/Assets.xcassets/Section Transfers.imageset/Contents.json b/Hotline/Assets.xcassets/Section Transfers.imageset/Contents.json new file mode 100644 index 0000000..490868e --- /dev/null +++ b/Hotline/Assets.xcassets/Section Transfers.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Section Transfers.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Section Transfers@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Section Transfers@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers.png b/Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers.png new file mode 100644 index 0000000..cbbd304 Binary files /dev/null and b/Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers.png differ diff --git a/Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers@2x.png b/Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers@2x.png new file mode 100644 index 0000000..2e8836b Binary files /dev/null and b/Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers@2x.png differ diff --git a/Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers@3x.png b/Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers@3x.png new file mode 100644 index 0000000..a8086b7 Binary files /dev/null and b/Hotline/Assets.xcassets/Section Transfers.imageset/Section Transfers@3x.png differ diff --git a/Hotline/macOS/TrackerView.swift b/Hotline/macOS/TrackerView.swift deleted file mode 100644 index 6fab091..0000000 --- a/Hotline/macOS/TrackerView.swift +++ /dev/null @@ -1,785 +0,0 @@ -import SwiftUI -import SwiftData -import Foundation -import UniformTypeIdentifiers - -enum TrackerSelection: Hashable { - case bookmark(Bookmark) - case bookmarkServer(BookmarkServer) - - var server: Server? { - switch self { - case .bookmark(let b): return b.server - case .bookmarkServer(let t): return t.server - } - } -} - -struct TrackerView: View { - @Environment(\.colorScheme) private var colorScheme - @Environment(\.openWindow) private var openWindow - @Environment(\.controlActiveState) private var controlActiveState - @Environment(\.modelContext) private var modelContext - - @State private var refreshing = false - @State private var trackerSheetPresented: Bool = false - @State private var trackerSheetBookmark: Bookmark? = nil - @State private var serverSheetBookmark: Bookmark? = nil - @State private var attemptedPrepopulate: Bool = false - @State private var fileDropActive = false - @State private var bookmarkExportActive = false - @State private var bookmarkExport: BookmarkDocument? = nil - @State private var expandedTrackers: Set = [] - @State private var trackerServers: [Bookmark: [BookmarkServer]] = [:] - @State private var loadingTrackers: Set = [] - @State private var fetchTasks: [Bookmark: Task] = [:] - @State private var searchText: String = "" - @State private var isSearching = false - - @Query(sort: \Bookmark.order) private var bookmarks: [Bookmark] - @Binding var selection: TrackerSelection? - - private var filteredBookmarks: [Bookmark] { - guard !self.searchText.isEmpty else { - return self.bookmarks - } - - let searchWords = self.searchText.lowercased().split(separator: " ").map(String.init) - - return self.bookmarks.filter { bookmark in - // Always show tracker bookmarks (filter only their servers) - if bookmark.type == .tracker { - return true - } - - // Filter server bookmarks by search text - return self.bookmarkMatchesSearch(bookmark, searchWords: searchWords) - } - } - - private func bookmarkMatchesSearch(_ bookmark: Bookmark, searchWords: [String]) -> Bool { - let searchableText = "\(bookmark.name) \(bookmark.address)".lowercased() - - // All search words must match - return searchWords.allSatisfy { word in - searchableText.contains(word) - } - } - - private func filteredServers(for bookmark: Bookmark) -> [BookmarkServer] { - let servers = self.trackerServers[bookmark] ?? [] - print("TrackerView.filteredServers: Looking up servers for \(bookmark.name), found \(servers.count) servers") - - guard !self.searchText.isEmpty else { - return servers - } - - let searchWords = self.searchText.lowercased().split(separator: " ").map(String.init) - - return servers.filter { server in - let searchableText = "\(server.name ?? "") \(server.address) \(server.description ?? "")".lowercased() - - // All search words must match - return searchWords.allSatisfy { word in - searchableText.contains(word) - } - } - } - - var body: some View { - List(selection: $selection) { - ForEach(filteredBookmarks, id: \.self) { bookmark in - TrackerItemView( - bookmark: bookmark, - isExpanded: self.expandedTrackers.contains(bookmark), - isLoading: self.loadingTrackers.contains(bookmark), - count: self.trackerServers[bookmark]?.count ?? 0 - ) { - self.toggleExpanded(for: bookmark) - } - .tag(TrackerSelection.bookmark(bookmark)) - - if bookmark.type == .tracker && self.expandedTrackers.contains(bookmark) { - ForEach(self.filteredServers(for: bookmark), id: \.self) { trackedServer in - TrackerBookmarkServerView(server: trackedServer) - .moveDisabled(true) - .deleteDisabled(true) - .tag(TrackerSelection.bookmarkServer(trackedServer)) - .padding(.leading, 16 + 8 + 10) - } - } - } - .onMove { movedIndexes, destinationIndex in - Bookmark.move(movedIndexes, to: destinationIndex, context: modelContext) - } - .onDelete { deletedIndexes in - Bookmark.delete(at: deletedIndexes, context: modelContext) - } - } - .onDeleteCommand { - switch self.selection { - case .bookmark(let bookmark): - Bookmark.delete(bookmark, context: modelContext) - default: - break - } - -// if let bookmark = selection, -// bookmark.type != .temporary { -// Bookmark.delete(bookmark, context: modelContext) -// } - } - .environment(\.defaultMinListRowHeight, 34) - .listStyle(.inset) - .alternatingRowBackgrounds(.enabled) - .onChange(of: AppState.shared.cloudKitReady) { - if attemptedPrepopulate { - print("Tracker: Already attempted to prepopulate bookmarks") - return - } - - print("Tracker: Prepopulating bookmarks") - - attemptedPrepopulate = true - - // Make sure default bookmarks are there when empty. - Bookmark.populateDefaults(context: modelContext) - } - .onAppear { -// Bookmark.deleteAll(context: modelContext) - } - .contextMenu(forSelectionType: TrackerSelection.self) { items in - if let item = items.first { - switch item { - case .bookmark(let bookmark): - self.bookmarkContextMenu(bookmark) - case .bookmarkServer(let server): - self.bookmarkServerContextMenu(server) - } - } - } primaryAction: { items in - guard let clickedItem = items.first else { - return - } - - switch clickedItem { - case .bookmark(let bookmark): - if bookmark.type == .server { - if let s = bookmark.server { - openWindow(id: "server", value: s) - } - } - else if bookmark.type == .tracker { - if NSEvent.modifierFlags.contains(.option) { - trackerSheetBookmark = bookmark - } - else { - self.toggleExpanded(for: bookmark) - } - } - - case .bookmarkServer(let bookmarkServer): - openWindow(id: "server", value: bookmarkServer.server) - } - } - .fileExporter(isPresented: $bookmarkExportActive, document: bookmarkExport, contentTypes: [.data], defaultFilename: "\(bookmarkExport?.bookmark.name ?? "Hotline Bookmark").hlbm", onCompletion: { result in - switch result { - case .success(let fileURL): - print("Hotline Bookmark: Successfully exported:", fileURL) - case .failure(let err): - print("Hotline Bookmark: Failed to export:", err) - } - - bookmarkExport = nil - bookmarkExportActive = false - }, onCancellation: {}) - .onKeyPress(.rightArrow) { - switch self.selection { - case .bookmark(let bookmark): - if bookmark.type == .tracker { - self.setExpanded(true, for: bookmark) - return .handled - } - default: - break - } - - return .ignored - } - .onKeyPress(.leftArrow) { - switch self.selection { - case .bookmark(let bookmark): - if bookmark.type == .tracker { - self.setExpanded(false, for: bookmark) - return .handled - } - default: - break - } - - return .ignored - } - .onDrop(of: [UTType.fileURL], isTargeted: $fileDropActive) { providers, dropPoint in - for provider in providers { - let _ = provider.loadDataRepresentation(for: UTType.fileURL) { dataRepresentation, err in - // HOTLINE CREATOR CODE: 1213484099 - // HOTLINE BOOKMARK TYPE CODE: 1213489773 - - if let filePathData = dataRepresentation, - let filePath = String(data: filePathData, encoding: .utf8), - let fileURL = URL(string: filePath) { - - print("Hotline Bookmark: Dropped from ", fileURL.path(percentEncoded: false)) - - DispatchQueue.main.async { - if let newBookmark = Bookmark(fileURL: fileURL) { - print("Hotline Bookmark: Added bookmark.") - Bookmark.add(newBookmark, context: modelContext) - } - else { - print("Hotline Bookmark: Failed to parse.") - } - } - } - } - } - - return true - } - .sheet(item: $trackerSheetBookmark) { item in - TrackerBookmarkSheet(item) - } - .sheet(isPresented: $trackerSheetPresented) { - TrackerBookmarkSheet() - } - .sheet(item: $serverSheetBookmark) { item in - ServerBookmarkSheet(item) - } - .navigationTitle("Servers") - .toolbar { - if #available(macOS 26.0, *) { - ToolbarItem(placement: .navigation) { - self.hotlineLogoImage - } - .sharedBackgroundVisibility(.hidden) - } - else { - ToolbarItem(placement: .navigation) { - self.hotlineLogoImage - } - } - - ToolbarItem(placement: .primaryAction) { - Button { - self.refreshing = true - self.refresh() - self.refreshing = false - } label: { - Label("Refresh", systemImage: "arrow.clockwise") - } - .disabled(refreshing) - .help("Refresh Trackers") - } - - ToolbarItem(placement: .primaryAction) { - Button { - trackerSheetPresented = true - } label: { - Label("Add Tracker", systemImage: "point.3.filled.connected.trianglepath.dotted") - } - .help("Add Tracker") - } - - ToolbarItem(placement: .primaryAction) { - Button { - openWindow(id: "server") - } label: { - Label("Connect to Server", systemImage: "globe.americas.fill") - } - .help("Connect to Server") - } - } - .onOpenURL(perform: { url in - if let s = Server(url: url) { - openWindow(id: "server", value: s) - } - }) - .searchable(text: $searchText, isPresented: $isSearching, placement: .automatic, prompt: "Search") - .background(Button("", action: { isSearching = true }).keyboardShortcut("f").hidden()) - } - - private var hotlineLogoImage: some View { - Image("Hotline") - .resizable() - .renderingMode(.template) - .scaledToFit() - .foregroundColor(Color(hex: 0xE10000)) - .frame(width: 9) - .opacity(controlActiveState == .inactive ? 0.5 : 1.0) - } - - @ViewBuilder - func bookmarkServerContextMenu(_ server: BookmarkServer) -> some View { - Button { - let newBookmark = Bookmark(type: .server, name: server.name ?? server.address, address: server.address, port: server.port, login: nil, password: nil) - Bookmark.add(newBookmark, context: modelContext) - } label: { - Label("Bookmark", systemImage: "bookmark") - } - - Divider() - - Button { - NSPasteboard.general.clearContents() - let displayAddress = (server.port == HotlinePorts.DefaultServerPort) ? server.address : "\(server.address):\(server.port)" - NSPasteboard.general.setString(displayAddress, forType: .string) - } label: { - Label("Copy Address", systemImage: "doc.on.doc") - } - } - - @ViewBuilder - func bookmarkContextMenu(_ bookmark: Bookmark) -> some View { - Button { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(bookmark.displayAddress, forType: .string) - } label: { - Label("Copy Address", systemImage: "doc.on.doc") - } - - Divider() - - if bookmark.type == .tracker { - Button { - trackerSheetBookmark = bookmark - } label: { - Label("Edit Tracker...", systemImage: "pencil") - } - } - - if bookmark.type == .server { - Button { - serverSheetBookmark = bookmark - } label: { - Label("Edit Bookmark...", systemImage: "pencil") - } - - Button { - bookmarkExport = BookmarkDocument(bookmark: bookmark) - bookmarkExportActive = true - } label: { - Label("Export Bookmark...", systemImage: "square.and.arrow.down") - } - } - - Divider() - - Button { - Bookmark.delete(bookmark, context: modelContext) - } label: { - Label(bookmark.type == .tracker ? "Delete Tracker" : "Delete Bookmark", systemImage: "trash") - } - } - - - func refresh() { - // When a tracker is selected, refresh only that tracker. - if let trackerSelection = self.selection { - switch trackerSelection { - case .bookmark(let bookmark): - if bookmark.type == .tracker { - if self.expandedTrackers.contains(bookmark) { - // Already expanded, cancel old fetch and start new one - self.fetchTasks[bookmark]?.cancel() - let task = Task { - await self.fetchServers(for: bookmark) - } - self.fetchTasks[bookmark] = task - } else { - // Not expanded, expand it (which also fetches) - self.setExpanded(true, for: bookmark) - } - return - } - break - default: - break - } - } - - // Otherwise refresh/expand all trackers. - for bookmark in self.bookmarks { - if bookmark.type == .tracker { - if self.expandedTrackers.contains(bookmark) { - // Already expanded, cancel old fetch and start new one - self.fetchTasks[bookmark]?.cancel() - let task = Task { - await self.fetchServers(for: bookmark) - } - self.fetchTasks[bookmark] = task - } else { - // Not expanded, expand it (which also fetches) - self.setExpanded(true, for: bookmark) - } - } - } - } - - func toggleExpanded(for bookmark: Bookmark) { - guard bookmark.type == .tracker else { return } - self.setExpanded(!self.expandedTrackers.contains(bookmark), for: bookmark) - } - - func setExpanded(_ expanded: Bool, for bookmark: Bookmark) { - guard bookmark.type == .tracker else { return } - - if expanded && !self.expandedTrackers.contains(bookmark) { - self.expandedTrackers.insert(bookmark) - let task = Task { - await self.fetchServers(for: bookmark) - } - self.fetchTasks[bookmark] = task - } else if !expanded && self.expandedTrackers.contains(bookmark) { - // Cancel ongoing fetch and clear data - self.fetchTasks[bookmark]?.cancel() - self.fetchTasks[bookmark] = nil - self.expandedTrackers.remove(bookmark) - self.trackerServers[bookmark] = nil - self.loadingTrackers.remove(bookmark) - } - } - - private func fetchServers(for bookmark: Bookmark) async { - print("TrackerView.fetchServers: Starting fetch for bookmark: \(bookmark.name)") - self.loadingTrackers.insert(bookmark) - let servers = await bookmark.fetchServers() - print("TrackerView.fetchServers: Got \(servers.count) servers from bookmark.fetchServers()") - await MainActor.run { - print("TrackerView.fetchServers: Assigning \(servers.count) servers to trackerServers[\(bookmark.name)]") - self.trackerServers[bookmark] = servers - self.loadingTrackers.remove(bookmark) - self.fetchTasks[bookmark] = nil // Clean up completed task - print("TrackerView.fetchServers: trackerServers now has \(self.trackerServers.count) entries") - print("TrackerView.fetchServers: Verification - trackerServers[\(bookmark.name)] now has \(self.trackerServers[bookmark]?.count ?? -1) servers") - } - } -} - -struct TrackerBookmarkSheet: View { - @Environment(\.dismiss) private var dismiss - @Environment(\.modelContext) private var modelContext - - @State private var bookmark: Bookmark? = nil - @State private var trackerAddress: String = "" - @State private var trackerName: String = "" - - init() { - - } - - init(_ editingBookmark: Bookmark) { - _bookmark = .init(initialValue: editingBookmark) - _trackerAddress = .init(initialValue: editingBookmark.displayAddress) - _trackerName = .init(initialValue: editingBookmark.name) - } - - var body: some View { - VStack(alignment: .leading) { - Form { - if self.bookmark == nil { - HStack(alignment: .top, spacing: 10) { - GroupedIconView(color: .blue, systemName: "point.3.filled.connected.trianglepath.dotted", padding: 5.0) - .frame(width: 28, height: 28) - - VStack(alignment: .leading) { - Text("Add a Hotline Tracker") - - Text("Enter the address and name of a Hotline Tracker you want to add.") - .foregroundStyle(.secondary) - .font(.subheadline) - } - } - } - else { - HStack(alignment: .top, spacing: 10) { - GroupedIconView(color: .blue, systemName: "point.3.filled.connected.trianglepath.dotted", padding: 5.0) - .frame(width: 28, height: 28) - - VStack(alignment: .leading) { - Text("Edit Hotline Tracker") - - Text("Change the address and name of your Hotline Tracker.") - .foregroundStyle(.secondary) - .font(.subheadline) - } - } - } - - Group { - TextField(text: $trackerAddress) { - Text("Address") - } - TextField(text: $trackerName, prompt: Text("Optional")) { - Text("Name") - } - } -// .textFieldStyle(.roundedBorder) - .controlSize(.large) - } - .formStyle(.grouped) - } - .frame(width: 350) - .fixedSize(horizontal: true, vertical: true) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button { - self.saveTracker() - } label: { - if self.bookmark != nil { - Text("Save") - } - else { - Text("Add") - } - } - } - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - self.trackerName = "" - self.trackerAddress = "" - - self.dismiss() - } - } - } - } - - private func saveTracker() { - var displayName = trackerName.trimmingCharacters(in: .whitespacesAndNewlines) - let (host, port) = Tracker.parseTrackerAddressAndPort(trackerAddress) - - if displayName.isEmpty { - displayName = host - } - - if !displayName.isEmpty && !host.isEmpty { - if !host.isEmpty { - if self.bookmark != nil { - // We're editing an existing bookmark. - self.bookmark?.name = displayName - self.bookmark?.address = host - self.bookmark?.port = port - } - else { - // We're creating a new bookmark. - let newBookmark = Bookmark(type: .tracker, name: displayName, address: host, port: port) - Bookmark.add(newBookmark, context: modelContext) - } - - self.trackerName = "" - self.trackerAddress = "" - - self.dismiss() - } - } - } -} - -struct ServerBookmarkSheet: View { - @Environment(\.dismiss) private var dismiss - @Environment(\.modelContext) private var modelContext - - @State private var bookmark: Bookmark - @State private var serverName: String = "" - @State private var serverAddress: String = "" - @State private var serverLogin: String = "" - @State private var serverPassword: String = "" - - init(_ editingBookmark: Bookmark) { - _bookmark = .init(initialValue: editingBookmark) - _serverName = .init(initialValue: editingBookmark.name) - _serverAddress = .init(initialValue: editingBookmark.displayAddress) - _serverLogin = .init(initialValue: editingBookmark.login ?? "") - _serverPassword = .init(initialValue: editingBookmark.password ?? "") - } - - var body: some View { - VStack(alignment: .leading) { - Form { - Group { - TextField(text: $serverName) { - Text("Name:") - } - .padding(.bottom) - - TextField(text: $serverAddress) { - Text("Address:") - } - TextField(text: $serverLogin, prompt: Text("Optional")) { - Text("Login:") - } - SecureField(text: $serverPassword, prompt: Text("Optional")) { - Text("Password:") - } - } - .textFieldStyle(.roundedBorder) - .controlSize(.large) - } - } - .frame(width: 300) - .fixedSize(horizontal: true, vertical: true) - .padding() - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("Save Bookmark") { - let displayName = self.serverName.trimmingCharacters(in: .whitespacesAndNewlines) - let (host, port) = Server.parseServerAddressAndPort(self.serverAddress) - let login = self.serverLogin.trimmingCharacters(in: .whitespacesAndNewlines) - let password = self.serverPassword - - if !displayName.isEmpty && !host.isEmpty { - self.bookmark.name = displayName - self.bookmark.address = host - self.bookmark.port = port - self.bookmark.login = login.isEmpty ? nil : login - self.bookmark.password = password.isEmpty ? nil : password - - self.dismiss() - } - } - } - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - self.dismiss() - } - } - } - } -} - -struct TrackerBookmarkServerView: View { - let server: BookmarkServer - - var body: some View { - HStack(alignment: .center, spacing: 6) { - Image("Server") - .resizable() - .scaledToFit() - .frame(width: 16, height: 16, alignment: .center) - Text(self.server.name ?? "Server").lineLimit(1).truncationMode(.tail) - if let serverDescription = self.server.description { - Text(serverDescription) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) - } - Spacer(minLength: 0) - if self.server.users > 0 { - Text(String(self.server.users)) - .foregroundStyle(.secondary) - .lineLimit(1) - - Circle() - .fill(.fileComplete) - .frame(width: 7, height: 7) - .keyframeAnimator(initialValue: 1.0, repeating: true) { content, opacity in - content.opacity(opacity) - } keyframes: { _ in - CubicKeyframe(1.0, duration: 2.0) // Stay visible for 1 second - CubicKeyframe(0.6, duration: 0.5) // Fade out quickly - CubicKeyframe(1.0, duration: 0.5) // Fade in quickly - } - .padding(.trailing, 6) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } -} - -struct TrackerItemView: View { - let bookmark: Bookmark - let isExpanded: Bool - let isLoading: Bool - let count: Int - let onToggleExpanded: () -> Void - @Environment(\.appearsActive) private var appearsActive - - var body: some View { - HStack(alignment: .center, spacing: 6) { - if bookmark.type == .tracker { - Button { - self.onToggleExpanded() - } label: { - Text(Image(systemName: self.isExpanded ? "chevron.down" : "chevron.right")) - .bold() - .font(.system(size: 10)) - .opacity(0.5) - .frame(alignment: .center) - } - .buttonStyle(.plain) - .frame(width: 10) - .padding(.leading, 4) - .padding(.trailing, 2) - } - - switch bookmark.type { - case .tracker: - Image("Tracker") - .resizable() - .scaledToFit() - .frame(width: 16, height: 16, alignment: .center) - Text(bookmark.name).bold().lineLimit(1).truncationMode(.tail) - if isLoading { - ProgressView() - .padding([.leading, .trailing], 2) - .controlSize(.small) - } - Spacer(minLength: 0) - if isExpanded && count > 0 { - HStack(spacing: 4) { - Text(String(count)) - - SpinningGlobeView() - .fontWeight(.semibold) - .frame(width: 12, height: 12) - } - .padding(.horizontal, 6) - .padding(.vertical, 2) - .foregroundStyle(.secondary) -// .background(.quinary) - .clipShape(.capsule) - } - case .server: - Image(systemName: "bookmark.fill") - .resizable() - .scaledToFit() - .foregroundStyle(Color.secondary) - .frame(width: 11, height: 11, alignment: .center) - .opacity(0.75) - .padding(.leading, 3) - .padding(.trailing, 2) - Image("Server") - .resizable() - .scaledToFit() - .frame(width: 16, height: 16, alignment: .center) - Text(bookmark.name).lineLimit(1).truncationMode(.tail) - Spacer(minLength: 0) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } -} - -#if DEBUG -private struct TrackerViewPreview: View { - @State var selection: TrackerSelection? = nil - - var body: some View { - TrackerView(selection: $selection) - } -} - -#Preview { - TrackerViewPreview() -} -#endif diff --git a/Hotline/macOS/Trackers/TrackerBookmarkServerView.swift b/Hotline/macOS/Trackers/TrackerBookmarkServerView.swift new file mode 100644 index 0000000..dc42ea9 --- /dev/null +++ b/Hotline/macOS/Trackers/TrackerBookmarkServerView.swift @@ -0,0 +1,38 @@ +struct TrackerBookmarkServerView: View { + let server: BookmarkServer + + var body: some View { + HStack(alignment: .center, spacing: 6) { + Image("Server") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16, alignment: .center) + Text(self.server.name ?? "Server").lineLimit(1).truncationMode(.tail) + if let serverDescription = self.server.description { + Text(serverDescription) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + Spacer(minLength: 0) + if self.server.users > 0 { + Text(String(self.server.users)) + .foregroundStyle(.secondary) + .lineLimit(1) + + Circle() + .fill(.fileComplete) + .frame(width: 7, height: 7) + .keyframeAnimator(initialValue: 1.0, repeating: true) { content, opacity in + content.opacity(opacity) + } keyframes: { _ in + CubicKeyframe(1.0, duration: 2.0) // Stay visible for 1 second + CubicKeyframe(0.6, duration: 0.5) // Fade out quickly + CubicKeyframe(1.0, duration: 0.5) // Fade in quickly + } + .padding(.trailing, 6) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} \ No newline at end of file diff --git a/Hotline/macOS/Trackers/TrackerBookmarkSheet.swift b/Hotline/macOS/Trackers/TrackerBookmarkSheet.swift new file mode 100644 index 0000000..4943016 --- /dev/null +++ b/Hotline/macOS/Trackers/TrackerBookmarkSheet.swift @@ -0,0 +1,119 @@ +struct TrackerBookmarkSheet: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + + @State private var bookmark: Bookmark? = nil + @State private var trackerAddress: String = "" + @State private var trackerName: String = "" + + init() { + + } + + init(_ editingBookmark: Bookmark) { + _bookmark = .init(initialValue: editingBookmark) + _trackerAddress = .init(initialValue: editingBookmark.displayAddress) + _trackerName = .init(initialValue: editingBookmark.name) + } + + var body: some View { + VStack(alignment: .leading) { + Form { + if self.bookmark == nil { + HStack(alignment: .top, spacing: 10) { + GroupedIconView(color: .blue, systemName: "point.3.filled.connected.trianglepath.dotted", padding: 5.0) + .frame(width: 28, height: 28) + + VStack(alignment: .leading) { + Text("Add a Hotline Tracker") + + Text("Enter the address and name of a Hotline Tracker you want to add.") + .foregroundStyle(.secondary) + .font(.subheadline) + } + } + } + else { + HStack(alignment: .top, spacing: 10) { + GroupedIconView(color: .blue, systemName: "point.3.filled.connected.trianglepath.dotted", padding: 5.0) + .frame(width: 28, height: 28) + + VStack(alignment: .leading) { + Text("Edit Hotline Tracker") + + Text("Change the address and name of your Hotline Tracker.") + .foregroundStyle(.secondary) + .font(.subheadline) + } + } + } + + Group { + TextField(text: $trackerAddress) { + Text("Address") + } + TextField(text: $trackerName, prompt: Text("Optional")) { + Text("Name") + } + } +// .textFieldStyle(.roundedBorder) + .controlSize(.large) + } + .formStyle(.grouped) + } + .frame(width: 350) + .fixedSize(horizontal: true, vertical: true) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button { + self.saveTracker() + } label: { + if self.bookmark != nil { + Text("Save") + } + else { + Text("Add") + } + } + } + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + self.trackerName = "" + self.trackerAddress = "" + + self.dismiss() + } + } + } + } + + private func saveTracker() { + var displayName = trackerName.trimmingCharacters(in: .whitespacesAndNewlines) + let (host, port) = Tracker.parseTrackerAddressAndPort(trackerAddress) + + if displayName.isEmpty { + displayName = host + } + + if !displayName.isEmpty && !host.isEmpty { + if !host.isEmpty { + if self.bookmark != nil { + // We're editing an existing bookmark. + self.bookmark?.name = displayName + self.bookmark?.address = host + self.bookmark?.port = port + } + else { + // We're creating a new bookmark. + let newBookmark = Bookmark(type: .tracker, name: displayName, address: host, port: port) + Bookmark.add(newBookmark, context: modelContext) + } + + self.trackerName = "" + self.trackerAddress = "" + + self.dismiss() + } + } + } +} \ No newline at end of file diff --git a/Hotline/macOS/Trackers/TrackerItemView.swift b/Hotline/macOS/Trackers/TrackerItemView.swift new file mode 100644 index 0000000..59caad5 --- /dev/null +++ b/Hotline/macOS/Trackers/TrackerItemView.swift @@ -0,0 +1,73 @@ +struct TrackerItemView: View { + let bookmark: Bookmark + let isExpanded: Bool + let isLoading: Bool + let count: Int + let onToggleExpanded: () -> Void + @Environment(\.appearsActive) private var appearsActive + + var body: some View { + HStack(alignment: .center, spacing: 6) { + if bookmark.type == .tracker { + Button { + self.onToggleExpanded() + } label: { + Text(Image(systemName: self.isExpanded ? "chevron.down" : "chevron.right")) + .bold() + .font(.system(size: 10)) + .opacity(0.5) + .frame(alignment: .center) + } + .buttonStyle(.plain) + .frame(width: 10) + .padding(.leading, 4) + .padding(.trailing, 2) + } + + switch bookmark.type { + case .tracker: + Image("Tracker") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16, alignment: .center) + Text(bookmark.name).bold().lineLimit(1).truncationMode(.tail) + if isLoading { + ProgressView() + .padding([.leading, .trailing], 2) + .controlSize(.small) + } + Spacer(minLength: 0) + if isExpanded && count > 0 { + HStack(spacing: 4) { + Text(String(count)) + + SpinningGlobeView() + .fontWeight(.semibold) + .frame(width: 12, height: 12) + } + .padding(.horizontal, 6) + .padding(.vertical, 2) + .foregroundStyle(.secondary) +// .background(.quinary) + .clipShape(.capsule) + } + case .server: + Image(systemName: "bookmark.fill") + .resizable() + .scaledToFit() + .foregroundStyle(Color.secondary) + .frame(width: 11, height: 11, alignment: .center) + .opacity(0.75) + .padding(.leading, 3) + .padding(.trailing, 2) + Image("Server") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16, alignment: .center) + Text(bookmark.name).lineLimit(1).truncationMode(.tail) + Spacer(minLength: 0) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} \ No newline at end of file -- cgit From 6afa5551add4541f376867b3d6527df9a0f793f3 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 7 Nov 2025 16:16:31 -0800 Subject: Further UI tweraks. Transfers button in toolbar. Better empty states in places. Fix race condition with transactions ids (yikes)! --- Hotline.xcodeproj/project.pbxproj | 26 +- Hotline/Hotline/HotlineClientNew.swift | 100 +++-- Hotline/Hotline/HotlineProtocol.swift | 10 +- Hotline/macOS/HotlinePanelView.swift | 12 + Hotline/macOS/News/NewsView.swift | 2 +- Hotline/macOS/Trackers/ServerBookmarkSheet.swift | 70 +++ .../macOS/Trackers/TrackerBookmarkServerView.swift | 4 +- Hotline/macOS/Trackers/TrackerBookmarkSheet.swift | 4 +- Hotline/macOS/Trackers/TrackerItemView.swift | 4 +- Hotline/macOS/Trackers/TrackerView.swift | 480 +++++++++++++++++++++ 10 files changed, 653 insertions(+), 59 deletions(-) create mode 100644 Hotline/macOS/Trackers/ServerBookmarkSheet.swift create mode 100644 Hotline/macOS/Trackers/TrackerView.swift (limited to 'Hotline/macOS') diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index c63109a..2861aab 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -33,6 +33,10 @@ DA4F2BF82B16A17200D8ADDC /* HotlineProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4F2BF72B16A17200D8ADDC /* HotlineProtocol.swift */; }; DA4F2C012B1A558E00D8ADDC /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4F2C002B1A558E00D8ADDC /* ChatView.swift */; platformFilter = ios; }; DA501BE22EBE9018001714F8 /* GroupedIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA501BE12EBE9018001714F8 /* GroupedIconView.swift */; }; + DA501BE42EBE9517001714F8 /* ServerBookmarkSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA501BE32EBE9517001714F8 /* ServerBookmarkSheet.swift */; }; + 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 */; }; 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, ); }; @@ -139,6 +143,10 @@ DA4F2BF72B16A17200D8ADDC /* HotlineProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineProtocol.swift; sourceTree = ""; }; DA4F2C002B1A558E00D8ADDC /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = ""; }; DA501BE12EBE9018001714F8 /* GroupedIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupedIconView.swift; sourceTree = ""; }; + DA501BE32EBE9517001714F8 /* ServerBookmarkSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerBookmarkSheet.swift; sourceTree = ""; }; + DA501BE62EBE9542001714F8 /* TrackerBookmarkSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackerBookmarkSheet.swift; sourceTree = ""; }; + DA501BE82EBE9589001714F8 /* TrackerItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackerItemView.swift; sourceTree = ""; }; + DA501BEA2EBE95B4001714F8 /* TrackerBookmarkServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackerBookmarkServerView.swift; sourceTree = ""; }; DA52689B2EB0738B00DCB941 /* GeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsView.swift; sourceTree = ""; }; DA52689D2EB073A400DCB941 /* IconSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconSettingsView.swift; sourceTree = ""; }; DA52689F2EB073BC00DCB941 /* SoundSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundSettingsView.swift; sourceTree = ""; }; @@ -291,6 +299,18 @@ path = Views; sourceTree = ""; }; + DA501BE52EBE9520001714F8 /* Trackers */ = { + isa = PBXGroup; + children = ( + DAE734FA2B2E41F9000C56F6 /* TrackerView.swift */, + DA501BE82EBE9589001714F8 /* TrackerItemView.swift */, + DA501BEA2EBE95B4001714F8 /* TrackerBookmarkServerView.swift */, + DA501BE62EBE9542001714F8 /* TrackerBookmarkSheet.swift */, + DA501BE32EBE9517001714F8 /* ServerBookmarkSheet.swift */, + ); + path = Trackers; + sourceTree = ""; + }; DA52689A2EB0737400DCB941 /* Settings */ = { isa = PBXGroup; children = ( @@ -497,10 +517,10 @@ DA55AC762BE589F700034857 /* AboutView.swift */, DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */, DAE136B92B9D1147007D8307 /* HotlinePanelView.swift */, - DAE734FA2B2E41F9000C56F6 /* TrackerView.swift */, DAE734F82B2E4185000C56F6 /* ServerView.swift */, DAE735022B30C0BB000C56F6 /* MessageView.swift */, DA5268B42EB6840A00DCB941 /* TransfersView.swift */, + DA501BE52EBE9520001714F8 /* Trackers */, DA5268A82EB081AF00DCB941 /* Chat */, DA5268A62EB0762300DCB941 /* Board */, DA5268A92EB081DE00DCB941 /* News */, @@ -615,6 +635,7 @@ DA7725412B21435B006C5ABB /* ObservableScrollView.swift in Sources */, DAE734FF2B2E6750000C56F6 /* ChatView.swift in Sources */, DA5268B52EB6840A00DCB941 /* TransfersView.swift in Sources */, + DA501BE42EBE9517001714F8 /* ServerBookmarkSheet.swift in Sources */, 11F8288B2BF9428100216BA0 /* AccountManagerView.swift in Sources */, DAC6B2E42EAFE92F004E2CBA /* SpinningGlobeView.swift in Sources */, DADDB28D2B22B5920024040D /* Server.swift in Sources */, @@ -654,11 +675,13 @@ 11A7260A2BE0675A000C1DA7 /* FileDetailsView.swift in Sources */, DA72A0DD2B4CD0BF00A0F48A /* NewsEditorView.swift in Sources */, DA52689C2EB0738B00DCB941 /* GeneralSettingsView.swift in Sources */, + DA501BE92EBE9589001714F8 /* TrackerItemView.swift in Sources */, DA57536C2B36BA1D00FAC277 /* TextDocument.swift in Sources */, DACCE5E12EABE4B4008CDD92 /* AppUpdate.swift in Sources */, DA5268AD2EB12FE200DCB941 /* ServerState.swift in Sources */, DA77253F2B21176D006C5ABB /* NewsView.swift in Sources */, DAC6B2E22EAEE9FE004E2CBA /* NetSocket.swift in Sources */, + DA501BEB2EBE95B4001714F8 /* TrackerBookmarkServerView.swift in Sources */, DA99218E2C51AA5D0058FA6C /* HotlineDataBuilder.swift in Sources */, DA872B132BDDBF78008B1012 /* HotlinePanel.swift in Sources */, DA4B8F3A2EA6FB3C00CBFD53 /* iOSApp.swift in Sources */, @@ -680,6 +703,7 @@ DAC87F072C5010E80060FADF /* HotlineExtensions.swift in Sources */, DAE734FD2B2E65E9000C56F6 /* MessageBoardView.swift in Sources */, DA6980832BFFD06C003E434B /* BookmarkDocument.swift in Sources */, + DA501BE72EBE9542001714F8 /* TrackerBookmarkSheet.swift in Sources */, DAB4D87E2B4C8BCA0048A05C /* FilePreviewTextView.swift in Sources */, DA3429B72EBAB1750010784E /* QuickLookPreviewView.swift in Sources */, DA4F2C012B1A558E00D8ADDC /* ChatView.swift in Sources */, diff --git a/Hotline/Hotline/HotlineClientNew.swift b/Hotline/Hotline/HotlineClientNew.swift index 8642d42..303bd32 100644 --- a/Hotline/Hotline/HotlineClientNew.swift +++ b/Hotline/Hotline/HotlineClientNew.swift @@ -160,6 +160,13 @@ public actor HotlineClientNew { UInt16(0x0001) // Version UInt16(0x0002) // Sub-version }) + + // Transaction IDs + private var nextTransactionID: UInt32 = 1 + private func generateTransactionID() -> UInt32 { + defer { self.nextTransactionID += 1 } + return self.nextTransactionID + } // MARK: - Connection @@ -258,7 +265,7 @@ public actor HotlineClientNew { // MARK: - Login private func performLogin(_ login: HotlineLoginInfo) async throws -> HotlineServerInfo { - var transaction = HotlineTransaction(type: .login) + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .login) transaction.setFieldEncodedString(type: .userLogin, val: login.login) transaction.setFieldEncodedString(type: .userPassword, val: login.password) transaction.setFieldUInt16(type: .userIconID, val: login.iconID) @@ -494,28 +501,29 @@ public actor HotlineClientNew { // MARK: - Keep-Alive private func startKeepAlive() { - keepAliveTask = Task { [weak self] in + self.keepAliveTask = Task { [weak self] in while !Task.isCancelled { try? await Task.sleep(nanoseconds: 180_000_000_000) // 3 minutes - - guard let self else { return } - - do { - if let version = await self.serverInfo?.version, version >= 185 { - let transaction = HotlineTransaction(type: .connectionKeepAlive) - try await self.socket.send(transaction, endian: .big) - } else { - // Older servers: send getUserNameList as keep-alive - _ = try? await self.getUserList() - } - } catch { - print("HotlineClientNew: Keep-alive failed: \(error)") - } + await self?.sendKeepAlive() + } + } + } + + private func sendKeepAlive() async { + do { + if let version = self.serverInfo?.version, version >= 185 { + let transaction = HotlineTransaction(id: self.generateTransactionID(), type: .connectionKeepAlive) + try await self.socket.send(transaction, endian: .big) + } else { + // Older servers: send getUserNameList as keep-alive + let _ = try? await self.getUserList() } + } catch { + print("HotlineClientNew: Keep-alive failed: \(error)") } } - // MARK: - Public API - Chat + // MARK: - Chat /// Send a chat message to the server /// @@ -524,20 +532,20 @@ public actor HotlineClientNew { /// - encoding: Text encoding (default: UTF-8) /// - announce: Whether this is an announcement (admin only, default: false) public func sendChat(_ message: String, encoding: String.Encoding = .utf8, announce: Bool = false) async throws { - var transaction = HotlineTransaction(type: .sendChat) + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .sendChat) transaction.setFieldString(type: .data, val: message, encoding: encoding) transaction.setFieldUInt16(type: .chatOptions, val: announce ? 1 : 0) try await socket.send(transaction, endian: .big) } - // MARK: - Public API - Users + // MARK: - Users /// Get the list of users currently connected to the server /// /// - Returns: Array of connected users public func getUserList() async throws -> [HotlineUser] { - let transaction = HotlineTransaction(type: .getUserNameList) + let transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getUserNameList) let reply = try await sendTransaction(transaction) var users: [HotlineUser] = [] @@ -555,7 +563,7 @@ public actor HotlineClientNew { /// - userID: Target user ID /// - encoding: Text encoding (default: UTF-8) public func sendInstantMessage(_ message: String, to userID: UInt16, encoding: String.Encoding = .utf8) async throws { - var transaction = HotlineTransaction(type: .sendInstantMessage) + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .sendInstantMessage) transaction.setFieldUInt16(type: .userID, val: userID) transaction.setFieldUInt32(type: .options, val: 1) transaction.setFieldString(type: .data, val: message, encoding: encoding) @@ -576,7 +584,7 @@ public actor HotlineClientNew { options: HotlineUserOptions = [], autoresponse: String? = nil ) async throws { - var transaction = HotlineTransaction(type: .setClientUserInfo) + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .setClientUserInfo) transaction.setFieldString(type: .userName, val: username) transaction.setFieldUInt16(type: .userIconID, val: iconID) transaction.setFieldUInt16(type: .options, val: options.rawValue) @@ -588,13 +596,13 @@ public actor HotlineClientNew { try await socket.send(transaction, endian: .big) } - // MARK: - Public API - Agreement + // MARK: - Agreement /// Send agreement acceptance to the server /// /// Call this after receiving `.agreementRequired` event. public func sendAgree() async throws { - let transaction = HotlineTransaction(type: .agreed) + let transaction = HotlineTransaction(id: self.generateTransactionID(), type: .agreed) try await socket.send(transaction, endian: .big) } @@ -605,7 +613,7 @@ public actor HotlineClientNew { /// - Parameter path: Directory path (empty for root) /// - Returns: Array of files and folders public func getFileList(path: [String] = []) async throws -> [HotlineFile] { - var transaction = HotlineTransaction(type: .getFileNameList) + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getFileNameList) if !path.isEmpty { transaction.setFieldPath(type: .filePath, val: path) } @@ -634,7 +642,7 @@ public actor HotlineClientNew { path: [String], preview: Bool = false ) async throws -> (referenceNumber: UInt32, size: Int, fileSize: Int?, waitingCount: Int?) { - var transaction = HotlineTransaction(type: .downloadFile) + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .downloadFile) transaction.setFieldString(type: .fileName, val: name) transaction.setFieldPath(type: .filePath, val: path) @@ -664,7 +672,7 @@ public actor HotlineClientNew { /// - Parameter path: Category path (empty for root) /// - Returns: Array of news categories public func getNewsCategories(path: [String] = []) async throws -> [HotlineNewsCategory] { - var transaction = HotlineTransaction(type: .getNewsCategoryNameList) + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getNewsCategoryNameList) if !path.isEmpty { transaction.setFieldPath(type: .newsPath, val: path) } @@ -686,7 +694,7 @@ public actor HotlineClientNew { /// - Parameter path: Category path /// - Returns: Array of news articles public func getNewsArticles(path: [String] = []) async throws -> [HotlineNewsArticle] { - var transaction = HotlineTransaction(type: .getNewsArticleNameList) + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getNewsArticleNameList) if !path.isEmpty { transaction.setFieldPath(type: .newsPath, val: path) } @@ -713,7 +721,7 @@ public actor HotlineClientNew { /// - flavor: Content flavor (default: "text/plain") /// - Returns: Article content as string public func getNewsArticle(id: UInt32, path: [String], flavor: String = "text/plain") async throws -> String? { - var transaction = HotlineTransaction(type: .getNewsArticleData) + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getNewsArticleData) transaction.setFieldPath(type: .newsPath, val: path) transaction.setFieldUInt32(type: .newsArticleID, val: id) transaction.setFieldString(type: .newsArticleDataFlavor, val: flavor, encoding: .ascii) @@ -739,7 +747,7 @@ public actor HotlineClientNew { throw HotlineClientError.invalidResponse } - var transaction = HotlineTransaction(type: .postNewsArticle) + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .postNewsArticle) transaction.setFieldPath(type: .newsPath, val: path) transaction.setFieldUInt32(type: .newsArticleID, val: parentID) transaction.setFieldString(type: .newsArticleTitle, val: title) @@ -756,7 +764,7 @@ public actor HotlineClientNew { /// /// - Returns: Array of message strings public func getMessageBoard() async throws -> [String] { - let transaction = HotlineTransaction(type: .getMessageBoard) + let transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getMessageBoard) let reply = try await sendTransaction(transaction) guard let text = reply.getField(type: .data)?.getString() else { @@ -774,7 +782,7 @@ public actor HotlineClientNew { public func postMessageBoard(_ text: String) async throws { guard !text.isEmpty else { return } - var transaction = HotlineTransaction(type: .oldPostNews) + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .oldPostNews) transaction.setFieldString(type: .data, val: text, encoding: .macOSRoman) try await socket.send(transaction, endian: .big) @@ -789,7 +797,7 @@ public actor HotlineClientNew { /// - 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(type: .getFileInfo) + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getFileInfo) transaction.setFieldString(type: .fileName, val: name) transaction.setFieldPath(type: .filePath, val: path) @@ -828,7 +836,7 @@ public actor HotlineClientNew { /// - path: Directory path containing the item /// - Returns: True if deletion succeeded public func deleteFile(name: String, path: [String]) async throws -> Bool { - var transaction = HotlineTransaction(type: .deleteFile) + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .deleteFile) transaction.setFieldString(type: .fileName, val: name) transaction.setFieldPath(type: .filePath, val: path) @@ -840,13 +848,13 @@ public actor HotlineClientNew { } } - // MARK: - Public API - User Administration + // MARK: - Administration /// Get list of user accounts (requires admin access) /// /// - Returns: Array of user accounts sorted by login public func getAccounts() async throws -> [HotlineAccount] { - let transaction = HotlineTransaction(type: .getAccounts) + let transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getAccounts) let reply = try await sendTransaction(transaction) let accountFields = reply.getFieldList(type: .data) @@ -869,7 +877,7 @@ public actor HotlineClientNew { /// - password: Optional password (nil for no password) /// - access: Access permissions bitmask public func createUser(name: String, login: String, password: String?, access: UInt64) async throws { - var transaction = HotlineTransaction(type: .newUser) + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .newUser) transaction.setFieldString(type: .userName, val: name) transaction.setFieldEncodedString(type: .userLogin, val: login) @@ -891,7 +899,7 @@ public actor HotlineClientNew { /// - password: Password update - nil to keep current, "" to remove, or new password string /// - access: Access permissions bitmask public func setUser(name: String, login: String, newLogin: String?, password: String?, access: UInt64) async throws { - var transaction = HotlineTransaction(type: .setUser) + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .setUser) transaction.setFieldString(type: .userName, val: name) transaction.setFieldUInt64(type: .userAccess, val: access) @@ -919,20 +927,20 @@ public actor HotlineClientNew { /// /// - Parameter login: Login username to delete public func deleteUser(login: String) async throws { - var transaction = HotlineTransaction(type: .deleteUser) + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .deleteUser) transaction.setFieldEncodedString(type: .userLogin, val: login) _ = try await sendTransaction(transaction) } - // MARK: - Public API - Banner Download + // MARK: - Banners /// Request to download the server banner image /// /// - Returns: Tuple of (referenceNumber, transferSize) for the banner download /// - Throws: HotlineClientError if not connected or server doesn't support banners public func downloadBanner() async throws -> (referenceNumber: UInt32, transferSize: Int)? { - let transaction = HotlineTransaction(type: .downloadBanner) + let transaction = HotlineTransaction(id: self.generateTransactionID(), type: .downloadBanner) let reply = try await sendTransaction(transaction) guard @@ -947,7 +955,7 @@ public actor HotlineClientNew { return (referenceNumber, transferSize) } - // MARK: Files + // MARK: - Transfers /// Request to download a file /// @@ -957,7 +965,7 @@ public actor HotlineClientNew { /// - preview: If true, request preview mode (smaller transfer) /// - Returns: Tuple of (referenceNumber, transferSize, fileSize, waitingCount) for the download public func downloadFile(name: String, path: [String], preview: Bool = false) async throws -> (referenceNumber: UInt32, transferSize: Int, fileSize: Int, waitingCount: Int)? { - var transaction = HotlineTransaction(type: .downloadFile) + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .downloadFile) transaction.setFieldString(type: .fileName, val: name) transaction.setFieldPath(type: .filePath, val: path) @@ -989,7 +997,7 @@ public actor HotlineClientNew { /// - path: Directory path containing the folder /// - Returns: Tuple of (referenceNumber, transferSize, itemCount, waitingCount) for the download public func downloadFolder(name: String, path: [String]) async throws -> (referenceNumber: UInt32, transferSize: Int, itemCount: Int, waitingCount: Int)? { - var transaction = HotlineTransaction(type: .downloadFolder) + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .downloadFolder) transaction.setFieldString(type: .fileName, val: name) transaction.setFieldPath(type: .filePath, val: path) @@ -1016,7 +1024,7 @@ public actor HotlineClientNew { /// - path: Directory path where the file should be uploaded /// - Returns: Reference number for the upload transfer public func uploadFile(name: String, path: [String]) async throws -> UInt32? { - var transaction = HotlineTransaction(type: .uploadFile) + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .uploadFile) transaction.setFieldString(type: .fileName, val: name) transaction.setFieldPath(type: .filePath, val: path) @@ -1041,7 +1049,7 @@ public actor HotlineClientNew { 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)") - var transaction = HotlineTransaction(type: .uploadFolder) + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .uploadFolder) transaction.setFieldString(type: .fileName, val: name) transaction.setFieldPath(type: .filePath, val: path) transaction.setFieldUInt32(type: .transferSize, val: totalSize) diff --git a/Hotline/Hotline/HotlineProtocol.swift b/Hotline/Hotline/HotlineProtocol.swift index 3870261..5fd06dc 100644 --- a/Hotline/Hotline/HotlineProtocol.swift +++ b/Hotline/Hotline/HotlineProtocol.swift @@ -731,12 +731,6 @@ struct HotlineTransactionField { struct HotlineTransaction { static let headerSize = 20 - static var sequenceID: UInt32 = 1 - - static func nextID() -> UInt32 { - HotlineTransaction.sequenceID += 1 - return HotlineTransaction.sequenceID - } var flags: UInt8 = 0 var isReply: UInt8 = 0 @@ -748,9 +742,9 @@ struct HotlineTransaction { var fields: [HotlineTransactionField] = [] - init(type: HotlineTransactionType) { + init(id: UInt32, type: HotlineTransactionType) { self.type = type - self.id = HotlineTransaction.nextID() + self.id = id } init?(from data: [UInt8]) { diff --git a/Hotline/macOS/HotlinePanelView.swift b/Hotline/macOS/HotlinePanelView.swift index 7819c2f..81c24b7 100644 --- a/Hotline/macOS/HotlinePanelView.swift +++ b/Hotline/macOS/HotlinePanelView.swift @@ -119,6 +119,18 @@ struct HotlinePanelView: View { .disabled(self.activeServerState == nil) .help("Accounts") } + + Button { + self.openWindow(id: "transfers") + } + label: { + Image("Section Transfers") + .resizable() + .scaledToFit() + } + .buttonStyle(.plain) + .frame(width: 20, height: 20) + .help("File Transfers") SettingsLink(label: { Image("Section Settings") diff --git a/Hotline/macOS/News/NewsView.swift b/Hotline/macOS/News/NewsView.swift index a07a441..976a985 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 no newsgroups") + Text("This server has not created any newsgroups yet") } } diff --git a/Hotline/macOS/Trackers/ServerBookmarkSheet.swift b/Hotline/macOS/Trackers/ServerBookmarkSheet.swift new file mode 100644 index 0000000..6ad1657 --- /dev/null +++ b/Hotline/macOS/Trackers/ServerBookmarkSheet.swift @@ -0,0 +1,70 @@ +import SwiftUI + +struct ServerBookmarkSheet: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + + @State private var bookmark: Bookmark + @State private var serverName: String = "" + @State private var serverAddress: String = "" + @State private var serverLogin: String = "" + @State private var serverPassword: String = "" + + init(_ editingBookmark: Bookmark) { + _bookmark = .init(initialValue: editingBookmark) + _serverName = .init(initialValue: editingBookmark.name) + _serverAddress = .init(initialValue: editingBookmark.displayAddress) + _serverLogin = .init(initialValue: editingBookmark.login ?? "") + _serverPassword = .init(initialValue: editingBookmark.password ?? "") + } + + var body: some View { + Form { + Section { + TextField(text: $serverName) { + Text("Name") + } + } + + Section { + TextField(text: $serverAddress) { + Text("Address") + } + TextField(text: $serverLogin, prompt: Text("Optional")) { + Text("Login") + } + SecureField(text: $serverPassword, prompt: Text("Optional")) { + Text("Password") + } + } + } + .formStyle(.grouped) + .frame(width: 350) + .fixedSize(horizontal: true, vertical: true) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + let displayName = self.serverName.trimmingCharacters(in: .whitespacesAndNewlines) + let (host, port) = Server.parseServerAddressAndPort(self.serverAddress) + let login = self.serverLogin.trimmingCharacters(in: .whitespacesAndNewlines) + let password = self.serverPassword + + if !displayName.isEmpty && !host.isEmpty { + self.bookmark.name = displayName + self.bookmark.address = host + self.bookmark.port = port + self.bookmark.login = login.isEmpty ? nil : login + self.bookmark.password = password.isEmpty ? nil : password + + self.dismiss() + } + } + } + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + self.dismiss() + } + } + } + } +} diff --git a/Hotline/macOS/Trackers/TrackerBookmarkServerView.swift b/Hotline/macOS/Trackers/TrackerBookmarkServerView.swift index dc42ea9..93aea15 100644 --- a/Hotline/macOS/Trackers/TrackerBookmarkServerView.swift +++ b/Hotline/macOS/Trackers/TrackerBookmarkServerView.swift @@ -1,3 +1,5 @@ +import SwiftUI + struct TrackerBookmarkServerView: View { let server: BookmarkServer @@ -35,4 +37,4 @@ struct TrackerBookmarkServerView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) } -} \ No newline at end of file +} diff --git a/Hotline/macOS/Trackers/TrackerBookmarkSheet.swift b/Hotline/macOS/Trackers/TrackerBookmarkSheet.swift index 4943016..d66a466 100644 --- a/Hotline/macOS/Trackers/TrackerBookmarkSheet.swift +++ b/Hotline/macOS/Trackers/TrackerBookmarkSheet.swift @@ -1,3 +1,5 @@ +import SwiftUI + struct TrackerBookmarkSheet: View { @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var modelContext @@ -116,4 +118,4 @@ struct TrackerBookmarkSheet: View { } } } -} \ No newline at end of file +} diff --git a/Hotline/macOS/Trackers/TrackerItemView.swift b/Hotline/macOS/Trackers/TrackerItemView.swift index 59caad5..6e62518 100644 --- a/Hotline/macOS/Trackers/TrackerItemView.swift +++ b/Hotline/macOS/Trackers/TrackerItemView.swift @@ -1,3 +1,5 @@ +import SwiftUI + struct TrackerItemView: View { let bookmark: Bookmark let isExpanded: Bool @@ -70,4 +72,4 @@ struct TrackerItemView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) } -} \ No newline at end of file +} diff --git a/Hotline/macOS/Trackers/TrackerView.swift b/Hotline/macOS/Trackers/TrackerView.swift new file mode 100644 index 0000000..ddc0a67 --- /dev/null +++ b/Hotline/macOS/Trackers/TrackerView.swift @@ -0,0 +1,480 @@ +import SwiftUI +import SwiftData +import Foundation +import UniformTypeIdentifiers + +enum TrackerSelection: Hashable { + case bookmark(Bookmark) + case bookmarkServer(BookmarkServer) + + var server: Server? { + switch self { + case .bookmark(let b): return b.server + case .bookmarkServer(let t): return t.server + } + } +} + +struct TrackerView: View { + @Environment(\.colorScheme) private var colorScheme + @Environment(\.openWindow) private var openWindow + @Environment(\.controlActiveState) private var controlActiveState + @Environment(\.modelContext) private var modelContext + + @State private var refreshing = false + @State private var trackerSheetPresented: Bool = false + @State private var trackerSheetBookmark: Bookmark? = nil + @State private var serverSheetBookmark: Bookmark? = nil + @State private var attemptedPrepopulate: Bool = false + @State private var fileDropActive = false + @State private var bookmarkExportActive = false + @State private var bookmarkExport: BookmarkDocument? = nil + @State private var expandedTrackers: Set = [] + @State private var trackerServers: [Bookmark: [BookmarkServer]] = [:] + @State private var loadingTrackers: Set = [] + @State private var fetchTasks: [Bookmark: Task] = [:] + @State private var searchText: String = "" + @State private var isSearching = false + + @Query(sort: \Bookmark.order) private var bookmarks: [Bookmark] + @Binding var selection: TrackerSelection? + + private var filteredBookmarks: [Bookmark] { + guard !self.searchText.isEmpty else { + return self.bookmarks + } + + let searchWords = self.searchText.lowercased().split(separator: " ").map(String.init) + + return self.bookmarks.filter { bookmark in + // Always show tracker bookmarks (filter only their servers) + if bookmark.type == .tracker { + return true + } + + // Filter server bookmarks by search text + return self.bookmarkMatchesSearch(bookmark, searchWords: searchWords) + } + } + + private func bookmarkMatchesSearch(_ bookmark: Bookmark, searchWords: [String]) -> Bool { + let searchableText = "\(bookmark.name) \(bookmark.address)".lowercased() + + // All search words must match + return searchWords.allSatisfy { word in + searchableText.contains(word) + } + } + + private func filteredServers(for bookmark: Bookmark) -> [BookmarkServer] { + let servers = self.trackerServers[bookmark] ?? [] + print("TrackerView.filteredServers: Looking up servers for \(bookmark.name), found \(servers.count) servers") + + guard !self.searchText.isEmpty else { + return servers + } + + let searchWords = self.searchText.lowercased().split(separator: " ").map(String.init) + + return servers.filter { server in + let searchableText = "\(server.name ?? "") \(server.address) \(server.description ?? "")".lowercased() + + // All search words must match + return searchWords.allSatisfy { word in + searchableText.contains(word) + } + } + } + + var body: some View { + List(selection: $selection) { + ForEach(filteredBookmarks, id: \.self) { bookmark in + TrackerItemView( + bookmark: bookmark, + isExpanded: self.expandedTrackers.contains(bookmark), + isLoading: self.loadingTrackers.contains(bookmark), + count: self.trackerServers[bookmark]?.count ?? 0 + ) { + self.toggleExpanded(for: bookmark) + } + .tag(TrackerSelection.bookmark(bookmark)) + + if bookmark.type == .tracker && self.expandedTrackers.contains(bookmark) { + ForEach(self.filteredServers(for: bookmark), id: \.self) { trackedServer in + TrackerBookmarkServerView(server: trackedServer) + .moveDisabled(true) + .deleteDisabled(true) + .tag(TrackerSelection.bookmarkServer(trackedServer)) + .padding(.leading, 16 + 8 + 10) + } + } + } + .onMove { movedIndexes, destinationIndex in + Bookmark.move(movedIndexes, to: destinationIndex, context: modelContext) + } + .onDelete { deletedIndexes in + Bookmark.delete(at: deletedIndexes, context: modelContext) + } + } + .onDeleteCommand { + switch self.selection { + case .bookmark(let bookmark): + Bookmark.delete(bookmark, context: modelContext) + default: + break + } + +// if let bookmark = selection, +// bookmark.type != .temporary { +// Bookmark.delete(bookmark, context: modelContext) +// } + } + .environment(\.defaultMinListRowHeight, 34) + .listStyle(.inset) + .alternatingRowBackgrounds(.enabled) + .onChange(of: AppState.shared.cloudKitReady) { + if attemptedPrepopulate { + print("Tracker: Already attempted to prepopulate bookmarks") + return + } + + print("Tracker: Prepopulating bookmarks") + + attemptedPrepopulate = true + + // Make sure default bookmarks are there when empty. + Bookmark.populateDefaults(context: modelContext) + } + .onAppear { +// Bookmark.deleteAll(context: modelContext) + } + .contextMenu(forSelectionType: TrackerSelection.self) { items in + if let item = items.first { + switch item { + case .bookmark(let bookmark): + self.bookmarkContextMenu(bookmark) + case .bookmarkServer(let server): + self.bookmarkServerContextMenu(server) + } + } + } primaryAction: { items in + guard let clickedItem = items.first else { + return + } + + switch clickedItem { + case .bookmark(let bookmark): + if bookmark.type == .server { + if let s = bookmark.server { + openWindow(id: "server", value: s) + } + } + else if bookmark.type == .tracker { + if NSEvent.modifierFlags.contains(.option) { + trackerSheetBookmark = bookmark + } + else { + self.toggleExpanded(for: bookmark) + } + } + + case .bookmarkServer(let bookmarkServer): + openWindow(id: "server", value: bookmarkServer.server) + } + } + .fileExporter(isPresented: $bookmarkExportActive, document: bookmarkExport, contentTypes: [.data], defaultFilename: "\(bookmarkExport?.bookmark.name ?? "Hotline Bookmark").hlbm", onCompletion: { result in + switch result { + case .success(let fileURL): + print("Hotline Bookmark: Successfully exported:", fileURL) + case .failure(let err): + print("Hotline Bookmark: Failed to export:", err) + } + + bookmarkExport = nil + bookmarkExportActive = false + }, onCancellation: {}) + .onKeyPress(.rightArrow) { + switch self.selection { + case .bookmark(let bookmark): + if bookmark.type == .tracker { + self.setExpanded(true, for: bookmark) + return .handled + } + default: + break + } + + return .ignored + } + .onKeyPress(.leftArrow) { + switch self.selection { + case .bookmark(let bookmark): + if bookmark.type == .tracker { + self.setExpanded(false, for: bookmark) + return .handled + } + default: + break + } + + return .ignored + } + .onDrop(of: [UTType.fileURL], isTargeted: $fileDropActive) { providers, dropPoint in + for provider in providers { + let _ = provider.loadDataRepresentation(for: UTType.fileURL) { dataRepresentation, err in + // HOTLINE CREATOR CODE: 1213484099 + // HOTLINE BOOKMARK TYPE CODE: 1213489773 + + if let filePathData = dataRepresentation, + let filePath = String(data: filePathData, encoding: .utf8), + let fileURL = URL(string: filePath) { + + print("Hotline Bookmark: Dropped from ", fileURL.path(percentEncoded: false)) + + DispatchQueue.main.async { + if let newBookmark = Bookmark(fileURL: fileURL) { + print("Hotline Bookmark: Added bookmark.") + Bookmark.add(newBookmark, context: modelContext) + } + else { + print("Hotline Bookmark: Failed to parse.") + } + } + } + } + } + + return true + } + .sheet(item: $trackerSheetBookmark) { item in + TrackerBookmarkSheet(item) + } + .sheet(isPresented: $trackerSheetPresented) { + TrackerBookmarkSheet() + } + .sheet(item: $serverSheetBookmark) { item in + ServerBookmarkSheet(item) + } + .navigationTitle("Servers") + .toolbar { + if #available(macOS 26.0, *) { + ToolbarItem(placement: .navigation) { + self.hotlineLogoImage + } + .sharedBackgroundVisibility(.hidden) + } + else { + ToolbarItem(placement: .navigation) { + self.hotlineLogoImage + } + } + + ToolbarItem(placement: .primaryAction) { + Button { + self.refreshing = true + self.refresh() + self.refreshing = false + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .disabled(refreshing) + .help("Refresh Trackers") + } + + ToolbarItem(placement: .primaryAction) { + Button { + trackerSheetPresented = true + } label: { + Label("Add Tracker", systemImage: "point.3.filled.connected.trianglepath.dotted") + } + .help("Add Tracker") + } + + ToolbarItem(placement: .primaryAction) { + Button { + openWindow(id: "server") + } label: { + Label("Connect to Server", systemImage: "globe.americas.fill") + } + .help("Connect to Server") + } + } + .onOpenURL(perform: { url in + if let s = Server(url: url) { + openWindow(id: "server", value: s) + } + }) + .searchable(text: $searchText, isPresented: $isSearching, placement: .automatic, prompt: "Search") + .background(Button("", action: { isSearching = true }).keyboardShortcut("f").hidden()) + } + + private var hotlineLogoImage: some View { + Image("Hotline") + .resizable() + .renderingMode(.template) + .scaledToFit() + .foregroundColor(Color(hex: 0xE10000)) + .frame(width: 9) + .opacity(controlActiveState == .inactive ? 0.5 : 1.0) + } + + @ViewBuilder + func bookmarkServerContextMenu(_ server: BookmarkServer) -> some View { + Button { + let newBookmark = Bookmark(type: .server, name: server.name ?? server.address, address: server.address, port: server.port, login: nil, password: nil) + Bookmark.add(newBookmark, context: modelContext) + } label: { + Label("Bookmark", systemImage: "bookmark") + } + + Divider() + + Button { + NSPasteboard.general.clearContents() + let displayAddress = (server.port == HotlinePorts.DefaultServerPort) ? server.address : "\(server.address):\(server.port)" + NSPasteboard.general.setString(displayAddress, forType: .string) + } label: { + Label("Copy Address", systemImage: "doc.on.doc") + } + } + + @ViewBuilder + func bookmarkContextMenu(_ bookmark: Bookmark) -> some View { + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(bookmark.displayAddress, forType: .string) + } label: { + Label("Copy Address", systemImage: "doc.on.doc") + } + + Divider() + + if bookmark.type == .tracker { + Button { + trackerSheetBookmark = bookmark + } label: { + Label("Edit Tracker...", systemImage: "pencil") + } + } + + if bookmark.type == .server { + Button { + serverSheetBookmark = bookmark + } label: { + Label("Edit Bookmark...", systemImage: "pencil") + } + + Button { + bookmarkExport = BookmarkDocument(bookmark: bookmark) + bookmarkExportActive = true + } label: { + Label("Export Bookmark...", systemImage: "square.and.arrow.down") + } + } + + Divider() + + Button { + Bookmark.delete(bookmark, context: modelContext) + } label: { + Label(bookmark.type == .tracker ? "Delete Tracker" : "Delete Bookmark", systemImage: "trash") + } + } + + + func refresh() { + // When a tracker is selected, refresh only that tracker. + if let trackerSelection = self.selection { + switch trackerSelection { + case .bookmark(let bookmark): + if bookmark.type == .tracker { + if self.expandedTrackers.contains(bookmark) { + // Already expanded, cancel old fetch and start new one + self.fetchTasks[bookmark]?.cancel() + let task = Task { + await self.fetchServers(for: bookmark) + } + self.fetchTasks[bookmark] = task + } else { + // Not expanded, expand it (which also fetches) + self.setExpanded(true, for: bookmark) + } + return + } + break + default: + break + } + } + + // Otherwise refresh/expand all trackers. + for bookmark in self.bookmarks { + if bookmark.type == .tracker { + if self.expandedTrackers.contains(bookmark) { + // Already expanded, cancel old fetch and start new one + self.fetchTasks[bookmark]?.cancel() + let task = Task { + await self.fetchServers(for: bookmark) + } + self.fetchTasks[bookmark] = task + } else { + // Not expanded, expand it (which also fetches) + self.setExpanded(true, for: bookmark) + } + } + } + } + + func toggleExpanded(for bookmark: Bookmark) { + guard bookmark.type == .tracker else { return } + self.setExpanded(!self.expandedTrackers.contains(bookmark), for: bookmark) + } + + func setExpanded(_ expanded: Bool, for bookmark: Bookmark) { + guard bookmark.type == .tracker else { return } + + if expanded && !self.expandedTrackers.contains(bookmark) { + self.expandedTrackers.insert(bookmark) + let task = Task { + await self.fetchServers(for: bookmark) + } + self.fetchTasks[bookmark] = task + } else if !expanded && self.expandedTrackers.contains(bookmark) { + // Cancel ongoing fetch and clear data + self.fetchTasks[bookmark]?.cancel() + self.fetchTasks[bookmark] = nil + self.expandedTrackers.remove(bookmark) + self.trackerServers[bookmark] = nil + self.loadingTrackers.remove(bookmark) + } + } + + private func fetchServers(for bookmark: Bookmark) async { + print("TrackerView.fetchServers: Starting fetch for bookmark: \(bookmark.name)") + self.loadingTrackers.insert(bookmark) + let servers = await bookmark.fetchServers() + print("TrackerView.fetchServers: Got \(servers.count) servers from bookmark.fetchServers()") + await MainActor.run { + print("TrackerView.fetchServers: Assigning \(servers.count) servers to trackerServers[\(bookmark.name)]") + self.trackerServers[bookmark] = servers + self.loadingTrackers.remove(bookmark) + self.fetchTasks[bookmark] = nil // Clean up completed task + print("TrackerView.fetchServers: trackerServers now has \(self.trackerServers.count) entries") + print("TrackerView.fetchServers: Verification - trackerServers[\(bookmark.name)] now has \(self.trackerServers[bookmark]?.count ?? -1) servers") + } + } +} + +#if DEBUG +private struct TrackerViewPreview: View { + @State var selection: TrackerSelection? = nil + + var body: some View { + TrackerView(selection: $selection) + } +} + +#Preview { + TrackerViewPreview() +} +#endif -- 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/macOS') 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 2f332ee497af925db1a2583135e9dfcdaff84794 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 7 Nov 2025 21:03:53 -0800 Subject: Add Bonjour discovery for Hotline servers on the local network to the Servers window. --- Hotline.xcodeproj/project.pbxproj | 8 + Hotline/Models/Server.swift | 9 + Hotline/State/AppState.swift | 2 + Hotline/State/BonjourState.swift | 256 ++++++++++++++++++++++++++ Hotline/macOS/Trackers/BonjourServerRow.swift | 19 ++ Hotline/macOS/Trackers/TrackerItemView.swift | 2 +- Hotline/macOS/Trackers/TrackerView.swift | 114 +++++++++++- 7 files changed, 406 insertions(+), 4 deletions(-) create mode 100644 Hotline/State/BonjourState.swift create mode 100644 Hotline/macOS/Trackers/BonjourServerRow.swift (limited to 'Hotline/macOS') diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index cac321c..f94cba2 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -38,6 +38,8 @@ 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 */; }; + DA501BF02EBED848001714F8 /* BonjourState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA501BEF2EBED848001714F8 /* BonjourState.swift */; }; + DA501BF22EBEF415001714F8 /* BonjourServerRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA501BF12EBEF415001714F8 /* BonjourServerRow.swift */; }; 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, ); }; @@ -148,6 +150,8 @@ DA501BE62EBE9542001714F8 /* TrackerBookmarkSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackerBookmarkSheet.swift; sourceTree = ""; }; DA501BE82EBE9589001714F8 /* TrackerItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackerItemView.swift; sourceTree = ""; }; DA501BEA2EBE95B4001714F8 /* TrackerBookmarkServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackerBookmarkServerView.swift; sourceTree = ""; }; + DA501BEF2EBED848001714F8 /* BonjourState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BonjourState.swift; sourceTree = ""; }; + DA501BF12EBEF415001714F8 /* BonjourServerRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BonjourServerRow.swift; sourceTree = ""; }; DA52689B2EB0738B00DCB941 /* GeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsView.swift; sourceTree = ""; }; DA52689D2EB073A400DCB941 /* IconSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconSettingsView.swift; sourceTree = ""; }; DA52689F2EB073BC00DCB941 /* SoundSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundSettingsView.swift; sourceTree = ""; }; @@ -259,6 +263,7 @@ DACCE5E02EABE4B4008CDD92 /* AppUpdate.swift */, DA3429B42EBA8A450010784E /* FilePreviewState.swift */, DA5268B02EB2708E00DCB941 /* HotlineState.swift */, + DA501BEF2EBED848001714F8 /* BonjourState.swift */, ); path = State; sourceTree = ""; @@ -304,6 +309,7 @@ DA501BE52EBE9520001714F8 /* Trackers */ = { isa = PBXGroup; children = ( + DA501BF12EBEF415001714F8 /* BonjourServerRow.swift */, DAE734FA2B2E41F9000C56F6 /* TrackerView.swift */, DA501BE82EBE9589001714F8 /* TrackerItemView.swift */, DA501BEA2EBE95B4001714F8 /* TrackerBookmarkServerView.swift */, @@ -693,6 +699,7 @@ DA5753682B33E88A00FAC277 /* HotlineTransferClient.swift in Sources */, DAB4D8822B4C8FED0048A05C /* FileIconView.swift in Sources */, DA65499A2BEC280E00EDB697 /* ServerMessageView.swift in Sources */, + DA501BF02EBED848001714F8 /* BonjourState.swift in Sources */, DAAEE66F2B47625600A5BA07 /* FilePreviewImageView.swift in Sources */, DA872B152BDDEE1A008B1012 /* VisualEffectView.swift in Sources */, DA3429AE2EB9C0280010784E /* HotlineFileUploadClientNew.swift in Sources */, @@ -707,6 +714,7 @@ DAC87F072C5010E80060FADF /* HotlineExtensions.swift in Sources */, DAE734FD2B2E65E9000C56F6 /* MessageBoardView.swift in Sources */, DA6980832BFFD06C003E434B /* BookmarkDocument.swift in Sources */, + DA501BF22EBEF415001714F8 /* BonjourServerRow.swift in Sources */, DA501BE72EBE9542001714F8 /* TrackerBookmarkSheet.swift in Sources */, DAB4D87E2B4C8BCA0048A05C /* FilePreviewTextView.swift in Sources */, DA3429B72EBAB1750010784E /* QuickLookPreviewView.swift in Sources */, diff --git a/Hotline/Models/Server.swift b/Hotline/Models/Server.swift index e0fe38b..7d38752 100644 --- a/Hotline/Models/Server.swift +++ b/Hotline/Models/Server.swift @@ -10,6 +10,15 @@ struct Server: Codable { var login: String var password: String + var displayAddress: String { + if self.port == HotlinePorts.DefaultServerPort { + return self.address + } + else { + return "\(self.address):\(String(self.port))" + } + } + init(name: String?, description: String?, address: String, port: Int = HotlinePorts.DefaultServerPort, users: Int = 0, login: String? = nil, password: String? = nil) { self.name = name self.description = description diff --git a/Hotline/State/AppState.swift b/Hotline/State/AppState.swift index 558af2a..3aadf08 100644 --- a/Hotline/State/AppState.swift +++ b/Hotline/State/AppState.swift @@ -11,6 +11,8 @@ final class AppState { private init() { } + + var bonjourState = BonjourState() var activeHotline: HotlineState? = nil var activeServerState: ServerState? = nil diff --git a/Hotline/State/BonjourState.swift b/Hotline/State/BonjourState.swift new file mode 100644 index 0000000..aa7cf44 --- /dev/null +++ b/Hotline/State/BonjourState.swift @@ -0,0 +1,256 @@ +import Foundation +import Network + +@Observable +class BonjourState { + var isExpanded: Bool = false + var isBrowsing: Bool = false + var discoveredServers: [BonjourServer] = [] + + private var browser: NWBrowser? + private var resolutionTasks: [UUID: Task] = [:] + + private actor ConnectionResolverState { + var completed = false + func markComplete() { + self.completed = true + } + } + + struct BonjourServer: Identifiable, Hashable { + let id = UUID() + let serviceName: String + let name: String + let address: String? + let port: UInt16? + let txtRecords: [String: String] + + var displayName: String { + // Use the advertised name, fall back to service name + self.name.isEmpty ? self.serviceName : self.name + } + + var server: Server? { + guard let address = self.address, + let port = self.port else { + return nil + } + return Server(name: self.displayName, description: nil, address: address, port: Int(port)) + } + + var isLoopback: Bool { + guard let address = self.address else { return false } + return address.hasPrefix("127.") || address.hasPrefix("::1") + } + + static func == (lhs: BonjourServer, rhs: BonjourServer) -> Bool { + lhs.address == rhs.address && lhs.port == rhs.port + } + + func hash(into hasher: inout Hasher) { + hasher.combine(self.id) + } + } + + func startBrowsing() { + guard !self.isBrowsing else { + return + } + + self.isBrowsing = true + self.discoveredServers.removeAll() + + let parameters = NWParameters() + parameters.includePeerToPeer = true + + self.browser = NWBrowser(for: .bonjourWithTXTRecord(type: "_hotline._tcp", domain: nil), using: parameters) + + self.browser?.stateUpdateHandler = { [weak self] newState in + Task { @MainActor in + switch newState { + case .ready: + print("BonjourState: Browser ready") + case .failed(let error): + print("BonjourState: Browser failed: \(error)") + self?.stopBrowsing() + case .cancelled: + print("BonjourState: Browser cancelled") + self?.isBrowsing = false + default: + break + } + } + } + + self.browser?.browseResultsChangedHandler = { [weak self] results, changes in + guard let self = self else { + return + } + + Task { @MainActor in + print("BonjourState: Browse results changed, found \(results.count) services") + + // Handle removed services + for change in changes { + if case .removed(let result) = change { + if case .service(let name, _, _, _) = result.endpoint { + self.discoveredServers.removeAll { $0.serviceName == name } + print("BonjourState: Removed service: \(name)") + } + } + } + + // Handle added/updated services + for change in changes { + if case .added(let result) = change, case .service = result.endpoint { + await self.resolveService(result) + } else if case .changed(_, let new, _) = change, case .service = new.endpoint { + await self.resolveService(new) + } + } + } + } + + self.browser?.start(queue: .main) + } + + private func cleanAddress(_ addressString: String) -> String { + // For link-local IPv6 (fe80::), keep zone ID as it's required + if addressString.hasPrefix("fe80:") { + return addressString + } + + // For everything else, strip zone identifier + return addressString.components(separatedBy: "%").first ?? addressString + } + + private func resolveService(_ result: NWBrowser.Result) async { + guard case .service(let name, _, _, _) = result.endpoint else { + return + } + + // Create a connection to resolve the service + let connection = NWConnection(to: result.endpoint, using: .tcp) + let resolver = ConnectionResolverState() + + await withCheckedContinuation { (continuation: CheckedContinuation) in + connection.stateUpdateHandler = { [weak self] state in + Task { @MainActor in + if Task.isCancelled { + await resolver.markComplete() + return + } + + if await resolver.completed { + return + } + + switch state { + case .ready: + await resolver.markComplete() + + // Extract address and port + var address: String? + var port: UInt16? + + guard let path = connection.currentPath, let endpoint = path.remoteEndpoint else { + return + } + + var isLoopback: Bool = false + if path.usesInterfaceType(.loopback) { + isLoopback = true + } + + if case .hostPort(let host, let nwPort) = endpoint { + switch host { + case .ipv4(let ipv4): + address = self?.cleanAddress(ipv4.debugDescription) + case .ipv6(let ipv6): + address = self?.cleanAddress(ipv6.debugDescription) + case .name(let hostname, _): + address = hostname + @unknown default: + break + } + + if isLoopback { + address = "127.0.0.1" + } + port = nwPort.rawValue + } + + // Parse TXT records + var txtRecords: [String: String] = [:] + if case .bonjour(let txtRecord) = result.metadata { + for (key, value) in txtRecord.dictionary { + txtRecords[key] = value + } + } + + let server = BonjourServer( + serviceName: name, + name: name, + address: address, + port: port, + txtRecords: txtRecords + ) + + // Update or add server + if let index = self?.discoveredServers.firstIndex(where: { $0.serviceName == + name }) { + self?.discoveredServers[index] = server + } else { + self?.discoveredServers.append(server) + } + + connection.cancel() + continuation.resume() + + case .failed(let error): + await resolver.markComplete() + + print("BonjourState: Failed to resolve \(name): \(error)") + connection.cancel() + continuation.resume() + + default: + break + } + } + } + + connection.start(queue: .main) + + // Timeout after 5 seconds + Task { + try? await Task.sleep(nanoseconds: 5_000_000_000) + + if await resolver.completed == false { + await resolver.markComplete() + connection.cancel() + continuation.resume() + } + } + } + } + + func stopBrowsing() { + guard self.isBrowsing else { + return + } + + print("BonjourState: Stopping Bonjour browsing") + + // Cancel all resolution tasks + for (_, task) in self.resolutionTasks { + task.cancel() + } + self.resolutionTasks.removeAll() + + self.browser?.cancel() + self.browser = nil + self.isBrowsing = false + self.discoveredServers.removeAll() + } +} diff --git a/Hotline/macOS/Trackers/BonjourServerRow.swift b/Hotline/macOS/Trackers/BonjourServerRow.swift new file mode 100644 index 0000000..d99b23b --- /dev/null +++ b/Hotline/macOS/Trackers/BonjourServerRow.swift @@ -0,0 +1,19 @@ +import SwiftUI + +struct BonjourServerRow: View { + @Environment(\.openWindow) private var openWindow + + let server: BonjourState.BonjourServer + + var body: some View { + HStack(alignment: .center, spacing: 6) { + Image("Server") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16, alignment: .center) + Text(self.server.displayName).lineLimit(1).truncationMode(.tail) + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/Hotline/macOS/Trackers/TrackerItemView.swift b/Hotline/macOS/Trackers/TrackerItemView.swift index 6e62518..37093d2 100644 --- a/Hotline/macOS/Trackers/TrackerItemView.swift +++ b/Hotline/macOS/Trackers/TrackerItemView.swift @@ -36,7 +36,7 @@ struct TrackerItemView: View { if isLoading { ProgressView() .padding([.leading, .trailing], 2) - .controlSize(.small) + .controlSize(.mini) } Spacer(minLength: 0) if isExpanded && count > 0 { diff --git a/Hotline/macOS/Trackers/TrackerView.swift b/Hotline/macOS/Trackers/TrackerView.swift index ddc0a67..0eb4a12 100644 --- a/Hotline/macOS/Trackers/TrackerView.swift +++ b/Hotline/macOS/Trackers/TrackerView.swift @@ -6,11 +6,15 @@ import UniformTypeIdentifiers enum TrackerSelection: Hashable { case bookmark(Bookmark) case bookmarkServer(BookmarkServer) + case bonjourGroup + case bonjourServer(BonjourState.BonjourServer) var server: Server? { switch self { - case .bookmark(let b): return b.server - case .bookmarkServer(let t): return t.server + case .bookmark(let b): b.server + case .bookmarkServer(let t): t.server + case .bonjourGroup: nil + case .bonjourServer(let b): b.server } } } @@ -85,6 +89,62 @@ struct TrackerView: View { } } } + + var bonjourRowView: some View { + HStack(alignment: .center, spacing: 6) { + Button { + AppState.shared.bonjourState.isExpanded.toggle() + } label: { + Text(Image(systemName: AppState.shared.bonjourState.isExpanded ? "chevron.down" : "chevron.right")) + .bold() + .font(.system(size: 10)) + .opacity(0.5) + .frame(alignment: .center) + } + .buttonStyle(.plain) + .frame(width: 10) + .padding(.leading, 4) + .padding(.trailing, 2) + + Image(systemName: "bonjour") + .resizable() + .scaledToFit() + .symbolRenderingMode(.multicolor) + .frame(width: 16, height: 16, alignment: .center) + Text("Bonjour").bold().lineLimit(1).truncationMode(.tail) + + if AppState.shared.bonjourState.isBrowsing { + ProgressView() + .controlSize(.mini) + } + + Spacer(minLength: 0) + + if AppState.shared.bonjourState.isExpanded && !AppState.shared.bonjourState.discoveredServers.isEmpty { + HStack(spacing: 4) { + Text(String(AppState.shared.bonjourState.discoveredServers.count)) + + SpinningGlobeView() + .fontWeight(.semibold) + .frame(width: 12, height: 12) + } + .padding(.horizontal, 6) + .padding(.vertical, 2) + .foregroundStyle(.secondary) +// .background(.quinary) + .clipShape(.capsule) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onChange(of: AppState.shared.bonjourState.isExpanded) { oldState, newState in + if newState { + AppState.shared.bonjourState.startBrowsing() + } + else { + AppState.shared.bonjourState.stopBrowsing() + } + } + } var body: some View { List(selection: $selection) { @@ -115,6 +175,17 @@ struct TrackerView: View { .onDelete { deletedIndexes in Bookmark.delete(at: deletedIndexes, context: modelContext) } + + self.bonjourRowView + .tag(TrackerSelection.bonjourGroup) + + if AppState.shared.bonjourState.isExpanded { + ForEach(AppState.shared.bonjourState.discoveredServers, id: \.self) { record in + BonjourServerRow(server: record) + .tag(TrackerSelection.bonjourServer(record)) + .padding(.leading, 16 + 8 + 10) + } + } } .onDeleteCommand { switch self.selection { @@ -155,6 +226,10 @@ struct TrackerView: View { self.bookmarkContextMenu(bookmark) case .bookmarkServer(let server): self.bookmarkServerContextMenu(server) + case .bonjourGroup: + EmptyView() + case .bonjourServer(let bonjourServer): + self.bonjourServerContextMenu(bonjourServer) } } } primaryAction: { items in @@ -180,6 +255,15 @@ struct TrackerView: View { case .bookmarkServer(let bookmarkServer): openWindow(id: "server", value: bookmarkServer.server) + + case .bonjourGroup: + AppState.shared.bonjourState.isExpanded.toggle() + + case .bonjourServer(let bonjourServer): + if let server = bonjourServer.server { + openWindow(id: "server", value: server) + } + } } .fileExporter(isPresented: $bookmarkExportActive, document: bookmarkExport, contentTypes: [.data], defaultFilename: "\(bookmarkExport?.bookmark.name ?? "Hotline Bookmark").hlbm", onCompletion: { result in @@ -380,8 +464,32 @@ struct TrackerView: View { Label(bookmark.type == .tracker ? "Delete Tracker" : "Delete Bookmark", systemImage: "trash") } } - + @ViewBuilder + func bonjourServerContextMenu(_ bonjourServer: BonjourState.BonjourServer) -> some View { + Button { + guard let server = bonjourServer.server else { + return + } + let newBookmark = Bookmark(type: .server, name: server.name ?? server.address, address: server.address, port: server.port, login: nil, password: nil) + Bookmark.add(newBookmark, context: modelContext) + } label: { + Label("Bookmark", systemImage: "bookmark") + } + + Divider() + + Button { + guard let server = bonjourServer.server else { + return + } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(server.displayAddress, forType: .string) + } label: { + Label("Copy Address", systemImage: "doc.on.doc") + } + } + func refresh() { // When a tracker is selected, refresh only that tracker. if let trackerSelection = self.selection { -- cgit From 286c408370681b022deaabd254d499aefec28add Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Sat, 8 Nov 2025 12:33:00 -0800 Subject: Some work on making it possible to connect to servers with an IPv6 address. Improve connect form a bit. Add Copy Link to context menu for servers and trackers. --- Hotline.xcodeproj/project.pbxproj | 2 +- Hotline/Library/NetSocket/NetSocket.swift | 19 +++- Hotline/Models/Server.swift | 50 +++++++++- Hotline/State/BonjourState.swift | 22 +++-- Hotline/macOS/ServerView.swift | 151 +++++++++++++++--------------- Hotline/macOS/Trackers/TrackerView.swift | 50 ++++++++-- 6 files changed, 195 insertions(+), 99 deletions(-) (limited to 'Hotline/macOS') diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index f94cba2..3e600a4 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -309,12 +309,12 @@ DA501BE52EBE9520001714F8 /* Trackers */ = { isa = PBXGroup; children = ( - DA501BF12EBEF415001714F8 /* BonjourServerRow.swift */, DAE734FA2B2E41F9000C56F6 /* TrackerView.swift */, DA501BE82EBE9589001714F8 /* TrackerItemView.swift */, DA501BEA2EBE95B4001714F8 /* TrackerBookmarkServerView.swift */, DA501BE62EBE9542001714F8 /* TrackerBookmarkSheet.swift */, DA501BE32EBE9517001714F8 /* ServerBookmarkSheet.swift */, + DA501BF12EBEF415001714F8 /* BonjourServerRow.swift */, ); path = Trackers; sourceTree = ""; diff --git a/Hotline/Library/NetSocket/NetSocket.swift b/Hotline/Library/NetSocket/NetSocket.swift index 5c7d185..e66ef3f 100644 --- a/Hotline/Library/NetSocket/NetSocket.swift +++ b/Hotline/Library/NetSocket/NetSocket.swift @@ -173,7 +173,24 @@ public actor NetSocket { guard let nwPort = NWEndpoint.Port(rawValue: port) else { throw NetSocketError.invalidPort } - return try await self.connect(host: .name(host, nil), port: nwPort, tls: tls, config: config) + + // 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) } // MARK: Close diff --git a/Hotline/Models/Server.swift b/Hotline/Models/Server.swift index 7d38752..3ad3375 100644 --- a/Hotline/Models/Server.swift +++ b/Hotline/Models/Server.swift @@ -15,7 +15,12 @@ struct Server: Codable { return self.address } else { - return "\(self.address):\(String(self.port))" + // Wrap IPv6 addresses in brackets when displaying with port + if self.address.contains(":") { + return "[\(self.address)]:\(String(self.port))" + } else { + return "\(self.address):\(String(self.port))" + } } } @@ -50,10 +55,49 @@ struct Server: Codable { } static func parseServerAddressAndPort(_ address: String) -> (String, Int) { - let url = URL(string: "hotline://\(address)") + let trimmed = address.trimmingCharacters(in: .whitespacesAndNewlines) + + // Check if this looks like an IPv6 address (contains colons but no port delimiter) + // IPv6 addresses can be: + // - fe80::1234 + // - [fe80::1234]:5500 (with port) + // - 2001:db8::1 + // - [2001:db8::1]:6500 (with port) + + // If it starts with [, it's bracketed IPv6 with optional port + if trimmed.hasPrefix("[") { + // Find the closing bracket + if let closeBracketIndex = trimmed.firstIndex(of: "]") { + let hostEndIndex = trimmed.index(after: closeBracketIndex) + let host = String(trimmed[trimmed.index(after: trimmed.startIndex).. 1 { + // This is likely an IPv6 address without a port + // Keep it as-is, including any zone identifier (e.g., %en1 for link-local) + return (trimmed.lowercased(), HotlinePorts.DefaultServerPort) + } + + // Otherwise use URL parsing for IPv4 or hostnames + let url = URL(string: "hotline://\(trimmed)") let port = url?.port ?? HotlinePorts.DefaultServerPort let host = url?.host(percentEncoded: false) ?? "" - return (host.lowercased().trimmingCharacters(in: .whitespacesAndNewlines), port) + return (host.lowercased(), port) } } diff --git a/Hotline/State/BonjourState.swift b/Hotline/State/BonjourState.swift index aa7cf44..b0bb42c 100644 --- a/Hotline/State/BonjourState.swift +++ b/Hotline/State/BonjourState.swift @@ -115,12 +115,13 @@ class BonjourState { } private func cleanAddress(_ addressString: String) -> String { - // For link-local IPv6 (fe80::), keep zone ID as it's required + // For link-local IPv6 addresses (fe80::), we MUST keep the zone identifier + // because it tells the system which network interface to use for routing + // For all other addresses (global IPv6, IPv4), strip the zone identifier if addressString.hasPrefix("fe80:") { return addressString } - - // For everything else, strip zone identifier + return addressString.components(separatedBy: "%").first ?? addressString } @@ -128,7 +129,7 @@ class BonjourState { guard case .service(let name, _, _, _) = result.endpoint else { return } - + // Create a connection to resolve the service let connection = NWConnection(to: result.endpoint, using: .tcp) let resolver = ConnectionResolverState() @@ -148,32 +149,33 @@ class BonjourState { switch state { case .ready: await resolver.markComplete() - + // Extract address and port var address: String? var port: UInt16? - + guard let path = connection.currentPath, let endpoint = path.remoteEndpoint else { return } - + var isLoopback: Bool = false if path.usesInterfaceType(.loopback) { isLoopback = true } - + if case .hostPort(let host, let nwPort) = endpoint { switch host { case .ipv4(let ipv4): address = self?.cleanAddress(ipv4.debugDescription) case .ipv6(let ipv6): - address = self?.cleanAddress(ipv6.debugDescription) + let ipv6String = ipv6.debugDescription + address = self?.cleanAddress(ipv6String) case .name(let hostname, _): address = hostname @unknown default: break } - + if isLoopback { address = "127.0.0.1" } diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index a66a071..d2c503f 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -116,8 +116,13 @@ struct ServerView: View { var body: some View { Group { if model.status == .disconnected { - connectForm - .navigationTitle("Connect to Server") + VStack(alignment: .center) { + Spacer() + self.connectForm + Spacer() + } +// .frame(maxWidth: .infinity, maxHeight: .infinity) + .navigationTitle("Connect to Server") } else if case .failed(let error) = model.status { VStack { @@ -245,79 +250,80 @@ struct ServerView: View { } var connectForm: some View { - VStack(alignment: .center) { - GroupBox { - Form { - Group { - TextField(text: $connectAddress) { - Text("Address:") - } - .focused($focusedField, equals: .address) - - Text("Type the address of the Hotline server you would like to connect to. If you have an account on that server, type your login and password too.") - .font(.caption) - .foregroundStyle(.secondary) - .padding(.bottom, 4) - - TextField(text: $connectLogin, prompt: Text("Optional")) { - Text("Login:") - } - .focused($focusedField, equals: .login) - SecureField(text: $connectPassword, prompt: Text("Optional")) { - Text("Password:") - } - .focused($focusedField, equals: .password) - } - .textFieldStyle(.roundedBorder) - .controlSize(.large) - - HStack { - Button("Save...") { - if !connectAddress.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - connectNameSheetPresented = true - } - } - .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(.top, 8) - + 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) } - .padding() - .onChange(of: connectAddress) { - let (a, p) = Server.parseServerAddressAndPort(connectAddress) - server.address = a - server.port = p + } + + 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) + + HStack { + Button("Save...") { + if !connectAddress.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + connectNameSheetPresented = true + } } - .onChange(of: connectLogin) { - server.login = connectLogin.trimmingCharacters(in: .whitespacesAndNewlines) + .disabled(connectAddress.isEmpty) + .controlSize(.regular) + .buttonStyle(.automatic) + .help("Bookmark server") + + Spacer() + + Button("Cancel") { + dismiss() } - .onChange(of: connectPassword) { - server.password = connectPassword + .controlSize(.regular) + .buttonStyle(.automatic) + .keyboardShortcut(.cancelAction) + + Button("Connect") { + connectToServer() } + + .controlSize(.regular) + .buttonStyle(.automatic) + .keyboardShortcut(.defaultAction) } - .onAppear { - focusedField = .address - } + .padding(.top, 8) + } + .formStyle(.grouped) + .fixedSize(horizontal: false, vertical: true) + .onChange(of: connectAddress) { + 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 + } + .onAppear { + focusedField = .address } .frame(maxWidth: 380) .padding() @@ -346,8 +352,7 @@ struct ServerView: View { if !name.isEmpty { connectNameSheetPresented = false connectName = "" -// Task.detached { - + let (host, port) = Server.parseServerAddressAndPort(connectAddress) let login: String? = connectLogin.isEmpty ? nil : connectLogin let password: String? = connectPassword.isEmpty ? nil : connectPassword @@ -356,8 +361,6 @@ struct ServerView: View { 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/Trackers/TrackerView.swift b/Hotline/macOS/Trackers/TrackerView.swift index 0eb4a12..dbbacaa 100644 --- a/Hotline/macOS/Trackers/TrackerView.swift +++ b/Hotline/macOS/Trackers/TrackerView.swift @@ -405,14 +405,13 @@ struct TrackerView: View { @ViewBuilder func bookmarkServerContextMenu(_ server: BookmarkServer) -> some View { Button { - let newBookmark = Bookmark(type: .server, name: server.name ?? server.address, address: server.address, port: server.port, login: nil, password: nil) - Bookmark.add(newBookmark, context: modelContext) + NSPasteboard.general.clearContents() + let displayAddress = (server.port == HotlinePorts.DefaultServerPort) ? server.address : "\(server.address):\(server.port)" + NSPasteboard.general.setString("hotline://\(displayAddress)", forType: .string) } label: { - Label("Bookmark", systemImage: "bookmark") + Label("Copy Link", systemImage: "link") } - Divider() - Button { NSPasteboard.general.clearContents() let displayAddress = (server.port == HotlinePorts.DefaultServerPort) ? server.address : "\(server.address):\(server.port)" @@ -420,10 +419,30 @@ struct TrackerView: View { } label: { Label("Copy Address", systemImage: "doc.on.doc") } + + Divider() + + Button { + let newBookmark = Bookmark(type: .server, name: server.name ?? server.address, address: server.address, port: server.port, login: nil, password: nil) + Bookmark.add(newBookmark, context: modelContext) + } label: { + Label("Bookmark", systemImage: "bookmark") + } } @ViewBuilder func bookmarkContextMenu(_ bookmark: Bookmark) -> some View { + Button { + let linkString: String = switch bookmark.type { + case .tracker: "hotlinetracker://\(bookmark.displayAddress)" + case .server: "hotline://\(bookmark.displayAddress)" + } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(linkString, forType: .string) + } label: { + Label("Copy Link", systemImage: "link") + } + Button { NSPasteboard.general.clearContents() NSPasteboard.general.setString(bookmark.displayAddress, forType: .string) @@ -471,14 +490,13 @@ struct TrackerView: View { guard let server = bonjourServer.server else { return } - let newBookmark = Bookmark(type: .server, name: server.name ?? server.address, address: server.address, port: server.port, login: nil, password: nil) - Bookmark.add(newBookmark, context: modelContext) + NSPasteboard.general.clearContents() + let displayAddress = (server.port == HotlinePorts.DefaultServerPort) ? server.address : "\(server.address):\(server.port)" + NSPasteboard.general.setString("hotline://\(displayAddress)", forType: .string) } label: { - Label("Bookmark", systemImage: "bookmark") + Label("Copy Link", systemImage: "link") } - Divider() - Button { guard let server = bonjourServer.server else { return @@ -488,6 +506,18 @@ struct TrackerView: View { } label: { Label("Copy Address", systemImage: "doc.on.doc") } + + Divider() + + Button { + guard let server = bonjourServer.server else { + return + } + let newBookmark = Bookmark(type: .server, name: server.name ?? server.address, address: server.address, port: server.port, login: nil, password: nil) + Bookmark.add(newBookmark, context: modelContext) + } label: { + Label("Bookmark", systemImage: "bookmark") + } } func refresh() { -- cgit From 2caf86e633c1a121c8e23d068ac817ed8e80f6a7 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Sat, 8 Nov 2025 19:51:48 -0800 Subject: Moved SoundEffects to Managers. Updated connect form in ServerView. Changed some icons in context menus for bookmarks. --- Hotline.xcodeproj/project.pbxproj | 2 +- Hotline/Library/SoundEffects.swift | 41 ------------------ Hotline/Managers/SoundEffects.swift | 40 +++++++++++++++++ Hotline/macOS/ServerView.swift | 73 +++++++++++++++++--------------- Hotline/macOS/Trackers/TrackerView.swift | 7 ++- 5 files changed, 85 insertions(+), 78 deletions(-) delete mode 100644 Hotline/Library/SoundEffects.swift create mode 100644 Hotline/Managers/SoundEffects.swift (limited to 'Hotline/macOS') diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index 3e600a4..f91bcf8 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -441,7 +441,6 @@ children = ( DAB4D8832B4CABEF0048A05C /* Extensions.swift */, DA5268AA2EB11EA300DCB941 /* ColorArt.swift */, - DAE735062B3251B3000C56F6 /* SoundEffects.swift */, DA55AC782BE6A1AD00034857 /* RegularExpressions.swift */, DA6980822BFFD06C003E434B /* BookmarkDocument.swift */, DA501BE02EBE844F001714F8 /* Views */, @@ -494,6 +493,7 @@ DAC6B2DF2EAC6236004E2CBA /* Managers */ = { isa = PBXGroup; children = ( + DAE735062B3251B3000C56F6 /* SoundEffects.swift */, DAC6B2DE2EAC6236004E2CBA /* ChatStore.swift */, ); path = Managers; diff --git a/Hotline/Library/SoundEffects.swift b/Hotline/Library/SoundEffects.swift deleted file mode 100644 index 4964b94..0000000 --- a/Hotline/Library/SoundEffects.swift +++ /dev/null @@ -1,41 +0,0 @@ -import Foundation -import AppKit - -enum SoundEffect: String { - case loggedIn = "logged-in" - case chatMessage = "chat-message" - case transferComplete = "transfer-complete" - case userLogin = "user-login" - case userLogout = "user-logout" - 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 SoundEffects { - static let shared = SoundEffects() - - private var preloadedSounds: [SoundEffect: NSSound] = [:] - - 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 - } - } - } - - static func play(_ name: SoundEffect) { - Self.shared.play(name) - } - - func play(_ name: SoundEffect) { - self.preloadedSounds[name]?.play() - } -} diff --git a/Hotline/Managers/SoundEffects.swift b/Hotline/Managers/SoundEffects.swift new file mode 100644 index 0000000..85a1c0e --- /dev/null +++ b/Hotline/Managers/SoundEffects.swift @@ -0,0 +1,40 @@ +import Foundation +import AppKit + +enum SoundEffect: String { + case loggedIn = "logged-in" + case chatMessage = "chat-message" + case transferComplete = "transfer-complete" + case userLogin = "user-login" + case userLogout = "user-logout" + case newNews = "new-news" + case serverMessage = "server-message" + case error = "error" + + static var all: [SoundEffect] = [.loggedIn, .chatMessage, .transferComplete, .userLogin, .userLogout, .newNews, .serverMessage, .error] +} + +class SoundEffects { + static let shared = SoundEffects() + + static func play(_ name: SoundEffect) { + Self.shared.play(name) + } + + private var preloadedSounds: [SoundEffect: NSSound] = [:] + + 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 play(_ name: SoundEffect) { + self.preloadedSounds[name]?.play() + } +} diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index d2c503f..44274ea 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -121,8 +121,10 @@ struct ServerView: View { self.connectForm Spacer() } -// .frame(maxWidth: .infinity, maxHeight: .infinity) .navigationTitle("Connect to Server") + .onAppear { + self.focusedField = .address + } } else if case .failed(let error) = model.status { VStack { @@ -250,40 +252,47 @@ struct ServerView: View { } var connectForm: some View { - Form { - HStack(alignment: .top, spacing: 10) { - Image("Server Large") - .resizable() - .scaledToFit() - .frame(width: 28, height: 28) + 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) + } + } - 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) } - - 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("Save...") { + Button { if !connectAddress.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { connectNameSheetPresented = true } + } label: { + Image(systemName: "bookmark.fill") } .disabled(connectAddress.isEmpty) .controlSize(.regular) @@ -302,15 +311,12 @@ struct ServerView: View { Button("Connect") { connectToServer() } - .controlSize(.regular) .buttonStyle(.automatic) .keyboardShortcut(.defaultAction) } - .padding(.top, 8) + .padding(.horizontal, 20) } - .formStyle(.grouped) - .fixedSize(horizontal: false, vertical: true) .onChange(of: connectAddress) { let (a, p) = Server.parseServerAddressAndPort(connectAddress) server.address = a @@ -322,14 +328,11 @@ struct ServerView: View { .onChange(of: connectPassword) { server.password = connectPassword } - .onAppear { - focusedField = .address - } .frame(maxWidth: 380) .padding() .sheet(isPresented: $connectNameSheetPresented) { VStack(alignment: .leading) { - Text("Name this server bookmark:") + Text("Save Bookmark") .foregroundStyle(.secondary) .padding(.bottom, 4) TextField("Bookmark Name", text: $connectName) diff --git a/Hotline/macOS/Trackers/TrackerView.swift b/Hotline/macOS/Trackers/TrackerView.swift index dbbacaa..e0ca87d 100644 --- a/Hotline/macOS/Trackers/TrackerView.swift +++ b/Hotline/macOS/Trackers/TrackerView.swift @@ -480,7 +480,12 @@ struct TrackerView: View { Button { Bookmark.delete(bookmark, context: modelContext) } label: { - Label(bookmark.type == .tracker ? "Delete Tracker" : "Delete Bookmark", systemImage: "trash") + if bookmark.type == .tracker { + Label("Delete Tracker", systemImage: "xmark") + } + else { + Label("Delete Bookmark", systemImage: "bookmark.slash") + } } } -- 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/macOS') 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 5098adcccc0a60e600bf4aeb01d74537d31ba111 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Sun, 9 Nov 2025 22:26:53 -0800 Subject: Further work on trnasfer context menus with Open With, Move to Trash, etc. and handling of multiple selection. --- Hotline/State/AppState.swift | 8 + Hotline/macOS/TransfersView.swift | 303 ++++++++++++++++++++++++++------------ 2 files changed, 219 insertions(+), 92 deletions(-) (limited to 'Hotline/macOS') diff --git a/Hotline/State/AppState.swift b/Hotline/State/AppState.swift index 2d7820b..e268ba2 100644 --- a/Hotline/State/AppState.swift +++ b/Hotline/State/AppState.swift @@ -57,6 +57,14 @@ final class AppState { // Remove from transfers list self.transfers.remove(at: transferIndex) } + + /// Cancel specified transfers + @MainActor + func cancelTransfers(ids: [UUID]) { + for transferID in ids { + self.cancelTransfer(id: transferID) + } + } /// Cancel all active transfers @MainActor diff --git a/Hotline/macOS/TransfersView.swift b/Hotline/macOS/TransfersView.swift index 6b7a21b..489a50b 100644 --- a/Hotline/macOS/TransfersView.swift +++ b/Hotline/macOS/TransfersView.swift @@ -16,15 +16,24 @@ struct TransfersView: View { .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 { + if self.selectedTransfers.isEmpty { + if let downloadsURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first { + NSWorkspace.shared.open(downloadsURL) + } + } + else { + let fileURLs = self.selectedTransfers.compactMap(\.fileURL) + if !fileURLs.isEmpty { + NSWorkspace.shared.activateFileViewerSelecting(fileURLs) + } + } + } label: { + Label("Show Downloads", systemImage: "folder") + } + .help("Show Downloads") + } ToolbarItem(placement: .primaryAction) { Button { @@ -33,15 +42,16 @@ struct TransfersView: View { } self.selectedTransfers = [] } label: { - Label("Cancel Transfer", systemImage: "xmark") + Label(self.selectedTransfers.count == 1 ? "Remove Transfer" : "Remove Transfers", systemImage: "xmark") } .disabled(self.selectedTransfers.isEmpty) + .help(self.selectedTransfers.count == 1 ? "Remove Transfer" : "Remove Transfers") } } } - + // MARK: - Empty State - + private var emptyState: some View { ContentUnavailableView { Label("No Transfers", systemImage: "arrow.up.arrow.down") @@ -49,9 +59,9 @@ struct TransfersView: View { Text("Your Hotline file transfers will appear here") } } - + // MARK: - Transfers List - + private var transfersList: some View { List(selection: self.$selectedTransfers) { ForEach(self.appState.transfers) { transfer in @@ -62,40 +72,184 @@ struct TransfersView: View { .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) + 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("Show in Finder") { - NSWorkspace.shared.activateFileViewerSelecting([fileURL]) + let fileURLs: [URL] = items.compactMap(\.fileURL) + if !fileURLs.isEmpty { + NSWorkspace.shared.recycle(fileURLs) } - - Button("Open") { - NSWorkspace.shared.open(fileURL) + + self.selectedTransfers = [] + } + } + } primaryAction: { items in + if let fileURL = items.first?.fileURL { + NSWorkspace.shared.open(fileURL) + } + } + } + + private func getOpenWithApps(for fileURLs: [URL], defaultAppURL: URL? = nil) -> [(name: String, url: URL)] { + // If no files provided, there is no common app to open them + guard !fileURLs.isEmpty else { return [] } + + // Build a list of app URL sets for each file URL + let appSets: [Set] = fileURLs.map { url in + let apps = NSWorkspace.shared.urlsForApplications(toOpen: url) + return Set(apps) + } + + // Compute the intersection across all file URL app sets + guard var intersection = appSets.first else { return [] } + for set in appSets.dropFirst() { + intersection.formIntersection(set) + } + + // Optionally remove the default app from the list + if let defaultAppURL { + intersection.remove(defaultAppURL) + } + + // Map to display names and sort by name + let result: [(name: String, url: URL)] = intersection.compactMap { url in + let appName = FileManager.default + .displayName(atPath: url.path) + .replacingOccurrences(of: ".app", with: "") + return (name: appName, url: url) + }.sorted { $0.name < $1.name } + + return result + } + + private func getDefaultApp(for fileURLs: [URL]) -> (name: String, url: URL)? { + // No files -> no default app + guard !fileURLs.isEmpty else { return nil } + + // Single file: use the system default directly + if fileURLs.count == 1, let url = NSWorkspace.shared.urlForApplication(toOpen: fileURLs[0]) { + let name = FileManager.default + .displayName(atPath: url.path) + .replacingOccurrences(of: ".app", with: "") + return (name, url) + } + + // Build the intersection of apps that can open ALL files + let appSets: [Set] = fileURLs.map { url in + Set(NSWorkspace.shared.urlsForApplications(toOpen: url)) + } + guard var intersection = appSets.first else { return nil } + for set in appSets.dropFirst() { + intersection.formIntersection(set) + if intersection.isEmpty { return nil } + } + + // Tally the system default app for each file + var defaultCounts: [URL: Int] = [:] + for fileURL in fileURLs { + if let def = NSWorkspace.shared.urlForApplication(toOpen: fileURL) { + defaultCounts[def, default: 0] += 1 + } + } + + // Prefer the app that's the default for the majority of files, provided it can open all + if let bestByMajority = intersection.max(by: { (a, b) -> Bool in + let ca = defaultCounts[a, default: 0] + let cb = defaultCounts[b, default: 0] + if ca == cb { + // Tie-breaker deferred to later + return false + } + return ca < cb + }), + defaultCounts[bestByMajority, default: 0] > 0 { + let name = FileManager.default + .displayName(atPath: bestByMajority.path) + .replacingOccurrences(of: ".app", with: "") + return (name, bestByMajority) + } + + return nil + } + + private func openWithMenu(for fileURLs: [URL]) -> some View { + Menu("Open With") { + let defaultApp: (name: String, url: URL)? = self.getDefaultApp(for: fileURLs) + let apps: [(name: String, url: URL)] = self.getOpenWithApps(for: fileURLs, defaultAppURL: defaultApp?.url) + + if let defaultApp { + Button { + NSWorkspace.shared.open(fileURLs, withApplicationAt: defaultApp.url, configuration: NSWorkspace.OpenConfiguration()) + } label: { + Label { + Text(defaultApp.name) + } icon: { + Image(nsImage: NSWorkspace.shared.icon(forFile: defaultApp.url.path)) + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) } - + } + + if !apps.isEmpty { Divider() - - Button("Move to Trash") { - NSWorkspace.shared.recycle([fileURL]) - } } - else if !item.done { - Button("Cancel Transfer") { - self.appState.cancelTransfer(id: item.id) + } + + if !apps.isEmpty { + ForEach(apps, id: \.url) { app in + Button { + NSWorkspace.shared.open(fileURLs, withApplicationAt: app.url, configuration: NSWorkspace.OpenConfiguration()) + } label: { + Label { + Text(app.name) + } icon: { + Image(nsImage: NSWorkspace.shared.icon(forFile: app.url.path)) + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + } } } } - } primaryAction: { items in - let fileURLs: [URL] = items.compactMap { $0.fileURL } - if !fileURLs.isEmpty { - NSWorkspace.shared.activateFileViewerSelecting(fileURLs) - } } } } @@ -110,20 +264,24 @@ struct TransferRow: View { private var statsView: some View { HStack(spacing: 8) { // Progress percentage -// Text("\(Int(self.transfer.progress * 100))%") + // Text("\(Int(self.transfer.progress * 100))%") // Speed if let speed = self.transfer.speed { - Text(self.formatSpeed(speed)) + // TODO: Use arrow.up for uploads. + Label(self.formatSpeed(speed), systemImage: "arrow.down") +// Text(self.formatSpeed(speed)) } - + // Time remaining if let timeRemaining = self.transfer.timeRemaining { - Text(self.formatTimeRemaining(timeRemaining)) + Label(self.formatTimeRemaining(timeRemaining), systemImage: "clock") +// Text(self.formatTimeRemaining(timeRemaining)) } // File size - Text(self.formatSize(self.transfer.size)) + Label(self.formatSize(self.transfer.size), systemImage: "document") +// Text(self.formatSize(self.transfer.size)) } .font(.subheadline) .foregroundStyle(.secondary) @@ -151,13 +309,13 @@ struct TransferRow: View { } } } - + var body: some View { HStack(alignment: .center, spacing: 8) { self.fileIconView - VStack(alignment: .leading, spacing: 4) { - HStack(alignment: .firstTextBaseline, spacing: 4) { + VStack(alignment: .leading, spacing: 2) { + HStack(alignment: .firstTextBaseline, spacing: 8) { Text(self.transfer.title) .font(.headline) .lineLimit(1) @@ -182,7 +340,7 @@ struct TransferRow: View { .foregroundStyle(.secondary) } else if self.transfer.completed { - Text("Complete") + Text("Downloaded") .font(.subheadline) .foregroundStyle(.fileComplete) } @@ -192,65 +350,25 @@ struct TransferRow: View { .controlSize(.large) } } - - if self.transfer.completed { - Button { - guard let fileURL = self.transfer.fileURL else { - return - } - - NSWorkspace.shared.activateFileViewerSelecting([fileURL]) - } label: { - Image(systemName: "eye.circle.fill") - .resizable() - .scaledToFit() - .frame(width: 24, height: 24) - .foregroundStyle(.secondary) - } - .buttonBorderShape(.circle) - .buttonStyle(.plain) - } } - -// VStack(alignment: .leading, spacing: 2) { - - -// if let serverName = self.transfer.serverName { -// Text(serverName) -// .font(.caption) -// .foregroundStyle(.secondary) -// } -// } - -// // Cancel button -// Button { -// self.appState.cancelTransfer(id: transfer.id) -// } label: { -// Image(systemName: "xmark.circle.fill") -// .foregroundStyle(.secondary) -// } -// .buttonStyle(.plain) -// .help("Cancel download") -// } -// } } - + // MARK: - Formatting - + private func formatSize(_ bytes: UInt) -> String { let formatter = ByteCountFormatter() formatter.countStyle = .file formatter.allowedUnits = [.useKB, .useMB, .useGB] return formatter.string(fromByteCount: Int64(bytes)) } - + private func formatSpeed(_ bytesPerSecond: Double) -> String { let formatter = ByteCountFormatter() formatter.countStyle = .file formatter.allowedUnits = [.useKB, .useMB, .useGB] return "\(formatter.string(fromByteCount: Int64(bytesPerSecond)))/s" } - + private func formatTimeRemaining(_ seconds: TimeInterval) -> String { if seconds < 60 { return "\(Int(seconds))s" @@ -272,3 +390,4 @@ struct TransferRow: View { TransfersView() .environment(AppState.shared) } + -- 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/macOS') 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/macOS') 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/macOS') 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 86560ac84504f2da63c8fb08505857d8827684d4 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Tue, 11 Nov 2025 16:13:47 -0800 Subject: Use linear progress bar for transfers in the Server side bar. --- Hotline/Models/TransferInfo.swift | 43 ++++++++++++++++++ Hotline/macOS/ServerView.swift | 92 ++++++++++++++++++++++----------------- Hotline/macOS/TransfersView.swift | 54 +++++++---------------- 3 files changed, 111 insertions(+), 78 deletions(-) (limited to 'Hotline/macOS') diff --git a/Hotline/Models/TransferInfo.swift b/Hotline/Models/TransferInfo.swift index ebe9f64..cac4b90 100644 --- a/Hotline/Models/TransferInfo.swift +++ b/Hotline/Models/TransferInfo.swift @@ -58,4 +58,47 @@ class TransferInfo: Identifiable, Equatable, Hashable { func hash(into hasher: inout Hasher) { hasher.combine(self.id) } + +// var formatSize(_ bytes: UInt) -> String { +// let formatter = ByteCountFormatter() +// formatter.countStyle = .file +// formatter.allowedUnits = [.useKB, .useMB, .useGB] +// return formatter.string(fromByteCount: Int64(bytes)) +// } + + var displaySize: String { + let formatter = ByteCountFormatter() + formatter.countStyle = .file + formatter.allowedUnits = [.useKB, .useMB, .useGB] + return formatter.string(fromByteCount: Int64(self.size)) + } + + var displaySpeed: String? { + guard let speed = self.speed else { + return nil + } + + let formatter = ByteCountFormatter() + formatter.countStyle = .file + formatter.allowedUnits = [.useKB, .useMB, .useGB] + return "\(formatter.string(fromByteCount: Int64(speed)))/s" + } + + var displayTimeRemaining: String? { + guard let timeRemaining = self.timeRemaining else { + return nil + } + + if timeRemaining < 60 { + return "\(Int(timeRemaining))s" + } else if timeRemaining < 3600 { + let minutes = Int(timeRemaining / 60) + let secs = Int(timeRemaining.truncatingRemainder(dividingBy: 60)) + return "\(minutes)m \(secs)s" + } else { + let hours = Int(timeRemaining / 3600) + let minutes = Int((timeRemaining.truncatingRemainder(dividingBy: 3600)) / 60) + return "\(hours)h \(minutes)m" + } + } } diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index d4b3407..98ab976 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -107,13 +107,10 @@ struct ServerView: View { if self.model.status == .disconnected { VStack(alignment: .center) { Spacer() -// if self.connectionDisplayed { - self.connectForm -// } + self.connectForm Spacer() } .navigationTitle("Connect to Server") -// .animation(.default.delay(0.25), value: self.connectionDisplayed) } else if self.model.status.isLoggingIn { HStack { @@ -281,7 +278,7 @@ struct ServerView: View { var transfersSection: some View { ForEach(model.transfers) { transfer in - TransferItemView(transfer: transfer) + ServerTransferRow(transfer: transfer) } } @@ -421,7 +418,7 @@ struct ServerView: View { } -struct TransferItemView: View { +struct ServerTransferRow: View { let transfer: TransferInfo @Environment(\.controlActiveState) private var controlActiveState @@ -429,29 +426,11 @@ struct TransferItemView: View { @State private var hovered: Bool = false @State private var buttonHovered: Bool = false - private func formattedProgressHelp() -> String { - if self.transfer.completed { - return "File transfer complete" - } - else if self.transfer.failed { - return "File transfer failed" - } - else if self.transfer.progress > 0.0 { - if let estimate = self.transfer.timeRemaining, estimate > 0.0 { - return "\(round(self.transfer.progress * 100.0))% – \(estimate) seconds left" - } - else { - return "\(round(self.transfer.progress * 100.0))% complete" - } - } - return "" - } - var body: some View { HStack(alignment: .center, spacing: 5) { HStack(spacing: 0) { Spacer() - if transfer.isFolder { + if self.transfer.isFolder { Image("Folder") .resizable() .scaledToFit() @@ -467,11 +446,26 @@ struct TransferItemView: View { } .frame(width: 20) - Text(transfer.title) + Text(self.transfer.folderName ?? self.transfer.title) .lineLimit(1) .truncationMode(.middle) - Spacer() + Spacer(minLength: 0) + + if !self.transfer.done { + if self.transfer.progress == 0.0 { + ProgressView() + .progressViewStyle(.linear) + .controlSize(.extraLarge) + .frame(maxWidth: 40) + } + else { + ProgressView(value: self.transfer.progress, total: 1.0) + .progressViewStyle(.linear) + .controlSize(.extraLarge) + .frame(maxWidth: 40) + } + } if self.hovered { Button { @@ -509,19 +503,9 @@ struct TransferItemView: View { .frame(width: 16, height: 16) .opacity(controlActiveState == .inactive ? 0.5 : 1.0) } - else if transfer.progress == 0.0 { - ProgressView() - .progressViewStyle(.circular) - .controlSize(.small) - } - else { - ProgressView(value: transfer.progress, total: 1.0) - .progressViewStyle(.circular) - .controlSize(.small) - } } .onHover { hovered in - withAnimation(.easeOut(duration: 0.25)) { + withAnimation(.snappy(duration: 0.25, extraBounce: 0.3)) { self.hovered = hovered } } @@ -529,9 +513,37 @@ struct TransferItemView: View { guard transfer.completed, let url = transfer.fileURL else { return } - + NSWorkspace.shared.activateFileViewerSelecting([url]) } - .help(formattedProgressHelp()) + .help(self.formattedProgressHelp) + } + + private var formattedProgressHelp: String { + if self.transfer.completed { + return "File transfer complete" + } + else if self.transfer.failed { + return "File transfer failed" + } + else if self.transfer.cancelled { + return "File transfer cancelled" + } + else if self.transfer.progress > 0.0 { + var parts: [String] = [] + + if let speed = self.transfer.displaySpeed { + parts.append(speed) + } + + if let timeRemaining = self.transfer.displayTimeRemaining { + parts.append(timeRemaining) + } + + if parts.count > 0 { + return parts.joined(separator: " • ") + } + } + return "" } } diff --git a/Hotline/macOS/TransfersView.swift b/Hotline/macOS/TransfersView.swift index 5f9357d..9bef02c 100644 --- a/Hotline/macOS/TransfersView.swift +++ b/Hotline/macOS/TransfersView.swift @@ -312,9 +312,17 @@ struct TransferRow: View { .foregroundStyle(.fileComplete) } else { - ProgressView(value: self.transfer.progress, total: 1.0) - .progressViewStyle(.linear) - .controlSize(.large) + if self.transfer.progress == 0 { + ProgressView() + .progressViewStyle(.linear) + .controlSize(.large) + } + else { + ProgressView(value: self.transfer.progress, total: 1.0) + .progressViewStyle(.linear) + .controlSize(.large) + } + } } } @@ -328,17 +336,17 @@ struct TransferRow: View { // Text("\(Int(self.transfer.progress * 100))%") // Speed - if let speed = self.transfer.speed { - Label(self.formatSpeed(speed), systemImage: self.transfer.isUpload ? "arrow.up" : "arrow.down") + if let speed = self.transfer.displaySpeed { + Label(speed, systemImage: self.transfer.isUpload ? "arrow.up" : "arrow.down") } // Time remaining - if let timeRemaining = self.transfer.timeRemaining { - Label(self.formatTimeRemaining(timeRemaining), systemImage: "clock") + if let timeRemaining = self.transfer.displayTimeRemaining { + Label(timeRemaining, systemImage: "clock") } // File size - Label(self.formatSize(self.transfer.size), systemImage: "document") + Label(self.transfer.displaySize, systemImage: "document") } .font(.subheadline) .foregroundStyle(.secondary) @@ -392,36 +400,6 @@ struct TransferRow: View { } } } - - // MARK: - Formatting - - private func formatSize(_ bytes: UInt) -> String { - let formatter = ByteCountFormatter() - formatter.countStyle = .file - formatter.allowedUnits = [.useKB, .useMB, .useGB] - return formatter.string(fromByteCount: Int64(bytes)) - } - - private func formatSpeed(_ bytesPerSecond: Double) -> String { - let formatter = ByteCountFormatter() - formatter.countStyle = .file - formatter.allowedUnits = [.useKB, .useMB, .useGB] - return "\(formatter.string(fromByteCount: Int64(bytesPerSecond)))/s" - } - - private func formatTimeRemaining(_ seconds: TimeInterval) -> String { - if seconds < 60 { - return "\(Int(seconds))s" - } else if seconds < 3600 { - let minutes = Int(seconds / 60) - let secs = Int(seconds.truncatingRemainder(dividingBy: 60)) - return "\(minutes)m \(secs)s" - } else { - let hours = Int(seconds / 3600) - let minutes = Int((seconds.truncatingRemainder(dividingBy: 3600)) / 60) - return "\(hours)h \(minutes)m" - } - } } // MARK: - Preview -- 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/macOS') 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 b58129025413367d8237b3147a9fa96bd4e6924f Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Tue, 11 Nov 2025 17:46:31 -0800 Subject: Fix up File Info sheet. --- Hotline.xcodeproj/project.pbxproj | 12 ++- Hotline/macOS/Files/FileDetailsSheet.swift | 141 +++++++++++++++++++++++++++ Hotline/macOS/Files/FileDetailsView.swift | 147 ----------------------------- Hotline/macOS/Files/FilesView.swift | 35 +------ Hotline/macOS/Files/NewFolderSheet.swift | 32 +++++++ 5 files changed, 183 insertions(+), 184 deletions(-) create mode 100644 Hotline/macOS/Files/FileDetailsSheet.swift delete mode 100644 Hotline/macOS/Files/FileDetailsView.swift create mode 100644 Hotline/macOS/Files/NewFolderSheet.swift (limited to 'Hotline/macOS') diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index 2579dbe..1f9bdef 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -8,7 +8,7 @@ /* Begin PBXBuildFile section */ 11A726082BE0672A000C1DA7 /* FileDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11A726072BE0672A000C1DA7 /* FileDetails.swift */; }; - 11A7260A2BE0675A000C1DA7 /* FileDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11A726092BE06759000C1DA7 /* FileDetailsView.swift */; platformFilters = (macos, ); }; + 11A7260A2BE0675A000C1DA7 /* FileDetailsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11A726092BE06759000C1DA7 /* FileDetailsSheet.swift */; platformFilters = (macos, ); }; 11F8288B2BF9428100216BA0 /* AccountManagerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11F8288A2BF9428100216BA0 /* AccountManagerView.swift */; }; DA0D698D2B1E7CF700C71DF5 /* UsersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0D698C2B1E7CF700C71DF5 /* UsersView.swift */; platformFilter = ios; }; DA0D698F2B1E841600C71DF5 /* MessageBoardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0D698E2B1E841600C71DF5 /* MessageBoardView.swift */; platformFilter = ios; }; @@ -22,6 +22,7 @@ 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 */; }; + DA32F1CC2EC4175F00B243BC /* NewFolderSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32F1CB2EC4175F00B243BC /* NewFolderSheet.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 */; }; @@ -120,7 +121,7 @@ /* Begin PBXFileReference section */ 11A726072BE0672A000C1DA7 /* FileDetails.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileDetails.swift; sourceTree = ""; }; - 11A726092BE06759000C1DA7 /* FileDetailsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileDetailsView.swift; sourceTree = ""; }; + 11A726092BE06759000C1DA7 /* FileDetailsSheet.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileDetailsSheet.swift; sourceTree = ""; }; 11F8288A2BF9428100216BA0 /* AccountManagerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountManagerView.swift; sourceTree = ""; }; DA0D698C2B1E7CF700C71DF5 /* UsersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsersView.swift; sourceTree = ""; }; DA0D698E2B1E841600C71DF5 /* MessageBoardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageBoardView.swift; sourceTree = ""; }; @@ -136,6 +137,7 @@ 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 = ""; }; + DA32F1CB2EC4175F00B243BC /* NewFolderSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewFolderSheet.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 = ""; }; @@ -339,7 +341,8 @@ DAE735002B2E71F2000C56F6 /* FilesView.swift */, DA5268A42EB0743000DCB941 /* FileItemView.swift */, DA5268A22EB0741B00DCB941 /* FolderItemView.swift */, - 11A726092BE06759000C1DA7 /* FileDetailsView.swift */, + DA32F1CB2EC4175F00B243BC /* NewFolderSheet.swift */, + 11A726092BE06759000C1DA7 /* FileDetailsSheet.swift */, DAAEE66E2B47625600A5BA07 /* FilePreviewImageView.swift */, DAB4D87D2B4C8BCA0048A05C /* FilePreviewTextView.swift */, DA3429B82EBAB2130010784E /* FilePreviewQuickLookView.swift */, @@ -678,6 +681,7 @@ DA32CD4F2B2931CC0053B98B /* NewsInfo.swift in Sources */, DA9CAFBB2B126D5700CDA197 /* MacApp.swift in Sources */, DAAEE66B2B3FBC2100A5BA07 /* TransferInfo.swift in Sources */, + DA32F1CC2EC4175F00B243BC /* NewFolderSheet.swift in Sources */, DA0D698F2B1E841600C71DF5 /* MessageBoardView.swift in Sources */, DAE735072B3251B3000C56F6 /* SoundEffects.swift in Sources */, DA55AC752BE4888300034857 /* InstantMessage.swift in Sources */, @@ -686,7 +690,7 @@ DA5268B12EB2708E00DCB941 /* HotlineState.swift in Sources */, DA55AC792BE6A1AD00034857 /* RegularExpressions.swift in Sources */, DA5268A02EB073BC00DCB941 /* SoundSettingsView.swift in Sources */, - 11A7260A2BE0675A000C1DA7 /* FileDetailsView.swift in Sources */, + 11A7260A2BE0675A000C1DA7 /* FileDetailsSheet.swift in Sources */, DA72A0DD2B4CD0BF00A0F48A /* NewsEditorView.swift in Sources */, DA52689C2EB0738B00DCB941 /* GeneralSettingsView.swift in Sources */, DA501BE92EBE9589001714F8 /* TrackerItemView.swift in Sources */, diff --git a/Hotline/macOS/Files/FileDetailsSheet.swift b/Hotline/macOS/Files/FileDetailsSheet.swift new file mode 100644 index 0000000..d798fd4 --- /dev/null +++ b/Hotline/macOS/Files/FileDetailsSheet.swift @@ -0,0 +1,141 @@ +import Foundation +import SwiftUI + +struct FileDetailsSheet: View { + @Environment(HotlineState.self) private var model: HotlineState + @Environment(\.presentationMode) var presentationMode + + var fd: FileDetails + + @State private var comment: String = "" + @State private var filename: String = "" + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .center, spacing: 16){ + FileIconView(filename: fd.name, fileType: nil) + .frame(width: 32, height: 32) + TextField("", 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)) + ] + + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { + ForEach(rows, id: \.0) { label, value in + GridRow { + Text(label) + .font(.body.bold()) + .gridColumnAlignment(.trailing) // right-align label column + Text(value) + .font(.body) + .gridColumnAlignment(.leading) // left-align value column + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 32 + 16) + + TextField(text: self.$comment, prompt: Text("Comments"), axis: .vertical) { + EmptyView() + } + .font(.body) + .lineLimit(10, reservesSpace: true) + .padding(.leading, 32 + 16) + .disabled(!self.canSetComment()) + } + .padding(.vertical, 24) + .padding(.horizontal, 24) + .frame(width: 400) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + presentationMode.wrappedValue.dismiss() + } + } + + ToolbarItem(placement: .primaryAction) { + Button{ + var editedFilename: String? + if filename != fd.name { + editedFilename = filename + } + + var editedComment: String? + if comment != fd.comment { + editedComment = 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 + } label: { + Text("Save") + }.disabled(!isEdited()) + } + } + .onAppear { + self.filename = fd.name + self.comment = fd.comment + } + } + + + static var dateFormatter: DateFormatter = { + var dateFormatter = DateFormatter() + dateFormatter.dateStyle = .long + dateFormatter.timeStyle = .short + + // Original format: Fri, Aug 20, 2021, 5:14:07 PM + return dateFormatter + }() + + static var byteCountSizeFormatter: NumberFormatter = { + let numberFormatter = NumberFormatter() + numberFormatter.numberStyle = .decimal + return numberFormatter + }() + + static let byteFormatter = ByteCountFormatter() + + private func formattedFileSize(_ fileSize: UInt) -> String { + FileItemView.byteFormatter.allowedUnits = [.useAll] + FileItemView.byteFormatter.countStyle = .file + return FileItemView.byteFormatter.string(fromByteCount: Int64(fileSize)) + } + + // Format byte count Int into string like: 23.4M (24,601,664 bytes) + private func formattedSize(byteCount: Int) -> String { + let formattedByteCount = Self.byteCountSizeFormatter.string(from: NSNumber(value:byteCount)) ?? "0" + return "\(FileItemView.byteFormatter.string(fromByteCount: Int64(byteCount))) (\(formattedByteCount) bytes)" + } + + private func isEdited() -> Bool { + return self.filename != fd.name || self.comment != fd.comment + } + + private func canRename() -> Bool { + if self.fd.type == "fldr" { + return model.access?.contains(.canRenameFolders) == true + } + return model.access?.contains(.canRenameFiles) == true + } + + private func canSetComment() -> Bool { + if self.fd.type == "fldr" { + return model.access?.contains(.canSetFolderComment) == true + } + return 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 )) +//} diff --git a/Hotline/macOS/Files/FileDetailsView.swift b/Hotline/macOS/Files/FileDetailsView.swift deleted file mode 100644 index d001df2..0000000 --- a/Hotline/macOS/Files/FileDetailsView.swift +++ /dev/null @@ -1,147 +0,0 @@ -import Foundation -import SwiftUI - -struct FileDetailsView: View { - @Environment(HotlineState.self) private var model: HotlineState - @Environment(\.presentationMode) var presentationMode - - var fd: FileDetails - - @State private var comment: String = "" - @State private var filename: String = "" - - var body: some View { - VStack (alignment: .leading){ - Form { - HStack(alignment: .center){ - FileIconView(filename: fd.name, fileType: nil) - .frame(width: 32, height: 32) - TextField("", text: $filename) - .disabled(!self.canRename()) - } - HStack(alignment: .center){ - Text("Type:").bold().padding(.leading, 43) - Text(fd.type) - } - HStack(alignment: .center){ - Text("Creator:").bold().padding(.leading, 26) - Text(fd.creator) - } - HStack(alignment: .center){ - Text("Size:").bold().bold().padding(.leading, 48) - Text(self.formattedSize(byteCount: fd.size)) - } - HStack(alignment: .center){ - Text("Created:").bold().padding(.leading, 24) - Text("\(FileDetailsView.dateFormatter.string(from: fd.created))") - } - HStack(alignment: .center){ - Text("Modified:").bold().padding(.leading, 19) - Text("\(FileDetailsView.dateFormatter.string(from: fd.modified))") - } - HStack(alignment: .center){ - Text("Comments:").bold().padding(.top, 8) - .padding(.leading, 5) - } - - VStack(alignment: .trailing){ - TextEditor(text: $comment) - .padding(.leading, 2) - .padding(.top, 1) - .font(.system(size: 13)) - .background(Color(nsColor: .textBackgroundColor)) - .border(Color.secondary, width: 1) - .clipShape(RoundedRectangle(cornerRadius: 2, style: .continuous)) - .disabled(!self.canSetComment()) - } - } - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - presentationMode.wrappedValue.dismiss() - } - } - - ToolbarItem(placement: .primaryAction) { - Button{ - var editedFilename: String? - if filename != fd.name { - editedFilename = filename - } - - var editedComment: String? - if comment != fd.comment { - editedComment = 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 - } label: { - Text("Save") - }.disabled(!isEdited()) - } - } - - .onAppear { - self.filename = fd.name - self.comment = fd.comment - } - .padding(EdgeInsets(top: 16, leading: 24, bottom: 16, trailing: 24)) - } - .frame(minWidth: 400, minHeight: 400) - } - - - static var dateFormatter: DateFormatter = { - var dateFormatter = DateFormatter() - dateFormatter.dateStyle = .long - dateFormatter.timeStyle = .short - - // Original format: Fri, Aug 20, 2021, 5:14:07 PM - return dateFormatter - }() - - static var byteCountSizeFormatter: NumberFormatter = { - let numberFormatter = NumberFormatter() - numberFormatter.numberStyle = .decimal - return numberFormatter - }() - - static let byteFormatter = ByteCountFormatter() - - private func formattedFileSize(_ fileSize: UInt) -> String { - FileItemView.byteFormatter.allowedUnits = [.useAll] - FileItemView.byteFormatter.countStyle = .file - return FileItemView.byteFormatter.string(fromByteCount: Int64(fileSize)) - } - - // Format byte count Int into string like: 23.4M (24,601,664 bytes) - private func formattedSize(byteCount: Int) -> String { - let formattedByteCount = FileDetailsView.byteCountSizeFormatter.string(from: NSNumber(value:byteCount)) ?? "0" - return "\(FileItemView.byteFormatter.string(fromByteCount: Int64(byteCount))) (\(formattedByteCount) bytes)" - } - - private func isEdited() -> Bool { - return self.filename != fd.name || self.comment != fd.comment - } - - private func canRename() -> Bool { - if self.fd.type == "fldr" { - return model.access?.contains(.canRenameFolders) == true - } - return model.access?.contains(.canRenameFiles) == true - } - - private func canSetComment() -> Bool { - if self.fd.type == "fldr" { - return model.access?.contains(.canSetFolderComment) == true - } - return 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 )) -//} diff --git a/Hotline/macOS/Files/FilesView.swift b/Hotline/macOS/Files/FilesView.swift index 3732d9a..a9b0b83 100644 --- a/Hotline/macOS/Files/FilesView.swift +++ b/Hotline/macOS/Files/FilesView.swift @@ -2,37 +2,6 @@ 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 @Environment(\.openWindow) private var openWindow @@ -265,8 +234,8 @@ struct FilesView: View { self.newFolder(name: folderName, parent: self.selection) } } - .sheet(item: $fileDetails) { item in - FileDetailsView(fd: item) + .sheet(item: self.$fileDetails) { item in + FileDetailsSheet(fd: item) } .fileImporter(isPresented: $uploadFileSelectorDisplayed, allowedContentTypes: [.data, .folder], allowsMultipleSelection: false, onCompletion: { results in switch results { diff --git a/Hotline/macOS/Files/NewFolderSheet.swift b/Hotline/macOS/Files/NewFolderSheet.swift new file mode 100644 index 0000000..a899f36 --- /dev/null +++ b/Hotline/macOS/Files/NewFolderSheet.swift @@ -0,0 +1,32 @@ +import SwiftUI + +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() + } + } + } + } +} -- cgit From 5a3d06c527cf03d68149449965fade0dd5ea0ed2 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Tue, 11 Nov 2025 20:25:40 -0800 Subject: Fix warning in console about panel not restoring. Add popover for compact transfers list items. Remove push notification entitlement. --- Hotline/Hotline.entitlements | 4 ---- .../Transfers/HotlineFileDownloadClient.swift | 4 +--- Hotline/Info.plist | 10 ++++---- Hotline/Library/Views/HotlinePanel.swift | 5 +++- Hotline/macOS/ServerView.swift | 28 +++++++++++++++++++++- 5 files changed, 38 insertions(+), 13 deletions(-) (limited to 'Hotline/macOS') diff --git a/Hotline/Hotline.entitlements b/Hotline/Hotline.entitlements index e15d27d..074bc56 100644 --- a/Hotline/Hotline.entitlements +++ b/Hotline/Hotline.entitlements @@ -2,10 +2,6 @@ - aps-environment - development - com.apple.developer.aps-environment - development com.apple.developer.icloud-container-identifiers iCloud.co.goodmake.hotline diff --git a/Hotline/Hotline/Transfers/HotlineFileDownloadClient.swift b/Hotline/Hotline/Transfers/HotlineFileDownloadClient.swift index 82f61d4..6441047 100644 --- a/Hotline/Hotline/Transfers/HotlineFileDownloadClient.swift +++ b/Hotline/Hotline/Transfers/HotlineFileDownloadClient.swift @@ -125,9 +125,7 @@ public class HotlineFileDownloadClient: @MainActor HotlineTransferClient { 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)) + destinationURL = URL.downloadsDirectory.resolvingSymlinksInPath().generateUniqueFileURL(filename: filename) destinationFilename = destinationURL.lastPathComponent } diff --git a/Hotline/Info.plist b/Hotline/Info.plist index 82ee6ac..59b1e50 100644 --- a/Hotline/Info.plist +++ b/Hotline/Info.plist @@ -7,11 +7,13 @@ CFBundleTypeName Hotline Bookmark + CFBundleTypeRole + Editor LSHandlerRank Owner LSItemContentTypes - 'HTbm' + 'HTbm' @@ -19,7 +21,7 @@ CFBundleTypeRole - Viewer + Editor CFBundleURLName co.goodmake.hotline CFBundleURLSchemes @@ -29,7 +31,7 @@ CFBundleTypeRole - Viewer + Editor CFBundleURLName co.goodmake.hotline CFBundleURLSchemes @@ -46,7 +48,7 @@ UTTypeIconFiles UTTypeIdentifier - 'HTbm' + 'HTbm' UTTypeTagSpecification public.filename-extension diff --git a/Hotline/Library/Views/HotlinePanel.swift b/Hotline/Library/Views/HotlinePanel.swift index d7d8284..f6d76fc 100644 --- a/Hotline/Library/Views/HotlinePanel.swift +++ b/Hotline/Library/Views/HotlinePanel.swift @@ -22,7 +22,10 @@ class HotlinePanel: NSPanel { // Don't delete panel state when it's closed. self.isReleasedWhenClosed = false - + + // Disable state restoration for this utility panel + self.isRestorable = false + self.standardWindowButton(.closeButton)?.isHidden = false self.standardWindowButton(.zoomButton)?.isHidden = true self.standardWindowButton(.miniaturizeButton)?.isHidden = true diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index 98ab976..399deba 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -425,6 +425,7 @@ struct ServerTransferRow: View { @Environment(HotlineState.self) private var model: HotlineState @State private var hovered: Bool = false @State private var buttonHovered: Bool = false + @State private var detailsShown: Bool = false var body: some View { HStack(alignment: .center, spacing: 5) { @@ -508,6 +509,7 @@ struct ServerTransferRow: View { withAnimation(.snappy(duration: 0.25, extraBounce: 0.3)) { self.hovered = hovered } + self.detailsShown = hovered } .onTapGesture(count: 2) { guard transfer.completed, let url = transfer.fileURL else { @@ -516,7 +518,31 @@ struct ServerTransferRow: View { NSWorkspace.shared.activateFileViewerSelecting([url]) } - .help(self.formattedProgressHelp) + .popover(isPresented: .constant(self.detailsShown && !self.transfer.done), arrowEdge: .trailing) { + let rows: [(String, String)] = [ + ("document", self.transfer.title), + ("info", self.transfer.displaySize), + (self.transfer.isUpload ? "arrow.up" : "arrow.down", self.transfer.displaySpeed ?? "--"), + ("clock", self.transfer.displayTimeRemaining ?? "--") + ] + + Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 8) { + ForEach(rows, id: \.0) { imageName, label in + GridRow { + Image(systemName: imageName) + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + .gridColumnAlignment(.trailing) + Text(label) + .monospacedDigit() + .gridColumnAlignment(.leading) + } + } + } + .frame(minWidth: 200, maxWidth: 350, alignment: .leading) + .padding() + } } private var formattedProgressHelp: String { -- cgit From 5eff79cc4891076d85223dcfd96c7cb8894c80f2 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Wed, 12 Nov 2025 18:55:25 -0800 Subject: Get Accounts out of the sidebar, redesign Account management. --- Hotline.xcodeproj/project.pbxproj | 8 +- Hotline/Hotline/HotlineClient.swift | 42 +- Hotline/Hotline/HotlineExtensions.swift | 44 +- Hotline/Hotline/HotlineProtocol.swift | 6 +- Hotline/Hotline/HotlineTrackerClient.swift | 21 +- Hotline/MacApp.swift | 6 +- Hotline/Models/FileDetails.swift | 4 +- Hotline/State/AppUpdate.swift | 20 +- Hotline/State/ServerState.swift | 7 +- Hotline/macOS/Accounts/AccountManagerView.swift | 686 ++++++++++++------------ Hotline/macOS/Files/FileDetailsSheet.swift | 10 +- Hotline/macOS/Files/FilesView.swift | 26 +- Hotline/macOS/Files/NewFolderPopover.swift | 51 ++ Hotline/macOS/Files/NewFolderSheet.swift | 32 -- Hotline/macOS/HotlinePanelView.swift | 5 +- Hotline/macOS/ServerView.swift | 59 +- Hotline/macOS/TransfersView.swift | 6 +- 17 files changed, 548 insertions(+), 485 deletions(-) create mode 100644 Hotline/macOS/Files/NewFolderPopover.swift delete mode 100644 Hotline/macOS/Files/NewFolderSheet.swift (limited to 'Hotline/macOS') diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index 1f9bdef..7032420 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -22,7 +22,7 @@ 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 */; }; - DA32F1CC2EC4175F00B243BC /* NewFolderSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32F1CB2EC4175F00B243BC /* NewFolderSheet.swift */; }; + DA32F1CC2EC4175F00B243BC /* NewFolderPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32F1CB2EC4175F00B243BC /* NewFolderPopover.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 */; }; @@ -137,7 +137,7 @@ 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 = ""; }; - DA32F1CB2EC4175F00B243BC /* NewFolderSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewFolderSheet.swift; sourceTree = ""; }; + DA32F1CB2EC4175F00B243BC /* NewFolderPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewFolderPopover.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 = ""; }; @@ -341,7 +341,7 @@ DAE735002B2E71F2000C56F6 /* FilesView.swift */, DA5268A42EB0743000DCB941 /* FileItemView.swift */, DA5268A22EB0741B00DCB941 /* FolderItemView.swift */, - DA32F1CB2EC4175F00B243BC /* NewFolderSheet.swift */, + DA32F1CB2EC4175F00B243BC /* NewFolderPopover.swift */, 11A726092BE06759000C1DA7 /* FileDetailsSheet.swift */, DAAEE66E2B47625600A5BA07 /* FilePreviewImageView.swift */, DAB4D87D2B4C8BCA0048A05C /* FilePreviewTextView.swift */, @@ -681,7 +681,7 @@ DA32CD4F2B2931CC0053B98B /* NewsInfo.swift in Sources */, DA9CAFBB2B126D5700CDA197 /* MacApp.swift in Sources */, DAAEE66B2B3FBC2100A5BA07 /* TransferInfo.swift in Sources */, - DA32F1CC2EC4175F00B243BC /* NewFolderSheet.swift in Sources */, + DA32F1CC2EC4175F00B243BC /* NewFolderPopover.swift in Sources */, DA0D698F2B1E841600C71DF5 /* MessageBoardView.swift in Sources */, DAE735072B3251B3000C56F6 /* SoundEffects.swift in Sources */, DA55AC752BE4888300034857 /* InstantMessage.swift in Sources */, diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index 1faaf55..ee99ed7 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -142,10 +142,6 @@ public actor HotlineClient { // Transaction tracking for request/reply pattern private var pendingTransactions: [UInt32: CheckedContinuation] = [:] - private enum TransactionWaitError: Error { - case timeout - } - // Receive loop task private var receiveTask: Task? @@ -427,10 +423,10 @@ public actor HotlineClient { try await self.socket.send(transaction, endian: .big) do { - return try await withTimeout(seconds: timeout) { + return try await Task.withTimeout(seconds: timeout) { try await self.awaitReply(for: transactionID) } - } catch is TransactionWaitError { + } catch is TaskTimeoutError { throw HotlineClientError.timeout } catch let error as HotlineClientError { throw error @@ -467,32 +463,6 @@ public actor HotlineClient { } } - private func withTimeout(seconds: TimeInterval, operation: @escaping @Sendable () async throws -> T) async throws -> T { - if seconds <= 0 { - throw TransactionWaitError.timeout - } - - return try await withThrowingTaskGroup(of: T.self) { group in - group.addTask { - try await operation() - } - - group.addTask { - try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) - throw TransactionWaitError.timeout - } - - do { - let value = try await group.next()! - group.cancelAll() - return value - } catch { - group.cancelAll() - throw error - } - } - } - // MARK: - Keep-Alive private func startKeepAlive() { @@ -841,7 +811,7 @@ public actor HotlineClient { accounts.append(data.getAcccount()) } - accounts.sort { $0.login < $1.login } + accounts.sort { $0.name < $1.name } return accounts } @@ -893,7 +863,11 @@ public actor HotlineClient { // - other: Set new password if password == nil { transaction.setFieldUInt8(type: .userPassword, val: 0) - } else if password != "" { + } + else if password == "" { + // Don't add password to transaction (password will be removed) + } + else { transaction.setFieldEncodedString(type: .userPassword, val: password!) } diff --git a/Hotline/Hotline/HotlineExtensions.swift b/Hotline/Hotline/HotlineExtensions.swift index 6782a41..bcee812 100644 --- a/Hotline/Hotline/HotlineExtensions.swift +++ b/Hotline/Hotline/HotlineExtensions.swift @@ -7,6 +7,8 @@ enum LineEnding { case cr // Classic Mac-style (\r) } +// MARK: - + extension URL { func urlForResourceFork() -> URL { self.appendingPathComponent("..namedfork/rsrc") @@ -31,6 +33,40 @@ extension URL { #endif } +// MARK: - + +public struct TaskTimeoutError: Error {} + +extension Task where Success == Never, Failure == Never { + public static func withTimeout(seconds: TimeInterval, operation: @escaping @Sendable () async throws -> T) async throws -> T { + if seconds <= 0 { + throw TaskTimeoutError() + } + + return try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { + try await operation() + } + + group.addTask { + try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + throw TaskTimeoutError() + } + + do { + let value = try await group.next()! + group.cancelAll() + return value + } catch { + group.cancelAll() + throw error + } + } + } +} + +// MARK: - + extension String { func convertingLineEndings(to targetEnding: LineEnding) -> String { let lf = "\n" @@ -38,16 +74,16 @@ extension String { let cr = "\r" // Normalize all line endings to LF (\n) - let normalizedString = self.replacingOccurrences(of: cr, with: lf).replacingOccurrences(of: crlf, with: lf) + let normalizedString = self.replacing(cr, with: lf).replacing(crlf, with: lf) // Replace normalized LF (\n) line endings with the target line ending switch targetEnding { case .lf: return normalizedString case .crlf: - return normalizedString.replacingOccurrences(of: lf, with: crlf) + return normalizedString.replacing(lf, with: crlf) case .cr: - return normalizedString.replacingOccurrences(of: lf, with: cr) + return normalizedString.replacing(lf, with: cr) } } @@ -67,6 +103,8 @@ extension String { } } +// MARK: - + extension FileManager { static var extensionToHFSCreator: [String: UInt32] = [ // Documents diff --git a/Hotline/Hotline/HotlineProtocol.swift b/Hotline/Hotline/HotlineProtocol.swift index 5fd06dc..4857d87 100644 --- a/Hotline/Hotline/HotlineProtocol.swift +++ b/Hotline/Hotline/HotlineProtocol.swift @@ -233,18 +233,20 @@ public struct HotlineAccount: Identifiable { public let id: UUID = UUID() var name: String = "" var login: String = "" - var password: String? = nil - var persisted: Bool = true + var password: String = "" + var persisted: Bool = false var access: HotlineUserAccessOptions = HotlineUserAccessOptions() var fields: [HotlineTransactionField] = [] init(from data: [UInt8]) { self.decodeFields(from: data) + self.persisted = true } init(_ name: String, _ login: String, _ access: HotlineUserAccessOptions) { self.name = name self.login = login + self.password = HotlineAccount.randomPassword() self.access = access self.persisted = false } diff --git a/Hotline/Hotline/HotlineTrackerClient.swift b/Hotline/Hotline/HotlineTrackerClient.swift index 6a7a5b3..8580cd7 100644 --- a/Hotline/Hotline/HotlineTrackerClient.swift +++ b/Hotline/Hotline/HotlineTrackerClient.swift @@ -57,7 +57,7 @@ class HotlineTrackerClient { private func fetchServersInternal(address: String, port: Int, continuation: AsyncThrowingStream.Continuation) async { do { - try await withTimeout(seconds: 30) { + try await Task.withTimeout(seconds: 30) { try await self.doFetch(address: address, port: port, continuation: continuation) } } catch { @@ -160,23 +160,4 @@ class HotlineTrackerClient { print("HotlineTrackerClient: Completed - parsed \(totalEntriesParsed)/\(totalExpectedEntries) entries, yielded \(totalYielded) servers") continuation.finish() } - - private func withTimeout(seconds: TimeInterval, operation: @escaping () async throws -> T) async throws -> T { - try await withThrowingTaskGroup(of: T.self) { group in - group.addTask { - try await operation() - } - - group.addTask { - try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) - throw NSError(domain: "HotlineTracker", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "Tracker request timed out after \(seconds) seconds" - ]) - } - - let result = try await group.next()! - group.cancelAll() - return result - } - } } diff --git a/Hotline/MacApp.swift b/Hotline/MacApp.swift index 9ac54c1..5a84da8 100644 --- a/Hotline/MacApp.swift +++ b/Hotline/MacApp.swift @@ -277,11 +277,11 @@ struct Application: App { if activeHotline?.access?.contains(.canOpenUsers) == true { Divider() - Button("Accounts") { - activeServerState?.selection = .accounts + Button("Manage Server...") { + activeServerState?.accountsShown = true } .disabled(activeHotline?.status != .loggedIn || activeHotline?.access?.contains(.canOpenUsers) != true ) - .keyboardShortcut(.init("5"), modifiers: .command) +// .keyboardShortcut(.init("5"), modifiers: .command) } } } diff --git a/Hotline/Models/FileDetails.swift b/Hotline/Models/FileDetails.swift index 6164151..71ddb6d 100644 --- a/Hotline/Models/FileDetails.swift +++ b/Hotline/Models/FileDetails.swift @@ -1,7 +1,7 @@ import UniformTypeIdentifiers -public struct FileDetails:Identifiable { - public let id = UUID() +public struct FileDetails: Identifiable { + public let id: UUID = UUID() var name: String var path: [String] var size: Int diff --git a/Hotline/State/AppUpdate.swift b/Hotline/State/AppUpdate.swift index 558a296..83006a2 100644 --- a/Hotline/State/AppUpdate.swift +++ b/Hotline/State/AppUpdate.swift @@ -144,12 +144,13 @@ final class AppUpdate { self.releaseNotesCombined = nil self.isDownloading = false if trigger == .manual { - self.message = AppUpdateMessage( - title: "Hotline is up to date", - detail: "You're running the latest and greatest.", - kind: .success - ) - self.showWindow = true + self.showUpToDateAlert() +// self.message = AppUpdateMessage( +// title: "Hotline is up to date", +// detail: "You're running the latest and greatest.", +// kind: .success +// ) +// self.showWindow = true } else { self.message = nil self.showWindow = false @@ -175,6 +176,13 @@ final class AppUpdate { } } + private func showUpToDateAlert() { + let alert = NSAlert() + alert.messageText = "No Update Available" + alert.informativeText = "You are already using the latest version of Hotline!" + alert.runModal() + } + private func fetchNewerReleases() async throws -> [UpdateReleaseInfo] { let (data, _) = try await URLSession.shared.data(from: releasesURL) guard let jsonArray = try JSONSerialization.jsonObject(with: data, options: []) as? [[String: Any]] else { diff --git a/Hotline/State/ServerState.swift b/Hotline/State/ServerState.swift index 5913bf5..8f8d3bf 100644 --- a/Hotline/State/ServerState.swift +++ b/Hotline/State/ServerState.swift @@ -5,6 +5,7 @@ class ServerState: Equatable { var id: UUID = UUID() var selection: ServerNavigationType var serverName: String? = nil + var accountsShown: Bool = false // var serverBanner: NSImage? = nil // var bannerBackgroundColor: Color? = nil @@ -28,8 +29,8 @@ enum ServerNavigationType: Identifiable, Hashable, Equatable { return "Board" case .files: return "Files" - case .accounts: - return "Accounts" +// case .accounts: +// return "Accounts" case .user(let userID): return String(userID) } @@ -39,6 +40,6 @@ enum ServerNavigationType: Identifiable, Hashable, Equatable { case news case board case files - case accounts +// case accounts case user(userID: UInt16) } diff --git a/Hotline/macOS/Accounts/AccountManagerView.swift b/Hotline/macOS/Accounts/AccountManagerView.swift index c45ba18..bbb909b 100644 --- a/Hotline/macOS/Accounts/AccountManagerView.swift +++ b/Hotline/macOS/Accounts/AccountManagerView.swift @@ -1,402 +1,418 @@ import SwiftUI +fileprivate let DEFAULT_ACCOUNT_NAME = "Untitled Account" +fileprivate let PASSWORD_PLACEHOLDER = "xxxxxxxxxxxxxxxxxx" + struct AccountManagerView: View { @Environment(HotlineState.self) private var model: HotlineState + @Environment(\.dismiss) private var dismiss @State private var accounts: [HotlineAccount] = [] @State private var selection: HotlineAccount? @State private var loading: Bool = true - @State private var pendingName: String = "" - @State private var pendingLogin: String = "" - @State private var pendingPassword: String = "" - @State private var pendingAccess = HotlineUserAccessOptions.defaultAccess + @State private var creatorShown: Bool = false + @State private var deleteConfirm: Bool = false + @State private var accountToEdit: HotlineAccount? = nil + @State private var accountToDelete: HotlineAccount? = nil - @State private var toDelete: HotlineAccount? + private func newAccount() { + self.creatorShown = true + } - let placeholderPassword = "xxxxxxxxxxxxxxxxxx" + private func editAccount(_ account: HotlineAccount) { + // Always get the latest version from the array to avoid stale data + if let currentAccount = self.accounts.first(where: { $0.id == account.id }) { + self.accountToEdit = currentAccount + } + } + + private func deleteAccount(_ account: HotlineAccount) { + self.accountToDelete = account + self.deleteConfirm = true + } var body: some View { - HStack(spacing: 0) { - ZStack { - accountList - if loading { - ProgressView() - } - } - if selection != nil { - accountDetails - } - else { - ZStack(alignment: .center) { - Text("No Account Selected") - .font(.title) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding() + VStack(spacing: 8) { + HStack(alignment: .firstTextBaseline) { + Text("Accounts") + .font(.headline) + + Spacer() + + HStack { + Button { + self.newAccount() + } label: { + Image(systemName: "plus") + .padding(4) + } + .buttonBorderShape(.circle) + .help("New Account") + + Button { + if let account = self.selection { + self.editAccount(account) + } + } label: { + Image(systemName: "pencil") + .padding(4) + } + .buttonBorderShape(.circle) + .disabled(self.selection == nil) + .help("Edit Account") + + Button { + if let account = self.selection { + self.deleteAccount(account) + } + } label: { + Image(systemName: "trash") + .padding(4) + } + .tint(.hotlineRed) + .buttonBorderShape(.circle) + .disabled(self.selection == nil) + .help("Delete Account") } - .frame(maxWidth: .infinity) } + .padding(.horizontal, 16) + .padding(.top, 24) + + self.accountList +// .overlay { +// if self.loading { +// ProgressView() +// .progressViewStyle(.linear) +// .controlSize(.extraLarge) +// .frame(width: 100) +// } +// } } .environment(\.defaultMinListRowHeight, 34) .listStyle(.inset) .alternatingRowBackgrounds(.enabled) .task { - if loading { - accounts = (try? await model.getAccounts()) ?? [] - loading = false + if self.loading { + do { + self.accounts = try await self.model.getAccounts() + } + catch { + self.dismiss() + } + + self.loading = false } } .toolbar { - ToolbarItem(placement: .primaryAction) { - Button { - let newAccount = HotlineAccount("unnamed", "unnamed", HotlineUserAccessOptions.defaultAccess) - - pendingPassword = HotlineAccount.randomPassword() - accounts.append(newAccount) - selection = newAccount - } label: { - Label("New Account", systemImage: "plus") + if self.loading { + ToolbarItem { + ProgressView() + .controlSize(.small) } - .help("Create a new account") - .disabled(model.access?.contains(.canCreateUsers) != true) } - ToolbarItem(placement: .destructiveAction) { + ToolbarItem(placement: .confirmationAction) { Button { - toDelete = selection + self.dismiss() } label: { - Label("Delete Account", systemImage: "trash") + Text("OK") } - .help("Delete account") - .disabled(selection == nil || model.access?.contains(.canDeleteUsers) != true) } } } - var accountDetails: some View { - VStack(alignment: .center, spacing: 0) { - ScrollView(.vertical) { - Form { - Section { - TextField(text: $pendingName) { - Text("Name") - } - TextField("Login", text: $pendingLogin) - .disabled(selection?.persisted == true) - if selection?.persisted == true { - SecureField("Password", text: $pendingPassword) - } else { - TextField("Password", text: $pendingPassword) - } - } - .textFieldStyle(.roundedBorder) - .controlSize(.large) - - Section("File System Maintenance") { - Toggle("Can Download Files", isOn: $pendingAccess.bind(.canDownloadFiles)) - .disabled(model.access?.contains(.canDownloadFiles) == false) - Toggle("Can Download Folders", isOn: $pendingAccess.bind(.canDownloadFolders)) - .disabled(model.access?.contains(.canDownloadFolders) == false) - Toggle("Can Upload Files", isOn: $pendingAccess.bind(.canUploadFiles)) - .disabled(model.access?.contains(.canUploadFiles) == false) - Toggle("Can Upload Folders", isOn: $pendingAccess.bind(.canUploadFolders)) - .disabled(model.access?.contains(.canUploadFolders) == false) - Toggle("Can Upload Anywhere", isOn: $pendingAccess.bind(.canUploadAnywhere)) - .disabled(model.access?.contains(.canUploadAnywhere) == false) - Toggle("Can Delete Files", isOn: $pendingAccess.bind(.canDeleteFiles)) - .disabled(model.access?.contains(.canDeleteFiles) == false) - Toggle("Can Rename Files", isOn: $pendingAccess.bind(.canRenameFiles)) - .disabled(model.access?.contains(.canRenameFiles) == false) - Toggle("Can Move Files", isOn: $pendingAccess.bind(.canMoveFiles)) - .disabled(model.access?.contains(.canMoveFiles) == false) - Toggle("Can Comment Files", isOn: $pendingAccess.bind(.canSetFileComment)) - .disabled(model.access?.contains(.canSetFileComment) == false) - Toggle("Can Create Folders", isOn: $pendingAccess.bind(.canCreateFolders)) - .disabled(model.access?.contains(.canCreateFolders) == false) - Toggle("Can Delete Folders", isOn: $pendingAccess.bind(.canDeleteFolders)) - .disabled(model.access?.contains(.canDeleteFolders) == false) - Toggle("Can Rename Folders", isOn: $pendingAccess.bind(.canRenameFolders)) - .disabled(model.access?.contains(.canRenameFolders) == false) - Toggle("Can Move Folders", isOn: $pendingAccess.bind(.canMoveFolders)) - .disabled(model.access?.contains(.canMoveFolders) == false) - Toggle("Can Comment Folders", isOn: $pendingAccess.bind(.canSetFolderComment)) - .disabled(model.access?.contains(.canSetFolderComment) == false) - Toggle("Can View Drop Boxes", isOn: $pendingAccess.bind(.canViewDropBoxes)) - .disabled(model.access?.contains(.canViewDropBoxes) == false) - Toggle("Can Make Aliases", isOn: $pendingAccess.bind(.canMakeAliases)) - .disabled(model.access?.contains(.canMakeAliases) == false) - } - - Section("User Maintenance") { - Toggle("Can Create Accounts", isOn: $pendingAccess.bind(.canCreateUsers)) - .disabled(model.access?.contains(.canCreateUsers) == false) - Toggle("Can Delete Accounts", isOn: $pendingAccess.bind(.canDeleteUsers)) - .disabled(model.access?.contains(.canDeleteUsers) == false) - Toggle("Can Read Accounts", isOn: $pendingAccess.bind(.canOpenUsers)) - .disabled(model.access?.contains(.canOpenUsers) == false) - Toggle("Can Modify Accounts", isOn: $pendingAccess.bind(.canModifyUsers)) - .disabled(model.access?.contains(.canModifyUsers) == false) - Toggle("Can Get User Info", isOn: $pendingAccess.bind(.canGetClientInfo)) - .disabled(model.access?.contains(.canGetClientInfo) == false) - - Toggle("Can Disconnect Users", isOn: $pendingAccess.bind(.canDisconnectUsers)) - .disabled(model.access?.contains(.canDisconnectUsers) == false) - Toggle("Cannot be Disconnected", isOn: $pendingAccess.bind(.cantBeDisconnected)) - .disabled(model.access?.contains(.cantBeDisconnected) == false) - } - - Section("Messaging") { - Toggle("Can Send Messages", isOn: $pendingAccess.bind(.canSendMessages)) - .disabled(model.access?.contains(.canSendMessages) == false) - Toggle("Can Broadcast", isOn: $pendingAccess.bind(.canBroadcast)) - .disabled(model.access?.contains(.canBroadcast) == false) - } - - Section("News") { - Toggle("Can Read Articles", isOn: $pendingAccess.bind(.canReadMessageBoard)) - .disabled(model.access?.contains(.canReadMessageBoard) == false) - Toggle("Can Post Articles", isOn: $pendingAccess.bind(.canPostMessageBoard)) - .disabled(model.access?.contains(.canPostMessageBoard) == false) - Toggle("Can Delete Articles", isOn: $pendingAccess.bind(.canDeleteNewsArticles)) - .disabled(model.access?.contains(.canDeleteNewsArticles) == false) - Toggle("Can Create Categories", isOn: $pendingAccess.bind(.canCreateNewsCategories)) - .disabled(model.access?.contains(.canCreateNewsCategories) == false) - Toggle("Can Delete Categories", isOn: $pendingAccess.bind(.canDeleteNewsCategories)) - .disabled(model.access?.contains(.canDeleteNewsCategories) == false) - Toggle("Can Create News Bundles", isOn: $pendingAccess.bind(.canCreateNewsFolders)) - .disabled(model.access?.contains(.canCreateNewsFolders) == false) - Toggle("Can Delete News Bundles", isOn: $pendingAccess.bind(.canDeleteNewsFolders)) - .disabled(model.access?.contains(.canDeleteNewsFolders) == false) - } - - Section("Chat") { - Toggle("Can Initiate Private Chat", isOn: $pendingAccess.bind(.canCreateChat)) - .disabled(model.access?.contains(.canCreateChat) == false) - Toggle("Can Read Chat", isOn: $pendingAccess.bind(.canReadChat)) - .disabled(model.access?.contains(.canReadChat) == false) - Toggle("Can Send Chat", isOn: $pendingAccess.bind(.canSendChat)) - .disabled(model.access?.contains(.canSendChat) == false) - } - - Section("Miscellaneous") { - Toggle("Can Use Any Name", isOn: $pendingAccess.bind(.canUseAnyName)) - .disabled(model.access?.contains(.canUseAnyName) == false) - Toggle("Don't Show Agreement", isOn: $pendingAccess.bind(.canSkipAgreement)) - .disabled(model.access?.contains(.canSkipAgreement) == false) - } - } - .disabled(model.access?.contains(.canModifyUsers) == false) - .formStyle(.grouped) - .onChange(of: selection) { - if let selection { - pendingName = selection.name - pendingLogin = selection.login - pendingAccess = selection.access - - if selection.persisted { - if selection.password == nil { - pendingPassword = "" - } else { - pendingPassword = placeholderPassword - } - } - } - } - .onAppear() { - if let selection { - pendingName = selection.name - pendingLogin = selection.login - pendingAccess = selection.access - - if selection.persisted { - if selection.password == nil { - pendingPassword = "" - } else { - pendingPassword = placeholderPassword - } - } else { - pendingPassword = HotlineAccount.randomPassword() - } - } + private var accountList: some View { + List(self.accounts, id: \.self, selection: self.$selection) { account in + HStack(spacing: 5) { + Image(account.access.contains(.canDisconnectUsers) ? "User Admin" : "User") + .frame(width: 16, height: 16) + .opacity((account.access.rawValue == 0) ? 0.5 : 1.0) + Text(account.name) + .foregroundStyle(account.access.contains(.canDisconnectUsers) ? Color.hotlineRed : ((account.access.rawValue == 0) ? Color.secondary : Color.primary)) + + Spacer() + + Text(account.login) + .lineLimit(1) + .foregroundStyle(.secondary) + } + } + .contextMenu(forSelectionType: HotlineAccount.self) { items in + Button { + if let item = items.first { + self.editAccount(item) } + } label: { + Label("Edit Account...", systemImage: "pencil") } - .frame(maxWidth: .infinity) + .disabled(items.isEmpty) Divider() - HStack() { - Button("Revert") { - if let selection { - pendingAccess = selection.access - pendingName = selection.name - pendingLogin = selection.login - - if selection.password != nil { - pendingPassword = selection.password! - } - } + Button(role: .destructive) { + if let item = items.first { + self.deleteAccount(item) } - .controlSize(.large) - .frame(minWidth: 75) - .disabled(!self.isSaveable()) -// .padding() - - Spacer() - - Button(action: { - guard let selection else { - return - } - - // Update existing account - if selection.persisted == true { - - if pendingPassword == placeholderPassword { - Task { @MainActor in - try? await model.setUser(name: pendingName, login: pendingLogin, newLogin: nil, password: nil, access: pendingAccess.rawValue) - } - } else { - Task { @MainActor in - try? await model.setUser(name: pendingName, login: pendingLogin, newLogin: nil, password: pendingPassword, access: pendingAccess.rawValue) - } - } - - } else { - // Create new existing account - Task { @MainActor in - try? await model.createUser(name: pendingName, login: pendingLogin, password: pendingPassword, access: pendingAccess.rawValue) - } - self.selection?.password = pendingPassword - pendingPassword = placeholderPassword - } - - var account = HotlineAccount(pendingName, pendingLogin, pendingAccess) - account.persisted = true - account.password = placeholderPassword - - accounts = accounts.filter { $0.persisted == true && $0.login != selection.login } - - // Add new account to list - accounts.append(account) - - // Re-sort accounts - accounts.sort { $0.login < $1.login } - self.selection = account - }, label: { - Text("Save") - }) - .controlSize(.large) - .frame(minWidth: 75) - .keyboardShortcut(.defaultAction) - .disabled(!self.isSaveable()) + } label: { + Label("Delete Account...", systemImage: "trash") + } + .disabled(items.isEmpty) + } primaryAction: { items in + if let account = items.first { + self.editAccount(account) } - .padding() } - - } - - var accountList: some View { - List(accounts, id: \.self, selection: $selection) { account in - HStack(spacing: 5) { - if account.access.contains(.canDisconnectUsers) { - Image("User Admin") - .frame(width: 16, height: 16) - .opacity(account.persisted ? 1.0 : 0.25) - // .padding(.leading, 4) - Text(account.login) - .foregroundStyle(Color.hotlineRed) + .alert("Are you sure you want to delete the \"\(self.accountToDelete?.name ?? "unknown")\" account?", isPresented: self.$deleteConfirm, actions: { + Button("Delete", role: .destructive) { + guard let account = self.accountToDelete else { + return } - else if account.access.rawValue == 0 { - Image("User") - .frame(width: 16, height: 16) - // .padding(.leading, 4) - Text(account.login) - .foregroundStyle(.secondary) + + self.accountToDelete = nil + + Task { + self.selection = nil + + if account.persisted { + try await self.model.deleteUser(login: account.login) + } + + self.accounts = self.accounts.filter { $0.id != account.id } + self.deleteConfirm = false } - // else if account.persisted == false { - // HStack { - // Image("User") - // .frame(width: 16, height: 16) - //// .padding(.leading, 4) - // Text(account.login) - // .italic() - // } - // } - else { - Image("User") - .frame(width: 16, height: 16) - .opacity(account.persisted ? 1.0 : 0.5) - // .padding(.leading, 4) - Text(account.login) + } + }, message: { + Text("You cannot undo this action.") + }) + .sheet(item: self.$accountToEdit) { account in + AccountDetailsView(account: account) { editedAccount in + if let i = self.accounts.firstIndex(of: editedAccount) { + self.accounts.remove(at: i) + self.accounts.insert(editedAccount, at: i) } + self.accounts.sort { $0.name < $1.name } + self.selection = editedAccount + self.accountToEdit = nil } + .id(account.id) + .environment(self.model) + .frame(width: 480) + .frame(minHeight: 300, idealHeight: 400) + .presentationSizing(.fitted) } - .frame(width: 250) - .sheet(item: $toDelete) { item in - Form { - HStack{ - Image(systemName: "exclamationmark.triangle") - .font(.system(size: 30)) - Text("Delete account \"\(item.name)\" and all associated files?") - .lineSpacing(4) + .sheet(isPresented: self.$creatorShown) { + AccountDetailsView { newAccount in + self.accounts.append(newAccount) + self.accounts.sort { $0.name < $1.name } + self.selection = newAccount + } + .environment(self.model) + .frame(width: 480) + .frame(minHeight: 300, idealHeight: 400) + .presentationSizing(.fitted) + } + } +} + + +struct AccountDetailsView: View { + @Environment(HotlineState.self) private var model: HotlineState + @Environment(\.dismiss) private var dismiss + + @State var account: HotlineAccount = HotlineAccount("Untitled Account", "", HotlineUserAccessOptions.defaultAccess) + + let saved: ((HotlineAccount) -> Void)? + + @State private var password: String = "" + @State private var saving: Bool = false + + var body: some View { + self.accountDetails + .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. + if self.account.persisted { + self.password = PASSWORD_PLACEHOLDER } } - .frame(minWidth: 300, idealWidth: 450, maxWidth: .infinity, minHeight: 100, idealHeight: 100, maxHeight: .infinity) .toolbar { + if self.saving { + ToolbarItem { + ProgressView() + .controlSize(.small) + } + } + ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - toDelete = nil + Button { + self.dismiss() + } label: { + Text("Cancel") } } - ToolbarItem(placement: .primaryAction) { - Button(action: { - guard let userToDelete = toDelete else { - return - } - - self.toDelete = nil - self.selection = nil - - if userToDelete.persisted { - Task { @MainActor in - try? await model.deleteUser(login: userToDelete.login) + ToolbarItem(placement: .confirmationAction) { + Button { + Task { + do { + try await self.save() + self.dismiss() + } + catch { + print("ERROR SAVING ACCOUNT: \(error)") } } - - accounts = accounts.filter { $0.login != userToDelete.login } - - }, label: { - Text("Delete") - }) + } label: { + Text(self.account.persisted ? "Save" : "Create") + } + .disabled(self.saving) } } - } } - - private func isSaveable() -> Bool { - guard let selection else { - return false - } + private func save() async throws { + self.saving = true + defer { self.saving = false } - // Disable save if login field is cleared - if pendingLogin == "" { - return false + var accountName: String = self.account.name + if accountName.isBlank { + accountName = DEFAULT_ACCOUNT_NAME } - // If the account initial has a password and it was updated - if selection.password != nil && pendingPassword != placeholderPassword { - return true + // 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 { + // 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 } - // If the account initial has no password, but was updated to have one - if selection.password == nil && pendingPassword != "" { - return true + self.account.name = accountName + self.saved?(self.account) + } + + var accountDetails: some View { + Form { + Section { + TextField(text: self.$account.name, prompt: Text(DEFAULT_ACCOUNT_NAME)) { + Text("Account") + } + } + + Section { + TextField("Login", text: self.$account.login, prompt: Text("Required")) + .disabled(self.account.persisted) + + if self.account.persisted { + SecureField("Password", text: self.$password, prompt: Text("Optional")) + } else { + TextField("Password", text: self.$password, prompt: Text("Optional")) + } + } + + Section("Files") { + Toggle("Download Files", isOn: self.$account.access.bind(.canDownloadFiles)) + .disabled(self.model.access?.contains(.canDownloadFiles) == false) + Toggle("Download Folders", isOn: self.$account.access.bind(.canDownloadFolders)) + .disabled(model.access?.contains(.canDownloadFolders) == false) + Toggle("Upload Files", isOn: self.$account.access.bind(.canUploadFiles)) + .disabled(model.access?.contains(.canUploadFiles) == false) + Toggle("Upload Folders", isOn: self.$account.access.bind(.canUploadFolders)) + .disabled(model.access?.contains(.canUploadFolders) == false) + Toggle("Upload Anywhere", isOn: self.$account.access.bind(.canUploadAnywhere)) + .disabled(model.access?.contains(.canUploadAnywhere) == false) + Toggle("Delete Files", isOn: self.$account.access.bind(.canDeleteFiles)) + .disabled(model.access?.contains(.canDeleteFiles) == false) + Toggle("Rename Files", isOn: self.$account.access.bind(.canRenameFiles)) + .disabled(model.access?.contains(.canRenameFiles) == false) + Toggle("Move Files", isOn: self.$account.access.bind(.canMoveFiles)) + .disabled(model.access?.contains(.canMoveFiles) == false) + Toggle("Comment Files", isOn: self.$account.access.bind(.canSetFileComment)) + .disabled(model.access?.contains(.canSetFileComment) == false) + Toggle("Create Folders", isOn: self.$account.access.bind(.canCreateFolders)) + .disabled(model.access?.contains(.canCreateFolders) == false) + Toggle("Delete Folders", isOn: self.$account.access.bind(.canDeleteFolders)) + .disabled(model.access?.contains(.canDeleteFolders) == false) + Toggle("Rename Folders", isOn: self.$account.access.bind(.canRenameFolders)) + .disabled(model.access?.contains(.canRenameFolders) == false) + Toggle("Move Folders", isOn: self.$account.access.bind(.canMoveFolders)) + .disabled(model.access?.contains(.canMoveFolders) == false) + Toggle("Comment Folders", isOn: self.$account.access.bind(.canSetFolderComment)) + .disabled(model.access?.contains(.canSetFolderComment) == false) + Toggle("View Drop Boxes", isOn: self.$account.access.bind(.canViewDropBoxes)) + .disabled(model.access?.contains(.canViewDropBoxes) == false) + Toggle("Make Aliases", isOn: self.$account.access.bind(.canMakeAliases)) + .disabled(model.access?.contains(.canMakeAliases) == false) + } + + Section("User Maintenance") { + Toggle("Create Accounts", isOn: self.$account.access.bind(.canCreateUsers)) + .disabled(model.access?.contains(.canCreateUsers) == false) + Toggle("Delete Accounts", isOn: self.$account.access.bind(.canDeleteUsers)) + .disabled(model.access?.contains(.canDeleteUsers) == false) + Toggle("Read Accounts", isOn: self.$account.access.bind(.canOpenUsers)) + .disabled(model.access?.contains(.canOpenUsers) == false) + Toggle("Modify Accounts", isOn: self.$account.access.bind(.canModifyUsers)) + .disabled(model.access?.contains(.canModifyUsers) == false) + Toggle("Get User Info", isOn: self.$account.access.bind(.canGetClientInfo)) + .disabled(model.access?.contains(.canGetClientInfo) == false) + + Toggle("Disconnect Users", isOn: self.$account.access.bind(.canDisconnectUsers)) + .disabled(model.access?.contains(.canDisconnectUsers) == false) + Toggle("Cannot be Disconnected", isOn: self.$account.access.bind(.cantBeDisconnected)) + .disabled(model.access?.contains(.cantBeDisconnected) == false) + } + + Section("Messaging") { + Toggle("Send Messages", isOn: self.$account.access.bind(.canSendMessages)) + .disabled(model.access?.contains(.canSendMessages) == false) + Toggle("Broadcast", isOn: self.$account.access.bind(.canBroadcast)) + .disabled(model.access?.contains(.canBroadcast) == false) + } + + Section("News") { + Toggle("Read Articles", isOn: self.$account.access.bind(.canReadMessageBoard)) + .disabled(model.access?.contains(.canReadMessageBoard) == false) + Toggle("Post Articles", isOn: self.$account.access.bind(.canPostMessageBoard)) + .disabled(model.access?.contains(.canPostMessageBoard) == false) + Toggle("Delete Articles", isOn: self.$account.access.bind(.canDeleteNewsArticles)) + .disabled(model.access?.contains(.canDeleteNewsArticles) == false) + Toggle("Create Categories", isOn: self.$account.access.bind(.canCreateNewsCategories)) + .disabled(model.access?.contains(.canCreateNewsCategories) == false) + Toggle("Delete Categories", isOn: self.$account.access.bind(.canDeleteNewsCategories)) + .disabled(model.access?.contains(.canDeleteNewsCategories) == false) + Toggle("Create News Bundles", isOn: self.$account.access.bind(.canCreateNewsFolders)) + .disabled(model.access?.contains(.canCreateNewsFolders) == false) + Toggle("Delete News Bundles", isOn: self.$account.access.bind(.canDeleteNewsFolders)) + .disabled(model.access?.contains(.canDeleteNewsFolders) == false) + } + + Section("Chat") { + Toggle("Initiate Private Chat", isOn: self.$account.access.bind(.canCreateChat)) + .disabled(model.access?.contains(.canCreateChat) == false) + Toggle("Read Chat", isOn: self.$account.access.bind(.canReadChat)) + .disabled(model.access?.contains(.canReadChat) == false) + Toggle("Send Chat", isOn: self.$account.access.bind(.canSendChat)) + .disabled(model.access?.contains(.canSendChat) == false) + } + + Section("Miscellaneous") { + Toggle("Use Any Name", isOn: self.$account.access.bind(.canUseAnyName)) + .disabled(model.access?.contains(.canUseAnyName) == false) + Toggle("Don't Show Agreement", isOn: self.$account.access.bind(.canSkipAgreement)) + .disabled(model.access?.contains(.canSkipAgreement) == false) + } } - - // If the access bits or user name have been changed - return pendingAccess.rawValue != selection.access.rawValue || selection.name != pendingName + .disabled(self.model.access?.contains(.canModifyUsers) == false) + .formStyle(.grouped) } } diff --git a/Hotline/macOS/Files/FileDetailsSheet.swift b/Hotline/macOS/Files/FileDetailsSheet.swift index d798fd4..2118518 100644 --- a/Hotline/macOS/Files/FileDetailsSheet.swift +++ b/Hotline/macOS/Files/FileDetailsSheet.swift @@ -13,8 +13,14 @@ struct FileDetailsSheet: View { var body: some View { VStack(alignment: .leading, spacing: 16) { HStack(alignment: .center, spacing: 16){ - FileIconView(filename: fd.name, fileType: nil) - .frame(width: 32, height: 32) + if self.fd.type == "Folder" { + FolderIconView() + .frame(width: 32, height: 32) + } + else { + FileIconView(filename: fd.name, fileType: nil) + .frame(width: 32, height: 32) + } TextField("", text: $filename) .disabled(!self.canRename()) } diff --git a/Hotline/macOS/Files/FilesView.swift b/Hotline/macOS/Files/FilesView.swift index a9b0b83..2386d2a 100644 --- a/Hotline/macOS/Files/FilesView.swift +++ b/Hotline/macOS/Files/FilesView.swift @@ -12,8 +12,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 + @State private var confirmDeleteShown: Bool = false + @State private var newFolderShown: Bool = false var body: some View { NavigationStack { @@ -96,7 +96,7 @@ struct FilesView: View { Divider() Button { - self.deleteConfirmationDisplayed = true + self.confirmDeleteShown = true } label: { Label("Delete...", systemImage: "trash") } @@ -200,16 +200,21 @@ struct FilesView: View { ToolbarItem { Button { - self.newFolderSheetDisplayed = true + 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.deleteConfirmationDisplayed = true + self.confirmDeleteShown = true } label: { Label("Delete", systemImage: "trash") } @@ -218,7 +223,7 @@ struct FilesView: View { } } } - .alert("Are you sure you want to permanently delete \"\(self.selection?.name ?? "this file")\"?", isPresented: self.$deleteConfirmationDisplayed, actions: { + .alert("Are you sure you want to permanently delete \"\(self.selection?.name ?? "this file")\"?", isPresented: self.$confirmDeleteShown, actions: { Button("Delete", role: .destructive) { if let s = self.selection { Task { @@ -229,11 +234,6 @@ struct FilesView: View { }, message: { Text("You cannot undo this action.") }) - .sheet(isPresented: self.$newFolderSheetDisplayed) { - NewFolderSheet { folderName in - self.newFolder(name: folderName, parent: self.selection) - } - } .sheet(item: self.$fileDetails) { item in FileDetailsSheet(fd: item) } @@ -441,9 +441,7 @@ struct FilesView: View { @MainActor private func getFileInfo(_ file: FileInfo) { Task { if let fileInfo = try? await model.getFileDetails(file.name, path: file.path) { - Task { @MainActor in - self.fileDetails = fileInfo - } + self.fileDetails = fileInfo } } } diff --git a/Hotline/macOS/Files/NewFolderPopover.swift b/Hotline/macOS/Files/NewFolderPopover.swift new file mode 100644 index 0000000..4e282f5 --- /dev/null +++ b/Hotline/macOS/Files/NewFolderPopover.swift @@ -0,0 +1,51 @@ +import SwiftUI + +struct NewFolderPopover: View { + @Environment(\.dismiss) private var dismiss + + let action: ((String) -> Void)? + + @State private var folderName: String = "Untitled Folder" + + var body: some View { + VStack(spacing: 16) { + TextField("Folder Name", text: self.$folderName) + .onSubmit(of: .text) { + self.createFolder() + } + + HStack(spacing: 8) { + Spacer() + + Button("Cancel", role: .cancel) { + self.dismiss() + } + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + + if #available(macOS 26.0, *) { + Button("New Folder", role: .confirm) { + self.createFolder() + } + .buttonStyle(.borderedProminent) + .buttonBorderShape(.capsule) + } + else { + Button("OK") { + self.dismiss() + self.action?(self.folderName) + } + .buttonStyle(.borderedProminent) + .buttonBorderShape(.capsule) + } + } + } + .frame(width: 250) + .padding() + } + + private func createFolder() { + self.dismiss() + self.action?(self.folderName) + } +} diff --git a/Hotline/macOS/Files/NewFolderSheet.swift b/Hotline/macOS/Files/NewFolderSheet.swift deleted file mode 100644 index a899f36..0000000 --- a/Hotline/macOS/Files/NewFolderSheet.swift +++ /dev/null @@ -1,32 +0,0 @@ -import SwiftUI - -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() - } - } - } - } -} diff --git a/Hotline/macOS/HotlinePanelView.swift b/Hotline/macOS/HotlinePanelView.swift index a56cd44..6443366 100644 --- a/Hotline/macOS/HotlinePanelView.swift +++ b/Hotline/macOS/HotlinePanelView.swift @@ -114,7 +114,8 @@ struct HotlinePanelView: View { if self.activeHotline?.access?.contains(.canOpenUsers) == true { Button { - self.activeServerState?.selection = .accounts +// self.activeServerState?.selection = .accounts + self.activeServerState?.accountsShown = true } label: { Image("Section Users") @@ -124,7 +125,7 @@ struct HotlinePanelView: View { .buttonStyle(.plain) .frame(width: 20, height: 20) .disabled(self.activeServerState == nil) - .help("Administration") + .help("Manage Server") } Button { diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index 399deba..2fddee6 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -87,13 +87,14 @@ struct ServerView: View { @State private var connectLogin: String = "" @State private var connectPassword: String = "" @State private var connectionDisplayed: Bool = false +// @State private var accountsShown: 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: "Accounts", image: "Section Users"), ] static var classicMenuItems: [ServerMenuItem] = [ @@ -110,7 +111,7 @@ struct ServerView: View { self.connectForm Spacer() } - .navigationTitle("Connect to Server") + .navigationTitle("New Connection") } else if self.model.status.isLoggingIn { HStack { @@ -130,7 +131,7 @@ struct ServerView: View { } .frame(maxWidth: 300) .padding() - .navigationTitle("Connecting to Server") + .navigationTitle("New Connection") } else if self.model.status == .loggedIn { self.serverView @@ -153,6 +154,12 @@ struct ServerView: View { .onChange(of: Prefs.shared.automaticMessage) { Task { try? await self.model.sendUserPreferences() } } + .sheet(isPresented: self.$state.accountsShown) { + AccountManagerView() + .environment(self.model) + .frame(width: 400, height: 450) + .presentationSizing(.fitted) + } .toolbar { if #available(macOS 26.0, *) { ToolbarItem(placement: .navigation) { @@ -232,11 +239,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 == .accounts { - if model.access?.contains(.canOpenUsers) == 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) +// } +// } else if menuItem.type == .files { ListItemView(icon: menuItem.image, title: menuItem.name, unread: false).tag(menuItem.type) .overlay(alignment: .trailing) { @@ -320,39 +327,51 @@ struct ServerView: View { NavigationSplitView { self.navigationList .frame(maxWidth: .infinity) - .navigationSplitViewColumnWidth(min: 150, ideal: 200, max: 500) + .navigationSplitViewColumnWidth(min: 200, ideal: 250, max: 500) + .toolbar { + if self.model.access?.contains(.canOpenUsers) == true { + ToolbarItem { + Button { + self.state.accountsShown = true + } label: { + Label("Manage Server", systemImage: "gear") + } + .help("Manage Server") + } + } + } } detail: { switch state.selection { case .chat: ChatView() .navigationTitle(model.serverTitle) - .navigationSubtitle("Public Chat") +// .navigationSubtitle("Public Chat") .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .news: NewsView() .navigationTitle(model.serverTitle) - .navigationSubtitle("Newsgroups") +// .navigationSubtitle("Newsgroups") .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .board: MessageBoardView() .navigationTitle(model.serverTitle) - .navigationSubtitle("Message Board") +// .navigationSubtitle("Message Board") .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .files: FilesView() .navigationTitle(model.serverTitle) - .navigationSubtitle("Shared Files") +// .navigationSubtitle("Shared Files") .navigationSplitViewColumnWidth(min: 250, ideal: 500) - case .accounts: - AccountManagerView() - .navigationTitle(model.serverTitle) - .navigationSubtitle("Accounts") - .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 }) +// let user = model.users.first(where: { $0.id == userID }) MessageView(userID: userID) .navigationTitle(model.serverTitle) - .navigationSubtitle(user?.name ?? "Private Message") +// .navigationSubtitle(user?.name ?? "Private Message") .navigationSplitViewColumnWidth(min: 250, ideal: 500) .onAppear { model.markInstantMessagesAsRead(userID: userID) diff --git a/Hotline/macOS/TransfersView.swift b/Hotline/macOS/TransfersView.swift index 9bef02c..1fefe5a 100644 --- a/Hotline/macOS/TransfersView.swift +++ b/Hotline/macOS/TransfersView.swift @@ -163,7 +163,7 @@ struct TransfersView: View { let result: [(name: String, url: URL)] = intersection.compactMap { url in let appName = FileManager.default .displayName(atPath: url.path) - .replacingOccurrences(of: ".app", with: "") + .replacing(".app", with: "") return (name: appName, url: url) }.sorted { $0.name < $1.name } @@ -178,7 +178,7 @@ struct TransfersView: View { if fileURLs.count == 1, let url = NSWorkspace.shared.urlForApplication(toOpen: fileURLs[0]) { let name = FileManager.default .displayName(atPath: url.path) - .replacingOccurrences(of: ".app", with: "") + .replacing(".app", with: "") return (name, url) } @@ -213,7 +213,7 @@ struct TransfersView: View { defaultCounts[bestByMajority, default: 0] > 0 { let name = FileManager.default .displayName(atPath: bestByMajority.path) - .replacingOccurrences(of: ".app", with: "") + .replacing(".app", with: "") return (name, bestByMajority) } -- cgit From 79a37fa509576c94b96ff896550426d8ebfdec67 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Wed, 12 Nov 2025 19:52:08 -0800 Subject: Update to latest project settings. Also split up Account management views. --- Hotline.xcodeproj/project.pbxproj | 20 +- .../xcshareddata/xcschemes/Hotline.xcscheme | 2 +- Hotline/Hotline.entitlements | 10 - Hotline/macOS/Accounts/AccountDetailsView.swift | 204 +++++++++++++++++++++ Hotline/macOS/Accounts/AccountManagerView.swift | 203 +------------------- 5 files changed, 223 insertions(+), 216 deletions(-) create mode 100644 Hotline/macOS/Accounts/AccountDetailsView.swift (limited to 'Hotline/macOS') diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index 7032420..28b30f8 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -88,6 +88,7 @@ DAB4D8822B4C8FED0048A05C /* FileIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAB4D8812B4C8FED0048A05C /* FileIconView.swift */; }; 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 */; }; 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 */; }; @@ -201,6 +202,7 @@ DAB4D87D2B4C8BCA0048A05C /* FilePreviewTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePreviewTextView.swift; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; @@ -363,6 +365,7 @@ isa = PBXGroup; children = ( 11F8288A2BF9428100216BA0 /* AccountManagerView.swift */, + DABAFBE92EC58C170015E889 /* AccountDetailsView.swift */, ); path = Accounts; sourceTree = ""; @@ -579,7 +582,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1500; - LastUpgradeCheck = 1500; + LastUpgradeCheck = 2600; TargetAttributes = { DA9CAFB62B126D5700CDA197 = { CreatedOnToolsVersion = 15.0.1; @@ -642,6 +645,7 @@ DA52689E2EB073A400DCB941 /* IconSettingsView.swift in Sources */, DAC3D9832BC33FD000A727C9 /* AppState.swift in Sources */, DA65499C2BEC3FBD00EDB697 /* ServerAgreementView.swift in Sources */, + DABAFBEA2EC58C170015E889 /* AccountDetailsView.swift in Sources */, DA5268BC2EB95A7700DCB941 /* HotlineFolderDownloadClient.swift in Sources */, DA2863DD2B3E8B7000A7D050 /* FilePreview.swift in Sources */, DA3429B02EBA70790010784E /* HotlineFolderUploadClient.swift in Sources */, @@ -772,6 +776,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = 5AEEV7QB2U; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -795,6 +800,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; @@ -835,6 +841,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 5AEEV7QB2U; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -851,6 +858,7 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; VALIDATE_PRODUCT = YES; }; @@ -866,9 +874,12 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 24; DEVELOPMENT_ASSET_PATHS = "\"Hotline/Preview Content\""; - DEVELOPMENT_TEAM = 5AEEV7QB2U; + ENABLE_APP_SANDBOX = YES; + ENABLE_FILE_ACCESS_DOWNLOADS_FOLDER = readwrite; "ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = YES; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_PREVIEWS = YES; + ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Hotline/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Hotline; @@ -903,9 +914,12 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 24; DEVELOPMENT_ASSET_PATHS = "\"Hotline/Preview Content\""; - DEVELOPMENT_TEAM = 5AEEV7QB2U; + ENABLE_APP_SANDBOX = YES; + ENABLE_FILE_ACCESS_DOWNLOADS_FOLDER = readwrite; "ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = YES; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_PREVIEWS = YES; + ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Hotline/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Hotline; diff --git a/Hotline.xcodeproj/xcshareddata/xcschemes/Hotline.xcscheme b/Hotline.xcodeproj/xcshareddata/xcschemes/Hotline.xcscheme index 91934ce..0fe9b50 100644 --- a/Hotline.xcodeproj/xcshareddata/xcschemes/Hotline.xcscheme +++ b/Hotline.xcodeproj/xcshareddata/xcschemes/Hotline.xcscheme @@ -1,6 +1,6 @@ CloudKit - com.apple.security.app-sandbox - - com.apple.security.files.downloads.read-write - - com.apple.security.files.user-selected.read-only - - com.apple.security.files.user-selected.read-write - - com.apple.security.network.client - diff --git a/Hotline/macOS/Accounts/AccountDetailsView.swift b/Hotline/macOS/Accounts/AccountDetailsView.swift new file mode 100644 index 0000000..d4eab4e --- /dev/null +++ b/Hotline/macOS/Accounts/AccountDetailsView.swift @@ -0,0 +1,204 @@ +import SwiftUI + +fileprivate let PASSWORD_PLACEHOLDER = "xxxxxxxxxxxxxxxxxx" + +struct AccountDetailsView: View { + @Environment(HotlineState.self) private var model: HotlineState + @Environment(\.dismiss) private var dismiss + + @State var account: HotlineAccount = HotlineAccount(DEFAULT_ACCOUNT_NAME, "", HotlineUserAccessOptions.defaultAccess) + + let saved: ((HotlineAccount) -> Void)? + + @State private var password: String = "" + @State private var saving: Bool = false + + var body: some View { + self.accountDetails + .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. + if self.account.persisted { + self.password = PASSWORD_PLACEHOLDER + } + } + .toolbar { + if self.saving { + ToolbarItem { + ProgressView() + .controlSize(.small) + } + } + + ToolbarItem(placement: .cancellationAction) { + Button { + self.dismiss() + } label: { + Text("Cancel") + } + } + + ToolbarItem(placement: .confirmationAction) { + Button { + Task { + do { + try await self.save() + self.dismiss() + } + catch { + print("ERROR SAVING ACCOUNT: \(error)") + } + } + } label: { + Text(self.account.persisted ? "Save" : "Create") + } + .disabled(self.saving) + } + } + } + + private func save() async throws { + self.saving = true + defer { self.saving = false } + + 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) + } else { + try await model.setUser(name: accountName, login: self.account.login, newLogin: nil, 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 + } + + self.account.name = accountName + self.saved?(self.account) + } + + var accountDetails: some View { + Form { + Section { + TextField(text: self.$account.name, prompt: Text(DEFAULT_ACCOUNT_NAME)) { + Text("Account") + } + } + + Section { + TextField("Login", text: self.$account.login, prompt: Text("Required")) + .disabled(self.account.persisted) + + if self.account.persisted { + SecureField("Password", text: self.$password, prompt: Text("Optional")) + } else { + TextField("Password", text: self.$password, prompt: Text("Optional")) + } + } + + Section("Files") { + Toggle("Download Files", isOn: self.$account.access.bind(.canDownloadFiles)) + .disabled(self.model.access?.contains(.canDownloadFiles) == false) + Toggle("Download Folders", isOn: self.$account.access.bind(.canDownloadFolders)) + .disabled(model.access?.contains(.canDownloadFolders) == false) + Toggle("Upload Files", isOn: self.$account.access.bind(.canUploadFiles)) + .disabled(model.access?.contains(.canUploadFiles) == false) + Toggle("Upload Folders", isOn: self.$account.access.bind(.canUploadFolders)) + .disabled(model.access?.contains(.canUploadFolders) == false) + Toggle("Upload Anywhere", isOn: self.$account.access.bind(.canUploadAnywhere)) + .disabled(model.access?.contains(.canUploadAnywhere) == false) + Toggle("Delete Files", isOn: self.$account.access.bind(.canDeleteFiles)) + .disabled(model.access?.contains(.canDeleteFiles) == false) + Toggle("Rename Files", isOn: self.$account.access.bind(.canRenameFiles)) + .disabled(model.access?.contains(.canRenameFiles) == false) + Toggle("Move Files", isOn: self.$account.access.bind(.canMoveFiles)) + .disabled(model.access?.contains(.canMoveFiles) == false) + Toggle("Comment Files", isOn: self.$account.access.bind(.canSetFileComment)) + .disabled(model.access?.contains(.canSetFileComment) == false) + Toggle("Create Folders", isOn: self.$account.access.bind(.canCreateFolders)) + .disabled(model.access?.contains(.canCreateFolders) == false) + Toggle("Delete Folders", isOn: self.$account.access.bind(.canDeleteFolders)) + .disabled(model.access?.contains(.canDeleteFolders) == false) + Toggle("Rename Folders", isOn: self.$account.access.bind(.canRenameFolders)) + .disabled(model.access?.contains(.canRenameFolders) == false) + Toggle("Move Folders", isOn: self.$account.access.bind(.canMoveFolders)) + .disabled(model.access?.contains(.canMoveFolders) == false) + Toggle("Comment Folders", isOn: self.$account.access.bind(.canSetFolderComment)) + .disabled(model.access?.contains(.canSetFolderComment) == false) + Toggle("View Drop Boxes", isOn: self.$account.access.bind(.canViewDropBoxes)) + .disabled(model.access?.contains(.canViewDropBoxes) == false) + Toggle("Make Aliases", isOn: self.$account.access.bind(.canMakeAliases)) + .disabled(model.access?.contains(.canMakeAliases) == false) + } + + Section("User Maintenance") { + Toggle("Create Accounts", isOn: self.$account.access.bind(.canCreateUsers)) + .disabled(model.access?.contains(.canCreateUsers) == false) + Toggle("Delete Accounts", isOn: self.$account.access.bind(.canDeleteUsers)) + .disabled(model.access?.contains(.canDeleteUsers) == false) + Toggle("Read Accounts", isOn: self.$account.access.bind(.canOpenUsers)) + .disabled(model.access?.contains(.canOpenUsers) == false) + Toggle("Modify Accounts", isOn: self.$account.access.bind(.canModifyUsers)) + .disabled(model.access?.contains(.canModifyUsers) == false) + Toggle("Get User Info", isOn: self.$account.access.bind(.canGetClientInfo)) + .disabled(model.access?.contains(.canGetClientInfo) == false) + + Toggle("Disconnect Users", isOn: self.$account.access.bind(.canDisconnectUsers)) + .disabled(model.access?.contains(.canDisconnectUsers) == false) + Toggle("Cannot be Disconnected", isOn: self.$account.access.bind(.cantBeDisconnected)) + .disabled(model.access?.contains(.cantBeDisconnected) == false) + } + + Section("Messaging") { + Toggle("Send Messages", isOn: self.$account.access.bind(.canSendMessages)) + .disabled(model.access?.contains(.canSendMessages) == false) + Toggle("Broadcast", isOn: self.$account.access.bind(.canBroadcast)) + .disabled(model.access?.contains(.canBroadcast) == false) + } + + Section("News") { + Toggle("Read Articles", isOn: self.$account.access.bind(.canReadMessageBoard)) + .disabled(model.access?.contains(.canReadMessageBoard) == false) + Toggle("Post Articles", isOn: self.$account.access.bind(.canPostMessageBoard)) + .disabled(model.access?.contains(.canPostMessageBoard) == false) + Toggle("Delete Articles", isOn: self.$account.access.bind(.canDeleteNewsArticles)) + .disabled(model.access?.contains(.canDeleteNewsArticles) == false) + Toggle("Create Categories", isOn: self.$account.access.bind(.canCreateNewsCategories)) + .disabled(model.access?.contains(.canCreateNewsCategories) == false) + Toggle("Delete Categories", isOn: self.$account.access.bind(.canDeleteNewsCategories)) + .disabled(model.access?.contains(.canDeleteNewsCategories) == false) + Toggle("Create News Bundles", isOn: self.$account.access.bind(.canCreateNewsFolders)) + .disabled(model.access?.contains(.canCreateNewsFolders) == false) + Toggle("Delete News Bundles", isOn: self.$account.access.bind(.canDeleteNewsFolders)) + .disabled(model.access?.contains(.canDeleteNewsFolders) == false) + } + + Section("Chat") { + Toggle("Initiate Private Chat", isOn: self.$account.access.bind(.canCreateChat)) + .disabled(model.access?.contains(.canCreateChat) == false) + Toggle("Read Chat", isOn: self.$account.access.bind(.canReadChat)) + .disabled(model.access?.contains(.canReadChat) == false) + Toggle("Send Chat", isOn: self.$account.access.bind(.canSendChat)) + .disabled(model.access?.contains(.canSendChat) == false) + } + + Section("Miscellaneous") { + Toggle("Use Any Name", isOn: self.$account.access.bind(.canUseAnyName)) + .disabled(model.access?.contains(.canUseAnyName) == false) + Toggle("Don't Show Agreement", isOn: self.$account.access.bind(.canSkipAgreement)) + .disabled(model.access?.contains(.canSkipAgreement) == false) + } + } + .disabled(self.model.access?.contains(.canModifyUsers) == false) + .formStyle(.grouped) + } +} diff --git a/Hotline/macOS/Accounts/AccountManagerView.swift b/Hotline/macOS/Accounts/AccountManagerView.swift index bbb909b..d288196 100644 --- a/Hotline/macOS/Accounts/AccountManagerView.swift +++ b/Hotline/macOS/Accounts/AccountManagerView.swift @@ -1,7 +1,6 @@ import SwiftUI -fileprivate let DEFAULT_ACCOUNT_NAME = "Untitled Account" -fileprivate let PASSWORD_PLACEHOLDER = "xxxxxxxxxxxxxxxxxx" +let DEFAULT_ACCOUNT_NAME = "Untitled Account" struct AccountManagerView: View { @Environment(HotlineState.self) private var model: HotlineState @@ -216,203 +215,3 @@ struct AccountManagerView: View { } -struct AccountDetailsView: View { - @Environment(HotlineState.self) private var model: HotlineState - @Environment(\.dismiss) private var dismiss - - @State var account: HotlineAccount = HotlineAccount("Untitled Account", "", HotlineUserAccessOptions.defaultAccess) - - let saved: ((HotlineAccount) -> Void)? - - @State private var password: String = "" - @State private var saving: Bool = false - - var body: some View { - self.accountDetails - .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. - if self.account.persisted { - self.password = PASSWORD_PLACEHOLDER - } - } - .toolbar { - if self.saving { - ToolbarItem { - ProgressView() - .controlSize(.small) - } - } - - ToolbarItem(placement: .cancellationAction) { - Button { - self.dismiss() - } label: { - Text("Cancel") - } - } - - ToolbarItem(placement: .confirmationAction) { - Button { - Task { - do { - try await self.save() - self.dismiss() - } - catch { - print("ERROR SAVING ACCOUNT: \(error)") - } - } - } label: { - Text(self.account.persisted ? "Save" : "Create") - } - .disabled(self.saving) - } - } - } - - private func save() async throws { - self.saving = true - defer { self.saving = false } - - 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) - } else { - try await model.setUser(name: accountName, login: self.account.login, newLogin: nil, 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 - } - - self.account.name = accountName - self.saved?(self.account) - } - - var accountDetails: some View { - Form { - Section { - TextField(text: self.$account.name, prompt: Text(DEFAULT_ACCOUNT_NAME)) { - Text("Account") - } - } - - Section { - TextField("Login", text: self.$account.login, prompt: Text("Required")) - .disabled(self.account.persisted) - - if self.account.persisted { - SecureField("Password", text: self.$password, prompt: Text("Optional")) - } else { - TextField("Password", text: self.$password, prompt: Text("Optional")) - } - } - - Section("Files") { - Toggle("Download Files", isOn: self.$account.access.bind(.canDownloadFiles)) - .disabled(self.model.access?.contains(.canDownloadFiles) == false) - Toggle("Download Folders", isOn: self.$account.access.bind(.canDownloadFolders)) - .disabled(model.access?.contains(.canDownloadFolders) == false) - Toggle("Upload Files", isOn: self.$account.access.bind(.canUploadFiles)) - .disabled(model.access?.contains(.canUploadFiles) == false) - Toggle("Upload Folders", isOn: self.$account.access.bind(.canUploadFolders)) - .disabled(model.access?.contains(.canUploadFolders) == false) - Toggle("Upload Anywhere", isOn: self.$account.access.bind(.canUploadAnywhere)) - .disabled(model.access?.contains(.canUploadAnywhere) == false) - Toggle("Delete Files", isOn: self.$account.access.bind(.canDeleteFiles)) - .disabled(model.access?.contains(.canDeleteFiles) == false) - Toggle("Rename Files", isOn: self.$account.access.bind(.canRenameFiles)) - .disabled(model.access?.contains(.canRenameFiles) == false) - Toggle("Move Files", isOn: self.$account.access.bind(.canMoveFiles)) - .disabled(model.access?.contains(.canMoveFiles) == false) - Toggle("Comment Files", isOn: self.$account.access.bind(.canSetFileComment)) - .disabled(model.access?.contains(.canSetFileComment) == false) - Toggle("Create Folders", isOn: self.$account.access.bind(.canCreateFolders)) - .disabled(model.access?.contains(.canCreateFolders) == false) - Toggle("Delete Folders", isOn: self.$account.access.bind(.canDeleteFolders)) - .disabled(model.access?.contains(.canDeleteFolders) == false) - Toggle("Rename Folders", isOn: self.$account.access.bind(.canRenameFolders)) - .disabled(model.access?.contains(.canRenameFolders) == false) - Toggle("Move Folders", isOn: self.$account.access.bind(.canMoveFolders)) - .disabled(model.access?.contains(.canMoveFolders) == false) - Toggle("Comment Folders", isOn: self.$account.access.bind(.canSetFolderComment)) - .disabled(model.access?.contains(.canSetFolderComment) == false) - Toggle("View Drop Boxes", isOn: self.$account.access.bind(.canViewDropBoxes)) - .disabled(model.access?.contains(.canViewDropBoxes) == false) - Toggle("Make Aliases", isOn: self.$account.access.bind(.canMakeAliases)) - .disabled(model.access?.contains(.canMakeAliases) == false) - } - - Section("User Maintenance") { - Toggle("Create Accounts", isOn: self.$account.access.bind(.canCreateUsers)) - .disabled(model.access?.contains(.canCreateUsers) == false) - Toggle("Delete Accounts", isOn: self.$account.access.bind(.canDeleteUsers)) - .disabled(model.access?.contains(.canDeleteUsers) == false) - Toggle("Read Accounts", isOn: self.$account.access.bind(.canOpenUsers)) - .disabled(model.access?.contains(.canOpenUsers) == false) - Toggle("Modify Accounts", isOn: self.$account.access.bind(.canModifyUsers)) - .disabled(model.access?.contains(.canModifyUsers) == false) - Toggle("Get User Info", isOn: self.$account.access.bind(.canGetClientInfo)) - .disabled(model.access?.contains(.canGetClientInfo) == false) - - Toggle("Disconnect Users", isOn: self.$account.access.bind(.canDisconnectUsers)) - .disabled(model.access?.contains(.canDisconnectUsers) == false) - Toggle("Cannot be Disconnected", isOn: self.$account.access.bind(.cantBeDisconnected)) - .disabled(model.access?.contains(.cantBeDisconnected) == false) - } - - Section("Messaging") { - Toggle("Send Messages", isOn: self.$account.access.bind(.canSendMessages)) - .disabled(model.access?.contains(.canSendMessages) == false) - Toggle("Broadcast", isOn: self.$account.access.bind(.canBroadcast)) - .disabled(model.access?.contains(.canBroadcast) == false) - } - - Section("News") { - Toggle("Read Articles", isOn: self.$account.access.bind(.canReadMessageBoard)) - .disabled(model.access?.contains(.canReadMessageBoard) == false) - Toggle("Post Articles", isOn: self.$account.access.bind(.canPostMessageBoard)) - .disabled(model.access?.contains(.canPostMessageBoard) == false) - Toggle("Delete Articles", isOn: self.$account.access.bind(.canDeleteNewsArticles)) - .disabled(model.access?.contains(.canDeleteNewsArticles) == false) - Toggle("Create Categories", isOn: self.$account.access.bind(.canCreateNewsCategories)) - .disabled(model.access?.contains(.canCreateNewsCategories) == false) - Toggle("Delete Categories", isOn: self.$account.access.bind(.canDeleteNewsCategories)) - .disabled(model.access?.contains(.canDeleteNewsCategories) == false) - Toggle("Create News Bundles", isOn: self.$account.access.bind(.canCreateNewsFolders)) - .disabled(model.access?.contains(.canCreateNewsFolders) == false) - Toggle("Delete News Bundles", isOn: self.$account.access.bind(.canDeleteNewsFolders)) - .disabled(model.access?.contains(.canDeleteNewsFolders) == false) - } - - Section("Chat") { - Toggle("Initiate Private Chat", isOn: self.$account.access.bind(.canCreateChat)) - .disabled(model.access?.contains(.canCreateChat) == false) - Toggle("Read Chat", isOn: self.$account.access.bind(.canReadChat)) - .disabled(model.access?.contains(.canReadChat) == false) - Toggle("Send Chat", isOn: self.$account.access.bind(.canSendChat)) - .disabled(model.access?.contains(.canSendChat) == false) - } - - Section("Miscellaneous") { - Toggle("Use Any Name", isOn: self.$account.access.bind(.canUseAnyName)) - .disabled(model.access?.contains(.canUseAnyName) == false) - Toggle("Don't Show Agreement", isOn: self.$account.access.bind(.canSkipAgreement)) - .disabled(model.access?.contains(.canSkipAgreement) == false) - } - } - .disabled(self.model.access?.contains(.canModifyUsers) == false) - .formStyle(.grouped) - } -} -- 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/macOS') 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/macOS') 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/macOS') 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/macOS') 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/macOS') 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/macOS') 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/macOS') 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/macOS') 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 From 6a95b53616a4abfa306ddce43151cf4fefbd20ed Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Mon, 17 Nov 2025 11:48:02 -0800 Subject: New macOS 26 app icon. Prepping NewsView for category/topic management. --- Hotline.xcodeproj/project.pbxproj | 4 + .../xcshareddata/swiftpm/Package.resolved | 10 +-- Hotline/Assets/App Icon.icon/Assets/hl.svg | 3 + Hotline/Assets/App Icon.icon/icon.json | 49 +++++++++++ Hotline/macOS/News/NewsView.swift | 96 +++++++++++++--------- 5 files changed, 119 insertions(+), 43 deletions(-) create mode 100644 Hotline/Assets/App Icon.icon/Assets/hl.svg create mode 100644 Hotline/Assets/App Icon.icon/icon.json (limited to 'Hotline/macOS') diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index cd331c8..f1013ec 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -72,6 +72,7 @@ DA72A0E22B4DAA4000A0F48A /* NewsArticle.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA72A0E12B4DAA4000A0F48A /* NewsArticle.swift */; }; DA77253F2B21176D006C5ABB /* NewsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA77253E2B21176D006C5ABB /* NewsView.swift */; platformFilter = ios; }; DA7725412B21435B006C5ABB /* ObservableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA7725402B21435B006C5ABB /* ObservableScrollView.swift */; }; + DA81CA0E2ECBAA4200B198C5 /* App Icon.icon in Resources */ = {isa = PBXBuildFile; fileRef = DA81CA0D2ECBAA4200B198C5 /* App Icon.icon */; }; DA872B132BDDBF78008B1012 /* HotlinePanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA872B122BDDBF78008B1012 /* HotlinePanel.swift */; platformFilters = (macos, ); }; DA872B152BDDEE1A008B1012 /* VisualEffectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA872B142BDDEE1A008B1012 /* VisualEffectView.swift */; platformFilters = (macos, ); }; DA99218E2C51AA5D0058FA6C /* HotlineDataBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA99218D2C51AA5D0058FA6C /* HotlineDataBuilder.swift */; }; @@ -188,6 +189,7 @@ DA72A0E12B4DAA4000A0F48A /* NewsArticle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsArticle.swift; sourceTree = ""; }; DA77253E2B21176D006C5ABB /* NewsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsView.swift; sourceTree = ""; }; DA7725402B21435B006C5ABB /* ObservableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservableScrollView.swift; sourceTree = ""; }; + DA81CA0D2ECBAA4200B198C5 /* App Icon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = "App Icon.icon"; sourceTree = ""; }; DA872B122BDDBF78008B1012 /* HotlinePanel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HotlinePanel.swift; sourceTree = ""; }; DA872B142BDDEE1A008B1012 /* VisualEffectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualEffectView.swift; sourceTree = ""; }; DA99218D2C51AA5D0058FA6C /* HotlineDataBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineDataBuilder.swift; sourceTree = ""; }; @@ -282,6 +284,7 @@ DA4B8F3C2EA6FDCE00CBFD53 /* Assets */ = { isa = PBXGroup; children = ( + DA81CA0D2ECBAA4200B198C5 /* App Icon.icon */, DABE8BF22B55DBFC00884D28 /* Sounds */, ); path = Assets; @@ -627,6 +630,7 @@ DABE8BFE2B55E69000884D28 /* error.aiff in Resources */, DABE8BF42B55DC0A00884D28 /* transfer-complete.aiff in Resources */, DABE8C002B55E69800884D28 /* server-message.aiff in Resources */, + DA81CA0E2ECBAA4200B198C5 /* App Icon.icon in Resources */, DABE8C022B55E69D00884D28 /* new-news.aiff in Resources */, DABE8BFC2B55DC6800884D28 /* user-logout.aiff in Resources */, DABE8BF62B55DC2E00884D28 /* chat-message.aiff in Resources */, diff --git a/Hotline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Hotline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3084548..cf8aa9c 100644 --- a/Hotline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Hotline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "590f8ee2c1318c670012f5d30a51419d3387a50648e0dd04f9654da2b1b7cef5", + "originHash" : "b3995b8a3dcfa496e06e7cf496be577ccc3da760d703ce9c3d81b0e5644397fc", "pins" : [ { "identity" : "kingfisher", "kind" : "remoteSourceControl", "location" : "https://github.com/onevcat/Kingfisher", "state" : { - "revision" : "4d75de347da985a70c63af4d799ed482021f6733", - "version" : "8.6.1" + "revision" : "d30a5fad881137e2267f96a8e3fc35c58999bb94", + "version" : "8.6.2" } }, { @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-cmark", "state" : { - "revision" : "b97d09472e847a416629f026eceae0e2afcfad65", - "version" : "0.7.0" + "revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe", + "version" : "0.7.1" } }, { diff --git a/Hotline/Assets/App Icon.icon/Assets/hl.svg b/Hotline/Assets/App Icon.icon/Assets/hl.svg new file mode 100644 index 0000000..05a1bbb --- /dev/null +++ b/Hotline/Assets/App Icon.icon/Assets/hl.svg @@ -0,0 +1,3 @@ + + + diff --git a/Hotline/Assets/App Icon.icon/icon.json b/Hotline/Assets/App Icon.icon/icon.json new file mode 100644 index 0000000..e147fe7 --- /dev/null +++ b/Hotline/Assets/App Icon.icon/icon.json @@ -0,0 +1,49 @@ +{ + "fill" : { + "linear-gradient" : [ + "display-p3:0.98824,0.15686,0.15686,1.00000", + "srgb:0.83024,0.00000,0.00000,1.00000" + ], + "orientation" : { + "start" : { + "x" : 0.5, + "y" : 0 + }, + "stop" : { + "x" : 0.5, + "y" : 0.7 + } + } + }, + "groups" : [ + { + "layers" : [ + { + "image-name" : "hl.svg", + "name" : "hl", + "position" : { + "scale" : 1.33, + "translation-in-points" : [ + 0, + -4.509732002588862e-05 + ] + } + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} \ No newline at end of file diff --git a/Hotline/macOS/News/NewsView.swift b/Hotline/macOS/News/NewsView.swift index a07a441..39e8d68 100644 --- a/Hotline/macOS/News/NewsView.swift +++ b/Hotline/macOS/News/NewsView.swift @@ -9,7 +9,7 @@ struct NewsView: View { @State private var selection: NewsInfo? @State private var articleText: String? - @State private var splitHidden = SideHolder(.bottom) + @State private var splitHidden: SideHolder = SideHolder(.bottom) @State private var splitFraction = FractionHolder.usingUserDefaults(0.25, key: "News Split Fraction") @State private var editorOpen: Bool = false @State private var replyOpen: Bool = false @@ -20,19 +20,17 @@ struct NewsView: View { if model.serverVersion < 151 { disabledNewsView } + else if !model.newsLoaded { + loadingIndicator + } + else if model.news.isEmpty { + emptyNewsView + } else { NavigationStack { VSplit( top: { - if !model.newsLoaded { - loadingIndicator - } - else if model.news.isEmpty { - emptyNewsView - } - else { - newsBrowser - } + newsBrowser }, bottom: { articleViewer @@ -45,13 +43,13 @@ struct NewsView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(nsColor: .textBackgroundColor)) } - .task { - if !model.newsLoaded { - loading = true - try? await model.getNewsList() - loading = false - } - } + } + } + .task { + if !model.newsLoaded { + loading = true + try? await model.getNewsList() + loading = false } } .sheet(isPresented: $editorOpen) { @@ -78,7 +76,7 @@ struct NewsView: View { } } .toolbar { - ToolbarItem(placement: .primaryAction) { + ToolbarItem { Button { if selection?.type == .category || selection?.type == .article { editorOpen = true @@ -86,11 +84,11 @@ struct NewsView: View { } label: { Image(systemName: "square.and.pencil") } - .help("New Post") + .help("New post under current topic") .disabled(selection?.type != .category && selection?.type != .article) } - ToolbarItem(placement: .primaryAction) { + ToolbarItem { Button { if selection?.type == .article { replyOpen = true @@ -98,11 +96,37 @@ struct NewsView: View { } label: { Image(systemName: "arrowshape.turn.up.left") } - .help("Reply to Post") + .help("Reply to selected post") .disabled(selection?.type != .article) } - ToolbarItem(placement: .primaryAction) { + if #available(macOS 26.0, *) { + ToolbarSpacer(.fixed) + } + + ToolbarItem { + Button { +// if selection?.type == .category || selection?.type == .article { +// editorOpen = true +// } + } label: { + Image(systemName: "newspaper") + } + .help("Create a new topic") + } + + ToolbarItem { + Button { +// if selection?.type == .category || selection?.type == .article { +// editorOpen = true +// } + } label: { + Image(systemName: "tray") + } + .help("Create a new category") + } + + ToolbarItem { Button { loading = true if let selectionPath = selection?.path { @@ -120,7 +144,7 @@ struct NewsView: View { } label: { Image(systemName: "arrow.clockwise") } - .help("Reload News") + .help("Reload selected topic") .disabled(loading) } } @@ -265,22 +289,18 @@ struct NewsView: View { } } else { - HStack(alignment: .center) { - Spacer() - HStack(alignment: .center, spacing: 8) { -// Image(systemName: "doc.append") -// .resizable() -// .scaledToFit() + EmptyView() +// HStack(alignment: .center) { +// Spacer() +// HStack(alignment: .center, spacing: 8) { +// Text("Select a news article to read") // .foregroundStyle(.tertiary) -// .frame(width: 16, height: 16) - Text("Select a news post to read") - .foregroundStyle(.tertiary) - .font(.system(size: 13)) - } - Spacer() - } - .padding() - .padding(.top, 48) +// .font(.subheadline) +// } +// Spacer() +// } +// .padding() +// .padding(.top, 48) } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) -- cgit