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/Application-macOS.swift | 303 ---------------- Hotline/Assets/Sounds/chat-message.aiff | Bin 0 -> 4554 bytes Hotline/Assets/Sounds/error.aiff | Bin 0 -> 4610 bytes Hotline/Assets/Sounds/logged-in.aiff | Bin 0 -> 4166 bytes Hotline/Assets/Sounds/new-news.aiff | Bin 0 -> 17514 bytes Hotline/Assets/Sounds/server-message.aiff | Bin 0 -> 7914 bytes Hotline/Assets/Sounds/transfer-complete.aiff | Bin 0 -> 21378 bytes Hotline/Assets/Sounds/user-login.aiff | Bin 0 -> 6186 bytes Hotline/Assets/Sounds/user-logout.aiff | Bin 0 -> 7080 bytes Hotline/Library/AsyncLinkPreview.swift | 57 +++ Hotline/Library/DataAdditions.swift | 23 ++ Hotline/Library/FileIconView.swift | 74 ++++ Hotline/Library/FileImageView.swift | 98 ++++++ Hotline/Library/HotlinePanel.swift | 64 ++++ Hotline/Library/NetSocket.swift | 269 ++++++++++++++ Hotline/Library/TextView.swift | 119 +++++++ Hotline/Library/URLAdditions.swift | 26 ++ Hotline/Library/VisualEffectView.swift | 22 ++ Hotline/MacApp.swift | 303 ++++++++++++++++ Hotline/Models/ApplicationState.swift | 16 - Hotline/Models/Bookmark.swift | 100 ++---- Hotline/Models/Preferences.swift | 134 ------- Hotline/Shared/AsyncLinkPreview.swift | 57 --- Hotline/Shared/DataAdditions.swift | 23 -- Hotline/Shared/FileIconView.swift | 74 ---- Hotline/Shared/FileImageView.swift | 98 ------ Hotline/Shared/HotlinePanel.swift | 64 ---- Hotline/Shared/NetSocket.swift | 269 -------------- Hotline/Shared/TextView.swift | 119 ------- Hotline/Shared/URLAdditions.swift | 26 -- Hotline/Shared/VisualEffectView.swift | 22 -- Hotline/Sounds/Application-iOS.swift | 18 - Hotline/Sounds/chat-message.aiff | Bin 4554 -> 0 bytes Hotline/Sounds/error.aiff | Bin 4610 -> 0 bytes Hotline/Sounds/logged-in.aiff | Bin 4166 -> 0 bytes Hotline/Sounds/new-news.aiff | Bin 17514 -> 0 bytes Hotline/Sounds/server-message.aiff | Bin 7914 -> 0 bytes Hotline/Sounds/transfer-complete.aiff | Bin 21378 -> 0 bytes Hotline/Sounds/user-login.aiff | Bin 6186 -> 0 bytes Hotline/Sounds/user-logout.aiff | Bin 7080 -> 0 bytes Hotline/State/AppState.swift | 24 ++ Hotline/State/Preferences.swift | 138 ++++++++ Hotline/iOSApp.swift | 18 + Hotline/macOS/HotlinePanelView.swift | 24 +- Hotline/macOS/TrackerView.swift | 503 +++++++++++++++++++-------- 45 files changed, 1634 insertions(+), 1451 deletions(-) delete mode 100644 Hotline/Application-macOS.swift create mode 100644 Hotline/Assets/Sounds/chat-message.aiff create mode 100644 Hotline/Assets/Sounds/error.aiff create mode 100644 Hotline/Assets/Sounds/logged-in.aiff create mode 100644 Hotline/Assets/Sounds/new-news.aiff create mode 100644 Hotline/Assets/Sounds/server-message.aiff create mode 100644 Hotline/Assets/Sounds/transfer-complete.aiff create mode 100644 Hotline/Assets/Sounds/user-login.aiff create mode 100644 Hotline/Assets/Sounds/user-logout.aiff create mode 100644 Hotline/Library/AsyncLinkPreview.swift create mode 100644 Hotline/Library/DataAdditions.swift create mode 100644 Hotline/Library/FileIconView.swift create mode 100644 Hotline/Library/FileImageView.swift create mode 100644 Hotline/Library/HotlinePanel.swift create mode 100644 Hotline/Library/NetSocket.swift create mode 100644 Hotline/Library/TextView.swift create mode 100644 Hotline/Library/URLAdditions.swift create mode 100644 Hotline/Library/VisualEffectView.swift create mode 100644 Hotline/MacApp.swift delete mode 100644 Hotline/Models/ApplicationState.swift delete mode 100644 Hotline/Models/Preferences.swift delete mode 100644 Hotline/Shared/AsyncLinkPreview.swift delete mode 100644 Hotline/Shared/DataAdditions.swift delete mode 100644 Hotline/Shared/FileIconView.swift delete mode 100644 Hotline/Shared/FileImageView.swift delete mode 100644 Hotline/Shared/HotlinePanel.swift delete mode 100644 Hotline/Shared/NetSocket.swift delete mode 100644 Hotline/Shared/TextView.swift delete mode 100644 Hotline/Shared/URLAdditions.swift delete mode 100644 Hotline/Shared/VisualEffectView.swift delete mode 100644 Hotline/Sounds/Application-iOS.swift delete mode 100644 Hotline/Sounds/chat-message.aiff delete mode 100644 Hotline/Sounds/error.aiff delete mode 100644 Hotline/Sounds/logged-in.aiff delete mode 100644 Hotline/Sounds/new-news.aiff delete mode 100644 Hotline/Sounds/server-message.aiff delete mode 100644 Hotline/Sounds/transfer-complete.aiff delete mode 100644 Hotline/Sounds/user-login.aiff delete mode 100644 Hotline/Sounds/user-logout.aiff create mode 100644 Hotline/State/AppState.swift create mode 100644 Hotline/State/Preferences.swift create mode 100644 Hotline/iOSApp.swift (limited to 'Hotline') diff --git a/Hotline/Application-macOS.swift b/Hotline/Application-macOS.swift deleted file mode 100644 index fe3a828..0000000 --- a/Hotline/Application-macOS.swift +++ /dev/null @@ -1,303 +0,0 @@ -import SwiftUI -import SwiftData -import CloudKit -import UniformTypeIdentifiers - -@Observable -final class AppLaunchState { - static let shared = AppLaunchState() - - enum LaunchState { - case loading - case launched - case terminated - } - - var launchState = LaunchState.loading -} - -class AppDelegate: NSObject, NSApplicationDelegate { - private var cloudKitObserverToken: Any? = nil - - func applicationDidFinishLaunching(_ notification: Notification) { - AppLaunchState.shared.launchState = .launched - - CKContainer.default().accountStatus { status, error in - switch status { - case .noAccount: - print("iCloud Unavailable") - - // We mark CloudKit has available now since we're not waiting on - // a server sync or anything. - ApplicationState.shared.cloudKitReady = true - default: - print("iCloud Available") - - self.cloudKitObserverToken = NotificationCenter.default.addObserver(forName: NSPersistentCloudKitContainer.eventChangedNotification, object: nil, queue: OperationQueue.main) { [weak self] note in - print("iCloud Changed!") - ApplicationState.shared.cloudKitReady = true - - guard let token = self?.cloudKitObserverToken else { return } - NotificationCenter.default.removeObserver(token) - } - } - } - -// if FileManager.default.ubiquityIdentityToken == nil { -// print("iCloud Unavailable") -// -// // We mark CloudKit has available now since we're not waiting on -// // a server sync or anything. -// ApplicationState.shared.cloudKitReady = true -// } -// else { -// print("iCloud Available") -// -// self.cloudKitObserverToken = NotificationCenter.default.addObserver(forName: NSPersistentCloudKitContainer.eventChangedNotification, object: nil, queue: OperationQueue.main) { [weak self] note in -// print("iCloud Changed!") -// ApplicationState.shared.cloudKitReady = true -// -// guard let token = self?.cloudKitObserverToken else { return } -// NotificationCenter.default.removeObserver(token) -// } -// } - } - - func applicationWillTerminate(_ notification: Notification) { - AppLaunchState.shared.launchState = .terminated - } -} - -@main -struct Application: App { - @Environment(\.scenePhase) private var scenePhase - @Environment(\.openWindow) private var openWindow - @Environment(\.openURL) private var openURL - - @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate - - @State private var hotlinePanel: HotlinePanel? = nil - @State private var selection: Bookmark? = nil - - @FocusedValue(\.activeHotlineModel) private var activeHotline: Hotline? - @FocusedValue(\.activeServerState) private var activeServerState: ServerState? - - private var modelContainer: ModelContainer = { - let schema = Schema([ - Bookmark.self - ]) - let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false, cloudKitDatabase: .private("iCloud.co.goodmake.hotline")) - let modelContainer = try! ModelContainer(for: schema, configurations: [config]) - - // Print local SwiftData sqlite file. -// print(modelContainer.configurations.first?.url.path(percentEncoded: false)) - - return modelContainer - }() - - var body: some Scene { - // MARK: Tracker Window - Window("Servers", id: "servers") { - TrackerView(selection: $selection) - .frame(minWidth: 250, minHeight: 250) - } - .modelContainer(self.modelContainer) - .defaultSize(width: 700, height: 550) - .defaultPosition(.center) - .keyboardShortcut(.init("R"), modifiers: .command) - .onChange(of: AppLaunchState.shared.launchState) { - if AppLaunchState.shared.launchState == .launched { - if Prefs.shared.showBannerToolbar { - showBannerWindow() - } - } - } - - // MARK: About Box - Window("About", id: "about") { - AboutView() - .ignoresSafeArea() - .background(Color.hotlineRed) - } - .windowResizability(.contentSize) - .windowStyle(.hiddenTitleBar) - .defaultPosition(.center) - .commandsRemoved() // Remove About that was automatically added to Window menu. - .commands { - CommandGroup(replacing: CommandGroupPlacement.appInfo) { - Button("About Hotline") { - openWindow(id: "about") - } - } - } - - // MARK: Server Window - WindowGroup(id: "server", for: Server.self) { server in - ServerView(server: server) - .frame(minWidth: 430, minHeight: 300) - } defaultValue: { - Server(name: nil, description: nil, address: "") - } - .modelContainer(self.modelContainer) - .defaultSize(width: 750, height: 700) - .defaultPosition(.center) - .onChange(of: activeServerState) { - ApplicationState.shared.activeServerState = activeServerState - } - .onChange(of: activeHotline) { - ApplicationState.shared.activeHotline = activeHotline - } - .onChange(of: activeHotline?.serverTitle) { - if let hotline = activeHotline { - ApplicationState.shared.activeServerName = hotline.serverTitle - } - } - .onChange(of: activeHotline?.bannerImage) { - withAnimation { - ApplicationState.shared.activeServerBanner = activeHotline?.bannerImage - } - } - .onChange(of: activeHotline) { - ApplicationState.shared.activeHotline = activeHotline - if let hotline = activeHotline { - ApplicationState.shared.activeServerName = hotline.serverTitle - } - } - .commands { - CommandGroup(replacing: .newItem) { - Button("Connect to Server...") { - openWindow(id: "server") - } - .keyboardShortcut(.init("K"), modifiers: .command) - } - CommandGroup(after: .singleWindowList) { - Button("Toolbar") { - toggleBannerWindow() - } - .keyboardShortcut(.init("\\"), modifiers: [.shift, .command]) - } - CommandGroup(after: .help) { - Divider() - Button("Request Feature...") { - if let url = URL(string: "https://github.com/mierau/hotline/issues/new?labels=enhancement") { - openURL(url) - } - } - Button("Report Bug...") { - if let url = URL(string: "https://github.com/mierau/hotline/issues/new?labels=bug") { - openURL(url) - } - } - Divider() - Button("Download Latest...") { - if let url = URL(string: "https://github.com/mierau/hotline/releases/latest") { - openURL(url) - } - } - } - CommandMenu("Server") { - Button("Connect") { - guard let selection else { - return - } - connect(to: selection) - } - .disabled(selection == nil || selection?.server == nil) - .keyboardShortcut(.downArrow, modifiers: .command) - Button("Disconnect") { - activeHotline?.disconnect() - } - .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") { - activeServerState?.selection = .chat - } - .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("3"), modifiers: .command) - Button("Show Files") { - activeServerState?.selection = .files - } - .disabled(activeHotline?.status != .loggedIn) - .keyboardShortcut(.init("4"), modifiers: .command) - Button("Show Accounts") { - activeServerState?.selection = .accounts - } - .disabled(activeHotline?.status != .loggedIn || activeHotline?.access?.contains(.canOpenUsers) != true ) - .keyboardShortcut(.init("5"), modifiers: .command) - } - } - - // MARK: Settings Window - Settings { - SettingsView() - } - - // MARK: Image Preview Window - WindowGroup(id: "preview-image", for: PreviewFileInfo.self) { $info in - FilePreviewImageView(info: $info) - } - .windowResizability(.contentSize) - .windowStyle(.titleBar) - .windowToolbarStyle(.unifiedCompact(showsTitle: true)) - .defaultSize(width: 350, height: 150) - .defaultPosition(.center) - - // MARK: Text Preview Window - WindowGroup(id: "preview-text", for: PreviewFileInfo.self) { $info in - FilePreviewTextView(info: $info) - } - .windowResizability(.automatic) - .windowStyle(.titleBar) - .windowToolbarStyle(.unifiedCompact(showsTitle: true)) - .defaultSize(width: 450, height: 550) - .defaultPosition(.center) - } - - func connect(to item: Bookmark) { - if let server = item.server { - openWindow(id: "server", value: server) - } - } - - func showBannerWindow() { - if hotlinePanel == nil { - hotlinePanel = HotlinePanel(HotlinePanelView()) - } - - if hotlinePanel?.isVisible == false { - hotlinePanel?.orderFront(nil) - Prefs.shared.showBannerToolbar = true - } - } - - func toggleBannerWindow() { - if hotlinePanel == nil { - hotlinePanel = HotlinePanel(HotlinePanelView()) - } - - if hotlinePanel?.isVisible == true { - hotlinePanel?.orderOut(nil) - Prefs.shared.showBannerToolbar = false - } - else { - hotlinePanel?.orderFront(nil) - Prefs.shared.showBannerToolbar = true - } - } -} diff --git a/Hotline/Assets/Sounds/chat-message.aiff b/Hotline/Assets/Sounds/chat-message.aiff new file mode 100644 index 0000000..73ea391 Binary files /dev/null and b/Hotline/Assets/Sounds/chat-message.aiff differ diff --git a/Hotline/Assets/Sounds/error.aiff b/Hotline/Assets/Sounds/error.aiff new file mode 100644 index 0000000..8a9f7f6 Binary files /dev/null and b/Hotline/Assets/Sounds/error.aiff differ diff --git a/Hotline/Assets/Sounds/logged-in.aiff b/Hotline/Assets/Sounds/logged-in.aiff new file mode 100644 index 0000000..5f59de0 Binary files /dev/null and b/Hotline/Assets/Sounds/logged-in.aiff differ diff --git a/Hotline/Assets/Sounds/new-news.aiff b/Hotline/Assets/Sounds/new-news.aiff new file mode 100644 index 0000000..5c56582 Binary files /dev/null and b/Hotline/Assets/Sounds/new-news.aiff differ diff --git a/Hotline/Assets/Sounds/server-message.aiff b/Hotline/Assets/Sounds/server-message.aiff new file mode 100644 index 0000000..152bebe Binary files /dev/null and b/Hotline/Assets/Sounds/server-message.aiff differ diff --git a/Hotline/Assets/Sounds/transfer-complete.aiff b/Hotline/Assets/Sounds/transfer-complete.aiff new file mode 100644 index 0000000..b22e0ae Binary files /dev/null and b/Hotline/Assets/Sounds/transfer-complete.aiff differ diff --git a/Hotline/Assets/Sounds/user-login.aiff b/Hotline/Assets/Sounds/user-login.aiff new file mode 100644 index 0000000..0dcfb6f Binary files /dev/null and b/Hotline/Assets/Sounds/user-login.aiff differ diff --git a/Hotline/Assets/Sounds/user-logout.aiff b/Hotline/Assets/Sounds/user-logout.aiff new file mode 100644 index 0000000..8999edc Binary files /dev/null and b/Hotline/Assets/Sounds/user-logout.aiff differ diff --git a/Hotline/Library/AsyncLinkPreview.swift b/Hotline/Library/AsyncLinkPreview.swift new file mode 100644 index 0000000..89b186f --- /dev/null +++ b/Hotline/Library/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/DataAdditions.swift b/Hotline/Library/DataAdditions.swift new file mode 100644 index 0000000..0e9dd96 --- /dev/null +++ b/Hotline/Library/DataAdditions.swift @@ -0,0 +1,23 @@ +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/FileIconView.swift b/Hotline/Library/FileIconView.swift new file mode 100644 index 0000000..d0949fa --- /dev/null +++ b/Hotline/Library/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/FileImageView.swift b/Hotline/Library/FileImageView.swift new file mode 100644 index 0000000..0cc50f1 --- /dev/null +++ b/Hotline/Library/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/HotlinePanel.swift b/Hotline/Library/HotlinePanel.swift new file mode 100644 index 0000000..191e89a --- /dev/null +++ b/Hotline/Library/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 = false + self.level = .floating + self.hidesOnDeactivate = true + self.animationBehavior = .utilityWindow + + // Allow the panel to appear in a fullscreen space +// self.collectionBehavior.insert(.fullScreenAuxiliary) + self.collectionBehavior.insert(.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 = true + self.standardWindowButton(.zoomButton)?.isHidden = true + self.standardWindowButton(.miniaturizeButton)?.isHidden = true + + // Make it transparent, the view inside will have to set the background. + // This is necessary because otherwise, we will have some space for the titlebar on top of the height of the view itself which we don't want. + self.isOpaque = false + self.backgroundColor = .clear + + // Since we don't show a statusbar, this allows us to drag the window by its background instead of the titlebar. + self.isMovableByWindowBackground = true + self.titlebarAppearsTransparent = true + + let hostingView = NSHostingView(rootView: view.edgesIgnoringSafeArea(.top)) + hostingView.sizingOptions = [.preferredContentSize] + + let visualEffectView = NSVisualEffectView(frame: NSRect(x: 0, y: 0, width: HOTLINE_PANEL_SIZE.width, height: HOTLINE_PANEL_SIZE.height)) + 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.swift b/Hotline/Library/NetSocket.swift new file mode 100644 index 0000000..a7d44ce --- /dev/null +++ b/Hotline/Library/NetSocket.swift @@ -0,0 +1,269 @@ + +// 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.. 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 new file mode 100644 index 0000000..1f25541 --- /dev/null +++ b/Hotline/Library/URLAdditions.swift @@ -0,0 +1,26 @@ +import Foundation + +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 + } +} diff --git a/Hotline/Library/VisualEffectView.swift b/Hotline/Library/VisualEffectView.swift new file mode 100644 index 0000000..e595c55 --- /dev/null +++ b/Hotline/Library/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/MacApp.swift b/Hotline/MacApp.swift new file mode 100644 index 0000000..a4ff3cb --- /dev/null +++ b/Hotline/MacApp.swift @@ -0,0 +1,303 @@ +import SwiftUI +import SwiftData +import CloudKit +import UniformTypeIdentifiers + +@Observable +final class AppLaunchState { + static let shared = AppLaunchState() + + enum LaunchState { + case loading + case launched + case terminated + } + + var launchState = LaunchState.loading +} + +class AppDelegate: NSObject, NSApplicationDelegate { + private var cloudKitObserverToken: Any? = nil + + func applicationDidFinishLaunching(_ notification: Notification) { + AppLaunchState.shared.launchState = .launched + + CKContainer.default().accountStatus { status, error in + switch status { + case .noAccount: + print("iCloud Unavailable") + + // We mark CloudKit has available now since we're not waiting on + // a server sync or anything. + AppState.shared.cloudKitReady = true + default: + print("iCloud Available") + + self.cloudKitObserverToken = NotificationCenter.default.addObserver(forName: NSPersistentCloudKitContainer.eventChangedNotification, object: nil, queue: OperationQueue.main) { [weak self] note in + print("iCloud Changed!") + AppState.shared.cloudKitReady = true + + guard let token = self?.cloudKitObserverToken else { return } + NotificationCenter.default.removeObserver(token) + } + } + } + +// if FileManager.default.ubiquityIdentityToken == nil { +// print("iCloud Unavailable") +// +// // We mark CloudKit has available now since we're not waiting on +// // a server sync or anything. +// AppState.shared.cloudKitReady = true +// } +// else { +// print("iCloud Available") +// +// self.cloudKitObserverToken = NotificationCenter.default.addObserver(forName: NSPersistentCloudKitContainer.eventChangedNotification, object: nil, queue: OperationQueue.main) { [weak self] note in +// print("iCloud Changed!") +// AppState.shared.cloudKitReady = true +// +// guard let token = self?.cloudKitObserverToken else { return } +// NotificationCenter.default.removeObserver(token) +// } +// } + } + + func applicationWillTerminate(_ notification: Notification) { + AppLaunchState.shared.launchState = .terminated + } +} + +@main +struct Application: App { + @Environment(\.scenePhase) private var scenePhase + @Environment(\.openWindow) private var openWindow + @Environment(\.openURL) private var openURL + + @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + + @State private var hotlinePanel: HotlinePanel? = nil + @State private var selection: TrackerSelection? = nil + + @FocusedValue(\.activeHotlineModel) private var activeHotline: Hotline? + @FocusedValue(\.activeServerState) private var activeServerState: ServerState? + + private var modelContainer: ModelContainer = { + let schema = Schema([ + Bookmark.self + ]) + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false, cloudKitDatabase: .private("iCloud.co.goodmake.hotline")) + let modelContainer = try! ModelContainer(for: schema, configurations: [config]) + + // Print local SwiftData sqlite file. +// print(modelContainer.configurations.first?.url.path(percentEncoded: false)) + + return modelContainer + }() + + var body: some Scene { + // MARK: Tracker Window + Window("Servers", id: "servers") { + TrackerView(selection: $selection) + .frame(minWidth: 250, minHeight: 250) + } + .modelContainer(self.modelContainer) + .defaultSize(width: 700, height: 550) + .defaultPosition(.center) + .keyboardShortcut(.init("R"), modifiers: .command) + .onChange(of: AppLaunchState.shared.launchState) { + if AppLaunchState.shared.launchState == .launched { + if Prefs.shared.showBannerToolbar { + showBannerWindow() + } + } + } + + // MARK: About Box + Window("About", id: "about") { + AboutView() + .ignoresSafeArea() + .background(Color.hotlineRed) + } + .windowResizability(.contentSize) + .windowStyle(.hiddenTitleBar) + .defaultPosition(.center) + .commandsRemoved() // Remove About that was automatically added to Window menu. + .commands { + CommandGroup(replacing: CommandGroupPlacement.appInfo) { + Button("About Hotline") { + openWindow(id: "about") + } + } + } + + // MARK: Server Window + WindowGroup(id: "server", for: Server.self) { server in + ServerView(server: server) + .frame(minWidth: 430, minHeight: 300) + } defaultValue: { + Server(name: nil, description: nil, address: "") + } + .modelContainer(self.modelContainer) + .defaultSize(width: 750, height: 700) + .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 + } + } + .onChange(of: activeHotline) { + AppState.shared.activeHotline = activeHotline + if let hotline = activeHotline { + AppState.shared.activeServerName = hotline.serverTitle + } + } + .commands { + CommandGroup(replacing: .newItem) { + Button("Connect to Server...") { + openWindow(id: "server") + } + .keyboardShortcut(.init("K"), modifiers: .command) + } + CommandGroup(after: .singleWindowList) { + Button("Toolbar") { + toggleBannerWindow() + } + .keyboardShortcut(.init("\\"), modifiers: [.shift, .command]) + } + CommandGroup(after: .help) { + Divider() + Button("Request Feature...") { + if let url = URL(string: "https://github.com/mierau/hotline/issues/new?labels=enhancement") { + openURL(url) + } + } + Button("Report Bug...") { + if let url = URL(string: "https://github.com/mierau/hotline/issues/new?labels=bug") { + openURL(url) + } + } + Divider() + Button("Download Latest...") { + if let url = URL(string: "https://github.com/mierau/hotline/releases/latest") { + openURL(url) + } + } + } + CommandMenu("Server") { + Button("Connect") { + guard let selection else { + return + } + connect(to: selection) + } + .disabled(selection == nil || selection?.server == nil) + .keyboardShortcut(.downArrow, modifiers: .command) + Button("Disconnect") { + activeHotline?.disconnect() + } + .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") { + activeServerState?.selection = .chat + } + .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("3"), modifiers: .command) + Button("Show Files") { + activeServerState?.selection = .files + } + .disabled(activeHotline?.status != .loggedIn) + .keyboardShortcut(.init("4"), modifiers: .command) + Button("Show Accounts") { + activeServerState?.selection = .accounts + } + .disabled(activeHotline?.status != .loggedIn || activeHotline?.access?.contains(.canOpenUsers) != true ) + .keyboardShortcut(.init("5"), modifiers: .command) + } + } + + // MARK: Settings Window + Settings { + SettingsView() + } + + // MARK: Image Preview Window + WindowGroup(id: "preview-image", for: PreviewFileInfo.self) { $info in + FilePreviewImageView(info: $info) + } + .windowResizability(.contentSize) + .windowStyle(.titleBar) + .windowToolbarStyle(.unifiedCompact(showsTitle: true)) + .defaultSize(width: 350, height: 150) + .defaultPosition(.center) + + // MARK: Text Preview Window + WindowGroup(id: "preview-text", for: PreviewFileInfo.self) { $info in + FilePreviewTextView(info: $info) + } + .windowResizability(.automatic) + .windowStyle(.titleBar) + .windowToolbarStyle(.unifiedCompact(showsTitle: true)) + .defaultSize(width: 450, height: 550) + .defaultPosition(.center) + } + + func connect(to item: TrackerSelection) { + if let server = item.server { + openWindow(id: "server", value: server) + } + } + + func showBannerWindow() { + if hotlinePanel == nil { + hotlinePanel = HotlinePanel(HotlinePanelView()) + } + + if hotlinePanel?.isVisible == false { + hotlinePanel?.orderFront(nil) + Prefs.shared.showBannerToolbar = true + } + } + + func toggleBannerWindow() { + if hotlinePanel == nil { + hotlinePanel = HotlinePanel(HotlinePanelView()) + } + + if hotlinePanel?.isVisible == true { + hotlinePanel?.orderOut(nil) + Prefs.shared.showBannerToolbar = false + } + else { + hotlinePanel?.orderFront(nil) + Prefs.shared.showBannerToolbar = true + } + } +} diff --git a/Hotline/Models/ApplicationState.swift b/Hotline/Models/ApplicationState.swift deleted file mode 100644 index 6e077c5..0000000 --- a/Hotline/Models/ApplicationState.swift +++ /dev/null @@ -1,16 +0,0 @@ -import SwiftUI - -@Observable -final class ApplicationState { - static let shared = ApplicationState() - - var activeHotline: Hotline? = nil - var activeServerState: ServerState? = nil - - // Frontmost server window information - var activeServerID: UUID? = nil - var activeServerBanner: NSImage? = nil - var activeServerName: String? = nil - - var cloudKitReady: Bool = false -} diff --git a/Hotline/Models/Bookmark.swift b/Hotline/Models/Bookmark.swift index 733a6eb..394f9f6 100644 --- a/Hotline/Models/Bookmark.swift +++ b/Hotline/Models/Bookmark.swift @@ -4,7 +4,27 @@ import SwiftData enum BookmarkType: String, Codable { case tracker = "tracker" case server = "server" - case temporary = "temporary" +} + +struct BookmarkServer: Hashable, Identifiable { + let id = UUID() + let name: String? + let address: String + let port: Int + let description: String? + let users: Int + + var server: Server { + Server(name: self.name, description: self.description, address: self.address, port: self.port, users: self.users) + } + + init(server: Server) { + self.name = server.name + self.address = server.address + self.port = server.port + self.description = server.description + self.users = server.users + } } @Model @@ -18,29 +38,10 @@ final class Bookmark { @Attribute(.allowsCloudEncryption) var login: String? - + @Attribute(.allowsCloudEncryption) var password: String? - - @Attribute(.ephemeral) - var expanded: Bool = false - - @Attribute(.ephemeral) - var loading: Bool = false - - @Attribute(.ephemeral) - var serverDescription: String? = nil - - @Attribute(.ephemeral) - var serverUserCount: Int? = nil - - @Transient - var servers: [Bookmark] = [] - - func hash(into hasher: inout Hasher) { - - } - + @Transient var displayAddress: String { switch self.type { @@ -51,8 +52,8 @@ final class Bookmark { else { return "\(self.address):\(String(self.port))" } - - case .server, .temporary: + + case .server: if self.port == HotlinePorts.DefaultServerPort { return self.address } @@ -67,9 +68,9 @@ final class Bookmark { switch self.type { case .tracker: return nil - - case .server, .temporary: - return Server(name: self.name, description: self.serverDescription, address: self.address, port: self.port, login: self.login, password: self.password) + + case .server: + return Server(name: self.name, description: nil, address: self.address, port: self.port, login: self.login, password: self.password) } } @@ -84,22 +85,11 @@ final class Bookmark { self.name = name self.address = address self.port = port - + self.login = login self.password = password } - - init(temporaryServer server: Server) { - self.type = .temporary - - self.name = server.name ?? server.address - self.address = server.address - self.port = server.port - - self.serverDescription = server.description - self.serverUserCount = server.users - } - + init?(fileData: Data, name: String? = nil) { guard fileData.count <= 2000 else { return nil @@ -248,11 +238,6 @@ final class Bookmark { } static func add(_ bookmark: Bookmark, context: ModelContext) { - guard bookmark.type != .temporary else { - print("Bookmark: Attempting to add temporary bookmark to store. Aborting.") - return - } - let existingBookmarks = Bookmark.fetchAll(context: context) // Reindex bookmarks before insert. @@ -348,32 +333,23 @@ final class Bookmark { } } - func fetchServers() async { + func fetchServers() async -> [BookmarkServer] { guard self.type == .tracker else { - // self.loading = false - return - } - - DispatchQueue.main.sync { - self.loading = true + return [] } - - var fetchedBookmarks: [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 { 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(Bookmark(temporaryServer: server)) + fetchedBookmarks.append(BookmarkServer(server: server)) } } } - - let newServers = fetchedBookmarks - DispatchQueue.main.sync { - self.servers = newServers - self.loading = false - } + + return fetchedBookmarks } } diff --git a/Hotline/Models/Preferences.swift b/Hotline/Models/Preferences.swift deleted file mode 100644 index 207ce91..0000000 --- a/Hotline/Models/Preferences.swift +++ /dev/null @@ -1,134 +0,0 @@ -import SwiftUI - -enum PrefsKeys: String { - case username = "username" - case userIconID = "user icon id" - case refusePrivateMessages = "refuse private messages" - case refusePrivateChat = "refuse private chat" - case enableAutomaticMessage = "enable automatic message" - case automaticMessage = "automatic message" - case playSounds = "play sounds" - case playChatSound = "play chat sound" - case playFileTransferCompleteSound = "play file transfer complete sound" - case playPrivateMessageSound = "play private message sound" - case playJoinSound = "play join sound" - case playLeaveSound = "play leave sound" - case playLoggedInSound = "play logged in sound" - case playErrorSound = "play error sound" - case playChatInvitationSound = "play chat invitation sound" - case showBannerToolbar = "show banner toolbar" - case showJoinLeaveMessages = "show join leave messages" -} - -@Observable -class Prefs { - init() { - UserDefaults.standard.register(defaults:[ - PrefsKeys.username.rawValue: "guest", - PrefsKeys.userIconID.rawValue: 191, - PrefsKeys.refusePrivateMessages.rawValue: false, - PrefsKeys.refusePrivateChat.rawValue: false, - PrefsKeys.enableAutomaticMessage.rawValue: false, - PrefsKeys.automaticMessage.rawValue: "", - PrefsKeys.playSounds.rawValue: true, - PrefsKeys.playChatSound.rawValue: true, - PrefsKeys.playFileTransferCompleteSound.rawValue: true, - PrefsKeys.playPrivateMessageSound.rawValue: true, - PrefsKeys.playJoinSound.rawValue: true, - PrefsKeys.playLeaveSound.rawValue: true, - PrefsKeys.playLoggedInSound.rawValue: true, - PrefsKeys.playErrorSound.rawValue: true, - PrefsKeys.playChatInvitationSound.rawValue: true, - PrefsKeys.showBannerToolbar.rawValue: true, - PrefsKeys.showJoinLeaveMessages.rawValue: true, - ]) - - self.username = UserDefaults.standard.string(forKey: PrefsKeys.username.rawValue)! - self.userIconID = UserDefaults.standard.integer(forKey: PrefsKeys.userIconID.rawValue) - self.refusePrivateMessages = UserDefaults.standard.bool(forKey: PrefsKeys.refusePrivateMessages.rawValue) - self.refusePrivateChat = UserDefaults.standard.bool(forKey: PrefsKeys.refusePrivateChat.rawValue) - self.enableAutomaticMessage = UserDefaults.standard.bool(forKey: PrefsKeys.enableAutomaticMessage.rawValue) - self.automaticMessage = UserDefaults.standard.string(forKey: PrefsKeys.automaticMessage.rawValue)! - self.playSounds = UserDefaults.standard.bool(forKey: PrefsKeys.playSounds.rawValue) - self.playChatSound = UserDefaults.standard.bool(forKey: PrefsKeys.playChatSound.rawValue) - self.playFileTransferCompleteSound = UserDefaults.standard.bool(forKey: PrefsKeys.playFileTransferCompleteSound.rawValue) - self.playPrivateMessageSound = UserDefaults.standard.bool(forKey: PrefsKeys.playPrivateMessageSound.rawValue) - self.playJoinSound = UserDefaults.standard.bool(forKey: PrefsKeys.playJoinSound.rawValue) - self.playLeaveSound = UserDefaults.standard.bool(forKey: PrefsKeys.playLeaveSound.rawValue) - self.playLoggedInSound = UserDefaults.standard.bool(forKey: PrefsKeys.playLoggedInSound.rawValue) - self.playErrorSound = UserDefaults.standard.bool(forKey: PrefsKeys.playErrorSound.rawValue) - self.playChatInvitationSound = UserDefaults.standard.bool(forKey: PrefsKeys.playChatInvitationSound.rawValue) - self.showBannerToolbar = UserDefaults.standard.bool(forKey: PrefsKeys.showBannerToolbar.rawValue) - self.showJoinLeaveMessages = UserDefaults.standard.bool(forKey: PrefsKeys.showJoinLeaveMessages.rawValue) - } - - public static let shared = Prefs() - - var username: String { - didSet { UserDefaults.standard.set(self.username, forKey: PrefsKeys.username.rawValue) } - } - - var userIconID: Int { - didSet { UserDefaults.standard.set(self.userIconID, forKey: PrefsKeys.userIconID.rawValue) } - } - - var refusePrivateMessages: Bool { - didSet { UserDefaults.standard.set(self.refusePrivateMessages, forKey: PrefsKeys.refusePrivateMessages.rawValue) } - } - - var playSounds: Bool { - didSet { UserDefaults.standard.set(self.playSounds, forKey: PrefsKeys.playSounds.rawValue) } - } - - var playChatSound: Bool { - didSet { UserDefaults.standard.set(self.playChatSound, forKey: PrefsKeys.playChatSound.rawValue) } - } - - var playFileTransferCompleteSound: Bool { - didSet { UserDefaults.standard.set(self.playFileTransferCompleteSound, forKey: PrefsKeys.playFileTransferCompleteSound.rawValue) } - } - - var playPrivateMessageSound: Bool { - didSet { UserDefaults.standard.set(self.playPrivateMessageSound, forKey: PrefsKeys.playPrivateMessageSound.rawValue) } - } - - var playJoinSound: Bool { - didSet { UserDefaults.standard.set(self.playJoinSound, forKey: PrefsKeys.playJoinSound.rawValue) } - } - - var playLeaveSound: Bool { - didSet { UserDefaults.standard.set(self.playLeaveSound, forKey: PrefsKeys.playLeaveSound.rawValue) } - } - - var playLoggedInSound: Bool { - didSet { UserDefaults.standard.set(self.playLoggedInSound, forKey: PrefsKeys.playLoggedInSound.rawValue) } - } - - var playErrorSound: Bool { - didSet { UserDefaults.standard.set(self.playErrorSound, forKey: PrefsKeys.playErrorSound.rawValue) } - } - - var playChatInvitationSound: Bool { - didSet { UserDefaults.standard.set(self.playChatInvitationSound, forKey: PrefsKeys.playChatInvitationSound.rawValue) } - } - - var refusePrivateChat: Bool { - didSet { UserDefaults.standard.set(self.refusePrivateChat, forKey: PrefsKeys.refusePrivateChat.rawValue) } - } - - var enableAutomaticMessage: Bool { - didSet { UserDefaults.standard.set(self.enableAutomaticMessage, forKey: PrefsKeys.enableAutomaticMessage.rawValue) } - } - - var automaticMessage: String { - didSet { UserDefaults.standard.set(self.automaticMessage, forKey: PrefsKeys.automaticMessage.rawValue) } - } - - var showBannerToolbar: Bool { - didSet { UserDefaults.standard.set(self.showBannerToolbar, forKey: PrefsKeys.showBannerToolbar.rawValue) } - } - - var showJoinLeaveMessages: Bool { - didSet { UserDefaults.standard.set(self.showJoinLeaveMessages, forKey: PrefsKeys.showJoinLeaveMessages.rawValue) } - } -} diff --git a/Hotline/Shared/AsyncLinkPreview.swift b/Hotline/Shared/AsyncLinkPreview.swift deleted file mode 100644 index 89b186f..0000000 --- a/Hotline/Shared/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/Shared/DataAdditions.swift b/Hotline/Shared/DataAdditions.swift deleted file mode 100644 index 0e9dd96..0000000 --- a/Hotline/Shared/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/Shared/FileIconView.swift b/Hotline/Shared/FileIconView.swift deleted file mode 100644 index d0949fa..0000000 --- a/Hotline/Shared/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/Shared/FileImageView.swift b/Hotline/Shared/FileImageView.swift deleted file mode 100644 index 0cc50f1..0000000 --- a/Hotline/Shared/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/Shared/HotlinePanel.swift b/Hotline/Shared/HotlinePanel.swift deleted file mode 100644 index 191e89a..0000000 --- a/Hotline/Shared/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 = false - self.level = .floating - self.hidesOnDeactivate = true - self.animationBehavior = .utilityWindow - - // Allow the panel to appear in a fullscreen space -// self.collectionBehavior.insert(.fullScreenAuxiliary) - self.collectionBehavior.insert(.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 = true - self.standardWindowButton(.zoomButton)?.isHidden = true - self.standardWindowButton(.miniaturizeButton)?.isHidden = true - - // Make it transparent, the view inside will have to set the background. - // This is necessary because otherwise, we will have some space for the titlebar on top of the height of the view itself which we don't want. - self.isOpaque = false - self.backgroundColor = .clear - - // Since we don't show a statusbar, this allows us to drag the window by its background instead of the titlebar. - self.isMovableByWindowBackground = true - self.titlebarAppearsTransparent = true - - let hostingView = NSHostingView(rootView: view.edgesIgnoringSafeArea(.top)) - hostingView.sizingOptions = [.preferredContentSize] - - let visualEffectView = NSVisualEffectView(frame: NSRect(x: 0, y: 0, width: HOTLINE_PANEL_SIZE.width, height: HOTLINE_PANEL_SIZE.height)) - visualEffectView.material = .sidebar - visualEffectView.blendingMode = .behindWindow - visualEffectView.state = NSVisualEffectView.State.active - visualEffectView.autoresizingMask = [.width, .height] - visualEffectView.autoresizesSubviews = true - visualEffectView.addSubview(hostingView) - - self.contentView = visualEffectView - - hostingView.frame = visualEffectView.bounds - - self.cascadeTopLeft(from: NSMakePoint(16, 16)) - } - - override var canBecomeKey: Bool { - return false - } - - override var canBecomeMain: Bool { - return false - } -} diff --git a/Hotline/Shared/NetSocket.swift b/Hotline/Shared/NetSocket.swift deleted file mode 100644 index a7d44ce..0000000 --- a/Hotline/Shared/NetSocket.swift +++ /dev/null @@ -1,269 +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.. 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/Shared/URLAdditions.swift b/Hotline/Shared/URLAdditions.swift deleted file mode 100644 index 1f25541..0000000 --- a/Hotline/Shared/URLAdditions.swift +++ /dev/null @@ -1,26 +0,0 @@ -import Foundation - -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 - } -} diff --git a/Hotline/Shared/VisualEffectView.swift b/Hotline/Shared/VisualEffectView.swift deleted file mode 100644 index e595c55..0000000 --- a/Hotline/Shared/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/Sounds/Application-iOS.swift b/Hotline/Sounds/Application-iOS.swift deleted file mode 100644 index 2fb6ad2..0000000 --- a/Hotline/Sounds/Application-iOS.swift +++ /dev/null @@ -1,18 +0,0 @@ -import SwiftUI -import SwiftData -import UniformTypeIdentifiers - -@main -struct Application: App { - private var model = Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient()) - - @FocusedValue(\.activeHotlineModel) private var activeHotline: Hotline? - @FocusedValue(\.activeServerState) private var activeServerState: ServerState? - - var body: some Scene { - WindowGroup { - TrackerView() - .environment(model) - } - } -} diff --git a/Hotline/Sounds/chat-message.aiff b/Hotline/Sounds/chat-message.aiff deleted file mode 100644 index 73ea391..0000000 Binary files a/Hotline/Sounds/chat-message.aiff and /dev/null differ diff --git a/Hotline/Sounds/error.aiff b/Hotline/Sounds/error.aiff deleted file mode 100644 index 8a9f7f6..0000000 Binary files a/Hotline/Sounds/error.aiff and /dev/null differ diff --git a/Hotline/Sounds/logged-in.aiff b/Hotline/Sounds/logged-in.aiff deleted file mode 100644 index 5f59de0..0000000 Binary files a/Hotline/Sounds/logged-in.aiff and /dev/null differ diff --git a/Hotline/Sounds/new-news.aiff b/Hotline/Sounds/new-news.aiff deleted file mode 100644 index 5c56582..0000000 Binary files a/Hotline/Sounds/new-news.aiff and /dev/null differ diff --git a/Hotline/Sounds/server-message.aiff b/Hotline/Sounds/server-message.aiff deleted file mode 100644 index 152bebe..0000000 Binary files a/Hotline/Sounds/server-message.aiff and /dev/null differ diff --git a/Hotline/Sounds/transfer-complete.aiff b/Hotline/Sounds/transfer-complete.aiff deleted file mode 100644 index b22e0ae..0000000 Binary files a/Hotline/Sounds/transfer-complete.aiff and /dev/null differ diff --git a/Hotline/Sounds/user-login.aiff b/Hotline/Sounds/user-login.aiff deleted file mode 100644 index 0dcfb6f..0000000 Binary files a/Hotline/Sounds/user-login.aiff and /dev/null differ diff --git a/Hotline/Sounds/user-logout.aiff b/Hotline/Sounds/user-logout.aiff deleted file mode 100644 index 8999edc..0000000 Binary files a/Hotline/Sounds/user-logout.aiff and /dev/null differ diff --git a/Hotline/State/AppState.swift b/Hotline/State/AppState.swift new file mode 100644 index 0000000..11bae74 --- /dev/null +++ b/Hotline/State/AppState.swift @@ -0,0 +1,24 @@ +import SwiftUI + +extension EnvironmentValues { + @Entry var appState: AppState = AppState.shared +} + +@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/State/Preferences.swift b/Hotline/State/Preferences.swift new file mode 100644 index 0000000..fef2b9a --- /dev/null +++ b/Hotline/State/Preferences.swift @@ -0,0 +1,138 @@ +import SwiftUI + +extension EnvironmentValues { + @Entry var preferences: Prefs = Prefs.shared +} + +enum PrefsKeys: String { + case username = "username" + case userIconID = "user icon id" + case refusePrivateMessages = "refuse private messages" + case refusePrivateChat = "refuse private chat" + case enableAutomaticMessage = "enable automatic message" + case automaticMessage = "automatic message" + case playSounds = "play sounds" + case playChatSound = "play chat sound" + case playFileTransferCompleteSound = "play file transfer complete sound" + case playPrivateMessageSound = "play private message sound" + case playJoinSound = "play join sound" + case playLeaveSound = "play leave sound" + case playLoggedInSound = "play logged in sound" + case playErrorSound = "play error sound" + case playChatInvitationSound = "play chat invitation sound" + case showBannerToolbar = "show banner toolbar" + case showJoinLeaveMessages = "show join leave messages" +} + +@Observable +class Prefs { + static let shared = Prefs() + + private init() { + UserDefaults.standard.register(defaults:[ + PrefsKeys.username.rawValue: "guest", + PrefsKeys.userIconID.rawValue: 191, + PrefsKeys.refusePrivateMessages.rawValue: false, + PrefsKeys.refusePrivateChat.rawValue: false, + PrefsKeys.enableAutomaticMessage.rawValue: false, + PrefsKeys.automaticMessage.rawValue: "", + PrefsKeys.playSounds.rawValue: true, + PrefsKeys.playChatSound.rawValue: true, + PrefsKeys.playFileTransferCompleteSound.rawValue: true, + PrefsKeys.playPrivateMessageSound.rawValue: true, + PrefsKeys.playJoinSound.rawValue: true, + PrefsKeys.playLeaveSound.rawValue: true, + PrefsKeys.playLoggedInSound.rawValue: true, + PrefsKeys.playErrorSound.rawValue: true, + PrefsKeys.playChatInvitationSound.rawValue: true, + PrefsKeys.showBannerToolbar.rawValue: true, + PrefsKeys.showJoinLeaveMessages.rawValue: true, + ]) + + self.username = UserDefaults.standard.string(forKey: PrefsKeys.username.rawValue)! + self.userIconID = UserDefaults.standard.integer(forKey: PrefsKeys.userIconID.rawValue) + self.refusePrivateMessages = UserDefaults.standard.bool(forKey: PrefsKeys.refusePrivateMessages.rawValue) + self.refusePrivateChat = UserDefaults.standard.bool(forKey: PrefsKeys.refusePrivateChat.rawValue) + self.enableAutomaticMessage = UserDefaults.standard.bool(forKey: PrefsKeys.enableAutomaticMessage.rawValue) + self.automaticMessage = UserDefaults.standard.string(forKey: PrefsKeys.automaticMessage.rawValue)! + self.playSounds = UserDefaults.standard.bool(forKey: PrefsKeys.playSounds.rawValue) + self.playChatSound = UserDefaults.standard.bool(forKey: PrefsKeys.playChatSound.rawValue) + self.playFileTransferCompleteSound = UserDefaults.standard.bool(forKey: PrefsKeys.playFileTransferCompleteSound.rawValue) + self.playPrivateMessageSound = UserDefaults.standard.bool(forKey: PrefsKeys.playPrivateMessageSound.rawValue) + self.playJoinSound = UserDefaults.standard.bool(forKey: PrefsKeys.playJoinSound.rawValue) + self.playLeaveSound = UserDefaults.standard.bool(forKey: PrefsKeys.playLeaveSound.rawValue) + self.playLoggedInSound = UserDefaults.standard.bool(forKey: PrefsKeys.playLoggedInSound.rawValue) + self.playErrorSound = UserDefaults.standard.bool(forKey: PrefsKeys.playErrorSound.rawValue) + self.playChatInvitationSound = UserDefaults.standard.bool(forKey: PrefsKeys.playChatInvitationSound.rawValue) + self.showBannerToolbar = UserDefaults.standard.bool(forKey: PrefsKeys.showBannerToolbar.rawValue) + self.showJoinLeaveMessages = UserDefaults.standard.bool(forKey: PrefsKeys.showJoinLeaveMessages.rawValue) + } + + var username: String { + didSet { UserDefaults.standard.set(self.username, forKey: PrefsKeys.username.rawValue) } + } + + var userIconID: Int { + didSet { UserDefaults.standard.set(self.userIconID, forKey: PrefsKeys.userIconID.rawValue) } + } + + var refusePrivateMessages: Bool { + didSet { UserDefaults.standard.set(self.refusePrivateMessages, forKey: PrefsKeys.refusePrivateMessages.rawValue) } + } + + var playSounds: Bool { + didSet { UserDefaults.standard.set(self.playSounds, forKey: PrefsKeys.playSounds.rawValue) } + } + + var playChatSound: Bool { + didSet { UserDefaults.standard.set(self.playChatSound, forKey: PrefsKeys.playChatSound.rawValue) } + } + + var playFileTransferCompleteSound: Bool { + didSet { UserDefaults.standard.set(self.playFileTransferCompleteSound, forKey: PrefsKeys.playFileTransferCompleteSound.rawValue) } + } + + var playPrivateMessageSound: Bool { + didSet { UserDefaults.standard.set(self.playPrivateMessageSound, forKey: PrefsKeys.playPrivateMessageSound.rawValue) } + } + + var playJoinSound: Bool { + didSet { UserDefaults.standard.set(self.playJoinSound, forKey: PrefsKeys.playJoinSound.rawValue) } + } + + var playLeaveSound: Bool { + didSet { UserDefaults.standard.set(self.playLeaveSound, forKey: PrefsKeys.playLeaveSound.rawValue) } + } + + var playLoggedInSound: Bool { + didSet { UserDefaults.standard.set(self.playLoggedInSound, forKey: PrefsKeys.playLoggedInSound.rawValue) } + } + + var playErrorSound: Bool { + didSet { UserDefaults.standard.set(self.playErrorSound, forKey: PrefsKeys.playErrorSound.rawValue) } + } + + var playChatInvitationSound: Bool { + didSet { UserDefaults.standard.set(self.playChatInvitationSound, forKey: PrefsKeys.playChatInvitationSound.rawValue) } + } + + var refusePrivateChat: Bool { + didSet { UserDefaults.standard.set(self.refusePrivateChat, forKey: PrefsKeys.refusePrivateChat.rawValue) } + } + + var enableAutomaticMessage: Bool { + didSet { UserDefaults.standard.set(self.enableAutomaticMessage, forKey: PrefsKeys.enableAutomaticMessage.rawValue) } + } + + var automaticMessage: String { + didSet { UserDefaults.standard.set(self.automaticMessage, forKey: PrefsKeys.automaticMessage.rawValue) } + } + + var showBannerToolbar: Bool { + didSet { UserDefaults.standard.set(self.showBannerToolbar, forKey: PrefsKeys.showBannerToolbar.rawValue) } + } + + var showJoinLeaveMessages: Bool { + didSet { UserDefaults.standard.set(self.showJoinLeaveMessages, forKey: PrefsKeys.showJoinLeaveMessages.rawValue) } + } +} diff --git a/Hotline/iOSApp.swift b/Hotline/iOSApp.swift new file mode 100644 index 0000000..2fb6ad2 --- /dev/null +++ b/Hotline/iOSApp.swift @@ -0,0 +1,18 @@ +import SwiftUI +import SwiftData +import UniformTypeIdentifiers + +@main +struct Application: App { + private var model = Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient()) + + @FocusedValue(\.activeHotlineModel) private var activeHotline: Hotline? + @FocusedValue(\.activeServerState) private var activeServerState: ServerState? + + var body: some Scene { + WindowGroup { + TrackerView() + .environment(model) + } + } +} 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