From 9435cde95452bd0a84e9f4aa79dca29e6f29f7af Mon Sep 17 00:00:00 2001 From: Kenenisa Alemayehu <53818162+kenenisa@users.noreply.github.com> Date: Thu, 18 Sep 2025 18:09:33 +0300 Subject: [PATCH 01/38] feat: TabManager and TabScriptHandler to enhance extension handling and configuration. --- ora/Modules/Browser/BrowserView.swift | 63 +++++++++ ora/OraRoot.swift | 7 + .../Extentions/OraExtensionManager.swift | 130 ++++++++++++++++++ ora/Services/TabManager.swift | 1 + ora/Services/TabScriptHandler.swift | 22 +-- project.yml | 2 +- 6 files changed, 215 insertions(+), 10 deletions(-) create mode 100644 ora/Services/Extentions/OraExtensionManager.swift diff --git a/ora/Modules/Browser/BrowserView.swift b/ora/Modules/Browser/BrowserView.swift index 29f54be3..d30c428d 100644 --- a/ora/Modules/Browser/BrowserView.swift +++ b/ora/Modules/Browser/BrowserView.swift @@ -16,8 +16,71 @@ struct BrowserView: View { sidebarVisibility.toggle(.primary) } } + private func printExtensionInfo() { + if let tab = tabManager.activeTab { + if let controller = tab.webView.configuration.webExtensionController { + print("Controller: \(controller)") + print("Extensions Count: \(controller.extensions.count)") + print("Contexts Count: \(controller.extensionContexts.count)") + for extCtx in controller.extensionContexts { + print("πŸ”₯ Extension Context Properties:") + print(" baseURL: \(extCtx.baseURL)") + print(" commands: \(extCtx.commands.map { $0.id })") + print(" currentPermissionMatchPatterns: \(extCtx.currentPermissionMatchPatterns)") + print(" currentPermissions: \(extCtx.currentPermissions.map { $0.rawValue })") + print(" deniedPermissionMatchPatterns: \(extCtx.deniedPermissionMatchPatterns)") + print(" deniedPermissions: \(extCtx.deniedPermissions.map { "\($0.key.rawValue): \($0.value)" })") + print(" errors: \(extCtx.errors.map { $0.localizedDescription })") + print(" grantedPermissionMatchPatterns: \(extCtx.grantedPermissionMatchPatterns.map { "\($0.key): \($0.value)" })") + print(" grantedPermissions: \(extCtx.grantedPermissions.map { "\($0.key.rawValue): \($0.value)" })") + print(" hasAccessToAllHosts: \(extCtx.hasAccessToAllHosts)") + print(" hasAccessToAllURLs: \(extCtx.hasAccessToAllURLs)") + print(" hasAccessToPrivateData: \(extCtx.hasAccessToPrivateData)") + print(" hasContentModificationRules: \(extCtx.hasContentModificationRules)") + print(" hasInjectedContent: \(extCtx.hasInjectedContent)") + print(" hasRequestedOptionalAccessToAllHosts: \(extCtx.hasRequestedOptionalAccessToAllHosts)") + print(" inspectionName: \(extCtx.inspectionName ?? "None")") + print(" isInspectable: \(extCtx.isInspectable)") + print(" isLoaded: \(extCtx.isLoaded)") + // print(" isBackgroundContentLoaded: \(extCtx.isBackgroundContentLoaded)") + // print(" isContentScriptLoaded: \(extCtx.isContentScriptLoaded)") + print(" openTabs: \(extCtx.openTabs)") + print(" optionsPageURL: \(extCtx.optionsPageURL?.absoluteString ?? "None")") + print(" overrideNewTabPageURL: \(extCtx.overrideNewTabPageURL?.absoluteString ?? "None")") + print(" uniqueIdentifier: \(extCtx.uniqueIdentifier)") + print(" unsupportedAPIs: \(extCtx.unsupportedAPIs ?? [])") + // print(" webExtension: \(extCtx.webExtension?.displayName ?? "None")") + print(" webExtensionController: \(extCtx.webExtensionController != nil ? "Loaded" : "None")") + print(" webViewConfiguration: \(extCtx.webViewConfiguration != nil ? "Configured" : "None")") + let ext = extCtx.webExtension + print(" Extension Details:") + print(" Display Name: \(ext.displayName ?? "None")") + print(" Display Version: \(ext.displayVersion ?? "None")") + print(" Display Description: \(ext.displayDescription ?? "None")") + // print(" Permissions: \(ext.permissions.map { $0.rawValue })") + // print(" Background Content URL: \(ext.backgroundContentURL?.absoluteString ?? "None")") + // print(" Content Scripts Count: \(ext.contentScripts.count)") + + + } + }else{ + print(" there is no controller🀣") + print(tab.webView.configuration.allowsInlinePredictions) + print(tab.webView.configuration.className) + } + } + } var body: some View { + Button("Is ext loaded?"){ + printExtensionInfo() + }.onAppear { + Task { + let url = URL(fileURLWithPath: "/Users/keni/Downloads/dark.zip") + await OraExtensionManager.shared.installExtension(from: url) + + } + } ZStack(alignment: .leading) { HSplit( left: { diff --git a/ora/OraRoot.swift b/ora/OraRoot.swift index 4f98ded9..81140a2a 100644 --- a/ora/OraRoot.swift +++ b/ora/OraRoot.swift @@ -78,6 +78,13 @@ struct OraRoot: View { } var body: some View { + Button("Click to install") { + + Task { + let url = URL(fileURLWithPath: "/Users/keni/Downloads/ext") + await OraExtensionManager.shared.installExtension(from: url) + } + } BrowserView() .background(WindowReader(window: $window)) .environmentObject(appState) diff --git a/ora/Services/Extentions/OraExtensionManager.swift b/ora/Services/Extentions/OraExtensionManager.swift new file mode 100644 index 00000000..1c032d24 --- /dev/null +++ b/ora/Services/Extentions/OraExtensionManager.swift @@ -0,0 +1,130 @@ +// +// OraExtensionManager.swift +// ora +// +// Created by keni on 9/17/25. +// + + +import SwiftUI +import WebKit +import os.log + + +// MARK: - Ora Extension Manager +class OraExtensionManager: NSObject, ObservableObject { + static let shared = OraExtensionManager() + + public var controller: WKWebExtensionController + private let logger = Logger(subsystem: "com.ora.browser", category: "ExtensionManager") + + @Published var installedExtensions: [WKWebExtension] = [] + + override init() { + logger.info("Initializing OraExtensionManager") + let config = WKWebExtensionController.Configuration(identifier: UUID()) + controller = WKWebExtensionController(configuration: config) + super.init() + controller.delegate = self + logger.info("OraExtensionManager initialized successfully") + } + + /// Install an extension from a local file + @MainActor + func installExtension(from url: URL) async { + logger.info("Starting extension installation from URL: \(url.path)") + + Task { + do { + logger.debug("Creating WKWebExtension from resource URL") + let webExtension = try await WKWebExtension(resourceBaseURL: url) + logger.debug("Extension created successfully: \(webExtension.displayName ?? "Unknown")") + + logger.debug("Creating WKWebExtensionContext") + let webContext = WKWebExtensionContext(for: webExtension) + + + logger.debug("Loading extension context into controller") + try controller.load(webContext) + + // Grant permission to allow injection into all pages + if let allUrlsPattern = try? WKWebExtension.MatchPattern(string: "") { + webContext.setPermissionStatus(.grantedExplicitly, for: allUrlsPattern) + logger.debug("Granted permission for extension") + } + + print("\(controller.extensionContexts.count) ctx") + print("\(controller.extensions.count) ext") + + logger.debug("Adding extension to installed extensions list") + installedExtensions.append(webExtension) + + logger.info("Extension installed successfully: \(webExtension.displayName ?? "Unknown")") + } catch { + logger.error("Failed to install extension from \(url.path): \(error.localizedDescription)") + print("❌ Failed to install extension: \(error)") + } + } + + } + + /// Uninstall extension + func uninstallExtension(_ webExtension: WKWebExtension) { + logger.info("Uninstalling extension: \(webExtension.displayName ?? "Unknown")") + + // TODO: Implement proper unload when available + // controller.unload(webExtension) + + let removedCount = installedExtensions.count + installedExtensions.removeAll { $0 == webExtension } + let newCount = installedExtensions.count + + if removedCount > newCount { + logger.info("Extension uninstalled successfully. Remaining extensions: \(newCount)") + } else { + logger.warning("Extension not found in installed extensions list") + } + } +} + +// MARK: - Delegate for Permissions & Lifecycle +extension OraExtensionManager: WKWebExtensionControllerDelegate { + + // When extension requests new permissions + func webExtensionController( + _ controller: WKWebExtensionController, + webExtension: WKWebExtension, + requestsAccessTo permissions: [WKWebExtension.Permission] + ) async -> Bool { + + let extensionName = webExtension.displayName ?? "Unknown" + let permissionNames = permissions.map { $0.rawValue }.joined(separator: ", ") + + logger.info("Extension '\(extensionName)' requesting permissions: \(permissionNames)") + + // βœ… Show SwiftUI prompt to user + print("πŸ”’ Extension \(extensionName) requests: \(permissionNames)") + + // TODO: Replace with real SwiftUI dialog + let granted = true // allow for now + logger.info("Permission request for '\(extensionName)' \(granted ? "granted" : "denied")") + + return granted + } + + // Handle background script messages + func webExtensionController( + _ controller: WKWebExtensionController, + webExtension: WKWebExtension, + didReceiveMessage message: Any, + from context: WKWebExtensionContext + ) { + let extensionName = webExtension.displayName ?? "Unknown" + logger.debug("Received message from extension '\(extensionName)': \(String(describing: message))") + + print("πŸ“© Message from \(extensionName): \(message)") + + // Example: forward to Ora's tab system + logger.debug("Message processing completed for extension '\(extensionName)'") + } +} diff --git a/ora/Services/TabManager.swift b/ora/Services/TabManager.swift index ac2a09da..ca12b455 100644 --- a/ora/Services/TabManager.swift +++ b/ora/Services/TabManager.swift @@ -368,6 +368,7 @@ class TabManager: ObservableObject { activeContainer = tab.container tab.container.lastAccessedAt = Date() tab.updateHeaderColor() + try? modelContext.save() } diff --git a/ora/Services/TabScriptHandler.swift b/ora/Services/TabScriptHandler.swift index 23ee78bb..9cbce956 100644 --- a/ora/Services/TabScriptHandler.swift +++ b/ora/Services/TabScriptHandler.swift @@ -71,6 +71,7 @@ class TabScriptHandler: NSObject, WKScriptMessageHandler { let userAgent = "Mozilla/5.0 (Macintosh; arm64 Mac OS X 14_5) AppleWebKit/616.1.1 (KHTML, like Gecko) Version/18.5 Safari/616.1.1 Ora/1.0" configuration.applicationNameForUserAgent = userAgent + configuration.allowsInlinePredictions = false // Enable JavaScript configuration.preferences.setValue(true, forKey: "developerExtrasEnabled") @@ -84,21 +85,24 @@ class TabScriptHandler: NSObject, WKScriptMessageHandler { forIdentifier: containerId ) } + + configuration.webExtensionController = OraExtensionManager.shared.controller + // Performance optimizations configuration.allowsAirPlayForMediaPlayback = true configuration.preferences.javaScriptCanOpenWindowsAutomatically = false // Enable process pool for better memory management - let processPool = WKProcessPool() - configuration.processPool = processPool - // video shit - configuration.preferences.isElementFullscreenEnabled = true - if #unavailable(macOS 10.12) { - // Picture in picture not available on older macOS versions - } else { -// configuration.allowsPictureInPictureMediaPlaybook = true - } +// let processPool = WKProcessPool() +// configuration.processPool = processPool +// // video shit +// configuration.preferences.isElementFullscreenEnabled = true +// if #unavailable(macOS 10.12) { +// // Picture in picture not available on older macOS versions +// } else { +//// configuration.allowsPictureInPictureMediaPlaybook = true +// } // Enable media playback without user interaction configuration.mediaTypesRequiringUserActionForPlayback = [] diff --git a/project.yml b/project.yml index 5d072cc5..32d13a9e 100644 --- a/project.yml +++ b/project.yml @@ -11,7 +11,7 @@ targets: ora: type: application platform: "macOS" - deploymentTarget: "15.0" + deploymentTarget: "15.4" sources: - path: ora excludes: From fbfb6b9228736d88c3dc5313749b72bea7d5cec4 Mon Sep 17 00:00:00 2001 From: Kenenisa Alemayehu <53818162+kenenisa@users.noreply.github.com> Date: Sat, 20 Sep 2025 03:10:57 +0300 Subject: [PATCH 02/38] feat: integrate OraExtensionManager into URLBar and enhance TabScriptHandler configuration - Added OraExtensionManager as an environment object in URLBar for managing installed extensions. - Implemented a new ExtensionIconView to display extension icons in the URLBar. - Enhanced TabScriptHandler configuration with additional preferences for push notifications and fullscreen support. - Removed unnecessary print statements from BrowserView for cleaner code. --- ora/Modules/Browser/BrowserView.swift | 4 -- ora/OraRoot.swift | 2 + ora/Services/TabScriptHandler.swift | 8 +++ ora/UI/URLBar.swift | 81 +++++++++++++++++++++++---- 4 files changed, 81 insertions(+), 14 deletions(-) diff --git a/ora/Modules/Browser/BrowserView.swift b/ora/Modules/Browser/BrowserView.swift index 439755d6..7da9d191 100644 --- a/ora/Modules/Browser/BrowserView.swift +++ b/ora/Modules/Browser/BrowserView.swift @@ -65,10 +65,6 @@ struct BrowserView: View { } - }else{ - print(" there is no controller🀣") - print(tab.webView.configuration.allowsInlinePredictions) - print(tab.webView.configuration.className) } } } diff --git a/ora/OraRoot.swift b/ora/OraRoot.swift index 81140a2a..ca84b506 100644 --- a/ora/OraRoot.swift +++ b/ora/OraRoot.swift @@ -20,6 +20,7 @@ struct OraRoot: View { @StateObject private var historyManager: HistoryManager @StateObject private var downloadManager: DownloadManager @StateObject private var privacyMode: PrivacyMode + @StateObject private var extensionManager = OraExtensionManager.shared let tabContext: ModelContext let historyContext: ModelContext @@ -96,6 +97,7 @@ struct OraRoot: View { .environmentObject(downloadManager) .environmentObject(updateService) .environmentObject(privacyMode) + .environmentObject(extensionManager) .modelContext(tabContext) .modelContext(historyContext) .modelContext(downloadContext) diff --git a/ora/Services/TabScriptHandler.swift b/ora/Services/TabScriptHandler.swift index 9cbce956..6395d14b 100644 --- a/ora/Services/TabScriptHandler.swift +++ b/ora/Services/TabScriptHandler.swift @@ -78,6 +78,14 @@ class TabScriptHandler: NSObject, WKScriptMessageHandler { configuration.preferences.setValue(true, forKey: "allowsPictureInPictureMediaPlayback") configuration.preferences.setValue(true, forKey: "javaScriptEnabled") configuration.preferences.setValue(true, forKey: "javaScriptCanOpenWindowsAutomatically") + configuration.preferences.setValue(true, forKey: "pushAPIEnabled") + configuration.preferences.setValue(true, forKey: "notificationsEnabled") + configuration.preferences.setValue(true, forKey: "notificationEventEnabled") + configuration.preferences.setValue(true, forKey: "fullScreenEnabled") + // configuration.preferences.setValue(false, forKey: "allowsAutomaticSpellingCorrection") + // configuration.preferences.setValue(false, forKey: "allowsAutomaticTextReplacement") + // configuration.preferences.setValue(false, forKey: "allowsAutomaticQuoteSubstitution") + // configuration.preferences.setValue(false, forKey: "allowsAutomaticDashSubstitution") if temporaryStorage { configuration.websiteDataStore = WKWebsiteDataStore.nonPersistent() } else { diff --git a/ora/UI/URLBar.swift b/ora/UI/URLBar.swift index f3795f09..b8ae4b45 100644 --- a/ora/UI/URLBar.swift +++ b/ora/UI/URLBar.swift @@ -1,19 +1,41 @@ import AppKit import SwiftUI +import WebKit + +struct ExtensionIconView: NSViewRepresentable { + let iconName: String + let tooltip: String + + func makeNSView(context: Context) -> NSImageView { + let imageView = NSImageView() + imageView.image = NSImage(systemSymbolName: iconName, accessibilityDescription: nil) + imageView.imageScaling = .scaleProportionallyUpOrDown + imageView.toolTip = tooltip + imageView.isEditable = false + return imageView + } + + func updateNSView(_ nsView: NSImageView, context: Context) { + nsView.toolTip = tooltip + } +} // MARK: - URLBar struct URLBar: View { @EnvironmentObject var tabManager: TabManager @EnvironmentObject var appState: AppState + @EnvironmentObject var extensionManager: OraExtensionManager @State private var showCopiedAnimation = false @State private var startWheelAnimation = false @State private var editingURLString: String = "" @FocusState private var isEditing: Bool @Environment(\.colorScheme) var colorScheme + @State private var alertMessage: String? let onSidebarToggle: () -> Void + let size: CGSize = CGSize(width: 32, height: 32) private func getForegroundColor(_ tab: Tab) -> Color { // Convert backgroundColor to NSColor for luminance calculation @@ -64,6 +86,40 @@ struct URLBar: View { picker.show(relativeTo: sourceRect, of: sourceView, preferredEdge: .minY) } } + + private var extensionIconsView: some View { + HStack(spacing: 4) { + + ForEach(extensionManager.installedExtensions, id: \.self) { ext in + Text(ext.displayName ?? "Unknown") + if let image = ext.icon(for: size) { + Image(nsImage: image) // use NSImage(nsImage:) on macOS + .resizable() + .frame(width: size.width, height: size.height) + .cornerRadius(6) + }else { + + + Image(systemName: "puzzlepiece.extension") + .resizable() + .frame(width: size.width, height: size.height) + .foregroundColor(.secondary) + } + + + } + } + .padding(.horizontal, 4) + .alert("Notice", isPresented: .constant(alertMessage != nil), actions: { + Button("OK", role: .cancel) { + alertMessage = nil + } + }, message: { + if let message = alertMessage { + Text(message) + } + }) + } var body: some View { HStack { @@ -209,17 +265,22 @@ struct URLBar: View { ) ) ) - .overlay( - // Hidden button for keyboard shortcut - Button("") { - isEditing = true - } - .keyboardShortcut(KeyboardShortcuts.Address.focus) - .opacity(0) - .allowsHitTesting(false) - ) + .overlay( + // Hidden button for keyboard shortcut + Button("") { + isEditing = true + } + .keyboardShortcut(KeyboardShortcuts.Address.focus) + .opacity(0) + .allowsHitTesting(false) + ) + + // Extension icons + if !extensionManager.installedExtensions.isEmpty { + extensionIconsView + } - ShareLinkButton( + ShareLinkButton( isEnabled: true, foregroundColor: buttonForegroundColor, onShare: { sourceView, sourceRect in From 2513f48781374a2a5e8627b609349aae0151d0a0 Mon Sep 17 00:00:00 2001 From: Kenenisa Alemayehu <53818162+kenenisa@users.noreply.github.com> Date: Sat, 27 Sep 2025 00:30:26 +0300 Subject: [PATCH 03/38] feat: implement extension loading and update UI components - Added functionality to load all available extensions from the application support directory in OraExtensionManager. - Updated OraRoot and BrowserView to remove deprecated installation buttons and improve UI structure. - Introduced a new Extensions tab in SettingsContentView for better extension management. --- ora/Modules/Browser/BrowserView.swift | 11 +- .../Sections/ExtensionsSettingsView.swift | 130 ++++++++++++++++++ .../Settings/SettingsContentView.swift | 8 +- ora/OraRoot.swift | 10 +- .../Extentions/OraExtensionManager.swift | 33 ++++- 5 files changed, 173 insertions(+), 19 deletions(-) create mode 100644 ora/Modules/Settings/Sections/ExtensionsSettingsView.swift diff --git a/ora/Modules/Browser/BrowserView.swift b/ora/Modules/Browser/BrowserView.swift index a73e4005..93281a40 100644 --- a/ora/Modules/Browser/BrowserView.swift +++ b/ora/Modules/Browser/BrowserView.swift @@ -72,15 +72,6 @@ struct BrowserView: View { } var body: some View { - Button("Is ext loaded?"){ - printExtensionInfo() - }.onAppear { - Task { - let url = URL(fileURLWithPath: "/Users/keni/Downloads/dark.zip") - await OraExtensionManager.shared.installExtension(from: url) - - } - } ZStack(alignment: .leading) { HSplit( left: { @@ -99,6 +90,7 @@ struct BrowserView: View { } } ) + .hide(sidebarVisibility) .splitter { Splitter.invisible() } .fraction(sidebarFraction) @@ -136,6 +128,7 @@ struct BrowserView: View { FloatingTabSwitcher() } } + if sidebarVisibility.side == .primary { // Floating sidebar with resizable width based on persisted fraction diff --git a/ora/Modules/Settings/Sections/ExtensionsSettingsView.swift b/ora/Modules/Settings/Sections/ExtensionsSettingsView.swift new file mode 100644 index 00000000..aad07f79 --- /dev/null +++ b/ora/Modules/Settings/Sections/ExtensionsSettingsView.swift @@ -0,0 +1,130 @@ +import SwiftUI + +struct ExtensionsSettingsView: View { + + @State private var isImporting = false + @State private var extensionDirectories: [URL] = [] + @State private var extensionsDir: URL? + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + Text("Extensions") + .font(.title) + .padding(.bottom, 10) + + if let dir = extensionsDir { + Text("Extensions Directory: \(dir.path)") + .font(.caption) + .foregroundColor(.secondary) + } + + Button("Import Extension Zip") { + isImporting = true + } + .fileImporter( + isPresented: $isImporting, + allowedContentTypes: [.zip], + allowsMultipleSelection: false + ) { result in + switch result { + case .success(let urls): + if let url = urls.first { + Task { + await importAndExtractZip(from: url) + } + } + case .failure(let error): + print("File import failed: \(error)") + } + } + + Divider() + + Text("Installed Extensions:") + .font(.headline) + + List(extensionDirectories, id: \.self) { dir in + HStack { + Text(dir.lastPathComponent) + Spacer() + Button("Install") { + Task { + await OraExtensionManager.shared.installExtension(from: dir) + + } + } + .buttonStyle(.bordered) + } + } + .frame(height: 200) + } + .padding() + .onAppear { + setupExtensionsDirectory() + loadExtensionDirectories() + } + } + + private func setupExtensionsDirectory() { + let supportDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + extensionsDir = supportDir.appendingPathComponent("extensions") + if !FileManager.default.fileExists(atPath: extensionsDir!.path) { + try? FileManager.default.createDirectory(at: extensionsDir!, withIntermediateDirectories: true) + } + } + + private func loadExtensionDirectories() { + guard let dir = extensionsDir else { return } + do { + let contents = try FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: [.isDirectoryKey]) + extensionDirectories = contents.filter { url in + var isDir: ObjCBool = false + FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir) + return isDir.boolValue + } + } catch { + print("Failed to load directories: \(error)") + } + } + + private func importAndExtractZip(from zipURL: URL) async { + guard let destDir = extensionsDir else { return } + + // Create a subfolder named after the zip file (without .zip) + let zipName = zipURL.deletingPathExtension().lastPathComponent + let extractDir = destDir.appendingPathComponent(zipName) + if !FileManager.default.fileExists(atPath: extractDir.path) { + try? FileManager.default.createDirectory(at: extractDir, withIntermediateDirectories: true) + } + + // Copy zip to temp location in extractDir + let tempZipURL = extractDir.appendingPathComponent("temp.zip") + do { + try FileManager.default.copyItem(at: zipURL, to: tempZipURL) + } catch { + print("Failed to copy zip: \(error)") + return + } + + // Extract using bash unzip + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip") + process.arguments = ["-o", tempZipURL.path, "-d", extractDir.path] // -o to overwrite + + do { + try process.run() + process.waitUntilExit() + if process.terminationStatus == 0 { + print("Extraction successful") + // Remove temp zip + try? FileManager.default.removeItem(at: tempZipURL) + // Reload directories + loadExtensionDirectories() + } else { + print("Extraction failed") + } + } catch { + print("Failed to extract: \(error)") + } + } +} diff --git a/ora/Modules/Settings/SettingsContentView.swift b/ora/Modules/Settings/SettingsContentView.swift index 2ec2801f..0b105c08 100644 --- a/ora/Modules/Settings/SettingsContentView.swift +++ b/ora/Modules/Settings/SettingsContentView.swift @@ -1,7 +1,7 @@ import SwiftUI enum SettingsTab: Hashable { - case general, spaces, privacySecurity, shortcuts, searchEngines + case general, spaces, privacySecurity, shortcuts, searchEngines, extensions var title: String { switch self { @@ -10,6 +10,7 @@ enum SettingsTab: Hashable { case .privacySecurity: return "Privacy" case .shortcuts: return "Shortcuts" case .searchEngines: return "Search" + case .extensions: return "Extensions" } } @@ -20,6 +21,7 @@ enum SettingsTab: Hashable { case .privacySecurity: return "lock.shield" case .shortcuts: return "command" case .searchEngines: return "magnifyingglass" + case .extensions: return "puzzlepiece" } } } @@ -50,6 +52,10 @@ struct SettingsContentView: View { SearchEngineSettingsView() .tabItem { Label(SettingsTab.searchEngines.title, systemImage: SettingsTab.searchEngines.symbol) } .tag(SettingsTab.searchEngines) + + ExtensionsSettingsView() + .tabItem { Label(SettingsTab.extensions.title, systemImage: SettingsTab.extensions.symbol) } + .tag(SettingsTab.extensions) } .tabViewStyle(.automatic) .frame(width: 600, height: 350) diff --git a/ora/OraRoot.swift b/ora/OraRoot.swift index 87014631..098126a5 100644 --- a/ora/OraRoot.swift +++ b/ora/OraRoot.swift @@ -79,13 +79,7 @@ struct OraRoot: View { } var body: some View { - Button("Click to install") { - Task { - let url = URL(fileURLWithPath: "/Users/keni/Downloads/ext") - await OraExtensionManager.shared.installExtension(from: url) - } - } BrowserView() .background(WindowReader(window: $window)) .environmentObject(appState) @@ -123,6 +117,10 @@ struct OraRoot: View { updateService.checkForUpdatesInBackground() } } + + Task { + await extensionManager.loadAllExtensions() + } NotificationCenter.default.addObserver(forName: .showLauncher, object: nil, queue: .main) { note in guard note.object as? NSWindow === window ?? NSApp.keyWindow else { return } if tabManager.activeTab != nil { diff --git a/ora/Services/Extentions/OraExtensionManager.swift b/ora/Services/Extentions/OraExtensionManager.swift index 1c032d24..3e006b09 100644 --- a/ora/Services/Extentions/OraExtensionManager.swift +++ b/ora/Services/Extentions/OraExtensionManager.swift @@ -68,17 +68,44 @@ class OraExtensionManager: NSObject, ObservableObject { } + /// Load all available extensions from the extensions directory + @MainActor + func loadAllExtensions() async { + logger.info("Loading all available extensions") + let supportDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + let extensionsDir = supportDir.appendingPathComponent("extensions") + + guard FileManager.default.fileExists(atPath: extensionsDir.path) else { + logger.info("Extensions directory does not exist, skipping load") + return + } + + do { + let contents = try FileManager.default.contentsOfDirectory(at: extensionsDir, includingPropertiesForKeys: [.isDirectoryKey]) + for url in contents { + var isDir: ObjCBool = false + if FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir), isDir.boolValue { + logger.debug("Loading extension from: \(url.path)") + await installExtension(from: url) + } + } + logger.info("Finished loading all extensions") + } catch { + logger.error("Failed to load extensions: \(error.localizedDescription)") + } + } + /// Uninstall extension func uninstallExtension(_ webExtension: WKWebExtension) { logger.info("Uninstalling extension: \(webExtension.displayName ?? "Unknown")") - + // TODO: Implement proper unload when available // controller.unload(webExtension) - + let removedCount = installedExtensions.count installedExtensions.removeAll { $0 == webExtension } let newCount = installedExtensions.count - + if removedCount > newCount { logger.info("Extension uninstalled successfully. Remaining extensions: \(newCount)") } else { From 4e8b94f378add1fefa3ca6b2950f1a029b5175ce Mon Sep 17 00:00:00 2001 From: Kenenisa Alemayehu <53818162+kenenisa@users.noreply.github.com> Date: Sat, 27 Sep 2025 18:23:45 +0300 Subject: [PATCH 04/38] feat: enhance extension management and download functionality - Integrated a new ExtensionViewModel to manage extension directories in ExtensionsSettingsView. - Added download and installation capabilities for extensions via the TabScriptHandler. - Implemented a floating download button for .xpi links in WebViewNavigationDelegate. - Updated OraExtensionManager to handle tab API messages and manage installed extensions more effectively. - Improved UI components in URLBar to display extension icons using a dedicated ExtensionIconButton. --- .../Sections/ExtensionsSettingsView.swift | 42 ++++-- ora/OraRoot.swift | 1 + .../Extentions/OraExtensionManager.swift | 139 +++++++++++++++++- ora/Services/TabScriptHandler.swift | 61 ++++++++ ora/Services/WebViewNavigationDelegate.swift | 112 +++++++++++++- ora/UI/URLBar.swift | 65 +++++--- 6 files changed, 378 insertions(+), 42 deletions(-) diff --git a/ora/Modules/Settings/Sections/ExtensionsSettingsView.swift b/ora/Modules/Settings/Sections/ExtensionsSettingsView.swift index aad07f79..faa7c402 100644 --- a/ora/Modules/Settings/Sections/ExtensionsSettingsView.swift +++ b/ora/Modules/Settings/Sections/ExtensionsSettingsView.swift @@ -1,9 +1,12 @@ import SwiftUI +class ExtensionViewModel: ObservableObject { + @Published var directories: [URL] = [] +} + struct ExtensionsSettingsView: View { - + @StateObject private var viewModel = ExtensionViewModel() @State private var isImporting = false - @State private var extensionDirectories: [URL] = [] @State private var extensionsDir: URL? var body: some View { @@ -43,17 +46,32 @@ struct ExtensionsSettingsView: View { Text("Installed Extensions:") .font(.headline) - List(extensionDirectories, id: \.self) { dir in - HStack { - Text(dir.lastPathComponent) - Spacer() - Button("Install") { - Task { - await OraExtensionManager.shared.installExtension(from: dir) - + ScrollView { + VStack(spacing: 10) { + ForEach(viewModel.directories, id: \.path) { dir in + HStack { + Text(dir.lastPathComponent) + Spacer() + Button("Install") { + Task { + await OraExtensionManager.shared.installExtension(from: dir) + } + } + .buttonStyle(.bordered) + if let extensionToUninstall = OraExtensionManager.shared.extensionMap[dir] { + Button("Delete") { + OraExtensionManager.shared.uninstallExtension(extensionToUninstall) + // Remove the directory + try? FileManager.default.removeItem(at: dir) + // Reload directories + loadExtensionDirectories() + } + .buttonStyle(.bordered) + .foregroundColor(.red) + } } + .padding(.horizontal) } - .buttonStyle(.bordered) } } .frame(height: 200) @@ -77,7 +95,7 @@ struct ExtensionsSettingsView: View { guard let dir = extensionsDir else { return } do { let contents = try FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: [.isDirectoryKey]) - extensionDirectories = contents.filter { url in + viewModel.directories = contents.filter { url in var isDir: ObjCBool = false FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir) return isDir.boolValue diff --git a/ora/OraRoot.swift b/ora/OraRoot.swift index 098126a5..506019ab 100644 --- a/ora/OraRoot.swift +++ b/ora/OraRoot.swift @@ -118,6 +118,7 @@ struct OraRoot: View { } } + OraExtensionManager.shared.tabManager = tabManager Task { await extensionManager.loadAllExtensions() } diff --git a/ora/Services/Extentions/OraExtensionManager.swift b/ora/Services/Extentions/OraExtensionManager.swift index 3e006b09..403b3b86 100644 --- a/ora/Services/Extentions/OraExtensionManager.swift +++ b/ora/Services/Extentions/OraExtensionManager.swift @@ -14,11 +14,13 @@ import os.log // MARK: - Ora Extension Manager class OraExtensionManager: NSObject, ObservableObject { static let shared = OraExtensionManager() - + public var controller: WKWebExtensionController private let logger = Logger(subsystem: "com.ora.browser", category: "ExtensionManager") - + @Published var installedExtensions: [WKWebExtension] = [] + var extensionMap: [URL: WKWebExtension] = [:] + var tabManager: TabManager? override init() { logger.info("Initializing OraExtensionManager") @@ -42,22 +44,35 @@ class OraExtensionManager: NSObject, ObservableObject { logger.debug("Creating WKWebExtensionContext") let webContext = WKWebExtensionContext(for: webExtension) - - + webContext.isInspectable = true + logger.debug("Loading extension context into controller") try controller.load(webContext) - // Grant permission to allow injection into all pages + // Load background content if available + webContext.loadBackgroundContent { [self] error in + if let error = error { + self.logger.error("Failed to load background content: \(error.localizedDescription)") + } else { + self.logger.debug("Background content loaded successfully") + } + } + + // Grant permissions if let allUrlsPattern = try? WKWebExtension.MatchPattern(string: "") { webContext.setPermissionStatus(.grantedExplicitly, for: allUrlsPattern) logger.debug("Granted permission for extension") } + let storagePermission = WKWebExtension.Permission.storage + webContext.setPermissionStatus(.grantedExplicitly, for: storagePermission) + logger.debug("Granted storage permission for extension") print("\(controller.extensionContexts.count) ctx") print("\(controller.extensions.count) ext") logger.debug("Adding extension to installed extensions list") installedExtensions.append(webExtension) + extensionMap[url] = webExtension logger.info("Extension installed successfully: \(webExtension.displayName ?? "Unknown")") } catch { @@ -104,6 +119,7 @@ class OraExtensionManager: NSObject, ObservableObject { let removedCount = installedExtensions.count installedExtensions.removeAll { $0 == webExtension } + extensionMap = extensionMap.filter { $0.value != webExtension } let newCount = installedExtensions.count if removedCount > newCount { @@ -148,10 +164,117 @@ extension OraExtensionManager: WKWebExtensionControllerDelegate { ) { let extensionName = webExtension.displayName ?? "Unknown" logger.debug("Received message from extension '\(extensionName)': \(String(describing: message))") - + print("πŸ“© Message from \(extensionName): \(message)") - - // Example: forward to Ora's tab system + + // Handle tab API messages + handleTabAPIMessage(message, from: context) + logger.debug("Message processing completed for extension '\(extensionName)'") } + + private func handleTabAPIMessage(_ message: Any, from context: WKWebExtensionContext) { + guard let dict = message as? [String: Any], + let api = dict["api"] as? String, api == "tabs", + let method = dict["method"] as? String, + let params = dict["params"] as? [String: Any] else { + return + } + + guard let tabManager = tabManager else { + logger.error("TabManager not available for extension tab API") + return + } + + Task { @MainActor in + switch method { + case "create": + handleTabsCreate(params: params, context: context) + case "remove": + handleTabsRemove(params: params, context: context) + case "update": + handleTabsUpdate(params: params, context: context) + case "query": + handleTabsQuery(params: params, context: context) + case "get": + handleTabsGet(params: params, context: context) + default: + logger.debug("Unknown tabs API method: \(method)") + } + } + } + + @MainActor + private func handleTabsCreate(params: [String: Any], context: WKWebExtensionContext) { + guard let urlString = params["url"] as? String, + let url = URL(string: urlString), + let container = tabManager?.activeContainer else { + return + } + + let isPrivate = params["incognito"] as? Bool ?? false + let active = params["active"] as? Bool ?? true + + // Create history and download managers if needed + let historyManager = HistoryManager(modelContainer: tabManager!.modelContainer, modelContext: tabManager!.modelContext) + let downloadManager = DownloadManager(modelContainer: tabManager!.modelContainer, modelContext: tabManager!.modelContext) + + if active { + tabManager?.openTab(url: url, historyManager: historyManager, downloadManager: downloadManager, isPrivate: isPrivate) + } else { + _ = tabManager?.addTab(url: url, container: container, historyManager: historyManager, downloadManager: downloadManager, isPrivate: isPrivate) + } + } + + @MainActor + private func handleTabsRemove(params: [String: Any], context: WKWebExtensionContext) { + guard let tabIdStrings = params["tabIds"] as? [String] else { return } + + for tabIdString in tabIdStrings { + if let tabId = UUID(uuidString: tabIdString), + let container = tabManager?.activeContainer, + let tab = container.tabs.first(where: { $0.id == tabId }) { + tabManager?.closeTab(tab: tab) + } + } + } + + @MainActor + private func handleTabsUpdate(params: [String: Any], context: WKWebExtensionContext) { + guard let tabIdString = params["tabId"] as? String, + let tabId = UUID(uuidString: tabIdString), + let container = tabManager?.activeContainer, + let tab = container.tabs.first(where: { $0.id == tabId }) else { return } + + // Update tab properties + if let urlString = params["url"] as? String, let url = URL(string: urlString) { + tab.url = url + tab.webView.load(URLRequest(url: url)) + } + } + + @MainActor + private func handleTabsQuery(params: [String: Any], context: WKWebExtensionContext) { + // Query tabs + // For now, return all tabs in active container + guard let container = tabManager?.activeContainer else { return } + let tabs: [[String: Any]] = container.tabs.map { tab in + ["id": tab.id.uuidString, "url": tab.urlString, "title": tab.title, "active": tabManager?.isActive(tab) ?? false] as [String: Any] + } + // Note: Cannot send response back to extension via WKWebExtensionContext + // Extensions should use events or other mechanisms + logger.debug("Tabs query result: \(tabs)") + } + + @MainActor + private func handleTabsGet(params: [String: Any], context: WKWebExtensionContext) { + guard let tabIdString = params["tabId"] as? String, + let tabId = UUID(uuidString: tabIdString), + let container = tabManager?.activeContainer, + let tab = container.tabs.first(where: { $0.id == tabId }) else { return } + + let tabInfo: [String: Any] = ["id": tab.id.uuidString, "url": tab.urlString, "title": tab.title, "active": tabManager?.isActive(tab) ?? false] + // Note: Cannot send response back to extension via WKWebExtensionContext + logger.debug("Tab get result: \(tabInfo)") + } } diff --git a/ora/Services/TabScriptHandler.swift b/ora/Services/TabScriptHandler.swift index 584622c1..1297e3e2 100644 --- a/ora/Services/TabScriptHandler.swift +++ b/ora/Services/TabScriptHandler.swift @@ -62,6 +62,15 @@ class TabScriptHandler: NSObject, WKScriptMessageHandler { self?.mediaController?.receive(event: payload, from: tab) } } + } else if message.name == "downloadExtension" { + guard let body = message.body as? [String: Any], + let urlString = body["url"] as? String, + let url = URL(string: urlString), + let tab = self.tab + else { return } + Task { + await downloadAndInstallExtension(from: url, tab: tab) + } } } @@ -82,6 +91,7 @@ class TabScriptHandler: NSObject, WKScriptMessageHandler { configuration.preferences.setValue(true, forKey: "notificationsEnabled") configuration.preferences.setValue(true, forKey: "notificationEventEnabled") configuration.preferences.setValue(true, forKey: "fullScreenEnabled") + // configuration.preferences.setValue(false, forKey: "allowsAutomaticSpellingCorrection") // configuration.preferences.setValue(false, forKey: "allowsAutomaticTextReplacement") // configuration.preferences.setValue(false, forKey: "allowsAutomaticQuoteSubstitution") @@ -125,11 +135,62 @@ class TabScriptHandler: NSObject, WKScriptMessageHandler { contentController.add(self, name: "listener") contentController.add(self, name: "linkHover") contentController.add(self, name: "mediaEvent") + contentController.add(self, name: "downloadExtension") configuration.userContentController = contentController return configuration } + private func downloadAndInstallExtension(from url: URL, tab: Tab) async { + logger.info("Downloading extension from: \(url.absoluteString)") + + // Download the file + guard let (data, response) = try? await URLSession.shared.data(from: url), + let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + logger.error("Failed to download extension") + return + } + + // Save to temp file + let tempDir = FileManager.default.temporaryDirectory + let tempZipURL = tempDir.appendingPathComponent("downloaded_extension.zip") + try? data.write(to: tempZipURL) + + // Extract + let extensionsDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!.appendingPathComponent("extensions") + if !FileManager.default.fileExists(atPath: extensionsDir.path) { + try? FileManager.default.createDirectory(at: extensionsDir, withIntermediateDirectories: true) + } + + // Create subfolder named after the file or something + let zipName = url.deletingPathExtension().lastPathComponent + let extractDir = extensionsDir.appendingPathComponent(zipName) + if !FileManager.default.fileExists(atPath: extractDir.path) { + try? FileManager.default.createDirectory(at: extractDir, withIntermediateDirectories: true) + } + + // Extract using unzip + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip") + process.arguments = ["-o", tempZipURL.path, "-d", extractDir.path] + try? process.run() + process.waitUntilExit() + + if process.terminationStatus == 0 { + logger.info("Extraction successful, installing extension") + await OraExtensionManager.shared.installExtension(from: extractDir) + // Reload the tab + DispatchQueue.main.async { + tab.webView.reload() + } + } else { + logger.error("Extraction failed") + } + + // Clean up temp file + try? FileManager.default.removeItem(at: tempZipURL) + } + deinit { // Optional cleanup logger.debug("TabScriptHandler deinitialized") diff --git a/ora/Services/WebViewNavigationDelegate.swift b/ora/Services/WebViewNavigationDelegate.swift index 50577ce7..901cc9ec 100644 --- a/ora/Services/WebViewNavigationDelegate.swift +++ b/ora/Services/WebViewNavigationDelegate.swift @@ -2,6 +2,7 @@ import AppKit import os.log import SwiftUI @preconcurrency import WebKit +import Foundation private let logger = Logger(subsystem: "com.orabrowser.ora", category: "WebViewNavigationDelegate") @@ -251,11 +252,11 @@ class WebViewNavigationDelegate: NSObject, WKNavigationDelegate { """ ) if navigationAction.modifierFlags.contains(.command), - let url = navigationAction.request.url, - let tab = self.tab, - let tabManager = tab.tabManager, - let historyManager = tab.historyManager, - let downloadManager = tab.downloadManager + let url = navigationAction.request.url, + let tab = self.tab, + let tabManager = tab.tabManager, + let historyManager = tab.historyManager, + let downloadManager = tab.downloadManager { // Open link in new tab DispatchQueue.main.async { @@ -272,6 +273,8 @@ class WebViewNavigationDelegate: NSObject, WKNavigationDelegate { return } + + // Allow normal navigation decisionHandler(.allow) } @@ -302,6 +305,7 @@ class WebViewNavigationDelegate: NSObject, WKNavigationDelegate { onChange?(webView.title, webView.url) onProgressChange?(webView.estimatedProgress * 100.0) webView.evaluateJavaScript(navigationScript, completionHandler: nil) + injectDownloadScriptIfNeeded(webView) takeSnapshotAfterLoad(webView) originalURL = nil // Clear stored URL after successful navigation } @@ -405,6 +409,104 @@ class WebViewNavigationDelegate: NSObject, WKNavigationDelegate { } } + private func injectDownloadScriptIfNeeded(_ webView: WKWebView) { + guard let url = webView.url, url.host?.contains("mozilla.org") == true else { return } + + // Inject script to add a fixed floating download button if .xpi links are found + let downloadScript = """ + (function() { + const xpiLinks = document.querySelectorAll('a[href*=".xpi"]'); + if (xpiLinks.length > 0 && !document.querySelector('.ora-floating-download-btn')) { + const button = document.createElement('button'); + button.innerText = 'Download to Ora!'; + button.className = 'ora-floating-download-btn'; + button.style.position = 'fixed'; + button.style.top = '20px'; + button.style.right = '20px'; + button.style.zIndex = '10000'; + button.style.padding = '12px 20px'; + button.style.background = 'linear-gradient(45deg, #ff6b35, #f7931e, #ff4757)'; + button.style.color = 'white'; + button.style.border = '3px solid #fff'; + button.style.borderRadius = '25px'; + button.style.cursor = 'pointer'; + button.style.boxShadow = '0 4px 15px rgba(255, 107, 53, 0.4)'; + button.style.fontWeight = 'bold'; + button.style.fontSize = '14px'; + button.style.textTransform = 'uppercase'; + button.style.letterSpacing = '1px'; + button.style.transition = 'all 0.3s ease'; + button.onmouseover = function() { button.style.transform = 'scale(1.05)'; button.style.boxShadow = '0 6px 20px rgba(255, 107, 53, 0.6)'; }; + button.onmouseout = function() { button.style.transform = 'scale(1)'; button.style.boxShadow = '0 4px 15px rgba(255, 107, 53, 0.4)'; }; + button.onclick = function(e) { + const link = xpiLinks[0]; // Download the first .xpi link + window.webkit.messageHandlers.downloadExtension.postMessage({url: link.href}); + }; + document.body.appendChild(button); + } + })(); + """ + webView.evaluateJavaScript(downloadScript, completionHandler: nil) + } + + private func downloadAndInstallExtension(from url: URL, tab: Tab) async { + logger.info("Downloading extension from: \(url.absoluteString)") + + // Download the file + guard let (data, response) = try? await URLSession.shared.data(from: url), + let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + logger.error("Failed to download extension") + return + } + + // Save to temp file + let tempDir = FileManager.default.temporaryDirectory + let tempFileURL = tempDir.appendingPathComponent("downloaded_extension.xpi") + do { + try data.write(to: tempFileURL) + } catch { + logger.error("Failed to save temp file: \(error)") + return + } + + // Extract + let extensionsDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!.appendingPathComponent("extensions") + if !FileManager.default.fileExists(atPath: extensionsDir.path) { + try? FileManager.default.createDirectory(at: extensionsDir, withIntermediateDirectories: true) + } + + // Create subfolder named after the file + let fileName = url.deletingPathExtension().lastPathComponent + let extractDir = extensionsDir.appendingPathComponent(fileName) + if !FileManager.default.fileExists(atPath: extractDir.path) { + try? FileManager.default.createDirectory(at: extractDir, withIntermediateDirectories: true) + } + + // Extract using unzip + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip") + process.arguments = ["-o", tempFileURL.path, "-d", extractDir.path] + do { + try process.run() + process.waitUntilExit() + if process.terminationStatus == 0 { + logger.info("Extraction successful, installing extension") + await OraExtensionManager.shared.installExtension(from: extractDir) + // Reload the tab + DispatchQueue.main.async { + tab.webView.reload() + } + } else { + logger.error("Extraction failed") + } + } catch { + logger.error("Failed to extract: \(error)") + } + + // Clean up temp file + try? FileManager.default.removeItem(at: tempFileURL) + } + private func extractDominantColor(from cgImage: CGImage) -> NSColor? { let width = cgImage.width let height = cgImage.height diff --git a/ora/UI/URLBar.swift b/ora/UI/URLBar.swift index 7e02ef23..da760ba6 100644 --- a/ora/UI/URLBar.swift +++ b/ora/UI/URLBar.swift @@ -20,6 +20,52 @@ struct ExtensionIconView: NSViewRepresentable { } } +struct ExtensionIconButton: NSViewRepresentable { + let ext: WKWebExtension + let extensionManager: OraExtensionManager + + func makeNSView(context: Context) -> NSButton { + let button = NSButton() + button.image = ext.icon(for: NSSize(width: 32, height: 32)) + button.imageScaling = .scaleProportionallyUpOrDown + button.toolTip = ext.displayName ?? "Extension" + button.isBordered = false + button.bezelStyle = .regularSquare + button.target = context.coordinator + button.action = #selector(Coordinator.clicked(_:)) + return button + } + + func updateNSView(_ nsView: NSButton, context: Context) { + nsView.image = ext.icon(for: NSSize(width: 32, height: 32)) + nsView.toolTip = ext.displayName ?? "Extension" + } + + func makeCoordinator() -> Coordinator { + Coordinator(ext: ext, extensionManager: extensionManager) + } + + class Coordinator: NSObject { + let ext: WKWebExtension + let extensionManager: OraExtensionManager + + init(ext: WKWebExtension, extensionManager: OraExtensionManager) { + self.ext = ext + self.extensionManager = extensionManager + } + + @objc func clicked(_ sender: NSButton) { + guard let context = extensionManager.controller.extensionContexts.first(where: { $0.webExtension == ext }), + let action = context.action(for: nil), action.presentsPopup, + let popover = action.popupPopover else { + print("No popup for extension: \(ext.displayName ?? "Unknown")") + return + } + popover.show(relativeTo: sender.bounds, of: sender, preferredEdge: NSRectEdge.maxY) + } + } +} + // MARK: - URLBar struct URLBar: View { @@ -89,24 +135,9 @@ struct URLBar: View { private var extensionIconsView: some View { HStack(spacing: 4) { - ForEach(extensionManager.installedExtensions, id: \.self) { ext in - Text(ext.displayName ?? "Unknown") - if let image = ext.icon(for: size) { - Image(nsImage: image) // use NSImage(nsImage:) on macOS - .resizable() - .frame(width: size.width, height: size.height) - .cornerRadius(6) - }else { - - - Image(systemName: "puzzlepiece.extension") - .resizable() - .frame(width: size.width, height: size.height) - .foregroundColor(.secondary) - } - - + ExtensionIconButton(ext: ext, extensionManager: extensionManager) + .frame(width: size.width, height: size.height) } } .padding(.horizontal, 4) From 6662373950d010006939c6b59100cabe79b641c1 Mon Sep 17 00:00:00 2001 From: versecafe <147033096+versecafe@users.noreply.github.com> Date: Sun, 28 Sep 2025 11:05:22 -0700 Subject: [PATCH 05/38] ux: Cleanup search engines implementation (#121) * ux: Split AI & classic search engines - also adds duck duck go, bing, & kagi * cleanup: Remove uneeded google check from the AI search engines * cleanup: Polish up engine details - resolves a bug introduced when issue #110 was fixed by moving where the padding is implemented - removes hard coded case statement for AI vs regular search for "Search on" vs "Ask" - sets brand colors to for kagi & bing to match official hex codes --- .gitignore | 2 +- ora/Common/Utils/SettingsStore.swift | 12 +- ora/Modules/Launcher/LauncherView.swift | 8 +- ora/Modules/Launcher/Main/LauncherMain.swift | 68 +++----- .../Sections/SearchEngineSettingsView.swift | 147 +++++++++++++----- ora/Services/SearchEngineService.swift | 68 ++++++-- 6 files changed, 208 insertions(+), 97 deletions(-) diff --git a/.gitignore b/.gitignore index 58a03de7..04aa6706 100644 --- a/.gitignore +++ b/.gitignore @@ -93,4 +93,4 @@ codesign* # Uncomment these if you want to commit release artifacts: # !build/*.dmg # !build/appcast.xml -# !build/dsa_pub.pem \ No newline at end of file +# !build/dsa_pub.pem diff --git a/ora/Common/Utils/SettingsStore.swift b/ora/Common/Utils/SettingsStore.swift index a34a11d9..85206307 100644 --- a/ora/Common/Utils/SettingsStore.swift +++ b/ora/Common/Utils/SettingsStore.swift @@ -35,6 +35,7 @@ struct CustomSearchEngine: Codable, Identifiable, Hashable { let aliases: [String] let faviconData: Data? let faviconBackgroundColorData: Data? + let isAIChat: Bool init( id: String = UUID().uuidString, @@ -42,7 +43,8 @@ struct CustomSearchEngine: Codable, Identifiable, Hashable { searchURL: String, aliases: [String] = [], faviconData: Data? = nil, - faviconBackgroundColorData: Data? = nil + faviconBackgroundColorData: Data? = nil, + isAIChat: Bool = false ) { self.id = id self.name = name @@ -50,6 +52,7 @@ struct CustomSearchEngine: Codable, Identifiable, Hashable { self.aliases = aliases self.faviconData = faviconData self.faviconBackgroundColorData = faviconBackgroundColorData + self.isAIChat = isAIChat } var favicon: NSImage? { @@ -72,6 +75,7 @@ struct CustomSearchEngine: Codable, Identifiable, Hashable { name: String, searchURL: String, aliases: [String] = [], + isAIChat: Bool = false, completion: @escaping (CustomSearchEngine) -> Void ) { let faviconService = FaviconService() @@ -91,7 +95,8 @@ struct CustomSearchEngine: Codable, Identifiable, Hashable { searchURL: searchURL, aliases: aliases, faviconData: faviconData, - faviconBackgroundColorData: colorData + faviconBackgroundColorData: colorData, + isAIChat: isAIChat ) completion(engine) } else { @@ -116,7 +121,8 @@ struct CustomSearchEngine: Codable, Identifiable, Hashable { searchURL: searchURL, aliases: aliases, faviconData: faviconData, - faviconBackgroundColorData: colorData + faviconBackgroundColorData: colorData, + isAIChat: isAIChat ) completion(engine) } diff --git a/ora/Modules/Launcher/LauncherView.swift b/ora/Modules/Launcher/LauncherView.swift index 4b622373..4ad9061a 100644 --- a/ora/Modules/Launcher/LauncherView.swift +++ b/ora/Modules/Launcher/LauncherView.swift @@ -37,7 +37,9 @@ struct LauncherView: View { var engineToUse = match if engineToUse == nil, - let defaultEngine = searchEngineService.getDefaultSearchEngine(for: tabManager.activeContainer?.id) + let defaultEngine = searchEngineService.getDefaultSearchEngine( + for: tabManager.activeContainer?.id + ) { let customEngine = searchEngineService.settings.customSearchEngines .first { $0.searchURL == defaultEngine.searchURL } @@ -88,6 +90,7 @@ struct LauncherView: View { color: match?.faviconBackgroundColor ?? match?.color ?? .clear, trigger: match != nil ) + .padding(.horizontal, 20) // Add horizontal margins around the search bar .offset(y: 250) .scaleEffect(isVisible ? 1.0 : 0.9) .opacity(isVisible ? 1.0 : 0.0) @@ -101,9 +104,6 @@ struct LauncherView: View { .onChange(of: appState.showLauncher) { _, newValue in isVisible = newValue } - // .onChange(of: theme) { _, newValue in - // searchEngineService.setTheme(newValue) - // } } .frame(maxWidth: .infinity, maxHeight: .infinity) .onExitCommand { diff --git a/ora/Modules/Launcher/Main/LauncherMain.swift b/ora/Modules/Launcher/Main/LauncherMain.swift index 244e083f..bb98694f 100644 --- a/ora/Modules/Launcher/Main/LauncherMain.swift +++ b/ora/Modules/Launcher/Main/LauncherMain.swift @@ -53,10 +53,11 @@ struct LauncherMain: View { @StateObject private var faviconService = FaviconService() @StateObject private var searchEngineService = SearchEngineService() - @State private var suggestions: [LauncherSuggestion] = [ - ] + @State private var suggestions: [LauncherSuggestion] = [] - private func createAISuggestion(engineName: SearchEngineID, query: String? = nil) -> LauncherSuggestion { + private func createAISuggestion(engineName: SearchEngineID, query: String? = nil) + -> LauncherSuggestion + { guard let engine = searchEngineService.getSearchEngine(engineName) else { return LauncherSuggestion( type: .aiChat, @@ -66,7 +67,7 @@ struct LauncherMain: View { ) } - let favicon = faviconService.getFavicon(for: engine.searchURL) + _ = faviconService.getFavicon(for: engine.searchURL) let faviconURL = faviconService.faviconURL(for: URL(string: engine.searchURL)?.host ?? "") return LauncherSuggestion( @@ -163,13 +164,14 @@ struct LauncherMain: View { private func appendOpenURLSuggestionIfNeeded(_ text: String) { guard let candidateURL = URL(string: text) else { return } - let finalURL: URL? = if candidateURL.scheme != nil { - candidateURL - } else if isValidURL(text) { - constructURL(from: text) - } else { - nil - } + let finalURL: URL? = + if candidateURL.scheme != nil { + candidateURL + } else if isValidURL(text) { + constructURL(from: text) + } else { + nil + } guard let url = finalURL else { return } suggestions.append( LauncherSuggestion( @@ -318,7 +320,8 @@ struct LauncherMain: View { onMoveDown: { moveFocusedElement(.down) }, - cursorColor: match?.faviconBackgroundColor ?? match?.color ?? (theme.foreground), + cursorColor: match?.faviconBackgroundColor ?? match?.color + ?? (theme.foreground), placeholder: getPlaceholder(match: match) ) .onChange(of: text) { _, _ in @@ -349,7 +352,8 @@ struct LauncherMain: View { RoundedRectangle(cornerRadius: 16, style: .continuous) .inset(by: 0.25) .stroke( - Color(match?.faviconBackgroundColor ?? match?.color ?? theme.foreground).opacity(0.15), + Color(match?.faviconBackgroundColor ?? match?.color ?? theme.foreground) + .opacity(0.15), lineWidth: 0.5 ) ) @@ -357,43 +361,21 @@ struct LauncherMain: View { color: Color.black.opacity(0.1), radius: 40, x: 0, y: 24 ) - .padding(.horizontal, 20) // Add horizontal margins around the entire search bar } private func getPlaceholder(match: Match?) -> String { if match == nil { return "Search the web or enter url..." } - switch match!.text { - case "X": - return "Search on X" - case "Youtube": - return "Search on Youtube" - case "Google": - return "Search on Google" - case "ChatGPT": - return "Ask ChatGPT" - case "Claude": - return "Ask Claude" - case "Grok": - return "Ask Grok" - case "Perplexity": - return "Ask Perplexity" - case "Reddit": - return "Search on Reddit" - case "T3Chat": - return "Ask T3Chat" - case "Gemini": - return "Ask Gemini" - case "Copilot": - return "Ask Copilot" - case "GitHub Copilot": - return "Ask GitHub Copilot" - case "Meta AI": - return "Ask Meta AI" - default: - return "Search on \(match!.text)" + + // Find the search engine by name to get its isAIChat property + if let engine = searchEngineService.getSearchEngine(byName: match!.text) { + let prefix = engine.isAIChat ? "Ask" : "Search on" + return "\(prefix) \(engine.name)" } + + // Fallback (should rarely happen) + return "Search on \(match!.text)" } private func getIconName(match: Match?, text: String) -> String { diff --git a/ora/Modules/Settings/Sections/SearchEngineSettingsView.swift b/ora/Modules/Settings/Sections/SearchEngineSettingsView.swift index c475abd1..f01c519d 100644 --- a/ora/Modules/Settings/Sections/SearchEngineSettingsView.swift +++ b/ora/Modules/Settings/Sections/SearchEngineSettingsView.swift @@ -11,10 +11,12 @@ struct SearchEngineSettingsView: View { @State private var newEngineName = "" @State private var newEngineURL = "" @State private var newEngineAliases = "" + @State private var newEngineIsAI = false private var isValidURL: Bool { newEngineURL - .contains("{query}") && URL(string: newEngineURL.replacingOccurrences(of: "{query}", with: "test")) != nil + .contains("{query}") + && URL(string: newEngineURL.replacingOccurrences(of: "{query}", with: "test")) != nil } var body: some View { @@ -63,7 +65,10 @@ struct SearchEngineSettingsView: View { Text("URL:") .frame(width: 80, alignment: .leading) VStack(alignment: .leading, spacing: 4) { - TextField("https://example.com/search?q={query}", text: $newEngineURL) + TextField( + "https://example.com/search?q={query}", + text: $newEngineURL + ) Text("Include {query} where the search term should go") .font(.caption) .foregroundColor(.secondary) @@ -86,6 +91,19 @@ struct SearchEngineSettingsView: View { } } + HStack { + Text("Type:") + .frame(width: 80, alignment: .leading) + VStack(alignment: .leading, spacing: 4) { + Toggle("AI Chat Engine", isOn: $newEngineIsAI) + Text( + "Check if this is an AI chat service (affects placeholder text)" + ) + .font(.caption) + .foregroundColor(.secondary) + } + } + HStack { Spacer() Button("Save") { @@ -115,20 +133,54 @@ struct SearchEngineSettingsView: View { .font(.caption) .foregroundStyle(.secondary) - // Built-in search engines - ForEach(searchEngineService.builtInSearchEngines, id: \.name) { engine in - BuiltInSearchEngineRow( - engine: engine, - isDefault: settings.globalDefaultSearchEngine == engine - .name || (settings.globalDefaultSearchEngine == nil && engine.name == "Google"), - onSetAsDefault: { - if engine.name == "Google" { - settings.globalDefaultSearchEngine = nil - } else { + // Conventional Search Engines + let conventionalEngines = searchEngineService.builtInSearchEngines.filter { + !$0.isAIChat + } + if !conventionalEngines.isEmpty { + Text("Conventional Search Engines") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.bottom, 4) + + ForEach(conventionalEngines, id: \.name) { engine in + BuiltInSearchEngineRow( + engine: engine, + isDefault: settings.globalDefaultSearchEngine + == engine + .name + || (settings.globalDefaultSearchEngine == nil + && engine.name == "Google" + ), + onSetAsDefault: { + if engine.name == "Google" { + settings.globalDefaultSearchEngine = nil + } else { + settings.globalDefaultSearchEngine = engine.name + } + } + ) + } + } + + // AI Search Engines + let aiEngines = searchEngineService.builtInSearchEngines.filter(\.isAIChat) + if !aiEngines.isEmpty { + Text("AI Search Engines") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.top, 8) + .padding(.bottom, 4) + + ForEach(aiEngines, id: \.name) { engine in + BuiltInSearchEngineRow( + engine: engine, + isDefault: settings.globalDefaultSearchEngine == engine.name, + onSetAsDefault: { settings.globalDefaultSearchEngine = engine.name } - } - ) + ) + } } if !settings.customSearchEngines.isEmpty { @@ -173,6 +225,7 @@ struct SearchEngineSettingsView: View { newEngineName = "" newEngineURL = "" newEngineAliases = "" + newEngineIsAI = false } private func cancelForm() { @@ -188,16 +241,18 @@ struct SearchEngineSettingsView: View { } private func saveSearchEngine() { - let aliasesList = newEngineAliases - .split(separator: ",") - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } + let aliasesList = + newEngineAliases + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } // Create engine with favicon fetched upfront CustomSearchEngine.createWithFavicon( name: newEngineName, searchURL: newEngineURL, - aliases: aliasesList + aliases: aliasesList, + isAIChat: newEngineIsAI ) { [weak settings] engine in settings?.addCustomSearchEngine(engine) } @@ -238,16 +293,6 @@ struct BuiltInSearchEngineRow: View { Text(engine.name) .font(.body) - if engine.isAIChat { - Text("AI") - .font(.caption) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(Color.purple.opacity(0.2)) - .foregroundColor(.purple) - .cornerRadius(4) - } - if isDefault { Text("Default") .font(.caption) @@ -284,9 +329,11 @@ struct CustomSearchEngineRow: View { @State private var editName = "" @State private var editURL = "" @State private var editAliases = "" + @State private var editIsAI = false private var isValidEditURL: Bool { - editURL.contains("{query}") && URL(string: editURL.replacingOccurrences(of: "{query}", with: "test")) != nil + editURL.contains("{query}") + && URL(string: editURL.replacingOccurrences(of: "{query}", with: "test")) != nil } var body: some View { @@ -341,6 +388,19 @@ struct CustomSearchEngineRow: View { TextField("e.g., ddg, duck", text: $editAliases) } + HStack { + Text("Type:") + .frame(width: 80, alignment: .leading) + VStack(alignment: .leading, spacing: 4) { + Toggle("AI Chat Engine", isOn: $editIsAI) + Text( + "Check if this is an AI chat service (affects placeholder text)" + ) + .font(.caption) + .foregroundColor(.secondary) + } + } + HStack { Spacer() Button("Cancel") { @@ -372,7 +432,7 @@ struct CustomSearchEngineRow: View { } } - // Name and Default badge + // Name and badges HStack(spacing: 8) { Text(engine.name) .font(.body) @@ -385,6 +445,15 @@ struct CustomSearchEngineRow: View { .foregroundColor(.blue) .cornerRadius(4) } + if engine.isAIChat { + Text("AI") + .font(.caption) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.purple.opacity(0.2)) + .foregroundColor(.purple) + .cornerRadius(4) + } } Spacer() @@ -429,13 +498,15 @@ struct CustomSearchEngineRow: View { editName = engine.name editURL = engine.searchURL editAliases = engine.aliases.joined(separator: ", ") + editIsAI = engine.isAIChat } private func saveEdit() { - let aliasesList = editAliases - .split(separator: ",") - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } + let aliasesList = + editAliases + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } // Create updated engine with favicon if URL changed, otherwise keep existing favicon if editURL != engine.searchURL { @@ -444,7 +515,8 @@ struct CustomSearchEngineRow: View { id: engine.id, name: editName, searchURL: editURL, - aliases: aliasesList + aliases: aliasesList, + isAIChat: editIsAI ) { [weak settings] updatedEngine in settings?.updateCustomSearchEngine(updatedEngine) } @@ -456,7 +528,8 @@ struct CustomSearchEngineRow: View { searchURL: editURL, aliases: aliasesList, faviconData: engine.faviconData, - faviconBackgroundColorData: engine.faviconBackgroundColorData + faviconBackgroundColorData: engine.faviconBackgroundColorData, + isAIChat: editIsAI ) settings.updateCustomSearchEngine(updatedEngine) } diff --git a/ora/Services/SearchEngineService.swift b/ora/Services/SearchEngineService.swift index c4b3e617..708b4283 100644 --- a/ora/Services/SearchEngineService.swift +++ b/ora/Services/SearchEngineService.swift @@ -44,6 +44,21 @@ class SearchEngineService: ObservableObject { return settingsStore } + /// All built-in search engine IDs derived from the built-in engines + var builtInEngineIDs: [SearchEngineID] { + return builtInSearchEngines.compactMap { SearchEngineID(rawValue: $0.name) } + } + + /// Check if a name corresponds to a built-in search engine + func isBuiltInEngine(_ name: String) -> Bool { + return builtInSearchEngines.contains { $0.name == name } + } + + /// Get SearchEngineID from engine name if it exists + func getSearchEngineID(from name: String) -> SearchEngineID? { + return SearchEngineID(rawValue: name) + } + var builtInSearchEngines: [SearchEngine] { [ SearchEngine( @@ -76,10 +91,35 @@ class SearchEngineService: ObservableObject { color: .blue, icon: "", aliases: ["google", "goo", "g", "search"], - searchURL: "https://www.google.com/search?client=safari&rls=en&ie=UTF-8&oe=UTF-8&q={query}", + searchURL: + "https://www.google.com/search?client=safari&rls=en&ie=UTF-8&oe=UTF-8&q={query}", isAIChat: false, autoSuggestions: self.googleSuggestions ), + SearchEngine( + name: "DuckDuckGo", + color: Color(hex: "#DE5833"), + icon: "", + aliases: ["duckduckgo", "ddg", "duck"], + searchURL: "https://duckduckgo.com/?q={query}", + isAIChat: false + ), + SearchEngine( + name: "Kagi", + color: Color(hex: "#FFB319"), + icon: "", + aliases: ["kagi", "kg"], + searchURL: "https://kagi.com/search?q={query}", + isAIChat: false + ), + SearchEngine( + name: "Bing", + color: Color(hex: "#02B7E9"), + icon: "", + aliases: ["bing", "b", "microsoft"], + searchURL: "https://www.bing.com/search?q={query}", + isAIChat: false + ), SearchEngine( name: "Grok", color: theme?.foreground ?? .white, @@ -168,7 +208,7 @@ class SearchEngineService: ObservableObject { icon: "", aliases: custom.aliases, searchURL: custom.searchURL, - isAIChat: false + isAIChat: custom.isAIChat ) } @@ -217,6 +257,10 @@ class SearchEngineService: ObservableObject { return searchEngines.first(where: { $0.name == engineName.rawValue }) } + func getSearchEngine(byName name: String) -> SearchEngine? { + return searchEngines.first(where: { $0.name == name }) + } + func getSearchURLForEngine(engineName: SearchEngineID, query: String) -> URL? { if let engine = getSearchEngine(engineName) { if let url = createSearchURL( @@ -230,28 +274,34 @@ class SearchEngineService: ObservableObject { } func createSearchURL(for engine: SearchEngine, query: String) -> URL? { - let encodedQuery = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + let encodedQuery = + query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" let urlString = engine.searchURL.replacingOccurrences(of: "{query}", with: encodedQuery) return URL(string: urlString) } func createSearchURL(for match: LauncherMain.Match, query: String) -> URL? { - let encodedQuery = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + let encodedQuery = + query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" let urlString = match.searchURL.replacingOccurrences(of: "{query}", with: encodedQuery) return URL(string: urlString) } func createSuggestionsURL(urlString: String, query: String) -> URL? { - let encodedQuery = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + let encodedQuery = + query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" let urlString = urlString.replacingOccurrences(of: "{query}", with: encodedQuery) return URL(string: urlString) } func googleSuggestions(_ query: String) async -> [String] { - guard let url = createSuggestionsURL( - urlString: "https://suggestqueries.google.com/complete/search?client=firefox&q={query}", - query: query - ) else { + guard + let url = createSuggestionsURL( + urlString: + "https://suggestqueries.google.com/complete/search?client=firefox&q={query}", + query: query + ) + else { return [] } From a4e26873356d574badb01cae14032d42be260fef Mon Sep 17 00:00:00 2001 From: Furkan Koseoglu <49058467+furkanksl@users.noreply.github.com> Date: Sun, 28 Sep 2025 21:10:29 +0300 Subject: [PATCH 06/38] fix(sidebar): update sidebar visibility persistance (#124) Updated the sidebar visibility to use user defaults for persistence. Added functionality to restore the active tab's transient state on app startup if it is not ready, enhancing user experience by ensuring continuity in tab management. --- ora/Modules/Browser/BrowserView.swift | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/ora/Modules/Browser/BrowserView.swift b/ora/Modules/Browser/BrowserView.swift index 93281a40..4bd28973 100644 --- a/ora/Modules/Browser/BrowserView.swift +++ b/ora/Modules/Browser/BrowserView.swift @@ -12,8 +12,7 @@ struct BrowserView: View { @State private var showFloatingSidebar = false @State private var isMouseOverSidebar = false @StateObject private var sidebarFraction = FractionHolder.usingUserDefaults(0.2, key: "ui.sidebar.fraction") - - @StateObject var sidebarVisibility = SideHolder() + @StateObject private var sidebarVisibility = SideHolder.usingUserDefaults(key: "ui.sidebar.visibility") private func toggleSidebar() { withAnimation(.spring(response: 0.2, dampingFraction: 1.0)) { @@ -222,6 +221,19 @@ struct BrowserView: View { } } } + .onAppear { + // Restore active tab on app startup if not already ready + if let tab = tabManager.activeTab, !tab.isWebViewReady { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + tab.restoreTransientState( + historyManger: historyManager, + downloadManager: downloadManager, + tabManager: tabManager, + isPrivate: privacyMode.isPrivate + ) + } + } + } } @ViewBuilder From 0e05ee5a14aeae8ff2baa62acad2555b1ef3971c Mon Sep 17 00:00:00 2001 From: Chase Date: Sun, 28 Sep 2025 11:30:51 -0700 Subject: [PATCH 07/38] feat: add ability to edit container name and emoji (#125) * feat: add ability to edit container name and emoji * Change default emoji in createContainer method --------- Co-authored-by: Kenenisa Alemayehu <53818162+kenenisa@users.noreply.github.com> --- ora/Common/Constants/ContainerConstants.swift | 22 +++++ .../Sidebar/BottomOption/ContainerForm.swift | 80 +++++++++++++++++ .../BottomOption/ContainerSwitcher.swift | 46 ++++++---- .../BottomOption/EditContainerModal.swift | 63 +++++++++++++ .../BottomOption/NewContainerButton.swift | 90 +++++-------------- ora/Services/TabManager.swift | 3 +- 6 files changed, 220 insertions(+), 84 deletions(-) create mode 100644 ora/Common/Constants/ContainerConstants.swift create mode 100644 ora/Modules/Sidebar/BottomOption/ContainerForm.swift create mode 100644 ora/Modules/Sidebar/BottomOption/EditContainerModal.swift diff --git a/ora/Common/Constants/ContainerConstants.swift b/ora/Common/Constants/ContainerConstants.swift new file mode 100644 index 00000000..2a3715dc --- /dev/null +++ b/ora/Common/Constants/ContainerConstants.swift @@ -0,0 +1,22 @@ +import Foundation + +/// Constants related to container functionality +enum ContainerConstants { + /// Default emoji used when no emoji is selected for a container + static let defaultEmoji = "β€’" + + /// UI constants for container forms and displays + enum UI { + static let normalButtonWidth: CGFloat = 28 + static let compactButtonWidth: CGFloat = 12 + static let popoverWidth: CGFloat = 300 + static let emojiButtonSize: CGFloat = 32 + static let cornerRadius: CGFloat = 10 + } + + /// Animation constants for container interactions + enum Animation { + static let hoverDuration: Double = 0.15 + static let emojiPickerDuration: Double = 0.1 + } +} diff --git a/ora/Modules/Sidebar/BottomOption/ContainerForm.swift b/ora/Modules/Sidebar/BottomOption/ContainerForm.swift new file mode 100644 index 00000000..b7a35f9f --- /dev/null +++ b/ora/Modules/Sidebar/BottomOption/ContainerForm.swift @@ -0,0 +1,80 @@ +import SwiftUI + +struct ContainerForm: View { + @Binding var name: String + @Binding var emoji: String + @Binding var isEmojiPickerOpen: Bool + @FocusState.Binding var isTextFieldFocused: Bool + + let onSubmit: () -> Void + let defaultEmoji: String + + @Environment(\.theme) private var theme + @State private var isEmojiPickerHovering = false + + var body: some View { + HStack(spacing: 8) { + emojiPickerButton + nameTextField + } + } + + private var emojiPickerButton: some View { + Button(action: { + isEmojiPickerOpen.toggle() + }) { + ZStack { + RoundedRectangle(cornerRadius: ContainerConstants.UI.cornerRadius, style: .continuous) + .stroke( + emoji.isEmpty ? theme.border : theme.border, + style: emoji.isEmpty + ? StrokeStyle(lineWidth: 1, dash: [5]) + : StrokeStyle(lineWidth: 1) + ) + .animation( + .easeOut(duration: ContainerConstants.Animation.emojiPickerDuration), + value: emoji.isEmpty + ) + .background(isEmojiPickerHovering ? Color.gray.opacity(0.3) : Color.gray.opacity(0.2)) + .cornerRadius(ContainerConstants.UI.cornerRadius) + + if emoji.isEmpty { + Image(systemName: "plus") + .font(.system(size: 12)) + } else { + Text(emoji) + .font(.system(size: 12)) + } + } + } + .popover(isPresented: $isEmojiPickerOpen, arrowEdge: .bottom) { + EmojiPickerView(onSelect: { selectedEmoji in + emoji = selectedEmoji + isEmojiPickerOpen = false + }) + } + .frame(width: ContainerConstants.UI.emojiButtonSize, height: ContainerConstants.UI.emojiButtonSize) + .background(isEmojiPickerHovering ? Color.gray.opacity(0.3) : Color.gray.opacity(0.2)) + .cornerRadius(ContainerConstants.UI.cornerRadius) + .buttonStyle(.plain) + .onHover { isEmojiPickerHovering = $0 } + } + + private var nameTextField: some View { + TextField("Name", text: $name) + .textFieldStyle(.plain) + .frame(maxWidth: .infinity) + .padding(8) + .background(Color.gray.opacity(0.1)) + .cornerRadius(ContainerConstants.UI.cornerRadius) + .focused($isTextFieldFocused) + .onSubmit(onSubmit) + .overlay( + RoundedRectangle(cornerRadius: ContainerConstants.UI.cornerRadius, style: .continuous) + .stroke( + isTextFieldFocused ? theme.foreground.opacity(0.5) : theme.border, + lineWidth: isTextFieldFocused ? 2 : 1 + ) + ) + } +} diff --git a/ora/Modules/Sidebar/BottomOption/ContainerSwitcher.swift b/ora/Modules/Sidebar/BottomOption/ContainerSwitcher.swift index 24020d95..14e0de36 100644 --- a/ora/Modules/Sidebar/BottomOption/ContainerSwitcher.swift +++ b/ora/Modules/Sidebar/BottomOption/ContainerSwitcher.swift @@ -9,18 +9,18 @@ struct ContainerSwitcher: View { @Query var containers: [TabContainer] @State private var hoveredContainer: UUID? - - private let normalButtonWidth: CGFloat = 28 - let defaultEmoji = "β€’" - // Never used - private let compactButtonWidth: CGFloat = 12 + @State private var editingContainer: TabContainer? + @State private var isEditModalOpen = false var body: some View { GeometryReader { geometry in let availableWidth = geometry.size.width let totalWidth = - CGFloat(containers.count) * normalButtonWidth + CGFloat(max(0, containers.count - 1)) - * 2 + CGFloat(containers.count) * ContainerConstants.UI.normalButtonWidth + CGFloat(max( + 0, + containers.count - 1 + )) + * 2 let isCompact = totalWidth > availableWidth HStack(alignment: .center, spacing: isCompact ? 4 : 2) { @@ -33,6 +33,14 @@ struct ContainerSwitcher: View { } .padding(0) .frame(height: 28) + .popover(isPresented: $isEditModalOpen) { + if let container = editingContainer { + EditContainerModal( + container: container, + isPresented: $isEditModalOpen + ) + } + } } @ViewBuilder @@ -41,12 +49,15 @@ struct ContainerSwitcher: View { { let isActive = tabManager.activeContainer?.id == container.id let isHovered = hoveredContainer == container.id - let displayEmoji = isCompact && !isActive ? (isHovered ? container.emoji : defaultEmoji) : container.emoji - let buttonSize = isCompact && !isActive ? (isHovered ? compactButtonWidth + 4 : compactButtonWidth) : - normalButtonWidth - let fontSize: CGFloat = isCompact && !isActive ? (isHovered ? (container.emoji == defaultEmoji ? 24 : 12) : 12 - ) : - (container.emoji == defaultEmoji ? 24 : 12) + let displayEmoji = isCompact && !isActive ? (isHovered ? container.emoji : ContainerConstants.defaultEmoji) : + container.emoji + let buttonSize = isCompact && !isActive ? + (isHovered ? ContainerConstants.UI.compactButtonWidth + 4 : ContainerConstants.UI.compactButtonWidth) : + ContainerConstants.UI.normalButtonWidth + let fontSize: CGFloat = isCompact && !isActive ? + (isHovered ? (container.emoji == ContainerConstants.defaultEmoji ? 24 : 12) : 12 + ) : + (container.emoji == ContainerConstants.defaultEmoji ? 24 : 12) Button(action: { onContainerSelected(container) @@ -54,7 +65,7 @@ struct ContainerSwitcher: View { HStack { Text(displayEmoji) .font(.system(size: fontSize)) - .foregroundColor(displayEmoji == defaultEmoji ? .primary : .secondary) + .foregroundColor(displayEmoji == ContainerConstants.defaultEmoji ? .primary : .secondary) } .frame(width: buttonSize, height: buttonSize) .grayscale(!isActive && !isHovered ? 0.5 : 0) @@ -76,9 +87,10 @@ struct ContainerSwitcher: View { } } .contextMenu { - // Button("Rename Container") { - // tabManager.renameContainer(container, name: "New Name", emoji: "πŸ’©") - // } + Button("Edit Container") { + editingContainer = container + isEditModalOpen = true + } Button("Delete Container") { tabManager.deleteContainer(container) } diff --git a/ora/Modules/Sidebar/BottomOption/EditContainerModal.swift b/ora/Modules/Sidebar/BottomOption/EditContainerModal.swift new file mode 100644 index 00000000..130b3095 --- /dev/null +++ b/ora/Modules/Sidebar/BottomOption/EditContainerModal.swift @@ -0,0 +1,63 @@ +import SwiftUI + +struct EditContainerModal: View { + let container: TabContainer + @Binding var isPresented: Bool + + @Environment(\.theme) private var theme + @EnvironmentObject var tabManager: TabManager + + @State private var name: String = "" + @State private var emoji: String = "" + @State private var isEmojiPickerOpen = false + @FocusState private var isTextFieldFocused: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + headerView + containerForm + actionButtons + } + .frame(width: ContainerConstants.UI.popoverWidth) + .padding() + .onAppear { + setupInitialValues() + } + } + + private var headerView: some View { + Text("Edit Container") + .font(.headline) + } + + private var containerForm: some View { + ContainerForm( + name: $name, + emoji: $emoji, + isEmojiPickerOpen: $isEmojiPickerOpen, + isTextFieldFocused: $isTextFieldFocused, + onSubmit: saveContainer, + defaultEmoji: ContainerConstants.defaultEmoji + ) + } + + private var actionButtons: some View { + Button("Save") { + saveContainer() + } + .disabled(name.isEmpty) + } + + private func setupInitialValues() { + name = container.name + emoji = container.emoji + } + + private func saveContainer() { + guard !name.isEmpty else { return } + + let finalEmoji = emoji.isEmpty ? ContainerConstants.defaultEmoji : emoji + tabManager.renameContainer(container, name: name, emoji: finalEmoji) + isPresented = false + } +} diff --git a/ora/Modules/Sidebar/BottomOption/NewContainerButton.swift b/ora/Modules/Sidebar/BottomOption/NewContainerButton.swift index 0331395d..29981682 100644 --- a/ora/Modules/Sidebar/BottomOption/NewContainerButton.swift +++ b/ora/Modules/Sidebar/BottomOption/NewContainerButton.swift @@ -3,11 +3,9 @@ import SwiftUI struct NewContainerButton: View { @State private var isHovering = false - @State private var isEmojiPickerHovering = false @State private var isPopoverOpen = false @State private var name = "" @State private var emoji = "" - let defaultEmoji = "β€’" @State private var isEmojiPickerOpen = false @FocusState private var isTextFieldFocused: Bool @@ -34,76 +32,36 @@ struct NewContainerButton: View { Text("New Container") .font(.headline) - HStack(spacing: 8) { - Button(action: { - isEmojiPickerOpen.toggle() - }) { - ZStack { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke( - emoji.isEmpty ? theme.border : theme.border, - style: emoji.isEmpty - ? StrokeStyle(lineWidth: 1, dash: [5]) - : StrokeStyle(lineWidth: 1) - ) - .animation(.easeOut(duration: 0.1), value: emoji.isEmpty) - .background(isEmojiPickerHovering ? Color.gray.opacity(0.3) : Color.gray.opacity(0.2)) - .cornerRadius(10) - if emoji.isEmpty { - Image(systemName: "plus") - .font(.system(size: 12)) - } else { - Text(emoji) - .font(.system(size: 12)) - } - } - } - .popover(isPresented: $isEmojiPickerOpen, arrowEdge: .bottom) { - EmojiPickerView(onSelect: { emoji in - self.emoji = emoji - isEmojiPickerOpen = false - }) - } - .frame(width: 32, height: 32) - .background(isEmojiPickerHovering ? Color.gray.opacity(0.3) : Color.gray.opacity(0.2)) - .cornerRadius(10) - .buttonStyle(.plain) - .onHover { isEmojiPickerHovering = $0 } - - TextField("Name", text: $name) - .textFieldStyle(.plain) - .frame(maxWidth: .infinity) - .padding(8) - .background(Color.gray.opacity(0.1)) - .cornerRadius(10) - .focused($isTextFieldFocused) - .onSubmit { - if !name.isEmpty { - tabManager.createContainer(name: name, emoji: emoji.isEmpty ? defaultEmoji : emoji) - isPopoverOpen = false - name = "" - emoji = "" - } - } - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke( - isTextFieldFocused ? theme.foreground.opacity(0.5) : theme.border, - lineWidth: isTextFieldFocused ? 2 : 1 - ) - ) - } + ContainerForm( + name: $name, + emoji: $emoji, + isEmojiPickerOpen: $isEmojiPickerOpen, + isTextFieldFocused: $isTextFieldFocused, + onSubmit: createContainer, + defaultEmoji: ContainerConstants.defaultEmoji + ) Button("Create") { - tabManager.createContainer(name: name, emoji: emoji.isEmpty ? defaultEmoji : emoji) - isPopoverOpen = false - name = "" - emoji = "" + createContainer() } .disabled(name.isEmpty) } - .frame(width: 300) + .frame(width: ContainerConstants.UI.popoverWidth) .padding() } } + + private func createContainer() { + guard !name.isEmpty else { return } + + let finalEmoji = emoji.isEmpty ? ContainerConstants.defaultEmoji : emoji + tabManager.createContainer(name: name, emoji: finalEmoji) + isPopoverOpen = false + resetForm() + } + + private func resetForm() { + name = "" + emoji = "" + } } diff --git a/ora/Services/TabManager.swift b/ora/Services/TabManager.swift index 9a873563..62368ea6 100644 --- a/ora/Services/TabManager.swift +++ b/ora/Services/TabManager.swift @@ -167,7 +167,8 @@ class TabManager: ObservableObject { } } - func createContainer(name: String = "Default", emoji: String = "πŸ’©") -> TabContainer { + @discardableResult + func createContainer(name: String = "Default", emoji: String = "β€’") -> TabContainer { let newContainer = TabContainer(name: name, emoji: emoji) modelContext.insert(newContainer) activeContainer = newContainer From ca11825101a4f35be63a1056d46c4467d276ff6a Mon Sep 17 00:00:00 2001 From: Chase Date: Sun, 28 Sep 2025 11:39:28 -0700 Subject: [PATCH 08/38] fix: move to container shows proper containers now (#126) --- ora/UI/TabItem.swift | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/ora/UI/TabItem.swift b/ora/UI/TabItem.swift index 336acb13..68317602 100644 --- a/ora/UI/TabItem.swift +++ b/ora/UI/TabItem.swift @@ -208,23 +208,21 @@ struct TabItem: View { ) } - Divider() - - Menu("Move to Container") { - ForEach(availableContainers) { container in - if tab.container.id != tabManager.activeContainer?.id { - Button(action: { onMoveToContainer(tab.container) }) { - Label { - Text(container.name) - } icon: { - Text(container.emoji) // This is where you show the emoji + if availableContainers.count > 1 { + Divider() + + Menu("Move to Container") { + ForEach(availableContainers) { container in + if tab.container.id != container.id { + Button(action: { onMoveToContainer(container) }) { + Text(container.emoji.isEmpty ? container.name : "\(container.emoji) \(container.name)") } } } } - } - Divider() + Divider() + } Button(role: .destructive, action: onClose) { Label("Close Tab", systemImage: "xmark") From 22eb71562e62c200009b90818f812e4f353db2a5 Mon Sep 17 00:00:00 2001 From: Chase Date: Sun, 28 Sep 2025 15:08:10 -0700 Subject: [PATCH 09/38] feat: double click full resize of window (#128) * feat: wip double click * feat: add double click to maximize view * remove deprecation change * fix: when resizing use previous frame --- .../Extensions/EnvironmentValues+Window.swift | 13 ++ .../Extensions/NSWindow+Extensions.swift | 113 ++++++++++++++++++ ora/Modules/Browser/BrowserView.swift | 8 ++ ora/OraRoot.swift | 1 + 4 files changed, 135 insertions(+) create mode 100644 ora/Common/Extensions/EnvironmentValues+Window.swift create mode 100644 ora/Common/Extensions/NSWindow+Extensions.swift diff --git a/ora/Common/Extensions/EnvironmentValues+Window.swift b/ora/Common/Extensions/EnvironmentValues+Window.swift new file mode 100644 index 00000000..1655d940 --- /dev/null +++ b/ora/Common/Extensions/EnvironmentValues+Window.swift @@ -0,0 +1,13 @@ +import AppKit +import SwiftUI + +private struct WindowEnvironmentKey: EnvironmentKey { + static let defaultValue: NSWindow? = nil +} + +extension EnvironmentValues { + var window: NSWindow? { + get { self[WindowEnvironmentKey.self] } + set { self[WindowEnvironmentKey.self] = newValue } + } +} diff --git a/ora/Common/Extensions/NSWindow+Extensions.swift b/ora/Common/Extensions/NSWindow+Extensions.swift new file mode 100644 index 00000000..e661c4e0 --- /dev/null +++ b/ora/Common/Extensions/NSWindow+Extensions.swift @@ -0,0 +1,113 @@ +import AppKit +import Foundation + +extension NSWindow { + // Private key for storing the previous frame in UserDefaults + private static let previousFrameKey = "window.previousFrame" + + /// Stores the current frame as the previous frame before maximizing + private var previousFrame: NSRect? { + get { + let defaults = UserDefaults.standard + guard let rectString = defaults.string(forKey: Self.previousFrameKey) else { return nil } + return NSRectFromString(rectString) + } + set { + let defaults = UserDefaults.standard + if let newValue { + defaults.set(NSStringFromRect(newValue), forKey: Self.previousFrameKey) + } else { + defaults.removeObject(forKey: Self.previousFrameKey) + } + } + } + + /// Toggles the window between maximized (filling the visible screen) and restored states. + /// Uses smooth animations and respects the menu bar and dock. + /// Remembers the previous frame before maximizing and restores to that exact size/position. + func toggleMaximized() { + // Get the screen's visible frame (excludes menu bar and dock) + guard let screen = self.screen else { return } + let screenFrame = screen.visibleFrame + + // Check if window is already maximized (with some tolerance for small differences) + let currentFrame = self.frame + let tolerance: CGFloat = 10 + let isMaximized = abs(currentFrame.size.width - screenFrame.size.width) < tolerance && + abs(currentFrame.size.height - screenFrame.size.height) < tolerance && + abs(currentFrame.origin.x - screenFrame.origin.x) < tolerance && + abs(currentFrame.origin.y - screenFrame.origin.y) < tolerance + + if isMaximized { + // If already maximized, restore to the previous frame if available + if let storedFrame = previousFrame { + self.setFrame(storedFrame, display: true, animate: true) + // Clear the stored frame since we're restoring + previousFrame = nil + } else { + // Fallback to default size if no previous frame is stored + let restoredWidth: CGFloat = 1440 + let restoredHeight: CGFloat = 900 + let newFrame = NSRect( + x: screenFrame.midX - restoredWidth / 2, + y: screenFrame.midY - restoredHeight / 2, + width: restoredWidth, + height: restoredHeight + ) + self.setFrame(newFrame, display: true, animate: true) + } + } else { + // Store the current frame before maximizing + previousFrame = currentFrame + // Maximize to fill the visible screen area + self.setFrame(screenFrame, display: true, animate: true) + } + } + + /// Returns true if the window is currently maximized to fill the visible screen area + var isMaximized: Bool { + guard let screen = self.screen else { return false } + let screenFrame = screen.visibleFrame + let currentFrame = self.frame + let tolerance: CGFloat = 10 + + return abs(currentFrame.size.width - screenFrame.size.width) < tolerance && + abs(currentFrame.size.height - screenFrame.size.height) < tolerance && + abs(currentFrame.origin.x - screenFrame.origin.x) < tolerance && + abs(currentFrame.origin.y - screenFrame.origin.y) < tolerance + } + + /// Maximizes the window to fill the visible screen area + /// Stores the current frame before maximizing so it can be restored later + func maximize() { + guard let screen = self.screen else { return } + let screenFrame = screen.visibleFrame + + // Store the current frame before maximizing (unless already maximized) + if !isMaximized { + previousFrame = self.frame + } + + self.setFrame(screenFrame, display: true, animate: true) + } + + /// Restores the window to a default size and centers it on the screen + /// Clears any stored previous frame since we're explicitly setting a new size + func restoreToDefaultSize() { + guard let screen = self.screen else { return } + let screenFrame = screen.visibleFrame + let restoredWidth: CGFloat = 1440 + let restoredHeight: CGFloat = 900 + let newFrame = NSRect( + x: screenFrame.midX - restoredWidth / 2, + y: screenFrame.midY - restoredHeight / 2, + width: restoredWidth, + height: restoredHeight + ) + + // Clear any stored previous frame since we're explicitly restoring to default + previousFrame = nil + + self.setFrame(newFrame, display: true, animate: true) + } +} diff --git a/ora/Modules/Browser/BrowserView.swift b/ora/Modules/Browser/BrowserView.swift index 4bd28973..a7b6b889 100644 --- a/ora/Modules/Browser/BrowserView.swift +++ b/ora/Modules/Browser/BrowserView.swift @@ -8,6 +8,7 @@ struct BrowserView: View { @EnvironmentObject private var downloadManager: DownloadManager @EnvironmentObject private var historyManager: HistoryManager @EnvironmentObject private var privacyMode: PrivacyMode + @Environment(\.window) var window: NSWindow? @State private var isFullscreen = false @State private var showFloatingSidebar = false @State private var isMouseOverSidebar = false @@ -70,6 +71,10 @@ struct BrowserView: View { } } + private func toggleMaximizeWindow() { + window?.toggleMaximized() + } + var body: some View { ZStack(alignment: .leading) { HSplit( @@ -221,6 +226,9 @@ struct BrowserView: View { } } } + .onTapGesture(count: 2) { + toggleMaximizeWindow() + } .onAppear { // Restore active tab on app startup if not already ready if let tab = tabManager.activeTab, !tab.isWebViewReady { diff --git a/ora/OraRoot.swift b/ora/OraRoot.swift index 506019ab..b60bb318 100644 --- a/ora/OraRoot.swift +++ b/ora/OraRoot.swift @@ -82,6 +82,7 @@ struct OraRoot: View { BrowserView() .background(WindowReader(window: $window)) + .environment(\.window, window) .environmentObject(appState) .environmentObject(tabManager) .environmentObject(historyManager) From c690fb5c15870e8caa4db0acb757b561da9df52a Mon Sep 17 00:00:00 2001 From: Chase Date: Sun, 28 Sep 2025 15:19:12 -0700 Subject: [PATCH 10/38] fix: settings model container and spaces UI (#127) * fix: settings model container * fix: space settings ui more digestible * fix: move extension file to extension directory * fix: refactor retry of container creation failure * fix: allow left and right settings view in spaces to scroll independently --- .../ModelConfiguration+Shared.swift | 25 +++ .../Sections/SpacesSettingsView.swift | 203 ++++++++++++------ ora/OraRoot.swift | 10 +- ora/oraApp.swift | 26 ++- 4 files changed, 181 insertions(+), 83 deletions(-) create mode 100644 ora/Common/Extensions/ModelConfiguration+Shared.swift diff --git a/ora/Common/Extensions/ModelConfiguration+Shared.swift b/ora/Common/Extensions/ModelConfiguration+Shared.swift new file mode 100644 index 00000000..fd0ee5c4 --- /dev/null +++ b/ora/Common/Extensions/ModelConfiguration+Shared.swift @@ -0,0 +1,25 @@ +import Foundation +import SwiftData + +extension ModelConfiguration { + /// Shared model configuration for the main Ora database + static func oraDatabase(isPrivate: Bool = false) -> ModelConfiguration { + if isPrivate { + return ModelConfiguration(isStoredInMemoryOnly: true) + } else { + return ModelConfiguration( + "OraData", + schema: Schema([TabContainer.self, History.self, Download.self]), + url: URL.applicationSupportDirectory.appending(path: "OraData.sqlite") + ) + } + } + + /// Creates a ModelContainer using the standard Ora database configuration + static func createOraContainer(isPrivate: Bool = false) throws -> ModelContainer { + return try ModelContainer( + for: TabContainer.self, History.self, Download.self, + configurations: oraDatabase(isPrivate: isPrivate) + ) + } +} diff --git a/ora/Modules/Settings/Sections/SpacesSettingsView.swift b/ora/Modules/Settings/Sections/SpacesSettingsView.swift index 570eb044..ba53c50d 100644 --- a/ora/Modules/Settings/Sections/SpacesSettingsView.swift +++ b/ora/Modules/Settings/Sections/SpacesSettingsView.swift @@ -14,7 +14,7 @@ struct SpacesSettingsView: View { } var body: some View { - SettingsContainer(maxContentWidth: 1040) { + SettingsContainer(maxContentWidth: 1040, usesScrollView: false) { HStack(spacing: 0) { // Left list List(selection: $selectedContainerId) { @@ -31,88 +31,153 @@ struct SpacesSettingsView: View { Divider() // Right details - VStack(alignment: .leading, spacing: 20) { - if let container = selectedContainer { - VStack(alignment: .leading, spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - Text("Space-Specific Defaults") - .font(.subheadline) - .fontWeight(.medium) - .foregroundStyle(.secondary) - } + ScrollView { + VStack(alignment: .leading, spacing: 24) { + if let container = selectedContainer { + // Space-Specific Defaults Section + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + Text("Space-Specific Defaults") + .font(.headline) + .fontWeight(.semibold) + Text("Configure default settings for this space") + .font(.caption) + .foregroundStyle(.secondary) + } - VStack(alignment: .leading, spacing: 8) { - Text("Search Engine Override") - .font(.caption) - .foregroundStyle(.secondary) - Picker( - "Search engine", - selection: Binding( - get: { - settings.defaultSearchEngineId(for: container.id) - }, - set: { settings.setDefaultSearchEngineId($0, for: container.id) } - ) - ) { - Text("Use Global Default").tag(nil as String?) - Divider() - ForEach(searchService.searchEngines.filter { !$0.isAIChat }, id: \.name) { engine in - Text(engine.name).tag(Optional(engine.name)) + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 8) { + Text("Search Engine Override") + .font(.subheadline) + .fontWeight(.medium) + Picker( + "Search engine", + selection: Binding( + get: { + settings.defaultSearchEngineId(for: container.id) + }, + set: { settings.setDefaultSearchEngineId($0, for: container.id) } + ) + ) { + Text("Use Global Default").tag(nil as String?) + Divider() + ForEach( + searchService.searchEngines.filter { !$0.isAIChat }, + id: \.name + ) { engine in + Text(engine.name).tag(Optional(engine.name)) + } + } + .pickerStyle(.menu) + .frame(minWidth: 200, maxWidth: .infinity, alignment: .leading) } - } - } + .padding(12) + .background(Color(.controlBackgroundColor)) + .cornerRadius(6) - VStack(alignment: .leading, spacing: 8) { - Text("AI Chat Override") - .font(.caption) - .foregroundStyle(.secondary) - Picker( - "AI Chat", - selection: Binding( - get: { - settings.defaultAIEngineId(for: container.id) - }, - set: { settings.setDefaultAIEngineId($0, for: container.id) } - ) - ) { - Text("Use Global Default").tag(nil as String?) - Divider() - ForEach(searchService.searchEngines.filter(\.isAIChat), id: \.name) { engine in - Text(engine.name).tag(Optional(engine.name)) + VStack(alignment: .leading, spacing: 8) { + Text("AI Chat Override") + .font(.subheadline) + .fontWeight(.medium) + Picker( + "AI Chat", + selection: Binding( + get: { + settings.defaultAIEngineId(for: container.id) + }, + set: { settings.setDefaultAIEngineId($0, for: container.id) } + ) + ) { + Text("Use Global Default").tag(nil as String?) + Divider() + ForEach( + searchService.searchEngines.filter(\.isAIChat), + id: \.name + ) { engine in + Text(engine.name).tag(Optional(engine.name)) + } + } + .pickerStyle(.menu) + .frame(minWidth: 200, maxWidth: .infinity, alignment: .leading) } + .padding(12) + .background(Color(.controlBackgroundColor)) + .cornerRadius(6) + + VStack(alignment: .leading, spacing: 8) { + Text("Auto Clear Tabs") + .font(.subheadline) + .fontWeight(.medium) + Picker( + "Clear tabs after", + selection: Binding( + get: { settings.autoClearTabsAfter(for: container.id) }, + set: { settings.setAutoClearTabsAfter($0, for: container.id) } + ) + ) { + ForEach(AutoClearTabsAfter.allCases) { value in + Text(value.rawValue).tag(value) + } + } + .pickerStyle(.menu) + .frame(minWidth: 200, maxWidth: .infinity, alignment: .leading) + } + .padding(12) + .background(Color(.controlBackgroundColor)) + .cornerRadius(6) } } - Picker( - "Clear tabs after", - selection: Binding( - get: { settings.autoClearTabsAfter(for: container.id) }, - set: { settings.setAutoClearTabsAfter($0, for: container.id) } - ) - ) { - ForEach(AutoClearTabsAfter.allCases) { value in - Text(value.rawValue).tag(value) + Divider() + + // Clear Data Section + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + Text("Privacy & Data") + .font(.headline) + .fontWeight(.semibold) + Text("Clear stored data for this space") + .font(.caption) + .foregroundStyle(.secondary) } + + VStack(spacing: 8) { + Button("Clear Cache") { + PrivacyService.clearCache(container) + } + .frame(maxWidth: .infinity, alignment: .leading) + + Button("Clear Cookies") { + PrivacyService.clearCookies(container) + } + .frame(maxWidth: .infinity, alignment: .leading) + + Button("Clear History") { + historyManger.clearContainerHistory(container) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.bordered) } - } - .padding(8) - VStack(alignment: .leading, spacing: 12) { - Text("Clear Data").foregroundStyle(.secondary) - Button("Clear Cache") { PrivacyService.clearCache(container) } - Button("Clear Cookies") { PrivacyService.clearCookies(container) } - Button("Clear Browsing History") { - historyManger.clearContainerHistory(container) + } else { + VStack(spacing: 12) { + Text("No spaces found") + .font(.headline) + .foregroundStyle(.secondary) + Text("Create a space to configure its settings") + .font(.caption) + .foregroundStyle(.secondary) } + .frame(maxWidth: .infinity, maxHeight: .infinity) } - .padding(8) - - } else { - Text("No spaces found").foregroundStyle(.secondary) + Spacer(minLength: 0) } - Spacer(minLength: 0) + .padding(16) + .frame(maxWidth: .infinity, alignment: .topLeading) } - .padding(16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .clipped() } } .onAppear { if selectedContainerId == nil { selectedContainerId = containers.first?.id } } diff --git a/ora/OraRoot.swift b/ora/OraRoot.swift index b60bb318..13493e19 100644 --- a/ora/OraRoot.swift +++ b/ora/OraRoot.swift @@ -29,19 +29,11 @@ struct OraRoot: View { init(isPrivate: Bool = false) { _privacyMode = StateObject(wrappedValue: PrivacyMode(isPrivate: isPrivate)) - let modelConfiguration = isPrivate ? ModelConfiguration(isStoredInMemoryOnly: true) : ModelConfiguration( - "OraData", - schema: Schema([TabContainer.self, History.self, Download.self]), - url: URL.applicationSupportDirectory.appending(path: "OraData.sqlite") - ) let container: ModelContainer let modelContext: ModelContext do { - container = try ModelContainer( - for: TabContainer.self, History.self, Download.self, - configurations: modelConfiguration - ) + container = try ModelConfiguration.createOraContainer(isPrivate: isPrivate) modelContext = ModelContext(container) } catch { deleteSwiftDataStore("OraData.sqlite") diff --git a/ora/oraApp.swift b/ora/oraApp.swift index 7e736374..f73c72e4 100644 --- a/ora/oraApp.swift +++ b/ora/oraApp.swift @@ -39,6 +39,10 @@ class AppState: ObservableObject { struct OraApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + // Shared model container that uses the same configuration as the main browser + private let sharedModelContainer: ModelContainer? = + try? ModelConfiguration.createOraContainer(isPrivate: false) + var body: some Scene { WindowGroup(id: "normal") { OraRoot() @@ -57,10 +61,22 @@ struct OraApp: App { .windowResizability(.contentMinSize) Settings { - SettingsContentView() - .environmentObject(AppearanceManager.shared) - .environmentObject(UpdateService.shared) - .withTheme() - }.commands { OraCommands() } + if let sharedModelContainer { + SettingsContentView() + .environmentObject(AppearanceManager.shared) + .environmentObject(UpdateService.shared) + .withTheme() + .modelContainer(sharedModelContainer) + } else { + // Fallback UI when SwiftData is completely broken + VStack { + Text("Settings Unavailable") + .font(.title) + } + .padding() + .frame(width: 400, height: 300) + } + } + .commands { OraCommands() } } } From f11afd25971319eed12166f2f3f16ec67f791a73 Mon Sep 17 00:00:00 2001 From: Chase Date: Sun, 28 Sep 2025 15:08:10 -0700 Subject: [PATCH 11/38] feat: double click full resize of window (#128) * feat: wip double click * feat: add double click to maximize view * remove deprecation change * fix: when resizing use previous frame --- ora/Modules/Browser/BrowserView.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ora/Modules/Browser/BrowserView.swift b/ora/Modules/Browser/BrowserView.swift index a7b6b889..9744e85a 100644 --- a/ora/Modules/Browser/BrowserView.swift +++ b/ora/Modules/Browser/BrowserView.swift @@ -75,6 +75,10 @@ struct BrowserView: View { window?.toggleMaximized() } + private func toggleMaximizeWindow() { + window?.toggleMaximized() + } + var body: some View { ZStack(alignment: .leading) { HSplit( From f7759a11bc3809ff68cad5ba3b39c228b70e6cfa Mon Sep 17 00:00:00 2001 From: Chase Date: Sun, 28 Sep 2025 15:08:10 -0700 Subject: [PATCH 12/38] feat: double click full resize of window (#128) * feat: wip double click * feat: add double click to maximize view * remove deprecation change * fix: when resizing use previous frame --- ora/Modules/Browser/BrowserView.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ora/Modules/Browser/BrowserView.swift b/ora/Modules/Browser/BrowserView.swift index 9744e85a..f0e6c99c 100644 --- a/ora/Modules/Browser/BrowserView.swift +++ b/ora/Modules/Browser/BrowserView.swift @@ -79,6 +79,10 @@ struct BrowserView: View { window?.toggleMaximized() } + private func toggleMaximizeWindow() { + window?.toggleMaximized() + } + var body: some View { ZStack(alignment: .leading) { HSplit( From 7fa4521db983e9ebd0ea54abcca1c6df060f81a2 Mon Sep 17 00:00:00 2001 From: Chase Date: Sun, 28 Sep 2025 15:08:10 -0700 Subject: [PATCH 13/38] feat: double click full resize of window (#128) * feat: wip double click * feat: add double click to maximize view * remove deprecation change * fix: when resizing use previous frame --- ora/Modules/Browser/BrowserView.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ora/Modules/Browser/BrowserView.swift b/ora/Modules/Browser/BrowserView.swift index f0e6c99c..a0ff3e7b 100644 --- a/ora/Modules/Browser/BrowserView.swift +++ b/ora/Modules/Browser/BrowserView.swift @@ -83,6 +83,10 @@ struct BrowserView: View { window?.toggleMaximized() } + private func toggleMaximizeWindow() { + window?.toggleMaximized() + } + var body: some View { ZStack(alignment: .leading) { HSplit( From d5adfd5ccc956d26aa0e2dad7c98aabbc276a215 Mon Sep 17 00:00:00 2001 From: Kenenisa Alemayehu <53818162+kenenisa@users.noreply.github.com> Date: Wed, 1 Oct 2025 18:39:17 +0300 Subject: [PATCH 14/38] refactor: remove redundant toggleMaximizeWindow functions in BrowserView --- ora/Modules/Browser/BrowserView.swift | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/ora/Modules/Browser/BrowserView.swift b/ora/Modules/Browser/BrowserView.swift index a0ff3e7b..a7b6b889 100644 --- a/ora/Modules/Browser/BrowserView.swift +++ b/ora/Modules/Browser/BrowserView.swift @@ -75,18 +75,6 @@ struct BrowserView: View { window?.toggleMaximized() } - private func toggleMaximizeWindow() { - window?.toggleMaximized() - } - - private func toggleMaximizeWindow() { - window?.toggleMaximized() - } - - private func toggleMaximizeWindow() { - window?.toggleMaximized() - } - var body: some View { ZStack(alignment: .leading) { HSplit( From b947d244f27051e2a10bc0bf9c3cfcbaf5aa70c1 Mon Sep 17 00:00:00 2001 From: Aarav Gupta Date: Thu, 9 Oct 2025 00:14:47 +0530 Subject: [PATCH 15/38] Fixes issues with extensions (#142) - Fixes Problem with Importing - Minor UI padding fixes. --- ora/Modules/Browser/BrowserView.swift | 22 ++-- .../Sections/ExtensionsSettingsView.swift | 106 +++++++++++++----- ora/OraRoot.swift | 1 - .../Extentions/OraExtensionManager.swift | 94 ++++++++++------ ora/Services/TabManager.swift | 2 +- ora/Services/TabScriptHandler.swift | 11 +- ora/Services/WebViewNavigationDelegate.swift | 20 ++-- ora/UI/URLBar.swift | 33 +++--- 8 files changed, 187 insertions(+), 102 deletions(-) diff --git a/ora/Modules/Browser/BrowserView.swift b/ora/Modules/Browser/BrowserView.swift index a7b6b889..5412420c 100644 --- a/ora/Modules/Browser/BrowserView.swift +++ b/ora/Modules/Browser/BrowserView.swift @@ -20,6 +20,7 @@ struct BrowserView: View { sidebarVisibility.toggle(.primary) } } + private func printExtensionInfo() { if let tab = tabManager.activeTab { if let controller = tab.webView.configuration.webExtensionController { @@ -29,14 +30,17 @@ struct BrowserView: View { for extCtx in controller.extensionContexts { print("πŸ”₯ Extension Context Properties:") print(" baseURL: \(extCtx.baseURL)") - print(" commands: \(extCtx.commands.map { $0.id })") + print(" commands: \(extCtx.commands.map(\.id))") print(" currentPermissionMatchPatterns: \(extCtx.currentPermissionMatchPatterns)") - print(" currentPermissions: \(extCtx.currentPermissions.map { $0.rawValue })") + print(" currentPermissions: \(extCtx.currentPermissions.map(\.rawValue))") print(" deniedPermissionMatchPatterns: \(extCtx.deniedPermissionMatchPatterns)") print(" deniedPermissions: \(extCtx.deniedPermissions.map { "\($0.key.rawValue): \($0.value)" })") - print(" errors: \(extCtx.errors.map { $0.localizedDescription })") - print(" grantedPermissionMatchPatterns: \(extCtx.grantedPermissionMatchPatterns.map { "\($0.key): \($0.value)" })") - print(" grantedPermissions: \(extCtx.grantedPermissions.map { "\($0.key.rawValue): \($0.value)" })") + print(" errors: \(extCtx.errors.map(\.localizedDescription))") + print( + " grantedPermissionMatchPatterns: \(extCtx.grantedPermissionMatchPatterns.map { "\($0.key): \($0.value)" })" + ) + print(" grantedPermissions: \(extCtx.grantedPermissions.map { "\($0.key.rawValue): \($0.value)" })" + ) print(" hasAccessToAllHosts: \(extCtx.hasAccessToAllHosts)") print(" hasAccessToAllURLs: \(extCtx.hasAccessToAllURLs)") print(" hasAccessToPrivateData: \(extCtx.hasAccessToPrivateData)") @@ -62,10 +66,9 @@ struct BrowserView: View { print(" Display Version: \(ext.displayVersion ?? "None")") print(" Display Description: \(ext.displayDescription ?? "None")") // print(" Permissions: \(ext.permissions.map { $0.rawValue })") - // print(" Background Content URL: \(ext.backgroundContentURL?.absoluteString ?? "None")") + // print(" Background Content URL: + // \(ext.backgroundContentURL?.absoluteString ?? "None")") // print(" Content Scripts Count: \(ext.contentScripts.count)") - - } } } @@ -94,7 +97,7 @@ struct BrowserView: View { } } ) - + .hide(sidebarVisibility) .splitter { Splitter.invisible() } .fraction(sidebarFraction) @@ -132,7 +135,6 @@ struct BrowserView: View { FloatingTabSwitcher() } } - if sidebarVisibility.side == .primary { // Floating sidebar with resizable width based on persisted fraction diff --git a/ora/Modules/Settings/Sections/ExtensionsSettingsView.swift b/ora/Modules/Settings/Sections/ExtensionsSettingsView.swift index faa7c402..3f13490f 100644 --- a/ora/Modules/Settings/Sections/ExtensionsSettingsView.swift +++ b/ora/Modules/Settings/Sections/ExtensionsSettingsView.swift @@ -1,43 +1,34 @@ import SwiftUI -class ExtensionViewModel: ObservableObject { - @Published var directories: [URL] = [] +@Observable +class ExtensionViewModel { + var directories: [URL] = [] + var isInstalled = false } struct ExtensionsSettingsView: View { - @StateObject private var viewModel = ExtensionViewModel() + @State private var viewModel = ExtensionViewModel() @State private var isImporting = false @State private var extensionsDir: URL? var body: some View { VStack(alignment: .leading, spacing: 20) { - Text("Extensions") + Text("Manage Extensions") .font(.title) - .padding(.bottom, 10) + .fontWeight(.semibold) + .padding(.top, 20) if let dir = extensionsDir { Text("Extensions Directory: \(dir.path)") .font(.caption) .foregroundColor(.secondary) + .multilineTextAlignment(.leading) } Button("Import Extension Zip") { isImporting = true - } - .fileImporter( - isPresented: $isImporting, - allowedContentTypes: [.zip], - allowsMultipleSelection: false - ) { result in - switch result { - case .success(let urls): - if let url = urls.first { - Task { - await importAndExtractZip(from: url) - } - } - case .failure(let error): - print("File import failed: \(error)") + Task { + await importAndExtractZip() } } @@ -55,6 +46,12 @@ struct ExtensionsSettingsView: View { Button("Install") { Task { await OraExtensionManager.shared.installExtension(from: dir) + + // Reload view if extension has been installed. + viewModel.isInstalled = true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + viewModel.isInstalled = false + } } } .buttonStyle(.bordered) @@ -81,6 +78,9 @@ struct ExtensionsSettingsView: View { setupExtensionsDirectory() loadExtensionDirectories() } + .onChange(of: viewModel.isInstalled) { _, _ in + loadExtensionDirectories() + } } private func setupExtensionsDirectory() { @@ -94,7 +94,10 @@ struct ExtensionsSettingsView: View { private func loadExtensionDirectories() { guard let dir = extensionsDir else { return } do { - let contents = try FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: [.isDirectoryKey]) + let contents = try FileManager.default.contentsOfDirectory( + at: dir, + includingPropertiesForKeys: [.isDirectoryKey] + ) viewModel.directories = contents.filter { url in var isDir: ObjCBool = false FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir) @@ -105,7 +108,18 @@ struct ExtensionsSettingsView: View { } } - private func importAndExtractZip(from zipURL: URL) async { + private func importAndExtractZip() async { + // Use NSOpenPanel to let the user select a ZIP file + let openPanel = NSOpenPanel() + openPanel.allowedContentTypes = [.zip] + openPanel.canChooseFiles = true + openPanel.canChooseDirectories = false + + guard openPanel.runModal() == .OK, let zipURL = openPanel.urls.first else { + print("No file selected or user canceled.") + return + } + guard let destDir = extensionsDir else { return } // Create a subfolder named after the zip file (without .zip) @@ -115,7 +129,7 @@ struct ExtensionsSettingsView: View { try? FileManager.default.createDirectory(at: extractDir, withIntermediateDirectories: true) } - // Copy zip to temp location in extractDir + // Copy zip to temp location inside extractDir let tempZipURL = extractDir.appendingPathComponent("temp.zip") do { try FileManager.default.copyItem(at: zipURL, to: tempZipURL) @@ -124,19 +138,28 @@ struct ExtensionsSettingsView: View { return } - // Extract using bash unzip + // Extract using Process let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip") - process.arguments = ["-o", tempZipURL.path, "-d", extractDir.path] // -o to overwrite + process.arguments = ["-o", tempZipURL.path, "-d", extractDir.path] do { try process.run() process.waitUntilExit() + if process.terminationStatus == 0 { print("Extraction successful") - // Remove temp zip + + // Delete temp.zip try? FileManager.default.removeItem(at: tempZipURL) - // Reload directories + + // Flattens extension folder structure. + flattenDir(from: extractDir, to: zipName) + + // Remove __MACOSX (macOS metadata) if it exists + cleanUp(extractDir) + + // Reload as needed loadExtensionDirectories() } else { print("Extraction failed") @@ -145,4 +168,33 @@ struct ExtensionsSettingsView: View { print("Failed to extract: \(error)") } } + + func flattenDir(from extractDir: URL, to zipName: String) { + // Move contents of extractDir/zipName to extractDir + let nestedDir = extractDir.appendingPathComponent(zipName) + if FileManager.default.fileExists(atPath: nestedDir.path) { + do { + let contents = try FileManager.default.contentsOfDirectory( + at: nestedDir, + includingPropertiesForKeys: nil + ) + for item in contents { + let destinationURL = extractDir.appendingPathComponent(item.lastPathComponent) + try? FileManager.default.moveItem(at: item, to: destinationURL) + } + + // Remove the nested folder after moving + try? FileManager.default.removeItem(at: nestedDir) + } catch { + print("Error moving nested contents: \(error)") + } + } + } + + func cleanUp(_ extractDir: URL) { + let macosxDir = extractDir.appendingPathComponent("__MACOSX") + if FileManager.default.fileExists(atPath: macosxDir.path) { + try? FileManager.default.removeItem(at: macosxDir) + } + } } diff --git a/ora/OraRoot.swift b/ora/OraRoot.swift index 13493e19..9a23cc50 100644 --- a/ora/OraRoot.swift +++ b/ora/OraRoot.swift @@ -71,7 +71,6 @@ struct OraRoot: View { } var body: some View { - BrowserView() .background(WindowReader(window: $window)) .environment(\.window, window) diff --git a/ora/Services/Extentions/OraExtensionManager.swift b/ora/Services/Extentions/OraExtensionManager.swift index 403b3b86..27dd2a83 100644 --- a/ora/Services/Extentions/OraExtensionManager.swift +++ b/ora/Services/Extentions/OraExtensionManager.swift @@ -5,23 +5,22 @@ // Created by keni on 9/17/25. // - +import os.log import SwiftUI import WebKit -import os.log - // MARK: - Ora Extension Manager + class OraExtensionManager: NSObject, ObservableObject { static let shared = OraExtensionManager() - public var controller: WKWebExtensionController + var controller: WKWebExtensionController private let logger = Logger(subsystem: "com.ora.browser", category: "ExtensionManager") @Published var installedExtensions: [WKWebExtension] = [] var extensionMap: [URL: WKWebExtension] = [:] var tabManager: TabManager? - + override init() { logger.info("Initializing OraExtensionManager") let config = WKWebExtensionController.Configuration(identifier: UUID()) @@ -30,18 +29,18 @@ class OraExtensionManager: NSObject, ObservableObject { controller.delegate = self logger.info("OraExtensionManager initialized successfully") } - + /// Install an extension from a local file @MainActor func installExtension(from url: URL) async { logger.info("Starting extension installation from URL: \(url.path)") - + Task { do { logger.debug("Creating WKWebExtension from resource URL") let webExtension = try await WKWebExtension(resourceBaseURL: url) logger.debug("Extension created successfully: \(webExtension.displayName ?? "Unknown")") - + logger.debug("Creating WKWebExtensionContext") let webContext = WKWebExtensionContext(for: webExtension) webContext.isInspectable = true @@ -51,7 +50,7 @@ class OraExtensionManager: NSObject, ObservableObject { // Load background content if available webContext.loadBackgroundContent { [self] error in - if let error = error { + if let error { self.logger.error("Failed to load background content: \(error.localizedDescription)") } else { self.logger.debug("Background content loaded successfully") @@ -69,20 +68,19 @@ class OraExtensionManager: NSObject, ObservableObject { print("\(controller.extensionContexts.count) ctx") print("\(controller.extensions.count) ext") - + logger.debug("Adding extension to installed extensions list") installedExtensions.append(webExtension) extensionMap[url] = webExtension - + logger.info("Extension installed successfully: \(webExtension.displayName ?? "Unknown")") } catch { logger.error("Failed to install extension from \(url.path): \(error.localizedDescription)") print("❌ Failed to install extension: \(error)") } } - } - + /// Load all available extensions from the extensions directory @MainActor func loadAllExtensions() async { @@ -96,7 +94,10 @@ class OraExtensionManager: NSObject, ObservableObject { } do { - let contents = try FileManager.default.contentsOfDirectory(at: extensionsDir, includingPropertiesForKeys: [.isDirectoryKey]) + let contents = try FileManager.default.contentsOfDirectory( + at: extensionsDir, + includingPropertiesForKeys: [.isDirectoryKey] + ) for url in contents { var isDir: ObjCBool = false if FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir), isDir.boolValue { @@ -131,30 +132,29 @@ class OraExtensionManager: NSObject, ObservableObject { } // MARK: - Delegate for Permissions & Lifecycle + extension OraExtensionManager: WKWebExtensionControllerDelegate { - // When extension requests new permissions func webExtensionController( _ controller: WKWebExtensionController, webExtension: WKWebExtension, requestsAccessTo permissions: [WKWebExtension.Permission] ) async -> Bool { - let extensionName = webExtension.displayName ?? "Unknown" - let permissionNames = permissions.map { $0.rawValue }.joined(separator: ", ") - + let permissionNames = permissions.map(\.rawValue).joined(separator: ", ") + logger.info("Extension '\(extensionName)' requesting permissions: \(permissionNames)") - + // βœ… Show SwiftUI prompt to user print("πŸ”’ Extension \(extensionName) requests: \(permissionNames)") - + // TODO: Replace with real SwiftUI dialog let granted = true // allow for now logger.info("Permission request for '\(extensionName)' \(granted ? "granted" : "denied")") - + return granted } - + // Handle background script messages func webExtensionController( _ controller: WKWebExtensionController, @@ -177,11 +177,12 @@ extension OraExtensionManager: WKWebExtensionControllerDelegate { guard let dict = message as? [String: Any], let api = dict["api"] as? String, api == "tabs", let method = dict["method"] as? String, - let params = dict["params"] as? [String: Any] else { + let params = dict["params"] as? [String: Any] + else { return } - guard let tabManager = tabManager else { + guard let tabManager else { logger.error("TabManager not available for extension tab API") return } @@ -208,7 +209,8 @@ extension OraExtensionManager: WKWebExtensionControllerDelegate { private func handleTabsCreate(params: [String: Any], context: WKWebExtensionContext) { guard let urlString = params["url"] as? String, let url = URL(string: urlString), - let container = tabManager?.activeContainer else { + let container = tabManager?.activeContainer + else { return } @@ -216,13 +218,30 @@ extension OraExtensionManager: WKWebExtensionControllerDelegate { let active = params["active"] as? Bool ?? true // Create history and download managers if needed - let historyManager = HistoryManager(modelContainer: tabManager!.modelContainer, modelContext: tabManager!.modelContext) - let downloadManager = DownloadManager(modelContainer: tabManager!.modelContainer, modelContext: tabManager!.modelContext) + let historyManager = HistoryManager( + modelContainer: tabManager!.modelContainer, + modelContext: tabManager!.modelContext + ) + let downloadManager = DownloadManager( + modelContainer: tabManager!.modelContainer, + modelContext: tabManager!.modelContext + ) if active { - tabManager?.openTab(url: url, historyManager: historyManager, downloadManager: downloadManager, isPrivate: isPrivate) + tabManager?.openTab( + url: url, + historyManager: historyManager, + downloadManager: downloadManager, + isPrivate: isPrivate + ) } else { - _ = tabManager?.addTab(url: url, container: container, historyManager: historyManager, downloadManager: downloadManager, isPrivate: isPrivate) + _ = tabManager?.addTab( + url: url, + container: container, + historyManager: historyManager, + downloadManager: downloadManager, + isPrivate: isPrivate + ) } } @@ -233,7 +252,8 @@ extension OraExtensionManager: WKWebExtensionControllerDelegate { for tabIdString in tabIdStrings { if let tabId = UUID(uuidString: tabIdString), let container = tabManager?.activeContainer, - let tab = container.tabs.first(where: { $0.id == tabId }) { + let tab = container.tabs.first(where: { $0.id == tabId }) + { tabManager?.closeTab(tab: tab) } } @@ -259,7 +279,12 @@ extension OraExtensionManager: WKWebExtensionControllerDelegate { // For now, return all tabs in active container guard let container = tabManager?.activeContainer else { return } let tabs: [[String: Any]] = container.tabs.map { tab in - ["id": tab.id.uuidString, "url": tab.urlString, "title": tab.title, "active": tabManager?.isActive(tab) ?? false] as [String: Any] + [ + "id": tab.id.uuidString, + "url": tab.urlString, + "title": tab.title, + "active": tabManager?.isActive(tab) ?? false + ] as [String: Any] } // Note: Cannot send response back to extension via WKWebExtensionContext // Extensions should use events or other mechanisms @@ -273,7 +298,12 @@ extension OraExtensionManager: WKWebExtensionControllerDelegate { let container = tabManager?.activeContainer, let tab = container.tabs.first(where: { $0.id == tabId }) else { return } - let tabInfo: [String: Any] = ["id": tab.id.uuidString, "url": tab.urlString, "title": tab.title, "active": tabManager?.isActive(tab) ?? false] + let tabInfo: [String: Any] = [ + "id": tab.id.uuidString, + "url": tab.urlString, + "title": tab.title, + "active": tabManager?.isActive(tab) ?? false + ] // Note: Cannot send response back to extension via WKWebExtensionContext logger.debug("Tab get result: \(tabInfo)") } diff --git a/ora/Services/TabManager.swift b/ora/Services/TabManager.swift index 62368ea6..b630bafc 100644 --- a/ora/Services/TabManager.swift +++ b/ora/Services/TabManager.swift @@ -387,7 +387,7 @@ class TabManager: ObservableObject { activeContainer = tab.container tab.container.lastAccessedAt = Date() tab.updateHeaderColor() - + try? modelContext.save() } diff --git a/ora/Services/TabScriptHandler.swift b/ora/Services/TabScriptHandler.swift index 1297e3e2..eb8bbd3b 100644 --- a/ora/Services/TabScriptHandler.swift +++ b/ora/Services/TabScriptHandler.swift @@ -103,9 +103,8 @@ class TabScriptHandler: NSObject, WKScriptMessageHandler { forIdentifier: containerId ) } - + configuration.webExtensionController = OraExtensionManager.shared.controller - // Performance optimizations configuration.allowsAirPlayForMediaPlayback = true @@ -119,7 +118,7 @@ class TabScriptHandler: NSObject, WKScriptMessageHandler { // if #unavailable(macOS 10.12) { // // Picture in picture not available on older macOS versions // } else { -//// configuration.allowsPictureInPictureMediaPlaybook = true + //// configuration.allowsPictureInPictureMediaPlaybook = true // } // Enable media playback without user interaction @@ -146,7 +145,8 @@ class TabScriptHandler: NSObject, WKScriptMessageHandler { // Download the file guard let (data, response) = try? await URLSession.shared.data(from: url), - let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 + else { logger.error("Failed to download extension") return } @@ -157,7 +157,8 @@ class TabScriptHandler: NSObject, WKScriptMessageHandler { try? data.write(to: tempZipURL) // Extract - let extensionsDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!.appendingPathComponent("extensions") + let extensionsDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + .appendingPathComponent("extensions") if !FileManager.default.fileExists(atPath: extensionsDir.path) { try? FileManager.default.createDirectory(at: extensionsDir, withIntermediateDirectories: true) } diff --git a/ora/Services/WebViewNavigationDelegate.swift b/ora/Services/WebViewNavigationDelegate.swift index 901cc9ec..27585473 100644 --- a/ora/Services/WebViewNavigationDelegate.swift +++ b/ora/Services/WebViewNavigationDelegate.swift @@ -1,8 +1,8 @@ import AppKit +import Foundation import os.log import SwiftUI @preconcurrency import WebKit -import Foundation private let logger = Logger(subsystem: "com.orabrowser.ora", category: "WebViewNavigationDelegate") @@ -252,11 +252,11 @@ class WebViewNavigationDelegate: NSObject, WKNavigationDelegate { """ ) if navigationAction.modifierFlags.contains(.command), - let url = navigationAction.request.url, - let tab = self.tab, - let tabManager = tab.tabManager, - let historyManager = tab.historyManager, - let downloadManager = tab.downloadManager + let url = navigationAction.request.url, + let tab = self.tab, + let tabManager = tab.tabManager, + let historyManager = tab.historyManager, + let downloadManager = tab.downloadManager { // Open link in new tab DispatchQueue.main.async { @@ -273,8 +273,6 @@ class WebViewNavigationDelegate: NSObject, WKNavigationDelegate { return } - - // Allow normal navigation decisionHandler(.allow) } @@ -454,7 +452,8 @@ class WebViewNavigationDelegate: NSObject, WKNavigationDelegate { // Download the file guard let (data, response) = try? await URLSession.shared.data(from: url), - let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 + else { logger.error("Failed to download extension") return } @@ -470,7 +469,8 @@ class WebViewNavigationDelegate: NSObject, WKNavigationDelegate { } // Extract - let extensionsDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!.appendingPathComponent("extensions") + let extensionsDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + .appendingPathComponent("extensions") if !FileManager.default.fileExists(atPath: extensionsDir.path) { try? FileManager.default.createDirectory(at: extensionsDir, withIntermediateDirectories: true) } diff --git a/ora/UI/URLBar.swift b/ora/UI/URLBar.swift index da760ba6..5ac41853 100644 --- a/ora/UI/URLBar.swift +++ b/ora/UI/URLBar.swift @@ -57,7 +57,8 @@ struct ExtensionIconButton: NSViewRepresentable { @objc func clicked(_ sender: NSButton) { guard let context = extensionManager.controller.extensionContexts.first(where: { $0.webExtension == ext }), let action = context.action(for: nil), action.presentsPopup, - let popover = action.popupPopover else { + let popover = action.popupPopover + else { print("No popup for extension: \(ext.displayName ?? "Unknown")") return } @@ -81,7 +82,7 @@ struct URLBar: View { @State private var alertMessage: String? let onSidebarToggle: () -> Void - let size: CGSize = CGSize(width: 32, height: 32) + let size: CGSize = .init(width: 32, height: 32) private func getForegroundColor(_ tab: Tab) -> Color { // Convert backgroundColor to NSColor for luminance calculation @@ -132,7 +133,7 @@ struct URLBar: View { picker.show(relativeTo: sourceRect, of: sourceView, preferredEdge: .minY) } } - + private var extensionIconsView: some View { HStack(spacing: 4) { ForEach(extensionManager.installedExtensions, id: \.self) { ext in @@ -142,14 +143,14 @@ struct URLBar: View { } .padding(.horizontal, 4) .alert("Notice", isPresented: .constant(alertMessage != nil), actions: { - Button("OK", role: .cancel) { - alertMessage = nil - } - }, message: { - if let message = alertMessage { - Text(message) - } - }) + Button("OK", role: .cancel) { + alertMessage = nil + } + }, message: { + if let message = alertMessage { + Text(message) + } + }) } var body: some View { @@ -310,12 +311,12 @@ struct URLBar: View { .allowsHitTesting(false) ) - // Extension icons - if !extensionManager.installedExtensions.isEmpty { - extensionIconsView - } + // Extension icons + if !extensionManager.installedExtensions.isEmpty { + extensionIconsView + } - ShareLinkButton( + ShareLinkButton( isEnabled: true, foregroundColor: buttonForegroundColor, onShare: { sourceView, sourceRect in From d750a121b3934f67adfc3810e2de01cf06ab9745 Mon Sep 17 00:00:00 2001 From: Kenenisa Alemayehu <53818162+kenenisa@users.noreply.github.com> Date: Thu, 9 Oct 2025 20:39:40 +0300 Subject: [PATCH 16/38] refactor: replace OraExtensionManager with ExtensionManager across the codebase --- ora/Models/Tab.swift | 23 ++++ .../Sections/ExtensionsSettingsView.swift | 12 +- ora/OraRoot.swift | 4 +- ...onManager.swift => ExtensionManager.swift} | 112 +++++++++++++--- .../Extentions/ExtensionTabWrapper.swift | 125 ++++++++++++++++++ .../Extentions/ExtensionWindowWrapper.swift | 19 +++ ora/Services/TabManager.swift | 1 + ora/Services/TabScriptHandler.swift | 7 +- ora/Services/WebViewNavigationDelegate.swift | 2 +- ora/UI/URLBar.swift | 8 +- 10 files changed, 274 insertions(+), 39 deletions(-) rename ora/Services/Extentions/{OraExtensionManager.swift => ExtensionManager.swift} (74%) create mode 100644 ora/Services/Extentions/ExtensionTabWrapper.swift create mode 100644 ora/Services/Extentions/ExtensionWindowWrapper.swift diff --git a/ora/Models/Tab.swift b/ora/Models/Tab.swift index bed015f5..da8e3d68 100644 --- a/ora/Models/Tab.swift +++ b/ora/Models/Tab.swift @@ -51,6 +51,7 @@ class Tab: ObservableObject, Identifiable { @Transient @Published var failedURL: URL? @Transient @Published var hoveredLinkURL: String? @Transient var isPrivate: Bool = false + @Transient var extensionTabWrapper: ExtensionTabWrapper? @Relationship(inverse: \TabContainer.tabs) var container: TabContainer @@ -101,6 +102,7 @@ class Tab: ObservableObject, Identifiable { config.tab = self config.mediaController = tabManager.mediaController + self.attachExtension() // Configure WebView for performance webView.allowsMagnification = true webView.allowsBackForwardNavigationGestures = true @@ -251,6 +253,7 @@ class Tab: ObservableObject, Identifiable { tabManager: TabManager, isPrivate: Bool ) { + self.attachExtension() // Avoid double initialization if webView.url != nil { return } @@ -349,6 +352,11 @@ class Tab: ObservableObject, Identifiable { } func destroyWebView() { + if let wrapper = extensionTabWrapper { + print("[Tab] destroyWebView didCloseTab wrapperId=\(wrapper.id) tabId=\(id.uuidString)") + ExtensionManager.shared.controller.didCloseTab(wrapper) + extensionTabWrapper = nil + } webView.stopLoading() webView.navigationDelegate = nil webView.uiDelegate = nil @@ -381,6 +389,21 @@ class Tab: ObservableObject, Identifiable { webView.load(request) } } + func attachExtension(){ + if extensionTabWrapper == nil { + let newId = ExtensionManager.shared.nextTabId() + print("[Tab] attachExtension creating wrapper wrapperId=\(newId) tabId=\(id.uuidString)") + let wrapper = ExtensionTabWrapper(nativeTab: self, id: newId) + extensionTabWrapper = wrapper + Task { @MainActor in + ExtensionManager.shared.ensureWindowOpened() + ExtensionManager.shared.controller.didOpenTab(wrapper) + print("[ExtMgr] didOpenTab wrapperId=\(newId)") + } + } else { + print("[Tab] attachExtension skipped (already attached) tabId=\(id.uuidString) wrapperId=\(extensionTabWrapper?.id ?? -1)") + } + } } extension FileManager { diff --git a/ora/Modules/Settings/Sections/ExtensionsSettingsView.swift b/ora/Modules/Settings/Sections/ExtensionsSettingsView.swift index 3f13490f..84269b7a 100644 --- a/ora/Modules/Settings/Sections/ExtensionsSettingsView.swift +++ b/ora/Modules/Settings/Sections/ExtensionsSettingsView.swift @@ -45,19 +45,13 @@ struct ExtensionsSettingsView: View { Spacer() Button("Install") { Task { - await OraExtensionManager.shared.installExtension(from: dir) - - // Reload view if extension has been installed. - viewModel.isInstalled = true - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - viewModel.isInstalled = false - } + await ExtensionManager.shared.installExtension(from: dir) } } .buttonStyle(.bordered) - if let extensionToUninstall = OraExtensionManager.shared.extensionMap[dir] { + if let extensionToUninstall = ExtensionManager.shared.extensionMap[dir] { Button("Delete") { - OraExtensionManager.shared.uninstallExtension(extensionToUninstall) + ExtensionManager.shared.uninstallExtension(extensionToUninstall) // Remove the directory try? FileManager.default.removeItem(at: dir) // Reload directories diff --git a/ora/OraRoot.swift b/ora/OraRoot.swift index 9a23cc50..e7e99e6b 100644 --- a/ora/OraRoot.swift +++ b/ora/OraRoot.swift @@ -20,7 +20,7 @@ struct OraRoot: View { @StateObject private var historyManager: HistoryManager @StateObject private var downloadManager: DownloadManager @StateObject private var privacyMode: PrivacyMode - @StateObject private var extensionManager = OraExtensionManager.shared + @StateObject private var extensionManager = ExtensionManager.shared let tabContext: ModelContext let historyContext: ModelContext @@ -110,7 +110,7 @@ struct OraRoot: View { } } - OraExtensionManager.shared.tabManager = tabManager + ExtensionManager.shared.tabManager = tabManager Task { await extensionManager.loadAllExtensions() } diff --git a/ora/Services/Extentions/OraExtensionManager.swift b/ora/Services/Extentions/ExtensionManager.swift similarity index 74% rename from ora/Services/Extentions/OraExtensionManager.swift rename to ora/Services/Extentions/ExtensionManager.swift index 27dd2a83..38c0d44a 100644 --- a/ora/Services/Extentions/OraExtensionManager.swift +++ b/ora/Services/Extentions/ExtensionManager.swift @@ -10,9 +10,8 @@ import SwiftUI import WebKit // MARK: - Ora Extension Manager - -class OraExtensionManager: NSObject, ObservableObject { - static let shared = OraExtensionManager() +class ExtensionManager: NSObject, ObservableObject { + static let shared = ExtensionManager() var controller: WKWebExtensionController private let logger = Logger(subsystem: "com.ora.browser", category: "ExtensionManager") @@ -20,7 +19,10 @@ class OraExtensionManager: NSObject, ObservableObject { @Published var installedExtensions: [WKWebExtension] = [] var extensionMap: [URL: WKWebExtension] = [:] var tabManager: TabManager? - + private var nextId: Int = 1 + private var nextWindowId: Int = 1 + private(set) var mainWindow: ExtensionWindowWrapper? + override init() { logger.info("Initializing OraExtensionManager") let config = WKWebExtensionController.Configuration(identifier: UUID()) @@ -28,6 +30,29 @@ class OraExtensionManager: NSObject, ObservableObject { super.init() controller.delegate = self logger.info("OraExtensionManager initialized successfully") + print("[ExtMgr] controller ready with identifier=\(config.identifier?.uuidString ?? "nil")") + } + + func nextTabId() -> Int { + let current = nextId + nextId += 1 + return current + } + + func nextWindowID() -> Int { + let current = nextWindowId + nextWindowId += 1 + return current + } + + @MainActor + func ensureWindowOpened() { + if mainWindow == nil { + let window = ExtensionWindowWrapper(id: nextWindowID()) + mainWindow = window + controller.didOpenWindow(window) + print("[ExtMgr] didOpenWindow id=\(window.id)") + } } /// Install an extension from a local file @@ -40,13 +65,16 @@ class OraExtensionManager: NSObject, ObservableObject { logger.debug("Creating WKWebExtension from resource URL") let webExtension = try await WKWebExtension(resourceBaseURL: url) logger.debug("Extension created successfully: \(webExtension.displayName ?? "Unknown")") - + print("[ExtMgr] created extension name=\(webExtension.displayName ?? "Unknown") base=\(url.path)") + logger.debug("Creating WKWebExtensionContext") let webContext = WKWebExtensionContext(for: webExtension) webContext.isInspectable = true + print("[ExtMgr] context created for=\(webExtension.displayName ?? "Unknown")") logger.debug("Loading extension context into controller") try controller.load(webContext) + print("[ExtMgr] context loaded for=\(webExtension.displayName ?? "Unknown")") // Load background content if available webContext.loadBackgroundContent { [self] error in @@ -61,10 +89,36 @@ class OraExtensionManager: NSObject, ObservableObject { if let allUrlsPattern = try? WKWebExtension.MatchPattern(string: "") { webContext.setPermissionStatus(.grantedExplicitly, for: allUrlsPattern) logger.debug("Granted permission for extension") + print("[ExtMgr] granted for=\(webExtension.displayName ?? "Unknown")") } let storagePermission = WKWebExtension.Permission.storage webContext.setPermissionStatus(.grantedExplicitly, for: storagePermission) logger.debug("Granted storage permission for extension") + print("[ExtMgr] granted storage for=\(webExtension.displayName ?? "Unknown")") + + + let permissionsToGrant: [WKWebExtension.Permission] = [ + .activeTab, + .alarms, + .clipboardWrite, + .contextMenus, + .cookies, + .declarativeNetRequest, + .declarativeNetRequestFeedback, + .declarativeNetRequestWithHostAccess, + .menus, + .nativeMessaging, + .scripting, + .storage, + .tabs, + .unlimitedStorage, + .webNavigation, + .webRequest + ] + for permission in permissionsToGrant { + webContext.setPermissionStatus(.grantedExplicitly, for: permission) + print("[ExtMgr] granted \(permission) for=\(webExtension.displayName ?? "Unknown")") + } print("\(controller.extensionContexts.count) ctx") print("\(controller.extensions.count) ext") @@ -132,8 +186,8 @@ class OraExtensionManager: NSObject, ObservableObject { } // MARK: - Delegate for Permissions & Lifecycle - -extension OraExtensionManager: WKWebExtensionControllerDelegate { +extension ExtensionManager: WKWebExtensionControllerDelegate { + // When extension requests new permissions func webExtensionController( _ controller: WKWebExtensionController, @@ -167,6 +221,14 @@ extension OraExtensionManager: WKWebExtensionControllerDelegate { print("πŸ“© Message from \(extensionName): \(message)") + if let dict = message as? [String: Any] { + let api = dict["api"] as? String ?? "" + let method = dict["method"] as? String ?? "" + print("[ExtMgr] route api=\(api) method=\(method)") + } else { + print("[ExtMgr] non-dict message received") + } + // Handle tab API messages handleTabAPIMessage(message, from: context) @@ -275,20 +337,30 @@ extension OraExtensionManager: WKWebExtensionControllerDelegate { @MainActor private func handleTabsQuery(params: [String: Any], context: WKWebExtensionContext) { - // Query tabs - // For now, return all tabs in active container - guard let container = tabManager?.activeContainer else { return } - let tabs: [[String: Any]] = container.tabs.map { tab in - [ - "id": tab.id.uuidString, - "url": tab.urlString, - "title": tab.title, - "active": tabManager?.isActive(tab) ?? false - ] as [String: Any] + // Diagnostics: enumerate all containers and tabs + guard let tm = tabManager else { + print("[ExtMgr] tabs.query: tabManager is nil") + return + } + let containers = tm.containers + print("[ExtMgr] tabs.query: containers=\(containers.count)") + + var totalTabs = 0 + for (ci, container) in containers.enumerated() { + print("[ExtMgr] container[\(ci)] id=\(container.id) tabs=\(container.tabs.count)") + for (ti, tab) in container.tabs.enumerated() { + totalTabs += 1 + let wrapperId = tab.extensionTabWrapper?.id + let isActive = tm.isActive(tab) + print(" [ExtMgr] tab[\(ti)] uuid=\(tab.id) wrapperId=\(wrapperId ?? -1) active=\(isActive) url=\(tab.urlString)") + } + } + print("[ExtMgr] tabs.query: totalTabsEnumerated=\(totalTabs)") + + // Maintain previous behavior (no reply), but log a brief summary for active container + if let active = tm.activeContainer { + print("[ExtMgr] tabs.query: activeContainerTabs=\(active.tabs.count)") } - // Note: Cannot send response back to extension via WKWebExtensionContext - // Extensions should use events or other mechanisms - logger.debug("Tabs query result: \(tabs)") } @MainActor diff --git a/ora/Services/Extentions/ExtensionTabWrapper.swift b/ora/Services/Extentions/ExtensionTabWrapper.swift new file mode 100644 index 00000000..b01ed795 --- /dev/null +++ b/ora/Services/Extentions/ExtensionTabWrapper.swift @@ -0,0 +1,125 @@ +// +// ExtensionTabWrapper.swift +// ora +// +// Created by keni on 10/9/25. +// +import WebKit +import Foundation +import AppKit // For NSImage + + +class ExtensionTabWrapper: NSObject, WKWebExtensionTab { + weak var nativeTab: Tab? // Weak to prevent cycles + let id: Int + + init(nativeTab: Tab, id: Int) { + self.nativeTab = nativeTab + self.id = id + print("[ExtTabWrapper] init wrapperId=\(id) tabId=\(nativeTab.id.uuidString) url=\(nativeTab.url.absoluteString)") + super.init() + } + + deinit { + print("[ExtTabWrapper] deinit wrapperId=\(id)") + } + +// // Required: Unique tab ID (manage via a counter in your app) +// var id: Int { +// get { self.id } // Backing storage +// // Note: This is read-only in the protocol, so no setter needed +// } + + // Core bridging: Expose the webView for injection + func webView(for context: WKWebExtensionContext) -> WKWebView? { + let extName = context.webExtension.displayName ?? "Unknown" + if let tab = nativeTab { + print("[ExtTabWrapper] webView(for:) wrapperId=\(id) tabId=\(tab.id.uuidString) ext=\(extName) url=\(tab.url.absoluteString)") + return tab.webView + } else { + print("[ExtTabWrapper] webView(for:) wrapperId=\(id) ext=\(extName) tab=nil") + return nil + } + } + + // Bridge loadURL: Translate to native method + func loadURL(_ url: URL, for context: WKWebExtensionContext, completionHandler: @escaping (Error?) -> Void) { + guard let nativeTab = nativeTab else { + completionHandler(NSError(domain: "ExtensionError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Tab no longer exists"])) + return + } + print("[ExtTabWrapper] loadURL wrapperId=\(id) tabId=\(nativeTab.id.uuidString) target=\(url.absoluteString)") + // Check permissions + guard context.permissionStatus(for: .activeTab) == .grantedExplicitly else { + completionHandler(NSError(domain: "PermissionError", code: -2, userInfo: [NSLocalizedDescriptionKey: "No permission to load URL"])) + return + } + nativeTab.loadURL(url.absoluteString) + // Assuming loadURL is fire-and-forget; call completion with no error + // If Tab.loadURL supports a completion, use it: nativeTab.loadURL(url.absoluteString) { error in completionHandler(error) } + completionHandler(nil) + } + +// // Bridge snapshot: Forward to native helper +// func takeSnapshot(using options: WKWebExtension.SnapshotOptions?, for context: WKWebExtensionContext, completionHandler: @escaping (NSImage?, Error?) -> Void) { +// guard let nativeTab = nativeTab else { +// completionHandler(nil, NSError(domain: "ExtensionError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Tab no longer exists"])) +// return +// } +// nativeTab.takeSnapshot { [weak self] image, error in +// // Optional: Permission/access check here if needed +// guard let self = self, self.nativeTab != nil else { +// completionHandler(nil, NSError(domain: "ExtensionError", code: -3, userInfo: [NSLocalizedDescriptionKey: "Tab invalidated during snapshot"])) +// return +// } +// completionHandler(image, error) +// } +// } + + // Bridge getters: Query native state + func url(for context: WKWebExtensionContext) -> URL? { + return nativeTab?.url + } + + func title(for context: WKWebExtensionContext) -> String? { + return nativeTab?.title + } + + // Required: Zoom factor (stub; delegate to native if available) + func zoomFactor(for context: WKWebExtensionContext) -> Double { + return 1.0 // Assume Tab has this; default to 1.0 + } + + // Required: Set zoom (stub) + func setZoomFactor(_ zoomFactor: Double, for context: WKWebExtensionContext, completionHandler: @escaping (Error?) -> Void) { + guard let nativeTab = nativeTab, zoomFactor >= 0.1 && zoomFactor <= 5.0 else { + completionHandler(NSError(domain: "ExtensionError", code: -4, userInfo: [NSLocalizedDescriptionKey: "Invalid zoom or tab missing"])) + return + } + // Delegate to native: nativeTab.setZoomFactor(zoomFactor) + completionHandler(nil) + } + +// // Required: Execute script (stub; use WKWebView's evaluateJavaScript) +// func executeScript(_ script: WKWebExtension.Script, for context: WKWebExtensionContext, completionHandler: @escaping (Any?, Error?) -> Void) { +// guard let webView = webView(for: context) else { +// completionHandler(nil, NSError(domain: "ExtensionError", code: -5, userInfo: [NSLocalizedDescriptionKey: "No web view available"])) +// return +// } +// webView.evaluateJavaScript(script.source) { result, error in +// completionHandler(result, error) +// } +// } + + // Required: Reader mode (stubs; implement if your Tab supports it) + func setReaderModeActive(_ active: Bool, for context: WKWebExtensionContext, completionHandler: @escaping (Error?) -> Void) { + // Delegate to nativeTab.setReaderModeActive(active) + completionHandler(nil) // Or handle error + } + + func isReaderModeAvailable(for context: WKWebExtensionContext) -> Bool { + return false + } + + // Add other required methods as needed (e.g., navigateBack, reload)... +} diff --git a/ora/Services/Extentions/ExtensionWindowWrapper.swift b/ora/Services/Extentions/ExtensionWindowWrapper.swift new file mode 100644 index 00000000..43727493 --- /dev/null +++ b/ora/Services/Extentions/ExtensionWindowWrapper.swift @@ -0,0 +1,19 @@ +import WebKit +import Foundation + +final class ExtensionWindowWrapper: NSObject, WKWebExtensionWindow { + let id: Int + + init(id: Int) { + self.id = id + super.init() + print("[ExtWindow] init id=\(id)") + } + + deinit { + print("[ExtWindow] deinit id=\(id)") + } +} + + + diff --git a/ora/Services/TabManager.swift b/ora/Services/TabManager.swift index b630bafc..5325db60 100644 --- a/ora/Services/TabManager.swift +++ b/ora/Services/TabManager.swift @@ -389,6 +389,7 @@ class TabManager: ObservableObject { tab.updateHeaderColor() try? modelContext.save() + // Note: Controller API has no setActive; skipping explicit activation. } // Activate a tab by its persistent id. If the tab is in a diff --git a/ora/Services/TabScriptHandler.swift b/ora/Services/TabScriptHandler.swift index eb8bbd3b..2450acd2 100644 --- a/ora/Services/TabScriptHandler.swift +++ b/ora/Services/TabScriptHandler.swift @@ -103,8 +103,9 @@ class TabScriptHandler: NSObject, WKScriptMessageHandler { forIdentifier: containerId ) } - - configuration.webExtensionController = OraExtensionManager.shared.controller + + configuration.webExtensionController = ExtensionManager.shared.controller + // Performance optimizations configuration.allowsAirPlayForMediaPlayback = true @@ -179,7 +180,7 @@ class TabScriptHandler: NSObject, WKScriptMessageHandler { if process.terminationStatus == 0 { logger.info("Extraction successful, installing extension") - await OraExtensionManager.shared.installExtension(from: extractDir) + await ExtensionManager.shared.installExtension(from: extractDir) // Reload the tab DispatchQueue.main.async { tab.webView.reload() diff --git a/ora/Services/WebViewNavigationDelegate.swift b/ora/Services/WebViewNavigationDelegate.swift index 27585473..ce3a02e0 100644 --- a/ora/Services/WebViewNavigationDelegate.swift +++ b/ora/Services/WebViewNavigationDelegate.swift @@ -491,7 +491,7 @@ class WebViewNavigationDelegate: NSObject, WKNavigationDelegate { process.waitUntilExit() if process.terminationStatus == 0 { logger.info("Extraction successful, installing extension") - await OraExtensionManager.shared.installExtension(from: extractDir) + await ExtensionManager.shared.installExtension(from: extractDir) // Reload the tab DispatchQueue.main.async { tab.webView.reload() diff --git a/ora/UI/URLBar.swift b/ora/UI/URLBar.swift index 5ac41853..7817ad27 100644 --- a/ora/UI/URLBar.swift +++ b/ora/UI/URLBar.swift @@ -22,7 +22,7 @@ struct ExtensionIconView: NSViewRepresentable { struct ExtensionIconButton: NSViewRepresentable { let ext: WKWebExtension - let extensionManager: OraExtensionManager + let extensionManager: ExtensionManager func makeNSView(context: Context) -> NSButton { let button = NSButton() @@ -47,9 +47,9 @@ struct ExtensionIconButton: NSViewRepresentable { class Coordinator: NSObject { let ext: WKWebExtension - let extensionManager: OraExtensionManager + let extensionManager: ExtensionManager - init(ext: WKWebExtension, extensionManager: OraExtensionManager) { + init(ext: WKWebExtension, extensionManager: ExtensionManager) { self.ext = ext self.extensionManager = extensionManager } @@ -72,7 +72,7 @@ struct ExtensionIconButton: NSViewRepresentable { struct URLBar: View { @EnvironmentObject var tabManager: TabManager @EnvironmentObject var appState: AppState - @EnvironmentObject var extensionManager: OraExtensionManager + @EnvironmentObject var extensionManager: ExtensionManager @State private var showCopiedAnimation = false @State private var startWheelAnimation = false From 75272c3763bfd808940d424ea0faa8a424d647e2 Mon Sep 17 00:00:00 2001 From: Furkan Koseoglu <49058467+furkanksl@users.noreply.github.com> Date: Sun, 28 Sep 2025 21:10:29 +0300 Subject: [PATCH 17/38] fix(sidebar): update sidebar visibility persistance (#124) Updated the sidebar visibility to use user defaults for persistence. Added functionality to restore the active tab's transient state on app startup if it is not ready, enhancing user experience by ensuring continuity in tab management. --- ora/Modules/Browser/BrowserView.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/ora/Modules/Browser/BrowserView.swift b/ora/Modules/Browser/BrowserView.swift index 5412420c..f8c42823 100644 --- a/ora/Modules/Browser/BrowserView.swift +++ b/ora/Modules/Browser/BrowserView.swift @@ -228,9 +228,6 @@ struct BrowserView: View { } } } - .onTapGesture(count: 2) { - toggleMaximizeWindow() - } .onAppear { // Restore active tab on app startup if not already ready if let tab = tabManager.activeTab, !tab.isWebViewReady { From 2f4dc672f24f67045a22e3d7d97010ae4eb330a4 Mon Sep 17 00:00:00 2001 From: Chase Date: Sun, 28 Sep 2025 15:08:10 -0700 Subject: [PATCH 18/38] feat: double click full resize of window (#128) * feat: wip double click * feat: add double click to maximize view * remove deprecation change * fix: when resizing use previous frame --- ora/Modules/Browser/BrowserView.swift | 56 ++------------------------- 1 file changed, 3 insertions(+), 53 deletions(-) diff --git a/ora/Modules/Browser/BrowserView.swift b/ora/Modules/Browser/BrowserView.swift index f8c42823..fa5dd566 100644 --- a/ora/Modules/Browser/BrowserView.swift +++ b/ora/Modules/Browser/BrowserView.swift @@ -21,59 +21,6 @@ struct BrowserView: View { } } - private func printExtensionInfo() { - if let tab = tabManager.activeTab { - if let controller = tab.webView.configuration.webExtensionController { - print("Controller: \(controller)") - print("Extensions Count: \(controller.extensions.count)") - print("Contexts Count: \(controller.extensionContexts.count)") - for extCtx in controller.extensionContexts { - print("πŸ”₯ Extension Context Properties:") - print(" baseURL: \(extCtx.baseURL)") - print(" commands: \(extCtx.commands.map(\.id))") - print(" currentPermissionMatchPatterns: \(extCtx.currentPermissionMatchPatterns)") - print(" currentPermissions: \(extCtx.currentPermissions.map(\.rawValue))") - print(" deniedPermissionMatchPatterns: \(extCtx.deniedPermissionMatchPatterns)") - print(" deniedPermissions: \(extCtx.deniedPermissions.map { "\($0.key.rawValue): \($0.value)" })") - print(" errors: \(extCtx.errors.map(\.localizedDescription))") - print( - " grantedPermissionMatchPatterns: \(extCtx.grantedPermissionMatchPatterns.map { "\($0.key): \($0.value)" })" - ) - print(" grantedPermissions: \(extCtx.grantedPermissions.map { "\($0.key.rawValue): \($0.value)" })" - ) - print(" hasAccessToAllHosts: \(extCtx.hasAccessToAllHosts)") - print(" hasAccessToAllURLs: \(extCtx.hasAccessToAllURLs)") - print(" hasAccessToPrivateData: \(extCtx.hasAccessToPrivateData)") - print(" hasContentModificationRules: \(extCtx.hasContentModificationRules)") - print(" hasInjectedContent: \(extCtx.hasInjectedContent)") - print(" hasRequestedOptionalAccessToAllHosts: \(extCtx.hasRequestedOptionalAccessToAllHosts)") - print(" inspectionName: \(extCtx.inspectionName ?? "None")") - print(" isInspectable: \(extCtx.isInspectable)") - print(" isLoaded: \(extCtx.isLoaded)") - // print(" isBackgroundContentLoaded: \(extCtx.isBackgroundContentLoaded)") - // print(" isContentScriptLoaded: \(extCtx.isContentScriptLoaded)") - print(" openTabs: \(extCtx.openTabs)") - print(" optionsPageURL: \(extCtx.optionsPageURL?.absoluteString ?? "None")") - print(" overrideNewTabPageURL: \(extCtx.overrideNewTabPageURL?.absoluteString ?? "None")") - print(" uniqueIdentifier: \(extCtx.uniqueIdentifier)") - print(" unsupportedAPIs: \(extCtx.unsupportedAPIs ?? [])") - // print(" webExtension: \(extCtx.webExtension?.displayName ?? "None")") - print(" webExtensionController: \(extCtx.webExtensionController != nil ? "Loaded" : "None")") - print(" webViewConfiguration: \(extCtx.webViewConfiguration != nil ? "Configured" : "None")") - let ext = extCtx.webExtension - print(" Extension Details:") - print(" Display Name: \(ext.displayName ?? "None")") - print(" Display Version: \(ext.displayVersion ?? "None")") - print(" Display Description: \(ext.displayDescription ?? "None")") - // print(" Permissions: \(ext.permissions.map { $0.rawValue })") - // print(" Background Content URL: - // \(ext.backgroundContentURL?.absoluteString ?? "None")") - // print(" Content Scripts Count: \(ext.contentScripts.count)") - } - } - } - } - private func toggleMaximizeWindow() { window?.toggleMaximized() } @@ -228,6 +175,9 @@ struct BrowserView: View { } } } + .onTapGesture(count: 2) { + toggleMaximizeWindow() + } .onAppear { // Restore active tab on app startup if not already ready if let tab = tabManager.activeTab, !tab.isWebViewReady { From 3f6b21b236ed61c0372ba788203b9749d6858cfc Mon Sep 17 00:00:00 2001 From: AryanRogye <161079183+AryanRogye@users.noreply.github.com> Date: Wed, 1 Oct 2025 10:27:11 -0500 Subject: [PATCH 19/38] Prevent Window Drag Interference During Tab Drags and Refactor TabManager for Modularity (#101) * attempting to fix a weird dragging issue I see * fixing things for the linter * split out the isDragging to make intent more cleaner * ci: workaround SwiftLint type_body_length in TabManager (untouched file) * CI is failing on existing SwiftLint violations in untouched files. This PR only contains the drag fix * Extract tab search logic into dedicated service Extract tab search logic into dedicated service - Moves search functionality out of TabManager to reduce class size - Uses protocol for testability - Service is instantiated directly but can easily be made injectable if needed * fixing linting issue inside of MouseTrackingArea * Refactor TabManager: added explicit public modifiers, simplify addTab URL defaults * Update ora/Services/TabManager.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Remove unused private methods in TabManager Removed unused private methods related to container and tab initialization. --------- Co-authored-by: Yonathan Dejene Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Kenenisa Alemayehu <53818162+kenenisa@users.noreply.github.com> --- .../Representables/MouseTrackingArea.swift | 20 +- ora/Modules/Sidebar/ContainerView.swift | 39 +++- ora/Services/TabManager.swift | 211 ++++++++++-------- 3 files changed, 164 insertions(+), 106 deletions(-) diff --git a/ora/Common/Representables/MouseTrackingArea.swift b/ora/Common/Representables/MouseTrackingArea.swift index 8871fcb2..f7101d3b 100644 --- a/ora/Common/Representables/MouseTrackingArea.swift +++ b/ora/Common/Representables/MouseTrackingArea.swift @@ -37,15 +37,15 @@ private final class TrackingStrip: NSView { win.level = .statusBar /// colored view - let overlay = NSView(frame: win.contentView!.bounds) - overlay.wantsLayer = true - overlay.layer?.backgroundColor = NSColor.systemGreen.withAlphaComponent(0.2).cgColor - overlay.layer?.borderColor = NSColor.systemGreen.cgColor - overlay.layer?.borderWidth = 2 - win.contentView?.addSubview(overlay) - - win.orderFrontRegardless() - debugWindow = win + if let contentView = win.contentView { + let overlay = NSView(frame: contentView.bounds) + overlay.wantsLayer = true + overlay.layer?.backgroundColor = NSColor.systemGreen.withAlphaComponent(0.2).cgColor + overlay.layer?.borderColor = NSColor.systemGreen.cgColor + overlay.layer?.borderWidth = 2 + overlay.autoresizingMask = [.width, .height] + contentView.addSubview(overlay) + } } #endif @@ -161,7 +161,7 @@ private final class TrackingStrip: NSView { } func stop() { - if let local = localMonitor { NSEvent.removeMonitor(local) } + if let localMonitor { NSEvent.removeMonitor(localMonitor) } localMonitor = nil isInside = false } diff --git a/ora/Modules/Sidebar/ContainerView.swift b/ora/Modules/Sidebar/ContainerView.swift index f7c6d696..82f2a958 100644 --- a/ora/Modules/Sidebar/ContainerView.swift +++ b/ora/Modules/Sidebar/ContainerView.swift @@ -8,6 +8,8 @@ struct ContainerView: View { @EnvironmentObject var appState: AppState @EnvironmentObject var tabManager: TabManager @EnvironmentObject var privacyMode: PrivacyMode + + @State var isDragging = false @State private var draggedItem: UUID? @State private var editingURLString: String = "" @@ -84,7 +86,7 @@ struct ContainerView: View { } } } - .modifier(OraWindowDragGesture()) + .modifier(OraWindowDragGesture(isDragging: $isDragging)) } private var favoriteTabs: [Tab] { @@ -137,6 +139,7 @@ struct ContainerView: View { } private func dragTab(_ tabId: UUID) -> NSItemProvider { + isDragging = true draggedItem = tabId let provider = TabItemProvider(object: tabId.uuidString as NSString) provider.didEnd = { @@ -146,33 +149,53 @@ struct ContainerView: View { } private func dropTab(_ tabId: String) { + isDragging = false draggedItem = nil } } private struct OraWindowDragGesture: ViewModifier { + @Binding var isDragging: Bool + func body(content: Content) -> some View { - if #available(macOS 15.0, *) { - content.gesture(WindowDragGesture()) - } else { - content.gesture(BackportWindowDragGesture()) + Group { + if isDragging { + content + } else { + if #available(macOS 15.0, *) { + content.gesture(WindowDragGesture()) + } else { + content.gesture(BackportWindowDragGesture(isDragging: $isDragging)) + } + } } } } private struct BackportWindowDragGesture: Gesture { + @Binding var isDragging: Bool + struct Value: Equatable { static func == (lhs: Value, rhs: Value) -> Bool { true } } - init() {} + init(isDragging: Binding) { + self._isDragging = isDragging + } var body: some Gesture { DragGesture() .onChanged { _ in - if let nsWindow = NSApp.keyWindow, let event = NSApp.currentEvent { - nsWindow.performDrag(with: event) + /// Makes intent cleaner, if we're dragging, then just return + /// Maybe some other case needs to be watched for here + guard !isDragging else { + return } + guard let win = NSApp.keyWindow, let event = NSApp.currentEvent else { + return + } + + win.performDrag(with: event) } .map { _ in Value() } } diff --git a/ora/Services/TabManager.swift b/ora/Services/TabManager.swift index 5325db60..fabfb26d 100644 --- a/ora/Services/TabManager.swift +++ b/ora/Services/TabManager.swift @@ -12,84 +12,34 @@ class TabManager: ObservableObject { let modelContext: ModelContext let mediaController: MediaController + // Note: Could be made injectable via init parameter if preferred + let tabSearchingService: TabSearchingProviding + @Query(sort: \TabContainer.lastAccessedAt, order: .reverse) var containers: [TabContainer] init( modelContainer: ModelContainer, modelContext: ModelContext, - mediaController: MediaController + mediaController: MediaController, + tabSearchingService: TabSearchingProviding = TabSearchingService() ) { self.modelContainer = modelContainer self.modelContext = modelContext self.mediaController = mediaController + self.tabSearchingService = tabSearchingService self.modelContext.undoManager = UndoManager() initializeActiveContainerAndTab() } - func search(_ text: String) -> [Tab] { - let activeContainerId = activeContainer?.id ?? UUID() - let trimmedText = text.trimmingCharacters(in: .whitespaces) - - let predicate: Predicate - if trimmedText.isEmpty { - predicate = #Predicate { _ in true } - } else { - predicate = #Predicate { tab in - ( - tab.urlString.localizedStandardContains(trimmedText) || - tab.title - .localizedStandardContains( - trimmedText - ) - ) && tab.container.id == activeContainerId - } - } - - let descriptor = FetchDescriptor(predicate: predicate) - - do { - let results = try modelContext.fetch(descriptor) - let now = Date() - - return results.sorted { result1, result2 in - let result1Score = combinedScore(for: result1, query: trimmedText, now: now) - let result2Score = combinedScore(for: result2, query: trimmedText, now: now) - return result1Score > result2Score - } - - } catch { - return [] - } - } - - private func combinedScore(for tab: Tab, query: String, now: Date) -> Double { - let match = scoreMatch(tab, text: query) - - let timeInterval: TimeInterval = if let accessedAt = tab.lastAccessedAt { - now.timeIntervalSince(accessedAt) - } else { - 1_000_000 // far in the past β†’ lowest recency - } - - let recencyBoost = max(0, 1_000_000 - timeInterval) - return Double(match * 1000) + recencyBoost - } - - private func scoreMatch(_ tab: Tab, text: String) -> Int { - let text = text.lowercased() - let title = tab.title.lowercased() - let url = tab.urlString.lowercased() + // MARK: - Public API's - func score(_ field: String) -> Int { - if field == text { return 100 } - if field.hasPrefix(text) { return 90 } - if field.contains(text) { return 75 } - if text.contains(field) { return 50 } - return 0 - } - - return max(score(title), score(url)) + func search(_ text: String) -> [Tab] { + tabSearchingService.search( + text, + activeContainer: activeContainer, + modelContext: modelContext + ) } func openFromEngine( @@ -137,15 +87,12 @@ class TabManager: ObservableObject { try? modelContext.save() } - func getActiveTab() -> Tab? { - return self.activeTab - } + // MARK: - Container Public API's func moveTabToContainer(_ tab: Tab, toContainer: TabContainer) { tab.container = toContainer try? modelContext.save() } - private func initializeActiveContainerAndTab() { // Ensure containers are fetched let containers = fetchContainers() @@ -169,6 +116,7 @@ class TabManager: ObservableObject { @discardableResult func createContainer(name: String = "Default", emoji: String = "β€’") -> TabContainer { + let newContainer = TabContainer(name: name, emoji: emoji) modelContext.insert(newContainer) activeContainer = newContainer @@ -188,9 +136,32 @@ class TabManager: ObservableObject { modelContext.delete(container) } + func activateContainer(_ container: TabContainer, activateLastAccessedTab: Bool = true) { + activeContainer = container + container.lastAccessedAt = Date() + + // Set the most recently accessed tab in the container + if let lastAccessedTab = container.tabs + .sorted(by: { $0.lastAccessedAt ?? Date() > $1.lastAccessedAt ?? Date() }).first, + lastAccessedTab.isWebViewReady + { + activeTab?.maybeIsActive = false + activeTab = lastAccessedTab + activeTab?.maybeIsActive = true + lastAccessedTab.lastAccessedAt = Date() + } else { + activeTab = nil + } + + try? modelContext.save() + } + + // MARK: - Tab Public API's + func addTab( title: String = "Untitled", - url: URL = URL(string: "https://www.youtube.com/") ?? URL(string: "about:blank") ?? URL(fileURLWithPath: ""), + /// Will Always Work + url: URL = URL(string: "about:blank")!, container: TabContainer, favicon: URL? = nil, historyManager: HistoryManager? = nil, @@ -359,26 +330,6 @@ class TabManager: ObservableObject { try? modelContext.save() // Persist the undo operation } - func activateContainer(_ container: TabContainer, activateLastAccessedTab: Bool = true) { - activeContainer = container - container.lastAccessedAt = Date() - - // Set the most recently accessed tab in the container - if let lastAccessedTab = container.tabs - .sorted(by: { $0.lastAccessedAt ?? Date() > $1.lastAccessedAt ?? Date() }).first, - lastAccessedTab.isWebViewReady - { - activeTab?.maybeIsActive = false - activeTab = lastAccessedTab - activeTab?.maybeIsActive = true - lastAccessedTab.lastAccessedAt = Date() - } else { - activeTab = nil - } - - try? modelContext.save() - } - func activateTab(_ tab: Tab) { activeTab?.maybeIsActive = false activeTab = tab @@ -405,6 +356,7 @@ class TabManager: ObservableObject { } } + func selectTabAtIndex(_ index: Int) { guard let container = activeContainer else { return } @@ -462,3 +414,86 @@ class TabManager: ObservableObject { } } } + +// MARK: - Tab Searching Providing + +protocol TabSearchingProviding { + func search( + _ text: String, + activeContainer: TabContainer?, + modelContext: ModelContext + ) -> [Tab] +} + +// MARK: - Tab Searching Service + +final class TabSearchingService: TabSearchingProviding { + func search( + _ text: String, + activeContainer: TabContainer? = nil, + modelContext: ModelContext + ) -> [Tab] { + let activeContainerId = activeContainer?.id ?? UUID() + let trimmedText = text.trimmingCharacters(in: .whitespaces) + + let predicate: Predicate + if trimmedText.isEmpty { + predicate = #Predicate { _ in true } + } else { + predicate = #Predicate { tab in + ( + tab.urlString.localizedStandardContains(trimmedText) || + tab.title + .localizedStandardContains( + trimmedText + ) + ) && tab.container.id == activeContainerId + } + } + + let descriptor = FetchDescriptor(predicate: predicate) + + do { + let results = try modelContext.fetch(descriptor) + let now = Date() + + return results.sorted { result1, result2 in + let result1Score = combinedScore(for: result1, query: trimmedText, now: now) + let result2Score = combinedScore(for: result2, query: trimmedText, now: now) + return result1Score > result2Score + } + + } catch { + return [] + } + } + + private func combinedScore(for tab: Tab, query: String, now: Date) -> Double { + let match = scoreMatch(tab, text: query) + + let timeInterval: TimeInterval = if let accessedAt = tab.lastAccessedAt { + now.timeIntervalSince(accessedAt) + } else { + 1_000_000 // far in the past β†’ lowest recency + } + + let recencyBoost = max(0, 1_000_000 - timeInterval) + return Double(match * 1000) + recencyBoost + } + + private func scoreMatch(_ tab: Tab, text: String) -> Int { + let text = text.lowercased() + let title = tab.title.lowercased() + let url = tab.urlString.lowercased() + + func score(_ field: String) -> Int { + if field == text { return 100 } + if field.hasPrefix(text) { return 90 } + if field.contains(text) { return 75 } + if text.contains(field) { return 50 } + return 0 + } + + return max(score(title), score(url)) + } +} From c22f325a9df2d14c4dbc5062d3bd58b047002196 Mon Sep 17 00:00:00 2001 From: Kenenisa Alemayehu <53818162+kenenisa@users.noreply.github.com> Date: Fri, 3 Oct 2025 19:51:09 +0300 Subject: [PATCH 20/38] Add GNU General Public License v2 --- LICENSE | 339 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 339 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..d159169d --- /dev/null +++ b/LICENSE @@ -0,0 +1,339 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. From 2cc56866889b125955800651b25e365e40c85eec Mon Sep 17 00:00:00 2001 From: Kenenisa Alemayehu <53818162+kenenisa@users.noreply.github.com> Date: Fri, 3 Oct 2025 19:52:36 +0300 Subject: [PATCH 21/38] Delete LICENSE.md --- LICENSE.md | 21 --------------------- 1 file changed, 21 deletions(-) delete mode 100644 LICENSE.md diff --git a/LICENSE.md b/LICENSE.md deleted file mode 100644 index fdfc4886..00000000 --- a/LICENSE.md +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 - Present, The Ora Team - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file From 0528fa96b026d3a0918fa7b290add18a71a6eaff Mon Sep 17 00:00:00 2001 From: Kenenisa Alemayehu <53818162+kenenisa@users.noreply.github.com> Date: Fri, 3 Oct 2025 19:55:11 +0300 Subject: [PATCH 22/38] Change license from MIT to GPL-2.0 Updated the license information from MIT to GPL-2.0. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f47e256b..ad47a8e7 100644 --- a/README.md +++ b/README.md @@ -117,5 +117,5 @@ Questions or support? Join the community on [Discord](https://discord.gg/9aZWH52 ## License -Ora is open source and licensed under the [MIT License](LICENSE.md). -Feel free to use, modify, and distribute it under the terms of the MIT License. +Ora is open source and licensed under the [GPL-2.0 license](LICENSE). +Feel free to use, modify, and distribute it under the terms of the GPL-2.0 license. From 698490185a28b66087a6665a4608a3d232c5dd0b Mon Sep 17 00:00:00 2001 From: Kenenisa Alemayehu <53818162+kenenisa@users.noreply.github.com> Date: Fri, 3 Oct 2025 20:55:37 +0300 Subject: [PATCH 23/38] feat: add URL handling and deep linking support (#132) * feat: add URL handling and deep linking support - Implemented URL scheme handling in the app to allow opening URLs directly. - Added CFBundleURLTypes to project configuration for http and https schemes. - Enhanced AppDelegate to manage incoming URLs and notify the UI. - Updated OraRoot to handle URL opening via notifications. * Update ora/Common/Utils/WindowFactory.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: update main window initialization to use OraRoot instance * feat: enhance browser capabilities and settings integration - Added support for document types in project configuration, including Web URL, HTML, and XHTML. - Updated Info.plist to define application category and minimum system version. - Integrated DefaultBrowserManager to manage default browser status and reflect changes in the UI. - Improved settings view to utilize DefaultBrowserManager for default browser checks. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- ora/Common/Constants/AppEvents.swift | 3 + ora/Common/Utils/WindowFactory.swift | 26 ++++++++ ora/Info.plist | 63 +++++++++++++++++++ .../Sections/GeneralSettingsView.swift | 31 ++++----- ora/OraRoot.swift | 16 +++++ ora/Services/DefaultBrowserManager.swift | 53 ++++++++++++++++ ora/oraApp.swift | 38 +++++++++-- project.yml | 27 ++++++++ 8 files changed, 233 insertions(+), 24 deletions(-) create mode 100644 ora/Common/Utils/WindowFactory.swift create mode 100644 ora/Services/DefaultBrowserManager.swift diff --git a/ora/Common/Constants/AppEvents.swift b/ora/Common/Constants/AppEvents.swift index 30d5c4be..5195393b 100644 --- a/ora/Common/Constants/AppEvents.swift +++ b/ora/Common/Constants/AppEvents.swift @@ -21,4 +21,7 @@ extension Notification.Name { // Per-window settings/events static let setAppearance = Notification.Name("SetAppearance") // userInfo: ["appearance": String] static let checkForUpdates = Notification.Name("CheckForUpdates") + + // AppDelegate β†’ UI routing + static let openURL = Notification.Name("OpenURL") // userInfo: ["url": URL] } diff --git a/ora/Common/Utils/WindowFactory.swift b/ora/Common/Utils/WindowFactory.swift new file mode 100644 index 00000000..b66f9941 --- /dev/null +++ b/ora/Common/Utils/WindowFactory.swift @@ -0,0 +1,26 @@ +import AppKit +import SwiftUI + +enum WindowFactory { + static func makeMainWindow(rootView: some View, size: CGSize = CGSize(width: 1440, height: 900)) -> NSWindow { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: size.width, height: size.height), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.isReleasedWhenClosed = false + + let hostingController = NSHostingController(rootView: rootView) + window.contentViewController = hostingController + window.center() + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + return window + } +} + + + diff --git a/ora/Info.plist b/ora/Info.plist index a0ce0850..3b13bf3a 100644 --- a/ora/Info.plist +++ b/ora/Info.plist @@ -4,6 +4,45 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) + CFBundleDocumentTypes + + + CFBundleTypeName + Web URL + CFBundleTypeRole + Viewer + LSHandlerRank + Owner + LSItemContentTypes + + public.url + + + + CFBundleTypeName + HTML document + CFBundleTypeRole + Viewer + LSHandlerRank + Default + LSItemContentTypes + + public.html + + + + CFBundleTypeName + XHTML document + CFBundleTypeRole + Viewer + LSHandlerRank + Default + LSItemContentTypes + + public.xhtml + + + CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -16,8 +55,32 @@ APPL CFBundleShortVersionString 1.0 + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + Ora Browser + CFBundleURLSchemes + + http + https + + LSHandlerRank + Owner + + CFBundleVersion 1 + LSApplicationCategoryType + public.app-category.web-browser + LSMinimumSystemVersion + 13.0 + NSUserActivityTypes + + NSUserActivityTypeBrowsingWeb + SUEnableAutomaticChecks SUFeedURL diff --git a/ora/Modules/Settings/Sections/GeneralSettingsView.swift b/ora/Modules/Settings/Sections/GeneralSettingsView.swift index f4863c5c..8b6c9770 100644 --- a/ora/Modules/Settings/Sections/GeneralSettingsView.swift +++ b/ora/Modules/Settings/Sections/GeneralSettingsView.swift @@ -5,6 +5,7 @@ struct GeneralSettingsView: View { @EnvironmentObject var appearanceManager: AppearanceManager @EnvironmentObject var updateService: UpdateService @StateObject private var settings = SettingsStore.shared + @StateObject private var defaultBrowserManager = DefaultBrowserManager.shared @Environment(\.theme) var theme var body: some View { @@ -29,16 +30,19 @@ struct GeneralSettingsView: View { .padding(12) .background(theme.solidWindowBackgroundColor) .cornerRadius(8) - - HStack { - Text("Born for your Mac. Make Ora your default browser.") - Spacer() - Button("Set Ora as default") { openDefaultBrowserSettings() } + + if !defaultBrowserManager.isDefault { + + HStack { + Text("Born for your Mac. Make Ora your default browser.") + Spacer() + Button("Set Ora as default") { DefaultBrowserManager.requestSetAsDefault() } + } + .frame(maxWidth: .infinity) + .padding(8) + .background(theme.solidWindowBackgroundColor) + .cornerRadius(8) } - .frame(maxWidth: .infinity) - .padding(8) - .background(theme.solidWindowBackgroundColor) - .cornerRadius(8) AppearanceSelector(selection: $appearanceManager.appearance) @@ -94,15 +98,6 @@ struct GeneralSettingsView: View { } } - private func openDefaultBrowserSettings() { - guard - let url = URL( - string: "x-apple.systempreferences:com.apple.preference.general?DefaultWebBrowser" - ) - else { return } - NSWorkspace.shared.open(url) - } - private func getAppVersion() -> String { let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown" let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown" diff --git a/ora/OraRoot.swift b/ora/OraRoot.swift index e7e99e6b..f08652db 100644 --- a/ora/OraRoot.swift +++ b/ora/OraRoot.swift @@ -184,6 +184,22 @@ struct OraRoot: View { tabManager.selectTabAtIndex(index) } } + NotificationCenter.default.addObserver(forName: .openURL, object: nil, queue: .main) { note in + let targetWindow = window ?? NSApp.keyWindow + if let sender = note.object as? NSWindow { + guard sender === targetWindow else { return } + } else { + guard NSApp.keyWindow === targetWindow else { return } + } + guard let url = note.userInfo?["url"] as? URL else { return } + tabManager.openTab( + url: url, + historyManager: historyManager, + downloadManager: downloadManager, + focusAfterOpening: true, + isPrivate: privacyMode.isPrivate + ) + } } } } diff --git a/ora/Services/DefaultBrowserManager.swift b/ora/Services/DefaultBrowserManager.swift new file mode 100644 index 00000000..67fdc55a --- /dev/null +++ b/ora/Services/DefaultBrowserManager.swift @@ -0,0 +1,53 @@ +// +// DefaultBrowserManager.swift +// ora +// +// Created by keni on 9/30/25. +// + + +import CoreServices +import AppKit +import Combine + +class DefaultBrowserManager: ObservableObject { + static let shared = DefaultBrowserManager() + + @Published var isDefault: Bool = false + + private var cancellables = Set() + + private init() { + updateIsDefault() + // Periodically check if default browser status changed. I couldn't find another way. + Timer.publish(every: 1.0, on: .main, in: .common) + .autoconnect() + .sink { [weak self] _ in + self?.updateIsDefault() + } + .store(in: &cancellables) + } + + private func updateIsDefault() { + let newValue = Self.checkIsDefault() + if newValue != isDefault { + isDefault = newValue + } + } + + static func checkIsDefault() -> Bool { + guard let testURL = URL(string: "http://example.com"), + let appURL = NSWorkspace.shared.urlForApplication(toOpen: testURL), + let appBundle = Bundle(url: appURL) else { + return false + } + + return appBundle.bundleIdentifier == Bundle.main.bundleIdentifier + } + + static func requestSetAsDefault() { + let bundleID = Bundle.main.bundleIdentifier! as CFString + LSSetDefaultHandlerForURLScheme("http" as CFString, bundleID) + LSSetDefaultHandlerForURLScheme("https" as CFString, bundleID) + } +} diff --git a/ora/oraApp.swift b/ora/oraApp.swift index f73c72e4..2197ae88 100644 --- a/ora/oraApp.swift +++ b/ora/oraApp.swift @@ -9,6 +9,28 @@ class AppDelegate: NSObject, NSApplicationDelegate { NSWindow.allowsAutomaticWindowTabbing = false AppearanceManager.shared.updateAppearance() } + func application(_ application: NSApplication, open urls: [URL]) { + handleIncomingURLs(urls) + } + + func getWindow() -> NSWindow? { + if let key = NSApp.keyWindow { return key } + if let visible = NSApp.windows.first(where: { $0.isVisible }) { return visible } + if let any = NSApp.windows.first { + any.makeKeyAndOrderFront(nil) + return any + } + return WindowFactory.makeMainWindow(rootView: OraRoot()) + } + func handleIncomingURLs(_ urls: [URL]) { + let window = getWindow()! + for url in urls { + let userInfo: [AnyHashable: Any] = ["url": url] + DispatchQueue.main.async { + NotificationCenter.default.post(name: .openURL, object: window, userInfo: userInfo) + } + } + } } extension Notification.Name {} @@ -18,7 +40,6 @@ func deleteSwiftDataStore(_ loc: String) { let storeURL = URL.applicationSupportDirectory.appending(path: loc) let shmURL = storeURL.appendingPathExtension("-shm") let walURL = storeURL.appendingPathExtension("-wal") - try? fileManager.removeItem(at: storeURL) try? fileManager.removeItem(at: shmURL) try? fileManager.removeItem(at: walURL) @@ -38,33 +59,38 @@ class AppState: ObservableObject { @main struct OraApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate - + // Shared model container that uses the same configuration as the main browser private let sharedModelContainer: ModelContainer? = - try? ModelConfiguration.createOraContainer(isPrivate: false) - + try? ModelConfiguration.createOraContainer(isPrivate: false) + var body: some Scene { WindowGroup(id: "normal") { OraRoot() .frame(minWidth: 500, minHeight: 360) + .environmentObject(DefaultBrowserManager.shared) } .defaultSize(width: 1440, height: 900) .windowStyle(.hiddenTitleBar) .windowResizability(.contentMinSize) - + .handlesExternalEvents(matching: []) + WindowGroup("Private", id: "private") { OraRoot(isPrivate: true) .frame(minWidth: 500, minHeight: 360) + .environmentObject(DefaultBrowserManager.shared) } .defaultSize(width: 1440, height: 900) .windowStyle(.hiddenTitleBar) .windowResizability(.contentMinSize) - + .handlesExternalEvents(matching: []) + Settings { if let sharedModelContainer { SettingsContentView() .environmentObject(AppearanceManager.shared) .environmentObject(UpdateService.shared) + .environmentObject(DefaultBrowserManager.shared) .withTheme() .modelContainer(sharedModelContainer) } else { diff --git a/project.yml b/project.yml index 34d02af6..6c0397c9 100644 --- a/project.yml +++ b/project.yml @@ -49,6 +49,33 @@ targets: SUFeedURL: "https://the-ora.github.io/browser/appcast.xml" SUPublicEDKey: "Ozj+rezzbJAD76RfajtfQ7rFojJbpFSCl/0DcFSBCTI=" SUEnableAutomaticChecks: YES + CFBundleURLTypes: + - CFBundleURLName: Ora Browser + CFBundleTypeRole: Editor + LSHandlerRank: Owner + CFBundleURLSchemes: + - http + - https + CFBundleDocumentTypes: + - CFBundleTypeName: Web URL + CFBundleTypeRole: Viewer + LSHandlerRank: Owner + LSItemContentTypes: + - public.url + - CFBundleTypeName: HTML document + CFBundleTypeRole: Viewer + LSHandlerRank: Default + LSItemContentTypes: + - public.html + - CFBundleTypeName: XHTML document + CFBundleTypeRole: Viewer + LSHandlerRank: Default + LSItemContentTypes: + - public.xhtml + NSUserActivityTypes: + - NSUserActivityTypeBrowsingWeb + LSMinimumSystemVersion: "13.0" + LSApplicationCategoryType: public.app-category.web-browser entitlements: path: ora/ora.entitlements From 7f5f1b90666875e033f88b46586f644a6377daab Mon Sep 17 00:00:00 2001 From: Yonathan Dejene Date: Sun, 5 Oct 2025 23:02:26 +0300 Subject: [PATCH 24/38] Update macOS and License badge in README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ad47a8e7..e640e5c8 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,11 @@

- macOS + macOS Xcode Swift Version Homebrew - License: MIT + License: MIT

> **⚠️ Disclaimer** From e994295287eff34a6ac6626efd82e98e88d8eac1 Mon Sep 17 00:00:00 2001 From: Kenenisa Alemayehu <53818162+kenenisa@users.noreply.github.com> Date: Mon, 6 Oct 2025 19:34:03 +0300 Subject: [PATCH 25/38] Enhance Tab Management Settings and UI (#139) * Refactor BrowserView for Improved Tab Management and UI Responsiveness - Enhanced tab handling by ensuring only active web views are displayed. - Introduced a new method for rendering web views based on the active tab. - Improved sidebar toggle functionality and UI responsiveness. - Cleaned up code formatting and removed unnecessary whitespace for better readability. * Enhance Tab Management and UI with Media Playback Indicators - Added isPlayingMedia property to Tab model for tracking media playback state. - Updated FloatingTabSwitcher and TabItem views to display media playback indicators. - Refactored BrowserView to improve tab rendering logic and maintain UI responsiveness. - Ensured MediaController updates the isPlayingMedia property in the corresponding Tab. - Improved recent tab sorting and rendering logic in TabManager for better user experience. * Add Tab Management Settings for Automatic Cleanup - Introduced settings for tab management, allowing users to configure timeouts for tab activity and removal. - Added UI elements in GeneralSettingsView for adjusting tab alive and removal timeouts. - Implemented logic in TabManager to automatically clean up and remove inactive tabs based on user-defined settings. - Enhanced Tab model with isAlive property to determine recent activity of tabs. * Enhance Tab Management Settings and UI - Updated tab management settings to allow users to configure timeouts for tab activity and removal, with new options in GeneralSettingsView. - Adjusted default timeout values for tab activity and removal to 1 hour and 1 day, respectively. - Implemented automatic cleanup logic in TabManager to manage inactive tabs based on user-defined settings. - Improved UI elements for better user experience in managing tab settings. - Cleaned up code formatting and removed unnecessary whitespace for improved readability. * Refactor BrowserView to Improve Code Readability - Removed unnecessary whitespace and cleaned up code formatting in BrowserView.swift for better readability. - Streamlined the rendering logic for active web views, enhancing overall code clarity. * Fix Typo in HistoryManager Parameter Name Across Multiple Files - Corrected the parameter name from `historyManger` to `historyManager` in the `restoreTransientState` method across `Tab.swift`, `BrowserView.swift`, and `TabManager.swift` to ensure consistency and prevent potential errors. * Refactor Window Closing Logic and Update URL Loading in Tabs - Modified the "Close Window" button to only close the Settings window when it is the active window. - Updated URL loading logic in Tab class to conditionally use savedURL based on tab type, ensuring correct URL handling. - Corrected parameter name from `historyManger` to `historyManager` in multiple files for consistency. * feat: make maximum recent tabs configurable in settings * feat: normalize tab timeout settings for improved user configuration - Introduced normalization logic for tab alive and removal timeouts, ensuring values are within supported ranges. - Updated default timeout values to enhance user experience and prevent invalid settings. - Added a helper method to streamline timeout normalization process. --- ora/Common/Constants/ContainerConstants.swift | 6 + ora/Common/Utils/SettingsStore.swift | 62 +++++ ora/Models/Tab.swift | 32 ++- ora/Modules/Browser/BrowserView.swift | 132 ++++++----- ora/Modules/Launcher/Main/LauncherMain.swift | 2 +- .../Sections/GeneralSettingsView.swift | 74 +++++- .../TabSwitch/FloatingTabSwitcher.swift | 3 +- ora/OraCommands.swift | 13 ++ ora/Services/MediaController.swift | 6 + ora/Services/SectionDropDelegate.swift | 11 +- ora/Services/TabManager.swift | 211 ++++++++++++------ ora/UI/FavTabItem.swift | 26 ++- ora/UI/TabItem.swift | 22 +- 13 files changed, 439 insertions(+), 161 deletions(-) diff --git a/ora/Common/Constants/ContainerConstants.swift b/ora/Common/Constants/ContainerConstants.swift index 2a3715dc..f3e9ff11 100644 --- a/ora/Common/Constants/ContainerConstants.swift +++ b/ora/Common/Constants/ContainerConstants.swift @@ -5,6 +5,12 @@ enum ContainerConstants { /// Default emoji used when no emoji is selected for a container static let defaultEmoji = "β€’" + /// Default time in seconds after which a tab is no longer considered alive + static let defaultTabAliveTimeout: TimeInterval = 60 * 60 // 1 hour + + /// Default time in seconds after which normal tabs are completely removed + static let defaultTabRemovalTimeout: TimeInterval = 24 * 60 * 60 // 1 day + /// UI constants for container forms and displays enum UI { static let normalButtonWidth: CGFloat = 28 diff --git a/ora/Common/Utils/SettingsStore.swift b/ora/Common/Utils/SettingsStore.swift index 85206307..b2bbfd8d 100644 --- a/ora/Common/Utils/SettingsStore.swift +++ b/ora/Common/Utils/SettingsStore.swift @@ -146,6 +146,9 @@ class SettingsStore: ObservableObject { private let customSearchEnginesKey = "settings.customSearchEngines" private let globalDefaultSearchEngineKey = "settings.globalDefaultSearchEngine" private let customKeyboardShortcutsKey = "settings.customKeyboardShortcuts" + private let tabAliveTimeoutKey = "settings.tabAliveTimeout" + private let tabRemovalTimeoutKey = "settings.tabRemovalTimeout" + private let maxRecentTabsKey = "settings.maxRecentTabs" // MARK: - Per-Container @@ -197,6 +200,18 @@ class SettingsStore: ObservableObject { didSet { saveCodable(customKeyboardShortcuts, forKey: customKeyboardShortcutsKey) } } + @Published var tabAliveTimeout: TimeInterval { + didSet { defaults.set(tabAliveTimeout, forKey: tabAliveTimeoutKey) } + } + + @Published var tabRemovalTimeout: TimeInterval { + didSet { defaults.set(tabRemovalTimeout, forKey: tabRemovalTimeoutKey) } + } + + @Published var maxRecentTabs: Int { + didSet { defaults.set(maxRecentTabs, forKey: maxRecentTabsKey) } + } + init() { autoUpdateEnabled = defaults.bool(forKey: autoUpdateKey) blockThirdPartyTrackers = defaults.bool(forKey: trackingThirdPartyKey) @@ -220,6 +235,35 @@ class SettingsStore: ObservableObject { customKeyboardShortcuts = Self.loadCodable([String: KeyChord].self, key: customKeyboardShortcutsKey) ?? [:] + + let aliveTimeoutValue = defaults.double(forKey: tabAliveTimeoutKey) + let supportedTimeouts: [TimeInterval] = [ + 60 * 60, // 1 hour + 6 * 60 * 60, // 6 hours + 12 * 60 * 60, // 12 hours + 24 * 60 * 60, // 1 day + 2 * 24 * 60 * 60, // 2 days + 365 * 24 * 60 * 60 // "Never" sentinel + ] + let normalizedAlive = Self.normalizeTimeout( + aliveTimeoutValue, + defaultSeconds: 60 * 60, + supported: supportedTimeouts + ) + defaults.set(normalizedAlive, forKey: tabAliveTimeoutKey) + tabAliveTimeout = normalizedAlive + + let removalTimeoutValue = defaults.double(forKey: tabRemovalTimeoutKey) + let normalizedRemoval = Self.normalizeTimeout( + removalTimeoutValue, + defaultSeconds: 24 * 60 * 60, + supported: supportedTimeouts + ) + defaults.set(normalizedRemoval, forKey: tabRemovalTimeoutKey) + tabRemovalTimeout = normalizedRemoval + + let maxRecentTabsValue = defaults.integer(forKey: maxRecentTabsKey) + maxRecentTabs = maxRecentTabsValue == 0 ? 5 : maxRecentTabsValue } // MARK: - Per-container helpers @@ -318,4 +362,22 @@ class SettingsStore: ObservableObject { guard let data = defaults.data(forKey: key) else { return nil } return try? JSONDecoder().decode(T.self, from: data) } + + // MARK: - Normalization helpers + + private static func normalizeTimeout( + _ raw: TimeInterval, + defaultSeconds: TimeInterval, + supported: [TimeInterval] + ) -> TimeInterval { + let value: TimeInterval = raw == 0 ? defaultSeconds : raw + + if supported.contains(value) { + return value + } + + return supported.min { lhs, rhs in + abs(lhs - value) < abs(rhs - value) + } ?? defaultSeconds + } } diff --git a/ora/Models/Tab.swift b/ora/Models/Tab.swift index da8e3d68..48d93195 100644 --- a/ora/Models/Tab.swift +++ b/ora/Models/Tab.swift @@ -3,6 +3,8 @@ import SwiftData import SwiftUI import WebKit +// Import for accessing settings + enum TabType: String, Codable { case pinned case fav @@ -27,14 +29,16 @@ class Tab: ObservableObject, Identifiable { var favicon: URL? // Add favicon property var createdAt: Date var lastAccessedAt: Date? - var isPlayingMedia: Bool - var isLoading: Bool = false + + var type: TabType var order: Int var faviconLocalFile: URL? var backgroundColorHex: String = "#000000" // @Transient @Published var backgroundColor: Color = Color(.black) + @Transient var isPlayingMedia: Bool = false + @Transient var isLoading: Bool = false @Transient @Published var backgroundColor: Color = .black @Transient var historyManager: HistoryManager? @Transient var downloadManager: DownloadManager? @@ -55,6 +59,13 @@ class Tab: ObservableObject, Identifiable { @Relationship(inverse: \TabContainer.tabs) var container: TabContainer + /// Whether this tab is considered alive (recently accessed) + var isAlive: Bool { + guard let lastAccessed = lastAccessedAt else { return false } + let timeout = SettingsStore.shared.tabAliveTimeout + return Date().timeIntervalSince(lastAccessed) < timeout + } + init( id: UUID = UUID(), url: URL, @@ -159,6 +170,12 @@ class Tab: ObservableObject, Identifiable { func switchSections(from: Tab, to: Tab) { from.type = to.type + switch to.type { + case .pinned, .fav: + from.savedURL = from.url + case .normal: + from.savedURL = nil + } } func updateHeaderColor() { @@ -238,17 +255,19 @@ class Tab: ObservableObject, Identifiable { } func goForward() { + lastAccessedAt = Date() self.webView.goForward() self.updateHeaderColor() } func goBack() { + lastAccessedAt = Date() self.webView.goBack() self.updateHeaderColor() } func restoreTransientState( - historyManger: HistoryManager, + historyManager: HistoryManager, downloadManager: DownloadManager, tabManager: TabManager, isPrivate: Bool @@ -278,7 +297,7 @@ class Tab: ObservableObject, Identifiable { layer.drawsAsynchronously = true } - self.historyManager = historyManger + self.historyManager = historyManager self.downloadManager = downloadManager self.tabManager = tabManager self.isWebViewReady = false @@ -286,7 +305,8 @@ class Tab: ObservableObject, Identifiable { self.syncBackgroundColorFromHex() // Load after a short delay to ensure layout DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { - self.webView.load(URLRequest(url: self.url)) + let url = if self.type != .normal { self.savedURL } else { self.url } + self.webView.load(URLRequest(url: url ?? self.url)) self.isWebViewReady = true } } @@ -312,6 +332,7 @@ class Tab: ObservableObject, Identifiable { } func loadURL(_ urlString: String) { + lastAccessedAt = Date() let input = urlString.trimmingCharacters(in: .whitespacesAndNewlines) // 1) Try to construct a direct URL (has scheme or valid domain+TLD/IP) @@ -363,6 +384,7 @@ class Tab: ObservableObject, Identifiable { webView.configuration.userContentController.removeAllUserScripts() webView.removeFromSuperview() webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration()) + isWebViewReady = false } func setNavigationError(_ error: Error, for url: URL?) { diff --git a/ora/Modules/Browser/BrowserView.swift b/ora/Modules/Browser/BrowserView.swift index fa5dd566..87b8ee28 100644 --- a/ora/Modules/Browser/BrowserView.swift +++ b/ora/Modules/Browser/BrowserView.swift @@ -14,37 +14,42 @@ struct BrowserView: View { @State private var isMouseOverSidebar = false @StateObject private var sidebarFraction = FractionHolder.usingUserDefaults(0.2, key: "ui.sidebar.fraction") @StateObject private var sidebarVisibility = SideHolder.usingUserDefaults(key: "ui.sidebar.visibility") - + private func toggleSidebar() { withAnimation(.spring(response: 0.2, dampingFraction: 1.0)) { sidebarVisibility.toggle(.primary) } } - + private func toggleMaximizeWindow() { window?.toggleMaximized() } - + var body: some View { - ZStack(alignment: .leading) { - HSplit( - left: { - SidebarView(isFullscreen: isFullscreen) - }, - right: { - if tabManager.activeTab != nil { - BrowserContentContainer(isFullscreen: isFullscreen, hideState: sidebarVisibility) { - webView - } - } else { - // Start page (visible when no tab is active) - BrowserContentContainer(isFullscreen: isFullscreen, hideState: sidebarVisibility) { - HomeView(sidebarToggle: toggleSidebar) + let splitView = HSplit( + left: { + SidebarView(isFullscreen: isFullscreen) + }, + right: { + if tabManager.activeTab == nil { + // Start page (visible when no tab is active) + BrowserContentContainer(isFullscreen: isFullscreen, hideState: sidebarVisibility) { + HomeView(sidebarToggle: toggleSidebar) + } + } + ZStack { + let activeId = tabManager.activeTab?.id + ForEach(tabManager.tabsToRender) { tab in + if tab.isWebViewReady { + BrowserContentContainer(isFullscreen: isFullscreen, hideState: sidebarVisibility) { + webView(for: tab) + } + .opacity((activeId == tab.id) ? 1 : 0) } } } - ) - + } + ) .hide(sidebarVisibility) .splitter { Splitter.invisible() } .fraction(sidebarFraction) @@ -54,7 +59,7 @@ struct BrowserView: View { priority: .left, dragToHideP: true ) - // In autohide mode, remove any draggable splitter area to unhide + // In autohide mode, remove any draggable splitter area to unhide .styling(hideSplitter: true) .ignoresSafeArea(.all) .background(theme.subtleWindowBackgroundColor) @@ -77,12 +82,15 @@ struct BrowserView: View { if appState.showLauncher, tabManager.activeTab != nil { LauncherView() } - + if appState.isFloatingTabSwitchVisible { FloatingTabSwitcher() } } - + + ZStack(alignment: .leading) { + splitView + if sidebarVisibility.side == .primary { // Floating sidebar with resizable width based on persisted fraction GeometryReader { geo in @@ -101,9 +109,9 @@ struct BrowserView: View { Rectangle() .fill(Color.clear) .frame(width: 14) - #if targetEnvironment(macCatalyst) || os(macOS) +#if targetEnvironment(macCatalyst) || os(macOS) .cursor(NSCursor.resizeLeftRight) - #endif +#endif .contentShape(Rectangle()) .gesture( DragGesture() @@ -167,7 +175,7 @@ struct BrowserView: View { if let tab = newTab, !tab.isWebViewReady { DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { tab.restoreTransientState( - historyManger: historyManager, + historyManager: historyManager, downloadManager: downloadManager, tabManager: tabManager, isPrivate: privacyMode.isPrivate @@ -183,7 +191,7 @@ struct BrowserView: View { if let tab = tabManager.activeTab, !tab.isWebViewReady { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { tab.restoreTransientState( - historyManger: historyManager, + historyManager: historyManager, downloadManager: downloadManager, tabManager: tabManager, isPrivate: privacyMode.isPrivate @@ -192,9 +200,9 @@ struct BrowserView: View { } } } - + @ViewBuilder - private var webView: some View { + private func webView(for tab: Tab) -> some View { VStack(alignment: .leading, spacing: 0) { if !appState.isToolbarHidden { URLBar( @@ -205,47 +213,37 @@ struct BrowserView: View { removal: .push(from: .bottom) )) } - if let tab = tabManager.activeTab { - if tab.isWebViewReady { - if tab.hasNavigationError, let error = tab.navigationError { - StatusPageView( - error: error, - failedURL: tab.failedURL, - onRetry: { - tab.retryNavigation() - }, - onGoBack: tab.webView.canGoBack - ? { - tab.webView.goBack() - tab.clearNavigationError() - } : nil - ) - .id(tab.id) - } else { - ZStack(alignment: .topTrailing) { - WebView(webView: tab.webView) - .id(tab.id) - - if appState.showFinderIn == tab.id { - FindView(webView: tab.webView) - .padding(.top, 16) - .padding(.trailing, 16) - .zIndex(1000) - } - - if let hovered = tab.hoveredLinkURL, !hovered.isEmpty { - LinkPreview(text: hovered) - } + if tab.isWebViewReady { + if tab.hasNavigationError, let error = tab.navigationError { + StatusPageView( + error: error, + failedURL: tab.failedURL, + onRetry: { + tab.retryNavigation() + }, + onGoBack: tab.webView.canGoBack + ? { + tab.webView.goBack() + tab.clearNavigationError() + } : nil + ) + .id(tab.id) + } else { + ZStack(alignment: .topTrailing) { + WebView(webView: tab.webView) + .id(tab.id) + + if appState.showFinderIn == tab.id { + FindView(webView: tab.webView) + .padding(.top, 16) + .padding(.trailing, 16) + .zIndex(1000) + } + + if let hovered = tab.hoveredLinkURL, !hovered.isEmpty { + LinkPreview(text: hovered) } } - } else { - ZStack { - Rectangle() - .fill(theme.background) - - ProgressView().frame(width: 32, height: 32) - - }.frame(maxWidth: .infinity, maxHeight: .infinity) } } } diff --git a/ora/Modules/Launcher/Main/LauncherMain.swift b/ora/Modules/Launcher/Main/LauncherMain.swift index bb98694f..c9ce1638 100644 --- a/ora/Modules/Launcher/Main/LauncherMain.swift +++ b/ora/Modules/Launcher/Main/LauncherMain.swift @@ -148,7 +148,7 @@ struct LauncherMain: View { action: { if !tab.isWebViewReady { tab.restoreTransientState( - historyManger: historyManager, + historyManager: historyManager, downloadManager: downloadManager, tabManager: tabManager, isPrivate: privacyMode.isPrivate diff --git a/ora/Modules/Settings/Sections/GeneralSettingsView.swift b/ora/Modules/Settings/Sections/GeneralSettingsView.swift index 8b6c9770..4277fd35 100644 --- a/ora/Modules/Settings/Sections/GeneralSettingsView.swift +++ b/ora/Modules/Settings/Sections/GeneralSettingsView.swift @@ -7,7 +7,7 @@ struct GeneralSettingsView: View { @StateObject private var settings = SettingsStore.shared @StateObject private var defaultBrowserManager = DefaultBrowserManager.shared @Environment(\.theme) var theme - + var body: some View { SettingsContainer(maxContentWidth: 760) { Form { @@ -22,7 +22,7 @@ struct GeneralSettingsView: View { .font(.subheadline) .foregroundColor(.secondary) } - + Text("Fast, secure, and beautiful browser built for macOS") .font(.caption) .foregroundColor(.secondary) @@ -45,45 +45,98 @@ struct GeneralSettingsView: View { } AppearanceSelector(selection: $appearanceManager.appearance) + VStack(alignment: .leading, spacing: 12) { + Text("Tab Management") + .font(.headline) + + VStack(alignment: .leading, spacing: 8) { + Text("Automatically clean up old tabs to preserve memory.") + .font(.caption) + .foregroundColor(.secondary) + + HStack { + Text("Destroy web views after:") + Spacer() + Picker("", selection: $settings.tabAliveTimeout) { + Text("1 hour").tag(TimeInterval(60 * 60)) + Text("6 hours").tag(TimeInterval(6 * 60 * 60)) + Text("12 hours").tag(TimeInterval(12 * 60 * 60)) + Text("1 day").tag(TimeInterval(24 * 60 * 60)) + Text("2 days").tag(TimeInterval(2 * 24 * 60 * 60)) + Text("Never").tag(TimeInterval(365 * 24 * 60 * 60)) // Effectively never + } + .frame(width: 120) + } + + HStack { + Text("Remove tabs completely after:") + Spacer() + Picker("", selection: $settings.tabRemovalTimeout) { + Text("1 hour").tag(TimeInterval(60 * 60)) + Text("6 hours").tag(TimeInterval(6 * 60 * 60)) + Text("12 hours").tag(TimeInterval(12 * 60 * 60)) + Text("1 day").tag(TimeInterval(24 * 60 * 60)) + Text("2 days").tag(TimeInterval(2 * 24 * 60 * 60)) + Text("Never").tag(TimeInterval(365 * 24 * 60 * 60)) // Effectively never + } + .frame(width: 120) + } + + HStack { + Text("Maximum recent tabs to keep in view:") + Spacer() + Picker("", selection: $settings.maxRecentTabs) { + ForEach(1...10, id: \.self) { num in + Text("\(num)").tag(num) + } + } + .frame(width: 80) + } + Text("Note: Pinned and favorite tabs are never automatically removed.") + .font(.caption2) + .foregroundColor(.secondary) + } + } + .padding(.vertical, 8) VStack(alignment: .leading, spacing: 12) { Text("Updates") .font(.headline) - + Toggle("Automatically check for updates", isOn: $settings.autoUpdateEnabled) - + VStack(alignment: .leading, spacing: 8) { HStack { Button("Check for Updates") { updateService.checkForUpdates() } - + if updateService.isCheckingForUpdates { ProgressView() .scaleEffect(0.5) .frame(width: 16, height: 16) } - + if updateService.updateAvailable { Text("Update available!") .foregroundColor(.green) .font(.caption) } } - + if let result = updateService.lastCheckResult { Text(result) .font(.caption) .foregroundColor(updateService.updateAvailable ? .green : .secondary) } - + // Show current app version if let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String { Text("Current version: \(appVersion)") .font(.caption2) .foregroundColor(.secondary) } - + // Show last check time if let lastCheck = updateService.lastCheckDate { Text("Last checked: \(lastCheck.formatted(date: .abbreviated, time: .shortened))") @@ -92,7 +145,8 @@ struct GeneralSettingsView: View { } } } - .padding(.vertical, 8) + + } } } diff --git a/ora/Modules/TabSwitch/FloatingTabSwitcher.swift b/ora/Modules/TabSwitch/FloatingTabSwitcher.swift index 8a9bbac1..15665efc 100644 --- a/ora/Modules/TabSwitch/FloatingTabSwitcher.swift +++ b/ora/Modules/TabSwitch/FloatingTabSwitcher.swift @@ -172,7 +172,8 @@ struct FloatingTabSwitcher: View { isWebViewReady: tab.isWebViewReady, favicon: tab.favicon, faviconLocalFile: tab.faviconLocalFile, - textColor: theme.foreground + textColor: theme.foreground, + isPlayingMedia: tab.isPlayingMedia ) .frame(width: 16, height: 16) diff --git a/ora/OraCommands.swift b/ora/OraCommands.swift index 45a30612..d3573f87 100644 --- a/ora/OraCommands.swift +++ b/ora/OraCommands.swift @@ -174,6 +174,19 @@ struct OraCommands: Commands { Button("Toggle Toolbar") { NotificationCenter.default.post(name: .toggleToolbar, object: NSApp.keyWindow) } .keyboardShortcut(KeyboardShortcuts.App.toggleToolbar.keyboardShortcut) } + + CommandGroup(after: .windowList) { + Button("Close Window") { + if let keyWindow = NSApp.keyWindow, keyWindow.title == "Settings" { + keyWindow.performClose(nil) + } + } + .keyboardShortcut("w", modifiers: .command) + .disabled({ + guard let keyWindow = NSApp.keyWindow else { return true } + return keyWindow.title != "Settings" + }()) + } } private func showAboutWindow() { diff --git a/ora/Services/MediaController.swift b/ora/Services/MediaController.swift index a9aacf4b..52151b66 100644 --- a/ora/Services/MediaController.swift +++ b/ora/Services/MediaController.swift @@ -70,6 +70,8 @@ final class MediaController: ObservableObject { let idx = ensureSession() let playing = (event.state == "playing") sessions[idx].isPlaying = playing + // Update tab's isPlayingMedia property + tabRefs[tab.id]?.value?.isPlayingMedia = playing if let newTitle = event.title, !newTitle.isEmpty { sessions[idx].title = newTitle } if let vol = event.volume { sessions[idx].volume = clamp(vol) } // Update recency when it starts playing @@ -96,6 +98,8 @@ final class MediaController: ObservableObject { case "ended": if let idx = sessions.firstIndex(where: { $0.tabID == id }) { sessions[idx].isPlaying = false + // Update tab's isPlayingMedia property + tabRefs[tab.id]?.value?.isPlayingMedia = false } case "titleChange": @@ -155,6 +159,8 @@ final class MediaController: ObservableObject { if let idx = sessions.firstIndex(where: { $0.tabID == id }) { sessions.remove(at: idx) } + // Update tab's isPlayingMedia property + tabRefs[id]?.value?.isPlayingMedia = false tabRefs[id] = nil isVisible = !visibleSessions.isEmpty } diff --git a/ora/Services/SectionDropDelegate.swift b/ora/Services/SectionDropDelegate.swift index a258d8d2..e6a70639 100644 --- a/ora/Services/SectionDropDelegate.swift +++ b/ora/Services/SectionDropDelegate.swift @@ -24,9 +24,18 @@ struct SectionDropDelegate: DropDelegate { if self.items.isEmpty { // Section is empty, just change type and order - from.type = tabType(for: self.targetSection) + let newType = tabType(for: self.targetSection) + from.type = newType + // Update savedURL when moving into pinned/fav; clear when moving to normal + switch newType { + case .pinned, .fav: + from.savedURL = from.url + case .normal: + from.savedURL = nil + } let maxOrder = container.tabs.max(by: { $0.order < $1.order })?.order ?? 0 from.order = maxOrder + 1 + try? self.tabManager.modelContext.save() } // else if let to = self.items.last { // if isInSameSection(from: from, to: to) { diff --git a/ora/Services/TabManager.swift b/ora/Services/TabManager.swift index fabfb26d..f187c4c2 100644 --- a/ora/Services/TabManager.swift +++ b/ora/Services/TabManager.swift @@ -2,6 +2,7 @@ import SwiftData import SwiftUI import WebKit + // MARK: - Tab Manager @MainActor @@ -11,12 +12,26 @@ class TabManager: ObservableObject { let modelContainer: ModelContainer let modelContext: ModelContext let mediaController: MediaController - + + var recentTabs: [Tab] { + guard let container = activeContainer else { return [] } + return Array(container.tabs.sorted { ($0.lastAccessedAt ?? Date.distantPast) > ($1.lastAccessedAt ?? Date.distantPast) }.prefix(SettingsStore.shared.maxRecentTabs)) + } + + var tabsToRender: [Tab] { + guard let container = activeContainer else { return [] } + let specialTabs = container.tabs.filter { $0.type == .pinned || $0.type == .fav || $0.isPlayingMedia } + let combined = Set(recentTabs + specialTabs) + return Array(combined) + } + // Note: Could be made injectable via init parameter if preferred let tabSearchingService: TabSearchingProviding - + @Query(sort: \TabContainer.lastAccessedAt, order: .reverse) var containers: [TabContainer] - + + private var cleanupTimer: Timer? + init( modelContainer: ModelContainer, modelContext: ModelContext, @@ -27,13 +42,16 @@ class TabManager: ObservableObject { self.modelContext = modelContext self.mediaController = mediaController self.tabSearchingService = tabSearchingService - + self.modelContext.undoManager = UndoManager() initializeActiveContainerAndTab() + + // Start automatic cleanup timer (every minute) + startCleanupTimer() } - + // MARK: - Public API's - + func search(_ text: String) -> [Tab] { tabSearchingService.search( text, @@ -41,7 +59,7 @@ class TabManager: ObservableObject { modelContext: modelContext ) } - + func openFromEngine( engineName: SearchEngineID, query: String, @@ -55,14 +73,14 @@ class TabManager: ObservableObject { openTab(url: url, historyManager: historyManager, isPrivate: isPrivate) } } - + func isActive(_ tab: Tab) -> Bool { if let activeTab = self.activeTab { return activeTab.id == tab.id } return false } - + func togglePinTab(_ tab: Tab) { if tab.type == .pinned { tab.type = .normal @@ -71,10 +89,10 @@ class TabManager: ObservableObject { tab.type = .pinned tab.savedURL = tab.url } - + try? modelContext.save() } - + func toggleFavTab(_ tab: Tab) { if tab.type == .fav { tab.type = .normal @@ -83,12 +101,12 @@ class TabManager: ObservableObject { tab.type = .fav tab.savedURL = tab.url } - + try? modelContext.save() } - + // MARK: - Container Public API's - + func moveTabToContainer(_ tab: Tab, toContainer: TabContainer) { tab.container = toContainer try? modelContext.save() @@ -96,7 +114,7 @@ class TabManager: ObservableObject { private func initializeActiveContainerAndTab() { // Ensure containers are fetched let containers = fetchContainers() - + // Get the last accessed container if let lastAccessedContainer = containers.first { activeContainer = lastAccessedContainer @@ -113,10 +131,10 @@ class TabManager: ObservableObject { activeContainer = newContainer } } - + @discardableResult func createContainer(name: String = "Default", emoji: String = "β€’") -> TabContainer { - + let newContainer = TabContainer(name: name, emoji: emoji) modelContext.insert(newContainer) activeContainer = newContainer @@ -125,25 +143,25 @@ class TabManager: ObservableObject { // _ = fetchContainers() // Refresh containers return newContainer } - + func renameContainer(_ container: TabContainer, name: String, emoji: String) { container.name = name container.emoji = emoji try? modelContext.save() } - + func deleteContainer(_ container: TabContainer) { modelContext.delete(container) } - + func activateContainer(_ container: TabContainer, activateLastAccessedTab: Bool = true) { activeContainer = container container.lastAccessedAt = Date() - + // Set the most recently accessed tab in the container if let lastAccessedTab = container.tabs .sorted(by: { $0.lastAccessedAt ?? Date() > $1.lastAccessedAt ?? Date() }).first, - lastAccessedTab.isWebViewReady + lastAccessedTab.isWebViewReady { activeTab?.maybeIsActive = false activeTab = lastAccessedTab @@ -152,12 +170,12 @@ class TabManager: ObservableObject { } else { activeTab = nil } - + try? modelContext.save() } - + // MARK: - Tab Public API's - + func addTab( title: String = "Untitled", /// Will Always Work @@ -192,10 +210,10 @@ class TabManager: ObservableObject { activeTab?.maybeIsActive = true newTab.lastAccessedAt = Date() container.lastAccessedAt = Date() - + // Initialize the WebView for the new active tab newTab.restoreTransientState( - historyManger: historyManager ?? HistoryManager(modelContainer: modelContainer, modelContext: modelContext), + historyManager: historyManager ?? HistoryManager(modelContainer: modelContainer, modelContext: modelContext), downloadManager: downloadManager ?? DownloadManager( modelContainer: modelContainer, modelContext: modelContext @@ -203,11 +221,11 @@ class TabManager: ObservableObject { tabManager: self, isPrivate: isPrivate ) - + try? modelContext.save() return newTab } - + func openTab( url: URL, historyManager: HistoryManager, @@ -218,9 +236,9 @@ class TabManager: ObservableObject { if let container = activeContainer { if let host = url.host { let faviconURL = URL(string: "https://www.google.com/s2/favicons?domain=\(host)") - + let cleanHost = host.hasPrefix("www.") ? String(host.dropFirst(4)) : host - + let newTab = Tab( url: url, title: cleanHost, @@ -236,16 +254,16 @@ class TabManager: ObservableObject { ) modelContext.insert(newTab) container.tabs.append(newTab) - + if focusAfterOpening { activeTab?.maybeIsActive = false activeTab = newTab activeTab?.maybeIsActive = true newTab.lastAccessedAt = Date() - + // Initialize the WebView for the new active tab newTab.restoreTransientState( - historyManger: historyManager, + historyManager: historyManager, downloadManager: downloadManager ?? DownloadManager( modelContainer: modelContainer, modelContext: modelContext @@ -254,23 +272,23 @@ class TabManager: ObservableObject { isPrivate: isPrivate ) } - + container.lastAccessedAt = Date() try? modelContext.save() } } } - + func reorderTabs(from: Tab, toTab: Tab) { from.container.reorderTabs(from: from, to: toTab) try? modelContext.save() } - + func switchSections(from: Tab, toTab: Tab) { from.switchSections(from: from, to: toTab) try? modelContext.save() } - + func closeTab(tab: Tab) { // If the closed tab was active, select another tab if self.activeTab?.id == tab.id { @@ -280,7 +298,7 @@ class TabManager: ObservableObject { .first { self.activateTab(nextTab) - + // } else if let nextContainer = containers.first(where: { $0.id != tab.container.id }) { // self.activateContainer(nextContainer) // @@ -295,7 +313,7 @@ class TabManager: ObservableObject { { activeTab? .restoreTransientState( - historyManger: historyManager, + historyManager: historyManager, downloadManager: downloadManager, tabManager: tabManager, isPrivate: tab.isPrivate @@ -315,7 +333,7 @@ class TabManager: ObservableObject { } self.activeTab?.maybeIsActive = true } - + func closeActiveTab() { if let tab = activeTab { closeTab(tab: tab) @@ -323,13 +341,13 @@ class TabManager: ObservableObject { NSApp.keyWindow?.close() } } - + func restoreLastTab() { guard let undoManager = modelContext.undoManager else { return } undoManager.undo() // Reverts the last deletion try? modelContext.save() // Persist the undo operation } - + func activateTab(_ tab: Tab) { activeTab?.maybeIsActive = false activeTab = tab @@ -337,12 +355,71 @@ class TabManager: ObservableObject { tab.lastAccessedAt = Date() activeContainer = tab.container tab.container.lastAccessedAt = Date() + + // Lazy load WebView if not ready + if !tab.isWebViewReady { + tab.restoreTransientState( + historyManager: tab.historyManager ?? HistoryManager(modelContainer: modelContainer, modelContext: modelContext), + downloadManager: tab.downloadManager ?? DownloadManager(modelContainer: modelContainer, modelContext: modelContext), + tabManager: self, + isPrivate: tab.isPrivate + ) + } tab.updateHeaderColor() try? modelContext.save() // Note: Controller API has no setActive; skipping explicit activation. } - + + /// Clean up old tabs that haven't been accessed recently to preserve memory + func cleanupOldTabs() { + let timeout = SettingsStore.shared.tabAliveTimeout + // Skip cleanup if set to "Never" (365 days) + guard timeout < 365 * 24 * 60 * 60 else { return } + + let allContainers = fetchContainers() + for container in allContainers { + for tab in container.tabs { + if !tab.isAlive && tab.isWebViewReady && tab.id != activeTab?.id && !tab.isPlayingMedia && tab.type == .normal { + tab.destroyWebView() + } + } + } + } + + /// Completely remove old normal tabs that haven't been accessed for a long time + func removeOldTabs() { + let cutoffDate = Date().addingTimeInterval(-SettingsStore.shared.tabRemovalTimeout) + let allContainers = fetchContainers() + + for container in allContainers { + for tab in container.tabs { + if let lastAccessed = tab.lastAccessedAt, + lastAccessed < cutoffDate, + tab.id != activeTab?.id, + !tab.isPlayingMedia, + tab.type == .normal { + closeTab(tab: tab) + } + } + } + } + + + /// Start the automatic cleanup timer + private func startCleanupTimer() { + cleanupTimer = Timer.scheduledTimer(withTimeInterval: 60.0, repeats: true) { [weak self] _ in + DispatchQueue.main.async { + self?.cleanupOldTabs() + self?.removeOldTabs() + } + } + } + + deinit { + cleanupTimer?.invalidate() + } + // Activate a tab by its persistent id. If the tab is in a // different container, also activate that container. func activateTab(id: UUID) { @@ -355,38 +432,38 @@ class TabManager: ObservableObject { } } } - - + + func selectTabAtIndex(_ index: Int) { guard let container = activeContainer else { return } - + // Match the sidebar ordering: favorites, then pinned, then normal tabs // All sorted by order in descending order let favoriteTabs = container.tabs .filter { $0.type == .fav } .sorted(by: { $0.order > $1.order }) - + let pinnedTabs = container.tabs .filter { $0.type == .pinned } .sorted(by: { $0.order > $1.order }) - + let normalTabs = container.tabs .filter { $0.type == .normal } .sorted(by: { $0.order > $1.order }) - + // Combine all tabs in the same order as the sidebar let allTabs = favoriteTabs + pinnedTabs + normalTabs - + // Handle special case: Command+9 selects the last tab let targetIndex = (index == 9) ? allTabs.count - 1 : index - 1 - + // Validate index is within bounds guard targetIndex >= 0, targetIndex < allTabs.count else { return } - + let targetTab = allTabs[targetIndex] activateTab(targetTab) } - + private func fetchContainers() -> [TabContainer] { do { let descriptor = FetchDescriptor(sortBy: [SortDescriptor(\.lastAccessedAt, order: .reverse)]) @@ -396,7 +473,7 @@ class TabManager: ObservableObject { } return [] } - + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { if message.name == "listener", let url = message.body as? String @@ -435,7 +512,7 @@ final class TabSearchingService: TabSearchingProviding { ) -> [Tab] { let activeContainerId = activeContainer?.id ?? UUID() let trimmedText = text.trimmingCharacters(in: .whitespaces) - + let predicate: Predicate if trimmedText.isEmpty { predicate = #Predicate { _ in true } @@ -443,49 +520,49 @@ final class TabSearchingService: TabSearchingProviding { predicate = #Predicate { tab in ( tab.urlString.localizedStandardContains(trimmedText) || - tab.title + tab.title .localizedStandardContains( trimmedText ) ) && tab.container.id == activeContainerId } } - + let descriptor = FetchDescriptor(predicate: predicate) - + do { let results = try modelContext.fetch(descriptor) let now = Date() - + return results.sorted { result1, result2 in let result1Score = combinedScore(for: result1, query: trimmedText, now: now) let result2Score = combinedScore(for: result2, query: trimmedText, now: now) return result1Score > result2Score } - + } catch { return [] } } - + private func combinedScore(for tab: Tab, query: String, now: Date) -> Double { let match = scoreMatch(tab, text: query) - + let timeInterval: TimeInterval = if let accessedAt = tab.lastAccessedAt { now.timeIntervalSince(accessedAt) } else { 1_000_000 // far in the past β†’ lowest recency } - + let recencyBoost = max(0, 1_000_000 - timeInterval) return Double(match * 1000) + recencyBoost } - + private func scoreMatch(_ tab: Tab, text: String) -> Int { let text = text.lowercased() let title = tab.title.lowercased() let url = tab.urlString.lowercased() - + func score(_ field: String) -> Int { if field == text { return 100 } if field.hasPrefix(text) { return 90 } @@ -493,7 +570,7 @@ final class TabSearchingService: TabSearchingProviding { if text.contains(field) { return 50 } return 0 } - + return max(score(title), score(url)) } } diff --git a/ora/UI/FavTabItem.swift b/ora/UI/FavTabItem.swift index 86a0f501..6d91cf01 100644 --- a/ora/UI/FavTabItem.swift +++ b/ora/UI/FavTabItem.swift @@ -42,13 +42,33 @@ struct FavTabItem: View { textColor: Color(.white) ) } + + if tab.isPlayingMedia { + VStack { + Spacer() + HStack { + Spacer() + Image(systemName: "speaker.wave.2.fill") + .resizable() + .scaledToFit() + .frame(width: 8, height: 8) + .foregroundColor(.white.opacity(0.9)) + .background( + Circle() + .fill(Color.black.opacity(0.6)) + .frame(width: 12, height: 12) + ) + } + } + .padding(2) + } } .onTapGesture { onTap() if !tab.isWebViewReady { tab .restoreTransientState( - historyManger: historyManager, + historyManager: historyManager, downloadManager: downloadManager, tabManager: tabManager, isPrivate: privacyMode.isPrivate @@ -59,7 +79,7 @@ struct FavTabItem: View { if tabManager.isActive(tab) { tab .restoreTransientState( - historyManger: historyManager, + historyManager: historyManager, downloadManager: downloadManager, tabManager: tabManager, isPrivate: privacyMode.isPrivate @@ -92,7 +112,7 @@ struct FavTabItem: View { if !tab.isWebViewReady { tab .restoreTransientState( - historyManger: historyManager, + historyManager: historyManager, downloadManager: downloadManager, tabManager: tabManager, isPrivate: privacyMode.isPrivate diff --git a/ora/UI/TabItem.swift b/ora/UI/TabItem.swift index 68317602..564e42ce 100644 --- a/ora/UI/TabItem.swift +++ b/ora/UI/TabItem.swift @@ -45,9 +45,10 @@ struct FavIcon: View { let favicon: URL? let faviconLocalFile: URL? let textColor: Color + var isPlayingMedia: Bool = false var body: some View { - HStack { + HStack(spacing: 4) { if let favicon, isWebViewReady { AsyncImage( url: favicon @@ -68,8 +69,16 @@ struct FavIcon: View { textColor: textColor ) } + + if isPlayingMedia { + Image(systemName: "speaker.wave.2.fill") + .resizable() + .scaledToFit() + .frame(width: 8, height: 8) + .foregroundColor(textColor.opacity(0.8)) + } } - .frame(width: 16, height: 16) + .frame(width: isPlayingMedia ? 28 : 16, height: 16) } } @@ -97,7 +106,8 @@ struct TabItem: View { isWebViewReady: tab.isWebViewReady, favicon: tab.favicon, faviconLocalFile: tab.faviconLocalFile, - textColor: textColor + textColor: textColor, + isPlayingMedia: tab.isPlayingMedia ) tabTitle Spacer() @@ -107,7 +117,7 @@ struct TabItem: View { if tabManager.isActive(tab) { tab .restoreTransientState( - historyManger: historyManager, + historyManager: historyManager, downloadManager: downloadManager, tabManager: tabManager, isPrivate: privacyMode.isPrivate @@ -120,7 +130,7 @@ struct TabItem: View { if !tab.isWebViewReady { tab .restoreTransientState( - historyManger: historyManager, + historyManager: historyManager, downloadManager: downloadManager, tabManager: tabManager, isPrivate: privacyMode.isPrivate @@ -147,7 +157,7 @@ struct TabItem: View { if !tab.isWebViewReady { tab .restoreTransientState( - historyManger: historyManager, + historyManager: historyManager, downloadManager: downloadManager, tabManager: tabManager, isPrivate: privacyMode.isPrivate From 205d70dc1c7b2fc81a14c50328d12f2460059e2b Mon Sep 17 00:00:00 2001 From: Kenenisa Alemayehu <53818162+kenenisa@users.noreply.github.com> Date: Mon, 6 Oct 2025 23:15:42 +0300 Subject: [PATCH 26/38] Update to v0.2.4 --- appcast.xml | 47 +++++++++++++++++++++++------------------------ project.yml | 4 ++-- 2 files changed, 25 insertions(+), 26 deletions(-) diff --git a/appcast.xml b/appcast.xml index c03c07ef..ab8621c5 100644 --- a/appcast.xml +++ b/appcast.xml @@ -5,45 +5,44 @@ Most recent changes with links to updates. en - Version 0.2.3 + Version 0.2.4 Ora Browser v0.2.3 +

Ora Browser v0.2.4

Changes since last release:

Features

    -
  • feat: add JavaScript alert, confirm, and prompt handling (#108) β€” Kenenisa Alemayehu
  • +
  • feat: add URL handling and deep linking support (#132) β€” Kenenisa Alemayehu
  • +
  • feat: double click full resize of window (#128) β€” Chase
  • +
  • feat: add ability to edit container name and emoji (#125) β€” Chase

Fixes

    -
  • fix: prevent appearance update crash when NSApp is nil (#115) β€” Kenenisa Alemayehu
  • -
  • fix(tabs): add keyboard shortcuts for tab selection and notification handling (#106) β€” Furkan Koseoglu
  • -
  • fix: closing tab animation (#53) β€” Roman Potapov
  • -
  • fix(sidebar): fixes sidebar behavior with downloads widgets when side… (#100) β€” Furkan Koseoglu
  • -
  • fix: mini player spacing on sidebar β€” yonaries
  • -
  • fix: min and max launcher width β€” yonaries
  • -
-

Chores

-
    -
  • chore: swiftlint β€” yonaries
  • -
  • chore: skip version bump commits in release script β€” Kenenisa Alemayehu
  • -
  • chore: update .gitignore to exclude backup files and remove obsolete appcast.xml.backup β€” Kenenisa Alemayehu
  • +
  • fix: settings model container and spaces UI (#127) β€” Chase
  • +
  • fix: move to container shows proper containers now (#126) β€” Chase
  • +
  • fix(sidebar): update sidebar visibility persistance (#124) β€” Furkan Koseoglu
  • +
  • fix: update user agent string in TabScriptHandler for compatibility β€” Kenenisa Alemayehu

Other

    -
  • Add custom keyboard shortcut support + update UI (#89) β€” Joe McLaughlin
  • -
  • Add horizontal padding to search bar in LauncherMain SwiftUI view (#110) β€” FormalSnake
  • -
  • Update Wiki link in README.md (#104) β€” Aether
  • +
  • Enhance Tab Management Settings and UI (#139) β€” Kenenisa Alemayehu
  • +
  • Update macOS and License badge in README β€” Yonathan Dejene
  • +
  • Change license from MIT to GPL-2.0 β€” Kenenisa Alemayehu
  • +
  • Delete LICENSE.md β€” Kenenisa Alemayehu
  • +
  • Add GNU General Public License v2 β€” Kenenisa Alemayehu
  • +
  • Prevent Window Drag Interference During Tab Drags and Refactor TabManager for Modularity (#101) β€” AryanRogye
  • +
  • ux: Cleanup search engines implementation (#121) β€” versecafe
  • +
  • ux: Improve page finder (#122) β€” versecafe
]]>
- Sat, 20 Sep 2025 08:43:15 +0000 - Mon, 06 Oct 2025 20:11:13 +0000 + + sparkle:edSignature="NCBtkaA9uR347Y+dHHabtdPmfgRQ+HEQiDR7P2oBDLLK1IEX9fo8vGo2PV/tnPszZD+mCD4V3+mRc0IBYpz8Aw=="/>
diff --git a/project.yml b/project.yml index 6c0397c9..4f2541c9 100644 --- a/project.yml +++ b/project.yml @@ -96,8 +96,8 @@ targets: base: SWIFT_VERSION: 5.9 CODE_SIGN_STYLE: Automatic - MARKETING_VERSION: 0.2.3 - CURRENT_PROJECT_VERSION: 91 + MARKETING_VERSION: 0.2.4 + CURRENT_PROJECT_VERSION: 92 PRODUCT_NAME: Ora PRODUCT_BUNDLE_IDENTIFIER: com.orabrowser.app GENERATE_INFOPLIST_FILE: YES From 214f551916b0dab9779b99ccd44b60c0c3f46654 Mon Sep 17 00:00:00 2001 From: Yonathan Dejene Date: Wed, 8 Oct 2025 23:08:59 +0300 Subject: [PATCH 27/38] chore: update discord link From 521433cb116061545ad91556fdd59945a65e0d86 Mon Sep 17 00:00:00 2001 From: Yonathan Dejene Date: Mon, 13 Oct 2025 10:09:53 +0300 Subject: [PATCH 28/38] Add Left/Right Sidebar Positioning, Floating URL Bar, and Toolbar Enhancements (#133) * feat: left and right sidebar positioning * fix: sidebar position swapping * fix: drag splitter to hide sidebar * fix: webpage issue in sidebar toggle * fix: ignore safe area in NSPageView * refactor: browser view to multiple file * feat: floating sidebar on right * fix: proper fraction inverstion * refactor: global mouse tracking area * refactor: clear state naming and radius for macos 26 * fix: bind error * fix: typo * fix: logger address * fix: double click to max window to sidebar * feat: custom window control added * fix: reduce window control button size * fix: conditional window control button size * feat: floating URLbar and move sidebarPosition to AppState * refactor: created sidebar manager and apply changes * fix: fullscreen window control button * improve: added a sidebar manager with persistant state * fix: update button label for sidebar position toggle * fix: improve trigger are for urlbar * refactor: added a sidebar toolbar * refactor: added a toolbar manager * fix: persistant toolbar state * update toolbar command buttons label * improve: ora commands * improve: appearance manager using appstorage now --- ora/Common/Constants/AppEvents.swift | 1 + ora/Common/Constants/KeyboardShortcuts.swift | 15 ++ .../GlobalMouseTrackingArea.swift | 214 ++++++++++++++++ .../Representables/MouseTrackingArea.swift | 169 ------------- .../Representables/WindowAccessor.swift | 51 +--- ora/Common/Utils/WindowFactory.swift | 3 - ora/Models/Tab.swift | 3 +- .../Browser/BrowserContentContainer.swift | 38 +-- ora/Modules/Browser/BrowserSplitView.swift | 98 ++++++++ ora/Modules/Browser/BrowserView.swift | 238 +++--------------- .../Browser/BrowserWebContentView.swift | 66 +++++ .../Browser/FloatingSidebarOverlay.swift | 117 +++++++++ ora/Modules/Find/FindView.swift | 1 + ora/Modules/Importer/ImportDataButton.swift | 6 +- ora/Modules/Launcher/LauncherView.swift | 1 + ora/Modules/Launcher/Main/LauncherMain.swift | 1 + .../Suggestions/LauncherSuggestionItem.swift | 1 + .../Sections/GeneralSettingsView.swift | 35 ++- .../Sections/SpacesSettingsView.swift | 4 +- ora/Modules/Sidebar/ContainerView.swift | 3 +- ora/Modules/Sidebar/FloatingSidebar.swift | 11 +- ora/Modules/Sidebar/SidebarToolbar.swift | 96 +++++++ ora/Modules/Sidebar/SidebarURLDisplay.swift | 5 +- ora/Modules/Sidebar/SidebarView.swift | 36 ++- ora/Modules/SplitView/Split.swift | 2 +- ora/Modules/SplitView/SplitHolders.swift | 14 ++ ora/Modules/URLBar/FloatingURLBar.swift | 63 +++++ ora/OraCommands.swift | 226 ++++++++--------- ora/OraRoot.swift | 21 +- ora/Services/AppearanceManager.swift | 9 +- ora/Services/DefaultBrowserManager.swift | 6 +- ora/Services/SidebarManager.swift | 40 +++ ora/Services/TabManager.swift | 176 +++++++------ ora/Services/ToolbarManager.swift | 7 + ora/UI/Buttons/URLBarButton.swift | 2 +- ora/UI/EmptyFavTabItem.swift | 6 +- ora/UI/HomeView.swift | 22 +- ora/UI/NSPageView.swift | 16 +- ora/UI/URLBar.swift | 38 ++- ora/UI/WindowControls.swift | 71 ++++++ ora/WindowControls.xcassets/Contents.json | 6 + .../close-hover.imageset/Close Hover Icon.svg | 15 ++ .../close-hover.imageset/Contents.json | 12 + .../close-normal.imageset/Contents.json | 12 + .../close-normal.imageset/close-normal.svg | 1 + .../maximize-hover.imageset/Contents.json | 12 + .../maximize-hover.svg | 12 + .../maximize-normal.imageset/Contents.json | 12 + .../maximize-normal.svg | 1 + .../minimize-hover.imageset/Contents.json | 12 + .../minimize-hover.svg | 12 + .../minimize-normal.imageset/Contents.json | 12 + .../minimize-normal.svg | 1 + .../no-focus.imageset/Contents.json | 12 + .../no-focus.imageset/no-focus.svg | 1 + ora/oraApp.swift | 17 +- 56 files changed, 1349 insertions(+), 733 deletions(-) create mode 100644 ora/Common/Representables/GlobalMouseTrackingArea.swift delete mode 100644 ora/Common/Representables/MouseTrackingArea.swift create mode 100644 ora/Modules/Browser/BrowserSplitView.swift create mode 100644 ora/Modules/Browser/BrowserWebContentView.swift create mode 100644 ora/Modules/Browser/FloatingSidebarOverlay.swift create mode 100644 ora/Modules/Sidebar/SidebarToolbar.swift create mode 100644 ora/Modules/URLBar/FloatingURLBar.swift create mode 100644 ora/Services/SidebarManager.swift create mode 100644 ora/Services/ToolbarManager.swift create mode 100644 ora/UI/WindowControls.swift create mode 100644 ora/WindowControls.xcassets/Contents.json create mode 100644 ora/WindowControls.xcassets/close-hover.imageset/Close Hover Icon.svg create mode 100644 ora/WindowControls.xcassets/close-hover.imageset/Contents.json create mode 100644 ora/WindowControls.xcassets/close-normal.imageset/Contents.json create mode 100644 ora/WindowControls.xcassets/close-normal.imageset/close-normal.svg create mode 100644 ora/WindowControls.xcassets/maximize-hover.imageset/Contents.json create mode 100644 ora/WindowControls.xcassets/maximize-hover.imageset/maximize-hover.svg create mode 100644 ora/WindowControls.xcassets/maximize-normal.imageset/Contents.json create mode 100644 ora/WindowControls.xcassets/maximize-normal.imageset/maximize-normal.svg create mode 100644 ora/WindowControls.xcassets/minimize-hover.imageset/Contents.json create mode 100644 ora/WindowControls.xcassets/minimize-hover.imageset/minimize-hover.svg create mode 100644 ora/WindowControls.xcassets/minimize-normal.imageset/Contents.json create mode 100644 ora/WindowControls.xcassets/minimize-normal.imageset/minimize-normal.svg create mode 100644 ora/WindowControls.xcassets/no-focus.imageset/Contents.json create mode 100644 ora/WindowControls.xcassets/no-focus.imageset/no-focus.svg diff --git a/ora/Common/Constants/AppEvents.swift b/ora/Common/Constants/AppEvents.swift index 5195393b..939a40b9 100644 --- a/ora/Common/Constants/AppEvents.swift +++ b/ora/Common/Constants/AppEvents.swift @@ -2,6 +2,7 @@ import Foundation extension Notification.Name { static let toggleSidebar = Notification.Name("ToggleSidebar") + static let toggleSidebarPosition = Notification.Name("ToggleSidebarPosition") static let copyAddressURL = Notification.Name("CopyAddressURL") static let showLauncher = Notification.Name("ShowLauncher") diff --git a/ora/Common/Constants/KeyboardShortcuts.swift b/ora/Common/Constants/KeyboardShortcuts.swift index 2f1a12e2..2394668f 100644 --- a/ora/Common/Constants/KeyboardShortcuts.swift +++ b/ora/Common/Constants/KeyboardShortcuts.swift @@ -113,6 +113,21 @@ enum KeyboardShortcuts { category: "Tabs", defaultChord: KeyChord(keyEquivalent: .init("9"), modifiers: [.command]) ) + + static func keyboardShortcut(for index: Int) -> KeyboardShortcut { + switch index { + case 1: return tab1.keyboardShortcut + case 2: return tab2.keyboardShortcut + case 3: return tab3.keyboardShortcut + case 4: return tab4.keyboardShortcut + case 5: return tab5.keyboardShortcut + case 6: return tab6.keyboardShortcut + case 7: return tab7.keyboardShortcut + case 8: return tab8.keyboardShortcut + case 9: return tab9.keyboardShortcut + default: return tab1.keyboardShortcut + } + } } // MARK: - Navigation diff --git a/ora/Common/Representables/GlobalMouseTrackingArea.swift b/ora/Common/Representables/GlobalMouseTrackingArea.swift new file mode 100644 index 00000000..3a3e7e0a --- /dev/null +++ b/ora/Common/Representables/GlobalMouseTrackingArea.swift @@ -0,0 +1,214 @@ +import SwiftUI + +enum TrackingEdge { + case left + case right + case top + case bottom +} + +struct GlobalMouseTrackingArea: NSViewRepresentable { + @Binding var mouseEntered: Bool + let edge: TrackingEdge + let padding: CGFloat + let slack: CGFloat + + init( + mouseEntered: Binding, + edge: TrackingEdge, + padding: CGFloat = 40, + slack: CGFloat = 8 + ) { + self._mouseEntered = mouseEntered + self.edge = edge + self.padding = padding + self.slack = slack + } + + func makeNSView(context: Context) -> NSView { + let view = GlobalTrackingStrip(edge: edge, padding: padding, slack: slack) + + view.onHoverChange = { hovering in + self.mouseEntered = hovering + } + + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + if let strip = nsView as? GlobalTrackingStrip { + strip.edge = edge + strip.padding = padding + strip.slack = slack + } + } +} + +private final class GlobalTrackingStrip: NSView { + var edge: TrackingEdge + var padding: CGFloat + var slack: CGFloat + + #if DEBUG + private var debugWindow: NSWindow? + func showDebugOverlay(for screenRect: NSRect) { + debugWindow?.orderOut(nil) + debugWindow = nil + + let win = NSWindow( + contentRect: screenRect, + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + win.isOpaque = false + win.backgroundColor = .clear + win.hasShadow = false + win.level = .statusBar + + let overlay = NSView(frame: win.contentView!.bounds) + overlay.wantsLayer = true + overlay.layer?.backgroundColor = NSColor.systemBlue.withAlphaComponent(0.2).cgColor + overlay.layer?.borderColor = NSColor.systemBlue.cgColor + overlay.layer?.borderWidth = 2 + win.contentView?.addSubview(overlay) + + win.orderFrontRegardless() + debugWindow = win + } + #endif + + var onHoverChange: ((Bool) -> Void)? + private var hoverTracker: GlobalHoverTracker? + + init(edge: TrackingEdge, padding: CGFloat = 40, slack: CGFloat = 8) { + self.edge = edge + self.padding = padding + self.slack = slack + super.init(frame: .zero) + self.hoverTracker = GlobalHoverTracker(view: self) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + override func viewWillMove(toWindow newWindow: NSWindow?) { + if newWindow == nil { hoverTracker?.stop() } + super.viewWillMove(toWindow: newWindow) + } + + deinit { + hoverTracker?.stop() + } + + override func viewDidMoveToWindow() { + if window == nil { + hoverTracker?.stop() + } else { + hoverTracker?.startTracking { [weak self] inside in + guard let self else { return } + self.onHoverChange?(inside) + } + } + super.viewDidMoveToWindow() + } +} + +private class GlobalHoverTracker { + typealias LocalMonitorToken = Any + + private var localMonitor: LocalMonitorToken? + private var armed = false + private var isInside = false + + weak var view: GlobalTrackingStrip? + + init(view: GlobalTrackingStrip? = nil) { + self.view = view + } + + func startTracking(completion: @escaping (Bool) -> Void) { + guard localMonitor == nil else { return } + + var handler: ((NSEvent) -> Void)! + + handler = { [weak self] _ in + guard let self else { return } + guard let view = self.view else { return } + guard let window = view.window else { return } + + let mouse = NSEvent.mouseLocation + let screenRect = window.convertToScreen( + view.convert(view.bounds, to: nil) + ) + + let basePadding: CGFloat = armed ? view.padding : 0 + let offset: CGFloat = -1 + + // Create extended band based on edge + let band = switch view.edge { + case .left: + NSRect( + x: screenRect.minX - offset - basePadding, + y: screenRect.minY - view.slack, + width: basePadding, + height: screenRect.height + 2 * view.slack + ) + case .right: + NSRect( + x: screenRect.maxX + offset, + y: screenRect.minY - view.slack, + width: basePadding, + height: screenRect.height + 2 * view.slack + ) + case .top: + NSRect( + x: screenRect.minX - view.slack, + y: screenRect.maxY + offset, + width: screenRect.width + 2 * view.slack, + height: basePadding + ) + case .bottom: + NSRect( + x: screenRect.minX - view.slack, + y: screenRect.minY - offset - basePadding, + width: screenRect.width + 2 * view.slack, + height: basePadding + ) + } + + let insideBase = screenRect.contains(mouse) + let inBand = band.contains(mouse) + let effective = insideBase || inBand + +// #if DEBUG +// DispatchQueue.main.async { +// view.showDebugOverlay(for: band) +// } +// #endif + + if effective != isInside { + isInside = effective + armed = effective + if Thread.isMainThread { + completion(effective) + } else { + DispatchQueue.main.async { completion(effective) } + } + } + } + + localMonitor = NSEvent.addLocalMonitorForEvents( + matching: [.mouseMoved] + ) { event in + handler(event) + return event + } + } + + func stop() { + if let local = localMonitor { NSEvent.removeMonitor(local) } + localMonitor = nil + isInside = false + } +} diff --git a/ora/Common/Representables/MouseTrackingArea.swift b/ora/Common/Representables/MouseTrackingArea.swift deleted file mode 100644 index f7101d3b..00000000 --- a/ora/Common/Representables/MouseTrackingArea.swift +++ /dev/null @@ -1,169 +0,0 @@ -import SwiftUI - -struct MouseTrackingArea: NSViewRepresentable { - @Binding var mouseEntered: Bool - - func makeNSView(context: Context) -> NSView { - let view = TrackingStrip() - - /// No Need To Pass We Can handle it nicely with closures - view.onHoverChange = { hovering in - self.mouseEntered = hovering - } - - return view - } - - func updateNSView(_ nsView: NSView, context: Context) {} -} - -private final class TrackingStrip: NSView { - #if DEBUG - private var debugWindow: NSWindow? - func showDebugOverlay(for screenRect: NSRect) { - debugWindow?.orderOut(nil) - debugWindow = nil - - /// Make a borderless, transparent window at the rect - let win = NSWindow( - contentRect: screenRect, - styleMask: [.borderless], - backing: .buffered, - defer: false - ) - win.isOpaque = false - win.backgroundColor = .clear - win.hasShadow = false - win.level = .statusBar - - /// colored view - if let contentView = win.contentView { - let overlay = NSView(frame: contentView.bounds) - overlay.wantsLayer = true - overlay.layer?.backgroundColor = NSColor.systemGreen.withAlphaComponent(0.2).cgColor - overlay.layer?.borderColor = NSColor.systemGreen.cgColor - overlay.layer?.borderWidth = 2 - overlay.autoresizingMask = [.width, .height] - contentView.addSubview(overlay) - } - } - #endif - - var onHoverChange: ((Bool) -> Void)? - - private var hoverTracker: HoverTracker? - - init() { - super.init(frame: .zero) - self.hoverTracker = HoverTracker(view: self) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { fatalError() } - - override func viewWillMove(toWindow newWindow: NSWindow?) { - if newWindow == nil { hoverTracker?.stop() } - super.viewWillMove(toWindow: newWindow) - } - - deinit { - hoverTracker?.stop() - } - - override func viewDidMoveToWindow() { - if window == nil { - hoverTracker?.stop() - } else { - hoverTracker?.startTracking { [weak self] inside in - guard let self else { return } - self.onHoverChange?(inside) - } - } - super.viewDidMoveToWindow() - } - - class HoverTracker { - typealias LocalMonitorToken = Any - - private var localMonitor: LocalMonitorToken? - - private var armed = false - private var isInside = false - - weak var view: TrackingStrip? - - /// Maybe Configurable inside settings or tuned to liking? - let padding: CGFloat = 40 - let verticalSlack: CGFloat = 8 // extra Y tolerance - - init( - view: TrackingStrip? = nil - ) { - self.view = view - } - - func startTracking(completion: @escaping (Bool) -> Void) { - guard localMonitor == nil else { return } - - var handler: ((NSEvent) -> Void)! - - handler = { [weak self] _ in - guard let self else { return } - guard let view = self.view else { return } - guard let window = view.window else { return } - - /// Global Screen Coordinates - let mouse = NSEvent.mouseLocation - - /// This is where the view is - let screenRect = window.convertToScreen(view.convert(view.bounds, to: nil)) - - /* - Maybe wanna let a bit of the left allow the sidebar to show: - Move offset + - Also Make baseWidth negation something larger - */ - /// If we are showing the sidebar THEN we grow the size - let baseWidth: CGFloat = armed ? padding : 0 - /// - goes to right, + goes to left, I like -1 - let offset: CGFloat = -1 - - let leftBand = NSRect( - x: screenRect.minX - offset - baseWidth, - y: screenRect.minY - verticalSlack, - width: baseWidth, - height: screenRect.height + 2 * verticalSlack - ) - - let insideBase = screenRect.contains(mouse) - let inLeftBand = leftBand.contains(mouse) - - let effective = insideBase || inLeftBand - -// #if DEBUG -// /// UNCOMMENT IF WANNA SEE RECT GETTING HIDDEN/SHOWN -// DispatchQueue.main.async { -// view.showDebugOverlay(for: leftBand) -// } -// #endif - - if effective != isInside { - isInside = effective - armed = effective - if Thread.isMainThread { completion(effective) } - else { DispatchQueue.main.async { completion(effective) } } - } - } - - localMonitor = NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved]) { event in handler(event) - return event - } - } - - func stop() { - if let localMonitor { NSEvent.removeMonitor(localMonitor) } - localMonitor = nil - isInside = false - } - } -} diff --git a/ora/Common/Representables/WindowAccessor.swift b/ora/Common/Representables/WindowAccessor.swift index 2605e695..5e510956 100644 --- a/ora/Common/Representables/WindowAccessor.swift +++ b/ora/Common/Representables/WindowAccessor.swift @@ -2,13 +2,8 @@ import AppKit import SwiftUI struct WindowAccessor: NSViewRepresentable { - let isSidebarHidden: Bool - @Binding var isFloatingSidebar: Bool @Binding var isFullscreen: Bool - // Store original button frames to restore them later - private static var originalButtonFrames: [NSWindow.ButtonType: NSRect] = [:] - func makeCoordinator() -> Coordinator { Coordinator(self) } @@ -21,13 +16,13 @@ struct WindowAccessor: NSViewRepresentable { self.parent = parent } - @objc func didEnterFullScreen(_ notification: Notification) { + @objc func willEnterFullScreenNotification(_ notification: Notification) { guard let window = notification.object as? NSWindow else { return } parent.isFullscreen = true parent.updateTrafficLights(for: window) } - @objc func didExitFullScreen(_ notification: Notification) { + @objc func willExitFullScreenNotification(_ notification: Notification) { guard let window = notification.object as? NSWindow else { return } parent.isFullscreen = false parent.updateTrafficLights(for: window) @@ -44,17 +39,17 @@ struct WindowAccessor: NSViewRepresentable { let coordinator = context.coordinator let enterObserver = NotificationCenter.default.addObserver( - forName: NSWindow.didEnterFullScreenNotification, + forName: NSWindow.willEnterFullScreenNotification, object: window, queue: nil, - using: coordinator.didEnterFullScreen + using: coordinator.willEnterFullScreenNotification ) let exitObserver = NotificationCenter.default.addObserver( - forName: NSWindow.didExitFullScreenNotification, + forName: NSWindow.willExitFullScreenNotification, object: window, queue: nil, - using: coordinator.didExitFullScreen + using: coordinator.willExitFullScreenNotification ) coordinator.observers = [enterObserver, exitObserver] @@ -76,33 +71,13 @@ struct WindowAccessor: NSViewRepresentable { } private func updateTrafficLights(for window: NSWindow) { - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.25 - context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) - - for buttonType in [NSWindow.ButtonType.closeButton, .miniaturizeButton, .zoomButton] { - guard let button = window.standardWindowButton(buttonType) else { continue } - - // Store original frame if we haven't already - if WindowAccessor.originalButtonFrames[buttonType] == nil { - WindowAccessor.originalButtonFrames[buttonType] = button.frame - } - - if let originalFrame = WindowAccessor.originalButtonFrames[buttonType] { - if isSidebarHidden, !isFullscreen { - // Always offset when sidebar is hidden - var newFrame = originalFrame - newFrame.origin.x += 8 - newFrame.origin.y -= 8 - button.animator().setFrameOrigin(newFrame.origin) - } else { - // Restore to original frame when visible - button.animator().setFrameOrigin(originalFrame.origin) - } - } - - button.animator().isHidden = (isSidebarHidden && !isFloatingSidebar && !isFullscreen) - } + for type in [ + NSWindow.ButtonType.closeButton, + .miniaturizeButton, + .zoomButton + ] { + guard let button = window.standardWindowButton(type) else { continue } + button.animator().isHidden = !isFullscreen } } } diff --git a/ora/Common/Utils/WindowFactory.swift b/ora/Common/Utils/WindowFactory.swift index b66f9941..15ee5251 100644 --- a/ora/Common/Utils/WindowFactory.swift +++ b/ora/Common/Utils/WindowFactory.swift @@ -21,6 +21,3 @@ enum WindowFactory { return window } } - - - diff --git a/ora/Models/Tab.swift b/ora/Models/Tab.swift index 48d93195..0a6f0308 100644 --- a/ora/Models/Tab.swift +++ b/ora/Models/Tab.swift @@ -29,8 +29,7 @@ class Tab: ObservableObject, Identifiable { var favicon: URL? // Add favicon property var createdAt: Date var lastAccessedAt: Date? - - + var type: TabType var order: Int var faviconLocalFile: URL? diff --git a/ora/Modules/Browser/BrowserContentContainer.swift b/ora/Modules/Browser/BrowserContentContainer.swift index 09f8cdea..0e8d86dc 100644 --- a/ora/Modules/Browser/BrowserContentContainer.swift +++ b/ora/Modules/Browser/BrowserContentContainer.swift @@ -2,34 +2,35 @@ import SwiftUI struct BrowserContentContainer: View { @EnvironmentObject var tabManager: TabManager + @EnvironmentObject var appState: AppState + @EnvironmentObject var sidebarManager: SidebarManager + let content: () -> Content - let isFullscreen: Bool - let hideState: SideHolder - let sidebarCornerRadius: CGFloat = { + private var isCompleteFullscreen: Bool { + appState.isFullscreen && sidebarManager.isSidebarHidden + } + + private var cornerRadius: CGFloat { if #available(macOS 26, *) { - return 8 + return 13 } else { return 6 } - }() + } - init(isFullscreen: Bool, hideState: SideHolder, @ViewBuilder content: @escaping () -> Content) { - self.isFullscreen = isFullscreen - self.hideState = hideState + init( + @ViewBuilder content: @escaping () -> Content + ) { self.content = content } var body: some View { content() .frame(maxWidth: .infinity, maxHeight: .infinity) - .clipShape( - ConditionallyConcentricRectangle( - cornerRadius: isFullscreen && hideState.side == .primary ? 0 : sidebarCornerRadius - ) - ) + .clipShape(RoundedRectangle(cornerRadius: isCompleteFullscreen ? 0 : cornerRadius, style: .continuous)) .padding( - isFullscreen && hideState.side == .primary + isCompleteFullscreen ? EdgeInsets( top: 0, leading: 0, @@ -38,12 +39,15 @@ struct BrowserContentContainer: View { ) : EdgeInsets( top: 6, - leading: hideState.side == .primary ? 6 : 0, + leading: sidebarManager.sidebarPosition != .primary || sidebarManager.hiddenSidebar + .side == .primary ? 6 : 0, bottom: 6, - trailing: 6 + trailing: sidebarManager.sidebarPosition != .secondary || sidebarManager.hiddenSidebar + .side == .secondary ? 6 : 0 ) ) - .shadow(color: .black.opacity(0.15), radius: sidebarCornerRadius, x: 0, y: 2) + .animation(.easeInOut(duration: 0.3), value: appState.isFullscreen) + .shadow(color: .black.opacity(0.15), radius: isCompleteFullscreen ? 0 : cornerRadius, x: 0, y: 2) .ignoresSafeArea(.all) } } diff --git a/ora/Modules/Browser/BrowserSplitView.swift b/ora/Modules/Browser/BrowserSplitView.swift new file mode 100644 index 00000000..d73a467d --- /dev/null +++ b/ora/Modules/Browser/BrowserSplitView.swift @@ -0,0 +1,98 @@ +import SwiftUI + +struct BrowserSplitView: View { + @EnvironmentObject var tabManager: TabManager + @EnvironmentObject var appState: AppState + @EnvironmentObject var toolbarManager: ToolbarManager + @EnvironmentObject var sidebarManager: SidebarManager + + private var targetSide: SplitSide { + sidebarManager.sidebarPosition == .primary ? .primary : .secondary + } + + private var splitFraction: FractionHolder { + sidebarManager.sidebarPosition == .primary + ? sidebarManager.currentFraction + : sidebarManager.currentFraction.inverted() + } + + private var minPF: CGFloat { + sidebarManager.sidebarPosition == .primary ? 0.16 : 0.7 + } + + private var minSF: CGFloat { + sidebarManager.sidebarPosition == .primary ? 0.7 : 0.16 + } + + private var prioritySide: SplitSide { + sidebarManager.sidebarPosition == .primary ? .primary : .secondary + } + + private var dragToHidePFlag: Bool { + sidebarManager.sidebarPosition == .primary + } + + private var dragToHideSFlag: Bool { + sidebarManager.sidebarPosition == .secondary + } + + var body: some View { + HSplit(left: { primaryPane() }, right: { secondaryPane() }) + .hide(sidebarManager.hiddenSidebar) + .splitter { Splitter.invisible() } + .fraction(splitFraction) + .constraints( + minPFraction: minPF, + minSFraction: minSF, + priority: prioritySide, + dragToHideP: dragToHidePFlag, + dragToHideS: dragToHideSFlag + ) + .styling(hideSplitter: true) + } + + @ViewBuilder + private func primaryPane() -> some View { + paneContent( + isSidebarPane: sidebarManager.sidebarPosition == .primary, + isOtherPaneHidden: sidebarManager.hiddenSidebar.side == .secondary + ) + } + + @ViewBuilder + private func secondaryPane() -> some View { + paneContent( + isSidebarPane: sidebarManager.sidebarPosition == .secondary, + isOtherPaneHidden: sidebarManager.hiddenSidebar.side == .primary + ) + } + + @ViewBuilder + private func paneContent(isSidebarPane: Bool, isOtherPaneHidden: Bool) -> some View { + if isSidebarPane, !isOtherPaneHidden { + SidebarView() + } else { + contentView() + } + } + + @ViewBuilder + private func contentView() -> some View { + if tabManager.activeTab == nil { + BrowserContentContainer { + HomeView() + } + } + ZStack { + let activeId = tabManager.activeTab?.id + ForEach(tabManager.tabsToRender) { tab in + if tab.isWebViewReady { + BrowserContentContainer { + BrowserWebContentView(tab: tab) + } + .opacity((activeId == tab.id) ? 1 : 0) + } + } + } + } +} diff --git a/ora/Modules/Browser/BrowserView.swift b/ora/Modules/Browser/BrowserView.swift index 87b8ee28..4fb1d2b9 100644 --- a/ora/Modules/Browser/BrowserView.swift +++ b/ora/Modules/Browser/BrowserView.swift @@ -2,176 +2,72 @@ import AppKit import SwiftUI struct BrowserView: View { - @EnvironmentObject var tabManager: TabManager @Environment(\.theme) var theme + @EnvironmentObject var tabManager: TabManager @EnvironmentObject private var appState: AppState @EnvironmentObject private var downloadManager: DownloadManager @EnvironmentObject private var historyManager: HistoryManager @EnvironmentObject private var privacyMode: PrivacyMode - @Environment(\.window) var window: NSWindow? - @State private var isFullscreen = false - @State private var showFloatingSidebar = false + @EnvironmentObject private var sidebarManager: SidebarManager + @EnvironmentObject private var toolbarManager: ToolbarManager + + @State private var isMouseOverURLBar = false + @State private var showFloatingURLBar = false @State private var isMouseOverSidebar = false - @StateObject private var sidebarFraction = FractionHolder.usingUserDefaults(0.2, key: "ui.sidebar.fraction") - @StateObject private var sidebarVisibility = SideHolder.usingUserDefaults(key: "ui.sidebar.visibility") - - private func toggleSidebar() { - withAnimation(.spring(response: 0.2, dampingFraction: 1.0)) { - sidebarVisibility.toggle(.primary) - } - } - - private func toggleMaximizeWindow() { - window?.toggleMaximized() - } - + @State private var showFloatingSidebar = false + var body: some View { - let splitView = HSplit( - left: { - SidebarView(isFullscreen: isFullscreen) - }, - right: { - if tabManager.activeTab == nil { - // Start page (visible when no tab is active) - BrowserContentContainer(isFullscreen: isFullscreen, hideState: sidebarVisibility) { - HomeView(sidebarToggle: toggleSidebar) + ZStack(alignment: .top) { + BrowserSplitView() + .ignoresSafeArea(.all) + .background(theme.subtleWindowBackgroundColor) + .background( + BlurEffectView(material: .underWindowBackground, blendingMode: .behindWindow) + .ignoresSafeArea(.all) + ) + .overlay { + if appState.showLauncher, tabManager.activeTab != nil { + LauncherView() } - } - ZStack { - let activeId = tabManager.activeTab?.id - ForEach(tabManager.tabsToRender) { tab in - if tab.isWebViewReady { - BrowserContentContainer(isFullscreen: isFullscreen, hideState: sidebarVisibility) { - webView(for: tab) - } - .opacity((activeId == tab.id) ? 1 : 0) - } + if appState.isFloatingTabSwitchVisible { + FloatingTabSwitcher() } } - } - ) - .hide(sidebarVisibility) - .splitter { Splitter.invisible() } - .fraction(sidebarFraction) - .constraints( - minPFraction: 0.16, - minSFraction: 0.7, - priority: .left, - dragToHideP: true - ) - // In autohide mode, remove any draggable splitter area to unhide - .styling(hideSplitter: true) - .ignoresSafeArea(.all) - .background(theme.subtleWindowBackgroundColor) - .background( - BlurEffectView( - material: .underWindowBackground, - blendingMode: .behindWindow - ).ignoresSafeArea(.all) - ) - .background( - WindowAccessor( - isSidebarHidden: sidebarVisibility.side == .primary, - isFloatingSidebar: $showFloatingSidebar, - isFullscreen: $isFullscreen + + if sidebarManager.isSidebarHidden { + FloatingSidebarOverlay( + showFloatingSidebar: $showFloatingSidebar, + isMouseOverSidebar: $isMouseOverSidebar, + sidebarFraction: sidebarManager.currentFraction, + isDownloadsPopoverOpen: downloadManager.isDownloadsPopoverOpen ) - .id("showFloatingSidebar = \(showFloatingSidebar)") // Forces WindowAccessor to update (for Traffic - // Lights) - ) - .overlay { - if appState.showLauncher, tabManager.activeTab != nil { - LauncherView() - } - - if appState.isFloatingTabSwitchVisible { - FloatingTabSwitcher() - } } - - ZStack(alignment: .leading) { - splitView - - if sidebarVisibility.side == .primary { - // Floating sidebar with resizable width based on persisted fraction - GeometryReader { geo in - let totalWidth = geo.size.width - let minFraction: CGFloat = 0.16 - let maxFraction: CGFloat = 0.30 - let clampedFraction = min(max(sidebarFraction.value, minFraction), maxFraction) - let floatingWidth = max(0, min(totalWidth * clampedFraction, totalWidth)) - ZStack(alignment: .leading) { - if showFloatingSidebar { - FloatingSidebar(isFullscreen: isFullscreen) - .frame(width: floatingWidth) - .transition(.move(edge: .leading)) - .overlay(alignment: .trailing) { - // Invisible resize handle to adjust width in autohide mode - Rectangle() - .fill(Color.clear) - .frame(width: 14) -#if targetEnvironment(macCatalyst) || os(macOS) - .cursor(NSCursor.resizeLeftRight) -#endif - .contentShape(Rectangle()) - .gesture( - DragGesture() - .onChanged { value in - let proposedWidth = max( - 0, - min(floatingWidth + value.translation.width, totalWidth) - ) - let newFraction = proposedWidth / max(totalWidth, 1) - // Clamp to same constraints as HSplit - sidebarFraction.value = min( - max(newFraction, minFraction), - maxFraction - ) - } - ) - } - .zIndex(3) - } - // Hover tracking strip to show/hide floating sidebar - Color.clear - .frame(width: showFloatingSidebar ? floatingWidth : 10) - .overlay( - MouseTrackingArea( - mouseEntered: Binding( - get: { showFloatingSidebar }, - set: { newValue in - isMouseOverSidebar = newValue - // Don't hide sidebar if downloads popover is open - if !newValue, downloadManager.isDownloadsPopoverOpen { - return - } - showFloatingSidebar = newValue - } - ) - ) - ) - .zIndex(2) - } - } + + if toolbarManager.isToolbarHidden { + FloatingURLBar( + showFloatingURLBar: $showFloatingURLBar, + isMouseOverURLBar: $isMouseOverURLBar + ) } } .edgesIgnoringSafeArea(.all) .animation(.easeOut(duration: 0.1), value: showFloatingSidebar) .onReceive(NotificationCenter.default.publisher(for: .toggleSidebar)) { _ in - toggleSidebar() + sidebarManager.toggleSidebar() } - .onChange(of: downloadManager.isDownloadsPopoverOpen) { isOpen in - if sidebarVisibility.side == .primary { + .onReceive(NotificationCenter.default.publisher(for: .toggleSidebarPosition)) { _ in + sidebarManager.toggleSidebarPosition() + } + .onChange(of: downloadManager.isDownloadsPopoverOpen) { _, isOpen in + if sidebarManager.isSidebarHidden { if isOpen { - // Keep sidebar visible while downloads popover is open showFloatingSidebar = true } else if !isMouseOverSidebar { - // Hide sidebar when popover closes and mouse is not over sidebar showFloatingSidebar = false } } } - .onChange(of: tabManager.activeTab) { newTab in - // Restore tab state when switching tabs via keyboard shortcut + .onChange(of: tabManager.activeTab) { _, newTab in if let tab = newTab, !tab.isWebViewReady { DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { tab.restoreTransientState( @@ -183,11 +79,7 @@ struct BrowserView: View { } } } - .onTapGesture(count: 2) { - toggleMaximizeWindow() - } .onAppear { - // Restore active tab on app startup if not already ready if let tab = tabManager.activeTab, !tab.isWebViewReady { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { tab.restoreTransientState( @@ -200,52 +92,4 @@ struct BrowserView: View { } } } - - @ViewBuilder - private func webView(for tab: Tab) -> some View { - VStack(alignment: .leading, spacing: 0) { - if !appState.isToolbarHidden { - URLBar( - onSidebarToggle: { toggleSidebar() } - ) - .transition(.asymmetric( - insertion: .push(from: .top), - removal: .push(from: .bottom) - )) - } - if tab.isWebViewReady { - if tab.hasNavigationError, let error = tab.navigationError { - StatusPageView( - error: error, - failedURL: tab.failedURL, - onRetry: { - tab.retryNavigation() - }, - onGoBack: tab.webView.canGoBack - ? { - tab.webView.goBack() - tab.clearNavigationError() - } : nil - ) - .id(tab.id) - } else { - ZStack(alignment: .topTrailing) { - WebView(webView: tab.webView) - .id(tab.id) - - if appState.showFinderIn == tab.id { - FindView(webView: tab.webView) - .padding(.top, 16) - .padding(.trailing, 16) - .zIndex(1000) - } - - if let hovered = tab.hoveredLinkURL, !hovered.isEmpty { - LinkPreview(text: hovered) - } - } - } - } - } - } } diff --git a/ora/Modules/Browser/BrowserWebContentView.swift b/ora/Modules/Browser/BrowserWebContentView.swift new file mode 100644 index 00000000..6ca524fe --- /dev/null +++ b/ora/Modules/Browser/BrowserWebContentView.swift @@ -0,0 +1,66 @@ +import SwiftUI + +struct BrowserWebContentView: View { + @Environment(\.theme) var theme + @EnvironmentObject var tabManager: TabManager + @EnvironmentObject private var appState: AppState + @EnvironmentObject private var toolbarManager: ToolbarManager + let tab: Tab + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if !toolbarManager.isToolbarHidden { + URLBar( + onSidebarToggle: { + NotificationCenter.default.post( + name: .toggleSidebar, object: nil + ) + } + ) + .transition( + .asymmetric( + insertion: .push(from: .top), + removal: .push(from: .bottom) + ) + ) + } + + if tab.isWebViewReady { + if tab.hasNavigationError, let error = tab.navigationError { + StatusPageView( + error: error, + failedURL: tab.failedURL, + onRetry: { tab.retryNavigation() }, + onGoBack: tab.webView.canGoBack + ? { + tab.webView.goBack() + tab.clearNavigationError() + } : nil + ) + .id(tab.id) + } else { + ZStack(alignment: .topTrailing) { + WebView(webView: tab.webView).id(tab.id) + + if appState.showFinderIn == tab.id { + FindView(webView: tab.webView) + .padding(.top, 16) + .padding(.trailing, 16) + .zIndex(1000) + } + + if let hovered = tab.hoveredLinkURL, !hovered.isEmpty { + LinkPreview(text: hovered) + } + } + } + } else { + ZStack { + Rectangle().fill(theme.background) + ProgressView().frame(width: 32, height: 32) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } +} diff --git a/ora/Modules/Browser/FloatingSidebarOverlay.swift b/ora/Modules/Browser/FloatingSidebarOverlay.swift new file mode 100644 index 00000000..71f27c9b --- /dev/null +++ b/ora/Modules/Browser/FloatingSidebarOverlay.swift @@ -0,0 +1,117 @@ +import SwiftUI + +struct FloatingSidebarOverlay: View { + @EnvironmentObject private var sidebarManager: SidebarManager + + @Binding var showFloatingSidebar: Bool + @Binding var isMouseOverSidebar: Bool + + var sidebarFraction: FractionHolder + let isDownloadsPopoverOpen: Bool + + @State private var dragFraction: CGFloat? + + var body: some View { + GeometryReader { geo in + let totalWidth = geo.size.width + let minFraction: CGFloat = 0.16 + let maxFraction: CGFloat = 0.30 + let currentFraction = dragFraction ?? sidebarFraction.value + let clampedFraction = min(max(currentFraction, minFraction), maxFraction) + let floatingWidth = max(0, min(totalWidth * clampedFraction, totalWidth)) + + ZStack(alignment: sidebarManager.sidebarPosition == .primary ? .leading : .trailing) { + if showFloatingSidebar { + FloatingSidebar() + .frame(width: floatingWidth) + .transition(.move(edge: sidebarManager.sidebarPosition == .primary ? .leading : .trailing)) + .overlay(alignment: sidebarManager.sidebarPosition == .primary ? .trailing : .leading) { + ResizeHandle( + dragFraction: $dragFraction, + sidebarFraction: sidebarFraction, + sidebarPosition: sidebarManager.sidebarPosition, + floatingWidth: floatingWidth, + totalWidth: totalWidth, + minFraction: minFraction, + maxFraction: maxFraction + ) + } + .zIndex(3) + } + + HStack(spacing: 0) { + if sidebarManager.sidebarPosition == .primary { + hoverStrip(width: showFloatingSidebar ? floatingWidth : 10) + Spacer() + } else { + Spacer() + hoverStrip(width: showFloatingSidebar ? floatingWidth : 10) + } + } + .zIndex(2) + } + } + } + + @ViewBuilder + private func hoverStrip(width: CGFloat) -> some View { + Color.clear + .frame(width: width) + .overlay( + GlobalMouseTrackingArea( + mouseEntered: Binding( + get: { showFloatingSidebar }, + set: { newValue in + isMouseOverSidebar = newValue + if !newValue, isDownloadsPopoverOpen { + return + } + showFloatingSidebar = newValue + } + ), + edge: sidebarManager.sidebarPosition == .primary ? .left : .right, + padding: 40, + slack: 8 + ) + ) + } +} + +private struct ResizeHandle: View { + @Binding var dragFraction: CGFloat? + var sidebarFraction: FractionHolder + let sidebarPosition: SidebarPosition + let floatingWidth: CGFloat + let totalWidth: CGFloat + let minFraction: CGFloat + let maxFraction: CGFloat + + var body: some View { + Rectangle() + .fill(Color.clear) + .frame(width: 14) + #if targetEnvironment(macCatalyst) || os(macOS) + .cursor(NSCursor.resizeLeftRight) + #endif + .contentShape(Rectangle()) + .gesture( + DragGesture() + .onChanged { value in + let proposedWidth: CGFloat = if sidebarPosition == .primary { + max(0, min(floatingWidth + value.translation.width, totalWidth)) + } else { + max(0, min(floatingWidth - value.translation.width, totalWidth)) + } + + let newFraction = proposedWidth / max(totalWidth, 1) + dragFraction = min(max(newFraction, minFraction), maxFraction) + } + .onEnded { _ in + if let fraction = dragFraction { + sidebarFraction.value = fraction + } + dragFraction = nil + } + ) + } +} diff --git a/ora/Modules/Find/FindView.swift b/ora/Modules/Find/FindView.swift index 22a8aefb..7ecc2799 100644 --- a/ora/Modules/Find/FindView.swift +++ b/ora/Modules/Find/FindView.swift @@ -14,6 +14,7 @@ struct FindView: View { @State private var currentMatch = 0 @FocusState private var isTextFieldFocused: Bool @EnvironmentObject private var appState: AppState + @EnvironmentObject var toolbarManager: ToolbarManager @Environment(\.theme) var theme @Environment(\.colorScheme) var colorScheme private let controller: FindController diff --git a/ora/Modules/Importer/ImportDataButton.swift b/ora/Modules/Importer/ImportDataButton.swift index 5eaec0b6..f97fd10f 100644 --- a/ora/Modules/Importer/ImportDataButton.swift +++ b/ora/Modules/Importer/ImportDataButton.swift @@ -2,7 +2,7 @@ import SwiftUI struct ImportDataButton: View { @EnvironmentObject var tabManager: TabManager - @EnvironmentObject var historyManger: HistoryManager + @EnvironmentObject var historyManager: HistoryManager @EnvironmentObject var downloadManager: DownloadManager @EnvironmentObject var privacyMode: PrivacyMode @@ -36,7 +36,7 @@ struct ImportDataButton: View { title: tab.title, url: url, container: container, - historyManager: historyManger, + historyManager: historyManager, downloadManager: downloadManager, isPrivate: privacyMode.isPrivate ) @@ -73,7 +73,7 @@ struct ImportDataButton: View { title: tab.title, url: url, container: container, - historyManager: historyManger, + historyManager: historyManager, downloadManager: downloadManager, isPrivate: privacyMode.isPrivate ) diff --git a/ora/Modules/Launcher/LauncherView.swift b/ora/Modules/Launcher/LauncherView.swift index 4ad9061a..ef71d72f 100644 --- a/ora/Modules/Launcher/LauncherView.swift +++ b/ora/Modules/Launcher/LauncherView.swift @@ -3,6 +3,7 @@ import SwiftUI struct LauncherView: View { @EnvironmentObject var appState: AppState + @EnvironmentObject var toolbarManager: ToolbarManager @EnvironmentObject var tabManager: TabManager @EnvironmentObject var historyManager: HistoryManager @EnvironmentObject var downloadManager: DownloadManager diff --git a/ora/Modules/Launcher/Main/LauncherMain.swift b/ora/Modules/Launcher/Main/LauncherMain.swift index c9ce1638..f10797ae 100644 --- a/ora/Modules/Launcher/Main/LauncherMain.swift +++ b/ora/Modules/Launcher/Main/LauncherMain.swift @@ -48,6 +48,7 @@ struct LauncherMain: View { @EnvironmentObject var downloadManager: DownloadManager @EnvironmentObject var tabManager: TabManager @EnvironmentObject var appState: AppState + @EnvironmentObject var toolbarManager: ToolbarManager @EnvironmentObject var privacyMode: PrivacyMode @State var focusedElement: UUID = .init() @StateObject private var faviconService = FaviconService() diff --git a/ora/Modules/Launcher/Suggestions/LauncherSuggestionItem.swift b/ora/Modules/Launcher/Suggestions/LauncherSuggestionItem.swift index 009ccaf0..992df419 100644 --- a/ora/Modules/Launcher/Suggestions/LauncherSuggestionItem.swift +++ b/ora/Modules/Launcher/Suggestions/LauncherSuggestionItem.swift @@ -44,6 +44,7 @@ struct LauncherSuggestionItem: View { @State private var isHovered = false @Environment(\.theme) private var theme @EnvironmentObject var appState: AppState + @EnvironmentObject var toolbarManager: ToolbarManager init(suggestion: LauncherSuggestion, defaultAI: SearchEngine?, focusedElement: Binding) { self.suggestion = suggestion diff --git a/ora/Modules/Settings/Sections/GeneralSettingsView.swift b/ora/Modules/Settings/Sections/GeneralSettingsView.swift index 4277fd35..b9725108 100644 --- a/ora/Modules/Settings/Sections/GeneralSettingsView.swift +++ b/ora/Modules/Settings/Sections/GeneralSettingsView.swift @@ -7,7 +7,7 @@ struct GeneralSettingsView: View { @StateObject private var settings = SettingsStore.shared @StateObject private var defaultBrowserManager = DefaultBrowserManager.shared @Environment(\.theme) var theme - + var body: some View { SettingsContainer(maxContentWidth: 760) { Form { @@ -22,7 +22,7 @@ struct GeneralSettingsView: View { .font(.subheadline) .foregroundColor(.secondary) } - + Text("Fast, secure, and beautiful browser built for macOS") .font(.caption) .foregroundColor(.secondary) @@ -30,9 +30,8 @@ struct GeneralSettingsView: View { .padding(12) .background(theme.solidWindowBackgroundColor) .cornerRadius(8) - - if !defaultBrowserManager.isDefault { - + + if !defaultBrowserManager.isDefault { HStack { Text("Born for your Mac. Make Ora your default browser.") Spacer() @@ -48,12 +47,12 @@ struct GeneralSettingsView: View { VStack(alignment: .leading, spacing: 12) { Text("Tab Management") .font(.headline) - + VStack(alignment: .leading, spacing: 8) { Text("Automatically clean up old tabs to preserve memory.") .font(.caption) .foregroundColor(.secondary) - + HStack { Text("Destroy web views after:") Spacer() @@ -67,7 +66,7 @@ struct GeneralSettingsView: View { } .frame(width: 120) } - + HStack { Text("Remove tabs completely after:") Spacer() @@ -81,12 +80,12 @@ struct GeneralSettingsView: View { } .frame(width: 120) } - + HStack { Text("Maximum recent tabs to keep in view:") Spacer() Picker("", selection: $settings.maxRecentTabs) { - ForEach(1...10, id: \.self) { num in + ForEach(1 ... 10, id: \.self) { num in Text("\(num)").tag(num) } } @@ -102,41 +101,41 @@ struct GeneralSettingsView: View { VStack(alignment: .leading, spacing: 12) { Text("Updates") .font(.headline) - + Toggle("Automatically check for updates", isOn: $settings.autoUpdateEnabled) - + VStack(alignment: .leading, spacing: 8) { HStack { Button("Check for Updates") { updateService.checkForUpdates() } - + if updateService.isCheckingForUpdates { ProgressView() .scaleEffect(0.5) .frame(width: 16, height: 16) } - + if updateService.updateAvailable { Text("Update available!") .foregroundColor(.green) .font(.caption) } } - + if let result = updateService.lastCheckResult { Text(result) .font(.caption) .foregroundColor(updateService.updateAvailable ? .green : .secondary) } - + // Show current app version if let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String { Text("Current version: \(appVersion)") .font(.caption2) .foregroundColor(.secondary) } - + // Show last check time if let lastCheck = updateService.lastCheckDate { Text("Last checked: \(lastCheck.formatted(date: .abbreviated, time: .shortened))") @@ -145,8 +144,6 @@ struct GeneralSettingsView: View { } } } - - } } } diff --git a/ora/Modules/Settings/Sections/SpacesSettingsView.swift b/ora/Modules/Settings/Sections/SpacesSettingsView.swift index ba53c50d..95cfc12b 100644 --- a/ora/Modules/Settings/Sections/SpacesSettingsView.swift +++ b/ora/Modules/Settings/Sections/SpacesSettingsView.swift @@ -7,7 +7,7 @@ struct SpacesSettingsView: View { @StateObject private var settings = SettingsStore.shared @State private var searchService = SearchEngineService() @State private var selectedContainerId: UUID? - @EnvironmentObject var historyManger: HistoryManager + @EnvironmentObject var historyManager: HistoryManager private var selectedContainer: TabContainer? { containers.first { $0.id == selectedContainerId } ?? containers.first @@ -153,7 +153,7 @@ struct SpacesSettingsView: View { .frame(maxWidth: .infinity, alignment: .leading) Button("Clear History") { - historyManger.clearContainerHistory(container) + historyManager.clearContainerHistory(container) } .frame(maxWidth: .infinity, alignment: .leading) } diff --git a/ora/Modules/Sidebar/ContainerView.swift b/ora/Modules/Sidebar/ContainerView.swift index 82f2a958..3dc552d9 100644 --- a/ora/Modules/Sidebar/ContainerView.swift +++ b/ora/Modules/Sidebar/ContainerView.swift @@ -6,6 +6,7 @@ struct ContainerView: View { let containers: [TabContainer] @EnvironmentObject var appState: AppState + @EnvironmentObject var toolbarManager: ToolbarManager @EnvironmentObject var tabManager: TabManager @EnvironmentObject var privacyMode: PrivacyMode @@ -15,7 +16,7 @@ struct ContainerView: View { var body: some View { VStack(alignment: .leading, spacing: 16) { - if appState.isToolbarHidden, let tab = tabManager.activeTab { + if toolbarManager.isToolbarHidden, let tab = tabManager.activeTab { SidebarURLDisplay( tab: tab, editingURLString: $editingURLString diff --git a/ora/Modules/Sidebar/FloatingSidebar.swift b/ora/Modules/Sidebar/FloatingSidebar.swift index 698d51bc..8958543e 100644 --- a/ora/Modules/Sidebar/FloatingSidebar.swift +++ b/ora/Modules/Sidebar/FloatingSidebar.swift @@ -1,11 +1,11 @@ import SwiftUI struct FloatingSidebar: View { - let isFullscreen: Bool @Environment(\.theme) var theme + let sidebarCornerRadius: CGFloat = { if #available(macOS 26, *) { - return 8 + return 13 } else { return 6 } @@ -15,13 +15,12 @@ struct FloatingSidebar: View { let clipShape = ConditionallyConcentricRectangle(cornerRadius: sidebarCornerRadius) ZStack(alignment: .leading) { - SidebarView(isFullscreen: isFullscreen) + SidebarView() .background(theme.subtleWindowBackgroundColor) .background(BlurEffectView(material: .popover, blendingMode: .withinWindow)) .clipShape(clipShape) - .overlay( - clipShape - .stroke(theme.invertedSolidWindowBackgroundColor.opacity(0.3), lineWidth: 1) + .overlay(clipShape + .stroke(theme.invertedSolidWindowBackgroundColor.opacity(0.3), lineWidth: 1) ) } .padding(6) diff --git a/ora/Modules/Sidebar/SidebarToolbar.swift b/ora/Modules/Sidebar/SidebarToolbar.swift new file mode 100644 index 00000000..774b3996 --- /dev/null +++ b/ora/Modules/Sidebar/SidebarToolbar.swift @@ -0,0 +1,96 @@ +// +// SidebarToolbar.swift +// ora +// +// Created by Yonathan Dejene on 13/10/2025. +// +import SwiftUI + +struct SidebarToolbar: View { + @Environment(\.theme) private var theme + @EnvironmentObject var appState: AppState + @EnvironmentObject var tabManager: TabManager + @EnvironmentObject var sidebarManager: SidebarManager + @EnvironmentObject var toolbarManager: ToolbarManager + + private var sidebarIcon: String { + sidebarManager.sidebarPosition == .secondary ? "sidebar.right" : "sidebar.left" + } + + var body: some View { + HStack(spacing: 0) { + if sidebarManager.sidebarPosition != .secondary { + WindowControls(isFullscreen: appState.isFullscreen).frame(height: 30) + } + + if toolbarManager.isToolbarHidden { + HStack(spacing: 0) { + if sidebarManager.sidebarPosition == .primary { + HStack { + URLBarButton( + systemName: sidebarIcon, + isEnabled: tabManager.activeTab != nil, + foregroundColor: theme.foreground.opacity(0.7), + action: { sidebarManager.toggleSidebar() } + ) + .oraShortcutHelp("Toggle Sidebar", for: KeyboardShortcuts.App.toggleSidebar) + Spacer() + } + } + URLBarButton( + systemName: "chevron.left", + isEnabled: tabManager.activeTab?.webView.canGoBack ?? false, + foregroundColor: theme.foreground.opacity(0.7), + action: { + if let activeTab = tabManager.activeTab { + activeTab.goBack() + } + } + ) + .oraShortcutHelp("Go Back", for: KeyboardShortcuts.Navigation.back) + + URLBarButton( + systemName: "chevron.right", + isEnabled: tabManager.activeTab?.webView.canGoForward ?? false, + foregroundColor: theme.foreground.opacity(0.7), + action: { + if let activeTab = tabManager.activeTab { + activeTab.goForward() + } + } + ) + .oraShortcutHelp("Go Forward", for: KeyboardShortcuts.Navigation.forward) + + URLBarButton( + systemName: "arrow.clockwise", + isEnabled: tabManager.activeTab != nil, + foregroundColor: theme.foreground.opacity(0.7), + action: { + if let activeTab = tabManager.activeTab { + activeTab.webView.reload() + } + } + ) + .oraShortcutHelp("Reload This Page", for: KeyboardShortcuts.Navigation.reload) + + if sidebarManager.sidebarPosition == .secondary { + HStack { + Spacer() + URLBarButton( + systemName: sidebarIcon, + isEnabled: tabManager.activeTab != nil, + foregroundColor: theme.foreground.opacity(0.7), + action: { sidebarManager.toggleSidebar() } + ) + .oraShortcutHelp("Toggle Sidebar", for: KeyboardShortcuts.App.toggleSidebar) + } + } + } + .padding(.trailing, 6) + .padding(.leading, sidebarManager.sidebarPosition == .primary ? 0 : 6) + .padding(.vertical, 0) + } + } + .padding(0) + } +} diff --git a/ora/Modules/Sidebar/SidebarURLDisplay.swift b/ora/Modules/Sidebar/SidebarURLDisplay.swift index d686cd2d..bba137c7 100644 --- a/ora/Modules/Sidebar/SidebarURLDisplay.swift +++ b/ora/Modules/Sidebar/SidebarURLDisplay.swift @@ -6,6 +6,7 @@ struct SidebarURLDisplay: View { @Environment(\.theme) private var theme @EnvironmentObject var tabManager: TabManager @EnvironmentObject var appState: AppState + @EnvironmentObject var toolbarManager: ToolbarManager let tab: Tab @Binding var editingURLString: String @@ -115,7 +116,7 @@ struct SidebarURLDisplay: View { .onChange(of: tab.url) { _, _ in if !isEditing { editingURLString = "" } } - .onChange(of: appState.showFullURL) { _, _ in + .onChange(of: toolbarManager.showFullURL) { _, _ in if !isEditing { editingURLString = "" } } .onChange(of: isEditing) { _, newValue in @@ -134,7 +135,7 @@ struct SidebarURLDisplay: View { } private func getDisplayURL() -> String { - if appState.showFullURL { + if toolbarManager.showFullURL { return tab.url.absoluteString } else { return tab.url.host ?? tab.url.absoluteString diff --git a/ora/Modules/Sidebar/SidebarView.swift b/ora/Modules/Sidebar/SidebarView.swift index de86db16..c1eb47da 100644 --- a/ora/Modules/Sidebar/SidebarView.swift +++ b/ora/Modules/Sidebar/SidebarView.swift @@ -4,17 +4,23 @@ import SwiftUI struct SidebarView: View { @Environment(\.theme) private var theme + @Environment(\.window) var window: NSWindow? @EnvironmentObject var tabManager: TabManager - @EnvironmentObject var historyManger: HistoryManager + @EnvironmentObject var historyManager: HistoryManager @EnvironmentObject var downloadManager: DownloadManager @EnvironmentObject var appState: AppState @EnvironmentObject var privacyMode: PrivacyMode @EnvironmentObject var media: MediaController + @EnvironmentObject var sidebarManager: SidebarManager + @EnvironmentObject var toolbarManager: ToolbarManager + @Query var containers: [TabContainer] - @Query(filter: nil, sort: [.init(\History.lastAccessedAt, order: .reverse)]) var histories: - [History] + @Query(filter: nil, sort: [.init(\History.lastAccessedAt, order: .reverse)]) + var histories: [History] + private let columns = Array(repeating: GridItem(spacing: 10), count: 3) - let isFullscreen: Bool + + @State private var isHoveringSidebarToggle = false private var shouldShowMediaWidget: Bool { let activeId = tabManager.activeTab?.id @@ -28,8 +34,10 @@ struct SidebarView: View { private var selectedContainerIndex: Binding { Binding( get: { - guard let activeContainer = tabManager.activeContainer else { return 0 } - return containers.firstIndex(where: { $0.id == activeContainer.id }) ?? 0 + guard let activeContainer = tabManager.activeContainer else { + return 0 + } + return containers.firstIndex { $0.id == activeContainer.id } ?? 0 }, set: { newIndex in guard newIndex >= 0, newIndex < containers.count else { return } @@ -40,6 +48,7 @@ struct SidebarView: View { var body: some View { VStack(alignment: .leading, spacing: 16) { + SidebarToolbar() NSPageView( selection: selectedContainerIndex, pageObjects: containers, @@ -52,18 +61,20 @@ struct SidebarView: View { ) .padding(.horizontal, 10) .environmentObject(tabManager) - .environmentObject(historyManger) + .environmentObject(historyManager) .environmentObject(downloadManager) .environmentObject(appState) .environmentObject(privacyMode) + .environmentObject(toolbarManager) } - // Show player if there is at least one playing session not belonging to the active tab + if shouldShowMediaWidget { GlobalMediaPlayer() .environmentObject(media) .padding(.horizontal, 10) .transition(.move(edge: .bottom).combined(with: .opacity)) } + if !privacyMode.isPrivate { HStack { DownloadsWidget() @@ -78,12 +89,15 @@ struct SidebarView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .padding( EdgeInsets( - top: isFullscreen ? 10 : 36, + top: toolbarManager.isToolbarHidden ? 10 : 0, leading: 0, bottom: 10, trailing: 0 ) ) + .onTapGesture(count: 2) { + toggleMaximizeWindow() + } } private func onContainerSelected(container: TabContainer) { @@ -91,4 +105,8 @@ struct SidebarView: View { tabManager.activateContainer(container) } } + + private func toggleMaximizeWindow() { + window?.toggleMaximized() + } } diff --git a/ora/Modules/SplitView/Split.swift b/ora/Modules/SplitView/Split.swift index 18c6618e..7008cbf1 100644 --- a/ora/Modules/SplitView/Split.swift +++ b/ora/Modules/SplitView/Split.swift @@ -109,7 +109,7 @@ public struct Split: View { } .clipped() // Can cause problems in some List styles if not clipped .environmentObject(layout) - // .onChange(of: fraction.value) { _, new in constrainedFraction = new } + .onChange(of: fraction.value) { _, new in constrainedFraction = new } } } diff --git a/ora/Modules/SplitView/SplitHolders.swift b/ora/Modules/SplitView/SplitHolders.swift index a9ddf604..92f9323c 100644 --- a/ora/Modules/SplitView/SplitHolders.swift +++ b/ora/Modules/SplitView/SplitHolders.swift @@ -82,6 +82,20 @@ public class FractionHolder: ObservableObject { setter: { fraction in UserDefaults.standard.set(fraction, forKey: key) } ) } + + public func inverted() -> FractionHolder { + FractionHolder( + 1.0 - value, + getter: { [weak self] in + guard let self else { return 0.5 } + return 1.0 - self.value + }, + setter: { [weak self] newValue in + guard let self else { return } + self.value = 1.0 - newValue + } + ) + } } /// An ObservableObject that `Split` view observes to change whether one of the `SplitSide`s is hidden. diff --git a/ora/Modules/URLBar/FloatingURLBar.swift b/ora/Modules/URLBar/FloatingURLBar.swift new file mode 100644 index 00000000..f953e3c3 --- /dev/null +++ b/ora/Modules/URLBar/FloatingURLBar.swift @@ -0,0 +1,63 @@ +import SwiftUI + +struct FloatingURLBar: View { + @Binding var showFloatingURLBar: Bool + @Binding var isMouseOverURLBar: Bool + + private var triggerAreaPadding: CGFloat { + showFloatingURLBar ? 50 : 16 + } + + var body: some View { + ZStack(alignment: .top) { + if showFloatingURLBar { + URLBar( + onSidebarToggle: { + NotificationCenter.default.post( + name: .toggleSidebar, object: nil + ) + } + ) + .shadow(color: Color.black.opacity(0.2), radius: 10, y: 4) + .overlay( + Rectangle() + .frame(height: 0.5) + .foregroundColor(Color(.separatorColor)), + alignment: .bottom + ) + .transition(.move(edge: .top).combined(with: .opacity)) + .zIndex(1) + } + + VStack(alignment: .leading) { + hoverStrip(width: .infinity) + Spacer() + } + .frame(maxWidth: .infinity) + .frame(height: triggerAreaPadding) + } + .animation(.easeInOut(duration: 0.1), value: showFloatingURLBar) + } + + @ViewBuilder + private func hoverStrip(width: CGFloat) -> some View { + Color.clear + .overlay( + GlobalMouseTrackingArea( + mouseEntered: Binding( + get: { showFloatingURLBar }, + set: { newValue in + withAnimation(.easeInOut(duration: 0.25)) { + isMouseOverURLBar = newValue + showFloatingURLBar = newValue + } + } + ), + edge: .top, + padding: triggerAreaPadding, + slack: 8 + ) + .id(triggerAreaPadding) + ) + } +} diff --git a/ora/OraCommands.swift b/ora/OraCommands.swift index d3573f87..d2f43193 100644 --- a/ora/OraCommands.swift +++ b/ora/OraCommands.swift @@ -1,37 +1,56 @@ -import AppKit import SwiftUI struct OraCommands: Commands { @AppStorage("AppAppearance") private var appearanceRaw: String = AppAppearance.system.rawValue + @AppStorage("ui.sidebar.hidden") private var isSidebarHidden: Bool = false + @AppStorage("ui.sidebar.position") private var sidebarPosition: SidebarPosition = .primary + @AppStorage("ui.toolbar.hidden") private var isToolbarHidden: Bool = false + @AppStorage("ui.toolbar.showfullurl") private var showFullURL: Bool = true @Environment(\.openWindow) private var openWindow - @ObservedObject private var shortcutManager = CustomKeyboardShortcutManager.shared var body: some Commands { CommandGroup(replacing: .newItem) { - Button("New Window") { - openWindow(id: "normal") - } - .keyboardShortcut(KeyboardShortcuts.Window.new.keyboardShortcut) + Button("New Window") { openWindow(id: "normal") } + .keyboardShortcut(KeyboardShortcuts.Window.new.keyboardShortcut) - Button("New Private Window") { - openWindow(id: "private") - } - .keyboardShortcut(KeyboardShortcuts.Window.newPrivate.keyboardShortcut) + Button("New Private Window") { openWindow(id: "private") } + .keyboardShortcut(KeyboardShortcuts.Window.newPrivate.keyboardShortcut) - Button("New Tab") { NotificationCenter.default.post(name: .showLauncher, object: NSApp.keyWindow) } - .keyboardShortcut(KeyboardShortcuts.Tabs.new.keyboardShortcut) + Button("New Tab") { + NotificationCenter.default.post(name: .showLauncher, object: NSApp.keyWindow) + }.keyboardShortcut(KeyboardShortcuts.Tabs.new.keyboardShortcut) - Button("Close Tab") { NotificationCenter.default.post(name: .closeActiveTab, object: NSApp.keyWindow) } - .keyboardShortcut(KeyboardShortcuts.Tabs.close.keyboardShortcut) + Divider() ImportDataButton() + + Divider() + + Button("Close Tab") { + NotificationCenter.default.post(name: .closeActiveTab, object: NSApp.keyWindow) + }.keyboardShortcut(KeyboardShortcuts.Tabs.close.keyboardShortcut) + + Button("Close Window") { + if let keyWindow = NSApp.keyWindow, keyWindow.title == "Settings" { + keyWindow.performClose(nil) + } + } + .keyboardShortcut("w", modifiers: .command) + .disabled({ + guard let keyWindow = NSApp.keyWindow else { return true } + return keyWindow.title != "Settings" + }()) } - CommandGroup(after: .pasteboard) { - Button("Restore") { NotificationCenter.default.post(name: .restoreLastTab, object: NSApp.keyWindow) } - .keyboardShortcut(KeyboardShortcuts.Tabs.restore.keyboardShortcut) + CommandMenu("Edit") { + Button("Restore Last Tab") { + NotificationCenter.default.post(name: .restoreLastTab, object: NSApp.keyWindow) + } + .keyboardShortcut(KeyboardShortcuts.Tabs.restore.keyboardShortcut) + + Divider() - Button("Find") { + Button("Find in Page") { NotificationCenter.default.post(name: .findInPage, object: NSApp.keyWindow) } .keyboardShortcut(KeyboardShortcuts.Edit.find.keyboardShortcut) @@ -45,6 +64,7 @@ struct OraCommands: Commands { } CommandGroup(replacing: .sidebar) { + // APPEARANCE Picker("Appearance", selection: Binding( get: { AppAppearance(rawValue: appearanceRaw) ?? .system }, set: { newValue in @@ -57,138 +77,100 @@ struct OraCommands: Commands { } )) { ForEach(AppAppearance.allCases) { mode in - Text(mode.rawValue).tag(mode) + Text(mode.rawValue.capitalized).tag(mode) } } - } - CommandGroup(after: .sidebar) { - Button("Toggle Sidebar") { + Divider() + + // VISIBILITY + Button(isSidebarHidden ? "Show Sidebar" : "Hide Sidebar") { NotificationCenter.default.post(name: .toggleSidebar, object: nil) } .keyboardShortcut(KeyboardShortcuts.App.toggleSidebar.keyboardShortcut) - Divider() + Button(isToolbarHidden ? "Show Toolbar" : "Hide Toolbar") { + NotificationCenter.default.post(name: .toggleToolbar, object: NSApp.keyWindow) + } + .keyboardShortcut(KeyboardShortcuts.App.toggleToolbar.keyboardShortcut) - Button("Toggle Full URL") { NotificationCenter.default.post(name: .toggleFullURL, object: NSApp.keyWindow) } - } + Divider() - CommandGroup(replacing: .appInfo) { - Button("About Ora") { showAboutWindow() } + // LAYOUT + Button(sidebarPosition == .primary ? "Right Side Tabs" : "Left Side Tabs") { + NotificationCenter.default.post(name: .toggleSidebarPosition, object: nil) + } - Button("Check for Updates") { NotificationCenter.default.post( - name: .checkForUpdates, - object: NSApp.keyWindow - ) } + Button(showFullURL ? "Hide Full URL" : "Show Full URL") { + NotificationCenter.default.post(name: .toggleFullURL, object: NSApp.keyWindow) + } + Divider() } CommandMenu("Navigation") { - Button("Reload") { NotificationCenter.default.post(name: .reloadPage, object: NSApp.keyWindow) } - .keyboardShortcut(KeyboardShortcuts.Navigation.reload.keyboardShortcut) - Button("Back") { NotificationCenter.default.post(name: .goBack, object: NSApp.keyWindow) } - .keyboardShortcut(KeyboardShortcuts.Navigation.back.keyboardShortcut) - Button("Forward") { NotificationCenter.default.post(name: .goForward, object: NSApp.keyWindow) } - .keyboardShortcut(KeyboardShortcuts.Navigation.forward.keyboardShortcut) + Button("Reload Page") { + NotificationCenter.default.post(name: .reloadPage, object: NSApp.keyWindow) + } + .keyboardShortcut(KeyboardShortcuts.Navigation.reload.keyboardShortcut) + + Divider() + + Button("Back") { + NotificationCenter.default.post(name: .goBack, object: NSApp.keyWindow) + } + .keyboardShortcut(KeyboardShortcuts.Navigation.back.keyboardShortcut) + + Button("Forward") { + NotificationCenter.default.post(name: .goForward, object: NSApp.keyWindow) + } + .keyboardShortcut(KeyboardShortcuts.Navigation.forward.keyboardShortcut) } CommandMenu("Tabs") { - Button("New Tab") { NotificationCenter.default.post(name: .showLauncher, object: NSApp.keyWindow) } - .keyboardShortcut(KeyboardShortcuts.Tabs.new.keyboardShortcut) - Button("Pin Tab") { NotificationCenter.default.post(name: .togglePinTab, object: NSApp.keyWindow) } - .keyboardShortcut(KeyboardShortcuts.Tabs.pin.keyboardShortcut) + Button("Pin Tab") { + NotificationCenter.default.post(name: .togglePinTab, object: NSApp.keyWindow) + }.keyboardShortcut(KeyboardShortcuts.Tabs.pin.keyboardShortcut) Divider() - Button("Next Tab") { NotificationCenter.default.post(name: .nextTab, object: NSApp.keyWindow) } - .keyboardShortcut(KeyboardShortcuts.Tabs.next.keyboardShortcut) - Button("Previous Tab") { NotificationCenter.default.post(name: .previousTab, object: NSApp.keyWindow) } - .keyboardShortcut(KeyboardShortcuts.Tabs.previous.keyboardShortcut) + Button("Next Tab") { + NotificationCenter.default.post(name: .nextTab, object: NSApp.keyWindow) + } + .keyboardShortcut(KeyboardShortcuts.Tabs.next.keyboardShortcut) - Divider() + Button("Previous Tab") { + NotificationCenter.default.post(name: .previousTab, object: NSApp.keyWindow) + } + .keyboardShortcut(KeyboardShortcuts.Tabs.previous.keyboardShortcut) - Button("Tab 1") { NotificationCenter.default.post( - name: .selectTabAtIndex, - object: NSApp.keyWindow, - userInfo: ["index": 1] - ) } - .keyboardShortcut(KeyboardShortcuts.Tabs.tab1.keyboardShortcut) - - Button("Tab 2") { NotificationCenter.default.post( - name: .selectTabAtIndex, - object: NSApp.keyWindow, - userInfo: ["index": 2] - ) } - .keyboardShortcut(KeyboardShortcuts.Tabs.tab2.keyboardShortcut) - - Button("Tab 3") { NotificationCenter.default.post( - name: .selectTabAtIndex, - object: NSApp.keyWindow, - userInfo: ["index": 3] - ) } - .keyboardShortcut(KeyboardShortcuts.Tabs.tab3.keyboardShortcut) - - Button("Tab 4") { NotificationCenter.default.post( - name: .selectTabAtIndex, - object: NSApp.keyWindow, - userInfo: ["index": 4] - ) } - .keyboardShortcut(KeyboardShortcuts.Tabs.tab4.keyboardShortcut) - - Button("Tab 5") { NotificationCenter.default.post( - name: .selectTabAtIndex, - object: NSApp.keyWindow, - userInfo: ["index": 5] - ) } - .keyboardShortcut(KeyboardShortcuts.Tabs.tab5.keyboardShortcut) - - Button("Tab 6") { NotificationCenter.default.post( - name: .selectTabAtIndex, - object: NSApp.keyWindow, - userInfo: ["index": 6] - ) } - .keyboardShortcut(KeyboardShortcuts.Tabs.tab6.keyboardShortcut) - - Button("Tab 7") { NotificationCenter.default.post( - name: .selectTabAtIndex, - object: NSApp.keyWindow, - userInfo: ["index": 7] - ) } - .keyboardShortcut(KeyboardShortcuts.Tabs.tab7.keyboardShortcut) - - Button("Tab 8") { NotificationCenter.default.post( - name: .selectTabAtIndex, - object: NSApp.keyWindow, - userInfo: ["index": 8] - ) } - .keyboardShortcut(KeyboardShortcuts.Tabs.tab8.keyboardShortcut) - - Button("Tab 9") { NotificationCenter.default.post( - name: .selectTabAtIndex, - object: NSApp.keyWindow, - userInfo: ["index": 9] - ) } - .keyboardShortcut(KeyboardShortcuts.Tabs.tab9.keyboardShortcut) - } + Divider() - CommandGroup(replacing: .toolbar) { - Button("Toggle Toolbar") { NotificationCenter.default.post(name: .toggleToolbar, object: NSApp.keyWindow) } - .keyboardShortcut(KeyboardShortcuts.App.toggleToolbar.keyboardShortcut) + // Quick Tab Selection (1–9) + ForEach(1 ... 9, id: \.self) { index in + Button("Tab \(index)") { + NotificationCenter.default.post( + name: .selectTabAtIndex, + object: NSApp.keyWindow, + userInfo: ["index": index] + ) + } + .keyboardShortcut(KeyboardShortcuts.Tabs.keyboardShortcut(for: index)) + } } - CommandGroup(after: .windowList) { - Button("Close Window") { - if let keyWindow = NSApp.keyWindow, keyWindow.title == "Settings" { - keyWindow.performClose(nil) - } + CommandGroup(replacing: .appInfo) { + Button("About Ora") { showAboutWindow() } + Button("Check for Updates") { + NotificationCenter.default.post( + name: .checkForUpdates, + object: NSApp.keyWindow + ) } - .keyboardShortcut("w", modifiers: .command) - .disabled({ - guard let keyWindow = NSApp.keyWindow else { return true } - return keyWindow.title != "Settings" - }()) } } + // MARK: - Utility Helpers + private func showAboutWindow() { let alert = NSAlert() alert.messageText = "Ora Browser" diff --git a/ora/OraRoot.swift b/ora/OraRoot.swift index f08652db..05d14523 100644 --- a/ora/OraRoot.swift +++ b/ora/OraRoot.swift @@ -13,7 +13,6 @@ final class PrivacyMode: ObservableObject { struct OraRoot: View { @StateObject private var appState = AppState() @StateObject private var keyModifierListener = KeyModifierListener() - @StateObject private var appearanceManager = AppearanceManager() @StateObject private var updateService = UpdateService() @StateObject private var mediaController: MediaController @StateObject private var tabManager: TabManager @@ -21,6 +20,8 @@ struct OraRoot: View { @StateObject private var downloadManager: DownloadManager @StateObject private var privacyMode: PrivacyMode @StateObject private var extensionManager = ExtensionManager.shared + @StateObject private var sidebarManager = SidebarManager() + @StateObject private var toolbarManager = ToolbarManager() let tabContext: ModelContext let historyContext: ModelContext @@ -73,6 +74,14 @@ struct OraRoot: View { var body: some View { BrowserView() .background(WindowReader(window: $window)) + .background( + WindowAccessor( + isFullscreen: Binding( + get: { appState.isFullscreen }, + set: { newValue in appState.isFullscreen = newValue } + ) + ) + ) .environment(\.window, window) .environmentObject(appState) .environmentObject(tabManager) @@ -80,11 +89,13 @@ struct OraRoot: View { .environmentObject(mediaController) .environmentObject(keyModifierListener) .environmentObject(CustomKeyboardShortcutManager.shared) - .environmentObject(appearanceManager) + .environmentObject(AppearanceManager.shared) .environmentObject(downloadManager) .environmentObject(updateService) .environmentObject(privacyMode) .environmentObject(extensionManager) + .environmentObject(sidebarManager) + .environmentObject(toolbarManager) .modelContext(tabContext) .modelContext(historyContext) .modelContext(downloadContext) @@ -134,12 +145,12 @@ struct OraRoot: View { } NotificationCenter.default.addObserver(forName: .toggleFullURL, object: nil, queue: .main) { note in guard note.object as? NSWindow === window ?? NSApp.keyWindow else { return } - appState.showFullURL.toggle() + toolbarManager.showFullURL.toggle() } NotificationCenter.default.addObserver(forName: .toggleToolbar, object: nil, queue: .main) { note in guard note.object as? NSWindow === window ?? NSApp.keyWindow else { return } withAnimation(.easeInOut(duration: 0.2)) { - appState.isToolbarHidden.toggle() + toolbarManager.isToolbarHidden.toggle() } } NotificationCenter.default.addObserver(forName: .reloadPage, object: nil, queue: .main) { note in @@ -171,7 +182,7 @@ struct OraRoot: View { if let raw = note.userInfo?["appearance"] as? String, let mode = AppAppearance(rawValue: raw) { - appearanceManager.appearance = mode + AppearanceManager.shared.appearance = mode } } NotificationCenter.default.addObserver(forName: .checkForUpdates, object: nil, queue: .main) { note in diff --git a/ora/Services/AppearanceManager.swift b/ora/Services/AppearanceManager.swift index 2a882a41..2b5cd3a1 100644 --- a/ora/Services/AppearanceManager.swift +++ b/ora/Services/AppearanceManager.swift @@ -10,19 +10,12 @@ enum AppAppearance: String, CaseIterable, Identifiable { class AppearanceManager: ObservableObject { static let shared = AppearanceManager() - @Published var appearance: AppAppearance { + @AppStorage("ui.app.appearance") var appearance: AppAppearance = .system { didSet { updateAppearance() - UserDefaults.standard.set(appearance.rawValue, forKey: "AppAppearance") } } - init() { - let saved = UserDefaults.standard.string(forKey: "AppAppearance") - self.appearance = AppAppearance(rawValue: saved ?? "") ?? .system - updateAppearance() - } - func updateAppearance() { guard NSApp != nil else { print("NSApp is nil, skipping appearance update") diff --git a/ora/Services/DefaultBrowserManager.swift b/ora/Services/DefaultBrowserManager.swift index 67fdc55a..d993b8da 100644 --- a/ora/Services/DefaultBrowserManager.swift +++ b/ora/Services/DefaultBrowserManager.swift @@ -5,10 +5,9 @@ // Created by keni on 9/30/25. // - -import CoreServices import AppKit import Combine +import CoreServices class DefaultBrowserManager: ObservableObject { static let shared = DefaultBrowserManager() @@ -38,7 +37,8 @@ class DefaultBrowserManager: ObservableObject { static func checkIsDefault() -> Bool { guard let testURL = URL(string: "http://example.com"), let appURL = NSWorkspace.shared.urlForApplication(toOpen: testURL), - let appBundle = Bundle(url: appURL) else { + let appBundle = Bundle(url: appURL) + else { return false } diff --git a/ora/Services/SidebarManager.swift b/ora/Services/SidebarManager.swift new file mode 100644 index 00000000..8ac6eb1e --- /dev/null +++ b/ora/Services/SidebarManager.swift @@ -0,0 +1,40 @@ +import SwiftUI + +enum SidebarPosition: String, Hashable { + case primary + case secondary +} + +@MainActor +class SidebarManager: ObservableObject { + @AppStorage("ui.sidebar.hidden") var isSidebarHidden: Bool = false + @AppStorage("ui.sidebar.position") var sidebarPosition: SidebarPosition = .primary + + @Published var primaryFraction = FractionHolder.usingUserDefaults(0.2, key: "ui.sidebar.fraction.primary") + @Published var secondaryFraction = FractionHolder.usingUserDefaults(0.2, key: "ui.sidebar.fraction.secondary") + @Published var hiddenSidebar = SideHolder.usingUserDefaults(key: "ui.sidebar.visibility") + + var currentFraction: FractionHolder { + sidebarPosition == .primary ? primaryFraction : secondaryFraction + } + + func updateSidebarHidden() { + isSidebarHidden = hiddenSidebar.side == .primary || hiddenSidebar.side == .secondary + } + + func toggleSidebar() { + let targetSide = sidebarPosition == .primary ? SplitSide.primary : .secondary + withAnimation(.spring(response: 0.2, dampingFraction: 1.0)) { + hiddenSidebar.side = (hiddenSidebar.side == targetSide) ? nil : targetSide + updateSidebarHidden() + } + } + + func toggleSidebarPosition() { + let isCurrentSidebarHidden = hiddenSidebar.side == (sidebarPosition == .primary ? .primary : .secondary) + sidebarPosition = sidebarPosition == .primary ? .secondary : .primary + if isCurrentSidebarHidden { + hiddenSidebar.side = sidebarPosition == .primary ? .primary : .secondary + } + } +} diff --git a/ora/Services/TabManager.swift b/ora/Services/TabManager.swift index f187c4c2..2b393dc6 100644 --- a/ora/Services/TabManager.swift +++ b/ora/Services/TabManager.swift @@ -2,7 +2,6 @@ import SwiftData import SwiftUI import WebKit - // MARK: - Tab Manager @MainActor @@ -12,26 +11,29 @@ class TabManager: ObservableObject { let modelContainer: ModelContainer let modelContext: ModelContext let mediaController: MediaController - + var recentTabs: [Tab] { guard let container = activeContainer else { return [] } - return Array(container.tabs.sorted { ($0.lastAccessedAt ?? Date.distantPast) > ($1.lastAccessedAt ?? Date.distantPast) }.prefix(SettingsStore.shared.maxRecentTabs)) + return Array(container.tabs + .sorted { ($0.lastAccessedAt ?? Date.distantPast) > ($1.lastAccessedAt ?? Date.distantPast) } + .prefix(SettingsStore.shared.maxRecentTabs) + ) } - + var tabsToRender: [Tab] { guard let container = activeContainer else { return [] } let specialTabs = container.tabs.filter { $0.type == .pinned || $0.type == .fav || $0.isPlayingMedia } let combined = Set(recentTabs + specialTabs) return Array(combined) } - + // Note: Could be made injectable via init parameter if preferred let tabSearchingService: TabSearchingProviding - + @Query(sort: \TabContainer.lastAccessedAt, order: .reverse) var containers: [TabContainer] - + private var cleanupTimer: Timer? - + init( modelContainer: ModelContainer, modelContext: ModelContext, @@ -42,16 +44,16 @@ class TabManager: ObservableObject { self.modelContext = modelContext self.mediaController = mediaController self.tabSearchingService = tabSearchingService - + self.modelContext.undoManager = UndoManager() initializeActiveContainerAndTab() - + // Start automatic cleanup timer (every minute) startCleanupTimer() } - + // MARK: - Public API's - + func search(_ text: String) -> [Tab] { tabSearchingService.search( text, @@ -59,7 +61,7 @@ class TabManager: ObservableObject { modelContext: modelContext ) } - + func openFromEngine( engineName: SearchEngineID, query: String, @@ -73,14 +75,14 @@ class TabManager: ObservableObject { openTab(url: url, historyManager: historyManager, isPrivate: isPrivate) } } - + func isActive(_ tab: Tab) -> Bool { if let activeTab = self.activeTab { return activeTab.id == tab.id } return false } - + func togglePinTab(_ tab: Tab) { if tab.type == .pinned { tab.type = .normal @@ -89,10 +91,10 @@ class TabManager: ObservableObject { tab.type = .pinned tab.savedURL = tab.url } - + try? modelContext.save() } - + func toggleFavTab(_ tab: Tab) { if tab.type == .fav { tab.type = .normal @@ -101,20 +103,21 @@ class TabManager: ObservableObject { tab.type = .fav tab.savedURL = tab.url } - + try? modelContext.save() } - + // MARK: - Container Public API's - + func moveTabToContainer(_ tab: Tab, toContainer: TabContainer) { tab.container = toContainer try? modelContext.save() } + private func initializeActiveContainerAndTab() { // Ensure containers are fetched let containers = fetchContainers() - + // Get the last accessed container if let lastAccessedContainer = containers.first { activeContainer = lastAccessedContainer @@ -131,10 +134,9 @@ class TabManager: ObservableObject { activeContainer = newContainer } } - + @discardableResult func createContainer(name: String = "Default", emoji: String = "β€’") -> TabContainer { - let newContainer = TabContainer(name: name, emoji: emoji) modelContext.insert(newContainer) activeContainer = newContainer @@ -143,25 +145,25 @@ class TabManager: ObservableObject { // _ = fetchContainers() // Refresh containers return newContainer } - + func renameContainer(_ container: TabContainer, name: String, emoji: String) { container.name = name container.emoji = emoji try? modelContext.save() } - + func deleteContainer(_ container: TabContainer) { modelContext.delete(container) } - + func activateContainer(_ container: TabContainer, activateLastAccessedTab: Bool = true) { activeContainer = container container.lastAccessedAt = Date() - + // Set the most recently accessed tab in the container if let lastAccessedTab = container.tabs .sorted(by: { $0.lastAccessedAt ?? Date() > $1.lastAccessedAt ?? Date() }).first, - lastAccessedTab.isWebViewReady + lastAccessedTab.isWebViewReady { activeTab?.maybeIsActive = false activeTab = lastAccessedTab @@ -170,12 +172,12 @@ class TabManager: ObservableObject { } else { activeTab = nil } - + try? modelContext.save() } - + // MARK: - Tab Public API's - + func addTab( title: String = "Untitled", /// Will Always Work @@ -210,10 +212,13 @@ class TabManager: ObservableObject { activeTab?.maybeIsActive = true newTab.lastAccessedAt = Date() container.lastAccessedAt = Date() - + // Initialize the WebView for the new active tab newTab.restoreTransientState( - historyManager: historyManager ?? HistoryManager(modelContainer: modelContainer, modelContext: modelContext), + historyManager: historyManager ?? HistoryManager( + modelContainer: modelContainer, + modelContext: modelContext + ), downloadManager: downloadManager ?? DownloadManager( modelContainer: modelContainer, modelContext: modelContext @@ -221,11 +226,11 @@ class TabManager: ObservableObject { tabManager: self, isPrivate: isPrivate ) - + try? modelContext.save() return newTab } - + func openTab( url: URL, historyManager: HistoryManager, @@ -236,9 +241,9 @@ class TabManager: ObservableObject { if let container = activeContainer { if let host = url.host { let faviconURL = URL(string: "https://www.google.com/s2/favicons?domain=\(host)") - + let cleanHost = host.hasPrefix("www.") ? String(host.dropFirst(4)) : host - + let newTab = Tab( url: url, title: cleanHost, @@ -254,13 +259,13 @@ class TabManager: ObservableObject { ) modelContext.insert(newTab) container.tabs.append(newTab) - + if focusAfterOpening { activeTab?.maybeIsActive = false activeTab = newTab activeTab?.maybeIsActive = true newTab.lastAccessedAt = Date() - + // Initialize the WebView for the new active tab newTab.restoreTransientState( historyManager: historyManager, @@ -272,23 +277,23 @@ class TabManager: ObservableObject { isPrivate: isPrivate ) } - + container.lastAccessedAt = Date() try? modelContext.save() } } } - + func reorderTabs(from: Tab, toTab: Tab) { from.container.reorderTabs(from: from, to: toTab) try? modelContext.save() } - + func switchSections(from: Tab, toTab: Tab) { from.switchSections(from: from, to: toTab) try? modelContext.save() } - + func closeTab(tab: Tab) { // If the closed tab was active, select another tab if self.activeTab?.id == tab.id { @@ -298,7 +303,7 @@ class TabManager: ObservableObject { .first { self.activateTab(nextTab) - + // } else if let nextContainer = containers.first(where: { $0.id != tab.container.id }) { // self.activateContainer(nextContainer) // @@ -333,7 +338,7 @@ class TabManager: ObservableObject { } self.activeTab?.maybeIsActive = true } - + func closeActiveTab() { if let tab = activeTab { closeTab(tab: tab) @@ -341,13 +346,13 @@ class TabManager: ObservableObject { NSApp.keyWindow?.close() } } - + func restoreLastTab() { guard let undoManager = modelContext.undoManager else { return } undoManager.undo() // Reverts the last deletion try? modelContext.save() // Persist the undo operation } - + func activateTab(_ tab: Tab) { activeTab?.maybeIsActive = false activeTab = tab @@ -355,12 +360,18 @@ class TabManager: ObservableObject { tab.lastAccessedAt = Date() activeContainer = tab.container tab.container.lastAccessedAt = Date() - + // Lazy load WebView if not ready if !tab.isWebViewReady { tab.restoreTransientState( - historyManager: tab.historyManager ?? HistoryManager(modelContainer: modelContainer, modelContext: modelContext), - downloadManager: tab.downloadManager ?? DownloadManager(modelContainer: modelContainer, modelContext: modelContext), + historyManager: tab.historyManager ?? HistoryManager( + modelContainer: modelContainer, + modelContext: modelContext + ), + downloadManager: tab.downloadManager ?? DownloadManager( + modelContainer: modelContainer, + modelContext: modelContext + ), tabManager: self, isPrivate: tab.isPrivate ) @@ -370,42 +381,42 @@ class TabManager: ObservableObject { try? modelContext.save() // Note: Controller API has no setActive; skipping explicit activation. } - + /// Clean up old tabs that haven't been accessed recently to preserve memory func cleanupOldTabs() { let timeout = SettingsStore.shared.tabAliveTimeout // Skip cleanup if set to "Never" (365 days) guard timeout < 365 * 24 * 60 * 60 else { return } - + let allContainers = fetchContainers() for container in allContainers { for tab in container.tabs { - if !tab.isAlive && tab.isWebViewReady && tab.id != activeTab?.id && !tab.isPlayingMedia && tab.type == .normal { + if !tab.isAlive, tab.isWebViewReady, tab.id != activeTab?.id, !tab.isPlayingMedia, tab.type == .normal { tab.destroyWebView() } } } } - + /// Completely remove old normal tabs that haven't been accessed for a long time func removeOldTabs() { let cutoffDate = Date().addingTimeInterval(-SettingsStore.shared.tabRemovalTimeout) let allContainers = fetchContainers() - + for container in allContainers { for tab in container.tabs { if let lastAccessed = tab.lastAccessedAt, lastAccessed < cutoffDate, tab.id != activeTab?.id, !tab.isPlayingMedia, - tab.type == .normal { + tab.type == .normal + { closeTab(tab: tab) } } } } - - + /// Start the automatic cleanup timer private func startCleanupTimer() { cleanupTimer = Timer.scheduledTimer(withTimeInterval: 60.0, repeats: true) { [weak self] _ in @@ -415,11 +426,11 @@ class TabManager: ObservableObject { } } } - + deinit { cleanupTimer?.invalidate() } - + // Activate a tab by its persistent id. If the tab is in a // different container, also activate that container. func activateTab(id: UUID) { @@ -432,38 +443,37 @@ class TabManager: ObservableObject { } } } - - + func selectTabAtIndex(_ index: Int) { guard let container = activeContainer else { return } - + // Match the sidebar ordering: favorites, then pinned, then normal tabs // All sorted by order in descending order let favoriteTabs = container.tabs .filter { $0.type == .fav } .sorted(by: { $0.order > $1.order }) - + let pinnedTabs = container.tabs .filter { $0.type == .pinned } .sorted(by: { $0.order > $1.order }) - + let normalTabs = container.tabs .filter { $0.type == .normal } .sorted(by: { $0.order > $1.order }) - + // Combine all tabs in the same order as the sidebar let allTabs = favoriteTabs + pinnedTabs + normalTabs - + // Handle special case: Command+9 selects the last tab let targetIndex = (index == 9) ? allTabs.count - 1 : index - 1 - + // Validate index is within bounds guard targetIndex >= 0, targetIndex < allTabs.count else { return } - + let targetTab = allTabs[targetIndex] activateTab(targetTab) } - + private func fetchContainers() -> [TabContainer] { do { let descriptor = FetchDescriptor(sortBy: [SortDescriptor(\.lastAccessedAt, order: .reverse)]) @@ -473,7 +483,7 @@ class TabManager: ObservableObject { } return [] } - + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { if message.name == "listener", let url = message.body as? String @@ -512,7 +522,7 @@ final class TabSearchingService: TabSearchingProviding { ) -> [Tab] { let activeContainerId = activeContainer?.id ?? UUID() let trimmedText = text.trimmingCharacters(in: .whitespaces) - + let predicate: Predicate if trimmedText.isEmpty { predicate = #Predicate { _ in true } @@ -520,49 +530,49 @@ final class TabSearchingService: TabSearchingProviding { predicate = #Predicate { tab in ( tab.urlString.localizedStandardContains(trimmedText) || - tab.title + tab.title .localizedStandardContains( trimmedText ) ) && tab.container.id == activeContainerId } } - + let descriptor = FetchDescriptor(predicate: predicate) - + do { let results = try modelContext.fetch(descriptor) let now = Date() - + return results.sorted { result1, result2 in let result1Score = combinedScore(for: result1, query: trimmedText, now: now) let result2Score = combinedScore(for: result2, query: trimmedText, now: now) return result1Score > result2Score } - + } catch { return [] } } - + private func combinedScore(for tab: Tab, query: String, now: Date) -> Double { let match = scoreMatch(tab, text: query) - + let timeInterval: TimeInterval = if let accessedAt = tab.lastAccessedAt { now.timeIntervalSince(accessedAt) } else { 1_000_000 // far in the past β†’ lowest recency } - + let recencyBoost = max(0, 1_000_000 - timeInterval) return Double(match * 1000) + recencyBoost } - + private func scoreMatch(_ tab: Tab, text: String) -> Int { let text = text.lowercased() let title = tab.title.lowercased() let url = tab.urlString.lowercased() - + func score(_ field: String) -> Int { if field == text { return 100 } if field.hasPrefix(text) { return 90 } @@ -570,7 +580,7 @@ final class TabSearchingService: TabSearchingProviding { if text.contains(field) { return 50 } return 0 } - + return max(score(title), score(url)) } } diff --git a/ora/Services/ToolbarManager.swift b/ora/Services/ToolbarManager.swift new file mode 100644 index 00000000..aa1405f0 --- /dev/null +++ b/ora/Services/ToolbarManager.swift @@ -0,0 +1,7 @@ +import Foundation +import SwiftUI + +class ToolbarManager: ObservableObject { + @AppStorage("ui.toolbar.hidden") var isToolbarHidden: Bool = false + @AppStorage("ui.toolbar.showfullurl") var showFullURL: Bool = true +} diff --git a/ora/UI/Buttons/URLBarButton.swift b/ora/UI/Buttons/URLBarButton.swift index da6d545b..eaeefec0 100644 --- a/ora/UI/Buttons/URLBarButton.swift +++ b/ora/UI/Buttons/URLBarButton.swift @@ -17,7 +17,7 @@ struct URLBarButton: View { ) .frame(width: 30, height: 30) .background( - RoundedRectangle(cornerRadius: 6) + ConditionallyConcentricRectangle(cornerRadius: 6) .fill(isHovering && isEnabled ? foregroundColor.opacity(0.2) : Color.clear) ) } diff --git a/ora/UI/EmptyFavTabItem.swift b/ora/UI/EmptyFavTabItem.swift index cb315dc2..7f3baa09 100644 --- a/ora/UI/EmptyFavTabItem.swift +++ b/ora/UI/EmptyFavTabItem.swift @@ -4,6 +4,8 @@ struct EmptyFavTabItem: View { @Environment(\.theme) var theme @State private var isTargeted = false + let cornerRadius: CGFloat = 8 + var body: some View { VStack(spacing: 8) { Image(systemName: "star") @@ -18,9 +20,9 @@ struct EmptyFavTabItem: View { .frame(maxWidth: .infinity, alignment: .center) .frame(height: 96) .background(theme.invertedSolidWindowBackgroundColor.opacity(0.07)) - .cornerRadius(10) + .cornerRadius(cornerRadius) .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) + ConditionallyConcentricRectangle(cornerRadius: cornerRadius) .stroke( theme.invertedSolidWindowBackgroundColor.opacity(0.25), style: StrokeStyle(lineWidth: 1, dash: [5, 5]) diff --git a/ora/UI/HomeView.swift b/ora/UI/HomeView.swift index 5d1e9fad..e7cf2f85 100644 --- a/ora/UI/HomeView.swift +++ b/ora/UI/HomeView.swift @@ -1,8 +1,8 @@ import SwiftUI struct HomeView: View { - let sidebarToggle: () -> Void @Environment(\.theme) var theme + @EnvironmentObject private var sidebarManager: SidebarManager var body: some View { ZStack(alignment: .top) { @@ -15,15 +15,19 @@ struct HomeView: View { BlurEffectView(material: .underWindowBackground, blendingMode: .behindWindow) ) - URLBarButton( - systemName: "sidebar.left", - isEnabled: true, - foregroundColor: theme.foreground.opacity(0.3), - action: { sidebarToggle() } - ) - .oraShortcut(KeyboardShortcuts.App.toggleSidebar) - .position(x: 20, y: 20) + HStack { + URLBarButton( + systemName: "sidebar.left", + isEnabled: true, + foregroundColor: theme.foreground.opacity(0.3), + action: { sidebarManager.toggleSidebar() } + ) + .oraShortcut(KeyboardShortcuts.App.toggleSidebar) + } .zIndex(3) + .frame(maxWidth: .infinity, alignment: sidebarManager.sidebarPosition == .primary ? .leading : .trailing) + .padding(6) + .ignoresSafeArea(.all) VStack(alignment: .center, spacing: 16) { Image("ora-logo-plain") diff --git a/ora/UI/NSPageView.swift b/ora/UI/NSPageView.swift index fcdb3d17..f4e204b5 100644 --- a/ora/UI/NSPageView.swift +++ b/ora/UI/NSPageView.swift @@ -5,7 +5,7 @@ import SwiftUI import os.log private let logger = Logger( - subsystem: "com.juniperphoton.photonutilityview", category: "NSPageView" + subsystem: "com.orabrowser.app", category: "NSPageView" ) /// A ``NSViewControllerRepresentable`` for showing ``NSPageController``. @@ -48,13 +48,9 @@ import SwiftUI return contentView(object) } controller.idToObject = { [weak controller] id in - // Should apply weak reference to the controller to prevent circle causing memory leak. guard let controller else { return nil } - // We should refer to controller.pageObjects to get the updated objects, in which controller is a - // reference type. - // Since NSPageView is a struct type, which can't be captured in the block. return controller.pageObjects.first { page in let pageId = page[keyPath: idKeyPath] return pageId == id @@ -137,8 +133,6 @@ import SwiftUI super.viewDidLoad() self.delegate = self self.updateDataSource() - - // Configure the page controller to handle horizontal gestures with higher priority self.view.wantsLayer = true } @@ -148,9 +142,6 @@ import SwiftUI view.frame = self.view.bounds } - // When our container changes size due to SwiftUI layout (e.g., Split resizing), - // NSPageController may not immediately resize its current page until a transition occurs. - // Force-complete the transition so the current page view adopts the new bounds. let currentSize = self.view.bounds.size if currentSize != previousBoundsSize { previousBoundsSize = currentSize @@ -213,7 +204,7 @@ import SwiftUI var content: ((T) -> V)? var object: T? - private var hostingView: NSHostingView? + private var hostingView: NSHostingView? override func loadView() { self.view = NSView() @@ -226,7 +217,8 @@ import SwiftUI return } let view = content(object) - let hostingView = NSHostingView(rootView: view) + let wrappedView = AnyView(view.ignoresSafeArea()) + let hostingView = NSHostingView(rootView: wrappedView) hostingView.translatesAutoresizingMaskIntoConstraints = false self.view.addSubview(hostingView) NSLayoutConstraint.activate([ diff --git a/ora/UI/URLBar.swift b/ora/UI/URLBar.swift index 7817ad27..cb765087 100644 --- a/ora/UI/URLBar.swift +++ b/ora/UI/URLBar.swift @@ -73,6 +73,8 @@ struct URLBar: View { @EnvironmentObject var tabManager: TabManager @EnvironmentObject var appState: AppState @EnvironmentObject var extensionManager: ExtensionManager + @EnvironmentObject var sidebarManager: SidebarManager + @EnvironmentObject var toolbarManager: ToolbarManager @State private var showCopiedAnimation = false @State private var startWheelAnimation = false @@ -114,7 +116,7 @@ struct URLBar: View { } private func getDisplayURL(_ tab: Tab) -> String { - if appState.showFullURL { + if toolbarManager.showFullURL { return tab.url.absoluteString } else { return tab.url.host ?? tab.url.absoluteString @@ -157,13 +159,19 @@ struct URLBar: View { HStack { if let tab = tabManager.activeTab { HStack(spacing: 4) { - URLBarButton( - systemName: "sidebar.left", - isEnabled: true, - foregroundColor: buttonForegroundColor, - action: onSidebarToggle - ) - .oraShortcutHelp("Toggle Sidebar", for: KeyboardShortcuts.App.toggleSidebar) + if toolbarManager.isToolbarHidden || sidebarManager.sidebarPosition == .secondary { + WindowControls(isFullscreen: appState.isFullscreen) + } + + if sidebarManager.sidebarPosition == .primary { + URLBarButton( + systemName: "sidebar.left", + isEnabled: true, + foregroundColor: buttonForegroundColor, + action: onSidebarToggle + ) + .oraShortcutHelp("Toggle Sidebar", for: KeyboardShortcuts.App.toggleSidebar) + } // Back button URLBarButton( @@ -292,7 +300,7 @@ struct URLBar: View { .padding(.horizontal, 8) .background( RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(getUrlFieldColor(tab).opacity(0.12)) + .fill(getUrlFieldColor(tab).opacity(0.08)) .overlay( RoundedRectangle(cornerRadius: 6, style: .continuous) .stroke( @@ -332,6 +340,16 @@ struct URLBar: View { foregroundColor: buttonForegroundColor, action: {} ) + + if sidebarManager.sidebarPosition == .secondary { + URLBarButton( + systemName: "sidebar.right", + isEnabled: true, + foregroundColor: buttonForegroundColor, + action: onSidebarToggle + ) + .oraShortcutHelp("Toggle Sidebar", for: KeyboardShortcuts.App.toggleSidebar) + } } .padding(4) .onAppear { @@ -345,7 +363,7 @@ struct URLBar: View { editingURLString = getDisplayURL(tab) } } - .onChange(of: appState.showFullURL) { _, _ in + .onChange(of: toolbarManager.showFullURL) { _, _ in if !isEditing, let tab = tabManager.activeTab { editingURLString = getDisplayURL(tab) } diff --git a/ora/UI/WindowControls.swift b/ora/UI/WindowControls.swift new file mode 100644 index 00000000..0d8c0f29 --- /dev/null +++ b/ora/UI/WindowControls.swift @@ -0,0 +1,71 @@ +import AppKit +import SwiftUI + +enum WindowControlType { + case close, minimize, zoom +} + +struct WindowControls: View { + @State private var isHovered = false + let isFullscreen: Bool + + var body: some View { + if !isFullscreen { + HStack(spacing: 9) { + WindowControlButton(type: .close, isHovered: $isHovered) + WindowControlButton(type: .minimize, isHovered: $isHovered) + WindowControlButton(type: .zoom, isHovered: $isHovered) + } + .padding(.horizontal, 8) + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.1)) { + isHovered = hovering + } + } + } else { + EmptyView() + } + } +} + +struct WindowControlButton: View { + let type: WindowControlType + @Binding var isHovered: Bool + + private var buttonSize: CGFloat { + if #available(macOS 26.0, *) { + return 14 + } else { + return 12 + } + } + + private var assetBaseName: String { + switch type { + case .close: return "close" + case .minimize: return "minimize" + case .zoom: return "maximize" + } + } + + var body: some View { + Image(isHovered ? "\(assetBaseName)-hover" : "\(assetBaseName)-normal") + .resizable() + .frame(width: buttonSize, height: buttonSize) + .onTapGesture { + performAction() + } + } + + private func performAction() { + guard let window = NSApp.keyWindow else { return } + switch type { + case .close: + window.performClose(nil) + case .minimize: + window.performMiniaturize(nil) + case .zoom: + window.toggleFullScreen(nil) + } + } +} diff --git a/ora/WindowControls.xcassets/Contents.json b/ora/WindowControls.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/ora/WindowControls.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ora/WindowControls.xcassets/close-hover.imageset/Close Hover Icon.svg b/ora/WindowControls.xcassets/close-hover.imageset/Close Hover Icon.svg new file mode 100644 index 00000000..c7460baf --- /dev/null +++ b/ora/WindowControls.xcassets/close-hover.imageset/Close Hover Icon.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/ora/WindowControls.xcassets/close-hover.imageset/Contents.json b/ora/WindowControls.xcassets/close-hover.imageset/Contents.json new file mode 100644 index 00000000..7d0673a5 --- /dev/null +++ b/ora/WindowControls.xcassets/close-hover.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Close Hover Icon.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ora/WindowControls.xcassets/close-normal.imageset/Contents.json b/ora/WindowControls.xcassets/close-normal.imageset/Contents.json new file mode 100644 index 00000000..98a7e010 --- /dev/null +++ b/ora/WindowControls.xcassets/close-normal.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "close-normal.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ora/WindowControls.xcassets/close-normal.imageset/close-normal.svg b/ora/WindowControls.xcassets/close-normal.imageset/close-normal.svg new file mode 100644 index 00000000..193b6649 --- /dev/null +++ b/ora/WindowControls.xcassets/close-normal.imageset/close-normal.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ora/WindowControls.xcassets/maximize-hover.imageset/Contents.json b/ora/WindowControls.xcassets/maximize-hover.imageset/Contents.json new file mode 100644 index 00000000..5dc51319 --- /dev/null +++ b/ora/WindowControls.xcassets/maximize-hover.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "maximize-hover.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ora/WindowControls.xcassets/maximize-hover.imageset/maximize-hover.svg b/ora/WindowControls.xcassets/maximize-hover.imageset/maximize-hover.svg new file mode 100644 index 00000000..3e0cfdff --- /dev/null +++ b/ora/WindowControls.xcassets/maximize-hover.imageset/maximize-hover.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/ora/WindowControls.xcassets/maximize-normal.imageset/Contents.json b/ora/WindowControls.xcassets/maximize-normal.imageset/Contents.json new file mode 100644 index 00000000..63e19945 --- /dev/null +++ b/ora/WindowControls.xcassets/maximize-normal.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "maximize-normal.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ora/WindowControls.xcassets/maximize-normal.imageset/maximize-normal.svg b/ora/WindowControls.xcassets/maximize-normal.imageset/maximize-normal.svg new file mode 100644 index 00000000..bc189e8c --- /dev/null +++ b/ora/WindowControls.xcassets/maximize-normal.imageset/maximize-normal.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ora/WindowControls.xcassets/minimize-hover.imageset/Contents.json b/ora/WindowControls.xcassets/minimize-hover.imageset/Contents.json new file mode 100644 index 00000000..cd7f007d --- /dev/null +++ b/ora/WindowControls.xcassets/minimize-hover.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "minimize-hover.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ora/WindowControls.xcassets/minimize-hover.imageset/minimize-hover.svg b/ora/WindowControls.xcassets/minimize-hover.imageset/minimize-hover.svg new file mode 100644 index 00000000..b16697b3 --- /dev/null +++ b/ora/WindowControls.xcassets/minimize-hover.imageset/minimize-hover.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/ora/WindowControls.xcassets/minimize-normal.imageset/Contents.json b/ora/WindowControls.xcassets/minimize-normal.imageset/Contents.json new file mode 100644 index 00000000..70e4a6a8 --- /dev/null +++ b/ora/WindowControls.xcassets/minimize-normal.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "minimize-normal.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ora/WindowControls.xcassets/minimize-normal.imageset/minimize-normal.svg b/ora/WindowControls.xcassets/minimize-normal.imageset/minimize-normal.svg new file mode 100644 index 00000000..8200155c --- /dev/null +++ b/ora/WindowControls.xcassets/minimize-normal.imageset/minimize-normal.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ora/WindowControls.xcassets/no-focus.imageset/Contents.json b/ora/WindowControls.xcassets/no-focus.imageset/Contents.json new file mode 100644 index 00000000..9338045c --- /dev/null +++ b/ora/WindowControls.xcassets/no-focus.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "no-focus.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ora/WindowControls.xcassets/no-focus.imageset/no-focus.svg b/ora/WindowControls.xcassets/no-focus.imageset/no-focus.svg new file mode 100644 index 00000000..b5642243 --- /dev/null +++ b/ora/WindowControls.xcassets/no-focus.imageset/no-focus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ora/oraApp.swift b/ora/oraApp.swift index 2197ae88..7a2df56e 100644 --- a/ora/oraApp.swift +++ b/ora/oraApp.swift @@ -9,6 +9,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { NSWindow.allowsAutomaticWindowTabbing = false AppearanceManager.shared.updateAppearance() } + func application(_ application: NSApplication, open urls: [URL]) { handleIncomingURLs(urls) } @@ -22,6 +23,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { } return WindowFactory.makeMainWindow(rootView: OraRoot()) } + func handleIncomingURLs(_ urls: [URL]) { let window = getWindow()! for url in urls { @@ -50,20 +52,17 @@ class AppState: ObservableObject { @Published var launcherSearchText: String = "" @Published var showFinderIn: UUID? @Published var isFloatingTabSwitchVisible: Bool = false - @Published var isToolbarHidden: Bool = false - @Published var showFullURL: Bool = (UserDefaults.standard.object(forKey: "showFullURL") as? Bool) ?? true { - didSet { UserDefaults.standard.set(showFullURL, forKey: "showFullURL") } - } + @Published var isFullscreen: Bool = false } @main struct OraApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate - + // Shared model container that uses the same configuration as the main browser private let sharedModelContainer: ModelContainer? = - try? ModelConfiguration.createOraContainer(isPrivate: false) - + try? ModelConfiguration.createOraContainer(isPrivate: false) + var body: some Scene { WindowGroup(id: "normal") { OraRoot() @@ -74,7 +73,7 @@ struct OraApp: App { .windowStyle(.hiddenTitleBar) .windowResizability(.contentMinSize) .handlesExternalEvents(matching: []) - + WindowGroup("Private", id: "private") { OraRoot(isPrivate: true) .frame(minWidth: 500, minHeight: 360) @@ -84,7 +83,7 @@ struct OraApp: App { .windowStyle(.hiddenTitleBar) .windowResizability(.contentMinSize) .handlesExternalEvents(matching: []) - + Settings { if let sharedModelContainer { SettingsContentView() From fa120865e1367e5e67a6421763f6e28fe4958cbd Mon Sep 17 00:00:00 2001 From: yonaries Date: Mon, 13 Oct 2025 22:43:36 +0300 Subject: [PATCH 29/38] added swiftformat --- .github/workflows/build-and-test.yml | 51 +++++++--------------------- 1 file changed, 12 insertions(+), 39 deletions(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index bf4ec875..bc2652e4 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -1,4 +1,4 @@ -name: Lint and Formating +name: Lint and Formatting on: push: @@ -17,6 +17,7 @@ jobs: steps: - name: Check architecture run: uname -m + - name: Checkout code uses: actions/checkout@v4 @@ -35,11 +36,17 @@ jobs: - name: Generate Xcode project run: xcodegen - - name: Install Swiftlint + - name: Install SwiftLint run: brew install swiftlint - - name: Run Swiftlint - run: swiftlint lint . --quiet + - name: Run SwiftLint + run: swiftlint lint + + - name: Install SwiftFormat + run: brew install swiftformat + + - name: Check code formatting + run: swiftformat --lint . - name: Install Xcbeautify run: brew install xcbeautify @@ -58,38 +65,4 @@ jobs: ${{ runner.os }}-spm- - name: Resolve dependencies - run: xcodebuild -resolvePackageDependencies - - # - name: Build project - # run: | - # chmod +x xcbuild-debug.sh - # ./xcbuild-debug.sh - - # - name: Run tests - # run: | - # xcodebuild test \ - # -scheme ora \ - # -destination "platform=macOS" \ - # -configuration Debug \ - # CODE_SIGN_IDENTITY="" \ - # CODE_SIGNING_REQUIRED=NO \ - # -enableCodeCoverage YES \ - # -resultBundlePath TestResults - - # - name: Upload test results - # uses: actions/upload-artifact@v4 - # if: always() - # with: - # name: test-results - # path: TestResults.xcresult - - # - name: Generate code coverage report - # run: | - # xcrun xccov view --report --json TestResults.xcresult > coverage.json - # xcrun xccov view --report TestResults.xcresult - - # - name: Upload coverage reports to Codecov - # uses: codecov/codecov-action@v3 - # with: - # files: ./coverage.json - # fail_ci_if_error: false \ No newline at end of file + run: xcodebuild -resolvePackageDependencies \ No newline at end of file From eacb78ffe1070e4533ddd206b6b5cce383cd8870 Mon Sep 17 00:00:00 2001 From: Kenenisa Alemayehu <53818162+kenenisa@users.noreply.github.com> Date: Mon, 13 Oct 2025 21:42:17 +0300 Subject: [PATCH 30/38] feat: brew release cask automation --- .github/workflows/brew-release.yml | 42 ++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/workflows/brew-release.yml diff --git a/.github/workflows/brew-release.yml b/.github/workflows/brew-release.yml new file mode 100644 index 00000000..32f7f2f6 --- /dev/null +++ b/.github/workflows/brew-release.yml @@ -0,0 +1,42 @@ +name: Update Homebrew Cask + +on: + release: + types: [published] + +jobs: + update-cask: + runs-on: ubuntu-latest + steps: + - name: Checkout homebrew-ora repo + uses: actions/checkout@v4 + with: + repository: the-ora/homebrew-ora + token: ${{ secrets.HOMEBREW_PAT }} + ref: main + + - name: Download Ora.dmg from release + run: | + VERSION="${{ github.event.release.tag_name }}" + STRIPPED_VERSION=$(echo "$VERSION" | sed 's/^v//') + DOWNLOAD_URL="https://github.com/the-ora/browser/releases/download/${VERSION}/Ora.dmg" + curl -L -o Ora.dmg "$DOWNLOAD_URL" + + - name: Compute SHA256 + run: | + SHA256=$(shasum -a 256 Ora.dmg | awk '{print $1}') + echo "SHA256=$SHA256" >> $GITHUB_ENV + echo "STRIPPED_VERSION=${STRIPPED_VERSION:-$(echo '${{ github.event.release.tag_name }}' | sed 's/^v//')}" >> $GITHUB_ENV + + - name: Update ora.rb + run: | + sed -i "s/version \\".*\\"/version \\"${{ env.STRIPPED_VERSION }}\\"/g" ora.rb + sed -i "s/sha256 \\".*\\"/sha256 \\"${{ env.SHA256 }}\\"/g" ora.rb + + - name: Commit and push changes + run: | + git config user.name "GitHub Actions" + git config user.email "actions@github.com" + git add ora.rb + git commit -m "Update Ora cask to ${{ env.STRIPPED_VERSION }} (SHA256: ${{ env.SHA256 }})" || exit 0 # Exit 0 if no changes + git push \ No newline at end of file From 34e1aeacff4bb9beccd7056a451ddc11122d2885 Mon Sep 17 00:00:00 2001 From: Kenenisa Alemayehu <53818162+kenenisa@users.noreply.github.com> Date: Mon, 13 Oct 2025 21:44:13 +0300 Subject: [PATCH 31/38] fix: update GitHub token in brew release workflow --- .github/workflows/brew-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/brew-release.yml b/.github/workflows/brew-release.yml index 32f7f2f6..add4bdcd 100644 --- a/.github/workflows/brew-release.yml +++ b/.github/workflows/brew-release.yml @@ -12,7 +12,7 @@ jobs: uses: actions/checkout@v4 with: repository: the-ora/homebrew-ora - token: ${{ secrets.HOMEBREW_PAT }} + token: ${{ secrets.GITHUB_TOKEN }} ref: main - name: Download Ora.dmg from release From 76b62e5dc99d722a13612417b965dcb3ec930216 Mon Sep 17 00:00:00 2001 From: Kenenisa Alemayehu <53818162+kenenisa@users.noreply.github.com> Date: Mon, 13 Oct 2025 21:48:47 +0300 Subject: [PATCH 32/38] feat: enhance brew release workflow with optional inputs for version and DMG URL --- .github/workflows/brew-release.yml | 42 +++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/.github/workflows/brew-release.yml b/.github/workflows/brew-release.yml index add4bdcd..2d0a99f0 100644 --- a/.github/workflows/brew-release.yml +++ b/.github/workflows/brew-release.yml @@ -3,6 +3,18 @@ name: Update Homebrew Cask on: release: types: [published] + workflow_dispatch: + inputs: + version: + description: 'Version for testing (e.g., 0.2.5; omit "v" prefix)' + required: false + type: string + default: '' + dmg_url: + description: 'Full DMG download URL for testing (e.g., https://github.com/the-ora/browser/releases/download/v0.2.5/Ora.dmg)' + required: false + type: string + default: '' jobs: update-cask: @@ -13,20 +25,28 @@ jobs: with: repository: the-ora/homebrew-ora token: ${{ secrets.GITHUB_TOKEN }} - ref: main + ref: main # Change to your default branch if not 'main' (e.g., 'master') - - name: Download Ora.dmg from release + - name: Download Ora.dmg run: | - VERSION="${{ github.event.release.tag_name }}" - STRIPPED_VERSION=$(echo "$VERSION" | sed 's/^v//') - DOWNLOAD_URL="https://github.com/the-ora/browser/releases/download/${VERSION}/Ora.dmg" - curl -L -o Ora.dmg "$DOWNLOAD_URL" + if [ -n "${{ github.event.inputs.dmg_url }}" ]; then + DOWNLOAD_URL="${{ github.event.inputs.dmg_url }}" + else + VERSION="${{ github.event.release.tag_name }}" + STRIPPED_VERSION=$(echo "$VERSION" | sed 's/^v//') + DOWNLOAD_URL="https://github.com/the-ora/browser/releases/download/${VERSION}/Ora.dmg" + fi + curl -L --fail -o Ora.dmg "$DOWNLOAD_URL" - name: Compute SHA256 run: | SHA256=$(shasum -a 256 Ora.dmg | awk '{print $1}') echo "SHA256=$SHA256" >> $GITHUB_ENV - echo "STRIPPED_VERSION=${STRIPPED_VERSION:-$(echo '${{ github.event.release.tag_name }}' | sed 's/^v//')}" >> $GITHUB_ENV + if [ -n "${{ github.event.inputs.version }}" ]; then + echo "STRIPPED_VERSION=${{ github.event.inputs.version }}" >> $GITHUB_ENV + else + echo "STRIPPED_VERSION=$(echo '${{ github.event.release.tag_name }}' | sed 's/^v//')" >> $GITHUB_ENV + fi - name: Update ora.rb run: | @@ -38,5 +58,9 @@ jobs: git config user.name "GitHub Actions" git config user.email "actions@github.com" git add ora.rb - git commit -m "Update Ora cask to ${{ env.STRIPPED_VERSION }} (SHA256: ${{ env.SHA256 }})" || exit 0 # Exit 0 if no changes - git push \ No newline at end of file + if git diff --staged --quiet; then + echo "No changes to commit" + else + git commit -m "Update Ora cask to ${{ env.STRIPPED_VERSION }} (SHA256: ${{ env.SHA256 }})" + git push + fi \ No newline at end of file From e715534e7648c3314a27c7a1b06911c9ae36c1f6 Mon Sep 17 00:00:00 2001 From: Kenenisa Alemayehu <53818162+kenenisa@users.noreply.github.com> Date: Mon, 13 Oct 2025 22:04:12 +0300 Subject: [PATCH 33/38] fix: sed for ora.rb --- .github/workflows/brew-release.yml | 32 +++++++++++++++++++----------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/.github/workflows/brew-release.yml b/.github/workflows/brew-release.yml index 2d0a99f0..986642f0 100644 --- a/.github/workflows/brew-release.yml +++ b/.github/workflows/brew-release.yml @@ -25,33 +25,41 @@ jobs: with: repository: the-ora/homebrew-ora token: ${{ secrets.GITHUB_TOKEN }} - ref: main # Change to your default branch if not 'main' (e.g., 'master') + ref: main - - name: Download Ora.dmg + - name: Download Ora.dmg and set version run: | - if [ -n "${{ github.event.inputs.dmg_url }}" ]; then - DOWNLOAD_URL="${{ github.event.inputs.dmg_url }}" + EVENT_NAME="${{ github.event_name }}" + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + if [ -n "${{ github.event.inputs.dmg_url }}" ]; then + DOWNLOAD_URL="${{ github.event.inputs.dmg_url }}" + else + echo "DMG URL is required for manual dispatch" + exit 1 + fi + if [ -n "${{ github.event.inputs.version }}" ]; then + STRIPPED_VERSION="${{ github.event.inputs.version }}" + else + echo "Version is required for manual dispatch" + exit 1 + fi else VERSION="${{ github.event.release.tag_name }}" STRIPPED_VERSION=$(echo "$VERSION" | sed 's/^v//') - DOWNLOAD_URL="https://github.com/the-ora/browser/releases/download/${VERSION}/Ora.dmg" + DOWNLOAD_URL="https://github.com/the-ora/browser/releases/download/${VERSION}/Ora-Browser-${STRIPPED_VERSION}.dmg" fi + echo "STRIPPED_VERSION=$STRIPPED_VERSION" >> $GITHUB_ENV curl -L --fail -o Ora.dmg "$DOWNLOAD_URL" - name: Compute SHA256 run: | SHA256=$(shasum -a 256 Ora.dmg | awk '{print $1}') echo "SHA256=$SHA256" >> $GITHUB_ENV - if [ -n "${{ github.event.inputs.version }}" ]; then - echo "STRIPPED_VERSION=${{ github.event.inputs.version }}" >> $GITHUB_ENV - else - echo "STRIPPED_VERSION=$(echo '${{ github.event.release.tag_name }}' | sed 's/^v//')" >> $GITHUB_ENV - fi - name: Update ora.rb run: | - sed -i "s/version \\".*\\"/version \\"${{ env.STRIPPED_VERSION }}\\"/g" ora.rb - sed -i "s/sha256 \\".*\\"/sha256 \\"${{ env.SHA256 }}\\"/g" ora.rb + sed -i 's/version ".*"/version "${{ env.STRIPPED_VERSION }}"/g' ora.rb + sed -i 's/sha256 ".*"/sha256 "${{ env.SHA256 }}"/g' ora.rb - name: Commit and push changes run: | From b89735b83b5cf162d5df5d0694b12c91240633c9 Mon Sep 17 00:00:00 2001 From: Kenenisa Alemayehu <53818162+kenenisa@users.noreply.github.com> Date: Mon, 13 Oct 2025 22:06:44 +0300 Subject: [PATCH 34/38] fix: update paths for ora.rb in brew release workflow --- .github/workflows/brew-release.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/brew-release.yml b/.github/workflows/brew-release.yml index 986642f0..cc96a4f4 100644 --- a/.github/workflows/brew-release.yml +++ b/.github/workflows/brew-release.yml @@ -25,7 +25,7 @@ jobs: with: repository: the-ora/homebrew-ora token: ${{ secrets.GITHUB_TOKEN }} - ref: main + ref: main - name: Download Ora.dmg and set version run: | @@ -58,14 +58,14 @@ jobs: - name: Update ora.rb run: | - sed -i 's/version ".*"/version "${{ env.STRIPPED_VERSION }}"/g' ora.rb - sed -i 's/sha256 ".*"/sha256 "${{ env.SHA256 }}"/g' ora.rb + sed -i 's/version ".*"/version "${{ env.STRIPPED_VERSION }}"/g' Casks/ora.rb + sed -i 's/sha256 ".*"/sha256 "${{ env.SHA256 }}"/g' Casks/ora.rb - name: Commit and push changes run: | git config user.name "GitHub Actions" git config user.email "actions@github.com" - git add ora.rb + git add Casks/ora.rb if git diff --staged --quiet; then echo "No changes to commit" else From 68fc1af4eefee59ae34cd90347f59b1e12f05e05 Mon Sep 17 00:00:00 2001 From: Kenenisa Alemayehu <53818162+kenenisa@users.noreply.github.com> Date: Mon, 13 Oct 2025 22:16:59 +0300 Subject: [PATCH 35/38] fix: update GitHub token to HOMEBREW_SECRET in brew release workflow --- .github/workflows/brew-release.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/brew-release.yml b/.github/workflows/brew-release.yml index cc96a4f4..42a9be9e 100644 --- a/.github/workflows/brew-release.yml +++ b/.github/workflows/brew-release.yml @@ -11,7 +11,7 @@ on: type: string default: '' dmg_url: - description: 'Full DMG download URL for testing (e.g., https://github.com/the-ora/browser/releases/download/v0.2.5/Ora.dmg)' + description: 'Full DMG download URL for testing (e.g., https://github.com/the-ora/browser/releases/download/v0.2.5/Ora-Browser-0.2.5.dmg)' required: false type: string default: '' @@ -24,8 +24,8 @@ jobs: uses: actions/checkout@v4 with: repository: the-ora/homebrew-ora - token: ${{ secrets.GITHUB_TOKEN }} - ref: main + token: ${{ secrets.HOMEBREW_SECRET }} + ref: main - name: Download Ora.dmg and set version run: | From 454b13965b688ecf710aad98bf2f8c8ae12ff162 Mon Sep 17 00:00:00 2001 From: Kenenisa Alemayehu <53818162+kenenisa@users.noreply.github.com> Date: Mon, 13 Oct 2025 22:59:39 +0300 Subject: [PATCH 36/38] feat: Add Auto Picture-in-Picture Feature and Refactor Favicon Handling (#146) * Add Auto Picture-in-Picture Feature and Settings Integration - Introduced a new setting for enabling/disabling automatic Picture-in-Picture (PiP) mode on tab switch. - Updated SettingsStore to manage the autoPiPEnabled state and persist it. - Enhanced GeneralSettingsView to include a toggle for the auto PiP feature. - Modified TabManager to trigger PiP when switching to a new tab if the setting is enabled. - Implemented JavaScript function to handle PiP requests in WebViewNavigationDelegate. * Enhance Media Playback Management and Favicon Handling - Updated Tab model to synchronize title changes with the media controller when a tab's title is set. - Refactored favicon handling in MediaPlayerCard to use AsyncImage for improved loading and display. - Modified FaviconService to cache favicons more efficiently and updated the favicon URL size for better resolution. - Enhanced MediaController to track whether media sessions were played and filter visible sessions accordingly. - Implemented session removal logic in TabManager to ensure proper cleanup of media sessions when tabs are closed. - Improved JavaScript integration for media state tracking, including handling of media play events and title updates. * Refactor Tab and Favicon Handling for Improved Code Clarity - Aligned whitespace in the Tab model for better readability. - Updated favicon handling in FaviconService to streamline fetching and caching logic. - Introduced a new method for generating favicon URLs, enhancing maintainability. - Improved asynchronous favicon fetching with better error handling and completion callbacks. - Enhanced the average color computation for favicons to ensure accurate color representation. * Refactor Favicon Handling to Use Shared Instance - Updated instances of FaviconService to use the shared singleton instance across multiple files for consistency and improved memory management. - Removed unnecessary faviconService parameters from functions in SearchEngine and LauncherView to streamline code. - Cleaned up code by eliminating redundant faviconService declarations in various views. * Enhance BrowserSplitView Opacity and Hit Testing Logic - Updated opacity handling in BrowserSplitView to use a more concise comparison for active tab identification. - Added allowsHitTesting modifier to ensure user interactions are only enabled for the active tab, improving user experience. * Fix Typo in FaviconService Completion Handler * chore: swiftformat --------- Co-authored-by: yonaries --- ora/Common/Utils/SettingsStore.swift | 9 ++- ora/Models/SearchEngine.swift | 5 +- ora/Models/Tab.swift | 20 ++--- ora/Modules/Browser/BrowserSplitView.swift | 3 +- ora/Modules/Launcher/LauncherView.swift | 3 - ora/Modules/Launcher/Main/LauncherMain.swift | 6 +- ora/Modules/Player/GlobalMediaPlayer.swift | 20 +++-- .../Sections/GeneralSettingsView.swift | 2 + .../Sections/SearchEngineSettingsView.swift | 2 +- ora/Services/FaviconService.swift | 64 ++++++++++++--- ora/Services/MediaController.swift | 77 +++++++++---------- ora/Services/TabManager.swift | 13 +++- ora/Services/WebViewNavigationDelegate.swift | 64 +++++++++++---- 13 files changed, 194 insertions(+), 94 deletions(-) diff --git a/ora/Common/Utils/SettingsStore.swift b/ora/Common/Utils/SettingsStore.swift index b2bbfd8d..821146b6 100644 --- a/ora/Common/Utils/SettingsStore.swift +++ b/ora/Common/Utils/SettingsStore.swift @@ -78,7 +78,7 @@ struct CustomSearchEngine: Codable, Identifiable, Hashable { isAIChat: Bool = false, completion: @escaping (CustomSearchEngine) -> Void ) { - let faviconService = FaviconService() + let faviconService = FaviconService.shared // Try to fetch favicon synchronously first (from cache) if let favicon = faviconService.getFavicon(for: searchURL) { @@ -149,6 +149,7 @@ class SettingsStore: ObservableObject { private let tabAliveTimeoutKey = "settings.tabAliveTimeout" private let tabRemovalTimeoutKey = "settings.tabRemovalTimeout" private let maxRecentTabsKey = "settings.maxRecentTabs" + private let autoPiPEnabledKey = "settings.autoPiPEnabled" // MARK: - Per-Container @@ -212,6 +213,10 @@ class SettingsStore: ObservableObject { didSet { defaults.set(maxRecentTabs, forKey: maxRecentTabsKey) } } + @Published var autoPiPEnabled: Bool { + didSet { defaults.set(autoPiPEnabled, forKey: autoPiPEnabledKey) } + } + init() { autoUpdateEnabled = defaults.bool(forKey: autoUpdateKey) blockThirdPartyTrackers = defaults.bool(forKey: trackingThirdPartyKey) @@ -264,6 +269,8 @@ class SettingsStore: ObservableObject { let maxRecentTabsValue = defaults.integer(forKey: maxRecentTabsKey) maxRecentTabs = maxRecentTabsValue == 0 ? 5 : maxRecentTabsValue + + autoPiPEnabled = defaults.object(forKey: autoPiPEnabledKey) as? Bool ?? true } // MARK: - Per-container helpers diff --git a/ora/Models/SearchEngine.swift b/ora/Models/SearchEngine.swift index d55b6713..a04351bf 100644 --- a/ora/Models/SearchEngine.swift +++ b/ora/Models/SearchEngine.swift @@ -34,7 +34,6 @@ struct SearchEngine { extension SearchEngine { func toLauncherMatch( originalAlias: String, - faviconService: FaviconService? = nil, customEngine: CustomSearchEngine? = nil ) -> LauncherMain.Match { var favicon: NSImage? @@ -46,8 +45,8 @@ extension SearchEngine { faviconColor = customEngine.faviconBackgroundColor } else { // For built-in engines, use favicon service - favicon = faviconService?.getFavicon(for: searchURL) - faviconColor = faviconService?.getFaviconColor(for: searchURL) + favicon = FaviconService.shared.getFavicon(for: searchURL) + faviconColor = FaviconService.shared.getFaviconColor(for: searchURL) } return LauncherMain.Match( diff --git a/ora/Models/Tab.swift b/ora/Models/Tab.swift index 0a6f0308..52f47f31 100644 --- a/ora/Models/Tab.swift +++ b/ora/Models/Tab.swift @@ -145,8 +145,8 @@ class Tab: ObservableObject, Identifiable { func setFavicon(faviconURLDefault: URL? = nil) { guard let host = self.url.host else { return } - let faviconURL = faviconURLDefault != nil ? faviconURLDefault! : - URL(string: "https://www.google.com/s2/favicons?domain=\(host)")! + let domain = host.hasPrefix("www.") ? String(host.dropFirst(4)) : host + let faviconURL = faviconURLDefault ?? URL(string: "https://www.google.com/s2/favicons?domain=\(domain)")! self.favicon = faviconURL // Infer extension from URL or fallback to png @@ -154,15 +154,12 @@ class Tab: ObservableObject, Identifiable { let fileName = "\(self.id.uuidString).\(ext)" let saveURL = FileManager.default.faviconDirectory.appendingPathComponent(fileName) - Task { - do { - let (data, _) = try await URLSession.shared.data(from: faviconURL) - try data.write(to: saveURL, options: .atomic) - + FaviconService.shared.downloadAndSaveFavicon(for: domain, to: saveURL) { sourceURL, success in + if success { self.faviconLocalFile = saveURL - - } catch { - // Failed to download/save favicon + if let sourceURL { + self.favicon = sourceURL + } } } } @@ -222,6 +219,9 @@ class Tab: ObservableObject, Identifiable { DispatchQueue.main.async { if let title, !title.isEmpty { self?.title = title + if let self { + self.tabManager?.mediaController.syncTitleForTab(self.id, newTitle: title) + } } } } diff --git a/ora/Modules/Browser/BrowserSplitView.swift b/ora/Modules/Browser/BrowserSplitView.swift index d73a467d..c2c1c366 100644 --- a/ora/Modules/Browser/BrowserSplitView.swift +++ b/ora/Modules/Browser/BrowserSplitView.swift @@ -90,7 +90,8 @@ struct BrowserSplitView: View { BrowserContentContainer { BrowserWebContentView(tab: tab) } - .opacity((activeId == tab.id) ? 1 : 0) + .opacity(tab.id == activeId ? 1 : 0) + .allowsHitTesting(tab.id == activeId) } } } diff --git a/ora/Modules/Launcher/LauncherView.swift b/ora/Modules/Launcher/LauncherView.swift index ef71d72f..a574411f 100644 --- a/ora/Modules/Launcher/LauncherView.swift +++ b/ora/Modules/Launcher/LauncherView.swift @@ -10,7 +10,6 @@ struct LauncherView: View { @EnvironmentObject var privacyMode: PrivacyMode @Environment(\.theme) private var theme @StateObject private var searchEngineService = SearchEngineService() - @StateObject private var faviconService = FaviconService() @State private var input = "" @State private var isVisible = false @@ -26,7 +25,6 @@ struct LauncherView: View { .first { $0.searchURL == searchEngine.searchURL } match = searchEngine.toLauncherMatch( originalAlias: input, - faviconService: faviconService, customEngine: customEngine ) input = "" @@ -46,7 +44,6 @@ struct LauncherView: View { .first { $0.searchURL == defaultEngine.searchURL } engineToUse = defaultEngine.toLauncherMatch( originalAlias: correctInput, - faviconService: faviconService, customEngine: customEngine ) } diff --git a/ora/Modules/Launcher/Main/LauncherMain.swift b/ora/Modules/Launcher/Main/LauncherMain.swift index f10797ae..a468506b 100644 --- a/ora/Modules/Launcher/Main/LauncherMain.swift +++ b/ora/Modules/Launcher/Main/LauncherMain.swift @@ -51,7 +51,7 @@ struct LauncherMain: View { @EnvironmentObject var toolbarManager: ToolbarManager @EnvironmentObject var privacyMode: PrivacyMode @State var focusedElement: UUID = .init() - @StateObject private var faviconService = FaviconService() + @StateObject private var searchEngineService = SearchEngineService() @State private var suggestions: [LauncherSuggestion] = [] @@ -68,8 +68,8 @@ struct LauncherMain: View { ) } - _ = faviconService.getFavicon(for: engine.searchURL) - let faviconURL = faviconService.faviconURL(for: URL(string: engine.searchURL)?.host ?? "") + _ = FaviconService.shared.getFavicon(for: engine.searchURL) + let faviconURL = FaviconService.shared.faviconURL(for: URL(string: engine.searchURL)?.host ?? "") return LauncherSuggestion( type: .aiChat, diff --git a/ora/Modules/Player/GlobalMediaPlayer.swift b/ora/Modules/Player/GlobalMediaPlayer.swift index a7770d5e..068f58ba 100644 --- a/ora/Modules/Player/GlobalMediaPlayer.swift +++ b/ora/Modules/Player/GlobalMediaPlayer.swift @@ -47,11 +47,22 @@ private struct MediaPlayerCard: View { @State private var showVolume: Bool = false @State private var hovered: Bool = false - private var faviconImage: Image { + private var faviconView: some View { if let url = session.favicon { - return Image(nsImage: NSImage(byReferencing: url)) + return AnyView( + AsyncImage(url: url) { image in + image.resizable() + } placeholder: { + Image(systemName: "play.rectangle.fill") + .resizable() + } + ) + } else { + return AnyView( + Image(systemName: "play.rectangle.fill") + .resizable() + ) } - return Image(systemName: "play.rectangle.fill") } var body: some View { @@ -76,8 +87,7 @@ private struct MediaPlayerCard: View { HStack { Button { tabManager.activateTab(id: session.tabID) } label: { - faviconImage - .resizable() + faviconView .scaledToFit() .frame(width: 18, height: 18) .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) diff --git a/ora/Modules/Settings/Sections/GeneralSettingsView.swift b/ora/Modules/Settings/Sections/GeneralSettingsView.swift index b9725108..505e6a94 100644 --- a/ora/Modules/Settings/Sections/GeneralSettingsView.swift +++ b/ora/Modules/Settings/Sections/GeneralSettingsView.swift @@ -96,6 +96,8 @@ struct GeneralSettingsView: View { .font(.caption2) .foregroundColor(.secondary) } + + Toggle("Auto Picture-in-Picture on tab switch", isOn: $settings.autoPiPEnabled) } .padding(.vertical, 8) VStack(alignment: .leading, spacing: 12) { diff --git a/ora/Modules/Settings/Sections/SearchEngineSettingsView.swift b/ora/Modules/Settings/Sections/SearchEngineSettingsView.swift index f01c519d..da2c27d8 100644 --- a/ora/Modules/Settings/Sections/SearchEngineSettingsView.swift +++ b/ora/Modules/Settings/Sections/SearchEngineSettingsView.swift @@ -4,7 +4,7 @@ import SwiftUI struct SearchEngineSettingsView: View { @StateObject private var settings = SettingsStore.shared @StateObject private var searchEngineService = SearchEngineService() - @StateObject private var faviconService = FaviconService() + @Environment(\.theme) var theme @State private var showingAddForm = false diff --git a/ora/Services/FaviconService.swift b/ora/Services/FaviconService.swift index e866896a..1035c440 100644 --- a/ora/Services/FaviconService.swift +++ b/ora/Services/FaviconService.swift @@ -3,16 +3,14 @@ import CoreImage import SwiftUI class FaviconService: ObservableObject { + static let shared = FaviconService() + private var cache: [String: NSImage] = [:] private var colorCache: [String: Color] = [:] func getFavicon(for searchURL: String) -> NSImage? { guard let domain = extractDomain(from: searchURL) else { return nil } - if let cachedFavicon = cache[domain] { - return cachedFavicon - } - // Try to fetch favicon asynchronously fetchFavicon(for: domain) { [weak self] favicon in if let favicon { @@ -24,6 +22,10 @@ class FaviconService: ObservableObject { } } + if let cachedFavicon = cache[domain] { + return cachedFavicon + } + return nil } @@ -47,7 +49,7 @@ class FaviconService: ObservableObject { } func faviconURL(for domain: String) -> URL? { - return URL(string: "https://www.google.com/s2/favicons?domain=\(domain)&sz=16") + return URL(string: "https://www.google.com/s2/favicons?domain=\(domain)&sz=64") } private func extractDomain(from searchURL: String) -> String? { @@ -64,12 +66,7 @@ class FaviconService: ObservableObject { } private func fetchFavicon(for domain: String, completion: @escaping (NSImage?) -> Void) { - let faviconURLs = [ - "https://www.google.com/s2/favicons?domain=\(domain)&sz=64", - "https://\(domain)/favicon.ico", - "https://\(domain)/apple-touch-icon.png" - ] - + let faviconURLs = self.getFaviconURLs(for: domain) tryFetchingFavicon(from: faviconURLs, index: 0, completion: completion) } @@ -92,6 +89,51 @@ class FaviconService: ObservableObject { } }.resume() } + + private func getFaviconURLs(for domain: String) -> [String] { + let faviconURLs = [ + "https://www.google.com/s2/favicons?domain=\(domain)&sz=64", + "https://\(domain)/favicon.ico", + "https://\(domain)/apple-touch-icon.png" + ] + return faviconURLs + } + + private func tryFetchingFaviconData(from urls: [String], index: Int, completion: @escaping (Data?, URL?) -> Void) { + guard index < urls.count else { + completion(nil, nil) + return + } + + guard let url = URL(string: urls[index]) else { + tryFetchingFaviconData(from: urls, index: index + 1, completion: completion) + return + } + + URLSession.shared.dataTask(with: url) { data, _, _ in + if let data { + completion(data, url) + } else { + self.tryFetchingFaviconData(from: urls, index: index + 1, completion: completion) + } + }.resume() + } + + func downloadAndSaveFavicon(for domain: String, to saveURL: URL, completion: @escaping (URL?, Bool) -> Void) { + let faviconURLs = self.getFaviconURLs(for: domain) + tryFetchingFaviconData(from: faviconURLs, index: 0) { data, url in + if let data, let url { + do { + try data.write(to: saveURL, options: .atomic) + completion(url, true) + } catch { + completion(nil, false) + } + } else { + completion(nil, false) + } + } + } } extension NSImage { diff --git a/ora/Services/MediaController.swift b/ora/Services/MediaController.swift index 52151b66..1459d74d 100644 --- a/ora/Services/MediaController.swift +++ b/ora/Services/MediaController.swift @@ -15,6 +15,7 @@ final class MediaController: ObservableObject { var canGoNext: Bool var canGoPrevious: Bool var lastActive: Date + var wasPlayed: Bool } // Published list of sessions ordered by recency (most recent first) @@ -40,7 +41,7 @@ final class MediaController: ObservableObject { // MARK: - Public accessors var primary: Session? { visibleSessions.first } - var visibleSessions: [Session] { sessions } + var visibleSessions: [Session] { sessions.filter(\.wasPlayed) } // MARK: - Receive events from JS bridge @@ -59,7 +60,8 @@ final class MediaController: ObservableObject { volume: 1.0, canGoNext: false, canGoPrevious: false, - lastActive: Date() + lastActive: Date(), + wasPlayed: false ) sessions.insert(session, at: 0) return 0 @@ -72,17 +74,14 @@ final class MediaController: ObservableObject { sessions[idx].isPlaying = playing // Update tab's isPlayingMedia property tabRefs[tab.id]?.value?.isPlayingMedia = playing - if let newTitle = event.title, !newTitle.isEmpty { sessions[idx].title = newTitle } if let vol = event.volume { sessions[idx].volume = clamp(vol) } // Update recency when it starts playing if playing { sessions[idx].lastActive = Date() moveToFront(index: idx) } - - case "ready": - if let idx = sessions.firstIndex(where: { $0.tabID == id }) { - if let newTitle = event.title, !newTitle.isEmpty { sessions[idx].title = newTitle } - } + if let wasPlayed = event.wasPlayed { sessions[idx].wasPlayed = wasPlayed } +// case "ready": + // Session is already ensured in other cases case "volume": if let idx = sessions.firstIndex(where: { $0.tabID == id }), let vol = event.volume { @@ -102,12 +101,13 @@ final class MediaController: ObservableObject { tabRefs[tab.id]?.value?.isPlayingMedia = false } - case "titleChange": - if let idx = sessions.firstIndex(where: { $0.tabID == id }), - let newTitle = event.title, !newTitle.isEmpty - { - sessions[idx].title = newTitle + case "removed": + if let idx = sessions.firstIndex(where: { $0.tabID == id }) { + sessions.remove(at: idx) } + // Update tab's isPlayingMedia property + tabRefs[tab.id]?.value?.isPlayingMedia = false + self.removeSession(for: tab.id) default: break @@ -165,6 +165,16 @@ final class MediaController: ObservableObject { isVisible = !visibleSessions.isEmpty } + func removeSession(for tabID: UUID) { + if let idx = sessions.firstIndex(where: { $0.tabID == tabID }) { + sessions.remove(at: idx) + } + // Update tab's isPlayingMedia property + tabRefs[tabID]?.value?.isPlayingMedia = false + tabRefs[tabID] = nil + isVisible = !visibleSessions.isEmpty + } + // Helpers func volume(of tabID: UUID) -> Double { sessions.first(where: { $0.tabID == tabID })?.volume ?? 1.0 } func canGoNext(of tabID: UUID) -> Bool { sessions.first(where: { $0.tabID == tabID })?.canGoNext ?? false } @@ -196,13 +206,12 @@ final class MediaController: ObservableObject { private func syncTitlesForPlayingSessions() { let playingSessions = sessions.filter(\.isPlaying) for session in playingSessions { - fetchDocumentTitle(for: session.tabID) { [weak self] newTitle in - guard let self, let title = newTitle, !title.isEmpty, - let idx = self.sessions.firstIndex(where: { $0.tabID == session.tabID }), - title != self.sessions[idx].title - else { return } - - self.sessions[idx].title = title + if let tab = tabRefs[session.tabID]?.value, + let idx = sessions.firstIndex(where: { $0.tabID == session.tabID }), + !tab.title.isEmpty, + tab.title != sessions[idx].title + { + sessions[idx].title = tab.title } } } @@ -217,35 +226,25 @@ final class MediaController: ObservableObject { private func scheduleTitleSync(for tabID: UUID, attempts: Int = 6, delay: TimeInterval = 0.25) { guard attempts > 0 else { return } - let currentTitle = sessions.first(where: { $0.tabID == tabID })?.title - fetchDocumentTitle(for: tabID) { [weak self] newTitle in + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in guard let self else { return } - if let title = newTitle, !title.isEmpty, title != currentTitle, - let idx = self.sessions.firstIndex(where: { $0.tabID == tabID }) + if let tab = self.tabRefs[tabID]?.value, + let idx = self.sessions.firstIndex(where: { $0.tabID == tabID }), + !tab.title.isEmpty, + tab.title != self.sessions[idx].title { - self.sessions[idx].title = title - } else { - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { - self.scheduleTitleSync(for: tabID, attempts: attempts - 1, delay: delay) - } + self.sessions[idx].title = tab.title + } else if attempts > 1 { + self.scheduleTitleSync(for: tabID, attempts: attempts - 1, delay: delay) } } } - - private func fetchDocumentTitle(for tabID: UUID, completion: @escaping (String?) -> Void) { - guard let webView = tabRefs[tabID]?.value?.webView else { completion(nil) - return - } - let js = "(function(){ try { return (window.__oraMedia && window.__oraMedia.title && window.__oraMedia.title()) || document.title || ''; } catch(e) { return document.title || ''; } })()" - webView.evaluateJavaScript(js) { result, _ in - completion(result as? String) - } - } } // Payload from injected JS struct MediaEventPayload: Codable { let type: String + let wasPlayed: Bool? let state: String? let volume: Double? let title: String? diff --git a/ora/Services/TabManager.swift b/ora/Services/TabManager.swift index 2b393dc6..9d084080 100644 --- a/ora/Services/TabManager.swift +++ b/ora/Services/TabManager.swift @@ -7,7 +7,17 @@ import WebKit @MainActor class TabManager: ObservableObject { @Published var activeContainer: TabContainer? - @Published var activeTab: Tab? + @Published var activeTab: Tab? { + willSet { + guard let tab = activeTab, SettingsStore.shared.autoPiPEnabled else { return } + tab.webView.evaluateJavaScript("window.__oraTriggerPiP()") + } + didSet { + guard let tab = activeTab, SettingsStore.shared.autoPiPEnabled else { return } + tab.webView.evaluateJavaScript("window.__oraTriggerPiP(true)") + } + } + let modelContainer: ModelContainer let modelContext: ModelContext let mediaController: MediaController @@ -333,6 +343,7 @@ class TabManager: ObservableObject { tab.isWebViewReady = false tab.destroyWebView() } + self.mediaController.removeSession(for: tab.id) try? self.modelContext.save() } } diff --git a/ora/Services/WebViewNavigationDelegate.swift b/ora/Services/WebViewNavigationDelegate.swift index ce3a02e0..3f09e59b 100644 --- a/ora/Services/WebViewNavigationDelegate.swift +++ b/ora/Services/WebViewNavigationDelegate.swift @@ -112,36 +112,46 @@ let navigationScript = """ const stateFrom = (el) => ({ type: 'state', + wasPlayed: el && el.__oraWasPlayed, state: el && !el.paused ? 'playing' : 'paused', volume: el ? (el.muted ? 0 : el.volume) : undefined, title: document.title }); - - // Enhanced title change monitoring for media sessions - let lastMediaTitle = document.title; - function checkTitleChange() { - if (document.title !== lastMediaTitle) { - lastMediaTitle = document.title; - // If any media is currently playing, send a title update - const activeMedia = document.querySelector('video:not([paused]), audio:not([paused])'); - if (activeMedia) { - post({ type: 'titleChange', title: document.title }); + function watchRemoval(element, callback) { + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + for (const removed of mutation.removedNodes) { + if (removed === element || removed.contains(element)) { + callback(); + observer.disconnect(); + return; + } + } } - } + }); + + observer.observe(document.body, { childList: true, subtree: true }); } function attach(el) { if (!el || el.__oraAttached) return; el.__oraAttached = true; const update = () => post(stateFrom(el)); - el.addEventListener('play', update); + el.addEventListener('play', ()=>{ + update(); + el.__oraWasPlayed = true; + }); el.addEventListener('pause', update); el.addEventListener('ended', () => post({ type: 'ended' })); el.addEventListener('volumechange', () => post({ type: 'volume', volume: el.muted ? 0 : el.volume }) ); // If already playing, announce - if (!el.paused) update(); + if (!el.paused) { + el.__oraWasPlayed = true; + update(); + } + watchRemoval(el, () => post({ type: 'removed' })); } function scan() { @@ -153,9 +163,6 @@ let navigationScript = """ mo.observe(document.documentElement, { childList: true, subtree: true }); scan(); - // Set up periodic title checking for active media - setInterval(checkTitleChange, 1000); - window.__oraMedia = { active: null, _pick() { @@ -219,6 +226,31 @@ let navigationScript = """ return document.title; } }; + window.__oraTriggerPiP = function(isActive = false) { + const video = document.querySelector('video'); + + function hasAudio(video) { + if (!video) return false; + if (video.audioTracks && video.audioTracks.length > 0) return true; + if (!video.muted && video.volume > 0) return true; + return false; + } + + if ( + video && + video.tagName === 'VIDEO' && + !document.pictureInPictureElement && + !video.paused && + !isActive && + hasAudio(video) + ) { + video.requestPictureInPicture() + .catch(e => {}); + } else if (document.pictureInPictureElement) { + document.exitPictureInPicture() + .catch(e => {}); + } + }; post({ type: 'ready', title: document.title }); })(); From e9752574ecdda1a103fbb620067d411fda9bdc07 Mon Sep 17 00:00:00 2001 From: Brooksolomon <86517756+Brooksolomon@users.noreply.github.com> Date: Mon, 13 Oct 2025 23:00:29 +0300 Subject: [PATCH 37/38] feat: support for duplicate tab (#129) * added duplication feature * fix : added calculation logic * fix : drag and reorder issue when duplicate tabs are located * fix : duplicate appears below duplicated tab instead of last * fix : fixed merge conflicts with main * fix : refactored duplicate method to use existing functions * fix : refactored open tab to return the tab and reordered the tabs when new tab is created using duplicate * fix : removed duplicate functions * fixed the reorder tab issue * Enhance TabManager to support silent loading of tabs. Added 'loadSilently' parameter to the tab opening method, allowing for silent initialization of web views based on user preferences. * feat : added is alive check when duplicating tab to check if the tab is dormant or not * feat : replaced is alive check with is web view ready --------- Co-authored-by: Kenenisa Alemayehu <53818162+kenenisa@users.noreply.github.com> --- ora/Modules/Sidebar/ContainerView.swift | 7 +++++ ora/Modules/Sidebar/TabList/FavTabsList.swift | 2 ++ .../Sidebar/TabList/NormalTabsList.swift | 2 ++ .../Sidebar/TabList/PinnedTabsList.swift | 2 ++ ora/Services/TabDropDelegate.swift | 23 +++++++++++++++- ora/Services/TabManager.swift | 26 ++++++++++++++++--- ora/UI/FavTabItem.swift | 5 ++++ ora/UI/TabItem.swift | 8 ++++++ 8 files changed, 70 insertions(+), 5 deletions(-) diff --git a/ora/Modules/Sidebar/ContainerView.swift b/ora/Modules/Sidebar/ContainerView.swift index 3dc552d9..a614137d 100644 --- a/ora/Modules/Sidebar/ContainerView.swift +++ b/ora/Modules/Sidebar/ContainerView.swift @@ -35,6 +35,7 @@ struct ContainerView: View { onSelect: selectTab, onFavoriteToggle: toggleFavorite, onClose: removeTab, + onDuplicate: duplicateTab, onMoveToContainer: moveTab ) } else { @@ -68,6 +69,7 @@ struct ContainerView: View { onPinToggle: togglePin, onFavoriteToggle: toggleFavorite, onClose: removeTab, + onDuplicate: duplicateTab, onMoveToContainer: moveTab, containers: containers ) @@ -81,6 +83,7 @@ struct ContainerView: View { onPinToggle: togglePin, onFavoriteToggle: toggleFavorite, onClose: removeTab, + onDuplicate: duplicateTab, onMoveToContainer: moveTab, onAddNewTab: addNewTab ) @@ -153,6 +156,10 @@ struct ContainerView: View { isDragging = false draggedItem = nil } + + private func duplicateTab(_ tab: Tab) { + tabManager.duplicateTab(tab) + } } private struct OraWindowDragGesture: ViewModifier { diff --git a/ora/Modules/Sidebar/TabList/FavTabsList.swift b/ora/Modules/Sidebar/TabList/FavTabsList.swift index a51f34b0..68c095c0 100644 --- a/ora/Modules/Sidebar/TabList/FavTabsList.swift +++ b/ora/Modules/Sidebar/TabList/FavTabsList.swift @@ -11,6 +11,7 @@ struct FavTabsGrid: View { let onSelect: (Tab) -> Void let onFavoriteToggle: (Tab) -> Void let onClose: (Tab) -> Void + let onDuplicate: (Tab) -> Void let onMoveToContainer: ( Tab, @@ -45,6 +46,7 @@ struct FavTabsGrid: View { onTap: { onSelect(tab) }, onFavoriteToggle: { onFavoriteToggle(tab) }, onClose: { onClose(tab) }, + onDuplicate: { onDuplicate(tab) }, onMoveToContainer: { onMoveToContainer(tab, $0) } ) .onDrag { onDrag(tab.id) } diff --git a/ora/Modules/Sidebar/TabList/NormalTabsList.swift b/ora/Modules/Sidebar/TabList/NormalTabsList.swift index ee1551e4..00c4794e 100644 --- a/ora/Modules/Sidebar/TabList/NormalTabsList.swift +++ b/ora/Modules/Sidebar/TabList/NormalTabsList.swift @@ -9,6 +9,7 @@ struct NormalTabsList: View { let onPinToggle: (Tab) -> Void let onFavoriteToggle: (Tab) -> Void let onClose: (Tab) -> Void + let onDuplicate: (Tab) -> Void let onMoveToContainer: ( Tab, @@ -31,6 +32,7 @@ struct NormalTabsList: View { onPinToggle: { onPinToggle(tab) }, onFavoriteToggle: { onFavoriteToggle(tab) }, onClose: { onClose(tab) }, + onDuplicate: { onDuplicate(tab) }, onMoveToContainer: { onMoveToContainer(tab, $0) }, availableContainers: containers ) diff --git a/ora/Modules/Sidebar/TabList/PinnedTabsList.swift b/ora/Modules/Sidebar/TabList/PinnedTabsList.swift index 93bebfef..63b16ab1 100644 --- a/ora/Modules/Sidebar/TabList/PinnedTabsList.swift +++ b/ora/Modules/Sidebar/TabList/PinnedTabsList.swift @@ -9,6 +9,7 @@ struct PinnedTabsList: View { let onPinToggle: (Tab) -> Void let onFavoriteToggle: (Tab) -> Void let onClose: (Tab) -> Void + let onDuplicate: (Tab) -> Void let onMoveToContainer: (Tab, TabContainer) -> Void let containers: [TabContainer] @EnvironmentObject var tabManager: TabManager @@ -33,6 +34,7 @@ struct PinnedTabsList: View { onPinToggle: { onPinToggle(tab) }, onFavoriteToggle: { onFavoriteToggle(tab) }, onClose: { onClose(tab) }, + onDuplicate: { onDuplicate(tab) }, onMoveToContainer: { onMoveToContainer(tab, $0) }, availableContainers: containers ) diff --git a/ora/Services/TabDropDelegate.swift b/ora/Services/TabDropDelegate.swift index a3d82aa2..b1a95e84 100644 --- a/ora/Services/TabDropDelegate.swift +++ b/ora/Services/TabDropDelegate.swift @@ -1,6 +1,13 @@ import AppKit import SwiftUI +extension Array where Element: Hashable { + func unique() -> [Element] { + var seen = Set() + return filter { seen.insert($0).inserted } + } +} + struct TabDropDelegate: DropDelegate { let item: Tab // to @Binding var draggedItem: UUID? @@ -15,7 +22,21 @@ struct TabDropDelegate: DropDelegate { let uuid = UUID(uuidString: string) { DispatchQueue.main.async { - guard let from = self.item.container.tabs.first(where: { $0.id == uuid }) else { return } + // First try to find the tab in the target container + var from = self.item.container.tabs.first(where: { $0.id == uuid }) + + // If not found, try to find it in all containers of the same type + if from == nil { + // Look through all tabs in all containers to find the dragged tab + for container in self.item.container.tabs.compactMap(\.container).unique() { + if let foundTab = container.tabs.first(where: { $0.id == uuid }) { + from = foundTab + break + } + } + } + + guard let from else { return } if isInSameSection( from: from, diff --git a/ora/Services/TabManager.swift b/ora/Services/TabManager.swift index 9d084080..ce846e84 100644 --- a/ora/Services/TabManager.swift +++ b/ora/Services/TabManager.swift @@ -246,8 +246,9 @@ class TabManager: ObservableObject { historyManager: HistoryManager, downloadManager: DownloadManager? = nil, focusAfterOpening: Bool = true, - isPrivate: Bool - ) { + isPrivate: Bool, + loadSilently: Bool = false + ) -> Tab? { if let container = activeContainer { if let host = url.host { let faviconURL = URL(string: "https://www.google.com/s2/favicons?domain=\(host)") @@ -275,7 +276,8 @@ class TabManager: ObservableObject { activeTab = newTab activeTab?.maybeIsActive = true newTab.lastAccessedAt = Date() - + } + if focusAfterOpening || loadSilently { // Initialize the WebView for the new active tab newTab.restoreTransientState( historyManager: historyManager, @@ -290,8 +292,10 @@ class TabManager: ObservableObject { container.lastAccessedAt = Date() try? modelContext.save() + return newTab } } + return nil } func reorderTabs(from: Tab, toTab: Tab) { @@ -499,7 +503,7 @@ class TabManager: ObservableObject { if message.name == "listener", let url = message.body as? String { - // You can update the active tab’s url if needed + // You can update the active tab's url if needed DispatchQueue.main.async { if let validURL = URL(string: url) { self.activeTab?.url = validURL @@ -511,6 +515,20 @@ class TabManager: ObservableObject { } } } + + func duplicateTab(_ tab: Tab) { + // Create a new tab using the existing openTab method + guard let historyManager = tab.historyManager else { return } + guard let newTab = openTab( + url: tab.url, + historyManager: historyManager, + downloadManager: tab.downloadManager, + focusAfterOpening: false, + isPrivate: tab.isPrivate, + loadSilently: true + ) else { return } + self.reorderTabs(from: tab, toTab: newTab) + } } // MARK: - Tab Searching Providing diff --git a/ora/UI/FavTabItem.swift b/ora/UI/FavTabItem.swift index 6d91cf01..82d1bc5a 100644 --- a/ora/UI/FavTabItem.swift +++ b/ora/UI/FavTabItem.swift @@ -9,6 +9,7 @@ struct FavTabItem: View { let onTap: () -> Void let onFavoriteToggle: () -> Void let onClose: () -> Void + let onDuplicate: () -> Void let onMoveToContainer: (TabContainer) -> Void @Environment(\.theme) private var theme @@ -125,6 +126,10 @@ struct FavTabItem: View { Label("Remove from Favorites", systemImage: "star.slash") } + Button(action: onDuplicate) { + Label("Duplicate Tab", systemImage: "doc.on.doc") + } + // Divider() // Menu("Move to Container") { diff --git a/ora/UI/TabItem.swift b/ora/UI/TabItem.swift index 564e42ce..7047d44d 100644 --- a/ora/UI/TabItem.swift +++ b/ora/UI/TabItem.swift @@ -90,6 +90,7 @@ struct TabItem: View { let onPinToggle: () -> Void let onFavoriteToggle: () -> Void let onClose: () -> Void + let onDuplicate: () -> Void let onMoveToContainer: (TabContainer) -> Void @EnvironmentObject var tabManager: TabManager @EnvironmentObject var historyManager: HistoryManager @@ -218,6 +219,13 @@ struct TabItem: View { ) } + Button(action: onDuplicate) { + Label("Duplicate Tab", systemImage: "doc.on.doc") + } + .disabled(!tab.isWebViewReady) + + Divider() + if availableContainers.count > 1 { Divider() From 33e9b4038de3a82b947c6c071366c078a2da0943 Mon Sep 17 00:00:00 2001 From: Kenenisa Alemayehu <53818162+kenenisa@users.noreply.github.com> Date: Mon, 13 Oct 2025 23:44:16 +0300 Subject: [PATCH 38/38] Update to v0.2.5 --- appcast.xml | 45 ++++++++++++++++++++++----------------------- project.yml | 4 ++-- 2 files changed, 24 insertions(+), 25 deletions(-) diff --git a/appcast.xml b/appcast.xml index ab8621c5..326a6525 100644 --- a/appcast.xml +++ b/appcast.xml @@ -5,44 +5,43 @@ Most recent changes with links to updates. en - Version 0.2.4 + Version 0.2.5 Ora Browser v0.2.4 +

Ora Browser v0.2.5

Changes since last release:

Features

    -
  • feat: add URL handling and deep linking support (#132) β€” Kenenisa Alemayehu
  • -
  • feat: double click full resize of window (#128) β€” Chase
  • -
  • feat: add ability to edit container name and emoji (#125) β€” Chase
  • +
  • feat: support for duplicate tab (#129) β€” Brooksolomon
  • +
  • feat: Add Auto Picture-in-Picture Feature and Refactor Favicon Handling (#146) β€” Kenenisa Alemayehu
  • +
  • feat: enhance brew release workflow with optional inputs for version and DMG URL β€” Kenenisa Alemayehu
  • +
  • feat: brew release cask automation β€” Kenenisa Alemayehu

Fixes

    -
  • fix: settings model container and spaces UI (#127) β€” Chase
  • -
  • fix: move to container shows proper containers now (#126) β€” Chase
  • -
  • fix(sidebar): update sidebar visibility persistance (#124) β€” Furkan Koseoglu
  • -
  • fix: update user agent string in TabScriptHandler for compatibility β€” Kenenisa Alemayehu
  • +
  • fix: update GitHub token to HOMEBREW_SECRET in brew release workflow β€” Kenenisa Alemayehu
  • +
  • fix: update paths for ora.rb in brew release workflow β€” Kenenisa Alemayehu
  • +
  • fix: sed for ora.rb β€” Kenenisa Alemayehu
  • +
  • fix: update GitHub token in brew release workflow β€” Kenenisa Alemayehu
  • +
+

Chores

+
    +
  • chore: update discord link β€” Yonathan Dejene

Other

    -
  • Enhance Tab Management Settings and UI (#139) β€” Kenenisa Alemayehu
  • -
  • Update macOS and License badge in README β€” Yonathan Dejene
  • -
  • Change license from MIT to GPL-2.0 β€” Kenenisa Alemayehu
  • -
  • Delete LICENSE.md β€” Kenenisa Alemayehu
  • -
  • Add GNU General Public License v2 β€” Kenenisa Alemayehu
  • -
  • Prevent Window Drag Interference During Tab Drags and Refactor TabManager for Modularity (#101) β€” AryanRogye
  • -
  • ux: Cleanup search engines implementation (#121) β€” versecafe
  • -
  • ux: Improve page finder (#122) β€” versecafe
  • +
  • added swiftformat β€” yonaries
  • +
  • Add Left/Right Sidebar Positioning, Floating URL Bar, and Toolbar Enhancements (#133) β€” Yonathan Dejene
]]>
- Mon, 06 Oct 2025 20:11:13 +0000 - Mon, 13 Oct 2025 20:41:08 +0000 + + sparkle:edSignature="a4beTLCi7kMhmqbuI57XRbOooPWonvq9d/5aou/kPuY9mqJW2uNLDFLKhcFUmdJdye3ZarkSkcZkrPEhCguCAg=="/>
diff --git a/project.yml b/project.yml index 4f2541c9..8b00de13 100644 --- a/project.yml +++ b/project.yml @@ -96,8 +96,8 @@ targets: base: SWIFT_VERSION: 5.9 CODE_SIGN_STYLE: Automatic - MARKETING_VERSION: 0.2.4 - CURRENT_PROJECT_VERSION: 92 + MARKETING_VERSION: 0.2.5 + CURRENT_PROJECT_VERSION: 93 PRODUCT_NAME: Ora PRODUCT_BUNDLE_IDENTIFIER: com.orabrowser.app GENERATE_INFOPLIST_FILE: YES