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