From 73d0cef2610cb2e75444c1dc0c2016a4b28526ad Mon Sep 17 00:00:00 2001 From: brooksolomon Date: Mon, 8 Sep 2025 21:01:22 +0300 Subject: [PATCH 01/26] created site settings menu and list of main permissions --- .../PrivacySecuritySettingsView.swift | 57 ++-- .../Settings/Sections/SiteSettingsView.swift | 256 ++++++++++++++++++ 2 files changed, 296 insertions(+), 17 deletions(-) create mode 100644 ora/Modules/Settings/Sections/SiteSettingsView.swift diff --git a/ora/Modules/Settings/Sections/PrivacySecuritySettingsView.swift b/ora/Modules/Settings/Sections/PrivacySecuritySettingsView.swift index 75079c9e..d7e774be 100644 --- a/ora/Modules/Settings/Sections/PrivacySecuritySettingsView.swift +++ b/ora/Modules/Settings/Sections/PrivacySecuritySettingsView.swift @@ -4,27 +4,50 @@ 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() + } 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 00000000..0df47be8 --- /dev/null +++ b/ora/Modules/Settings/Sections/SiteSettingsView.swift @@ -0,0 +1,256 @@ +import SwiftUI + +struct SiteSettingsView: View { + @Environment(\.dismiss) private var dismiss + var body: some View { + SettingsContainer(maxContentWidth: 760) { + Form { + VStack(alignment: .leading, spacing: 0) { + InlineBackButton(action: { dismiss() }) + .padding(.bottom, 8) + PermissionRow( + title: "Location", + subtitle: "Sites can ask for your location", + systemImage: "location" + ) { + LocationPermissionView() + } + + 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() + } + + PermissionRow( + title: "Notifications", + subtitle: "Collapse unwanted requests (recommended)", + systemImage: "bell" + ) { + NotificationsPermissionView() + } + + PermissionRow( + title: "Embedded content", + subtitle: "Sites can ask to use information they've saved about you", + systemImage: "rectangle.on.rectangle" + ) { + EmbeddedContentPermissionView() + } + + DisclosureGroup { + VStack(spacing: 0) { + PermissionRow( + title: "Clipboard", + subtitle: "Sites can ask to read your clipboard", + systemImage: "clipboard" + ) { + AdditionalPermissionListView(title: "Clipboard") + } + PermissionRow( + title: "Sensors", + subtitle: "Sites can ask to use sensors", + systemImage: "dot.radiowaves.left.right" + ) { + AdditionalPermissionListView(title: "Sensors") + } + PermissionRow( + title: "Popups and redirects", + subtitle: "Manage popups and redirects", + systemImage: "arrowshape.turn.up.right" + ) { + AdditionalPermissionListView(title: "Popups and redirects") + } + } + .padding(.top, 8) + } label: { + Text("Additional permissions") + } + .padding(.top, 16) + } + } + } + .navigationTitle("Site settings") + } +} + +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 + +private struct PerSiteToggleList: View { + @ObservedObject var settings = SettingsStore.shared + let keyPath: WritableKeyPath + + var body: some View { + List(Array(settings.sitePermissions.values).sorted(by: { $0.host < $1.host })) { item in + Toggle(isOn: Binding( + get: { item[keyPath: keyPath] }, + set: { newValue in + var updated = item + updated[keyPath: keyPath] = newValue + settings.upsertSitePermission(updated) + } + )) { + Text(item.host) + } + .toggleStyle(.switch) + .contextMenu { + Button(role: .destructive) { + settings.removeSitePermission(host: item.host) + } label: { + Label("Remove", systemImage: "trash") + } + } + } + .listStyle(.inset) + } +} + +struct LocationPermissionView: View { + @Environment(\.dismiss) private var dismiss + var body: some View { + VStack(alignment: .leading, spacing: 12) { + InlineBackButton(action: { dismiss() }) + Text("Sites can ask for your location").foregroundStyle(.secondary) + PerSiteToggleList(keyPath: \._Location) + } + .navigationTitle("Location") + .padding(.horizontal, 20) + .padding(.vertical, 12) + } +} + +struct CameraPermissionView: View { + @Environment(\.dismiss) private var dismiss + var body: some View { + VStack(alignment: .leading, spacing: 12) { + InlineBackButton(action: { dismiss() }) + Text("Sites can ask to use your camera").foregroundStyle(.secondary) + PerSiteToggleList(keyPath: \._Camera) + } + .navigationTitle("Camera") + .padding(.horizontal, 20) + .padding(.vertical, 12) + } +} + +struct MicrophonePermissionView: View { + @Environment(\.dismiss) private var dismiss + var body: some View { + VStack(alignment: .leading, spacing: 12) { + InlineBackButton(action: { dismiss() }) + Text("Sites can ask to use your microphone").foregroundStyle(.secondary) + PerSiteToggleList(keyPath: \._Microphone) + } + .navigationTitle("Microphone") + .padding(.horizontal, 20) + .padding(.vertical, 12) + } +} + +struct NotificationsPermissionView: View { + @Environment(\.dismiss) private var dismiss + var body: some View { + VStack(alignment: .leading, spacing: 12) { + InlineBackButton(action: { dismiss() }) + Text("Collapse unwanted requests (recommended)").foregroundStyle(.secondary) + PerSiteToggleList(keyPath: \._Notifications) + } + .navigationTitle("Notifications") + .padding(.horizontal, 20) + .padding(.vertical, 12) + } +} + +struct EmbeddedContentPermissionView: View { + @Environment(\.dismiss) private var dismiss + var body: some View { + VStack(alignment: .leading, spacing: 12) { + InlineBackButton(action: { dismiss() }) + Text("Sites can ask to use information they've saved about you").foregroundStyle(.secondary) + // Placeholder list reused for now + PerSiteToggleList(keyPath: \._Notifications) + } + .navigationTitle("Embedded content") + .padding(.horizontal, 20) + .padding(.vertical, 12) + } +} + +struct AdditionalPermissionListView: View { + let title: String + @Environment(\.dismiss) private var dismiss + var body: some View { + VStack(alignment: .leading, spacing: 12) { + InlineBackButton(action: { dismiss() }) + Text(title).foregroundStyle(.secondary) + Text("No additional settings available yet.").foregroundStyle(.tertiary) + } + .navigationTitle(title) + .padding(.horizontal, 20) + .padding(.vertical, 12) + } +} + +// MARK: - KeyPath helpers to access specific fields on SitePermissionSettings + +private extension SitePermissionSettings { + var _Location: Bool { get { location } set { location = newValue } } + var _Camera: Bool { get { camera } set { camera = newValue } } + var _Microphone: Bool { get { microphone } set { microphone = newValue } } + var _Notifications: Bool { get { notifications } set { notifications = newValue } } +} + +// 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) + } +} From 530f0872721e2b7638bcf0593558aeb7297cf0ee Mon Sep 17 00:00:00 2001 From: brooksolomon Date: Mon, 8 Sep 2025 21:06:23 +0300 Subject: [PATCH 02/26] Added additional permissions --- .../Settings/Sections/SiteSettingsView.swift | 125 ++++++++++++++++-- 1 file changed, 115 insertions(+), 10 deletions(-) diff --git a/ora/Modules/Settings/Sections/SiteSettingsView.swift b/ora/Modules/Settings/Sections/SiteSettingsView.swift index 0df47be8..441b38a1 100644 --- a/ora/Modules/Settings/Sections/SiteSettingsView.swift +++ b/ora/Modules/Settings/Sections/SiteSettingsView.swift @@ -50,26 +50,131 @@ struct SiteSettingsView: View { DisclosureGroup { VStack(spacing: 0) { + PermissionRow( + title: "Background sync", + subtitle: "Recently closed sites can finish sending and receiving data", + systemImage: "gear" + ) { + AdditionalPermissionListView(title: "Background sync") + } + PermissionRow( + title: "Motion sensors", + subtitle: "Sites can use motion sensors", + systemImage: "gear" + ) { + AdditionalPermissionListView(title: "Motion sensors") + } + PermissionRow( + title: "Automatic downloads", + subtitle: "Sites can ask to automatically download multiple files", + systemImage: "gear" + ) { + AdditionalPermissionListView(title: "Automatic downloads") + } + PermissionRow( + title: "Protocol handlers", + subtitle: "Sites can ask to handle protocols", + systemImage: "gear" + ) { + AdditionalPermissionListView(title: "Protocol handlers") + } + PermissionRow( + title: "MIDI device control & reprogram", + subtitle: "Sites can ask to control and reprogram your MIDI devices", + systemImage: "gear" + ) { + AdditionalPermissionListView(title: "MIDI device control & reprogram") + } + PermissionRow( + title: "USB devices", + subtitle: "Sites can ask to connect to USB devices", + systemImage: "gear" + ) { + AdditionalPermissionListView(title: "USB devices") + } + PermissionRow( + title: "Serial ports", + subtitle: "Sites can ask to connect to serial ports", + systemImage: "gear" + ) { + AdditionalPermissionListView(title: "Serial ports") + } + PermissionRow( + title: "File editing", + subtitle: "Sites can ask to edit files and folders on your device", + systemImage: "gear" + ) { + AdditionalPermissionListView(title: "File editing") + } + PermissionRow( + title: "HID devices", + subtitle: "Ask when a site wants to access HID devices", + systemImage: "gear" + ) { + AdditionalPermissionListView(title: "HID devices") + } PermissionRow( title: "Clipboard", - subtitle: "Sites can ask to read your clipboard", - systemImage: "clipboard" + subtitle: "Sites can ask to see text and images on your clipboard", + systemImage: "gear" ) { AdditionalPermissionListView(title: "Clipboard") } PermissionRow( - title: "Sensors", - subtitle: "Sites can ask to use sensors", - systemImage: "dot.radiowaves.left.right" + title: "Payment handlers", + subtitle: "Sites can install payment handlers", + systemImage: "gear" + ) { + AdditionalPermissionListView(title: "Payment handlers") + } + PermissionRow( + title: "Augmented reality", + subtitle: "Ask when a site wants to create a 3D map of your surroundings or track camera position", + systemImage: "gear" + ) { + AdditionalPermissionListView(title: "Augmented reality") + } + PermissionRow( + title: "Virtual reality", + subtitle: "Sites can ask to use virtual reality devices and data", + systemImage: "gear" + ) { + AdditionalPermissionListView(title: "Virtual reality") + } + PermissionRow( + title: "Your device use", + subtitle: "Sites can ask to know when you're actively using your device", + systemImage: "gear" + ) { + AdditionalPermissionListView(title: "Your device use") + } + PermissionRow( + title: "Window management", + subtitle: "Sites can ask to manage windows on all your displays", + systemImage: "gear" + ) { + AdditionalPermissionListView(title: "Window management") + } + PermissionRow( + title: "Fonts", + subtitle: "Sites can ask to use fonts installed on your device", + systemImage: "gear" + ) { + AdditionalPermissionListView(title: "Fonts") + } + PermissionRow( + title: "Automatic picture-in-picture", + subtitle: "Sites can enter picture-in-picture automatically", + systemImage: "gear" ) { - AdditionalPermissionListView(title: "Sensors") + AdditionalPermissionListView(title: "Automatic picture-in-picture") } PermissionRow( - title: "Popups and redirects", - subtitle: "Manage popups and redirects", - systemImage: "arrowshape.turn.up.right" + title: "Scrolling and zooming shared tabs", + subtitle: "Sites can ask to scroll and zoom shared tabs", + systemImage: "gear" ) { - AdditionalPermissionListView(title: "Popups and redirects") + AdditionalPermissionListView(title: "Scrolling and zooming shared tabs") } } .padding(.top, 8) From 204114c5a9280258d385c7a3d20c7a920b71fdbd Mon Sep 17 00:00:00 2001 From: brooksolomon Date: Mon, 8 Sep 2025 21:56:00 +0300 Subject: [PATCH 03/26] navigation into and out of site settings working --- .../PrivacySecuritySettingsView.swift | 1 + .../Settings/Sections/SiteSettingsView.swift | 53 +++++++++---------- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/ora/Modules/Settings/Sections/PrivacySecuritySettingsView.swift b/ora/Modules/Settings/Sections/PrivacySecuritySettingsView.swift index d7e774be..0fb6b586 100644 --- a/ora/Modules/Settings/Sections/PrivacySecuritySettingsView.swift +++ b/ora/Modules/Settings/Sections/PrivacySecuritySettingsView.swift @@ -33,6 +33,7 @@ struct PrivacySecuritySettingsView: View { Section { NavigationLink { SiteSettingsView() + .navigationBarBackButtonHidden(true) } label: { HStack(spacing: 12) { Image(systemName: "gear") diff --git a/ora/Modules/Settings/Sections/SiteSettingsView.swift b/ora/Modules/Settings/Sections/SiteSettingsView.swift index 441b38a1..cfa5de22 100644 --- a/ora/Modules/Settings/Sections/SiteSettingsView.swift +++ b/ora/Modules/Settings/Sections/SiteSettingsView.swift @@ -2,6 +2,7 @@ import SwiftUI struct SiteSettingsView: View { @Environment(\.dismiss) private var dismiss + @State private var isAdditionalExpanded: Bool = false var body: some View { SettingsContainer(maxContentWidth: 760) { Form { @@ -48,144 +49,148 @@ struct SiteSettingsView: View { EmbeddedContentPermissionView() } - DisclosureGroup { + DisclosureGroup(isExpanded: $isAdditionalExpanded) { VStack(spacing: 0) { PermissionRow( title: "Background sync", subtitle: "Recently closed sites can finish sending and receiving data", - systemImage: "gear" + systemImage: "arrow.triangle.2.circlepath" ) { AdditionalPermissionListView(title: "Background sync") } PermissionRow( title: "Motion sensors", subtitle: "Sites can use motion sensors", - systemImage: "gear" + systemImage: "waveform.path.ecg" ) { AdditionalPermissionListView(title: "Motion sensors") } PermissionRow( title: "Automatic downloads", subtitle: "Sites can ask to automatically download multiple files", - systemImage: "gear" + systemImage: "arrow.down.circle" ) { AdditionalPermissionListView(title: "Automatic downloads") } PermissionRow( title: "Protocol handlers", subtitle: "Sites can ask to handle protocols", - systemImage: "gear" + systemImage: "link" ) { AdditionalPermissionListView(title: "Protocol handlers") } PermissionRow( title: "MIDI device control & reprogram", subtitle: "Sites can ask to control and reprogram your MIDI devices", - systemImage: "gear" + systemImage: "pianokeys" ) { AdditionalPermissionListView(title: "MIDI device control & reprogram") } PermissionRow( title: "USB devices", subtitle: "Sites can ask to connect to USB devices", - systemImage: "gear" + systemImage: "externaldrive" ) { AdditionalPermissionListView(title: "USB devices") } PermissionRow( title: "Serial ports", subtitle: "Sites can ask to connect to serial ports", - systemImage: "gear" + systemImage: "cable.connector.horizontal" ) { AdditionalPermissionListView(title: "Serial ports") } PermissionRow( title: "File editing", subtitle: "Sites can ask to edit files and folders on your device", - systemImage: "gear" + systemImage: "folder" ) { AdditionalPermissionListView(title: "File editing") } PermissionRow( title: "HID devices", subtitle: "Ask when a site wants to access HID devices", - systemImage: "gear" + systemImage: "dot.radiowaves.left.and.right" ) { AdditionalPermissionListView(title: "HID devices") } PermissionRow( title: "Clipboard", subtitle: "Sites can ask to see text and images on your clipboard", - systemImage: "gear" + systemImage: "clipboard" ) { AdditionalPermissionListView(title: "Clipboard") } PermissionRow( title: "Payment handlers", subtitle: "Sites can install payment handlers", - systemImage: "gear" + systemImage: "creditcard" ) { AdditionalPermissionListView(title: "Payment handlers") } PermissionRow( title: "Augmented reality", subtitle: "Ask when a site wants to create a 3D map of your surroundings or track camera position", - systemImage: "gear" + systemImage: "arkit" ) { AdditionalPermissionListView(title: "Augmented reality") } PermissionRow( title: "Virtual reality", subtitle: "Sites can ask to use virtual reality devices and data", - systemImage: "gear" + systemImage: "visionpro" ) { AdditionalPermissionListView(title: "Virtual reality") } PermissionRow( title: "Your device use", subtitle: "Sites can ask to know when you're actively using your device", - systemImage: "gear" + systemImage: "cursorarrow.rays" ) { AdditionalPermissionListView(title: "Your device use") } PermissionRow( title: "Window management", subtitle: "Sites can ask to manage windows on all your displays", - systemImage: "gear" + systemImage: "macwindow.on.rectangle" ) { AdditionalPermissionListView(title: "Window management") } PermissionRow( title: "Fonts", subtitle: "Sites can ask to use fonts installed on your device", - systemImage: "gear" + systemImage: "textformat" ) { AdditionalPermissionListView(title: "Fonts") } PermissionRow( title: "Automatic picture-in-picture", subtitle: "Sites can enter picture-in-picture automatically", - systemImage: "gear" + systemImage: "pip" ) { AdditionalPermissionListView(title: "Automatic picture-in-picture") } PermissionRow( title: "Scrolling and zooming shared tabs", subtitle: "Sites can ask to scroll and zoom shared tabs", - systemImage: "gear" + systemImage: "magnifyingglass" ) { AdditionalPermissionListView(title: "Scrolling and zooming shared tabs") } } .padding(.top, 8) } label: { - Text("Additional permissions") + HStack { + Text("Additional permissions") + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + .onTapGesture { isAdditionalExpanded.toggle() } } .padding(.top, 16) } } } - .navigationTitle("Site settings") } } @@ -257,7 +262,6 @@ struct LocationPermissionView: View { Text("Sites can ask for your location").foregroundStyle(.secondary) PerSiteToggleList(keyPath: \._Location) } - .navigationTitle("Location") .padding(.horizontal, 20) .padding(.vertical, 12) } @@ -271,7 +275,6 @@ struct CameraPermissionView: View { Text("Sites can ask to use your camera").foregroundStyle(.secondary) PerSiteToggleList(keyPath: \._Camera) } - .navigationTitle("Camera") .padding(.horizontal, 20) .padding(.vertical, 12) } @@ -285,7 +288,6 @@ struct MicrophonePermissionView: View { Text("Sites can ask to use your microphone").foregroundStyle(.secondary) PerSiteToggleList(keyPath: \._Microphone) } - .navigationTitle("Microphone") .padding(.horizontal, 20) .padding(.vertical, 12) } @@ -299,7 +301,6 @@ struct NotificationsPermissionView: View { Text("Collapse unwanted requests (recommended)").foregroundStyle(.secondary) PerSiteToggleList(keyPath: \._Notifications) } - .navigationTitle("Notifications") .padding(.horizontal, 20) .padding(.vertical, 12) } @@ -314,7 +315,6 @@ struct EmbeddedContentPermissionView: View { // Placeholder list reused for now PerSiteToggleList(keyPath: \._Notifications) } - .navigationTitle("Embedded content") .padding(.horizontal, 20) .padding(.vertical, 12) } @@ -329,7 +329,6 @@ struct AdditionalPermissionListView: View { Text(title).foregroundStyle(.secondary) Text("No additional settings available yet.").foregroundStyle(.tertiary) } - .navigationTitle(title) .padding(.horizontal, 20) .padding(.vertical, 12) } From ee0cd9f15763d362429a08c7e638f612423c032e Mon Sep 17 00:00:00 2001 From: brooksolomon Date: Mon, 8 Sep 2025 23:02:32 +0300 Subject: [PATCH 04/26] created site settings view --- ora/Models/SitePermission.swift | 25 +++++ .../Settings/Sections/SiteSettingsView.swift | 77 ++++++++++++++- ora/Services/PermissionSettingsStore.swift | 93 +++++++++++++++++++ ora/oraApp.swift | 5 +- 4 files changed, 195 insertions(+), 5 deletions(-) create mode 100644 ora/Models/SitePermission.swift create mode 100644 ora/Services/PermissionSettingsStore.swift diff --git a/ora/Models/SitePermission.swift b/ora/Models/SitePermission.swift new file mode 100644 index 00000000..62e586b9 --- /dev/null +++ b/ora/Models/SitePermission.swift @@ -0,0 +1,25 @@ +import Foundation +import SwiftData + +@Model +final class SitePermission { + @Attribute(.unique) var host: String + var locationAllowed: Bool + var cameraAllowed: Bool + var microphoneAllowed: Bool + var notificationsAllowed: Bool + + init( + host: String, + locationAllowed: Bool = true, + cameraAllowed: Bool = true, + microphoneAllowed: Bool = true, + notificationsAllowed: Bool = true + ) { + self.host = host + self.locationAllowed = locationAllowed + self.cameraAllowed = cameraAllowed + self.microphoneAllowed = microphoneAllowed + self.notificationsAllowed = notificationsAllowed + } +} diff --git a/ora/Modules/Settings/Sections/SiteSettingsView.swift b/ora/Modules/Settings/Sections/SiteSettingsView.swift index cfa5de22..03e5ae3a 100644 --- a/ora/Modules/Settings/Sections/SiteSettingsView.swift +++ b/ora/Modules/Settings/Sections/SiteSettingsView.swift @@ -256,17 +256,88 @@ private struct PerSiteToggleList: View { struct LocationPermissionView: View { @Environment(\.dismiss) private var dismiss + @StateObject private var store = PermissionSettingsStore.shared + @State private var newHost: String = "" + @State private var newPolicyAllow: Bool = true + var body: some View { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 16) { InlineBackButton(action: { dismiss() }) - Text("Sites can ask for your location").foregroundStyle(.secondary) - PerSiteToggleList(keyPath: \._Location) + + Text("Sites usually use your location for relevant features or info, like local news or nearby shops") + .foregroundStyle(.secondary) + + Group { + Text("Customized behaviors").font(.headline) + + let blocked = store.notAllowedSites(for: .location) + let allowed = store.allowedSites(for: .location) + + if !blocked.isEmpty { + Text("Not allowed to see your location").font(.subheadline) + ForEach(blocked, id: \.host) { entry in + SiteRow(entry: entry, onRemove: { store.removeSite(host: entry.host) }) + } + } + + if !allowed.isEmpty { + Text("Allowed to see your location").font(.subheadline) + ForEach(allowed, id: \.host) { entry in + SiteRow(entry: entry, onRemove: { store.removeSite(host: entry.host) }) + } + } + + HStack(spacing: 8) { + TextField("Add site (e.g. example.com)", text: $newHost) + .textFieldStyle(.roundedBorder) + Picker("Policy", selection: $newPolicyAllow) { + Text("Allow").tag(true) + Text("Block").tag(false) + } + .pickerStyle(.segmented) + Button("Add") { + let host = newHost.trimmingCharacters(in: .whitespacesAndNewlines) + guard !host.isEmpty else { return } + store.addOrUpdateSite(host: host, allow: newPolicyAllow, for: .location) + newHost = "" + newPolicyAllow = true + } + .buttonStyle(.bordered) + } + } } .padding(.horizontal, 20) .padding(.vertical, 12) } } +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 { @Environment(\.dismiss) private var dismiss var body: some View { diff --git a/ora/Services/PermissionSettingsStore.swift b/ora/Services/PermissionSettingsStore.swift new file mode 100644 index 00000000..506866ca --- /dev/null +++ b/ora/Services/PermissionSettingsStore.swift @@ -0,0 +1,93 @@ +import Foundation +import SwiftData + +enum PermissionKind: CaseIterable { + case location, camera, microphone, notifications +} + +@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)]) + )) ?? [] + } + + // MARK: - Filtering + + private func filterSites(for kind: PermissionKind, allowed: Bool) -> [SitePermission] { + switch kind { + case .location: return sitePermissions.filter { $0.locationAllowed == allowed } + case .camera: return sitePermissions.filter { $0.cameraAllowed == allowed } + case .microphone: return sitePermissions.filter { $0.microphoneAllowed == allowed } + case .notifications: return sitePermissions.filter { $0.notificationsAllowed == 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 .location: entry?.locationAllowed = allow + case .camera: entry?.cameraAllowed = allow + case .microphone: entry?.microphoneAllowed = allow + case .notifications: entry?.notificationsAllowed = allow + } + + saveContext() + sitePermissions.sort { $0.host.lowercased() < $1.host.lowercased() } + // trigger update + objectWillChange.send() + } + + func removeSite(host: String) { + guard let idx = sitePermissions.firstIndex(where: { + $0.host.caseInsensitiveCompare(host) == .orderedSame + }) else { return } + + let entry = sitePermissions.remove(at: idx) + context.delete(entry) + saveContext() + objectWillChange.send() + } + + // MARK: - Persistence + + private func saveContext() { + do { + try context.save() + } catch { + print("❌ Failed to save permissions: \(error)") + } + } +} diff --git a/ora/oraApp.swift b/ora/oraApp.swift index 0a405bec..8d9ea514 100644 --- a/ora/oraApp.swift +++ b/ora/oraApp.swift @@ -37,7 +37,7 @@ struct OraApp: App { let modelConfiguration = 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: "OraData.sqlite") ) init() { @@ -50,7 +50,7 @@ struct OraApp: App { let modelContext: ModelContext do { container = try ModelContainer( - for: TabContainer.self, History.self, Download.self, + for: TabContainer.self, History.self, Download.self, SitePermission.self, configurations: modelConfiguration ) modelContext = ModelContext(container) @@ -81,6 +81,7 @@ struct OraApp: App { modelContext: modelContext ) ) + PermissionSettingsStore.shared = PermissionSettingsStore(context: modelContext) } var body: some Scene { From 6bc24447bd68edaf0e169be0739338f50923b7d6 Mon Sep 17 00:00:00 2001 From: brooksolomon Date: Mon, 8 Sep 2025 23:25:33 +0300 Subject: [PATCH 05/26] replaced with query for realtime updates to permission change --- .../Settings/Sections/SiteSettingsView.swift | 186 ++++++++++-------- 1 file changed, 105 insertions(+), 81 deletions(-) diff --git a/ora/Modules/Settings/Sections/SiteSettingsView.swift b/ora/Modules/Settings/Sections/SiteSettingsView.swift index 03e5ae3a..faccf10e 100644 --- a/ora/Modules/Settings/Sections/SiteSettingsView.swift +++ b/ora/Modules/Settings/Sections/SiteSettingsView.swift @@ -1,3 +1,4 @@ +import SwiftData import SwiftUI struct SiteSettingsView: View { @@ -225,65 +226,62 @@ private struct PermissionRow: View { // MARK: - Permission detail screens -private struct PerSiteToggleList: View { - @ObservedObject var settings = SettingsStore.shared - let keyPath: WritableKeyPath +struct DynamicPermissionView: View { + let permissionKind: PermissionKind + let title: String + let description: String + let allowedText: String + let blockedText: String - var body: some View { - List(Array(settings.sitePermissions.values).sorted(by: { $0.host < $1.host })) { item in - Toggle(isOn: Binding( - get: { item[keyPath: keyPath] }, - set: { newValue in - var updated = item - updated[keyPath: keyPath] = newValue - settings.upsertSitePermission(updated) - } - )) { - Text(item.host) - } - .toggleStyle(.switch) - .contextMenu { - Button(role: .destructive) { - settings.removeSitePermission(host: item.host) - } label: { - Label("Remove", systemImage: "trash") - } + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + @Query(sort: \SitePermission.host) private var allSitePermissions: [SitePermission] + @State private var newHost: String = "" + @State private var newPolicyAllow: Bool = true + + private var allowedSites: [SitePermission] { + allSitePermissions.filter { site in + switch permissionKind { + case .location: return site.locationAllowed + case .camera: return site.cameraAllowed + case .microphone: return site.microphoneAllowed + case .notifications: return site.notificationsAllowed } } - .listStyle(.inset) } -} -struct LocationPermissionView: View { - @Environment(\.dismiss) private var dismiss - @StateObject private var store = PermissionSettingsStore.shared - @State private var newHost: String = "" - @State private var newPolicyAllow: Bool = true + private var blockedSites: [SitePermission] { + allSitePermissions.filter { site in + switch permissionKind { + case .location: return !site.locationAllowed + case .camera: return !site.cameraAllowed + case .microphone: return !site.microphoneAllowed + case .notifications: return !site.notificationsAllowed + } + } + } var body: some View { VStack(alignment: .leading, spacing: 16) { InlineBackButton(action: { dismiss() }) - Text("Sites usually use your location for relevant features or info, like local news or nearby shops") + Text(description) .foregroundStyle(.secondary) Group { Text("Customized behaviors").font(.headline) - let blocked = store.notAllowedSites(for: .location) - let allowed = store.allowedSites(for: .location) - - if !blocked.isEmpty { - Text("Not allowed to see your location").font(.subheadline) - ForEach(blocked, id: \.host) { entry in - SiteRow(entry: entry, onRemove: { store.removeSite(host: entry.host) }) + if !blockedSites.isEmpty { + Text(blockedText).font(.subheadline) + ForEach(blockedSites, id: \.host) { entry in + SiteRow(entry: entry, onRemove: { removeSite(entry) }) } } - if !allowed.isEmpty { - Text("Allowed to see your location").font(.subheadline) - ForEach(allowed, id: \.host) { entry in - SiteRow(entry: entry, onRemove: { store.removeSite(host: entry.host) }) + if !allowedSites.isEmpty { + Text(allowedText).font(.subheadline) + ForEach(allowedSites, id: \.host) { entry in + SiteRow(entry: entry, onRemove: { removeSite(entry) }) } } @@ -296,11 +294,7 @@ struct LocationPermissionView: View { } .pickerStyle(.segmented) Button("Add") { - let host = newHost.trimmingCharacters(in: .whitespacesAndNewlines) - guard !host.isEmpty else { return } - store.addOrUpdateSite(host: host, allow: newPolicyAllow, for: .location) - newHost = "" - newPolicyAllow = true + addSite() } .buttonStyle(.bordered) } @@ -309,6 +303,49 @@ struct LocationPermissionView: View { .padding(.horizontal, 20) .padding(.vertical, 12) } + + private func addSite() { + let host = newHost.trimmingCharacters(in: .whitespacesAndNewlines) + guard !host.isEmpty else { return } + + let existingSite = allSitePermissions.first { + $0.host.caseInsensitiveCompare(host) == .orderedSame + } + + let site = existingSite ?? { + let newSite = SitePermission(host: host) + modelContext.insert(newSite) + return newSite + }() + + switch permissionKind { + case .location: site.locationAllowed = newPolicyAllow + case .camera: site.cameraAllowed = newPolicyAllow + case .microphone: site.microphoneAllowed = newPolicyAllow + case .notifications: site.notificationsAllowed = newPolicyAllow + } + + try? modelContext.save() + newHost = "" + newPolicyAllow = true + } + + private func removeSite(_ site: SitePermission) { + modelContext.delete(site) + try? modelContext.save() + } +} + +struct LocationPermissionView: View { + var body: some View { + DynamicPermissionView( + permissionKind: .location, + title: "Location", + description: "Sites usually use your location for relevant features or info, like local news or nearby shops", + allowedText: "Allowed to see your location", + blockedText: "Not allowed to see your location" + ) + } } private struct SiteRow: View { @@ -339,41 +376,38 @@ private struct RadioButton: View { } struct CameraPermissionView: View { - @Environment(\.dismiss) private var dismiss var body: some View { - VStack(alignment: .leading, spacing: 12) { - InlineBackButton(action: { dismiss() }) - Text("Sites can ask to use your camera").foregroundStyle(.secondary) - PerSiteToggleList(keyPath: \._Camera) - } - .padding(.horizontal, 20) - .padding(.vertical, 12) + DynamicPermissionView( + permissionKind: .camera, + title: "Camera", + description: "Sites can ask to use your camera for video calls, photos, and other features", + allowedText: "Allowed to use your camera", + blockedText: "Not allowed to use your camera" + ) } } struct MicrophonePermissionView: View { - @Environment(\.dismiss) private var dismiss var body: some View { - VStack(alignment: .leading, spacing: 12) { - InlineBackButton(action: { dismiss() }) - Text("Sites can ask to use your microphone").foregroundStyle(.secondary) - PerSiteToggleList(keyPath: \._Microphone) - } - .padding(.horizontal, 20) - .padding(.vertical, 12) + DynamicPermissionView( + permissionKind: .microphone, + title: "Microphone", + description: "Sites can ask to use your microphone for voice calls, recordings, and audio features", + allowedText: "Allowed to use your microphone", + blockedText: "Not allowed to use your microphone" + ) } } struct NotificationsPermissionView: View { - @Environment(\.dismiss) private var dismiss var body: some View { - VStack(alignment: .leading, spacing: 12) { - InlineBackButton(action: { dismiss() }) - Text("Collapse unwanted requests (recommended)").foregroundStyle(.secondary) - PerSiteToggleList(keyPath: \._Notifications) - } - .padding(.horizontal, 20) - .padding(.vertical, 12) + DynamicPermissionView( + permissionKind: .notifications, + title: "Notifications", + description: "Sites can ask to send you notifications for updates, messages, and alerts", + allowedText: "Allowed to send notifications", + blockedText: "Not allowed to send notifications" + ) } } @@ -383,8 +417,7 @@ struct EmbeddedContentPermissionView: View { VStack(alignment: .leading, spacing: 12) { InlineBackButton(action: { dismiss() }) Text("Sites can ask to use information they've saved about you").foregroundStyle(.secondary) - // Placeholder list reused for now - PerSiteToggleList(keyPath: \._Notifications) + Text("No additional settings available yet.").foregroundStyle(.tertiary) } .padding(.horizontal, 20) .padding(.vertical, 12) @@ -405,15 +438,6 @@ struct AdditionalPermissionListView: View { } } -// MARK: - KeyPath helpers to access specific fields on SitePermissionSettings - -private extension SitePermissionSettings { - var _Location: Bool { get { location } set { location = newValue } } - var _Camera: Bool { get { camera } set { camera = newValue } } - var _Microphone: Bool { get { microphone } set { microphone = newValue } } - var _Notifications: Bool { get { notifications } set { notifications = newValue } } -} - // MARK: - Inline Back Button private struct InlineBackButton: View { From 3cf6c0fb7dbf1d225fd84ce37c12340910bbbd65 Mon Sep 17 00:00:00 2001 From: brooksolomon Date: Mon, 8 Sep 2025 23:39:36 +0300 Subject: [PATCH 06/26] Added separation of permissions so that setting one doesnt mean triggering the rest --- ora/Models/SitePermission.swift | 12 +++++++ .../Settings/Sections/SiteSettingsView.swift | 32 ++++++++++++------- 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/ora/Models/SitePermission.swift b/ora/Models/SitePermission.swift index 62e586b9..fd5e2889 100644 --- a/ora/Models/SitePermission.swift +++ b/ora/Models/SitePermission.swift @@ -9,6 +9,12 @@ final class SitePermission { var microphoneAllowed: Bool var notificationsAllowed: Bool + // Track which permissions have been explicitly set + var locationConfigured: Bool + var cameraConfigured: Bool + var microphoneConfigured: Bool + var notificationsConfigured: Bool + init( host: String, locationAllowed: Bool = true, @@ -21,5 +27,11 @@ final class SitePermission { self.cameraAllowed = cameraAllowed self.microphoneAllowed = microphoneAllowed self.notificationsAllowed = notificationsAllowed + + // Initially, no permissions are configured + self.locationConfigured = false + self.cameraConfigured = false + self.microphoneConfigured = false + self.notificationsConfigured = false } } diff --git a/ora/Modules/Settings/Sections/SiteSettingsView.swift b/ora/Modules/Settings/Sections/SiteSettingsView.swift index faccf10e..d08f0bea 100644 --- a/ora/Modules/Settings/Sections/SiteSettingsView.swift +++ b/ora/Modules/Settings/Sections/SiteSettingsView.swift @@ -242,10 +242,10 @@ struct DynamicPermissionView: View { private var allowedSites: [SitePermission] { allSitePermissions.filter { site in switch permissionKind { - case .location: return site.locationAllowed - case .camera: return site.cameraAllowed - case .microphone: return site.microphoneAllowed - case .notifications: return site.notificationsAllowed + case .location: return site.locationConfigured && site.locationAllowed + case .camera: return site.cameraConfigured && site.cameraAllowed + case .microphone: return site.microphoneConfigured && site.microphoneAllowed + case .notifications: return site.notificationsConfigured && site.notificationsAllowed } } } @@ -253,10 +253,10 @@ struct DynamicPermissionView: View { private var blockedSites: [SitePermission] { allSitePermissions.filter { site in switch permissionKind { - case .location: return !site.locationAllowed - case .camera: return !site.cameraAllowed - case .microphone: return !site.microphoneAllowed - case .notifications: return !site.notificationsAllowed + case .location: return site.locationConfigured && !site.locationAllowed + case .camera: return site.cameraConfigured && !site.cameraAllowed + case .microphone: return site.microphoneConfigured && !site.microphoneAllowed + case .notifications: return site.notificationsConfigured && !site.notificationsAllowed } } } @@ -319,10 +319,18 @@ struct DynamicPermissionView: View { }() switch permissionKind { - case .location: site.locationAllowed = newPolicyAllow - case .camera: site.cameraAllowed = newPolicyAllow - case .microphone: site.microphoneAllowed = newPolicyAllow - case .notifications: site.notificationsAllowed = newPolicyAllow + case .location: + site.locationAllowed = newPolicyAllow + site.locationConfigured = true + case .camera: + site.cameraAllowed = newPolicyAllow + site.cameraConfigured = true + case .microphone: + site.microphoneAllowed = newPolicyAllow + site.microphoneConfigured = true + case .notifications: + site.notificationsAllowed = newPolicyAllow + site.notificationsConfigured = true } try? modelContext.save() From 876a5e80f915b771bfcd2b25397b65913ab2da59 Mon Sep 17 00:00:00 2001 From: brooksolomon Date: Mon, 8 Sep 2025 23:46:59 +0300 Subject: [PATCH 07/26] added search bar to filter domains --- .../Settings/Sections/SiteSettingsView.swift | 38 +++++++++++++++++-- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/ora/Modules/Settings/Sections/SiteSettingsView.swift b/ora/Modules/Settings/Sections/SiteSettingsView.swift index d08f0bea..c6d06790 100644 --- a/ora/Modules/Settings/Sections/SiteSettingsView.swift +++ b/ora/Modules/Settings/Sections/SiteSettingsView.swift @@ -238,9 +238,10 @@ struct DynamicPermissionView: View { @Query(sort: \SitePermission.host) private var allSitePermissions: [SitePermission] @State private var newHost: String = "" @State private var newPolicyAllow: Bool = true + @State private var searchText: String = "" private var allowedSites: [SitePermission] { - allSitePermissions.filter { site in + let filtered = allSitePermissions.filter { site in switch permissionKind { case .location: return site.locationConfigured && site.locationAllowed case .camera: return site.cameraConfigured && site.cameraAllowed @@ -248,10 +249,16 @@ struct DynamicPermissionView: View { case .notifications: return site.notificationsConfigured && site.notificationsAllowed } } + + if searchText.isEmpty { + return filtered + } else { + return filtered.filter { $0.host.localizedCaseInsensitiveContains(searchText) } + } } private var blockedSites: [SitePermission] { - allSitePermissions.filter { site in + let filtered = allSitePermissions.filter { site in switch permissionKind { case .location: return site.locationConfigured && !site.locationAllowed case .camera: return site.cameraConfigured && !site.cameraAllowed @@ -259,11 +266,33 @@ struct DynamicPermissionView: View { case .notifications: return site.notificationsConfigured && !site.notificationsAllowed } } + + if searchText.isEmpty { + return filtered + } else { + return filtered.filter { $0.host.localizedCaseInsensitiveContains(searchText) } + } } var body: some View { VStack(alignment: .leading, spacing: 16) { - InlineBackButton(action: { dismiss() }) + 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(description) .foregroundStyle(.secondary) @@ -299,7 +328,10 @@ struct DynamicPermissionView: View { .buttonStyle(.bordered) } } + + Spacer() } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .padding(.horizontal, 20) .padding(.vertical, 12) } From 7dea7ceba54082c1ce20e68751ad9fc5d0e31c14 Mon Sep 17 00:00:00 2001 From: brooksolomon Date: Tue, 9 Sep 2025 00:03:10 +0300 Subject: [PATCH 08/26] added dynmaic view window for all permissions --- .../Settings/Sections/SiteSettingsView.swift | 291 +++++++++++------- 1 file changed, 185 insertions(+), 106 deletions(-) diff --git a/ora/Modules/Settings/Sections/SiteSettingsView.swift b/ora/Modules/Settings/Sections/SiteSettingsView.swift index c6d06790..540dd5a4 100644 --- a/ora/Modules/Settings/Sections/SiteSettingsView.swift +++ b/ora/Modules/Settings/Sections/SiteSettingsView.swift @@ -57,126 +57,216 @@ struct SiteSettingsView: View { subtitle: "Recently closed sites can finish sending and receiving data", systemImage: "arrow.triangle.2.circlepath" ) { - AdditionalPermissionListView(title: "Background sync") + BackgroundSyncPermissionView() } PermissionRow( title: "Motion sensors", subtitle: "Sites can use motion sensors", systemImage: "waveform.path.ecg" ) { - AdditionalPermissionListView(title: "Motion sensors") + MotionSensorsPermissionView() } PermissionRow( title: "Automatic downloads", subtitle: "Sites can ask to automatically download multiple files", systemImage: "arrow.down.circle" ) { - AdditionalPermissionListView(title: "Automatic downloads") + AutomaticDownloadsPermissionView() } PermissionRow( title: "Protocol handlers", subtitle: "Sites can ask to handle protocols", systemImage: "link" ) { - AdditionalPermissionListView(title: "Protocol handlers") + UnifiedPermissionView( + title: "Protocol handlers", + description: "Sites can ask to handle protocols", + permissionKind: nil, + allowedText: nil, + blockedText: nil + ) } PermissionRow( title: "MIDI device control & reprogram", subtitle: "Sites can ask to control and reprogram your MIDI devices", - systemImage: "pianokeys" + systemImage: "airpodspro" ) { - AdditionalPermissionListView(title: "MIDI device control & reprogram") + UnifiedPermissionView( + title: "MIDI device control & reprogram", + description: "Sites can ask to control and reprogram your MIDI devices", + permissionKind: nil, + allowedText: nil, + blockedText: nil + ) } PermissionRow( title: "USB devices", subtitle: "Sites can ask to connect to USB devices", systemImage: "externaldrive" ) { - AdditionalPermissionListView(title: "USB devices") + UnifiedPermissionView( + title: "USB devices", + description: "Sites can ask to connect to USB devices", + permissionKind: nil, + allowedText: nil, + blockedText: nil + ) } PermissionRow( title: "Serial ports", subtitle: "Sites can ask to connect to serial ports", systemImage: "cable.connector.horizontal" ) { - AdditionalPermissionListView(title: "Serial ports") + UnifiedPermissionView( + title: "Serial ports", + description: "Sites can ask to connect to serial ports", + permissionKind: nil, + allowedText: nil, + blockedText: nil + ) } PermissionRow( title: "File editing", subtitle: "Sites can ask to edit files and folders on your device", systemImage: "folder" ) { - AdditionalPermissionListView(title: "File editing") + UnifiedPermissionView( + title: "File editing", + description: "Sites can ask to edit files and folders on your device", + permissionKind: nil, + allowedText: nil, + blockedText: nil + ) } PermissionRow( title: "HID devices", subtitle: "Ask when a site wants to access HID devices", systemImage: "dot.radiowaves.left.and.right" ) { - AdditionalPermissionListView(title: "HID devices") + UnifiedPermissionView( + title: "HID devices", + description: "Ask when a site wants to access HID devices", + permissionKind: nil, + allowedText: nil, + blockedText: nil + ) } PermissionRow( title: "Clipboard", subtitle: "Sites can ask to see text and images on your clipboard", systemImage: "clipboard" ) { - AdditionalPermissionListView(title: "Clipboard") + UnifiedPermissionView( + title: "Clipboard", + description: "Sites can ask to see text and images on your clipboard", + permissionKind: nil, + allowedText: nil, + blockedText: nil + ) } PermissionRow( title: "Payment handlers", subtitle: "Sites can install payment handlers", systemImage: "creditcard" ) { - AdditionalPermissionListView(title: "Payment handlers") + UnifiedPermissionView( + title: "Payment handlers", + description: "Sites can install payment handlers", + permissionKind: nil, + allowedText: nil, + blockedText: nil + ) } PermissionRow( title: "Augmented reality", subtitle: "Ask when a site wants to create a 3D map of your surroundings or track camera position", systemImage: "arkit" ) { - AdditionalPermissionListView(title: "Augmented reality") + UnifiedPermissionView( + title: "Augmented reality", + description: "Ask when a site wants to create a 3D map of your surroundings or track camera position", + permissionKind: nil, + allowedText: nil, + blockedText: nil + ) } PermissionRow( title: "Virtual reality", subtitle: "Sites can ask to use virtual reality devices and data", systemImage: "visionpro" ) { - AdditionalPermissionListView(title: "Virtual reality") + UnifiedPermissionView( + title: "Virtual reality", + description: "Sites can ask to use virtual reality devices and data", + permissionKind: nil, + allowedText: nil, + blockedText: nil + ) } PermissionRow( title: "Your device use", subtitle: "Sites can ask to know when you're actively using your device", systemImage: "cursorarrow.rays" ) { - AdditionalPermissionListView(title: "Your device use") + UnifiedPermissionView( + title: "Your device use", + description: "Sites can ask to know when you're actively using your device", + permissionKind: nil, + allowedText: nil, + blockedText: nil + ) } PermissionRow( title: "Window management", subtitle: "Sites can ask to manage windows on all your displays", systemImage: "macwindow.on.rectangle" ) { - AdditionalPermissionListView(title: "Window management") + UnifiedPermissionView( + title: "Window management", + description: "Sites can ask to manage windows on all your displays", + permissionKind: nil, + allowedText: nil, + blockedText: nil + ) } PermissionRow( title: "Fonts", subtitle: "Sites can ask to use fonts installed on your device", systemImage: "textformat" ) { - AdditionalPermissionListView(title: "Fonts") + UnifiedPermissionView( + title: "Fonts", + description: "Sites can ask to use fonts installed on your device", + permissionKind: nil, + allowedText: nil, + blockedText: nil + ) } PermissionRow( title: "Automatic picture-in-picture", subtitle: "Sites can enter picture-in-picture automatically", systemImage: "pip" ) { - AdditionalPermissionListView(title: "Automatic picture-in-picture") + UnifiedPermissionView( + title: "Automatic picture-in-picture", + description: "Sites can enter picture-in-picture automatically", + permissionKind: nil, + allowedText: nil, + blockedText: nil + ) } PermissionRow( title: "Scrolling and zooming shared tabs", subtitle: "Sites can ask to scroll and zoom shared tabs", systemImage: "magnifyingglass" ) { - AdditionalPermissionListView(title: "Scrolling and zooming shared tabs") + UnifiedPermissionView( + title: "Scrolling and zooming shared tabs", + description: "Sites can ask to scroll and zoom shared tabs", + permissionKind: nil, + allowedText: nil, + blockedText: nil + ) } } .padding(.top, 8) @@ -226,21 +316,21 @@ private struct PermissionRow: View { // MARK: - Permission detail screens -struct DynamicPermissionView: View { - let permissionKind: PermissionKind +struct UnifiedPermissionView: View { let title: String let description: String - let allowedText: String - let blockedText: 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 newHost: String = "" - @State private var newPolicyAllow: Bool = true @State private var searchText: String = "" private var allowedSites: [SitePermission] { + guard let permissionKind else { return [] } + let filtered = allSitePermissions.filter { site in switch permissionKind { case .location: return site.locationConfigured && site.locationAllowed @@ -258,6 +348,8 @@ struct DynamicPermissionView: View { } private var blockedSites: [SitePermission] { + guard let permissionKind else { return [] } + let filtered = allSitePermissions.filter { site in switch permissionKind { case .location: return site.locationConfigured && !site.locationAllowed @@ -294,38 +386,36 @@ struct DynamicPermissionView: View { .frame(width: 200) } + Text(title) + .font(.title2) + .fontWeight(.semibold) + Text(description) .foregroundStyle(.secondary) Group { Text("Customized behaviors").font(.headline) - if !blockedSites.isEmpty { - Text(blockedText).font(.subheadline) - ForEach(blockedSites, id: \.host) { entry in - SiteRow(entry: entry, onRemove: { removeSite(entry) }) + 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).font(.subheadline) - ForEach(allowedSites, 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) }) + } } - } - HStack(spacing: 8) { - TextField("Add site (e.g. example.com)", text: $newHost) - .textFieldStyle(.roundedBorder) - Picker("Policy", selection: $newPolicyAllow) { - Text("Allow").tag(true) - Text("Block").tag(false) - } - .pickerStyle(.segmented) - Button("Add") { - addSite() + if allowedSites.isEmpty, blockedSites.isEmpty { + Text("No sites configured yet.").foregroundStyle(.tertiary) } - .buttonStyle(.bordered) + } else { + Text("No sites configured yet.").foregroundStyle(.tertiary) } } @@ -336,40 +426,6 @@ struct DynamicPermissionView: View { .padding(.vertical, 12) } - private func addSite() { - let host = newHost.trimmingCharacters(in: .whitespacesAndNewlines) - guard !host.isEmpty else { return } - - let existingSite = allSitePermissions.first { - $0.host.caseInsensitiveCompare(host) == .orderedSame - } - - let site = existingSite ?? { - let newSite = SitePermission(host: host) - modelContext.insert(newSite) - return newSite - }() - - switch permissionKind { - case .location: - site.locationAllowed = newPolicyAllow - site.locationConfigured = true - case .camera: - site.cameraAllowed = newPolicyAllow - site.cameraConfigured = true - case .microphone: - site.microphoneAllowed = newPolicyAllow - site.microphoneConfigured = true - case .notifications: - site.notificationsAllowed = newPolicyAllow - site.notificationsConfigured = true - } - - try? modelContext.save() - newHost = "" - newPolicyAllow = true - } - private func removeSite(_ site: SitePermission) { modelContext.delete(site) try? modelContext.save() @@ -378,10 +434,10 @@ struct DynamicPermissionView: View { struct LocationPermissionView: View { var body: some View { - DynamicPermissionView( - permissionKind: .location, + UnifiedPermissionView( title: "Location", description: "Sites usually use your location for relevant features or info, like local news or nearby shops", + permissionKind: .location, allowedText: "Allowed to see your location", blockedText: "Not allowed to see your location" ) @@ -417,10 +473,10 @@ private struct RadioButton: View { struct CameraPermissionView: View { var body: some View { - DynamicPermissionView( - permissionKind: .camera, + 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" ) @@ -429,10 +485,10 @@ struct CameraPermissionView: View { struct MicrophonePermissionView: View { var body: some View { - DynamicPermissionView( - permissionKind: .microphone, + 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" ) @@ -441,10 +497,10 @@ struct MicrophonePermissionView: View { struct NotificationsPermissionView: View { var body: some View { - DynamicPermissionView( - permissionKind: .notifications, + UnifiedPermissionView( title: "Notifications", description: "Sites can ask to send you notifications for updates, messages, and alerts", + permissionKind: .notifications, allowedText: "Allowed to send notifications", blockedText: "Not allowed to send notifications" ) @@ -452,29 +508,52 @@ struct NotificationsPermissionView: View { } struct EmbeddedContentPermissionView: View { - @Environment(\.dismiss) private var dismiss var body: some View { - VStack(alignment: .leading, spacing: 12) { - InlineBackButton(action: { dismiss() }) - Text("Sites can ask to use information they've saved about you").foregroundStyle(.secondary) - Text("No additional settings available yet.").foregroundStyle(.tertiary) - } - .padding(.horizontal, 20) - .padding(.vertical, 12) + UnifiedPermissionView( + title: "Embedded content", + description: "Sites can ask to use information they've saved about you", + permissionKind: nil, + allowedText: nil, + blockedText: nil + ) } } -struct AdditionalPermissionListView: View { - let title: String - @Environment(\.dismiss) private var dismiss +// MARK: - Additional Permission Views + +struct BackgroundSyncPermissionView: View { var body: some View { - VStack(alignment: .leading, spacing: 12) { - InlineBackButton(action: { dismiss() }) - Text(title).foregroundStyle(.secondary) - Text("No additional settings available yet.").foregroundStyle(.tertiary) - } - .padding(.horizontal, 20) - .padding(.vertical, 12) + UnifiedPermissionView( + title: "Background sync", + description: "Recently closed sites can finish sending and receiving data", + permissionKind: nil, + allowedText: nil, + blockedText: nil + ) + } +} + +struct MotionSensorsPermissionView: View { + var body: some View { + UnifiedPermissionView( + title: "Motion sensors", + description: "Sites can use motion sensors", + permissionKind: nil, + allowedText: nil, + blockedText: nil + ) + } +} + +struct AutomaticDownloadsPermissionView: View { + var body: some View { + UnifiedPermissionView( + title: "Automatic downloads", + description: "Sites can ask to automatically download multiple files", + permissionKind: nil, + allowedText: nil, + blockedText: nil + ) } } From 7d8553d1fb310978dae1ebfdb89ef1fffb976689 Mon Sep 17 00:00:00 2001 From: brooksolomon Date: Tue, 9 Sep 2025 00:32:12 +0300 Subject: [PATCH 09/26] created small menu on the three dots --- ora/UI/URLBar.swift | 190 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 189 insertions(+), 1 deletion(-) diff --git a/ora/UI/URLBar.swift b/ora/UI/URLBar.swift index 66f7ccf0..2c9bbbf4 100644 --- a/ora/UI/URLBar.swift +++ b/ora/UI/URLBar.swift @@ -1,6 +1,188 @@ import AppKit import SwiftUI +// MARK: - Extensions Popup View + +struct ExtensionsPopupView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.theme) var theme + + private func openSettingsPermissions() { + // Open Ora's settings window to the Privacy & Security tab + NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil) + dismiss() + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + // Settings section + VStack(alignment: .leading, spacing: 8) { + Text("Settings") + .font(.headline) + .foregroundColor(.primary) + + PopupPermissionRow(icon: "location", title: "Location", status: "Ask") + PopupPermissionRow(icon: "camera", title: "Camera", status: "Ask") + PopupPermissionRow(icon: "mic", title: "Microphone", status: "Ask") + PopupPermissionRow(icon: "bell", title: "Notifications", status: "Ask") + + Button(action: { + openSettingsPermissions() + }) { + 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) + } + + .padding(.top, 8) + } + .padding(16) + .frame(width: 320) + .background(Color(NSColor.controlBackgroundColor)) + .cornerRadius(12) + } +} + +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 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() + } + } +} + +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: 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(.secondary) + } + + Spacer() + } + } +} + // MARK: - URLBar struct URLBar: View { @@ -12,6 +194,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 @@ -229,8 +412,13 @@ struct URLBar: View { systemName: "ellipsis", isEnabled: true, foregroundColor: buttonForegroundColor, - action: {} + action: { + showExtensionsPopup.toggle() + } ) + .popover(isPresented: $showExtensionsPopup, arrowEdge: .bottom) { + ExtensionsPopupView() + } } } .padding(.horizontal, 12) From 94849a6e98d7ea245b1c13ebde90e9064f7528c1 Mon Sep 17 00:00:00 2001 From: brooksolomon Date: Tue, 9 Sep 2025 00:39:22 +0300 Subject: [PATCH 10/26] connected mini menu with the settings and the storage so now everything is dynamic --- ora/UI/URLBar.swift | 125 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 116 insertions(+), 9 deletions(-) diff --git a/ora/UI/URLBar.swift b/ora/UI/URLBar.swift index 2c9bbbf4..a142c06f 100644 --- a/ora/UI/URLBar.swift +++ b/ora/UI/URLBar.swift @@ -1,4 +1,5 @@ import AppKit +import SwiftData import SwiftUI // MARK: - Extensions Popup View @@ -6,6 +7,93 @@ import SwiftUI struct ExtensionsPopupView: View { @Environment(\.dismiss) private var dismiss @Environment(\.theme) var theme + @Environment(\.modelContext) private var modelContext + @EnvironmentObject var tabManager: TabManager + + @State private var locationPermission: PermissionState = .ask + @State private var cameraPermission: PermissionState = .ask + @State private var microphonePermission: PermissionState = .ask + @State private var notificationsPermission: 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 + } + } + } + + 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 + + // Update SwiftData + updateSitePermission(for: kind, allow: newState == .allow) + } + + 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 .location: + site.locationAllowed = allow + site.locationConfigured = true + case .camera: + site.cameraAllowed = allow + site.cameraConfigured = true + case .microphone: + site.microphoneAllowed = allow + site.microphoneConfigured = true + case .notifications: + site.notificationsAllowed = allow + site.notificationsConfigured = true + } + + 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 { + locationPermission = site.locationConfigured ? (site.locationAllowed ? .allow : .block) : .ask + cameraPermission = site.cameraConfigured ? (site.cameraAllowed ? .allow : .block) : .ask + microphonePermission = site.microphoneConfigured ? (site.microphoneAllowed ? .allow : .block) : .ask + notificationsPermission = site + .notificationsConfigured ? (site.notificationsAllowed ? .allow : .block) : .ask + } + } private func openSettingsPermissions() { // Open Ora's settings window to the Privacy & Security tab @@ -21,10 +109,25 @@ struct ExtensionsPopupView: View { .font(.headline) .foregroundColor(.primary) - PopupPermissionRow(icon: "location", title: "Location", status: "Ask") - PopupPermissionRow(icon: "camera", title: "Camera", status: "Ask") - PopupPermissionRow(icon: "mic", title: "Microphone", status: "Ask") - PopupPermissionRow(icon: "bell", title: "Notifications", status: "Ask") + Button(action: { togglePermission(.location, currentState: $locationPermission) }) { + PopupPermissionRow(icon: "location", title: "Location", status: locationPermission.rawValue) + } + .buttonStyle(.plain) + + 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) + + Button(action: { togglePermission(.notifications, currentState: $notificationsPermission) }) { + PopupPermissionRow(icon: "bell", title: "Notifications", status: notificationsPermission.rawValue) + } + .buttonStyle(.plain) Button(action: { openSettingsPermissions() @@ -57,6 +160,9 @@ struct ExtensionsPopupView: View { .frame(width: 320) .background(Color(NSColor.controlBackgroundColor)) .cornerRadius(12) + .onAppear { + loadCurrentPermissions() + } } } @@ -163,23 +269,24 @@ struct PopupPermissionRow: View { var body: some View { HStack(spacing: 12) { Image(systemName: icon) - .font(.system(size: 16, weight: .medium)) + .font(.system(size: 18, weight: .medium)) .foregroundColor(.primary) - .frame(width: 24, height: 24) + .frame(width: 28, height: 28) .background(Color.secondary.opacity(0.1)) .cornerRadius(6) VStack(alignment: .leading, spacing: 2) { Text(title) - .font(.subheadline) + .font(.body) .foregroundColor(.primary) Text(status) - .font(.caption) - .foregroundColor(.secondary) + .font(.subheadline) + .foregroundColor(status == "Allow" ? .green : status == "Block" ? .red : .secondary) } Spacer() } + .padding(.vertical, 2) } } From d7c8cb2f173693616afd614ccc93ecd7bcd16e25 Mon Sep 17 00:00:00 2001 From: brooksolomon Date: Tue, 9 Sep 2025 00:56:46 +0300 Subject: [PATCH 11/26] added more settings button functionality --- ora/UI/URLBar.swift | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/ora/UI/URLBar.swift b/ora/UI/URLBar.swift index a142c06f..312c2208 100644 --- a/ora/UI/URLBar.swift +++ b/ora/UI/URLBar.swift @@ -95,12 +95,6 @@ struct ExtensionsPopupView: View { } } - private func openSettingsPermissions() { - // Open Ora's settings window to the Privacy & Security tab - NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil) - dismiss() - } - var body: some View { VStack(alignment: .leading, spacing: 16) { // Settings section @@ -129,9 +123,7 @@ struct ExtensionsPopupView: View { } .buttonStyle(.plain) - Button(action: { - openSettingsPermissions() - }) { + SettingsLink { HStack(spacing: 12) { Image(systemName: "gear") .font(.system(size: 16, weight: .medium)) @@ -152,6 +144,9 @@ struct ExtensionsPopupView: View { } } .buttonStyle(.plain) + .onTapGesture { + dismiss() + } } .padding(.top, 8) From 268e19f2e2cf344a91cfdce3bd135c72a7c48bbd Mon Sep 17 00:00:00 2001 From: brooksolomon Date: Tue, 9 Sep 2025 12:28:35 +0300 Subject: [PATCH 12/26] scale pemission managment backend to all permissions --- ora/Models/SitePermission.swift | 107 +++++++++++- .../Settings/Sections/SiteSettingsView.swift | 156 +++++++++++------- ora/Services/PermissionSettingsStore.swift | 99 ++++++++++- 3 files changed, 297 insertions(+), 65 deletions(-) diff --git a/ora/Models/SitePermission.swift b/ora/Models/SitePermission.swift index fd5e2889..d3759c0a 100644 --- a/ora/Models/SitePermission.swift +++ b/ora/Models/SitePermission.swift @@ -4,34 +4,133 @@ import SwiftData @Model final class SitePermission { @Attribute(.unique) var host: String + + // Main permissions var locationAllowed: Bool var cameraAllowed: Bool var microphoneAllowed: Bool var notificationsAllowed: Bool + // Additional permissions + var embeddedContentAllowed: Bool + var backgroundSyncAllowed: Bool + var motionSensorsAllowed: Bool + var automaticDownloadsAllowed: Bool + var protocolHandlersAllowed: Bool + var midiDeviceAllowed: Bool + var usbDevicesAllowed: Bool + var serialPortsAllowed: Bool + var fileEditingAllowed: Bool + var hidDevicesAllowed: Bool + var clipboardAllowed: Bool + var paymentHandlersAllowed: Bool + var augmentedRealityAllowed: Bool + var virtualRealityAllowed: Bool + var deviceUseAllowed: Bool + var windowManagementAllowed: Bool + var fontsAllowed: Bool + var automaticPictureInPictureAllowed: Bool + var scrollingZoomingSharedTabsAllowed: Bool + // Track which permissions have been explicitly set var locationConfigured: Bool var cameraConfigured: Bool var microphoneConfigured: Bool var notificationsConfigured: Bool + var embeddedContentConfigured: Bool + var backgroundSyncConfigured: Bool + var motionSensorsConfigured: Bool + var automaticDownloadsConfigured: Bool + var protocolHandlersConfigured: Bool + var midiDeviceConfigured: Bool + var usbDevicesConfigured: Bool + var serialPortsConfigured: Bool + var fileEditingConfigured: Bool + var hidDevicesConfigured: Bool + var clipboardConfigured: Bool + var paymentHandlersConfigured: Bool + var augmentedRealityConfigured: Bool + var virtualRealityConfigured: Bool + var deviceUseConfigured: Bool + var windowManagementConfigured: Bool + var fontsConfigured: Bool + var automaticPictureInPictureConfigured: Bool + var scrollingZoomingSharedTabsConfigured: Bool init( host: String, - locationAllowed: Bool = true, - cameraAllowed: Bool = true, - microphoneAllowed: Bool = true, - notificationsAllowed: Bool = true + locationAllowed: Bool = false, + cameraAllowed: Bool = false, + microphoneAllowed: Bool = false, + notificationsAllowed: Bool = false, + embeddedContentAllowed: Bool = false, + backgroundSyncAllowed: Bool = false, + motionSensorsAllowed: Bool = false, + automaticDownloadsAllowed: Bool = false, + protocolHandlersAllowed: Bool = false, + midiDeviceAllowed: Bool = false, + usbDevicesAllowed: Bool = false, + serialPortsAllowed: Bool = false, + fileEditingAllowed: Bool = false, + hidDevicesAllowed: Bool = false, + clipboardAllowed: Bool = false, + paymentHandlersAllowed: Bool = false, + augmentedRealityAllowed: Bool = false, + virtualRealityAllowed: Bool = false, + deviceUseAllowed: Bool = false, + windowManagementAllowed: Bool = false, + fontsAllowed: Bool = false, + automaticPictureInPictureAllowed: Bool = false, + scrollingZoomingSharedTabsAllowed: Bool = false ) { self.host = host self.locationAllowed = locationAllowed self.cameraAllowed = cameraAllowed self.microphoneAllowed = microphoneAllowed self.notificationsAllowed = notificationsAllowed + self.embeddedContentAllowed = embeddedContentAllowed + self.backgroundSyncAllowed = backgroundSyncAllowed + self.motionSensorsAllowed = motionSensorsAllowed + self.automaticDownloadsAllowed = automaticDownloadsAllowed + self.protocolHandlersAllowed = protocolHandlersAllowed + self.midiDeviceAllowed = midiDeviceAllowed + self.usbDevicesAllowed = usbDevicesAllowed + self.serialPortsAllowed = serialPortsAllowed + self.fileEditingAllowed = fileEditingAllowed + self.hidDevicesAllowed = hidDevicesAllowed + self.clipboardAllowed = clipboardAllowed + self.paymentHandlersAllowed = paymentHandlersAllowed + self.augmentedRealityAllowed = augmentedRealityAllowed + self.virtualRealityAllowed = virtualRealityAllowed + self.deviceUseAllowed = deviceUseAllowed + self.windowManagementAllowed = windowManagementAllowed + self.fontsAllowed = fontsAllowed + self.automaticPictureInPictureAllowed = automaticPictureInPictureAllowed + self.scrollingZoomingSharedTabsAllowed = scrollingZoomingSharedTabsAllowed // Initially, no permissions are configured self.locationConfigured = false self.cameraConfigured = false self.microphoneConfigured = false self.notificationsConfigured = false + self.embeddedContentConfigured = false + self.backgroundSyncConfigured = false + self.motionSensorsConfigured = false + self.automaticDownloadsConfigured = false + self.protocolHandlersConfigured = false + self.midiDeviceConfigured = false + self.usbDevicesConfigured = false + self.serialPortsConfigured = false + self.fileEditingConfigured = false + self.hidDevicesConfigured = false + self.clipboardConfigured = false + self.paymentHandlersConfigured = false + self.augmentedRealityConfigured = false + self.virtualRealityConfigured = false + self.deviceUseConfigured = false + self.windowManagementConfigured = false + self.fontsConfigured = false + self.automaticPictureInPictureConfigured = false + self.scrollingZoomingSharedTabsConfigured = false } } diff --git a/ora/Modules/Settings/Sections/SiteSettingsView.swift b/ora/Modules/Settings/Sections/SiteSettingsView.swift index 540dd5a4..ab539b2f 100644 --- a/ora/Modules/Settings/Sections/SiteSettingsView.swift +++ b/ora/Modules/Settings/Sections/SiteSettingsView.swift @@ -81,9 +81,9 @@ struct SiteSettingsView: View { UnifiedPermissionView( title: "Protocol handlers", description: "Sites can ask to handle protocols", - permissionKind: nil, - allowedText: nil, - blockedText: nil + permissionKind: .protocolHandlers, + allowedText: "Allowed to handle protocols", + blockedText: "Not allowed to handle protocols" ) } PermissionRow( @@ -94,9 +94,9 @@ struct SiteSettingsView: View { UnifiedPermissionView( title: "MIDI device control & reprogram", description: "Sites can ask to control and reprogram your MIDI devices", - permissionKind: nil, - allowedText: nil, - blockedText: nil + permissionKind: .midiDevice, + allowedText: "Allowed to control MIDI devices", + blockedText: "Not allowed to control MIDI devices" ) } PermissionRow( @@ -107,9 +107,9 @@ struct SiteSettingsView: View { UnifiedPermissionView( title: "USB devices", description: "Sites can ask to connect to USB devices", - permissionKind: nil, - allowedText: nil, - blockedText: nil + permissionKind: .usbDevices, + allowedText: "Allowed to connect to USB devices", + blockedText: "Not allowed to connect to USB devices" ) } PermissionRow( @@ -120,9 +120,9 @@ struct SiteSettingsView: View { UnifiedPermissionView( title: "Serial ports", description: "Sites can ask to connect to serial ports", - permissionKind: nil, - allowedText: nil, - blockedText: nil + permissionKind: .serialPorts, + allowedText: "Allowed to connect to serial ports", + blockedText: "Not allowed to connect to serial ports" ) } PermissionRow( @@ -133,9 +133,9 @@ struct SiteSettingsView: View { UnifiedPermissionView( title: "File editing", description: "Sites can ask to edit files and folders on your device", - permissionKind: nil, - allowedText: nil, - blockedText: nil + permissionKind: .fileEditing, + allowedText: "Allowed to edit files", + blockedText: "Not allowed to edit files" ) } PermissionRow( @@ -146,9 +146,9 @@ struct SiteSettingsView: View { UnifiedPermissionView( title: "HID devices", description: "Ask when a site wants to access HID devices", - permissionKind: nil, - allowedText: nil, - blockedText: nil + permissionKind: .hidDevices, + allowedText: "Allowed to access HID devices", + blockedText: "Not allowed to access HID devices" ) } PermissionRow( @@ -159,9 +159,9 @@ struct SiteSettingsView: View { UnifiedPermissionView( title: "Clipboard", description: "Sites can ask to see text and images on your clipboard", - permissionKind: nil, - allowedText: nil, - blockedText: nil + permissionKind: .clipboard, + allowedText: "Allowed to access clipboard", + blockedText: "Not allowed to access clipboard" ) } PermissionRow( @@ -172,9 +172,9 @@ struct SiteSettingsView: View { UnifiedPermissionView( title: "Payment handlers", description: "Sites can install payment handlers", - permissionKind: nil, - allowedText: nil, - blockedText: nil + permissionKind: .paymentHandlers, + allowedText: "Allowed to install payment handlers", + blockedText: "Not allowed to install payment handlers" ) } PermissionRow( @@ -185,9 +185,9 @@ struct SiteSettingsView: View { UnifiedPermissionView( title: "Augmented reality", description: "Ask when a site wants to create a 3D map of your surroundings or track camera position", - permissionKind: nil, - allowedText: nil, - blockedText: nil + permissionKind: .augmentedReality, + allowedText: "Allowed to use augmented reality", + blockedText: "Not allowed to use augmented reality" ) } PermissionRow( @@ -198,9 +198,9 @@ struct SiteSettingsView: View { UnifiedPermissionView( title: "Virtual reality", description: "Sites can ask to use virtual reality devices and data", - permissionKind: nil, - allowedText: nil, - blockedText: nil + permissionKind: .virtualReality, + allowedText: "Allowed to use virtual reality", + blockedText: "Not allowed to use virtual reality" ) } PermissionRow( @@ -211,9 +211,9 @@ struct SiteSettingsView: View { UnifiedPermissionView( title: "Your device use", description: "Sites can ask to know when you're actively using your device", - permissionKind: nil, - allowedText: nil, - blockedText: nil + permissionKind: .deviceUse, + allowedText: "Allowed to track device use", + blockedText: "Not allowed to track device use" ) } PermissionRow( @@ -224,9 +224,9 @@ struct SiteSettingsView: View { UnifiedPermissionView( title: "Window management", description: "Sites can ask to manage windows on all your displays", - permissionKind: nil, - allowedText: nil, - blockedText: nil + permissionKind: .windowManagement, + allowedText: "Allowed to manage windows", + blockedText: "Not allowed to manage windows" ) } PermissionRow( @@ -237,9 +237,9 @@ struct SiteSettingsView: View { UnifiedPermissionView( title: "Fonts", description: "Sites can ask to use fonts installed on your device", - permissionKind: nil, - allowedText: nil, - blockedText: nil + permissionKind: .fonts, + allowedText: "Allowed to access fonts", + blockedText: "Not allowed to access fonts" ) } PermissionRow( @@ -250,9 +250,9 @@ struct SiteSettingsView: View { UnifiedPermissionView( title: "Automatic picture-in-picture", description: "Sites can enter picture-in-picture automatically", - permissionKind: nil, - allowedText: nil, - blockedText: nil + permissionKind: .automaticPictureInPicture, + allowedText: "Allowed to use automatic picture-in-picture", + blockedText: "Not allowed to use automatic picture-in-picture" ) } PermissionRow( @@ -263,9 +263,9 @@ struct SiteSettingsView: View { UnifiedPermissionView( title: "Scrolling and zooming shared tabs", description: "Sites can ask to scroll and zoom shared tabs", - permissionKind: nil, - allowedText: nil, - blockedText: nil + permissionKind: .scrollingZoomingSharedTabs, + allowedText: "Allowed to scroll and zoom shared tabs", + blockedText: "Not allowed to scroll and zoom shared tabs" ) } } @@ -337,6 +337,27 @@ struct UnifiedPermissionView: View { case .camera: return site.cameraConfigured && site.cameraAllowed case .microphone: return site.microphoneConfigured && site.microphoneAllowed case .notifications: return site.notificationsConfigured && site.notificationsAllowed + case .embeddedContent: return site.embeddedContentConfigured && site.embeddedContentAllowed + case .backgroundSync: return site.backgroundSyncConfigured && site.backgroundSyncAllowed + case .motionSensors: return site.motionSensorsConfigured && site.motionSensorsAllowed + case .automaticDownloads: return site.automaticDownloadsConfigured && site.automaticDownloadsAllowed + case .protocolHandlers: return site.protocolHandlersConfigured && site.protocolHandlersAllowed + case .midiDevice: return site.midiDeviceConfigured && site.midiDeviceAllowed + case .usbDevices: return site.usbDevicesConfigured && site.usbDevicesAllowed + case .serialPorts: return site.serialPortsConfigured && site.serialPortsAllowed + case .fileEditing: return site.fileEditingConfigured && site.fileEditingAllowed + case .hidDevices: return site.hidDevicesConfigured && site.hidDevicesAllowed + case .clipboard: return site.clipboardConfigured && site.clipboardAllowed + case .paymentHandlers: return site.paymentHandlersConfigured && site.paymentHandlersAllowed + case .augmentedReality: return site.augmentedRealityConfigured && site.augmentedRealityAllowed + case .virtualReality: return site.virtualRealityConfigured && site.virtualRealityAllowed + case .deviceUse: return site.deviceUseConfigured && site.deviceUseAllowed + case .windowManagement: return site.windowManagementConfigured && site.windowManagementAllowed + case .fonts: return site.fontsConfigured && site.fontsAllowed + case .automaticPictureInPicture: return site.automaticPictureInPictureConfigured && site + .automaticPictureInPictureAllowed + case .scrollingZoomingSharedTabs: return site.scrollingZoomingSharedTabsConfigured && site + .scrollingZoomingSharedTabsAllowed } } @@ -356,6 +377,27 @@ struct UnifiedPermissionView: View { case .camera: return site.cameraConfigured && !site.cameraAllowed case .microphone: return site.microphoneConfigured && !site.microphoneAllowed case .notifications: return site.notificationsConfigured && !site.notificationsAllowed + case .embeddedContent: return site.embeddedContentConfigured && !site.embeddedContentAllowed + case .backgroundSync: return site.backgroundSyncConfigured && !site.backgroundSyncAllowed + case .motionSensors: return site.motionSensorsConfigured && !site.motionSensorsAllowed + case .automaticDownloads: return site.automaticDownloadsConfigured && !site.automaticDownloadsAllowed + case .protocolHandlers: return site.protocolHandlersConfigured && !site.protocolHandlersAllowed + case .midiDevice: return site.midiDeviceConfigured && !site.midiDeviceAllowed + case .usbDevices: return site.usbDevicesConfigured && !site.usbDevicesAllowed + case .serialPorts: return site.serialPortsConfigured && !site.serialPortsAllowed + case .fileEditing: return site.fileEditingConfigured && !site.fileEditingAllowed + case .hidDevices: return site.hidDevicesConfigured && !site.hidDevicesAllowed + case .clipboard: return site.clipboardConfigured && !site.clipboardAllowed + case .paymentHandlers: return site.paymentHandlersConfigured && !site.paymentHandlersAllowed + case .augmentedReality: return site.augmentedRealityConfigured && !site.augmentedRealityAllowed + case .virtualReality: return site.virtualRealityConfigured && !site.virtualRealityAllowed + case .deviceUse: return site.deviceUseConfigured && !site.deviceUseAllowed + case .windowManagement: return site.windowManagementConfigured && !site.windowManagementAllowed + case .fonts: return site.fontsConfigured && !site.fontsAllowed + case .automaticPictureInPicture: return site.automaticPictureInPictureConfigured && !site + .automaticPictureInPictureAllowed + case .scrollingZoomingSharedTabs: return site.scrollingZoomingSharedTabsConfigured && !site + .scrollingZoomingSharedTabsAllowed } } @@ -512,9 +554,9 @@ struct EmbeddedContentPermissionView: View { UnifiedPermissionView( title: "Embedded content", description: "Sites can ask to use information they've saved about you", - permissionKind: nil, - allowedText: nil, - blockedText: nil + permissionKind: .embeddedContent, + allowedText: "Allowed to use embedded content", + blockedText: "Not allowed to use embedded content" ) } } @@ -526,9 +568,9 @@ struct BackgroundSyncPermissionView: View { UnifiedPermissionView( title: "Background sync", description: "Recently closed sites can finish sending and receiving data", - permissionKind: nil, - allowedText: nil, - blockedText: nil + permissionKind: .backgroundSync, + allowedText: "Allowed to sync in background", + blockedText: "Not allowed to sync in background" ) } } @@ -538,9 +580,9 @@ struct MotionSensorsPermissionView: View { UnifiedPermissionView( title: "Motion sensors", description: "Sites can use motion sensors", - permissionKind: nil, - allowedText: nil, - blockedText: nil + permissionKind: .motionSensors, + allowedText: "Allowed to use motion sensors", + blockedText: "Not allowed to use motion sensors" ) } } @@ -550,9 +592,9 @@ struct AutomaticDownloadsPermissionView: View { UnifiedPermissionView( title: "Automatic downloads", description: "Sites can ask to automatically download multiple files", - permissionKind: nil, - allowedText: nil, - blockedText: nil + permissionKind: .automaticDownloads, + allowedText: "Allowed to download automatically", + blockedText: "Not allowed to download automatically" ) } } diff --git a/ora/Services/PermissionSettingsStore.swift b/ora/Services/PermissionSettingsStore.swift index 506866ca..ff1cedfb 100644 --- a/ora/Services/PermissionSettingsStore.swift +++ b/ora/Services/PermissionSettingsStore.swift @@ -3,6 +3,11 @@ import SwiftData enum PermissionKind: CaseIterable { case location, camera, microphone, notifications + case embeddedContent, backgroundSync, motionSensors, automaticDownloads + case protocolHandlers, midiDevice, usbDevices, serialPorts + case fileEditing, hidDevices, clipboard, paymentHandlers + case augmentedReality, virtualReality, deviceUse, windowManagement + case fonts, automaticPictureInPicture, scrollingZoomingSharedTabs } @MainActor @@ -30,6 +35,27 @@ final class PermissionSettingsStore: ObservableObject { case .camera: return sitePermissions.filter { $0.cameraAllowed == allowed } case .microphone: return sitePermissions.filter { $0.microphoneAllowed == allowed } case .notifications: return sitePermissions.filter { $0.notificationsAllowed == allowed } + case .embeddedContent: return sitePermissions.filter { $0.embeddedContentAllowed == allowed } + case .backgroundSync: return sitePermissions.filter { $0.backgroundSyncAllowed == allowed } + case .motionSensors: return sitePermissions.filter { $0.motionSensorsAllowed == allowed } + case .automaticDownloads: return sitePermissions.filter { $0.automaticDownloadsAllowed == allowed } + case .protocolHandlers: return sitePermissions.filter { $0.protocolHandlersAllowed == allowed } + case .midiDevice: return sitePermissions.filter { $0.midiDeviceAllowed == allowed } + case .usbDevices: return sitePermissions.filter { $0.usbDevicesAllowed == allowed } + case .serialPorts: return sitePermissions.filter { $0.serialPortsAllowed == allowed } + case .fileEditing: return sitePermissions.filter { $0.fileEditingAllowed == allowed } + case .hidDevices: return sitePermissions.filter { $0.hidDevicesAllowed == allowed } + case .clipboard: return sitePermissions.filter { $0.clipboardAllowed == allowed } + case .paymentHandlers: return sitePermissions.filter { $0.paymentHandlersAllowed == allowed } + case .augmentedReality: return sitePermissions.filter { $0.augmentedRealityAllowed == allowed } + case .virtualReality: return sitePermissions.filter { $0.virtualRealityAllowed == allowed } + case .deviceUse: return sitePermissions.filter { $0.deviceUseAllowed == allowed } + case .windowManagement: return sitePermissions.filter { $0.windowManagementAllowed == allowed } + case .fonts: return sitePermissions.filter { $0.fontsAllowed == allowed } + case .automaticPictureInPicture: return sitePermissions + .filter { $0.automaticPictureInPictureAllowed == allowed } + case .scrollingZoomingSharedTabs: return sitePermissions + .filter { $0.scrollingZoomingSharedTabsAllowed == allowed } } } @@ -58,10 +84,75 @@ final class PermissionSettingsStore: ObservableObject { } switch kind { - case .location: entry?.locationAllowed = allow - case .camera: entry?.cameraAllowed = allow - case .microphone: entry?.microphoneAllowed = allow - case .notifications: entry?.notificationsAllowed = allow + case .location: + entry?.locationAllowed = allow + entry?.locationConfigured = true + case .camera: + entry?.cameraAllowed = allow + entry?.cameraConfigured = true + case .microphone: + entry?.microphoneAllowed = allow + entry?.microphoneConfigured = true + case .notifications: + entry?.notificationsAllowed = allow + entry?.notificationsConfigured = true + case .embeddedContent: + entry?.embeddedContentAllowed = allow + entry?.embeddedContentConfigured = true + case .backgroundSync: + entry?.backgroundSyncAllowed = allow + entry?.backgroundSyncConfigured = true + case .motionSensors: + entry?.motionSensorsAllowed = allow + entry?.motionSensorsConfigured = true + case .automaticDownloads: + entry?.automaticDownloadsAllowed = allow + entry?.automaticDownloadsConfigured = true + case .protocolHandlers: + entry?.protocolHandlersAllowed = allow + entry?.protocolHandlersConfigured = true + case .midiDevice: + entry?.midiDeviceAllowed = allow + entry?.midiDeviceConfigured = true + case .usbDevices: + entry?.usbDevicesAllowed = allow + entry?.usbDevicesConfigured = true + case .serialPorts: + entry?.serialPortsAllowed = allow + entry?.serialPortsConfigured = true + case .fileEditing: + entry?.fileEditingAllowed = allow + entry?.fileEditingConfigured = true + case .hidDevices: + entry?.hidDevicesAllowed = allow + entry?.hidDevicesConfigured = true + case .clipboard: + entry?.clipboardAllowed = allow + entry?.clipboardConfigured = true + case .paymentHandlers: + entry?.paymentHandlersAllowed = allow + entry?.paymentHandlersConfigured = true + case .augmentedReality: + entry?.augmentedRealityAllowed = allow + entry?.augmentedRealityConfigured = true + case .virtualReality: + entry?.virtualRealityAllowed = allow + entry?.virtualRealityConfigured = true + case .deviceUse: + entry?.deviceUseAllowed = allow + entry?.deviceUseConfigured = true + case .windowManagement: + entry?.windowManagementAllowed = allow + entry?.windowManagementConfigured = true + case .fonts: + entry?.fontsAllowed = allow + entry?.fontsConfigured = true + case .automaticPictureInPicture: + entry?.automaticPictureInPictureAllowed = allow + entry?.automaticPictureInPictureConfigured = true + case .scrollingZoomingSharedTabs: + entry?.scrollingZoomingSharedTabsAllowed = allow + entry?.scrollingZoomingSharedTabsConfigured = true } saveContext() From d20ed58e6dc349cc3073100d3564cf20bcb72cc5 Mon Sep 17 00:00:00 2001 From: brooksolomon Date: Tue, 9 Sep 2025 13:01:14 +0300 Subject: [PATCH 13/26] added logic to communicate with asking permissions and let the user allow or decline based on website trigger --- ora/Models/SitePermission.swift | 4 +- ora/Models/Tab.swift | 24 ++- ora/Modules/Browser/BrowserView.swift | 3 + ora/Services/PermissionInterceptor.swift | 209 ++++++++++++++++++++++ ora/Services/PermissionManager.swift | 211 +++++++++++++++++++++++ ora/Services/TabScriptHandler.swift | 3 + ora/UI/PermissionDialog.swift | 189 ++++++++++++++++++++ ora/UI/URLBar.swift | 57 ++++++ ora/UI/WebView.swift | 58 ++++++- ora/oraApp.swift | 7 +- 10 files changed, 756 insertions(+), 9 deletions(-) create mode 100644 ora/Services/PermissionInterceptor.swift create mode 100644 ora/Services/PermissionManager.swift create mode 100644 ora/UI/PermissionDialog.swift diff --git a/ora/Models/SitePermission.swift b/ora/Models/SitePermission.swift index d3759c0a..94319bb4 100644 --- a/ora/Models/SitePermission.swift +++ b/ora/Models/SitePermission.swift @@ -64,7 +64,7 @@ final class SitePermission { microphoneAllowed: Bool = false, notificationsAllowed: Bool = false, embeddedContentAllowed: Bool = false, - backgroundSyncAllowed: Bool = false, + backgroundSyncAllowed: Bool = true, // Allow by default motionSensorsAllowed: Bool = false, automaticDownloadsAllowed: Bool = false, protocolHandlersAllowed: Bool = false, @@ -114,7 +114,7 @@ final class SitePermission { self.microphoneConfigured = false self.notificationsConfigured = false self.embeddedContentConfigured = false - self.backgroundSyncConfigured = false + self.backgroundSyncConfigured = true self.motionSensorsConfigured = false self.automaticDownloadsConfigured = false self.protocolHandlersConfigured = false diff --git a/ora/Models/Tab.swift b/ora/Models/Tab.swift index 220ef238..f3c56c8c 100644 --- a/ora/Models/Tab.swift +++ b/ora/Models/Tab.swift @@ -320,8 +320,28 @@ class Tab: ObservableObject, Identifiable { initiatedByFrame frame: WKFrameInfo, decisionHandler: @escaping (WKPermissionDecision) -> Void ) { - // For now, grant all - decisionHandler(.grant) + let host = origin.host + + // Request camera permission first, then microphone if camera is granted + Task { @MainActor in + PermissionManager.shared.requestPermission( + for: .camera, + from: host, + webView: webView + ) { cameraAllowed in + if cameraAllowed { + PermissionManager.shared.requestPermission( + for: .microphone, + from: host, + webView: webView + ) { microphoneAllowed in + decisionHandler(microphoneAllowed ? .grant : .deny) + } + } else { + decisionHandler(.deny) + } + } + } } func destroyWebView() { diff --git a/ora/Modules/Browser/BrowserView.swift b/ora/Modules/Browser/BrowserView.swift index 37b7c1cf..a77d900e 100644 --- a/ora/Modules/Browser/BrowserView.swift +++ b/ora/Modules/Browser/BrowserView.swift @@ -109,6 +109,9 @@ struct BrowserView: View { if appState.isFloatingTabSwitchVisible { FloatingTabSwitcher() } + + // Permission dialog overlay + PermissionDialogOverlay() } if hide.side == .primary { diff --git a/ora/Services/PermissionInterceptor.swift b/ora/Services/PermissionInterceptor.swift new file mode 100644 index 00000000..8005c9d6 --- /dev/null +++ b/ora/Services/PermissionInterceptor.swift @@ -0,0 +1,209 @@ +import Foundation +import WebKit + +class PermissionInterceptor: NSObject, WKScriptMessageHandler { + static let shared = PermissionInterceptor() + + override private init() { + super.init() + } + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + guard let body = message.body as? [String: Any], + let permissionType = body["permission"] as? String, + let host = body["host"] as? String, + let callbackId = body["callbackId"] as? String + else { + return + } + + guard let permission = mapJSPermissionToKind(permissionType) else { + // Unknown permission type, deny by default + sendPermissionResponse(to: message.webView, callbackId: callbackId, allowed: false) + return + } + + Task { @MainActor in + PermissionManager.shared.requestPermission( + for: permission, + from: host, + webView: message.webView! + ) { allowed in + self.sendPermissionResponse(to: message.webView, callbackId: callbackId, allowed: allowed) + } + } + } + + private func mapJSPermissionToKind(_ jsPermission: String) -> PermissionKind? { + switch jsPermission.lowercased() { + case "geolocation": return .location + case "notifications": return .notifications + case "clipboard-read", "clipboard-write": return .clipboard + case "background-sync": return .backgroundSync + case "persistent-storage": return .fileEditing + case "midi": return .midiDevice + case "camera": return .camera + case "microphone": return .microphone + default: return nil + } + } + + private func sendPermissionResponse(to webView: WKWebView?, callbackId: String, allowed: Bool) { + let script = """ + if (window.oraPermissionCallbacks && window.oraPermissionCallbacks['\(callbackId)']) { + window.oraPermissionCallbacks['\(callbackId)'](\(allowed)); + delete window.oraPermissionCallbacks['\(callbackId)']; + } + """ + + DispatchQueue.main.async { + webView?.evaluateJavaScript(script, completionHandler: nil) + } + } + + static let interceptorScript = """ + // Ora Permission Interceptor + (function() { + // Store callbacks + window.oraPermissionCallbacks = {}; + let callbackCounter = 0; + + // Helper function to request permission + function requestOraPermission(permission, originalMethod, ...args) { + const callbackId = 'callback_' + (++callbackCounter); + const host = window.location.hostname; + + return new Promise((resolve, reject) => { + window.oraPermissionCallbacks[callbackId] = resolve; + + // Send message to native code + window.webkit.messageHandlers.oraPermissionHandler.postMessage({ + permission: permission, + host: host, + callbackId: callbackId + }); + + // Timeout after 30 seconds + setTimeout(() => { + if (window.oraPermissionCallbacks[callbackId]) { + delete window.oraPermissionCallbacks[callbackId]; + reject(new Error('Permission request timeout')); + } + }, 30000); + }); + } + + // Intercept Geolocation API + if (navigator.geolocation) { + const originalGetCurrentPosition = navigator.geolocation.getCurrentPosition; + const originalWatchPosition = navigator.geolocation.watchPosition; + + navigator.geolocation.getCurrentPosition = function(success, error, options) { + requestOraPermission('geolocation').then(allowed => { + if (allowed) { + originalGetCurrentPosition.call(this, success, error, options); + } else if (error) { + error({ code: 1, message: 'Permission denied' }); + } + }).catch(err => { + if (error) error({ code: 2, message: err.message }); + }); + }; + + navigator.geolocation.watchPosition = function(success, error, options) { + requestOraPermission('geolocation').then(allowed => { + if (allowed) { + return originalWatchPosition.call(this, success, error, options); + } else if (error) { + error({ code: 1, message: 'Permission denied' }); + } + return -1; + }).catch(err => { + if (error) error({ code: 2, message: err.message }); + return -1; + }); + }; + } + + // Intercept Notification API + if (window.Notification) { + const originalRequestPermission = Notification.requestPermission; + + Notification.requestPermission = function() { + return requestOraPermission('notifications').then(allowed => { + return allowed ? 'granted' : 'denied'; + }); + }; + } + + // Intercept Clipboard API + if (navigator.clipboard) { + const originalReadText = navigator.clipboard.readText; + const originalWriteText = navigator.clipboard.writeText; + + navigator.clipboard.readText = function() { + return requestOraPermission('clipboard-read').then(allowed => { + if (allowed) { + return originalReadText.call(this); + } else { + throw new Error('Permission denied'); + } + }); + }; + + navigator.clipboard.writeText = function(text) { + return requestOraPermission('clipboard-write').then(allowed => { + if (allowed) { + return originalWriteText.call(this, text); + } else { + throw new Error('Permission denied'); + } + }); + }; + } + + // Intercept Service Worker registration (for background sync) + if (navigator.serviceWorker) { + const originalRegister = navigator.serviceWorker.register; + + navigator.serviceWorker.register = function(scriptURL, options) { + return requestOraPermission('background-sync').then(allowed => { + if (allowed) { + return originalRegister.call(this, scriptURL, options); + } else { + throw new Error('Permission denied'); + } + }); + }; + } + + // Intercept MIDI API + if (navigator.requestMIDIAccess) { + const originalRequestMIDIAccess = navigator.requestMIDIAccess; + + navigator.requestMIDIAccess = function(options) { + return requestOraPermission('midi').then(allowed => { + if (allowed) { + return originalRequestMIDIAccess.call(this, options); + } else { + throw new Error('Permission denied'); + } + }); + }; + } + })(); + """ + + func addToWebViewConfiguration(_ configuration: WKWebViewConfiguration) { + // Add message handler + configuration.userContentController.add(self, name: "oraPermissionHandler") + + // Add interceptor script + let script = WKUserScript( + source: Self.interceptorScript, + injectionTime: .atDocumentStart, + forMainFrameOnly: false + ) + configuration.userContentController.addUserScript(script) + } +} diff --git a/ora/Services/PermissionManager.swift b/ora/Services/PermissionManager.swift new file mode 100644 index 00000000..88ec6334 --- /dev/null +++ b/ora/Services/PermissionManager.swift @@ -0,0 +1,211 @@ +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 + ) { + 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) { + completion(existingPermission) + return + } + + // Show permission dialog + self.pendingRequest = request + self.showPermissionDialog = true + } + + func handlePermissionResponse(allow: Bool) { + guard let request = pendingRequest else { return } + + // 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 + } + + private 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 .location: return site.locationConfigured ? site.locationAllowed : getDefaultPermissionValue(for: type) + case .camera: return site.cameraConfigured ? site.cameraAllowed : getDefaultPermissionValue(for: type) + case .microphone: return site.microphoneConfigured ? site + .microphoneAllowed : getDefaultPermissionValue(for: type) + case .notifications: return site.notificationsConfigured ? site + .notificationsAllowed : getDefaultPermissionValue(for: type) + case .embeddedContent: return site.embeddedContentConfigured ? site + .embeddedContentAllowed : getDefaultPermissionValue(for: type) + case .backgroundSync: return site.backgroundSyncConfigured ? site + .backgroundSyncAllowed : getDefaultPermissionValue(for: type) + case .motionSensors: return site.motionSensorsConfigured ? site + .motionSensorsAllowed : getDefaultPermissionValue(for: type) + case .automaticDownloads: return site.automaticDownloadsConfigured ? site + .automaticDownloadsAllowed : getDefaultPermissionValue(for: type) + case .protocolHandlers: return site.protocolHandlersConfigured ? site + .protocolHandlersAllowed : getDefaultPermissionValue(for: type) + case .midiDevice: return site.midiDeviceConfigured ? site + .midiDeviceAllowed : getDefaultPermissionValue(for: type) + case .usbDevices: return site.usbDevicesConfigured ? site + .usbDevicesAllowed : getDefaultPermissionValue(for: type) + case .serialPorts: return site.serialPortsConfigured ? site + .serialPortsAllowed : getDefaultPermissionValue(for: type) + case .fileEditing: return site.fileEditingConfigured ? site + .fileEditingAllowed : getDefaultPermissionValue(for: type) + case .hidDevices: return site.hidDevicesConfigured ? site + .hidDevicesAllowed : getDefaultPermissionValue(for: type) + case .clipboard: return site.clipboardConfigured ? site.clipboardAllowed : getDefaultPermissionValue(for: type) + case .paymentHandlers: return site.paymentHandlersConfigured ? site + .paymentHandlersAllowed : getDefaultPermissionValue(for: type) + case .augmentedReality: return site.augmentedRealityConfigured ? site + .augmentedRealityAllowed : getDefaultPermissionValue(for: type) + case .virtualReality: return site.virtualRealityConfigured ? site + .virtualRealityAllowed : getDefaultPermissionValue(for: type) + case .deviceUse: return site.deviceUseConfigured ? site.deviceUseAllowed : getDefaultPermissionValue(for: type) + case .windowManagement: return site.windowManagementConfigured ? site + .windowManagementAllowed : getDefaultPermissionValue(for: type) + case .fonts: return site.fontsConfigured ? site.fontsAllowed : getDefaultPermissionValue(for: type) + case .automaticPictureInPicture: return site.automaticPictureInPictureConfigured ? site + .automaticPictureInPictureAllowed : getDefaultPermissionValue(for: type) + case .scrollingZoomingSharedTabs: return site.scrollingZoomingSharedTabsConfigured ? site + .scrollingZoomingSharedTabsAllowed : getDefaultPermissionValue(for: type) + } + } + + private func getDefaultPermissionValue(for type: PermissionKind) -> Bool? { + switch type { + case .backgroundSync: + return true // Allow background sync by default + default: + return nil // Show dialog for all other permissions + } + } +} + +struct PermissionRequest { + let permissionType: PermissionKind + let host: String + let webView: WKWebView + let completion: (Bool) -> Void +} + +extension PermissionKind { + var displayName: String { + switch self { + case .location: return "Location" + case .camera: return "Camera" + case .microphone: return "Microphone" + case .notifications: return "Notifications" + case .embeddedContent: return "Embedded Content" + case .backgroundSync: return "Background Sync" + case .motionSensors: return "Motion Sensors" + case .automaticDownloads: return "Automatic Downloads" + case .protocolHandlers: return "Protocol Handlers" + case .midiDevice: return "MIDI Device Control" + case .usbDevices: return "USB Devices" + case .serialPorts: return "Serial Ports" + case .fileEditing: return "File Editing" + case .hidDevices: return "HID Devices" + case .clipboard: return "Clipboard" + case .paymentHandlers: return "Payment Handlers" + case .augmentedReality: return "Augmented Reality" + case .virtualReality: return "Virtual Reality" + case .deviceUse: return "Device Use Tracking" + case .windowManagement: return "Window Management" + case .fonts: return "Fonts" + case .automaticPictureInPicture: return "Automatic Picture-in-Picture" + case .scrollingZoomingSharedTabs: return "Scrolling and Zooming Shared Tabs" + } + } + + var description: String { + switch self { + case .location: return "Access your location" + case .camera: return "Use your camera" + case .microphone: return "Use your microphone" + case .notifications: return "Send you notifications" + case .embeddedContent: return "Use embedded content" + case .backgroundSync: return "Sync data in the background" + case .motionSensors: return "Access motion sensors" + case .automaticDownloads: return "Automatically download files" + case .protocolHandlers: return "Handle custom protocols" + case .midiDevice: return "Control MIDI devices" + case .usbDevices: return "Connect to USB devices" + case .serialPorts: return "Connect to serial ports" + case .fileEditing: return "Edit files on your device" + case .hidDevices: return "Access HID devices" + case .clipboard: return "Access your clipboard" + case .paymentHandlers: return "Install payment handlers" + case .augmentedReality: return "Use augmented reality features" + case .virtualReality: return "Use virtual reality features" + case .deviceUse: return "Track when you're using your device" + case .windowManagement: return "Manage windows on your displays" + case .fonts: return "Access fonts on your device" + case .automaticPictureInPicture: return "Enter picture-in-picture automatically" + case .scrollingZoomingSharedTabs: return "Scroll and zoom shared tabs" + } + } + + var iconName: String { + switch self { + case .location: return "location" + case .camera: return "camera" + case .microphone: return "mic" + case .notifications: return "bell" + case .embeddedContent: return "rectangle.on.rectangle" + case .backgroundSync: return "arrow.triangle.2.circlepath" + case .motionSensors: return "waveform.path.ecg" + case .automaticDownloads: return "arrow.down.circle" + case .protocolHandlers: return "link" + case .midiDevice: return "airpodspro" + case .usbDevices: return "externaldrive" + case .serialPorts: return "cable.connector.horizontal" + case .fileEditing: return "folder" + case .hidDevices: return "dot.radiowaves.left.and.right" + case .clipboard: return "clipboard" + case .paymentHandlers: return "creditcard" + case .augmentedReality: return "arkit" + case .virtualReality: return "visionpro" + case .deviceUse: return "cursorarrow.rays" + case .windowManagement: return "macwindow.on.rectangle" + case .fonts: return "textformat" + case .automaticPictureInPicture: return "pip" + case .scrollingZoomingSharedTabs: return "magnifyingglass" + } + } +} diff --git a/ora/Services/TabScriptHandler.swift b/ora/Services/TabScriptHandler.swift index 64e8b24b..1d7c87ec 100644 --- a/ora/Services/TabScriptHandler.swift +++ b/ora/Services/TabScriptHandler.swift @@ -97,6 +97,9 @@ class TabScriptHandler: NSObject, WKScriptMessageHandler { contentController.add(self, name: "linkHover") configuration.userContentController = contentController + // Add permission interceptor + PermissionInterceptor.shared.addToWebViewConfiguration(configuration) + return configuration } diff --git a/ora/UI/PermissionDialog.swift b/ora/UI/PermissionDialog.swift new file mode 100644 index 00000000..290b9529 --- /dev/null +++ b/ora/UI/PermissionDialog.swift @@ -0,0 +1,189 @@ +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 .location: + return "This allows the site to know your approximate location for location-based features." + 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." + case .notifications: + return "This allows the site to send you notifications even when you're not actively using it." + case .backgroundSync: + return "This allows the site to sync data in the background for a better experience." + case .clipboard: + return "This allows the site to read from and write to your clipboard." + case .motionSensors: + return "This allows the site to access device motion and orientation sensors." + 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 312c2208..69f17801 100644 --- a/ora/UI/URLBar.swift +++ b/ora/UI/URLBar.swift @@ -72,6 +72,63 @@ struct ExtensionsPopupView: View { case .notifications: site.notificationsAllowed = allow site.notificationsConfigured = true + case .embeddedContent: + site.embeddedContentAllowed = allow + site.embeddedContentConfigured = true + case .backgroundSync: + site.backgroundSyncAllowed = allow + site.backgroundSyncConfigured = true + case .motionSensors: + site.motionSensorsAllowed = allow + site.motionSensorsConfigured = true + case .automaticDownloads: + site.automaticDownloadsAllowed = allow + site.automaticDownloadsConfigured = true + case .protocolHandlers: + site.protocolHandlersAllowed = allow + site.protocolHandlersConfigured = true + case .midiDevice: + site.midiDeviceAllowed = allow + site.midiDeviceConfigured = true + case .usbDevices: + site.usbDevicesAllowed = allow + site.usbDevicesConfigured = true + case .serialPorts: + site.serialPortsAllowed = allow + site.serialPortsConfigured = true + case .fileEditing: + site.fileEditingAllowed = allow + site.fileEditingConfigured = true + case .hidDevices: + site.hidDevicesAllowed = allow + site.hidDevicesConfigured = true + case .clipboard: + site.clipboardAllowed = allow + site.clipboardConfigured = true + case .paymentHandlers: + site.paymentHandlersAllowed = allow + site.paymentHandlersConfigured = true + case .augmentedReality: + site.augmentedRealityAllowed = allow + site.augmentedRealityConfigured = true + case .virtualReality: + site.virtualRealityAllowed = allow + site.virtualRealityConfigured = true + case .deviceUse: + site.deviceUseAllowed = allow + site.deviceUseConfigured = true + case .windowManagement: + site.windowManagementAllowed = allow + site.windowManagementConfigured = true + case .fonts: + site.fontsAllowed = allow + site.fontsConfigured = true + case .automaticPictureInPicture: + site.automaticPictureInPictureAllowed = allow + site.automaticPictureInPictureConfigured = true + case .scrollingZoomingSharedTabs: + site.scrollingZoomingSharedTabsAllowed = allow + site.scrollingZoomingSharedTabsConfigured = true } try? modelContext.save() diff --git a/ora/UI/WebView.swift b/ora/UI/WebView.swift index f09d9766..be7a094d 100644 --- a/ora/UI/WebView.swift +++ b/ora/UI/WebView.swift @@ -110,7 +110,29 @@ struct WebView: NSViewRepresentable { initiatedByFrame frame: WKFrameInfo, decisionHandler: @escaping (WKPermissionDecision) -> Void ) { - decisionHandler(.grant) + let host = origin.host + + // For media capture, we need to determine if it's camera or microphone + // Since WebKit doesn't specify which type, we'll request both + Task { @MainActor in + PermissionManager.shared.requestPermission( + for: .camera, + from: host, + webView: webView + ) { cameraAllowed in + if cameraAllowed { + PermissionManager.shared.requestPermission( + for: .microphone, + from: host, + webView: webView + ) { microphoneAllowed in + decisionHandler(microphoneAllowed ? .grant : .deny) + } + } else { + decisionHandler(.deny) + } + } + } } func webView( @@ -131,6 +153,40 @@ struct WebView: NSViewRepresentable { } } + // MARK: - Additional Permission Handlers + + // Note: Most permissions are handled via JavaScript interceptor + // WebKit only provides delegates for a limited set of permissions + + func webView( + _ webView: WKWebView, + runJavaScriptAlertPanelWithMessage message: String, + initiatedByFrame frame: WKFrameInfo, + completionHandler: @escaping () -> Void + ) { + let alert = NSAlert() + alert.messageText = "Alert" + alert.informativeText = message + alert.addButton(withTitle: "OK") + alert.runModal() + completionHandler() + } + + func webView( + _ webView: WKWebView, + runJavaScriptConfirmPanelWithMessage message: String, + initiatedByFrame frame: WKFrameInfo, + completionHandler: @escaping (Bool) -> Void + ) { + let alert = NSAlert() + alert.messageText = "Confirm" + alert.informativeText = message + 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( diff --git a/ora/oraApp.swift b/ora/oraApp.swift index 8d9ea514..616e21d7 100644 --- a/ora/oraApp.swift +++ b/ora/oraApp.swift @@ -41,10 +41,9 @@ struct OraApp: App { url: URL.applicationSupportDirectory.appending(path: "OraData.sqlite") ) init() { - // #if DEBUG - // deleteSwiftDataStore("OraData.sqlite") - // #endif - // + #if DEBUG + deleteSwiftDataStore("OraData.sqlite") + #endif // Create single container for all models let container: ModelContainer let modelContext: ModelContext From 8779987fef986976b09008a20f1b89a2f4e7e56f Mon Sep 17 00:00:00 2001 From: brooksolomon Date: Tue, 9 Sep 2025 13:21:51 +0300 Subject: [PATCH 14/26] added camera and mic request to info.plist so that we can ask from the system --- ora/Info.plist | 52 +++++++++++++++++++++++++++++--------------------- 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/ora/Info.plist b/ora/Info.plist index a0ce0850..905c6ed1 100644 --- a/ora/Info.plist +++ b/ora/Info.plist @@ -2,27 +2,35 @@ - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - APPL - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - SUEnableAutomaticChecks - - SUFeedURL - https://the-ora.github.io/browser/appcast.xml - SUPublicEDKey - Ozj+rezzbJAD76RfajtfQ7rFojJbpFSCl/0DcFSBCTI= + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + + NSCameraUsageDescription + This browser needs camera access for video calls and websites that use the camera. + NSMicrophoneUsageDescription + This browser needs microphone access for voice calls and websites that use the microphone. + + + SUEnableAutomaticChecks + + SUFeedURL + https://the-ora.github.io/browser/appcast.xml + SUPublicEDKey + Ozj+rezzbJAD76RfajtfQ7rFojJbpFSCl/0DcFSBCTI= From 1c1ad3a6dab5554be576541161ba0cb72b166656 Mon Sep 17 00:00:00 2001 From: brooksolomon Date: Mon, 15 Sep 2025 23:23:08 +0300 Subject: [PATCH 15/26] removed javascript injectors and implimented it with UI deligates --- ora/Models/Tab.swift | 185 ++++++++++++++---- ora/Services/PermissionInterceptor.swift | 209 --------------------- ora/Services/PermissionManager.swift | 39 +++- ora/Services/PermissionSettingsStore.swift | 13 +- ora/Services/TabScriptHandler.swift | 3 +- ora/UI/WebView.swift | 84 ++++++--- 6 files changed, 254 insertions(+), 279 deletions(-) delete mode 100644 ora/Services/PermissionInterceptor.swift diff --git a/ora/Models/Tab.swift b/ora/Models/Tab.swift index f3c56c8c..684857f8 100644 --- a/ora/Models/Tab.swift +++ b/ora/Models/Tab.swift @@ -42,6 +42,7 @@ class Tab: ObservableObject, Identifiable { // Not persisted: in-memory only @Transient var webView: WKWebView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration()) @Transient var navigationDelegate: WebViewNavigationDelegate? + @Transient var uiDelegate: TabUIDelegate? @Transient @Published var isWebViewReady: Bool = false @Transient @Published var loadingProgress: Double = 10.0 @Transient var colorUpdated = false @@ -106,11 +107,12 @@ class Tab: ObservableObject, Identifiable { layer.drawsAsynchronously = true } - // Set up navigation delegate + // Set up navigation delegate and UI delegate // Load initial URL DispatchQueue.main.async { self.setupNavigationDelegate() + self.setupUIDelegate() self.syncBackgroundColorFromHex() self.webView.load(URLRequest(url: url)) self.isWebViewReady = true @@ -232,6 +234,13 @@ class Tab: ObservableObject, Identifiable { webView.navigationDelegate = delegate } + private func setupUIDelegate() { + print("🎥 setupUIDelegate") + let delegate = TabUIDelegate(tab: self) + self.uiDelegate = delegate + webView.uiDelegate = delegate + } + func goForward() { self.webView.goForward() self.updateHeaderColor() @@ -268,6 +277,8 @@ class Tab: ObservableObject, Identifiable { self.tabManager = tabManager self.isWebViewReady = false self.setupNavigationDelegate() + print("🎥 setupUIDelegate") + self.setupUIDelegate() self.syncBackgroundColorFromHex() // Load after a short delay to ensure layout DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { @@ -314,43 +325,16 @@ class Tab: ObservableObject, Identifiable { // Navigation failed } - func webView( - _ webView: WKWebView, - requestMediaCapturePermissionFor origin: WKSecurityOrigin, - initiatedByFrame frame: WKFrameInfo, - decisionHandler: @escaping (WKPermissionDecision) -> Void - ) { - let host = origin.host - - // Request camera permission first, then microphone if camera is granted - Task { @MainActor in - PermissionManager.shared.requestPermission( - for: .camera, - from: host, - webView: webView - ) { cameraAllowed in - if cameraAllowed { - PermissionManager.shared.requestPermission( - for: .microphone, - from: host, - webView: webView - ) { microphoneAllowed in - decisionHandler(microphoneAllowed ? .grant : .deny) - } - } else { - decisionHandler(.deny) - } - } - } - } - func destroyWebView() { webView.stopLoading() webView.navigationDelegate = nil webView.uiDelegate = nil webView.configuration.userContentController.removeAllUserScripts() webView.removeFromSuperview() - webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration()) + + let config = TabScriptHandler() + config.tab = self + webView = WKWebView(frame: .zero, configuration: config.defaultWKConfig()) } func setNavigationError(_ error: Error, for url: URL?) { @@ -379,6 +363,143 @@ class Tab: ObservableObject, Identifiable { } } +// MARK: - TabUIDelegate + +class TabUIDelegate: NSObject, WKUIDelegate { + weak var tab: Tab? + + init(tab: Tab) { + self.tab = tab + super.init() + // TabUIDelegate initialized + } + + func webView( + _ webView: WKWebView, + requestMediaCapturePermissionFor origin: WKSecurityOrigin, + initiatedByFrame frame: WKFrameInfo, + type: WKMediaCaptureType, + decisionHandler: @escaping (WKPermissionDecision) -> Void + ) { + let host = origin.host + print("🎥 TabUIDelegate: Requesting \(type) for \(host)") + // Handle media capture permission request + + // 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)") + // Handle combined camera and microphone permission request + + // 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) + } + } + } + } + } + } +} + extension FileManager { var faviconDirectory: URL { let dir = urls(for: .cachesDirectory, in: .userDomainMask).first! diff --git a/ora/Services/PermissionInterceptor.swift b/ora/Services/PermissionInterceptor.swift deleted file mode 100644 index 8005c9d6..00000000 --- a/ora/Services/PermissionInterceptor.swift +++ /dev/null @@ -1,209 +0,0 @@ -import Foundation -import WebKit - -class PermissionInterceptor: NSObject, WKScriptMessageHandler { - static let shared = PermissionInterceptor() - - override private init() { - super.init() - } - - func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { - guard let body = message.body as? [String: Any], - let permissionType = body["permission"] as? String, - let host = body["host"] as? String, - let callbackId = body["callbackId"] as? String - else { - return - } - - guard let permission = mapJSPermissionToKind(permissionType) else { - // Unknown permission type, deny by default - sendPermissionResponse(to: message.webView, callbackId: callbackId, allowed: false) - return - } - - Task { @MainActor in - PermissionManager.shared.requestPermission( - for: permission, - from: host, - webView: message.webView! - ) { allowed in - self.sendPermissionResponse(to: message.webView, callbackId: callbackId, allowed: allowed) - } - } - } - - private func mapJSPermissionToKind(_ jsPermission: String) -> PermissionKind? { - switch jsPermission.lowercased() { - case "geolocation": return .location - case "notifications": return .notifications - case "clipboard-read", "clipboard-write": return .clipboard - case "background-sync": return .backgroundSync - case "persistent-storage": return .fileEditing - case "midi": return .midiDevice - case "camera": return .camera - case "microphone": return .microphone - default: return nil - } - } - - private func sendPermissionResponse(to webView: WKWebView?, callbackId: String, allowed: Bool) { - let script = """ - if (window.oraPermissionCallbacks && window.oraPermissionCallbacks['\(callbackId)']) { - window.oraPermissionCallbacks['\(callbackId)'](\(allowed)); - delete window.oraPermissionCallbacks['\(callbackId)']; - } - """ - - DispatchQueue.main.async { - webView?.evaluateJavaScript(script, completionHandler: nil) - } - } - - static let interceptorScript = """ - // Ora Permission Interceptor - (function() { - // Store callbacks - window.oraPermissionCallbacks = {}; - let callbackCounter = 0; - - // Helper function to request permission - function requestOraPermission(permission, originalMethod, ...args) { - const callbackId = 'callback_' + (++callbackCounter); - const host = window.location.hostname; - - return new Promise((resolve, reject) => { - window.oraPermissionCallbacks[callbackId] = resolve; - - // Send message to native code - window.webkit.messageHandlers.oraPermissionHandler.postMessage({ - permission: permission, - host: host, - callbackId: callbackId - }); - - // Timeout after 30 seconds - setTimeout(() => { - if (window.oraPermissionCallbacks[callbackId]) { - delete window.oraPermissionCallbacks[callbackId]; - reject(new Error('Permission request timeout')); - } - }, 30000); - }); - } - - // Intercept Geolocation API - if (navigator.geolocation) { - const originalGetCurrentPosition = navigator.geolocation.getCurrentPosition; - const originalWatchPosition = navigator.geolocation.watchPosition; - - navigator.geolocation.getCurrentPosition = function(success, error, options) { - requestOraPermission('geolocation').then(allowed => { - if (allowed) { - originalGetCurrentPosition.call(this, success, error, options); - } else if (error) { - error({ code: 1, message: 'Permission denied' }); - } - }).catch(err => { - if (error) error({ code: 2, message: err.message }); - }); - }; - - navigator.geolocation.watchPosition = function(success, error, options) { - requestOraPermission('geolocation').then(allowed => { - if (allowed) { - return originalWatchPosition.call(this, success, error, options); - } else if (error) { - error({ code: 1, message: 'Permission denied' }); - } - return -1; - }).catch(err => { - if (error) error({ code: 2, message: err.message }); - return -1; - }); - }; - } - - // Intercept Notification API - if (window.Notification) { - const originalRequestPermission = Notification.requestPermission; - - Notification.requestPermission = function() { - return requestOraPermission('notifications').then(allowed => { - return allowed ? 'granted' : 'denied'; - }); - }; - } - - // Intercept Clipboard API - if (navigator.clipboard) { - const originalReadText = navigator.clipboard.readText; - const originalWriteText = navigator.clipboard.writeText; - - navigator.clipboard.readText = function() { - return requestOraPermission('clipboard-read').then(allowed => { - if (allowed) { - return originalReadText.call(this); - } else { - throw new Error('Permission denied'); - } - }); - }; - - navigator.clipboard.writeText = function(text) { - return requestOraPermission('clipboard-write').then(allowed => { - if (allowed) { - return originalWriteText.call(this, text); - } else { - throw new Error('Permission denied'); - } - }); - }; - } - - // Intercept Service Worker registration (for background sync) - if (navigator.serviceWorker) { - const originalRegister = navigator.serviceWorker.register; - - navigator.serviceWorker.register = function(scriptURL, options) { - return requestOraPermission('background-sync').then(allowed => { - if (allowed) { - return originalRegister.call(this, scriptURL, options); - } else { - throw new Error('Permission denied'); - } - }); - }; - } - - // Intercept MIDI API - if (navigator.requestMIDIAccess) { - const originalRequestMIDIAccess = navigator.requestMIDIAccess; - - navigator.requestMIDIAccess = function(options) { - return requestOraPermission('midi').then(allowed => { - if (allowed) { - return originalRequestMIDIAccess.call(this, options); - } else { - throw new Error('Permission denied'); - } - }); - }; - } - })(); - """ - - func addToWebViewConfiguration(_ configuration: WKWebViewConfiguration) { - // Add message handler - configuration.userContentController.add(self, name: "oraPermissionHandler") - - // Add interceptor script - let script = WKUserScript( - source: Self.interceptorScript, - injectionTime: .atDocumentStart, - forMainFrameOnly: false - ) - configuration.userContentController.addUserScript(script) - } -} diff --git a/ora/Services/PermissionManager.swift b/ora/Services/PermissionManager.swift index 88ec6334..982a251d 100644 --- a/ora/Services/PermissionManager.swift +++ b/ora/Services/PermissionManager.swift @@ -19,6 +19,8 @@ class PermissionManager: NSObject, ObservableObject { webView: WKWebView, completion: @escaping (Bool) -> Void ) { + print("🔧 PermissionManager: Requesting \(permissionType) for \(host)") + let request = PermissionRequest( permissionType: permissionType, host: host, @@ -28,17 +30,47 @@ class PermissionManager: NSObject, ObservableObject { // 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 { return } + 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( @@ -53,10 +85,13 @@ class PermissionManager: NSObject, ObservableObject { // Clear pending request self.pendingRequest = nil self.showPermissionDialog = false + + print("🔧 Permission dialog cleared, ready for next request") } - private func getExistingPermission(for host: String, type: PermissionKind) -> Bool? { + 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) diff --git a/ora/Services/PermissionSettingsStore.swift b/ora/Services/PermissionSettingsStore.swift index ff1cedfb..6ef4b6fb 100644 --- a/ora/Services/PermissionSettingsStore.swift +++ b/ora/Services/PermissionSettingsStore.swift @@ -27,6 +27,14 @@ final class PermissionSettingsStore: ObservableObject { )) ?? [] } + // 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] { @@ -156,9 +164,8 @@ final class PermissionSettingsStore: ObservableObject { } saveContext() - sitePermissions.sort { $0.host.lowercased() < $1.host.lowercased() } - // trigger update - objectWillChange.send() + // Refresh permissions from context to ensure we have the latest data + refreshPermissions() } func removeSite(host: String) { diff --git a/ora/Services/TabScriptHandler.swift b/ora/Services/TabScriptHandler.swift index 1d7c87ec..f2f21ed1 100644 --- a/ora/Services/TabScriptHandler.swift +++ b/ora/Services/TabScriptHandler.swift @@ -97,8 +97,7 @@ class TabScriptHandler: NSObject, WKScriptMessageHandler { contentController.add(self, name: "linkHover") configuration.userContentController = contentController - // Add permission interceptor - PermissionInterceptor.shared.addToWebViewConfiguration(configuration) + // Permission handling is done via WKUIDelegate methods only return configuration } diff --git a/ora/UI/WebView.swift b/ora/UI/WebView.swift index be7a094d..c3b27e3f 100644 --- a/ora/UI/WebView.swift +++ b/ora/UI/WebView.swift @@ -13,7 +13,8 @@ struct WebView: NSViewRepresentable { } func makeNSView(context: Context) -> WKWebView { - webView.uiDelegate = context.coordinator + // Don't override uiDelegate - let Tab handle it + // webView.uiDelegate = context.coordinator webView.autoresizingMask = [.width, .height] webView.layer?.isOpaque = true @@ -104,36 +105,57 @@ struct WebView: NSViewRepresentable { return webView.bounds.contains(locationInWebView) } - func webView( - _ webView: WKWebView, - requestMediaCapturePermissionFor origin: WKSecurityOrigin, - initiatedByFrame frame: WKFrameInfo, - decisionHandler: @escaping (WKPermissionDecision) -> Void - ) { - let host = origin.host - - // For media capture, we need to determine if it's camera or microphone - // Since WebKit doesn't specify which type, we'll request both - Task { @MainActor in - PermissionManager.shared.requestPermission( - for: .camera, - from: host, - webView: webView - ) { cameraAllowed in - if cameraAllowed { - PermissionManager.shared.requestPermission( - for: .microphone, - from: host, - webView: webView - ) { microphoneAllowed in - decisionHandler(microphoneAllowed ? .grant : .deny) - } - } else { - decisionHandler(.deny) - } - } - } - } + // func webView( + // _ webView: WKWebView, + // requestMediaCapturePermissionFor origin: WKSecurityOrigin, + // initiatedByFrame frame: WKFrameInfo, + // decisionHandler: @escaping (WKPermissionDecision) -> Void + // ) { + // let host = origin.host + // print("🎥 WebKit requesting media capture for: \(host)") + + // // Check if we already have permissions configured for this host + // let cameraPermission = PermissionManager.shared.getExistingPermission(for: host, type: .camera) + // let microphonePermission = PermissionManager.shared.getExistingPermission(for: host, type: .microphone) + + // print("🎥 Existing permissions - Camera: \(String(describing: cameraPermission)), Microphone: + // \(String(describing: microphonePermission))") + + // // If both permissions are already configured, use them + // if let cameraAllowed = cameraPermission, let microphoneAllowed = microphonePermission { + // let shouldGrant = cameraAllowed || microphoneAllowed + // print("🎥 Using existing permissions, granting: \(shouldGrant)") + // decisionHandler(shouldGrant ? .grant : .deny) + // return + // } + + // print("🎥 Requesting new permissions...") + + // // If permissions aren't configured, we need to request them + // // Since WebKit doesn't specify which media type, we'll request both + // Task { @MainActor in + // // First request camera permission + // PermissionManager.shared.requestPermission( + // for: .camera, + // from: host, + // webView: webView + // ) { cameraAllowed in + // print("🎥 Camera permission result: \(cameraAllowed)") + // // Then request microphone permission + // PermissionManager.shared.requestPermission( + // for: .microphone, + // from: host, + // webView: webView + // ) { microphoneAllowed in + // print("🎥 Microphone permission result: \(microphoneAllowed)") + // // Grant if either permission is allowed + // let shouldGrant = cameraAllowed || microphoneAllowed + // print("🎥 Final decision: \(shouldGrant)") + // decisionHandler(shouldGrant ? .grant : .deny) + // } + // } + // } + // } func webView( _ webView: WKWebView, runOpenPanelWith parameters: WKOpenPanelParameters, From 34a29560b92ed5549120edf99a4da48c4d7e713b Mon Sep 17 00:00:00 2001 From: brooksolomon Date: Tue, 16 Sep 2025 11:41:15 +0300 Subject: [PATCH 16/26] removed all the other permissions and only kept camera and micraphoen --- ora/Models/SitePermission.swift | 113 +----- .../Settings/Sections/SiteSettingsView.swift | 371 ------------------ ora/Services/PermissionManager.swift | 116 +----- ora/Services/PermissionSettingsStore.swift | 99 +---- ora/UI/PermissionDialog.swift | 10 - ora/UI/URLBar.swift | 87 +--- 6 files changed, 19 insertions(+), 777 deletions(-) diff --git a/ora/Models/SitePermission.swift b/ora/Models/SitePermission.swift index 94319bb4..ec86ce94 100644 --- a/ora/Models/SitePermission.swift +++ b/ora/Models/SitePermission.swift @@ -5,132 +5,23 @@ import SwiftData final class SitePermission { @Attribute(.unique) var host: String - // Main permissions - var locationAllowed: Bool + // Permissions var cameraAllowed: Bool var microphoneAllowed: Bool - var notificationsAllowed: Bool - - // Additional permissions - var embeddedContentAllowed: Bool - var backgroundSyncAllowed: Bool - var motionSensorsAllowed: Bool - var automaticDownloadsAllowed: Bool - var protocolHandlersAllowed: Bool - var midiDeviceAllowed: Bool - var usbDevicesAllowed: Bool - var serialPortsAllowed: Bool - var fileEditingAllowed: Bool - var hidDevicesAllowed: Bool - var clipboardAllowed: Bool - var paymentHandlersAllowed: Bool - var augmentedRealityAllowed: Bool - var virtualRealityAllowed: Bool - var deviceUseAllowed: Bool - var windowManagementAllowed: Bool - var fontsAllowed: Bool - var automaticPictureInPictureAllowed: Bool - var scrollingZoomingSharedTabsAllowed: Bool // Track which permissions have been explicitly set - var locationConfigured: Bool var cameraConfigured: Bool var microphoneConfigured: Bool - var notificationsConfigured: Bool - var embeddedContentConfigured: Bool - var backgroundSyncConfigured: Bool - var motionSensorsConfigured: Bool - var automaticDownloadsConfigured: Bool - var protocolHandlersConfigured: Bool - var midiDeviceConfigured: Bool - var usbDevicesConfigured: Bool - var serialPortsConfigured: Bool - var fileEditingConfigured: Bool - var hidDevicesConfigured: Bool - var clipboardConfigured: Bool - var paymentHandlersConfigured: Bool - var augmentedRealityConfigured: Bool - var virtualRealityConfigured: Bool - var deviceUseConfigured: Bool - var windowManagementConfigured: Bool - var fontsConfigured: Bool - var automaticPictureInPictureConfigured: Bool - var scrollingZoomingSharedTabsConfigured: Bool init( host: String, - locationAllowed: Bool = false, cameraAllowed: Bool = false, - microphoneAllowed: Bool = false, - notificationsAllowed: Bool = false, - embeddedContentAllowed: Bool = false, - backgroundSyncAllowed: Bool = true, // Allow by default - motionSensorsAllowed: Bool = false, - automaticDownloadsAllowed: Bool = false, - protocolHandlersAllowed: Bool = false, - midiDeviceAllowed: Bool = false, - usbDevicesAllowed: Bool = false, - serialPortsAllowed: Bool = false, - fileEditingAllowed: Bool = false, - hidDevicesAllowed: Bool = false, - clipboardAllowed: Bool = false, - paymentHandlersAllowed: Bool = false, - augmentedRealityAllowed: Bool = false, - virtualRealityAllowed: Bool = false, - deviceUseAllowed: Bool = false, - windowManagementAllowed: Bool = false, - fontsAllowed: Bool = false, - automaticPictureInPictureAllowed: Bool = false, - scrollingZoomingSharedTabsAllowed: Bool = false + microphoneAllowed: Bool = false ) { self.host = host - self.locationAllowed = locationAllowed self.cameraAllowed = cameraAllowed self.microphoneAllowed = microphoneAllowed - self.notificationsAllowed = notificationsAllowed - self.embeddedContentAllowed = embeddedContentAllowed - self.backgroundSyncAllowed = backgroundSyncAllowed - self.motionSensorsAllowed = motionSensorsAllowed - self.automaticDownloadsAllowed = automaticDownloadsAllowed - self.protocolHandlersAllowed = protocolHandlersAllowed - self.midiDeviceAllowed = midiDeviceAllowed - self.usbDevicesAllowed = usbDevicesAllowed - self.serialPortsAllowed = serialPortsAllowed - self.fileEditingAllowed = fileEditingAllowed - self.hidDevicesAllowed = hidDevicesAllowed - self.clipboardAllowed = clipboardAllowed - self.paymentHandlersAllowed = paymentHandlersAllowed - self.augmentedRealityAllowed = augmentedRealityAllowed - self.virtualRealityAllowed = virtualRealityAllowed - self.deviceUseAllowed = deviceUseAllowed - self.windowManagementAllowed = windowManagementAllowed - self.fontsAllowed = fontsAllowed - self.automaticPictureInPictureAllowed = automaticPictureInPictureAllowed - self.scrollingZoomingSharedTabsAllowed = scrollingZoomingSharedTabsAllowed - - // Initially, no permissions are configured - self.locationConfigured = false self.cameraConfigured = false self.microphoneConfigured = false - self.notificationsConfigured = false - self.embeddedContentConfigured = false - self.backgroundSyncConfigured = true - self.motionSensorsConfigured = false - self.automaticDownloadsConfigured = false - self.protocolHandlersConfigured = false - self.midiDeviceConfigured = false - self.usbDevicesConfigured = false - self.serialPortsConfigured = false - self.fileEditingConfigured = false - self.hidDevicesConfigured = false - self.clipboardConfigured = false - self.paymentHandlersConfigured = false - self.augmentedRealityConfigured = false - self.virtualRealityConfigured = false - self.deviceUseConfigured = false - self.windowManagementConfigured = false - self.fontsConfigured = false - self.automaticPictureInPictureConfigured = false - self.scrollingZoomingSharedTabsConfigured = false } } diff --git a/ora/Modules/Settings/Sections/SiteSettingsView.swift b/ora/Modules/Settings/Sections/SiteSettingsView.swift index ab539b2f..3b1e04ab 100644 --- a/ora/Modules/Settings/Sections/SiteSettingsView.swift +++ b/ora/Modules/Settings/Sections/SiteSettingsView.swift @@ -10,13 +10,6 @@ struct SiteSettingsView: View { VStack(alignment: .leading, spacing: 0) { InlineBackButton(action: { dismiss() }) .padding(.bottom, 8) - PermissionRow( - title: "Location", - subtitle: "Sites can ask for your location", - systemImage: "location" - ) { - LocationPermissionView() - } PermissionRow( title: "Camera", @@ -34,250 +27,6 @@ struct SiteSettingsView: View { MicrophonePermissionView() } - PermissionRow( - title: "Notifications", - subtitle: "Collapse unwanted requests (recommended)", - systemImage: "bell" - ) { - NotificationsPermissionView() - } - - PermissionRow( - title: "Embedded content", - subtitle: "Sites can ask to use information they've saved about you", - systemImage: "rectangle.on.rectangle" - ) { - EmbeddedContentPermissionView() - } - - DisclosureGroup(isExpanded: $isAdditionalExpanded) { - VStack(spacing: 0) { - PermissionRow( - title: "Background sync", - subtitle: "Recently closed sites can finish sending and receiving data", - systemImage: "arrow.triangle.2.circlepath" - ) { - BackgroundSyncPermissionView() - } - PermissionRow( - title: "Motion sensors", - subtitle: "Sites can use motion sensors", - systemImage: "waveform.path.ecg" - ) { - MotionSensorsPermissionView() - } - PermissionRow( - title: "Automatic downloads", - subtitle: "Sites can ask to automatically download multiple files", - systemImage: "arrow.down.circle" - ) { - AutomaticDownloadsPermissionView() - } - PermissionRow( - title: "Protocol handlers", - subtitle: "Sites can ask to handle protocols", - systemImage: "link" - ) { - UnifiedPermissionView( - title: "Protocol handlers", - description: "Sites can ask to handle protocols", - permissionKind: .protocolHandlers, - allowedText: "Allowed to handle protocols", - blockedText: "Not allowed to handle protocols" - ) - } - PermissionRow( - title: "MIDI device control & reprogram", - subtitle: "Sites can ask to control and reprogram your MIDI devices", - systemImage: "airpodspro" - ) { - UnifiedPermissionView( - title: "MIDI device control & reprogram", - description: "Sites can ask to control and reprogram your MIDI devices", - permissionKind: .midiDevice, - allowedText: "Allowed to control MIDI devices", - blockedText: "Not allowed to control MIDI devices" - ) - } - PermissionRow( - title: "USB devices", - subtitle: "Sites can ask to connect to USB devices", - systemImage: "externaldrive" - ) { - UnifiedPermissionView( - title: "USB devices", - description: "Sites can ask to connect to USB devices", - permissionKind: .usbDevices, - allowedText: "Allowed to connect to USB devices", - blockedText: "Not allowed to connect to USB devices" - ) - } - PermissionRow( - title: "Serial ports", - subtitle: "Sites can ask to connect to serial ports", - systemImage: "cable.connector.horizontal" - ) { - UnifiedPermissionView( - title: "Serial ports", - description: "Sites can ask to connect to serial ports", - permissionKind: .serialPorts, - allowedText: "Allowed to connect to serial ports", - blockedText: "Not allowed to connect to serial ports" - ) - } - PermissionRow( - title: "File editing", - subtitle: "Sites can ask to edit files and folders on your device", - systemImage: "folder" - ) { - UnifiedPermissionView( - title: "File editing", - description: "Sites can ask to edit files and folders on your device", - permissionKind: .fileEditing, - allowedText: "Allowed to edit files", - blockedText: "Not allowed to edit files" - ) - } - PermissionRow( - title: "HID devices", - subtitle: "Ask when a site wants to access HID devices", - systemImage: "dot.radiowaves.left.and.right" - ) { - UnifiedPermissionView( - title: "HID devices", - description: "Ask when a site wants to access HID devices", - permissionKind: .hidDevices, - allowedText: "Allowed to access HID devices", - blockedText: "Not allowed to access HID devices" - ) - } - PermissionRow( - title: "Clipboard", - subtitle: "Sites can ask to see text and images on your clipboard", - systemImage: "clipboard" - ) { - UnifiedPermissionView( - title: "Clipboard", - description: "Sites can ask to see text and images on your clipboard", - permissionKind: .clipboard, - allowedText: "Allowed to access clipboard", - blockedText: "Not allowed to access clipboard" - ) - } - PermissionRow( - title: "Payment handlers", - subtitle: "Sites can install payment handlers", - systemImage: "creditcard" - ) { - UnifiedPermissionView( - title: "Payment handlers", - description: "Sites can install payment handlers", - permissionKind: .paymentHandlers, - allowedText: "Allowed to install payment handlers", - blockedText: "Not allowed to install payment handlers" - ) - } - PermissionRow( - title: "Augmented reality", - subtitle: "Ask when a site wants to create a 3D map of your surroundings or track camera position", - systemImage: "arkit" - ) { - UnifiedPermissionView( - title: "Augmented reality", - description: "Ask when a site wants to create a 3D map of your surroundings or track camera position", - permissionKind: .augmentedReality, - allowedText: "Allowed to use augmented reality", - blockedText: "Not allowed to use augmented reality" - ) - } - PermissionRow( - title: "Virtual reality", - subtitle: "Sites can ask to use virtual reality devices and data", - systemImage: "visionpro" - ) { - UnifiedPermissionView( - title: "Virtual reality", - description: "Sites can ask to use virtual reality devices and data", - permissionKind: .virtualReality, - allowedText: "Allowed to use virtual reality", - blockedText: "Not allowed to use virtual reality" - ) - } - PermissionRow( - title: "Your device use", - subtitle: "Sites can ask to know when you're actively using your device", - systemImage: "cursorarrow.rays" - ) { - UnifiedPermissionView( - title: "Your device use", - description: "Sites can ask to know when you're actively using your device", - permissionKind: .deviceUse, - allowedText: "Allowed to track device use", - blockedText: "Not allowed to track device use" - ) - } - PermissionRow( - title: "Window management", - subtitle: "Sites can ask to manage windows on all your displays", - systemImage: "macwindow.on.rectangle" - ) { - UnifiedPermissionView( - title: "Window management", - description: "Sites can ask to manage windows on all your displays", - permissionKind: .windowManagement, - allowedText: "Allowed to manage windows", - blockedText: "Not allowed to manage windows" - ) - } - PermissionRow( - title: "Fonts", - subtitle: "Sites can ask to use fonts installed on your device", - systemImage: "textformat" - ) { - UnifiedPermissionView( - title: "Fonts", - description: "Sites can ask to use fonts installed on your device", - permissionKind: .fonts, - allowedText: "Allowed to access fonts", - blockedText: "Not allowed to access fonts" - ) - } - PermissionRow( - title: "Automatic picture-in-picture", - subtitle: "Sites can enter picture-in-picture automatically", - systemImage: "pip" - ) { - UnifiedPermissionView( - title: "Automatic picture-in-picture", - description: "Sites can enter picture-in-picture automatically", - permissionKind: .automaticPictureInPicture, - allowedText: "Allowed to use automatic picture-in-picture", - blockedText: "Not allowed to use automatic picture-in-picture" - ) - } - PermissionRow( - title: "Scrolling and zooming shared tabs", - subtitle: "Sites can ask to scroll and zoom shared tabs", - systemImage: "magnifyingglass" - ) { - UnifiedPermissionView( - title: "Scrolling and zooming shared tabs", - description: "Sites can ask to scroll and zoom shared tabs", - permissionKind: .scrollingZoomingSharedTabs, - allowedText: "Allowed to scroll and zoom shared tabs", - blockedText: "Not allowed to scroll and zoom shared tabs" - ) - } - } - .padding(.top, 8) - } label: { - HStack { - Text("Additional permissions") - Spacer(minLength: 0) - } - .contentShape(Rectangle()) - .onTapGesture { isAdditionalExpanded.toggle() } - } .padding(.top, 16) } } @@ -333,31 +82,8 @@ struct UnifiedPermissionView: View { let filtered = allSitePermissions.filter { site in switch permissionKind { - case .location: return site.locationConfigured && site.locationAllowed case .camera: return site.cameraConfigured && site.cameraAllowed case .microphone: return site.microphoneConfigured && site.microphoneAllowed - case .notifications: return site.notificationsConfigured && site.notificationsAllowed - case .embeddedContent: return site.embeddedContentConfigured && site.embeddedContentAllowed - case .backgroundSync: return site.backgroundSyncConfigured && site.backgroundSyncAllowed - case .motionSensors: return site.motionSensorsConfigured && site.motionSensorsAllowed - case .automaticDownloads: return site.automaticDownloadsConfigured && site.automaticDownloadsAllowed - case .protocolHandlers: return site.protocolHandlersConfigured && site.protocolHandlersAllowed - case .midiDevice: return site.midiDeviceConfigured && site.midiDeviceAllowed - case .usbDevices: return site.usbDevicesConfigured && site.usbDevicesAllowed - case .serialPorts: return site.serialPortsConfigured && site.serialPortsAllowed - case .fileEditing: return site.fileEditingConfigured && site.fileEditingAllowed - case .hidDevices: return site.hidDevicesConfigured && site.hidDevicesAllowed - case .clipboard: return site.clipboardConfigured && site.clipboardAllowed - case .paymentHandlers: return site.paymentHandlersConfigured && site.paymentHandlersAllowed - case .augmentedReality: return site.augmentedRealityConfigured && site.augmentedRealityAllowed - case .virtualReality: return site.virtualRealityConfigured && site.virtualRealityAllowed - case .deviceUse: return site.deviceUseConfigured && site.deviceUseAllowed - case .windowManagement: return site.windowManagementConfigured && site.windowManagementAllowed - case .fonts: return site.fontsConfigured && site.fontsAllowed - case .automaticPictureInPicture: return site.automaticPictureInPictureConfigured && site - .automaticPictureInPictureAllowed - case .scrollingZoomingSharedTabs: return site.scrollingZoomingSharedTabsConfigured && site - .scrollingZoomingSharedTabsAllowed } } @@ -373,31 +99,8 @@ struct UnifiedPermissionView: View { let filtered = allSitePermissions.filter { site in switch permissionKind { - case .location: return site.locationConfigured && !site.locationAllowed case .camera: return site.cameraConfigured && !site.cameraAllowed case .microphone: return site.microphoneConfigured && !site.microphoneAllowed - case .notifications: return site.notificationsConfigured && !site.notificationsAllowed - case .embeddedContent: return site.embeddedContentConfigured && !site.embeddedContentAllowed - case .backgroundSync: return site.backgroundSyncConfigured && !site.backgroundSyncAllowed - case .motionSensors: return site.motionSensorsConfigured && !site.motionSensorsAllowed - case .automaticDownloads: return site.automaticDownloadsConfigured && !site.automaticDownloadsAllowed - case .protocolHandlers: return site.protocolHandlersConfigured && !site.protocolHandlersAllowed - case .midiDevice: return site.midiDeviceConfigured && !site.midiDeviceAllowed - case .usbDevices: return site.usbDevicesConfigured && !site.usbDevicesAllowed - case .serialPorts: return site.serialPortsConfigured && !site.serialPortsAllowed - case .fileEditing: return site.fileEditingConfigured && !site.fileEditingAllowed - case .hidDevices: return site.hidDevicesConfigured && !site.hidDevicesAllowed - case .clipboard: return site.clipboardConfigured && !site.clipboardAllowed - case .paymentHandlers: return site.paymentHandlersConfigured && !site.paymentHandlersAllowed - case .augmentedReality: return site.augmentedRealityConfigured && !site.augmentedRealityAllowed - case .virtualReality: return site.virtualRealityConfigured && !site.virtualRealityAllowed - case .deviceUse: return site.deviceUseConfigured && !site.deviceUseAllowed - case .windowManagement: return site.windowManagementConfigured && !site.windowManagementAllowed - case .fonts: return site.fontsConfigured && !site.fontsAllowed - case .automaticPictureInPicture: return site.automaticPictureInPictureConfigured && !site - .automaticPictureInPictureAllowed - case .scrollingZoomingSharedTabs: return site.scrollingZoomingSharedTabsConfigured && !site - .scrollingZoomingSharedTabsAllowed } } @@ -474,18 +177,6 @@ struct UnifiedPermissionView: View { } } -struct LocationPermissionView: View { - var body: some View { - UnifiedPermissionView( - title: "Location", - description: "Sites usually use your location for relevant features or info, like local news or nearby shops", - permissionKind: .location, - allowedText: "Allowed to see your location", - blockedText: "Not allowed to see your location" - ) - } -} - private struct SiteRow: View { let entry: SitePermission let onRemove: () -> Void @@ -537,68 +228,6 @@ struct MicrophonePermissionView: View { } } -struct NotificationsPermissionView: View { - var body: some View { - UnifiedPermissionView( - title: "Notifications", - description: "Sites can ask to send you notifications for updates, messages, and alerts", - permissionKind: .notifications, - allowedText: "Allowed to send notifications", - blockedText: "Not allowed to send notifications" - ) - } -} - -struct EmbeddedContentPermissionView: View { - var body: some View { - UnifiedPermissionView( - title: "Embedded content", - description: "Sites can ask to use information they've saved about you", - permissionKind: .embeddedContent, - allowedText: "Allowed to use embedded content", - blockedText: "Not allowed to use embedded content" - ) - } -} - -// MARK: - Additional Permission Views - -struct BackgroundSyncPermissionView: View { - var body: some View { - UnifiedPermissionView( - title: "Background sync", - description: "Recently closed sites can finish sending and receiving data", - permissionKind: .backgroundSync, - allowedText: "Allowed to sync in background", - blockedText: "Not allowed to sync in background" - ) - } -} - -struct MotionSensorsPermissionView: View { - var body: some View { - UnifiedPermissionView( - title: "Motion sensors", - description: "Sites can use motion sensors", - permissionKind: .motionSensors, - allowedText: "Allowed to use motion sensors", - blockedText: "Not allowed to use motion sensors" - ) - } -} - -struct AutomaticDownloadsPermissionView: View { - var body: some View { - UnifiedPermissionView( - title: "Automatic downloads", - description: "Sites can ask to automatically download multiple files", - permissionKind: .automaticDownloads, - allowedText: "Allowed to download automatically", - blockedText: "Not allowed to download automatically" - ) - } -} - // MARK: - Inline Back Button private struct InlineBackButton: View { diff --git a/ora/Services/PermissionManager.swift b/ora/Services/PermissionManager.swift index 982a251d..9ef6ddff 100644 --- a/ora/Services/PermissionManager.swift +++ b/ora/Services/PermissionManager.swift @@ -98,57 +98,16 @@ class PermissionManager: NSObject, ObservableObject { } switch type { - case .location: return site.locationConfigured ? site.locationAllowed : getDefaultPermissionValue(for: type) - case .camera: return site.cameraConfigured ? site.cameraAllowed : getDefaultPermissionValue(for: type) - case .microphone: return site.microphoneConfigured ? site - .microphoneAllowed : getDefaultPermissionValue(for: type) - case .notifications: return site.notificationsConfigured ? site - .notificationsAllowed : getDefaultPermissionValue(for: type) - case .embeddedContent: return site.embeddedContentConfigured ? site - .embeddedContentAllowed : getDefaultPermissionValue(for: type) - case .backgroundSync: return site.backgroundSyncConfigured ? site - .backgroundSyncAllowed : getDefaultPermissionValue(for: type) - case .motionSensors: return site.motionSensorsConfigured ? site - .motionSensorsAllowed : getDefaultPermissionValue(for: type) - case .automaticDownloads: return site.automaticDownloadsConfigured ? site - .automaticDownloadsAllowed : getDefaultPermissionValue(for: type) - case .protocolHandlers: return site.protocolHandlersConfigured ? site - .protocolHandlersAllowed : getDefaultPermissionValue(for: type) - case .midiDevice: return site.midiDeviceConfigured ? site - .midiDeviceAllowed : getDefaultPermissionValue(for: type) - case .usbDevices: return site.usbDevicesConfigured ? site - .usbDevicesAllowed : getDefaultPermissionValue(for: type) - case .serialPorts: return site.serialPortsConfigured ? site - .serialPortsAllowed : getDefaultPermissionValue(for: type) - case .fileEditing: return site.fileEditingConfigured ? site - .fileEditingAllowed : getDefaultPermissionValue(for: type) - case .hidDevices: return site.hidDevicesConfigured ? site - .hidDevicesAllowed : getDefaultPermissionValue(for: type) - case .clipboard: return site.clipboardConfigured ? site.clipboardAllowed : getDefaultPermissionValue(for: type) - case .paymentHandlers: return site.paymentHandlersConfigured ? site - .paymentHandlersAllowed : getDefaultPermissionValue(for: type) - case .augmentedReality: return site.augmentedRealityConfigured ? site - .augmentedRealityAllowed : getDefaultPermissionValue(for: type) - case .virtualReality: return site.virtualRealityConfigured ? site - .virtualRealityAllowed : getDefaultPermissionValue(for: type) - case .deviceUse: return site.deviceUseConfigured ? site.deviceUseAllowed : getDefaultPermissionValue(for: type) - case .windowManagement: return site.windowManagementConfigured ? site - .windowManagementAllowed : getDefaultPermissionValue(for: type) - case .fonts: return site.fontsConfigured ? site.fontsAllowed : getDefaultPermissionValue(for: type) - case .automaticPictureInPicture: return site.automaticPictureInPictureConfigured ? site - .automaticPictureInPictureAllowed : getDefaultPermissionValue(for: type) - case .scrollingZoomingSharedTabs: return site.scrollingZoomingSharedTabsConfigured ? site - .scrollingZoomingSharedTabsAllowed : getDefaultPermissionValue(for: 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? { - switch type { - case .backgroundSync: - return true // Allow background sync by default - default: - return nil // Show dialog for all other permissions - } + // Always show dialog for camera and microphone permissions + return nil } } @@ -162,85 +121,22 @@ struct PermissionRequest { extension PermissionKind { var displayName: String { switch self { - case .location: return "Location" case .camera: return "Camera" case .microphone: return "Microphone" - case .notifications: return "Notifications" - case .embeddedContent: return "Embedded Content" - case .backgroundSync: return "Background Sync" - case .motionSensors: return "Motion Sensors" - case .automaticDownloads: return "Automatic Downloads" - case .protocolHandlers: return "Protocol Handlers" - case .midiDevice: return "MIDI Device Control" - case .usbDevices: return "USB Devices" - case .serialPorts: return "Serial Ports" - case .fileEditing: return "File Editing" - case .hidDevices: return "HID Devices" - case .clipboard: return "Clipboard" - case .paymentHandlers: return "Payment Handlers" - case .augmentedReality: return "Augmented Reality" - case .virtualReality: return "Virtual Reality" - case .deviceUse: return "Device Use Tracking" - case .windowManagement: return "Window Management" - case .fonts: return "Fonts" - case .automaticPictureInPicture: return "Automatic Picture-in-Picture" - case .scrollingZoomingSharedTabs: return "Scrolling and Zooming Shared Tabs" } } var description: String { switch self { - case .location: return "Access your location" case .camera: return "Use your camera" case .microphone: return "Use your microphone" - case .notifications: return "Send you notifications" - case .embeddedContent: return "Use embedded content" - case .backgroundSync: return "Sync data in the background" - case .motionSensors: return "Access motion sensors" - case .automaticDownloads: return "Automatically download files" - case .protocolHandlers: return "Handle custom protocols" - case .midiDevice: return "Control MIDI devices" - case .usbDevices: return "Connect to USB devices" - case .serialPorts: return "Connect to serial ports" - case .fileEditing: return "Edit files on your device" - case .hidDevices: return "Access HID devices" - case .clipboard: return "Access your clipboard" - case .paymentHandlers: return "Install payment handlers" - case .augmentedReality: return "Use augmented reality features" - case .virtualReality: return "Use virtual reality features" - case .deviceUse: return "Track when you're using your device" - case .windowManagement: return "Manage windows on your displays" - case .fonts: return "Access fonts on your device" - case .automaticPictureInPicture: return "Enter picture-in-picture automatically" - case .scrollingZoomingSharedTabs: return "Scroll and zoom shared tabs" } } var iconName: String { switch self { - case .location: return "location" case .camera: return "camera" case .microphone: return "mic" - case .notifications: return "bell" - case .embeddedContent: return "rectangle.on.rectangle" - case .backgroundSync: return "arrow.triangle.2.circlepath" - case .motionSensors: return "waveform.path.ecg" - case .automaticDownloads: return "arrow.down.circle" - case .protocolHandlers: return "link" - case .midiDevice: return "airpodspro" - case .usbDevices: return "externaldrive" - case .serialPorts: return "cable.connector.horizontal" - case .fileEditing: return "folder" - case .hidDevices: return "dot.radiowaves.left.and.right" - case .clipboard: return "clipboard" - case .paymentHandlers: return "creditcard" - case .augmentedReality: return "arkit" - case .virtualReality: return "visionpro" - case .deviceUse: return "cursorarrow.rays" - case .windowManagement: return "macwindow.on.rectangle" - case .fonts: return "textformat" - case .automaticPictureInPicture: return "pip" - case .scrollingZoomingSharedTabs: return "magnifyingglass" } } } diff --git a/ora/Services/PermissionSettingsStore.swift b/ora/Services/PermissionSettingsStore.swift index 6ef4b6fb..2c4a0482 100644 --- a/ora/Services/PermissionSettingsStore.swift +++ b/ora/Services/PermissionSettingsStore.swift @@ -2,12 +2,7 @@ import Foundation import SwiftData enum PermissionKind: CaseIterable { - case location, camera, microphone, notifications - case embeddedContent, backgroundSync, motionSensors, automaticDownloads - case protocolHandlers, midiDevice, usbDevices, serialPorts - case fileEditing, hidDevices, clipboard, paymentHandlers - case augmentedReality, virtualReality, deviceUse, windowManagement - case fonts, automaticPictureInPicture, scrollingZoomingSharedTabs + case camera, microphone } @MainActor @@ -39,31 +34,10 @@ final class PermissionSettingsStore: ObservableObject { private func filterSites(for kind: PermissionKind, allowed: Bool) -> [SitePermission] { switch kind { - case .location: return sitePermissions.filter { $0.locationAllowed == allowed } - case .camera: return sitePermissions.filter { $0.cameraAllowed == allowed } - case .microphone: return sitePermissions.filter { $0.microphoneAllowed == allowed } - case .notifications: return sitePermissions.filter { $0.notificationsAllowed == allowed } - case .embeddedContent: return sitePermissions.filter { $0.embeddedContentAllowed == allowed } - case .backgroundSync: return sitePermissions.filter { $0.backgroundSyncAllowed == allowed } - case .motionSensors: return sitePermissions.filter { $0.motionSensorsAllowed == allowed } - case .automaticDownloads: return sitePermissions.filter { $0.automaticDownloadsAllowed == allowed } - case .protocolHandlers: return sitePermissions.filter { $0.protocolHandlersAllowed == allowed } - case .midiDevice: return sitePermissions.filter { $0.midiDeviceAllowed == allowed } - case .usbDevices: return sitePermissions.filter { $0.usbDevicesAllowed == allowed } - case .serialPorts: return sitePermissions.filter { $0.serialPortsAllowed == allowed } - case .fileEditing: return sitePermissions.filter { $0.fileEditingAllowed == allowed } - case .hidDevices: return sitePermissions.filter { $0.hidDevicesAllowed == allowed } - case .clipboard: return sitePermissions.filter { $0.clipboardAllowed == allowed } - case .paymentHandlers: return sitePermissions.filter { $0.paymentHandlersAllowed == allowed } - case .augmentedReality: return sitePermissions.filter { $0.augmentedRealityAllowed == allowed } - case .virtualReality: return sitePermissions.filter { $0.virtualRealityAllowed == allowed } - case .deviceUse: return sitePermissions.filter { $0.deviceUseAllowed == allowed } - case .windowManagement: return sitePermissions.filter { $0.windowManagementAllowed == allowed } - case .fonts: return sitePermissions.filter { $0.fontsAllowed == allowed } - case .automaticPictureInPicture: return sitePermissions - .filter { $0.automaticPictureInPictureAllowed == allowed } - case .scrollingZoomingSharedTabs: return sitePermissions - .filter { $0.scrollingZoomingSharedTabsAllowed == allowed } + case .camera: + return sitePermissions.filter { $0.cameraAllowed == allowed } + case .microphone: + return sitePermissions.filter { $0.microphoneAllowed == allowed } } } @@ -92,75 +66,12 @@ final class PermissionSettingsStore: ObservableObject { } switch kind { - case .location: - entry?.locationAllowed = allow - entry?.locationConfigured = true case .camera: entry?.cameraAllowed = allow entry?.cameraConfigured = true case .microphone: entry?.microphoneAllowed = allow entry?.microphoneConfigured = true - case .notifications: - entry?.notificationsAllowed = allow - entry?.notificationsConfigured = true - case .embeddedContent: - entry?.embeddedContentAllowed = allow - entry?.embeddedContentConfigured = true - case .backgroundSync: - entry?.backgroundSyncAllowed = allow - entry?.backgroundSyncConfigured = true - case .motionSensors: - entry?.motionSensorsAllowed = allow - entry?.motionSensorsConfigured = true - case .automaticDownloads: - entry?.automaticDownloadsAllowed = allow - entry?.automaticDownloadsConfigured = true - case .protocolHandlers: - entry?.protocolHandlersAllowed = allow - entry?.protocolHandlersConfigured = true - case .midiDevice: - entry?.midiDeviceAllowed = allow - entry?.midiDeviceConfigured = true - case .usbDevices: - entry?.usbDevicesAllowed = allow - entry?.usbDevicesConfigured = true - case .serialPorts: - entry?.serialPortsAllowed = allow - entry?.serialPortsConfigured = true - case .fileEditing: - entry?.fileEditingAllowed = allow - entry?.fileEditingConfigured = true - case .hidDevices: - entry?.hidDevicesAllowed = allow - entry?.hidDevicesConfigured = true - case .clipboard: - entry?.clipboardAllowed = allow - entry?.clipboardConfigured = true - case .paymentHandlers: - entry?.paymentHandlersAllowed = allow - entry?.paymentHandlersConfigured = true - case .augmentedReality: - entry?.augmentedRealityAllowed = allow - entry?.augmentedRealityConfigured = true - case .virtualReality: - entry?.virtualRealityAllowed = allow - entry?.virtualRealityConfigured = true - case .deviceUse: - entry?.deviceUseAllowed = allow - entry?.deviceUseConfigured = true - case .windowManagement: - entry?.windowManagementAllowed = allow - entry?.windowManagementConfigured = true - case .fonts: - entry?.fontsAllowed = allow - entry?.fontsConfigured = true - case .automaticPictureInPicture: - entry?.automaticPictureInPictureAllowed = allow - entry?.automaticPictureInPictureConfigured = true - case .scrollingZoomingSharedTabs: - entry?.scrollingZoomingSharedTabsAllowed = allow - entry?.scrollingZoomingSharedTabsConfigured = true } saveContext() diff --git a/ora/UI/PermissionDialog.swift b/ora/UI/PermissionDialog.swift index 290b9529..c6c70bd5 100644 --- a/ora/UI/PermissionDialog.swift +++ b/ora/UI/PermissionDialog.swift @@ -101,20 +101,10 @@ struct PermissionDialog: View { private func getPermissionExplanation() -> String { switch request.permissionType { - case .location: - return "This allows the site to know your approximate location for location-based features." 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." - case .notifications: - return "This allows the site to send you notifications even when you're not actively using it." - case .backgroundSync: - return "This allows the site to sync data in the background for a better experience." - case .clipboard: - return "This allows the site to read from and write to your clipboard." - case .motionSensors: - return "This allows the site to access device motion and orientation sensors." default: return "This allows the site to use \(request.permissionType.displayName.lowercased()) functionality." } diff --git a/ora/UI/URLBar.swift b/ora/UI/URLBar.swift index 69f17801..9b595205 100644 --- a/ora/UI/URLBar.swift +++ b/ora/UI/URLBar.swift @@ -10,10 +10,8 @@ struct ExtensionsPopupView: View { @Environment(\.modelContext) private var modelContext @EnvironmentObject var tabManager: TabManager - @State private var locationPermission: PermissionState = .ask @State private var cameraPermission: PermissionState = .ask @State private var microphonePermission: PermissionState = .ask - @State private var notificationsPermission: PermissionState = .ask enum PermissionState: String, CaseIterable { case ask = "Ask" @@ -24,7 +22,7 @@ struct ExtensionsPopupView: View { switch self { case .ask: return .allow case .allow: return .block - case .block: return .allow + case .block: return .ask } } } @@ -60,75 +58,15 @@ struct ExtensionsPopupView: View { // Update the specific permission switch kind { - case .location: - site.locationAllowed = allow - site.locationConfigured = true case .camera: site.cameraAllowed = allow site.cameraConfigured = true case .microphone: site.microphoneAllowed = allow site.microphoneConfigured = true - case .notifications: - site.notificationsAllowed = allow - site.notificationsConfigured = true - case .embeddedContent: - site.embeddedContentAllowed = allow - site.embeddedContentConfigured = true - case .backgroundSync: - site.backgroundSyncAllowed = allow - site.backgroundSyncConfigured = true - case .motionSensors: - site.motionSensorsAllowed = allow - site.motionSensorsConfigured = true - case .automaticDownloads: - site.automaticDownloadsAllowed = allow - site.automaticDownloadsConfigured = true - case .protocolHandlers: - site.protocolHandlersAllowed = allow - site.protocolHandlersConfigured = true - case .midiDevice: - site.midiDeviceAllowed = allow - site.midiDeviceConfigured = true - case .usbDevices: - site.usbDevicesAllowed = allow - site.usbDevicesConfigured = true - case .serialPorts: - site.serialPortsAllowed = allow - site.serialPortsConfigured = true - case .fileEditing: - site.fileEditingAllowed = allow - site.fileEditingConfigured = true - case .hidDevices: - site.hidDevicesAllowed = allow - site.hidDevicesConfigured = true - case .clipboard: - site.clipboardAllowed = allow - site.clipboardConfigured = true - case .paymentHandlers: - site.paymentHandlersAllowed = allow - site.paymentHandlersConfigured = true - case .augmentedReality: - site.augmentedRealityAllowed = allow - site.augmentedRealityConfigured = true - case .virtualReality: - site.virtualRealityAllowed = allow - site.virtualRealityConfigured = true - case .deviceUse: - site.deviceUseAllowed = allow - site.deviceUseConfigured = true - case .windowManagement: - site.windowManagementAllowed = allow - site.windowManagementConfigured = true - case .fonts: - site.fontsAllowed = allow - site.fontsConfigured = true - case .automaticPictureInPicture: - site.automaticPictureInPictureAllowed = allow - site.automaticPictureInPictureConfigured = true - case .scrollingZoomingSharedTabs: - site.scrollingZoomingSharedTabsAllowed = allow - site.scrollingZoomingSharedTabsConfigured = true + default: + // All other permission types are not handled + break } try? modelContext.save() @@ -144,27 +82,19 @@ struct ExtensionsPopupView: View { ) if let site = try? modelContext.fetch(descriptor).first { - locationPermission = site.locationConfigured ? (site.locationAllowed ? .allow : .block) : .ask cameraPermission = site.cameraConfigured ? (site.cameraAllowed ? .allow : .block) : .ask microphonePermission = site.microphoneConfigured ? (site.microphoneAllowed ? .allow : .block) : .ask - notificationsPermission = site - .notificationsConfigured ? (site.notificationsAllowed ? .allow : .block) : .ask } } var body: some View { VStack(alignment: .leading, spacing: 16) { - // Settings section + // Media Permissions VStack(alignment: .leading, spacing: 8) { - Text("Settings") + Text("Media Permissions") .font(.headline) .foregroundColor(.primary) - Button(action: { togglePermission(.location, currentState: $locationPermission) }) { - PopupPermissionRow(icon: "location", title: "Location", status: locationPermission.rawValue) - } - .buttonStyle(.plain) - Button(action: { togglePermission(.camera, currentState: $cameraPermission) }) { PopupPermissionRow(icon: "camera", title: "Camera", status: cameraPermission.rawValue) } @@ -175,11 +105,6 @@ struct ExtensionsPopupView: View { } .buttonStyle(.plain) - Button(action: { togglePermission(.notifications, currentState: $notificationsPermission) }) { - PopupPermissionRow(icon: "bell", title: "Notifications", status: notificationsPermission.rawValue) - } - .buttonStyle(.plain) - SettingsLink { HStack(spacing: 12) { Image(systemName: "gear") From 4364098350de43b9bd7f27dc84f5601e2db3ce87 Mon Sep 17 00:00:00 2001 From: brooksolomon Date: Tue, 16 Sep 2025 12:06:04 +0300 Subject: [PATCH 17/26] added detection and refresh when the menu changes --- ora/UI/URLBar.swift | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/ora/UI/URLBar.swift b/ora/UI/URLBar.swift index 9b595205..8556e4d7 100644 --- a/ora/UI/URLBar.swift +++ b/ora/UI/URLBar.swift @@ -1,6 +1,7 @@ import AppKit import SwiftData import SwiftUI +import WebKit // MARK: - Extensions Popup View @@ -22,7 +23,7 @@ struct ExtensionsPopupView: View { switch self { case .ask: return .allow case .allow: return .block - case .block: return .ask + case .block: return .allow // Don't go back to .ask } } } @@ -34,9 +35,39 @@ struct ExtensionsPopupView: View { 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: newState == .allow) + 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) { From e16c5898d1803a509629d6905dfe34c20c9e7de0 Mon Sep 17 00:00:00 2001 From: Kenenisa Alemayehu <53818162+kenenisa@users.noreply.github.com> Date: Tue, 16 Sep 2025 20:23:35 +0300 Subject: [PATCH 18/26] refactor: clean up Info.plist formatting and update Tab and URLBar implementations --- ora/Info.plist | 52 +++++++++++++++++++------------------------- ora/Models/Tab.swift | 6 ++--- ora/UI/URLBar.swift | 4 ++-- 3 files changed, 27 insertions(+), 35 deletions(-) diff --git a/ora/Info.plist b/ora/Info.plist index 905c6ed1..a0ce0850 100644 --- a/ora/Info.plist +++ b/ora/Info.plist @@ -2,35 +2,27 @@ - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - APPL - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - - - NSCameraUsageDescription - This browser needs camera access for video calls and websites that use the camera. - NSMicrophoneUsageDescription - This browser needs microphone access for voice calls and websites that use the microphone. - - - SUEnableAutomaticChecks - - SUFeedURL - https://the-ora.github.io/browser/appcast.xml - SUPublicEDKey - Ozj+rezzbJAD76RfajtfQ7rFojJbpFSCl/0DcFSBCTI= + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + SUEnableAutomaticChecks + + SUFeedURL + https://the-ora.github.io/browser/appcast.xml + SUPublicEDKey + Ozj+rezzbJAD76RfajtfQ7rFojJbpFSCl/0DcFSBCTI= diff --git a/ora/Models/Tab.swift b/ora/Models/Tab.swift index 9bc5b173..342eceac 100644 --- a/ora/Models/Tab.swift +++ b/ora/Models/Tab.swift @@ -119,7 +119,7 @@ class Tab: ObservableObject, Identifiable { // Load initial URL DispatchQueue.main.async { self.setupNavigationDelegate() - self.setupUIDelegate() +// self.setupUIDelegate() self.syncBackgroundColorFromHex() self.webView.load(URLRequest(url: url)) self.isWebViewReady = true @@ -294,7 +294,7 @@ class Tab: ObservableObject, Identifiable { self.isWebViewReady = false self.setupNavigationDelegate() print("🎥 setupUIDelegate") - self.setupUIDelegate() +// self.setupUIDelegate() self.syncBackgroundColorFromHex() // Load after a short delay to ensure layout DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { @@ -362,7 +362,7 @@ class Tab: ObservableObject, Identifiable { let config = TabScriptHandler() config.tab = self - webView = WKWebView(frame: .zero, configuration: config.defaultWKConfig()) + webView = WKWebView(frame: .zero, configuration: config.customWKConfig(containerId: self.container.id)) } func setNavigationError(_ error: Error, for url: URL?) { diff --git a/ora/UI/URLBar.swift b/ora/UI/URLBar.swift index 18ac9c77..5e467483 100644 --- a/ora/UI/URLBar.swift +++ b/ora/UI/URLBar.swift @@ -527,7 +527,7 @@ struct URLBar: View { } ) - NavigationButton( + URLBarButton( systemName: "ellipsis", isEnabled: true, foregroundColor: buttonForegroundColor, @@ -539,7 +539,7 @@ struct URLBar: View { ExtensionsPopupView() } } - } + .padding(4) .onAppear { editingURLString = getDisplayURL(tab) From 7a052933441a5a9ee94512cefa28965739a6f822 Mon Sep 17 00:00:00 2001 From: brooksolomon Date: Thu, 18 Sep 2025 20:04:11 +0300 Subject: [PATCH 19/26] feat : added permissions to project.yml --- ora/Info.plist | 4 ++++ ora/Services/FaviconService.swift | 2 +- ora/UI/URLBar.swift | 22 +++++++++++----------- ora/oraApp.swift | 1 - project.yml | 5 ++++- 5 files changed, 20 insertions(+), 14 deletions(-) diff --git a/ora/Info.plist b/ora/Info.plist index a0ce0850..119d6553 100644 --- a/ora/Info.plist +++ b/ora/Info.plist @@ -18,6 +18,10 @@ 1.0 CFBundleVersion 1 + NSCameraUsageDescription + Ora requires access to your camera to support video features. + NSMicrophoneUsageDescription + Ora requires access to your microphone to support audio features. SUEnableAutomaticChecks SUFeedURL diff --git a/ora/Services/FaviconService.swift b/ora/Services/FaviconService.swift index 6ca8eeb9..e866896a 100644 --- a/ora/Services/FaviconService.swift +++ b/ora/Services/FaviconService.swift @@ -65,7 +65,7 @@ class FaviconService: ObservableObject { private func fetchFavicon(for domain: String, completion: @escaping (NSImage?) -> Void) { let faviconURLs = [ - "https://www.google.com/s2/favicons?domain=\(domain)&sz=64", + "https://www.google.com/s2/favicons?domain=\(domain)&sz=64", "https://\(domain)/favicon.ico", "https://\(domain)/apple-touch-icon.png" ] diff --git a/ora/UI/URLBar.swift b/ora/UI/URLBar.swift index 5e467483..30d1c230 100644 --- a/ora/UI/URLBar.swift +++ b/ora/UI/URLBar.swift @@ -527,19 +527,19 @@ struct URLBar: View { } ) - URLBarButton( - systemName: "ellipsis", - isEnabled: true, - foregroundColor: buttonForegroundColor, - action: { - showExtensionsPopup.toggle() - } - ) - .popover(isPresented: $showExtensionsPopup, arrowEdge: .bottom) { - ExtensionsPopupView() + URLBarButton( + systemName: "ellipsis", + isEnabled: true, + foregroundColor: buttonForegroundColor, + action: { + showExtensionsPopup.toggle() } + ) + .popover(isPresented: $showExtensionsPopup, arrowEdge: .bottom) { + ExtensionsPopupView() } - + } + .padding(4) .onAppear { editingURLString = getDisplayURL(tab) diff --git a/ora/oraApp.swift b/ora/oraApp.swift index e42ff6b8..adf6fbd8 100644 --- a/ora/oraApp.swift +++ b/ora/oraApp.swift @@ -36,7 +36,6 @@ class AppState: ObservableObject { @main struct OraApp: App { - @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate var body: some Scene { WindowGroup(id: "normal") { diff --git a/project.yml b/project.yml index 120e73a5..886494a5 100644 --- a/project.yml +++ b/project.yml @@ -49,7 +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." + entitlements: path: ora/ora.entitlements properties: @@ -77,6 +79,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 From b0ac9aaa59763cd42caab8d3ccd7e08168e14818 Mon Sep 17 00:00:00 2001 From: brooksolomon Date: Thu, 18 Sep 2025 20:36:34 +0300 Subject: [PATCH 20/26] feat : added site permissions to ORa.root --- ora/Models/Tab.swift | 2 +- ora/OraRoot.swift | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/ora/Models/Tab.swift b/ora/Models/Tab.swift index 342eceac..948ed8f4 100644 --- a/ora/Models/Tab.swift +++ b/ora/Models/Tab.swift @@ -294,7 +294,7 @@ class Tab: ObservableObject, Identifiable { self.isWebViewReady = false self.setupNavigationDelegate() print("🎥 setupUIDelegate") -// self.setupUIDelegate() + self.setupUIDelegate() self.syncBackgroundColorFromHex() // Load after a short delay to ensure layout DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { diff --git a/ora/OraRoot.swift b/ora/OraRoot.swift index 4f98ded9..3d779414 100644 --- a/ora/OraRoot.swift +++ b/ora/OraRoot.swift @@ -30,7 +30,7 @@ struct OraRoot: View { _privacyMode = StateObject(wrappedValue: PrivacyMode(isPrivate: isPrivate)) let modelConfiguration = isPrivate ? ModelConfiguration(isStoredInMemoryOnly: true) : 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: "OraData.sqlite") ) @@ -38,10 +38,13 @@ struct OraRoot: View { let modelContext: ModelContext do { container = try ModelContainer( - for: TabContainer.self, History.self, Download.self, + for: TabContainer.self, History.self, Download.self, SitePermission.self, configurations: modelConfiguration ) 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)") From 7e3c4d4ff4a4cdc3fba1e52b0769055b214b5600 Mon Sep 17 00:00:00 2001 From: brooksolomon Date: Tue, 14 Oct 2025 18:41:25 +0300 Subject: [PATCH 21/26] feat : removed duplicate webview handlers --- ora/UI/WebView.swift | 83 -------------------------------------------- 1 file changed, 83 deletions(-) diff --git a/ora/UI/WebView.swift b/ora/UI/WebView.swift index edac2053..4dae3799 100644 --- a/ora/UI/WebView.swift +++ b/ora/UI/WebView.swift @@ -161,58 +161,6 @@ struct WebView: NSViewRepresentable { return webView.bounds.contains(locationInWebView) } - // func webView( - // _ webView: WKWebView, - // requestMediaCapturePermissionFor origin: WKSecurityOrigin, - // initiatedByFrame frame: WKFrameInfo, - // decisionHandler: @escaping (WKPermissionDecision) -> Void - // ) { - // let host = origin.host - // print("🎥 WebKit requesting media capture for: \(host)") - - // // Check if we already have permissions configured for this host - // let cameraPermission = PermissionManager.shared.getExistingPermission(for: host, type: .camera) - // let microphonePermission = PermissionManager.shared.getExistingPermission(for: host, type: .microphone) - - // print("🎥 Existing permissions - Camera: \(String(describing: cameraPermission)), Microphone: - // \(String(describing: microphonePermission))") - - // // If both permissions are already configured, use them - // if let cameraAllowed = cameraPermission, let microphoneAllowed = microphonePermission { - // let shouldGrant = cameraAllowed || microphoneAllowed - // print("🎥 Using existing permissions, granting: \(shouldGrant)") - // decisionHandler(shouldGrant ? .grant : .deny) - // return - // } - - // print("🎥 Requesting new permissions...") - - // // If permissions aren't configured, we need to request them - // // Since WebKit doesn't specify which media type, we'll request both - // Task { @MainActor in - // // First request camera permission - // PermissionManager.shared.requestPermission( - // for: .camera, - // from: host, - // webView: webView - // ) { cameraAllowed in - // print("🎥 Camera permission result: \(cameraAllowed)") - // // Then request microphone permission - // PermissionManager.shared.requestPermission( - // for: .microphone, - // from: host, - // webView: webView - // ) { microphoneAllowed in - // print("🎥 Microphone permission result: \(microphoneAllowed)") - // // Grant if either permission is allowed - // let shouldGrant = cameraAllowed || microphoneAllowed - // print("🎥 Final decision: \(shouldGrant)") - // decisionHandler(shouldGrant ? .grant : .deny) - // } - // } - // } - // } - func webView( _ webView: WKWebView, runOpenPanelWith parameters: WKOpenPanelParameters, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping ([URL]?) -> Void @@ -296,37 +244,6 @@ struct WebView: NSViewRepresentable { return nil } - func webView( - _ webView: WKWebView, - runJavaScriptAlertPanelWithMessage message: String, - initiatedByFrame frame: WKFrameInfo, - completionHandler: @escaping () -> Void - ) { - let alert = NSAlert() - alert.messageText = "Alert" - alert.informativeText = message - alert.alertStyle = .informational - alert.addButton(withTitle: "OK") - alert.runModal() - completionHandler() - } - - func webView( - _ webView: WKWebView, - runJavaScriptConfirmPanelWithMessage message: String, - initiatedByFrame frame: WKFrameInfo, - completionHandler: @escaping (Bool) -> Void - ) { - 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) - } - func webView( _ webView: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String, From 66b99f2e03c5fadf4da4d4c69fdcad43b03c1419 Mon Sep 17 00:00:00 2001 From: brooksolomon Date: Tue, 14 Oct 2025 19:15:45 +0300 Subject: [PATCH 22/26] fix : Resolved code lost in merge conflict --- ora/Common/Extensions/ModelConfiguration+Shared.swift | 4 ++-- ora/Modules/Browser/BrowserView.swift | 3 ++- ora/UI/URLBar.swift | 5 +++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/ora/Common/Extensions/ModelConfiguration+Shared.swift b/ora/Common/Extensions/ModelConfiguration+Shared.swift index 02ca7bf0..bcacc394 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/Modules/Browser/BrowserView.swift b/ora/Modules/Browser/BrowserView.swift index 4fb1d2b9..de76c8bc 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/UI/URLBar.swift b/ora/UI/URLBar.swift index bd4fb050..ba634479 100644 --- a/ora/UI/URLBar.swift +++ b/ora/UI/URLBar.swift @@ -546,8 +546,9 @@ struct URLBar: View { action: { showExtensionsPopup.toggle() } - ) - + ).popover(isPresented: $showExtensionsPopup, arrowEdge: .bottom) { + ExtensionsPopupView() + } if sidebarManager.sidebarPosition == .secondary { URLBarButton( systemName: "sidebar.right", From 948c47123ea35f29fb585b651fbce81a57f044e8 Mon Sep 17 00:00:00 2001 From: brooksolomon Date: Tue, 14 Oct 2025 21:13:47 +0300 Subject: [PATCH 23/26] fix : settings remove button linked with permission store --- .../Settings/Sections/SiteSettingsView.swift | 13 ++++++++- ora/Services/PermissionSettingsStore.swift | 27 +++++++++++++------ 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/ora/Modules/Settings/Sections/SiteSettingsView.swift b/ora/Modules/Settings/Sections/SiteSettingsView.swift index 3b1e04ab..0ca9df64 100644 --- a/ora/Modules/Settings/Sections/SiteSettingsView.swift +++ b/ora/Modules/Settings/Sections/SiteSettingsView.swift @@ -77,6 +77,14 @@ struct UnifiedPermissionView: View { @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 [] } @@ -172,7 +180,10 @@ struct UnifiedPermissionView: View { } private func removeSite(_ site: SitePermission) { - modelContext.delete(site) + // 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() } } diff --git a/ora/Services/PermissionSettingsStore.swift b/ora/Services/PermissionSettingsStore.swift index 2c4a0482..fc626767 100644 --- a/ora/Services/PermissionSettingsStore.swift +++ b/ora/Services/PermissionSettingsStore.swift @@ -80,14 +80,25 @@ final class PermissionSettingsStore: ObservableObject { } func removeSite(host: String) { - guard let idx = sitePermissions.firstIndex(where: { - $0.host.caseInsensitiveCompare(host) == .orderedSame - }) else { return } - - let entry = sitePermissions.remove(at: idx) - context.delete(entry) - saveContext() - objectWillChange.send() + 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 From 8965db70745a72706bb1e5ca6aed74935a3092d4 Mon Sep 17 00:00:00 2001 From: brooksolomon Date: Tue, 14 Oct 2025 21:24:45 +0300 Subject: [PATCH 24/26] fix : Separated extension logic to its own file --- ora/UI/URLBar.swift | 266 ---------------------------------- ora/UI/URLBarExtension.swift | 270 +++++++++++++++++++++++++++++++++++ 2 files changed, 270 insertions(+), 266 deletions(-) create mode 100644 ora/UI/URLBarExtension.swift diff --git a/ora/UI/URLBar.swift b/ora/UI/URLBar.swift index ba634479..bdb96052 100644 --- a/ora/UI/URLBar.swift +++ b/ora/UI/URLBar.swift @@ -3,243 +3,6 @@ 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 BoostRow: View { let icon: String let title: String @@ -269,35 +32,6 @@ struct BoostRow: View { } } -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) - } -} - // MARK: - URLBar struct URLBar: View { diff --git a/ora/UI/URLBarExtension.swift b/ora/UI/URLBarExtension.swift new file mode 100644 index 00000000..629dbdbd --- /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) + } +} From 9d3037d5ff0a6ef4591419194552251bcfe957c4 Mon Sep 17 00:00:00 2001 From: brooksolomon Date: Tue, 14 Oct 2025 22:54:01 +0300 Subject: [PATCH 25/26] fix : added blank tab opener to ui deligate handlers --- ora/Models/Tab.swift | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/ora/Models/Tab.swift b/ora/Models/Tab.swift index 653ff826..4fdf1056 100644 --- a/ora/Models/Tab.swift +++ b/ora/Models/Tab.swift @@ -415,6 +415,32 @@ class TabUIDelegate: NSObject, WKUIDelegate { // TabUIDelegate initialized } + // Handle new window requests (target="_blank" links) + func webView( + _ webView: WKWebView, + createWebViewWith configuration: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, + windowFeatures: WKWindowFeatures + ) -> WKWebView? { + guard let url = navigationAction.request.url, + let tabManager = self.tab?.tabManager, + let historyManager = self.tab?.historyManager + else { + return nil + } + + // Create a new tab in the background + tabManager.openTab( + url: url, + historyManager: historyManager, + downloadManager: self.tab?.downloadManager, + isPrivate: self.tab?.isPrivate ?? false + ) + + // Return nil to prevent creating a new WebView instance + return nil + } + func webView( _ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin, From abb0347228eba89ec019c3a084006645bf41ff08 Mon Sep 17 00:00:00 2001 From: brooksolomon Date: Tue, 14 Oct 2025 23:11:46 +0300 Subject: [PATCH 26/26] fix : moved all the UI delegate code to the coordinator --- ora/Models/Tab.swift | 173 ------------------------------------------- ora/UI/WebView.swift | 138 +++++++++++++++++++++++++++++++++- 2 files changed, 136 insertions(+), 175 deletions(-) diff --git a/ora/Models/Tab.swift b/ora/Models/Tab.swift index 4fdf1056..16a497b1 100644 --- a/ora/Models/Tab.swift +++ b/ora/Models/Tab.swift @@ -45,7 +45,6 @@ class Tab: ObservableObject, Identifiable { // Not persisted: in-memory only @Transient var webView: WKWebView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration()) @Transient var navigationDelegate: WebViewNavigationDelegate? - @Transient var uiDelegate: TabUIDelegate? @Transient @Published var isWebViewReady: Bool = false @Transient @Published var loadingProgress: Double = 10.0 @Transient var colorUpdated = false @@ -252,13 +251,6 @@ class Tab: ObservableObject, Identifiable { webView.navigationDelegate = delegate } - private func setupUIDelegate() { - print("🎥 setupUIDelegate") - let delegate = TabUIDelegate(tab: self) - self.uiDelegate = delegate - webView.uiDelegate = delegate - } - func goForward() { lastAccessedAt = Date() self.webView.goForward() @@ -306,8 +298,6 @@ class Tab: ObservableObject, Identifiable { self.tabManager = tabManager self.isWebViewReady = false self.setupNavigationDelegate() - print("🎥 setupUIDelegate") - self.setupUIDelegate() self.syncBackgroundColorFromHex() // Load after a short delay to ensure layout DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { @@ -404,169 +394,6 @@ class Tab: ObservableObject, Identifiable { } } -// MARK: - TabUIDelegate - -class TabUIDelegate: NSObject, WKUIDelegate { - weak var tab: Tab? - - init(tab: Tab) { - self.tab = tab - super.init() - // TabUIDelegate initialized - } - - // Handle new window requests (target="_blank" links) - func webView( - _ webView: WKWebView, - createWebViewWith configuration: WKWebViewConfiguration, - for navigationAction: WKNavigationAction, - windowFeatures: WKWindowFeatures - ) -> WKWebView? { - guard let url = navigationAction.request.url, - let tabManager = self.tab?.tabManager, - let historyManager = self.tab?.historyManager - else { - return nil - } - - // Create a new tab in the background - tabManager.openTab( - url: url, - historyManager: historyManager, - downloadManager: self.tab?.downloadManager, - isPrivate: self.tab?.isPrivate ?? false - ) - - // Return nil to prevent creating a new WebView instance - return nil - } - - func webView( - _ webView: WKWebView, - requestMediaCapturePermissionFor origin: WKSecurityOrigin, - initiatedByFrame frame: WKFrameInfo, - type: WKMediaCaptureType, - decisionHandler: @escaping (WKPermissionDecision) -> Void - ) { - let host = origin.host - print("🎥 TabUIDelegate: Requesting \(type) for \(host)") - // Handle media capture permission request - - // 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)") - // Handle combined camera and microphone permission request - - // 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) - } - } - } - } - } - } -} - extension FileManager { var faviconDirectory: URL { let dir = urls(for: .cachesDirectory, in: .userDomainMask).first! diff --git a/ora/UI/WebView.swift b/ora/UI/WebView.swift index 4dae3799..a232aa22 100644 --- a/ora/UI/WebView.swift +++ b/ora/UI/WebView.swift @@ -19,8 +19,11 @@ struct WebView: NSViewRepresentable { } func makeNSView(context: Context) -> WKWebView { - // Don't override uiDelegate - let Tab handle it - // webView.uiDelegate = context.coordinator + // 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 @@ -53,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?,