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 @@
+
+
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 @@
+
\ 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