diff --git a/ora/Common/Extensions/ModelConfiguration+Shared.swift b/ora/Common/Extensions/ModelConfiguration+Shared.swift
index 02ca7bf..bcacc39 100644
--- a/ora/Common/Extensions/ModelConfiguration+Shared.swift
+++ b/ora/Common/Extensions/ModelConfiguration+Shared.swift
@@ -9,7 +9,7 @@ extension ModelConfiguration {
} else {
return ModelConfiguration(
"OraData",
- schema: Schema([TabContainer.self, History.self, Download.self]),
+ schema: Schema([TabContainer.self, History.self, Download.self, SitePermission.self]),
url: URL.applicationSupportDirectory.appending(path: "Ora/OraData.sqlite")
)
}
@@ -18,7 +18,7 @@ extension ModelConfiguration {
/// Creates a ModelContainer using the standard Ora database configuration
static func createOraContainer(isPrivate: Bool = false) throws -> ModelContainer {
return try ModelContainer(
- for: TabContainer.self, History.self, Download.self,
+ for: TabContainer.self, History.self, Download.self, SitePermission.self,
configurations: oraDatabase(isPrivate: isPrivate)
)
}
diff --git a/ora/Info.plist b/ora/Info.plist
index 3b13bf3..d99887d 100644
--- a/ora/Info.plist
+++ b/ora/Info.plist
@@ -73,6 +73,10 @@
CFBundleVersion
1
+ NSCameraUsageDescription
+ Ora requires access to your camera to support video features.
+ NSMicrophoneUsageDescription
+ Ora requires access to your microphone to support audio features.
LSApplicationCategoryType
public.app-category.web-browser
LSMinimumSystemVersion
diff --git a/ora/Models/SitePermission.swift b/ora/Models/SitePermission.swift
new file mode 100644
index 0000000..ec86ce9
--- /dev/null
+++ b/ora/Models/SitePermission.swift
@@ -0,0 +1,27 @@
+import Foundation
+import SwiftData
+
+@Model
+final class SitePermission {
+ @Attribute(.unique) var host: String
+
+ // Permissions
+ var cameraAllowed: Bool
+ var microphoneAllowed: Bool
+
+ // Track which permissions have been explicitly set
+ var cameraConfigured: Bool
+ var microphoneConfigured: Bool
+
+ init(
+ host: String,
+ cameraAllowed: Bool = false,
+ microphoneAllowed: Bool = false
+ ) {
+ self.host = host
+ self.cameraAllowed = cameraAllowed
+ self.microphoneAllowed = microphoneAllowed
+ self.cameraConfigured = false
+ self.microphoneConfigured = false
+ }
+}
diff --git a/ora/Models/Tab.swift b/ora/Models/Tab.swift
index a180b26..16a497b 100644
--- a/ora/Models/Tab.swift
+++ b/ora/Models/Tab.swift
@@ -123,7 +123,7 @@ class Tab: ObservableObject, Identifiable {
layer.drawsAsynchronously = true
}
- // Set up navigation delegate
+ // Set up navigation delegate and UI delegate
// Don't automatically load URL - let TabManager handle it
// This prevents all tabs from loading on app launch
@@ -358,16 +358,6 @@ class Tab: ObservableObject, Identifiable {
// Navigation failed
}
- func webView(
- _ webView: WKWebView,
- requestMediaCapturePermissionFor origin: WKSecurityOrigin,
- initiatedByFrame frame: WKFrameInfo,
- decisionHandler: @escaping (WKPermissionDecision) -> Void
- ) {
- // For now, grant all
- decisionHandler(.grant)
- }
-
func destroyWebView() {
webView.stopLoading()
webView.navigationDelegate = nil
diff --git a/ora/Modules/Browser/BrowserView.swift b/ora/Modules/Browser/BrowserView.swift
index 4fb1d2b..de76c8b 100644
--- a/ora/Modules/Browser/BrowserView.swift
+++ b/ora/Modules/Browser/BrowserView.swift
@@ -42,7 +42,8 @@ struct BrowserView: View {
isDownloadsPopoverOpen: downloadManager.isDownloadsPopoverOpen
)
}
-
+ // Permission dialog overlay
+ PermissionDialogOverlay()
if toolbarManager.isToolbarHidden {
FloatingURLBar(
showFloatingURLBar: $showFloatingURLBar,
diff --git a/ora/Modules/Settings/Sections/PrivacySecuritySettingsView.swift b/ora/Modules/Settings/Sections/PrivacySecuritySettingsView.swift
index 75079c9..0fb6b58 100644
--- a/ora/Modules/Settings/Sections/PrivacySecuritySettingsView.swift
+++ b/ora/Modules/Settings/Sections/PrivacySecuritySettingsView.swift
@@ -4,27 +4,51 @@ struct PrivacySecuritySettingsView: View {
@StateObject private var settings = SettingsStore.shared
var body: some View {
- SettingsContainer(maxContentWidth: 760) {
- Form {
- VStack(alignment: .leading, spacing: 32) {
- VStack(alignment: .leading, spacing: 8) {
- Section {
- Text("Tracking Prevention").foregroundStyle(.secondary)
- Toggle("Block third-party trackers", isOn: $settings.blockThirdPartyTrackers)
- Toggle("Block fingerprinting", isOn: $settings.blockFingerprinting)
- Toggle("Ad Blocking", isOn: $settings.adBlocking)
+ NavigationStack {
+ SettingsContainer(maxContentWidth: 760) {
+ Form {
+ VStack(alignment: .leading, spacing: 32) {
+ VStack(alignment: .leading, spacing: 8) {
+ Section {
+ Text("Tracking Prevention").foregroundStyle(.secondary)
+ Toggle("Block third-party trackers", isOn: $settings.blockThirdPartyTrackers)
+ Toggle("Block fingerprinting", isOn: $settings.blockFingerprinting)
+ Toggle("Ad Blocking", isOn: $settings.adBlocking)
+ }
+ }
+
+ VStack(alignment: .leading, spacing: 8) {
+ Section {
+ Text("Cookies").foregroundStyle(.secondary)
+ Picker("", selection: $settings.cookiesPolicy) {
+ ForEach(CookiesPolicy.allCases) { policy in
+ Text(policy.rawValue).tag(policy)
+ }
+ }
+ .pickerStyle(.radioGroup)
+ }
}
- }
- VStack(alignment: .leading, spacing: 8) {
- Section {
- Text("Cookies").foregroundStyle(.secondary)
- Picker("", selection: $settings.cookiesPolicy) {
- ForEach(CookiesPolicy.allCases) { policy in
- Text(policy.rawValue).tag(policy)
+ VStack(alignment: .leading, spacing: 12) {
+ Section {
+ NavigationLink {
+ SiteSettingsView()
+ .navigationBarBackButtonHidden(true)
+ } label: {
+ HStack(spacing: 12) {
+ Image(systemName: "gear")
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Site settings")
+ Text("Manage permissions by site")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ Spacer()
+ Image(systemName: "chevron.right")
+ .foregroundStyle(.tertiary)
+ }
}
}
- .pickerStyle(.radioGroup)
}
}
}
diff --git a/ora/Modules/Settings/Sections/SiteSettingsView.swift b/ora/Modules/Settings/Sections/SiteSettingsView.swift
new file mode 100644
index 0000000..0ca9df6
--- /dev/null
+++ b/ora/Modules/Settings/Sections/SiteSettingsView.swift
@@ -0,0 +1,256 @@
+import SwiftData
+import SwiftUI
+
+struct SiteSettingsView: View {
+ @Environment(\.dismiss) private var dismiss
+ @State private var isAdditionalExpanded: Bool = false
+ var body: some View {
+ SettingsContainer(maxContentWidth: 760) {
+ Form {
+ VStack(alignment: .leading, spacing: 0) {
+ InlineBackButton(action: { dismiss() })
+ .padding(.bottom, 8)
+
+ PermissionRow(
+ title: "Camera",
+ subtitle: "Sites can ask to use your camera",
+ systemImage: "camera"
+ ) {
+ CameraPermissionView()
+ }
+
+ PermissionRow(
+ title: "Microphone",
+ subtitle: "Sites can ask to use your microphone",
+ systemImage: "mic"
+ ) {
+ MicrophonePermissionView()
+ }
+
+ .padding(.top, 16)
+ }
+ }
+ }
+ }
+}
+
+private struct PermissionRow: View {
+ let title: String
+ let subtitle: String
+ let systemImage: String
+ @ViewBuilder var destination: () -> Destination
+
+ var body: some View {
+ NavigationLink {
+ destination()
+ } label: {
+ HStack(spacing: 12) {
+ Image(systemName: systemImage)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(title)
+ Text(subtitle)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ Spacer(minLength: 0)
+ Image(systemName: "chevron.right")
+ .foregroundStyle(.tertiary)
+ }
+ .padding(.vertical, 10)
+ }
+ .buttonStyle(.plain)
+ Divider()
+ }
+}
+
+// MARK: - Permission detail screens
+
+struct UnifiedPermissionView: View {
+ let title: String
+ let description: String
+ let permissionKind: PermissionKind?
+ let allowedText: String?
+ let blockedText: String?
+
+ @Environment(\.dismiss) private var dismiss
+ @Environment(\.modelContext) private var modelContext
+ @Query(sort: \SitePermission.host) private var allSitePermissions: [SitePermission]
+ @State private var searchText: String = ""
+
+ // Access the shared permission store
+ private var permissionStore: PermissionSettingsStore {
+ guard let store = PermissionSettingsStore.shared else {
+ fatalError("PermissionSettingsStore.shared is not initialized")
+ }
+ return store
+ }
+
+ private var allowedSites: [SitePermission] {
+ guard let permissionKind else { return [] }
+
+ let filtered = allSitePermissions.filter { site in
+ switch permissionKind {
+ case .camera: return site.cameraConfigured && site.cameraAllowed
+ case .microphone: return site.microphoneConfigured && site.microphoneAllowed
+ }
+ }
+
+ if searchText.isEmpty {
+ return filtered
+ } else {
+ return filtered.filter { $0.host.localizedCaseInsensitiveContains(searchText) }
+ }
+ }
+
+ private var blockedSites: [SitePermission] {
+ guard let permissionKind else { return [] }
+
+ let filtered = allSitePermissions.filter { site in
+ switch permissionKind {
+ case .camera: return site.cameraConfigured && !site.cameraAllowed
+ case .microphone: return site.microphoneConfigured && !site.microphoneAllowed
+ }
+ }
+
+ if searchText.isEmpty {
+ return filtered
+ } else {
+ return filtered.filter { $0.host.localizedCaseInsensitiveContains(searchText) }
+ }
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ HStack {
+ InlineBackButton(action: { dismiss() })
+
+ Spacer()
+
+ HStack {
+ Image(systemName: "magnifyingglass")
+ .foregroundStyle(.secondary)
+ TextField("Search sites...", text: $searchText)
+ .textFieldStyle(.plain)
+ }
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
+ .background(Color(NSColor.controlBackgroundColor))
+ .cornerRadius(6)
+ .frame(width: 200)
+ }
+
+ Text(title)
+ .font(.title2)
+ .fontWeight(.semibold)
+
+ Text(description)
+ .foregroundStyle(.secondary)
+
+ Group {
+ Text("Customized behaviors").font(.headline)
+
+ if permissionKind != nil {
+ if !blockedSites.isEmpty {
+ Text(blockedText ?? "Blocked sites").font(.subheadline)
+ ForEach(blockedSites, id: \.host) { entry in
+ SiteRow(entry: entry, onRemove: { removeSite(entry) })
+ }
+ }
+
+ if !allowedSites.isEmpty {
+ Text(allowedText ?? "Allowed sites").font(.subheadline)
+ ForEach(allowedSites, id: \.host) { entry in
+ SiteRow(entry: entry, onRemove: { removeSite(entry) })
+ }
+ }
+
+ if allowedSites.isEmpty, blockedSites.isEmpty {
+ Text("No sites configured yet.").foregroundStyle(.tertiary)
+ }
+ } else {
+ Text("No sites configured yet.").foregroundStyle(.tertiary)
+ }
+ }
+
+ Spacer()
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+ .padding(.horizontal, 20)
+ .padding(.vertical, 12)
+ }
+
+ private func removeSite(_ site: SitePermission) {
+ // Use the permission store to properly reset the site's permissions
+ permissionStore.removeSite(host: site.host)
+
+ // Refresh the view by forcing a model context save
+ try? modelContext.save()
+ }
+}
+
+private struct SiteRow: View {
+ let entry: SitePermission
+ let onRemove: () -> Void
+ var body: some View {
+ HStack {
+ Text(entry.host)
+ Spacer()
+ Button(role: .destructive, action: onRemove) {
+ Image(systemName: "trash")
+ }
+ .buttonStyle(.plain)
+ }
+ .padding(.vertical, 6)
+ }
+}
+
+private struct RadioButton: View {
+ let isSelected: Bool
+ let action: () -> Void
+ var body: some View {
+ Button(action: action) {
+ Image(systemName: isSelected ? "largecircle.fill.circle" : "circle")
+ }
+ .buttonStyle(.plain)
+ }
+}
+
+struct CameraPermissionView: View {
+ var body: some View {
+ UnifiedPermissionView(
+ title: "Camera",
+ description: "Sites can ask to use your camera for video calls, photos, and other features",
+ permissionKind: .camera,
+ allowedText: "Allowed to use your camera",
+ blockedText: "Not allowed to use your camera"
+ )
+ }
+}
+
+struct MicrophonePermissionView: View {
+ var body: some View {
+ UnifiedPermissionView(
+ title: "Microphone",
+ description: "Sites can ask to use your microphone for voice calls, recordings, and audio features",
+ permissionKind: .microphone,
+ allowedText: "Allowed to use your microphone",
+ blockedText: "Not allowed to use your microphone"
+ )
+ }
+}
+
+// MARK: - Inline Back Button
+
+private struct InlineBackButton: View {
+ let action: () -> Void
+ var body: some View {
+ Button(action: action) {
+ HStack(spacing: 6) {
+ Image(systemName: "chevron.left")
+ Text("Back")
+ }
+ }
+ .buttonStyle(.plain)
+ .padding(.vertical, 6)
+ }
+}
diff --git a/ora/OraRoot.swift b/ora/OraRoot.swift
index 7619e4f..b6a1317 100644
--- a/ora/OraRoot.swift
+++ b/ora/OraRoot.swift
@@ -35,6 +35,9 @@ struct OraRoot: View {
do {
container = try ModelConfiguration.createOraContainer(isPrivate: isPrivate)
modelContext = ModelContext(container)
+
+ // Initialize PermissionSettingsStore.shared with the model context
+ PermissionSettingsStore.shared = PermissionSettingsStore(context: modelContext)
} catch {
deleteSwiftDataStore("OraData.sqlite")
fatalError("Failed to initialize ModelContainer: \(error)")
diff --git a/ora/Services/PermissionManager.swift b/ora/Services/PermissionManager.swift
new file mode 100644
index 0000000..9ef6ddf
--- /dev/null
+++ b/ora/Services/PermissionManager.swift
@@ -0,0 +1,142 @@
+import Foundation
+import SwiftUI
+import WebKit
+
+@MainActor
+class PermissionManager: NSObject, ObservableObject {
+ static let shared = PermissionManager()
+
+ @Published var pendingRequest: PermissionRequest?
+ @Published var showPermissionDialog = false
+
+ override private init() {
+ super.init()
+ }
+
+ func requestPermission(
+ for permissionType: PermissionKind,
+ from host: String,
+ webView: WKWebView,
+ completion: @escaping (Bool) -> Void
+ ) {
+ print("🔧 PermissionManager: Requesting \(permissionType) for \(host)")
+
+ let request = PermissionRequest(
+ permissionType: permissionType,
+ host: host,
+ webView: webView,
+ completion: completion
+ )
+
+ // Check if permission is already configured
+ if let existingPermission = getExistingPermission(for: host, type: permissionType) {
+ print("🔧 Using existing permission: \(existingPermission)")
+ completion(existingPermission)
+ return
+ }
+
+ // Check if there's already a pending request
+ if pendingRequest != nil {
+ print("🔧 Already have pending request, waiting for it to complete...")
+ // Wait for the current request to complete, then try again
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ self.requestPermission(for: permissionType, from: host, webView: webView, completion: completion)
+ }
+ return
+ }
+
+ print("🔧 Showing permission dialog for \(permissionType)")
+ // Show permission dialog
+ self.pendingRequest = request
+ self.showPermissionDialog = true
+
+ // Safety timeout to ensure completion is always called
+ DispatchQueue.main.asyncAfter(deadline: .now() + 30) {
+ if self.pendingRequest?.completion != nil,
+ self.pendingRequest?.host == request.host,
+ self.pendingRequest?.permissionType == request.permissionType
+ {
+ // Request timed out, call completion with false and clear
+ request.completion(false)
+ self.pendingRequest = nil
+ self.showPermissionDialog = false
+ }
+ }
+ }
+
+ func handlePermissionResponse(allow: Bool) {
+ guard let request = pendingRequest else {
+ print("🔧 ERROR: No pending request to handle")
+ return
+ }
+
+ print("🔧 Handling response: \(allow) for \(request.permissionType)")
+
+ // Update permission store
+ PermissionSettingsStore.shared.addOrUpdateSite(
+ host: request.host,
+ allow: allow,
+ for: request.permissionType
+ )
+
+ // Call completion
+ request.completion(allow)
+
+ // Clear pending request
+ self.pendingRequest = nil
+ self.showPermissionDialog = false
+
+ print("🔧 Permission dialog cleared, ready for next request")
+ }
+
+ func getExistingPermission(for host: String, type: PermissionKind) -> Bool? {
+ let sites = PermissionSettingsStore.shared.sitePermissions
+
+ guard let site = sites.first(where: { $0.host.caseInsensitiveCompare(host) == .orderedSame }) else {
+ // No site entry exists, return default value for permissions that should be allowed by default
+ return getDefaultPermissionValue(for: type)
+ }
+
+ switch type {
+ case .camera:
+ return site.cameraConfigured ? site.cameraAllowed : getDefaultPermissionValue(for: type)
+ case .microphone:
+ return site.microphoneConfigured ? site.microphoneAllowed : getDefaultPermissionValue(for: type)
+ }
+ }
+
+ private func getDefaultPermissionValue(for type: PermissionKind) -> Bool? {
+ // Always show dialog for camera and microphone permissions
+ return nil
+ }
+}
+
+struct PermissionRequest {
+ let permissionType: PermissionKind
+ let host: String
+ let webView: WKWebView
+ let completion: (Bool) -> Void
+}
+
+extension PermissionKind {
+ var displayName: String {
+ switch self {
+ case .camera: return "Camera"
+ case .microphone: return "Microphone"
+ }
+ }
+
+ var description: String {
+ switch self {
+ case .camera: return "Use your camera"
+ case .microphone: return "Use your microphone"
+ }
+ }
+
+ var iconName: String {
+ switch self {
+ case .camera: return "camera"
+ case .microphone: return "mic"
+ }
+ }
+}
diff --git a/ora/Services/PermissionSettingsStore.swift b/ora/Services/PermissionSettingsStore.swift
new file mode 100644
index 0000000..fc62676
--- /dev/null
+++ b/ora/Services/PermissionSettingsStore.swift
@@ -0,0 +1,113 @@
+import Foundation
+import SwiftData
+
+enum PermissionKind: CaseIterable {
+ case camera, microphone
+}
+
+@MainActor
+final class PermissionSettingsStore: ObservableObject {
+ // Use an explicitly initialized shared instance from App setup
+ static var shared: PermissionSettingsStore!
+
+ @Published private(set) var sitePermissions: [SitePermission]
+
+ private let context: ModelContext
+
+ // Safer init: no AppDelegate poking here
+ init(context: ModelContext) {
+ self.context = context
+ self.sitePermissions = (try? context.fetch(
+ FetchDescriptor(sortBy: [.init(\.host)])
+ )) ?? []
+ }
+
+ // Refresh permissions from context
+ func refreshPermissions() {
+ self.sitePermissions = (try? context.fetch(
+ FetchDescriptor(sortBy: [.init(\.host)])
+ )) ?? []
+ objectWillChange.send()
+ }
+
+ // MARK: - Filtering
+
+ private func filterSites(for kind: PermissionKind, allowed: Bool) -> [SitePermission] {
+ switch kind {
+ case .camera:
+ return sitePermissions.filter { $0.cameraAllowed == allowed }
+ case .microphone:
+ return sitePermissions.filter { $0.microphoneAllowed == allowed }
+ }
+ }
+
+ func allowedSites(for kind: PermissionKind) -> [SitePermission] {
+ filterSites(for: kind, allowed: true)
+ }
+
+ func notAllowedSites(for kind: PermissionKind) -> [SitePermission] {
+ filterSites(for: kind, allowed: false)
+ }
+
+ // MARK: - Mutations
+
+ func addOrUpdateSite(host: String, allow: Bool, for kind: PermissionKind) {
+ let normalized = host.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !normalized.isEmpty else { return }
+
+ var entry = sitePermissions.first {
+ $0.host.caseInsensitiveCompare(normalized) == .orderedSame
+ }
+
+ if entry == nil {
+ entry = SitePermission(host: normalized)
+ context.insert(entry!)
+ sitePermissions.append(entry!)
+ }
+
+ switch kind {
+ case .camera:
+ entry?.cameraAllowed = allow
+ entry?.cameraConfigured = true
+ case .microphone:
+ entry?.microphoneAllowed = allow
+ entry?.microphoneConfigured = true
+ }
+
+ saveContext()
+ // Refresh permissions from context to ensure we have the latest data
+ refreshPermissions()
+ }
+
+ func removeSite(host: String) {
+ let normalizedHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !normalizedHost.isEmpty else { return }
+
+ // Instead of deleting, find and reset the permissions
+ if let site = sitePermissions.first(where: { $0.host.caseInsensitiveCompare(normalizedHost) == .orderedSame }) {
+ // Reset all configured permissions to false
+ if site.cameraConfigured {
+ site.cameraConfigured = false
+ site.cameraAllowed = false
+ }
+ if site.microphoneConfigured {
+ site.microphoneConfigured = false
+ site.microphoneAllowed = false
+ }
+
+ // Save changes
+ saveContext()
+ objectWillChange.send()
+ }
+ }
+
+ // MARK: - Persistence
+
+ private func saveContext() {
+ do {
+ try context.save()
+ } catch {
+ print("❌ Failed to save permissions: \(error)")
+ }
+ }
+}
diff --git a/ora/Services/TabScriptHandler.swift b/ora/Services/TabScriptHandler.swift
index 6aaa3f3..72d1cb7 100644
--- a/ora/Services/TabScriptHandler.swift
+++ b/ora/Services/TabScriptHandler.swift
@@ -115,6 +115,8 @@ class TabScriptHandler: NSObject, WKScriptMessageHandler {
contentController.add(self, name: "mediaEvent")
configuration.userContentController = contentController
+ // Permission handling is done via WKUIDelegate methods only
+
return configuration
}
diff --git a/ora/UI/PermissionDialog.swift b/ora/UI/PermissionDialog.swift
new file mode 100644
index 0000000..c6c70bd
--- /dev/null
+++ b/ora/UI/PermissionDialog.swift
@@ -0,0 +1,179 @@
+import SwiftUI
+
+struct PermissionDialog: View {
+ let request: PermissionRequest
+ let onResponse: (Bool) -> Void
+ @Environment(\.colorScheme) var colorScheme
+
+ var body: some View {
+ VStack(spacing: 0) {
+ // Header with icon and site info
+ VStack(spacing: 16) {
+ HStack(spacing: 12) {
+ // Permission icon with background
+ ZStack {
+ Circle()
+ .fill(Color.blue.opacity(0.1))
+ .frame(width: 44, height: 44)
+
+ Image(systemName: request.permissionType.iconName)
+ .font(.title2)
+ .foregroundColor(.blue)
+ }
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(request.host)
+ .font(.headline)
+ .fontWeight(.semibold)
+
+ Text("wants to \(request.permissionType.description.lowercased())")
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+ .multilineTextAlignment(.leading)
+ }
+
+ Spacer()
+ }
+
+ // Permission explanation
+ HStack(spacing: 12) {
+ Image(systemName: "info.circle")
+ .foregroundColor(.blue)
+ .font(.subheadline)
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(request.permissionType.displayName)
+ .font(.subheadline)
+ .fontWeight(.medium)
+
+ Text(getPermissionExplanation())
+ .font(.caption)
+ .foregroundColor(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+
+ Spacer()
+ }
+ .padding(.horizontal, 4)
+ }
+ .padding(.top, 24)
+ .padding(.horizontal, 24)
+ .padding(.bottom, 20)
+
+ // Divider
+ Divider()
+
+ // Action buttons
+ HStack(spacing: 0) {
+ Button("Don't Allow") {
+ onResponse(false)
+ }
+ .buttonStyle(PermissionButtonStyle(isPrimary: false))
+ .frame(maxWidth: .infinity)
+
+ Divider()
+ .frame(height: 44)
+
+ Button("Allow") {
+ onResponse(true)
+ }
+ .buttonStyle(PermissionButtonStyle(isPrimary: true))
+ .frame(maxWidth: .infinity)
+ }
+ .frame(height: 44)
+ }
+ .frame(width: 420)
+ .background(
+ RoundedRectangle(cornerRadius: 16, style: .continuous)
+ .fill(Color(NSColor.windowBackgroundColor))
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 16, style: .continuous)
+ .stroke(Color(NSColor.separatorColor).opacity(0.3), lineWidth: 1)
+ )
+ .shadow(
+ color: colorScheme == .dark ? .black.opacity(0.5) : .black.opacity(0.15),
+ radius: 20,
+ x: 0,
+ y: 8
+ )
+ }
+
+ private func getPermissionExplanation() -> String {
+ switch request.permissionType {
+ case .camera:
+ return "This allows the site to access your camera for video calls, photos, and other features."
+ case .microphone:
+ return "This allows the site to access your microphone for voice calls, recordings, and audio features."
+ default:
+ return "This allows the site to use \(request.permissionType.displayName.lowercased()) functionality."
+ }
+ }
+}
+
+struct PermissionButtonStyle: ButtonStyle {
+ let isPrimary: Bool
+ @Environment(\.colorScheme) var colorScheme
+
+ func makeBody(configuration: Configuration) -> some View {
+ configuration.label
+ .font(.system(size: 15, weight: isPrimary ? .semibold : .medium))
+ .foregroundColor(
+ isPrimary
+ ? .white
+ : (colorScheme == .dark ? .white : .black)
+ )
+ .frame(maxWidth: .infinity, minHeight: 44)
+ .background(
+ isPrimary
+ ? Color.blue
+ : Color.clear
+ )
+ .scaleEffect(configuration.isPressed ? 0.98 : 1.0)
+ .opacity(configuration.isPressed ? 0.8 : 1.0)
+ .animation(.easeInOut(duration: 0.1), value: configuration.isPressed)
+ }
+}
+
+struct PermissionDialogOverlay: View {
+ @ObservedObject var permissionManager = PermissionManager.shared
+ @Environment(\.colorScheme) var colorScheme
+
+ var body: some View {
+ ZStack {
+ if permissionManager.showPermissionDialog, let request = permissionManager.pendingRequest {
+ // Background overlay with blur effect
+ ZStack {
+ Color.black.opacity(colorScheme == .dark ? 0.6 : 0.4)
+ .ignoresSafeArea()
+
+ // Subtle blur effect
+ Rectangle()
+ .fill(.ultraThinMaterial)
+ .ignoresSafeArea()
+ .opacity(0.3)
+ }
+ .onTapGesture {
+ // Dismiss on background tap (deny permission)
+ withAnimation(.easeInOut(duration: 0.2)) {
+ permissionManager.handlePermissionResponse(allow: false)
+ }
+ }
+
+ // Permission dialog
+ PermissionDialog(request: request) { allow in
+ withAnimation(.easeInOut(duration: 0.2)) {
+ permissionManager.handlePermissionResponse(allow: allow)
+ }
+ }
+ .transition(
+ .asymmetric(
+ insertion: .scale(scale: 0.8).combined(with: .opacity),
+ removal: .scale(scale: 0.9).combined(with: .opacity)
+ )
+ )
+ }
+ }
+ .animation(.spring(response: 0.4, dampingFraction: 0.8), value: permissionManager.showPermissionDialog)
+ }
+}
diff --git a/ora/UI/URLBar.swift b/ora/UI/URLBar.swift
index d9d3853..bdb9605 100644
--- a/ora/UI/URLBar.swift
+++ b/ora/UI/URLBar.swift
@@ -1,5 +1,36 @@
import AppKit
+import SwiftData
import SwiftUI
+import WebKit
+
+struct BoostRow: View {
+ let icon: String
+ let title: String
+ let status: String
+ let isEnabled: Bool
+
+ var body: some View {
+ HStack(spacing: 12) {
+ Image(systemName: icon)
+ .font(.system(size: 16, weight: .medium))
+ .foregroundColor(.primary)
+ .frame(width: 24, height: 24)
+ .background(Color.secondary.opacity(0.1))
+ .cornerRadius(6)
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text(title)
+ .font(.subheadline)
+ .foregroundColor(.primary)
+ Text(status)
+ .font(.caption)
+ .foregroundColor(isEnabled ? .green : .secondary)
+ }
+
+ Spacer()
+ }
+ }
+}
// MARK: - URLBar
@@ -14,6 +45,7 @@ struct URLBar: View {
@State private var editingURLString: String = ""
@FocusState private var isEditing: Bool
@Environment(\.colorScheme) var colorScheme
+ @State private var showExtensionsPopup = false
let onSidebarToggle: () -> Void
@@ -245,9 +277,12 @@ struct URLBar: View {
systemName: "ellipsis",
isEnabled: true,
foregroundColor: buttonForegroundColor,
- action: {}
- )
-
+ action: {
+ showExtensionsPopup.toggle()
+ }
+ ).popover(isPresented: $showExtensionsPopup, arrowEdge: .bottom) {
+ ExtensionsPopupView()
+ }
if sidebarManager.sidebarPosition == .secondary {
URLBarButton(
systemName: "sidebar.right",
@@ -258,6 +293,7 @@ struct URLBar: View {
.oraShortcutHelp("Toggle Sidebar", for: KeyboardShortcuts.App.toggleSidebar)
}
}
+
.padding(4)
.onAppear {
editingURLString = getDisplayURL(tab)
diff --git a/ora/UI/URLBarExtension.swift b/ora/UI/URLBarExtension.swift
new file mode 100644
index 0000000..629dbdb
--- /dev/null
+++ b/ora/UI/URLBarExtension.swift
@@ -0,0 +1,270 @@
+import AppKit
+import SwiftData
+import SwiftUI
+import WebKit
+
+// MARK: - Extensions Popup View
+
+struct ExtensionsPopupView: View {
+ @Environment(\.dismiss) private var dismiss
+ @Environment(\.theme) var theme
+ @Environment(\.modelContext) private var modelContext
+ @EnvironmentObject var tabManager: TabManager
+
+ @State private var cameraPermission: PermissionState = .ask
+ @State private var microphonePermission: PermissionState = .ask
+
+ enum PermissionState: String, CaseIterable {
+ case ask = "Ask"
+ case allow = "Allow"
+ case block = "Block"
+
+ var next: PermissionState {
+ switch self {
+ case .ask: return .allow
+ case .allow: return .block
+ case .block: return .allow // Don't go back to .ask
+ }
+ }
+ }
+
+ private var currentHost: String {
+ return tabManager.activeTab?.url.host ?? "example.com"
+ }
+
+ private func togglePermission(_ kind: PermissionKind, currentState: Binding) {
+ let newState = currentState.wrappedValue.next
+ currentState.wrappedValue = newState
+ let isAllowed = newState == .allow
+
+ // Update SwiftData
+ updateSitePermission(for: kind, allow: isAllowed)
+
+ // Notify the web view of the permission change
+ if let tab = tabManager.activeTab {
+ // Get the current URL and host
+ let currentURL = tab.url
+ let currentHost = currentURL.host ?? ""
+
+ // Force the web view to re-evaluate permissions by temporarily changing the URL
+ // This is a workaround to trigger a permission re-evaluation without a full page reload
+ if currentURL.absoluteString.hasPrefix("http") {
+ // Create a temporary URL with a different fragment to force a navigation
+ var components = URLComponents(url: currentURL, resolvingAgainstBaseURL: false)!
+ let currentFragment = components.fragment ?? ""
+ components.fragment = currentFragment.isEmpty ? "_" : ""
+
+ if let tempURL = components.url {
+ // Store the original URL
+ let originalURL = currentURL
+
+ // Navigate to the temporary URL
+ tab.webView.load(URLRequest(url: tempURL))
+
+ // After a short delay, navigate back to the original URL
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
+ tab.webView.load(URLRequest(url: originalURL))
+ }
+ }
+ }
+ }
+ }
+
+ private func updateSitePermission(for kind: PermissionKind, allow: Bool) {
+ let host = currentHost
+
+ // Find existing site permission or create new one
+ let descriptor = FetchDescriptor(
+ predicate: #Predicate { site in
+ site.host.localizedStandardContains(host)
+ }
+ )
+
+ let existingSite = try? modelContext.fetch(descriptor).first
+ let site = existingSite ?? {
+ let newSite = SitePermission(host: host)
+ modelContext.insert(newSite)
+ return newSite
+ }()
+
+ // Update the specific permission
+ switch kind {
+ case .camera:
+ site.cameraAllowed = allow
+ site.cameraConfigured = true
+ case .microphone:
+ site.microphoneAllowed = allow
+ site.microphoneConfigured = true
+ default:
+ // All other permission types are not handled
+ break
+ }
+
+ try? modelContext.save()
+ }
+
+ private func loadCurrentPermissions() {
+ let host = currentHost
+
+ let descriptor = FetchDescriptor(
+ predicate: #Predicate { site in
+ site.host.localizedStandardContains(host)
+ }
+ )
+
+ if let site = try? modelContext.fetch(descriptor).first {
+ cameraPermission = site.cameraConfigured ? (site.cameraAllowed ? .allow : .block) : .ask
+ microphonePermission = site.microphoneConfigured ? (site.microphoneAllowed ? .allow : .block) : .ask
+ }
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ // Media Permissions
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Media Permissions")
+ .font(.headline)
+ .foregroundColor(.primary)
+
+ Button(action: { togglePermission(.camera, currentState: $cameraPermission) }) {
+ PopupPermissionRow(icon: "camera", title: "Camera", status: cameraPermission.rawValue)
+ }
+ .buttonStyle(.plain)
+
+ Button(action: { togglePermission(.microphone, currentState: $microphonePermission) }) {
+ PopupPermissionRow(icon: "mic", title: "Microphone", status: microphonePermission.rawValue)
+ }
+ .buttonStyle(.plain)
+
+ SettingsLink {
+ HStack(spacing: 12) {
+ Image(systemName: "gear")
+ .font(.system(size: 16, weight: .medium))
+ .foregroundColor(.primary)
+ .frame(width: 24, height: 24)
+ .background(Color.secondary.opacity(0.1))
+ .cornerRadius(6)
+
+ Text("More settings")
+ .font(.subheadline)
+ .foregroundColor(.primary)
+
+ Spacer()
+
+ Image(systemName: "chevron.right")
+ .font(.system(size: 12, weight: .medium))
+ .foregroundColor(.secondary)
+ }
+ }
+ .buttonStyle(.plain)
+ .onTapGesture {
+ dismiss()
+ }
+ }
+
+ .padding(.top, 8)
+ }
+ .padding(16)
+ .frame(width: 320)
+ .background(Color(NSColor.controlBackgroundColor))
+ .cornerRadius(12)
+ .onAppear {
+ loadCurrentPermissions()
+ }
+ }
+}
+
+struct PopupActionButton: View {
+ let icon: String
+ let title: String
+
+ var body: some View {
+ Button(action: {}) {
+ VStack(spacing: 4) {
+ Image(systemName: icon)
+ .font(.system(size: 16, weight: .medium))
+ .foregroundColor(.primary)
+ .frame(width: 32, height: 32)
+ .background(Color.secondary.opacity(0.1))
+ .cornerRadius(8)
+
+ Text(title)
+ .font(.caption2)
+ .foregroundColor(.secondary)
+ }
+ }
+ .buttonStyle(.plain)
+ }
+}
+
+struct ExtensionIcon: View {
+ let index: Int
+
+ private var iconName: String {
+ let icons = [
+ "doc.text",
+ "globe",
+ "circle.fill",
+ "square.grid.2x2",
+ "star.fill",
+ "folder",
+ "paintbrush",
+ "plus.circle",
+ "photo",
+ "camera",
+ "map",
+ "gamecontroller",
+ "music.note",
+ "video",
+ "textformat",
+ "gear"
+ ]
+ return icons[index % icons.count]
+ }
+
+ private var iconColor: Color {
+ let colors: [Color] = [.blue, .green, .red, .orange, .purple, .pink, .yellow, .gray]
+ return colors[index % colors.count]
+ }
+
+ var body: some View {
+ Button(action: {}) {
+ Image(systemName: iconName)
+ .font(.system(size: 16, weight: .medium))
+ .foregroundColor(iconColor)
+ .frame(width: 40, height: 40)
+ .background(iconColor.opacity(0.1))
+ .cornerRadius(8)
+ }
+ .buttonStyle(.plain)
+ }
+}
+
+struct PopupPermissionRow: View {
+ let icon: String
+ let title: String
+ let status: String
+
+ var body: some View {
+ HStack(spacing: 12) {
+ Image(systemName: icon)
+ .font(.system(size: 18, weight: .medium))
+ .foregroundColor(.primary)
+ .frame(width: 28, height: 28)
+ .background(Color.secondary.opacity(0.1))
+ .cornerRadius(6)
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text(title)
+ .font(.body)
+ .foregroundColor(.primary)
+ Text(status)
+ .font(.subheadline)
+ .foregroundColor(status == "Allow" ? .green : status == "Block" ? .red : .secondary)
+ }
+
+ Spacer()
+ }
+ .padding(.vertical, 2)
+ }
+}
diff --git a/ora/UI/WebView.swift b/ora/UI/WebView.swift
index 8659ede..a232aa2 100644
--- a/ora/UI/WebView.swift
+++ b/ora/UI/WebView.swift
@@ -19,8 +19,12 @@ struct WebView: NSViewRepresentable {
}
func makeNSView(context: Context) -> WKWebView {
+ // Set the coordinator as the UI delegate
webView.uiDelegate = context.coordinator
+ // Store the webView reference in the coordinator for later use
+ context.coordinator.setWebView(webView)
+
webView.autoresizingMask = [.width, .height]
webView.layer?.isOpaque = true
webView.layer?.drawsAsynchronously = true
@@ -52,6 +56,137 @@ struct WebView: NSViewRepresentable {
private var mouseEventMonitor: Any?
private weak var webView: WKWebView?
+ func setWebView(_ webView: WKWebView) {
+ self.webView = webView
+ }
+
+ // MARK: - Permission Handling
+
+ func webView(
+ _ webView: WKWebView,
+ requestMediaCapturePermissionFor origin: WKSecurityOrigin,
+ initiatedByFrame frame: WKFrameInfo,
+ type: WKMediaCaptureType,
+ decisionHandler: @escaping (WKPermissionDecision) -> Void
+ ) {
+ let host = origin.host
+ print("🎥 WebView.Coordinator: Requesting \(type) for \(host)")
+
+ // Determine which permission type is being requested
+ print("🎥 Media capture type raw value: \(type.rawValue)")
+
+ let permissionType: PermissionKind
+ switch type.rawValue {
+ case 0: // .camera
+ print("🎥 Camera only request")
+ permissionType = .camera
+ case 1: // .microphone
+ print("🎥 Microphone only request")
+ permissionType = .microphone
+ case 2: // .cameraAndMicrophone
+ print("🎥 Camera and microphone request")
+ handleCameraAndMicrophonePermission(host: host, webView: webView, decisionHandler: decisionHandler)
+ return
+ default:
+ print("🎥 Unknown media capture type: \(type.rawValue)")
+ decisionHandler(.deny)
+ return
+ }
+
+ // Check if we already have this specific permission configured
+ if let existingPermission = PermissionManager.shared
+ .getExistingPermission(for: host, type: permissionType)
+ {
+ decisionHandler(existingPermission ? .grant : .deny)
+ return
+ }
+
+ // Request new permission with timeout safety
+ var hasResponded = false
+
+ // Set up a timeout to ensure decision handler is always called
+ DispatchQueue.main.asyncAfter(deadline: .now() + 30) {
+ if !hasResponded {
+ hasResponded = true
+ decisionHandler(.deny) // Default to deny if no response
+ }
+ }
+
+ // Request the specific permission
+ Task { @MainActor in
+ PermissionManager.shared.requestPermission(
+ for: permissionType,
+ from: host,
+ webView: webView
+ ) { allowed in
+ if !hasResponded {
+ hasResponded = true
+ decisionHandler(allowed ? .grant : .deny)
+ }
+ }
+ }
+ }
+
+ private func handleCameraAndMicrophonePermission(
+ host: String,
+ webView: WKWebView,
+ decisionHandler: @escaping (WKPermissionDecision) -> Void
+ ) {
+ print("🎥 handleCameraAndMicrophonePermission called for \(host)")
+
+ // Check if we already have both permissions configured
+ let cameraPermission = PermissionManager.shared.getExistingPermission(for: host, type: .camera)
+ let microphonePermission = PermissionManager.shared.getExistingPermission(for: host, type: .microphone)
+
+ if let cameraAllowed = cameraPermission, let microphoneAllowed = microphonePermission {
+ let shouldGrant = cameraAllowed && microphoneAllowed
+ decisionHandler(shouldGrant ? .grant : .deny)
+ return
+ }
+
+ // Request permissions sequentially with timeout safety
+ var hasResponded = false
+
+ // Set up a timeout to ensure decision handler is always called
+ DispatchQueue.main.asyncAfter(deadline: .now() + 60) {
+ if !hasResponded {
+ hasResponded = true
+ decisionHandler(.deny) // Default to deny if no response
+ }
+ }
+
+ Task { @MainActor in
+ print("🎥 Requesting camera permission first...")
+ // First request camera permission
+ PermissionManager.shared.requestPermission(
+ for: .camera,
+ from: host,
+ webView: webView
+ ) { cameraAllowed in
+ print("🎥 Camera permission result: \(cameraAllowed), now requesting microphone...")
+
+ // Add a small delay to ensure the first request is fully cleared
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
+ // Then request microphone permission
+ PermissionManager.shared.requestPermission(
+ for: .microphone,
+ from: host,
+ webView: webView
+ ) { microphoneAllowed in
+ print("🎥 Microphone permission result: \(microphoneAllowed)")
+ if !hasResponded {
+ hasResponded = true
+ // Grant only if both are allowed
+ let shouldGrant = cameraAllowed && microphoneAllowed
+ print("🎥 Final decision: \(shouldGrant)")
+ decisionHandler(shouldGrant ? .grant : .deny)
+ }
+ }
+ }
+ }
+ }
+ }
+
init(
tabManager: TabManager?,
historyManager: HistoryManager?,
@@ -160,15 +295,6 @@ struct WebView: NSViewRepresentable {
return webView.bounds.contains(locationInWebView)
}
- func webView(
- _ webView: WKWebView,
- requestMediaCapturePermissionFor origin: WKSecurityOrigin,
- initiatedByFrame frame: WKFrameInfo,
- decisionHandler: @escaping (WKPermissionDecision) -> Void
- ) {
- decisionHandler(.grant)
- }
-
func webView(
_ webView: WKWebView, runOpenPanelWith parameters: WKOpenPanelParameters,
initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping ([URL]?) -> Void
@@ -187,36 +313,10 @@ struct WebView: NSViewRepresentable {
}
}
- // MARK: - Handle target="_blank" and new window requests
-
- func webView(
- _ webView: WKWebView,
- createWebViewWith configuration: WKWebViewConfiguration,
- for navigationAction: WKNavigationAction,
- windowFeatures: WKWindowFeatures
- ) -> WKWebView? {
- // Handle target="_blank" links and other new window requests
- guard let url = navigationAction.request.url,
- let tabManager = self.tabManager,
- let historyManager = self.historyManager
- else {
- return nil
- }
-
- // Create a new tab in the background for the target URL
- DispatchQueue.main.async {
- tabManager.openTab(
- url: url,
- historyManager: historyManager,
- downloadManager: self.downloadManager,
- isPrivate: self.privacyMode?.isPrivate ?? false
- )
- }
+ // MARK: - Additional Permission Handlers
- // Return nil to prevent creating a new WebView instance
- // The new tab will handle the navigation
- return nil
- }
+ // Note: Most permissions are handled via JavaScript interceptor
+ // WebKit only provides delegates for a limited set of permissions
func webView(
_ webView: WKWebView,
@@ -227,7 +327,6 @@ struct WebView: NSViewRepresentable {
let alert = NSAlert()
alert.messageText = "Alert"
alert.informativeText = message
- alert.alertStyle = .informational
alert.addButton(withTitle: "OK")
alert.runModal()
completionHandler()
@@ -242,13 +341,43 @@ struct WebView: NSViewRepresentable {
let alert = NSAlert()
alert.messageText = "Confirm"
alert.informativeText = message
- alert.alertStyle = .informational
alert.addButton(withTitle: "OK")
alert.addButton(withTitle: "Cancel")
let response = alert.runModal()
completionHandler(response == .alertFirstButtonReturn)
}
+ // MARK: - Handle target="_blank" and new window requests
+
+ func webView(
+ _ webView: WKWebView,
+ createWebViewWith configuration: WKWebViewConfiguration,
+ for navigationAction: WKNavigationAction,
+ windowFeatures: WKWindowFeatures
+ ) -> WKWebView? {
+ // Handle target="_blank" links and other new window requests
+ guard let url = navigationAction.request.url,
+ let tabManager = self.tabManager,
+ let historyManager = self.historyManager
+ else {
+ return nil
+ }
+
+ // Create a new tab in the background for the target URL
+ DispatchQueue.main.async {
+ tabManager.openTab(
+ url: url,
+ historyManager: historyManager,
+ downloadManager: self.downloadManager,
+ isPrivate: self.privacyMode?.isPrivate ?? false
+ )
+ }
+
+ // Return nil to prevent creating a new WebView instance
+ // The new tab will handle the navigation
+ return nil
+ }
+
func webView(
_ webView: WKWebView,
runJavaScriptTextInputPanelWithPrompt prompt: String,
diff --git a/project.yml b/project.yml
index 73cf118..bba90a3 100644
--- a/project.yml
+++ b/project.yml
@@ -49,6 +49,9 @@ targets:
SUFeedURL: "https://the-ora.github.io/browser/appcast.xml"
SUPublicEDKey: "Ozj+rezzbJAD76RfajtfQ7rFojJbpFSCl/0DcFSBCTI="
SUEnableAutomaticChecks: YES
+ NSCameraUsageDescription: "Ora requires access to your camera to support video features."
+ NSMicrophoneUsageDescription: "Ora requires access to your microphone to support audio features."
+
CFBundleURLTypes:
- CFBundleURLName: Ora Browser
CFBundleTypeRole: Editor
@@ -104,6 +107,7 @@ targets:
SUFeedURL: "https://the-ora.github.io/browser/appcast.xml"
SUPublicEDKey: "Ozj+rezzbJAD76RfajtfQ7rFojJbpFSCl/0DcFSBCTI="
SUEnableAutomaticChecks: YES
+ CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION: YES
configs:
Debug:
ASSETCATALOG_COMPILER_APPICON_NAME: OraIconDev