diff --git a/ora/Assets/Catalogs/Capsule.xcassets/bing-capsule-logo.imageset/Contents.json b/ora/Assets/Catalogs/Capsule.xcassets/bing-capsule-logo.imageset/Contents.json new file mode 100644 index 00000000..75aac257 --- /dev/null +++ b/ora/Assets/Catalogs/Capsule.xcassets/bing-capsule-logo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "bing-capsule-logo.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ora/Assets/Catalogs/Capsule.xcassets/bing-capsule-logo.imageset/bing-capsule-logo.svg b/ora/Assets/Catalogs/Capsule.xcassets/bing-capsule-logo.imageset/bing-capsule-logo.svg new file mode 100644 index 00000000..0d93e379 --- /dev/null +++ b/ora/Assets/Catalogs/Capsule.xcassets/bing-capsule-logo.imageset/bing-capsule-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ora/Assets/Catalogs/Capsule.xcassets/claude-capsule-logo.imageset/Contents.json b/ora/Assets/Catalogs/Capsule.xcassets/claude-capsule-logo.imageset/Contents.json new file mode 100644 index 00000000..330d0cf8 --- /dev/null +++ b/ora/Assets/Catalogs/Capsule.xcassets/claude-capsule-logo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "claude-capsule-logo.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ora/Assets/Catalogs/Capsule.xcassets/claude-capsule-logo.imageset/claude-capsule-logo.svg b/ora/Assets/Catalogs/Capsule.xcassets/claude-capsule-logo.imageset/claude-capsule-logo.svg new file mode 100644 index 00000000..88968da6 --- /dev/null +++ b/ora/Assets/Catalogs/Capsule.xcassets/claude-capsule-logo.imageset/claude-capsule-logo.svg @@ -0,0 +1,7 @@ + + + Claude + + + + diff --git a/ora/Assets/Catalogs/Capsule.xcassets/copilot-capsule-logo.imageset/Contents.json b/ora/Assets/Catalogs/Capsule.xcassets/copilot-capsule-logo.imageset/Contents.json new file mode 100644 index 00000000..ba150758 --- /dev/null +++ b/ora/Assets/Catalogs/Capsule.xcassets/copilot-capsule-logo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "copilot-capsule-logo.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ora/Assets/Catalogs/Capsule.xcassets/copilot-capsule-logo.imageset/copilot-capsule-logo.svg b/ora/Assets/Catalogs/Capsule.xcassets/copilot-capsule-logo.imageset/copilot-capsule-logo.svg new file mode 100644 index 00000000..78875ee3 --- /dev/null +++ b/ora/Assets/Catalogs/Capsule.xcassets/copilot-capsule-logo.imageset/copilot-capsule-logo.svg @@ -0,0 +1,42 @@ + + + + + + + + diff --git a/ora/Assets/Catalogs/Capsule.xcassets/copilot-color-capsule-logo.imageset/Contents.json b/ora/Assets/Catalogs/Capsule.xcassets/copilot-color-capsule-logo.imageset/Contents.json new file mode 100644 index 00000000..151637a6 --- /dev/null +++ b/ora/Assets/Catalogs/Capsule.xcassets/copilot-color-capsule-logo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "copilot-color.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ora/Assets/Catalogs/Capsule.xcassets/copilot-color-capsule-logo.imageset/copilot-color.svg b/ora/Assets/Catalogs/Capsule.xcassets/copilot-color-capsule-logo.imageset/copilot-color.svg new file mode 100644 index 00000000..4f4031a8 --- /dev/null +++ b/ora/Assets/Catalogs/Capsule.xcassets/copilot-color-capsule-logo.imageset/copilot-color.svg @@ -0,0 +1 @@ +Copilot \ No newline at end of file diff --git a/ora/Assets/Catalogs/Capsule.xcassets/duckduckgo-capsule-logo.imageset/Contents.json b/ora/Assets/Catalogs/Capsule.xcassets/duckduckgo-capsule-logo.imageset/Contents.json new file mode 100644 index 00000000..03ec690c --- /dev/null +++ b/ora/Assets/Catalogs/Capsule.xcassets/duckduckgo-capsule-logo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "duckduckgo-capsule-logo.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ora/Assets/Catalogs/Capsule.xcassets/duckduckgo-capsule-logo.imageset/duckduckgo-capsule-logo.svg b/ora/Assets/Catalogs/Capsule.xcassets/duckduckgo-capsule-logo.imageset/duckduckgo-capsule-logo.svg new file mode 100644 index 00000000..415fda00 --- /dev/null +++ b/ora/Assets/Catalogs/Capsule.xcassets/duckduckgo-capsule-logo.imageset/duckduckgo-capsule-logo.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ora/Assets/Catalogs/Capsule.xcassets/gemini-capsule-logo.imageset/Contents.json b/ora/Assets/Catalogs/Capsule.xcassets/gemini-capsule-logo.imageset/Contents.json new file mode 100644 index 00000000..d97ba10b --- /dev/null +++ b/ora/Assets/Catalogs/Capsule.xcassets/gemini-capsule-logo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "gemini-capsule-logo.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ora/Assets/Catalogs/Capsule.xcassets/gemini-capsule-logo.imageset/gemini-capsule-logo.svg b/ora/Assets/Catalogs/Capsule.xcassets/gemini-capsule-logo.imageset/gemini-capsule-logo.svg new file mode 100644 index 00000000..a3fc733e --- /dev/null +++ b/ora/Assets/Catalogs/Capsule.xcassets/gemini-capsule-logo.imageset/gemini-capsule-logo.svg @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/ora/Assets/Catalogs/Capsule.xcassets/gemini-color-capsule-logo.imageset/Contents.json b/ora/Assets/Catalogs/Capsule.xcassets/gemini-color-capsule-logo.imageset/Contents.json new file mode 100644 index 00000000..de59dddb --- /dev/null +++ b/ora/Assets/Catalogs/Capsule.xcassets/gemini-color-capsule-logo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "gemini-color.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ora/Assets/Catalogs/Capsule.xcassets/gemini-color-capsule-logo.imageset/gemini-color.png b/ora/Assets/Catalogs/Capsule.xcassets/gemini-color-capsule-logo.imageset/gemini-color.png new file mode 100644 index 00000000..e539633a Binary files /dev/null and b/ora/Assets/Catalogs/Capsule.xcassets/gemini-color-capsule-logo.imageset/gemini-color.png differ diff --git a/ora/Assets/Catalogs/Capsule.xcassets/google-capsule-logo.imageset/Contents.json b/ora/Assets/Catalogs/Capsule.xcassets/google-capsule-logo.imageset/Contents.json new file mode 100644 index 00000000..9b1a0ee6 --- /dev/null +++ b/ora/Assets/Catalogs/Capsule.xcassets/google-capsule-logo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "google-capsule-logo.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ora/Assets/Catalogs/Capsule.xcassets/google-capsule-logo.imageset/google-capsule-logo.svg b/ora/Assets/Catalogs/Capsule.xcassets/google-capsule-logo.imageset/google-capsule-logo.svg new file mode 100644 index 00000000..cf464af1 --- /dev/null +++ b/ora/Assets/Catalogs/Capsule.xcassets/google-capsule-logo.imageset/google-capsule-logo.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/ora/Assets/Catalogs/Capsule.xcassets/grok-capsule-logo.imageset/Contents.json b/ora/Assets/Catalogs/Capsule.xcassets/grok-capsule-logo.imageset/Contents.json index d179dcd0..dbf7dd1c 100644 --- a/ora/Assets/Catalogs/Capsule.xcassets/grok-capsule-logo.imageset/Contents.json +++ b/ora/Assets/Catalogs/Capsule.xcassets/grok-capsule-logo.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "grok-white-capsule-logo.svg", + "filename" : "grok-black-capsule-logo.svg", "idiom" : "universal" }, { @@ -11,7 +11,7 @@ "value" : "dark" } ], - "filename" : "grok-black-capsule-logo.svg", + "filename" : "grok-white-capsule-logo.svg", "idiom" : "universal" } ], diff --git a/ora/Assets/Catalogs/Capsule.xcassets/kagi-capsule-logo.imageset/Contents.json b/ora/Assets/Catalogs/Capsule.xcassets/kagi-capsule-logo.imageset/Contents.json new file mode 100644 index 00000000..cae69d1a --- /dev/null +++ b/ora/Assets/Catalogs/Capsule.xcassets/kagi-capsule-logo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "kagi-capsule-logo.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ora/Assets/Catalogs/Capsule.xcassets/kagi-capsule-logo.imageset/kagi-capsule-logo.svg b/ora/Assets/Catalogs/Capsule.xcassets/kagi-capsule-logo.imageset/kagi-capsule-logo.svg new file mode 100644 index 00000000..6e52ecb6 --- /dev/null +++ b/ora/Assets/Catalogs/Capsule.xcassets/kagi-capsule-logo.imageset/kagi-capsule-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ora/Assets/Catalogs/Capsule.xcassets/openai-capsule-logo.imageset/Contents.json b/ora/Assets/Catalogs/Capsule.xcassets/openai-capsule-logo.imageset/Contents.json index 7488035c..0eabd4e8 100644 --- a/ora/Assets/Catalogs/Capsule.xcassets/openai-capsule-logo.imageset/Contents.json +++ b/ora/Assets/Catalogs/Capsule.xcassets/openai-capsule-logo.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "openai-white-capsule-logo 1.svg", + "filename" : "opeai-black-capsule-logo.svg", "idiom" : "universal" }, { @@ -11,7 +11,7 @@ "value" : "dark" } ], - "filename" : "opeai-black-capsule-logo.svg", + "filename" : "openai-white-capsule-logo 1.svg", "idiom" : "universal" } ], diff --git a/ora/Assets/Catalogs/Capsule.xcassets/x-capsule-logo.imageset/Contents.json b/ora/Assets/Catalogs/Capsule.xcassets/x-capsule-logo.imageset/Contents.json new file mode 100644 index 00000000..b2589a7a --- /dev/null +++ b/ora/Assets/Catalogs/Capsule.xcassets/x-capsule-logo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "x-capsule-logo.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ora/Assets/Catalogs/Capsule.xcassets/x-capsule-logo.imageset/x-capsule-logo.svg b/ora/Assets/Catalogs/Capsule.xcassets/x-capsule-logo.imageset/x-capsule-logo.svg new file mode 100644 index 00000000..1c16ee3b --- /dev/null +++ b/ora/Assets/Catalogs/Capsule.xcassets/x-capsule-logo.imageset/x-capsule-logo.svg @@ -0,0 +1,10 @@ + + + + + + + diff --git a/ora/Core/BrowserEngine/BrowserPage.swift b/ora/Core/BrowserEngine/BrowserPage.swift index 0f201338..8e90201e 100644 --- a/ora/Core/BrowserEngine/BrowserPage.swift +++ b/ora/Core/BrowserEngine/BrowserPage.swift @@ -10,6 +10,7 @@ final class BrowserPage: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptM private var originalURL: URL? private(set) var lastCommittedURL: URL? private(set) var isDownloadNavigation = false + private(set) var sslBypassedHosts: Set = [] init( profile: BrowserEngineProfile, @@ -159,6 +160,10 @@ final class BrowserPage: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptM webView.removeFromSuperview() } + func bypassSSL(for host: String) { + sslBypassedHosts.insert(host) + } + private func emitNavigationEvent( phase: BrowserNavigationPhase, url: URL?, @@ -306,6 +311,21 @@ final class BrowserPage: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptM originalURL = nil } + func webView( + _ webView: WKWebView, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, + let serverTrust = challenge.protectionSpace.serverTrust, + sslBypassedHosts.contains(challenge.protectionSpace.host) + { + completionHandler(.useCredential, URLCredential(trust: serverTrust)) + } else { + completionHandler(.performDefaultHandling, nil) + } + } + @available(macOS 11.3, *) func webView( _ webView: WKWebView, diff --git a/ora/Core/Constants/Theme.swift b/ora/Core/Constants/Theme.swift index a0e86d80..2af22482 100644 --- a/ora/Core/Constants/Theme.swift +++ b/ora/Core/Constants/Theme.swift @@ -66,7 +66,7 @@ struct Theme: Equatable { } var launcherMainBackground: Color { - colorScheme == .dark ? Color(.windowBackgroundColor).opacity(0.7) : .white.opacity(0.8) + colorScheme == .dark ? self.popoverBackground.opacity(0.75) : .white.opacity(0.8) } var placeholder: Color { diff --git a/ora/Core/Utilities/Utils.swift b/ora/Core/Utilities/Utils.swift index 63ca8df4..3dd36666 100644 --- a/ora/Core/Utilities/Utils.swift +++ b/ora/Core/Utilities/Utils.swift @@ -15,6 +15,10 @@ func extractDomainOrIP(from text: String) -> String? { func isValidURL(_ text: String) -> Bool { guard let host = extractDomainOrIP(from: text) else { return false } + if host == "localhost" { + return true + } + let ipPattern = #"^(\d{1,3}\.){3}\d{1,3}$"# if host.range(of: ipPattern, options: .regularExpression) != nil { return true @@ -31,8 +35,8 @@ func constructURL(from text: String) -> URL? { if trimmed.hasPrefix("http://") || trimmed.hasPrefix("https://") { return URL(string: trimmed) } - if isValidURL(trimmed) { - return URL(string: "https://\(trimmed)") - } - return nil + guard isValidURL(trimmed) else { return nil } + let host = extractDomainOrIP(from: trimmed) + let scheme = (host == "localhost") ? "http" : "https" + return URL(string: "\(scheme)://\(trimmed)") } diff --git a/ora/Features/Browser/Views/BrowserWebContentView.swift b/ora/Features/Browser/Views/BrowserWebContentView.swift index c2fdcbcd..77e99e99 100644 --- a/ora/Features/Browser/Views/BrowserWebContentView.swift +++ b/ora/Features/Browser/Views/BrowserWebContentView.swift @@ -35,7 +35,10 @@ struct BrowserWebContentView: View { ? { tab.goBack() tab.clearNavigationError() - } : nil + } : nil, + onContinueAnyway: { + tab.continueToInsecureSite() + } ) .id(tab.id) } else if let page = tab.browserPage { diff --git a/ora/Features/Browser/Views/HomeView.swift b/ora/Features/Browser/Views/HomeView.swift index 0177192a..4c6eae1d 100644 --- a/ora/Features/Browser/Views/HomeView.swift +++ b/ora/Features/Browser/Views/HomeView.swift @@ -10,7 +10,7 @@ struct HomeView: View { .ignoresSafeArea(.all) .frame(maxWidth: .infinity, maxHeight: .infinity) .contentShape(Rectangle()) - .background(theme.background.opacity(0.65)) + .background(theme.background.opacity(0.85)) .background( BlurEffectView(material: .underWindowBackground, blendingMode: .behindWindow) ) @@ -36,11 +36,11 @@ struct HomeView: View { .resizable() .renderingMode(.template) .frame(width: 50, height: 50) - .foregroundColor(theme.foreground.opacity(0.3)) + .foregroundColor(theme.foreground.opacity(0.2)) Text("Less noise, more browsing.") .font(.system(size: 16, weight: .semibold)) - .foregroundColor(theme.foreground.opacity(0.3)) + .foregroundColor(theme.foreground.opacity(0.2)) } .offset(x: -10, y: 120) .zIndex(2) diff --git a/ora/Features/Browser/Views/StatusPageView.swift b/ora/Features/Browser/Views/StatusPageView.swift index 111c3348..5b6c2b09 100644 --- a/ora/Features/Browser/Views/StatusPageView.swift +++ b/ora/Features/Browser/Views/StatusPageView.swift @@ -7,6 +7,7 @@ struct StatusPageView: View { let failedURL: URL? let onRetry: () -> Void let onGoBack: (() -> Void)? + var onContinueAnyway: (() -> Void)? @Environment(\.theme) var theme var body: some View { @@ -59,6 +60,16 @@ struct StatusPageView: View { } .padding(.top, 8) + if errorType == .security, let continueAnyway = onContinueAnyway { + OraButton( + label: "Continue Anyway", + variant: .ghost, + size: .sm, + labelColorOverride: theme.mutedForeground, + action: continueAnyway + ) + } + Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/ora/Features/Downloads/Views/DownloadHistoryRow.swift b/ora/Features/Downloads/Views/DownloadHistoryRow.swift index 594ee5f8..c56aecd2 100644 --- a/ora/Features/Downloads/Views/DownloadHistoryRow.swift +++ b/ora/Features/Downloads/Views/DownloadHistoryRow.swift @@ -63,7 +63,7 @@ struct DownloadHistoryRow: View { .padding(.horizontal, 6) .padding(.vertical, 6) .background( - RoundedRectangle(cornerRadius: 6) + ConditionallyConcentricRectangle(cornerRadius: 12) .fill(isHovered ? theme.mutedBackground.opacity(0.5) : .clear) ) .contentShape(Rectangle()) diff --git a/ora/Features/Launcher/LauncherEnvironment.swift b/ora/Features/Launcher/LauncherEnvironment.swift new file mode 100644 index 00000000..b311741c --- /dev/null +++ b/ora/Features/Launcher/LauncherEnvironment.swift @@ -0,0 +1,17 @@ +import SwiftUI + +enum MoveDirection { + case up + case down +} + +struct LauncherMouseHasMovedKey: EnvironmentKey { + static let defaultValue: Bool = false +} + +extension EnvironmentValues { + var launcherMouseHasMoved: Bool { + get { self[LauncherMouseHasMovedKey.self] } + set { self[LauncherMouseHasMovedKey.self] = newValue } + } +} diff --git a/ora/Features/Launcher/LauncherView.swift b/ora/Features/Launcher/LauncherView.swift index a574411f..332d2966 100644 --- a/ora/Features/Launcher/LauncherView.swift +++ b/ora/Features/Launcher/LauncherView.swift @@ -3,25 +3,27 @@ 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 @EnvironmentObject var privacyMode: PrivacyMode @Environment(\.theme) private var theme - @StateObject private var searchEngineService = SearchEngineService() + + @StateObject private var viewModel = LauncherViewModel() @State private var input = "" @State private var isVisible = false @FocusState private var isTextFieldFocused: Bool - @State private var match: LauncherMain.Match? + @State private var match: LauncherMatch? + @State private var mouseHasMoved = false + @State private var mouseMonitor: Any? var clearOverlay: Bool? = false private func onTabPress() { guard !input.isEmpty else { return } - if let searchEngine = searchEngineService.findSearchEngine(for: input) { - let customEngine = searchEngineService.settings.customSearchEngines + if let searchEngine = viewModel.searchEngineService.findSearchEngine(for: input) { + let customEngine = viewModel.searchEngineService.settings.customSearchEngines .first { $0.searchURL == searchEngine.searchURL } match = searchEngine.toLauncherMatch( originalAlias: input, @@ -36,11 +38,11 @@ struct LauncherView: View { var engineToUse = match if engineToUse == nil, - let defaultEngine = searchEngineService.getDefaultSearchEngine( + let defaultEngine = viewModel.searchEngineService.getDefaultSearchEngine( for: tabManager.activeContainer?.id ) { - let customEngine = searchEngineService.settings.customSearchEngines + let customEngine = viewModel.searchEngineService.settings.customSearchEngines .first { $0.searchURL == defaultEngine.searchURL } engineToUse = defaultEngine.toLauncherMatch( originalAlias: correctInput, @@ -49,7 +51,7 @@ struct LauncherView: View { } if let engine = engineToUse, - let url = searchEngineService.createSearchURL(for: engine, query: correctInput) + let url = viewModel.searchEngineService.createSearchURL(for: engine, query: correctInput) { tabManager .openTab( @@ -82,13 +84,14 @@ struct LauncherView: View { match: $match, isFocused: $isTextFieldFocused, onTabPress: onTabPress, - onSubmit: onSubmit + onSubmit: onSubmit, + viewModel: viewModel ) .gradientAnimatingBorder( - color: match?.faviconBackgroundColor ?? match?.color ?? .clear, + color: match?.color ?? .clear, trigger: match != nil ) - .padding(.horizontal, 20) // Add horizontal margins around the search bar + .padding(.horizontal, 20) .offset(y: 250) .scaleEffect(isVisible ? 1.0 : 0.9) .opacity(isVisible ? 1.0 : 0.0) @@ -97,13 +100,37 @@ struct LauncherView: View { .onAppear { isVisible = true isTextFieldFocused = true - searchEngineService.setTheme(theme) + viewModel.searchEngineService.setTheme(theme) + viewModel.configure( + tabManager: tabManager, + historyManager: historyManager, + downloadManager: downloadManager, + appState: appState, + privacyMode: privacyMode, + onSubmit: onSubmit + ) + mouseHasMoved = false + mouseMonitor = NSEvent.addLocalMonitorForEvents(matching: .mouseMoved) { event in + mouseHasMoved = true + if let monitor = mouseMonitor { + NSEvent.removeMonitor(monitor) + mouseMonitor = nil + } + return event + } + } + .onDisappear { + if let monitor = mouseMonitor { + NSEvent.removeMonitor(monitor) + mouseMonitor = nil + } } .onChange(of: appState.showLauncher) { _, newValue in isVisible = newValue } } .frame(maxWidth: .infinity, maxHeight: .infinity) + .environment(\.launcherMouseHasMoved, mouseHasMoved) .onExitCommand { if tabManager.activeTab != nil { isVisible = false diff --git a/ora/Features/Launcher/Main/LauncherMain.swift b/ora/Features/Launcher/Main/LauncherMain.swift index 10928710..b62a23d1 100644 --- a/ora/Features/Launcher/Main/LauncherMain.swift +++ b/ora/Features/Launcher/Main/LauncherMain.swift @@ -1,284 +1,14 @@ import SwiftUI -enum MoveDirection { - case up - case down -} - -class Debouncer { - private var workItem: DispatchWorkItem? - private let delay: TimeInterval - - init(delay: TimeInterval) { - self.delay = delay - } - - func run(_ block: @escaping @Sendable () async -> Void) { - workItem?.cancel() - let item = DispatchWorkItem { - Task { await block() } - } - workItem = item - DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: item) - } -} - -let debouncer = Debouncer(delay: 0.2) - struct LauncherMain: View { - struct Match { - let text: String - let color: Color - let foregroundColor: Color - let icon: String - let originalAlias: String - let searchURL: String - let favicon: NSImage? - let faviconBackgroundColor: Color? - } - @Binding var text: String - @Binding var match: Match? + @Binding var match: LauncherMatch? var isFocused: FocusState.Binding let onTabPress: () -> Void let onSubmit: (String?) -> Void + @ObservedObject var viewModel: LauncherViewModel @Environment(\.theme) private var theme - @EnvironmentObject var historyManager: HistoryManager - @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 searchEngineService = SearchEngineService() - - @State private var suggestions: [LauncherSuggestion] = [] - - private func createAISuggestion(engineName: SearchEngineID, query: String? = nil) - -> LauncherSuggestion - { - guard let engine = searchEngineService.getSearchEngine(engineName) else { - return LauncherSuggestion( - type: .aiChat, - title: query ?? engineName.rawValue, - name: engineName.rawValue, - action: {} - ) - } - - _ = FaviconService.shared.getFavicon(for: engine.searchURL) - let faviconURL = FaviconService.shared.faviconURL(forSearchURL: engine.searchURL) - - return LauncherSuggestion( - type: .aiChat, - title: query ?? engine.name, - name: engine.name, - faviconURL: faviconURL, - action: { - tabManager.openFromEngine( - engineName: engineName, - query: query ?? text, - historyManager: historyManager, - isPrivate: privacyMode.isPrivate - ) - } - ) - } - - func defaultSuggestions() -> [LauncherSuggestion] { - let containerId = tabManager.activeContainer?.id - let searchEngine = searchEngineService.getDefaultSearchEngine(for: containerId) - let engineName = searchEngine?.name ?? "Google" - return [ - LauncherSuggestion( - type: .suggestedQuery, title: "Search on \(engineName)", - action: { onSubmit(nil) } - ), - createAISuggestion(engineName: .grok), - createAISuggestion(engineName: .chatgpt), - createAISuggestion(engineName: .claude), - createAISuggestion(engineName: .gemini) - ] - } - - private func isValidHostname(_ input: String) -> Bool { - let regex = #"^([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$"# - return input.range(of: regex, options: .regularExpression) != nil - } - - func searchHandler(_ text: String) { - guard !text.trimmingCharacters(in: .whitespaces).isEmpty else { - suggestions = defaultSuggestions() - return - } - - let histories = historyManager.search( - text, - activeContainerId: tabManager.activeContainer?.id ?? UUID() - ) - let tabs = tabManager.search(text) - - suggestions = [] - - var itemsCount = 0 - appendOpenTabs(tabs, itemsCount: &itemsCount) - appendOpenURLSuggestionIfNeeded(text) - appendSearchWithDefaultEngineSuggestion(text) - - let insertIndex = suggestions.count - requestAutoSuggestions(text, insertAt: insertIndex) - - appendHistorySuggestions(histories, itemsCount: &itemsCount) - appendAISuggestionsIfNeeded(text) - - focusedElement = suggestions.first?.id ?? UUID() - } - - private func appendOpenTabs(_ tabs: [Tab], itemsCount: inout Int) { - for tab in tabs { - if itemsCount >= 2 { break } - suggestions.append( - LauncherSuggestion( - type: .openedTab, - title: tab.title, - url: tab.url, - faviconURL: tab.favicon, - faviconLocalFile: tab.faviconLocalFile, - action: { - if !tab.isWebViewReady { - tab.restoreTransientState( - historyManager: historyManager, - downloadManager: downloadManager, - tabManager: tabManager, - isPrivate: privacyMode.isPrivate - ) - } - tabManager.activateTab(tab) - } - ) - ) - itemsCount += 1 - } - } - - 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 - } - guard let url = finalURL else { return } - suggestions.append( - LauncherSuggestion( - type: .suggestedLink, - title: text, - url: url, - action: { - tabManager.openTab( - url: url, - historyManager: historyManager, - downloadManager: downloadManager, - isPrivate: privacyMode.isPrivate - ) - } - ) - ) - } - - private func appendSearchWithDefaultEngineSuggestion(_ text: String) { - let containerId = tabManager.activeContainer?.id - let searchEngine = searchEngineService.getDefaultSearchEngine(for: containerId) - let engineName = searchEngine?.name ?? "Google" - suggestions.append( - LauncherSuggestion( - type: .suggestedQuery, - title: "\(text) - Search with \(engineName)", - action: { onSubmit(nil) } - ) - ) - } - - private func requestAutoSuggestions(_ text: String, insertAt: Int) { - let containerId = tabManager.activeContainer?.id - debouncer.run { - let searchEngine = await self.searchEngineService.getDefaultSearchEngine(for: containerId) - if let autoSuggestions = searchEngine?.autoSuggestions { - let searchSuggestions = await autoSuggestions(text) - await MainActor.run { - var localCount = 0 - for ss in searchSuggestions { - if localCount == 3 { break } - let insertIndex = insertAt + localCount - let suggestion = LauncherSuggestion( - type: .suggestedQuery, - title: ss, - action: { onSubmit(ss) } - ) - if insertIndex <= suggestions.count { - suggestions.insert(suggestion, at: insertIndex) - } else { - suggestions.append(suggestion) - } - localCount += 1 - } - } - } - } - } - - private func appendHistorySuggestions(_ histories: [History], itemsCount: inout Int) { - for history in histories { - if itemsCount >= 5 { break } - suggestions.append( - LauncherSuggestion( - type: .suggestedLink, - title: history.title, - url: history.url, - faviconURL: history.faviconURL, - faviconLocalFile: history.faviconLocalFile, - action: { - tabManager.openTab( - url: history.url, - historyManager: historyManager, - isPrivate: privacyMode.isPrivate - ) - } - ) - ) - itemsCount += 1 - } - } - - private func appendAISuggestionsIfNeeded(_ text: String) { - guard isAISuitableQuery(text) else { return } - suggestions.append(createAISuggestion(engineName: .grok, query: text)) - suggestions.append(createAISuggestion(engineName: .chatgpt, query: text)) - suggestions.append(createAISuggestion(engineName: .claude, query: text)) - suggestions.append(createAISuggestion(engineName: .gemini, query: text)) - } - - func executeCommand() { - if let suggestion = - suggestions - .first(where: { $0.id == focusedElement }) - { - suggestion.action() - appState.showLauncher = false - } - } - - func moveFocusedElement(_ dir: MoveDirection) { - guard let idx = suggestions.firstIndex(where: { $0.id == focusedElement }) else { return } - let offset = dir == .up ? -1 : 1 - let newIndex = (idx + offset + suggestions.count) % suggestions.count - focusedElement = suggestions[newIndex].id - } var body: some View { VStack(alignment: .leading, spacing: 6) { @@ -295,9 +25,7 @@ struct LauncherMain: View { text: match?.text ?? "", color: match?.color ?? .blue, foregroundColor: match?.foregroundColor ?? .white, - icon: match?.icon ?? "", - favicon: match?.favicon, - faviconBackgroundColor: match?.faviconBackgroundColor + icon: match?.icon ?? "" ) } LauncherTextField( @@ -305,28 +33,28 @@ struct LauncherMain: View { font: NSFont.systemFont(ofSize: 18, weight: .medium), onTab: onTabPress, onSubmit: { - executeCommand() + viewModel.executeCommand() }, onDelete: { - if text.isEmpty, match != nil { - text = match!.originalAlias + if text.isEmpty, let currentMatch = match { + text = currentMatch.originalAlias match = nil return true } return false }, onMoveUp: { - moveFocusedElement(.up) + viewModel.moveFocusedElement(.up) }, onMoveDown: { - moveFocusedElement(.down) + viewModel.moveFocusedElement(.down) }, - cursorColor: match?.faviconBackgroundColor ?? match?.color - ?? (theme.foreground), + cursorColor: match?.color ?? theme.foreground, placeholder: getPlaceholder(match: match) ) - .onChange(of: text) { _, _ in - searchHandler(text) + .onChange(of: text) { _, newValue in + viewModel.currentText = newValue + viewModel.searchHandler(newValue) } .textFieldStyle(PlainTextFieldStyle()) .focused(isFocused) @@ -336,11 +64,11 @@ struct LauncherMain: View { .padding(.vertical, 10) .frame(maxWidth: .infinity, alignment: .leading) - if match == nil, !suggestions.isEmpty { + if match == nil, !viewModel.suggestions.isEmpty { LauncherSuggestionsView( text: $text, - suggestions: $suggestions, - focusedElement: $focusedElement + suggestions: $viewModel.suggestions, + focusedElement: $viewModel.focusedElement ) } } @@ -348,15 +76,15 @@ struct LauncherMain: View { .frame(minWidth: 320, maxWidth: 814, alignment: .leading) .background(theme.launcherMainBackground) .background(BlurEffectView(material: .popover, blendingMode: .withinWindow)) - .cornerRadius(16) + .clipShape(ConditionallyConcentricRectangle(cornerRadius: 20, style: .continuous)) .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .inset(by: 0.25) + ConditionallyConcentricRectangle(cornerRadius: 20, style: .continuous) .stroke( - Color(match?.faviconBackgroundColor ?? match?.color ?? theme.foreground) - .opacity(0.15), - lineWidth: 0.5 + Color(match?.color ?? theme.foreground) + .opacity(0.05), + lineWidth: 1 ) + .padding(0.25) ) .shadow( color: Color.black.opacity(0.1), @@ -364,46 +92,23 @@ struct LauncherMain: View { ) } - private func getPlaceholder(match: Match?) -> String { - if match == nil { + private func getPlaceholder(match: LauncherMatch?) -> String { + guard let match else { return "Search the web or enter url..." } - // Find the search engine by name to get its isAIChat property - if let engine = searchEngineService.getSearchEngine(byName: match!.text) { + if let engine = viewModel.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)" + return "Search on \(match.text)" } - private func getIconName(match: Match?, text: String) -> String { + private func getIconName(match: LauncherMatch?, text: String) -> String { if match != nil { return "magnifyingglass" } return isValidURL(text) ? "globe" : "magnifyingglass" } } - -func isAISuitableQuery(_ query: String) -> Bool { - let lowercased = query.lowercased() - - // AI-suited queries: open-ended, creative, opinion-based, etc. - let aiKeywords = [ - #"^(who|when|where|what|how|why)\b.*\?$"#, // e.g. "When was Apple founded?" - #"^\d{4}"#, - "summarize", "rewrite", "explain", "code", "how to", "generate", - "idea", "opinion", "feedback", "story", "joke", "email", "draft", - "translate", "compare", "alternatives", "improve", "fix", "suggest" - ] - - for keyword in aiKeywords { - if lowercased.contains(keyword) { - return true - } - } - - return false -} diff --git a/ora/Features/Launcher/Main/SearchCapsule.swift b/ora/Features/Launcher/Main/SearchCapsule.swift index a997360f..8eaefc0f 100644 --- a/ora/Features/Launcher/Main/SearchCapsule.swift +++ b/ora/Features/Launcher/Main/SearchCapsule.swift @@ -1,4 +1,3 @@ -import AppKit import SwiftUI struct SearchEngineCapsule: View { @@ -6,22 +5,17 @@ struct SearchEngineCapsule: View { let color: Color let foregroundColor: Color let icon: String - let favicon: NSImage? - let faviconBackgroundColor: Color? var body: some View { HStack(alignment: .center, spacing: 8) { - if let favicon { - Image(nsImage: favicon) - .resizable() - .frame(width: 16, height: 16) - } else if icon.isEmpty { + if icon.isEmpty { Image(systemName: "magnifyingglass") .resizable() .frame(width: 16, height: 16) .foregroundStyle(foregroundColor) } else { Image(icon) + .renderingMode(.template) .resizable() .frame(width: 16, height: 16) .foregroundStyle(foregroundColor) @@ -34,7 +28,7 @@ struct SearchEngineCapsule: View { .padding(.vertical, 6) .padding(.horizontal, 12) .frame(alignment: .leading) - .background(faviconBackgroundColor ?? color) + .background(color) .cornerRadius(99) } } diff --git a/ora/Features/Launcher/Models/LauncherMatch.swift b/ora/Features/Launcher/Models/LauncherMatch.swift new file mode 100644 index 00000000..b53afd33 --- /dev/null +++ b/ora/Features/Launcher/Models/LauncherMatch.swift @@ -0,0 +1,10 @@ +import SwiftUI + +struct LauncherMatch { + let text: String + let color: Color + let foregroundColor: Color + let icon: String + let originalAlias: String + let searchURL: String +} diff --git a/ora/Features/Launcher/Models/LauncherSuggestion.swift b/ora/Features/Launcher/Models/LauncherSuggestion.swift new file mode 100644 index 00000000..1dbe8430 --- /dev/null +++ b/ora/Features/Launcher/Models/LauncherSuggestion.swift @@ -0,0 +1,43 @@ +import SwiftUI + +enum LauncherSuggestionType { + case openedTab, suggestedQuery, suggestedLink, aiChat +} + +struct LauncherSuggestion: Identifiable { + let id = UUID() + let type: LauncherSuggestionType + let title: String + let name: String? + let url: URL? + let icon: String? + let color: Color? + let engineForegroundColor: Color? + let faviconURL: URL? + let faviconLocalFile: URL? + let action: () -> Void + + init( + type: LauncherSuggestionType, + title: String, + name: String? = nil, + url: URL? = nil, + icon: String? = nil, + color: Color? = nil, + engineForegroundColor: Color? = nil, + faviconURL: URL? = nil, + faviconLocalFile: URL? = nil, + action: @escaping () -> Void + ) { + self.type = type + self.title = title + self.name = name + self.url = url + self.icon = icon + self.color = color + self.engineForegroundColor = engineForegroundColor + self.faviconURL = faviconURL + self.faviconLocalFile = faviconLocalFile + self.action = action + } +} diff --git a/ora/Features/Launcher/State/LauncherViewModel.swift b/ora/Features/Launcher/State/LauncherViewModel.swift new file mode 100644 index 00000000..1e6c3dda --- /dev/null +++ b/ora/Features/Launcher/State/LauncherViewModel.swift @@ -0,0 +1,349 @@ +import SwiftUI + +@MainActor +class LauncherViewModel: ObservableObject { + let searchEngineService = SearchEngineService() + + @Published var suggestions: [LauncherSuggestion] = [] + @Published var focusedElement: UUID = .init() + + /// Kept in sync with the view's text binding so closures can read current input. + var currentText: String = "" + + private let debouncer = Debouncer(delay: 0.2) + + // Dependencies injected from the view layer + private(set) var tabManager: TabManager? + private(set) var historyManager: HistoryManager? + private(set) var downloadManager: DownloadManager? + private(set) var appState: AppState? + private(set) var privacyMode: PrivacyMode? + private(set) var onSubmit: ((String?) -> Void)? + + func configure( + tabManager: TabManager, + historyManager: HistoryManager, + downloadManager: DownloadManager, + appState: AppState, + privacyMode: PrivacyMode, + onSubmit: @escaping (String?) -> Void + ) { + self.tabManager = tabManager + self.historyManager = historyManager + self.downloadManager = downloadManager + self.appState = appState + self.privacyMode = privacyMode + self.onSubmit = onSubmit + } + + // MARK: - Search Logic + + func searchHandler(_ text: String) { + guard let tabManager, let historyManager else { return } + + guard !text.trimmingCharacters(in: .whitespaces).isEmpty else { + suggestions = defaultSuggestions() + return + } + + let histories = historyManager.search( + text, + activeContainerId: tabManager.activeContainer?.id ?? UUID() + ) + let tabs = tabManager.search(text) + + suggestions = [] + + var itemsCount = 0 + appendOpenTabs(tabs, itemsCount: &itemsCount) + appendOpenURLSuggestionIfNeeded(text) + appendSearchWithDefaultEngineSuggestion(text) + + let insertIndex = suggestions.count + requestAutoSuggestions(text, insertAt: insertIndex) + + appendHistorySuggestions(histories, itemsCount: &itemsCount) + appendAISuggestionsIfNeeded(text) + + focusedElement = suggestions.first?.id ?? UUID() + } + + func defaultSuggestions() -> [LauncherSuggestion] { + guard let tabManager else { return [] } + let containerId = tabManager.activeContainer?.id + let searchEngine = searchEngineService.getDefaultSearchEngine(for: containerId) + let engineName = searchEngine?.name ?? "Google" + return [ + LauncherSuggestion( + type: .suggestedQuery, title: "Search on \(engineName)", + action: { [weak self] in self?.onSubmit?(nil) } + ), + createAISuggestion(engineName: .grok), + createAISuggestion(engineName: .chatgpt), + createAISuggestion(engineName: .claude), + createAISuggestion(engineName: .gemini) + ] + } + + func executeCommand() { + if let suggestion = suggestions.first(where: { $0.id == focusedElement }) { + suggestion.action() + appState?.showLauncher = false + } + } + + func moveFocusedElement(_ dir: MoveDirection) { + guard let idx = suggestions.firstIndex(where: { $0.id == focusedElement }) else { return } + let offset = dir == .up ? -1 : 1 + let newIndex = (idx + offset + suggestions.count) % suggestions.count + focusedElement = suggestions[newIndex].id + } + + // MARK: - Private Helpers + + private func createAISuggestion(engineName: SearchEngineID, query: String? = nil) + -> LauncherSuggestion + { + guard let engine = searchEngineService.getSearchEngine(engineName) else { + return LauncherSuggestion( + type: .aiChat, + title: query ?? engineName.rawValue, + name: engineName.rawValue, + action: {} + ) + } + + return LauncherSuggestion( + type: .aiChat, + title: query ?? engine.name, + name: engine.name, + icon: engine.icon.isEmpty ? nil : engine.icon, + color: engine.color, + engineForegroundColor: engine.foregroundColor, + action: { [weak self] in + guard let self, let tabManager = self.tabManager, + let historyManager = self.historyManager, + let privacyMode = self.privacyMode + else { return } + tabManager.openFromEngine( + engineName: engineName, + query: query ?? self.currentText, + historyManager: historyManager, + isPrivate: privacyMode.isPrivate + ) + } + ) + } + + private func appendOpenTabs(_ tabs: [Tab], itemsCount: inout Int) { + guard let tabManager, let historyManager, let downloadManager, let privacyMode else { + return + } + for tab in tabs { + if itemsCount >= 2 { break } + suggestions.append( + LauncherSuggestion( + type: .openedTab, + title: tab.title, + url: tab.url, + faviconURL: tab.favicon, + faviconLocalFile: tab.faviconLocalFile, + action: { + if !tab.isWebViewReady { + tab.restoreTransientState( + historyManager: historyManager, + downloadManager: downloadManager, + tabManager: tabManager, + isPrivate: privacyMode.isPrivate + ) + } + tabManager.activateTab(tab) + } + ) + ) + itemsCount += 1 + } + } + + private func appendOpenURLSuggestionIfNeeded(_ text: String) { + guard let tabManager, let historyManager, let downloadManager, let privacyMode else { + return + } + let finalURL: URL? = if let candidateURL = URL(string: text), candidateURL.scheme != nil, + candidateURL.host != nil + { + candidateURL + } else if isValidURL(text) { + constructURL(from: text) + } else { + nil + } + guard let url = finalURL else { return } + suggestions.append( + LauncherSuggestion( + type: .suggestedLink, + title: text, + url: url, + action: { + tabManager.openTab( + url: url, + historyManager: historyManager, + downloadManager: downloadManager, + isPrivate: privacyMode.isPrivate + ) + } + ) + ) + } + + private func appendSearchWithDefaultEngineSuggestion(_ text: String) { + guard let tabManager else { return } + let containerId = tabManager.activeContainer?.id + let searchEngine = searchEngineService.getDefaultSearchEngine(for: containerId) + let engineName = searchEngine?.name ?? "Google" + suggestions.append( + LauncherSuggestion( + type: .suggestedQuery, + title: "\(text) - \(engineName)", + action: { [weak self] in self?.onSubmit?(nil) } + ) + ) + } + + private func requestAutoSuggestions(_ text: String, insertAt: Int) { + guard let tabManager else { return } + let containerId = tabManager.activeContainer?.id + debouncer.run { [weak self] in + guard let self else { return } + let searchEngine = await self.searchEngineService.getDefaultSearchEngine( + for: containerId + ) + if let autoSuggestions = searchEngine?.autoSuggestions { + let searchSuggestions = await autoSuggestions(text) + await MainActor.run { + var localCount = 0 + for searchSuggestion in searchSuggestions { + if localCount == 3 { break } + let insertIndex = insertAt + localCount + let suggestion = LauncherSuggestion( + type: .suggestedQuery, + title: searchSuggestion, + action: { [weak self] in self?.onSubmit?(searchSuggestion) } + ) + if insertIndex <= self.suggestions.count { + self.suggestions.insert(suggestion, at: insertIndex) + } else { + self.suggestions.append(suggestion) + } + localCount += 1 + } + } + } + } + } + + private func appendHistorySuggestions(_ histories: [History], itemsCount: inout Int) { + guard let tabManager, let historyManager, let privacyMode else { return } + for history in histories { + if itemsCount >= 5 { break } + suggestions.append( + LauncherSuggestion( + type: .suggestedLink, + title: history.title, + url: history.url, + faviconURL: history.faviconURL, + faviconLocalFile: history.faviconLocalFile, + action: { + tabManager.openTab( + url: history.url, + historyManager: historyManager, + isPrivate: privacyMode.isPrivate + ) + } + ) + ) + itemsCount += 1 + } + } + + private func appendAISuggestionsIfNeeded(_ text: String) { + guard isAISuitableQuery(text) else { return } + suggestions.append(createAISuggestion(engineName: .grok, query: text)) + suggestions.append(createAISuggestion(engineName: .chatgpt, query: text)) + suggestions.append(createAISuggestion(engineName: .claude, query: text)) + suggestions.append(createAISuggestion(engineName: .gemini, query: text)) + } + + private func isAISuitableQuery(_ query: String) -> Bool { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + let lowercased = trimmed.lowercased() + let words = lowercased.split(separator: " ") + + // Negative signals: single words and URLs are not AI queries + if words.count <= 1 { return false } + if isValidURL(trimmed) { return false } + + // Starts with a question word + let questionPrefixes = [ + "who ", "what ", "where ", "when ", "how ", "why ", "which ", + "is ", "are ", "can ", "does ", "do ", "should ", "would ", + "could ", "will ", "was ", "were ", "has ", "have " + ] + if questionPrefixes.contains(where: { lowercased.hasPrefix($0) }) { + return true + } + + // Ends with a question mark + if trimmed.hasSuffix("?") { + return true + } + + // Imperative / command phrases + let imperativePhrases = [ + "write me", "help me", "create a", "give me", "list of", + "make a", "tell me", "show me", "find me", "build a", + "design a", "plan a", "write a", "make me", "help with" + ] + if imperativePhrases.contains(where: { lowercased.contains($0) }) { + return true + } + + // Action keywords + let actionKeywords = [ + "summarize", "rewrite", "explain", "generate", "how to", + "translate", "compare", "alternatives", "improve", "suggest", + "recommend", "analyze", "convert", "calculate", "define", + "describe", "simplify", "debug", "optimize", "refactor", + "review", "draft", "code", "idea", "opinion", "story", + "joke", "email" + ] + if actionKeywords.contains(where: { lowercased.contains($0) }) { + return true + } + + // Natural language heuristic: 4+ words likely conversational + if words.count >= 4 { + return true + } + + return false + } +} + +private class Debouncer { + private var workItem: DispatchWorkItem? + private let delay: TimeInterval + + init(delay: TimeInterval) { + self.delay = delay + } + + func run(_ block: @escaping @Sendable () async -> Void) { + workItem?.cancel() + let item = DispatchWorkItem { + Task { await block() } + } + workItem = item + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: item) + } +} diff --git a/ora/Features/Launcher/Suggestions/LauncherSuggestionItem.swift b/ora/Features/Launcher/Suggestions/LauncherSuggestionItem.swift index 992df419..d2edc5fb 100644 --- a/ora/Features/Launcher/Suggestions/LauncherSuggestionItem.swift +++ b/ora/Features/Launcher/Suggestions/LauncherSuggestionItem.swift @@ -1,69 +1,34 @@ import SwiftUI -enum LauncherSuggestionType { - case openedTab, suggestedQuery, suggestedLink, aiChat -} - -struct LauncherSuggestion: Identifiable { - let id = UUID() - let type: LauncherSuggestionType - let title: String - let name: String? - let url: URL? - let icon: String? - let faviconURL: URL? - let faviconLocalFile: URL? - let action: () -> Void - - init( - type: LauncherSuggestionType, - title: String, - name: String? = nil, - url: URL? = nil, - icon: String? = nil, - faviconURL: URL? = nil, - faviconLocalFile: URL? = nil, - action: @escaping () -> Void - ) { - self.type = type - self.title = title - self.name = name - self.url = url - self.icon = icon - self.faviconURL = faviconURL - self.faviconLocalFile = faviconLocalFile - self.action = action - } -} - struct LauncherSuggestionItem: View { let suggestion: LauncherSuggestion - let defaultAI: SearchEngine? @Binding var focusedElement: UUID @State private var isHovered = false @Environment(\.theme) private var theme + @Environment(\.launcherMouseHasMoved) private var mouseHasMoved @EnvironmentObject var appState: AppState - @EnvironmentObject var toolbarManager: ToolbarManager - - init(suggestion: LauncherSuggestion, defaultAI: SearchEngine?, focusedElement: Binding) { - self.suggestion = suggestion - self.defaultAI = defaultAI - self._focusedElement = focusedElement - } private var isAIChat: Bool { suggestion.type == .aiChat } private var shouldShowURL: Bool { - suggestion.url != nil && !isAIChat && suggestion.type != .suggestedQuery && suggestion.type != .openedTab + guard let url = suggestion.url else { return false } + if isAIChat || suggestion.type == .suggestedQuery || suggestion.type == .openedTab { return false } + let urlString = url.absoluteString + if suggestion.title == urlString || urlString.hasSuffix("://\(suggestion.title)") || urlString + .hasSuffix("://\(suggestion.title)/") + { return false } + return true + } + + private var isFocusedOrHovered: Bool { + focusedElement == suggestion.id || isHovered } private var foregroundColor: Color { - if focusedElement == suggestion.id || isHovered, isAIChat { - return defaultAI?.foregroundColor ?? .secondary - } else if focusedElement == suggestion.id { + if focusedElement == suggestion.id { return theme.foreground } return .secondary @@ -71,26 +36,15 @@ struct LauncherSuggestionItem: View { private var backgroundColor: Color { if focusedElement != suggestion.id || isHovered { return .clear } - return isAIChat - ? defaultAI?.color ?? .clear - : isHovered ? theme.foreground.opacity(0.07) : theme.foreground.opacity(0.1) - } - - private var aiIcon: String { - guard isAIChat && defaultAI?.icon != nil else { return "" } - return focusedElement == suggestion.id || isHovered - ? defaultAI!.icon - : defaultAI!.icon + "-inverted" + return isAIChat ? theme.background : theme.foreground.opacity(0.1) } @ViewBuilder var icon: some View { - if isAIChat, defaultAI?.icon != nil { - Image( - aiIcon - ) - .resizable() - .frame(width: 14, height: 14) + if isAIChat, let suggestionIcon = suggestion.icon, !suggestionIcon.isEmpty { + Image(suggestionIcon) + .resizable() + .frame(width: 14, height: 14) } else if suggestion.faviconURL != nil { FavIcon( isWebViewReady: true, @@ -113,27 +67,22 @@ struct LauncherSuggestionItem: View { var actionLabel: some View { if isAIChat { HStack(alignment: .center, spacing: 10) { - Text("Ask \(suggestion.name ?? defaultAI?.name ?? "") ↩") + Text("Ask \(suggestion.name ?? "") ↩") .font(.system(size: 12, weight: .medium)) .foregroundStyle( - focusedElement == suggestion.id || isHovered - ? defaultAI?.foregroundColor ?? .secondary : .secondary + isFocusedOrHovered ? theme.foreground : .secondary ) } .padding(.horizontal, 8) .padding(.vertical, 4) - .background( - focusedElement == suggestion.id || isHovered - ? defaultAI?.foregroundColor?.opacity(0.10) ?? .clear : theme.foreground.opacity(0.07) - ) - .cornerRadius(6) + .background(theme.foreground.opacity(0.07)) + .clipShape(ConditionallyConcentricRectangle(cornerRadius: 8, style: .continuous)) } else if suggestion.type == .openedTab { HStack(alignment: .center, spacing: 8) { Text("Switch to tab ") .font(.system(size: 12, weight: .medium)) .foregroundStyle( - focusedElement == suggestion.id || isHovered - ? theme.foreground : .secondary + isFocusedOrHovered ? theme.foreground : .secondary ) Image(systemName: "arrow.right") @@ -141,21 +90,17 @@ struct LauncherSuggestionItem: View { .frame(width: 12, height: 12) .padding(6) .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) + ConditionallyConcentricRectangle(cornerRadius: 8, style: .continuous) .fill( - focusedElement == suggestion.id || isHovered + isFocusedOrHovered ? theme.foreground : theme.foreground.opacity(0.07) ) ) .foregroundStyle( - focusedElement == suggestion.id || isHovered - ? theme.background : .secondary + isFocusedOrHovered ? theme.background : .secondary ) } - // .padding(.horizontal, 8) - // .padding(.vertical, 4) - // .background(theme.foreground.opacity(0.07)) - .cornerRadius(6) + .clipShape(ConditionallyConcentricRectangle(cornerRadius: 8, style: .continuous)) } } @@ -185,16 +130,15 @@ struct LauncherSuggestionItem: View { .padding(.vertical, 10) .frame(width: 798, alignment: .leading) .background(backgroundColor) - .cornerRadius(8) + .clipShape(ConditionallyConcentricRectangle(cornerRadius: 12, style: .continuous)) .onTapGesture { suggestion.action() appState.showLauncher = false } .onHover { hover in - if hover { + if hover, mouseHasMoved { focusedElement = suggestion.id } } -// .focused($focusedElement, equals: suggestion.id) } } diff --git a/ora/Features/Launcher/Suggestions/LauncherSuggestionsView.swift b/ora/Features/Launcher/Suggestions/LauncherSuggestionsView.swift index 525658a3..2057042e 100644 --- a/ora/Features/Launcher/Suggestions/LauncherSuggestionsView.swift +++ b/ora/Features/Launcher/Suggestions/LauncherSuggestionsView.swift @@ -1,13 +1,8 @@ import SwiftUI -enum SuggestionFocus: Hashable { - case suggestion(id: UUID) -} - struct LauncherSuggestionsView: View { @Environment(\.theme) private var theme @Binding var text: String - @StateObject private var searchEngineService = SearchEngineService() @Binding var suggestions: [LauncherSuggestion] @Binding var focusedElement: UUID @@ -16,24 +11,11 @@ struct LauncherSuggestionsView: View { ForEach(suggestions) { suggestion in LauncherSuggestionItem( suggestion: suggestion, - defaultAI: searchEngineService.getDefaultAIChat(), focusedElement: $focusedElement ) } } .frame(maxWidth: .infinity) .padding(.top, 4) - .overlay( - Rectangle() - .frame(height: 1) - .foregroundColor(theme.border.opacity(0.5)), - alignment: .top - ) - .onAppear { - searchEngineService.setTheme(theme) - } - // .onChange(of: theme) { _, newValue in - // searchEngineService.setTheme(newValue) - // } } } diff --git a/ora/Features/Search/Models/SearchEngine.swift b/ora/Features/Search/Models/SearchEngine.swift index a04351bf..a11e587d 100644 --- a/ora/Features/Search/Models/SearchEngine.swift +++ b/ora/Features/Search/Models/SearchEngine.swift @@ -35,29 +35,14 @@ extension SearchEngine { func toLauncherMatch( originalAlias: String, customEngine: CustomSearchEngine? = nil - ) -> LauncherMain.Match { - var favicon: NSImage? - var faviconColor: Color? - - // Use cached favicon data from custom engine if available - if let customEngine { - favicon = customEngine.favicon - faviconColor = customEngine.faviconBackgroundColor - } else { - // For built-in engines, use favicon service - favicon = FaviconService.shared.getFavicon(for: searchURL) - faviconColor = FaviconService.shared.getFaviconColor(for: searchURL) - } - - return LauncherMain.Match( + ) -> LauncherMatch { + return LauncherMatch( text: name, color: color, foregroundColor: foregroundColor ?? .white, icon: icon, originalAlias: originalAlias, - searchURL: searchURL, - favicon: favicon, - faviconBackgroundColor: faviconColor + searchURL: searchURL ) } } diff --git a/ora/Features/Search/Services/SearchEngineService.swift b/ora/Features/Search/Services/SearchEngineService.swift index 708b4283..d9a8478a 100644 --- a/ora/Features/Search/Services/SearchEngineService.swift +++ b/ora/Features/Search/Services/SearchEngineService.swift @@ -81,7 +81,7 @@ class SearchEngineService: ObservableObject { SearchEngine( name: "Claude", color: Color(hex: "#DE7C4C"), - icon: "", + icon: "claude-capsule-logo", aliases: ["claude", "cl", "cla", "anthropic"], searchURL: "https://claude.ai?q={query}", isAIChat: true @@ -89,7 +89,7 @@ class SearchEngineService: ObservableObject { SearchEngine( name: "Google", color: .blue, - icon: "", + icon: "google-capsule-logo", aliases: ["google", "goo", "g", "search"], searchURL: "https://www.google.com/search?client=safari&rls=en&ie=UTF-8&oe=UTF-8&q={query}", @@ -99,7 +99,7 @@ class SearchEngineService: ObservableObject { SearchEngine( name: "DuckDuckGo", color: Color(hex: "#DE5833"), - icon: "", + icon: "duckduckgo-capsule-logo", aliases: ["duckduckgo", "ddg", "duck"], searchURL: "https://duckduckgo.com/?q={query}", isAIChat: false @@ -107,7 +107,7 @@ class SearchEngineService: ObservableObject { SearchEngine( name: "Kagi", color: Color(hex: "#FFB319"), - icon: "", + icon: "kagi-capsule-logo", aliases: ["kagi", "kg"], searchURL: "https://kagi.com/search?q={query}", isAIChat: false @@ -115,7 +115,7 @@ class SearchEngineService: ObservableObject { SearchEngine( name: "Bing", color: Color(hex: "#02B7E9"), - icon: "", + icon: "bing-capsule-logo", aliases: ["bing", "b", "microsoft"], searchURL: "https://www.bing.com/search?q={query}", isAIChat: false @@ -156,7 +156,7 @@ class SearchEngineService: ObservableObject { SearchEngine( name: "X", color: theme?.foreground ?? .white, - icon: "", + icon: "x-capsule-logo", aliases: ["x", "x.com", "twitter", "tw", "twtr", "twit", "twitt", "twitte"], searchURL: "https://twitter.com/search?q={query}", isAIChat: false, @@ -165,7 +165,7 @@ class SearchEngineService: ObservableObject { SearchEngine( name: "Gemini", color: Color(hex: "#4285F4"), - icon: "", + icon: "gemini-color-capsule-logo", aliases: ["gemini", "gem", "bard", "google ai", "gai"], searchURL: "https://gemini.google.com/app?q={query}", isAIChat: true @@ -173,7 +173,7 @@ class SearchEngineService: ObservableObject { SearchEngine( name: "Copilot", color: Color(hex: "#0078D4"), - icon: "", + icon: "copilot-color-capsule-logo", aliases: ["copilot", "microsoft copilot", "bing chat", "bing", "ms copilot"], searchURL: "https://copilot.microsoft.com/?q={query}", isAIChat: true @@ -181,7 +181,7 @@ class SearchEngineService: ObservableObject { SearchEngine( name: "GitHub Copilot", color: Color(hex: "#24292F"), - icon: "", + icon: "copilot-color-capsule-logo", aliases: ["github copilot", "gh copilot", "github ai", "ghc"], searchURL: "https://github.com/copilot?q={query}", isAIChat: true, @@ -280,7 +280,7 @@ class SearchEngineService: ObservableObject { return URL(string: urlString) } - func createSearchURL(for match: LauncherMain.Match, query: String) -> URL? { + func createSearchURL(for match: LauncherMatch, query: String) -> URL? { let encodedQuery = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" let urlString = match.searchURL.replacingOccurrences(of: "{query}", with: encodedQuery) diff --git a/ora/Features/Tabs/Models/Tab.swift b/ora/Features/Tabs/Models/Tab.swift index b734b791..a200bdb9 100644 --- a/ora/Features/Tabs/Models/Tab.swift +++ b/ora/Features/Tabs/Models/Tab.swift @@ -319,6 +319,14 @@ class Tab: ObservableObject, Identifiable { } } + func continueToInsecureSite() { + let url = failedURL ?? self.url + guard let host = url.host else { return } + browserPage?.bypassSSL(for: host) + clearNavigationError() + browserPage?.load(URLRequest(url: url)) + } + var canGoBack: Bool { browserPage?.canGoBack ?? false } diff --git a/ora/Features/Tabs/Switcher/FloatingTabSwitcher.swift b/ora/Features/Tabs/Switcher/FloatingTabSwitcher.swift index fc614b68..3106d2dc 100644 --- a/ora/Features/Tabs/Switcher/FloatingTabSwitcher.swift +++ b/ora/Features/Tabs/Switcher/FloatingTabSwitcher.swift @@ -15,6 +15,8 @@ struct FloatingTabSwitcher: View { @FocusState private var focusedTab: Tab.ID? @State private var tabSnapshots: [Tab: TabSnapshot] = [:] @State private var isLoadingSnapshots = false + @State private var mouseHasMoved = false + @State private var mouseMonitor: Any? // MARK: - Constants @@ -40,6 +42,10 @@ struct FloatingTabSwitcher: View { let to = recentTabs.count == 1 ? 0 : 1 focusedTab = recentTabs[to].id } + startMouseMonitor() + } + .onDisappear { + stopMouseMonitor() } .onChange(of: appState.isFloatingTabSwitchVisible) { _, isVisible in if isVisible { @@ -48,6 +54,9 @@ struct FloatingTabSwitcher: View { let to = recentTabs.count == 1 ? 0 : 1 focusedTab = recentTabs[to].id } + startMouseMonitor() + } else { + stopMouseMonitor() } } .onChange(of: keyModifierListener.modifierFlags) { _, newFlags in @@ -128,7 +137,7 @@ struct FloatingTabSwitcher: View { .frame(width: Constants.previewWidth, alignment: .leading) .padding(.horizontal, 4) .onHover { isHovered in - if isHovered { + if isHovered, mouseHasMoved { focusedTab = tab.id } } @@ -327,6 +336,25 @@ struct FloatingTabSwitcher: View { BrowserSnapshotConfiguration(rect: nil, afterScreenUpdates: false) } + private func startMouseMonitor() { + mouseHasMoved = false + mouseMonitor = NSEvent.addLocalMonitorForEvents(matching: .mouseMoved) { event in + mouseHasMoved = true + if let monitor = mouseMonitor { + NSEvent.removeMonitor(monitor) + mouseMonitor = nil + } + return event + } + } + + private func stopMouseMonitor() { + if let monitor = mouseMonitor { + NSEvent.removeMonitor(monitor) + mouseMonitor = nil + } + } + private func closeFloatingTabSwitch() { appState.isFloatingTabSwitchVisible = false } diff --git a/ora/Shared/Components/Buttons/OraButton.swift b/ora/Shared/Components/Buttons/OraButton.swift index c1fcbcc3..5160c157 100644 --- a/ora/Shared/Components/Buttons/OraButton.swift +++ b/ora/Shared/Components/Buttons/OraButton.swift @@ -23,6 +23,7 @@ struct OraButton: View { var keyboardShortcut: String? var leadingIcon: String? var trailingIcon: String? + var labelColorOverride: Color? let action: () -> Void @Environment(\.theme) private var theme @@ -96,6 +97,7 @@ struct OraButton: View { private var labelColor: Color { guard !isDisabled else { return theme.disabledForeground } + if let override = labelColorOverride { return override } switch variant { case .default, .destructive: return .white diff --git a/ora/Shared/Components/Icons/OraIcon.swift b/ora/Shared/Components/Icons/OraIcon.swift index 9274a0be..f5d87f6b 100644 --- a/ora/Shared/Components/Icons/OraIcon.swift +++ b/ora/Shared/Components/Icons/OraIcon.swift @@ -9,7 +9,7 @@ enum OraIconSize { var dimension: CGFloat { switch self { case .xs: 10 - case .sm: 14 + case .sm: 12 case .md: 16 case .lg: 20 case .xl: 24